omp-uwu 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -26,6 +26,8 @@ Restart omp. uwu mode is **on** by default in every session.
26
26
 
27
27
  The palette colors chat markdown and the user-message bubble without putting ANSI codes into message text/history. It is applied as an in-memory TUI theme; components sharing those colors can change too. The host's in-memory theme setter pauses automatic theme detection until omp restarts. The TUI also shows a theme-accent `(◕ᴗ◕✿) uwu` status badge; other clients/plain output are not colorized.
28
28
 
29
+ With colors on, the TUI also paints **sparkles** in the agent's chat prose: `uwu`/`owo` get a per-letter pastel rainbow, and kaomoji (listed or not, e.g. `(◕ᴗ◕✿)`, `(ノ◕ヮ◕)ノ`) and glyphs like `♡ ☆ ✧ ✿` get a pastel tint. This happens at render time through omp's per-message text color transform, which only ever sees plain prose runs, so inline code, code blocks, link targets and the stored message text stay untouched. omp's `AssistantMessageComponent` is not part of the extension API, so the plugin finds it through its shared `Container` base class when the first assistant message is created; if a future omp changes that shape, sparkles are simply skipped and the palette still applies.
30
+
29
31
  ## What gets uwufied, and what doesn't
30
32
 
31
33
  | uwufied | kept byte-for-byte |
@@ -36,7 +38,7 @@ The palette colors chat markdown and the user-message bubble without putting ANS
36
38
  | | Tool-call arguments and file contents the agent writes or edits |
37
39
  | | Commit messages and prompts sent to subagents |
38
40
 
