@el4cteo/rbx-studio-mcp 0.1.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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/bridge/rpc.js +243 -0
  4. package/dist/bridge/rpc.js.map +1 -0
  5. package/dist/bridge/server.js +281 -0
  6. package/dist/bridge/server.js.map +1 -0
  7. package/dist/index.js +104 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/lib/apidump.js +269 -0
  10. package/dist/lib/apidump.js.map +1 -0
  11. package/dist/lib/errors.js +38 -0
  12. package/dist/lib/errors.js.map +1 -0
  13. package/dist/lib/format.js +191 -0
  14. package/dist/lib/format.js.map +1 -0
  15. package/dist/lib/pluginbuild.js +83 -0
  16. package/dist/lib/pluginbuild.js.map +1 -0
  17. package/dist/lib/png.js +84 -0
  18. package/dist/lib/png.js.map +1 -0
  19. package/dist/lib/protocol.js +22 -0
  20. package/dist/lib/protocol.js.map +1 -0
  21. package/dist/lib/tool.js +27 -0
  22. package/dist/lib/tool.js.map +1 -0
  23. package/dist/resources.js +70 -0
  24. package/dist/resources.js.map +1 -0
  25. package/dist/tools/api.js +78 -0
  26. package/dist/tools/api.js.map +1 -0
  27. package/dist/tools/character.js +94 -0
  28. package/dist/tools/character.js.map +1 -0
  29. package/dist/tools/debug.js +211 -0
  30. package/dist/tools/debug.js.map +1 -0
  31. package/dist/tools/device.js +74 -0
  32. package/dist/tools/device.js.map +1 -0
  33. package/dist/tools/discover.js +217 -0
  34. package/dist/tools/discover.js.map +1 -0
  35. package/dist/tools/exec.js +191 -0
  36. package/dist/tools/exec.js.map +1 -0
  37. package/dist/tools/input.js +96 -0
  38. package/dist/tools/input.js.map +1 -0
  39. package/dist/tools/instances.js +261 -0
  40. package/dist/tools/instances.js.map +1 -0
  41. package/dist/tools/perf.js +367 -0
  42. package/dist/tools/perf.js.map +1 -0
  43. package/dist/tools/playtest.js +153 -0
  44. package/dist/tools/playtest.js.map +1 -0
  45. package/dist/tools/screenshot.js +75 -0
  46. package/dist/tools/screenshot.js.map +1 -0
  47. package/dist/tools/scripts.js +316 -0
  48. package/dist/tools/scripts.js.map +1 -0
  49. package/dist/tools/session.js +152 -0
  50. package/dist/tools/session.js.map +1 -0
  51. package/dist/tools/world.js +281 -0
  52. package/dist/tools/world.js.map +1 -0
  53. package/package.json +62 -0
  54. package/plugin/default.project.json +6 -0
  55. package/plugin/src/Config.luau +59 -0
  56. package/plugin/src/Console.luau +657 -0
  57. package/plugin/src/Context.luau +35 -0
  58. package/plugin/src/Dispatch.luau +90 -0
  59. package/plugin/src/Editor.luau +142 -0
  60. package/plugin/src/Emulation.luau +151 -0
  61. package/plugin/src/LogBuffer.luau +277 -0
  62. package/plugin/src/Net.luau +102 -0
  63. package/plugin/src/Paths.luau +255 -0
  64. package/plugin/src/Phrase.luau +465 -0
  65. package/plugin/src/Png.luau +238 -0
  66. package/plugin/src/Scope.luau +78 -0
  67. package/plugin/src/ScriptEdit.luau +100 -0
  68. package/plugin/src/Serialize.luau +287 -0
  69. package/plugin/src/TextEdit.luau +296 -0
  70. package/plugin/src/Transport.luau +328 -0
  71. package/plugin/src/Undo.luau +72 -0
  72. package/plugin/src/Visuals.luau +710 -0
  73. package/plugin/src/handlers/Api.luau +242 -0
  74. package/plugin/src/handlers/Assets.luau +145 -0
  75. package/plugin/src/handlers/Capture.luau +187 -0
  76. package/plugin/src/handlers/Character.luau +361 -0
  77. package/plugin/src/handlers/Debug.luau +391 -0
  78. package/plugin/src/handlers/Device.luau +119 -0
  79. package/plugin/src/handlers/Discover.luau +289 -0
  80. package/plugin/src/handlers/Exec.luau +270 -0
  81. package/plugin/src/handlers/Geometry.luau +261 -0
  82. package/plugin/src/handlers/Input.luau +287 -0
  83. package/plugin/src/handlers/Instances.luau +389 -0
  84. package/plugin/src/handlers/Perf.luau +645 -0
  85. package/plugin/src/handlers/Playtest.luau +205 -0
  86. package/plugin/src/handlers/Scripts.luau +387 -0
  87. package/plugin/src/handlers/Session.luau +168 -0
  88. package/plugin/src/handlers/Viewport.luau +302 -0
  89. package/plugin/src/handlers/World.luau +176 -0
  90. package/plugin/src/init.server.luau +317 -0
  91. package/scripts/build-plugin.mjs +157 -0
  92. package/scripts/check-plugin.mjs +97 -0
  93. package/scripts/install-plugin.mjs +39 -0
  94. package/scripts/latency.mjs +201 -0
  95. package/scripts/locate-luau.mjs +51 -0
  96. package/scripts/sourcemap.mjs +58 -0
  97. package/scripts/test-plugin.mjs +82 -0
