@el4cteo/rbx-studio-mcp 0.3.1 → 0.3.5

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.
Files changed (37) hide show
  1. package/README.md +20 -26
  2. package/dist/index.js +1 -1
  3. package/dist/lib/format.js +18 -2
  4. package/dist/lib/format.js.map +1 -1
  5. package/dist/tools/api.js +20 -1
  6. package/dist/tools/api.js.map +1 -1
  7. package/dist/tools/debug.js +18 -8
  8. package/dist/tools/debug.js.map +1 -1
  9. package/dist/tools/discover.js +5 -0
  10. package/dist/tools/discover.js.map +1 -1
  11. package/dist/tools/instances.js +49 -2
  12. package/dist/tools/instances.js.map +1 -1
  13. package/dist/tools/perf.js +9 -8
  14. package/dist/tools/perf.js.map +1 -1
  15. package/dist/tools/scripts.js +10 -1
  16. package/dist/tools/scripts.js.map +1 -1
  17. package/package.json +1 -1
  18. package/plugin/src/Config.luau +1 -1
  19. package/plugin/src/Console.luau +257 -116
  20. package/plugin/src/Serialize.luau +43 -3
  21. package/plugin/src/ThemePicker.luau +458 -0
  22. package/plugin/src/Themes/Aurora.luau +147 -0
  23. package/plugin/src/Themes/Blueprint.luau +213 -0
  24. package/plugin/src/Themes/Draw.luau +177 -0
  25. package/plugin/src/Themes/Lattice.luau +304 -0
  26. package/plugin/src/Themes/Nebula.luau +182 -0
  27. package/plugin/src/Themes/Observatory.luau +200 -0
  28. package/plugin/src/Themes/Orbit.luau +268 -0
  29. package/plugin/src/Themes/Phosphor.luau +200 -0
  30. package/plugin/src/Themes/Theme.luau +121 -0
  31. package/plugin/src/Themes/Void.luau +230 -0
  32. package/plugin/src/Themes/init.luau +116 -0
  33. package/plugin/src/Visuals.luau +788 -907
  34. package/plugin/src/handlers/Discover.luau +75 -1
  35. package/plugin/src/handlers/Exec.luau +17 -5
  36. package/plugin/src/handlers/Instances.luau +35 -3
  37. package/plugin/src/init.server.luau +386 -354
