@bramblex/codex-workbench 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/services/session-sources.js +22 -3
- package/src/services/ssh-runner.js +69 -2
- package/src/ui/blessed-compat.js +21 -0
- package/src/ui/directory-picker.js +1 -0
- package/src/ui/workbench.js +115 -13
package/README.md
CHANGED
|
@@ -111,7 +111,7 @@ Most commands accept a full session id, a unique prefix, a saved name, or a sess
|
|
|
111
111
|
|
|
112
112
|
## Interactive UI
|
|
113
113
|
|
|
114
|
-
The UI groups sessions by source and working directory, with sources/projects on the left, sessions on the upper right, and details below. When you start or resume a session, Codex temporarily takes over the terminal; when Codex exits, codex-workbench redraws the UI.
|
|
114
|
+
The UI groups sessions by source and working directory, with sources/projects on the left, sessions on the upper right, and details below. Local sessions render immediately, while remote SSH sources load in the background. When you start or resume a session, Codex temporarily takes over the terminal; when Codex exits, codex-workbench redraws the UI.
|
|
115
115
|
|
|
116
116
|
Common keys:
|
|
117
117
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bramblex/codex-workbench",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Terminal workbench for browsing and managing local and SSH Codex sessions.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"LICENSE"
|
|
33
33
|
],
|
|
34
34
|
"scripts": {
|
|
35
|
-
"test": "node -e \"const fs=require('fs'),{spawnSync}=require('child_process');function files(d){return fs.readdirSync(d,{withFileTypes:true}).flatMap(e=>e.isDirectory()?files(d+'/'+e.name):e.name.endsWith('.js')?[d+'/'+e.name]:[])}for(const f of files('src')){const r=spawnSync(process.execPath,['--check',f],{stdio:'inherit'});if(r.status)process.exit(r.status)}\" && node --check bin/codex-workbench && node --check scripts/pty-codex.js && node --check scripts/tui-pty-codex.js && node --check scripts/blessed-xterm-codex.js && node test/codex-bin.test.js && node test/session-sources.test.js && node test/smoke.js",
|
|
35
|
+
"test": "node -e \"const fs=require('fs'),{spawnSync}=require('child_process');function files(d){return fs.readdirSync(d,{withFileTypes:true}).flatMap(e=>e.isDirectory()?files(d+'/'+e.name):e.name.endsWith('.js')?[d+'/'+e.name]:[])}for(const f of files('src')){const r=spawnSync(process.execPath,['--check',f],{stdio:'inherit'});if(r.status)process.exit(r.status)}\" && node --check bin/codex-workbench && node --check scripts/pty-codex.js && node --check scripts/tui-pty-codex.js && node --check scripts/blessed-xterm-codex.js && node test/codex-bin.test.js && node test/blessed-compat.test.js && node test/session-sources.test.js && node test/smoke.js",
|
|
36
36
|
"pty:codex": "node scripts/pty-codex.js",
|
|
37
37
|
"tui:codex": "node scripts/tui-pty-codex.js",
|
|
38
38
|
"xterm:codex": "node scripts/blessed-xterm-codex.js"
|
|
@@ -5,7 +5,7 @@ const { createChildDirectory, listDirectories } = require('../model/directories'
|
|
|
5
5
|
const { listSessions, updateMetadata } = require('../model/session-store');
|
|
6
6
|
const { listServers } = require('../model/workbench-config');
|
|
7
7
|
const { runCodexCommand, runNewCodexSession, usableCwd } = require('./codex-runner');
|
|
8
|
-
const { runRemoteCwb, runRemoteCwbJson } = require('./ssh-runner');
|
|
8
|
+
const { runRemoteCwb, runRemoteCwbJson, runRemoteCwbJsonAsync } = require('./ssh-runner');
|
|
9
9
|
|
|
10
10
|
const LOCAL_SOURCE = {
|
|
11
11
|
id: 'local',
|
|
@@ -41,9 +41,26 @@ function configuredSources() {
|
|
|
41
41
|
return [LOCAL_SOURCE, ...listServers().map(sourceForServer)];
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function sortSessions(sessions) {
|
|
45
|
+
sessions.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
|
46
|
+
return sessions;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function loadLocalWorkbenchSessions(sources = configuredSources()) {
|
|
50
|
+
const sessions = listSessions().map((session) => attachSource(session, LOCAL_SOURCE));
|
|
51
|
+
return { errors: [], sessions, sources };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function loadRemoteSourceSessions(source) {
|
|
55
|
+
return runRemoteCwbJsonAsync(source, ['list', '--json', '--compact']).then((remoteSessions) => {
|
|
56
|
+
if (!Array.isArray(remoteSessions)) throw new Error('remote list did not return an array');
|
|
57
|
+
return remoteSessions.map((session) => attachSource(session, source));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
function loadWorkbenchSessions() {
|
|
45
62
|
const sources = configuredSources();
|
|
46
|
-
const sessions =
|
|
63
|
+
const sessions = loadLocalWorkbenchSessions(sources).sessions;
|
|
47
64
|
const errors = [];
|
|
48
65
|
|
|
49
66
|
for (const source of sources.filter((candidate) => candidate.remote)) {
|
|
@@ -56,7 +73,7 @@ function loadWorkbenchSessions() {
|
|
|
56
73
|
}
|
|
57
74
|
}
|
|
58
75
|
|
|
59
|
-
sessions
|
|
76
|
+
sortSessions(sessions);
|
|
60
77
|
return { errors, sessions, sources };
|
|
61
78
|
}
|
|
62
79
|
|
|
@@ -139,6 +156,8 @@ module.exports = {
|
|
|
139
156
|
configuredSources,
|
|
140
157
|
createSourceDirectory,
|
|
141
158
|
listSourceDirectories,
|
|
159
|
+
loadLocalWorkbenchSessions,
|
|
160
|
+
loadRemoteSourceSessions,
|
|
142
161
|
loadWorkbenchSessions,
|
|
143
162
|
runSourceNewSession,
|
|
144
163
|
runSourceSessionCommand,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { spawnSync } = require('child_process');
|
|
3
|
+
const { spawn, spawnSync } = require('child_process');
|
|
4
4
|
const { shellQuote } = require('./codex-runner');
|
|
5
5
|
|
|
6
|
+
const DEFAULT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
7
|
+
|
|
6
8
|
function sshBaseArgs(server, opts = {}) {
|
|
7
9
|
const args = [];
|
|
8
10
|
if (opts.tty) args.push('-t');
|
|
@@ -21,11 +23,59 @@ function runRemoteCwb(server, argv, opts = {}) {
|
|
|
21
23
|
return spawnSync('ssh', args, {
|
|
22
24
|
encoding: opts.encoding,
|
|
23
25
|
env: process.env,
|
|
24
|
-
maxBuffer: opts.maxBuffer ||
|
|
26
|
+
maxBuffer: opts.maxBuffer || DEFAULT_MAX_BUFFER,
|
|
25
27
|
stdio: opts.stdio || (opts.encoding ? ['ignore', 'pipe', 'pipe'] : 'inherit'),
|
|
26
28
|
});
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
function runRemoteCwbAsync(server, argv, opts = {}) {
|
|
32
|
+
const command = remoteCwbCommand(server, argv);
|
|
33
|
+
const args = [...sshBaseArgs(server, { tty: opts.tty }), command];
|
|
34
|
+
const maxBuffer = opts.maxBuffer || DEFAULT_MAX_BUFFER;
|
|
35
|
+
const encoding = opts.encoding || 'utf8';
|
|
36
|
+
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const child = spawn('ssh', args, {
|
|
39
|
+
env: process.env,
|
|
40
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
41
|
+
});
|
|
42
|
+
let stdout = '';
|
|
43
|
+
let stderr = '';
|
|
44
|
+
let stdoutSize = 0;
|
|
45
|
+
let stderrSize = 0;
|
|
46
|
+
let settled = false;
|
|
47
|
+
|
|
48
|
+
const finish = (result) => {
|
|
49
|
+
if (settled) return;
|
|
50
|
+
settled = true;
|
|
51
|
+
resolve(result);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const append = (name, chunk) => {
|
|
55
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString(encoding) : String(chunk);
|
|
56
|
+
const size = Buffer.byteLength(text);
|
|
57
|
+
if (name === 'stdout') {
|
|
58
|
+
stdout += text;
|
|
59
|
+
stdoutSize += size;
|
|
60
|
+
} else {
|
|
61
|
+
stderr += text;
|
|
62
|
+
stderrSize += size;
|
|
63
|
+
}
|
|
64
|
+
if (stdoutSize + stderrSize > maxBuffer) {
|
|
65
|
+
child.kill();
|
|
66
|
+
const error = new Error('spawn ssh ENOBUFS');
|
|
67
|
+
error.code = 'ENOBUFS';
|
|
68
|
+
finish({ error, stdout, stderr, status: null, signal: 'SIGTERM' });
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
child.stdout.on('data', (chunk) => append('stdout', chunk));
|
|
73
|
+
child.stderr.on('data', (chunk) => append('stderr', chunk));
|
|
74
|
+
child.on('error', (error) => finish({ error, stdout, stderr, status: null, signal: null }));
|
|
75
|
+
child.on('close', (status, signal) => finish({ stdout, stderr, status, signal }));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
29
79
|
function runRemoteCwbJson(server, argv) {
|
|
30
80
|
const result = runRemoteCwb(server, argv, { encoding: 'utf8' });
|
|
31
81
|
if (result.error) {
|
|
@@ -41,9 +91,26 @@ function runRemoteCwbJson(server, argv) {
|
|
|
41
91
|
return JSON.parse(result.stdout || 'null');
|
|
42
92
|
}
|
|
43
93
|
|
|
94
|
+
async function runRemoteCwbJsonAsync(server, argv) {
|
|
95
|
+
const result = await runRemoteCwbAsync(server, argv, { encoding: 'utf8' });
|
|
96
|
+
if (result.error) {
|
|
97
|
+
if (result.error.code === 'ENOBUFS') {
|
|
98
|
+
throw new Error('remote output exceeded buffer; update the remote codex-workbench so compact listing is available');
|
|
99
|
+
}
|
|
100
|
+
throw result.error;
|
|
101
|
+
}
|
|
102
|
+
if (result.status !== 0) {
|
|
103
|
+
const stderr = (result.stderr || '').trim();
|
|
104
|
+
throw new Error(stderr || `ssh exited with code ${result.status}`);
|
|
105
|
+
}
|
|
106
|
+
return JSON.parse(result.stdout || 'null');
|
|
107
|
+
}
|
|
108
|
+
|
|
44
109
|
module.exports = {
|
|
45
110
|
remoteCwbCommand,
|
|
46
111
|
runRemoteCwb,
|
|
112
|
+
runRemoteCwbAsync,
|
|
47
113
|
runRemoteCwbJson,
|
|
114
|
+
runRemoteCwbJsonAsync,
|
|
48
115
|
sshBaseArgs,
|
|
49
116
|
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Tput = require('blessed/lib/tput');
|
|
4
|
+
|
|
5
|
+
function patchBlessedTerminfo() {
|
|
6
|
+
if (Tput.prototype._codexWorkbenchPatched) return;
|
|
7
|
+
|
|
8
|
+
const compile = Tput.prototype._compile;
|
|
9
|
+
Tput.prototype._compile = function patchedCompile(info, key, str) {
|
|
10
|
+
if (key === 'plab_norm') return () => '';
|
|
11
|
+
return compile.call(this, info, key, str);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
Tput.prototype._codexWorkbenchPatched = true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
patchBlessedTerminfo();
|
|
18
|
+
|
|
19
|
+
module.exports = {
|
|
20
|
+
patchBlessedTerminfo,
|
|
21
|
+
};
|
package/src/ui/workbench.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
|
+
require('./blessed-compat');
|
|
4
5
|
const blessed = require('blessed');
|
|
5
6
|
const { printList, printShow } = require('../cli-output');
|
|
6
7
|
const { deleteSessionFile } = require('../model/session-store');
|
|
@@ -9,6 +10,8 @@ const {
|
|
|
9
10
|
LOCAL_SOURCE,
|
|
10
11
|
createSourceDirectory,
|
|
11
12
|
listSourceDirectories,
|
|
13
|
+
loadLocalWorkbenchSessions,
|
|
14
|
+
loadRemoteSourceSessions,
|
|
12
15
|
loadWorkbenchSessions,
|
|
13
16
|
runSourceNewSession,
|
|
14
17
|
runSourceSessionCommand,
|
|
@@ -34,6 +37,9 @@ async function runWorkbench() {
|
|
|
34
37
|
let syncingProjects = false;
|
|
35
38
|
let projectWidth = 32;
|
|
36
39
|
let activePanel = 'projects';
|
|
40
|
+
let remoteLoadId = 0;
|
|
41
|
+
let remoteLoading = false;
|
|
42
|
+
let closed = false;
|
|
37
43
|
|
|
38
44
|
const screen = blessed.screen({
|
|
39
45
|
smartCSR: true,
|
|
@@ -159,6 +165,17 @@ async function runWorkbench() {
|
|
|
159
165
|
|
|
160
166
|
const currentGroup = () => groups[groupIndex] || groups[0] || { kind: 'all', source: null, cwd: null };
|
|
161
167
|
|
|
168
|
+
const groupKey = (group) => {
|
|
169
|
+
if (!group || group.kind === 'all') return 'all';
|
|
170
|
+
if (group.kind === 'source') return `source:${group.source.id}`;
|
|
171
|
+
return `project:${group.source.id}:${group.cwd}`;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const restoreGroupKey = (key) => {
|
|
175
|
+
const index = groups.findIndex((group) => groupKey(group) === key);
|
|
176
|
+
if (index !== -1) groupIndex = index;
|
|
177
|
+
};
|
|
178
|
+
|
|
162
179
|
const currentSessions = () => {
|
|
163
180
|
const group = currentGroup();
|
|
164
181
|
if (group.kind === 'all') return sessions;
|
|
@@ -220,20 +237,101 @@ async function runWorkbench() {
|
|
|
220
237
|
status.style.fg = isError ? 'red' : 'white';
|
|
221
238
|
};
|
|
222
239
|
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
240
|
+
const visibleSession = (session) => !session.archived && !session.hidden;
|
|
241
|
+
|
|
242
|
+
const sortSessionList = (list) => {
|
|
243
|
+
list.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
|
244
|
+
return list;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const setSourceErrorMessage = () => {
|
|
248
|
+
if (!sourceErrors.length) return false;
|
|
249
|
+
const first = sourceErrors[0];
|
|
250
|
+
const detail = `${first.source.label}: ${first.error}`;
|
|
251
|
+
const prefix = sourceErrors.length === 1 ? 'Remote source failed' : `${sourceErrors.length} remote sources failed`;
|
|
252
|
+
setMessage(`${prefix}: ${truncate(detail, 100)}`, true);
|
|
253
|
+
return true;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const updateSessionViews = (preferredGroupKey = groupKey(currentGroup())) => {
|
|
228
257
|
groups = buildGroups();
|
|
258
|
+
restoreGroupKey(preferredGroupKey);
|
|
229
259
|
if (groupIndex >= groups.length) groupIndex = Math.max(0, groups.length - 1);
|
|
230
260
|
const visible = currentSessions();
|
|
231
261
|
if (selected >= visible.length) selected = Math.max(0, visible.length - 1);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const reloadLocal = (preserveRemote = true) => {
|
|
265
|
+
const preferredGroupKey = groupKey(currentGroup());
|
|
266
|
+
const state = loadLocalWorkbenchSessions();
|
|
267
|
+
const sourceIds = new Set(state.sources.map((source) => source.id));
|
|
268
|
+
const remoteSessions = preserveRemote
|
|
269
|
+
? sessions.filter((session) => session.sourceRemote && sourceIds.has(session.sourceId))
|
|
270
|
+
: [];
|
|
271
|
+
sources = state.sources;
|
|
272
|
+
sourceErrors = sourceErrors.filter((item) => sourceIds.has(item.source.id));
|
|
273
|
+
sessions = sortSessionList([...state.sessions, ...remoteSessions].filter(visibleSession));
|
|
274
|
+
updateSessionViews(preferredGroupKey);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const replaceSourceSessions = (source, sourceSessions) => {
|
|
278
|
+
const preferredGroupKey = groupKey(currentGroup());
|
|
279
|
+
sessions = sortSessionList([
|
|
280
|
+
...sessions.filter((session) => session.sourceId !== source.id),
|
|
281
|
+
...sourceSessions.filter(visibleSession),
|
|
282
|
+
]);
|
|
283
|
+
updateSessionViews(preferredGroupKey);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const renderRemoteUpdate = () => {
|
|
287
|
+
if (closed) return;
|
|
288
|
+
syncProjects();
|
|
289
|
+
syncList();
|
|
290
|
+
render();
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const startRemoteReload = (quiet = false) => {
|
|
294
|
+
const remoteSources = sources.filter((source) => source.remote);
|
|
295
|
+
remoteLoadId += 1;
|
|
296
|
+
const loadId = remoteLoadId;
|
|
297
|
+
sourceErrors = [];
|
|
298
|
+
if (!remoteSources.length) {
|
|
299
|
+
remoteLoading = false;
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
remoteLoading = true;
|
|
304
|
+
let completed = 0;
|
|
305
|
+
if (!quiet && (!message || message === 'Ready')) {
|
|
306
|
+
setMessage(`Loading ${remoteSources.length} remote source${remoteSources.length === 1 ? '' : 's'}...`);
|
|
307
|
+
renderRemoteUpdate();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for (const source of remoteSources) {
|
|
311
|
+
loadRemoteSourceSessions(source)
|
|
312
|
+
.then((sourceSessions) => {
|
|
313
|
+
if (closed || loadId !== remoteLoadId) return;
|
|
314
|
+
replaceSourceSessions(source, sourceSessions);
|
|
315
|
+
})
|
|
316
|
+
.catch((err) => {
|
|
317
|
+
if (closed || loadId !== remoteLoadId) return;
|
|
318
|
+
sourceErrors.push({ source, error: err.message });
|
|
319
|
+
})
|
|
320
|
+
.finally(() => {
|
|
321
|
+
if (closed || loadId !== remoteLoadId) return;
|
|
322
|
+
completed += 1;
|
|
323
|
+
remoteLoading = completed < remoteSources.length;
|
|
324
|
+
if (remoteLoading) {
|
|
325
|
+
if (!quiet && message.startsWith('Loading ')) {
|
|
326
|
+
setMessage(`Loading remote sources... ${completed}/${remoteSources.length}`);
|
|
327
|
+
}
|
|
328
|
+
} else if (sourceErrors.length) {
|
|
329
|
+
setSourceErrorMessage();
|
|
330
|
+
} else if (message.startsWith('Loading ')) {
|
|
331
|
+
setMessage('Remote sources loaded.');
|
|
332
|
+
}
|
|
333
|
+
renderRemoteUpdate();
|
|
334
|
+
});
|
|
237
335
|
}
|
|
238
336
|
};
|
|
239
337
|
|
|
@@ -356,12 +454,13 @@ async function runWorkbench() {
|
|
|
356
454
|
const promptOpen = () => prompt.visible || question.visible || directoryPicker.isOpen();
|
|
357
455
|
|
|
358
456
|
const leaveScreen = () => {
|
|
457
|
+
closed = true;
|
|
359
458
|
screen.destroy();
|
|
360
459
|
};
|
|
361
460
|
|
|
362
461
|
const refreshAfterAction = (text, isError = false, focusCwd = null, focusSourceId = null) => {
|
|
363
462
|
setMessage(text, isError);
|
|
364
|
-
|
|
463
|
+
reloadLocal();
|
|
365
464
|
if (focusCwd) {
|
|
366
465
|
const nextGroupIndex = groups.findIndex((group) => {
|
|
367
466
|
return group.kind === 'project' &&
|
|
@@ -373,6 +472,7 @@ async function runWorkbench() {
|
|
|
373
472
|
syncProjects();
|
|
374
473
|
syncList();
|
|
375
474
|
render();
|
|
475
|
+
startRemoteReload(true);
|
|
376
476
|
};
|
|
377
477
|
|
|
378
478
|
const selectGroup = (index) => {
|
|
@@ -453,8 +553,9 @@ async function runWorkbench() {
|
|
|
453
553
|
}
|
|
454
554
|
};
|
|
455
555
|
|
|
456
|
-
|
|
457
|
-
|
|
556
|
+
reloadLocal(false);
|
|
557
|
+
const remoteSourceCount = sources.filter((source) => source.remote).length;
|
|
558
|
+
setMessage(remoteSourceCount ? `Loading ${remoteSourceCount} remote source${remoteSourceCount === 1 ? '' : 's'}...` : 'Ready');
|
|
458
559
|
applyLayout();
|
|
459
560
|
syncProjects();
|
|
460
561
|
syncList();
|
|
@@ -648,6 +749,7 @@ async function runWorkbench() {
|
|
|
648
749
|
|
|
649
750
|
projectsList.focus();
|
|
650
751
|
render();
|
|
752
|
+
startRemoteReload(true);
|
|
651
753
|
|
|
652
754
|
return new Promise(() => {});
|
|
653
755
|
}
|