@mingxy/opencode-mascot 0.1.2 → 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.2",
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,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: "#B8C4D8",
193
+ defaultFg: "#8B7EB8",
22
194
  },
23
195
 
24
196
  animations: {
25
197
  blinkInterval: 2500,
26
198
  blinkChance: 0.3,
27
- expressionInterval: 50000,
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
  };
@@ -1,51 +1,161 @@
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";
6
7
 
7
8
  interface SidebarMascotProps {
8
- mascot: MascotPack;
9
+ mascots: Record<string, MascotPack>;
10
+ switchConfig?: SwitchConfig;
11
+ initialMascot?: string;
9
12
  api: {
10
13
  event: {
11
14
  on(event: string, callback: (data: unknown) => void): void;
12
15
  };
13
- slots: {
14
- register(registration: { slots: Record<string, () => JSX.Element> }): void;
16
+ renderer: {
17
+ clearSelection(): void;
15
18
  };
16
19
  };
17
20
  }
18
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
+
19
30
  export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
20
- 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;
46
+
47
+ const renderers: Record<string, ReturnType<typeof createAnimatedRenderer>> = {};
48
+ for (const [name, pack] of Object.entries(props.mascots)) {
49
+ renderers[name] = createAnimatedRenderer(pack);
50
+ }
51
+
52
+ const switchTo = (name: string) => {
53
+ if (props.mascots[name] && name !== currentName()) {
54
+ setCurrentName(name);
55
+ }
56
+ };
57
+
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
+ };
21
69
 
22
70
  props.api.event.on("session.status", (data: unknown) => {
23
- const event = data as { status?: { type?: string } } | null;
24
- const statusType = event?.status?.type;
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;
25
74
 
26
75
  if (statusType === "busy" || statusType === "retry") {
27
- setState("busy");
76
+ renderers[currentName()].setState("busy");
28
77
  } else {
29
- setState("idle");
78
+ setStateWithSwitch("idle");
30
79
  }
31
80
  });
32
81
 
33
82
  props.api.event.on("session.idle", () => {
34
- setState("happy");
35
- setTimeout(() => setState("idle"), 3000);
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();
36
101
  });
37
102
 
38
103
  return (
39
104
  <box
40
105
  position="absolute"
41
- left={0}
42
- right={0}
43
- top={2}
106
+ left={posX()}
107
+ top={posY()}
44
108
  alignItems="center"
45
109
  zIndex={100}
46
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
+
47
157
  >
48
- {element()}
158
+ {renderers[currentName()]?.element() ?? null}
49
159
  </box>
50
160
  );
51
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",
@@ -18,11 +18,11 @@ const DEFAULT_ANIM = {
18
18
  expressionInterval: 8000,
19
19
  idleTimeout: 120000,
20
20
  breathInterval: 3000,
21
- ahogeInterval: 1500,
22
- ahogeChance: 0.25,
21
+ walkEnabled: true,
22
+ walkMinDelay: 20000,
23
+ walkMaxDelay: 40000,
23
24
  jumpMinDelay: 20000,
24
25
  jumpMaxDelay: 40000,
25
- stompThreshold: 30000,
26
26
  };
27
27
 
28
28
  const WALK_PATH = [1, 2, 3, 4, 3, 2, 1, 0, -1, -2, -3, -2, -1, 0];
