@el4cteo/rbx-studio-mcp 0.3.6 → 0.3.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.
@@ -1,1041 +1,1101 @@
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 ThemePicker = require(script.Parent.ThemePicker)
22
- local Themes = require(script.Parent.Themes)
23
- local Visuals = require(script.Parent.Visuals)
24
-
25
- local Console = {}
26
-
27
- --[[
28
- Ring buffer bound, counted in logged rows rather than in rendered lines.
29
-
30
- It used to bound the rendered lines, which meant a burst of failures -- each
31
- writing a message plus two wrapped lines of explanation -- evicted three
32
- times as much history as a burst of successes. Bounding the records makes
33
- "the last three hundred things that happened" mean what it says.
34
- ]]
35
- local MAX_RECORDS = 300
36
-
37
- --[[
38
- The active preset's colours, rebound rather than re-read.
39
-
40
- Every use site in this file is a plain `PALETTE.dim`, and there are about
41
- forty of them. Turning each into a function call to satisfy theming would
42
- have been forty edits to working code for no gain: the palette changes only
43
- when the user picks a different preset, so it is simply reassigned there and
44
- the reads stay as they were.
45
-
46
- The catch is that anything which COPIES a colour out of here keeps the old
47
- one. Instances built in `mount` do exactly that, which is why `applyTheme`
48
- has to walk them by hand.
49
- ]]
50
- local PALETTE: Themes.Palette = Themes.palette()
51
-
52
- --[[
53
- An optional listener on everything this console is told.
54
-
55
- Exists for `Mirror`, which relays a playtest server's activity to the client
56
- view that cannot reach the bridge itself. Kept as one hook rather than as
57
- calls sprinkled through the file so there is a single place where "what the
58
- console was told" is defined, and so the client half -- which sets no
59
- observer -- cannot echo what it replays back into the channel.
60
- ]]
61
- local observer: ((string, { any }) -> ())? = nil
62
-
63
- function Console.setObserver(fn: ((string, { any }) -> ())?)
64
- observer = fn
65
- end
66
-
67
- local function notify(kind: string, arguments: { any })
68
- local listener = observer
69
- if listener ~= nil then
70
- task.spawn(listener, kind, arguments)
71
- end
72
- end
73
-
74
- --[[
75
- Set on the console that is REPLAYING someone else's events.
76
-
77
- A replayed call must not grow its own consequences. `recordCall` schedules
78
- the "agent idle" summary, so a mirrored session scheduled one of its own and
79
- then received the original over the channel too -- the same line twice, a
80
- few pixels apart, which is exactly the sort of thing that makes a panel look
81
- broken. The origin is the only session entitled to decide the burst ended.
82
- ]]
83
- local mirroring = false
84
-
85
- function Console.setMirroring(value: boolean)
86
- mirroring = value
87
- end
88
-
89
- -- Sigil plus colour per level. The sigil is what makes the log scannable at a
90
- -- glance; colour alone fails for anyone who cannot separate red from green.
91
- -- Named by palette key rather than by colour, because this table is built once
92
- -- at load and a colour captured then belongs to whichever preset happened to
93
- -- be active at the time.
94
- local LEVELS: { [string]: { sigil: string, key: string } } = {
95
- ok = { sigil = "\u{25C6}", key = "green" },
96
- error = { sigil = "\u{2715}", key = "red" },
97
- warn = { sigil = "\u{25B2}", key = "amber" },
98
- info = { sigil = "\u{25C6}", key = "violet" },
99
- dim = { sigil = "\u{00B7}", key = "dim" },
100
- call = { sigil = "\u{25B8}", key = "cyan" },
101
- reply = { sigil = "\u{25C2}", key = "violet" },
102
- }
103
-
104
- export type Level = "ok" | "error" | "warn" | "info" | "dim" | "call" | "reply"
105
-
106
- --[[
107
- Column the right-aligned detail is padded out to, in characters.
108
-
109
- Sized for the widget's default width rather than for the longest message: at
110
- 12pt Code with a timestamp ahead of it, anything past this wraps, and a
111
- wrapped line breaks the very column the padding exists to produce. Messages
112
- are cut to fit instead.
113
- ]]
114
- local DETAIL_COLUMN = 52
115
-
116
- --[[
117
- How far past the column an inline detail may run before it is moved below the
118
- message instead. A latency fits; a sentence does not.
119
- ]]
120
- local INLINE_DETAIL = 14
121
-
122
- -- Continuation lines sit under the message text, clear of the timestamp and the
123
- -- sigil, so a wrapped explanation reads as belonging to the line above it.
124
- local DETAIL_INDENT = string.rep(" ", 11)
125
-
126
- -- Characters per continuation line. Fixed rather than measured from the widget:
127
- -- the console is monospaced, and a width that changes as the user drags the
128
- -- panel would rewrap history every frame.
129
- local DETAIL_WRAP = 74
130
-
131
- --[[
132
- Breaks a long detail into lines at word boundaries.
133
-
134
- Roblox's own TextWrapped would do this, and would wrap to column 0 -- there
135
- is no hanging indent for a TextLabel -- which is the ragged shape this
136
- replaces. Wrapping here means every continuation line can carry the indent.
137
- ]]
138
- local function wrapDetail(detail: string): { string }
139
- local lines: { string } = {}
140
- local current = ""
141
- for word in string.gmatch(detail, "%S+") do
142
- local candidate = if current == "" then word else current .. " " .. word
143
- if (utf8.len(candidate) or #candidate) > DETAIL_WRAP and current ~= "" then
144
- table.insert(lines, current)
145
- current = word
146
- else
147
- current = candidate
148
- end
149
- end
150
- if current ~= "" then
151
- table.insert(lines, current)
152
- end
153
- return lines
154
- end
155
-
156
- --[[
157
- One logged row, before it is coloured.
158
-
159
- Holding the parts rather than the finished string is what lets a theme
160
- switch repaint history: see `renderRecord`.
161
- ]]
162
- type Record = {
163
- level: Level,
164
- message: string,
165
- detail: string?,
166
- stamp: string,
167
- }
168
-
169
- type State = {
170
- records: { Record },
171
- -- The last status reported, replayed after a theme switch. Without it the
172
- -- header has no way to know what colour it should be wearing.
173
- status: string,
174
- statusMeta: string,
175
- -- The accent rule's gradient. A ColorSequence rather than a Color3, so it
176
- -- cannot ride the generic role list.
177
- ruleFade: UIGradient?,
178
- --[[
179
- Rows written, as opposed to lines rendered.
180
-
181
- `lines` also holds the indented continuations a long detail wraps onto,
182
- so counting it would report a number nobody wrote -- "cleared 41 logs"
183
- for twelve commands. This counts calls to `log`, which is what a person
184
- means by a log line.
185
- ]]
186
- entries: number,
187
- label: TextLabel?,
188
- scroller: ScrollingFrame?,
189
- statusDot: TextLabel?,
190
- statusText: TextLabel?,
191
- metaText: TextLabel?,
192
- countersText: TextLabel?,
193
- clientsChip: TextLabel?,
194
- pinned: boolean,
195
- calls: number,
196
- errors: number,
197
- totalMs: number,
198
- -- The command in flight, shown in the footer while it runs. One value that
199
- -- is replaced, never a list that grows.
200
- running: string?,
201
- --[[
202
- Bumped by every call, so a pending idle timer can tell whether it is still
203
- the most recent one. Cheaper and less error-prone than cancelling timers:
204
- `task.delay` has no handle to cancel, and a stale closure that checks a
205
- counter simply does nothing.
206
- ]]
207
- generation: number,
208
- -- How many MCP clients share this bridge. Only ever displayed above one.
209
- clients: number,
210
- -- Set while a repaint is already scheduled for the end of this frame.
211
- dirty: boolean,
212
- }
213
-
214
- local state: State = {
215
- records = {},
216
- ruleFade = nil,
217
- status = "disconnected",
218
- statusMeta = "",
219
- entries = 0,
220
- label = nil,
221
- scroller = nil,
222
- statusDot = nil,
223
- statusText = nil,
224
- metaText = nil,
225
- countersText = nil,
226
- clientsChip = nil,
227
- pinned = true,
228
- calls = 0,
229
- errors = 0,
230
- totalMs = 0,
231
- running = nil,
232
- generation = 0,
233
- clients = 1,
234
- dirty = false,
235
- }
236
-
237
- -- How long the session must be silent before the console says so. Long enough
238
- -- that an agent pausing to think is not announced as having stopped.
239
- local QUIET_SECONDS = 20
240
-
241
- local function hex(color: Color3): string
242
- return string.format(
243
- "#%02X%02X%02X",
244
- math.floor(color.R * 255 + 0.5),
245
- math.floor(color.G * 255 + 0.5),
246
- math.floor(color.B * 255 + 0.5)
247
- )
248
- end
249
-
250
- -- RichText is markup, so anything user- or engine-supplied has to be escaped or
251
- -- a stray `<` in an error message silently eats the rest of the line.
252
- local function escape(value: string): string
253
- local escaped = string.gsub(value, "&", "&amp;")
254
- escaped = string.gsub(escaped, "<", "&lt;")
255
- escaped = string.gsub(escaped, ">", "&gt;")
256
- return escaped
257
- end
258
-
259
- local function span(color: Color3, value: string): string
260
- return string.format('<font color="%s">%s</font>', hex(color), escape(value))
261
- end
262
-
263
- --[[
264
- Turns one record into the lines it occupies.
265
-
266
- Rendering is deferred to here rather than done when the row is logged, which
267
- is the change that made theming possible at all: a line that has already had
268
- `#A78BFA` baked into it cannot be recoloured, so switching preset used to
269
- leave the whole session's history in the previous theme's palette while new
270
- rows arrived in the new one. Now nothing is coloured until it is painted.
271
- ]]
272
- local function renderRecord(record: Record): { string }
273
- local spec = LEVELS[record.level] or LEVELS.info
274
- local detail = record.detail
275
-
276
- --[[
277
- Measured in characters, not bytes.
278
-
279
- `#body` counts bytes, and every sigil in this console is a 3-byte UTF-8
280
- glyph, so it over-counted each line by two and pushed the latency column
281
- two places left on exactly the lines that had a latency. The column was
282
- never straight, and the cause was invisible until two different sigils
283
- sat next to each other.
284
- ]]
285
- --[[
286
- Only a detail that will actually sit in the column costs the message any
287
- of its width. Cutting the message to make room for a detail that then
288
- goes on its own line below would shorten it for nothing.
289
- ]]
290
- local detailWidth = if detail then (utf8.len(detail) or #detail) else 0
291
- local inlineDetail = detail ~= nil
292
- and (utf8.len(record.message) or #record.message) + detailWidth + 3
293
- <= DETAIL_COLUMN + INLINE_DETAIL
294
-
295
- local trimmed = record.message
296
- local budget = DETAIL_COLUMN - 3
297
- if inlineDetail and (utf8.len(trimmed) or #trimmed) > budget then
298
- -- Cut rather than wrap. A wrapped line destroys the alignment and
299
- -- carries the detail off the end of the visible width as well.
300
- local offset = utf8.offset(trimmed, budget) or budget
301
- trimmed = string.sub(trimmed, 1, offset - 1) .. utf8.char(0x2026)
302
- end
303
-
304
- local body = string.format("%s %s", spec.sigil, trimmed)
305
- local bodyWidth = utf8.len(body) or #body
306
- local lines = { span(PALETTE.dim, record.stamp) .. " " .. span(PALETTE[spec.key], body) }
307
-
308
- --[[
309
- Short details ride the right-hand column; long ones get their own lines.
310
-
311
- The column exists for latencies -- "12ms" stacking into something
312
- readable -- and it was applied to every detail regardless of length. A
313
- sentence of prose therefore started at column 52, ran off the widget, and
314
- wrapped back to column 0, so the explanation of a standby session came
315
- out as a ragged block that began in the middle of the screen and ended at
316
- the left edge. Anything that will not fit beside the message is better
317
- off beneath it.
318
- ]]
319
- if detail then
320
- if inlineDetail then
321
- local padding = math.max(1, DETAIL_COLUMN - bodyWidth)
322
- lines[1] ..= span(PALETTE.dim, string.rep(" ", padding) .. detail)
323
- else
324
- for _, wrapped in wrapDetail(detail) do
325
- table.insert(lines, span(PALETTE.dim, DETAIL_INDENT .. wrapped))
326
- end
327
- end
328
- end
329
- return lines
330
- end
331
-
332
- local function paint()
333
- state.dirty = false
334
- local label = state.label
335
- if not label then
336
- return
337
- end
338
-
339
- local lines: { string } = {}
340
- for _, record in state.records do
341
- for _, line in renderRecord(record) do
342
- table.insert(lines, line)
343
- end
344
- end
345
- label.Text = table.concat(lines, "\n")
346
-
347
- -- Only follow the tail when the user has not scrolled up to read history.
348
- local scroller = state.scroller
349
- if scroller and state.pinned then
350
- task.defer(function()
351
- if scroller.Parent then
352
- scroller.CanvasPosition = Vector2.new(0, math.max(0, scroller.AbsoluteCanvasSize.Y))
353
- end
354
- end)
355
- end
356
- end
357
-
358
- --[[
359
- Asks for a repaint, at most one per frame.
360
-
361
- Painting is a concat of up to three hundred strings followed by a RichText
362
- relayout of the whole label, and it used to run once per appended row. A
363
- failed command writes three rows -- the failure, its message, sometimes a
364
- hint -- so a single unlucky call repainted the entire console three times,
365
- and all of that sat in front of the reply on its way back to the agent.
366
- Deferring collapses them into the one paint that was always sufficient.
367
- ]]
368
- local function redraw()
369
- if state.dirty then
370
- return
371
- end
372
- state.dirty = true
373
- task.defer(paint)
374
- end
375
-
376
- local function refreshCounters()
377
- local counters = state.countersText
378
- if not counters then
379
- return
380
- end
381
- --[[
382
- The footer is for totals, and only totals.
383
-
384
- It briefly doubled as the in-flight readout, which meant a running
385
- command overwrote "4 calls 0 errors avg 84ms" with its own name -- the
386
- session statistics disappearing exactly when the session was busiest.
387
- Two live readouts on one small panel is one too many, and the band
388
- already has the better spot for it, right beside the solid.
389
- ]]
390
- counters.TextColor3 = PALETTE.dim
391
- if state.calls == 0 then
392
- counters.Text = "idle"
393
- return
394
- end
395
- counters.Text = string.format(
396
- "%d call%s %d error%s avg %.0fms",
397
- state.calls,
398
- if state.calls == 1 then "" else "s",
399
- state.errors,
400
- if state.errors == 1 then "" else "s",
401
- state.totalMs / state.calls
402
- )
403
- end
404
-
405
- --[[
406
- Appends one line. `detail` is padded to a fixed column and dimmed, so
407
- latencies stack into a readable column instead of trailing each message at a
408
- ragged offset.
409
- ]]
410
- function Console.log(level: Level, message: string, detail: string?)
411
- table.insert(state.records, {
412
- level = level,
413
- message = message,
414
- detail = detail,
415
- -- Stamped when the row happened, not when it is painted. A repaint after
416
- -- a theme switch re-renders every line, and re-reading the clock there
417
- -- would restamp the whole session to the moment the user changed colour.
418
- stamp = os.date("%H:%M:%S") :: string,
419
- })
420
- state.entries += 1
421
- while #state.records > MAX_RECORDS do
422
- table.remove(state.records, 1)
423
- end
424
- redraw()
425
- notify("log", { level, message, detail })
426
- end
427
-
428
- --[[
429
- Announces a command as it starts -- everywhere except the log.
430
-
431
- This used to append a line, and then the reply appended a second one saying
432
- the same thing in a different colour. Every call cost two rows and read as
433
- duplicated output, which is exactly what it was: a cyan "Edit KillBrick"
434
- followed by a violet "Edit KillBrick".
435
-
436
- The log now takes one line per call, written when it finishes and carrying
437
- the latency it took. What is running *right now* belongs in a place that
438
- updates rather than accumulates, so it goes to the footer and the activity
439
- band -- both of which show a single current value and neither of which grows.
440
- ]]
441
- --[[
442
- Colour and pace per kind of work, so the band reads as what is happening.
443
-
444
- Reads are cool and quick because they are constant and harmless; writes are
445
- violet and slower because they change the user's game; running code is green
446
- and heavier still. Urgency drives both the spin rate and how strongly the
447
- colour takes over, so the two never disagree.
448
- ]]
449
- local KIND_LOOK: { [string]: { key: string, urgency: number } } = {
450
- read = { key = "cyan", urgency = 0.3 },
451
- write = { key = "violet", urgency = 0.7 },
452
- run = { key = "green", urgency = 0.9 },
453
- debug = { key = "amber", urgency = 0.5 },
454
- }
455
-
456
- function Console.beginCall(title: string, kind: string)
457
- state.running = title
458
- state.generation += 1
459
- local look = KIND_LOOK[kind] or KIND_LOOK.read
460
- Visuals.setCaption(title)
461
- Visuals.setKind(PALETTE[look.key], look.urgency)
462
- refreshCounters()
463
- notify("beginCall", { title, kind })
464
- end
465
-
466
- --[[
467
- Records one completed command for the session counters. Kept separate from
468
- `log` so callers can log freely without skewing the statistics.
469
- ]]
470
- function Console.recordCall(ok: boolean, milliseconds: number)
471
- state.calls += 1
472
- state.totalMs += milliseconds
473
- if not ok then
474
- state.errors += 1
475
- end
476
- -- Read before it is cleared: the bar wants the same phrase the log row uses,
477
- -- and this is the only place that still has it.
478
- local title = state.running or "call"
479
- state.running = nil
480
- refreshCounters()
481
- -- The footer reports totals when idle, so the band shows what just ran
482
- -- instead of repeating the same word on the same screen.
483
- Visuals.setIdle()
484
-
485
- -- The band plots it: bar height is how long it took, colour is whether it
486
- -- worked, and a failure knocks the solid off its axis as well.
487
- Visuals.recordCall(milliseconds, ok, title)
488
- notify("recordCall", { ok, milliseconds, title })
489
-
490
- --[[
491
- Says so when the session goes quiet, once per burst.
492
-
493
- There is no "the agent has finished" message in MCP -- an agent that has
494
- stopped and one that is thinking are the same silence -- so this reports
495
- the silence rather than claiming to know what caused it: what ran, how
496
- much of it, and how fast. The generation check is what makes it once per
497
- burst: every later call bumps the counter, so all but the newest timer
498
- wake up, find they are stale, and do nothing.
499
- ]]
500
- state.generation += 1
501
- local mine = state.generation
502
- if mirroring then
503
- return
504
- end
505
- task.delay(QUIET_SECONDS, function()
506
- if state.generation ~= mine or state.calls == 0 then
507
- return
508
- end
509
- Console.log(
510
- "dim",
511
- string.format(
512
- "agent idle -- %d call%s, avg %.0fms",
513
- state.calls,
514
- if state.calls == 1 then "" else "s",
515
- state.totalMs / state.calls
516
- )
517
- )
518
- Visuals.setQuiet()
519
- end)
520
- end
521
-
522
- --[[
523
- Pins a line of text beside the activity strip.
524
-
525
- Unlike the caption a running command sets, this survives until something
526
- else replaces it, which is what a session that will never run a command
527
- needs: the standby client view has nothing to report and no reason to say
528
- "waiting for a command" forever when it is not waiting for one.
529
- ]]
530
- function Console.setCaption(message: string)
531
- Visuals.setCaption(message)
532
- end
533
-
534
- --[[
535
- Announces that an MCP client disconnected.
536
-
537
- This is the one moment the bridge can be certain a session ended rather than
538
- paused -- the client's process is gone -- so it is the one place the console
539
- is allowed to state it outright. Everything else it knows about agent
540
- activity is inference, and is worded as inference.
541
- ]]
542
- function Console.agentFinished()
543
- --[[
544
- Named for what happened, not for what it might have meant.
545
-
546
- This said "Agent finished task." and that was overclaiming: the bridge
547
- sees a client disconnect and nothing more. Quitting the editor, a crash
548
- and a restart all arrive here identically, and the first time one was
549
- watched live it announced a completed task for an agent that had simply
550
- been closed mid-idle.
551
- ]]
552
- Console.log("ok", "Agent disconnected.", "client left")
553
- Visuals.setQuiet()
554
- end
555
-
556
- --[[
557
- How many MCP clients are sharing this bridge.
558
-
559
- Reported only above one. A single client is the ordinary case and needs no
560
- badge -- a permanent "1 client connected" is a label, not a signal, and the
561
- header has better uses for the width.
562
- ]]
563
- function Console.setClients(count: number)
564
- local previous = state.clients
565
- state.clients = count
566
-
567
- local chip = state.clientsChip
568
- if chip then
569
- chip.Visible = count > 1
570
- -- Pluralised even though the badge hides at one. A string that is only
571
- -- ever correct because nobody can see it is a trap for whoever changes
572
- -- the visibility rule later.
573
- chip.Text = string.format(
574
- "\u{25C6} %d client%s",
575
- count,
576
- if count == 1 then "" else "s"
577
- )
578
- end
579
-
580
- local meta = state.metaText
581
- if meta then
582
- meta.Size = UDim2.new(1, if count > 1 then -486 else -394, 1, 0)
583
- end
584
-
585
- --[[
586
- Only the arrival is worth a row.
587
-
588
- The departure had one too, and seeing it live made the redundancy plain:
589
- "one MCP client connected" landed in the same second as "Agent finished
590
- task.", saying the same thing less well, while the badge disappearing
591
- said it a third time. The count going up is news because nothing else
592
- reports it; the count coming down is already covered twice over.
593
- ]]
594
- if count > 1 and previous <= 1 then
595
- Console.log("info", string.format("%d MCP clients connected", count))
596
- end
597
- end
598
-
599
- function Console.clear()
600
- --[[
601
- Says what it did, with a timestamp, like everything else in here.
602
-
603
- A button that empties the screen and leaves no trace is indistinguishable
604
- from one that crashed the panel. The count is read before the clear and
605
- written after it, so the first row of the fresh log is the receipt for
606
- the one that went.
607
- ]]
608
- local cleared = state.entries
609
- table.clear(state.records)
610
- state.entries = 0
611
- state.calls = 0
612
- state.errors = 0
613
- state.totalMs = 0
614
- refreshCounters()
615
- -- The bars belonged to the log that just went. Leaving forty timings from an
616
- -- erased session on screen while the footer reads "idle" is two answers to
617
- -- one question.
618
- Visuals.clearTrace()
619
- redraw()
620
-
621
- if cleared > 0 then
622
- Console.log(
623
- "dim",
624
- string.format("cleared %d log%s", cleared, if cleared == 1 then "" else "s")
625
- )
626
- end
627
- end
628
-
629
- --[[
630
- Updates the header. Kept separate from the log so the current state is always
631
- visible without scrolling, however long the session has run.
632
- ]]
633
- function Console.setStatus(status: string, meta: string)
634
- state.status = status
635
- state.statusMeta = meta
636
-
637
- local dot = state.statusDot
638
- local text = state.statusText
639
- local metaLabel = state.metaText
640
- if not dot or not text or not metaLabel then
641
- return
642
- end
643
-
644
- -- "standby" is a working state, not a fault: the client half of a playtest
645
- -- cannot use HTTP and is not meant to connect. Painting it red like a real
646
- -- disconnection made a correct setup look broken.
647
- local color = if status == "connected"
648
- then PALETTE.green
649
- elseif status == "connecting" then PALETTE.amber
650
- elseif status == "standby" or status == "mirroring" then PALETTE.dim
651
- else PALETTE.red
652
-
653
- dot.TextColor3 = color
654
- text.Text = string.upper(status)
655
- text.TextColor3 = color
656
- metaLabel.Text = meta
657
-
658
- -- The wireframe takes the same colour as the status light, so the panel
659
- -- reads as disconnected at a glance even with the header off screen.
660
- Visuals.setTint(color)
661
- end
662
-
663
- --[[
664
- Records which palette colour an Instance is wearing, and puts it on.
665
-
666
- The console builds about thirty Instances and copies a colour into each. A
667
- copy does not follow the palette when the user picks a different preset, so
668
- the panel would keep the old theme's header, rule, scrollbar and buttons
669
- while the log underneath repainted -- which looks less like a theme than
670
- like a half-finished render.
671
-
672
- Rather than keep thirty named references and re-set thirty properties by
673
- hand, each one declares the property and palette key it is wearing when it
674
- is built, and `Console.applyTheme` walks the list. Adding a widget therefore
675
- cannot forget to theme it: the same call that colours it registers it.
676
- ]]
677
- type Role = { instance: Instance, property: string, key: string }
678
- local roles: { Role } = {}
679
-
680
- local function themed<T>(instance: T & Instance, property: string, key: string): T
681
- table.insert(roles, { instance = instance, property = property, key = key })
682
- ;(instance :: any)[property] = PALETTE[key]
683
- return instance
684
- end
685
-
686
- local function makeButton(parent: Instance, text: string, order: number): TextButton
687
- local button = Instance.new("TextButton")
688
- button.Name = text
689
- button.Text = text
690
- button.Font = Enum.Font.Code
691
- button.TextSize = 11
692
- themed(button, "TextColor3", "dim")
693
- themed(button, "BackgroundColor3", "background")
694
- button.AutoButtonColor = false
695
- button.BorderSizePixel = 0
696
- button.Size = UDim2.new(0, 76, 0, 20)
697
- button.LayoutOrder = order
698
- button.Parent = parent
699
-
700
- local corner = Instance.new("UICorner")
701
- corner.CornerRadius = UDim.new(0, 3)
702
- corner.Parent = button
703
-
704
- local stroke = Instance.new("UIStroke")
705
- themed(stroke, "Color", "dim")
706
- stroke.Transparency = 0.6
707
- stroke.Parent = button
708
-
709
- button.MouseEnter:Connect(function()
710
- button.TextColor3 = PALETTE.violet
711
- stroke.Color = PALETTE.violet
712
- stroke.Transparency = 0.3
713
- end)
714
- button.MouseLeave:Connect(function()
715
- button.TextColor3 = PALETTE.dim
716
- stroke.Color = PALETTE.dim
717
- stroke.Transparency = 0.6
718
- end)
719
-
720
- return button
721
- end
722
-
723
- export type Handlers = {
724
- onReconnect: () -> (),
725
- onClear: () -> (),
726
- -- Called after a preset switch has already been applied, so the caller's
727
- -- only job is to remember it. Persistence lives there because
728
- -- `plugin:SetSetting` is not reachable from a ModuleScript.
729
- onTheme: (string) -> (),
730
- }
731
-
732
- --[[
733
- Builds the widget contents. Colours are fixed rather than theme-derived: this
734
- is a console, and a console that repaints itself light grey reads as a form.
735
- ]]
736
- function Console.mount(parent: Instance, handlers: Handlers)
737
- local root = Instance.new("Frame")
738
- root.Size = UDim2.fromScale(1, 1)
739
- themed(root, "BackgroundColor3", "background")
740
- root.BorderSizePixel = 0
741
- root.Parent = parent
742
-
743
- -- Header ---------------------------------------------------------------
744
- local header = Instance.new("Frame")
745
- header.Size = UDim2.new(1, 0, 0, 32)
746
- themed(header, "BackgroundColor3", "surface")
747
- header.BorderSizePixel = 0
748
- header.Parent = root
749
-
750
- local headerPadding = Instance.new("UIPadding")
751
- headerPadding.PaddingLeft = UDim.new(0, 12)
752
- headerPadding.PaddingRight = UDim.new(0, 8)
753
- headerPadding.Parent = header
754
-
755
- local dot = Instance.new("TextLabel")
756
- dot.Text = "\u{25CF}"
757
- dot.Font = Enum.Font.Code
758
- dot.TextSize = 13
759
- -- Deliberately NOT registered with `themed`. Red here is the value it
760
- -- STARTS at, not the colour it wears: what it should be is whatever
761
- -- `setStatus` last reported. Registering it meant every theme switch
762
- -- repainted a connected session red.
763
- dot.TextColor3 = PALETTE.red
764
- dot.BackgroundTransparency = 1
765
- dot.Size = UDim2.new(0, 12, 1, 0)
766
- dot.Parent = header
767
- state.statusDot = dot
768
-
769
- local status = Instance.new("TextLabel")
770
- status.Text = "DISCONNECTED"
771
- status.Font = Enum.Font.Code
772
- status.TextSize = 12
773
- status.TextColor3 = PALETTE.red
774
- status.TextXAlignment = Enum.TextXAlignment.Left
775
- status.BackgroundTransparency = 1
776
- status.Position = UDim2.new(0, 18, 0, 0)
777
- status.Size = UDim2.new(0, 110, 1, 0)
778
- status.Parent = header
779
- state.statusText = status
780
-
781
- local meta = Instance.new("TextLabel")
782
- meta.Text = ""
783
- meta.Font = Enum.Font.Code
784
- meta.TextSize = 11
785
- themed(meta, "TextColor3", "dim")
786
- meta.TextXAlignment = Enum.TextXAlignment.Left
787
- meta.TextTruncate = Enum.TextTruncate.AtEnd
788
- meta.BackgroundTransparency = 1
789
- meta.Position = UDim2.new(0, 132, 0, 0)
790
- meta.Size = UDim2.new(1, -394, 1, 0)
791
- meta.Parent = header
792
- state.metaText = meta
793
-
794
- --[[
795
- The shared-bridge badge, hidden until there is something to share.
796
-
797
- Sits between the meta line and the buttons rather than in the activity
798
- band, because it is a fact about the connection and the header is where
799
- connection facts live. `meta` gives up the width when it appears; see
800
- `Console.setClients`.
801
- ]]
802
- local clientsChip = Instance.new("TextLabel")
803
- clientsChip.Name = "Clients"
804
- clientsChip.AnchorPoint = Vector2.new(1, 0.5)
805
- clientsChip.Position = UDim2.new(1, -252, 0.5, 0)
806
- clientsChip.Size = UDim2.new(0, 84, 0, 18)
807
- themed(clientsChip, "BackgroundColor3", "background")
808
- clientsChip.BorderSizePixel = 0
809
- clientsChip.Font = Enum.Font.Code
810
- clientsChip.TextSize = 11
811
- themed(clientsChip, "TextColor3", "cyan")
812
- clientsChip.Text = ""
813
- clientsChip.Visible = false
814
- clientsChip.Parent = header
815
- state.clientsChip = clientsChip
816
-
817
- local chipCorner = Instance.new("UICorner")
818
- chipCorner.CornerRadius = UDim.new(0, 3)
819
- chipCorner.Parent = clientsChip
820
-
821
- local chipStroke = Instance.new("UIStroke")
822
- themed(chipStroke, "Color", "cyan")
823
- chipStroke.Transparency = 0.6
824
- chipStroke.Parent = clientsChip
825
-
826
- local buttons = Instance.new("Frame")
827
- buttons.AnchorPoint = Vector2.new(1, 0.5)
828
- buttons.Position = UDim2.new(1, 0, 0.5, 0)
829
- buttons.Size = UDim2.new(0, 244, 0, 20)
830
- buttons.BackgroundTransparency = 1
831
- buttons.Parent = header
832
-
833
- local buttonLayout = Instance.new("UIListLayout")
834
- buttonLayout.FillDirection = Enum.FillDirection.Horizontal
835
- buttonLayout.HorizontalAlignment = Enum.HorizontalAlignment.Right
836
- buttonLayout.VerticalAlignment = Enum.VerticalAlignment.Center
837
- buttonLayout.Padding = UDim.new(0, 6)
838
- buttonLayout.SortOrder = Enum.SortOrder.LayoutOrder
839
- buttonLayout.Parent = buttons
840
-
841
- local visualsButton = makeButton(buttons, "visuals", 1)
842
- makeButton(buttons, "reconnect", 2).MouseButton1Click:Connect(handlers.onReconnect)
843
- makeButton(buttons, "clear", 3).MouseButton1Click:Connect(handlers.onClear)
844
-
845
- -- Accent rule under the header. One hairline in the signature violet is what
846
- -- separates this from every other grey dock in Studio.
847
- local rule = Instance.new("Frame")
848
- rule.Position = UDim2.new(0, 0, 0, 32)
849
- rule.Size = UDim2.new(1, 0, 0, 1)
850
- themed(rule, "BackgroundColor3", "rule")
851
- rule.BorderSizePixel = 0
852
- rule.Parent = root
853
-
854
- local ruleFade = Instance.new("UIGradient")
855
- ruleFade.Color = ColorSequence.new(PALETTE.rule)
856
- state.ruleFade = ruleFade
857
- ruleFade.Transparency = NumberSequence.new({
858
- NumberSequenceKeypoint.new(0, 0.15),
859
- NumberSequenceKeypoint.new(1, 0.85),
860
- })
861
- ruleFade.Parent = rule
862
-
863
- -- Log ------------------------------------------------------------------
864
- local scroller = Instance.new("ScrollingFrame")
865
- scroller.Position = UDim2.new(0, 0, 0, 33)
866
- scroller.Size = UDim2.new(1, 0, 1, -55)
867
- scroller.BackgroundTransparency = 1
868
- scroller.BorderSizePixel = 0
869
- scroller.ScrollBarThickness = 5
870
- themed(scroller, "ScrollBarImageColor3", "violet")
871
- scroller.ScrollBarImageTransparency = 0.5
872
- scroller.CanvasSize = UDim2.new()
873
- scroller.AutomaticCanvasSize = Enum.AutomaticSize.Y
874
- scroller.ScrollingDirection = Enum.ScrollingDirection.Y
875
- scroller.Parent = root
876
- state.scroller = scroller
877
-
878
- local logPadding = Instance.new("UIPadding")
879
- logPadding.PaddingTop = UDim.new(0, 8)
880
- logPadding.PaddingBottom = UDim.new(0, 8)
881
- logPadding.PaddingLeft = UDim.new(0, 12)
882
- logPadding.PaddingRight = UDim.new(0, 12)
883
- logPadding.Parent = scroller
884
-
885
- local label = Instance.new("TextLabel")
886
- label.Size = UDim2.new(1, 0, 0, 0)
887
- label.AutomaticSize = Enum.AutomaticSize.Y
888
- label.BackgroundTransparency = 1
889
- label.Font = Enum.Font.Code
890
- label.TextSize = 12
891
- label.LineHeight = 1.25
892
- themed(label, "TextColor3", "text")
893
- label.RichText = true
894
- label.TextWrapped = true
895
- label.TextXAlignment = Enum.TextXAlignment.Left
896
- label.TextYAlignment = Enum.TextYAlignment.Top
897
- label.Text = ""
898
- label.Parent = scroller
899
- state.label = label
900
-
901
- -- Status bar ------------------------------------------------------------
902
- local footer = Instance.new("Frame")
903
- footer.AnchorPoint = Vector2.new(0, 1)
904
- footer.Position = UDim2.fromScale(0, 1)
905
- footer.Size = UDim2.new(1, 0, 0, 22)
906
- themed(footer, "BackgroundColor3", "surface")
907
- footer.BorderSizePixel = 0
908
- footer.Parent = root
909
-
910
- local footerPadding = Instance.new("UIPadding")
911
- footerPadding.PaddingLeft = UDim.new(0, 12)
912
- footerPadding.PaddingRight = UDim.new(0, 12)
913
- footerPadding.Parent = footer
914
-
915
- local prompt = Instance.new("TextLabel")
916
- prompt.Text = "rbx\u{00B7}studio"
917
- prompt.Font = Enum.Font.Code
918
- prompt.TextSize = 11
919
- themed(prompt, "TextColor3", "violet")
920
- prompt.TextXAlignment = Enum.TextXAlignment.Left
921
- prompt.BackgroundTransparency = 1
922
- prompt.Size = UDim2.new(0, 70, 1, 0)
923
- prompt.Parent = footer
924
-
925
- --[[
926
- No cursor here.
927
-
928
- A blinking block after a prompt is the universal sign that something is
929
- waiting to be typed into, and nothing in this panel accepts input. It
930
- was there to prove the widget was live, which the activity band now does
931
- honestly, by moving only when there is something to move about.
932
- ]]
933
-
934
- local counters = Instance.new("TextLabel")
935
- counters.Text = "idle"
936
- counters.Font = Enum.Font.Code
937
- counters.TextSize = 11
938
- themed(counters, "TextColor3", "dim")
939
- counters.TextXAlignment = Enum.TextXAlignment.Right
940
- counters.BackgroundTransparency = 1
941
- counters.AnchorPoint = Vector2.new(1, 0)
942
- counters.Position = UDim2.fromScale(1, 0)
943
- counters.Size = UDim2.new(1, -90, 1, 0)
944
- counters.Parent = footer
945
- state.countersText = counters
946
-
947
- --[[
948
- The band sits above the log and pushes it down, rather than over it.
949
-
950
- The first version covered the log and was slightly transparent, so the
951
- thing you actually read was both hidden and softened. Decoration that
952
- costs legibility is a bad trade however good it looks, and this is a
953
- console before it is anything else.
954
- ]]
955
- Visuals.mount(root)
956
- local band = root:FindFirstChild("ActivityBand") :: Frame
957
- band.Position = UDim2.new(0, 0, 0, 33)
958
- band.Size = UDim2.new(1, 0, 0, Visuals.BAND_HEIGHT)
959
-
960
- -- The log's top edge follows the band, so turning it on never hides a line.
961
- local function layoutLog()
962
- local top = 33 + (if Visuals.isVisible() then Visuals.BAND_HEIGHT else 0)
963
- scroller.Position = UDim2.new(0, 0, 0, top)
964
- scroller.Size = UDim2.new(1, 0, 1, -(top + 22))
965
- end
966
-
967
- -- On by default. It is the part that says the session is alive, and a signal
968
- -- nobody discovers is not a signal; anyone who wants the extra 44px back can
969
- -- turn it off in one click.
970
- Visuals.setVisible(true)
971
- visualsButton.Text = utf8.char(0x25C6) .. " visuals"
972
- Visuals.setIdle()
973
- layoutLog()
974
-
975
- visualsButton.MouseButton1Click:Connect(function()
976
- Visuals.setVisible(not Visuals.isVisible())
977
- layoutLog()
978
- -- The button reports the state it is in, not the state it would move to.
979
- -- A toggle that reads as an instruction is ambiguous the moment you look
980
- -- away and back.
981
- visualsButton.Text = if Visuals.isVisible() then "\u{25C6} visuals" else "visuals"
982
- end)
983
-
984
- --[[
985
- The preset drawer, mounted last so its tab sits over everything.
986
-
987
- It has to be the last child of `root` as well as the highest ZIndex: the
988
- band, the log and the footer are all built before it and a drawer that
989
- opens behind the log is a drawer nobody can click.
990
- ]]
991
- ThemePicker.mount(root, function(id)
992
- Console.applyTheme()
993
- ThemePicker.applyTheme()
994
- layoutLog()
995
- handlers.onTheme(id)
996
- end)
997
-
998
- -- Treat "scrolled away from the bottom" as the user reading history, and
999
- -- stop yanking the view down under them until they scroll back.
1000
- scroller:GetPropertyChangedSignal("CanvasPosition"):Connect(function()
1001
- local maxScroll = math.max(0, scroller.AbsoluteCanvasSize.Y - scroller.AbsoluteWindowSize.Y)
1002
- state.pinned = maxScroll - scroller.CanvasPosition.Y < 24
1003
- end)
1004
- end
1005
-
1006
- --[[
1007
- Switches the whole panel to the active preset.
1008
-
1009
- Three things have to move together or the switch looks broken: the palette
1010
- this file reads, the Instances that copied a colour out of it, and the log,
1011
- whose rows are re-rendered from records rather than recoloured in place. The
1012
- band goes last because remounting a preset is the expensive part and there
1013
- is no reason to make the text wait for it.
1014
- ]]
1015
- function Console.applyTheme()
1016
- PALETTE = Themes.palette()
1017
-
1018
- for _, role in roles do
1019
- -- A widget can outlive its registration if the panel is rebuilt, and
1020
- -- writing to a destroyed Instance throws.
1021
- if role.instance.Parent ~= nil or role.instance:IsA("UIStroke") then
1022
- pcall(function()
1023
- (role.instance :: any)[role.property] = PALETTE[role.key]
1024
- end)
1025
- end
1026
- end
1027
-
1028
- local ruleFade = state.ruleFade
1029
- if ruleFade then
1030
- ruleFade.Color = ColorSequence.new(PALETTE.rule)
1031
- end
1032
-
1033
- refreshCounters()
1034
- -- Replayed rather than recoloured: the status light's colour is a fact about
1035
- -- the connection, and the only thing that knows it is `setStatus`.
1036
- Console.setStatus(state.status, state.statusMeta)
1037
- redraw()
1038
- Visuals.applyTheme()
1039
- end
1040
-
1041
- return Console
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 ThemePicker = require(script.Parent.ThemePicker)
22
+ local Themes = require(script.Parent.Themes)
23
+ local Visuals = require(script.Parent.Visuals)
24
+
25
+ local Console = {}
26
+
27
+ --[[
28
+ Ring buffer bound, counted in logged rows rather than in rendered lines.
29
+
30
+ It used to bound the rendered lines, which meant a burst of failures -- each
31
+ writing a message plus two wrapped lines of explanation -- evicted three
32
+ times as much history as a burst of successes. Bounding the records makes
33
+ "the last three hundred things that happened" mean what it says.
34
+ ]]
35
+ local MAX_RECORDS = 300
36
+
37
+ --[[
38
+ The active preset's colours, rebound rather than re-read.
39
+
40
+ Every use site in this file is a plain `PALETTE.dim`, and there are about
41
+ forty of them. Turning each into a function call to satisfy theming would
42
+ have been forty edits to working code for no gain: the palette changes only
43
+ when the user picks a different preset, so it is simply reassigned there and
44
+ the reads stay as they were.
45
+
46
+ The catch is that anything which COPIES a colour out of here keeps the old
47
+ one. Instances built in `mount` do exactly that, which is why `applyTheme`
48
+ has to walk them by hand.
49
+ ]]
50
+ local PALETTE: Themes.Palette = Themes.palette()
51
+
52
+ --[[
53
+ An optional listener on everything this console is told.
54
+
55
+ Exists for `Mirror`, which relays a playtest server's activity to the client
56
+ view that cannot reach the bridge itself. Kept as one hook rather than as
57
+ calls sprinkled through the file so there is a single place where "what the
58
+ console was told" is defined, and so the client half -- which sets no
59
+ observer -- cannot echo what it replays back into the channel.
60
+ ]]
61
+ local observer: ((string, { any }) -> ())? = nil
62
+
63
+ function Console.setObserver(fn: ((string, { any }) -> ())?)
64
+ observer = fn
65
+ end
66
+
67
+ local function notify(kind: string, arguments: { any })
68
+ local listener = observer
69
+ if listener ~= nil then
70
+ task.spawn(listener, kind, arguments)
71
+ end
72
+ end
73
+
74
+ --[[
75
+ Set on the console that is REPLAYING someone else's events.
76
+
77
+ A replayed call must not grow its own consequences. `recordCall` schedules
78
+ the "agent idle" summary, so a mirrored session scheduled one of its own and
79
+ then received the original over the channel too -- the same line twice, a
80
+ few pixels apart, which is exactly the sort of thing that makes a panel look
81
+ broken. The origin is the only session entitled to decide the burst ended.
82
+ ]]
83
+ local mirroring = false
84
+
85
+ function Console.setMirroring(value: boolean)
86
+ mirroring = value
87
+ end
88
+
89
+ -- Sigil plus colour per level. The sigil is what makes the log scannable at a
90
+ -- glance; colour alone fails for anyone who cannot separate red from green.
91
+ -- Named by palette key rather than by colour, because this table is built once
92
+ -- at load and a colour captured then belongs to whichever preset happened to
93
+ -- be active at the time.
94
+ local LEVELS: { [string]: { sigil: string, key: string } } = {
95
+ ok = { sigil = "\u{25C6}", key = "green" },
96
+ error = { sigil = "\u{2715}", key = "red" },
97
+ warn = { sigil = "\u{25B2}", key = "amber" },
98
+ info = { sigil = "\u{25C6}", key = "violet" },
99
+ dim = { sigil = "\u{00B7}", key = "dim" },
100
+ call = { sigil = "\u{25B8}", key = "cyan" },
101
+ reply = { sigil = "\u{25C2}", key = "violet" },
102
+ }
103
+
104
+ export type Level = "ok" | "error" | "warn" | "info" | "dim" | "call" | "reply"
105
+
106
+ --[[
107
+ Column the right-aligned detail is padded out to, in characters.
108
+
109
+ Sized for the widget's default width rather than for the longest message: at
110
+ 12pt Code with a timestamp ahead of it, anything past this wraps, and a
111
+ wrapped line breaks the very column the padding exists to produce. Messages
112
+ are cut to fit instead.
113
+ ]]
114
+ local DETAIL_COLUMN = 52
115
+
116
+ --[[
117
+ How far past the column an inline detail may run before it is moved below the
118
+ message instead. A latency fits; a sentence does not.
119
+ ]]
120
+ local INLINE_DETAIL = 14
121
+
122
+ -- Continuation lines sit under the message text, clear of the timestamp and the
123
+ -- sigil, so a wrapped explanation reads as belonging to the line above it.
124
+ local DETAIL_INDENT = string.rep(" ", 11)
125
+
126
+ -- Characters per continuation line. Fixed rather than measured from the widget:
127
+ -- the console is monospaced, and a width that changes as the user drags the
128
+ -- panel would rewrap history every frame.
129
+ local DETAIL_WRAP = 74
130
+
131
+ --[[
132
+ Breaks a long detail into lines at word boundaries.
133
+
134
+ Roblox's own TextWrapped would do this, and would wrap to column 0 -- there
135
+ is no hanging indent for a TextLabel -- which is the ragged shape this
136
+ replaces. Wrapping here means every continuation line can carry the indent.
137
+ ]]
138
+ local function wrapDetail(detail: string): { string }
139
+ local lines: { string } = {}
140
+ local current = ""
141
+ for word in string.gmatch(detail, "%S+") do
142
+ local candidate = if current == "" then word else current .. " " .. word
143
+ if (utf8.len(candidate) or #candidate) > DETAIL_WRAP and current ~= "" then
144
+ table.insert(lines, current)
145
+ current = word
146
+ else
147
+ current = candidate
148
+ end
149
+ end
150
+ if current ~= "" then
151
+ table.insert(lines, current)
152
+ end
153
+ return lines
154
+ end
155
+
156
+ --[[
157
+ One logged row, before it is coloured.
158
+
159
+ Holding the parts rather than the finished string is what lets a theme
160
+ switch repaint history: see `renderRecord`.
161
+ ]]
162
+ type Record = {
163
+ level: Level,
164
+ message: string,
165
+ detail: string?,
166
+ stamp: string,
167
+ }
168
+
169
+ type State = {
170
+ records: { Record },
171
+ -- The last status reported, replayed after a theme switch. Without it the
172
+ -- header has no way to know what colour it should be wearing.
173
+ status: string,
174
+ statusMeta: string,
175
+ -- The accent rule's gradient. A ColorSequence rather than a Color3, so it
176
+ -- cannot ride the generic role list.
177
+ ruleFade: UIGradient?,
178
+ --[[
179
+ Rows written, as opposed to lines rendered.
180
+
181
+ `lines` also holds the indented continuations a long detail wraps onto,
182
+ so counting it would report a number nobody wrote -- "cleared 41 logs"
183
+ for twelve commands. This counts calls to `log`, which is what a person
184
+ means by a log line.
185
+ ]]
186
+ entries: number,
187
+ label: TextLabel?,
188
+ scroller: ScrollingFrame?,
189
+ statusDot: TextLabel?,
190
+ statusText: TextLabel?,
191
+ metaText: TextLabel?,
192
+ countersText: TextLabel?,
193
+ clientsChip: TextLabel?,
194
+ pinned: boolean,
195
+ calls: number,
196
+ errors: number,
197
+ totalMs: number,
198
+ -- The command in flight, shown in the footer while it runs. One value that
199
+ -- is replaced, never a list that grows.
200
+ running: string?,
201
+ --[[
202
+ Bumped by every call, so a pending idle timer can tell whether it is still
203
+ the most recent one. Cheaper and less error-prone than cancelling timers:
204
+ `task.delay` has no handle to cancel, and a stale closure that checks a
205
+ counter simply does nothing.
206
+ ]]
207
+ generation: number,
208
+ -- How many MCP clients share this bridge. Only ever displayed above one.
209
+ clients: number,
210
+ -- Set while a repaint is already scheduled for the end of this frame.
211
+ dirty: boolean,
212
+ }
213
+
214
+ local state: State = {
215
+ records = {},
216
+ ruleFade = nil,
217
+ status = "disconnected",
218
+ statusMeta = "",
219
+ entries = 0,
220
+ label = nil,
221
+ scroller = nil,
222
+ statusDot = nil,
223
+ statusText = nil,
224
+ metaText = nil,
225
+ countersText = nil,
226
+ clientsChip = nil,
227
+ pinned = true,
228
+ calls = 0,
229
+ errors = 0,
230
+ totalMs = 0,
231
+ running = nil,
232
+ generation = 0,
233
+ clients = 1,
234
+ dirty = false,
235
+ }
236
+
237
+ -- How long the session must be silent before the console says so. Long enough
238
+ -- that an agent pausing to think is not announced as having stopped.
239
+ local QUIET_SECONDS = 20
240
+
241
+ local function hex(color: Color3): string
242
+ return string.format(
243
+ "#%02X%02X%02X",
244
+ math.floor(color.R * 255 + 0.5),
245
+ math.floor(color.G * 255 + 0.5),
246
+ math.floor(color.B * 255 + 0.5)
247
+ )
248
+ end
249
+
250
+ -- RichText is markup, so anything user- or engine-supplied has to be escaped or
251
+ -- a stray `<` in an error message silently eats the rest of the line.
252
+ local function escape(value: string): string
253
+ local escaped = string.gsub(value, "&", "&amp;")
254
+ escaped = string.gsub(escaped, "<", "&lt;")
255
+ escaped = string.gsub(escaped, ">", "&gt;")
256
+ return escaped
257
+ end
258
+
259
+ local function span(color: Color3, value: string): string
260
+ return string.format('<font color="%s">%s</font>', hex(color), escape(value))
261
+ end
262
+
263
+ --[[
264
+ Turns one record into the lines it occupies.
265
+
266
+ Rendering is deferred to here rather than done when the row is logged, which
267
+ is the change that made theming possible at all: a line that has already had
268
+ `#A78BFA` baked into it cannot be recoloured, so switching preset used to
269
+ leave the whole session's history in the previous theme's palette while new
270
+ rows arrived in the new one. Now nothing is coloured until it is painted.
271
+ ]]
272
+ local function renderRecord(record: Record): { string }
273
+ local spec = LEVELS[record.level] or LEVELS.info
274
+ local detail = record.detail
275
+
276
+ --[[
277
+ Measured in characters, not bytes.
278
+
279
+ `#body` counts bytes, and every sigil in this console is a 3-byte UTF-8
280
+ glyph, so it over-counted each line by two and pushed the latency column
281
+ two places left on exactly the lines that had a latency. The column was
282
+ never straight, and the cause was invisible until two different sigils
283
+ sat next to each other.
284
+ ]]
285
+ --[[
286
+ Only a detail that will actually sit in the column costs the message any
287
+ of its width. Cutting the message to make room for a detail that then
288
+ goes on its own line below would shorten it for nothing.
289
+ ]]
290
+ local detailWidth = if detail then (utf8.len(detail) or #detail) else 0
291
+ local inlineDetail = detail ~= nil
292
+ and (utf8.len(record.message) or #record.message) + detailWidth + 3
293
+ <= DETAIL_COLUMN + INLINE_DETAIL
294
+
295
+ local trimmed = record.message
296
+ local budget = DETAIL_COLUMN - 3
297
+ if inlineDetail and (utf8.len(trimmed) or #trimmed) > budget then
298
+ -- Cut rather than wrap. A wrapped line destroys the alignment and
299
+ -- carries the detail off the end of the visible width as well.
300
+ local offset = utf8.offset(trimmed, budget) or budget
301
+ trimmed = string.sub(trimmed, 1, offset - 1) .. utf8.char(0x2026)
302
+ end
303
+
304
+ local body = string.format("%s %s", spec.sigil, trimmed)
305
+ local bodyWidth = utf8.len(body) or #body
306
+ local lines = { span(PALETTE.dim, record.stamp) .. " " .. span(PALETTE[spec.key], body) }
307
+
308
+ --[[
309
+ Short details ride the right-hand column; long ones get their own lines.
310
+
311
+ The column exists for latencies -- "12ms" stacking into something
312
+ readable -- and it was applied to every detail regardless of length. A
313
+ sentence of prose therefore started at column 52, ran off the widget, and
314
+ wrapped back to column 0, so the explanation of a standby session came
315
+ out as a ragged block that began in the middle of the screen and ended at
316
+ the left edge. Anything that will not fit beside the message is better
317
+ off beneath it.
318
+ ]]
319
+ if detail then
320
+ if inlineDetail then
321
+ local padding = math.max(1, DETAIL_COLUMN - bodyWidth)
322
+ lines[1] ..= span(PALETTE.dim, string.rep(" ", padding) .. detail)
323
+ else
324
+ for _, wrapped in wrapDetail(detail) do
325
+ table.insert(lines, span(PALETTE.dim, DETAIL_INDENT .. wrapped))
326
+ end
327
+ end
328
+ end
329
+ return lines
330
+ end
331
+
332
+ local function paint()
333
+ state.dirty = false
334
+ local label = state.label
335
+ if not label then
336
+ return
337
+ end
338
+
339
+ local lines: { string } = {}
340
+ for _, record in state.records do
341
+ for _, line in renderRecord(record) do
342
+ table.insert(lines, line)
343
+ end
344
+ end
345
+ label.Text = table.concat(lines, "\n")
346
+
347
+ -- Only follow the tail when the user has not scrolled up to read history.
348
+ local scroller = state.scroller
349
+ if scroller and state.pinned then
350
+ task.defer(function()
351
+ if scroller.Parent then
352
+ scroller.CanvasPosition = Vector2.new(0, math.max(0, scroller.AbsoluteCanvasSize.Y))
353
+ end
354
+ end)
355
+ end
356
+ end
357
+
358
+ --[[
359
+ Asks for a repaint, at most one per frame.
360
+
361
+ Painting is a concat of up to three hundred strings followed by a RichText
362
+ relayout of the whole label, and it used to run once per appended row. A
363
+ failed command writes three rows -- the failure, its message, sometimes a
364
+ hint -- so a single unlucky call repainted the entire console three times,
365
+ and all of that sat in front of the reply on its way back to the agent.
366
+ Deferring collapses them into the one paint that was always sufficient.
367
+ ]]
368
+ local function redraw()
369
+ if state.dirty then
370
+ return
371
+ end
372
+ state.dirty = true
373
+ task.defer(paint)
374
+ end
375
+
376
+ local function refreshCounters()
377
+ local counters = state.countersText
378
+ if not counters then
379
+ return
380
+ end
381
+ --[[
382
+ The footer is for totals, and only totals.
383
+
384
+ It briefly doubled as the in-flight readout, which meant a running
385
+ command overwrote "4 calls 0 errors avg 84ms" with its own name -- the
386
+ session statistics disappearing exactly when the session was busiest.
387
+ Two live readouts on one small panel is one too many, and the band
388
+ already has the better spot for it, right beside the solid.
389
+ ]]
390
+ counters.TextColor3 = PALETTE.dim
391
+ if state.calls == 0 then
392
+ counters.Text = "idle"
393
+ return
394
+ end
395
+ counters.Text = string.format(
396
+ "%d call%s %d error%s avg %.0fms",
397
+ state.calls,
398
+ if state.calls == 1 then "" else "s",
399
+ state.errors,
400
+ if state.errors == 1 then "" else "s",
401
+ state.totalMs / state.calls
402
+ )
403
+ end
404
+
405
+ --[[
406
+ Appends one line. `detail` is padded to a fixed column and dimmed, so
407
+ latencies stack into a readable column instead of trailing each message at a
408
+ ragged offset.
409
+ ]]
410
+ function Console.log(level: Level, message: string, detail: string?)
411
+ table.insert(state.records, {
412
+ level = level,
413
+ message = message,
414
+ detail = detail,
415
+ -- Stamped when the row happened, not when it is painted. A repaint after
416
+ -- a theme switch re-renders every line, and re-reading the clock there
417
+ -- would restamp the whole session to the moment the user changed colour.
418
+ stamp = os.date("%H:%M:%S") :: string,
419
+ })
420
+ state.entries += 1
421
+ while #state.records > MAX_RECORDS do
422
+ table.remove(state.records, 1)
423
+ end
424
+ redraw()
425
+ notify("log", { level, message, detail })
426
+ end
427
+
428
+ export type Row = {
429
+ level: string,
430
+ message: string,
431
+ detail: string?,
432
+ stamp: string,
433
+ }
434
+
435
+ --[[
436
+ The rows this console is holding, oldest first.
437
+
438
+ Handed out as copies of the table but not of the records themselves: nothing
439
+ outside this file writes to a record, and deep-copying three hundred of them
440
+ every two seconds to guard against a caller that does not exist is a cost for
441
+ nobody. See `History`, which is the only caller.
442
+ ]]
443
+ function Console.snapshot(): { Row }
444
+ local rows: { Row } = {}
445
+ for _, record in state.records do
446
+ table.insert(rows, {
447
+ level = record.level :: string,
448
+ message = record.message,
449
+ detail = record.detail,
450
+ stamp = record.stamp,
451
+ })
452
+ end
453
+ return rows
454
+ end
455
+
456
+ --[[
457
+ Puts rows back, from before this copy of the plugin existed.
458
+
459
+ Their own timestamps come with them rather than being restamped: a restored
460
+ row happened when it happened, and stamping it "now" would make a log that is
461
+ being carried across a playtest look like a log that is repeating itself.
462
+
463
+ Refuses once anything has been logged. Restoring is a load-time act, and
464
+ splicing history under a running session would put old rows below new ones --
465
+ the one thing a chronological log may not do.
466
+ ]]
467
+ function Console.restore(rows: { Row })
468
+ if #state.records > 0 then
469
+ return
470
+ end
471
+ for _, row in rows do
472
+ -- A level from disk names a colour and a sigil; an unknown one would
473
+ -- render as `info` silently, so it is normalised here instead.
474
+ local level: Level = if LEVELS[row.level] ~= nil then (row.level :: any) else "dim"
475
+ table.insert(state.records, {
476
+ level = level,
477
+ message = row.message,
478
+ detail = row.detail,
479
+ stamp = row.stamp,
480
+ })
481
+ end
482
+ while #state.records > MAX_RECORDS do
483
+ table.remove(state.records, 1)
484
+ end
485
+ redraw()
486
+ end
487
+
488
+ --[[
489
+ Announces a command as it starts -- everywhere except the log.
490
+
491
+ This used to append a line, and then the reply appended a second one saying
492
+ the same thing in a different colour. Every call cost two rows and read as
493
+ duplicated output, which is exactly what it was: a cyan "Edit KillBrick"
494
+ followed by a violet "Edit KillBrick".
495
+
496
+ The log now takes one line per call, written when it finishes and carrying
497
+ the latency it took. What is running *right now* belongs in a place that
498
+ updates rather than accumulates, so it goes to the footer and the activity
499
+ band -- both of which show a single current value and neither of which grows.
500
+ ]]
501
+ --[[
502
+ Colour and pace per kind of work, so the band reads as what is happening.
503
+
504
+ Reads are cool and quick because they are constant and harmless; writes are
505
+ violet and slower because they change the user's game; running code is green
506
+ and heavier still. Urgency drives both the spin rate and how strongly the
507
+ colour takes over, so the two never disagree.
508
+ ]]
509
+ local KIND_LOOK: { [string]: { key: string, urgency: number } } = {
510
+ read = { key = "cyan", urgency = 0.3 },
511
+ write = { key = "violet", urgency = 0.7 },
512
+ run = { key = "green", urgency = 0.9 },
513
+ debug = { key = "amber", urgency = 0.5 },
514
+ }
515
+
516
+ function Console.beginCall(title: string, kind: string)
517
+ state.running = title
518
+ state.generation += 1
519
+ local look = KIND_LOOK[kind] or KIND_LOOK.read
520
+ Visuals.setCaption(title)
521
+ Visuals.setKind(PALETTE[look.key], look.urgency)
522
+ refreshCounters()
523
+ notify("beginCall", { title, kind })
524
+ end
525
+
526
+ --[[
527
+ Records one completed command for the session counters. Kept separate from
528
+ `log` so callers can log freely without skewing the statistics.
529
+ ]]
530
+ function Console.recordCall(ok: boolean, milliseconds: number)
531
+ state.calls += 1
532
+ state.totalMs += milliseconds
533
+ if not ok then
534
+ state.errors += 1
535
+ end
536
+ -- Read before it is cleared: the bar wants the same phrase the log row uses,
537
+ -- and this is the only place that still has it.
538
+ local title = state.running or "call"
539
+ state.running = nil
540
+ refreshCounters()
541
+ -- The footer reports totals when idle, so the band shows what just ran
542
+ -- instead of repeating the same word on the same screen.
543
+ Visuals.setIdle()
544
+
545
+ -- The band plots it: bar height is how long it took, colour is whether it
546
+ -- worked, and a failure knocks the solid off its axis as well.
547
+ Visuals.recordCall(milliseconds, ok, title)
548
+ notify("recordCall", { ok, milliseconds, title })
549
+
550
+ --[[
551
+ Says so when the session goes quiet, once per burst.
552
+
553
+ There is no "the agent has finished" message in MCP -- an agent that has
554
+ stopped and one that is thinking are the same silence -- so this reports
555
+ the silence rather than claiming to know what caused it: what ran, how
556
+ much of it, and how fast. The generation check is what makes it once per
557
+ burst: every later call bumps the counter, so all but the newest timer
558
+ wake up, find they are stale, and do nothing.
559
+ ]]
560
+ state.generation += 1
561
+ local mine = state.generation
562
+ if mirroring then
563
+ return
564
+ end
565
+ task.delay(QUIET_SECONDS, function()
566
+ if state.generation ~= mine or state.calls == 0 then
567
+ return
568
+ end
569
+ Console.log(
570
+ "dim",
571
+ string.format(
572
+ "agent idle -- %d call%s, avg %.0fms",
573
+ state.calls,
574
+ if state.calls == 1 then "" else "s",
575
+ state.totalMs / state.calls
576
+ )
577
+ )
578
+ Visuals.setQuiet()
579
+ end)
580
+ end
581
+
582
+ --[[
583
+ Pins a line of text beside the activity strip.
584
+
585
+ Unlike the caption a running command sets, this survives until something
586
+ else replaces it, which is what a session that will never run a command
587
+ needs: the standby client view has nothing to report and no reason to say
588
+ "waiting for a command" forever when it is not waiting for one.
589
+ ]]
590
+ function Console.setCaption(message: string)
591
+ Visuals.setCaption(message)
592
+ end
593
+
594
+ --[[
595
+ Announces that an MCP client disconnected.
596
+
597
+ This is the one moment the bridge can be certain a session ended rather than
598
+ paused -- the client's process is gone -- so it is the one place the console
599
+ is allowed to state it outright. Everything else it knows about agent
600
+ activity is inference, and is worded as inference.
601
+ ]]
602
+ function Console.agentFinished()
603
+ --[[
604
+ Named for what happened, not for what it might have meant.
605
+
606
+ This said "Agent finished task." and that was overclaiming: the bridge
607
+ sees a client disconnect and nothing more. Quitting the editor, a crash
608
+ and a restart all arrive here identically, and the first time one was
609
+ watched live it announced a completed task for an agent that had simply
610
+ been closed mid-idle.
611
+ ]]
612
+ Console.log("ok", "Agent disconnected.", "client left")
613
+ Visuals.setQuiet()
614
+ end
615
+
616
+ --[[
617
+ How many MCP clients are sharing this bridge.
618
+
619
+ Reported only above one. A single client is the ordinary case and needs no
620
+ badge -- a permanent "1 client connected" is a label, not a signal, and the
621
+ header has better uses for the width.
622
+ ]]
623
+ function Console.setClients(count: number)
624
+ local previous = state.clients
625
+ state.clients = count
626
+
627
+ local chip = state.clientsChip
628
+ if chip then
629
+ chip.Visible = count > 1
630
+ -- Pluralised even though the badge hides at one. A string that is only
631
+ -- ever correct because nobody can see it is a trap for whoever changes
632
+ -- the visibility rule later.
633
+ chip.Text = string.format(
634
+ "\u{25C6} %d client%s",
635
+ count,
636
+ if count == 1 then "" else "s"
637
+ )
638
+ end
639
+
640
+ local meta = state.metaText
641
+ if meta then
642
+ meta.Size = UDim2.new(1, if count > 1 then -486 else -394, 1, 0)
643
+ end
644
+
645
+ --[[
646
+ Only the arrival is worth a row.
647
+
648
+ The departure had one too, and seeing it live made the redundancy plain:
649
+ "one MCP client connected" landed in the same second as "Agent finished
650
+ task.", saying the same thing less well, while the badge disappearing
651
+ said it a third time. The count going up is news because nothing else
652
+ reports it; the count coming down is already covered twice over.
653
+ ]]
654
+ if count > 1 and previous <= 1 then
655
+ Console.log("info", string.format("%d MCP clients connected", count))
656
+ end
657
+ end
658
+
659
+ function Console.clear()
660
+ --[[
661
+ Says what it did, with a timestamp, like everything else in here.
662
+
663
+ A button that empties the screen and leaves no trace is indistinguishable
664
+ from one that crashed the panel. The count is read before the clear and
665
+ written after it, so the first row of the fresh log is the receipt for
666
+ the one that went.
667
+ ]]
668
+ local cleared = state.entries
669
+ table.clear(state.records)
670
+ state.entries = 0
671
+ state.calls = 0
672
+ state.errors = 0
673
+ state.totalMs = 0
674
+ refreshCounters()
675
+ -- The bars belonged to the log that just went. Leaving forty timings from an
676
+ -- erased session on screen while the footer reads "idle" is two answers to
677
+ -- one question.
678
+ Visuals.clearTrace()
679
+ redraw()
680
+
681
+ if cleared > 0 then
682
+ Console.log(
683
+ "dim",
684
+ string.format("cleared %d log%s", cleared, if cleared == 1 then "" else "s")
685
+ )
686
+ end
687
+ end
688
+
689
+ --[[
690
+ Updates the header. Kept separate from the log so the current state is always
691
+ visible without scrolling, however long the session has run.
692
+ ]]
693
+ function Console.setStatus(status: string, meta: string)
694
+ state.status = status
695
+ state.statusMeta = meta
696
+
697
+ local dot = state.statusDot
698
+ local text = state.statusText
699
+ local metaLabel = state.metaText
700
+ if not dot or not text or not metaLabel then
701
+ return
702
+ end
703
+
704
+ -- "standby" is a working state, not a fault: the client half of a playtest
705
+ -- cannot use HTTP and is not meant to connect. Painting it red like a real
706
+ -- disconnection made a correct setup look broken.
707
+ local color = if status == "connected"
708
+ then PALETTE.green
709
+ elseif status == "connecting" then PALETTE.amber
710
+ elseif status == "standby" or status == "mirroring" then PALETTE.dim
711
+ else PALETTE.red
712
+
713
+ dot.TextColor3 = color
714
+ text.Text = string.upper(status)
715
+ text.TextColor3 = color
716
+ metaLabel.Text = meta
717
+
718
+ -- The wireframe takes the same colour as the status light, so the panel
719
+ -- reads as disconnected at a glance even with the header off screen.
720
+ Visuals.setTint(color)
721
+ end
722
+
723
+ --[[
724
+ Records which palette colour an Instance is wearing, and puts it on.
725
+
726
+ The console builds about thirty Instances and copies a colour into each. A
727
+ copy does not follow the palette when the user picks a different preset, so
728
+ the panel would keep the old theme's header, rule, scrollbar and buttons
729
+ while the log underneath repainted -- which looks less like a theme than
730
+ like a half-finished render.
731
+
732
+ Rather than keep thirty named references and re-set thirty properties by
733
+ hand, each one declares the property and palette key it is wearing when it
734
+ is built, and `Console.applyTheme` walks the list. Adding a widget therefore
735
+ cannot forget to theme it: the same call that colours it registers it.
736
+ ]]
737
+ type Role = { instance: Instance, property: string, key: string }
738
+ local roles: { Role } = {}
739
+
740
+ local function themed<T>(instance: T & Instance, property: string, key: string): T
741
+ table.insert(roles, { instance = instance, property = property, key = key })
742
+ ;(instance :: any)[property] = PALETTE[key]
743
+ return instance
744
+ end
745
+
746
+ local function makeButton(parent: Instance, text: string, order: number): TextButton
747
+ local button = Instance.new("TextButton")
748
+ button.Name = text
749
+ button.Text = text
750
+ button.Font = Enum.Font.Code
751
+ button.TextSize = 11
752
+ themed(button, "TextColor3", "dim")
753
+ themed(button, "BackgroundColor3", "background")
754
+ button.AutoButtonColor = false
755
+ button.BorderSizePixel = 0
756
+ button.Size = UDim2.new(0, 76, 0, 20)
757
+ button.LayoutOrder = order
758
+ button.Parent = parent
759
+
760
+ local corner = Instance.new("UICorner")
761
+ corner.CornerRadius = UDim.new(0, 3)
762
+ corner.Parent = button
763
+
764
+ local stroke = Instance.new("UIStroke")
765
+ themed(stroke, "Color", "dim")
766
+ stroke.Transparency = 0.6
767
+ stroke.Parent = button
768
+
769
+ button.MouseEnter:Connect(function()
770
+ button.TextColor3 = PALETTE.violet
771
+ stroke.Color = PALETTE.violet
772
+ stroke.Transparency = 0.3
773
+ end)
774
+ button.MouseLeave:Connect(function()
775
+ button.TextColor3 = PALETTE.dim
776
+ stroke.Color = PALETTE.dim
777
+ stroke.Transparency = 0.6
778
+ end)
779
+
780
+ return button
781
+ end
782
+
783
+ export type Handlers = {
784
+ onReconnect: () -> (),
785
+ onClear: () -> (),
786
+ -- Called after a preset switch has already been applied, so the caller's
787
+ -- only job is to remember it. Persistence lives there because
788
+ -- `plugin:SetSetting` is not reachable from a ModuleScript.
789
+ onTheme: (string) -> (),
790
+ }
791
+
792
+ --[[
793
+ Builds the widget contents. Colours are fixed rather than theme-derived: this
794
+ is a console, and a console that repaints itself light grey reads as a form.
795
+ ]]
796
+ function Console.mount(parent: Instance, handlers: Handlers)
797
+ local root = Instance.new("Frame")
798
+ root.Size = UDim2.fromScale(1, 1)
799
+ themed(root, "BackgroundColor3", "background")
800
+ root.BorderSizePixel = 0
801
+ root.Parent = parent
802
+
803
+ -- Header ---------------------------------------------------------------
804
+ local header = Instance.new("Frame")
805
+ header.Size = UDim2.new(1, 0, 0, 32)
806
+ themed(header, "BackgroundColor3", "surface")
807
+ header.BorderSizePixel = 0
808
+ header.Parent = root
809
+
810
+ local headerPadding = Instance.new("UIPadding")
811
+ headerPadding.PaddingLeft = UDim.new(0, 12)
812
+ headerPadding.PaddingRight = UDim.new(0, 8)
813
+ headerPadding.Parent = header
814
+
815
+ local dot = Instance.new("TextLabel")
816
+ dot.Text = "\u{25CF}"
817
+ dot.Font = Enum.Font.Code
818
+ dot.TextSize = 13
819
+ -- Deliberately NOT registered with `themed`. Red here is the value it
820
+ -- STARTS at, not the colour it wears: what it should be is whatever
821
+ -- `setStatus` last reported. Registering it meant every theme switch
822
+ -- repainted a connected session red.
823
+ dot.TextColor3 = PALETTE.red
824
+ dot.BackgroundTransparency = 1
825
+ dot.Size = UDim2.new(0, 12, 1, 0)
826
+ dot.Parent = header
827
+ state.statusDot = dot
828
+
829
+ local status = Instance.new("TextLabel")
830
+ status.Text = "DISCONNECTED"
831
+ status.Font = Enum.Font.Code
832
+ status.TextSize = 12
833
+ status.TextColor3 = PALETTE.red
834
+ status.TextXAlignment = Enum.TextXAlignment.Left
835
+ status.BackgroundTransparency = 1
836
+ status.Position = UDim2.new(0, 18, 0, 0)
837
+ status.Size = UDim2.new(0, 110, 1, 0)
838
+ status.Parent = header
839
+ state.statusText = status
840
+
841
+ local meta = Instance.new("TextLabel")
842
+ meta.Text = ""
843
+ meta.Font = Enum.Font.Code
844
+ meta.TextSize = 11
845
+ themed(meta, "TextColor3", "dim")
846
+ meta.TextXAlignment = Enum.TextXAlignment.Left
847
+ meta.TextTruncate = Enum.TextTruncate.AtEnd
848
+ meta.BackgroundTransparency = 1
849
+ meta.Position = UDim2.new(0, 132, 0, 0)
850
+ meta.Size = UDim2.new(1, -394, 1, 0)
851
+ meta.Parent = header
852
+ state.metaText = meta
853
+
854
+ --[[
855
+ The shared-bridge badge, hidden until there is something to share.
856
+
857
+ Sits between the meta line and the buttons rather than in the activity
858
+ band, because it is a fact about the connection and the header is where
859
+ connection facts live. `meta` gives up the width when it appears; see
860
+ `Console.setClients`.
861
+ ]]
862
+ local clientsChip = Instance.new("TextLabel")
863
+ clientsChip.Name = "Clients"
864
+ clientsChip.AnchorPoint = Vector2.new(1, 0.5)
865
+ clientsChip.Position = UDim2.new(1, -252, 0.5, 0)
866
+ clientsChip.Size = UDim2.new(0, 84, 0, 18)
867
+ themed(clientsChip, "BackgroundColor3", "background")
868
+ clientsChip.BorderSizePixel = 0
869
+ clientsChip.Font = Enum.Font.Code
870
+ clientsChip.TextSize = 11
871
+ themed(clientsChip, "TextColor3", "cyan")
872
+ clientsChip.Text = ""
873
+ clientsChip.Visible = false
874
+ clientsChip.Parent = header
875
+ state.clientsChip = clientsChip
876
+
877
+ local chipCorner = Instance.new("UICorner")
878
+ chipCorner.CornerRadius = UDim.new(0, 3)
879
+ chipCorner.Parent = clientsChip
880
+
881
+ local chipStroke = Instance.new("UIStroke")
882
+ themed(chipStroke, "Color", "cyan")
883
+ chipStroke.Transparency = 0.6
884
+ chipStroke.Parent = clientsChip
885
+
886
+ local buttons = Instance.new("Frame")
887
+ buttons.AnchorPoint = Vector2.new(1, 0.5)
888
+ buttons.Position = UDim2.new(1, 0, 0.5, 0)
889
+ buttons.Size = UDim2.new(0, 244, 0, 20)
890
+ buttons.BackgroundTransparency = 1
891
+ buttons.Parent = header
892
+
893
+ local buttonLayout = Instance.new("UIListLayout")
894
+ buttonLayout.FillDirection = Enum.FillDirection.Horizontal
895
+ buttonLayout.HorizontalAlignment = Enum.HorizontalAlignment.Right
896
+ buttonLayout.VerticalAlignment = Enum.VerticalAlignment.Center
897
+ buttonLayout.Padding = UDim.new(0, 6)
898
+ buttonLayout.SortOrder = Enum.SortOrder.LayoutOrder
899
+ buttonLayout.Parent = buttons
900
+
901
+ local visualsButton = makeButton(buttons, "visuals", 1)
902
+ makeButton(buttons, "reconnect", 2).MouseButton1Click:Connect(handlers.onReconnect)
903
+ makeButton(buttons, "clear", 3).MouseButton1Click:Connect(handlers.onClear)
904
+
905
+ -- Accent rule under the header. One hairline in the signature violet is what
906
+ -- separates this from every other grey dock in Studio.
907
+ local rule = Instance.new("Frame")
908
+ rule.Position = UDim2.new(0, 0, 0, 32)
909
+ rule.Size = UDim2.new(1, 0, 0, 1)
910
+ themed(rule, "BackgroundColor3", "rule")
911
+ rule.BorderSizePixel = 0
912
+ rule.Parent = root
913
+
914
+ local ruleFade = Instance.new("UIGradient")
915
+ ruleFade.Color = ColorSequence.new(PALETTE.rule)
916
+ state.ruleFade = ruleFade
917
+ ruleFade.Transparency = NumberSequence.new({
918
+ NumberSequenceKeypoint.new(0, 0.15),
919
+ NumberSequenceKeypoint.new(1, 0.85),
920
+ })
921
+ ruleFade.Parent = rule
922
+
923
+ -- Log ------------------------------------------------------------------
924
+ local scroller = Instance.new("ScrollingFrame")
925
+ scroller.Position = UDim2.new(0, 0, 0, 33)
926
+ scroller.Size = UDim2.new(1, 0, 1, -55)
927
+ scroller.BackgroundTransparency = 1
928
+ scroller.BorderSizePixel = 0
929
+ scroller.ScrollBarThickness = 5
930
+ themed(scroller, "ScrollBarImageColor3", "violet")
931
+ scroller.ScrollBarImageTransparency = 0.5
932
+ scroller.CanvasSize = UDim2.new()
933
+ scroller.AutomaticCanvasSize = Enum.AutomaticSize.Y
934
+ scroller.ScrollingDirection = Enum.ScrollingDirection.Y
935
+ scroller.Parent = root
936
+ state.scroller = scroller
937
+
938
+ local logPadding = Instance.new("UIPadding")
939
+ logPadding.PaddingTop = UDim.new(0, 8)
940
+ logPadding.PaddingBottom = UDim.new(0, 8)
941
+ logPadding.PaddingLeft = UDim.new(0, 12)
942
+ logPadding.PaddingRight = UDim.new(0, 12)
943
+ logPadding.Parent = scroller
944
+
945
+ local label = Instance.new("TextLabel")
946
+ label.Size = UDim2.new(1, 0, 0, 0)
947
+ label.AutomaticSize = Enum.AutomaticSize.Y
948
+ label.BackgroundTransparency = 1
949
+ label.Font = Enum.Font.Code
950
+ label.TextSize = 12
951
+ label.LineHeight = 1.25
952
+ themed(label, "TextColor3", "text")
953
+ label.RichText = true
954
+ label.TextWrapped = true
955
+ label.TextXAlignment = Enum.TextXAlignment.Left
956
+ label.TextYAlignment = Enum.TextYAlignment.Top
957
+ label.Text = ""
958
+ label.Parent = scroller
959
+ state.label = label
960
+
961
+ -- Status bar ------------------------------------------------------------
962
+ local footer = Instance.new("Frame")
963
+ footer.AnchorPoint = Vector2.new(0, 1)
964
+ footer.Position = UDim2.fromScale(0, 1)
965
+ footer.Size = UDim2.new(1, 0, 0, 22)
966
+ themed(footer, "BackgroundColor3", "surface")
967
+ footer.BorderSizePixel = 0
968
+ footer.Parent = root
969
+
970
+ local footerPadding = Instance.new("UIPadding")
971
+ footerPadding.PaddingLeft = UDim.new(0, 12)
972
+ footerPadding.PaddingRight = UDim.new(0, 12)
973
+ footerPadding.Parent = footer
974
+
975
+ local prompt = Instance.new("TextLabel")
976
+ prompt.Text = "rbx\u{00B7}studio"
977
+ prompt.Font = Enum.Font.Code
978
+ prompt.TextSize = 11
979
+ themed(prompt, "TextColor3", "violet")
980
+ prompt.TextXAlignment = Enum.TextXAlignment.Left
981
+ prompt.BackgroundTransparency = 1
982
+ prompt.Size = UDim2.new(0, 70, 1, 0)
983
+ prompt.Parent = footer
984
+
985
+ --[[
986
+ No cursor here.
987
+
988
+ A blinking block after a prompt is the universal sign that something is
989
+ waiting to be typed into, and nothing in this panel accepts input. It
990
+ was there to prove the widget was live, which the activity band now does
991
+ honestly, by moving only when there is something to move about.
992
+ ]]
993
+
994
+ local counters = Instance.new("TextLabel")
995
+ counters.Text = "idle"
996
+ counters.Font = Enum.Font.Code
997
+ counters.TextSize = 11
998
+ themed(counters, "TextColor3", "dim")
999
+ counters.TextXAlignment = Enum.TextXAlignment.Right
1000
+ counters.BackgroundTransparency = 1
1001
+ counters.AnchorPoint = Vector2.new(1, 0)
1002
+ counters.Position = UDim2.fromScale(1, 0)
1003
+ counters.Size = UDim2.new(1, -90, 1, 0)
1004
+ counters.Parent = footer
1005
+ state.countersText = counters
1006
+
1007
+ --[[
1008
+ The band sits above the log and pushes it down, rather than over it.
1009
+
1010
+ The first version covered the log and was slightly transparent, so the
1011
+ thing you actually read was both hidden and softened. Decoration that
1012
+ costs legibility is a bad trade however good it looks, and this is a
1013
+ console before it is anything else.
1014
+ ]]
1015
+ Visuals.mount(root)
1016
+ local band = root:FindFirstChild("ActivityBand") :: Frame
1017
+ band.Position = UDim2.new(0, 0, 0, 33)
1018
+ band.Size = UDim2.new(1, 0, 0, Visuals.BAND_HEIGHT)
1019
+
1020
+ -- The log's top edge follows the band, so turning it on never hides a line.
1021
+ local function layoutLog()
1022
+ local top = 33 + (if Visuals.isVisible() then Visuals.BAND_HEIGHT else 0)
1023
+ scroller.Position = UDim2.new(0, 0, 0, top)
1024
+ scroller.Size = UDim2.new(1, 0, 1, -(top + 22))
1025
+ end
1026
+
1027
+ -- On by default. It is the part that says the session is alive, and a signal
1028
+ -- nobody discovers is not a signal; anyone who wants the extra 44px back can
1029
+ -- turn it off in one click.
1030
+ Visuals.setVisible(true)
1031
+ visualsButton.Text = utf8.char(0x25C6) .. " visuals"
1032
+ Visuals.setIdle()
1033
+ layoutLog()
1034
+
1035
+ visualsButton.MouseButton1Click:Connect(function()
1036
+ Visuals.setVisible(not Visuals.isVisible())
1037
+ layoutLog()
1038
+ -- The button reports the state it is in, not the state it would move to.
1039
+ -- A toggle that reads as an instruction is ambiguous the moment you look
1040
+ -- away and back.
1041
+ visualsButton.Text = if Visuals.isVisible() then "\u{25C6} visuals" else "visuals"
1042
+ end)
1043
+
1044
+ --[[
1045
+ The preset drawer, mounted last so its tab sits over everything.
1046
+
1047
+ It has to be the last child of `root` as well as the highest ZIndex: the
1048
+ band, the log and the footer are all built before it and a drawer that
1049
+ opens behind the log is a drawer nobody can click.
1050
+ ]]
1051
+ ThemePicker.mount(root, function(id)
1052
+ Console.applyTheme()
1053
+ ThemePicker.applyTheme()
1054
+ layoutLog()
1055
+ handlers.onTheme(id)
1056
+ end)
1057
+
1058
+ -- Treat "scrolled away from the bottom" as the user reading history, and
1059
+ -- stop yanking the view down under them until they scroll back.
1060
+ scroller:GetPropertyChangedSignal("CanvasPosition"):Connect(function()
1061
+ local maxScroll = math.max(0, scroller.AbsoluteCanvasSize.Y - scroller.AbsoluteWindowSize.Y)
1062
+ state.pinned = maxScroll - scroller.CanvasPosition.Y < 24
1063
+ end)
1064
+ end
1065
+
1066
+ --[[
1067
+ Switches the whole panel to the active preset.
1068
+
1069
+ Three things have to move together or the switch looks broken: the palette
1070
+ this file reads, the Instances that copied a colour out of it, and the log,
1071
+ whose rows are re-rendered from records rather than recoloured in place. The
1072
+ band goes last because remounting a preset is the expensive part and there
1073
+ is no reason to make the text wait for it.
1074
+ ]]
1075
+ function Console.applyTheme()
1076
+ PALETTE = Themes.palette()
1077
+
1078
+ for _, role in roles do
1079
+ -- A widget can outlive its registration if the panel is rebuilt, and
1080
+ -- writing to a destroyed Instance throws.
1081
+ if role.instance.Parent ~= nil or role.instance:IsA("UIStroke") then
1082
+ pcall(function()
1083
+ (role.instance :: any)[role.property] = PALETTE[role.key]
1084
+ end)
1085
+ end
1086
+ end
1087
+
1088
+ local ruleFade = state.ruleFade
1089
+ if ruleFade then
1090
+ ruleFade.Color = ColorSequence.new(PALETTE.rule)
1091
+ end
1092
+
1093
+ refreshCounters()
1094
+ -- Replayed rather than recoloured: the status light's colour is a fact about
1095
+ -- the connection, and the only thing that knows it is `setStatus`.
1096
+ Console.setStatus(state.status, state.statusMeta)
1097
+ redraw()
1098
+ Visuals.applyTheme()
1099
+ end
1100
+
1101
+ return Console