@el4cteo/rbx-studio-mcp 0.5.6 → 0.5.9

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