omp-uwu 0.3.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,16 +16,23 @@ 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
 
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**.
35
+
29
36
  ## What gets uwufied, and what doesn't
30
37
 
31
38
  | uwufied | kept byte-for-byte |
@@ -36,7 +43,9 @@ The palette colors chat markdown and the user-message bubble without putting ANS
36
43
  | | Tool-call arguments and file contents the agent writes or edits |
37
44
  | | Commit messages and prompts sent to subagents |
38
45
 
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.
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.
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.
40
49
 
41
50
  Here's the same prompt and model, with the mode off and on:
42
51
 
@@ -54,19 +63,24 @@ Only the two scores come from the benchmark run. The other numbers on the chart
54
63
 
55
64
  ## How it works
56
65
 
57
- 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.
58
67
 
59
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.
60
69
 
61
70
  - **Deterministic rewrite.** No style instruction/token overhead once the hook is detected.
62
71
  - **Prompt style.** Model-dependent, with live uwu output while streaming.
63
- - **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.
64
78
 
65
79
  ## Install options
66
80
 
67
81
  ```sh
68
82
  omp plugin install omp-uwu # from npm (recommended)
69
- omp plugin install omp-uwu@0.3.0 # pin a version
83
+ omp plugin install omp-uwu@0.5.0 # pin a version
70
84
  omp plugin uninstall omp-uwu # remove
71
85
  ```
72
86
 
@@ -82,6 +96,10 @@ omp plugin link . # use this checkout instead of the installed copy
82
96
  omp -e ./src/index.ts # or load it for a single run
83
97
  ```
84
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
+
85
103
  ### Re-recording the demo
86
104
 
87
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:
@@ -106,8 +124,8 @@ CI (`.github/workflows/ci.yml`) runs `check` and the tests on pushes to `main` a
106
124
 
107
125
  ```sh
108
126
  # bump "version" in package.json, commit, then:
109
- git tag v0.2.0
110
- git push origin main v0.2.0
127
+ git tag v0.5.0
128
+ git push origin main v0.5.0
111
129
  ```
112
130
 
113
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.3.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
@@ -1,24 +1,51 @@
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
- import { uwufy } from "./uwufy.ts";
7
+ import { type UwuLevel, type UwuLocale, uwufy, uwufyProse } from "./uwufy.ts";
7
8
 
8
9
  export const UWU_PROMPT = `
9
10
  # uwu mode (display style only)
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
18
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)
19
22
  The style applies only to natural-language chat text shown to the user. Task quality and correctness are unchanged.
