@mingxy/opencode-mascot 0.1.0 → 0.1.2

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.0",
3
+ "version": "0.1.2",
4
4
  "description": "OpenCode TUI mascot plugin framework - customizable ASCII mascots for your terminal",
5
5
  "author": "mingxy",
6
6
  "license": "MIT",
@@ -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,24 +1,25 @@
1
1
  /**
2
2
  * 月儿 (Yue'er) — Ice Empress mascot pack.
3
3
  *
4
- * Nine Heavens' Empress with silver-white hair, ice-blue eyes, and imperial crown.
4
+ * Nine Heavens' Empress with ice-blue eyes and silver-white hair.
5
5
  * She awaits her Master's command with grace and quiet devotion.
6
6
  */
7
7
 
8
8
  import type { MascotPack } from "../../core/types";
9
9
  import { frames } from "./frames";
10
- import { colors } from "./colors";
11
10
 
12
11
  export const yueerPack: MascotPack = {
13
12
  name: "@mingxy/mascot-yueer",
14
13
  displayName: "月儿",
15
14
  version: "0.1.0",
16
15
  author: "mingxy",
17
- description:
18
- "Ice Empress of the Nine Heavens — elegant, powerful, and devoted to her Master.",
16
+ description: "Ice Empress of the Nine Heavens — elegant, powerful, and devoted to her Master.",
19
17
 
20
18
  frames,
21
- colors,
19
+
20
+ colors: {
21
+ defaultFg: "#B8C4D8",
22
+ },
22
23
 
23
24
  animations: {
24
25
  blinkInterval: 2500,
@@ -28,14 +29,7 @@ export const yueerPack: MascotPack = {
28
29
  },
29
30
 
30
31
  sidebar: {
31
- greetings: [
32
- "师尊,月儿在此候命~",
33
- "月儿恭候师尊多时了~",
34
- "师尊今日气色不错呢~",
35
- ],
36
- busyPhrases: [
37
- "月儿正在铸造法器...",
38
- "驱除心魔中...",
39
- ],
32
+ greetings: ["师尊,月儿在此候命~"],
33
+ busyPhrases: ["铸造法器中..."],
40
34
  },
41
35
  };
@@ -1,41 +1,51 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
 
3
- import { createSignal, createEffect } from "solid-js";
4
3
  import type { JSX } from "@opentui/solid";
5
- import type { MascotPack, MascotState } from "../core/types";
4
+ import type { MascotPack } from "../core/types";
6
5
  import { createAnimatedRenderer } from "../core/ascii-renderer";
7
6
 
8
7
  interface SidebarMascotProps {
9
8
  mascot: MascotPack;
10
- isRunning?: boolean;
9
+ api: {
10
+ event: {
11
+ on(event: string, callback: (data: unknown) => void): void;
12
+ };
13
+ slots: {
14
+ register(registration: { slots: Record<string, () => JSX.Element> }): void;
15
+ };
16
+ };
11
17
  }
12
18
 
13
19
  export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
14
20
  const { element, setState } = createAnimatedRenderer(props.mascot);
15
21
 
16
- const greetings = props.mascot.sidebar?.greetings ?? ["~"];
17
- const busyPhrases = props.mascot.sidebar?.busyPhrases ?? ["Working..."];
22
+ props.api.event.on("session.status", (data: unknown) => {
23
+ const event = data as { status?: { type?: string } } | null;
24
+ const statusType = event?.status?.type;
18
25
 
19
- const initialGreeting = greetings[Math.floor(Math.random() * greetings.length)];
20
- const [stateText, setStateText] = createSignal<string>(initialGreeting);
21
-
22
- createEffect(() => {
23
- if (props.isRunning) {
24
- setState("busy" as MascotState);
25
- const phrase = busyPhrases[Math.floor(Math.random() * busyPhrases.length)];
26
- setStateText(phrase);
26
+ if (statusType === "busy" || statusType === "retry") {
27
+ setState("busy");
27
28
  } else {
28
29
  setState("idle");
29
- setStateText(greetings[Math.floor(Math.random() * greetings.length)]);
30
30
  }
31
31
  });
32
32
 
33
+ props.api.event.on("session.idle", () => {
34
+ setState("happy");
35
+ setTimeout(() => setState("idle"), 3000);
36
+ });
37
+
33
38
  return (
34
- <box flexDirection="column" paddingTop={1} paddingBottom={1} paddingLeft={1}>
39
+ <box
40
+ position="absolute"
41
+ left={0}
42
+ right={0}
43
+ top={2}
44
+ alignItems="center"
45
+ zIndex={100}
46
+ flexDirection="column"
47
+ >
35
48
  {element()}
36
- <text fg="gray" wrapMode="word">
37
- {stateText()}
38
- </text>
39
49
  </box>
40
50
  );
41
51
  }
@@ -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
+ ahogeInterval: 1500,
22
+ ahogeChance: 0.25,
23
+ jumpMinDelay: 20000,
24
+ jumpMaxDelay: 40000,
25
+ stompThreshold: 30000,
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) => (
@@ -43,37 +47,274 @@ export function createAnimatedRenderer(pack: MascotPack): {
43
47
  setState: (s: MascotState) => void;
44
48
  } {
45
49
  const anim = { ...DEFAULT_ANIM, ...pack.animations };
50
+ const fg = pack.colors?.defaultFg || undefined;
51
+
46
52
  const [currentState, setCurrentState] = createSignal<MascotState>("idle");
47
53
  const [frameOverride, setFrameOverride] = createSignal<string | null>(null);
54
+ const [legsVisible, setLegsVisible] = createSignal(true);
55
+ 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
+ const [jumpOffset, setJumpOffset] = createSignal(0);
61
+ const [stompActive, setStompActive] = createSignal(false);
62
+ const [stompAlt, setStompAlt] = createSignal(false);
63
+
64
+ let thinkingStartTime = 0;
65
+
66
+ // ============ 1. Blink ============
67
+ const hasBlink = (pack.frames as Record<string, string[] | undefined>)["blink"] !== undefined;
48
68
 
49
69
  const blinkTimer = setInterval(() => {
50
- if (Math.random() < anim.blinkChance) {
70
+ if (Math.random() < anim.blinkChance && hasBlink) {
51
71
  setFrameOverride("blink");
52
72
  setTimeout(() => setFrameOverride(null), 150);
53
73
  }
54
74
  }, anim.blinkInterval);
55
75
 
56
- const availableFrames = Object.keys(pack.frames).filter(
57
- (k) => k !== "default" && k !== "blink"
76
+ // ============ 2. Random expression ============
77
+ const availableExpressions = Object.keys(pack.frames).filter(
78
+ (k) => k !== "default" && k !== "blink",
58
79
  );
80
+
59
81
  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);
82
+ if (currentState() === "idle" && !frameOverride()) {
83
+ const pick = availableExpressions[Math.floor(Math.random() * availableExpressions.length)];
84
+ if (pick) {
85
+ setFrameOverride(pick);
86
+ if (pick === "happy") startWave();
87
+ setTimeout(() => {
88
+ setFrameOverride(null);
89
+ stopWave();
90
+ }, 2000);
91
+ }
64
92
  }
65
93
  }, anim.expressionInterval);
66
94
 
95
+ // ============ 3. Breathing (legs tuck + braids flutter) ============
96
+ 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);
106
+ }
107
+ }, anim.ahogeInterval);
108
+
109
+ // ============ 5. Walk ============
110
+ let walkStep = -1;
111
+ let walkInterval: ReturnType<typeof setInterval> | null = null;
112
+
113
+ const startWalk = () => {
114
+ if (walkInterval) return;
115
+ walkStep = 0;
116
+ walkInterval = setInterval(() => {
117
+ if (walkStep < WALK_PATH.length) {
118
+ setWalkOffset(WALK_PATH[walkStep]);
119
+ walkStep++;
120
+ } else {
121
+ if (walkInterval) clearInterval(walkInterval);
122
+ walkInterval = null;
123
+ walkStep = -1;
124
+ setWalkOffset(0);
125
+ }
126
+ }, 400);
127
+ };
128
+
129
+ const stopWalk = () => {
130
+ if (walkInterval) {
131
+ clearInterval(walkInterval);
132
+ walkInterval = null;
133
+ }
134
+ walkStep = -1;
135
+ setWalkOffset(0);
136
+ };
137
+
138
+ let walkTimeout: ReturnType<typeof setTimeout>;
139
+
140
+ function scheduleNextWalk() {
141
+ const delay = 5000 + Math.floor(Math.random() * 5000);
142
+ return setTimeout(() => {
143
+ if (currentState() === "idle" && !frameOverride() && walkStep === -1) {
144
+ startWalk();
145
+ }
146
+ walkTimeout = scheduleNextWalk();
147
+ }, delay);
148
+ }
149
+
150
+ walkTimeout = scheduleNextWalk();
151
+
152
+ // ============ 6. Jump ============
153
+ let jumpTimeout: ReturnType<typeof setTimeout>;
154
+
155
+ function scheduleNextJump() {
156
+ const delay = anim.jumpMinDelay + Math.floor(Math.random() * (anim.jumpMaxDelay - anim.jumpMinDelay));
157
+ return setTimeout(() => {
158
+ if (currentState() === "idle" && !frameOverride() && walkStep === -1) {
159
+ setJumpOffset(-2);
160
+ setTimeout(() => setJumpOffset(0), 500);
161
+ }
162
+ jumpTimeout = scheduleNextJump();
163
+ }, delay);
164
+ }
165
+
166
+ jumpTimeout = scheduleNextJump();
167
+
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;
180
+ }
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);
206
+
207
+ // ============ Cleanup ============
67
208
  onCleanup(() => {
68
209
  clearInterval(blinkTimer);
69
210
  clearInterval(expressionTimer);
211
+ clearInterval(breathTimer);
212
+ clearInterval(ahogeTimer);
213
+ clearInterval(zzzTimer);
214
+ clearInterval(stompTimer);
215
+ clearTimeout(walkTimeout);
216
+ clearTimeout(jumpTimeout);
217
+ if (walkInterval) clearInterval(walkInterval);
218
+ stopWave();
70
219
  });
