omp-uwu 0.4.0 → 0.5.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
@@ -16,17 +16,22 @@ The prose is uwufied, but the inline code, numbers and the fixed loop stay exact
16
16
  omp plugin install omp-uwu
17
17
  ```
18
18
 
19
- Restart omp. uwu mode is **on** by default in every session.
19
+ Restart omp. Defaults are **on**, **rewrite**, **mid**, **auto**, with colors **off** (unless saved preferences say otherwise).
20
20
 
21
21
  - `/uwu` toggles it
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 display` explicitly opts into **experimental, ANSI-TUI-only** display styling; never changes history or adds a prompt
26
+ - `/uwu level low` / `/uwu level mid` / `/uwu level max` selects light, standard or stronger intensity
27
+ - `/uwu locale auto` / `/uwu locale en` / `/uwu locale tr` selects critical-word protection and prompt language guidance
28
+ - `/uwu status` reports settings and observed capabilities (unknown/pending is not a support guarantee)
29
+ - `/uwu preview <text>` shows a deterministic sample with its original case, without changing any settings—even if uwu is off
25
30
  - `/uwu colors on` / `/uwu colors off` enables or disables the optional kawaii palette (off by default; preference persists)
26
31
 
27
32
  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
33
 
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.
34
+ With colors on, the ANSI 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, so inline code, code blocks, link targets and stored message text stay untouched. Display styling works independently of colors; when both are on, prose is transformed first, then painted. A host-owned transform (such as live-voice transcript coloring) wins over both. omp's `AssistantMessageComponent` is not public extension API, so the plugin discovers it through the shared `Container` base class. If discovery is unavailable, display/sparkles do nothing; the palette can still apply. There is **no silent prompt fallback for display**.
30
35
 
31
36
  ## What gets uwufied, and what doesn't
32
37
 
@@ -40,6 +45,8 @@ With colors on, the TUI also paints **sparkles** in the agent's chat prose: `uwu
40
45
 
41
46
  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.
42
47
 
48
+ Intensity and locale apply to rewrite, prompt and display. `mid` keeps the original rewrite strength; `low` changes fewer words and adds fewer decorations; `max` is stronger. `auto` protects English **and** Turkish critical words, not automatic language detection. `tr` also protects Turkish forms while retaining English protection; `en` uses English protection. These settings never translate text. Negations/warnings and common Turkish inflections are protected conservatively, not by a complete linguistic parser.
49
+
43
50
  Here's the same prompt and model, with the mode off and on:
44
51
 