20
23
  `.trim();
21
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
+
22
49
  type RewriteEvent = {
23
50
  message: { role: string; content: Array<{ type: string; text?: string; [key: string]: unknown }> };
24
51
  };
@@ -37,7 +64,11 @@ export default function uwuExtension(pi: ExtensionAPI) {
37
64
  let enabled = true;
38
65
  let colorsEnabled = false;
39
66
  let stateReady: Promise<void> | undefined;
40
- 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;
41
72
  let hookSupport: boolean | undefined;
42
73
  let promptAddedThisTurn = false;
43
74
  let notifiedFallback = false;
@@ -50,11 +81,14 @@ export default function uwuExtension(pi: ExtensionAPI) {
50
81
  if (typeof state !== "object" || state === null) return;
51
82
  if ("enabled" in state && typeof state.enabled === "boolean") enabled = state.enabled;
52
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;
53
87
  })
54
88
  .catch(() => {});
55
89
  return stateReady;
56
90
  };
57
- 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");
58
92
 
59
93
  const updateBadge = (ctx: UiContext) => {
60
94
  if (ctx.mode !== "tui" || ctx.agent?.kind === "sub" || !ctx.ui?.setStatus) return;
@@ -62,6 +96,18 @@ export default function uwuExtension(pi: ExtensionAPI) {
62
96
  ctx.ui.setStatus("omp-uwu", badge && ctx.ui.theme ? ctx.ui.theme.fg("accent", badge) : badge);
63
97
  };
64
98
 
99
+ // Display and sparkle composition share the host's per-prose ANSI hook.
100
+ // Prose is transformed before Markdown wrapping; no message/history changes.
101
+ const paint = themePaint(() => pi.pi.theme);
102
+ const host = pi.pi as { Container?: ContainerClass };
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
+ });
110
+
65
111
  const syncColors = (ctx: UiContext) => {
66
112
  if (ctx.mode !== "tui" || ctx.agent?.kind === "sub" || !ctx.ui?.theme) return;
67
113
  if (enabled && colorsEnabled) kawaiiTheme.enable(ctx.ui.theme);
@@ -69,38 +115,43 @@ export default function uwuExtension(pi: ExtensionAPI) {
69
115
  };
70
116
 
71
117
  pi.registerCommand("uwu", {
72
- 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)",
73
119
  handler: async (args, rawCtx) => {
74
120
  await loadState();
75
121
  const ctx = rawCtx as UiContext;
76
- const arg = String(args ?? "").trim().toLowerCase();
77
- if (arg.startsWith("colors")) {
78
- const option = arg.slice("colors".length).trim();
79
- if (option === "on") colorsEnabled = true;
80
- else if (option === "off") colorsEnabled = false;
81
- else if (!option) colorsEnabled = !colorsEnabled;
82
- else {
83
- 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");
84
136
  return;
85
137
  }
86
- try {
87
- await saveState();
88
- } catch {
89
- ctx.ui?.notify("uwu setting could not be saved; it may reset next session", "info");
90
- }
91
- syncColors(ctx);
92
- updateBadge(ctx);
93
- 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");
94
139
  return;
95
140
  }
96
- 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")) {
97
148
  style = arg;
98
149
  enabled = true;
99
- } else if (arg === "on") enabled = true;
100
- else if (arg === "off") enabled = false;
101
- 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;
102
153
  else {
103
- ctx.ui?.notify("Usage: /uwu [on|off|prompt|rewrite|colors [on|off]]", "info");
154
+ ctx.ui?.notify(USAGE, "info");
104
155
  return;
105
156
  }
106
157
  try {
@@ -108,9 +159,18 @@ export default function uwuExtension(pi: ExtensionAPI) {
108
159
  } catch {
109
160
  ctx.ui?.notify("uwu setting could not be saved; it may reset next session", "info");
110
161
  }
162
+ renderAllowed = ctx.mode === "tui";
111
163
  syncColors(ctx);
164
+ rendering?.refresh();
112
165
  updateBadge(ctx);
113
- 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
+ }
114
174
  },
115
175
  });
116
176
 
@@ -118,6 +178,8 @@ export default function uwuExtension(pi: ExtensionAPI) {
118
178
  await loadState();
119
179
  const ctx = rawCtx as UiContext;
120
180
  syncColors(ctx);
181
+ renderAllowed = ctx.mode === "tui" && ctx.agent?.kind !== "sub";
182
+ rendering?.refresh();
121
183
  updateBadge(ctx);
122
184
  });
123
185
 
@@ -125,27 +187,29 @@ export default function uwuExtension(pi: ExtensionAPI) {
125
187
  await loadState();
126
188
  promptAddedThisTurn = false;
127
189
  const ctx = rawCtx as UiContext;
128
- if (ctx.agent?.kind === "sub") return undefined;
190
+ subagentTurn = ctx.agent?.kind === "sub";
191
+ if (subagentTurn) return undefined;
129
192
  // Start with the prompt until this host proves it has the finalized hook.
130
193
  // That styles the first reply on both old and new omp versions.
131
- const needsPrompt = style === "prompt" || hookSupport !== true;
194
+ const needsPrompt = style === "prompt" || (style === "rewrite" && hookSupport !== true);
132
195
  if (!enabled || !needsPrompt) return undefined;
133
196
  promptAddedThisTurn = true;
134
- return { systemPrompt: [...event.systemPrompt, UWU_PROMPT] };
197
+ return { systemPrompt: [...event.systemPrompt, stylePrompt(level, locale)] };
135
198
  });
136
199
 
137
200
 
138
201
  // The hook is newer than the bundled 18.4.3 types, so register structurally.
139
202
  // ExtensionAPI.on stores event names as strings; old hosts simply never emit it.
140
- (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)(
141
204
  "assistant_message",
142
- (event) => {
205
+ (event, ctx) => {
206
+ if (ctx?.agent?.kind === "sub" || (!ctx?.agent && subagentTurn)) return;
143
207
  hookSupport = true;
144
208
  if (!enabled || style !== "rewrite" || promptAddedThisTurn || event.message.role !== "assistant") return;
145
209
  let changed = false;
146
210
  const content = event.message.content.map((block) => {
147
211
  if (block.type !== "text" || typeof block.text !== "string") return block;
148
- const text = uwufy(block.text);
212
+ const text = uwufy(block.text, { level, locale });
149
213
  if (text === block.text) return block;
150
214
  changed = true;
151
215
  return { ...block, text };
@@ -154,7 +218,9 @@ export default function uwuExtension(pi: ExtensionAPI) {
154
218
  },
155
219
  );
156
220
 
157
- 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;
158
224
  const message = (rawEvent as MessageEndEvent).message;
159
225
  if (message.role === "assistant" && message.stopReason !== "aborted" && message.stopReason !== "error" && hookSupport === undefined) {
160
226
  hookSupport = false;
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,233 @@
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 assistant prose should be transformed (independent of colors). */
97
+ isActive(): boolean;
98
+ transform: ColorTransform;
99
+ }
100
+
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. */
111
+ interface Registry {
112
+ control: SparkleControl;
113
+ transform: ColorTransform;
114
+ assistant?: AssistantPrototype;
115
+ components: Set<WeakRef<object & { invalidate?: () => void }>>;
116
+ seen: WeakSet<object>;
117
+ restoreAssistant?: () => void;
118
+ restoreWatch?: () => void;
119
+ }
120
+ const REGISTRY = Symbol.for("omp-uwu.sparkles");
121
+
122
+ /** Walk up from an instance to the prototype that defines both assistant methods. */
123
+ function findAssistantPrototype(value: unknown): AssistantPrototype | undefined {
124
+ for (let proto = value && typeof value === "object" ? Object.getPrototypeOf(value) : null; proto; proto = Object.getPrototypeOf(proto)) {
125
+ if (
126
+ Object.hasOwn(proto, "setTextColorTransform") &&
127
+ Object.hasOwn(proto, "updateContent") &&
128
+ typeof proto.setTextColorTransform === "function" &&
129
+ typeof proto.updateContent === "function"
130
+ ) {
131
+ return proto as AssistantPrototype;
132
+ }
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ /**
138
+ * Give every assistant message component the sparkle transform while
139
+ * `control.isActive()`. A transform set by the host itself (e.g. the
140
+ * live-voice transcript tint) always wins over sparkles.
141
+ */
142
+ function patchAssistant(proto: AssistantPrototype, registry: Registry): void {
143
+ const originalSet = proto.setTextColorTransform;
144
+ const originalUpdate = proto.updateContent;
145
+ const hostTransforms = new WeakMap<object, ColorTransform>();
146
+ const applied = new WeakMap<object, ColorTransform | undefined>();
147
+
148
+ proto.setTextColorTransform = function (this: object, transform?: ColorTransform) {
149
+ if (transform) hostTransforms.set(this, transform);
150
+ else hostTransforms.delete(this);
151
+ applied.delete(this);
152
+ originalSet.call(this, transform);
153
+ };
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
+ }
159
+ if (!hostTransforms.has(this)) {
160
+ const wanted = registry.control.isActive() ? registry.transform : undefined;
161
+ if (applied.get(this) !== wanted) {
162
+ applied.set(this, wanted);
163
+ originalSet.call(this, wanted);
164
+ }
165
+ }
166
+ return originalUpdate.apply(this, args);
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
+ };
174
+ }
175
+
176
+ /**
177
+ * Hook sparkles into omp's AssistantMessageComponent.
178
+ *
179
+ * That class is not part of the extension API, and importing its module by
180
+ * path loads a second, unused copy when omp runs from its bundle. What
181
+ * extensions do share with the host is `Container`, its base class, and the
182
+ * component adds its first child in its constructor before any content
183
+ * update. So `addChild` is watched until the first assistant component shows
184
+ * up, its prototype is patched, and the watch is removed again.
185
+ */
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
+ });
194
+ registry.control = control;
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);
202
+ }
203
+ };
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
+ };
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", "(◕ᴗ◕✿)",
@@ -23,10 +31,11 @@ export const EMOTICONS = [
23
31
  "(♡˙︶˙♡)", "ヽ(♡‿♡)ノ", "(´꒳`)♡", "(ノ´ з `)ノ", "ヾ(・ω・*)",
24
32
  "(⌒ω⌒)ノ", "(ᵔ⩊ᵔ)", "(=^・ω・^=)", "(ฅ^•ﻌ•^ฅ)", "(ᵕ—ᴗ—)",
25
33
  "(。•̀ᴗ-)✧", "(づ。◕‿‿◕。)づ", "(っ´▽`)っ", "( ˶ˆᗜˆ˵ )", "(๑>◡<๑)",
