@mingxy/opencode-mascot 0.2.0 → 0.2.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.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "OpenCode TUI mascot plugin framework - customizable ASCII mascots for your terminal",
5
5
  "author": "mingxy",
6
6
  "license": "MIT",
@@ -15,18 +15,27 @@
15
15
  "tui.tsx",
16
16
  "src/"
17
17
  ],
18
- "oc-plugin": ["tui"],
18
+ "oc-plugin": [
19
+ "tui"
20
+ ],
19
21
  "scripts": {
20
22
  "typecheck": "tsc --noEmit"
21
23
  },
22
24
  "peerDependencies": {
23
- "@opentui/solid": ">=0.0.1",
24
- "@opencode-ai/plugin": ">=0.1.0"
25
+ "@opencode-ai/plugin": ">=0.1.0",
26
+ "@opentui/solid": ">=0.0.1"
25
27
  },
26
28
  "devDependencies": {
29
+ "@types/node": "^25.9.3",
27
30
  "typescript": "^5.7.0"
28
31
  },
29
- "keywords": ["opencode", "tui", "mascot", "plugin", "ascii-art"],
32
+ "keywords": [
33
+ "opencode",
34
+ "tui",
35
+ "mascot",
36
+ "plugin",
37
+ "ascii-art"
38
+ ],
30
39
  "repository": {
31
40
  "type": "git",
32
41
  "url": "https://github.com/mengfanbo123/opencode-mascot"
@@ -8,7 +8,11 @@ const STEAM_PATTERNS = [
8
8
  " ◦∘~ ",
9
9
  ];
10
10
 
11
- const BUBBLE_TEXTS = ["ᵃⁿᵍ~", "ˣᶦᵃⁿ!", "ᵏᵘᵃⁱ", "ᶠᵃⁱ"];
11
+ const BUBBLE_TEXTS = [
12
+ "ᵃⁿᵍ~", "ˣᶦᵃⁿ!", "ᵏᵘᵃⁱ", "ᶠᵃⁱ",
13
+ "ʳᵉⁿ~..", "ᵖᵃⁿ~", "ᵗᵃⁿᵍ!", "ʸᵉ~..",
14
+ "ᵐⁱᵃⁿ~", "ᵍᵘᵒ!", "ˢʰᵘ~..", "ʰᵘᵒ~",
15
+ ];
12
16
 
13
17
  const baoziEffects: MascotPack["effects"] = {
14
18
  signals: [
@@ -0,0 +1,103 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import { createSignal } from "solid-js";
4
+ import type { JSX } from "@opentui/solid";
5
+ import type { MascotPack } from "../core/types";
6
+ import { createAnimatedRenderer } from "../core/ascii-renderer";
7
+ import { onCelebrate } from "../core/celebration-bus";
8
+
9
+ interface HomeMascotProps {
10
+ mascots: Record<string, MascotPack>;
11
+ api: {
12
+ renderer: {
13
+ clearSelection(): void;
14
+ };
15
+ };
16
+ }
17
+
18
+ export function HomeMascot(props: HomeMascotProps): JSX.Element {
19
+ const names = Object.keys(props.mascots);
20
+ const initialName = names[Math.floor(Math.random() * names.length)];
21
+
22
+ const [currentName, setCurrentName] = createSignal(initialName);
23
+ const [posX, setPosX] = createSignal(0);
24
+ const [posY, setPosY] = createSignal(0);
25
+ let dragStartX = 0;
26
+ let dragStartY = 0;
27
+ let dragAnchorX = 0;
28
+ let dragAnchorY = 0;
29
+ let lastClickTime = 0;
30
+ let isDragging = false;
31
+
32
+ const renderers: Record<string, ReturnType<typeof createAnimatedRenderer>> = {};
33
+ for (const [name, pack] of Object.entries(props.mascots)) {
34
+ renderers[name] = createAnimatedRenderer(pack);
35
+ }
36
+
37
+ const switchTo = (name: string) => {
38
+ if (props.mascots[name] && name !== currentName()) {
39
+ setCurrentName(name);
40
+ }
41
+ };
42
+
43
+ onCelebrate((newVersion) => {
44
+ renderers[currentName()].celebrateUpdate(newVersion);
45
+ });
46
+
47
+ return (
48
+ <box
49
+ left={posX()}
50
+ top={posY()}
51
+ alignItems="center"
52
+ zIndex={100}
53
+ flexDirection="column"
54
+ onMouseDown={(e: any) => {
55
+ const now = Date.now();
56
+ if (now - lastClickTime < 300) {
57
+ const cur = currentName();
58
+ const idx = names.indexOf(cur);
59
+ const next = names[(idx + 1) % names.length];
60
+ switchTo(next);
61
+ lastClickTime = 0;
62
+ return;
63
+ }
64
+ lastClickTime = now;
65
+
66
+ if (e.modifiers?.alt) {
67
+ dragStartX = e.x;
68
+ dragStartY = e.y;
69
+ dragAnchorX = posX();
70
+ dragAnchorY = posY();
71
+ isDragging = true;
72
+ renderers[currentName()].setDragging(true);
73
+ e.preventDefault();
74
+ e.stopPropagation();
75
+ props.api.renderer.clearSelection();
76
+ }
77
+ }}
78
+ onMouseDrag={(e: any) => {
79
+ if (e.modifiers?.alt && isDragging) {
80
+ setPosX(dragAnchorX + (e.x - dragStartX));
81
+ setPosY(dragAnchorY + (e.y - dragStartY));
82
+ e.preventDefault();
83
+ e.stopPropagation();
84
+ props.api.renderer.clearSelection();
85
+ }
86
+ }}
87
+ onMouseUp={() => {
88
+ if (isDragging) {
89
+ isDragging = false;
90
+ renderers[currentName()].setDragging(false);
91
+ }
92
+ }}
93
+ onMouseDragEnd={() => {
94
+ if (isDragging) {
95
+ isDragging = false;
96
+ renderers[currentName()].setDragging(false);
97
+ }
98
+ }}
99
+ >
100
+ {renderers[currentName()]?.element() ?? null}
101
+ </box>
102
+ );
103
+ }
@@ -4,6 +4,7 @@ import { createSignal } from "solid-js";
4
4
  import type { JSX } from "@opentui/solid";
5
5
  import type { MascotPack, MascotState, SwitchConfig } from "../core/types";
6
6
  import { createAnimatedRenderer } from "../core/ascii-renderer";
7
+ import { onCelebrate } from "../core/celebration-bus";
7
8
 
8
9
  interface SidebarMascotProps {
9
10
  mascots: Record<string, MascotPack>;
@@ -100,6 +101,10 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
100
101
  renderers[currentName()].toggleWalk();
101
102
  });
102
103
 
104
+ onCelebrate((newVersion) => {
105
+ renderers[currentName()].celebrateUpdate(newVersion);
106
+ });
107
+
103
108
  return (
104
109
  <box
105
110
  position="absolute"
@@ -47,6 +47,7 @@ export function createAnimatedRenderer(pack: MascotPack): {
47
47
  setState: (s: MascotState) => void;
48
48
  toggleWalk: () => void;
49
49
  setDragging: (v: boolean) => void;
50
+ celebrateUpdate: (newVersion: string) => void;
50
51
  } {
51
52
  const anim = { ...DEFAULT_ANIM, ...pack.animations };
52
53
  const fg = pack.colors?.defaultFg || undefined;
@@ -59,6 +60,7 @@ export function createAnimatedRenderer(pack: MascotPack): {
59
60
  const [jumpOffset, setJumpOffset] = createSignal(0);
60
61
  const [walkEnabled, setWalkEnabled] = createSignal(anim.walkEnabled ?? true);
61
62
  const [dragging, setDraggingSignal] = createSignal(false);
63
+ const [celebrate, setCelebrate] = createSignal<{ text: string; count: number } | null>(null);
62
64
 
63
65
  let idleSleepTimeout: ReturnType<typeof setTimeout> | null = null;
64
66
 
@@ -227,6 +229,7 @@ export function createAnimatedRenderer(pack: MascotPack): {
227
229
  frameOverride();
228
230
  currentState();
229
231
  dragging();
232
+ celebrate();
230
233
 
231
234
  for (const [, [get]] of extraSignals) {
232
235
  get();
@@ -261,10 +264,12 @@ export function createAnimatedRenderer(pack: MascotPack): {
261
264
 
262
265
  const top = jumpOffset();
263
266
  const left = offset > 0 ? offset : 0;
267
+ const cel = celebrate();
264
268
 
265
269
  return (
266
270
  <box flexDirection="column" left={left} top={top}>
267
271
  {renderLines(lines, fg)}
272
+ {cel ? <text fg={fg}>{cel.text}</text> : null}
268
273
  </box>
269
274
  );
270
275
  };
@@ -296,11 +301,37 @@ export function createAnimatedRenderer(pack: MascotPack): {
296
301
  const setDragging = (v: boolean) => {
297
302
  setDraggingSignal(v);
298
303
  if (v) {
304
+ // 睡着时被拖拽 → 惊醒到 idle,切回 default 帧后手臂 ┃███┃ 才能被扇手渲染匹配
305
+ if (currentState() === "sleeping") {
306
+ setState("idle");
307
+ }
299
308
  setJumpOffset(-1);
300
309
  } else {
301
310
  setJumpOffset(0);
302
311
  }
303
312
  };
304
313
 
305
- return { element, setState, toggleWalk, setDragging };
314
+ // 连续跳跃 + 吐火星文泡泡庆祝更新成功
315
+ const celebrateUpdate = (newVersion: string) => {
316
+ const bubbles = pack.bubbleTexts ?? ["ᵘᵖ~"];
317
+ if (currentState() === "sleeping") setState("idle");
318
+
319
+ let step = 0;
320
+ const JUMPS = 3;
321
+ const tick = () => {
322
+ if (step >= JUMPS) {
323
+ setJumpOffset(0);
324
+ setCelebrate(null);
325
+ return;
326
+ }
327
+ setJumpOffset(step % 2 === 0 ? -2 : 0);
328
+ const word = bubbles[Math.floor(Math.random() * bubbles.length)];
329
+ setCelebrate({ text: `${word} ᵘᵖ→ᵛ${newVersion}`, count: step });
330
+ step++;
331
+ setTimeout(tick, 600);
332
+ };
333
+ tick();
334
+ };
335
+
336
+ return { element, setState, toggleWalk, setDragging, celebrateUpdate };
306
337
  }
@@ -0,0 +1,16 @@
1
+ const bus = new EventTarget();
2
+
3
+ const CELEBRATE_EVENT = "mascot:celebrate";
4
+
5
+ export function emitCelebrate(newVersion: string): void {
6
+ bus.dispatchEvent(new CustomEvent(CELEBRATE_EVENT, { detail: { newVersion } }));
7
+ }
8
+
9
+ export function onCelebrate(handler: (newVersion: string) => void): () => void {
10
+ const listener = (e: Event) => {
11
+ const detail = (e as CustomEvent).detail as { newVersion: string };
12
+ handler(detail.newVersion);
13
+ };
14
+ bus.addEventListener(CELEBRATE_EVENT, listener);
15
+ return () => bus.removeEventListener(CELEBRATE_EVENT, listener);
16
+ }
@@ -0,0 +1,125 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { createRequire } from "node:module";
4
+ import { join, dirname } from "node:path";
5
+ import { homedir, tmpdir } from "node:os";
6
+ import { openSync, closeSync, unlinkSync, statSync, writeSync, mkdtempSync, readdirSync, rmSync, readFileSync, writeFileSync } from "node:fs";
7
+
8
+ const execFileAsync = promisify(execFile);
9
+ const require = createRequire(import.meta.url);
10
+
11
+ const PKG_NAME = "@mingxy/opencode-mascot";
12
+ const LOCK_FILE = join(tmpdir(), "mascot-update.lock");
13
+ const STALE_LOCK_MS = 5 * 60 * 1000;
14
+ let lockFd: number | null = null;
15
+
16
+ async function getLatestVersion(): Promise<string | null> {
17
+ try {
18
+ const { stdout } = await execFileAsync("npm", ["view", PKG_NAME, "version"], { timeout: 10000 });
19
+ return stdout.trim();
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function compareVersions(a: string, b: string): number {
26
+ const pa = a.replace(/^v/, "").split(".").map(Number);
27
+ const pb = b.replace(/^v/, "").split(".").map(Number);
28
+ for (let i = 0; i < 3; i++) {
29
+ if ((pa[i] ?? 0) > (pb[i] ?? 0)) return 1;
30
+ if ((pa[i] ?? 0) < (pb[i] ?? 0)) return -1;
31
+ }
32
+ return 0;
33
+ }
34
+
35
+ function getInstallDir(): string {
36
+ try {
37
+ const pkgPath = require.resolve(`${PKG_NAME}/package.json`);
38
+ return dirname(pkgPath);
39
+ } catch {
40
+ return join(homedir(), ".cache", "opencode", "packages", "@mingxy", "opencode-mascot");
41
+ }
42
+ }
43
+
44
+ async function installUpdate(targetDir: string, newVersion: string): Promise<boolean> {
45
+ const tmpDir = mkdtempSync(join(tmpdir(), "mascot-update-"));
46
+ try {
47
+ await execFileAsync("npm", ["pack", `${PKG_NAME}@latest`, "--pack-destination", tmpDir], { timeout: 60000 });
48
+ const files = readdirSync(tmpDir);
49
+ const tgz = files.find(f => f.endsWith(".tgz"));
50
+ if (!tgz) return false;
51
+ await execFileAsync(
52
+ "tar",
53
+ ["-xzf", join(tmpDir, tgz), "-C", targetDir, "--strip-components=1", "--no-same-owner", "--no-same-permissions"],
54
+ { timeout: 30000 },
55
+ );
56
+
57
+ // 更新 opencode 插件管理清单的版本号,防止重启时回滚
58
+ let dir = targetDir;
59
+ for (let i = 0; i < 5; i++) {
60
+ dir = dirname(dir);
61
+ const manifestPath = join(dir, "package.json");
62
+ try {
63
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
64
+ if (manifest.dependencies && PKG_NAME in manifest.dependencies) {
65
+ manifest.dependencies[PKG_NAME] = newVersion;
66
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
67
+ break;
68
+ }
69
+ } catch {}
70
+ }
71
+
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ } finally {
76
+ try { rmSync(tmpDir, { recursive: true }); } catch {}
77
+ }
78
+ }
79
+
80
+ function acquireLock(): boolean {
81
+ try {
82
+ try {
83
+ const s = statSync(LOCK_FILE);
84
+ if (Date.now() - s.mtimeMs > STALE_LOCK_MS) unlinkSync(LOCK_FILE);
85
+ } catch {}
86
+ lockFd = openSync(LOCK_FILE, "wx");
87
+ writeSync(lockFd, String(process.pid));
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ function releaseLock(): void {
95
+ if (lockFd !== null) {
96
+ try { closeSync(lockFd); } catch {}
97
+ lockFd = null;
98
+ try { unlinkSync(LOCK_FILE); } catch {}
99
+ }
100
+ }
101
+
102
+ /**
103
+ * 更新成功后的回调:把新版本号交给调用方,由其触发吉祥物庆祝动画
104
+ */
105
+ export async function checkAndUpdate(
106
+ currentVersion: string,
107
+ onSuccess: (newVersion: string) => void,
108
+ ): Promise<void> {
109
+ const latest = await getLatestVersion();
110
+ if (!latest) return;
111
+
112
+ if (compareVersions(latest, currentVersion) <= 0) return;
113
+
114
+ if (!acquireLock()) return;
115
+
116
+ try {
117
+ const targetDir = getInstallDir();
118
+ const success = await installUpdate(targetDir, latest);
119
+ if (success) {
120
+ onSuccess(latest);
121
+ }
122
+ } finally {
123
+ releaseLock();
124
+ }
125
+ }
package/tui.tsx CHANGED
@@ -1,13 +1,27 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
- import { loadAllMascots, getRandomMascot } from "./src/core/mascot-loader"
3
+ import { readFileSync } from "node:fs"
4
+ import { join, dirname } from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+ import { loadAllMascots } from "./src/core/mascot-loader"
4
7
  import { SidebarMascot } from "./src/components/sidebar-mascot"
5
- import { createAnimatedRenderer } from "./src/core/ascii-renderer"
8
+ import { HomeMascot } from "./src/components/home-mascot"
9
+ import { checkAndUpdate } from "./src/core/updater"
10
+ import { emitCelebrate } from "./src/core/celebration-bus"
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+
15
+ let pluginVersion = "unknown";
16
+ try {
17
+ const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
18
+ if (pkg?.version && typeof pkg.version === "string") {
19
+ pluginVersion = pkg.version;
20
+ }
21
+ } catch {}
6
22
 
7
23
  const tui: TuiPlugin = async (api, _options) => {
8
24
  const mascots = loadAllMascots()
9
- const homeMascot = getRandomMascot()
10
- const homeRenderer = createAnimatedRenderer(homeMascot)
11
25
 
12
26
  api.slots.register({
13
27
  slots: {
@@ -15,14 +29,14 @@ const tui: TuiPlugin = async (api, _options) => {
15
29
  return <SidebarMascot mascots={mascots} api={api} />
16
30
  },
17
31
  home_bottom() {
18
- return (
19
- <box flexDirection="column" alignItems="center">
20
- {homeRenderer.element()}
21
- </box>
22
- )
32
+ return <HomeMascot mascots={mascots} api={api} />
23
33
  }
24
34
  }
25
35
  })
36
+
37
+ checkAndUpdate(pluginVersion, (newVersion) => {
38
+ emitCelebrate(newVersion);
39
+ }).catch(() => {});
26
40
  }
27
41
 
28
42
  const plugin: TuiPluginModule = {