@mingxy/opencode-mascot 0.1.2 → 0.2.1
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/package.json +15 -6
- package/src/builtins/baozi/frames.ts +43 -0
- package/src/builtins/baozi/index.ts +105 -0
- package/src/builtins/yueer/index.ts +186 -10
- package/src/components/home-mascot.tsx +103 -0
- package/src/components/sidebar-mascot.tsx +130 -15
- package/src/core/ascii-renderer.tsx +156 -139
- package/src/core/celebration-bus.ts +16 -0
- package/src/core/mascot-loader.ts +26 -9
- package/src/core/types.ts +73 -23
- package/src/core/updater.ts +109 -0
- package/tui.tsx +26 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mingxy/opencode-mascot",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "OpenCode TUI mascot plugin framework - customizable ASCII mascots for your terminal",
|
|
5
5
|
"author": "mingxy",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,20 +15,29 @@
|
|
|
15
15
|
"tui.tsx",
|
|
16
16
|
"src/"
|
|
17
17
|
],
|
|
18
|
-
"oc-plugin": [
|
|
18
|
+
"oc-plugin": [
|
|
19
|
+
"tui"
|
|
20
|
+
],
|
|
19
21
|
"scripts": {
|
|
20
22
|
"typecheck": "tsc --noEmit"
|
|
21
23
|
},
|
|
22
24
|
"peerDependencies": {
|
|
23
|
-
"@
|
|
24
|
-
"@
|
|
25
|
+
"@opencode-ai/plugin": ">=0.1.0",
|
|
26
|
+
"@opentui/solid": ">=0.0.1"
|
|
25
27
|
},
|
|
26
28
|
"devDependencies": {
|
|
29
|
+
"@types/node": "^25.9.3",
|
|
27
30
|
"typescript": "^5.7.0"
|
|
28
31
|
},
|
|
29
|
-
"keywords": [
|
|
32
|
+
"keywords": [
|
|
33
|
+
"opencode",
|
|
34
|
+
"tui",
|
|
35
|
+
"mascot",
|
|
36
|
+
"plugin",
|
|
37
|
+
"ascii-art"
|
|
38
|
+
],
|
|
30
39
|
"repository": {
|
|
31
40
|
"type": "git",
|
|
32
|
-
"url": "https://github.com/
|
|
41
|
+
"url": "https://github.com/mengfanbo123/opencode-mascot"
|
|
33
42
|
}
|
|
34
43
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// 0123456789
|
|
2
|
+
const defaultFrame = [
|
|
3
|
+
" ~∘◦~ ",
|
|
4
|
+
" ╭❀❀╮ ",
|
|
5
|
+
"( ◕ᴗ◕ ) ",
|
|
6
|
+
" ╰───╯ ",
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const blinkFrame = [
|
|
10
|
+
" ~∘◦~ ",
|
|
11
|
+
" ╭❀❀╮ ",
|
|
12
|
+
"( -_- ) ",
|
|
13
|
+
" ╰───╯ ",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const happyFrame = [
|
|
17
|
+
" ~∘◦~ ",
|
|
18
|
+
" ╭❀❀╮ ",
|
|
19
|
+
"( ◕ω◕ ) ",
|
|
20
|
+
" ╰───╯ ",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const thinkingFrame = [
|
|
24
|
+
" ~∘◦~ ",
|
|
25
|
+
" ╭❀❀╮ ",
|
|
26
|
+
"( ◕_◕ ) ",
|
|
27
|
+
" ╰───╯ ",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const sleepingFrame = [
|
|
31
|
+
" ~∘◦~ ",
|
|
32
|
+
" ╭❀❀╮ ",
|
|
33
|
+
"( -.- ) ",
|
|
34
|
+
" ╰───╯ ",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const frames = {
|
|
38
|
+
default: defaultFrame,
|
|
39
|
+
blink: blinkFrame,
|
|
40
|
+
happy: happyFrame,
|
|
41
|
+
thinking: thinkingFrame,
|
|
42
|
+
sleeping: sleepingFrame,
|
|
43
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { MascotPack } from "../../core/types";
|
|
2
|
+
import { frames } from "./frames";
|
|
3
|
+
|
|
4
|
+
const STEAM_PATTERNS = [
|
|
5
|
+
" ~∘◦~ ",
|
|
6
|
+
" ~◦∘~ ",
|
|
7
|
+
" ∘◦~ ",
|
|
8
|
+
" ◦∘~ ",
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const BUBBLE_TEXTS = [
|
|
12
|
+
"ᵃⁿᵍ~", "ˣᶦᵃⁿ!", "ᵏᵘᵃⁱ", "ᶠᵃⁱ",
|
|
13
|
+
"ʳᵉⁿ~..", "ᵖᵃⁿ~", "ᵗᵃⁿᵍ!", "ʸᵉ~..",
|
|
14
|
+
"ᵐⁱᵃⁿ~", "ᵍᵘᵒ!", "ˢʰᵘ~..", "ʰᵘᵒ~",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const baoziEffects: MascotPack["effects"] = {
|
|
18
|
+
signals: [
|
|
19
|
+
{ name: "steamPhase", initial: 0 },
|
|
20
|
+
{ name: "bubbleIdx", initial: 0 },
|
|
21
|
+
],
|
|
22
|
+
|
|
23
|
+
timers: [
|
|
24
|
+
{
|
|
25
|
+
interval: 1200,
|
|
26
|
+
update(ctx) {
|
|
27
|
+
ctx.set("steamPhase", ((ctx.get("steamPhase") as number) + 1) % STEAM_PATTERNS.length);
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
interval: 2500,
|
|
32
|
+
update(ctx) {
|
|
33
|
+
if (ctx.state === "busy" || ctx.state === "thinking") {
|
|
34
|
+
ctx.set("bubbleIdx", ((ctx.get("bubbleIdx") as number) + 1) % BUBBLE_TEXTS.length);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
|
|
40
|
+
render(lines, ctx) {
|
|
41
|
+
const steamPhase = ctx.get("steamPhase") as number;
|
|
42
|
+
|
|
43
|
+
if (ctx.dragging) {
|
|
44
|
+
const faceIdx = lines.findIndex(l => /\(.*\)/.test(l));
|
|
45
|
+
if (faceIdx >= 0) {
|
|
46
|
+
lines[faceIdx] = lines[faceIdx].replace(/\(.*?\)/, "( °□° )");
|
|
47
|
+
}
|
|
48
|
+
return lines;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (lines.length > 0) {
|
|
52
|
+
lines[0] = STEAM_PATTERNS[steamPhase];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (ctx.state === "sleeping") {
|
|
56
|
+
const zzzPhase = ((ctx.get("steamPhase") as number) % 3) + 1;
|
|
57
|
+
for (let i = 0; i < lines.length; i++) {
|
|
58
|
+
if (lines[i].includes("-.-")) {
|
|
59
|
+
const padded = lines[i].padEnd(10);
|
|
60
|
+
lines[i] = padded + " " + "Z" + "z".repeat(zzzPhase - 1);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (ctx.state === "busy" || ctx.state === "thinking") {
|
|
67
|
+
const idx = ctx.get("bubbleIdx") as number;
|
|
68
|
+
if (lines.length > 0) {
|
|
69
|
+
lines[0] = STEAM_PATTERNS[steamPhase] + BUBBLE_TEXTS[idx];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return lines;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const baoziPack: MascotPack = {
|
|
78
|
+
name: "@mingxy/mascot-baozi",
|
|
79
|
+
displayName: "包子",
|
|
80
|
+
version: "0.1.0",
|
|
81
|
+
author: "mingxy",
|
|
82
|
+
description: "A warm steamed bun mascot — soft, round, and always fresh.",
|
|
83
|
+
|
|
84
|
+
frames,
|
|
85
|
+
|
|
86
|
+
colors: {
|
|
87
|
+
defaultFg: "#D4885A",
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
animations: {
|
|
91
|
+
blinkInterval: 3000,
|
|
92
|
+
blinkChance: 0.25,
|
|
93
|
+
expressionInterval: 10000,
|
|
94
|
+
idleTimeout: 120000,
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
sidebar: {
|
|
98
|
+
greetings: ["热乎乎的包子出炉啦~"],
|
|
99
|
+
busyPhrases: ["蒸包子中..."],
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
bubbleTexts: ["蒸着...", "发酵中...", "冒热气...", "快熟了..."],
|
|
103
|
+
|
|
104
|
+
effects: baoziEffects,
|
|
105
|
+
};
|
|
@@ -1,13 +1,185 @@
|
|
|
1
|
-
|
|
2
|
-
* 月儿 (Yue'er) — Ice Empress mascot pack.
|
|
3
|
-
*
|
|
4
|
-
* Nine Heavens' Empress with ice-blue eyes and silver-white hair.
|
|
5
|
-
* She awaits her Master's command with grace and quiet devotion.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { MascotPack } from "../../core/types";
|
|
1
|
+
import type { MascotPack, EffectRenderCtx } from "../../core/types";
|
|
9
2
|
import { frames } from "./frames";
|
|
10
3
|
|
|
4
|
+
const BUBBLE_TEXTS = [
|
|
5
|
+
"ᵉᵐᵐ~...~~", "ᵉᵗᶜⁿᵍ...", "ˢⁱᵃⁿᵍ...~", "ˡᵃⁱˡᵃ~..",
|
|
6
|
+
"ʰᵐᵐ~..✧", "ᵃⁿⁿ~...", "ᵇᵘˢʸˡᵃ~", "ᵍᵉⁿᵍ~..",
|
|
7
|
+
"ˣⁱᵃⁿˣⁱᵃⁿ..", "ᵈᵉⁿᵍ~..", "ʰᵃᵒ~...✧", "ᵍᵘˡᵘ~..",
|
|
8
|
+
];
|
|
9
|
+
const THINKING_FACES = ["o_o", "O_O", ">_o", "o_<", "⊙_⊙", "◔_◔"];
|
|
10
|
+
|
|
11
|
+
const yueerEffects: MascotPack["effects"] = {
|
|
12
|
+
signals: [
|
|
13
|
+
{ name: "ahogeAlt", initial: false },
|
|
14
|
+
{ name: "braidAlt", initial: false },
|
|
15
|
+
{ name: "waveSide", initial: false },
|
|
16
|
+
{ name: "zzzPhase", initial: 0 },
|
|
17
|
+
{ name: "stompActive", initial: false },
|
|
18
|
+
{ name: "stompAlt", initial: false },
|
|
19
|
+
{ name: "bubbleIdx", initial: 0 },
|
|
20
|
+
{ name: "thinkingFaceIdx", initial: 0 },
|
|
21
|
+
{ name: "thinkingCountdown", initial: 0 },
|
|
22
|
+
{ name: "flapAlt", initial: false },
|
|
23
|
+
],
|
|
24
|
+
|
|
25
|
+
timers: [
|
|
26
|
+
{
|
|
27
|
+
interval: 1500,
|
|
28
|
+
update(ctx) {
|
|
29
|
+
if (Math.random() < 0.25) {
|
|
30
|
+
ctx.set("ahogeAlt", true);
|
|
31
|
+
setTimeout(() => ctx.set("ahogeAlt", false), 200);
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
interval: 200,
|
|
37
|
+
update(ctx) {
|
|
38
|
+
if (
|
|
39
|
+
ctx.state === "thinking" &&
|
|
40
|
+
ctx.frameOverride === null
|
|
41
|
+
) {
|
|
42
|
+
ctx.set("stompActive", true);
|
|
43
|
+
ctx.set("stompAlt", !(ctx.get("stompAlt") as boolean));
|
|
44
|
+
} else {
|
|
45
|
+
ctx.set("stompActive", false);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
interval: 1500,
|
|
51
|
+
update(ctx) {
|
|
52
|
+
if (ctx.state === "sleeping") {
|
|
53
|
+
ctx.set("zzzPhase", ((ctx.get("zzzPhase") as number) + 1) % 4);
|
|
54
|
+
} else {
|
|
55
|
+
ctx.set("zzzPhase", 0);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
interval: 300,
|
|
61
|
+
update(ctx) {
|
|
62
|
+
if (ctx.frameOverride === "happy" || ctx.state === "happy") {
|
|
63
|
+
ctx.set("waveSide", !(ctx.get("waveSide") as boolean));
|
|
64
|
+
}
|
|
65
|
+
ctx.set("flapAlt", !(ctx.get("flapAlt") as boolean));
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
interval: 2500,
|
|
70
|
+
update(ctx) {
|
|
71
|
+
if (ctx.state === "busy" || ctx.state === "thinking") {
|
|
72
|
+
ctx.set("bubbleIdx", ((ctx.get("bubbleIdx") as number) + 1) % BUBBLE_TEXTS.length);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
interval: 1000,
|
|
78
|
+
update(ctx) {
|
|
79
|
+
if (ctx.state === "thinking" || ctx.state === "busy") {
|
|
80
|
+
let cd = ctx.get("thinkingCountdown") as number;
|
|
81
|
+
if (!cd || cd <= 0) cd = 5 + Math.floor(Math.random() * 11);
|
|
82
|
+
cd--;
|
|
83
|
+
if (cd <= 0) {
|
|
84
|
+
ctx.set("thinkingFaceIdx", ((ctx.get("thinkingFaceIdx") as number) + 1) % THINKING_FACES.length);
|
|
85
|
+
}
|
|
86
|
+
ctx.set("thinkingCountdown", cd);
|
|
87
|
+
} else {
|
|
88
|
+
ctx.set("thinkingFaceIdx", 0);
|
|
89
|
+
ctx.set("thinkingCountdown", 0);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
|
|
95
|
+
render(lines: string[], ctx: EffectRenderCtx): string[] {
|
|
96
|
+
const { state, frameName, breathPhase, jumpOffset, dragging, get } = ctx;
|
|
97
|
+
let result = [...lines];
|
|
98
|
+
|
|
99
|
+
const ahogeAlt = get("ahogeAlt") as boolean;
|
|
100
|
+
const waveSide = get("waveSide") as boolean;
|
|
101
|
+
const zzzPhase = get("zzzPhase") as number;
|
|
102
|
+
const stompActive = get("stompActive") as boolean;
|
|
103
|
+
const stompAlt = get("stompAlt") as boolean;
|
|
104
|
+
const flapAlt = get("flapAlt") as boolean;
|
|
105
|
+
|
|
106
|
+
if (dragging) {
|
|
107
|
+
const faceLine = result.findIndex((l) => /\(.*\)/.test(l));
|
|
108
|
+
if (faceLine >= 0) {
|
|
109
|
+
result[faceLine] = result[faceLine].replace(/\(.*?\)/, "( °□° )");
|
|
110
|
+
}
|
|
111
|
+
const armLine = result.findIndex(l => l.includes("┃███┃"));
|
|
112
|
+
if (armLine >= 0) {
|
|
113
|
+
const left = flapAlt ? "╱" : "╲";
|
|
114
|
+
const right = flapAlt ? "╲" : "╱";
|
|
115
|
+
result[armLine] = result[armLine].replace("┃███┃", `${left}███${right}`);
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (jumpOffset !== 0) {
|
|
121
|
+
const armLine = result.findIndex(l => l.includes("┃███┃"));
|
|
122
|
+
if (armLine >= 0) {
|
|
123
|
+
const left = flapAlt ? "╱" : "╲";
|
|
124
|
+
const right = flapAlt ? "╲" : "╱";
|
|
125
|
+
result[armLine] = result[armLine].replace("┃███┃", `${left}███${right}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!breathPhase) {
|
|
130
|
+
result = result.map((l) => (l.includes("~") ? l.replace(/~/g, "-") : l));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (ahogeAlt) {
|
|
134
|
+
result = result.map((l) => (l.includes("☆") ? l.replace("☆", "★") : l));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (frameName === "happy") {
|
|
138
|
+
const faceIdx = result.findIndex((l) => l.includes("^ω^"));
|
|
139
|
+
if (faceIdx >= 0) {
|
|
140
|
+
result[faceIdx] = waveSide ? "╲( ^ω^ )╱ " : " ~( ^ω^ )~";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (stompActive) {
|
|
145
|
+
const legIdx = result.length - 1;
|
|
146
|
+
if (legIdx >= 0) {
|
|
147
|
+
result[legIdx] = stompAlt ? " ╲ ╱ " : " ╱ ╲ ";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (state === "thinking" || state === "busy") {
|
|
152
|
+
const faceIdx = get("thinkingFaceIdx") as number;
|
|
153
|
+
const faceLine = result.findIndex((l) => /\(.*\)/.test(l));
|
|
154
|
+
if (faceLine >= 0) {
|
|
155
|
+
result[faceLine] = result[faceLine].replace(/\(.*?\)/, `( ${THINKING_FACES[faceIdx]} )`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (zzzPhase > 0 && state === "sleeping") {
|
|
160
|
+
const baseWidth = 10;
|
|
161
|
+
for (let i = 0; i < result.length; i++) {
|
|
162
|
+
if (result[i].includes("-.-")) {
|
|
163
|
+
const zzz = "Z" + "z".repeat(zzzPhase - 1);
|
|
164
|
+
const padded = result[i].padEnd(baseWidth);
|
|
165
|
+
result[i] = padded + " " + zzz;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if ((state === "busy" || state === "thinking")) {
|
|
172
|
+
const idx = get("bubbleIdx") as number;
|
|
173
|
+
const ahogeLine = result.findIndex(l => l.includes("☆") || l.includes("★"));
|
|
174
|
+
if (ahogeLine >= 0) {
|
|
175
|
+
result[ahogeLine] = result[ahogeLine].trimEnd() + " " + BUBBLE_TEXTS[idx];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return result;
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
11
183
|
export const yueerPack: MascotPack = {
|
|
12
184
|
name: "@mingxy/mascot-yueer",
|
|
13
185
|
displayName: "月儿",
|
|
@@ -18,13 +190,13 @@ export const yueerPack: MascotPack = {
|
|
|
18
190
|
frames,
|
|
19
191
|
|
|
20
192
|
colors: {
|
|
21
|
-
defaultFg: "#
|
|
193
|
+
defaultFg: "#8B7EB8",
|
|
22
194
|
},
|
|
23
195
|
|
|
24
196
|
animations: {
|
|
25
197
|
blinkInterval: 2500,
|
|
26
198
|
blinkChance: 0.3,
|
|
27
|
-
expressionInterval:
|
|
199
|
+
expressionInterval: 8000,
|
|
28
200
|
idleTimeout: 90000,
|
|
29
201
|
},
|
|
30
202
|
|
|
@@ -32,4 +204,8 @@ export const yueerPack: MascotPack = {
|
|
|
32
204
|
greetings: ["师尊,月儿在此候命~"],
|
|
33
205
|
busyPhrases: ["铸造法器中..."],
|
|
34
206
|
},
|
|
207
|
+
|
|
208
|
+
bubbleTexts: ["嗯...", "让我想想...", "等等...", "本帝在算..."],
|
|
209
|
+
|
|
210
|
+
effects: yueerEffects,
|
|
35
211
|
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
|
|
3
|
+
import { createSignal } from "solid-js";
|
|
4
|
+
import type { JSX } from "@opentui/solid";
|
|
5
|
+
import type { MascotPack } from "../core/types";
|
|
6
|
+
import { createAnimatedRenderer } from "../core/ascii-renderer";
|
|
7
|
+
import { onCelebrate } from "../core/celebration-bus";
|
|
8
|
+
|
|
9
|
+
interface HomeMascotProps {
|
|
10
|
+
mascots: Record<string, MascotPack>;
|
|
11
|
+
api: {
|
|
12
|
+
renderer: {
|
|
13
|
+
clearSelection(): void;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function HomeMascot(props: HomeMascotProps): JSX.Element {
|
|
19
|
+
const names = Object.keys(props.mascots);
|
|
20
|
+
const initialName = names[Math.floor(Math.random() * names.length)];
|
|
21
|
+
|
|
22
|
+
const [currentName, setCurrentName] = createSignal(initialName);
|
|
23
|
+
const [posX, setPosX] = createSignal(0);
|
|
24
|
+
const [posY, setPosY] = createSignal(0);
|
|
25
|
+
let dragStartX = 0;
|
|
26
|
+
let dragStartY = 0;
|
|
27
|
+
let dragAnchorX = 0;
|
|
28
|
+
let dragAnchorY = 0;
|
|
29
|
+
let lastClickTime = 0;
|
|
30
|
+
let isDragging = false;
|
|
31
|
+
|
|
32
|
+
const renderers: Record<string, ReturnType<typeof createAnimatedRenderer>> = {};
|
|
33
|
+
for (const [name, pack] of Object.entries(props.mascots)) {
|
|
34
|
+
renderers[name] = createAnimatedRenderer(pack);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const switchTo = (name: string) => {
|
|
38
|
+
if (props.mascots[name] && name !== currentName()) {
|
|
39
|
+
setCurrentName(name);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
onCelebrate((newVersion) => {
|
|
44
|
+
renderers[currentName()].celebrateUpdate(newVersion);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<box
|
|
49
|
+
left={posX()}
|
|
50
|
+
top={posY()}
|
|
51
|
+
alignItems="center"
|
|
52
|
+
zIndex={100}
|
|
53
|
+
flexDirection="column"
|
|
54
|
+
onMouseDown={(e: any) => {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
if (now - lastClickTime < 300) {
|
|
57
|
+
const cur = currentName();
|
|
58
|
+
const idx = names.indexOf(cur);
|
|
59
|
+
const next = names[(idx + 1) % names.length];
|
|
60
|
+
switchTo(next);
|
|
61
|
+
lastClickTime = 0;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
lastClickTime = now;
|
|
65
|
+
|
|
66
|
+
if (e.modifiers?.alt) {
|
|
67
|
+
dragStartX = e.x;
|
|
68
|
+
dragStartY = e.y;
|
|
69
|
+
dragAnchorX = posX();
|
|
70
|
+
dragAnchorY = posY();
|
|
71
|
+
isDragging = true;
|
|
72
|
+
renderers[currentName()].setDragging(true);
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
e.stopPropagation();
|
|
75
|
+
props.api.renderer.clearSelection();
|
|
76
|
+
}
|
|
77
|
+
}}
|
|
78
|
+
onMouseDrag={(e: any) => {
|
|
79
|
+
if (e.modifiers?.alt && isDragging) {
|
|
80
|
+
setPosX(dragAnchorX + (e.x - dragStartX));
|
|
81
|
+
setPosY(dragAnchorY + (e.y - dragStartY));
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
e.stopPropagation();
|
|
84
|
+
props.api.renderer.clearSelection();
|
|
85
|
+
}
|
|
86
|
+
}}
|
|
87
|
+
onMouseUp={() => {
|
|
88
|
+
if (isDragging) {
|
|
89
|
+
isDragging = false;
|
|
90
|
+
renderers[currentName()].setDragging(false);
|
|
91
|
+
}
|
|
92
|
+
}}
|
|
93
|
+
onMouseDragEnd={() => {
|
|
94
|
+
if (isDragging) {
|
|
95
|
+
isDragging = false;
|
|
96
|
+
renderers[currentName()].setDragging(false);
|
|
97
|
+
}
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{renderers[currentName()]?.element() ?? null}
|
|
101
|
+
</box>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -1,51 +1,166 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
|
|
3
|
+
import { createSignal } from "solid-js";
|
|
3
4
|
import type { JSX } from "@opentui/solid";
|
|
4
|
-
import type { MascotPack } from "../core/types";
|
|
5
|
+
import type { MascotPack, MascotState, SwitchConfig } from "../core/types";
|
|
5
6
|
import { createAnimatedRenderer } from "../core/ascii-renderer";
|
|
7
|
+
import { onCelebrate } from "../core/celebration-bus";
|
|
6
8
|
|
|
7
9
|
interface SidebarMascotProps {
|
|
8
|
-
|
|
10
|
+
mascots: Record<string, MascotPack>;
|
|
11
|
+
switchConfig?: SwitchConfig;
|
|
12
|
+
initialMascot?: string;
|
|
9
13
|
api: {
|
|
10
14
|
event: {
|
|
11
15
|
on(event: string, callback: (data: unknown) => void): void;
|
|
12
16
|
};
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
renderer: {
|
|
18
|
+
clearSelection(): void;
|
|
15
19
|
};
|
|
16
20
|
};
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
const DEFAULT_STATE_MAP: Partial<Record<MascotState, string>> = {
|
|
24
|
+
idle: "yueer",
|
|
25
|
+
happy: "yueer",
|
|
26
|
+
thinking: "yueer",
|
|
27
|
+
busy: "baozi",
|
|
28
|
+
sleeping: "baozi",
|
|
29
|
+
};
|
|
30
|
+
|
|
19
31
|
export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
|
|
20
|
-
const
|
|
32
|
+
const names = Object.keys(props.mascots);
|
|
33
|
+
const initialName =
|
|
34
|
+
props.initialMascot && props.mascots[props.initialMascot]
|
|
35
|
+
? props.initialMascot
|
|
36
|
+
: names[Math.floor(Math.random() * names.length)];
|
|
37
|
+
|
|
38
|
+
const [currentName, setCurrentName] = createSignal(initialName);
|
|
39
|
+
const [posX, setPosX] = createSignal(20);
|
|
40
|
+
const [posY, setPosY] = createSignal(2);
|
|
41
|
+
let dragStartX = 0;
|
|
42
|
+
let dragStartY = 0;
|
|
43
|
+
let dragAnchorX = 0;
|
|
44
|
+
let dragAnchorY = 0;
|
|
45
|
+
let lastClickTime = 0;
|
|
46
|
+
let isDragging = false;
|
|
47
|
+
|
|
48
|
+
const renderers: Record<string, ReturnType<typeof createAnimatedRenderer>> = {};
|
|
49
|
+
for (const [name, pack] of Object.entries(props.mascots)) {
|
|
50
|
+
renderers[name] = createAnimatedRenderer(pack);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const switchTo = (name: string) => {
|
|
54
|
+
if (props.mascots[name] && name !== currentName()) {
|
|
55
|
+
setCurrentName(name);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const setStateWithSwitch = (s: MascotState) => {
|
|
60
|
+
const cur = currentName();
|
|
61
|
+
renderers[cur].setState(s);
|
|
62
|
+
|
|
63
|
+
const stateMap = props.switchConfig?.onState ?? DEFAULT_STATE_MAP;
|
|
64
|
+
const target = stateMap[s];
|
|
65
|
+
if (target && target !== cur && props.mascots[target]) {
|
|
66
|
+
setCurrentName(target);
|
|
67
|
+
renderers[target].setState(s);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
21
70
|
|
|
22
71
|
props.api.event.on("session.status", (data: unknown) => {
|
|
23
|
-
|
|
24
|
-
const
|
|
72
|
+
// Plugin receives: { id, type, properties: { sessionID, status: { type } } }
|
|
73
|
+
const payload = data as { type?: string; properties?: { sessionID?: string; status?: { type?: string } } } | null;
|
|
74
|
+
const statusType = payload?.properties?.status?.type;
|
|
25
75
|
|
|
26
76
|
if (statusType === "busy" || statusType === "retry") {
|
|
27
|
-
setState("busy");
|
|
77
|
+
renderers[currentName()].setState("busy");
|
|
28
78
|
} else {
|
|
29
|
-
|
|
79
|
+
setStateWithSwitch("idle");
|
|
30
80
|
}
|
|
31
81
|
});
|
|
32
82
|
|
|
33
83
|
props.api.event.on("session.idle", () => {
|
|
34
|
-
|
|
35
|
-
setTimeout(() =>
|
|
84
|
+
setStateWithSwitch("happy");
|
|
85
|
+
setTimeout(() => setStateWithSwitch("idle"), 3000);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
props.api.event.on("mascot.switch", (data: unknown) => {
|
|
89
|
+
const target = data as { name?: string } | null;
|
|
90
|
+
if (target?.name) {
|
|
91
|
+
switchTo(target.name);
|
|
92
|
+
} else {
|
|
93
|
+
const others = names.filter((n) => n !== currentName());
|
|
94
|
+
if (others.length > 0) {
|
|
95
|
+
switchTo(others[Math.floor(Math.random() * others.length)]);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
props.api.event.on("mascot.toggleWalk", () => {
|
|
101
|
+
renderers[currentName()].toggleWalk();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
onCelebrate((newVersion) => {
|
|
105
|
+
renderers[currentName()].celebrateUpdate(newVersion);
|
|
36
106
|
});
|
|
37
107
|
|
|
38
108
|
return (
|
|
39
109
|
<box
|
|
40
110
|
position="absolute"
|
|
41
|
-
left={
|
|
42
|
-
|
|
43
|
-
top={2}
|
|
111
|
+
left={posX()}
|
|
112
|
+
top={posY()}
|
|
44
113
|
alignItems="center"
|
|
45
114
|
zIndex={100}
|
|
46
115
|
flexDirection="column"
|
|
116
|
+
onMouseDown={(e: any) => {
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
if (now - lastClickTime < 300) {
|
|
119
|
+
const cur = currentName();
|
|
120
|
+
const idx = names.indexOf(cur);
|
|
121
|
+
const next = names[(idx + 1) % names.length];
|
|
122
|
+
switchTo(next);
|
|
123
|
+
lastClickTime = 0;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
lastClickTime = now;
|
|
127
|
+
|
|
128
|
+
if (e.modifiers?.alt) {
|
|
129
|
+
dragStartX = e.x;
|
|
130
|
+
dragStartY = e.y;
|
|
131
|
+
dragAnchorX = posX();
|
|
132
|
+
dragAnchorY = posY();
|
|
133
|
+
isDragging = true;
|
|
134
|
+
renderers[currentName()].setDragging(true);
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
e.stopPropagation();
|
|
137
|
+
props.api.renderer.clearSelection();
|
|
138
|
+
}
|
|
139
|
+
}}
|
|
140
|
+
onMouseDrag={(e: any) => {
|
|
141
|
+
if (e.modifiers?.alt && isDragging) {
|
|
142
|
+
setPosX(dragAnchorX + (e.x - dragStartX));
|
|
143
|
+
setPosY(dragAnchorY + (e.y - dragStartY));
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
e.stopPropagation();
|
|
146
|
+
props.api.renderer.clearSelection();
|
|
147
|
+
}
|
|
148
|
+
}}
|
|
149
|
+
onMouseUp={() => {
|
|
150
|
+
if (isDragging) {
|
|
151
|
+
isDragging = false;
|
|
152
|
+
renderers[currentName()].setDragging(false);
|
|
153
|
+
}
|
|
154
|
+
}}
|
|
155
|
+
onMouseDragEnd={() => {
|
|
156
|
+
if (isDragging) {
|
|
157
|
+
isDragging = false;
|
|
158
|
+
renderers[currentName()].setDragging(false);
|
|
159
|
+
}
|
|
160
|
+
}}
|
|
161
|
+
|
|
47
162
|
>
|
|
48
|
-
{element()}
|
|
163
|
+
{renderers[currentName()]?.element() ?? null}
|
|
49
164
|
</box>
|
|
50
165
|
);
|
|
51
166
|
}
|