opencube 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.0",
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,37 @@ const STATE_FILE = path.join(DATA_DIR, "state.json")
14
14
  const PET_HTML_FILE = path.join(DATA_DIR, "pet.html")
15
15
  const ICON_PATH = process.env.OPENCODE_PET_ICON || path.join(__dirname, "..", "assets", "opencode-icon.png")
16
16
  const MAX_EVENTS = 100
17
+ const MAX_INTERACTION_EVENTS = 80
17
18
  const IDLE_TTL_MS = 5 * 60 * 1000
18
19
 
19
20
  let petWindow = null
20
21
  let panelWindow = null
22
+ let tray = null
21
23
  let server = null
22
24
  let events = []
25
+ let interactionEvents = []
23
26
  let petSignals = []
24
27
  let sessionMap = new Map()
28
+ let activeToolsBySession = new Map()
25
29
  let cleanupTimer = null
30
+ let dragState = null
31
+
32
+ ipcMain.on("opencube-drag-start", (event, point) => {
33
+ if (!petWindow || petWindow.isDestroyed()) return
34
+ const [x, y] = petWindow.getPosition()
35
+ dragState = { windowX: x, windowY: y, screenX: point.screenX, screenY: point.screenY }
36
+ })
37
+
38
+ ipcMain.on("opencube-drag-move", (event, point) => {
39
+ if (!petWindow || petWindow.isDestroyed() || !dragState) return
40
+ const nextX = Math.round(dragState.windowX + point.screenX - dragState.screenX)
41
+ const nextY = Math.round(dragState.windowY + point.screenY - dragState.screenY)
42
+ petWindow.setPosition(nextX, nextY, false)
43
+ })
44
+
45
+ ipcMain.on("opencube-drag-end", () => {
46
+ dragState = null
47
+ })
26
48
 