34
+ "✨", "💖", "🌸", "🎀",
26
35
  ] as const;
27
36
 
28
37
  /** Tokens treated as emoticons: never counted as words, never rewritten. */
29
- const EMOTICON_TOKENS = new Set<string>([
38
+ export const EMOTICON_TOKENS: ReadonlySet<string> = new Set<string>([
30
39
  ...EMOTICONS,
31
40
  "UwU",
32
41
  "OwO",
@@ -38,6 +47,11 @@ const EMOTICON_TOKENS = new Set<string>([
38
47
  "x3",
39
48
  ]);
40
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
+
41
55
  /** Words that carry meaning a reader must not miss. Kept exact. */
42
56
  const KEEP_WORDS = new Set([
43
57
  "no",
@@ -82,11 +96,22 @@ const KEEP_WORDS = new Set([
82
96
 
83
97
  const TH_WORDS = new Set(["the", "this", "that", "these", "those", "them", "then", "there", "their", "they", "than"]);
84
98
 
85
- // Probabilities for the "sometimes" transforms.
86
- const P_TH = 0.35;
87
- const P_NY = 0.5;
88
- const P_STUTTER = 0.12;
89
- 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
+ ];
90
115
 
91
116
  /**
92
117
  * Inline spans that are never rewritten. Only the first alternative captures,
@@ -94,15 +119,20 @@ const P_EMOTICON = 0.3;
94
119
  */
95
120
  const PROTECTED_INLINE = new RegExp(
96
121
  [
97
- String.raw`(\`+)[\s\S]*?\1(?!\`)`, // code span
98
- 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
99
124
  String.raw`\]\([^)\s]*(?:\s+"[^"]*")?\)`, // markdown link target
100
125
  String.raw`<\/?[A-Za-z][^>\n]*>`, // HTML tag or autolink
101
126
  String.raw`\b(?:[a-z][a-z0-9+.-]*:\/\/|www\.)\S*`, // bare URL
102
127
  String.raw`"[^"\n]*"`, // "quoted text"
103
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)`,
104
134
  ].join("|"),
105
- "gi",
135
+ "giu",
106
136
  );
107
137
 
108
138
  const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
@@ -128,8 +158,25 @@ interface ProseLine {
128
158
  decorate: boolean;
129
159
  }
130
160
 
131
- /** Rewrite the prose of a markdown string in uwu-speak. */
132
- 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" };
133
180
  const lines = text.split("\n");
134
181
  const kinds = classifyLines(lines);
135
182
 
@@ -146,7 +193,7 @@ export function uwufy(text: string): string {
146
193
  current = [];
147
194
  paragraphs.push(current);
148
195
  }
149
- current.push({ index, decorate: !heading && !TABLE_ROW.test(line) });
196
+ current.push({ index, decorate: decorate && !heading && !TABLE_ROW.test(line) });
150
197
  if (heading) current = undefined;
151
198
  }
152
199
 
@@ -156,7 +203,7 @@ export function uwufy(text: string): string {
156
203
  // which keeps them varied between paragraphs yet stable across re-runs.
157
204
  const wordCount = walkParagraph(lines, paragraph, () => undefined);
158
205
  const seed = `${paragraphIndex}|${wordCount}`;
159
- walkParagraph(lines, paragraph, (event) => rewriteToken(event, seed), out);
206
+ walkParagraph(lines, paragraph, (event) => rewriteToken(event, seed, resolved), out);
160
207
  }
161
208
  return out.join("\n");
162
209
  }
@@ -202,7 +249,7 @@ function classifyLines(lines: string[]): LineKind[] {
202
249
 
203
250
  interface TokenEvent {
204
251
  token: string;
205
- /** Text after the token within its prose segment, for emoticon lookahead. */
252
+ /** Text after the token on its full line, including protected emoticons. */
206
253
  rest: string;
207
254
  /** Whether the segment ends the line (nothing protected follows it). */
208
255
  lineEnd: boolean;
@@ -229,14 +276,14 @@ function walkParagraph(
229
276
  const line = lines[index] ?? "";
230
277
  let rebuilt = "";
231
278
  let cursor = 0;
232
- const segments: Array<{ prose: boolean; text: string }> = [];
279
+ const segments: Array<{ prose: boolean; text: string; start: number }> = [];
233
280
  for (const match of line.matchAll(PROTECTED_INLINE)) {
234
281
  const start = match.index ?? 0;
235
- if (start > cursor) segments.push({ prose: true, text: line.slice(cursor, start) });
236
- 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 });
237
284
  cursor = start + match[0].length;
238
285
  }
239
- 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 });
240
287
 
241
288
  for (const [segmentIndex, segment] of segments.entries()) {
242
289
  if (!segment.prose) {
@@ -252,7 +299,7 @@ function walkParagraph(
252
299
  if (isWord) {
253
300
  replacement = visit({
254
301
  token,
255
- rest: segment.text.slice(offset + token.length),
302
+ rest: line.slice(segment.start + offset + token.length),
256
303
  lineEnd,
257
304
  decorate,
258
305
  sentence,
@@ -274,24 +321,25 @@ function walkParagraph(
274
321
  return word;
275
322
  }
276
323
 
277
- function rewriteToken(event: TokenEvent, seed: string): string {
324
+ function rewriteToken(event: TokenEvent, seed: string, options: Required<UwuOptions>): string {
325
+ const intensity = INTENSITY[options.level];
278
326
  const [, lead = "", core = "", trail = ""] = TOKEN_PARTS.exec(event.token) ?? [];
279
327
  const key = `${seed}|${event.sentence}|${event.word}`;
280
328
  let result = event.token;
281
329
 
282
- if (!isKeptWord(core)) {
330
+ if (!isKeptWord(core, options.locale)) {
283
331
  let word = core;
284
- if (TH_WORDS.has(word.toLowerCase()) && roll(`${key}|th`) < P_TH) {
332
+ if (TH_WORDS.has(word.toLowerCase()) && roll(`${key}|th`) < intensity.th) {
285
333
  word = (word[0] === "T" ? "D" : "d") + word.slice(2);
286
334
  }
287
- word = word.replace(/[rl]/g, "w").replace(/[RL]/g, "W");
288
- 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");
289
337
  if (
290
338
  event.decorate &&
291
339
  event.wordInSentence === 0 &&
292
340
  word.length >= 3 &&
293
341
  !STUTTERED.test(word) &&
294
- roll(`${key}|stutter`) < P_STUTTER
342
+ roll(`${key}|stutter`) < intensity.stutter
295
343
  ) {
296
344
  word = `${word[0]}-${word}`;
297
345
  }
@@ -303,8 +351,8 @@ function rewriteToken(event: TokenEvent, seed: string): string {
303
351
  event.decorate &&
304
352
  SENTENCE_END.test(trail) &&
305
353
  (/^\s/.test(event.rest) || (event.rest === "" && event.lineEnd)) &&
306
- !EMOTICON_TOKENS.has(/^\s+(\S+)/.exec(event.rest)?.[1] ?? "") &&
307
- roll(`${seed}|${event.sentence}|emoticon`) < P_EMOTICON
354
+ !EMOTICON_AHEAD.test(event.rest) &&
355
+ roll(`${seed}|${event.sentence}|emoticon`) < intensity.emoticon
308
356
  ) {
309
357
  result += ` ${EMOTICONS[Math.floor(roll(`${seed}|${event.sentence}|pick`) * EMOTICONS.length)]}`;
310
358
  }
@@ -312,8 +360,12 @@ function rewriteToken(event: TokenEvent, seed: string): string {
312
360
  }
313
361
 
314
362
  /** Acronyms, camelCase names, flags and meaning-critical words stay exact. */
315
- function isKeptWord(core: string): boolean {
363
+ function isKeptWord(core: string, locale: UwuLocale): boolean {
316
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
+ }
317
369
  if (core.endsWith("-") || core.includes("--")) return true;
318
370
  if (/\p{Ll}\p{Lu}/u.test(core)) return true; // camelCase, GitHub, iOS
319
371
  const letters = core.replace(/[^\p{L}]/gu, "");