osdy-pi 0.1.8 → 1.2.0

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.
Files changed (39) hide show
  1. package/README.md +209 -161
  2. package/bin/osdy-pi.mjs +127 -0
  3. package/extensions/osdy-pi/account-profiles.test.ts +517 -0
  4. package/extensions/osdy-pi/account-profiles.ts +581 -0
  5. package/extensions/osdy-pi/animation.ts +47 -5
  6. package/extensions/osdy-pi/constants.ts +282 -103
  7. package/extensions/osdy-pi/diff-panel.ts +78 -24
  8. package/extensions/osdy-pi/editor-settings.test.ts +163 -0
  9. package/extensions/osdy-pi/editor-settings.ts +112 -0
  10. package/extensions/osdy-pi/metrics.ts +8 -135
  11. package/extensions/osdy-pi/plugin-events.ts +26 -0
  12. package/extensions/osdy-pi/profile-label.ts +29 -0
  13. package/extensions/osdy-pi/runtime-helpers.test.ts +26 -0
  14. package/extensions/osdy-pi/runtime-helpers.ts +87 -44
  15. package/extensions/osdy-pi/runtime.test.ts +62 -0
  16. package/extensions/osdy-pi/runtime.ts +256 -64
  17. package/extensions/osdy-pi/types.ts +33 -10
  18. package/extensions/osdy-pi/ui.test.ts +51 -0
  19. package/extensions/osdy-pi/ui.ts +298 -114
  20. package/extensions/osdy-pi/utils.ts +43 -1
  21. package/extensions/osdy-pi/working-tree.ts +25 -6
  22. package/package.json +22 -5
  23. package/scripts/osdy-pi-account-profiles.mjs +501 -0
  24. package/themes/osdy-pi-catppuccin-frappe.json +94 -0
  25. package/themes/osdy-pi-catppuccin-latte.json +94 -0
  26. package/themes/osdy-pi-catppuccin-macchiato.json +94 -0
  27. package/themes/osdy-pi-catppuccin-mocha.json +94 -0
  28. package/themes/osdy-pi-dark.json +0 -6
  29. package/themes/osdy-pi-dracula.json +83 -0
  30. package/themes/osdy-pi-kanagawa-dragon.json +91 -0
  31. package/themes/osdy-pi-kanagawa-lotus.json +92 -0
  32. package/themes/osdy-pi-kanagawa-wave.json +91 -0
  33. package/themes/osdy-pi-lucent-orange.json +80 -0
  34. package/themes/osdy-pi-matrix.json +83 -0
  35. package/themes/osdy-pi-new.json +91 -0
  36. package/themes/osdy-pi-sexy.json +84 -0
  37. package/themes/osdy-pi-tokyo-night.json +83 -0
  38. package/themes/osdy-pi-light.json +0 -84
  39. package/themes/osdy-pi-simple.json +0 -83
