slybeaver-opencode-visualizer 1.0.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/index.ts +282 -0
- package/package.json +13 -0
- package/visualizer/index.html +588 -0
package/index.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { existsSync } from "fs"
|
|
4
|
+
|
|
5
|
+
const SERVER_PORT = 5173
|
|
6
|
+
|
|
7
|
+
// ─── WebSocket server ───────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
interface PluginClient {
|
|
10
|
+
instanceId: string
|
|
11
|
+
ws: WebSocket
|
|
12
|
+
cwd: string
|
|
13
|
+
connectedAt: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FrontendClient {
|
|
17
|
+
ws: WebSocket
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const pluginClients = new Map<string, PluginClient>()
|
|
21
|
+
const frontendClients = new Set<FrontendClient>()
|
|
22
|
+
let windowOpened = false
|
|
23
|
+
let htmlContent: string | null = null
|
|
24
|
+
|
|
25
|
+
function getHtmlPath(): string {
|
|
26
|
+
const paths = [
|
|
27
|
+
join(import.meta.dir, "visualizer", "index.html"),
|
|
28
|
+
join(process.cwd(), "visualizer", "index.html"),
|
|
29
|
+
]
|
|
30
|
+
for (const p of paths) {
|
|
31
|
+
if (existsSync(p)) return p
|
|
32
|
+
}
|
|
33
|
+
throw new Error("Cannot find visualizer/index.html")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function broadcast(data: Record<string, unknown>) {
|
|
37
|
+
const msg = JSON.stringify(data)
|
|
38
|
+
for (const client of frontendClients) {
|
|
39
|
+
if (client.ws.readyState === WebSocket.OPEN) {
|
|
40
|
+
client.ws.send(msg)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function openChromeWindow() {
|
|
46
|
+
if (windowOpened) return
|
|
47
|
+
windowOpened = true
|
|
48
|
+
const url = `http://localhost:${SERVER_PORT}`
|
|
49
|
+
|
|
50
|
+
// Delay: if frontend reconnects from a still-open page, skip opening
|
|
51
|
+
setTimeout(() => {
|
|
52
|
+
if (frontendClients.size > 0) return
|
|
53
|
+
|
|
54
|
+
const chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
55
|
+
if (existsSync(chromePath)) {
|
|
56
|
+
Bun.spawn([chromePath, `--app=${url}`, "--window-size=640,480"], {
|
|
57
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
58
|
+
})
|
|
59
|
+
} else {
|
|
60
|
+
console.warn(`[visualizer] Chrome not found, open manually: ${url}`)
|
|
61
|
+
}
|
|
62
|
+
}, 3000)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let shutdownTimer: ReturnType<typeof setTimeout> | null = null
|
|
66
|
+
|
|
67
|
+
function resetShutdownTimer() {
|
|
68
|
+
if (shutdownTimer) clearTimeout(shutdownTimer)
|
|
69
|
+
shutdownTimer = setTimeout(() => {
|
|
70
|
+
if (pluginClients.size === 0) {
|
|
71
|
+
// Server will shut down when process exits
|
|
72
|
+
}
|
|
73
|
+
}, 10_000)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const server = Bun.serve({
|
|
78
|
+
port: SERVER_PORT,
|
|
79
|
+
hostname: "127.0.0.1",
|
|
80
|
+
async fetch(req) {
|
|
81
|
+
if (server.upgrade(req)) {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
if (!htmlContent) {
|
|
86
|
+
htmlContent = await Bun.file(getHtmlPath()).text()
|
|
87
|
+
}
|
|
88
|
+
return new Response(htmlContent, {
|
|
89
|
+
headers: {
|
|
90
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
91
|
+
"Cache-Control": "no-cache",
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
} catch {
|
|
95
|
+
return new Response("<h1>Visualization server — frontend not found</h1>", {
|
|
96
|
+
headers: { "Content-Type": "text/html" },
|
|
97
|
+
status: 500,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
websocket: {
|
|
102
|
+
open(_ws) {},
|
|
103
|
+
message(ws, message) {
|
|
104
|
+
let data: Record<string, unknown>
|
|
105
|
+
try {
|
|
106
|
+
data = JSON.parse(message as string)
|
|
107
|
+
} catch {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
switch (data.type) {
|
|
112
|
+
case "register": {
|
|
113
|
+
const id = data.instanceId as string
|
|
114
|
+
pluginClients.set(id, {
|
|
115
|
+
instanceId: id,
|
|
116
|
+
ws,
|
|
117
|
+
cwd: (data.cwd as string) || "",
|
|
118
|
+
connectedAt: Date.now(),
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
broadcast({
|
|
122
|
+
type: "instance.added",
|
|
123
|
+
instanceId: id,
|
|
124
|
+
cwd: data.cwd,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
openChromeWindow()
|
|
128
|
+
resetShutdownTimer()
|
|
129
|
+
break
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case "frontend.register": {
|
|
133
|
+
frontendClients.add({ ws })
|
|
134
|
+
const instances = [...pluginClients.values()].map((c) => ({
|
|
135
|
+
instanceId: c.instanceId,
|
|
136
|
+
cwd: c.cwd,
|
|
137
|
+
}))
|
|
138
|
+
ws.send(JSON.stringify({ type: "state.sync", instances }))
|
|
139
|
+
break
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
case "event": {
|
|
143
|
+
broadcast({
|
|
144
|
+
type: "instance.event",
|
|
145
|
+
instanceId: data.instanceId,
|
|
146
|
+
event: data.event,
|
|
147
|
+
})
|
|
148
|
+
break
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
close(ws) {
|
|
153
|
+
for (const [id, client] of pluginClients) {
|
|
154
|
+
if (client.ws === ws) {
|
|
155
|
+
pluginClients.delete(id)
|
|
156
|
+
broadcast({ type: "instance.removed", instanceId: id })
|
|
157
|
+
break
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const client of frontendClients) {
|
|
162
|
+
if (client.ws === ws) {
|
|
163
|
+
frontendClients.delete(client)
|
|
164
|
+
break
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
resetShutdownTimer()
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.error("[visualizer] Failed to start server:", err)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Plugin client ──────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
let ws: WebSocket | null = null
|
|
179
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
180
|
+
const instanceId = `oc-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
|
|
181
|
+
|
|
182
|
+
function connectToServer(): Promise<void> {
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
const socket = new WebSocket(`ws://localhost:${SERVER_PORT}`)
|
|
185
|
+
|
|
186
|
+
const timeout = setTimeout(() => {
|
|
187
|
+
socket.close()
|
|
188
|
+
reject(new Error("Connection timeout"))
|
|
189
|
+
}, 3000)
|
|
190
|
+
|
|
191
|
+
socket.onopen = () => {
|
|
192
|
+
clearTimeout(timeout)
|
|
193
|
+
if (reconnectTimer) {
|
|
194
|
+
clearTimeout(reconnectTimer)
|
|
195
|
+
reconnectTimer = null
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
ws = socket
|
|
199
|
+
socket.send(
|
|
200
|
+
JSON.stringify({
|
|
201
|
+
type: "register",
|
|
202
|
+
instanceId,
|
|
203
|
+
cwd: process.cwd(),
|
|
204
|
+
})
|
|
205
|
+
)
|
|
206
|
+
resolve()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
socket.onerror = () => {
|
|
210
|
+
clearTimeout(timeout)
|
|
211
|
+
socket.close()
|
|
212
|
+
reject(new Error("Connection failed"))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
socket.onclose = () => {
|
|
216
|
+
ws = null
|
|
217
|
+
if (!reconnectTimer) {
|
|
218
|
+
reconnectTimer = setTimeout(() => {
|
|
219
|
+
reconnectTimer = null
|
|
220
|
+
connectToServer().catch(() => {})
|
|
221
|
+
}, 2000)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
socket.onmessage = () => {}
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function sendEvent(eventType: string, payload: Record<string, unknown> = {}) {
|
|
230
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
|
231
|
+
ws.send(
|
|
232
|
+
JSON.stringify({
|
|
233
|
+
type: "event",
|
|
234
|
+
instanceId,
|
|
235
|
+
event: { type: eventType, ...payload },
|
|
236
|
+
})
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Plugin export ──────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
const VisualizerPlugin: Plugin = async ({ project, client, $, directory, worktree }) => {
|
|
243
|
+
// Wait up to 6 seconds for the server to be ready
|
|
244
|
+
for (let i = 0; i < 20; i++) {
|
|
245
|
+
try {
|
|
246
|
+
await connectToServer()
|
|
247
|
+
break
|
|
248
|
+
} catch {
|
|
249
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const interestingEvents = new Set([
|
|
254
|
+
"session.status",
|
|
255
|
+
"session.created",
|
|
256
|
+
"session.deleted",
|
|
257
|
+
"session.idle",
|
|
258
|
+
"session.error",
|
|
259
|
+
"session.diff",
|
|
260
|
+
"message.updated",
|
|
261
|
+
"todo.updated",
|
|
262
|
+
])
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
event: async ({ event }) => {
|
|
266
|
+
if (interestingEvents.has(event.type)) {
|
|
267
|
+
sendEvent(event.type, event.properties as Record<string, unknown>)
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
"tool.execute.after": async (input, _output) => {
|
|
272
|
+
sendEvent("tool.execute.after", { tool: input.tool })
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
"tool.execute.before": async (input, _output) => {
|
|
276
|
+
sendEvent("tool.execute.before", { tool: input.tool })
|
|
277
|
+
},
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export { VisualizerPlugin }
|
|
282
|
+
export default VisualizerPlugin
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "slybeaver-opencode-visualizer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenCode session visualizer — real-time orb animation in Chrome app window",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.ts",
|
|
9
|
+
"visualizer/index.html"
|
|
10
|
+
],
|
|
11
|
+
"keywords": ["opencode", "opencode-plugin", "visualizer", "monitor"],
|
|
12
|
+
"license": "MIT"
|
|
13
|
+
}
|
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>OpenCode Visualizer</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
body {
|
|
10
|
+
background: #08080f;
|
|
11
|
+
overflow: hidden;
|
|
12
|
+
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
|
13
|
+
-webkit-app-region: drag;
|
|
14
|
+
user-select: none;
|
|
15
|
+
}
|
|
16
|
+
canvas { display: block; }
|
|
17
|
+
#overlay {
|
|
18
|
+
position: fixed;
|
|
19
|
+
bottom: 24px;
|
|
20
|
+
left: 50%;
|
|
21
|
+
transform: translateX(-50%);
|
|
22
|
+
display: flex;
|
|
23
|
+
gap: 32px;
|
|
24
|
+
padding: 8px 20px;
|
|
25
|
+
background: rgba(8, 8, 15, 0.7);
|
|
26
|
+
border-radius: 8px;
|
|
27
|
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
28
|
+
backdrop-filter: blur(12px);
|
|
29
|
+
-webkit-backdrop-filter: blur(12px);
|
|
30
|
+
pointer-events: none;
|
|
31
|
+
z-index: 10;
|
|
32
|
+
}
|
|
33
|
+
.stat {
|
|
34
|
+
display: flex;
|
|
35
|
+
gap: 6px;
|
|
36
|
+
align-items: center;
|
|
37
|
+
color: rgba(255, 255, 255, 0.35);
|
|
38
|
+
font-size: 11px;
|
|
39
|
+
letter-spacing: 0.5px;
|
|
40
|
+
text-transform: uppercase;
|
|
41
|
+
}
|
|
42
|
+
.stat-value {
|
|
43
|
+
color: rgba(255, 255, 255, 0.8);
|
|
44
|
+
font-weight: 600;
|
|
45
|
+
font-variant-numeric: tabular-nums;
|
|
46
|
+
}
|
|
47
|
+
.stat-dot {
|
|
48
|
+
width: 6px;
|
|
49
|
+
height: 6px;
|
|
50
|
+
border-radius: 50%;
|
|
51
|
+
background: #40ff80;
|
|
52
|
+
box-shadow: 0 0 6px #40ff80;
|
|
53
|
+
}
|
|
54
|
+
</style>
|
|
55
|
+
</head>
|
|
56
|
+
<body>
|
|
57
|
+
<canvas id="canvas"></canvas>
|
|
58
|
+
<div id="overlay">
|
|
59
|
+
<div class="stat"><span class="stat-dot"></span> Instances: <span class="stat-value" id="instances">0</span></div>
|
|
60
|
+
<div class="stat">Events: <span class="stat-value" id="events">0</span></div>
|
|
61
|
+
<div class="stat">Uptime: <span class="stat-value" id="uptime">0s</span></div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<script>
|
|
65
|
+
(() => {
|
|
66
|
+
const canvas = document.getElementById("canvas")
|
|
67
|
+
const ctx = canvas.getContext("2d")
|
|
68
|
+
|
|
69
|
+
const elInstances = document.getElementById("instances")
|
|
70
|
+
const elEvents = document.getElementById("events")
|
|
71
|
+
const elUptime = document.getElementById("uptime")
|
|
72
|
+
|
|
73
|
+
const PALETTE = [
|
|
74
|
+
{ h: 200, s: 80, l: 62 },
|
|
75
|
+
{ h: 275, s: 70, l: 62 },
|
|
76
|
+
{ h: 340, s: 75, l: 62 },
|
|
77
|
+
{ h: 160, s: 65, l: 52 },
|
|
78
|
+
{ h: 40, s: 85, l: 58 },
|
|
79
|
+
{ h: 30, s: 90, l: 55 },
|
|
80
|
+
{ h: 190, s: 75, l: 55 },
|
|
81
|
+
{ h: 310, s: 60, l: 60 },
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
// ---- state ----
|
|
85
|
+
const nodes = new Map()
|
|
86
|
+
let eventCount = 0
|
|
87
|
+
let startTime = Date.now()
|
|
88
|
+
let nextColorIndex = 0
|
|
89
|
+
|
|
90
|
+
function resize() {
|
|
91
|
+
canvas.width = window.innerWidth * devicePixelRatio
|
|
92
|
+
canvas.height = window.innerHeight * devicePixelRatio
|
|
93
|
+
canvas.style.width = window.innerWidth + "px"
|
|
94
|
+
canvas.style.height = window.innerHeight + "px"
|
|
95
|
+
ctx.scale(devicePixelRatio, devicePixelRatio)
|
|
96
|
+
recalculateLayout()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- layout ----
|
|
100
|
+
function recalculateLayout() {
|
|
101
|
+
const W = window.innerWidth
|
|
102
|
+
const H = window.innerHeight
|
|
103
|
+
const cx = W / 2
|
|
104
|
+
const cy = H / 2 - 20
|
|
105
|
+
const count = nodes.size
|
|
106
|
+
|
|
107
|
+
if (count === 0) return
|
|
108
|
+
|
|
109
|
+
const positions = layoutCircle(count, cx, cy, Math.min(W, H) * 0.28)
|
|
110
|
+
|
|
111
|
+
let i = 0
|
|
112
|
+
for (const [, node] of nodes) {
|
|
113
|
+
if (i < positions.length) {
|
|
114
|
+
node.tx = positions[i].x
|
|
115
|
+
node.ty = positions[i].y
|
|
116
|
+
}
|
|
117
|
+
i++
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function layoutCircle(n, cx, cy, radius) {
|
|
122
|
+
if (n === 1) return [{ x: cx, y: cy }]
|
|
123
|
+
const positions = []
|
|
124
|
+
const startAngle = -Math.PI / 2
|
|
125
|
+
for (let i = 0; i < n; i++) {
|
|
126
|
+
const angle = startAngle + (2 * Math.PI * i) / n
|
|
127
|
+
positions.push({
|
|
128
|
+
x: cx + Math.cos(angle) * radius,
|
|
129
|
+
y: cy + Math.sin(angle) * radius,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
return positions
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- nodes ----
|
|
136
|
+
function addNode(instanceId, cwd = "") {
|
|
137
|
+
if (nodes.has(instanceId)) return
|
|
138
|
+
const color = PALETTE[nextColorIndex % PALETTE.length]
|
|
139
|
+
nextColorIndex++
|
|
140
|
+
|
|
141
|
+
const cx = window.innerWidth / 2
|
|
142
|
+
const cy = window.innerHeight / 2 - 20
|
|
143
|
+
|
|
144
|
+
nodes.set(instanceId, {
|
|
145
|
+
id: instanceId,
|
|
146
|
+
cwd,
|
|
147
|
+
hue: color.h,
|
|
148
|
+
sat: color.s,
|
|
149
|
+
light: color.l,
|
|
150
|
+
x: cx,
|
|
151
|
+
y: cy,
|
|
152
|
+
tx: cx,
|
|
153
|
+
ty: cy,
|
|
154
|
+
radius: 0,
|
|
155
|
+
targetRadius: 18,
|
|
156
|
+
pulse: 0,
|
|
157
|
+
pulsePhase: Math.random() * Math.PI * 2,
|
|
158
|
+
status: "idle",
|
|
159
|
+
lastEventTime: 0,
|
|
160
|
+
particles: [],
|
|
161
|
+
shakeX: 0,
|
|
162
|
+
shakeY: 0,
|
|
163
|
+
shakeTimer: 0,
|
|
164
|
+
errorFlash: 0,
|
|
165
|
+
toolBurst: 0,
|
|
166
|
+
messageGlow: 0,
|
|
167
|
+
})
|
|
168
|
+
recalculateLayout()
|
|
169
|
+
updateOverlay()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function removeNode(instanceId) {
|
|
173
|
+
nodes.delete(instanceId)
|
|
174
|
+
recalculateLayout()
|
|
175
|
+
updateOverlay()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function clearAllNodes() {
|
|
179
|
+
nodes.clear()
|
|
180
|
+
recalculateLayout()
|
|
181
|
+
updateOverlay()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function getNode(instanceId) {
|
|
185
|
+
return nodes.get(instanceId)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---- particles ----
|
|
189
|
+
function emitParticles(node, count, speed, life, hue, sat, light) {
|
|
190
|
+
for (let i = 0; i < count; i++) {
|
|
191
|
+
const angle = Math.random() * Math.PI * 2
|
|
192
|
+
const spd = speed * (0.5 + Math.random())
|
|
193
|
+
node.particles.push({
|
|
194
|
+
x: 0,
|
|
195
|
+
y: 0,
|
|
196
|
+
vx: Math.cos(angle) * spd,
|
|
197
|
+
vy: Math.sin(angle) * spd,
|
|
198
|
+
life,
|
|
199
|
+
maxLife: life,
|
|
200
|
+
size: 1.5 + Math.random() * 3,
|
|
201
|
+
hue,
|
|
202
|
+
sat,
|
|
203
|
+
light: light + Math.random() * 20,
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function handleEvent(instanceId, event) {
|
|
209
|
+
const node = getNode(instanceId)
|
|
210
|
+
if (!node) return
|
|
211
|
+
|
|
212
|
+
eventCount++
|
|
213
|
+
node.lastEventTime = performance.now()
|
|
214
|
+
updateOverlay()
|
|
215
|
+
|
|
216
|
+
switch (event.type) {
|
|
217
|
+
case "session.status":
|
|
218
|
+
{
|
|
219
|
+
const st = event.status?.type || event.status
|
|
220
|
+
if (st === "busy") {
|
|
221
|
+
node.status = "busy"
|
|
222
|
+
node.targetRadius = 50
|
|
223
|
+
node.label = "Думаю"
|
|
224
|
+
} else if (st === "retry") {
|
|
225
|
+
node.status = "retry"
|
|
226
|
+
node.targetRadius = 45
|
|
227
|
+
node.label = "Повтор"
|
|
228
|
+
} else {
|
|
229
|
+
node.status = "idle"
|
|
230
|
+
node.targetRadius = 18
|
|
231
|
+
node.label = "Жду"
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
break
|
|
235
|
+
|
|
236
|
+
case "session.idle":
|
|
237
|
+
node.status = "idle"
|
|
238
|
+
node.targetRadius = 18
|
|
239
|
+
node.label = "Жду"
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
case "session.error":
|
|
243
|
+
node.status = "error"
|
|
244
|
+
node.errorFlash = 1
|
|
245
|
+
node.shakeTimer = 600
|
|
246
|
+
node.targetRadius = 48
|
|
247
|
+
node.label = "Ошибка"
|
|
248
|
+
emitParticles(node, 12, 2, 80, 0, 100, 50)
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
case "session.created":
|
|
252
|
+
node.targetRadius = 35
|
|
253
|
+
node.label = "Старт"
|
|
254
|
+
emitParticles(node, 20, 3, 100, node.hue, node.sat, node.light)
|
|
255
|
+
break
|
|
256
|
+
|
|
257
|
+
case "session.deleted":
|
|
258
|
+
node.targetRadius = 0
|
|
259
|
+
emitParticles(node, 30, 4, 120, node.hue, node.sat, 80)
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
case "tool.execute.after":
|
|
263
|
+
case "tool.execute.before":
|
|
264
|
+
node.toolBurst = 1
|
|
265
|
+
emitParticles(node, 10, 1.5, 60, node.hue, node.sat, node.light)
|
|
266
|
+
break
|
|
267
|
+
|
|
268
|
+
case "message.updated":
|
|
269
|
+
node.messageGlow = 1
|
|
270
|
+
emitParticles(node, 5, 0.8, 50, node.hue, node.sat, 80)
|
|
271
|
+
break
|
|
272
|
+
|
|
273
|
+
case "todo.updated":
|
|
274
|
+
node.messageGlow = 0.5
|
|
275
|
+
emitParticles(node, 3, 0.5, 40, node.hue, node.sat, node.light)
|
|
276
|
+
break
|
|
277
|
+
|
|
278
|
+
case "session.diff":
|
|
279
|
+
node.toolBurst = 0.7
|
|
280
|
+
emitParticles(node, 6, 1, 55, 45, 90, 60)
|
|
281
|
+
break
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---- update loop ----
|
|
286
|
+
function update(dt) {
|
|
287
|
+
for (const [, node] of nodes) {
|
|
288
|
+
// smooth position
|
|
289
|
+
node.x += (node.tx - node.x) * 0.08
|
|
290
|
+
node.y += (node.ty - node.y) * 0.08
|
|
291
|
+
|
|
292
|
+
// smooth radius
|
|
293
|
+
node.radius += (node.targetRadius - node.radius) * 0.1
|
|
294
|
+
|
|
295
|
+
// pulse (only when busy/retry)
|
|
296
|
+
if (node.status === "busy" || node.status === "retry") {
|
|
297
|
+
const pulseSpeed = node.status === "retry" ? 2 : 4
|
|
298
|
+
node.pulsePhase += dt * pulseSpeed
|
|
299
|
+
node.pulse = Math.sin(node.pulsePhase) * 0.5 + 0.5
|
|
300
|
+
} else {
|
|
301
|
+
node.pulse = 0
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// error flash decay
|
|
305
|
+
if (node.errorFlash > 0) {
|
|
306
|
+
node.errorFlash = Math.max(0, node.errorFlash - dt * 2.5)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// tool burst decay
|
|
310
|
+
if (node.toolBurst > 0) {
|
|
311
|
+
node.toolBurst = Math.max(0, node.toolBurst - dt * 3)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// message glow decay
|
|
315
|
+
if (node.messageGlow > 0) {
|
|
316
|
+
node.messageGlow = Math.max(0, node.messageGlow - dt * 2)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// shake decay
|
|
320
|
+
if (node.shakeTimer > 0) {
|
|
321
|
+
node.shakeTimer -= dt * 1000
|
|
322
|
+
const intensity = node.shakeTimer / 600 * 4
|
|
323
|
+
node.shakeX = (Math.random() - 0.5) * intensity
|
|
324
|
+
node.shakeY = (Math.random() - 0.5) * intensity
|
|
325
|
+
} else {
|
|
326
|
+
node.shakeX = 0
|
|
327
|
+
node.shakeY = 0
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// particles update
|
|
331
|
+
for (const p of node.particles) {
|
|
332
|
+
p.x += p.vx * dt * 60
|
|
333
|
+
p.y += p.vy * dt * 60
|
|
334
|
+
p.life -= dt * 60
|
|
335
|
+
}
|
|
336
|
+
node.particles = node.particles.filter(p => p.life > 0)
|
|
337
|
+
|
|
338
|
+
// continuous particle emission only while busy
|
|
339
|
+
if (node.status === "busy" && Math.random() < dt * 8) {
|
|
340
|
+
node.particles.push({
|
|
341
|
+
x: 0,
|
|
342
|
+
y: 0,
|
|
343
|
+
vx: (Math.random() - 0.5) * 0.8,
|
|
344
|
+
vy: -1 - Math.random(),
|
|
345
|
+
life: 40 + Math.random() * 20,
|
|
346
|
+
maxLife: 60,
|
|
347
|
+
size: 1 + Math.random() * 2,
|
|
348
|
+
hue: node.hue,
|
|
349
|
+
sat: node.sat,
|
|
350
|
+
light: node.light,
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---- draw ----
|
|
357
|
+
function drawBackground(W, H) {
|
|
358
|
+
// subtle radial gradient
|
|
359
|
+
const grad = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) * 0.7)
|
|
360
|
+
grad.addColorStop(0, "rgba(20, 20, 40, 0.3)")
|
|
361
|
+
grad.addColorStop(1, "rgba(8, 8, 15, 0)")
|
|
362
|
+
ctx.fillStyle = "#08080f"
|
|
363
|
+
ctx.fillRect(0, 0, W, H)
|
|
364
|
+
ctx.fillStyle = grad
|
|
365
|
+
ctx.fillRect(0, 0, W, H)
|
|
366
|
+
|
|
367
|
+
// subtle dot grid
|
|
368
|
+
ctx.fillStyle = "rgba(255, 255, 255, 0.015)"
|
|
369
|
+
const spacing = 30
|
|
370
|
+
for (let x = spacing; x < W; x += spacing) {
|
|
371
|
+
for (let y = spacing; y < H; y += spacing) {
|
|
372
|
+
ctx.beginPath()
|
|
373
|
+
ctx.arc(x, y, 1, 0, Math.PI * 2)
|
|
374
|
+
ctx.fill()
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function drawConnections(W, H) {
|
|
380
|
+
const cx = W / 2
|
|
381
|
+
const cy = H / 2 - 20
|
|
382
|
+
|
|
383
|
+
for (const [, node] of nodes) {
|
|
384
|
+
if (node.radius < 2) continue
|
|
385
|
+
const dx = cx - (node.x + node.shakeX)
|
|
386
|
+
const dy = cy - (node.y + node.shakeY)
|
|
387
|
+
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
388
|
+
|
|
389
|
+
if (dist < 2) continue
|
|
390
|
+
|
|
391
|
+
// connection line to center
|
|
392
|
+
const alpha = 0.06 + node.pulse * 0.06
|
|
393
|
+
ctx.strokeStyle = `hsla(${node.hue}, ${node.sat}%, ${node.light}%, ${alpha})`
|
|
394
|
+
ctx.lineWidth = 1
|
|
395
|
+
ctx.beginPath()
|
|
396
|
+
ctx.moveTo(node.x + node.shakeX, node.y + node.shakeY)
|
|
397
|
+
ctx.lineTo(cx, cy)
|
|
398
|
+
ctx.stroke()
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// draw center hub
|
|
402
|
+
if (nodes.size > 0) {
|
|
403
|
+
const active = [...nodes.values()].some(n => n.status === "busy")
|
|
404
|
+
const baseR = 4
|
|
405
|
+
const r = active ? baseR + 2 * Math.sin(performance.now() / 1000 * 3) : baseR
|
|
406
|
+
ctx.fillStyle = "rgba(255, 255, 255, 0.15)"
|
|
407
|
+
ctx.beginPath()
|
|
408
|
+
ctx.arc(cx, cy, r, 0, Math.PI * 2)
|
|
409
|
+
ctx.fill()
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function drawNode(node) {
|
|
414
|
+
if (node.radius < 1) return
|
|
415
|
+
|
|
416
|
+
const x = node.x + node.shakeX
|
|
417
|
+
const y = node.y + node.shakeY
|
|
418
|
+
const r = node.radius
|
|
419
|
+
const pulseBoost = 1 + node.pulse * 0.15
|
|
420
|
+
|
|
421
|
+
// glow layers
|
|
422
|
+
const glowAlpha = 0.06 + node.pulse * 0.08 + node.toolBurst * 0.12 + node.messageGlow * 0.06
|
|
423
|
+
for (let i = 3; i >= 1; i--) {
|
|
424
|
+
const gr = r * (1.5 + i * 0.6) * pulseBoost
|
|
425
|
+
ctx.fillStyle = `hsla(${node.hue}, ${node.sat}%, ${node.light}%, ${glowAlpha / i})`
|
|
426
|
+
ctx.beginPath()
|
|
427
|
+
ctx.arc(x, y, gr, 0, Math.PI * 2)
|
|
428
|
+
ctx.fill()
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// main orb
|
|
432
|
+
const orbGrad = ctx.createRadialGradient(x - r * 0.2, y - r * 0.2, r * 0.1, x, y, r)
|
|
433
|
+
const baseLight = node.errorFlash > 0
|
|
434
|
+
? 30 + node.errorFlash * 30
|
|
435
|
+
: node.light + node.toolBurst * 15 + node.messageGlow * 10
|
|
436
|
+
|
|
437
|
+
const hueShift = node.errorFlash > 0 ? 0 : node.hue
|
|
438
|
+
orbGrad.addColorStop(0, `hsla(${hueShift}, ${node.sat}%, ${baseLight + 30}%, 0.9)`)
|
|
439
|
+
orbGrad.addColorStop(0.3, `hsla(${hueShift}, ${node.sat}%, ${baseLight}%, 0.7)`)
|
|
440
|
+
orbGrad.addColorStop(0.7, `hsla(${hueShift}, ${node.sat * 0.6}%, ${baseLight * 0.4}%, 0.4)`)
|
|
441
|
+
orbGrad.addColorStop(1, `hsla(${hueShift}, ${node.sat * 0.3}%, ${baseLight * 0.2}%, 0)`)
|
|
442
|
+
|
|
443
|
+
ctx.fillStyle = orbGrad
|
|
444
|
+
ctx.beginPath()
|
|
445
|
+
ctx.arc(x, y, r * pulseBoost, 0, Math.PI * 2)
|
|
446
|
+
ctx.fill()
|
|
447
|
+
|
|
448
|
+
// border ring
|
|
449
|
+
ctx.strokeStyle = `hsla(${node.hue}, ${node.sat}%, ${baseLight + 20}%, ${0.2 + node.pulse * 0.3})`
|
|
450
|
+
ctx.lineWidth = 1.5
|
|
451
|
+
ctx.beginPath()
|
|
452
|
+
ctx.arc(x, y, r * pulseBoost, 0, Math.PI * 2)
|
|
453
|
+
ctx.stroke()
|
|
454
|
+
|
|
455
|
+
// status indicator dot
|
|
456
|
+
if (node.status === "busy" || node.status === "retry" || node.status === "error") {
|
|
457
|
+
const dotColor = node.status === "error" ? "rgba(255, 60, 60, 0.8)"
|
|
458
|
+
: node.status === "retry" ? "rgba(255, 200, 40, 0.8)"
|
|
459
|
+
: "rgba(64, 255, 128, 0.8)"
|
|
460
|
+
ctx.fillStyle = dotColor
|
|
461
|
+
ctx.beginPath()
|
|
462
|
+
ctx.arc(x + r * 0.55, y - r * 0.55, 4, 0, Math.PI * 2)
|
|
463
|
+
ctx.fill()
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function drawParticles(node) {
|
|
468
|
+
for (const p of node.particles) {
|
|
469
|
+
const x = node.x + node.shakeX + p.x
|
|
470
|
+
const y = node.y + node.shakeY + p.y
|
|
471
|
+
const alpha = Math.max(0, p.life / p.maxLife)
|
|
472
|
+
ctx.fillStyle = `hsla(${p.hue}, ${p.sat}%, ${p.light}%, ${alpha})`
|
|
473
|
+
ctx.beginPath()
|
|
474
|
+
ctx.arc(x, y, p.size * alpha, 0, Math.PI * 2)
|
|
475
|
+
ctx.fill()
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function drawLabel(node) {
|
|
480
|
+
if (node.radius < 15) return
|
|
481
|
+
|
|
482
|
+
const x = node.x + node.shakeX
|
|
483
|
+
const y = node.y + node.shakeY
|
|
484
|
+
const label = node.label || ""
|
|
485
|
+
|
|
486
|
+
const alpha = node.status === "idle" ? 0.6 : 0.85
|
|
487
|
+
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`
|
|
488
|
+
ctx.font = "9px 'SF Mono', 'Fira Code', 'Consolas', monospace"
|
|
489
|
+
ctx.textAlign = "center"
|
|
490
|
+
ctx.textBaseline = "middle"
|
|
491
|
+
ctx.fillText(label, x, y)
|
|
492
|
+
|
|
493
|
+
// Folder name below the circle
|
|
494
|
+
const folder = node.cwd ? node.cwd.split("/").pop() || node.cwd : ""
|
|
495
|
+
if (folder) {
|
|
496
|
+
ctx.fillStyle = "rgba(255, 255, 255, 0.3)"
|
|
497
|
+
ctx.font = "8px 'SF Mono', 'Fira Code', 'Consolas', monospace"
|
|
498
|
+
ctx.fillText(folder, x, y + node.radius + 10)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function draw() {
|
|
503
|
+
const W = window.innerWidth
|
|
504
|
+
const H = window.innerHeight
|
|
505
|
+
|
|
506
|
+
ctx.clearRect(0, 0, W, H)
|
|
507
|
+
drawBackground(W, H)
|
|
508
|
+
drawConnections(W, H)
|
|
509
|
+
|
|
510
|
+
for (const [, node] of nodes) {
|
|
511
|
+
drawParticles(node)
|
|
512
|
+
drawNode(node)
|
|
513
|
+
drawLabel(node)
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function updateOverlay() {
|
|
518
|
+
elInstances.textContent = nodes.size
|
|
519
|
+
elEvents.textContent = eventCount
|
|
520
|
+
const uptime = Math.floor((Date.now() - startTime) / 1000)
|
|
521
|
+
elUptime.textContent = uptime + "s"
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ---- main loop ----
|
|
525
|
+
let lastTime = performance.now()
|
|
526
|
+
|
|
527
|
+
function loop(time) {
|
|
528
|
+
const dt = Math.min((time - lastTime) / 1000, 0.1)
|
|
529
|
+
lastTime = time
|
|
530
|
+
|
|
531
|
+
update(dt)
|
|
532
|
+
draw()
|
|
533
|
+
requestAnimationFrame(loop)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ---- websocket ----
|
|
537
|
+
function connect() {
|
|
538
|
+
const proto = location.protocol === "https:" ? "wss" : "ws"
|
|
539
|
+
const sock = new WebSocket(`${proto}://${location.host}`)
|
|
540
|
+
|
|
541
|
+
sock.onopen = () => {
|
|
542
|
+
clearAllNodes()
|
|
543
|
+
sock.send(JSON.stringify({ type: "frontend.register" }))
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
sock.onmessage = (e) => {
|
|
547
|
+
let data
|
|
548
|
+
try { data = JSON.parse(e.data) } catch { return }
|
|
549
|
+
|
|
550
|
+
switch (data.type) {
|
|
551
|
+
case "state.sync":
|
|
552
|
+
for (const inst of data.instances || []) {
|
|
553
|
+
addNode(inst.instanceId, inst.cwd)
|
|
554
|
+
}
|
|
555
|
+
break
|
|
556
|
+
|
|
557
|
+
case "instance.added":
|
|
558
|
+
addNode(data.instanceId, data.cwd)
|
|
559
|
+
break
|
|
560
|
+
|
|
561
|
+
case "instance.removed":
|
|
562
|
+
removeNode(data.instanceId)
|
|
563
|
+
break
|
|
564
|
+
|
|
565
|
+
case "instance.event":
|
|
566
|
+
handleEvent(data.instanceId, data.event)
|
|
567
|
+
break
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
sock.onclose = () => {
|
|
572
|
+
clearAllNodes()
|
|
573
|
+
setTimeout(connect, 2000)
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ---- init ----
|
|
578
|
+
window.addEventListener("resize", resize)
|
|
579
|
+
resize()
|
|
580
|
+
connect()
|
|
581
|
+
requestAnimationFrame(loop)
|
|
582
|
+
|
|
583
|
+
// uptime tick
|
|
584
|
+
setInterval(updateOverlay, 1000)
|
|
585
|
+
})()
|
|
586
|
+
</script>
|
|
587
|
+
</body>
|
|
588
|
+
</html>
|