omp-uwu 0.2.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
@@ -22,8 +22,11 @@ Restart omp. uwu mode is **on** by default in every session.
22
22
  - `/uwu on` / `/uwu off` sets it explicitly
23
23
  - `/uwu rewrite` uses omp's finalized-message rewrite hook when available
24
24
  - `/uwu prompt` asks the model to write in uwu style while it streams
25
+ - `/uwu colors on` / `/uwu colors off` enables or disables the optional kawaii palette (off by default; preference persists)
25
26
 
26
- The TUI also shows a small, theme-accent-colored `(◕ᴗ◕✿) uwu` status badge. Other clients and plain output are not colorized.
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
+
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.
27
30
 
28
31
  ## What gets uwufied, and what doesn't
29
32
 
@@ -35,7 +38,7 @@ The TUI also shows a small, theme-accent-colored `(◕ᴗ◕✿) uwu` status bad
35
38
  | | Tool-call arguments and file contents the agent writes or edits |
36
39
  | | Commit messages and prompts sent to subagents |
37
40
 
38
- 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.
39
42
 
40
43
  Here's the same prompt and model, with the mode off and on:
41
44
 
@@ -43,6 +46,14 @@ Here's the same prompt and model, with the mode off and on:
43
46
 
44
47
  These are real, unedited replies from `anthropic/claude-opus-5-5`, rendered as images. Both code blocks are identical byte for byte. The transcripts are in [`demo/`](demo).
45
48
 