@@ -45,35 +45,70 @@ function renderLines(lines: string[], fg?: string): JSX.Element {
45
45
  export function createAnimatedRenderer(pack: MascotPack): {
46
46
  element: () => JSX.Element;
47
47
  setState: (s: MascotState) => void;
48
+ toggleWalk: () => void;
49
+ setDragging: (v: boolean) => void;
48
50
  } {
49
51
  const anim = { ...DEFAULT_ANIM, ...pack.animations };
50
52
  const fg = pack.colors?.defaultFg || undefined;
53
+ const effects = pack.effects;
51
54
 
52
55
  const [currentState, setCurrentState] = createSignal<MascotState>("idle");
53
56
  const [frameOverride, setFrameOverride] = createSignal<string | null>(null);
54
- const [legsVisible, setLegsVisible] = createSignal(true);
57
+ const [breathPhase, setBreathPhase] = createSignal(true);
55
58
  const [walkOffset, setWalkOffset] = createSignal(0);
56
- const [ahogeAlt, setAhogeAlt] = createSignal(false);
57
- const [braidAlt, setBraidAlt] = createSignal(false);
58
- const [waveSide, setWaveSide] = createSignal(false);
59
- const [zzzPhase, setZzzPhase] = createSignal(0);
60
59
  const [jumpOffset, setJumpOffset] = createSignal(0);
61
- const [stompActive, setStompActive] = createSignal(false);
62
- const [stompAlt, setStompAlt] = createSignal(false);
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
+ }
63
87
 
64
- let thinkingStartTime = 0;
88
+ const getExtra = (name: string): unknown => extraSignals.get(name)?.[0]() ?? null;
89
+ const setExtra = (name: string, value: unknown) => extraSignals.get(name)?.[1](value);
65
90
 
66
- // ============ 1. Blink ============
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
67
102
  const hasBlink = (pack.frames as Record<string, string[] | undefined>)["blink"] !== undefined;
68
103
 
69
104
  const blinkTimer = setInterval(() => {
70
- if (Math.random() < anim.blinkChance && hasBlink) {
105
+ if (currentState() !== "sleeping" && Math.random() < anim.blinkChance && hasBlink) {
71
106
  setFrameOverride("blink");
72
107
  setTimeout(() => setFrameOverride(null), 150);
73
108
  }
74
109
  }, anim.blinkInterval);
75
110
 
76
- // ============ 2. Random expression ============
111
+ // 2. Random expression
77
112
  const availableExpressions = Object.keys(pack.frames).filter(
78
113
  (k) => k !== "default" && k !== "blink",
79
114
  );
@@ -83,37 +118,30 @@ export function createAnimatedRenderer(pack: MascotPack): {
83
118
  const pick = availableExpressions[Math.floor(Math.random() * availableExpressions.length)];
84
119
  if (pick) {
85
120
  setFrameOverride(pick);
86
- if (pick === "happy") startWave();
87
- setTimeout(() => {
88
- setFrameOverride(null);
89
- stopWave();
90
- }, 2000);
121
+ setTimeout(() => setFrameOverride(null), 2000);
91
122
  }
92
123
  }
93
124
  }, anim.expressionInterval);
94
125
 
95
- // ============ 3. Breathing (legs tuck + braids flutter) ============
126
+ // 3. Breathing
96
127
  const breathTimer = setInterval(() => {
97
- setLegsVisible((v) => !v);
98
- setBraidAlt((v) => !v);
99
- }, anim.breathInterval);
100
-
101
- // ============ 4. Ahoge wobble (呆毛晃) ============
102
- const ahogeTimer = setInterval(() => {
103
- if (Math.random() < anim.ahogeChance) {
104
- setAhogeAlt(true);
105
- setTimeout(() => setAhogeAlt(false), 200);
128
+ if (currentState() === "idle") {
129
+ setBreathPhase((v) => !v);
106
130
  }
107
- }, anim.ahogeInterval);
131
+ }, anim.breathInterval);
108
132
 
109
- // ============ 5. Walk ============
133
+ // 4. Walk
110
134
  let walkStep = -1;
111
135
  let walkInterval: ReturnType<typeof setInterval> | null = null;
112
136
 
