@mingxy/opencode-mascot 0.2.4 → 0.2.6

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.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "OpenCode TUI mascot plugin framework - customizable ASCII mascots for your terminal",
5
5
  "author": "mingxy",
6
6
  "license": "MIT",
@@ -94,12 +94,5 @@ export const baoziPack: MascotPack = {
94
94
  idleTimeout: 120000,
95
95
  },
96
96
 
97
- sidebar: {
98
- greetings: ["热乎乎的包子出炉啦~"],
99
- busyPhrases: ["蒸包子中..."],
100
- },
101
-
102
- bubbleTexts: ["蒸着...", "发酵中...", "冒热气...", "快熟了..."],
103
-
104
97
  effects: baoziEffects,
105
98
  };
@@ -200,12 +200,5 @@ export const yueerPack: MascotPack = {
200
200
  idleTimeout: 90000,
201
201
  },
202
202
 
203
- sidebar: {
204
- greetings: ["师尊,月儿在此候命~"],
205
- busyPhrases: ["铸造法器中..."],
206
- },
207
-
208
- bubbleTexts: ["嗯...", "让我想想...", "等等...", "本帝在算..."],
209
-
210
203
  effects: yueerEffects,
211
204
  };
@@ -5,7 +5,6 @@ import type { JSX } from "@opentui/solid";
5
5
  import type { MascotPack } from "../core/types";
6
6
  import { createAnimatedRenderer } from "../core/ascii-renderer";
7
7
  import { onCelebrate } from "../core/celebration-bus";
8
- import { useDraggableMascot } from "./use-draggable-mascot";
9
8
 
