@mingxy/opencode-mascot 0.1.1 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mingxy/opencode-mascot",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "OpenCode TUI mascot plugin framework - customizable ASCII mascots for your terminal",
5
5
  "author": "mingxy",
6
6
  "license": "MIT",
@@ -29,6 +29,6 @@
29
29
  "keywords": ["opencode", "tui", "mascot", "plugin", "ascii-art"],
30
30
  "repository": {
31
31
  "type": "git",
32
- "url": "https://github.com/mingxy/opencode-mascot"
32
+ "url": "https://github.com/mengfanbo123/opencode-mascot"
33
33
  }
34
34
  }
@@ -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,101 @@
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
+ const baoziEffects: MascotPack["effects"] = {
14
+ signals: [
15
+ { name: "steamPhase", initial: 0 },
16
+ { name: "bubbleIdx", initial: 0 },
17
+ ],
18
+
19
+ timers: [
20
+ {
21
+ interval: 1200,
22
+ update(ctx) {
23
+ ctx.set("steamPhase", ((ctx.get("steamPhase") as number) + 1) % STEAM_PATTERNS.length);
24
+ },
25
+ },
26
+ {
27
+ interval: 2500,
28
+ update(ctx) {
29
+ if (ctx.state === "busy" || ctx.state === "thinking") {
30
+ ctx.set("bubbleIdx", ((ctx.get("bubbleIdx") as number) + 1) % BUBBLE_TEXTS.length);
31
+ }
32
+ },
33
+ },
34
+ ],
35
+
36
+ render(lines, ctx) {
37
+ const steamPhase = ctx.get("steamPhase") as number;
38
+
39
+ if (ctx.dragging) {
40
+ const faceIdx = lines.findIndex(l => /\(.*\)/.test(l));
41
+ if (faceIdx >= 0) {
42
+ lines[faceIdx] = lines[faceIdx].replace(/\(.*?\)/, "( °□° )");
43
+ }
44
+ return lines;
45
+ }
46
+
47
+ if (lines.length > 0) {
48
+ lines[0] = STEAM_PATTERNS[steamPhase];
49
+ }
50
+
51
+ if (ctx.state === "sleeping") {
52
+ const zzzPhase = ((ctx.get("steamPhase") as number) % 3) + 1;
53
+ for (let i = 0; i < lines.length; i++) {
54
+ if (lines[i].includes("-.-")) {
55
+ const padded = lines[i].padEnd(10);
56
+ lines[i] = padded + " " + "Z" + "z".repeat(zzzPhase - 1);
57
+ break;
58
+ }
59
+ }
60
+ }
61
+
62
+ if (ctx.state === "busy" || ctx.state === "thinking") {
63
+ const idx = ctx.get("bubbleIdx") as number;
64
+ if (lines.length > 0) {
65
+ lines[0] = STEAM_PATTERNS[steamPhase] + BUBBLE_TEXTS[idx];
66
+ }
67
+ }
68
+
69
+ return lines;
70
+ },
71
+ };
72
+
73
+ export const baoziPack: MascotPack = {
74
+ name: "@mingxy/mascot-baozi",
75
+ displayName: "包子",
76
+ version: "0.1.0",
77
+ author: "mingxy",
78
+ description: "A warm steamed bun mascot — soft, round, and always fresh.",
79
+
80
+ frames,
81
+
82
+ colors: {
83
+ defaultFg: "#D4885A",
84
+ },
85
+
86
+ animations: {
87
+ blinkInterval: 3000,
88
+ blinkChance: 0.25,
89
+ expressionInterval: 10000,
90
+ idleTimeout: 120000,
91
+ },
92
+
93
+ sidebar: {
94
+ greetings: ["热乎乎的包子出炉啦~"],
95
+ busyPhrases: ["蒸包子中..."],
96
+ },
97
+
98
+ bubbleTexts: ["蒸着...", "发酵中...", "冒热气...", "快熟了..."],
99
+
100
+ effects: baoziEffects,
101
+ };
@@ -1,99 +1,43 @@
1
- /**
2
- * 月儿 (Yue'er) — ASCII art frames for the Ice Empress mascot.
3
- *
4
- * Each expression is a single frame (string[]) where every string is one line.
5
- * ALL frames have the same line count (14) for consistent rendering.
6
- *
7
- * Key design elements:
8
- * - Imperial crown with three prongs
9
- * - Long flowing silver-white hair
10
- * - Big anime eyes (ice blue)
11
- * - Elegant ice-blue robe with fur trim
12
- */
1
+ // 0123456789
2
+ const defaultFrame = [
3
+ " ☆ ",
4
+ " ~( ^-^ )~",
5
+ " ┃███┃ ",
6
+ " ║ ║ ",
7
+ ];
13
8
 