45
52
  ![Two real omp replies side by side: plain English on the left, uwu-speak on the right, with an identical code block in both](https://raw.githubusercontent.com/NaC-L/omp-uwu/main/demo/before-after.png)
@@ -56,19 +63,24 @@ Only the two scores come from the benchmark run. The other numbers on the chart
56
63
 
57
64
  ## How it works
58
65
 
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.
66
+ In the default **rewrite** style, the first turn uses prompt styling until the host demonstrates support for the awaited `assistant_message` hook; after that, finalized assistant text is deterministically uwufied before it is added to history and context. This first-turn check supports both older and newer omp builds. Only existing text blocks change; code/tool blocks and metadata stay untouched. Rewrites preserve fenced/inline code, URLs, paths, numbers, quoted spans, identifiers and safety-critical words. Subagent prompts, messages and tool-call arguments are never styled.
60
67
 
61
68
  The hook runs after streaming has finished. The TUI refreshes the current assistant message at completion, but clients that render only streamed chunks may continue showing the original text. If the hook is unavailable (including omp `18.4.3`), prompt style remains enabled as the compatibility fallback. `/uwu prompt` selects live prompt styling directly.
62
69
 
63
70
  - **Deterministic rewrite.** No style instruction/token overhead once the hook is detected.
64
71
  - **Prompt style.** Model-dependent, with live uwu output while streaming.
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.
72
+ - **Experimental display.** Explicit opt-in, ANSI TUI only: `uwufyProse` transforms individual Markdown prose runs before width/wrapping, without rewriting stored content or injecting instructions. No appended emoticons or stutters. Display/level/locale/on/off changes invalidate existing discovered assistant components even with colors off.
73
+ - **Persistent settings.** Enabled state, colors, style, level and locale are saved in `~/.omp/agent/omp-uwu.json`. Older enabled/colors-only files load with rewrite/mid/auto defaults; invalid individual values keep their defaults.
74
+
75
+ Display is deliberately **fragment-based**: inline formatting, links, newlines and streaming edits can split runs and reset deterministic word positions, so effects may differ from a whole-message rewrite or preview. Identifiers, numbers and complete quoted spans within a run stay protected; a quote split across Markdown runs cannot be protected as a whole. Host paths that bypass its prose transform (including blockquotes, some table/heading/math rendering) may remain unchanged or differ from normal paragraphs. This is not a universal Markdown rewriting layer. Prompt-mode preview is only a deterministic approximation, not a prediction of model output.
76
+
77
+ Capability fallback is explicit: `/uwu status` distinguishes a missing `Container`, pending assistant discovery and an observed ANSI hook; rewrite support is reported as unknown/detected/not observed. Discovery alone does not prove every render path works. Native/client rendering is **unsupported**: the installed omp `18.4.4` native description sends raw Markdown rather than using the ANSI text transform. RPC/print/export output and history stay original in display mode. Use `/uwu rewrite` or `/uwu prompt` explicitly if you want another style; display never switches to them automatically.
66
78
 
67
79
  ## Install options
68
80
 
69
81
  ```sh
70
82
  omp plugin install omp-uwu # from npm (recommended)
71
- omp plugin install omp-uwu@0.4.0 # pin a version
83
+ omp plugin install omp-uwu@0.5.0 # pin a version
72
84
  omp plugin uninstall omp-uwu # remove
73
85
  ```
74
86
 
@@ -84,6 +96,10 @@ omp plugin link . # use this checkout instead of the installed copy
84
96
  omp -e ./src/index.ts # or load it for a single run
85
97
  ```
86
98
 
99
+ The full-module integration tests exercise real omp Markdown/Assistant components, narrow wrapping, inline/fenced code preservation, unchanged raw messages, cache invalidation without colors, and host-transform precedence. They restore patched prototypes after each case. An additional installed-host test automatically uses `~/.bun/install/global/node_modules/@oh-my-pi/pi-tui` if present; set `OMP_UWU_HOST_TUI` to a different pi-tui package directory to test another installation (otherwise that case is skipped).
100
+
101
+ For an interactive smoke check, start `omp -e ./src/index.ts` in an isolated test agent directory, run `/uwu colors off`, `/uwu display`, then `/uwu status`. Ask for ordinary prose plus inline/fenced code and narrow the terminal; prose should be styled and rewrapped while code stays exact. Change `/uwu level low` to `max`, switch locales and `/uwu off` without enabling colors: existing assistant paragraphs should refresh. Reopen the saved transcript/export to confirm raw text is unchanged, and verify the next model prompt has no uwu instruction. On a native client, expect unchanged prose, not fallback prompt styling.
102
+
87
103
  ### Re-recording the demo
88
104
 
89
105
  The GIF and the side-by-side image are rendered from real transcripts in `demo/`. `record.sh` captures one reply with the extension loaded and one without it. It runs in a throwaway agent dir, so your personal rules and extensions don't affect the replies. `render.py` then draws both images:
@@ -108,8 +124,8 @@ CI (`.github/workflows/ci.yml`) runs `check` and the tests on pushes to `main` a
108
124
 
109
125
  ```sh
110
126
  # bump "version" in package.json, commit, then:
111
- git tag v0.2.0
112
- git push origin main v0.2.0
127
+ git tag v0.5.0
128
+ git push origin main v0.5.0
113
129
  ```
114
130
 
115
131
  The workflow uses npm [trusted publishing](https://docs.npmjs.com/trusted-publishers/), so the repository has no npm token. npm only allows trusted publishing on a package that already exists, so the first version is published by hand with `npm publish --access public`. After that, go to the package's Settings → Trusted publishing on npmjs.com and add GitHub Actions with user `NaC-L`, repository `omp-uwu`, and workflow `publish.yml`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-uwu",
3
- "version": "0.4.0",
3
+ "version": "0.5.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
@@ -4,7 +4,7 @@ import { KawaiiTheme } from "./kawaii.ts";
4
4
  import { type ContainerClass, installSparkles, sparkle, themePaint } from "./sparkle.ts";
5
5
 
6
6
  import type { ExtensionAPI, Theme } from "@oh-my-pi/pi-coding-agent";
7
- import { uwufy } from "./uwufy.ts";
7
+ import { type UwuLevel, type UwuLocale, uwufy, uwufyProse } from "./uwufy.ts";
8
8
 
9
9
  export const UWU_PROMPT = `
10
10
  # uwu mode (display style only)
@@ -18,9 +18,34 @@ Write your prose replies to the user in playful "uwu" speak:
18
18
  NEVER uwufy any of these — keep them exact and byte-for-byte correct:
19
19
  - code, code blocks, inline code, shell commands, file paths, URLs, identifiers, config keys, error messages you quote
20
20
  - tool call arguments, file contents the agent writes or edits, commit messages and prompts sent to subagents
21
+ - English negations, warnings and errors; in auto/tr also Turkish negations, warnings and errors (including inflected forms)
21
22
  The style applies only to natural-language chat text shown to the user. Task quality and correctness are unchanged.
22
23
  `.trim();
23
24
 
25
+ type UwuStyle = "rewrite" | "prompt" | "display";
26
+ const USAGE = "Usage: /uwu [on|off|rewrite|prompt|display|level low|mid|max|locale auto|en|tr|status|preview <text>|colors [on|off]]";
27
+
28
+ function stylePrompt(level: UwuLevel, locale: UwuLocale): string {
29
+ let prompt = UWU_PROMPT;
30
+ if (level === "low") {
31
+ prompt = prompt
32
+ .replace('replace r/l with w in most words ("weawwy", "hewwo"), "th" → "d" occasionally, "na/ne/no" → "nya/nye/nyo" sometimes',
33
+ 'use a light, mostly unchanged style: rarely replace r/l with w; very rarely use "th" → "d" or "na/ne/no" → "nya/nye/nyo"')
34
+ .replace('occasional stutter ("h-hewwo") and cute kaomoji/emoticons', 'very rare stutter ("h-hewwo") and cute kaomoji/emoticons');
35
+ } else if (level === "max") {
36
+ prompt = prompt
37
+ .replace('replace r/l with w in most words ("weawwy", "hewwo"), "th" → "d" occasionally, "na/ne/no" → "nya/nye/nyo" sometimes',
38
+ 'use a stronger but readable style: replace r/l with w in ordinary words; frequently use "th" → "d" and "na/ne/no" → "nya/nye/nyo"')
39
+ .replace('occasional stutter ("h-hewwo") and cute kaomoji/emoticons', 'more frequent stutter ("h-hewwo") and cute kaomoji/emoticons');
40
+ }
41
+ if (locale === "en") {
42
+ prompt = prompt.replace("in auto/tr also Turkish negations, warnings and errors (including inflected forms)", "English-focused style and critical-word protection");
43
+ } else if (locale === "tr") {
44
+ prompt += "\nUse Turkish-aware prose styling and protect Turkish critical words/inflections; English critical words remain protected. Do not translate the reply.";
45
+ }
46
+ return prompt;
47
+ }
48
+
24
49
  type RewriteEvent = {
25
50
  message: { role: string; content: Array<{ type: string; text?: string; [key: string]: unknown }> };
26
51
  };
@@ -39,7 +64,11 @@ export default function uwuExtension(pi: ExtensionAPI) {
39
64
  let enabled = true;
40
65
  let colorsEnabled = false;
41
66
  let stateReady: Promise<void> | undefined;
42
- let style: "rewrite" | "prompt" = "rewrite";
67
+ let style: UwuStyle = "rewrite";
68
+ let level: UwuLevel = "mid";
69
+ let locale: UwuLocale = "auto";
70
+ let subagentTurn = false;
71
+ let renderAllowed = true;
43
72
  let hookSupport: boolean | undefined;
44
73
  let promptAddedThisTurn = false;
45
74
  let notifiedFallback = false;
@@ -52,11 +81,14 @@ export default function uwuExtension(pi: ExtensionAPI) {
52
81
  if (typeof state !== "object" || state === null) return;
53
82
  if ("enabled" in state && typeof state.enabled === "boolean") enabled = state.enabled;
54
83
  if ("colors" in state && typeof state.colors === "boolean") colorsEnabled = state.colors;
84
+ if ("style" in state && (state.style === "rewrite" || state.style === "prompt" || state.style === "display")) style = state.style;
85
+ if ("level" in state && (state.level === "low" || state.level === "mid" || state.level === "max")) level = state.level;
86
+ if ("locale" in state && (state.locale === "auto" || state.locale === "en" || state.locale === "tr")) locale = state.locale;
55
87
  })
56
88
  .catch(() => {});
57
89
  return stateReady;
58
90
  };
59
- const saveState = () => writeFile(statePath, `${JSON.stringify({ enabled, colors: colorsEnabled }, null, 2)}\n`, "utf8");
91
+ const saveState = () => writeFile(statePath, `${JSON.stringify({ enabled, colors: colorsEnabled, style, level, locale }, null, 2)}\n`, "utf8");
60
92
 
61
93
  const updateBadge = (ctx: UiContext) => {
62
94
  if (ctx.mode !== "tui" || ctx.agent?.kind === "sub" || !ctx.ui?.setStatus) return;
@@ -64,13 +96,17 @@ export default function uwuExtension(pi: ExtensionAPI) {
64
96
  ctx.ui.setStatus("omp-uwu", badge && ctx.ui.theme ? ctx.ui.theme.fg("accent", badge) : badge);
65
97
  };
66
98
 
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.
99
+ // Display and sparkle composition share the host's per-prose ANSI hook.
100
+ // Prose is transformed before Markdown wrapping; no message/history changes.
69
101
  const paint = themePaint(() => pi.pi.theme);
70
102
  const host = pi.pi as { Container?: ContainerClass };
71
- if (host.Container) {
72
- installSparkles(host.Container, { isActive: () => enabled && colorsEnabled, transform: (text) => sparkle(text, paint) });
73
- }
103
+ const rendering = host.Container && installSparkles(host.Container, {
104
+ isActive: () => renderAllowed && enabled && (style === "display" || colorsEnabled),
105
+ transform: (text) => {
106
+ const prose = style === "display" ? uwufyProse(text, { level, locale }) : text;
107
+ return colorsEnabled ? sparkle(prose, paint) : prose;
108
+ },
109
+ });
74
110
 
75
111
  const syncColors = (ctx: UiContext) => {
76
112
  if (ctx.mode !== "tui" || ctx.agent?.kind === "sub" || !ctx.ui?.theme) return;
@@ -79,38 +115,43 @@ export default function uwuExtension(pi: ExtensionAPI) {
79
115
  };
80
116
 
81
117
  pi.registerCommand("uwu", {
82
- description: "UwU chat style and mode (usage: /uwu [on|off|prompt|rewrite|colors [on|off]])",
118
+ description: "UwU style, intensity, locale and experimental display (use /uwu status)",
83
119
  handler: async (args, rawCtx) => {
84
120
  await loadState();
85
121
  const ctx = rawCtx as UiContext;
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");
122
+ if (ctx.agent?.kind === "sub") return;
123
+ const input = String(args ?? "").trim();
124
+ const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(input);
125
+ const arg = (match?.[1] ?? "").toLowerCase();
126
+ const option = (match?.[2] ?? "").trim();
127
+ const value = option.toLowerCase();
128
+ if (arg === "status" && !option) {
129
+ const display = !host.Container ? "unavailable (no Container)" : rendering?.isSupported() ? "ANSI hook detected" : "pending component discovery";
130
+ ctx.ui?.notify(`uwu ${enabled ? "on" : "off"}; style=${style}; level=${level}; locale=${locale}; colors=${colorsEnabled ? "on" : "off"}; rewrite=${hookSupport === undefined ? "unknown" : hookSupport ? "detected" : "not observed"}; display=${display}; native/client display=unsupported; prompt fallback=${enabled && style === "rewrite" && hookSupport !== true ? "on" : "off"}`, "info");
131
+ return;
132
+ }
133
+ if (arg === "preview") {
134
+ if (!option) {
135
+ ctx.ui?.notify(USAGE, "info");
94
136
  return;
95
137
  }
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");
138
+ ctx.ui?.notify(style === "display" ? uwufyProse(option, { level, locale }) : uwufy(option, { level, locale }), "info");
104
139
  return;
105
140
  }
106
- if (arg === "prompt" || arg === "rewrite") {
141
+ if (arg === "colors" && (value === "on" || value === "off" || !value)) {
142
+ colorsEnabled = value ? value === "on" : !colorsEnabled;
143
+ } else if (arg === "level" && (value === "low" || value === "mid" || value === "max")) {
144
+ level = value;
145
+ } else if (arg === "locale" && (value === "auto" || value === "en" || value === "tr")) {
146
+ locale = value;
147
+ } else if (!option && (arg === "prompt" || arg === "rewrite" || arg === "display")) {
107
148
  style = arg;
108
149
  enabled = true;
109
- } else if (arg === "on") enabled = true;
110
- else if (arg === "off") enabled = false;
111
- else if (!arg) enabled = !enabled;
150
+ } else if (!option && arg === "on") enabled = true;
151
+ else if (!option && arg === "off") enabled = false;
152
+ else if (!input) enabled = !enabled;
112
153
  else {
113
- ctx.ui?.notify("Usage: /uwu [on|off|prompt|rewrite|colors [on|off]]", "info");
154
+ ctx.ui?.notify(USAGE, "info");
114
155
  return;
115
156
  }
116
157
  try {
@@ -118,9 +159,18 @@ export default function uwuExtension(pi: ExtensionAPI) {
118
159
  } catch {
119
160
  ctx.ui?.notify("uwu setting could not be saved; it may reset next session", "info");
120
161
  }
162
+ renderAllowed = ctx.mode === "tui";
121
163
  syncColors(ctx);
164
+ rendering?.refresh();
122
165
  updateBadge(ctx);
123
- ctx.ui?.notify(enabled ? `uwu mode ${style === "rewrite" ? "rewrite" : "prompt"}! (◕ᴗ◕✿)` : "uwu mode off", "info");
166
+ if (arg === "colors") {
167
+ ctx.ui?.notify(`kawaii chat colors ${colorsEnabled ? "on" : "off"}${colorsEnabled && !enabled ? " (active when uwu mode is on)" : ""}`, "info");
168
+ } else {
169
+ ctx.ui?.notify(enabled ? `uwu mode ${style}; level=${level}; locale=${locale}! (◕ᴗ◕✿)` : "uwu mode off", "info");
170
+ }
171
+ if (enabled && style === "display") {
172
+ ctx.ui?.notify(`Experimental display: ANSI TUI prose only; ${!host.Container ? "render hook unavailable" : rendering?.isSupported() ? "render hook detected" : "awaiting assistant component discovery"}. Native/client rendering is unsupported; history stays unchanged and no prompt fallback is used.`, "info");
173
+ }
124
174
  },
125
175
  });
126
176
 
@@ -128,6 +178,8 @@ export default function uwuExtension(pi: ExtensionAPI) {
128
178
  await loadState();
129
179
  const ctx = rawCtx as UiContext;
130
180
  syncColors(ctx);
181
+ renderAllowed = ctx.mode === "tui" && ctx.agent?.kind !== "sub";
182
+ rendering?.refresh();
131
183
  updateBadge(ctx);
132
184
  });
133
185
 
@@ -135,27 +187,29 @@ export default function uwuExtension(pi: ExtensionAPI) {
135
187
  await loadState();
136
188
  promptAddedThisTurn = false;
137
189
  const ctx = rawCtx as UiContext;
138
- if (ctx.agent?.kind === "sub") return undefined;
190
+ subagentTurn = ctx.agent?.kind === "sub";
191
+ if (subagentTurn) return undefined;
139
192
  // Start with the prompt until this host proves it has the finalized hook.
140
193
  // That styles the first reply on both old and new omp versions.
141
- const needsPrompt = style === "prompt" || hookSupport !== true;
194
+ const needsPrompt = style === "prompt" || (style === "rewrite" && hookSupport !== true);
142
195
  if (!enabled || !needsPrompt) return undefined;
143
196
  promptAddedThisTurn = true;
144
- return { systemPrompt: [...event.systemPrompt, UWU_PROMPT] };
197
+ return { systemPrompt: [...event.systemPrompt, stylePrompt(level, locale)] };
145
198
  });
146
199
 
147
200
 
148
201
  // The hook is newer than the bundled 18.4.3 types, so register structurally.
149
202
  // ExtensionAPI.on stores event names as strings; old hosts simply never emit it.
150
- (pi.on as unknown as (name: string, handler: (event: RewriteEvent) => unknown) => void)(
203
+ (pi.on as unknown as (name: string, handler: (event: RewriteEvent, ctx?: UiContext) => unknown) => void)(
151
204
  "assistant_message",
152
- (event) => {
205
+ (event, ctx) => {
206
+ if (ctx?.agent?.kind === "sub" || (!ctx?.agent && subagentTurn)) return;
153
207
  hookSupport = true;
154
208
  if (!enabled || style !== "rewrite" || promptAddedThisTurn || event.message.role !== "assistant") return;
155
209
  let changed = false;
156
210
  const content = event.message.content.map((block) => {
157
211
  if (block.type !== "text" || typeof block.text !== "string") return block;
158
- const text = uwufy(block.text);
212
+ const text = uwufy(block.text, { level, locale });
159
213
  if (text === block.text) return block;
160
214
  changed = true;
161
215
  return { ...block, text };
@@ -164,7 +218,9 @@ export default function uwuExtension(pi: ExtensionAPI) {
164
218
  },
165
219
  );
166
220
 
167
- pi.on("message_end", (rawEvent) => {
221
+ pi.on("message_end", (rawEvent, rawCtx) => {
222
+ const ctx = rawCtx as UiContext | undefined;
223
+ if (ctx?.agent?.kind === "sub" || (!ctx?.agent && subagentTurn)) return;
168
224
  const message = (rawEvent as MessageEndEvent).message;
169
225
  if (message.role === "assistant" && message.stopReason !== "aborted" && message.stopReason !== "error" && hookSupport === undefined) {
170
226
  hookSupport = false;
package/src/sparkle.ts CHANGED
@@ -93,17 +93,29 @@ export interface ContainerClass {
93
93
  }
94
94
 
95
95
  export interface SparkleControl {
96
- /** Whether new/updated assistant messages should be painted. */
96
+ /** Whether assistant prose should be transformed (independent of colors). */
97
97
  isActive(): boolean;
98
- /** Stable transform identity; Markdown caches renders by it. */
99
98
  transform: ColorTransform;
100
99
  }
101
100
 
102
- /** Process-wide state, so extension reloads swap the control instead of re-patching. */
101
+ export interface SparkleInstallation {
102
+ /** Discovery proves only the ANSI render hook, not native/client rendering. */
103
+ isSupported(): boolean;
104
+ /** Refresh existing components, including when no theme change occurs. */
105
+ refresh(): void;
106
+ /** Restore host methods; useful for isolated integration tests. */
107
+ dispose(): void;
108
+ }
109
+
110
+ /** Shared per host Container, so reloads replace controls without double patches. */
103
111
  interface Registry {
104
- control?: SparkleControl;
112
+ control: SparkleControl;
113
+ transform: ColorTransform;
105
114
  assistant?: AssistantPrototype;
106
- watching?: boolean;
115
+ components: Set<WeakRef<object & { invalidate?: () => void }>>;
116
+ seen: WeakSet<object>;
117
+ restoreAssistant?: () => void;
118
+ restoreWatch?: () => void;
107
119
  }
108
120
  const REGISTRY = Symbol.for("omp-uwu.sparkles");
109
121
 
@@ -139,12 +151,13 @@ function patchAssistant(proto: AssistantPrototype, registry: Registry): void {
139
151
  applied.delete(this);
140
152
  originalSet.call(this, transform);
141
153
  };
142
- // Theme changes re-enter updateContent through invalidate(), so toggling
143
- // colors repaints existing messages too.
144
154
  proto.updateContent = function (this: object, ...args: unknown[]) {
155
+ if (!registry.seen.has(this)) {
156
+ registry.seen.add(this);
157
+ registry.components.add(new WeakRef(this));
158
+ }
145
159
  if (!hostTransforms.has(this)) {
146
- const control = registry.control;
147
- const wanted = control?.isActive() ? control.transform : undefined;
160
+ const wanted = registry.control.isActive() ? registry.transform : undefined;
148
161
  if (applied.get(this) !== wanted) {
149
162
  applied.set(this, wanted);
150
163
  originalSet.call(this, wanted);
@@ -152,6 +165,12 @@ function patchAssistant(proto: AssistantPrototype, registry: Registry): void {
152
165
  }
153
166
  return originalUpdate.apply(this, args);
154
167
  };
168
+ const patchedSet = proto.setTextColorTransform;
169
+ const patchedUpdate = proto.updateContent;
170
+ registry.restoreAssistant = () => {
171
+ if (proto.setTextColorTransform === patchedSet) proto.setTextColorTransform = originalSet;
172
+ if (proto.updateContent === patchedUpdate) proto.updateContent = originalUpdate;
173
+ };
155
174
  }
156
175
 
157
176
  /**
@@ -164,24 +183,51 @@ function patchAssistant(proto: AssistantPrototype, registry: Registry): void {
164
183
  * update. So `addChild` is watched until the first assistant component shows
165
184
  * up, its prototype is patched, and the watch is removed again.
166
185
  */
167
- export function installSparkles(Container: ContainerClass, control: SparkleControl): void {
168
- const holder = globalThis as { [REGISTRY]?: Registry };
169
- const registry = (holder[REGISTRY] ??= {});
186
+ export function installSparkles(Container: ContainerClass, control: SparkleControl): SparkleInstallation {
187
+ const containerProto = Container.prototype as ContainerClass["prototype"] & { [REGISTRY]?: Registry };
188
+ const registry = (containerProto[REGISTRY] ??= {
189
+ control,
190
+ transform: (text: string) => control.transform(text),
191
+ components: new Set(),
192
+ seen: new WeakSet(),
193
+ });
170
194
  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);
195
+ const refresh = () => {
196
+ // A new identity also drops the host's stable-row and Markdown caches.
197
+ registry.transform = (text) => registry.control.transform(text);
198
+ for (const ref of registry.components) {
199
+ const component = ref.deref();
200
+ if (component) component.invalidate?.();
201
+ else registry.components.delete(ref);
183
202
  }
184
- return originalAdd.call(this, child);
185
203
  };
186
- containerProto.addChild = watch;
204
+ if (!registry.assistant && !registry.restoreWatch) {
205
+ const originalAdd = containerProto.addChild;
206
+ const watch = function (this: object, child: unknown) {
207
+ const assistant = findAssistantPrototype(this) ?? findAssistantPrototype(child);
208
+ if (assistant) {
209
+ registry.restoreWatch?.();
210
+ registry.restoreWatch = undefined;
211
+ registry.assistant = assistant;
212
+ patchAssistant(assistant, registry);
213
+ }
214
+ return originalAdd.call(this, child);
215
+ };
216
+ registry.restoreWatch = () => {
217
+ if (containerProto.addChild === watch) containerProto.addChild = originalAdd;
218
+ };
219
+ containerProto.addChild = watch;
220
+ }
221
+ refresh();
222
+ return {
223
+ isSupported: () => registry.assistant !== undefined,
224
+ refresh,
225
+ dispose: () => {
226
+ registry.control = { isActive: () => false, transform: (text) => text };
227
+ refresh();
228
+ registry.restoreWatch?.();
229
+ registry.restoreAssistant?.();
230
+ delete containerProto[REGISTRY];
231
+ },
232
+ };
187
233
  }
package/src/uwufy.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Only natural-language words are touched. Everything that has to stay exact is
5
5
  * left byte-for-byte alone: fenced/indented code, math blocks, inline code,
6
- * URLs and link targets, HTML tags, double-quoted spans, and any token that
6
+ * URLs and link targets, HTML tags, quoted spans, and any token that
7
7
  * looks like an identifier, path, number, flag, acronym, or warning word.
8
8
  *
9
9
  * Every "sometimes" decision is keyed on the word's position (paragraph,
@@ -14,6 +14,14 @@
14
14
  * the model may start answering in uwu-speak on its own.
15
15
  */
16
16
 
17
+ export type UwuLevel = "low" | "mid" | "max";
18
+ export type UwuLocale = "auto" | "en" | "tr";
19
+
20
+ export interface UwuOptions {
21
+ level?: UwuLevel;
22
+ locale?: UwuLocale;
23
+ }
24
+
17
25
  /** Emoticons the rewriter appends at sentence ends. */
18
26
  export const EMOTICONS = [
19
27
  "uwu", "owo", ">w<", "^w^", ":3", "(◕ᴗ◕✿)",
@@ -39,6 +47,11 @@ export const EMOTICON_TOKENS: ReadonlySet<string> = new Set<string>([
39
47
  "x3",
40
48
  ]);
41
49
 
50
+ const EMOTICON_PATTERN = [...EMOTICON_TOKENS]
51
+ .map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
52
+ .join("|");
53
+ const EMOTICON_AHEAD = new RegExp(String.raw`^\s+(?:${EMOTICON_PATTERN})(?=\s|$)`, "iu");
54
+
42
55
  /** Words that carry meaning a reader must not miss. Kept exact. */
43
56
  const KEEP_WORDS = new Set([
44
57
  "no",
@@ -83,11 +96,22 @@ const KEEP_WORDS = new Set([
83
96
 
84
97
  const TH_WORDS = new Set(["the", "this", "that", "these", "those", "them", "then", "there", "their", "they", "than"]);
85
98
 
86
- // Probabilities for the "sometimes" transforms.
87
- const P_TH = 0.35;
88
- const P_NY = 0.5;
89
- const P_STUTTER = 0.12;
90
- const P_EMOTICON = 0.3;
99
+ // Mid retains the original probabilities; low also softens the r/l rewrite.
100
+ const INTENSITY = {
101
+ low: { rl: 0.4, th: 0.15, ny: 0.2, stutter: 0.04, emoticon: 0.1 },
102
+ mid: { rl: 1, th: 0.35, ny: 0.5, stutter: 0.12, emoticon: 0.3 },
103
+ max: { rl: 1, th: 0.75, ny: 0.85, stutter: 0.25, emoticon: 0.6 },
104
+ } as const satisfies Record<UwuLevel, { rl: number; th: number; ny: number; stutter: number; emoticon: number }>;
105
+
106
+ const TURKISH_KEEP_WORDS: Record<string, true> = { hayır: true, asla: true, hiç: true, hiçbir: true, sakın: true };
107
+ // Conservative lexical guards, not a morphological analyzer. Keep common
108
+ // case/possessive forms and conjugations (including negative deletion forms).
109
+ const TURKISH_CRITICAL = [
110
+ /^değil[\p{L}\p{M}]*$/u,
111
+ /^yok[\p{L}\p{M}]*$/u,
112
+ /^(?:hata|uyarı)(?:lar|ler)?(?:[ıiuü]?m(?:[ıiuü]z)?|[ıiuü]?n(?:[ıiuü]z)?|s?[ıiuü])?(?:[ny]?[ıiuüae]|n?[dt][ae]n?|n?[ıiuü]n|y?l[ae]|s[ıiuü]z|l[ıiuü])?(?:ki)?(?:(?:[dt][ıiuü]|y[dt][ıiuü]|ym[ıiuü]ş|y[ıiuü]|s[ıiuü]n)[\p{L}\p{M}]*)?$/u,
113
+ /^sil(?:e(?:r|ce[kğ]|lim|bil|me|mi)[\p{L}\p{M}]*|i(?:n|yor|ver|p)[\p{L}\p{M}]*|(?:me|mi|di|se|sin)[\p{L}\p{M}]*)?$/u,
114
+ ];
91
115
 
92
116
  /**
93
117
  * Inline spans that are never rewritten. Only the first alternative captures,
@@ -95,15 +119,20 @@ const P_EMOTICON = 0.3;
95
119
  */
96
120
  const PROTECTED_INLINE = new RegExp(
97
121
  [
98
- String.raw`(\`+)[\s\S]*?\1(?!\`)`, // code span
99
- String.raw`\`.*$`, // unmatched backtick: keep the rest of the line
122
+ "(`+)[\\s\\S]*?\\1(?!`)", // code span
123
+ "`.*$", // unmatched backtick: keep the rest of the line
100
124
  String.raw`\]\([^)\s]*(?:\s+"[^"]*")?\)`, // markdown link target
101
125
  String.raw`<\/?[A-Za-z][^>\n]*>`, // HTML tag or autolink
102
126
  String.raw`\b(?:[a-z][a-z0-9+.-]*:\/\/|www\.)\S*`, // bare URL
103
127
  String.raw`"[^"\n]*"`, // "quoted text"
104
128
  String.raw`“[^”\n]*”`, // “quoted text”
129
+ // Word boundaries distinguish quote delimiters from we're / they're.
130
+ String.raw`(?<![\p{L}\p{M}\p{N}_])'(?:[^'\n]|'(?=[\p{L}\p{M}\p{N}_]))*'(?![\p{L}\p{M}\p{N}_])`,
131
+ String.raw`(?<![\p{L}\p{M}\p{N}_])‘(?:[^’\n]|’(?=[\p{L}\p{M}\p{N}_]))*’(?![\p{L}\p{M}\p{N}_])`,
132
+ // Multi-token kaomoji must not change the word-position seed on re-runs.
133
+ String.raw`(?<!\S)(?:${EMOTICON_PATTERN})(?!\S)`,
105
134
  ].join("|"),
106
- "gi",
135
+ "giu",
107
136
  );
