agentp 2.0.1 → 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/CONTRIBUTING.md +7 -5
- package/README.md +119 -25
- package/bin/agentp +3 -6
- package/bin/ocmux +717 -268
- package/bin/tgagentp +121 -130
- package/docs/specification.md +2 -1
- package/docs/specification_v2.md +213 -624
- package/lib/ocmux.js +38 -293
- package/lib/opencode.js +186 -43
- package/lib/tui-cmd.js +12 -5
- package/lib/tui-registry.js +321 -0
- package/package.json +1 -1
package/bin/ocmux
CHANGED
|
@@ -5,26 +5,11 @@ const path = require('path');
|
|
|
5
5
|
const readline = require('readline');
|
|
6
6
|
const http = require('http');
|
|
7
7
|
const { URL } = require('url');
|
|
8
|
-
const
|
|
8
|
+
const child_process = require('child_process');
|
|
9
9
|
const os = require('os');
|
|
10
|
-
const {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
tuiPaneId,
|
|
14
|
-
windowByDir,
|
|
15
|
-
windowNameByIndex,
|
|
16
|
-
activeWindowIndex,
|
|
17
|
-
listProjects,
|
|
18
|
-
activateProject,
|
|
19
|
-
relaunchTui,
|
|
20
|
-
closeProjectWindow,
|
|
21
|
-
createProjectWindow,
|
|
22
|
-
ensureSession,
|
|
23
|
-
pinWindowName,
|
|
24
|
-
sleep,
|
|
25
|
-
tuiAttachCommand,
|
|
26
|
-
} = require('../lib/ocmux');
|
|
27
|
-
const { listSessions, createSession, createSessionWithModel, updateSession, listModels, switchModel, resolveModelRef, getAuthHeaders, sortSessionsByRecency, deleteSession, listAgents, getActiveSessions } = require('../lib/opencode');
|
|
10
|
+
const { tuiArgs, switchTui } = require('../lib/ocmux');
|
|
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');
|
|
28
13
|
const projectState = require('../lib/project-state');
|
|
29
14
|
|
|
30
15
|
// ---------------------------------------------------------------------------
|
|
@@ -41,15 +26,6 @@ const DEFAULT_SERVER = process.env.OCMUX_SERVER || 'http://localhost:4096';
|
|
|
41
26
|
// Helpers
|
|
42
27
|
// ---------------------------------------------------------------------------
|
|
43
28
|
|
|
44
|
-
function tmux(args) {
|
|
45
|
-
const result = spawnSync('tmux', args, { encoding: 'utf8', maxBuffer: 1024 * 1024 });
|
|
46
|
-
if (result.error && result.error.code === 'ENOENT') {
|
|
47
|
-
console.error('Error: tmux is not installed or not in PATH');
|
|
48
|
-
process.exit(1);
|
|
49
|
-
}
|
|
50
|
-
return result;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
29
|
function findParentStatefile(startDir) {
|
|
54
30
|
let dir = path.resolve(startDir);
|
|
55
31
|
while (true) {
|
|
@@ -93,18 +69,12 @@ function resolveGitDir(startDir, requireDir, flagLabel) {
|
|
|
93
69
|
if (!result.dir) {
|
|
94
70
|
const label = requireDir ? '.git directory' : '.git entry';
|
|
95
71
|
console.error(`Error: no ${label} found in ${startDir} or any of its parents.`);
|
|
96
|
-
console.error(`Run '${SCRIPT} serve' without ${flagLabel} to
|
|
72
|
+
console.error(`Run '${SCRIPT} serve' without ${flagLabel} to initialize the current directory.`);
|
|
97
73
|
process.exit(1);
|
|
98
74
|
}
|
|
99
75
|
return result.dir;
|
|
100
76
|
}
|
|
101
77
|
|
|
102
|
-
function findWindowsByBasename(name) {
|
|
103
|
-
const r = tmux(['list-windows', '-t', SESSION, '-F', '#{window_name}']);
|
|
104
|
-
if (r.status !== 0) return [];
|
|
105
|
-
return r.stdout.trim().split('\n').filter(Boolean).filter(w => path.basename(w) === name);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
78
|
function resolveDir(arg) {
|
|
109
79
|
const resolved = path.resolve(arg);
|
|
110
80
|
try {
|
|
@@ -112,17 +82,6 @@ function resolveDir(arg) {
|
|
|
112
82
|
} catch {
|
|
113
83
|
// not a valid path or inaccessible
|
|
114
84
|
}
|
|
115
|
-
|
|
116
|
-
if (!arg.includes('/')) {
|
|
117
|
-
const matches = findWindowsByBasename(arg);
|
|
118
|
-
if (matches.length === 1) return matches[0];
|
|
119
|
-
if (matches.length > 1) {
|
|
120
|
-
console.error(`Error: more than one match for '${arg}':`);
|
|
121
|
-
for (const m of matches) console.error(` ${m}`);
|
|
122
|
-
process.exit(1);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
85
|
console.error(`Error: invalid directory '${arg}'`);
|
|
127
86
|
console.error(`Run '${SCRIPT} -h' for help.`);
|
|
128
87
|
process.exit(1);
|
|
@@ -154,6 +113,62 @@ function checkServer(server) {
|
|
|
154
113
|
});
|
|
155
114
|
}
|
|
156
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
|
+
|
|
133
|
+
function refreshTui(directory, server, session) {
|
|
134
|
+
const result = switchTui(directory, server, session, path.resolve(process.argv[1]));
|
|
135
|
+
if (!result.ok && result.reason === 'failed') {
|
|
136
|
+
console.error(`Warning: session selected, but the ${result.kind} TUI could not be refreshed: ${result.error}`);
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function configuredProjects(server) {
|
|
142
|
+
const [projects, sessions] = await Promise.all([
|
|
143
|
+
listOpenCodeProjects(server),
|
|
144
|
+
listSessions(server),
|
|
145
|
+
]);
|
|
146
|
+
const rows = [];
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
const directories = [
|
|
149
|
+
...(projects || []).map(project => project.canonical || project.directory),
|
|
150
|
+
...(sessions || []).map(session => session.location && session.location.directory),
|
|
151
|
+
];
|
|
152
|
+
for (const dir of directories) {
|
|
153
|
+
if (!dir) continue;
|
|
154
|
+
let canonical;
|
|
155
|
+
try { canonical = fs.realpathSync(dir); } catch { canonical = path.resolve(dir); }
|
|
156
|
+
if (seen.has(canonical)) continue;
|
|
157
|
+
const sf = path.join(canonical, '.ocmux.json');
|
|
158
|
+
const state = projectState.readProjectState(sf);
|
|
159
|
+
if (!state) continue;
|
|
160
|
+
seen.add(canonical);
|
|
161
|
+
const target = tuiRegistry.resolve(canonical);
|
|
162
|
+
rows.push({
|
|
163
|
+
dir: canonical,
|
|
164
|
+
status: target ? target.kind : 'headless',
|
|
165
|
+
session: state.session,
|
|
166
|
+
server: state.server || server,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return rows.sort((a, b) => a.dir.localeCompare(b.dir));
|
|
170
|
+
}
|
|
171
|
+
|
|
157
172
|
function newestSessionId(sessions) {
|
|
158
173
|
if (!sessions || sessions.length === 0) return null;
|
|
159
174
|
// Implicit targets skip child/subagent sessions (parentID set) — OpenCode's
|
|
@@ -190,6 +205,15 @@ function padLeft(s, w) {
|
|
|
190
205
|
return ' '.repeat(Math.max(0, w - str.length)) + str;
|
|
191
206
|
}
|
|
192
207
|
|
|
208
|
+
// ── Palette (classic 16-colour ANSI) ─────────────────────────────────────────
|
|
209
|
+
// Colour 3 renders as brown/dark-yellow; bold colour 3 as light yellow. The
|
|
210
|
+
// whole list UI (title/status bars, cursor pointer, info labels) sticks to these
|
|
211
|
+
// two shades so the chrome reads as one theme.
|
|
212
|
+
const BROWN = '\x1b[33m'; // text: brown/dark-yellow
|
|
213
|
+
const BAR_BG = '\x1b[30;43m'; // title/status bar: black on brown
|
|
214
|
+
const LIGHT_YELLOW = '\x1b[1;33m'; // text: light-yellow (bold brown)
|
|
215
|
+
const QUESTION_BG = '\x1b[30;103m'; // question/input bar: black on light-yellow
|
|
216
|
+
|
|
193
217
|
// ── '/' incremental search for interactive lists ──────────────────────────
|
|
194
218
|
// Filter `items` by `pattern`: case-insensitive, space-separated tokens that
|
|
195
219
|
// must ALL be substrings of `textOf(item)`. Empty pattern → list unchanged.
|
|
@@ -278,8 +302,8 @@ function handleSearch(str, key, s) {
|
|
|
278
302
|
return 'pass';
|
|
279
303
|
}
|
|
280
304
|
|
|
281
|
-
// The
|
|
282
|
-
//
|
|
305
|
+
// The status bar's search line: normal key hints, or the pattern while a '/'
|
|
306
|
+
// filter is active (live = input with caret; committed = pattern kept).
|
|
283
307
|
// The caller adds the `Enter: confirm · Esc: cancel` hint on the right via
|
|
284
308
|
// renderList's `footerRight`.
|
|
285
309
|
const SEARCH_HINT = 'Enter: confirm · Esc: cancel';
|
|
@@ -289,6 +313,23 @@ function searchFooter(s, base) {
|
|
|
289
313
|
return s.live ? `Search: ${shown}` : `Search: ${shown} · /: edit`;
|
|
290
314
|
}
|
|
291
315
|
|
|
316
|
+
// ── Model picker ordering ─────────────────────────────────────────────────────
|
|
317
|
+
// Models sorted by provider then id (the provider already leads the label, so
|
|
318
|
+
// this is also alphabetical by display order).
|
|
319
|
+
function sortModels(models) {
|
|
320
|
+
const key = (m) => `${String(m.providerID || '').toLowerCase()}/${String(m.id || '').toLowerCase()}`;
|
|
321
|
+
return [...(models || [])].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Index of `model` (as stored on a session: { providerID, id }) inside
|
|
325
|
+
// `models`, or -1 when unknown/unmatched — so a picker can open on it.
|
|
326
|
+
function indexOfModel(models, model) {
|
|
327
|
+
if (!model || !models || !models.length) return -1;
|
|
328
|
+
const key = (m) => `${String(m.providerID || '').toLowerCase()}/${String(m.id || '').toLowerCase()}`;
|
|
329
|
+
const target = key(model);
|
|
330
|
+
return models.findIndex((m) => key(m) === target);
|
|
331
|
+
}
|
|
332
|
+
|
|
292
333
|
// Sentinel returned by switchMenu on Ctrl+C so the caller fully exits the CLI
|
|
293
334
|
// (instead of just returning to the session menu).
|
|
294
335
|
const SWITCH_QUIT = Symbol('ocmux-switch-quit');
|
|
@@ -376,13 +417,14 @@ function labeledLine(label, value, cols, labelColor = '\x1b[1m') {
|
|
|
376
417
|
return `${labelColor}${lbl}\x1b[0m ${v}`;
|
|
377
418
|
}
|
|
378
419
|
|
|
379
|
-
// Info lines about the selected session: title + location
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
420
|
+
// Info lines about the selected session: title + location and a responsive
|
|
421
|
+
// grid. Fields are ALWAYS present (placeholders when unknown).
|
|
422
|
+
// Labels are brown (the same shade as the title/status bars); when `highlight`
|
|
423
|
+
// is true (the cursor is on the session currently selected in the TUI) they are
|
|
424
|
+
// light-yellow instead.
|
|
383
425
|
function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false) {
|
|
384
426
|
if (!s) return [];
|
|
385
|
-
const labelColor = highlight ?
|
|
427
|
+
const labelColor = highlight ? LIGHT_YELLOW : BROWN;
|
|
386
428
|
const title = (s.title && s.title.trim()) || 'Untitled';
|
|
387
429
|
const location = (s.location && s.location.directory) || 'Unknown';
|
|
388
430
|
const out = [
|
|
@@ -423,15 +465,17 @@ function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false)
|
|
|
423
465
|
}
|
|
424
466
|
|
|
425
467
|
// Render a scrollable list into the alternate screen.
|
|
426
|
-
// heading -> centered
|
|
468
|
+
// heading -> centered title bar (brown background)
|
|
427
469
|
// row(index, item) -> string | { text, reverse }
|
|
428
470
|
// extraLines -> prompt/confirm lines right after the list (normal colors)
|
|
429
|
-
// footer ->
|
|
471
|
+
// footer -> status bar (brown background), key hints right-aligned
|
|
430
472
|
// (left-aligned when footerLeft is set — used by the search line)
|
|
431
473
|
// footerRight-> optional hint pinned to the right end of a left-aligned footer
|
|
432
|
-
//
|
|
474
|
+
// footerBg -> background for the footer bar (default: BAR_BG; question bars
|
|
475
|
+
// pass QUESTION_BG so an active prompt stands out)
|
|
476
|
+
// infoLines -> details below the status bar (colours supplied by the caller)
|
|
433
477
|
// A "(start-end/total)" range is appended to the heading when scrolling.
|
|
434
|
-
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 }) {
|
|
435
479
|
const cols = colsArg || process.stdout.columns || 80;
|
|
436
480
|
const termRows = rowsArg || process.stdout.rows || 24;
|
|
437
481
|
const extras = (extraLines || []).filter((l) => l != null && l !== '');
|
|
@@ -449,7 +493,7 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
|
|
|
449
493
|
}
|
|
450
494
|
|
|
451
495
|
let out = '\x1b[2J\x1b[H';
|
|
452
|
-
out +=
|
|
496
|
+
out += BAR_BG + centerIn(titleLine, cols) + '\x1b[0m\n\n';
|
|
453
497
|
|
|
454
498
|
let rendered = 0;
|
|
455
499
|
if (items.length === 0) {
|
|
@@ -461,8 +505,11 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
|
|
|
461
505
|
const r = row(idx, items[idx]);
|
|
462
506
|
const text = r && typeof r === 'object' ? r.text : r;
|
|
463
507
|
const reverse = r && typeof r === 'object' && r.reverse;
|
|
464
|
-
|
|
465
|
-
|
|
508
|
+
// The pointer sits outside the row's own styling so a light-yellow arrow
|
|
509
|
+
// stays visible even on a reverse-video row.
|
|
510
|
+
const ptr = idx === cursor ? `${LIGHT_YELLOW}▶\x1b[0m ` : ' ';
|
|
511
|
+
const body = text == null ? '' : text;
|
|
512
|
+
out += ptr + (reverse ? `\x1b[7m${body}\x1b[0m` : body) + '\n';
|
|
466
513
|
rendered++;
|
|
467
514
|
}
|
|
468
515
|
}
|
|
@@ -485,7 +532,7 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
|
|
|
485
532
|
} else {
|
|
486
533
|
hint = padLeft(footer, cols);
|
|
487
534
|
}
|
|
488
|
-
out +=
|
|
535
|
+
out += footerBg + hint + '\x1b[0m\n';
|
|
489
536
|
for (const l of infos) out += l + '\n';
|
|
490
537
|
return out.replace(/\n$/, '');
|
|
491
538
|
}
|
|
@@ -496,7 +543,9 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
|
|
|
496
543
|
// q/Ctrl+C quit. Input mode offers readline-style caret editing.
|
|
497
544
|
// Resolves on quit (null) or with the string 'switch-project' (caller then runs
|
|
498
545
|
// the project switcher and re-invokes this picker for the new project).
|
|
499
|
-
|
|
546
|
+
// `dir` names the project this list belongs to — it heads the title bar so a
|
|
547
|
+
// multi-project run always says which project's sessions are on screen.
|
|
548
|
+
function sessionMenu(sessions, currentSession, annotations, server, { onPick, onRename, onDelete, onReminder, runningIds, modelContext, onBroadcastChange, onInspectSession, dir, notices = [] } = {}) {
|
|
500
549
|
return new Promise((resolve) => {
|
|
501
550
|
if (!process.stdin.isTTY) return resolve(null);
|
|
502
551
|
|
|
@@ -505,6 +554,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
505
554
|
let cursor = 0;
|
|
506
555
|
let mode = 'normal'; // 'normal' | 'help' | 'confirm' | 'input'
|
|
507
556
|
let confirmDel = null;
|
|
557
|
+
let confirmAll = false; // 'D' deletes every broadcast-selected session
|
|
508
558
|
let input = null; // { label, value, kind, caret }
|
|
509
559
|
let list = null; // { title, items, format, onEnter }
|
|
510
560
|
let listCursor = 0;
|
|
@@ -528,6 +578,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
528
578
|
'R (Shift+r) set a REMINDER for the selected session (sent in front of',
|
|
529
579
|
' every agentp prompt; blank clears)',
|
|
530
580
|
'd delete the selected session (then y/N to confirm)',
|
|
581
|
+
'D (Shift+d) in BROADCAST: delete ALL selected sessions (then y/N)',
|
|
531
582
|
'a switch the AGENT of the selected session',
|
|
532
583
|
'm switch the MODEL of the selected session',
|
|
533
584
|
'p open the project switcher',
|
|
@@ -576,6 +627,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
576
627
|
out += ' Enter keep the broadcast selection; switch TUI to cursor\n';
|
|
577
628
|
out += ' m switch the model for ALL selected sessions\n';
|
|
578
629
|
out += ' d delete the cursor session (with confirmation)\n';
|
|
630
|
+
out += ' D (Shift+d) delete ALL selected sessions (with confirmation)\n';
|
|
579
631
|
out += ' j / k, arrows move the cursor (selecting shows it in the TUI)\n';
|
|
580
632
|
out += ' / search/filter the session list\n';
|
|
581
633
|
out += ' ESC / q cancel broadcast mode (back to the session list)\n';
|
|
@@ -618,27 +670,60 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
618
670
|
Math.max(8, cols - (showTime ? TIME_W + 10 : 8)),
|
|
619
671
|
);
|
|
620
672
|
const inBc = mode === 'broadcast' || mode === 'broadcast-confirm';
|
|
621
|
-
const extras = [];
|
|
622
|
-
if ((mode === 'confirm' || mode === 'broadcast-confirm') && confirmDel) {
|
|
623
|
-
extras.push(`Delete session "${confirmDel.title || confirmDel.id || ''}"? (y/N) `);
|
|
624
|
-
} else if (mode === 'input' && input) {
|
|
625
|
-
extras.push(`${input.label} ${renderInputValue(input.value)}`);
|
|
626
|
-
}
|
|
673
|
+
const extras = [...notices];
|
|
627
674
|
if (search.active && view.length === 0) extras.push(`No sessions match "${search.value}"`);
|
|
628
675
|
const selected = view[cursor];
|
|
629
676
|
let infoLines = [];
|
|
630
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;
|
|
631
680
|
if (inBc) {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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';
|
|
635
696
|
} else {
|
|
636
697
|
const selIsCurrent = !!(selected && String(selected.id) === String(current));
|
|
637
698
|
infoLines = sessionInfoLines(selected, runningIds, modelContext, cols, selIsCurrent);
|
|
638
699
|
}
|
|
639
|
-
|
|
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
|
+
}
|
|
640
723
|
process.stderr.write(renderList({
|
|
641
|
-
title
|
|
724
|
+
// The project name heads the title so you always know which project's
|
|
725
|
+
// sessions are listed (matters when --all-projects moves you around).
|
|
726
|
+
title: dir ? `ocmux — ${path.basename(dir)} sessions` : 'ocmux — sessions',
|
|
642
727
|
items: view,
|
|
643
728
|
cursor,
|
|
644
729
|
row: (i, s) => {
|
|
@@ -649,19 +734,20 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
649
734
|
const hasNote = annotations && annotations[s.id] ? ' ◈' : '';
|
|
650
735
|
const spin = (runningIds && runningIds.has(s.id)) ? SPINNER[spinIdx % SPINNER.length] + ' ' : ' ';
|
|
651
736
|
const text = `${sel}${spin}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
|
|
652
|
-
//
|
|
653
|
-
return String(s.id) === String(current) ?
|
|
737
|
+
// Brown for the session currently selected in the TUI window.
|
|
738
|
+
return String(s.id) === String(current) ? `${BROWN}${text}\x1b[0m` : text;
|
|
654
739
|
},
|
|
655
740
|
footer,
|
|
656
|
-
footerLeft
|
|
657
|
-
footerRight
|
|
741
|
+
footerLeft,
|
|
742
|
+
footerRight,
|
|
743
|
+
footerBg: questionBar ? QUESTION_BG : BAR_BG,
|
|
658
744
|
infoLines,
|
|
659
745
|
extraLines: extras,
|
|
660
746
|
}));
|
|
661
747
|
};
|
|
662
748
|
|
|
663
|
-
const startInput = (
|
|
664
|
-
input = {
|
|
749
|
+
const startInput = (prompt, hint, value, kind) => {
|
|
750
|
+
input = { prompt, hint, value, kind, caret: value.length };
|
|
665
751
|
mode = 'input';
|
|
666
752
|
draw();
|
|
667
753
|
};
|
|
@@ -698,15 +784,18 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
698
784
|
applySearch();
|
|
699
785
|
const ci = view.indexOf(created);
|
|
700
786
|
cursor = ci >= 0 ? ci : 0;
|
|
787
|
+
current = created.id;
|
|
701
788
|
} else if (!row.new) {
|
|
702
789
|
current = row.id;
|
|
703
790
|
}
|
|
704
791
|
inspected = false;
|
|
705
792
|
confirmDel = null;
|
|
793
|
+
confirmAll = false;
|
|
706
794
|
draw();
|
|
707
795
|
}).catch((err) => {
|
|
708
796
|
process.stderr.write(`\nError: ${err.message}\n`);
|
|
709
797
|
confirmDel = null;
|
|
798
|
+
confirmAll = false;
|
|
710
799
|
draw();
|
|
711
800
|
});
|
|
712
801
|
};
|
|
@@ -715,11 +804,31 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
715
804
|
const s = confirmDel;
|
|
716
805
|
if (!s || !onDelete) return;
|
|
717
806
|
const wasBroadcast = mode === 'broadcast-confirm';
|
|
718
|
-
|
|
719
|
-
|
|
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)));
|
|
720
818
|
applySearch();
|
|
721
819
|
if (cursor >= view.length && cursor > 0) cursor = view.length - 1;
|
|
722
|
-
|
|
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
|
+
}
|
|
723
832
|
if (wasBroadcast) {
|
|
724
833
|
broadcastSel.delete(String(s.id));
|
|
725
834
|
if (broadcastSel.size < 2) {
|
|
@@ -735,15 +844,32 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
735
844
|
} else {
|
|
736
845
|
persistBroadcast([...broadcastSel]);
|
|
737
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
|
+
}
|
|
738
853
|
draw();
|
|
739
854
|
}
|
|
740
855
|
} else {
|
|
741
856
|
mode = 'normal';
|
|
742
|
-
|
|
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
|
+
}
|
|
743
868
|
}
|
|
744
869
|
}).catch((err) => {
|
|
745
870
|
process.stderr.write(`\nError: ${err.message}\n`);
|
|
746
871
|
confirmDel = null;
|
|
872
|
+
confirmAll = false;
|
|
747
873
|
mode = wasBroadcast ? 'broadcast' : 'normal';
|
|
748
874
|
draw();
|
|
749
875
|
});
|
|
@@ -802,7 +928,9 @@ const toggleBroadcast = (idx) => {
|
|
|
802
928
|
draw();
|
|
803
929
|
};
|
|
804
930
|
|
|
805
|
-
|
|
931
|
+
// `startCursor` lets a picker open with the cursor already on the relevant
|
|
932
|
+
// entry (the model pickers open on the current model).
|
|
933
|
+
const openList = (title, items, format, onEnter, returnMode = 'normal', startCursor = 0) => {
|
|
806
934
|
if (!items || items.length === 0) {
|
|
807
935
|
process.stderr.write(`\nNo ${title.toLowerCase()} available.\n`);
|
|
808
936
|
draw();
|
|
@@ -810,7 +938,7 @@ const toggleBroadcast = (idx) => {
|
|
|
810
938
|
}
|
|
811
939
|
list = { title, items, view: items, format, onEnter, returnMode };
|
|
812
940
|
listSearch = newSearchState();
|
|
813
|
-
listCursor = 0;
|
|
941
|
+
listCursor = Math.max(0, Math.min(startCursor, items.length - 1));
|
|
814
942
|
mode = 'list';
|
|
815
943
|
draw();
|
|
816
944
|
};
|
|
@@ -820,6 +948,16 @@ const toggleBroadcast = (idx) => {
|
|
|
820
948
|
draw();
|
|
821
949
|
};
|
|
822
950
|
|
|
951
|
+
// Open the model picker with the list sorted by provider and the cursor
|
|
952
|
+
// already on `session`'s current model (so `m` starts where the change is).
|
|
953
|
+
const openModelPicker = (title, session, returnMode, onPick) => {
|
|
954
|
+
listModels(server).then((models) => {
|
|
955
|
+
const sorted = sortModels(models);
|
|
956
|
+
openList(title, sorted, (x) => `${x.label}${x.variant ? `#${x.variant}` : ''}`,
|
|
957
|
+
onPick, returnMode, indexOfModel(sorted, session && session.model));
|
|
958
|
+
}).catch(commandErr);
|
|
959
|
+
};
|
|
960
|
+
|
|
823
961
|
process.stdin.on('keypress', (str, key) => {
|
|
824
962
|
// Ctrl+C always fully exits ocmux, from any menu.
|
|
825
963
|
if (key.ctrl && key.name === 'c') {
|
|
@@ -839,6 +977,7 @@ const toggleBroadcast = (idx) => {
|
|
|
839
977
|
if (key.name === 'y') return applyDelete();
|
|
840
978
|
if (key.name === 'n' || key.name === 'escape' || key.name === 'q' || key.name === 'return') {
|
|
841
979
|
confirmDel = null;
|
|
980
|
+
confirmAll = false;
|
|
842
981
|
mode = mode === 'broadcast-confirm' ? 'broadcast' : 'normal';
|
|
843
982
|
draw();
|
|
844
983
|
}
|
|
@@ -943,6 +1082,15 @@ const toggleBroadcast = (idx) => {
|
|
|
943
1082
|
draw();
|
|
944
1083
|
} else if (key.name === 'space') {
|
|
945
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
|
+
}
|
|
946
1094
|
} else if (key.name === 'd') {
|
|
947
1095
|
if (cursor < view.length) {
|
|
948
1096
|
confirmDel = view[cursor];
|
|
@@ -950,11 +1098,9 @@ const toggleBroadcast = (idx) => {
|
|
|
950
1098
|
draw();
|
|
951
1099
|
}
|
|
952
1100
|
} else if (key.name === 'm') {
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
}, 'broadcast');
|
|
957
|
-
}).catch(commandErr);
|
|
1101
|
+
openModelPicker('Broadcast model (all selected sessions)', view[cursor], 'broadcast', async (item) => {
|
|
1102
|
+
await Promise.all([...broadcastSel].map(id => switchModel(server, id, item)));
|
|
1103
|
+
});
|
|
958
1104
|
} else if (key.name === 'return' || key.name === 'enter') {
|
|
959
1105
|
if (cursor < view.length) {
|
|
960
1106
|
mode = 'normal';
|
|
@@ -978,18 +1124,18 @@ const toggleBroadcast = (idx) => {
|
|
|
978
1124
|
return;
|
|
979
1125
|
}
|
|
980
1126
|
if (key.name === 'n') {
|
|
981
|
-
startInput('New session name (blank
|
|
1127
|
+
startInput('New session name (blank = auto):', 'Enter: create · Esc: cancel', '', 'create');
|
|
982
1128
|
return;
|
|
983
1129
|
}
|
|
984
1130
|
// Shift+r → Reminder; lowercase r → Rename.
|
|
985
1131
|
if (str === 'R') {
|
|
986
1132
|
const row = view[cursor];
|
|
987
1133
|
const note = (row && annotations && annotations[row.id]) || '';
|
|
988
|
-
startInput('Reminder
|
|
1134
|
+
startInput('Reminder (blank clears):', 'Enter: save · Esc: cancel', note, 'reminder');
|
|
989
1135
|
return;
|
|
990
1136
|
}
|
|
991
1137
|
if (key.name === 'r' && cursor < view.length) {
|
|
992
|
-
startInput('Rename to
|
|
1138
|
+
startInput('Rename to:', 'Enter: rename · Esc: cancel', view[cursor].title || '', 'rename');
|
|
993
1139
|
return;
|
|
994
1140
|
}
|
|
995
1141
|
if (key.name === 'a') {
|
|
@@ -1006,12 +1152,7 @@ const toggleBroadcast = (idx) => {
|
|
|
1006
1152
|
}
|
|
1007
1153
|
if (key.name === 'm') {
|
|
1008
1154
|
const target = view[cursor];
|
|
1009
|
-
if (target)
|
|
1010
|
-
listModels(server).then((models) => {
|
|
1011
|
-
openList('Models', models, (x) => `${x.label}${x.variant ? `#${x.variant}` : ''}`, (item) =>
|
|
1012
|
-
switchModel(server, target.id, item));
|
|
1013
|
-
}).catch(commandErr);
|
|
1014
|
-
}
|
|
1155
|
+
if (target) openModelPicker('Models', target, 'normal', (item) => switchModel(server, target.id, item));
|
|
1015
1156
|
return;
|
|
1016
1157
|
}
|
|
1017
1158
|
if (key.name === 'd') {
|
|
@@ -1053,8 +1194,19 @@ const toggleBroadcast = (idx) => {
|
|
|
1053
1194
|
});
|
|
1054
1195
|
}
|
|
1055
1196
|
|
|
1056
|
-
// Interactive project picker —
|
|
1057
|
-
|
|
1197
|
+
// Interactive project picker — never writes state (it only moves what a
|
|
1198
|
+
// registered dedicated/shared TUI displays).
|
|
1199
|
+
//
|
|
1200
|
+
// Default (safe): an INSPECTOR. Selecting a project/session row still focuses
|
|
1201
|
+
// the routed TUI and shows the session there, but the promise always resolves to
|
|
1202
|
+
// null, so the caller re-opens its own project's list. Nothing about the
|
|
1203
|
+
// caller's project changes.
|
|
1204
|
+
//
|
|
1205
|
+
// `{ allProjects: true }` (CLI `--all-projects`): a real SWITCHER. Selecting a
|
|
1206
|
+
// row also records the target directory, so the caller moves its picker there
|
|
1207
|
+
// and can then write that project's `.ocmux.json` — the file `agentp` reads in
|
|
1208
|
+
// that directory.
|
|
1209
|
+
function switchMenu(rows, { allProjects = false, onOpen } = {}) {
|
|
1058
1210
|
return new Promise((resolve) => {
|
|
1059
1211
|
if (!process.stdin.isTTY) {
|
|
1060
1212
|
console.error('Error: "switch" is interactive and requires a TTY.');
|
|
@@ -1074,22 +1226,53 @@ function switchMenu(rows, longMode) {
|
|
|
1074
1226
|
const lineWidth = Math.max(cols, nameW + 2 + stsW + 2 + ssnW + 6);
|
|
1075
1227
|
|
|
1076
1228
|
let cursor = 0;
|
|
1077
|
-
let
|
|
1078
|
-
let lastDir = null;
|
|
1229
|
+
let lastDir = null; // target dir to move to (only with allProjects)
|
|
1079
1230
|
let done = false;
|
|
1080
1231
|
let help = false;
|
|
1081
1232
|
const search = newSearchState();
|
|
1082
|
-
let view =
|
|
1233
|
+
let view = []; // flattened nodes: { kind:'project'|'session', ... }
|
|
1234
|
+
// Fold state per project directory: { open, sessions, loading, error }.
|
|
1235
|
+
const folds = new Map();
|
|
1236
|
+
|
|
1237
|
+
const foldOf = (p) => folds.get(p.dir) || { open: false, sessions: null, loading: false, error: null };
|
|
1238
|
+
const projectText = (p) => `${path.basename(p.dir)} ${p.dir} ${p.session || ''}`;
|
|
1239
|
+
const sessionText = (s) => `${s.title || ''} ${s.id || ''}`;
|
|
1240
|
+
const matches = (pattern, text) => filterByPattern([1], pattern, () => text).length > 0;
|
|
1241
|
+
|
|
1242
|
+
// Flatten projects (plus their unfolded sessions) honouring the '/' pattern.
|
|
1243
|
+
// A project header survives when it matches itself OR when one of its
|
|
1244
|
+
// unfolded sessions does, so a match deeper down is still navigable.
|
|
1245
|
+
const buildView = () => {
|
|
1246
|
+
const pattern = search.active ? search.value : '';
|
|
1247
|
+
const out = [];
|
|
1248
|
+
for (const p of rows) {
|
|
1249
|
+
const f = foldOf(p);
|
|
1250
|
+
const sessions = f.open && f.sessions ? f.sessions : [];
|
|
1251
|
+
const shown = sessions.filter((s) => matches(pattern, sessionText(s)));
|
|
1252
|
+
const header = { kind: 'project', project: p };
|
|
1253
|
+
if (sessions.length === 0) {
|
|
1254
|
+
if (matches(pattern, projectText(p))) out.push(header);
|
|
1255
|
+
} else if (matches(pattern, projectText(p)) || shown.length) {
|
|
1256
|
+
out.push(header);
|
|
1257
|
+
for (const s of shown) out.push({ kind: 'session', project: p, session: s });
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return out;
|
|
1261
|
+
};
|
|
1083
1262
|
|
|
1084
|
-
const
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
if (cursor >= view.length) cursor = Math.max(0, view.length - 1);
|
|
1263
|
+
const sameNode = (a, b) => {
|
|
1264
|
+
if (!a || !b || a.kind !== b.kind || a.project.dir !== b.project.dir) return false;
|
|
1265
|
+
return a.kind !== 'session' || a.session.id === b.session.id;
|
|
1088
1266
|
};
|
|
1089
1267
|
|
|
1090
|
-
const
|
|
1091
|
-
|
|
1268
|
+
const applySearch = () => {
|
|
1269
|
+
const prev = view[cursor];
|
|
1270
|
+
view = buildView();
|
|
1271
|
+
const idx = prev ? view.findIndex((n) => sameNode(n, prev)) : -1;
|
|
1272
|
+
if (idx >= 0) cursor = idx;
|
|
1273
|
+
else if (cursor >= view.length) cursor = Math.max(0, view.length - 1);
|
|
1092
1274
|
};
|
|
1275
|
+
applySearch();
|
|
1093
1276
|
|
|
1094
1277
|
const cleanup = () => {
|
|
1095
1278
|
process.stderr.write('\x1b[?25h');
|
|
@@ -1108,53 +1291,130 @@ function switchMenu(rows, longMode) {
|
|
|
1108
1291
|
resolve(value === undefined ? lastDir : value);
|
|
1109
1292
|
};
|
|
1110
1293
|
|
|
1294
|
+
// Mode-dependent wording: the switcher inspects by default and really
|
|
1295
|
+
// switches only under --all-projects.
|
|
1296
|
+
const switcherTitle = allProjects ? 'ocmux — project switcher (all projects)' : 'ocmux — project switcher';
|
|
1297
|
+
const enterHint = allProjects ? 'Enter: switch' : 'Enter: view';
|
|
1298
|
+
const modeNote = allProjects
|
|
1299
|
+
? ['--all-projects: selecting moves the picker to that project, so a',
|
|
1300
|
+
'session picked there updates ITS .ocmux.json — the file agentp',
|
|
1301
|
+
'reads when it runs in that directory.']
|
|
1302
|
+
: ['Selecting only moves the TUI view — .ocmux.json is never',
|
|
1303
|
+
'written, so agentp keeps prompting your own project’s session.',
|
|
1304
|
+
'You always come back to your own project’s list.'];
|
|
1305
|
+
|
|
1111
1306
|
const draw = () => {
|
|
1307
|
+
if (done) return;
|
|
1112
1308
|
if (help) {
|
|
1113
1309
|
let out = '\x1b[2J\x1b[H';
|
|
1114
1310
|
out += 'ocmux project switcher — help\n\n';
|
|
1115
|
-
out += ' j / k, arrows move the cursor\n';
|
|
1116
|
-
out +=
|
|
1311
|
+
out += ' j / k, arrows move the cursor (projects and sessions)\n';
|
|
1312
|
+
out += ` Enter ${allProjects ? 'switch the picker to the selected project' : 'focus the selected project (menu stays open)'}\n`;
|
|
1313
|
+
out += ' (on a session, show it in that project’s TUI)\n';
|
|
1314
|
+
out += ' Space fold/unfold a project’s sessions;\n';
|
|
1315
|
+
out += ' on a session, show it in the TUI as well\n';
|
|
1117
1316
|
out += ' / search/filter the list (Enter keeps, ESC clears)\n';
|
|
1118
1317
|
out += ' h toggle this help\n';
|
|
1119
1318
|
out += ' q return to the session menu\n';
|
|
1120
1319
|
out += ' Ctrl+C quit ocmux\n';
|
|
1320
|
+
out += '\n';
|
|
1321
|
+
for (const l of modeNote) out += `${l}\n`;
|
|
1121
1322
|
out += '\nPress any key to return.\n';
|
|
1122
1323
|
process.stderr.write(out);
|
|
1123
1324
|
return;
|
|
1124
1325
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1326
|
+
const extras = [];
|
|
1327
|
+
if (search.active && view.length === 0) extras.push(`No projects match "${search.value}"`);
|
|
1328
|
+
for (const n of view) {
|
|
1329
|
+
if (n.kind !== 'project') continue;
|
|
1330
|
+
const f = folds.get(n.project.dir);
|
|
1331
|
+
if (!f || !f.open) continue;
|
|
1332
|
+
const name = path.basename(n.project.dir);
|
|
1333
|
+
if (f.loading) extras.push(` … loading sessions of ${name} …`);
|
|
1334
|
+
else if (f.error) extras.push(` ! ${name}: ${f.error}`);
|
|
1335
|
+
}
|
|
1127
1336
|
process.stderr.write(renderList({
|
|
1128
|
-
title:
|
|
1337
|
+
title: switcherTitle,
|
|
1129
1338
|
items: view,
|
|
1130
1339
|
cursor,
|
|
1131
|
-
row: (i,
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1340
|
+
row: (i, node) => {
|
|
1341
|
+
if (node.kind === 'session') {
|
|
1342
|
+
const s = node.session;
|
|
1343
|
+
const id = String(s.id || '');
|
|
1344
|
+
const mark = String(s.id) === String(node.project.session) ? ' *' : '';
|
|
1345
|
+
const title = (s.title || id).slice(0, Math.max(8, nameW + 6));
|
|
1346
|
+
return ` ${title}${mark} ${id}`;
|
|
1347
|
+
}
|
|
1348
|
+
const p = node.project;
|
|
1349
|
+
const fold = foldOf(p).open ? '▾' : '▸';
|
|
1350
|
+
const session = p.session ? p.session.slice(0, 14) : '(none)';
|
|
1351
|
+
const text = `${fold} ${pad(path.basename(p.dir), nameW)} ${pad(p.status, stsW)} ${pad(session, ssnW)} ${p.dir}`;
|
|
1352
|
+
return text;
|
|
1135
1353
|
},
|
|
1136
|
-
footer: searchFooter(search,
|
|
1354
|
+
footer: searchFooter(search, `${enterHint} · Space: fold · /: search · h: help · q: back · Ctrl+C: quit`),
|
|
1137
1355
|
footerLeft: search.active,
|
|
1138
1356
|
footerRight: search.active ? SEARCH_HINT : null,
|
|
1139
1357
|
extraLines: extras,
|
|
1140
1358
|
}));
|
|
1141
1359
|
};
|
|
1142
1360
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1361
|
+
// Enter on a project row: show its current session in its routed TUI. With
|
|
1362
|
+
// allProjects off this stays a pure inspection and
|
|
1363
|
+
// `lastDir` is left null so the caller returns to its own project.
|
|
1364
|
+
const activate = (p) => {
|
|
1365
|
+
if (!p) return;
|
|
1145
1366
|
try {
|
|
1146
|
-
if (
|
|
1147
|
-
|
|
1148
|
-
} else {
|
|
1149
|
-
tmux(['select-window', '-t', `${SESSION}:${row.index}`]);
|
|
1150
|
-
}
|
|
1151
|
-
lastDir = row.dir;
|
|
1367
|
+
if (p.server && p.session && onOpen) onOpen(p, p.session);
|
|
1368
|
+
if (allProjects) lastDir = p.dir;
|
|
1152
1369
|
} catch (err) {
|
|
1153
1370
|
process.stderr.write(`\nError: ${err.message}\n`);
|
|
1154
1371
|
}
|
|
1155
1372
|
draw();
|
|
1156
1373
|
};
|
|
1157
1374
|
|
|
1375
|
+
// Enter/Space on a session row: move the TUI VIEW onto it. The switcher
|
|
1376
|
+
// itself never writes `.ocmux.json`; with allProjects on the caller only
|
|
1377
|
+
// moves its picker to that project — the write happens later, when a
|
|
1378
|
+
// session is picked there (and that file is what agentp reads there).
|
|
1379
|
+
const openSession = (node) => {
|
|
1380
|
+
const p = node.project;
|
|
1381
|
+
try {
|
|
1382
|
+
if (p.server && onOpen) onOpen(p, node.session.id);
|
|
1383
|
+
if (allProjects) lastDir = p.dir;
|
|
1384
|
+
} catch (err) {
|
|
1385
|
+
process.stderr.write(`\nError: ${err.message}\n`);
|
|
1386
|
+
}
|
|
1387
|
+
draw();
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
// Space on a project row: fetch its sessions once, then flip the fold.
|
|
1391
|
+
const toggleFold = (p) => {
|
|
1392
|
+
const f = folds.get(p.dir) || { open: false, sessions: null, loading: false, error: null };
|
|
1393
|
+
folds.set(p.dir, f);
|
|
1394
|
+
if (!f.open && !f.sessions && !f.error && !f.loading) {
|
|
1395
|
+
if (!p.server) {
|
|
1396
|
+
f.error = 'no server recorded';
|
|
1397
|
+
} else {
|
|
1398
|
+
f.loading = true;
|
|
1399
|
+
f.open = true;
|
|
1400
|
+
applySearch();
|
|
1401
|
+
draw();
|
|
1402
|
+
listSessions(p.server, p.dir)
|
|
1403
|
+
.then((list) => {
|
|
1404
|
+
// Main sessions only: child/subagent sessions are work products,
|
|
1405
|
+
// not navigation targets (same rule as the session picker).
|
|
1406
|
+
f.sessions = sortSessionsByRecency((list || []).filter((s) => !s.parentID));
|
|
1407
|
+
})
|
|
1408
|
+
.catch((err) => { f.error = err.message; })
|
|
1409
|
+
.then(() => { f.loading = false; applySearch(); draw(); });
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
f.open = !f.open;
|
|
1414
|
+
applySearch();
|
|
1415
|
+
draw();
|
|
1416
|
+
};
|
|
1417
|
+
|
|
1158
1418
|
const onSig = () => finish(SWITCH_QUIT);
|
|
1159
1419
|
process.on('SIGINT', onSig);
|
|
1160
1420
|
|
|
@@ -1167,14 +1427,19 @@ function switchMenu(rows, longMode) {
|
|
|
1167
1427
|
if (key.name === 'h') { help = true; draw(); return; }
|
|
1168
1428
|
if (key.name === 'q') return finish();
|
|
1169
1429
|
if (view.length === 0) return;
|
|
1430
|
+
const node = view[cursor];
|
|
1170
1431
|
if (key.name === 'up' || str === 'k') {
|
|
1171
1432
|
cursor = (cursor + view.length - 1) % view.length;
|
|
1172
1433
|
draw();
|
|
1173
1434
|
} else if (key.name === 'down' || str === 'j') {
|
|
1174
1435
|
cursor = (cursor + 1) % view.length;
|
|
1175
1436
|
draw();
|
|
1176
|
-
} else if (key.name === 'return' || key.name === 'enter'
|
|
1177
|
-
activate(
|
|
1437
|
+
} else if (key.name === 'return' || key.name === 'enter') {
|
|
1438
|
+
if (node.kind === 'project') activate(node.project);
|
|
1439
|
+
else openSession(node);
|
|
1440
|
+
} else if (key.name === 'space') {
|
|
1441
|
+
if (node.kind === 'project') toggleFold(node.project);
|
|
1442
|
+
else openSession(node);
|
|
1178
1443
|
}
|
|
1179
1444
|
});
|
|
1180
1445
|
|
|
@@ -1191,17 +1456,22 @@ function switchMenu(rows, longMode) {
|
|
|
1191
1456
|
// Subcommand: default — interactive session switcher for the current project
|
|
1192
1457
|
// ---------------------------------------------------------------------------
|
|
1193
1458
|
|
|
1194
|
-
|
|
1459
|
+
// `allProjects` (CLI `--all-projects`) unlocks cross-project switching: the
|
|
1460
|
+
// project switcher may then move this picker onto another project, whose
|
|
1461
|
+
// `.ocmux.json` gets written when a session is picked there. Without it the
|
|
1462
|
+
// switcher is inspection-only and this command always stays on `targetDir`'s
|
|
1463
|
+
// project.
|
|
1464
|
+
async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
1195
1465
|
let target = targetDir;
|
|
1196
1466
|
for (;;) {
|
|
1197
1467
|
const sf = findParentStatefile(target);
|
|
1198
1468
|
if (!sf) {
|
|
1199
1469
|
console.error(`Error: no .ocmux.json found in ${target} or any of its parents.`);
|
|
1200
|
-
|
|
1470
|
+
console.error(`Run 'ocmux serve' to initialize the project.`);
|
|
1201
1471
|
process.exit(1);
|
|
1202
1472
|
}
|
|
1203
1473
|
const state = projectState.readProjectState(sf);
|
|
1204
|
-
|
|
1474
|
+
let server = state.server;
|
|
1205
1475
|
if (!server) {
|
|
1206
1476
|
console.error(`Error: ${sf} does not record a server.`);
|
|
1207
1477
|
console.error(`Run 'ocmux serve --server <url>' to set it up, or add "server" to the file.`);
|
|
@@ -1209,29 +1479,43 @@ async function cmdSessionSwitch(targetDir, directRef) {
|
|
|
1209
1479
|
}
|
|
1210
1480
|
const dir = state.directory;
|
|
1211
1481
|
|
|
1212
|
-
|
|
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
|
+
}
|
|
1213
1495
|
if (!alive) {
|
|
1214
1496
|
console.error(`Error: OpenCode server not reachable at ${server}.`);
|
|
1215
1497
|
console.error(`Start it with 'opencode serve' (or your usual launcher) first.`);
|
|
1216
1498
|
process.exit(1);
|
|
1217
1499
|
}
|
|
1218
1500
|
|
|
1219
|
-
const winIdx = windowByDir(dir);
|
|
1220
|
-
if (winIdx == null) {
|
|
1221
|
-
console.error(`Error: no tmux window for ${dir}.`);
|
|
1222
|
-
console.error(`Run 'ocmux serve' to create it.`);
|
|
1223
|
-
process.exit(1);
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
// Focus this project's window up-front so the picker (and any session
|
|
1227
|
-
// switch) never leaves another project's window selected.
|
|
1228
|
-
activateProject(dir, winIdx, server, state.session);
|
|
1229
|
-
|
|
1230
1501
|
const sessions = await listSessions(server, dir).catch((err) => {
|
|
1231
1502
|
console.error(`Error connecting to ${server}: ${err.message}`);
|
|
1232
1503
|
process.exit(1);
|
|
1233
1504
|
});
|
|
1234
1505
|
const sorted = sortSessionsByRecency(sessions);
|
|
1506
|
+
const notices = [];
|
|
1507
|
+
if (state.session && !sorted.some(s => String(s.id) === String(state.session))) {
|
|
1508
|
+
try {
|
|
1509
|
+
const moved = await getSession(server, state.session);
|
|
1510
|
+
const movedDir = moved && moved.location && moved.location.directory;
|
|
1511
|
+
if (movedDir && path.resolve(movedDir) !== path.resolve(dir)) {
|
|
1512
|
+
notices.push(`⚠ Current session moved to ${movedDir}`);
|
|
1513
|
+
notices.push(` Run: ocmux serve ${JSON.stringify(movedDir)} --server ${JSON.stringify(server)}`);
|
|
1514
|
+
}
|
|
1515
|
+
} catch {
|
|
1516
|
+
// A deleted/stale stored session is handled by the normal picker.
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1235
1519
|
// Only "main" sessions are listed in the picker: exclude child/subagent
|
|
1236
1520
|
// sessions (parentID set) — they are work products of a main session, not
|
|
1237
1521
|
// targets. Explicit --session can still reach one by id/title.
|
|
@@ -1246,7 +1530,7 @@ async function cmdSessionSwitch(targetDir, directRef) {
|
|
|
1246
1530
|
|
|
1247
1531
|
const applySession = (id) => {
|
|
1248
1532
|
projectState.writeProjectState(sf, { session: id });
|
|
1249
|
-
|
|
1533
|
+
refreshTui(dir, server, id);
|
|
1250
1534
|
};
|
|
1251
1535
|
|
|
1252
1536
|
if (directRef) {
|
|
@@ -1262,8 +1546,7 @@ async function cmdSessionSwitch(targetDir, directRef) {
|
|
|
1262
1546
|
}
|
|
1263
1547
|
|
|
1264
1548
|
if (!process.stdin.isTTY) {
|
|
1265
|
-
// Silent path (non-TTY):
|
|
1266
|
-
activateProject(dir, winIdx, server, state.session);
|
|
1549
|
+
// Silent path (non-TTY): project state already identifies the target.
|
|
1267
1550
|
return;
|
|
1268
1551
|
}
|
|
1269
1552
|
|
|
@@ -1271,12 +1554,20 @@ async function cmdSessionSwitch(targetDir, directRef) {
|
|
|
1271
1554
|
// model of the previously selected session); r renames; R sets a reminder;
|
|
1272
1555
|
// d deletes; a switches agent; m switches model; p switches project; q quits.
|
|
1273
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
|
+
}
|
|
1274
1563
|
const result = await new Promise((resolve) => {
|
|
1275
1564
|
sessionMenu(mainSessions, state.session, annotations, server, {
|
|
1565
|
+
dir,
|
|
1276
1566
|
runningIds,
|
|
1277
1567
|
modelContext,
|
|
1568
|
+
notices,
|
|
1278
1569
|
onBroadcastChange: (ids) => projectState.writeProjectState(sf, { broadcast: ids }),
|
|
1279
|
-
onInspectSession: (id) =>
|
|
1570
|
+
onInspectSession: (id) => refreshTui(dir, server, id),
|
|
1280
1571
|
onPick: async (row) => {
|
|
1281
1572
|
if (row.new) {
|
|
1282
1573
|
const created = await createSessionWithModel(server, row.name || undefined, dir, [lastRef, ...sorted]).catch((err) => {
|
|
@@ -1317,10 +1608,18 @@ async function cmdSessionSwitch(targetDir, directRef) {
|
|
|
1317
1608
|
});
|
|
1318
1609
|
|
|
1319
1610
|
if (result === 'switch-project') {
|
|
1320
|
-
// 'p' pressed: open the project switcher.
|
|
1321
|
-
//
|
|
1322
|
-
|
|
1323
|
-
|
|
1611
|
+
// 'p' pressed: open the project switcher. Without --all-projects it only
|
|
1612
|
+
// inspects (picked stays null => re-run this picker for the same
|
|
1613
|
+
// project); with it, picking a project re-runs the picker there.
|
|
1614
|
+
// Ctrl+C fully exits.
|
|
1615
|
+
const rows = await configuredProjects(server).catch((err) => {
|
|
1616
|
+
console.error(`Error listing projects: ${err.message}`);
|
|
1617
|
+
return [];
|
|
1618
|
+
});
|
|
1619
|
+
const picked = await switchMenu(rows, {
|
|
1620
|
+
allProjects,
|
|
1621
|
+
onOpen: (project, id) => refreshTui(project.dir, project.server, id),
|
|
1622
|
+
});
|
|
1324
1623
|
if (picked === SWITCH_QUIT) break;
|
|
1325
1624
|
if (!picked) continue;
|
|
1326
1625
|
target = picked;
|
|
@@ -1331,7 +1630,7 @@ async function cmdSessionSwitch(targetDir, directRef) {
|
|
|
1331
1630
|
}
|
|
1332
1631
|
|
|
1333
1632
|
// ---------------------------------------------------------------------------
|
|
1334
|
-
// Subcommand: serve —
|
|
1633
|
+
// Subcommand: serve — initialize project/session state (server is user-managed)
|
|
1335
1634
|
// ---------------------------------------------------------------------------
|
|
1336
1635
|
|
|
1337
1636
|
async function cmdServeNew(targetDir, serverArg, force) {
|
|
@@ -1340,7 +1639,7 @@ async function cmdServeNew(targetDir, serverArg, force) {
|
|
|
1340
1639
|
const sf = path.join(targetDir, '.ocmux.json');
|
|
1341
1640
|
if (fs.existsSync(sf)) {
|
|
1342
1641
|
if (!force) {
|
|
1343
|
-
console.error(`Error:
|
|
1642
|
+
console.error(`Error: project state already exists in ${targetDir}.`);
|
|
1344
1643
|
console.error(`Use 'ocmux' to switch sessions there, or 'ocmux serve --server <url> --force' to repoint it.`);
|
|
1345
1644
|
process.exit(1);
|
|
1346
1645
|
}
|
|
@@ -1350,11 +1649,10 @@ async function cmdServeNew(targetDir, serverArg, force) {
|
|
|
1350
1649
|
const parent = findParentStatefile(path.dirname(targetDir));
|
|
1351
1650
|
if (parent) {
|
|
1352
1651
|
const parentDir = path.dirname(parent);
|
|
1353
|
-
console.error(`Warning: there is already an
|
|
1354
|
-
console.error(`Run 'ocmux' and press 'p' to move between project windows.`);
|
|
1652
|
+
console.error(`Warning: there is already an ocmux project in ${parentDir}.`);
|
|
1355
1653
|
|
|
1356
1654
|
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
1357
|
-
rl.question('Are you sure you want to
|
|
1655
|
+
rl.question('Are you sure you want to initialize a nested project here? [y/N] ', (answer) => {
|
|
1358
1656
|
rl.close();
|
|
1359
1657
|
if (!/^[yY]([eE][sS])?$/.test(answer.trim())) {
|
|
1360
1658
|
console.error('Cancelled.');
|
|
@@ -1370,8 +1668,8 @@ async function cmdServeNew(targetDir, serverArg, force) {
|
|
|
1370
1668
|
return doServeNew(targetDir, serverArg);
|
|
1371
1669
|
}
|
|
1372
1670
|
|
|
1373
|
-
// Repoint an existing project to a (possibly new) server
|
|
1374
|
-
//
|
|
1671
|
+
// Repoint an existing project to a (possibly new) server. A registered TUI is
|
|
1672
|
+
// refreshed through the normal dedicated → shared routing rule.
|
|
1375
1673
|
async function doForceServe(targetDir, sf, server) {
|
|
1376
1674
|
const alive = await checkServer(server);
|
|
1377
1675
|
if (!alive) {
|
|
@@ -1392,14 +1690,8 @@ async function doForceServe(targetDir, sf, server) {
|
|
|
1392
1690
|
session = created.id;
|
|
1393
1691
|
}
|
|
1394
1692
|
projectState.writeProjectState(sf, { server, session });
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
relaunchTui(winIdx, targetDir, server, session);
|
|
1398
|
-
console.error(`Repointed ${targetDir} to ${server} (session ${session}).`);
|
|
1399
|
-
} else {
|
|
1400
|
-
createProjectWindow(targetDir, server, session);
|
|
1401
|
-
console.error(`Recreated project window for ${targetDir} → ${server} (session ${session}).`);
|
|
1402
|
-
}
|
|
1693
|
+
refreshTui(targetDir, server, session);
|
|
1694
|
+
console.error(`Repointed ${targetDir} to ${server} (session ${session}).`);
|
|
1403
1695
|
}
|
|
1404
1696
|
|
|
1405
1697
|
async function doServeNew(targetDir, serverArg) {
|
|
@@ -1431,108 +1723,183 @@ async function doServeNew(targetDir, serverArg) {
|
|
|
1431
1723
|
}
|
|
1432
1724
|
}
|
|
1433
1725
|
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1726
|
+
projectState.writeProjectState(path.join(targetDir, '.ocmux.json'), {
|
|
1727
|
+
directory: targetDir,
|
|
1728
|
+
server,
|
|
1729
|
+
session,
|
|
1730
|
+
status: 'active',
|
|
1731
|
+
});
|
|
1732
|
+
refreshTui(targetDir, server, session);
|
|
1733
|
+
console.error(`Initialized ${targetDir} → ${server} (session ${session}).`);
|
|
1441
1734
|
}
|
|
1442
1735
|
|
|
1443
1736
|
// ---------------------------------------------------------------------------
|
|
1444
|
-
// Subcommand:
|
|
1737
|
+
// Subcommand: tui — register this tmux pane and host an OpenCode TUI
|
|
1445
1738
|
// ---------------------------------------------------------------------------
|
|
1446
1739
|
|
|
1447
|
-
function
|
|
1448
|
-
const
|
|
1449
|
-
if (
|
|
1450
|
-
console.
|
|
1451
|
-
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
const dirW = Math.max(...rows.map(r => r.dir.length), 9);
|
|
1455
|
-
const stsW = Math.max(...rows.map(r => r.status.length), 6);
|
|
1456
|
-
const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
|
|
1457
|
-
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
|
1458
|
-
|
|
1459
|
-
console.log(`${pad('Directory', dirW)} ${pad('Status', stsW)} ${pad('Session', ssnW)}`);
|
|
1460
|
-
for (const row of rows) {
|
|
1461
|
-
console.log(`${pad(row.dir, dirW)} ${pad(row.status, stsW)} ${pad(row.session || '(none)', ssnW)}`);
|
|
1462
|
-
if (longMode && row.server) console.log(` server: ${row.server}`);
|
|
1740
|
+
async function cmdTui(targetDir, serverArg, shared, sessionArg) {
|
|
1741
|
+
const tmuxTarget = tuiRegistry.parseTmuxEnvironment();
|
|
1742
|
+
if (!tmuxTarget) {
|
|
1743
|
+
console.error("Error: 'ocmux tui' must run inside a tmux pane.");
|
|
1744
|
+
process.exit(1);
|
|
1463
1745
|
}
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
// ---------------------------------------------------------------------------
|
|
1467
|
-
// Subcommand: kill — close the project window but keep .ocmux.json
|
|
1468
|
-
// ---------------------------------------------------------------------------
|
|
1469
|
-
|
|
1470
|
-
function cmdKill(targetDir) {
|
|
1471
1746
|
const sf = findParentStatefile(targetDir);
|
|
1472
|
-
|
|
1747
|
+
const state = sf ? projectState.readProjectState(sf) : null;
|
|
1748
|
+
if (!state && !shared) {
|
|
1473
1749
|
console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
|
|
1750
|
+
console.error("Run 'ocmux serve' first.");
|
|
1474
1751
|
process.exit(1);
|
|
1475
1752
|
}
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
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;
|
|
1757
|
+
if (!await checkServer(server)) {
|
|
1758
|
+
console.error(`Error: OpenCode server not reachable at ${server}.`);
|
|
1759
|
+
process.exit(1);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
let session = sessionArg || (state && state.session) || null;
|
|
1763
|
+
if (session) {
|
|
1480
1764
|
try {
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1765
|
+
const found = await getSession(server, session);
|
|
1766
|
+
if (found.location && found.location.directory) targetDir = found.location.directory;
|
|
1767
|
+
} catch {
|
|
1768
|
+
session = null;
|
|
1485
1769
|
}
|
|
1486
|
-
} else {
|
|
1487
|
-
console.error(`Tmux window not found for ${serverDir} (state kept).`);
|
|
1488
1770
|
}
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1771
|
+
if (!session) {
|
|
1772
|
+
const sessions = await listSessions(server, directory);
|
|
1773
|
+
session = newestSessionId(sessions);
|
|
1774
|
+
if (!session) session = (await createSessionWithModel(server, undefined, directory, sessions)).id;
|
|
1775
|
+
if (sf) projectState.writeProjectState(sf, { session, server });
|
|
1776
|
+
}
|
|
1493
1777
|
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1778
|
+
const registration = tuiRegistry.register({
|
|
1779
|
+
...tmuxTarget,
|
|
1780
|
+
shared,
|
|
1781
|
+
directory: shared ? targetDir : directory,
|
|
1782
|
+
server,
|
|
1783
|
+
session,
|
|
1784
|
+
pid: process.pid,
|
|
1785
|
+
});
|
|
1497
1786
|
|
|
1498
|
-
|
|
1787
|
+
const instance = registration.instance;
|
|
1788
|
+
const launchDir = targetDir || directory;
|
|
1789
|
+
const child = child_process.spawn('opencode', tuiArgs(server, session, launchDir), {
|
|
1790
|
+
cwd: launchDir,
|
|
1791
|
+
stdio: 'inherit',
|
|
1792
|
+
});
|
|
1793
|
+
tuiRegistry.updateInstance(instance.id, instance.token, { childPid: child.pid || null });
|
|
1794
|
+
|
|
1795
|
+
return new Promise((resolve, reject) => {
|
|
1796
|
+
let settled = false;
|
|
1797
|
+
const forward = (signal) => {
|
|
1798
|
+
try { child.kill(signal); } catch {}
|
|
1799
|
+
};
|
|
1800
|
+
const onHup = () => forward('SIGHUP');
|
|
1801
|
+
const onTerm = () => forward('SIGTERM');
|
|
1802
|
+
const onInt = () => forward('SIGINT');
|
|
1803
|
+
process.on('SIGHUP', onHup);
|
|
1804
|
+
process.on('SIGTERM', onTerm);
|
|
1805
|
+
process.on('SIGINT', onInt);
|
|
1806
|
+
const finish = (err) => {
|
|
1807
|
+
if (settled) return;
|
|
1808
|
+
settled = true;
|
|
1809
|
+
process.removeListener('SIGHUP', onHup);
|
|
1810
|
+
process.removeListener('SIGTERM', onTerm);
|
|
1811
|
+
process.removeListener('SIGINT', onInt);
|
|
1812
|
+
tuiRegistry.unregister(instance.id, instance.token);
|
|
1813
|
+
if (err) reject(err);
|
|
1814
|
+
else resolve();
|
|
1815
|
+
};
|
|
1816
|
+
child.once('error', finish);
|
|
1817
|
+
child.once('exit', () => finish());
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function tuiProjectDirectory(targetDir) {
|
|
1499
1822
|
const sf = findParentStatefile(targetDir);
|
|
1500
|
-
if (!sf)
|
|
1823
|
+
if (!sf) return null;
|
|
1824
|
+
const state = projectState.readProjectState(sf);
|
|
1825
|
+
return state && state.directory;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
function formatTuiRegistration(instance) {
|
|
1829
|
+
return `${instance.shared ? 'shared' : 'project'} ${instance.pane} ${instance.directory || '(none)'} ${instance.server || '(none)'} ${instance.session || '(none)'}`;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function cmdTuiList() {
|
|
1833
|
+
const instances = tuiRegistry.list();
|
|
1834
|
+
if (instances.length === 0) {
|
|
1835
|
+
console.log('No registered TUIs.');
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
console.log('Kind Pane Directory Server Session');
|
|
1839
|
+
for (const instance of instances) console.log(formatTuiRegistration(instance));
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
function cmdTuiStatus(targetDir, shared) {
|
|
1843
|
+
const directory = shared ? null : tuiProjectDirectory(targetDir);
|
|
1844
|
+
if (!shared && !directory) {
|
|
1501
1845
|
console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
|
|
1502
1846
|
process.exit(1);
|
|
1503
1847
|
}
|
|
1504
|
-
const
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1848
|
+
const instance = shared ? tuiRegistry.shared() : tuiRegistry.dedicated(directory);
|
|
1849
|
+
if (!instance) {
|
|
1850
|
+
console.log(shared ? 'Shared TUI: not registered' : `Project TUI: not registered (${directory})`);
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
console.log(formatTuiRegistration(instance));
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
function cmdTuiDetach(targetDir, shared) {
|
|
1857
|
+
const directory = shared ? null : tuiProjectDirectory(targetDir);
|
|
1858
|
+
if (!shared && !directory) {
|
|
1859
|
+
console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
|
|
1508
1860
|
process.exit(1);
|
|
1509
1861
|
}
|
|
1510
|
-
const
|
|
1862
|
+
const instance = shared ? tuiRegistry.shared() : tuiRegistry.dedicated(directory);
|
|
1863
|
+
if (!instance) {
|
|
1864
|
+
console.log(shared ? 'Shared TUI was not registered.' : `Project TUI was not registered (${directory}).`);
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
tuiRegistry.unregister(instance.id, instance.token);
|
|
1868
|
+
console.log(`Detached ${shared ? 'shared' : 'project'} TUI from ${instance.pane}.`);
|
|
1869
|
+
}
|
|
1511
1870
|
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1871
|
+
// ---------------------------------------------------------------------------
|
|
1872
|
+
// Subcommand: list
|
|
1873
|
+
// ---------------------------------------------------------------------------
|
|
1874
|
+
|
|
1875
|
+
async function cmdList(longMode, server) {
|
|
1876
|
+
const rows = await configuredProjects(server);
|
|
1877
|
+
if (rows.length === 0) {
|
|
1878
|
+
console.log('No configured ocmux projects found.');
|
|
1515
1879
|
return;
|
|
1516
1880
|
}
|
|
1517
1881
|
|
|
1518
|
-
const
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1882
|
+
const dirW = Math.max(...rows.map(r => r.dir.length), 9);
|
|
1883
|
+
const stsW = Math.max(...rows.map(r => r.status.length), 6);
|
|
1884
|
+
const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
|
|
1885
|
+
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
|
1886
|
+
|
|
1887
|
+
console.log(`${pad('Directory', dirW)} ${pad('Status', stsW)} ${pad('Session', ssnW)}`);
|
|
1888
|
+
for (const row of rows) {
|
|
1889
|
+
console.log(`${pad(row.dir, dirW)} ${pad(row.status, stsW)} ${pad(row.session || '(none)', ssnW)}`);
|
|
1890
|
+
if (longMode && row.server) console.log(` server: ${row.server}`);
|
|
1522
1891
|
}
|
|
1523
|
-
console.error(`Recreating project window for ${dir}...`);
|
|
1524
|
-
createProjectWindow(dir, server, state.session);
|
|
1525
1892
|
}
|
|
1526
1893
|
|
|
1527
1894
|
// ---------------------------------------------------------------------------
|
|
1528
1895
|
// Subcommand: migrate — rewrite legacy .ocmux.json files to v2
|
|
1529
1896
|
// ---------------------------------------------------------------------------
|
|
1530
1897
|
|
|
1531
|
-
function cmdMigrate() {
|
|
1532
|
-
const rows =
|
|
1898
|
+
async function cmdMigrate(server) {
|
|
1899
|
+
const rows = await configuredProjects(server);
|
|
1533
1900
|
let migrated = 0;
|
|
1534
1901
|
for (const row of rows) {
|
|
1535
|
-
const sf = statefileFor(row.dir);
|
|
1902
|
+
const sf = projectState.statefileFor(row.dir);
|
|
1536
1903
|
const state = projectState.readProjectState(sf);
|
|
1537
1904
|
if (!state) continue;
|
|
1538
1905
|
if (state.version >= 2) continue;
|
|
@@ -1553,31 +1920,44 @@ function cmdMigrate() {
|
|
|
1553
1920
|
// ---------------------------------------------------------------------------
|
|
1554
1921
|
|
|
1555
1922
|
function printHelp() {
|
|
1556
|
-
console.error(`Usage: ${SCRIPT} [-l] [<subcommand>] [<directory>]`);
|
|
1923
|
+
console.error(`Usage: ${SCRIPT} [-l] [--all-projects] [<subcommand>] [<directory>]`);
|
|
1557
1924
|
console.error('');
|
|
1558
|
-
console.error('Manage project
|
|
1559
|
-
console.error('
|
|
1925
|
+
console.error('Manage project/session routing and optional registered OpenCode TUIs.');
|
|
1926
|
+
console.error('The OpenCode server is user-managed: start it with `opencode serve`.');
|
|
1560
1927
|
console.error('');
|
|
1561
1928
|
console.error('Without arguments, searches upward from <directory> (default: $PWD)');
|
|
1562
1929
|
console.error('for .ocmux.json and opens an interactive SESSION picker for that');
|
|
1563
|
-
console.error('project (requires a TTY).
|
|
1930
|
+
console.error('project (requires a TTY). The title bar names the project whose');
|
|
1931
|
+
console.error('sessions are listed. In the picker:');
|
|
1564
1932
|
console.error(' Enter switch · n new · r rename · R remind · d delete · a agents · m model');
|
|
1565
1933
|
console.error(' p projects · h help · q quit');
|
|
1566
|
-
console.error('
|
|
1567
|
-
console.error('
|
|
1568
|
-
console.error('
|
|
1934
|
+
console.error(' In BROADCAST: Space toggles a session · d deletes cursor · D deletes all');
|
|
1935
|
+
console.error('Selecting updates .ocmux.json and refreshes a dedicated TUI, falling');
|
|
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');
|
|
1938
|
+
console.error('default it only inspects other projects and never modifies any');
|
|
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.');
|
|
1569
1942
|
console.error('');
|
|
1570
1943
|
console.error('Subcommands:');
|
|
1571
|
-
console.error(' serve
|
|
1944
|
+
console.error(' serve Initialize project state for <directory> (default: $PWD).');
|
|
1572
1945
|
console.error(' --server <url> Server to record (default: $OCMUX_SERVER or http://localhost:4096).');
|
|
1573
1946
|
console.error(' --force Repoint an existing project to --server (overwrites .ocmux.json).');
|
|
1574
1947
|
console.error(' --git / --GIT Use the nearest parent with a .git entry / directory.');
|
|
1575
1948
|
console.error(' Aliased as "new" for backwards compatibility.');
|
|
1949
|
+
console.error(' tui [--shared] [--server <url>] [directory]');
|
|
1950
|
+
console.error(' Register the current tmux pane and run its OpenCode TUI.');
|
|
1951
|
+
console.error(' Without --shared it is dedicated to the current 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).');
|
|
1954
|
+
console.error(' --list lists all live registrations; --status inspects one;');
|
|
1955
|
+
console.error(' --detach unregisters one without closing its pane or TUI.');
|
|
1956
|
+
console.error(' With --status/--detach, --shared targets the shared slot;');
|
|
1957
|
+
console.error(' otherwise the slot dedicated to directory is targeted.');
|
|
1576
1958
|
console.error(' session <id|title>');
|
|
1577
1959
|
console.error(' Non-interactive session switch for the current project.');
|
|
1578
|
-
console.error(' list [-l] List
|
|
1579
|
-
console.error(' kill Close the current project window. KEEPS .ocmux.json (session memory).');
|
|
1580
|
-
console.error(' resurrect Recreate the project window from its .ocmux.json.');
|
|
1960
|
+
console.error(' list [-l] List configured projects and TUI routing status.');
|
|
1581
1961
|
console.error(' migrate Rewrite legacy .ocmux.json files to the v2 schema.');
|
|
1582
1962
|
console.error('');
|
|
1583
1963
|
console.error('Reminders: the R key in the session picker attaches a note to a');
|
|
@@ -1585,6 +1965,12 @@ function printHelp() {
|
|
|
1585
1965
|
console.error('');
|
|
1586
1966
|
console.error('Options:');
|
|
1587
1967
|
console.error(' -l Long output (list: also show server URL).');
|
|
1968
|
+
console.error(' --all-projects');
|
|
1969
|
+
console.error(' Let the picker’s project switcher (p) MOVE to another');
|
|
1970
|
+
console.error(' project, so picking a session there updates that');
|
|
1971
|
+
console.error(' project’s .ocmux.json (what agentp reads in it).');
|
|
1972
|
+
console.error(' Default: the switcher only inspects other projects');
|
|
1973
|
+
console.error(' and you always return to your own project’s list.');
|
|
1588
1974
|
console.error(' --version Show version');
|
|
1589
1975
|
console.error(' -h Show this help');
|
|
1590
1976
|
console.error('');
|
|
@@ -1602,8 +1988,13 @@ function main() {
|
|
|
1602
1988
|
let gitMode = null;
|
|
1603
1989
|
let serverArg = null;
|
|
1604
1990
|
let force = false;
|
|
1991
|
+
let allProjects = false;
|
|
1992
|
+
let sharedTui = false;
|
|
1993
|
+
let tuiSession = null;
|
|
1994
|
+
let tuiList = false;
|
|
1995
|
+
let tuiStatus = false;
|
|
1996
|
+
let tuiDetach = false;
|
|
1605
1997
|
const positional = [];
|
|
1606
|
-
let dirIsForced = false;
|
|
1607
1998
|
|
|
1608
1999
|
const parsedArgs = process.argv.slice(2);
|
|
1609
2000
|
for (let i = 0; i < parsedArgs.length; i++) {
|
|
@@ -1624,6 +2015,24 @@ function main() {
|
|
|
1624
2015
|
process.exit(1);
|
|
1625
2016
|
} else if (arg === '--force') {
|
|
1626
2017
|
force = true;
|
|
2018
|
+
} else if (arg === '--shared') {
|
|
2019
|
+
sharedTui = true;
|
|
2020
|
+
} else if (arg === '--session-id') {
|
|
2021
|
+
const val = parsedArgs[i + 1];
|
|
2022
|
+
if (!val || val.startsWith('-')) {
|
|
2023
|
+
console.error("Error: '--session-id' requires a session ID.");
|
|
2024
|
+
process.exit(1);
|
|
2025
|
+
}
|
|
2026
|
+
tuiSession = val;
|
|
2027
|
+
i++;
|
|
2028
|
+
} else if (arg === '--list') {
|
|
2029
|
+
tuiList = true;
|
|
2030
|
+
} else if (arg === '--status') {
|
|
2031
|
+
tuiStatus = true;
|
|
2032
|
+
} else if (arg === '--detach') {
|
|
2033
|
+
tuiDetach = true;
|
|
2034
|
+
} else if (arg === '--all-projects') {
|
|
2035
|
+
allProjects = true;
|
|
1627
2036
|
} else if (arg === '--git') {
|
|
1628
2037
|
if (gitMode && gitMode !== 'nearest') {
|
|
1629
2038
|
console.error("Error: cannot combine '--git' with '--GIT'.");
|
|
@@ -1642,13 +2051,19 @@ function main() {
|
|
|
1642
2051
|
} else if (arg === '-h' || arg === '--help') {
|
|
1643
2052
|
printHelp();
|
|
1644
2053
|
} else if (arg === '--') {
|
|
1645
|
-
|
|
2054
|
+
const directory = parsedArgs[i + 1];
|
|
2055
|
+
if (!directory) {
|
|
2056
|
+
console.error("Error: '--' requires a directory argument.");
|
|
2057
|
+
process.exit(1);
|
|
2058
|
+
}
|
|
2059
|
+
positional.push({ value: directory, forcedDirectory: true });
|
|
2060
|
+
i++;
|
|
1646
2061
|
} else if (arg.startsWith('-')) {
|
|
1647
2062
|
console.error(`Error: unknown option '${arg}'`);
|
|
1648
2063
|
console.error(`Run '${SCRIPT} -h' for help.`);
|
|
1649
2064
|
process.exit(1);
|
|
1650
2065
|
} else {
|
|
1651
|
-
positional.push(arg);
|
|
2066
|
+
positional.push({ value: arg, forcedDirectory: false });
|
|
1652
2067
|
}
|
|
1653
2068
|
}
|
|
1654
2069
|
|
|
@@ -1658,16 +2073,16 @@ function main() {
|
|
|
1658
2073
|
let targetDir = null;
|
|
1659
2074
|
let directRef = null;
|
|
1660
2075
|
|
|
1661
|
-
for (const
|
|
1662
|
-
|
|
1663
|
-
|
|
2076
|
+
for (const positionalArg of positional) {
|
|
2077
|
+
const arg = positionalArg.value;
|
|
2078
|
+
if (positionalArg.forcedDirectory) {
|
|
1664
2079
|
if (targetDir) {
|
|
1665
2080
|
console.error(`Error: unexpected argument '${arg}'`);
|
|
1666
2081
|
process.exit(1);
|
|
1667
2082
|
}
|
|
1668
2083
|
targetDir = resolveDir(arg);
|
|
1669
|
-
} else if (arg === 'serve' || arg === 'new' || arg === '
|
|
1670
|
-
|| arg === '
|
|
2084
|
+
} else if (arg === 'serve' || arg === 'new' || arg === 'list' || arg === 'tui'
|
|
2085
|
+
|| arg === 'session'
|
|
1671
2086
|
|| arg === 'migrate') {
|
|
1672
2087
|
if (subcommand) {
|
|
1673
2088
|
console.error(`Error: multiple subcommands specified: '${subcommand}' and '${arg}'`);
|
|
@@ -1687,6 +2102,23 @@ function main() {
|
|
|
1687
2102
|
|
|
1688
2103
|
if (!targetDir) targetDir = process.cwd();
|
|
1689
2104
|
|
|
2105
|
+
if (sharedTui && subcommand !== 'tui') {
|
|
2106
|
+
console.error("Error: '--shared' is only valid with 'ocmux tui'.");
|
|
2107
|
+
process.exit(1);
|
|
2108
|
+
}
|
|
2109
|
+
if (tuiSession && subcommand !== 'tui') {
|
|
2110
|
+
console.error("Error: '--session-id' is only valid with 'ocmux tui'.");
|
|
2111
|
+
process.exit(1);
|
|
2112
|
+
}
|
|
2113
|
+
if ((tuiList || tuiStatus || tuiDetach) && subcommand !== 'tui') {
|
|
2114
|
+
console.error("Error: '--list', '--status', and '--detach' are only valid with 'ocmux tui'.");
|
|
2115
|
+
process.exit(1);
|
|
2116
|
+
}
|
|
2117
|
+
if ([tuiList, tuiStatus, tuiDetach].filter(Boolean).length > 1) {
|
|
2118
|
+
console.error("Error: combine at most one of '--list', '--status', and '--detach'.");
|
|
2119
|
+
process.exit(1);
|
|
2120
|
+
}
|
|
2121
|
+
|
|
1690
2122
|
// Dispatch. Each branch may throw synchronously (EXIT) for validation errors
|
|
1691
2123
|
// or return a promise for async commands; main() returns the settled promise
|
|
1692
2124
|
// so tests can await it.
|
|
@@ -1701,28 +2133,45 @@ function main() {
|
|
|
1701
2133
|
console.error("Error: 'list' does not accept a directory path.");
|
|
1702
2134
|
process.exit(1);
|
|
1703
2135
|
}
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
|
2136
|
+
p = cmdList(longMode, serverArg || DEFAULT_SERVER);
|
|
2137
|
+
} else if (subcommand === 'tui') {
|
|
2138
|
+
if (gitMode || force || allProjects || longMode) {
|
|
2139
|
+
console.error("Error: 'tui' does not accept --git, --GIT, --force, --all-projects, or -l.");
|
|
1709
2140
|
process.exit(1);
|
|
1710
2141
|
}
|
|
1711
|
-
|
|
1712
|
-
|
|
2142
|
+
if (tuiList) {
|
|
2143
|
+
if (sharedTui || serverArg || tuiSession || targetDir !== process.cwd()) {
|
|
2144
|
+
console.error("Error: 'ocmux tui --list' does not accept --shared, --server, --session-id, or a directory.");
|
|
2145
|
+
process.exit(1);
|
|
2146
|
+
}
|
|
2147
|
+
cmdTuiList();
|
|
2148
|
+
} else if (tuiStatus) {
|
|
2149
|
+
if (serverArg || tuiSession) {
|
|
2150
|
+
console.error("Error: 'ocmux tui --status' does not accept --server or --session-id.");
|
|
2151
|
+
process.exit(1);
|
|
2152
|
+
}
|
|
2153
|
+
cmdTuiStatus(targetDir, sharedTui);
|
|
2154
|
+
} else if (tuiDetach) {
|
|
2155
|
+
if (serverArg || tuiSession) {
|
|
2156
|
+
console.error("Error: 'ocmux tui --detach' does not accept --server or --session-id.");
|
|
2157
|
+
process.exit(1);
|
|
2158
|
+
}
|
|
2159
|
+
cmdTuiDetach(targetDir, sharedTui);
|
|
2160
|
+
} else {
|
|
2161
|
+
p = cmdTui(targetDir, serverArg, sharedTui, tuiSession);
|
|
2162
|
+
}
|
|
2163
|
+
} else if (subcommand === 'session') {
|
|
1713
2164
|
if (gitMode) {
|
|
1714
2165
|
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
|
1715
2166
|
process.exit(1);
|
|
1716
2167
|
}
|
|
1717
|
-
|
|
1718
|
-
} else if (subcommand === 'resurrect') {
|
|
1719
|
-
p = cmdResurrect(targetDir);
|
|
2168
|
+
p = cmdSessionSwitch(targetDir, directRef, allProjects);
|
|
1720
2169
|
} else if (subcommand === 'migrate') {
|
|
1721
2170
|
if (targetDir !== process.cwd()) {
|
|
1722
2171
|
console.error("Error: 'migrate' does not accept a directory path.");
|
|
1723
2172
|
process.exit(1);
|
|
1724
2173
|
}
|
|
1725
|
-
cmdMigrate();
|
|
2174
|
+
p = cmdMigrate(serverArg || DEFAULT_SERVER);
|
|
1726
2175
|
} else if (subcommand === 'serve' || subcommand === 'new') {
|
|
1727
2176
|
if (gitMode) {
|
|
1728
2177
|
const flagLabel = gitMode === 'root' ? '--GIT' : '--git';
|
|
@@ -1735,7 +2184,7 @@ function main() {
|
|
|
1735
2184
|
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
|
1736
2185
|
process.exit(1);
|
|
1737
2186
|
}
|
|
1738
|
-
p = cmdSessionSwitch(targetDir, null);
|
|
2187
|
+
p = cmdSessionSwitch(targetDir, null, allProjects);
|
|
1739
2188
|
}
|
|
1740
2189
|
|
|
1741
2190
|
return p.catch((err) => {
|
|
@@ -1751,4 +2200,4 @@ if (require.main === module) {
|
|
|
1751
2200
|
main();
|
|
1752
2201
|
}
|
|
1753
2202
|
|
|
1754
|
-
module.exports = { main, checkServer, newestSessionId,
|
|
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 };
|