@@ -0,0 +1,657 @@
1
+ --!strict
2
+ --[[
3
+ The Studio MCP console.
4
+
5
+ This is the only feedback channel the plugin has. It deliberately never
6
+ writes to Studio's Output window, because that log is also what the agent
7
+ reads back through the console tool -- plugin chatter there would pollute the
8
+ very thing it is reporting on.
9
+
10
+ Visual design is deliberate rather than default-dark: a sigil gutter that
11
+ makes call/reply/error scannable without reading the text, right-aligned
12
+ latencies that line up into a column you can eyeball for outliers, and a live
13
+ status bar with session counters. Every glyph is drawn from a monospace-safe
14
+ set so the columns stay true at any width.
15
+
16
+ Rendering is one RichText label inside a ScrollingFrame rather than a label
17
+ per line. A log that scrolls constantly should not churn hundreds of
18
+ Instances, and colour spans do the same job in a fraction of the code.
19
+ ]]
20
+
21
+ local Visuals = require(script.Parent.Visuals)
22
+
23
+ local Console = {}
24
+
25
+ -- Ring buffer bound. Long enough to cover a working session, short enough that
26
+ -- re-rendering the whole string on every append stays cheap.
27
+ local MAX_LINES = 300
28
+
29
+ -- Violet-tinted near-black. The cast is slight but it stops the panel reading
30
+ -- as the same grey as every other Studio dock.
31
+ local PALETTE = {
32
+ background = Color3.fromRGB(11, 12, 20),
33
+ surface = Color3.fromRGB(18, 20, 31),
34
+ rule = Color3.fromRGB(167, 139, 250),
35
+ text = Color3.fromRGB(206, 212, 226),
36
+ dim = Color3.fromRGB(90, 97, 120),
37
+ green = Color3.fromRGB(110, 231, 168),
38
+ red = Color3.fromRGB(251, 113, 133),
39
+ amber = Color3.fromRGB(251, 191, 36),
40
+ cyan = Color3.fromRGB(103, 232, 249),
41
+ violet = Color3.fromRGB(167, 139, 250),
42
+ }
43
+
44
+ -- Sigil plus colour per level. The sigil is what makes the log scannable at a
45
+ -- glance; colour alone fails for anyone who cannot separate red from green.
46
+ local LEVELS: { [string]: { sigil: string, color: Color3 } } = {
47
+ ok = { sigil = "\u{25C6}", color = PALETTE.green },
48
+ error = { sigil = "\u{2715}", color = PALETTE.red },
49
+ warn = { sigil = "\u{25B2}", color = PALETTE.amber },
50
+ info = { sigil = "\u{25C6}", color = PALETTE.violet },
51
+ dim = { sigil = "\u{00B7}", color = PALETTE.dim },
52
+ call = { sigil = "\u{25B8}", color = PALETTE.cyan },
53
+ reply = { sigil = "\u{25C2}", color = PALETTE.violet },
54
+ }
55
+
56
+ export type Level = "ok" | "error" | "warn" | "info" | "dim" | "call" | "reply"
57
+
58
+ --[[
59
+ Column the right-aligned detail is padded out to, in characters.
60
+
61
+ Sized for the widget's default width rather than for the longest message: at
62
+ 12pt Code with a timestamp ahead of it, anything past this wraps, and a
63
+ wrapped line breaks the very column the padding exists to produce. Messages
64
+ are cut to fit instead.
65
+ ]]
66
+ local DETAIL_COLUMN = 52
67
+
68
+ --[[
69
+ How far past the column an inline detail may run before it is moved below the
70
+ message instead. A latency fits; a sentence does not.
71
+ ]]
72
+ local INLINE_DETAIL = 14
73
+
74
+ -- Continuation lines sit under the message text, clear of the timestamp and the
75
+ -- sigil, so a wrapped explanation reads as belonging to the line above it.
76
+ local DETAIL_INDENT = string.rep(" ", 11)
77
+
78
+ -- Characters per continuation line. Fixed rather than measured from the widget:
79
+ -- the console is monospaced, and a width that changes as the user drags the
80
+ -- panel would rewrap history every frame.
81
+ local DETAIL_WRAP = 74
82
+
83
+ --[[
84
+ Breaks a long detail into lines at word boundaries.
85
+
86
+ Roblox's own TextWrapped would do this, and would wrap to column 0 -- there
87
+ is no hanging indent for a TextLabel -- which is the ragged shape this
88
+ replaces. Wrapping here means every continuation line can carry the indent.
89
+ ]]
90
+ local function wrapDetail(detail: string): { string }
91
+ local lines: { string } = {}
92
+ local current = ""
93
+ for word in string.gmatch(detail, "%S+") do
94
+ local candidate = if current == "" then word else current .. " " .. word
95
+ if (utf8.len(candidate) or #candidate) > DETAIL_WRAP and current ~= "" then
96
+ table.insert(lines, current)
97
+ current = word
98
+ else
99
+ current = candidate
100
+ end
101
+ end
102
+ if current ~= "" then
103
+ table.insert(lines, current)
104
+ end
105
+ return lines
106
+ end
107
+
108
+ type State = {
109
+ lines: { string },
110
+ label: TextLabel?,
111
+ scroller: ScrollingFrame?,
112
+ statusDot: TextLabel?,
113
+ statusText: TextLabel?,
114
+ metaText: TextLabel?,
115
+ countersText: TextLabel?,
116
+ pinned: boolean,
117
+ calls: number,
118
+ errors: number,
119
+ totalMs: number,
120
+ -- The command in flight, shown in the footer while it runs. One value that
121
+ -- is replaced, never a list that grows.
122
+ running: string?,
123
+ }
124
+
125
+ local state: State = {
126
+ lines = {},
127
+ label = nil,
128
+ scroller = nil,
129
+ statusDot = nil,
130
+ statusText = nil,
131
+ metaText = nil,
132
+ countersText = nil,
133
+ pinned = true,
134
+ calls = 0,
135
+ errors = 0,
136
+ totalMs = 0,
137
+ running = nil,
138
+ }
139
+
140
+ local function hex(color: Color3): string
141
+ return string.format(
142
+ "#%02X%02X%02X",
143
+ math.floor(color.R * 255 + 0.5),
144
+ math.floor(color.G * 255 + 0.5),
145
+ math.floor(color.B * 255 + 0.5)
146
+ )
147
+ end
148
+
149
+ -- RichText is markup, so anything user- or engine-supplied has to be escaped or
150
+ -- a stray `<` in an error message silently eats the rest of the line.
151
+ local function escape(value: string): string
152
+ local escaped = string.gsub(value, "&", "&amp;")
153
+ escaped = string.gsub(escaped, "<", "&lt;")
154
+ escaped = string.gsub(escaped, ">", "&gt;")
155
+ return escaped
156
+ end
157
+
158
+ local function span(color: Color3, value: string): string
159
+ return string.format('<font color="%s">%s</font>', hex(color), escape(value))
160
+ end
161
+
162
+ local function redraw()
163
+ local label = state.label
164
+ if not label then
165
+ return
166
+ end
167
+ label.Text = table.concat(state.lines, "\n")
168
+
169
+ -- Only follow the tail when the user has not scrolled up to read history.
170
+ local scroller = state.scroller
171
+ if scroller and state.pinned then
172
+ task.defer(function()
173
+ if scroller.Parent then
174
+ scroller.CanvasPosition = Vector2.new(0, math.max(0, scroller.AbsoluteCanvasSize.Y))
175
+ end
176
+ end)
177
+ end
178
+ end
179
+
180
+ local function refreshCounters()
181
+ local counters = state.countersText
182
+ if not counters then
183
+ return
184
+ end
185
+ --[[
186
+ The footer is for totals, and only totals.
187
+
188
+ It briefly doubled as the in-flight readout, which meant a running
189
+ command overwrote "4 calls 0 errors avg 84ms" with its own name -- the
190
+ session statistics disappearing exactly when the session was busiest.
191
+ Two live readouts on one small panel is one too many, and the band
192
+ already has the better spot for it, right beside the solid.
193
+ ]]
194
+ counters.TextColor3 = PALETTE.dim
195
+ if state.calls == 0 then
196
+ counters.Text = "idle"
197
+ return
198
+ end
199
+ counters.Text = string.format(
200
+ "%d call%s %d error%s avg %.0fms",
201
+ state.calls,
202
+ if state.calls == 1 then "" else "s",
203
+ state.errors,
204
+ if state.errors == 1 then "" else "s",
205
+ state.totalMs / state.calls
206
+ )
207
+ end
208
+
209
+ --[[
210
+ Appends one line. `detail` is padded to a fixed column and dimmed, so
211
+ latencies stack into a readable column instead of trailing each message at a
212
+ ragged offset.
213
+ ]]
214
+ function Console.log(level: Level, message: string, detail: string?)
215
+ local spec = LEVELS[level] or LEVELS.info
216
+
217
+ --[[
218
+ Measured in characters, not bytes.
219
+
220
+ `#body` counts bytes, and every sigil in this console is a 3-byte UTF-8
221
+ glyph, so it over-counted each line by two and pushed the latency column
222
+ two places left on exactly the lines that had a latency. The column was
223
+ never straight, and the cause was invisible until two different sigils
224
+ sat next to each other.
225
+ ]]
226
+ --[[
227
+ Only a detail that will actually sit in the column costs the message any
228
+ of its width. Cutting the message to make room for a detail that then
229
+ goes on its own line below would shorten it for nothing.
230
+ ]]
231
+ local detailWidth = if detail then (utf8.len(detail) or #detail) else 0
232
+ local inlineDetail = detail ~= nil
233
+ and (utf8.len(message) or #message) + detailWidth + 3 <= DETAIL_COLUMN + INLINE_DETAIL
234
+
235
+ local trimmed = message
236
+ local budget = DETAIL_COLUMN - 3
237
+ if inlineDetail and (utf8.len(trimmed) or #trimmed) > budget then
238
+ -- Cut rather than wrap. A wrapped line destroys the alignment and
239
+ -- carries the detail off the end of the visible width as well.
240
+ local offset = utf8.offset(trimmed, budget) or budget
241
+ trimmed = string.sub(trimmed, 1, offset - 1) .. utf8.char(0x2026)
242
+ end
243
+
244
+ local body = string.format("%s %s", spec.sigil, trimmed)
245
+ local bodyWidth = utf8.len(body) or #body
246
+ local line = span(PALETTE.dim, os.date("%H:%M:%S") :: string) .. " " .. span(spec.color, body)
247
+
248
+ --[[
249
+ Short details ride the right-hand column; long ones get their own lines.
250
+
251
+ The column exists for latencies -- "12ms" stacking into something
252
+ readable -- and it was applied to every detail regardless of length. A
253
+ sentence of prose therefore started at column 52, ran off the widget, and
254
+ wrapped back to column 0, so the explanation of a standby session came
255
+ out as a ragged block that began in the middle of the screen and ended at
256
+ the left edge. Anything that will not fit beside the message is better
257
+ off beneath it.
258
+ ]]
259
+ table.insert(state.lines, line)
260
+ if detail then
261
+ if inlineDetail then
262
+ local padding = math.max(1, DETAIL_COLUMN - bodyWidth)
263
+ state.lines[#state.lines] ..= span(PALETTE.dim, string.rep(" ", padding) .. detail)
264
+ else
265
+ for _, wrapped in wrapDetail(detail) do
266
+ table.insert(state.lines, span(PALETTE.dim, DETAIL_INDENT .. wrapped))
267
+ end
268
+ end
269
+ end
270
+ while #state.lines > MAX_LINES do
271
+ table.remove(state.lines, 1)
272
+ end
273
+ redraw()
274
+ end
275
+
276
+ --[[
277
+ Announces a command as it starts -- everywhere except the log.
278
+
279
+ This used to append a line, and then the reply appended a second one saying
280
+ the same thing in a different colour. Every call cost two rows and read as
281
+ duplicated output, which is exactly what it was: a cyan "Edit KillBrick"
282
+ followed by a violet "Edit KillBrick".
283
+
284
+ The log now takes one line per call, written when it finishes and carrying
285
+ the latency it took. What is running *right now* belongs in a place that
286
+ updates rather than accumulates, so it goes to the footer and the activity
287
+ band -- both of which show a single current value and neither of which grows.
288
+ ]]
289
+ --[[
290
+ Colour and pace per kind of work, so the band reads as what is happening.
291
+
292
+ Reads are cool and quick because they are constant and harmless; writes are
293
+ violet and slower because they change the user's game; running code is green
294
+ and heavier still. Urgency drives both the spin rate and how strongly the
295
+ colour takes over, so the two never disagree.
296
+ ]]
297
+ local KIND_LOOK: { [string]: { color: Color3, urgency: number } } = {
298
+ read = { color = PALETTE.cyan, urgency = 0.3 },
299
+ write = { color = PALETTE.violet, urgency = 0.7 },
300
+ run = { color = PALETTE.green, urgency = 0.9 },
301
+ debug = { color = PALETTE.amber, urgency = 0.5 },
302
+ }
303
+
304
+ function Console.beginCall(title: string, kind: string)
305
+ state.running = title
306
+ local look = KIND_LOOK[kind] or KIND_LOOK.read
307
+ Visuals.setCaption(title)
308
+ Visuals.setKind(look.color, look.urgency)
309
+ refreshCounters()
310
+ end
311
+
312
+ --[[
313
+ Records one completed command for the session counters. Kept separate from
314
+ `log` so callers can log freely without skewing the statistics.
315
+ ]]
316
+ function Console.recordCall(ok: boolean, milliseconds: number)
317
+ state.calls += 1
318
+ state.totalMs += milliseconds
319
+ if not ok then
320
+ state.errors += 1
321
+ end
322
+ state.running = nil
323
+ refreshCounters()
324
+ -- The footer reports totals when idle, so the band shows what just ran
325
+ -- instead of repeating the same word on the same screen.
326
+ Visuals.setIdle()
327
+
328
+ -- The band plots it: bar height is how long it took, colour is whether it
329
+ -- worked, and a failure knocks the solid off its axis as well.
330
+ Visuals.recordCall(milliseconds, ok)
331
+ end
332
+
333
+ --[[
334
+ Pins a line of text beside the activity strip.
335
+
336
+ Unlike the caption a running command sets, this survives until something
337
+ else replaces it, which is what a session that will never run a command
338
+ needs: the standby client view has nothing to report and no reason to say
339
+ "waiting for a command" forever when it is not waiting for one.
340
+ ]]
341
+ function Console.setCaption(message: string)
342
+ Visuals.setCaption(message)
343
+ end
344
+
345
+ function Console.clear()
346
+ table.clear(state.lines)
347
+ state.calls = 0
348
+ state.errors = 0
349
+ state.totalMs = 0
350
+ refreshCounters()
351
+ redraw()
352
+ end
353
+
354
+ --[[
355
+ Updates the header. Kept separate from the log so the current state is always
356
+ visible without scrolling, however long the session has run.
357
+ ]]
358
+ function Console.setStatus(status: string, meta: string)
359
+ local dot = state.statusDot
360
+ local text = state.statusText
361
+ local metaLabel = state.metaText
362
+ if not dot or not text or not metaLabel then
363
+ return
364
+ end
365
+
366
+ -- "standby" is a working state, not a fault: the client half of a playtest
367
+ -- cannot use HTTP and is not meant to connect. Painting it red like a real
368
+ -- disconnection made a correct setup look broken.
369
+ local color = if status == "connected"
370
+ then PALETTE.green
371
+ elseif status == "connecting" then PALETTE.amber
372
+ elseif status == "standby" then PALETTE.dim
373
+ else PALETTE.red
374
+
375
+ dot.TextColor3 = color
376
+ text.Text = string.upper(status)
377
+ text.TextColor3 = color
378
+ metaLabel.Text = meta
379
+
380
+ -- The wireframe takes the same colour as the status light, so the panel
381
+ -- reads as disconnected at a glance even with the header off screen.
382
+ Visuals.setTint(color)
383
+ end
384
+
385
+ local function makeButton(parent: Instance, text: string, order: number): TextButton
386
+ local button = Instance.new("TextButton")
387
+ button.Name = text
388
+ button.Text = text
389
+ button.Font = Enum.Font.Code
390
+ button.TextSize = 11
391
+ button.TextColor3 = PALETTE.dim
392
+ button.BackgroundColor3 = PALETTE.background
393
+ button.AutoButtonColor = false
394
+ button.BorderSizePixel = 0
395
+ button.Size = UDim2.new(0, 76, 0, 20)
396
+ button.LayoutOrder = order
397
+ button.Parent = parent
398
+
399
+ local corner = Instance.new("UICorner")
400
+ corner.CornerRadius = UDim.new(0, 3)
401
+ corner.Parent = button
402
+
403
+ local stroke = Instance.new("UIStroke")
404
+ stroke.Color = PALETTE.dim
405
+ stroke.Transparency = 0.6
406
+ stroke.Parent = button
407
+
408
+ button.MouseEnter:Connect(function()
409
+ button.TextColor3 = PALETTE.violet
410
+ stroke.Color = PALETTE.violet
411
+ stroke.Transparency = 0.3
412
+ end)
413
+ button.MouseLeave:Connect(function()
414
+ button.TextColor3 = PALETTE.dim
415
+ stroke.Color = PALETTE.dim
416
+ stroke.Transparency = 0.6
417
+ end)
418
+
419
+ return button
420
+ end
421
+
422
+ export type Handlers = {
423
+ onReconnect: () -> (),
424
+ onClear: () -> (),
425
+ }
426
+
427
+ --[[
428
+ Builds the widget contents. Colours are fixed rather than theme-derived: this
429
+ is a console, and a console that repaints itself light grey reads as a form.
430
+ ]]
431
+ function Console.mount(parent: Instance, handlers: Handlers)
432
+ local root = Instance.new("Frame")
433
+ root.Size = UDim2.fromScale(1, 1)
434
+ root.BackgroundColor3 = PALETTE.background
435
+ root.BorderSizePixel = 0
436
+ root.Parent = parent
437
+
438
+ -- Header ---------------------------------------------------------------
439
+ local header = Instance.new("Frame")
440
+ header.Size = UDim2.new(1, 0, 0, 32)
441
+ header.BackgroundColor3 = PALETTE.surface
442
+ header.BorderSizePixel = 0
443
+ header.Parent = root
444
+
445
+ local headerPadding = Instance.new("UIPadding")
446
+ headerPadding.PaddingLeft = UDim.new(0, 12)
447
+ headerPadding.PaddingRight = UDim.new(0, 8)
448
+ headerPadding.Parent = header
449
+
450
+ local dot = Instance.new("TextLabel")
451
+ dot.Text = "\u{25CF}"
452
+ dot.Font = Enum.Font.Code
453
+ dot.TextSize = 13
454
+ dot.TextColor3 = PALETTE.red
455
+ dot.BackgroundTransparency = 1
456
+ dot.Size = UDim2.new(0, 12, 1, 0)
457
+ dot.Parent = header
458
+ state.statusDot = dot
459
+
460
+ local status = Instance.new("TextLabel")
461
+ status.Text = "DISCONNECTED"
462
+ status.Font = Enum.Font.Code
463
+ status.TextSize = 12
464
+ status.TextColor3 = PALETTE.red
465
+ status.TextXAlignment = Enum.TextXAlignment.Left
466
+ status.BackgroundTransparency = 1
467
+ status.Position = UDim2.new(0, 18, 0, 0)
468
+ status.Size = UDim2.new(0, 110, 1, 0)
469
+ status.Parent = header
470
+ state.statusText = status
471
+
472
+ local meta = Instance.new("TextLabel")
473
+ meta.Text = ""
474
+ meta.Font = Enum.Font.Code
475
+ meta.TextSize = 11
476
+ meta.TextColor3 = PALETTE.dim
477
+ meta.TextXAlignment = Enum.TextXAlignment.Left
478
+ meta.TextTruncate = Enum.TextTruncate.AtEnd
479
+ meta.BackgroundTransparency = 1
480
+ meta.Position = UDim2.new(0, 132, 0, 0)
481
+ meta.Size = UDim2.new(1, -394, 1, 0)
482
+ meta.Parent = header
483
+ state.metaText = meta
484
+
485
+ local buttons = Instance.new("Frame")
486
+ buttons.AnchorPoint = Vector2.new(1, 0.5)
487
+ buttons.Position = UDim2.new(1, 0, 0.5, 0)
488
+ buttons.Size = UDim2.new(0, 244, 0, 20)
489
+ buttons.BackgroundTransparency = 1
490
+ buttons.Parent = header
491
+
492
+ local buttonLayout = Instance.new("UIListLayout")
493
+ buttonLayout.FillDirection = Enum.FillDirection.Horizontal
494
+ buttonLayout.HorizontalAlignment = Enum.HorizontalAlignment.Right
495
+ buttonLayout.VerticalAlignment = Enum.VerticalAlignment.Center
496
+ buttonLayout.Padding = UDim.new(0, 6)
497
+ buttonLayout.SortOrder = Enum.SortOrder.LayoutOrder
498
+ buttonLayout.Parent = buttons
499
+
500
+ local visualsButton = makeButton(buttons, "visuals", 1)
501
+ makeButton(buttons, "reconnect", 2).MouseButton1Click:Connect(handlers.onReconnect)
502
+ makeButton(buttons, "clear", 3).MouseButton1Click:Connect(handlers.onClear)
503
+
504
+ -- Accent rule under the header. One hairline in the signature violet is what
505
+ -- separates this from every other grey dock in Studio.
506
+ local rule = Instance.new("Frame")
507
+ rule.Position = UDim2.new(0, 0, 0, 32)
508
+ rule.Size = UDim2.new(1, 0, 0, 1)
509
+ rule.BackgroundColor3 = PALETTE.rule
510
+ rule.BorderSizePixel = 0
511
+ rule.Parent = root
512
+
513
+ local ruleFade = Instance.new("UIGradient")
514
+ ruleFade.Color = ColorSequence.new(PALETTE.rule)
515
+ ruleFade.Transparency = NumberSequence.new({
516
+ NumberSequenceKeypoint.new(0, 0.15),
517
+ NumberSequenceKeypoint.new(1, 0.85),
518
+ })
519
+ ruleFade.Parent = rule
520
+
521
+ -- Log ------------------------------------------------------------------
522
+ local scroller = Instance.new("ScrollingFrame")
523
+ scroller.Position = UDim2.new(0, 0, 0, 33)
524
+ scroller.Size = UDim2.new(1, 0, 1, -55)
525
+ scroller.BackgroundTransparency = 1
526
+ scroller.BorderSizePixel = 0
527
+ scroller.ScrollBarThickness = 5
528
+ scroller.ScrollBarImageColor3 = PALETTE.violet
529
+ scroller.ScrollBarImageTransparency = 0.5
530
+ scroller.CanvasSize = UDim2.new()
531
+ scroller.AutomaticCanvasSize = Enum.AutomaticSize.Y
532
+ scroller.ScrollingDirection = Enum.ScrollingDirection.Y
533
+ scroller.Parent = root
534
+ state.scroller = scroller
535
+
536
+ local logPadding = Instance.new("UIPadding")
537
+ logPadding.PaddingTop = UDim.new(0, 8)
538
+ logPadding.PaddingBottom = UDim.new(0, 8)
539
+ logPadding.PaddingLeft = UDim.new(0, 12)
540
+ logPadding.PaddingRight = UDim.new(0, 12)
541
+ logPadding.Parent = scroller
542
+
543
+ local label = Instance.new("TextLabel")
544
+ label.Size = UDim2.new(1, 0, 0, 0)
545
+ label.AutomaticSize = Enum.AutomaticSize.Y
546
+ label.BackgroundTransparency = 1
547
+ label.Font = Enum.Font.Code
548
+ label.TextSize = 12
549
+ label.LineHeight = 1.25
550
+ label.TextColor3 = PALETTE.text
551
+ label.RichText = true
552
+ label.TextWrapped = true
553
+ label.TextXAlignment = Enum.TextXAlignment.Left
554
+ label.TextYAlignment = Enum.TextYAlignment.Top
555
+ label.Text = ""
556
+ label.Parent = scroller
557
+ state.label = label
558
+
559
+ -- Status bar ------------------------------------------------------------
560
+ local footer = Instance.new("Frame")
561
+ footer.AnchorPoint = Vector2.new(0, 1)
562
+ footer.Position = UDim2.fromScale(0, 1)
563
+ footer.Size = UDim2.new(1, 0, 0, 22)
564
+ footer.BackgroundColor3 = PALETTE.surface
565
+ footer.BorderSizePixel = 0
566
+ footer.Parent = root
567
+
568
+ local footerPadding = Instance.new("UIPadding")
569
+ footerPadding.PaddingLeft = UDim.new(0, 12)
570
+ footerPadding.PaddingRight = UDim.new(0, 12)
571
+ footerPadding.Parent = footer
572
+
573
+ local prompt = Instance.new("TextLabel")
574
+ prompt.Text = "studio\u{00B7}mcp"
575
+ prompt.Font = Enum.Font.Code
576
+ prompt.TextSize = 11
577
+ prompt.TextColor3 = PALETTE.violet
578
+ prompt.TextXAlignment = Enum.TextXAlignment.Left
579
+ prompt.BackgroundTransparency = 1
580
+ prompt.Size = UDim2.new(0, 70, 1, 0)
581
+ prompt.Parent = footer
582
+
583
+ --[[
584
+ No cursor here.
585
+
586
+ A blinking block after a prompt is the universal sign that something is
587
+ waiting to be typed into, and nothing in this panel accepts input. It
588
+ was there to prove the widget was live, which the activity band now does
589
+ honestly, by moving only when there is something to move about.
590
+ ]]
591
+
592
+ local counters = Instance.new("TextLabel")
593
+ counters.Text = "idle"
594
+ counters.Font = Enum.Font.Code
595
+ counters.TextSize = 11
596
+ counters.TextColor3 = PALETTE.dim
597
+ counters.TextXAlignment = Enum.TextXAlignment.Right
598
+ counters.BackgroundTransparency = 1
599
+ counters.AnchorPoint = Vector2.new(1, 0)
600
+ counters.Position = UDim2.fromScale(1, 0)
601
+ counters.Size = UDim2.new(1, -90, 1, 0)
602
+ counters.Parent = footer
603
+ state.countersText = counters
604
+
605
+ --[[
606
+ The band sits above the log and pushes it down, rather than over it.
607
+
608
+ The first version covered the log and was slightly transparent, so the
609
+ thing you actually read was both hidden and softened. Decoration that
610
+ costs legibility is a bad trade however good it looks, and this is a
611
+ console before it is anything else.
612
+ ]]
613
+ Visuals.mount(root, {
614
+ background = PALETTE.background,
615
+ surface = PALETTE.surface,
616
+ text = PALETTE.text,
617
+ dim = PALETTE.dim,
618
+ violet = PALETTE.violet,
619
+ red = PALETTE.red,
620
+ })
621
+ local band = root:FindFirstChild("ActivityBand") :: Frame
622
+ band.Position = UDim2.new(0, 0, 0, 33)
623
+ band.Size = UDim2.new(1, 0, 0, Visuals.BAND_HEIGHT)
624
+
625
+ -- The log's top edge follows the band, so turning it on never hides a line.
626
+ local function layoutLog()
627
+ local top = 33 + (if Visuals.isVisible() then Visuals.BAND_HEIGHT else 0)
628
+ scroller.Position = UDim2.new(0, 0, 0, top)
629
+ scroller.Size = UDim2.new(1, 0, 1, -(top + 22))
630
+ end
631
+
632
+ -- On by default. It is the part that says the session is alive, and a signal
633
+ -- nobody discovers is not a signal; anyone who wants the extra 44px back can
634
+ -- turn it off in one click.
635
+ Visuals.setVisible(true)
636
+ visualsButton.Text = utf8.char(0x25C6) .. " visuals"
637
+ Visuals.setIdle()
638
+ layoutLog()
639
+
640
+ visualsButton.MouseButton1Click:Connect(function()
641
+ Visuals.setVisible(not Visuals.isVisible())
642
+ layoutLog()
643
+ -- The button reports the state it is in, not the state it would move to.
644
+ -- A toggle that reads as an instruction is ambiguous the moment you look
645
+ -- away and back.
646
+ visualsButton.Text = if Visuals.isVisible() then "\u{25C6} visuals" else "visuals"
647
+ end)
648
+
649
+ -- Treat "scrolled away from the bottom" as the user reading history, and
650
+ -- stop yanking the view down under them until they scroll back.
651
+ scroller:GetPropertyChangedSignal("CanvasPosition"):Connect(function()
652
+ local maxScroll = math.max(0, scroller.AbsoluteCanvasSize.Y - scroller.AbsoluteWindowSize.Y)
653
+ state.pinned = maxScroll - scroller.CanvasPosition.Y < 24
654
+ end)
655
+ end
656
+
657
+ return Console
@@ -0,0 +1,35 @@
1
+ --!strict
2
+ --[[
3
+ Which of Studio's sessions this plugin copy is running in.
4
+
5
+ Pressing play adds a second connection for the playtest's server, on the same
6
+ place with the same name and the same id, so a listing shows two rows that
7
+ look identical. Choosing wrongly is not a small mistake: instances created or
8
+ changed in a play session are discarded the moment the user stops, so the work
9
+ appears to succeed and then vanishes.
10
+
11
+ Kept in its own module because two callers need it at different times. The
12
+ status handler reports it on request, and the transport announces it in the
13
+ handshake -- and the handshake matters more than it sounds, because the
14
+ ambiguity error that forces the choice is raised before any status call could
15
+ have filled it in.
16
+ ]]
17
+
18
+ local RunService = game:GetService("RunService")
19
+
20
+ local Context = {}
21
+
22
+ function Context.of(): string
23
+ if RunService:IsEdit() then
24
+ return "edit"
25
+ end
26
+ if not RunService:IsRunning() then
27
+ return "unknown"
28
+ end
29
+ if RunService:IsServer() and RunService:IsClient() then
30
+ return "playtest (solo)"
31
+ end
32
+ return if RunService:IsServer() then "playtest server" else "playtest client"
33
+ end
34
+
35
+ return Context