108
137
 
109
138
  const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
@@ -129,8 +158,25 @@ interface ProseLine {
129
158
  decorate: boolean;
130
159
  }
131
160
 
132
- /** Rewrite the prose of a markdown string in uwu-speak. */
133
- export function uwufy(text: string): string {
161
+ /** Rewrite markdown prose. Defaults to the original mid intensity and auto locale. */
162
+ export function uwufy(text: string, options: UwuOptions = {}): string {
163
+ return rewriteText(text, options, true);
164
+ }
165
+
166
+ /**
167
+ * Rewrite a render-time prose run without adding stutters or emoticons.
168
+ * Quoted spans, identifiers and markdown protected surfaces still stay exact.
169
+ * Positions reset per supplied run; changing renderer fragment boundaries can
170
+ * change probabilistic choices. This is a lexical rewriter, not a translator.
171
+ * Only balanced quotes contained in one supplied line/run are guarded; callers
172
+ * must not split quoted or technical spans across independently rewritten runs.
173
+ */
174
+ export function uwufyProse(text: string, options: UwuOptions = {}): string {
175
+ return rewriteText(text, options, false);
176
+ }
177
+
178
+ function rewriteText(text: string, options: UwuOptions, decorate: boolean): string {
179
+ const resolved: Required<UwuOptions> = { level: options.level ?? "mid", locale: options.locale ?? "auto" };
134
180
  const lines = text.split("\n");
135
181
  const kinds = classifyLines(lines);
136
182
 
@@ -147,7 +193,7 @@ export function uwufy(text: string): string {
147
193
  current = [];
148
194
  paragraphs.push(current);
149
195
  }
150
- current.push({ index, decorate: !heading && !TABLE_ROW.test(line) });
196
+ current.push({ index, decorate: decorate && !heading && !TABLE_ROW.test(line) });
151
197
  if (heading) current = undefined;
152
198
  }
153
199
 
@@ -157,7 +203,7 @@ export function uwufy(text: string): string {
157
203
  // which keeps them varied between paragraphs yet stable across re-runs.
158
204
  const wordCount = walkParagraph(lines, paragraph, () => undefined);
159
205
  const seed = `${paragraphIndex}|${wordCount}`;
160
- walkParagraph(lines, paragraph, (event) => rewriteToken(event, seed), out);
206
+ walkParagraph(lines, paragraph, (event) => rewriteToken(event, seed, resolved), out);
161
207
  }
162
208
  return out.join("\n");
163
209
  }