@@ -1,907 +1,788 @@
1
- --!strict
2
- --[[
3
- The console's activity band: a turning wireframe beside a live latency trace.
4
-
5
- Watching an agent work is mostly waiting, and the log answers "what is it
6
- doing" precisely while answering "is it still alive" badly -- a stalled
7
- session and a thinking one produce the same still screen.
8
-
9
- This began as a full-panel overlay and that was wrong. It sat on top of the
10
- log, so the thing you actually read was covered and, being slightly
11
- transparent, softened underneath. Decoration that costs legibility is a bad
12
- trade however good it looks. So it is a band now: a fixed strip above the
13
- log, which keeps every line at full contrast and never moves under the
14
- reader. The log shrinks by the band's height and nothing overlaps.
15
-
16
- The strip earns its space twice over. The wireframe on the left says the
17
- session is live; the trace beside it is the last forty calls, each a bar as
18
- tall as it was slow and coloured by whether it worked. That turns "is it
19
- alive" and "is it healthy" into one glance, which is more than the log gives
20
- and more than an animation alone would.
21
-
22
- The solid is a real projection, not a canned loop: vertices rotate in three
23
- dimensions and pass through a perspective divide every frame, so near edges
24
- are brighter and thicker because they genuinely are nearer. Roblox has no
25
- line primitive, so each edge is a thin Frame turned about its midpoint, and
26
- the frames are pooled -- allocating Instances inside a per-frame loop is how
27
- a decorative strip becomes a performance complaint.
28
- ]]
29
-
30
- local RunService = game:GetService("RunService")
31
-
32
- local Visuals = {}
33
-
34
- local PHI = (1 + math.sqrt(5)) / 2
35
-
36
- type Shape = {
37
- name: string,
38
- vertices: { Vector3 },
39
- edges: { { number } },
40
- }
41
-
42
- --[[
43
- Ordered by how busy they look. The session climbs this list as it works, so
44
- the shape itself reports load: an idle plugin shows a tetrahedron, a busy one
45
- an icosahedron.
46
- ]]
47
- local SHAPES: { Shape } = {
48
- {
49
- name = "tetrahedron",
50
- vertices = {
51
- Vector3.new(1, 1, 1),
52
- Vector3.new(1, -1, -1),
53
- Vector3.new(-1, 1, -1),
54
- Vector3.new(-1, -1, 1),
55
- },
56
- edges = { { 1, 2 }, { 1, 3 }, { 1, 4 }, { 2, 3 }, { 2, 4 }, { 3, 4 } },
57
- },
58
- {
59
- name = "octahedron",
60
- vertices = {
61
- Vector3.new(1, 0, 0),
62
- Vector3.new(-1, 0, 0),
63
- Vector3.new(0, 1, 0),
64
- Vector3.new(0, -1, 0),
65
- Vector3.new(0, 0, 1),
66
- Vector3.new(0, 0, -1),
67
- },
68
- edges = {
69
- { 1, 3 }, { 1, 4 }, { 1, 5 }, { 1, 6 },
70
- { 2, 3 }, { 2, 4 }, { 2, 5 }, { 2, 6 },
71
- { 3, 5 }, { 5, 4 }, { 4, 6 }, { 6, 3 },
72
- },
73
- },
74
- {
75
- name = "cube",
76
- vertices = {
77
- Vector3.new(-1, -1, -1), Vector3.new(1, -1, -1),
78
- Vector3.new(1, 1, -1), Vector3.new(-1, 1, -1),
79
- Vector3.new(-1, -1, 1), Vector3.new(1, -1, 1),
80
- Vector3.new(1, 1, 1), Vector3.new(-1, 1, 1),
81
- },
82
- edges = {
83
- { 1, 2 }, { 2, 3 }, { 3, 4 }, { 4, 1 },
84
- { 5, 6 }, { 6, 7 }, { 7, 8 }, { 8, 5 },
85
- { 1, 5 }, { 2, 6 }, { 3, 7 }, { 4, 8 },
86
- },
87
- },
88
- {
89
- name = "icosahedron",
90
- vertices = {
91
- Vector3.new(0, 1, PHI), Vector3.new(0, -1, PHI),
92
- Vector3.new(0, 1, -PHI), Vector3.new(0, -1, -PHI),
93
- Vector3.new(1, PHI, 0), Vector3.new(-1, PHI, 0),
94
- Vector3.new(1, -PHI, 0), Vector3.new(-1, -PHI, 0),
95
- Vector3.new(PHI, 0, 1), Vector3.new(PHI, 0, -1),
96
- Vector3.new(-PHI, 0, 1), Vector3.new(-PHI, 0, -1),
97
- },
98
- edges = {
99
- { 1, 2 }, { 1, 5 }, { 1, 6 }, { 1, 9 }, { 1, 11 },
100
- { 2, 7 }, { 2, 8 }, { 2, 9 }, { 2, 11 },
101
- { 3, 4 }, { 3, 5 }, { 3, 6 }, { 3, 10 }, { 3, 12 },
102
- { 4, 7 }, { 4, 8 }, { 4, 10 }, { 4, 12 },
103
- { 5, 6 }, { 5, 9 }, { 5, 10 },
104
- { 6, 11 }, { 6, 12 },
105
- { 7, 8 }, { 7, 9 }, { 7, 10 },
106
- { 8, 11 }, { 8, 12 },
107
- { 9, 10 }, { 11, 12 },
108
- },
109
- },
110
- }
111
-
112
- local MAX_EDGES = 30
113
- local MAX_VERTICES = 12
114
- local FOCAL = 3.4
115
-
116
- -- The inner solid, counter-rotating inside the outer one. An octahedron
117
- -- regardless of what is outside it: it reads as a core rather than as a second
118
- -- object competing for attention, and its 12 edges are cheap.
119
- local INNER_SHAPE = 2
120
- local INNER_SCALE = 0.42
121
-
122
- -- The strip's height, and the width reserved for the solid at its left end.
123
- -- The solid was originally as tall as the band and read as looming: it crowded
124
- -- the caption and made a 46px strip feel like a panel. It is an indicator, not
125
- -- the subject, so it sits small with air around it.
126
- --
127
- -- Both grew when the solid learned to overshoot. `cell` clips its descendants,
128
- -- so a shape that swells to 1.4x has to have somewhere to swell into, and the
129
- -- extra band height goes to the trace, whose bars are what people try to point
130
- -- at.
131
- Visuals.BAND_HEIGHT = 52
132
- local CELL = 40
133
- local GUTTER = 16
134
-
135
- --[[
136
- The solid's resting radius as a fraction of the cell.
137
-
138
- Tuned against the new cell so resting size is unchanged: 40 x 0.185 is 7.4px
139
- where 34 x 0.2 was 6.8px. At full overshoot the perspective divide pushes a
140
- near vertex to roughly 15px, which still clears the cell's 20px half-width.
141
- ]]
142
- local BASE_RADIUS = 0.185
143
-
144
- --[[
145
- Spring constants for the scale. Stiff enough that a call reads as a snap
146
- rather than a swell, damped just under critical so a reply overshoots once
147
- and settles instead of ringing.
148
- ]]
149
- local SPRING = 90
150
- local DAMPING = 14
151
-
152
- -- How long the session must go without a command before the solid winds down.
153
- -- Matches the console's idle notice, so the picture and the log agree.
154
- local QUIET_AFTER = 20
155
-
156
- -- How many calls the trace remembers. Forty is about a screen's width of bars
157
- -- at two pixels each, and about as far back as anyone reads a trend.
158
- local TRACE = 40
159
-
160
- -- Bars are scaled against this, so a normal call is a low bar and a slow one is
161
- -- obvious. Anything past it clamps to full height rather than flattening the
162
- -- rest of the trace into invisibility.
163
- local SLOW_MS = 400
164
-
165
- -- Pixels from one bar to the next. Also the width of a bar's hit target and of
166
- -- the hover highlight, so pointing between two bars still selects one of them
167
- -- rather than falling into a dead gap.
168
- local BAR_STRIDE = 5
169
-
170
- -- Height of the trace, and so of every bar's hit column. The taller band spends
171
- -- its extra pixels here: a 19px column was a small thing to aim a pointer at.
172
- local TRACE_HEIGHT = 26
173
-
174
- type Bar = {
175
- frame: Frame,
176
- --[[
177
- An invisible column over the bar, full height of the trace, wide enough to
178
- hit. The bar itself is three pixels wide and usually one pixel tall -- a
179
- fast call is a stub on the baseline -- so hovering the bar is not something
180
- a person can reliably do. The hit target is the whole column above it, so
181
- pointing anywhere near a bar selects it.
182
- ]]
183
- hit: TextButton,
184
- milliseconds: number,
185
- ok: boolean,
186
- --[[
187
- What ran, in the same words the log uses.
188
-
189
- The trace plotted durations with nothing to attach them to, so a tall bar
190
- raised the question it could not answer: forty numbers and no way to tell
191
- which of them was the script edit. Carried per bar rather than looked up,
192
- because the bar outlives whatever produced it.
193
- ]]
194
- title: string,
195
- }
196
-
197
- type Runtime = {
198
- band: Frame?,
199
- cell: Frame?,
200
- trace: Frame?,
201
- highlight: Frame?,
202
- readout: TextLabel?,
203
- caption: TextLabel?,
204
- edges: { Frame },
205
- innerEdges: { Frame },
206
- dots: { Frame },
207
- bars: { Bar },
208
- connection: RBXScriptConnection?,
209
-
210
- spin: Vector3,
211
- angle: Vector3,
212
- shapeIndex: number,
213
-
214
- energy: number,
215
- shake: number,
216
- --[[
217
- The solid's size, as a spring rather than a value.
218
-
219
- Scale was previously a small function of energy -- a 24% range that
220
- nobody could see. Driving it as a spring means each event can push it and
221
- let physics do the rest: a dispatch pulls it in, a reply throws it out
222
- past its resting size, and it settles on its own.
223
- ]]
224
- scale: number,
225
- scaleVelocity: number,
226
- -- Brightens and fattens the vertices for a moment after a call lands.
227
- flare: number,
228
- -- Seconds since anything last ran. Drives the wind-down.
229
- quiet: number,
230
- tint: Color3,
231
- targetTint: Color3,
232
- baseTint: Color3,
233
- activeTint: Color3,
234
- activity: number,
235
- lastTitle: string?,
236
- palette: { [string]: Color3 },
237
- }
238
-
239
- local runtime: Runtime = {
240
- band = nil,
241
- cell = nil,
242
- trace = nil,
243
- highlight = nil,
244
- readout = nil,
245
- caption = nil,
246
- edges = {},
247
- innerEdges = {},
248
- dots = {},
249
- bars = {},
250
- connection = nil,
251
- spin = Vector3.new(0.18, 0.27, 0.11),
252
- angle = Vector3.zero,
253
- shapeIndex = 1,
254
- energy = 0,
255
- shake = 0,
256
- scale = 1,
257
- scaleVelocity = 0,
258
- flare = 0,
259
- quiet = 0,
260
- tint = Color3.fromRGB(167, 139, 250),
261
- targetTint = Color3.fromRGB(167, 139, 250),
262
- baseTint = Color3.fromRGB(167, 139, 250),
263
- activeTint = Color3.fromRGB(167, 139, 250),
264
- activity = 0,
265
- lastTitle = nil,
266
- palette = {},
267
- }
268
-
269
- local function rotate(point: Vector3, angle: Vector3): Vector3
270
- local sinX, cosX = math.sin(angle.X), math.cos(angle.X)
271
- local sinY, cosY = math.sin(angle.Y), math.cos(angle.Y)
272
- local sinZ, cosZ = math.sin(angle.Z), math.cos(angle.Z)
273
-
274
- local y1 = point.Y * cosX - point.Z * sinX
275
- local z1 = point.Y * sinX + point.Z * cosX
276
- local x2 = point.X * cosY + z1 * sinY
277
- local z2 = -point.X * sinY + z1 * cosY
278
- local x3 = x2 * cosZ - y1 * sinZ
279
- local y3 = x2 * sinZ + y1 * cosZ
280
-
281
- return Vector3.new(x3, y3, z2)
282
- end
283
-
284
- --[[
285
- One edge as a rectangle turned about its midpoint. `Rotation` pivots a
286
- GuiObject around its centre, which is exactly what a segment needs.
287
- ]]
288
- local function drawEdge(frame: Frame, a: Vector2, b: Vector2, thickness: number, color: Color3, transparency: number)
289
- local delta = b - a
290
- local length = delta.Magnitude
291
- if length < 0.5 then
292
- frame.Visible = false
293
- return
294
- end
295
- frame.Visible = true
296
- frame.Position = UDim2.fromOffset((a.X + b.X) / 2, (a.Y + b.Y) / 2)
297
- frame.Size = UDim2.fromOffset(math.ceil(length), thickness)
298
- frame.Rotation = math.deg(math.atan2(delta.Y, delta.X))
299
- frame.BackgroundColor3 = color
300
- frame.BackgroundTransparency = transparency
301
- end
302
-
303
- local function step(delta: number)
304
- local band = runtime.band
305
- if band == nil or not band.Visible then
306
- return
307
- end
308
-
309
- runtime.energy = math.max(0, runtime.energy - delta * 0.9)
310
- runtime.shake = math.max(0, runtime.shake - delta * 2.4)
311
- --[[
312
- Slower than it was. The shape is meant to climb as the session works, and
313
- at the old rates a call had to arrive every three seconds just to hold the
314
- octahedron -- so the icosahedron at the top of the list was, in practice,
315
- unreachable. A burst of four calls now gets there, and it unwinds over
316
- about ten seconds afterwards.
317
- ]]
318
- runtime.activity = math.max(0, runtime.activity - delta * 0.28)
319
- runtime.flare = math.max(0, runtime.flare - delta * 3)
320
- runtime.quiet += delta
321
- --[[
322
- Two colours, blended by how recently something happened.
323
-
324
- `baseTint` is the connection state, which is what the panel should read
325
- as when nothing is going on. `activeTint` is the kind of work last done
326
- -- reading, writing, running, debugging -- and it takes over while that
327
- work is fresh, then fades back. So the strip is not one fixed colour: it
328
- leans toward whatever the session is actually doing and returns to
329
- resting on its own.
330
- ]]
331
- local blend = math.clamp(runtime.energy, 0, 1)
332
- runtime.targetTint = runtime.baseTint:Lerp(runtime.activeTint, blend)
333
- runtime.tint = runtime.tint:Lerp(runtime.targetTint, math.min(1, delta * 5))
334
-
335
- -- Turning faster while busy makes the rate itself readable: a glance says
336
- -- whether anything is happening without reading a word.
337
- --
338
- -- And nearly stopping once the session has gone quiet, for the same reason in
339
- -- reverse: a strip that keeps spinning at its working rate hours after the
340
- -- last command claims an activity that is not happening.
341
- local pace = if runtime.quiet > QUIET_AFTER then 0.25 else 1
342
- runtime.angle += runtime.spin * delta * (1 + runtime.energy * 2.2) * pace
343
-
344
- --[[
345
- Where the solid wants to be, before the spring gets a say.
346
-
347
- Three things move it and they are deliberately ordered. Energy -- how
348
- recently work happened -- swells it while a session is busy. The breathe
349
- is a slow sine that only survives while energy is near zero, so an idle
350
- panel is visibly alive without a resting animation competing with the
351
- reaction to a real call. And past QUIET_AFTER seconds of nothing, it draws
352
- in and stays there: the picture of a session that has stopped.
353
- ]]
354
- local calm = 1 - math.clamp(runtime.energy, 0, 1)
355
- local breathe = math.sin(os.clock() * 0.9) * 0.04 * calm
356
- local target = 1 + math.clamp(runtime.energy, 0, 1.6) * 0.18 + breathe
357
- if runtime.quiet > QUIET_AFTER then
358
- target = 0.82
359
- end
360
-
361
- --[[
362
- A damped spring rather than a lerp, because a lerp cannot overshoot and
363
- overshoot is the whole point: a reply that pushes the solid past its
364
- resting size and lets it fall back reads as a thing being struck, where
365
- easing toward a value reads as a slider being dragged.
366
-
367
- Integrated semi-implicitly -- velocity first, then position from the new
368
- velocity -- which stays stable at the frame times Studio actually hands us
369
- rather than only at small ones.
370
- ]]
371
- local step = math.min(delta, 1 / 30)
372
- runtime.scaleVelocity += (target - runtime.scale) * SPRING * step
373
- runtime.scaleVelocity -= runtime.scaleVelocity * DAMPING * step
374
- runtime.scale += runtime.scaleVelocity * step
375
- runtime.scale = math.clamp(runtime.scale, 0.55, 1.7)
376
-
377
- local shape = SHAPES[runtime.shapeIndex]
378
- local centre = Vector2.new(CELL / 2, CELL / 2)
379
- -- Kept clear of the cell's edge. The perspective divide pushes a near vertex
380
- -- outward, so sizing to the full half-width clipped the solid against its own
381
- -- container whenever a corner swung toward the viewer.
382
- local radius = CELL * BASE_RADIUS * runtime.scale
383
-
384
- -- Wobble the axis rather than the panel: shaking the strip would read as a
385
- -- glitch, tilting the solid reads as it being knocked.
386
- local wobble = if runtime.shake > 0
387
- then Vector3.new(
388
- math.sin(os.clock() * 37) * runtime.shake * 0.12,
389
- math.cos(os.clock() * 29) * runtime.shake * 0.12,
390
- 0
391
- )
392
- else Vector3.zero
393
-
394
- local projected: { Vector2 } = {}
395
- local depths: { number } = {}
396
- for index, vertex in shape.vertices do
397
- local turned = rotate(vertex, runtime.angle + wobble)
398
- local scale = FOCAL / (FOCAL - turned.Z)
399
- projected[index] = centre + Vector2.new(turned.X * scale * radius, turned.Y * scale * radius)
400
- depths[index] = turned.Z
401
- end
402
-
403
- for index = 1, MAX_EDGES do
404
- local frame = runtime.edges[index]
405
- local edge = shape.edges[index]
406
- if edge == nil then
407
- frame.Visible = false
408
- continue
409
- end
410
- local a, b = edge[1], edge[2]
411
- local depth = math.clamp(((depths[a] + depths[b]) / 2 + 2) / 4, 0, 1)
412
- drawEdge(
413
- frame,
414
- projected[a],
415
- projected[b],
416
- math.max(1, math.floor(1 + depth * 1.6 + runtime.flare * 0.8)),
417
- runtime.tint:Lerp(Color3.new(1, 1, 1), depth * 0.35 + runtime.flare * 0.3),
418
- math.max(0, 0.84 - depth * 0.74 - runtime.flare * 0.3)
419
- )
420
- end
421
-
422
- --[[
423
- Vertices as points, brighter than the edges they join.
424
-
425
- Wireframes read as flat when every stroke is the same weight. Lighting
426
- the corners gives the eye something to track as the solid turns, which is
427
- what makes it read as rotating rather than merely flickering.
428
- ]]
429
- for index = 1, MAX_VERTICES do
430
- local dot = runtime.dots[index]
431
- local point = projected[index]
432
- if point == nil then
433
- dot.Visible = false
434
- continue
435
- end
436
- local depth = math.clamp((depths[index] + 2) / 4, 0, 1)
437
- -- The corners carry the flare. A pulse of light travelling through the
438
- -- vertices is what makes a completed call land as an event rather than as
439
- -- a bar quietly appearing somewhere off to the right.
440
- local size = math.max(2, math.floor(1.5 + depth * 2.5 + runtime.flare * 2.5))
441
- dot.Visible = true
442
- dot.Position = UDim2.fromOffset(point.X, point.Y)
443
- dot.Size = UDim2.fromOffset(size, size)
444
- dot.BackgroundColor3 = runtime.tint:Lerp(Color3.new(1, 1, 1), 0.25 + depth * 0.5)
445
- dot.BackgroundTransparency = math.max(0, 0.55 - depth * 0.5 - runtime.flare * 0.45)
446
- end
447
-
448
- --[[
449
- A second solid inside the first, turning the other way.
450
-
451
- One rotating outline is a loading spinner. Two, at different rates and
452
- opposite directions, read as a mechanism -- and the counter-rotation
453
- makes the outer solid's direction obvious, which a lone wireframe hides
454
- whenever it passes through a symmetrical angle.
455
- ]]
456
- local inner = SHAPES[INNER_SHAPE]
457
- local innerAngle = -runtime.angle * 1.6
458
- local innerProjected: { Vector2 } = {}
459
- local innerDepths: { number } = {}
460
- for index, vertex in inner.vertices do
461
- local turned = rotate(vertex, innerAngle)
462
- local scale = FOCAL / (FOCAL - turned.Z)
463
- innerProjected[index] = centre
464
- + Vector2.new(turned.X * scale * radius * INNER_SCALE, turned.Y * scale * radius * INNER_SCALE)
465
- innerDepths[index] = turned.Z
466
- end
467
- for index, frame in runtime.innerEdges do
468
- local edge = inner.edges[index]
469
- if edge == nil then
470
- frame.Visible = false
471
- continue
472
- end
473
- local a, b = edge[1], edge[2]
474
- local depth = math.clamp(((innerDepths[a] + innerDepths[b]) / 2 + 2) / 4, 0, 1)
475
- drawEdge(
476
- frame,
477
- innerProjected[a],
478
- innerProjected[b],
479
- 1,
480
- runtime.tint:Lerp(Color3.new(1, 1, 1), 0.5),
481
- 0.9 - depth * 0.35
482
- )
483
- end
484
- end
485
-
486
- --[[
487
- Puts the highlight behind one bar and prints its timing.
488
-
489
- The trace is only legible if you already know what it plots, and nothing on
490
- screen said so. Hovering answers it directly: the column lights up, and the
491
- number that made that bar its height appears in words. One reading at a time,
492
- so a single highlight and a single label are moved around rather than one of
493
- each being built per bar.
494
- ]]
495
- local function showReading(entry: Bar)
496
- local highlight = runtime.highlight
497
- local readout = runtime.readout
498
- if highlight == nil or readout == nil then
499
- return
500
- end
501
-
502
- --[[
503
- The Y here has to be 1, and was 0.
504
-
505
- The highlight is anchored to its own bottom edge, so a Y scale of 0 put
506
- that edge on the trace's top line and drew the whole 26px column above it
507
- -- outside a frame that clips its descendants. The hover has therefore
508
- never been visible to anyone: the hit targets fired, the readout appeared,
509
- and the highlight they were meant to explain was off screen every time.
510
- ]]
511
- highlight.Position = UDim2.new(entry.hit.Position.X.Scale, entry.hit.Position.X.Offset, 1, 0)
512
- highlight.Visible = true
513
-
514
- --[[
515
- Named, not just timed.
516
-
517
- A duration with nothing attached to it raises the question it cannot
518
- answer -- forty bars, one of them tall, and no way to tell whether that
519
- was a script edit or a screenshot. The title is the same phrase the log
520
- row uses, so pointing at a bar and reading the log agree with each other.
521
- ]]
522
- readout.Text = if entry.ok
523
- then string.format("%s: %d ms", entry.title, math.round(entry.milliseconds))
524
- else string.format("%s: failed after %d ms", entry.title, math.round(entry.milliseconds))
525
- readout.TextColor3 = if entry.ok
526
- then runtime.palette.text or Color3.fromRGB(226, 232, 240)
527
- else runtime.palette.red or Color3.fromRGB(251, 113, 133)
528
- readout.Visible = true
529
- end
530
-
531
- local function clearReading()
532
- if runtime.highlight then
533
- runtime.highlight.Visible = false
534
- end
535
- if runtime.readout then
536
- runtime.readout.Visible = false
537
- end
538
- end
539
-
540
- --[[
541
- Records one finished call as a bar on the trace.
542
-
543
- Height is duration and colour is outcome, so a slow call and a failed one are
544
- distinguishable at a glance -- which is the pair of questions that actually
545
- get asked of a log this size.
546
- ]]
547
- function Visuals.recordCall(milliseconds: number, ok: boolean, title: string)
548
- runtime.energy = math.min(1.6, runtime.energy + (if ok then 0.3 else 0.8))
549
- runtime.activity = math.min(1, runtime.activity + 0.34)
550
- runtime.shapeIndex = math.clamp(1 + math.floor(runtime.activity * (#SHAPES - 0.001)), 1, #SHAPES)
551
- runtime.quiet = 0
552
- runtime.flare = 1
553
-
554
- --[[
555
- A shove rather than a new target.
556
-
557
- Setting the scale outright would snap; giving the spring velocity lets it
558
- carry past its resting size and fall back, which is the difference between
559
- a value changing and something being struck. A failure shoves the other
560
- way and knocks the axis with it.
561
- ]]
562
- if ok then
563
- runtime.scaleVelocity += 9
564
- else
565
- runtime.scale = 0.8
566
- runtime.scaleVelocity = -2
567
- runtime.shake = 1
568
- end
569
-
570
- local trace = runtime.trace
571
- if trace == nil then
572
- return
573
- end
574
-
575
- local bar = Instance.new("Frame")
576
- bar.AnchorPoint = Vector2.new(0, 1)
577
- bar.BorderSizePixel = 0
578
- bar.Parent = trace
579
-
580
- -- A TextButton rather than a Frame: buttons take mouse events reliably in a
581
- -- plugin widget, and with no text and no background it is purely a target.
582
- local hit = Instance.new("TextButton")
583
- hit.AnchorPoint = Vector2.new(0.5, 1)
584
- hit.BackgroundTransparency = 1
585
- hit.Text = ""
586
- hit.AutoButtonColor = false
587
- hit.BorderSizePixel = 0
588
- hit.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
589
- hit.Parent = trace
590
-
591
- local entry: Bar = {
592
- frame = bar,
593
- hit = hit,
594
- milliseconds = milliseconds,
595
- ok = ok,
596
- title = if title ~= "" then title else "call",
597
- }
598
- table.insert(runtime.bars, entry)
599
-
600
- hit.MouseEnter:Connect(function()
601
- showReading(entry)
602
- end)
603
- hit.MouseLeave:Connect(function()
604
- clearReading()
605
- end)
606
-
607
- while #runtime.bars > TRACE do
608
- local oldest = runtime.bars[1]
609
- oldest.frame:Destroy()
610
- oldest.hit:Destroy()
611
- table.remove(runtime.bars, 1)
612
- end
613
-
614
- -- Laid out right to left so the newest bar is always at the same edge and
615
- -- the trace reads as scrolling rather than reshuffling.
616
- local total = #runtime.bars
617
- for index, item in runtime.bars do
618
- local fromRight = total - index
619
- local height = math.clamp(item.milliseconds / SLOW_MS, 0.06, 1)
620
- local offset = -(fromRight + 1) * BAR_STRIDE
621
- item.frame.Position = UDim2.new(1, offset, 1, 0)
622
- item.frame.Size = UDim2.new(0, 3, height, 0)
623
- item.frame.BackgroundColor3 = if item.ok
624
- then runtime.palette.violet or runtime.tint
625
- else runtime.palette.red or Color3.fromRGB(251, 113, 133)
626
- item.frame.BackgroundTransparency = 0.15 + (fromRight / TRACE) * 0.6
627
- -- Centred on the bar, so the column a person points at is the one they get.
628
- item.hit.Position = UDim2.new(1, offset + 1, 1, 0)
629
- end
630
-
631
- -- The bars have all moved, so a reading still on screen now names the wrong
632
- -- one. Cheaper and more honest to drop it than to work out which bar the
633
- -- pointer has ended up over.
634
- clearReading()
635
- end
636
-
637
- --[[
638
- The resting colour, which is the connection state.
639
- ]]
640
- function Visuals.setTint(color: Color3)
641
- runtime.baseTint = color
642
- end
643
-
644
- --[[
645
- The colour and pace of the work now running.
646
-
647
- Both are set from the same call because they describe the same thing: what
648
- kind of command this is. Reads are quick and cool, writes are slower and
649
- warm, so the strip's colour and its rate agree with each other instead of
650
- moving independently.
651
- ]]
652
- function Visuals.setKind(color: Color3, urgency: number)
653
- runtime.activeTint = color
654
- runtime.energy = math.min(1.6, math.max(runtime.energy, urgency))
655
- runtime.quiet = 0
656
- --[[
657
- Drawn in as the command goes out, so the pair reads as one gesture: the
658
- solid contracts while the request is in flight and springs open when the
659
- answer arrives. On a fast call the two are almost one motion, which is
660
- itself the report -- a slow call visibly holds its breath.
661
- ]]
662
- runtime.scale = math.min(runtime.scale, 0.85)
663
- runtime.scaleVelocity = math.min(runtime.scaleVelocity, 0)
664
- -- Re-aimed per kind so successive commands of different types visibly
665
- -- change the axis rather than continuing the same turn.
666
- runtime.spin = Vector3.new(
667
- 0.12 + math.random() * 0.16,
668
- 0.18 + math.random() * 0.22,
669
- 0.06 + math.random() * 0.12
670
- )
671
- end
672
-
673
- --[[
674
- What is running, in the same words the log uses. The motion says something is
675
- happening; this says what, and neither answers the other's question.
676
- ]]
677
- function Visuals.setCaption(title: string)
678
- runtime.lastTitle = title
679
- if runtime.caption then
680
- runtime.caption.Text = title
681
- end
682
- end
683
-
684
- --[[
685
- Falls back to the last thing that ran once a command finishes.
686
-
687
- The footer already reports totals, so repeating "idle" here would say the
688
- same word twice on one screen. What has just happened is more useful and is
689
- not shown anywhere else.
690
- ]]
691
- function Visuals.setIdle()
692
- if runtime.caption == nil then
693
- return
694
- end
695
- runtime.caption.Text = if runtime.lastTitle ~= nil
696
- then "last: " .. runtime.lastTitle
697
- else "waiting for a command"
698
- end
699
-
700
- --[[
701
- Winds the solid down, because the session has stopped rather than paused.
702
-
703
- The strip already decays on its own, but decay bottoms out at "idle and
704
- turning", which looks the same after twenty seconds as after two hours. This
705
- is the console telling the picture what it has just told the log, so the two
706
- agree: the shape falls back to a tetrahedron, the spin drops to a quarter
707
- pace, and the solid draws in.
708
- ]]
709
- function Visuals.setQuiet()
710
- runtime.quiet = QUIET_AFTER + 1
711
- runtime.activity = 0
712
- runtime.shapeIndex = 1
713
- end
714
-
715
- --[[
716
- Empties the trace.
717
-
718
- Paired with the console's clear button. The bars used to survive it while the
719
- footer counters reset, so "clear" wiped the log, wiped the statistics, and
720
- left forty timings from the session it had just erased sitting on screen.
721
- ]]
722
- function Visuals.clearTrace()
723
- for _, entry in runtime.bars do
724
- entry.frame:Destroy()
725
- entry.hit:Destroy()
726
- end
727
- table.clear(runtime.bars)
728
- clearReading()
729
- end
730
-
731
- function Visuals.setVisible(visible: boolean)
732
- local band = runtime.band
733
- if band == nil then
734
- return
735
- end
736
- band.Visible = visible
737
-
738
- -- Connected only while on screen: a hidden strip that keeps projecting
739
- -- geometry every frame is a battery complaint waiting to happen.
740
- if visible and runtime.connection == nil then
741
- runtime.connection = RunService.Heartbeat:Connect(step)
742
- elseif not visible and runtime.connection ~= nil then
743
- runtime.connection:Disconnect()
744
- runtime.connection = nil
745
- end
746
- end
747
-
748
- function Visuals.isVisible(): boolean
749
- local band = runtime.band
750
- return band ~= nil and band.Visible
751
- end
752
-
753
- --[[
754
- Builds the strip. Every edge frame is created here and never again, which is
755
- what keeps the per-frame path allocation-free.
756
- ]]
757
- function Visuals.mount(parent: Instance, palette: { [string]: Color3 })
758
- runtime.palette = palette
759
-
760
- local band = Instance.new("Frame")
761
- band.Name = "ActivityBand"
762
- band.BackgroundColor3 = palette.surface
763
- band.BorderSizePixel = 0
764
- band.Visible = false
765
- band.ClipsDescendants = true
766
- band.Parent = parent
767
- runtime.band = band
768
-
769
- local cell = Instance.new("Frame")
770
- cell.Name = "Solid"
771
- cell.Position = UDim2.fromOffset(6, (Visuals.BAND_HEIGHT - CELL) / 2)
772
- cell.Size = UDim2.fromOffset(CELL, CELL)
773
- cell.BackgroundTransparency = 1
774
- cell.ClipsDescendants = true
775
- cell.Parent = band
776
- runtime.cell = cell
777
-
778
- for index = 1, MAX_EDGES do
779
- local edge = Instance.new("Frame")
780
- edge.Name = string.format("Edge%02d", index)
781
- edge.AnchorPoint = Vector2.new(0.5, 0.5)
782
- edge.BorderSizePixel = 0
783
- edge.Visible = false
784
- edge.Parent = cell
785
- runtime.edges[index] = edge
786
- end
787
-
788
- -- A hairline between the solid and the trace, so the strip reads as two
789
- -- instruments rather than one busy rectangle.
790
- local divider = Instance.new("Frame")
791
- divider.Position = UDim2.fromOffset(CELL + GUTTER / 2, 8)
792
- divider.Size = UDim2.new(0, 1, 1, -16)
793
- divider.BackgroundColor3 = palette.dim
794
- divider.BackgroundTransparency = 0.7
795
- divider.BorderSizePixel = 0
796
- divider.Parent = band
797
-
798
- for index = 1, MAX_VERTICES do
799
- local dot = Instance.new("Frame")
800
- dot.Name = string.format("Vertex%02d", index)
801
- dot.AnchorPoint = Vector2.new(0.5, 0.5)
802
- dot.BorderSizePixel = 0
803
- dot.Visible = false
804
- dot.Parent = cell
805
- local round = Instance.new("UICorner")
806
- round.CornerRadius = UDim.new(1, 0)
807
- round.Parent = dot
808
- runtime.dots[index] = dot
809
- end
810
-
811
- for index = 1, #SHAPES[INNER_SHAPE].edges do
812
- local edge = Instance.new("Frame")
813
- edge.Name = string.format("Inner%02d", index)
814
- edge.AnchorPoint = Vector2.new(0.5, 0.5)
815
- edge.BorderSizePixel = 0
816
- edge.Visible = false
817
- edge.Parent = cell
818
- runtime.innerEdges[index] = edge
819
- end
820
-
821
- local caption = Instance.new("TextLabel")
822
- caption.Position = UDim2.new(0, CELL + GUTTER, 0, 4)
823
- caption.Size = UDim2.new(1, -CELL - GUTTER - 12, 0, 14)
824
- caption.BackgroundTransparency = 1
825
- caption.Font = Enum.Font.Code
826
- caption.TextSize = 11
827
- caption.TextColor3 = palette.text
828
- caption.TextXAlignment = Enum.TextXAlignment.Left
829
- caption.TextTruncate = Enum.TextTruncate.AtEnd
830
- caption.Text = "idle"
831
- caption.Parent = band
832
- runtime.caption = caption
833
-
834
- local trace = Instance.new("Frame")
835
- trace.Name = "Trace"
836
- trace.Position = UDim2.new(0, CELL + GUTTER, 0, 21)
837
- trace.Size = UDim2.new(1, -CELL - GUTTER - 12, 0, TRACE_HEIGHT)
838
- trace.BackgroundTransparency = 1
839
- trace.ClipsDescendants = true
840
- trace.Parent = band
841
- runtime.trace = trace
842
-
843
- -- A baseline under the bars, so an empty trace still reads as an instrument
844
- -- waiting for data rather than as a blank gap.
845
- local baseline = Instance.new("Frame")
846
- baseline.AnchorPoint = Vector2.new(0, 1)
847
- baseline.Position = UDim2.fromScale(0, 1)
848
- baseline.Size = UDim2.new(1, 0, 0, 1)
849
- baseline.BackgroundColor3 = palette.dim
850
- baseline.BackgroundTransparency = 0.75
851
- baseline.BorderSizePixel = 0
852
- baseline.Parent = trace
853
-
854
- --[[
855
- Built once and moved, and created BEFORE the bars exist so it sits under
856
- them in draw order -- a highlight drawn over a one-pixel bar would hide the
857
- very thing it is pointing at.
858
- ]]
859
- local highlight = Instance.new("Frame")
860
- highlight.Name = "Highlight"
861
- highlight.AnchorPoint = Vector2.new(0.5, 1)
862
- highlight.Position = UDim2.new(1, 0, 1, 0)
863
- highlight.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
864
- highlight.BackgroundColor3 = palette.text or Color3.fromRGB(226, 232, 240)
865
- -- Faint enough not to hide the bar it sits behind, solid enough to be seen
866
- -- at all. The previous value was chosen for a highlight that never rendered,
867
- -- so it had never been looked at.
868
- highlight.BackgroundTransparency = 0.7
869
- highlight.BorderSizePixel = 0
870
- highlight.Visible = false
871
- highlight.Parent = trace
872
- runtime.highlight = highlight
873
-
874
- --[[
875
- The reading sits at the left end of the trace rather than beside the bar it
876
- describes. Following the pointer would put it off the right edge for the
877
- newest bars, which are the ones most often asked about, and the left end is
878
- empty until forty calls have accumulated.
879
- ]]
880
- local readout = Instance.new("TextLabel")
881
- readout.Name = "Readout"
882
- readout.AnchorPoint = Vector2.new(0, 0.5)
883
- readout.Position = UDim2.new(0, 0, 0.5, 0)
884
- -- Sized by its text now that it carries a name as well as a number. A fixed
885
- -- 110px was enough for "18 ms" and truncates anything with a phrase in front
886
- -- of it, which is the half worth reading.
887
- readout.AutomaticSize = Enum.AutomaticSize.X
888
- readout.Size = UDim2.new(0, 0, 0, 14)
889
- readout.BackgroundColor3 = palette.background or Color3.fromRGB(11, 12, 20)
890
- readout.BackgroundTransparency = 0.15
891
- readout.BorderSizePixel = 0
892
- readout.Font = Enum.Font.Code
893
- readout.TextSize = 11
894
- readout.TextXAlignment = Enum.TextXAlignment.Left
895
- readout.Text = ""
896
- readout.Visible = false
897
- readout.ZIndex = 3
898
- readout.Parent = trace
899
- runtime.readout = readout
900
-
901
- local readoutPadding = Instance.new("UIPadding")
902
- readoutPadding.PaddingLeft = UDim.new(0, 4)
903
- readoutPadding.PaddingRight = UDim.new(0, 4)
904
- readoutPadding.Parent = readout
905
- end
906
-
907
- return Visuals
1
+ --!strict
2
+ --[[
3
+ The console's activity band: a themed cell beside a live latency trace.
4
+
5
+ Watching an agent work is mostly waiting, and the log answers "what is it
6
+ doing" precisely while answering "is it still alive" badly -- a stalled
7
+ session and a thinking one produce the same still screen.
8
+
9
+ This began as a full-panel overlay and that was wrong. It sat on top of the
10
+ log, so the thing you actually read was covered and, being slightly
11
+ transparent, softened underneath. Decoration that costs legibility is a bad
12
+ trade however good it looks. So it is a band now: a fixed strip above the
13
+ log, which keeps every line at full contrast and never moves under the
14
+ reader. The log shrinks by the band's height and nothing overlaps.
15
+
16
+ The strip earns its space twice over. The cell on the left says the session
17
+ is live; the trace beside it is the last forty calls, each drawn as tall as
18
+ it was slow and coloured by whether it worked. That turns "is it alive" and
19
+ "is it healthy" into one glance, which is more than the log gives and more
20
+ than an animation alone would.
21
+
22
+ WHAT THIS FILE OWNS, since it used to own everything: the strip's Instances,
23
+ the pointer handling on the trace, and the simulation -- energy, activity,
24
+ flare, shake, the scale spring, the quiet timer and the blended tint. What
25
+ it does NOT own is any drawing inside the cell. That belongs to whichever
26
+ preset is active (see Themes/), which is handed the simulation each frame
27
+ and paints whatever it likes with it. Eight presets are therefore eight
28
+ painters over one set of physics rather than eight animation systems, and
29
+ only one of them is ever mounted.
30
+ ]]
31
+
32
+ local RunService = game:GetService("RunService")
33
+
34
+ local Themes = require(script.Parent.Themes)
35
+
36
+ local Visuals = {}
37
+
38
+ -- How long the session must go without a command before the cell winds down.
39
+ -- Matches the console's idle notice, so the picture and the log agree.
40
+ local QUIET_AFTER = 20
41
+
42
+ --[[
43
+ Spring constants for the scale. Stiff enough that a call reads as a snap
44
+ rather than a swell, damped just under critical so a reply overshoots once
45
+ and settles instead of ringing.
46
+ ]]
47
+ local SPRING = 90
48
+ local DAMPING = 14
49
+
50
+ -- How many calls the trace remembers. Forty is about a screen's width of bars
51
+ -- at five pixels each, and about as far back as anyone reads a trend.
52
+ local TRACE = 40
53
+
54
+ -- Bars are scaled against this, so a normal call is a low bar and a slow one is
55
+ -- obvious. Anything past it clamps to full height rather than flattening the
56
+ -- rest of the trace into invisibility.
57
+ local SLOW_MS = 400
58
+
59
+ -- Pixels from one bar to the next. Also the width of a bar's hit target and of
60
+ -- the hover highlight, so pointing between two bars still selects one of them
61
+ -- rather than falling into a dead gap.
62
+ local BAR_STRIDE = 5
63
+
64
+ -- Height of the trace, and so of every bar's hit column. The taller band spends
65
+ -- its extra pixels here: a 19px column was a small thing to aim a pointer at.
66
+ local TRACE_HEIGHT = 26
67
+
68
+ --[[
69
+ The strip's height, and the geometry around the cell.
70
+
71
+ The cell was originally as tall as the band and read as looming; it crowded
72
+ the caption and made the strip feel like a panel. It is an indicator, not the
73
+ subject, so it sits small with air around it.
74
+
75
+ INSET and GUTTER both grew after the cell stopped being a wireframe and
76
+ started being eight different things. At six pixels from the panel edge the
77
+ cell was pressed into the corner, which reads as cramped for a prism and
78
+ simply wrong for a starfield -- a sky needs a horizon around it, not a
79
+ border. The band grew with them so the extra margin comes out of empty space
80
+ rather than out of the trace, whose bars are what people try to point at.
81
+ ]]
82
+ Visuals.BAND_HEIGHT = 58
83
+ local CELL = 40
84
+ local INSET = 12
85
+ local GUTTER = 22
86
+
87
+ type Bar = {
88
+ frame: Frame,
89
+ --[[
90
+ An invisible column over the bar, full height of the trace, wide enough to
91
+ hit. The bar itself is three pixels wide and usually one pixel tall -- a
92
+ fast call is a stub on the baseline -- so hovering the bar is not something
93
+ a person can reliably do. The hit target is the whole column above it, so
94
+ pointing anywhere near a bar selects it.
95
+ ]]
96
+ hit: TextButton,
97
+ milliseconds: number,
98
+ ok: boolean,
99
+ --[[
100
+ What ran, in the same words the log uses.
101
+
102
+ The trace plotted durations with nothing to attach them to, so a tall bar
103
+ raised the question it could not answer: forty numbers and no way to tell
104
+ which of them was the script edit. Carried per bar rather than looked up,
105
+ because the bar outlives whatever produced it.
106
+ ]]
107
+ title: string,
108
+ }
109
+
110
+ type Runtime = {
111
+ band: Frame?,
112
+ cell: Frame?,
113
+ trace: Frame?,
114
+ divider: Frame?,
115
+ baseline: Frame?,
116
+ highlight: Frame?,
117
+ readout: TextLabel?,
118
+ caption: TextLabel?,
119
+ bars: { Bar },
120
+ connection: RBXScriptConnection?,
121
+
122
+ spin: Vector3,
123
+ angle: Vector3,
124
+
125
+ energy: number,
126
+ shake: number,
127
+ --[[
128
+ The cell's size, as a spring rather than a value.
129
+
130
+ Scale was previously a small function of energy -- a 24% range that
131
+ nobody could see. Driving it as a spring means each event can push it and
132
+ let physics do the rest: a dispatch pulls it in, a reply throws it out
133
+ past its resting size, and it settles on its own.
134
+ ]]
135
+ scale: number,
136
+ scaleVelocity: number,
137
+ -- Spikes to 1 the instant a reply lands, gone in a third of a second.
138
+ flare: number,
139
+ -- Seconds since anything last ran. Drives the wind-down.
140
+ quiet: number,
141
+ tint: Color3,
142
+ targetTint: Color3,
143
+ baseTint: Color3,
144
+ activeTint: Color3,
145
+ activity: number,
146
+ lastTitle: string?,
147
+ -- Whether the active preset has been given its Instances yet.
148
+ mounted: boolean,
149
+ }
150
+
151
+ local runtime: Runtime = {
152
+ band = nil,
153
+ cell = nil,
154
+ trace = nil,
155
+ divider = nil,
156
+ baseline = nil,
157
+ highlight = nil,
158
+ readout = nil,
159
+ caption = nil,
160
+ bars = {},
161
+ connection = nil,
162
+ spin = Vector3.new(0.18, 0.27, 0.11),
163
+ angle = Vector3.zero,
164
+ energy = 0,
165
+ shake = 0,
166
+ scale = 1,
167
+ scaleVelocity = 0,
168
+ flare = 0,
169
+ quiet = 0,
170
+ tint = Color3.fromRGB(167, 139, 250),
171
+ targetTint = Color3.fromRGB(167, 139, 250),
172
+ baseTint = Color3.fromRGB(167, 139, 250),
173
+ activeTint = Color3.fromRGB(167, 139, 250),
174
+ activity = 0,
175
+ lastTitle = nil,
176
+ mounted = false,
177
+ }
178
+
179
+ --[[
180
+ The simulation, packaged for whichever preset is drawing it.
181
+
182
+ Rebuilt every frame rather than mutated in place. A preset holding onto the
183
+ table between frames would see values change under it, and the one rule that
184
+ keeps eight renderers honest is that they read this and draw -- they do not
185
+ own any of it.
186
+ ]]
187
+ local function context(): Themes.Context
188
+ return {
189
+ palette = Themes.palette(),
190
+ tint = runtime.tint,
191
+ energy = runtime.energy,
192
+ activity = runtime.activity,
193
+ flare = runtime.flare,
194
+ shake = runtime.shake,
195
+ scale = runtime.scale,
196
+ quiet = runtime.quiet,
197
+ angle = runtime.angle,
198
+ clock = os.clock(),
199
+ size = CELL,
200
+ centre = Vector2.new(CELL / 2, CELL / 2),
201
+ }
202
+ end
203
+
204
+ local function step(delta: number)
205
+ local band = runtime.band
206
+ local cell = runtime.cell
207
+ if band == nil or cell == nil or not band.Visible then
208
+ return
209
+ end
210
+
211
+ runtime.energy = math.max(0, runtime.energy - delta * 0.9)
212
+ runtime.shake = math.max(0, runtime.shake - delta * 2.4)
213
+ --[[
214
+ Slower than it was. The cell is meant to climb as the session works, and
215
+ at the old rates a call had to arrive every three seconds just to hold the
216
+ middle of the range -- so the top of it was, in practice, unreachable. A
217
+ burst of four calls now gets there, and it unwinds over about ten seconds
218
+ afterwards.
219
+ ]]
220
+ runtime.activity = math.max(0, runtime.activity - delta * 0.28)
221
+ runtime.flare = math.max(0, runtime.flare - delta * 3)
222
+ runtime.quiet += delta
223
+ --[[
224
+ Two colours, blended by how recently something happened.
225
+
226
+ `baseTint` is the connection state, which is what the panel should read
227
+ as when nothing is going on. `activeTint` is the kind of work last done
228
+ -- reading, writing, running, debugging -- and it takes over while that
229
+ work is fresh, then fades back. So the strip is not one fixed colour: it
230
+ leans toward whatever the session is actually doing and returns to
231
+ resting on its own.
232
+ ]]
233
+ local blend = math.clamp(runtime.energy, 0, 1)
234
+ runtime.targetTint = runtime.baseTint:Lerp(runtime.activeTint, blend)
235
+ runtime.tint = runtime.tint:Lerp(runtime.targetTint, math.min(1, delta * 5))
236
+
237
+ -- Turning faster while busy makes the rate itself readable: a glance says
238
+ -- whether anything is happening without reading a word.
239
+ --
240
+ -- And nearly stopping once the session has gone quiet, for the same reason in
241
+ -- reverse: a strip that keeps moving at its working rate hours after the
242
+ -- last command claims an activity that is not happening.
243
+ local pace = if runtime.quiet > QUIET_AFTER then 0.25 else 1
244
+ runtime.angle += runtime.spin * delta * (1 + runtime.energy * 2.2) * pace
245
+
246
+ --[[
247
+ Where the cell wants to be, before the spring gets a say.
248
+
249
+ Three things move it and they are deliberately ordered. Energy -- how
250
+ recently work happened -- swells it while a session is busy. The breathe
251
+ is a slow sine that only survives while energy is near zero, so an idle
252
+ panel is visibly alive without a resting animation competing with the
253
+ reaction to a real call. And past QUIET_AFTER seconds of nothing, it draws
254
+ in and stays there: the picture of a session that has stopped.
255
+ ]]
256
+ local calm = 1 - math.clamp(runtime.energy, 0, 1)
257
+ local breathe = math.sin(os.clock() * 0.9) * 0.04 * calm
258
+ local target = 1 + math.clamp(runtime.energy, 0, 1.6) * 0.18 + breathe
259
+ if runtime.quiet > QUIET_AFTER then
260
+ target = 0.82
261
+ end
262
+
263
+ --[[
264
+ A damped spring rather than a lerp, because a lerp cannot overshoot and
265
+ overshoot is the whole point: a reply that pushes the cell past its
266
+ resting size and lets it fall back reads as a thing being struck, where
267
+ easing toward a value reads as a slider being dragged.
268
+
269
+ Integrated semi-implicitly -- velocity first, then position from the new
270
+ velocity -- which stays stable at the frame times Studio actually hands us
271
+ rather than only at small ones.
272
+ ]]
273
+ local integration = math.min(delta, 1 / 30)
274
+ runtime.scaleVelocity += (target - runtime.scale) * SPRING * integration
275
+ runtime.scaleVelocity -= runtime.scaleVelocity * DAMPING * integration
276
+ runtime.scale += runtime.scaleVelocity * integration
277
+ runtime.scale = math.clamp(runtime.scale, 0.55, 1.7)
278
+
279
+ --[[
280
+ Handed off. A preset that throws must not take the console's status
281
+ display down with it, so a failure here disables the strip and says so
282
+ once rather than erroring sixty times a second.
283
+ ]]
284
+ local ok, err = pcall(Themes.active().paint, context(), delta)
285
+ if not ok then
286
+ band.Visible = false
287
+ warn(string.format("[rbx-studio] theme %q failed to paint: %s", Themes.activeId(), tostring(err)))
288
+ end
289
+ end
290
+
291
+ --[[
292
+ Puts the highlight behind one bar and prints its timing.
293
+
294
+ The trace is only legible if you already know what it plots, and nothing on
295
+ screen said so. Hovering answers it directly: the column lights up, and the
296
+ number that made that bar its height appears in words. One reading at a time,
297
+ so a single highlight and a single label are moved around rather than one of
298
+ each being built per bar.
299
+ ]]
300
+ local function showReading(entry: Bar)
301
+ local highlight = runtime.highlight
302
+ local readout = runtime.readout
303
+ if highlight == nil or readout == nil then
304
+ return
305
+ end
306
+
307
+ --[[
308
+ The Y here has to be 1, and was 0.
309
+
310
+ The highlight is anchored to its own bottom edge, so a Y scale of 0 put
311
+ that edge on the trace's top line and drew the whole 26px column above it
312
+ -- outside a frame that clips its descendants. The hover was therefore
313
+ never visible to anyone: the hit targets fired, the readout appeared, and
314
+ the highlight they were meant to explain was off screen every time.
315
+ ]]
316
+ highlight.Position = UDim2.new(entry.hit.Position.X.Scale, entry.hit.Position.X.Offset, 1, 0)
317
+ highlight.Visible = true
318
+
319
+ --[[
320
+ Named, not just timed.
321
+
322
+ A duration with nothing attached to it raises the question it cannot
323
+ answer -- forty bars, one of them tall, and no way to tell whether that
324
+ was a script edit or a screenshot. The title is the same phrase the log
325
+ row uses, so pointing at a bar and reading the log agree with each other.
326
+ ]]
327
+ local palette = Themes.palette()
328
+ readout.Text = if entry.ok
329
+ then string.format("%s: %d ms", entry.title, math.round(entry.milliseconds))
330
+ else string.format("%s: failed after %d ms", entry.title, math.round(entry.milliseconds))
331
+ readout.TextColor3 = if entry.ok then palette.text else palette.red
332
+ readout.Visible = true
333
+ end
334
+
335
+ local function clearReading()
336
+ if runtime.highlight then
337
+ runtime.highlight.Visible = false
338
+ end
339
+ if runtime.readout then
340
+ runtime.readout.Visible = false
341
+ end
342
+ end
343
+
344
+ --[[
345
+ Bars, for a preset that does not want to draw its own history.
346
+
347
+ Height is duration and colour is outcome, so a slow call and a failed one are
348
+ distinguishable at a glance -- which is the pair of questions that actually
349
+ get asked of a log this size.
350
+ ]]
351
+ local function defaultSlot(slot: Themes.Slot, ctx: Themes.Context)
352
+ local frame = slot.frame
353
+ frame.AnchorPoint = Vector2.new(0.5, 1)
354
+ frame.Size = UDim2.new(0, 3, math.max(slot.weight, 0.06), 0)
355
+ frame.Position = UDim2.new(frame.Position.X.Scale, frame.Position.X.Offset, 1, 0)
356
+ frame.BackgroundColor3 = if slot.ok then ctx.palette.violet else ctx.palette.red
357
+ frame.BackgroundTransparency = 0.15 + slot.age * 0.6
358
+ frame.Rotation = 0
359
+ end
360
+
361
+ --[[
362
+ Re-places every bar and hands each to the active preset to draw.
363
+
364
+ Laid out right to left so the newest bar is always at the same edge and the
365
+ trace reads as scrolling rather than reshuffling. The horizontal position is
366
+ set here and the preset is expected to keep it -- everything else about the
367
+ bar is the preset's to decide.
368
+ ]]
369
+ local function relayout()
370
+ local ctx = context()
371
+ local painter = Themes.active().paintSlot or defaultSlot
372
+ local total = #runtime.bars
373
+
374
+ for index, item in runtime.bars do
375
+ local fromRight = total - index
376
+ local offset = -(fromRight + 1) * BAR_STRIDE + BAR_STRIDE / 2
377
+
378
+ item.frame.Position = UDim2.new(1, offset, 1, 0)
379
+ item.hit.Position = UDim2.new(1, offset, 1, 0)
380
+
381
+ local slot: Themes.Slot = {
382
+ frame = item.frame,
383
+ milliseconds = item.milliseconds,
384
+ ok = item.ok,
385
+ title = item.title,
386
+ -- 0 for the newest, rising toward 1 for the oldest still shown.
387
+ age = if TRACE > 1 then fromRight / (TRACE - 1) else 0,
388
+ weight = math.clamp(item.milliseconds / SLOW_MS, 0.06, 1),
389
+ }
390
+ local ok, err = pcall(painter, slot, ctx)
391
+ if not ok then
392
+ -- One bad slot must not leave the other thirty-nine unplaced.
393
+ defaultSlot(slot, ctx)
394
+ warn(string.format("[rbx-studio] theme %q failed on a slot: %s", Themes.activeId(), tostring(err)))
395
+ end
396
+ end
397
+ end
398
+
399
+ --[[
400
+ Records one finished call and re-draws the trace.
401
+ ]]
402
+ function Visuals.recordCall(milliseconds: number, ok: boolean, title: string)
403
+ runtime.energy = math.min(1.6, runtime.energy + (if ok then 0.3 else 0.8))
404
+ runtime.activity = math.min(1, runtime.activity + 0.34)
405
+ runtime.quiet = 0
406
+ runtime.flare = 1
407
+
408
+ --[[
409
+ A shove rather than a new target.
410
+
411
+ Setting the scale outright would snap; giving the spring velocity lets it
412
+ carry past its resting size and fall back, which is the difference between
413
+ a value changing and something being struck. A failure shoves the other
414
+ way and knocks the axis with it.
415
+ ]]
416
+ if ok then
417
+ runtime.scaleVelocity += 9
418
+ else
419
+ runtime.scale = 0.8
420
+ runtime.scaleVelocity = -2
421
+ runtime.shake = 1
422
+ end
423
+
424
+ local trace = runtime.trace
425
+ if trace == nil then
426
+ return
427
+ end
428
+
429
+ local bar = Instance.new("Frame")
430
+ bar.AnchorPoint = Vector2.new(0.5, 1)
431
+ bar.BorderSizePixel = 0
432
+ bar.Parent = trace
433
+ -- Presets draw points as well as bars, and a square point is not a point.
434
+ local corner = Instance.new("UICorner")
435
+ corner.CornerRadius = UDim.new(0, 1)
436
+ corner.Parent = bar
437
+
438
+ -- A TextButton rather than a Frame: buttons take mouse events reliably in a
439
+ -- plugin widget, and with no text and no background it is purely a target.
440
+ local hit = Instance.new("TextButton")
441
+ hit.AnchorPoint = Vector2.new(0.5, 1)
442
+ hit.BackgroundTransparency = 1
443
+ hit.Text = ""
444
+ hit.AutoButtonColor = false
445
+ hit.BorderSizePixel = 0
446
+ hit.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
447
+ hit.Parent = trace
448
+
449
+ local entry: Bar = {
450
+ frame = bar,
451
+ hit = hit,
452
+ milliseconds = milliseconds,
453
+ ok = ok,
454
+ title = if title ~= "" then title else "call",
455
+ }
456
+ table.insert(runtime.bars, entry)
457
+
458
+ hit.MouseEnter:Connect(function()
459
+ showReading(entry)
460
+ end)
461
+ hit.MouseLeave:Connect(function()
462
+ clearReading()
463
+ end)
464
+
465
+ while #runtime.bars > TRACE do
466
+ local oldest = runtime.bars[1]
467
+ oldest.frame:Destroy()
468
+ oldest.hit:Destroy()
469
+ table.remove(runtime.bars, 1)
470
+ end
471
+
472
+ relayout()
473
+
474
+ -- The bars have all moved, so a reading still on screen now names the wrong
475
+ -- one. Cheaper and more honest to drop it than to work out which bar the
476
+ -- pointer has ended up over.
477
+ clearReading()
478
+ end
479
+
480
+ --[[
481
+ The resting colour, which is the connection state.
482
+ ]]
483
+ function Visuals.setTint(color: Color3)
484
+ runtime.baseTint = color
485
+ end
486
+
487
+ --[[
488
+ The colour and pace of the work now running.
489
+
490
+ Both are set from the same call because they describe the same thing: what
491
+ kind of command this is. Reads are quick and cool, writes are slower and
492
+ warm, so the strip's colour and its rate agree with each other instead of
493
+ moving independently.
494
+ ]]
495
+ function Visuals.setKind(color: Color3, urgency: number)
496
+ runtime.activeTint = color
497
+ runtime.energy = math.min(1.6, math.max(runtime.energy, urgency))
498
+ runtime.quiet = 0
499
+ --[[
500
+ Drawn in as the command goes out, so the pair reads as one gesture: the
501
+ cell contracts while the request is in flight and springs open when the
502
+ answer arrives. On a fast call the two are almost one motion, which is
503
+ itself the report -- a slow call visibly holds its breath.
504
+ ]]
505
+ runtime.scale = math.min(runtime.scale, 0.85)
506
+ runtime.scaleVelocity = math.min(runtime.scaleVelocity, 0)
507
+ -- Re-aimed per kind so successive commands of different types visibly
508
+ -- change the axis rather than continuing the same turn.
509
+ runtime.spin = Vector3.new(
510
+ 0.12 + math.random() * 0.16,
511
+ 0.18 + math.random() * 0.22,
512
+ 0.06 + math.random() * 0.12
513
+ )
514
+ end
515
+
516
+ --[[
517
+ What is running, in the same words the log uses. The motion says something is
518
+ happening; this says what, and neither answers the other's question.
519
+ ]]
520
+ function Visuals.setCaption(title: string)
521
+ runtime.lastTitle = title
522
+ if runtime.caption then
523
+ runtime.caption.Text = title
524
+ end
525
+ end
526
+
527
+ --[[
528
+ Falls back to the last thing that ran once a command finishes.
529
+
530
+ The footer already reports totals, so repeating "idle" here would say the
531
+ same word twice on one screen. What has just happened is more useful and is
532
+ not shown anywhere else.
533
+ ]]
534
+ function Visuals.setIdle()
535
+ if runtime.caption == nil then
536
+ return
537
+ end
538
+ runtime.caption.Text = if runtime.lastTitle ~= nil
539
+ then "last: " .. runtime.lastTitle
540
+ else "waiting for a command"
541
+ end
542
+
543
+ --[[
544
+ Winds the cell down, because the session has stopped rather than paused.
545
+
546
+ The strip already decays on its own, but decay bottoms out at "idle and
547
+ moving", which looks the same after twenty seconds as after two hours. This
548
+ is the console telling the picture what it has just told the log, so the two
549
+ agree.
550
+ ]]
551
+ function Visuals.setQuiet()
552
+ runtime.quiet = QUIET_AFTER + 1
553
+ runtime.activity = 0
554
+ end
555
+
556
+ --[[
557
+ Empties the trace.
558
+
559
+ Paired with the console's clear button. The bars used to survive it while the
560
+ footer counters reset, so "clear" wiped the log, wiped the statistics, and
561
+ left forty timings from the session it had just erased sitting on screen.
562
+ ]]
563
+ function Visuals.clearTrace()
564
+ for _, entry in runtime.bars do
565
+ entry.frame:Destroy()
566
+ entry.hit:Destroy()
567
+ end
568
+ table.clear(runtime.bars)
569
+ clearReading()
570
+ end
571
+
572
+ function Visuals.setVisible(visible: boolean)
573
+ local band = runtime.band
574
+ if band == nil then
575
+ return
576
+ end
577
+ band.Visible = visible
578
+
579
+ -- Connected only while on screen: a hidden strip that keeps projecting
580
+ -- geometry every frame is a battery complaint waiting to happen.
581
+ if visible and runtime.connection == nil then
582
+ runtime.connection = RunService.Heartbeat:Connect(step)
583
+ elseif not visible and runtime.connection ~= nil then
584
+ runtime.connection:Disconnect()
585
+ runtime.connection = nil
586
+ end
587
+ end
588
+
589
+ function Visuals.isVisible(): boolean
590
+ local band = runtime.band
591
+ return band ~= nil and band.Visible
592
+ end
593
+
594
+ --[[
595
+ Swaps the cell's contents and recolours the strip's own chrome.
596
+
597
+ The order matters. The outgoing preset is unmounted before the cell is
598
+ emptied, so a preset that keeps references cannot be handed destroyed
599
+ Instances; the cell is then cleared outright rather than trusted to be
600
+ clean, because a preset that errored midway through `mount` will have left
601
+ something behind.
602
+ ]]
603
+ function Visuals.applyTheme()
604
+ local cell = runtime.cell
605
+ local band = runtime.band
606
+ if cell == nil or band == nil then
607
+ return
608
+ end
609
+
610
+ if runtime.mounted then
611
+ pcall(function()
612
+ Themes.active().unmount()
613
+ end)
614
+ end
615
+ for _, child in cell:GetChildren() do
616
+ child:Destroy()
617
+ end
618
+
619
+ local palette = Themes.palette()
620
+ band.BackgroundColor3 = palette.surface
621
+ if runtime.divider then
622
+ (runtime.divider :: Frame).BackgroundColor3 = palette.dim
623
+ end
624
+ if runtime.baseline then
625
+ (runtime.baseline :: Frame).BackgroundColor3 = palette.dim
626
+ end
627
+ if runtime.highlight then
628
+ (runtime.highlight :: Frame).BackgroundColor3 = palette.text
629
+ end
630
+ if runtime.caption then
631
+ (runtime.caption :: TextLabel).TextColor3 = palette.text
632
+ end
633
+ if runtime.readout then
634
+ (runtime.readout :: TextLabel).BackgroundColor3 = palette.background
635
+ end
636
+
637
+ local ok, err = pcall(function()
638
+ Themes.active().mount(cell)
639
+ end)
640
+ runtime.mounted = ok
641
+ if not ok then
642
+ warn(string.format("[rbx-studio] theme %q failed to mount: %s", Themes.activeId(), tostring(err)))
643
+ end
644
+
645
+ relayout()
646
+ -- A preset that failed to paint disabled the band; a new one deserves the
647
+ -- chance to prove it works.
648
+ band.Visible = true
649
+ end
650
+
651
+ --[[
652
+ Builds the strip. Everything except the cell's contents is created here and
653
+ never again, which is what keeps the per-frame path allocation-free.
654
+ ]]
655
+ function Visuals.mount(parent: Instance)
656
+ local palette = Themes.palette()
657
+
658
+ local band = Instance.new("Frame")
659
+ band.Name = "ActivityBand"
660
+ band.BackgroundColor3 = palette.surface
661
+ band.BorderSizePixel = 0
662
+ band.Visible = false
663
+ band.ClipsDescendants = true
664
+ band.Parent = parent
665
+ runtime.band = band
666
+
667
+ local cell = Instance.new("Frame")
668
+ cell.Name = "Cell"
669
+ cell.Position = UDim2.fromOffset(INSET, (Visuals.BAND_HEIGHT - CELL) / 2)
670
+ cell.Size = UDim2.fromOffset(CELL, CELL)
671
+ cell.BackgroundTransparency = 1
672
+ cell.ClipsDescendants = true
673
+ cell.Parent = band
674
+ runtime.cell = cell
675
+
676
+ -- A hairline between the cell and the trace, so the strip reads as two
677
+ -- instruments rather than one busy rectangle. Centred in the gutter, which
678
+ -- is what keeps the extra margin as air on both sides rather than as a gap
679
+ -- on one.
680
+ local divider = Instance.new("Frame")
681
+ divider.Name = "Divider"
682
+ divider.Position = UDim2.fromOffset(INSET + CELL + GUTTER / 2, 10)
683
+ divider.Size = UDim2.new(0, 1, 1, -20)
684
+ divider.BackgroundColor3 = palette.dim
685
+ divider.BackgroundTransparency = 0.78
686
+ divider.BorderSizePixel = 0
687
+ divider.Parent = band
688
+ runtime.divider = divider
689
+
690
+ local right = INSET + CELL + GUTTER
691
+
692
+ local caption = Instance.new("TextLabel")
693
+ caption.Name = "Caption"
694
+ caption.Position = UDim2.new(0, right, 0, 6)
695
+ caption.Size = UDim2.new(1, -right - 12, 0, 14)
696
+ caption.BackgroundTransparency = 1
697
+ caption.Font = Enum.Font.Code
698
+ caption.TextSize = 11
699
+ caption.TextColor3 = palette.text
700
+ caption.TextXAlignment = Enum.TextXAlignment.Left
701
+ caption.TextTruncate = Enum.TextTruncate.AtEnd
702
+ caption.Text = "idle"
703
+ caption.Parent = band
704
+ runtime.caption = caption
705
+
706
+ local trace = Instance.new("Frame")
707
+ trace.Name = "Trace"
708
+ trace.Position = UDim2.new(0, right, 0, 24)
709
+ trace.Size = UDim2.new(1, -right - 12, 0, TRACE_HEIGHT)
710
+ trace.BackgroundTransparency = 1
711
+ trace.ClipsDescendants = true
712
+ trace.Parent = band
713
+ runtime.trace = trace
714
+
715
+ -- A baseline under the bars, so an empty trace still reads as an instrument
716
+ -- waiting for data rather than as a blank gap.
717
+ local baseline = Instance.new("Frame")
718
+ baseline.Name = "Baseline"
719
+ baseline.AnchorPoint = Vector2.new(0, 1)
720
+ baseline.Position = UDim2.fromScale(0, 1)
721
+ baseline.Size = UDim2.new(1, 0, 0, 1)
722
+ baseline.BackgroundColor3 = palette.dim
723
+ baseline.BackgroundTransparency = 0.75
724
+ baseline.BorderSizePixel = 0
725
+ baseline.Parent = trace
726
+ runtime.baseline = baseline
727
+
728
+ --[[
729
+ Built once and moved, and created BEFORE the bars exist so it sits under
730
+ them in draw order -- a highlight drawn over a one-pixel bar would hide the
731
+ very thing it is pointing at.
732
+ ]]
733
+ local highlight = Instance.new("Frame")
734
+ highlight.Name = "Highlight"
735
+ highlight.AnchorPoint = Vector2.new(0.5, 1)
736
+ highlight.Position = UDim2.new(1, 0, 1, 0)
737
+ highlight.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
738
+ highlight.BackgroundColor3 = palette.text
739
+ -- Faint enough not to hide the bar it sits behind, solid enough to be seen
740
+ -- at all.
741
+ highlight.BackgroundTransparency = 0.7
742
+ highlight.BorderSizePixel = 0
743
+ highlight.Visible = false
744
+ highlight.Parent = trace
745
+ runtime.highlight = highlight
746
+
747
+ --[[
748
+ The reading sits at the left end of the trace rather than beside the bar it
749
+ describes. Following the pointer would put it off the right edge for the
750
+ newest bars, which are the ones most often asked about, and the left end is
751
+ empty until forty calls have accumulated.
752
+ ]]
753
+ local readout = Instance.new("TextLabel")
754
+ readout.Name = "Readout"
755
+ readout.AnchorPoint = Vector2.new(0, 0.5)
756
+ readout.Position = UDim2.new(0, 0, 0.5, 0)
757
+ -- Sized by its text now that it carries a name as well as a number. A fixed
758
+ -- 110px was enough for "18 ms" and truncates anything with a phrase in front
759
+ -- of it, which is the half worth reading.
760
+ readout.AutomaticSize = Enum.AutomaticSize.X
761
+ readout.Size = UDim2.new(0, 0, 0, 14)
762
+ readout.BackgroundColor3 = palette.background
763
+ readout.BackgroundTransparency = 0.15
764
+ readout.BorderSizePixel = 0
765
+ readout.Font = Enum.Font.Code
766
+ readout.TextSize = 11
767
+ readout.TextXAlignment = Enum.TextXAlignment.Left
768
+ readout.Text = ""
769
+ readout.Visible = false
770
+ readout.ZIndex = 3
771
+ readout.Parent = trace
772
+ runtime.readout = readout
773
+
774
+ local readoutPadding = Instance.new("UIPadding")
775
+ readoutPadding.PaddingLeft = UDim.new(0, 4)
776
+ readoutPadding.PaddingRight = UDim.new(0, 4)
777
+ readoutPadding.Parent = readout
778
+
779
+ local ok, err = pcall(function()
780
+ Themes.active().mount(cell)
781
+ end)
782
+ runtime.mounted = ok
783
+ if not ok then
784
+ warn(string.format("[rbx-studio] theme %q failed to mount: %s", Themes.activeId(), tostring(err)))
785
+ end
786
+ end
787
+
788
+ return Visuals