49
+ ### kawaii/uwu-bench
50
+
51
+ On kawaii/uwu-bench, Opus 5.5 scores 0.1% on its own and 98.5% with omp-uwu enabled:
52
+
53
+ ![kawaii/uwu-bench: Opus 5.5 scores 0.1%, Opus 5.5 with omp-uwu scores 98.5%](https://raw.githubusercontent.com/NaC-L/omp-uwu/main/demo/uwu-bench.png)
54
+
55
+ Only the two scores come from the benchmark run. The other numbers on the chart are computed from them, and the terminal and config panels are decoration.
56
+
46
57
  ## How it works
47
58
 
48
59
  On the first turn, omp-uwu uses the prompt style until the host demonstrates support for the awaited `assistant_message` hook; after that, the default **rewrite** style deterministically uwufies finalized assistant text before it is added to history and context. This first-turn check makes the experience work on both older and newer omp builds. Only text in existing text blocks is changed; code/tool blocks and their metadata stay untouched. Rewrites are markdown-aware and preserve fenced/inline code, URLs, paths, numbers, quoted text, identifiers, and safety-critical words.
@@ -51,13 +62,13 @@ The hook runs after streaming has finished. The TUI refreshes the current assist
51
62
 
52
63
  - **Deterministic rewrite.** No style instruction/token overhead once the hook is detected.
53
64
  - **Prompt style.** Model-dependent, with live uwu output while streaming.
54
- - **Session-local toggle.** Mode and enabled state reset for each new session.
65
+ - **Persistent settings.** `/uwu off` is remembered across sessions; `/uwu on` re-enables uwu. The colors preference is remembered too. Settings are stored in `~/.omp/agent/omp-uwu.json`. UwU mode remains on by default until explicitly changed.
55
66
 
56
67
  ## Install options
57
68
 
58
69
  ```sh
59
70
  omp plugin install omp-uwu # from npm (recommended)
60
- omp plugin install omp-uwu@0.1.0 # pin a version
71
+ omp plugin install omp-uwu@0.4.0 # pin a version
61
72
  omp plugin uninstall omp-uwu # remove
62
73
  ```
63
74
 
@@ -85,6 +96,12 @@ uv run --with pillow python demo/render.py
85
96
 
86
97
  Model output varies between runs, so re-run `record.sh` until you get a reply that reads well.
87
98
 
99
+ The uwu-bench chart is drawn from [`demo/uwu-bench.html`](demo/uwu-bench.html). Edit the HTML, then take a 1536×960 screenshot with headless Chrome:
100
+
101
+ ```sh
102
+ chrome --headless=new --hide-scrollbars --window-size=1536,960 --screenshot="$PWD/demo/uwu-bench.png" "file://$PWD/demo/uwu-bench.html"
103
+ ```
104
+
88
105
  ### Releasing
89
106
 
90
107
  CI (`.github/workflows/ci.yml`) runs `check` and the tests on pushes to `main` and on pull requests. Pushing a `v*` tag runs them again, checks that the tag matches `version` in `package.json`, and publishes to npm with provenance (`.github/workflows/publish.yml`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-uwu",
3
- "version": "0.2.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,4 +1,9 @@
1
- import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { KawaiiTheme } from "./kawaii.ts";
4
+ import { type ContainerClass, installSparkles, sparkle, themePaint } from "./sparkle.ts";
5
+
6
+ import type { ExtensionAPI, Theme } from "@oh-my-pi/pi-coding-agent";
2
7
  import { uwufy } from "./uwufy.ts";
3
8
 
4
9
  export const UWU_PROMPT = `
@@ -6,8 +11,9 @@ export const UWU_PROMPT = `
6
11
  Write your prose replies to the user in playful "uwu" speak:
7
12
  - replace r/l with w in most words ("weawwy", "hewwo"), "th" → "d" occasionally, "na/ne/no" → "nya/nye/nyo" sometimes
8
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
9
15
  - keep it readable; never let the style hide meaning, numbers or warnings
10
- - 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
11
17
 
12
18
  NEVER uwufy any of these — keep them exact and byte-for-byte correct:
13
19
  - code, code blocks, inline code, shell commands, file paths, URLs, identifiers, config keys, error messages you quote
@@ -25,16 +31,32 @@ type UiContext = {
25
31
  ui?: {
26
32
  notify(message: string, level: "info"): void;
27
33
  setStatus?(key: string, text: string | undefined): void;
28
- theme?: { fg(color: string, text: string): string };
34
+ theme?: Theme;
29
35
  };
30
36
  };
31
37
 
32
38
  export default function uwuExtension(pi: ExtensionAPI) {
33
39
  let enabled = true;
40
+ let colorsEnabled = false;
41
+ let stateReady: Promise<void> | undefined;
34
42
  let style: "rewrite" | "prompt" = "rewrite";
35
43
  let hookSupport: boolean | undefined;
36
44
  let promptAddedThisTurn = false;
37
45
  let notifiedFallback = false;
46
+ const statePath = join(pi.pi.getAgentDir(), "omp-uwu.json");
47
+ const kawaiiTheme = new KawaiiTheme(pi.pi);
48
+ const loadState = () => {
49
+ stateReady ??= readFile(statePath, "utf8")
50
+ .then((raw) => {
51
+ const state: unknown = JSON.parse(raw);
52
+ if (typeof state !== "object" || state === null) return;
53
+ if ("enabled" in state && typeof state.enabled === "boolean") enabled = state.enabled;
54
+ if ("colors" in state && typeof state.colors === "boolean") colorsEnabled = state.colors;
55
+ })
56
+ .catch(() => {});
57
+ return stateReady;
58
+ };
59
+ const saveState = () => writeFile(statePath, `${JSON.stringify({ enabled, colors: colorsEnabled }, null, 2)}\n`, "utf8");
38
60
 
39
61
  const updateBadge = (ctx: UiContext) => {
40
62
  if (ctx.mode !== "tui" || ctx.agent?.kind === "sub" || !ctx.ui?.setStatus) return;
@@ -42,11 +64,45 @@ export default function uwuExtension(pi: ExtensionAPI) {
42
64
  ctx.ui.setStatus("omp-uwu", badge && ctx.ui.theme ? ctx.ui.theme.fg("accent", badge) : badge);
43
65
  };
44
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
+
75
+ const syncColors = (ctx: UiContext) => {
76
+ if (ctx.mode !== "tui" || ctx.agent?.kind === "sub" || !ctx.ui?.theme) return;
77
+ if (enabled && colorsEnabled) kawaiiTheme.enable(ctx.ui.theme);
78
+ else kawaiiTheme.disable(ctx.ui.theme);
79
+ };
80
+
45
81
  pi.registerCommand("uwu", {
46
- description: "UwU chat style and mode (usage: /uwu [on|off|prompt|rewrite])",
82
+ description: "UwU chat style and mode (usage: /uwu [on|off|prompt|rewrite|colors [on|off]])",
47
83
  handler: async (args, rawCtx) => {
84
+ await loadState();
48
85
  const ctx = rawCtx as UiContext;
49
86
  const arg = String(args ?? "").trim().toLowerCase();
87
+ if (arg.startsWith("colors")) {
88
+ const option = arg.slice("colors".length).trim();
89
+ if (option === "on") colorsEnabled = true;
90
+ else if (option === "off") colorsEnabled = false;
91
+ else if (!option) colorsEnabled = !colorsEnabled;
92
+ else {
93
+ ctx.ui?.notify("Usage: /uwu [on|off|prompt|rewrite|colors [on|off]]", "info");
94
+ return;
95
+ }
96
+ try {
97
+ await saveState();
98
+ } catch {
99
+ ctx.ui?.notify("uwu setting could not be saved; it may reset next session", "info");
100
+ }
101
+ syncColors(ctx);
102
+ updateBadge(ctx);
103
+ ctx.ui?.notify(`kawaii chat colors ${colorsEnabled ? "on" : "off"}${colorsEnabled && !enabled ? " (active when uwu mode is on)" : ""}`, "info");
104
+ return;
105
+ }
50
106
  if (arg === "prompt" || arg === "rewrite") {
51
107
  style = arg;
52
108
  enabled = true;
@@ -54,17 +110,29 @@ export default function uwuExtension(pi: ExtensionAPI) {
54
110
  else if (arg === "off") enabled = false;
55
111
  else if (!arg) enabled = !enabled;
56
112
  else {
57
- ctx.ui?.notify("Usage: /uwu [on|off|prompt|rewrite]", "info");
113
+ ctx.ui?.notify("Usage: /uwu [on|off|prompt|rewrite|colors [on|off]]", "info");
58
114
  return;
59
115
  }
116
+ try {
117
+ await saveState();
118
+ } catch {
119
+ ctx.ui?.notify("uwu setting could not be saved; it may reset next session", "info");
120
+ }
121
+ syncColors(ctx);
60
122
  updateBadge(ctx);
61
123
  ctx.ui?.notify(enabled ? `uwu mode ${style === "rewrite" ? "rewrite" : "prompt"}! (◕ᴗ◕✿)` : "uwu mode off", "info");
62
124
  },
63
125
  });
64
126
 
65
- pi.on("session_start", (_event, rawCtx) => updateBadge(rawCtx as UiContext));
127
+ pi.on("session_start", async (_event, rawCtx) => {
128
+ await loadState();
129
+ const ctx = rawCtx as UiContext;
130
+ syncColors(ctx);
131
+ updateBadge(ctx);
132
+ });
66
133
 
67
134
  pi.on("before_agent_start", async (event, rawCtx) => {
135
+ await loadState();
68
136
  promptAddedThisTurn = false;
69
137
  const ctx = rawCtx as UiContext;
70
138
  if (ctx.agent?.kind === "sub") return undefined;
@@ -76,6 +144,7 @@ export default function uwuExtension(pi: ExtensionAPI) {
76
144
  return { systemPrompt: [...event.systemPrompt, UWU_PROMPT] };
77
145
  });
78
146
 
147
+
79
148
  // The hook is newer than the bundled 18.4.3 types, so register structurally.
80
149
  // ExtensionAPI.on stores event names as strings; old hosts simply never emit it.
81
150
  (pi.on as unknown as (name: string, handler: (event: RewriteEvent) => unknown) => void)(
package/src/kawaii.ts ADDED
@@ -0,0 +1,103 @@
1
+ import type * as PiCodingAgent from "@oh-my-pi/pi-coding-agent";
2
+ import type { Theme, ThemeBg, ThemeColor } from "@oh-my-pi/pi-coding-agent";
3
+
4
+ export type ThemeRuntime = Pick<typeof PiCodingAgent, "Theme" | "setThemeInstance">;
5
+
6
+ const FG_KEYS: ThemeColor[] = [
7
+ "accent", "border", "borderAccent", "borderMuted", "success", "error", "warning", "muted", "dim", "text",
8
+ "thinkingText", "userMessageText", "customMessageText", "customMessageLabel", "toolTitle", "toolOutput",
9
+ "mdHeading", "mdLink", "mdLinkUrl", "mdCode", "mdCodeBlock", "mdCodeBlockBorder", "mdQuote", "mdQuoteBorder",
10
+ "mdHr", "mdListBullet", "toolDiffAdded", "toolDiffRemoved", "toolDiffContext", "syntaxComment", "syntaxKeyword",
11
+ "syntaxFunction", "syntaxVariable", "syntaxString", "syntaxNumber", "syntaxType", "syntaxOperator", "syntaxPunctuation",
12
+ "thinkingOff", "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh", "thinkingXhigh", "thinkingMax",
13
+ "bashMode", "pythonMode", "statusLineSep", "statusLineModel", "statusLinePath", "statusLineGitClean", "statusLineGitDirty",
14
+ "statusLineContext", "statusLineSpend", "statusLineStaged", "statusLineDirty", "statusLineUntracked", "statusLineOutput",
15
+ "statusLineCost", "statusLineSubagents",
16
+ ];
17
+
18
+ const BG_KEYS: ThemeBg[] = ["selectedBg", "userMessageBg", "customMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg", "statusLineBg"];
19
+ const FG_DEFAULT = "\x1b[39m";
20
+ const BG_DEFAULT = "\x1b[49m";
21
+
22
+ const DARK_PALETTE: Partial<Record<ThemeColor | ThemeBg, string>> = {
23
+ accent: "#ff8fcf", border: "#b99aca", borderAccent: "#ff8fcf", thinkingText: "#cbb8ef",
24
+ mdHeading: "#ff9ed8", mdLink: "#b5a8ff", mdLinkUrl: "#a08fd8", mdCode: "#8fe8c3",
25
+ mdCodeBlockBorder: "#b99aca", mdQuote: "#ffc4e1", mdQuoteBorder: "#ff8fcf", mdHr: "#b99aca",
26
+ mdListBullet: "#ffb38a", userMessageBg: "#3a2433",
27
+ };
28
+
29
+ const LIGHT_PALETTE: Partial<Record<ThemeColor | ThemeBg, string>> = {
30
+ accent: "#c52f80", border: "#9c75b6", borderAccent: "#c52f80", thinkingText: "#735f9e",
31
+ mdHeading: "#b32470", mdLink: "#624ac6", mdLinkUrl: "#7663b1", mdCode: "#087b5c",
32
+ mdCodeBlockBorder: "#9c75b6", mdQuote: "#99416f", mdQuoteBorder: "#c52f80", mdHr: "#9c75b6",
33
+ mdListBullet: "#c35424", userMessageBg: "#fde8f3",
34
+ };
35
+
36
+ /** Build a pastel copy of a host theme, retaining its colors outside the palette. */
37
+ export function buildKawaiiTheme(ThemeClass: ThemeRuntime["Theme"], base: Theme): Theme {
38
+ const palette = base.isLight ? LIGHT_PALETTE : DARK_PALETTE;
39
+ const fg: Partial<Record<ThemeColor, string>> = {};
40
+ const bg: Partial<Record<ThemeBg, string>> = {};
41
+
42
+ for (const key of FG_KEYS) {
43
+ try {
44
+ const ansi = base.getFgAnsi(key);
45
+ fg[key] = palette[key] ?? (ansi === FG_DEFAULT ? "" : base.getColorHex(key));
46
+ } catch {
47
+ // A host newer than the plugin's type dependency may expose extra colors.
48
+ }
49
+ }
50
+ for (const key of BG_KEYS) {
51
+ try {
52
+ const ansi = base.getBgAnsi(key);
53
+ bg[key] = palette[key] ?? (ansi === BG_DEFAULT ? "" : base.getBgHex(key));
54
+ } catch {
55
+ // Missing theme colors are left to the host defaults.
56
+ }
57
+ }
58
+
59
+ const preset = base.getSymbolPreset();
60
+ const theme = new ThemeClass(
61
+ fg as Record<ThemeColor, string>,
62
+ bg as Record<ThemeBg, string>,
63
+ base.getColorMode(),
64
+ preset,
65
+ {},
66
+ { status: base.getSpinnerFrames("status"), activity: base.getSpinnerFrames("activity") },
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;
77
+ }
78
+
79
+ /** A reversible, ephemeral kawaii theme; never edits the user's theme file. */
80
+ export class KawaiiTheme {
81
+ #runtime: ThemeRuntime;
82
+ #base: Theme | undefined;
83
+ #overlay: Theme | undefined;
84
+
85
+ constructor(runtime: ThemeRuntime) {
86
+ this.#runtime = runtime;
87
+ }
88
+
89
+
90
+ enable(current: Theme): void {
91
+ if (this.#overlay && current === this.#overlay) return;
92
+ this.#base = current;
93
+ this.#overlay = buildKawaiiTheme(this.#runtime.Theme, current);
94
+ this.#runtime.setThemeInstance(this.#overlay);
95
+ }
96
+
97
+ disable(current: Theme | undefined): void {
98
+ if (!this.#overlay) return;
99
+ if (current === this.#overlay && this.#base) this.#runtime.setThemeInstance(this.#base);
100
+ this.#base = undefined;
101
+ this.#overlay = undefined;
102
+ }
103
+ }
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",