pi-codex-search 0.1.1 → 0.1.3
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 +198 -50
- package/index.ts +922 -93
- package/package.json +10 -4
- package/scripts/codex-e2e.ts +797 -0
- package/src/codex.ts +90 -352
- package/src/command.ts +564 -0
- package/src/config.ts +287 -0
- package/src/cookies.ts +131 -0
- package/src/errors.ts +56 -0
- package/src/modes/responses.ts +310 -0
- package/src/modes/standalone.ts +378 -0
- package/src/modes/types.ts +41 -0
- package/src/ref-store.ts +74 -0
- package/src/transport.ts +110 -0
- package/src/ua.ts +67 -0
package/src/command.ts
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
type Component,
|
|
5
|
+
Input,
|
|
6
|
+
type SelectItem,
|
|
7
|
+
SelectList,
|
|
8
|
+
type SettingItem,
|
|
9
|
+
SettingsList,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
import { type CodexModel, extractAccountIdFromToken, fetchCodexModels } from "./codex.ts";
|
|
12
|
+
import {
|
|
13
|
+
type ConfigScope,
|
|
14
|
+
DEFAULT_BATCH_SIZE,
|
|
15
|
+
DEFAULT_ENABLED,
|
|
16
|
+
DEFAULT_FRESHNESS,
|
|
17
|
+
DEFAULT_SEARCH_CONTEXT_SIZE,
|
|
18
|
+
DEFAULT_STANDALONE_ENABLED,
|
|
19
|
+
STANDALONE_TOOL_NAME,
|
|
20
|
+
deleteConfig,
|
|
21
|
+
getConfigPath,
|
|
22
|
+
isProjectTrustedContext,
|
|
23
|
+
loadConfig,
|
|
24
|
+
MAX_BATCH_SIZE,
|
|
25
|
+
MIN_BATCH_SIZE,
|
|
26
|
+
type PiCodexSearchConfig,
|
|
27
|
+
type ResolvedConfig,
|
|
28
|
+
saveConfig,
|
|
29
|
+
} from "./config.ts";
|
|
30
|
+
|
|
31
|
+
const COMMAND_NAME = "codex-search-settings";
|
|
32
|
+
const SUBCOMMANDS = ["status", "reset"] as const;
|
|
33
|
+
const OPENAI_CODEX_PROVIDER = "openai-codex";
|
|
34
|
+
|
|
35
|
+
const DEFAULT_SUFFIX = " (default)";
|
|
36
|
+
const defaultTag = (value: string): string => `${value}${DEFAULT_SUFFIX}`;
|
|
37
|
+
const isDefaultTag = (value: string): boolean => value.endsWith(DEFAULT_SUFFIX);
|
|
38
|
+
|
|
39
|
+
function normalizeStandaloneSearchContext(config: PiCodexSearchConfig): boolean {
|
|
40
|
+
const standaloneEnabled = config.standaloneEnabled ?? config.searchApi === "standalone";
|
|
41
|
+
if (!standaloneEnabled || config.searchContextSize !== "low") return false;
|
|
42
|
+
config.searchContextSize = DEFAULT_SEARCH_CONTEXT_SIZE;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface CycleField {
|
|
47
|
+
id: string;
|
|
48
|
+
label: string;
|
|
49
|
+
description: string;
|
|
50
|
+
values(cfg: PiCodexSearchConfig): string[];
|
|
51
|
+
get(cfg: PiCodexSearchConfig): string;
|
|
52
|
+
apply(cfg: PiCodexSearchConfig, value: string): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface TextField {
|
|
56
|
+
id: string;
|
|
57
|
+
label: string;
|
|
58
|
+
description: string;
|
|
59
|
+
/** Shown (tagged as default) when the field is not set in the active scope. */
|
|
60
|
+
defaultDisplay: string;
|
|
61
|
+
get(cfg: PiCodexSearchConfig): string | undefined;
|
|
62
|
+
apply(cfg: PiCodexSearchConfig, value: string): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The default value is shown first as "<value> (default)"; selecting it clears the field.
|
|
66
|
+
const CYCLE_FIELDS: CycleField[] = [
|
|
67
|
+
{
|
|
68
|
+
id: "enabled",
|
|
69
|
+
label: "Enabled",
|
|
70
|
+
description: "Register the search tool at session start",
|
|
71
|
+
values: () => [defaultTag(String(DEFAULT_ENABLED)), "false"],
|
|
72
|
+
get: (c) =>
|
|
73
|
+
c.enabled === undefined || c.enabled === DEFAULT_ENABLED
|
|
74
|
+
? defaultTag(String(DEFAULT_ENABLED))
|
|
75
|
+
: String(c.enabled),
|
|
76
|
+
apply: (c, v) => {
|
|
77
|
+
if (isDefaultTag(v)) delete c.enabled;
|
|
78
|
+
else c.enabled = v === "true";
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "standaloneEnabled",
|
|
83
|
+
label: "Standalone web tool",
|
|
84
|
+
description:
|
|
85
|
+
"Register codex_standalone_web for page open/find/click/screenshot and domain lookups",
|
|
86
|
+
values: () => [defaultTag(String(DEFAULT_STANDALONE_ENABLED)), "true", "false"],
|
|
87
|
+
get: (c) =>
|
|
88
|
+
c.standaloneEnabled === undefined && c.searchApi !== "standalone"
|
|
89
|
+
? defaultTag(String(DEFAULT_STANDALONE_ENABLED))
|
|
90
|
+
: String(c.standaloneEnabled ?? c.searchApi === "standalone"),
|
|
91
|
+
apply: (c, v) => {
|
|
92
|
+
delete c.searchApi;
|
|
93
|
+
if (isDefaultTag(v)) delete c.standaloneEnabled;
|
|
94
|
+
else c.standaloneEnabled = v === "true";
|
|
95
|
+
normalizeStandaloneSearchContext(c);
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "freshness",
|
|
100
|
+
label: "Freshness",
|
|
101
|
+
description: "live / indexed / cached web access",
|
|
102
|
+
values: () => [defaultTag(DEFAULT_FRESHNESS), "cached", "indexed"],
|
|
103
|
+
get: (c) =>
|
|
104
|
+
c.freshness === undefined || c.freshness === DEFAULT_FRESHNESS
|
|
105
|
+
? defaultTag(DEFAULT_FRESHNESS)
|
|
106
|
+
: c.freshness,
|
|
107
|
+
apply: (c, v) => {
|
|
108
|
+
if (isDefaultTag(v)) delete c.freshness;
|
|
109
|
+
else c.freshness = v as PiCodexSearchConfig["freshness"];
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "searchContextSize",
|
|
114
|
+
label: "Search context size",
|
|
115
|
+
description: "Amount of web context to retrieve",
|
|
116
|
+
values: (c) =>
|
|
117
|
+
c.standaloneEnabled || c.searchApi === "standalone"
|
|
118
|
+
? [defaultTag(DEFAULT_SEARCH_CONTEXT_SIZE), "high"]
|
|
119
|
+
: [defaultTag(DEFAULT_SEARCH_CONTEXT_SIZE), "low", "high"],
|
|
120
|
+
get: (c) =>
|
|
121
|
+
c.searchContextSize === undefined || c.searchContextSize === DEFAULT_SEARCH_CONTEXT_SIZE
|
|
122
|
+
? defaultTag(DEFAULT_SEARCH_CONTEXT_SIZE)
|
|
123
|
+
: c.searchContextSize,
|
|
124
|
+
apply: (c, v) => {
|
|
125
|
+
if (isDefaultTag(v)) delete c.searchContextSize;
|
|
126
|
+
else c.searchContextSize = v as PiCodexSearchConfig["searchContextSize"];
|
|
127
|
+
normalizeStandaloneSearchContext(c);
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const TEXT_FIELDS: TextField[] = [
|
|
133
|
+
{
|
|
134
|
+
id: "model",
|
|
135
|
+
label: "Model",
|
|
136
|
+
description: "Pick from models loaded via /codex/models (default = auto-select)",
|
|
137
|
+
defaultDisplay: "auto",
|
|
138
|
+
get: (c) => c.model,
|
|
139
|
+
apply: (c, v) => {
|
|
140
|
+
if (v) c.model = v;
|
|
141
|
+
else delete c.model;
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: "baseUrl",
|
|
146
|
+
label: "Base URL",
|
|
147
|
+
description: "Codex backend base URL",
|
|
148
|
+
defaultDisplay: "built-in",
|
|
149
|
+
get: (c) => c.baseUrl,
|
|
150
|
+
apply: (c, v) => {
|
|
151
|
+
if (v) c.baseUrl = v;
|
|
152
|
+
else delete c.baseUrl;
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: "clientVersion",
|
|
157
|
+
label: "Client version",
|
|
158
|
+
description: "Client version sent to /codex/models",
|
|
159
|
+
defaultDisplay: "built-in",
|
|
160
|
+
get: (c) => c.clientVersion,
|
|
161
|
+
apply: (c, v) => {
|
|
162
|
+
if (v) c.clientVersion = v;
|
|
163
|
+
else delete c.clientVersion;
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "batchSize",
|
|
168
|
+
label: "Max batch size",
|
|
169
|
+
description: `Max queries per tool call (${MIN_BATCH_SIZE}-${MAX_BATCH_SIZE})`,
|
|
170
|
+
defaultDisplay: String(DEFAULT_BATCH_SIZE),
|
|
171
|
+
get: (c) => (c.batchSize === undefined ? undefined : String(c.batchSize)),
|
|
172
|
+
apply: (c, v) => {
|
|
173
|
+
const trimmed = v.trim();
|
|
174
|
+
if (!trimmed) {
|
|
175
|
+
delete c.batchSize;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const parsed = Number(trimmed);
|
|
179
|
+
if (!Number.isInteger(parsed) || parsed < MIN_BATCH_SIZE || parsed > MAX_BATCH_SIZE) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Max batch size must be an integer between ${MIN_BATCH_SIZE} and ${MAX_BATCH_SIZE}.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
c.batchSize = parsed;
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
function textDisplay(field: TextField, cfg: PiCodexSearchConfig): string {
|
|
190
|
+
return field.get(cfg) ?? defaultTag(field.defaultDisplay);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function registerSettingsCommand(pi: ExtensionAPI): void {
|
|
194
|
+
pi.registerCommand(COMMAND_NAME, {
|
|
195
|
+
description: "Configure pi-codex-search (tool name, model, API, defaults, freshness).",
|
|
196
|
+
getArgumentCompletions(prefix) {
|
|
197
|
+
const lower = prefix.toLowerCase();
|
|
198
|
+
const matches = SUBCOMMANDS.filter((name) => name.startsWith(lower));
|
|
199
|
+
return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
|
|
200
|
+
},
|
|
201
|
+
handler: async (args, ctx) => {
|
|
202
|
+
const trimmed = args.trim();
|
|
203
|
+
try {
|
|
204
|
+
if (!trimmed) {
|
|
205
|
+
if (ctx.mode === "tui") {
|
|
206
|
+
await openSettingsMenu(ctx);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
await printStatus(ctx);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (trimmed === "status") {
|
|
213
|
+
await printStatus(ctx);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (trimmed === "reset") {
|
|
217
|
+
if (ctx.hasUI) {
|
|
218
|
+
await openResetMenu(ctx, isProjectTrustedContext(ctx));
|
|
219
|
+
} else {
|
|
220
|
+
notify(
|
|
221
|
+
ctx,
|
|
222
|
+
"`reset` requires interactive mode. Delete the config files manually.",
|
|
223
|
+
"warning",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
notify(
|
|
229
|
+
ctx,
|
|
230
|
+
`Unknown subcommand: ${trimmed}. Expected: ${SUBCOMMANDS.join(", ")}.`,
|
|
231
|
+
"error",
|
|
232
|
+
);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
notify(ctx, (error as Error).message, "error");
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function openSettingsMenu(ctx: ExtensionCommandContext): Promise<void> {
|
|
241
|
+
const isProjectTrusted = isProjectTrustedContext(ctx);
|
|
242
|
+
const resolved = await loadConfig(ctx.cwd, isProjectTrusted);
|
|
243
|
+
const drafts: Record<ConfigScope, PiCodexSearchConfig> = {
|
|
244
|
+
project: { ...resolved.sources.project },
|
|
245
|
+
home: { ...resolved.sources.home },
|
|
246
|
+
};
|
|
247
|
+
let scope: ConfigScope = isProjectTrusted ? "project" : "home";
|
|
248
|
+
let dirty = false;
|
|
249
|
+
let saveQueue: Promise<void> = Promise.resolve();
|
|
250
|
+
|
|
251
|
+
for (const currentScope of ["home", "project"] as const) {
|
|
252
|
+
if (currentScope === "project" && !isProjectTrusted) continue;
|
|
253
|
+
if (normalizeStandaloneSearchContext(drafts[currentScope])) {
|
|
254
|
+
const currentDraft = { ...drafts[currentScope] };
|
|
255
|
+
saveQueue = saveQueue
|
|
256
|
+
.catch(() => undefined)
|
|
257
|
+
.then(async () => {
|
|
258
|
+
try {
|
|
259
|
+
await saveConfig(currentScope, ctx.cwd, currentDraft);
|
|
260
|
+
dirty = true;
|
|
261
|
+
} catch (error: unknown) {
|
|
262
|
+
ctx.ui.notify((error as Error).message, "error");
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let models: CodexModel[] = [];
|
|
269
|
+
|
|
270
|
+
await ctx.ui.custom<void>((_tui, theme, _keybindings, done) => {
|
|
271
|
+
const settingsTheme = buildSettingsTheme(theme);
|
|
272
|
+
const selectTheme = buildSelectTheme(theme);
|
|
273
|
+
|
|
274
|
+
let list: SettingsList;
|
|
275
|
+
|
|
276
|
+
const refreshDisplays = () => {
|
|
277
|
+
scopeItem.description = formatScopeDescription(scope, ctx.cwd);
|
|
278
|
+
for (const f of CYCLE_FIELDS) {
|
|
279
|
+
const item = items.find((candidate) => candidate.id === f.id);
|
|
280
|
+
if (item) item.values = f.values(drafts[scope]);
|
|
281
|
+
list.updateValue(f.id, f.get(drafts[scope]));
|
|
282
|
+
}
|
|
283
|
+
for (const f of TEXT_FIELDS) list.updateValue(f.id, textDisplay(f, drafts[scope]));
|
|
284
|
+
list.invalidate();
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const save = () => {
|
|
288
|
+
if (scope === "project" && !isProjectTrusted) {
|
|
289
|
+
ctx.ui.notify("Project config cannot be saved until the project is trusted.", "warning");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const currentScope = scope;
|
|
293
|
+
const currentDraft = { ...drafts[scope] };
|
|
294
|
+
saveQueue = saveQueue
|
|
295
|
+
.catch(() => undefined)
|
|
296
|
+
.then(async () => {
|
|
297
|
+
try {
|
|
298
|
+
await saveConfig(currentScope, ctx.cwd, currentDraft);
|
|
299
|
+
dirty = true;
|
|
300
|
+
} catch (error: unknown) {
|
|
301
|
+
ctx.ui.notify((error as Error).message, "error");
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const onChange = (id: string, newValue: string) => {
|
|
307
|
+
if (id === "scope") {
|
|
308
|
+
scope = newValue as ConfigScope;
|
|
309
|
+
refreshDisplays();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const cycle = CYCLE_FIELDS.find((f) => f.id === id);
|
|
313
|
+
if (cycle) {
|
|
314
|
+
cycle.apply(drafts[scope], newValue);
|
|
315
|
+
refreshDisplays();
|
|
316
|
+
save();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const text = TEXT_FIELDS.find((f) => f.id === id);
|
|
320
|
+
if (text) {
|
|
321
|
+
try {
|
|
322
|
+
text.apply(drafts[scope], newValue.trim());
|
|
323
|
+
} catch (error: unknown) {
|
|
324
|
+
ctx.ui.notify((error as Error).message, "error");
|
|
325
|
+
list.updateValue(id, textDisplay(text, drafts[scope]));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
list.updateValue(id, textDisplay(text, drafts[scope]));
|
|
329
|
+
save();
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const scopeItem: SettingItem = {
|
|
334
|
+
id: "scope",
|
|
335
|
+
label: "Config scope",
|
|
336
|
+
description: isProjectTrusted
|
|
337
|
+
? formatScopeDescription(scope, ctx.cwd)
|
|
338
|
+
: "Project config disabled until the project is trusted; editing home config only",
|
|
339
|
+
currentValue: scope,
|
|
340
|
+
values: isProjectTrusted ? ["project", "home"] : ["home"],
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const items: SettingItem[] = [
|
|
344
|
+
scopeItem,
|
|
345
|
+
...CYCLE_FIELDS.map(
|
|
346
|
+
(f): SettingItem => ({
|
|
347
|
+
id: f.id,
|
|
348
|
+
label: f.label,
|
|
349
|
+
description: f.description,
|
|
350
|
+
currentValue: f.get(drafts[scope]),
|
|
351
|
+
values: f.values(drafts[scope]),
|
|
352
|
+
}),
|
|
353
|
+
),
|
|
354
|
+
...TEXT_FIELDS.map(
|
|
355
|
+
(f): SettingItem => ({
|
|
356
|
+
id: f.id,
|
|
357
|
+
label: f.label,
|
|
358
|
+
description: f.description,
|
|
359
|
+
currentValue: textDisplay(f, drafts[scope]),
|
|
360
|
+
submenu:
|
|
361
|
+
f.id === "model"
|
|
362
|
+
? (_current, submenuDone) =>
|
|
363
|
+
buildModelSelect(models, drafts[scope].model, selectTheme, submenuDone)
|
|
364
|
+
: (_current, submenuDone) => {
|
|
365
|
+
const input = new Input();
|
|
366
|
+
input.setValue(f.get(drafts[scope]) ?? "");
|
|
367
|
+
input.onSubmit = (value) => submenuDone(value);
|
|
368
|
+
input.onEscape = () => submenuDone();
|
|
369
|
+
return input;
|
|
370
|
+
},
|
|
371
|
+
}),
|
|
372
|
+
),
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
const modelItem = items.find((i) => i.id === "model");
|
|
376
|
+
if (modelItem) modelItem.description = "Loading models via /codex/models…";
|
|
377
|
+
|
|
378
|
+
loadModels(ctx, resolved)
|
|
379
|
+
.then((loaded) => {
|
|
380
|
+
models = loaded;
|
|
381
|
+
if (modelItem) {
|
|
382
|
+
modelItem.description =
|
|
383
|
+
loaded.length === 0
|
|
384
|
+
? "No models loaded from /codex/models (default = auto-select)"
|
|
385
|
+
: `Pick from ${loaded.length} model${loaded.length === 1 ? "" : "s"} loaded via /codex/models (default = auto-select)`;
|
|
386
|
+
}
|
|
387
|
+
list.invalidate();
|
|
388
|
+
})
|
|
389
|
+
.catch((error: unknown) => {
|
|
390
|
+
if (modelItem) {
|
|
391
|
+
modelItem.description =
|
|
392
|
+
"Could not load models from /codex/models (default = auto-select)";
|
|
393
|
+
}
|
|
394
|
+
list.invalidate();
|
|
395
|
+
ctx.ui.notify(`Could not load model list: ${(error as Error).message}`, "warning");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
list = new SettingsList(items, items.length, settingsTheme, onChange, () => done(), {
|
|
399
|
+
enableSearch: true,
|
|
400
|
+
});
|
|
401
|
+
return list as Component;
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
await saveQueue;
|
|
405
|
+
if (dirty) await ctx.reload();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function buildSettingsTheme(theme: Theme) {
|
|
409
|
+
return {
|
|
410
|
+
label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
|
|
411
|
+
value: (text: string, selected: boolean) =>
|
|
412
|
+
selected ? theme.bold(theme.fg("accent", text)) : theme.fg("muted", text),
|
|
413
|
+
description: (text: string) => theme.fg("dim", text),
|
|
414
|
+
cursor: theme.fg("accent", "> "),
|
|
415
|
+
hint: (text: string) => theme.fg("dim", text),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function buildSelectTheme(theme: Theme) {
|
|
420
|
+
return {
|
|
421
|
+
selectedPrefix: (text: string) => theme.fg("accent", text),
|
|
422
|
+
selectedText: (text: string) => theme.bold(theme.fg("accent", text)),
|
|
423
|
+
description: (text: string) => theme.fg("dim", text),
|
|
424
|
+
scrollInfo: (text: string) => theme.fg("dim", text),
|
|
425
|
+
noMatch: (text: string) => theme.fg("warning", text),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function buildModelSelect(
|
|
430
|
+
models: CodexModel[],
|
|
431
|
+
current: string | undefined,
|
|
432
|
+
theme: ReturnType<typeof buildSelectTheme>,
|
|
433
|
+
done: (value?: string) => void,
|
|
434
|
+
): Component {
|
|
435
|
+
const items: SelectItem[] = [
|
|
436
|
+
{ value: "", label: defaultTag("auto"), description: "Auto-select from /codex/models" },
|
|
437
|
+
...models.map((m): SelectItem => ({ value: m.id, label: m.id, description: m.name })),
|
|
438
|
+
];
|
|
439
|
+
const select = new SelectList(items, Math.min(items.length, 10), theme);
|
|
440
|
+
const currentIndex = items.findIndex((i) => i.value === (current ?? ""));
|
|
441
|
+
if (currentIndex >= 0) select.setSelectedIndex(currentIndex);
|
|
442
|
+
select.onSelect = (item) => done(item.value);
|
|
443
|
+
select.onCancel = () => done();
|
|
444
|
+
return select;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function loadModels(
|
|
448
|
+
ctx: ExtensionCommandContext,
|
|
449
|
+
resolved: ResolvedConfig,
|
|
450
|
+
): Promise<CodexModel[]> {
|
|
451
|
+
const token = await ctx.modelRegistry.getApiKeyForProvider(OPENAI_CODEX_PROVIDER);
|
|
452
|
+
if (!token) return [];
|
|
453
|
+
const credential = ctx.modelRegistry.authStorage.get(OPENAI_CODEX_PROVIDER);
|
|
454
|
+
const accountId =
|
|
455
|
+
credential?.type === "oauth" && typeof credential.accountId === "string"
|
|
456
|
+
? credential.accountId
|
|
457
|
+
: extractAccountIdFromToken(token);
|
|
458
|
+
if (!accountId) return [];
|
|
459
|
+
|
|
460
|
+
const opts: Parameters<typeof fetchCodexModels>[0] = { token, accountId };
|
|
461
|
+
if (resolved.baseUrl !== undefined) opts.baseUrl = resolved.baseUrl;
|
|
462
|
+
if (resolved.clientVersion !== undefined) opts.clientVersion = resolved.clientVersion;
|
|
463
|
+
return fetchCodexModels(opts);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function openResetMenu(
|
|
467
|
+
ctx: ExtensionCommandContext,
|
|
468
|
+
isProjectTrusted: boolean,
|
|
469
|
+
): Promise<boolean> {
|
|
470
|
+
let removed = false;
|
|
471
|
+
|
|
472
|
+
while (true) {
|
|
473
|
+
const options = [
|
|
474
|
+
...(isProjectTrusted
|
|
475
|
+
? [`Delete project config (${relative(ctx.cwd, getConfigPath("project", ctx.cwd))})`]
|
|
476
|
+
: []),
|
|
477
|
+
`Delete home config (${homeRelative(getConfigPath("home", ctx.cwd))})`,
|
|
478
|
+
"Back",
|
|
479
|
+
];
|
|
480
|
+
const choice = await ctx.ui.select("Reset configuration", options);
|
|
481
|
+
|
|
482
|
+
if (!choice || choice === "Back") return removed;
|
|
483
|
+
|
|
484
|
+
const scope: ConfigScope = choice.startsWith("Delete project") ? "project" : "home";
|
|
485
|
+
const filePath = getConfigPath(scope, ctx.cwd);
|
|
486
|
+
const confirmed = await ctx.ui.confirm("Delete config", `Remove ${filePath}?`);
|
|
487
|
+
if (!confirmed) continue;
|
|
488
|
+
try {
|
|
489
|
+
const deleted = await deleteConfig(scope, ctx.cwd);
|
|
490
|
+
if (deleted) {
|
|
491
|
+
ctx.ui.notify(`Deleted ${filePath}.`);
|
|
492
|
+
removed = true;
|
|
493
|
+
} else {
|
|
494
|
+
ctx.ui.notify(`${filePath} did not exist.`, "warning");
|
|
495
|
+
}
|
|
496
|
+
} catch (error) {
|
|
497
|
+
ctx.ui.notify(`Failed to delete ${filePath}: ${(error as Error).message}`, "error");
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function printStatus(ctx: ExtensionCommandContext): Promise<void> {
|
|
503
|
+
const resolved = await loadConfig(ctx.cwd, isProjectTrustedContext(ctx));
|
|
504
|
+
notify(ctx, formatStatus(resolved, ctx.cwd));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function formatStatus(resolved: ResolvedConfig, cwd: string): string {
|
|
508
|
+
const lines = ["Codex Search settings:"];
|
|
509
|
+
lines.push(` enabled = ${resolved.enabled}`);
|
|
510
|
+
lines.push(` searchToolName = codex_search`);
|
|
511
|
+
lines.push(` standaloneToolName = ${STANDALONE_TOOL_NAME}`);
|
|
512
|
+
lines.push(` model = ${resolved.model ?? "(auto from /codex/models)"}`);
|
|
513
|
+
lines.push(` baseUrl = ${resolved.baseUrl ?? "(default)"}`);
|
|
514
|
+
lines.push(` clientVersion = ${resolved.clientVersion ?? "(default)"}`);
|
|
515
|
+
lines.push(` searchContextSize = ${resolved.defaultSearchContextSize}`);
|
|
516
|
+
lines.push(` freshness = ${resolved.defaultFreshness}`);
|
|
517
|
+
lines.push(` searchApi = responses`);
|
|
518
|
+
lines.push(` standaloneEnabled = ${resolved.standaloneEnabled}`);
|
|
519
|
+
lines.push(` maxBatchSize = ${resolved.batchSize}`);
|
|
520
|
+
lines.push(` standaloneBatchSize = 1`);
|
|
521
|
+
lines.push("");
|
|
522
|
+
lines.push("Sources (env > project > home):");
|
|
523
|
+
lines.push(` env = ${describeSource(resolved.sources.env)}`);
|
|
524
|
+
lines.push(
|
|
525
|
+
` project = ${describeSource(resolved.sources.project)} (${relative(cwd, getConfigPath("project", cwd))})`,
|
|
526
|
+
);
|
|
527
|
+
lines.push(
|
|
528
|
+
` home = ${describeSource(resolved.sources.home)} (${homeRelative(getConfigPath("home", cwd))})`,
|
|
529
|
+
);
|
|
530
|
+
return lines.join("\n");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function describeSource(config: PiCodexSearchConfig | undefined): string {
|
|
534
|
+
if (!config) return "(none)";
|
|
535
|
+
const keys = Object.keys(config);
|
|
536
|
+
if (keys.length === 0) return "(empty)";
|
|
537
|
+
return keys.sort().join(", ");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function formatScopeDescription(scope: ConfigScope, cwd: string): string {
|
|
541
|
+
const filePath = getConfigPath(scope, cwd);
|
|
542
|
+
const displayPath = scope === "home" ? homeRelative(filePath) : relative(cwd, filePath);
|
|
543
|
+
return `Writes to the ${scope} config file: ${displayPath}`;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function homeRelative(filePath: string): string {
|
|
547
|
+
const home = process.env.HOME ?? "";
|
|
548
|
+
return home && filePath.startsWith(`${home}/`)
|
|
549
|
+
? `~/${filePath.slice(home.length + 1)}`
|
|
550
|
+
: filePath;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function notify(
|
|
554
|
+
ctx: Pick<ExtensionCommandContext, "hasUI" | "ui">,
|
|
555
|
+
message: string,
|
|
556
|
+
level: "info" | "warning" | "error" = "info",
|
|
557
|
+
): void {
|
|
558
|
+
if (ctx.hasUI) {
|
|
559
|
+
ctx.ui.notify(message, level);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (level === "error") console.error(message);
|
|
563
|
+
else console.log(message);
|
|
564
|
+
}
|