pi-better-openai 0.1.19 → 0.1.21

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/src/format.ts CHANGED
@@ -1,8 +1,25 @@
1
+ import {
2
+ truncateToWidth as truncateTerminalText,
3
+ visibleWidth as terminalVisibleWidth,
4
+ } from "@earendil-works/pi-tui";
5
+
1
6
  const ANSI_ESCAPE_PATTERN = String.raw`\u001B\[[0-?]*[ -/]*[@-~]`;
2
7
  const ANSI_ESCAPE_REGEXP = new RegExp(ANSI_ESCAPE_PATTERN, "g");
3
- const LEADING_ANSI_ESCAPE_REGEXP = new RegExp(`^(?:${ANSI_ESCAPE_PATTERN})+`);
4
- const TRAILING_ANSI_ESCAPE_REGEXP = new RegExp(`(?:${ANSI_ESCAPE_PATTERN})+$`);
5
8
  const DIAGNOSTIC_MAX_LENGTH = 500;
9
+ const REDACTED = "[REDACTED]";
10
+ const SENSITIVE_DIAGNOSTIC_KEY_SUFFIXES = [
11
+ "token",
12
+ "apikey",
13
+ "accesskey",
14
+ "authorization",
15
+ "password",
16
+ "passwd",
17
+ "secret",
18
+ "privatekey",
19
+ "credential",
20
+ "credentials",
21
+ "accountid",
22
+ ] as const;
6
23
 