113
137
  const startWalk = () => {
114
- if (walkInterval) return;
138
+ if (walkInterval || !walkEnabled()) return;
115
139
  walkStep = 0;
116
140
  walkInterval = setInterval(() => {
141
+ if (currentState() !== "idle") {
142
+ stopWalk();
143
+ return;
144
+ }
117
145
  if (walkStep < WALK_PATH.length) {
118
146
  setWalkOffset(WALK_PATH[walkStep]);
119
147
  walkStep++;
@@ -138,18 +166,21 @@ export function createAnimatedRenderer(pack: MascotPack): {
138
166
  let walkTimeout: ReturnType<typeof setTimeout>;
139
167
 
140
168
  function scheduleNextWalk() {
141
- const delay = 5000 + Math.floor(Math.random() * 5000);
169
+ if (!walkEnabled()) return setTimeout(() => {}, 60000);
170
+ const delay = anim.walkMinDelay + Math.floor(Math.random() * (anim.walkMaxDelay - anim.walkMinDelay));
142
171
  return setTimeout(() => {
143
- if (currentState() === "idle" && !frameOverride() && walkStep === -1) {
172
+ if (currentState() === "idle" && !frameOverride() && walkStep === -1 && walkEnabled()) {
144
173
  startWalk();
145
174
  }
146
- walkTimeout = scheduleNextWalk();
175
+ if (currentState() !== "sleeping") {
176
+ walkTimeout = scheduleNextWalk();
177
+ }
147
178
  }, delay);
148
179
  }
149
180
 
150
181
  walkTimeout = scheduleNextWalk();
151
182
 
152
- // ============ 6. Jump ============
183
+ // 5. Jump
153
184
  let jumpTimeout: ReturnType<typeof setTimeout>;
154
185
 
155
186
  function scheduleNextJump() {
@@ -157,77 +188,49 @@ export function createAnimatedRenderer(pack: MascotPack): {
157
188
  return setTimeout(() => {
158
189
  if (currentState() === "idle" && !frameOverride() && walkStep === -1) {
159
190
  setJumpOffset(-2);
160
- setTimeout(() => setJumpOffset(0), 500);
191
+ setTimeout(() => setJumpOffset(-1), 1500);
192
+ setTimeout(() => setJumpOffset(0), 2000);
193
+ }
194
+ if (currentState() !== "sleeping") {
195
+ jumpTimeout = scheduleNextJump();
161
196
  }
162
- jumpTimeout = scheduleNextJump();
163
197
  }, delay);
164
198
  }
165
199
 
166
200
  jumpTimeout = scheduleNextJump();
167
201
 
168
- // ============ 7. Wave (挥手 — happy state) ============
169
- let waveTimer: ReturnType<typeof setInterval> | null = null;
170
-
171
- const startWave = () => {
172
- if (waveTimer) return;
173
- waveTimer = setInterval(() => setWaveSide((v) => !v), 300);
174
- };
175
-
176
- const stopWave = () => {
177
- if (waveTimer) {
178
- clearInterval(waveTimer);
179
- waveTimer = null;
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));
180
207
  }
181
- setWaveSide(false);
182
- };
183
-
184
- // ============ 8. Zzz bubbles (sleeping state) ============
185
- const zzzTimer = setInterval(() => {
186
- if (currentState() === "sleeping") {
187
- setZzzPhase((p) => (p + 1) % 4);
188
- } else {
189
- setZzzPhase(0);
190
- }
191
- }, 1500);
192
-
193
- // ============ 9. Stomp (跺脚 — thinking > threshold) ============
194
- const stompTimer = setInterval(() => {
195
- if (
196
- currentState() === "thinking" &&
197
- thinkingStartTime > 0 &&
198
- Date.now() - thinkingStartTime > anim.stompThreshold
199
- ) {
200
- setStompActive(true);
201
- setStompAlt((a) => !a);
202
- } else {
203
- setStompActive(false);
204
- }
205
- }, 200);
208
+ }
206
209
 
207
- // ============ Cleanup ============
210
+ // ─── Cleanup ───
208
211
  onCleanup(() => {
209
212
  clearInterval(blinkTimer);
210
213
  clearInterval(expressionTimer);
211
214
  clearInterval(breathTimer);
212
- clearInterval(ahogeTimer);
213
- clearInterval(zzzTimer);
214
- clearInterval(stompTimer);
215
215
  clearTimeout(walkTimeout);
216
216
  clearTimeout(jumpTimeout);
217
+ if (idleSleepTimeout) clearTimeout(idleSleepTimeout);
217
218
  if (walkInterval) clearInterval(walkInterval);
218
- stopWave();
219
+ for (const t of effectTimers) clearInterval(t);
219
220
  });
220
221
 
221
- // ============ Render ============
222
+ // ─── Render ───
222
223
  const element = () => {
223
- legsVisible();
224
- ahogeAlt();
225
- braidAlt();
226
- waveSide();
227
- zzzPhase();
224
+ breathPhase();
225
+ walkOffset();
228
226
  jumpOffset();
229
- stompActive();
230
- stompAlt();
227
+ frameOverride();
228
+ currentState();
229
+ dragging();
230
+
231
+ for (const [, [get]] of extraSignals) {
232
+ get();
233
+ }
231
234
 
232
235
  const frameName = frameOverride() ?? STATE_TO_FRAME[currentState()] ?? "default";
233
236
  const rawLines = getFrameLines(pack, frameName);
@@ -236,51 +239,24 @@ export function createAnimatedRenderer(pack: MascotPack): {
236
239
  const width = rawLines[0]?.length ?? 10;
237
240
  const blank = " ".repeat(width);
238
241
 
239
- let lines = rawLines.map((line, i) => {
240
- if (!legsVisible()) {
242
+ let lines: string[] = rawLines.map((line, i) => {
243
+ if (!breathPhase()) {
241
244
  if (i === 0) return blank;
242
245
  return rawLines[i - 1];
243
246
  }
244
247
  return line;
245
248
  });
246
249
 
247
- // 2. Ahoge wobble: ☆ ↔ ★
248
- if (ahogeAlt()) {
249
- lines = lines.map((l) => (l.includes("☆") ? l.replace("☆", "★") : l));
250
- }
251
-
252
- // 3. Braid flutter: ~ ↔ -
253
- if (braidAlt()) {
254
- lines = lines.map((l) => (l.includes("~") ? l.replace(/~/g, "-") : l));
255
- }
256
-
257
- // 4. Wave: braids become hands ╲ / ╱ on face row
258
- if (frameName === "happy") {
259
- const faceIdx = lines.findIndex((l) => l.includes("^ω^"));
260
- if (faceIdx >= 0) {
261
- lines[faceIdx] = waveSide()
262
- ? "╲( ^ω^ )╱ "
263
- : " ~( ^ω^ )~";
264
- }
265
- }
266
-
267
- // 5. Stomp: legs alternate ╲ ╱ ↔ ╱ ╲
268
- if (stompActive()) {
269
- const legIdx = lines.length - 1;
270
- if (legIdx >= 0) {
271
- lines[legIdx] = stompAlt() ? " ╲ ╱ " : " ╱ ╲ ";
272
- }
273
- }
274
-
275
- // 6. Zzz: floating Z bubbles beside face
276
- const zzz = zzzPhase();
277
- if (zzz > 0 && currentState() === "sleeping") {
278
- for (let i = 0; i < lines.length; i++) {
279
- if (lines[i].includes("-.-")) {
280
- lines[i] = lines[i] + " " + "Z".repeat(zzz);
281
- break;
282
- }
283
- }
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);
284
260
  }
285
261
 
286
262
  const top = jumpOffset();
@@ -293,28 +269,38 @@ export function createAnimatedRenderer(pack: MascotPack): {
293
269
  );
294
270
  };
295
271
 
296
- // ============ State control ============
272
+ // ─── State control ───
297
273
  const setState = (s: MascotState) => {
298
- const prev = currentState();
299
274
  setCurrentState(s);
275
+ setBreathPhase(true);
276
+ resetIdleSleep();
300
277
 
301
278
  if (s !== "idle") {
302
279
  stopWalk();
280
+ } else if (walkEnabled()) {
281
+ walkTimeout = scheduleNextWalk();
282
+ jumpTimeout = scheduleNextJump();
303
283
  }
284
+ };
304
285
 
305
- if (s === "thinking" && prev !== "thinking") {
306
- thinkingStartTime = Date.now();
307
- } else if (s !== "thinking") {
308
- thinkingStartTime = 0;
309
- setStompActive(false);
286
+ const toggleWalk = () => {
287
+ const next = !walkEnabled();
288
+ setWalkEnabled(next);
289
+ if (!next) {
290
+ stopWalk();
291
+ } else if (currentState() === "idle") {
292
+ walkTimeout = scheduleNextWalk();
310
293
  }
294
+ };
311
295
 
312
- if (s === "happy") {
313
- startWave();
296
+ const setDragging = (v: boolean) => {
297
+ setDraggingSignal(v);
298
+ if (v) {
299
+ setJumpOffset(-1);
314
300
  } else {
315
- stopWave();
301
+ setJumpOffset(0);
316
302
  }
317
303
  };
318
304
 
319
- return { element, setState };
305
+ return { element, setState, toggleWalk, setDragging };
320
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
@@ -17,18 +17,12 @@ export interface AnimationConfig {
17
17
  blinkChance?: number;
18
18
  expressionInterval?: number;
19
19
  idleTimeout?: number;
20
- /** Breathing cycle in ms (default: 3000) */
21
20
  breathInterval?: number;
22
- /** Ahoge wobble check interval in ms (default: 1500) */
23
- ahogeInterval?: number;
24
- /** Probability of ahoge wobble per check (default: 0.25) */
25
- ahogeChance?: number;
26
- /** Minimum delay between jumps in ms (default: 20000) */
21
+ walkEnabled?: boolean;
22
+ walkMinDelay?: number;
23
+ walkMaxDelay?: number;
27
24
  jumpMinDelay?: number;
28
- /** Maximum delay between jumps in ms (default: 40000) */
29
25
  jumpMaxDelay?: number;
30
- /** Stomp trigger: ms in thinking state before stomping (default: 30000) */
31
- stompThreshold?: number;
32
26
  }
33
27
 
34
28
  /**
@@ -39,26 +33,72 @@ export interface SidebarConfig {
39
33
  busyPhrases?: string[];
40
34
  }
41
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;
45
+ }
46
+
42
47
  /**
43
- * Context passed to render hooks.
48
+ * Timer context passed to EffectTimer.update().
44
49
  */
45
- 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;
55
+ /** Current mascot state */
46
56
  state: MascotState;
47
- frameIndex: number;
48
- timestamp: number;
49
- sessionData?: {
50
- tokenUsage?: number;
51
- tokenLimit?: number;
52
- model?: string;
53
- };
57
+ /** Current frame override (null = use state mapping) */
58
+ frameOverride: string | null;
59
+ /** Override the displayed frame */
60
+ setFrameOverride: (name: string | null) => void;
61
+ }
62
+
63
+ /**
64
+ * A recurring timer that updates extra signals.
65
+ */
66
+ export interface EffectTimer {
67
+ /** Interval in ms */
68
+ interval: number;
69
+ /** Called on each tick */
70
+ update: (ctx: EffectTimerCtx) => void;
54
71
  }
55
72
 
56
73
  /**
57
- * Lifecycle hooks for customizing mascot behavior.
74
+ * Context passed to EffectRenderFn.
58
75
  */
59
- export interface MascotHooks {
60
- beforeRender?: (context: RenderContext, frame: string[]) => string[];
61
- onStateChange?: (prevState: MascotState, newState: MascotState) => void;
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;
62
102
  }
63
103
 
64
104
  /**
@@ -91,7 +131,16 @@ export interface MascotPack {
91
131
 
92
132
  animations?: AnimationConfig;
93
133
  sidebar?: SidebarConfig;
94
- hooks?: MascotHooks;
134
+
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>>;
95
144
  }
96
145
 
97
146
  /**
@@ -101,4 +150,5 @@ export interface MascotConfig {
101
150
  mascot: string;
102
151
  animations: boolean;
103
152
  idleSleep: boolean;
153
+ switchConfig?: SwitchConfig;
104
154
  }
package/tui.tsx CHANGED
@@ -1,17 +1,18 @@
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
5
  import { createAnimatedRenderer } from "./src/core/ascii-renderer"
6
6
 
7
- const tui: TuiPlugin = async (api, options) => {
8
- const mascot = await loadMascot(options)
9
- const homeRenderer = createAnimatedRenderer(mascot)
7
+ const tui: TuiPlugin = async (api, _options) => {
8
+ const mascots = loadAllMascots()
9
+ const homeMascot = getRandomMascot()
10
+ const homeRenderer = createAnimatedRenderer(homeMascot)
10
11
 
11
12
  api.slots.register({
12
13
  slots: {
13
14
  sidebar_content() {
14
- return <SidebarMascot mascot={mascot} api={api} />
15
+ return <SidebarMascot mascots={mascots} api={api} />
15
16
  },
16
17
  home_bottom() {
17
18
  return (