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 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
- - idle sessions release their face glow after the cube slows down
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencube",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "A tiny Three.js desktop pet for opencode session activity.",
5
5
  "main": "src/main.js",
6
6
  "exports": {
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
- .list { height: 292px; overflow: auto; padding-right: 4px; }
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>opencode pet inbox</span><span class="hint">${events.length}/${MAX_EVENTS}</span></div>
252
- <div class="list">${rows || `<div class="empty">还没有收到事件。试试 /pet 或 /pet_stop。</div>`}</div>
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
- if (isBusy && !wasBusy) setNextTorque(now)
953
- if (isBusy && now >= nextTorqueAt) setNextTorque(now)
954
- if (!isBusy) torque = { x: 0, y: 0, z: 0 }
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 friction = isBusy ? 0.58 : 2.85
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: 360,
1080
- height: 360,
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 (!petWindow || petWindow.isDestroyed() || !panelWindow || panelWindow.isDestroyed()) return
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 display = screen.getDisplayNearestPoint({ x, y }).workArea
1108
- let nextX = x - panelBounds.width - 10
1109
- let nextY = y
1110
- if (nextX < display.x) nextX = x + petBounds.width + 10
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 Pet", click: showPet },
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 Pet", click: () => app.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?.()
@@ -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
  }