omp-uwu 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -4
- package/package.json +1 -1
- package/src/index.ts +75 -6
- package/src/kawaii.ts +103 -0
- package/src/sparkle.ts +187 -0
- package/src/uwufy.ts +2 -1
package/README.md
CHANGED
|
@@ -22,8 +22,11 @@ Restart omp. uwu mode is **on** by default in every session.
|
|
|
22
22
|
- `/uwu on` / `/uwu off` sets it explicitly
|
|
23
23
|
- `/uwu rewrite` uses omp's finalized-message rewrite hook when available
|
|
24
24
|
- `/uwu prompt` asks the model to write in uwu style while it streams
|
|
25
|
+
- `/uwu colors on` / `/uwu colors off` enables or disables the optional kawaii palette (off by default; preference persists)
|
|
25
26
|
|
|
26
|
-
The TUI also shows a
|
|
27
|
+
The palette colors chat markdown and the user-message bubble without putting ANSI codes into message text/history. It is applied as an in-memory TUI theme; components sharing those colors can change too. The host's in-memory theme setter pauses automatic theme detection until omp restarts. The TUI also shows a theme-accent `(◕ᴗ◕✿) uwu` status badge; other clients/plain output are not colorized.
|
|
28
|
+
|
|
29
|
+
With colors on, the TUI also paints **sparkles** in the agent's chat prose: `uwu`/`owo` get a per-letter pastel rainbow, and kaomoji (listed or not, e.g. `(◕ᴗ◕✿)`, `(ノ◕ヮ◕)ノ`) and glyphs like `♡ ☆ ✧ ✿` get a pastel tint. This happens at render time through omp's per-message text color transform, which only ever sees plain prose runs, so inline code, code blocks, link targets and the stored message text stay untouched. omp's `AssistantMessageComponent` is not part of the extension API, so the plugin finds it through its shared `Container` base class when the first assistant message is created; if a future omp changes that shape, sparkles are simply skipped and the palette still applies.
|
|
27
30
|
|
|
28
31
|
## What gets uwufied, and what doesn't
|
|
29
32
|
|
|
@@ -35,7 +38,7 @@ The TUI also shows a small, theme-accent-colored `(◕ᴗ◕✿) uwu` status bad
|
|
|
35
38
|
| | Tool-call arguments and file contents the agent writes or edits |
|
|
36
39
|
| | Commit messages and prompts sent to subagents |
|
|
37
40
|
|
|
38
|
-
Style rules include `r`/`l` → `w`, occasional `th` → `d`, `na/ne/no` → `nya/nye/nyo`, occasional stutter,
|
|
41
|
+
Style rules include `r`/`l` → `w`, occasional `th` → `d`, `na/ne/no` → `nya/nye/nyo`, occasional stutter, a broad rotating selection of kaomoji, and the odd cute emoji (`✨ 💖 🌸 🎀`). The kaomoji selection includes examples from [kaomoji.you](https://kaomoji.you/), such as `٩(◕‿◕。)۶`, `(ฅ^•ﻌ•^ฅ)`, and `(づ。◕‿‿◕。)づ`. Meaning, numbers and warnings must stay readable.
|
|
39
42
|
|
|
40
43
|
Here's the same prompt and model, with the mode off and on:
|
|
41
44
|
|
|
@@ -43,6 +46,14 @@ Here's the same prompt and model, with the mode off and on:
|
|
|
43
46
|
|
|
44
47
|
These are real, unedited replies from `anthropic/claude-opus-5-5`, rendered as images. Both code blocks are identical byte for byte. The transcripts are in [`demo/`](demo).
|
|
45
48
|
|
|
49
|
+
### kawaii/uwu-bench
|
|
50
|
+
|
|
51
|
+
On kawaii/uwu-bench, Opus 5.5 scores 0.1% on its own and 98.5% with omp-uwu enabled:
|
|
52
|
+
|
|
53
|
+

|
|
54
|
+
|
|
55
|
+
Only the two scores come from the benchmark run. The other numbers on the chart are computed from them, and the terminal and config panels are decoration.
|
|
56
|
+
|
|
46
57
|
## How it works
|
|
47
58
|
|
|
48
59
|
On the first turn, omp-uwu uses the prompt style until the host demonstrates support for the awaited `assistant_message` hook; after that, the default **rewrite** style deterministically uwufies finalized assistant text before it is added to history and context. This first-turn check makes the experience work on both older and newer omp builds. Only text in existing text blocks is changed; code/tool blocks and their metadata stay untouched. Rewrites are markdown-aware and preserve fenced/inline code, URLs, paths, numbers, quoted text, identifiers, and safety-critical words.
|
|
@@ -51,13 +62,13 @@ The hook runs after streaming has finished. The TUI refreshes the current assist
|
|
|
51
62
|
|
|
52
63
|
- **Deterministic rewrite.** No style instruction/token overhead once the hook is detected.
|
|
53
64
|
- **Prompt style.** Model-dependent, with live uwu output while streaming.
|
|
54
|
-
- **
|
|
65
|
+
- **Persistent settings.** `/uwu off` is remembered across sessions; `/uwu on` re-enables uwu. The colors preference is remembered too. Settings are stored in `~/.omp/agent/omp-uwu.json`. UwU mode remains on by default until explicitly changed.
|
|
55
66
|
|
|
56
67
|
## Install options
|
|
57
68
|
|
|
58
69
|
```sh
|
|
59
70
|
omp plugin install omp-uwu # from npm (recommended)
|
|
60
|
-
omp plugin install omp-uwu@0.
|
|
71
|
+
omp plugin install omp-uwu@0.4.0 # pin a version
|
|
61
72
|
omp plugin uninstall omp-uwu # remove
|
|
62
73
|
```
|
|
63
74
|
|
|
@@ -85,6 +96,12 @@ uv run --with pillow python demo/render.py
|
|
|
85
96
|
|
|
86
97
|
Model output varies between runs, so re-run `record.sh` until you get a reply that reads well.
|
|
87
98
|
|
|
99
|
+
The uwu-bench chart is drawn from [`demo/uwu-bench.html`](demo/uwu-bench.html). Edit the HTML, then take a 1536×960 screenshot with headless Chrome:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
chrome --headless=new --hide-scrollbars --window-size=1536,960 --screenshot="$PWD/demo/uwu-bench.png" "file://$PWD/demo/uwu-bench.html"
|
|
103
|
+
```
|
|
104
|
+
|
|
88
105
|
### Releasing
|
|
89
106
|
|
|
90
107
|
CI (`.github/workflows/ci.yml`) runs `check` and the tests on pushes to `main` and on pull requests. Pushing a `v*` tag runs them again, checks that the tag matches `version` in `package.json`, and publishes to npm with provenance (`.github/workflows/publish.yml`).
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { KawaiiTheme } from "./kawaii.ts";
|
|
4
|
+
import { type ContainerClass, installSparkles, sparkle, themePaint } from "./sparkle.ts";
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI, Theme } from "@oh-my-pi/pi-coding-agent";
|
|
2
7
|
import { uwufy } from "./uwufy.ts";
|
|
3
8
|
|
|
4
9
|
export const UWU_PROMPT = `
|
|
@@ -6,8 +11,9 @@ export const UWU_PROMPT = `
|
|
|
6
11
|
Write your prose replies to the user in playful "uwu" speak:
|
|
7
12
|
- replace r/l with w in most words ("weawwy", "hewwo"), "th" → "d" occasionally, "na/ne/no" → "nya/nye/nyo" sometimes
|
|
8
13
|
- occasional stutter ("h-hewwo") and cute kaomoji/emoticons, varied naturally: (◕ᴗ◕✿), (o^▽^o), (´。• ω •。\u0060), ٩(◕‿◕。)۶, (✧ω✧), (๑˃ᴗ˂)ﻭ, ヽ(・∀・)ノ, (っ˘ω˘ς), (。•̀ᴗ-)✧, (づ。◕‿‿◕。)づ, (ฅ^•ﻌ•^ฅ), (๑>◡<๑), owo, >w<, ^w^, :3; at most one at a sentence end
|
|
14
|
+
- now and then a cute emoji or sparkle instead (✨, 💖, 🌸, 🎀, ♡, ☆), never more than one per paragraph
|
|
9
15
|
- keep it readable; never let the style hide meaning, numbers or warnings
|
|
10
|
-
- colors
|
|
16
|
+
- the TUI colors kaomoji, uwu and sparkles by itself; never insert ANSI color codes into chat text
|
|
11
17
|
|
|
12
18
|
NEVER uwufy any of these — keep them exact and byte-for-byte correct:
|
|
13
19
|
- code, code blocks, inline code, shell commands, file paths, URLs, identifiers, config keys, error messages you quote
|
|
@@ -25,16 +31,32 @@ type UiContext = {
|
|
|
25
31
|
ui?: {
|
|
26
32
|
notify(message: string, level: "info"): void;
|
|
27
33
|
setStatus?(key: string, text: string | undefined): void;
|
|
28
|
-
theme?:
|
|
34
|
+
theme?: Theme;
|
|
29
35
|
};
|
|
30
36
|
};
|
|
31
37
|
|
|
32
38
|
export default function uwuExtension(pi: ExtensionAPI) {
|
|
33
39
|
let enabled = true;
|
|
40
|
+
let colorsEnabled = false;
|
|
41
|
+
let stateReady: Promise<void> | undefined;
|
|
34
42
|
let style: "rewrite" | "prompt" = "rewrite";
|
|
35
43
|
let hookSupport: boolean | undefined;
|
|
36
44
|
let promptAddedThisTurn = false;
|
|
37
45
|
let notifiedFallback = false;
|
|
46
|
+
const statePath = join(pi.pi.getAgentDir(), "omp-uwu.json");
|
|
47
|
+
const kawaiiTheme = new KawaiiTheme(pi.pi);
|
|
48
|
+
const loadState = () => {
|
|
49
|
+
stateReady ??= readFile(statePath, "utf8")
|
|
50
|
+
.then((raw) => {
|
|
51
|
+
const state: unknown = JSON.parse(raw);
|
|
52
|
+
if (typeof state !== "object" || state === null) return;
|
|
53
|
+
if ("enabled" in state && typeof state.enabled === "boolean") enabled = state.enabled;
|
|
54
|
+
if ("colors" in state && typeof state.colors === "boolean") colorsEnabled = state.colors;
|
|
55
|
+
})
|
|
56
|
+
.catch(() => {});
|
|
57
|
+
return stateReady;
|
|
58
|
+
};
|
|
59
|
+
const saveState = () => writeFile(statePath, `${JSON.stringify({ enabled, colors: colorsEnabled }, null, 2)}\n`, "utf8");
|
|
38
60
|
|
|
39
61
|
const updateBadge = (ctx: UiContext) => {
|
|
40
62
|
if (ctx.mode !== "tui" || ctx.agent?.kind === "sub" || !ctx.ui?.setStatus) return;
|
|
@@ -42,11 +64,45 @@ export default function uwuExtension(pi: ExtensionAPI) {
|
|
|
42
64
|
ctx.ui.setStatus("omp-uwu", badge && ctx.ui.theme ? ctx.ui.theme.fg("accent", badge) : badge);
|
|
43
65
|
};
|
|
44
66
|
|
|
67
|
+
// Paints kaomoji/uwu in rendered chat prose at display time only (see sparkle.ts).
|
|
68
|
+
// Installed at load so the first assistant component, even a resumed one, is caught.
|
|
69
|
+
const paint = themePaint(() => pi.pi.theme);
|
|
70
|
+
const host = pi.pi as { Container?: ContainerClass };
|
|
71
|
+
if (host.Container) {
|
|
72
|
+
installSparkles(host.Container, { isActive: () => enabled && colorsEnabled, transform: (text) => sparkle(text, paint) });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const syncColors = (ctx: UiContext) => {
|
|
76
|
+
if (ctx.mode !== "tui" || ctx.agent?.kind === "sub" || !ctx.ui?.theme) return;
|
|
77
|
+
if (enabled && colorsEnabled) kawaiiTheme.enable(ctx.ui.theme);
|
|
78
|
+
else kawaiiTheme.disable(ctx.ui.theme);
|
|
79
|
+
};
|
|
80
|
+
|
|
45
81
|
pi.registerCommand("uwu", {
|
|
46
|
-
description: "UwU chat style and mode (usage: /uwu [on|off|prompt|rewrite])",
|
|
82
|
+
description: "UwU chat style and mode (usage: /uwu [on|off|prompt|rewrite|colors [on|off]])",
|
|
47
83
|
handler: async (args, rawCtx) => {
|
|
84
|
+
await loadState();
|
|
48
85
|
const ctx = rawCtx as UiContext;
|
|
49
86
|
const arg = String(args ?? "").trim().toLowerCase();
|
|
87
|
+
if (arg.startsWith("colors")) {
|
|
88
|
+
const option = arg.slice("colors".length).trim();
|
|
89
|
+
if (option === "on") colorsEnabled = true;
|
|
90
|
+
else if (option === "off") colorsEnabled = false;
|
|
91
|
+
else if (!option) colorsEnabled = !colorsEnabled;
|
|
92
|
+
else {
|
|
93
|
+
ctx.ui?.notify("Usage: /uwu [on|off|prompt|rewrite|colors [on|off]]", "info");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
await saveState();
|
|
98
|
+
} catch {
|
|
99
|
+
ctx.ui?.notify("uwu setting could not be saved; it may reset next session", "info");
|
|
100
|
+
}
|
|
101
|
+
syncColors(ctx);
|
|
102
|
+
updateBadge(ctx);
|
|
103
|
+
ctx.ui?.notify(`kawaii chat colors ${colorsEnabled ? "on" : "off"}${colorsEnabled && !enabled ? " (active when uwu mode is on)" : ""}`, "info");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
50
106
|
if (arg === "prompt" || arg === "rewrite") {
|
|
51
107
|
style = arg;
|
|
52
108
|
enabled = true;
|
|
@@ -54,17 +110,29 @@ export default function uwuExtension(pi: ExtensionAPI) {
|
|
|
54
110
|
else if (arg === "off") enabled = false;
|
|
55
111
|
else if (!arg) enabled = !enabled;
|
|
56
112
|
else {
|
|
57
|
-
ctx.ui?.notify("Usage: /uwu [on|off|prompt|rewrite]", "info");
|
|
113
|
+
ctx.ui?.notify("Usage: /uwu [on|off|prompt|rewrite|colors [on|off]]", "info");
|
|
58
114
|
return;
|
|
59
115
|
}
|
|
116
|
+
try {
|
|
117
|
+
await saveState();
|
|
118
|
+
} catch {
|
|
119
|
+
ctx.ui?.notify("uwu setting could not be saved; it may reset next session", "info");
|
|
120
|
+
}
|
|
121
|
+
syncColors(ctx);
|
|
60
122
|
updateBadge(ctx);
|
|
61
123
|
ctx.ui?.notify(enabled ? `uwu mode ${style === "rewrite" ? "rewrite" : "prompt"}! (◕ᴗ◕✿)` : "uwu mode off", "info");
|
|
62
124
|
},
|
|
63
125
|
});
|
|
64
126
|
|
|
65
|
-
pi.on("session_start", (_event, rawCtx) =>
|
|
127
|
+
pi.on("session_start", async (_event, rawCtx) => {
|
|
128
|
+
await loadState();
|
|
129
|
+
const ctx = rawCtx as UiContext;
|
|
130
|
+
syncColors(ctx);
|
|
131
|
+
updateBadge(ctx);
|
|
132
|
+
});
|
|
66
133
|
|
|
67
134
|
pi.on("before_agent_start", async (event, rawCtx) => {
|
|
135
|
+
await loadState();
|
|
68
136
|
promptAddedThisTurn = false;
|
|
69
137
|
const ctx = rawCtx as UiContext;
|
|
70
138
|
if (ctx.agent?.kind === "sub") return undefined;
|
|
@@ -76,6 +144,7 @@ export default function uwuExtension(pi: ExtensionAPI) {
|
|
|
76
144
|
return { systemPrompt: [...event.systemPrompt, UWU_PROMPT] };
|
|
77
145
|
});
|
|
78
146
|
|
|
147
|
+
|
|
79
148
|
// The hook is newer than the bundled 18.4.3 types, so register structurally.
|
|
80
149
|
// ExtensionAPI.on stores event names as strings; old hosts simply never emit it.
|
|
81
150
|
(pi.on as unknown as (name: string, handler: (event: RewriteEvent) => unknown) => void)(
|
package/src/kawaii.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type * as PiCodingAgent from "@oh-my-pi/pi-coding-agent";
|
|
2
|
+
import type { Theme, ThemeBg, ThemeColor } from "@oh-my-pi/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
export type ThemeRuntime = Pick<typeof PiCodingAgent, "Theme" | "setThemeInstance">;
|
|
5
|
+
|
|
6
|
+
const FG_KEYS: ThemeColor[] = [
|
|
7
|
+
"accent", "border", "borderAccent", "borderMuted", "success", "error", "warning", "muted", "dim", "text",
|
|
8
|
+
"thinkingText", "userMessageText", "customMessageText", "customMessageLabel", "toolTitle", "toolOutput",
|
|
9
|
+
"mdHeading", "mdLink", "mdLinkUrl", "mdCode", "mdCodeBlock", "mdCodeBlockBorder", "mdQuote", "mdQuoteBorder",
|
|
10
|
+
"mdHr", "mdListBullet", "toolDiffAdded", "toolDiffRemoved", "toolDiffContext", "syntaxComment", "syntaxKeyword",
|
|
11
|
+
"syntaxFunction", "syntaxVariable", "syntaxString", "syntaxNumber", "syntaxType", "syntaxOperator", "syntaxPunctuation",
|
|
12
|
+
"thinkingOff", "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh", "thinkingXhigh", "thinkingMax",
|
|
13
|
+
"bashMode", "pythonMode", "statusLineSep", "statusLineModel", "statusLinePath", "statusLineGitClean", "statusLineGitDirty",
|
|
14
|
+
"statusLineContext", "statusLineSpend", "statusLineStaged", "statusLineDirty", "statusLineUntracked", "statusLineOutput",
|
|
15
|
+
"statusLineCost", "statusLineSubagents",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const BG_KEYS: ThemeBg[] = ["selectedBg", "userMessageBg", "customMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg", "statusLineBg"];
|
|
19
|
+
const FG_DEFAULT = "\x1b[39m";
|
|
20
|
+
const BG_DEFAULT = "\x1b[49m";
|
|
21
|
+
|
|
22
|
+
const DARK_PALETTE: Partial<Record<ThemeColor | ThemeBg, string>> = {
|
|
23
|
+
accent: "#ff8fcf", border: "#b99aca", borderAccent: "#ff8fcf", thinkingText: "#cbb8ef",
|
|
24
|
+
mdHeading: "#ff9ed8", mdLink: "#b5a8ff", mdLinkUrl: "#a08fd8", mdCode: "#8fe8c3",
|
|
25
|
+
mdCodeBlockBorder: "#b99aca", mdQuote: "#ffc4e1", mdQuoteBorder: "#ff8fcf", mdHr: "#b99aca",
|
|
26
|
+
mdListBullet: "#ffb38a", userMessageBg: "#3a2433",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const LIGHT_PALETTE: Partial<Record<ThemeColor | ThemeBg, string>> = {
|
|
30
|
+
accent: "#c52f80", border: "#9c75b6", borderAccent: "#c52f80", thinkingText: "#735f9e",
|
|
31
|
+
mdHeading: "#b32470", mdLink: "#624ac6", mdLinkUrl: "#7663b1", mdCode: "#087b5c",
|
|
32
|
+
mdCodeBlockBorder: "#9c75b6", mdQuote: "#99416f", mdQuoteBorder: "#c52f80", mdHr: "#9c75b6",
|
|
33
|
+
mdListBullet: "#c35424", userMessageBg: "#fde8f3",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Build a pastel copy of a host theme, retaining its colors outside the palette. */
|
|
37
|
+
export function buildKawaiiTheme(ThemeClass: ThemeRuntime["Theme"], base: Theme): Theme {
|
|
38
|
+
const palette = base.isLight ? LIGHT_PALETTE : DARK_PALETTE;
|
|
39
|
+
const fg: Partial<Record<ThemeColor, string>> = {};
|
|
40
|
+
const bg: Partial<Record<ThemeBg, string>> = {};
|
|
41
|
+
|
|
42
|
+
for (const key of FG_KEYS) {
|
|
43
|
+
try {
|
|
44
|
+
const ansi = base.getFgAnsi(key);
|
|
45
|
+
fg[key] = palette[key] ?? (ansi === FG_DEFAULT ? "" : base.getColorHex(key));
|
|
46
|
+
} catch {
|
|
47
|
+
// A host newer than the plugin's type dependency may expose extra colors.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const key of BG_KEYS) {
|
|
51
|
+
try {
|
|
52
|
+
const ansi = base.getBgAnsi(key);
|
|
53
|
+
bg[key] = palette[key] ?? (ansi === BG_DEFAULT ? "" : base.getBgHex(key));
|
|
54
|
+
} catch {
|
|
55
|
+
// Missing theme colors are left to the host defaults.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const preset = base.getSymbolPreset();
|
|
60
|
+
const theme = new ThemeClass(
|
|
61
|
+
fg as Record<ThemeColor, string>,
|
|
62
|
+
bg as Record<ThemeBg, string>,
|
|
63
|
+
base.getColorMode(),
|
|
64
|
+
preset,
|
|
65
|
+
{},
|
|
66
|
+
{ status: base.getSpinnerFrames("status"), activity: base.getSpinnerFrames("activity") },
|
|
67
|
+
);
|
|
68
|
+
// A nested colored span (a sparkle, an inline code span) ends in a
|
|
69
|
+
// foreground reset, which would drop the enclosing heading/link color for the
|
|
70
|
+
// rest of the span. Switch back to the enclosing color there instead.
|
|
71
|
+
const plainFg = theme.fg.bind(theme);
|
|
72
|
+
theme.fg = (color, text) => {
|
|
73
|
+
const open = theme.getFgAnsi(color);
|
|
74
|
+
return plainFg(color, open === FG_DEFAULT ? text : text.replaceAll(FG_DEFAULT, open));
|
|
75
|
+
};
|
|
76
|
+
return theme;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A reversible, ephemeral kawaii theme; never edits the user's theme file. */
|
|
80
|
+
export class KawaiiTheme {
|
|
81
|
+
#runtime: ThemeRuntime;
|
|
82
|
+
#base: Theme | undefined;
|
|
83
|
+
#overlay: Theme | undefined;
|
|
84
|
+
|
|
85
|
+
constructor(runtime: ThemeRuntime) {
|
|
86
|
+
this.#runtime = runtime;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
enable(current: Theme): void {
|
|
91
|
+
if (this.#overlay && current === this.#overlay) return;
|
|
92
|
+
this.#base = current;
|
|
93
|
+
this.#overlay = buildKawaiiTheme(this.#runtime.Theme, current);
|
|
94
|
+
this.#runtime.setThemeInstance(this.#overlay);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
disable(current: Theme | undefined): void {
|
|
98
|
+
if (!this.#overlay) return;
|
|
99
|
+
if (current === this.#overlay && this.#base) this.#runtime.setThemeInstance(this.#base);
|
|
100
|
+
this.#base = undefined;
|
|
101
|
+
this.#overlay = undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/sparkle.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-time "sparkles": paints kaomoji, uwu/owo and cute symbols in the
|
|
3
|
+
* assistant's chat prose with the kawaii palette.
|
|
4
|
+
*
|
|
5
|
+
* Nothing is written into message text, history or model context. omp renders
|
|
6
|
+
* assistant prose through a per-component text color transform that Markdown
|
|
7
|
+
* applies to plain-text runs only — never to inline code, code blocks or link
|
|
8
|
+
* targets — so painting there leaves every exact-text surface untouched.
|
|
9
|
+
*/
|
|
10
|
+
import type { Theme, ThemeColor } from "@oh-my-pi/pi-coding-agent";
|
|
11
|
+
import { EMOTICON_TOKENS } from "./uwufy.ts";
|
|
12
|
+
|
|
13
|
+
/** Palette keys the kawaii theme sets; painting rotates through them. */
|
|
14
|
+
const TONES: readonly ThemeColor[] = ["accent", "mdLink", "mdCode", "mdListBullet", "mdHeading", "thinkingText"];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Decorative monochrome glyphs worth a tint on their own. Color emoji ignore fg,
|
|
18
|
+
* and a glyph carrying VS16 renders as emoji, so those are left alone.
|
|
19
|
+
*/
|
|
20
|
+
const SYMBOLS = "♡✧☆★✿❀⋆";
|
|
21
|
+
|
|
22
|
+
// Known emoticons, longest first so a kaomoji wins over the uwu/♡ inside it.
|
|
23
|
+
// Word-edge guards keep `:3` in `a:3` or `owo` in `owowl` unpainted.
|
|
24
|
+
const KNOWN = [...EMOTICON_TOKENS]
|
|
25
|
+
.filter((token) => !/^\p{Extended_Pictographic}+$/u.test(token))
|
|
26
|
+
.sort((a, b) => b.length - a.length)
|
|
27
|
+
.map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
28
|
+
.join("|");
|
|
29
|
+
// Unlisted kaomoji: parentheses holding at least one face feature (eyes,
|
|
30
|
+
// mouths, cheeks), no digits and no two letters in a row, optionally hugged by
|
|
31
|
+
// up to two arm glyphs, and not glued to a word. So `(x−y)`, `(a—b)`,
|
|
32
|
+
// `(şimdi)` and `ş(a…)ı` stay plain.
|
|
33
|
+
const FEATURES = "◕ᴗω▽◡•ᵔ˘≧≦˃˂・・︶﹏‿꒳ᗜﻌ∀ヮ´︿◉⊙ಠ︵□ᴥ^。⌒⩊ᵕ≖ˆ˶˵ᐛ⁀﹃ↀ✧♡";
|
|
34
|
+
const ARMS = String.raw`٩۶ヽノノづっ✧♡☆⌒ヾᕕᕗ\\/`;
|
|
35
|
+
const FACE = String.raw`(?<![\p{L}\p{N}])[${ARMS}]{0,2}\((?=[^()\n]*[${FEATURES}])(?![^()\n]*(?:\p{L}\p{M}*){2})[^()\n\p{N}]{1,16}\)[${ARMS}]{0,2}(?![\p{L}\p{N}])`;
|
|
36
|
+
// Symbols take their combining marks along, so no color code splits a grapheme.
|
|
37
|
+
const SPARKLE = new RegExp(
|
|
38
|
+
String.raw`(?<![\p{L}\p{N}_])(?:${KNOWN})(?![\p{L}\p{N}_])|${FACE}|[${SYMBOLS}](?!\p{M}*\uFE0F)\p{M}*`,
|
|
39
|
+
"gu",
|
|
40
|
+
);
|
|
41
|
+
const LETTERS = /^\p{L}+$/u;
|
|
42
|
+
|
|
43
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
44
|
+
|
|
45
|
+
export type Paint = (tone: ThemeColor, text: string) => string;
|
|
46
|
+
|
|
47
|
+
/** FNV-1a; picks a stable starting tone per token. */
|
|
48
|
+
function hash(value: string): number {
|
|
49
|
+
let h = 0x811c9dc5;
|
|
50
|
+
for (let i = 0; i < value.length; i++) h = Math.imul(h ^ value.charCodeAt(i), 0x01000193);
|
|
51
|
+
return h >>> 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Paint every sparkle in one plain-text run. Word emoticons (`uwu`, `OwO`) get
|
|
56
|
+
* a per-letter rainbow; faces and symbols get one tone. Deterministic, so a
|
|
57
|
+
* re-render of the same run yields the same bytes.
|
|
58
|
+
*/
|
|
59
|
+
export function sparkle(text: string, paint: Paint): string {
|
|
60
|
+
let ordinal = 0;
|
|
61
|
+
return text.replace(SPARKLE, (match) => {
|
|
62
|
+
const start = hash(match) + ordinal++;
|
|
63
|
+
if (!LETTERS.test(match)) return paint(TONES[start % TONES.length]!, match);
|
|
64
|
+
let index = 0;
|
|
65
|
+
let out = "";
|
|
66
|
+
for (const { segment } of graphemes.segment(match)) out += paint(TONES[(start + index++) % TONES.length]!, segment);
|
|
67
|
+
return out;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A paint function over the live host theme; unknown colors fall back to plain text. */
|
|
72
|
+
export function themePaint(getTheme: () => Theme | undefined): Paint {
|
|
73
|
+
return (tone, text) => {
|
|
74
|
+
try {
|
|
75
|
+
return getTheme()?.fg(tone, text) ?? text;
|
|
76
|
+
} catch {
|
|
77
|
+
return text;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type ColorTransform = (text: string) => string;
|
|
83
|
+
|
|
84
|
+
/** The two public methods of omp's AssistantMessageComponent this relies on. */
|
|
85
|
+
interface AssistantPrototype {
|
|
86
|
+
updateContent(...args: unknown[]): unknown;
|
|
87
|
+
setTextColorTransform(transform?: ColorTransform): void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** omp's TUI `Container`, the base class of AssistantMessageComponent. */
|
|
91
|
+
export interface ContainerClass {
|
|
92
|
+
prototype: { addChild(child: unknown): unknown };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface SparkleControl {
|
|
96
|
+
/** Whether new/updated assistant messages should be painted. */
|
|
97
|
+
isActive(): boolean;
|
|
98
|
+
/** Stable transform identity; Markdown caches renders by it. */
|
|
99
|
+
transform: ColorTransform;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Process-wide state, so extension reloads swap the control instead of re-patching. */
|
|
103
|
+
interface Registry {
|
|
104
|
+
control?: SparkleControl;
|
|
105
|
+
assistant?: AssistantPrototype;
|
|
106
|
+
watching?: boolean;
|
|
107
|
+
}
|
|
108
|
+
const REGISTRY = Symbol.for("omp-uwu.sparkles");
|
|
109
|
+
|
|
110
|
+
/** Walk up from an instance to the prototype that defines both assistant methods. */
|
|
111
|
+
function findAssistantPrototype(value: unknown): AssistantPrototype | undefined {
|
|
112
|
+
for (let proto = value && typeof value === "object" ? Object.getPrototypeOf(value) : null; proto; proto = Object.getPrototypeOf(proto)) {
|
|
113
|
+
if (
|
|
114
|
+
Object.hasOwn(proto, "setTextColorTransform") &&
|
|
115
|
+
Object.hasOwn(proto, "updateContent") &&
|
|
116
|
+
typeof proto.setTextColorTransform === "function" &&
|
|
117
|
+
typeof proto.updateContent === "function"
|
|
118
|
+
) {
|
|
119
|
+
return proto as AssistantPrototype;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Give every assistant message component the sparkle transform while
|
|
127
|
+
* `control.isActive()`. A transform set by the host itself (e.g. the
|
|
128
|
+
* live-voice transcript tint) always wins over sparkles.
|
|
129
|
+
*/
|
|
130
|
+
function patchAssistant(proto: AssistantPrototype, registry: Registry): void {
|
|
131
|
+
const originalSet = proto.setTextColorTransform;
|
|
132
|
+
const originalUpdate = proto.updateContent;
|
|
133
|
+
const hostTransforms = new WeakMap<object, ColorTransform>();
|
|
134
|
+
const applied = new WeakMap<object, ColorTransform | undefined>();
|
|
135
|
+
|
|
136
|
+
proto.setTextColorTransform = function (this: object, transform?: ColorTransform) {
|
|
137
|
+
if (transform) hostTransforms.set(this, transform);
|
|
138
|
+
else hostTransforms.delete(this);
|
|
139
|
+
applied.delete(this);
|
|
140
|
+
originalSet.call(this, transform);
|
|
141
|
+
};
|
|
142
|
+
// Theme changes re-enter updateContent through invalidate(), so toggling
|
|
143
|
+
// colors repaints existing messages too.
|
|
144
|
+
proto.updateContent = function (this: object, ...args: unknown[]) {
|
|
145
|
+
if (!hostTransforms.has(this)) {
|
|
146
|
+
const control = registry.control;
|
|
147
|
+
const wanted = control?.isActive() ? control.transform : undefined;
|
|
148
|
+
if (applied.get(this) !== wanted) {
|
|
149
|
+
applied.set(this, wanted);
|
|
150
|
+
originalSet.call(this, wanted);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return originalUpdate.apply(this, args);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Hook sparkles into omp's AssistantMessageComponent.
|
|
159
|
+
*
|
|
160
|
+
* That class is not part of the extension API, and importing its module by
|
|
161
|
+
* path loads a second, unused copy when omp runs from its bundle. What
|
|
162
|
+
* extensions do share with the host is `Container`, its base class, and the
|
|
163
|
+
* component adds its first child in its constructor before any content
|
|
164
|
+
* update. So `addChild` is watched until the first assistant component shows
|
|
165
|
+
* up, its prototype is patched, and the watch is removed again.
|
|
166
|
+
*/
|
|
167
|
+
export function installSparkles(Container: ContainerClass, control: SparkleControl): void {
|
|
168
|
+
const holder = globalThis as { [REGISTRY]?: Registry };
|
|
169
|
+
const registry = (holder[REGISTRY] ??= {});
|
|
170
|
+
registry.control = control;
|
|
171
|
+
if (registry.assistant || registry.watching) return;
|
|
172
|
+
|
|
173
|
+
registry.watching = true;
|
|
174
|
+
const containerProto = Container.prototype;
|
|
175
|
+
const originalAdd = containerProto.addChild;
|
|
176
|
+
const watch = function (this: object, child: unknown) {
|
|
177
|
+
const assistant = findAssistantPrototype(this) ?? findAssistantPrototype(child);
|
|
178
|
+
if (assistant) {
|
|
179
|
+
if (containerProto.addChild === watch) containerProto.addChild = originalAdd;
|
|
180
|
+
registry.watching = false;
|
|
181
|
+
registry.assistant = assistant;
|
|
182
|
+
patchAssistant(assistant, registry);
|
|
183
|
+
}
|
|
184
|
+
return originalAdd.call(this, child);
|
|
185
|
+
};
|
|
186
|
+
containerProto.addChild = watch;
|
|
187
|
+
}
|
package/src/uwufy.ts
CHANGED
|
@@ -23,10 +23,11 @@ export const EMOTICONS = [
|
|
|
23
23
|
"(♡˙︶˙♡)", "ヽ(♡‿♡)ノ", "(´꒳`)♡", "(ノ´ з `)ノ", "ヾ(・ω・*)",
|
|
24
24
|
"(⌒ω⌒)ノ", "(ᵔ⩊ᵔ)", "(=^・ω・^=)", "(ฅ^•ﻌ•^ฅ)", "(ᵕ—ᴗ—)",
|
|
25
25
|
"(。•̀ᴗ-)✧", "(づ。◕‿‿◕。)づ", "(っ´▽`)っ", "( ˶ˆᗜˆ˵ )", "(๑>◡<๑)",
|
|
26
|
+
"✨", "💖", "🌸", "🎀",
|
|
26
27
|
] as const;
|
|
27
28
|
|
|
28
29
|
/** Tokens treated as emoticons: never counted as words, never rewritten. */
|
|
29
|
-
const EMOTICON_TOKENS = new Set<string>([
|
|
30
|
+
export const EMOTICON_TOKENS: ReadonlySet<string> = new Set<string>([
|
|
30
31
|
...EMOTICONS,
|
|
31
32
|
"UwU",
|
|
32
33
|
"OwO",
|