agentp 2.1.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -6
- package/bin/ocmux +162 -33
- package/docs/specification_v2.md +29 -4
- package/lib/ocmux.js +10 -2
- package/lib/tui-registry.js +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -352,11 +352,22 @@ the project found upward from `<directory>` (default: `$PWD`):
|
|
|
352
352
|
and switches normally; `ESC`/`q` cancels back to the session list and returns
|
|
353
353
|
the TUI to the stored session; deselecting down to a single session selects
|
|
354
354
|
that remaining session. New selections open in the TUI for inspection.
|
|
355
|
-
Broadcast mode also has `h` help, `d` delete,
|
|
356
|
-
|
|
355
|
+
Broadcast mode also has `h` help, `d` delete, `D` (Shift+d) to delete every
|
|
356
|
+
selected session at once (confirmation in the status bar), and `m` to change
|
|
357
|
+
the model for every selected session. The `Broadcast to sessions: …` line
|
|
358
|
+
caps itself to one row — up to 480 characters of names (or the terminal
|
|
359
|
+
width, whichever is smaller); past that the selection is truncated from the
|
|
360
|
+
beginning with a leading `...`, so the footer never wraps.
|
|
357
361
|
- `n` create (name input) · `r` rename (edit in place) · `R` set a reminder ·
|
|
358
362
|
`d` delete (confirm) · `a` switch agent · `m` switch model · `p` project
|
|
359
|
-
switcher (inspect-only by default) · `h` help · `q` quit
|
|
363
|
+
switcher (inspect-only by default) · `h` help · `q` quit. The `n`/`r`/`R`
|
|
364
|
+
prompts and the `d` confirmation take over the **status bar**, with their
|
|
365
|
+
essential keys on the right (`Enter: create/rename/save · Esc: cancel`,
|
|
366
|
+
`y: delete · n/Esc: cancel`). While a question is active the bar flips from
|
|
367
|
+
brown to the same **light yellow as the list pointer**, so the change of
|
|
368
|
+
state is obvious at a glance. Deleting the current session adopts the session
|
|
369
|
+
under the cursor as the new current (recorded, refreshed in the TUI, and
|
|
370
|
+
highlighted)
|
|
360
371
|
- `m` opens the model list **sorted by provider** with the cursor already on the
|
|
361
372
|
session's **current model** — also in broadcast mode, where it starts from the
|
|
362
373
|
cursor session's model, so a bulk change begins where you are
|
|
@@ -366,7 +377,14 @@ the project found upward from `<directory>` (default: `$PWD`):
|
|
|
366
377
|
- switching updates `.ocmux.json` and refreshes the project's dedicated TUI, or
|
|
367
378
|
the shared TUI when no live dedicated one exists; silent on success. The file
|
|
368
379
|
updated is always the one of the project being listed — your own project
|
|
369
|
-
unless ocmux was started with `--all-projects
|
|
380
|
+
unless ocmux was started with `--all-projects`. Opening the picker in a
|
|
381
|
+
project also routes that project's TUI to its stored session (a TUI already
|
|
382
|
+
showing it is left untouched), so running `ocmux` from anywhere switches the
|
|
383
|
+
display even without picking a row
|
|
384
|
+
- when `.ocmux.json`'s server is unreachable but the default server
|
|
385
|
+
(`$OCMUX_SERVER` or `http://localhost:4096`) answers, `ocmux` offers (on a TTY)
|
|
386
|
+
to repoint the project to the default and rewrites `server` in `.ocmux.json`
|
|
387
|
+
if you confirm
|
|
370
388
|
- new sessions inherit the model of the previously selected session (v2
|
|
371
389
|
sessions created via the API have no model and won't run a prompt until set)
|
|
372
390
|
|
|
@@ -387,8 +405,10 @@ Subcommands:
|
|
|
387
405
|
- **`tui [--shared] [--server <url>] [dir]`** — register the current tmux pane and run OpenCode
|
|
388
406
|
in it. Without `--shared`, the pane follows only that project. With
|
|
389
407
|
`--shared`, it becomes the one fallback TUI for all projects and can reconnect
|
|
390
|
-
across server URLs as selections change
|
|
391
|
-
|
|
408
|
+
across server URLs as selections change; `--shared` also starts without a
|
|
409
|
+
`.ocmux.json`, defaulting to the default server. Re-registering a slot stops
|
|
410
|
+
its old wrapper without destroying the old pane. Closing the TUI unregisters
|
|
411
|
+
it.
|
|
392
412
|
`tui --list` lists live registrations; `tui [--shared] --status` inspects a
|
|
393
413
|
dedicated/shared slot; `tui [--shared] --detach` unregisters that slot without
|
|
394
414
|
closing the pane or its current TUI.
|
package/bin/ocmux
CHANGED
|
@@ -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,7 @@ 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
|
|
198
216
|
|
|
199
217
|
// ── '/' incremental search for interactive lists ──────────────────────────
|
|
200
218
|
// Filter `items` by `pattern`: case-insensitive, space-separated tokens that
|
|
@@ -453,9 +471,11 @@ function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false)
|
|
|
453
471
|
// footer -> status bar (brown background), key hints right-aligned
|
|
454
472
|
// (left-aligned when footerLeft is set — used by the search line)
|
|
455
473
|
// footerRight-> optional hint pinned to the right end of a left-aligned footer
|
|
474
|
+
// footerBg -> background for the footer bar (default: BAR_BG; question bars
|
|
475
|
+
// pass QUESTION_BG so an active prompt stands out)
|
|
456
476
|
// infoLines -> details below the status bar (colours supplied by the caller)
|
|
457
477
|
// 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 }) {
|
|
478
|
+
function renderList({ title, items, cursor, row, footer, infoLines = [], extraLines = [], cols: colsArg, rows: rowsArg, footerLeft = false, footerRight = null, footerBg = BAR_BG }) {
|
|
459
479
|
const cols = colsArg || process.stdout.columns || 80;
|
|
460
480
|
const termRows = rowsArg || process.stdout.rows || 24;
|
|
461
481
|
const extras = (extraLines || []).filter((l) => l != null && l !== '');
|
|
@@ -512,7 +532,7 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
|
|
|
512
532
|
} else {
|
|
513
533
|
hint = padLeft(footer, cols);
|
|
514
534
|
}
|
|
515
|
-
out +=
|
|
535
|
+
out += footerBg + hint + '\x1b[0m\n';
|
|
516
536
|
for (const l of infos) out += l + '\n';
|
|
517
537
|
return out.replace(/\n$/, '');
|
|
518
538
|
}
|
|
@@ -534,6 +554,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
534
554
|
let cursor = 0;
|
|
535
555
|
let mode = 'normal'; // 'normal' | 'help' | 'confirm' | 'input'
|
|
536
556
|
let confirmDel = null;
|
|
557
|
+
let confirmAll = false; // 'D' deletes every broadcast-selected session
|
|
537
558
|
let input = null; // { label, value, kind, caret }
|
|
538
559
|
let list = null; // { title, items, format, onEnter }
|
|
539
560
|
let listCursor = 0;
|
|
@@ -557,6 +578,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
557
578
|
'R (Shift+r) set a REMINDER for the selected session (sent in front of',
|
|
558
579
|
' every agentp prompt; blank clears)',
|
|
559
580
|
'd delete the selected session (then y/N to confirm)',
|
|
581
|
+
'D (Shift+d) in BROADCAST: delete ALL selected sessions (then y/N)',
|
|
560
582
|
'a switch the AGENT of the selected session',
|
|
561
583
|
'm switch the MODEL of the selected session',
|
|
562
584
|
'p open the project switcher',
|
|
@@ -605,6 +627,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
605
627
|
out += ' Enter keep the broadcast selection; switch TUI to cursor\n';
|
|
606
628
|
out += ' m switch the model for ALL selected sessions\n';
|
|
607
629
|
out += ' d delete the cursor session (with confirmation)\n';
|
|
630
|
+
out += ' D (Shift+d) delete ALL selected sessions (with confirmation)\n';
|
|
608
631
|
out += ' j / k, arrows move the cursor (selecting shows it in the TUI)\n';
|
|
609
632
|
out += ' / search/filter the session list\n';
|
|
610
633
|
out += ' ESC / q cancel broadcast mode (back to the session list)\n';
|
|
@@ -648,24 +671,55 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
648
671
|
);
|
|
649
672
|
const inBc = mode === 'broadcast' || mode === 'broadcast-confirm';
|
|
650
673
|
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
674
|
if (search.active && view.length === 0) extras.push(`No sessions match "${search.value}"`);
|
|
657
675
|
const selected = view[cursor];
|
|
658
676
|
let infoLines = [];
|
|
659
677
|
let footer = 'Enter: switch · Space: broadcast · /: search · h: help · q: quit';
|
|
678
|
+
let footerLeft = search.active;
|
|
679
|
+
let footerRight = search.active ? SEARCH_HINT : null;
|
|
660
680
|
if (inBc) {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
681
|
+
// List every selected session, capped so the info line never wraps. On
|
|
682
|
+
// overflow the head is trimmed and marked with '...' — the picker lists
|
|
683
|
+
// most-recently-viewed first, so the trimmed head is the most recently
|
|
684
|
+
// viewed part of the selection.
|
|
685
|
+
let names = rows.filter(r => broadcastSel.has(String(r.id))).map(r => r.title || r.id).join(', ');
|
|
686
|
+
const bcHead = 'Broadcast to sessions: ';
|
|
687
|
+
const budget = Math.max(8, Math.min(480, cols - bcHead.length));
|
|
688
|
+
if (names.length > budget) {
|
|
689
|
+
let keep = names.slice(-(budget - 4)); // 3 for '...', 1 for the space
|
|
690
|
+
const at = keep.indexOf(', ');
|
|
691
|
+
if (at > 0) keep = keep.slice(at + 2);
|
|
692
|
+
names = '... ' + keep;
|
|
693
|
+
}
|
|
694
|
+
infoLines = [bcHead + names];
|
|
695
|
+
footer = 'Enter: switch · Space: toggle · d: delete · D: delete all · m: model · /: search · h: help · q: cancel · Ctrl+C: quit';
|
|
664
696
|
} else {
|
|
665
697
|
const selIsCurrent = !!(selected && String(selected.id) === String(current));
|
|
666
698
|
infoLines = sessionInfoLines(selected, runningIds, modelContext, cols, selIsCurrent);
|
|
667
699
|
}
|
|
668
|
-
|
|
700
|
+
// 'd'/n/r/R prompts take over the status bar (previously an easy-to-miss
|
|
701
|
+
// line at the bottom of the list) with their essential keys on the right.
|
|
702
|
+
// A prompt also flips the bar to light yellow, matching the list pointer,
|
|
703
|
+
// so the change of state is obvious at a glance.
|
|
704
|
+
let questionBar = false;
|
|
705
|
+
if ((mode === 'confirm' || mode === 'broadcast-confirm') && confirmDel) {
|
|
706
|
+
questionBar = true;
|
|
707
|
+
if (confirmAll) {
|
|
708
|
+
const n = broadcastSel.size;
|
|
709
|
+
footer = `Delete all ${n} selected session${n === 1 ? '' : 's'}?`;
|
|
710
|
+
} else {
|
|
711
|
+
footer = `Delete session "${confirmDel.title || confirmDel.id || ''}"?`;
|
|
712
|
+
}
|
|
713
|
+
footerLeft = true;
|
|
714
|
+
footerRight = 'y: delete · n/Esc: cancel';
|
|
715
|
+
} else if (mode === 'input' && input) {
|
|
716
|
+
questionBar = true;
|
|
717
|
+
footer = `${input.prompt} ${renderInputValue(input.value)}`;
|
|
718
|
+
footerLeft = true;
|
|
719
|
+
footerRight = input.hint;
|
|
720
|
+
} else {
|
|
721
|
+
footer = searchFooter(search, footer);
|
|
722
|
+
}
|
|
669
723
|
process.stderr.write(renderList({
|
|
670
724
|
// The project name heads the title so you always know which project's
|
|
671
725
|
// sessions are listed (matters when --all-projects moves you around).
|
|
@@ -684,15 +738,16 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
684
738
|
return String(s.id) === String(current) ? `${BROWN}${text}\x1b[0m` : text;
|
|
685
739
|
},
|
|
686
740
|
footer,
|
|
687
|
-
footerLeft
|
|
688
|
-
footerRight
|
|
741
|
+
footerLeft,
|
|
742
|
+
footerRight,
|
|
743
|
+
footerBg: questionBar ? QUESTION_BG : BAR_BG,
|
|
689
744
|
infoLines,
|
|
690
745
|
extraLines: extras,
|
|
691
746
|
}));
|
|
692
747
|
};
|
|
693
748
|
|
|
694
|
-
const startInput = (
|
|
695
|
-
input = {
|
|
749
|
+
const startInput = (prompt, hint, value, kind) => {
|
|
750
|
+
input = { prompt, hint, value, kind, caret: value.length };
|
|
696
751
|
mode = 'input';
|
|
697
752
|
draw();
|
|
698
753
|
};
|
|
@@ -735,10 +790,12 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
735
790
|
}
|
|
736
791
|
inspected = false;
|
|
737
792
|
confirmDel = null;
|
|
793
|
+
confirmAll = false;
|
|
738
794
|
draw();
|
|
739
795
|
}).catch((err) => {
|
|
740
796
|
process.stderr.write(`\nError: ${err.message}\n`);
|
|
741
797
|
confirmDel = null;
|
|
798
|
+
confirmAll = false;
|
|
742
799
|
draw();
|
|
743
800
|
});
|
|
744
801
|
};
|
|
@@ -747,11 +804,31 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
747
804
|
const s = confirmDel;
|
|
748
805
|
if (!s || !onDelete) return;
|
|
749
806
|
const wasBroadcast = mode === 'broadcast-confirm';
|
|
750
|
-
|
|
751
|
-
|
|
807
|
+
const deletingAll = confirmAll;
|
|
808
|
+
// 'D' removes every session in the broadcast selection; 'd' removes just
|
|
809
|
+
// the cursor row.
|
|
810
|
+
const targets = deletingAll
|
|
811
|
+
? rows.filter(r => broadcastSel.has(String(r.id)))
|
|
812
|
+
: [s];
|
|
813
|
+
confirmDel = null;
|
|
814
|
+
confirmAll = false;
|
|
815
|
+
Promise.resolve(Promise.all(targets.map(t => onDelete(t)))).then(() => {
|
|
816
|
+
const removed = new Set(targets.map(t => String(t.id)));
|
|
817
|
+
rows = rows.filter(r => !removed.has(String(r.id)));
|
|
752
818
|
applySearch();
|
|
753
819
|
if (cursor >= view.length && cursor > 0) cursor = view.length - 1;
|
|
754
|
-
|
|
820
|
+
if (deletingAll) {
|
|
821
|
+
// Deleting the whole broadcast selection ends broadcast mode; adopt
|
|
822
|
+
// the row now under the cursor as the new current (state + TUI +
|
|
823
|
+
// highlight) so the list and the TUI agree on the new selection.
|
|
824
|
+
persistBroadcast([]);
|
|
825
|
+
broadcastSel = new Set();
|
|
826
|
+
mode = 'normal';
|
|
827
|
+
const next = view[cursor];
|
|
828
|
+
if (next) applyPick(next);
|
|
829
|
+
else { current = null; draw(); }
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
755
832
|
if (wasBroadcast) {
|
|
756
833
|
broadcastSel.delete(String(s.id));
|
|
757
834
|
if (broadcastSel.size < 2) {
|
|
@@ -767,15 +844,32 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
767
844
|
} else {
|
|
768
845
|
persistBroadcast([...broadcastSel]);
|
|
769
846
|
mode = 'broadcast';
|
|
847
|
+
// Keep the anchor session pointing at something that still exists
|
|
848
|
+
// (the cursor row when it is one of the remaining selections).
|
|
849
|
+
if (String(s.id) === String(current)) {
|
|
850
|
+
const next = view[cursor];
|
|
851
|
+
current = (next && broadcastSel.has(String(next.id))) ? next.id : ([...broadcastSel][0] || null);
|
|
852
|
+
}
|
|
770
853
|
draw();
|
|
771
854
|
}
|
|
772
855
|
} else {
|
|
773
856
|
mode = 'normal';
|
|
774
|
-
|
|
857
|
+
if (String(s.id) === String(current)) {
|
|
858
|
+
// The highlighted session was deleted: adopt the row now under the
|
|
859
|
+
// cursor as the new current. applyPick records it in .ocmux.json
|
|
860
|
+
// and refreshes the TUI, so the list highlight and the display
|
|
861
|
+
// agree on the new selection.
|
|
862
|
+
const next = view[cursor];
|
|
863
|
+
if (next) applyPick(next);
|
|
864
|
+
else { current = null; draw(); }
|
|
865
|
+
} else {
|
|
866
|
+
draw();
|
|
867
|
+
}
|
|
775
868
|
}
|
|
776
869
|
}).catch((err) => {
|
|
777
870
|
process.stderr.write(`\nError: ${err.message}\n`);
|
|
778
871
|
confirmDel = null;
|
|
872
|
+
confirmAll = false;
|
|
779
873
|
mode = wasBroadcast ? 'broadcast' : 'normal';
|
|
780
874
|
draw();
|
|
781
875
|
});
|
|
@@ -883,6 +977,7 @@ const toggleBroadcast = (idx) => {
|
|
|
883
977
|
if (key.name === 'y') return applyDelete();
|
|
884
978
|
if (key.name === 'n' || key.name === 'escape' || key.name === 'q' || key.name === 'return') {
|
|
885
979
|
confirmDel = null;
|
|
980
|
+
confirmAll = false;
|
|
886
981
|
mode = mode === 'broadcast-confirm' ? 'broadcast' : 'normal';
|
|
887
982
|
draw();
|
|
888
983
|
}
|
|
@@ -987,6 +1082,15 @@ const toggleBroadcast = (idx) => {
|
|
|
987
1082
|
draw();
|
|
988
1083
|
} else if (key.name === 'space') {
|
|
989
1084
|
if (cursor < view.length) toggleBroadcast(cursor);
|
|
1085
|
+
} else if (str === 'D') {
|
|
1086
|
+
// Shift+d: delete ALL broadcast-selected sessions at once (with
|
|
1087
|
+
// confirmation), handy to drop a whole selection you no longer want.
|
|
1088
|
+
if (broadcastSel.size > 0) {
|
|
1089
|
+
confirmAll = true;
|
|
1090
|
+
confirmDel = { all: true };
|
|
1091
|
+
mode = 'broadcast-confirm';
|
|
1092
|
+
draw();
|
|
1093
|
+
}
|
|
990
1094
|
} else if (key.name === 'd') {
|
|
991
1095
|
if (cursor < view.length) {
|
|
992
1096
|
confirmDel = view[cursor];
|
|
@@ -1020,18 +1124,18 @@ const toggleBroadcast = (idx) => {
|
|
|
1020
1124
|
return;
|
|
1021
1125
|
}
|
|
1022
1126
|
if (key.name === 'n') {
|
|
1023
|
-
startInput('New session name (blank
|
|
1127
|
+
startInput('New session name (blank = auto):', 'Enter: create · Esc: cancel', '', 'create');
|
|
1024
1128
|
return;
|
|
1025
1129
|
}
|
|
1026
1130
|
// Shift+r → Reminder; lowercase r → Rename.
|
|
1027
1131
|
if (str === 'R') {
|
|
1028
1132
|
const row = view[cursor];
|
|
1029
1133
|
const note = (row && annotations && annotations[row.id]) || '';
|
|
1030
|
-
startInput('Reminder
|
|
1134
|
+
startInput('Reminder (blank clears):', 'Enter: save · Esc: cancel', note, 'reminder');
|
|
1031
1135
|
return;
|
|
1032
1136
|
}
|
|
1033
1137
|
if (key.name === 'r' && cursor < view.length) {
|
|
1034
|
-
startInput('Rename to
|
|
1138
|
+
startInput('Rename to:', 'Enter: rename · Esc: cancel', view[cursor].title || '', 'rename');
|
|
1035
1139
|
return;
|
|
1036
1140
|
}
|
|
1037
1141
|
if (key.name === 'a') {
|
|
@@ -1367,7 +1471,7 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
|
1367
1471
|
process.exit(1);
|
|
1368
1472
|
}
|
|
1369
1473
|
const state = projectState.readProjectState(sf);
|
|
1370
|
-
|
|
1474
|
+
let server = state.server;
|
|
1371
1475
|
if (!server) {
|
|
1372
1476
|
console.error(`Error: ${sf} does not record a server.`);
|
|
1373
1477
|
console.error(`Run 'ocmux serve --server <url>' to set it up, or add "server" to the file.`);
|
|
@@ -1375,7 +1479,19 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
|
1375
1479
|
}
|
|
1376
1480
|
const dir = state.directory;
|
|
1377
1481
|
|
|
1378
|
-
|
|
1482
|
+
// If the recorded server is down, offer to repoint the project to the
|
|
1483
|
+
// default server — but only after confirming the default actually answers.
|
|
1484
|
+
let alive = await checkServer(server);
|
|
1485
|
+
if (!alive && process.stdin.isTTY && server !== DEFAULT_SERVER
|
|
1486
|
+
&& await checkServer(DEFAULT_SERVER)) {
|
|
1487
|
+
const useDefault = await askYesNo(
|
|
1488
|
+
`OpenCode server not reachable at ${server}. Use the default ${DEFAULT_SERVER} instead? [y/N] `);
|
|
1489
|
+
if (useDefault) {
|
|
1490
|
+
projectState.writeProjectState(sf, { server: DEFAULT_SERVER });
|
|
1491
|
+
server = DEFAULT_SERVER;
|
|
1492
|
+
alive = true;
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1379
1495
|
if (!alive) {
|
|
1380
1496
|
console.error(`Error: OpenCode server not reachable at ${server}.`);
|
|
1381
1497
|
console.error(`Start it with 'opencode serve' (or your usual launcher) first.`);
|
|
@@ -1438,6 +1554,12 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
|
1438
1554
|
// model of the previously selected session); r renames; R sets a reminder;
|
|
1439
1555
|
// d deletes; a switches agent; m switches model; p switches project; q quits.
|
|
1440
1556
|
let lastRef = (state.session && mainSessions.find(s => String(s.id) === String(state.session))) || mainSessions[0] || null;
|
|
1557
|
+
// Opening the picker in a project routes its applicable TUI to the stored
|
|
1558
|
+
// session, so merely running `ocmux` here switches the pane. (Respawn is
|
|
1559
|
+
// skipped when the TUI is already showing that project/session.)
|
|
1560
|
+
if (state.session && mainSessions.some(s => String(s.id) === String(state.session))) {
|
|
1561
|
+
refreshTui(dir, server, state.session);
|
|
1562
|
+
}
|
|
1441
1563
|
const result = await new Promise((resolve) => {
|
|
1442
1564
|
sessionMenu(mainSessions, state.session, annotations, server, {
|
|
1443
1565
|
dir,
|
|
@@ -1622,20 +1744,22 @@ async function cmdTui(targetDir, serverArg, shared, sessionArg) {
|
|
|
1622
1744
|
process.exit(1);
|
|
1623
1745
|
}
|
|
1624
1746
|
const sf = findParentStatefile(targetDir);
|
|
1625
|
-
|
|
1747
|
+
const state = sf ? projectState.readProjectState(sf) : null;
|
|
1748
|
+
if (!state && !shared) {
|
|
1626
1749
|
console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
|
|
1627
1750
|
console.error("Run 'ocmux serve' first.");
|
|
1628
1751
|
process.exit(1);
|
|
1629
1752
|
}
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
const
|
|
1753
|
+
// The shared TUI is a cross-project fallback, so it can run without a project
|
|
1754
|
+
// state file (defaulting to the configured/default server).
|
|
1755
|
+
const directory = state ? state.directory : targetDir;
|
|
1756
|
+
const server = serverArg || (state && state.server) || DEFAULT_SERVER;
|
|
1633
1757
|
if (!await checkServer(server)) {
|
|
1634
1758
|
console.error(`Error: OpenCode server not reachable at ${server}.`);
|
|
1635
1759
|
process.exit(1);
|
|
1636
1760
|
}
|
|
1637
1761
|
|
|
1638
|
-
let session = sessionArg || state.session;
|
|
1762
|
+
let session = sessionArg || (state && state.session) || null;
|
|
1639
1763
|
if (session) {
|
|
1640
1764
|
try {
|
|
1641
1765
|
const found = await getSession(server, session);
|
|
@@ -1648,7 +1772,7 @@ async function cmdTui(targetDir, serverArg, shared, sessionArg) {
|
|
|
1648
1772
|
const sessions = await listSessions(server, directory);
|
|
1649
1773
|
session = newestSessionId(sessions);
|
|
1650
1774
|
if (!session) session = (await createSessionWithModel(server, undefined, directory, sessions)).id;
|
|
1651
|
-
projectState.writeProjectState(sf, { session, server });
|
|
1775
|
+
if (sf) projectState.writeProjectState(sf, { session, server });
|
|
1652
1776
|
}
|
|
1653
1777
|
|
|
1654
1778
|
const registration = tuiRegistry.register({
|
|
@@ -1807,10 +1931,14 @@ function printHelp() {
|
|
|
1807
1931
|
console.error('sessions are listed. In the picker:');
|
|
1808
1932
|
console.error(' Enter switch · n new · r rename · R remind · d delete · a agents · m model');
|
|
1809
1933
|
console.error(' p projects · h help · q quit');
|
|
1934
|
+
console.error(' In BROADCAST: Space toggles a session · d deletes cursor · D deletes all');
|
|
1810
1935
|
console.error('Selecting updates .ocmux.json and refreshes a dedicated TUI, falling');
|
|
1811
|
-
console.error('back to the shared TUI.
|
|
1936
|
+
console.error('back to the shared TUI. Opening the picker also routes that project\'s');
|
|
1937
|
+
console.error('TUI to its stored session. Silent on success. (p opens projects; by');
|
|
1812
1938
|
console.error('default it only inspects other projects and never modifies any');
|
|
1813
1939
|
console.error('.ocmux.json — see --all-projects.)');
|
|
1940
|
+
console.error('If the recorded server is down but the default server answers, ocmux');
|
|
1941
|
+
console.error('offers to repoint the project to the default.');
|
|
1814
1942
|
console.error('');
|
|
1815
1943
|
console.error('Subcommands:');
|
|
1816
1944
|
console.error(' serve Initialize project state for <directory> (default: $PWD).');
|
|
@@ -1821,7 +1949,8 @@ function printHelp() {
|
|
|
1821
1949
|
console.error(' tui [--shared] [--server <url>] [directory]');
|
|
1822
1950
|
console.error(' Register the current tmux pane and run its OpenCode TUI.');
|
|
1823
1951
|
console.error(' Without --shared it is dedicated to the current project;');
|
|
1824
|
-
console.error(' --shared makes it the single fallback for every project
|
|
1952
|
+
console.error(' --shared makes it the single fallback for every project and');
|
|
1953
|
+
console.error(' runs even without .ocmux.json (using the default server).');
|
|
1825
1954
|
console.error(' --list lists all live registrations; --status inspects one;');
|
|
1826
1955
|
console.error(' --detach unregisters one without closing its pane or TUI.');
|
|
1827
1956
|
console.error(' With --status/--detach, --shared targets the shared slot;');
|
|
@@ -2071,4 +2200,4 @@ if (require.main === module) {
|
|
|
2071
2200
|
main();
|
|
2072
2201
|
}
|
|
2073
2202
|
|
|
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 };
|
|
2203
|
+
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 };
|
package/docs/specification_v2.md
CHANGED
|
@@ -59,6 +59,8 @@ Each instance records:
|
|
|
59
59
|
- tmux socket and pane ID;
|
|
60
60
|
- dedicated/shared mode;
|
|
61
61
|
- last directory, server, and session;
|
|
62
|
+
- the directory and session currently displayed (so routing can skip a
|
|
63
|
+
respawn that would change nothing);
|
|
62
64
|
- foreground wrapper PID and current child PID (diagnostic only);
|
|
63
65
|
- registration/update timestamps.
|
|
64
66
|
|
|
@@ -95,7 +97,9 @@ a different server, the same pane reconnects to that server.
|
|
|
95
97
|
`ocmux tui [--shared] [directory]`:
|
|
96
98
|
|
|
97
99
|
1. Requires `$TMUX` and `$TMUX_PANE`.
|
|
98
|
-
2. Resolves `.ocmux.json`, server, directory, and session.
|
|
100
|
+
2. Resolves `.ocmux.json`, server, directory, and session. A shared TUI may run
|
|
101
|
+
without a state file, in which case it uses the current directory and the
|
|
102
|
+
default server (`$OCMUX_SERVER` or `http://localhost:4096`).
|
|
99
103
|
3. Health-checks the server and validates the session, creating one with a
|
|
100
104
|
model when necessary.
|
|
101
105
|
4. Marks and registers the current pane.
|
|
@@ -127,6 +131,10 @@ The new `ocmux tui` process re-registers the pane and hosts the new OpenCode
|
|
|
127
131
|
child. Server-side session execution continues while the old client disconnects.
|
|
128
132
|
Client-local state—draft prompt, scroll position, dialogs, and tabs—is lost.
|
|
129
133
|
|
|
134
|
+
Routing is idempotent: the registry records the directory and session the pane
|
|
135
|
+
currently displays, so a switch to the same project/session is skipped without
|
|
136
|
+
respawning.
|
|
137
|
+
|
|
130
138
|
Native TUI navigation is temporary inspection and is not observable through
|
|
131
139
|
the OpenCode HTTP API. The next ocmux selection restores the canonical target.
|
|
132
140
|
|
|
@@ -142,7 +150,8 @@ or creating one with a model. It creates no tmux session, window, or pane.
|
|
|
142
150
|
|
|
143
151
|
Registers and hosts a dedicated or shared TUI as described above. `--shared`
|
|
144
152
|
is valid only for this command. `--server` overrides the project's recorded
|
|
145
|
-
server for the launched wrapper
|
|
153
|
+
server for the launched wrapper; when `--shared` runs without a state file it
|
|
154
|
+
falls back to the default server. Management modes are:
|
|
146
155
|
|
|
147
156
|
- `ocmux tui --list` — prune stale entries and print every live registration;
|
|
148
157
|
- `ocmux tui [dir] --status` — inspect the dedicated slot for that project;
|
|
@@ -156,9 +165,25 @@ destroy the pane. Management modes do not require running inside tmux.
|
|
|
156
165
|
### `ocmux`
|
|
157
166
|
|
|
158
167
|
Opens the current project's session picker. Picking writes that project's
|
|
159
|
-
session and refreshes its routed TUI.
|
|
168
|
+
session and refreshes its routed TUI. Merely opening the picker also routes the
|
|
169
|
+
applicable TUI to the project's stored session (skipped when it already shows
|
|
170
|
+
it), so starting `ocmux` in a project switches the display even without picking
|
|
171
|
+
a row. When the recorded server is unreachable but the default server answers,
|
|
172
|
+
`ocmux` offers on a TTY to repoint the project to the default and rewrites
|
|
173
|
+
`server`. Session creation, rename/delete,
|
|
160
174
|
annotations, agent/model choice, broadcast selection, search, and project
|
|
161
|
-
inspection operate through OpenCode's API.
|
|
175
|
+
inspection operate through OpenCode's API. Deleting the session currently
|
|
176
|
+
selected adopts the session under the cursor as the new current (state write,
|
|
177
|
+
TUI refresh, and list highlight); deleting any other session leaves the current
|
|
178
|
+
selection unchanged. In broadcast mode `d` deletes just the cursor session
|
|
179
|
+
while `D` (Shift+d) deletes every selected session at once — after confirming,
|
|
180
|
+
broadcast mode ends and the row under the cursor becomes the new current.
|
|
181
|
+
Interactive prompts (create/rename/reminder/delete/delete-all) take over the
|
|
182
|
+
status bar and flip its background from brown to light yellow — the same color
|
|
183
|
+
as the session-list pointer — so an active question is immediately visible.
|
|
184
|
+
The broadcast info line (`Broadcast to sessions: …`) never wraps: it holds up
|
|
185
|
+
to 480 characters of names (or the terminal width, whichever is smaller), and
|
|
186
|
+
an oversized selection is truncated from the beginning with a leading `...`.
|
|
162
187
|
|
|
163
188
|
### `ocmux session <id|title> [dir]`
|
|
164
189
|
|
package/lib/ocmux.js
CHANGED
|
@@ -18,7 +18,15 @@ function tuiArgs(server, session, directory) {
|
|
|
18
18
|
function switchTui(directory, server, session, executable) {
|
|
19
19
|
const target = registry.resolve(directory);
|
|
20
20
|
if (!target) return { ok: false, reason: 'none' };
|
|
21
|
-
|
|
21
|
+
// Skip the respawn when the routed pane already shows this project/session,
|
|
22
|
+
// so merely opening ocmux does not restart a TUI that is already correct.
|
|
23
|
+
const instance = target.instance;
|
|
24
|
+
if (instance.activeDirectory
|
|
25
|
+
&& path.resolve(instance.activeDirectory) === path.resolve(directory)
|
|
26
|
+
&& String(instance.activeSession || '') === String(session || '')) {
|
|
27
|
+
return { ok: true, kind: target.kind, instance, unchanged: true };
|
|
28
|
+
}
|
|
29
|
+
const result = registry.respawn(instance, {
|
|
22
30
|
executable: executable || path.resolve(__dirname, '..', 'bin', 'ocmux'),
|
|
23
31
|
directory,
|
|
24
32
|
server,
|
|
@@ -28,7 +36,7 @@ function switchTui(directory, server, session, executable) {
|
|
|
28
36
|
registry.pruneDead();
|
|
29
37
|
return { ok: false, reason: 'failed', kind: target.kind, error: result.error };
|
|
30
38
|
}
|
|
31
|
-
return { ok: true, kind: target.kind, instance
|
|
39
|
+
return { ok: true, kind: target.kind, instance };
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
module.exports = {
|
package/lib/tui-registry.js
CHANGED
|
@@ -171,6 +171,10 @@ function register({ socket, pane, shared, directory, server, session, pid }) {
|
|
|
171
171
|
directory: directory ? path.resolve(directory) : null,
|
|
172
172
|
server: server || null,
|
|
173
173
|
session: session || null,
|
|
174
|
+
// What the pane is currently displaying. `respawn()` refreshes these so a
|
|
175
|
+
// caller (ocmux) can skip restarting a TUI that already shows the target.
|
|
176
|
+
activeDirectory: directory ? path.resolve(directory) : null,
|
|
177
|
+
activeSession: session || null,
|
|
174
178
|
pid: Number(pid) || process.pid,
|
|
175
179
|
registeredAt: now,
|
|
176
180
|
updatedAt: now,
|
|
@@ -276,6 +280,10 @@ function respawn(instance, { executable, directory, server, session }) {
|
|
|
276
280
|
if (result.status !== 0) {
|
|
277
281
|
return { ok: false, error: (result.stderr || '').trim() || `failed to respawn ${instance.pane}` };
|
|
278
282
|
}
|
|
283
|
+
updateInstance(instance.id, instance.token, {
|
|
284
|
+
activeDirectory: directory ? path.resolve(directory) : null,
|
|
285
|
+
activeSession: session || null,
|
|
286
|
+
});
|
|
279
287
|
return { ok: true };
|
|
280
288
|
}
|
|
281
289
|
|