@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.
@@ -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 detail into lines at word boundaries.
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, "&", "&amp;")
355
- escaped = string.gsub(escaped, "<", "&lt;")
356
- escaped = string.gsub(escaped, ">", "&gt;")
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 (utf8.len(record.message) or #record.message) + detailWidth + 3
394
- <= DETAIL_COLUMN + INLINE_DETAIL
443
+ and messageWidth <= budget
444
+ and messageWidth + detailWidth + 3 <= DETAIL_COLUMN + INLINE_DETAIL
395
445
 
396
446
  local trimmed = record.message
397
- local budget = DETAIL_COLUMN - 3
398
- if inlineDetail and (utf8.len(trimmed) or #trimmed) > budget then
399
- -- Cut rather than wrap. A wrapped line destroys the alignment and
400
- -- carries the detail off the end of the visible width as well.
401
- local offset = utf8.offset(trimmed, budget) or budget
402
- trimmed = string.sub(trimmed, 1, offset - 1) .. utf8.char(0x2026)
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) .. " " .. span(PALETTE[spec.key], 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
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 wrapDetail(detail) do
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
- for _, line in renderRecord(record) do
443
- table.insert(lines, line)
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, 22)
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 + 22))
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
- Visuals.setVisible(not Visuals.isVisible())
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, "&", "&amp;")
66
+ escaped = string.gsub(escaped, "<", "&lt;")
67
+ escaped = string.gsub(escaped, ">", "&gt;")
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