@4xeoz/re-entry 0.2.3 → 0.2.5

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
@@ -19,23 +19,27 @@ npx @4xeoz/re-entry install
19
19
  This uses the deployed Re-entry Cloud Receiver by default:
20
20
  `https://reentry-cloud.vercel.app`.
21
21
 
22
- The interactive CLI lets you choose the Codex workspace with ↑/↓ and Enter. If you want to choose
23
- the workspace explicitly, pass it directly:
22
+ The interactive CLI first offers Desktop, the current folder, or a small folder browser. Choose
23
+ with ↑/↓ and Enter. If you want to skip the picker, pass the workspace directly:
24
24
 
25
25
  ```sh
26
26
  npx @4xeoz/re-entry install \
27
27
  --codex-cd /absolute/path/to/your/project
28
28
  ```
29
29
 
30
+ The guided screen stays intentionally small: **Workspace → System check → Connect Re-entry**. It
31
+ shows one clear next command when setup finishes; internal Connector IDs, credential paths, and log
32
+ paths stay out of the normal success screen.
33
+
30
34
  Run this from the Host project directory, your home directory, or another normal working
31
35
  directory—not from a checked-out `runtime/local-connector` package directory. npm can treat that
32
36
  source directory as the package itself and fail to create the temporary executable link.
33
37
 
34
- If npm prints `sh: re-entry: command not found`, the package is present but that npm installation
35
- has an `npx` temporary-bin `PATH` bug. Use the reliable one-time global installation instead:
38
+ If your npm installation cannot create the temporary `npx` executable, use the one-time global
39
+ installation instead:
36
40
 
37
41
  ```sh
38
- npm install --global @4xeoz/re-entry@0.2.1
42
+ npm install --global @4xeoz/re-entry
39
43
  re-entry install --codex-cd /absolute/path/to/your/project
40
44
  ```
41
45
 
@@ -51,8 +55,8 @@ npx --yes --package=@4xeoz/re-entry re-entry install \
51
55
  `re-entry install` performs the whole user setup:
52
56
 
