@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 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.5",
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 = listSessions().map((session) => attachSource(session, LOCAL_SOURCE));
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.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
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 || 64 * 1024 * 1024,
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
+ };
@@ -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 { createChildDirectory, directoryNameError, listDirectories } = require('../model/directories');
6
7
 
@@ -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 reload = () => {
224
- const state = loadWorkbenchSessions();
225
- sources = state.sources;
226
- sourceErrors = state.errors;
227
- sessions = state.sessions.filter((s) => !s.archived && !s.hidden);
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
- if (sourceErrors.length && (!message || message === 'Ready')) {
233
- const first = sourceErrors[0];
234
- const detail = `${first.source.label}: ${first.error}`;
235
- const prefix = sourceErrors.length === 1 ? 'Remote source failed' : `${sourceErrors.length} remote sources failed`;
236
- setMessage(`${prefix}: ${truncate(detail, 100)}`, true);
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
- reload();
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
- reload();
457
- if (!sourceErrors.length) setMessage('Ready');
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
  }