osdy-pi 0.1.3 → 0.1.5

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
@@ -1,22 +1,32 @@
1
1
  # Osdy Pi
2
2
 
3
- ![Osdy Pi preview](https://raw.githubusercontent.com/OsdyOrtiz/Osdy-Pi/main/mapche1.png)
3
+ <img width="1857" height="847" alt="image" src="https://github.com/user-attachments/assets/028eeb14-3f43-4f1c-9603-0c55a8d2856d" />
4
4
 
5
5
  Theme package for [Pi](https://github.com/earendil-works/pi) with the Osdy terminal style: neon pink/purple colors, a custom ASCII header, and a framed editor experience.
6
6
 
7
+ Visit the Osdy landing page: [landing-osdy.vercel.app](https://landing-osdy.vercel.app/).
8
+
7
9
  ## What you get
8
10
 
9
11
  - **Dark theme:** `osdy-pi-dark`, enabled by default when the package starts.
10
12
  - **Light theme:** `osdy-pi-light`, with the same Osdy palette adapted for light terminals.
11
- - **Custom header:** centered Osdy-Pi ASCII branding with responsive status metadata.
13
+ - **Simple theme:** `osdy-pi-simple`, a blue/red/slate console theme for the full Pi interface.
14
+ - **Custom header:** two selectable header styles with responsive status metadata: `osdy-theme` (default) and `classic`.
12
15
  - **Custom editor:** full-width framed input area with model, thinking, token, cost, and context status.
13
- - **Clean layout:** the built-in footer/working row is hidden while Osdy Pi is enabled to avoid duplicated UI.
16
+ - **Custom working indicator:** a dedicated working widget/spinner appears above the text box, outside the editor frame.
17
+ - **Clean layout:** the built-in working row is hidden while Osdy Pi is enabled to avoid duplicated UI.
14
18
 
15
19
  <img width="1280" height="433" alt="image" src="https://github.com/user-attachments/assets/20c7624d-9ad8-4494-97fb-6b6d81aaf328" />
16
20
 
17
21
  ## Install in Pi
18
22
 
19
- Install the package directly from GitHub:
23
+ Install the published package from npm:
24
+
25
+ ```bash
26
+ pi install npm:osdy-pi
27
+ ```
28
+
29
+ You can also install it directly from GitHub:
20
30
 
21
31
  ```bash
22
32
  pi install git:github.com/OsdyOrtiz/Osdy-Pi
@@ -43,6 +53,7 @@ Then select one of these theme names:
43
53
  ```text
44
54
  osdy-pi-dark
45
55
  osdy-pi-light
56
+ osdy-pi-simple
46
57
  ```
47
58
 
48
59
  You can also set it in your Pi `settings.json`:
@@ -53,7 +64,7 @@ You can also set it in your Pi `settings.json`:
53
64
  }
54
65
  ```
55
66
 
56
- Use `osdy-pi-light` instead if you prefer the light version.
67
+ Use `osdy-pi-light` if you prefer the light version, or `osdy-pi-simple` if you want the blue/red/slate palette across the whole console.
57
68
 
58
69
  ## Commands
59
70
 
@@ -63,11 +74,20 @@ Osdy Pi includes a small command group:
63
74
  /osdy-pi enable
64
75
  /osdy-pi disable
65
76
  /osdy-pi status
77
+ /osdy-pi osdy-theme
78
+ /osdy-pi classic
79
+ /osdy-pi-osdy-theme
80
+ /osdy-pi-classic
66
81
  ```
67
82
 
68
83
  - `enable` applies the dark Osdy theme, custom header, custom editor, and clean layout.
69
84
  - `disable` restores Pi's built-in header, editor, footer, and working visibility, then switches back to the previous theme or `dark`.
70
- - `status` shows whether the Osdy Pi UI is currently enabled.
85
+ - `status` shows whether the Osdy Pi UI is currently enabled, including the active style.
86
+ - `osdy-theme` is the default OsdyTheme header with pink, cyan, and purple styling, plus the mascot glow on the right edge.
87
+ - `classic` keeps the previous classic header shape with the shared mascot.
88
+ - `/osdy-pi-osdy-theme` and `/osdy-pi-classic` are direct aliases.
89
+
90
+ After changing a local extension, run `/reload` or restart Pi so the updated commands are registered.
71
91
 
72
92
  ## Local install
73
93
 
@@ -83,16 +103,31 @@ To install it from a local path:
83
103
  pi install /absolute/path/to/Osdy-Pi
84
104
  ```
85
105
 
106
+ ![Osdy Pi preview](https://raw.githubusercontent.com/OsdyOrtiz/Osdy-Pi/main/mapche1.png)
107
+
86
108
  ## Package contents
87
109
 
88
110
  ```text
89
111
  themes/osdy-pi-dark.json
90
112
  themes/osdy-pi-light.json
113
+ themes/osdy-pi-simple.json
91
114
  extensions/osdy-pi.ts
115
+ extensions/osdy-pi/
92
116
  ```
93
117
 
118
+ `extensions/osdy-pi.ts` is the package entrypoint. The implementation lives in the modular `extensions/osdy-pi/` folder (runtime, UI, metrics, working controller, animation, border, and formatting helpers).
119
+
94
120
  The Pi manifest is declared in `package.json` through `pi.themes` and `pi.extensions`, so Pi can discover the themes and extension after installation.
95
121
 
122
+ ## Development
123
+
124
+ If you are working on the package locally, you can run:
125
+
126
+ ```bash
127
+ npm run typecheck
128
+ npm run lint
129
+ ```
130
+
96
131
  ## Uninstall or turn off
97
132
 
98
133
  To temporarily turn off the custom UI inside Pi:
@@ -0,0 +1,99 @@
1
+ import { ANIMATION_ENABLED } from "./constants.js";
2
+ import type { AnimationMode, SimpleTheme } from "./types.js";
3
+ import type { MascotTonePalette } from "./constants.js";
4
+ import { positiveModulo } from "./utils.js";
5
+
6
+ export type AsciiAnimationStyle = "static" | "animated";
7
+
8
+ const ANIMATION_OVERRIDE_MAP: Record<string, AnimationMode> = {
9
+ "0": "off",
10
+ "1": "continuous",
11
+ off: "off",
12
+ on: "continuous",
13
+ intro: "intro",
14
+ continuous: "continuous",
15
+ };
16
+
17
+ function getAnimationOverrideMode(
18
+ override: string | undefined,
19
+ ): AnimationMode | undefined {
20
+ return override ? ANIMATION_OVERRIDE_MAP[override] : undefined;
21
+ }
22
+
23
+ export function asciiAnimationMode(): AnimationMode {
24
+ if (!ANIMATION_ENABLED) return "off";
25
+ return getAnimationOverrideMode(process.env.OSDY_PI_ANIMATION) ?? "intro";
26
+ }
27
+
28
+ function getAnimatedAsciiColor(
29
+ wave: number,
30
+ baseColor: string,
31
+ highlightColor: string,
32
+ trailColor: string,
33
+ ): string {
34
+ if (wave <= 3) return highlightColor;
35
+ if (wave <= 8) return trailColor;
36
+ return baseColor;
37
+ }
38
+
39
+ export function animateAsciiLine(
40
+ line: string,
41
+ lineIndex: number,
42
+ frame: number,
43
+ theme: SimpleTheme,
44
+ baseColor: string,
45
+ highlightColor: string,
46
+ trailColor: string,
47
+ style: AsciiAnimationStyle,
48
+ ): string {
49
+ if (style === "static") return theme.fg(baseColor, line);
50
+ return Array.from(line)
51
+ .map((char, charIndex) => {
52
+ if (char === " ") return char;
53
+ const wave = positiveModulo(charIndex + lineIndex * 2 - frame * 5, 44);
54
+ return theme.fg(
55
+ getAnimatedAsciiColor(wave, baseColor, highlightColor, trailColor),
56
+ char,
57
+ );
58
+ })
59
+ .join("");
60
+ }
61
+
62
+ export function animateAsciiLineWithToneMap(
63
+ line: string,
64
+ toneMap: string,
65
+ lineIndex: number,
66
+ frame: number,
67
+ theme: SimpleTheme,
68
+ palette: MascotTonePalette,
69
+ style: AsciiAnimationStyle,
70
+ ): string {
71
+ return Array.from(line)
72
+ .map((char, charIndex) => {
73
+ if (char === " ") return char;
74
+ const tone = toneMap[charIndex];
75
+ if (
76
+ tone !== "b" &&
77
+ tone !== "h" &&
78
+ tone !== "l" &&
79
+ tone !== "m" &&
80
+ tone !== "d" &&
81
+ tone !== "p" &&
82
+ tone !== "c" &&
83
+ tone !== "v"
84
+ ) {
85
+ return char;
86
+ }
87
+ const baseColor = palette[tone];
88
+ if (style === "static") return theme.fg(baseColor, char);
89
+ const wave = positiveModulo(charIndex + lineIndex * 2 - frame * 5, 44);
90
+ const highlightColor =
91
+ tone === "b" || tone === "h" || tone === "p" ? palette.h : palette.l;
92
+ const trailColor = tone === "d" ? palette.m : palette.b;
93
+ return theme.fg(
94
+ getAnimatedAsciiColor(wave, baseColor, highlightColor, trailColor),
95
+ char,
96
+ );
97
+ })
98
+ .join("");
99
+ }
@@ -0,0 +1,49 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ function borderOverflows(left: string, right: string, width: number): boolean {
4
+ const fixedWidth = 2;
5
+ const minGap = 1;
6
+ return fixedWidth + visibleWidth(left) + visibleWidth(right) + minGap > width;
7
+ }
8
+
9
+ function trimBorderText(value: string): string {
10
+ return truncateToWidth(value, Math.max(0, visibleWidth(value) - 1), "");
11
+ }
12
+
13
+ function shrinkBorderTexts(
14
+ left: string,
15
+ right: string,
16
+ width: number,
17
+ ): { leftText: string; rightText: string } {
18
+ let leftText = left;
19
+ let rightText = right;
20
+ while (
21
+ borderOverflows(leftText, rightText, width) &&
22
+ visibleWidth(rightText) > 0
23
+ ) {
24
+ rightText = trimBorderText(rightText);
25
+ }
26
+ while (
27
+ borderOverflows(leftText, rightText, width) &&
28
+ visibleWidth(leftText) > 0
29
+ ) {
30
+ leftText = trimBorderText(leftText);
31
+ }
32
+ return { leftText, rightText };
33
+ }
34
+
35
+ export function fitBorder(
36
+ left: string,
37
+ right: string,
38
+ width: number,
39
+ color: (text: string) => string,
40
+ ): string {
41
+ if (width <= 0) return "";
42
+ if (width === 1) return color("─");
43
+ const { leftText, rightText } = shrinkBorderTexts(left, right, width);
44
+ const fillWidth = Math.max(
45
+ 0,
46
+ width - 2 - visibleWidth(leftText) - visibleWidth(rightText),
47
+ );
48
+ return `${color("─")}${leftText}${color("─".repeat(fillWidth))}${rightText}${color("─")}`;
49
+ }
@@ -0,0 +1,338 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import type { HeaderVariant } from "./types.js";
3
+
4
+ export const THEME_NAME = "osdy-pi-dark";
5
+ export const ANIMATION_ENABLED = true;
6
+ export const ANIMATION_INTERVAL_MS = 30;
7
+ export const INTRO_ANIMATION_FRAMES = 28;
8
+ export const WORKING_SPINNER_FRAMES = [
9
+ "⠋",
10
+ "⠙",
11
+ "⠹",
12
+ "⠸",
13
+ "⠼",
14
+ "⠴",
15
+ "⠦",
16
+ "⠧",
17
+ "⠇",
18
+ "⠏",
19
+ ];
20
+ export const WORKING_WIDGET_KEY = "osdy-pi-working";
21
+ export const MASCOT_MIN_ROWS = 34;
22
+ export const MASCOT_GAP = 0;
23
+
24
+ const HTML_MASCOT = [
25
+ " ▓▓▓▓▒ ",
26
+ " ░ ▓▓▓ ▒▓ ",
27
+ " ░░░▓▓▓ ▓▓ ",
28
+ " ▒ ▒ ▒▒ ▒ ░░ ▓▓▓▓ ░▒▓▓ ",
29
+ "▓▓▓▓▓▓▓▓░░░░░ ░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒ ░░▒▓ ",
30
+ "▓▓ ▓▓▓▓▓░░░ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒ ",
31
+ "▓▓░ ▒▓▓▓▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒░░ ░▒▒ ",
32
+ " ▓▒░░ ▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓█▓▓█▓▓▓▓▓▒▒▒░▒▒░ ",
33
+ " ▓▒░░ ░▒▒▒▒▒▓▓▓▓▓▒█▒▒▒▒▒▓▓▓▓ ░░░ ▓▓▓▓▒▒▒░░ ",
34
+ " ▒▒░░ ░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▓▓ ░ ▓ ▒▒░░ ",
35
+ " ▒▒▒▒░▒▒▓▓▓▓░ ░▓▒▒▒▒▒ ░▒▒█▓ ▒▓▓▓░▒ ",
36
+ " ░ ░▒▒▓▓▒ ▓░░ ░░ ░ ▓▓▓▒ ",
37
+ " ░░▒▓░ ░▒░▓▓ ░░ ░▓▓▒ ",
38
+ " ░▒ ▒ ░▒▒▒ ▒▒▒▒ ░ ▒▒▒ ",
39
+ " ░▒▓▓░ ▓▓░░░ █▓ ▒ ░▒▒▒ ",
40
+ " ▒▓▓▓░ ▓▓ ▓▓▓▓▓▓▓▓▓ ░░▒ ",
41
+ " ▓▓▓░ ░ ▓▓▓▓▓▓▒▓▓▓▓ ▓▓▓▓▓▓▓▒░░░░ ░ ",
42
+ " ▒▒▒▒ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒░░░░ ",
43
+ " ▓▒▒░░ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░ ▓ ",
44
+ " ░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒ ░░░ ",
45
+ " ░ ░░░░░░░░░░░▒▒░░ ░ ░ ░ ▓ ",
46
+ " ░░░░░▒▒▒▒░░▒░ ▒▒▒▒▒░░ ░░▒▒▒░░░░░░░░ ",
47
+ " ▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ▒░░▒▒▒▒▓▓▓▓░░░░ ",
48
+ " ░▒▒▒▒▒░ ░░▒▒▒▒▒▒▒▒░░░ ░░▒▓▓▓▓▒▒░░ ",
49
+ " ▓░░░░░░ ░ ░░░░░░░░░░░ ░░░░░░▓▒▒▒▒ ░",
50
+ " ░░░▒▒▓▓▓▓▓▒░░░░░░░░░░ ░░▒▒▒▒░░░░░░░▒▒░░ ",
51
+ " ░▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░ ░▒▒▒▓▓▒░░░ ░░░▒ ",
52
+ " ▒░▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░ ░▒▒▓▓▒░ ░ ",
53
+ " ░░▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒░░░░ ░▒▒▒▒▒ ░ ",
54
+ " ▒░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░ ▒▒░▒ ",
55
+ " ░░░░░░░░░░▒░░▒░▒▒░░░░ ░ ░░░░ ",
56
+ " ░░░░ ░░░░░░░░░░░ ░░░░ ",
57
+ " ░ ░░░░░░ ▓ ",
58
+ " ░░░░░ ░░░░░░ ",
59
+ " ░ ░ ",
60
+ ] as const;
61
+
62
+ const HTML_MASCOT_MAP = [
63
+ " hhhhl ",
64
+ " dmmhhhmmlhm ",
65
+ " dmmmhhhdmmmmhh ",
66
+ " ldldllddmdldmmmmhhlhddmmmlhh ",
67
+ "hhhhhhhhmmmlm llllllllllllllllllmlldddmmmll ",
68
+ "hhmmmmlhhhhmmmdllllllllllllllllllllllllmddmmmll ",
69
+ "hhmmmddlhhhlmlllllllllllllllllllhlllllllllmmlld ",
70
+ " llmmmdddlmllllllllllllllllllhbhhbhhhhhllllllm ",
71
+ " dllmmmddllllllhhhhhlblllllhhhhmmmmmmhhhhlllmm ",
72
+ " dllmmmlllllhhhhhhhhhhllllhhmmmmmmmmmddddhdlllm ",
73
+ " lllllllhhhhmmmmmmmhllllldmdlllbhmmmmddddlhhhlldd ",
74
+ " mdlllhhlmmdhmmmddmmdmmddmmmmdldddddddddmmmhhhl ",
75
+ " lllhmddmlmhhmmmmmddmmmddddddddddddddddmmdmmmhhl ",
76
+ " mlldldddmlllddddddddlllldmddddddddddddmmmddmlll ",
77
+ " llhhmddddmmdddddddddhhmmmdbhdldddddddddmmddmlll ",
78
+ " llhhmmmdddddddddddddhhddddmhhhhhhhhhmmdddddlml ",
79
+ " hhhmmmmmddddddddldhhhhhhlhhhhmhhhhhhhlmmmmmm ",
80
+ " llllmddmddddddddhhhhhhhhhhlhhhhhhhlllmmmm ",
81
+ " hllllmddddddmhhhhhhhhhhhhhllllllmmmh ",
82
+ " dmmmmmmmmlllllllllllllmdmmm ",
83
+ " mmmmmmmmmmmmmmmlllmm ",
84
+ " dlmmmmllllmmlmmllllllm dmllllmmmmmmmm ",
85
+ " lllllmmlllllllllllllmmm lmllllllllhlmmmmm ",
86
+ " mlllllmdmlllllllllmmmd mddmmmmlhhhlllmmmmm ",
87
+ " hmmmmmmdmdmmmmmmmmmmm dddmmmmmmmmlllllmmdmm",
88
+ " mmmlllllhllllmmmmmmmm dmmllllmmmmmmmlllmdm ",
89
+ " mlllhhhhhhhhlllllllmmdddmlllhllmmmmmmlmml ",
90
+ " llllllhhhhhhhllllllllmmdddmmlllllmmmmdmmd ",
91
+ " mllllllllhhllllllllllmmmddmmmlllllmdddmm ",
92
+ " lmllmlllllllllllllllmmmmmddmmmllllmdddd ",
93
+ " lmmmmmmllllmllllllmmmdmdddddmmmmmddmd ",
94
+ " dmmmmmmd dmmmmmmmmmmmmddddmmmmmdd ",
95
+ " dmmmmmd dmmmmmmd h ",
96
+ " mmmmmmmm dmmmmmmm ",
97
+ " dmmmmmm ",
98
+ ] as const;
99
+
100
+ const HEADER_CLASSIC = [
101
+ "░█████╗░░██████╗██████╗░██╗░░░██╗░░░░░░██████╗░██╗",
102
+ "██╔══██╗██╔════╝██╔══██╗╚██╗░██╔╝░░░░░░██╔══██╗ ║",
103
+ "██║░░██║╚█████╗░██║░░██║░╚████╔╝░█████╗██████╔╝██║",
104
+ "██║░░██║░╚═══██╗██║░░██║░░╚██╔╝░░╚════╝██╔═══╝░██║",
105
+ "╚█████╔╝██████╔╝██████╔╝░░░██║░░░░░░░░░██║░░░░░██║",
106
+ "░╚════╝░╚═════╝░╚═════╝░░░░╚═╝░░░░░░░░░╚═╝░░░░░╚═╝",
107
+ " ╭━╮╱╱╱╱╱╱╭╮╱╱╱╱╱╭╮╱╱╱╱╱╭╮╱╱╭╮╭╮╱╱╱╱╭━╮",
108
+ " ┃╭╋━┳━━┳━╋╋╮╭━╮╭╯┣━┳┳╮╭╯┣━╮┣╋╯┣━┳━╮┃━┫",
109
+ " ┃╰┫╋┃┃┃┃╋┃┃╰┫╋╰┫╋┃╋┃╭╯┃╋┃┻┫┃┃╋┃┻┫╋╰╋━┃",
110
+ " ╰━┻━┻┻┻┫╭┻┻━┻━━┻━┻━┻╯╱╰━┻━╯╰┻━┻━┻━━┻━╯",
111
+ " ╱╱╱╱╱╱╱╰╯ </>",
112
+ ] as const;
113
+
114
+ const HEADER_SIMPLE = [
115
+ " ███████ █████ ███████████ ███ ",
116
+ " ███░░░░░███ ░░███ ░░███░░░░░███ ░░░ ",
117
+ " ███ ░░███ █████ ███████ █████ ████ ░███ ░███ ████ ",
118
+ "░███ ░███ ███░░ ███░░███ ░░███ ░███ ██████████ ░██████████ ░░███ ",
119
+ "░███ ░███░░█████ ░███ ░███ ░███ ░███ ░░░░░░░░░░ ░███░░░░░░ ░███ ",
120
+ "░░███ ███ ░░░░███░███ ░███ ░███ ░███ ░███ ░███ ",
121
+ " ░░░███████░ ██████ ░░████████ ░░███████ █████ █████",
122
+ " ░░░░░░░ ░░░░░░ ░░░░░░░░ ░░░░░███ ░░░░░ ░░░░░ ",
123
+ " ███ ░███",
124
+ " ░░██████",
125
+ " ░░░░░░ </>",
126
+ ] as const;
127
+
128
+ type HeaderPalette = {
129
+ baseColor: string;
130
+ highlightColor: string;
131
+ trailColor: string;
132
+ };
133
+
134
+ export type MascotToneKey = "b" | "h" | "l" | "m" | "d" | "p" | "c" | "v";
135
+
136
+ export type MascotTonePalette = Record<MascotToneKey, string>;
137
+
138
+ type HeaderVariantConfig = {
139
+ label: string;
140
+ header: readonly string[];
141
+ headerMap?: readonly string[];
142
+ headerTonePalette?: MascotTonePalette;
143
+ minRowsForFull?: number;
144
+ fallbackHeader?: readonly string[];
145
+ fallbackLinePalette?: (lineIndex: number) => HeaderPalette;
146
+ mascot?: readonly string[];
147
+ mascotMap?: readonly string[];
148
+ linePalette: (lineIndex: number) => HeaderPalette;
149
+ mascotPalette: HeaderPalette;
150
+ mascotTonePalette?: MascotTonePalette;
151
+ };
152
+
153
+ const CLASSIC_PALETTE: HeaderPalette = {
154
+ baseColor: "accent",
155
+ highlightColor: "mdHeading",
156
+ trailColor: "mdLink",
157
+ };
158
+
159
+ const CLASSIC_LINK_PALETTE: HeaderPalette = {
160
+ baseColor: "mdLink",
161
+ highlightColor: "mdHeading",
162
+ trailColor: "mdLink",
163
+ };
164
+
165
+ const OSDY_THEME_PINK_PALETTE: HeaderPalette = {
166
+ baseColor: "accent",
167
+ highlightColor: "mdHeading",
168
+ trailColor: "mdLink",
169
+ };
170
+
171
+ const OSDY_THEME_CYAN_PALETTE: HeaderPalette = {
172
+ baseColor: "mdLink",
173
+ highlightColor: "accent",
174
+ trailColor: "mdHeading",
175
+ };
176
+
177
+ const OSDY_THEME_PURPLE_PALETTE: HeaderPalette = {
178
+ baseColor: "mdHeading",
179
+ highlightColor: "mdLink",
180
+ trailColor: "accent",
181
+ };
182
+
183
+ const OSDY_THEME_MASCOT_PALETTE: HeaderPalette = {
184
+ baseColor: "mdLink",
185
+ highlightColor: "accent",
186
+ trailColor: "mdHeading",
187
+ };
188
+
189
+ const HTML_MASCOT_TONES: MascotTonePalette = {
190
+ b: "mascotBg",
191
+ h: "mascotBright",
192
+ l: "mascotLight",
193
+ m: "mascotMid",
194
+ d: "mascotDark",
195
+ p: "htmlPink",
196
+ c: "mdLink",
197
+ v: "mdHeading",
198
+ };
199
+
200
+ type MascotArt = {
201
+ mascot: readonly string[];
202
+ toneMap: readonly string[];
203
+ };
204
+
205
+ function sampleIndex(
206
+ outputIndex: number,
207
+ outputCount: number,
208
+ inputCount: number,
209
+ ): number {
210
+ if (outputCount <= 1) return 0;
211
+ return Math.min(
212
+ inputCount - 1,
213
+ Math.round((outputIndex * (inputCount - 1)) / (outputCount - 1)),
214
+ );
215
+ }
216
+
217
+ function sampleLine(
218
+ line: string,
219
+ outputWidth: number,
220
+ inputWidth: number,
221
+ ): string {
222
+ const chars = Array.from(line.padEnd(inputWidth, " "));
223
+ return Array.from({ length: outputWidth }, (_value, outputIndex) => {
224
+ const inputIndex = sampleIndex(outputIndex, outputWidth, inputWidth);
225
+ return chars[inputIndex] ?? " ";
226
+ }).join("");
227
+ }
228
+
229
+ function scaleMascotArt(
230
+ mascot: readonly string[],
231
+ toneMap: readonly string[],
232
+ scale: number,
233
+ ): MascotArt {
234
+ const inputRows = Math.min(mascot.length, toneMap.length);
235
+ const inputWidth = Math.max(
236
+ ...mascot.slice(0, inputRows).map((line) => Array.from(line).length),
237
+ ...toneMap.slice(0, inputRows).map((line) => Array.from(line).length),
238
+ );
239
+ const outputRows = Math.max(1, Math.round(inputRows * scale));
240
+ const outputWidth = Math.max(1, Math.round(inputWidth * scale));
241
+
242
+ return Array.from({ length: outputRows }, (_value, outputIndex) => {
243
+ const inputIndex = sampleIndex(outputIndex, outputRows, inputRows);
244
+ return {
245
+ mascot: sampleLine(mascot[inputIndex] ?? "", outputWidth, inputWidth),
246
+ toneMap: sampleLine(toneMap[inputIndex] ?? "", outputWidth, inputWidth),
247
+ };
248
+ }).reduce<MascotArt>(
249
+ (accumulator, line) => ({
250
+ mascot: [...accumulator.mascot, line.mascot],
251
+ toneMap: [...accumulator.toneMap, line.toneMap],
252
+ }),
253
+ { mascot: [], toneMap: [] },
254
+ );
255
+ }
256
+
257
+ function addRightEdgeGlow(
258
+ mascot: readonly string[],
259
+ toneMap: readonly string[],
260
+ ): readonly string[] {
261
+ return toneMap.map((lineMap, lineIndex) => {
262
+ const chars = Array.from(lineMap);
263
+ const mascotLine = mascot[lineIndex] ?? "";
264
+ const rightEdgeIndex = Math.max(
265
+ ...Array.from(mascotLine).map((char, index) =>
266
+ char === " " ? -1 : index,
267
+ ),
268
+ );
269
+ if (rightEdgeIndex < 0) return lineMap;
270
+ const glowTones = ["p", "c", "v"] as const;
271
+ for (let offset = 0; offset < glowTones.length; offset += 1) {
272
+ const glowTone = glowTones[offset];
273
+ const index = rightEdgeIndex - offset;
274
+ if (
275
+ glowTone &&
276
+ index >= 0 &&
277
+ index < chars.length &&
278
+ chars[index] !== " "
279
+ ) {
280
+ chars[index] = glowTone;
281
+ }
282
+ }
283
+ return chars.join("");
284
+ });
285
+ }
286
+
287
+ const OSDY_THEME_MASCOT_ART = scaleMascotArt(HTML_MASCOT, HTML_MASCOT_MAP, 0.9);
288
+ const OSDY_THEME_MASCOT_MAP = addRightEdgeGlow(
289
+ OSDY_THEME_MASCOT_ART.mascot,
290
+ OSDY_THEME_MASCOT_ART.toneMap,
291
+ );
292
+
293
+ function osdyThemePalette(lineIndex: number): HeaderPalette {
294
+ if (lineIndex < 4) return OSDY_THEME_PINK_PALETTE;
295
+ if (lineIndex < 8) return OSDY_THEME_CYAN_PALETTE;
296
+ return OSDY_THEME_PURPLE_PALETTE;
297
+ }
298
+
299
+ export const HEADER_VARIANTS: Record<HeaderVariant, HeaderVariantConfig> = {
300
+ "osdy-theme": {
301
+ label: "OsdyTheme",
302
+ header: HEADER_SIMPLE,
303
+ mascot: OSDY_THEME_MASCOT_ART.mascot,
304
+ mascotMap: OSDY_THEME_MASCOT_MAP,
305
+ linePalette: osdyThemePalette,
306
+ mascotPalette: OSDY_THEME_MASCOT_PALETTE,
307
+ mascotTonePalette: HTML_MASCOT_TONES,
308
+ },
309
+ classic: {
310
+ label: "Classic",
311
+ header: HEADER_CLASSIC,
312
+ mascot: HTML_MASCOT,
313
+ mascotMap: HTML_MASCOT_MAP,
314
+ linePalette: (lineIndex) =>
315
+ lineIndex >= 6 ? CLASSIC_LINK_PALETTE : CLASSIC_PALETTE,
316
+ mascotPalette: OSDY_THEME_MASCOT_PALETTE,
317
+ mascotTonePalette: HTML_MASCOT_TONES,
318
+ },
319
+ };
320
+
321
+ export function headerWidth(variant: HeaderVariant): number {
322
+ return HEADER_VARIANTS[variant].header.reduce(
323
+ (maxWidth, line) => Math.max(maxWidth, visibleWidth(line)),
324
+ 0,
325
+ );
326
+ }
327
+
328
+ export function mascotWidth(variant: HeaderVariant): number {
329
+ return (HEADER_VARIANTS[variant].mascot ?? []).reduce(
330
+ (maxWidth, line) => Math.max(maxWidth, visibleWidth(line)),
331
+ 0,
332
+ );
333
+ }
334
+
335
+ export const HEADER_FALLBACK = [
336
+ "OSDY - PI",
337
+ "</> Compilador de ideas",
338
+ ] as const;
@@ -0,0 +1,42 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ export function formatPath(cwd: string): string {
4
+ const home = process.env.HOME;
5
+ return home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
6
+ }
7
+
8
+ function getMiddleWidths(maxWidth: number): {
9
+ headWidth: number;
10
+ tailWidth: number;
11
+ } {
12
+ const headWidth = Math.max(1, Math.ceil((maxWidth - 1) / 2));
13
+ return {
14
+ headWidth,
15
+ tailWidth: Math.max(1, maxWidth - 1 - headWidth),
16
+ };
17
+ }
18
+
19
+ function getTrailingText(value: string, tailWidth: number): string {
20
+ const reversedValue = Array.from(value).reverse().join("");
21
+ return truncateToWidth(reversedValue, tailWidth, "")
22
+ .split("")
23
+ .reverse()
24
+ .join("");
25
+ }
26
+
27
+ export function truncateMiddle(value: string, maxWidth: number): string {
28
+ if (maxWidth <= 0) return "";
29
+ if (visibleWidth(value) <= maxWidth) return value;
30
+ if (maxWidth <= 3) return truncateToWidth(value, maxWidth);
31
+ const { headWidth, tailWidth } = getMiddleWidths(maxWidth);
32
+ const head = truncateToWidth(value, headWidth, "");
33
+ const tail = getTrailingText(value, tailWidth);
34
+ return `${head}…${tail}`;
35
+ }
36
+
37
+ export function shortNumber(value: number): string {
38
+ if (!Number.isFinite(value)) return "0";
39
+ if (Math.abs(value) < 1_000) return Math.round(value).toString();
40
+ if (Math.abs(value) < 1_000_000) return `${(value / 1_000).toFixed(1)}k`;
41
+ return `${(value / 1_000_000).toFixed(1)}m`;
42
+ }