27
49
  function ensureDataDir() {
28
50
  fs.mkdirSync(DATA_DIR, { recursive: true })
@@ -108,6 +130,7 @@ function recordEvent(event) {
108
130
  ...event,
109
131
  }
110
132
  applySessionEvent(item)
133
+ applyToolEvent(item)
111
134
  if (item.type === "hello" || item.type === "fancy_hello") {
112
135
  petSignals.push({
113
136
  id: item.id,
@@ -125,6 +148,41 @@ function recordEvent(event) {
125
148
  return item
126
149
  }
127
150
 
151
+ function applyToolEvent(event) {
152
+ if (!event || typeof event.sessionID !== "string" || typeof event.callID !== "string") return
153
+ if (event.type !== "tool.start" && event.type !== "tool.finish") return
154
+
155
+ let tools = activeToolsBySession.get(event.sessionID)
156
+ if (!tools) {
157
+ tools = new Map()
158
+ activeToolsBySession.set(event.sessionID, tools)
159
+ }
160
+
161
+ if (event.type === "tool.start") {
162
+ tools.set(event.callID, {
163
+ callID: event.callID,
164
+ tool: event.tool,
165
+ startedAt: event.receivedAt || Date.now(),
166
+ })
167
+ return
168
+ }
169
+
170
+ tools.delete(event.callID)
171
+ if (tools.size === 0) activeToolsBySession.delete(event.sessionID)
172
+ }
173
+
174
+ function recordInteractionEvent(event) {
175
+ const item = {
176
+ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
177
+ receivedAt: Date.now(),
178
+ ...event,
179
+ }
180
+ interactionEvents.unshift(item)
181
+ interactionEvents = interactionEvents.slice(0, MAX_INTERACTION_EVENTS)
182
+ updatePanel()
183
+ return item
184
+ }
185
+
128
186
  function applySessionEvent(event) {
129
187
  if (!event || typeof event.sessionID !== "string") return
130
188
  const now = event.receivedAt || Date.now()
@@ -145,6 +203,7 @@ function applySessionEvent(event) {
145
203
  }
146
204
 
147
205
  if (event.type === "session.idle") {
206
+ activeToolsBySession.delete(event.sessionID)
148
207
  sessionMap.set(event.sessionID, {
149
208
  sessionID: event.sessionID,
150
209
  state: "idle",
@@ -165,6 +224,7 @@ function pruneIdleSessions(refresh = true) {
165
224
  const expiresFrom = session.idleAt || session.lastAt
166
225
  if (session.state === "idle" && expiresFrom && now - expiresFrom > IDLE_TTL_MS) {
167
226
  sessionMap.delete(sessionID)
227
+ activeToolsBySession.delete(sessionID)
168
228
  changed = true
169
229
  }
170
230
  }
@@ -186,6 +246,7 @@ function getPetState() {
186
246
  ball: { size: 14 },
187
247
  },
188
248
  sessions: Array.from(sessionMap.values()).map((session, index) => ({
249
+ activeTools: Array.from(activeToolsBySession.get(session.sessionID)?.values() || []),
189
250
  sessionID: session.sessionID,
190
251
  state: session.state,
191
252
  busyAt: session.busyAt,
@@ -230,6 +291,14 @@ function panelHtml() {
230
291
  })
231
292
  .join("")
232
293
 
294
+ const interactionRows = interactionEvents
295
+ .map((event) => {
296
+ const time = new Date(event.receivedAt).toLocaleTimeString()
297
+ const payload = JSON.stringify(event, null, 2)
298
+ return `<div class="event interaction"><div class="meta interaction-meta">${escapeHtml(time)} · ${escapeHtml(event.type || "interaction")}</div><pre>${escapeHtml(payload)}</pre></div>`
299
+ })
300
+ .join("")
301
+
233
302
  return `<!doctype html>
234
303
  <html>
235
304
  <head>
@@ -239,17 +308,31 @@ function panelHtml() {
239
308
  .panel { box-sizing: border-box; width: 100%; height: 100%; padding: 12px; border-radius: 16px; background: rgba(24, 24, 27, 0.94); color: white; box-shadow: 0 14px 42px rgba(0,0,0,.28); overflow: hidden; }
240
309
  .header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 13px; font-weight: 700; }
241
310
  .hint { color: rgba(255,255,255,.6); font-size: 11px; font-weight: 500; }
242
- .list { height: 292px; overflow: auto; padding-right: 4px; }
311
+ .columns { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; height: 316px; }
312
+ .section { min-width: 0; overflow: hidden; display: flex; flex-direction: column; }
313
+ .section-title { display: flex; justify-content: space-between; color: rgba(255,255,255,.72); font-size: 11px; font-weight: 700; margin: 0 2px 6px; }
314
+ .list { flex: 1; overflow: auto; padding-right: 4px; }
243
315
  .empty { color: rgba(255,255,255,.6); font-size: 13px; padding: 18px 4px; }
244
316
  .event { border: 1px solid rgba(255,255,255,.12); border-radius: 10px; padding: 8px; margin-bottom: 8px; background: rgba(255,255,255,.06); }
317
+ .event.interaction { background: rgba(56,189,248,.08); border-color: rgba(56,189,248,.18); }
245
318
  .meta { color: #a7f3d0; font-size: 11px; margin-bottom: 5px; }
319
+ .interaction-meta { color: #93c5fd; }
246
320
  pre { margin: 0; color: rgba(255,255,255,.88); white-space: pre-wrap; word-break: break-word; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
247
321
  </style>
248
322
  </head>
249
323
  <body>
250
324
  <div class="panel">
251
- <div class="header"><span>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>
325
+ <div class="header"><span>OpenCube inbox</span><span class="hint">events ${events.length}/${MAX_EVENTS} · input ${interactionEvents.length}/${MAX_INTERACTION_EVENTS}</span></div>
326
+ <div class="columns">
327
+ <section class="section">
328
+ <div class="section-title"><span>HTTP / hook events</span><span>${events.length}</span></div>
329
+ <div class="list">${rows || `<div class="empty">还没有收到事件。</div>`}</div>
330
+ </section>
331
+ <section class="section">
332
+ <div class="section-title"><span>Mouse / keyboard</span><span>${interactionEvents.length}</span></div>
333
+ <div class="list">${interactionRows || `<div class="empty">还没有捕捉到输入事件。</div>`}</div>
334
+ </section>
335
+ </div>
253
336
  </div>
254
337
  </body>
255
338
  </html>`
@@ -548,7 +631,7 @@ function petHtml3D() {
548
631
  height: 100%;
549
632
  position: relative;
550
633
  background: transparent;
551
- -webkit-app-region: drag;
634
+ -webkit-app-region: no-drag;
552
635
  user-select: none;
553
636
  }
554
637
  #scene {
@@ -558,11 +641,18 @@ function petHtml3D() {
558
641
  height: 100%;
559
642
  pointer-events: none;
560
643
  }
644
+ #hit-layer {
645
+ position: absolute;
646
+ inset: 0;
647
+ background: transparent;
648
+ -webkit-app-region: no-drag;
649
+ }
561
650
  </style>
562
651
  </head>
563
652
  <body>
564
653
  <div class="stage" title="opencode pet:拖拽移动,右键打开菜单">
565
654
  <canvas id="scene" aria-label="3D opencode pet"></canvas>
655
+ <div id="hit-layer" aria-label="OpenCube interaction layer"></div>
566
656
  </div>
567
657
  <script>
568
658
  window.__PET_BOOT_ERROR = null
@@ -575,9 +665,11 @@ function petHtml3D() {
575
665
  </script>
576
666
  <script>
577
667
  const THREE = require(${threeCjsPath})
668
+ const { ipcRenderer } = require("electron")
578
669
 
579
670
  window.__PET_STATE = ${initialStateJson}
580
671
  const stage = document.querySelector(".stage")
672
+ const hitLayer = document.getElementById("hit-layer")
581
673
  const faceOrder = ["front", "right", "top", "back", "left", "bottom"]
582
674
  const sessionFaceMap = new Map()
583
675
  const sessionColorMap = new Map()
@@ -593,6 +685,10 @@ function petHtml3D() {
593
685
  let torque = { x: 0, y: 0, z: 0 }
594
686
  let nextTorqueAt = 0
595
687
  let wasBusy = false
688
+ let frictionHoldActive = false
689
+ let frictionHoldLevel = 0
690
+ let leftDragActive = false
691
+ let dragEmitAccumulator = 0
596
692
  let latestDebug = { now: Date.now(), busy: 0, rotation, angularVelocity, torque, speed: 0, faceRotations: {} }
597
693
 
598
694
  const canvas = document.getElementById("scene")
@@ -606,6 +702,11 @@ function petHtml3D() {
606
702
 
607
703
  const cubeGroup = new THREE.Group()
608
704
  scene.add(cubeGroup)
705
+ const dragParticleGroup = new THREE.Group()
706
+ dragParticleGroup.position.z = 0.88
707
+ scene.add(dragParticleGroup)
708
+ const toolParticleGroup = new THREE.Group()
709
+ scene.add(toolParticleGroup)
609
710
  const iconTexture = new THREE.TextureLoader().load("${iconUrl}")
610
711
  iconTexture.colorSpace = THREE.SRGBColorSpace
611
712
  iconTexture.generateMipmaps = false
@@ -619,6 +720,7 @@ function petHtml3D() {
619
720
  side: THREE.DoubleSide,
620
721
  })
621
722
  const glowTexture = createGlowTexture()
723
+ const dragParticleTexture = createDragParticleTexture()
622
724
  const faceGeometry = new THREE.PlaneGeometry(0.60, 0.60)
623
725
  const glowGeometry = new THREE.PlaneGeometry(1.18, 1.18)
624
726
  const rad = THREE.MathUtils.degToRad
@@ -641,6 +743,247 @@ function petHtml3D() {
641
743
  return texture
642
744
  }
643
745
 
746
+ function createDragParticleTexture() {
747
+ const canvas = document.createElement("canvas")
748
+ canvas.width = 96
749
+ canvas.height = 96
750
+ const ctx = canvas.getContext("2d")
751
+ const gradient = ctx.createRadialGradient(48, 48, 1, 48, 48, 45)
752
+ gradient.addColorStop(0, "rgba(255,255,255,1)")
753
+ gradient.addColorStop(0.24, "rgba(255,255,255,1)")
754
+ gradient.addColorStop(0.52, "rgba(255,255,255,.52)")
755
+ gradient.addColorStop(1, "rgba(255,255,255,0)")
756
+ ctx.fillStyle = gradient
757
+ ctx.fillRect(0, 0, 96, 96)
758
+ const texture = new THREE.CanvasTexture(canvas)
759
+ texture.colorSpace = THREE.SRGBColorSpace
760
+ return texture
761
+ }
762
+
763
+ const dragParticles = []
764
+ const toolParticles = []
765
+ const toolEmitAccumulators = new Map()
766
+ const faceVectors = {
767
+ front: { position: new THREE.Vector3(0, 0, 0.34), normal: new THREE.Vector3(0, 0, 1) },
768
+ back: { position: new THREE.Vector3(0, 0, -0.34), normal: new THREE.Vector3(0, 0, -1) },
769
+ right: { position: new THREE.Vector3(0.34, 0, 0), normal: new THREE.Vector3(1, 0, 0) },
770
+ left: { position: new THREE.Vector3(-0.34, 0, 0), normal: new THREE.Vector3(-1, 0, 0) },
771
+ top: { position: new THREE.Vector3(0, 0.34, 0), normal: new THREE.Vector3(0, 1, 0) },
772
+ bottom: { position: new THREE.Vector3(0, -0.34, 0), normal: new THREE.Vector3(0, -1, 0) },
773
+ }
774
+
775
+ function createDragParticles() {
776
+ const geometry = new THREE.PlaneGeometry(0.086, 0.086)
777
+ for (let index = 0; index < 34; index++) {
778
+ const material = new THREE.MeshBasicMaterial({
779
+ map: dragParticleTexture,
780
+ color: new THREE.Color(1, 1, 1),
781
+ transparent: true,
782
+ opacity: 0,
783
+ blending: THREE.AdditiveBlending,
784
+ depthWrite: false,
785
+ side: THREE.DoubleSide,
786
+ })
787
+ const particle = new THREE.Mesh(geometry, material)
788
+ particle.userData = {
789
+ active: false,
790
+ life: 0,
791
+ maxLife: 1,
792
+ vx: 0,
793
+ vy: 0,
794
+ size: randomBetween(0.72, 1.28),
795
+ }
796
+ dragParticleGroup.add(particle)
797
+ dragParticles.push(particle)
798
+ }
799
+ }
800
+
801
+ function createToolParticles() {
802
+ for (let index = 0; index < 56; index++) {
803
+ const material = new THREE.SpriteMaterial({
804
+ map: dragParticleTexture,
805
+ color: 0xffffff,
806
+ transparent: true,
807
+ opacity: 0,
808
+ blending: THREE.AdditiveBlending,
809
+ depthWrite: false,
810
+ })
811
+ const particle = new THREE.Sprite(material)
812
+ particle.userData = {
813
+ active: false,
814
+ life: 0,
815
+ maxLife: 1,
816
+ velocity: new THREE.Vector3(),
817
+ size: 0.06,
818
+ }
819
+ toolParticleGroup.add(particle)
820
+ toolParticles.push(particle)
821
+ }
822
+ }
823
+
824
+ function colorToThree(color) {
825
+ return new THREE.Color((color?.r ?? 255) / 255, (color?.g ?? 255) / 255, (color?.b ?? 255) / 255)
826
+ }
827
+
828
+ function emitToolParticle(faceName, color) {
829
+ const face = faceVectors[faceName]
830
+ if (!face) return false
831
+ const particle = toolParticles.find((item) => !item.userData.active)
832
+ if (!particle) return false
833
+
834
+ const origin = face.position.clone()
835
+ const normal = face.normal.clone()
836
+ cubeGroup.localToWorld(origin)
837
+ normal.applyQuaternion(cubeGroup.quaternion).normalize()
838
+
839
+ const basis = Math.abs(normal.y) > 0.82 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0)
840
+ const tangentA = new THREE.Vector3().crossVectors(normal, basis).normalize()
841
+ const tangentB = new THREE.Vector3().crossVectors(normal, tangentA).normalize()
842
+
843
+ const data = particle.userData
844
+ data.active = true
845
+ data.life = 0
846
+ data.maxLife = randomBetween(0.45, 0.78)
847
+ data.size = randomBetween(0.062, 0.115)
848
+ data.velocity.copy(normal).multiplyScalar(randomBetween(1.45, 2.35))
849
+ data.velocity.add(tangentA.multiplyScalar(randomBetween(-0.32, 0.32)))
850
+ data.velocity.add(tangentB.multiplyScalar(randomBetween(-0.32, 0.32)))
851
+ particle.position.copy(origin).add(normal.clone().multiplyScalar(0.035))
852
+ particle.scale.setScalar(data.size)
853
+ particle.material.color.copy(colorToThree(color))
854
+ particle.material.opacity = 0.92
855
+ return true
856
+ }
857
+
858
+ function updateToolParticles(sessions, dt) {
859
+ const activeToolSessions = sessions.filter((session) => session.state === "busy" && session.activeTools?.length > 0)
860
+ const activeIDs = new Set(activeToolSessions.map((session) => session.sessionID))
861
+ for (const sessionID of Array.from(toolEmitAccumulators.keys())) {
862
+ if (!activeIDs.has(sessionID)) toolEmitAccumulators.delete(sessionID)
863
+ }
864
+
865
+ for (const session of activeToolSessions) {
866
+ const faceName = sessionFaceMap.get(session.sessionID)
867
+ if (!faceName) continue
868
+ const color = sessionColorMap.get(session.sessionID) || randomSessionGlowColor()
869
+ const jitterRate = randomBetween(7.5, 11.5)
870
+ const next = (toolEmitAccumulators.get(session.sessionID) || 0) + dt * jitterRate
871
+ let accumulator = next
872
+ while (accumulator >= 1) {
873
+ if (!emitToolParticle(faceName, color)) break
874
+ accumulator -= 1
875
+ }
876
+ toolEmitAccumulators.set(session.sessionID, accumulator)
877
+ }
878
+
879
+ let activeCount = 0
880
+ for (const particle of toolParticles) {
881
+ const data = particle.userData
882
+ if (!data.active) {
883
+ particle.material.opacity = 0
884
+ continue
885
+ }
886
+ data.life += dt
887
+ if (data.life >= data.maxLife) {
888
+ data.active = false
889
+ particle.material.opacity = 0
890
+ continue
891
+ }
892
+ activeCount += 1
893
+ const age = data.life / data.maxLife
894
+ particle.position.addScaledVector(data.velocity, dt)
895
+ particle.scale.setScalar(data.size * (1.02 + age * 0.42))
896
+ particle.material.opacity = Math.min(1, 0.24 + Math.sin(age * Math.PI) * 1.18)
897
+ }
898
+ toolParticleGroup.visible = activeCount > 0 || activeToolSessions.length > 0
899
+ return { activeSessions: activeToolSessions.length, activeCount }
900
+ }
901
+
902
+ function activeDragColors() {
903
+ const colors = []
904
+ for (const color of sessionColorMap.values()) {
905
+ if (color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b)) colors.push(color)
906
+ }
907
+ return colors
908
+ }
909
+
910
+ function monochromeDragColor(index, cycle) {
911
+ const value = 0.26 + ((index % 5) / 4) * 0.56 + Math.sin(cycle * Math.PI) * 0.12
912
+ const clamped = Math.max(0.18, Math.min(0.92, value))
913
+ return { r: Math.round(clamped * 255), g: Math.round(clamped * 255), b: Math.round(clamped * 255) }
914
+ }
915
+
916
+ function chooseDragParticleColor(index, palette) {
917
+ if (palette.length > 0) return palette[index % palette.length]
918
+ const value = index % 2 === 0 ? 238 : 32
919
+ return { r: value, g: value, b: value }
920
+ }
921
+
922
+ function emitDragParticle(palette) {
923
+ const particle = dragParticles.find((item) => !item.userData.active)
924
+ if (!particle) return false
925
+ const data = particle.userData
926
+ const angle = randomBetween(0, Math.PI * 2)
927
+ const startRadius = randomBetween(0.16, 0.30)
928
+ const velocity = randomBetween(0.10, 0.20)
929
+ const tangent = randomBetween(-0.045, 0.045)
930
+ const color = chooseDragParticleColor(Math.floor(randomBetween(0, 1000)), palette)
931
+ data.active = true
932
+ data.life = 0
933
+ data.maxLife = randomBetween(1.15, 1.9)
934
+ data.vx = Math.cos(angle) * velocity + Math.cos(angle + Math.PI / 2) * tangent
935
+ data.vy = Math.sin(angle) * velocity + Math.sin(angle + Math.PI / 2) * tangent
936
+ data.size = randomBetween(0.92, 1.52)
937
+ particle.position.x = Math.cos(angle) * startRadius
938
+ particle.position.y = Math.sin(angle) * startRadius
939
+ particle.position.z = 0
940
+ particle.scale.setScalar(data.size)
941
+ particle.material.color.setRGB(color.r / 255, color.g / 255, color.b / 255)
942
+ particle.material.opacity = 0.96
943
+ return true
944
+ }
945
+
946
+ function updateDragParticles(now, holdLevel, speed, dt) {
947
+ const hold = Math.max(0, Math.min(1, holdLevel / 11))
948
+ const intensity = hold
949
+ const palette = activeDragColors()
950
+ if (hold > 0.02) {
951
+ const emitRate = 7.5 - hold * 5.2
952
+ dragEmitAccumulator += dt * emitRate
953
+ while (dragEmitAccumulator >= 1) {
954
+ if (!emitDragParticle(palette)) break
955
+ dragEmitAccumulator -= 1
956
+ }
957
+ } else {
958
+ dragEmitAccumulator = 0
959
+ }
960
+
961
+ let activeCount = 0
962
+ for (const particle of dragParticles) {
963
+ const data = particle.userData
964
+ if (!data.active) {
965
+ particle.material.opacity = 0
966
+ continue
967
+ }
968
+ data.life += dt
969
+ if (data.life >= data.maxLife) {
970
+ data.active = false
971
+ particle.material.opacity = 0
972
+ continue
973
+ }
974
+ activeCount += 1
975
+ const age = data.life / data.maxLife
976
+ particle.position.x += data.vx * dt
977
+ particle.position.y += data.vy * dt
978
+ const fade = Math.sin(age * Math.PI)
979
+ const scale = data.size * (0.92 + age * 0.58)
980
+ particle.scale.setScalar(scale)
981
+ particle.material.opacity = Math.min(1, 0.12 + fade * 0.98)
982
+ }
983
+ dragParticleGroup.visible = activeCount > 0 || intensity > 0.01
984
+ return { intensity, activeCount, emitRate: hold > 0.02 ? 7.5 - hold * 5.2 : 0 }
985
+ }
986
+
644
987
  function resize() {
645
988
  const width = Math.max(1, window.innerWidth)
646
989
  const height = Math.max(1, window.innerHeight)
@@ -651,6 +994,113 @@ function petHtml3D() {
651
994
  window.addEventListener("resize", resize)
652
995
  resize()
653
996
 
997
+ function postInteraction(event) {
998
+ fetch("http://${HOST}:${PORT}/interaction", {
999
+ method: "POST",
1000
+ headers: { "content-type": "application/json" },
1001
+ body: JSON.stringify({
1002
+ ...event,
1003
+ source: "opencube-renderer",
1004
+ frictionHoldActive,
1005
+ frictionHoldLevel,
1006
+ frictionHoldMultiplier: 1 + frictionHoldLevel,
1007
+ }),
1008
+ }).catch(() => {})
1009
+ }
1010
+
1011
+ function pointerPayload(type, event) {
1012
+ return {
1013
+ type,
1014
+ nativeType: event.type,
1015
+ pointerId: event.pointerId,
1016
+ pointerType: event.pointerType,
1017
+ button: event.button,
1018
+ buttons: event.buttons,
1019
+ screenX: Math.round(event.screenX || 0),
1020
+ screenY: Math.round(event.screenY || 0),
1021
+ clientX: Math.round(event.clientX),
1022
+ clientY: Math.round(event.clientY),
1023
+ altKey: event.altKey,
1024
+ ctrlKey: event.ctrlKey,
1025
+ metaKey: event.metaKey,
1026
+ shiftKey: event.shiftKey,
1027
+ target: "cube",
1028
+ }
1029
+ }
1030
+
1031
+ function beginFrictionHold(event) {
1032
+ if (event.button !== 2) return
1033
+ event.preventDefault()
1034
+ try { hitLayer.setPointerCapture(event.pointerId) } catch {}
1035
+ frictionHoldActive = true
1036
+ postInteraction(pointerPayload("mouse.right.down", event))
1037
+ }
1038
+
1039
+ function endFrictionHold(event) {
1040
+ if (event && event.button !== undefined && event.button !== 2) return
1041
+ if (frictionHoldActive) postInteraction(pointerPayload("mouse.right.up", event || { button: 2, buttons: 0, clientX: 0, clientY: 0 }))
1042
+ frictionHoldActive = false
1043
+ }
1044
+
1045
+ function beginLeftDrag(event) {
1046
+ if (event.button !== 0) return
1047
+ event.preventDefault()
1048
+ try { hitLayer.setPointerCapture(event.pointerId) } catch {}
1049
+ leftDragActive = true
1050
+ ipcRenderer.send("opencube-drag-start", { screenX: event.screenX, screenY: event.screenY })
1051
+ }
1052
+
1053
+ function moveLeftDrag(event) {
1054
+ if (!leftDragActive) return
1055
+ event.preventDefault()
1056
+ ipcRenderer.send("opencube-drag-move", { screenX: event.screenX, screenY: event.screenY })
1057
+ }
1058
+
1059
+ function endLeftDrag(event) {
1060
+ if (!leftDragActive) return
1061
+ leftDragActive = false
1062
+ ipcRenderer.send("opencube-drag-end")
1063
+ }
1064
+
1065
+ function updateFrictionHold(dt) {
1066
+ const growPerSecond = 2.4
1067
+ const recoverPerSecond = 8.0
1068
+ if (frictionHoldActive) {
1069
+ frictionHoldLevel = Math.min(11, frictionHoldLevel + growPerSecond * dt)
1070
+ } else {
1071
+ frictionHoldLevel = Math.max(0, frictionHoldLevel - recoverPerSecond * dt)
1072
+ }
1073
+ return 1 + frictionHoldLevel
1074
+ }
1075
+
1076
+ hitLayer.addEventListener("pointerdown", (event) => {
1077
+ if (event.button === 0) beginLeftDrag(event)
1078
+ if (event.button === 2) beginFrictionHold(event)
1079
+ })
1080
+ hitLayer.addEventListener("pointermove", moveLeftDrag)
1081
+ hitLayer.addEventListener("pointerup", (event) => {
1082
+ if (event.button === 0) endLeftDrag(event)
1083
+ if (event.button === 2) endFrictionHold(event)
1084
+ })
1085
+ hitLayer.addEventListener("pointercancel", (event) => {
1086
+ endLeftDrag(event)
1087
+ endFrictionHold(event)
1088
+ })
1089
+ hitLayer.addEventListener("contextmenu", (event) => {
1090
+ event.preventDefault()
1091
+ postInteraction(pointerPayload("mouse.right.contextmenu", event))
1092
+ })
1093
+ window.addEventListener("keydown", (event) => postInteraction({ type: "keyboard.down", key: event.key, code: event.code, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey }))
1094
+ window.addEventListener("keyup", (event) => postInteraction({ type: "keyboard.up", key: event.key, code: event.code, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey }))
1095
+ document.addEventListener("mouseup", endFrictionHold)
1096
+ window.addEventListener("mouseup", endFrictionHold)
1097
+ window.addEventListener("pointerup", endFrictionHold)
1098
+ window.addEventListener("pointercancel", () => endFrictionHold())
1099
+ window.addEventListener("blur", () => {
1100
+ endLeftDrag()
1101
+ endFrictionHold()
1102
+ })
1103
+
654
1104
  function randomBetween(min, max) {
655
1105
  return min + Math.random() * (max - min)
656
1106
  }
@@ -722,6 +1172,8 @@ function petHtml3D() {
722
1172
  createFace("left", [-0.30, 0, 0], [0, -Math.PI / 2, rad(faceRotations.left || 0)], [-0.314, 0, 0])
723
1173
  createFace("top", [0, 0.30, 0], [-Math.PI / 2, 0, rad(faceRotations.top || 0)], [0, 0.314, 0])
724
1174
  createFace("bottom", [0, -0.30, 0], [Math.PI / 2, 0, rad(faceRotations.bottom || 0)], [0, -0.314, 0])
1175
+ createDragParticles()
1176
+ createToolParticles()
725
1177
 
726
1178
  function createFace(name, position, rotation, glowPosition) {
727
1179
  const face = new THREE.Mesh(faceGeometry, iconMaterial.clone())
@@ -949,15 +1401,20 @@ function petHtml3D() {
949
1401
  const busyCount = sessions.filter((session) => session.state === "busy").length
950
1402
  const isBusy = busyCount > 0
951
1403
 
952
- if (isBusy && !wasBusy) setNextTorque(now)
953
- if (isBusy && now >= nextTorqueAt) setNextTorque(now)
954
- if (!isBusy) torque = { x: 0, y: 0, z: 0 }
1404
+ // Keep the same randomized torque model in both busy and idle modes.
1405
+ // Busy uses low friction, so the cube accelerates into active motion.
1406
+ // Idle keeps receiving random torque, but high friction turns it into a
1407
+ // subtle breathing/drifting motion instead of stopping completely.
1408
+ if (isBusy !== wasBusy) setNextTorque(now)
1409
+ if (now >= nextTorqueAt) setNextTorque(now)
955
1410
  wasBusy = isBusy
956
1411
  stage.classList.toggle("has-busy", isBusy)
957
1412
  stage.classList.toggle("has-sessions", sessions.length > 0)
958
1413
 
959
1414
  const inertia = 1.18
960
- const friction = isBusy ? 0.58 : 2.85
1415
+ const baseFriction = isBusy ? 0.58 : 2.85
1416
+ const holdMultiplier = updateFrictionHold(dt)
1417
+ const friction = baseFriction * holdMultiplier
961
1418
  angularVelocity.x += (torque.x / inertia) * dt
962
1419
  angularVelocity.y += (torque.y / inertia) * dt
963
1420
  angularVelocity.z += (torque.z / inertia) * dt
@@ -970,6 +1427,10 @@ function petHtml3D() {
970
1427
  rotation.x += angularVelocity.x * dt
971
1428
  rotation.y += angularVelocity.y * dt
972
1429
  rotation.z += angularVelocity.z * dt
1430
+ cubeGroup.rotation.x = rad(rotation.x)
1431
+ cubeGroup.rotation.y = rad(rotation.y)
1432
+ cubeGroup.rotation.z = rad(rotation.z)
1433
+ cubeGroup.updateMatrixWorld(true)
973
1434
 
974
1435
  const speed = magnitude(angularVelocity)
975
1436
  const speedRatio = Math.min(1, speed / 1400)
@@ -977,20 +1438,35 @@ function petHtml3D() {
977
1438
  const glowR = Math.round(92 + (0 - 92) * glow)
978
1439
  const glowG = Math.round(255 + (190 - 255) * glow)
979
1440
  const glowB = Math.round(232 + (210 - 232) * glow)
1441
+ const dragParticles = updateDragParticles(now, frictionHoldLevel, speed, dt)
980
1442
  const busyFaces = syncBusyFaces(sessions, speed)
1443
+ const toolParticles = updateToolParticles(sessions, dt)
981
1444
  processSignals(snapshot.signals || [], now)
982
1445
  const helloFlashes = applyFlashFaces(now)
983
1446
  renderCube()
984
1447
  latestDebug = {
985
1448
  now: Date.now(),
986
1449
  busy: busyCount,
1450
+ mode: isBusy ? "busy" : "idle",
1451
+ sessions: sessions.map((session) => ({
1452
+ sessionID: session.sessionID,
1453
+ state: session.state,
1454
+ activeTools: session.activeTools || [],
1455
+ })),
987
1456
  rotation: { ...rotation },
988
1457
  angularVelocity: { ...angularVelocity },
989
1458
  torque: { ...torque },
1459
+ friction,
1460
+ baseFriction,
1461
+ frictionHoldActive,
1462
+ frictionHoldMultiplier: holdMultiplier,
1463
+ frictionHoldLevel,
990
1464
  speed,
991
1465
  speedRatio,
992
1466
  nextTorqueAt,
993
1467
  glow,
1468
+ dragParticles,
1469
+ toolParticles,
994
1470
  colorReleaseSpeed,
995
1471
  glowColor: { r: glowR, g: glowG, b: glowB },
996
1472
  faceRotations,
@@ -1059,11 +1535,9 @@ function createPetWindow() {
1059
1535
  })
1060
1536
  petWindow.setAlwaysOnTop(true, "floating")
1061
1537
  petWindow.loadFile(writePetHtmlFile())
1062
- petWindow.webContents.on("context-menu", () => buildMenu().popup({ window: petWindow }))
1063
1538
  petWindow.on("moved", () => {
1064
1539
  const [x, y] = petWindow.getPosition()
1065
1540
  writeState({ visible: true, x, y })
1066
- positionPanel()
1067
1541
  })
1068
1542
  petWindow.on("closed", () => {
1069
1543
  petWindow = null
@@ -1076,8 +1550,8 @@ function createPanelWindow() {
1076
1550
  if (panelWindow && !panelWindow.isDestroyed()) return panelWindow
1077
1551
 
1078
1552
  panelWindow = new BrowserWindow({
1079
- width: 360,
1080
- height: 360,
1553
+ width: 680,
1554
+ height: 420,
1081
1555
  show: false,
1082
1556
  frame: false,
1083
1557
  resizable: false,
@@ -1100,14 +1574,28 @@ function createPanelWindow() {
1100
1574
  }
1101
1575
 
1102
1576
  function positionPanel() {
1103
- if (!petWindow || petWindow.isDestroyed() || !panelWindow || panelWindow.isDestroyed()) return
1104
- const [x, y] = petWindow.getPosition()
1105
- const petBounds = petWindow.getBounds()
1577
+ if (!panelWindow || panelWindow.isDestroyed()) return
1106
1578
  const panelBounds = panelWindow.getBounds()
1107
- const 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
1579
+ const anchor = tray && !tray.isDestroyed() ? tray.getBounds() : undefined
1580
+ const display = anchor
1581
+ ? screen.getDisplayNearestPoint({ x: anchor.x, y: anchor.y }).workArea
1582
+ : screen.getPrimaryDisplay().workArea
1583
+
1584
+ let nextX = anchor
1585
+ ? Math.round(anchor.x + anchor.width / 2 - panelBounds.width / 2)
1586
+ : display.x + display.width - panelBounds.width - 16
1587
+ let nextY = anchor
1588
+ ? Math.round(anchor.y + anchor.height + 8)
1589
+ : display.y + 36
1590
+
1591
+ // If the menu bar reports a coordinate outside the workArea, keep the panel
1592
+ // as a standalone top-right popover instead of attaching it to the cube.
1593
+ if (!anchor || nextY < display.y || nextY > display.y + display.height) {
1594
+ nextX = display.x + display.width - panelBounds.width - 16
1595
+ nextY = display.y + 36
1596
+ }
1597
+
1598
+ if (nextX < display.x) nextX = display.x + 8
1111
1599
  if (nextX + panelBounds.width > display.x + display.width) nextX = display.x + display.width - panelBounds.width - 8
1112
1600
  if (nextY + panelBounds.height > display.y + display.height) nextY = display.y + display.height - panelBounds.height - 8
1113
1601
  if (nextY < display.y) nextY = display.y + 8
@@ -1115,7 +1603,6 @@ function positionPanel() {
1115
1603
  }
1116
1604
 
1117
1605
  function showPanel() {
1118
- showPet()
1119
1606
  const win = createPanelWindow()
1120
1607
  updatePanel()
1121
1608
  positionPanel()
@@ -1147,15 +1634,32 @@ function hidePet() {
1147
1634
 
1148
1635
  function buildMenu() {
1149
1636
  return Menu.buildFromTemplate([
1150
- { label: "Show Pet", click: showPet },
1637
+ { label: "Show OpenCube", click: showPet },
1638
+ { label: "Hide OpenCube", click: hidePet },
1639
+ { type: "separator" },
1151
1640
  { label: "Show Inbox", click: showPanel },
1152
- { label: "Hide Pet", click: hidePet },
1153
1641
  { label: "Hide Inbox", click: hidePanel },
1154
1642
  { type: "separator" },
1155
- { label: "Quit Pet", click: () => app.quit() },
1643
+ { label: "Quit OpenCube", click: () => app.quit() },
1156
1644
  ])
1157
1645
  }
1158
1646
 
1647
+ function createTray() {
1648
+ if (tray) return tray
1649
+ let image = nativeImage.createFromPath(ICON_PATH)
1650
+ if (image.isEmpty()) {
1651
+ image = nativeImage.createFromDataURL(
1652
+ "data:image/svg+xml;utf8," +
1653
+ encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><rect x="4" y="4" width="24" height="24" rx="6" fill="#111"/><rect x="11" y="8" width="10" height="16" rx="2" fill="#fff"/><rect x="14" y="12" width="5" height="9" rx="1" fill="#111"/></svg>`),
1654
+ )
1655
+ }
1656
+ image = image.resize({ width: 18, height: 18 })
1657
+ tray = new Tray(image)
1658
+ tray.setToolTip("OpenCube")
1659
+ tray.setContextMenu(buildMenu())
1660
+ return tray
1661
+ }
1662
+
1159
1663
  function startServer() {
1160
1664
  if (server) return
1161
1665
  server = http.createServer(async (req, res) => {
@@ -1208,6 +1712,12 @@ function startServer() {
1208
1712
  return json(res, 200, { ok: true, event: item })
1209
1713
  }
1210
1714
 
1715
+ if (req.method === "POST" && url.pathname === "/interaction") {
1716
+ const body = await readRequestJson(req)
1717
+ const item = recordInteractionEvent(body)
1718
+ return json(res, 200, { ok: true, event: item })
1719
+ }
1720
+
1211
1721
  if (req.method === "POST" && url.pathname === "/show") {
1212
1722
  showPet()
1213
1723
  return json(res, 200, { ok: true })
@@ -1238,6 +1748,7 @@ function startServer() {
1238
1748
  function start() {
1239
1749
  app.dock?.hide()
1240
1750
  writeState({ visible: false, mode: "floating" })
1751
+ createTray()
1241
1752
  startServer()
1242
1753
  cleanupTimer = setInterval(() => pruneIdleSessions(true), 30 * 1000)
1243
1754
  cleanupTimer.unref?.()
@@ -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 }) => {
@@ -164,6 +175,31 @@ module.exports = {
164
175
  source: "opencube-plugin",
165
176
  })
166
177
  },
178
+
179
+ "tool.execute.before": async (input, output) => {
180
+ await sendEvent({
181
+ type: "tool.start",
182
+ message: `tool ${input.tool} started`,
183
+ sessionID: input.sessionID,
184
+ tool: input.tool,
185
+ callID: input.callID,
186
+ args: output?.args,
187
+ source: "opencube-plugin",
188
+ })
189
+ },
190
+
191
+ "tool.execute.after": async (input, output) => {
192
+ await sendEvent({
193
+ type: "tool.finish",
194
+ message: `tool ${input.tool} finished`,
195
+ sessionID: input.sessionID,
196
+ tool: input.tool,
197
+ callID: input.callID,
198
+ args: input.args,
199
+ result: summarizeToolOutput(output),
200
+ source: "opencube-plugin",
201
+ })
202
+ },
167
203
  }
168
204
  },
169
205
  }