7
24
  function replaceControlCharacters(value: string): string {
8
25
  let result = "";
@@ -18,23 +35,11 @@ export function stripAnsi(value: string): string {
18
35
  }
19
36
 
20
37
  export function visibleWidth(value: string): number {
21
- return stripAnsi(value).length;
22
- }
23
-
24
- function leadingAnsi(value: string): string {
25
- return value.match(LEADING_ANSI_ESCAPE_REGEXP)?.[0] ?? "";
26
- }
27
-
28
- function trailingAnsi(value: string): string {
29
- return value.match(TRAILING_ANSI_ESCAPE_REGEXP)?.[0] ?? "";
38
+ return terminalVisibleWidth(value);
30
39
  }
31
40
 
32
41
  export function truncateToWidth(value: string, width: number, ellipsis = "..."): string {
33
- if (visibleWidth(value) <= width) return value;
34
- if (width <= 0) return "";
35
- const plain = stripAnsi(value);
36
- if (width <= ellipsis.length) return ellipsis.slice(0, width);
37
- return `${leadingAnsi(value)}${plain.slice(0, Math.max(0, width - ellipsis.length))}${ellipsis}${trailingAnsi(value)}`;
42
+ return truncateTerminalText(value, width, ellipsis);
38
43
  }
39
44
 
40
45
  export function formatTokens(count: number): string {
@@ -46,10 +51,7 @@ export function formatTokens(count: number): string {
46
51
  }
47
52
 
48
53
  export function sanitizeStatusText(text: string): string {
49
- return text
50
- .replace(/[\r\n\t]/g, " ")
51
- .replace(/ +/g, " ")
52
- .trim();
54
+ return text.replace(/[ \r\n\t]+/g, " ").trim();
53
55
  }
54
56
 
55
57
  export function maskIdentifier(value: string | undefined): string | undefined {
@@ -77,3 +79,26 @@ export function sanitizeDiagnosticError(
77
79
  if (sanitized.length <= maxLength) return sanitized;
78
80
  return `${sanitized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
79
81
  }
82
+
83
+ function isSensitiveDiagnosticKey(key: string): boolean {
84
+ const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
85
+ return (
86
+ normalized === "access" ||
87
+ normalized === "auth" ||
88
+ normalized === "refresh" ||
89
+ SENSITIVE_DIAGNOSTIC_KEY_SUFFIXES.some((suffix) => normalized.endsWith(suffix))
90
+ );
91
+ }
92
+
93
+ export function redactDiagnosticValue(value: unknown): unknown {
94
+ if (typeof value === "string") return sanitizeDiagnosticError(value);
95
+ if (Array.isArray(value)) return value.map(redactDiagnosticValue);
96
+ if (typeof value !== "object" || value === null) return value;
97
+
98
+ return Object.fromEntries(
99
+ Object.entries(value).map(([key, entry]) => [
100
+ key,
101
+ isSensitiveDiagnosticKey(key) ? REDACTED : redactDiagnosticValue(entry),
102
+ ]),
103
+ );
104
+ }
package/src/image.ts CHANGED
@@ -2,7 +2,8 @@ import { randomUUID } from "node:crypto";
2
2
  import { mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { extname, isAbsolute, join, resolve, sep } from "node:path";
5
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
5
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { Box, Container, Image, Text } from "@earendil-works/pi-tui";
6
7
  import sharp from "sharp";
7
8
  import type { ResolvedConfig } from "./config.ts";
8
9
  import { isRecord } from "./config.ts";
@@ -12,13 +13,17 @@ import {
12
13
  type CodexCredentialsWithSource,
13
14
  } from "./codex-auth.ts";
14
15
  import { maskIdentifier, sanitizeDiagnosticError } from "./format.ts";
16
+ import { piAgentDir, resolveUserPath } from "./paths.ts";
15
17
 
16
18
  const OPENAI_IMAGE_TOOL = "openai_image";
17
19
  const OPENAI_IMAGE_COMMAND = "openai-image";
18
20
  const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
19
21
  const DEFAULT_TIMEOUT_MS = 180_000;
20
22
  const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024;
23
+ const MAX_IMAGE_INPUTS = 5;
24
+ const MAX_TOTAL_IMAGE_INPUT_BYTES = 50 * 1024 * 1024;
21
25
  const SUPPORTED_INPUT_IMAGE_FORMATS = new Set(["png", "jpeg", "jpg", "webp", "gif"]);
26
+ const SSE_EVENT_BOUNDARY = /\r?\n\r?\n/;
22
27
 
23
28
  export const IMAGE_SAVE_MODES = ["none", "project", "global", "custom"] as const;
24
29
  export const IMAGE_ACTIONS = ["auto", "generate", "edit"] as const;
@@ -44,6 +49,7 @@ const TOOL_PARAMS = {
44
49
  },
45
50
  images: {
46
51
  type: "array",
52
+ maxItems: MAX_IMAGE_INPUTS,
47
53
  items: { type: "string" },
48
54
  description: "Local image paths to use as edit targets or references.",
49
55
  },
@@ -116,8 +122,11 @@ export type ImageGenerationDebug = {
116
122
  lastError?: string;
117
123
  };
118
124
 
119
- async function getCredentials(ctx: ExtensionContext): Promise<CodexImageCredentials> {
120
- const credentials = await getCodexCredentials(ctx);
125
+ async function getCredentials(
126
+ ctx: ExtensionContext,
127
+ signal?: AbortSignal,
128
+ ): Promise<CodexImageCredentials> {
129
+ const credentials = await getCodexCredentials(ctx, signal);
121
130
  if (credentials) return credentials;
122
131
  throw new Error("Missing openai-codex OAuth credentials. Run /login openai-codex.");
123
132
  }
@@ -141,12 +150,14 @@ function resolveImageConfig(cfg: ResolvedConfig, params: ToolParams) {
141
150
  }
142
151
 
143
152
  function imageMimeType(path: string, outputFormat?: string): string {
153
+ if (outputFormat === "jpeg" || outputFormat === "jpg") return "image/jpeg";
154
+ if (outputFormat === "webp") return "image/webp";
155
+ if (outputFormat === "gif") return "image/gif";
156
+ if (outputFormat === "png") return "image/png";
144
157
  const ext = extname(path).toLowerCase();
145
158
  if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
146
159
  if (ext === ".webp") return "image/webp";
147
160
  if (ext === ".gif") return "image/gif";
148
- if (outputFormat === "jpeg") return "image/jpeg";
149
- if (outputFormat === "webp") return "image/webp";
150
161
  return "image/png";
151
162
  }
152
163
 
@@ -162,15 +173,17 @@ function isInsideDirectory(root: string, child: string): boolean {
162
173
  );
163
174
  }
164
175
 
165
- async function validateImageInput(path: string, workspaceRoot: string): Promise<void> {
166
- const realWorkspaceRoot = await realpath(workspaceRoot).catch(() => workspaceRoot);
176
+ async function validateImageInput(
177
+ path: string,
178
+ realWorkspaceRoot: string,
179
+ ): Promise<{ mimeType: string; path: string; size: number }> {
167
180
  const realInputPath = await realpath(path).catch(() => undefined);
168
181
  if (!realInputPath || !isInsideDirectory(realWorkspaceRoot, realInputPath))
169
182
  throw new Error(
170
183
  `Image input must be a file inside the current workspace: ${displayPath(path)}`,
171
184
  );
172
185
 
173
- const pathStats = await stat(path).catch(() => undefined);
186
+ const pathStats = await stat(realInputPath).catch(() => undefined);
174
187
  if (!pathStats?.isFile())
175
188
  throw new Error(
176
189
  `Image input must be a file inside the current workspace: ${displayPath(path)}`,
@@ -178,16 +191,24 @@ async function validateImageInput(path: string, workspaceRoot: string): Promise<
178
191
  if (pathStats.size > MAX_IMAGE_INPUT_BYTES)
179
192
  throw new Error(`Image input is too large (max 20 MB): ${displayPath(path)}`);
180
193
 
181
- const metadata = await sharp(path, { animated: false })
194
+ const metadata = await sharp(realInputPath, { animated: false })
182
195
  .metadata()
183
196
  .catch(() => undefined);
184
197
  if (!metadata?.format || !SUPPORTED_INPUT_IMAGE_FORMATS.has(metadata.format))
185
198
  throw new Error(`Image input is not a readable image: ${displayPath(path)}`);
199
+ return {
200
+ mimeType: imageMimeType(path, metadata.format),
201
+ path: realInputPath,
202
+ size: pathStats.size,
203
+ };
186
204
  }
187
205
 
188
206
  async function readImageInputs(paths: string[] | undefined, cwd: string): Promise<ImageInput[]> {
189
- const inputs: ImageInput[] = [];
207
+ const validatedInputs: Array<{ path: string; mimeType: string }> = [];
208
+ const seenPaths = new Set<string>();
209
+ let totalBytes = 0;
190
210
  const workspaceRoot = resolve(cwd);
211
+ let realWorkspaceRoot: string | undefined;
191
212
  for (const rawPath of paths ?? []) {
192
213
  const trimmed = rawPath.trim();
193
214
  if (!trimmed) continue;
@@ -196,11 +217,23 @@ async function readImageInputs(paths: string[] | undefined, cwd: string): Promis
196
217
  throw new Error(
197
218
  `Image input must be a file inside the current workspace: ${displayPath(path)}`,
198
219
  );
199
- await validateImageInput(path, workspaceRoot);
200
- const data = (await readFile(path)).toString("base64");
201
- inputs.push({ path, data, mimeType: imageMimeType(path) });
220
+ realWorkspaceRoot ??= await realpath(workspaceRoot).catch(() => workspaceRoot);
221
+ const input = await validateImageInput(path, realWorkspaceRoot);
222
+ if (seenPaths.has(input.path)) continue;
223
+ if (validatedInputs.length >= MAX_IMAGE_INPUTS)
224
+ throw new Error(`Too many image inputs (max ${MAX_IMAGE_INPUTS}).`);
225
+ totalBytes += input.size;
226
+ if (totalBytes > MAX_TOTAL_IMAGE_INPUT_BYTES)
227
+ throw new Error("Image inputs are too large in total (max 50 MB).");
228
+ seenPaths.add(input.path);
229
+ validatedInputs.push({ path: input.path, mimeType: input.mimeType });
202
230
  }
203
- return inputs;
231
+ return Promise.all(
232
+ validatedInputs.map(async (input) => ({
233
+ ...input,
234
+ data: (await readFile(input.path)).toString("base64"),
235
+ })),
236
+ );
204
237
  }
205
238
 
206
239
  function resolveSaveDir(
@@ -210,14 +243,10 @@ function resolveSaveDir(
210
243
  ): string | undefined {
211
244
  if (mode === "none") return undefined;
212
245
  if (mode === "project") return join(cwd, ".pi", "generated-images");
213
- if (mode === "global")
214
- return join(
215
- process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent"),
216
- "generated-images",
217
- );
246
+ if (mode === "global") return join(piAgentDir(), "generated-images");
218
247
  const dir = params.saveDir?.trim() || process.env.PI_IMAGE_SAVE_DIR?.trim();
219
248
  if (!dir) throw new Error("save=custom requires saveDir or PI_IMAGE_SAVE_DIR.");
220
- return dir;
249
+ return resolveUserPath(dir, cwd);
221
250
  }
222
251
 
223
252
  async function saveImage(
@@ -342,19 +371,19 @@ async function parseSseForImage(
342
371
  const reader = response.body.getReader();
343
372
  const decoder = new TextDecoder();
344
373
  let buffer = "";
345
- let lastImage: ExtractedImageResult | undefined;
346
374
  try {
347
375
  while (true) {
348
376
  if (signal?.aborted) throw new Error("Image request was aborted.");
349
377
  const { done, value } = await reader.read();
350
378
  if (done) break;
351
379
  buffer += decoder.decode(value, { stream: true });
352
- let idx = buffer.indexOf("\n\n");
353
- while (idx !== -1) {
380
+ let boundary = SSE_EVENT_BOUNDARY.exec(buffer);
381
+ while (boundary) {
382
+ const idx = boundary.index;
354
383
  const chunk = buffer.slice(0, idx);
355
- buffer = buffer.slice(idx + 2);
384
+ buffer = buffer.slice(idx + boundary[0].length);
356
385
  const data = chunk
357
- .split("\n")
386
+ .split(/\r?\n/)
358
387
  .filter((line) => line.startsWith("data:"))
359
388
  .map((line) => line.slice(5).trim())
360
389
  .join("\n")
@@ -367,12 +396,9 @@ async function parseSseForImage(
367
396
  event = undefined;
368
397
  }
369
398
  const image = extractImageFromEvent(event, fallbackMimeType);
370
- if (image?.data) {
371
- lastImage = image;
372
- if (image.status === "completed") {
373
- await reader.cancel().catch(() => undefined);
374
- return image;
375
- }
399
+ if (image?.data && image.status === "completed") {
400
+ await reader.cancel().catch(() => undefined);
401
+ return image;
376
402
  }
377
403
  if (isRecord(event) && event.type === "response.failed") {
378
404
  const error =
@@ -391,13 +417,12 @@ async function parseSseForImage(
391
417
  throw new Error(`Codex image error: ${message}`);
392
418
  }
393
419
  }
394
- idx = buffer.indexOf("\n\n");
420
+ boundary = SSE_EVENT_BOUNDARY.exec(buffer);
395
421
  }
396
422
  }
397
423
  } finally {
398
424
  reader.releaseLock();
399
425
  }
400
- if (lastImage?.status === "completed") return lastImage;
401
426
  throw new Error("No completed image_generation_call result returned by Codex.");
402
427
  }
403
428
 
@@ -408,14 +433,16 @@ async function requestCodexImage(
408
433
  requestSignal?: AbortSignal,
409
434
  ): Promise<CodexImageResult> {
410
435
  if (!cfg.image.enabled) throw new Error("OpenAI image generation is disabled in config.");
411
- const credentials = await getCredentials(ctx);
436
+ const cwd = ctx.cwd || process.cwd();
412
437
  const model = resolveModel(params, ctx, cfg);
413
438
  const { action, outputFormat, save } = resolveImageConfig(cfg, params);
414
- const images = await readImageInputs(params.images, ctx.cwd || process.cwd());
415
- const request = buildRequest(params, model, cfg, images);
439
+ const saveDir = resolveSaveDir(save, params, cwd);
416
440
  const timeoutSignal = AbortSignal.timeout(cfg.image.timeoutMs);
417
441
  const baseSignal = requestSignal ?? ctx.signal;
418
442
  const signal = baseSignal ? AbortSignal.any([baseSignal, timeoutSignal]) : timeoutSignal;
443
+ const credentials = await getCredentials(ctx, signal);
444
+ const images = await readImageInputs(params.images, cwd);
445
+ const request = buildRequest(params, model, cfg, images);
419
446
  const response = await fetch(CODEX_RESPONSES_URL, {
420
447
  method: "POST",
421
448
  headers: {
@@ -441,7 +468,6 @@ async function requestCodexImage(
441
468
  imageMimeType(`image.${outputFormat}`, outputFormat),
442
469
  signal,
443
470
  );
444
- const saveDir = resolveSaveDir(save, params, ctx.cwd || process.cwd());
445
471
  const savedPath = saveDir
446
472
  ? await saveImage(parsed.data, outputFormat, saveDir, parsed.id)
447
473
  : undefined;
@@ -456,37 +482,6 @@ function displayPath(path: string): string {
456
482
  return path.startsWith(homePrefix) ? `~/${path.slice(homePrefix.length)}` : path;
457
483
  }
458
484
 
459
- function textFromMessageContent(content: unknown): string | undefined {
460
- if (typeof content === "string") return content.trim() || undefined;
461
- if (!Array.isArray(content)) return undefined;
462
- const text = content
463
- .filter((part) => isRecord(part) && part.type === "text" && typeof part.text === "string")
464
- .map((part) => (part as { text: string }).text)
465
- .join("\n")
466
- .trim();
467
- return text || undefined;
468
- }
469
-
470
- function latestUserPromptFromEntries(entries: unknown[]): string | undefined {
471
- for (let i = entries.length - 1; i >= 0; i--) {
472
- const entry = entries[i];
473
- if (
474
- !isRecord(entry) ||
475
- entry.type !== "message" ||
476
- !isRecord(entry.message) ||
477
- entry.message.role !== "user"
478
- )
479
- continue;
480
- const text = textFromMessageContent(entry.message.content);
481
- if (text) return text;
482
- }
483
- return undefined;
484
- }
485
-
486
- function resolveToolPrompt(params: ToolParams, ctx: ExtensionContext): string {
487
- return latestUserPromptFromEntries(ctx.sessionManager.getEntries()) ?? params.prompt;
488
- }
489
-
490
485
  function resultText(result: CodexImageResult): string {
491
486
  const parts = [
492
487
  `Generated image using OpenAI image_generation tool via openai-codex/${result.model}.`,
@@ -544,63 +539,57 @@ export function registerOpenAIImage(
544
539
  };
545
540
  }
546
541
 
547
- void import("@mariozechner/pi-tui")
548
- .then(({ Box, Container, Image, Text }) => {
549
- pi.registerMessageRenderer<CodexImageResult>("openai-image", (message, _options, theme) => {
550
- const result = message.details;
551
- const text =
552
- result && isRecord(result)
553
- ? resultText(result as CodexImageResult)
554
- : typeof message.content === "string"
555
- ? message.content
556
- : message.content
557
- .filter((part) => part.type === "text")
558
- .map((part) => part.text)
559
- .join("\n");
560
- let image: { data: string; mimeType: string; savedPath?: string } | undefined;
561
- if (
562
- result &&
563
- isRecord(result) &&
564
- typeof result.data === "string" &&
565
- typeof result.mimeType === "string"
566
- ) {
567
- image = {
568
- data: result.data,
569
- mimeType: result.mimeType,
570
- savedPath: typeof result.savedPath === "string" ? result.savedPath : undefined,
571
- };
572
- } else if (Array.isArray(message.content)) {
573
- const imagePart = message.content.find(isImageContent);
574
- if (imagePart) image = { data: imagePart.data, mimeType: imagePart.mimeType };
575
- }
542
+ pi.registerMessageRenderer<CodexImageResult>("openai-image", (message, _options, theme) => {
543
+ const result = message.details;
544
+ const text =
545
+ result && isRecord(result)
546
+ ? resultText(result as CodexImageResult)
547
+ : typeof message.content === "string"
548
+ ? message.content
549
+ : message.content
550
+ .filter((part) => part.type === "text")
551
+ .map((part) => part.text)
552
+ .join("\n");
553
+ let image: { data: string; mimeType: string; savedPath?: string } | undefined;
554
+ if (
555
+ result &&
556
+ isRecord(result) &&
557
+ typeof result.data === "string" &&
558
+ typeof result.mimeType === "string"
559
+ ) {
560
+ image = {
561
+ data: result.data,
562
+ mimeType: result.mimeType,
563
+ savedPath: typeof result.savedPath === "string" ? result.savedPath : undefined,
564
+ };
565
+ } else if (Array.isArray(message.content)) {
566
+ const imagePart = message.content.find(isImageContent);
567
+ if (imagePart) image = { data: imagePart.data, mimeType: imagePart.mimeType };
568
+ }
576
569
 
577
- const container = new Container();
578
- const box = new Box(1, 1, (line) => theme.bg("customMessageBg", line));
579
- box.addChild(
580
- new Text(`${theme.fg("accent", theme.bold("[openai-image]"))}\n\n${text}`, 0, 0),
581
- );
582
- if (image) {
583
- box.addChild(
584
- new Image(
585
- image.data,
586
- image.mimeType,
587
- { fallbackColor: (line) => theme.fg("dim", line) },
588
- {
589
- maxWidthCells: 80,
590
- maxHeightCells: 24,
591
- filename:
592
- "savedPath" in image && typeof image.savedPath === "string"
593
- ? image.savedPath
594
- : undefined,
595
- },
596
- ),
597
- );
598
- }
599
- container.addChild(box);
600
- return container;
601
- });
602
- })
603
- .catch(() => undefined);
570
+ const container = new Container();
571
+ const box = new Box(1, 1, (line) => theme.bg("customMessageBg", line));
572
+ box.addChild(new Text(`${theme.fg("accent", theme.bold("[openai-image]"))}\n\n${text}`, 0, 0));
573
+ if (image) {
574
+ box.addChild(
575
+ new Image(
576
+ image.data,
577
+ image.mimeType,
578
+ { fallbackColor: (line) => theme.fg("dim", line) },
579
+ {
580
+ maxWidthCells: 80,
581
+ maxHeightCells: 24,
582
+ filename:
583
+ "savedPath" in image && typeof image.savedPath === "string"
584
+ ? image.savedPath
585
+ : undefined,
586
+ },
587
+ ),
588
+ );
589
+ }
590
+ container.addChild(box);
591
+ return container;
592
+ });
604
593
 
605
594
  pi.registerCommand(OPENAI_IMAGE_COMMAND, {
606
595
  description: "Generate an image with OpenAI Codex image generation",
@@ -639,7 +628,6 @@ export function registerOpenAIImage(
639
628
  async execute(_toolCallId, params: ToolParams, signal, onUpdate, ctx) {
640
629
  const cfg = getConfig(ctx);
641
630
  const model = resolveModel(params, ctx, cfg);
642
- const requestParams = { ...params, prompt: resolveToolPrompt(params, ctx) };
643
631
  onUpdate?.({
644
632
  content: [
645
633
  {
@@ -649,7 +637,7 @@ export function registerOpenAIImage(
649
637
  ],
650
638
  details: undefined,
651
639
  });
652
- const result = await generate(requestParams, ctx, signal);
640
+ const result = await generate(params, ctx, signal);
653
641
  return {
654
642
  content: [
655
643
  { type: "text", text: resultText(result) },
@@ -669,11 +657,12 @@ export const _imageTest = {
669
657
  OPENAI_IMAGE_TOOL,
670
658
  OPENAI_IMAGE_COMMAND,
671
659
  MAX_IMAGE_INPUT_BYTES,
660
+ MAX_IMAGE_INPUTS,
661
+ MAX_TOTAL_IMAGE_INPUT_BYTES,
672
662
  extractAccountIdFromJwt,
673
663
  imageMimeType,
674
664
  dataUrlParts,
675
665
  extractImageFromEvent,
676
666
  displayPath,
677
- latestUserPromptFromEntries,
678
667
  buildRequest,
679
668
  };
package/src/paths.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+
4
+ export function expandTildePath(path: string, home = homedir()): string {
5
+ if (path === "~") return home;
6
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
7
+ return path;
8
+ }
9
+
10
+ export function piAgentDir(env = process.env, home = homedir()): string {
11
+ const configuredDir = env.PI_CODING_AGENT_DIR?.trim();
12
+ return configuredDir ? expandTildePath(configuredDir, home) : join(home, ".pi", "agent");
13
+ }
14
+
15
+ export function resolveUserPath(path: string, cwd: string, home = homedir()): string {
16
+ return resolve(cwd, expandTildePath(path, home));
17
+ }
@@ -1,5 +1,5 @@
1
- import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
- import { calculateImageRows, getCapabilities, getCellDimensions } from "@mariozechner/pi-tui";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { calculateImageRows, getCapabilities, getCellDimensions } from "@earendil-works/pi-tui";
3
3
  import type { PetPlacement, PetState, ResolvedConfig } from "./config.ts";
4
4
  import {
5
5
  animationFrameAt,
@@ -165,6 +165,7 @@ export class PetFooterController {
165
165
  this.petSettingsPreviewActive = next;
166
166
  if (!this.getConfig(ctx).pets.enabled) this.petLoadKey = undefined;
167
167
  void this.refresh(ctx, this.getConfig(ctx)).then(() => {
168
+ if (this.shuttingDown) return;
168
169
  if (!this.shouldRenderInFooter(this.getConfig(ctx))) this.flushKittyCleanupNow();
169
170
  this.requestSettingsRender?.();
170
171
  });
@@ -376,8 +377,12 @@ export class PetFooterController {
376
377
  updateActivity(ctx: ExtensionContext, cfg: ResolvedConfig): void {
377
378
  const resizeFrozen = this.isResizeFrozen();
378
379
  const shouldAnimatePet = this.shouldLoadForConfig(cfg);
379
- this.stopAnimation();
380
- if (shouldAnimatePet && !resizeFrozen) this.startAnimation(ctx);
380
+ if (!shouldAnimatePet || resizeFrozen) {
381
+ this.stopAnimation();
382
+ } else {
383
+ if (this.currentState(ctx, cfg) !== this.petAnimationState) this.stopAnimation();
384
+ this.startAnimation(ctx);
385
+ }
381
386
  if (!resizeFrozen && this.shouldRunIdleEmotes(ctx, cfg)) this.scheduleIdleEmote(ctx, cfg);
382
387
  else this.stopIdleEmotes();
383
388
  }
@@ -552,20 +557,22 @@ export class PetFooterController {
552
557
  ),
553
558
  );
554
559
  } else {
555
- const { frameIndex } = petFrameInfo(this.pet, petState, elapsedMs);
556
- const cacheKey = petRenderCacheKey(
557
- this.pet,
558
- petState,
559
- frameIndex,
560
- options.requestedPetPlacement,
561
- options.petColumnWidth,
562
- options.petRenderSizeCells,
563
- );
564
- const cachedPetLines = this.petRenderCache.get(cacheKey);
565
560
  const imageProtocol = getCapabilities().images;
566
- if (cachedPetLines) {
567
- petLines.push(...cachedPetLines);
568
- } else {
561
+ if (imageProtocol !== null && imageProtocol !== "kitty") {
562
+ const { frameIndex } = petFrameInfo(this.pet, petState, elapsedMs);
563
+ const cacheKey = petRenderCacheKey(
564
+ this.pet,
565
+ petState,
566
+ frameIndex,
567
+ options.requestedPetPlacement,
568
+ options.petColumnWidth,
569
+ options.petRenderSizeCells,
570
+ );
571
+ const cachedPetLines = this.petRenderCache.get(cacheKey);
572
+ if (cachedPetLines) {
573
+ petLines.push(...cachedPetLines);
574
+ return petLines;
575
+ }
569
576
  const renderedPetLines = renderCodexPetFrame(
570
577
  this.pet,
571
578
  petState,
@@ -580,10 +587,17 @@ export class PetFooterController {
580
587
  },
581
588
  );
582
589
  petLines.push(...renderedPetLines);
583
- if (renderedPetLines.length > 0) {
584
- if (imageProtocol !== null && imageProtocol !== "kitty")
585
- this.rememberRender(cacheKey, renderedPetLines);
586
- }
590
+ if (renderedPetLines.length > 0) this.rememberRender(cacheKey, renderedPetLines);
591
+ } else {
592
+ petLines.push(
593
+ ...renderCodexPetFrame(this.pet, petState, options.petColumnWidth, options.theme, {
594
+ sizeCells: options.petRenderSizeCells,
595
+ imageId: this.petImageId,
596
+ now: elapsedMs,
597
+ durationMultiplier: 1,
598
+ kittyManager: this.petKittyManager,
599
+ }),
600
+ );
587
601
  }
588
602
  }
589
603
  } else if (this.petError) {
@@ -607,5 +621,7 @@ export class PetFooterController {
607
621
  this.stopIdleEmotes();
608
622
  this.stopAnimation();
609
623
  this.stopPendingRenderRequest();
624
+ this.requestFooterRender = undefined;
625
+ this.requestSettingsRender = undefined;
610
626
  }
611
627
  }