53
57
  ```text
54
- check Node + find Codex + validate project directory
55
- -> choose the Codex workspace with ↑/↓ and Enter when --codex-cd is omitted
58
+ choose Desktop, the current folder, or another Codex workspace
59
+ -> check Node + find Codex + validate the selected directory
56
60
  -> request a device authorization from Re-entry
57
61
  -> show the verification URL and wait for the user to press Enter
58
62
  -> open Re-entry in the default browser
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4xeoz/re-entry",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "description": "Outbound Local Connector process for the Re-entry Core local preview.",
6
6
  "publishConfig": {
@@ -8,7 +8,7 @@ import process from "node:process";
8
8
  export async function waitForEnterToOpenBrowser(options = {}) {
9
9
  const input = options.input ?? process.stdin;
10
10
  const output = options.output ?? process.stdout;
11
- const prompt = options.prompt ?? " Press Enter to open Re-entry in your browser: ";
11
+ const prompt = options.prompt ?? "\n Press Enter to open Re-entry ";
12
12
  const readline = createInterface({ input, output });
13
13
  try {
14
14
  await readline.question(prompt);
package/src/main.mjs CHANGED
@@ -32,6 +32,7 @@ import { createTerminalUi } from "./terminal-ui.mjs";
32
32
  const DEFAULT_RECEIVER_ORIGIN = process.env.REENTRY_RECEIVER_ORIGIN ?? "https://reentry-cloud.vercel.app";
33
33
  const DEFAULT_POLL_INTERVAL_MS = 5_000;
34
34
  const DEFAULT_MAX_CONSECUTIVE_ERRORS = 5;
35
+ const DEFAULT_PAIRING_REQUEST_TIMEOUT_MS = 20_000;
35
36
  const CONNECTOR_VERSION = readConnectorVersion();
36
37
 
37
38
  async function main() {
@@ -148,9 +149,12 @@ function readConnectorVersion() {
148
149
  }
149
150
 
150
151
  function doctor(flags, ui) {
151
- if (ui.interactive) ui.begin("Check this Mac", "Read-only readiness check");
152
+ if (ui.interactive) {
153
+ ui.begin("System check", "Confirm that this Mac is ready for Re-entry.");
154
+ ui.section("CHECK", "Requirements");
155
+ }
152
156
  const readiness = inspectReadiness(flags);
153
- showReadiness(readiness, ui);
157
+ showReadiness(readiness, ui, { detailed: true });
154
158
  if (!ui.interactive) {
155
159
  process.stdout.write(`${JSON.stringify({
156
160
  event: "connector_ready",
@@ -178,21 +182,26 @@ async function withWorkspaceDirectory(flags, ui) {
178
182
  if (flags["codex-cd"] !== undefined || !ui.interactive) {
179
183
  return { ...flags, "codex-cd": flags["codex-cd"] ?? process.cwd() };
180
184
  }
181
- ui.info("Workspace", "choose the folder Codex will open");
182
185
  const selected = await chooseWorkspaceDirectory({ startDirectory: process.cwd() });
183
186
  if (!selected) throw cliFailure("connector_codex_cd_missing");
184
- ui.success("Workspace", selected);
185
187
  return { ...flags, "codex-cd": selected };
186
188
  }
187
189
 
188
- function showReadiness(readiness, ui) {
190
+ function showReadiness(readiness, ui, options = {}) {
189
191
  if (!ui.interactive) return;
190
- ui.success("Node.js", process.versions.node);
191
- ui.success("Codex", `${readiness.installation.version} · ${readiness.installation.executable}`);
192
- if (readiness.workingDirectory) {
193
- ui.success("Host project", readiness.workingDirectory);
194
- } else {
195
- ui.info("Host project", "not selected; pass --codex-cd when starting Codex");
192
+ ui.success("Node.js", `v${process.versions.node}`);
193
+ ui.success(
194
+ "Codex",
195
+ options.detailed
196
+ ? `${readiness.installation.version} · ${readiness.installation.executable}`
197
+ : "ready",
198
+ );
199
+ if (options.includeWorkspace !== false) {
200
+ if (readiness.workingDirectory) {
201
+ ui.success("Workspace", readiness.workingDirectory);
202
+ } else {
203
+ ui.info("Workspace", "selected automatically when Re-entry starts");
204
+ }
196
205
  }
197
206
  }
198
207
 
@@ -207,20 +216,23 @@ async function pair(flags, ui, options = {}) {
207
216
  }
208
217
  const userCode = flags.code ?? await askForPairingCode(ui);
209
218
  if (ui.interactive) ui.step("Pairing code", "received");
210
- const client = new LocalConnectorPairingClient({ baseUrl: receiver });
219
+ const client = new LocalConnectorPairingClient({
220
+ baseUrl: receiver,
221
+ requestTimeoutMs: DEFAULT_PAIRING_REQUEST_TIMEOUT_MS,
222
+ });
223
+ if (ui.interactive) ui.wait("Contacting Re-entry…");
211
224
  const credentials = await client.pair({ userCode }, async ({ verificationUri }) => {
212
225
  if (ui.interactive) {
213
- ui.step("Browser", "press Enter to open the Re-entry approval page");
214
- ui.info("Open this URL", verificationUri);
226
+ ui.stopWait("Secure link", "ready");
227
+ ui.info("Browser link", verificationUri);
215
228
  await waitForEnterToOpenBrowser();
216
- ui.step("Browser", "opening the Re-entry approval page");
217
- ui.wait("Waiting for you to approve this Mac…");
229
+ ui.wait("Waiting for approval in your browser…");
218
230
  } else {
219
231
  process.stdout.write(`${JSON.stringify({ event: "pairing_waiting", verification_uri: verificationUri })}\n`);
220
232
  }
221
233
  });
222
234
  if (ui.interactive) {
223
- ui.stopWait("Approval received", "the Receiver approved this Connector");
235
+ ui.stopWait("Approved", "this Mac is connected");
224
236
  if (!credentials.browserOpened) {
225
237
  ui.warning("Browser", "did not open automatically; use the URL above");
226
238
  }
@@ -234,7 +246,8 @@ async function pair(flags, ui, options = {}) {
234
246
  connector_expires_at: credentials.connector_expires_at,
235
247
  });
236
248
  if (ui.interactive) {
237
- ui.success("This Mac is paired", `credential saved at ${credentialFile}`);
249
+ ui.complete("Pairing complete", "Re-entry can now deliver approved work to this Mac.");
250
+ ui.next("re-entry start", "Wait for approved work in this terminal.");
238
251
  } else {
239
252
  process.stdout.write(`${JSON.stringify({ event: "connector_paired", connector_id: credentials.connector_id })}\n`);
240
253
  }
@@ -251,8 +264,10 @@ async function connect(flags, ui, options = {}) {
251
264
  const currentIsValid = current && Date.parse(current.connector_expires_at) > Date.now();
252
265
  if (currentIsValid && current.receiver_origin === receiver) {
253
266
  if (ui.interactive) {
254
- ui.success("Already connected", `${current.connector_id} · ${current.receiver_origin}`);
255
- ui.info("Next", "run `re-entry start` to wait for approved work");
267
+ ui.success("Account", "already connected");
268
+ if (!options.guidedInstall) {
269
+ ui.next("re-entry start", "Wait for approved work in this terminal.");
270
+ }
256
271
  } else {
257
272
  process.stdout.write(`${JSON.stringify({
258
273
  event: "connector_already_connected",
@@ -268,19 +283,22 @@ async function connect(flags, ui, options = {}) {
268
283
  }
269
284
 
270
285
  if (ui.interactive) {
271
- ui.step("Re-entry", receiver);
272
- ui.step("This Mac", flags["device-name"] ?? defaultDeviceName());
286
+ if (!options.guidedInstall) ui.info("Re-entry", displayReceiver(receiver));
287
+ ui.info("This Mac", flags["device-name"] ?? defaultDeviceName());
273
288
  }
274
- const client = new LocalConnectorPairingClient({ baseUrl: receiver });
289
+ const client = new LocalConnectorPairingClient({
290
+ baseUrl: receiver,
291
+ requestTimeoutMs: DEFAULT_PAIRING_REQUEST_TIMEOUT_MS,
292
+ });
293
+ if (ui.interactive) ui.wait("Contacting Re-entry…");
275
294
  const credentials = await client.connect(
276
295
  { deviceName: flags["device-name"] ?? defaultDeviceName() },
277
296
  async ({ verificationUri }) => {
278
297
  if (ui.interactive) {
279
- ui.step("Browser", "press Enter to open Re-entry sign-in and approval");
280
- ui.info("If it does not open", verificationUri);
298
+ ui.stopWait("Secure link", "ready");
299
+ ui.info("Browser link", verificationUri);
281
300
  await waitForEnterToOpenBrowser();
282
- ui.step("Browser", "opening Re-entry sign-in and approval");
283
- ui.wait("Waiting for you to connect this Mac…");
301
+ ui.wait("Waiting for approval in your browser…");
284
302
  } else {
285
303
  process.stdout.write(`${JSON.stringify({
286
304
  event: "connector_authorization_waiting",
@@ -290,7 +308,7 @@ async function connect(flags, ui, options = {}) {
290
308
  },
291
309
  );
292
310
  if (ui.interactive) {
293
- ui.stopWait("Approved", "Re-entry linked this Mac to your account");
311
+ ui.stopWait("Account", "connected");
294
312
  if (!credentials.browserOpened) {
295
313
  ui.warning("Browser", "did not open automatically; use the URL shown above");
296
314
  }
@@ -304,8 +322,10 @@ async function connect(flags, ui, options = {}) {
304
322
  };
305
323
  await store.save(saved);
306
324
  if (ui.interactive) {
307
- ui.success("Connected", `credential saved at ${credentialFile}`);
308
- ui.info("Next", "run `re-entry start` once; it will keep waiting in the background");
325
+ if (!options.guidedInstall) {
326
+ ui.complete("This Mac is connected", "Re-entry can now route approved work here.");
327
+ ui.next("re-entry start", "Wait for approved work in this terminal.");
328
+ }
309
329
  } else {
310
330
  process.stdout.write(`${JSON.stringify({
311
331
  event: "connector_connected",
@@ -316,22 +336,32 @@ async function connect(flags, ui, options = {}) {
316
336
  }
317
337
 
318
338
  async function status(flags, ui) {
319
- if (ui.interactive) ui.begin("Connector status", "Account, background service, Receiver, and Codex");
339
+ if (ui.interactive) ui.begin("Status", "A quick check of this Mac and Re-entry.");
320
340
  const credentialFile = flags["credential-file"] ?? defaultCredentialFile();
321
341
  const credentials = await new LocalConnectorCredentialStore({ filename: credentialFile }).load();
322
342
  const readiness = inspectReadiness(flags);
323
343
  const service = await inspectMacConnectorService();
324
344
  const receiverReady = credentials ? await inspectReceiver(credentials.receiver_origin) : false;
325
- showReadiness(readiness, ui);
326
345
  const connected = Boolean(credentials && Date.parse(credentials.connector_expires_at) > Date.now());
327
346
  if (ui.interactive) {
328
- if (connected) ui.success("Account", `${credentials.connector_id} · authorized`);
329
- else ui.warning("Re-entry", "not connected; run `re-entry connect`");
330
- if (service.running) ui.success("Background", "running at login");
331
- else if (service.installed) ui.warning("Background", "installed but not running; run `re-entry install`");
332
- else ui.warning("Background", "not installed; run `re-entry install`");
333
- if (connected && receiverReady) ui.success("Receiver", `${credentials.receiver_origin} · reachable`);
334
- else if (connected) ui.warning("Receiver", `${credentials.receiver_origin} · unavailable`);
347
+ ui.section("SYSTEM", "This Mac");
348
+ showReadiness(readiness, ui);
349
+ ui.section("CONNECTION", "Re-entry");
350
+ if (connected) ui.success("Account", "connected");
351
+ else ui.warning("Account", "not connected");
352
+ if (service.running) ui.success("Background", "running");
353
+ else if (service.installed) ui.warning("Background", "stopped");
354
+ else ui.warning("Background", "not installed");
355
+ if (connected && receiverReady) ui.success("Cloud", "online");
356
+ else if (connected) ui.warning("Cloud", "unavailable");
357
+
358
+ if (!connected || !service.running) {
359
+ ui.next("re-entry install", "Finish setup and start Re-entry in the background.");
360
+ } else if (!receiverReady) {
361
+ ui.next("re-entry status", "Check again when the Re-entry Cloud service is available.");
362
+ } else {
363
+ ui.complete("Everything looks good", "Re-entry is ready for approved work.");
364
+ }
335
365
  } else {
336
366
  process.stdout.write(`${JSON.stringify({
337
367
  event: "connector_status",
@@ -355,8 +385,9 @@ async function stop(flags, ui) {
355
385
  const result = await stopMacConnectorService();
356
386
  if (ui.interactive) {
357
387
  if (!result.supported) ui.warning("Platform", "background service control currently supports macOS only");
358
- else if (result.stopped) ui.success("Stopped", "the background Connector is no longer running");
388
+ else if (result.stopped) ui.complete("Re-entry is paused", "Your account connection is still saved.");
359
389
  else ui.info("Already stopped", "no running background Connector was found");
390
+ if (result.supported) ui.next("re-entry install", "Start Re-entry in the background again.");
360
391
  } else {
361
392
  process.stdout.write(`${JSON.stringify({
362
393
  event: "connector_stopped",
@@ -378,8 +409,8 @@ async function uninstall(flags, ui) {
378
409
  credentialFile: flags["credential-file"] ?? defaultCredentialFile(),
379
410
  });
380
411
  if (ui.interactive) {
381
- ui.success("Uninstalled", "local Connector service data was removed");
382
- ui.info("Package", "remove the npm package separately with `npm uninstall --global @4xeoz/re-entry`");
412
+ ui.complete("Removed from this Mac", "The local service, connection, and logs are gone.");
413
+ ui.next("npx @4xeoz/re-entry install", "Connect this Mac again whenever you are ready.");
383
414
  } else {
384
415
  process.stdout.write(`${JSON.stringify({
385
416
  event: "connector_uninstalled",
@@ -390,16 +421,18 @@ async function uninstall(flags, ui) {
390
421
  }
391
422
 
392
423
  async function install(flags, ui) {
393
- if (ui.interactive) {
394
- ui.begin("Install Re-entry", "Check Codex, connect your account, then start at login");
395
- ui.info("Setup", "choose a workspace, approve this Mac, then leave the Connector running");
396
- }
397
424
  const runtimeFlags = await withWorkspaceDirectory(flags, ui);
398
- if (ui.interactive) ui.info("Receiver", runtimeFlags.receiver ?? DEFAULT_RECEIVER_ORIGIN);
399
425
  const readiness = inspectReadiness(runtimeFlags);
400
- showReadiness(readiness, ui);
401
- const credentials = await connect(runtimeFlags, ui, { quietHeader: true });
402
- if (ui.interactive) ui.wait("Installing the background Connector…");
426
+ if (ui.interactive) {
427
+ ui.begin("Set up this Mac", "Three quick steps. You only do this once.");
428
+ ui.section("1 OF 3", "Workspace");
429
+ ui.success("Selected", readiness.workingDirectory);
430
+ ui.section("2 OF 3", "System check");
431
+ showReadiness(readiness, ui, { includeWorkspace: false });
432
+ ui.section("3 OF 3", "Connect Re-entry", "Approve this Mac in your browser.");
433
+ }
434
+ const credentials = await connect(runtimeFlags, ui, { quietHeader: true, guidedInstall: true });
435
+ if (ui.interactive) ui.wait("Starting Re-entry in the background…");
403
436
  const service = await installMacConnectorService({
404
437
  nodeExecutable: process.execPath,
405
438
  entrypoint: fileURLToPath(import.meta.url),
@@ -407,11 +440,9 @@ async function install(flags, ui) {
407
440
  credentialFile: flags["credential-file"] ?? defaultCredentialFile(),
408
441
  });
409
442
  if (ui.interactive) {
410
- ui.stopWait("Installed", "Re-entry will start automatically when you log in");
411
- ui.success("Account", credentials.connector_id);
412
- ui.info("Logs", service.stdoutPath);
413
- ui.info("Commands", "status to inspect · stop to pause · uninstall to remove local setup");
414
- ui.info("You are done", "the Connector now waits in the background");
443
+ ui.stopWait("Background", "running at login");
444
+ ui.complete("You're all set", "Re-entry is connected and waiting for approved work.");
445
+ ui.next("re-entry status", "Check the connection at any time.");
415
446
  } else {
416
447
  process.stdout.write(`${JSON.stringify({
417
448
  event: "connector_service_installed",
@@ -424,12 +455,13 @@ async function install(flags, ui) {
424
455
  }
425
456
 
426
457
  async function start(flags, ui) {
427
- if (ui.interactive) {
428
- ui.begin("Re-entry is starting", "Connect once, then wait quietly for work you approve");
429
- }
430
458
  const runtimeFlags = await withWorkspaceDirectory(flags, ui);
431
459
  const readiness = inspectReadiness(runtimeFlags);
432
- showReadiness(readiness, ui);
460
+ if (ui.interactive) {
461
+ ui.begin("Start Re-entry", "Wait for work you have approved.");
462
+ ui.section("READY", "This Mac");
463
+ showReadiness(readiness, ui);
464
+ }
433
465
  if (!ui.interactive) {
434
466
  process.stdout.write(`${JSON.stringify({
435
467
  event: "connector_ready",
@@ -715,6 +747,14 @@ function defaultDeviceName() {
715
747
  : "This Mac";
716
748
  }
717
749
 
750
+ function displayReceiver(origin) {
751
+ try {
752
+ return new URL(origin).host;
753
+ } catch {
754
+ return origin;
755
+ }
756
+ }
757
+
718
758
  function readBoundedNumber(value, minimum, maximum, code) {
719
759
  const number = typeof value === "number" ? value : Number(value);
720
760
  if (!Number.isSafeInteger(number) || number < minimum || number > maximum) {
@@ -802,6 +842,10 @@ function errorHint(error) {
802
842
  pairing_code_invalid: "use the 16-character code returned by the Host, for example ABCD-EFGH-IJKL-MNOP",
803
843
  host_subject_already_paired: "use the existing Connector credential or revoke/reset the preview pairing",
804
844
  pairing_expired: "ask the Host backend for a new pairing code",
845
+ pairing_request_timeout: "the Receiver took too long to answer; check your connection and run the command again",
846
+ pairing_network_error: "check your internet connection and the Receiver address, then try again",
847
+ workspace_directory_unavailable: "choose a readable folder or pass --codex-cd /absolute/path",
848
+ workspace_selection_cancelled: "run the command again when you are ready to choose a workspace",
805
849
  device_authorization_expired: "run `re-entry connect` again and approve within ten minutes",
806
850
  device_authorization_denied: "run `re-entry connect` again when you are ready to approve this Mac",
807
851
  connector_poll_interval_invalid: "use a polling interval between 1000 and 60000 milliseconds",
@@ -8,19 +8,12 @@ const YELLOW = "\u001b[33m";
8
8
  const RED = "\u001b[31m";
9
9
  const CYAN = "\u001b[36m";
10
10
  const SPINNER_FRAMES = ["·", "✦", "✧", "✦"];
11
- const REENTRY_ASCII = [
12
- " ____ _____ _____ _ _ _____ ____ __ __",
13
- " | _ \\| ____| | ____| \\ | | ____| _ \\ \\ \\ / /",
14
- " | |_) | _| _____ | _| | \\| | _| | |_) | \\ V /",
15
- " | _ <| |__|_____| | |___| |\\ | |___| _ < | |",
16
- " |_| \\_\\_____| |_____|_| \\_|_____|_| \\_\\ |_|",
17
- ];
18
- const REENTRY_PANEL = [
19
- " +-----------------------------------------------+",
20
- " | RE-ENTRY |",
21
- " | LOCAL CONNECTOR |",
22
- " +-----------------------------------------------+",
23
- ];
11
+ const RULE = " ─────────────────────────────────────────────";
12
+
13
+ export const REENTRY_WORDMARK = Object.freeze([
14
+ " █▀█ █▀▀ █▀▀ █▄░█ ▀█▀ █▀█ █▄█",
15
+ " █▀▄ ██▄ ██▄ █░▀█ ░█░ █▀▄ ░█░",
16
+ ]);
24
17
 
25
18
  /**
26
19
  * Small dependency-free terminal presentation for the Local Connector CLI.
@@ -36,7 +29,7 @@ export function createTerminalUi(options = {}) {
36
29
  let spinnerFrame = 0;
37
30
 
38
31
  const style = (value, code) => color ? `${code}${value}${RESET}` : value;
39
- const write = (value) => output.write(`${value}\n`);
32
+ const write = (value = "") => output.write(`${value}\n`);
40
33
  const clearSpinnerLine = () => {
41
34
  if (spinnerTimer === null) return;
42
35
  output.write("\r\u001b[2K");
@@ -44,44 +37,56 @@ export function createTerminalUi(options = {}) {
44
37
  spinnerTimer = null;
45
38
  };
46
39
  const renderSpinner = () => {
47
- output.write(`\r\u001b[2K ${style(SPINNER_FRAMES[spinnerFrame], CYAN)} ${spinnerMessage}`);
40
+ output.write(`\r\u001b[2K ${style(SPINNER_FRAMES[spinnerFrame], CYAN)} ${spinnerMessage}`);
48
41
  spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
49
42
  };
43
+ const renderState = (symbol, symbolColor, label, detail) => {
44
+ clearSpinnerLine();
45
+ write(` ${style(symbol, symbolColor)} ${style(label, BOLD)}${detail ? ` ${style(detail, DIM)}` : ""}`);
46
+ };
50
47
 
51
48
  return Object.freeze({
52
49
  interactive,
53
50
 
54
51
  begin(title, subtitle) {
55
52
  if (!interactive) return;
56
- write("");
57
- for (const line of REENTRY_ASCII) write(style(line, BOLD));
58
- write("");
59
- for (const line of REENTRY_PANEL) write(style(line, BOLD));
60
- write("");
61
- write(` ${style("RE-ENTRY", BOLD)} ${style("LOCAL CONNECTOR", DIM)}`);
53
+ clearSpinnerLine();
54
+ write();
55
+ for (const line of REENTRY_WORDMARK) write(style(line, `${BOLD}${CYAN}`));
56
+ write(` ${style("LOCAL CONNECTOR", DIM)}`);
57
+ write();
62
58
  write(` ${style(title, BOLD)}`);
63
59
  if (subtitle) write(` ${style(subtitle, DIM)}`);
64
- write("");
60
+ write(RULE);
61
+ },
62
+
63
+ section(step, title, detail) {
64
+ if (!interactive) return;
65
+ clearSpinnerLine();
66
+ write();
67
+ write(` ${style(step, CYAN)} ${style(title, BOLD)}`);
68
+ if (detail) write(` ${style(detail, DIM)}`);
69
+ write();
65
70
  },
66
71
 
67
72
  step(label, detail) {
68
73
  if (!interactive) return;
69
- write(` ${style("→", CYAN)} ${style(label, BOLD)}${detail ? ` ${detail}` : ""}`);
74
+ renderState("→", CYAN, label, detail);
70
75
  },
71
76
 
72
77
  success(label, detail) {
73
78
  if (!interactive) return;
74
- write(` ${style("✓", GREEN)} ${style(label, BOLD)}${detail ? ` ${detail}` : ""}`);
79
+ renderState("✓", GREEN, label, detail);
75
80
  },
76
81
 
77
82
  info(label, detail) {
78
83
  if (!interactive) return;
79
- write(` ${style("·", CYAN)} ${style(label, BOLD)}${detail ? ` ${detail}` : ""}`);
84
+ renderState("·", CYAN, label, detail);
80
85
  },
81
86
 
82
87
  warning(label, detail) {
83
88
  if (!interactive) return;
84
- write(` ${style("!", YELLOW)} ${style(label, BOLD)}${detail ? ` ${detail}` : ""}`);
89
+ renderState("!", YELLOW, label, detail);
85
90
  },
86
91
 
87
92
  wait(message) {
@@ -97,16 +102,38 @@ export function createTerminalUi(options = {}) {
97
102
  stopWait(label, detail, outcome = "success") {
98
103
  if (!interactive) return;
99
104
  clearSpinnerLine();
100
- if (outcome === "warning") this.warning(label, detail);
101
- else if (outcome === "info") this.info(label, detail);
102
- else this.success(label, detail);
105
+ if (outcome === "warning") renderState("!", YELLOW, label, detail);
106
+ else if (outcome === "info") renderState("·", CYAN, label, detail);
107
+ else renderState("✓", GREEN, label, detail);
108
+ },
109
+
110
+ complete(title, detail) {
111
+ if (!interactive) return;
112
+ clearSpinnerLine();
113
+ write();
114
+ write(` ${style("✓", GREEN)} ${style(title, BOLD)}`);
115
+ if (detail) write(` ${style(detail, DIM)}`);
116
+ },
117
+
118
+ next(command, detail) {
119
+ if (!interactive) return;
120
+ clearSpinnerLine();
121
+ write();
122
+ write(` ${style("NEXT", CYAN)}`);
123
+ write(` ${style("$", CYAN)} ${style(command, BOLD)}`);
124
+ if (detail) write(` ${style(detail, DIM)}`);
125
+ write();
103
126
  },
104
127
 
105
128
  error(label, detail, hint) {
106
129
  if (!interactive) return;
107
130
  clearSpinnerLine();
108
- errorOutput.write(` ${style("✕", RED)} ${style(label, BOLD)}${detail ? ` ${detail}` : ""}\n`);
109
- if (hint) errorOutput.write(` ${style("Next:", DIM)} ${hint}\n`);
131
+ errorOutput.write(`\n ${style("✕", RED)} ${style(label, BOLD)}\n`);
132
+ if (detail) errorOutput.write(` ${style(detail, DIM)}\n`);
133
+ if (hint) {
134
+ errorOutput.write(`\n ${style("NEXT", CYAN)}\n`);
135
+ errorOutput.write(` ${style("→", CYAN)} ${hint}\n\n`);
136
+ }
110
137
  },
111
138
 
112
139
  close() {
@@ -1,10 +1,12 @@
1
- import { readdir } from "node:fs/promises";
1
+ import { readdir, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { emitKeypressEvents } from "node:readline";
5
5
  import process from "node:process";
6
6
 
7
- const MAX_VISIBLE_DIRECTORIES = 40;
7
+ import { REENTRY_WORDMARK } from "./terminal-ui.mjs";
8
+
9
+ const MAX_VISIBLE_DIRECTORIES = 12;
8
10
 
9
11
  /**
10
12
  * Let an interactive user choose the directory passed to Codex as its workspace.
@@ -19,19 +21,47 @@ export async function chooseWorkspaceDirectory(options = {}) {
19
21
 
20
22
  const homeDirectory = resolve(options.homeDirectory ?? homedir());
21
23
  const preferredDirectory = resolve(options.startDirectory ?? process.cwd());
22
- let current = homeDirectory;
23
- const shortcuts = shortcutDirectories(homeDirectory);
24
24
  let selected = 0;
25
25
  const wasRaw = Boolean(input.isRaw);
26
26
  emitKeypressEvents(input);
27
27
  input.setRawMode(true);
28
28
 
29
29
  try {
30
+ const quickChoices = await createQuickChoices({ homeDirectory, preferredDirectory });
31
+ while (true) {
32
+ renderPicker(output, {
33
+ title: "Choose where Codex should open for approved work.",
34
+ choices: quickChoices,
35
+ selected,
36
+ });
37
+ const key = await readKey(input);
38
+ if (key.name === "up") {
39
+ selected = (selected - 1 + quickChoices.length) % quickChoices.length;
40
+ } else if (key.name === "down") {
41
+ selected = (selected + 1) % quickChoices.length;
42
+ } else if (key.name === "return" || key.name === "enter") {
43
+ const choice = quickChoices[selected];
44
+ if (choice.action === "use") return choice.path;
45
+ if (choice.action === "cancel") throw cancelledFailure();
46
+ break;
47
+ } else if (key.name === "escape" || key.sequence === "\u0003") {
48
+ throw cancelledFailure();
49
+ }
50
+ }
51
+
52
+ let current = homeDirectory;
53
+ selected = 0;
30
54
  while (true) {
31
55
  const directories = await readableDirectories(current);
32
- const choices = createChoices({ current, homeDirectory, preferredDirectory, shortcuts, directories });
56
+ const choices = createBrowserChoices({ current, directories });
33
57
  selected = Math.min(selected, choices.length - 1);
34
- renderPicker(output, current, choices, selected);
58
+ renderPicker(output, {
59
+ title: "Choose another folder.",
60
+ current,
61
+ choices,
62
+ selected,
63
+ truncated: directories.truncated,
64
+ });
35
65
  const key = await readKey(input);
36
66
  if (key.name === "up") {
37
67
  selected = (selected - 1 + choices.length) % choices.length;
@@ -40,72 +70,112 @@ export async function chooseWorkspaceDirectory(options = {}) {
40
70
  } else if (key.name === "return" || key.name === "enter") {
41
71
  const choice = choices[selected];
42
72
  if (choice.action === "use") return choice.path;
43
- if (choice.action === "cancel") {
44
- throw pickerFailure("workspace_selection_cancelled", "Workspace selection was cancelled");
45
- }
73
+ if (choice.action === "cancel") throw cancelledFailure();
46
74
  current = choice.path;
47
75
  selected = 0;
48
76
  } else if (key.name === "escape" || key.sequence === "\u0003") {
49
- throw pickerFailure("workspace_selection_cancelled", "Workspace selection was cancelled");
77
+ throw cancelledFailure();
50
78
  }
51
79
  }
52
80
  } finally {
53
81
  input.setRawMode(wasRaw);
54
- output.write("\n");
82
+ output.write("\u001b[2J\u001b[H");
55
83
  }
56
84
  }
57
85
 
58
- function createChoices({ current, homeDirectory, preferredDirectory, shortcuts, directories }) {
86
+ async function createQuickChoices({ homeDirectory, preferredDirectory }) {
59
87
  const choices = [];
60
- if (current === homeDirectory && preferredDirectory !== homeDirectory) {
61
- choices.push({ label: `Use current folder ${preferredDirectory}`, action: "use", path: preferredDirectory });
88
+ const desktop = join(homeDirectory, "Desktop");
89
+ if (await isDirectory(desktop)) {
90
+ choices.push({
91
+ label: "Use Desktop (recommended)",
92
+ detail: desktop,
93
+ action: "use",
94
+ path: desktop,
95
+ });
62
96
  }
63
- choices.push({ label: `Use this folder ${current}`, action: "use", path: current });
64
- if (current === homeDirectory) {
65
- for (const shortcut of shortcuts) {
66
- if (directories.some((directory) => directory.path === shortcut.path)) {
67
- choices.push({ label: `Open ${shortcut.label}`, action: "open", path: shortcut.path });
68
- }
69
- }
97
+ if (preferredDirectory !== desktop && await isDirectory(preferredDirectory)) {
98
+ choices.push({
99
+ label: "Use current folder",
100
+ detail: preferredDirectory,
101
+ action: "use",
102
+ path: preferredDirectory,
103
+ });
104
+ }
105
+ if (choices.length === 0) {
106
+ choices.push({
107
+ label: "Use Home",
108
+ detail: homeDirectory,
109
+ action: "use",
110
+ path: homeDirectory,
111
+ });
70
112
  }
113
+ choices.push({ label: "Choose another folder…", action: "browse", path: homeDirectory });
114
+ choices.push({ label: "Cancel", action: "cancel", path: null });
115
+ return choices;
116
+ }
117
+
118
+ function createBrowserChoices({ current, directories }) {
119
+ const choices = [
120
+ { label: "Use this folder", detail: current, action: "use", path: current },
121
+ ];
71
122
  const parent = dirname(current);
72
- if (parent !== current) choices.push({ label: `Go up ${parent}`, action: "open", path: parent });
73
- const shortcutPaths = new Set(shortcuts.map((shortcut) => shortcut.path));
74
- for (const directory of directories) {
75
- if (current === homeDirectory && shortcutPaths.has(directory.path)) continue;
76
- choices.push({ label: `Open ${directory.name}`, action: "open", path: directory.path });
123
+ if (parent !== current) {
124
+ choices.push({ label: "Go up", detail: parent, action: "open", path: parent });
125
+ }
126
+ for (const directory of directories.items) {
127
+ choices.push({ label: directory.name, action: "open", path: directory.path });
77
128
  }
78
129
  choices.push({ label: "Cancel", action: "cancel", path: null });
79
130
  return choices;
80
131
  }
81
132
 
82
- async function readableDirectories(directory) {
83
- const entries = await readdir(directory, { withFileTypes: true });
84
- return entries
85
- .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
86
- .sort((left, right) => left.name.localeCompare(right.name))
87
- .slice(0, MAX_VISIBLE_DIRECTORIES)
88
- .map((entry) => ({ name: entry.name, path: join(directory, entry.name) }));
133
+ async function isDirectory(path) {
134
+ try {
135
+ return (await stat(path)).isDirectory();
136
+ } catch {
137
+ return false;
138
+ }
89
139
  }
90
140
 
91
- function shortcutDirectories(home) {
92
- return [
93
- { label: "Desktop", path: join(home, "Desktop") },
94
- { label: "Documents", path: join(home, "Documents") },
95
- { label: "Downloads", path: join(home, "Downloads") },
96
- { label: "Home", path: home },
97
- ];
141
+ async function readableDirectories(directory) {
142
+ let entries;
143
+ try {
144
+ entries = await readdir(directory, { withFileTypes: true });
145
+ } catch (error) {
146
+ const failure = pickerFailure("workspace_directory_unavailable", `Cannot open ${directory}`);
147
+ failure.cause = error;
148
+ throw failure;
149
+ }
150
+ const directories = entries
151
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
152
+ .sort((left, right) => left.name.localeCompare(right.name));
153
+ return {
154
+ items: directories
155
+ .slice(0, MAX_VISIBLE_DIRECTORIES)
156
+ .map((entry) => ({ name: entry.name, path: join(directory, entry.name) })),
157
+ truncated: directories.length > MAX_VISIBLE_DIRECTORIES,
158
+ };
98
159
  }
99
160
 
100
- function renderPicker(output, current, choices, selected) {
161
+ function renderPicker(output, { title, current, choices, selected, truncated = false }) {
101
162
  output.write("\u001b[2J\u001b[H");
102
- output.write("RE-ENTRY WORKSPACE\n\n");
103
- output.write("Choose the folder where Codex will work. Files stay inside this workspace.\n");
104
- output.write(`Current folder: ${current}\n\n`);
163
+ for (const line of REENTRY_WORDMARK) output.write(`${line}\n`);
164
+ output.write(" LOCAL CONNECTOR\n\n");
165
+ output.write(" 1 OF 3 WORKSPACE\n");
166
+ output.write(` ${title}\n`);
167
+ if (current) output.write(` Current: ${current}\n`);
168
+ output.write(" ─────────────────────────────────────────────\n");
169
+ output.write("\n");
105
170
  for (let index = 0; index < choices.length; index += 1) {
106
- output.write(`${index === selected ? "❯" : " "} ${choices[index].label}\n`);
171
+ const choice = choices[index];
172
+ output.write(` ${index === selected ? "❯" : " "} ${choice.label}\n`);
173
+ if (choice.detail) output.write(` ${choice.detail}\n`);
174
+ }
175
+ if (truncated) {
176
+ output.write(`\n Showing the first ${MAX_VISIBLE_DIRECTORIES} folders.\n`);
107
177
  }
108
- output.write("\n↑/↓ Move Enter Select Esc Cancel");
178
+ output.write("\n ↑↓ Move Enter Select Esc Cancel");
109
179
  }
110
180
 
111
181
  function readKey(input) {
@@ -118,6 +188,10 @@ function readKey(input) {
118
188
  });
119
189
  }
120
190
 
191
+ function cancelledFailure() {
192
+ return pickerFailure("workspace_selection_cancelled", "Workspace selection was cancelled");
193
+ }
194
+
121
195
  function pickerFailure(code, message) {
122
196
  const error = new Error(message);
123
197
  error.code = code;