@@ -0,0 +1,581 @@
1
+ import { spawn as spawnChild } from "node:child_process";
2
+ import type { ChildProcess } from "node:child_process";
3
+ import { readdir } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { fileURLToPath } from "node:url";
6
+ import { isAbsolute, join, resolve } from "node:path";
7
+ import type {
8
+ ExtensionCommandContext,
9
+ ExtensionContext,
10
+ } from "@earendil-works/pi-coding-agent";
11
+
12
+ const PROFILE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,62})$/;
13
+ const RESERVED_PROFILE_NAMES = new Set(["default", "profiles", "auth.json"]);
14
+
15
+ export type AccountContext = Pick<
16
+ ExtensionCommandContext,
17
+ "isIdle" | "waitForIdle" | "shutdown"
18
+ > & {
19
+ sessionManager: Pick<
20
+ ExtensionCommandContext["sessionManager"],
21
+ "getSessionFile"
22
+ >;
23
+ ui: Pick<ExtensionContext["ui"], "notify" | "select" | "input">;
24
+ };
25
+
26
+ export interface AccountHandoffDependencies {
27
+ spawn(command: string, args: string[]): Promise<void>;
28
+ launcher?: string;
29
+ }
30
+
31
+ export interface AccountManagementDependencies {
32
+ profiles(): Promise<string[]>;
33
+ run(args: string[]): Promise<{ code: number; stdout: string; stderr: string }>;
34
+ activeProfile?: string | undefined;
35
+ handoff?: (profile: string) => Promise<void>;
36
+ }
37
+
38
+ type LauncherMessage =
39
+ | { type: "osdy-pi-ready" }
40
+ | { type: "osdy-pi-error"; message: string };
41
+
42
+ type ScheduleTimeout = (callback: () => void, delay: number) => NodeJS.Timeout;
43
+
44
+ const LAUNCHER_READY_TIMEOUT_MS = 10_000;
45
+
46
+ function isProfileName(value: string): boolean {
47
+ return PROFILE_NAME.test(value) && !RESERVED_PROFILE_NAMES.has(value);
48
+ }
49
+
50
+ function sharedAgentDir(env: NodeJS.ProcessEnv): string {
51
+ return resolve(
52
+ env.OSDY_PI_SHARED_AGENT_DIR ??
53
+ env.PI_CODING_AGENT_DIR ??
54
+ join(homedir(), ".pi", "agent"),
55
+ );
56
+ }
57
+
58
+ async function availableProfiles(baseDir: string): Promise<string[]> {
59
+ try {
60
+ const entries = await readdir(join(baseDir, "osdy-pi", "profiles"), {
61
+ withFileTypes: true,
62
+ });
63
+ const profiles: string[] = [];
64
+ for (const entry of entries) {
65
+ if (entry.isDirectory() && isProfileName(entry.name))
66
+ profiles.push(entry.name);
67
+ }
68
+ return profiles.sort(compareStrings);
69
+ } catch (error: unknown) {
70
+ if (isErrorCode(error, "ENOENT")) return [];
71
+ throw error;
72
+ }
73
+ }
74
+
75
+ function isErrorCode(error: unknown, code: string): boolean {
76
+ return (
77
+ typeof error === "object" &&
78
+ error !== null &&
79
+ "code" in error &&
80
+ error.code === code
81
+ );
82
+ }
83
+
84
+ function compareStrings(left: string, right: string): number {
85
+ return left < right ? -1 : left > right ? 1 : 0;
86
+ }
87
+
88
+ export async function handoffToAccount(
89
+ ctx: AccountContext,
90
+ profile: string,
91
+ dependencies: AccountHandoffDependencies,
92
+ ): Promise<void> {
93
+ if (!isProfileName(profile)) {
94
+ ctx.ui.notify(
95
+ "Cannot switch accounts: the selected profile name is invalid.",
96
+ "warning",
97
+ );
98
+ return;
99
+ }
100
+ const sessionPath = ctx.sessionManager.getSessionFile();
101
+ if (!sessionPath || !isAbsolute(sessionPath)) {
102
+ ctx.ui.notify(
103
+ "Cannot switch accounts: the current session has not been saved yet.",
104
+ "warning",
105
+ );
106
+ return;
107
+ }
108
+ if (!ctx.isIdle()) await ctx.waitForIdle();
109
+ const launcher = dependencies.launcher ?? "osdy-pi";
110
+ try {
111
+ await dependencies.spawn(launcher, [
112
+ "account",
113
+ "use",
114
+ profile,
115
+ "--",
116
+ "--session",
117
+ sessionPath,
118
+ ]);
119
+ } catch {
120
+ ctx.ui.notify(
121
+ "Cannot switch accounts: unable to start the replacement Pi process.",
122
+ "warning",
123
+ );
124
+ return;
125
+ }
126
+ ctx.shutdown();
127
+ }
128
+
129
+ function isLauncherMessage(message: unknown): message is LauncherMessage {
130
+ if (typeof message !== "object" || message === null || !("type" in message))
131
+ return false;
132
+ if (message.type === "osdy-pi-ready") return true;
133
+ return (
134
+ message.type === "osdy-pi-error" &&
135
+ "message" in message &&
136
+ typeof message.message === "string"
137
+ );
138
+ }
139
+
140
+ export function waitForLauncherReady(
141
+ child: ChildProcess,
142
+ timeoutMs = LAUNCHER_READY_TIMEOUT_MS,
143
+ scheduleTimeout: ScheduleTimeout = setTimeout,
144
+ ): Promise<void> {
145
+ return new Promise((resolveReady, rejectReady) => {
146
+ let settled = false;
147
+ const cleanup = (): void => {
148
+ clearTimeout(timeout);
149
+ child.removeListener("error", onError);
150
+ child.removeListener("exit", onExit);
151
+ child.removeListener("message", onMessage);
152
+ };
153
+ const fail = (error: Error): void => {
154
+ if (settled) return;
155
+ settled = true;
156
+ cleanup();
157
+ rejectReady(error);
158
+ };
159
+ const onError = (error: Error): void => fail(error);
160
+ const onExit = (): void =>
161
+ fail(new Error("Replacement Pi exited before confirming readiness."));
162
+ const onMessage = (message: unknown): void => {
163
+ if (!isLauncherMessage(message)) return;
164
+ if (message.type === "osdy-pi-error") {
165
+ fail(new Error(message.message));
166
+ return;
167
+ }
168
+ if (settled) return;
169
+ settled = true;
170
+ cleanup();
171
+ child.disconnect();
172
+ child.unref();
173
+ resolveReady();
174
+ };
175
+ const timeout = scheduleTimeout(
176
+ () =>
177
+ fail(new Error("Replacement Pi timed out before confirming readiness.")),
178
+ timeoutMs,
179
+ );
180
+ child.once("error", onError);
181
+ child.once("exit", onExit);
182
+ child.on("message", onMessage);
183
+ });
184
+ }
185
+
186
+ function bundledLauncherPath(): string {
187
+ return fileURLToPath(new URL("../../bin/osdy-pi.mjs", import.meta.url));
188
+ }
189
+
190
+ function validProfileName(value: string): boolean {
191
+ return isProfileName(value);
192
+ }
193
+
194
+ function managementError(result: { code: number; stderr: string }): string {
195
+ return (
196
+ result.stderr
197
+ .replaceAll(/[\r\n]+/g, " ")
198
+ .trim()
199
+ .slice(0, 180) || `command exited ${result.code}`
200
+ );
201
+ }
202
+
203
+ export type DefaultAccountState =
204
+ | { status: "valid"; profile: string }
205
+ | { status: "unset" }
206
+ | { status: "invalid" }
207
+ | { status: "error"; message: string };
208
+
209
+ export function parseDefaultAccountResult(result: {
210
+ code: number;
211
+ stdout: string;
212
+ stderr: string;
213
+ }): DefaultAccountState {
214
+ if (result.code !== 0)
215
+ return { status: "error", message: managementError(result) };
216
+ const output = result.stdout.trim();
217
+ if (output === "No default account.") return { status: "unset" };
218
+ if (output === "Default account metadata is invalid; no account selected.")
219
+ return { status: "invalid" };
220
+ if (isProfileName(output)) return { status: "valid", profile: output };
221
+ return {
222
+ status: "error",
223
+ message: `unexpected default account output: ${output.replaceAll(/[\r\n]+/g, " ").slice(0, 180) || "(empty)"}`,
224
+ };
225
+ }
226
+
227
+ async function runManagementCommand(
228
+ ctx: AccountContext,
229
+ dependencies: AccountManagementDependencies,
230
+ args: string[],
231
+ ): Promise<{ code: number; stdout: string; stderr: string } | undefined> {
232
+ try {
233
+ return await dependencies.run(args);
234
+ } catch {
235
+ ctx.ui.notify(
236
+ "Account operation failed before it could complete.",
237
+ "warning",
238
+ );
239
+ return undefined;
240
+ }
241
+ }
242
+
243
+ function markedProfiles(
244
+ profiles: string[],
245
+ activeProfile: string | undefined,
246
+ defaultProfile: string | undefined,
247
+ ): string[] {
248
+ return profiles.map(
249
+ (profile) =>
250
+ `${profile}${profile === activeProfile ? " (active)" : ""}${profile === defaultProfile ? " (default)" : ""}`,
251
+ );
252
+ }
253
+
254
+ async function currentDefault(
255
+ dependencies: AccountManagementDependencies,
256
+ ): Promise<DefaultAccountState> {
257
+ let result: { code: number; stdout: string; stderr: string };
258
+ try {
259
+ result = await dependencies.run(["account", "default"]);
260
+ } catch {
261
+ return { status: "error", message: "account command could not be started" };
262
+ }
263
+ return parseDefaultAccountResult(result);
264
+ }
265
+
266
+ export async function manageAccountProfile(
267
+ ctx: AccountContext,
268
+ mode: "rename" | "remove",
269
+ dependencies: AccountManagementDependencies,
270
+ ): Promise<void> {
271
+ let profiles: string[];
272
+ try {
273
+ profiles = await dependencies.profiles();
274
+ } catch {
275
+ ctx.ui.notify("Could not list account profiles.", "warning");
276
+ return;
277
+ }
278
+ if (profiles.length === 0) {
279
+ ctx.ui.notify("No account profiles are available.", "warning");
280
+ return;
281
+ }
282
+ const selected = await ctx.ui.select(
283
+ mode === "rename"
284
+ ? "Rename OpenAI account"
285
+ : "Remove OpenAI account permanently",
286
+ markedProfiles(profiles, dependencies.activeProfile, undefined),
287
+ );
288
+ if (selected === undefined) return;
289
+ const activeMarker = selected.indexOf(" (");
290
+ const target =
291
+ activeMarker === -1 ? selected : selected.slice(0, activeMarker);
292
+ if (!profiles.includes(target)) return;
293
+ if (target === dependencies.activeProfile) {
294
+ ctx.ui.notify(
295
+ "Cannot change the active profile. Switch to another profile first.",
296
+ "warning",
297
+ );
298
+ return;
299
+ }
300
+ if (mode === "rename") {
301
+ const newName = await ctx.ui.input("New profile name");
302
+ if (newName === undefined) return;
303
+ if (!validProfileName(newName)) {
304
+ ctx.ui.notify(
305
+ "Profile names use lowercase letters, numbers, and hyphens only.",
306
+ "warning",
307
+ );
308
+ return;
309
+ }
310
+ const result = await runManagementCommand(ctx, dependencies, [
311
+ "account",
312
+ "rename",
313
+ target,
314
+ newName,
315
+ ]);
316
+ if (result)
317
+ ctx.ui.notify(
318
+ result.code === 0
319
+ ? `Account profile renamed to ${newName}.`
320
+ : `Rename failed: ${managementError(result)}`,
321
+ result.code === 0 ? "info" : "warning",
322
+ );
323
+ return;
324
+ }
325
+ const defaultState = await currentDefault(dependencies);
326
+ if (defaultState.status === "invalid" || defaultState.status === "error") {
327
+ ctx.ui.notify(
328
+ "Cannot safely remove an account while default metadata is unavailable; clear or fix the default account first.",
329
+ "warning",
330
+ );
331
+ return;
332
+ }
333
+ let replacement: string | undefined;
334
+ if (defaultState.status === "valid" && defaultState.profile === target) {
335
+ replacement = await ctx.ui.select(
336
+ "Choose a new default account",
337
+ profiles.filter((profile) => profile !== target),
338
+ );
339
+ if (replacement === undefined) return;
340
+ }
341
+ const confirmation = await ctx.ui.input(
342
+ `Type ${target} to permanently remove it`,
343
+ );
344
+ if (confirmation === undefined) return;
345
+ if (confirmation !== target) {
346
+ ctx.ui.notify(
347
+ "Removal cancelled: confirmation did not match the profile name.",
348
+ "warning",
349
+ );
350
+ return;
351
+ }
352
+ const args = ["account", "remove", target, "--confirm", target];
353
+ if (replacement !== undefined) args.push("--replacement", replacement);
354
+ const result = await runManagementCommand(ctx, dependencies, args);
355
+ if (result)
356
+ ctx.ui.notify(
357
+ result.code === 0
358
+ ? `Account profile ${target} permanently removed.`
359
+ : `Removal failed: ${managementError(result)}`,
360
+ result.code === 0 ? "info" : "warning",
361
+ );
362
+ }
363
+
364
+ export async function manageAccountProfiles(
365
+ ctx: AccountContext,
366
+ dependencies: AccountManagementDependencies,
367
+ ): Promise<void> {
368
+ for (;;) {
369
+ let profiles: string[];
370
+ try {
371
+ profiles = await dependencies.profiles();
372
+ } catch {
373
+ ctx.ui.notify("Could not list account profiles.", "warning");
374
+ return;
375
+ }
376
+ const defaultState = await currentDefault(dependencies);
377
+ const defaultProfile =
378
+ defaultState.status === "valid" ? defaultState.profile : undefined;
379
+ const action = await ctx.ui.select("OpenAI account manager", [
380
+ "Switch",
381
+ "Add",
382
+ "Default",
383
+ "Rename",
384
+ "Remove",
385
+ "Account info",
386
+ "Cancel",
387
+ ]);
388
+ if (action === undefined || action === "Cancel") return;
389
+ if (action === "Account info") {
390
+ const defaultNote =
391
+ defaultState.status === "invalid"
392
+ ? "\nDefault account metadata is invalid; clear or fix the default account."
393
+ : defaultState.status === "error"
394
+ ? "\nDefault account unavailable."
395
+ : "";
396
+ ctx.ui.notify(
397
+ `Accounts:\n${markedProfiles(profiles, dependencies.activeProfile, defaultProfile).join("\n") || "No account profiles."}${defaultNote}`,
398
+ "info",
399
+ );
400
+ continue;
401
+ }
402
+ if (action === "Add") {
403
+ const name = await ctx.ui.input("New profile name");
404
+ if (name === undefined) continue;
405
+ if (!validProfileName(name)) {
406
+ ctx.ui.notify(
407
+ "Profile names use lowercase letters, numbers, and hyphens only.",
408
+ "warning",
409
+ );
410
+ continue;
411
+ }
412
+ const result = await runManagementCommand(ctx, dependencies, [
413
+ "account",
414
+ "create",
415
+ name,
416
+ ]);
417
+ if (result)
418
+ ctx.ui.notify(
419
+ result.code === 0
420
+ ? "Account profile created. Select it later via Switch, then run /login and select ChatGPT Plus/Pro (Codex)."
421
+ : `Create failed: ${managementError(result)}`,
422
+ result.code === 0 ? "info" : "warning",
423
+ );
424
+ continue;
425
+ }
426
+ if (action === "Default") {
427
+ const choice = await ctx.ui.select("Default account", [
428
+ "Show current",
429
+ "Set default",
430
+ "Clear default",
431
+ "Back",
432
+ ]);
433
+ if (choice === "Set default") {
434
+ const profile = await ctx.ui.select("Choose default account", profiles);
435
+ if (profile !== undefined) {
436
+ const result = await runManagementCommand(ctx, dependencies, [
437
+ "account",
438
+ "default",
439
+ profile,
440
+ ]);
441
+ if (result)
442
+ ctx.ui.notify(
443
+ result.code === 0
444
+ ? `Default account set to ${profile}.`
445
+ : `Default failed: ${managementError(result)}`,
446
+ result.code === 0 ? "info" : "warning",
447
+ );
448
+ }
449
+ } else if (choice === "Clear default") {
450
+ const result = await runManagementCommand(ctx, dependencies, [
451
+ "account",
452
+ "default",
453
+ "--clear",
454
+ ]);
455
+ if (result)
456
+ ctx.ui.notify(
457
+ result.code === 0
458
+ ? "Default account cleared."
459
+ : `Default failed: ${managementError(result)}`,
460
+ result.code === 0 ? "info" : "warning",
461
+ );
462
+ } else if (choice === "Show current") {
463
+ if (defaultState.status === "valid")
464
+ ctx.ui.notify(`Default account: ${defaultState.profile}`, "info");
465
+ else if (defaultState.status === "unset")
466
+ ctx.ui.notify("No default account.", "info");
467
+ else if (defaultState.status === "invalid")
468
+ ctx.ui.notify(
469
+ "Default account metadata is invalid; no account selected.",
470
+ "warning",
471
+ );
472
+ else
473
+ ctx.ui.notify(
474
+ "Default account is unavailable; clear or fix the default account before continuing.",
475
+ "warning",
476
+ );
477
+ }
478
+ continue;
479
+ }
480
+ if (action === "Rename" || action === "Remove") {
481
+ await manageAccountProfile(
482
+ ctx,
483
+ action.toLowerCase() as "rename" | "remove",
484
+ dependencies,
485
+ );
486
+ continue;
487
+ }
488
+ if (profiles.length === 0) {
489
+ ctx.ui.notify("No account profiles are available.", "warning");
490
+ continue;
491
+ }
492
+ const target = await ctx.ui.select(
493
+ "Switch OpenAI account",
494
+ markedProfiles(
495
+ profiles,
496
+ dependencies.activeProfile,
497
+ defaultProfile ?? undefined,
498
+ ),
499
+ );
500
+ if (target === undefined) continue;
501
+ const marker = target.indexOf(" (");
502
+ const profile = marker === -1 ? target : target.slice(0, marker);
503
+ if (!profiles.includes(profile)) continue;
504
+ if (profile === dependencies.activeProfile) {
505
+ ctx.ui.notify("That account is already active.", "info");
506
+ continue;
507
+ }
508
+ if (dependencies.handoff) await dependencies.handoff(profile);
509
+ return;
510
+ }
511
+ }
512
+
513
+ function runBundledCommand(
514
+ args: string[],
515
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
516
+ return new Promise((resolveResult, rejectResult) => {
517
+ const child = spawnChild(process.execPath, [bundledLauncherPath(), ...args], {
518
+ stdio: ["ignore", "pipe", "pipe"],
519
+ });
520
+ let stdout = "";
521
+ let stderr = "";
522
+ const append = (current: string, chunk: Buffer): string =>
523
+ (current + chunk.toString("utf8")).slice(-1024);
524
+ child.stdout?.on("data", (chunk: Buffer) => {
525
+ stdout = append(stdout, chunk);
526
+ });
527
+ child.stderr?.on("data", (chunk: Buffer) => {
528
+ stderr = append(stderr, chunk);
529
+ });
530
+ child.once("error", rejectResult);
531
+ child.once("exit", (code) =>
532
+ resolveResult({ code: code ?? 1, stdout, stderr }),
533
+ );
534
+ });
535
+ }
536
+
537
+ export function registerAccountProfilesCommand(pi: {
538
+ registerCommand(
539
+ name: string,
540
+ options: {
541
+ description: string;
542
+ handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
543
+ },
544
+ ): void;
545
+ }): void {
546
+ pi.registerCommand("osdy-account", {
547
+ description: "Restart Pi with another isolated OpenAI account profile.",
548
+ handler: async (args, ctx) => {
549
+ const mode = args.trim();
550
+ if ((ctx as ExtensionCommandContext & { hasUI?: boolean }).hasUI === false) {
551
+ ctx.ui.notify("Account manager requires an interactive Pi UI.", "warning");
552
+ return;
553
+ }
554
+ const dependencies: AccountManagementDependencies = {
555
+ profiles: () => availableProfiles(sharedAgentDir(process.env)),
556
+ run: runBundledCommand,
557
+ activeProfile: process.env.OSDY_PI_PROFILE_NAME,
558
+ handoff: (profile) =>
559
+ handoffToAccount(ctx, profile, {
560
+ launcher: bundledLauncherPath(),
561
+ spawn: (command, launchArgs) =>
562
+ waitForLauncherReady(
563
+ spawnChild(process.execPath, [command, ...launchArgs], {
564
+ detached: true,
565
+ stdio: ["ignore", "ignore", "ignore", "ipc"],
566
+ }),
567
+ ),
568
+ }),
569
+ };
570
+ if (mode === "rename" || mode === "remove") {
571
+ await manageAccountProfile(ctx, mode, dependencies);
572
+ return;
573
+ }
574
+ if (mode) {
575
+ ctx.ui.notify("Usage: /osdy-account [rename|remove]", "warning");
576
+ return;
577
+ }
578
+ await manageAccountProfiles(ctx, dependencies);
579
+ },
580
+ });
581
+ }
@@ -36,6 +36,33 @@ function getAnimatedAsciiColor(
36
36
  return baseColor;
37
37
  }
