pi-better-openai 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,8 @@ A pi extension for OpenAI subscription workflows: fast mode, usage visibility, f
4
4
 
5
5
  ## Install
6
6
 
7
+ Requires Node.js 22.19.0 or newer.
8
+
7
9
  Install from GitHub:
8
10
 
9
11
  ```bash
@@ -52,7 +54,15 @@ Project overrides global. Global values fill fields omitted by the project file.
52
54
  Default supported models:
53
55
 
54
56
  ```json
55
- ["openai/gpt-5.4", "openai/gpt-5.5", "openai-codex/gpt-5.4", "openai-codex/gpt-5.5"]
57
+ [
58
+ "openai/gpt-5.4",
59
+ "openai/gpt-5.5",
60
+ "openai-codex/gpt-5.6-sol",
61
+ "openai-codex/gpt-5.6-terra",
62
+ "openai-codex/gpt-5.6-luna",
63
+ "openai-codex/gpt-5.4",
64
+ "openai-codex/gpt-5.5"
65
+ ]
56
66
  ```
57
67
 
58
68
  Example config:
@@ -146,7 +156,7 @@ Then reload Codex skills (`Cmd/Ctrl+K → Force Reload Skills`) and ask:
146
156
  $hatch-pet create a new pet inspired by pi-better-openai
147
157
  ```
148
158
 
149
- Custom pets should end up in `${CODEX_HOME:-~/.codex}/pets/<pet-name>/` with `pet.json` and `spritesheet.webp`. Refresh custom pets in Codex settings and toggle the overlay with `/pet`.
159
+ Custom pets should end up in `${CODEX_HOME:-~/.codex}/pets/<pet-name>/` with `pet.json` and `spritesheet.webp`. The spritesheet must be a 1536×1872 atlas arranged as 8 columns by 9 animation rows. Animated footer rendering also requires a terminal image protocol supported by pi. Refresh custom pets in Codex settings and toggle the overlay with `/pet`.
150
160
 
151
161
  ## Screenshots
152
162
 
package/index.ts CHANGED
@@ -4,7 +4,6 @@
4
4
  * Adds `service_tier: "priority"` to OpenAI provider payloads while fast mode is
5
5
  * enabled and the selected model is in the configured allow-list.
6
6
  */
