codex-copilot-dx 0.7.0 → 0.7.2
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 +36 -8
- package/bin/cli.mjs +19 -4
- package/package.json +2 -2
- package/src/adapter.mjs +69 -13
- package/src/cli-animation.mjs +108 -0
- package/src/cli-options.mjs +9 -1
- package/src/codex-model-catalog.mjs +261 -0
- package/src/launcher.mjs +4 -5
- package/src/models.mjs +1 -0
- package/src/responses-handler.mjs +11 -0
- package/src/terminal-activity.mjs +40 -34
- package/src/terminal-animation.mjs +249 -0
- package/src/user-settings.mjs +50 -0
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# codex-copilot-dx
|
|
2
2
|
|
|
3
|
-
Use Codex
|
|
3
|
+
Use Codex App with GPT models provided by your **GitHub Copilot** subscription.
|
|
4
4
|
|
|
5
|
-
CCDX is a local compatibility adapter. It configures Codex
|
|
5
|
+
CCDX is a local compatibility adapter for Codex App. It configures Codex to use an OpenAI Responses API on loopback while GitHub authentication, model discovery, and inference stay backed by GitHub Copilot.
|
|
6
6
|
|
|
7
7
|
## How it works
|
|
8
8
|
|
|
@@ -14,7 +14,7 @@ One in-process adapter listens on `127.0.0.1:2026` by default and exposes:
|
|
|
14
14
|
|
|
15
15
|
Responses-only models and compaction use Copilot's Responses transport directly. For GPT models that advertise only Chat Completions, CCDX converts the supported text, image, and function-tool subset and rejects incompatible request shapes explicitly. Both streaming SSE and non-streaming responses are supported.
|
|
16
16
|
|
|
17
|
-
The adapter preserves Codex
|
|
17
|
+
The adapter preserves Codex App behavior around response history, encrypted reasoning state, function/custom tools, images, compaction, cancellation, retries, and usage metadata. Model routing follows the live Copilot catalog instead of a hard-coded GPT model list.
|
|
18
18
|
|
|
19
19
|
### Fast and Standard modes
|
|
20
20
|
|
|
@@ -22,11 +22,17 @@ Codex can select Standard or Fast without changing CCDX configuration. When the
|
|
|
22
22
|
|
|
23
23
|
Codex Auto-review uses the hidden `codex-auto-review` model ID. CCDX maps it to Copilot's `gpt-5.5` Responses model by default, independently of the interactive Fast/Standard selection, and logs both model IDs when the mapping is used. The review target can be changed interactively without reinstalling or restarting CCDX.
|
|
24
24
|
|
|
25
|
+
### GPT-6 model catalog
|
|
26
|
+
|
|
27
|
+
Codex App requests a versioned, capability-rich model catalog that is different from Copilot's raw model list. CCDX reads the complete catalog bundled with the installed Codex App and preserves every other model entry and capability. It makes `gpt-6-astra` visible only when the live Copilot catalog advertises that exact model as enabled, selectable, OpenAI-owned, and Responses-capable.
|
|
28
|
+
|
|
29
|
+
CCDX 0.7.2 exposes only the verified GPT-6 Standard path and removes its unverified speed-tier metadata. Codex Ultra remains available because the client maps it to the model's supported multi-agent reasoning effort. GPT-5.6 Sol Fast remains unchanged. The bundled catalog is matched to the Codex client version, cached by application binary identity, and reloaded automatically after an App update.
|
|
30
|
+
|
|
25
31
|
## Prerequisites
|
|
26
32
|
|
|
27
33
|
- GitHub Copilot subscription (Individual, Business, or Enterprise)
|
|
28
34
|
- Node.js 22.15 or newer, for built-in Zstandard request decompression
|
|
29
|
-
- Codex
|
|
35
|
+
- Codex App on macOS
|
|
30
36
|
|
|
31
37
|
## Install and start
|
|
32
38
|
|
|
@@ -49,7 +55,7 @@ On a normal launch, CCDX:
|
|
|
49
55
|
2. Reuses a compatible running adapter when available; otherwise it reuses a compatible local Copilot credential or starts GitHub Device Flow when authentication is required.
|
|
50
56
|
3. Refreshes Copilot model metadata and listens on the configured loopback address.
|
|
51
57
|
4. Updates `~/.codex/config.toml` to use the local `/v1` endpoint while preserving unrelated Codex settings.
|
|
52
|
-
5. Attempts to open Codex
|
|
58
|
+
5. Attempts to open Codex App on macOS unless `CCDX_AUTO_LAUNCH` disables it.
|
|
53
59
|
|
|
54
60
|
The `OPENAI_API_KEY=dummy` value written into the Codex shell environment is only a client-side placeholder. It is not a GitHub credential. CCDX exchanges the saved GitHub OAuth credential for short-lived Copilot service tokens internally.
|
|
55
61
|
|
|
@@ -87,6 +93,28 @@ The selector first queries the running adapter, including its last-known-good mo
|
|
|
87
93
|
|
|
88
94
|
The selection is saved in `~/.config/codex-copilot-dx/config.json`, or under `XDG_CONFIG_HOME` when set. Choosing `gpt-5.5` clears the override and restores the package default. A running adapter reads the setting on the next Auto-review request.
|
|
89
95
|
|
|
96
|
+
### Terminal animation
|
|
97
|
+
|
|
98
|
+
Choose the terminal activity animation interactively:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
ccdx animation
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The selector is ordered numerically:
|
|
105
|
+
|
|
106
|
+
1. Comet (default)
|
|
107
|
+
2. Twin
|
|
108
|
+
3. Shuttle
|
|
109
|
+
4. Chase
|
|
110
|
+
5. Mirror
|
|
111
|
+
6. Pulse
|
|
112
|
+
7. Braille
|
|
113
|
+
|
|
114
|
+
Enter the corresponding number to select an animation. The Comet row includes a static preview, and after a selection CCDX plays one cycle of the chosen animation as confirmation when terminal animation is enabled. The setting is saved in `~/.config/codex-copilot-dx/config.json`, or under `XDG_CONFIG_HOME` when set.
|
|
115
|
+
|
|
116
|
+
The selected theme is loaded once when a new adapter starts, so changing it does not restart or alter an adapter that is already running. Stop and start CCDX to apply the new theme. `CCDX_TERMINAL_ANIMATION=0` (also `false`, `no`, or `off`) remains the boolean gate that disables terminal animation regardless of the selected theme.
|
|
117
|
+
|
|
90
118
|
### Update
|
|
91
119
|
|
|
92
120
|
Choose a source interactively:
|
|
@@ -197,7 +225,7 @@ ccdx
|
|
|
197
225
|
| `CCDX_LOG_PATH` | unset | Mirror terminal logs to a file; set to `1` for `~/.local/share/codex-copilot-dx/debug.log` |
|
|
198
226
|
| `CCDX_LOG_LEVEL` | `info` | Set to `debug` for upstream attempts, status codes, retry causes, and timings |
|
|
199
227
|
| `CCDX_LOG_MAX_BYTES` | `16777216` | Rotate the debug log at this size and retain one `.1` backup; set to `0` to disable rotation |
|
|
200
|
-
| `CCDX_TERMINAL_ANIMATION` | auto | Show
|
|
228
|
+
| `CCDX_TERMINAL_ANIMATION` | auto | Show the selected transient animation while an interactive terminal has active requests and no output for 800 ms; set to `0`, `false`, `no`, or `off` to disable |
|
|
201
229
|
| `CCDX_IMG_MAX_DIM` | `2048` | Maximum long edge in pixels for image downscaling |
|
|
202
230
|
| `CCDX_IMG_QUALITY` | `82` | Initial WebP quality for image re-encoding |
|
|
203
231
|
| `CCDX_IMG_MIN_BYTES` | `100000` | In-bounds images smaller than this remain unchanged; oversized images are still downscaled |
|
|
@@ -205,7 +233,7 @@ ccdx
|
|
|
205
233
|
| `CCDX_IMG_MAX_INPUT_PIXELS` | `40000000` | Maximum decoded pixels accepted for one image |
|
|
206
234
|
| `CCDX_IMG_CACHE_MAX_BYTES` | `67108864` | Process-local byte ceiling for cached image transforms; set to `0` to disable the cache |
|
|
207
235
|
| `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
|
|
208
|
-
| `CCDX_AUTO_LAUNCH` | enabled | Set to `0`, `false`, `no`, or `off` to start without opening Codex
|
|
236
|
+
| `CCDX_AUTO_LAUNCH` | enabled | Set to `0`, `false`, `no`, or `off` to start without opening Codex App |
|
|
209
237
|
| `CCDX_GITHUB_TOKEN` | unset | Explicit GitHub Copilot OAuth token to validate and import before Device Flow |
|
|
210
238
|
| `CCDX_GITHUB_TOKEN_PATH` | unset | Explicit file containing a GitHub Copilot OAuth token |
|
|
211
239
|
| `CCDX_GITHUB_TOKEN_PATHS` | unset | Multiple token files separated by the platform path delimiter (`:` on macOS/Linux, `;` on Windows) |
|
|
@@ -255,7 +283,7 @@ For `/v1/responses/compact`, CCDX sends one terminal `compaction_trigger`, valid
|
|
|
255
283
|
|
|
256
284
|
Successful streaming responses are accepted only when the upstream body is SSE and reaches its protocol terminal event. Unexpected EOF becomes a protocol-native stream error, and repeated blank tool-argument deltas are stopped per tool call. Safe quota, retry, model, trace, and upstream request-ID metadata are forwarded; cookies, authorization, body-length, and encoding headers are not.
|
|
257
285
|
|
|
258
|
-
Newer Codex
|
|
286
|
+
Newer Codex App builds can advertise an `image_gen` namespace that already exists upstream. CCDX removes that exact conflicting client tool before forwarding and retries once only when Copilot explicitly reports an image namespace collision. Image input and screenshot optimization remain enabled.
|
|
259
287
|
|
|
260
288
|
## Development
|
|
261
289
|
|
package/bin/cli.mjs
CHANGED
|
@@ -16,11 +16,12 @@ import { cliCommandName, cliHelp, parseAdapterProbeOptions, parseCliArgs, parseR
|
|
|
16
16
|
import { formatAdapterStatus, readAdapterStatus } from "../src/cli-status.mjs";
|
|
17
17
|
import { closeHttpServer } from "../src/shutdown.mjs";
|
|
18
18
|
import { runAutoReviewModelCommand } from "../src/auto-review-model.mjs";
|
|
19
|
-
import { autoReviewModelPreference } from "../src/user-settings.mjs";
|
|
19
|
+
import { autoReviewModelPreference, terminalAnimationPreference } from "../src/user-settings.mjs";
|
|
20
20
|
import { fetchLiveCopilotModels, formatLiveCopilotModels } from "../src/cli-models.mjs";
|
|
21
21
|
import { runAuthCommand } from "../src/cli-auth.mjs";
|
|
22
22
|
import { createProfileRuntime } from "../src/profile-runtime.mjs";
|
|
23
23
|
import { createProfileModelRuntime } from "../src/profile-model-runtime.mjs";
|
|
24
|
+
import { createCodexModelCatalog } from "../src/codex-model-catalog.mjs";
|
|
24
25
|
|
|
25
26
|
const LOCAL_VERSION = localPackageVersion();
|
|
26
27
|
const CLI_NAME = cliCommandName();
|
|
@@ -43,6 +44,16 @@ if (CLI.command === "version") {
|
|
|
43
44
|
console.log(CLI_BANNER);
|
|
44
45
|
process.exit(0);
|
|
45
46
|
}
|
|
47
|
+
if (CLI.command === "animation") {
|
|
48
|
+
try {
|
|
49
|
+
const { runAnimationCommand } = await import("../src/cli-animation.mjs");
|
|
50
|
+
await runAnimationCommand({ commandName: CLI_NAME });
|
|
51
|
+
process.exit(0);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
console.error(status("err", e.message));
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
46
57
|
if (CLI.command === "usage") {
|
|
47
58
|
await printUsageSummary({
|
|
48
59
|
format: CLI.outputFormat || "auto",
|
|
@@ -191,7 +202,7 @@ if (CLI.command === "doctor") {
|
|
|
191
202
|
|
|
192
203
|
console.log(`
|
|
193
204
|
${CLI_BANNER}
|
|
194
|
-
Use Codex
|
|
205
|
+
Use Codex App with GitHub Copilot
|
|
195
206
|
`);
|
|
196
207
|
|
|
197
208
|
async function printUpdateNotice() {
|
|
@@ -231,17 +242,21 @@ try {
|
|
|
231
242
|
}
|
|
232
243
|
|
|
233
244
|
// Start the in-process adapter.
|
|
245
|
+
const terminalAnimationTheme = terminalAnimationPreference().theme;
|
|
246
|
+
const codexModelCatalog = createCodexModelCatalog();
|
|
234
247
|
activeServer = await startAdapter(ADAPTER_PORT, ADAPTER_HOST, {
|
|
235
248
|
autoReviewModelResolver: () => autoReviewModelPreference().model,
|
|
236
249
|
codexClient: profileRuntime.codexClient,
|
|
250
|
+
codexModelCatalog,
|
|
237
251
|
codexModelRegistry: profileModels.codexRegistry,
|
|
238
252
|
showRequestId: CLI.showRequestId,
|
|
239
253
|
upstreamTimeoutMs: RUNTIME.upstreamTimeoutMs,
|
|
240
254
|
streamHandshakeTimeoutMs: RUNTIME.streamHandshakeTimeoutMs,
|
|
241
255
|
streamIdleTimeoutMs: RUNTIME.streamIdleTimeoutMs,
|
|
256
|
+
terminalAnimationTheme,
|
|
242
257
|
});
|
|
243
258
|
|
|
244
|
-
// Point Codex
|
|
259
|
+
// Point Codex App at the adapter.
|
|
245
260
|
ensureCodexConfig(ADAPTER_PORT, { host: ADAPTER_HOST });
|
|
246
261
|
|
|
247
262
|
// Launch Codex when available.
|
|
@@ -258,7 +273,7 @@ try {
|
|
|
258
273
|
}
|
|
259
274
|
|
|
260
275
|
console.log(`
|
|
261
|
-
${status("ok", "Ready, Codex App
|
|
276
|
+
${status("ok", "Ready, Codex App is ready to use")}
|
|
262
277
|
|
|
263
278
|
Adapter: ${adapterBaseUrl(ADAPTER_HOST, ADAPTER_PORT)}
|
|
264
279
|
|
package/package.json
CHANGED
package/src/adapter.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import { defaultCopilotClient } from "./copilot.mjs";
|
|
3
|
+
import { buildCodexModelResponse } from "./codex-model-catalog.mjs";
|
|
3
4
|
import { isValidModelList } from "./model-cache.mjs";
|
|
4
5
|
import {
|
|
5
6
|
ADAPTER_STATUS_PATH,
|
|
@@ -78,13 +79,58 @@ function modelListCanUseLastKnownGood(statusCode) {
|
|
|
78
79
|
return [408, 425, 429].includes(statusCode) || statusCode >= 500;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
function
|
|
82
|
+
function modelClientVersion(reqUrl) {
|
|
83
|
+
try {
|
|
84
|
+
return String(new URL(reqUrl || "/", "http://localhost").searchParams.get("client_version") || "").trim();
|
|
85
|
+
} catch {
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function sendModelList(res, models, {
|
|
91
|
+
codexModelCatalog,
|
|
92
|
+
clientVersion = "",
|
|
93
|
+
headers = {},
|
|
94
|
+
rawBody,
|
|
95
|
+
statusCode = 200,
|
|
96
|
+
} = {}) {
|
|
97
|
+
let body = rawBody || JSON.stringify(models);
|
|
98
|
+
if (clientVersion && typeof codexModelCatalog?.load === "function") {
|
|
99
|
+
let response = null;
|
|
100
|
+
try {
|
|
101
|
+
const codexCatalog = await codexModelCatalog.load({ clientVersion });
|
|
102
|
+
response = buildCodexModelResponse({ copilotModels: models, codexCatalog });
|
|
103
|
+
} catch {}
|
|
104
|
+
if (!response) {
|
|
105
|
+
if (res.destroyed || res.writableEnded) return false;
|
|
106
|
+
res.writeHead(503, {
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
"Retry-After": "5",
|
|
109
|
+
...headers,
|
|
110
|
+
});
|
|
111
|
+
res.end(JSON.stringify({
|
|
112
|
+
error: {
|
|
113
|
+
message: "The Codex App model catalog is temporarily unavailable",
|
|
114
|
+
type: "server_error",
|
|
115
|
+
code: "ccdx_codex_model_catalog_unavailable",
|
|
116
|
+
},
|
|
117
|
+
}));
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
body = JSON.stringify(response);
|
|
121
|
+
}
|
|
122
|
+
if (res.destroyed || res.writableEnded) return false;
|
|
123
|
+
res.writeHead(statusCode, { "Content-Type": "application/json", ...headers });
|
|
124
|
+
res.end(body);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function sendLastKnownGoodModels(res, modelRegistry, options = {}) {
|
|
82
129
|
if (!isValidModelList(modelRegistry?.models) || res.destroyed || res.writableEnded) return false;
|
|
83
|
-
res.
|
|
84
|
-
|
|
85
|
-
"X-CCDX-Model-Source": "last-known-good",
|
|
130
|
+
await sendModelList(res, modelRegistry.models, {
|
|
131
|
+
...options,
|
|
132
|
+
headers: { "X-CCDX-Model-Source": "last-known-good" },
|
|
86
133
|
});
|
|
87
|
-
res.end(JSON.stringify(modelRegistry.models));
|
|
88
134
|
return true;
|
|
89
135
|
}
|
|
90
136
|
|
|
@@ -100,6 +146,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
100
146
|
const responsesFn = options.responsesFn || codexClient.responses;
|
|
101
147
|
const responsesCompactFn = options.responsesCompactFn || codexClient.responsesCompact;
|
|
102
148
|
const listModelsFn = options.listModelsFn || codexClient.listModels;
|
|
149
|
+
const codexModelCatalog = options.codexModelCatalog;
|
|
103
150
|
const getCachedModelEndpointsFn = options.getCachedModelEndpointsFn
|
|
104
151
|
|| codexClient.getCachedModelEndpoints;
|
|
105
152
|
const codexModelRegistry = options.codexModelRegistry || options.modelRegistry;
|
|
@@ -176,10 +223,12 @@ export function createAdapterHandler(options = {}) {
|
|
|
176
223
|
}
|
|
177
224
|
|
|
178
225
|
if (req.method === "GET" && pathname === "/v1/models") {
|
|
226
|
+
const clientVersion = modelClientVersion(req.url);
|
|
179
227
|
const abort = createRequestAbort(req, res);
|
|
180
228
|
abort.setTimeout(upstreamTimeoutMs);
|
|
181
229
|
listModelsFn({ signal: abort.signal })
|
|
182
|
-
.then(({ status, body }) => {
|
|
230
|
+
.then(async ({ status, body }) => {
|
|
231
|
+
abort.clearTimeout();
|
|
183
232
|
if (status >= 200 && status < 300) {
|
|
184
233
|
let models;
|
|
185
234
|
try { models = JSON.parse(body); } catch {}
|
|
@@ -187,21 +236,26 @@ export function createAdapterHandler(options = {}) {
|
|
|
187
236
|
if (codexModelRegistry) {
|
|
188
237
|
codexModelRegistry.models = models;
|
|
189
238
|
}
|
|
190
|
-
res
|
|
191
|
-
|
|
239
|
+
await sendModelList(res, models, {
|
|
240
|
+
codexModelCatalog,
|
|
241
|
+
clientVersion,
|
|
242
|
+
rawBody: body,
|
|
243
|
+
statusCode: status,
|
|
244
|
+
});
|
|
192
245
|
return;
|
|
193
246
|
}
|
|
194
|
-
if (sendLastKnownGoodModels(res, codexModelRegistry)) return;
|
|
247
|
+
if (await sendLastKnownGoodModels(res, codexModelRegistry, { codexModelCatalog, clientVersion })) return;
|
|
195
248
|
sendJsonError(res, httpError("Copilot models response contained no valid models", 502), 502);
|
|
196
249
|
return;
|
|
197
250
|
}
|
|
198
|
-
if (modelListCanUseLastKnownGood(status)
|
|
251
|
+
if (modelListCanUseLastKnownGood(status)
|
|
252
|
+
&& await sendLastKnownGoodModels(res, codexModelRegistry, { codexModelCatalog, clientVersion })) return;
|
|
199
253
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
200
254
|
res.end(body);
|
|
201
255
|
})
|
|
202
|
-
.catch((e) => {
|
|
256
|
+
.catch(async (e) => {
|
|
203
257
|
logRequestFailure("Models", e, abort);
|
|
204
|
-
if (sendLastKnownGoodModels(res, codexModelRegistry)) return;
|
|
258
|
+
if (await sendLastKnownGoodModels(res, codexModelRegistry, { codexModelCatalog, clientVersion })) return;
|
|
205
259
|
if (!res.destroyed && !res.writableEnded) {
|
|
206
260
|
if (!res.headersSent) res.writeHead(e?.statusCode || 502);
|
|
207
261
|
res.end(JSON.stringify({ error: e.message }));
|
|
@@ -311,7 +365,9 @@ export function createAdapterHandler(options = {}) {
|
|
|
311
365
|
|
|
312
366
|
export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
|
|
313
367
|
const ownsTerminalActivity = !Object.hasOwn(options, "terminalActivity");
|
|
314
|
-
const terminalActivity = ownsTerminalActivity
|
|
368
|
+
const terminalActivity = ownsTerminalActivity
|
|
369
|
+
? createTerminalActivityIndicator({ theme: options.terminalAnimationTheme })
|
|
370
|
+
: options.terminalActivity;
|
|
315
371
|
const handler = createAdapterHandler({ ...options, terminalActivity });
|
|
316
372
|
const server = http.createServer(handler);
|
|
317
373
|
const cleanupTerminalActivity = () => {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_TERMINAL_ANIMATION_THEME,
|
|
5
|
+
playTerminalAnimationPreview,
|
|
6
|
+
renderTerminalAnimationFrame,
|
|
7
|
+
TERMINAL_ANIMATION_THEMES,
|
|
8
|
+
} from "./terminal-animation.mjs";
|
|
9
|
+
import {
|
|
10
|
+
readUserSettings,
|
|
11
|
+
terminalAnimationPreference,
|
|
12
|
+
writeTerminalAnimationTheme,
|
|
13
|
+
} from "./user-settings.mjs";
|
|
14
|
+
|
|
15
|
+
const DISABLED_ENV_VALUES = new Set(["0", "false", "no", "off"]);
|
|
16
|
+
|
|
17
|
+
function markers(theme, currentTheme) {
|
|
18
|
+
const values = [];
|
|
19
|
+
if (theme.id === DEFAULT_TERMINAL_ANIMATION_THEME) values.push("default");
|
|
20
|
+
if (theme.id === currentTheme) values.push("current");
|
|
21
|
+
return values.length ? ` [${values.join(", ")}]` : "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function disabledEnvironmentValue(env) {
|
|
25
|
+
const value = String(env.CCDX_TERMINAL_ANIMATION || "").trim();
|
|
26
|
+
return DISABLED_ENV_VALUES.has(value.toLowerCase()) ? value : "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runAnimationCommand({
|
|
30
|
+
commandName = "ccdx",
|
|
31
|
+
env = process.env,
|
|
32
|
+
home = os.homedir(),
|
|
33
|
+
input = process.stdin,
|
|
34
|
+
output = process.stdout,
|
|
35
|
+
prompt,
|
|
36
|
+
preview = playTerminalAnimationPreview,
|
|
37
|
+
} = {}) {
|
|
38
|
+
if (!prompt && (!input.isTTY || !output.isTTY)) {
|
|
39
|
+
throw new Error(`${commandName} animation requires an interactive terminal`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
readUserSettings({ env, home, strict: true });
|
|
43
|
+
const current = terminalAnimationPreference({ env, home });
|
|
44
|
+
const currentIndex = TERMINAL_ANIMATION_THEMES.findIndex(({ id }) => id === current.theme);
|
|
45
|
+
|
|
46
|
+
output.write(`${commandName} animation\n`);
|
|
47
|
+
output.write(`Current: ${TERMINAL_ANIMATION_THEMES[currentIndex].label} (${current.source})\n\n`);
|
|
48
|
+
TERMINAL_ANIMATION_THEMES.forEach((theme, index) => {
|
|
49
|
+
const staticPreview = theme.id === DEFAULT_TERMINAL_ANIMATION_THEME
|
|
50
|
+
? ` ${renderTerminalAnimationFrame(theme.id, 10)}`
|
|
51
|
+
: "";
|
|
52
|
+
output.write(` ${index + 1}. ${theme.label}${markers(theme, current.theme)}${staticPreview}\n`);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
let readline;
|
|
56
|
+
const ask = prompt || (async (question) => {
|
|
57
|
+
readline ||= createInterface({ input, output });
|
|
58
|
+
return readline.question(question);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
let selectedTheme = current.theme;
|
|
62
|
+
let keepCurrent = false;
|
|
63
|
+
try {
|
|
64
|
+
while (true) {
|
|
65
|
+
const answer = String(await ask(`Select [${currentIndex + 1}], or q to cancel: `) || "").trim();
|
|
66
|
+
if (answer.toLowerCase() === "q") {
|
|
67
|
+
output.write("No changes made.\n");
|
|
68
|
+
return { changed: false, cancelled: true, theme: current.theme };
|
|
69
|
+
}
|
|
70
|
+
if (answer === "") {
|
|
71
|
+
keepCurrent = true;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
const selectedIndex = Number(answer) - 1;
|
|
75
|
+
if (Number.isInteger(selectedIndex)
|
|
76
|
+
&& selectedIndex >= 0
|
|
77
|
+
&& selectedIndex < TERMINAL_ANIMATION_THEMES.length) {
|
|
78
|
+
selectedTheme = TERMINAL_ANIMATION_THEMES[selectedIndex].id;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
output.write(`Enter a number from 1 to ${TERMINAL_ANIMATION_THEMES.length}, or q to cancel.\n`);
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
readline?.close();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result = keepCurrent
|
|
88
|
+
? { changed: false, theme: current.theme }
|
|
89
|
+
: writeTerminalAnimationTheme(selectedTheme, { env, home });
|
|
90
|
+
const disabledValue = disabledEnvironmentValue(env);
|
|
91
|
+
if (!disabledValue) {
|
|
92
|
+
try {
|
|
93
|
+
await preview(selectedTheme, { output });
|
|
94
|
+
} catch {
|
|
95
|
+
output.write("[WARN] Preview unavailable; the terminal animation setting was still applied.\n");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const selected = TERMINAL_ANIMATION_THEMES.find(({ id }) => id === selectedTheme);
|
|
100
|
+
output.write(`${result.changed ? "Saved" : "Kept"} terminal animation: ${selected.label}\n`);
|
|
101
|
+
if (disabledValue) {
|
|
102
|
+
output.write(`CCDX_TERMINAL_ANIMATION=${disabledValue} remains effective until it is unset; terminal animation stays disabled.\n`);
|
|
103
|
+
output.write("The saved animation will be used on the next ccdx start after the override is unset.\n");
|
|
104
|
+
} else {
|
|
105
|
+
output.write("The next ccdx start will use this animation.\n");
|
|
106
|
+
}
|
|
107
|
+
return { ...result, cancelled: false, theme: selectedTheme };
|
|
108
|
+
}
|
package/src/cli-options.mjs
CHANGED
|
@@ -19,6 +19,7 @@ const HELP_TOPICS = new Set([
|
|
|
19
19
|
"status",
|
|
20
20
|
"models",
|
|
21
21
|
"usage",
|
|
22
|
+
"animation",
|
|
22
23
|
"auto-review-model",
|
|
23
24
|
"update",
|
|
24
25
|
"version",
|
|
@@ -189,6 +190,10 @@ export function parseCliArgs(args = []) {
|
|
|
189
190
|
}
|
|
190
191
|
throw new Error(`Unknown auth action: ${action}`);
|
|
191
192
|
}
|
|
193
|
+
if (command === "animation") {
|
|
194
|
+
if (rest.length) unexpectedArgs(rest);
|
|
195
|
+
return baseCommand("animation");
|
|
196
|
+
}
|
|
192
197
|
if (command === "auto-review-model") {
|
|
193
198
|
if (rest.length) unexpectedArgs(rest);
|
|
194
199
|
return baseCommand("auto-review-model");
|
|
@@ -268,13 +273,14 @@ export function cliCommandName() {
|
|
|
268
273
|
|
|
269
274
|
function topicHelp(name, topic) {
|
|
270
275
|
const sections = {
|
|
271
|
-
start: `Usage:\n ${name} [start] [--show-request-id]\n\nStarts or reuses the local adapter, updates Codex configuration, and attempts to open Codex
|
|
276
|
+
start: `Usage:\n ${name} [start] [--show-request-id]\n\nStarts or reuses the local adapter, updates Codex configuration, and attempts to open Codex App when auto-launch is enabled and supported.`,
|
|
272
277
|
auth: `Usage:\n ${name} auth status [--online] [--format table|plain]\n\nShows the saved GitHub Copilot account without exposing credentials. --online also verifies its entitlement and model catalog.`,
|
|
273
278
|
"auth status": `Usage:\n ${name} auth status [--online] [--format table|plain]\n\nShows the saved GitHub Copilot account without exposing credentials. --online also verifies its entitlement and model catalog. Interactive terminals use a table by default.`,
|
|
274
279
|
doctor: `Usage:\n ${name} doctor [--online] [--compat]\n\nChecks the local credential, Codex configuration, and adapter. --online validates account entitlement; --compat sends minimal inference requests and consumes a small amount of Copilot usage.`,
|
|
275
280
|
status: `Usage:\n ${name} status\n\nShows bounded runtime, routing, performance, queue, and cache metrics from a running adapter.`,
|
|
276
281
|
models: `Usage:\n ${name} models [--format table|plain]\n\nPerforms a fresh, read-only Copilot model-directory lookup for the saved account. Interactive terminals use a table by default.`,
|
|
277
282
|
usage: `Usage:\n ${name} usage [--format table|plain]\n\nSummarizes local token usage metadata without reading prompt or completion content. Interactive terminals use a table by default.`,
|
|
283
|
+
animation: `Usage:\n ${name} animation\n\nInteractively selects the terminal activity animation used the next time the adapter starts.`,
|
|
278
284
|
"auto-review-model": `Usage:\n ${name} auto-review-model\n\nInteractively selects an enabled Responses model for Codex Auto-review.`,
|
|
279
285
|
update: `Usage:\n ${name} update [npm|github]\n\nUpdates the global package from the configured npm registry or GitHub main. With no source, an interactive terminal prompts for one.`,
|
|
280
286
|
version: `Usage:\n ${name} --version`,
|
|
@@ -293,6 +299,7 @@ export function cliHelp(commandName = "ccdx", topic = "") {
|
|
|
293
299
|
${name} status
|
|
294
300
|
${name} models [--format table|plain]
|
|
295
301
|
${name} usage [--format table|plain]
|
|
302
|
+
${name} animation
|
|
296
303
|
${name} auto-review-model
|
|
297
304
|
${name} update [npm|github]
|
|
298
305
|
${name} --version
|
|
@@ -305,6 +312,7 @@ Commands:
|
|
|
305
312
|
status Show runtime routing, performance, queue, and cache health
|
|
306
313
|
models Query a saved account's live Copilot model catalog
|
|
307
314
|
usage Summarize locally recorded token usage
|
|
315
|
+
animation Select the terminal activity animation
|
|
308
316
|
auto-review-model Select the Codex Auto-review Responses model
|
|
309
317
|
update Update the global package from npm or GitHub
|
|
310
318
|
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { CODEX_GPT6_MODEL } from "./models.mjs";
|
|
6
|
+
|
|
7
|
+
export { CODEX_GPT6_MODEL };
|
|
8
|
+
|
|
9
|
+
export const CODEX_APP_BINARY_PATHS = [
|
|
10
|
+
"/Applications/Codex.app/Contents/Resources/codex",
|
|
11
|
+
"/Applications/ChatGPT.app/Contents/Resources/codex",
|
|
12
|
+
path.join(os.homedir(), "Applications", "Codex.app", "Contents", "Resources", "codex"),
|
|
13
|
+
path.join(os.homedir(), "Applications", "ChatGPT.app", "Contents", "Resources", "codex"),
|
|
14
|
+
];
|
|
15
|
+
export const CODEX_MODEL_CATALOG_TIMEOUT_MS = 5_000;
|
|
16
|
+
export const CODEX_MODEL_CATALOG_MAX_BUFFER = 8 * 1024 * 1024;
|
|
17
|
+
export const CODEX_MODEL_CATALOG_FAILURE_BACKOFF_MS = 5_000;
|
|
18
|
+
|
|
19
|
+
function objectRecord(value) {
|
|
20
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function codexCatalogIsValid(catalog) {
|
|
24
|
+
if (!objectRecord(catalog) || !Array.isArray(catalog.models) || catalog.models.length === 0) return false;
|
|
25
|
+
const slugs = new Set();
|
|
26
|
+
for (const model of catalog.models) {
|
|
27
|
+
const slug = String(model?.slug || "").trim();
|
|
28
|
+
if (!objectRecord(model) || !slug || slugs.has(slug)) return false;
|
|
29
|
+
slugs.add(slug);
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function copilotModelData(models) {
|
|
35
|
+
if (!objectRecord(models) || !Array.isArray(models.data) || models.data.length === 0) return null;
|
|
36
|
+
return models.data;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function supportsResponses(model) {
|
|
40
|
+
const endpoints = Array.isArray(model?.supported_endpoints) ? model.supported_endpoints : [];
|
|
41
|
+
return endpoints.includes("/responses") || endpoints.includes("/v1/responses");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isEligibleOpenAIModel(model, id) {
|
|
45
|
+
const vendor = String(model?.vendor || model?.owned_by || "").trim().toLowerCase();
|
|
46
|
+
return String(model?.id || "").trim() === id
|
|
47
|
+
&& vendor === "openai"
|
|
48
|
+
&& String(model?.policy?.state || "").trim().toLowerCase() === "enabled"
|
|
49
|
+
&& model?.model_picker_enabled === true
|
|
50
|
+
&& supportsResponses(model);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isEligibleCopilotGpt6(copilotModels) {
|
|
54
|
+
const data = copilotModelData(copilotModels);
|
|
55
|
+
if (!data) return false;
|
|
56
|
+
const matches = data.filter((model) => String(model?.id || "").trim() === CODEX_GPT6_MODEL);
|
|
57
|
+
return matches.length === 1 && isEligibleOpenAIModel(matches[0], CODEX_GPT6_MODEL);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildCodexModelResponse({ copilotModels, codexCatalog } = {}) {
|
|
61
|
+
if (!copilotModelData(copilotModels) || !codexCatalogIsValid(codexCatalog)) return null;
|
|
62
|
+
|
|
63
|
+
const exposeGpt6 = isEligibleCopilotGpt6(copilotModels);
|
|
64
|
+
const models = codexCatalog.models.map((model) => {
|
|
65
|
+
if (model.slug !== CODEX_GPT6_MODEL) return model;
|
|
66
|
+
if (!exposeGpt6) return { ...model, visibility: "hide" };
|
|
67
|
+
const patched = {
|
|
68
|
+
...model,
|
|
69
|
+
visibility: "list",
|
|
70
|
+
additional_speed_tiers: [],
|
|
71
|
+
service_tiers: [],
|
|
72
|
+
};
|
|
73
|
+
delete patched.default_service_tier;
|
|
74
|
+
return patched;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return { ...copilotModels, object: copilotModels.object || "list", models };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function binaryCacheKey(binaryPath, stats) {
|
|
81
|
+
return [
|
|
82
|
+
binaryPath,
|
|
83
|
+
stats.dev,
|
|
84
|
+
stats.ino,
|
|
85
|
+
stats.size,
|
|
86
|
+
stats.mtimeMs,
|
|
87
|
+
stats.ctimeMs,
|
|
88
|
+
].map((part) => String(part ?? "")).join(":");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function installedCodexBinaries(binaryPaths, statFn) {
|
|
92
|
+
const installed = [];
|
|
93
|
+
for (const binaryPath of binaryPaths) {
|
|
94
|
+
let stats;
|
|
95
|
+
try {
|
|
96
|
+
stats = await statFn(binaryPath);
|
|
97
|
+
} catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (stats && typeof stats.isFile === "function" && stats.isFile()) {
|
|
101
|
+
installed.push({ binaryPath, key: binaryCacheKey(binaryPath, stats) });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return installed;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function runCodexCommand(execFileFn, binaryPath, args, options) {
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
try {
|
|
110
|
+
execFileFn(binaryPath, args, options, (error, stdout) => {
|
|
111
|
+
if (error) reject(error);
|
|
112
|
+
else resolve(stdout);
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
reject(error);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parsedCodexVersion(stdout) {
|
|
121
|
+
const match = /^(?:codex-cli|codex)\s+(\S+)$/.exec(String(stdout).trim());
|
|
122
|
+
return match?.[1] || null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function wholeSemver(value) {
|
|
126
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(String(value || "").trim());
|
|
127
|
+
if (!match) return null;
|
|
128
|
+
if (match[4]?.split(".").some((part) => /^\d+$/.test(part) && part.length > 1 && part.startsWith("0"))) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
return `${match[1]}.${match[2]}.${match[3]}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizedBackoff(value) {
|
|
135
|
+
return Number.isFinite(value) && value >= 0 ? value : CODEX_MODEL_CATALOG_FAILURE_BACKOFF_MS;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function createCodexModelCatalog({
|
|
139
|
+
binaryPath,
|
|
140
|
+
execFileFn = execFile,
|
|
141
|
+
statFn = fs.promises.stat,
|
|
142
|
+
timeoutMs = CODEX_MODEL_CATALOG_TIMEOUT_MS,
|
|
143
|
+
maxBuffer = CODEX_MODEL_CATALOG_MAX_BUFFER,
|
|
144
|
+
failureBackoffMs = CODEX_MODEL_CATALOG_FAILURE_BACKOFF_MS,
|
|
145
|
+
now = Date.now,
|
|
146
|
+
} = {}) {
|
|
147
|
+
const binaryPaths = binaryPath === undefined
|
|
148
|
+
? CODEX_APP_BINARY_PATHS
|
|
149
|
+
: [binaryPath].filter((candidate) => typeof candidate === "string" && path.isAbsolute(candidate));
|
|
150
|
+
const commandOptions = {
|
|
151
|
+
encoding: "utf8",
|
|
152
|
+
maxBuffer,
|
|
153
|
+
timeout: timeoutMs,
|
|
154
|
+
windowsHide: true,
|
|
155
|
+
};
|
|
156
|
+
const backoffMs = normalizedBackoff(failureBackoffMs);
|
|
157
|
+
const versionCache = new Map();
|
|
158
|
+
const versionFailures = new Map();
|
|
159
|
+
const versionPending = new Map();
|
|
160
|
+
const catalogCache = new Map();
|
|
161
|
+
const catalogFailures = new Map();
|
|
162
|
+
const catalogPending = new Map();
|
|
163
|
+
let activeBinaryKeys = new Set();
|
|
164
|
+
|
|
165
|
+
function retainCurrentEntries(map) {
|
|
166
|
+
for (const key of map.keys()) {
|
|
167
|
+
if (!activeBinaryKeys.has(key)) map.delete(key);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function probeVersion(candidate) {
|
|
172
|
+
if (versionCache.has(candidate.key)) return versionCache.get(candidate.key);
|
|
173
|
+
const failure = versionFailures.get(candidate.key);
|
|
174
|
+
if (failure && now() < failure.retryAt) return null;
|
|
175
|
+
if (versionPending.has(candidate.key)) return versionPending.get(candidate.key);
|
|
176
|
+
|
|
177
|
+
const promise = (async () => {
|
|
178
|
+
try {
|
|
179
|
+
const stdout = await runCodexCommand(execFileFn, candidate.binaryPath, ["--version"], commandOptions);
|
|
180
|
+
const version = wholeSemver(parsedCodexVersion(stdout));
|
|
181
|
+
if (!version) throw new Error("Invalid Codex version output");
|
|
182
|
+
if (activeBinaryKeys.has(candidate.key)) {
|
|
183
|
+
versionCache.set(candidate.key, version);
|
|
184
|
+
versionFailures.delete(candidate.key);
|
|
185
|
+
}
|
|
186
|
+
return version;
|
|
187
|
+
} catch {
|
|
188
|
+
if (activeBinaryKeys.has(candidate.key)) {
|
|
189
|
+
versionFailures.set(candidate.key, { retryAt: now() + backoffMs });
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
})();
|
|
194
|
+
versionPending.set(candidate.key, promise);
|
|
195
|
+
try {
|
|
196
|
+
return await promise;
|
|
197
|
+
} finally {
|
|
198
|
+
if (versionPending.get(candidate.key) === promise) versionPending.delete(candidate.key);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function selectBinary(clientVersion) {
|
|
203
|
+
const installed = await installedCodexBinaries(binaryPaths, statFn);
|
|
204
|
+
activeBinaryKeys = new Set(installed.map((candidate) => candidate.key));
|
|
205
|
+
for (const map of [versionCache, versionFailures, catalogCache, catalogFailures]) {
|
|
206
|
+
retainCurrentEntries(map);
|
|
207
|
+
}
|
|
208
|
+
if (installed.length === 0) return null;
|
|
209
|
+
const rawVersion = String(clientVersion || "").trim();
|
|
210
|
+
if (!rawVersion) return installed[0];
|
|
211
|
+
const expectedVersion = wholeSemver(rawVersion);
|
|
212
|
+
if (!expectedVersion) return null;
|
|
213
|
+
for (const candidate of installed) {
|
|
214
|
+
if (await probeVersion(candidate) === expectedVersion) return candidate;
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function loadCatalog(candidate) {
|
|
220
|
+
if (catalogCache.has(candidate.key)) return catalogCache.get(candidate.key);
|
|
221
|
+
const failure = catalogFailures.get(candidate.key);
|
|
222
|
+
if (failure && now() < failure.retryAt) return null;
|
|
223
|
+
if (catalogPending.has(candidate.key)) return catalogPending.get(candidate.key);
|
|
224
|
+
|
|
225
|
+
const promise = (async () => {
|
|
226
|
+
try {
|
|
227
|
+
const stdout = await runCodexCommand(
|
|
228
|
+
execFileFn,
|
|
229
|
+
candidate.binaryPath,
|
|
230
|
+
["debug", "models", "--bundled"],
|
|
231
|
+
commandOptions,
|
|
232
|
+
);
|
|
233
|
+
const catalog = JSON.parse(String(stdout));
|
|
234
|
+
if (!codexCatalogIsValid(catalog)) throw new Error("Invalid Codex model catalog");
|
|
235
|
+
if (activeBinaryKeys.has(candidate.key)) {
|
|
236
|
+
catalogCache.set(candidate.key, catalog);
|
|
237
|
+
catalogFailures.delete(candidate.key);
|
|
238
|
+
}
|
|
239
|
+
return catalog;
|
|
240
|
+
} catch {
|
|
241
|
+
if (activeBinaryKeys.has(candidate.key)) {
|
|
242
|
+
catalogFailures.set(candidate.key, { retryAt: now() + backoffMs });
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
})();
|
|
247
|
+
catalogPending.set(candidate.key, promise);
|
|
248
|
+
try {
|
|
249
|
+
return await promise;
|
|
250
|
+
} finally {
|
|
251
|
+
if (catalogPending.get(candidate.key) === promise) catalogPending.delete(candidate.key);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function load({ clientVersion = "" } = {}) {
|
|
256
|
+
const candidate = await selectBinary(clientVersion);
|
|
257
|
+
return candidate ? loadCatalog(candidate) : null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { load };
|
|
261
|
+
}
|
package/src/launcher.mjs
CHANGED
|
@@ -3,8 +3,7 @@ import { status } from "./status.mjs";
|
|
|
3
3
|
|
|
4
4
|
export { ensureAuth } from "./auth.mjs";
|
|
5
5
|
|
|
6
|
-
// Prefer the Codex bundle identifier, then fall back to
|
|
7
|
-
// This supports both Codex.app and ChatGPT.app regardless of installation path.
|
|
6
|
+
// Prefer the Codex bundle identifier, then fall back to its known app paths.
|
|
8
7
|
const CODEX_BUNDLE_ID = "com.openai.codex";
|
|
9
8
|
const APP_PATH_CANDIDATES = [
|
|
10
9
|
"/Applications/Codex.app",
|
|
@@ -40,12 +39,12 @@ export function launchAttempts() {
|
|
|
40
39
|
export async function openCodex({ env = process.env, spawnImpl = spawn, platform = process.platform } = {}) {
|
|
41
40
|
if (platform !== "darwin") {
|
|
42
41
|
console.log(status("warn", "Auto-launch is only supported on macOS"));
|
|
43
|
-
console.log(status("info", "Open Codex
|
|
42
|
+
console.log(status("info", "Open Codex App manually; it will connect to the adapter."));
|
|
44
43
|
return false;
|
|
45
44
|
}
|
|
46
45
|
|
|
47
46
|
if (!autoLaunchEnabled(env)) {
|
|
48
|
-
console.log(status("info", "Auto-launch disabled (CCDX_AUTO_LAUNCH=0); open Codex
|
|
47
|
+
console.log(status("info", "Auto-launch disabled (CCDX_AUTO_LAUNCH=0); open Codex App manually."));
|
|
49
48
|
return false;
|
|
50
49
|
}
|
|
51
50
|
|
|
@@ -57,7 +56,7 @@ export async function openCodex({ env = process.env, spawnImpl = spawn, platform
|
|
|
57
56
|
}
|
|
58
57
|
|
|
59
58
|
console.log(status("warn", "Could not launch the Codex app (checked com.openai.codex, Codex.app, ChatGPT.app)"));
|
|
60
|
-
console.log(status("info", "Open Codex
|
|
59
|
+
console.log(status("info", "Open Codex App manually; it will connect to the adapter."));
|
|
61
60
|
console.log(status("info", "Download it from https://openai.com/codex"));
|
|
62
61
|
return false;
|
|
63
62
|
}
|
package/src/models.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const CODEX_AUTO_REVIEW_MODEL = "codex-auto-review";
|
|
2
2
|
export const DEFAULT_CODEX_AUTO_REVIEW_MODEL = "gpt-5.5";
|
|
3
|
+
export const CODEX_GPT6_MODEL = "gpt-6-astra";
|
|
3
4
|
|
|
4
5
|
function hasOpenAIEndpoint(model) {
|
|
5
6
|
const endpoints = Array.isArray(model?.supported_endpoints) ? model.supported_endpoints : [];
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
writeOrDrain,
|
|
13
13
|
} from "./http-transport.mjs";
|
|
14
14
|
import {
|
|
15
|
+
CODEX_GPT6_MODEL,
|
|
15
16
|
CODEX_AUTO_REVIEW_MODEL,
|
|
16
17
|
modelIsResponsesOnly,
|
|
17
18
|
modelSupportsChatCompletions,
|
|
@@ -87,6 +88,14 @@ function resolveRequestModel(model, openAIModelEnv, autoReviewModelResolver) {
|
|
|
87
88
|
return resolveOpenAIModel(model, openAIModelEnv, options);
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
function stripUnsupportedGpt6ServiceTier(body, requestedModel, upstreamModel) {
|
|
92
|
+
if (requestedModel === CODEX_GPT6_MODEL
|
|
93
|
+
&& upstreamModel === requestedModel
|
|
94
|
+
&& body.service_tier === "priority") {
|
|
95
|
+
delete body.service_tier;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
90
99
|
export function createResponsesHandler(options) {
|
|
91
100
|
const {
|
|
92
101
|
acquireRequest,
|
|
@@ -216,6 +225,7 @@ export function createResponsesHandler(options) {
|
|
|
216
225
|
upstreamModel = priorityTierModel;
|
|
217
226
|
delete prepared.body.service_tier;
|
|
218
227
|
}
|
|
228
|
+
stripUnsupportedGpt6ServiceTier(prepared.body, requestedModel, upstreamModel);
|
|
219
229
|
if (requestedModel === CODEX_AUTO_REVIEW_MODEL) delete prepared.body.service_tier;
|
|
220
230
|
if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
|
|
221
231
|
const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
|
|
@@ -465,6 +475,7 @@ export function createResponsesCompactHandler(options) {
|
|
|
465
475
|
upstreamModel = priorityTierModel;
|
|
466
476
|
delete prepared.body.service_tier;
|
|
467
477
|
}
|
|
478
|
+
stripUnsupportedGpt6ServiceTier(prepared.body, requestedModel, upstreamModel);
|
|
468
479
|
if (requestedModel === CODEX_AUTO_REVIEW_MODEL) delete prepared.body.service_tier;
|
|
469
480
|
if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
|
|
470
481
|
const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
|
|
@@ -1,14 +1,21 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_TERMINAL_ANIMATION_THEME,
|
|
3
|
+
TERMINAL_ANIMATION_THEMES,
|
|
4
|
+
TERMINAL_ANIMATION_TRACK_WIDTH,
|
|
5
|
+
getTerminalAnimationCycleLength,
|
|
6
|
+
getTerminalAnimationFrameDelay,
|
|
7
|
+
isTerminalAnimationTheme,
|
|
8
|
+
renderTerminalAnimationFrame,
|
|
9
|
+
} from "./terminal-animation.mjs";
|
|
10
|
+
|
|
11
|
+
export { renderCometFrame } from "./terminal-animation.mjs";
|
|
12
|
+
|
|
1
13
|
const ESC = "\u001b";
|
|
2
14
|
const ERASE_LINE = `\r${ESC}[2K`;
|
|
3
15
|
const HIDE_CURSOR = `${ESC}[?25l`;
|
|
4
16
|
const SHOW_CURSOR = `${ESC}[?25h`;
|
|
5
17
|
|
|
6
|
-
const TRACK_WIDTH = 20;
|
|
7
|
-
const TAIL_LENGTH = 7;
|
|
8
18
|
const IDLE_DELAY_MS = 800;
|
|
9
|
-
const FRAME_DELAY_MS = 45;
|
|
10
|
-
const LOOP_PAUSE_MS = 200;
|
|
11
|
-
const TAIL_COLORS = [159, 123, 87, 51, 45, 39, 33];
|
|
12
19
|
|
|
13
20
|
function envFlagEnabled(value) {
|
|
14
21
|
const normalized = String(value || "").trim().toLowerCase();
|
|
@@ -21,22 +28,7 @@ function terminalActivityEnabled({ env, output, errorOutput }) {
|
|
|
21
28
|
if (envFlagEnabled(env.CI) || String(env.TERM || "").toLowerCase() === "dumb") return false;
|
|
22
29
|
if (output?.isTTY !== true || errorOutput?.isTTY !== true) return false;
|
|
23
30
|
const columns = Number(output.columns);
|
|
24
|
-
return !Number.isFinite(columns) || columns <= 0 || columns >=
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function renderCometFrame(position) {
|
|
28
|
-
let track = "";
|
|
29
|
-
for (let column = 0; column < TRACK_WIDTH; column += 1) {
|
|
30
|
-
const distance = position - column;
|
|
31
|
-
if (distance === 0) {
|
|
32
|
-
track += `${ESC}[97m:${ESC}[0m`;
|
|
33
|
-
} else if (distance > 0 && distance <= TAIL_LENGTH) {
|
|
34
|
-
track += `${ESC}[38;5;${TAIL_COLORS[distance - 1]}m:${ESC}[0m`;
|
|
35
|
-
} else {
|
|
36
|
-
track += " ";
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return `[${track}]`;
|
|
31
|
+
return !Number.isFinite(columns) || columns <= 0 || columns >= TERMINAL_ANIMATION_TRACK_WIDTH + 2;
|
|
40
32
|
}
|
|
41
33
|
|
|
42
34
|
function disabledIndicator() {
|
|
@@ -53,8 +45,13 @@ export function createTerminalActivityIndicator({
|
|
|
53
45
|
errorOutput = process.stderr,
|
|
54
46
|
consoleObj = console,
|
|
55
47
|
timers = { setTimeout, clearTimeout },
|
|
48
|
+
theme = DEFAULT_TERMINAL_ANIMATION_THEME,
|
|
56
49
|
} = {}) {
|
|
57
50
|
if (!terminalActivityEnabled({ env, output, errorOutput })) return disabledIndicator();
|
|
51
|
+
if (!isTerminalAnimationTheme(theme)) throw new TypeError(`Unknown terminal animation theme: ${String(theme)}`);
|
|
52
|
+
|
|
53
|
+
const themeMetadata = TERMINAL_ANIMATION_THEMES.find(({ id }) => id === theme);
|
|
54
|
+
const cycleLength = getTerminalAnimationCycleLength(theme);
|
|
58
55
|
|
|
59
56
|
const methods = ["log", "warn", "error", "debug"];
|
|
60
57
|
const originals = Object.fromEntries(methods.map((method) => [method, consoleObj[method]]));
|
|
@@ -81,9 +78,12 @@ export function createTerminalActivityIndicator({
|
|
|
81
78
|
clearTimer(frameTimer);
|
|
82
79
|
frameTimer = null;
|
|
83
80
|
framePosition = 0;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
81
|
+
try {
|
|
82
|
+
if (cursorHidden || frameVisible) output.write(`${ERASE_LINE}${SHOW_CURSOR}`);
|
|
83
|
+
} finally {
|
|
84
|
+
cursorHidden = false;
|
|
85
|
+
frameVisible = false;
|
|
86
|
+
}
|
|
87
87
|
};
|
|
88
88
|
|
|
89
89
|
const cancelPendingOutput = () => {
|
|
@@ -98,20 +98,23 @@ export function createTerminalActivityIndicator({
|
|
|
98
98
|
clearAnimation();
|
|
99
99
|
return;
|
|
100
100
|
}
|
|
101
|
-
if (framePosition >=
|
|
102
|
-
output.write(ERASE_LINE);
|
|
103
|
-
frameVisible = false;
|
|
101
|
+
if (framePosition >= cycleLength) {
|
|
104
102
|
framePosition = 0;
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
if (themeMetadata.loopPauseMs > 0) {
|
|
104
|
+
output.write(ERASE_LINE);
|
|
105
|
+
frameVisible = false;
|
|
106
|
+
frameTimer = schedule(drawNextFrame, themeMetadata.loopPauseMs);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
const frameIndex = themeMetadata.startFrame + framePosition;
|
|
109
112
|
const prefix = cursorHidden ? "" : HIDE_CURSOR;
|
|
110
113
|
cursorHidden = true;
|
|
111
114
|
frameVisible = true;
|
|
112
|
-
output.write(`${prefix}${ERASE_LINE}${
|
|
115
|
+
output.write(`${prefix}${ERASE_LINE}${renderTerminalAnimationFrame(theme, frameIndex)}`);
|
|
113
116
|
framePosition += 1;
|
|
114
|
-
frameTimer = schedule(drawNextFrame,
|
|
117
|
+
frameTimer = schedule(drawNextFrame, getTerminalAnimationFrameDelay(theme, frameIndex));
|
|
115
118
|
};
|
|
116
119
|
|
|
117
120
|
const scheduleAfterIdle = () => {
|
|
@@ -161,9 +164,12 @@ export function createTerminalActivityIndicator({
|
|
|
161
164
|
activeRequests = 0;
|
|
162
165
|
clearTimer(idleTimer);
|
|
163
166
|
idleTimer = null;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
+
try {
|
|
168
|
+
clearAnimation();
|
|
169
|
+
} finally {
|
|
170
|
+
for (const method of methods) {
|
|
171
|
+
if (consoleObj[method] === wrappers[method]) consoleObj[method] = originals[method];
|
|
172
|
+
}
|
|
167
173
|
}
|
|
168
174
|
},
|
|
169
175
|
};
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
const ESC = "\u001b";
|
|
2
|
+
|
|
3
|
+
export const TERMINAL_ANIMATION_TRACK_WIDTH = 20;
|
|
4
|
+
export const DEFAULT_TERMINAL_ANIMATION_THEME = "comet";
|
|
5
|
+
|
|
6
|
+
const TAIL_LENGTH = 7;
|
|
7
|
+
const TAIL_COLORS = Object.freeze([159, 123, 87, 51, 45, 39, 33]);
|
|
8
|
+
const BRAILLE_GLYPHS = Object.freeze(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]);
|
|
9
|
+
|
|
10
|
+
function colorCell(glyph, color) {
|
|
11
|
+
return `${ESC}[${color}m${glyph}${ESC}[0m`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function blankTrack() {
|
|
15
|
+
return Array.from({ length: TERMINAL_ANIMATION_TRACK_WIDTH }, () => " ");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function setCell(cells, index, glyph, color) {
|
|
19
|
+
if (index < 0 || index >= TERMINAL_ANIMATION_TRACK_WIDTH) return;
|
|
20
|
+
cells[index] = colorCell(glyph, color);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function drawComet(cells, head, direction, tailLength = TAIL_LENGTH) {
|
|
24
|
+
for (let distance = tailLength; distance >= 1; distance -= 1) {
|
|
25
|
+
setCell(cells, head - (direction * distance), ":", `38;5;${TAIL_COLORS[distance - 1]}`);
|
|
26
|
+
}
|
|
27
|
+
setCell(cells, head, ":", "97");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function wrapTrack(cells) {
|
|
31
|
+
return `[${cells.join("")}]`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pingPongState(index) {
|
|
35
|
+
const period = (TERMINAL_ANIMATION_TRACK_WIDTH - 1) * 2;
|
|
36
|
+
const offset = index % period;
|
|
37
|
+
return offset < TERMINAL_ANIMATION_TRACK_WIDTH
|
|
38
|
+
? { position: offset, direction: 1 }
|
|
39
|
+
: { position: period - offset, direction: -1 };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const THEME_DEFINITIONS = Object.freeze([
|
|
43
|
+
Object.freeze({
|
|
44
|
+
id: "comet",
|
|
45
|
+
label: "Comet",
|
|
46
|
+
frameCount: TERMINAL_ANIMATION_TRACK_WIDTH + TAIL_LENGTH,
|
|
47
|
+
frameDelayMs: 45,
|
|
48
|
+
loopPauseMs: 200,
|
|
49
|
+
startFrame: 0,
|
|
50
|
+
render: renderCometFrame,
|
|
51
|
+
}),
|
|
52
|
+
Object.freeze({
|
|
53
|
+
id: "twin",
|
|
54
|
+
label: "Twin",
|
|
55
|
+
frameCount: 26,
|
|
56
|
+
frameDelayMs: 60,
|
|
57
|
+
loopPauseMs: 0,
|
|
58
|
+
startFrame: 5,
|
|
59
|
+
render(index) {
|
|
60
|
+
const cells = blankTrack();
|
|
61
|
+
drawComet(cells, index - 3, 1, 4);
|
|
62
|
+
drawComet(cells, TERMINAL_ANIMATION_TRACK_WIDTH + 2 - index, -1, 4);
|
|
63
|
+
return wrapTrack(cells);
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
Object.freeze({
|
|
67
|
+
id: "shuttle",
|
|
68
|
+
label: "Shuttle",
|
|
69
|
+
frameCount: (TERMINAL_ANIMATION_TRACK_WIDTH - 1) * 2,
|
|
70
|
+
frameDelayMs: 55,
|
|
71
|
+
loopPauseMs: 0,
|
|
72
|
+
startFrame: 7,
|
|
73
|
+
frameDelay(index) {
|
|
74
|
+
const { position } = pingPongState(index);
|
|
75
|
+
return position === 0 || position === TERMINAL_ANIMATION_TRACK_WIDTH - 1 ? 160 : 55;
|
|
76
|
+
},
|
|
77
|
+
render(index) {
|
|
78
|
+
const cells = blankTrack();
|
|
79
|
+
const { position, direction } = pingPongState(index);
|
|
80
|
+
drawComet(cells, position, direction);
|
|
81
|
+
return wrapTrack(cells);
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
Object.freeze({
|
|
85
|
+
id: "chase",
|
|
86
|
+
label: "Chase",
|
|
87
|
+
frameCount: TERMINAL_ANIMATION_TRACK_WIDTH + 14,
|
|
88
|
+
frameDelayMs: 65,
|
|
89
|
+
loopPauseMs: 0,
|
|
90
|
+
startFrame: 13,
|
|
91
|
+
render(index) {
|
|
92
|
+
const cells = blankTrack();
|
|
93
|
+
const first = index - 4;
|
|
94
|
+
let second = first - 11;
|
|
95
|
+
if (second < -4) second += TERMINAL_ANIMATION_TRACK_WIDTH + 14;
|
|
96
|
+
drawComet(cells, first, 1, 4);
|
|
97
|
+
drawComet(cells, second, 1, 4);
|
|
98
|
+
return wrapTrack(cells);
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
Object.freeze({
|
|
102
|
+
id: "mirror",
|
|
103
|
+
label: "Mirror",
|
|
104
|
+
frameCount: (Math.floor(TERMINAL_ANIMATION_TRACK_WIDTH / 2) - 1) * 2,
|
|
105
|
+
frameDelayMs: 75,
|
|
106
|
+
loopPauseMs: 0,
|
|
107
|
+
startFrame: 3,
|
|
108
|
+
render(index) {
|
|
109
|
+
const cells = blankTrack();
|
|
110
|
+
const half = Math.floor(TERMINAL_ANIMATION_TRACK_WIDTH / 2);
|
|
111
|
+
const period = (half - 1) * 2;
|
|
112
|
+
const distance = index < half ? index : period - index;
|
|
113
|
+
drawComet(cells, half - 1 - distance, -1, 3);
|
|
114
|
+
drawComet(cells, half + distance, 1, 3);
|
|
115
|
+
return wrapTrack(cells);
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
Object.freeze({
|
|
119
|
+
id: "pulse",
|
|
120
|
+
label: "Pulse",
|
|
121
|
+
frameCount: 8,
|
|
122
|
+
frameDelayMs: 115,
|
|
123
|
+
loopPauseMs: 0,
|
|
124
|
+
startFrame: 2,
|
|
125
|
+
render(index) {
|
|
126
|
+
const cells = blankTrack();
|
|
127
|
+
const radii = [0, 1, 2, 3, 4, 3, 2, 1];
|
|
128
|
+
const radius = radii[index];
|
|
129
|
+
const center = Math.floor((TERMINAL_ANIMATION_TRACK_WIDTH - 1) / 2);
|
|
130
|
+
setCell(cells, center, ":", "97");
|
|
131
|
+
for (let distance = 1; distance <= radius; distance += 1) {
|
|
132
|
+
const color = `38;5;${TAIL_COLORS[distance]}`;
|
|
133
|
+
setCell(cells, center - distance, ":", color);
|
|
134
|
+
setCell(cells, center + distance, ":", color);
|
|
135
|
+
}
|
|
136
|
+
return wrapTrack(cells);
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
Object.freeze({
|
|
140
|
+
id: "braille",
|
|
141
|
+
label: "Braille",
|
|
142
|
+
frameCount: BRAILLE_GLYPHS.length,
|
|
143
|
+
frameDelayMs: 80,
|
|
144
|
+
loopPauseMs: 0,
|
|
145
|
+
startFrame: 0,
|
|
146
|
+
render(index) {
|
|
147
|
+
const cells = blankTrack();
|
|
148
|
+
setCell(cells, Math.floor(TERMINAL_ANIMATION_TRACK_WIDTH / 2), BRAILLE_GLYPHS[index], `38;5;${TAIL_COLORS[2]}`);
|
|
149
|
+
return wrapTrack(cells);
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
const DEFINITIONS_BY_ID = new Map(THEME_DEFINITIONS.map((theme) => [theme.id, theme]));
|
|
155
|
+
|
|
156
|
+
export const TERMINAL_ANIMATION_THEMES = Object.freeze(THEME_DEFINITIONS.map(({
|
|
157
|
+
id,
|
|
158
|
+
label,
|
|
159
|
+
frameCount,
|
|
160
|
+
frameDelayMs,
|
|
161
|
+
loopPauseMs,
|
|
162
|
+
startFrame,
|
|
163
|
+
}) => Object.freeze({ id, label, frameCount, frameDelayMs, loopPauseMs, startFrame })));
|
|
164
|
+
|
|
165
|
+
export function isTerminalAnimationTheme(value) {
|
|
166
|
+
return typeof value === "string" && DEFINITIONS_BY_ID.has(value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function themeDefinition(theme) {
|
|
170
|
+
const definition = DEFINITIONS_BY_ID.get(theme);
|
|
171
|
+
if (!definition) throw new TypeError(`Unknown terminal animation theme: ${String(theme)}`);
|
|
172
|
+
return definition;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizedFrameIndex(frameIndex, frameCount) {
|
|
176
|
+
if (!Number.isSafeInteger(frameIndex)) throw new TypeError("Terminal animation frame index must be an integer");
|
|
177
|
+
return ((frameIndex % frameCount) + frameCount) % frameCount;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function getTerminalAnimationCycleLength(theme) {
|
|
181
|
+
return themeDefinition(theme).frameCount;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function getTerminalAnimationFrameDelay(theme, frameIndex) {
|
|
185
|
+
const definition = themeDefinition(theme);
|
|
186
|
+
const index = normalizedFrameIndex(frameIndex, definition.frameCount);
|
|
187
|
+
return definition.frameDelay?.(index) ?? definition.frameDelayMs;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function renderTerminalAnimationFrame(theme, frameIndex) {
|
|
191
|
+
const definition = themeDefinition(theme);
|
|
192
|
+
return definition.render(normalizedFrameIndex(frameIndex, definition.frameCount));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function renderCometFrame(position) {
|
|
196
|
+
let track = "";
|
|
197
|
+
for (let column = 0; column < TERMINAL_ANIMATION_TRACK_WIDTH; column += 1) {
|
|
198
|
+
const distance = position - column;
|
|
199
|
+
if (distance === 0) {
|
|
200
|
+
track += `${ESC}[97m:${ESC}[0m`;
|
|
201
|
+
} else if (distance > 0 && distance <= TAIL_LENGTH) {
|
|
202
|
+
track += `${ESC}[38;5;${TAIL_COLORS[distance - 1]}m:${ESC}[0m`;
|
|
203
|
+
} else {
|
|
204
|
+
track += " ";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return `[${track}]`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function outputSupportsPreview(output) {
|
|
211
|
+
if (output?.isTTY !== true) return false;
|
|
212
|
+
const columns = Number(output.columns);
|
|
213
|
+
return !Number.isFinite(columns) || columns <= 0 || columns >= TERMINAL_ANIMATION_TRACK_WIDTH + 2;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function playTerminalAnimationPreview(theme, {
|
|
217
|
+
output = process.stdout,
|
|
218
|
+
timers = { setTimeout, clearTimeout },
|
|
219
|
+
} = {}) {
|
|
220
|
+
const definition = themeDefinition(theme);
|
|
221
|
+
if (!outputSupportsPreview(output)) return false;
|
|
222
|
+
|
|
223
|
+
const eraseLine = `\r${ESC}[2K`;
|
|
224
|
+
let timer = null;
|
|
225
|
+
let previewStarted = false;
|
|
226
|
+
|
|
227
|
+
const wait = (delay) => new Promise((resolve) => {
|
|
228
|
+
let settled = false;
|
|
229
|
+
const nextTimer = timers.setTimeout(() => {
|
|
230
|
+
settled = true;
|
|
231
|
+
timer = null;
|
|
232
|
+
resolve();
|
|
233
|
+
}, delay);
|
|
234
|
+
timer = settled ? null : nextTimer;
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
for (let offset = 0; offset < definition.frameCount; offset += 1) {
|
|
239
|
+
const frameIndex = definition.startFrame + offset;
|
|
240
|
+
previewStarted = true;
|
|
241
|
+
output.write(`${eraseLine}${renderTerminalAnimationFrame(theme, frameIndex)}`);
|
|
242
|
+
await wait(getTerminalAnimationFrameDelay(theme, frameIndex));
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
} finally {
|
|
246
|
+
if (timer) timers.clearTimeout(timer);
|
|
247
|
+
if (previewStarted) output.write(eraseLine);
|
|
248
|
+
}
|
|
249
|
+
}
|
package/src/user-settings.mjs
CHANGED
|
@@ -3,8 +3,14 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
|
|
5
5
|
import { DEFAULT_CODEX_AUTO_REVIEW_MODEL } from "./models.mjs";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_TERMINAL_ANIMATION_THEME,
|
|
8
|
+
isTerminalAnimationTheme,
|
|
9
|
+
TERMINAL_ANIMATION_THEMES,
|
|
10
|
+
} from "./terminal-animation.mjs";
|
|
6
11
|
|
|
7
12
|
const AUTO_REVIEW_MODEL_KEY = "auto_review_model";
|
|
13
|
+
const TERMINAL_ANIMATION_KEY = "terminal_animation";
|
|
8
14
|
|
|
9
15
|
export function userSettingsPath({ env = process.env, home = os.homedir() } = {}) {
|
|
10
16
|
const xdgConfigHome = String(env.XDG_CONFIG_HOME || "").trim();
|
|
@@ -29,6 +35,16 @@ export function readUserSettings({ env = process.env, home = os.homedir(), stric
|
|
|
29
35
|
throw invalidSettings(filePath, `${AUTO_REVIEW_MODEL_KEY} must be a non-empty string`);
|
|
30
36
|
}
|
|
31
37
|
}
|
|
38
|
+
if (Object.hasOwn(parsed, TERMINAL_ANIMATION_KEY)) {
|
|
39
|
+
const value = parsed[TERMINAL_ANIMATION_KEY];
|
|
40
|
+
if (!isTerminalAnimationTheme(value)) {
|
|
41
|
+
const choices = TERMINAL_ANIMATION_THEMES.map(({ id }) => id).join(", ");
|
|
42
|
+
if (strict) {
|
|
43
|
+
throw invalidSettings(filePath, `${TERMINAL_ANIMATION_KEY} must be one of: ${choices}`);
|
|
44
|
+
}
|
|
45
|
+
delete parsed[TERMINAL_ANIMATION_KEY];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
32
48
|
return parsed;
|
|
33
49
|
} catch (error) {
|
|
34
50
|
if (error?.code === "ENOENT") return {};
|
|
@@ -54,6 +70,18 @@ export function autoReviewModelPreference({ env = process.env, home = os.homedir
|
|
|
54
70
|
return { model: DEFAULT_CODEX_AUTO_REVIEW_MODEL, source: "default" };
|
|
55
71
|
}
|
|
56
72
|
|
|
73
|
+
export function savedTerminalAnimationTheme(options = {}) {
|
|
74
|
+
const settings = readUserSettings(options);
|
|
75
|
+
const theme = settings[TERMINAL_ANIMATION_KEY];
|
|
76
|
+
return isTerminalAnimationTheme(theme) ? theme : "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function terminalAnimationPreference({ env = process.env, home = os.homedir() } = {}) {
|
|
80
|
+
const savedTheme = savedTerminalAnimationTheme({ env, home });
|
|
81
|
+
if (savedTheme) return { theme: savedTheme, source: "settings" };
|
|
82
|
+
return { theme: DEFAULT_TERMINAL_ANIMATION_THEME, source: "default" };
|
|
83
|
+
}
|
|
84
|
+
|
|
57
85
|
export function writeAutoReviewModel(model, { env = process.env, home = os.homedir() } = {}) {
|
|
58
86
|
const filePath = userSettingsPath({ env, home });
|
|
59
87
|
const settings = readUserSettings({ env, home, strict: true });
|
|
@@ -65,3 +93,25 @@ export function writeAutoReviewModel(model, { env = process.env, home = os.homed
|
|
|
65
93
|
const changed = atomicWriteFileIfChangedSync(filePath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
66
94
|
return { changed, filePath, model: value || DEFAULT_CODEX_AUTO_REVIEW_MODEL };
|
|
67
95
|
}
|
|
96
|
+
|
|
97
|
+
export function writeTerminalAnimationTheme(theme, { env = process.env, home = os.homedir() } = {}) {
|
|
98
|
+
if (!isTerminalAnimationTheme(theme)) {
|
|
99
|
+
const choices = TERMINAL_ANIMATION_THEMES.map(({ id }) => id).join(", ");
|
|
100
|
+
throw new Error(`Terminal animation must be one of: ${choices}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const filePath = userSettingsPath({ env, home });
|
|
104
|
+
const settings = readUserSettings({ env, home, strict: true });
|
|
105
|
+
const next = { ...settings };
|
|
106
|
+
if (theme === DEFAULT_TERMINAL_ANIMATION_THEME) {
|
|
107
|
+
if (!Object.hasOwn(next, TERMINAL_ANIMATION_KEY)) {
|
|
108
|
+
return { changed: false, filePath, theme };
|
|
109
|
+
}
|
|
110
|
+
delete next[TERMINAL_ANIMATION_KEY];
|
|
111
|
+
} else {
|
|
112
|
+
next[TERMINAL_ANIMATION_KEY] = theme;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const changed = atomicWriteFileIfChangedSync(filePath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
116
|
+
return { changed, filePath, theme };
|
|
117
|
+
}
|