opencube 0.1.1 → 0.2.1
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 +39 -3
- package/package.json +1 -1
- package/src/main.js +659 -24
- package/src/plugin-server.cjs +66 -0
package/README.md
CHANGED
|
@@ -9,7 +9,11 @@ OpenCube is a tiny desktop pet for [opencode](https://opencode.ai/).
|
|
|
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
|
|
|
@@ -48,6 +52,21 @@ You can also add it manually to `~/.config/opencode/opencode.json`:
|
|
|
48
52
|
|
|
49
53
|
These commands are handled by the plugin and do not get sent to the model.
|
|
50
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
|
+
|
|
51
70
|
## How it works
|
|
52
71
|
|
|
53
72
|
OpenCube has two parts in one npm package:
|
|
@@ -55,13 +74,28 @@ OpenCube has two parts in one npm package:
|
|
|
55
74
|
1. `src/plugin-server.cjs` — the opencode plugin entrypoint.
|
|
56
75
|
2. `src/main.js` — the Electron desktop pet.
|
|
57
76
|
|
|
58
|
-
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:
|
|
59
78
|
|
|
60
79
|
```text
|
|
61
80
|
opencode plugin -> http://127.0.0.1:47832 -> Electron OpenCube
|
|
62
81
|
```
|
|
63
82
|
|
|
64
|
-
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.
|
|
65
99
|
|
|
66
100
|
## Requirements
|
|
67
101
|
|
|
@@ -74,9 +108,11 @@ Users do not need to run `npm install` manually when installing via `opencode pl
|
|
|
74
108
|
## Notes
|
|
75
109
|
|
|
76
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.
|
|
77
112
|
- If commands do not appear after installation, restart opencode.
|
|
78
113
|
- OpenCube uses a local-only HTTP server on `127.0.0.1:47832`.
|
|
79
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.
|
|
80
116
|
|
|
81
117
|
## Local development
|
|
82
118
|
|
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")
|
|
@@ -14,15 +14,38 @@ 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()
|
|
29
|
+
let pendingPermissionsByRequest = new Map()
|
|
25
30
|
let cleanupTimer = null
|
|
31
|
+
let dragState = null
|
|
32
|
+
|
|
33
|
+
ipcMain.on("opencube-drag-start", (event, point) => {
|
|
34
|
+
if (!petWindow || petWindow.isDestroyed()) return
|
|
35
|
+
const [x, y] = petWindow.getPosition()
|
|
36
|
+
dragState = { windowX: x, windowY: y, screenX: point.screenX, screenY: point.screenY }
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
ipcMain.on("opencube-drag-move", (event, point) => {
|
|
40
|
+
if (!petWindow || petWindow.isDestroyed() || !dragState) return
|
|
41
|
+
const nextX = Math.round(dragState.windowX + point.screenX - dragState.screenX)
|
|
42
|
+
const nextY = Math.round(dragState.windowY + point.screenY - dragState.screenY)
|
|
43
|
+
petWindow.setPosition(nextX, nextY, false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
ipcMain.on("opencube-drag-end", () => {
|
|
47
|
+
dragState = null
|
|
48
|
+
})
|
|
26
49
|
|
|
27
50
|
function ensureDataDir() {
|
|
28
51
|
fs.mkdirSync(DATA_DIR, { recursive: true })
|
|
@@ -108,6 +131,8 @@ function recordEvent(event) {
|
|
|
108
131
|
...event,
|
|
109
132
|
}
|
|
110
133
|
applySessionEvent(item)
|
|
134
|
+
applyToolEvent(item)
|
|
135
|
+
applyPermissionEvent(item)
|
|
111
136
|
if (item.type === "hello" || item.type === "fancy_hello") {
|
|
112
137
|
petSignals.push({
|
|
113
138
|
id: item.id,
|
|
@@ -125,6 +150,71 @@ function recordEvent(event) {
|
|
|
125
150
|
return item
|
|
126
151
|
}
|
|
127
152
|
|
|
153
|
+
function applyPermissionEvent(event) {
|
|
154
|
+
if (!event || typeof event.sessionID !== "string") return
|
|
155
|
+
|
|
156
|
+
if (event.type === "permission.ask") {
|
|
157
|
+
const requestID = typeof event.requestID === "string" ? event.requestID : event.id
|
|
158
|
+
if (!requestID) return
|
|
159
|
+
pendingPermissionsByRequest.set(requestID, {
|
|
160
|
+
requestID,
|
|
161
|
+
sessionID: event.sessionID,
|
|
162
|
+
permission: event.permission,
|
|
163
|
+
patterns: event.patterns,
|
|
164
|
+
metadata: event.metadata,
|
|
165
|
+
always: event.always,
|
|
166
|
+
tool: event.tool,
|
|
167
|
+
askedAt: event.receivedAt || Date.now(),
|
|
168
|
+
})
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (event.type === "permission.reply") {
|
|
173
|
+
if (typeof event.requestID === "string") pendingPermissionsByRequest.delete(event.requestID)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function clearPendingPermissionsForSession(sessionID) {
|
|
178
|
+
for (const [requestID, permission] of pendingPermissionsByRequest) {
|
|
179
|
+
if (permission?.sessionID === sessionID) pendingPermissionsByRequest.delete(requestID)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function applyToolEvent(event) {
|
|
184
|
+
if (!event || typeof event.sessionID !== "string" || typeof event.callID !== "string") return
|
|
185
|
+
if (event.type !== "tool.start" && event.type !== "tool.finish") return
|
|
186
|
+
|
|
187
|
+
let tools = activeToolsBySession.get(event.sessionID)
|
|
188
|
+
if (!tools) {
|
|
189
|
+
tools = new Map()
|
|
190
|
+
activeToolsBySession.set(event.sessionID, tools)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (event.type === "tool.start") {
|
|
194
|
+
tools.set(event.callID, {
|
|
195
|
+
callID: event.callID,
|
|
196
|
+
tool: event.tool,
|
|
197
|
+
startedAt: event.receivedAt || Date.now(),
|
|
198
|
+
})
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
tools.delete(event.callID)
|
|
203
|
+
if (tools.size === 0) activeToolsBySession.delete(event.sessionID)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function recordInteractionEvent(event) {
|
|
207
|
+
const item = {
|
|
208
|
+
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
209
|
+
receivedAt: Date.now(),
|
|
210
|
+
...event,
|
|
211
|
+
}
|
|
212
|
+
interactionEvents.unshift(item)
|
|
213
|
+
interactionEvents = interactionEvents.slice(0, MAX_INTERACTION_EVENTS)
|
|
214
|
+
updatePanel()
|
|
215
|
+
return item
|
|
216
|
+
}
|
|
217
|
+
|
|
128
218
|
function applySessionEvent(event) {
|
|
129
219
|
if (!event || typeof event.sessionID !== "string") return
|
|
130
220
|
const now = event.receivedAt || Date.now()
|
|
@@ -145,6 +235,8 @@ function applySessionEvent(event) {
|
|
|
145
235
|
}
|
|
146
236
|
|
|
147
237
|
if (event.type === "session.idle") {
|
|
238
|
+
activeToolsBySession.delete(event.sessionID)
|
|
239
|
+
clearPendingPermissionsForSession(event.sessionID)
|
|
148
240
|
sessionMap.set(event.sessionID, {
|
|
149
241
|
sessionID: event.sessionID,
|
|
150
242
|
state: "idle",
|
|
@@ -165,6 +257,8 @@ function pruneIdleSessions(refresh = true) {
|
|
|
165
257
|
const expiresFrom = session.idleAt || session.lastAt
|
|
166
258
|
if (session.state === "idle" && expiresFrom && now - expiresFrom > IDLE_TTL_MS) {
|
|
167
259
|
sessionMap.delete(sessionID)
|
|
260
|
+
activeToolsBySession.delete(sessionID)
|
|
261
|
+
clearPendingPermissionsForSession(sessionID)
|
|
168
262
|
changed = true
|
|
169
263
|
}
|
|
170
264
|
}
|
|
@@ -186,6 +280,7 @@ function getPetState() {
|
|
|
186
280
|
ball: { size: 14 },
|
|
187
281
|
},
|
|
188
282
|
sessions: Array.from(sessionMap.values()).map((session, index) => ({
|
|
283
|
+
activeTools: Array.from(activeToolsBySession.get(session.sessionID)?.values() || []),
|
|
189
284
|
sessionID: session.sessionID,
|
|
190
285
|
state: session.state,
|
|
191
286
|
busyAt: session.busyAt,
|
|
@@ -198,6 +293,7 @@ function getPetState() {
|
|
|
198
293
|
idleIndex: session.state === "idle" ? idleIndex++ : undefined,
|
|
199
294
|
color: DEFAULT_SESSION_COLORS[index % DEFAULT_SESSION_COLORS.length],
|
|
200
295
|
})),
|
|
296
|
+
permissions: Array.from(pendingPermissionsByRequest.values()),
|
|
201
297
|
signals: petSignals,
|
|
202
298
|
}
|
|
203
299
|
}
|
|
@@ -230,6 +326,14 @@ function panelHtml() {
|
|
|
230
326
|
})
|
|
231
327
|
.join("")
|
|
232
328
|
|
|
329
|
+
const interactionRows = interactionEvents
|
|
330
|
+
.map((event) => {
|
|
331
|
+
const time = new Date(event.receivedAt).toLocaleTimeString()
|
|
332
|
+
const payload = JSON.stringify(event, null, 2)
|
|
333
|
+
return `<div class="event interaction"><div class="meta interaction-meta">${escapeHtml(time)} · ${escapeHtml(event.type || "interaction")}</div><pre>${escapeHtml(payload)}</pre></div>`
|
|
334
|
+
})
|
|
335
|
+
.join("")
|
|
336
|
+
|
|
233
337
|
return `<!doctype html>
|
|
234
338
|
<html>
|
|
235
339
|
<head>
|
|
@@ -239,17 +343,31 @@ function panelHtml() {
|
|
|
239
343
|
.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
344
|
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 13px; font-weight: 700; }
|
|
241
345
|
.hint { color: rgba(255,255,255,.6); font-size: 11px; font-weight: 500; }
|
|
242
|
-
.
|
|
346
|
+
.columns { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; height: 316px; }
|
|
347
|
+
.section { min-width: 0; overflow: hidden; display: flex; flex-direction: column; }
|
|
348
|
+
.section-title { display: flex; justify-content: space-between; color: rgba(255,255,255,.72); font-size: 11px; font-weight: 700; margin: 0 2px 6px; }
|
|
349
|
+
.list { flex: 1; overflow: auto; padding-right: 4px; }
|
|
243
350
|
.empty { color: rgba(255,255,255,.6); font-size: 13px; padding: 18px 4px; }
|
|
244
351
|
.event { border: 1px solid rgba(255,255,255,.12); border-radius: 10px; padding: 8px; margin-bottom: 8px; background: rgba(255,255,255,.06); }
|
|
352
|
+
.event.interaction { background: rgba(56,189,248,.08); border-color: rgba(56,189,248,.18); }
|
|
245
353
|
.meta { color: #a7f3d0; font-size: 11px; margin-bottom: 5px; }
|
|
354
|
+
.interaction-meta { color: #93c5fd; }
|
|
246
355
|
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
356
|
</style>
|
|
248
357
|
</head>
|
|
249
358
|
<body>
|
|
250
359
|
<div class="panel">
|
|
251
|
-
<div class="header"><span>
|
|
252
|
-
<div class="
|
|
360
|
+
<div class="header"><span>OpenCube inbox</span><span class="hint">events ${events.length}/${MAX_EVENTS} · input ${interactionEvents.length}/${MAX_INTERACTION_EVENTS}</span></div>
|
|
361
|
+
<div class="columns">
|
|
362
|
+
<section class="section">
|
|
363
|
+
<div class="section-title"><span>HTTP / hook events</span><span>${events.length}</span></div>
|
|
364
|
+
<div class="list">${rows || `<div class="empty">还没有收到事件。</div>`}</div>
|
|
365
|
+
</section>
|
|
366
|
+
<section class="section">
|
|
367
|
+
<div class="section-title"><span>Mouse / keyboard</span><span>${interactionEvents.length}</span></div>
|
|
368
|
+
<div class="list">${interactionRows || `<div class="empty">还没有捕捉到输入事件。</div>`}</div>
|
|
369
|
+
</section>
|
|
370
|
+
</div>
|
|
253
371
|
</div>
|
|
254
372
|
</body>
|
|
255
373
|
</html>`
|
|
@@ -548,7 +666,7 @@ function petHtml3D() {
|
|
|
548
666
|
height: 100%;
|
|
549
667
|
position: relative;
|
|
550
668
|
background: transparent;
|
|
551
|
-
-webkit-app-region: drag;
|
|
669
|
+
-webkit-app-region: no-drag;
|
|
552
670
|
user-select: none;
|
|
553
671
|
}
|
|
554
672
|
#scene {
|
|
@@ -558,11 +676,18 @@ function petHtml3D() {
|
|
|
558
676
|
height: 100%;
|
|
559
677
|
pointer-events: none;
|
|
560
678
|
}
|
|
679
|
+
#hit-layer {
|
|
680
|
+
position: absolute;
|
|
681
|
+
inset: 0;
|
|
682
|
+
background: transparent;
|
|
683
|
+
-webkit-app-region: no-drag;
|
|
684
|
+
}
|
|
561
685
|
</style>
|
|
562
686
|
</head>
|
|
563
687
|
<body>
|
|
564
688
|
<div class="stage" title="opencode pet:拖拽移动,右键打开菜单">
|
|
565
689
|
<canvas id="scene" aria-label="3D opencode pet"></canvas>
|
|
690
|
+
<div id="hit-layer" aria-label="OpenCube interaction layer"></div>
|
|
566
691
|
</div>
|
|
567
692
|
<script>
|
|
568
693
|
window.__PET_BOOT_ERROR = null
|
|
@@ -575,9 +700,11 @@ function petHtml3D() {
|
|
|
575
700
|
</script>
|
|
576
701
|
<script>
|
|
577
702
|
const THREE = require(${threeCjsPath})
|
|
703
|
+
const { ipcRenderer } = require("electron")
|
|
578
704
|
|
|
579
705
|
window.__PET_STATE = ${initialStateJson}
|
|
580
706
|
const stage = document.querySelector(".stage")
|
|
707
|
+
const hitLayer = document.getElementById("hit-layer")
|
|
581
708
|
const faceOrder = ["front", "right", "top", "back", "left", "bottom"]
|
|
582
709
|
const sessionFaceMap = new Map()
|
|
583
710
|
const sessionColorMap = new Map()
|
|
@@ -586,6 +713,7 @@ function petHtml3D() {
|
|
|
586
713
|
const colorReleaseSpeed = 90
|
|
587
714
|
const faceMeshes = new Map()
|
|
588
715
|
const glowMeshes = new Map()
|
|
716
|
+
const permissionGlowMeshes = new Map()
|
|
589
717
|
let snapshot = window.__PET_STATE || { sessions: [] }
|
|
590
718
|
let lastFrame = performance.now()
|
|
591
719
|
let rotation = { x: -14, y: -28, z: 0 }
|
|
@@ -593,6 +721,10 @@ function petHtml3D() {
|
|
|
593
721
|
let torque = { x: 0, y: 0, z: 0 }
|
|
594
722
|
let nextTorqueAt = 0
|
|
595
723
|
let wasBusy = false
|
|
724
|
+
let frictionHoldActive = false
|
|
725
|
+
let frictionHoldLevel = 0
|
|
726
|
+
let leftDragActive = false
|
|
727
|
+
let dragEmitAccumulator = 0
|
|
596
728
|
let latestDebug = { now: Date.now(), busy: 0, rotation, angularVelocity, torque, speed: 0, faceRotations: {} }
|
|
597
729
|
|
|
598
730
|
const canvas = document.getElementById("scene")
|
|
@@ -606,6 +738,11 @@ function petHtml3D() {
|
|
|
606
738
|
|
|
607
739
|
const cubeGroup = new THREE.Group()
|
|
608
740
|
scene.add(cubeGroup)
|
|
741
|
+
const dragParticleGroup = new THREE.Group()
|
|
742
|
+
dragParticleGroup.position.z = 0.88
|
|
743
|
+
scene.add(dragParticleGroup)
|
|
744
|
+
const toolParticleGroup = new THREE.Group()
|
|
745
|
+
scene.add(toolParticleGroup)
|
|
609
746
|
const iconTexture = new THREE.TextureLoader().load("${iconUrl}")
|
|
610
747
|
iconTexture.colorSpace = THREE.SRGBColorSpace
|
|
611
748
|
iconTexture.generateMipmaps = false
|
|
@@ -619,8 +756,10 @@ function petHtml3D() {
|
|
|
619
756
|
side: THREE.DoubleSide,
|
|
620
757
|
})
|
|
621
758
|
const glowTexture = createGlowTexture()
|
|
759
|
+
const dragParticleTexture = createDragParticleTexture()
|
|
622
760
|
const faceGeometry = new THREE.PlaneGeometry(0.60, 0.60)
|
|
623
761
|
const glowGeometry = new THREE.PlaneGeometry(1.18, 1.18)
|
|
762
|
+
const permissionGlowGeometry = new THREE.PlaneGeometry(1.42, 1.42)
|
|
624
763
|
const rad = THREE.MathUtils.degToRad
|
|
625
764
|
|
|
626
765
|
function createGlowTexture() {
|
|
@@ -641,6 +780,286 @@ function petHtml3D() {
|
|
|
641
780
|
return texture
|
|
642
781
|
}
|
|
643
782
|
|
|
783
|
+
function createDragParticleTexture() {
|
|
784
|
+
const canvas = document.createElement("canvas")
|
|
785
|
+
canvas.width = 96
|
|
786
|
+
canvas.height = 96
|
|
787
|
+
const ctx = canvas.getContext("2d")
|
|
788
|
+
const gradient = ctx.createRadialGradient(48, 48, 1, 48, 48, 45)
|
|
789
|
+
gradient.addColorStop(0, "rgba(255,255,255,1)")
|
|
790
|
+
gradient.addColorStop(0.24, "rgba(255,255,255,1)")
|
|
791
|
+
gradient.addColorStop(0.52, "rgba(255,255,255,.52)")
|
|
792
|
+
gradient.addColorStop(1, "rgba(255,255,255,0)")
|
|
793
|
+
ctx.fillStyle = gradient
|
|
794
|
+
ctx.fillRect(0, 0, 96, 96)
|
|
795
|
+
const texture = new THREE.CanvasTexture(canvas)
|
|
796
|
+
texture.colorSpace = THREE.SRGBColorSpace
|
|
797
|
+
return texture
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const dragParticles = []
|
|
801
|
+
const toolParticles = []
|
|
802
|
+
const toolEmitAccumulators = new Map()
|
|
803
|
+
const toolEmissionStates = new Map()
|
|
804
|
+
const toolEmissionHoldMs = 2000
|
|
805
|
+
const faceVectors = {
|
|
806
|
+
front: { position: new THREE.Vector3(0, 0, 0.34), normal: new THREE.Vector3(0, 0, 1) },
|
|
807
|
+
back: { position: new THREE.Vector3(0, 0, -0.34), normal: new THREE.Vector3(0, 0, -1) },
|
|
808
|
+
right: { position: new THREE.Vector3(0.34, 0, 0), normal: new THREE.Vector3(1, 0, 0) },
|
|
809
|
+
left: { position: new THREE.Vector3(-0.34, 0, 0), normal: new THREE.Vector3(-1, 0, 0) },
|
|
810
|
+
top: { position: new THREE.Vector3(0, 0.34, 0), normal: new THREE.Vector3(0, 1, 0) },
|
|
811
|
+
bottom: { position: new THREE.Vector3(0, -0.34, 0), normal: new THREE.Vector3(0, -1, 0) },
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function createDragParticles() {
|
|
815
|
+
const geometry = new THREE.PlaneGeometry(0.086, 0.086)
|
|
816
|
+
for (let index = 0; index < 34; index++) {
|
|
817
|
+
const material = new THREE.MeshBasicMaterial({
|
|
818
|
+
map: dragParticleTexture,
|
|
819
|
+
color: new THREE.Color(1, 1, 1),
|
|
820
|
+
transparent: true,
|
|
821
|
+
opacity: 0,
|
|
822
|
+
blending: THREE.AdditiveBlending,
|
|
823
|
+
depthWrite: false,
|
|
824
|
+
side: THREE.DoubleSide,
|
|
825
|
+
})
|
|
826
|
+
const particle = new THREE.Mesh(geometry, material)
|
|
827
|
+
particle.userData = {
|
|
828
|
+
active: false,
|
|
829
|
+
life: 0,
|
|
830
|
+
maxLife: 1,
|
|
831
|
+
vx: 0,
|
|
832
|
+
vy: 0,
|
|
833
|
+
size: randomBetween(0.72, 1.28),
|
|
834
|
+
}
|
|
835
|
+
dragParticleGroup.add(particle)
|
|
836
|
+
dragParticles.push(particle)
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function createToolParticles() {
|
|
841
|
+
for (let index = 0; index < 56; index++) {
|
|
842
|
+
const material = new THREE.SpriteMaterial({
|
|
843
|
+
map: dragParticleTexture,
|
|
844
|
+
color: 0xffffff,
|
|
845
|
+
transparent: true,
|
|
846
|
+
opacity: 0,
|
|
847
|
+
blending: THREE.AdditiveBlending,
|
|
848
|
+
depthWrite: false,
|
|
849
|
+
})
|
|
850
|
+
const particle = new THREE.Sprite(material)
|
|
851
|
+
particle.userData = {
|
|
852
|
+
active: false,
|
|
853
|
+
life: 0,
|
|
854
|
+
maxLife: 1,
|
|
855
|
+
velocity: new THREE.Vector3(),
|
|
856
|
+
size: 0.06,
|
|
857
|
+
}
|
|
858
|
+
toolParticleGroup.add(particle)
|
|
859
|
+
toolParticles.push(particle)
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function colorToThree(color) {
|
|
864
|
+
return new THREE.Color((color?.r ?? 255) / 255, (color?.g ?? 255) / 255, (color?.b ?? 255) / 255)
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function emitToolParticle(faceName, color) {
|
|
868
|
+
const face = faceVectors[faceName]
|
|
869
|
+
if (!face) return false
|
|
870
|
+
const particle = toolParticles.find((item) => !item.userData.active)
|
|
871
|
+
if (!particle) return false
|
|
872
|
+
|
|
873
|
+
const origin = face.position.clone()
|
|
874
|
+
const normal = face.normal.clone()
|
|
875
|
+
cubeGroup.localToWorld(origin)
|
|
876
|
+
normal.applyQuaternion(cubeGroup.quaternion).normalize()
|
|
877
|
+
|
|
878
|
+
const basis = Math.abs(normal.y) > 0.82 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0)
|
|
879
|
+
const tangentA = new THREE.Vector3().crossVectors(normal, basis).normalize()
|
|
880
|
+
const tangentB = new THREE.Vector3().crossVectors(normal, tangentA).normalize()
|
|
881
|
+
|
|
882
|
+
const data = particle.userData
|
|
883
|
+
data.active = true
|
|
884
|
+
data.life = 0
|
|
885
|
+
data.maxLife = randomBetween(0.45, 0.78)
|
|
886
|
+
data.size = randomBetween(0.062, 0.115)
|
|
887
|
+
data.velocity.copy(normal).multiplyScalar(randomBetween(1.45, 2.35))
|
|
888
|
+
data.velocity.add(tangentA.multiplyScalar(randomBetween(-0.32, 0.32)))
|
|
889
|
+
data.velocity.add(tangentB.multiplyScalar(randomBetween(-0.32, 0.32)))
|
|
890
|
+
particle.position.copy(origin).add(normal.clone().multiplyScalar(0.035))
|
|
891
|
+
particle.scale.setScalar(data.size)
|
|
892
|
+
particle.material.color.copy(colorToThree(color))
|
|
893
|
+
particle.material.opacity = 0.92
|
|
894
|
+
return true
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function updateToolParticles(sessions, dt, now) {
|
|
898
|
+
const activeIDs = new Set()
|
|
899
|
+
const emitters = []
|
|
900
|
+
const busyIDs = new Set(
|
|
901
|
+
sessions
|
|
902
|
+
.filter((session) => session?.state === "busy" && typeof session.sessionID === "string")
|
|
903
|
+
.map((session) => session.sessionID),
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
for (const session of sessions) {
|
|
907
|
+
if (session?.state !== "busy" || typeof session.sessionID !== "string" || !session.activeTools?.length) continue
|
|
908
|
+
const currentState = toolEmissionStates.get(session.sessionID)
|
|
909
|
+
const faceName = sessionFaceMap.get(session.sessionID) || currentState?.faceName
|
|
910
|
+
if (!faceName) continue
|
|
911
|
+
const color = sessionColorMap.get(session.sessionID) || currentState?.color || randomSessionGlowColor()
|
|
912
|
+
|
|
913
|
+
activeIDs.add(session.sessionID)
|
|
914
|
+
toolEmissionStates.set(session.sessionID, {
|
|
915
|
+
faceName,
|
|
916
|
+
color,
|
|
917
|
+
holdUntil: now + toolEmissionHoldMs,
|
|
918
|
+
})
|
|
919
|
+
emitters.push({ sessionID: session.sessionID, faceName, color, held: false, holdRemainingMs: toolEmissionHoldMs })
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
for (const [sessionID, state] of Array.from(toolEmissionStates.entries())) {
|
|
923
|
+
if (activeIDs.has(sessionID)) continue
|
|
924
|
+
if (!busyIDs.has(sessionID) || !state?.faceName || state.holdUntil <= now) {
|
|
925
|
+
toolEmissionStates.delete(sessionID)
|
|
926
|
+
toolEmitAccumulators.delete(sessionID)
|
|
927
|
+
continue
|
|
928
|
+
}
|
|
929
|
+
emitters.push({
|
|
930
|
+
sessionID,
|
|
931
|
+
faceName: state.faceName,
|
|
932
|
+
color: state.color || randomSessionGlowColor(),
|
|
933
|
+
held: true,
|
|
934
|
+
holdRemainingMs: state.holdUntil - now,
|
|
935
|
+
})
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const emittingIDs = new Set(emitters.map((emitter) => emitter.sessionID))
|
|
939
|
+
for (const sessionID of Array.from(toolEmitAccumulators.keys())) {
|
|
940
|
+
if (!emittingIDs.has(sessionID)) toolEmitAccumulators.delete(sessionID)
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
for (const emitter of emitters) {
|
|
944
|
+
const jitterRate = randomBetween(7.5, 11.5)
|
|
945
|
+
const next = (toolEmitAccumulators.get(emitter.sessionID) || 0) + dt * jitterRate
|
|
946
|
+
let accumulator = next
|
|
947
|
+
while (accumulator >= 1) {
|
|
948
|
+
if (!emitToolParticle(emitter.faceName, emitter.color)) break
|
|
949
|
+
accumulator -= 1
|
|
950
|
+
}
|
|
951
|
+
toolEmitAccumulators.set(emitter.sessionID, accumulator)
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
let activeCount = 0
|
|
955
|
+
for (const particle of toolParticles) {
|
|
956
|
+
const data = particle.userData
|
|
957
|
+
if (!data.active) {
|
|
958
|
+
particle.material.opacity = 0
|
|
959
|
+
continue
|
|
960
|
+
}
|
|
961
|
+
data.life += dt
|
|
962
|
+
if (data.life >= data.maxLife) {
|
|
963
|
+
data.active = false
|
|
964
|
+
particle.material.opacity = 0
|
|
965
|
+
continue
|
|
966
|
+
}
|
|
967
|
+
activeCount += 1
|
|
968
|
+
const age = data.life / data.maxLife
|
|
969
|
+
particle.position.addScaledVector(data.velocity, dt)
|
|
970
|
+
particle.scale.setScalar(data.size * (1.02 + age * 0.42))
|
|
971
|
+
particle.material.opacity = Math.min(1, 0.24 + Math.sin(age * Math.PI) * 1.18)
|
|
972
|
+
}
|
|
973
|
+
const heldSessions = emitters.filter((emitter) => emitter.held).length
|
|
974
|
+
toolParticleGroup.visible = activeCount > 0 || emitters.length > 0
|
|
975
|
+
return { activeSessions: activeIDs.size, emittingSessions: emitters.length, heldSessions, activeCount }
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function activeDragColors() {
|
|
979
|
+
const colors = []
|
|
980
|
+
for (const color of sessionColorMap.values()) {
|
|
981
|
+
if (color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b)) colors.push(color)
|
|
982
|
+
}
|
|
983
|
+
return colors
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function monochromeDragColor(index, cycle) {
|
|
987
|
+
const value = 0.26 + ((index % 5) / 4) * 0.56 + Math.sin(cycle * Math.PI) * 0.12
|
|
988
|
+
const clamped = Math.max(0.18, Math.min(0.92, value))
|
|
989
|
+
return { r: Math.round(clamped * 255), g: Math.round(clamped * 255), b: Math.round(clamped * 255) }
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function chooseDragParticleColor(index, palette) {
|
|
993
|
+
if (palette.length > 0) return palette[index % palette.length]
|
|
994
|
+
const value = index % 2 === 0 ? 238 : 32
|
|
995
|
+
return { r: value, g: value, b: value }
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function emitDragParticle(palette) {
|
|
999
|
+
const particle = dragParticles.find((item) => !item.userData.active)
|
|
1000
|
+
if (!particle) return false
|
|
1001
|
+
const data = particle.userData
|
|
1002
|
+
const angle = randomBetween(0, Math.PI * 2)
|
|
1003
|
+
const startRadius = randomBetween(0.16, 0.30)
|
|
1004
|
+
const velocity = randomBetween(0.10, 0.20)
|
|
1005
|
+
const tangent = randomBetween(-0.045, 0.045)
|
|
1006
|
+
const color = chooseDragParticleColor(Math.floor(randomBetween(0, 1000)), palette)
|
|
1007
|
+
data.active = true
|
|
1008
|
+
data.life = 0
|
|
1009
|
+
data.maxLife = randomBetween(1.15, 1.9)
|
|
1010
|
+
data.vx = Math.cos(angle) * velocity + Math.cos(angle + Math.PI / 2) * tangent
|
|
1011
|
+
data.vy = Math.sin(angle) * velocity + Math.sin(angle + Math.PI / 2) * tangent
|
|
1012
|
+
data.size = randomBetween(0.92, 1.52)
|
|
1013
|
+
particle.position.x = Math.cos(angle) * startRadius
|
|
1014
|
+
particle.position.y = Math.sin(angle) * startRadius
|
|
1015
|
+
particle.position.z = 0
|
|
1016
|
+
particle.scale.setScalar(data.size)
|
|
1017
|
+
particle.material.color.setRGB(color.r / 255, color.g / 255, color.b / 255)
|
|
1018
|
+
particle.material.opacity = 0.96
|
|
1019
|
+
return true
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function updateDragParticles(now, holdLevel, speed, dt) {
|
|
1023
|
+
const hold = Math.max(0, Math.min(1, holdLevel / 11))
|
|
1024
|
+
const intensity = hold
|
|
1025
|
+
const palette = activeDragColors()
|
|
1026
|
+
if (hold > 0.02) {
|
|
1027
|
+
const emitRate = 7.5 - hold * 5.2
|
|
1028
|
+
dragEmitAccumulator += dt * emitRate
|
|
1029
|
+
while (dragEmitAccumulator >= 1) {
|
|
1030
|
+
if (!emitDragParticle(palette)) break
|
|
1031
|
+
dragEmitAccumulator -= 1
|
|
1032
|
+
}
|
|
1033
|
+
} else {
|
|
1034
|
+
dragEmitAccumulator = 0
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
let activeCount = 0
|
|
1038
|
+
for (const particle of dragParticles) {
|
|
1039
|
+
const data = particle.userData
|
|
1040
|
+
if (!data.active) {
|
|
1041
|
+
particle.material.opacity = 0
|
|
1042
|
+
continue
|
|
1043
|
+
}
|
|
1044
|
+
data.life += dt
|
|
1045
|
+
if (data.life >= data.maxLife) {
|
|
1046
|
+
data.active = false
|
|
1047
|
+
particle.material.opacity = 0
|
|
1048
|
+
continue
|
|
1049
|
+
}
|
|
1050
|
+
activeCount += 1
|
|
1051
|
+
const age = data.life / data.maxLife
|
|
1052
|
+
particle.position.x += data.vx * dt
|
|
1053
|
+
particle.position.y += data.vy * dt
|
|
1054
|
+
const fade = Math.sin(age * Math.PI)
|
|
1055
|
+
const scale = data.size * (0.92 + age * 0.58)
|
|
1056
|
+
particle.scale.setScalar(scale)
|
|
1057
|
+
particle.material.opacity = Math.min(1, 0.12 + fade * 0.98)
|
|
1058
|
+
}
|
|
1059
|
+
dragParticleGroup.visible = activeCount > 0 || intensity > 0.01
|
|
1060
|
+
return { intensity, activeCount, emitRate: hold > 0.02 ? 7.5 - hold * 5.2 : 0 }
|
|
1061
|
+
}
|
|
1062
|
+
|
|
644
1063
|
function resize() {
|
|
645
1064
|
const width = Math.max(1, window.innerWidth)
|
|
646
1065
|
const height = Math.max(1, window.innerHeight)
|
|
@@ -651,6 +1070,113 @@ function petHtml3D() {
|
|
|
651
1070
|
window.addEventListener("resize", resize)
|
|
652
1071
|
resize()
|
|
653
1072
|
|
|
1073
|
+
function postInteraction(event) {
|
|
1074
|
+
fetch("http://${HOST}:${PORT}/interaction", {
|
|
1075
|
+
method: "POST",
|
|
1076
|
+
headers: { "content-type": "application/json" },
|
|
1077
|
+
body: JSON.stringify({
|
|
1078
|
+
...event,
|
|
1079
|
+
source: "opencube-renderer",
|
|
1080
|
+
frictionHoldActive,
|
|
1081
|
+
frictionHoldLevel,
|
|
1082
|
+
frictionHoldMultiplier: 1 + frictionHoldLevel,
|
|
1083
|
+
}),
|
|
1084
|
+
}).catch(() => {})
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function pointerPayload(type, event) {
|
|
1088
|
+
return {
|
|
1089
|
+
type,
|
|
1090
|
+
nativeType: event.type,
|
|
1091
|
+
pointerId: event.pointerId,
|
|
1092
|
+
pointerType: event.pointerType,
|
|
1093
|
+
button: event.button,
|
|
1094
|
+
buttons: event.buttons,
|
|
1095
|
+
screenX: Math.round(event.screenX || 0),
|
|
1096
|
+
screenY: Math.round(event.screenY || 0),
|
|
1097
|
+
clientX: Math.round(event.clientX),
|
|
1098
|
+
clientY: Math.round(event.clientY),
|
|
1099
|
+
altKey: event.altKey,
|
|
1100
|
+
ctrlKey: event.ctrlKey,
|
|
1101
|
+
metaKey: event.metaKey,
|
|
1102
|
+
shiftKey: event.shiftKey,
|
|
1103
|
+
target: "cube",
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function beginFrictionHold(event) {
|
|
1108
|
+
if (event.button !== 2) return
|
|
1109
|
+
event.preventDefault()
|
|
1110
|
+
try { hitLayer.setPointerCapture(event.pointerId) } catch {}
|
|
1111
|
+
frictionHoldActive = true
|
|
1112
|
+
postInteraction(pointerPayload("mouse.right.down", event))
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function endFrictionHold(event) {
|
|
1116
|
+
if (event && event.button !== undefined && event.button !== 2) return
|
|
1117
|
+
if (frictionHoldActive) postInteraction(pointerPayload("mouse.right.up", event || { button: 2, buttons: 0, clientX: 0, clientY: 0 }))
|
|
1118
|
+
frictionHoldActive = false
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function beginLeftDrag(event) {
|
|
1122
|
+
if (event.button !== 0) return
|
|
1123
|
+
event.preventDefault()
|
|
1124
|
+
try { hitLayer.setPointerCapture(event.pointerId) } catch {}
|
|
1125
|
+
leftDragActive = true
|
|
1126
|
+
ipcRenderer.send("opencube-drag-start", { screenX: event.screenX, screenY: event.screenY })
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function moveLeftDrag(event) {
|
|
1130
|
+
if (!leftDragActive) return
|
|
1131
|
+
event.preventDefault()
|
|
1132
|
+
ipcRenderer.send("opencube-drag-move", { screenX: event.screenX, screenY: event.screenY })
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function endLeftDrag(event) {
|
|
1136
|
+
if (!leftDragActive) return
|
|
1137
|
+
leftDragActive = false
|
|
1138
|
+
ipcRenderer.send("opencube-drag-end")
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function updateFrictionHold(dt) {
|
|
1142
|
+
const growPerSecond = 2.4
|
|
1143
|
+
const recoverPerSecond = 8.0
|
|
1144
|
+
if (frictionHoldActive) {
|
|
1145
|
+
frictionHoldLevel = Math.min(11, frictionHoldLevel + growPerSecond * dt)
|
|
1146
|
+
} else {
|
|
1147
|
+
frictionHoldLevel = Math.max(0, frictionHoldLevel - recoverPerSecond * dt)
|
|
1148
|
+
}
|
|
1149
|
+
return 1 + frictionHoldLevel
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
hitLayer.addEventListener("pointerdown", (event) => {
|
|
1153
|
+
if (event.button === 0) beginLeftDrag(event)
|
|
1154
|
+
if (event.button === 2) beginFrictionHold(event)
|
|
1155
|
+
})
|
|
1156
|
+
hitLayer.addEventListener("pointermove", moveLeftDrag)
|
|
1157
|
+
hitLayer.addEventListener("pointerup", (event) => {
|
|
1158
|
+
if (event.button === 0) endLeftDrag(event)
|
|
1159
|
+
if (event.button === 2) endFrictionHold(event)
|
|
1160
|
+
})
|
|
1161
|
+
hitLayer.addEventListener("pointercancel", (event) => {
|
|
1162
|
+
endLeftDrag(event)
|
|
1163
|
+
endFrictionHold(event)
|
|
1164
|
+
})
|
|
1165
|
+
hitLayer.addEventListener("contextmenu", (event) => {
|
|
1166
|
+
event.preventDefault()
|
|
1167
|
+
postInteraction(pointerPayload("mouse.right.contextmenu", event))
|
|
1168
|
+
})
|
|
1169
|
+
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 }))
|
|
1170
|
+
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 }))
|
|
1171
|
+
document.addEventListener("mouseup", endFrictionHold)
|
|
1172
|
+
window.addEventListener("mouseup", endFrictionHold)
|
|
1173
|
+
window.addEventListener("pointerup", endFrictionHold)
|
|
1174
|
+
window.addEventListener("pointercancel", () => endFrictionHold())
|
|
1175
|
+
window.addEventListener("blur", () => {
|
|
1176
|
+
endLeftDrag()
|
|
1177
|
+
endFrictionHold()
|
|
1178
|
+
})
|
|
1179
|
+
|
|
654
1180
|
function randomBetween(min, max) {
|
|
655
1181
|
return min + Math.random() * (max - min)
|
|
656
1182
|
}
|
|
@@ -722,6 +1248,8 @@ function petHtml3D() {
|
|
|
722
1248
|
createFace("left", [-0.30, 0, 0], [0, -Math.PI / 2, rad(faceRotations.left || 0)], [-0.314, 0, 0])
|
|
723
1249
|
createFace("top", [0, 0.30, 0], [-Math.PI / 2, 0, rad(faceRotations.top || 0)], [0, 0.314, 0])
|
|
724
1250
|
createFace("bottom", [0, -0.30, 0], [Math.PI / 2, 0, rad(faceRotations.bottom || 0)], [0, -0.314, 0])
|
|
1251
|
+
createDragParticles()
|
|
1252
|
+
createToolParticles()
|
|
725
1253
|
|
|
726
1254
|
function createFace(name, position, rotation, glowPosition) {
|
|
727
1255
|
const face = new THREE.Mesh(faceGeometry, iconMaterial.clone())
|
|
@@ -746,6 +1274,23 @@ function petHtml3D() {
|
|
|
746
1274
|
glow.rotation.set(...rotation)
|
|
747
1275
|
cubeGroup.add(glow)
|
|
748
1276
|
glowMeshes.set(name, glow)
|
|
1277
|
+
|
|
1278
|
+
const permissionGlow = new THREE.Mesh(
|
|
1279
|
+
permissionGlowGeometry,
|
|
1280
|
+
new THREE.MeshBasicMaterial({
|
|
1281
|
+
map: glowTexture,
|
|
1282
|
+
color: 0xff2448,
|
|
1283
|
+
transparent: true,
|
|
1284
|
+
opacity: 0,
|
|
1285
|
+
blending: THREE.AdditiveBlending,
|
|
1286
|
+
depthWrite: false,
|
|
1287
|
+
side: THREE.DoubleSide,
|
|
1288
|
+
}),
|
|
1289
|
+
)
|
|
1290
|
+
permissionGlow.position.set(...glowPosition.map((value) => value === 0 ? 0 : value * 1.055))
|
|
1291
|
+
permissionGlow.rotation.set(...rotation)
|
|
1292
|
+
cubeGroup.add(permissionGlow)
|
|
1293
|
+
permissionGlowMeshes.set(name, permissionGlow)
|
|
749
1294
|
}
|
|
750
1295
|
|
|
751
1296
|
function magnitude(vector) {
|
|
@@ -932,6 +1477,35 @@ function petHtml3D() {
|
|
|
932
1477
|
return active
|
|
933
1478
|
}
|
|
934
1479
|
|
|
1480
|
+
function applyPermissionGlowFaces(permissions, now) {
|
|
1481
|
+
const pendingSessionIDs = new Set(
|
|
1482
|
+
(permissions || [])
|
|
1483
|
+
.map((permission) => permission?.sessionID)
|
|
1484
|
+
.filter((sessionID) => typeof sessionID === "string"),
|
|
1485
|
+
)
|
|
1486
|
+
const active = {}
|
|
1487
|
+
const wave = (Math.sin(now * 0.0095) + 1) / 2
|
|
1488
|
+
const pulse = Math.pow(wave, 1.85)
|
|
1489
|
+
|
|
1490
|
+
for (const faceName of faceOrder) {
|
|
1491
|
+
const permissionGlow = permissionGlowMeshes.get(faceName)
|
|
1492
|
+
if (!permissionGlow) continue
|
|
1493
|
+
|
|
1494
|
+
const sessionID = Array.from(pendingSessionIDs).find((id) => sessionFaceMap.get(id) === faceName)
|
|
1495
|
+
if (!sessionID) {
|
|
1496
|
+
permissionGlow.material.opacity = 0
|
|
1497
|
+
continue
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
permissionGlow.material.color.setRGB(1, 0.08, 0.16)
|
|
1501
|
+
permissionGlow.material.opacity = 0.16 + pulse * 0.62
|
|
1502
|
+
permissionGlow.scale.setScalar(1.00 + pulse * 0.34)
|
|
1503
|
+
active[faceName] = { sessionID, pulse, pending: true }
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
return active
|
|
1507
|
+
}
|
|
1508
|
+
|
|
935
1509
|
window.__setPetState = setSnapshot
|
|
936
1510
|
window.__getPetDebug = () => latestDebug
|
|
937
1511
|
|
|
@@ -949,15 +1523,20 @@ function petHtml3D() {
|
|
|
949
1523
|
const busyCount = sessions.filter((session) => session.state === "busy").length
|
|
950
1524
|
const isBusy = busyCount > 0
|
|
951
1525
|
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1526
|
+
// Keep the same randomized torque model in both busy and idle modes.
|
|
1527
|
+
// Busy uses low friction, so the cube accelerates into active motion.
|
|
1528
|
+
// Idle keeps receiving random torque, but high friction turns it into a
|
|
1529
|
+
// subtle breathing/drifting motion instead of stopping completely.
|
|
1530
|
+
if (isBusy !== wasBusy) setNextTorque(now)
|
|
1531
|
+
if (now >= nextTorqueAt) setNextTorque(now)
|
|
955
1532
|
wasBusy = isBusy
|
|
956
1533
|
stage.classList.toggle("has-busy", isBusy)
|
|
957
1534
|
stage.classList.toggle("has-sessions", sessions.length > 0)
|
|
958
1535
|
|
|
959
1536
|
const inertia = 1.18
|
|
960
|
-
const
|
|
1537
|
+
const baseFriction = isBusy ? 0.58 : 2.85
|
|
1538
|
+
const holdMultiplier = updateFrictionHold(dt)
|
|
1539
|
+
const friction = baseFriction * holdMultiplier
|
|
961
1540
|
angularVelocity.x += (torque.x / inertia) * dt
|
|
962
1541
|
angularVelocity.y += (torque.y / inertia) * dt
|
|
963
1542
|
angularVelocity.z += (torque.z / inertia) * dt
|
|
@@ -970,6 +1549,10 @@ function petHtml3D() {
|
|
|
970
1549
|
rotation.x += angularVelocity.x * dt
|
|
971
1550
|
rotation.y += angularVelocity.y * dt
|
|
972
1551
|
rotation.z += angularVelocity.z * dt
|
|
1552
|
+
cubeGroup.rotation.x = rad(rotation.x)
|
|
1553
|
+
cubeGroup.rotation.y = rad(rotation.y)
|
|
1554
|
+
cubeGroup.rotation.z = rad(rotation.z)
|
|
1555
|
+
cubeGroup.updateMatrixWorld(true)
|
|
973
1556
|
|
|
974
1557
|
const speed = magnitude(angularVelocity)
|
|
975
1558
|
const speedRatio = Math.min(1, speed / 1400)
|
|
@@ -977,25 +1560,42 @@ function petHtml3D() {
|
|
|
977
1560
|
const glowR = Math.round(92 + (0 - 92) * glow)
|
|
978
1561
|
const glowG = Math.round(255 + (190 - 255) * glow)
|
|
979
1562
|
const glowB = Math.round(232 + (210 - 232) * glow)
|
|
1563
|
+
const dragParticles = updateDragParticles(now, frictionHoldLevel, speed, dt)
|
|
980
1564
|
const busyFaces = syncBusyFaces(sessions, speed)
|
|
1565
|
+
const toolParticles = updateToolParticles(sessions, dt, now)
|
|
981
1566
|
processSignals(snapshot.signals || [], now)
|
|
982
1567
|
const helloFlashes = applyFlashFaces(now)
|
|
1568
|
+
const permissionGlows = applyPermissionGlowFaces(snapshot.permissions || [], now)
|
|
983
1569
|
renderCube()
|
|
984
1570
|
latestDebug = {
|
|
985
1571
|
now: Date.now(),
|
|
986
1572
|
busy: busyCount,
|
|
1573
|
+
mode: isBusy ? "busy" : "idle",
|
|
1574
|
+
sessions: sessions.map((session) => ({
|
|
1575
|
+
sessionID: session.sessionID,
|
|
1576
|
+
state: session.state,
|
|
1577
|
+
activeTools: session.activeTools || [],
|
|
1578
|
+
})),
|
|
987
1579
|
rotation: { ...rotation },
|
|
988
1580
|
angularVelocity: { ...angularVelocity },
|
|
989
1581
|
torque: { ...torque },
|
|
1582
|
+
friction,
|
|
1583
|
+
baseFriction,
|
|
1584
|
+
frictionHoldActive,
|
|
1585
|
+
frictionHoldMultiplier: holdMultiplier,
|
|
1586
|
+
frictionHoldLevel,
|
|
990
1587
|
speed,
|
|
991
1588
|
speedRatio,
|
|
992
1589
|
nextTorqueAt,
|
|
993
1590
|
glow,
|
|
1591
|
+
dragParticles,
|
|
1592
|
+
toolParticles,
|
|
994
1593
|
colorReleaseSpeed,
|
|
995
1594
|
glowColor: { r: glowR, g: glowG, b: glowB },
|
|
996
1595
|
faceRotations,
|
|
997
1596
|
busyFaces,
|
|
998
1597
|
helloFlashes,
|
|
1598
|
+
permissionGlows,
|
|
999
1599
|
}
|
|
1000
1600
|
requestAnimationFrame(tick)
|
|
1001
1601
|
}
|
|
@@ -1059,11 +1659,9 @@ function createPetWindow() {
|
|
|
1059
1659
|
})
|
|
1060
1660
|
petWindow.setAlwaysOnTop(true, "floating")
|
|
1061
1661
|
petWindow.loadFile(writePetHtmlFile())
|
|
1062
|
-
petWindow.webContents.on("context-menu", () => buildMenu().popup({ window: petWindow }))
|
|
1063
1662
|
petWindow.on("moved", () => {
|
|
1064
1663
|
const [x, y] = petWindow.getPosition()
|
|
1065
1664
|
writeState({ visible: true, x, y })
|
|
1066
|
-
positionPanel()
|
|
1067
1665
|
})
|
|
1068
1666
|
petWindow.on("closed", () => {
|
|
1069
1667
|
petWindow = null
|
|
@@ -1076,8 +1674,8 @@ function createPanelWindow() {
|
|
|
1076
1674
|
if (panelWindow && !panelWindow.isDestroyed()) return panelWindow
|
|
1077
1675
|
|
|
1078
1676
|
panelWindow = new BrowserWindow({
|
|
1079
|
-
width:
|
|
1080
|
-
height:
|
|
1677
|
+
width: 680,
|
|
1678
|
+
height: 420,
|
|
1081
1679
|
show: false,
|
|
1082
1680
|
frame: false,
|
|
1083
1681
|
resizable: false,
|
|
@@ -1100,14 +1698,28 @@ function createPanelWindow() {
|
|
|
1100
1698
|
}
|
|
1101
1699
|
|
|
1102
1700
|
function positionPanel() {
|
|
1103
|
-
if (!
|
|
1104
|
-
const [x, y] = petWindow.getPosition()
|
|
1105
|
-
const petBounds = petWindow.getBounds()
|
|
1701
|
+
if (!panelWindow || panelWindow.isDestroyed()) return
|
|
1106
1702
|
const panelBounds = panelWindow.getBounds()
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1703
|
+
const anchor = tray && !tray.isDestroyed() ? tray.getBounds() : undefined
|
|
1704
|
+
const display = anchor
|
|
1705
|
+
? screen.getDisplayNearestPoint({ x: anchor.x, y: anchor.y }).workArea
|
|
1706
|
+
: screen.getPrimaryDisplay().workArea
|
|
1707
|
+
|
|
1708
|
+
let nextX = anchor
|
|
1709
|
+
? Math.round(anchor.x + anchor.width / 2 - panelBounds.width / 2)
|
|
1710
|
+
: display.x + display.width - panelBounds.width - 16
|
|
1711
|
+
let nextY = anchor
|
|
1712
|
+
? Math.round(anchor.y + anchor.height + 8)
|
|
1713
|
+
: display.y + 36
|
|
1714
|
+
|
|
1715
|
+
// If the menu bar reports a coordinate outside the workArea, keep the panel
|
|
1716
|
+
// as a standalone top-right popover instead of attaching it to the cube.
|
|
1717
|
+
if (!anchor || nextY < display.y || nextY > display.y + display.height) {
|
|
1718
|
+
nextX = display.x + display.width - panelBounds.width - 16
|
|
1719
|
+
nextY = display.y + 36
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
if (nextX < display.x) nextX = display.x + 8
|
|
1111
1723
|
if (nextX + panelBounds.width > display.x + display.width) nextX = display.x + display.width - panelBounds.width - 8
|
|
1112
1724
|
if (nextY + panelBounds.height > display.y + display.height) nextY = display.y + display.height - panelBounds.height - 8
|
|
1113
1725
|
if (nextY < display.y) nextY = display.y + 8
|
|
@@ -1115,7 +1727,6 @@ function positionPanel() {
|
|
|
1115
1727
|
}
|
|
1116
1728
|
|
|
1117
1729
|
function showPanel() {
|
|
1118
|
-
showPet()
|
|
1119
1730
|
const win = createPanelWindow()
|
|
1120
1731
|
updatePanel()
|
|
1121
1732
|
positionPanel()
|
|
@@ -1147,15 +1758,32 @@ function hidePet() {
|
|
|
1147
1758
|
|
|
1148
1759
|
function buildMenu() {
|
|
1149
1760
|
return Menu.buildFromTemplate([
|
|
1150
|
-
{ label: "Show
|
|
1761
|
+
{ label: "Show OpenCube", click: showPet },
|
|
1762
|
+
{ label: "Hide OpenCube", click: hidePet },
|
|
1763
|
+
{ type: "separator" },
|
|
1151
1764
|
{ label: "Show Inbox", click: showPanel },
|
|
1152
|
-
{ label: "Hide Pet", click: hidePet },
|
|
1153
1765
|
{ label: "Hide Inbox", click: hidePanel },
|
|
1154
1766
|
{ type: "separator" },
|
|
1155
|
-
{ label: "Quit
|
|
1767
|
+
{ label: "Quit OpenCube", click: () => app.quit() },
|
|
1156
1768
|
])
|
|
1157
1769
|
}
|
|
1158
1770
|
|
|
1771
|
+
function createTray() {
|
|
1772
|
+
if (tray) return tray
|
|
1773
|
+
let image = nativeImage.createFromPath(ICON_PATH)
|
|
1774
|
+
if (image.isEmpty()) {
|
|
1775
|
+
image = nativeImage.createFromDataURL(
|
|
1776
|
+
"data:image/svg+xml;utf8," +
|
|
1777
|
+
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>`),
|
|
1778
|
+
)
|
|
1779
|
+
}
|
|
1780
|
+
image = image.resize({ width: 18, height: 18 })
|
|
1781
|
+
tray = new Tray(image)
|
|
1782
|
+
tray.setToolTip("OpenCube")
|
|
1783
|
+
tray.setContextMenu(buildMenu())
|
|
1784
|
+
return tray
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1159
1787
|
function startServer() {
|
|
1160
1788
|
if (server) return
|
|
1161
1789
|
server = http.createServer(async (req, res) => {
|
|
@@ -1208,6 +1836,12 @@ function startServer() {
|
|
|
1208
1836
|
return json(res, 200, { ok: true, event: item })
|
|
1209
1837
|
}
|
|
1210
1838
|
|
|
1839
|
+
if (req.method === "POST" && url.pathname === "/interaction") {
|
|
1840
|
+
const body = await readRequestJson(req)
|
|
1841
|
+
const item = recordInteractionEvent(body)
|
|
1842
|
+
return json(res, 200, { ok: true, event: item })
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1211
1845
|
if (req.method === "POST" && url.pathname === "/show") {
|
|
1212
1846
|
showPet()
|
|
1213
1847
|
return json(res, 200, { ok: true })
|
|
@@ -1238,6 +1872,7 @@ function startServer() {
|
|
|
1238
1872
|
function start() {
|
|
1239
1873
|
app.dock?.hide()
|
|
1240
1874
|
writeState({ visible: false, mode: "floating" })
|
|
1875
|
+
createTray()
|
|
1241
1876
|
startServer()
|
|
1242
1877
|
cleanupTimer = setInterval(() => pruneIdleSessions(true), 30 * 1000)
|
|
1243
1878
|
cleanupTimer.unref?.()
|
package/src/plugin-server.cjs
CHANGED
|
@@ -47,6 +47,17 @@ 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
62
|
id: "opencube",
|
|
52
63
|
server: async ({ client }) => {
|
|
@@ -128,6 +139,36 @@ module.exports = {
|
|
|
128
139
|
},
|
|
129
140
|
|
|
130
141
|
event: async ({ event }) => {
|
|
142
|
+
if (event.type === "permission.asked") {
|
|
143
|
+
const permission = event.properties || {}
|
|
144
|
+
await sendEvent({
|
|
145
|
+
type: "permission.ask",
|
|
146
|
+
message: "opencode is waiting for permission",
|
|
147
|
+
sessionID: permission.sessionID,
|
|
148
|
+
requestID: permission.id,
|
|
149
|
+
permission: permission.permission,
|
|
150
|
+
patterns: permission.patterns,
|
|
151
|
+
metadata: permission.metadata,
|
|
152
|
+
always: permission.always,
|
|
153
|
+
tool: permission.tool,
|
|
154
|
+
source: "opencube-plugin",
|
|
155
|
+
})
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (event.type === "permission.replied") {
|
|
160
|
+
const permission = event.properties || {}
|
|
161
|
+
await sendEvent({
|
|
162
|
+
type: "permission.reply",
|
|
163
|
+
message: "opencode permission was answered",
|
|
164
|
+
sessionID: permission.sessionID,
|
|
165
|
+
requestID: permission.requestID,
|
|
166
|
+
reply: permission.reply,
|
|
167
|
+
source: "opencube-plugin",
|
|
168
|
+
})
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
131
172
|
if (event.type !== "session.status") return
|
|
132
173
|
|
|
133
174
|
const sessionID = event.properties?.sessionID
|
|
@@ -164,6 +205,31 @@ module.exports = {
|
|
|
164
205
|
source: "opencube-plugin",
|
|
165
206
|
})
|
|
166
207
|
},
|
|
208
|
+
|
|
209
|
+
"tool.execute.before": async (input, output) => {
|
|
210
|
+
await sendEvent({
|
|
211
|
+
type: "tool.start",
|
|
212
|
+
message: `tool ${input.tool} started`,
|
|
213
|
+
sessionID: input.sessionID,
|
|
214
|
+
tool: input.tool,
|
|
215
|
+
callID: input.callID,
|
|
216
|
+
args: output?.args,
|
|
217
|
+
source: "opencube-plugin",
|
|
218
|
+
})
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
"tool.execute.after": async (input, output) => {
|
|
222
|
+
await sendEvent({
|
|
223
|
+
type: "tool.finish",
|
|
224
|
+
message: `tool ${input.tool} finished`,
|
|
225
|
+
sessionID: input.sessionID,
|
|
226
|
+
tool: input.tool,
|
|
227
|
+
callID: input.callID,
|
|
228
|
+
args: input.args,
|
|
229
|
+
result: summarizeToolOutput(output),
|
|
230
|
+
source: "opencube-plugin",
|
|
231
|
+
})
|
|
232
|
+
},
|
|
167
233
|
}
|
|
168
234
|
},
|
|
169
235
|
}
|