38
38
 
39
+ const RAW_HEX_COLOR = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/;
40
+
41
+ const GLOW_HIGHLIGHT_TONES = {
42
+ p: "c",
43
+ c: "v",
44
+ v: "p",
45
+ } as const;
46
+
47
+ const GLOW_TRAIL_TONES = {
48
+ p: "v",
49
+ c: "p",
50
+ v: "c",
51
+ } as const;
52
+
53
+ function colorAsciiCharacter(
54
+ theme: SimpleTheme,
55
+ color: string,
56
+ character: string,
57
+ ): string {
58
+ const hexMatch = RAW_HEX_COLOR.exec(color);
59
+ if (!hexMatch) return theme.fg(color, character);
60
+ const red = Number.parseInt(hexMatch[1] ?? "", 16);
61
+ const green = Number.parseInt(hexMatch[2] ?? "", 16);
62
+ const blue = Number.parseInt(hexMatch[3] ?? "", 16);
63
+ return `\u001B[38;2;${red};${green};${blue}m${character}\u001B[39m`;
64
+ }
65
+
39
66
  export function animateAsciiLine(
40
67
  line: string,
41
68
  lineIndex: number,
@@ -68,10 +95,11 @@ export function animateAsciiLineWithToneMap(
68
95
  palette: MascotTonePalette,
69
96
  style: AsciiAnimationStyle,
70
97
  ): string {
98
+ const toneCharacters = Array.from(toneMap);
71
99
  return Array.from(line)
72
100
  .map((char, charIndex) => {
73
101
  if (char === " ") return char;
74
- const tone = toneMap[charIndex];
102
+ const tone = toneCharacters[charIndex];
75
103
  if (
76
104
  tone !== "b" &&
77
105
  tone !== "h" &&
@@ -85,12 +113,26 @@ export function animateAsciiLineWithToneMap(
85
113
  return char;
86
114
  }
87
115
  const baseColor = palette[tone];
88
- if (style === "static") return theme.fg(baseColor, char);
116
+ if (style === "static") {
117
+ return colorAsciiCharacter(theme, baseColor, char);
118
+ }
89
119
  const wave = positiveModulo(charIndex + lineIndex * 2 - frame * 5, 44);
90
- const highlightColor =
91
- tone === "b" || tone === "h" || tone === "p" ? palette.h : palette.l;
120
+ if (tone === "p" || tone === "c" || tone === "v") {
121
+ return colorAsciiCharacter(
122
+ theme,
123
+ getAnimatedAsciiColor(
124
+ wave,
125
+ baseColor,
126
+ palette[GLOW_HIGHLIGHT_TONES[tone]],
127
+ palette[GLOW_TRAIL_TONES[tone]],
128
+ ),
129
+ char,
130
+ );
131
+ }
132
+ const highlightColor = tone === "b" || tone === "h" ? palette.h : palette.l;
92
133
  const trailColor = tone === "d" ? palette.m : palette.b;
93
- return theme.fg(
134
+ return colorAsciiCharacter(
135
+ theme,
94
136
  getAnimatedAsciiColor(wave, baseColor, highlightColor, trailColor),
95
137
  char,
96
138
  );