SynagyBridge false Manage Plugins > Synagy, then click Connect." elseif string.match(err, "^HTTP %d+") then return "foreign", "Port " .. port .. " belongs to another program, not the Synagy MCP server." end return "waiting", "Waiting for your AI agent (start it with the Synagy MCP server)." end local function pollLoop(mine) local idle = 2 local query = "/poll?engine=roblox&session=" .. session .. "&app=" .. HttpService:UrlEncode(version()) .. "&project=" .. HttpService:UrlEncode(game.Name) .. "&plugin=" .. VERSION .. "&wait=15" while enabled and mine == generation do local cmd, err = request("GET", query) if mine ~= generation then break end if err then local kind, text = classify(err) if connected then print("Synagy bridge: lost the MCP server (" .. err .. ")") end connected = false Window.setBridge(kind, text) if kind == "blocked" then -- Asking again would only repeat the refusal: wait for the user to click Connect. enabled = false button:SetActive(false) warn("Synagy bridge: " .. text) break end task.wait(idle) idle = math.min(idle + 2, 10) else idle = 2 if not connected then connected = true Window.setBridge("connected", "Your AI agent is connected.") print("Synagy bridge: connected to the MCP server") end if cmd.id then -- Own thread: a snippet that yields must not stop the polling. task.spawn(function() -- The whole result goes back, including data (an Attachment from the screenshot op). request("POST", "/result", Ops.handle(cmd)) end) end end end end local function apply() generation += 1 button:SetActive(enabled) if enabled then task.spawn(pollLoop, generation) else connected = false Window.setBridge("off", "") end end -- Plugins cannot open a browser; the MCP server on this computer can, but only for synagy.org/link. Account.onChange(function(state, info) if state == "code" and connected then task.spawn(request, "POST", "/open", { url = info.verificationUri .. "?code=" .. info.userCode }) end end) button.Click:Connect(function() enabled = not enabled plugin:SetSetting("SynagyEnabled", enabled) print(if enabled then "Synagy bridge: on, waiting for your agent on " .. base() else "Synagy bridge: off") apply() end) -- ── Account button ────────────────────────────────────────────────────────── local accountButton = toolbar:CreateButton("SynagyAccount", "Sign in to your Synagy account (synagy.org) or sign out", "", "Account") accountButton.ClickableWhenViewportHidden = true accountButton.Click:Connect(function() accountButton:SetActive(false) if Account.signingIn() then Account.cancel() elseif Account.token() then Account.signOut() else task.spawn(Account.signIn) end end) plugin.Unloading:Connect(function() Account.cancel() Cloud.stop() enabled = false generation += 1 end) apply() Cloud.start() ]]> Account Capture = MAX_IMPORT then break end local fine, result = pcall(function() local uri = file:GetTemporaryId() local src = readImage(uri) local okEncode, payload = pcall(encode, src, MAX_SIDE) src:Destroy() if not okEncode then error(payload, 0) end return item(file.Name, "Image file", uri, payload) end) if fine then table.insert(items, result) else warn("Synagy: could not import " .. tostring(file.Name) .. ": " .. tostring(result)) end end return items end function Capture.init(_plugin, Ops) if not Ops or not Ops.ACTIONS then return end -- Op `screenshot` (Synagy Cloud judge/critic, MCP engine_screenshot): cmd { maxSize? } → data = Attachment. -- CONTRACT-GAP: Cloud.luau (runOp) and the bridge must pass `data` through to op_result; today they keep only -- ok/output/error/image. Ops.ACTIONS.screenshot = function(cmd) local ok, result = Capture.screenshot({ maxSize = tonumber(cmd and cmd.maxSize) }) if not ok then return { ok = false, error = result } end local p = result.payload return { ok = true, output = string.format("Screenshot of the Studio viewport, %d × %d (%s, %d KB).", p.width, p.height, p.mime .. (if p.compression == "zstd" then " zstd" else ""), math.ceil(#p.data / 1024)), data = p, } end end return Capture ]]> Cloud true while Ops runs it local done = {} -- id -> op_result, kept so a re-sent op gets its answer instead of a second run local doneOrder = {} local unsent = {} -- id -> op_result that finished while offline -- After the first welcome the window still shows the chat, so the cloud need not replay it. local resumable = false local lastSeen = 0 Cloud.status = "signed_out" -- signed_out | connecting | online | offline | update Cloud.detail = "" Cloud.credits = nil Cloud.email = "" Cloud.projectId = "" Cloud.plan = nil -- welcome.plan ("free" | "starter" | "creator" | "studio"); nil from an older cloud function Cloud.init(p, ops, account, pluginVer) plugin, Ops, Account, pluginVersion = p, ops, account, pluginVer end -- Capabilities for hello.caps; set before Cloud.start(). An older cloud ignores the field. function Cloud.setCaps(list) caps = if type(list) == "table" then table.clone(list) else {} end -- The Context module: a chat without its own context/attachments gets Context.payload(). function Cloud.setContext(ctx) Context = ctx end -- fn(msg) for every message from the cloud, plus { t = "status" } when the connection changes. function Cloud.onMessage(fn) table.insert(listeners, fn) end local function tell(msg) for _, fn in listeners do task.spawn(fn, msg) end end local function setStatus(status, detail) Cloud.status, Cloud.detail = status, detail or "" tell({ t = "status", status = status, detail = Cloud.detail }) end function Cloud.url(): string local u = plugin:GetSetting(URL_SETTING) return if type(u) == "string" and u ~= "" then u else DEFAULT_URL end -- The account token only goes to the Synagy cloud, or to this computer (local development): a changed setting -- must not be able to send it to a foreign server. local function safe(url: string): boolean return string.match(url, "^wss://api%.synagy%.org/") ~= nil or string.match(url, "^wss?://127%.0%.0%.1[:/]") ~= nil or string.match(url, "^wss?://localhost[:/]") ~= nil end -- The project this place builds on, so a chat continues after restarting Studio. local function projectKey() return "SynagyProject_" .. (if game.PlaceId ~= 0 then tostring(game.PlaceId) else (string.gsub(game.Name, "%W", "_"))) end local function locale() local ok, id = pcall(function() return game:GetService("LocalizationService").SystemLocaleId end) return if ok and type(id) == "string" and id ~= "" then string.sub(id, 1, 2) else "en" end local MAX_CONTEXT, MAX_ATTACHMENTS = 12, 3 local function first(list, n) local out = {} for i = 1, math.min(#list, n) do out[i] = list[i] end return out end function Cloud.send(msg) if msg.t == "chat" then if msg.context == nil and msg.attachments == nil and Context then local ok, p = pcall(Context.payload) if ok and type(p) == "table" then msg.context, msg.attachments = p.context, p.attachments end end -- Empty lists stay away: an older cloud sees the same chat message as before. msg.context = if type(msg.context) == "table" and #msg.context > 0 then first(msg.context, MAX_CONTEXT) else nil msg.attachments = if type(msg.attachments) == "table" and #msg.attachments > 0 then first(msg.attachments, MAX_ATTACHMENTS) else nil end if client and Cloud.status == "online" then local ok = pcall(function() client:Send(HttpService:JSONEncode(msg)) end) return ok end return false end local connect local function retry(mine, seconds, reason) setStatus("offline", (if reason and reason ~= "" then reason .. " · " else "") .. "Reconnecting in " .. seconds .. " s") task.delay(seconds, function() if mine == generation then connect() end end) end local function remember(r) done[r.id] = r table.insert(doneOrder, r.id) if #doneOrder > 50 then done[table.remove(doneOrder, 1)] = nil end end local function answer(r) if not Cloud.send(r) then unsent[r.id] = r end end local function runOp(msg) if inflight[msg.id] then return end if done[msg.id] then answer(done[msg.id]) return end inflight[msg.id] = true local ok, result = pcall(Ops.handle, msg) if not ok then result = { ok = false, error = tostring(result) } end inflight[msg.id] = nil local r = { t = "op_result", id = msg.id, ok = result.ok == true, output = tostring(result.output or ""), error = tostring(result.error or ""), image = if type(result.image) == "string" then result.image else "" } -- Op "screenshot" answers with an Attachment table in result.data (the old "image" field stays PNG base64). if type(result.data) == "table" then r.data = result.data end remember(r) answer(r) end local function handle(mine, msg) if msg.t == "welcome" then retryIn = 3 resumable = true unsent = {} Cloud.projectId, Cloud.credits, Cloud.email = msg.projectId, msg.credits, msg.email Cloud.plan = if type(msg.plan) == "string" then msg.plan else nil plugin:SetSetting(projectKey(), msg.projectId) setStatus("online") elseif msg.t == "op" then task.spawn(runOp, msg) return elseif msg.t == "progress" then if Ops.track then Ops.track(msg.componentId) end elseif msg.t == "balance" or msg.t == "component_done" or msg.t == "component_failed" then if msg.credits ~= nil then Cloud.credits = msg.credits end -- What this component created or wrote, for "Show me" (Context.focus(msg.paths)). if msg.componentId and Ops.touched then msg.paths = Ops.touched(msg.componentId) end elseif msg.t == "error" then if msg.code == "auth" then generation += 1 resumable = false Account.signOut(false) setStatus("signed_out", msg.message) elseif msg.code == "protocol" then generation += 1 setStatus("update", msg.message) elseif msg.code == "account_unreachable" then retryIn = 15 end -- "plan_required" (tier not in the plan) keeps the connection; the window shows the upgrade sheet. elseif msg.t == "pong" then return end tell(msg) end connect = function() generation += 1 local mine = generation if client then pcall(function() client:Close() end) client = nil end local token = Account.token() if not token then setStatus("signed_out") return end local url = Cloud.url() if not safe(url) then setStatus("offline", "Refusing to send your token to " .. url .. " (only api.synagy.org or this computer). Reset the setting SynagyCloudUrl.") return end setStatus("connecting") local ok, created = pcall(function() return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, { Url = url }) end) local dead = false -- Error and Closed may both fire for one drop: only the first one schedules a reconnect. local function lost(reason) if dead or mine ~= generation then return end dead = true client = nil local wait = retryIn retryIn = math.min(retryIn * 2, 30) retry(mine, wait, reason) end if not ok then lost(tostring(created)) return end client = created lastSeen = os.clock() created.Opened:Connect(function() if mine ~= generation then return end pcall(function() created:Send(HttpService:JSONEncode({ t = "hello", protocol = PROTOCOL, token = token, pluginVersion = pluginVersion, unityVersion = version(), engine = "roblox", projectName = game.Name, projectId = plugin:GetSetting(projectKey()) or "", locale = locale(), results = (function() local list = {} for _, r in unsent do table.insert(list, { id = r.id, ok = r.ok, output = r.output, error = r.error, image = r.image, data = r.data }) end return list end)(), have = (function() local list = {} for id in inflight do table.insert(list, id) end return list end)(), resume = resumable, caps = caps, observer = false, })) end) end) created.MessageReceived:Connect(function(text) if mine ~= generation then return end lastSeen = os.clock() local decoded, msg = pcall(function() return HttpService:JSONDecode(text) end) if decoded and type(msg) == "table" then handle(mine, msg) end end) created.Error:Connect(function(_, message) lost(tostring(message)) end) created.Closed:Connect(function() lost() end) -- Keep the connection alive through proxies. task.spawn(function() while mine == generation do task.wait(25) if mine == generation and Cloud.status == "online" then -- A half-open socket looks online forever: no answer for a minute means reconnect. if os.clock() - lastSeen > 60 then pcall(function() created:Close() end) lost("No answer from the Synagy cloud") return end Cloud.send({ t = "ping" }) end end end) end function Cloud.start() retryIn = 3 connect() end function Cloud.stop() generation += 1 if client then pcall(function() client:Close() end) client = nil end setStatus(if Account.token() then "offline" else "signed_out") end -- A new chat: forget the project of this place, the cloud starts a fresh one. function Cloud.newProject() plugin:SetSetting(projectKey(), nil) resumable = false Cloud.start() end return Cloud ]]> Context Instance, so payload() describes the instance as it is when the message goes out local listeners = {} local windowOpen = false local lastSelection = {} -- Instance -> true local lastClick = -math.huge local function service(name) local ok, s = pcall(function() return game:GetService(name) end) return if ok then s else nil end local function changed() local copy = table.clone(items) for _, fn in listeners do task.spawn(fn, copy) end end local function trim(text: string, limit: number): string if #text <= limit then return text end -- Cut on a line break when there is one near the end, so the cloud sees whole lines. local cut = string.sub(text, 1, limit - 40) local last = string.match(cut, "^.*()\n") if last and last > limit / 2 then cut = string.sub(cut, 1, last - 1) end return cut .. "\n-- … trimmed (" .. #text .. " characters in total)" end -- Path in the file notation of Ops (service first, "/" between names, Rojo suffix for scripts). local function pathOf(inst: Instance): string local parts = {} local node = inst while node and node ~= game do table.insert(parts, 1, node.Name) node = node.Parent end local suffix = SUFFIX[inst.ClassName] if suffix and #parts > 0 then parts[#parts] ..= suffix end return table.concat(parts, "/") end local function icon(className: string) local ok, result = pcall(function() return service("StudioService"):GetClassIcon(className) end) return if ok and type(result) == "table" then result else nil end local function idOf(inst: Instance): string local ok, debugId = pcall(function() return inst:GetDebugId(0) end) return "inst:" .. (if ok and debugId then tostring(debugId) else pathOf(inst)) end local function fmt(value): string local kind = typeof(value) if kind == "number" then return string.format("%.4g", value) elseif kind == "Vector3" then return string.format("(%.1f, %.1f, %.1f)", value.X, value.Y, value.Z) elseif kind == "Vector2" then return string.format("(%.1f, %.1f)", value.X, value.Y) elseif kind == "Color3" then return string.format("rgb(%d, %d, %d)", math.round(value.R * 255), math.round(value.G * 255), math.round(value.B * 255)) elseif kind == "UDim2" then return string.format("{%.3g, %d}, {%.3g, %d}", value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset) elseif kind == "EnumItem" then return value.Name elseif kind == "Instance" then return value.Name elseif kind == "string" then return string.format("%q", string.sub(value, 1, 120)) end return tostring(value) end -- Properties worth telling the model, by base class. Read with pcall: not every class has every one. local PROPS = { { "BasePart", { "Position", "Size", "Color", "Material", "Transparency", "Anchored", "CanCollide", "Shape" } }, { "Model", { "PrimaryPart" } }, { "Light", { "Brightness", "Range", "Color" } }, { "Sound", { "SoundId", "Volume", "Looped" } }, { "GuiObject", { "Position", "Size", "Visible", "BackgroundColor3" } }, { "TextLabel", { "Text" } }, { "TextButton", { "Text" } }, { "SpawnLocation", { "Neutral", "TeamColor" } }, { "Humanoid", { "Health", "MaxHealth", "WalkSpeed" } }, { "ParticleEmitter", { "Rate", "Lifetime", "Texture" } }, { "Decal", { "Texture" } }, { "MeshPart", { "MeshId", "TextureID" } }, { "ProximityPrompt", { "ActionText", "MaxActivationDistance" } }, { "ValueBase", { "Value" } }, } -- Short text about an instance: class, path, key properties, size, attributes, children. local function summary(inst: Instance): string local lines = { inst.ClassName .. " " .. inst:GetFullName() } for _, entry in PROPS do if inst:IsA(entry[1]) then for _, prop in entry[2] do local ok, value = pcall(function() return (inst :: any)[prop] end) if ok and value ~= nil then table.insert(lines, prop .. " = " .. fmt(value)) end end end end if inst:IsA("Model") then local ok, size = pcall(function() return (inst :: any):GetExtentsSize() end) if ok then table.insert(lines, "Extents = " .. fmt(size)) end end local okAttr, attributes = pcall(function() return inst:GetAttributes() end) if okAttr and type(attributes) == "table" and next(attributes) then local list = {} for k, v in attributes do table.insert(list, k .. " = " .. fmt(v)) end table.sort(list) table.insert(lines, "Attributes: " .. table.concat(list, ", ")) end local children = inst:GetChildren() local okD, descendants = pcall(function() return #inst:GetDescendants() end) table.insert(lines, #children .. " child(ren)" .. (if okD and descendants > #children then ", " .. descendants .. " descendant(s)" else "")) for i, child in children do if i > 20 then table.insert(lines, " … " .. (#children - 20) .. " more") break end table.insert(lines, " " .. child.Name .. " [" .. child.ClassName .. "]") end return trim(table.concat(lines, "\n"), MAX_TEXT) end local function scriptText(inst: Instance): string local ok, source = pcall(function() return (inst :: any).Source end) return trim(if ok and type(source) == "string" then source else "", MAX_TEXT) end local function counts() local context, images = 0, 0 for _, item in items do if item.kind == "image" then images += 1 else context += 1 end end return context, images end function Context.init(p) plugin = p -- Ctrl+Click: Studio selects on mouse down; the plugin sees the button and the selection it produced. pcall(function() UserInputService.InputBegan:Connect(function(input) if input.UserInputType == Enum.UserInputType.MouseButton1 then lastClick = os.clock() end end) end) for _, inst in Selection:Get() do lastSelection[inst] = true end Selection.SelectionChanged:Connect(function() local now = Selection:Get() local before = lastSelection lastSelection = {} for _, inst in now do lastSelection[inst] = true end if not (windowOpen and Context.ctrlClick()) then return end -- Only what this click added; Ctrl+Click on a selected object deselects it and adds nothing. local added = {} for _, inst in now do if not before[inst] then table.insert(added, inst) end end if #added == 0 then return end -- A moment later: the mouse event may arrive after the selection it caused. task.delay(0.05, function() local ctrl = UserInputService:IsKeyDown(Enum.KeyCode.LeftControl) or UserInputService:IsKeyDown(Enum.KeyCode.RightControl) if not ctrl or os.clock() - lastClick > CLICK_WINDOW then return end for _, inst in added do if inst.Parent ~= nil then Context.add(Context.fromInstance(inst)) end end end) end) end function Context.items(): { Item } return table.clone(items) end function Context.onChanged(fn) table.insert(listeners, fn) end function Context.add(item: Item): boolean if type(item) ~= "table" or type(item.id) ~= "string" then return false end for _, existing in items do if existing.id == item.id then return false end end local context, images = counts() if (item.kind == "image" and images >= MAX_IMAGES) or (item.kind ~= "image" and context >= MAX_CONTEXT) then return false end table.insert(items, item) changed() return true end function Context.remove(id: string) for i, item in items do if item.id == id then table.remove(items, i) sources[id] = nil changed() return end end end function Context.clear() if #items == 0 then return end items, sources = {}, {} changed() end -- An Item for an instance (a script becomes kind "script" with its source). function Context.fromInstance(inst: Instance): Item local isScript = inst:IsA("LuaSourceContainer") local path = pathOf(inst) local id = idOf(inst) sources[id] = inst local label = inst.Name .. (if isScript then (SUFFIX[inst.ClassName] or "") else "") return { id = id, kind = if isScript then "script" else "instance", label = label, detail = inst:GetFullName() .. " · " .. inst.ClassName, class = inst.ClassName, icon = icon(inst.ClassName), payload = { kind = if isScript then "script" else "instance", label = label, path = path, class = inst.ClassName, text = if isScript then scriptText(inst) else summary(inst), }, } end function Context.addSelection(): number local added = 0 for _, inst in Selection:Get() do if inst ~= game and inst.Parent ~= nil and Context.add(Context.fromInstance(inst)) then added += 1 end end return added end function Context.addOpenScript(): boolean local ok, active = pcall(function() return service("StudioService").ActiveScript end) if not ok or typeof(active) ~= "Instance" or not active:IsA("LuaSourceContainer") then return false end return Context.add(Context.fromInstance(active)) end -- The newest error and warning lines of the Output as one "output" item (replaces an older one). Returns the number -- of lines taken. function Context.addOutputErrors(): number local history = LogService:GetLogHistory() local lines, errors, warnings = {}, 0, 0 for i = #history, 1, -1 do local entry = history[i] local isError = entry.messageType == Enum.MessageType.MessageError local isWarning = entry.messageType == Enum.MessageType.MessageWarning -- Synagy's own lines say nothing about the game. if (isError or isWarning) and not string.find(entry.message, "Synagy", 1, true) then table.insert(lines, 1, (if isError then "[E] " else "[W] ") .. entry.message) if isError then errors += 1 else warnings += 1 end if #lines >= 30 then break end end end if #lines == 0 then return 0 end for i, item in items do if item.id == "output" then table.remove(items, i) break end end local label = if errors > 0 then errors .. (if errors == 1 then " error" else " errors") else warnings .. (if warnings == 1 then " warning" else " warnings") local item = { id = "output", kind = "output", label = label, detail = "Output: " .. errors .. " error(s), " .. warnings .. " warning(s)", class = "LogService", icon = icon("LogService"), payload = { kind = "output", label = label, text = trim(table.concat(lines, "\n"), MAX_TEXT) }, } if not Context.add(item) then changed() -- the old item went away even if the list is full return 0 end return #lines end function Context.ctrlClick(): boolean return plugin == nil or plugin:GetSetting(CTRL_CLICK_SETTING) ~= false end function Context.setCtrlClick(on: boolean) if plugin then plugin:SetSetting(CTRL_CLICK_SETTING, on == true) end end function Context.setWindowOpen(open: boolean) windowOpen = open == true end -- Instance for a path in the Ops notation ("Workspace/Map/Lava", "ServerScriptService/Coins.server.luau"); also -- accepts a dotted full name without dots in names ("Workspace.Map.Lava"). local function find(path: string): Instance? local parts = {} local sep = if string.find(path, "/", 1, true) or string.find(path, "\\", 1, true) then "[^/\\]+" else "[^%.]+" for part in string.gmatch(path, sep) do table.insert(parts, part) end if parts[1] == "game" then table.remove(parts, 1) end local node = if parts[1] then service(parts[1]) else nil for i = 2, #parts do if not node then return nil end local name = parts[i] local child = node:FindFirstChild(name) if not child and i == #parts then for _, suffix in { ".server.luau", ".client.luau", ".luau", ".server.lua", ".client.lua", ".lua" } do if #name > #suffix and string.sub(name, -#suffix) == suffix then child = node:FindFirstChild(string.sub(name, 1, -#suffix - 1)) break end end end node = child end return node end Context.find = find -- "Show me": selects the instances and frames them in the viewport; a lone script opens in the editor. function Context.focus(paths: { string }): number local found, scripts = {}, {} local min, max = nil, nil for _, path in paths or {} do local inst = find(path) if inst then table.insert(found, inst) if inst:IsA("LuaSourceContainer") then table.insert(scripts, inst) end local ok, cf, size = pcall(function() if inst:IsA("BasePart") then return (inst :: any).CFrame, (inst :: any).Size elseif inst:IsA("Model") then return (inst :: any):GetBoundingBox() end return nil, nil end) if ok and cf and size then local half = size / 2 local lo, hi = cf.Position - half, cf.Position + half min = if min then min:Min(lo) else lo max = if max then max:Max(hi) else hi end end end if #found == 0 then return 0 end Selection:Set(found) if min and max then local camera = workspace.CurrentCamera local center = (min + max) / 2 local radius = math.max((max - min).Magnitude / 2, 4) local back = camera.CFrame.LookVector local distance = radius / math.tan(math.rad(camera.FieldOfView / 2)) * 1.2 camera.CFrame = CFrame.lookAt(center - back * distance, center) pcall(function() camera.Focus = CFrame.new(center) end) elseif #scripts == 1 and plugin then pcall(function() plugin:OpenScript(scripts[1]) end) end return #found end -- For the chat message; does not clear. Instances and scripts are described as they are now. function Context.payload(): { context: { any }, attachments: { any } } local context, attachments = {}, {} for _, item in items do if item.kind == "image" then table.insert(attachments, item.payload) else local inst = sources[item.id] if inst and inst.Parent ~= nil then item.payload.text = if item.kind == "script" then scriptText(inst) else summary(inst) item.payload.path = pathOf(inst) end table.insert(context, item.payload) end end return { context = context, attachments = attachments } end return Context ]]> Hotkeys combinations that -- reached the plugin in Studio 0.741 without Studio acting on them. Taken by Studio (do not use): Ctrl+Shift+H (find -- and replace in all scripts), I (intersect), O (opens a new Studio window), L and T (swallowed), plus the documented -- S Z V X P G N U F B E C and F1-F5. Ctrl+Alt is avoided: it is AltGr on many European layouts (@ € { } in scripts). -- Every Studio-scope shortcut is also a PluginAction, so users can rebind it in File › Customize Shortcuts; a key -- that fires both ways within a moment runs once. -- Window-scope entries (Enter, Shift+Enter, ↑, Esc) are implemented by the Synagy window itself and listed here only -- for the hotkey page. local UserInputService = game:GetService("UserInputService") local Hotkeys = {} local SETTING = "SynagyHotkeysOff" -- list of ids the user switched off local CTRL_CLICK_SETTING = "SynagyCtrlClick" -- shared with Context local DEDUPE = 0.2 -- key = KeyCode name, pressed with exactly Ctrl+Shift (no Alt); hold = sends "down" and "up". local DEFS = { { id = "toggle_window", label = "Open or close Synagy", group = "General", key = "Y" }, { id = "focus_input", label = "Type to Synagy", group = "General", key = "Slash" }, { id = "hotkeys", label = "Show all hotkeys", group = "General", key = "Comma" }, { id = "new_chat", label = "New chat", group = "General", key = "One" }, { id = "add_click", label = "Add clicked object", group = "Context", mouse = true }, { id = "add_selection", label = "Add selection", group = "Context", key = "A" }, { id = "add_script", label = "Add open script", group = "Context", key = "J" }, { id = "screenshot", label = "Add viewport screenshot", group = "Context", key = "K" }, { id = "voice", label = "Hold to talk", group = "Context", key = "Space", hold = true }, { id = "fix_errors", label = "Fix Output errors", group = "Actions", key = "D" }, { id = "explain", label = "Explain selection", group = "Actions", key = "Q" }, { id = "playtest_review", label = "Playtest & review", group = "Actions", key = "Five" }, { id = "stop", label = "Stop the build", group = "Actions", key = "Period" }, { id = "tier_next", label = "Next model tier", group = "Model", key = "M" }, { id = "workflow_next", label = "Next workflow", group = "Model", key = "W" }, { id = "send", label = "Send", group = "Chat", keys = { "Enter" }, scope = "window" }, { id = "newline", label = "New line", group = "Chat", keys = { "Shift", "Enter" }, scope = "window" }, { id = "history_prev", label = "Last prompt (empty input)", group = "Chat", keys = { "↑" }, scope = "window" }, { id = "close", label = "Close menu or sheet", group = "Chat", keys = { "Esc" }, scope = "window" }, } local SHOWN = { Period = ".", Comma = ",", Slash = "/", One = "1", Five = "5", Space = "Space" } local plugin = nil local handlers = {} -- id -> { fn } local listeners = {} local off = {} -- id -> true local lastFired = {} -- id -> os.clock() of the last "down" local held = {} -- id -> true while a hold key is down local byKey = {} -- KeyCode name -> def local actions = {} -- id -> PluginAction for _, def in DEFS do def.scope = def.scope or "studio" if def.key then def.keys = { "Ctrl", "Shift", SHOWN[def.key] or def.key } byKey[def.key] = def elseif def.mouse then def.keys = { "Ctrl", "Click" } end end local function changed() for _, fn in listeners do task.spawn(fn) end end local function enabled(id: string): boolean if id == "add_click" then return plugin == nil or plugin:GetSetting(CTRL_CLICK_SETTING) ~= false end return not off[id] end -- Runs the handlers of id. "down" twice within DEDUPE seconds (key + PluginAction) runs once. function Hotkeys.fire(id: string, phase: string?) phase = phase or "down" if not enabled(id) then return false end if phase == "down" then local now = os.clock() if lastFired[id] and now - lastFired[id] < DEDUPE then return false end lastFired[id] = now end for _, fn in handlers[id] or {} do task.spawn(fn, phase) end return true end local function modsExact(): boolean local ctrl = UserInputService:IsKeyDown(Enum.KeyCode.LeftControl) or UserInputService:IsKeyDown(Enum.KeyCode.RightControl) local shift = UserInputService:IsKeyDown(Enum.KeyCode.LeftShift) or UserInputService:IsKeyDown(Enum.KeyCode.RightShift) local alt = UserInputService:IsKeyDown(Enum.KeyCode.LeftAlt) or UserInputService:IsKeyDown(Enum.KeyCode.RightAlt) return ctrl and shift and not alt end local function began(input) if input.UserInputType ~= Enum.UserInputType.Keyboard then return end local def = byKey[input.KeyCode.Name] if def and def.scope == "studio" and modsExact() then if def.hold then if held[def.id] then return end if Hotkeys.fire(def.id, "down") then held[def.id] = true end else Hotkeys.fire(def.id, "down") end end end local function ended(input) if input.UserInputType ~= Enum.UserInputType.Keyboard then return end local name = input.KeyCode.Name -- Releasing the key or one of its modifiers ends a hold. for id in held do local def = nil for _, d in DEFS do if d.id == id then def = d end end if def and (name == def.key or name == "LeftControl" or name == "RightControl" or name == "LeftShift" or name == "RightShift") then held[id] = nil for _, fn in handlers[id] or {} do task.spawn(fn, "up") end end end end -- Keys typed while a GuiObject of the Synagy window has focus reach that object, not UserInputService in every -- Studio build: the window passes its root frame here so the shortcuts work there too. -- CONTRACT-GAP: Hotkeys.attach(gui) is an addition; C calls it with the widget's root GuiObject. function Hotkeys.attach(gui: GuiObject) gui.InputBegan:Connect(began) gui.InputEnded:Connect(ended) end function Hotkeys.init(p) plugin = p local saved = plugin:GetSetting(SETTING) if type(saved) == "table" then for _, id in saved do off[id] = true end end UserInputService.InputBegan:Connect(began) UserInputService.InputEnded:Connect(ended) for _, def in DEFS do if def.scope == "studio" and not def.mouse then local ok, action = pcall(function() return plugin:CreatePluginAction("Synagy_" .. def.id, "Synagy: " .. def.label, def.label .. " (default " .. table.concat(def.keys, "+") .. ")", "", true) end) if ok and action then actions[def.id] = action action.Triggered:Connect(function() -- An action has no key-up: a bound hold key toggles instead. if def.hold then if held[def.id] then held[def.id] = nil for _, fn in handlers[def.id] or {} do task.spawn(fn, "up") end elseif Hotkeys.fire(def.id, "down") then held[def.id] = true end else Hotkeys.fire(def.id, "down") end end) end end end end function Hotkeys.on(id: string, fn: (string) -> ()) handlers[id] = handlers[id] or {} table.insert(handlers[id], fn) end function Hotkeys.list() local list = {} for _, def in DEFS do table.insert(list, { id = def.id, label = def.label, group = def.group, keys = table.clone(def.keys), enabled = enabled(def.id), scope = def.scope }) end return list end function Hotkeys.setEnabled(id: string, on: boolean) if id == "add_click" then if plugin then plugin:SetSetting(CTRL_CLICK_SETTING, on == true) end else off[id] = if on then nil else true local saved = {} for k in off do table.insert(saved, k) end table.sort(saved) if plugin then plugin:SetSetting(SETTING, saved) end end changed() end function Hotkeys.onChanged(fn) table.insert(listeners, fn) end -- "Ctrl+Shift+Y" for a tooltip. function Hotkeys.text(id: string): string for _, def in DEFS do if def.id == id then return table.concat(def.keys, "+") end end return "" end return Hotkeys ]]> Motion true local Style, Dir = Enum.EasingStyle, Enum.EasingDirection function Motion.tween(inst: Instance, time: number, goal: { [string]: any }, style: Enum.EasingStyle?, dir: Enum.EasingDirection?, delay: number?) local t = TweenService:Create(inst, TweenInfo.new(time, style or Style.Quint, dir or Dir.Out, 0, false, delay or 0), goal) t:Play() return t end -- Jelly spring: overshoots a little and settles (Back out). function Motion.spring(inst: Instance, goal: { [string]: any }, time: number?) return Motion.tween(inst, time or 0.42, goal, Style.Back) end -- An endless tween (repeat -1). reverses = ping-pong. Paused while the widget is hidden. function Motion.loop(inst: Instance, time: number, goal: { [string]: any }, opts: { style: Enum.EasingStyle?, dir: Enum.EasingDirection?, reverses: boolean?, delay: number? }?) opts = opts or {} local t = TweenService:Create(inst, TweenInfo.new(time, opts.style or Style.Linear, opts.dir or Dir.InOut, -1, opts.reverses == true, 0), goal) loops[t] = true local con con = inst.Destroying:Connect(function() loops[t] = nil t:Cancel() con:Disconnect() end) local function start() if loops[t] and active then t:Play() end end if opts.delay and opts.delay > 0 then task.delay(opts.delay, start) else start() end return { stop = function() loops[t] = nil t:Cancel() con:Disconnect() end, } end function Motion.setActive(on: boolean) if active == on then return end active = on for t in loops do if on then t:Play() else t:Pause() end end end function Motion.isActive(): boolean return active end local function scaleOf(inst: Instance): UIScale local s = inst:FindFirstChildOfClass("UIScale") if not s then s = Instance.new("UIScale") s.Parent = inst end return s end Motion.scaleOf = scaleOf -- Pop in: grows from `from` with a spring. function Motion.popIn(inst: GuiObject, from: number?, time: number?) local s = scaleOf(inst) s.Scale = from or 0.6 return Motion.spring(s, { Scale = 1 }, time) end -- Short squash for feedback (a press, a new value). function Motion.bump(inst: GuiObject, amount: number?) local s = scaleOf(inst) s.Scale = 1 + (amount or 0.12) return Motion.spring(s, { Scale = 1 }, 0.35) end -- Gradient that sweeps across (text shimmer, glow sweep, skeleton): moves Offset from -1 to 1 forever. function Motion.shimmer(gradient: UIGradient, period: number?, delay: number?) gradient.Offset = Vector2.new(-1, 0) return Motion.loop(gradient, period or 1.4, { Offset = Vector2.new(1, 0) }, { style = Style.Sine, dir = Dir.InOut, delay = delay }) end -- Breathing value between the current and the goal (ping-pong). function Motion.pulse(inst: Instance, goal: { [string]: any }, period: number?, delay: number?) return Motion.loop(inst, period or 0.8, goal, { style = Style.Sine, dir = Dir.InOut, reverses = true, delay = delay }) end -- Typewriter: reveals a label's graphemes over `time`; the height is already measured for the full text. function Motion.typewriter(label: TextLabel, chars: number, time: number) if not active or chars <= 0 then label.MaxVisibleGraphemes = -1 return nil end local v = Instance.new("NumberValue") v.Value = 0 label.MaxVisibleGraphemes = 0 local con = v.Changed:Connect(function(x) label.MaxVisibleGraphemes = math.floor(x) end) local t = TweenService:Create(v, TweenInfo.new(time, Style.Linear), { Value = chars }) local function finish() con:Disconnect() v:Destroy() if label.Parent then label.MaxVisibleGraphemes = -1 end end t.Completed:Connect(finish) t:Play() return { skip = function() t:Cancel() finish() end, } end -- Rolls a number label from `from` to `to` (credits after a build). function Motion.roll(label: TextLabel, from: number, to: number, format: (number) -> string, time: number?) if not active or from == to then label.Text = format(to) return end local v = Instance.new("NumberValue") v.Value = from local con = v.Changed:Connect(function(x) label.Text = format(math.floor(x + 0.5)) end) local t = TweenService:Create(v, TweenInfo.new(time or 0.8, Style.Quint, Dir.Out), { Value = to }) t.Completed:Connect(function() con:Disconnect() v:Destroy() label.Text = format(to) end) t:Play() end -- Ripple from a point inside a clipped face (button press). function Motion.ripple(face: GuiObject, x: number?, y: number?, color: Color3?) if not active then return end local size = math.max(face.AbsoluteSize.X, face.AbsoluteSize.Y) * 2.2 local px = (x or face.AbsolutePosition.X + face.AbsoluteSize.X / 2) - face.AbsolutePosition.X local py = (y or face.AbsolutePosition.Y + face.AbsoluteSize.Y / 2) - face.AbsolutePosition.Y local r = Instance.new("Frame") r.Name = "Ripple" r.BackgroundColor3 = color or Color3.new(1, 1, 1) r.BackgroundTransparency = 0.55 r.BorderSizePixel = 0 r.AnchorPoint = Vector2.new(0.5, 0.5) r.Position = UDim2.fromOffset(px, py) r.Size = UDim2.fromOffset(0, 0) r.ZIndex = face.ZIndex + 1 local c = Instance.new("UICorner") c.CornerRadius = UDim.new(0.5, 0) c.Parent = r r.Parent = face local t = Motion.tween(r, 0.5, { Size = UDim2.fromOffset(size, size), BackgroundTransparency = 1 }, Style.Quad) t.Completed:Connect(function() r:Destroy() end) end -- Confetti burst from a point of `layer` (a full-size overlay): small jelly bits fly out, spin and fade. ~0.9 s, -- then everything is destroyed again. function Motion.confetti(layer: GuiObject, x: number, y: number, colors: { Color3 }, count: number?) if not active then return end local ox, oy = x - layer.AbsolutePosition.X, y - layer.AbsolutePosition.Y local n = count or 16 for i = 1, n do local bit = Instance.new("Frame") bit.Name = "Confetti" bit.BorderSizePixel = 0 bit.BackgroundColor3 = colors[(i - 1) % #colors + 1] bit.AnchorPoint = Vector2.new(0.5, 0.5) local w = if i % 3 == 0 then 6 else 4 bit.Size = UDim2.fromOffset(w, if i % 2 == 0 then 9 else w) bit.Position = UDim2.fromOffset(ox, oy) bit.Rotation = math.random(0, 180) bit.ZIndex = 60 local c = Instance.new("UICorner") c.CornerRadius = UDim.new(0, 2) c.Parent = bit bit.Parent = layer local angle = (i / n) * math.pi * 2 + math.random() * 0.5 local dist = 34 + math.random() * 40 local dx, dy = math.cos(angle) * dist, math.sin(angle) * dist - 18 Motion.tween(bit, 0.45, { Position = UDim2.fromOffset(ox + dx, oy + dy), Rotation = bit.Rotation + math.random(-200, 200) }, Style.Quint) local fall = Motion.tween(bit, 0.5, { Position = UDim2.fromOffset(ox + dx * 1.15, oy + dy + 26), BackgroundTransparency = 1 }, Style.Quad, Dir.In, 0.42) fall.Completed:Connect(function() bit:Destroy() end) end end return Motion ]]> Ops { list = {path}, seen = {path -> true} } local touchedOrder = {} local pathOf -- defined with the file helpers below function Ops.track(componentId) tracking = if type(componentId) == "string" and componentId ~= "" then componentId else nil if tracking and not touched[tracking] then touched[tracking] = { list = {}, seen = {} } table.insert(touchedOrder, tracking) if #touchedOrder > 30 then touched[table.remove(touchedOrder, 1)] = nil end end end -- Paths (Ops file notation, e.g. "Workspace/Lava", "ServerScriptService/Coins.server.luau") a component touched. function Ops.touched(componentId): { string } local entry = touched[componentId] return if entry then table.clone(entry.list) else {} end local function touch(inst) local entry = tracking and touched[tracking] if not entry or #entry.list >= MAX_TOUCHED or typeof(inst) ~= "Instance" or inst.Parent == nil then return end local path = pathOf(inst) if not entry.seen[path] then entry.seen[path] = true table.insert(entry.list, path) end end local function show(value, depth) depth = depth or 0 local kind = typeof(value) if kind == "Instance" then return value:GetFullName() .. " [" .. value.ClassName .. "]" elseif kind == "table" then if depth > 3 then return "{…}" end local parts, n = {}, 0 for k, v in value do n += 1 if n > 50 then table.insert(parts, "…") break end table.insert(parts, (if type(k) == "number" then "" else tostring(k) .. " = ") .. show(v, depth + 1)) end return "{" .. table.concat(parts, ", ") .. "}" elseif kind == "string" then return if depth > 0 then string.format("%q", value) else value end return tostring(value) end -- Groups everything a function changes into one undo step named "Synagy". local function recorded(fn) -- New instances of a tracked step: only the top of each new subtree counts. local added, connection = {}, nil if tracking then pcall(function() connection = game.DescendantAdded:Connect(function(inst) table.insert(added, inst) end) end) end local function collect() if connection then connection:Disconnect() local set = {} for _, inst in added do set[inst] = true end for _, inst in added do if not set[inst.Parent] then touch(inst) end end end end local recording pcall(function() recording = ChangeHistoryService:TryBeginRecording("Synagy") end) -- A failing step must still close the recording, or every later step loses its undo point. local results = table.pack(pcall(fn)) pcall(function() if recording then ChangeHistoryService:FinishRecording(recording, if results[1] then Enum.FinishRecordingOperation.Commit else Enum.FinishRecordingOperation.Cancel) else ChangeHistoryService:SetWaypoint("Synagy") end end) collect() if not results[1] then error(results[2], 0) end return table.unpack(results, 2, results.n) end -- engine_run gets the plugin, but not Synagy's own settings: the account token must not reach the local MCP server. local function guardedPlugin() if not pluginRef then return nil end return setmetatable({}, { __index = function(_, k) local v = pluginRef[k] if type(v) ~= "function" then return v end return function(_, ...) local first = ... if (k == "GetSetting" or k == "SetSetting") and type(first) == "string" and string.sub(first, 1, 6) == "Synagy" then error("Synagy's own plugin settings are not available to engine_run.", 2) end return v(pluginRef, ...) end end, __newindex = function(_, k, v) pluginRef[k] = v end, __tostring = function() return tostring(pluginRef) end, }) end function Ops.run(cmd) local fn, syntaxError = loadstring(cmd.code or "", "=engine_run") if not fn then return { ok = false, error = tostring(syntaxError) } end local lines = {} local function capture(prefix) return function(...) local parts = table.pack(...) for i = 1, parts.n do parts[i] = show(parts[i]) end table.insert(lines, prefix .. table.concat(parts, " ", 1, parts.n)) end end -- The snippet sees the normal globals and the plugin; only print and warn are collected for the answer. setfenv(fn, setmetatable({ print = capture(""), warn = capture("[W] "), plugin = guardedPlugin() }, { __index = getfenv(fn) })) local results = recorded(function() return table.pack(xpcall(fn, function(err) -- Keep only the frames of the snippet; the plugin's own frames help nobody. local frames = {} for line in string.gmatch(debug.traceback("", 2), "[^\n]+") do if string.find(line, "engine_run", 1, true) then table.insert(frames, line) end end return tostring(err) .. (if #frames > 1 then "\n" .. table.concat(frames, "\n") else "") end)) end) local output = table.concat(lines, "\n") if not results[1] then return { ok = false, error = tostring(results[2]), output = output } end if results.n > 1 then local values = {} for i = 2, results.n do table.insert(values, show(results[i])) end output ..= (if output == "" then "" else "\n") .. "return: " .. table.concat(values, ", ") end return { ok = true, output = output } end local function round(n) return string.format("%.1f", n) end local function describe(inst) local parts = { inst.Name .. " [" .. inst.ClassName .. "]" } if inst:IsA("BasePart") then local p, s = inst.Position, inst.Size table.insert(parts, "pos (" .. round(p.X) .. ", " .. round(p.Y) .. ", " .. round(p.Z) .. ")") table.insert(parts, "size (" .. round(s.X) .. ", " .. round(s.Y) .. ", " .. round(s.Z) .. ")") if inst.Anchored then table.insert(parts, "anchored") end elseif inst:IsA("LuaSourceContainer") then local ok, source = pcall(function() return inst.Source end) if ok then local _, breaks = string.gsub(source, "\n", "") table.insert(parts, (breaks + 1) .. " lines") end if inst:IsA("BaseScript") and not inst.Enabled then table.insert(parts, "disabled") end end return table.concat(parts, " · ") end function Ops.scene(cmd) local limit = tonumber(cmd.limit) or 200 if limit <= 0 then limit = 200 end local lines = { "Roblox Studio " .. version() .. " · place \"" .. game.Name .. "\" · units are studs", "" } local count = 0 local function walk(inst, depth) if count >= limit then return end count += 1 table.insert(lines, string.rep(" ", depth) .. describe(inst)) for _, child in inst:GetChildren() do walk(child, depth + 1) end end for _, name in SERVICES do local ok, service = pcall(function() return game:GetService(name) end) if ok and service then local children = service:GetChildren() table.insert(lines, name .. " (" .. #children .. ")") for _, child in children do -- The default camera and terrain are always there and say nothing about the game. if not (child:IsA("Camera") or child:IsA("Terrain")) then walk(child, 1) end end end end if count >= limit then table.insert(lines, "… more instances (raise 'limit', now " .. limit .. ")") end return { ok = true, output = table.concat(lines, "\n") } end local function mark(kind) if kind == Enum.MessageType.MessageError then return "[E]" elseif kind == Enum.MessageType.MessageWarning then return "[W]" end return " " end Ops.mark = mark function Ops.logs(cmd) local limit = tonumber(cmd.limit) or 60 if limit <= 0 then limit = 60 end local history = LogService:GetLogHistory() local lines, errors = {}, 0 for i = math.max(1, #history - limit + 1), #history do local entry = history[i] if entry.messageType == Enum.MessageType.MessageError then errors += 1 end table.insert(lines, mark(entry.messageType) .. " " .. entry.message) end return { ok = true, output = #lines .. " line(s), " .. errors .. " error(s). [E] error, [W] warning.\n" .. table.concat(lines, "\n") } end -- ── Playtests ─────────────────────────────────────────────────────────────── -- Starts a playtest and waits until the server side of the test (Ops.serveTest) ends it and hands back its report. -- Studio silently drops a test that starts while the last one is still shutting down, and the Execute call -- then never returns. So: a pause after each test, and a deadline that frees the plugin again. local SETTLE = 2 local testing, lastEnded = false, -math.huge function Ops.playTest(cmd) if testing then return { ok = false, error = "A Synagy playtest is already running." } end local seconds = math.clamp(tonumber(cmd.seconds) or 4, 1, 20) local args = { synagy = { seconds = seconds, code = if type(cmd.code) == "string" then cmd.code else "", mode = if cmd.mode == "play" then "Play" else "Run" }, } testing = true local pause = lastEnded + SETTLE - os.clock() if pause > 0 then task.wait(pause) end local done, ok, report = false, false, nil task.spawn(function() local okHere, reportHere = pcall(function() if cmd.mode == "play" then return StudioTestService:ExecutePlayModeAsync(args) end return StudioTestService:ExecuteRunModeAsync(args) end) if not done then done, ok, report = true, okHere, reportHere end end) local deadline = os.clock() + seconds + 60 while not done and os.clock() < deadline do task.wait(0.25) end testing, lastEnded = false, os.clock() if not done then done = true return { ok = false, error = "The playtest did not start or end within " .. (seconds + 60) .. " s. Is a playtest still open in Studio? Stop it and try again." } end if not ok then return { ok = false, error = "Studio did not start the playtest: " .. tostring(report) } end if type(report) ~= "table" then return { ok = false, error = "The playtest ended before Synagy could read it (stopped by hand?)." } end return { ok = report.ok == true, output = tostring(report.output or ""), error = if report.error then tostring(report.error) else nil } end -- loadstring does not exist in a running game (LoadStringEnabled is not scriptable), so the snippet runs as -- a server Script. Its first line is the wrapper, so line numbers in errors match the agent's code. local SNIPPET_HEAD = 'local __p, __w = print, warn; local function __s(sep, ...) local t = table.pack(...) for i = 1, t.n do t[i] = tostring(t[i]) end return table.concat(t, sep, 1, t.n) end; ' .. 'local print = function(...) __p("[snippet] " .. __s(" ", ...)) end; local warn = function(...) __w("[snippet] " .. __s(" ", ...)) end; ' .. "local __r = table.pack(pcall(function() " local SNIPPET_TAIL = '\nend)); script:SetAttribute("SynagyResult", if __r[1] then __s(", ", table.unpack(__r, 2, __r.n)) else tostring(__r[2])); script:SetAttribute("SynagyOk", __r[1])' -- Runs on the server side of a playtest that Ops.playTest started: collects the Output, runs the agent's -- snippet, waits and ends the test; ExecuteRunModeAsync/ExecutePlayModeAsync then return this report. function Ops.serveTest(test) local lines, errors = {}, 0 -- Im Play-Modus startet das Plugin erst nach den Skripten des Spiels; was die schon ausgegeben haben, -- steht nur in der Historie dieses DataModels. Ohne yield dazwischen geht keine Zeile verloren. for _, entry in LogService:GetLogHistory() do if entry.messageType == Enum.MessageType.MessageError then errors += 1 end table.insert(lines, mark(entry.messageType) .. " " .. entry.message) end LogService.MessageOut:Connect(function(message, kind) if kind == Enum.MessageType.MessageError then errors += 1 end if #lines < 500 then table.insert(lines, mark(kind) .. " " .. message) end end) task.spawn(function() local snippet = nil if test.code ~= "" then snippet = Instance.new("Script") snippet.Name = "SynagySnippet" snippet.Source = SNIPPET_HEAD .. test.code .. SNIPPET_TAIL snippet.Parent = game:GetService("ServerScriptService") end task.wait(test.seconds) local report = { string.format("%s mode, %g s · %d line(s), %d error(s). [E] error, [W] warning.", tostring(test.mode), test.seconds, #lines, errors), } table.move(lines, 1, #lines, 2, report) local failure = if errors > 0 then errors .. " error(s) in the Output of this run." else nil if snippet then local ok, result = snippet:GetAttribute("SynagyOk"), snippet:GetAttribute("SynagyResult") if ok == true then table.insert(report, "\nSnippet finished" .. (if result ~= "" then ", returned: " .. tostring(result) else ".")) elseif ok == false then failure = "Snippet failed: " .. tostring(result) else table.insert(report, "\nSnippet: " .. (if errors > 0 then "did not finish, see the errors above." else "still running when the test ended.")) end end StudioTestService:EndTest({ ok = failure == nil, output = table.concat(report, "\n"), error = failure }) end) end -- ── Scripts as files ──────────────────────────────────────────────────────── -- "ServerScriptService/Coins/Pickup.server.luau" is the Script Pickup in the Folder Coins. Folders on the way -- are created as Folder instances. local function split(path) local parts = {} for part in string.gmatch(path or "", "[^/\\]+") do table.insert(parts, part) end return parts end -- Name and class from the last path part; nil class = no script suffix. local function scriptName(part) for _, pair in { { ".server.luau", "Script" }, { ".client.luau", "LocalScript" }, { ".server.lua", "Script" }, { ".client.lua", "LocalScript" }, { ".luau", "ModuleScript" }, { ".lua", "ModuleScript" } } do local suffix = pair[1] if #part > #suffix and string.sub(part, -#suffix) == suffix then return string.sub(part, 1, -#suffix - 1), pair[2] end end return part, nil end local function service(name) local ok, s = pcall(function() return game:GetService(name) end) return if ok then s else nil end -- Finds the instance at path; with create, missing folders (not the last part) are created. local function resolve(path, create) local parts = split(path) if #parts == 0 then return nil, "Empty path." end local node = service(parts[1]) if not node then return nil, "'" .. parts[1] .. "' is not a service. Start the path with a service such as ServerScriptService, StarterPlayer, ReplicatedStorage or Workspace." end for i = 2, #parts do local name = if i == #parts then (scriptName(parts[i])) else parts[i] local child = node:FindFirstChild(name) if not child then if not create or i == #parts then return nil, nil, node, parts[#parts] end child = Instance.new("Folder") child.Name = name child.Parent = node end node = child end return node, nil, node.Parent, parts[#parts] end local function setSource(s, text) local ok = pcall(function() ScriptEditorService:UpdateSourceAsync(s, function() return text end) end) if not ok then s.Source = text end end function Ops.writeFile(cmd) local _, class = scriptName(split(cmd.path)[#split(cmd.path)] or "") if not class then return { ok = false, error = "End the path in .server.luau (Script), .client.luau (LocalScript) or .luau (ModuleScript)." } end return recorded(function() local found, err, parent, last = resolve(cmd.path, true) if err then return { ok = false, error = err } end local name = scriptName(last) local replaced = nil if found and found.ClassName ~= class then -- Only a script changes its kind (Script → ModuleScript); a folder or model with that name stays. if not found:IsA("LuaSourceContainer") then return { ok = false, error = found:GetFullName() .. " is a " .. found.ClassName .. ", not a script. Pick another name or delete it first." } end replaced, found = found, nil end local created = found == nil local s = found or Instance.new(class) s.Name = name if replaced then for _, child in replaced:GetChildren() do child.Parent = s end replaced:Destroy() end if created then s.Parent = parent end setSource(s, cmd.text or "") touch(s) return { ok = true, output = (if created then "Created " else "Updated ") .. s:GetFullName() .. " [" .. class .. "]" } end) end function Ops.readFile(cmd) local found, err = resolve(cmd.path, false) if err or not found then return { ok = false, error = err or ("Nothing at " .. tostring(cmd.path) .. ".") } end if not found:IsA("LuaSourceContainer") then return { ok = false, error = found:GetFullName() .. " is a " .. found.ClassName .. ", not a script." } end return { ok = true, output = found.Source } end -- Path of an instance in the file notation (service first, Rojo suffix for scripts). pathOf = function(inst) local parts = {} local node = inst while node and node ~= game do table.insert(parts, 1, node.Name) node = node.Parent end local suffix = SUFFIX[inst.ClassName] if suffix then parts[#parts] ..= suffix end return table.concat(parts, "/") end function Ops.listFiles(cmd) local limit = tonumber(cmd.limit) or 300 if limit <= 0 then limit = 300 end -- Ohne Pfad der ganze Ort: alle Dienste aus SERVICES local roots = {} if cmd.path == nil or cmd.path == "" then for _, name in SERVICES do table.insert(roots, service(name)) end else local root, err = resolve(cmd.path, false) if err or not root then return { ok = true, output = "(empty: nothing at " .. tostring(cmd.path) .. ")" } end roots = { root } end local lines = {} local function walk(inst) if #lines >= limit then return end if inst:IsA("LuaSourceContainer") then local _, breaks = string.gsub(inst.Source, "\n", "") table.insert(lines, pathOf(inst) .. " (" .. inst.ClassName .. ", " .. (breaks + 1) .. " lines)") elseif inst.Parent ~= game and (inst:IsA("Folder") or inst:IsA("Model") or inst:IsA("ScreenGui")) then -- Workspace ist auch ein Model table.insert(lines, pathOf(inst) .. "/") end for _, child in inst:GetChildren() do walk(child) end end for _, root in roots do walk(root) end return { ok = true, output = if #lines > 0 then table.concat(lines, "\n") else "(no scripts)" } end function Ops.deleteFile(cmd) local found, err = resolve(cmd.path, false) if err or not found then return { ok = false, error = err or ("Nothing at " .. tostring(cmd.path) .. ".") } end if found.Parent == game then return { ok = false, error = "Services cannot be deleted." } end local name = found:GetFullName() recorded(function() found:Destroy() end) return { ok = true, output = "Deleted " .. name } end -- Roblox does not compile ahead of time: check the syntax of every script in the place instead. function Ops.check() local problems, count = {}, 0 for _, name in SERVICES do local s = service(name) if s then for _, inst in s:GetDescendants() do if inst:IsA("LuaSourceContainer") then count += 1 local fn, err = loadstring(inst.Source, "=" .. inst:GetFullName()) if not fn then table.insert(problems, tostring(err)) end end end end end if #problems > 0 then return { ok = false, error = #problems .. " script(s) with syntax errors:\n" .. table.concat(problems, "\n"), output = count .. " script(s) checked." } end return { ok = true, output = count .. " script(s) checked, no syntax errors." } end function Ops.info() local lines = { "Roblox Studio " .. version(), "Place: " .. game.Name .. (if game.PlaceId ~= 0 then " (PlaceId " .. game.PlaceId .. ")" else " (not published)"), "Units: studs (1 stud = 0.28 m). Gravity " .. string.format("%.1f", workspace.Gravity) .. ".", } local sp = game:GetService("StarterPlayer") table.insert(lines, "StarterPlayer: walk speed " .. sp.CharacterWalkSpeed .. ", jump " .. (if sp.CharacterUseJumpPower then "power " .. sp.CharacterJumpPower else "height " .. sp.CharacterJumpHeight)) local spawns = 0 for _, d in workspace:GetDescendants() do if d:IsA("SpawnLocation") then spawns += 1 end end table.insert(lines, "SpawnLocations: " .. spawns) table.insert(lines, "") for _, name in SERVICES do local s = service(name) if s then local scripts = 0 for _, d in s:GetDescendants() do if d:IsA("LuaSourceContainer") then scripts += 1 end end table.insert(lines, name .. ": " .. #s:GetChildren() .. " child(ren), " .. scripts .. " script(s)") end end return { ok = true, output = table.concat(lines, "\n") } end -- Actions by name, as the MCP bridge (hub.ts) and the Synagy Cloud (protocol.ts) send them. Ops.ACTIONS = { run = Ops.run, scene = Ops.scene, logs = Ops.logs, play_test = Ops.playTest, info = Ops.info, compile = Ops.check, write_file = Ops.writeFile, read_file = Ops.readFile, list_files = Ops.listFiles, delete_file = Ops.deleteFile, } function Ops.handle(cmd) local action = Ops.ACTIONS[cmd.action] if not action then return { id = cmd.id, ok = false, error = "Roblox Studio does not support '" .. tostring(cmd.action) .. "'." } end local ok, result = pcall(action, cmd) if not ok then result = { ok = false, error = tostring(result) } end result.id = cmd.id return result end return Ops ]]> Theme UiCompose 0 then if not plus then plus = key("", "soft", nil, { height = 26, radius = 13, lip = 2, padX = 9, textSize = 11, name = "More", Parent = chipsRow }) plus = plus.outer moreDd = W.dropdown(plus :: any, { width = 230, build = function(dd) dd.header(("In context · %d"):format(#items)) for _, it in items do dd.row({ text = escape(it.label or "?"), sub = it.detail and escape(it.detail) or nil, icon = if it.kind == "image" then "image" elseif it.kind == "script" then "script" elseif it.kind == "output" then "output" else "select", right = function(r) W.icon("close", C.faint, 12, { Parent = r }) end, rightWidth = 16, keepOpen = true, onSelect = function() if Context then Context.remove(it.id) end end, }) end end, }) end plus.LayoutOrder = 999 plus:FindFirstChild("Face").Text = "+" .. more elseif plus then plus:Destroy() end chipsRow.Visible = #items > 0 end function Compose.setChips(newItems: { any }) local before = #items items = newItems or {} drawChips(true) if #items > before then Motion.bump(outer, 0.015) end end chipsRow:GetPropertyChangedSignal("AbsoluteSize"):Connect(function() if #items > 0 then drawChips(false) end end) -- ── Input ──────────────────────────────────────────────────────────────── local input = new("TextBox", { Parent = face, Name = "Input", BackgroundTransparency = 1, ClearTextOnFocus = false, MultiLine = true, PlaceholderText = "Describe what to build…", PlaceholderColor3 = C.faint, Text = "", FontFace = Theme.BODY, TextSize = 13, TextColor3 = C.ink, TextWrapped = true, TextXAlignment = Enum.TextXAlignment.Left, TextYAlignment = Enum.TextYAlignment.Top, Size = UDim2.new(1, 0, 0, 40), LayoutOrder = 2, }, { pad(4, 5) }) Compose.input = input local countLabel local function grow() local params = Instance.new("GetTextBoundsParams") params.Text, params.Font, params.Size, params.Width = if input.Text == "" then " " else input.Text, Theme.BODY, 13, math.max(10, input.AbsoluteSize.X - 8) local ok, size = pcall(function() return game:GetService("TextService"):GetTextBoundsAsync(params) end) input.Size = UDim2.new(1, 0, 0, math.clamp((if ok then size.Y else 16) + 12, 40, 160)) end local shift = false local function shiftDown(): boolean if shift then return true end local ok, down = pcall(function() local uis = game:GetService("UserInputService") return uis:IsKeyDown(Enum.KeyCode.LeftShift) or uis:IsKeyDown(Enum.KeyCode.RightShift) end) return ok and down == true end function Compose.submit(): boolean local text = input.Text if ui.sendChat(text) then history.push(text) input.Text = "" return true end return false end local lastText, editing = "", false input:GetPropertyChangedSignal("Text"):Connect(function() if editing then return end local now = input.Text local at = Logic.typedNewline(lastText, now) if at and not shiftDown() then -- Enter: send instead of a new line editing = true input.Text = Logic.removeAt(now, at) editing = false lastText = input.Text Compose.submit() lastText = input.Text else lastText = now end task.spawn(grow) local n = utf8.len(input.Text) or #input.Text countLabel.Text = if n > 3000 then ("%d / 4000"):format(n) else "" countLabel.TextColor3 = if n > 4000 then C.bad else C.faint Compose.refreshQuick() end) input.InputBegan:Connect(function(io: InputObject) if io.UserInputType ~= Enum.UserInputType.Keyboard then return end local code = io.KeyCode if code == Enum.KeyCode.LeftShift or code == Enum.KeyCode.RightShift then shift = true elseif W.openDropdown() and (code == Enum.KeyCode.Up or code == Enum.KeyCode.Down or code == Enum.KeyCode.Escape) then W.openDropdown().key(code) elseif code == Enum.KeyCode.Up and (input.Text == "" or history.pos > 0) and not string.find(input.Text, "\n") then local prev = history.prev() if prev then input.Text = prev input.CursorPosition = #prev + 1 end elseif code == Enum.KeyCode.Down and history.pos > 0 then local nxt = history.next() or "" input.Text = nxt input.CursorPosition = #nxt + 1 elseif code == Enum.KeyCode.Escape then ui.escape() end end) input.InputEnded:Connect(function(io: InputObject) if io.KeyCode == Enum.KeyCode.LeftShift or io.KeyCode == Enum.KeyCode.RightShift then shift = false end end) input.Focused:Connect(function() Motion.tween(outer, 0.2, { BackgroundColor3 = C.tang }) end) input.FocusLost:Connect(function(_, cause: InputObject?) shift = false Motion.tween(outer, 0.25, { BackgroundColor3 = C.line2 }) if cause and cause.KeyCode == Enum.KeyCode.Escape then ui.escape() end end) function Compose.setText(text: string) input.Text = text input:CaptureFocus() input.CursorPosition = #text + 1 Motion.bump(outer, 0.02) end function Compose.focus() input:CaptureFocus() end -- ── Quick actions ──────────────────────────────────────────────────────── local quickRow = new("Frame", { Parent = face, Name = "Quick", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = 3, Visible = false }, { new("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Wraps = true, Padding = UDim.new(0, 5), SortOrder = Enum.SortOrder.LayoutOrder }), }) local quickKey = "" local QUICK_HUE = { fix_errors = "bub", explain = "sky", make_part = "tang", playtest_review = "lime" } function Compose.refreshQuick() local actions = if input.Text == "" then Logic.quickActions(ui.quickState()) else {} local sig = "" for _, a in actions do sig ..= a.id .. a.text .. ";" end if sig == quickKey then return end quickKey = sig for _, c in quickRow:GetChildren() do if c:IsA("GuiObject") then c:Destroy() end end for i, a in actions do local hue = HUES[QUICK_HUE[a.id] or "tang"] local b = key(escape(a.text), "soft", function() ui.quick(a.id) end, { height = 24, radius = 12, lip = 2, padX = 10, textSize = 11, order = i, Parent = quickRow, hover = hue[4], textColor = hue[3] }) b.face.TextXAlignment = Enum.TextXAlignment.Left local p = b.face:FindFirstChildOfClass("UIPadding") if p then p.PaddingLeft = UDim.new(0, 20) end new("Frame", { Parent = b.face, BackgroundColor3 = hue[1], BorderSizePixel = 0, Size = UDim2.fromOffset(6, 6), Position = UDim2.new(0, -12, 0.5, -3), Rotation = 45 }, { corner(1) }) Motion.popIn(b.outer, 0.6, 0.35 + i * 0.05) end quickRow.Visible = #actions > 0 end -- ── Bottom bar ─────────────────────────────────────────────────────────── local bar = new("Frame", { Parent = face, Name = "Bar", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 30), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = 4 }) local barLayout = new("UIListLayout", { Parent = bar, FillDirection = Enum.FillDirection.Horizontal, Padding = UDim.new(0, 5), SortOrder = Enum.SortOrder.LayoutOrder, VerticalAlignment = Enum.VerticalAlignment.Center }) -- A bar key with a leading icon/dot, a label and a caret; label may be hidden (compact). local function menuKey(order: number, lead: (Instance) -> Instance?, text: string, name: string) local k = key("", "soft", nil, { height = 28, radius = 9, lip = 2, padX = 8, textSize = 11.5, order = order, name = name, Parent = bar }) k.face.Text = "" list(4, true).Parent = k.face local leadInst = lead(k.face) if leadInst then leadInst.LayoutOrder = 1 end local l = new("TextLabel", { Parent = k.face, BackgroundTransparency = 1, Text = text, FontFace = Theme.BOLD, TextSize = 11.5, TextColor3 = C.ink, AutomaticSize = Enum.AutomaticSize.X, Size = UDim2.new(0, 0, 1, 0), LayoutOrder = 2, RichText = true }, { new("UISizeConstraint", { MaxSize = Vector2.new(90, 30) }) }) l.TextTruncate = Enum.TextTruncate.AtEnd local caret = W.icon("caret", C.faint, 10, { Parent = k.face, LayoutOrder = 3 }) k.label, k.caret, k.lead = l, caret, leadInst -- the caret flips up while its dropdown is open function k.flip(open: boolean) Motion.tween(caret, 0.3, { Rotation = if open then 180 else 0 }, Enum.EasingStyle.Back) W.tintIcon(caret, if open then C.tang else C.faint) end return k end local function hotkeyRight(id: string) local caps = ui.hotkeyCaps(id) if not caps or #caps == 0 or narrow then return nil end return function(r) W.shortcut(caps, { Parent = r, height = 16, textSize = 9 }) end end -- Attach (+) local attach if Context or Capture then attach = W.sq("plus", nil, { small = true, Parent = bar, order = 1, iconColor = C.ink, name = "Attach" }) W.tooltip(attach.outer, "Add context") W.dropdown(attach.outer, { width = 250, onOpen = function() Motion.tween(attach.icon, 0.25, { Rotation = 45 }, Enum.EasingStyle.Back) end, onClose = function() Motion.tween(attach.icon, 0.25, { Rotation = 0 }, Enum.EasingStyle.Back) end, build = function(dd) dd.header("Add to context") if Capture then local ok, why = Capture.available() dd.row({ text = "Screenshot", sub = if ok then "The viewport as you see it" else why, icon = "camera", iconColor = C.bub, disabled = not ok, right = hotkeyRight("screenshot"), rightWidth = 70, onSelect = function() ui.screenshot() end }) dd.row({ text = "Import image", sub = if ok then "PNG or JPG from your computer" else nil, icon = "image", iconColor = C.bub, disabled = not ok, onSelect = function() ui.importImages() end }) end if Context then if Capture then dd.sep() end dd.row({ text = "Selection", sub = "What you selected in Explorer or 3D", icon = "select", iconColor = C.tang, right = hotkeyRight("add_selection"), rightWidth = 70, onSelect = function() ui.addSelection() end }) dd.row({ text = "Open script", sub = "The script in the editor", icon = "script", iconColor = C.sky, right = hotkeyRight("add_script"), rightWidth = 70, onSelect = function() ui.addScript() end }) dd.row({ text = "Output errors", sub = "Recent errors and warnings", icon = "output", iconColor = C.butter, onSelect = function() ui.addErrors() end }) local caps = ui.hotkeyCaps("add_click") if Context.ctrlClick and Context.ctrlClick() then dd.note("Tip: " .. (if caps and #caps > 0 then table.concat(caps, "+") else "Ctrl+Click") .. " an object in the viewport adds it too.") end end end, }) end -- Model local modelKey = menuKey(2, function(f) local dot = new("Frame", { Parent = f, BackgroundColor3 = C.soft, BorderSizePixel = 0, Size = UDim2.fromOffset(8, 8) }, { W.round() }) return dot end, "Auto", "Model") W.dropdown(modelKey.outer, { width = 268, onOpen = function() modelKey.flip(true) end, onClose = function() modelKey.flip(false) end, build = function(dd) local planHue = HUES[Theme.PLAN_HUE[ui.plan] or "ink"] dd.header("Model", function(r) W.pill(string.upper(Logic.planName(ui.plan)) .. " PLAN", planHue[3], planHue[4], { Parent = r, size = 9, height = 16, padX = 6 }) end) local anyLocked = false for i, t in ui.tiers do local hue = HUES[Theme.TIER_HUE[t.id] or "tang"] or HUES.tang anyLocked = anyLocked or t.locked dd.row({ text = escape(t.label), sub = escape(t.note), icon = function(holder) if t.locked then local box = new("Frame", { Parent = holder, BackgroundColor3 = C.panel2, BorderSizePixel = 0, Size = UDim2.fromOffset(22, 22) }, { corner(7) }) W.icon("lock", C.faint, 12, { Parent = box, AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5) }) else local tile = new("Frame", { Parent = holder, BackgroundColor3 = hue[3], BorderSizePixel = 0, Size = UDim2.fromOffset(20, 20), Position = UDim2.fromOffset(1, 0), Rotation = -6 }, { corner(6), pad(0, 0, 0, 2) }) new("Frame", { Parent = tile, BackgroundColor3 = hue[1], BorderSizePixel = 0, Size = UDim2.fromScale(1, 1) }, { corner(6) }) end end, right = function(r) if t.locked and t.requires then local ph = HUES[Theme.PLAN_HUE[t.requires] or "tang"] W.pill(Logic.planName(t.requires), ph[3], ph[4], { Parent = r, size = 9.5, height = 18, padX = 6, order = 1 }) elseif t.factor then W.pill(Logic.factorText(t.factor), C.soft, C.panel2, { Parent = r, size = 9.5, height = 18, padX = 6, order = 1 }) end end, rightWidth = if t.locked then 64 else 52, selected = i == ui.tier, dim = t.locked, onSelect = function() if t.locked then ui.upgrade(t) else ui.setTier(i, true) end end, }) end if anyLocked then dd.sep() dd.row({ text = "Upgrade plan", sub = "More models, more credits", icon = "spark", iconColor = C.tang, onSelect = function() ui.upgrade(nil) end }) end dd.note("The factor multiplies the credits a build costs.") end, }) -- Workflow local flowKey = menuKey(3, function(f) return W.icon("flow", C.grape, 12, { Parent = f }) end, "Build", "Workflow") W.dropdown(flowKey.outer, { width = 280, onOpen = function() flowKey.flip(true) end, onClose = function() flowKey.flip(false) end, enabled = function() return #ui.catalog.workflows > 0 end, onBlocked = function() ui.toast("Workflows load once Synagy is connected") end, build = function(dd) dd.header("Workflow") local current for _, w in ui.catalog.workflows do local agents = if type(w.agents) == "table" then w.agents else {} if w.id == ui.workflow then current = w end dd.row({ text = escape(w.name), sub = escape(w.description or ""), icon = "flow", iconColor = if w.id == ui.workflow then C.grape else C.faint, right = if #agents > 0 and not compact then function(r) teamAvatars(r, agents, 16) end else nil, rightWidth = 16 + math.max(0, #agents - 1) * 11 + 6, selected = w.id == ui.workflow, onSelect = function() ui.setWorkflow(w.id, true) end, }) end if current and type(current.agents) == "table" and #current.agents > 0 then dd.sep() dd.note("Team: " .. roleNames(current.agents)) end end, }) -- Skills local skillsKey = menuKey(4, function(f) return W.icon("spark", C.lime, 12, { Parent = f }) end, "Skills", "Skills") W.dropdown(skillsKey.outer, { width = 260, onOpen = function() skillsKey.flip(true) end, onClose = function() skillsKey.flip(false) end, build = function(dd) local n = 0 for _ in ui.activeSkills do n += 1 end dd.header("Skills", function(r) if n > 0 then W.pill(("%d ON"):format(n), C.limeLip, C.limeTint, { Parent = r, size = 9, height = 16, padX = 6 }) end end) local skills = ui.catalog.skills if #skills == 0 then dd.note(if ui.Cloud.status == "online" then "No skills yet." else "Skills load once Synagy is connected.") else local sorted = table.clone(skills) table.sort(sorted, function(a, b) local ao, bo = ui.activeSkills[a.id] == true, ui.activeSkills[b.id] == true if ao ~= bo then return ao end return a.name < b.name end) for i = 1, math.min(#sorted, 7) do local k = sorted[i] local on = ui.activeSkills[k.id] == true dd.row({ text = escape(k.name), sub = escape(k.description or ""), right = function(r) local sw = W.switch(on, nil, { Parent = r, w = 28, h = 16 }) -- the row toggles; the switch only shows the state sw.frame.Active = false pcall(function() sw.frame.Interactable = false end) end, rightWidth = 32, keepOpen = true, onSelect = function() ui.toggleSkill(k.id) end, }) end end dd.sep() dd.row({ text = "All skills…", icon = "spark", iconColor = C.lime, onSelect = function() ui.setView("skills") end }) end, }) -- spacer, count local spacer = W.flex(new("Frame", { Parent = bar, BackgroundTransparency = 1, Size = UDim2.new(0, 0, 0, 28), LayoutOrder = 5 })) countLabel = new("TextLabel", { Parent = bar, BackgroundTransparency = 1, Text = "", FontFace = Theme.BODY, TextSize = 10, TextColor3 = C.faint, AutomaticSize = Enum.AutomaticSize.X, Size = UDim2.new(0, 0, 0, 28), LayoutOrder = 6 }) -- Hotkeys (?) local help = W.sq("help", function() ui.setView("hotkeys") end, { small = true, Parent = bar, order = 7, name = "Help" }) W.tooltip(help.outer, function() local caps = ui.hotkeyCaps("hotkeys") return "Shortcuts" .. (if caps and #caps > 0 then " · " .. table.concat(caps, "+") else "") end) -- Microphone local micKey, wave, waveBars local voiceState, voiceBase = "idle", "" if Voice then micKey = W.sq("mic", nil, { small = true, Parent = bar, order = 8, name = "Mic" }) wave = new("Frame", { Parent = micKey.face, BackgroundTransparency = 1, Size = UDim2.fromScale(1, 1), Visible = false }, { new("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, HorizontalAlignment = Enum.HorizontalAlignment.Center, VerticalAlignment = Enum.VerticalAlignment.Center, Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }), }) waveBars = {} for i = 1, 4 do waveBars[i] = new("Frame", { Parent = wave, BackgroundColor3 = C.white, BorderSizePixel = 0, Size = UDim2.fromOffset(3, 4), LayoutOrder = i }, { W.round() }) end local ring = new("UIStroke", { Parent = micKey.outer, Color = C.bub, Thickness = 0, Transparency = 0.3, ApplyStrokeMode = Enum.ApplyStrokeMode.Border }) local ringLoop W.tooltip(micKey.outer, function() local ok, why = Voice.available() if not ok then return "Voice input is unavailable: " .. tostring(why or "") end local caps = ui.hotkeyCaps("voice") return "Talk to Synagy (English)" .. (if caps and #caps > 0 then " · hold " .. table.concat(caps, "+") else "") end) local function paintMic() local ok = Voice.available() local listening = voiceState == "listening" local processing = voiceState == "processing" wave.Visible = listening micKey.icon.Visible = not listening if listening then micKey.setColors(C.bub, C.bub, C.white, C.bubLip) if not ringLoop then ring.Thickness = 0 ringLoop = Motion.pulse(ring, { Thickness = 4, Transparency = 0.85 }, 0.7) end else if ringLoop then ringLoop.stop() ringLoop = nil end ring.Thickness = if processing then 2 else 0 micKey.setColors(if processing then C.bubTint else C.panel, C.panel2, nil, C.line2) end W.tintIcon(micKey.icon, if not ok then C.panel4 elseif processing then C.bub else C.soft) end function Compose.voiceStart() local ok, why = Voice.available() if not ok then ui.toast("Voice input: " .. tostring(why or "unavailable"), "bub") return end voiceBase = if input.Text == "" then "" else input.Text .. " " Voice.start() end function Compose.voiceStop() if voiceState == "listening" then Voice.stop() end end micKey.outer.MouseButton1Click:Connect(function() if voiceState == "listening" then Compose.voiceStop() else Compose.voiceStart() end end) Voice.onState(function(state, info) voiceState = state paintMic() if state == "error" and info then ui.toast("Voice: " .. tostring(info), "bub") end end) Voice.onText(function(text, final) editing = true input.Text = voiceBase .. text editing = false lastText = input.Text task.spawn(grow) if final then voiceBase = input.Text .. " " end end) local seed = { 0.7, 1, 0.85, 0.55 } Voice.onLevel(function(level) if not wave.Visible then return end for i, b in waveBars do local h = 4 + math.clamp(level, 0, 1) * 14 * seed[i] * (0.75 + math.random() * 0.5) Motion.tween(b, 0.08, { Size = UDim2.fromOffset(3, math.floor(h)) }, Enum.EasingStyle.Quad) end end) paintMic() end -- Send / stop local send = W.sq("send", nil, { kind = "main", Parent = bar, order = 9, name = "Send", iconColor = C.white, s = 30 }) local stopIcon = W.icon("stop", C.white, 14, { Parent = send.face, AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5), Visible = false }) send.outer.MouseButton1Click:Connect(function() if ui.building then ui.stop() else if Compose.submit() then Motion.tween(send.icon, 0.12, { Position = UDim2.fromScale(0.5, 0.2) }, Enum.EasingStyle.Quad).Completed:Connect(function() send.icon.Position = UDim2.fromScale(0.5, 0.9) Motion.spring(send.icon, { Position = UDim2.fromScale(0.5, 0.5) }, 0.3) end) end end end) W.tooltip(send.outer, function() local caps = ui.hotkeyCaps("stop") return if ui.building then "Stop after this step" .. (if caps and #caps > 0 then " · " .. table.concat(caps, "+") else "") else "Send · Enter" end) function Compose.setBusy(building: boolean) send.icon.Visible = not building stopIcon.Visible = building send.setColors(if building then C.ink else C.tang, if building then C.ink2 else C.tang2, nil, if building then C.black else C.tangLip) Compose.refreshQuick() end function Compose.refreshTier() local t = ui.tiers[ui.tier] if not t then return end local hue = HUES[Theme.TIER_HUE[t.id] or "tang"] or HUES.tang modelKey.label.Text = escape(t.label) modelKey.lead.BackgroundColor3 = hue[1] Motion.bump(modelKey.lead, 0.5) end function Compose.refreshWorkflow() local name = "Build" for _, w in ui.catalog.workflows do if w.id == ui.workflow then name = w.name end end flowKey.label.Text = escape(name) end function Compose.refreshSkills() local n = 0 for _ in ui.activeSkills do n += 1 end skillsKey.label.Text = if compact then (if n > 0 then tostring(n) else "") elseif n > 0 then ("Skills · %d"):format(n) else "Skills" skillsKey.label.Visible = not compact or n > 0 skillsKey.setColors(if n > 0 then C.limeTint else C.panel, if n > 0 then C.limeTint else C.panel2, nil, if n > 0 then Color3.fromHex("BDE8AD") else C.line2) skillsKey.label.TextColor3 = if n > 0 then C.limeLip else C.ink end -- Width of the window: narrow wraps the bar (right-aligned lines), compact hides labels of workflow and skills. function Compose.setWidth(w: number) local n, c = w < 300, w < 240 if n == narrow and c == compact then return end narrow, compact = n, c barLayout.Wraps = narrow barLayout.HorizontalAlignment = if narrow then Enum.HorizontalAlignment.Right else Enum.HorizontalAlignment.Left spacer.Visible = not narrow countLabel.Visible = not narrow flowKey.label.Visible = not compact flowKey.caret.Visible = not compact skillsKey.caret.Visible = not compact facePad.PaddingLeft = UDim.new(0, if compact then 6 else 8) facePad.PaddingRight = UDim.new(0, if compact then 6 else 8) wrap:FindFirstChildOfClass("UIPadding").PaddingLeft = UDim.new(0, if compact then 8 else 12) wrap:FindFirstChildOfClass("UIPadding").PaddingRight = UDim.new(0, if compact then 8 else 12) Compose.refreshSkills() end Compose.frame = wrap Compose.refreshWorkflow() Compose.refreshSkills() return Compose end return Compose ]]> UiFeed 0 then return end table.insert(loops, Motion.loop(orbit, 1.6, { Rotation = 360 })) table.insert(loops, Motion.pulse(orbitScale, { Scale = 0.62 }, 0.9)) table.insert(loops, Motion.pulse(core, { Size = UDim2.fromOffset(11, 11) }, 0.9)) for i, d in orbDots do table.insert(loops, Motion.pulse(d, { Size = UDim2.fromOffset(4, 4) }, 0.6, i * 0.2)) end table.insert(loops, Motion.shimmer(shimmerGrad, 1.5)) end -- Agent team lane local lane = new("Frame", { Parent = live, Name = "Lane", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 30), LayoutOrder = 3, Visible = false }) local laneTrack = new("Frame", { Parent = lane, BackgroundColor3 = C.line2, BorderSizePixel = 0, Size = UDim2.new(0, 0, 0, 2), Position = UDim2.new(0, 10, 0, 10) }) local baton = new("Frame", { Parent = lane, Name = "Baton", BackgroundColor3 = C.tang, BorderSizePixel = 0, Size = UDim2.fromOffset(12, 3), Position = UDim2.fromOffset(4, 25), AnchorPoint = Vector2.new(0.5, 0) }, { W.round() }) local laneLabel = label("", { Parent = lane, wrap = false, TextSize = 11, TextColor3 = C.soft, Size = UDim2.new(1, 0, 0, 14), Position = UDim2.new(0, 0, 0, 3) }) local team, seen, avatars, activeRole = {}, {}, {}, nil local function drawLane() for _, a in avatars do a.frame:Destroy() end avatars = {} local roles = Logic.team(ui.teamFor(), seen) team = roles local step = 28 for i, role in roles do local info = Theme.ROLES[role] or { hue = "ink", label = role } local hue = HUES[info.hue] or HUES.ink local f = new("Frame", { Parent = lane, Name = "Avatar_" .. role, BackgroundColor3 = hue[4], BorderSizePixel = 0, AnchorPoint = Vector2.new(0.5, 0.5), Size = UDim2.fromOffset(20, 20), Position = UDim2.fromOffset(10 + (i - 1) * step, 11), ZIndex = 2 }, { W.round() }) local stroke = new("UIStroke", { Parent = f, Color = hue[2], Thickness = 0, ApplyStrokeMode = Enum.ApplyStrokeMode.Border }) local letter = new("TextLabel", { Parent = f, BackgroundTransparency = 1, Text = string.upper(string.sub(info.label, 1, 1)), FontFace = Theme.DISPLAY, TextSize = 10, TextColor3 = hue[3], Size = UDim2.fromScale(1, 1), ZIndex = 2 }) W.tooltip(f, info.label) avatars[role] = { frame = f, stroke = stroke, letter = letter, hue = hue, x = 10 + (i - 1) * step } end laneTrack.Size = UDim2.new(0, math.max(0, (#roles - 1) * step), 0, 2) laneLabel.Position = UDim2.new(0, 10 + math.max(0, #roles - 1) * step + 18, 0, 4) laneLabel.Size = UDim2.new(1, -(10 + math.max(0, #roles - 1) * step + 18), 0, 14) end local function setActive(role: string?, text: string?) for r, a in avatars do local on = r == role local wasSeen = table.find(seen, r) ~= nil a.frame.BackgroundColor3 = if on then a.hue[1] elseif wasSeen then a.hue[2] else a.hue[4] a.letter.TextColor3 = if on or wasSeen then C.white else a.hue[3] a.stroke.Thickness = if on then 2 else 0 local s = Motion.scaleOf(a.frame) Motion.spring(s, { Scale = if on then 1.18 else 0.9 }, 0.4) end local a = role and avatars[role] if a then baton.BackgroundColor3 = a.hue[1] Motion.tween(baton, 0.45, { Position = UDim2.fromOffset(a.x, 25), Size = UDim2.fromOffset(16, 3) }, Enum.EasingStyle.Back) local info = Theme.ROLES[role :: string] local name = escape(text or (info and info.label) or role) laneLabel.Text = "" .. name .. " is on it" end activeRole = role end function Feed.agent(agent: { role: string, label: string? }?) if not agent or type(agent.role) ~= "string" then return end local isNew = table.find(seen, agent.role) == nil if isNew then table.insert(seen, agent.role) end if isNew and not avatars[agent.role] then drawLane() end lane.Visible = true if activeRole ~= agent.role then setActive(agent.role, agent.label) elseif agent.label then laneLabel.Text = "" .. escape(agent.label) .. " is on it" end end function Feed.resetLane() seen, activeRole = {}, nil lane.Visible = false drawLane() end -- meta: "1:05 · Part 2/3" on the right of the live strip; fraction: whole-plan bar (nil hides it) function Feed.setMeta(meta: string, fraction: number?) liveMeta.Text = escape(meta) liveText.Size = UDim2.new(1, -(32 + (if meta == "" then 0 else (utf8.len(meta) or #meta) * 6 + 10)), 1, 0) liveBar.Visible = fraction ~= nil and live.Visible if fraction then Motion.tween(liveFill, 0.6, { Size = UDim2.new(math.clamp(fraction, 0, 1), 0, 1, 0) }) end end -- mode: hidden | thinking | building | connecting | offline; text: ticker or status line function Feed.setLive(mode: string, text: string?) local busy = mode == "thinking" or mode == "building" live.Visible = mode ~= "hidden" liveText.Text = escape(text or "") liveText.TextColor3 = if busy then C.ink else C.soft shimmerGrad.Enabled = busy orbit.Visible = busy core.BackgroundColor3 = if busy then C.tang elseif mode == "connecting" then C.butter else C.panel4 if busy and live.Visible then startLoops() else stopLoops() core.Size = UDim2.fromOffset(7, 7) end if not busy then liveMeta.Text = "" liveBar.Visible = false liveText.Size = UDim2.new(1, -32, 1, 0) lane.Visible = false elseif #seen > 0 then lane.Visible = true end end -- ── Messages ───────────────────────────────────────────────────────────── local function clearEmpty() if empty then empty:Destroy() empty = nil end end function Feed.message(text: string, from: string?) clearEmpty() if lastTyping then lastTyping.skip() lastTyping = nil end if from == "notice" or from == "system" then local row = new("Frame", { Parent = feed, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = nextOrder() }, { pad(0, 0, 0, 0) }) label(escape(text), { Parent = row, TextColor3 = C.faint, TextSize = 11, TextXAlignment = Enum.TextXAlignment.Center }) enter(row) return row end if from == "error" then local row = new("Frame", { Parent = feed, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = nextOrder() }, { pad(0, 0, 0, 0) }) local box = new("Frame", { Parent = row, BackgroundColor3 = C.badTint, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y }, { corner(12), pad(30, 8, 11, 8), new("UIStroke", { Color = C.badLine, Thickness = 1 }), }) W.icon("output", C.bad, 14, { Parent = box, Position = UDim2.new(0, -21, 0, 1) }) label(escape(text), { Parent = box, TextColor3 = C.bad, TextSize = 12 }) enter(row) Motion.bump(box, 0.04) scrollDown() return row end local user = from == "user" local row = new("Frame", { Parent = feed, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = nextOrder() }, { pad(0, 0, 0, 0) }) if user then -- Right-aligned bubble, tangerine tint with lip; width follows the text up to 85 % local params = Instance.new("GetTextBoundsParams") params.Text, params.Font, params.Size, params.Width = text, Theme.BODY, 13, 2000 local ok, size = pcall(function() return game:GetService("TextService"):GetTextBoundsAsync(params) end) local w = if ok then size.X + 30 else 9999 local outer, face = surface({ Parent = row, auto = true, radius = 16, lip = 2, size = UDim2.new(0.85, 0, 0, 0), bg = C.tangTint, border = C.tangLine }) outer.AnchorPoint = Vector2.new(1, 0) outer.Position = UDim2.new(1, 0, 0, 0) new("UISizeConstraint", { Parent = outer, MaxSize = Vector2.new(w, math.huge) }) pad(13, 9).Parent = face label(escape(text), { Parent = face, TextSize = 13 }) local s = Motion.scaleOf(outer) if Motion.isActive() then s.Scale = 0.9 Motion.spring(s, { Scale = 1 }, 0.4) end else -- Synagy: tilted tangerine tile as avatar, text next to it, typed in local avatar = new("Frame", { Parent = row, BackgroundColor3 = C.tangLip, Size = UDim2.new(0, 22, 0, 22), Rotation = -5, BorderSizePixel = 0 }, { corner(7), pad(0, 0, 0, 3) }) new("Frame", { Parent = avatar, BackgroundColor3 = C.tang, Size = UDim2.new(1, 0, 1, 0), BorderSizePixel = 0 }, { corner(7) }) local rich = W.markdown(text) local l = label(rich, { Parent = row, TextSize = 13, Position = UDim2.new(0, 31, 0, 2), Size = UDim2.new(1, -31, 0, 16) }) local n = Logic.plainLength(rich) lastTyping = Motion.typewriter(l, n, Logic.typeTime(n)) if Motion.isActive() then avatar.Rotation = -40 Motion.spring(avatar, { Rotation = -5 }, 0.5) end end enter(row) scrollDown() return row end -- ── Empty state ────────────────────────────────────────────────────────── function Feed.emptyState() if empty then return end empty = new("Frame", { Parent = feed, Name = "Empty", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = 0 }, { list(0), pad(0, 4, 0, 0) }) -- Three jelly tiles that float a little: the Synagy mark local hero = new("Frame", { Parent = empty, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 52), LayoutOrder = 1 }) for i, h in { "tang", "bub", "sky" } do local hue = HUES[h] local tile = new("Frame", { Parent = hero, BackgroundColor3 = hue[3], Size = UDim2.fromOffset(30 - i * 3, 30 - i * 3), Position = UDim2.fromOffset((i - 1) * 25, 8 + i * 3), Rotation = ({ -8, 6, -3 })[i], BorderSizePixel = 0, ZIndex = 4 - i }, { corner(9), pad(0, 0, 0, 3) }) new("Frame", { Parent = tile, BackgroundColor3 = hue[1], Size = UDim2.fromScale(1, 1), BorderSizePixel = 0, ZIndex = 4 - i }, { corner(9), new("UIStroke", { Color = hue[2], Thickness = 1, Transparency = 0.4 }) }) Motion.pulse(tile, { Position = UDim2.fromOffset((i - 1) * 25, 3 + i * 3), Rotation = tile.Rotation + (if i == 2 then -5 else 5) }, 1.6 + i * 0.25, i * 0.3) Motion.popIn(tile, 0.3, 0.5 + i * 0.08) end label('What should we build?', { Parent = empty, FontFace = Theme.DISPLAY, TextSize = 26, LayoutOrder = 2 }) gap(empty, 6, 3) label("Describe a game or a mechanic. Synagy plans it, shows the price and builds it right here in your place.", { Parent = empty, TextColor3 = C.soft, TextSize = 13, LayoutOrder = 4 }) gap(empty, 14, 5) local ideas = new("Frame", { Parent = empty, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = 6 }, { list(7) }) for i, idea in IDEAS do local hue = HUES[idea[2]] local holder = new("Frame", { Parent = ideas, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = i }, { pad(0, 0, 0, 0) }) local b = key(escape(idea[1]), "ghost", function() ui.setInput(idea[1]) end, { size = UDim2.new(1, 0, 0, 40), radius = 12, lip = 2, font = Theme.BODY, textSize = 12, align = Enum.TextXAlignment.Left, padX = 34, padRight = 22, hover = hue[4], Parent = holder }) local dot = new("Frame", { Parent = b.face, BackgroundColor3 = hue[1], Size = UDim2.fromOffset(8, 8), Position = UDim2.new(0, -21, 0.5, -4), BorderSizePixel = 0, Rotation = 45 }, { corner(2) }) local arrow = new("TextLabel", { Parent = b.face, BackgroundTransparency = 1, Text = "›", FontFace = Theme.XBOLD, TextSize = 15, TextColor3 = hue[1], Size = UDim2.new(0, 14, 1, 0), AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, 12, 0, 0) }) b.outer.MouseEnter:Connect(function() Motion.tween(arrow, 0.25, { Position = UDim2.new(1, 16, 0, 0) }) Motion.spring(dot, { Rotation = 135 }, 0.4) end) b.outer.MouseLeave:Connect(function() Motion.tween(arrow, 0.25, { Position = UDim2.new(1, 12, 0, 0) }) Motion.spring(dot, { Rotation = 45 }, 0.4) end) enter(holder, 0.05 * i) end -- Hotkey tips (from Hotkeys.list(); hidden without Hotkeys) local tips = ui.tips() if #tips > 0 then gap(empty, 16, 20) label("SHORTCUTS", { Parent = empty, wrap = false, FontFace = Theme.XBOLD, TextSize = 10, TextColor3 = C.faint, Size = UDim2.new(1, 0, 0, 14), LayoutOrder = 21 }) gap(empty, 6, 22) local box = new("Frame", { Parent = empty, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = 23 }, { list(6) }) -- narrow docks stack the label over its keys local stacked = ui.width() < 300 for i, h in tips do local row = new("Frame", { Parent = box, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, if stacked then 42 else 22), LayoutOrder = i }) local sc = W.shortcut(Logic.keycaps(h.keys), { Parent = row }) if stacked then sc.Position = UDim2.new(0, 0, 0, 18) else sc.AnchorPoint = Vector2.new(1, 0) sc.Position = UDim2.new(1, 0, 0, 0) end label(escape(h.label), { Parent = row, wrap = false, TextSize = 12, TextColor3 = C.soft, Size = UDim2.new(1, if stacked then 0 else -150, 0, if stacked then 16 else 22), TextYAlignment = Enum.TextYAlignment.Center }) end local more = new("TextButton", { Parent = empty, BackgroundTransparency = 1, Text = "All shortcuts ›", FontFace = Theme.BOLD, TextSize = 11.5, TextColor3 = C.sky, AutomaticSize = Enum.AutomaticSize.X, Size = UDim2.new(0, 0, 0, 24), LayoutOrder = 24, TextXAlignment = Enum.TextXAlignment.Left }) more.MouseButton1Click:Connect(function() ui.setView("hotkeys") end) end gap(empty, 12, 40) key("More templates ›", "plain", function() ui.setView("templates") end, { order = 41, Parent = empty }) end -- ── Questions ──────────────────────────────────────────────────────────── questionsBox = new("Frame", { Parent = parent, Name = "Questions", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = 3, Visible = false }, { pad(14, 0, 14, 8), list(6) }) function Feed.clearQuestions() for _, c in questionsBox:GetChildren() do if c:IsA("GuiObject") then c:Destroy() end end questionsBox.Visible = false end function Feed.questions(msg) Feed.clearQuestions() for i, q in msg.items or {} do label(escape(q.question), { Parent = questionsBox, FontFace = Theme.BOLD, TextSize = 12.5, LayoutOrder = i * 10 }) local opts = new("Frame", { Parent = questionsBox, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = i * 10 + 1 }, { new("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Wraps = true, Padding = UDim.new(0, 6), SortOrder = Enum.SortOrder.LayoutOrder }), }) for j, option in q.options or {} do local b = key(escape(option), "ghost", function() ui.sendChat(option) end, { height = 30, radius = 10, lip = 2, font = Theme.BOLD, textSize = 12, padX = 12, order = j, hover = C.tangTint, Parent = opts }) Motion.popIn(b.outer, 0.7, 0.35 + j * 0.05) end end questionsBox.Visible = #(msg.items or {}) > 0 end -- ── Plan and parts ─────────────────────────────────────────────────────── local function card(o: number) local outer, face = surface({ Parent = feed, auto = true, radius = 18, lip = 3, order = o, name = "Card" }) pad(14, 14).Parent = face list(0).Parent = face return outer, face end local function partCard(id: string) local p = parts[id] if p then return p end local n = 0 for _ in parts do n += 1 end local holder = new("Frame", { Parent = feed, Name = "PartHolder", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = planOrder + 1 + n }, { pad(0, 0, 0, 0) }) local outer, face = surface({ Parent = holder, auto = true, radius = 18, lip = 3, name = "Card" }) pad(14, 13).Parent = face list(0).Parent = face local head = new("Frame", { Parent = face, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 20), LayoutOrder = 1 }) local name = label("", { Parent = head, wrap = false, FontFace = Theme.BOLD, TextSize = 13, Size = UDim2.new(1, if ui.narrow then -62 else -96, 1, 0), TextYAlignment = Enum.TextYAlignment.Center }) local stage = pill("", C.soft, C.panel2, { Parent = head, size = 9.5, height = 18 }) stage.AnchorPoint = Vector2.new(1, 0.5) stage.Position = UDim2.new(1, 0, 0.5, 0) gap(face, 10, 2) local track = new("Frame", { Parent = face, BackgroundColor3 = C.panel2, Size = UDim2.new(1, 0, 0, 8), LayoutOrder = 3, BorderSizePixel = 0, ClipsDescendants = true }, { W.round() }) local fill = new("Frame", { Parent = track, BackgroundColor3 = C.tang, Size = UDim2.new(0, 0, 1, 0), BorderSizePixel = 0, ClipsDescendants = true }, { W.round(), new("UIGradient", { Color = ColorSequence.new(C.tang2, C.tang), Rotation = 90 }), }) -- the glow that sweeps over the fill while it builds local sheen = new("Frame", { Parent = fill, BackgroundColor3 = C.white, Size = UDim2.fromScale(1, 1), BorderSizePixel = 0 }, { W.round() }) local sheenGrad = new("UIGradient", { Parent = sheen, Transparency = NumberSequence.new({ NumberSequenceKeypoint.new(0, 1), NumberSequenceKeypoint.new(0.42, 1), NumberSequenceKeypoint.new(0.5, 0.35), NumberSequenceKeypoint.new(0.58, 1), NumberSequenceKeypoint.new(1, 1), }), }) gap(face, 7, 4) local text = label("", { Parent = face, TextColor3 = C.soft, TextSize = 11, LayoutOrder = 5 }) local foot = new("Frame", { Parent = face, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = 6, Visible = false }, { pad(0, 10, 0, 0) }) p = { card = outer, holder = holder, name = name, stage = stage, fill = fill, sheen = sheen, sheenLoop = Motion.shimmer(sheenGrad, 1.1), label = text, foot = foot, status = "building" } parts[id] = p enter(holder) scrollDown() return p end local function partFoot(id: string, p) if p.foot:FindFirstChild("Row") then return end local row = new("Frame", { Parent = p.foot, Name = "Row", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 30), AutomaticSize = Enum.AutomaticSize.Y }, { new("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Wraps = true, Padding = UDim.new(0, 6), SortOrder = Enum.SortOrder.LayoutOrder, VerticalAlignment = Enum.VerticalAlignment.Center }), }) local up = W.sq(nil, nil, { small = true, Parent = row, text = "✓", textSize = 13, order = 1 }) local down = W.sq(nil, nil, { small = true, Parent = row, text = "×", textSize = 14, order = 2 }) W.tooltip(up.outer, "Good part") W.tooltip(down.outer, "Ask for a change") up.outer.MouseButton1Click:Connect(function() ui.Cloud.send({ t = "feedback", componentId = id, liked = true, text = "" }) up.setColors(C.tangTint, C.tangTint, C.tang) Motion.bump(up.outer, 0.2) ui.toast("Thanks!", "lime") end) down.outer.MouseButton1Click:Connect(function() ui.Cloud.send({ t = "feedback", componentId = id, liked = false, text = "" }) local item = ui.planItems[id] ui.setInput("Change " .. (item and item.name or "this") .. ": ") end) local paths = ui.touched(id) if paths and #paths > 0 then local show show = key("Show me", "ghost", function() if not ui.focus(ui.touched(id) or paths) then show.outer.Visible = false end end, { height = 28, radius = 9, lip = 2, padX = 11, textSize = 11.5, order = 3, Parent = row }) end key("▶ Try it", "plain", ui.runPlay, { height = 28, radius = 9, padX = 12, textSize = 11.5, order = 4, Parent = row }) end local function updatePart(id: string, data) local p = partCard(id) local item = ui.planItems[id] p.name.Text = escape(data.name or (item and item.name) or id) local before = p.status local status = data.status or p.status p.status = status local stage = if status == "done" then "done" elseif status == "failed" then "failed" else (data.stage or "build") local colors = Theme.STAGE[stage] or { C.soft, C.panel2 } local stageText = string.upper(stage) .. (if status == "building" and (data.attempt or 1) > 1 then " · TRY " .. data.attempt else "") if p.stage.Text ~= stageText then p.stage.Text = stageText Motion.bump(p.stage, 0.18) end p.stage.TextColor3, p.stage.BackgroundColor3 = colors[1], colors[2] local fraction = if status == "done" or status == "failed" then 1 else math.clamp(data.fraction or 0, 0, 1) p.fill.BackgroundColor3 = if status == "done" then C.lime elseif status == "failed" then C.bad else C.tang local grad = p.fill:FindFirstChildOfClass("UIGradient") if grad then grad.Enabled = status == "building" end Motion.tween(p.fill, 0.6, { Size = UDim2.new(fraction, 0, 1, 0) }, Enum.EasingStyle.Quint) if status ~= "building" and p.sheenLoop then p.sheenLoop.stop() p.sheenLoop = nil p.sheen.Visible = false end p.label.Text = escape(if status == "failed" then (data.reason or "Failed") elseif status == "done" then ("Built · %d credits"):format(data.charged or 0) else (data.label or "")) if status == "done" then p.foot.Visible = true partFoot(id, p) if before ~= "done" then -- success burst from the stage pill task.defer(function() local pos = p.stage.AbsolutePosition + p.stage.AbsoluteSize / 2 ui.burst(pos.X, pos.Y) end) ui.built = true end elseif status == "failed" and before ~= "failed" then Motion.bump(p.card, 0.03) end end function Feed.plan(msg) clearEmpty() Feed.clearQuestions() local open = not msg.confirmed local building = false for _, item in msg.items or {} do if msg.confirmed and not item.removed and (item.status == "open" or item.status == "building") then building, open = true, true end end -- An unfinished plan updates in place; a new plan after a finished one starts a new block if planCard and not planOpen then planCard, parts = nil, {} end local fresh = planCard == nil planOpen = open if not planCard then planOrder = nextOrder(100) nextOrder(100) -- room for the part cards end if planCard then planCard:Destroy() end ui.planItems = {} local outer, face = card(planOrder) planCard = outer local stickerHue = if msg.confirmed then (if building then "sky" else "lime") else nil W.sticker(if msg.confirmed then (if building then "Building" else "Done") else "Plan", face, 1, stickerHue) gap(face, 9, 2) label(escape(msg.name or "Plan"), { Parent = face, FontFace = Theme.DISPLAY, TextSize = 17, LayoutOrder = 3 }) if msg.summary and msg.summary ~= "" then gap(face, 3, 4) label(escape(msg.summary), { Parent = face, TextColor3 = C.soft, TextSize = 12, LayoutOrder = 5 }) end gap(face, 8, 6) for i, item in msg.items or {} do ui.planItems[item.id] = item local row = new("Frame", { Parent = face, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = 10 + i }, { pad(30, 9, 0, 9) }) new("Frame", { Parent = row, BackgroundColor3 = C.line, BorderSizePixel = 0, Size = UDim2.new(1, 30, 0, 1), Position = UDim2.new(0, -30, 0, -9) }) local canToggle = not msg.confirmed and item.kind ~= "planning" local on = not item.removed local check check = key(if on then "✓" else "", "soft", function() if canToggle then Motion.bump(check.outer, 0.25) ui.Cloud.send({ t = "plan_toggle", id = item.id, removed = on }) end end, { size = UDim2.new(0, 20, 0, 20), radius = 6, lip = 2, padX = 0, textSize = 11, font = Theme.XBOLD, Parent = row, bg = if on then C.tang else C.panel, textColor = C.white, hover = if on then C.tang2 else C.panel2, lipColor = if on then C.tangLip else nil }) check.outer.Position = UDim2.new(0, -30, 0, 0) if not canToggle then check.face.BackgroundTransparency = 0.35 end -- narrow docks: the price goes under the name instead of beside it local narrowRow = ui.narrow local textCol = new("Frame", { Parent = row, BackgroundTransparency = 1, Size = UDim2.new(1, if narrowRow then 0 else -64, 0, 0), AutomaticSize = Enum.AutomaticSize.Y }, { list(if narrowRow then 3 else 1) }) local alpha = if item.removed then 0.6 else 0 label(escape(item.name), { Parent = textCol, FontFace = Theme.BOLD, TextSize = 12.5, LayoutOrder = 1, TextTransparency = alpha }) if item.summary and item.summary ~= "" then label(escape(item.summary), { Parent = textCol, TextColor3 = C.soft, TextSize = 11, LayoutOrder = 2, TextTransparency = alpha }) end local status = item.status or "open" local price = pill(if status == "done" then "✓ paid" elseif status == "failed" then "free" else tostring(item.price) .. " cr", if status == "done" then C.ok elseif status == "failed" then C.bad else C.ink, if status == "done" then C.limeTint elseif status == "failed" then C.badTint else C.panel2, { Parent = if narrowRow then textCol else row, order = 3, height = if narrowRow then 18 else 20 }) if not narrowRow then price.AnchorPoint = Vector2.new(1, 0) price.Position = UDim2.new(1, 0, 0, 0) end price.TextTransparency = alpha if fresh then enter(row, 0.04 * i) end if msg.confirmed and item.kind ~= "planning" and not item.removed and status ~= "open" and not parts[item.id] then updatePart(item.id, { status = status, stage = if status == "building" then "build" else status, fraction = 0, name = item.name }) end end if not msg.confirmed then local foot = new("Frame", { Parent = face, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = 100 }, { pad(0, 12, 0, 0) }) new("Frame", { Parent = foot, BackgroundColor3 = C.line, BorderSizePixel = 0, Size = UDim2.new(1, 0, 0, 1), Position = UDim2.new(0, 0, 0, -12) }) local narrow = ui.narrow local total = label(("%s %d credits · you have %s"):format(if msg.affordable == false then "Needs" else "Total", msg.total or 0, Logic.formatCredits(msg.credits or 0)), { Parent = foot, TextColor3 = if msg.affordable == false then C.bad else C.soft, TextSize = 12, Size = UDim2.new(1, if narrow then 0 else -120, 0, 16) }) total.Position = UDim2.new(0, 0, 0, if narrow then 0 else 9) local ok = msg.affordable ~= false and (msg.total or 0) > 0 and ui.Cloud.status == "online" local build = key(("Build · %d"):format(msg.total or 0), "main", function() ui.Cloud.send({ t = "plan_confirm" }) end, { Parent = foot }) build.outer.AnchorPoint = Vector2.new(if narrow then 0 else 1, 0) build.outer.Position = if narrow then UDim2.new(0, 0, 0, 24) else UDim2.new(1, 0, 0, 0) build.setEnabled(ok) if ok then -- a soft glow ring that breathes around the Build key local glow = new("UIStroke", { Parent = build.outer, Color = C.tangGlow, Thickness = 0, Transparency = 0.2, ApplyStrokeMode = Enum.ApplyStrokeMode.Border }) Motion.pulse(glow, { Thickness = 4, Transparency = 0.7 }, 0.9) end end if fresh then enter(outer) end scrollDown() return building end function Feed.progress(msg) if ui.planItems[msg.componentId] and ui.planItems[msg.componentId].kind == "planning" then return end if type(msg.componentId) ~= "string" or msg.componentId == "" then return end updatePart(msg.componentId, { status = "building", stage = msg.stage, fraction = msg.fraction, label = msg.label, attempt = msg.attempt, name = msg.name }) end function Feed.done(id: string, charged: number?) updatePart(id, { status = "done", charged = charged }) end function Feed.failed(id: string, reason: string?) updatePart(id, { status = "failed", reason = reason }) end function Feed.clear() for _, child in feed:GetChildren() do if child:IsA("GuiObject") then child:Destroy() end end empty, planCard, parts, planOpen = nil, nil, {}, false ui.planItems = {} ui.built = false Feed.clearQuestions() Feed.resetLane() Feed.emptyState() end -- Redraw the empty state (e.g. after the hotkey list changed) function Feed.refreshEmpty() if empty then empty:Destroy() empty = nil Feed.emptyState() end end Feed.frame = feed drawLane() return Feed end return Feed ]]> UiHotkeys 0 then caps else { "Ctrl", "Click" }, { Parent = top, height = 19, textSize = 10 }) local sw = W.switch(ctx.ctrlClick(), function(on) -- one setting (SynagyCtrlClick) behind both; Hotkeys keeps the list in step if ui.Hotkeys then pcall(ui.Hotkeys.setEnabled, "add_click", on) elseif ctx.setCtrlClick then ctx.setCtrlClick(on) end ui.toast(if on then "Ctrl+Click adds objects to the chat" else "Ctrl+Click is off", "tang") end, { Parent = top, hue = "tang", w = 34, h = 20 }) sw.frame.AnchorPoint = Vector2.new(1, 0) sw.frame.Position = UDim2.new(1, 0, 0, 0) sc.Position = UDim2.new(0, 0, 0, 0) local text = label("Add from the viewport. Hold Ctrl and click a part while Synagy is open – it lands in the chat as context.", { Parent = face, TextSize = 11.5, TextColor3 = C.soft, Position = UDim2.new(0, 0, 0, 28), Size = UDim2.new(1, 0, 0, 16) }) local holder = new("Frame", { Parent = face, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0) }) text:GetPropertyChangedSignal("Size"):Connect(function() holder.Size = UDim2.new(1, 0, 0, 28 + text.Size.Y.Offset) end) holder.Size = UDim2.new(1, 0, 0, 44) if animate then Motion.popIn(outer, 0.92, 0.35) end end local groups = Logic.hotkeyGroups(all(), search.Text) for gi, g in groups do local hue = HUES[GROUP_HUE[g.name] or "ink"] local h = new("Frame", { Parent = listFrame, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 24), LayoutOrder = gi * 10 }) new("Frame", { Parent = h, BackgroundColor3 = hue[1], BorderSizePixel = 0, Size = UDim2.fromOffset(7, 7), Position = UDim2.new(0, 3, 0, 11), Rotation = 45 }, { corner(2) }) label(string.upper(escape(g.name)), { Parent = h, wrap = false, FontFace = Theme.XBOLD, TextSize = 10.5, TextColor3 = C.faint, Size = UDim2.new(1, -20, 0, 14), Position = UDim2.new(0, 17, 0, 7) }) local outer, face = surface({ Parent = listFrame, auto = true, radius = 14, lip = 3, order = gi * 10 + 1 }) face.ClipsDescendants = true list(0).Parent = face for i, item in g.items do row(item, face, i, i == 1) end if animate then Motion.popIn(outer, 0.94, 0.35 + gi * 0.05) end end if #groups == 0 then local _, face = surface({ Parent = listFrame, auto = true, radius = 16, lip = 3, order = 1 }) pad(16, 14).Parent = face label("Nothing matches. Try “screenshot” or “Ctrl”.", { Parent = face, TextColor3 = C.soft, TextSize = 12 }) end end search:GetPropertyChangedSignal("Text"):Connect(function() if view.Visible then draw(false) end end) function api.show() search.Text = "" draw(true) end function api.redraw() if view.Visible then draw(false) end end return api end return Hotkeys ]]> UiLibrary 1 then new("Frame", { Parent = row, BackgroundColor3 = C.line, BorderSizePixel = 0, Size = UDim2.new(1, 0, 0, 1) }) end end return outer end local draw local function skillRow(k, parentFrame: Instance, order: number, first: boolean) local on = ui.activeSkills[k.id] == true local hueNames = { "tang", "bub", "sky", "lime", "grape" } local hue = HUES[hueNames[(#k.name % #hueNames) + 1]] local row = new("TextButton", { Parent = parentFrame, Text = "", AutoButtonColor = false, BackgroundColor3 = if on then C.limeWash else C.panel, BorderSizePixel = 0, Size = UDim2.new(1, 0, 0, 50), LayoutOrder = order }) if not first then new("Frame", { Parent = row, BackgroundColor3 = C.line, BorderSizePixel = 0, Size = UDim2.new(1, 0, 0, 1) }) end local orb = new("Frame", { Parent = row, BackgroundColor3 = hue[3], Size = UDim2.new(0, 28, 0, 28), Position = UDim2.new(0, 10, 0.5, -14), Rotation = -4, BorderSizePixel = 0 }, { corner(9), pad(0, 0, 0, 3) }) local orbCore = new("Frame", { Parent = orb, BackgroundColor3 = hue[1], Size = UDim2.new(1, 0, 1, 0), BorderSizePixel = 0 }, { corner(9) }) new("TextLabel", { Parent = orbCore, BackgroundTransparency = 1, Text = string.upper(string.sub(k.name, 1, 1)), FontFace = Theme.DISPLAY, TextSize = 13, TextColor3 = C.white, Size = UDim2.new(1, 0, 1, 0) }) label(escape(k.name), { Parent = row, FontFace = Theme.BOLD, TextSize = 13, wrap = false, Position = UDim2.new(0, 49, 0, 9), Size = UDim2.new(1, -110, 0, 16) }) label(escape(k.description), { Parent = row, TextColor3 = C.soft, TextSize = 11, wrap = false, Position = UDim2.new(0, 49, 0, 26), Size = UDim2.new(1, -110, 0, 14) }) local sw = W.switch(on, nil, { Parent = row, w = 36, h = 22 }) sw.frame.AnchorPoint = Vector2.new(1, 0.5) sw.frame.Position = UDim2.new(1, -12, 0.5, 0) local function toggle() local now = ui.toggleSkill(k.id) sw.set(now, true) Motion.spring(orb, { Rotation = if now then 8 else -4 }, 0.4) -- After a short pause the skill slides into its group, like the Unity window task.delay(0.3, function() if kind == "skills" then draw(false) end end) end row.MouseEnter:Connect(function() if not ui.activeSkills[k.id] then row.BackgroundColor3 = C.panel2 end end) row.MouseLeave:Connect(function() row.BackgroundColor3 = if ui.activeSkills[k.id] then C.limeWash else C.panel end) row.MouseButton1Click:Connect(toggle) -- the row toggles; the switch only shows the state sw.frame.Active = false pcall(function() sw.frame.Interactable = false end) return row end draw = function(animate: boolean) for _, c in listFrame:GetChildren() do if c:IsA("GuiObject") then c:Destroy() end end local q = string.lower(search.Text) local function hit(text: string) return q == "" or string.find(string.lower(text), q, 1, true) ~= nil end local sec = SECTION[kind] tile.BackgroundColor3 = sec[1] tileLip.BackgroundColor3 = HUES[if kind == "templates" then "bub" else "lime"][3] if kind == "templates" then glyph.Text, title.Text = "T", "Templates" lead.Text = "Pick a genre – Synagy writes the plan, you press Enter to build it." local hits = {} for _, t in STARTERS do if hit(t.title .. " " .. t.tagline) then table.insert(hits, t) end end if #hits == 0 then emptyCard("Nothing matches.", "Try another word, or just describe your game in the chat.", 1) return end section("Made for Roblox", #hits, 1) local grid = new("Frame", { Parent = listFrame, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = 2 }, { new("UIGridLayout", { CellSize = UDim2.new(if ui.width() < 230 then 1 else 0.5, -6, 0, 172), CellPadding = UDim2.new(0, 12, 0, 12), SortOrder = Enum.SortOrder.LayoutOrder }), }) for i, t in hits do cartridge(t, grid, i, animate) end else glyph.Text, title.Text = "S", "Skills" lead.Text = "House rules the builder follows on every build. Flip them on for this chat." if #ui.catalog.skills == 0 and ui.Cloud.status ~= "online" and ui.Cloud.status ~= "signed_out" then skeleton() return end local on, groups, groupOrder = {}, {}, {} for _, k in ui.catalog.skills do if hit(k.name .. " " .. (k.description or "")) then if ui.activeSkills[k.id] then table.insert(on, k) else local g = if k.group and k.group ~= "" then k.group else "More" if not groups[g] then groups[g] = {} table.insert(groupOrder, g) end table.insert(groups[g], k) end end end local n = 0 local function box(boxTitle: string, items: { any }) if #items == 0 then return end n += 1 local h = section(boxTitle, #items, n * 10) if boxTitle == "On for this chat" then local off = key("Turn all off", "soft", function() ui.clearSkills() draw(false) end, { height = 22, radius = 7, lip = 2, padX = 8, textSize = 10, Parent = h }) off.outer.AnchorPoint = Vector2.new(1, 0) off.outer.Position = UDim2.new(1, -28, 0, 3) end local outer, face = surface({ Parent = listFrame, auto = true, radius = 16, lip = 3, order = n * 10 + 1 }) face.ClipsDescendants = true list(0).Parent = face for i, k in items do skillRow(k, face, i, i == 1) end if animate then Motion.popIn(outer, 0.92, 0.35 + n * 0.05) end gap(listFrame, 4, n * 10 + 2) end box("On for this chat", on) for _, g in groupOrder do box(string.upper(string.sub(g, 1, 1)) .. string.sub(g, 2), groups[g]) end if n == 0 then if #ui.catalog.skills == 0 then emptyCard("No skills yet.", if ui.Cloud.status == "online" then "Skills are house rules like Game Feel or Low-poly Look." else "They load once Synagy is connected.", 1) else emptyCard("Nothing matches.", "Try another word.", 1) end end end end search:GetPropertyChangedSignal("Text"):Connect(function() if view.Visible then draw(false) end end) search.Focused:Connect(function() searchOuter.BackgroundColor3 = SECTION[kind][1] end) search.FocusLost:Connect(function() searchOuter.BackgroundColor3 = C.line2 end) local api = { frame = view } -- Show templates or skills; animate = first draw after switching function api.show(which: string) local changed = which ~= lastDrawn kind = which lastDrawn = which search.Text = "" draw(changed) end function api.redraw() if view.Visible then draw(false) end end return api end return Library ]]> UiLogic 0 then fromCloud else Logic.DEFAULT_TIERS local out = {} for _, t in src do if type(t) == "table" and type(t.id) == "string" then table.insert(out, { id = t.id, label = if type(t.label) == "string" and t.label ~= "" then t.label else t.id, note = if type(t.note) == "string" then t.note else "", factor = if type(t.factor) == "number" then t.factor else nil, locked = t.locked == true, requires = if type(t.requires) == "string" then t.requires else nil, }) end end return out end function Logic.factorText(f: number?): string if type(f) ~= "number" then return "" end if f == math.floor(f) then return "×" .. tostring(math.floor(f)) end return "×" .. (string.gsub(string.format("%.2f", f), "0+$", "")) end function Logic.tierIndex(tiers: { any }, id: string?): number? for i, t in tiers do if t.id == (id or "") then return i end end return nil end -- Next unlocked tier after index i (wraps); i itself when nothing else is unlocked. function Logic.nextTier(tiers: { any }, i: number): number local n = #tiers for step = 1, n do local j = (i - 1 + step) % n + 1 if not tiers[j].locked then return j end end return i end -- The saved tier, or the first unlocked one when it is gone or locked now. function Logic.pickTier(tiers: { any }, savedId: string?): number local i = Logic.tierIndex(tiers, savedId) if i and not tiers[i].locked then return i end for j, t in tiers do if not t.locked then return j end end return 1 end -- ── Numbers and text ──────────────────────────────────────────────────────── function Logic.formatCredits(n: number?): string if type(n) ~= "number" then return "–" end local s = tostring(math.floor(n)) local neg = string.sub(s, 1, 1) == "-" if neg then s = string.sub(s, 2) end s = string.reverse((string.gsub(string.reverse(s), "(%d%d%d)", "%1,"))) s = string.gsub(s, "^,", "") return (if neg then "-" else "") .. s end function Logic.trim(s: string?): string return (string.gsub(string.gsub(s or "", "^%s+", ""), "%s+$", "")) end -- Visible characters of rich text (for the typewriter): tags and entities count as what they show. function Logic.plainLength(rich: string): number local plain = string.gsub(rich, "<[^>]->", "") plain = string.gsub(plain, "&%a+;", "x") return utf8.len(plain) or #plain end -- Typewriter duration: quick for short replies, never longer than 1.2 s. function Logic.typeTime(chars: number): number return math.clamp(chars / 110, 0.2, 1.2) end -- ── Dropdowns ─────────────────────────────────────────────────────────────── -- Keyboard step in a list of rows; disabled rows are skipped, wraps around. index 0 = nothing highlighted. function Logic.navigate(disabled: { boolean }, index: number, dir: number): number local n = #disabled if n == 0 then return 0 end local i = index for _ = 1, n do if i == 0 then i = if dir > 0 then 1 else n else i = (i - 1 + dir) % n + 1 end if not disabled[i] then return i end end return 0 end -- Place a panel of (w, h) next to an anchor inside an area of (areaW, areaH): above the anchor when it fits (the -- compose bar sits at the bottom), else below, else on the side with more room and scrolling. Horizontally aligned -- with the anchor's left edge and clamped into the area. Returns x, y, width, height. function Logic.place(ax: number, ay: number, aw: number, ah: number, w: number, h: number, areaW: number, areaH: number, margin: number?) local m = margin or 8 local width = math.max(60, math.min(w, areaW - 2 * m)) local x = math.clamp(ax, m, math.max(m, areaW - m - width)) local gapY = 6 local above = ay - gapY - m local below = areaH - (ay + ah + gapY) - m local height, y if h <= above then height, y = h, ay - gapY - h elseif h <= below then height, y = h, ay + ah + gapY elseif above >= below then height = math.max(40, above) y = ay - gapY - height else height = math.max(40, below) y = ay + ah + gapY end return x, y, width, height end -- Hover intent of a dropdown: opens after `openDelay` of hovering the trigger, closes `closeDelay` after the pointer -- left both trigger and panel. A pinned (clicked) dropdown ignores leaving. The widget calls the methods with the -- clock and asks `due(now)` from a task.delay. function Logic.intent(openDelay: number, closeDelay: number) local self = { open = false, pinned = false, inside = 0, since = 0, want = nil :: string? } function self.enter(now: number, instant: boolean?) self.inside += 1 if not self.open then self.want, self.since = "open", now - (if instant then openDelay else 0) else self.want = nil end end function self.leave(now: number) self.inside = math.max(0, self.inside - 1) if self.inside == 0 then if self.open and not self.pinned then self.want, self.since = "close", now elseif not self.open then self.want = nil end end end function self.click() if self.open and self.pinned then self.open, self.pinned, self.want = false, false, nil return "close" end self.open, self.pinned, self.want = true, true, nil return "open" end -- "open" / "close" when a pending change is due now, else nil function self.due(now: number): string? if self.want == "open" and self.inside > 0 and now - self.since >= openDelay - 1e-3 then self.open, self.want = true, nil return "open" elseif self.want == "close" and self.inside == 0 and not self.pinned and now - self.since >= closeDelay - 1e-3 then self.open, self.want = false, nil return "close" end return nil end -- Closed from outside (selection, Esc, another dropdown): forget hover state too, the panel is gone function self.reset() self.open, self.pinned, self.want, self.inside = false, false, nil, 0 end return self end -- ── Context chips ─────────────────────────────────────────────────────────── -- How many chips fit into one row of `width` (chips ~`chipW` wide, `gap` apart) when one slot is kept for "+N". function Logic.chipSplit(count: number, width: number, chipW: number, gap: number): (number, number) local fit = math.max(1, math.floor((width + gap) / (chipW + gap))) if count <= fit then return count, 0 end local shown = math.max(1, fit - 1) return shown, count - shown end -- ── Prompt history (↑ in an empty input) ──────────────────────────────────── function Logic.history(max: number?) local self = { items = {}, pos = 0 } local cap = max or 20 function self.push(text: string) text = Logic.trim(text) if text == "" then return end if self.items[#self.items] ~= text then table.insert(self.items, text) if #self.items > cap then table.remove(self.items, 1) end end self.pos = 0 end -- One step back (older); nil when there is nothing function self.prev(): string? if #self.items == 0 then return nil end self.pos = math.min(self.pos + 1, #self.items) return self.items[#self.items - self.pos + 1] end function self.next(): string? if self.pos <= 1 then self.pos = 0 return "" end self.pos -= 1 return self.items[#self.items - self.pos + 1] end return self end -- ── Input: Enter sends, Shift+Enter keeps the newline ─────────────────────── -- Compares the text before and after one edit. Returns the inserted newline's position when exactly one "\n" was -- typed, else nil. function Logic.typedNewline(before: string, after: string): number? if #after ~= #before + 1 then return nil end for i = 1, #after do if string.sub(after, i, i) ~= string.sub(before, i, i) then if string.sub(after, i, i) == "\n" and string.sub(after, i + 1) == string.sub(before, i) then return i end return nil end end return nil end function Logic.removeAt(s: string, i: number): string return string.sub(s, 1, i - 1) .. string.sub(s, i + 1) end -- ── Hotkeys page ──────────────────────────────────────────────────────────── local KEY_NAMES = { LeftControl = "Ctrl", RightControl = "Ctrl", Control = "Ctrl", LeftShift = "Shift", RightShift = "Shift", LeftAlt = "Alt", RightAlt = "Alt", Return = "Enter", Escape = "Esc", Up = "↑", Down = "↓", MouseButton1 = "Click", LeftClick = "Click", Space = "Space", } -- Keycaps of a shortcut: {"LeftControl", "K"} or {"Ctrl+K"} → {"Ctrl", "K"} function Logic.keycaps(keys: { string }?): { string } local out = {} for _, k in keys or {} do for part in string.gmatch(tostring(k), "[^%+]+") do part = Logic.trim(part) if part ~= "" then table.insert(out, KEY_NAMES[part] or part) end end end return out end local GROUP_ORDER = { "Context", "Chat", "Build", "Window" } -- Groups of the hotkey list filtered by a query (label, group or keys). → { { name, items } } in a stable order. function Logic.hotkeyGroups(list: { any }, query: string?): { any } local q = string.lower(Logic.trim(query)) local groups, order = {}, {} for _, h in list do local hay = string.lower((h.label or "") .. " " .. (h.group or "") .. " " .. table.concat(Logic.keycaps(h.keys), " ")) if q == "" or string.find(hay, q, 1, true) then local g = if type(h.group) == "string" and h.group ~= "" then h.group else "More" if not groups[g] then groups[g] = {} table.insert(order, g) end table.insert(groups[g], h) end end local rank = {} for i, g in GROUP_ORDER do rank[g] = i end table.sort(order, function(a, b) local ra, rb = rank[a] or 99, rank[b] or 99 if ra ~= rb then return ra < rb end return a < b end) local out = {} for _, g in order do table.insert(out, { name = g, items = groups[g] }) end return out end -- Hotkeys the empty chat state suggests: the three most useful ones that are on. local TIP_IDS = { "add_click", "screenshot", "voice", "add_selection", "hotkeys" } function Logic.tips(list: { any }): { any } local byId = {} for _, h in list do byId[h.id] = h end local out = {} for _, id in TIP_IDS do local h = byId[id] if h and h.enabled ~= false and #out < 3 then table.insert(out, h) end end return out end -- ── Quick actions under the input ─────────────────────────────────────────── -- s = { errors: number, selected: number, part: string? (name of one selected BasePart), built: boolean, -- busy: boolean } → up to 3 { id, text } chips, most useful first. function Logic.quickActions(s: any): { any } local out = {} if s.busy then return out end if (s.errors or 0) > 0 then table.insert(out, { id = "fix_errors", text = if s.errors == 1 then "Fix 1 error" else ("Fix %d errors"):format(math.min(s.errors, 99)) }) end if (s.selected or 0) > 0 then table.insert(out, { id = "explain", text = if s.selected == 1 then "Explain selection" else ("Explain %d selected"):format(s.selected) }) end if s.part then table.insert(out, { id = "make_part", text = "Make " .. s.part .. " …" }) end if s.built then table.insert(out, { id = "playtest_review", text = "Playtest & review" }) end while #out > 3 do table.remove(out) end return out end -- ── Agent team lane ───────────────────────────────────────────────────────── -- Roles shown in the lane: the workflow's team, plus roles that showed up in progress.agent but were not listed. function Logic.team(workflowAgents: { string }?, seen: { string }?): { string } local out, has = {}, {} for _, r in workflowAgents or {} do if type(r) == "string" and not has[r] then has[r] = true table.insert(out, r) end end for _, r in seen or {} do if type(r) == "string" and not has[r] then has[r] = true table.insert(out, r) end end return out end -- ── Live strip: how far along ─────────────────────────────────────────────── -- "0:07", "4:32", "1:02:09" function Logic.clock(seconds: number): string local s = math.max(0, math.floor(seconds)) local h, m = s // 3600, (s // 60) % 60 if h > 0 then return ("%d:%02d:%02d"):format(h, m, s % 60) end return ("%d:%02d"):format(m, s % 60) end -- Overall progress of a confirmed plan: parts that count (not removed, not the planning row), how many are finished, -- which one is running (1-based) and the fraction 0..1 with the running part's own progress mixed in. function Logic.overall(items: { [string]: any }?, fractions: { [string]: number }?): { total: number, done: number, current: number, fraction: number } local ids = {} for id, item in items or {} do if type(item) == "table" and item.kind ~= "planning" and not item.removed then table.insert(ids, id) end end local done, running, partial = 0, 0, 0 for _, id in ids do local st = items[id].status if st == "done" or st == "failed" then done += 1 elseif st == "building" then running += 1 partial += math.clamp((fractions and fractions[id]) or 0, 0, 1) end end local total = #ids if total == 0 then return { total = 0, done = 0, current = 0, fraction = 0 } end return { total = total, done = done, current = math.min(total, done + math.max(running, 1)), fraction = math.clamp((done + partial) / total, 0, 1) } end -- Right side of the live strip: elapsed time, part counter, and a calm note when nothing new arrived for a while. function Logic.liveMeta(elapsed: number, quiet: number, overall: { total: number, current: number }?): string local parts = { Logic.clock(elapsed) } if overall and overall.total > 0 then table.insert(parts, ("Part %d/%d"):format(overall.current, overall.total)) end if quiet >= 30 then table.insert(parts, "still working") end return table.concat(parts, " · ") end return Logic ]]> Voice 0 then lastSound = os.clock() end emit("level", level) if os.clock() - lastSound > SILENT and os.clock() - started > SILENT then session += 1 teardown() setState("error", "No microphone signal reaches Studio (level 0). Check Studio's microphone setting and the system input device.") return end if os.clock() - chunkStart >= CHUNK then -- Next request: keep what was heard, restart the transcription. committed = join(committed, stt.Text) stt.Enabled = false pcall(function() stt.Text = "" end) stt.Enabled = true chunkStart = os.clock() end task.wait(RATE) end end) end function Voice.stop() if state ~= "listening" or not chain then return end local mine = session local stt = chain.stt setState("processing") emit("level", 0) task.spawn(function() task.wait(SETTLE) if session ~= mine then return end local text = join(committed, stt.Text) session += 1 teardown() emit("text", text, true) setState("idle") end) end function Voice.toggle() if state == "listening" then Voice.stop() else Voice.start() end end function Voice.state(): string return state end function Voice.onState(fn) table.insert(listeners.state, fn) end function Voice.onText(fn) table.insert(listeners.text, fn) end function Voice.onLevel(fn) table.insert(listeners.level, fn) end return Voice ]]> Widgets ", ">")) end -- The little Markdown of the Unity window: **bold**, `code`, bullets. Everything else stays text. function W.markdown(t: string): string t = W.escape(t) t = string.gsub(t, "%*%*(.-)%*%*", "%1") t = string.gsub(t, "`([^`\n]+)`", '%1') t = string.gsub(t, "\n%s*[-*] ", "\n• ") t = string.gsub(t, "^%s*[-*] ", "• ") return t end -- Height of wrapped text, measured whenever width or text change. AutomaticSize with TextWrapped measures only one -- line inside dock widgets (seen in Studio 0.741), so the height is set by hand. function W.fit(l: TextLabel, maxLines: number?) local busy, again = false, false local function update() if busy then again = true return end local w = l.AbsoluteSize.X if w <= 0 or not l.Parent then return end busy = true task.spawn(function() local params = Instance.new("GetTextBoundsParams") params.Text = if l.Text == "" then " " else l.Text params.Font = l.FontFace params.Size = l.TextSize params.Width = w pcall(function() params.RichText = l.RichText end) local ok, size = pcall(TextService.GetTextBoundsAsync, TextService, params) local h = if ok then math.ceil(size.Y) + 1 else l.TextSize if maxLines then h = math.min(h, math.ceil(l.TextSize * 1.25 * maxLines)) end if l.Size.Y.Offset ~= h then l.Size = UDim2.new(l.Size.X.Scale, l.Size.X.Offset, 0, h) end busy = false if again then again = false update() end end) end l:GetPropertyChangedSignal("AbsoluteSize"):Connect(update) l:GetPropertyChangedSignal("Text"):Connect(update) task.defer(update) end -- A label. wrap = true measures its height (see fit); otherwise one line of its Size. function W.label(text: string?, props: { [string]: any }?) local l = new("TextLabel", { BackgroundTransparency = 1, FontFace = Theme.BODY, TextSize = 13, TextColor3 = C.ink, Text = text or "", RichText = true, TextWrapped = true, TextXAlignment = Enum.TextXAlignment.Left, TextYAlignment = Enum.TextYAlignment.Top, Size = UDim2.new(1, 0, 0, 16), }) local wrap = true for k, v in props or {} do if k == "wrap" then wrap = v elseif k ~= "Parent" then (l :: any)[k] = v end end if not wrap then l.TextWrapped = false if not (props and props.TextTruncate) then l.TextTruncate = Enum.TextTruncate.AtEnd end end if wrap then W.fit(l) end if props and props.Parent then l.Parent = props.Parent end return l end -- A surface with a 1 px border and a thicker lip at the bottom (USS: border-width 1, border-bottom-width 3). -- Returns outer (border colour) and face (fill). auto = height follows the content of face. function W.surface(props: { [string]: any }) local r, lip = props.radius or 12, props.lip or 3 local outer = new("Frame", { BackgroundColor3 = props.border or C.line2, BorderSizePixel = 0, Size = props.size or UDim2.new(1, 0, 0, 0), AutomaticSize = if props.auto then Enum.AutomaticSize.Y else Enum.AutomaticSize.None, LayoutOrder = props.order or 0, Name = props.name or "Surface", }, { corner(r), pad(props.edge or 1, props.edge or 1, props.edge or 1, lip) }) local face = new("Frame", { Parent = outer, Name = "Face", BackgroundColor3 = props.bg or C.panel, BorderSizePixel = 0, Size = if props.auto then UDim2.new(1, 0, 0, 0) else UDim2.new(1, 0, 1, 0), AutomaticSize = if props.auto then Enum.AutomaticSize.Y else Enum.AutomaticSize.None, }, { corner(math.max(0, r - 1)) }) if props.Parent then outer.Parent = props.Parent end return outer, face end -- ── Icons, drawn with Frames ───────────────────────────────────────────────── local function bar(parent, w, h, color, rot, x, y) return new("Frame", { Parent = parent, BackgroundColor3 = color, BorderSizePixel = 0, AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.new(0.5, x or 0, 0.5, y or 0), Size = UDim2.fromOffset(w, h), Rotation = rot or 0, }, { new("UICorner", { CornerRadius = UDim.new(0, math.min(w, h) / 2) }) }) end local function ring(parent, w, h, color, thick, x, y, r) return new("Frame", { Parent = parent, BackgroundTransparency = 1, AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.new(0.5, x or 0, 0.5, y or 0), Size = UDim2.fromOffset(w, h), }, { new("UICorner", { CornerRadius = UDim.new(0, r or math.min(w, h) / 2) }), new("UIStroke", { Color = color, Thickness = thick or 1.5 }) }) end local function glyph(parent, text, color, size, rot, font) return new("TextLabel", { Parent = parent, BackgroundTransparency = 1, Text = text, TextColor3 = color, FontFace = font or Theme.XBOLD, TextSize = size, Size = UDim2.fromScale(1, 1), Rotation = rot or 0, }) end -- Icon of `size` px in `color`. Kinds: plus, caret, close, lock, mic, help, camera, image, select, script, output, -- keyboard, team, check, spark, stop, send, back, model, flow. function W.icon(kind: string, color: Color3, size: number?, props: { [string]: any }?) local s = size or 14 local f = new("Frame", { BackgroundTransparency = 1, Size = UDim2.fromOffset(s, s), Name = "Icon_" .. kind }) local k = s / 14 if kind == "plus" then bar(f, 10 * k, 2, color) bar(f, 2, 10 * k, color) elseif kind == "caret" then glyph(f, "›", color, 13 * k, 90) elseif kind == "back" then glyph(f, "‹", color, 16 * k) elseif kind == "close" then bar(f, 9 * k, 1.75, color, 45) bar(f, 9 * k, 1.75, color, -45) elseif kind == "lock" then ring(f, 6 * k, 7 * k, color, 1.5, 0, -2.5 * k, 3 * k) bar(f, 9 * k, 6.5 * k, color, 0, 0, 2.5 * k).UICorner.CornerRadius = UDim.new(0, 2) elseif kind == "mic" then ring(f, 6 * k, 9 * k, color, 1.5, 0, -1.5 * k, 3 * k) bar(f, 1.5, 2.5 * k, color, 0, 0, 4.5 * k) bar(f, 6 * k, 1.5, color, 0, 0, 6 * k) elseif kind == "help" then glyph(f, "?", color, 13 * k) elseif kind == "camera" then ring(f, 12 * k, 9 * k, color, 1.5, 0, 1 * k, 3) ring(f, 4.5 * k, 4.5 * k, color, 1.5, 0, 1 * k) bar(f, 4 * k, 2, color, 0, -2 * k, -4.5 * k) elseif kind == "image" then ring(f, 12 * k, 10 * k, color, 1.5, 0, 0, 3) bar(f, 2.5 * k, 2.5 * k, color, 0, 2.5 * k, -1.5 * k) bar(f, 5 * k, 5 * k, color, 45, -2 * k, 3 * k).UICorner.CornerRadius = UDim.new(0, 1) elseif kind == "select" then ring(f, 10 * k, 10 * k, color, 1.5, -1 * k, -1 * k, 2) bar(f, 5 * k, 5 * k, color, 0, 3.5 * k, 3.5 * k).UICorner.CornerRadius = UDim.new(0, 1) elseif kind == "script" then ring(f, 10 * k, 12 * k, color, 1.5, 0, 0, 2) bar(f, 5 * k, 1.5, color, 0, 0, -2 * k) bar(f, 5 * k, 1.5, color, 0, 0, 1 * k) bar(f, 3 * k, 1.5, color, 0, -1 * k, 4 * k) elseif kind == "output" then ring(f, 12 * k, 12 * k, color, 1.5) bar(f, 1.75, 5 * k, color, 0, 0, -1 * k) bar(f, 1.75, 1.75, color, 0, 0, 3.5 * k) elseif kind == "keyboard" then ring(f, 13 * k, 9 * k, color, 1.5, 0, 0, 2) for i = -1, 1 do bar(f, 1.75, 1.75, color, 0, i * 3 * k, -1 * k) end bar(f, 6 * k, 1.5, color, 0, 0, 2 * k) elseif kind == "team" then bar(f, 5 * k, 5 * k, color, 0, -3 * k, -2 * k) bar(f, 5 * k, 5 * k, color, 0, 3 * k, -2 * k) bar(f, 12 * k, 4 * k, color, 0, 0, 4 * k) elseif kind == "check" then glyph(f, "✓", color, 12 * k) elseif kind == "spark" then bar(f, 7 * k, 7 * k, color, 45).UICorner.CornerRadius = UDim.new(0, 1.5) bar(f, 3 * k, 3 * k, color, 45, 4.5 * k, -4.5 * k).UICorner.CornerRadius = UDim.new(0, 1) elseif kind == "stop" then bar(f, 8 * k, 8 * k, color).UICorner.CornerRadius = UDim.new(0, 2) elseif kind == "send" then glyph(f, "↑", color, 15 * k) elseif kind == "model" then bar(f, 6 * k, 6 * k, color, 45, 0, 0).UICorner.CornerRadius = UDim.new(0, 1.5) ring(f, 12 * k, 12 * k, color, 1.25) elseif kind == "flow" then bar(f, 4 * k, 4 * k, color, 0, -4 * k, 0) bar(f, 4 * k, 4 * k, color, 0, 4 * k, 0) bar(f, 5 * k, 1.5, color, 0, 0, 0) end for key_, v in props or {} do if key_ == "Parent" then continue end (f :: any)[key_] = v end if props and props.Parent then f.Parent = props.Parent end return f end -- Recolour an icon (its bars, rings and glyphs). function W.tintIcon(icon: Instance, color: Color3) for _, d in icon:GetDescendants() do if d:IsA("UIStroke") then d.Color = color elseif d:IsA("TextLabel") then d.TextColor3 = color elseif d:IsA("Frame") and d.BackgroundTransparency < 1 then d.BackgroundColor3 = color end end end -- ── Keys ──────────────────────────────────────────────────────────────────── -- A key (button with lip). kind: main (tangerine) · plain (ink) · ghost (white) · soft (white, quiet text) local KINDS = { main = { bg = C.tang, hover = C.tang2, lip = C.tangLip, text = C.white, font = Theme.XBOLD, edge = 0 }, plain = { bg = C.ink, hover = C.ink2, lip = C.black, text = C.white, font = Theme.XBOLD, edge = 0 }, ghost = { bg = C.panel, hover = C.panel2, lip = C.line2, text = C.ink, font = Theme.XBOLD, edge = 1 }, soft = { bg = C.panel, hover = C.panel2, lip = C.line2, text = C.soft, font = Theme.BOLD, edge = 1 }, } W.KINDS = KINDS function W.key(text: string, kind: string?, onClick: (() -> ())?, props: { [string]: any }?) props = props or {} local k = KINDS[kind or "main"] or KINDS.main local h = props.height or 34 local lip = props.lip or (if kind == "soft" or kind == "ghost" then 2 else 3) local outer = new("TextButton", { Text = "", AutoButtonColor = false, BackgroundColor3 = props.lipColor or k.lip, BorderSizePixel = 0, Size = props.size or UDim2.new(0, 0, 0, h), AutomaticSize = if props.size then Enum.AutomaticSize.None else Enum.AutomaticSize.X, LayoutOrder = props.order or 0, Name = props.name or "Key", }, { corner(props.radius or 11) }) local padding = pad(k.edge, k.edge, k.edge, lip) padding.Parent = outer local face = new("TextLabel", { Parent = outer, Name = "Face", BackgroundColor3 = props.bg or k.bg, BorderSizePixel = 0, Text = text, RichText = true, TextColor3 = props.textColor or k.text, FontFace = props.font or k.font, TextSize = props.textSize or 12.5, Size = if props.size then UDim2.new(1, 0, 1, 0) else UDim2.new(0, 0, 1, 0), AutomaticSize = if props.size then Enum.AutomaticSize.None else Enum.AutomaticSize.X, TextTruncate = Enum.TextTruncate.AtEnd, TextXAlignment = props.align or Enum.TextXAlignment.Center, }, { corner(math.max(0, (props.radius or 11) - 1)), pad(props.padX or 16, 0, props.padRight or props.padX or 16, 0) }) local enabled, hovering = true, false local api = { outer = outer, face = face, padding = padding } local bg, hover, fg = props.bg or k.bg, props.hover or k.hover, props.textColor or k.text local function paint() face.BackgroundColor3 = if not enabled then C.panel3 elseif hovering then hover else bg end function api.setColors(newBg: Color3?, newHover: Color3?, newText: Color3?, newLip: Color3?) bg, hover = newBg or bg, newHover or hover if newText then fg = newText face.TextColor3 = newText end if newLip then outer.BackgroundColor3 = newLip end paint() end outer.MouseEnter:Connect(function() hovering = true if enabled then paint() end end) outer.MouseLeave:Connect(function() hovering = false paint() padding.PaddingTop, padding.PaddingBottom = UDim.new(0, k.edge), UDim.new(0, lip) end) outer.MouseButton1Down:Connect(function() if enabled then padding.PaddingTop, padding.PaddingBottom = UDim.new(0, k.edge + 1), UDim.new(0, math.max(1, lip - 1)) end end) outer.MouseButton1Up:Connect(function() padding.PaddingTop, padding.PaddingBottom = UDim.new(0, k.edge), UDim.new(0, lip) end) outer.MouseButton1Click:Connect(function() if enabled and onClick then onClick() end end) if props.Parent then outer.Parent = props.Parent end function api.setEnabled(on: boolean) enabled = on outer.BackgroundColor3 = if on then (props.lipColor or k.lip) else C.panel4 face.TextColor3 = if on then fg else C.faint paint() end function api.setText(t: string) face.Text = t end function api.enabled() return enabled end return api end -- Square white key of the header and compose bar (.sq), with an icon or a glyph. function W.sq(iconKind: string?, onClick: (() -> ())?, props: { [string]: any }?) props = props or {} local s = props.s or (if props.small then 28 else 30) local k = W.key(props.text or "", props.kind or "soft", onClick, { size = UDim2.new(0, s, 0, s), radius = props.radius or (if props.small then 9 else 10), lip = props.lip or (if props.small then 2 else 3), padX = 0, textSize = props.textSize or 14, font = Theme.BOLD, order = props.order, name = props.name, bg = props.bg, hover = props.hover, textColor = props.textColor, Parent = props.Parent, }) if iconKind then k.icon = W.icon(iconKind, props.iconColor or C.soft, props.iconSize or 14, { Parent = k.face, AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5) }) end return k end -- ── Small pieces ───────────────────────────────────────────────────────────── -- Coloured pill: price, stage, plan badge function W.pill(text: string, fg: Color3, bg: Color3, props: { [string]: any }?) props = props or {} return new("TextLabel", { BackgroundColor3 = bg, TextColor3 = fg, FontFace = props.font or Theme.XBOLD, TextSize = props.size or 10, Text = text, AutomaticSize = Enum.AutomaticSize.X, Size = UDim2.new(0, 0, 0, props.height or 20), LayoutOrder = props.order or 0, Rotation = props.rotation or 0, Parent = props.Parent, Name = props.name or "Pill", BorderSizePixel = 0, }, { corner(props.radius or 7), pad(props.padX or 8, 0) }) end -- Sticker (.tag): butter with lip, slightly tilted function W.sticker(text: string, parent: Instance, order: number?, hue: string?) local h = Theme.HUES[hue or "butter"] or Theme.HUES.butter local outer = new("Frame", { Parent = parent, Name = "Sticker", BackgroundColor3 = h[3], AutomaticSize = Enum.AutomaticSize.XY, Size = UDim2.new(0, 0, 0, 0), Rotation = -3, LayoutOrder = order or 0, BorderSizePixel = 0 }, { corner(7), pad(0, 0, 0, 2) }) local face = new("TextLabel", { Parent = outer, BackgroundColor3 = if hue then h[2] else C.butterBody, Text = text, FontFace = Theme.DISPLAY, TextSize = 10.5, TextColor3 = C.ink, AutomaticSize = Enum.AutomaticSize.XY, Size = UDim2.new(0, 0, 0, 0), BorderSizePixel = 0 }, { corner(7), pad(8, 3, 8, 4) }) return outer, face end -- Keycap ("Ctrl", "K"): white with a lip, like a real key function W.keycap(text: string, props: { [string]: any }?) props = props or {} local h = props.height or 20 local outer = new("Frame", { Name = "Keycap", BackgroundColor3 = props.lip or C.panel4, BorderSizePixel = 0, AutomaticSize = Enum.AutomaticSize.X, Size = UDim2.new(0, 0, 0, h), LayoutOrder = props.order or 0 }, { corner(6), pad(0, 0, 0, 2), new("UISizeConstraint", { MinSize = Vector2.new(h, 0) }), }) new("TextLabel", { Parent = outer, BackgroundColor3 = props.bg or C.panel, BorderSizePixel = 0, Text = text, FontFace = Theme.BOLD, TextSize = props.textSize or 10.5, TextColor3 = props.color or C.ink, AutomaticSize = Enum.AutomaticSize.X, Size = UDim2.new(0, 0, 1, 0), }, { corner(6), pad(5, 0), new("UIStroke", { Color = C.line2, Thickness = 1, ApplyStrokeMode = Enum.ApplyStrokeMode.Border }), new("UISizeConstraint", { MinSize = Vector2.new(h, 0) }) }) if props.Parent then outer.Parent = props.Parent end return outer end -- A row of keycaps with "+" between them function W.shortcut(caps: { string }, props: { [string]: any }?) props = props or {} local row = new("Frame", { Name = "Shortcut", BackgroundTransparency = 1, AutomaticSize = Enum.AutomaticSize.X, Size = UDim2.new(0, 0, 0, (props.height or 20) + 2), LayoutOrder = props.order or 0 }, { list(3, true) }) for i, c in caps do if i > 1 then new("TextLabel", { Parent = row, BackgroundTransparency = 1, Text = "+", FontFace = Theme.BOLD, TextSize = 10, TextColor3 = C.faint, Size = UDim2.new(0, 7, 1, 0), LayoutOrder = i * 2 - 1 }) end W.keycap(c, { Parent = row, order = i * 2, height = props.height, textSize = props.textSize, color = props.color }) end if props.Parent then row.Parent = props.Parent end return row end -- Switch (skills, hotkeys). Returns { frame, set(on, animate), get() } function W.switch(on: boolean, onToggle: ((boolean) -> ())?, props: { [string]: any }?) props = props or {} local hue = Theme.HUES[props.hue or "lime"] local w, h = props.w or 34, props.h or 20 local k = h - 6 local sw = new("TextButton", { Name = "Switch", Text = "", AutoButtonColor = false, BackgroundColor3 = if on then hue[1] else C.panel4, Size = UDim2.fromOffset(w, h), BorderSizePixel = 0, LayoutOrder = props.order or 0 }, { W.round() }) local knob = new("Frame", { Parent = sw, BackgroundColor3 = C.white, Size = UDim2.fromOffset(k, k), Position = UDim2.fromOffset(if on then w - k - 3 else 3, 3), BorderSizePixel = 0 }, { W.round() }) local state = on local api = { frame = sw } function api.set(v: boolean, animate: boolean?) state = v local goal = UDim2.fromOffset(if v then w - k - 3 else 3, 3) if animate then Motion.spring(knob, { Position = goal }, 0.32) Motion.tween(sw, 0.2, { BackgroundColor3 = if v then hue[1] else C.panel4 }) else knob.Position = goal sw.BackgroundColor3 = if v then hue[1] else C.panel4 end end function api.get() return state end sw.MouseButton1Click:Connect(function() api.set(not state, true) if onToggle then onToggle(state) end end) if props.Parent then sw.Parent = props.Parent end return api end -- ── Tooltip ────────────────────────────────────────────────────────────────── local tip: Frame?, tipLabel: TextLabel?, tipToken = nil, nil, 0 local function hideTip() tipToken += 1 if tip then tip.Visible = false end end W.hideTip = hideTip -- A small ink tooltip after hovering `target` for 0.45 s. text may be a function (read at show time). function W.tooltip(target: GuiObject, text: string | () -> string?) target.MouseEnter:Connect(function() tipToken += 1 local my = tipToken task.delay(0.45, function() if my ~= tipToken or not overlay or not target.Parent then return end local t = if type(text) == "function" then text() else text if not t or t == "" then return end if not tip then tip = new("Frame", { Parent = overlay, Name = "Tooltip", BackgroundColor3 = C.ink, BorderSizePixel = 0, AutomaticSize = Enum.AutomaticSize.XY, Size = UDim2.new(), Visible = false, ZIndex = 80 }, { corner(8), pad(9, 5) }) tipLabel = new("TextLabel", { Parent = tip, BackgroundTransparency = 1, TextColor3 = C.white, FontFace = Theme.BOLD, TextSize = 11, AutomaticSize = Enum.AutomaticSize.XY, Size = UDim2.new(), ZIndex = 81, RichText = true }, { new("UISizeConstraint", { MaxSize = Vector2.new(220, 200) }) }) end local o = overlay :: Frame local label = tipLabel :: TextLabel label.Text = W.escape(t) local box = tip :: Frame box.Visible = true task.defer(function() if my ~= tipToken then return end local ax, ay = target.AbsolutePosition.X - o.AbsolutePosition.X, target.AbsolutePosition.Y - o.AbsolutePosition.Y local bw, bh = box.AbsoluteSize.X, box.AbsoluteSize.Y local x = math.clamp(ax + target.AbsoluteSize.X / 2 - bw / 2, 6, math.max(6, o.AbsoluteSize.X - bw - 6)) local y = if ay - bh - 6 > 4 then ay - bh - 6 else ay + target.AbsoluteSize.Y + 6 box.Position = UDim2.fromOffset(x, y + 4) Motion.tween(box, 0.18, { Position = UDim2.fromOffset(x, y) }) end) end) end) target.MouseLeave:Connect(hideTip) if target:IsA("GuiButton") then target.MouseButton1Down:Connect(hideTip) end end -- ── Dropdown ───────────────────────────────────────────────────────────────── local current = nil -- the open dropdown (only one at a time) local blocked = false -- a sheet is open: no hover dropdowns local OPEN_DELAY, CLOSE_DELAY = 0.12, 0.25 function W.blockDropdowns(on: boolean) blocked = on if on and current then current.close() end end function W.closeDropdowns() if current then current.close() return true end return false end function W.openDropdown() return current end -- A dropdown on `trigger` (a GuiButton). opts: -- width: number desired width (clamped into the widget, works from ~170 px) -- build: (dd) -> () fills the panel each time it opens (dd.header, dd.row, dd.sep, dd.note, dd.custom) -- onOpen / onClose: () -> () function W.dropdown(trigger: GuiButton, opts: { [string]: any }) local intent = Logic.intent(OPEN_DELAY, CLOSE_DELAY) local dd = { trigger = trigger, rows = {}, index = 0 } local catcher, panel, face, body, layout, scaler local order = 0 local isOpen = false local function ensure() if panel then return end local o = overlay :: Frame catcher = new("TextButton", { Parent = o, Name = "DropdownCatcher", Text = "", AutoButtonColor = false, BackgroundTransparency = 1, Size = UDim2.fromScale(1, 1), Visible = false, ZIndex = 40 }) panel, face = W.surface({ radius = 14, lip = 4, size = UDim2.fromOffset(opts.width or 240, 100), name = "Dropdown" }) panel.ZIndex = 41 panel.Visible = false panel.Parent = o face.ClipsDescendants = true scaler = new("UIScale", { Parent = panel }) new("UIStroke", { Parent = panel, Color = C.black, Transparency = 0.93, Thickness = 3, ApplyStrokeMode = Enum.ApplyStrokeMode.Border }) body = new("ScrollingFrame", { Parent = face, BackgroundTransparency = 1, BorderSizePixel = 0, Size = UDim2.fromScale(1, 1), CanvasSize = UDim2.new(), AutomaticCanvasSize = Enum.AutomaticSize.Y, ScrollBarThickness = 3, ScrollBarImageColor3 = C.panel4, ScrollingDirection = Enum.ScrollingDirection.Y, }, { pad(5, 5, 5, 5) }) layout = list(1) layout.Parent = body layout:GetPropertyChangedSignal("AbsoluteContentSize"):Connect(function() if isOpen then dd.reflow() end end) panel.MouseEnter:Connect(function() intent.enter(os.clock()) end) panel.MouseLeave:Connect(function() intent.leave(os.clock()) dd.schedule(CLOSE_DELAY) end) catcher.MouseButton1Click:Connect(function() dd.close() end) local function keys(input: InputObject) if input.UserInputType == Enum.UserInputType.Keyboard then dd.key(input.KeyCode) end end panel.InputBegan:Connect(keys) catcher.InputBegan:Connect(keys) end function dd.reflow() if not panel or not overlay then return end local o = overlay :: Frame local content = layout.AbsoluteContentSize.Y + 10 + 5 -- body padding + lip/edge local ax = trigger.AbsolutePosition.X - o.AbsolutePosition.X local ay = trigger.AbsolutePosition.Y - o.AbsolutePosition.Y local wantW = opts.width or 240 local x, y, w, h = Logic.place(ax, ay, trigger.AbsoluteSize.X, trigger.AbsoluteSize.Y, wantW, math.min(content, opts.maxHeight or 420), o.AbsoluteSize.X, o.AbsoluteSize.Y, 8) if opts.alignRight then x = math.clamp(ax + trigger.AbsoluteSize.X - w, 8, math.max(8, o.AbsoluteSize.X - 8 - w)) end panel.Size = UDim2.fromOffset(w, h) panel.Position = UDim2.fromOffset(x, y) local above = y < ay panel.AnchorPoint = Vector2.new(0, 0) scaler:SetAttribute("Above", above) end function dd.schedule(delay: number) task.delay(delay + 0.01, function() local change = intent.due(os.clock()) if change == "open" then dd.open(false) elseif change == "close" then dd.close() end end) end local function clear() for _, c in body:GetChildren() do if c:IsA("GuiObject") then c:Destroy() end end dd.rows, dd.index, order = {}, 0, 0 end local function nextOrder() order += 1 return order end function dd.header(text: string, right: ((Frame) -> ())?) local h = new("Frame", { Parent = body, Name = "Header", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 24), LayoutOrder = nextOrder() }, { pad(8, 0, 6, 0) }) W.label(string.upper(text), { Parent = h, wrap = false, FontFace = Theme.XBOLD, TextSize = 9.5, TextColor3 = C.faint, Size = UDim2.new(1, 0, 1, 0), TextYAlignment = Enum.TextYAlignment.Center }) if right then local r = new("Frame", { Parent = h, BackgroundTransparency = 1, AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, 0, 0.5, 0), Size = UDim2.new(0, 0, 1, 0), AutomaticSize = Enum.AutomaticSize.X }, { list(4, true) }) right(r) end return h end function dd.sep() local s = new("Frame", { Parent = body, Name = "Sep", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 9), LayoutOrder = nextOrder() }) new("Frame", { Parent = s, BackgroundColor3 = C.line, BorderSizePixel = 0, Size = UDim2.new(1, -12, 0, 1), Position = UDim2.new(0, 6, 0, 4) }) return s end function dd.note(text: string, color: Color3?) local holder = new("Frame", { Parent = body, Name = "Note", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = nextOrder() }, { pad(8, 3, 8, 5) }) W.label(text, { Parent = holder, TextSize = 11, TextColor3 = color or C.faint }) return holder end -- Free-form block (e.g. the copyable upgrade link). Not keyboard-navigable. function dd.custom(height: number?) return new("Frame", { Parent = body, Name = "Custom", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, height or 0), AutomaticSize = if height then Enum.AutomaticSize.None else Enum.AutomaticSize.Y, LayoutOrder = nextOrder() }) end -- spec: { text, sub?, icon?: string | (Frame) -> (), iconColor?, right?: (Frame) -> (), selected?, disabled?, -- dim?, onSelect: () -> (), keepOpen? } function dd.row(spec: { [string]: any }) local hasSub = spec.sub and spec.sub ~= "" local h = spec.height or (if hasSub then 44 else 32) local row = new("TextButton", { Parent = body, Name = "Row", Text = "", AutoButtonColor = false, BackgroundColor3 = C.panel, BackgroundTransparency = 0, BorderSizePixel = 0, Size = UDim2.new(1, 0, 0, h), LayoutOrder = nextOrder(), ClipsDescendants = true }, { corner(9) }) local left = 9 if spec.icon then local holder = new("Frame", { Parent = row, BackgroundTransparency = 1, Size = UDim2.fromOffset(22, 22), Position = UDim2.new(0, 6, 0.5, -11) }) if type(spec.icon) == "string" then W.icon(spec.icon, spec.iconColor or C.soft, 14, { Parent = holder, AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5) }) else spec.icon(holder) end left = 34 end local rightW = 0 local right = new("Frame", { Parent = row, Name = "Right", BackgroundTransparency = 1, AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -8, 0.5, 0), Size = UDim2.new(0, 0, 0, 20), AutomaticSize = Enum.AutomaticSize.X }, { list(5, true, Enum.HorizontalAlignment.Right) }) if spec.right then spec.right(right) rightW = spec.rightWidth or 64 end if spec.selected then W.icon("check", C.tang, 14, { Parent = right, LayoutOrder = 99 }) rightW = math.max(rightW, 20) end local textColor = if spec.disabled or spec.dim then C.faint else C.ink local title = W.label(spec.text, { Parent = row, wrap = false, FontFace = Theme.BOLD, TextSize = 12.5, TextColor3 = textColor, Position = UDim2.new(0, left, 0, if hasSub then 6 else 0), Size = UDim2.new(1, -(left + rightW + 12), 0, if hasSub then 17 else h), TextYAlignment = Enum.TextYAlignment.Center }) if hasSub then W.label(spec.sub, { Parent = row, wrap = false, TextSize = 10.5, TextColor3 = C.faint, Position = UDim2.new(0, left, 0, 23), Size = UDim2.new(1, -(left + rightW + 12), 0, 14), TextYAlignment = Enum.TextYAlignment.Center }) end local entry = { row = row, spec = spec, title = title } table.insert(dd.rows, entry) local idx = #dd.rows local function paint() row.BackgroundColor3 = if dd.index == idx then C.tangTint elseif row:GetAttribute("Hover") then C.panel2 else C.panel end entry.paint = paint row.MouseEnter:Connect(function() row:SetAttribute("Hover", true) paint() end) row.MouseLeave:Connect(function() row:SetAttribute("Hover", false) paint() end) row.MouseButton1Click:Connect(function() dd.choose(idx) end) return entry end function dd.highlight(i: number) dd.index = i for _, e in dd.rows do e.paint() end local e = dd.rows[i] if e and body then -- keep the row in view local top = e.row.AbsolutePosition.Y - body.AbsolutePosition.Y + body.CanvasPosition.Y local bottom = top + e.row.AbsoluteSize.Y if top < body.CanvasPosition.Y then body.CanvasPosition = Vector2.new(0, top - 4) elseif bottom > body.CanvasPosition.Y + body.AbsoluteSize.Y then body.CanvasPosition = Vector2.new(0, bottom - body.AbsoluteSize.Y + 4) end end end function dd.choose(i: number) local e = dd.rows[i] if not e then return end Motion.ripple(e.row, nil, nil, C.tangLine) if e.spec.disabled and not e.spec.onDisabled then return end local fn = if e.spec.disabled then e.spec.onDisabled else e.spec.onSelect if not e.spec.keepOpen then dd.close() end if fn then fn() end if e.spec.keepOpen and isOpen then dd.rebuild() end end -- ↑ ↓ Enter Esc; true when the key was used function dd.key(code: Enum.KeyCode): boolean if not isOpen then return false end if code == Enum.KeyCode.Down or code == Enum.KeyCode.Up then local disabled = {} for i, e in dd.rows do disabled[i] = e.spec.disabled == true and not e.spec.onDisabled end dd.highlight(Logic.navigate(disabled, dd.index, if code == Enum.KeyCode.Down then 1 else -1)) return true elseif code == Enum.KeyCode.Return or code == Enum.KeyCode.KeypadEnter then if dd.index > 0 then dd.choose(dd.index) end return true elseif code == Enum.KeyCode.Escape then dd.close() return true end return false end function dd.rebuild() if not panel then return end local keep = dd.index local scroll = body.CanvasPosition clear() opts.build(dd) if keep > 0 and dd.rows[keep] then dd.highlight(keep) end body.CanvasPosition = scroll dd.reflow() end function dd.open(pinned: boolean?) if not overlay then return end ensure() if current and current ~= dd then current.close(true) end hideTip() current = dd intent.open = true if pinned then intent.pinned = true end catcher.Visible = intent.pinned if isOpen then return end isOpen = true clear() opts.build(dd) panel.Visible = true dd.reflow() task.defer(dd.reflow) -- grow out of the trigger: scale + a short slide local p = panel.Position local above = scaler:GetAttribute("Above") ~= false panel.Position = p + UDim2.fromOffset(0, if above then 8 else -8) scaler.Scale = 0.94 Motion.spring(scaler, { Scale = 1 }, 0.3) Motion.tween(panel, 0.22, { Position = p }) if opts.onOpen then opts.onOpen() end end function dd.close(instant: boolean?) intent.reset() if current == dd then current = nil end if not isOpen then return end isOpen = false catcher.Visible = false if instant or not Motion.isActive() then panel.Visible = false else local t = Motion.tween(scaler, 0.1, { Scale = 0.96 }, Enum.EasingStyle.Quad) t.Completed:Connect(function() if not isOpen then panel.Visible = false end end) end if opts.onClose then opts.onClose() end end function dd.isOpen() return isOpen end trigger.MouseEnter:Connect(function() if blocked or (opts.enabled and not opts.enabled()) then return end local switching = current ~= nil and current ~= dd intent.enter(os.clock(), switching) if switching then dd.open(false) else dd.schedule(OPEN_DELAY) end end) trigger.MouseLeave:Connect(function() intent.leave(os.clock()) dd.schedule(CLOSE_DELAY) end) trigger.MouseButton1Click:Connect(function() if opts.enabled and not opts.enabled() then if opts.onBlocked then opts.onBlocked() end return end if isOpen and not intent.pinned then -- opened by hover: a click pins it intent.pinned = true catcher.Visible = true return end if intent.click() == "open" then dd.open(true) else dd.close() end end) return dd end -- ── Context chip ───────────────────────────────────────────────────────────── -- One chip of the context row: class icon or thumbnail, label, × remove. classIcon(className) returns the -- StudioService icon table ({ Image, ImageRectOffset, ImageRectSize }) or nil. function W.chip(item: { [string]: any }, onRemove: () -> (), classIcon: ((string) -> any)?) local hueName = if item.kind == "image" then "bub" elseif item.kind == "script" then "sky" elseif item.kind == "output" then "butter" else "tang" local hue = Theme.HUES[hueName] local outer = new("Frame", { Name = "Chip", BackgroundColor3 = hue[1], BorderSizePixel = 0, AutomaticSize = Enum.AutomaticSize.X, Size = UDim2.new(0, 0, 0, 26) }, { W.round(), pad(0, 0, 0, 2), new("UISizeConstraint", { MaxSize = Vector2.new(150, 26) }) }) local face = new("Frame", { Parent = outer, Name = "Face", BackgroundColor3 = hue[4], BorderSizePixel = 0, AutomaticSize = Enum.AutomaticSize.X, Size = UDim2.new(0, 0, 1, 0) }, { W.round(), pad(4, 0, 4, 0), list(4, true), new("UISizeConstraint", { MaxSize = Vector2.new(150, 26) }) }) local lead = new("Frame", { Parent = face, BackgroundColor3 = C.white, Size = UDim2.fromOffset(18, 18), LayoutOrder = 1, BorderSizePixel = 0 }, { W.round() }) local shown = false local icon = item.icon if type(icon) ~= "table" and item.class and classIcon then local ok, got = pcall(classIcon, item.class) icon = if ok then got else nil end if item.thumb and item.thumb ~= "" then new("ImageLabel", { Parent = lead, BackgroundTransparency = 1, Image = item.thumb, Size = UDim2.fromScale(1, 1), ScaleType = Enum.ScaleType.Crop }, { W.round() }) shown = true elseif type(icon) == "table" and icon.Image then do new("ImageLabel", { Parent = lead, BackgroundTransparency = 1, Image = icon.Image, ImageRectOffset = icon.ImageRectOffset or Vector2.zero, ImageRectSize = icon.ImageRectSize or Vector2.zero, Size = UDim2.fromOffset(14, 14), AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5) }) shown = true end end if not shown then local kind = if item.kind == "image" then "image" elseif item.kind == "script" then "script" elseif item.kind == "output" then "output" else "select" W.icon(kind, hue[3], 11, { Parent = lead, AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5) }) end local text = new("TextLabel", { Parent = face, BackgroundTransparency = 1, Text = W.escape(item.label or "?"), RichText = true, FontFace = Theme.BOLD, TextSize = 11.5, TextColor3 = hue[3], AutomaticSize = Enum.AutomaticSize.X, Size = UDim2.new(0, 0, 1, 0), TextTruncate = Enum.TextTruncate.AtEnd, LayoutOrder = 2 }, { new("UISizeConstraint", { MaxSize = Vector2.new(96, 26) }) }) local x = new("TextButton", { Parent = face, Name = "Remove", Text = "", AutoButtonColor = false, BackgroundColor3 = hue[1], BackgroundTransparency = 1, Size = UDim2.fromOffset(16, 16), LayoutOrder = 3, BorderSizePixel = 0 }, { W.round() }) local xi = W.icon("close", hue[3], 10, { Parent = x, AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5) }) x.MouseEnter:Connect(function() x.BackgroundTransparency = 0 W.tintIcon(xi, C.white) end) x.MouseLeave:Connect(function() x.BackgroundTransparency = 1 W.tintIcon(xi, hue[3]) end) x.MouseButton1Click:Connect(onRemove) W.tooltip(face, item.detail or item.label) return outer, text end return W ]]> Window 0 then w else 440 end function ui.hotkeyCaps(id: string): { string }? if not ui.Hotkeys then return nil end local ok, l = pcall(ui.Hotkeys.list) if not ok or type(l) ~= "table" then return nil end if ui.Hotkeys.text then for _, h in l do if h.id == id and h.enabled ~= false then local tok, t = pcall(ui.Hotkeys.text, id) if tok and type(t) == "string" and t ~= "" then return Logic.keycaps({ t }) end end end end for _, h in l do if h.id == id then return if h.enabled == false then nil else Logic.keycaps(h.keys) end end return nil end function ui.tips() if not ui.Hotkeys then return {} end local ok, l = pcall(ui.Hotkeys.list) return if ok and type(l) == "table" then Logic.tips(l) else {} end function ui.teamFor(): { string } for _, w in ui.catalog.workflows do if w.id == ui.workflow and type(w.agents) == "table" then return w.agents end end return {} end -- Paths a part touched, for "Show me": component_done / component_failed carry msg.paths (bridge, ADR 0023). -- Older bridges: Context.touched or Ops.touched, if one exists. local touchedPaths = {} function ui.touched(componentId: string): { string }? if touchedPaths[componentId] then return touchedPaths[componentId] end for _, m in { ui.Context, ui.Ops } do if m and type(m.touched) == "function" then local ok, paths = pcall(m.touched, componentId) if ok and type(paths) == "table" then return paths end end end return nil end -- Selects and frames the paths; false when none of them exists any more. function ui.focus(paths: { string }): boolean if ui.Context and ui.Context.focus then local ok, n = pcall(ui.Context.focus, paths) if ok and n == 0 then toast("Those objects are gone from the place", "ink") return false end end return true end function ui.burst(x: number, y: number) if overlay then Motion.confetti(overlay, x, y, { C.tang, C.bub, C.sky, C.lime, C.butter, C.grape }, 18) end end function ui.runPlay() -- Like Play (F5); returns once the user stops the test. Without Synagy arguments the test runs untouched. task.spawn(function() local ok, err = pcall(function() game:GetService("StudioTestService"):ExecutePlayModeAsync({}) end) if not ok then toast("Press Play (F5) to try it") warn("Synagy: could not start Play: " .. tostring(err)) end end) end function ui.setInput(text: string) if ui.view ~= "chat" then Window.setView("chat") end Compose.setText(text) end -- ── Live strip ────────────────────────────────────────────────────────────── -- How far along: elapsed time, part counter and whole-plan bar, refreshed once a second while busy. local busySince: number? = nil local lastNews = os.clock() local fractions: { [string]: number } = {} local clockRunning = false local function refreshMeta() if not busySince then return end local now = os.clock() local o = if ui.building then Logic.overall(ui.planItems, fractions) else nil Feed.setMeta(Logic.liveMeta(now - busySince, now - lastNews, o), if o and o.total > 0 then o.fraction else nil) end local function runClock() if clockRunning then return end clockRunning = true local function tick() if not busySince then clockRunning = false return end refreshMeta() task.delay(1, tick) end task.delay(1, tick) end local function setTicker() if not Feed then return end local s = ui.Cloud.status if ui.Account.token() == nil then Feed.setLive("hidden") elseif s == "online" then if tickerText ~= "" or ui.building or ui.thinking then Feed.setLive(if ui.building then "building" else "thinking", if tickerText ~= "" then tickerText elseif ui.building then "Building…" else "Synagy is thinking…") if not busySince then busySince, lastNews = os.clock(), os.clock() end refreshMeta() runClock() else busySince = nil Feed.setLive("hidden") end elseif s == "connecting" then Feed.setLive("connecting", "Connecting to Synagy Cloud…") elseif s == "update" then Feed.setLive("offline", "Please update the Synagy plugin.") else Feed.setLive("offline", if ui.Cloud.detail ~= "" then ui.Cloud.detail .. " Retrying…" else "Offline. Retrying…") end end -- ── Header, status, views ─────────────────────────────────────────────────── local function setStatus() local Cloud = ui.Cloud local s = Cloud.status local signedIn = ui.Account.token() ~= nil statusDot.BackgroundColor3 = if s == "online" then C.lime elseif s == "connecting" then C.butter else C.panel4 local credits = if signedIn and type(Cloud.credits) == "number" then math.floor(Cloud.credits) else nil if credits ~= shownCredits then if credits and shownCredits then Motion.roll(creditsLabel, shownCredits, credits, Logic.formatCredits, 0.9) Motion.bump(creditsLabel.Parent, if credits > shownCredits then 0.15 else 0.08) else creditsLabel.Text = Logic.formatCredits(credits) end shownCredits = credits end creditsGem.BackgroundColor3 = if credits and credits < 20 then C.bad else C.butter signView.Visible = not signedIn and ui.view ~= "hotkeys" tabsWrap.Visible = signedIn mainView.Visible = signedIn and ui.view == "chat" Library.frame.Visible = signedIn and (ui.view == "templates" or ui.view == "skills") HotkeyPage.frame.Visible = ui.view == "hotkeys" setTicker() Compose.setBusy(ui.building) end local function placeInk(animate: boolean) local names = { "chat", "templates", "skills" } local index = table.find(names, ui.view) local w = tabsBar.AbsoluteSize.X if w <= 0 then return end local each = (w - 8) / #names if index then local goal = { Position = UDim2.new(0, 4 + (index - 1) * each, 0, 4), Size = UDim2.new(0, each, 1, -8), BackgroundTransparency = 0 } if animate then Motion.tween(tabInk, 0.38, goal, Enum.EasingStyle.Back) else tabInk.Position, tabInk.Size, tabInk.BackgroundTransparency = goal.Position, goal.Size, 0 end local sec = SECTION[ui.view] Motion.tween(tabInk, 0.25, { BackgroundColor3 = sec[2] }) tabInk:FindFirstChildOfClass("UIStroke").Color = sec[3] tabInk:FindFirstChildOfClass("UIStroke").Transparency = 0 else Motion.tween(tabInk, 0.2, { BackgroundTransparency = 1 }) tabInk:FindFirstChildOfClass("UIStroke").Transparency = 1 end for name, b in tabButtons do b.TextColor3 = if name == ui.view then C.ink else C.soft end end function Window.setView(v: string) W.closeDropdowns() ui.view = v placeInk(true) setStatus() if v == "templates" or v == "skills" then Library.show(v) elseif v == "hotkeys" then HotkeyPage.show() end end ui.setView = Window.setView -- ── Tiers, workflows, skills ──────────────────────────────────────────────── function ui.setTier(i: number, announce: boolean?) local t = ui.tiers[i] if not t or t.locked then return end ui.tier = i ui.plugin:SetSetting("SynagyTier", t.id) Compose.refreshTier() if announce then toast(t.label .. (if t.note ~= "" then " · " .. t.note else ""), Theme.TIER_HUE[t.id] or "tang") end end function ui.setWorkflow(id: string, announce: boolean?) ui.workflow = id Compose.refreshWorkflow() Feed.resetLane() if announce then for _, w in ui.catalog.workflows do if w.id == id then toast(w.name .. (if w.description ~= "" then ": " .. w.description else ""), "grape") end end end end local function saveSkills() local ids = {} for id in ui.activeSkills do table.insert(ids, id) end ui.plugin:SetSetting("SynagySkills", ids) Compose.refreshSkills() end function ui.toggleSkill(id: string): boolean local now = not ui.activeSkills[id] ui.activeSkills[id] = if now then true else nil saveSkills() return now end function ui.clearSkills() ui.activeSkills = {} saveSkills() end -- ── Upgrade sheet ─────────────────────────────────────────────────────────── local function closeSheet() if not sheet then return false end local s = sheet sheet = nil W.blockDropdowns(false) Motion.tween(s.scrim, 0.2, { BackgroundTransparency = 1 }, Enum.EasingStyle.Quad) local t = Motion.tween(s.panel, 0.22, { Position = UDim2.new(0.5, 0, 1, 40), AnchorPoint = Vector2.new(0.5, 0) }, Enum.EasingStyle.Quad) t.Completed:Connect(function() s.scrim:Destroy() end) return true end -- Why a tier is locked and where to upgrade. Plugins cannot open a browser: the link is selectable to copy. -- tier = the locked tier the user picked (or nil for a general upgrade); requires = plan id from the cloud. function ui.upgrade(tier: { [string]: any }?, requires: string?, message: string?) W.closeDropdowns() closeSheet() W.blockDropdowns(true) local need = requires or (tier and tier.requires) or nil local scrim = new("TextButton", { Parent = overlay, Name = "Sheet", Text = "", AutoButtonColor = false, BackgroundColor3 = C.ink, BackgroundTransparency = 1, Size = UDim2.fromScale(1, 1), ZIndex = 70 }) Motion.tween(scrim, 0.25, { BackgroundTransparency = 0.55 }, Enum.EasingStyle.Quad) local panel, face = surface({ Parent = scrim, auto = true, radius = 20, lip = 4, size = UDim2.new(1, -16, 0, 0), name = "UpgradeSheet" }) panel.AnchorPoint = Vector2.new(0.5, 0) panel.Position = UDim2.new(0.5, 0, 1, 20) new("UISizeConstraint", { Parent = panel, MaxSize = Vector2.new(400, math.huge) }) pad(16, 16, 16, 14).Parent = face list(0).Parent = face -- the panel sinks clicks, so only the scrim around it closes the sheet panel.Active = true W.sticker("Upgrade", face, 1, "tang") W.gap(face, 10, 2) local planName = Logic.planName(ui.plan) local titleText = if tier and need then ("%s needs %s"):format(escape(tier.label), Logic.planName(need)) elseif need then ("Needs the %s plan"):format(Logic.planName(need)) else "Unlock more models" label(titleText, { Parent = face, FontFace = Theme.DISPLAY, TextSize = 19, LayoutOrder = 3 }) W.gap(face, 6, 4) label(escape(message or ("You are on " .. planName .. ". Bigger plans unlock stronger models; credits still pay for each build.")), { Parent = face, TextColor3 = C.soft, TextSize = 12, LayoutOrder = 5 }) W.gap(face, 12, 6) local ladder = new("Frame", { Parent = face, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = 7 }, { list(4) }) for i, p in PLAN_LADDER do local hue = HUES[Theme.PLAN_HUE[p.id] or "ink"] local mine, needed = p.id == ui.plan, p.id == need local row = new("Frame", { Parent = ladder, BackgroundColor3 = if needed then hue[4] elseif mine then C.panel2 else C.panel, BorderSizePixel = 0, Size = UDim2.new(1, 0, 0, 30), LayoutOrder = i }, { corner(9), new("UIStroke", { Color = if needed then hue[1] else C.line, Thickness = if needed then 1.5 else 1 }), }) new("Frame", { Parent = row, BackgroundColor3 = hue[1], BorderSizePixel = 0, Size = UDim2.fromOffset(8, 8), Position = UDim2.new(0, 10, 0.5, -4), Rotation = 45 }, { corner(2) }) label("" .. Logic.planName(p.id) .. "" .. (if mine then ' · yours' else ""), { Parent = row, wrap = false, TextSize = 12, Position = UDim2.new(0, 26, 0, 0), Size = UDim2.new(0.45, -26, 1, 0), TextYAlignment = Enum.TextYAlignment.Center }) label(p.tiers, { Parent = row, wrap = false, TextSize = 11, TextColor3 = C.soft, Position = UDim2.new(0.45, 0, 0, 0), Size = UDim2.new(0.55, -10, 1, 0), TextXAlignment = Enum.TextXAlignment.Right, TextYAlignment = Enum.TextYAlignment.Center }) if needed then Motion.popIn(row, 0.9, 0.5) end end W.gap(face, 12, 8) local url = ui.catalog.upgradeUrl or DEFAULT_UPGRADE_URL local linkOuter, linkFace = surface({ Parent = face, radius = 10, lip = 2, size = UDim2.new(1, 0, 0, 34), order = 9, bg = C.panel2 }) linkOuter.AutomaticSize = Enum.AutomaticSize.None local link = new("TextBox", { Parent = linkFace, BackgroundTransparency = 1, ClearTextOnFocus = false, TextEditable = false, Text = url, FontFace = Theme.BOLD, TextSize = 12, TextColor3 = C.ink, TextXAlignment = Enum.TextXAlignment.Left, Size = UDim2.fromScale(1, 1), TextTruncate = Enum.TextTruncate.AtEnd }, { pad(10, 0) }) W.gap(face, 10, 10) local buttons = new("Frame", { Parent = face, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 34), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = 11 }, { new("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Wraps = true, Padding = UDim.new(0, 8), SortOrder = Enum.SortOrder.LayoutOrder }), }) key("Select link", "main", function() link:CaptureFocus() link.SelectionStart = 1 link.CursorPosition = #link.Text + 1 toast("Selected – press Ctrl+C to copy", "tang") end, { order = 1, Parent = buttons }) key("Not now", "ghost", closeSheet, { order = 2, Parent = buttons }) scrim.MouseButton1Click:Connect(closeSheet) Motion.tween(panel, 0.45, { Position = UDim2.new(0.5, 0, 1, -8), AnchorPoint = Vector2.new(0.5, 1) }, Enum.EasingStyle.Back) sheet = { scrim = scrim, panel = panel } end function ui.escape() if W.closeDropdowns() then return end if closeSheet() then return end if ui.view == "hotkeys" then Window.setView("chat") end end -- ── Context actions ───────────────────────────────────────────────────────── local function selectionInfo(): (number, string?) local ok, sel = pcall(function() return game:GetService("Selection"):Get() end) if not ok or type(sel) ~= "table" then return 0, nil end local part = nil if #sel == 1 and typeof(sel[1]) == "Instance" and sel[1]:IsA("BasePart") then part = sel[1].Name if utf8.len(part) and (utf8.len(part) :: number) > 14 then part = string.sub(part, 1, 13) .. "…" end end return #sel, part end function ui.quickState() local n, part = selectionInfo() return { errors = errorCount, selected = n, part = part, built = ui.built, busy = ui.building or ui.thinking } end function ui.addSelection() if not ui.Context then return end local n = ui.Context.addSelection() if n == 0 then toast("Select something in Explorer or the viewport first", "tang") end end function ui.addScript() if ui.Context and not ui.Context.addOpenScript() then toast("Open a script in the editor first", "sky") end end function ui.addErrors() if ui.Context and ui.Context.addOutputErrors() == 0 then toast("No errors in the Output – nice", "lime") end end function ui.screenshot() local Capture = ui.Capture if not Capture then return end local ok, why = Capture.available() if not ok then toast("Screenshot: " .. tostring(why or "unavailable"), "bub") return end task.spawn(function() local done, res = Capture.screenshot() if done and type(res) == "table" then if ui.Context then ui.Context.add(res) end toast("Screenshot added", "bub") else toast("Screenshot failed: " .. tostring(res), "bub") end end) end function ui.importImages() if not ui.Capture then return end task.spawn(function() local ok, imgs = pcall(ui.Capture.importImages) if ok and type(imgs) == "table" and ui.Context then for _, it in imgs do ui.Context.add(it) end end end) end -- ── Chat ──────────────────────────────────────────────────────────────────── function ui.sendChat(text: string?): boolean local Cloud = ui.Cloud text = Logic.trim(text) local payload = nil if ui.Context then local ok, p = pcall(ui.Context.payload) if ok and type(p) == "table" then payload = p end end local context = payload and payload.context or {} local attachments = payload and payload.attachments or {} if text == "" then -- The cloud needs at least one character, also when only an image or context goes along if #attachments > 0 then text = if #attachments == 1 then "Look at this screenshot." else "Look at these screenshots." elseif #context > 0 then text = "Take a look at this." else return false end end if Cloud.status ~= "online" then toast(if Cloud.status == "signed_out" then "Authorize Synagy first" else "Not connected yet – your text stays in the box") return false end Feed.clearQuestions() local skills = {} for id in ui.activeSkills do table.insert(skills, id) end local msg: { [string]: any } = { t = "chat", text = string.sub(text, 1, 4000), model = ui.tiers[ui.tier] and ui.tiers[ui.tier].id or "", skills = skills, workflow = ui.workflow } if #context > 0 then msg.context = context end if #attachments > 0 then msg.attachments = attachments end Cloud.send(msg) -- Your own message shows right away (the cloud only replays it after a reconnect, like the Unity window) Feed.message(text, "user") if ui.Context and (#context > 0 or #attachments > 0) then ui.Context.clear() end errorCount = 0 ui.thinking = true Feed.resetLane() setTicker() Compose.refreshQuick() return true end function ui.stop() ui.Cloud.send({ t = "stop" }) toast("Stopping after the current step", "ink") end function ui.quick(id: string) if id == "fix_errors" then if ui.Context then ui.Context.addOutputErrors() end ui.sendChat("Fix the errors in the Output.") elseif id == "explain" then if ui.Context then ui.Context.addSelection() end ui.sendChat("Explain what the selected objects do and how they work together.") elseif id == "make_part" then if ui.Context then ui.Context.addSelection() end ui.setInput("Make the selected part ") elseif id == "playtest_review" then ui.sendChat("Playtest the game and review it like a player: what works, what feels off, what to fix first.") end end local function clearChat() W.closeDropdowns() ui.building, ui.thinking = false, false tickerText = "" touchedPaths = {} fractions = {} Feed.clear() setStatus() Compose.refreshQuick() end local function newChat() clearChat() ui.Cloud.newProject() end -- ── Cloud messages ────────────────────────────────────────────────────────── local function onMessage(msg) if msg.t ~= "status" and msg.t ~= "pong" then lastNews = os.clock() end -- Keep part states current for the whole-plan bar local item = type(msg.componentId) == "string" and ui.planItems and ui.planItems[msg.componentId] if item then if msg.t == "progress" then item.status = "building" fractions[msg.componentId] = tonumber(msg.fraction) or 0 elseif msg.t == "component_done" then item.status = "done" elseif msg.t == "component_failed" then item.status = "failed" end end if msg.t == "status" or msg.t == "welcome" then if msg.t == "welcome" then local plan = if type(msg.plan) == "string" and msg.plan ~= "" then msg.plan else ui.Cloud.plan if type(plan) == "string" and plan ~= "" then ui.plan = plan end if msg.notice and msg.notice ~= "" then Feed.message(msg.notice, "notice") end end setStatus() if ui.view == "skills" then Library.redraw() end elseif msg.t == "catalog" then ui.catalog.workflows = msg.workflows or {} ui.catalog.skills = msg.skills or {} if type(msg.upgradeUrl) == "string" and msg.upgradeUrl ~= "" then ui.catalog.upgradeUrl = msg.upgradeUrl end ui.tiers = Logic.tiers(msg.tiers) ui.tier = Logic.pickTier(ui.tiers, ui.plugin:GetSetting("SynagyTier")) Compose.refreshTier() if ui.workflow == "" then ui.workflow = msg.workflow or "" end Compose.refreshWorkflow() Compose.refreshSkills() if ui.view == "skills" then Library.redraw() end elseif msg.t == "chat" then if msg.from ~= "user" then ui.thinking = false end -- Replayed history after a reconnect, and the cloud's answers Feed.message(msg.text, msg.from or "synagy") setTicker() Compose.refreshQuick() elseif msg.t == "questions" then ui.thinking = false Feed.questions(msg) setTicker() elseif msg.t == "plan" then ui.thinking = false ui.building = Feed.plan(msg) setStatus() elseif msg.t == "progress" then ui.building = true Feed.agent(msg.agent) Feed.progress(msg) setStatus() elseif msg.t == "ticker" then tickerText = msg.text or "" Feed.agent(msg.agent) setTicker() elseif msg.t == "component_done" then if type(msg.paths) == "table" then touchedPaths[msg.componentId] = msg.paths end Feed.done(msg.componentId, msg.charged) setStatus() Compose.refreshQuick() elseif msg.t == "component_failed" then if type(msg.paths) == "table" then touchedPaths[msg.componentId] = msg.paths end Feed.failed(msg.componentId, msg.reason) setStatus() elseif msg.t == "balance" then setStatus() elseif msg.t == "error" then -- An error ends the turn: no thinking orb, no agent lane, no stale ticker left behind ui.thinking, ui.building = false, false tickerText = "" Feed.resetLane() if msg.code == "plan_required" then Feed.message(tostring(msg.message), "notice") local tier = ui.tiers[ui.tier] ui.upgrade(if tier and tier.locked then tier else nil, msg.requires, msg.message) else Feed.message(tostring(msg.message), "error") end setStatus() end end -- ── Sign-in (Authorize) ───────────────────────────────────────────────────── local function onAccount(state, info) signHero.Text = 'Authorize Synagy' if state == "code" then signLead.Text = "Open synagy.org/link in your browser, sign in and confirm this code. The link below has the code in it." signCode.Text = info.userCode codeWrap.Visible = true signUrl.Text = info.verificationUri .. "?code=" .. info.userCode signUrl.Parent.Visible = true signMain.outer.Visible = false signCancel.outer.Visible = true signFine.Text = "Waiting for the browser…" signFine.TextColor3 = C.faint Motion.popIn(codeWrap, 0.6, 0.5) else signLead.Text = "Synagy is locked in this place until you authorize it with your Synagy account. That unlocks the chat, templates and skills, and lets your own agents (MCP) work here." codeWrap.Visible, signUrl.Parent.Visible = false, false signMain.outer.Visible, signCancel.outer.Visible = true, false signFine.Text = if state == "failed" then "Sign-in failed: " .. tostring(info.message) .. " Try again." else "Studio shows a code; you confirm it on synagy.org. No password is stored in Roblox Studio." signFine.TextColor3 = if state == "failed" then C.bad else C.faint if state == "signed_in" then clearChat() ui.Cloud.start() elseif state == "signed_out" then clearChat() ui.Cloud.stop() end end setStatus() end -- ── Hotkeys ───────────────────────────────────────────────────────────────── -- Wiring split (with the bridge): the bridge handles toggle_window, add_selection, add_script and screenshot; the -- window handles every other Studio-scope id. Hotkeys.attach(root) lets the keys fire while the widget has focus. local function bindHotkeys() local hk = ui.Hotkeys if not hk then return end if hk.attach then pcall(hk.attach, rootFrame) end local function on(id: string, fn: (string?) -> ()) pcall(hk.on, id, function(phase) fn(phase) end) end local function down(fn: () -> ()) return function(phase) if phase == nil or phase == "down" then fn() end end end local function show() widget.Enabled = true end on("focus_input", down(function() show() if ui.view ~= "chat" then Window.setView("chat") end Compose.focus() end)) on("voice", function(phase) if not Compose.voiceStart then return end if phase == "up" then Compose.voiceStop() else show() Compose.voiceStart() end end) on("fix_errors", down(function() show() ui.quick("fix_errors") end)) on("explain", down(function() show() ui.quick("explain") end)) on("playtest_review", down(function() show() ui.quick("playtest_review") end)) on("new_chat", down(function() show() newChat() end)) on("tier_next", down(function() ui.setTier(Logic.nextTier(ui.tiers, ui.tier), true) end)) on("workflow_next", down(function() local l = ui.catalog.workflows if #l == 0 then return end local idx = 1 for i, w in l do if w.id == ui.workflow then idx = i end end ui.setWorkflow(l[idx % #l + 1].id, true) end)) on("hotkeys", down(function() show() Window.setView(if ui.view == "hotkeys" then "chat" else "hotkeys") end)) on("stop", down(function() if ui.building then ui.stop() end end)) if hk.onChanged then pcall(hk.onChanged, function() HotkeyPage.redraw() Feed.refreshEmpty() end) end end -- ── Build ─────────────────────────────────────────────────────────────────── local function header() local top = new("Frame", { Parent = rootFrame, Name = "Header", BackgroundColor3 = C.bg, BorderSizePixel = 0, Size = UDim2.new(1, 0, 0, 52), LayoutOrder = 1 }, { pad(14, 0, 12, 0) }) new("Frame", { Parent = rootFrame, BackgroundColor3 = C.line, BorderSizePixel = 0, Size = UDim2.new(1, 0, 0, 1), LayoutOrder = 2 }) local logo = new("Frame", { Parent = top, BackgroundColor3 = C.tangLip, Size = UDim2.new(0, 22, 0, 22), Position = UDim2.new(0, 0, 0.5, -11), Rotation = -6, BorderSizePixel = 0 }, { corner(7), pad(0, 0, 0, 3) }) new("Frame", { Parent = logo, BackgroundColor3 = C.tang, Size = UDim2.new(1, 0, 1, 0), BorderSizePixel = 0 }, { corner(7), new("UIStroke", { Color = C.tangGlow, Thickness = 1, Transparency = 0.5 }) }) local hoverZone = new("TextButton", { Parent = top, Text = "", BackgroundTransparency = 1, Size = UDim2.new(0, 110, 1, 0) }) hoverZone.MouseEnter:Connect(function() Motion.spring(logo, { Rotation = 12 }, 0.5) Motion.bump(logo, 0.12) end) hoverZone.MouseLeave:Connect(function() Motion.spring(logo, { Rotation = -6 }, 0.5) end) local wordmark = new("TextLabel", { Parent = top, Name = "Wordmark", BackgroundTransparency = 1, Text = "synagy", FontFace = Theme.DISPLAY, TextSize = 21, TextColor3 = C.ink, AutomaticSize = Enum.AutomaticSize.X, Size = UDim2.new(0, 0, 1, -2), Position = UDim2.new(0, 30, 0, 0), TextXAlignment = Enum.TextXAlignment.Left }) local right = new("Frame", { Parent = top, BackgroundTransparency = 1, AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, 0, 0.5, 0), Size = UDim2.new(0, 0, 0, 30), AutomaticSize = Enum.AutomaticSize.X }, { new("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Padding = UDim.new(0, 6), SortOrder = Enum.SortOrder.LayoutOrder, VerticalAlignment = Enum.VerticalAlignment.Center }), }) -- Credits pill with a dropdown: balance, plan, where to top up local credits = new("TextButton", { Parent = right, Name = "Credits", Text = "", AutoButtonColor = false, BackgroundColor3 = C.bg, BorderSizePixel = 0, AutomaticSize = Enum.AutomaticSize.X, Size = UDim2.new(0, 0, 0, 30), LayoutOrder = 1 }, { corner(15), new("UIStroke", { Color = C.line2, Thickness = 1 }), pad(10, 0, 11, 0), list(7, true), }) local gemBox = new("Frame", { Parent = credits, BackgroundTransparency = 1, Size = UDim2.new(0, 11, 0, 11), LayoutOrder = 1 }) creditsGem = new("Frame", { Parent = gemBox, BackgroundColor3 = C.butter, Size = UDim2.new(0, 8, 0, 8), AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.new(0.5, 0, 0.5, 0), Rotation = 45, BorderSizePixel = 0 }, { corner(2) }) creditsLabel = new("TextLabel", { Parent = credits, BackgroundTransparency = 1, Text = "–", FontFace = Theme.XBOLD, TextSize = 12, TextColor3 = C.ink, AutomaticSize = Enum.AutomaticSize.X, Size = UDim2.new(0, 0, 1, 0), LayoutOrder = 2 }) credits.MouseEnter:Connect(function() credits.BackgroundColor3 = C.panel Motion.spring(creditsGem, { Rotation = 135 }, 0.5) end) credits.MouseLeave:Connect(function() credits.BackgroundColor3 = C.bg Motion.spring(creditsGem, { Rotation = 45 }, 0.5) end) W.dropdown(credits, { width = 240, alignRight = true, build = function(dd) local planHue = HUES[Theme.PLAN_HUE[ui.plan] or "ink"] dd.header("Your account", function(r) W.pill(string.upper(Logic.planName(ui.plan)), planHue[3], planHue[4], { Parent = r, size = 9, height = 16, padX = 6 }) end) local holder = dd.custom(48) local big = label(Logic.formatCredits(shownCredits), { Parent = holder, wrap = false, FontFace = Theme.DISPLAY, TextSize = 24, Position = UDim2.new(0, 10, 0, 2), Size = UDim2.new(1, -20, 0, 28) }) big.TextTruncate = Enum.TextTruncate.None label("credits left · builds show their price first", { Parent = holder, wrap = false, TextSize = 10.5, TextColor3 = C.faint, Position = UDim2.new(0, 10, 0, 30), Size = UDim2.new(1, -20, 0, 14) }) dd.sep() dd.row({ text = "Top up or upgrade", sub = "synagy.org/pricing", icon = "spark", iconColor = C.tang, onSelect = function() ui.upgrade(nil) end }) end, }) local playKey = W.sq(nil, ui.runPlay, { Parent = right, order = 2, text = "▶", textSize = 11, name = "Play" }) W.tooltip(playKey.outer, "Play (F5)") local menu = W.sq(nil, nil, { Parent = right, order = 3, name = "Menu" }) -- Three dots instead of ⋮: Builder Sans has no glyph for it for i = -1, 1 do new("Frame", { Parent = menu.face, BackgroundColor3 = C.soft, Size = UDim2.new(0, 3, 0, 3), AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.new(0.5, 0, 0.5, i * 5), BorderSizePixel = 0 }, { corner(2) }) end statusDot = new("Frame", { Parent = menu.outer, BackgroundColor3 = C.panel4, Size = UDim2.new(0, 8, 0, 8), AnchorPoint = Vector2.new(1, 1), Position = UDim2.new(1, -2, 1, -2), BorderSizePixel = 0, ZIndex = 3 }, { corner(4), new("UIStroke", { Color = C.panel, Thickness = 2 }), }) W.dropdown(menu.outer, { width = 250, alignRight = true, build = function(dd) local s = ui.Cloud.status local signedIn = ui.Account.token() ~= nil dd.note(if s == "online" then "Connected as " .. escape(ui.Cloud.email) elseif s == "connecting" then "Connecting…" elseif signedIn then escape(if ui.Cloud.detail ~= "" then ui.Cloud.detail else "Offline") else "Not authorized", if s == "online" then C.ok else C.faint) dd.note(if bridgeKind == "off" then "Agent bridge: off" else "Agent bridge: " .. escape(bridgeText), if bridgeKind == "connected" then C.ok elseif bridgeKind == "blocked" or bridgeKind == "foreign" then C.bad else C.faint) dd.sep() if signedIn then dd.row({ text = "New chat", icon = "plus", iconColor = C.tang, right = (function() local caps = ui.hotkeyCaps("new_chat") return if caps and #caps > 0 then function(r) W.shortcut(caps, { Parent = r, height = 16, textSize = 9 }) end else nil end)(), rightWidth = 80, onSelect = newChat }) end dd.row({ text = "Shortcuts", icon = "keyboard", iconColor = C.sky, onSelect = function() Window.setView("hotkeys") end }) if signedIn then dd.row({ text = "Sign out", icon = "back", iconColor = C.faint, onSelect = function() ui.Account.signOut() end }) end end, }) -- Narrow side docks: first the play key goes, then the wordmark (the Unity window wraps the same way) return function(w: number) playKey.outer.Visible = w >= 300 wordmark.Visible = w >= 250 end end local function tabs() tabsWrap = new("Frame", { Parent = rootFrame, Name = "Tabs", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 50), LayoutOrder = 3 }, { pad(12, 10, 12, 0) }) tabsBar = new("Frame", { Parent = tabsWrap, BackgroundColor3 = C.white, BackgroundTransparency = 0.3, Size = UDim2.new(1, 0, 1, 0), BorderSizePixel = 0 }, { corner(14), new("UIStroke", { Color = C.line2, Thickness = 1 }) }) tabInk = new("Frame", { Parent = tabsBar, BackgroundColor3 = C.tangTint, BorderSizePixel = 0, Size = UDim2.new(0, 60, 1, -8), Position = UDim2.new(0, 4, 0, 4) }, { corner(10), new("UIStroke", { Color = C.tangLine, Thickness = 1 }) }) tabButtons = {} local tabRow = new("Frame", { Parent = tabsBar, BackgroundTransparency = 1, Size = UDim2.new(1, -8, 1, -8), Position = UDim2.new(0, 4, 0, 4), ZIndex = 2 }, { new("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, SortOrder = Enum.SortOrder.LayoutOrder, HorizontalFlex = Enum.UIFlexAlignment.Fill }), }) for i, name in { "chat", "templates", "skills" } do local b = new("TextButton", { Parent = tabRow, Text = string.upper(string.sub(name, 1, 1)) .. string.sub(name, 2), AutoButtonColor = false, BackgroundTransparency = 1, FontFace = Theme.BOLD, TextSize = 12.5, TextColor3 = C.soft, Size = UDim2.new(0, 60, 1, 0), LayoutOrder = i, ZIndex = 2, TextTruncate = Enum.TextTruncate.AtEnd }) b.MouseButton1Click:Connect(function() Window.setView(name) Motion.bump(b, 0.1) end) b.MouseEnter:Connect(function() b.TextColor3 = C.ink end) b.MouseLeave:Connect(function() b.TextColor3 = if ui.view == name then C.ink else C.soft end) tabButtons[name] = b end tabsBar:GetPropertyChangedSignal("AbsoluteSize"):Connect(function() placeInk(false) end) end local function signIn() signView = W.flex(new("Frame", { Parent = rootFrame, Name = "SignIn", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), LayoutOrder = 4 }, { pad(12, 10, 12, 12) })) local _, signFace = surface({ Parent = signView, auto = true, radius = 16, lip = 3 }) pad(18, 16).Parent = signFace list(0).Parent = signFace signHero = label("", { Parent = signFace, FontFace = Theme.DISPLAY, TextSize = 22, LayoutOrder = 1 }) W.gap(signFace, 6, 2) signLead = label("", { Parent = signFace, TextColor3 = C.soft, TextSize = 13, LayoutOrder = 3 }) W.gap(signFace, 14, 4) codeWrap = new("Frame", { Parent = signFace, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 62), LayoutOrder = 5, Visible = false }) local codeLip = new("Frame", { Parent = codeWrap, BackgroundColor3 = C.butterLip, AutomaticSize = Enum.AutomaticSize.X, Size = UDim2.new(0, 0, 0, 50), Rotation = -2, BorderSizePixel = 0, Position = UDim2.new(0, 2, 0, 4) }, { corner(12), pad(0, 0, 0, 4) }) signCode = new("TextBox", { Parent = codeLip, BackgroundColor3 = C.butterBody, ClearTextOnFocus = false, TextEditable = false, Text = "", FontFace = Theme.DISPLAY, TextSize = 26, TextColor3 = C.ink, AutomaticSize = Enum.AutomaticSize.X, Size = UDim2.new(0, 0, 1, 0), BorderSizePixel = 0 }, { corner(12), pad(16, 0) }) local urlWrap = new("Frame", { Parent = signFace, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 44), LayoutOrder = 6, Visible = false }) signUrl = new("TextBox", { Parent = urlWrap, BackgroundColor3 = C.panel2, ClearTextOnFocus = false, TextEditable = false, Text = "", FontFace = Theme.BODY, TextSize = 12, TextColor3 = C.ink, TextWrapped = true, TextXAlignment = Enum.TextXAlignment.Left, Size = UDim2.new(1, 0, 0, 34), BorderSizePixel = 0 }, { corner(10), pad(10, 0) }) signMain = key("Authorize", "main", function() task.spawn(ui.Account.signIn) end, { order = 7, Parent = signFace }) signCancel = key("Cancel", "plain", function() ui.Account.cancel() end, { order = 8, Parent = signFace }) signCancel.outer.Visible = false W.gap(signFace, 8, 9) signFine = label("", { Parent = signFace, TextColor3 = C.faint, TextSize = 11, LayoutOrder = 10 }) end -- deps = { Context, Hotkeys, Capture, Voice, Ops? }; every one may be nil → that feature stays hidden. function Window.init(p, cloud, account, deps) deps = deps or {} ui.plugin, ui.Cloud, ui.Account = p, cloud, account ui.Context, ui.Hotkeys, ui.Capture, ui.Voice, ui.Ops = deps.Context, deps.Hotkeys, deps.Capture, deps.Voice, deps.Ops local saved = p:GetSetting("SynagySkills") if type(saved) == "table" then for _, id in saved do if type(id) == "string" then ui.activeSkills[id] = true end end end -- Docked right. Floating (Float) is not shown at all by niri under Vinegar/Wine; the width is that of the right -- dock column Studio keeps in its layout (Linux: DockCX of FixedSplitLayoutEast0 in user.reg). local info = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, true, false, 440, 760, 360, 480) widget = p:CreateDockWidgetPluginGui("SynagyWindow", info) pcall(function() widget.Title = "Synagy" end) widget.Name = "Synagy" widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling ui.widget = widget rootFrame = new("Frame", { Parent = widget, Name = "Root", BackgroundColor3 = C.bg, Size = UDim2.new(1, 0, 1, 0), BorderSizePixel = 0 }, { list(0) }) -- Popover layer above everything (dropdowns, tooltips, sheet, toast, confetti) overlay = new("Frame", { Parent = widget, Name = "Overlay", BackgroundTransparency = 1, Size = UDim2.fromScale(1, 1), ZIndex = 50 }) W.setOverlay(overlay) local headerWidth = header() tabs() signIn() mainView = W.flex(new("Frame", { Parent = rootFrame, Name = "Main", BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), LayoutOrder = 5 }, { list(0) })) Feed = UiFeed.build(ui, mainView) Compose = UiCompose.build(ui, mainView) Library = UiLibrary.build(ui, rootFrame, 6) HotkeyPage = UiHotkeys.build(ui, rootFrame, 7) -- Toast (spring in, above the compose box) toastFrame = new("Frame", { Parent = overlay, Name = "Toast", BackgroundColor3 = C.black, AnchorPoint = Vector2.new(0.5, 1), Position = UDim2.new(0.5, 0, 1, -124), AutomaticSize = Enum.AutomaticSize.XY, Size = UDim2.new(0, 0, 0, 0), Visible = false, ZIndex = 90, BorderSizePixel = 0 }, { corner(12), pad(0, 0, 0, 2) }) local toastFace = new("Frame", { Parent = toastFrame, BackgroundColor3 = C.ink, AutomaticSize = Enum.AutomaticSize.XY, Size = UDim2.new(0, 0, 0, 0), BorderSizePixel = 0 }, { corner(12), pad(12, 8, 13, 8), list(8, true) }) toastDot = new("Frame", { Parent = toastFace, BackgroundColor3 = C.tang, Size = UDim2.fromOffset(7, 7), BorderSizePixel = 0, Rotation = 45, LayoutOrder = 1 }, { corner(2) }) toastLabel = new("TextLabel", { Parent = toastFace, BackgroundTransparency = 1, Text = "", RichText = true, FontFace = Theme.BOLD, TextSize = 11.5, TextColor3 = C.white, AutomaticSize = Enum.AutomaticSize.XY, Size = UDim2.new(0, 0, 0, 0), TextWrapped = true, LayoutOrder = 2 }, { new("UISizeConstraint", { MaxSize = Vector2.new(260, 200) }) }) -- Width: header items, bar wrapping, plan footer local function onWidth() local w = rootFrame.AbsoluteSize.X if w <= 0 then return end local was = ui.narrow ui.narrow = w < 300 if was ~= ui.narrow then Feed.refreshEmpty() end headerWidth(w) Compose.setWidth(w) toastLabel:FindFirstChildOfClass("UISizeConstraint").MaxSize = Vector2.new(math.max(120, w - 60), 200) end rootFrame:GetPropertyChangedSignal("AbsoluteSize"):Connect(onWidth) task.defer(onWidth) -- Esc while the pointer is over the window rootFrame.InputBegan:Connect(function(io) if io.UserInputType == Enum.UserInputType.Keyboard then local dd = W.openDropdown() if dd and dd.key(io.KeyCode) then return end if io.KeyCode == Enum.KeyCode.Escape then ui.escape() end end end) -- Tiers saved from last time (the catalog refines them) ui.tier = Logic.pickTier(ui.tiers, p:GetSetting("SynagyTier")) Compose.refreshTier() -- Studio state for quick actions: errors in the Output, the selection (only listened to while the window is open) pcall(function() game:GetService("LogService").MessageOut:Connect(function(_, kind) if kind == Enum.MessageType.MessageError then errorCount += 1 if widget.Enabled then Compose.refreshQuick() end end end) end) local selCon = nil local function onOpen(open: boolean) Motion.setActive(open) if ui.Context and ui.Context.setWindowOpen then pcall(ui.Context.setWindowOpen, open) end if open and not selCon then pcall(function() selCon = game:GetService("Selection").SelectionChanged:Connect(function() Compose.refreshQuick() end) end) Compose.refreshQuick() elseif not open then if selCon then selCon:Disconnect() selCon = nil end W.closeDropdowns() W.hideTip() end end widget:GetPropertyChangedSignal("Enabled"):Connect(function() onOpen(widget.Enabled) end) onOpen(widget.Enabled) -- Context chips follow Context (Ctrl+Click, toolbar, hotkeys, attach menu) if ui.Context and ui.Context.onChanged then ui.Context.onChanged(function(items) local before = #(Compose._lastItems or {}) Compose._lastItems = items Compose.setChips(items) if #items > before and not widget.Enabled then return end if #items > before then local last = items[#items] if last and last.kind == "instance" then toast("Added " .. tostring(last.label), "tang") end end end) local ok, items = pcall(ui.Context.items) if ok and type(items) == "table" then Compose._lastItems = items Compose.setChips(items) end end bindHotkeys() ui.Cloud.onMessage(onMessage) ui.Account.onChange(onAccount) onAccount("idle", {}) Feed.emptyState() setStatus() task.defer(placeInk, false) return widget end -- State of the MCP bridge (your own AI agent), shown in the menu. kind: off | waiting | connected | blocked | foreign function Window.setBridge(kind, text) bridgeKind, bridgeText = kind, text end function Window.toggle() widget.Enabled = not widget.Enabled return widget.Enabled end function Window.widget() return widget end return Window ]]>