@el4cteo/rbx-studio-mcp 0.6.1 → 0.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -2
- package/dist/bridge/console.js +182 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/cloudassets.js +233 -0
- package/dist/lib/cloudassets.js.map +1 -0
- package/dist/lib/credentials.js +180 -0
- package/dist/lib/credentials.js.map +1 -0
- package/dist/lib/livedata.js +325 -0
- package/dist/lib/livedata.js.map +1 -0
- package/dist/lib/liveluau.js +83 -0
- package/dist/lib/liveluau.js.map +1 -0
- package/dist/lib/liveops.js +358 -0
- package/dist/lib/liveops.js.map +1 -0
- package/dist/lib/opencloud.js +235 -0
- package/dist/lib/opencloud.js.map +1 -0
- package/dist/tools/anim.js +159 -0
- package/dist/tools/anim.js.map +1 -0
- package/dist/tools/audio.js +96 -0
- package/dist/tools/audio.js.map +1 -0
- package/dist/tools/character.js +95 -5
- package/dist/tools/character.js.map +1 -1
- package/dist/tools/data.js +292 -0
- package/dist/tools/data.js.map +1 -0
- package/dist/tools/device.js +77 -7
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +80 -4
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +96 -2
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +35 -9
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/perf.js +74 -7
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/scripts.js +162 -6
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/spatial.js +135 -0
- package/dist/tools/spatial.js.map +1 -0
- package/dist/tools/universe.js +177 -0
- package/dist/tools/universe.js.map +1 -0
- package/dist/tools/upload.js +294 -0
- package/dist/tools/upload.js.map +1 -0
- package/dist/tools/world.js +675 -51
- package/dist/tools/world.js.map +1 -1
- package/package.json +2 -2
- package/plugin/src/Commands.luau +31 -7
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/Console.luau +1909 -1843
- package/plugin/src/Emulation.luau +172 -0
- package/plugin/src/Phrase.luau +816 -618
- package/plugin/src/Png.luau +8 -4
- package/plugin/src/Prompt.luau +965 -961
- package/plugin/src/Secret.luau +86 -0
- package/plugin/src/Serialize.luau +440 -8
- package/plugin/src/Undo.luau +94 -6
- package/plugin/src/handlers/Anim.luau +897 -0
- package/plugin/src/handlers/Assets.luau +286 -2
- package/plugin/src/handlers/Audio.luau +411 -0
- package/plugin/src/handlers/Capture.luau +155 -20
- package/plugin/src/handlers/Character.luau +823 -361
- package/plugin/src/handlers/Data.luau +539 -0
- package/plugin/src/handlers/Device.luau +394 -139
- package/plugin/src/handlers/Discover.luau +685 -363
- package/plugin/src/handlers/Geometry.luau +722 -450
- package/plugin/src/handlers/Instances.luau +84 -4
- package/plugin/src/handlers/Perf.luau +227 -0
- package/plugin/src/handlers/Scripts.luau +673 -539
- package/plugin/src/handlers/Session.luau +3 -0
- package/plugin/src/handlers/Spatial.luau +334 -0
- package/plugin/src/handlers/Viewport.luau +268 -0
- package/plugin/src/handlers/World.luau +89 -15
- package/plugin/src/init.server.luau +9 -1
- package/scripts/build-plugin.mjs +20 -0
- package/scripts/check-plugin.mjs +171 -124
package/plugin/src/Console.luau
CHANGED
|
@@ -1,1843 +1,1909 @@
|
|
|
1
|
-
--!strict
|
|
2
|
-
--[[
|
|
3
|
-
The rbx-studio 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 TweenService = game:GetService("TweenService")
|
|
22
|
-
|
|
23
|
-
local Format = require(script.Parent.Format)
|
|
24
|
-
local Prompt = require(script.Parent.Prompt)
|
|
25
|
-
local ThemePicker = require(script.Parent.ThemePicker)
|
|
26
|
-
local Themes = require(script.Parent.Themes)
|
|
27
|
-
local Visuals = require(script.Parent.Visuals)
|
|
28
|
-
|
|
29
|
-
local Console = {}
|
|
30
|
-
|
|
31
|
-
--[[
|
|
32
|
-
The clear wipe: an old CRT being switched off.
|
|
33
|
-
|
|
34
|
-
A button that empties the screen and leaves no trace is indistinguishable
|
|
35
|
-
from one that crashed the panel. The receipt row answers that in words; this
|
|
36
|
-
answers it in the two hundred milliseconds before anyone has read the words.
|
|
37
|
-
|
|
38
|
-
It is an OVERLAY and nothing else. The log is cleared for real at the same
|
|
39
|
-
instant, underneath, so nothing about the console's state waits on an
|
|
40
|
-
animation -- a line that arrives mid-wipe lands in the fresh log and is
|
|
41
|
-
simply revealed when the overlay goes. What collapses is a still copy of the
|
|
42
|
-
text that was on screen, which is the honest thing to animate: it is a
|
|
43
|
-
picture of what was erased.
|
|
44
|
-
|
|
45
|
-
Three phases, and the timings are the whole effect. The picture holds and
|
|
46
|
-
then whips shut to a line; the line sits a moment; the line whips to a point
|
|
47
|
-
and is gone. Easing into each collapse rather than out of it is what makes it
|
|
48
|
-
read as a tube discharging rather than as a panel being resized.
|
|
49
|
-
]]
|
|
50
|
-
local CRT_COLLAPSE = 0.16
|
|
51
|
-
local CRT_HOLD = 0.07
|
|
52
|
-
local CRT_BLINK = 0.17
|
|
53
|
-
local CRT_SHUT = TweenInfo.new(CRT_COLLAPSE, Enum.EasingStyle.Quart, Enum.EasingDirection.In)
|
|
54
|
-
local CRT_SETTLE = TweenInfo.new(0.05, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
|
|
55
|
-
local CRT_OUT = TweenInfo.new(CRT_BLINK, Enum.EasingStyle.Quart, Enum.EasingDirection.In)
|
|
56
|
-
|
|
57
|
-
--[[
|
|
58
|
-
Explicit depths, because a plugin widget draws with ZIndexBehavior.Global
|
|
59
|
-
and a child does not inherit its parent's. Above the log, the band and the
|
|
60
|
-
hover readout; below the preset drawer, which must stay clickable.
|
|
61
|
-
]]
|
|
62
|
-
local Z_CRT = 20
|
|
63
|
-
local Z_CRT_TEXT = 21
|
|
64
|
-
local Z_CRT_LINE = 22
|
|
65
|
-
|
|
66
|
-
--[[
|
|
67
|
-
Ring buffer bound, counted in logged rows rather than in rendered lines.
|
|
68
|
-
|
|
69
|
-
It used to bound the rendered lines, which meant a burst of failures -- each
|
|
70
|
-
writing a message plus two wrapped lines of explanation -- evicted three
|
|
71
|
-
times as much history as a burst of successes. Bounding the records makes
|
|
72
|
-
"the last three hundred things that happened" mean what it says.
|
|
73
|
-
]]
|
|
74
|
-
local MAX_RECORDS = 300
|
|
75
|
-
|
|
76
|
-
-- The status bar's height. Named because three things now measure against it:
|
|
77
|
-
-- the bar itself, the prompt row sitting on top of it, and the log above both.
|
|
78
|
-
local FOOTER_HEIGHT = 22
|
|
79
|
-
|
|
80
|
-
--[[
|
|
81
|
-
The active preset's colours, rebound rather than re-read.
|
|
82
|
-
|
|
83
|
-
Every use site in this file is a plain `PALETTE.dim`, and there are about
|
|
84
|
-
forty of them. Turning each into a function call to satisfy theming would
|
|
85
|
-
have been forty edits to working code for no gain: the palette changes only
|
|
86
|
-
when the user picks a different preset, so it is simply reassigned there and
|
|
87
|
-
the reads stay as they were.
|
|
88
|
-
|
|
89
|
-
The catch is that anything which COPIES a colour out of here keeps the old
|
|
90
|
-
one. Instances built in `mount` do exactly that, which is why `applyTheme`
|
|
91
|
-
has to walk them by hand.
|
|
92
|
-
]]
|
|
93
|
-
local PALETTE: Themes.Palette = Themes.palette()
|
|
94
|
-
|
|
95
|
-
--[[
|
|
96
|
-
An optional listener on everything this console is told.
|
|
97
|
-
|
|
98
|
-
Exists for `Mirror`, which relays a playtest server's activity to the client
|
|
99
|
-
view that cannot reach the bridge itself. Kept as one hook rather than as
|
|
100
|
-
calls sprinkled through the file so there is a single place where "what the
|
|
101
|
-
console was told" is defined, and so the client half -- which sets no
|
|
102
|
-
observer -- cannot echo what it replays back into the channel.
|
|
103
|
-
]]
|
|
104
|
-
local observer: ((string, { any }) -> ())? = nil
|
|
105
|
-
|
|
106
|
-
function Console.setObserver(fn: ((string, { any }) -> ())?)
|
|
107
|
-
observer = fn
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
local function notify(kind: string, arguments: { any })
|
|
111
|
-
local listener = observer
|
|
112
|
-
if listener ~= nil then
|
|
113
|
-
task.spawn(listener, kind, arguments)
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
--[[
|
|
118
|
-
Set on the console that is REPLAYING someone else's events.
|
|
119
|
-
|
|
120
|
-
A replayed call must not grow its own consequences. `recordCall` schedules
|
|
121
|
-
the "agent idle" summary, so a mirrored session scheduled one of its own and
|
|
122
|
-
then received the original over the channel too -- the same line twice, a
|
|
123
|
-
few pixels apart, which is exactly the sort of thing that makes a panel look
|
|
124
|
-
broken. The origin is the only session entitled to decide the burst ended.
|
|
125
|
-
]]
|
|
126
|
-
local mirroring = false
|
|
127
|
-
|
|
128
|
-
function Console.setMirroring(value: boolean)
|
|
129
|
-
mirroring = value
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
-- Sigil plus colour per level. The sigil is what makes the log scannable at a
|
|
133
|
-
-- glance; colour alone fails for anyone who cannot separate red from green.
|
|
134
|
-
-- Named by palette key rather than by colour, because this table is built once
|
|
135
|
-
-- at load and a colour captured then belongs to whichever preset happened to
|
|
136
|
-
-- be active at the time.
|
|
137
|
-
local LEVELS: { [string]: { sigil: string, key: string } } = {
|
|
138
|
-
ok = { sigil = "\u{25C6}", key = "green" },
|
|
139
|
-
error = { sigil = "\u{2715}", key = "red" },
|
|
140
|
-
warn = { sigil = "\u{25B2}", key = "amber" },
|
|
141
|
-
info = { sigil = "\u{25C6}", key = "violet" },
|
|
142
|
-
dim = { sigil = "\u{00B7}", key = "dim" },
|
|
143
|
-
call = { sigil = "\u{25B8}", key = "cyan" },
|
|
144
|
-
reply = { sigil = "\u{25C2}", key = "violet" },
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export type Level = "ok" | "error" | "warn" | "info" | "dim" | "call" | "reply"
|
|
148
|
-
|
|
149
|
-
--[[
|
|
150
|
-
Column the right-aligned detail is padded out to, in characters.
|
|
151
|
-
|
|
152
|
-
Sized for the widget's default width rather than for the longest message: at
|
|
153
|
-
12pt Code with a timestamp ahead of it, anything past this wraps, and a
|
|
154
|
-
wrapped line breaks the very column the padding exists to produce. Messages
|
|
155
|
-
are cut to fit instead.
|
|
156
|
-
]]
|
|
157
|
-
local DETAIL_COLUMN = 52
|
|
158
|
-
|
|
159
|
-
--[[
|
|
160
|
-
How far past the column an inline detail may run before it is moved below the
|
|
161
|
-
message instead. A latency fits; a sentence does not.
|
|
162
|
-
]]
|
|
163
|
-
local INLINE_DETAIL = 14
|
|
164
|
-
|
|
165
|
-
-- Continuation lines sit under the message text, clear of the timestamp and the
|
|
166
|
-
-- sigil, so a wrapped explanation reads as belonging to the line above it.
|
|
167
|
-
local DETAIL_INDENT = string.rep(" ", 11)
|
|
168
|
-
|
|
169
|
-
-- Characters per continuation line. Fixed rather than measured from the widget:
|
|
170
|
-
-- the console is monospaced, and a width that changes as the user drags the
|
|
171
|
-
-- panel would rewrap history every frame.
|
|
172
|
-
local DETAIL_WRAP = 74
|
|
173
|
-
|
|
174
|
-
--[[
|
|
175
|
-
Breaks a long string into lines at word boundaries.
|
|
176
|
-
|
|
177
|
-
Roblox's own TextWrapped would do this, and would wrap to column 0 -- there
|
|
178
|
-
is no hanging indent for a TextLabel -- which is the ragged shape this
|
|
179
|
-
replaces. Wrapping here means every continuation line can carry the indent.
|
|
180
|
-
|
|
181
|
-
A single word longer than the width is cut rather than allowed to overhang.
|
|
182
|
-
That case is not prose: it is a path, a URL or a base64 blob, and letting one
|
|
183
|
-
of those push the line out re-creates the exact wrap this exists to prevent.
|
|
184
|
-
]]
|
|
185
|
-
|
|
186
|
-
--[[
|
|
187
|
-
One logged row, before it is coloured.
|
|
188
|
-
|
|
189
|
-
Holding the parts rather than the finished string is what lets a theme
|
|
190
|
-
switch repaint history: see `renderRecord`.
|
|
191
|
-
]]
|
|
192
|
-
type Record = {
|
|
193
|
-
level: Level,
|
|
194
|
-
message: string,
|
|
195
|
-
detail: string?,
|
|
196
|
-
stamp: string,
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
--[[
|
|
200
|
-
One MCP client sharing this bridge, as the server describes it.
|
|
201
|
-
|
|
202
|
-
`name` is what the agent calls itself in the MCP handshake, so it is
|
|
203
|
-
"claude-code" or "codex" rather than anything the bridge guessed. A client
|
|
204
|
-
that never introduced itself arrives as "unknown", which is honest: it is
|
|
205
|
-
connected, we just do not know what it is.
|
|
206
|
-
]]
|
|
207
|
-
export type Client = {
|
|
208
|
-
name: string,
|
|
209
|
-
version: string,
|
|
210
|
-
pid: number,
|
|
211
|
-
connectedAt: number,
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
type State = {
|
|
215
|
-
records: { Record },
|
|
216
|
-
-- The last status reported, replayed after a theme switch. Without it the
|
|
217
|
-
-- header has no way to know what colour it should be wearing.
|
|
218
|
-
status: string,
|
|
219
|
-
statusMeta: string,
|
|
220
|
-
-- The accent rule's gradient. A ColorSequence rather than a Color3, so it
|
|
221
|
-
-- cannot ride the generic role list.
|
|
222
|
-
ruleFade: UIGradient?,
|
|
223
|
-
--[[
|
|
224
|
-
Rows written, as opposed to lines rendered.
|
|
225
|
-
|
|
226
|
-
`lines` also holds the indented continuations a long detail wraps onto,
|
|
227
|
-
so counting it would report a number nobody wrote -- "cleared 41 logs"
|
|
228
|
-
for twelve commands. This counts calls to `log`, which is what a person
|
|
229
|
-
means by a log line.
|
|
230
|
-
]]
|
|
231
|
-
entries: number,
|
|
232
|
-
label: TextLabel?,
|
|
233
|
-
scroller: ScrollingFrame?,
|
|
234
|
-
statusDot: TextLabel?,
|
|
235
|
-
statusText: TextLabel?,
|
|
236
|
-
metaText: TextLabel?,
|
|
237
|
-
countersText: TextLabel?,
|
|
238
|
-
clientsChip: TextButton?,
|
|
239
|
-
pinned: boolean,
|
|
240
|
-
calls: number,
|
|
241
|
-
errors: number,
|
|
242
|
-
totalMs: number,
|
|
243
|
-
|
|
244
|
-
--
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
clients
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
is
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
--
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
local
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
math.floor(color.
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
the
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
local
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
The
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
local
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
local
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
end
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
end
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
end
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
end
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
--
|
|
665
|
-
--
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
--
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
and
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
]]
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
chip.
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
]
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
]]
|
|
1033
|
-
|
|
1034
|
-
local
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
if
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
--
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
--
|
|
1199
|
-
--
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
--
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
--
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
end
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
themed(
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
local
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
local
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
--
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
end
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
--
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
end
|
|
1842
|
-
|
|
1843
|
-
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
The rbx-studio 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 TweenService = game:GetService("TweenService")
|
|
22
|
+
|
|
23
|
+
local Format = require(script.Parent.Format)
|
|
24
|
+
local Prompt = require(script.Parent.Prompt)
|
|
25
|
+
local ThemePicker = require(script.Parent.ThemePicker)
|
|
26
|
+
local Themes = require(script.Parent.Themes)
|
|
27
|
+
local Visuals = require(script.Parent.Visuals)
|
|
28
|
+
|
|
29
|
+
local Console = {}
|
|
30
|
+
|
|
31
|
+
--[[
|
|
32
|
+
The clear wipe: an old CRT being switched off.
|
|
33
|
+
|
|
34
|
+
A button that empties the screen and leaves no trace is indistinguishable
|
|
35
|
+
from one that crashed the panel. The receipt row answers that in words; this
|
|
36
|
+
answers it in the two hundred milliseconds before anyone has read the words.
|
|
37
|
+
|
|
38
|
+
It is an OVERLAY and nothing else. The log is cleared for real at the same
|
|
39
|
+
instant, underneath, so nothing about the console's state waits on an
|
|
40
|
+
animation -- a line that arrives mid-wipe lands in the fresh log and is
|
|
41
|
+
simply revealed when the overlay goes. What collapses is a still copy of the
|
|
42
|
+
text that was on screen, which is the honest thing to animate: it is a
|
|
43
|
+
picture of what was erased.
|
|
44
|
+
|
|
45
|
+
Three phases, and the timings are the whole effect. The picture holds and
|
|
46
|
+
then whips shut to a line; the line sits a moment; the line whips to a point
|
|
47
|
+
and is gone. Easing into each collapse rather than out of it is what makes it
|
|
48
|
+
read as a tube discharging rather than as a panel being resized.
|
|
49
|
+
]]
|
|
50
|
+
local CRT_COLLAPSE = 0.16
|
|
51
|
+
local CRT_HOLD = 0.07
|
|
52
|
+
local CRT_BLINK = 0.17
|
|
53
|
+
local CRT_SHUT = TweenInfo.new(CRT_COLLAPSE, Enum.EasingStyle.Quart, Enum.EasingDirection.In)
|
|
54
|
+
local CRT_SETTLE = TweenInfo.new(0.05, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
|
|
55
|
+
local CRT_OUT = TweenInfo.new(CRT_BLINK, Enum.EasingStyle.Quart, Enum.EasingDirection.In)
|
|
56
|
+
|
|
57
|
+
--[[
|
|
58
|
+
Explicit depths, because a plugin widget draws with ZIndexBehavior.Global
|
|
59
|
+
and a child does not inherit its parent's. Above the log, the band and the
|
|
60
|
+
hover readout; below the preset drawer, which must stay clickable.
|
|
61
|
+
]]
|
|
62
|
+
local Z_CRT = 20
|
|
63
|
+
local Z_CRT_TEXT = 21
|
|
64
|
+
local Z_CRT_LINE = 22
|
|
65
|
+
|
|
66
|
+
--[[
|
|
67
|
+
Ring buffer bound, counted in logged rows rather than in rendered lines.
|
|
68
|
+
|
|
69
|
+
It used to bound the rendered lines, which meant a burst of failures -- each
|
|
70
|
+
writing a message plus two wrapped lines of explanation -- evicted three
|
|
71
|
+
times as much history as a burst of successes. Bounding the records makes
|
|
72
|
+
"the last three hundred things that happened" mean what it says.
|
|
73
|
+
]]
|
|
74
|
+
local MAX_RECORDS = 300
|
|
75
|
+
|
|
76
|
+
-- The status bar's height. Named because three things now measure against it:
|
|
77
|
+
-- the bar itself, the prompt row sitting on top of it, and the log above both.
|
|
78
|
+
local FOOTER_HEIGHT = 22
|
|
79
|
+
|
|
80
|
+
--[[
|
|
81
|
+
The active preset's colours, rebound rather than re-read.
|
|
82
|
+
|
|
83
|
+
Every use site in this file is a plain `PALETTE.dim`, and there are about
|
|
84
|
+
forty of them. Turning each into a function call to satisfy theming would
|
|
85
|
+
have been forty edits to working code for no gain: the palette changes only
|
|
86
|
+
when the user picks a different preset, so it is simply reassigned there and
|
|
87
|
+
the reads stay as they were.
|
|
88
|
+
|
|
89
|
+
The catch is that anything which COPIES a colour out of here keeps the old
|
|
90
|
+
one. Instances built in `mount` do exactly that, which is why `applyTheme`
|
|
91
|
+
has to walk them by hand.
|
|
92
|
+
]]
|
|
93
|
+
local PALETTE: Themes.Palette = Themes.palette()
|
|
94
|
+
|
|
95
|
+
--[[
|
|
96
|
+
An optional listener on everything this console is told.
|
|
97
|
+
|
|
98
|
+
Exists for `Mirror`, which relays a playtest server's activity to the client
|
|
99
|
+
view that cannot reach the bridge itself. Kept as one hook rather than as
|
|
100
|
+
calls sprinkled through the file so there is a single place where "what the
|
|
101
|
+
console was told" is defined, and so the client half -- which sets no
|
|
102
|
+
observer -- cannot echo what it replays back into the channel.
|
|
103
|
+
]]
|
|
104
|
+
local observer: ((string, { any }) -> ())? = nil
|
|
105
|
+
|
|
106
|
+
function Console.setObserver(fn: ((string, { any }) -> ())?)
|
|
107
|
+
observer = fn
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
local function notify(kind: string, arguments: { any })
|
|
111
|
+
local listener = observer
|
|
112
|
+
if listener ~= nil then
|
|
113
|
+
task.spawn(listener, kind, arguments)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
--[[
|
|
118
|
+
Set on the console that is REPLAYING someone else's events.
|
|
119
|
+
|
|
120
|
+
A replayed call must not grow its own consequences. `recordCall` schedules
|
|
121
|
+
the "agent idle" summary, so a mirrored session scheduled one of its own and
|
|
122
|
+
then received the original over the channel too -- the same line twice, a
|
|
123
|
+
few pixels apart, which is exactly the sort of thing that makes a panel look
|
|
124
|
+
broken. The origin is the only session entitled to decide the burst ended.
|
|
125
|
+
]]
|
|
126
|
+
local mirroring = false
|
|
127
|
+
|
|
128
|
+
function Console.setMirroring(value: boolean)
|
|
129
|
+
mirroring = value
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
-- Sigil plus colour per level. The sigil is what makes the log scannable at a
|
|
133
|
+
-- glance; colour alone fails for anyone who cannot separate red from green.
|
|
134
|
+
-- Named by palette key rather than by colour, because this table is built once
|
|
135
|
+
-- at load and a colour captured then belongs to whichever preset happened to
|
|
136
|
+
-- be active at the time.
|
|
137
|
+
local LEVELS: { [string]: { sigil: string, key: string } } = {
|
|
138
|
+
ok = { sigil = "\u{25C6}", key = "green" },
|
|
139
|
+
error = { sigil = "\u{2715}", key = "red" },
|
|
140
|
+
warn = { sigil = "\u{25B2}", key = "amber" },
|
|
141
|
+
info = { sigil = "\u{25C6}", key = "violet" },
|
|
142
|
+
dim = { sigil = "\u{00B7}", key = "dim" },
|
|
143
|
+
call = { sigil = "\u{25B8}", key = "cyan" },
|
|
144
|
+
reply = { sigil = "\u{25C2}", key = "violet" },
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type Level = "ok" | "error" | "warn" | "info" | "dim" | "call" | "reply"
|
|
148
|
+
|
|
149
|
+
--[[
|
|
150
|
+
Column the right-aligned detail is padded out to, in characters.
|
|
151
|
+
|
|
152
|
+
Sized for the widget's default width rather than for the longest message: at
|
|
153
|
+
12pt Code with a timestamp ahead of it, anything past this wraps, and a
|
|
154
|
+
wrapped line breaks the very column the padding exists to produce. Messages
|
|
155
|
+
are cut to fit instead.
|
|
156
|
+
]]
|
|
157
|
+
local DETAIL_COLUMN = 52
|
|
158
|
+
|
|
159
|
+
--[[
|
|
160
|
+
How far past the column an inline detail may run before it is moved below the
|
|
161
|
+
message instead. A latency fits; a sentence does not.
|
|
162
|
+
]]
|
|
163
|
+
local INLINE_DETAIL = 14
|
|
164
|
+
|
|
165
|
+
-- Continuation lines sit under the message text, clear of the timestamp and the
|
|
166
|
+
-- sigil, so a wrapped explanation reads as belonging to the line above it.
|
|
167
|
+
local DETAIL_INDENT = string.rep(" ", 11)
|
|
168
|
+
|
|
169
|
+
-- Characters per continuation line. Fixed rather than measured from the widget:
|
|
170
|
+
-- the console is monospaced, and a width that changes as the user drags the
|
|
171
|
+
-- panel would rewrap history every frame.
|
|
172
|
+
local DETAIL_WRAP = 74
|
|
173
|
+
|
|
174
|
+
--[[
|
|
175
|
+
Breaks a long string into lines at word boundaries.
|
|
176
|
+
|
|
177
|
+
Roblox's own TextWrapped would do this, and would wrap to column 0 -- there
|
|
178
|
+
is no hanging indent for a TextLabel -- which is the ragged shape this
|
|
179
|
+
replaces. Wrapping here means every continuation line can carry the indent.
|
|
180
|
+
|
|
181
|
+
A single word longer than the width is cut rather than allowed to overhang.
|
|
182
|
+
That case is not prose: it is a path, a URL or a base64 blob, and letting one
|
|
183
|
+
of those push the line out re-creates the exact wrap this exists to prevent.
|
|
184
|
+
]]
|
|
185
|
+
|
|
186
|
+
--[[
|
|
187
|
+
One logged row, before it is coloured.
|
|
188
|
+
|
|
189
|
+
Holding the parts rather than the finished string is what lets a theme
|
|
190
|
+
switch repaint history: see `renderRecord`.
|
|
191
|
+
]]
|
|
192
|
+
type Record = {
|
|
193
|
+
level: Level,
|
|
194
|
+
message: string,
|
|
195
|
+
detail: string?,
|
|
196
|
+
stamp: string,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
--[[
|
|
200
|
+
One MCP client sharing this bridge, as the server describes it.
|
|
201
|
+
|
|
202
|
+
`name` is what the agent calls itself in the MCP handshake, so it is
|
|
203
|
+
"claude-code" or "codex" rather than anything the bridge guessed. A client
|
|
204
|
+
that never introduced itself arrives as "unknown", which is honest: it is
|
|
205
|
+
connected, we just do not know what it is.
|
|
206
|
+
]]
|
|
207
|
+
export type Client = {
|
|
208
|
+
name: string,
|
|
209
|
+
version: string,
|
|
210
|
+
pid: number,
|
|
211
|
+
connectedAt: number,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
type State = {
|
|
215
|
+
records: { Record },
|
|
216
|
+
-- The last status reported, replayed after a theme switch. Without it the
|
|
217
|
+
-- header has no way to know what colour it should be wearing.
|
|
218
|
+
status: string,
|
|
219
|
+
statusMeta: string,
|
|
220
|
+
-- The accent rule's gradient. A ColorSequence rather than a Color3, so it
|
|
221
|
+
-- cannot ride the generic role list.
|
|
222
|
+
ruleFade: UIGradient?,
|
|
223
|
+
--[[
|
|
224
|
+
Rows written, as opposed to lines rendered.
|
|
225
|
+
|
|
226
|
+
`lines` also holds the indented continuations a long detail wraps onto,
|
|
227
|
+
so counting it would report a number nobody wrote -- "cleared 41 logs"
|
|
228
|
+
for twelve commands. This counts calls to `log`, which is what a person
|
|
229
|
+
means by a log line.
|
|
230
|
+
]]
|
|
231
|
+
entries: number,
|
|
232
|
+
label: TextLabel?,
|
|
233
|
+
scroller: ScrollingFrame?,
|
|
234
|
+
statusDot: TextLabel?,
|
|
235
|
+
statusText: TextLabel?,
|
|
236
|
+
metaText: TextLabel?,
|
|
237
|
+
countersText: TextLabel?,
|
|
238
|
+
clientsChip: TextButton?,
|
|
239
|
+
pinned: boolean,
|
|
240
|
+
calls: number,
|
|
241
|
+
errors: number,
|
|
242
|
+
totalMs: number,
|
|
243
|
+
durations: { number },
|
|
244
|
+
-- The command in flight, shown in the footer while it runs. One value that
|
|
245
|
+
-- is replaced, never a list that grows.
|
|
246
|
+
running: string?,
|
|
247
|
+
--[[
|
|
248
|
+
Bumped by every call, so a pending idle timer can tell whether it is still
|
|
249
|
+
the most recent one. Cheaper and less error-prone than cancelling timers:
|
|
250
|
+
`task.delay` has no handle to cancel, and a stale closure that checks a
|
|
251
|
+
counter simply does nothing.
|
|
252
|
+
]]
|
|
253
|
+
generation: number,
|
|
254
|
+
-- How many MCP clients share this bridge. Only ever displayed above one.
|
|
255
|
+
clients: number,
|
|
256
|
+
--[[
|
|
257
|
+
Whether the count above was learned from this connection or the last.
|
|
258
|
+
|
|
259
|
+
The bridge sends the roster the moment a stream opens, so the first
|
|
260
|
+
frame after every reconnect looks exactly like an agent arriving. It is
|
|
261
|
+
not: the same agents were there a second ago, and the connect itself has
|
|
262
|
+
already played its flourish. Cleared whenever the transport leaves
|
|
263
|
+
"connected", so only a genuine arrival mid-session celebrates.
|
|
264
|
+
]]
|
|
265
|
+
clientsKnown: boolean,
|
|
266
|
+
--[[
|
|
267
|
+
Who those clients are, newest last, as the bridge last reported them.
|
|
268
|
+
|
|
269
|
+
Kept even at one client, unlike the badge: the roster is what answers
|
|
270
|
+
"is this extra one a problem?", and the moment it becomes worth asking
|
|
271
|
+
is the moment a second appears -- too late to start recording.
|
|
272
|
+
]]
|
|
273
|
+
clientList: { Client },
|
|
274
|
+
-- Set while a repaint is already scheduled for the end of this frame.
|
|
275
|
+
dirty: boolean,
|
|
276
|
+
|
|
277
|
+
--[[
|
|
278
|
+
The clear wipe's overlay: an unclipped shell over the log region, the
|
|
279
|
+
collapsing screen inside it, a fixed window holding the still copy of
|
|
280
|
+
the text, and the bright line the picture discharges into.
|
|
281
|
+
]]
|
|
282
|
+
crt: Frame?,
|
|
283
|
+
crtScreen: Frame?,
|
|
284
|
+
crtWindow: Frame?,
|
|
285
|
+
crtGhost: TextLabel?,
|
|
286
|
+
crtLine: Frame?,
|
|
287
|
+
--[[
|
|
288
|
+
Bumped by every wipe, so the delayed halves of an earlier one know they
|
|
289
|
+
have been superseded and do nothing. `task.delay` hands back no handle to
|
|
290
|
+
cancel, and clearing twice inside four hundred milliseconds is a click
|
|
291
|
+
away.
|
|
292
|
+
]]
|
|
293
|
+
crtGeneration: number,
|
|
294
|
+
|
|
295
|
+
--[[
|
|
296
|
+
Which levels the log is currently showing, or nil for all of them.
|
|
297
|
+
|
|
298
|
+
A filter over the RECORDS rather than over what is appended: turning it
|
|
299
|
+
off has to bring the hidden rows back, and a log that dropped them on the
|
|
300
|
+
way in could only ever show what happened since. Records are kept whole
|
|
301
|
+
and `paint` decides what to draw.
|
|
302
|
+
]]
|
|
303
|
+
filter: { [string]: boolean }?,
|
|
304
|
+
|
|
305
|
+
--[[
|
|
306
|
+
Re-runs the log's top and bottom edges after something above it moves.
|
|
307
|
+
|
|
308
|
+
Defined inside `mount`, where the band and the scroller are both in
|
|
309
|
+
scope, and kept here so `toggleVisuals` can reach it. A second copy of
|
|
310
|
+
that arithmetic is how the log ends up overlapping the band.
|
|
311
|
+
]]
|
|
312
|
+
relayout: (() -> ())?,
|
|
313
|
+
visualsButton: TextButton?,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
local state: State = {
|
|
317
|
+
records = {},
|
|
318
|
+
ruleFade = nil,
|
|
319
|
+
status = "disconnected",
|
|
320
|
+
statusMeta = "",
|
|
321
|
+
entries = 0,
|
|
322
|
+
label = nil,
|
|
323
|
+
scroller = nil,
|
|
324
|
+
statusDot = nil,
|
|
325
|
+
statusText = nil,
|
|
326
|
+
metaText = nil,
|
|
327
|
+
countersText = nil,
|
|
328
|
+
clientsChip = nil,
|
|
329
|
+
pinned = true,
|
|
330
|
+
calls = 0,
|
|
331
|
+
errors = 0,
|
|
332
|
+
totalMs = 0,
|
|
333
|
+
durations = {},
|
|
334
|
+
running = nil,
|
|
335
|
+
generation = 0,
|
|
336
|
+
clients = 1,
|
|
337
|
+
clientsKnown = false,
|
|
338
|
+
clientList = {},
|
|
339
|
+
dirty = false,
|
|
340
|
+
crt = nil,
|
|
341
|
+
crtScreen = nil,
|
|
342
|
+
crtWindow = nil,
|
|
343
|
+
crtGhost = nil,
|
|
344
|
+
crtLine = nil,
|
|
345
|
+
crtGeneration = 0,
|
|
346
|
+
filter = nil,
|
|
347
|
+
relayout = nil,
|
|
348
|
+
visualsButton = nil,
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
-- Declared ahead of its definition: `setClients` calls it and is written
|
|
352
|
+
-- above it, and a plain `function layoutHeader()` there would have quietly
|
|
353
|
+
-- become a global.
|
|
354
|
+
local layoutHeader: () -> ()
|
|
355
|
+
|
|
356
|
+
-- How long the session must be silent before the console says so. Long enough
|
|
357
|
+
-- that an agent pausing to think is not announced as having stopped.
|
|
358
|
+
local QUIET_SECONDS = 20
|
|
359
|
+
|
|
360
|
+
local function hex(color: Color3): string
|
|
361
|
+
return string.format(
|
|
362
|
+
"#%02X%02X%02X",
|
|
363
|
+
math.floor(color.R * 255 + 0.5),
|
|
364
|
+
math.floor(color.G * 255 + 0.5),
|
|
365
|
+
math.floor(color.B * 255 + 0.5)
|
|
366
|
+
)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
local function span(color: Color3, value: string): string
|
|
371
|
+
return string.format('<font color="%s">%s</font>', hex(color), Format.escape(value))
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
--[[
|
|
375
|
+
Turns an agent's markdown into the markup this label already speaks.
|
|
376
|
+
|
|
377
|
+
Agents write for a terminal that renders markdown, so their replies arrive
|
|
378
|
+
full of `**` and backticks. Printed raw they are worse than noise -- the
|
|
379
|
+
asterisks land in the middle of a sentence and read as typos -- and stripping
|
|
380
|
+
them would throw away the emphasis the agent chose. The label is RichText, so
|
|
381
|
+
the third option is simply to honour it.
|
|
382
|
+
|
|
383
|
+
Applied AFTER escaping, which is what makes it safe: `escape` has already
|
|
384
|
+
turned every angle bracket in the agent's own text into an entity, so the
|
|
385
|
+
only tags in the string afterwards are the ones put there here.
|
|
386
|
+
|
|
387
|
+
Bold before italic, or the outer pair of a `**` run is eaten as two italics.
|
|
388
|
+
Underscores are deliberately not italic markers: `execute_luau` and
|
|
389
|
+
`mcp__rbx-studio__modify` are the vocabulary of this log, and they would
|
|
390
|
+
spend most of their lives in italics for no reason.
|
|
391
|
+
]]
|
|
392
|
+
|
|
393
|
+
--[[
|
|
394
|
+
Collapses anything that would break the one-line-per-row contract.
|
|
395
|
+
|
|
396
|
+
A row is a line. Every width here is measured in characters and every
|
|
397
|
+
continuation is indented by hand, so a newline arriving inside a message or
|
|
398
|
+
a detail lands in the middle of that arithmetic and comes out at column 0 --
|
|
399
|
+
which is how a two-line Luau snippet in a tool argument left a bare "m"
|
|
400
|
+
sitting under the log. Tabs go the same way, for the same reason.
|
|
401
|
+
]]
|
|
402
|
+
|
|
403
|
+
--[[
|
|
404
|
+
Turns one record into the lines it occupies.
|
|
405
|
+
|
|
406
|
+
Rendering is deferred to here rather than done when the row is logged, which
|
|
407
|
+
is the change that made theming possible at all: a line that has already had
|
|
408
|
+
`#A78BFA` baked into it cannot be recoloured, so switching preset used to
|
|
409
|
+
leave the whole session's history in the previous theme's palette while new
|
|
410
|
+
rows arrived in the new one. Now nothing is coloured until it is painted.
|
|
411
|
+
]]
|
|
412
|
+
local function renderRecord(record: Record): { string }
|
|
413
|
+
local spec = LEVELS[record.level] or LEVELS.info
|
|
414
|
+
local detail = record.detail
|
|
415
|
+
|
|
416
|
+
--[[
|
|
417
|
+
Measured in characters, not bytes.
|
|
418
|
+
|
|
419
|
+
`#body` counts bytes, and every sigil in this console is a 3-byte UTF-8
|
|
420
|
+
glyph, so it over-counted each line by two and pushed the latency column
|
|
421
|
+
two places left on exactly the lines that had a latency. The column was
|
|
422
|
+
never straight, and the cause was invisible until two different sigils
|
|
423
|
+
sat next to each other.
|
|
424
|
+
]]
|
|
425
|
+
--[[
|
|
426
|
+
Only a detail that will actually sit in the column costs the message any
|
|
427
|
+
of its width. Cutting the message to make room for a detail that then
|
|
428
|
+
goes on its own line below would shorten it for nothing.
|
|
429
|
+
]]
|
|
430
|
+
local detailWidth = if detail then (utf8.len(detail) or #detail) else 0
|
|
431
|
+
local messageWidth = utf8.len(record.message) or #record.message
|
|
432
|
+
local budget = DETAIL_COLUMN - 3
|
|
433
|
+
--[[
|
|
434
|
+
The message has to fit as it stands, not once it has been cut down.
|
|
435
|
+
|
|
436
|
+
The rule used to be about the PAIR fitting, and then the message was
|
|
437
|
+
truncated to make the pair true. That was written when every message was
|
|
438
|
+
a tool name, where losing the tail of "Run Luau (7 lines)" costs nothing.
|
|
439
|
+
A prompt echoed back is a message too, and it came out as the user's own
|
|
440
|
+
sentence chopped at 49 characters with an ellipsis -- to make room for
|
|
441
|
+
the word "you". A detail is worth less than the line it annotates, so
|
|
442
|
+
when both cannot fit it is the detail that moves.
|
|
443
|
+
]]
|
|
444
|
+
local inlineDetail = detail ~= nil
|
|
445
|
+
and messageWidth <= budget
|
|
446
|
+
and messageWidth + detailWidth + 3 <= DETAIL_COLUMN + INLINE_DETAIL
|
|
447
|
+
|
|
448
|
+
local trimmed = record.message
|
|
449
|
+
|
|
450
|
+
--[[
|
|
451
|
+
A message too long for the width is wrapped here, not by the label.
|
|
452
|
+
|
|
453
|
+
Details have always been wrapped with a hanging indent; messages never
|
|
454
|
+
were, because until now every message was a tool name. Agent output is
|
|
455
|
+
prose -- whole paragraphs arriving as one row -- and a paragraph left to
|
|
456
|
+
the TextLabel wraps back to column 0, under the timestamps, so the second
|
|
457
|
+
line of a sentence reads as a new entry. The indent is what keeps a
|
|
458
|
+
wrapped sentence visibly part of the row above it.
|
|
459
|
+
|
|
460
|
+
Skipped when a detail is riding the right-hand column: the message has
|
|
461
|
+
already been cut to fit beside it, and wrapping something that fits would
|
|
462
|
+
only break the column the cut was made to protect.
|
|
463
|
+
]]
|
|
464
|
+
local continuation: { string } = {}
|
|
465
|
+
if not inlineDetail and (utf8.len(trimmed) or #trimmed) > DETAIL_WRAP then
|
|
466
|
+
local wrapped = Format.wrap(trimmed, DETAIL_WRAP)
|
|
467
|
+
trimmed = table.remove(wrapped, 1) :: string
|
|
468
|
+
continuation = wrapped
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
--[[
|
|
472
|
+
Only an agent's own prose is read as markdown.
|
|
473
|
+
|
|
474
|
+
`reply` is the level its sentences arrive on. Every other level carries
|
|
475
|
+
names this console generated -- tool names, paths, glob patterns -- where
|
|
476
|
+
an asterisk is a character rather than an instruction, and italicising
|
|
477
|
+
half a path would be a worse bug than the one this fixes.
|
|
478
|
+
]]
|
|
479
|
+
local prose = record.level == "reply"
|
|
480
|
+
|
|
481
|
+
local function line(text: string): string
|
|
482
|
+
local escaped = Format.escape(text)
|
|
483
|
+
if prose then
|
|
484
|
+
escaped = Format.emphasise(escaped, hex(PALETTE.cyan))
|
|
485
|
+
end
|
|
486
|
+
return string.format('<font color="%s">%s</font>', hex(PALETTE[spec.key]), escaped)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
local body = string.format("%s %s", spec.sigil, trimmed)
|
|
490
|
+
local bodyWidth = utf8.len(body) or #body
|
|
491
|
+
local lines = { span(PALETTE.dim, record.stamp) .. " " .. line(body) }
|
|
492
|
+
|
|
493
|
+
-- In the message's own colour, not the dim of a detail: these lines ARE the
|
|
494
|
+
-- message, and greying them would read as an explanation of it.
|
|
495
|
+
for _, extra in continuation do
|
|
496
|
+
table.insert(lines, line(DETAIL_INDENT .. extra))
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
--[[
|
|
500
|
+
Short details ride the right-hand column; long ones get their own lines.
|
|
501
|
+
|
|
502
|
+
The column exists for latencies -- "12ms" stacking into something
|
|
503
|
+
readable -- and it was applied to every detail regardless of length. A
|
|
504
|
+
sentence of prose therefore started at column 52, ran off the widget, and
|
|
505
|
+
wrapped back to column 0, so the explanation of a standby session came
|
|
506
|
+
out as a ragged block that began in the middle of the screen and ended at
|
|
507
|
+
the left edge. Anything that will not fit beside the message is better
|
|
508
|
+
off beneath it.
|
|
509
|
+
]]
|
|
510
|
+
if detail then
|
|
511
|
+
if inlineDetail then
|
|
512
|
+
local padding = math.max(1, DETAIL_COLUMN - bodyWidth)
|
|
513
|
+
lines[1] ..= span(PALETTE.dim, string.rep(" ", padding) .. detail)
|
|
514
|
+
else
|
|
515
|
+
for _, wrapped in Format.wrap(detail, DETAIL_WRAP) do
|
|
516
|
+
table.insert(lines, span(PALETTE.dim, DETAIL_INDENT .. wrapped))
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
return lines
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
local function paint()
|
|
524
|
+
state.dirty = false
|
|
525
|
+
local label = state.label
|
|
526
|
+
if not label then
|
|
527
|
+
return
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
local lines: { string } = {}
|
|
531
|
+
local filter = state.filter
|
|
532
|
+
for _, record in state.records do
|
|
533
|
+
if filter == nil or filter[record.level] then
|
|
534
|
+
for _, line in renderRecord(record) do
|
|
535
|
+
table.insert(lines, line)
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
label.Text = table.concat(lines, "\n")
|
|
540
|
+
|
|
541
|
+
-- Only follow the tail when the user has not scrolled up to read history.
|
|
542
|
+
local scroller = state.scroller
|
|
543
|
+
if scroller and state.pinned then
|
|
544
|
+
task.defer(function()
|
|
545
|
+
if scroller.Parent then
|
|
546
|
+
scroller.CanvasPosition = Vector2.new(0, math.max(0, scroller.AbsoluteCanvasSize.Y))
|
|
547
|
+
end
|
|
548
|
+
end)
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
--[[
|
|
553
|
+
Asks for a repaint, at most one per frame.
|
|
554
|
+
|
|
555
|
+
Painting is a concat of up to three hundred strings followed by a RichText
|
|
556
|
+
relayout of the whole label, and it used to run once per appended row. A
|
|
557
|
+
failed command writes three rows -- the failure, its message, sometimes a
|
|
558
|
+
hint -- so a single unlucky call repainted the entire console three times,
|
|
559
|
+
and all of that sat in front of the reply on its way back to the agent.
|
|
560
|
+
Deferring collapses them into the one paint that was always sufficient.
|
|
561
|
+
]]
|
|
562
|
+
local function redraw()
|
|
563
|
+
if state.dirty then
|
|
564
|
+
return
|
|
565
|
+
end
|
|
566
|
+
state.dirty = true
|
|
567
|
+
task.defer(paint)
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
--[[
|
|
571
|
+
How many recent calls the typical-latency figure looks at.
|
|
572
|
+
]]
|
|
573
|
+
local TIMING_WINDOW = 50
|
|
574
|
+
|
|
575
|
+
--[[
|
|
576
|
+
The typical call time, as a median rather than a mean.
|
|
577
|
+
|
|
578
|
+
A mean is the wrong statistic for latency and the panel had it: nine calls,
|
|
579
|
+
seven of them under 65ms and two of them deliberate 45-second waits, summed
|
|
580
|
+
to "avg 5027ms". Nothing about the bridge was slow, and the one number on
|
|
581
|
+
screen said everything was. One long call is normal -- a profile, a sleep, a
|
|
582
|
+
Creator Store search -- so the summary has to survive one, and a median does
|
|
583
|
+
while a mean cannot.
|
|
584
|
+
]]
|
|
585
|
+
local function typicalMs(): number
|
|
586
|
+
local total = #state.durations
|
|
587
|
+
if total == 0 then
|
|
588
|
+
return 0
|
|
589
|
+
end
|
|
590
|
+
local sorted = table.clone(state.durations)
|
|
591
|
+
table.sort(sorted)
|
|
592
|
+
local middle = (total + 1) / 2
|
|
593
|
+
if total % 2 == 1 then
|
|
594
|
+
return sorted[middle]
|
|
595
|
+
end
|
|
596
|
+
return (sorted[middle - 0.5] + sorted[middle + 0.5]) / 2
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
--[[
|
|
600
|
+
A duration as the panel shows it.
|
|
601
|
+
|
|
602
|
+
Rounded to milliseconds, a handler that does its work in a fraction of one
|
|
603
|
+
reads as "0ms", and nothing takes zero time -- it is the one figure on the
|
|
604
|
+
panel that claims something impossible. Most handlers genuinely are under a
|
|
605
|
+
millisecond, so this is the common case rather than an edge.
|
|
606
|
+
|
|
607
|
+
Exported because the same number is written in two places -- the footer's
|
|
608
|
+
typical, and the time beside every row -- and they were formatted
|
|
609
|
+
separately. That showed: the footer said "typical <1ms" directly above a
|
|
610
|
+
column of rows all claiming "0ms", which reads as a bug in whichever one you
|
|
611
|
+
believe less.
|
|
612
|
+
]]
|
|
613
|
+
function Console.durationText(milliseconds: number): string
|
|
614
|
+
if milliseconds < 1 then
|
|
615
|
+
return "<1ms"
|
|
616
|
+
end
|
|
617
|
+
return string.format("%.0fms", milliseconds)
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
local function typicalText(): string
|
|
621
|
+
return Console.durationText(typicalMs())
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
local function refreshCounters()
|
|
625
|
+
local counters = state.countersText
|
|
626
|
+
if not counters then
|
|
627
|
+
return
|
|
628
|
+
end
|
|
629
|
+
--[[
|
|
630
|
+
The footer is for totals, and only totals.
|
|
631
|
+
|
|
632
|
+
It briefly doubled as the in-flight readout, which meant a running
|
|
633
|
+
command overwrote "4 calls 0 errors avg 84ms" with its own name -- the
|
|
634
|
+
session statistics disappearing exactly when the session was busiest.
|
|
635
|
+
Two live readouts on one small panel is one too many, and the band
|
|
636
|
+
already has the better spot for it, right beside the solid.
|
|
637
|
+
]]
|
|
638
|
+
counters.TextColor3 = PALETTE.dim
|
|
639
|
+
if state.calls == 0 then
|
|
640
|
+
counters.Text = "idle"
|
|
641
|
+
return
|
|
642
|
+
end
|
|
643
|
+
counters.Text = string.format(
|
|
644
|
+
"%d call%s %d error%s typical %s",
|
|
645
|
+
state.calls,
|
|
646
|
+
if state.calls == 1 then "" else "s",
|
|
647
|
+
state.errors,
|
|
648
|
+
if state.errors == 1 then "" else "s",
|
|
649
|
+
typicalText()
|
|
650
|
+
)
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
--[[
|
|
654
|
+
Appends one line. `detail` is padded to a fixed column and dimmed, so
|
|
655
|
+
latencies stack into a readable column instead of trailing each message at a
|
|
656
|
+
ragged offset.
|
|
657
|
+
]]
|
|
658
|
+
function Console.log(level: Level, message: string, detail: string?)
|
|
659
|
+
table.insert(state.records, {
|
|
660
|
+
level = level,
|
|
661
|
+
message = Format.flatten(message),
|
|
662
|
+
detail = if detail ~= nil then Format.flatten(detail) else nil,
|
|
663
|
+
-- Stamped when the row happened, not when it is painted. A repaint after
|
|
664
|
+
-- a theme switch re-renders every line, and re-reading the clock there
|
|
665
|
+
-- would restamp the whole session to the moment the user changed colour.
|
|
666
|
+
stamp = os.date("%H:%M:%S") :: string,
|
|
667
|
+
})
|
|
668
|
+
state.entries += 1
|
|
669
|
+
while #state.records > MAX_RECORDS do
|
|
670
|
+
table.remove(state.records, 1)
|
|
671
|
+
end
|
|
672
|
+
redraw()
|
|
673
|
+
notify("log", { level, message, detail })
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
export type Row = {
|
|
677
|
+
level: string,
|
|
678
|
+
message: string,
|
|
679
|
+
detail: string?,
|
|
680
|
+
stamp: string,
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
--[[
|
|
684
|
+
The rows this console is holding, oldest first.
|
|
685
|
+
|
|
686
|
+
Handed out as copies of the table but not of the records themselves: nothing
|
|
687
|
+
outside this file writes to a record, and deep-copying three hundred of them
|
|
688
|
+
every two seconds to guard against a caller that does not exist is a cost for
|
|
689
|
+
nobody. See `History`, which is the only caller.
|
|
690
|
+
]]
|
|
691
|
+
function Console.snapshot(): { Row }
|
|
692
|
+
local rows: { Row } = {}
|
|
693
|
+
for _, record in state.records do
|
|
694
|
+
table.insert(rows, {
|
|
695
|
+
level = record.level :: string,
|
|
696
|
+
message = record.message,
|
|
697
|
+
detail = record.detail,
|
|
698
|
+
stamp = record.stamp,
|
|
699
|
+
})
|
|
700
|
+
end
|
|
701
|
+
return rows
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
--[[
|
|
705
|
+
Puts rows back, from before this copy of the plugin existed.
|
|
706
|
+
|
|
707
|
+
Their own timestamps come with them rather than being restamped: a restored
|
|
708
|
+
row happened when it happened, and stamping it "now" would make a log that is
|
|
709
|
+
being carried across a playtest look like a log that is repeating itself.
|
|
710
|
+
|
|
711
|
+
Refuses once anything has been logged. Restoring is a load-time act, and
|
|
712
|
+
splicing history under a running session would put old rows below new ones --
|
|
713
|
+
the one thing a chronological log may not do.
|
|
714
|
+
]]
|
|
715
|
+
function Console.restore(rows: { Row })
|
|
716
|
+
if #state.records > 0 then
|
|
717
|
+
return
|
|
718
|
+
end
|
|
719
|
+
for _, row in rows do
|
|
720
|
+
-- A level from disk names a colour and a sigil; an unknown one would
|
|
721
|
+
-- render as `info` silently, so it is normalised here instead.
|
|
722
|
+
local level: Level = if LEVELS[row.level] ~= nil then (row.level :: any) else "dim"
|
|
723
|
+
table.insert(state.records, {
|
|
724
|
+
level = level,
|
|
725
|
+
message = row.message,
|
|
726
|
+
detail = row.detail,
|
|
727
|
+
stamp = row.stamp,
|
|
728
|
+
})
|
|
729
|
+
end
|
|
730
|
+
while #state.records > MAX_RECORDS do
|
|
731
|
+
table.remove(state.records, 1)
|
|
732
|
+
end
|
|
733
|
+
redraw()
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
--[[
|
|
737
|
+
Announces a command as it starts -- everywhere except the log.
|
|
738
|
+
|
|
739
|
+
This used to append a line, and then the reply appended a second one saying
|
|
740
|
+
the same thing in a different colour. Every call cost two rows and read as
|
|
741
|
+
duplicated output, which is exactly what it was: a cyan "Edit KillBrick"
|
|
742
|
+
followed by a violet "Edit KillBrick".
|
|
743
|
+
|
|
744
|
+
The log now takes one line per call, written when it finishes and carrying
|
|
745
|
+
the latency it took. What is running *right now* belongs in a place that
|
|
746
|
+
updates rather than accumulates, so it goes to the footer and the activity
|
|
747
|
+
band -- both of which show a single current value and neither of which grows.
|
|
748
|
+
]]
|
|
749
|
+
--[[
|
|
750
|
+
Colour and pace per kind of work, so the band reads as what is happening.
|
|
751
|
+
|
|
752
|
+
Reads are cool and quick because they are constant and harmless; writes are
|
|
753
|
+
violet and slower because they change the user's game; running code is green
|
|
754
|
+
and heavier still. Urgency drives both the spin rate and how strongly the
|
|
755
|
+
colour takes over, so the two never disagree.
|
|
756
|
+
]]
|
|
757
|
+
local KIND_LOOK: { [string]: { key: string, urgency: number } } = {
|
|
758
|
+
read = { key = "cyan", urgency = 0.3 },
|
|
759
|
+
write = { key = "violet", urgency = 0.7 },
|
|
760
|
+
run = { key = "green", urgency = 0.9 },
|
|
761
|
+
debug = { key = "amber", urgency = 0.5 },
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function Console.beginCall(title: string, kind: string)
|
|
765
|
+
state.running = title
|
|
766
|
+
state.generation += 1
|
|
767
|
+
local look = KIND_LOOK[kind] or KIND_LOOK.read
|
|
768
|
+
Visuals.setCaption(title)
|
|
769
|
+
Visuals.setKind(PALETTE[look.key], look.urgency)
|
|
770
|
+
refreshCounters()
|
|
771
|
+
notify("beginCall", { title, kind })
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
--[[
|
|
775
|
+
Records one completed command for the session counters. Kept separate from
|
|
776
|
+
`log` so callers can log freely without skewing the statistics.
|
|
777
|
+
]]
|
|
778
|
+
function Console.recordCall(ok: boolean, milliseconds: number)
|
|
779
|
+
state.calls += 1
|
|
780
|
+
state.totalMs += milliseconds
|
|
781
|
+
--[[
|
|
782
|
+
Kept for the median. The window is bounded because this grows for as long
|
|
783
|
+
as the panel is open, and because a figure meant to say "how is it
|
|
784
|
+
feeling now" should not be answering with this morning.
|
|
785
|
+
]]
|
|
786
|
+
table.insert(state.durations, milliseconds)
|
|
787
|
+
if #state.durations > TIMING_WINDOW then
|
|
788
|
+
table.remove(state.durations, 1)
|
|
789
|
+
end
|
|
790
|
+
if not ok then
|
|
791
|
+
state.errors += 1
|
|
792
|
+
end
|
|
793
|
+
-- Read before it is cleared: the bar wants the same phrase the log row uses,
|
|
794
|
+
-- and this is the only place that still has it.
|
|
795
|
+
local title = state.running or "call"
|
|
796
|
+
state.running = nil
|
|
797
|
+
refreshCounters()
|
|
798
|
+
-- The footer reports totals when idle, so the band shows what just ran
|
|
799
|
+
-- instead of repeating the same word on the same screen.
|
|
800
|
+
Visuals.setIdle()
|
|
801
|
+
|
|
802
|
+
-- The band plots it: bar height is how long it took, colour is whether it
|
|
803
|
+
-- worked, and a failure knocks the solid off its axis as well.
|
|
804
|
+
Visuals.recordCall(milliseconds, ok, title)
|
|
805
|
+
notify("recordCall", { ok, milliseconds, title })
|
|
806
|
+
|
|
807
|
+
--[[
|
|
808
|
+
Says so when the session goes quiet, once per burst.
|
|
809
|
+
|
|
810
|
+
There is no "the agent has finished" message in MCP -- an agent that has
|
|
811
|
+
stopped and one that is thinking are the same silence -- so this reports
|
|
812
|
+
the silence rather than claiming to know what caused it: what ran, how
|
|
813
|
+
much of it, and how fast. The generation check is what makes it once per
|
|
814
|
+
burst: every later call bumps the counter, so all but the newest timer
|
|
815
|
+
wake up, find they are stale, and do nothing.
|
|
816
|
+
]]
|
|
817
|
+
state.generation += 1
|
|
818
|
+
local mine = state.generation
|
|
819
|
+
if mirroring then
|
|
820
|
+
return
|
|
821
|
+
end
|
|
822
|
+
task.delay(QUIET_SECONDS, function()
|
|
823
|
+
if state.generation ~= mine or state.calls == 0 then
|
|
824
|
+
return
|
|
825
|
+
end
|
|
826
|
+
--[[
|
|
827
|
+
Not while an agent this panel started is still working.
|
|
828
|
+
|
|
829
|
+
This line reports a silence, and its whole justification was that MCP
|
|
830
|
+
gives no way to tell a finished agent from a thinking one. For an
|
|
831
|
+
agent we started ourselves that is no longer true -- we hold its
|
|
832
|
+
process and know exactly when it exits -- so announcing it idle
|
|
833
|
+
mid-run would be stating something we can see is false, in the middle
|
|
834
|
+
of its own output.
|
|
835
|
+
]]
|
|
836
|
+
if Prompt.isBusy() then
|
|
837
|
+
return
|
|
838
|
+
end
|
|
839
|
+
Console.log(
|
|
840
|
+
"dim",
|
|
841
|
+
string.format(
|
|
842
|
+
"agent idle -- %d call%s, typical %s",
|
|
843
|
+
state.calls,
|
|
844
|
+
if state.calls == 1 then "" else "s",
|
|
845
|
+
typicalText()
|
|
846
|
+
)
|
|
847
|
+
)
|
|
848
|
+
Visuals.setQuiet()
|
|
849
|
+
end)
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
--[[
|
|
853
|
+
Pins a line of text beside the activity strip.
|
|
854
|
+
|
|
855
|
+
Unlike the caption a running command sets, this survives until something
|
|
856
|
+
else replaces it, which is what a session that will never run a command
|
|
857
|
+
needs: the standby client view has nothing to report and no reason to say
|
|
858
|
+
"waiting for a command" forever when it is not waiting for one.
|
|
859
|
+
]]
|
|
860
|
+
function Console.setCaption(message: string)
|
|
861
|
+
Visuals.setCaption(message)
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
--[[
|
|
865
|
+
Announces that an MCP client disconnected.
|
|
866
|
+
|
|
867
|
+
This is the one moment the bridge can be certain a session ended rather than
|
|
868
|
+
paused -- the client's process is gone -- so it is the one place the console
|
|
869
|
+
is allowed to state it outright. Everything else it knows about agent
|
|
870
|
+
activity is inference, and is worded as inference.
|
|
871
|
+
]]
|
|
872
|
+
function Console.agentFinished()
|
|
873
|
+
--[[
|
|
874
|
+
Named for what happened, not for what it might have meant.
|
|
875
|
+
|
|
876
|
+
This said "Agent finished task." and that was overclaiming: the bridge
|
|
877
|
+
sees a client disconnect and nothing more. Quitting the editor, a crash
|
|
878
|
+
and a restart all arrive here identically, and the first time one was
|
|
879
|
+
watched live it announced a completed task for an agent that had simply
|
|
880
|
+
been closed mid-idle.
|
|
881
|
+
]]
|
|
882
|
+
Console.log("ok", "Agent disconnected.", "client left")
|
|
883
|
+
Visuals.setQuiet()
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
--[[
|
|
887
|
+
How many MCP clients are sharing this bridge.
|
|
888
|
+
|
|
889
|
+
Reported only above one. A single client is the ordinary case and needs no
|
|
890
|
+
badge -- a permanent "1 client connected" is a label, not a signal, and the
|
|
891
|
+
header has better uses for the width.
|
|
892
|
+
]]
|
|
893
|
+
function Console.setClients(count: number, list: { Client }?)
|
|
894
|
+
local previous = state.clients
|
|
895
|
+
local known = state.clientsKnown
|
|
896
|
+
state.clients = count
|
|
897
|
+
state.clientsKnown = true
|
|
898
|
+
if list ~= nil then
|
|
899
|
+
state.clientList = list
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
--[[
|
|
903
|
+
An agent joining gets the same flourish as a session landing.
|
|
904
|
+
|
|
905
|
+
It is the same kind of news -- something that can now drive this Studio
|
|
906
|
+
has arrived -- and the trace is where this panel says so. Guarded on
|
|
907
|
+
`known` because the bridge re-sends the roster on every reconnect, and
|
|
908
|
+
replaying the arrival of agents that never left would fire it several
|
|
909
|
+
times a session until it stopped meaning anything.
|
|
910
|
+
]]
|
|
911
|
+
if known and count > previous then
|
|
912
|
+
Visuals.celebrate()
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
local chip = state.clientsChip
|
|
916
|
+
if chip then
|
|
917
|
+
-- Pluralised even though the badge hides at one. A string that is only
|
|
918
|
+
-- ever correct because nobody can see it is a trap for whoever changes
|
|
919
|
+
-- the visibility rule later.
|
|
920
|
+
chip.Text = string.format(
|
|
921
|
+
"\u{25C6} %d client%s",
|
|
922
|
+
count,
|
|
923
|
+
if count == 1 then "" else "s"
|
|
924
|
+
)
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
layoutHeader()
|
|
928
|
+
|
|
929
|
+
--[[
|
|
930
|
+
Only the arrival is worth a row.
|
|
931
|
+
|
|
932
|
+
The departure had one too, and seeing it live made the redundancy plain:
|
|
933
|
+
"one MCP client connected" landed in the same second as "Agent finished
|
|
934
|
+
task.", saying the same thing less well, while the badge disappearing
|
|
935
|
+
said it a third time. The count going up is news because nothing else
|
|
936
|
+
reports it; the count coming down is already covered twice over.
|
|
937
|
+
]]
|
|
938
|
+
if count > 1 and previous <= 1 then
|
|
939
|
+
--[[
|
|
940
|
+
Named, not just counted.
|
|
941
|
+
|
|
942
|
+
"3 MCP clients connected" is the line people bring to us asking
|
|
943
|
+
whether something is wrong, because a number cannot say whether the
|
|
944
|
+
extra ones are agents they started or processes they forgot. The
|
|
945
|
+
names can, so they are in the row that raises the question rather
|
|
946
|
+
than only in a panel the reader has to know to hover.
|
|
947
|
+
]]
|
|
948
|
+
Console.log(
|
|
949
|
+
"info",
|
|
950
|
+
string.format("%d MCP clients connected", count),
|
|
951
|
+
Console.clientSummary()
|
|
952
|
+
)
|
|
953
|
+
end
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
--[[
|
|
957
|
+
The roster on one line, for the caption and the arrival row.
|
|
958
|
+
|
|
959
|
+
Names only, deduplicated by name with a count where it repeats: two Codex
|
|
960
|
+
windows are "codex x2", not "codex, codex". The interesting fact is which
|
|
961
|
+
tools are attached, and a list that repeats a name reads as a mistake.
|
|
962
|
+
]]
|
|
963
|
+
--[[
|
|
964
|
+
Places the header's right-hand controls, and gives the meta line what is left.
|
|
965
|
+
|
|
966
|
+
One function rather than each control minding its own position, because the
|
|
967
|
+
clients badge comes and goes: it only appears above one client. With the
|
|
968
|
+
positions written at each call site, the meta line's width had to encode
|
|
969
|
+
every combination as a constant, and the two had to be kept in step by hand.
|
|
970
|
+
|
|
971
|
+
Laid out right to left from the buttons, each control claiming its width
|
|
972
|
+
plus a gap. The meta line then ends a fixed distance further left, which is
|
|
973
|
+
the rule it always followed -- it just no longer has to be told the answer.
|
|
974
|
+
]]
|
|
975
|
+
local BUTTONS_WIDTH = 244
|
|
976
|
+
local HEADER_GAP = 8
|
|
977
|
+
local CHIP_WIDTH = 84
|
|
978
|
+
local META_CLEARANCE = 150
|
|
979
|
+
|
|
980
|
+
layoutHeader = function()
|
|
981
|
+
local edge = BUTTONS_WIDTH
|
|
982
|
+
|
|
983
|
+
local chip = state.clientsChip
|
|
984
|
+
if chip then
|
|
985
|
+
chip.Visible = state.clients > 1
|
|
986
|
+
if chip.Visible then
|
|
987
|
+
edge += HEADER_GAP
|
|
988
|
+
chip.Position = UDim2.new(1, -edge, 0.5, 0)
|
|
989
|
+
edge += CHIP_WIDTH
|
|
990
|
+
end
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
local meta = state.metaText
|
|
994
|
+
if meta then
|
|
995
|
+
meta.Size = UDim2.new(1, -(edge + META_CLEARANCE), 1, 0)
|
|
996
|
+
end
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
function Console.clientSummary(): string
|
|
1003
|
+
local list = state.clientList
|
|
1004
|
+
if #list == 0 then
|
|
1005
|
+
return ""
|
|
1006
|
+
end
|
|
1007
|
+
local order: { string } = {}
|
|
1008
|
+
local seen: { [string]: number } = {}
|
|
1009
|
+
for _, client in list do
|
|
1010
|
+
local name = if client.name ~= "" then client.name else "unknown"
|
|
1011
|
+
if seen[name] == nil then
|
|
1012
|
+
seen[name] = 0
|
|
1013
|
+
table.insert(order, name)
|
|
1014
|
+
end
|
|
1015
|
+
seen[name] += 1
|
|
1016
|
+
end
|
|
1017
|
+
local parts: { string } = {}
|
|
1018
|
+
for _, name in order do
|
|
1019
|
+
local total = seen[name]
|
|
1020
|
+
table.insert(parts, if total > 1 then string.format("%s x%d", name, total) else name)
|
|
1021
|
+
end
|
|
1022
|
+
return table.concat(parts, ", ")
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
--[[
|
|
1026
|
+
The roster in full, one row per client, written into the log.
|
|
1027
|
+
|
|
1028
|
+
In the log rather than a hover panel because that is where this console puts
|
|
1029
|
+
facts it wants the user to be able to scroll back to -- and because a list
|
|
1030
|
+
that only exists while the pointer is on it cannot be read and acted on at
|
|
1031
|
+
the same time.
|
|
1032
|
+
]]
|
|
1033
|
+
function Console.reportClients()
|
|
1034
|
+
local list = state.clientList
|
|
1035
|
+
if #list == 0 then
|
|
1036
|
+
--[[
|
|
1037
|
+
An empty roster beside a count above one is not "nobody is here",
|
|
1038
|
+
it is a server too old to say who. Worth distinguishing: the first
|
|
1039
|
+
reading sends someone looking for a connection problem that does
|
|
1040
|
+
not exist, and the fix -- restart the MCP server -- is not one
|
|
1041
|
+
anybody guesses from "no client has introduced itself".
|
|
1042
|
+
]]
|
|
1043
|
+
if state.clients > 0 then
|
|
1044
|
+
Console.log(
|
|
1045
|
+
"dim",
|
|
1046
|
+
string.format("%d connected, but this bridge did not say which", state.clients),
|
|
1047
|
+
"restart the MCP server"
|
|
1048
|
+
)
|
|
1049
|
+
else
|
|
1050
|
+
Console.log("dim", "no MCP client is connected")
|
|
1051
|
+
end
|
|
1052
|
+
return
|
|
1053
|
+
end
|
|
1054
|
+
Console.log("info", string.format("%d MCP client%s on this bridge", #list, if #list == 1 then "" else "s"))
|
|
1055
|
+
local now = os.time()
|
|
1056
|
+
for _, client in list do
|
|
1057
|
+
local name = if client.name ~= "" then client.name else "unknown"
|
|
1058
|
+
local version = if client.version ~= "" then " " .. client.version else ""
|
|
1059
|
+
-- Seconds since the epoch on both sides, so this is a real elapsed time
|
|
1060
|
+
-- and not a guess: the bridge and Studio are the same machine.
|
|
1061
|
+
local since = math.max(0, now - math.floor(client.connectedAt / 1000))
|
|
1062
|
+
Console.log(
|
|
1063
|
+
"dim",
|
|
1064
|
+
string.format(" %s%s", name, version),
|
|
1065
|
+
string.format("pid %d %s", client.pid, Console.humanDuration(since))
|
|
1066
|
+
)
|
|
1067
|
+
end
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
--[[
|
|
1071
|
+
A duration a person reads at a glance, not a precise one.
|
|
1072
|
+
|
|
1073
|
+
Rounded down deliberately: "2m" for anything in that minute is what someone
|
|
1074
|
+
scanning a list wants, and a ticking "2m 47s" invites the reader to watch it
|
|
1075
|
+
rather than to read past it.
|
|
1076
|
+
]]
|
|
1077
|
+
function Console.humanDuration(seconds: number): string
|
|
1078
|
+
if seconds < 60 then
|
|
1079
|
+
return string.format("%ds", seconds)
|
|
1080
|
+
end
|
|
1081
|
+
if seconds < 3600 then
|
|
1082
|
+
return string.format("%dm", math.floor(seconds / 60))
|
|
1083
|
+
end
|
|
1084
|
+
return string.format("%dh %dm", math.floor(seconds / 3600), math.floor(seconds % 3600 / 60))
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
--[[
|
|
1088
|
+
Plays the wipe over whatever is on screen right now.
|
|
1089
|
+
|
|
1090
|
+
Must be called BEFORE the log is emptied: the still copy it collapses is
|
|
1091
|
+
read straight off the live label, scroll offset and all, so what discharges
|
|
1092
|
+
is exactly the text the user was looking at rather than a re-render of it.
|
|
1093
|
+
|
|
1094
|
+
Silent when there is nothing to play it on -- a widget dragged down to a
|
|
1095
|
+
sliver has no room for a picture to collapse, and a flourish that plays in
|
|
1096
|
+
four pixels is a flicker, not an effect.
|
|
1097
|
+
]]
|
|
1098
|
+
local function crtWipe()
|
|
1099
|
+
local shell = state.crt
|
|
1100
|
+
local screen = state.crtScreen
|
|
1101
|
+
local window = state.crtWindow
|
|
1102
|
+
local ghost = state.crtGhost
|
|
1103
|
+
local line = state.crtLine
|
|
1104
|
+
local scroller = state.scroller
|
|
1105
|
+
local label = state.label
|
|
1106
|
+
if
|
|
1107
|
+
shell == nil
|
|
1108
|
+
or screen == nil
|
|
1109
|
+
or window == nil
|
|
1110
|
+
or ghost == nil
|
|
1111
|
+
or line == nil
|
|
1112
|
+
or scroller == nil
|
|
1113
|
+
or label == nil
|
|
1114
|
+
then
|
|
1115
|
+
return
|
|
1116
|
+
end
|
|
1117
|
+
|
|
1118
|
+
local height = scroller.AbsoluteSize.Y
|
|
1119
|
+
if height < 24 then
|
|
1120
|
+
return
|
|
1121
|
+
end
|
|
1122
|
+
|
|
1123
|
+
state.crtGeneration += 1
|
|
1124
|
+
local generation = state.crtGeneration
|
|
1125
|
+
|
|
1126
|
+
-- Read at play time rather than tracked, so the overlay lands on the log
|
|
1127
|
+
-- wherever the band toggle and the widget's size have left it.
|
|
1128
|
+
shell.Position = scroller.Position
|
|
1129
|
+
shell.Size = scroller.Size
|
|
1130
|
+
|
|
1131
|
+
--[[
|
|
1132
|
+
The still copy, offset by exactly as far as the log is scrolled.
|
|
1133
|
+
|
|
1134
|
+
A TextLabel cannot scroll its own text, so the window is a fixed-height
|
|
1135
|
+
clip and the label inside it is pushed up by the canvas offset. That is
|
|
1136
|
+
what makes the copy line up with the real log to the pixel instead of
|
|
1137
|
+
snapping to the top of the buffer the moment the wipe starts.
|
|
1138
|
+
]]
|
|
1139
|
+
window.Size = UDim2.new(1, 0, 0, height)
|
|
1140
|
+
ghost.Position = UDim2.new(0, 12, 0, 8 - scroller.CanvasPosition.Y)
|
|
1141
|
+
ghost.Size = UDim2.new(1, -24, 0, 0)
|
|
1142
|
+
ghost.TextColor3 = PALETTE.text
|
|
1143
|
+
ghost.Text = label.Text
|
|
1144
|
+
|
|
1145
|
+
screen.BackgroundColor3 = PALETTE.background
|
|
1146
|
+
screen.Size = UDim2.new(1, 0, 1, 0)
|
|
1147
|
+
screen.Visible = true
|
|
1148
|
+
|
|
1149
|
+
line.BackgroundColor3 = PALETTE.text
|
|
1150
|
+
line.Size = UDim2.new(1, 0, 0, 2)
|
|
1151
|
+
line.BackgroundTransparency = 1
|
|
1152
|
+
line.Visible = false
|
|
1153
|
+
|
|
1154
|
+
shell.Visible = true
|
|
1155
|
+
|
|
1156
|
+
-- The picture, whipping shut around its own middle.
|
|
1157
|
+
TweenService:Create(screen, CRT_SHUT, { Size = UDim2.new(1, 0, 0, 2) }):Play()
|
|
1158
|
+
|
|
1159
|
+
task.delay(CRT_COLLAPSE, function()
|
|
1160
|
+
if state.crtGeneration ~= generation then
|
|
1161
|
+
return
|
|
1162
|
+
end
|
|
1163
|
+
|
|
1164
|
+
--[[
|
|
1165
|
+
The handover. The screen goes and the line arrives in the same frame,
|
|
1166
|
+
a little over-thick, and settles -- which is the flash. Fading one
|
|
1167
|
+
into the other instead just looks like a crossfade of two rectangles.
|
|
1168
|
+
]]
|
|
1169
|
+
screen.Visible = false
|
|
1170
|
+
line.BackgroundTransparency = 0
|
|
1171
|
+
line.Size = UDim2.new(1, 0, 0, 3)
|
|
1172
|
+
line.Visible = true
|
|
1173
|
+
TweenService:Create(line, CRT_SETTLE, { Size = UDim2.new(1, 0, 0, 2) }):Play()
|
|
1174
|
+
|
|
1175
|
+
task.delay(CRT_HOLD, function()
|
|
1176
|
+
if state.crtGeneration ~= generation then
|
|
1177
|
+
return
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
TweenService:Create(line, CRT_OUT, {
|
|
1181
|
+
Size = UDim2.new(0, 0, 0, 2),
|
|
1182
|
+
BackgroundTransparency = 0.35,
|
|
1183
|
+
}):Play()
|
|
1184
|
+
|
|
1185
|
+
task.delay(CRT_BLINK, function()
|
|
1186
|
+
if state.crtGeneration ~= generation then
|
|
1187
|
+
return
|
|
1188
|
+
end
|
|
1189
|
+
shell.Visible = false
|
|
1190
|
+
line.Visible = false
|
|
1191
|
+
screen.Visible = true
|
|
1192
|
+
end)
|
|
1193
|
+
end)
|
|
1194
|
+
end)
|
|
1195
|
+
end
|
|
1196
|
+
|
|
1197
|
+
function Console.clear()
|
|
1198
|
+
-- Before anything is emptied: the wipe collapses a copy of what is on
|
|
1199
|
+
-- screen, and a moment later there is nothing on screen to copy.
|
|
1200
|
+
crtWipe()
|
|
1201
|
+
|
|
1202
|
+
--[[
|
|
1203
|
+
Says what it did, with a timestamp, like everything else in here.
|
|
1204
|
+
|
|
1205
|
+
A button that empties the screen and leaves no trace is indistinguishable
|
|
1206
|
+
from one that crashed the panel. The count is read before the clear and
|
|
1207
|
+
written after it, so the first row of the fresh log is the receipt for
|
|
1208
|
+
the one that went.
|
|
1209
|
+
]]
|
|
1210
|
+
local cleared = state.entries
|
|
1211
|
+
table.clear(state.records)
|
|
1212
|
+
state.entries = 0
|
|
1213
|
+
state.calls = 0
|
|
1214
|
+
state.errors = 0
|
|
1215
|
+
state.totalMs = 0
|
|
1216
|
+
table.clear(state.durations)
|
|
1217
|
+
refreshCounters()
|
|
1218
|
+
-- The bars belonged to the log that just went. Leaving forty timings from an
|
|
1219
|
+
-- erased session on screen while the footer reads "idle" is two answers to
|
|
1220
|
+
-- one question.
|
|
1221
|
+
Visuals.clearTrace()
|
|
1222
|
+
redraw()
|
|
1223
|
+
|
|
1224
|
+
if cleared > 0 then
|
|
1225
|
+
Console.log(
|
|
1226
|
+
"dim",
|
|
1227
|
+
string.format("cleared %d log%s", cleared, if cleared == 1 then "" else "s")
|
|
1228
|
+
)
|
|
1229
|
+
end
|
|
1230
|
+
end
|
|
1231
|
+
|
|
1232
|
+
--[[
|
|
1233
|
+
Updates the header. Kept separate from the log so the current state is always
|
|
1234
|
+
visible without scrolling, however long the session has run.
|
|
1235
|
+
]]
|
|
1236
|
+
function Console.setStatus(status: string, meta: string)
|
|
1237
|
+
-- Read before it is overwritten: the arrival flourish below is the one thing
|
|
1238
|
+
-- here that cares whether this is a change or a repeat.
|
|
1239
|
+
local previous = state.status
|
|
1240
|
+
state.status = status
|
|
1241
|
+
state.statusMeta = meta
|
|
1242
|
+
|
|
1243
|
+
local dot = state.statusDot
|
|
1244
|
+
local text = state.statusText
|
|
1245
|
+
local metaLabel = state.metaText
|
|
1246
|
+
if not dot or not text or not metaLabel then
|
|
1247
|
+
return
|
|
1248
|
+
end
|
|
1249
|
+
|
|
1250
|
+
-- "standby" is a working state, not a fault: the client half of a playtest
|
|
1251
|
+
-- cannot use HTTP and is not meant to connect. Painting it red like a real
|
|
1252
|
+
-- disconnection made a correct setup look broken.
|
|
1253
|
+
local color = if status == "connected"
|
|
1254
|
+
then PALETTE.green
|
|
1255
|
+
elseif status == "connecting" then PALETTE.amber
|
|
1256
|
+
elseif status == "standby" or status == "mirroring" then PALETTE.dim
|
|
1257
|
+
else PALETTE.red
|
|
1258
|
+
|
|
1259
|
+
dot.TextColor3 = color
|
|
1260
|
+
text.Text = string.upper(status)
|
|
1261
|
+
text.TextColor3 = color
|
|
1262
|
+
metaLabel.Text = meta
|
|
1263
|
+
|
|
1264
|
+
-- The wireframe takes the same colour as the status light, so the panel
|
|
1265
|
+
-- reads as disconnected at a glance even with the header off screen.
|
|
1266
|
+
Visuals.setTint(color)
|
|
1267
|
+
|
|
1268
|
+
-- The trace has no calls to plot until a session exists, so it waves while
|
|
1269
|
+
-- the transport is reaching for one rather than sitting as a dead baseline
|
|
1270
|
+
-- at exactly the moment somebody is watching to see whether it is alive.
|
|
1271
|
+
Visuals.setConnecting(status == "connecting")
|
|
1272
|
+
|
|
1273
|
+
--[[
|
|
1274
|
+
And it celebrates when the session finally lands.
|
|
1275
|
+
|
|
1276
|
+
Only on the transition. `setStatus` is also how a theme switch and a
|
|
1277
|
+
port change repaint the header, and both replay the current status --
|
|
1278
|
+
which would fire the flourish again for an event that already happened,
|
|
1279
|
+
several times a session, until it read as noise rather than as news.
|
|
1280
|
+
]]
|
|
1281
|
+
if status == "connected" and previous ~= "connected" then
|
|
1282
|
+
Visuals.celebrate()
|
|
1283
|
+
end
|
|
1284
|
+
|
|
1285
|
+
-- A roster learned on the old connection says nothing about this one.
|
|
1286
|
+
if status ~= "connected" then
|
|
1287
|
+
state.clientsKnown = false
|
|
1288
|
+
end
|
|
1289
|
+
end
|
|
1290
|
+
|
|
1291
|
+
--[[
|
|
1292
|
+
Records which palette colour an Instance is wearing, and puts it on.
|
|
1293
|
+
|
|
1294
|
+
The console builds about thirty Instances and copies a colour into each. A
|
|
1295
|
+
copy does not follow the palette when the user picks a different preset, so
|
|
1296
|
+
the panel would keep the old theme's header, rule, scrollbar and buttons
|
|
1297
|
+
while the log underneath repainted -- which looks less like a theme than
|
|
1298
|
+
like a half-finished render.
|
|
1299
|
+
|
|
1300
|
+
Rather than keep thirty named references and re-set thirty properties by
|
|
1301
|
+
hand, each one declares the property and palette key it is wearing when it
|
|
1302
|
+
is built, and `Console.applyTheme` walks the list. Adding a widget therefore
|
|
1303
|
+
cannot forget to theme it: the same call that colours it registers it.
|
|
1304
|
+
]]
|
|
1305
|
+
type Role = { instance: Instance, property: string, key: string }
|
|
1306
|
+
local roles: { Role } = {}
|
|
1307
|
+
|
|
1308
|
+
local function themed<T>(instance: T & Instance, property: string, key: string): T
|
|
1309
|
+
table.insert(roles, { instance = instance, property = property, key = key })
|
|
1310
|
+
;(instance :: any)[property] = PALETTE[key]
|
|
1311
|
+
return instance
|
|
1312
|
+
end
|
|
1313
|
+
|
|
1314
|
+
local function makeButton(parent: Instance, text: string, order: number): TextButton
|
|
1315
|
+
local button = Instance.new("TextButton")
|
|
1316
|
+
button.Name = text
|
|
1317
|
+
button.Text = text
|
|
1318
|
+
button.Font = Enum.Font.Code
|
|
1319
|
+
button.TextSize = 11
|
|
1320
|
+
themed(button, "TextColor3", "dim")
|
|
1321
|
+
themed(button, "BackgroundColor3", "background")
|
|
1322
|
+
button.AutoButtonColor = false
|
|
1323
|
+
button.BorderSizePixel = 0
|
|
1324
|
+
button.Size = UDim2.new(0, 76, 0, 20)
|
|
1325
|
+
button.LayoutOrder = order
|
|
1326
|
+
button.Parent = parent
|
|
1327
|
+
|
|
1328
|
+
local corner = Instance.new("UICorner")
|
|
1329
|
+
corner.CornerRadius = UDim.new(0, 3)
|
|
1330
|
+
corner.Parent = button
|
|
1331
|
+
|
|
1332
|
+
local stroke = Instance.new("UIStroke")
|
|
1333
|
+
themed(stroke, "Color", "dim")
|
|
1334
|
+
stroke.Transparency = 0.6
|
|
1335
|
+
stroke.Parent = button
|
|
1336
|
+
|
|
1337
|
+
button.MouseEnter:Connect(function()
|
|
1338
|
+
button.TextColor3 = PALETTE.violet
|
|
1339
|
+
stroke.Color = PALETTE.violet
|
|
1340
|
+
stroke.Transparency = 0.3
|
|
1341
|
+
end)
|
|
1342
|
+
button.MouseLeave:Connect(function()
|
|
1343
|
+
button.TextColor3 = PALETTE.dim
|
|
1344
|
+
stroke.Color = PALETTE.dim
|
|
1345
|
+
stroke.Transparency = 0.6
|
|
1346
|
+
end)
|
|
1347
|
+
|
|
1348
|
+
return button
|
|
1349
|
+
end
|
|
1350
|
+
|
|
1351
|
+
export type Handlers = {
|
|
1352
|
+
onReconnect: () -> (),
|
|
1353
|
+
onClear: () -> (),
|
|
1354
|
+
-- Called after a preset switch has already been applied, so the caller's
|
|
1355
|
+
-- only job is to remember it. Persistence lives there because
|
|
1356
|
+
-- `plugin:SetSetting` is not reachable from a ModuleScript.
|
|
1357
|
+
onTheme: (string) -> (),
|
|
1358
|
+
--[[
|
|
1359
|
+
A line the user typed into the prompt row.
|
|
1360
|
+
|
|
1361
|
+
The console does not interpret it. Commands live in their own module
|
|
1362
|
+
because half of them are about things this file knows nothing about --
|
|
1363
|
+
the transport, the port setting, the bridge -- and a console that
|
|
1364
|
+
reached for all of that to answer `status` would be the whole plugin.
|
|
1365
|
+
]]
|
|
1366
|
+
onSubmit: (string) -> (),
|
|
1367
|
+
-- Command names starting with the given prefix, for Tab and the hint.
|
|
1368
|
+
onComplete: (string) -> { string },
|
|
1369
|
+
--[[
|
|
1370
|
+
The same commands, described, for the menu that opens above the prompt.
|
|
1371
|
+
|
|
1372
|
+
Separate from `onComplete` because the two want different things: Tab
|
|
1373
|
+
wants names to compare against what is typed, and the menu wants a usage
|
|
1374
|
+
line and a summary to put on screen.
|
|
1375
|
+
]]
|
|
1376
|
+
onSuggest: (string) -> { { name: string, usage: string, summary: string } },
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
--[[
|
|
1380
|
+
Builds the widget contents. Colours are fixed rather than theme-derived: this
|
|
1381
|
+
is a console, and a console that repaints itself light grey reads as a form.
|
|
1382
|
+
]]
|
|
1383
|
+
function Console.mount(parent: Instance, handlers: Handlers)
|
|
1384
|
+
local root = Instance.new("Frame")
|
|
1385
|
+
root.Size = UDim2.fromScale(1, 1)
|
|
1386
|
+
themed(root, "BackgroundColor3", "background")
|
|
1387
|
+
root.BorderSizePixel = 0
|
|
1388
|
+
root.Parent = parent
|
|
1389
|
+
|
|
1390
|
+
-- Header ---------------------------------------------------------------
|
|
1391
|
+
local header = Instance.new("Frame")
|
|
1392
|
+
header.Size = UDim2.new(1, 0, 0, 32)
|
|
1393
|
+
themed(header, "BackgroundColor3", "surface")
|
|
1394
|
+
header.BorderSizePixel = 0
|
|
1395
|
+
header.Parent = root
|
|
1396
|
+
|
|
1397
|
+
local headerPadding = Instance.new("UIPadding")
|
|
1398
|
+
headerPadding.PaddingLeft = UDim.new(0, 12)
|
|
1399
|
+
headerPadding.PaddingRight = UDim.new(0, 8)
|
|
1400
|
+
headerPadding.Parent = header
|
|
1401
|
+
|
|
1402
|
+
local dot = Instance.new("TextLabel")
|
|
1403
|
+
dot.Text = "\u{25CF}"
|
|
1404
|
+
dot.Font = Enum.Font.Code
|
|
1405
|
+
dot.TextSize = 13
|
|
1406
|
+
-- Deliberately NOT registered with `themed`. Red here is the value it
|
|
1407
|
+
-- STARTS at, not the colour it wears: what it should be is whatever
|
|
1408
|
+
-- `setStatus` last reported. Registering it meant every theme switch
|
|
1409
|
+
-- repainted a connected session red.
|
|
1410
|
+
dot.TextColor3 = PALETTE.red
|
|
1411
|
+
dot.BackgroundTransparency = 1
|
|
1412
|
+
dot.Size = UDim2.new(0, 12, 1, 0)
|
|
1413
|
+
dot.Parent = header
|
|
1414
|
+
state.statusDot = dot
|
|
1415
|
+
|
|
1416
|
+
local status = Instance.new("TextLabel")
|
|
1417
|
+
status.Text = "DISCONNECTED"
|
|
1418
|
+
status.Font = Enum.Font.Code
|
|
1419
|
+
status.TextSize = 12
|
|
1420
|
+
status.TextColor3 = PALETTE.red
|
|
1421
|
+
status.TextXAlignment = Enum.TextXAlignment.Left
|
|
1422
|
+
status.BackgroundTransparency = 1
|
|
1423
|
+
status.Position = UDim2.new(0, 18, 0, 0)
|
|
1424
|
+
status.Size = UDim2.new(0, 110, 1, 0)
|
|
1425
|
+
status.Parent = header
|
|
1426
|
+
state.statusText = status
|
|
1427
|
+
|
|
1428
|
+
local meta = Instance.new("TextLabel")
|
|
1429
|
+
meta.Text = ""
|
|
1430
|
+
meta.Font = Enum.Font.Code
|
|
1431
|
+
meta.TextSize = 11
|
|
1432
|
+
themed(meta, "TextColor3", "dim")
|
|
1433
|
+
meta.TextXAlignment = Enum.TextXAlignment.Left
|
|
1434
|
+
meta.TextTruncate = Enum.TextTruncate.AtEnd
|
|
1435
|
+
meta.BackgroundTransparency = 1
|
|
1436
|
+
meta.Position = UDim2.new(0, 132, 0, 0)
|
|
1437
|
+
meta.Size = UDim2.new(1, -394, 1, 0)
|
|
1438
|
+
meta.Parent = header
|
|
1439
|
+
state.metaText = meta
|
|
1440
|
+
|
|
1441
|
+
--[[
|
|
1442
|
+
The shared-bridge badge, hidden until there is something to share.
|
|
1443
|
+
|
|
1444
|
+
Sits between the meta line and the buttons rather than in the activity
|
|
1445
|
+
band, because it is a fact about the connection and the header is where
|
|
1446
|
+
connection facts live. `meta` gives up the width when it appears; see
|
|
1447
|
+
`Console.setClients`.
|
|
1448
|
+
]]
|
|
1449
|
+
local clientsChip = Instance.new("TextButton")
|
|
1450
|
+
clientsChip.Name = "Clients"
|
|
1451
|
+
clientsChip.AnchorPoint = Vector2.new(1, 0.5)
|
|
1452
|
+
clientsChip.Position = UDim2.new(1, -252, 0.5, 0)
|
|
1453
|
+
clientsChip.Size = UDim2.new(0, 84, 0, 18)
|
|
1454
|
+
themed(clientsChip, "BackgroundColor3", "background")
|
|
1455
|
+
clientsChip.BorderSizePixel = 0
|
|
1456
|
+
clientsChip.Font = Enum.Font.Code
|
|
1457
|
+
clientsChip.TextSize = 11
|
|
1458
|
+
themed(clientsChip, "TextColor3", "cyan")
|
|
1459
|
+
clientsChip.Text = ""
|
|
1460
|
+
clientsChip.AutoButtonColor = false
|
|
1461
|
+
clientsChip.Visible = false
|
|
1462
|
+
clientsChip.Parent = header
|
|
1463
|
+
state.clientsChip = clientsChip
|
|
1464
|
+
|
|
1465
|
+
--[[
|
|
1466
|
+
A count raises a question the count cannot answer.
|
|
1467
|
+
|
|
1468
|
+
"3 clients" is the exact thing users bring to us asking whether it is a
|
|
1469
|
+
problem, and it never is answerable from a number: three agents they
|
|
1470
|
+
started and three processes they forgot look identical. Hovering names
|
|
1471
|
+
them in the caption -- cheap, no click, no panel to dismiss -- and
|
|
1472
|
+
clicking writes the full roster into the log, where it can be scrolled
|
|
1473
|
+
back to and acted on.
|
|
1474
|
+
]]
|
|
1475
|
+
clientsChip.MouseEnter:Connect(function()
|
|
1476
|
+
Visuals.showNote(Console.clientSummary())
|
|
1477
|
+
end)
|
|
1478
|
+
clientsChip.MouseLeave:Connect(function()
|
|
1479
|
+
Visuals.clearNote()
|
|
1480
|
+
end)
|
|
1481
|
+
clientsChip.Activated:Connect(function()
|
|
1482
|
+
Console.reportClients()
|
|
1483
|
+
end)
|
|
1484
|
+
|
|
1485
|
+
local chipCorner = Instance.new("UICorner")
|
|
1486
|
+
chipCorner.CornerRadius = UDim.new(0, 3)
|
|
1487
|
+
chipCorner.Parent = clientsChip
|
|
1488
|
+
|
|
1489
|
+
local chipStroke = Instance.new("UIStroke")
|
|
1490
|
+
themed(chipStroke, "Color", "cyan")
|
|
1491
|
+
chipStroke.Transparency = 0.6
|
|
1492
|
+
chipStroke.Parent = clientsChip
|
|
1493
|
+
|
|
1494
|
+
local buttons = Instance.new("Frame")
|
|
1495
|
+
buttons.AnchorPoint = Vector2.new(1, 0.5)
|
|
1496
|
+
buttons.Position = UDim2.new(1, 0, 0.5, 0)
|
|
1497
|
+
buttons.Size = UDim2.new(0, 244, 0, 20)
|
|
1498
|
+
buttons.BackgroundTransparency = 1
|
|
1499
|
+
buttons.Parent = header
|
|
1500
|
+
|
|
1501
|
+
local buttonLayout = Instance.new("UIListLayout")
|
|
1502
|
+
buttonLayout.FillDirection = Enum.FillDirection.Horizontal
|
|
1503
|
+
buttonLayout.HorizontalAlignment = Enum.HorizontalAlignment.Right
|
|
1504
|
+
buttonLayout.VerticalAlignment = Enum.VerticalAlignment.Center
|
|
1505
|
+
buttonLayout.Padding = UDim.new(0, 6)
|
|
1506
|
+
buttonLayout.SortOrder = Enum.SortOrder.LayoutOrder
|
|
1507
|
+
buttonLayout.Parent = buttons
|
|
1508
|
+
|
|
1509
|
+
local visualsButton = makeButton(buttons, "visuals", 1)
|
|
1510
|
+
makeButton(buttons, "reconnect", 2).MouseButton1Click:Connect(handlers.onReconnect)
|
|
1511
|
+
makeButton(buttons, "clear", 3).MouseButton1Click:Connect(handlers.onClear)
|
|
1512
|
+
|
|
1513
|
+
-- Accent rule under the header. One hairline in the signature violet is what
|
|
1514
|
+
-- separates this from every other grey dock in Studio.
|
|
1515
|
+
local rule = Instance.new("Frame")
|
|
1516
|
+
rule.Position = UDim2.new(0, 0, 0, 32)
|
|
1517
|
+
rule.Size = UDim2.new(1, 0, 0, 1)
|
|
1518
|
+
themed(rule, "BackgroundColor3", "rule")
|
|
1519
|
+
rule.BorderSizePixel = 0
|
|
1520
|
+
rule.Parent = root
|
|
1521
|
+
|
|
1522
|
+
local ruleFade = Instance.new("UIGradient")
|
|
1523
|
+
ruleFade.Color = ColorSequence.new(PALETTE.rule)
|
|
1524
|
+
state.ruleFade = ruleFade
|
|
1525
|
+
ruleFade.Transparency = NumberSequence.new({
|
|
1526
|
+
NumberSequenceKeypoint.new(0, 0.15),
|
|
1527
|
+
NumberSequenceKeypoint.new(1, 0.85),
|
|
1528
|
+
})
|
|
1529
|
+
ruleFade.Parent = rule
|
|
1530
|
+
|
|
1531
|
+
-- Log ------------------------------------------------------------------
|
|
1532
|
+
local scroller = Instance.new("ScrollingFrame")
|
|
1533
|
+
scroller.Position = UDim2.new(0, 0, 0, 33)
|
|
1534
|
+
scroller.Size = UDim2.new(1, 0, 1, -55)
|
|
1535
|
+
scroller.BackgroundTransparency = 1
|
|
1536
|
+
scroller.BorderSizePixel = 0
|
|
1537
|
+
scroller.ScrollBarThickness = 5
|
|
1538
|
+
themed(scroller, "ScrollBarImageColor3", "violet")
|
|
1539
|
+
scroller.ScrollBarImageTransparency = 0.5
|
|
1540
|
+
scroller.CanvasSize = UDim2.new()
|
|
1541
|
+
scroller.AutomaticCanvasSize = Enum.AutomaticSize.Y
|
|
1542
|
+
scroller.ScrollingDirection = Enum.ScrollingDirection.Y
|
|
1543
|
+
scroller.Parent = root
|
|
1544
|
+
state.scroller = scroller
|
|
1545
|
+
|
|
1546
|
+
local logPadding = Instance.new("UIPadding")
|
|
1547
|
+
logPadding.PaddingTop = UDim.new(0, 8)
|
|
1548
|
+
logPadding.PaddingBottom = UDim.new(0, 8)
|
|
1549
|
+
logPadding.PaddingLeft = UDim.new(0, 12)
|
|
1550
|
+
logPadding.PaddingRight = UDim.new(0, 12)
|
|
1551
|
+
logPadding.Parent = scroller
|
|
1552
|
+
|
|
1553
|
+
local label = Instance.new("TextLabel")
|
|
1554
|
+
label.Size = UDim2.new(1, 0, 0, 0)
|
|
1555
|
+
label.AutomaticSize = Enum.AutomaticSize.Y
|
|
1556
|
+
label.BackgroundTransparency = 1
|
|
1557
|
+
label.Font = Enum.Font.Code
|
|
1558
|
+
label.TextSize = 12
|
|
1559
|
+
label.LineHeight = 1.25
|
|
1560
|
+
themed(label, "TextColor3", "text")
|
|
1561
|
+
label.RichText = true
|
|
1562
|
+
label.TextWrapped = true
|
|
1563
|
+
label.TextXAlignment = Enum.TextXAlignment.Left
|
|
1564
|
+
label.TextYAlignment = Enum.TextYAlignment.Top
|
|
1565
|
+
label.Text = ""
|
|
1566
|
+
label.Parent = scroller
|
|
1567
|
+
state.label = label
|
|
1568
|
+
|
|
1569
|
+
--[[
|
|
1570
|
+
The clear wipe's overlay, built once and hidden.
|
|
1571
|
+
|
|
1572
|
+
Four nested pieces, and each one earns its place. `crt` is unclipped and
|
|
1573
|
+
covers the log region, so the line can stay full width while the screen
|
|
1574
|
+
inside it closes. `screen` is the clip that actually collapses, anchored
|
|
1575
|
+
to its own middle so it shuts toward the centre rather than rolling up
|
|
1576
|
+
from the top. `window` is a fixed-height clip that does not move with it,
|
|
1577
|
+
which is what holds the copied text still while the screen closes over
|
|
1578
|
+
it. `ghost` is the copy.
|
|
1579
|
+
]]
|
|
1580
|
+
local crt = Instance.new("Frame")
|
|
1581
|
+
crt.Name = "ClearWipe"
|
|
1582
|
+
crt.BackgroundTransparency = 1
|
|
1583
|
+
crt.BorderSizePixel = 0
|
|
1584
|
+
crt.Visible = false
|
|
1585
|
+
crt.ZIndex = Z_CRT
|
|
1586
|
+
crt.Parent = root
|
|
1587
|
+
state.crt = crt
|
|
1588
|
+
|
|
1589
|
+
local crtScreen = Instance.new("Frame")
|
|
1590
|
+
crtScreen.Name = "Screen"
|
|
1591
|
+
crtScreen.AnchorPoint = Vector2.new(0.5, 0.5)
|
|
1592
|
+
crtScreen.Position = UDim2.fromScale(0.5, 0.5)
|
|
1593
|
+
crtScreen.Size = UDim2.new(1, 0, 1, 0)
|
|
1594
|
+
themed(crtScreen, "BackgroundColor3", "background")
|
|
1595
|
+
crtScreen.BorderSizePixel = 0
|
|
1596
|
+
crtScreen.ClipsDescendants = true
|
|
1597
|
+
crtScreen.ZIndex = Z_CRT
|
|
1598
|
+
crtScreen.Parent = crt
|
|
1599
|
+
state.crtScreen = crtScreen
|
|
1600
|
+
|
|
1601
|
+
local crtWindow = Instance.new("Frame")
|
|
1602
|
+
crtWindow.Name = "Window"
|
|
1603
|
+
crtWindow.AnchorPoint = Vector2.new(0.5, 0.5)
|
|
1604
|
+
crtWindow.Position = UDim2.fromScale(0.5, 0.5)
|
|
1605
|
+
crtWindow.Size = UDim2.new(1, 0, 1, 0)
|
|
1606
|
+
crtWindow.BackgroundTransparency = 1
|
|
1607
|
+
crtWindow.BorderSizePixel = 0
|
|
1608
|
+
crtWindow.ClipsDescendants = true
|
|
1609
|
+
crtWindow.ZIndex = Z_CRT_TEXT
|
|
1610
|
+
crtWindow.Parent = crtScreen
|
|
1611
|
+
state.crtWindow = crtWindow
|
|
1612
|
+
|
|
1613
|
+
-- Every text property the log's own label has, because the copy has to be
|
|
1614
|
+
-- indistinguishable from it for the frame before it starts moving.
|
|
1615
|
+
local crtGhost = Instance.new("TextLabel")
|
|
1616
|
+
crtGhost.Name = "Ghost"
|
|
1617
|
+
crtGhost.BackgroundTransparency = 1
|
|
1618
|
+
crtGhost.Font = Enum.Font.Code
|
|
1619
|
+
crtGhost.TextSize = 12
|
|
1620
|
+
crtGhost.LineHeight = 1.25
|
|
1621
|
+
themed(crtGhost, "TextColor3", "text")
|
|
1622
|
+
crtGhost.RichText = true
|
|
1623
|
+
crtGhost.TextWrapped = true
|
|
1624
|
+
crtGhost.AutomaticSize = Enum.AutomaticSize.Y
|
|
1625
|
+
crtGhost.Size = UDim2.new(1, -24, 0, 0)
|
|
1626
|
+
crtGhost.TextXAlignment = Enum.TextXAlignment.Left
|
|
1627
|
+
crtGhost.TextYAlignment = Enum.TextYAlignment.Top
|
|
1628
|
+
crtGhost.Text = ""
|
|
1629
|
+
crtGhost.ZIndex = Z_CRT_TEXT
|
|
1630
|
+
crtGhost.Parent = crtWindow
|
|
1631
|
+
state.crtGhost = crtGhost
|
|
1632
|
+
|
|
1633
|
+
-- The line the picture discharges into. Outside `screen`, so it keeps its
|
|
1634
|
+
-- width while the screen closes to nothing behind it.
|
|
1635
|
+
local crtLine = Instance.new("Frame")
|
|
1636
|
+
crtLine.Name = "Line"
|
|
1637
|
+
crtLine.AnchorPoint = Vector2.new(0.5, 0.5)
|
|
1638
|
+
crtLine.Position = UDim2.fromScale(0.5, 0.5)
|
|
1639
|
+
crtLine.Size = UDim2.new(1, 0, 0, 2)
|
|
1640
|
+
themed(crtLine, "BackgroundColor3", "text")
|
|
1641
|
+
crtLine.BorderSizePixel = 0
|
|
1642
|
+
crtLine.Visible = false
|
|
1643
|
+
crtLine.ZIndex = Z_CRT_LINE
|
|
1644
|
+
crtLine.Parent = crt
|
|
1645
|
+
state.crtLine = crtLine
|
|
1646
|
+
|
|
1647
|
+
-- Status bar ------------------------------------------------------------
|
|
1648
|
+
local footer = Instance.new("Frame")
|
|
1649
|
+
footer.AnchorPoint = Vector2.new(0, 1)
|
|
1650
|
+
footer.Position = UDim2.fromScale(0, 1)
|
|
1651
|
+
footer.Size = UDim2.new(1, 0, 0, FOOTER_HEIGHT)
|
|
1652
|
+
themed(footer, "BackgroundColor3", "surface")
|
|
1653
|
+
footer.BorderSizePixel = 0
|
|
1654
|
+
footer.Parent = root
|
|
1655
|
+
|
|
1656
|
+
local footerPadding = Instance.new("UIPadding")
|
|
1657
|
+
footerPadding.PaddingLeft = UDim.new(0, 12)
|
|
1658
|
+
footerPadding.PaddingRight = UDim.new(0, 12)
|
|
1659
|
+
footerPadding.Parent = footer
|
|
1660
|
+
|
|
1661
|
+
local prompt = Instance.new("TextLabel")
|
|
1662
|
+
prompt.Text = "rbx\u{00B7}studio"
|
|
1663
|
+
prompt.Font = Enum.Font.Code
|
|
1664
|
+
prompt.TextSize = 11
|
|
1665
|
+
themed(prompt, "TextColor3", "violet")
|
|
1666
|
+
prompt.TextXAlignment = Enum.TextXAlignment.Left
|
|
1667
|
+
prompt.BackgroundTransparency = 1
|
|
1668
|
+
prompt.Size = UDim2.new(0, 70, 1, 0)
|
|
1669
|
+
prompt.Parent = footer
|
|
1670
|
+
|
|
1671
|
+
--[[
|
|
1672
|
+
No cursor here.
|
|
1673
|
+
|
|
1674
|
+
A blinking block after a prompt is the universal sign that something is
|
|
1675
|
+
waiting to be typed into, and nothing in this panel accepts input. It
|
|
1676
|
+
was there to prove the widget was live, which the activity band now does
|
|
1677
|
+
honestly, by moving only when there is something to move about.
|
|
1678
|
+
]]
|
|
1679
|
+
|
|
1680
|
+
local counters = Instance.new("TextLabel")
|
|
1681
|
+
counters.Text = "idle"
|
|
1682
|
+
counters.Font = Enum.Font.Code
|
|
1683
|
+
counters.TextSize = 11
|
|
1684
|
+
themed(counters, "TextColor3", "dim")
|
|
1685
|
+
counters.TextXAlignment = Enum.TextXAlignment.Right
|
|
1686
|
+
counters.BackgroundTransparency = 1
|
|
1687
|
+
counters.AnchorPoint = Vector2.new(1, 0)
|
|
1688
|
+
counters.Position = UDim2.fromScale(1, 0)
|
|
1689
|
+
counters.Size = UDim2.new(1, -90, 1, 0)
|
|
1690
|
+
counters.Parent = footer
|
|
1691
|
+
state.countersText = counters
|
|
1692
|
+
|
|
1693
|
+
--[[
|
|
1694
|
+
The band sits above the log and pushes it down, rather than over it.
|
|
1695
|
+
|
|
1696
|
+
The first version covered the log and was slightly transparent, so the
|
|
1697
|
+
thing you actually read was both hidden and softened. Decoration that
|
|
1698
|
+
costs legibility is a bad trade however good it looks, and this is a
|
|
1699
|
+
console before it is anything else.
|
|
1700
|
+
]]
|
|
1701
|
+
--[[
|
|
1702
|
+
The prompt sits at the BOTTOM, above the status bar.
|
|
1703
|
+
|
|
1704
|
+
It started under the header, which put it as far from the newest log line
|
|
1705
|
+
as the panel allows: you typed at the top, the answer arrived at the
|
|
1706
|
+
bottom, and reading your own session meant crossing the whole widget
|
|
1707
|
+
twice. Every terminal and every chat window puts the input against the
|
|
1708
|
+
tail of the output for that reason, and this is both of those things.
|
|
1709
|
+
|
|
1710
|
+
It is not inside the activity band either, though the band's caption line
|
|
1711
|
+
reads like a prompt already. The band is toggleable, and hiding the only
|
|
1712
|
+
way to type into the panel behind a decoration switch is a trap.
|
|
1713
|
+
]]
|
|
1714
|
+
Prompt.mount(root, {
|
|
1715
|
+
submit = handlers.onSubmit,
|
|
1716
|
+
complete = handlers.onComplete,
|
|
1717
|
+
suggest = handlers.onSuggest,
|
|
1718
|
+
})
|
|
1719
|
+
local promptRow = root:FindFirstChild("Prompt") :: Frame
|
|
1720
|
+
promptRow.AnchorPoint = Vector2.new(0, 1)
|
|
1721
|
+
promptRow.Position = UDim2.new(0, 0, 1, -FOOTER_HEIGHT)
|
|
1722
|
+
promptRow.Size = UDim2.new(1, 0, 0, Prompt.HEIGHT)
|
|
1723
|
+
|
|
1724
|
+
Visuals.mount(root)
|
|
1725
|
+
local band = root:FindFirstChild("ActivityBand") :: Frame
|
|
1726
|
+
band.Position = UDim2.new(0, 0, 0, 33)
|
|
1727
|
+
band.Size = UDim2.new(1, 0, 0, Visuals.BAND_HEIGHT)
|
|
1728
|
+
|
|
1729
|
+
-- The log's top edge follows the band, so turning it on never hides a line.
|
|
1730
|
+
-- Its bottom edge clears the prompt and the status bar, which do not move.
|
|
1731
|
+
local function layoutLog()
|
|
1732
|
+
local top = 33 + (if Visuals.isVisible() then Visuals.BAND_HEIGHT else 0)
|
|
1733
|
+
scroller.Position = UDim2.new(0, 0, 0, top)
|
|
1734
|
+
scroller.Size = UDim2.new(1, 0, 1, -(top + FOOTER_HEIGHT + Prompt.HEIGHT))
|
|
1735
|
+
end
|
|
1736
|
+
state.relayout = layoutLog
|
|
1737
|
+
|
|
1738
|
+
-- On by default. It is the part that says the session is alive, and a signal
|
|
1739
|
+
-- nobody discovers is not a signal; anyone who wants the extra 44px back can
|
|
1740
|
+
-- turn it off in one click.
|
|
1741
|
+
Visuals.setVisible(true)
|
|
1742
|
+
visualsButton.Text = utf8.char(0x25C6) .. " visuals"
|
|
1743
|
+
Visuals.setIdle()
|
|
1744
|
+
layoutLog()
|
|
1745
|
+
|
|
1746
|
+
state.visualsButton = visualsButton
|
|
1747
|
+
visualsButton.MouseButton1Click:Connect(function()
|
|
1748
|
+
Console.toggleVisuals()
|
|
1749
|
+
end)
|
|
1750
|
+
|
|
1751
|
+
--[[
|
|
1752
|
+
The preset drawer, mounted last so its tab sits over everything.
|
|
1753
|
+
|
|
1754
|
+
It has to be the last child of `root` as well as the highest ZIndex: the
|
|
1755
|
+
band, the log and the footer are all built before it and a drawer that
|
|
1756
|
+
opens behind the log is a drawer nobody can click.
|
|
1757
|
+
]]
|
|
1758
|
+
ThemePicker.mount(root, function(id)
|
|
1759
|
+
Console.applyTheme()
|
|
1760
|
+
ThemePicker.applyTheme()
|
|
1761
|
+
layoutLog()
|
|
1762
|
+
handlers.onTheme(id)
|
|
1763
|
+
end)
|
|
1764
|
+
|
|
1765
|
+
-- Treat "scrolled away from the bottom" as the user reading history, and
|
|
1766
|
+
-- stop yanking the view down under them until they scroll back.
|
|
1767
|
+
scroller:GetPropertyChangedSignal("CanvasPosition"):Connect(function()
|
|
1768
|
+
local maxScroll = math.max(0, scroller.AbsoluteCanvasSize.Y - scroller.AbsoluteWindowSize.Y)
|
|
1769
|
+
state.pinned = maxScroll - scroller.CanvasPosition.Y < 24
|
|
1770
|
+
end)
|
|
1771
|
+
end
|
|
1772
|
+
|
|
1773
|
+
--[[
|
|
1774
|
+
Shows or hides the activity band, from the button or from `visuals`.
|
|
1775
|
+
|
|
1776
|
+
One implementation for two callers. The button used to own this outright,
|
|
1777
|
+
which meant the command could either duplicate the three lines it takes --
|
|
1778
|
+
and go stale the day one of them changes -- or leave the button's own label
|
|
1779
|
+
saying the opposite of what the panel was doing.
|
|
1780
|
+
]]
|
|
1781
|
+
function Console.toggleVisuals(): boolean
|
|
1782
|
+
local wanted = not Visuals.isVisible()
|
|
1783
|
+
Visuals.setVisible(wanted)
|
|
1784
|
+
local relayout = state.relayout
|
|
1785
|
+
if relayout then
|
|
1786
|
+
relayout()
|
|
1787
|
+
end
|
|
1788
|
+
local button = state.visualsButton
|
|
1789
|
+
if button then
|
|
1790
|
+
-- The button reports the state it is in, not the state it would move to.
|
|
1791
|
+
-- A toggle that reads as an instruction is ambiguous the moment you look
|
|
1792
|
+
-- away and back.
|
|
1793
|
+
button.Text = if wanted then utf8.char(0x25C6) .. " visuals" else "visuals"
|
|
1794
|
+
end
|
|
1795
|
+
return wanted
|
|
1796
|
+
end
|
|
1797
|
+
|
|
1798
|
+
--[[
|
|
1799
|
+
Narrows the log to one level, or opens it back up when given nothing.
|
|
1800
|
+
|
|
1801
|
+
Nothing is discarded. The records are all still there and a repaint brings
|
|
1802
|
+
them back, which is the only behaviour that makes a filter safe to reach for
|
|
1803
|
+
mid-session: the alternative is a user hiding the very row they were about
|
|
1804
|
+
to read and having no way back to it.
|
|
1805
|
+
]]
|
|
1806
|
+
function Console.setFilter(levels: { string }?)
|
|
1807
|
+
if levels == nil or #levels == 0 then
|
|
1808
|
+
state.filter = nil
|
|
1809
|
+
else
|
|
1810
|
+
local wanted: { [string]: boolean } = {}
|
|
1811
|
+
for _, level in levels do
|
|
1812
|
+
wanted[level] = true
|
|
1813
|
+
end
|
|
1814
|
+
state.filter = wanted
|
|
1815
|
+
end
|
|
1816
|
+
redraw()
|
|
1817
|
+
end
|
|
1818
|
+
|
|
1819
|
+
--[[
|
|
1820
|
+
The log as plain text, timestamps and all, with the markup taken back out.
|
|
1821
|
+
|
|
1822
|
+
`copy` needs the words rather than the rendering, and rebuilding them from
|
|
1823
|
+
the records is the only honest source: the rendered label is RichText, and a
|
|
1824
|
+
stripped copy of it would carry whatever the escaping did to the user's own
|
|
1825
|
+
angle brackets.
|
|
1826
|
+
|
|
1827
|
+
Every line is commented out, because the only place Studio will show this to
|
|
1828
|
+
you is a script editor -- and a log pasted in as code is a wall of red
|
|
1829
|
+
underlines with a syntax error on the first word. Commented, it opens as
|
|
1830
|
+
something you read, which is what it is.
|
|
1831
|
+
]]
|
|
1832
|
+
function Console.plainText(): string
|
|
1833
|
+
local lines: { string } = { "-- rbx-studio console log" }
|
|
1834
|
+
for _, record in state.records do
|
|
1835
|
+
local sigil = (LEVELS[record.level] or LEVELS.info).sigil
|
|
1836
|
+
local detail = if record.detail ~= nil then " " .. record.detail else ""
|
|
1837
|
+
table.insert(
|
|
1838
|
+
lines,
|
|
1839
|
+
string.format("-- %s %s %s%s", record.stamp, sigil, record.message, detail)
|
|
1840
|
+
)
|
|
1841
|
+
end
|
|
1842
|
+
return table.concat(lines, "\n")
|
|
1843
|
+
end
|
|
1844
|
+
|
|
1845
|
+
function Console.focusPrompt()
|
|
1846
|
+
Prompt.focus()
|
|
1847
|
+
end
|
|
1848
|
+
|
|
1849
|
+
--[[
|
|
1850
|
+
Reports that an agent started from the prompt is working.
|
|
1851
|
+
|
|
1852
|
+
Two instruments, one fact. The caret says it to whoever is about to type;
|
|
1853
|
+
the cell says it to whoever is glancing at the panel from across the screen.
|
|
1854
|
+
Told in one place because they must never disagree -- a caret that has gone
|
|
1855
|
+
back to violet beside a cell still pulsing is a panel arguing with itself.
|
|
1856
|
+
]]
|
|
1857
|
+
--[[
|
|
1858
|
+
Passes Studio's current selection to the prompt row.
|
|
1859
|
+
|
|
1860
|
+
A pass-through, and worth having anyway: `init.server` owns the watcher and
|
|
1861
|
+
the console owns the widget, and letting the watcher reach into `Prompt`
|
|
1862
|
+
directly would give the panel two owners.
|
|
1863
|
+
]]
|
|
1864
|
+
function Console.setSelection(text: string)
|
|
1865
|
+
Prompt.setSelection(text)
|
|
1866
|
+
end
|
|
1867
|
+
|
|
1868
|
+
function Console.setPromptBusy(busy: boolean)
|
|
1869
|
+
Prompt.setBusy(busy)
|
|
1870
|
+
Visuals.setThinking(busy)
|
|
1871
|
+
end
|
|
1872
|
+
|
|
1873
|
+
--[[
|
|
1874
|
+
Switches the whole panel to the active preset.
|
|
1875
|
+
|
|
1876
|
+
Three things have to move together or the switch looks broken: the palette
|
|
1877
|
+
this file reads, the Instances that copied a colour out of it, and the log,
|
|
1878
|
+
whose rows are re-rendered from records rather than recoloured in place. The
|
|
1879
|
+
band goes last because remounting a preset is the expensive part and there
|
|
1880
|
+
is no reason to make the text wait for it.
|
|
1881
|
+
]]
|
|
1882
|
+
function Console.applyTheme()
|
|
1883
|
+
PALETTE = Themes.palette()
|
|
1884
|
+
|
|
1885
|
+
for _, role in roles do
|
|
1886
|
+
-- A widget can outlive its registration if the panel is rebuilt, and
|
|
1887
|
+
-- writing to a destroyed Instance throws.
|
|
1888
|
+
if role.instance.Parent ~= nil or role.instance:IsA("UIStroke") then
|
|
1889
|
+
pcall(function()
|
|
1890
|
+
(role.instance :: any)[role.property] = PALETTE[role.key]
|
|
1891
|
+
end)
|
|
1892
|
+
end
|
|
1893
|
+
end
|
|
1894
|
+
|
|
1895
|
+
local ruleFade = state.ruleFade
|
|
1896
|
+
if ruleFade then
|
|
1897
|
+
ruleFade.Color = ColorSequence.new(PALETTE.rule)
|
|
1898
|
+
end
|
|
1899
|
+
|
|
1900
|
+
refreshCounters()
|
|
1901
|
+
-- Replayed rather than recoloured: the status light's colour is a fact about
|
|
1902
|
+
-- the connection, and the only thing that knows it is `setStatus`.
|
|
1903
|
+
Console.setStatus(state.status, state.statusMeta)
|
|
1904
|
+
redraw()
|
|
1905
|
+
Prompt.applyTheme()
|
|
1906
|
+
Visuals.applyTheme()
|
|
1907
|
+
end
|
|
1908
|
+
|
|
1909
|
+
return Console
|