pi-openai-codex-compat 0.0.1 → 0.0.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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.0.2 - 2026-08-03
6
+
7
+ ### Added
8
+
9
+ - Add transient `PI_OPENAI_CODEX_COMPAT_*` environment overrides for every compatibility setting, including common boolean aliases, explicit `off`/`default` percentage-compaction values, and an unambiguous `WEB_SEARCH_MODE` name.
10
+
11
+ ### Changed
12
+
13
+ - Make `Enter` save and close `/codex-settings`, make `Escape` discard unsaved changes and close, and retain `Ctrl+S` as save without closing.
14
+
5
15
  ## 0.0.1 - 2026-08-03
6
16
 
7
17
  ### Added
package/README.md CHANGED
@@ -12,7 +12,7 @@ OpenAI Codex compatibility for [Pi](https://github.com/earendil-works/pi-mono),
12
12
  - **Dedicated Codex tool UI**: renders `apply_patch`, `image_gen.imagegen`, and `web.run` on a shared configurable surface with compact summaries and `Ctrl+O` expansion.
13
13
  - **Hosted web-search fallback**: injects native `web_search` only when `web.run` is inactive, with cached, indexed, or live modes.
14
14
  - **Native request controls**: configures Responses API text verbosity, reasoning summaries, and GPT-5.6 standard/pro reasoning mode.
15
- - **Session-local settings pane**: `/codex-settings` changes every compatibility setting for the current session; `Ctrl+S` explicitly persists the current values.
15
+ - **Session-local settings pane**: `/codex-settings` changes every compatibility setting for the current session; `Enter` persists and closes, `Escape` discards unsaved changes and closes, and `Ctrl+S` persists without closing.
16
16
  - **Compact footer indicators**: non-default Codex request modes are appended to the model side of Pi's normal second footer line.
17
17
 
18
18
  Pi provides the Codex OAuth flow and model catalog. At session start, this package overrides the built-in `openai-codex` runtime under the same provider id so ordinary responses and remote compaction share one transport, parser, native-history store, and sticky WebSocket session.
@@ -143,7 +143,7 @@ A trusted project can override it at:
143
143
  <project>/.pi/openai-codex-compat.json
144
144
  ```
145
145
 
146
- Each session inherits the effective file-backed settings. Open `/codex-settings` to make immediate session-local changes. Press `Ctrl+S` in the pane to persist the current values; the global file is the normal save target, while an existing trusted project override remains the target for that project.
146
+ Each session inherits the effective file-backed settings. Open `/codex-settings` to make immediate session-local changes. Press `Enter` to persist and close, `Escape` to discard unsaved changes and close, or `Ctrl+S` to persist without closing. The global file is the normal save target, while an existing trusted project override remains the target for that project. After `Ctrl+S`, later unsaved changes can still be discarded back to the values from that save.
147
147
 
148
148
  The effective settings are printed once when a TUI session starts. The footer shows `fast` and `pro` only when enabled, and shows text verbosity or reasoning summary only when they differ from their defaults.
149
149
 
@@ -181,7 +181,36 @@ Defaults:
181
181
  | `reasoningSummary` | `auto`, `concise`, `detailed`, `off` | `auto` | Sets `reasoning.summary` when reasoning is enabled; `off` omits the summary parameter. |
182
182
  | `reasoningMode` | `standard`, `pro` | `standard` | Sets `reasoning.mode` on GPT-5.6 models independently of Pi's reasoning-effort control. |
183
183
 
184
- Invalid values are ignored and invalid JSON does not prevent Pi from starting. The settings pane never writes on ordinary changes, refuses to overwrite invalid JSON when `Ctrl+S` is pressed, and retains unknown keys when saving. Project configuration is read only when the project is trusted.
184
+ Invalid JSON setting values are ignored and invalid JSON does not prevent Pi from starting. The settings pane never writes on ordinary changes, refuses to overwrite invalid JSON when `Enter` or `Ctrl+S` attempts to save, and retains unknown keys when saving. Project configuration is read only when the project is trusted.
185
+
186
+ Every setting can also be overridden for one Pi process with an environment variable:
187
+
188
+ | Setting | Environment variable |
189
+ | ---------------------- | ------------------------------------------------ |
190
+ | `fastMode` | `PI_OPENAI_CODEX_COMPAT_FAST_MODE` |
191
+ | `toolBackground` | `PI_OPENAI_CODEX_COMPAT_TOOL_BACKGROUND` |
192
+ | `applyPatch` | `PI_OPENAI_CODEX_COMPAT_APPLY_PATCH` |
193
+ | `imageGeneration` | `PI_OPENAI_CODEX_COMPAT_IMAGE_GENERATION` |
194
+ | `imageDetail` | `PI_OPENAI_CODEX_COMPAT_IMAGE_DETAIL` |
195
+ | `webRun` | `PI_OPENAI_CODEX_COMPAT_WEB_RUN` |
196
+ | `autoCompactAtPercent` | `PI_OPENAI_CODEX_COMPAT_AUTO_COMPACT_AT_PERCENT` |
197
+ | `webSearch` | `PI_OPENAI_CODEX_COMPAT_WEB_SEARCH_MODE` |
198
+ | `textVerbosity` | `PI_OPENAI_CODEX_COMPAT_TEXT_VERBOSITY` |
199
+ | `reasoningSummary` | `PI_OPENAI_CODEX_COMPAT_REASONING_SUMMARY` |
200
+ | `reasoningMode` | `PI_OPENAI_CODEX_COMPAT_REASONING_MODE` |
201
+
202
+ Environment variables have the highest precedence: defaults < global JSON < trusted-project JSON < environment. Boolean values accept `true`/`false`, `1`/`0`, `on`/`off`, or `enabled`/`disabled`. Other settings use the values in the defaults table; `PI_OPENAI_CODEX_COMPAT_AUTO_COMPACT_AT_PERCENT=off` and `PI_OPENAI_CODEX_COMPAT_AUTO_COMPACT_AT_PERCENT=default` explicitly select Pi's default compaction lifecycle.
203
+
204
+ Environment-controlled rows are marked `(env)` and locked in `/codex-settings`. Saving the pane does not copy their effective values into JSON, so CLI overrides remain transient. Invalid environment values fail fast with the variable name and accepted values.
205
+
206
+ For example:
207
+
208
+ ```bash
209
+ PI_OPENAI_CODEX_COMPAT_WEB_RUN=off \
210
+ PI_OPENAI_CODEX_COMPAT_IMAGE_DETAIL=high \
211
+ PI_OPENAI_CODEX_COMPAT_AUTO_COMPACT_AT_PERCENT=90 \
212
+ pi
213
+ ```
185
214
 
186
215
  ## Native compaction
187
216
 
@@ -262,7 +291,7 @@ The tool generates new images with `gpt-image-2` or edits up to five local/recen
262
291
  ~/.pi/agent/generated_images/<session-id>/<call-id>.png
263
292
  ```
264
293
 
265
- The active Pi agent directory replaces `~/.pi/agent` when configured differently. Turning `imageGeneration` off removes the tool immediately for the current session; `Ctrl+S` in `/codex-settings` persists the value.
294
+ The active Pi agent directory replaces `~/.pi/agent` when configured differently. Turning `imageGeneration` off removes the tool immediately for the current session; `Enter` or `Ctrl+S` in `/codex-settings` persists the value.
266
295
 
267
296
  Pi also persists the returned image content in tool-result history so later image edits and provider replay remain self-contained. Generated-image turns therefore increase the session file by approximately the base64 image size in addition to the saved PNG artifact.
268
297
 
@@ -11,6 +11,8 @@ export type ReasoningMode = "standard" | "pro";
11
11
  export type ImageDetail = "auto" | "low" | "high" | "original";
12
12
  export type CodexToolBackground = "subtle" | "status" | "none";
13
13
 
14
+ export const ENV_PREFIX = "PI_OPENAI_CODEX_COMPAT_";
15
+
14
16
  export interface CodexCompatConfig {
15
17
  /** Send OpenAI Codex requests through the priority service tier. */
16
18
  fastMode: boolean;
@@ -36,6 +38,20 @@ export interface CodexCompatConfig {
36
38
  reasoningMode: ReasoningMode;
37
39
  }
38
40
 
41
+ export const CONFIG_ENVIRONMENT_VARIABLES = {
42
+ fastMode: `${ENV_PREFIX}FAST_MODE`,
43
+ applyPatch: `${ENV_PREFIX}APPLY_PATCH`,
44
+ toolBackground: `${ENV_PREFIX}TOOL_BACKGROUND`,
45
+ imageGeneration: `${ENV_PREFIX}IMAGE_GENERATION`,
46
+ imageDetail: `${ENV_PREFIX}IMAGE_DETAIL`,
47
+ webRun: `${ENV_PREFIX}WEB_RUN`,
48
+ autoCompactAtPercent: `${ENV_PREFIX}AUTO_COMPACT_AT_PERCENT`,
49
+ webSearch: `${ENV_PREFIX}WEB_SEARCH_MODE`,
50
+ textVerbosity: `${ENV_PREFIX}TEXT_VERBOSITY`,
51
+ reasoningSummary: `${ENV_PREFIX}REASONING_SUMMARY`,
52
+ reasoningMode: `${ENV_PREFIX}REASONING_MODE`,
53
+ } as const satisfies Record<keyof CodexCompatConfig, string>;
54
+
39
55
  export type ConfigLayer = {
40
56
  fastMode?: boolean;
41
57
  applyPatch?: boolean;
@@ -71,10 +87,136 @@ const REASONING_MODES = new Set<ReasoningMode>(["standard", "pro"]);
71
87
  const IMAGE_DETAILS = new Set<ImageDetail>(["auto", "low", "high", "original"]);
72
88
  const CODEX_TOOL_BACKGROUNDS = new Set<CodexToolBackground>(["subtle", "status", "none"]);
73
89
 
90
+ type Environment = Readonly<Record<string, string | undefined>>;
91
+
74
92
  function isRecord(value: unknown): value is Record<string, unknown> {
75
93
  return value !== null && typeof value === "object" && !Array.isArray(value);
76
94
  }
77
95
 
96
+ function invalidEnvironmentValue(name: string, value: string, expected: string): never {
97
+ throw new Error(`Invalid ${name}=${JSON.stringify(value)}; expected ${expected}`);
98
+ }
99
+
100
+ function environmentBoolean(environment: Environment, name: string): boolean | undefined {
101
+ const raw = environment[name];
102
+ if (raw === undefined) return undefined;
103
+
104
+ switch (raw.trim().toLowerCase()) {
105
+ case "1":
106
+ case "enabled":
107
+ case "on":
108
+ case "true":
109
+ return true;
110
+ case "0":
111
+ case "disabled":
112
+ case "off":
113
+ case "false":
114
+ return false;
115
+ default:
116
+ return invalidEnvironmentValue(name, raw, "true, false, 1, 0, on, off, enabled, or disabled");
117
+ }
118
+ }
119
+
120
+ function environmentEnum<T extends string>(
121
+ environment: Environment,
122
+ name: string,
123
+ values: ReadonlySet<T>,
124
+ ): T | undefined {
125
+ const raw = environment[name];
126
+ if (raw === undefined) return undefined;
127
+
128
+ const value = raw.trim();
129
+ if (values.has(value as T)) return value as T;
130
+ return invalidEnvironmentValue(name, raw, [...values].join(", "));
131
+ }
132
+
133
+ /**
134
+ * Parse explicit process-level overrides. Unlike invalid JSON settings, invalid
135
+ * environment values fail fast so a CLI test cannot silently exercise a
136
+ * different configuration than requested.
137
+ */
138
+ export function parseEnvironmentConfig(environment: Environment = process.env): ConfigLayer {
139
+ const layer: ConfigLayer = {};
140
+
141
+ const fastMode = environmentBoolean(environment, CONFIG_ENVIRONMENT_VARIABLES.fastMode);
142
+ if (fastMode !== undefined) layer.fastMode = fastMode;
143
+
144
+ const applyPatch = environmentBoolean(environment, CONFIG_ENVIRONMENT_VARIABLES.applyPatch);
145
+ if (applyPatch !== undefined) layer.applyPatch = applyPatch;
146
+
147
+ const toolBackground = environmentEnum(
148
+ environment,
149
+ CONFIG_ENVIRONMENT_VARIABLES.toolBackground,
150
+ CODEX_TOOL_BACKGROUNDS,
151
+ );
152
+ if (toolBackground !== undefined) layer.toolBackground = toolBackground;
153
+
154
+ const imageGeneration = environmentBoolean(
155
+ environment,
156
+ CONFIG_ENVIRONMENT_VARIABLES.imageGeneration,
157
+ );
158
+ if (imageGeneration !== undefined) layer.imageGeneration = imageGeneration;
159
+
160
+ const imageDetail = environmentEnum(
161
+ environment,
162
+ CONFIG_ENVIRONMENT_VARIABLES.imageDetail,
163
+ IMAGE_DETAILS,
164
+ );
165
+ if (imageDetail !== undefined) layer.imageDetail = imageDetail;
166
+
167
+ const webRun = environmentBoolean(environment, CONFIG_ENVIRONMENT_VARIABLES.webRun);
168
+ if (webRun !== undefined) layer.webRun = webRun;
169
+
170
+ const thresholdName = CONFIG_ENVIRONMENT_VARIABLES.autoCompactAtPercent;
171
+ const rawThreshold = environment[thresholdName];
172
+ if (rawThreshold !== undefined) {
173
+ const value = rawThreshold.trim();
174
+ if (value.toLowerCase() === "off" || value.toLowerCase() === "default") {
175
+ layer.autoCompactAtPercent = null;
176
+ } else {
177
+ const threshold = Number(value);
178
+ if (value.length === 0 || !Number.isFinite(threshold) || threshold <= 0 || threshold > 100) {
179
+ invalidEnvironmentValue(
180
+ thresholdName,
181
+ rawThreshold,
182
+ "a number greater than 0 and at most 100, off, or default",
183
+ );
184
+ }
185
+ layer.autoCompactAtPercent = threshold;
186
+ }
187
+ }
188
+
189
+ const webSearch = environmentEnum(
190
+ environment,
191
+ CONFIG_ENVIRONMENT_VARIABLES.webSearch,
192
+ WEB_SEARCH_MODES,
193
+ );
194
+ if (webSearch !== undefined) layer.webSearch = webSearch;
195
+
196
+ const textVerbosity = environmentEnum(
197
+ environment,
198
+ CONFIG_ENVIRONMENT_VARIABLES.textVerbosity,
199
+ TEXT_VERBOSITIES,
200
+ );
201
+ if (textVerbosity !== undefined) layer.textVerbosity = textVerbosity;
202
+
203
+ const reasoningSummary = environmentEnum(
204
+ environment,
205
+ CONFIG_ENVIRONMENT_VARIABLES.reasoningSummary,
206
+ REASONING_SUMMARIES,
207
+ );
208
+ if (reasoningSummary !== undefined) layer.reasoningSummary = reasoningSummary;
209
+
210
+ const reasoningMode = environmentEnum(
211
+ environment,
212
+ CONFIG_ENVIRONMENT_VARIABLES.reasoningMode,
213
+ REASONING_MODES,
214
+ );
215
+ if (reasoningMode !== undefined) layer.reasoningMode = reasoningMode;
216
+
217
+ return layer;
218
+ }
219
+
78
220
  export function parseConfig(value: unknown): ConfigLayer {
79
221
  if (!isRecord(value)) return {};
80
222
 
@@ -157,8 +299,9 @@ function readConfig(filePath: string): ConfigLayer {
157
299
  export function resolveConfig(
158
300
  globalConfig: ConfigLayer,
159
301
  projectConfig: ConfigLayer,
302
+ environmentConfig: ConfigLayer = {},
160
303
  ): CodexCompatConfig {
161
- const merged = { ...globalConfig, ...projectConfig };
304
+ const merged = { ...globalConfig, ...projectConfig, ...environmentConfig };
162
305
  return {
163
306
  ...DEFAULT_CONFIG,
164
307
  ...(typeof merged.fastMode === "boolean" ? { fastMode: merged.fastMode } : {}),
@@ -193,10 +336,14 @@ export function writableConfigPath(cwd: string, projectTrusted: boolean): string
193
336
  return projectTrusted && existsSync(projectPath) ? projectPath : globalConfigPath();
194
337
  }
195
338
 
196
- export function loadConfig(cwd: string, projectTrusted: boolean): CodexCompatConfig {
339
+ export function loadConfig(
340
+ cwd: string,
341
+ projectTrusted: boolean,
342
+ environment: Environment = process.env,
343
+ ): CodexCompatConfig {
197
344
  const globalConfig = readConfig(globalConfigPath());
198
345
  const projectConfig = projectTrusted ? readConfig(projectConfigPath(cwd)) : {};
199
- return resolveConfig(globalConfig, projectConfig);
346
+ return resolveConfig(globalConfig, projectConfig, parseEnvironmentConfig(environment));
200
347
  }
201
348
 
202
349
  export function configLayer(config: CodexCompatConfig): ConfigLayer {
@@ -215,6 +362,18 @@ export function configLayer(config: CodexCompatConfig): ConfigLayer {
215
362
  };
216
363
  }
217
364
 
365
+ /** Keep process-level overrides transient when an effective config is saved. */
366
+ export function withoutEnvironmentOverrides(
367
+ layer: ConfigLayer,
368
+ environmentConfig: ConfigLayer,
369
+ ): ConfigLayer {
370
+ const result = { ...layer };
371
+ for (const key of Object.keys(environmentConfig) as (keyof ConfigLayer)[]) {
372
+ delete result[key];
373
+ }
374
+ return result;
375
+ }
376
+
218
377
  async function readWritableConfig(filePath: string): Promise<Record<string, unknown>> {
219
378
  try {
220
379
  const value = JSON.parse(await readFile(filePath, "utf8")) as unknown;
@@ -30,7 +30,7 @@ function settingsSummary(ctx: ExtensionContext, config: CodexCompatConfig): stri
30
30
  `reasoning summary: ${config.reasoningSummary}`,
31
31
  `auto-compact threshold: ${config.autoCompactAtPercent ?? "Pi default"}`,
32
32
  `save target: ${writableConfigPath(ctx.cwd, ctx.isProjectTrusted())}`,
33
- "settings: /codex-settings (session-only until Ctrl+S)",
33
+ "settings: /codex-settings (Enter saves & closes; Esc discards; Ctrl+S saves)",
34
34
  ].join("\n");
35
35
  }
36
36
 
@@ -10,11 +10,15 @@ import {
10
10
  type SettingItem,
11
11
  SettingsList,
12
12
  Text,
13
+ truncateToWidth,
13
14
  } from "@earendil-works/pi-tui";
14
15
  import {
16
+ CONFIG_ENVIRONMENT_VARIABLES,
15
17
  configLayer,
16
18
  loadConfig,
19
+ parseEnvironmentConfig,
17
20
  saveConfig,
21
+ withoutEnvironmentOverrides,
18
22
  writableConfigPath,
19
23
  type CodexCompatConfig,
20
24
  type ConfigLayer,
@@ -48,8 +52,11 @@ function thresholdValue(value: number | undefined): string {
48
52
  return value === undefined ? "Pi default" : `${value}%`;
49
53
  }
50
54
 
51
- export function settingItems(config: CodexCompatConfig): SettingItem[] {
52
- return [
55
+ export function settingItems(
56
+ config: CodexCompatConfig,
57
+ environmentConfig: ConfigLayer = {},
58
+ ): SettingItem[] {
59
+ const items: SettingItem[] = [
53
60
  {
54
61
  id: "fastMode",
55
62
  label: "Fast mode",
@@ -130,6 +137,18 @@ export function settingItems(config: CodexCompatConfig): SettingItem[] {
130
137
  values: ["Pi default", "75%", "80%", "85%", "90%", "95%"],
131
138
  },
132
139
  ];
140
+
141
+ return items.map((item) => {
142
+ const id = item.id as SettingId;
143
+ if (!Object.hasOwn(environmentConfig, id)) return item;
144
+
145
+ const variable = CONFIG_ENVIRONMENT_VARIABLES[id];
146
+ const lockedItem = { ...item };
147
+ lockedItem.currentValue = `${item.currentValue} (env)`;
148
+ lockedItem.description = `${item.description} Locked by ${variable}.`;
149
+ delete lockedItem.values;
150
+ return lockedItem;
151
+ });
133
152
  }
134
153
 
135
154
  export function settingPatch(id: string, value: string): ConfigLayer | undefined {
@@ -212,18 +231,21 @@ async function showSettings(
212
231
  }
213
232
 
214
233
  let config = callbacks.getConfig?.(ctx) ?? loadConfig(ctx.cwd, ctx.isProjectTrusted());
234
+ let persistedConfig = { ...config };
215
235
  let revision = 0;
216
236
  let savedRevision = 0;
217
237
  let saveQueue = Promise.resolve();
218
238
  const filePath = writableConfigPath(ctx.cwd, ctx.isProjectTrusted());
239
+ const environmentConfig = parseEnvironmentConfig();
219
240
 
220
- await ctx.ui.custom((tui, theme, _keybindings, done) => {
241
+ await ctx.ui.custom((tui, theme, keybindings, done) => {
242
+ let closing = false;
221
243
  const container = new Container();
222
244
  container.addChild(
223
245
  new Text(theme.fg("accent", theme.bold("OpenAI Codex Compatibility Settings")), 1, 1),
224
246
  );
225
247
  container.addChild(
226
- new Text(theme.fg("dim", `Session-only. Ctrl+S saves to ${filePath}`), 1, 0),
248
+ new Text(theme.fg("dim", `Ctrl+S saves without closing to ${filePath}`), 1, 0),
227
249
  );
228
250
  const saveStatus = new Text(
229
251
  theme.fg("dim", "Changes apply to this session immediately."),
@@ -232,10 +254,61 @@ async function showSettings(
232
254
  );
233
255
  container.addChild(saveStatus);
234
256
 
257
+ const queueSave = (closeAfterSave: boolean): void => {
258
+ if (closing) return;
259
+ if (closeAfterSave) closing = true;
260
+
261
+ const snapshotConfig = { ...config };
262
+ const snapshot = withoutEnvironmentOverrides(configLayer(snapshotConfig), environmentConfig);
263
+ const snapshotRevision = revision;
264
+ saveStatus.setText(theme.fg("dim", closeAfterSave ? "Saving and closing…" : "Saving…"));
265
+ saveQueue = saveQueue.then(async () => {
266
+ try {
267
+ const savedPath = await saveConfig(ctx.cwd, ctx.isProjectTrusted(), snapshot);
268
+ if (snapshotRevision >= savedRevision) {
269
+ persistedConfig = snapshotConfig;
270
+ savedRevision = snapshotRevision;
271
+ }
272
+ if (closeAfterSave) {
273
+ done(undefined);
274
+ return;
275
+ }
276
+ saveStatus.setText(
277
+ savedRevision === revision
278
+ ? theme.fg("success", `Saved to ${savedPath}`)
279
+ : theme.fg("warning", "Unsaved session changes."),
280
+ );
281
+ } catch (error) {
282
+ if (closeAfterSave) closing = false;
283
+ const message = error instanceof Error ? error.message : String(error);
284
+ saveStatus.setText(theme.fg("error", message));
285
+ ctx.ui.notify(message, "error");
286
+ }
287
+ tui.requestRender();
288
+ });
289
+ tui.requestRender();
290
+ };
291
+
292
+ const discardAndClose = (): void => {
293
+ if (closing) return;
294
+ closing = true;
295
+ saveStatus.setText(theme.fg("dim", "Discarding unsaved changes…"));
296
+ saveQueue = saveQueue.then(() => {
297
+ if (revision !== savedRevision) {
298
+ config = { ...persistedConfig };
299
+ revision = savedRevision;
300
+ callbacks.onChange?.(config, ctx);
301
+ }
302
+ done(undefined);
303
+ });
304
+ tui.requestRender();
305
+ };
306
+
307
+ const listTheme = getSettingsListTheme();
235
308
  const list = new SettingsList(
236
- settingItems(config),
309
+ settingItems(config, environmentConfig),
237
310
  12,
238
- getSettingsListTheme(),
311
+ listTheme,
239
312
  (id, value) => {
240
313
  const patch = settingPatch(id, value);
241
314
  if (!patch) return;
@@ -246,36 +319,42 @@ async function showSettings(
246
319
  saveStatus.setText(theme.fg("warning", "Unsaved session changes."));
247
320
  tui.requestRender();
248
321
  },
249
- () => done(undefined),
322
+ discardAndClose,
250
323
  { enableSearch: true },
251
324
  );
252
- container.addChild(list);
325
+ container.addChild({
326
+ render: (width: number) => {
327
+ const lines = list.render(width);
328
+ if (lines.length > 0) {
329
+ lines[lines.length - 1] = truncateToWidth(
330
+ listTheme.hint(
331
+ " Type to search · Space changes · Enter save & close · Esc discard & close",
332
+ ),
333
+ width,
334
+ "",
335
+ );
336
+ }
337
+ return lines;
338
+ },
339
+ invalidate: () => list.invalidate(),
340
+ handleInput: (data: string) => list.handleInput(data),
341
+ });
253
342
 
254
343
  return {
255
344
  render: (width: number) => container.render(width),
256
345
  invalidate: () => container.invalidate(),
257
346
  handleInput: (data: string) => {
347
+ if (closing) return;
258
348
  if (matchesKey(data, Key.ctrl("s"))) {
259
- const snapshot = configLayer(config);
260
- const snapshotRevision = revision;
261
- saveStatus.setText(theme.fg("dim", "Saving…"));
262
- saveQueue = saveQueue.then(async () => {
263
- try {
264
- const savedPath = await saveConfig(ctx.cwd, ctx.isProjectTrusted(), snapshot);
265
- savedRevision = Math.max(savedRevision, snapshotRevision);
266
- saveStatus.setText(
267
- savedRevision === revision
268
- ? theme.fg("success", `Saved to ${savedPath}`)
269
- : theme.fg("warning", "Unsaved session changes."),
270
- );
271
- } catch (error) {
272
- const message = error instanceof Error ? error.message : String(error);
273
- saveStatus.setText(theme.fg("error", message));
274
- ctx.ui.notify(message, "error");
275
- }
276
- tui.requestRender();
277
- });
278
- tui.requestRender();
349
+ queueSave(false);
350
+ return;
351
+ }
352
+ if (matchesKey(data, Key.enter)) {
353
+ queueSave(true);
354
+ return;
355
+ }
356
+ if (matchesKey(data, Key.escape) || keybindings.matches(data, "tui.select.cancel")) {
357
+ discardAndClose();
279
358
  return;
280
359
  }
281
360
  list.handleInput(data);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-codex-compat",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "OpenAI Codex compatibility for Pi with native compaction, fast mode, and Codex-optimized capabilities",
5
5
  "keywords": [
6
6
  "pi-package"