opencube 0.1.0 → 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/README.md +51 -17
- package/package.json +1 -1
- package/src/main.js +537 -26
- package/src/plugin-server.cjs +47 -11
- package/src/plugin-shared.cjs +19 -19
- package/src/plugin-tui.cjs +1 -1
package/README.md
CHANGED
|
@@ -1,28 +1,30 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="assets/opencode-icon.png" width="96" height="96" alt="
|
|
2
|
+
<img src="assets/opencode-icon.png" width="96" height="96" alt="OpenCube icon" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
|
-
#
|
|
5
|
+
# OpenCube
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
OpenCube is a tiny desktop pet for [opencode](https://opencode.ai/).
|
|
8
8
|
|
|
9
9
|
It watches opencode session activity and renders a small Three.js cube on your desktop:
|
|
10
10
|
|
|
11
11
|
- busy sessions light up cube faces
|
|
12
|
-
-
|
|
12
|
+
- active tool calls emit bright face-colored sparks from the corresponding cube face
|
|
13
|
+
- right-click drag applies extra rotational friction with small braking particles
|
|
14
|
+
- idle sessions keep a subtle torque-breathing motion and release face glow after the cube slows down
|
|
15
|
+
- a macOS-style tray/menu bar control can show, hide, quit, or open the Inbox
|
|
16
|
+
- the Inbox shows raw opencode hook events plus local mouse/keyboard diagnostics
|
|
13
17
|
- `/pet_say_hello` flashes one free face
|
|
14
18
|
- `/pet_fancy_say_hello` plays a randomized light show on free faces
|
|
15
19
|
|
|
16
|
-
|
|
20
|
+
OpenCube is packaged as an opencode plugin plus an Electron desktop process.
|
|
17
21
|
|
|
18
22
|
## Install
|
|
19
23
|
|
|
20
|
-
> OpenCub is not published yet. These are the intended install commands once published.
|
|
21
|
-
|
|
22
24
|
Install globally through opencode:
|
|
23
25
|
|
|
24
26
|
```sh
|
|
25
|
-
opencode plugin
|
|
27
|
+
opencode plugin opencube --global
|
|
26
28
|
```
|
|
27
29
|
|
|
28
30
|
Then restart opencode and run:
|
|
@@ -35,7 +37,7 @@ You can also add it manually to `~/.config/opencode/opencode.json`:
|
|
|
35
37
|
|
|
36
38
|
```json
|
|
37
39
|
{
|
|
38
|
-
"plugin": ["
|
|
40
|
+
"plugin": ["opencube"]
|
|
39
41
|
}
|
|
40
42
|
```
|
|
41
43
|
|
|
@@ -43,27 +45,57 @@ You can also add it manually to `~/.config/opencode/opencode.json`:
|
|
|
43
45
|
|
|
44
46
|
| Command | Description |
|
|
45
47
|
| --- | --- |
|
|
46
|
-
| `/pet` | Show or start
|
|
47
|
-
| `/pet_stop` | Quit
|
|
48
|
+
| `/pet` | Show or start OpenCube. |
|
|
49
|
+
| `/pet_stop` | Quit OpenCube. |
|
|
48
50
|
| `/pet_say_hello` | Flash one currently free face three times with a random color. |
|
|
49
51
|
| `/pet_fancy_say_hello` | Run a denser randomized light show across currently free faces. |
|
|
50
52
|
|
|
51
53
|
These commands are handled by the plugin and do not get sent to the model.
|
|
52
54
|
|
|
55
|
+
## What you can see
|
|
56
|
+
|
|
57
|
+
OpenCube turns opencode lifecycle events into small desktop signals:
|
|
58
|
+
|
|
59
|
+
| opencode activity | OpenCube signal |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| Session becomes busy | One cube face lights up with a stable session color. |
|
|
62
|
+
| Session becomes idle | The face glow is released once the cube slows down. |
|
|
63
|
+
| A tool call starts | The session's face emits fast, bright sparks in that face's current outward direction. |
|
|
64
|
+
| A tool call finishes | Spark emission stops; existing sparks fade out naturally. |
|
|
65
|
+
| Right mouse hold on the cube | Friction increases and braking particles appear. |
|
|
66
|
+
| Tray menu → Show Inbox | Opens a two-column event/debug panel. |
|
|
67
|
+
|
|
68
|
+
Multiple busy sessions can light multiple faces. If more than six sessions are busy, OpenCube shows the latest six.
|
|
69
|
+
|
|
53
70
|
## How it works
|
|
54
71
|
|
|
55
|
-
|
|
72
|
+
OpenCube has two parts in one npm package:
|
|
56
73
|
|
|
57
74
|
1. `src/plugin-server.cjs` — the opencode plugin entrypoint.
|
|
58
75
|
2. `src/main.js` — the Electron desktop pet.
|
|
59
76
|
|
|
60
|
-
The plugin registers slash commands, listens for opencode `session.status` events, and sends events to the desktop pet over a local HTTP API:
|
|
77
|
+
The plugin registers slash commands, listens for opencode `session.status` events and `tool.execute.*` hooks, and sends events to the desktop pet over a local HTTP API:
|
|
61
78
|
|
|
62
79
|
```text
|
|
63
|
-
opencode plugin -> http://127.0.0.1:47832 -> Electron
|
|
80
|
+
opencode plugin -> http://127.0.0.1:47832 -> Electron OpenCube
|
|
64
81
|
```
|
|
65
82
|
|
|
66
|
-
The Electron process owns the window, Three.js renderer, cube rotation, face glow state, and inbox/debug endpoints.
|
|
83
|
+
The Electron process owns the window, tray menu, Three.js renderer, cube rotation, face glow state, particle effects, and inbox/debug endpoints.
|
|
84
|
+
|
|
85
|
+
## Local API
|
|
86
|
+
|
|
87
|
+
OpenCube exposes a local-only HTTP API while it is running:
|
|
88
|
+
|
|
89
|
+
| Endpoint | Purpose |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `GET /health` | Check whether OpenCube is running. |
|
|
92
|
+
| `GET /debug-render` | Inspect renderer state, busy faces, active tools, and particle counters. |
|
|
93
|
+
| `POST /event` | Receive opencode lifecycle/tool/hello events. |
|
|
94
|
+
| `POST /interaction` | Receive local renderer mouse/keyboard diagnostics. |
|
|
95
|
+
| `POST /show` | Show the cube window. |
|
|
96
|
+
| `POST /quit` | Quit OpenCube. |
|
|
97
|
+
|
|
98
|
+
The API binds to `127.0.0.1` only.
|
|
67
99
|
|
|
68
100
|
## Requirements
|
|
69
101
|
|
|
@@ -76,9 +108,11 @@ Users do not need to run `npm install` manually when installing via `opencode pl
|
|
|
76
108
|
## Notes
|
|
77
109
|
|
|
78
110
|
- The first install may take a while because Electron is downloaded as a runtime dependency.
|
|
111
|
+
- If Electron's platform binary is missing after installation, OpenCube attempts a first-run self-repair download.
|
|
79
112
|
- If commands do not appear after installation, restart opencode.
|
|
80
|
-
-
|
|
113
|
+
- OpenCube uses a local-only HTTP server on `127.0.0.1:47832`.
|
|
81
114
|
- If that port is already in use, set `OPENCODE_PET_PORT` before starting opencode.
|
|
115
|
+
- In opencode desktop, OpenCube currently uses a command-abort sentinel to keep local slash commands out of the model flow; depending on opencode version, the desktop UI may show an error toast even though the command was handled.
|
|
82
116
|
|
|
83
117
|
## Local development
|
|
84
118
|
|
|
@@ -93,7 +127,7 @@ For local opencode plugin testing, point your opencode config at the package dir
|
|
|
93
127
|
|
|
94
128
|
```json
|
|
95
129
|
{
|
|
96
|
-
"plugin": ["/path/to/
|
|
130
|
+
"plugin": ["/path/to/opencube"]
|
|
97
131
|
}
|
|
98
132
|
```
|
|
99
133
|
|
package/package.json
CHANGED
package/src/main.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { app, BrowserWindow, Menu, screen } = require("electron")
|
|
1
|
+
const { app, BrowserWindow, Menu, Tray, nativeImage, screen, ipcMain } = require("electron")
|
|
2
2
|
const fs = require("node:fs")
|
|
3
3
|
const http = require("node:http")
|
|
4
4
|
const os = require("node:os")
|
|
@@ -9,20 +9,42 @@ const { DEFAULT_SESSION_COLORS, pixelPetSvg } = require("./pixel-pet-reference.c
|
|
|
9
9
|
const APP_NAME = "opencode pet"
|
|
10
10
|
const HOST = "127.0.0.1"
|
|
11
11
|
const PORT = Number(process.env.OPENCODE_PET_PORT || 47832)
|
|
12
|
-
const DATA_DIR = path.join(os.homedir(), ".local", "share", "
|
|
12
|
+
const DATA_DIR = path.join(os.homedir(), ".local", "share", "opencube")
|
|
13
13
|
const STATE_FILE = path.join(DATA_DIR, "state.json")
|
|
14
14
|
const PET_HTML_FILE = path.join(DATA_DIR, "pet.html")
|
|
15
15
|
const ICON_PATH = process.env.OPENCODE_PET_ICON || path.join(__dirname, "..", "assets", "opencode-icon.png")
|
|
16
16
|
const MAX_EVENTS = 100
|
|
17
|
+
const MAX_INTERACTION_EVENTS = 80
|
|
17
18
|
const IDLE_TTL_MS = 5 * 60 * 1000
|
|
18
19
|
|
|
19
20
|
let petWindow = null
|
|
20
21
|
let panelWindow = null
|
|
22
|
+
let tray = null
|
|
21
23
|
let server = null
|
|
22
24
|
let events = []
|
|
25
|
+
let interactionEvents = []
|
|
23
26
|
let petSignals = []
|
|
24
27
|
let sessionMap = new Map()
|
|
28
|
+
let activeToolsBySession = new Map()
|
|
25
29
|
let cleanupTimer = null
|
|
30
|
+
let dragState = null
|
|
31
|
+
|
|
32
|
+
ipcMain.on("opencube-drag-start", (event, point) => {
|
|
33
|
+
if (!petWindow || petWindow.isDestroyed()) return
|
|
34
|
+
const [x, y] = petWindow.getPosition()
|
|
35
|
+
dragState = { windowX: x, windowY: y, screenX: point.screenX, screenY: point.screenY }
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
ipcMain.on("opencube-drag-move", (event, point) => {
|
|
39
|
+
if (!petWindow || petWindow.isDestroyed() || !dragState) return
|
|
40
|
+
const nextX = Math.round(dragState.windowX + point.screenX - dragState.screenX)
|
|
41
|
+
const nextY = Math.round(dragState.windowY + point.screenY - dragState.screenY)
|
|
42
|
+
petWindow.setPosition(nextX, nextY, false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
ipcMain.on("opencube-drag-end", () => {
|
|
46
|
+
dragState = null
|
|
47
|
+
})
|
|
26
48
|
|
|
27
49
|
function ensureDataDir() {
|
|
28
50
|
fs.mkdirSync(DATA_DIR, { recursive: true })
|
|
@@ -108,6 +130,7 @@ function recordEvent(event) {
|
|
|
108
130
|
...event,
|
|
109
131
|
}
|
|
110
132
|
applySessionEvent(item)
|
|
133
|
+
applyToolEvent(item)
|
|
111
134
|
if (item.type === "hello" || item.type === "fancy_hello") {
|
|
112
135
|
petSignals.push({
|
|
113
136
|
id: item.id,
|
|
@@ -125,6 +148,41 @@ function recordEvent(event) {
|
|
|
125
148
|
return item
|
|
126
149
|
}
|
|
127
150
|
|
|
151
|
+
function applyToolEvent(event) {
|
|
152
|
+
if (!event || typeof event.sessionID !== "string" || typeof event.callID !== "string") return
|
|
153
|
+
if (event.type !== "tool.start" && event.type !== "tool.finish") return
|
|
154
|
+
|
|
155
|
+
let tools = activeToolsBySession.get(event.sessionID)
|
|
156
|
+
if (!tools) {
|
|
157
|
+
tools = new Map()
|
|
158
|
+
activeToolsBySession.set(event.sessionID, tools)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (event.type === "tool.start") {
|
|
162
|
+
tools.set(event.callID, {
|
|
163
|
+
callID: event.callID,
|
|
164
|
+
tool: event.tool,
|
|
165
|
+
startedAt: event.receivedAt || Date.now(),
|
|
166
|
+
})
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
tools.delete(event.callID)
|
|
171
|
+
if (tools.size === 0) activeToolsBySession.delete(event.sessionID)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function recordInteractionEvent(event) {
|
|
175
|
+
const item = {
|
|
176
|
+
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
177
|
+
receivedAt: Date.now(),
|
|
178
|
+
...event,
|
|
179
|
+
}
|
|
180
|
+
interactionEvents.unshift(item)
|
|
181
|
+
interactionEvents = interactionEvents.slice(0, MAX_INTERACTION_EVENTS)
|
|
182
|
+
updatePanel()
|
|
183
|
+
return item
|
|
184
|
+
}
|
|
185
|
+
|
|
128
186
|
function applySessionEvent(event) {
|
|
129
187
|
if (!event || typeof event.sessionID !== "string") return
|
|
130
188
|
const now = event.receivedAt || Date.now()
|
|
@@ -145,6 +203,7 @@ function applySessionEvent(event) {
|
|
|
145
203
|
}
|
|
146
204
|
|
|
147
205
|
if (event.type === "session.idle") {
|
|
206
|
+
activeToolsBySession.delete(event.sessionID)
|
|
148
207
|
sessionMap.set(event.sessionID, {
|
|
149
208
|
sessionID: event.sessionID,
|
|
150
209
|
state: "idle",
|
|
@@ -165,6 +224,7 @@ function pruneIdleSessions(refresh = true) {
|
|
|
165
224
|
const expiresFrom = session.idleAt || session.lastAt
|
|
166
225
|
if (session.state === "idle" && expiresFrom && now - expiresFrom > IDLE_TTL_MS) {
|
|
167
226
|
sessionMap.delete(sessionID)
|
|
227
|
+
activeToolsBySession.delete(sessionID)
|
|
168
228
|
changed = true
|
|
169
229
|
}
|
|
170
230
|
}
|
|
@@ -186,6 +246,7 @@ function getPetState() {
|
|
|
186
246
|
ball: { size: 14 },
|
|
187
247
|
},
|
|
188
248
|
sessions: Array.from(sessionMap.values()).map((session, index) => ({
|
|
249
|
+
activeTools: Array.from(activeToolsBySession.get(session.sessionID)?.values() || []),
|
|
189
250
|
sessionID: session.sessionID,
|
|
190
251
|
state: session.state,
|
|
191
252
|
busyAt: session.busyAt,
|
|
@@ -230,6 +291,14 @@ function panelHtml() {
|
|
|
230
291
|
})
|
|
231
292
|
.join("")
|
|
232
293
|
|
|
294
|
+
const interactionRows = interactionEvents
|
|
295
|
+
.map((event) => {
|
|
296
|
+
const time = new Date(event.receivedAt).toLocaleTimeString()
|
|
297
|
+
const payload = JSON.stringify(event, null, 2)
|
|
298
|
+
return `<div class="event interaction"><div class="meta interaction-meta">${escapeHtml(time)} · ${escapeHtml(event.type || "interaction")}</div><pre>${escapeHtml(payload)}</pre></div>`
|
|
299
|
+
})
|
|
300
|
+
.join("")
|
|
301
|
+
|
|
233
302
|
return `<!doctype html>
|
|
234
303
|
<html>
|
|
235
304
|
<head>
|
|
@@ -239,17 +308,31 @@ function panelHtml() {
|
|
|
239
308
|
.panel { box-sizing: border-box; width: 100%; height: 100%; padding: 12px; border-radius: 16px; background: rgba(24, 24, 27, 0.94); color: white; box-shadow: 0 14px 42px rgba(0,0,0,.28); overflow: hidden; }
|
|
240
309
|
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 13px; font-weight: 700; }
|
|
241
310
|
.hint { color: rgba(255,255,255,.6); font-size: 11px; font-weight: 500; }
|
|
242
|
-
.
|
|
311
|
+
.columns { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; height: 316px; }
|
|
312
|
+
.section { min-width: 0; overflow: hidden; display: flex; flex-direction: column; }
|
|
313
|
+
.section-title { display: flex; justify-content: space-between; color: rgba(255,255,255,.72); font-size: 11px; font-weight: 700; margin: 0 2px 6px; }
|
|
314
|
+
.list { flex: 1; overflow: auto; padding-right: 4px; }
|
|
243
315
|
.empty { color: rgba(255,255,255,.6); font-size: 13px; padding: 18px 4px; }
|
|
244
316
|
.event { border: 1px solid rgba(255,255,255,.12); border-radius: 10px; padding: 8px; margin-bottom: 8px; background: rgba(255,255,255,.06); }
|
|
317
|
+
.event.interaction { background: rgba(56,189,248,.08); border-color: rgba(56,189,248,.18); }
|
|
245
318
|
.meta { color: #a7f3d0; font-size: 11px; margin-bottom: 5px; }
|
|
319
|
+
.interaction-meta { color: #93c5fd; }
|
|
246
320
|
pre { margin: 0; color: rgba(255,255,255,.88); white-space: pre-wrap; word-break: break-word; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
247
321
|
</style>
|
|
248
322
|
</head>
|
|
249
323
|
<body>
|
|
250
324
|
<div class="panel">
|
|
251
|
-
<div class="header"><span>
|
|
252
|
-
<div class="
|
|
325
|
+
<div class="header"><span>OpenCube inbox</span><span class="hint">events ${events.length}/${MAX_EVENTS} · input ${interactionEvents.length}/${MAX_INTERACTION_EVENTS}</span></div>
|
|
326
|
+
<div class="columns">
|
|
327
|
+
<section class="section">
|
|
328
|
+
<div class="section-title"><span>HTTP / hook events</span><span>${events.length}</span></div>
|
|
329
|
+
<div class="list">${rows || `<div class="empty">还没有收到事件。</div>`}</div>
|
|
330
|
+
</section>
|
|
331
|
+
<section class="section">
|
|
332
|
+
<div class="section-title"><span>Mouse / keyboard</span><span>${interactionEvents.length}</span></div>
|
|
333
|
+
<div class="list">${interactionRows || `<div class="empty">还没有捕捉到输入事件。</div>`}</div>
|
|
334
|
+
</section>
|
|
335
|
+
</div>
|
|
253
336
|
</div>
|
|
254
337
|
</body>
|
|
255
338
|
</html>`
|
|
@@ -548,7 +631,7 @@ function petHtml3D() {
|
|
|
548
631
|
height: 100%;
|
|
549
632
|
position: relative;
|
|
550
633
|
background: transparent;
|
|
551
|
-
-webkit-app-region: drag;
|
|
634
|
+
-webkit-app-region: no-drag;
|
|
552
635
|
user-select: none;
|
|
553
636
|
}
|
|
554
637
|
#scene {
|
|
@@ -558,11 +641,18 @@ function petHtml3D() {
|
|
|
558
641
|
height: 100%;
|
|
559
642
|
pointer-events: none;
|
|
560
643
|
}
|
|
644
|
+
#hit-layer {
|
|
645
|
+
position: absolute;
|
|
646
|
+
inset: 0;
|
|
647
|
+
background: transparent;
|
|
648
|
+
-webkit-app-region: no-drag;
|
|
649
|
+
}
|
|
561
650
|
</style>
|
|
562
651
|
</head>
|
|
563
652
|
<body>
|
|
564
653
|
<div class="stage" title="opencode pet:拖拽移动,右键打开菜单">
|
|
565
654
|
<canvas id="scene" aria-label="3D opencode pet"></canvas>
|
|
655
|
+
<div id="hit-layer" aria-label="OpenCube interaction layer"></div>
|
|
566
656
|
</div>
|
|
567
657
|
<script>
|
|
568
658
|
window.__PET_BOOT_ERROR = null
|
|
@@ -575,9 +665,11 @@ function petHtml3D() {
|
|
|
575
665
|
</script>
|
|
576
666
|
<script>
|
|
577
667
|
const THREE = require(${threeCjsPath})
|
|
668
|
+
const { ipcRenderer } = require("electron")
|
|
578
669
|
|
|
579
670
|
window.__PET_STATE = ${initialStateJson}
|
|
580
671
|
const stage = document.querySelector(".stage")
|
|
672
|
+
const hitLayer = document.getElementById("hit-layer")
|
|
581
673
|
const faceOrder = ["front", "right", "top", "back", "left", "bottom"]
|
|
582
674
|
const sessionFaceMap = new Map()
|
|
583
675
|
const sessionColorMap = new Map()
|
|
@@ -593,6 +685,10 @@ function petHtml3D() {
|
|
|
593
685
|
let torque = { x: 0, y: 0, z: 0 }
|
|
594
686
|
let nextTorqueAt = 0
|
|
595
687
|
let wasBusy = false
|
|
688
|
+
let frictionHoldActive = false
|
|
689
|
+
let frictionHoldLevel = 0
|
|
690
|
+
let leftDragActive = false
|
|
691
|
+
let dragEmitAccumulator = 0
|
|
596
692
|
let latestDebug = { now: Date.now(), busy: 0, rotation, angularVelocity, torque, speed: 0, faceRotations: {} }
|
|
597
693
|
|
|
598
694
|
const canvas = document.getElementById("scene")
|
|
@@ -606,6 +702,11 @@ function petHtml3D() {
|
|
|
606
702
|
|
|
607
703
|
const cubeGroup = new THREE.Group()
|
|
608
704
|
scene.add(cubeGroup)
|
|
705
|
+
const dragParticleGroup = new THREE.Group()
|
|
706
|
+
dragParticleGroup.position.z = 0.88
|
|
707
|
+
scene.add(dragParticleGroup)
|
|
708
|
+
const toolParticleGroup = new THREE.Group()
|
|
709
|
+
scene.add(toolParticleGroup)
|
|
609
710
|
const iconTexture = new THREE.TextureLoader().load("${iconUrl}")
|
|
610
711
|
iconTexture.colorSpace = THREE.SRGBColorSpace
|
|
611
712
|
iconTexture.generateMipmaps = false
|
|
@@ -619,6 +720,7 @@ function petHtml3D() {
|
|
|
619
720
|
side: THREE.DoubleSide,
|
|
620
721
|
})
|
|
621
722
|
const glowTexture = createGlowTexture()
|
|
723
|
+
const dragParticleTexture = createDragParticleTexture()
|
|
622
724
|
const faceGeometry = new THREE.PlaneGeometry(0.60, 0.60)
|
|
623
725
|
const glowGeometry = new THREE.PlaneGeometry(1.18, 1.18)
|
|
624
726
|
const rad = THREE.MathUtils.degToRad
|
|
@@ -641,6 +743,247 @@ function petHtml3D() {
|
|
|
641
743
|
return texture
|
|
642
744
|
}
|
|
643
745
|
|
|
746
|
+
function createDragParticleTexture() {
|
|
747
|
+
const canvas = document.createElement("canvas")
|
|
748
|
+
canvas.width = 96
|
|
749
|
+
canvas.height = 96
|
|
750
|
+
const ctx = canvas.getContext("2d")
|
|
751
|
+
const gradient = ctx.createRadialGradient(48, 48, 1, 48, 48, 45)
|
|
752
|
+
gradient.addColorStop(0, "rgba(255,255,255,1)")
|
|
753
|
+
gradient.addColorStop(0.24, "rgba(255,255,255,1)")
|
|
754
|
+
gradient.addColorStop(0.52, "rgba(255,255,255,.52)")
|
|
755
|
+
gradient.addColorStop(1, "rgba(255,255,255,0)")
|
|
756
|
+
ctx.fillStyle = gradient
|
|
757
|
+
ctx.fillRect(0, 0, 96, 96)
|
|
758
|
+
const texture = new THREE.CanvasTexture(canvas)
|
|
759
|
+
texture.colorSpace = THREE.SRGBColorSpace
|
|
760
|
+
return texture
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const dragParticles = []
|
|
764
|
+
const toolParticles = []
|
|
765
|
+
const toolEmitAccumulators = new Map()
|
|
766
|
+
const faceVectors = {
|
|
767
|
+
front: { position: new THREE.Vector3(0, 0, 0.34), normal: new THREE.Vector3(0, 0, 1) },
|
|
768
|
+
back: { position: new THREE.Vector3(0, 0, -0.34), normal: new THREE.Vector3(0, 0, -1) },
|
|
769
|
+
right: { position: new THREE.Vector3(0.34, 0, 0), normal: new THREE.Vector3(1, 0, 0) },
|
|
770
|
+
left: { position: new THREE.Vector3(-0.34, 0, 0), normal: new THREE.Vector3(-1, 0, 0) },
|
|
771
|
+
top: { position: new THREE.Vector3(0, 0.34, 0), normal: new THREE.Vector3(0, 1, 0) },
|
|
772
|
+
bottom: { position: new THREE.Vector3(0, -0.34, 0), normal: new THREE.Vector3(0, -1, 0) },
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function createDragParticles() {
|
|
776
|
+
const geometry = new THREE.PlaneGeometry(0.086, 0.086)
|
|
777
|
+
for (let index = 0; index < 34; index++) {
|
|
778
|
+
const material = new THREE.MeshBasicMaterial({
|
|
779
|
+
map: dragParticleTexture,
|
|
780
|
+
color: new THREE.Color(1, 1, 1),
|
|
781
|
+
transparent: true,
|
|
782
|
+
opacity: 0,
|
|
783
|
+
blending: THREE.AdditiveBlending,
|
|
784
|
+
depthWrite: false,
|
|
785
|
+
side: THREE.DoubleSide,
|
|
786
|
+
})
|
|
787
|
+
const particle = new THREE.Mesh(geometry, material)
|
|
788
|
+
particle.userData = {
|
|
789
|
+
active: false,
|
|
790
|
+
life: 0,
|
|
791
|
+
maxLife: 1,
|
|
792
|
+
vx: 0,
|
|
793
|
+
vy: 0,
|
|
794
|
+
size: randomBetween(0.72, 1.28),
|
|
795
|
+
}
|
|
796
|
+
dragParticleGroup.add(particle)
|
|
797
|
+
dragParticles.push(particle)
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function createToolParticles() {
|
|
802
|
+
for (let index = 0; index < 56; index++) {
|
|
803
|
+
const material = new THREE.SpriteMaterial({
|
|
804
|
+
map: dragParticleTexture,
|
|
805
|
+
color: 0xffffff,
|
|
806
|
+
transparent: true,
|
|
807
|
+
opacity: 0,
|
|
808
|
+
blending: THREE.AdditiveBlending,
|
|
809
|
+
depthWrite: false,
|
|
810
|
+
})
|
|
811
|
+
const particle = new THREE.Sprite(material)
|
|
812
|
+
particle.userData = {
|
|
813
|
+
active: false,
|
|
814
|
+
life: 0,
|
|
815
|
+
maxLife: 1,
|
|
816
|
+
velocity: new THREE.Vector3(),
|
|
817
|
+
size: 0.06,
|
|
818
|
+
}
|
|
819
|
+
toolParticleGroup.add(particle)
|
|
820
|
+
toolParticles.push(particle)
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function colorToThree(color) {
|
|
825
|
+
return new THREE.Color((color?.r ?? 255) / 255, (color?.g ?? 255) / 255, (color?.b ?? 255) / 255)
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function emitToolParticle(faceName, color) {
|
|
829
|
+
const face = faceVectors[faceName]
|
|
830
|
+
if (!face) return false
|
|
831
|
+
const particle = toolParticles.find((item) => !item.userData.active)
|
|
832
|
+
if (!particle) return false
|
|
833
|
+
|
|
834
|
+
const origin = face.position.clone()
|
|
835
|
+
const normal = face.normal.clone()
|
|
836
|
+
cubeGroup.localToWorld(origin)
|
|
837
|
+
normal.applyQuaternion(cubeGroup.quaternion).normalize()
|
|
838
|
+
|
|
839
|
+
const basis = Math.abs(normal.y) > 0.82 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0)
|
|
840
|
+
const tangentA = new THREE.Vector3().crossVectors(normal, basis).normalize()
|
|
841
|
+
const tangentB = new THREE.Vector3().crossVectors(normal, tangentA).normalize()
|
|
842
|
+
|
|
843
|
+
const data = particle.userData
|
|
844
|
+
data.active = true
|
|
845
|
+
data.life = 0
|
|
846
|
+
data.maxLife = randomBetween(0.45, 0.78)
|
|
847
|
+
data.size = randomBetween(0.062, 0.115)
|
|
848
|
+
data.velocity.copy(normal).multiplyScalar(randomBetween(1.45, 2.35))
|
|
849
|
+
data.velocity.add(tangentA.multiplyScalar(randomBetween(-0.32, 0.32)))
|
|
850
|
+
data.velocity.add(tangentB.multiplyScalar(randomBetween(-0.32, 0.32)))
|
|
851
|
+
particle.position.copy(origin).add(normal.clone().multiplyScalar(0.035))
|
|
852
|
+
particle.scale.setScalar(data.size)
|
|
853
|
+
particle.material.color.copy(colorToThree(color))
|
|
854
|
+
particle.material.opacity = 0.92
|
|
855
|
+
return true
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function updateToolParticles(sessions, dt) {
|
|
859
|
+
const activeToolSessions = sessions.filter((session) => session.state === "busy" && session.activeTools?.length > 0)
|
|
860
|
+
const activeIDs = new Set(activeToolSessions.map((session) => session.sessionID))
|
|
861
|
+
for (const sessionID of Array.from(toolEmitAccumulators.keys())) {
|
|
862
|
+
if (!activeIDs.has(sessionID)) toolEmitAccumulators.delete(sessionID)
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
for (const session of activeToolSessions) {
|
|
866
|
+
const faceName = sessionFaceMap.get(session.sessionID)
|
|
867
|
+
if (!faceName) continue
|
|
868
|
+
const color = sessionColorMap.get(session.sessionID) || randomSessionGlowColor()
|
|
869
|
+
const jitterRate = randomBetween(7.5, 11.5)
|
|
870
|
+
const next = (toolEmitAccumulators.get(session.sessionID) || 0) + dt * jitterRate
|
|
871
|
+
let accumulator = next
|
|
872
|
+
while (accumulator >= 1) {
|
|
873
|
+
if (!emitToolParticle(faceName, color)) break
|
|
874
|
+
accumulator -= 1
|
|
875
|
+
}
|
|
876
|
+
toolEmitAccumulators.set(session.sessionID, accumulator)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
let activeCount = 0
|
|
880
|
+
for (const particle of toolParticles) {
|
|
881
|
+
const data = particle.userData
|
|
882
|
+
if (!data.active) {
|
|
883
|
+
particle.material.opacity = 0
|
|
884
|
+
continue
|
|
885
|
+
}
|
|
886
|
+
data.life += dt
|
|
887
|
+
if (data.life >= data.maxLife) {
|
|
888
|
+
data.active = false
|
|
889
|
+
particle.material.opacity = 0
|
|
890
|
+
continue
|
|
891
|
+
}
|
|
892
|
+
activeCount += 1
|
|
893
|
+
const age = data.life / data.maxLife
|
|
894
|
+
particle.position.addScaledVector(data.velocity, dt)
|
|
895
|
+
particle.scale.setScalar(data.size * (1.02 + age * 0.42))
|
|
896
|
+
particle.material.opacity = Math.min(1, 0.24 + Math.sin(age * Math.PI) * 1.18)
|
|
897
|
+
}
|
|
898
|
+
toolParticleGroup.visible = activeCount > 0 || activeToolSessions.length > 0
|
|
899
|
+
return { activeSessions: activeToolSessions.length, activeCount }
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function activeDragColors() {
|
|
903
|
+
const colors = []
|
|
904
|
+
for (const color of sessionColorMap.values()) {
|
|
905
|
+
if (color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b)) colors.push(color)
|
|
906
|
+
}
|
|
907
|
+
return colors
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function monochromeDragColor(index, cycle) {
|
|
911
|
+
const value = 0.26 + ((index % 5) / 4) * 0.56 + Math.sin(cycle * Math.PI) * 0.12
|
|
912
|
+
const clamped = Math.max(0.18, Math.min(0.92, value))
|
|
913
|
+
return { r: Math.round(clamped * 255), g: Math.round(clamped * 255), b: Math.round(clamped * 255) }
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function chooseDragParticleColor(index, palette) {
|
|
917
|
+
if (palette.length > 0) return palette[index % palette.length]
|
|
918
|
+
const value = index % 2 === 0 ? 238 : 32
|
|
919
|
+
return { r: value, g: value, b: value }
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function emitDragParticle(palette) {
|
|
923
|
+
const particle = dragParticles.find((item) => !item.userData.active)
|
|
924
|
+
if (!particle) return false
|
|
925
|
+
const data = particle.userData
|
|
926
|
+
const angle = randomBetween(0, Math.PI * 2)
|
|
927
|
+
const startRadius = randomBetween(0.16, 0.30)
|
|
928
|
+
const velocity = randomBetween(0.10, 0.20)
|
|
929
|
+
const tangent = randomBetween(-0.045, 0.045)
|
|
930
|
+
const color = chooseDragParticleColor(Math.floor(randomBetween(0, 1000)), palette)
|
|
931
|
+
data.active = true
|
|
932
|
+
data.life = 0
|
|
933
|
+
data.maxLife = randomBetween(1.15, 1.9)
|
|
934
|
+
data.vx = Math.cos(angle) * velocity + Math.cos(angle + Math.PI / 2) * tangent
|
|
935
|
+
data.vy = Math.sin(angle) * velocity + Math.sin(angle + Math.PI / 2) * tangent
|
|
936
|
+
data.size = randomBetween(0.92, 1.52)
|
|
937
|
+
particle.position.x = Math.cos(angle) * startRadius
|
|
938
|
+
particle.position.y = Math.sin(angle) * startRadius
|
|
939
|
+
particle.position.z = 0
|
|
940
|
+
particle.scale.setScalar(data.size)
|
|
941
|
+
particle.material.color.setRGB(color.r / 255, color.g / 255, color.b / 255)
|
|
942
|
+
particle.material.opacity = 0.96
|
|
943
|
+
return true
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function updateDragParticles(now, holdLevel, speed, dt) {
|
|
947
|
+
const hold = Math.max(0, Math.min(1, holdLevel / 11))
|
|
948
|
+
const intensity = hold
|
|
949
|
+
const palette = activeDragColors()
|
|
950
|
+
if (hold > 0.02) {
|
|
951
|
+
const emitRate = 7.5 - hold * 5.2
|
|
952
|
+
dragEmitAccumulator += dt * emitRate
|
|
953
|
+
while (dragEmitAccumulator >= 1) {
|
|
954
|
+
if (!emitDragParticle(palette)) break
|
|
955
|
+
dragEmitAccumulator -= 1
|
|
956
|
+
}
|
|
957
|
+
} else {
|
|
958
|
+
dragEmitAccumulator = 0
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
let activeCount = 0
|
|
962
|
+
for (const particle of dragParticles) {
|
|
963
|
+
const data = particle.userData
|
|
964
|
+
if (!data.active) {
|
|
965
|
+
particle.material.opacity = 0
|
|
966
|
+
continue
|
|
967
|
+
}
|
|
968
|
+
data.life += dt
|
|
969
|
+
if (data.life >= data.maxLife) {
|
|
970
|
+
data.active = false
|
|
971
|
+
particle.material.opacity = 0
|
|
972
|
+
continue
|
|
973
|
+
}
|
|
974
|
+
activeCount += 1
|
|
975
|
+
const age = data.life / data.maxLife
|
|
976
|
+
particle.position.x += data.vx * dt
|
|
977
|
+
particle.position.y += data.vy * dt
|
|
978
|
+
const fade = Math.sin(age * Math.PI)
|
|
979
|
+
const scale = data.size * (0.92 + age * 0.58)
|
|
980
|
+
particle.scale.setScalar(scale)
|
|
981
|
+
particle.material.opacity = Math.min(1, 0.12 + fade * 0.98)
|
|
982
|
+
}
|
|
983
|
+
dragParticleGroup.visible = activeCount > 0 || intensity > 0.01
|
|
984
|
+
return { intensity, activeCount, emitRate: hold > 0.02 ? 7.5 - hold * 5.2 : 0 }
|
|
985
|
+
}
|
|
986
|
+
|
|
644
987
|
function resize() {
|
|
645
988
|
const width = Math.max(1, window.innerWidth)
|
|
646
989
|
const height = Math.max(1, window.innerHeight)
|
|
@@ -651,6 +994,113 @@ function petHtml3D() {
|
|
|
651
994
|
window.addEventListener("resize", resize)
|
|
652
995
|
resize()
|
|
653
996
|
|
|
997
|
+
function postInteraction(event) {
|
|
998
|
+
fetch("http://${HOST}:${PORT}/interaction", {
|
|
999
|
+
method: "POST",
|
|
1000
|
+
headers: { "content-type": "application/json" },
|
|
1001
|
+
body: JSON.stringify({
|
|
1002
|
+
...event,
|
|
1003
|
+
source: "opencube-renderer",
|
|
1004
|
+
frictionHoldActive,
|
|
1005
|
+
frictionHoldLevel,
|
|
1006
|
+
frictionHoldMultiplier: 1 + frictionHoldLevel,
|
|
1007
|
+
}),
|
|
1008
|
+
}).catch(() => {})
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function pointerPayload(type, event) {
|
|
1012
|
+
return {
|
|
1013
|
+
type,
|
|
1014
|
+
nativeType: event.type,
|
|
1015
|
+
pointerId: event.pointerId,
|
|
1016
|
+
pointerType: event.pointerType,
|
|
1017
|
+
button: event.button,
|
|
1018
|
+
buttons: event.buttons,
|
|
1019
|
+
screenX: Math.round(event.screenX || 0),
|
|
1020
|
+
screenY: Math.round(event.screenY || 0),
|
|
1021
|
+
clientX: Math.round(event.clientX),
|
|
1022
|
+
clientY: Math.round(event.clientY),
|
|
1023
|
+
altKey: event.altKey,
|
|
1024
|
+
ctrlKey: event.ctrlKey,
|
|
1025
|
+
metaKey: event.metaKey,
|
|
1026
|
+
shiftKey: event.shiftKey,
|
|
1027
|
+
target: "cube",
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function beginFrictionHold(event) {
|
|
1032
|
+
if (event.button !== 2) return
|
|
1033
|
+
event.preventDefault()
|
|
1034
|
+
try { hitLayer.setPointerCapture(event.pointerId) } catch {}
|
|
1035
|
+
frictionHoldActive = true
|
|
1036
|
+
postInteraction(pointerPayload("mouse.right.down", event))
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function endFrictionHold(event) {
|
|
1040
|
+
if (event && event.button !== undefined && event.button !== 2) return
|
|
1041
|
+
if (frictionHoldActive) postInteraction(pointerPayload("mouse.right.up", event || { button: 2, buttons: 0, clientX: 0, clientY: 0 }))
|
|
1042
|
+
frictionHoldActive = false
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function beginLeftDrag(event) {
|
|
1046
|
+
if (event.button !== 0) return
|
|
1047
|
+
event.preventDefault()
|
|
1048
|
+
try { hitLayer.setPointerCapture(event.pointerId) } catch {}
|
|
1049
|
+
leftDragActive = true
|
|
1050
|
+
ipcRenderer.send("opencube-drag-start", { screenX: event.screenX, screenY: event.screenY })
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function moveLeftDrag(event) {
|
|
1054
|
+
if (!leftDragActive) return
|
|
1055
|
+
event.preventDefault()
|
|
1056
|
+
ipcRenderer.send("opencube-drag-move", { screenX: event.screenX, screenY: event.screenY })
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function endLeftDrag(event) {
|
|
1060
|
+
if (!leftDragActive) return
|
|
1061
|
+
leftDragActive = false
|
|
1062
|
+
ipcRenderer.send("opencube-drag-end")
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function updateFrictionHold(dt) {
|
|
1066
|
+
const growPerSecond = 2.4
|
|
1067
|
+
const recoverPerSecond = 8.0
|
|
1068
|
+
if (frictionHoldActive) {
|
|
1069
|
+
frictionHoldLevel = Math.min(11, frictionHoldLevel + growPerSecond * dt)
|
|
1070
|
+
} else {
|
|
1071
|
+
frictionHoldLevel = Math.max(0, frictionHoldLevel - recoverPerSecond * dt)
|
|
1072
|
+
}
|
|
1073
|
+
return 1 + frictionHoldLevel
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
hitLayer.addEventListener("pointerdown", (event) => {
|
|
1077
|
+
if (event.button === 0) beginLeftDrag(event)
|
|
1078
|
+
if (event.button === 2) beginFrictionHold(event)
|
|
1079
|
+
})
|
|
1080
|
+
hitLayer.addEventListener("pointermove", moveLeftDrag)
|
|
1081
|
+
hitLayer.addEventListener("pointerup", (event) => {
|
|
1082
|
+
if (event.button === 0) endLeftDrag(event)
|
|
1083
|
+
if (event.button === 2) endFrictionHold(event)
|
|
1084
|
+
})
|
|
1085
|
+
hitLayer.addEventListener("pointercancel", (event) => {
|
|
1086
|
+
endLeftDrag(event)
|
|
1087
|
+
endFrictionHold(event)
|
|
1088
|
+
})
|
|
1089
|
+
hitLayer.addEventListener("contextmenu", (event) => {
|
|
1090
|
+
event.preventDefault()
|
|
1091
|
+
postInteraction(pointerPayload("mouse.right.contextmenu", event))
|
|
1092
|
+
})
|
|
1093
|
+
window.addEventListener("keydown", (event) => postInteraction({ type: "keyboard.down", key: event.key, code: event.code, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey }))
|
|
1094
|
+
window.addEventListener("keyup", (event) => postInteraction({ type: "keyboard.up", key: event.key, code: event.code, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey }))
|
|
1095
|
+
document.addEventListener("mouseup", endFrictionHold)
|
|
1096
|
+
window.addEventListener("mouseup", endFrictionHold)
|
|
1097
|
+
window.addEventListener("pointerup", endFrictionHold)
|
|
1098
|
+
window.addEventListener("pointercancel", () => endFrictionHold())
|
|
1099
|
+
window.addEventListener("blur", () => {
|
|
1100
|
+
endLeftDrag()
|
|
1101
|
+
endFrictionHold()
|
|
1102
|
+
})
|
|
1103
|
+
|
|
654
1104
|
function randomBetween(min, max) {
|
|
655
1105
|
return min + Math.random() * (max - min)
|
|
656
1106
|
}
|
|
@@ -722,6 +1172,8 @@ function petHtml3D() {
|
|
|
722
1172
|
createFace("left", [-0.30, 0, 0], [0, -Math.PI / 2, rad(faceRotations.left || 0)], [-0.314, 0, 0])
|
|
723
1173
|
createFace("top", [0, 0.30, 0], [-Math.PI / 2, 0, rad(faceRotations.top || 0)], [0, 0.314, 0])
|
|
724
1174
|
createFace("bottom", [0, -0.30, 0], [Math.PI / 2, 0, rad(faceRotations.bottom || 0)], [0, -0.314, 0])
|
|
1175
|
+
createDragParticles()
|
|
1176
|
+
createToolParticles()
|
|
725
1177
|
|
|
726
1178
|
function createFace(name, position, rotation, glowPosition) {
|
|
727
1179
|
const face = new THREE.Mesh(faceGeometry, iconMaterial.clone())
|
|
@@ -949,15 +1401,20 @@ function petHtml3D() {
|
|
|
949
1401
|
const busyCount = sessions.filter((session) => session.state === "busy").length
|
|
950
1402
|
const isBusy = busyCount > 0
|
|
951
1403
|
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1404
|
+
// Keep the same randomized torque model in both busy and idle modes.
|
|
1405
|
+
// Busy uses low friction, so the cube accelerates into active motion.
|
|
1406
|
+
// Idle keeps receiving random torque, but high friction turns it into a
|
|
1407
|
+
// subtle breathing/drifting motion instead of stopping completely.
|
|
1408
|
+
if (isBusy !== wasBusy) setNextTorque(now)
|
|
1409
|
+
if (now >= nextTorqueAt) setNextTorque(now)
|
|
955
1410
|
wasBusy = isBusy
|
|
956
1411
|
stage.classList.toggle("has-busy", isBusy)
|
|
957
1412
|
stage.classList.toggle("has-sessions", sessions.length > 0)
|
|
958
1413
|
|
|
959
1414
|
const inertia = 1.18
|
|
960
|
-
const
|
|
1415
|
+
const baseFriction = isBusy ? 0.58 : 2.85
|
|
1416
|
+
const holdMultiplier = updateFrictionHold(dt)
|
|
1417
|
+
const friction = baseFriction * holdMultiplier
|
|
961
1418
|
angularVelocity.x += (torque.x / inertia) * dt
|
|
962
1419
|
angularVelocity.y += (torque.y / inertia) * dt
|
|
963
1420
|
angularVelocity.z += (torque.z / inertia) * dt
|
|
@@ -970,6 +1427,10 @@ function petHtml3D() {
|
|
|
970
1427
|
rotation.x += angularVelocity.x * dt
|
|
971
1428
|
rotation.y += angularVelocity.y * dt
|
|
972
1429
|
rotation.z += angularVelocity.z * dt
|
|
1430
|
+
cubeGroup.rotation.x = rad(rotation.x)
|
|
1431
|
+
cubeGroup.rotation.y = rad(rotation.y)
|
|
1432
|
+
cubeGroup.rotation.z = rad(rotation.z)
|
|
1433
|
+
cubeGroup.updateMatrixWorld(true)
|
|
973
1434
|
|
|
974
1435
|
const speed = magnitude(angularVelocity)
|
|
975
1436
|
const speedRatio = Math.min(1, speed / 1400)
|
|
@@ -977,20 +1438,35 @@ function petHtml3D() {
|
|
|
977
1438
|
const glowR = Math.round(92 + (0 - 92) * glow)
|
|
978
1439
|
const glowG = Math.round(255 + (190 - 255) * glow)
|
|
979
1440
|
const glowB = Math.round(232 + (210 - 232) * glow)
|
|
1441
|
+
const dragParticles = updateDragParticles(now, frictionHoldLevel, speed, dt)
|
|
980
1442
|
const busyFaces = syncBusyFaces(sessions, speed)
|
|
1443
|
+
const toolParticles = updateToolParticles(sessions, dt)
|
|
981
1444
|
processSignals(snapshot.signals || [], now)
|
|
982
1445
|
const helloFlashes = applyFlashFaces(now)
|
|
983
1446
|
renderCube()
|
|
984
1447
|
latestDebug = {
|
|
985
1448
|
now: Date.now(),
|
|
986
1449
|
busy: busyCount,
|
|
1450
|
+
mode: isBusy ? "busy" : "idle",
|
|
1451
|
+
sessions: sessions.map((session) => ({
|
|
1452
|
+
sessionID: session.sessionID,
|
|
1453
|
+
state: session.state,
|
|
1454
|
+
activeTools: session.activeTools || [],
|
|
1455
|
+
})),
|
|
987
1456
|
rotation: { ...rotation },
|
|
988
1457
|
angularVelocity: { ...angularVelocity },
|
|
989
1458
|
torque: { ...torque },
|
|
1459
|
+
friction,
|
|
1460
|
+
baseFriction,
|
|
1461
|
+
frictionHoldActive,
|
|
1462
|
+
frictionHoldMultiplier: holdMultiplier,
|
|
1463
|
+
frictionHoldLevel,
|
|
990
1464
|
speed,
|
|
991
1465
|
speedRatio,
|
|
992
1466
|
nextTorqueAt,
|
|
993
1467
|
glow,
|
|
1468
|
+
dragParticles,
|
|
1469
|
+
toolParticles,
|
|
994
1470
|
colorReleaseSpeed,
|
|
995
1471
|
glowColor: { r: glowR, g: glowG, b: glowB },
|
|
996
1472
|
faceRotations,
|
|
@@ -1059,11 +1535,9 @@ function createPetWindow() {
|
|
|
1059
1535
|
})
|
|
1060
1536
|
petWindow.setAlwaysOnTop(true, "floating")
|
|
1061
1537
|
petWindow.loadFile(writePetHtmlFile())
|
|
1062
|
-
petWindow.webContents.on("context-menu", () => buildMenu().popup({ window: petWindow }))
|
|
1063
1538
|
petWindow.on("moved", () => {
|
|
1064
1539
|
const [x, y] = petWindow.getPosition()
|
|
1065
1540
|
writeState({ visible: true, x, y })
|
|
1066
|
-
positionPanel()
|
|
1067
1541
|
})
|
|
1068
1542
|
petWindow.on("closed", () => {
|
|
1069
1543
|
petWindow = null
|
|
@@ -1076,8 +1550,8 @@ function createPanelWindow() {
|
|
|
1076
1550
|
if (panelWindow && !panelWindow.isDestroyed()) return panelWindow
|
|
1077
1551
|
|
|
1078
1552
|
panelWindow = new BrowserWindow({
|
|
1079
|
-
width:
|
|
1080
|
-
height:
|
|
1553
|
+
width: 680,
|
|
1554
|
+
height: 420,
|
|
1081
1555
|
show: false,
|
|
1082
1556
|
frame: false,
|
|
1083
1557
|
resizable: false,
|
|
@@ -1100,14 +1574,28 @@ function createPanelWindow() {
|
|
|
1100
1574
|
}
|
|
1101
1575
|
|
|
1102
1576
|
function positionPanel() {
|
|
1103
|
-
if (!
|
|
1104
|
-
const [x, y] = petWindow.getPosition()
|
|
1105
|
-
const petBounds = petWindow.getBounds()
|
|
1577
|
+
if (!panelWindow || panelWindow.isDestroyed()) return
|
|
1106
1578
|
const panelBounds = panelWindow.getBounds()
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1579
|
+
const anchor = tray && !tray.isDestroyed() ? tray.getBounds() : undefined
|
|
1580
|
+
const display = anchor
|
|
1581
|
+
? screen.getDisplayNearestPoint({ x: anchor.x, y: anchor.y }).workArea
|
|
1582
|
+
: screen.getPrimaryDisplay().workArea
|
|
1583
|
+
|
|
1584
|
+
let nextX = anchor
|
|
1585
|
+
? Math.round(anchor.x + anchor.width / 2 - panelBounds.width / 2)
|
|
1586
|
+
: display.x + display.width - panelBounds.width - 16
|
|
1587
|
+
let nextY = anchor
|
|
1588
|
+
? Math.round(anchor.y + anchor.height + 8)
|
|
1589
|
+
: display.y + 36
|
|
1590
|
+
|
|
1591
|
+
// If the menu bar reports a coordinate outside the workArea, keep the panel
|
|
1592
|
+
// as a standalone top-right popover instead of attaching it to the cube.
|
|
1593
|
+
if (!anchor || nextY < display.y || nextY > display.y + display.height) {
|
|
1594
|
+
nextX = display.x + display.width - panelBounds.width - 16
|
|
1595
|
+
nextY = display.y + 36
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
if (nextX < display.x) nextX = display.x + 8
|
|
1111
1599
|
if (nextX + panelBounds.width > display.x + display.width) nextX = display.x + display.width - panelBounds.width - 8
|
|
1112
1600
|
if (nextY + panelBounds.height > display.y + display.height) nextY = display.y + display.height - panelBounds.height - 8
|
|
1113
1601
|
if (nextY < display.y) nextY = display.y + 8
|
|
@@ -1115,7 +1603,6 @@ function positionPanel() {
|
|
|
1115
1603
|
}
|
|
1116
1604
|
|
|
1117
1605
|
function showPanel() {
|
|
1118
|
-
showPet()
|
|
1119
1606
|
const win = createPanelWindow()
|
|
1120
1607
|
updatePanel()
|
|
1121
1608
|
positionPanel()
|
|
@@ -1147,15 +1634,32 @@ function hidePet() {
|
|
|
1147
1634
|
|
|
1148
1635
|
function buildMenu() {
|
|
1149
1636
|
return Menu.buildFromTemplate([
|
|
1150
|
-
{ label: "Show
|
|
1637
|
+
{ label: "Show OpenCube", click: showPet },
|
|
1638
|
+
{ label: "Hide OpenCube", click: hidePet },
|
|
1639
|
+
{ type: "separator" },
|
|
1151
1640
|
{ label: "Show Inbox", click: showPanel },
|
|
1152
|
-
{ label: "Hide Pet", click: hidePet },
|
|
1153
1641
|
{ label: "Hide Inbox", click: hidePanel },
|
|
1154
1642
|
{ type: "separator" },
|
|
1155
|
-
{ label: "Quit
|
|
1643
|
+
{ label: "Quit OpenCube", click: () => app.quit() },
|
|
1156
1644
|
])
|
|
1157
1645
|
}
|
|
1158
1646
|
|
|
1647
|
+
function createTray() {
|
|
1648
|
+
if (tray) return tray
|
|
1649
|
+
let image = nativeImage.createFromPath(ICON_PATH)
|
|
1650
|
+
if (image.isEmpty()) {
|
|
1651
|
+
image = nativeImage.createFromDataURL(
|
|
1652
|
+
"data:image/svg+xml;utf8," +
|
|
1653
|
+
encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><rect x="4" y="4" width="24" height="24" rx="6" fill="#111"/><rect x="11" y="8" width="10" height="16" rx="2" fill="#fff"/><rect x="14" y="12" width="5" height="9" rx="1" fill="#111"/></svg>`),
|
|
1654
|
+
)
|
|
1655
|
+
}
|
|
1656
|
+
image = image.resize({ width: 18, height: 18 })
|
|
1657
|
+
tray = new Tray(image)
|
|
1658
|
+
tray.setToolTip("OpenCube")
|
|
1659
|
+
tray.setContextMenu(buildMenu())
|
|
1660
|
+
return tray
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1159
1663
|
function startServer() {
|
|
1160
1664
|
if (server) return
|
|
1161
1665
|
server = http.createServer(async (req, res) => {
|
|
@@ -1169,7 +1673,7 @@ function startServer() {
|
|
|
1169
1673
|
pid: process.pid,
|
|
1170
1674
|
port: PORT,
|
|
1171
1675
|
events: events.length,
|
|
1172
|
-
pet: "
|
|
1676
|
+
pet: "opencube",
|
|
1173
1677
|
sessions: getPetState().sessions.map(({ sessionID, state, busyIndex, idleIndex, color }) => ({
|
|
1174
1678
|
sessionID,
|
|
1175
1679
|
state,
|
|
@@ -1208,6 +1712,12 @@ function startServer() {
|
|
|
1208
1712
|
return json(res, 200, { ok: true, event: item })
|
|
1209
1713
|
}
|
|
1210
1714
|
|
|
1715
|
+
if (req.method === "POST" && url.pathname === "/interaction") {
|
|
1716
|
+
const body = await readRequestJson(req)
|
|
1717
|
+
const item = recordInteractionEvent(body)
|
|
1718
|
+
return json(res, 200, { ok: true, event: item })
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1211
1721
|
if (req.method === "POST" && url.pathname === "/show") {
|
|
1212
1722
|
showPet()
|
|
1213
1723
|
return json(res, 200, { ok: true })
|
|
@@ -1238,6 +1748,7 @@ function startServer() {
|
|
|
1238
1748
|
function start() {
|
|
1239
1749
|
app.dock?.hide()
|
|
1240
1750
|
writeState({ visible: false, mode: "floating" })
|
|
1751
|
+
createTray()
|
|
1241
1752
|
startServer()
|
|
1242
1753
|
cleanupTimer = setInterval(() => pruneIdleSessions(true), 30 * 1000)
|
|
1243
1754
|
cleanupTimer.unref?.()
|
package/src/plugin-server.cjs
CHANGED
|
@@ -47,8 +47,19 @@ function cubNotice(text, icon = CUB_ICON) {
|
|
|
47
47
|
return `${icon} ${text}`
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function summarizeToolOutput(output) {
|
|
51
|
+
if (!output || typeof output !== "object") return undefined
|
|
52
|
+
const text = typeof output.output === "string" ? output.output : undefined
|
|
53
|
+
return {
|
|
54
|
+
title: output.title,
|
|
55
|
+
output: text && text.length > 2000 ? `${text.slice(0, 2000)}…` : text,
|
|
56
|
+
outputLength: text?.length,
|
|
57
|
+
metadata: output.metadata,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
module.exports = {
|
|
51
|
-
id: "
|
|
62
|
+
id: "opencube",
|
|
52
63
|
server: async ({ client }) => {
|
|
53
64
|
const sessionStatus = new Map()
|
|
54
65
|
|
|
@@ -68,11 +79,11 @@ module.exports = {
|
|
|
68
79
|
}
|
|
69
80
|
cfg.command.pet_say_hello = {
|
|
70
81
|
template: "/pet_say_hello",
|
|
71
|
-
description: "Send a hello test event to
|
|
82
|
+
description: "Send a hello test event to OpenCube.",
|
|
72
83
|
}
|
|
73
84
|
cfg.command.pet_fancy_say_hello = {
|
|
74
85
|
template: "/pet_fancy_say_hello",
|
|
75
|
-
description: "Trigger a randomized light show on
|
|
86
|
+
description: "Trigger a randomized light show on OpenCube's free faces.",
|
|
76
87
|
}
|
|
77
88
|
},
|
|
78
89
|
|
|
@@ -87,7 +98,7 @@ module.exports = {
|
|
|
87
98
|
|
|
88
99
|
if (shouldQuit(input)) {
|
|
89
100
|
await quitPet()
|
|
90
|
-
await injectNotice(client, input.sessionID, cubNotice("
|
|
101
|
+
await injectNotice(client, input.sessionID, cubNotice("OpenCube is going to sleep 🐾", "◌"))
|
|
91
102
|
} else if (isSayHello(input)) {
|
|
92
103
|
const result = await sendEvent({
|
|
93
104
|
type: "hello",
|
|
@@ -95,12 +106,12 @@ module.exports = {
|
|
|
95
106
|
command: input.command,
|
|
96
107
|
arguments: input.arguments,
|
|
97
108
|
sessionID: input.sessionID,
|
|
98
|
-
source: "
|
|
109
|
+
source: "opencube-plugin",
|
|
99
110
|
})
|
|
100
111
|
await injectNotice(
|
|
101
112
|
client,
|
|
102
113
|
input.sessionID,
|
|
103
|
-
result ? cubNotice("
|
|
114
|
+
result ? cubNotice("OpenCube got your hello 🐾", "✦") : cubNotice("OpenCube is sleeping... zzz Use /pet to wake it.", "☾"),
|
|
104
115
|
)
|
|
105
116
|
} else if (isFancySayHello(input)) {
|
|
106
117
|
const result = await sendEvent({
|
|
@@ -109,14 +120,14 @@ module.exports = {
|
|
|
109
120
|
command: input.command,
|
|
110
121
|
arguments: input.arguments,
|
|
111
122
|
sessionID: input.sessionID,
|
|
112
|
-
source: "
|
|
123
|
+
source: "opencube-plugin",
|
|
113
124
|
})
|
|
114
125
|
await injectNotice(
|
|
115
126
|
client,
|
|
116
127
|
input.sessionID,
|
|
117
128
|
result
|
|
118
|
-
? cubNotice("
|
|
119
|
-
: cubNotice("
|
|
129
|
+
? cubNotice("OpenCube is putting on a light show ✨", "✺")
|
|
130
|
+
: cubNotice("OpenCube is sleeping... zzz Start it with /pet before the light show.", "☾"),
|
|
120
131
|
)
|
|
121
132
|
} else {
|
|
122
133
|
await showPet({
|
|
@@ -146,7 +157,7 @@ module.exports = {
|
|
|
146
157
|
sessionID,
|
|
147
158
|
status,
|
|
148
159
|
previousStatus: previous,
|
|
149
|
-
source: "
|
|
160
|
+
source: "opencube-plugin",
|
|
150
161
|
})
|
|
151
162
|
return
|
|
152
163
|
}
|
|
@@ -161,7 +172,32 @@ module.exports = {
|
|
|
161
172
|
sessionID,
|
|
162
173
|
status,
|
|
163
174
|
previousStatus: previous,
|
|
164
|
-
source: "
|
|
175
|
+
source: "opencube-plugin",
|
|
176
|
+
})
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
"tool.execute.before": async (input, output) => {
|
|
180
|
+
await sendEvent({
|
|
181
|
+
type: "tool.start",
|
|
182
|
+
message: `tool ${input.tool} started`,
|
|
183
|
+
sessionID: input.sessionID,
|
|
184
|
+
tool: input.tool,
|
|
185
|
+
callID: input.callID,
|
|
186
|
+
args: output?.args,
|
|
187
|
+
source: "opencube-plugin",
|
|
188
|
+
})
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
"tool.execute.after": async (input, output) => {
|
|
192
|
+
await sendEvent({
|
|
193
|
+
type: "tool.finish",
|
|
194
|
+
message: `tool ${input.tool} finished`,
|
|
195
|
+
sessionID: input.sessionID,
|
|
196
|
+
tool: input.tool,
|
|
197
|
+
callID: input.callID,
|
|
198
|
+
args: input.args,
|
|
199
|
+
result: summarizeToolOutput(output),
|
|
200
|
+
source: "opencube-plugin",
|
|
165
201
|
})
|
|
166
202
|
},
|
|
167
203
|
}
|
package/src/plugin-shared.cjs
CHANGED
|
@@ -30,7 +30,7 @@ async function emitProgress(onProgress, message) {
|
|
|
30
30
|
try {
|
|
31
31
|
await onProgress(message)
|
|
32
32
|
} catch {
|
|
33
|
-
// Progress is best-effort; never block
|
|
33
|
+
// Progress is best-effort; never block OpenCube startup on UI notices.
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -76,11 +76,11 @@ async function installElectronBinary(electronDir, options = {}) {
|
|
|
76
76
|
const executablePath = path.join(distPath, platformPath)
|
|
77
77
|
|
|
78
78
|
if (fs.existsSync(executablePath)) {
|
|
79
|
-
await emitProgress(options.onProgress, "
|
|
79
|
+
await emitProgress(options.onProgress, "OpenCube: Electron binary is ready ✅")
|
|
80
80
|
return executablePath
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
await emitProgress(options.onProgress, `
|
|
83
|
+
await emitProgress(options.onProgress, `OpenCube: downloading Electron ${version} for ${platform}/${arch}...`)
|
|
84
84
|
const zipPath = await downloadArtifact({
|
|
85
85
|
version,
|
|
86
86
|
artifactName: "electron",
|
|
@@ -89,19 +89,19 @@ async function installElectronBinary(electronDir, options = {}) {
|
|
|
89
89
|
platform,
|
|
90
90
|
arch,
|
|
91
91
|
})
|
|
92
|
-
await emitProgress(options.onProgress, "
|
|
92
|
+
await emitProgress(options.onProgress, "OpenCube: extracting Electron binary...")
|
|
93
93
|
await extractElectronZip(zipPath, distPath)
|
|
94
94
|
await fs.promises.writeFile(path.join(electronDir, "path.txt"), platformPath)
|
|
95
|
-
await emitProgress(options.onProgress, "
|
|
95
|
+
await emitProgress(options.onProgress, "OpenCube: Electron binary installed ✅")
|
|
96
96
|
return executablePath
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
async function resolveElectronPath(options = {}) {
|
|
100
|
-
await emitProgress(options.onProgress, "
|
|
100
|
+
await emitProgress(options.onProgress, "OpenCube: checking Electron runtime...")
|
|
101
101
|
try {
|
|
102
102
|
const electronPath = require("electron")
|
|
103
103
|
if (typeof electronPath === "string") {
|
|
104
|
-
await emitProgress(options.onProgress, "
|
|
104
|
+
await emitProgress(options.onProgress, "OpenCube: Electron runtime is ready ✅")
|
|
105
105
|
return electronPath
|
|
106
106
|
}
|
|
107
107
|
|
|
@@ -109,12 +109,12 @@ async function resolveElectronPath(options = {}) {
|
|
|
109
109
|
// environment require("electron") can resolve to Electron's built-in API
|
|
110
110
|
// object instead of the npm package's executable path string. Fall through
|
|
111
111
|
// to the npm package directory and resolve/repair the packaged binary.
|
|
112
|
-
await emitProgress(options.onProgress, "
|
|
112
|
+
await emitProgress(options.onProgress, "OpenCube: locating packaged Electron binary...")
|
|
113
113
|
const electronPackage = require.resolve("electron/package.json")
|
|
114
114
|
const electronDir = path.dirname(electronPackage)
|
|
115
115
|
return await installElectronBinary(electronDir, options)
|
|
116
116
|
} catch (error) {
|
|
117
|
-
await emitProgress(options.onProgress, "
|
|
117
|
+
await emitProgress(options.onProgress, "OpenCube: Electron runtime is incomplete; repairing...")
|
|
118
118
|
const electronPackage = require.resolve("electron/package.json")
|
|
119
119
|
const electronDir = path.dirname(electronPackage)
|
|
120
120
|
return await installElectronBinary(electronDir, options)
|
|
@@ -123,7 +123,7 @@ async function resolveElectronPath(options = {}) {
|
|
|
123
123
|
|
|
124
124
|
async function launchPet(args = [], options = {}) {
|
|
125
125
|
const electronPath = await resolveElectronPath(options)
|
|
126
|
-
await emitProgress(options.onProgress, "
|
|
126
|
+
await emitProgress(options.onProgress, "OpenCube: launching desktop pet...")
|
|
127
127
|
const child = spawn(electronPath, [PET_APP_DIR, ...args], {
|
|
128
128
|
cwd: PET_APP_DIR,
|
|
129
129
|
detached: true,
|
|
@@ -134,7 +134,7 @@ async function launchPet(args = [], options = {}) {
|
|
|
134
134
|
},
|
|
135
135
|
})
|
|
136
136
|
child.unref()
|
|
137
|
-
await emitProgress(options.onProgress, "
|
|
137
|
+
await emitProgress(options.onProgress, "OpenCube: launch request sent 🐾")
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
async function requestPet(pathname, options = {}) {
|
|
@@ -165,28 +165,28 @@ async function healthPet() {
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
async function waitForPet(timeoutMs = 3500, options = {}) {
|
|
168
|
-
await emitProgress(options.onProgress, "
|
|
168
|
+
await emitProgress(options.onProgress, "OpenCube: waiting for local server...")
|
|
169
169
|
const startedAt = Date.now()
|
|
170
170
|
while (Date.now() - startedAt < timeoutMs) {
|
|
171
171
|
const health = await healthPet()
|
|
172
172
|
if (health) {
|
|
173
|
-
await emitProgress(options.onProgress, "
|
|
173
|
+
await emitProgress(options.onProgress, "OpenCube: local server is ready ✅")
|
|
174
174
|
return health
|
|
175
175
|
}
|
|
176
176
|
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
177
177
|
}
|
|
178
|
-
await emitProgress(options.onProgress, "
|
|
178
|
+
await emitProgress(options.onProgress, "OpenCube: local server did not answer yet")
|
|
179
179
|
return undefined
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
async function ensurePet(options = {}) {
|
|
183
|
-
await emitProgress(options.onProgress, "
|
|
183
|
+
await emitProgress(options.onProgress, "OpenCube: checking whether it is already running...")
|
|
184
184
|
const existing = await healthPet()
|
|
185
185
|
if (existing) {
|
|
186
|
-
await emitProgress(options.onProgress, "
|
|
186
|
+
await emitProgress(options.onProgress, "OpenCube: already running; showing window...")
|
|
187
187
|
return existing
|
|
188
188
|
}
|
|
189
|
-
await emitProgress(options.onProgress, "
|
|
189
|
+
await emitProgress(options.onProgress, "OpenCube: not running; starting now...")
|
|
190
190
|
await launchPet(["--show"], options)
|
|
191
191
|
return await waitForPet(3500, options)
|
|
192
192
|
}
|
|
@@ -194,7 +194,7 @@ async function ensurePet(options = {}) {
|
|
|
194
194
|
async function showPet(options = {}) {
|
|
195
195
|
const health = await ensurePet(options)
|
|
196
196
|
await requestPet("/show", { method: "POST", timeoutMs: 800 })
|
|
197
|
-
await emitProgress(options.onProgress, health ? "
|
|
197
|
+
await emitProgress(options.onProgress, health ? "OpenCube: shown ✨" : "OpenCube: start requested, still warming up...")
|
|
198
198
|
return health
|
|
199
199
|
}
|
|
200
200
|
|
|
@@ -208,7 +208,7 @@ async function quitPet() {
|
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
async function sendEvent(event) {
|
|
211
|
-
// Only /pet is allowed to start
|
|
211
|
+
// Only /pet is allowed to start OpenCube. Session lifecycle events and hello
|
|
212
212
|
// commands should talk to the desktop pet only if it is already running.
|
|
213
213
|
const health = await healthPet()
|
|
214
214
|
if (!health) return undefined
|
package/src/plugin-tui.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module.exports = {
|
|
2
|
-
id: "
|
|
2
|
+
id: "opencube",
|
|
3
3
|
// Slash commands are registered from the server plugin via cfg.command, using
|
|
4
4
|
// the same command.execute.before abort trick as @slkiser/opencode-quota.
|
|
5
5
|
// Keep a no-op TUI entry so opencode can load ./tui without duplicate /pet rows.
|