@el4cteo/rbx-studio-mcp 0.4.6 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -1
- package/dist/bridge/api.js +22 -3
- package/dist/bridge/api.js.map +1 -1
- package/dist/bridge/console.js +348 -0
- package/dist/bridge/console.js.map +1 -0
- package/dist/bridge/harness.js +883 -0
- package/dist/bridge/harness.js.map +1 -0
- package/dist/bridge/remote.js +9 -1
- package/dist/bridge/remote.js.map +1 -1
- package/dist/bridge/rpc.js +69 -4
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +54 -6
- package/dist/bridge/server.js.map +1 -1
- package/dist/doctor.js +13 -1
- package/dist/doctor.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/protocol.js.map +1 -1
- package/dist/tools/screenshot.js +7 -6
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/terrain.js +116 -0
- package/dist/tools/terrain.js.map +1 -0
- package/package.json +4 -4
- package/plugin/src/Commands.luau +524 -0
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +282 -49
- package/plugin/src/Format.luau +114 -0
- package/plugin/src/Prompt.luau +436 -0
- package/plugin/src/ScriptEdit.luau +48 -0
- package/plugin/src/Visuals.luau +1409 -1332
- package/plugin/src/handlers/Capture.luau +68 -20
- package/plugin/src/handlers/Terrain.luau +371 -0
- package/plugin/src/init.server.luau +829 -725
- package/scripts/test-console.mjs +529 -0
- package/scripts/test-failover.mjs +47 -0
- package/scripts/test-plugin.mjs +85 -82
package/plugin/src/Console.luau
CHANGED
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
|
|
21
21
|
local TweenService = game:GetService("TweenService")
|
|
22
22
|
|
|
23
|
+
local Format = require(script.Parent.Format)
|
|
24
|
+
local Prompt = require(script.Parent.Prompt)
|
|
23
25
|
local ThemePicker = require(script.Parent.ThemePicker)
|
|
24
26
|
local Themes = require(script.Parent.Themes)
|
|
25
27
|
local Visuals = require(script.Parent.Visuals)
|
|
@@ -71,6 +73,10 @@ local Z_CRT_LINE = 22
|
|
|
71
73
|
]]
|
|
72
74
|
local MAX_RECORDS = 300
|
|
73
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
|
+
|
|
74
80
|
--[[
|
|
75
81
|
The active preset's colours, rebound rather than re-read.
|
|
76
82
|
|
|
@@ -166,29 +172,16 @@ local DETAIL_INDENT = string.rep(" ", 11)
|
|
|
166
172
|
local DETAIL_WRAP = 74
|
|
167
173
|
|
|
168
174
|
--[[
|
|
169
|
-
Breaks a long
|
|
175
|
+
Breaks a long string into lines at word boundaries.
|
|
170
176
|
|
|
171
177
|
Roblox's own TextWrapped would do this, and would wrap to column 0 -- there
|
|
172
178
|
is no hanging indent for a TextLabel -- which is the ragged shape this
|
|
173
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.
|
|
174
184
|
]]
|
|
175
|
-
local function wrapDetail(detail: string): { string }
|
|
176
|
-
local lines: { string } = {}
|
|
177
|
-
local current = ""
|
|
178
|
-
for word in string.gmatch(detail, "%S+") do
|
|
179
|
-
local candidate = if current == "" then word else current .. " " .. word
|
|
180
|
-
if (utf8.len(candidate) or #candidate) > DETAIL_WRAP and current ~= "" then
|
|
181
|
-
table.insert(lines, current)
|
|
182
|
-
current = word
|
|
183
|
-
else
|
|
184
|
-
current = candidate
|
|
185
|
-
end
|
|
186
|
-
end
|
|
187
|
-
if current ~= "" then
|
|
188
|
-
table.insert(lines, current)
|
|
189
|
-
end
|
|
190
|
-
return lines
|
|
191
|
-
end
|
|
192
185
|
|
|
193
186
|
--[[
|
|
194
187
|
One logged row, before it is coloured.
|
|
@@ -297,6 +290,26 @@ type State = {
|
|
|
297
290
|
away.
|
|
298
291
|
]]
|
|
299
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?,
|
|
300
313
|
}
|
|
301
314
|
|
|
302
315
|
local state: State = {
|
|
@@ -328,6 +341,9 @@ local state: State = {
|
|
|
328
341
|
crtGhost = nil,
|
|
329
342
|
crtLine = nil,
|
|
330
343
|
crtGeneration = 0,
|
|
344
|
+
filter = nil,
|
|
345
|
+
relayout = nil,
|
|
346
|
+
visualsButton = nil,
|
|
331
347
|
}
|
|
332
348
|
|
|
333
349
|
-- Declared ahead of its definition: `setClients` calls it and is written
|
|
@@ -348,19 +364,40 @@ local function hex(color: Color3): string
|
|
|
348
364
|
)
|
|
349
365
|
end
|
|
350
366
|
|
|
351
|
-
-- RichText is markup, so anything user- or engine-supplied has to be escaped or
|
|
352
|
-
-- a stray `<` in an error message silently eats the rest of the line.
|
|
353
|
-
local function escape(value: string): string
|
|
354
|
-
local escaped = string.gsub(value, "&", "&")
|
|
355
|
-
escaped = string.gsub(escaped, "<", "<")
|
|
356
|
-
escaped = string.gsub(escaped, ">", ">")
|
|
357
|
-
return escaped
|
|
358
|
-
end
|
|
359
367
|
|
|
360
368
|
local function span(color: Color3, value: string): string
|
|
361
|
-
return string.format('<font color="%s">%s</font>', hex(color), escape(value))
|
|
369
|
+
return string.format('<font color="%s">%s</font>', hex(color), Format.escape(value))
|
|
362
370
|
end
|
|
363
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
|
+
|
|
364
401
|
--[[
|
|
365
402
|
Turns one record into the lines it occupies.
|
|
366
403
|
|
|
@@ -389,22 +426,73 @@ local function renderRecord(record: Record): { string }
|
|
|
389
426
|
goes on its own line below would shorten it for nothing.
|
|
390
427
|
]]
|
|
391
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
|
+
]]
|
|
392
442
|
local inlineDetail = detail ~= nil
|
|
393
|
-
and
|
|
394
|
-
|
|
443
|
+
and messageWidth <= budget
|
|
444
|
+
and messageWidth + detailWidth + 3 <= DETAIL_COLUMN + INLINE_DETAIL
|
|
395
445
|
|
|
396
446
|
local trimmed = record.message
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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)
|
|
403
485
|
end
|
|
404
486
|
|
|
405
487
|
local body = string.format("%s %s", spec.sigil, trimmed)
|
|
406
488
|
local bodyWidth = utf8.len(body) or #body
|
|
407
|
-
local lines = { span(PALETTE.dim, record.stamp) .. " " ..
|
|
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
|
|
408
496
|
|
|
409
497
|
--[[
|
|
410
498
|
Short details ride the right-hand column; long ones get their own lines.
|
|
@@ -422,7 +510,7 @@ local function renderRecord(record: Record): { string }
|
|
|
422
510
|
local padding = math.max(1, DETAIL_COLUMN - bodyWidth)
|
|
423
511
|
lines[1] ..= span(PALETTE.dim, string.rep(" ", padding) .. detail)
|
|
424
512
|
else
|
|
425
|
-
for _, wrapped in
|
|
513
|
+
for _, wrapped in Format.wrap(detail, DETAIL_WRAP) do
|
|
426
514
|
table.insert(lines, span(PALETTE.dim, DETAIL_INDENT .. wrapped))
|
|
427
515
|
end
|
|
428
516
|
end
|
|
@@ -438,9 +526,12 @@ local function paint()
|
|
|
438
526
|
end
|
|
439
527
|
|
|
440
528
|
local lines: { string } = {}
|
|
529
|
+
local filter = state.filter
|
|
441
530
|
for _, record in state.records do
|
|
442
|
-
|
|
443
|
-
|
|
531
|
+
if filter == nil or filter[record.level] then
|
|
532
|
+
for _, line in renderRecord(record) do
|
|
533
|
+
table.insert(lines, line)
|
|
534
|
+
end
|
|
444
535
|
end
|
|
445
536
|
end
|
|
446
537
|
label.Text = table.concat(lines, "\n")
|
|
@@ -511,8 +602,8 @@ end
|
|
|
511
602
|
function Console.log(level: Level, message: string, detail: string?)
|
|
512
603
|
table.insert(state.records, {
|
|
513
604
|
level = level,
|
|
514
|
-
message = message,
|
|
515
|
-
detail = detail,
|
|
605
|
+
message = Format.flatten(message),
|
|
606
|
+
detail = if detail ~= nil then Format.flatten(detail) else nil,
|
|
516
607
|
-- Stamped when the row happened, not when it is painted. A repaint after
|
|
517
608
|
-- a theme switch re-renders every line, and re-reading the clock there
|
|
518
609
|
-- would restamp the whole session to the moment the user changed colour.
|
|
@@ -667,6 +758,19 @@ function Console.recordCall(ok: boolean, milliseconds: number)
|
|
|
667
758
|
if state.generation ~= mine or state.calls == 0 then
|
|
668
759
|
return
|
|
669
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
|
|
670
774
|
Console.log(
|
|
671
775
|
"dim",
|
|
672
776
|
string.format(
|
|
@@ -1185,6 +1289,17 @@ export type Handlers = {
|
|
|
1185
1289
|
-- only job is to remember it. Persistence lives there because
|
|
1186
1290
|
-- `plugin:SetSetting` is not reachable from a ModuleScript.
|
|
1187
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 },
|
|
1188
1303
|
}
|
|
1189
1304
|
|
|
1190
1305
|
--[[
|
|
@@ -1459,7 +1574,7 @@ function Console.mount(parent: Instance, handlers: Handlers)
|
|
|
1459
1574
|
local footer = Instance.new("Frame")
|
|
1460
1575
|
footer.AnchorPoint = Vector2.new(0, 1)
|
|
1461
1576
|
footer.Position = UDim2.fromScale(0, 1)
|
|
1462
|
-
footer.Size = UDim2.new(1, 0, 0,
|
|
1577
|
+
footer.Size = UDim2.new(1, 0, 0, FOOTER_HEIGHT)
|
|
1463
1578
|
themed(footer, "BackgroundColor3", "surface")
|
|
1464
1579
|
footer.BorderSizePixel = 0
|
|
1465
1580
|
footer.Parent = root
|
|
@@ -1509,17 +1624,38 @@ function Console.mount(parent: Instance, handlers: Handlers)
|
|
|
1509
1624
|
costs legibility is a bad trade however good it looks, and this is a
|
|
1510
1625
|
console before it is anything else.
|
|
1511
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
|
+
|
|
1512
1646
|
Visuals.mount(root)
|
|
1513
1647
|
local band = root:FindFirstChild("ActivityBand") :: Frame
|
|
1514
1648
|
band.Position = UDim2.new(0, 0, 0, 33)
|
|
1515
1649
|
band.Size = UDim2.new(1, 0, 0, Visuals.BAND_HEIGHT)
|
|
1516
1650
|
|
|
1517
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.
|
|
1518
1653
|
local function layoutLog()
|
|
1519
1654
|
local top = 33 + (if Visuals.isVisible() then Visuals.BAND_HEIGHT else 0)
|
|
1520
1655
|
scroller.Position = UDim2.new(0, 0, 0, top)
|
|
1521
|
-
scroller.Size = UDim2.new(1, 0, 1, -(top +
|
|
1656
|
+
scroller.Size = UDim2.new(1, 0, 1, -(top + FOOTER_HEIGHT + Prompt.HEIGHT))
|
|
1522
1657
|
end
|
|
1658
|
+
state.relayout = layoutLog
|
|
1523
1659
|
|
|
1524
1660
|
-- On by default. It is the part that says the session is alive, and a signal
|
|
1525
1661
|
-- nobody discovers is not a signal; anyone who wants the extra 44px back can
|
|
@@ -1529,13 +1665,9 @@ function Console.mount(parent: Instance, handlers: Handlers)
|
|
|
1529
1665
|
Visuals.setIdle()
|
|
1530
1666
|
layoutLog()
|
|
1531
1667
|
|
|
1668
|
+
state.visualsButton = visualsButton
|
|
1532
1669
|
visualsButton.MouseButton1Click:Connect(function()
|
|
1533
|
-
|
|
1534
|
-
layoutLog()
|
|
1535
|
-
-- The button reports the state it is in, not the state it would move to.
|
|
1536
|
-
-- A toggle that reads as an instruction is ambiguous the moment you look
|
|
1537
|
-
-- away and back.
|
|
1538
|
-
visualsButton.Text = if Visuals.isVisible() then "\u{25C6} visuals" else "visuals"
|
|
1670
|
+
Console.toggleVisuals()
|
|
1539
1671
|
end)
|
|
1540
1672
|
|
|
1541
1673
|
--[[
|
|
@@ -1560,6 +1692,106 @@ function Console.mount(parent: Instance, handlers: Handlers)
|
|
|
1560
1692
|
end)
|
|
1561
1693
|
end
|
|
1562
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
|
+
|
|
1563
1795
|
--[[
|
|
1564
1796
|
Switches the whole panel to the active preset.
|
|
1565
1797
|
|
|
@@ -1592,6 +1824,7 @@ function Console.applyTheme()
|
|
|
1592
1824
|
-- the connection, and the only thing that knows it is `setStatus`.
|
|
1593
1825
|
Console.setStatus(state.status, state.statusMeta)
|
|
1594
1826
|
redraw()
|
|
1827
|
+
Prompt.applyTheme()
|
|
1595
1828
|
Visuals.applyTheme()
|
|
1596
1829
|
end
|
|
1597
1830
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Text shaping for the console log.
|
|
4
|
+
|
|
5
|
+
Four pure functions, pulled out of `Console` so they can be tested. That is
|
|
6
|
+
the whole reason this file exists, and it is a good one: every function here
|
|
7
|
+
has already shipped a visible bug -- a paragraph that wrapped back to column
|
|
8
|
+
0 under the timestamps, a stray "m" on its own line, raw `**` in the middle
|
|
9
|
+
of a sentence -- and each of those was found by looking at a screenshot
|
|
10
|
+
rather than by a test, because `Console` requires half of Studio and cannot
|
|
11
|
+
be bundled outside it.
|
|
12
|
+
|
|
13
|
+
Nothing in here requires anything, touches an Instance, or reads a palette.
|
|
14
|
+
Colour arrives as a hex string the caller has already resolved, so the
|
|
15
|
+
module never needs Color3 and the same code runs under the standalone Luau
|
|
16
|
+
interpreter that `npm test` drives.
|
|
17
|
+
]]
|
|
18
|
+
|
|
19
|
+
local Format = {}
|
|
20
|
+
|
|
21
|
+
--[[
|
|
22
|
+
Breaks a long string into lines at word boundaries.
|
|
23
|
+
|
|
24
|
+
Roblox's own TextWrapped would do this, and would wrap to column 0 -- there
|
|
25
|
+
is no hanging indent for a TextLabel -- which is the ragged shape this
|
|
26
|
+
replaces. Wrapping here means every continuation line can carry the indent.
|
|
27
|
+
|
|
28
|
+
A single word longer than the width is cut rather than allowed to overhang.
|
|
29
|
+
That case is not prose: it is a path, a URL or a base64 blob, and letting one
|
|
30
|
+
of those push the line out re-creates the exact wrap this exists to prevent.
|
|
31
|
+
]]
|
|
32
|
+
function Format.wrap(text: string, width: number): { string }
|
|
33
|
+
local lines: { string } = {}
|
|
34
|
+
local current = ""
|
|
35
|
+
|
|
36
|
+
local function flush()
|
|
37
|
+
if current ~= "" then
|
|
38
|
+
table.insert(lines, current)
|
|
39
|
+
current = ""
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
for word in string.gmatch(text, "%S+") do
|
|
44
|
+
while (utf8.len(word) or #word) > width do
|
|
45
|
+
flush()
|
|
46
|
+
local cut = utf8.offset(word, width + 1) or (width + 1)
|
|
47
|
+
table.insert(lines, string.sub(word, 1, cut - 1))
|
|
48
|
+
word = string.sub(word, cut)
|
|
49
|
+
end
|
|
50
|
+
local candidate = if current == "" then word else current .. " " .. word
|
|
51
|
+
if (utf8.len(candidate) or #candidate) > width and current ~= "" then
|
|
52
|
+
flush()
|
|
53
|
+
current = word
|
|
54
|
+
else
|
|
55
|
+
current = candidate
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
flush()
|
|
59
|
+
return lines
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
-- RichText is markup, so anything user- or engine-supplied has to be escaped or
|
|
63
|
+
-- a stray `<` in an error message silently eats the rest of the line.
|
|
64
|
+
function Format.escape(value: string): string
|
|
65
|
+
local escaped = string.gsub(value, "&", "&")
|
|
66
|
+
escaped = string.gsub(escaped, "<", "<")
|
|
67
|
+
escaped = string.gsub(escaped, ">", ">")
|
|
68
|
+
return escaped
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
--[[
|
|
72
|
+
Turns an agent's markdown into the markup this label already speaks.
|
|
73
|
+
|
|
74
|
+
Agents write for a terminal that renders markdown, so their replies arrive
|
|
75
|
+
full of `**` and backticks. Printed raw they are worse than noise -- the
|
|
76
|
+
asterisks land in the middle of a sentence and read as typos -- and stripping
|
|
77
|
+
them would throw away the emphasis the agent chose. The label is RichText, so
|
|
78
|
+
the third option is simply to honour it.
|
|
79
|
+
|
|
80
|
+
Must be applied AFTER `escape`, which is what makes it safe: escaping has
|
|
81
|
+
already turned every angle bracket in the agent's own text into an entity, so
|
|
82
|
+
the only tags in the string afterwards are the ones put there here.
|
|
83
|
+
|
|
84
|
+
Bold before italic, or the outer pair of a `**` run is eaten as two italics.
|
|
85
|
+
Underscores are deliberately not italic markers: `execute_luau` and
|
|
86
|
+
`mcp__rbx-studio__modify` are the vocabulary of this log, and they would
|
|
87
|
+
spend most of their lives in italics for no reason.
|
|
88
|
+
|
|
89
|
+
`code` is a "#RRGGBB" string rather than a Color3 so this module stays free
|
|
90
|
+
of the engine; the caller has a palette and this does not.
|
|
91
|
+
]]
|
|
92
|
+
function Format.emphasise(escaped: string, code: string): string
|
|
93
|
+
local marked = string.gsub(escaped, "%*%*(.-)%*%*", "<b>%1</b>")
|
|
94
|
+
marked = string.gsub(marked, "`([^`]+)`", function(inner: string): string
|
|
95
|
+
return string.format('<font color="%s">%s</font>', code, inner)
|
|
96
|
+
end)
|
|
97
|
+
marked = string.gsub(marked, "%*([^%*]+)%*", "<i>%1</i>")
|
|
98
|
+
return marked
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
--[[
|
|
102
|
+
Collapses anything that would break the one-line-per-row contract.
|
|
103
|
+
|
|
104
|
+
A row is a line. Every width in the console is measured in characters and
|
|
105
|
+
every continuation is indented by hand, so a newline arriving inside a
|
|
106
|
+
message or a detail lands in the middle of that arithmetic and comes out at
|
|
107
|
+
column 0 -- which is how a two-line Luau snippet in a tool argument left a
|
|
108
|
+
bare "m" sitting under the log. Tabs go the same way, for the same reason.
|
|
109
|
+
]]
|
|
110
|
+
function Format.flatten(text: string): string
|
|
111
|
+
return (string.gsub(text, "%s+", " "))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
return Format
|