71
220
 
221
+ // ============ Render ============
72
222
  const element = () => {
73
- const override = frameOverride();
74
- const state = override ? (override as MascotState) : currentState();
75
- return renderFrame(pack, state);
223
+ legsVisible();
224
+ ahogeAlt();
225
+ braidAlt();
226
+ waveSide();
227
+ zzzPhase();
228
+ jumpOffset();
229
+ stompActive();
230
+ stompAlt();
231
+
232
+ const frameName = frameOverride() ?? STATE_TO_FRAME[currentState()] ?? "default";
233
+ const rawLines = getFrameLines(pack, frameName);
234
+ const offset = walkOffset();
235
+
236
+ const width = rawLines[0]?.length ?? 10;
237
+ const blank = " ".repeat(width);
238
+
239
+ let lines = rawLines.map((line, i) => {
240
+ if (!legsVisible()) {
241
+ if (i === 0) return blank;
242
+ return rawLines[i - 1];
243
+ }
244
+ return line;
245
+ });
246
+
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
+ }
284
+ }
285
+
286
+ const top = jumpOffset();
287
+ const left = offset > 0 ? offset : 0;
288
+
289
+ return (
290
+ <box flexDirection="column" left={left} top={top}>
291
+ {renderLines(lines, fg)}
292
+ </box>
293
+ );
294
+ };
295
+
296
+ // ============ State control ============
297
+ const setState = (s: MascotState) => {
298
+ const prev = currentState();
299
+ setCurrentState(s);
300
+
301
+ if (s !== "idle") {
302
+ stopWalk();
303
+ }
304
+
305
+ if (s === "thinking" && prev !== "thinking") {
306
+ thinkingStartTime = Date.now();
307
+ } else if (s !== "thinking") {
308
+ thinkingStartTime = 0;
309
+ setStompActive(false);
310
+ }
311
+
312
+ if (s === "happy") {
313
+ startWave();
314
+ } else {
315
+ stopWave();
316
+ }
76
317
  };