10
9
  interface HomeMascotProps {
11
10
  mascots: Record<string, MascotPack>;
@@ -21,6 +20,14 @@ export function HomeMascot(props: HomeMascotProps): JSX.Element {
21
20
  const initialName = names[Math.floor(Math.random() * names.length)];
22
21
 
23
22
  const [currentName, setCurrentName] = createSignal(initialName);
23
+ const [posX, setPosX] = createSignal(0);
24
+ const [posY, setPosY] = createSignal(0);
25
+ let dragStartX = 0;
26
+ let dragStartY = 0;
27
+ let dragAnchorX = 0;
28
+ let dragAnchorY = 0;
29
+ let lastClickTime = 0;
30
+ let isDragging = false;
24
31
 
25
32
  const renderers: Record<string, ReturnType<typeof createAnimatedRenderer>> = {};
26
33
  for (const [name, pack] of Object.entries(props.mascots)) {
@@ -33,17 +40,6 @@ export function HomeMascot(props: HomeMascotProps): JSX.Element {
33
40
  setCurrentName(names[(idx + 1) % names.length]);
34
41
  };
35
42
 
36
- const { posX, posY, mouseProps } = useDraggableMascot({
37
- initialX: 0,
38
- initialY: 0,
39
- mascotWidth: 10,
40
- mascotHeight: 5,
41
- onSwitch: switchToNext,
42
- clearSelection: () => props.api.renderer.clearSelection(),
43
- setDragging: (v) => renderers[currentName()].setDragging(v),
44
- enableEdge: false,
45
- });
46
-
47
43
  onCelebrate((newVersion) => {
48
44
  renderers[currentName()].celebrateUpdate(newVersion);
49
45
  });
@@ -55,7 +51,48 @@ export function HomeMascot(props: HomeMascotProps): JSX.Element {
55
51
  alignItems="center"
56
52
  zIndex={100}
57
53
  flexDirection="column"
58
- {...mouseProps}
54
+ onMouseDown={(e: any) => {
55
+ const now = Date.now();
56
+ if (now - lastClickTime < 300) {
57
+ switchToNext();
58
+ lastClickTime = 0;
59
+ return;
60
+ }
61
+ lastClickTime = now;
62
+
63
+ if (e.modifiers?.alt) {
64
+ dragStartX = e.x;
65
+ dragStartY = e.y;
66
+ dragAnchorX = posX();
67
+ dragAnchorY = posY();
68
+ isDragging = true;
69
+ renderers[currentName()].setDragging(true);
70
+ e.preventDefault();
71
+ e.stopPropagation();
72
+ props.api.renderer.clearSelection();
73
+ }
74
+ }}
75
+ onMouseDrag={(e: any) => {
76
+ if (e.modifiers?.alt && isDragging) {
77
+ setPosX(dragAnchorX + (e.x - dragStartX));
78
+ setPosY(dragAnchorY + (e.y - dragStartY));
79
+ e.preventDefault();
80
+ e.stopPropagation();
81
+ props.api.renderer.clearSelection();
82
+ }
83
+ }}
84
+ onMouseUp={() => {
85
+ if (isDragging) {
86
+ isDragging = false;
87
+ renderers[currentName()].setDragging(false);
88
+ }
89
+ }}
90
+ onMouseDragEnd={() => {
91
+ if (isDragging) {
92
+ isDragging = false;
93
+ renderers[currentName()].setDragging(false);
94
+ }
95
+ }}
59
96
  >
60
97
  {renderers[currentName()]?.element() ?? null}
61
98
  </box>
@@ -1,15 +1,13 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
 
3
- import { createSignal } from "solid-js";
3
+ import { createSignal, onCleanup } from "solid-js";
4
4
  import type { JSX } from "@opentui/solid";
5
- import type { MascotPack, MascotState, SwitchConfig } from "../core/types";
5
+ import type { MascotPack, MascotState } from "../core/types";
6
6
  import { createAnimatedRenderer } from "../core/ascii-renderer";
7
7
  import { onCelebrate } from "../core/celebration-bus";
8
- import { useDraggableMascot } from "./use-draggable-mascot";
9
8
 
10
9
  interface SidebarMascotProps {
11
10
  mascots: Record<string, MascotPack>;
12
- switchConfig?: SwitchConfig;
13
11
  initialMascot?: string;
14
12
  api: {
15
13
  event: {
@@ -31,6 +29,12 @@ const DEFAULT_STATE_MAP: Partial<Record<MascotState, string>> = {
31
29
 
32
30
  const MASCOT_WIDTH = 10;
33
31
  const MASCOT_HEIGHT = 5;
32
+ const PEEK = 2;
33
+ const PEEK_INTERVAL = 1200;
34
+
35
+ function termWidth(): number {
36
+ return (typeof process !== "undefined" && process.stdout?.columns) || 80;
37
+ }
34
38
 
35
39
  export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
36
40
  const names = Object.keys(props.mascots);
@@ -40,41 +44,101 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
40
44
  : names[Math.floor(Math.random() * names.length)];
41
45
 
42
46
  const [currentName, setCurrentName] = createSignal(initialName);
47
+ const [posX, setPosX] = createSignal(20);
48
+ const [posY, setPosY] = createSignal(2);
49
+ let dragStartX = 0;
50
+ let dragStartY = 0;
51
+ let dragAnchorX = 0;
52
+ let dragAnchorY = 0;
53
+ let lastClickTime = 0;
54
+ let isDragging = false;
55
+ let hideSide: "left" | "right" | null = null;
56
+ let peekTimer: ReturnType<typeof setInterval> | null = null;
57
+ let returnTimer: ReturnType<typeof setInterval> | null = null;
43
58
 
44
59
  const renderers: Record<string, ReturnType<typeof createAnimatedRenderer>> = {};
45
60
  for (const [name, pack] of Object.entries(props.mascots)) {
46
61
  renderers[name] = createAnimatedRenderer(pack);
47
62
  }
48
63
 
64
+ const stopPeek = () => {
65
+ if (peekTimer) { clearInterval(peekTimer); peekTimer = null; }
66
+ };
67
+ const stopReturn = () => {
68
+ if (returnTimer) { clearInterval(returnTimer); returnTimer = null; }
69
+ };
70
+
71
+ onCleanup(() => { stopPeek(); stopReturn(); });
72
+
49
73
  const switchToNext = () => {
50
74
  const cur = currentName();
51
75
  const idx = names.indexOf(cur);
52
76
  setCurrentName(names[(idx + 1) % names.length]);
53
77
  };
54
78
 
55
- const { posX, posY, mouseProps, returnToView } = useDraggableMascot({
56
- initialX: 20,
57
- initialY: 2,
58
- mascotWidth: MASCOT_WIDTH,
59
- mascotHeight: MASCOT_HEIGHT,
60
- onSwitch: switchToNext,
61
- clearSelection: () => props.api.renderer.clearSelection(),
62
- setDragging: (v) => renderers[currentName()].setDragging(v),
63
- onReturnComplete: () => renderers[currentName()].bounce(),
64
- });
79
+ const startPeek = () => {
80
+ stopPeek();
81
+ let phase = false;
82
+ peekTimer = setInterval(() => {
83
+ phase = !phase;
84
+ const stretch = phase ? PEEK : 0;
85
+ const w = termWidth();
86
+ if (hideSide === "left") {
87
+ setPosX(-(MASCOT_WIDTH - PEEK) + stretch);
88
+ } else if (hideSide === "right") {
89
+ setPosX(w - PEEK - stretch);
90
+ }
91
+ }, PEEK_INTERVAL);
92
+ };
65
93
 
66
- const switchTo = (name: string) => {
67
- if (props.mascots[name] && name !== currentName()) {
68
- setCurrentName(name);
94
+ const returnToView = () => {
95
+ if (!hideSide) return;
96
+ stopPeek();
97
+ const w = termWidth();
98
+ const cur = posX();
99
+ const targetX = hideSide === "left" ? 0 : Math.max(0, w - MASCOT_WIDTH);
100
+ const step = targetX > cur ? 2 : -2;
101
+ returnTimer = setInterval(() => {
102
+ const now = posX();
103
+ if (Math.abs(now - targetX) <= 2) {
104
+ setPosX(targetX);
105
+ stopReturn();
106
+ hideSide = null;
107
+ renderers[currentName()].bounce();
108
+ return;
109
+ }
110
+ setPosX(now + step);
111
+ }, 16);
112
+ };
113
+
114
+ const checkEdge = () => {
115
+ const w = termWidth();
116
+ const x = posX();
117
+ if (x <= -(MASCOT_WIDTH - PEEK) + 1) {
118
+ hideSide = "left";
119
+ startPeek();
120
+ } else if (x >= w - PEEK - 1) {
121
+ hideSide = "right";
122
+ startPeek();
69
123
  }
70
124
  };
71
125
 
126
+ const clampX = (rawX: number): number => {
127
+ const w = termWidth();
128
+ const minX = -(MASCOT_WIDTH - PEEK);
129
+ const maxX = w - PEEK;
130
+ return Math.max(minX, Math.min(rawX, maxX));
131
+ };
132
+
133
+ const clampY = (rawY: number): number => {
134
+ const h = (typeof process !== "undefined" && process.stdout?.rows) || 24;
135
+ return Math.max(0, Math.min(rawY, h - MASCOT_HEIGHT));
136
+ };
137
+
72
138
  const setStateWithSwitch = (s: MascotState) => {
73
139
  const cur = currentName();
74
140
  renderers[cur].setState(s);
75
-
76
- const stateMap = props.switchConfig?.onState ?? DEFAULT_STATE_MAP;
77
- const target = stateMap[s];
141
+ const target = DEFAULT_STATE_MAP[s];
78
142
  if (target && target !== cur && props.mascots[target]) {
79
143
  setCurrentName(target);
80
144
  renderers[target].setState(s);
@@ -84,9 +148,8 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
84
148
  props.api.event.on("session.status", (data: unknown) => {
85
149
  const payload = data as { type?: string; properties?: { sessionID?: string; status?: { type?: string } } } | null;
86
150
  const statusType = payload?.properties?.status?.type;
87
-
88
151
  if (statusType === "busy" || statusType === "retry") {
89
- returnToView();
152
+ if (hideSide) returnToView();
90
153
  renderers[currentName()].setState("busy");
91
154
  } else {
92
155
  setStateWithSwitch("idle");
@@ -101,12 +164,10 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
101
164
  props.api.event.on("mascot.switch", (data: unknown) => {
102
165
  const target = data as { name?: string } | null;
103
166
  if (target?.name) {
104
- switchTo(target.name);
167
+ const name = target.name;
168
+ if (props.mascots[name] && name !== currentName()) setCurrentName(name);
105
169
  } else {
106
- const others = names.filter((n) => n !== currentName());
107
- if (others.length > 0) {
108
- switchTo(others[Math.floor(Math.random() * others.length)]);
109
- }
170
+ switchToNext();
110
171
  }
111
172
  });
112
173
 
@@ -126,7 +187,52 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
126
187
  alignItems="center"
127
188
  zIndex={100}
128
189
  flexDirection="column"
129
- {...mouseProps}
190
+ onMouseDown={(e: any) => {
191
+ if (hideSide) { returnToView(); return; }
192
+
193
+ const now = Date.now();
194
+ if (now - lastClickTime < 300) {
195
+ switchToNext();
196
+ lastClickTime = 0;
197
+ return;
198
+ }
199
+ lastClickTime = now;
200
+
201
+ if (e.modifiers?.alt) {
202
+ dragStartX = e.x;
203
+ dragStartY = e.y;
204
+ dragAnchorX = posX();
205
+ dragAnchorY = posY();
206
+ isDragging = true;
207
+ renderers[currentName()].setDragging(true);
208
+ e.preventDefault();
209
+ e.stopPropagation();
210
+ props.api.renderer.clearSelection();
211
+ }
212
+ }}
213
+ onMouseDrag={(e: any) => {
214
+ if (e.modifiers?.alt && isDragging) {
215
+ setPosX(clampX(dragAnchorX + (e.x - dragStartX)));
216
+ setPosY(clampY(dragAnchorY + (e.y - dragStartY)));
217
+ e.preventDefault();
218
+ e.stopPropagation();
219
+ props.api.renderer.clearSelection();
220
+ }
221
+ }}
222
+ onMouseUp={() => {
223
+ if (isDragging) {
224
+ isDragging = false;
225
+ renderers[currentName()].setDragging(false);
226
+ checkEdge();
227
+ }
228
+ }}
229
+ onMouseDragEnd={() => {
230
+ if (isDragging) {
231
+ isDragging = false;
232
+ renderers[currentName()].setDragging(false);
233
+ checkEdge();
234
+ }
235
+ }}
130
236
  >
131
237
  {renderers[currentName()]?.element() ?? null}
132
238
  </box>
@@ -314,7 +314,7 @@ export function createAnimatedRenderer(pack: MascotPack): {
314
314
 
315
315
  // 连续跳跃 + 吐火星文泡泡庆祝更新成功
316
316
  const celebrateUpdate = (newVersion: string) => {
317
- const bubbles = pack.bubbleTexts ?? ["ᵘᵖ~"];
317
+ const bubbles = ["ᵘᵖ~", "ⁿᵉʷ!", "ʸᵉ~", "ᵍᵒ~", "ᵒᵏ~"];
318
318
  setState("happy");
319
319
  setFrameOverride("happy");
320
320
 
package/src/core/types.ts CHANGED
@@ -25,14 +25,6 @@ export interface AnimationConfig {
25
25
  jumpMaxDelay?: number;
26
26
  }
27
27
 
28
- /**
29
- * Sidebar display configuration.
30
- */
31
- export interface SidebarConfig {
32
- greetings?: string[];
33
- busyPhrases?: string[];
34
- }
35
-
36
28
  // ─── Effect system types ───
37
29
 
38
30
  /**
@@ -130,25 +122,7 @@ export interface MascotPack {
130
122
  };
131
123
 
132
124
  animations?: AnimationConfig;
133
- sidebar?: SidebarConfig;
134
125
 
135
126
  /** Mascot-specific animation effects (timers + render) */
136
127
  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>>;
144
- }
145
-
146
- /**
147
- * User-facing configuration merged from opencode config file.
148
- */
149
- export interface MascotConfig {
150
- mascot: string;
151
- animations: boolean;
152
- idleSleep: boolean;
153
- switchConfig?: SwitchConfig;
154
128
  }
@@ -1,183 +0,0 @@
1
- /** @jsxImportSource @opentui/solid */
2
-
3
- import { createSignal, onCleanup, type Accessor } from "solid-js";
4
- import { useTerminalDimensions } from "@opentui/solid";
5
-
6
- type DragState = "normal" | "edge_hidden" | "returning";
7
- type HideSide = "left" | "right" | null;
8
-
9
- export interface MouseProps {
10
- onMouseDown: (e: any) => void;
11
- onMouseDrag: (e: any) => void;
12
- onMouseUp: (e: any) => void;
13
- onMouseDragEnd: (e: any) => void;
14
- }
15
-
16
- interface UseDraggableOpts {
17
- initialX: number;
18
- initialY: number;
19
- mascotWidth: number;
20
- mascotHeight: number;
21
- onSwitch: () => void;
22
- clearSelection: () => void;
23
- setDragging: (v: boolean) => void;
24
- onReturnComplete?: () => void;
25
- peekVisible?: number;
26
- enableEdge?: boolean;
27
- }
28
-
29
- export function useDraggableMascot(opts: UseDraggableOpts): {
30
- posX: Accessor<number>;
31
- posY: Accessor<number>;
32
- mouseProps: MouseProps;
33
- returnToView: () => void;
34
- isHidden: () => boolean;
35
- } {
36
- const peek = opts.peekVisible ?? 2;
37
- const enableEdge = opts.enableEdge ?? true;
38
-
39
- const [posX, setPosX] = createSignal(opts.initialX);
40
- const [posY, setPosY] = createSignal(opts.initialY);
41
- const dims = useTerminalDimensions();
42
-
43
- let dragStartX = 0;
44
- let dragStartY = 0;
45
- let dragAnchorX = 0;
46
- let dragAnchorY = 0;
47
- let lastClickTime = 0;
48
- let isDragging = false;
49
- let state: DragState = "normal";
50
- let hideSide: HideSide = null;
51
- let peekTimer: ReturnType<typeof setInterval> | null = null;
52
- let returnTimer: ReturnType<typeof setInterval> | null = null;
53
-
54
- const stopPeek = () => {
55
- if (peekTimer) { clearInterval(peekTimer); peekTimer = null; }
56
- };
57
- const stopReturn = () => {
58
- if (returnTimer) { clearInterval(returnTimer); returnTimer = null; }
59
- };
60
-
61
- onCleanup(() => { stopPeek(); stopReturn(); });
62
-
63
- const clampX = (rawX: number): number => {
64
- if (!enableEdge) return rawX;
65
- const { width } = dims();
66
- const minX = -(opts.mascotWidth - peek);
67
- const maxX = width - peek;
68
- return Math.max(minX, Math.min(rawX, maxX));
69
- };
70
-
71
- const clampY = (rawY: number): number => {
72
- if (!enableEdge) return rawY;
73
- const { height } = dims();
74
- return Math.max(0, Math.min(rawY, height - opts.mascotHeight));
75
- };
76
-
77
- const startPeek = () => {
78
- stopPeek();
79
- state = "edge_hidden";
80
- let phase = false;
81
- peekTimer = setInterval(() => {
82
- phase = !phase;
83
- const stretch = phase ? 2 : 0;
84
- const { width } = dims();
85
- if (hideSide === "left") {
86
- setPosX(-(opts.mascotWidth - peek) + stretch);
87
- } else if (hideSide === "right") {
88
- setPosX(width - peek - stretch);
89
- }
90
- }, 1200);
91
- };
92
-
93
- const returnToView = () => {
94
- if (state !== "edge_hidden") return;
95
- stopPeek();
96
- state = "returning";
97
- const { width } = dims();
98
- const cur = posX();
99
- const targetX = hideSide === "left" ? 0 : Math.max(0, width - opts.mascotWidth);
100
- const step = targetX > cur ? 2 : -2;
101
-
102
- returnTimer = setInterval(() => {
103
- const now = posX();
104
- if (Math.abs(now - targetX) <= 2) {
105
- setPosX(targetX);
106
- stopReturn();
107
- state = "normal";
108
- hideSide = null;
109
- opts.onReturnComplete?.();
110
- return;
111
- }
112
- setPosX(now + step);
113
- }, 16);
114
- };
115
-
116
- const checkEdgeOnRelease = () => {
117
- if (!enableEdge) return;
118
- const { width } = dims();
119
- const x = posX();
120
- if (x <= -(opts.mascotWidth - peek) + 1) {
121
- hideSide = "left";
122
- startPeek();
123
- } else if (x >= width - peek - 1) {
124
- hideSide = "right";
125
- startPeek();
126
- }
127
- };
128
-
129
- const mouseProps: MouseProps = {
130
- onMouseDown(e: any) {
131
- if (state === "edge_hidden") {
132
- returnToView();
133
- return;
134
- }
135
- if (state === "returning") return;
136
-
137
- const now = Date.now();
138
- if (now - lastClickTime < 300) {
139
- opts.onSwitch();
140
- lastClickTime = 0;
141
- return;
142
- }
143
- lastClickTime = now;
144
-
145
- if (e.modifiers?.alt) {
146
- dragStartX = e.x;
147
- dragStartY = e.y;
148
- dragAnchorX = posX();
149
- dragAnchorY = posY();
150
- isDragging = true;
151
- opts.setDragging(true);
152
- e.preventDefault();
153
- e.stopPropagation();
154
- opts.clearSelection();
155
- }
156
- },
157
- onMouseDrag(e: any) {
158
- if (e.modifiers?.alt && isDragging) {
159
- setPosX(clampX(dragAnchorX + (e.x - dragStartX)));
160
- setPosY(clampY(dragAnchorY + (e.y - dragStartY)));
161
- e.preventDefault();
162
- e.stopPropagation();
163
- opts.clearSelection();
164
- }
165
- },
166
- onMouseUp() {
167
- if (isDragging) {
168
- isDragging = false;
169
- opts.setDragging(false);
170
- checkEdgeOnRelease();
171
- }
172
- },
173
- onMouseDragEnd() {
174
- if (isDragging) {
175
- isDragging = false;
176
- opts.setDragging(false);
177
- checkEdgeOnRelease();
178
- }
179
- },
180
- };
181
-
182
- return { posX, posY, mouseProps, returnToView, isHidden: () => state === "edge_hidden" };
183
- }