--!strict
--[[
Text shaping for the console log.
Four pure functions, pulled out of `Console` so they can be tested. That is
the whole reason this file exists, and it is a good one: every function here
has already shipped a visible bug -- a paragraph that wrapped back to column
0 under the timestamps, a stray "m" on its own line, raw `**` in the middle
of a sentence -- and each of those was found by looking at a screenshot
rather than by a test, because `Console` requires half of Studio and cannot
be bundled outside it.
Nothing in here requires anything, touches an Instance, or reads a palette.
Colour arrives as a hex string the caller has already resolved, so the
module never needs Color3 and the same code runs under the standalone Luau
interpreter that `npm test` drives.
]]
local Format = {}
--[[
Breaks a long string into lines at word boundaries.
Roblox's own TextWrapped would do this, and would wrap to column 0 -- there
is no hanging indent for a TextLabel -- which is the ragged shape this
replaces. Wrapping here means every continuation line can carry the indent.
A single word longer than the width is cut rather than allowed to overhang.
That case is not prose: it is a path, a URL or a base64 blob, and letting one
of those push the line out re-creates the exact wrap this exists to prevent.
]]
function Format.wrap(text: string, width: number): { string }
local lines: { string } = {}
local current = ""
local function flush()
if current ~= "" then
table.insert(lines, current)
current = ""
end
end
for word in string.gmatch(text, "%S+") do
while (utf8.len(word) or #word) > width do
flush()
local cut = utf8.offset(word, width + 1) or (width + 1)
table.insert(lines, string.sub(word, 1, cut - 1))
word = string.sub(word, cut)
end
local candidate = if current == "" then word else current .. " " .. word
if (utf8.len(candidate) or #candidate) > width and current ~= "" then
flush()
current = word
else
current = candidate
end
end
flush()
return lines
end
-- RichText is markup, so anything user- or engine-supplied has to be escaped or
-- a stray `<` in an error message silently eats the rest of the line.
function Format.escape(value: string): string
local escaped = string.gsub(value, "&", "&")
escaped = string.gsub(escaped, "<", "<")
escaped = string.gsub(escaped, ">", ">")
return escaped
end
--[[
Turns an agent's markdown into the markup this label already speaks.
Agents write for a terminal that renders markdown, so their replies arrive
full of `**` and backticks. Printed raw they are worse than noise -- the
asterisks land in the middle of a sentence and read as typos -- and stripping
them would throw away the emphasis the agent chose. The label is RichText, so
the third option is simply to honour it.
Must be applied AFTER `escape`, which is what makes it safe: escaping has
already turned every angle bracket in the agent's own text into an entity, so
the only tags in the string afterwards are the ones put there here.
Bold before italic, or the outer pair of a `**` run is eaten as two italics.
Underscores are deliberately not italic markers: `execute_luau` and
`mcp__rbx-studio__modify` are the vocabulary of this log, and they would
spend most of their lives in italics for no reason.
`code` is a "#RRGGBB" string rather than a Color3 so this module stays free
of the engine; the caller has a palette and this does not.
]]
function Format.emphasise(escaped: string, code: string): string
local marked = string.gsub(escaped, "%*%*(.-)%*%*", "%1")
marked = string.gsub(marked, "`([^`]+)`", function(inner: string): string
return string.format('%s', code, inner)
end)
marked = string.gsub(marked, "%*([^%*]+)%*", "%1")
return marked
end
--[[
Collapses anything that would break the one-line-per-row contract.
A row is a line. Every width in the console is measured in characters and
every continuation is indented by hand, so a newline arriving inside a
message or a detail lands in the middle of that arithmetic and comes out at
column 0 -- which is how a two-line Luau snippet in a tool argument left a
bare "m" sitting under the log. Tabs go the same way, for the same reason.
]]
function Format.flatten(text: string): string
return (string.gsub(text, "%s+", " "))
end
return Format