agentp 2.0.0 → 2.1.0
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 +129 -27
- package/bin/agentp +224 -48
- package/bin/ocmux +965 -290
- package/bin/tgagentp +121 -130
- package/docs/specification.md +2 -1
- package/docs/specification_v2.md +188 -583
- package/lib/ocmux.js +26 -289
- package/lib/opencode.js +186 -43
- package/lib/project-state.js +8 -0
- package/lib/tui-cmd.js +12 -5
- package/lib/tui-registry.js +313 -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,9 +113,51 @@ function checkServer(server) {
|
|
|
154
113
|
});
|
|
155
114
|
}
|
|
156
115
|
|
|
116
|
+
function refreshTui(directory, server, session) {
|
|
117
|
+
const result = switchTui(directory, server, session, path.resolve(process.argv[1]));
|
|
118
|
+
if (!result.ok && result.reason === 'failed') {
|
|
119
|
+
console.error(`Warning: session selected, but the ${result.kind} TUI could not be refreshed: ${result.error}`);
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function configuredProjects(server) {
|
|
125
|
+
const [projects, sessions] = await Promise.all([
|
|
126
|
+
listOpenCodeProjects(server),
|
|
127
|
+
listSessions(server),
|
|
128
|
+
]);
|
|
129
|
+
const rows = [];
|
|
130
|
+
const seen = new Set();
|
|
131
|
+
const directories = [
|
|
132
|
+
...(projects || []).map(project => project.canonical || project.directory),
|
|
133
|
+
...(sessions || []).map(session => session.location && session.location.directory),
|
|
134
|
+
];
|
|
135
|
+
for (const dir of directories) {
|
|
136
|
+
if (!dir) continue;
|
|
137
|
+
let canonical;
|
|
138
|
+
try { canonical = fs.realpathSync(dir); } catch { canonical = path.resolve(dir); }
|
|
139
|
+
if (seen.has(canonical)) continue;
|
|
140
|
+
const sf = path.join(canonical, '.ocmux.json');
|
|
141
|
+
const state = projectState.readProjectState(sf);
|
|
142
|
+
if (!state) continue;
|
|
143
|
+
seen.add(canonical);
|
|
144
|
+
const target = tuiRegistry.resolve(canonical);
|
|
145
|
+
rows.push({
|
|
146
|
+
dir: canonical,
|
|
147
|
+
status: target ? target.kind : 'headless',
|
|
148
|
+
session: state.session,
|
|
149
|
+
server: state.server || server,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return rows.sort((a, b) => a.dir.localeCompare(b.dir));
|
|
153
|
+
}
|
|
154
|
+
|
|
157
155
|
function newestSessionId(sessions) {
|
|
158
156
|
if (!sessions || sessions.length === 0) return null;
|
|
159
|
-
|
|
157
|
+
// Implicit targets skip child/subagent sessions (parentID set) — OpenCode's
|
|
158
|
+
// TUI opens those on its "Subagents" tab instead of the prompt. Callers fall
|
|
159
|
+
// back to creating a session when this returns null.
|
|
160
|
+
const sorted = sortSessionsByRecency(sessions).filter(s => s && s.id && !s.parentID);
|
|
160
161
|
return sorted.length > 0 ? sorted[0].id : null;
|
|
161
162
|
}
|
|
162
163
|
|
|
@@ -186,6 +187,134 @@ function padLeft(s, w) {
|
|
|
186
187
|
const str = String(s);
|
|
187
188
|
return ' '.repeat(Math.max(0, w - str.length)) + str;
|
|
188
189
|
}
|
|
190
|
+
|
|
191
|
+
// ── Palette (classic 16-colour ANSI) ─────────────────────────────────────────
|
|
192
|
+
// Colour 3 renders as brown/dark-yellow; bold colour 3 as light yellow. The
|
|
193
|
+
// whole list UI (title/status bars, cursor pointer, info labels) sticks to these
|
|
194
|
+
// two shades so the chrome reads as one theme.
|
|
195
|
+
const BROWN = '\x1b[33m'; // text: brown/dark-yellow
|
|
196
|
+
const BAR_BG = '\x1b[30;43m'; // title/status bar: black on brown
|
|
197
|
+
const LIGHT_YELLOW = '\x1b[1;33m'; // text: light-yellow (bold brown)
|
|
198
|
+
|
|
199
|
+
// ── '/' incremental search for interactive lists ──────────────────────────
|
|
200
|
+
// Filter `items` by `pattern`: case-insensitive, space-separated tokens that
|
|
201
|
+
// must ALL be substrings of `textOf(item)`. Empty pattern → list unchanged.
|
|
202
|
+
function filterByPattern(items, pattern, textOf) {
|
|
203
|
+
const p = String(pattern || '').trim().toLowerCase();
|
|
204
|
+
if (!p) return items;
|
|
205
|
+
const tokens = p.split(/\s+/);
|
|
206
|
+
return items.filter((it) => {
|
|
207
|
+
const hay = String(textOf(it) || '').toLowerCase();
|
|
208
|
+
return tokens.every((t) => hay.includes(t));
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// State of a '/' search line: `active` a filter exists, `live` the user is
|
|
213
|
+
// still typing into it, `value` the pattern, `caret` the edit position.
|
|
214
|
+
function newSearchState() {
|
|
215
|
+
return { active: false, live: false, value: '', caret: 0 };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function clearSearchState(s) {
|
|
219
|
+
s.active = false;
|
|
220
|
+
s.live = false;
|
|
221
|
+
s.value = '';
|
|
222
|
+
s.caret = 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Drive a '/' search line from a keypress. Returns:
|
|
226
|
+
// 'edit' — pattern/caret changed (caller re-filters and redraws)
|
|
227
|
+
// 'close' — search cancelled/cleared (caller re-filters and redraws)
|
|
228
|
+
// 'done' — search committed, pattern kept (caller redraws)
|
|
229
|
+
// 'pass' — not handled here (caller runs its normal key logic)
|
|
230
|
+
function handleSearch(str, key, s) {
|
|
231
|
+
if (str === '/' && !key.ctrl && !key.meta && !s.live) {
|
|
232
|
+
// '/' starts a new search or resumes editing the active filter.
|
|
233
|
+
if (!s.active) { s.value = ''; s.caret = 0; }
|
|
234
|
+
else s.caret = s.value.length;
|
|
235
|
+
s.active = true;
|
|
236
|
+
s.live = true;
|
|
237
|
+
return 'edit';
|
|
238
|
+
}
|
|
239
|
+
if (!s.active) return 'pass';
|
|
240
|
+
if (!s.live) {
|
|
241
|
+
// Committed filter: ESC drops it, Backspace resumes editing it.
|
|
242
|
+
if (key.name === 'escape') { clearSearchState(s); return 'close'; }
|
|
243
|
+
if (key.name === 'backspace') {
|
|
244
|
+
if (s.value === '') { clearSearchState(s); return 'close'; }
|
|
245
|
+
s.live = true;
|
|
246
|
+
s.caret = s.value.length;
|
|
247
|
+
s.value = s.value.slice(0, -1);
|
|
248
|
+
s.caret--;
|
|
249
|
+
return 'edit';
|
|
250
|
+
}
|
|
251
|
+
return 'pass';
|
|
252
|
+
}
|
|
253
|
+
// Live typing.
|
|
254
|
+
if (key.name === 'escape') { clearSearchState(s); return 'close'; }
|
|
255
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
256
|
+
if (!s.value.trim()) { clearSearchState(s); return 'close'; }
|
|
257
|
+
s.live = false;
|
|
258
|
+
return 'done';
|
|
259
|
+
}
|
|
260
|
+
if (key.name === 'backspace') {
|
|
261
|
+
// Backspace on an already-empty field exits the search (like ESC). Removing
|
|
262
|
+
// the last character keeps the field open.
|
|
263
|
+
if (s.value === '') { clearSearchState(s); return 'close'; }
|
|
264
|
+
if (s.caret > 0) {
|
|
265
|
+
s.value = s.value.slice(0, s.caret - 1) + s.value.slice(s.caret);
|
|
266
|
+
s.caret--;
|
|
267
|
+
}
|
|
268
|
+
return 'edit';
|
|
269
|
+
}
|
|
270
|
+
if (key.name === 'delete' || key.name === 'del') {
|
|
271
|
+
if (s.caret < s.value.length) s.value = s.value.slice(0, s.caret) + s.value.slice(s.caret + 1);
|
|
272
|
+
return 'edit';
|
|
273
|
+
}
|
|
274
|
+
if (key.name === 'left') { s.caret = Math.max(0, s.caret - 1); return 'done'; }
|
|
275
|
+
if (key.name === 'right') { s.caret = Math.min(s.value.length, s.caret + 1); return 'done'; }
|
|
276
|
+
if (key.name === 'home') { s.caret = 0; return 'done'; }
|
|
277
|
+
if (key.name === 'end') { s.caret = s.value.length; return 'done'; }
|
|
278
|
+
if (key.name === 'up' || key.name === 'down') return 'pass'; // navigate the filtered list
|
|
279
|
+
if (str && !key.ctrl && !key.meta && str >= ' ') {
|
|
280
|
+
s.value = s.value.slice(0, s.caret) + str + s.value.slice(s.caret);
|
|
281
|
+
s.caret += str.length;
|
|
282
|
+
return 'edit';
|
|
283
|
+
}
|
|
284
|
+
return 'pass';
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// The status bar's search line: normal key hints, or the pattern while a '/'
|
|
288
|
+
// filter is active (live = input with caret; committed = pattern kept).
|
|
289
|
+
// The caller adds the `Enter: confirm · Esc: cancel` hint on the right via
|
|
290
|
+
// renderList's `footerRight`.
|
|
291
|
+
const SEARCH_HINT = 'Enter: confirm · Esc: cancel';
|
|
292
|
+
function searchFooter(s, base) {
|
|
293
|
+
if (!s.active) return base;
|
|
294
|
+
const shown = s.live ? s.value.slice(0, s.caret) + '▏' + s.value.slice(s.caret) : s.value;
|
|
295
|
+
return s.live ? `Search: ${shown}` : `Search: ${shown} · /: edit`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── Model picker ordering ─────────────────────────────────────────────────────
|
|
299
|
+
// Models sorted by provider then id (the provider already leads the label, so
|
|
300
|
+
// this is also alphabetical by display order).
|
|
301
|
+
function sortModels(models) {
|
|
302
|
+
const key = (m) => `${String(m.providerID || '').toLowerCase()}/${String(m.id || '').toLowerCase()}`;
|
|
303
|
+
return [...(models || [])].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Index of `model` (as stored on a session: { providerID, id }) inside
|
|
307
|
+
// `models`, or -1 when unknown/unmatched — so a picker can open on it.
|
|
308
|
+
function indexOfModel(models, model) {
|
|
309
|
+
if (!model || !models || !models.length) return -1;
|
|
310
|
+
const key = (m) => `${String(m.providerID || '').toLowerCase()}/${String(m.id || '').toLowerCase()}`;
|
|
311
|
+
const target = key(model);
|
|
312
|
+
return models.findIndex((m) => key(m) === target);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Sentinel returned by switchMenu on Ctrl+C so the caller fully exits the CLI
|
|
316
|
+
// (instead of just returning to the session menu).
|
|
317
|
+
const SWITCH_QUIT = Symbol('ocmux-switch-quit');
|
|
189
318
|
// Last-view stamp: HH:MM within the last 24h, else DD/MM/YYYY; '--:--' if unknown.
|
|
190
319
|
function fmtClock(ts) {
|
|
191
320
|
if (!ts) return '--:--';
|
|
@@ -270,13 +399,14 @@ function labeledLine(label, value, cols, labelColor = '\x1b[1m') {
|
|
|
270
399
|
return `${labelColor}${lbl}\x1b[0m ${v}`;
|
|
271
400
|
}
|
|
272
401
|
|
|
273
|
-
// Info lines about the selected session: title + location
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
402
|
+
// Info lines about the selected session: title + location and a responsive
|
|
403
|
+
// grid. Fields are ALWAYS present (placeholders when unknown).
|
|
404
|
+
// Labels are brown (the same shade as the title/status bars); when `highlight`
|
|
405
|
+
// is true (the cursor is on the session currently selected in the TUI) they are
|
|
406
|
+
// light-yellow instead.
|
|
277
407
|
function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false) {
|
|
278
408
|
if (!s) return [];
|
|
279
|
-
const labelColor = highlight ?
|
|
409
|
+
const labelColor = highlight ? LIGHT_YELLOW : BROWN;
|
|
280
410
|
const title = (s.title && s.title.trim()) || 'Untitled';
|
|
281
411
|
const location = (s.location && s.location.directory) || 'Unknown';
|
|
282
412
|
const out = [
|
|
@@ -317,13 +447,15 @@ function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false)
|
|
|
317
447
|
}
|
|
318
448
|
|
|
319
449
|
// Render a scrollable list into the alternate screen.
|
|
320
|
-
// heading -> centered
|
|
450
|
+
// heading -> centered title bar (brown background)
|
|
321
451
|
// row(index, item) -> string | { text, reverse }
|
|
322
452
|
// extraLines -> prompt/confirm lines right after the list (normal colors)
|
|
323
|
-
// footer ->
|
|
324
|
-
//
|
|
453
|
+
// footer -> status bar (brown background), key hints right-aligned
|
|
454
|
+
// (left-aligned when footerLeft is set — used by the search line)
|
|
455
|
+
// footerRight-> optional hint pinned to the right end of a left-aligned footer
|
|
456
|
+
// infoLines -> details below the status bar (colours supplied by the caller)
|
|
325
457
|
// A "(start-end/total)" range is appended to the heading when scrolling.
|
|
326
|
-
function renderList({ title, items, cursor, row, footer, infoLines = [], extraLines = [], cols: colsArg, rows: rowsArg }) {
|
|
458
|
+
function renderList({ title, items, cursor, row, footer, infoLines = [], extraLines = [], cols: colsArg, rows: rowsArg, footerLeft = false, footerRight = null }) {
|
|
327
459
|
const cols = colsArg || process.stdout.columns || 80;
|
|
328
460
|
const termRows = rowsArg || process.stdout.rows || 24;
|
|
329
461
|
const extras = (extraLines || []).filter((l) => l != null && l !== '');
|
|
@@ -341,7 +473,7 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
|
|
|
341
473
|
}
|
|
342
474
|
|
|
343
475
|
let out = '\x1b[2J\x1b[H';
|
|
344
|
-
out +=
|
|
476
|
+
out += BAR_BG + centerIn(titleLine, cols) + '\x1b[0m\n\n';
|
|
345
477
|
|
|
346
478
|
let rendered = 0;
|
|
347
479
|
if (items.length === 0) {
|
|
@@ -353,8 +485,11 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
|
|
|
353
485
|
const r = row(idx, items[idx]);
|
|
354
486
|
const text = r && typeof r === 'object' ? r.text : r;
|
|
355
487
|
const reverse = r && typeof r === 'object' && r.reverse;
|
|
356
|
-
|
|
357
|
-
|
|
488
|
+
// The pointer sits outside the row's own styling so a light-yellow arrow
|
|
489
|
+
// stays visible even on a reverse-video row.
|
|
490
|
+
const ptr = idx === cursor ? `${LIGHT_YELLOW}▶\x1b[0m ` : ' ';
|
|
491
|
+
const body = text == null ? '' : text;
|
|
492
|
+
out += ptr + (reverse ? `\x1b[7m${body}\x1b[0m` : body) + '\n';
|
|
358
493
|
rendered++;
|
|
359
494
|
}
|
|
360
495
|
}
|
|
@@ -362,8 +497,22 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
|
|
|
362
497
|
// (instead of floating right under the last row on a tall window).
|
|
363
498
|
for (let i = rendered; i < viewHeight; i++) out += '\n';
|
|
364
499
|
for (const l of extras) out += l + '\n';
|
|
365
|
-
|
|
366
|
-
|
|
500
|
+
let hint;
|
|
501
|
+
if (footerRight) {
|
|
502
|
+
// Left-aligned text plus a hint pinned to the right end of the bar.
|
|
503
|
+
const left = footer.length + footerRight.length + 1 <= cols
|
|
504
|
+
? footer
|
|
505
|
+
: footer.slice(0, Math.max(0, cols - footerRight.length - 1));
|
|
506
|
+
const gap = Math.max(0, cols - left.length - footerRight.length);
|
|
507
|
+
hint = (left + ' '.repeat(gap) + footerRight).slice(0, cols);
|
|
508
|
+
} else if (footer.length > cols) {
|
|
509
|
+
hint = footer.slice(0, cols);
|
|
510
|
+
} else if (footerLeft) {
|
|
511
|
+
hint = footer + ' '.repeat(cols - footer.length);
|
|
512
|
+
} else {
|
|
513
|
+
hint = padLeft(footer, cols);
|
|
514
|
+
}
|
|
515
|
+
out += BAR_BG + hint + '\x1b[0m\n';
|
|
367
516
|
for (const l of infos) out += l + '\n';
|
|
368
517
|
return out.replace(/\n$/, '');
|
|
369
518
|
}
|
|
@@ -374,7 +523,9 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
|
|
|
374
523
|
// q/Ctrl+C quit. Input mode offers readline-style caret editing.
|
|
375
524
|
// Resolves on quit (null) or with the string 'switch-project' (caller then runs
|
|
376
525
|
// the project switcher and re-invokes this picker for the new project).
|
|
377
|
-
|
|
526
|
+
// `dir` names the project this list belongs to — it heads the title bar so a
|
|
527
|
+
// multi-project run always says which project's sessions are on screen.
|
|
528
|
+
function sessionMenu(sessions, currentSession, annotations, server, { onPick, onRename, onDelete, onReminder, runningIds, modelContext, onBroadcastChange, onInspectSession, dir, notices = [] } = {}) {
|
|
378
529
|
return new Promise((resolve) => {
|
|
379
530
|
if (!process.stdin.isTTY) return resolve(null);
|
|
380
531
|
|
|
@@ -390,10 +541,17 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
390
541
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
391
542
|
let spinIdx = 0;
|
|
392
543
|
let spinTimer = null;
|
|
544
|
+
let broadcastSel = new Set(); // session ids selected in broadcast mode
|
|
545
|
+
let search = newSearchState(); // '/' filter for the session list
|
|
546
|
+
let listSearch = newSearchState(); // '/' filter for inline lists (models/agents)
|
|
547
|
+
let view = rows; // rows filtered by `search` (what is displayed)
|
|
548
|
+
let inspected = false; // broadcast inspection moved the TUI off `current`
|
|
393
549
|
|
|
394
550
|
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
|
395
551
|
const helpLines = [
|
|
396
|
-
'Enter
|
|
552
|
+
'Enter switch to the selected session (stays open)',
|
|
553
|
+
'Space start BROADCAST: add the selected session to the current one',
|
|
554
|
+
' (Space toggles each session; Enter keeps the selection)',
|
|
397
555
|
'n create a new session (type a name, Enter; blank = auto)',
|
|
398
556
|
'r rename the selected session (edit in place; ESC cancels)',
|
|
399
557
|
'R (Shift+r) set a REMINDER for the selected session (sent in front of',
|
|
@@ -402,6 +560,8 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
402
560
|
'a switch the AGENT of the selected session',
|
|
403
561
|
'm switch the MODEL of the selected session',
|
|
404
562
|
'p open the project switcher',
|
|
563
|
+
'/ search/filter this list (Enter keeps the filter,',
|
|
564
|
+
' ESC clears it, / resumes editing it)',
|
|
405
565
|
'j / k, arrows move the cursor',
|
|
406
566
|
'h toggle this help',
|
|
407
567
|
'q / Ctrl+C quit',
|
|
@@ -437,21 +597,45 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
437
597
|
};
|
|
438
598
|
|
|
439
599
|
const draw = () => {
|
|
440
|
-
if (mode === 'help') {
|
|
600
|
+
if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help') {
|
|
441
601
|
let out = '\x1b[2J\x1b[H';
|
|
442
|
-
|
|
443
|
-
|
|
602
|
+
if (mode === 'broadcast-help') {
|
|
603
|
+
out += 'ocmux broadcast mode — help\n\n';
|
|
604
|
+
out += ' Space select/deselect the cursor session\n';
|
|
605
|
+
out += ' Enter keep the broadcast selection; switch TUI to cursor\n';
|
|
606
|
+
out += ' m switch the model for ALL selected sessions\n';
|
|
607
|
+
out += ' d delete the cursor session (with confirmation)\n';
|
|
608
|
+
out += ' j / k, arrows move the cursor (selecting shows it in the TUI)\n';
|
|
609
|
+
out += ' / search/filter the session list\n';
|
|
610
|
+
out += ' ESC / q cancel broadcast mode (back to the session list)\n';
|
|
611
|
+
out += ' Ctrl+C quit ocmux\n';
|
|
612
|
+
} else if (mode === 'list-help') {
|
|
613
|
+
out += `ocmux ${list && list.title ? list.title : 'list'} — help\n\n`;
|
|
614
|
+
out += ' j / k, arrows move the cursor\n';
|
|
615
|
+
out += ' Enter / Space select the highlighted item\n';
|
|
616
|
+
out += ' / search/filter the list (Enter keeps, ESC clears)\n';
|
|
617
|
+
out += ' h toggle this help\n';
|
|
618
|
+
out += ' ESC / q / Backspace return to the previous menu\n';
|
|
619
|
+
out += ' Ctrl+C quit ocmux\n';
|
|
620
|
+
} else {
|
|
621
|
+
out += 'ocmux session picker — help\n\n';
|
|
622
|
+
for (const l of helpLines) out += ' ' + l + '\n';
|
|
623
|
+
}
|
|
444
624
|
out += '\nPress any key to return.\n';
|
|
445
625
|
process.stderr.write(out);
|
|
446
626
|
return;
|
|
447
627
|
}
|
|
448
628
|
if (mode === 'list' && list) {
|
|
629
|
+
const listExtras = (listSearch.active && list.view.length === 0) ? [`No matches for "${listSearch.value}"`] : [];
|
|
449
630
|
process.stderr.write(renderList({
|
|
450
631
|
title: `ocmux — ${list.title}`,
|
|
451
|
-
items: list.
|
|
632
|
+
items: list.view,
|
|
452
633
|
cursor: listCursor,
|
|
453
634
|
row: (i, item) => list.format(item) || '',
|
|
454
|
-
footer: 'Enter: select · ESC: back',
|
|
635
|
+
footer: searchFooter(listSearch, 'Enter: select · /: search · h: help · ESC: back'),
|
|
636
|
+
footerLeft: listSearch.active,
|
|
637
|
+
footerRight: listSearch.active ? SEARCH_HINT : null,
|
|
638
|
+
extraLines: listExtras,
|
|
455
639
|
}));
|
|
456
640
|
return;
|
|
457
641
|
}
|
|
@@ -462,30 +646,46 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
462
646
|
Math.max(...rows.map(s => (s.title || s.id || '').length), 8) + 6,
|
|
463
647
|
Math.max(8, cols - (showTime ? TIME_W + 10 : 8)),
|
|
464
648
|
);
|
|
465
|
-
const
|
|
466
|
-
|
|
649
|
+
const inBc = mode === 'broadcast' || mode === 'broadcast-confirm';
|
|
650
|
+
const extras = [...notices];
|
|
651
|
+
if ((mode === 'confirm' || mode === 'broadcast-confirm') && confirmDel) {
|
|
467
652
|
extras.push(`Delete session "${confirmDel.title || confirmDel.id || ''}"? (y/N) `);
|
|
468
653
|
} else if (mode === 'input' && input) {
|
|
469
654
|
extras.push(`${input.label} ${renderInputValue(input.value)}`);
|
|
470
655
|
}
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
|
|
656
|
+
if (search.active && view.length === 0) extras.push(`No sessions match "${search.value}"`);
|
|
657
|
+
const selected = view[cursor];
|
|
658
|
+
let infoLines = [];
|
|
659
|
+
let footer = 'Enter: switch · Space: broadcast · /: search · h: help · q: quit';
|
|
660
|
+
if (inBc) {
|
|
661
|
+
const names = rows.filter(r => broadcastSel.has(String(r.id))).map(r => r.title || r.id).join(', ');
|
|
662
|
+
infoLines = [`Broadcast to sessions: ${names}`];
|
|
663
|
+
footer = 'Enter: switch · Space: toggle · d: delete · m: model · /: search · h: help · q: cancel · Ctrl+C: quit';
|
|
664
|
+
} else {
|
|
665
|
+
const selIsCurrent = !!(selected && String(selected.id) === String(current));
|
|
666
|
+
infoLines = sessionInfoLines(selected, runningIds, modelContext, cols, selIsCurrent);
|
|
667
|
+
}
|
|
668
|
+
footer = searchFooter(search, footer);
|
|
474
669
|
process.stderr.write(renderList({
|
|
475
|
-
title
|
|
476
|
-
|
|
670
|
+
// The project name heads the title so you always know which project's
|
|
671
|
+
// sessions are listed (matters when --all-projects moves you around).
|
|
672
|
+
title: dir ? `ocmux — ${path.basename(dir)} sessions` : 'ocmux — sessions',
|
|
673
|
+
items: view,
|
|
477
674
|
cursor,
|
|
478
675
|
row: (i, s) => {
|
|
676
|
+
const sel = inBc ? (broadcastSel.has(String(s.id)) ? '[x] ' : '[ ] ') : '';
|
|
479
677
|
const title = (s.title || s.id || '').slice(0, maxLen);
|
|
480
678
|
const timeStr = showTime ? padLeft(fmtClock(s.time && s.time.viewed), TIME_W) + ' ' : '';
|
|
481
679
|
const mark = String(s.id) === String(current) ? ' *' : '';
|
|
482
680
|
const hasNote = annotations && annotations[s.id] ? ' ◈' : '';
|
|
483
681
|
const spin = (runningIds && runningIds.has(s.id)) ? SPINNER[spinIdx % SPINNER.length] + ' ' : ' ';
|
|
484
|
-
const text = `${spin}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
|
|
485
|
-
//
|
|
486
|
-
return String(s.id) === String(current) ?
|
|
682
|
+
const text = `${sel}${spin}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
|
|
683
|
+
// Brown for the session currently selected in the TUI window.
|
|
684
|
+
return String(s.id) === String(current) ? `${BROWN}${text}\x1b[0m` : text;
|
|
487
685
|
},
|
|
488
|
-
footer
|
|
686
|
+
footer,
|
|
687
|
+
footerLeft: search.active,
|
|
688
|
+
footerRight: search.active ? SEARCH_HINT : null,
|
|
489
689
|
infoLines,
|
|
490
690
|
extraLines: extras,
|
|
491
691
|
}));
|
|
@@ -498,7 +698,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
498
698
|
};
|
|
499
699
|
|
|
500
700
|
const applyRenameWith = (name) => {
|
|
501
|
-
const row =
|
|
701
|
+
const row = view[cursor];
|
|
502
702
|
if (!row || !onRename || !name) return;
|
|
503
703
|
Promise.resolve(onRename(row, name)).then((updated) => {
|
|
504
704
|
row.title = (updated && updated.title) || name;
|
|
@@ -510,7 +710,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
510
710
|
};
|
|
511
711
|
|
|
512
712
|
const applyReminder = (text) => {
|
|
513
|
-
const row =
|
|
713
|
+
const row = view[cursor];
|
|
514
714
|
if (!row || !onReminder) return;
|
|
515
715
|
Promise.resolve(onReminder(row, text)).then((saved) => {
|
|
516
716
|
if (saved) annotations[row.id] = saved;
|
|
@@ -526,11 +726,14 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
526
726
|
Promise.resolve(onPick ? onPick(row) : null).then((created) => {
|
|
527
727
|
if (row.new && created) {
|
|
528
728
|
rows.unshift(created);
|
|
729
|
+
applySearch();
|
|
730
|
+
const ci = view.indexOf(created);
|
|
731
|
+
cursor = ci >= 0 ? ci : 0;
|
|
529
732
|
current = created.id;
|
|
530
|
-
cursor = 0;
|
|
531
733
|
} else if (!row.new) {
|
|
532
734
|
current = row.id;
|
|
533
735
|
}
|
|
736
|
+
inspected = false;
|
|
534
737
|
confirmDel = null;
|
|
535
738
|
draw();
|
|
536
739
|
}).catch((err) => {
|
|
@@ -543,28 +746,105 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
543
746
|
const applyDelete = () => {
|
|
544
747
|
const s = confirmDel;
|
|
545
748
|
if (!s || !onDelete) return;
|
|
749
|
+
const wasBroadcast = mode === 'broadcast-confirm';
|
|
546
750
|
Promise.resolve(onDelete(s)).then(() => {
|
|
547
751
|
rows = rows.filter(r => r.id !== s.id);
|
|
548
|
-
|
|
752
|
+
applySearch();
|
|
753
|
+
if (cursor >= view.length && cursor > 0) cursor = view.length - 1;
|
|
549
754
|
confirmDel = null;
|
|
550
|
-
|
|
551
|
-
|
|
755
|
+
if (wasBroadcast) {
|
|
756
|
+
broadcastSel.delete(String(s.id));
|
|
757
|
+
if (broadcastSel.size < 2) {
|
|
758
|
+
// Broadcast ended: make the remaining selected session the real
|
|
759
|
+
// selection (switch the TUI to it and store it).
|
|
760
|
+
const remaining = [...broadcastSel][0];
|
|
761
|
+
persistBroadcast([]);
|
|
762
|
+
broadcastSel = new Set();
|
|
763
|
+
mode = 'normal';
|
|
764
|
+
const row = rows.find(r => String(r.id) === String(remaining));
|
|
765
|
+
if (row) applyPick(row);
|
|
766
|
+
else { draw(); }
|
|
767
|
+
} else {
|
|
768
|
+
persistBroadcast([...broadcastSel]);
|
|
769
|
+
mode = 'broadcast';
|
|
770
|
+
draw();
|
|
771
|
+
}
|
|
772
|
+
} else {
|
|
773
|
+
mode = 'normal';
|
|
774
|
+
draw();
|
|
775
|
+
}
|
|
552
776
|
}).catch((err) => {
|
|
553
777
|
process.stderr.write(`\nError: ${err.message}\n`);
|
|
554
778
|
confirmDel = null;
|
|
555
|
-
mode = 'normal';
|
|
779
|
+
mode = wasBroadcast ? 'broadcast' : 'normal';
|
|
556
780
|
draw();
|
|
557
781
|
});
|
|
558
782
|
};
|
|
559
783
|
|
|
560
|
-
const
|
|
784
|
+
const persistBroadcast = (ids) => {
|
|
785
|
+
if (onBroadcastChange) onBroadcastChange(ids);
|
|
786
|
+
};
|
|
787
|
+
// Recompute the filtered session view after `rows` or the pattern change.
|
|
788
|
+
const applySearch = () => {
|
|
789
|
+
view = filterByPattern(rows, search.active ? search.value : '',
|
|
790
|
+
(r) => `${r.title || ''} ${r.id || ''}`);
|
|
791
|
+
if (cursor >= view.length) cursor = Math.max(0, view.length - 1);
|
|
792
|
+
};
|
|
793
|
+
// Recompute the filtered view of the currently open inline list.
|
|
794
|
+
const applyListSearch = () => {
|
|
795
|
+
if (!list) return;
|
|
796
|
+
list.view = filterByPattern(list.items, listSearch.active ? listSearch.value : '', list.format);
|
|
797
|
+
if (listCursor >= list.view.length) listCursor = Math.max(0, list.view.length - 1);
|
|
798
|
+
};
|
|
799
|
+
// After a broadcast attempt (cancel / drop below two): put the menu — and
|
|
800
|
+
// the TUI — back on the stored current session, which inspection may have
|
|
801
|
+
// relaunched away without updating `.ocmux.json`.
|
|
802
|
+
const restoreCurrent = () => {
|
|
803
|
+
const idx = view.findIndex((r) => String(r.id) === String(current));
|
|
804
|
+
if (idx >= 0) cursor = idx;
|
|
805
|
+
if (inspected && current != null && onInspectSession) onInspectSession(current);
|
|
806
|
+
inspected = false;
|
|
807
|
+
};
|
|
808
|
+
const startBroadcast = (idx) => {
|
|
809
|
+
broadcastSel = new Set([String(current), String(view[idx].id)]);
|
|
810
|
+
persistBroadcast([...broadcastSel]);
|
|
811
|
+
mode = 'broadcast';
|
|
812
|
+
if (onInspectSession) { onInspectSession(view[idx].id); inspected = true; }
|
|
813
|
+
draw();
|
|
814
|
+
};
|
|
815
|
+
const toggleBroadcast = (idx) => {
|
|
816
|
+
const id = String(view[idx].id);
|
|
817
|
+
const adding = !broadcastSel.has(id);
|
|
818
|
+
if (adding) broadcastSel.add(id);
|
|
819
|
+
else broadcastSel.delete(id);
|
|
820
|
+
if (adding && onInspectSession) { onInspectSession(id); inspected = true; }
|
|
821
|
+
if (broadcastSel.size < 2) {
|
|
822
|
+
// Deselecting down to one session ends broadcast mode by making that
|
|
823
|
+
// remaining session the actual selection.
|
|
824
|
+
const remaining = [...broadcastSel][0];
|
|
825
|
+
persistBroadcast([]);
|
|
826
|
+
broadcastSel = new Set();
|
|
827
|
+
mode = 'normal';
|
|
828
|
+
const row = rows.find(r => String(r.id) === String(remaining));
|
|
829
|
+
if (row) applyPick(row);
|
|
830
|
+
else { restoreCurrent(); draw(); }
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
persistBroadcast([...broadcastSel]);
|
|
834
|
+
draw();
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
// `startCursor` lets a picker open with the cursor already on the relevant
|
|
838
|
+
// entry (the model pickers open on the current model).
|
|
839
|
+
const openList = (title, items, format, onEnter, returnMode = 'normal', startCursor = 0) => {
|
|
561
840
|
if (!items || items.length === 0) {
|
|
562
841
|
process.stderr.write(`\nNo ${title.toLowerCase()} available.\n`);
|
|
563
842
|
draw();
|
|
564
843
|
return;
|
|
565
844
|
}
|
|
566
|
-
list = { title, items, format, onEnter };
|
|
567
|
-
|
|
845
|
+
list = { title, items, view: items, format, onEnter, returnMode };
|
|
846
|
+
listSearch = newSearchState();
|
|
847
|
+
listCursor = Math.max(0, Math.min(startCursor, items.length - 1));
|
|
568
848
|
mode = 'list';
|
|
569
849
|
draw();
|
|
570
850
|
};
|
|
@@ -574,17 +854,36 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
574
854
|
draw();
|
|
575
855
|
};
|
|
576
856
|
|
|
857
|
+
// Open the model picker with the list sorted by provider and the cursor
|
|
858
|
+
// already on `session`'s current model (so `m` starts where the change is).
|
|
859
|
+
const openModelPicker = (title, session, returnMode, onPick) => {
|
|
860
|
+
listModels(server).then((models) => {
|
|
861
|
+
const sorted = sortModels(models);
|
|
862
|
+
openList(title, sorted, (x) => `${x.label}${x.variant ? `#${x.variant}` : ''}`,
|
|
863
|
+
onPick, returnMode, indexOfModel(sorted, session && session.model));
|
|
864
|
+
}).catch(commandErr);
|
|
865
|
+
};
|
|
866
|
+
|
|
577
867
|
process.stdin.on('keypress', (str, key) => {
|
|
578
|
-
|
|
579
|
-
|
|
868
|
+
// Ctrl+C always fully exits ocmux, from any menu.
|
|
869
|
+
if (key.ctrl && key.name === 'c') {
|
|
870
|
+
if (mode === 'broadcast' || mode === 'broadcast-confirm') {
|
|
871
|
+
persistBroadcast([]);
|
|
872
|
+
broadcastSel = new Set();
|
|
873
|
+
restoreCurrent();
|
|
874
|
+
}
|
|
875
|
+
return finish(null);
|
|
876
|
+
}
|
|
877
|
+
if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help') {
|
|
878
|
+
mode = mode === 'broadcast-help' ? 'broadcast' : (mode === 'list-help' ? 'list' : 'normal');
|
|
580
879
|
draw();
|
|
581
880
|
return;
|
|
582
881
|
}
|
|
583
|
-
if (mode === 'confirm') {
|
|
882
|
+
if (mode === 'confirm' || mode === 'broadcast-confirm') {
|
|
584
883
|
if (key.name === 'y') return applyDelete();
|
|
585
884
|
if (key.name === 'n' || key.name === 'escape' || key.name === 'q' || key.name === 'return') {
|
|
586
885
|
confirmDel = null;
|
|
587
|
-
mode = 'normal';
|
|
886
|
+
mode = mode === 'broadcast-confirm' ? 'broadcast' : 'normal';
|
|
588
887
|
draw();
|
|
589
888
|
}
|
|
590
889
|
return;
|
|
@@ -638,27 +937,81 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
638
937
|
return;
|
|
639
938
|
}
|
|
640
939
|
if (mode === 'list') {
|
|
940
|
+
const lr = handleSearch(str, key, listSearch);
|
|
941
|
+
if (lr !== 'pass') { applyListSearch(); draw(); return; }
|
|
641
942
|
if (key.name === 'escape' || key.name === 'q' || key.name === 'backspace') {
|
|
943
|
+
const returnMode = list.returnMode;
|
|
642
944
|
list = null;
|
|
643
|
-
mode =
|
|
945
|
+
mode = returnMode;
|
|
946
|
+
draw();
|
|
947
|
+
} else if (key.name === 'h') {
|
|
948
|
+
mode = 'list-help';
|
|
644
949
|
draw();
|
|
645
950
|
} else if (key.name === 'up' || str === 'k') {
|
|
646
|
-
listCursor = (listCursor - 1 + list.
|
|
951
|
+
if (list.view.length) listCursor = (listCursor - 1 + list.view.length) % list.view.length;
|
|
647
952
|
draw();
|
|
648
953
|
} else if (key.name === 'down' || str === 'j') {
|
|
649
|
-
listCursor = (listCursor + 1) % list.
|
|
954
|
+
if (list.view.length) listCursor = (listCursor + 1) % list.view.length;
|
|
650
955
|
draw();
|
|
651
956
|
} else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
|
|
652
|
-
const item = list.
|
|
957
|
+
const item = list.view[listCursor];
|
|
958
|
+
if (item === undefined) { draw(); return; }
|
|
653
959
|
const onEnter = list.onEnter;
|
|
960
|
+
const returnMode = list.returnMode;
|
|
654
961
|
list = null;
|
|
655
|
-
mode =
|
|
962
|
+
mode = returnMode;
|
|
656
963
|
draw();
|
|
657
964
|
Promise.resolve(onEnter(item)).then(() => draw()).catch(commandErr);
|
|
658
965
|
}
|
|
659
966
|
return;
|
|
660
967
|
}
|
|
661
|
-
|
|
968
|
+
const sr = handleSearch(str, key, search);
|
|
969
|
+
if (sr !== 'pass') { applySearch(); draw(); return; }
|
|
970
|
+
if (mode === 'broadcast') {
|
|
971
|
+
if (key.name === 'escape') {
|
|
972
|
+
persistBroadcast([]);
|
|
973
|
+
broadcastSel = new Set();
|
|
974
|
+
mode = 'normal';
|
|
975
|
+
restoreCurrent();
|
|
976
|
+
draw();
|
|
977
|
+
} else if (key.name === 'q') {
|
|
978
|
+
// 'q' only quits from the plain session list; in broadcast mode it
|
|
979
|
+
// cancels back to that list (Ctrl+C quits from anywhere).
|
|
980
|
+
persistBroadcast([]);
|
|
981
|
+
broadcastSel = new Set();
|
|
982
|
+
mode = 'normal';
|
|
983
|
+
restoreCurrent();
|
|
984
|
+
draw();
|
|
985
|
+
} else if (key.name === 'h') {
|
|
986
|
+
mode = 'broadcast-help';
|
|
987
|
+
draw();
|
|
988
|
+
} else if (key.name === 'space') {
|
|
989
|
+
if (cursor < view.length) toggleBroadcast(cursor);
|
|
990
|
+
} else if (key.name === 'd') {
|
|
991
|
+
if (cursor < view.length) {
|
|
992
|
+
confirmDel = view[cursor];
|
|
993
|
+
mode = 'broadcast-confirm';
|
|
994
|
+
draw();
|
|
995
|
+
}
|
|
996
|
+
} else if (key.name === 'm') {
|
|
997
|
+
openModelPicker('Broadcast model (all selected sessions)', view[cursor], 'broadcast', async (item) => {
|
|
998
|
+
await Promise.all([...broadcastSel].map(id => switchModel(server, id, item)));
|
|
999
|
+
});
|
|
1000
|
+
} else if (key.name === 'return' || key.name === 'enter') {
|
|
1001
|
+
if (cursor < view.length) {
|
|
1002
|
+
mode = 'normal';
|
|
1003
|
+
broadcastSel = new Set(); // selection stays persisted for agentp
|
|
1004
|
+
applyPick(view[cursor]);
|
|
1005
|
+
}
|
|
1006
|
+
} else if (view.length > 0 && (key.name === 'up' || str === 'k')) {
|
|
1007
|
+
cursor = (cursor - 1 + view.length) % view.length;
|
|
1008
|
+
draw();
|
|
1009
|
+
} else if (view.length > 0 && (key.name === 'down' || str === 'j')) {
|
|
1010
|
+
cursor = (cursor + 1) % view.length;
|
|
1011
|
+
draw();
|
|
1012
|
+
}
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
662
1015
|
if (key.name === 'q') return finish(null);
|
|
663
1016
|
if (key.name === 'p') return finish('switch-project');
|
|
664
1017
|
if (key.name === 'h') {
|
|
@@ -672,17 +1025,17 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
672
1025
|
}
|
|
673
1026
|
// Shift+r → Reminder; lowercase r → Rename.
|
|
674
1027
|
if (str === 'R') {
|
|
675
|
-
const row =
|
|
1028
|
+
const row = view[cursor];
|
|
676
1029
|
const note = (row && annotations && annotations[row.id]) || '';
|
|
677
1030
|
startInput('Reminder for this session (blank clears; ESC cancels):', note, 'reminder');
|
|
678
1031
|
return;
|
|
679
1032
|
}
|
|
680
|
-
if (key.name === 'r' && cursor <
|
|
681
|
-
startInput('Rename to (ESC cancels):',
|
|
1033
|
+
if (key.name === 'r' && cursor < view.length) {
|
|
1034
|
+
startInput('Rename to (ESC cancels):', view[cursor].title || '', 'rename');
|
|
682
1035
|
return;
|
|
683
1036
|
}
|
|
684
1037
|
if (key.name === 'a') {
|
|
685
|
-
const target =
|
|
1038
|
+
const target = view[cursor];
|
|
686
1039
|
if (target) {
|
|
687
1040
|
listAgents(server).then((all) => {
|
|
688
1041
|
const primary = all.filter(x => x.mode === 'primary' && !x.hidden);
|
|
@@ -694,31 +1047,33 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
694
1047
|
return;
|
|
695
1048
|
}
|
|
696
1049
|
if (key.name === 'm') {
|
|
697
|
-
const target =
|
|
698
|
-
if (target)
|
|
699
|
-
listModels(server).then((models) => {
|
|
700
|
-
openList('Models', models, (x) => `${x.label}${x.variant ? `#${x.variant}` : ''}`, (item) =>
|
|
701
|
-
switchModel(server, target.id, item));
|
|
702
|
-
}).catch(commandErr);
|
|
703
|
-
}
|
|
1050
|
+
const target = view[cursor];
|
|
1051
|
+
if (target) openModelPicker('Models', target, 'normal', (item) => switchModel(server, target.id, item));
|
|
704
1052
|
return;
|
|
705
1053
|
}
|
|
706
1054
|
if (key.name === 'd') {
|
|
707
|
-
if (cursor <
|
|
708
|
-
confirmDel =
|
|
1055
|
+
if (cursor < view.length) {
|
|
1056
|
+
confirmDel = view[cursor];
|
|
709
1057
|
mode = 'confirm';
|
|
710
1058
|
draw();
|
|
711
1059
|
}
|
|
712
1060
|
return;
|
|
713
1061
|
}
|
|
714
|
-
if (
|
|
715
|
-
cursor = (cursor - 1 +
|
|
1062
|
+
if (view.length > 0 && (key.name === 'up' || str === 'k')) {
|
|
1063
|
+
cursor = (cursor - 1 + view.length) % view.length;
|
|
716
1064
|
draw();
|
|
717
|
-
} else if (
|
|
718
|
-
cursor = (cursor + 1) %
|
|
1065
|
+
} else if (view.length > 0 && (key.name === 'down' || str === 'j')) {
|
|
1066
|
+
cursor = (cursor + 1) % view.length;
|
|
719
1067
|
draw();
|
|
720
|
-
} else if (key.name === 'return' || key.name === 'enter'
|
|
721
|
-
if (cursor <
|
|
1068
|
+
} else if (key.name === 'return' || key.name === 'enter') {
|
|
1069
|
+
if (cursor < view.length) applyPick(view[cursor]);
|
|
1070
|
+
} else if (key.name === 'space') {
|
|
1071
|
+
// Start broadcast only when adding a DIFFERENT session to the current one.
|
|
1072
|
+
if (cursor < view.length && String(view[cursor].id) !== String(current)) {
|
|
1073
|
+
startBroadcast(cursor);
|
|
1074
|
+
} else if (cursor < view.length) {
|
|
1075
|
+
draw(); // no-op on the already-selected session
|
|
1076
|
+
}
|
|
722
1077
|
}
|
|
723
1078
|
});
|
|
724
1079
|
|
|
@@ -735,8 +1090,19 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
735
1090
|
});
|
|
736
1091
|
}
|
|
737
1092
|
|
|
738
|
-
// Interactive project picker —
|
|
739
|
-
|
|
1093
|
+
// Interactive project picker — never writes state (it only moves what a
|
|
1094
|
+
// registered dedicated/shared TUI displays).
|
|
1095
|
+
//
|
|
1096
|
+
// Default (safe): an INSPECTOR. Selecting a project/session row still focuses
|
|
1097
|
+
// the routed TUI and shows the session there, but the promise always resolves to
|
|
1098
|
+
// null, so the caller re-opens its own project's list. Nothing about the
|
|
1099
|
+
// caller's project changes.
|
|
1100
|
+
//
|
|
1101
|
+
// `{ allProjects: true }` (CLI `--all-projects`): a real SWITCHER. Selecting a
|
|
1102
|
+
// row also records the target directory, so the caller moves its picker there
|
|
1103
|
+
// and can then write that project's `.ocmux.json` — the file `agentp` reads in
|
|
1104
|
+
// that directory.
|
|
1105
|
+
function switchMenu(rows, { allProjects = false, onOpen } = {}) {
|
|
740
1106
|
return new Promise((resolve) => {
|
|
741
1107
|
if (!process.stdin.isTTY) {
|
|
742
1108
|
console.error('Error: "switch" is interactive and requires a TTY.');
|
|
@@ -756,17 +1122,53 @@ function switchMenu(rows, longMode) {
|
|
|
756
1122
|
const lineWidth = Math.max(cols, nameW + 2 + stsW + 2 + ssnW + 6);
|
|
757
1123
|
|
|
758
1124
|
let cursor = 0;
|
|
759
|
-
let
|
|
760
|
-
let lastDir = null;
|
|
1125
|
+
let lastDir = null; // target dir to move to (only with allProjects)
|
|
761
1126
|
let done = false;
|
|
762
|
-
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
1127
|
+
let help = false;
|
|
1128
|
+
const search = newSearchState();
|
|
1129
|
+
let view = []; // flattened nodes: { kind:'project'|'session', ... }
|
|
1130
|
+
// Fold state per project directory: { open, sessions, loading, error }.
|
|
1131
|
+
const folds = new Map();
|
|
1132
|
+
|
|
1133
|
+
const foldOf = (p) => folds.get(p.dir) || { open: false, sessions: null, loading: false, error: null };
|
|
1134
|
+
const projectText = (p) => `${path.basename(p.dir)} ${p.dir} ${p.session || ''}`;
|
|
1135
|
+
const sessionText = (s) => `${s.title || ''} ${s.id || ''}`;
|
|
1136
|
+
const matches = (pattern, text) => filterByPattern([1], pattern, () => text).length > 0;
|
|
1137
|
+
|
|
1138
|
+
// Flatten projects (plus their unfolded sessions) honouring the '/' pattern.
|
|
1139
|
+
// A project header survives when it matches itself OR when one of its
|
|
1140
|
+
// unfolded sessions does, so a match deeper down is still navigable.
|
|
1141
|
+
const buildView = () => {
|
|
1142
|
+
const pattern = search.active ? search.value : '';
|
|
1143
|
+
const out = [];
|
|
1144
|
+
for (const p of rows) {
|
|
1145
|
+
const f = foldOf(p);
|
|
1146
|
+
const sessions = f.open && f.sessions ? f.sessions : [];
|
|
1147
|
+
const shown = sessions.filter((s) => matches(pattern, sessionText(s)));
|
|
1148
|
+
const header = { kind: 'project', project: p };
|
|
1149
|
+
if (sessions.length === 0) {
|
|
1150
|
+
if (matches(pattern, projectText(p))) out.push(header);
|
|
1151
|
+
} else if (matches(pattern, projectText(p)) || shown.length) {
|
|
1152
|
+
out.push(header);
|
|
1153
|
+
for (const s of shown) out.push({ kind: 'session', project: p, session: s });
|
|
1154
|
+
}
|
|
768
1155
|
}
|
|
1156
|
+
return out;
|
|
1157
|
+
};
|
|
1158
|
+
|
|
1159
|
+
const sameNode = (a, b) => {
|
|
1160
|
+
if (!a || !b || a.kind !== b.kind || a.project.dir !== b.project.dir) return false;
|
|
1161
|
+
return a.kind !== 'session' || a.session.id === b.session.id;
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
const applySearch = () => {
|
|
1165
|
+
const prev = view[cursor];
|
|
1166
|
+
view = buildView();
|
|
1167
|
+
const idx = prev ? view.findIndex((n) => sameNode(n, prev)) : -1;
|
|
1168
|
+
if (idx >= 0) cursor = idx;
|
|
1169
|
+
else if (cursor >= view.length) cursor = Math.max(0, view.length - 1);
|
|
769
1170
|
};
|
|
1171
|
+
applySearch();
|
|
770
1172
|
|
|
771
1173
|
const cleanup = () => {
|
|
772
1174
|
process.stderr.write('\x1b[?25h');
|
|
@@ -775,58 +1177,165 @@ function switchMenu(rows, longMode) {
|
|
|
775
1177
|
try { process.stdin.setRawMode(false); } catch {}
|
|
776
1178
|
};
|
|
777
1179
|
|
|
778
|
-
const finish = () => {
|
|
1180
|
+
const finish = (value) => {
|
|
779
1181
|
if (done) return;
|
|
780
1182
|
done = true;
|
|
781
1183
|
cleanup();
|
|
782
1184
|
process.stdin.pause();
|
|
783
|
-
|
|
1185
|
+
process.stdin.removeAllListeners('keypress');
|
|
1186
|
+
process.removeListener('SIGINT', onSig);
|
|
1187
|
+
resolve(value === undefined ? lastDir : value);
|
|
784
1188
|
};
|
|
785
1189
|
|
|
1190
|
+
// Mode-dependent wording: the switcher inspects by default and really
|
|
1191
|
+
// switches only under --all-projects.
|
|
1192
|
+
const switcherTitle = allProjects ? 'ocmux — project switcher (all projects)' : 'ocmux — project switcher';
|
|
1193
|
+
const enterHint = allProjects ? 'Enter: switch' : 'Enter: view';
|
|
1194
|
+
const modeNote = allProjects
|
|
1195
|
+
? ['--all-projects: selecting moves the picker to that project, so a',
|
|
1196
|
+
'session picked there updates ITS .ocmux.json — the file agentp',
|
|
1197
|
+
'reads when it runs in that directory.']
|
|
1198
|
+
: ['Selecting only moves the TUI view — .ocmux.json is never',
|
|
1199
|
+
'written, so agentp keeps prompting your own project’s session.',
|
|
1200
|
+
'You always come back to your own project’s list.'];
|
|
1201
|
+
|
|
786
1202
|
const draw = () => {
|
|
787
|
-
|
|
1203
|
+
if (done) return;
|
|
1204
|
+
if (help) {
|
|
1205
|
+
let out = '\x1b[2J\x1b[H';
|
|
1206
|
+
out += 'ocmux project switcher — help\n\n';
|
|
1207
|
+
out += ' j / k, arrows move the cursor (projects and sessions)\n';
|
|
1208
|
+
out += ` Enter ${allProjects ? 'switch the picker to the selected project' : 'focus the selected project (menu stays open)'}\n`;
|
|
1209
|
+
out += ' (on a session, show it in that project’s TUI)\n';
|
|
1210
|
+
out += ' Space fold/unfold a project’s sessions;\n';
|
|
1211
|
+
out += ' on a session, show it in the TUI as well\n';
|
|
1212
|
+
out += ' / search/filter the list (Enter keeps, ESC clears)\n';
|
|
1213
|
+
out += ' h toggle this help\n';
|
|
1214
|
+
out += ' q return to the session menu\n';
|
|
1215
|
+
out += ' Ctrl+C quit ocmux\n';
|
|
1216
|
+
out += '\n';
|
|
1217
|
+
for (const l of modeNote) out += `${l}\n`;
|
|
1218
|
+
out += '\nPress any key to return.\n';
|
|
1219
|
+
process.stderr.write(out);
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
const extras = [];
|
|
1223
|
+
if (search.active && view.length === 0) extras.push(`No projects match "${search.value}"`);
|
|
1224
|
+
for (const n of view) {
|
|
1225
|
+
if (n.kind !== 'project') continue;
|
|
1226
|
+
const f = folds.get(n.project.dir);
|
|
1227
|
+
if (!f || !f.open) continue;
|
|
1228
|
+
const name = path.basename(n.project.dir);
|
|
1229
|
+
if (f.loading) extras.push(` … loading sessions of ${name} …`);
|
|
1230
|
+
else if (f.error) extras.push(` ! ${name}: ${f.error}`);
|
|
1231
|
+
}
|
|
788
1232
|
process.stderr.write(renderList({
|
|
789
|
-
title:
|
|
790
|
-
items:
|
|
1233
|
+
title: switcherTitle,
|
|
1234
|
+
items: view,
|
|
791
1235
|
cursor,
|
|
792
|
-
row: (i,
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
1236
|
+
row: (i, node) => {
|
|
1237
|
+
if (node.kind === 'session') {
|
|
1238
|
+
const s = node.session;
|
|
1239
|
+
const id = String(s.id || '');
|
|
1240
|
+
const mark = String(s.id) === String(node.project.session) ? ' *' : '';
|
|
1241
|
+
const title = (s.title || id).slice(0, Math.max(8, nameW + 6));
|
|
1242
|
+
return ` ${title}${mark} ${id}`;
|
|
1243
|
+
}
|
|
1244
|
+
const p = node.project;
|
|
1245
|
+
const fold = foldOf(p).open ? '▾' : '▸';
|
|
1246
|
+
const session = p.session ? p.session.slice(0, 14) : '(none)';
|
|
1247
|
+
const text = `${fold} ${pad(path.basename(p.dir), nameW)} ${pad(p.status, stsW)} ${pad(session, ssnW)} ${p.dir}`;
|
|
1248
|
+
return text;
|
|
796
1249
|
},
|
|
797
|
-
footer:
|
|
1250
|
+
footer: searchFooter(search, `${enterHint} · Space: fold · /: search · h: help · q: back · Ctrl+C: quit`),
|
|
1251
|
+
footerLeft: search.active,
|
|
1252
|
+
footerRight: search.active ? SEARCH_HINT : null,
|
|
1253
|
+
extraLines: extras,
|
|
798
1254
|
}));
|
|
799
1255
|
};
|
|
800
1256
|
|
|
801
|
-
|
|
802
|
-
|
|
1257
|
+
// Enter on a project row: show its current session in its routed TUI. With
|
|
1258
|
+
// allProjects off this stays a pure inspection and
|
|
1259
|
+
// `lastDir` is left null so the caller returns to its own project.
|
|
1260
|
+
const activate = (p) => {
|
|
1261
|
+
if (!p) return;
|
|
803
1262
|
try {
|
|
804
|
-
if (
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
1263
|
+
if (p.server && p.session && onOpen) onOpen(p, p.session);
|
|
1264
|
+
if (allProjects) lastDir = p.dir;
|
|
1265
|
+
} catch (err) {
|
|
1266
|
+
process.stderr.write(`\nError: ${err.message}\n`);
|
|
1267
|
+
}
|
|
1268
|
+
draw();
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
// Enter/Space on a session row: move the TUI VIEW onto it. The switcher
|
|
1272
|
+
// itself never writes `.ocmux.json`; with allProjects on the caller only
|
|
1273
|
+
// moves its picker to that project — the write happens later, when a
|
|
1274
|
+
// session is picked there (and that file is what agentp reads there).
|
|
1275
|
+
const openSession = (node) => {
|
|
1276
|
+
const p = node.project;
|
|
1277
|
+
try {
|
|
1278
|
+
if (p.server && onOpen) onOpen(p, node.session.id);
|
|
1279
|
+
if (allProjects) lastDir = p.dir;
|
|
810
1280
|
} catch (err) {
|
|
811
1281
|
process.stderr.write(`\nError: ${err.message}\n`);
|
|
812
1282
|
}
|
|
813
1283
|
draw();
|
|
814
1284
|
};
|
|
815
1285
|
|
|
816
|
-
|
|
1286
|
+
// Space on a project row: fetch its sessions once, then flip the fold.
|
|
1287
|
+
const toggleFold = (p) => {
|
|
1288
|
+
const f = folds.get(p.dir) || { open: false, sessions: null, loading: false, error: null };
|
|
1289
|
+
folds.set(p.dir, f);
|
|
1290
|
+
if (!f.open && !f.sessions && !f.error && !f.loading) {
|
|
1291
|
+
if (!p.server) {
|
|
1292
|
+
f.error = 'no server recorded';
|
|
1293
|
+
} else {
|
|
1294
|
+
f.loading = true;
|
|
1295
|
+
f.open = true;
|
|
1296
|
+
applySearch();
|
|
1297
|
+
draw();
|
|
1298
|
+
listSessions(p.server, p.dir)
|
|
1299
|
+
.then((list) => {
|
|
1300
|
+
// Main sessions only: child/subagent sessions are work products,
|
|
1301
|
+
// not navigation targets (same rule as the session picker).
|
|
1302
|
+
f.sessions = sortSessionsByRecency((list || []).filter((s) => !s.parentID));
|
|
1303
|
+
})
|
|
1304
|
+
.catch((err) => { f.error = err.message; })
|
|
1305
|
+
.then(() => { f.loading = false; applySearch(); draw(); });
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
f.open = !f.open;
|
|
1310
|
+
applySearch();
|
|
1311
|
+
draw();
|
|
1312
|
+
};
|
|
1313
|
+
|
|
1314
|
+
const onSig = () => finish(SWITCH_QUIT);
|
|
817
1315
|
process.on('SIGINT', onSig);
|
|
818
1316
|
|
|
819
1317
|
process.stdin.on('keypress', (str, key) => {
|
|
820
|
-
|
|
1318
|
+
// Ctrl+C fully exits ocmux; 'q' only closes this menu (back to sessions).
|
|
1319
|
+
if (key.ctrl && key.name === 'c') return finish(SWITCH_QUIT);
|
|
1320
|
+
if (help) { help = false; draw(); return; }
|
|
1321
|
+
const sr = handleSearch(str, key, search);
|
|
1322
|
+
if (sr !== 'pass') { applySearch(); draw(); return; }
|
|
1323
|
+
if (key.name === 'h') { help = true; draw(); return; }
|
|
821
1324
|
if (key.name === 'q') return finish();
|
|
1325
|
+
if (view.length === 0) return;
|
|
1326
|
+
const node = view[cursor];
|
|
822
1327
|
if (key.name === 'up' || str === 'k') {
|
|
823
|
-
cursor = (cursor +
|
|
1328
|
+
cursor = (cursor + view.length - 1) % view.length;
|
|
824
1329
|
draw();
|
|
825
1330
|
} else if (key.name === 'down' || str === 'j') {
|
|
826
|
-
cursor = (cursor + 1) %
|
|
1331
|
+
cursor = (cursor + 1) % view.length;
|
|
827
1332
|
draw();
|
|
828
|
-
} else if (key.name === 'return' || key.name === 'enter'
|
|
829
|
-
activate(
|
|
1333
|
+
} else if (key.name === 'return' || key.name === 'enter') {
|
|
1334
|
+
if (node.kind === 'project') activate(node.project);
|
|
1335
|
+
else openSession(node);
|
|
1336
|
+
} else if (key.name === 'space') {
|
|
1337
|
+
if (node.kind === 'project') toggleFold(node.project);
|
|
1338
|
+
else openSession(node);
|
|
830
1339
|
}
|
|
831
1340
|
});
|
|
832
1341
|
|
|
@@ -843,13 +1352,18 @@ function switchMenu(rows, longMode) {
|
|
|
843
1352
|
// Subcommand: default — interactive session switcher for the current project
|
|
844
1353
|
// ---------------------------------------------------------------------------
|
|
845
1354
|
|
|
846
|
-
|
|
1355
|
+
// `allProjects` (CLI `--all-projects`) unlocks cross-project switching: the
|
|
1356
|
+
// project switcher may then move this picker onto another project, whose
|
|
1357
|
+
// `.ocmux.json` gets written when a session is picked there. Without it the
|
|
1358
|
+
// switcher is inspection-only and this command always stays on `targetDir`'s
|
|
1359
|
+
// project.
|
|
1360
|
+
async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
847
1361
|
let target = targetDir;
|
|
848
1362
|
for (;;) {
|
|
849
1363
|
const sf = findParentStatefile(target);
|
|
850
1364
|
if (!sf) {
|
|
851
1365
|
console.error(`Error: no .ocmux.json found in ${target} or any of its parents.`);
|
|
852
|
-
|
|
1366
|
+
console.error(`Run 'ocmux serve' to initialize the project.`);
|
|
853
1367
|
process.exit(1);
|
|
854
1368
|
}
|
|
855
1369
|
const state = projectState.readProjectState(sf);
|
|
@@ -868,22 +1382,28 @@ async function cmdSessionSwitch(targetDir, directRef) {
|
|
|
868
1382
|
process.exit(1);
|
|
869
1383
|
}
|
|
870
1384
|
|
|
871
|
-
const winIdx = windowByDir(dir);
|
|
872
|
-
if (winIdx == null) {
|
|
873
|
-
console.error(`Error: no tmux window for ${dir}.`);
|
|
874
|
-
console.error(`Run 'ocmux serve' to create it.`);
|
|
875
|
-
process.exit(1);
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
// Focus this project's window up-front so the picker (and any session
|
|
879
|
-
// switch) never leaves another project's window selected.
|
|
880
|
-
activateProject(dir, winIdx, server, state.session);
|
|
881
|
-
|
|
882
1385
|
const sessions = await listSessions(server, dir).catch((err) => {
|
|
883
1386
|
console.error(`Error connecting to ${server}: ${err.message}`);
|
|
884
1387
|
process.exit(1);
|
|
885
1388
|
});
|
|
886
1389
|
const sorted = sortSessionsByRecency(sessions);
|
|
1390
|
+
const notices = [];
|
|
1391
|
+
if (state.session && !sorted.some(s => String(s.id) === String(state.session))) {
|
|
1392
|
+
try {
|
|
1393
|
+
const moved = await getSession(server, state.session);
|
|
1394
|
+
const movedDir = moved && moved.location && moved.location.directory;
|
|
1395
|
+
if (movedDir && path.resolve(movedDir) !== path.resolve(dir)) {
|
|
1396
|
+
notices.push(`⚠ Current session moved to ${movedDir}`);
|
|
1397
|
+
notices.push(` Run: ocmux serve ${JSON.stringify(movedDir)} --server ${JSON.stringify(server)}`);
|
|
1398
|
+
}
|
|
1399
|
+
} catch {
|
|
1400
|
+
// A deleted/stale stored session is handled by the normal picker.
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
// Only "main" sessions are listed in the picker: exclude child/subagent
|
|
1404
|
+
// sessions (parentID set) — they are work products of a main session, not
|
|
1405
|
+
// targets. Explicit --session can still reach one by id/title.
|
|
1406
|
+
const mainSessions = sorted.filter(s => !s.parentID);
|
|
887
1407
|
const annotations = projectState.readAnnotations(sf);
|
|
888
1408
|
const runningIds = await getActiveSessions(server);
|
|
889
1409
|
let modelContext = new Map();
|
|
@@ -894,7 +1414,7 @@ async function cmdSessionSwitch(targetDir, directRef) {
|
|
|
894
1414
|
|
|
895
1415
|
const applySession = (id) => {
|
|
896
1416
|
projectState.writeProjectState(sf, { session: id });
|
|
897
|
-
|
|
1417
|
+
refreshTui(dir, server, id);
|
|
898
1418
|
};
|
|
899
1419
|
|
|
900
1420
|
if (directRef) {
|
|
@@ -910,19 +1430,22 @@ async function cmdSessionSwitch(targetDir, directRef) {
|
|
|
910
1430
|
}
|
|
911
1431
|
|
|
912
1432
|
if (!process.stdin.isTTY) {
|
|
913
|
-
// Silent path (non-TTY):
|
|
914
|
-
activateProject(dir, winIdx, server, state.session);
|
|
1433
|
+
// Silent path (non-TTY): project state already identifies the target.
|
|
915
1434
|
return;
|
|
916
1435
|
}
|
|
917
1436
|
|
|
918
1437
|
// Interactive: Enter switches but stays open; n creates (inheriting the
|
|
919
1438
|
// model of the previously selected session); r renames; R sets a reminder;
|
|
920
1439
|
// d deletes; a switches agent; m switches model; p switches project; q quits.
|
|
921
|
-
let lastRef = (state.session &&
|
|
1440
|
+
let lastRef = (state.session && mainSessions.find(s => String(s.id) === String(state.session))) || mainSessions[0] || null;
|
|
922
1441
|
const result = await new Promise((resolve) => {
|
|
923
|
-
sessionMenu(
|
|
1442
|
+
sessionMenu(mainSessions, state.session, annotations, server, {
|
|
1443
|
+
dir,
|
|
924
1444
|
runningIds,
|
|
925
1445
|
modelContext,
|
|
1446
|
+
notices,
|
|
1447
|
+
onBroadcastChange: (ids) => projectState.writeProjectState(sf, { broadcast: ids }),
|
|
1448
|
+
onInspectSession: (id) => refreshTui(dir, server, id),
|
|
926
1449
|
onPick: async (row) => {
|
|
927
1450
|
if (row.new) {
|
|
928
1451
|
const created = await createSessionWithModel(server, row.name || undefined, dir, [lastRef, ...sorted]).catch((err) => {
|
|
@@ -963,12 +1486,21 @@ async function cmdSessionSwitch(targetDir, directRef) {
|
|
|
963
1486
|
});
|
|
964
1487
|
|
|
965
1488
|
if (result === 'switch-project') {
|
|
966
|
-
// 'p' pressed: open the project switcher
|
|
967
|
-
//
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1489
|
+
// 'p' pressed: open the project switcher. Without --all-projects it only
|
|
1490
|
+
// inspects (picked stays null => re-run this picker for the same
|
|
1491
|
+
// project); with it, picking a project re-runs the picker there.
|
|
1492
|
+
// Ctrl+C fully exits.
|
|
1493
|
+
const rows = await configuredProjects(server).catch((err) => {
|
|
1494
|
+
console.error(`Error listing projects: ${err.message}`);
|
|
1495
|
+
return [];
|
|
1496
|
+
});
|
|
1497
|
+
const picked = await switchMenu(rows, {
|
|
1498
|
+
allProjects,
|
|
1499
|
+
onOpen: (project, id) => refreshTui(project.dir, project.server, id),
|
|
1500
|
+
});
|
|
1501
|
+
if (picked === SWITCH_QUIT) break;
|
|
1502
|
+
if (!picked) continue;
|
|
1503
|
+
target = picked;
|
|
972
1504
|
continue;
|
|
973
1505
|
}
|
|
974
1506
|
break;
|
|
@@ -976,7 +1508,7 @@ async function cmdSessionSwitch(targetDir, directRef) {
|
|
|
976
1508
|
}
|
|
977
1509
|
|
|
978
1510
|
// ---------------------------------------------------------------------------
|
|
979
|
-
// Subcommand: serve —
|
|
1511
|
+
// Subcommand: serve — initialize project/session state (server is user-managed)
|
|
980
1512
|
// ---------------------------------------------------------------------------
|
|
981
1513
|
|
|
982
1514
|
async function cmdServeNew(targetDir, serverArg, force) {
|
|
@@ -985,7 +1517,7 @@ async function cmdServeNew(targetDir, serverArg, force) {
|
|
|
985
1517
|
const sf = path.join(targetDir, '.ocmux.json');
|
|
986
1518
|
if (fs.existsSync(sf)) {
|
|
987
1519
|
if (!force) {
|
|
988
|
-
console.error(`Error:
|
|
1520
|
+
console.error(`Error: project state already exists in ${targetDir}.`);
|
|
989
1521
|
console.error(`Use 'ocmux' to switch sessions there, or 'ocmux serve --server <url> --force' to repoint it.`);
|
|
990
1522
|
process.exit(1);
|
|
991
1523
|
}
|
|
@@ -995,11 +1527,10 @@ async function cmdServeNew(targetDir, serverArg, force) {
|
|
|
995
1527
|
const parent = findParentStatefile(path.dirname(targetDir));
|
|
996
1528
|
if (parent) {
|
|
997
1529
|
const parentDir = path.dirname(parent);
|
|
998
|
-
console.error(`Warning: there is already an
|
|
999
|
-
console.error(`Run 'ocmux' and press 'p' to move between project windows.`);
|
|
1530
|
+
console.error(`Warning: there is already an ocmux project in ${parentDir}.`);
|
|
1000
1531
|
|
|
1001
1532
|
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
1002
|
-
rl.question('Are you sure you want to
|
|
1533
|
+
rl.question('Are you sure you want to initialize a nested project here? [y/N] ', (answer) => {
|
|
1003
1534
|
rl.close();
|
|
1004
1535
|
if (!/^[yY]([eE][sS])?$/.test(answer.trim())) {
|
|
1005
1536
|
console.error('Cancelled.');
|
|
@@ -1015,8 +1546,8 @@ async function cmdServeNew(targetDir, serverArg, force) {
|
|
|
1015
1546
|
return doServeNew(targetDir, serverArg);
|
|
1016
1547
|
}
|
|
1017
1548
|
|
|
1018
|
-
// Repoint an existing project to a (possibly new) server
|
|
1019
|
-
//
|
|
1549
|
+
// Repoint an existing project to a (possibly new) server. A registered TUI is
|
|
1550
|
+
// refreshed through the normal dedicated → shared routing rule.
|
|
1020
1551
|
async function doForceServe(targetDir, sf, server) {
|
|
1021
1552
|
const alive = await checkServer(server);
|
|
1022
1553
|
if (!alive) {
|
|
@@ -1037,14 +1568,8 @@ async function doForceServe(targetDir, sf, server) {
|
|
|
1037
1568
|
session = created.id;
|
|
1038
1569
|
}
|
|
1039
1570
|
projectState.writeProjectState(sf, { server, session });
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
relaunchTui(winIdx, targetDir, server, session);
|
|
1043
|
-
console.error(`Repointed ${targetDir} to ${server} (session ${session}).`);
|
|
1044
|
-
} else {
|
|
1045
|
-
createProjectWindow(targetDir, server, session);
|
|
1046
|
-
console.error(`Recreated project window for ${targetDir} → ${server} (session ${session}).`);
|
|
1047
|
-
}
|
|
1571
|
+
refreshTui(targetDir, server, session);
|
|
1572
|
+
console.error(`Repointed ${targetDir} to ${server} (session ${session}).`);
|
|
1048
1573
|
}
|
|
1049
1574
|
|
|
1050
1575
|
async function doServeNew(targetDir, serverArg) {
|
|
@@ -1076,108 +1601,181 @@ async function doServeNew(targetDir, serverArg) {
|
|
|
1076
1601
|
}
|
|
1077
1602
|
}
|
|
1078
1603
|
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1604
|
+
projectState.writeProjectState(path.join(targetDir, '.ocmux.json'), {
|
|
1605
|
+
directory: targetDir,
|
|
1606
|
+
server,
|
|
1607
|
+
session,
|
|
1608
|
+
status: 'active',
|
|
1609
|
+
});
|
|
1610
|
+
refreshTui(targetDir, server, session);
|
|
1611
|
+
console.error(`Initialized ${targetDir} → ${server} (session ${session}).`);
|
|
1086
1612
|
}
|
|
1087
1613
|
|
|
1088
1614
|
// ---------------------------------------------------------------------------
|
|
1089
|
-
// Subcommand:
|
|
1615
|
+
// Subcommand: tui — register this tmux pane and host an OpenCode TUI
|
|
1090
1616
|
// ---------------------------------------------------------------------------
|
|
1091
1617
|
|
|
1092
|
-
function
|
|
1093
|
-
const
|
|
1094
|
-
if (
|
|
1095
|
-
console.
|
|
1096
|
-
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
const dirW = Math.max(...rows.map(r => r.dir.length), 9);
|
|
1100
|
-
const stsW = Math.max(...rows.map(r => r.status.length), 6);
|
|
1101
|
-
const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
|
|
1102
|
-
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
|
1103
|
-
|
|
1104
|
-
console.log(`${pad('Directory', dirW)} ${pad('Status', stsW)} ${pad('Session', ssnW)}`);
|
|
1105
|
-
for (const row of rows) {
|
|
1106
|
-
console.log(`${pad(row.dir, dirW)} ${pad(row.status, stsW)} ${pad(row.session || '(none)', ssnW)}`);
|
|
1107
|
-
if (longMode && row.server) console.log(` server: ${row.server}`);
|
|
1618
|
+
async function cmdTui(targetDir, serverArg, shared, sessionArg) {
|
|
1619
|
+
const tmuxTarget = tuiRegistry.parseTmuxEnvironment();
|
|
1620
|
+
if (!tmuxTarget) {
|
|
1621
|
+
console.error("Error: 'ocmux tui' must run inside a tmux pane.");
|
|
1622
|
+
process.exit(1);
|
|
1108
1623
|
}
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
// ---------------------------------------------------------------------------
|
|
1112
|
-
// Subcommand: kill — close the project window but keep .ocmux.json
|
|
1113
|
-
// ---------------------------------------------------------------------------
|
|
1114
|
-
|
|
1115
|
-
function cmdKill(targetDir) {
|
|
1116
1624
|
const sf = findParentStatefile(targetDir);
|
|
1117
1625
|
if (!sf) {
|
|
1118
1626
|
console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
|
|
1627
|
+
console.error("Run 'ocmux serve' first.");
|
|
1628
|
+
process.exit(1);
|
|
1629
|
+
}
|
|
1630
|
+
const state = projectState.readProjectState(sf);
|
|
1631
|
+
const directory = state.directory;
|
|
1632
|
+
const server = serverArg || state.server || DEFAULT_SERVER;
|
|
1633
|
+
if (!await checkServer(server)) {
|
|
1634
|
+
console.error(`Error: OpenCode server not reachable at ${server}.`);
|
|
1119
1635
|
process.exit(1);
|
|
1120
1636
|
}
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
if (
|
|
1124
|
-
console.error(`Closing project window for ${serverDir}...`);
|
|
1637
|
+
|
|
1638
|
+
let session = sessionArg || state.session;
|
|
1639
|
+
if (session) {
|
|
1125
1640
|
try {
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1641
|
+
const found = await getSession(server, session);
|
|
1642
|
+
if (found.location && found.location.directory) targetDir = found.location.directory;
|
|
1643
|
+
} catch {
|
|
1644
|
+
session = null;
|
|
1130
1645
|
}
|
|
1131
|
-
} else {
|
|
1132
|
-
console.error(`Tmux window not found for ${serverDir} (state kept).`);
|
|
1133
1646
|
}
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1647
|
+
if (!session) {
|
|
1648
|
+
const sessions = await listSessions(server, directory);
|
|
1649
|
+
session = newestSessionId(sessions);
|
|
1650
|
+
if (!session) session = (await createSessionWithModel(server, undefined, directory, sessions)).id;
|
|
1651
|
+
projectState.writeProjectState(sf, { session, server });
|
|
1652
|
+
}
|
|
1138
1653
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1654
|
+
const registration = tuiRegistry.register({
|
|
1655
|
+
...tmuxTarget,
|
|
1656
|
+
shared,
|
|
1657
|
+
directory: shared ? targetDir : directory,
|
|
1658
|
+
server,
|
|
1659
|
+
session,
|
|
1660
|
+
pid: process.pid,
|
|
1661
|
+
});
|
|
1662
|
+
|
|
1663
|
+
const instance = registration.instance;
|
|
1664
|
+
const launchDir = targetDir || directory;
|
|
1665
|
+
const child = child_process.spawn('opencode', tuiArgs(server, session, launchDir), {
|
|
1666
|
+
cwd: launchDir,
|
|
1667
|
+
stdio: 'inherit',
|
|
1668
|
+
});
|
|
1669
|
+
tuiRegistry.updateInstance(instance.id, instance.token, { childPid: child.pid || null });
|
|
1142
1670
|
|
|
1143
|
-
|
|
1671
|
+
return new Promise((resolve, reject) => {
|
|
1672
|
+
let settled = false;
|
|
1673
|
+
const forward = (signal) => {
|
|
1674
|
+
try { child.kill(signal); } catch {}
|
|
1675
|
+
};
|
|
1676
|
+
const onHup = () => forward('SIGHUP');
|
|
1677
|
+
const onTerm = () => forward('SIGTERM');
|
|
1678
|
+
const onInt = () => forward('SIGINT');
|
|
1679
|
+
process.on('SIGHUP', onHup);
|
|
1680
|
+
process.on('SIGTERM', onTerm);
|
|
1681
|
+
process.on('SIGINT', onInt);
|
|
1682
|
+
const finish = (err) => {
|
|
1683
|
+
if (settled) return;
|
|
1684
|
+
settled = true;
|
|
1685
|
+
process.removeListener('SIGHUP', onHup);
|
|
1686
|
+
process.removeListener('SIGTERM', onTerm);
|
|
1687
|
+
process.removeListener('SIGINT', onInt);
|
|
1688
|
+
tuiRegistry.unregister(instance.id, instance.token);
|
|
1689
|
+
if (err) reject(err);
|
|
1690
|
+
else resolve();
|
|
1691
|
+
};
|
|
1692
|
+
child.once('error', finish);
|
|
1693
|
+
child.once('exit', () => finish());
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function tuiProjectDirectory(targetDir) {
|
|
1144
1698
|
const sf = findParentStatefile(targetDir);
|
|
1145
|
-
if (!sf)
|
|
1699
|
+
if (!sf) return null;
|
|
1700
|
+
const state = projectState.readProjectState(sf);
|
|
1701
|
+
return state && state.directory;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function formatTuiRegistration(instance) {
|
|
1705
|
+
return `${instance.shared ? 'shared' : 'project'} ${instance.pane} ${instance.directory || '(none)'} ${instance.server || '(none)'} ${instance.session || '(none)'}`;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
function cmdTuiList() {
|
|
1709
|
+
const instances = tuiRegistry.list();
|
|
1710
|
+
if (instances.length === 0) {
|
|
1711
|
+
console.log('No registered TUIs.');
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
console.log('Kind Pane Directory Server Session');
|
|
1715
|
+
for (const instance of instances) console.log(formatTuiRegistration(instance));
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
function cmdTuiStatus(targetDir, shared) {
|
|
1719
|
+
const directory = shared ? null : tuiProjectDirectory(targetDir);
|
|
1720
|
+
if (!shared && !directory) {
|
|
1146
1721
|
console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
|
|
1147
1722
|
process.exit(1);
|
|
1148
1723
|
}
|
|
1149
|
-
const
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1724
|
+
const instance = shared ? tuiRegistry.shared() : tuiRegistry.dedicated(directory);
|
|
1725
|
+
if (!instance) {
|
|
1726
|
+
console.log(shared ? 'Shared TUI: not registered' : `Project TUI: not registered (${directory})`);
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
console.log(formatTuiRegistration(instance));
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function cmdTuiDetach(targetDir, shared) {
|
|
1733
|
+
const directory = shared ? null : tuiProjectDirectory(targetDir);
|
|
1734
|
+
if (!shared && !directory) {
|
|
1735
|
+
console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
|
|
1153
1736
|
process.exit(1);
|
|
1154
1737
|
}
|
|
1155
|
-
const
|
|
1738
|
+
const instance = shared ? tuiRegistry.shared() : tuiRegistry.dedicated(directory);
|
|
1739
|
+
if (!instance) {
|
|
1740
|
+
console.log(shared ? 'Shared TUI was not registered.' : `Project TUI was not registered (${directory}).`);
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
tuiRegistry.unregister(instance.id, instance.token);
|
|
1744
|
+
console.log(`Detached ${shared ? 'shared' : 'project'} TUI from ${instance.pane}.`);
|
|
1745
|
+
}
|
|
1156
1746
|
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1747
|
+
// ---------------------------------------------------------------------------
|
|
1748
|
+
// Subcommand: list
|
|
1749
|
+
// ---------------------------------------------------------------------------
|
|
1750
|
+
|
|
1751
|
+
async function cmdList(longMode, server) {
|
|
1752
|
+
const rows = await configuredProjects(server);
|
|
1753
|
+
if (rows.length === 0) {
|
|
1754
|
+
console.log('No configured ocmux projects found.');
|
|
1160
1755
|
return;
|
|
1161
1756
|
}
|
|
1162
1757
|
|
|
1163
|
-
const
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1758
|
+
const dirW = Math.max(...rows.map(r => r.dir.length), 9);
|
|
1759
|
+
const stsW = Math.max(...rows.map(r => r.status.length), 6);
|
|
1760
|
+
const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
|
|
1761
|
+
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
|
1762
|
+
|
|
1763
|
+
console.log(`${pad('Directory', dirW)} ${pad('Status', stsW)} ${pad('Session', ssnW)}`);
|
|
1764
|
+
for (const row of rows) {
|
|
1765
|
+
console.log(`${pad(row.dir, dirW)} ${pad(row.status, stsW)} ${pad(row.session || '(none)', ssnW)}`);
|
|
1766
|
+
if (longMode && row.server) console.log(` server: ${row.server}`);
|
|
1167
1767
|
}
|
|
1168
|
-
console.error(`Recreating project window for ${dir}...`);
|
|
1169
|
-
createProjectWindow(dir, server, state.session);
|
|
1170
1768
|
}
|
|
1171
1769
|
|
|
1172
1770
|
// ---------------------------------------------------------------------------
|
|
1173
1771
|
// Subcommand: migrate — rewrite legacy .ocmux.json files to v2
|
|
1174
1772
|
// ---------------------------------------------------------------------------
|
|
1175
1773
|
|
|
1176
|
-
function cmdMigrate() {
|
|
1177
|
-
const rows =
|
|
1774
|
+
async function cmdMigrate(server) {
|
|
1775
|
+
const rows = await configuredProjects(server);
|
|
1178
1776
|
let migrated = 0;
|
|
1179
1777
|
for (const row of rows) {
|
|
1180
|
-
const sf = statefileFor(row.dir);
|
|
1778
|
+
const sf = projectState.statefileFor(row.dir);
|
|
1181
1779
|
const state = projectState.readProjectState(sf);
|
|
1182
1780
|
if (!state) continue;
|
|
1183
1781
|
if (state.version >= 2) continue;
|
|
@@ -1198,31 +1796,39 @@ function cmdMigrate() {
|
|
|
1198
1796
|
// ---------------------------------------------------------------------------
|
|
1199
1797
|
|
|
1200
1798
|
function printHelp() {
|
|
1201
|
-
console.error(`Usage: ${SCRIPT} [-l] [<subcommand>] [<directory>]`);
|
|
1799
|
+
console.error(`Usage: ${SCRIPT} [-l] [--all-projects] [<subcommand>] [<directory>]`);
|
|
1202
1800
|
console.error('');
|
|
1203
|
-
console.error('Manage project
|
|
1204
|
-
console.error('
|
|
1801
|
+
console.error('Manage project/session routing and optional registered OpenCode TUIs.');
|
|
1802
|
+
console.error('The OpenCode server is user-managed: start it with `opencode serve`.');
|
|
1205
1803
|
console.error('');
|
|
1206
1804
|
console.error('Without arguments, searches upward from <directory> (default: $PWD)');
|
|
1207
1805
|
console.error('for .ocmux.json and opens an interactive SESSION picker for that');
|
|
1208
|
-
console.error('project (requires a TTY).
|
|
1806
|
+
console.error('project (requires a TTY). The title bar names the project whose');
|
|
1807
|
+
console.error('sessions are listed. In the picker:');
|
|
1209
1808
|
console.error(' Enter switch · n new · r rename · R remind · d delete · a agents · m model');
|
|
1210
1809
|
console.error(' p projects · h help · q quit');
|
|
1211
|
-
console.error('Selecting updates .ocmux.json and
|
|
1212
|
-
console.error('
|
|
1213
|
-
console.error('
|
|
1810
|
+
console.error('Selecting updates .ocmux.json and refreshes a dedicated TUI, falling');
|
|
1811
|
+
console.error('back to the shared TUI. Silent on success. (p opens projects; by');
|
|
1812
|
+
console.error('default it only inspects other projects and never modifies any');
|
|
1813
|
+
console.error('.ocmux.json — see --all-projects.)');
|
|
1214
1814
|
console.error('');
|
|
1215
1815
|
console.error('Subcommands:');
|
|
1216
|
-
console.error(' serve
|
|
1816
|
+
console.error(' serve Initialize project state for <directory> (default: $PWD).');
|
|
1217
1817
|
console.error(' --server <url> Server to record (default: $OCMUX_SERVER or http://localhost:4096).');
|
|
1218
1818
|
console.error(' --force Repoint an existing project to --server (overwrites .ocmux.json).');
|
|
1219
1819
|
console.error(' --git / --GIT Use the nearest parent with a .git entry / directory.');
|
|
1220
1820
|
console.error(' Aliased as "new" for backwards compatibility.');
|
|
1821
|
+
console.error(' tui [--shared] [--server <url>] [directory]');
|
|
1822
|
+
console.error(' Register the current tmux pane and run its OpenCode TUI.');
|
|
1823
|
+
console.error(' Without --shared it is dedicated to the current project;');
|
|
1824
|
+
console.error(' --shared makes it the single fallback for every project.');
|
|
1825
|
+
console.error(' --list lists all live registrations; --status inspects one;');
|
|
1826
|
+
console.error(' --detach unregisters one without closing its pane or TUI.');
|
|
1827
|
+
console.error(' With --status/--detach, --shared targets the shared slot;');
|
|
1828
|
+
console.error(' otherwise the slot dedicated to directory is targeted.');
|
|
1221
1829
|
console.error(' session <id|title>');
|
|
1222
1830
|
console.error(' Non-interactive session switch for the current project.');
|
|
1223
|
-
console.error(' list [-l] List
|
|
1224
|
-
console.error(' kill Close the current project window. KEEPS .ocmux.json (session memory).');
|
|
1225
|
-
console.error(' resurrect Recreate the project window from its .ocmux.json.');
|
|
1831
|
+
console.error(' list [-l] List configured projects and TUI routing status.');
|
|
1226
1832
|
console.error(' migrate Rewrite legacy .ocmux.json files to the v2 schema.');
|
|
1227
1833
|
console.error('');
|
|
1228
1834
|
console.error('Reminders: the R key in the session picker attaches a note to a');
|
|
@@ -1230,6 +1836,12 @@ function printHelp() {
|
|
|
1230
1836
|
console.error('');
|
|
1231
1837
|
console.error('Options:');
|
|
1232
1838
|
console.error(' -l Long output (list: also show server URL).');
|
|
1839
|
+
console.error(' --all-projects');
|
|
1840
|
+
console.error(' Let the picker’s project switcher (p) MOVE to another');
|
|
1841
|
+
console.error(' project, so picking a session there updates that');
|
|
1842
|
+
console.error(' project’s .ocmux.json (what agentp reads in it).');
|
|
1843
|
+
console.error(' Default: the switcher only inspects other projects');
|
|
1844
|
+
console.error(' and you always return to your own project’s list.');
|
|
1233
1845
|
console.error(' --version Show version');
|
|
1234
1846
|
console.error(' -h Show this help');
|
|
1235
1847
|
console.error('');
|
|
@@ -1247,8 +1859,13 @@ function main() {
|
|
|
1247
1859
|
let gitMode = null;
|
|
1248
1860
|
let serverArg = null;
|
|
1249
1861
|
let force = false;
|
|
1862
|
+
let allProjects = false;
|
|
1863
|
+
let sharedTui = false;
|
|
1864
|
+
let tuiSession = null;
|
|
1865
|
+
let tuiList = false;
|
|
1866
|
+
let tuiStatus = false;
|
|
1867
|
+
let tuiDetach = false;
|
|
1250
1868
|
const positional = [];
|
|
1251
|
-
let dirIsForced = false;
|
|
1252
1869
|
|
|
1253
1870
|
const parsedArgs = process.argv.slice(2);
|
|
1254
1871
|
for (let i = 0; i < parsedArgs.length; i++) {
|
|
@@ -1269,6 +1886,24 @@ function main() {
|
|
|
1269
1886
|
process.exit(1);
|
|
1270
1887
|
} else if (arg === '--force') {
|
|
1271
1888
|
force = true;
|
|
1889
|
+
} else if (arg === '--shared') {
|
|
1890
|
+
sharedTui = true;
|
|
1891
|
+
} else if (arg === '--session-id') {
|
|
1892
|
+
const val = parsedArgs[i + 1];
|
|
1893
|
+
if (!val || val.startsWith('-')) {
|
|
1894
|
+
console.error("Error: '--session-id' requires a session ID.");
|
|
1895
|
+
process.exit(1);
|
|
1896
|
+
}
|
|
1897
|
+
tuiSession = val;
|
|
1898
|
+
i++;
|
|
1899
|
+
} else if (arg === '--list') {
|
|
1900
|
+
tuiList = true;
|
|
1901
|
+
} else if (arg === '--status') {
|
|
1902
|
+
tuiStatus = true;
|
|
1903
|
+
} else if (arg === '--detach') {
|
|
1904
|
+
tuiDetach = true;
|
|
1905
|
+
} else if (arg === '--all-projects') {
|
|
1906
|
+
allProjects = true;
|
|
1272
1907
|
} else if (arg === '--git') {
|
|
1273
1908
|
if (gitMode && gitMode !== 'nearest') {
|
|
1274
1909
|
console.error("Error: cannot combine '--git' with '--GIT'.");
|
|
@@ -1287,13 +1922,19 @@ function main() {
|
|
|
1287
1922
|
} else if (arg === '-h' || arg === '--help') {
|
|
1288
1923
|
printHelp();
|
|
1289
1924
|
} else if (arg === '--') {
|
|
1290
|
-
|
|
1925
|
+
const directory = parsedArgs[i + 1];
|
|
1926
|
+
if (!directory) {
|
|
1927
|
+
console.error("Error: '--' requires a directory argument.");
|
|
1928
|
+
process.exit(1);
|
|
1929
|
+
}
|
|
1930
|
+
positional.push({ value: directory, forcedDirectory: true });
|
|
1931
|
+
i++;
|
|
1291
1932
|
} else if (arg.startsWith('-')) {
|
|
1292
1933
|
console.error(`Error: unknown option '${arg}'`);
|
|
1293
1934
|
console.error(`Run '${SCRIPT} -h' for help.`);
|
|
1294
1935
|
process.exit(1);
|
|
1295
1936
|
} else {
|
|
1296
|
-
positional.push(arg);
|
|
1937
|
+
positional.push({ value: arg, forcedDirectory: false });
|
|
1297
1938
|
}
|
|
1298
1939
|
}
|
|
1299
1940
|
|
|
@@ -1303,16 +1944,16 @@ function main() {
|
|
|
1303
1944
|
let targetDir = null;
|
|
1304
1945
|
let directRef = null;
|
|
1305
1946
|
|
|
1306
|
-
for (const
|
|
1307
|
-
|
|
1308
|
-
|
|
1947
|
+
for (const positionalArg of positional) {
|
|
1948
|
+
const arg = positionalArg.value;
|
|
1949
|
+
if (positionalArg.forcedDirectory) {
|
|
1309
1950
|
if (targetDir) {
|
|
1310
1951
|
console.error(`Error: unexpected argument '${arg}'`);
|
|
1311
1952
|
process.exit(1);
|
|
1312
1953
|
}
|
|
1313
1954
|
targetDir = resolveDir(arg);
|
|
1314
|
-
} else if (arg === 'serve' || arg === 'new' || arg === '
|
|
1315
|
-
|| arg === '
|
|
1955
|
+
} else if (arg === 'serve' || arg === 'new' || arg === 'list' || arg === 'tui'
|
|
1956
|
+
|| arg === 'session'
|
|
1316
1957
|
|| arg === 'migrate') {
|
|
1317
1958
|
if (subcommand) {
|
|
1318
1959
|
console.error(`Error: multiple subcommands specified: '${subcommand}' and '${arg}'`);
|
|
@@ -1332,6 +1973,23 @@ function main() {
|
|
|
1332
1973
|
|
|
1333
1974
|
if (!targetDir) targetDir = process.cwd();
|
|
1334
1975
|
|
|
1976
|
+
if (sharedTui && subcommand !== 'tui') {
|
|
1977
|
+
console.error("Error: '--shared' is only valid with 'ocmux tui'.");
|
|
1978
|
+
process.exit(1);
|
|
1979
|
+
}
|
|
1980
|
+
if (tuiSession && subcommand !== 'tui') {
|
|
1981
|
+
console.error("Error: '--session-id' is only valid with 'ocmux tui'.");
|
|
1982
|
+
process.exit(1);
|
|
1983
|
+
}
|
|
1984
|
+
if ((tuiList || tuiStatus || tuiDetach) && subcommand !== 'tui') {
|
|
1985
|
+
console.error("Error: '--list', '--status', and '--detach' are only valid with 'ocmux tui'.");
|
|
1986
|
+
process.exit(1);
|
|
1987
|
+
}
|
|
1988
|
+
if ([tuiList, tuiStatus, tuiDetach].filter(Boolean).length > 1) {
|
|
1989
|
+
console.error("Error: combine at most one of '--list', '--status', and '--detach'.");
|
|
1990
|
+
process.exit(1);
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1335
1993
|
// Dispatch. Each branch may throw synchronously (EXIT) for validation errors
|
|
1336
1994
|
// or return a promise for async commands; main() returns the settled promise
|
|
1337
1995
|
// so tests can await it.
|
|
@@ -1346,28 +2004,45 @@ function main() {
|
|
|
1346
2004
|
console.error("Error: 'list' does not accept a directory path.");
|
|
1347
2005
|
process.exit(1);
|
|
1348
2006
|
}
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
|
2007
|
+
p = cmdList(longMode, serverArg || DEFAULT_SERVER);
|
|
2008
|
+
} else if (subcommand === 'tui') {
|
|
2009
|
+
if (gitMode || force || allProjects || longMode) {
|
|
2010
|
+
console.error("Error: 'tui' does not accept --git, --GIT, --force, --all-projects, or -l.");
|
|
1354
2011
|
process.exit(1);
|
|
1355
2012
|
}
|
|
1356
|
-
|
|
1357
|
-
|
|
2013
|
+
if (tuiList) {
|
|
2014
|
+
if (sharedTui || serverArg || tuiSession || targetDir !== process.cwd()) {
|
|
2015
|
+
console.error("Error: 'ocmux tui --list' does not accept --shared, --server, --session-id, or a directory.");
|
|
2016
|
+
process.exit(1);
|
|
2017
|
+
}
|
|
2018
|
+
cmdTuiList();
|
|
2019
|
+
} else if (tuiStatus) {
|
|
2020
|
+
if (serverArg || tuiSession) {
|
|
2021
|
+
console.error("Error: 'ocmux tui --status' does not accept --server or --session-id.");
|
|
2022
|
+
process.exit(1);
|
|
2023
|
+
}
|
|
2024
|
+
cmdTuiStatus(targetDir, sharedTui);
|
|
2025
|
+
} else if (tuiDetach) {
|
|
2026
|
+
if (serverArg || tuiSession) {
|
|
2027
|
+
console.error("Error: 'ocmux tui --detach' does not accept --server or --session-id.");
|
|
2028
|
+
process.exit(1);
|
|
2029
|
+
}
|
|
2030
|
+
cmdTuiDetach(targetDir, sharedTui);
|
|
2031
|
+
} else {
|
|
2032
|
+
p = cmdTui(targetDir, serverArg, sharedTui, tuiSession);
|
|
2033
|
+
}
|
|
2034
|
+
} else if (subcommand === 'session') {
|
|
1358
2035
|
if (gitMode) {
|
|
1359
2036
|
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
|
1360
2037
|
process.exit(1);
|
|
1361
2038
|
}
|
|
1362
|
-
|
|
1363
|
-
} else if (subcommand === 'resurrect') {
|
|
1364
|
-
p = cmdResurrect(targetDir);
|
|
2039
|
+
p = cmdSessionSwitch(targetDir, directRef, allProjects);
|
|
1365
2040
|
} else if (subcommand === 'migrate') {
|
|
1366
2041
|
if (targetDir !== process.cwd()) {
|
|
1367
2042
|
console.error("Error: 'migrate' does not accept a directory path.");
|
|
1368
2043
|
process.exit(1);
|
|
1369
2044
|
}
|
|
1370
|
-
cmdMigrate();
|
|
2045
|
+
p = cmdMigrate(serverArg || DEFAULT_SERVER);
|
|
1371
2046
|
} else if (subcommand === 'serve' || subcommand === 'new') {
|
|
1372
2047
|
if (gitMode) {
|
|
1373
2048
|
const flagLabel = gitMode === 'root' ? '--GIT' : '--git';
|
|
@@ -1380,7 +2055,7 @@ function main() {
|
|
|
1380
2055
|
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
|
1381
2056
|
process.exit(1);
|
|
1382
2057
|
}
|
|
1383
|
-
p = cmdSessionSwitch(targetDir, null);
|
|
2058
|
+
p = cmdSessionSwitch(targetDir, null, allProjects);
|
|
1384
2059
|
}
|
|
1385
2060
|
|
|
1386
2061
|
return p.catch((err) => {
|
|
@@ -1396,4 +2071,4 @@ if (require.main === module) {
|
|
|
1396
2071
|
main();
|
|
1397
2072
|
}
|
|
1398
2073
|
|
|
1399
|
-
module.exports = { main, checkServer, newestSessionId,
|
|
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 };
|