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 +25 -7
- package/package.json +1 -1
- package/src/index.ts +100 -34
- package/src/kawaii.ts +10 -1
- package/src/sparkle.ts +233 -0
- package/src/uwufy.ts +81 -29
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
- **
|
|
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.
|
|
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.
|
|
110
|
-
git push origin main v0.
|
|
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
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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 === "
|
|
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 (!
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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:
|
|
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`) <
|
|
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`) <
|
|
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`) <
|
|
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
|
-
!
|
|
307
|
-
roll(`${seed}|${event.sentence}|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, "");
|