@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.
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/dist/bridge/rpc.js +243 -0
- package/dist/bridge/rpc.js.map +1 -0
- package/dist/bridge/server.js +281 -0
- package/dist/bridge/server.js.map +1 -0
- package/dist/index.js +104 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/apidump.js +269 -0
- package/dist/lib/apidump.js.map +1 -0
- package/dist/lib/errors.js +38 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/format.js +191 -0
- package/dist/lib/format.js.map +1 -0
- package/dist/lib/pluginbuild.js +83 -0
- package/dist/lib/pluginbuild.js.map +1 -0
- package/dist/lib/png.js +84 -0
- package/dist/lib/png.js.map +1 -0
- package/dist/lib/protocol.js +22 -0
- package/dist/lib/protocol.js.map +1 -0
- package/dist/lib/tool.js +27 -0
- package/dist/lib/tool.js.map +1 -0
- package/dist/resources.js +70 -0
- package/dist/resources.js.map +1 -0
- package/dist/tools/api.js +78 -0
- package/dist/tools/api.js.map +1 -0
- package/dist/tools/character.js +94 -0
- package/dist/tools/character.js.map +1 -0
- package/dist/tools/debug.js +211 -0
- package/dist/tools/debug.js.map +1 -0
- package/dist/tools/device.js +74 -0
- package/dist/tools/device.js.map +1 -0
- package/dist/tools/discover.js +217 -0
- package/dist/tools/discover.js.map +1 -0
- package/dist/tools/exec.js +191 -0
- package/dist/tools/exec.js.map +1 -0
- package/dist/tools/input.js +96 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/instances.js +261 -0
- package/dist/tools/instances.js.map +1 -0
- package/dist/tools/perf.js +367 -0
- package/dist/tools/perf.js.map +1 -0
- package/dist/tools/playtest.js +153 -0
- package/dist/tools/playtest.js.map +1 -0
- package/dist/tools/screenshot.js +75 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/scripts.js +316 -0
- package/dist/tools/scripts.js.map +1 -0
- package/dist/tools/session.js +152 -0
- package/dist/tools/session.js.map +1 -0
- package/dist/tools/world.js +281 -0
- package/dist/tools/world.js.map +1 -0
- package/package.json +62 -0
- package/plugin/default.project.json +6 -0
- package/plugin/src/Config.luau +59 -0
- package/plugin/src/Console.luau +657 -0
- package/plugin/src/Context.luau +35 -0
- package/plugin/src/Dispatch.luau +90 -0
- package/plugin/src/Editor.luau +142 -0
- package/plugin/src/Emulation.luau +151 -0
- package/plugin/src/LogBuffer.luau +277 -0
- package/plugin/src/Net.luau +102 -0
- package/plugin/src/Paths.luau +255 -0
- package/plugin/src/Phrase.luau +465 -0
- package/plugin/src/Png.luau +238 -0
- package/plugin/src/Scope.luau +78 -0
- package/plugin/src/ScriptEdit.luau +100 -0
- package/plugin/src/Serialize.luau +287 -0
- package/plugin/src/TextEdit.luau +296 -0
- package/plugin/src/Transport.luau +328 -0
- package/plugin/src/Undo.luau +72 -0
- package/plugin/src/Visuals.luau +710 -0
- package/plugin/src/handlers/Api.luau +242 -0
- package/plugin/src/handlers/Assets.luau +145 -0
- package/plugin/src/handlers/Capture.luau +187 -0
- package/plugin/src/handlers/Character.luau +361 -0
- package/plugin/src/handlers/Debug.luau +391 -0
- package/plugin/src/handlers/Device.luau +119 -0
- package/plugin/src/handlers/Discover.luau +289 -0
- package/plugin/src/handlers/Exec.luau +270 -0
- package/plugin/src/handlers/Geometry.luau +261 -0
- package/plugin/src/handlers/Input.luau +287 -0
- package/plugin/src/handlers/Instances.luau +389 -0
- package/plugin/src/handlers/Perf.luau +645 -0
- package/plugin/src/handlers/Playtest.luau +205 -0
- package/plugin/src/handlers/Scripts.luau +387 -0
- package/plugin/src/handlers/Session.luau +168 -0
- package/plugin/src/handlers/Viewport.luau +302 -0
- package/plugin/src/handlers/World.luau +176 -0
- package/plugin/src/init.server.luau +317 -0
- package/scripts/build-plugin.mjs +157 -0
- package/scripts/check-plugin.mjs +97 -0
- package/scripts/install-plugin.mjs +39 -0
- package/scripts/latency.mjs +201 -0
- package/scripts/locate-luau.mjs +51 -0
- package/scripts/sourcemap.mjs +58 -0
- 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, "&", "&")
|
|
153
|
+
escaped = string.gsub(escaped, "<", "<")
|
|
154
|
+
escaped = string.gsub(escaped, ">", ">")
|
|
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
|