39
- Style rules include `r`/`l` → `w`, occasional `th` → `d`, `na/ne/no` → `nya/nye/nyo`, occasional stutter, and a broad rotating selection of kaomoji. The kaomoji selection includes examples from [kaomoji.you](https://kaomoji.you/), such as `٩(◕‿◕。)۶`, `(ฅ^•ﻌ•^ฅ)`, and `(づ。◕‿‿◕。)づ`. Meaning, numbers and warnings must stay readable.
41
+ Style rules include `r`/`l` → `w`, occasional `th` → `d`, `na/ne/no` → `nya/nye/nyo`, occasional stutter, a broad rotating selection of kaomoji, and the odd cute emoji (`✨ 💖 🌸 🎀`). The kaomoji selection includes examples from [kaomoji.you](https://kaomoji.you/), such as `٩(◕‿◕。)۶`, `(ฅ^•ﻌ•^ฅ)`, and `(づ。◕‿‿◕。)づ`. Meaning, numbers and warnings must stay readable.
40
42
 
41
43
  Here's the same prompt and model, with the mode off and on:
42
44
 
@@ -66,7 +68,7 @@ The hook runs after streaming has finished. The TUI refreshes the current assist
66
68
 
67
69
  ```sh
68
70
  omp plugin install omp-uwu # from npm (recommended)
69
- omp plugin install omp-uwu@0.3.0 # pin a version
71
+ omp plugin install omp-uwu@0.4.0 # pin a version
70
72
  omp plugin uninstall omp-uwu # remove
71
73
  ```
72
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-uwu",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "omp extension for playful uwu chat styling, markdown-safe rewrites, and kawaii kaomoji",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { KawaiiTheme } from "./kawaii.ts";
4
+ import { type ContainerClass, installSparkles, sparkle, themePaint } from "./sparkle.ts";
4
5
 
5
6
  import type { ExtensionAPI, Theme } from "@oh-my-pi/pi-coding-agent";
6
7
  import { uwufy } from "./uwufy.ts";
@@ -10,8 +11,9 @@ export const UWU_PROMPT = `
10
11
  Write your prose replies to the user in playful "uwu" speak:
11
12
  - replace r/l with w in most words ("weawwy", "hewwo"), "th" → "d" occasionally, "na/ne/no" → "nya/nye/nyo" sometimes
12
13
  - occasional stutter ("h-hewwo") and cute kaomoji/emoticons, varied naturally: (◕ᴗ◕✿), (o^▽^o), (´。• ω •。\u0060), ٩(◕‿◕。)۶, (✧ω✧), (๑˃ᴗ˂)ﻭ, ヽ(・∀・)ノ, (っ˘ω˘ς), (。•̀ᴗ-)✧, (づ。◕‿‿◕。)づ, (ฅ^•ﻌ•^ฅ), (๑>◡<๑), owo, >w<, ^w^, :3; at most one at a sentence end
14
+ - now and then a cute emoji or sparkle instead (✨, 💖, 🌸, 🎀, ♡, ☆), never more than one per paragraph
13
15
  - keep it readable; never let the style hide meaning, numbers or warnings
14
- - colors are only for UI chrome; never insert ANSI color codes into chat text
16
+ - the TUI colors kaomoji, uwu and sparkles by itself; never insert ANSI color codes into chat text
15
17
 
16
18
  NEVER uwufy any of these — keep them exact and byte-for-byte correct:
17
19
  - code, code blocks, inline code, shell commands, file paths, URLs, identifiers, config keys, error messages you quote
@@ -62,6 +64,14 @@ export default function uwuExtension(pi: ExtensionAPI) {
62
64
  ctx.ui.setStatus("omp-uwu", badge && ctx.ui.theme ? ctx.ui.theme.fg("accent", badge) : badge);
63
65
  };
64
66
 
67
+ // Paints kaomoji/uwu in rendered chat prose at display time only (see sparkle.ts).
68
+ // Installed at load so the first assistant component, even a resumed one, is caught.
69
+ const paint = themePaint(() => pi.pi.theme);
70
+ const host = pi.pi as { Container?: ContainerClass };
71
+ if (host.Container) {
72
+ installSparkles(host.Container, { isActive: () => enabled && colorsEnabled, transform: (text) => sparkle(text, paint) });
73
+ }
74
+
65
75
  const syncColors = (ctx: UiContext) => {
66
76
  if (ctx.mode !== "tui" || ctx.agent?.kind === "sub" || !ctx.ui?.theme) return;
67
77
  if (enabled && colorsEnabled) kawaiiTheme.enable(ctx.ui.theme);
package/src/kawaii.ts CHANGED
@@ -57,7 +57,7 @@ export function buildKawaiiTheme(ThemeClass: ThemeRuntime["Theme"], base: Theme)
57
57
  }
58
58
 
59
59
  const preset = base.getSymbolPreset();
60
- return new ThemeClass(
60
+ const theme = new ThemeClass(
61
61
  fg as Record<ThemeColor, string>,
62
62
  bg as Record<ThemeBg, string>,
63
63
  base.getColorMode(),
@@ -65,6 +65,15 @@ export function buildKawaiiTheme(ThemeClass: ThemeRuntime["Theme"], base: Theme)
65
65
  {},
66
66
  { status: base.getSpinnerFrames("status"), activity: base.getSpinnerFrames("activity") },
67
67
  );
68
+ // A nested colored span (a sparkle, an inline code span) ends in a
69
+ // foreground reset, which would drop the enclosing heading/link color for the
70
+ // rest of the span. Switch back to the enclosing color there instead.
71
+ const plainFg = theme.fg.bind(theme);
72
+ theme.fg = (color, text) => {
73
+ const open = theme.getFgAnsi(color);
74
+ return plainFg(color, open === FG_DEFAULT ? text : text.replaceAll(FG_DEFAULT, open));
75
+ };
76
+ return theme;
68
77
  }
69
78
 
70
79
  /** A reversible, ephemeral kawaii theme; never edits the user's theme file. */
package/src/sparkle.ts ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Render-time "sparkles": paints kaomoji, uwu/owo and cute symbols in the
3
+ * assistant's chat prose with the kawaii palette.
4
+ *
5
+ * Nothing is written into message text, history or model context. omp renders
6
+ * assistant prose through a per-component text color transform that Markdown
7
+ * applies to plain-text runs only — never to inline code, code blocks or link
8
+ * targets — so painting there leaves every exact-text surface untouched.
9
+ */
10
+ import type { Theme, ThemeColor } from "@oh-my-pi/pi-coding-agent";
11
+ import { EMOTICON_TOKENS } from "./uwufy.ts";
12
+
13
+ /** Palette keys the kawaii theme sets; painting rotates through them. */
14
+ const TONES: readonly ThemeColor[] = ["accent", "mdLink", "mdCode", "mdListBullet", "mdHeading", "thinkingText"];
15
+
16
+ /**
17
+ * Decorative monochrome glyphs worth a tint on their own. Color emoji ignore fg,
18
+ * and a glyph carrying VS16 renders as emoji, so those are left alone.
19
+ */
20
+ const SYMBOLS = "♡✧☆★✿❀⋆";
21
+
22
+ // Known emoticons, longest first so a kaomoji wins over the uwu/♡ inside it.
23
+ // Word-edge guards keep `:3` in `a:3` or `owo` in `owowl` unpainted.
24
+ const KNOWN = [...EMOTICON_TOKENS]
25
+ .filter((token) => !/^\p{Extended_Pictographic}+$/u.test(token))
26
+ .sort((a, b) => b.length - a.length)
27
+ .map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
28
+ .join("|");
29
+ // Unlisted kaomoji: parentheses holding at least one face feature (eyes,
30
+ // mouths, cheeks), no digits and no two letters in a row, optionally hugged by
31
+ // up to two arm glyphs, and not glued to a word. So `(x−y)`, `(a—b)`,
32
+ // `(şimdi)` and `ş(a…)ı` stay plain.
33
+ const FEATURES = "◕ᴗω▽◡•ᵔ˘≧≦˃˂・・︶﹏‿꒳ᗜﻌ∀ヮ´︿◉⊙ಠ︵□ᴥ^。⌒⩊ᵕ≖ˆ˶˵ᐛ⁀﹃ↀ✧♡";
34
+ const ARMS = String.raw`٩۶ヽノノづっ✧♡☆⌒ヾᕕᕗ\\/`;
35
+ const FACE = String.raw`(?<![\p{L}\p{N}])[${ARMS}]{0,2}\((?=[^()\n]*[${FEATURES}])(?![^()\n]*(?:\p{L}\p{M}*){2})[^()\n\p{N}]{1,16}\)[${ARMS}]{0,2}(?![\p{L}\p{N}])`;
36
+ // Symbols take their combining marks along, so no color code splits a grapheme.
37
+ const SPARKLE = new RegExp(
38
+ String.raw`(?<![\p{L}\p{N}_])(?:${KNOWN})(?![\p{L}\p{N}_])|${FACE}|[${SYMBOLS}](?!\p{M}*\uFE0F)\p{M}*`,
39
+ "gu",
40
+ );
41
+ const LETTERS = /^\p{L}+$/u;
42
+
43
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
44
+
45
+ export type Paint = (tone: ThemeColor, text: string) => string;
46
+
47
+ /** FNV-1a; picks a stable starting tone per token. */
48
+ function hash(value: string): number {
49
+ let h = 0x811c9dc5;
50
+ for (let i = 0; i < value.length; i++) h = Math.imul(h ^ value.charCodeAt(i), 0x01000193);
51
+ return h >>> 0;
52
+ }
53
+
54
+ /**
55
+ * Paint every sparkle in one plain-text run. Word emoticons (`uwu`, `OwO`) get
56
+ * a per-letter rainbow; faces and symbols get one tone. Deterministic, so a
57
+ * re-render of the same run yields the same bytes.
58
+ */
59
+ export function sparkle(text: string, paint: Paint): string {
60
+ let ordinal = 0;
61
+ return text.replace(SPARKLE, (match) => {
62
+ const start = hash(match) + ordinal++;
63
+ if (!LETTERS.test(match)) return paint(TONES[start % TONES.length]!, match);
64
+ let index = 0;
65
+ let out = "";
66
+ for (const { segment } of graphemes.segment(match)) out += paint(TONES[(start + index++) % TONES.length]!, segment);
67
+ return out;
68
+ });
69
+ }
70
+
71
+ /** A paint function over the live host theme; unknown colors fall back to plain text. */
72
+ export function themePaint(getTheme: () => Theme | undefined): Paint {
73
+ return (tone, text) => {
74
+ try {
75
+ return getTheme()?.fg(tone, text) ?? text;
76
+ } catch {
77
+ return text;
78
+ }
79
+ };
80
+ }
81
+
82
+ type ColorTransform = (text: string) => string;
83
+
84
+ /** The two public methods of omp's AssistantMessageComponent this relies on. */
85
+ interface AssistantPrototype {
86
+ updateContent(...args: unknown[]): unknown;
87
+ setTextColorTransform(transform?: ColorTransform): void;
88
+ }
89
+
90
+ /** omp's TUI `Container`, the base class of AssistantMessageComponent. */
91
+ export interface ContainerClass {
92
+ prototype: { addChild(child: unknown): unknown };
93
+ }
94
+
95
+ export interface SparkleControl {
96
+ /** Whether new/updated assistant messages should be painted. */
97
+ isActive(): boolean;
98
+ /** Stable transform identity; Markdown caches renders by it. */
99
+ transform: ColorTransform;
100
+ }
101
+
102
+ /** Process-wide state, so extension reloads swap the control instead of re-patching. */
103
+ interface Registry {
104
+ control?: SparkleControl;
105
+ assistant?: AssistantPrototype;
106
+ watching?: boolean;
107
+ }
108
+ const REGISTRY = Symbol.for("omp-uwu.sparkles");
109
+
110
+ /** Walk up from an instance to the prototype that defines both assistant methods. */
111
+ function findAssistantPrototype(value: unknown): AssistantPrototype | undefined {
112
+ for (let proto = value && typeof value === "object" ? Object.getPrototypeOf(value) : null; proto; proto = Object.getPrototypeOf(proto)) {
113
+ if (
114
+ Object.hasOwn(proto, "setTextColorTransform") &&
115
+ Object.hasOwn(proto, "updateContent") &&
116
+ typeof proto.setTextColorTransform === "function" &&
117
+ typeof proto.updateContent === "function"
118
+ ) {
119
+ return proto as AssistantPrototype;
120
+ }
121
+ }
122
+ return undefined;
123
+ }
124
+
125
+ /**
126
+ * Give every assistant message component the sparkle transform while
127
+ * `control.isActive()`. A transform set by the host itself (e.g. the
128
+ * live-voice transcript tint) always wins over sparkles.
129
+ */
130
+ function patchAssistant(proto: AssistantPrototype, registry: Registry): void {
131
+ const originalSet = proto.setTextColorTransform;
132
+ const originalUpdate = proto.updateContent;
133
+ const hostTransforms = new WeakMap<object, ColorTransform>();
134
+ const applied = new WeakMap<object, ColorTransform | undefined>();
135
+
136
+ proto.setTextColorTransform = function (this: object, transform?: ColorTransform) {
137
+ if (transform) hostTransforms.set(this, transform);
138
+ else hostTransforms.delete(this);
139
+ applied.delete(this);
140
+ originalSet.call(this, transform);
141
+ };
142
+ // Theme changes re-enter updateContent through invalidate(), so toggling
143
+ // colors repaints existing messages too.
144
+ proto.updateContent = function (this: object, ...args: unknown[]) {
145
+ if (!hostTransforms.has(this)) {
146
+ const control = registry.control;
147
+ const wanted = control?.isActive() ? control.transform : undefined;
148
+ if (applied.get(this) !== wanted) {
149
+ applied.set(this, wanted);
150
+ originalSet.call(this, wanted);
151
+ }
152
+ }
153
+ return originalUpdate.apply(this, args);
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Hook sparkles into omp's AssistantMessageComponent.
159
+ *
160
+ * That class is not part of the extension API, and importing its module by
161
+ * path loads a second, unused copy when omp runs from its bundle. What
162
+ * extensions do share with the host is `Container`, its base class, and the
163
+ * component adds its first child in its constructor before any content
164
+ * update. So `addChild` is watched until the first assistant component shows
165
+ * up, its prototype is patched, and the watch is removed again.
166
+ */
167
+ export function installSparkles(Container: ContainerClass, control: SparkleControl): void {
168
+ const holder = globalThis as { [REGISTRY]?: Registry };
169
+ const registry = (holder[REGISTRY] ??= {});
170
+ registry.control = control;
171
+ if (registry.assistant || registry.watching) return;
172
+
173
+ registry.watching = true;
174
+ const containerProto = Container.prototype;
175
+ const originalAdd = containerProto.addChild;
176
+ const watch = function (this: object, child: unknown) {
177
+ const assistant = findAssistantPrototype(this) ?? findAssistantPrototype(child);
178
+ if (assistant) {
179
+ if (containerProto.addChild === watch) containerProto.addChild = originalAdd;
180
+ registry.watching = false;
181
+ registry.assistant = assistant;
182
+ patchAssistant(assistant, registry);
183
+ }
184
+ return originalAdd.call(this, child);
185
+ };
186
+ containerProto.addChild = watch;
187
+ }
package/src/uwufy.ts CHANGED
@@ -23,10 +23,11 @@ export const EMOTICONS = [
23
23
  "(♡˙︶˙♡)", "ヽ(♡‿♡)ノ", "(´꒳`)♡", "(ノ´ з `)ノ", "ヾ(・ω・*)",
24
24
  "(⌒ω⌒)ノ", "(ᵔ⩊ᵔ)", "(=^・ω・^=)", "(ฅ^•ﻌ•^ฅ)", "(ᵕ—ᴗ—)",
25
25
  "(。•̀ᴗ-)✧", "(づ。◕‿‿◕。)づ", "(っ´▽`)っ", "( ˶ˆᗜˆ˵ )", "(๑>◡<๑)",
26
+ "✨", "💖", "🌸", "🎀",
26
27
  ] as const;
27
28
 
28
29
  /** Tokens treated as emoticons: never counted as words, never rewritten. */
29
- const EMOTICON_TOKENS = new Set<string>([
30
+ export const EMOTICON_TOKENS: ReadonlySet<string> = new Set<string>([
30
31
  ...EMOTICONS,
31
32
  "UwU",
32
33
  "OwO",