agentp 1.14.0 → 2.0.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 +80 -83
- package/README.md +165 -51
- package/bin/agentp +525 -84
- package/bin/ocmux +1376 -580
- package/docs/specification.md +12 -454
- package/docs/specification_v2.md +652 -0
- package/lib/ocmux.js +123 -197
- package/lib/opencode.js +363 -657
- package/lib/project-state.js +213 -0
- package/package.json +1 -1
package/bin/ocmux
CHANGED
|
@@ -3,31 +3,29 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const readline = require('readline');
|
|
6
|
+
const http = require('http');
|
|
7
|
+
const { URL } = require('url');
|
|
6
8
|
const { spawnSync } = require('child_process');
|
|
7
9
|
const os = require('os');
|
|
8
10
|
const {
|
|
9
11
|
SESSION,
|
|
10
|
-
readState,
|
|
11
12
|
statefileFor,
|
|
12
13
|
tuiPaneId,
|
|
13
14
|
windowByDir,
|
|
14
15
|
windowNameByIndex,
|
|
15
16
|
activeWindowIndex,
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
listProjects,
|
|
18
|
+
activateProject,
|
|
19
|
+
relaunchTui,
|
|
20
|
+
closeProjectWindow,
|
|
21
|
+
createProjectWindow,
|
|
18
22
|
ensureSession,
|
|
19
23
|
pinWindowName,
|
|
20
24
|
sleep,
|
|
21
|
-
hashDir,
|
|
22
|
-
logfileFor,
|
|
23
|
-
URL_RE,
|
|
24
|
-
resurrectServer,
|
|
25
|
-
startServer,
|
|
26
|
-
getActiveServer,
|
|
27
|
-
activateServer,
|
|
28
25
|
tuiAttachCommand,
|
|
29
26
|
} = require('../lib/ocmux');
|
|
30
|
-
const { listSessions, listModels, switchModel, resolveModelRef } = require('../lib/opencode');
|
|
27
|
+
const { listSessions, createSession, createSessionWithModel, updateSession, listModels, switchModel, resolveModelRef, getAuthHeaders, sortSessionsByRecency, deleteSession, listAgents, getActiveSessions } = require('../lib/opencode');
|
|
28
|
+
const projectState = require('../lib/project-state');
|
|
31
29
|
|
|
32
30
|
// ---------------------------------------------------------------------------
|
|
33
31
|
// Constants
|
|
@@ -37,6 +35,7 @@ const { version } = require('../package.json');
|
|
|
37
35
|
|
|
38
36
|
const SCRIPT = path.basename(process.argv[1] || 'ocmux');
|
|
39
37
|
const HOME = os.homedir();
|
|
38
|
+
const DEFAULT_SERVER = process.env.OCMUX_SERVER || 'http://localhost:4096';
|
|
40
39
|
|
|
41
40
|
// ---------------------------------------------------------------------------
|
|
42
41
|
// Helpers
|
|
@@ -94,17 +93,9 @@ function resolveGitDir(startDir, requireDir, flagLabel) {
|
|
|
94
93
|
if (!result.dir) {
|
|
95
94
|
const label = requireDir ? '.git directory' : '.git entry';
|
|
96
95
|
console.error(`Error: no ${label} found in ${startDir} or any of its parents.`);
|
|
97
|
-
console.error(`Run '${SCRIPT} serve' without ${flagLabel} to create a
|
|
96
|
+
console.error(`Run '${SCRIPT} serve' without ${flagLabel} to create a project window in the current directory.`);
|
|
98
97
|
process.exit(1);
|
|
99
98
|
}
|
|
100
|
-
|
|
101
|
-
if (result.stateDir && result.stateDir !== result.dir) {
|
|
102
|
-
console.error(`Error: found an existing ocmux server in ${result.stateDir}.`);
|
|
103
|
-
console.error(`Refusing to create a new server at ${result.dir} because it is above that session.`);
|
|
104
|
-
console.error(`Move outside ${result.stateDir} or pass an explicit directory.`);
|
|
105
|
-
process.exit(1);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
99
|
return result.dir;
|
|
109
100
|
}
|
|
110
101
|
|
|
@@ -137,614 +128,1502 @@ function resolveDir(arg) {
|
|
|
137
128
|
process.exit(1);
|
|
138
129
|
}
|
|
139
130
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
131
|
+
// Health check: is a server answering on `server`? The server is user-managed
|
|
132
|
+
// (started with `opencode serve`); this only verifies something is listening.
|
|
133
|
+
function checkServer(server) {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
let parsed;
|
|
136
|
+
try {
|
|
137
|
+
parsed = new URL(`${server}/api/info`);
|
|
138
|
+
} catch {
|
|
139
|
+
return resolve(false);
|
|
140
|
+
}
|
|
141
|
+
const req = http.request({
|
|
142
|
+
hostname: parsed.hostname,
|
|
143
|
+
port: parsed.port,
|
|
144
|
+
path: parsed.pathname,
|
|
145
|
+
method: 'GET',
|
|
146
|
+
headers: getAuthHeaders(),
|
|
147
|
+
}, (res) => {
|
|
148
|
+
res.resume();
|
|
149
|
+
resolve(true); // any HTTP response means the server is up
|
|
150
|
+
});
|
|
151
|
+
req.setTimeout(3000, () => { req.destroy(); resolve(false); });
|
|
152
|
+
req.on('error', () => resolve(false));
|
|
153
|
+
req.end();
|
|
154
|
+
});
|
|
149
155
|
}
|
|
150
156
|
|
|
157
|
+
function newestSessionId(sessions) {
|
|
158
|
+
if (!sessions || sessions.length === 0) return null;
|
|
159
|
+
// Implicit targets skip child/subagent sessions (parentID set) — OpenCode's
|
|
160
|
+
// TUI opens those on its "Subagents" tab instead of the prompt. Callers fall
|
|
161
|
+
// back to creating a session when this returns null.
|
|
162
|
+
const sorted = sortSessionsByRecency(sessions).filter(s => s && s.id && !s.parentID);
|
|
163
|
+
return sorted.length > 0 ? sorted[0].id : null;
|
|
164
|
+
}
|
|
151
165
|
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Shared list rendering (scrollable viewport + resize-aware)
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
152
169
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
170
|
+
// Visible window for a list rendered in `height` rows around `cursor`.
|
|
171
|
+
// Pure (unit-tested). Returns { start, count }.
|
|
172
|
+
function windowFor(len, cursor, height) {
|
|
173
|
+
if (len <= 0 || height <= 0) return { start: 0, count: 0 };
|
|
174
|
+
const count = Math.min(len, height);
|
|
175
|
+
if (len <= count) return { start: 0, count };
|
|
176
|
+
let start = cursor - Math.floor(count / 2);
|
|
177
|
+
start = Math.max(0, Math.min(start, len - count));
|
|
178
|
+
return { start, count };
|
|
159
179
|
}
|
|
160
180
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
console.error('');
|
|
172
|
-
console.error('Subcommands:');
|
|
173
|
-
console.error(' serve Create a server in <directory> (default: $PWD) and attach a TUI.');
|
|
174
|
-
console.error(' Aliased as "new" for backwards compatibility.');
|
|
175
|
-
console.error(' Errors if one already exists there.');
|
|
176
|
-
console.error(' Warns if a parent directory already has a server.');
|
|
177
|
-
console.error(' --git Use the nearest parent with a .git entry (file or dir).');
|
|
178
|
-
console.error(' --GIT Use the nearest parent with a .git directory only.');
|
|
179
|
-
console.error(' Refuses to create above an existing .ocmux.json found while searching.');
|
|
180
|
-
console.error(' --print-logs Pass --print-logs to opencode serve (prints server logs to stderr).');
|
|
181
|
-
console.error(' kill Kill the server found upward from <directory>.');
|
|
182
|
-
console.error(' Removes its tmux window and state file.');
|
|
183
|
-
console.error(' resurrect Recover a dead server: kills the old window, removes the state file,');
|
|
184
|
-
console.error(' then creates a fresh server + TUI in the same directory.');
|
|
185
|
-
console.error(' Works even if no tmux window exists (stale state file).');
|
|
186
|
-
console.error(' --print-logs Pass --print-logs to the new opencode serve.');
|
|
187
|
-
console.error(' list List all running servers with their directories and URLs.');
|
|
188
|
-
console.error(' switch Interactively select a server to switch to.');
|
|
189
|
-
console.error(' j/k or arrows to move, Enter/Space selects (menu stays open),');
|
|
190
|
-
console.error(' q or Ctrl+C quits. Prints the URL of the last selected server on exit.');
|
|
191
|
-
console.error(' model Switch the model of the newest session on the current server.');
|
|
192
|
-
console.error(' With no argument: interactive model picker (requires a TTY).');
|
|
193
|
-
console.error(' With <ref>: switch directly and print the server URL on stdout,');
|
|
194
|
-
console.error(' so `agentp $(ocmux model <ref>)` switches before the next prompt.');
|
|
195
|
-
console.error(' Partial names work when unique (e.g. "ocmux model deepseek",');
|
|
196
|
-
console.error(' "opencode-go/deepseek", "...#high"). Multiple matches open the picker.');
|
|
197
|
-
console.error('');
|
|
198
|
-
console.error(' -- Treat the next argument as a directory even if it matches');
|
|
199
|
-
console.error(' a subcommand name (e.g. "ocmux -- kill").');
|
|
200
|
-
console.error('');
|
|
201
|
-
console.error('Options:');
|
|
202
|
-
console.error(' -l Long output: append " → <dir>" after the URL (serve/default).');
|
|
203
|
-
console.error(' --last Print the URL of the active tmux window (last used server).');
|
|
204
|
-
console.error(' --print-logs Pass --print-logs to opencode serve (prints server logs to stderr).');
|
|
205
|
-
console.error(' --version Show version');
|
|
206
|
-
console.error(' -h Show this help');
|
|
207
|
-
console.error('');
|
|
208
|
-
console.error('See also: agentp -- pipe prompts into OpenCode from stdin');
|
|
209
|
-
console.error(' tgagentp -- Telegram bridge for OpenCode (tgagentp --help)');
|
|
210
|
-
process.exit(0);
|
|
181
|
+
// ── Small formatting / layout helpers ──────────────────────────────
|
|
182
|
+
function centerIn(s, w) {
|
|
183
|
+
const str = String(s);
|
|
184
|
+
if (str.length >= w) return str.slice(0, w);
|
|
185
|
+
const left = Math.floor((w - str.length) / 2);
|
|
186
|
+
return ' '.repeat(left) + str + ' '.repeat(w - str.length - left);
|
|
187
|
+
}
|
|
188
|
+
function padLeft(s, w) {
|
|
189
|
+
const str = String(s);
|
|
190
|
+
return ' '.repeat(Math.max(0, w - str.length)) + str;
|
|
211
191
|
}
|
|
212
192
|
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
193
|
+
// ── '/' incremental search for interactive lists ──────────────────────────
|
|
194
|
+
// Filter `items` by `pattern`: case-insensitive, space-separated tokens that
|
|
195
|
+
// must ALL be substrings of `textOf(item)`. Empty pattern → list unchanged.
|
|
196
|
+
function filterByPattern(items, pattern, textOf) {
|
|
197
|
+
const p = String(pattern || '').trim().toLowerCase();
|
|
198
|
+
if (!p) return items;
|
|
199
|
+
const tokens = p.split(/\s+/);
|
|
200
|
+
return items.filter((it) => {
|
|
201
|
+
const hay = String(textOf(it) || '').toLowerCase();
|
|
202
|
+
return tokens.every((t) => hay.includes(t));
|
|
203
|
+
});
|
|
204
|
+
}
|
|
220
205
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
206
|
+
// State of a '/' search line: `active` a filter exists, `live` the user is
|
|
207
|
+
// still typing into it, `value` the pattern, `caret` the edit position.
|
|
208
|
+
function newSearchState() {
|
|
209
|
+
return { active: false, live: false, value: '', caret: 0 };
|
|
210
|
+
}
|
|
225
211
|
|
|
226
|
-
|
|
212
|
+
function clearSearchState(s) {
|
|
213
|
+
s.active = false;
|
|
214
|
+
s.live = false;
|
|
215
|
+
s.value = '';
|
|
216
|
+
s.caret = 0;
|
|
217
|
+
}
|
|
227
218
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
219
|
+
// Drive a '/' search line from a keypress. Returns:
|
|
220
|
+
// 'edit' — pattern/caret changed (caller re-filters and redraws)
|
|
221
|
+
// 'close' — search cancelled/cleared (caller re-filters and redraws)
|
|
222
|
+
// 'done' — search committed, pattern kept (caller redraws)
|
|
223
|
+
// 'pass' — not handled here (caller runs its normal key logic)
|
|
224
|
+
function handleSearch(str, key, s) {
|
|
225
|
+
if (str === '/' && !key.ctrl && !key.meta && !s.live) {
|
|
226
|
+
// '/' starts a new search or resumes editing the active filter.
|
|
227
|
+
if (!s.active) { s.value = ''; s.caret = 0; }
|
|
228
|
+
else s.caret = s.value.length;
|
|
229
|
+
s.active = true;
|
|
230
|
+
s.live = true;
|
|
231
|
+
return 'edit';
|
|
232
|
+
}
|
|
233
|
+
if (!s.active) return 'pass';
|
|
234
|
+
if (!s.live) {
|
|
235
|
+
// Committed filter: ESC drops it, Backspace resumes editing it.
|
|
236
|
+
if (key.name === 'escape') { clearSearchState(s); return 'close'; }
|
|
237
|
+
if (key.name === 'backspace') {
|
|
238
|
+
if (s.value === '') { clearSearchState(s); return 'close'; }
|
|
239
|
+
s.live = true;
|
|
240
|
+
s.caret = s.value.length;
|
|
241
|
+
s.value = s.value.slice(0, -1);
|
|
242
|
+
s.caret--;
|
|
243
|
+
return 'edit';
|
|
235
244
|
}
|
|
236
|
-
|
|
245
|
+
return 'pass';
|
|
237
246
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
} else {
|
|
254
|
-
tmux(['split-window', '-v', '-t', `${SESSION}:${winIdx}`, '-c', serverDir,
|
|
255
|
-
tuiAttachCommand(url)]);
|
|
247
|
+
// Live typing.
|
|
248
|
+
if (key.name === 'escape') { clearSearchState(s); return 'close'; }
|
|
249
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
250
|
+
if (!s.value.trim()) { clearSearchState(s); return 'close'; }
|
|
251
|
+
s.live = false;
|
|
252
|
+
return 'done';
|
|
253
|
+
}
|
|
254
|
+
if (key.name === 'backspace') {
|
|
255
|
+
// Backspace on an already-empty field exits the search (like ESC). Removing
|
|
256
|
+
// the last character keeps the field open.
|
|
257
|
+
if (s.value === '') { clearSearchState(s); return 'close'; }
|
|
258
|
+
if (s.caret > 0) {
|
|
259
|
+
s.value = s.value.slice(0, s.caret - 1) + s.value.slice(s.caret);
|
|
260
|
+
s.caret--;
|
|
256
261
|
}
|
|
262
|
+
return 'edit';
|
|
257
263
|
}
|
|
264
|
+
if (key.name === 'delete' || key.name === 'del') {
|
|
265
|
+
if (s.caret < s.value.length) s.value = s.value.slice(0, s.caret) + s.value.slice(s.caret + 1);
|
|
266
|
+
return 'edit';
|
|
267
|
+
}
|
|
268
|
+
if (key.name === 'left') { s.caret = Math.max(0, s.caret - 1); return 'done'; }
|
|
269
|
+
if (key.name === 'right') { s.caret = Math.min(s.value.length, s.caret + 1); return 'done'; }
|
|
270
|
+
if (key.name === 'home') { s.caret = 0; return 'done'; }
|
|
271
|
+
if (key.name === 'end') { s.caret = s.value.length; return 'done'; }
|
|
272
|
+
if (key.name === 'up' || key.name === 'down') return 'pass'; // navigate the filtered list
|
|
273
|
+
if (str && !key.ctrl && !key.meta && str >= ' ') {
|
|
274
|
+
s.value = s.value.slice(0, s.caret) + str + s.value.slice(s.caret);
|
|
275
|
+
s.caret += str.length;
|
|
276
|
+
return 'edit';
|
|
277
|
+
}
|
|
278
|
+
return 'pass';
|
|
279
|
+
}
|
|
258
280
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
281
|
+
// The inverted bottom line: normal key hints, or the search line while a
|
|
282
|
+
// '/' filter is active (live = input with caret; committed = pattern kept).
|
|
283
|
+
// The caller adds the `Enter: confirm · Esc: cancel` hint on the right via
|
|
284
|
+
// renderList's `footerRight`.
|
|
285
|
+
const SEARCH_HINT = 'Enter: confirm · Esc: cancel';
|
|
286
|
+
function searchFooter(s, base) {
|
|
287
|
+
if (!s.active) return base;
|
|
288
|
+
const shown = s.live ? s.value.slice(0, s.caret) + '▏' + s.value.slice(s.caret) : s.value;
|
|
289
|
+
return s.live ? `Search: ${shown}` : `Search: ${shown} · /: edit`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Sentinel returned by switchMenu on Ctrl+C so the caller fully exits the CLI
|
|
293
|
+
// (instead of just returning to the session menu).
|
|
294
|
+
const SWITCH_QUIT = Symbol('ocmux-switch-quit');
|
|
295
|
+
// Last-view stamp: HH:MM within the last 24h, else DD/MM/YYYY; '--:--' if unknown.
|
|
296
|
+
function fmtClock(ts) {
|
|
297
|
+
if (!ts) return '--:--';
|
|
298
|
+
const d = new Date(ts);
|
|
299
|
+
if (Date.now() - ts < 24 * 3600 * 1000) {
|
|
300
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
262
301
|
}
|
|
302
|
+
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
|
303
|
+
}
|
|
304
|
+
function fmtDuration(ms) {
|
|
305
|
+
const s = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
|
|
306
|
+
const h = Math.floor(s / 3600);
|
|
307
|
+
const m = Math.floor((s % 3600) / 60);
|
|
308
|
+
const sec = s % 60;
|
|
309
|
+
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
|
310
|
+
}
|
|
311
|
+
function fmtTokens(n) {
|
|
312
|
+
if (n == null) return null;
|
|
313
|
+
const v = Number(n);
|
|
314
|
+
if (!Number.isFinite(v)) return null;
|
|
315
|
+
if (v >= 1e9) return (v / 1e9).toFixed(1) + 'B';
|
|
316
|
+
if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M';
|
|
317
|
+
if (v >= 1e3) return Math.round(v / 1e3) + 'K';
|
|
318
|
+
return String(v);
|
|
319
|
+
}
|
|
320
|
+
function fmtCost(c) {
|
|
321
|
+
if (c == null) return null;
|
|
322
|
+
const v = Number(c);
|
|
323
|
+
return Number.isFinite(v) ? '$' + v.toFixed(4) : null;
|
|
324
|
+
}
|
|
263
325
|
|
|
264
|
-
|
|
265
|
-
|
|
326
|
+
// Lay cells out in a responsive grid (row-major), collapsing columns when narrow.
|
|
327
|
+
// A cell is a plain string or { label, value } (label rendered bold).
|
|
328
|
+
function cellPlain(c) {
|
|
329
|
+
return (c && typeof c === 'object') ? `${c.label}: ${c.value}` : String(c);
|
|
330
|
+
}
|
|
331
|
+
function cellRender(c, width, labelColor = '\x1b[1m') {
|
|
332
|
+
if (c && typeof c === 'object') {
|
|
333
|
+
const label = `${c.label}:`;
|
|
334
|
+
const avail = Math.max(0, width - label.length - 1);
|
|
335
|
+
let value = String(c.value == null ? '' : c.value);
|
|
336
|
+
if (value.length > avail) value = value.slice(0, avail);
|
|
337
|
+
const plainLen = label.length + 1 + value.length;
|
|
338
|
+
return `${labelColor}${label}\x1b[0m ${value}` + ' '.repeat(Math.max(0, width - plainLen));
|
|
339
|
+
}
|
|
340
|
+
return String(c) + ' '.repeat(Math.max(0, width - String(c).length));
|
|
341
|
+
}
|
|
342
|
+
function layoutCells(cells, cols, minCell = 26, labelColor = '\x1b[1m') {
|
|
343
|
+
if (!cells || cells.length === 0) return [];
|
|
344
|
+
const plains = cells.map(cellPlain);
|
|
345
|
+
let nCols = Math.max(1, Math.min(cells.length, Math.floor(Math.max(cols, 1) / minCell)));
|
|
346
|
+
const colWidths = (nc) => {
|
|
347
|
+
const w = new Array(nc).fill(0);
|
|
348
|
+
plains.forEach((p, i) => { w[i % nc] = Math.max(w[i % nc], p.length); });
|
|
349
|
+
return w;
|
|
350
|
+
};
|
|
351
|
+
let widths = colWidths(nCols);
|
|
352
|
+
while (nCols > 1 && widths.reduce((a, b) => a + b, 0) + 2 * (nCols - 1) > cols) {
|
|
353
|
+
nCols--;
|
|
354
|
+
widths = colWidths(nCols);
|
|
355
|
+
}
|
|
356
|
+
const nRows = Math.ceil(cells.length / nCols);
|
|
357
|
+
const lines = [];
|
|
358
|
+
for (let r = 0; r < nRows; r++) {
|
|
359
|
+
const parts = [];
|
|
360
|
+
for (let c = 0; c < nCols; c++) {
|
|
361
|
+
const idx = r * nCols + c;
|
|
362
|
+
if (idx >= cells.length) break;
|
|
363
|
+
parts.push(cellRender(cells[idx], widths[c], labelColor));
|
|
364
|
+
}
|
|
365
|
+
lines.push(parts.join(' ').trimEnd());
|
|
366
|
+
}
|
|
367
|
+
return lines;
|
|
266
368
|
}
|
|
267
369
|
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
370
|
+
// Bold a label and append a (truncated) value, fitting `cols`.
|
|
371
|
+
function labeledLine(label, value, cols, labelColor = '\x1b[1m') {
|
|
372
|
+
const lbl = `${label}:`;
|
|
373
|
+
const avail = Math.max(0, cols - lbl.length - 1);
|
|
374
|
+
let v = String(value == null ? '' : value);
|
|
375
|
+
if (v.length > avail) v = v.slice(0, avail);
|
|
376
|
+
return `${labelColor}${lbl}\x1b[0m ${v}`;
|
|
377
|
+
}
|
|
271
378
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
379
|
+
// Info lines about the selected session: title + location (bold labels) and a
|
|
380
|
+
// responsive grid. Fields are ALWAYS present (placeholders when unknown).
|
|
381
|
+
// When `highlight` is true (the cursor is on the current session), labels are
|
|
382
|
+
// yellow instead of white.
|
|
383
|
+
function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false) {
|
|
384
|
+
if (!s) return [];
|
|
385
|
+
const labelColor = highlight ? '\x1b[1;33m' : '\x1b[1m';
|
|
386
|
+
const title = (s.title && s.title.trim()) || 'Untitled';
|
|
387
|
+
const location = (s.location && s.location.directory) || 'Unknown';
|
|
388
|
+
const out = [
|
|
389
|
+
labeledLine('Title', title, cols, labelColor),
|
|
390
|
+
labeledLine('Location', location, cols, labelColor),
|
|
391
|
+
];
|
|
392
|
+
|
|
393
|
+
const model = s.model ? `${s.model.providerID}/${s.model.id}` : null;
|
|
394
|
+
const modelLabel = model
|
|
395
|
+
? model + (s.model.variant && s.model.variant !== 'default' ? '#' + s.model.variant : '')
|
|
396
|
+
: 'Unknown';
|
|
397
|
+
const busy = !!(runningIds && runningIds.has(s.id));
|
|
398
|
+
const since = busy
|
|
399
|
+
? (s.time && (s.time.updated || s.time.created))
|
|
400
|
+
: (s.time && (s.time.idle || s.time.updated || s.time.created));
|
|
401
|
+
const status = `${busy ? 'BUSY' : 'IDLE'}${since ? ' ' + fmtDuration(Date.now() - since) : ' --:--:--'}`;
|
|
402
|
+
|
|
403
|
+
let tokensTxt = '--';
|
|
404
|
+
if (s.tokens) {
|
|
405
|
+
const t = s.tokens;
|
|
406
|
+
const total = (t.input || 0) + (t.output || 0) + (t.reasoning || 0);
|
|
407
|
+
tokensTxt = fmtTokens(total) || '--';
|
|
408
|
+
if (t.cache && t.cache.read) tokensTxt += ' (+' + fmtTokens(t.cache.read) + ' cache)';
|
|
277
409
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
410
|
+
const cost = fmtCost(s.cost) || '--';
|
|
411
|
+
const ctx = (model && modelContext && modelContext.get(model)) ? fmtTokens(modelContext.get(model)) : '--';
|
|
412
|
+
|
|
413
|
+
const cells = [
|
|
414
|
+
{ label: 'Model', value: modelLabel },
|
|
415
|
+
{ label: 'Agent', value: s.agent || 'Unknown' },
|
|
416
|
+
{ label: 'Status', value: status },
|
|
417
|
+
{ label: 'Tokens', value: tokensTxt },
|
|
418
|
+
{ label: 'Cost', value: cost },
|
|
419
|
+
{ label: 'Ctx', value: ctx },
|
|
420
|
+
{ label: 'Outcome', value: s.outcome || 'Unknown' },
|
|
421
|
+
];
|
|
422
|
+
return out.concat(layoutCells(cells, cols, 26, labelColor));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Render a scrollable list into the alternate screen.
|
|
426
|
+
// heading -> centered + bold + inverted heading
|
|
427
|
+
// row(index, item) -> string | { text, reverse }
|
|
428
|
+
// extraLines -> prompt/confirm lines right after the list (normal colors)
|
|
429
|
+
// footer -> inverted, RIGHT-aligned key-hint bar (acts as a separator)
|
|
430
|
+
// (left-aligned when footerLeft is set — used by the search line)
|
|
431
|
+
// footerRight-> optional hint pinned to the right end of a left-aligned footer
|
|
432
|
+
// infoLines -> details below the footer (normal colors, bold labels)
|
|
433
|
+
// 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 }) {
|
|
435
|
+
const cols = colsArg || process.stdout.columns || 80;
|
|
436
|
+
const termRows = rowsArg || process.stdout.rows || 24;
|
|
437
|
+
const extras = (extraLines || []).filter((l) => l != null && l !== '');
|
|
438
|
+
const infos = (infoLines || []).filter((l) => l != null && l !== '');
|
|
439
|
+
|
|
440
|
+
// Chrome: heading + blank, extras, footer bar, infos.
|
|
441
|
+
const reserved = 2 + extras.length + 1 + infos.length;
|
|
442
|
+
const viewHeight = Math.max(1, termRows - reserved);
|
|
443
|
+
const cursorIdx = items.length ? Math.max(0, Math.min(cursor, items.length - 1)) : 0;
|
|
444
|
+
const win = windowFor(items.length, cursorIdx, viewHeight);
|
|
445
|
+
|
|
446
|
+
let titleLine = title;
|
|
447
|
+
if (items.length > win.count) {
|
|
448
|
+
titleLine += ` (${win.start + 1}-${win.start + win.count}/${items.length})`;
|
|
282
449
|
}
|
|
283
450
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const refreshActive = () => {
|
|
301
|
-
const idx = activeWindowIndex();
|
|
302
|
-
if (idx == null) return;
|
|
303
|
-
for (let i = 0; i < rows.length; i++) {
|
|
304
|
-
if (rows[i].index === idx) { activeRow = i; return; }
|
|
451
|
+
let out = '\x1b[2J\x1b[H';
|
|
452
|
+
out += '\x1b[1;7m' + centerIn(titleLine, cols) + '\x1b[0m\n\n';
|
|
453
|
+
|
|
454
|
+
let rendered = 0;
|
|
455
|
+
if (items.length === 0) {
|
|
456
|
+
out += ' (none)\n';
|
|
457
|
+
rendered = 1;
|
|
458
|
+
} else {
|
|
459
|
+
for (let i = 0; i < win.count; i++) {
|
|
460
|
+
const idx = win.start + i;
|
|
461
|
+
const r = row(idx, items[idx]);
|
|
462
|
+
const text = r && typeof r === 'object' ? r.text : r;
|
|
463
|
+
const reverse = r && typeof r === 'object' && r.reverse;
|
|
464
|
+
const line = `${idx === cursor ? '▶ ' : ' '}${text == null ? '' : text}`;
|
|
465
|
+
out += (reverse ? `\x1b[7m${line}\x1b[0m` : line) + '\n';
|
|
466
|
+
rendered++;
|
|
305
467
|
}
|
|
306
|
-
}
|
|
468
|
+
}
|
|
469
|
+
// Pad the list area so the footer stays pinned to the bottom of the terminal
|
|
470
|
+
// (instead of floating right under the last row on a tall window).
|
|
471
|
+
for (let i = rendered; i < viewHeight; i++) out += '\n';
|
|
472
|
+
for (const l of extras) out += l + '\n';
|
|
473
|
+
let hint;
|
|
474
|
+
if (footerRight) {
|
|
475
|
+
// Left-aligned text plus a hint pinned to the right end of the bar.
|
|
476
|
+
const left = footer.length + footerRight.length + 1 <= cols
|
|
477
|
+
? footer
|
|
478
|
+
: footer.slice(0, Math.max(0, cols - footerRight.length - 1));
|
|
479
|
+
const gap = Math.max(0, cols - left.length - footerRight.length);
|
|
480
|
+
hint = (left + ' '.repeat(gap) + footerRight).slice(0, cols);
|
|
481
|
+
} else if (footer.length > cols) {
|
|
482
|
+
hint = footer.slice(0, cols);
|
|
483
|
+
} else if (footerLeft) {
|
|
484
|
+
hint = footer + ' '.repeat(cols - footer.length);
|
|
485
|
+
} else {
|
|
486
|
+
hint = padLeft(footer, cols);
|
|
487
|
+
}
|
|
488
|
+
out += '\x1b[7m' + hint + '\x1b[0m\n';
|
|
489
|
+
for (const l of infos) out += l + '\n';
|
|
490
|
+
return out.replace(/\n$/, '');
|
|
491
|
+
}
|
|
307
492
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
493
|
+
// Render an interactive session picker (alt screen). Sorted most-recently-viewed
|
|
494
|
+
// first by the caller. Keys: Enter/Space switch (menu stays open), n create,
|
|
495
|
+
// r rename, d delete (confirm), a annotate, p switch project, h help,
|
|
496
|
+
// q/Ctrl+C quit. Input mode offers readline-style caret editing.
|
|
497
|
+
// Resolves on quit (null) or with the string 'switch-project' (caller then runs
|
|
498
|
+
// the project switcher and re-invokes this picker for the new project).
|
|
499
|
+
function sessionMenu(sessions, currentSession, annotations, server, { onPick, onRename, onDelete, onReminder, runningIds, modelContext, onBroadcastChange, onInspectSession } = {}) {
|
|
500
|
+
return new Promise((resolve) => {
|
|
501
|
+
if (!process.stdin.isTTY) return resolve(null);
|
|
313
502
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
503
|
+
let rows = [...sessions];
|
|
504
|
+
let current = currentSession;
|
|
505
|
+
let cursor = 0;
|
|
506
|
+
let mode = 'normal'; // 'normal' | 'help' | 'confirm' | 'input'
|
|
507
|
+
let confirmDel = null;
|
|
508
|
+
let input = null; // { label, value, kind, caret }
|
|
509
|
+
let list = null; // { title, items, format, onEnter }
|
|
510
|
+
let listCursor = 0;
|
|
511
|
+
let done = false;
|
|
512
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
513
|
+
let spinIdx = 0;
|
|
514
|
+
let spinTimer = null;
|
|
515
|
+
let broadcastSel = new Set(); // session ids selected in broadcast mode
|
|
516
|
+
let search = newSearchState(); // '/' filter for the session list
|
|
517
|
+
let listSearch = newSearchState(); // '/' filter for inline lists (models/agents)
|
|
518
|
+
let view = rows; // rows filtered by `search` (what is displayed)
|
|
519
|
+
let inspected = false; // broadcast inspection moved the TUI off `current`
|
|
520
|
+
|
|
521
|
+
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
|
522
|
+
const helpLines = [
|
|
523
|
+
'Enter switch to the selected session (stays open)',
|
|
524
|
+
'Space start BROADCAST: add the selected session to the current one',
|
|
525
|
+
' (Space toggles each session; Enter keeps the selection)',
|
|
526
|
+
'n create a new session (type a name, Enter; blank = auto)',
|
|
527
|
+
'r rename the selected session (edit in place; ESC cancels)',
|
|
528
|
+
'R (Shift+r) set a REMINDER for the selected session (sent in front of',
|
|
529
|
+
' every agentp prompt; blank clears)',
|
|
530
|
+
'd delete the selected session (then y/N to confirm)',
|
|
531
|
+
'a switch the AGENT of the selected session',
|
|
532
|
+
'm switch the MODEL of the selected session',
|
|
533
|
+
'p open the project switcher',
|
|
534
|
+
'/ search/filter this list (Enter keeps the filter,',
|
|
535
|
+
' ESC clears it, / resumes editing it)',
|
|
536
|
+
'j / k, arrows move the cursor',
|
|
537
|
+
'h toggle this help',
|
|
538
|
+
'q / Ctrl+C quit',
|
|
539
|
+
'',
|
|
540
|
+
'While typing (n/r/R):',
|
|
541
|
+
' left/right, Home/End move the caret',
|
|
542
|
+
' Backspace / Delete edit in place',
|
|
543
|
+
' Enter confirm, ESC cancels',
|
|
544
|
+
];
|
|
322
545
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
546
|
+
const cleanup = () => {
|
|
547
|
+
process.stderr.write('\x1b[?25h');
|
|
548
|
+
process.stderr.write('\x1b[?1049l');
|
|
549
|
+
if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
|
|
550
|
+
process.stdout.removeListener('resize', draw);
|
|
551
|
+
try { process.stdin.setRawMode(false); } catch {}
|
|
552
|
+
process.stdin.pause();
|
|
553
|
+
};
|
|
554
|
+
const finish = (value) => {
|
|
555
|
+
if (done) return;
|
|
556
|
+
done = true;
|
|
557
|
+
cleanup();
|
|
558
|
+
process.stdin.removeAllListeners('keypress');
|
|
559
|
+
process.removeListener('SIGINT', onSig);
|
|
560
|
+
resolve(value);
|
|
561
|
+
};
|
|
562
|
+
const onSig = () => finish(null);
|
|
563
|
+
process.on('SIGINT', onSig);
|
|
564
|
+
|
|
565
|
+
const renderInputValue = (v) => {
|
|
566
|
+
const c = input.caret;
|
|
567
|
+
return v.slice(0, c) + '▏' + v.slice(c);
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
const draw = () => {
|
|
571
|
+
if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help') {
|
|
572
|
+
let out = '\x1b[2J\x1b[H';
|
|
573
|
+
if (mode === 'broadcast-help') {
|
|
574
|
+
out += 'ocmux broadcast mode — help\n\n';
|
|
575
|
+
out += ' Space select/deselect the cursor session\n';
|
|
576
|
+
out += ' Enter keep the broadcast selection; switch TUI to cursor\n';
|
|
577
|
+
out += ' m switch the model for ALL selected sessions\n';
|
|
578
|
+
out += ' d delete the cursor session (with confirmation)\n';
|
|
579
|
+
out += ' j / k, arrows move the cursor (selecting shows it in the TUI)\n';
|
|
580
|
+
out += ' / search/filter the session list\n';
|
|
581
|
+
out += ' ESC / q cancel broadcast mode (back to the session list)\n';
|
|
582
|
+
out += ' Ctrl+C quit ocmux\n';
|
|
583
|
+
} else if (mode === 'list-help') {
|
|
584
|
+
out += `ocmux ${list && list.title ? list.title : 'list'} — help\n\n`;
|
|
585
|
+
out += ' j / k, arrows move the cursor\n';
|
|
586
|
+
out += ' Enter / Space select the highlighted item\n';
|
|
587
|
+
out += ' / search/filter the list (Enter keeps, ESC clears)\n';
|
|
588
|
+
out += ' h toggle this help\n';
|
|
589
|
+
out += ' ESC / q / Backspace return to the previous menu\n';
|
|
590
|
+
out += ' Ctrl+C quit ocmux\n';
|
|
591
|
+
} else {
|
|
592
|
+
out += 'ocmux session picker — help\n\n';
|
|
593
|
+
for (const l of helpLines) out += ' ' + l + '\n';
|
|
594
|
+
}
|
|
595
|
+
out += '\nPress any key to return.\n';
|
|
596
|
+
process.stderr.write(out);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (mode === 'list' && list) {
|
|
600
|
+
const listExtras = (listSearch.active && list.view.length === 0) ? [`No matches for "${listSearch.value}"`] : [];
|
|
601
|
+
process.stderr.write(renderList({
|
|
602
|
+
title: `ocmux — ${list.title}`,
|
|
603
|
+
items: list.view,
|
|
604
|
+
cursor: listCursor,
|
|
605
|
+
row: (i, item) => list.format(item) || '',
|
|
606
|
+
footer: searchFooter(listSearch, 'Enter: select · /: search · h: help · ESC: back'),
|
|
607
|
+
footerLeft: listSearch.active,
|
|
608
|
+
footerRight: listSearch.active ? SEARCH_HINT : null,
|
|
609
|
+
extraLines: listExtras,
|
|
610
|
+
}));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const cols = process.stdout.columns || 80;
|
|
614
|
+
const showTime = cols >= 48;
|
|
615
|
+
const TIME_W = 10; // dd/mm/yyyy worst case
|
|
616
|
+
const maxLen = Math.min(
|
|
617
|
+
Math.max(...rows.map(s => (s.title || s.id || '').length), 8) + 6,
|
|
618
|
+
Math.max(8, cols - (showTime ? TIME_W + 10 : 8)),
|
|
619
|
+
);
|
|
620
|
+
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
|
+
}
|
|
627
|
+
if (search.active && view.length === 0) extras.push(`No sessions match "${search.value}"`);
|
|
628
|
+
const selected = view[cursor];
|
|
629
|
+
let infoLines = [];
|
|
630
|
+
let footer = 'Enter: switch · Space: broadcast · /: search · h: help · q: quit';
|
|
631
|
+
if (inBc) {
|
|
632
|
+
const names = rows.filter(r => broadcastSel.has(String(r.id))).map(r => r.title || r.id).join(', ');
|
|
633
|
+
infoLines = [`Broadcast to sessions: ${names}`];
|
|
634
|
+
footer = 'Enter: switch · Space: toggle · d: delete · m: model · /: search · h: help · q: cancel · Ctrl+C: quit';
|
|
333
635
|
} else {
|
|
334
|
-
|
|
636
|
+
const selIsCurrent = !!(selected && String(selected.id) === String(current));
|
|
637
|
+
infoLines = sessionInfoLines(selected, runningIds, modelContext, cols, selIsCurrent);
|
|
335
638
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
639
|
+
footer = searchFooter(search, footer);
|
|
640
|
+
process.stderr.write(renderList({
|
|
641
|
+
title: 'ocmux — sessions',
|
|
642
|
+
items: view,
|
|
643
|
+
cursor,
|
|
644
|
+
row: (i, s) => {
|
|
645
|
+
const sel = inBc ? (broadcastSel.has(String(s.id)) ? '[x] ' : '[ ] ') : '';
|
|
646
|
+
const title = (s.title || s.id || '').slice(0, maxLen);
|
|
647
|
+
const timeStr = showTime ? padLeft(fmtClock(s.time && s.time.viewed), TIME_W) + ' ' : '';
|
|
648
|
+
const mark = String(s.id) === String(current) ? ' *' : '';
|
|
649
|
+
const hasNote = annotations && annotations[s.id] ? ' ◈' : '';
|
|
650
|
+
const spin = (runningIds && runningIds.has(s.id)) ? SPINNER[spinIdx % SPINNER.length] + ' ' : ' ';
|
|
651
|
+
const text = `${sel}${spin}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
|
|
652
|
+
// Yellow for the session currently selected in the TUI window.
|
|
653
|
+
return String(s.id) === String(current) ? `\x1b[33m${text}\x1b[0m` : text;
|
|
654
|
+
},
|
|
655
|
+
footer,
|
|
656
|
+
footerLeft: search.active,
|
|
657
|
+
footerRight: search.active ? SEARCH_HINT : null,
|
|
658
|
+
infoLines,
|
|
659
|
+
extraLines: extras,
|
|
660
|
+
}));
|
|
661
|
+
};
|
|
351
662
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (key.name === 'up' || str === 'k') {
|
|
356
|
-
cursor = (cursor + rows.length - 1) % rows.length;
|
|
663
|
+
const startInput = (label, value, kind) => {
|
|
664
|
+
input = { label, value, kind, caret: value.length };
|
|
665
|
+
mode = 'input';
|
|
357
666
|
draw();
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const applyRenameWith = (name) => {
|
|
670
|
+
const row = view[cursor];
|
|
671
|
+
if (!row || !onRename || !name) return;
|
|
672
|
+
Promise.resolve(onRename(row, name)).then((updated) => {
|
|
673
|
+
row.title = (updated && updated.title) || name;
|
|
674
|
+
draw();
|
|
675
|
+
}).catch((err) => {
|
|
676
|
+
process.stderr.write(`\nError: ${err.message}\n`);
|
|
677
|
+
draw();
|
|
678
|
+
});
|
|
679
|
+
};
|
|
365
680
|
|
|
366
|
-
|
|
367
|
-
|
|
681
|
+
const applyReminder = (text) => {
|
|
682
|
+
const row = view[cursor];
|
|
683
|
+
if (!row || !onReminder) return;
|
|
684
|
+
Promise.resolve(onReminder(row, text)).then((saved) => {
|
|
685
|
+
if (saved) annotations[row.id] = saved;
|
|
686
|
+
else delete annotations[row.id];
|
|
687
|
+
draw();
|
|
688
|
+
}).catch((err) => {
|
|
689
|
+
process.stderr.write(`\nError: ${err.message}\n`);
|
|
690
|
+
draw();
|
|
691
|
+
});
|
|
692
|
+
};
|
|
368
693
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
694
|
+
const applyPick = (row) => {
|
|
695
|
+
Promise.resolve(onPick ? onPick(row) : null).then((created) => {
|
|
696
|
+
if (row.new && created) {
|
|
697
|
+
rows.unshift(created);
|
|
698
|
+
applySearch();
|
|
699
|
+
const ci = view.indexOf(created);
|
|
700
|
+
cursor = ci >= 0 ? ci : 0;
|
|
701
|
+
} else if (!row.new) {
|
|
702
|
+
current = row.id;
|
|
703
|
+
}
|
|
704
|
+
inspected = false;
|
|
705
|
+
confirmDel = null;
|
|
706
|
+
draw();
|
|
707
|
+
}).catch((err) => {
|
|
708
|
+
process.stderr.write(`\nError: ${err.message}\n`);
|
|
709
|
+
confirmDel = null;
|
|
710
|
+
draw();
|
|
711
|
+
});
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
const applyDelete = () => {
|
|
715
|
+
const s = confirmDel;
|
|
716
|
+
if (!s || !onDelete) return;
|
|
717
|
+
const wasBroadcast = mode === 'broadcast-confirm';
|
|
718
|
+
Promise.resolve(onDelete(s)).then(() => {
|
|
719
|
+
rows = rows.filter(r => r.id !== s.id);
|
|
720
|
+
applySearch();
|
|
721
|
+
if (cursor >= view.length && cursor > 0) cursor = view.length - 1;
|
|
722
|
+
confirmDel = null;
|
|
723
|
+
if (wasBroadcast) {
|
|
724
|
+
broadcastSel.delete(String(s.id));
|
|
725
|
+
if (broadcastSel.size < 2) {
|
|
726
|
+
// Broadcast ended: make the remaining selected session the real
|
|
727
|
+
// selection (switch the TUI to it and store it).
|
|
728
|
+
const remaining = [...broadcastSel][0];
|
|
729
|
+
persistBroadcast([]);
|
|
730
|
+
broadcastSel = new Set();
|
|
731
|
+
mode = 'normal';
|
|
732
|
+
const row = rows.find(r => String(r.id) === String(remaining));
|
|
733
|
+
if (row) applyPick(row);
|
|
734
|
+
else { draw(); }
|
|
735
|
+
} else {
|
|
736
|
+
persistBroadcast([...broadcastSel]);
|
|
737
|
+
mode = 'broadcast';
|
|
738
|
+
draw();
|
|
739
|
+
}
|
|
740
|
+
} else {
|
|
741
|
+
mode = 'normal';
|
|
742
|
+
draw();
|
|
743
|
+
}
|
|
744
|
+
}).catch((err) => {
|
|
745
|
+
process.stderr.write(`\nError: ${err.message}\n`);
|
|
746
|
+
confirmDel = null;
|
|
747
|
+
mode = wasBroadcast ? 'broadcast' : 'normal';
|
|
748
|
+
draw();
|
|
749
|
+
});
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
const persistBroadcast = (ids) => {
|
|
753
|
+
if (onBroadcastChange) onBroadcastChange(ids);
|
|
754
|
+
};
|
|
755
|
+
// Recompute the filtered session view after `rows` or the pattern change.
|
|
756
|
+
const applySearch = () => {
|
|
757
|
+
view = filterByPattern(rows, search.active ? search.value : '',
|
|
758
|
+
(r) => `${r.title || ''} ${r.id || ''}`);
|
|
759
|
+
if (cursor >= view.length) cursor = Math.max(0, view.length - 1);
|
|
760
|
+
};
|
|
761
|
+
// Recompute the filtered view of the currently open inline list.
|
|
762
|
+
const applyListSearch = () => {
|
|
763
|
+
if (!list) return;
|
|
764
|
+
list.view = filterByPattern(list.items, listSearch.active ? listSearch.value : '', list.format);
|
|
765
|
+
if (listCursor >= list.view.length) listCursor = Math.max(0, list.view.length - 1);
|
|
766
|
+
};
|
|
767
|
+
// After a broadcast attempt (cancel / drop below two): put the menu — and
|
|
768
|
+
// the TUI — back on the stored current session, which inspection may have
|
|
769
|
+
// relaunched away without updating `.ocmux.json`.
|
|
770
|
+
const restoreCurrent = () => {
|
|
771
|
+
const idx = view.findIndex((r) => String(r.id) === String(current));
|
|
772
|
+
if (idx >= 0) cursor = idx;
|
|
773
|
+
if (inspected && current != null && onInspectSession) onInspectSession(current);
|
|
774
|
+
inspected = false;
|
|
775
|
+
};
|
|
776
|
+
const startBroadcast = (idx) => {
|
|
777
|
+
broadcastSel = new Set([String(current), String(view[idx].id)]);
|
|
778
|
+
persistBroadcast([...broadcastSel]);
|
|
779
|
+
mode = 'broadcast';
|
|
780
|
+
if (onInspectSession) { onInspectSession(view[idx].id); inspected = true; }
|
|
375
781
|
draw();
|
|
376
|
-
}
|
|
782
|
+
};
|
|
783
|
+
const toggleBroadcast = (idx) => {
|
|
784
|
+
const id = String(view[idx].id);
|
|
785
|
+
const adding = !broadcastSel.has(id);
|
|
786
|
+
if (adding) broadcastSel.add(id);
|
|
787
|
+
else broadcastSel.delete(id);
|
|
788
|
+
if (adding && onInspectSession) { onInspectSession(id); inspected = true; }
|
|
789
|
+
if (broadcastSel.size < 2) {
|
|
790
|
+
// Deselecting down to one session ends broadcast mode by making that
|
|
791
|
+
// remaining session the actual selection.
|
|
792
|
+
const remaining = [...broadcastSel][0];
|
|
793
|
+
persistBroadcast([]);
|
|
794
|
+
broadcastSel = new Set();
|
|
795
|
+
mode = 'normal';
|
|
796
|
+
const row = rows.find(r => String(r.id) === String(remaining));
|
|
797
|
+
if (row) applyPick(row);
|
|
798
|
+
else { restoreCurrent(); draw(); }
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
persistBroadcast([...broadcastSel]);
|
|
802
|
+
draw();
|
|
803
|
+
};
|
|
377
804
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
805
|
+
const openList = (title, items, format, onEnter, returnMode = 'normal') => {
|
|
806
|
+
if (!items || items.length === 0) {
|
|
807
|
+
process.stderr.write(`\nNo ${title.toLowerCase()} available.\n`);
|
|
808
|
+
draw();
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
list = { title, items, view: items, format, onEnter, returnMode };
|
|
812
|
+
listSearch = newSearchState();
|
|
813
|
+
listCursor = 0;
|
|
814
|
+
mode = 'list';
|
|
815
|
+
draw();
|
|
816
|
+
};
|
|
381
817
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
.
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
818
|
+
const commandErr = (err) => {
|
|
819
|
+
process.stderr.write(`\nError: ${err.message}\n`);
|
|
820
|
+
draw();
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
process.stdin.on('keypress', (str, key) => {
|
|
824
|
+
// Ctrl+C always fully exits ocmux, from any menu.
|
|
825
|
+
if (key.ctrl && key.name === 'c') {
|
|
826
|
+
if (mode === 'broadcast' || mode === 'broadcast-confirm') {
|
|
827
|
+
persistBroadcast([]);
|
|
828
|
+
broadcastSel = new Set();
|
|
829
|
+
restoreCurrent();
|
|
830
|
+
}
|
|
831
|
+
return finish(null);
|
|
832
|
+
}
|
|
833
|
+
if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help') {
|
|
834
|
+
mode = mode === 'broadcast-help' ? 'broadcast' : (mode === 'list-help' ? 'list' : 'normal');
|
|
835
|
+
draw();
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (mode === 'confirm' || mode === 'broadcast-confirm') {
|
|
839
|
+
if (key.name === 'y') return applyDelete();
|
|
840
|
+
if (key.name === 'n' || key.name === 'escape' || key.name === 'q' || key.name === 'return') {
|
|
841
|
+
confirmDel = null;
|
|
842
|
+
mode = mode === 'broadcast-confirm' ? 'broadcast' : 'normal';
|
|
843
|
+
draw();
|
|
844
|
+
}
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (mode === 'input') {
|
|
848
|
+
if (key.name === 'escape') {
|
|
849
|
+
input = null;
|
|
850
|
+
mode = 'normal';
|
|
851
|
+
draw();
|
|
852
|
+
} else if (key.name === 'return' || key.name === 'enter') {
|
|
853
|
+
const v = input.value;
|
|
854
|
+
const kind = input.kind;
|
|
855
|
+
input = null;
|
|
856
|
+
mode = 'normal';
|
|
857
|
+
draw();
|
|
858
|
+
if (kind === 'create') {
|
|
859
|
+
applyPick({ new: true, name: v.trim() });
|
|
860
|
+
} else if (kind === 'reminder') {
|
|
861
|
+
applyReminder(v.trim());
|
|
862
|
+
} else if (v.trim()) {
|
|
863
|
+
applyRenameWith(v.trim());
|
|
864
|
+
}
|
|
865
|
+
} else if (key.name === 'backspace') {
|
|
866
|
+
if (input.caret > 0) {
|
|
867
|
+
input.value = input.value.slice(0, input.caret - 1) + input.value.slice(input.caret);
|
|
868
|
+
input.caret--;
|
|
869
|
+
}
|
|
870
|
+
draw();
|
|
871
|
+
} else if (key.name === 'delete' || key.name === 'del') {
|
|
872
|
+
if (input.caret < input.value.length) {
|
|
873
|
+
input.value = input.value.slice(0, input.caret) + input.value.slice(input.caret + 1);
|
|
874
|
+
}
|
|
875
|
+
draw();
|
|
876
|
+
} else if (key.name === 'left' || str === '\u001b[D') {
|
|
877
|
+
input.caret = Math.max(0, input.caret - 1);
|
|
878
|
+
draw();
|
|
879
|
+
} else if (key.name === 'right' || str === '\u001b[C') {
|
|
880
|
+
input.caret = Math.min(input.value.length, input.caret + 1);
|
|
881
|
+
draw();
|
|
882
|
+
} else if (key.name === 'home') {
|
|
883
|
+
input.caret = 0;
|
|
884
|
+
draw();
|
|
885
|
+
} else if (key.name === 'end') {
|
|
886
|
+
input.caret = input.value.length;
|
|
887
|
+
draw();
|
|
888
|
+
} else if (str && !key.ctrl && !key.meta && str >= ' ') {
|
|
889
|
+
input.value = input.value.slice(0, input.caret) + str + input.value.slice(input.caret);
|
|
890
|
+
input.caret++;
|
|
891
|
+
draw();
|
|
892
|
+
}
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (mode === 'list') {
|
|
896
|
+
const lr = handleSearch(str, key, listSearch);
|
|
897
|
+
if (lr !== 'pass') { applyListSearch(); draw(); return; }
|
|
898
|
+
if (key.name === 'escape' || key.name === 'q' || key.name === 'backspace') {
|
|
899
|
+
const returnMode = list.returnMode;
|
|
900
|
+
list = null;
|
|
901
|
+
mode = returnMode;
|
|
902
|
+
draw();
|
|
903
|
+
} else if (key.name === 'h') {
|
|
904
|
+
mode = 'list-help';
|
|
905
|
+
draw();
|
|
906
|
+
} else if (key.name === 'up' || str === 'k') {
|
|
907
|
+
if (list.view.length) listCursor = (listCursor - 1 + list.view.length) % list.view.length;
|
|
908
|
+
draw();
|
|
909
|
+
} else if (key.name === 'down' || str === 'j') {
|
|
910
|
+
if (list.view.length) listCursor = (listCursor + 1) % list.view.length;
|
|
911
|
+
draw();
|
|
912
|
+
} else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
|
|
913
|
+
const item = list.view[listCursor];
|
|
914
|
+
if (item === undefined) { draw(); return; }
|
|
915
|
+
const onEnter = list.onEnter;
|
|
916
|
+
const returnMode = list.returnMode;
|
|
917
|
+
list = null;
|
|
918
|
+
mode = returnMode;
|
|
919
|
+
draw();
|
|
920
|
+
Promise.resolve(onEnter(item)).then(() => draw()).catch(commandErr);
|
|
921
|
+
}
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const sr = handleSearch(str, key, search);
|
|
925
|
+
if (sr !== 'pass') { applySearch(); draw(); return; }
|
|
926
|
+
if (mode === 'broadcast') {
|
|
927
|
+
if (key.name === 'escape') {
|
|
928
|
+
persistBroadcast([]);
|
|
929
|
+
broadcastSel = new Set();
|
|
930
|
+
mode = 'normal';
|
|
931
|
+
restoreCurrent();
|
|
932
|
+
draw();
|
|
933
|
+
} else if (key.name === 'q') {
|
|
934
|
+
// 'q' only quits from the plain session list; in broadcast mode it
|
|
935
|
+
// cancels back to that list (Ctrl+C quits from anywhere).
|
|
936
|
+
persistBroadcast([]);
|
|
937
|
+
broadcastSel = new Set();
|
|
938
|
+
mode = 'normal';
|
|
939
|
+
restoreCurrent();
|
|
940
|
+
draw();
|
|
941
|
+
} else if (key.name === 'h') {
|
|
942
|
+
mode = 'broadcast-help';
|
|
943
|
+
draw();
|
|
944
|
+
} else if (key.name === 'space') {
|
|
945
|
+
if (cursor < view.length) toggleBroadcast(cursor);
|
|
946
|
+
} else if (key.name === 'd') {
|
|
947
|
+
if (cursor < view.length) {
|
|
948
|
+
confirmDel = view[cursor];
|
|
949
|
+
mode = 'broadcast-confirm';
|
|
950
|
+
draw();
|
|
951
|
+
}
|
|
952
|
+
} else if (key.name === 'm') {
|
|
953
|
+
listModels(server).then((models) => {
|
|
954
|
+
openList('Broadcast model (all selected sessions)', models, (x) => `${x.label}${x.variant ? `#${x.variant}` : ''}`, async (item) => {
|
|
955
|
+
await Promise.all([...broadcastSel].map(id => switchModel(server, id, item)));
|
|
956
|
+
}, 'broadcast');
|
|
957
|
+
}).catch(commandErr);
|
|
958
|
+
} else if (key.name === 'return' || key.name === 'enter') {
|
|
959
|
+
if (cursor < view.length) {
|
|
960
|
+
mode = 'normal';
|
|
961
|
+
broadcastSel = new Set(); // selection stays persisted for agentp
|
|
962
|
+
applyPick(view[cursor]);
|
|
963
|
+
}
|
|
964
|
+
} else if (view.length > 0 && (key.name === 'up' || str === 'k')) {
|
|
965
|
+
cursor = (cursor - 1 + view.length) % view.length;
|
|
966
|
+
draw();
|
|
967
|
+
} else if (view.length > 0 && (key.name === 'down' || str === 'j')) {
|
|
968
|
+
cursor = (cursor + 1) % view.length;
|
|
969
|
+
draw();
|
|
970
|
+
}
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (key.name === 'q') return finish(null);
|
|
974
|
+
if (key.name === 'p') return finish('switch-project');
|
|
975
|
+
if (key.name === 'h') {
|
|
976
|
+
mode = 'help';
|
|
977
|
+
draw();
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if (key.name === 'n') {
|
|
981
|
+
startInput('New session name (blank for auto; ESC cancels):', '', 'create');
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
// Shift+r → Reminder; lowercase r → Rename.
|
|
985
|
+
if (str === 'R') {
|
|
986
|
+
const row = view[cursor];
|
|
987
|
+
const note = (row && annotations && annotations[row.id]) || '';
|
|
988
|
+
startInput('Reminder for this session (blank clears; ESC cancels):', note, 'reminder');
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
if (key.name === 'r' && cursor < view.length) {
|
|
992
|
+
startInput('Rename to (ESC cancels):', view[cursor].title || '', 'rename');
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
if (key.name === 'a') {
|
|
996
|
+
const target = view[cursor];
|
|
997
|
+
if (target) {
|
|
998
|
+
listAgents(server).then((all) => {
|
|
999
|
+
const primary = all.filter(x => x.mode === 'primary' && !x.hidden);
|
|
1000
|
+
const agents = primary.length ? primary : all;
|
|
1001
|
+
openList('Agents', agents, (x) => `${x.name || x.id}${x.description ? ' — ' + x.description.slice(0, 40) : ''}`, (item) =>
|
|
1002
|
+
updateSession(server, target.id, null, item.id));
|
|
1003
|
+
}).catch(commandErr);
|
|
1004
|
+
}
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
if (key.name === 'm') {
|
|
1008
|
+
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
|
+
}
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
if (key.name === 'd') {
|
|
1018
|
+
if (cursor < view.length) {
|
|
1019
|
+
confirmDel = view[cursor];
|
|
1020
|
+
mode = 'confirm';
|
|
1021
|
+
draw();
|
|
1022
|
+
}
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
if (view.length > 0 && (key.name === 'up' || str === 'k')) {
|
|
1026
|
+
cursor = (cursor - 1 + view.length) % view.length;
|
|
1027
|
+
draw();
|
|
1028
|
+
} else if (view.length > 0 && (key.name === 'down' || str === 'j')) {
|
|
1029
|
+
cursor = (cursor + 1) % view.length;
|
|
1030
|
+
draw();
|
|
1031
|
+
} else if (key.name === 'return' || key.name === 'enter') {
|
|
1032
|
+
if (cursor < view.length) applyPick(view[cursor]);
|
|
1033
|
+
} else if (key.name === 'space') {
|
|
1034
|
+
// Start broadcast only when adding a DIFFERENT session to the current one.
|
|
1035
|
+
if (cursor < view.length && String(view[cursor].id) !== String(current)) {
|
|
1036
|
+
startBroadcast(cursor);
|
|
1037
|
+
} else if (cursor < view.length) {
|
|
1038
|
+
draw(); // no-op on the already-selected session
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
391
1041
|
});
|
|
392
|
-
|
|
1042
|
+
|
|
1043
|
+
readline.emitKeypressEvents(process.stdin);
|
|
1044
|
+
process.stdin.setRawMode(true);
|
|
1045
|
+
process.stdin.resume();
|
|
1046
|
+
process.stdout.on('resize', draw);
|
|
1047
|
+
// Animate the spinner while any listed session is running.
|
|
1048
|
+
if (rows.some(s => runningIds && runningIds.has(s.id))) {
|
|
1049
|
+
spinTimer = setInterval(() => { spinIdx++; draw(); }, 120);
|
|
1050
|
+
}
|
|
1051
|
+
process.stderr.write('\x1b[?1049h\x1b[?25l');
|
|
1052
|
+
draw();
|
|
1053
|
+
});
|
|
393
1054
|
}
|
|
394
1055
|
|
|
395
|
-
// Interactive
|
|
396
|
-
|
|
397
|
-
function modelMenu(rows) {
|
|
1056
|
+
// Interactive project picker — purely visual/navigation, never writes state.
|
|
1057
|
+
function switchMenu(rows, longMode) {
|
|
398
1058
|
return new Promise((resolve) => {
|
|
399
|
-
if (!process.stdin.isTTY
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
1059
|
+
if (!process.stdin.isTTY) {
|
|
1060
|
+
console.error('Error: "switch" is interactive and requires a TTY.');
|
|
1061
|
+
process.exit(1);
|
|
1062
|
+
}
|
|
1063
|
+
if (rows.length === 0) {
|
|
1064
|
+
console.log('No opencode projects running.');
|
|
1065
|
+
resolve(null);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const nameW = Math.max(...rows.map(r => path.basename(r.dir).length), 4);
|
|
1070
|
+
const stsW = Math.max(...rows.map(r => r.status.length), 6);
|
|
1071
|
+
const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
|
|
408
1072
|
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
|
1073
|
+
const cols = process.stderr.columns || 80;
|
|
1074
|
+
const lineWidth = Math.max(cols, nameW + 2 + stsW + 2 + ssnW + 6);
|
|
1075
|
+
|
|
409
1076
|
let cursor = 0;
|
|
1077
|
+
let activeIdx = null; // tmux window index of the focused project
|
|
1078
|
+
let lastDir = null;
|
|
410
1079
|
let done = false;
|
|
1080
|
+
let help = false;
|
|
1081
|
+
const search = newSearchState();
|
|
1082
|
+
let view = rows;
|
|
1083
|
+
|
|
1084
|
+
const applySearch = () => {
|
|
1085
|
+
view = filterByPattern(rows, search.active ? search.value : '',
|
|
1086
|
+
(r) => `${path.basename(r.dir)} ${r.dir} ${r.session || ''}`);
|
|
1087
|
+
if (cursor >= view.length) cursor = Math.max(0, view.length - 1);
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1090
|
+
const refreshActive = () => {
|
|
1091
|
+
activeIdx = activeWindowIndex();
|
|
1092
|
+
};
|
|
411
1093
|
|
|
412
1094
|
const cleanup = () => {
|
|
413
1095
|
process.stderr.write('\x1b[?25h');
|
|
414
1096
|
process.stderr.write('\x1b[?1049l');
|
|
1097
|
+
process.stdout.removeListener('resize', draw);
|
|
415
1098
|
try { process.stdin.setRawMode(false); } catch {}
|
|
416
|
-
process.stdin.pause();
|
|
417
1099
|
};
|
|
1100
|
+
|
|
418
1101
|
const finish = (value) => {
|
|
419
1102
|
if (done) return;
|
|
420
1103
|
done = true;
|
|
421
1104
|
cleanup();
|
|
1105
|
+
process.stdin.pause();
|
|
422
1106
|
process.stdin.removeAllListeners('keypress');
|
|
423
1107
|
process.removeListener('SIGINT', onSig);
|
|
424
|
-
resolve(value);
|
|
1108
|
+
resolve(value === undefined ? lastDir : value);
|
|
425
1109
|
};
|
|
426
|
-
const onSig = () => finish(null);
|
|
427
|
-
process.on('SIGINT', onSig);
|
|
428
1110
|
|
|
429
1111
|
const draw = () => {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
out +=
|
|
437
|
-
|
|
438
|
-
|
|
1112
|
+
if (help) {
|
|
1113
|
+
let out = '\x1b[2J\x1b[H';
|
|
1114
|
+
out += 'ocmux project switcher — help\n\n';
|
|
1115
|
+
out += ' j / k, arrows move the cursor\n';
|
|
1116
|
+
out += ' Enter / Space focus the selected project (menu stays open)\n';
|
|
1117
|
+
out += ' / search/filter the list (Enter keeps, ESC clears)\n';
|
|
1118
|
+
out += ' h toggle this help\n';
|
|
1119
|
+
out += ' q return to the session menu\n';
|
|
1120
|
+
out += ' Ctrl+C quit ocmux\n';
|
|
1121
|
+
out += '\nPress any key to return.\n';
|
|
1122
|
+
process.stderr.write(out);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
refreshActive();
|
|
1126
|
+
const extras = (search.active && view.length === 0) ? [`No projects match "${search.value}"`] : [];
|
|
1127
|
+
process.stderr.write(renderList({
|
|
1128
|
+
title: 'ocmux — project switcher',
|
|
1129
|
+
items: view,
|
|
1130
|
+
cursor,
|
|
1131
|
+
row: (i, row) => {
|
|
1132
|
+
const session = row.session ? row.session.slice(0, 14) : '(none)';
|
|
1133
|
+
const text = `${pad(path.basename(row.dir), nameW)} ${pad(row.status, stsW)} ${pad(session, ssnW)} ${row.dir}`;
|
|
1134
|
+
return row.index === activeIdx ? { text, reverse: true } : text;
|
|
1135
|
+
},
|
|
1136
|
+
footer: searchFooter(search, 'Enter: focus · /: search · h: help · q: back · Ctrl+C: quit'),
|
|
1137
|
+
footerLeft: search.active,
|
|
1138
|
+
footerRight: search.active ? SEARCH_HINT : null,
|
|
1139
|
+
extraLines: extras,
|
|
1140
|
+
}));
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
const activate = (row) => {
|
|
1144
|
+
if (!row) return;
|
|
1145
|
+
try {
|
|
1146
|
+
if (row.server) {
|
|
1147
|
+
activateProject(row.dir, row.index, row.server, row.session);
|
|
1148
|
+
} else {
|
|
1149
|
+
tmux(['select-window', '-t', `${SESSION}:${row.index}`]);
|
|
1150
|
+
}
|
|
1151
|
+
lastDir = row.dir;
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
process.stderr.write(`\nError: ${err.message}\n`);
|
|
1154
|
+
}
|
|
1155
|
+
draw();
|
|
439
1156
|
};
|
|
440
1157
|
|
|
1158
|
+
const onSig = () => finish(SWITCH_QUIT);
|
|
1159
|
+
process.on('SIGINT', onSig);
|
|
1160
|
+
|
|
441
1161
|
process.stdin.on('keypress', (str, key) => {
|
|
442
|
-
|
|
443
|
-
if (key.name === '
|
|
1162
|
+
// Ctrl+C fully exits ocmux; 'q' only closes this menu (back to sessions).
|
|
1163
|
+
if (key.ctrl && key.name === 'c') return finish(SWITCH_QUIT);
|
|
1164
|
+
if (help) { help = false; draw(); return; }
|
|
1165
|
+
const sr = handleSearch(str, key, search);
|
|
1166
|
+
if (sr !== 'pass') { applySearch(); draw(); return; }
|
|
1167
|
+
if (key.name === 'h') { help = true; draw(); return; }
|
|
1168
|
+
if (key.name === 'q') return finish();
|
|
1169
|
+
if (view.length === 0) return;
|
|
444
1170
|
if (key.name === 'up' || str === 'k') {
|
|
445
|
-
cursor = (cursor +
|
|
1171
|
+
cursor = (cursor + view.length - 1) % view.length;
|
|
446
1172
|
draw();
|
|
447
1173
|
} else if (key.name === 'down' || str === 'j') {
|
|
448
|
-
cursor = (cursor + 1) %
|
|
1174
|
+
cursor = (cursor + 1) % view.length;
|
|
449
1175
|
draw();
|
|
450
1176
|
} else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
|
|
451
|
-
|
|
1177
|
+
activate(view[cursor]);
|
|
452
1178
|
}
|
|
453
1179
|
});
|
|
454
1180
|
|
|
455
1181
|
readline.emitKeypressEvents(process.stdin);
|
|
456
1182
|
process.stdin.setRawMode(true);
|
|
457
1183
|
process.stdin.resume();
|
|
1184
|
+
process.stdout.on('resize', draw);
|
|
458
1185
|
process.stderr.write('\x1b[?1049h\x1b[?25l');
|
|
459
1186
|
draw();
|
|
460
1187
|
});
|
|
461
1188
|
}
|
|
462
1189
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
console.error(`Error: no opencode server found in ${process.cwd()} or any of its parents.`);
|
|
467
|
-
console.error("Run 'ocmux serve' to create a new server.");
|
|
468
|
-
process.exit(1);
|
|
469
|
-
}
|
|
470
|
-
const state = readState(sf);
|
|
471
|
-
if (!state || !state.url) {
|
|
472
|
-
console.error(`Error: invalid state file ${sf}`);
|
|
473
|
-
process.exit(1);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Fail fast before any HTTP: interactive picker requires a TTY.
|
|
477
|
-
if (!modelRef && !process.stdin.isTTY) {
|
|
478
|
-
console.error('Error: "model" with no argument is interactive and requires a TTY.');
|
|
479
|
-
console.error('Use "ocmux model <ref>" (partial matches allowed when unique) to switch directly.');
|
|
480
|
-
process.exit(1);
|
|
481
|
-
}
|
|
1190
|
+
// ---------------------------------------------------------------------------
|
|
1191
|
+
// Subcommand: default — interactive session switcher for the current project
|
|
1192
|
+
// ---------------------------------------------------------------------------
|
|
482
1193
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
1194
|
+
async function cmdSessionSwitch(targetDir, directRef) {
|
|
1195
|
+
let target = targetDir;
|
|
1196
|
+
for (;;) {
|
|
1197
|
+
const sf = findParentStatefile(target);
|
|
1198
|
+
if (!sf) {
|
|
1199
|
+
console.error(`Error: no .ocmux.json found in ${target} or any of its parents.`);
|
|
1200
|
+
console.error(`Run 'ocmux serve' to create a project window.`);
|
|
1201
|
+
process.exit(1);
|
|
1202
|
+
}
|
|
1203
|
+
const state = projectState.readProjectState(sf);
|
|
1204
|
+
const server = state.server;
|
|
1205
|
+
if (!server) {
|
|
1206
|
+
console.error(`Error: ${sf} does not record a server.`);
|
|
1207
|
+
console.error(`Run 'ocmux serve --server <url>' to set it up, or add "server" to the file.`);
|
|
1208
|
+
process.exit(1);
|
|
1209
|
+
}
|
|
1210
|
+
const dir = state.directory;
|
|
493
1211
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
process.exit(1);
|
|
501
|
-
}
|
|
1212
|
+
const alive = await checkServer(server);
|
|
1213
|
+
if (!alive) {
|
|
1214
|
+
console.error(`Error: OpenCode server not reachable at ${server}.`);
|
|
1215
|
+
console.error(`Start it with 'opencode serve' (or your usual launcher) first.`);
|
|
1216
|
+
process.exit(1);
|
|
1217
|
+
}
|
|
502
1218
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
pick = model;
|
|
508
|
-
} else if (matches.length > 1 && process.stdin.isTTY) {
|
|
509
|
-
console.error(`Multiple models match "${modelRef}", select one:`);
|
|
510
|
-
pick = await modelMenu(matches);
|
|
511
|
-
} else {
|
|
512
|
-
console.error(`Error: ${error}`);
|
|
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.`);
|
|
513
1223
|
process.exit(1);
|
|
514
1224
|
}
|
|
515
|
-
} else if (process.stdin.isTTY) {
|
|
516
|
-
pick = await modelMenu(models);
|
|
517
|
-
} else {
|
|
518
|
-
// Unreachable (guarded above) — kept for safety.
|
|
519
|
-
console.error('Error: "model" with no argument is interactive and requires a TTY.');
|
|
520
|
-
console.error('Use "ocmux model <ref>" (partial matches allowed when unique) to switch directly.');
|
|
521
|
-
process.exit(1);
|
|
522
|
-
}
|
|
523
1225
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
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);
|
|
528
1229
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
1230
|
+
const sessions = await listSessions(server, dir).catch((err) => {
|
|
1231
|
+
console.error(`Error connecting to ${server}: ${err.message}`);
|
|
1232
|
+
process.exit(1);
|
|
1233
|
+
});
|
|
1234
|
+
const sorted = sortSessionsByRecency(sessions);
|
|
1235
|
+
// Only "main" sessions are listed in the picker: exclude child/subagent
|
|
1236
|
+
// sessions (parentID set) — they are work products of a main session, not
|
|
1237
|
+
// targets. Explicit --session can still reach one by id/title.
|
|
1238
|
+
const mainSessions = sorted.filter(s => !s.parentID);
|
|
1239
|
+
const annotations = projectState.readAnnotations(sf);
|
|
1240
|
+
const runningIds = await getActiveSessions(server);
|
|
1241
|
+
let modelContext = new Map();
|
|
1242
|
+
try {
|
|
1243
|
+
const models = await listModels(server);
|
|
1244
|
+
modelContext = new Map(models.filter(m => m.context).map(m => [`${m.providerID}/${m.id}`, m.context]));
|
|
1245
|
+
} catch { /* context limits unavailable */ }
|
|
539
1246
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
1247
|
+
const applySession = (id) => {
|
|
1248
|
+
projectState.writeProjectState(sf, { session: id });
|
|
1249
|
+
relaunchTui(winIdx, dir, server, id);
|
|
1250
|
+
};
|
|
543
1251
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
1252
|
+
if (directRef) {
|
|
1253
|
+
const pick = sorted.find(s => String(s.id) === directRef)
|
|
1254
|
+
|| sorted.find(s => (s.title || '').toLowerCase() === directRef.toLowerCase())
|
|
1255
|
+
|| null;
|
|
1256
|
+
if (!pick) {
|
|
1257
|
+
console.error(`Error: no session matching "${directRef}" in ${dir}.`);
|
|
1258
|
+
process.exit(1);
|
|
1259
|
+
}
|
|
1260
|
+
applySession(pick.id);
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
550
1263
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
1264
|
+
if (!process.stdin.isTTY) {
|
|
1265
|
+
// Silent path (non-TTY): just make sure the window is focused.
|
|
1266
|
+
activateProject(dir, winIdx, server, state.session);
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
554
1269
|
|
|
555
|
-
|
|
1270
|
+
// Interactive: Enter switches but stays open; n creates (inheriting the
|
|
1271
|
+
// model of the previously selected session); r renames; R sets a reminder;
|
|
1272
|
+
// d deletes; a switches agent; m switches model; p switches project; q quits.
|
|
1273
|
+
let lastRef = (state.session && mainSessions.find(s => String(s.id) === String(state.session))) || mainSessions[0] || null;
|
|
1274
|
+
const result = await new Promise((resolve) => {
|
|
1275
|
+
sessionMenu(mainSessions, state.session, annotations, server, {
|
|
1276
|
+
runningIds,
|
|
1277
|
+
modelContext,
|
|
1278
|
+
onBroadcastChange: (ids) => projectState.writeProjectState(sf, { broadcast: ids }),
|
|
1279
|
+
onInspectSession: (id) => relaunchTui(winIdx, dir, server, id),
|
|
1280
|
+
onPick: async (row) => {
|
|
1281
|
+
if (row.new) {
|
|
1282
|
+
const created = await createSessionWithModel(server, row.name || undefined, dir, [lastRef, ...sorted]).catch((err) => {
|
|
1283
|
+
console.error(`Error creating session: ${err.message}`);
|
|
1284
|
+
return null;
|
|
1285
|
+
});
|
|
1286
|
+
if (created) applySession(created.id);
|
|
1287
|
+
return created || null;
|
|
1288
|
+
}
|
|
1289
|
+
lastRef = row;
|
|
1290
|
+
applySession(row.id);
|
|
1291
|
+
},
|
|
1292
|
+
onRename: async (row, name) => {
|
|
1293
|
+
const updated = await updateSession(server, row.id, name).catch((err) => {
|
|
1294
|
+
console.error(`Error renaming session: ${err.message}`);
|
|
1295
|
+
throw err;
|
|
1296
|
+
});
|
|
1297
|
+
return updated;
|
|
1298
|
+
},
|
|
1299
|
+
onDelete: async (row) => {
|
|
1300
|
+
await deleteSession(server, row.id).catch((err) => {
|
|
1301
|
+
console.error(`Error deleting session: ${err.message}`);
|
|
1302
|
+
throw err;
|
|
1303
|
+
});
|
|
1304
|
+
if (String(row.id) === String(state.session)) {
|
|
1305
|
+
projectState.writeProjectState(sf, { session: null });
|
|
1306
|
+
}
|
|
1307
|
+
},
|
|
1308
|
+
onReminder: async (row, text) => {
|
|
1309
|
+
try {
|
|
1310
|
+
return projectState.writeAnnotation(sf, row.id, text);
|
|
1311
|
+
} catch (err) {
|
|
1312
|
+
console.error(`Error saving reminder: ${err.message}`);
|
|
1313
|
+
throw err;
|
|
1314
|
+
}
|
|
1315
|
+
},
|
|
1316
|
+
}).then(resolve);
|
|
1317
|
+
});
|
|
556
1318
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
1319
|
+
if (result === 'switch-project') {
|
|
1320
|
+
// 'p' pressed: open the project switcher. Picking a project re-runs this
|
|
1321
|
+
// picker for it; 'q' returns here unchanged; Ctrl+C fully exits.
|
|
1322
|
+
ensureSession();
|
|
1323
|
+
const picked = await switchMenu(listProjects());
|
|
1324
|
+
if (picked === SWITCH_QUIT) break;
|
|
1325
|
+
if (!picked) continue;
|
|
1326
|
+
target = picked;
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
break;
|
|
560
1330
|
}
|
|
561
1331
|
}
|
|
562
1332
|
|
|
563
1333
|
// ---------------------------------------------------------------------------
|
|
564
|
-
// Subcommand:
|
|
1334
|
+
// Subcommand: serve — create a project TUI window (server is user-managed)
|
|
565
1335
|
// ---------------------------------------------------------------------------
|
|
566
1336
|
|
|
567
|
-
function
|
|
1337
|
+
async function cmdServeNew(targetDir, serverArg, force) {
|
|
1338
|
+
try { targetDir = fs.realpathSync(targetDir); } catch {}
|
|
1339
|
+
|
|
568
1340
|
const sf = path.join(targetDir, '.ocmux.json');
|
|
569
1341
|
if (fs.existsSync(sf)) {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
1342
|
+
if (!force) {
|
|
1343
|
+
console.error(`Error: a project window already exists in ${targetDir}.`);
|
|
1344
|
+
console.error(`Use 'ocmux' to switch sessions there, or 'ocmux serve --server <url> --force' to repoint it.`);
|
|
1345
|
+
process.exit(1);
|
|
1346
|
+
}
|
|
1347
|
+
return doForceServe(targetDir, sf, serverArg || DEFAULT_SERVER);
|
|
573
1348
|
}
|
|
574
1349
|
|
|
575
1350
|
const parent = findParentStatefile(path.dirname(targetDir));
|
|
576
1351
|
if (parent) {
|
|
577
1352
|
const parentDir = path.dirname(parent);
|
|
578
|
-
console.error(`Warning: there is already an opencode
|
|
579
|
-
console.error(
|
|
1353
|
+
console.error(`Warning: there is already an opencode project in ${parentDir}.`);
|
|
1354
|
+
console.error(`Run 'ocmux' and press 'p' to move between project windows.`);
|
|
580
1355
|
|
|
581
1356
|
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
582
|
-
rl.question('Are you sure you want to create a new
|
|
1357
|
+
rl.question('Are you sure you want to create a new project window here? [y/N] ', (answer) => {
|
|
583
1358
|
rl.close();
|
|
584
1359
|
if (!/^[yY]([eE][sS])?$/.test(answer.trim())) {
|
|
585
1360
|
console.error('Cancelled.');
|
|
586
1361
|
process.exit(0);
|
|
587
1362
|
}
|
|
588
|
-
|
|
1363
|
+
doServeNew(targetDir, serverArg).catch((err) => {
|
|
1364
|
+
console.error(err ? err.message : String(err));
|
|
1365
|
+
process.exit(1);
|
|
1366
|
+
});
|
|
589
1367
|
});
|
|
590
|
-
|
|
591
|
-
doNew(targetDir, longMode, printLogs);
|
|
1368
|
+
return;
|
|
592
1369
|
}
|
|
1370
|
+
return doServeNew(targetDir, serverArg);
|
|
593
1371
|
}
|
|
594
1372
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
// Create window with interactive shell so .bashrc/.zshrc is sourced
|
|
604
|
-
const rc = tmux(['new-window', '-d', '-t', SESSION, '-n', targetDir, '-c', targetDir]);
|
|
605
|
-
if (rc.status !== 0) {
|
|
606
|
-
console.error(`Error: failed to create tmux window: ${rc.stderr}`);
|
|
1373
|
+
// Repoint an existing project to a (possibly new) server: update .ocmux.json's
|
|
1374
|
+
// `server`, keep the session if it still exists, and (re)launch the TUI on it.
|
|
1375
|
+
async function doForceServe(targetDir, sf, server) {
|
|
1376
|
+
const alive = await checkServer(server);
|
|
1377
|
+
if (!alive) {
|
|
1378
|
+
console.error(`Error: OpenCode server not reachable at ${server}.`);
|
|
1379
|
+
console.error(`Start it with 'opencode serve' (or pass --server <url>).`);
|
|
607
1380
|
process.exit(1);
|
|
608
1381
|
}
|
|
609
|
-
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
console.error('Error: window was not created properly');
|
|
1382
|
+
const state = projectState.readProjectState(sf);
|
|
1383
|
+
const sessions = await listSessions(server, targetDir).catch((err) => {
|
|
1384
|
+
console.error(`Error connecting to ${server}: ${err.message}`);
|
|
613
1385
|
process.exit(1);
|
|
1386
|
+
});
|
|
1387
|
+
let session = (state && state.session && sessions.some(s => String(s.id) === String(state.session)))
|
|
1388
|
+
? state.session
|
|
1389
|
+
: newestSessionId(sessions);
|
|
1390
|
+
if (!session) {
|
|
1391
|
+
const created = await createSessionWithModel(server, undefined, targetDir, sessions);
|
|
1392
|
+
session = created.id;
|
|
614
1393
|
}
|
|
1394
|
+
projectState.writeProjectState(sf, { server, session });
|
|
1395
|
+
const winIdx = windowByDir(targetDir);
|
|
1396
|
+
if (winIdx != null) {
|
|
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
|
+
}
|
|
1403
|
+
}
|
|
615
1404
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
// Truncate log file so stale URL from previous run can't be matched by polling
|
|
619
|
-
try { fs.truncateSync(lf); } catch { /* may not exist yet */ }
|
|
620
|
-
|
|
621
|
-
// Send the command to the interactive shell (like typing it manually)
|
|
622
|
-
const serveFlags = printLogs ? '--print-logs' : '';
|
|
623
|
-
tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`,
|
|
624
|
-
`opencode serve ${serveFlags} 2>&1 | tee '${lf}'`, 'Enter']);
|
|
1405
|
+
async function doServeNew(targetDir, serverArg) {
|
|
1406
|
+
const server = serverArg || DEFAULT_SERVER;
|
|
625
1407
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
sleep(0.2);
|
|
632
|
-
const log = (() => { try { return fs.readFileSync(lf, 'utf8'); } catch { return ''; } })();
|
|
633
|
-
const m = log.match(URL_RE);
|
|
634
|
-
if (m) url = m[1];
|
|
1408
|
+
const alive = await checkServer(server);
|
|
1409
|
+
if (!alive) {
|
|
1410
|
+
console.error(`Error: OpenCode server not reachable at ${server}.`);
|
|
1411
|
+
console.error(`Start it with 'opencode serve' (or --server <url> to point elsewhere).`);
|
|
1412
|
+
process.exit(1);
|
|
635
1413
|
}
|
|
636
1414
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
1415
|
+
// Pick the newest session already in this directory, or create one.
|
|
1416
|
+
let sessions = [];
|
|
1417
|
+
try {
|
|
1418
|
+
sessions = await listSessions(server, targetDir);
|
|
1419
|
+
} catch (err) {
|
|
1420
|
+
console.error(`Error connecting to ${server}: ${err.message}`);
|
|
641
1421
|
process.exit(1);
|
|
642
1422
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
sleep(0.5);
|
|
653
|
-
if (tuiPane) tmux(['resize-pane', '-Z', '-t', tuiPane]);
|
|
654
|
-
|
|
655
|
-
const activeWin = activeWindowIndex();
|
|
656
|
-
if (activeWin !== winIdx) {
|
|
657
|
-
tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
|
|
1423
|
+
let session = newestSessionId(sessions);
|
|
1424
|
+
if (!session) {
|
|
1425
|
+
try {
|
|
1426
|
+
const created = await createSessionWithModel(server, undefined, targetDir, sessions);
|
|
1427
|
+
session = created.id;
|
|
1428
|
+
} catch (err) {
|
|
1429
|
+
console.error(`Error creating session: ${err.message}`);
|
|
1430
|
+
process.exit(1);
|
|
1431
|
+
}
|
|
658
1432
|
}
|
|
659
1433
|
|
|
660
|
-
|
|
1434
|
+
console.error(`Creating project window for ${targetDir} (session ${session})...`);
|
|
1435
|
+
try {
|
|
1436
|
+
createProjectWindow(targetDir, server, session);
|
|
1437
|
+
} catch (err) {
|
|
1438
|
+
console.error(`Error: ${err.message}`);
|
|
1439
|
+
process.exit(1);
|
|
1440
|
+
}
|
|
661
1441
|
}
|
|
662
1442
|
|
|
663
1443
|
// ---------------------------------------------------------------------------
|
|
664
|
-
// Subcommand:
|
|
1444
|
+
// Subcommand: list
|
|
665
1445
|
// ---------------------------------------------------------------------------
|
|
666
1446
|
|
|
667
|
-
function
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
console.error(`Error: invalid state file ${statefile}`);
|
|
673
|
-
process.exit(1);
|
|
1447
|
+
function cmdList(longMode) {
|
|
1448
|
+
const rows = listProjects();
|
|
1449
|
+
if (rows.length === 0) {
|
|
1450
|
+
console.log('No opencode projects running.');
|
|
1451
|
+
return;
|
|
674
1452
|
}
|
|
675
1453
|
|
|
676
|
-
|
|
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));
|
|
677
1458
|
|
|
678
|
-
console.
|
|
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}`);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
679
1465
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
if (actualName !== serverDir) {
|
|
684
|
-
const found = windowByDir(serverDir);
|
|
685
|
-
if (found != null) winIdx = found;
|
|
686
|
-
}
|
|
1466
|
+
// ---------------------------------------------------------------------------
|
|
1467
|
+
// Subcommand: kill — close the project window but keep .ocmux.json
|
|
1468
|
+
// ---------------------------------------------------------------------------
|
|
687
1469
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
1470
|
+
function cmdKill(targetDir) {
|
|
1471
|
+
const sf = findParentStatefile(targetDir);
|
|
1472
|
+
if (!sf) {
|
|
1473
|
+
console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
|
|
1474
|
+
process.exit(1);
|
|
1475
|
+
}
|
|
1476
|
+
const serverDir = path.dirname(sf);
|
|
1477
|
+
const winIdx = windowByDir(serverDir);
|
|
1478
|
+
if (winIdx != null) {
|
|
1479
|
+
console.error(`Closing project window for ${serverDir}...`);
|
|
1480
|
+
try {
|
|
1481
|
+
closeProjectWindow(winIdx);
|
|
1482
|
+
} catch (err) {
|
|
1483
|
+
console.error(`Error: ${err.message}`);
|
|
1484
|
+
process.exit(1);
|
|
696
1485
|
}
|
|
697
1486
|
} else {
|
|
698
|
-
console.error(
|
|
1487
|
+
console.error(`Tmux window not found for ${serverDir} (state kept).`);
|
|
699
1488
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
console.error('Done.');
|
|
1489
|
+
// Session memory intentionally kept.
|
|
1490
|
+
projectState.writeProjectState(sf, { status: 'stopped' });
|
|
1491
|
+
console.error('Done (state file kept).');
|
|
703
1492
|
}
|
|
704
1493
|
|
|
705
1494
|
// ---------------------------------------------------------------------------
|
|
706
|
-
// Subcommand: resurrect —
|
|
1495
|
+
// Subcommand: resurrect — recreate a project window from its state
|
|
707
1496
|
// ---------------------------------------------------------------------------
|
|
708
1497
|
|
|
709
|
-
function cmdResurrect(targetDir
|
|
1498
|
+
async function cmdResurrect(targetDir) {
|
|
710
1499
|
const sf = findParentStatefile(targetDir);
|
|
711
1500
|
if (!sf) {
|
|
712
1501
|
console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
|
|
713
|
-
console.error("Run 'ocmux serve' to create a new server.");
|
|
714
1502
|
process.exit(1);
|
|
715
1503
|
}
|
|
1504
|
+
const state = projectState.readProjectState(sf);
|
|
1505
|
+
const server = state.server;
|
|
1506
|
+
if (!server) {
|
|
1507
|
+
console.error(`Error: ${sf} does not record a server.`);
|
|
1508
|
+
process.exit(1);
|
|
1509
|
+
}
|
|
1510
|
+
const dir = state.directory;
|
|
716
1511
|
|
|
717
|
-
const
|
|
718
|
-
|
|
1512
|
+
const winIdx = windowByDir(dir);
|
|
1513
|
+
if (winIdx != null) {
|
|
1514
|
+
console.error(`Project window for ${dir} already exists.`);
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
719
1517
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
} catch (err) {
|
|
724
|
-
console.error(`Error: ${err.message}`);
|
|
1518
|
+
const alive = await checkServer(server);
|
|
1519
|
+
if (!alive) {
|
|
1520
|
+
console.error(`Error: OpenCode server not reachable at ${server}.`);
|
|
725
1521
|
process.exit(1);
|
|
726
1522
|
}
|
|
1523
|
+
console.error(`Recreating project window for ${dir}...`);
|
|
1524
|
+
createProjectWindow(dir, server, state.session);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// ---------------------------------------------------------------------------
|
|
1528
|
+
// Subcommand: migrate — rewrite legacy .ocmux.json files to v2
|
|
1529
|
+
// ---------------------------------------------------------------------------
|
|
1530
|
+
|
|
1531
|
+
function cmdMigrate() {
|
|
1532
|
+
const rows = listProjects();
|
|
1533
|
+
let migrated = 0;
|
|
1534
|
+
for (const row of rows) {
|
|
1535
|
+
const sf = statefileFor(row.dir);
|
|
1536
|
+
const state = projectState.readProjectState(sf);
|
|
1537
|
+
if (!state) continue;
|
|
1538
|
+
if (state.version >= 2) continue;
|
|
1539
|
+
projectState.writeProjectState(sf, {
|
|
1540
|
+
directory: row.dir,
|
|
1541
|
+
server: state.server,
|
|
1542
|
+
session: state.session,
|
|
1543
|
+
});
|
|
1544
|
+
migrated++;
|
|
1545
|
+
console.error(`Migrated ${sf}`);
|
|
1546
|
+
}
|
|
1547
|
+
if (migrated === 0) console.error('No legacy state files found.');
|
|
727
1548
|
}
|
|
728
1549
|
|
|
729
1550
|
// ---------------------------------------------------------------------------
|
|
730
|
-
//
|
|
1551
|
+
// ---------------------------------------------------------------------------
|
|
1552
|
+
// Help
|
|
1553
|
+
// ---------------------------------------------------------------------------
|
|
1554
|
+
|
|
1555
|
+
function printHelp() {
|
|
1556
|
+
console.error(`Usage: ${SCRIPT} [-l] [<subcommand>] [<directory>]`);
|
|
1557
|
+
console.error('');
|
|
1558
|
+
console.error('Manage project OpenCode TUI windows in tmux. The OpenCode server');
|
|
1559
|
+
console.error('itself is user-managed: start it once with `opencode serve`.');
|
|
1560
|
+
console.error('');
|
|
1561
|
+
console.error('Without arguments, searches upward from <directory> (default: $PWD)');
|
|
1562
|
+
console.error('for .ocmux.json and opens an interactive SESSION picker for that');
|
|
1563
|
+
console.error('project (requires a TTY). In the picker:');
|
|
1564
|
+
console.error(' Enter switch · n new · r rename · R remind · d delete · a agents · m model');
|
|
1565
|
+
console.error(' p projects · h help · q quit');
|
|
1566
|
+
console.error('Selecting updates .ocmux.json and relaunches the project TUI on the');
|
|
1567
|
+
console.error('chosen session. Silent on success. (p opens the read-only project');
|
|
1568
|
+
console.error('switcher; it never modifies .ocmux.json.)');
|
|
1569
|
+
console.error('');
|
|
1570
|
+
console.error('Subcommands:');
|
|
1571
|
+
console.error(' serve Create a TUI window for <directory> (default: $PWD).');
|
|
1572
|
+
console.error(' --server <url> Server to record (default: $OCMUX_SERVER or http://localhost:4096).');
|
|
1573
|
+
console.error(' --force Repoint an existing project to --server (overwrites .ocmux.json).');
|
|
1574
|
+
console.error(' --git / --GIT Use the nearest parent with a .git entry / directory.');
|
|
1575
|
+
console.error(' Aliased as "new" for backwards compatibility.');
|
|
1576
|
+
console.error(' session <id|title>');
|
|
1577
|
+
console.error(' Non-interactive session switch for the current project.');
|
|
1578
|
+
console.error(' list [-l] List project windows (with -l, their server URL).');
|
|
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.');
|
|
1581
|
+
console.error(' migrate Rewrite legacy .ocmux.json files to the v2 schema.');
|
|
1582
|
+
console.error('');
|
|
1583
|
+
console.error('Reminders: the R key in the session picker attaches a note to a');
|
|
1584
|
+
console.error('session; agentp prepends it to every prompt sent to that session.');
|
|
1585
|
+
console.error('');
|
|
1586
|
+
console.error('Options:');
|
|
1587
|
+
console.error(' -l Long output (list: also show server URL).');
|
|
1588
|
+
console.error(' --version Show version');
|
|
1589
|
+
console.error(' -h Show this help');
|
|
1590
|
+
console.error('');
|
|
1591
|
+
console.error('See also: agentp -- pipe prompts into OpenCode from stdin');
|
|
1592
|
+
console.error(' tgagentp -- Telegram bridge for OpenCode (tgagentp --help)');
|
|
1593
|
+
process.exit(0);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// ---------------------------------------------------------------------------
|
|
1597
|
+
// Argument parsing + dispatch
|
|
731
1598
|
// ---------------------------------------------------------------------------
|
|
732
1599
|
|
|
733
1600
|
function main() {
|
|
734
1601
|
let longMode = false;
|
|
735
1602
|
let gitMode = null;
|
|
736
|
-
let
|
|
737
|
-
let
|
|
1603
|
+
let serverArg = null;
|
|
1604
|
+
let force = false;
|
|
738
1605
|
const positional = [];
|
|
739
1606
|
let dirIsForced = false;
|
|
740
1607
|
|
|
741
|
-
|
|
1608
|
+
const parsedArgs = process.argv.slice(2);
|
|
1609
|
+
for (let i = 0; i < parsedArgs.length; i++) {
|
|
1610
|
+
const arg = parsedArgs[i];
|
|
742
1611
|
if (arg === '-l') {
|
|
743
1612
|
longMode = true;
|
|
744
|
-
} else if (arg === '--
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1613
|
+
} else if (arg === '--server') {
|
|
1614
|
+
const val = parsedArgs[i + 1];
|
|
1615
|
+
if (!val || val.startsWith('-')) {
|
|
1616
|
+
console.error("Error: '--server' requires a URL argument.");
|
|
1617
|
+
process.exit(1);
|
|
1618
|
+
}
|
|
1619
|
+
serverArg = val;
|
|
1620
|
+
i++;
|
|
1621
|
+
} else if (arg === 'switch' || arg === 'model') {
|
|
1622
|
+
console.error(`Error: the '${arg}' subcommand was removed.`);
|
|
1623
|
+
console.error(`Press '${arg === 'switch' ? 'p' : 'm'}' inside the session picker (ocmux) for that action.`);
|
|
1624
|
+
process.exit(1);
|
|
1625
|
+
} else if (arg === '--force') {
|
|
1626
|
+
force = true;
|
|
748
1627
|
} else if (arg === '--git') {
|
|
749
1628
|
if (gitMode && gitMode !== 'nearest') {
|
|
750
1629
|
console.error("Error: cannot combine '--git' with '--GIT'.");
|
|
@@ -765,23 +1644,19 @@ function main() {
|
|
|
765
1644
|
} else if (arg === '--') {
|
|
766
1645
|
dirIsForced = true;
|
|
767
1646
|
} else if (arg.startsWith('-')) {
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
} else if (arg === '--kill') {
|
|
771
|
-
console.error("Error: '--kill' is no longer a flag. Use 'ocmux kill' instead.");
|
|
772
|
-
} else {
|
|
773
|
-
console.error(`Error: unknown option '${arg}'`);
|
|
774
|
-
console.error(`Run '${SCRIPT} -h' for help.`);
|
|
775
|
-
}
|
|
1647
|
+
console.error(`Error: unknown option '${arg}'`);
|
|
1648
|
+
console.error(`Run '${SCRIPT} -h' for help.`);
|
|
776
1649
|
process.exit(1);
|
|
777
1650
|
} else {
|
|
778
1651
|
positional.push(arg);
|
|
779
1652
|
}
|
|
780
1653
|
}
|
|
781
1654
|
|
|
1655
|
+
// Handle --server <url> (inline-consumed in the loop above; nothing here).
|
|
1656
|
+
|
|
782
1657
|
let subcommand = null;
|
|
783
1658
|
let targetDir = null;
|
|
784
|
-
let
|
|
1659
|
+
let directRef = null;
|
|
785
1660
|
|
|
786
1661
|
for (const arg of positional) {
|
|
787
1662
|
if (dirIsForced) {
|
|
@@ -791,15 +1666,16 @@ function main() {
|
|
|
791
1666
|
process.exit(1);
|
|
792
1667
|
}
|
|
793
1668
|
targetDir = resolveDir(arg);
|
|
794
|
-
} else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list'
|
|
1669
|
+
} else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list'
|
|
1670
|
+
|| arg === 'resurrect' || arg === 'session'
|
|
1671
|
+
|| arg === 'migrate') {
|
|
795
1672
|
if (subcommand) {
|
|
796
1673
|
console.error(`Error: multiple subcommands specified: '${subcommand}' and '${arg}'`);
|
|
797
1674
|
process.exit(1);
|
|
798
1675
|
}
|
|
799
1676
|
subcommand = arg;
|
|
800
|
-
} else if (subcommand === '
|
|
801
|
-
|
|
802
|
-
modelRef = arg;
|
|
1677
|
+
} else if (subcommand === 'session' && directRef === null) {
|
|
1678
|
+
directRef = arg;
|
|
803
1679
|
} else {
|
|
804
1680
|
if (targetDir) {
|
|
805
1681
|
console.error(`Error: unexpected argument '${arg}'`);
|
|
@@ -811,148 +1687,68 @@ function main() {
|
|
|
811
1687
|
|
|
812
1688
|
if (!targetDir) targetDir = process.cwd();
|
|
813
1689
|
|
|
814
|
-
// Dispatch
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
process.exit(1);
|
|
819
|
-
}
|
|
820
|
-
if (printLogs) {
|
|
821
|
-
console.error("Error: '--last' cannot be combined with '--print-logs'.");
|
|
822
|
-
process.exit(1);
|
|
823
|
-
}
|
|
824
|
-
if (subcommand) {
|
|
825
|
-
console.error(`Error: '--last' cannot be combined with subcommand '${subcommand}'.`);
|
|
826
|
-
process.exit(1);
|
|
827
|
-
}
|
|
828
|
-
if (targetDir !== process.cwd()) {
|
|
829
|
-
console.error("Error: '--last' does not accept a directory path.");
|
|
830
|
-
process.exit(1);
|
|
831
|
-
}
|
|
832
|
-
ensureSession();
|
|
833
|
-
const url = getActiveServer();
|
|
834
|
-
if (!url) {
|
|
835
|
-
console.error('Error: no active opencode server found in tmux.');
|
|
836
|
-
console.error("Run 'ocmux serve' to create a server, or switch to an existing one.");
|
|
837
|
-
process.exit(1);
|
|
838
|
-
}
|
|
839
|
-
outputUrl(url, null, longMode);
|
|
840
|
-
return;
|
|
841
|
-
}
|
|
1690
|
+
// Dispatch. Each branch may throw synchronously (EXIT) for validation errors
|
|
1691
|
+
// or return a promise for async commands; main() returns the settled promise
|
|
1692
|
+
// so tests can await it.
|
|
1693
|
+
let p = Promise.resolve();
|
|
842
1694
|
|
|
843
1695
|
if (subcommand === 'list') {
|
|
844
1696
|
if (gitMode) {
|
|
845
1697
|
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
|
846
1698
|
process.exit(1);
|
|
847
1699
|
}
|
|
848
|
-
if (printLogs) {
|
|
849
|
-
console.error("Error: '--print-logs' is only valid with 'serve'.");
|
|
850
|
-
process.exit(1);
|
|
851
|
-
}
|
|
852
1700
|
if (targetDir !== process.cwd()) {
|
|
853
1701
|
console.error("Error: 'list' does not accept a directory path.");
|
|
854
1702
|
process.exit(1);
|
|
855
1703
|
}
|
|
856
|
-
cmdList();
|
|
857
|
-
} else if (subcommand === 'switch') {
|
|
858
|
-
if (gitMode) {
|
|
859
|
-
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
|
860
|
-
process.exit(1);
|
|
861
|
-
}
|
|
862
|
-
if (printLogs) {
|
|
863
|
-
console.error("Error: '--print-logs' is only valid with 'serve'.");
|
|
864
|
-
process.exit(1);
|
|
865
|
-
}
|
|
866
|
-
if (targetDir !== process.cwd()) {
|
|
867
|
-
console.error("Error: 'switch' does not accept a directory path.");
|
|
868
|
-
process.exit(1);
|
|
869
|
-
}
|
|
870
1704
|
ensureSession();
|
|
871
|
-
|
|
872
|
-
} else if (subcommand === '
|
|
1705
|
+
cmdList(longMode);
|
|
1706
|
+
} else if (subcommand === 'session') {
|
|
873
1707
|
if (gitMode) {
|
|
874
1708
|
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
|
875
1709
|
process.exit(1);
|
|
876
1710
|
}
|
|
877
|
-
|
|
878
|
-
console.error("Error: '--print-logs' is only valid with 'serve'.");
|
|
879
|
-
process.exit(1);
|
|
880
|
-
}
|
|
881
|
-
if (targetDir !== process.cwd()) {
|
|
882
|
-
console.error("Error: 'model' does not accept a directory path.");
|
|
883
|
-
process.exit(1);
|
|
884
|
-
}
|
|
885
|
-
// cmdModel is async; swallow the (test-mocked) process.exit throw.
|
|
886
|
-
cmdModel(modelRef, longMode).catch((err) => {
|
|
887
|
-
if (!err || !err.message || !err.message.startsWith('EXIT:')) {
|
|
888
|
-
console.error(err ? err.message : String(err));
|
|
889
|
-
process.exit(1);
|
|
890
|
-
}
|
|
891
|
-
});
|
|
1711
|
+
p = cmdSessionSwitch(targetDir, directRef);
|
|
892
1712
|
} else if (subcommand === 'kill') {
|
|
893
1713
|
if (gitMode) {
|
|
894
1714
|
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
|
895
1715
|
process.exit(1);
|
|
896
1716
|
}
|
|
897
|
-
|
|
898
|
-
console.error("Error: '--print-logs' is only valid with 'serve'.");
|
|
899
|
-
process.exit(1);
|
|
900
|
-
}
|
|
901
|
-
const sf = findParentStatefile(targetDir);
|
|
902
|
-
if (!sf) {
|
|
903
|
-
console.error(`Error: no opencode server found in ${targetDir} or any of its parents.`);
|
|
904
|
-
console.error("Run 'ocmux serve' to create a new server.");
|
|
905
|
-
process.exit(1);
|
|
906
|
-
}
|
|
907
|
-
cmdKill(sf);
|
|
1717
|
+
cmdKill(targetDir);
|
|
908
1718
|
} else if (subcommand === 'resurrect') {
|
|
909
|
-
|
|
910
|
-
|
|
1719
|
+
p = cmdResurrect(targetDir);
|
|
1720
|
+
} else if (subcommand === 'migrate') {
|
|
1721
|
+
if (targetDir !== process.cwd()) {
|
|
1722
|
+
console.error("Error: 'migrate' does not accept a directory path.");
|
|
911
1723
|
process.exit(1);
|
|
912
1724
|
}
|
|
913
|
-
|
|
1725
|
+
cmdMigrate();
|
|
914
1726
|
} else if (subcommand === 'serve' || subcommand === 'new') {
|
|
915
1727
|
if (gitMode) {
|
|
916
1728
|
const flagLabel = gitMode === 'root' ? '--GIT' : '--git';
|
|
917
1729
|
targetDir = resolveGitDir(targetDir, gitMode === 'root', flagLabel);
|
|
918
1730
|
}
|
|
919
|
-
|
|
1731
|
+
p = cmdServeNew(targetDir, serverArg, force);
|
|
920
1732
|
} else {
|
|
1733
|
+
// Default: session switcher for the current project.
|
|
921
1734
|
if (gitMode) {
|
|
922
1735
|
console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
|
|
923
1736
|
process.exit(1);
|
|
924
1737
|
}
|
|
925
|
-
|
|
926
|
-
console.error("Error: '--print-logs' is only valid with 'serve'.");
|
|
927
|
-
process.exit(1);
|
|
928
|
-
}
|
|
929
|
-
// Default mode — find an existing server and switch to it
|
|
930
|
-
let sf = findParentStatefile(targetDir);
|
|
931
|
-
if (!sf) {
|
|
932
|
-
console.log(`Error: no opencode server found in ${targetDir} or any of its parents.`);
|
|
933
|
-
console.error("Run 'ocmux serve' to create a new server.");
|
|
934
|
-
process.exit(1);
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
// Skip stale state files whose tmux window no longer exists,
|
|
938
|
-
// and search from the parent directory instead.
|
|
939
|
-
const state = readState(sf);
|
|
940
|
-
if (state && state.window_index != null) {
|
|
941
|
-
const serverDir = path.dirname(sf);
|
|
942
|
-
const actualName = windowNameByIndex(state.window_index);
|
|
943
|
-
if (actualName !== serverDir && windowByDir(serverDir) == null) {
|
|
944
|
-
const parentSf = findParentStatefile(path.dirname(serverDir));
|
|
945
|
-
if (parentSf) sf = parentSf;
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
ensureSession();
|
|
950
|
-
cmdSwitch(sf, longMode);
|
|
1738
|
+
p = cmdSessionSwitch(targetDir, null);
|
|
951
1739
|
}
|
|
1740
|
+
|
|
1741
|
+
return p.catch((err) => {
|
|
1742
|
+
// Already exiting (a command called process.exit — the test stub throws
|
|
1743
|
+
// EXIT:code). Swallow so the chain settles cleanly.
|
|
1744
|
+
if (err && err.message && err.message.startsWith('EXIT:')) return;
|
|
1745
|
+
console.error(err ? err.message : String(err));
|
|
1746
|
+
process.exit(1);
|
|
1747
|
+
});
|
|
952
1748
|
}
|
|
953
1749
|
|
|
954
1750
|
if (require.main === module) {
|
|
955
1751
|
main();
|
|
956
1752
|
}
|
|
957
1753
|
|
|
958
|
-
module.exports = { main };
|
|
1754
|
+
module.exports = { main, checkServer, newestSessionId, cmdKill, cmdList, cmdMigrate, windowFor, renderList, fmtClock, fmtDuration, fmtTokens, fmtCost, layoutCells, sessionInfoLines, centerIn, filterByPattern, newSearchState, handleSearch, searchFooter, sessionMenu, switchMenu, SWITCH_QUIT, SEARCH_HINT };
|