@@ -203,7 +249,7 @@ function classifyLines(lines: string[]): LineKind[] {
203
249
 
204
250
  interface TokenEvent {
205
251
  token: string;
206
- /** Text after the token within its prose segment, for emoticon lookahead. */
252
+ /** Text after the token on its full line, including protected emoticons. */
207
253
  rest: string;
208
254
  /** Whether the segment ends the line (nothing protected follows it). */
209
255
  lineEnd: boolean;
@@ -230,14 +276,14 @@ function walkParagraph(
230
276
  const line = lines[index] ?? "";
231
277
  let rebuilt = "";
232
278
  let cursor = 0;
233
- const segments: Array<{ prose: boolean; text: string }> = [];
279
+ const segments: Array<{ prose: boolean; text: string; start: number }> = [];
234
280
  for (const match of line.matchAll(PROTECTED_INLINE)) {
235
281
  const start = match.index ?? 0;
236
- if (start > cursor) segments.push({ prose: true, text: line.slice(cursor, start) });
237
- segments.push({ prose: false, text: match[0] });
282
+ if (start > cursor) segments.push({ prose: true, text: line.slice(cursor, start), start: cursor });
283
+ segments.push({ prose: false, text: match[0], start });
238
284
  cursor = start + match[0].length;
239
285
  }
240
- if (cursor < line.length) segments.push({ prose: true, text: line.slice(cursor) });
286
+ if (cursor < line.length) segments.push({ prose: true, text: line.slice(cursor), start: cursor });
241
287
 
242
288
  for (const [segmentIndex, segment] of segments.entries()) {
243
289
  if (!segment.prose) {
@@ -253,7 +299,7 @@ function walkParagraph(
253
299
  if (isWord) {
254
300
  replacement = visit({
255
301
  token,
256
- rest: segment.text.slice(offset + token.length),
302
+ rest: line.slice(segment.start + offset + token.length),
257
303
  lineEnd,
258
304
  decorate,
259
305
  sentence,
@@ -275,24 +321,25 @@ function walkParagraph(
275
321
  return word;
276
322
  }
277
323
 
278
- function rewriteToken(event: TokenEvent, seed: string): string {
324
+ function rewriteToken(event: TokenEvent, seed: string, options: Required<UwuOptions>): string {
325
+ const intensity = INTENSITY[options.level];
279
326
  const [, lead = "", core = "", trail = ""] = TOKEN_PARTS.exec(event.token) ?? [];
280
327
  const key = `${seed}|${event.sentence}|${event.word}`;
281
328
  let result = event.token;
282
329
 
283
- if (!isKeptWord(core)) {
330
+ if (!isKeptWord(core, options.locale)) {
284
331
  let word = core;
285
- if (TH_WORDS.has(word.toLowerCase()) && roll(`${key}|th`) < P_TH) {
332
+ if (TH_WORDS.has(word.toLowerCase()) && roll(`${key}|th`) < intensity.th) {
286
333
  word = (word[0] === "T" ? "D" : "d") + word.slice(2);
287
334
  }
288
- word = word.replace(/[rl]/g, "w").replace(/[RL]/g, "W");
289
- if (roll(`${key}|ny`) < P_NY) word = word.replace(/([nN])(?=[aeo])/g, "$1y");
335
+ if (roll(`${key}|rl`) < intensity.rl) word = word.replace(/[rl]/g, "w").replace(/[RL]/g, "W");
336
+ if (roll(`${key}|ny`) < intensity.ny) word = word.replace(/([nN])(?=[aeo])/g, "$1y");
290
337
  if (
291
338
  event.decorate &&
292
339
  event.wordInSentence === 0 &&
293
340
  word.length >= 3 &&
294
341
  !STUTTERED.test(word) &&
295
- roll(`${key}|stutter`) < P_STUTTER
342
+ roll(`${key}|stutter`) < intensity.stutter
296
343
  ) {
297
344
  word = `${word[0]}-${word}`;
298
345
  }
@@ -304,8 +351,8 @@ function rewriteToken(event: TokenEvent, seed: string): string {
304
351
  event.decorate &&
305
352
  SENTENCE_END.test(trail) &&
306
353
  (/^\s/.test(event.rest) || (event.rest === "" && event.lineEnd)) &&
307
- !EMOTICON_TOKENS.has(/^\s+(\S+)/.exec(event.rest)?.[1] ?? "") &&
308
- roll(`${seed}|${event.sentence}|emoticon`) < P_EMOTICON
354
+ !EMOTICON_AHEAD.test(event.rest) &&
355
+ roll(`${seed}|${event.sentence}|emoticon`) < intensity.emoticon
309
356
  ) {
310
357
  result += ` ${EMOTICONS[Math.floor(roll(`${seed}|${event.sentence}|pick`) * EMOTICONS.length)]}`;
311
358
  }
@@ -313,8 +360,12 @@ function rewriteToken(event: TokenEvent, seed: string): string {
313
360
  }
314
361
 
315
362
  /** Acronyms, camelCase names, flags and meaning-critical words stay exact. */
316
- function isKeptWord(core: string): boolean {
363
+ function isKeptWord(core: string, locale: UwuLocale): boolean {
317
364
  if (KEEP_WORDS.has(core.toLowerCase().replaceAll("’", "'"))) return true;
365
+ if (locale !== "en") {
366
+ const turkish = core.normalize("NFC").toLocaleLowerCase("tr");
367
+ if (Object.hasOwn(TURKISH_KEEP_WORDS, turkish) || TURKISH_CRITICAL.some((pattern) => pattern.test(turkish))) return true;
368
+ }
318
369
  if (core.endsWith("-") || core.includes("--")) return true;
319
370
  if (/\p{Ll}\p{Lu}/u.test(core)) return true; // camelCase, GitHub, iOS
320
371
  const letters = core.replace(/[^\p{L}]/gu, "");