7
- import { sep } from "node:path";
8
7
  import {
9
8
  getSettingsListTheme,
10
9
  type ExtensionAPI,
@@ -33,7 +32,6 @@ import {
33
32
  type SettingsOptionDescriptor,
34
33
  configPaths,
35
34
  type ResolvedConfig,
36
- type PetPlacement,
37
35
  type PetState,
38
36
  isRecord,
39
37
  parseModelKey,
@@ -65,6 +63,13 @@ import {
65
63
  import { FastController, modelList, supportsFast } from "./src/fast-controller.ts";
66
64
  import { UsageController } from "./src/usage-controller.ts";
67
65
  import { PetFooterController } from "./src/pet-footer-controller.ts";
66
+ import {
67
+ abbreviateHomePath,
68
+ combineInlinePetFooter,
69
+ isInlinePetPlacement,
70
+ isTerminalImageLine,
71
+ petSizeCellsForPlacement,
72
+ } from "./src/footer-layout.ts";
68
73
 
69
74
  const COMMAND = "fast";
70
75
  const OPENAI_STATUS_COMMAND = "openai-usage";
@@ -84,28 +89,10 @@ type SettingsPickerItem = {
84
89
  ) => { render(width: number): string[]; invalidate(): void; handleInput?(data: string): void };
85
90
  };
86
91
 
87
- function abbreviateHomePath(
88
- path: string,
89
- home = process.env.HOME || process.env.USERPROFILE,
90
- ): string {
91
- if (!home) return path;
92
- if (path === home) return "~";
93
- const homePrefix = home.endsWith(sep) ? home : `${home}${sep}`;
94
- return path.startsWith(homePrefix) ? `~/${path.slice(homePrefix.length)}` : path;
95
- }
96
-
97
- function isInlinePetPlacement(placement: PetPlacement): boolean {
98
- return placement === "inline-left" || placement === "inline-right" || placement === "badge";
99
- }
100
-
101
92
  function hasTerminalUI(ctx: ExtensionContext): boolean {
102
93
  return ctx.mode === "tui" || (ctx.mode === undefined && ctx.hasUI);
103
94
  }
104
95
 
105
- function petSizeCellsForPlacement(placement: PetPlacement, sizeCells: number): number {
106
- return placement === "badge" ? Math.min(6, sizeCells) : sizeCells;
107
- }
108
-
109
96
  function readyPetPickerValues(pets: CodexPetPackage[]): string[] | undefined {
110
97
  const readyPets = pets.filter((pet) => pet.hasSpritesheet);
111
98
  return readyPets.length > 0 ? readyPets.map((pet) => pet.slug) : undefined;
@@ -131,8 +118,8 @@ function petPickerDescription(cfg: ResolvedConfig, pets: CodexPetPackage[]): str
131
118
  if (readyPets.length === 0)
132
119
  return "No ready custom pets found. Use /pets help to create one, then return here.";
133
120
  if (!cfg.pets.slug) {
134
- const firstPet = readyPets[0];
135
- const lastPet = readyPets[readyPets.length - 1];
121
+ const firstPet = readyPets[0]!;
122
+ const lastPet = readyPets[readyPets.length - 1]!;
136
123
  return `No pet selected. Enter/Space/→ selects ${firstPet.name}; ← selects ${lastPet.name}.`;
137
124
  }
138
125
  const selectedPet = findPickerPet(cfg.pets.slug, readyPets);
@@ -169,84 +156,6 @@ function formatPetSelectPrompt(
169
156
  return { message: lines.join("\n"), level: "info" };
170
157
  }
171
158
 
172
- function spaces(width: number): string {
173
- return " ".repeat(Math.max(0, width));
174
- }
175
-
176
- function padTextToWidth(value: string, width: number): string {
177
- return value + spaces(width - visibleWidth(value));
178
- }
179
-
180
- function isTerminalImageLine(line: string): boolean {
181
- return line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
182
- }
183
-
184
- function petLineCell(line: string, width: number): string {
185
- if (!line) return spaces(width);
186
- if (isTerminalImageLine(line)) return `\x1b[0m${line}`;
187
- const clipped = truncateToWidth(line, width, "");
188
- return clipped + spaces(width - visibleWidth(clipped));
189
- }
190
-
191
- function stripLeadingCursorUp(line: string): string {
192
- if (!line.startsWith("\x1b[")) return line;
193
- const end = line.indexOf("A", 2);
194
- if (end === -1) return line;
195
- return /^\d+$/.test(line.slice(2, end)) ? line.slice(end + 1) : line;
196
- }
197
-
198
- function stripTrailingCursorDown(line: string): string {
199
- if (!line.endsWith("B")) return line;
200
- const start = line.lastIndexOf("\x1b[");
201
- if (start === -1) return line;
202
- return /^\d+$/.test(line.slice(start + 2, -1)) ? line.slice(0, start) : line;
203
- }
204
-
205
- function terminalImageInlineLeftSequence(line: string, totalRows: number): string {
206
- const moveUp = totalRows > 1 ? `\x1b[${totalRows - 1}A` : "";
207
- const moveDown = totalRows > 1 ? `\x1b[${totalRows - 1}B` : "";
208
- const balancedLine = stripTrailingCursorDown(stripLeadingCursorUp(line));
209
- return `\x1b[0m\r${moveUp}${balancedLine}${moveDown}`;
210
- }
211
-
212
- function combineInlinePetFooter(
213
- petLines: string[],
214
- textLines: string[],
215
- width: number,
216
- placement: PetPlacement,
217
- petWidth: number,
218
- ): string[] {
219
- const gap = 2;
220
- const textWidth = Math.max(1, width - petWidth - gap);
221
- const totalRows = Math.max(petLines.length, textLines.length);
222
- const petStart = 0;
223
- const textStart = 0;
224
- const hasTerminalImageLine = petLines.some(isTerminalImageLine);
225
- const leftImageLine =
226
- placement === "inline-left" ? petLines.find(isTerminalImageLine) : undefined;
227
- const renderPetOnRight =
228
- placement === "inline-right" || (placement !== "inline-left" && hasTerminalImageLine);
229
- const lines: string[] = [];
230
-
231
- for (let row = 0; row < totalRows; row++) {
232
- const petLine = row >= petStart ? (petLines[row - petStart] ?? "") : "";
233
- const textLine = row >= textStart ? (textLines[row - textStart] ?? "") : "";
234
- const textPart = truncateToWidth(textLine, textWidth, "...");
235
- const petPart = leftImageLine ? spaces(petWidth) : petLineCell(petLine, petWidth);
236
- if (renderPetOnRight) {
237
- lines.push(`${padTextToWidth(textPart, textWidth)}${spaces(gap)}${petPart}`);
238
- } else {
239
- lines.push(`${petPart}${spaces(gap)}${textPart}`);
240
- }
241
- }
242
-
243
- if (leftImageLine && lines.length > 0) {
244
- lines[lines.length - 1] += terminalImageInlineLeftSequence(leftImageLine, totalRows);
245
- }
246
-
247
- return lines;
248
- }
249
-
250
159
  function textPanel(title: string, lines: string[], done: () => void) {
251
160
  return {
252
161
  render(width: number) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-openai",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Personal pi extension that improves OpenAI with fast mode, usage stats, and footer polish.",
5
5
  "keywords": [
6
6
  "fast",
@@ -43,7 +43,7 @@
43
43
  "devDependencies": {
44
44
  "@earendil-works/pi-coding-agent": "0.80.6",
45
45
  "@earendil-works/pi-tui": "0.80.6",
46
- "@types/node": "^24.13.2",
46
+ "@types/node": "~22.19.0",
47
47
  "oxfmt": "^0.47.0",
48
48
  "oxlint": "^1.62.0",
49
49
  "typescript": "^6.0.3",
package/src/config.ts CHANGED
@@ -29,6 +29,9 @@ export const PET_STATES = [
29
29
  export const DEFAULT_SUPPORTED_MODELS = [
30
30
  "openai/gpt-5.4",
31
31
  "openai/gpt-5.5",
32
+ "openai-codex/gpt-5.6-sol",
33
+ "openai-codex/gpt-5.6-terra",
34
+ "openai-codex/gpt-5.6-luna",
32
35
  "openai-codex/gpt-5.4",
33
36
  "openai-codex/gpt-5.5",
34
37
  ] as const;
@@ -40,8 +40,11 @@ export class FastController {
40
40
  private lastInjectedAt: number | undefined;
41
41
  private lastInjectedModel: string | undefined;
42
42
  private lastInjectedTier: string | undefined;
43
+ private readonly serviceTier: string;
43
44
 
44
- constructor(private readonly serviceTier: string) {}
45
+ constructor(serviceTier: string) {
46
+ this.serviceTier = serviceTier;
47
+ }
45
48
 
46
49
  applyDesiredState(ctx: ExtensionContext, cfg: ResolvedConfig): void {
47
50
  this.active = this.desiredActive && supportsFast(ctx, cfg.supportedModels);
@@ -0,0 +1,97 @@
1
+ import { sep } from "node:path";
2
+ import type { PetPlacement } from "./config.ts";
3
+ import { truncateToWidth, visibleWidth } from "./format.ts";
4
+
5
+ export function abbreviateHomePath(
6
+ path: string,
7
+ home = process.env.HOME || process.env.USERPROFILE,
8
+ ): string {
9
+ if (!home) return path;
10
+ if (path === home) return "~";
11
+ const homePrefix = home.endsWith(sep) ? home : `${home}${sep}`;
12
+ return path.startsWith(homePrefix) ? `~/${path.slice(homePrefix.length)}` : path;
13
+ }
14
+
15
+ export function isInlinePetPlacement(placement: PetPlacement): boolean {
16
+ return placement === "inline-left" || placement === "inline-right" || placement === "badge";
17
+ }
18
+
19
+ export function petSizeCellsForPlacement(placement: PetPlacement, sizeCells: number): number {
20
+ return placement === "badge" ? Math.min(6, sizeCells) : sizeCells;
21
+ }
22
+
23
+ function spaces(width: number): string {
24
+ return " ".repeat(Math.max(0, width));
25
+ }
26
+
27
+ function padTextToWidth(value: string, width: number): string {
28
+ return value + spaces(width - visibleWidth(value));
29
+ }
30
+
31
+ export function isTerminalImageLine(line: string): boolean {
32
+ return line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
33
+ }
34
+
35
+ function petLineCell(line: string, width: number): string {
36
+ if (!line) return spaces(width);
37
+ if (isTerminalImageLine(line)) return `\x1b[0m${line}`;
38
+ const clipped = truncateToWidth(line, width, "");
39
+ return clipped + spaces(width - visibleWidth(clipped));
40
+ }
41
+
42
+ function stripLeadingCursorUp(line: string): string {
43
+ if (!line.startsWith("\x1b[")) return line;
44
+ const end = line.indexOf("A", 2);
45
+ if (end === -1) return line;
46
+ return /^\d+$/.test(line.slice(2, end)) ? line.slice(end + 1) : line;
47
+ }
48
+
49
+ function stripTrailingCursorDown(line: string): string {
50
+ if (!line.endsWith("B")) return line;
51
+ const start = line.lastIndexOf("\x1b[");
52
+ if (start === -1) return line;
53
+ return /^\d+$/.test(line.slice(start + 2, -1)) ? line.slice(0, start) : line;
54
+ }
55
+
56
+ function terminalImageInlineLeftSequence(line: string, totalRows: number): string {
57
+ const moveUp = totalRows > 1 ? `\x1b[${totalRows - 1}A` : "";
58
+ const moveDown = totalRows > 1 ? `\x1b[${totalRows - 1}B` : "";
59
+ const balancedLine = stripTrailingCursorDown(stripLeadingCursorUp(line));
60
+ return `\x1b[0m\r${moveUp}${balancedLine}${moveDown}`;
61
+ }
62
+
63
+ export function combineInlinePetFooter(
64
+ petLines: string[],
65
+ textLines: string[],
66
+ width: number,
67
+ placement: PetPlacement,
68
+ petWidth: number,
69
+ ): string[] {
70
+ const gap = 2;
71
+ const textWidth = Math.max(1, width - petWidth - gap);
72
+ const totalRows = Math.max(petLines.length, textLines.length);
73
+ const hasTerminalImageLine = petLines.some(isTerminalImageLine);
74
+ const leftImageLine =
75
+ placement === "inline-left" ? petLines.find(isTerminalImageLine) : undefined;
76
+ const renderPetOnRight =
77
+ placement === "inline-right" || (placement !== "inline-left" && hasTerminalImageLine);
78
+ const lines: string[] = [];
79
+
80
+ for (let row = 0; row < totalRows; row++) {
81
+ const petLine = petLines[row] ?? "";
82
+ const textLine = textLines[row] ?? "";
83
+ const textPart = truncateToWidth(textLine, textWidth, "...");
84
+ const petPart = leftImageLine ? spaces(petWidth) : petLineCell(petLine, petWidth);
85
+ if (renderPetOnRight) {
86
+ lines.push(`${padTextToWidth(textPart, textWidth)}${spaces(gap)}${petPart}`);
87
+ } else {
88
+ lines.push(`${petPart}${spaces(gap)}${textPart}`);
89
+ }
90
+ }
91
+
92
+ if (leftImageLine && lines.length > 0) {
93
+ lines[lines.length - 1] += terminalImageInlineLeftSequence(leftImageLine, totalRows);
94
+ }
95
+
96
+ return lines;
97
+ }
package/src/image.ts CHANGED
@@ -296,7 +296,11 @@ function buildRequest(
296
296
 
297
297
  function dataUrlParts(value: string, fallbackMimeType: string): { data: string; mimeType: string } {
298
298
  const match = value.match(/^data:([^;,]+);base64,(.*)$/s);
299
- if (match) return { mimeType: match[1] || fallbackMimeType, data: match[2].trim() };
299
+ if (match)
300
+ return {
301
+ mimeType: match[1] || fallbackMimeType,
302
+ data: (match[2] ?? "").trim(),
303
+ };
300
304
  return { data: value.trim(), mimeType: fallbackMimeType };
301
305
  }
302
306
 
@@ -117,12 +117,19 @@ export class PetFooterController {
117
117
  private requestFooterRender: (() => void) | undefined;
118
118
  private requestSettingsRender: (() => void) | undefined;
119
119
  private shuttingDown = false;
120
+ private readonly getConfig: (ctx: ExtensionContext) => ResolvedConfig;
121
+ private readonly updateFooter: (ctx: ExtensionContext) => void;
122
+ private readonly getFooterInstalled: () => boolean;
120
123
 
121
124
  constructor(
122
- private readonly getConfig: (ctx: ExtensionContext) => ResolvedConfig,
123
- private readonly updateFooter: (ctx: ExtensionContext) => void,
124
- private readonly getFooterInstalled: () => boolean = () => false,
125
- ) {}
125
+ getConfig: (ctx: ExtensionContext) => ResolvedConfig,
126
+ updateFooter: (ctx: ExtensionContext) => void,
127
+ getFooterInstalled: () => boolean = () => false,
128
+ ) {
129
+ this.getConfig = getConfig;
130
+ this.updateFooter = updateFooter;
131
+ this.getFooterInstalled = getFooterInstalled;
132
+ }
126
133
 
127
134
  get loadedPet(): LoadedCodexPet | undefined {
128
135
  return this.pet;
@@ -239,6 +246,14 @@ export class PetFooterController {
239
246
  }
240
247
  }
241
248
 
249
+ private recallRender(key: string): string[] | undefined {
250
+ const lines = this.petRenderCache.get(key);
251
+ if (!lines) return undefined;
252
+ this.petRenderCache.delete(key);
253
+ this.petRenderCache.set(key, lines);
254
+ return lines;
255
+ }
256
+
242
257
  isResizeFrozen(now = Date.now()): boolean {
243
258
  return now < this.petResizeFreezeUntil;
244
259
  }
@@ -568,7 +583,7 @@ export class PetFooterController {
568
583
  options.petColumnWidth,
569
584
  options.petRenderSizeCells,
570
585
  );
571
- const cachedPetLines = this.petRenderCache.get(cacheKey);
586
+ const cachedPetLines = this.recallRender(cacheKey);
572
587
  if (cachedPetLines) {
573
588
  petLines.push(...cachedPetLines);
574
589
  return petLines;
package/src/pets.ts CHANGED
@@ -276,7 +276,8 @@ async function readCodexPetPackages(
276
276
  const readNext = async (): Promise<void> => {
277
277
  while (nextIndex < entries.length) {
278
278
  const index = nextIndex++;
279
- pets[index] = await readCodexPetPackage(petsDir, entries[index], options);
279
+ const entry = entries[index];
280
+ if (entry) pets[index] = await readCodexPetPackage(petsDir, entry, options);
280
281
  }
281
282
  };
282
283
  await Promise.all(
@@ -622,7 +623,7 @@ export function nextAnimationFrameDelayMs(
622
623
  if (cursor < duration) return Math.max(1, Math.ceil(duration - cursor));
623
624
  cursor -= duration;
624
625
  }
625
- return Math.max(1, Math.ceil(frames[0].durationMs));
626
+ return Math.max(1, Math.ceil(frames[0]!.durationMs));
626
627
  }
627
628
 
628
629
  function forEachCodexPetFrame(
@@ -691,8 +692,11 @@ function encodeKittyRawRgba(frame: PetFrame, imageId: number): string {
691
692
  export class CodexPetKittyManager {
692
693
  private previousFrameImageId: number | undefined;
693
694
  private readonly pendingCleanupImageIds = new Set<number>();
695
+ readonly placementImageId: number;
694
696
 
695
- constructor(readonly placementImageId: number) {}
697
+ constructor(placementImageId: number) {
698
+ this.placementImageId = placementImageId;
699
+ }
696
700
 
697
701
  queueCleanup(pet?: LoadedCodexPet): void {
698
702
  for (const imageId of codexPetKittyImageIds(pet)) this.pendingCleanupImageIds.add(imageId);
@@ -55,11 +55,16 @@ export class UsageController {
55
55
  private sessionAbortSignal: AbortSignal | undefined;
56
56
  private sessionAbortHandler: (() => void) | undefined;
57
57
  private sessionGeneration = 0;
58
+ private readonly getConfig: (ctx: ExtensionContext) => ResolvedConfig;
59
+ private readonly updateFooter: (ctx: ExtensionContext) => void;
58
60
 
59
61
  constructor(
60
- private readonly getConfig: (ctx: ExtensionContext) => ResolvedConfig,
61
- private readonly updateFooter: (ctx: ExtensionContext) => void,
62
- ) {}
62
+ getConfig: (ctx: ExtensionContext) => ResolvedConfig,
63
+ updateFooter: (ctx: ExtensionContext) => void,
64
+ ) {
65
+ this.getConfig = getConfig;
66
+ this.updateFooter = updateFooter;
67
+ }
63
68
 
64
69
  get snapshot(): UsageSnapshot | undefined {
65
70
  return this.usageSnapshot;