77
318
 
78
- return { element, setState: setCurrentState };
319
+ return { element, setState };
79
320
  }
package/src/core/types.ts CHANGED
@@ -9,53 +9,43 @@ 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
+ /** Breathing cycle in ms (default: 3000) */
21
+ 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) */
27
+ jumpMinDelay?: number;
28
+ /** Maximum delay between jumps in ms (default: 40000) */
29
+ jumpMaxDelay?: number;
30
+ /** Stomp trigger: ms in thinking state before stomping (default: 30000) */
31
+ stompThreshold?: number;
34
32
  }
35
33
 
36
34
  /**
37
35
  * Sidebar display configuration.
38
36
  */
39
37
  export interface SidebarConfig {
40
- /** Rotating greeting messages shown below the mascot */
41
38
  greetings?: string[];
42
- /** Rotating phrases shown while in busy state */
43
39
  busyPhrases?: string[];
44
- /** Sidebar width in characters (default: mascot frame width) */
45
- width?: number;
46
40
  }
47
41
 
48
42
  /**
49
43
  * Context passed to render hooks.
50
44
  */
51
45
  export interface RenderContext {
52
- /** Current mascot state */
53
46
  state: MascotState;
54
- /** Current frame index within the active expression */
55
47
  frameIndex: number;
56
- /** Unix timestamp (ms) of this render call */
57
48
  timestamp: number;
58
- /** Optional session metadata from the host */
59
49
  sessionData?: {
60
50
  tokenUsage?: number;
61
51
  tokenLimit?: number;
@@ -67,35 +57,24 @@ export interface RenderContext {
67
57
  * Lifecycle hooks for customizing mascot behavior.
68
58
  */
69
59
  export interface MascotHooks {
70
- /** Called before each frame render. Return modified frame lines or the original. */
71
60
  beforeRender?: (context: RenderContext, frame: string[]) => string[];
72
- /** Called when the mascot state transitions. */
73
61
  onStateChange?: (prevState: MascotState, newState: MascotState) => void;
74
62
  }
75
63
 
76
64
  /**
77
65
  * A complete mascot pack definition.
78
66
  *
79
- * Each pack provides ASCII-art frames for different expressions,
80
- * color zones, animation timing, sidebar text, and optional hooks.
67
+ * Frames are plain string arrays each string is one line, all lines same width.
68
+ * "default" is required; all other expressions are optional.
81
69
  */
82
70
  export interface MascotPack {
83
- /** Unique machine-readable identifier (e.g. "tuxie") */
84
71
  name: string;
85
- /** Human-readable display name (e.g. "Tuxie the Penguin") */
86
72
  displayName: string;
87
- /** Semantic version string (e.g. "1.0.0") */
88
73
  version: string;
89
- /** Pack author or team */
90
74
  author: string;
91
- /** Short description of the mascot */
92
75
  description: string;
93
76
 
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
- */
77
+ /** ASCII-art frames keyed by expression name. "default" is required. */
99
78
  frames: {
100
79
  default: string[];
101
80
  blink?: string[];
@@ -105,26 +84,13 @@ export interface MascotPack {
105
84
  sleeping?: string[];
106
85
  };
107
86
 
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 */
87
+ /** Optional color for the mascot text */
88
+ colors?: {
116
89
  defaultFg?: string;
117
- /** Default background applied to unmatched characters */
118
- defaultBg?: string;
119
90
  };
120
91
 
121
- /** Animation timing overrides */
122
92
  animations?: AnimationConfig;
123
-
124
- /** Sidebar content configuration */
125
93
  sidebar?: SidebarConfig;
126
-
127
- /** Optional lifecycle hooks */
128
94
  hooks?: MascotHooks;
129
95
  }
130
96
 
@@ -132,10 +98,7 @@ export interface MascotPack {
132
98
  * User-facing configuration merged from opencode config file.
133
99
  */
134
100
  export interface MascotConfig {
135
- /** Name of the selected mascot pack */
136
101
  mascot: string;
137
- /** Enable or disable mascot animations */
138
102
  animations: boolean;
139
- /** Allow mascot to enter sleep state on idle */
140
103
  idleSleep: boolean;
141
104
  }
package/tui.tsx CHANGED
@@ -2,14 +2,23 @@
2
2
  import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
3
  import { loadMascot } from "./src/core/mascot-loader"
4
4
  import { SidebarMascot } from "./src/components/sidebar-mascot"
5
+ import { createAnimatedRenderer } from "./src/core/ascii-renderer"
5
6
 
6
7
  const tui: TuiPlugin = async (api, options) => {
7
8
  const mascot = await loadMascot(options)
8
-
9
+ const homeRenderer = createAnimatedRenderer(mascot)
10
+
9
11
  api.slots.register({
10
12
  slots: {
11
13
  sidebar_content() {
12
- return <SidebarMascot mascot={mascot} />
14
+ return <SidebarMascot mascot={mascot} api={api} />
15
+ },
16
+ home_bottom() {
17
+ return (
18
+ <box flexDirection="column" alignItems="center">
19
+ {homeRenderer.element()}
20
+ </box>
21
+ )
13
22
  }
14
23
  }
15
24
  })
@@ -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
- };