14
- export const frames = {
15
- default: [
16
- " _.+._ ",
17
- " .* *. ",
18
- " .* *. ",
19
- " * {crown} *",
20
- " | /*=====*\\ | ",
21
- " | | O O | | ",
22
- " | | \\_/ | | ",
23
- " | \\ ___ / | ",
24
- " | \\_____/ | ",
25
- " |*~~*~~~~~*~~*| ",
26
- " *~~* ~~* ",
27
- " |* *| ",
28
- " |* *| ",
29
- " *==========* ",
30
- ],
9
+ const blinkFrame = [
10
+ " ☆ ",
11
+ " ~( -_- )~",
12
+ " ┃███┃ ",
13
+ " ║ ║ ",
14
+ ];
31
15
 
32
- blink: [
33
- " _.+._ ",
34
- " .* *. ",
35
- " .* *. ",
36
- " * {crown} *",
37
- " | /*=====*\\ | ",
38
- " | | - - | | ",
39
- " | | \\_/ | | ",
40
- " | \\ ___ / | ",
41
- " | \\_____/ | ",
42
- " |*~~*~~~~~*~~*| ",
43
- " *~~* ~~* ",
44
- " |* *| ",
45
- " |* *| ",
46
- " *==========* ",
47
- ],
16
+ const happyFrame = [
17
+ " ",
18
+ " ~( ^ω^ )~",
19
+ " ┃███┃ ",
20
+ " ║ ║ ",
21
+ ];
48
22
 
49
- happy: [
50
- " _.+._ ",
51
- " .* *. ",
52
- " .* *. ",
53
- " * {crown} *",
54
- " | /*=====*\\ | ",
55
- " | | ^ ^ | | ",
56
- " | | \\_/ | | ",
57
- " | \\ ___ / | ",
58
- " | \\_____/ | ",
59
- " |*~~*~~~~~*~~*| ",
60
- " *~~* ~~* ",
61
- " |* *| ",
62
- " |* *| ",
63
- " *==========* ",
64
- ],
23
+ const thinkingFrame = [
24
+ " ",
25
+ " ~( o_o )~",
26
+ " ┃░░░┃ ",
27
+ " ║ ║ ",
28
+ ];
65
29
 
66
- thinking: [
67
- " _.+._ ",
68
- " .* *. ",
69
- " .* *. ",
70
- " * {crown} *",
71
- " | /*=====*\\ | ",
72
- " | | O - | | ",
73
- " | | < | | ",
74
- " | \\ ___ / | ",
75
- " | \\_____/ | ",
76
- " |*~~*~~~~~*~~*| ",
77
- " *~~* ~~* ",
78
- " |* *| ",
79
- " |* *| ",
80
- " *==========* ",
81
- ],
30
+ const sleepingFrame = [
31
+ " ",
32
+ " ~( -.- )~",
33
+ " ┃~~~┃ ",
34
+ " ║ ║ ",
35
+ ];
82
36
 
83
- sleeping: [
84
- " _.+._ ",
85
- " .* *. ",
86
- " .* *. ",
87
- " * {crown} *",
88
- " | /*=====*\\ | ",
89
- " | | . . | | ",
90
- " | | --- | | ",
91
- " | \\ ___ / | ",
92
- " | \\_____/ | ",
93
- " |*~~*~~~~~*~~*| ",
94
- " *~~* ~~* ",
95
- " |* *| ",
96
- " |* *| ",
97
- " *==========* ",
98
- ],
37
+ export const frames = {
38
+ default: defaultFrame,
39
+ blink: blinkFrame,
40
+ happy: happyFrame,
41
+ thinking: thinkingFrame,
42
+ sleeping: sleepingFrame,
99
43
  };
@@ -1,41 +1,211 @@
1
- /**
2
- * 月儿 (Yue'er) — Ice Empress mascot pack.
3
- *
4
- * Nine Heavens' Empress with silver-white hair, ice-blue eyes, and imperial crown.
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
- import { colors } from "./colors";
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
+ };
11
182
 
12
183
  export const yueerPack: MascotPack = {
13
184
  name: "@mingxy/mascot-yueer",
14
185
  displayName: "月儿",
15
186
  version: "0.1.0",
16
187
  author: "mingxy",
17
- description:
18
- "Ice Empress of the Nine Heavens — elegant, powerful, and devoted to her Master.",
188
+ description: "Ice Empress of the Nine Heavens — elegant, powerful, and devoted to her Master.",
19
189
 
20
190
  frames,
21
- colors,
191
+
192
+ colors: {
193
+ defaultFg: "#8B7EB8",
194
+ },
22
195
 
23
196
  animations: {
24
197
  blinkInterval: 2500,
25
198
  blinkChance: 0.3,
26
- expressionInterval: 50000,
199
+ expressionInterval: 8000,
27
200
  idleTimeout: 90000,
28
201
  },
29
202
 
30
203
  sidebar: {
31
- greetings: [
32
- "师尊,月儿在此候命~",
33
- "月儿恭候师尊多时了~",
34
- "师尊今日气色不错呢~",
35
- ],
36
- busyPhrases: [
37
- "月儿正在铸造法器...",
38
- "驱除心魔中...",
39
- ],
204
+ greetings: ["师尊,月儿在此候命~"],
205
+ busyPhrases: ["铸造法器中..."],
40
206
  },
207
+
208
+ bubbleTexts: ["嗯...", "让我想想...", "等等...", "本帝在算..."],
209
+
210
+ effects: yueerEffects,
41
211
  };
@@ -1,41 +1,161 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
 
3
- import { createSignal, createEffect } from "solid-js";
3
+ import { createSignal } from "solid-js";
4
4
  import type { JSX } from "@opentui/solid";
5
- import type { MascotPack, MascotState } from "../core/types";
5
+ import type { MascotPack, MascotState, SwitchConfig } from "../core/types";
6
6
  import { createAnimatedRenderer } from "../core/ascii-renderer";
7
7
 
8
8
  interface SidebarMascotProps {
9
- mascot: MascotPack;
10
- isRunning?: boolean;
9
+ mascots: Record<string, MascotPack>;
10
+ switchConfig?: SwitchConfig;
11
+ initialMascot?: string;
12
+ api: {
13
+ event: {
14
+ on(event: string, callback: (data: unknown) => void): void;
15
+ };
16
+ renderer: {
17
+ clearSelection(): void;
18
+ };
19
+ };
11
20
  }
12
21
 
22
+ const DEFAULT_STATE_MAP: Partial<Record<MascotState, string>> = {
23
+ idle: "yueer",
24
+ happy: "yueer",
25
+ thinking: "yueer",
26
+ busy: "baozi",
27
+ sleeping: "baozi",
28
+ };
29
+
13
30
  export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
14
- const { element, setState } = createAnimatedRenderer(props.mascot);
31
+ const names = Object.keys(props.mascots);
32
+ const initialName =
33
+ props.initialMascot && props.mascots[props.initialMascot]
34
+ ? props.initialMascot
35
+ : names[Math.floor(Math.random() * names.length)];
36
+
37
+ const [currentName, setCurrentName] = createSignal(initialName);
38
+ const [posX, setPosX] = createSignal(20);
39
+ const [posY, setPosY] = createSignal(2);
40
+ let dragStartX = 0;
41
+ let dragStartY = 0;
42
+ let dragAnchorX = 0;
43
+ let dragAnchorY = 0;
44
+ let lastClickTime = 0;
45
+ let isDragging = false;
15
46
 
16
- const greetings = props.mascot.sidebar?.greetings ?? ["~"];
17
- const busyPhrases = props.mascot.sidebar?.busyPhrases ?? ["Working..."];
47
+ const renderers: Record<string, ReturnType<typeof createAnimatedRenderer>> = {};
48
+ for (const [name, pack] of Object.entries(props.mascots)) {
49
+ renderers[name] = createAnimatedRenderer(pack);
50
+ }
18
51
 
19
- const initialGreeting = greetings[Math.floor(Math.random() * greetings.length)];
20
- const [stateText, setStateText] = createSignal<string>(initialGreeting);
52
+ const switchTo = (name: string) => {
53
+ if (props.mascots[name] && name !== currentName()) {
54
+ setCurrentName(name);
55
+ }
56
+ };
21
57
 
22
- createEffect(() => {
23
- if (props.isRunning) {
24
- setState("busy" as MascotState);
25
- const phrase = busyPhrases[Math.floor(Math.random() * busyPhrases.length)];
26
- setStateText(phrase);
58
+ const setStateWithSwitch = (s: MascotState) => {
59
+ const cur = currentName();
60
+ renderers[cur].setState(s);
61
+
62
+ const stateMap = props.switchConfig?.onState ?? DEFAULT_STATE_MAP;
63
+ const target = stateMap[s];
64
+ if (target && target !== cur && props.mascots[target]) {
65
+ setCurrentName(target);
66
+ renderers[target].setState(s);
67
+ }
68
+ };
69
+
70
+ props.api.event.on("session.status", (data: unknown) => {
71
+ // Plugin receives: { id, type, properties: { sessionID, status: { type } } }
72
+ const payload = data as { type?: string; properties?: { sessionID?: string; status?: { type?: string } } } | null;
73
+ const statusType = payload?.properties?.status?.type;
74
+
75
+ if (statusType === "busy" || statusType === "retry") {
76
+ renderers[currentName()].setState("busy");
27
77
  } else {
28
- setState("idle");
29
- setStateText(greetings[Math.floor(Math.random() * greetings.length)]);
78
+ setStateWithSwitch("idle");
30
79
  }
31
80
  });
32
81
 
82
+ props.api.event.on("session.idle", () => {
83
+ setStateWithSwitch("happy");
84
+ setTimeout(() => setStateWithSwitch("idle"), 3000);
85
+ });
86
+
87
+ props.api.event.on("mascot.switch", (data: unknown) => {
88
+ const target = data as { name?: string } | null;
89
+ if (target?.name) {
90
+ switchTo(target.name);
91
+ } else {
92
+ const others = names.filter((n) => n !== currentName());
93
+ if (others.length > 0) {
94
+ switchTo(others[Math.floor(Math.random() * others.length)]);
95
+ }
96
+ }
97
+ });
98
+
99
+ props.api.event.on("mascot.toggleWalk", () => {
100
+ renderers[currentName()].toggleWalk();
101
+ });
102
+
33
103
  return (
34
- <box flexDirection="column" paddingTop={1} paddingBottom={1} paddingLeft={1}>
35
- {element()}
36
- <text fg="gray" wrapMode="word">
37
- {stateText()}
38
- </text>
104
+ <box
105
+ position="absolute"
106
+ left={posX()}
107
+ top={posY()}
108
+ alignItems="center"
109
+ zIndex={100}
110
+ flexDirection="column"
111
+ onMouseDown={(e: any) => {
112
+ const now = Date.now();
113
+ if (now - lastClickTime < 300) {
114
+ const cur = currentName();
115
+ const idx = names.indexOf(cur);
116
+ const next = names[(idx + 1) % names.length];
117
+ switchTo(next);
118
+ lastClickTime = 0;
119
+ return;
120
+ }
121
+ lastClickTime = now;
122
+
123
+ if (e.modifiers?.alt) {
124
+ dragStartX = e.x;
125
+ dragStartY = e.y;
126
+ dragAnchorX = posX();
127
+ dragAnchorY = posY();
128
+ isDragging = true;
129
+ renderers[currentName()].setDragging(true);
130
+ e.preventDefault();
131
+ e.stopPropagation();
132
+ props.api.renderer.clearSelection();
133
+ }
134
+ }}
135
+ onMouseDrag={(e: any) => {
136
+ if (e.modifiers?.alt && isDragging) {
137
+ setPosX(dragAnchorX + (e.x - dragStartX));
138
+ setPosY(dragAnchorY + (e.y - dragStartY));
139
+ e.preventDefault();
140
+ e.stopPropagation();
141
+ props.api.renderer.clearSelection();
142
+ }
143
+ }}
144
+ onMouseUp={() => {
145
+ if (isDragging) {
146
+ isDragging = false;
147
+ renderers[currentName()].setDragging(false);
148
+ }
149
+ }}
150
+ onMouseDragEnd={() => {
151
+ if (isDragging) {
152
+ isDragging = false;
153
+ renderers[currentName()].setDragging(false);
154
+ }
155
+ }}
156
+
157
+ >
158
+ {renderers[currentName()]?.element() ?? null}
39
159
  </box>
40
160
  );
41
161
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { createSignal, onCleanup } from "solid-js";
4
4
  import type { JSX } from "@opentui/solid";
5
- import type { MascotPack, MascotState } from "./types";
5
+ import type { MascotPack, MascotState, EffectTimerCtx, EffectRenderCtx } from "./types";
6
6
 
7
7
  const STATE_TO_FRAME: Record<MascotState, string> = {
8
8
  idle: "default",
@@ -13,22 +13,26 @@ const STATE_TO_FRAME: Record<MascotState, string> = {
13
13
  };
14
14
 
15
15
  const DEFAULT_ANIM = {
16
- blinkInterval: 2000,
17
- blinkChance: 0.35,
18
- expressionInterval: 45000,
16
+ blinkInterval: 2500,
17
+ blinkChance: 0.3,
18
+ expressionInterval: 8000,
19
19
  idleTimeout: 120000,
20
+ breathInterval: 3000,
21
+ walkEnabled: true,
22
+ walkMinDelay: 20000,
23
+ walkMaxDelay: 40000,
24
+ jumpMinDelay: 20000,
25
+ jumpMaxDelay: 40000,
20
26
  };
21
27
 
22
- function getFrame(pack: MascotPack, frameName: string): string[] {
28
+ const WALK_PATH = [1, 2, 3, 4, 3, 2, 1, 0, -1, -2, -3, -2, -1, 0];
29
+
30
+ function getFrameLines(pack: MascotPack, frameName: string): string[] {
23
31
  const frames = pack.frames as Record<string, string[] | undefined>;
24
32
  return frames[frameName] ?? frames["default"] ?? [];
25
33
  }
26
34
 
27
- export function renderFrame(pack: MascotPack, state: MascotState): JSX.Element {
28
- const frameName = STATE_TO_FRAME[state] ?? "default";
29
- const lines = getFrame(pack, frameName);
30
- const fg = pack.colors.defaultFg || undefined;
31
-
35
+ function renderLines(lines: string[], fg?: string): JSX.Element {
32
36
  return (
33
37
  <box flexDirection="column">
34
38
  {lines.map((line: string) => (
@@ -41,39 +45,262 @@ export function renderFrame(pack: MascotPack, state: MascotState): JSX.Element {
41
45
  export function createAnimatedRenderer(pack: MascotPack): {
42
46
  element: () => JSX.Element;
43
47
  setState: (s: MascotState) => void;
48
+ toggleWalk: () => void;
49
+ setDragging: (v: boolean) => void;
44
50
  } {
45
51
  const anim = { ...DEFAULT_ANIM, ...pack.animations };
52
+ const fg = pack.colors?.defaultFg || undefined;
53
+ const effects = pack.effects;
54
+
46
55
  const [currentState, setCurrentState] = createSignal<MascotState>("idle");
47
56
  const [frameOverride, setFrameOverride] = createSignal<string | null>(null);
57
+ const [breathPhase, setBreathPhase] = createSignal(true);
58
+ const [walkOffset, setWalkOffset] = createSignal(0);
59
+ const [jumpOffset, setJumpOffset] = createSignal(0);
60
+ const [walkEnabled, setWalkEnabled] = createSignal(anim.walkEnabled ?? true);
61
+ const [dragging, setDraggingSignal] = createSignal(false);
62
+
63
+ let idleSleepTimeout: ReturnType<typeof setTimeout> | null = null;
64
+
65
+ const resetIdleSleep = () => {
66
+ if (idleSleepTimeout) clearTimeout(idleSleepTimeout);
67
+ idleSleepTimeout = null;
68
+ if (currentState() !== "idle") return;
69
+ idleSleepTimeout = setTimeout(() => {
70
+ if (currentState() === "idle") {
71
+ setCurrentState("sleeping");
72
+ stopWalk();
73
+ }
74
+ }, anim.idleTimeout);
75
+ };
76
+
77
+ resetIdleSleep();
78
+
79
+ // ─── Extra signals from pack effects ───
80
+ const extraSignals = new Map<string, [() => unknown, (v: unknown) => void]>();
81
+ if (effects?.signals) {
82
+ for (const sig of effects.signals) {
83
+ const [get, set] = createSignal(sig.initial);
84
+ extraSignals.set(sig.name, [get, set]);
85
+ }
86
+ }
87
+
88
+ const getExtra = (name: string): unknown => extraSignals.get(name)?.[0]() ?? null;
89
+ const setExtra = (name: string, value: unknown) => extraSignals.get(name)?.[1](value);
90
+
91
+ const timerCtx: EffectTimerCtx = {
92
+ get: getExtra,
93
+ set: setExtra,
94
+ get state() { return currentState(); },
95
+ get frameOverride() { return frameOverride(); },
96
+ setFrameOverride: (name) => setFrameOverride(name),
97
+ };
98
+
99
+ // ─── Built-in timers ───
100
+
101
+ // 1. Blink
102
+ const hasBlink = (pack.frames as Record<string, string[] | undefined>)["blink"] !== undefined;
48
103
 
49
104
  const blinkTimer = setInterval(() => {
50
- if (Math.random() < anim.blinkChance) {
105
+ if (currentState() !== "sleeping" && Math.random() < anim.blinkChance && hasBlink) {
51
106
  setFrameOverride("blink");
52
107
  setTimeout(() => setFrameOverride(null), 150);
53
108
  }
54
109
  }, anim.blinkInterval);
55
110
 
56
- const availableFrames = Object.keys(pack.frames).filter(
57
- (k) => k !== "default" && k !== "blink"
111
+ // 2. Random expression
112
+ const availableExpressions = Object.keys(pack.frames).filter(
113
+ (k) => k !== "default" && k !== "blink",
58
114
  );
115
+
59
116
  const expressionTimer = setInterval(() => {
60
- if (currentState() === "idle" && availableFrames.length > 0) {
61
- const pick = availableFrames[Math.floor(Math.random() * availableFrames.length)];
62
- setFrameOverride(pick);
63
- setTimeout(() => setFrameOverride(null), 2000);
117
+ if (currentState() === "idle" && !frameOverride()) {
118
+ const pick = availableExpressions[Math.floor(Math.random() * availableExpressions.length)];
119
+ if (pick) {
120
+ setFrameOverride(pick);
121
+ setTimeout(() => setFrameOverride(null), 2000);
122
+ }
64
123
  }
65
124
  }, anim.expressionInterval);
66
125
 
126
+ // 3. Breathing
127
+ const breathTimer = setInterval(() => {
128
+ if (currentState() === "idle") {
129
+ setBreathPhase((v) => !v);
130
+ }
131
+ }, anim.breathInterval);
132
+
133
+ // 4. Walk
134
+ let walkStep = -1;
135
+ let walkInterval: ReturnType<typeof setInterval> | null = null;
136
+
137
+ const startWalk = () => {
138
+ if (walkInterval || !walkEnabled()) return;
139
+ walkStep = 0;
140
+ walkInterval = setInterval(() => {
141
+ if (currentState() !== "idle") {
142
+ stopWalk();
143
+ return;
144
+ }
145
+ if (walkStep < WALK_PATH.length) {
146
+ setWalkOffset(WALK_PATH[walkStep]);
147
+ walkStep++;
148
+ } else {
149
+ if (walkInterval) clearInterval(walkInterval);
150
+ walkInterval = null;
151
+ walkStep = -1;
152
+ setWalkOffset(0);
153
+ }
154
+ }, 400);
155
+ };
156
+
157
+ const stopWalk = () => {
158
+ if (walkInterval) {
159
+ clearInterval(walkInterval);
160
+ walkInterval = null;
161
+ }
162
+ walkStep = -1;
163
+ setWalkOffset(0);
164
+ };
165
+
166
+ let walkTimeout: ReturnType<typeof setTimeout>;
167
+
168
+ function scheduleNextWalk() {
169
+ if (!walkEnabled()) return setTimeout(() => {}, 60000);
170
+ const delay = anim.walkMinDelay + Math.floor(Math.random() * (anim.walkMaxDelay - anim.walkMinDelay));
171
+ return setTimeout(() => {
172
+ if (currentState() === "idle" && !frameOverride() && walkStep === -1 && walkEnabled()) {
173
+ startWalk();
174
+ }
175
+ if (currentState() !== "sleeping") {
176
+ walkTimeout = scheduleNextWalk();
177
+ }
178
+ }, delay);
179
+ }
180
+
181
+ walkTimeout = scheduleNextWalk();
182
+
183
+ // 5. Jump
184
+ let jumpTimeout: ReturnType<typeof setTimeout>;
185
+
186
+ function scheduleNextJump() {
187
+ const delay = anim.jumpMinDelay + Math.floor(Math.random() * (anim.jumpMaxDelay - anim.jumpMinDelay));
188
+ return setTimeout(() => {
189
+ if (currentState() === "idle" && !frameOverride() && walkStep === -1) {
190
+ setJumpOffset(-2);
191
+ setTimeout(() => setJumpOffset(-1), 1500);
192
+ setTimeout(() => setJumpOffset(0), 2000);
193
+ }
194
+ if (currentState() !== "sleeping") {
195
+ jumpTimeout = scheduleNextJump();
196
+ }
197
+ }, delay);
198
+ }
199
+
200
+ jumpTimeout = scheduleNextJump();
201
+
202
+ // ─── Pack-defined effect timers ───
203
+ const effectTimers: ReturnType<typeof setInterval>[] = [];
204
+ if (effects?.timers) {
205
+ for (const t of effects.timers) {
206
+ effectTimers.push(setInterval(() => t.update(timerCtx), t.interval));
207
+ }
208
+ }
209
+
210
+ // ─── Cleanup ───
67
211
  onCleanup(() => {
68
212
  clearInterval(blinkTimer);
69
213
  clearInterval(expressionTimer);
214
+ clearInterval(breathTimer);
215
+ clearTimeout(walkTimeout);
216
+ clearTimeout(jumpTimeout);
217
+ if (idleSleepTimeout) clearTimeout(idleSleepTimeout);
218
+ if (walkInterval) clearInterval(walkInterval);
219
+ for (const t of effectTimers) clearInterval(t);
70
220
  });
71
221
 
222
+ // ─── Render ───
72
223
  const element = () => {
73
- const override = frameOverride();
74
- const state = override ? (override as MascotState) : currentState();
75
- return renderFrame(pack, state);
224
+ breathPhase();
225
+ walkOffset();
226
+ jumpOffset();
227
+ frameOverride();
228
+ currentState();
229
+ dragging();
230
+
231
+ for (const [, [get]] of extraSignals) {
232
+ get();
233
+ }
234
+
235
+ const frameName = frameOverride() ?? STATE_TO_FRAME[currentState()] ?? "default";
236
+ const rawLines = getFrameLines(pack, frameName);
237
+ const offset = walkOffset();
238
+
239
+ const width = rawLines[0]?.length ?? 10;
240
+ const blank = " ".repeat(width);
241
+
242
+ let lines: string[] = rawLines.map((line, i) => {
243
+ if (!breathPhase()) {
244
+ if (i === 0) return blank;
245
+ return rawLines[i - 1];
246
+ }
247
+ return line;
248
+ });
249
+
250
+ if (effects?.render) {
251
+ const renderCtx: EffectRenderCtx = {
252
+ state: currentState(),
253
+ frameName,
254
+ breathPhase: breathPhase(),
255
+ jumpOffset: jumpOffset(),
256
+ dragging: dragging(),
257
+ get: getExtra,
258
+ };
259
+ lines = effects.render(lines, renderCtx);
260
+ }
261
+
262
+ const top = jumpOffset();
263
+ const left = offset > 0 ? offset : 0;
264
+
265
+ return (
266
+ <box flexDirection="column" left={left} top={top}>
267
+ {renderLines(lines, fg)}
268
+ </box>
269
+ );
270
+ };
271
+
272
+ // ─── State control ───
273
+ const setState = (s: MascotState) => {
274
+ setCurrentState(s);
275
+ setBreathPhase(true);
276
+ resetIdleSleep();
277
+
278
+ if (s !== "idle") {
279
+ stopWalk();
280
+ } else if (walkEnabled()) {
281
+ walkTimeout = scheduleNextWalk();
282
+ jumpTimeout = scheduleNextJump();
283
+ }
284
+ };
285
+
286
+ const toggleWalk = () => {
287
+ const next = !walkEnabled();
288
+ setWalkEnabled(next);
289
+ if (!next) {
290
+ stopWalk();
291
+ } else if (currentState() === "idle") {
292
+ walkTimeout = scheduleNextWalk();
293
+ }
294
+ };
295
+
296
+ const setDragging = (v: boolean) => {
297
+ setDraggingSignal(v);
298
+ if (v) {
299
+ setJumpOffset(-1);
300
+ } else {
301
+ setJumpOffset(0);
302
+ }
76
303
  };
77
304
 
78
- return { element, setState: setCurrentState };
305
+ return { element, setState, toggleWalk, setDragging };
79
306
  }
@@ -1,18 +1,35 @@
1
1
  import type { MascotPack } from "./types"
2
2
  import { yueerPack } from "../builtins/yueer"
3
+ import { baoziPack } from "../builtins/baozi"
3
4
 
4
5
  const BUILTINS: Record<string, MascotPack> = {
5
6
  yueer: yueerPack,
7
+ baozi: baoziPack,
8
+ }
9
+
10
+ const ALL_NAMES = Object.keys(BUILTINS)
11
+
12
+ function randomPick<T>(arr: T[]): T {
13
+ return arr[Math.floor(Math.random() * arr.length)]
14
+ }
15
+
16
+ export function loadAllMascots(): Record<string, MascotPack> {
17
+ return { ...BUILTINS }
18
+ }
19
+
20
+ export function getMascotNames(): string[] {
21
+ return [...ALL_NAMES]
22
+ }
23
+
24
+ export function getRandomMascot(): MascotPack {
25
+ return randomPick(Object.values(BUILTINS))
6
26
  }
7
27
 
8
28
  export async function loadMascot(options?: Record<string, unknown>): Promise<MascotPack> {
9
- const mascotName = (options?.mascot as string) || "yueer"
10
-
11
- if (BUILTINS[mascotName]) {
12
- return BUILTINS[mascotName]
13
- }
14
-
15
- // Future: npm package loading, local file loading
16
- // Fallback to yueer
17
- return yueerPack
29
+ const mascotName = (options?.mascot as string) || "random"
30
+
31
+ if (mascotName === "random") return getRandomMascot()
32
+ if (BUILTINS[mascotName]) return BUILTINS[mascotName]
33
+
34
+ return getRandomMascot()
18
35
  }
package/src/core/types.ts CHANGED
@@ -9,93 +9,112 @@ export type ExpressionName = 'default' | 'blink' | 'happy' | 'thinking' | 'busy'
9
9
  */
10
10
  export type MascotState = 'idle' | 'busy' | 'thinking' | 'sleeping' | 'happy';
11
11
 
12
- /**
13
- * Color definition for a zone within a frame.
14
- */
15
- export interface ZoneColor {
16
- /** ANSI foreground color code or CSS color */
17
- fg?: string;
18
- /** ANSI background color code or CSS color */
19
- bg?: string;
20
- }
21
-
22
12
  /**
23
13
  * Animation timing configuration with sensible defaults.
24
14
  */
25
15
  export interface AnimationConfig {
26
- /** Milliseconds between blink checks (default: 2000) */
27
16
  blinkInterval?: number;
28
- /** Probability of a blink on each check, 0–1 (default: 0.35) */
29
17
  blinkChance?: number;
30
- /** Milliseconds between random expression changes (default: 45000) */
31
18
  expressionInterval?: number;
32
- /** Milliseconds of inactivity before entering sleep state (default: 120000) */
33
19
  idleTimeout?: number;
20
+ breathInterval?: number;
21
+ walkEnabled?: boolean;
22
+ walkMinDelay?: number;
23
+ walkMaxDelay?: number;
24
+ jumpMinDelay?: number;
25
+ jumpMaxDelay?: number;
34
26
  }
35
27
 
36
28
  /**
37
29
  * Sidebar display configuration.
38
30
  */
39
31
  export interface SidebarConfig {
40
- /** Rotating greeting messages shown below the mascot */
41
32
  greetings?: string[];
42
- /** Rotating phrases shown while in busy state */
43
33
  busyPhrases?: string[];
44
- /** Sidebar width in characters (default: mascot frame width) */
45
- width?: number;
34
+ }
35
+
36
+ // ─── Effect system types ───
37
+
38
+ /**
39
+ * A single extra signal managed by the effect system.
40
+ * The renderer creates a Solid signal for each and exposes get/set to timers and render.
41
+ */
42
+ export interface SignalDef {
43
+ name: string;
44
+ initial: unknown;
46
45
  }
47
46
 
48
47
  /**
49
- * Context passed to render hooks.
48
+ * Timer context passed to EffectTimer.update().
50
49
  */
51
- export interface RenderContext {
50
+ export interface EffectTimerCtx {
51
+ /** Read an extra signal by name */
52
+ get: (name: string) => unknown;
53
+ /** Write an extra signal by name */
54
+ set: (name: string, value: unknown) => void;
52
55
  /** Current mascot state */
53
56
  state: MascotState;
54
- /** Current frame index within the active expression */
55
- frameIndex: number;
56
- /** Unix timestamp (ms) of this render call */
57
- timestamp: number;
58
- /** Optional session metadata from the host */
59
- sessionData?: {
60
- tokenUsage?: number;
61
- tokenLimit?: number;
62
- model?: string;
63
- };
57
+ /** Current frame override (null = use state mapping) */
58
+ frameOverride: string | null;
59
+ /** Override the displayed frame */
60
+ setFrameOverride: (name: string | null) => void;
64
61
  }
65
62
 
66
63
  /**
67
- * Lifecycle hooks for customizing mascot behavior.
64
+ * A recurring timer that updates extra signals.
68
65
  */
69
- export interface MascotHooks {
70
- /** Called before each frame render. Return modified frame lines or the original. */
71
- beforeRender?: (context: RenderContext, frame: string[]) => string[];
72
- /** Called when the mascot state transitions. */
73
- onStateChange?: (prevState: MascotState, newState: MascotState) => void;
66
+ export interface EffectTimer {
67
+ /** Interval in ms */
68
+ interval: number;
69
+ /** Called on each tick */
70
+ update: (ctx: EffectTimerCtx) => void;
71
+ }
72
+
73
+ /**
74
+ * Context passed to EffectRenderFn.
75
+ */
76
+ export interface EffectRenderCtx {
77
+ state: MascotState;
78
+ frameName: string;
79
+ breathPhase: boolean;
80
+ jumpOffset: number;
81
+ dragging: boolean;
82
+ get: (name: string) => unknown;
83
+ }
84
+
85
+ /**
86
+ * Pack-defined render effect. Called after built-in rendering (breathing),
87
+ * before final output. Return modified lines.
88
+ */
89
+ export type EffectRenderFn = (lines: string[], ctx: EffectRenderCtx) => string[];
90
+
91
+ /**
92
+ * Effect bundle: extra signals, timers, and render function.
93
+ * Packs use this to define mascot-specific animations.
94
+ */
95
+ export interface MascotEffects {
96
+ /** Extra signals (name → initial value) */
97
+ signals?: SignalDef[];
98
+ /** Recurring timers that drive signal changes */
99
+ timers?: EffectTimer[];
100
+ /** Render hook — modify lines based on signals */
101
+ render: EffectRenderFn;
74
102
  }
75
103
 
76
104
  /**
77
105
  * A complete mascot pack definition.
78
106
  *
79
- * Each pack provides ASCII-art frames for different expressions,
80
- * color zones, animation timing, sidebar text, and optional hooks.
107
+ * Frames are plain string arrays each string is one line, all lines same width.
108
+ * "default" is required; all other expressions are optional.
81
109
  */
82
110
  export interface MascotPack {
83
- /** Unique machine-readable identifier (e.g. "tuxie") */
84
111
  name: string;
85
- /** Human-readable display name (e.g. "Tuxie the Penguin") */
86
112
  displayName: string;
87
- /** Semantic version string (e.g. "1.0.0") */
88
113
  version: string;
89
- /** Pack author or team */
90
114
  author: string;
91
- /** Short description of the mascot */
92
115
  description: string;
93
116
 
94
- /**
95
- * Expression frames: expression name → array of ASCII-art lines.
96
- * Each entry is a single frame (one multi-line drawing).
97
- * "default" is required; all other expressions are optional.
98
- */
117
+ /** ASCII-art frames keyed by expression name. "default" is required. */
99
118
  frames: {
100
119
  default: string[];
101
120
  blink?: string[];
@@ -105,37 +124,31 @@ export interface MascotPack {
105
124
  sleeping?: string[];
106
125
  };
107
126
 
108
- /**
109
- * Color mapping for the mascot frames.
110
- * Zones are matched by substring or named markers in the frame lines.
111
- */
112
- colors: {
113
- /** Named zone → foreground/background color */
114
- zones?: Record<string, ZoneColor>;
115
- /** Default foreground applied to unmatched characters */
127
+ /** Optional color for the mascot text */
128
+ colors?: {
116
129
  defaultFg?: string;
117
- /** Default background applied to unmatched characters */
118
- defaultBg?: string;
119
130
  };
120
131
 
121
- /** Animation timing overrides */
122
132
  animations?: AnimationConfig;
123
-
124
- /** Sidebar content configuration */
125
133
  sidebar?: SidebarConfig;
126
134
 
127
- /** Optional lifecycle hooks */
128
- hooks?: MascotHooks;
135
+ /** Mascot-specific animation effects (timers + render) */
136
+ effects?: MascotEffects;
137
+
138
+ /** Thinking bubble phrases, shown when mascot is in busy/thinking state */
139
+ bubbleTexts?: string[];
140
+ }
141
+
142
+ export interface SwitchConfig {
143
+ onState?: Partial<Record<MascotState, string>>;
129
144
  }
130
145
 
131
146
  /**
132
147
  * User-facing configuration merged from opencode config file.
133
148
  */
134
149
  export interface MascotConfig {
135
- /** Name of the selected mascot pack */
136
150
  mascot: string;
137
- /** Enable or disable mascot animations */
138
151
  animations: boolean;
139
- /** Allow mascot to enter sleep state on idle */
140
152
  idleSleep: boolean;
153
+ switchConfig?: SwitchConfig;
141
154
  }
package/tui.tsx CHANGED
@@ -1,15 +1,25 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
- import { loadMascot } from "./src/core/mascot-loader"
3
+ import { loadAllMascots, getRandomMascot } from "./src/core/mascot-loader"
4
4
  import { SidebarMascot } from "./src/components/sidebar-mascot"
5
+ import { createAnimatedRenderer } from "./src/core/ascii-renderer"
6
+
7
+ const tui: TuiPlugin = async (api, _options) => {
8
+ const mascots = loadAllMascots()
9
+ const homeMascot = getRandomMascot()
10
+ const homeRenderer = createAnimatedRenderer(homeMascot)
5
11
 
6
- const tui: TuiPlugin = async (api, options) => {
7
- const mascot = await loadMascot(options)
8
-
9
12
  api.slots.register({
10
13
  slots: {
11
14
  sidebar_content() {
12
- return <SidebarMascot mascot={mascot} />
15
+ return <SidebarMascot mascots={mascots} api={api} />
16
+ },
17
+ home_bottom() {
18
+ return (
19
+ <box flexDirection="column" alignItems="center">
20
+ {homeRenderer.element()}
21
+ </box>
22
+ )
13
23
  }
14
24
  }
15
25
  })
@@ -1,34 +0,0 @@
1
- /**
2
- * 月儿 (Yue'er) — Color zone definitions.
3
- *
4
- * Zones are matched by the ASCII characters in frame lines.
5
- * Each zone maps to a foreground (fg) and optional background (bg) color.
6
- */
7
-
8
- export const colors = {
9
- zones: {
10
- /** Long flowing hair — silver white */
11
- hair: {
12
- fg: "#E8E0F0",
13
- },
14
- /** Imperial crown / headdress — gold */
15
- crown: {
16
- fg: "#FFD700",
17
- },
18
- /** Big anime eyes — ice blue */
19
- eyes: {
20
- fg: "#66CCFF",
21
- },
22
- /** Face and skin — warm peach */
23
- skin: {
24
- fg: "#FFE0BD",
25
- },
26
- /** Elegant robe / dress — ice blue */
27
- dress: {
28
- fg: "#7CB9E8",
29
- },
30
- },
31
-
32
- /** Default foreground for unmatched characters — cool silver */
33
- defaultFg: "#B8C4D8",
34
- };