-- BloxBuilder Editor UX Core -- Bundled ahead of VibeCoderAI.plugin.lua by the download route. -- Wrapped in do-end so chunk locals stay under Luau's 200-register limit. do local Editor = {} Editor.SETTING_HISTORY = "BloxBuilder_CommandHistory_v1" Editor.SETTING_VERSIONS = "BloxBuilder_BuildVersions_v1" Editor.SETTING_SESSION = "BloxBuilder_EditorSession_v1" Editor.MAX_HISTORY = 24 Editor.MAX_VERSIONS = 30 Editor.STAGE_LABELS = { intent = "Intent Detection", theme = "Theme Extraction", high_level_design = "High-Level Design", world = "World Planning", terrain = "Terrain Planning", genre = "Genre Design", agent = "Genre Agent", gameplay = "Gameplay Loop", gdd = "Game Design Doc", systems = "Systems Planning", retention = "Retention Planning", assets = "Asset Selection", buildings = "Building Placement", npcs = "NPC Generation", quests = "Quest Generation", knowledge = "Knowledge Graph", spawn = "Spawn System", economy = "Economy Planning", progression = "Progression Planning", balance = "Balance Engine", constraints = "Constraint Solver", performance = "Performance Optimizer", validation = "Validation", repair = "Repair Pass", artifacts = "Roblox Generation", } Editor.IMPROVE_SCOPES = { { id = "visuals", label = "Improve Visuals" }, { id = "terrain", label = "Improve Terrain" }, { id = "gameplay", label = "Improve Gameplay" }, { id = "npcs", label = "Improve NPCs" }, { id = "quests", label = "Improve Quests" }, { id = "economy", label = "Improve Economy" }, { id = "progression", label = "Improve Progression" }, { id = "performance", label = "Improve Performance" }, } function Editor.newCommandHistory(pluginRef, HttpService) local self = { plugin = pluginRef, http = HttpService, undoStack = {}, redoStack = {}, } function self:load() local ok, raw = pcall(function() return self.plugin:GetSetting(Editor.SETTING_HISTORY) end) if ok and typeof(raw) == "string" and raw ~= "" then local ok2, decoded = pcall(function() return self.http:JSONDecode(raw) end) if ok2 and typeof(decoded) == "table" then self.undoStack = decoded.undo or {} self.redoStack = decoded.redo or {} end end end function self:save() pcall(function() self.plugin:SetSetting(Editor.SETTING_HISTORY, self.http:JSONEncode({ undo = self.undoStack, redo = self.redoStack, })) end) end function self:push(entry) table.insert(self.undoStack, entry) while #self.undoStack > Editor.MAX_HISTORY do table.remove(self.undoStack, 1) end self.redoStack = {} self:save() end function self:undo() if #self.undoStack == 0 then return nil end local entry = table.remove(self.undoStack) table.insert(self.redoStack, entry) self:save() return entry end function self:redo() if #self.redoStack == 0 then return nil end local entry = table.remove(self.redoStack) table.insert(self.undoStack, entry) self:save() return entry end self:load() return self end function Editor.newVersionStore(pluginRef, HttpService) local self = { plugin = pluginRef, http = HttpService, versions = {} } function self:load() local ok, raw = pcall(function() return self.plugin:GetSetting(Editor.SETTING_VERSIONS) end) if ok and typeof(raw) == "string" and raw ~= "" then local ok2, decoded = pcall(function() return self.http:JSONDecode(raw) end) if ok2 and typeof(decoded) == "table" then self.versions = decoded end end end function self:save() pcall(function() self.plugin:SetSetting(Editor.SETTING_VERSIONS, self.http:JSONEncode(self.versions)) end) end function self:add(entry) table.insert(self.versions, 1, entry) while #self.versions > Editor.MAX_VERSIONS do table.remove(self.versions) end self:save() return entry.id end function self:get(id) for _, v in ipairs(self.versions) do if v.id == id then return v end end return nil end function self:remove(id) for i, v in ipairs(self.versions) do if v.id == id then table.remove(self.versions, i) self:save() return true end end return false end self:load() return self end function Editor.formatHealthPanel(health) if type(health) ~= "table" then return "Build Health\n—\nGenerate to see scores." end local lines = { "═══ Build Health ═══", ("World Quality: %d/100"):format(tonumber(health.worldQuality) or 0), ("Gameplay Quality: %d/100"):format(tonumber(health.gameplayQuality) or 0), ("Theme Consistency: %d/100"):format(tonumber(health.themeConsistency) or 0), ("Performance: %d/100"):format(tonumber(health.performanceScore) or 0), ("Validation: %d/100"):format(tonumber(health.validationScore) or 0), "", "── Summaries ──", tostring(health.summaries and health.summaries.world or "—"), tostring(health.summaries and health.summaries.systems or "—"), tostring(health.summaries and health.summaries.quests or "—"), tostring(health.summaries and health.summaries.npcs or "—"), } if health.errors and #health.errors > 0 then table.insert(lines, "") table.insert(lines, "── Errors ──") for _, e in ipairs(health.errors) do table.insert(lines, "• " .. tostring(e)) end end if health.warnings and #health.warnings > 0 then table.insert(lines, "") table.insert(lines, "── Warnings ──") for _, w in ipairs(health.warnings) do table.insert(lines, "• " .. tostring(w)) end end if health.repairSuggestions and #health.repairSuggestions > 0 then table.insert(lines, "") table.insert(lines, "── Repair Suggestions ──") for _, s in ipairs(health.repairSuggestions) do table.insert(lines, "→ " .. tostring(s)) end end return table.concat(lines, "\n") end function Editor.formatPreviewPanel(uiState, health) local lines = { "═══ Preview Mode ═══", "Flow: Generate → Validate → Preview → Apply", "", ("Prompt: %s"):format(tostring(uiState.lastPrompt or "—"):sub(1, 120)), ("Route: %s"):format(tostring(uiState.lastRoute or "—")), ("Ready to apply: %s"):format(uiState.canApply and "YES" or "no"), ("3D preview placed: %s"):format(uiState.previewApplied and "yes" or "no"), } if uiState.lastRoute == "gameplay" then table.insert(lines, "") table.insert(lines, "Gameplay: world geometry shows in Edit mode; scripts run on Play ▶.") table.insert(lines, "Check Explorer for scripts under ServerScriptService / ReplicatedStorage.") end table.insert(lines, "") table.insert(lines, Editor.formatHealthPanel(health)) return table.concat(lines, "\n") end function Editor.formatProgress(stage, durationMs, success) local label = Editor.STAGE_LABELS[stage] or stage or "Working" local ms = tonumber(durationMs) or 0 local mark = "✓" if success == false then mark = "✗" end if ms > 0 then return ("%s %s (%dms)"):format(mark, label, ms) end return ("… %s"):format(label) end function Editor.formatVersionLine(v) return ("[%s] %s — %s (val %s)"):format( tostring(v.createdAt or "?"):sub(1, 16), tostring(v.summary or "Build"):sub(1, 40), tostring(v.prompt or ""):sub(1, 50), tostring(v.validationScore or "—") ) end function Editor.newSessionRecovery(pluginRef, HttpService) local self = { plugin = pluginRef, http = HttpService } function self:save(session) pcall(function() self.plugin:SetSetting(Editor.SETTING_SESSION, self.http:JSONEncode(session)) end) end function self:load() local ok, raw = pcall(function() return self.plugin:GetSetting(Editor.SETTING_SESSION) end) if ok and typeof(raw) == "string" and raw ~= "" then local ok2, decoded = pcall(function() return self.http:JSONDecode(raw) end) if ok2 then return decoded end end return nil end function self:clear() pcall(function() self.plugin:SetSetting(Editor.SETTING_SESSION, "") end) end return self end function Editor.makeVersionId() return ("v_%d_%d"):format(os.time(), math.random(1000, 9999)) end function Editor.instancePath(inst) local parts = {} local p = inst while p and p ~= game do table.insert(parts, 1, p.Name) p = p.Parent end return table.concat(parts, "/") end function Editor.captureTaggedWorkspace(collectionService, tagName) local instances = {} for _, inst in ipairs(collectionService:GetTagged(tagName)) do if inst and inst.Parent then local kind = inst.ClassName local spec = { path = Editor.instancePath(inst.Parent), kind = kind, name = inst.Name, props = {}, } if inst:IsA("BasePart") then spec.props.size = { inst.Size.X, inst.Size.Y, inst.Size.Z } spec.props.position = { inst.Position.X, inst.Position.Y, inst.Position.Z } spec.props.anchored = inst.Anchored spec.props.canCollide = inst.CanCollide spec.props.material = tostring(inst.Material):gsub("Enum.Material.", "") spec.props.color = { math.floor(inst.Color.R * 255), math.floor(inst.Color.G * 255), math.floor(inst.Color.B * 255) } end if inst:IsA("LuaSourceContainer") then spec.props.source = inst.Source end table.insert(instances, spec) end end return { instances = instances } end function Editor.restoreTaggedWorkspace(snapshot, clearFn, insertFn) if type(clearFn) == "function" then clearFn() end if type(snapshot) == "table" and type(insertFn) == "function" and type(snapshot.instances) == "table" then insertFn({ instances = snapshot.instances, scripts = {} }) end end function Editor.compareVersionSummaries(a, b) if type(a) ~= "table" or type(b) ~= "table" then return { same = false } end return { sameProject = tostring(a.projectJson or "") == tostring(b.projectJson or ""), validationDelta = (tonumber(a.validationScore) or 0) - (tonumber(b.validationScore) or 0), promptA = tostring(a.prompt or ""):sub(1, 80), promptB = tostring(b.prompt or ""):sub(1, 80), } end function Editor.hydrateSession(session, uiState, setLastProjectJsonFn) if type(session) ~= "table" then return false end if type(session.lastProjectJson) == "string" and session.lastProjectJson ~= "" then setLastProjectJsonFn(session.lastProjectJson) end uiState.lastPrompt = session.lastPrompt uiState.lastRoute = session.lastRoute uiState.lastContextSnapshot = session.lastContextSnapshot uiState.lastPlanJson = session.lastPlanJson uiState.lastValidationScore = session.lastValidationScore uiState.lastHealth = session.lastHealth uiState.workflowStage = session.workflowStage or "preview" if type(session.lastDecoded) == "table" then uiState.lastDecoded = session.lastDecoded uiState.canApply = true end if type(session.lastLuau) == "string" and session.lastLuau ~= "" then uiState.lastLuau = session.lastLuau uiState.canApply = true end return true end _G.BloxBuilderEditorUX = Editor end -- BloxBuilder Chat UI Shell -- Bundled ahead of VibeCoderAI.plugin.lua by the download route. -- Wrapped in do-end so chunk locals stay under Luau's 200-register limit. do local TextService = game:GetService("TextService") local ChatUI = {} local F = { body = Enum.Font.SourceSans, bold = Enum.Font.SourceSansBold, semibold = Enum.Font.SourceSansSemibold, code = Enum.Font.Code, } local C = { bg = Color3.fromRGB(24, 24, 28), input = Color3.fromRGB(42, 42, 48), panel = Color3.fromRGB(36, 38, 46), border = Color3.fromRGB(70, 72, 82), text = Color3.fromRGB(240, 240, 245), muted = Color3.fromRGB(160, 163, 175), startBg = Color3.fromRGB(0, 120, 215), sendBg = Color3.fromRGB(58, 60, 70), } local INPUT_H = 96 local TOP_H = 46 local FOOTER_H = 20 local GREETING = "Hi there! What can I help you build?" local MAX_CHATS = 24 -- Phase labels streamed line-by-line into the activity log (no checklist card). local STEP_LABELS = { "Reading & analyzing the Explorer", "Planning changes & edits", "Calling the generation tool", "Script + environment generation", "Auto-updating your game", } local function round(parent, radius) local c = Instance.new("UICorner") c.CornerRadius = UDim.new(0, radius or 12) c.Parent = parent return c end local function stroke(parent, transparency) local s = Instance.new("UIStroke") s.Thickness = 1 s.Transparency = transparency or 0.3 s.Color = C.border s.Parent = parent return s end local function strokeColored(parent, color, transparency) local s = Instance.new("UIStroke") s.Thickness = 1 s.Transparency = transparency or 0.3 s.Color = color s.Parent = parent return s end local function addComposeIcon(parent, color) local root = Instance.new("Frame") root.Name = "ComposeIcon" root.BackgroundTransparency = 1 root.Size = UDim2.fromOffset(16, 16) root.ZIndex = parent.ZIndex + 1 root.Parent = parent local sheet = Instance.new("Frame") sheet.Size = UDim2.fromOffset(11, 13) sheet.Position = UDim2.fromOffset(0, 2) sheet.BackgroundTransparency = 1 sheet.ZIndex = root.ZIndex sheet.Parent = root round(sheet, 2) strokeColored(sheet, color, 0.05) local pen = Instance.new("Frame") pen.Size = UDim2.fromOffset(9, 2) pen.Position = UDim2.fromOffset(6, 0) pen.Rotation = -42 pen.BackgroundColor3 = color pen.BorderSizePixel = 0 pen.ZIndex = root.ZIndex + 1 pen.Parent = root round(pen, 1) return root end local function addChevronDown(parent, color) local root = Instance.new("Frame") root.Name = "Chevron" root.BackgroundTransparency = 1 root.Size = UDim2.fromOffset(10, 6) root.ZIndex = parent.ZIndex + 1 root.Parent = parent local left = Instance.new("Frame") left.Size = UDim2.fromOffset(6, 2) left.Position = UDim2.fromOffset(0, 1) left.Rotation = 35 left.BackgroundColor3 = color left.BorderSizePixel = 0 left.ZIndex = root.ZIndex left.Parent = root round(left, 1) local right = Instance.new("Frame") right.Size = UDim2.fromOffset(6, 2) right.Position = UDim2.fromOffset(4, 1) right.Rotation = -35 right.BackgroundColor3 = color right.BorderSizePixel = 0 right.ZIndex = root.ZIndex right.Parent = root round(right, 1) return root end local function addSearchIcon(parent, color) local root = Instance.new("Frame") root.Name = "SearchIcon" root.BackgroundTransparency = 1 root.Size = UDim2.fromOffset(14, 14) root.ZIndex = parent.ZIndex + 1 root.Parent = parent local ring = Instance.new("Frame") ring.Size = UDim2.fromOffset(10, 10) ring.Position = UDim2.fromOffset(0, 0) ring.BackgroundTransparency = 1 ring.ZIndex = root.ZIndex ring.Parent = root local rc = Instance.new("UICorner") rc.CornerRadius = UDim.new(1, 0) rc.Parent = ring strokeColored(ring, color, 0.05) local handle = Instance.new("Frame") handle.Size = UDim2.fromOffset(5, 2) handle.Position = UDim2.fromOffset(8, 9) handle.Rotation = 45 handle.BackgroundColor3 = color handle.BorderSizePixel = 0 handle.ZIndex = root.ZIndex handle.Parent = root round(handle, 1) return root end function ChatUI.mount(widget, opts) opts = opts or {} local shell = Instance.new("Frame") shell.Name = "ChatShell" shell.BackgroundColor3 = C.bg shell.BorderSizePixel = 0 shell.Size = UDim2.fromScale(1, 1) shell.ZIndex = 20 shell.Parent = widget -- ── Top bar ───────────────────────────────────────────────────────────── local topBar = Instance.new("Frame") topBar.Name = "TopBar" topBar.BackgroundColor3 = Color3.fromRGB(30, 30, 36) topBar.BorderSizePixel = 0 topBar.Size = UDim2.new(1, 0, 0, TOP_H) topBar.ZIndex = 21 topBar.Parent = shell local topLine = Instance.new("Frame") topLine.BackgroundColor3 = C.border topLine.BorderSizePixel = 0 topLine.AnchorPoint = Vector2.new(0, 1) topLine.Position = UDim2.new(0, 0, 1, 0) topLine.Size = UDim2.new(1, 0, 0, 1) topLine.ZIndex = 22 topLine.Parent = topBar -- Assistant-style "New chat" pill (chevron opens a small menu) + Search button local newChatRow = Instance.new("Frame") newChatRow.Name = "NewChat" newChatRow.Size = UDim2.fromOffset(230, 34) newChatRow.Position = UDim2.fromOffset(8, 6) newChatRow.BackgroundTransparency = 1 newChatRow.ZIndex = 23 newChatRow.Parent = topBar -- New chat pill local newChatPill = Instance.new("TextButton") newChatPill.Name = "NewChatPill" newChatPill.AutoButtonColor = false newChatPill.Position = UDim2.fromOffset(0, 2) newChatPill.Size = UDim2.fromOffset(126, 30) newChatPill.BackgroundColor3 = Color3.fromRGB(44, 46, 54) newChatPill.BackgroundTransparency = 0 newChatPill.Text = "" newChatPill.ZIndex = 23 newChatPill.Parent = newChatRow round(newChatPill, 15) stroke(newChatPill, 0.45) local pillLabel = Instance.new("TextLabel") pillLabel.Name = "PillLabel" pillLabel.BackgroundTransparency = 1 pillLabel.Position = UDim2.fromOffset(14, 0) pillLabel.Size = UDim2.fromOffset(84, 30) pillLabel.Font = F.semibold pillLabel.TextSize = 13 pillLabel.TextColor3 = C.text pillLabel.TextXAlignment = Enum.TextXAlignment.Left pillLabel.Text = "New chat" pillLabel.ZIndex = 24 pillLabel.Parent = newChatPill local pillChevron = addChevronDown(newChatPill, C.muted) pillChevron.AnchorPoint = Vector2.new(1, 0.5) pillChevron.Position = UDim2.new(1, -12, 0.5, 0) pillChevron.Active = false -- Search button (kept separate; opens search + history dropdown) local historyToggle = Instance.new("TextButton") historyToggle.Name = "SearchButton" historyToggle.AutoButtonColor = false historyToggle.Position = UDim2.fromOffset(136, 2) historyToggle.Size = UDim2.fromOffset(88, 30) historyToggle.BackgroundTransparency = 1 historyToggle.Text = "" historyToggle.ZIndex = 23 historyToggle.Parent = newChatRow round(historyToggle, 8) local searchBtnIcon = addSearchIcon(historyToggle, C.text) searchBtnIcon.Position = UDim2.fromOffset(8, 8) searchBtnIcon.Active = false local searchBtnLabel = Instance.new("TextLabel") searchBtnLabel.BackgroundTransparency = 1 searchBtnLabel.Position = UDim2.fromOffset(28, 0) searchBtnLabel.Size = UDim2.fromOffset(56, 30) searchBtnLabel.Font = F.semibold searchBtnLabel.TextSize = 13 searchBtnLabel.TextColor3 = C.text searchBtnLabel.TextXAlignment = Enum.TextXAlignment.Left searchBtnLabel.Text = "Search" searchBtnLabel.ZIndex = 24 searchBtnLabel.Parent = historyToggle local function pillHover(on) newChatPill.BackgroundColor3 = on and Color3.fromRGB(56, 58, 68) or Color3.fromRGB(44, 46, 54) end newChatPill.MouseEnter:Connect(function() pillHover(true) end) newChatPill.MouseLeave:Connect(function() pillHover(false) end) historyToggle.MouseEnter:Connect(function() historyToggle.BackgroundTransparency = 0.85 historyToggle.BackgroundColor3 = Color3.fromRGB(48, 50, 58) end) historyToggle.MouseLeave:Connect(function() historyToggle.BackgroundTransparency = 1 end) local phaseLabel = Instance.new("TextLabel") phaseLabel.Name = "Phase" phaseLabel.BackgroundTransparency = 1 phaseLabel.AnchorPoint = Vector2.new(1, 0.5) phaseLabel.Position = UDim2.new(1, -148, 0.5, 0) phaseLabel.Size = UDim2.fromOffset(56, 22) phaseLabel.Font = F.body phaseLabel.TextSize = 11 phaseLabel.TextColor3 = C.muted phaseLabel.TextXAlignment = Enum.TextXAlignment.Right phaseLabel.Text = "Idle" phaseLabel.ZIndex = 23 phaseLabel.Parent = topBar local undoBtn = Instance.new("TextButton") undoBtn.Name = "Undo" undoBtn.AutoButtonColor = false undoBtn.AnchorPoint = Vector2.new(1, 0.5) undoBtn.Position = UDim2.new(1, -90, 0.5, 0) undoBtn.Size = UDim2.fromOffset(52, 28) undoBtn.BackgroundColor3 = Color3.fromRGB(44, 46, 54) undoBtn.Font = F.semibold undoBtn.TextSize = 12 undoBtn.TextColor3 = C.text undoBtn.Text = "Undo" undoBtn.ZIndex = 23 undoBtn.Parent = topBar round(undoBtn, 8) stroke(undoBtn, 0.45) -- ⋯ menu button (replaces Settings text) local menuBtn = Instance.new("TextButton") menuBtn.Name = "Menu" menuBtn.AutoButtonColor = false menuBtn.AnchorPoint = Vector2.new(1, 0.5) menuBtn.Position = UDim2.new(1, -10, 0.5, 0) menuBtn.Size = UDim2.fromOffset(32, 30) menuBtn.BackgroundTransparency = 1 menuBtn.Font = F.bold menuBtn.TextSize = 18 menuBtn.TextColor3 = C.text menuBtn.Text = "..." menuBtn.ZIndex = 23 menuBtn.Parent = topBar round(menuBtn, 8) stroke(menuBtn, 0.5) -- ── Chat area ─────────────────────────────────────────────────────────── local chatArea = Instance.new("Frame") chatArea.Name = "ChatArea" chatArea.BackgroundTransparency = 1 chatArea.Position = UDim2.fromOffset(0, TOP_H) chatArea.Size = UDim2.new(1, 0, 0, 200) chatArea.ZIndex = 21 chatArea.Parent = shell local greeting = Instance.new("TextLabel") greeting.Name = "Greeting" greeting.BackgroundTransparency = 1 greeting.Position = UDim2.fromOffset(16, 8) greeting.Size = UDim2.new(1, -32, 0, 48) greeting.Font = F.body greeting.TextSize = 15 greeting.TextColor3 = C.text greeting.TextXAlignment = Enum.TextXAlignment.Left greeting.TextYAlignment = Enum.TextYAlignment.Top greeting.TextWrapped = true greeting.Text = GREETING greeting.Visible = true greeting.ZIndex = 22 greeting.Parent = chatArea local logScroll = Instance.new("ScrollingFrame") logScroll.Name = "Log" logScroll.BackgroundTransparency = 1 logScroll.BorderSizePixel = 0 logScroll.Position = UDim2.fromOffset(0, 8) logScroll.Size = UDim2.new(1, 0, 1, -8) logScroll.CanvasSize = UDim2.new(0, 0, 0, 0) logScroll.ScrollBarThickness = 5 logScroll.ScrollBarImageTransparency = 0.35 logScroll.ScrollingDirection = Enum.ScrollingDirection.Y logScroll.Visible = false logScroll.ZIndex = 22 logScroll.Parent = chatArea -- Stream state: while a run is active, keep the log visible (no steps card). local streamActive = false local currentStep = 0 -- Frozen prior turns + current "You:" header; live job text replaces under this only. local turnPrefix = "" local lastLoggedStep = 0 local function showGreeting() if streamActive then return end greeting.Text = GREETING greeting.Visible = true logScroll.Visible = false end local logBox = Instance.new("TextBox") logBox.Name = "LogText" logBox.BackgroundTransparency = 1 logBox.Size = UDim2.new(1, -24, 0, 40) logBox.Position = UDim2.fromOffset(12, 4) logBox.Text = "" logBox.TextWrapped = true logBox.TextEditable = false logBox.ClearTextOnFocus = false logBox.MultiLine = true logBox.Font = F.code logBox.TextSize = 13 logBox.TextColor3 = Color3.fromRGB(200, 204, 218) logBox.TextXAlignment = Enum.TextXAlignment.Left logBox.TextYAlignment = Enum.TextYAlignment.Top logBox.ZIndex = 23 logBox.Parent = logScroll local function refreshLogScroll() local w = math.max(60, logScroll.AbsoluteSize.X - 24) local text = logBox.Text if text == "" then text = " " end local sz = TextService:GetTextSize(text, logBox.TextSize, logBox.Font, Vector2.new(w, 100000)) local innerH = math.max(logScroll.AbsoluteSize.Y, math.ceil(sz.Y) + 16) logBox.Size = UDim2.new(1, -24, 0, innerH) logScroll.CanvasSize = UDim2.new(0, 0, 0, innerH) end local function scrollLogToEnd() refreshLogScroll() logScroll.CanvasPosition = Vector2.new(0, math.max(0, logScroll.CanvasSize.Y.Offset - logScroll.AbsoluteSize.Y)) end local function updateGreeting() if streamActive then greeting.Visible = false logScroll.Visible = true return end local hasLog = (logBox.Text or ""):gsub("%s", "") ~= "" if hasLog then greeting.Visible = false logScroll.Visible = true else showGreeting() end end -- Full-height activity log (Cursor-style: one scrolling stream, no card). local function layoutChatBody() logScroll.Position = UDim2.fromOffset(0, 8) logScroll.Size = UDim2.new(1, 0, 1, -8) refreshLogScroll() end -- ── Chat history (ChatGPT-style sessions; searchable dropdown) ─────────── local historyOpen = false local newChatMenuOpen = false local historyPanelHeight = 0 local chatHistory = {} local currentChatId = nil local historyFilter = "" -- Forward declarations so closures defined earlier resolve these upvalues. local relayout local setHistoryOpen local setNewChatMenuOpen local refreshHistoryList local saveCurrentChat local startNewChat local loadChatEntry local composerBox local syncPlaceholder local syncActionAppearance local handles if type(opts.loadChatHistory) == "function" then local loaded = opts.loadChatHistory() if type(loaded) == "table" then chatHistory = loaded end end -- Invisible scrim below the top bar: clicking the chat area closes the dropdown. -- Starts at TOP_H so the New Chat / Search buttons stay clickable while open. local historyScrim = Instance.new("TextButton") historyScrim.Name = "HistoryScrim" historyScrim.AutoButtonColor = false historyScrim.Text = "" historyScrim.BackgroundTransparency = 1 historyScrim.Position = UDim2.fromOffset(0, TOP_H) historyScrim.Size = UDim2.new(1, 0, 1, -TOP_H) historyScrim.Visible = false historyScrim.ZIndex = 39 historyScrim.Parent = shell -- Floating dropdown card (overlays chat; does NOT push layout down) local historyPanel = Instance.new("Frame") historyPanel.Name = "HistoryPanel" historyPanel.BackgroundColor3 = Color3.fromRGB(34, 35, 42) historyPanel.BorderSizePixel = 0 historyPanel.Position = UDim2.fromOffset(8, TOP_H - 2) historyPanel.Size = UDim2.fromOffset(268, 0) historyPanel.Visible = false historyPanel.ClipsDescendants = true historyPanel.ZIndex = 40 historyPanel.Parent = shell round(historyPanel, 10) stroke(historyPanel, 0.15) -- Small "New chat" menu (opened by the pill chevron) — Assistant-style local newChatMenu = Instance.new("Frame") newChatMenu.Name = "NewChatMenu" newChatMenu.BackgroundColor3 = Color3.fromRGB(34, 35, 42) newChatMenu.BorderSizePixel = 0 newChatMenu.Position = UDim2.fromOffset(8, TOP_H - 2) newChatMenu.Size = UDim2.fromOffset(150, 0) newChatMenu.Visible = false newChatMenu.ClipsDescendants = true newChatMenu.ZIndex = 40 newChatMenu.Parent = shell round(newChatMenu, 10) stroke(newChatMenu, 0.15) local newChatBtn = Instance.new("TextButton") newChatBtn.Name = "NewChatItem" newChatBtn.AutoButtonColor = false newChatBtn.Position = UDim2.fromOffset(6, 6) newChatBtn.Size = UDim2.new(1, -12, 0, 30) newChatBtn.BackgroundColor3 = Color3.fromRGB(48, 50, 58) newChatBtn.BackgroundTransparency = 1 newChatBtn.Text = "" newChatBtn.ZIndex = 42 newChatBtn.Parent = newChatMenu round(newChatBtn, 6) local composeIcon = addComposeIcon(newChatBtn, C.text) composeIcon.Position = UDim2.fromOffset(8, 8) composeIcon.Active = false local newChatItemLabel = Instance.new("TextLabel") newChatItemLabel.BackgroundTransparency = 1 newChatItemLabel.Position = UDim2.fromOffset(30, 0) newChatItemLabel.Size = UDim2.new(1, -38, 1, 0) newChatItemLabel.Font = F.semibold newChatItemLabel.TextSize = 13 newChatItemLabel.TextColor3 = C.text newChatItemLabel.TextXAlignment = Enum.TextXAlignment.Left newChatItemLabel.Text = "New chat" newChatItemLabel.ZIndex = 43 newChatItemLabel.Parent = newChatBtn newChatBtn.MouseEnter:Connect(function() newChatBtn.BackgroundTransparency = 0.4 end) newChatBtn.MouseLeave:Connect(function() newChatBtn.BackgroundTransparency = 1 end) -- Search chats box (top of history dropdown) local searchWrap = Instance.new("Frame") searchWrap.Name = "SearchWrap" searchWrap.BackgroundColor3 = Color3.fromRGB(26, 27, 33) searchWrap.BorderSizePixel = 0 searchWrap.Position = UDim2.fromOffset(8, 8) searchWrap.Size = UDim2.new(1, -16, 0, 28) searchWrap.ZIndex = 42 searchWrap.Parent = historyPanel round(searchWrap, 6) stroke(searchWrap, 0.4) local searchIcon = addSearchIcon(searchWrap, C.muted) searchIcon.Position = UDim2.fromOffset(8, 7) searchIcon.Active = false local searchBox = Instance.new("TextBox") searchBox.Name = "SearchChats" searchBox.BackgroundTransparency = 1 searchBox.BorderSizePixel = 0 searchBox.Position = UDim2.fromOffset(26, 0) searchBox.Size = UDim2.new(1, -34, 1, 0) searchBox.ClearTextOnFocus = false searchBox.Text = "" searchBox.PlaceholderText = "Search chats" searchBox.PlaceholderColor3 = C.muted searchBox.Font = F.body searchBox.TextSize = 12 searchBox.TextColor3 = C.text searchBox.TextXAlignment = Enum.TextXAlignment.Left searchBox.ZIndex = 43 searchBox.Parent = searchWrap local historyScroll = Instance.new("ScrollingFrame") historyScroll.BackgroundTransparency = 1 historyScroll.BorderSizePixel = 0 historyScroll.Position = UDim2.fromOffset(0, 42) historyScroll.Size = UDim2.new(1, 0, 1, -46) historyScroll.CanvasSize = UDim2.new(0, 0, 0, 0) historyScroll.ScrollBarThickness = 4 historyScroll.ScrollingDirection = Enum.ScrollingDirection.Y historyScroll.ZIndex = 41 historyScroll.Parent = historyPanel local historyLayout = Instance.new("UIListLayout") historyLayout.Padding = UDim.new(0, 2) historyLayout.SortOrder = Enum.SortOrder.LayoutOrder historyLayout.Parent = historyScroll local historyPad = Instance.new("UIPadding") historyPad.PaddingTop = UDim.new(0, 6) historyPad.PaddingBottom = UDim.new(0, 6) historyPad.PaddingLeft = UDim.new(0, 8) historyPad.PaddingRight = UDim.new(0, 8) historyPad.Parent = historyScroll local function persistHistory() if type(opts.saveChatHistory) ~= "function" then return end -- Dense array only (no holes) + hard size caps — Search must never crash. local clean = {} for _, chat in ipairs(chatHistory) do if type(chat) == "table" then local function scrub(s, maxLen) s = tostring(s or ""):gsub("%z", ""):gsub("[\1-\8\11\12\14-\31]", "") s = s:gsub("[\128-\255]", "?") if #s > maxLen then s = string.sub(s, 1, maxLen) end return s end clean[#clean + 1] = { id = scrub(chat.id or ("chat_" .. (#clean + 1)), 64), title = scrub(chat.title or "", 120), log = scrub(chat.log or "", 20000), prompt = scrub(chat.prompt or "", 2000), createdAt = scrub(chat.createdAt or "", 40), updatedAt = scrub(chat.updatedAt or "", 40), } end end pcall(function() opts.saveChatHistory(clean) end) end local function placeholderRow(text) local empty = Instance.new("TextLabel") empty.BackgroundTransparency = 1 empty.Size = UDim2.new(1, 0, 0, 28) empty.Font = F.body empty.TextSize = 12 empty.TextColor3 = C.muted empty.TextXAlignment = Enum.TextXAlignment.Left empty.Text = " " .. text empty.ZIndex = 42 empty.Parent = historyScroll historyScroll.CanvasSize = UDim2.new(0, 0, 0, 34) end refreshHistoryList = function() for _, child in ipairs(historyScroll:GetChildren()) do if child:IsA("TextButton") or child:IsA("TextLabel") then child:Destroy() end end if #chatHistory == 0 then placeholderRow("No previous chats") return end local filter = (historyFilter or ""):lower() local shown = 0 for i, chat in ipairs(chatHistory) do local title = tostring(chat.title or ("Chat " .. i)) if filter == "" or title:lower():find(filter, 1, true) then shown += 1 local item = Instance.new("TextButton") item.AutoButtonColor = false item.Size = UDim2.new(1, 0, 0, 30) item.BackgroundColor3 = Color3.fromRGB(40, 42, 50) item.BackgroundTransparency = (chat.id == currentChatId) and 0 or 0.35 item.Font = F.body item.TextSize = 12 item.TextColor3 = C.text item.TextXAlignment = Enum.TextXAlignment.Left item.TextTruncate = Enum.TextTruncate.AtEnd item.Text = " " .. title item.LayoutOrder = i item.ZIndex = 42 item.Parent = historyScroll round(item, 6) item.MouseButton1Click:Connect(function() setHistoryOpen(false) if loadChatEntry then loadChatEntry(chat) end end) end end if shown == 0 then placeholderRow("No matches") return end task.defer(function() historyScroll.CanvasSize = UDim2.new(0, 0, 0, historyLayout.AbsoluteContentSize.Y + 12) end) end searchBox:GetPropertyChangedSignal("Text"):Connect(function() historyFilter = searchBox.Text or "" refreshHistoryList() end) -- ── Input card ────────────────────────────────────────────────────────── local inputCard = Instance.new("Frame") inputCard.Name = "InputCard" inputCard.BackgroundColor3 = C.input inputCard.BorderSizePixel = 0 inputCard.AnchorPoint = Vector2.new(0.5, 1) inputCard.Position = UDim2.new(0.5, 0, 1, -(FOOTER_H + 6)) inputCard.Size = UDim2.new(1, -20, 0, INPUT_H) inputCard.ZIndex = 21 inputCard.Parent = shell round(inputCard, 14) stroke(inputCard, 0.2) composerBox = Instance.new("TextBox") composerBox.Name = "AskBloxBuilder" composerBox.Size = UDim2.new(1, -56, 0, 52) composerBox.Position = UDim2.fromOffset(12, 10) composerBox.ClearTextOnFocus = false composerBox.MultiLine = true composerBox.Text = "" composerBox.Font = F.body composerBox.TextSize = 14 composerBox.TextColor3 = C.text composerBox.TextXAlignment = Enum.TextXAlignment.Left composerBox.TextYAlignment = Enum.TextYAlignment.Top composerBox.BackgroundTransparency = 1 composerBox.BorderSizePixel = 0 composerBox.TextWrapped = true composerBox.ZIndex = 23 composerBox.Parent = inputCard local hintOverlay = Instance.new("TextLabel") hintOverlay.Name = "Placeholder" hintOverlay.BackgroundTransparency = 1 hintOverlay.Size = UDim2.new(1, -56, 0, 52) hintOverlay.Position = UDim2.fromOffset(12, 10) hintOverlay.Font = F.body hintOverlay.TextSize = 14 hintOverlay.TextColor3 = C.muted hintOverlay.TextXAlignment = Enum.TextXAlignment.Left hintOverlay.TextYAlignment = Enum.TextYAlignment.Top hintOverlay.Text = "Ask BloxBuilder (Enter sends — wait for Done; use ■ to stop)" hintOverlay.ZIndex = 22 hintOverlay.Active = false hintOverlay.Parent = inputCard syncPlaceholder = function() hintOverlay.Visible = (composerBox.Text or "") == "" end composerBox:GetPropertyChangedSignal("Text"):Connect(syncPlaceholder) local actionBtn = Instance.new("TextButton") actionBtn.Name = "Action" actionBtn.AnchorPoint = Vector2.new(1, 1) actionBtn.Position = UDim2.new(1, -12, 1, -12) actionBtn.Size = UDim2.fromOffset(36, 36) actionBtn.AutoButtonColor = false actionBtn.Font = F.bold actionBtn.TextSize = 18 actionBtn.TextColor3 = Color3.new(1, 1, 1) actionBtn.Text = "^" actionBtn.BackgroundColor3 = C.startBg actionBtn.ZIndex = 24 actionBtn.Parent = inputCard round(actionBtn, 18) local isGenerating = false syncActionAppearance = function() if isGenerating then actionBtn.Text = utf8.char(0x25A0) actionBtn.TextSize = 13 actionBtn.BackgroundColor3 = C.startBg actionBtn.TextTransparency = 0 actionBtn.BackgroundTransparency = 0 return end actionBtn.Text = "^" actionBtn.TextSize = 18 local hasText = (composerBox.Text or ""):gsub("%s", "") ~= "" actionBtn.BackgroundColor3 = hasText and C.startBg or C.sendBg actionBtn.TextTransparency = hasText and 0 or 0.25 actionBtn.BackgroundTransparency = hasText and 0 or 0.15 end composerBox:GetPropertyChangedSignal("Text"):Connect(syncActionAppearance) local disclaimer = Instance.new("TextLabel") disclaimer.BackgroundTransparency = 1 disclaimer.AnchorPoint = Vector2.new(0.5, 1) disclaimer.Position = UDim2.new(0.5, 0, 1, -1) disclaimer.Size = UDim2.new(1, -16, 0, FOOTER_H) disclaimer.Font = F.body disclaimer.TextSize = 10 disclaimer.TextColor3 = Color3.fromRGB(110, 112, 125) disclaimer.Text = "AI-powered — verify results in Studio before publishing." disclaimer.TextWrapped = true disclaimer.ZIndex = 21 disclaimer.Parent = shell -- ── Settings overlay (token) ──────────────────────────────────────────── local settingsOpen = false local settingsOverlay = Instance.new("Frame") settingsOverlay.Name = "SettingsOverlay" settingsOverlay.BackgroundColor3 = Color3.fromRGB(0, 0, 0) settingsOverlay.BackgroundTransparency = 0.45 settingsOverlay.BorderSizePixel = 0 settingsOverlay.Size = UDim2.fromScale(1, 1) settingsOverlay.ZIndex = 60 settingsOverlay.Visible = false settingsOverlay.Parent = shell local settingsCard = Instance.new("Frame") settingsCard.Name = "SettingsCard" settingsCard.BackgroundColor3 = C.panel settingsCard.BorderSizePixel = 0 settingsCard.AnchorPoint = Vector2.new(0.5, 0.5) settingsCard.Position = UDim2.fromScale(0.5, 0.5) settingsCard.Size = UDim2.new(1, -32, 0, 200) settingsCard.ZIndex = 61 settingsCard.Parent = settingsOverlay round(settingsCard, 14) stroke(settingsCard, 0.2) local settingsTitle = Instance.new("TextLabel") settingsTitle.BackgroundTransparency = 1 settingsTitle.Position = UDim2.fromOffset(16, 14) settingsTitle.Size = UDim2.new(1, -32, 0, 22) settingsTitle.Font = F.semibold settingsTitle.TextSize = 15 settingsTitle.TextColor3 = C.text settingsTitle.TextXAlignment = Enum.TextXAlignment.Left settingsTitle.Text = "API Token" settingsTitle.ZIndex = 62 settingsTitle.Parent = settingsCard local settingsHint = Instance.new("TextLabel") settingsHint.BackgroundTransparency = 1 settingsHint.Position = UDim2.fromOffset(16, 36) settingsHint.Size = UDim2.new(1, -32, 0, 28) settingsHint.Font = F.body settingsHint.TextSize = 11 settingsHint.TextColor3 = C.muted settingsHint.TextXAlignment = Enum.TextXAlignment.Left settingsHint.TextWrapped = true settingsHint.Text = "Paste your BloxBuilder token. Saved locally in Studio." settingsHint.ZIndex = 62 settingsHint.Parent = settingsCard local tokenScroll = Instance.new("ScrollingFrame") tokenScroll.BackgroundColor3 = Color3.fromRGB(28, 30, 36) tokenScroll.BorderSizePixel = 0 tokenScroll.Position = UDim2.fromOffset(16, 68) tokenScroll.Size = UDim2.new(1, -32, 0, 72) tokenScroll.CanvasSize = UDim2.new(0, 0, 0, 0) tokenScroll.ScrollBarThickness = 4 tokenScroll.ScrollingDirection = Enum.ScrollingDirection.Y tokenScroll.ZIndex = 62 tokenScroll.Parent = settingsCard round(tokenScroll, 8) stroke(tokenScroll, 0.35) local tokenBox = Instance.new("TextBox") tokenBox.Name = "TokenInput" tokenBox.Size = UDim2.new(1, -12, 1, 0) tokenBox.Position = UDim2.fromOffset(6, 4) tokenBox.ClearTextOnFocus = false tokenBox.MultiLine = true tokenBox.TextWrapped = false tokenBox.TextXAlignment = Enum.TextXAlignment.Left tokenBox.TextYAlignment = Enum.TextYAlignment.Top tokenBox.Font = F.code tokenBox.TextSize = 11 tokenBox.TextColor3 = C.text tokenBox.BackgroundTransparency = 1 tokenBox.BorderSizePixel = 0 tokenBox.PlaceholderText = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." tokenBox.PlaceholderColor3 = C.muted tokenBox.ZIndex = 63 tokenBox.Parent = tokenScroll local function refreshTokenScroll() local w = math.max(60, tokenScroll.AbsoluteSize.X - 12) local text = tokenBox.Text if text == "" then text = tokenBox.PlaceholderText or " " end local sz = TextService:GetTextSize(text, tokenBox.TextSize, tokenBox.Font, Vector2.new(w, 100000)) local innerH = math.max(tokenScroll.AbsoluteSize.Y, math.ceil(sz.Y) + 12) tokenBox.Size = UDim2.new(1, -12, 0, innerH) tokenScroll.CanvasSize = UDim2.new(0, 0, 0, innerH) end tokenScroll:GetPropertyChangedSignal("AbsoluteSize"):Connect(refreshTokenScroll) tokenBox:GetPropertyChangedSignal("Text"):Connect(refreshTokenScroll) local saveBtn = Instance.new("TextButton") saveBtn.Name = "SaveToken" saveBtn.AutoButtonColor = false saveBtn.AnchorPoint = Vector2.new(1, 1) saveBtn.Position = UDim2.new(1, -16, 1, -12) saveBtn.Size = UDim2.fromOffset(72, 32) saveBtn.BackgroundColor3 = C.startBg saveBtn.Font = F.bold saveBtn.TextSize = 13 saveBtn.TextColor3 = Color3.new(1, 1, 1) saveBtn.Text = "Save" saveBtn.ZIndex = 62 saveBtn.Parent = settingsCard round(saveBtn, 10) local closeBtn = Instance.new("TextButton") closeBtn.Name = "CloseSettings" closeBtn.AutoButtonColor = false closeBtn.AnchorPoint = Vector2.new(1, 0) closeBtn.Position = UDim2.new(1, -12, 0, 10) closeBtn.Size = UDim2.fromOffset(28, 28) closeBtn.BackgroundTransparency = 1 closeBtn.Font = F.bold closeBtn.TextSize = 16 closeBtn.TextColor3 = C.muted closeBtn.Text = "×" closeBtn.ZIndex = 63 closeBtn.Parent = settingsCard local maxChars = tonumber(opts.promptMaxChars) or 400 local function relayoutImpl() local totalH = shell.AbsoluteSize.Y if totalH < 120 then return end chatArea.Position = UDim2.fromOffset(0, TOP_H) local bottomBlock = INPUT_H + FOOTER_H + 14 local chatH = math.max(80, totalH - TOP_H - bottomBlock) chatArea.Size = UDim2.new(1, 0, 0, chatH) layoutChatBody() end relayout = relayoutImpl setHistoryOpen = function(open) historyOpen = open == true historyPanel.Visible = historyOpen historyScrim.Visible = historyOpen if historyOpen then if setNewChatMenuOpen then setNewChatMenuOpen(false) end refreshHistoryList() -- search box (44) + up to N rows; floats over chat, doesn't push layout local count = math.max(1, #chatHistory) historyPanelHeight = math.min(260, 44 + count * 34 + 8) historyPanel.Size = UDim2.fromOffset(268, historyPanelHeight) else historyPanelHeight = 0 historyPanel.Size = UDim2.fromOffset(268, 0) historyFilter = "" searchBox.Text = "" end historyScrim.Visible = historyOpen or newChatMenuOpen end setNewChatMenuOpen = function(open) newChatMenuOpen = open == true newChatMenu.Visible = newChatMenuOpen if newChatMenuOpen then if setHistoryOpen then setHistoryOpen(false) end newChatMenu.Size = UDim2.fromOffset(150, 42) else newChatMenu.Size = UDim2.fromOffset(150, 0) end historyScrim.Visible = historyOpen or newChatMenuOpen end local function chatTitleFrom(prompt, log) local t = (prompt or ""):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") if t == "" then t = (log or ""):gsub("[\r\n]+", " "):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") end t = t:sub(1, 48) if t == "" then t = "Chat " .. os.date("%H:%M") end return t end local function lastPromptFromLog(log) local source = tostring(log or "") local latest = "" for line in source:gmatch("[^\r\n]+") do local p = line:match("^You:%s*(.+)$") if p and p ~= "" then latest = p end end return latest end -- Upsert the active conversation into history (create on first content, else update). saveCurrentChat = function() local log = logBox.Text or "" local draftPrompt = composerBox.Text or "" local prompt = draftPrompt if prompt:gsub("%s", "") == "" then prompt = lastPromptFromLog(log) end if log:gsub("%s", "") == "" and prompt:gsub("%s", "") == "" then return end local now = os.date("%Y-%m-%d %H:%M") local title = chatTitleFrom(prompt, log) if currentChatId then for _, c in ipairs(chatHistory) do if c.id == currentChatId then c.title = title c.log = log c.prompt = prompt c.updatedAt = now persistHistory() return end end end currentChatId = tostring(os.time()) .. "_" .. tostring(math.random(1000, 9999)) table.insert(chatHistory, 1, { id = currentChatId, title = title, log = log, prompt = prompt, createdAt = now, updatedAt = now, }) while #chatHistory > MAX_CHATS do table.remove(chatHistory) end persistHistory() end startNewChat = function() saveCurrentChat() currentChatId = nil handles.resetChat() end loadChatEntry = function(entry) if not entry then return end saveCurrentChat() currentChatId = entry.id local log = tostring(entry.log or "") logBox.Text = log turnPrefix = log streamActive = false composerBox.Text = tostring(entry.prompt or "") updateGreeting() syncPlaceholder() syncActionAppearance() refreshLogScroll() end shell:GetPropertyChangedSignal("AbsoluteSize"):Connect(relayout) logScroll:GetPropertyChangedSignal("AbsoluteSize"):Connect(refreshLogScroll) logBox:GetPropertyChangedSignal("Text"):Connect(function() refreshLogScroll() updateGreeting() end) handles = { shell = shell, logBox = logBox, composerBox = composerBox, actionBtn = actionBtn, startBtn = actionBtn, stopBtn = actionBtn, phaseLabel = phaseLabel, greeting = greeting, tokenBox = tokenBox, } function handles.setSettingsOpen(open) settingsOpen = open == true settingsOverlay.Visible = settingsOpen if settingsOpen then local tok = "" if opts.getTokenText then tok = tostring(opts.getTokenText() or "") end tokenBox.Text = tok task.defer(refreshTokenScroll) end end function handles.resetChat() logBox.Text = "" composerBox.Text = "" logScroll.CanvasPosition = Vector2.new(0, 0) isGenerating = false streamActive = false currentStep = 0 turnPrefix = "" layoutChatBody() handles.setPhase("Idle") showGreeting() syncPlaceholder() syncActionAppearance() refreshLogScroll() end -- ── Cursor/ChatGPT stream: prior turns stay; only the live job replaces underneath ── local function freezeTranscript() turnPrefix = (logBox.Text or ""):gsub("%s+$", "") end function handles.beginSteps(promptText) streamActive = true currentStep = 1 lastLoggedStep = 0 greeting.Visible = false logScroll.Visible = true -- Keep everything already on screen (previous You:/responses) as prior history. local prior = (logBox.Text or ""):gsub("%s+$", "") local pt = tostring(promptText or ""):gsub("[\r\n]+", " "):gsub("%s+", " ") pt = pt:gsub("^%s+", ""):gsub("%s+$", "") local header = (pt ~= "") and ("You: " .. pt) or "You:" if prior ~= "" then -- Avoid double-appending the same header if Start was hit twice. if prior:sub(-#header) == header or prior:find("\n" .. header .. "\n", 1, true) then turnPrefix = prior if not prior:match("\n$") then turnPrefix = prior .. "\n" end else turnPrefix = prior .. "\n\n────────────────\n\n" .. header .. "\n" end else turnPrefix = header .. "\n" end logBox.Text = turnPrefix handles.setPhase("Working") layoutChatBody() scrollLogToEnd() end function handles.setStep(n) if not streamActive then streamActive = true greeting.Visible = false logScroll.Visible = true layoutChatBody() end local idx = math.clamp(tonumber(n) or 1, 1, #STEP_LABELS) if idx < currentStep then return end currentStep = idx local label = STEP_LABELS[idx] if label then handles.setPhase(label) end end function handles.completeSteps() if not streamActive and currentStep == 0 then return end currentStep = #STEP_LABELS handles.appendLogText("Done") handles.setPhase("Idle") streamActive = false lastLoggedStep = 0 freezeTranscript() updateGreeting() end function handles.failStep() if not streamActive and currentStep == 0 then return end local label = STEP_LABELS[currentStep] or "Run" handles.appendLogText("Failed - " .. label) handles.setPhase("Idle") streamActive = false freezeTranscript() updateGreeting() end function handles.hideSteps() streamActive = false currentStep = 0 lastLoggedStep = 0 freezeTranscript() layoutChatBody() updateGreeting() end function handles.setLogText(text) local t = tostring(text or "") if t:gsub("%s", "") == "" then if streamActive and turnPrefix ~= "" then -- Keep prior chat + You: header; clear only the live job segment. logBox.Text = turnPrefix greeting.Visible = false logScroll.Visible = true scrollLogToEnd() return end logBox.Text = "" turnPrefix = "" if not streamActive then showGreeting() end return end if streamActive then -- ChatGPT-style: frozen prior turns + You: stay; replace only the live job output. local live = t -- Drop accidental duplication of the current You: header inside job text. local youHeader = turnPrefix:match("(You: [^\r\n]+)\n?$") if youHeader and live:sub(1, #youHeader) == youHeader then live = live:sub(#youHeader + 1):gsub("^[\r\n]+", "") end logBox.Text = turnPrefix .. live else -- Idle status/errors must not wipe prior turns (append like ChatGPT). local cur = (logBox.Text or ""):gsub("%s+$", "") if cur == "" then logBox.Text = t else logBox.Text = cur .. "\n\n" .. t end turnPrefix = logBox.Text end greeting.Visible = false logScroll.Visible = true scrollLogToEnd() end function handles.appendLogText(text) local cur = logBox.Text or "" if cur ~= "" then cur = cur .. "\n" end logBox.Text = cur .. tostring(text or "") if not streamActive then turnPrefix = logBox.Text end updateGreeting() scrollLogToEnd() end function handles.setPhase(phase) phaseLabel.Text = tostring(phase or "Idle") end function handles.setGenerating(gen) local was = isGenerating isGenerating = gen == true syncActionAppearance() if isGenerating then handles.setPhase("Working") else handles.setPhase("Idle") -- Persist the conversation when a run finishes (ChatGPT-style history). if was and saveCurrentChat then saveCurrentChat() end end end function handles.getPromptText() return composerBox.Text or "" end function handles.setPromptText(text) composerBox.Text = tostring(text or "") syncPlaceholder() end function handles.syncFromLegacyPrompt(legacyBox) if legacyBox and legacyBox:IsA("TextBox") then composerBox.Text = legacyBox.Text or "" syncPlaceholder() end end local function saveTokenAndClose() local tok = (tokenBox.Text or ""):gsub("^%s+", ""):gsub("%s+$", "") if opts.saveToken then opts.saveToken(tok) end handles.setSettingsOpen(false) if opts.onTokenSaved then opts.onTokenSaved(tok) end end local function submitComposer() if isGenerating then -- Enter must not cancel a running job (only the Stop button does). return end local text = (composerBox.Text or ""):gsub("^%s+", ""):gsub("%s+$", "") if text == "" then return end if opts.onSend then opts.onSend() elseif opts.onStart then opts.onStart() end end actionBtn.MouseButton1Click:Connect(function() if isGenerating then if opts.onStop then opts.onStop() end return end submitComposer() end) -- Enter-to-send: MultiLine TextBox inserts "\n" on Enter (works in plugin dock on PC/Mac). local suppressNewlineSubmit = false local lastComposerText = "" composerBox:GetPropertyChangedSignal("Text"):Connect(function() if suppressNewlineSubmit then return end local t = composerBox.Text or "" if #t > maxChars then suppressNewlineSubmit = true t = string.sub(t, 1, maxChars) composerBox.Text = t suppressNewlineSubmit = false end local prev = lastComposerText lastComposerText = t if isGenerating then return end if not composerBox:IsFocused() then return end -- Single Enter at end of line (not paste with many newlines). if #t == #prev + 1 and t:sub(-1) == "\n" then suppressNewlineSubmit = true composerBox.Text = prev lastComposerText = prev suppressNewlineSubmit = false submitComposer() end end) composerBox.FocusLost:Connect(function(_enterPressed) if saveCurrentChat then saveCurrentChat() end end) -- Pill chevron opens the small New chat menu newChatPill.MouseButton1Click:Connect(function() handles.setSettingsOpen(false) setNewChatMenuOpen(not newChatMenuOpen) end) -- The "New chat" item inside that menu actually starts a new chat newChatBtn.MouseButton1Click:Connect(function() handles.setSettingsOpen(false) setNewChatMenuOpen(false) if setHistoryOpen then setHistoryOpen(false) end startNewChat() if opts.onNewChat then opts.onNewChat() end end) -- Search button opens the search + history dropdown historyToggle.MouseButton1Click:Connect(function() handles.setSettingsOpen(false) setHistoryOpen(not historyOpen) end) historyScrim.MouseButton1Click:Connect(function() setHistoryOpen(false) setNewChatMenuOpen(false) end) undoBtn.MouseButton1Click:Connect(function() if isGenerating then return end if opts.onUndo then opts.onUndo() end end) menuBtn.MouseButton1Click:Connect(function() handles.setSettingsOpen(not settingsOpen) end) closeBtn.MouseButton1Click:Connect(function() handles.setSettingsOpen(false) end) settingsOverlay.InputBegan:Connect(function(input) if input.UserInputType == Enum.UserInputType.MouseButton1 then local pos = input.Position local cardPos = settingsCard.AbsolutePosition local cardSize = settingsCard.AbsoluteSize local inside = pos.X >= cardPos.X and pos.X <= cardPos.X + cardSize.X and pos.Y >= cardPos.Y and pos.Y <= cardPos.Y + cardSize.Y if not inside then handles.setSettingsOpen(false) end end end) saveBtn.MouseButton1Click:Connect(saveTokenAndClose) tokenBox.FocusLost:Connect(function(enterPressed) if enterPressed then saveTokenAndClose() end end) -- Load token into settings field on first open if opts.getTokenText then tokenBox.Text = tostring(opts.getTokenText() or "") task.defer(refreshTokenScroll) end handles.setGenerating(false) handles.resetChat() task.defer(relayout) return handles end _G.BloxBuilderChatUI = ChatUI end -- BloxBuilder Editor UX Core -- Bundled ahead of VibeCoderAI.plugin.lua by the download route. -- Wrapped in do-end so chunk locals stay under Luau's 200-register limit. do local Editor = {} Editor.SETTING_HISTORY = "BloxBuilder_CommandHistory_v1" Editor.SETTING_VERSIONS = "BloxBuilder_BuildVersions_v1" Editor.SETTING_SESSION = "BloxBuilder_EditorSession_v1" Editor.MAX_HISTORY = 24 Editor.MAX_VERSIONS = 30 Editor.STAGE_LABELS = { intent = "Intent Detection", theme = "Theme Extraction", high_level_design = "High-Level Design", world = "World Planning", terrain = "Terrain Planning", genre = "Genre Design", agent = "Genre Agent", gameplay = "Gameplay Loop", gdd = "Game Design Doc", systems = "Systems Planning", retention = "Retention Planning", assets = "Asset Selection", buildings = "Building Placement", npcs = "NPC Generation", quests = "Quest Generation", knowledge = "Knowledge Graph", spawn = "Spawn System", economy = "Economy Planning", progression = "Progression Planning", balance = "Balance Engine", constraints = "Constraint Solver", performance = "Performance Optimizer", validation = "Validation", repair = "Repair Pass", artifacts = "Roblox Generation", } Editor.IMPROVE_SCOPES = { { id = "visuals", label = "Improve Visuals" }, { id = "terrain", label = "Improve Terrain" }, { id = "gameplay", label = "Improve Gameplay" }, { id = "npcs", label = "Improve NPCs" }, { id = "quests", label = "Improve Quests" }, { id = "economy", label = "Improve Economy" }, { id = "progression", label = "Improve Progression" }, { id = "performance", label = "Improve Performance" }, } function Editor.newCommandHistory(pluginRef, HttpService) local self = { plugin = pluginRef, http = HttpService, undoStack = {}, redoStack = {}, } function self:load() local ok, raw = pcall(function() return self.plugin:GetSetting(Editor.SETTING_HISTORY) end) if ok and typeof(raw) == "string" and raw ~= "" then local ok2, decoded = pcall(function() return self.http:JSONDecode(raw) end) if ok2 and typeof(decoded) == "table" then self.undoStack = decoded.undo or {} self.redoStack = decoded.redo or {} end end end function self:save() pcall(function() self.plugin:SetSetting(Editor.SETTING_HISTORY, self.http:JSONEncode({ undo = self.undoStack, redo = self.redoStack, })) end) end function self:push(entry) table.insert(self.undoStack, entry) while #self.undoStack > Editor.MAX_HISTORY do table.remove(self.undoStack, 1) end self.redoStack = {} self:save() end function self:undo() if #self.undoStack == 0 then return nil end local entry = table.remove(self.undoStack) table.insert(self.redoStack, entry) self:save() return entry end function self:redo() if #self.redoStack == 0 then return nil end local entry = table.remove(self.redoStack) table.insert(self.undoStack, entry) self:save() return entry end self:load() return self end function Editor.newVersionStore(pluginRef, HttpService) local self = { plugin = pluginRef, http = HttpService, versions = {} } function self:load() local ok, raw = pcall(function() return self.plugin:GetSetting(Editor.SETTING_VERSIONS) end) if ok and typeof(raw) == "string" and raw ~= "" then local ok2, decoded = pcall(function() return self.http:JSONDecode(raw) end) if ok2 and typeof(decoded) == "table" then self.versions = decoded end end end function self:save() pcall(function() self.plugin:SetSetting(Editor.SETTING_VERSIONS, self.http:JSONEncode(self.versions)) end) end function self:add(entry) table.insert(self.versions, 1, entry) while #self.versions > Editor.MAX_VERSIONS do table.remove(self.versions) end self:save() return entry.id end function self:get(id) for _, v in ipairs(self.versions) do if v.id == id then return v end end return nil end function self:remove(id) for i, v in ipairs(self.versions) do if v.id == id then table.remove(self.versions, i) self:save() return true end end return false end self:load() return self end function Editor.formatHealthPanel(health) if type(health) ~= "table" then return "Build Health\n—\nGenerate to see scores." end local lines = { "═══ Build Health ═══", ("World Quality: %d/100"):format(tonumber(health.worldQuality) or 0), ("Gameplay Quality: %d/100"):format(tonumber(health.gameplayQuality) or 0), ("Theme Consistency: %d/100"):format(tonumber(health.themeConsistency) or 0), ("Performance: %d/100"):format(tonumber(health.performanceScore) or 0), ("Validation: %d/100"):format(tonumber(health.validationScore) or 0), "", "── Summaries ──", tostring(health.summaries and health.summaries.world or "—"), tostring(health.summaries and health.summaries.systems or "—"), tostring(health.summaries and health.summaries.quests or "—"), tostring(health.summaries and health.summaries.npcs or "—"), } if health.errors and #health.errors > 0 then table.insert(lines, "") table.insert(lines, "── Errors ──") for _, e in ipairs(health.errors) do table.insert(lines, "• " .. tostring(e)) end end if health.warnings and #health.warnings > 0 then table.insert(lines, "") table.insert(lines, "── Warnings ──") for _, w in ipairs(health.warnings) do table.insert(lines, "• " .. tostring(w)) end end if health.repairSuggestions and #health.repairSuggestions > 0 then table.insert(lines, "") table.insert(lines, "── Repair Suggestions ──") for _, s in ipairs(health.repairSuggestions) do table.insert(lines, "→ " .. tostring(s)) end end return table.concat(lines, "\n") end function Editor.formatPreviewPanel(uiState, health) local lines = { "═══ Preview Mode ═══", "Flow: Generate → Validate → Preview → Apply", "", ("Prompt: %s"):format(tostring(uiState.lastPrompt or "—"):sub(1, 120)), ("Route: %s"):format(tostring(uiState.lastRoute or "—")), ("Ready to apply: %s"):format(uiState.canApply and "YES" or "no"), ("3D preview placed: %s"):format(uiState.previewApplied and "yes" or "no"), } if uiState.lastRoute == "gameplay" then table.insert(lines, "") table.insert(lines, "Gameplay: world geometry shows in Edit mode; scripts run on Play ▶.") table.insert(lines, "Check Explorer for scripts under ServerScriptService / ReplicatedStorage.") end table.insert(lines, "") table.insert(lines, Editor.formatHealthPanel(health)) return table.concat(lines, "\n") end function Editor.formatProgress(stage, durationMs, success) local label = Editor.STAGE_LABELS[stage] or stage or "Working" local ms = tonumber(durationMs) or 0 local mark = "✓" if success == false then mark = "✗" end if ms > 0 then return ("%s %s (%dms)"):format(mark, label, ms) end return ("… %s"):format(label) end function Editor.formatVersionLine(v) return ("[%s] %s — %s (val %s)"):format( tostring(v.createdAt or "?"):sub(1, 16), tostring(v.summary or "Build"):sub(1, 40), tostring(v.prompt or ""):sub(1, 50), tostring(v.validationScore or "—") ) end function Editor.newSessionRecovery(pluginRef, HttpService) local self = { plugin = pluginRef, http = HttpService } function self:save(session) pcall(function() self.plugin:SetSetting(Editor.SETTING_SESSION, self.http:JSONEncode(session)) end) end function self:load() local ok, raw = pcall(function() return self.plugin:GetSetting(Editor.SETTING_SESSION) end) if ok and typeof(raw) == "string" and raw ~= "" then local ok2, decoded = pcall(function() return self.http:JSONDecode(raw) end) if ok2 then return decoded end end return nil end function self:clear() pcall(function() self.plugin:SetSetting(Editor.SETTING_SESSION, "") end) end return self end function Editor.makeVersionId() return ("v_%d_%d"):format(os.time(), math.random(1000, 9999)) end function Editor.instancePath(inst) local parts = {} local p = inst while p and p ~= game do table.insert(parts, 1, p.Name) p = p.Parent end return table.concat(parts, "/") end function Editor.captureTaggedWorkspace(collectionService, tagName) local instances = {} for _, inst in ipairs(collectionService:GetTagged(tagName)) do if inst and inst.Parent then local kind = inst.ClassName local spec = { path = Editor.instancePath(inst.Parent), kind = kind, name = inst.Name, props = {}, } if inst:IsA("BasePart") then spec.props.size = { inst.Size.X, inst.Size.Y, inst.Size.Z } spec.props.position = { inst.Position.X, inst.Position.Y, inst.Position.Z } spec.props.anchored = inst.Anchored spec.props.canCollide = inst.CanCollide spec.props.material = tostring(inst.Material):gsub("Enum.Material.", "") spec.props.color = { math.floor(inst.Color.R * 255), math.floor(inst.Color.G * 255), math.floor(inst.Color.B * 255) } end if inst:IsA("LuaSourceContainer") then spec.props.source = inst.Source end table.insert(instances, spec) end end return { instances = instances } end function Editor.restoreTaggedWorkspace(snapshot, clearFn, insertFn) if type(clearFn) == "function" then clearFn() end if type(snapshot) == "table" and type(insertFn) == "function" and type(snapshot.instances) == "table" then insertFn({ instances = snapshot.instances, scripts = {} }) end end function Editor.compareVersionSummaries(a, b) if type(a) ~= "table" or type(b) ~= "table" then return { same = false } end return { sameProject = tostring(a.projectJson or "") == tostring(b.projectJson or ""), validationDelta = (tonumber(a.validationScore) or 0) - (tonumber(b.validationScore) or 0), promptA = tostring(a.prompt or ""):sub(1, 80), promptB = tostring(b.prompt or ""):sub(1, 80), } end function Editor.hydrateSession(session, uiState, setLastProjectJsonFn) if type(session) ~= "table" then return false end if type(session.lastProjectJson) == "string" and session.lastProjectJson ~= "" then setLastProjectJsonFn(session.lastProjectJson) end uiState.lastPrompt = session.lastPrompt uiState.lastRoute = session.lastRoute uiState.lastContextSnapshot = session.lastContextSnapshot uiState.lastPlanJson = session.lastPlanJson uiState.lastValidationScore = session.lastValidationScore uiState.lastHealth = session.lastHealth uiState.workflowStage = session.workflowStage or "preview" if type(session.lastDecoded) == "table" then uiState.lastDecoded = session.lastDecoded uiState.canApply = true end if type(session.lastLuau) == "string" and session.lastLuau ~= "" then uiState.lastLuau = session.lastLuau uiState.canApply = true end return true end _G.BloxBuilderEditorUX = Editor end -- BloxBuilder Chat UI Shell -- Bundled ahead of VibeCoderAI.plugin.lua by the download route. -- Wrapped in do-end so chunk locals stay under Luau's 200-register limit. do local TextService = game:GetService("TextService") local ChatUI = {} local F = { body = Enum.Font.SourceSans, bold = Enum.Font.SourceSansBold, semibold = Enum.Font.SourceSansSemibold, code = Enum.Font.Code, } local C = { bg = Color3.fromRGB(24, 24, 28), input = Color3.fromRGB(42, 42, 48), panel = Color3.fromRGB(36, 38, 46), border = Color3.fromRGB(70, 72, 82), text = Color3.fromRGB(240, 240, 245), muted = Color3.fromRGB(160, 163, 175), startBg = Color3.fromRGB(0, 120, 215), sendBg = Color3.fromRGB(58, 60, 70), } local INPUT_H = 96 local TOP_H = 46 local FOOTER_H = 20 local GREETING = "Hi there! What can I help you build?" local MAX_CHATS = 24 -- Phase labels streamed line-by-line into the activity log (no checklist card). local STEP_LABELS = { "Reading & analyzing the Explorer", "Planning changes & edits", "Calling the generation tool", "Script + environment generation", "Auto-updating your game", } local function round(parent, radius) local c = Instance.new("UICorner") c.CornerRadius = UDim.new(0, radius or 12) c.Parent = parent return c end local function stroke(parent, transparency) local s = Instance.new("UIStroke") s.Thickness = 1 s.Transparency = transparency or 0.3 s.Color = C.border s.Parent = parent return s end local function strokeColored(parent, color, transparency) local s = Instance.new("UIStroke") s.Thickness = 1 s.Transparency = transparency or 0.3 s.Color = color s.Parent = parent return s end local function addComposeIcon(parent, color) local root = Instance.new("Frame") root.Name = "ComposeIcon" root.BackgroundTransparency = 1 root.Size = UDim2.fromOffset(16, 16) root.ZIndex = parent.ZIndex + 1 root.Parent = parent local sheet = Instance.new("Frame") sheet.Size = UDim2.fromOffset(11, 13) sheet.Position = UDim2.fromOffset(0, 2) sheet.BackgroundTransparency = 1 sheet.ZIndex = root.ZIndex sheet.Parent = root round(sheet, 2) strokeColored(sheet, color, 0.05) local pen = Instance.new("Frame") pen.Size = UDim2.fromOffset(9, 2) pen.Position = UDim2.fromOffset(6, 0) pen.Rotation = -42 pen.BackgroundColor3 = color pen.BorderSizePixel = 0 pen.ZIndex = root.ZIndex + 1 pen.Parent = root round(pen, 1) return root end local function addChevronDown(parent, color) local root = Instance.new("Frame") root.Name = "Chevron" root.BackgroundTransparency = 1 root.Size = UDim2.fromOffset(10, 6) root.ZIndex = parent.ZIndex + 1 root.Parent = parent local left = Instance.new("Frame") left.Size = UDim2.fromOffset(6, 2) left.Position = UDim2.fromOffset(0, 1) left.Rotation = 35 left.BackgroundColor3 = color left.BorderSizePixel = 0 left.ZIndex = root.ZIndex left.Parent = root round(left, 1) local right = Instance.new("Frame") right.Size = UDim2.fromOffset(6, 2) right.Position = UDim2.fromOffset(4, 1) right.Rotation = -35 right.BackgroundColor3 = color right.BorderSizePixel = 0 right.ZIndex = root.ZIndex right.Parent = root round(right, 1) return root end local function addSearchIcon(parent, color) local root = Instance.new("Frame") root.Name = "SearchIcon" root.BackgroundTransparency = 1 root.Size = UDim2.fromOffset(14, 14) root.ZIndex = parent.ZIndex + 1 root.Parent = parent local ring = Instance.new("Frame") ring.Size = UDim2.fromOffset(10, 10) ring.Position = UDim2.fromOffset(0, 0) ring.BackgroundTransparency = 1 ring.ZIndex = root.ZIndex ring.Parent = root local rc = Instance.new("UICorner") rc.CornerRadius = UDim.new(1, 0) rc.Parent = ring strokeColored(ring, color, 0.05) local handle = Instance.new("Frame") handle.Size = UDim2.fromOffset(5, 2) handle.Position = UDim2.fromOffset(8, 9) handle.Rotation = 45 handle.BackgroundColor3 = color handle.BorderSizePixel = 0 handle.ZIndex = root.ZIndex handle.Parent = root round(handle, 1) return root end function ChatUI.mount(widget, opts) opts = opts or {} local shell = Instance.new("Frame") shell.Name = "ChatShell" shell.BackgroundColor3 = C.bg shell.BorderSizePixel = 0 shell.Size = UDim2.fromScale(1, 1) shell.ZIndex = 20 shell.Parent = widget -- ── Top bar ───────────────────────────────────────────────────────────── local topBar = Instance.new("Frame") topBar.Name = "TopBar" topBar.BackgroundColor3 = Color3.fromRGB(30, 30, 36) topBar.BorderSizePixel = 0 topBar.Size = UDim2.new(1, 0, 0, TOP_H) topBar.ZIndex = 21 topBar.Parent = shell local topLine = Instance.new("Frame") topLine.BackgroundColor3 = C.border topLine.BorderSizePixel = 0 topLine.AnchorPoint = Vector2.new(0, 1) topLine.Position = UDim2.new(0, 0, 1, 0) topLine.Size = UDim2.new(1, 0, 0, 1) topLine.ZIndex = 22 topLine.Parent = topBar -- Assistant-style "New chat" pill (chevron opens a small menu) + Search button local newChatRow = Instance.new("Frame") newChatRow.Name = "NewChat" newChatRow.Size = UDim2.fromOffset(230, 34) newChatRow.Position = UDim2.fromOffset(8, 6) newChatRow.BackgroundTransparency = 1 newChatRow.ZIndex = 23 newChatRow.Parent = topBar -- New chat pill local newChatPill = Instance.new("TextButton") newChatPill.Name = "NewChatPill" newChatPill.AutoButtonColor = false newChatPill.Position = UDim2.fromOffset(0, 2) newChatPill.Size = UDim2.fromOffset(126, 30) newChatPill.BackgroundColor3 = Color3.fromRGB(44, 46, 54) newChatPill.BackgroundTransparency = 0 newChatPill.Text = "" newChatPill.ZIndex = 23 newChatPill.Parent = newChatRow round(newChatPill, 15) stroke(newChatPill, 0.45) local pillLabel = Instance.new("TextLabel") pillLabel.Name = "PillLabel" pillLabel.BackgroundTransparency = 1 pillLabel.Position = UDim2.fromOffset(14, 0) pillLabel.Size = UDim2.fromOffset(84, 30) pillLabel.Font = F.semibold pillLabel.TextSize = 13 pillLabel.TextColor3 = C.text pillLabel.TextXAlignment = Enum.TextXAlignment.Left pillLabel.Text = "New chat" pillLabel.ZIndex = 24 pillLabel.Parent = newChatPill local pillChevron = addChevronDown(newChatPill, C.muted) pillChevron.AnchorPoint = Vector2.new(1, 0.5) pillChevron.Position = UDim2.new(1, -12, 0.5, 0) pillChevron.Active = false -- Search button (kept separate; opens search + history dropdown) local historyToggle = Instance.new("TextButton") historyToggle.Name = "SearchButton" historyToggle.AutoButtonColor = false historyToggle.Position = UDim2.fromOffset(136, 2) historyToggle.Size = UDim2.fromOffset(88, 30) historyToggle.BackgroundTransparency = 1 historyToggle.Text = "" historyToggle.ZIndex = 23 historyToggle.Parent = newChatRow round(historyToggle, 8) local searchBtnIcon = addSearchIcon(historyToggle, C.text) searchBtnIcon.Position = UDim2.fromOffset(8, 8) searchBtnIcon.Active = false local searchBtnLabel = Instance.new("TextLabel") searchBtnLabel.BackgroundTransparency = 1 searchBtnLabel.Position = UDim2.fromOffset(28, 0) searchBtnLabel.Size = UDim2.fromOffset(56, 30) searchBtnLabel.Font = F.semibold searchBtnLabel.TextSize = 13 searchBtnLabel.TextColor3 = C.text searchBtnLabel.TextXAlignment = Enum.TextXAlignment.Left searchBtnLabel.Text = "Search" searchBtnLabel.ZIndex = 24 searchBtnLabel.Parent = historyToggle local function pillHover(on) newChatPill.BackgroundColor3 = on and Color3.fromRGB(56, 58, 68) or Color3.fromRGB(44, 46, 54) end newChatPill.MouseEnter:Connect(function() pillHover(true) end) newChatPill.MouseLeave:Connect(function() pillHover(false) end) historyToggle.MouseEnter:Connect(function() historyToggle.BackgroundTransparency = 0.85 historyToggle.BackgroundColor3 = Color3.fromRGB(48, 50, 58) end) historyToggle.MouseLeave:Connect(function() historyToggle.BackgroundTransparency = 1 end) local phaseLabel = Instance.new("TextLabel") phaseLabel.Name = "Phase" phaseLabel.BackgroundTransparency = 1 phaseLabel.AnchorPoint = Vector2.new(1, 0.5) phaseLabel.Position = UDim2.new(1, -148, 0.5, 0) phaseLabel.Size = UDim2.fromOffset(56, 22) phaseLabel.Font = F.body phaseLabel.TextSize = 11 phaseLabel.TextColor3 = C.muted phaseLabel.TextXAlignment = Enum.TextXAlignment.Right phaseLabel.Text = "Idle" phaseLabel.ZIndex = 23 phaseLabel.Parent = topBar local undoBtn = Instance.new("TextButton") undoBtn.Name = "Undo" undoBtn.AutoButtonColor = false undoBtn.AnchorPoint = Vector2.new(1, 0.5) undoBtn.Position = UDim2.new(1, -90, 0.5, 0) undoBtn.Size = UDim2.fromOffset(52, 28) undoBtn.BackgroundColor3 = Color3.fromRGB(44, 46, 54) undoBtn.Font = F.semibold undoBtn.TextSize = 12 undoBtn.TextColor3 = C.text undoBtn.Text = "Undo" undoBtn.ZIndex = 23 undoBtn.Parent = topBar round(undoBtn, 8) stroke(undoBtn, 0.45) -- ⋯ menu button (replaces Settings text) local menuBtn = Instance.new("TextButton") menuBtn.Name = "Menu" menuBtn.AutoButtonColor = false menuBtn.AnchorPoint = Vector2.new(1, 0.5) menuBtn.Position = UDim2.new(1, -10, 0.5, 0) menuBtn.Size = UDim2.fromOffset(32, 30) menuBtn.BackgroundTransparency = 1 menuBtn.Font = F.bold menuBtn.TextSize = 18 menuBtn.TextColor3 = C.text menuBtn.Text = "..." menuBtn.ZIndex = 23 menuBtn.Parent = topBar round(menuBtn, 8) stroke(menuBtn, 0.5) -- ── Chat area ─────────────────────────────────────────────────────────── local chatArea = Instance.new("Frame") chatArea.Name = "ChatArea" chatArea.BackgroundTransparency = 1 chatArea.Position = UDim2.fromOffset(0, TOP_H) chatArea.Size = UDim2.new(1, 0, 0, 200) chatArea.ZIndex = 21 chatArea.Parent = shell local greeting = Instance.new("TextLabel") greeting.Name = "Greeting" greeting.BackgroundTransparency = 1 greeting.Position = UDim2.fromOffset(16, 8) greeting.Size = UDim2.new(1, -32, 0, 48) greeting.Font = F.body greeting.TextSize = 15 greeting.TextColor3 = C.text greeting.TextXAlignment = Enum.TextXAlignment.Left greeting.TextYAlignment = Enum.TextYAlignment.Top greeting.TextWrapped = true greeting.Text = GREETING greeting.Visible = true greeting.ZIndex = 22 greeting.Parent = chatArea local logScroll = Instance.new("ScrollingFrame") logScroll.Name = "Log" logScroll.BackgroundTransparency = 1 logScroll.BorderSizePixel = 0 logScroll.Position = UDim2.fromOffset(0, 8) logScroll.Size = UDim2.new(1, 0, 1, -8) logScroll.CanvasSize = UDim2.new(0, 0, 0, 0) logScroll.ScrollBarThickness = 5 logScroll.ScrollBarImageTransparency = 0.35 logScroll.ScrollingDirection = Enum.ScrollingDirection.Y logScroll.Visible = false logScroll.ZIndex = 22 logScroll.Parent = chatArea -- Stream state: while a run is active, keep the log visible (no steps card). local streamActive = false local currentStep = 0 -- Frozen prior turns + current "You:" header; live job text replaces under this only. local turnPrefix = "" local lastLoggedStep = 0 local function showGreeting() if streamActive then return end greeting.Text = GREETING greeting.Visible = true logScroll.Visible = false end local logBox = Instance.new("TextBox") logBox.Name = "LogText" logBox.BackgroundTransparency = 1 logBox.Size = UDim2.new(1, -24, 0, 40) logBox.Position = UDim2.fromOffset(12, 4) logBox.Text = "" logBox.TextWrapped = true logBox.TextEditable = false logBox.ClearTextOnFocus = false logBox.MultiLine = true logBox.Font = F.code logBox.TextSize = 13 logBox.TextColor3 = Color3.fromRGB(200, 204, 218) logBox.TextXAlignment = Enum.TextXAlignment.Left logBox.TextYAlignment = Enum.TextYAlignment.Top logBox.ZIndex = 23 logBox.Parent = logScroll local function refreshLogScroll() local w = math.max(60, logScroll.AbsoluteSize.X - 24) local text = logBox.Text if text == "" then text = " " end local sz = TextService:GetTextSize(text, logBox.TextSize, logBox.Font, Vector2.new(w, 100000)) local innerH = math.max(logScroll.AbsoluteSize.Y, math.ceil(sz.Y) + 16) logBox.Size = UDim2.new(1, -24, 0, innerH) logScroll.CanvasSize = UDim2.new(0, 0, 0, innerH) end local function scrollLogToEnd() refreshLogScroll() logScroll.CanvasPosition = Vector2.new(0, math.max(0, logScroll.CanvasSize.Y.Offset - logScroll.AbsoluteSize.Y)) end local function updateGreeting() if streamActive then greeting.Visible = false logScroll.Visible = true return end local hasLog = (logBox.Text or ""):gsub("%s", "") ~= "" if hasLog then greeting.Visible = false logScroll.Visible = true else showGreeting() end end -- Full-height activity log (Cursor-style: one scrolling stream, no card). local function layoutChatBody() logScroll.Position = UDim2.fromOffset(0, 8) logScroll.Size = UDim2.new(1, 0, 1, -8) refreshLogScroll() end -- ── Chat history (ChatGPT-style sessions; searchable dropdown) ─────────── local historyOpen = false local newChatMenuOpen = false local historyPanelHeight = 0 local chatHistory = {} local currentChatId = nil local historyFilter = "" -- Forward declarations so closures defined earlier resolve these upvalues. local relayout local setHistoryOpen local setNewChatMenuOpen local refreshHistoryList local saveCurrentChat local startNewChat local loadChatEntry local composerBox local syncPlaceholder local syncActionAppearance local handles if type(opts.loadChatHistory) == "function" then local loaded = opts.loadChatHistory() if type(loaded) == "table" then chatHistory = loaded end end -- Invisible scrim below the top bar: clicking the chat area closes the dropdown. -- Starts at TOP_H so the New Chat / Search buttons stay clickable while open. local historyScrim = Instance.new("TextButton") historyScrim.Name = "HistoryScrim" historyScrim.AutoButtonColor = false historyScrim.Text = "" historyScrim.BackgroundTransparency = 1 historyScrim.Position = UDim2.fromOffset(0, TOP_H) historyScrim.Size = UDim2.new(1, 0, 1, -TOP_H) historyScrim.Visible = false historyScrim.ZIndex = 39 historyScrim.Parent = shell -- Floating dropdown card (overlays chat; does NOT push layout down) local historyPanel = Instance.new("Frame") historyPanel.Name = "HistoryPanel" historyPanel.BackgroundColor3 = Color3.fromRGB(34, 35, 42) historyPanel.BorderSizePixel = 0 historyPanel.Position = UDim2.fromOffset(8, TOP_H - 2) historyPanel.Size = UDim2.fromOffset(268, 0) historyPanel.Visible = false historyPanel.ClipsDescendants = true historyPanel.ZIndex = 40 historyPanel.Parent = shell round(historyPanel, 10) stroke(historyPanel, 0.15) -- Small "New chat" menu (opened by the pill chevron) — Assistant-style local newChatMenu = Instance.new("Frame") newChatMenu.Name = "NewChatMenu" newChatMenu.BackgroundColor3 = Color3.fromRGB(34, 35, 42) newChatMenu.BorderSizePixel = 0 newChatMenu.Position = UDim2.fromOffset(8, TOP_H - 2) newChatMenu.Size = UDim2.fromOffset(150, 0) newChatMenu.Visible = false newChatMenu.ClipsDescendants = true newChatMenu.ZIndex = 40 newChatMenu.Parent = shell round(newChatMenu, 10) stroke(newChatMenu, 0.15) local newChatBtn = Instance.new("TextButton") newChatBtn.Name = "NewChatItem" newChatBtn.AutoButtonColor = false newChatBtn.Position = UDim2.fromOffset(6, 6) newChatBtn.Size = UDim2.new(1, -12, 0, 30) newChatBtn.BackgroundColor3 = Color3.fromRGB(48, 50, 58) newChatBtn.BackgroundTransparency = 1 newChatBtn.Text = "" newChatBtn.ZIndex = 42 newChatBtn.Parent = newChatMenu round(newChatBtn, 6) local composeIcon = addComposeIcon(newChatBtn, C.text) composeIcon.Position = UDim2.fromOffset(8, 8) composeIcon.Active = false local newChatItemLabel = Instance.new("TextLabel") newChatItemLabel.BackgroundTransparency = 1 newChatItemLabel.Position = UDim2.fromOffset(30, 0) newChatItemLabel.Size = UDim2.new(1, -38, 1, 0) newChatItemLabel.Font = F.semibold newChatItemLabel.TextSize = 13 newChatItemLabel.TextColor3 = C.text newChatItemLabel.TextXAlignment = Enum.TextXAlignment.Left newChatItemLabel.Text = "New chat" newChatItemLabel.ZIndex = 43 newChatItemLabel.Parent = newChatBtn newChatBtn.MouseEnter:Connect(function() newChatBtn.BackgroundTransparency = 0.4 end) newChatBtn.MouseLeave:Connect(function() newChatBtn.BackgroundTransparency = 1 end) -- Search chats box (top of history dropdown) local searchWrap = Instance.new("Frame") searchWrap.Name = "SearchWrap" searchWrap.BackgroundColor3 = Color3.fromRGB(26, 27, 33) searchWrap.BorderSizePixel = 0 searchWrap.Position = UDim2.fromOffset(8, 8) searchWrap.Size = UDim2.new(1, -16, 0, 28) searchWrap.ZIndex = 42 searchWrap.Parent = historyPanel round(searchWrap, 6) stroke(searchWrap, 0.4) local searchIcon = addSearchIcon(searchWrap, C.muted) searchIcon.Position = UDim2.fromOffset(8, 7) searchIcon.Active = false local searchBox = Instance.new("TextBox") searchBox.Name = "SearchChats" searchBox.BackgroundTransparency = 1 searchBox.BorderSizePixel = 0 searchBox.Position = UDim2.fromOffset(26, 0) searchBox.Size = UDim2.new(1, -34, 1, 0) searchBox.ClearTextOnFocus = false searchBox.Text = "" searchBox.PlaceholderText = "Search chats" searchBox.PlaceholderColor3 = C.muted searchBox.Font = F.body searchBox.TextSize = 12 searchBox.TextColor3 = C.text searchBox.TextXAlignment = Enum.TextXAlignment.Left searchBox.ZIndex = 43 searchBox.Parent = searchWrap local historyScroll = Instance.new("ScrollingFrame") historyScroll.BackgroundTransparency = 1 historyScroll.BorderSizePixel = 0 historyScroll.Position = UDim2.fromOffset(0, 42) historyScroll.Size = UDim2.new(1, 0, 1, -46) historyScroll.CanvasSize = UDim2.new(0, 0, 0, 0) historyScroll.ScrollBarThickness = 4 historyScroll.ScrollingDirection = Enum.ScrollingDirection.Y historyScroll.ZIndex = 41 historyScroll.Parent = historyPanel local historyLayout = Instance.new("UIListLayout") historyLayout.Padding = UDim.new(0, 2) historyLayout.SortOrder = Enum.SortOrder.LayoutOrder historyLayout.Parent = historyScroll local historyPad = Instance.new("UIPadding") historyPad.PaddingTop = UDim.new(0, 6) historyPad.PaddingBottom = UDim.new(0, 6) historyPad.PaddingLeft = UDim.new(0, 8) historyPad.PaddingRight = UDim.new(0, 8) historyPad.Parent = historyScroll local function persistHistory() if type(opts.saveChatHistory) ~= "function" then return end -- Dense array only (no holes) + hard size caps — Search must never crash. local clean = {} for _, chat in ipairs(chatHistory) do if type(chat) == "table" then local function scrub(s, maxLen) s = tostring(s or ""):gsub("%z", ""):gsub("[\1-\8\11\12\14-\31]", "") s = s:gsub("[\128-\255]", "?") if #s > maxLen then s = string.sub(s, 1, maxLen) end return s end clean[#clean + 1] = { id = scrub(chat.id or ("chat_" .. (#clean + 1)), 64), title = scrub(chat.title or "", 120), log = scrub(chat.log or "", 20000), prompt = scrub(chat.prompt or "", 2000), createdAt = scrub(chat.createdAt or "", 40), updatedAt = scrub(chat.updatedAt or "", 40), } end end pcall(function() opts.saveChatHistory(clean) end) end local function placeholderRow(text) local empty = Instance.new("TextLabel") empty.BackgroundTransparency = 1 empty.Size = UDim2.new(1, 0, 0, 28) empty.Font = F.body empty.TextSize = 12 empty.TextColor3 = C.muted empty.TextXAlignment = Enum.TextXAlignment.Left empty.Text = " " .. text empty.ZIndex = 42 empty.Parent = historyScroll historyScroll.CanvasSize = UDim2.new(0, 0, 0, 34) end refreshHistoryList = function() for _, child in ipairs(historyScroll:GetChildren()) do if child:IsA("TextButton") or child:IsA("TextLabel") then child:Destroy() end end if #chatHistory == 0 then placeholderRow("No previous chats") return end local filter = (historyFilter or ""):lower() local shown = 0 for i, chat in ipairs(chatHistory) do local title = tostring(chat.title or ("Chat " .. i)) if filter == "" or title:lower():find(filter, 1, true) then shown += 1 local item = Instance.new("TextButton") item.AutoButtonColor = false item.Size = UDim2.new(1, 0, 0, 30) item.BackgroundColor3 = Color3.fromRGB(40, 42, 50) item.BackgroundTransparency = (chat.id == currentChatId) and 0 or 0.35 item.Font = F.body item.TextSize = 12 item.TextColor3 = C.text item.TextXAlignment = Enum.TextXAlignment.Left item.TextTruncate = Enum.TextTruncate.AtEnd item.Text = " " .. title item.LayoutOrder = i item.ZIndex = 42 item.Parent = historyScroll round(item, 6) item.MouseButton1Click:Connect(function() setHistoryOpen(false) if loadChatEntry then loadChatEntry(chat) end end) end end if shown == 0 then placeholderRow("No matches") return end task.defer(function() historyScroll.CanvasSize = UDim2.new(0, 0, 0, historyLayout.AbsoluteContentSize.Y + 12) end) end searchBox:GetPropertyChangedSignal("Text"):Connect(function() historyFilter = searchBox.Text or "" refreshHistoryList() end) -- ── Input card ────────────────────────────────────────────────────────── local inputCard = Instance.new("Frame") inputCard.Name = "InputCard" inputCard.BackgroundColor3 = C.input inputCard.BorderSizePixel = 0 inputCard.AnchorPoint = Vector2.new(0.5, 1) inputCard.Position = UDim2.new(0.5, 0, 1, -(FOOTER_H + 6)) inputCard.Size = UDim2.new(1, -20, 0, INPUT_H) inputCard.ZIndex = 21 inputCard.Parent = shell round(inputCard, 14) stroke(inputCard, 0.2) composerBox = Instance.new("TextBox") composerBox.Name = "AskBloxBuilder" composerBox.Size = UDim2.new(1, -56, 0, 52) composerBox.Position = UDim2.fromOffset(12, 10) composerBox.ClearTextOnFocus = false composerBox.MultiLine = true composerBox.Text = "" composerBox.Font = F.body composerBox.TextSize = 14 composerBox.TextColor3 = C.text composerBox.TextXAlignment = Enum.TextXAlignment.Left composerBox.TextYAlignment = Enum.TextYAlignment.Top composerBox.BackgroundTransparency = 1 composerBox.BorderSizePixel = 0 composerBox.TextWrapped = true composerBox.ZIndex = 23 composerBox.Parent = inputCard local hintOverlay = Instance.new("TextLabel") hintOverlay.Name = "Placeholder" hintOverlay.BackgroundTransparency = 1 hintOverlay.Size = UDim2.new(1, -56, 0, 52) hintOverlay.Position = UDim2.fromOffset(12, 10) hintOverlay.Font = F.body hintOverlay.TextSize = 14 hintOverlay.TextColor3 = C.muted hintOverlay.TextXAlignment = Enum.TextXAlignment.Left hintOverlay.TextYAlignment = Enum.TextYAlignment.Top hintOverlay.Text = "Ask BloxBuilder (Enter sends — wait for Done; use ■ to stop)" hintOverlay.ZIndex = 22 hintOverlay.Active = false hintOverlay.Parent = inputCard syncPlaceholder = function() hintOverlay.Visible = (composerBox.Text or "") == "" end composerBox:GetPropertyChangedSignal("Text"):Connect(syncPlaceholder) local actionBtn = Instance.new("TextButton") actionBtn.Name = "Action" actionBtn.AnchorPoint = Vector2.new(1, 1) actionBtn.Position = UDim2.new(1, -12, 1, -12) actionBtn.Size = UDim2.fromOffset(36, 36) actionBtn.AutoButtonColor = false actionBtn.Font = F.bold actionBtn.TextSize = 18 actionBtn.TextColor3 = Color3.new(1, 1, 1) actionBtn.Text = "^" actionBtn.BackgroundColor3 = C.startBg actionBtn.ZIndex = 24 actionBtn.Parent = inputCard round(actionBtn, 18) local isGenerating = false syncActionAppearance = function() if isGenerating then actionBtn.Text = utf8.char(0x25A0) actionBtn.TextSize = 13 actionBtn.BackgroundColor3 = C.startBg actionBtn.TextTransparency = 0 actionBtn.BackgroundTransparency = 0 return end actionBtn.Text = "^" actionBtn.TextSize = 18 local hasText = (composerBox.Text or ""):gsub("%s", "") ~= "" actionBtn.BackgroundColor3 = hasText and C.startBg or C.sendBg actionBtn.TextTransparency = hasText and 0 or 0.25 actionBtn.BackgroundTransparency = hasText and 0 or 0.15 end composerBox:GetPropertyChangedSignal("Text"):Connect(syncActionAppearance) local disclaimer = Instance.new("TextLabel") disclaimer.BackgroundTransparency = 1 disclaimer.AnchorPoint = Vector2.new(0.5, 1) disclaimer.Position = UDim2.new(0.5, 0, 1, -1) disclaimer.Size = UDim2.new(1, -16, 0, FOOTER_H) disclaimer.Font = F.body disclaimer.TextSize = 10 disclaimer.TextColor3 = Color3.fromRGB(110, 112, 125) disclaimer.Text = "AI-powered — verify results in Studio before publishing." disclaimer.TextWrapped = true disclaimer.ZIndex = 21 disclaimer.Parent = shell -- ── Settings overlay (token) ──────────────────────────────────────────── local settingsOpen = false local settingsOverlay = Instance.new("Frame") settingsOverlay.Name = "SettingsOverlay" settingsOverlay.BackgroundColor3 = Color3.fromRGB(0, 0, 0) settingsOverlay.BackgroundTransparency = 0.45 settingsOverlay.BorderSizePixel = 0 settingsOverlay.Size = UDim2.fromScale(1, 1) settingsOverlay.ZIndex = 60 settingsOverlay.Visible = false settingsOverlay.Parent = shell local settingsCard = Instance.new("Frame") settingsCard.Name = "SettingsCard" settingsCard.BackgroundColor3 = C.panel settingsCard.BorderSizePixel = 0 settingsCard.AnchorPoint = Vector2.new(0.5, 0.5) settingsCard.Position = UDim2.fromScale(0.5, 0.5) settingsCard.Size = UDim2.new(1, -32, 0, 200) settingsCard.ZIndex = 61 settingsCard.Parent = settingsOverlay round(settingsCard, 14) stroke(settingsCard, 0.2) local settingsTitle = Instance.new("TextLabel") settingsTitle.BackgroundTransparency = 1 settingsTitle.Position = UDim2.fromOffset(16, 14) settingsTitle.Size = UDim2.new(1, -32, 0, 22) settingsTitle.Font = F.semibold settingsTitle.TextSize = 15 settingsTitle.TextColor3 = C.text settingsTitle.TextXAlignment = Enum.TextXAlignment.Left settingsTitle.Text = "API Token" settingsTitle.ZIndex = 62 settingsTitle.Parent = settingsCard local settingsHint = Instance.new("TextLabel") settingsHint.BackgroundTransparency = 1 settingsHint.Position = UDim2.fromOffset(16, 36) settingsHint.Size = UDim2.new(1, -32, 0, 28) settingsHint.Font = F.body settingsHint.TextSize = 11 settingsHint.TextColor3 = C.muted settingsHint.TextXAlignment = Enum.TextXAlignment.Left settingsHint.TextWrapped = true settingsHint.Text = "Paste your BloxBuilder token. Saved locally in Studio." settingsHint.ZIndex = 62 settingsHint.Parent = settingsCard local tokenScroll = Instance.new("ScrollingFrame") tokenScroll.BackgroundColor3 = Color3.fromRGB(28, 30, 36) tokenScroll.BorderSizePixel = 0 tokenScroll.Position = UDim2.fromOffset(16, 68) tokenScroll.Size = UDim2.new(1, -32, 0, 72) tokenScroll.CanvasSize = UDim2.new(0, 0, 0, 0) tokenScroll.ScrollBarThickness = 4 tokenScroll.ScrollingDirection = Enum.ScrollingDirection.Y tokenScroll.ZIndex = 62 tokenScroll.Parent = settingsCard round(tokenScroll, 8) stroke(tokenScroll, 0.35) local tokenBox = Instance.new("TextBox") tokenBox.Name = "TokenInput" tokenBox.Size = UDim2.new(1, -12, 1, 0) tokenBox.Position = UDim2.fromOffset(6, 4) tokenBox.ClearTextOnFocus = false tokenBox.MultiLine = true tokenBox.TextWrapped = false tokenBox.TextXAlignment = Enum.TextXAlignment.Left tokenBox.TextYAlignment = Enum.TextYAlignment.Top tokenBox.Font = F.code tokenBox.TextSize = 11 tokenBox.TextColor3 = C.text tokenBox.BackgroundTransparency = 1 tokenBox.BorderSizePixel = 0 tokenBox.PlaceholderText = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." tokenBox.PlaceholderColor3 = C.muted tokenBox.ZIndex = 63 tokenBox.Parent = tokenScroll local function refreshTokenScroll() local w = math.max(60, tokenScroll.AbsoluteSize.X - 12) local text = tokenBox.Text if text == "" then text = tokenBox.PlaceholderText or " " end local sz = TextService:GetTextSize(text, tokenBox.TextSize, tokenBox.Font, Vector2.new(w, 100000)) local innerH = math.max(tokenScroll.AbsoluteSize.Y, math.ceil(sz.Y) + 12) tokenBox.Size = UDim2.new(1, -12, 0, innerH) tokenScroll.CanvasSize = UDim2.new(0, 0, 0, innerH) end tokenScroll:GetPropertyChangedSignal("AbsoluteSize"):Connect(refreshTokenScroll) tokenBox:GetPropertyChangedSignal("Text"):Connect(refreshTokenScroll) local saveBtn = Instance.new("TextButton") saveBtn.Name = "SaveToken" saveBtn.AutoButtonColor = false saveBtn.AnchorPoint = Vector2.new(1, 1) saveBtn.Position = UDim2.new(1, -16, 1, -12) saveBtn.Size = UDim2.fromOffset(72, 32) saveBtn.BackgroundColor3 = C.startBg saveBtn.Font = F.bold saveBtn.TextSize = 13 saveBtn.TextColor3 = Color3.new(1, 1, 1) saveBtn.Text = "Save" saveBtn.ZIndex = 62 saveBtn.Parent = settingsCard round(saveBtn, 10) local closeBtn = Instance.new("TextButton") closeBtn.Name = "CloseSettings" closeBtn.AutoButtonColor = false closeBtn.AnchorPoint = Vector2.new(1, 0) closeBtn.Position = UDim2.new(1, -12, 0, 10) closeBtn.Size = UDim2.fromOffset(28, 28) closeBtn.BackgroundTransparency = 1 closeBtn.Font = F.bold closeBtn.TextSize = 16 closeBtn.TextColor3 = C.muted closeBtn.Text = "×" closeBtn.ZIndex = 63 closeBtn.Parent = settingsCard local maxChars = tonumber(opts.promptMaxChars) or 400 local function relayoutImpl() local totalH = shell.AbsoluteSize.Y if totalH < 120 then return end chatArea.Position = UDim2.fromOffset(0, TOP_H) local bottomBlock = INPUT_H + FOOTER_H + 14 local chatH = math.max(80, totalH - TOP_H - bottomBlock) chatArea.Size = UDim2.new(1, 0, 0, chatH) layoutChatBody() end relayout = relayoutImpl setHistoryOpen = function(open) historyOpen = open == true historyPanel.Visible = historyOpen historyScrim.Visible = historyOpen if historyOpen then if setNewChatMenuOpen then setNewChatMenuOpen(false) end refreshHistoryList() -- search box (44) + up to N rows; floats over chat, doesn't push layout local count = math.max(1, #chatHistory) historyPanelHeight = math.min(260, 44 + count * 34 + 8) historyPanel.Size = UDim2.fromOffset(268, historyPanelHeight) else historyPanelHeight = 0 historyPanel.Size = UDim2.fromOffset(268, 0) historyFilter = "" searchBox.Text = "" end historyScrim.Visible = historyOpen or newChatMenuOpen end setNewChatMenuOpen = function(open) newChatMenuOpen = open == true newChatMenu.Visible = newChatMenuOpen if newChatMenuOpen then if setHistoryOpen then setHistoryOpen(false) end newChatMenu.Size = UDim2.fromOffset(150, 42) else newChatMenu.Size = UDim2.fromOffset(150, 0) end historyScrim.Visible = historyOpen or newChatMenuOpen end local function chatTitleFrom(prompt, log) local t = (prompt or ""):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") if t == "" then t = (log or ""):gsub("[\r\n]+", " "):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") end t = t:sub(1, 48) if t == "" then t = "Chat " .. os.date("%H:%M") end return t end local function lastPromptFromLog(log) local source = tostring(log or "") local latest = "" for line in source:gmatch("[^\r\n]+") do local p = line:match("^You:%s*(.+)$") if p and p ~= "" then latest = p end end return latest end -- Upsert the active conversation into history (create on first content, else update). saveCurrentChat = function() local log = logBox.Text or "" local draftPrompt = composerBox.Text or "" local prompt = draftPrompt if prompt:gsub("%s", "") == "" then prompt = lastPromptFromLog(log) end if log:gsub("%s", "") == "" and prompt:gsub("%s", "") == "" then return end local now = os.date("%Y-%m-%d %H:%M") local title = chatTitleFrom(prompt, log) if currentChatId then for _, c in ipairs(chatHistory) do if c.id == currentChatId then c.title = title c.log = log c.prompt = prompt c.updatedAt = now persistHistory() return end end end currentChatId = tostring(os.time()) .. "_" .. tostring(math.random(1000, 9999)) table.insert(chatHistory, 1, { id = currentChatId, title = title, log = log, prompt = prompt, createdAt = now, updatedAt = now, }) while #chatHistory > MAX_CHATS do table.remove(chatHistory) end persistHistory() end startNewChat = function() saveCurrentChat() currentChatId = nil handles.resetChat() end loadChatEntry = function(entry) if not entry then return end saveCurrentChat() currentChatId = entry.id local log = tostring(entry.log or "") logBox.Text = log turnPrefix = log streamActive = false composerBox.Text = tostring(entry.prompt or "") updateGreeting() syncPlaceholder() syncActionAppearance() refreshLogScroll() end shell:GetPropertyChangedSignal("AbsoluteSize"):Connect(relayout) logScroll:GetPropertyChangedSignal("AbsoluteSize"):Connect(refreshLogScroll) logBox:GetPropertyChangedSignal("Text"):Connect(function() refreshLogScroll() updateGreeting() end) handles = { shell = shell, logBox = logBox, composerBox = composerBox, actionBtn = actionBtn, startBtn = actionBtn, stopBtn = actionBtn, phaseLabel = phaseLabel, greeting = greeting, tokenBox = tokenBox, } function handles.setSettingsOpen(open) settingsOpen = open == true settingsOverlay.Visible = settingsOpen if settingsOpen then local tok = "" if opts.getTokenText then tok = tostring(opts.getTokenText() or "") end tokenBox.Text = tok task.defer(refreshTokenScroll) end end function handles.resetChat() logBox.Text = "" composerBox.Text = "" logScroll.CanvasPosition = Vector2.new(0, 0) isGenerating = false streamActive = false currentStep = 0 turnPrefix = "" layoutChatBody() handles.setPhase("Idle") showGreeting() syncPlaceholder() syncActionAppearance() refreshLogScroll() end -- ── Cursor/ChatGPT stream: prior turns stay; only the live job replaces underneath ── local function freezeTranscript() turnPrefix = (logBox.Text or ""):gsub("%s+$", "") end function handles.beginSteps(promptText) streamActive = true currentStep = 1 lastLoggedStep = 0 greeting.Visible = false logScroll.Visible = true -- Keep everything already on screen (previous You:/responses) as prior history. local prior = (logBox.Text or ""):gsub("%s+$", "") local pt = tostring(promptText or ""):gsub("[\r\n]+", " "):gsub("%s+", " ") pt = pt:gsub("^%s+", ""):gsub("%s+$", "") local header = (pt ~= "") and ("You: " .. pt) or "You:" if prior ~= "" then -- Avoid double-appending the same header if Start was hit twice. if prior:sub(-#header) == header or prior:find("\n" .. header .. "\n", 1, true) then turnPrefix = prior if not prior:match("\n$") then turnPrefix = prior .. "\n" end else turnPrefix = prior .. "\n\n────────────────\n\n" .. header .. "\n" end else turnPrefix = header .. "\n" end logBox.Text = turnPrefix handles.setPhase("Working") layoutChatBody() scrollLogToEnd() end function handles.setStep(n) if not streamActive then streamActive = true greeting.Visible = false logScroll.Visible = true layoutChatBody() end local idx = math.clamp(tonumber(n) or 1, 1, #STEP_LABELS) if idx < currentStep then return end currentStep = idx local label = STEP_LABELS[idx] if label then handles.setPhase(label) end end function handles.completeSteps() if not streamActive and currentStep == 0 then return end currentStep = #STEP_LABELS handles.appendLogText("Done") handles.setPhase("Idle") streamActive = false lastLoggedStep = 0 freezeTranscript() updateGreeting() end function handles.failStep() if not streamActive and currentStep == 0 then return end local label = STEP_LABELS[currentStep] or "Run" handles.appendLogText("Failed - " .. label) handles.setPhase("Idle") streamActive = false freezeTranscript() updateGreeting() end function handles.hideSteps() streamActive = false currentStep = 0 lastLoggedStep = 0 freezeTranscript() layoutChatBody() updateGreeting() end function handles.setLogText(text) local t = tostring(text or "") if t:gsub("%s", "") == "" then if streamActive and turnPrefix ~= "" then -- Keep prior chat + You: header; clear only the live job segment. logBox.Text = turnPrefix greeting.Visible = false logScroll.Visible = true scrollLogToEnd() return end logBox.Text = "" turnPrefix = "" if not streamActive then showGreeting() end return end if streamActive then -- ChatGPT-style: frozen prior turns + You: stay; replace only the live job output. local live = t -- Drop accidental duplication of the current You: header inside job text. local youHeader = turnPrefix:match("(You: [^\r\n]+)\n?$") if youHeader and live:sub(1, #youHeader) == youHeader then live = live:sub(#youHeader + 1):gsub("^[\r\n]+", "") end logBox.Text = turnPrefix .. live else -- Idle status/errors must not wipe prior turns (append like ChatGPT). local cur = (logBox.Text or ""):gsub("%s+$", "") if cur == "" then logBox.Text = t else logBox.Text = cur .. "\n\n" .. t end turnPrefix = logBox.Text end greeting.Visible = false logScroll.Visible = true scrollLogToEnd() end function handles.appendLogText(text) local cur = logBox.Text or "" if cur ~= "" then cur = cur .. "\n" end logBox.Text = cur .. tostring(text or "") if not streamActive then turnPrefix = logBox.Text end updateGreeting() scrollLogToEnd() end function handles.setPhase(phase) phaseLabel.Text = tostring(phase or "Idle") end function handles.setGenerating(gen) local was = isGenerating isGenerating = gen == true syncActionAppearance() if isGenerating then handles.setPhase("Working") else handles.setPhase("Idle") -- Persist the conversation when a run finishes (ChatGPT-style history). if was and saveCurrentChat then saveCurrentChat() end end end function handles.getPromptText() return composerBox.Text or "" end function handles.setPromptText(text) composerBox.Text = tostring(text or "") syncPlaceholder() end function handles.syncFromLegacyPrompt(legacyBox) if legacyBox and legacyBox:IsA("TextBox") then composerBox.Text = legacyBox.Text or "" syncPlaceholder() end end local function saveTokenAndClose() local tok = (tokenBox.Text or ""):gsub("^%s+", ""):gsub("%s+$", "") if opts.saveToken then opts.saveToken(tok) end handles.setSettingsOpen(false) if opts.onTokenSaved then opts.onTokenSaved(tok) end end local function submitComposer() if isGenerating then -- Enter must not cancel a running job (only the Stop button does). return end local text = (composerBox.Text or ""):gsub("^%s+", ""):gsub("%s+$", "") if text == "" then return end if opts.onSend then opts.onSend() elseif opts.onStart then opts.onStart() end end actionBtn.MouseButton1Click:Connect(function() if isGenerating then if opts.onStop then opts.onStop() end return end submitComposer() end) -- Enter-to-send: MultiLine TextBox inserts "\n" on Enter (works in plugin dock on PC/Mac). local suppressNewlineSubmit = false local lastComposerText = "" composerBox:GetPropertyChangedSignal("Text"):Connect(function() if suppressNewlineSubmit then return end local t = composerBox.Text or "" if #t > maxChars then suppressNewlineSubmit = true t = string.sub(t, 1, maxChars) composerBox.Text = t suppressNewlineSubmit = false end local prev = lastComposerText lastComposerText = t if isGenerating then return end if not composerBox:IsFocused() then return end -- Single Enter at end of line (not paste with many newlines). if #t == #prev + 1 and t:sub(-1) == "\n" then suppressNewlineSubmit = true composerBox.Text = prev lastComposerText = prev suppressNewlineSubmit = false submitComposer() end end) composerBox.FocusLost:Connect(function(_enterPressed) if saveCurrentChat then saveCurrentChat() end end) -- Pill chevron opens the small New chat menu newChatPill.MouseButton1Click:Connect(function() handles.setSettingsOpen(false) setNewChatMenuOpen(not newChatMenuOpen) end) -- The "New chat" item inside that menu actually starts a new chat newChatBtn.MouseButton1Click:Connect(function() handles.setSettingsOpen(false) setNewChatMenuOpen(false) if setHistoryOpen then setHistoryOpen(false) end startNewChat() if opts.onNewChat then opts.onNewChat() end end) -- Search button opens the search + history dropdown historyToggle.MouseButton1Click:Connect(function() handles.setSettingsOpen(false) setHistoryOpen(not historyOpen) end) historyScrim.MouseButton1Click:Connect(function() setHistoryOpen(false) setNewChatMenuOpen(false) end) undoBtn.MouseButton1Click:Connect(function() if isGenerating then return end if opts.onUndo then opts.onUndo() end end) menuBtn.MouseButton1Click:Connect(function() handles.setSettingsOpen(not settingsOpen) end) closeBtn.MouseButton1Click:Connect(function() handles.setSettingsOpen(false) end) settingsOverlay.InputBegan:Connect(function(input) if input.UserInputType == Enum.UserInputType.MouseButton1 then local pos = input.Position local cardPos = settingsCard.AbsolutePosition local cardSize = settingsCard.AbsoluteSize local inside = pos.X >= cardPos.X and pos.X <= cardPos.X + cardSize.X and pos.Y >= cardPos.Y and pos.Y <= cardPos.Y + cardSize.Y if not inside then handles.setSettingsOpen(false) end end end) saveBtn.MouseButton1Click:Connect(saveTokenAndClose) tokenBox.FocusLost:Connect(function(enterPressed) if enterPressed then saveTokenAndClose() end end) -- Load token into settings field on first open if opts.getTokenText then tokenBox.Text = tostring(opts.getTokenText() or "") task.defer(refreshTokenScroll) end handles.setGenerating(false) handles.resetChat() task.defer(relayout) return handles end _G.BloxBuilderChatUI = ChatUI end -- BloxBuilder Roblox Studio Plugin (build 2025-06-22e — rich scenic props + auto-apply) -- Drop this file into a plugin project, or paste into Studio plugin source. -- One table for all services (Luau allows max 200 locals per chunk). local BB = { Http = game:GetService("HttpService"), Sel = game:GetService("Selection"), ChangeHist = game:GetService("ChangeHistoryService"), Coll = game:GetService("CollectionService"), Gui = game:GetService("GuiService"), RepStorage = game:GetService("ReplicatedStorage"), SSS = game:GetService("ServerScriptService"), StudioSvc = game:GetService("StudioService"), Tween = game:GetService("TweenService"), LogSvc = game:GetService("LogService"), Run = game:GetService("RunService"), Text = game:GetService("TextService"), } local toolbar = plugin:CreateToolbar("BloxBuilder") local button = toolbar:CreateButton("BloxBuilder", "Build Roblox games with AI", "rbxassetid://4458901886") -- Prefer IPv4 loopback; "localhost" may resolve to IPv6 (::1) on Windows. local API_BASE = "https://api.bloxbuilder.org" -- Same origin as the web app (landing + Stripe checkout). -- Base URL shown in the out-of-credits banner (append #pricing in refreshUpgradeUrlHint). local WEB_APP_URL = "https://www.bloxbuilder.org" -- Optional default; UI "API token" box overrides this when filled. local AUTH_TOKEN = "" -- Slightly faster polls = smoother “live” feel (still light on the API; /jobs is not rate-limited). local POLL_INTERVAL = 0.22 local PROMPT_MAX_CHARS = 400 -- Auto-fix settings local AUTO_FIX_ENABLED = true local AUTO_FIX_MAX_ATTEMPTS = 3 local AUTO_FIX_WINDOW_SEC = 120 local AUTO_FIX_DEBOUNCE_SEC = 1.2 local TOKEN_SETTING_KEY = "VibeCoderAuthToken" local BRIDGE_TOKEN_SYNC_URL = "http://127.0.0.1:9234/token" local CHAT_HISTORY_KEY = "BloxBuilder_ChatHistory_v1" local PROJECT_SETTING_KEY = "VibeCoderLastProjectJson" local TAG_NAME = "VibeCoderAI" local MODE_SETTING_KEY = "VibeCoderGenerationMode" local PREVIEW_MODE_DEFAULT = true local Editor = rawget(_G, "BloxBuilderEditorUX") if not Editor then -- Pipeline step definitions: order, emoji, short label, longer description -- Stages are ordered as they actually fire in the worker pipeline. local PIPELINE_STEPS = { { stage = "intent", emoji = "[intent]", label = "Classifying intent", desc = "Reading your prompt and detecting genre" }, { stage = "theme", emoji = "[theme]", label = "Building theme", desc = "Setting biome, setting, art style" }, { stage = "high_level_design",emoji = "[plan]", label = "High-level design", desc = "Drafting game pillars and core loop" }, { stage = "world", emoji = "[world]", label = "Planning world", desc = "Laying out zones, landmarks, roads" }, { stage = "genre", emoji = "[genre]", label = "Genre analysis", desc = "Selecting systems and mechanics for " }, { stage = "agent", emoji = "[agent]", label = "Genre agent", desc = "Applying genre-specific directives" }, { stage = "gameplay", emoji = "[loop]", label = "Gameplay loop", desc = "Designing primary, progression & retention loops" }, { stage = "gdd", emoji = "[doc]", label = "Game design doc", desc = "Writing the full GDD" }, { stage = "systems", emoji = "[systems]", label = "Game systems", desc = "Wiring save, combat, economy systems" }, { stage = "retention", emoji = "[retain]", label = "Retention plan", desc = "Dailies, seasons, social hooks" }, { stage = "terrain", emoji = "[terrain]", label = "Terrain generation", desc = "Sculpting heightmap and biome regions" }, { stage = "assets", emoji = "[assets]", label = "Asset planning", desc = "Selecting props, models and decorations" }, { stage = "buildings", emoji = "[build]", label = "Placing buildings", desc = "Positioning structures across zones" }, { stage = "npcs", emoji = "[npcs]", label = "NPC planning", desc = "Creating characters, merchants, enemies" }, { stage = "quests", emoji = "[quests]", label = "Quest planning", desc = "Designing main quests and side objectives" }, { stage = "knowledge", emoji = "[graph]", label = "Knowledge graph", desc = "Linking world entities into a graph" }, { stage = "spawn", emoji = "[spawn]", label = "Spawn systems", desc = "Configuring player and enemy respawn" }, { stage = "economy", emoji = "[economy]", label = "Economy planning", desc = "Currencies, vendors, drop tables" }, { stage = "progression", emoji = "[xp]", label = "Progression plan", desc = "XP curves, level gates, prestige" }, { stage = "balance", emoji = "[balance]", label = "Balance engine", desc = "Tuning numbers and reward rates" }, { stage = "constraints", emoji = "[limits]", label = "Constraint check", desc = "Enforcing part limits and performance rules" }, { stage = "performance", emoji = "[perf]", label = "Performance analysis", desc = "Estimating render cost and optimising" }, { stage = "validation", emoji = "[check]", label = "Validating plan", desc = "Checking schema integrity" }, { stage = "preview", emoji = "[preview]", label = "Building preview", desc = "Placing parts in your 3D viewport" }, { stage = "artifacts", emoji = "[scripts]", label = "Generating scripts", desc = "Writing Luau runtime scripts" }, } -- Quick lookup: stage → step info local STEP_BY_STAGE = {} for i, s in ipairs(PIPELINE_STEPS) do STEP_BY_STAGE[s.stage] = { index = i, total = #PIPELINE_STEPS, emoji = s.emoji, label = s.label, desc = s.desc } end -- STAGE_LABELS: stages that should be logged as completed lines in the output panel local STAGE_LABELS = {} for _, s in ipairs(PIPELINE_STEPS) do STAGE_LABELS[s.stage] = s.emoji .. " " .. s.label end local function formatProgress(stage, durationMs, success) local info = STEP_BY_STAGE[stage] local ms = tonumber(durationMs) or 0 local durStr = ms > 0 and (" (" .. string.format("%.1f", ms / 1000) .. "s)") or "" if info then local tick_or_x = (success == false) and " [x]" or (success == true and " [ok]" or " ...") return string.format("[%d/%d] %s %s%s%s", info.index, info.total, info.emoji, info.label, tick_or_x, durStr) end return "Running " .. tostring(stage or "Working") .. durStr end Editor = { SETTING_HISTORY = "BloxBuilder_CommandHistory_v1", SETTING_VERSIONS = "BloxBuilder_BuildVersions_v1", SETTING_SESSION = "BloxBuilder_EditorSession_v1", STAGE_LABELS = STAGE_LABELS, STEP_BY_STAGE = STEP_BY_STAGE, PIPELINE_STEPS = PIPELINE_STEPS, IMPROVE_SCOPES = { { id = "visuals", label = "Improve Visuals" }, { id = "terrain", label = "Improve Terrain" }, { id = "gameplay", label = "Improve Gameplay" }, { id = "npcs", label = "Improve NPCs" }, { id = "quests", label = "Improve Quests" }, { id = "economy", label = "Improve Economy" }, { id = "progression", label = "Improve Progression" }, { id = "performance", label = "Improve Performance" }, }, newCommandHistory = function(p, h) return { push = function() end, undo = function() return nil end, redo = function() return nil end } end, newVersionStore = function(p, h) return { add = function() end, get = function() return nil end, versions = {} } end, newSessionRecovery = function(p, h) return { save = function() end, load = function() return nil end, clear = function() end } end, formatHealthPanel = function(h) return "Build Health - generate to view scores." end, formatPreviewPanel = function(s, h) return "Preview mode - Generate then Apply." end, formatProgress = formatProgress, formatVersionLine = function(v) return tostring(v.prompt or "Build") end, makeVersionId = function() return "v_local" end, } end local commandHistory = Editor.newCommandHistory(plugin, BB.Http) local versionStore = Editor.newVersionStore(plugin, BB.Http) local sessionRecovery = Editor.newSessionRecovery(plugin, BB.Http) local improveMenuOpen = false local selectedImproveScope = "visuals" local uiState = { isGenerating = false, canApply = false, lastJobId = nil, lastRawOutput = nil, lastProjectJson = nil, hadStudioApply = false, lastDecoded = nil, lastLuau = nil, lastPrompt = nil, lastRoute = nil, fakePhase = "idle", previewApplied = false, previewMode = PREVIEW_MODE_DEFAULT, lastHealth = nil, lastContextSnapshot = nil, lastPlanJson = nil, lastValidationScore = nil, workflowStage = "idle", selectedVersionId = nil, } local healthLabel local historyLabel local refreshHistoryPanel local setPreview local runCompileValidationPass local showToast local captureWorkspaceSnapshot local restoreWorkspaceSnapshot local captureUndoCheckpoint local undoLastAgentBuild local chatHandles = nil -- Dock to the right by default (not floating in the center). Change to .Left if you prefer the left dock. local widgetInfo = DockWidgetPluginGuiInfo.new( Enum.InitialDockState.Right, true, false, 480, 640, 400, 480 ) local widget = plugin:CreateDockWidgetPluginGui("VibeCoderAIWidget", widgetInfo) widget.Title = "BloxBuilder" local function uiRound(uicornerParent, radius) local c = Instance.new("UICorner") c.CornerRadius = UDim.new(0, radius or 12) c.Parent = uicornerParent return c end local function uiStroke(parent, transparency) local s = Instance.new("UIStroke") s.Thickness = 1 s.Transparency = transparency or 0.6 s.Color = Color3.fromRGB(255, 255, 255) s.Parent = parent return s end -- Full-widget backdrop: UIGradient must NOT live on the ScrollingFrame (Studio can fail to composite -- children correctly — middle of the panel stays blank while the top token row still shows). local backdrop = Instance.new("Frame") backdrop.BorderSizePixel = 0 backdrop.BackgroundColor3 = Color3.fromRGB(18, 21, 35) backdrop.Size = UDim2.fromScale(1, 1) backdrop.ZIndex = 0 backdrop.Parent = widget local gradient = Instance.new("UIGradient") gradient.Color = ColorSequence.new({ ColorSequenceKeypoint.new(0, Color3.fromRGB(18, 21, 35)), ColorSequenceKeypoint.new(1, Color3.fromRGB(8, 10, 18)), }) gradient.Rotation = 35 gradient.Parent = backdrop -- Make the whole plugin UI scrollable (small Studio windows). local rootScroll = Instance.new("ScrollingFrame") rootScroll.Name = "RootScroll" rootScroll.BackgroundTransparency = 1 rootScroll.BorderSizePixel = 0 rootScroll.Size = UDim2.fromScale(1, 1) rootScroll.ZIndex = 1 rootScroll.CanvasSize = UDim2.new(0, 0, 0, 0) rootScroll.AutomaticCanvasSize = Enum.AutomaticSize.None -- Show a global scrollbar so controls don't appear "missing" when pushed below the fold. rootScroll.ScrollBarThickness = 8 rootScroll.ScrollBarImageTransparency = 0.35 rootScroll.ScrollBarImageColor3 = Color3.fromRGB(170, 175, 200) rootScroll.ScrollingDirection = Enum.ScrollingDirection.Y rootScroll.ScrollingEnabled = true rootScroll.Parent = widget local root = Instance.new("Frame") root.BackgroundTransparency = 1 root.BorderSizePixel = 0 root.Size = UDim2.new(1, 0, 0, 0) root.AutomaticSize = Enum.AutomaticSize.Y root.ZIndex = 1 root.Parent = rootScroll -- Padding on `root` so it is part of the measured column (AutomaticCanvasSize accounts for it). local padding = Instance.new("UIPadding") padding.PaddingTop = UDim.new(0, 14) padding.PaddingBottom = UDim.new(0, 14) padding.PaddingLeft = UDim.new(0, 14) padding.PaddingRight = UDim.new(0, 14) padding.Parent = root local layout = Instance.new("UIListLayout") layout.FillDirection = Enum.FillDirection.Vertical layout.HorizontalAlignment = Enum.HorizontalAlignment.Center layout.SortOrder = Enum.SortOrder.LayoutOrder layout.Padding = UDim.new(0, 10) layout.Parent = root -- Some Studio/plugin environments fail to update AutomaticCanvasSize reliably. -- Use AbsoluteContentSize for a deterministic CanvasSize update (no task.defer to avoid re-entrancy). local function refreshCanvas() local h = math.ceil(layout.AbsoluteContentSize.Y + padding.PaddingTop.Offset + padding.PaddingBottom.Offset + 2) rootScroll.CanvasSize = UDim2.new(0, 0, 0, h) end layout:GetPropertyChangedSignal("AbsoluteContentSize"):Connect(refreshCanvas) refreshCanvas() local title = Instance.new("TextLabel") title.BackgroundTransparency = 1 title.Size = UDim2.new(1, 0, 0, 28) title.Font = Enum.Font.GothamSemibold title.TextSize = 18 title.TextColor3 = Color3.fromRGB(240, 240, 255) title.Text = "BloxBuilder" title.Parent = root -- Minimal UI: no badges or helper text. -- Horizontal scroll container for the token box (tokens are long; make left↔right scrolling easy). local tokenScroll = Instance.new("ScrollingFrame") tokenScroll.BackgroundColor3 = Color3.fromRGB(14, 16, 28) tokenScroll.BorderSizePixel = 0 tokenScroll.Size = UDim2.new(1, 0, 0, 44) tokenScroll.CanvasSize = UDim2.new(0, 0, 0, 0) tokenScroll.AutomaticCanvasSize = Enum.AutomaticSize.None tokenScroll.ScrollBarThickness = 6 tokenScroll.ScrollingDirection = Enum.ScrollingDirection.X tokenScroll.ScrollingEnabled = true tokenScroll.Parent = root uiRound(tokenScroll, 10) uiStroke(tokenScroll, 0.78) local tokenBox = Instance.new("TextBox") tokenBox.Size = UDim2.new(1, 0, 1, 0) tokenBox.ClearTextOnFocus = false tokenBox.MultiLine = true tokenBox.TextWrapped = false tokenBox.ClipsDescendants = true tokenBox.TextXAlignment = Enum.TextXAlignment.Left tokenBox.TextYAlignment = Enum.TextYAlignment.Top tokenBox.Font = Enum.Font.Code tokenBox.TextSize = 11 tokenBox.TextColor3 = Color3.fromRGB(220, 225, 245) tokenBox.PlaceholderText = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." tokenBox.BackgroundTransparency = 1 tokenBox.BorderSizePixel = 0 tokenBox.Parent = tokenScroll local tokenPad = Instance.new("UIPadding") tokenPad.PaddingLeft = UDim.new(0, 10) tokenPad.PaddingRight = UDim.new(0, 10) tokenPad.Parent = tokenBox local function refreshTokenScroll() -- Ensure the inner TextBox is wide enough for horizontal scrolling. local s = tokenBox.Text or tokenBox.PlaceholderText or "" local sz = BB.Text:GetTextSize(s, tokenBox.TextSize, tokenBox.Font, Vector2.new(10000, 44)) local innerW = math.max(tokenScroll.AbsoluteSize.X, math.ceil(sz.X) + tokenPad.PaddingLeft.Offset + tokenPad.PaddingRight.Offset + 20) tokenBox.Size = UDim2.new(0, innerW, 1, 0) tokenScroll.CanvasSize = UDim2.new(0, innerW, 0, 0) end tokenScroll:GetPropertyChangedSignal("AbsoluteSize"):Connect(refreshTokenScroll) tokenBox:GetPropertyChangedSignal("Text"):Connect(refreshTokenScroll) refreshTokenScroll() local okLoad, savedToken = pcall(function() return plugin:GetSetting(TOKEN_SETTING_KEY) end) if okLoad and typeof(savedToken) == "string" then tokenBox.Text = savedToken end -- Plugin scripts reload into PlayClient/PlayServer on Play. HttpService from -- PlayClient errors ("Http requests can only be executed by game server") and -- Studio's exception debugger stops on it — skip all bridge/API HTTP outside Edit. local function isPluginEditMode() local ok, edit = pcall(function() return BB.Run:IsEdit() end) return ok and edit == true end local function syncTokenToBridge(tok) if not isPluginEditMode() then return false end tok = tostring(tok or ""):gsub("^%s+", ""):gsub("%s+$", "") if tok == "" then return false end local ok, res = pcall(function() return BB.Http:RequestAsync({ Url = BRIDGE_TOKEN_SYNC_URL, Method = "POST", Headers = { ["Content-Type"] = "application/json" }, Body = BB.Http:JSONEncode({ token = tok }), }) end) return ok and type(res) == "table" and res.Success == true end tokenBox.FocusLost:Connect(function() pcall(function() plugin:SetSetting(TOKEN_SETTING_KEY, tokenBox.Text) end) syncTokenToBridge(tokenBox.Text) end) if okLoad and typeof(savedToken) == "string" and savedToken ~= "" then task.defer(function() if isPluginEditMode() then syncTokenToBridge(savedToken) end end) end local function openWeb(url) local u = tostring(url or ""):gsub("%s+", "") if u == "" then return false, "Missing URL" end -- Studio support varies by channel/build; try to open the browser, otherwise fall back to copyable textbox. local ok, err = pcall(function() BB.Gui:OpenBrowserWindow(u) end) if ok then return true, "" end return false, tostring(err) end -- Quick actions row (website + install/token help). local webRow = Instance.new("Frame") webRow.BackgroundTransparency = 1 webRow.Size = UDim2.new(1, 0, 0, 34) webRow.Parent = root local webLayout = Instance.new("UIListLayout") webLayout.FillDirection = Enum.FillDirection.Horizontal webLayout.HorizontalAlignment = Enum.HorizontalAlignment.Left webLayout.SortOrder = Enum.SortOrder.LayoutOrder webLayout.Padding = UDim.new(0, 8) webLayout.Parent = webRow local webBtn = Instance.new("TextButton") webBtn.Size = UDim2.new(0, 160, 0, 28) webBtn.AutoButtonColor = false webBtn.Font = Enum.Font.GothamSemibold webBtn.TextSize = 12 webBtn.TextColor3 = Color3.fromRGB(255, 255, 255) webBtn.BackgroundColor3 = Color3.fromRGB(60, 64, 90) webBtn.BorderSizePixel = 0 webBtn.Text = "Open website" uiRound(webBtn, 12) uiStroke(webBtn, 0.8) webBtn.Parent = webRow local installBtn = Instance.new("TextButton") installBtn.Size = UDim2.new(0, 170, 0, 28) installBtn.AutoButtonColor = false installBtn.Font = Enum.Font.GothamSemibold installBtn.TextSize = 12 installBtn.TextColor3 = Color3.fromRGB(255, 255, 255) installBtn.BackgroundColor3 = Color3.fromRGB(46, 55, 86) installBtn.BorderSizePixel = 0 installBtn.Text = "Get token (install)" uiRound(installBtn, 12) uiStroke(installBtn, 0.84) installBtn.Parent = webRow local webHint = Instance.new("TextBox") webHint.BackgroundColor3 = Color3.fromRGB(14, 16, 28) webHint.BorderSizePixel = 0 webHint.Size = UDim2.new(1, 0, 0, 0) webHint.AutomaticSize = Enum.AutomaticSize.Y webHint.Font = Enum.Font.Code webHint.TextSize = 10 webHint.TextColor3 = Color3.fromRGB(160, 165, 195) webHint.TextWrapped = true webHint.TextXAlignment = Enum.TextXAlignment.Left webHint.TextEditable = false webHint.ClearTextOnFocus = false webHint.Visible = false webHint.Parent = root uiRound(webHint, 10) uiStroke(webHint, 0.82) local webHintPad = Instance.new("UIPadding") webHintPad.PaddingTop = UDim.new(0, 8) webHintPad.PaddingBottom = UDim.new(0, 8) webHintPad.PaddingLeft = UDim.new(0, 10) webHintPad.PaddingRight = UDim.new(0, 10) webHintPad.Parent = webHint local function showWebHint(text) webHint.Text = tostring(text or "") webHint.Visible = true end webBtn.MouseButton1Click:Connect(function() local base = (WEB_APP_URL:gsub("%s+", "")):gsub("/+$", "") local ok, err = openWeb(base) if not ok then showWebHint("Open this in your browser:\n" .. base .. "\n\n(Studio blocked opening the browser: " .. err .. ")") end end) installBtn.MouseButton1Click:Connect(function() local base = (WEB_APP_URL:gsub("%s+", "")):gsub("/+$", "") local url = base .. "/install" local ok, err = openWeb(url) if not ok then showWebHint("Get your token here:\n" .. url .. "\n\n(Studio blocked opening the browser: " .. err .. ")") end end) -- Shown when the API returns 402 (not enough credits). Copy the URL below into your browser (Studio cannot open it reliably from a plugin). local upgradeFrame = Instance.new("Frame") upgradeFrame.BackgroundColor3 = Color3.fromRGB(28, 22, 42) upgradeFrame.BorderSizePixel = 0 upgradeFrame.Size = UDim2.new(1, 0, 0, 0) -- When hidden, keep height at 0 so the rest of the UI isn't pushed off-screen. upgradeFrame.AutomaticSize = Enum.AutomaticSize.None upgradeFrame.Visible = false uiRound(upgradeFrame, 12) uiStroke(upgradeFrame, 0.75) upgradeFrame.Parent = root local upgradePad = Instance.new("UIPadding") upgradePad.PaddingTop = UDim.new(0, 10) upgradePad.PaddingBottom = UDim.new(0, 10) upgradePad.PaddingLeft = UDim.new(0, 12) upgradePad.PaddingRight = UDim.new(0, 12) upgradePad.Parent = upgradeFrame local upgradeLbl = Instance.new("TextLabel") upgradeLbl.BackgroundTransparency = 1 upgradeLbl.Size = UDim2.new(1, 0, 0, 0) upgradeLbl.AutomaticSize = Enum.AutomaticSize.Y upgradeLbl.Font = Enum.Font.GothamMedium upgradeLbl.TextSize = 12 upgradeLbl.TextColor3 = Color3.fromRGB(250, 220, 235) upgradeLbl.TextWrapped = true upgradeLbl.TextXAlignment = Enum.TextXAlignment.Left upgradeLbl.Text = "You're out of credits. Upgrade on the website (Pro / Ultra), complete checkout, then keep the same API token here. Subscribers: use the site’s billing portal for invoices, card on file, cancel, and auto-renew settings.\n\nRoblox Studio cannot open this link from the plugin—select the address below, copy (Ctrl+C), and paste it into Chrome, Edge, or another browser." upgradeLbl.Parent = upgradeFrame -- Read-only TextBox so the URL can be selected + Ctrl+C. local upgradeUrlHint = Instance.new("TextBox") upgradeUrlHint.BackgroundTransparency = 1 upgradeUrlHint.Size = UDim2.new(1, 0, 0, 0) upgradeUrlHint.AutomaticSize = Enum.AutomaticSize.Y upgradeUrlHint.Font = Enum.Font.Code upgradeUrlHint.TextSize = 10 upgradeUrlHint.TextColor3 = Color3.fromRGB(160, 165, 195) upgradeUrlHint.TextWrapped = true upgradeUrlHint.TextXAlignment = Enum.TextXAlignment.Left upgradeUrlHint.TextEditable = false upgradeUrlHint.ClearTextOnFocus = false upgradeUrlHint.Text = "" upgradeUrlHint.Parent = upgradeFrame local upgradeContentLayout = Instance.new("UIListLayout") upgradeContentLayout.FillDirection = Enum.FillDirection.Vertical upgradeContentLayout.SortOrder = Enum.SortOrder.LayoutOrder upgradeContentLayout.Padding = UDim.new(0, 8) upgradeContentLayout.Parent = upgradeFrame upgradeLbl.LayoutOrder = 1 upgradeUrlHint.LayoutOrder = 2 local function refreshUpgradeUrlHint() local base = (WEB_APP_URL:gsub("%s+", "")):gsub("/+$", "") local withHash = base .. "#pricing" upgradeUrlHint.Text = withHash end refreshUpgradeUrlHint() local function showUpgradeBanner(visible) upgradeFrame.Visible = visible if visible then upgradeFrame.AutomaticSize = Enum.AutomaticSize.Y else upgradeFrame.AutomaticSize = Enum.AutomaticSize.None upgradeFrame.Size = UDim2.new(1, 0, 0, 0) end end local function isPaymentRequired(errStr) local s = tostring(errStr or "") return string.sub(s, 1, 3) == "402" end local function isBridgeRequired(errStr) local s = tostring(errStr or "") return string.find(s, "BRIDGE_REQUIRED", 1, true) ~= nil or string.sub(s, 1, 3) == "503" end local bridgeOnline = false local function checkBridgeStatus() local tok = getToken and getToken() or AUTH_TOKEN if tok == "" then bridgeOnline = false return false end local ok, body = httpJson(API_BASE .. "/bridge/status", "GET", nil) if not ok then bridgeOnline = false return false end local okd, data = pcall(function() return BB.Http:JSONDecode(body) end) if okd and type(data) == "table" then bridgeOnline = data.online == true else bridgeOnline = false end return bridgeOnline end -- Forward declaration: setOutput is defined later but referenced by -- showBridgeRequiredOutput() above its definition. Declaring it here as an -- upvalue ensures the closure binds to the real function (not a nil global). local setOutput local function showBridgeRequiredOutput() setOutput( "Studio Bridge is not connected.\n\n" .. "LIVE SETUP (same genuine agent as localhost):\n" .. " 1. Download Studio Bridge from bloxbuilder.org/bridge/download\n" .. " 2. Unzip and double-click Start-BloxBuilder-Bridge.bat\n" .. " 3. Paste your API Token in this plugin and click away to save\n" .. " 4. Roblox Studio → Assistant → Manage MCP Servers → Enable Studio MCP\n" .. " 5. Keep Bridge open, then try Generate again.\n" ) end local function getToken() local t = (tokenBox.Text or ""):gsub("^%s+", ""):gsub("%s+$", "") if t ~= "" then return t end if AUTH_TOKEN ~= "" then return AUTH_TOKEN end return "" end -- Job source for /feedback (must match API z.enum). Declared before HTTP helpers. local lastJobSource = "plugin_generate" -- Studio's exception debugger can pause on JSONDecode failures even inside pcall. -- Only call JSONDecode when the payload clearly looks like JSON. local function safeJsonDecode(raw) local s = tostring(raw or ""):gsub("^%s+", ""):gsub("%s+$", "") if s == "" then return nil end local first = s:sub(1, 1) if first ~= "{" and first ~= "[" then return nil end local ok, decoded = pcall(function() return BB.Http:JSONDecode(s) end) if ok and type(decoded) == "table" then return decoded end return nil end local function httpJson(url, method, body) if not isPluginEditMode() then return false, "Plugin HTTP only works in Edit mode (stop Play first)." end local headers = { ["Content-Type"] = "application/json", } local tok = getToken() if tok ~= "" then headers["Authorization"] = "Bearer " .. tok end local ok, res = pcall(function() return BB.Http:RequestAsync({ Url = url, Method = method, Headers = headers, Body = body and BB.Http:JSONEncode(body) or nil, }) end) if not ok then return false, tostring(res) end if not res.Success then local detail = "" if typeof(res.Body) == "string" and res.Body ~= "" then detail = " — " .. res.Body end return false, tostring(res.StatusCode) .. " " .. tostring(res.StatusMessage or "Request failed") .. detail end return true, res.Body end local function sendFeedback(rating, usdPerMonth, note) local payload = { source = lastJobSource, rating = rating, } if typeof(usdPerMonth) == "number" then payload.willingMonthlyUsd = usdPerMonth end if typeof(note) == "string" and note ~= "" then payload.note = note end return httpJson(API_BASE .. "/feedback", "POST", payload) end -- Prompt gets its own vertical scrollbar (not the global/root scrollbar). local promptScroll = Instance.new("ScrollingFrame") promptScroll.BackgroundColor3 = Color3.fromRGB(14, 16, 28) promptScroll.BorderSizePixel = 0 promptScroll.Size = UDim2.new(1, 0, 0, 120) promptScroll.CanvasSize = UDim2.new(0, 0, 0, 0) promptScroll.AutomaticCanvasSize = Enum.AutomaticSize.None promptScroll.ScrollBarThickness = 6 promptScroll.ScrollingDirection = Enum.ScrollingDirection.Y promptScroll.ScrollingEnabled = true promptScroll.Parent = root uiRound(promptScroll, 14) uiStroke(promptScroll, 0.78) local promptBox = Instance.new("TextBox") promptBox.Size = UDim2.new(1, 0, 1, 0) promptBox.ClearTextOnFocus = false promptBox.MultiLine = true promptBox.Text = "" promptBox.TextWrapped = true promptBox.ClipsDescendants = true promptBox.TextXAlignment = Enum.TextXAlignment.Left promptBox.TextYAlignment = Enum.TextYAlignment.Top promptBox.Font = Enum.Font.Gotham promptBox.TextSize = 14 promptBox.TextColor3 = Color3.fromRGB(235, 235, 245) promptBox.PlaceholderText = "Describe your game in one short line (max " .. tostring(PROMPT_MAX_CHARS) .. " chars). Example: Desert quest game with collectibles." promptBox.BackgroundTransparency = 1 promptBox.BorderSizePixel = 0 promptBox.Parent = promptScroll local promptPad = Instance.new("UIPadding") promptPad.PaddingTop = UDim.new(0, 10) promptPad.PaddingBottom = UDim.new(0, 10) promptPad.PaddingLeft = UDim.new(0, 12) promptPad.PaddingRight = UDim.new(0, 12) promptPad.Parent = promptBox local function refreshPromptScroll() local w = math.max(40, promptScroll.AbsoluteSize.X - promptPad.PaddingLeft.Offset - promptPad.PaddingRight.Offset - 6) local text = promptBox.Text if text == "" then text = promptBox.PlaceholderText or "" end local sz = BB.Text:GetTextSize(text, promptBox.TextSize, promptBox.Font, Vector2.new(w, 100000)) local innerH = math.max(promptScroll.AbsoluteSize.Y, math.ceil(sz.Y) + promptPad.PaddingTop.Offset + promptPad.PaddingBottom.Offset + 12) promptBox.Size = UDim2.new(1, 0, 0, innerH) promptScroll.CanvasSize = UDim2.new(0, 0, 0, innerH) end promptScroll:GetPropertyChangedSignal("AbsoluteSize"):Connect(refreshPromptScroll) promptBox:GetPropertyChangedSignal("Text"):Connect(refreshPromptScroll) refreshPromptScroll() local promptHint = Instance.new("TextLabel") promptHint.BackgroundTransparency = 1 promptHint.Size = UDim2.new(1, 0, 0, 0) promptHint.AutomaticSize = Enum.AutomaticSize.Y promptHint.Font = Enum.Font.Gotham promptHint.TextSize = 11 promptHint.TextColor3 = Color3.fromRGB(155, 162, 195) promptHint.TextWrapped = true promptHint.TextXAlignment = Enum.TextXAlignment.Left promptHint.Text = "" promptHint.Visible = false promptHint.Parent = root local function getMode() local ok, v = pcall(function() return plugin:GetSetting(MODE_SETTING_KEY) end) if ok and typeof(v) == "string" and (v == "fast" or v == "full") then return v end return "fast" end local function setMode(mode) if mode ~= "fast" and mode ~= "full" then return end pcall(function() plugin:SetSetting(MODE_SETTING_KEY, mode) end) end local currentMode = getMode() local modeRow = Instance.new("Frame") modeRow.BackgroundTransparency = 1 modeRow.Size = UDim2.new(1, 0, 0, 34) modeRow.Parent = root local modeLayout = Instance.new("UIListLayout") modeLayout.FillDirection = Enum.FillDirection.Horizontal modeLayout.HorizontalAlignment = Enum.HorizontalAlignment.Left modeLayout.SortOrder = Enum.SortOrder.LayoutOrder modeLayout.Padding = UDim.new(0, 8) modeLayout.Parent = modeRow local modeBtn = Instance.new("TextButton") modeBtn.Size = UDim2.new(0, 150, 0, 28) modeBtn.AutoButtonColor = false modeBtn.Font = Enum.Font.GothamSemibold modeBtn.TextSize = 12 modeBtn.TextColor3 = Color3.fromRGB(255, 255, 255) modeBtn.BackgroundColor3 = Color3.fromRGB(60, 64, 90) modeBtn.BorderSizePixel = 0 uiRound(modeBtn, 12) uiStroke(modeBtn, 0.8) modeBtn.Parent = modeRow local modeHelp = Instance.new("TextLabel") modeHelp.BackgroundTransparency = 1 modeHelp.Size = UDim2.new(1, -160, 0, 28) modeHelp.Font = Enum.Font.Gotham modeHelp.TextSize = 11 modeHelp.TextColor3 = Color3.fromRGB(170, 175, 200) modeHelp.TextXAlignment = Enum.TextXAlignment.Left modeHelp.Text = "Fast: ~140 instances. Full: richer world. Keep prompts under " .. tostring(PROMPT_MAX_CHARS) .. " chars (5–20 credits)." modeHelp.Parent = modeRow local function refreshModeUi() if currentMode == "full" then modeBtn.Text = "Mode: FULL (slower)" else modeBtn.Text = "Mode: FAST" end end refreshModeUi() modeBtn.MouseButton1Click:Connect(function() if currentMode == "fast" then currentMode = "full" else currentMode = "fast" end setMode(currentMode) refreshModeUi() end) local buttonRow = Instance.new("Frame") buttonRow.BackgroundTransparency = 1 buttonRow.Size = UDim2.new(1, 0, 0, 36) buttonRow.Parent = root local rowLayout = Instance.new("UIListLayout") rowLayout.FillDirection = Enum.FillDirection.Horizontal rowLayout.HorizontalAlignment = Enum.HorizontalAlignment.Left rowLayout.SortOrder = Enum.SortOrder.LayoutOrder rowLayout.Padding = UDim.new(0, 5) rowLayout.Parent = buttonRow local buttonRow2 = Instance.new("Frame") buttonRow2.BackgroundTransparency = 1 buttonRow2.Size = UDim2.new(1, 0, 0, 36) buttonRow2.Parent = root local rowLayout2 = Instance.new("UIListLayout") rowLayout2.FillDirection = Enum.FillDirection.Horizontal rowLayout2.HorizontalAlignment = Enum.HorizontalAlignment.Left rowLayout2.SortOrder = Enum.SortOrder.LayoutOrder rowLayout2.Padding = UDim.new(0, 5) rowLayout2.Parent = buttonRow2 local function makeBtn(text, bg) local b = Instance.new("TextButton") b.Size = UDim2.new(0, 76, 0, 34) b.AutoButtonColor = false b.Text = text b.Font = Enum.Font.GothamSemibold b.TextSize = 11 b.TextColor3 = Color3.fromRGB(255, 255, 255) b.BackgroundColor3 = bg b.BorderSizePixel = 0 uiRound(b, 12) uiStroke(b, 0.75) return b end local genBtn = makeBtn("Generate", Color3.fromRGB(124, 58, 237)) genBtn.Parent = buttonRow local previewBtn = makeBtn("Preview", Color3.fromRGB(56, 189, 248)) previewBtn.Parent = buttonRow local applyBtn = makeBtn("Apply", Color3.fromRGB(99, 102, 241)) applyBtn.Parent = buttonRow local improveBtn = makeBtn("Improve v", Color3.fromRGB(34, 211, 238)) improveBtn.Parent = buttonRow local stopBtn = makeBtn("Stop", Color3.fromRGB(60, 64, 90)) stopBtn.Parent = buttonRow local undoBtn = makeBtn("Undo", Color3.fromRGB(60, 64, 90)) undoBtn.Parent = buttonRow2 local redoBtn = makeBtn("Redo", Color3.fromRGB(60, 64, 90)) redoBtn.Parent = buttonRow2 local autoFixBtn = makeBtn("Auto Fix", Color3.fromRGB(234, 179, 8)) autoFixBtn.Parent = buttonRow2 local snapshotBtn = makeBtn("Snapshot", Color3.fromRGB(60, 64, 90)) snapshotBtn.Parent = buttonRow2 local clearBtn = makeBtn("Reset", Color3.fromRGB(239, 68, 68)) clearBtn.Parent = buttonRow2 genBtn.LayoutOrder = 1 previewBtn.LayoutOrder = 2 applyBtn.LayoutOrder = 3 improveBtn.LayoutOrder = 4 stopBtn.LayoutOrder = 5 undoBtn.LayoutOrder = 1 redoBtn.LayoutOrder = 2 autoFixBtn.LayoutOrder = 3 snapshotBtn.LayoutOrder = 4 clearBtn.LayoutOrder = 5 -- Replace Previous toggle local replaceRow = Instance.new("Frame") replaceRow.BackgroundTransparency = 1 replaceRow.Size = UDim2.new(1, 0, 0, 26) replaceRow.Parent = root local replaceLayout = Instance.new("UIListLayout") replaceLayout.FillDirection = Enum.FillDirection.Horizontal replaceLayout.HorizontalAlignment = Enum.HorizontalAlignment.Left replaceLayout.SortOrder = Enum.SortOrder.LayoutOrder replaceLayout.Padding = UDim.new(0, 8) replaceLayout.Parent = replaceRow local replaceToggle = Instance.new("TextButton") replaceToggle.Size = UDim2.new(0, 220, 0, 24) replaceToggle.AutoButtonColor = false replaceToggle.Font = Enum.Font.GothamMedium replaceToggle.TextSize = 12 replaceToggle.TextColor3 = Color3.fromRGB(235, 235, 245) replaceToggle.BackgroundColor3 = Color3.fromRGB(14, 16, 28) replaceToggle.BorderSizePixel = 0 uiRound(replaceToggle, 12) uiStroke(replaceToggle, 0.82) replaceToggle.Parent = replaceRow local replacePrevious = true local function refreshReplaceToggle() replaceToggle.Text = (replacePrevious and "[x] " or "[ ] ") .. "Replace Previous Generation" end refreshReplaceToggle() replaceToggle.MouseButton1Click:Connect(function() replacePrevious = not replacePrevious refreshReplaceToggle() end) -- Improve scope picker (partial regeneration) local improveMenu = Instance.new("Frame") improveMenu.BackgroundColor3 = Color3.fromRGB(14, 16, 28) improveMenu.BorderSizePixel = 0 improveMenu.Size = UDim2.new(1, 0, 0, 0) improveMenu.AutomaticSize = Enum.AutomaticSize.Y improveMenu.Visible = false uiRound(improveMenu, 10) uiStroke(improveMenu, 0.82) improveMenu.Parent = root local improveMenuLayout = Instance.new("UIListLayout") improveMenuLayout.Padding = UDim.new(0, 4) improveMenuLayout.Parent = improveMenu local improveMenuPad = Instance.new("UIPadding") improveMenuPad.PaddingTop = UDim.new(0, 6) improveMenuPad.PaddingBottom = UDim.new(0, 6) improveMenuPad.PaddingLeft = UDim.new(0, 8) improveMenuPad.PaddingRight = UDim.new(0, 8) improveMenuPad.Parent = improveMenu for _, scope in ipairs(Editor.IMPROVE_SCOPES) do local item = Instance.new("TextButton") item.Size = UDim2.new(1, 0, 0, 26) item.AutoButtonColor = false item.BackgroundColor3 = Color3.fromRGB(24, 28, 44) item.Font = Enum.Font.Gotham item.TextSize = 12 item.TextColor3 = Color3.fromRGB(230, 235, 250) item.Text = scope.label item.BorderSizePixel = 0 uiRound(item, 8) item.Parent = improveMenu item.MouseButton1Click:Connect(function() selectedImproveScope = scope.id improveMenu.Visible = false improveMenuOpen = false if type(runPartialImprove) == "function" then runPartialImprove(scope.id, scope.label) end end) end -- Build health + progress panel local healthFrame = Instance.new("Frame") healthFrame.BackgroundColor3 = Color3.fromRGB(12, 14, 24) healthFrame.BorderSizePixel = 0 healthFrame.Size = UDim2.new(1, 0, 0, 110) uiRound(healthFrame, 12) uiStroke(healthFrame, 0.82) healthFrame.Parent = root healthLabel = Instance.new("TextLabel") healthLabel.BackgroundTransparency = 1 healthLabel.Size = UDim2.new(1, -12, 1, -8) healthLabel.Position = UDim2.fromOffset(6, 4) healthLabel.Font = Enum.Font.Code healthLabel.TextSize = 11 healthLabel.TextColor3 = Color3.fromRGB(185, 195, 220) healthLabel.TextXAlignment = Enum.TextXAlignment.Left healthLabel.TextYAlignment = Enum.TextYAlignment.Top healthLabel.TextWrapped = true healthLabel.Text = Editor.formatHealthPanel(nil) healthLabel.Parent = healthFrame local historyFrame = Instance.new("Frame") historyFrame.BackgroundColor3 = Color3.fromRGB(12, 14, 24) historyFrame.BorderSizePixel = 0 historyFrame.Size = UDim2.new(1, 0, 0, 0) historyFrame.AutomaticSize = Enum.AutomaticSize.Y historyFrame.ClipsDescendants = true uiRound(historyFrame, 12) uiStroke(historyFrame, 0.82) historyFrame.Parent = root local historyPad = Instance.new("UIPadding") historyPad.PaddingTop = UDim.new(0, 8) historyPad.PaddingBottom = UDim.new(0, 8) historyPad.PaddingLeft = UDim.new(0, 8) historyPad.PaddingRight = UDim.new(0, 8) historyPad.Parent = historyFrame local historyLayout = Instance.new("UIListLayout") historyLayout.FillDirection = Enum.FillDirection.Vertical historyLayout.SortOrder = Enum.SortOrder.LayoutOrder historyLayout.Padding = UDim.new(0, 8) historyLayout.Parent = historyFrame historyLabel = Instance.new("TextLabel") historyLabel.BackgroundTransparency = 1 historyLabel.Size = UDim2.new(1, 0, 0, 0) historyLabel.AutomaticSize = Enum.AutomaticSize.Y historyLabel.LayoutOrder = 1 historyLabel.Font = Enum.Font.Gotham historyLabel.TextSize = 11 historyLabel.TextColor3 = Color3.fromRGB(170, 180, 205) historyLabel.TextXAlignment = Enum.TextXAlignment.Left historyLabel.TextYAlignment = Enum.TextYAlignment.Top historyLabel.TextWrapped = true historyLabel.Text = "History - versions appear after Generate." historyLabel.Parent = historyFrame refreshHistoryPanel = function() if type(uiState) ~= "table" then return end local lines = { "-- Build History --" } for i, v in ipairs(versionStore.versions) do if i > 6 then break end local marker = (v.id == uiState.selectedVersionId) and "> " or " " table.insert(lines, marker .. Editor.formatVersionLine(v)) end if #lines == 1 then table.insert(lines, "No saved versions yet.") end historyLabel.Text = table.concat(lines, "\n") end local function selectLatestVersion() if #versionStore.versions > 0 then uiState.selectedVersionId = versionStore.versions[1].id end end local function restoreSelectedVersion() local id = uiState.selectedVersionId if not id then selectLatestVersion(); id = uiState.selectedVersionId end if not id then showToast("No version selected") return end local v = versionStore:get(id) if not v then showToast("Version not found") return end if v.projectJson then setLastProjectJson(v.projectJson) end if v.decoded then uiState.lastDecoded = v.decoded; uiState.canApply = true end if v.luau then uiState.lastLuau = v.luau; uiState.canApply = true end uiState.workflowStage = "preview" showToast("Version restored to editor") refreshActionStates() end local function duplicateSelectedVersion() local id = uiState.selectedVersionId if not id then selectLatestVersion(); id = uiState.selectedVersionId end if not id then return end local v = versionStore:get(id) if not v then return end local copy = { id = Editor.makeVersionId(), createdAt = os.date("!%Y-%m-%dT%H:%M:%SZ"), prompt = v.prompt, summary = (v.summary or "Build") .. " (copy)", validationScore = v.validationScore, projectJson = v.projectJson, decoded = v.decoded, luau = v.luau, } versionStore:add(copy) refreshHistoryPanel() pcall(function() httpJson(API_BASE .. "/builds/versions/" .. tostring(id) .. "/duplicate", "POST", {}) end) showToast("Version duplicated") end local function compareSelectedVersion() local id = uiState.selectedVersionId if not id or #versionStore.versions < 2 then showToast("Need 2+ versions") return end local a = versionStore:get(id) local b = versionStore.versions[2] if not a or not b then return end local cmp = Editor.compareVersionSummaries(a, b) setTab("preview") setPreview(("Compare\nA: %s\nB: %s\nValidation delta: %s\nSame JSON: %s"):format( tostring(cmp.promptA), tostring(cmp.promptB), tostring(cmp.validationDelta), tostring(cmp.sameProject) )) end local function reapplySelectedVersion() restoreSelectedVersion() applyToGame() end local historyActions = Instance.new("Frame") historyActions.BackgroundTransparency = 1 historyActions.Size = UDim2.new(1, 0, 0, 28) historyActions.LayoutOrder = 2 historyActions.Parent = historyFrame local historyActionsLayout = Instance.new("UIListLayout") historyActionsLayout.FillDirection = Enum.FillDirection.Horizontal historyActionsLayout.HorizontalAlignment = Enum.HorizontalAlignment.Left historyActionsLayout.SortOrder = Enum.SortOrder.LayoutOrder historyActionsLayout.Padding = UDim.new(0, 5) historyActionsLayout.Parent = historyActions local function makeHistoryBtn(label, order) local b = makeBtn(label, Color3.fromRGB(50, 54, 78)) b.Size = UDim2.new(0, 0, 0, 26) b.AutomaticSize = Enum.AutomaticSize.X local pad = Instance.new("UIPadding") pad.PaddingLeft = UDim.new(0, 8) pad.PaddingRight = UDim.new(0, 8) pad.Parent = b b.TextSize = 10 b.LayoutOrder = order b.Parent = historyActions return b end makeHistoryBtn("Restore", 1).MouseButton1Click:Connect(restoreSelectedVersion) makeHistoryBtn("Reapply", 2).MouseButton1Click:Connect(reapplySelectedVersion) makeHistoryBtn("Duplicate", 3).MouseButton1Click:Connect(duplicateSelectedVersion) makeHistoryBtn("Compare", 4).MouseButton1Click:Connect(compareSelectedVersion) refreshHistoryPanel() -- Feedback panel (shown after a successful insert) -- Feedback UI removed. -- Fixed-height output (scale Y was filling the dock and left a giant empty panel). Read-only TextBox scrolls + selects for Ctrl+C. local OUT_TEXT_PX = 168 local outFrame = Instance.new("Frame") outFrame.BackgroundColor3 = Color3.fromRGB(12, 14, 24) outFrame.BorderSizePixel = 0 outFrame.Size = UDim2.new(1, 0, 0, 0) outFrame.AutomaticSize = Enum.AutomaticSize.Y outFrame.ClipsDescendants = true uiRound(outFrame, 14) uiStroke(outFrame, 0.82) outFrame.Parent = root local outFramePad = Instance.new("UIPadding") outFramePad.PaddingTop = UDim.new(0, 8) outFramePad.PaddingBottom = UDim.new(0, 8) outFramePad.PaddingLeft = UDim.new(0, 10) outFramePad.PaddingRight = UDim.new(0, 10) outFramePad.Parent = outFrame local outVLayout = Instance.new("UIListLayout") outVLayout.FillDirection = Enum.FillDirection.Vertical outVLayout.SortOrder = Enum.SortOrder.LayoutOrder outVLayout.Padding = UDim.new(0, 6) outVLayout.Parent = outFrame local outToolbar = Instance.new("Frame") outToolbar.BackgroundTransparency = 1 outToolbar.Size = UDim2.new(1, 0, 0, 24) outToolbar.Parent = outFrame local outTitle = Instance.new("TextLabel") outTitle.BackgroundTransparency = 1 outTitle.Size = UDim2.new(0.55, 0, 1, 0) outTitle.Position = UDim2.new(0, 0, 0, 0) outTitle.Font = Enum.Font.GothamSemibold outTitle.TextSize = 12 outTitle.TextColor3 = Color3.fromRGB(200, 205, 235) outTitle.TextXAlignment = Enum.TextXAlignment.Left outTitle.Text = "Output" outTitle.Parent = outToolbar -- Tabs: Preview (default) + Code local tabBar = Instance.new("Frame") tabBar.BackgroundTransparency = 1 tabBar.Size = UDim2.new(0, 190, 0, 22) tabBar.Position = UDim2.new(0, 54, 0, 1) tabBar.Parent = outToolbar local tabLayout = Instance.new("UIListLayout") tabLayout.FillDirection = Enum.FillDirection.Horizontal tabLayout.HorizontalAlignment = Enum.HorizontalAlignment.Left tabLayout.VerticalAlignment = Enum.VerticalAlignment.Center tabLayout.SortOrder = Enum.SortOrder.LayoutOrder tabLayout.Padding = UDim.new(0, 6) tabLayout.Parent = tabBar local function makeTab(text) local b = Instance.new("TextButton") b.Size = UDim2.new(0, 88, 0, 22) b.AutoButtonColor = false b.Font = Enum.Font.GothamMedium b.TextSize = 11 b.TextColor3 = Color3.fromRGB(235, 235, 250) b.Text = text b.BackgroundColor3 = Color3.fromRGB(45, 48, 72) b.BorderSizePixel = 0 uiRound(b, 8) return b end local previewTabBtn = makeTab("Preview") previewTabBtn.Parent = tabBar local codeTabBtn = makeTab("Code") codeTabBtn.Parent = tabBar local btnStack = Instance.new("Frame") btnStack.BackgroundTransparency = 1 -- Copy buttons removed; keep an empty placeholder with zero width. btnStack.Size = UDim2.new(0, 0, 0, 22) btnStack.Position = UDim2.new(1, -4, 0, 0) btnStack.AnchorPoint = Vector2.new(1, 0) btnStack.Parent = outToolbar local stackLayout = Instance.new("UIListLayout") stackLayout.FillDirection = Enum.FillDirection.Horizontal stackLayout.HorizontalAlignment = Enum.HorizontalAlignment.Right stackLayout.VerticalAlignment = Enum.VerticalAlignment.Center stackLayout.Padding = UDim.new(0, 6) stackLayout.Parent = btnStack local copyOutputBtn = Instance.new("TextButton") copyOutputBtn.Size = UDim2.new(0, 92, 0, 22) copyOutputBtn.AutoButtonColor = false copyOutputBtn.Font = Enum.Font.GothamMedium copyOutputBtn.TextSize = 11 copyOutputBtn.TextColor3 = Color3.fromRGB(235, 235, 250) copyOutputBtn.Text = "Copy output" copyOutputBtn.BackgroundColor3 = Color3.fromRGB(45, 48, 72) copyOutputBtn.BorderSizePixel = 0 uiRound(copyOutputBtn, 8) copyOutputBtn.Parent = btnStack copyOutputBtn.Visible = false local copyPromptBtn = Instance.new("TextButton") copyPromptBtn.Size = UDim2.new(0, 94, 0, 22) copyPromptBtn.AutoButtonColor = false copyPromptBtn.Font = Enum.Font.GothamMedium copyPromptBtn.TextSize = 11 copyPromptBtn.TextColor3 = Color3.fromRGB(235, 235, 250) copyPromptBtn.Text = "Copy prompt" copyPromptBtn.BackgroundColor3 = Color3.fromRGB(45, 48, 72) copyPromptBtn.BorderSizePixel = 0 uiRound(copyPromptBtn, 8) copyPromptBtn.Parent = btnStack copyPromptBtn.Visible = false -- Output gets its own vertical scrollbar. local outContent = Instance.new("ScrollingFrame") outContent.BackgroundColor3 = Color3.fromRGB(8, 10, 18) outContent.BorderSizePixel = 0 outContent.Size = UDim2.new(1, 0, 0, OUT_TEXT_PX) outContent.CanvasSize = UDim2.new(0, 0, 0, 0) outContent.AutomaticCanvasSize = Enum.AutomaticSize.None outContent.ScrollBarThickness = 6 outContent.ScrollingDirection = Enum.ScrollingDirection.Y outContent.ScrollingEnabled = true outContent.Parent = outFrame uiRound(outContent, 10) uiStroke(outContent, 0.75) local previewBox = Instance.new("TextLabel") previewBox.Size = UDim2.new(1, 0, 1, 0) previewBox.BackgroundTransparency = 1 previewBox.Text = "" previewBox.TextWrapped = true previewBox.TextYAlignment = Enum.TextYAlignment.Top previewBox.TextXAlignment = Enum.TextXAlignment.Left previewBox.Font = Enum.Font.Gotham previewBox.TextSize = 13 previewBox.TextColor3 = Color3.fromRGB(220, 225, 245) previewBox.BorderSizePixel = 0 previewBox.Parent = outContent local outBox = Instance.new("TextBox") outBox.Size = UDim2.new(1, 0, 0, OUT_TEXT_PX) outBox.BackgroundTransparency = 1 outBox.Text = "" outBox.PlaceholderText = "Code + log..." outBox.ClearTextOnFocus = false outBox.MultiLine = true outBox.TextWrapped = true outBox.TextEditable = false outBox.Font = Enum.Font.Code outBox.TextSize = 13 outBox.TextColor3 = Color3.fromRGB(220, 225, 245) outBox.TextXAlignment = Enum.TextXAlignment.Left outBox.TextYAlignment = Enum.TextYAlignment.Top outBox.ClipsDescendants = true outBox.BorderSizePixel = 0 outBox.Visible = false outBox.Parent = outContent local outPad = Instance.new("UIPadding") outPad.PaddingTop = UDim.new(0, 8) outPad.PaddingBottom = UDim.new(0, 8) outPad.PaddingLeft = UDim.new(0, 10) outPad.PaddingRight = UDim.new(0, 10) outPad.Parent = outContent local function refreshOutScroll() local w = math.max(40, outContent.AbsoluteSize.X - outPad.PaddingLeft.Offset - outPad.PaddingRight.Offset - 6) local text = outBox.Text if text == "" then text = outBox.PlaceholderText or "" end local sz = BB.Text:GetTextSize(text, outBox.TextSize, outBox.Font, Vector2.new(w, 100000)) local innerH = math.max(outContent.AbsoluteSize.Y, math.ceil(sz.Y) + outPad.PaddingTop.Offset + outPad.PaddingBottom.Offset + 12) outBox.Size = UDim2.new(1, 0, 0, innerH) previewBox.Size = UDim2.new(1, 0, 0, innerH) outContent.CanvasSize = UDim2.new(0, 0, 0, innerH) end outContent:GetPropertyChangedSignal("AbsoluteSize"):Connect(refreshOutScroll) outBox:GetPropertyChangedSignal("Text"):Connect(refreshOutScroll) refreshOutScroll() local outCopyHint = Instance.new("TextLabel") outCopyHint.BackgroundTransparency = 1 outCopyHint.Size = UDim2.new(1, 0, 0, 0) outCopyHint.AutomaticSize = Enum.AutomaticSize.Y outCopyHint.Font = Enum.Font.Gotham outCopyHint.TextSize = 10 outCopyHint.TextColor3 = Color3.fromRGB(130, 135, 165) outCopyHint.TextXAlignment = Enum.TextXAlignment.Left outCopyHint.TextWrapped = true local DEFAULT_COPY_HINT = "Tip: select text in a field, then press Ctrl+C." outCopyHint.Text = DEFAULT_COPY_HINT outCopyHint.Parent = outFrame local function copyToClipboard(text) text = tostring(text or "") if text == "" then return false end -- Studio supports setclipboard() in plugin/editor contexts. if typeof(setclipboard) == "function" then local ok = pcall(function() setclipboard(text) end) return ok end return false end local function flashCopyHint(message) outCopyHint.Text = message task.delay(2.0, function() -- Avoid stomping other updates if this label is repurposed later. if outCopyHint.Text == message then outCopyHint.Text = DEFAULT_COPY_HINT end end) end -- Copy buttons removed (select text + Ctrl+C instead). function setOutput(t) -- Keep the output scrolled to bottom during streaming unless the user is actively selecting text. local oldText = outBox.Text or "" local oldCursor = outBox.CursorPosition local oldSel = outBox.SelectionStart local wasAtEnd = false if typeof(oldCursor) == "number" and oldCursor > 0 then wasAtEnd = oldCursor >= (#oldText - 2) end outBox.Text = t or "" -- If the user isn't actively selecting, auto-follow the tail (log-like). local selecting = typeof(oldSel) == "number" and oldSel ~= -1 and oldSel ~= oldCursor if not selecting and wasAtEnd then local n = #(outBox.Text or "") outBox.CursorPosition = n + 1 outBox.SelectionStart = n + 1 -- Also scroll the container to the bottom (visible scrollbar). if outContent and outContent:IsA("ScrollingFrame") then outContent.CanvasPosition = Vector2.new(0, math.max(0, outContent.CanvasSize.Y.Offset - outContent.AbsoluteSize.Y)) end else -- Best-effort: keep the prior cursor where it was. if typeof(oldCursor) == "number" and oldCursor > 0 then outBox.CursorPosition = math.min(oldCursor, #(outBox.Text or "") + 1) end if typeof(oldSel) == "number" and oldSel > 0 then outBox.SelectionStart = math.min(oldSel, #(outBox.Text or "") + 1) end end end -- Single-view UI: Preview + Code tabs for Generate → Validate → Preview → Apply flow. local activeTab = "code" local function setTab(tab) activeTab = tab or "code" if tab == "preview" then previewBox.Visible = true outBox.Visible = false previewTabBtn.Text = "* Preview" codeTabBtn.Text = "Code" else previewBox.Visible = false outBox.Visible = true previewTabBtn.Text = "Preview" codeTabBtn.Text = "* Code" end end previewTabBtn.Visible = true codeTabBtn.Visible = true previewTabBtn.MouseButton1Click:Connect(function() setTab("preview") if healthLabel then healthLabel.Text = Editor.formatPreviewPanel(uiState, uiState.lastHealth) end setPreview(Editor.formatPreviewPanel(uiState, uiState.lastHealth)) end) codeTabBtn.MouseButton1Click:Connect(function() setTab("code") end) setTab("code") setPreview = function(text) previewBox.Text = text or "" end local function setOutOfCreditsOutput(rawErr) showUpgradeBanner(true) local detail = tostring(rawErr or "") setOutput( "Not enough credits\n\n" .. "Your balance can't cover this generation. On the website, sign in and upgrade to Pro or Ultra (Stripe Checkout in the browser). After you subscribe, the billing portal is where you update payment details, download invoices, cancel, or turn off renewal - same API token in this plugin.\n\n" .. "Technical: " .. detail ) end local cancelled = false -- Studio log capture (for auto-fix) local LOG_RING_MAX = 220 local logRing = {} :: { string } local errRing = {} :: { string } local logWatchEnabled = false local logWatchUntil = 0 local lastErrorAt = 0 local autoFixInProgress = false local autoFixAttempts = 0 local lastProcessedErrCount = 0 local lastAutoFixTriggerAt = 0 local autoFixHeartbeatConn = nil -- Forward declare so Heartbeat/task.delay closures resolve to this local (not a nil global). local runAutoFixIfNeeded -- Used by persisted JSON helpers below; must appear before getLastProjectJson (Lua local visibility). local function trim(s) return tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", "") end -- Declared before runAutoFixIfNeeded: that function calls us from Heartbeat while this line is still -- "above" the old local function site — Lua would otherwise resolve getLastProjectJson as a nil global. local function getLastProjectJson() local ok, v = pcall(function() return plugin:GetSetting(PROJECT_SETTING_KEY) end) if ok and typeof(v) == "string" and trim(v) ~= "" then return v end return nil end local function setLastProjectJson(projectJson) if typeof(projectJson) ~= "string" then return end -- Allow empty string to clear persisted JSON (Reset must not leave stale improve context). if trim(projectJson) == "" then pcall(function() plugin:SetSetting(PROJECT_SETTING_KEY, "") end) return end pcall(function() plugin:SetSetting(PROJECT_SETTING_KEY, projectJson) end) end local function pushRing(t, s) table.insert(t, s) while #t > LOG_RING_MAX do table.remove(t, 1) end end local function formatLogLine(message, messageType) local kind = tostring(messageType or "") local prefix = "LOG" if kind:find("Error") then prefix = "ERR" elseif kind:find("Warning") then prefix = "WARN" end return os.date("!%H:%M:%S") .. " [" .. prefix .. "] " .. tostring(message or "") end BB.LogSvc.MessageOut:Connect(function(message, messageType) local line = formatLogLine(message, messageType) pushRing(logRing, line) if tostring(messageType):find("Error") then local msgStr = tostring(message or "") -- Never auto-fix BloxBuilder's own plugin errors (causes ghost "Studio Agent done" in chat). if msgStr:find("PluginDebugService", 1, true) or msgStr:find("BloxBuilder.plugin", 1, true) or msgStr:find("user_BloxBuilder", 1, true) then return end pushRing(errRing, line) lastErrorAt = tick() end end) local function stopLogWatch() logWatchEnabled = false logWatchUntil = 0 lastProcessedErrCount = #errRing if autoFixHeartbeatConn then pcall(function() autoFixHeartbeatConn:Disconnect() end) autoFixHeartbeatConn = nil end end local function startLogWatch() logWatchEnabled = true logWatchUntil = tick() + AUTO_FIX_WINDOW_SEC lastProcessedErrCount = #errRing autoFixAttempts = 0 autoFixInProgress = false lastAutoFixTriggerAt = 0 if not autoFixHeartbeatConn then autoFixHeartbeatConn = BB.Run.Heartbeat:Connect(function() -- Keep it lightweight; runAutoFixIfNeeded returns quickly when idle. runAutoFixIfNeeded() end) end end local function makeAutoFixRequest(errorLines) return table.concat({ "Fix runtime errors and keep gameplay the same.", "", "ERROR_LOG:", errorLines, "", "CONSTRAINTS:", "- Do not introduce obby/parkour/lava/finish/checkpoints unless explicitly requested.", "- Ensure every referenced Workspace object exists at the exact path/name; create missing Folders/Parts/Models if needed.", "- Avoid workspace.Foo direct indexing; use workspace:WaitForChild(\"Foo\", 10) with nil-check + create-if-missing.", "- Never call methods/events on nil; guard all WaitForChild results.", "- Use only valid Enum.Material values; never use Enum.Material.Gold (simulate with Metal + yellow color).", }, "\n") end runAutoFixIfNeeded = function() if not AUTO_FIX_ENABLED then return end if uiState.isGenerating then return end local staticErrors = runCompileValidationPass() if #staticErrors > 0 and not autoFixInProgress and autoFixAttempts < AUTO_FIX_MAX_ATTEMPTS then for _, line in ipairs(staticErrors) do table.insert(errRing, line) end lastErrorAt = tick() end if not logWatchEnabled or tick() > logWatchUntil then stopLogWatch() return end if autoFixInProgress then return end if autoFixAttempts >= AUTO_FIX_MAX_ATTEMPTS then stopLogWatch() return end if #errRing <= lastProcessedErrCount then return end -- Debounce bursts of errors. if (tick() - lastErrorAt) < AUTO_FIX_DEBOUNCE_SEC then return end if (tick() - lastAutoFixTriggerAt) < AUTO_FIX_DEBOUNCE_SEC then return end local projectJson = getLastProjectJson() if not projectJson then stopLogWatch() return end autoFixInProgress = true autoFixAttempts += 1 lastAutoFixTriggerAt = tick() lastProcessedErrCount = #errRing local startIdx = math.max(1, #errRing - 20) local slice = {} for i = startIdx, #errRing do table.insert(slice, errRing[i]) end local reqText = makeAutoFixRequest(table.concat(slice, "\n")) showToast("Auto-fix: improving...") uiState.isGenerating = true uiState.canApply = false uiState.lastRawOutput = nil uiState.lastProjectJson = nil uiState.lastDecoded = nil uiState.lastLuau = nil refreshActionStates() local ok, body = httpJson(API_BASE .. "/improve-job", "POST", { format = "json", projectJson = projectJson, request = reqText, mode = currentMode, }) if not ok then uiState.isGenerating = false refreshActionStates() autoFixInProgress = false showToast("Auto-fix failed") return end local decoded = safeJsonDecode(body) local jobId = decoded and decoded.jobId or nil if not jobId then uiState.isGenerating = false refreshActionStates() autoFixInProgress = false showToast("Auto-fix failed") return end task.spawn(function() pollJob(jobId, "Auto-fix job started.\nApplying a fix for runtime errors...", "plugin_autofix") uiState.isGenerating = false refreshActionStates() if uiState.canApply then showToast("Auto-fix: re-applying...") applyToGame() end autoFixInProgress = false showToast("Auto-fix finished") end) end local function triggerAutoFix(force) if not AUTO_FIX_ENABLED then return false end if autoFixInProgress then return true end if force and #errRing > 0 then lastProcessedErrCount = math.max(0, #errRing - 1) end runAutoFixIfNeeded() return autoFixInProgress end -- uiState initialized near top (before history panel); refresh action button states. local function setButtonEnabled(btn, enabled) btn.Active = enabled btn.AutoButtonColor = false btn.TextTransparency = enabled and 0 or 0.35 btn.BackgroundTransparency = enabled and 0 or 0.25 end local function refreshActionStates() local g = not uiState.isGenerating setButtonEnabled(genBtn, g) setButtonEnabled(improveBtn, g and uiState.canApply) setButtonEnabled(previewBtn, (not uiState.isGenerating) and uiState.canApply) setButtonEnabled(undoBtn, g) setButtonEnabled(redoBtn, g) setButtonEnabled(autoFixBtn, g and uiState.canApply) setButtonEnabled(snapshotBtn, g) setButtonEnabled(clearBtn, true) setButtonEnabled(stopBtn, uiState.isGenerating) setButtonEnabled(applyBtn, (not uiState.isGenerating) and uiState.canApply) if chatHandles then chatHandles.setGenerating(uiState.isGenerating) end end refreshActionStates() -- Toast local toast = Instance.new("Frame") toast.BackgroundColor3 = Color3.fromRGB(20, 24, 40) toast.BorderSizePixel = 0 toast.Size = UDim2.new(1, 0, 0, 34) toast.AnchorPoint = Vector2.new(0.5, 0) toast.Position = UDim2.new(0.5, 0, 0, -40) toast.Visible = true uiRound(toast, 12) uiStroke(toast, 0.8) toast.Parent = root local toastLabel = Instance.new("TextLabel") toastLabel.BackgroundTransparency = 1 toastLabel.Size = UDim2.new(1, -20, 1, 0) toastLabel.Position = UDim2.new(0, 10, 0, 0) toastLabel.Font = Enum.Font.GothamMedium toastLabel.TextSize = 12 toastLabel.TextColor3 = Color3.fromRGB(235, 235, 245) toastLabel.TextXAlignment = Enum.TextXAlignment.Left toastLabel.Text = "" toastLabel.Parent = toast local toastTween = nil -- Assigned (not `local function`) so earlier locals like runAutoFixIfNeeded close over this upvalue. showToast = function(msg) toastLabel.Text = tostring(msg or "") if toastTween then pcall(function() toastTween:Cancel() end) end toast.Position = UDim2.new(0.5, 0, 0, -40) local tIn = BB.Tween:Create(toast, TweenInfo.new(0.18, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), { Position = UDim2.new(0.5, 0, 0, 0), }) tIn:Play() toastTween = tIn task.delay(1.6, function() local tOut = BB.Tween:Create(toast, TweenInfo.new(0.22, Enum.EasingStyle.Quad, Enum.EasingDirection.In), { Position = UDim2.new(0.5, 0, 0, -40), }) tOut:Play() toastTween = tOut end) end -- Feedback UI removed. -- Luau allows at most 200 locals per function; build helpers run in an IIFE (own scope). local Build = (function() -- True when the worker output is plain Luau (gameplay stream), not project JSON. -- Must run before extractJsonBlob: Lua table literals use { } and would fool brace scanning. local function looksLikeLuauSource(raw) local s = tostring(raw or ""):gsub("^%s+", ""):gsub("%s+$", "") if s == "" then return false end -- Our project JSON always starts with "{" at the first non-space char. if s:sub(1, 1) == "{" then return false end if s:match("^%-%-") then return true end if s:match("^local") then return true end return false end local function extractJsonBlob(raw) local s = tostring(raw or "") local inner = s:match("```json%s*(.-)%s*```") or s:match("```%s*(.-)%s*```") if inner then s = inner end local startIdx = s:find("{", 1, true) if not startIdx then return nil end local depth = 0 for i = startIdx, #s do local c = s:sub(i, i) if c == "{" then depth += 1 elseif c == "}" then depth -= 1 if depth == 0 then return s:sub(startIdx, i) end end end return nil end -- Prefer showing real Luau in the UI by extracting scripts[].code from the project JSON. -- Returns nil if the JSON is incomplete/invalid or contains no scripts. local function tryExtractLuau(raw) if looksLikeLuauSource(raw) then local s = tostring(raw or ""):gsub("%s+$", "") return (s ~= "" and s) or nil end local blob = extractJsonBlob(raw) if not blob then -- Luau-only mode: raw is already Luau (no leading "local", e.g. starts with "game:GetService"). local s = tostring(raw or ""):gsub("%s+$", "") if s == "" then return nil end if s:find("\n") or s:find("local ", 1, true) or s:find("function ", 1, true) then return s end return nil end local ok, decoded = pcall(function() return BB.Http:JSONDecode(blob) end) if not ok or type(decoded) ~= "table" then return nil end local scripts = decoded.scripts if type(scripts) ~= "table" or #scripts == 0 then return nil end local out = "" for _, s in ipairs(scripts) do if type(s) == "table" and type(s.code) == "string" and s.code ~= "" then local p = tostring(s.path or "") local n = tostring(s.name or "Script") local k = tostring(s.kind or "Script") out ..= ("-- %s (%s)\n"):format((p ~= "" and (p .. "/") or "") .. n, k) out ..= s.code if not out:match("\n$") then out ..= "\n" end out ..= "\n" end end out = out:gsub("%s+$", "") if out == "" then return nil end return out end -- Best-effort extraction of scripts[].code while JSON is still streaming/incomplete. -- Returns nil until it can find at least one "code":"..." string. local function tryExtractLuauStreaming(raw) local s = tostring(raw or "") if looksLikeLuauSource(s) then local t = s:gsub("%s+$", "") return (t ~= "" and t) or nil end -- Prefer extracting from the JSON blob if present; otherwise treat raw as plain text stream. local blob = extractJsonBlob(s) or s local codes = {} local i = 1 while true do local keyStart = blob:find([["code"]], i, true) if not keyStart then break end local colon = blob:find(":", keyStart + 6, true) if not colon then break end local q = blob:find('"', colon + 1, true) if not q then break end -- Scan a JSON string, allowing incomplete tail (no closing quote yet). local j = q + 1 local out = {} while j <= #blob do local c = blob:sub(j, j) if c == '"' then -- End of string j += 1 break elseif c == "\\" then local n = blob:sub(j + 1, j + 1) if n == "" then break end if n == "n" then table.insert(out, "\n") j += 2 elseif n == "r" then table.insert(out, "\r") j += 2 elseif n == "t" then table.insert(out, "\t") j += 2 elseif n == "\\" or n == '"' or n == "/" then table.insert(out, n) j += 2 elseif n == "u" then -- Skip \uXXXX sequences (rare in Luau). Keep them as-is if incomplete. local hex = blob:sub(j + 2, j + 5) if #hex < 4 or not hex:match("^[0-9a-fA-F]+$") then break end -- Preserve as raw sequence to avoid unicode issues in Studio textbox. table.insert(out, "\\u" .. hex) j += 6 else -- Unknown escape; include literal char and continue. table.insert(out, n) j += 2 end else table.insert(out, c) j += 1 end end local code = table.concat(out) if code ~= "" then table.insert(codes, code) end i = math.max(j, keyStart + 6) end if #codes == 0 then return nil end return table.concat(codes, "\n\n") end local function applyLuauToGame(luauCode) local code = tostring(luauCode or ""):gsub("%s+$", "") if code == "" then return false, "No Luau code to apply." end -- replacePrevious cleanup runs in applyToGame (this function is declared before deletePreviousGeneratedFolders / clearGenerated exist). -- If we're applying Luau-only output (no project JSON), ensure any referenced "world" pieces exist -- so the result is still playable and not an empty baseplate. local function ensureFolder(parent, name) local f = parent:FindFirstChild(name) if f and f:IsA("Folder") then return f end if f then f.Name = name .. "_BadType" end local nf = Instance.new("Folder") nf.Name = name nf.Parent = parent pcall(function() BB.Coll:AddTag(nf, TAG_NAME) end) return nf end local function ensureWinGui() local starterGui = game:GetService("StarterGui") local guiNames = { "WinGui", "WinMessageGui" } local labelNames = { "WinMessage", "WinMessageLabel" } for _, guiName in ipairs(guiNames) do local winGui = starterGui:FindFirstChild(guiName) if winGui and not winGui:IsA("ScreenGui") then winGui.Name = guiName .. "_BadType" winGui = nil end if not winGui then winGui = Instance.new("ScreenGui") winGui.Name = guiName winGui.ResetOnSpawn = false winGui.Enabled = false winGui.Parent = starterGui pcall(function() BB.Coll:AddTag(winGui, TAG_NAME) end) end for _, labelName in ipairs(labelNames) do local lbl = winGui:FindFirstChild(labelName) if lbl and not lbl:IsA("TextLabel") then lbl.Name = labelName .. "_BadType" lbl = nil end if not lbl then lbl = Instance.new("TextLabel") lbl.Name = labelName lbl.Size = UDim2.new(1, 0, 0, 60) lbl.Position = UDim2.new(0, 0, 0, 24) lbl.BackgroundTransparency = 1 lbl.Visible = false lbl.Text = "You win!" lbl.Font = Enum.Font.GothamBlack lbl.TextScaled = true lbl.TextColor3 = Color3.fromRGB(255, 255, 255) lbl.Parent = winGui pcall(function() BB.Coll:AddTag(lbl, TAG_NAME) end) end end end end local function ensureCoinWorld() local vcWorld = ensureFolder(workspace, "VC_World") local assets = ensureFolder(vcWorld, "Assets") local platform = assets:FindFirstChild("FloatingPlatform") if platform and not platform:IsA("BasePart") then platform.Name = "FloatingPlatform_BadType" platform = nil end if not platform then local p = Instance.new("Part") p.Name = "FloatingPlatform" p.Anchored = true p.Size = Vector3.new(40, 2, 40) p.Position = Vector3.new(0, 10, 0) p.Material = Enum.Material.SmoothPlastic p.Color = Color3.fromRGB(90, 180, 255) p.TopSurface = Enum.SurfaceType.Smooth p.BottomSurface = Enum.SurfaceType.Smooth p.Parent = assets pcall(function() BB.Coll:AddTag(p, TAG_NAME) end) platform = p end local coinPositions = { Vector3.new(-12, 12, -12), Vector3.new(-12, 12, 12), Vector3.new(12, 12, -12), Vector3.new(12, 12, 12), Vector3.new(0, 12, 0), } for i = 1, 5 do local name = "Coin" .. tostring(i) local c = assets:FindFirstChild(name) if c and not c:IsA("BasePart") then c.Name = name .. "_BadType" c = nil end if not c then local coin = Instance.new("Part") coin.Name = name coin.Anchored = true coin.CanCollide = false coin.Shape = Enum.PartType.Cylinder coin.Size = Vector3.new(0.6, 2.0, 2.0) coin.Material = Enum.Material.Neon coin.Color = Color3.fromRGB(255, 221, 76) coin.CFrame = CFrame.new(coinPositions[i]) * CFrame.Angles(0, 0, math.rad(90)) coin.Parent = assets pcall(function() BB.Coll:AddTag(coin, TAG_NAME) end) end end end local folder = BB.SSS:FindFirstChild("VibeCoderAI") if not folder then folder = Instance.new("Folder") folder.Name = "VibeCoderAI" folder.Parent = BB.SSS end local scriptName = "VC_Main" local s = folder:FindFirstChild(scriptName) if not s or not s:IsA("Script") then if s then pcall(function() s:Destroy() end) end s = Instance.new("Script") s.Name = scriptName s.Parent = folder pcall(function() BB.Coll:AddTag(s, TAG_NAME) end) end local okSrc, srcErr = pcall(function() s.Source = code end) if not okSrc then return false, "Could not write script source: " .. tostring(srcErr) end BB.ChangeHist:SetWaypoint("BloxBuilder — applied Luau", false) return true, "Applied Luau script to BB.SSS/VibeCoderAI/VC_Main" end local function normalizeApplyPayload(decoded) if type(decoded) ~= "table" then return decoded end local artifacts = decoded.artifacts if type(artifacts) == "table" then local normalized = { schemaVersion = decoded.schemaVersion, title = decoded.title, summary = decoded.summary, artifacts = artifacts, instances = artifacts.instances, scripts = artifacts.scripts, terrain = artifacts.terrain, } return normalized end return decoded end local function safeDecodeProjectJson(raw) if looksLikeLuauSource(raw) then return nil, nil end local blob = extractJsonBlob(raw) if not blob then return nil, nil end local ok, decoded = pcall(function() return BB.Http:JSONDecode(blob) end) if not ok or type(decoded) ~= "table" then return nil, nil end -- Reject junk: brace-balanced Lua snippets decode as odd tables without our shape. if decoded.schemaVersion == nil and type(decoded.instances) ~= "table" and type(decoded.scripts) ~= "table" and type(decoded.artifacts) ~= "table" then return nil, nil end decoded = normalizeApplyPayload(decoded) return blob, decoded end local function formatPreview(decoded) if type(decoded) ~= "table" then return "No structured project found yet.\n\nSwitch to Code tab to see raw output." end local title = tostring(decoded.title or "Generation Ready") local summary = tostring(decoded.summary or "") local lines = {} table.insert(lines, title .. " ready") if summary ~= "" then table.insert(lines, "") table.insert(lines, summary) end table.insert(lines, "") local hasSpawn, hasFinish, hasLava = false, false, false local parts = 0 if type(decoded.instances) == "table" then for _, inst in ipairs(decoded.instances) do if type(inst) == "table" then local n = tostring(inst.name or "") local k = tostring(inst.kind or "") if k == "SpawnLocation" or n == "VC_Spawn" then hasSpawn = true end if n == "VC_Finish" then hasFinish = true end if n == "VC_Lava" then hasLava = true end if k == "Part" or k == "WedgePart" or k == "TrussPart" or k == "SpawnLocation" or k == "MeshPart" then parts += 1 end end end end local scriptCount = 0 if type(decoded.scripts) == "table" then scriptCount = #decoded.scripts end local terrainOps = 0 if type(decoded.terrain) == "table" then if type(decoded.terrain.fillBlocks) == "table" then terrainOps += #decoded.terrain.fillBlocks end if type(decoded.terrain.fillBalls) == "table" then terrainOps += #decoded.terrain.fillBalls end if type(decoded.terrain.fillRegions) == "table" then terrainOps += #decoded.terrain.fillRegions end end if hasSpawn then table.insert(lines, "- Spawn created") end if hasLava then table.insert(lines, "- Lava floor added") end if hasFinish then table.insert(lines, "- Finish platform added") end if (not hasSpawn) and (not hasLava) and (not hasFinish) then table.insert(lines, "- Parts: " .. tostring(parts)) end if terrainOps > 0 then table.insert(lines, "- Terrain ops: " .. tostring(terrainOps)) end if scriptCount > 0 then table.insert(lines, "- Scripts: " .. tostring(scriptCount)) end table.insert(lines, "") table.insert(lines, "Next: click Apply to insert into your game.") return table.concat(lines, "\n") end -- Short summary for VisualBuild: no JSON, no script listing — user sees viewport + this text only. local function formatVisualBuildSummary(decoded) if type(decoded) ~= "table" then return "Building visuals..." end -- Model JSON "title"/"summary" can drift (e.g. example "Red Block") while instances match the prompt. -- Prefer the user's actual prompt for the headline so the panel always matches what they typed. local userLine = type(uiState.lastPrompt) == "string" and trim(uiState.lastPrompt) or "" local title = tostring(decoded.title or "Build") if userLine ~= "" then title = userLine if #title > 120 then title = title:sub(1, 117) .. "..." end end local schemaTitle = tostring(decoded.title or "") local summary = tostring(decoded.summary or "") local n = 0 if type(decoded.instances) == "table" then n = #decoded.instances end local terrainOps = 0 if type(decoded.terrain) == "table" then local tr = decoded.terrain if type(tr.fillBlocks) == "table" then terrainOps += #tr.fillBlocks end if type(tr.fillBalls) == "table" then terrainOps += #tr.fillBalls end if type(tr.fillRegions) == "table" then terrainOps += #tr.fillRegions end end local lines = {} table.insert(lines, title) if userLine ~= "" and schemaTitle ~= "" and schemaTitle ~= userLine and schemaTitle ~= "Build" then table.insert(lines, ("(model title was: %s)"):format(schemaTitle)) end if summary ~= "" then table.insert(lines, "") table.insert(lines, summary) end table.insert(lines, "") table.insert(lines, ("- Objects in build: %d"):format(n)) if terrainOps > 0 then table.insert(lines, ("- Terrain updates: %d"):format(terrainOps)) end table.insert(lines, "") table.insert(lines, "Parts appear in Workspace/VC_World. Press F if the camera needs to refocus.") return table.concat(lines, "\n") end local function focusViewportOnBuild() local vc = workspace:FindFirstChild("VC_World") if not vc then return end local parts = {} for _, desc in ipairs(vc:GetDescendants()) do if desc:IsA("BasePart") then table.insert(parts, desc) end end if #parts == 0 then return end pcall(function() BB.Sel:Set(parts) end) local sum = Vector3.new(0, 0, 0) for _, p in ipairs(parts) do sum += p.Position end local center = sum / #parts local cam = workspace.CurrentCamera if cam then local dist = math.clamp(50 + #parts * 1.5, 60, 220) local height = math.clamp(35 + #parts, 40, 160) cam.CFrame = CFrame.new(center + Vector3.new(0, height, dist), center) end end local function clearGenerated() local removed = 0 BB.ChangeHist:SetWaypoint("BloxBuilder — clear build", false) -- Delete all tagged instances. Sort deepest-first so children delete before parents. local items = BB.Coll:GetTagged(TAG_NAME) table.sort(items, function(a, b) local da = 0 local p = a while p do da += 1 p = p.Parent end local db = 0 p = b while p do db += 1 p = p.Parent end return da > db end) for _, inst in ipairs(items) do if inst and inst.Parent then pcall(function() inst:Destroy() end) removed += 1 end end BB.ChangeHist:SetWaypoint("BloxBuilder — clear done", false) return removed end local function splitPath(pathStr) local parts = {} for part in string.gmatch(pathStr, "[^/]+") do table.insert(parts, part) end return parts end local RESERVED_CONTAINER_NAMES = { StarterPlayerScripts = true, StarterCharacterScripts = true, } local function isLockedStarterContainer(parent, name) if not parent or not name then return false end local starterPlayer = game:GetService("StarterPlayer") if parent ~= starterPlayer then return false end return RESERVED_CONTAINER_NAMES[name] == true end local function findOrCreatePathChild(parent, fname) local f = parent:FindFirstChild(fname) if f then return f end local starterPlayer = game:GetService("StarterPlayer") if parent == starterPlayer then if fname == "StarterPlayerScripts" then return starterPlayer:FindFirstChildOfClass("StarterPlayerScripts") elseif fname == "StarterCharacterScripts" then return starterPlayer:FindFirstChildOfClass("StarterCharacterScripts") end end f = Instance.new("Folder") f.Name = fname f.Parent = parent pcall(function() BB.Coll:AddTag(f, TAG_NAME) end) return f end local function resolveParentFromPath(pathStr) local parts = splitPath(pathStr) if #parts < 1 then return nil end local serviceName = parts[1] local okSvc, svc = pcall(function() return game:GetService(serviceName) end) if not okSvc or not svc then return nil end local parent = svc for i = 2, #parts do parent = findOrCreatePathChild(parent, parts[i]) if not parent then return nil end end return parent end local function vec3FromArray(a) if type(a) ~= "table" then return nil end local x = tonumber(a[1]) local y = tonumber(a[2]) local z = tonumber(a[3]) if not x or not y or not z then return nil end return Vector3.new(x, y, z) end local function color3FromArray(a) if type(a) ~= "table" then return nil end local r = tonumber(a[1]) local g = tonumber(a[2]) local b = tonumber(a[3]) if not r or not g or not b then return nil end if r <= 1 and g <= 1 and b <= 1 then return Color3.new(r, g, b) end return Color3.fromRGB(math.clamp(math.floor(r + 0.5), 0, 255), math.clamp(math.floor(g + 0.5), 0, 255), math.clamp(math.floor(b + 0.5), 0, 255)) end local function materialFromString(s) if type(s) ~= "string" then return nil end local ok, mat = pcall(function() return Enum.Material[s] end) if ok then return mat end return nil end -- Ensure a shared runtime library exists for generated scripts. local function ensureRuntimeLibrary() local root = BB.RepStorage:FindFirstChild("VibeCoderAI") if not root then root = Instance.new("Folder") root.Name = "VibeCoderAI" root.Parent = BB.RepStorage end local runtime = root:FindFirstChild("Runtime") if not runtime then runtime = Instance.new("Folder") runtime.Name = "Runtime" runtime.Parent = root end local util = runtime:FindFirstChild("VC_Util") if not util or not util:IsA("ModuleScript") then if util then pcall(function() util:Destroy() end) end util = Instance.new("ModuleScript") util.Name = "VC_Util" util.Parent = runtime end -- Keep this module tiny + stable; generators can rely on it. util.Source = [[ local Util = {} function Util.getOrCreate(parent: Instance, className: string, name: string): Instance local child = parent:FindFirstChild(name) if child and child.ClassName == className then return child end if child then child.Name = name .. "_BadType" end local inst = Instance.new(className) inst.Name = name inst.Parent = parent return inst end function Util.waitOrCreate(parent: Instance, className: string, name: string, timeoutSec: number?): Instance local t = tonumber(timeoutSec) or 5 local child = parent:WaitForChild(name, t) if child and child.ClassName == className then return child end return Util.getOrCreate(parent, className, name) end function Util.safeConnect(inst: any, signalName: string, fn) if not inst then return nil end local sig = inst[signalName] if typeof(sig) ~= "RBXScriptSignal" then return nil end return sig:Connect(fn) end function Util.materialFromName(name: any) local s = typeof(name) == "string" and name or "" -- Never allow non-existent materials like "Gold" if s:lower() == "gold" then return Enum.Material.Metal, Color3.fromRGB(255, 210, 70) end local ok, mat = pcall(function() return Enum.Material[s] end) if ok and mat then return mat, nil end return Enum.Material.SmoothPlastic, nil end return Util ]] return true end local INSTANCE_KIND_TO_CLASS = { Part = "Part", WedgePart = "WedgePart", TrussPart = "TrussPart", SpawnLocation = "SpawnLocation", MeshPart = "MeshPart", Model = "Model", Folder = "Folder", } local TERRAIN_OP_BUDGET = 56 local function isRobloxAssetId(s) if type(s) ~= "string" then return false end return string.sub(s, 1, 13) == "rbxassetid://" end local function region3FromCorners(minArr, maxArr) local av = vec3FromArray(minArr) local bv = vec3FromArray(maxArr) if not av or not bv then return nil end local minV = Vector3.new(math.min(av.X, bv.X), math.min(av.Y, bv.Y), math.min(av.Z, bv.Z)) local maxV = Vector3.new(math.max(av.X, bv.X), math.max(av.Y, bv.Y), math.max(av.Z, bv.Z)) return Region3.new(minV, maxV) end --- workspace.Terrain: FillBlock / FillBall / FillRegion from project JSON (schema v2). local function applyTerrain(decoded) local t = decoded.terrain if type(t) ~= "table" then return 0 end local terrain = workspace:FindFirstChildOfClass("Terrain") if not terrain then return 0 end local done = 0 local function oneOp(fn) if done >= TERRAIN_OP_BUDGET then return end local ok = pcall(fn) if ok then done += 1 end end if type(t.fillBlocks) == "table" then for _, fb in ipairs(t.fillBlocks) do if type(fb) == "table" and done < TERRAIN_OP_BUDGET then local pos = vec3FromArray(fb.position) local size = vec3FromArray(fb.size) local mat = materialFromString(fb.material) if pos and size and mat then oneOp(function() terrain:FillBlock(CFrame.new(pos), size, mat) end) end end end end if type(t.fillBalls) == "table" then for _, fb in ipairs(t.fillBalls) do if type(fb) == "table" and done < TERRAIN_OP_BUDGET then local center = vec3FromArray(fb.center) local rad = tonumber(fb.radius) local mat = materialFromString(fb.material) if center and rad and rad > 0 and mat then oneOp(function() terrain:FillBall(center, rad, mat) end) end end end end if type(t.fillRegions) == "table" then for _, fr in ipairs(t.fillRegions) do if type(fr) == "table" and done < TERRAIN_OP_BUDGET then local reg = region3FromCorners(fr.min, fr.max) local mat = materialFromString(fr.material) local res = tonumber(fr.resolution) if not res or res <= 0 then res = 4 end if reg and mat then oneOp(function() terrain:FillRegion(reg, res, mat) end) end end end end return done end local function mergeInstanceSpecs(decoded) local list = {} if type(decoded.instances) == "table" then for _, s in ipairs(decoded.instances) do table.insert(list, s) end end if type(decoded.meshAssets) == "table" then for _, s in ipairs(decoded.meshAssets) do table.insert(list, s) end end return list end local function insertInstances(decoded) local instances = mergeInstanceSpecs(decoded) if #instances == 0 then return 0 end local function prop(props, key) if type(props) ~= "table" then return nil end -- Support both schema styles: lowerCamel (size/position/color/material) and UpperCamel (Size/Position/Color/Material). local v = props[key] if v ~= nil then return v end local up = key:sub(1, 1):upper() .. key:sub(2) return props[up] end local created = 0 for _, spec in ipairs(instances) do if type(spec) == "table" then local pathStr = tostring(spec.path or "") local kind = tostring(spec.kind or "") local name = tostring(spec.name or "") local props = spec.props if pathStr ~= "" and kind ~= "" and name ~= "" then local skipInvalidMesh = kind == "MeshPart" and not isRobloxAssetId(tostring(spec.meshId or "")) if not skipInvalidMesh then local parent = resolveParentFromPath(pathStr) if parent and not isLockedStarterContainer(parent, name) then local className = INSTANCE_KIND_TO_CLASS[kind] if className then local inst = parent:FindFirstChild(name) if inst and inst.ClassName ~= className then if inst:IsA("StarterPlayerScripts") or inst:IsA("StarterCharacterScripts") then inst = nil else inst:Destroy() inst = nil end end if not inst then inst = Instance.new(className) inst.Name = name inst.Parent = parent pcall(function() BB.Coll:AddTag(inst, TAG_NAME) end) created += 1 end if inst:IsA("BasePart") then if type(props) == "table" then local size = vec3FromArray(prop(props, "size")) if size then inst.Size = size end local pos = vec3FromArray(prop(props, "position")) if pos then inst.Position = pos end local col = color3FromArray(prop(props, "color")) if col then inst.Color = col end local anchored = prop(props, "anchored") if typeof(anchored) == "boolean" then inst.Anchored = anchored end local canCollide = prop(props, "canCollide") if typeof(canCollide) == "boolean" then inst.CanCollide = canCollide end local mat = materialFromString(prop(props, "material")) if mat then inst.Material = mat end local tr = tonumber(prop(props, "transparency")) if tr then inst.Transparency = math.clamp(tr, 0, 1) end -- Optional: simple part shapes for better visuals (trees often want Ball leaves). if inst:IsA("Part") then local shape = tostring(prop(props, "shape") or "") if shape ~= "" then local s = shape:lower() if s == "ball" or s == "sphere" then inst.Shape = Enum.PartType.Ball elseif s == "cylinder" then inst.Shape = Enum.PartType.Cylinder elseif s == "block" then inst.Shape = Enum.PartType.Block end end end end end if inst:IsA("MeshPart") then local mid = spec.meshId if type(mid) == "string" and isRobloxAssetId(mid) then pcall(function() inst.MeshId = mid end) end local tid = spec.textureId if type(tid) == "string" and isRobloxAssetId(tid) then pcall(function() inst.TextureID = tid end) end end end end end end end end return created end local function ensureWorkspaceCoreParts(decoded) local function ensurePart(name, defaults) local existing = workspace:FindFirstChild(name) if existing and not existing:IsA("BasePart") then -- If the model/plugin accidentally created a Folder/Model with the reserved name, -- move it aside so the runtime script won't crash. existing.Name = name .. "_BadType" existing = nil end if not existing then local p = Instance.new("Part") p.Name = name p.Anchored = true p.TopSurface = Enum.SurfaceType.Smooth p.BottomSurface = Enum.SurfaceType.Smooth p.Parent = workspace pcall(function() BB.Coll:AddTag(p, TAG_NAME) end) existing = p end local part = existing :: BasePart if defaults.size then part.Size = defaults.size end if defaults.position then part.Position = defaults.position end if defaults.color then part.Color = defaults.color end if defaults.material then part.Material = defaults.material end if defaults.canCollide ~= nil then part.CanCollide = defaults.canCollide end if defaults.transparency ~= nil then part.Transparency = defaults.transparency end return part end -- Only create VC_Spawn when the build is likely to be played (scripts/gameplay), -- or when the output explicitly references a spawn. local shouldEnsureSpawn = false if type(decoded) == "table" then if type(decoded.instances) == "table" then for _, inst in ipairs(decoded.instances) do if type(inst) == "table" then local n = tostring(inst.name or ""):lower() local k = tostring(inst.kind or ""):lower() if k == "spawnlocation" or n == "vc_spawn" or n:find("spawn", 1, true) then shouldEnsureSpawn = true break end end end end if not shouldEnsureSpawn and type(decoded.scripts) == "table" and #decoded.scripts > 0 then shouldEnsureSpawn = true end -- Do not infer spawn from title/summary; only create spawn if explicitly needed. end if shouldEnsureSpawn then ensurePart("VC_Spawn", { size = Vector3.new(18, 1, 18), position = Vector3.new(0, 6, 0), color = Color3.fromRGB(80, 160, 255), material = Enum.Material.SmoothPlastic, canCollide = true, transparency = 0, }) end -- Only create VC_Finish / VC_Lava if the project actually needs them (obby/parkour). local needFinishOrLava = false if type(decoded) == "table" then if type(decoded.title) == "string" and decoded.title:lower():find("obby", 1, true) then needFinishOrLava = true end if type(decoded.summary) == "string" and decoded.summary:lower():find("obby", 1, true) then needFinishOrLava = true end if type(decoded.instances) == "table" then for _, inst in ipairs(decoded.instances) do if type(inst) == "table" then local n = tostring(inst.name or "") if n == "VC_Finish" or n == "VC_Lava" then needFinishOrLava = true break end end end end if not needFinishOrLava and type(decoded.scripts) == "table" then for _, s in ipairs(decoded.scripts) do if type(s) == "table" then local code = tostring(s.code or "") if code:find("VC_Finish", 1, true) or code:find("VC_Lava", 1, true) then needFinishOrLava = true break end end end end end if needFinishOrLava then ensurePart("VC_Finish", { size = Vector3.new(14, 1, 14), position = Vector3.new(0, 6, 140), color = Color3.fromRGB(60, 200, 90), material = Enum.Material.Neon, canCollide = true, transparency = 0, }) local lava = ensurePart("VC_Lava", { size = Vector3.new(400, 2, 400), position = Vector3.new(0, -50, 0), color = Color3.fromRGB(220, 60, 60), material = Enum.Material.Neon, canCollide = false, transparency = 0.05, }) lava.Locked = true end end -- Forward-compatible project JSON: keep in sync with apps/api/src/lib/projectSchema.ts local PLUGIN_SCHEMA_MAX = 2 local function nonEmptyTable(t) return type(t) == "table" and next(t) ~= nil end local function reservedBlocksNotice(decoded) if nonEmptyTable(decoded.assetPipeline) then return " Reserved: assetPipeline is not applied in this plugin build." end return "" end local function schemaVersionNotice(decoded) local v = tonumber(decoded.schemaVersion) if v == nil then return " Note: schemaVersion missing (treated as 1)." end if v > PLUGIN_SCHEMA_MAX then return " Warning: schemaVersion=" .. tostring(v) .. " (this plugin supports up to " .. tostring(PLUGIN_SCHEMA_MAX) .. ") — update the plugin for full support." end return "" end -- Parses API JSON (scripts[]) and creates Script / LocalScript / ModuleScript under the right services. local function insertScriptsFromPayload(raw) local blob = extractJsonBlob(raw) if not blob then return false, "Could not find JSON in the output (model may have returned plain text)." end local okd, decoded = pcall(function() return BB.Http:JSONDecode(blob) end) if not okd or type(decoded) ~= "table" then return false, "JSON decode failed." end local scripts = decoded.scripts if type(scripts) ~= "table" or #scripts == 0 then return false, 'No "scripts" array in JSON.' end BB.ChangeHist:SetWaypoint("BloxBuilder — insert scripts", false) local terrainCount = applyTerrain(decoded) local instanceCount = insertInstances(decoded) -- Safety net: ensure spawn exists, and obby parts only if needed. ensureWorkspaceCoreParts(decoded) local created = 0 for _, spec in ipairs(scripts) do if type(spec) == "table" then local pathStr = tostring(spec.path or "") local instName = tostring(spec.name or "Generated") local kind = tostring(spec.kind or "Script") local code = tostring(spec.code or "") if pathStr ~= "" and code ~= "" then local parent = resolveParentFromPath(pathStr) if parent then local className = "Script" if kind == "LocalScript" then className = "LocalScript" elseif kind == "ModuleScript" then className = "ModuleScript" end local inst = parent:FindFirstChild(instName) if inst and inst.ClassName ~= className then inst:Destroy() inst = nil end if not inst then inst = Instance.new(className) inst.Name = instName inst.Parent = parent pcall(function() BB.Coll:AddTag(inst, TAG_NAME) end) end if inst:IsA("LuaSourceContainer") then inst.Source = code created += 1 end end end end end BB.ChangeHist:SetWaypoint("BloxBuilder — insert done", false) if created == 0 and instanceCount == 0 and terrainCount == 0 then return false, "Nothing was inserted (no recognized scripts, instances, or terrain)." end local msg = "Inserted " .. tostring(created) .. " script(s)" if terrainCount > 0 then msg ..= " + " .. tostring(terrainCount) .. " terrain op(s)" end if instanceCount > 0 then msg ..= " + " .. tostring(instanceCount) .. " instance(s)" end msg ..= "." msg ..= schemaVersionNotice(decoded) msg ..= reservedBlocksNotice(decoded) return true, msg end local GENERATED_PREFIX = "Generated_" local function deletePreviousGeneratedFolders() local removed = 0 local function clearIn(parent) if not parent then return end for _, ch in ipairs(parent:GetChildren()) do if ch and ch.Name and string.sub(ch.Name, 1, #GENERATED_PREFIX) == GENERATED_PREFIX then removed += 1 pcall(function() ch:Destroy() end) end end end local function clearLegacyNamedIn(parent, names) if not parent then return end for _, ch in ipairs(parent:GetChildren()) do if ch and ch.Name then for _, legacyName in ipairs(names) do if ch.Name == legacyName then removed += 1 pcall(function() ch:Destroy() end) break end end end end end clearIn(workspace) clearIn(BB.SSS:FindFirstChild("VibeCoderAI")) clearIn(game:GetService("StarterGui")) clearIn(BB.RepStorage) local starterPlayer = game:GetService("StarterPlayer") clearIn(starterPlayer:FindFirstChild("StarterPlayerScripts")) clearIn(starterPlayer:FindFirstChild("StarterCharacterScripts")) -- Clean up legacy generations from older plugin/model outputs that were not tagged. clearLegacyNamedIn(workspace, { "AI_Build", "VC_World", "VibeCoderAI", "VC_Spawn" }) clearLegacyNamedIn(game:GetService("StarterGui"), { "WinGui", "WinMessageGui", "CoinCounterGui", "AI_Build" }) clearLegacyNamedIn(starterPlayer:FindFirstChild("StarterPlayerScripts"), { "AI_Build", "CoinCollector", "CoinCollectorClient", "MonetizationClient", "VibeCoderAI", }) clearLegacyNamedIn(BB.SSS, { "AI_Build", "LoadAssets", "LoadCoinAssets", "LoadNatureAssets", "MonetizationServer", "VibeCoderAI", }) return removed end local function makeGenerationRoot() local stamp = os.date("!%Y%m%d_%H%M%S") local name = GENERATED_PREFIX .. stamp local rootFolder = Instance.new("Folder") rootFolder.Name = name rootFolder.Parent = workspace pcall(function() BB.Coll:AddTag(rootFolder, TAG_NAME) end) local svcRoot = BB.SSS:FindFirstChild("VibeCoderAI") if not svcRoot then svcRoot = Instance.new("Folder") svcRoot.Name = "VibeCoderAI" svcRoot.Parent = BB.SSS end local scriptsRoot = Instance.new("Folder") scriptsRoot.Name = name scriptsRoot.Parent = svcRoot pcall(function() BB.Coll:AddTag(scriptsRoot, TAG_NAME) end) return name, rootFolder, scriptsRoot end local function resolveParentWithGeneration(rootWorkspaceFolder, rootScriptsFolder, pathStr) local parts = splitPath(pathStr) if #parts < 1 then return nil end local serviceName = parts[1] -- Workspace content goes inside the generation folder. if serviceName == "Workspace" then local parent = rootWorkspaceFolder for i = 2, #parts do local fname = parts[i] local f = parent:FindFirstChild(fname) if not f then f = Instance.new("Folder") f.Name = fname f.Parent = parent pcall(function() BB.Coll:AddTag(f, TAG_NAME) end) end parent = f end return parent end -- Script services go inside BB.SSS/VibeCoderAI/Generated_/... local okSvc, svc = pcall(function() return game:GetService(serviceName) end) if okSvc and svc and (svc == BB.SSS or svc == BB.RepStorage or svc == StarterPlayerScripts or svc == StarterGui) then local parent = rootScriptsFolder for i = 2, #parts do local fname = parts[i] local f = parent:FindFirstChild(fname) if not f then f = Instance.new("Folder") f.Name = fname f.Parent = parent pcall(function() BB.Coll:AddTag(f, TAG_NAME) end) end parent = f end return parent end -- Fallback: do not write outside generation roots. return nil end local function textHasAny(haystack, terms) for _, term in ipairs(terms) do if haystack:find(term, 1, true) then return true end end return false end -- Heuristic: short, visual-only prompts ("create a red block") should not auto-add spawn/floor/world. local function looksLikeSimpleVisualPrompt(rawPrompt) local p = trim(rawPrompt):lower() if p == "" then return false end if #p > 160 then return false end local hasLogicSignals = textHasAny(p, { "ui", "gui", "hud", "button", "click", "touch", "interact", "door", "script", "system", "logic", "inventory", "shop", "quest", "enemy", "npc", "combat", "weapon", "datastore", "save", }) if hasLogicSignals then return false end return textHasAny(p, { "block", "part", "baseplate", "platform", "wall", "room", "house", "building", "city", "town", "skyline", "village", "terrain", "tree", "trees", "lava", "color", "red", "blue", "green", "sun", "sunset", "sunrise", "sky", "moon", "star", "stars", "cloud", "clouds", "lighting", "atmosphere", "fog", "horizon", "scenic", "landscape", "vista", "view", "beach", "ocean", "lake", "mountain", "mountains", "create a", "create an", "make a", "make an", "add a", "add an", }) end -- Remove model-added spawn/floor when we are in VisualBuild / simple-visual mode (instances-only). local function stripVisualBuildPlayableDefaults(d) if type(d) ~= "table" or type(d.instances) ~= "table" then return d end local removeNames = { VC_World = true, VC_Spawn = true, VC_Floor = true, } local cleaned = {} for _, inst in ipairs(d.instances) do if type(inst) == "table" then local name = tostring(inst.name or "") local kind = tostring(inst.kind or "") if not (removeNames[name] or kind == "SpawnLocation") then table.insert(cleaned, inst) end end end d.instances = cleaned return d end local function applyDecodedToGame(decoded) if type(decoded) ~= "table" then return false, "No decoded project to apply." end decoded = normalizeApplyPayload(decoded) -- For simple visual-only prompts, strip "make it playable" defaults if they appear in JSON. -- This prevents unexpected VC_Spawn/VC_Floor/VC_World from being inserted when the user asked for a single part. local function stripPlayableDefaultsIfNeeded(d) local prompt = uiState.lastPrompt local visualMode = (uiState.lastRoute == "visual_build") or (type(prompt) == "string" and looksLikeSimpleVisualPrompt(prompt)) if not visualMode then return d end -- Only do this when there are no scripts (instances-only generation). if type(d.scripts) == "table" and #d.scripts > 0 then return d end if type(d.instances) ~= "table" then return d end return stripVisualBuildPlayableDefaults(d) end decoded = stripPlayableDefaultsIfNeeded(decoded) -- Heuristic safety: if the model forgot to include visible instances (platform/coins/UI), -- create the minimum required objects referenced by scripts so the result is playable. local function decodedScriptsText(d) if type(d) ~= "table" or type(d.scripts) ~= "table" then return "" end local out = {} for _, s in ipairs(d.scripts) do if type(s) == "table" and type(s.code) == "string" then table.insert(out, s.code) end end return table.concat(out, "\n\n") end local function ensureFolder(parent, name) local f = parent:FindFirstChild(name) if f and f:IsA("Folder") then return f end if f then f.Name = name .. "_BadType" end local nf = Instance.new("Folder") nf.Name = name nf.Parent = parent pcall(function() BB.Coll:AddTag(nf, TAG_NAME) end) return nf end local function ensureWinGui() local starterGui = game:GetService("StarterGui") local winGui = starterGui:FindFirstChild("WinGui") if winGui and not winGui:IsA("ScreenGui") then winGui.Name = "WinGui_BadType" winGui = nil end if not winGui then winGui = Instance.new("ScreenGui") winGui.Name = "WinGui" winGui.ResetOnSpawn = false winGui.Enabled = false winGui.Parent = starterGui pcall(function() BB.Coll:AddTag(winGui, TAG_NAME) end) end local lbl = winGui:FindFirstChild("WinMessage") if lbl and not lbl:IsA("TextLabel") then lbl.Name = "WinMessage_BadType" lbl = nil end if not lbl then lbl = Instance.new("TextLabel") lbl.Name = "WinMessage" lbl.Size = UDim2.new(1, 0, 0, 60) lbl.Position = UDim2.new(0, 0, 0, 24) lbl.BackgroundTransparency = 1 lbl.Text = "You win!" lbl.Font = Enum.Font.GothamBlack lbl.TextScaled = true lbl.TextColor3 = Color3.fromRGB(255, 255, 255) lbl.Parent = winGui pcall(function() BB.Coll:AddTag(lbl, TAG_NAME) end) end end local function ensureCoinWorld() local vcWorld = ensureFolder(workspace, "VC_World") local assets = ensureFolder(vcWorld, "Assets") local platform = assets:FindFirstChild("FloatingPlatform") if platform and not platform:IsA("BasePart") then platform.Name = "FloatingPlatform_BadType" platform = nil end if not platform then local p = Instance.new("Part") p.Name = "FloatingPlatform" p.Anchored = true p.Size = Vector3.new(40, 2, 40) p.Position = Vector3.new(0, 10, 0) p.Material = Enum.Material.SmoothPlastic p.Color = Color3.fromRGB(90, 180, 255) p.TopSurface = Enum.SurfaceType.Smooth p.BottomSurface = Enum.SurfaceType.Smooth p.Parent = assets pcall(function() BB.Coll:AddTag(p, TAG_NAME) end) platform = p end local coinPositions = { Vector3.new(-12, 12, -12), Vector3.new(-12, 12, 12), Vector3.new(12, 12, -12), Vector3.new(12, 12, 12), Vector3.new(0, 12, 0), } for i = 1, 5 do local name = "Coin" .. tostring(i) local c = assets:FindFirstChild(name) if c and not c:IsA("BasePart") then c.Name = name .. "_BadType" c = nil end if not c then local coin = Instance.new("Part") coin.Name = name coin.Anchored = true coin.CanCollide = false coin.Shape = Enum.PartType.Cylinder coin.Size = Vector3.new(0.6, 2.0, 2.0) coin.Material = Enum.Material.Neon coin.Color = Color3.fromRGB(255, 221, 76) coin.CFrame = CFrame.new(coinPositions[i]) * CFrame.Angles(0, 0, math.rad(90)) coin.Parent = assets pcall(function() BB.Coll:AddTag(coin, TAG_NAME) end) end end end ensureRuntimeLibrary() if replacePrevious then deletePreviousGeneratedFolders() -- Also clear tagged instances as a safety net (older builds). clearGenerated() end -- Apply directly to the requested paths (Workspace/..., BB.SSS/...). -- This avoids breaking scripts that do workspace:WaitForChild("X") expecting root-level names. BB.ChangeHist:SetWaypoint("BloxBuilder — apply build", false) local terrainCount = applyTerrain(decoded) local instanceCount = insertInstances(decoded) -- Safety net: ensure spawn exists, and only create obby parts when needed. local visualInstancesOnly = type(decoded.scripts) == "table" and #decoded.scripts == 0 and (uiState.lastRoute == "visual_build" or (type(uiState.lastPrompt) == "string" and looksLikeSimpleVisualPrompt(uiState.lastPrompt))) if not visualInstancesOnly then ensureWorkspaceCoreParts(decoded) end -- Scripts local createdScripts = 0 local function sanitizeGeneratedLuau(src) local s = tostring(src or "") -- Fix common hallucinated enums that crash at runtime. -- Roblox uses Enum.PartOperation (not Enum.NegateOperation). s = s:gsub("Enum%.NegateOperation", "Enum.PartOperation") -- Some models hallucinate Enum.Material.Gold; Roblox doesn't have it. s = s:gsub("Enum%.Material%.Gold", "Enum.Material.Metal") -- Roblox doesn't have Enum.Material.Dirt; closest is Ground. s = s:gsub("Enum%.Material%.Dirt", "Enum.Material.Ground") -- SECURITY: Runtime code cannot write Script.Source (requires Plugin/OpenCloud capability). -- Strip any attempted Source assignments so Play never throws "lacking capability PluginOrOpenCloud". -- This is a last-resort guardrail; proper behavior is "put all code in scripts[]". s = s:gsub("[^\n]*%.Source%s*=%s*[^\n]*\n", "") s = s:gsub("[^\n]*:SetAttribute%s*%(%s*['\"]Source['\"][^\n]*\n", "") return s end if type(decoded.scripts) == "table" then for _, spec in ipairs(decoded.scripts) do if type(spec) == "table" then local pathStr = tostring(spec.path or "") local instName = tostring(spec.name or "Generated") local kind = tostring(spec.kind or "Script") local code = sanitizeGeneratedLuau(spec.code or "") if pathStr ~= "" and code ~= "" then local parent = resolveParentFromPath(pathStr) if parent then local className = "Script" if kind == "LocalScript" then className = "LocalScript" elseif kind == "ModuleScript" then className = "ModuleScript" end local inst = parent:FindFirstChild(instName) if inst and inst.ClassName ~= className then if not (inst:IsA("StarterPlayerScripts") or inst:IsA("StarterCharacterScripts")) then inst:Destroy() inst = nil end end if not inst then inst = Instance.new(className) inst.Name = instName inst.Parent = parent pcall(function() BB.Coll:AddTag(inst, TAG_NAME) end) end if inst:IsA("LuaSourceContainer") then inst.Source = code createdScripts += 1 end end end end end end BB.ChangeHist:SetWaypoint("BloxBuilder — applied build", false) if createdScripts == 0 and instanceCount == 0 and terrainCount == 0 then -- If live preview already placed instances, Apply may be a no-op. -- In that case, treat it as success to avoid confusing "Apply failed" UX. if uiState.previewApplied then return true, "Applied (already previewed)" end return false, "Nothing to apply." end pcall(focusViewportOnBuild) return true, ("Applied (scripts=%d, instances=%d, terrainOps=%d)"):format(createdScripts, instanceCount, terrainCount) end -- Must match API/worker classifyGenerationRoute lists. local function classifyGenerationRoute(rawPrompt) local p = trim(rawPrompt):lower() if p == "" then return "visual_build" end -- "round" must be whole-word only — substring matches "ground", "surround", "background", etc. local function hasWholeWord(hay, word) for token in string.gmatch(hay, "%a+") do if token == word then return true end end return false end if hasWholeWord(p, "round") or hasWholeWord(p, "rounds") then return "gameplay" end if textHasAny(p, { "game", "games", "playable", "obby", "parkour", "coin", "coins", "collect", "collection", "simulator", "tycoon", "quest", "quests", "shop", "inventory", "combat", "enemy", "enemies", "npc", "npcs", "weapon", "weapons", "wave", "waves", "leaderboard", "datastore", "data store", "save", "saving", "gameplay", "minigame", "raid", "boss", "upgrade", "currency", "monetization", "pets", "pet", "grind", "survival", "pvp", "battle royale", "tower defense", "td ", "score", "checkpoint", "finish", "level", "rpg", "horror", "racing", "race", }) then return "gameplay" end if textHasAny(p, { "block", "blocks", "part", "parts", "tree", "trees", "house", "home", "building", "build", "mansion", "castle", "bridge", "road", "car", "cars", "wall", "walls", "floor", "platform", "terrain", "map", "model", "models", "prop", "props", "decoration", "decor", "room", "scene", "environment", "baseplate", "statue", "fountain", "garden", "park", "city", "cities", "town", "towns", "skyline", "village", "lamp", "roof", "door", "window", "windows", "stairs", "stair", "lava", "lavas", "sun", "sunset", "sunrise", "sky", "moon", "star", "stars", "cloud", "clouds", "lighting", "atmosphere", "fog", "horizon", "scenic", "landscape", "vista", "view", "beach", "ocean", "lake", "mountain", "mountains", }) then return "visual_build" end if looksLikeSimpleVisualPrompt(rawPrompt) then return "visual_build" end return "visual_build" end local function isExplicitCodeOnlyPrompt(rawPrompt) local p = trim(rawPrompt):lower() if p == "" then return false end return p:find("code only", 1, true) or p:find("luau only", 1, true) or p:find("lua only", 1, true) or p:find("script only", 1, true) or p:find("only code", 1, true) or p:find("only luau", 1, true) or p:find("only lua", 1, true) end local function chooseRequestFormat(rawPrompt) if isExplicitCodeOnlyPrompt(rawPrompt) then return "luau" end -- Default: full JSON pipeline (instances + scripts). Luau-only is opt-in via "luau only" in the prompt. return "json" end -- User wants a brand-new generation, not an improve on the last build. local function wantsExplicitFreshStart(rawPrompt) local p = trim(rawPrompt):lower() if p == "" then return false end return p:find("start over", 1, true) or p:find("from scratch", 1, true) or p:find("brand new", 1, true) or p:find("new game", 1, true) or p:find("different game", 1, true) or p:find("ignore previous", 1, true) or p:find("ignore the last", 1, true) or p:find("reset everything", 1, true) end -- When we already have a JSON project or Luau from the last Apply, treat short follow-ups as improve. local function shouldStartFresh(rawPrompt, haveContext) local p = trim(rawPrompt):lower() if p == "" then return true end if not haveContext then return true end local wantsImprove = p:match("^change%s*:") or p:match("^update%s*:") or p:match("^modify%s*:") or p:match("^improve%s*:") or p:match("^edit%s*:") or p:match("^continue%s*:") or p:find("improve the previous", 1, true) or p:find("update the previous", 1, true) or p:find("modify the previous", 1, true) or p:find("change the previous", 1, true) or p:find("keep the previous", 1, true) or p:find("same game", 1, true) if wantsImprove then return false end -- Common follow-ups (e.g. "add score UI") without re-pasting the whole game idea. if p:match("^add%s") or p:match("^also%s") or p:match("^now%s") or p:match("^include%s") then return false end if p:find("add score", 1, true) or p:find("add ui", 1, true) or p:find("score ui", 1, true) then return false end if p:find("leaderboard", 1, true) or p:find("on%-screen", 1, true) or p:find("onscreen", 1, true) then return false end if p:find("screen gui", 1, true) or p:find("screengui", 1, true) or p:find("startergui", 1, true) then return false end if p:find("please add", 1, true) or p:find("add a ", 1, true) or p:find("add an ", 1, true) then return false end if p:find("make it ", 1, true) or p:find("can you add", 1, true) then return false end if p:find("^fix", 1, true) or p:find("^tweak", 1, true) or p:find("^remove", 1, true) or p:find("^faster", 1, true) or p:find("^slower", 1, true) then return false end if p:find("^delete", 1, true) or p:find("^undo", 1, true) or p:find("^strip", 1, true) or p:find("^drop", 1, true) then return false end if p:find("take out", 1, true) or p:find("get rid of", 1, true) or p:find("no more", 1, true) or p:find("remove the", 1, true) then return false end if p:find("extra ui", 1, true) or p:find("remove ui", 1, true) or p:find("remove shop", 1, true) or p:find("without the", 1, true) then return false end if p:find("more coins", 1, true) or p:find("less coins", 1, true) or p:find("coin value", 1, true) then return false end if p:find("^make ", 1, true) or p:find("^make the ", 1, true) then return false end return true end local function getEnhancedPrompt(rawPrompt) local raw = trim(rawPrompt) if raw == "" then return raw end local ok, body = httpJson(API_BASE .. "/enhance", "POST", { prompt = raw }) if not ok then return raw end local decoded = nil local okd = pcall(function() decoded = BB.Http:JSONDecode(body) end) if not okd or type(decoded) ~= "table" then return raw end return trim(decoded.enhanced or raw) end -- Live preview: instances + terrain only (no scripts). Caller handles debounce / clear. local function applyPreviewInstances(decoded) if type(decoded) ~= "table" then return end if type(decoded.instances) ~= "table" or #decoded.instances == 0 then return end local d = table.clone(decoded) d.scripts = {} if uiState.lastRoute == "visual_build" then stripVisualBuildPlayableDefaults(d) end pcall(function() applyTerrain(d) end) pcall(function() insertInstances(d) end) pcall(focusViewportOnBuild) end return { normalizeApplyPayload = normalizeApplyPayload, safeDecodeProjectJson = safeDecodeProjectJson, tryExtractLuau = tryExtractLuau, tryExtractLuauStreaming = tryExtractLuauStreaming, formatPreview = formatPreview, formatVisualBuildSummary = formatVisualBuildSummary, clearGenerated = clearGenerated, deletePreviousGeneratedFolders = deletePreviousGeneratedFolders, applyDecodedToGame = applyDecodedToGame, applyLuauToGame = applyLuauToGame, applyPreviewInstances = applyPreviewInstances, classifyGenerationRoute = classifyGenerationRoute, chooseRequestFormat = chooseRequestFormat, shouldStartFresh = shouldStartFresh, wantsExplicitFreshStart = wantsExplicitFreshStart, insertInstances = insertInstances, } end)() -- NOTE: Roblox HttpService does not support true streaming/SSE. -- We use job + polling to simulate streaming in Studio. local function pollJob(jobId, headerText, source) local cursor = 0 local buffer = "" -- Keep server "enhanced" text out of `buffer` so JSON decode / Luau extract stay valid. local enhancedText = "" local enhancedShown = false local lastPreviewUpdate = 0 local lastPreviewApplyAt = 0 local previewCleared = false local lastActiveStage = nil -- tracks the most recent stage_start for the status banner local stepHighWater = 2 -- job was accepted before pollJob; never go backwards uiState.lastJobId = jobId -- Prevent accidental double-clicks from turning the Send button into Stop and -- cancelling the job before the bridge has started planning/editing. uiState._stopAllowedAt = tick() + 12.0 local function advanceChecklist(step) if not chatHandles or not chatHandles.setStep then return end local n = tonumber(step) or 1 if n > stepHighWater then stepHighWater = n chatHandles.setStep(n) end end local function maybeApplyLivePreview(decoded) if type(decoded) ~= "table" then return end decoded = Build.normalizeApplyPayload(decoded) if type(decoded.instances) ~= "table" or #decoded.instances == 0 then return end local now = tick() if (now - lastPreviewApplyAt) < 0.35 then return end lastPreviewApplyAt = now if replacePrevious and not previewCleared then Build.clearGenerated() previewCleared = true end Build.applyPreviewInstances(decoded) -- Production UX: if we can preview real instances, Apply should always work. -- Store the latest preview payload as the fallback "final" apply payload. uiState.previewApplied = true uiState.lastDecoded = decoded uiState.lastLuau = nil uiState.canApply = true refreshActionStates() end -- Fake instant feel (Preview tab) even if backend is slow. uiState.fakePhase = "start" setTab("code") setPreview("Job queued - waiting for worker...\n" .. string.rep(".", #(Editor.PIPELINE_STEPS or {}))) task.delay(0.3, function() if uiState.isGenerating and uiState.lastJobId == jobId then uiState.fakePhase = "placing" setPreview("Enhancing prompt and classifying intent...\n" .. string.rep(".", #(Editor.PIPELINE_STEPS or {}))) end end) task.delay(0.6, function() if uiState.isGenerating and uiState.lastJobId == jobId then uiState.fakePhase = "scripts" if uiState.lastRoute == "visual_build" then setPreview("Finishing visuals...") else setPreview("Streaming code...") end end end) -- Live preview in Explorer while streaming (disabled so it never runs). local function getOrCreateLivePreviewScript() local folder = BB.SSS:FindFirstChild("VibeCoderAI") if not folder then folder = Instance.new("Folder") folder.Name = "VibeCoderAI" folder.Parent = BB.SSS end local s = folder:FindFirstChild("VC_LiveStream") if not s or not s:IsA("Script") then if s then pcall(function() s:Destroy() end) end s = Instance.new("Script") s.Name = "VC_LiveStream" s.Disabled = true s.Source = "-- Live stream preview (disabled)\n" s.Parent = folder end return s end local livePreviewScript = getOrCreateLivePreviewScript() local function updateLivePreview(now) -- Throttle Explorer updates; frequent Source writes can stutter Studio. if (now - lastPreviewUpdate) < 0.25 then return end lastPreviewUpdate = now -- Visual builds stream JSON — do not mirror it into a Script (user asked: no code dump). if uiState.lastRoute == "visual_build" then pcall(function() livePreviewScript.Source = "-- BloxBuilder: visual build (JSON hidden in UI; see viewport)\n" end) return end local previewBody = Build.tryExtractLuau(buffer) or buffer local clipped = previewBody -- Keep it bounded so the Script doesn't grow forever on huge outputs. if #clipped > 18000 then clipped = clipped:sub(#clipped - 18000) clipped = "(clipped to last 18k chars)\n" .. clipped end pcall(function() if Build.tryExtractLuau(buffer) then livePreviewScript.Source = "-- Live stream Luau preview (disabled)\n-- Job: " .. tostring(jobId) .. "\n\n" .. clipped else livePreviewScript.Source = "-- Live stream preview (disabled)\n-- Job: " .. tostring(jobId) .. "\n\n--[[\n" .. clipped .. "\n]]" end end) end local function dotWave() local n = math.floor(tick() * 3) % 4 if n == 0 then return "" elseif n == 1 then return "." elseif n == 2 then return ".." end return "..." end local function statusBanner(phase, jobStatus) local p = "" if type(phase) == "string" then p = phase end local s = "" if type(jobStatus) == "string" then s = jobStatus end local line = "Working on your game" if s == "queued" or p == "queued" then line = "Queued - waiting for Studio Bridge..." elseif p == "studio_agent" or p == "starting" then line = "Studio Agent starting..." .. dotWave() elseif p == "playtest" then line = "Playtesting your game..." .. dotWave() elseif p == "partial" then line = "Running scoped improve..." .. dotWave() elseif p == "enhancing" then line = "Enhancing your prompt..." elseif p == "generating" or p == "planning" then -- Show current pipeline stage if available local stageInfo = lastActiveStage and Editor.STEP_BY_STAGE and Editor.STEP_BY_STAGE[lastActiveStage] if stageInfo then local bar = string.rep("#", stageInfo.index) .. string.rep(".", stageInfo.total - stageInfo.index) line = string.format("[%d/%d] %s %s%s\n%s", stageInfo.index, stageInfo.total, stageInfo.emoji, stageInfo.label, dotWave(), bar ) elseif uiState.lastRoute == "visual_build" then line = "Building visuals..." .. dotWave() elseif uiState.lastRoute == "gameplay" then line = "Planning game pipeline..." .. dotWave() else line = "Generating..." .. dotWave() end elseif p == "complete" or s == "done" then line = "Done!" elseif s == "error" then line = "Error" elseif s == "cancelled" then line = "Cancelled" end local hint = "\nTip: steps appear below as each stage completes." if uiState.lastRoute == "visual_build" then hint = "\nTip: live preview appears in the 3D viewport." elseif uiState.lastRoute == "gameplay" then hint = "\nTip: world planning can take 30-90s in Full mode." end -- When in "generating" phase, skip the dot wave from the banner suffix (already in line) if p == "generating" or p == "planning" then return line .. hint end return line .. dotWave() .. hint end local function appendEnhancedSpec(text) local t = tostring(text or ""):gsub("^%s+", ""):gsub("%s+$", "") if t == "" or enhancedShown then return end enhancedShown = true enhancedText = "Enhanced prompt (server):\n" .. t .. "\n\n────────────────\n\n" end local function summarizeStudioAppliedOutput(text) local raw = tostring(text or ""):gsub("^%s+", ""):gsub("%s+$", "") if raw == "" then return "The Studio Agent finished building your experience in Studio." end -- Do not call JSONDecode here. Studio can break on decoder errors even -- when wrapped, so this completion path is intentionally text-only. local cleaned = raw :gsub("Created in Studio via fallback:[^\r\n]*", "") :gsub("%(Bridge also applied[^\r\n]*%)", "") :gsub("\n%s*\n", "\n") :gsub("^%s+", "") :gsub("%s+$", "") if cleaned == "" or cleaned:sub(1, 1) == "{" then return "Studio Agent finished building in Studio. Check Explorer > Workspace." end if #cleaned > 800 then cleaned = cleaned:sub(1, 800) .. "..." end return cleaned end setOutput((headerText or "") .. "\n────────────────\n\n") updateLivePreview(tick()) setTab("code") while not cancelled do local ok2, body2 = httpJson(API_BASE .. "/jobs/" .. jobId .. "?cursor=" .. tostring(cursor), "GET", nil) if cancelled then break end if not ok2 then setOutput(statusBanner(nil, "error") .. "\n────────────────\n\n" .. buffer .. "\n\nError: " .. tostring(body2)) return nil, false end local state = safeJsonDecode(body2) if type(state) ~= "table" then -- Avoid Studio debugger pause / nil indexing on bad poll payloads. setOutput(statusBanner(nil, "error") .. "\n────────────────\n\n" .. buffer .. "\n\nError: bad job status response") return nil, false end appendEnhancedSpec(state.enhanced) if type(state.contextSnapshot) == "string" and state.contextSnapshot ~= "" then uiState.lastContextSnapshot = state.contextSnapshot end if state.validationScore then uiState.lastValidationScore = tonumber(state.validationScore) end if type(state.route) == "string" and (state.route == "visual_build" or state.route == "gameplay") then uiState.lastRoute = state.route end local chunks = state.chunks or {} for _, raw in ipairs(chunks) do if cancelled then break end local okc, c = pcall(function() return BB.Http:JSONDecode(raw) end) if okc and type(c) == "table" then if c.type == "delta" and c.text then buffer ..= c.text elseif c.type == "studio" and c.summary then -- Live agent action from the Studio Bridge local icon = "[tool]" local action = tostring(c.action or "") if action == "start_stop_play" then icon = "[play]" elseif action == "console_output" or action == "get_console_output" then icon = "[console]" elseif action == "screen_capture" then icon = "[capture]" elseif action == "multi_edit" then icon = "[edit]" elseif action == "explore_subagent" or action == "subagent" or action == "search_game_tree" then icon = "[search]" elseif action == "execute_luau" then icon = "[run]" elseif action == "generate_mesh" or action == "generate_procedural_model" then icon = "[mesh]" elseif action == "insert_asset" or action == "insert_from_creator_store" then icon = "[asset]" end setPreview(icon .. " " .. tostring(c.summary)) buffer ..= "\n" .. icon .. " " .. tostring(c.summary) -- Drive checklist from real Studio tools (not local generate prep). -- Only move forward so explore after mutate doesn't reset the card. if chatHandles and chatHandles.setStep then local explore = action == "subagent" or action == "explore_subagent" or action == "search_game_tree" or action == "inspect_instance" or action == "script_read" or action == "script_search" or action == "script_grep" or action == "list_roblox_studios" or action == "set_active_studio" local mutate = action == "multi_edit" or action == "execute_luau" or action == "generate_mesh" or action == "generate_material" or action == "generate_procedural_model" or action == "insert_asset" or action == "insert_from_creator_store" local play = action == "start_stop_play" or action == "get_console_output" or action == "console_output" or action == "screen_capture" if mutate or play then advanceChecklist(4) elseif explore then -- Exploring is step 1; once planning (2) started, bump to calling tools (3) if stepHighWater >= 2 then advanceChecklist(3) else advanceChecklist(1) end else advanceChecklist(3) end end elseif c.type == "enhanced" and c.text then appendEnhancedSpec(c.text) elseif c.type == "preview" and c.text then local okp, decodedPreview = pcall(function() return BB.Http:JSONDecode(c.text) end) if okp and type(decodedPreview) == "table" then maybeApplyLivePreview(decodedPreview) end elseif c.type == "health" and c.text then local okh, health = pcall(function() return BB.Http:JSONDecode(c.text) end) if okh and type(health) == "table" then uiState.lastHealth = health healthLabel.Text = Editor.formatHealthPanel(health) end elseif c.type == "context" and c.text then uiState.lastContextSnapshot = c.text elseif c.type == "plan" and c.text then uiState.lastPlanJson = c.text elseif c.type == "progress" and c.stage then -- stage_start: show live "running" banner lastActiveStage = c.stage local info = Editor.STEP_BY_STAGE and Editor.STEP_BY_STAGE[c.stage] if info then local progressBar = string.rep("#", info.index) .. string.rep(".", info.total - info.index) setPreview(string.format( "[%d/%d] %s %s\n%s\n\n%s", info.index, info.total, info.emoji, info.label, progressBar, info.desc )) else setPreview(Editor.formatProgress(c.stage, c.durationMs)) end elseif type(c.type) == "string" and Editor.STAGE_LABELS[c.type] then -- stage_complete: append a ✓ line to the output log local line = Editor.formatProgress(c.type, c.durationMs, c.success) setPreview(line) buffer ..= "\n" .. line end end end if cancelled then break end -- Streaming JSON: opportunistically decode partial buffer and show instances in Workspace. local _blobStream, decodedStream = Build.safeDecodeProjectJson(buffer) if decodedStream then maybeApplyLivePreview(decodedStream) end cursor = state.cursor or cursor local bodyText = buffer if type(state.fullOutput) == "string" and state.fullOutput ~= "" then bodyText = state.fullOutput end local luauOnly = Build.tryExtractLuau(bodyText) local displayText = "" local route = uiState.lastRoute if route == "visual_build" and not luauOnly then local _blob, decodedNow = Build.safeDecodeProjectJson(bodyText) if decodedNow then displayText = Build.formatVisualBuildSummary(decodedNow) else displayText = "Building visuals...\n\nWatch the 3D viewport - parts appear as they are generated.\nThis panel stays free of raw JSON or code." end elseif luauOnly then displayText = luauOnly elseif route == "gameplay" then local partialLuau = Build.tryExtractLuauStreaming(bodyText) if partialLuau then displayText = partialLuau else local _blob, decodedNow = Build.safeDecodeProjectJson(bodyText) if decodedNow then displayText = Build.formatPreview(decodedNow) elseif trim(buffer) ~= "" then displayText = buffer else displayText = "Planning game (world, quests, scripts)...\n\nStages appear here. Watch the 3D viewport for live previews." end end else local _blob, decodedNow = Build.safeDecodeProjectJson(bodyText) if decodedNow then displayText = Build.formatPreview(decodedNow) else local partialLuau = Build.tryExtractLuauStreaming(bodyText) if partialLuau then displayText = partialLuau else displayText = "(streaming... waiting for preview)" end end end if displayText:sub(1, 1) == "{" then displayText = "(streaming... waiting for preview)" end if bodyText == "" then if route == "visual_build" then displayText = "Starting visual build..." else displayText = "(waiting for first characters...)" end end local prefix = "" if route ~= "visual_build" and enhancedText ~= "" then prefix = enhancedText end setOutput(statusBanner(state.phase, state.status) .. "\n────────────────\n\n" .. prefix .. displayText) updateLivePreview(tick()) if state.status == "done" then if cancelled or uiState.lastJobId ~= jobId then return nil, false end lastJobSource = source or "plugin_generate" -- Studio Bridge applied edits directly inside Studio — no Apply step needed if state.studioApplied == true then uiState.workflowStage = "applied" uiState.canApply = false uiState.hadStudioApply = true local summary = "" if type(state.fullOutput) == "string" and state.fullOutput ~= "" then summary = state.fullOutput else summary = "The Studio Agent finished building your experience in Studio." end summary = summarizeStudioAppliedOutput(summary) -- Persist a lightweight stub so follow-up "add/improve" prompts can keep context. local stubTitle = tostring(uiState.lastPrompt or "Studio build") if #stubTitle > 80 then stubTitle = stubTitle:sub(1, 77) .. "..." end local stubSummary = tostring(summary or ""):sub(1, 400) local stub = ( '{"schemaVersion":1,"title":' .. BB.Http:JSONEncode(stubTitle) .. ',"summary":' .. BB.Http:JSONEncode(stubSummary) .. ',"instances":[],"scripts":[]}' ) setLastProjectJson(stub) uiState.lastProjectJson = stub uiState.lastRawOutput = summary if type(uiState._preGenerateCheckpoint) == "table" then commandHistory:push(uiState._preGenerateCheckpoint) uiState._preGenerateCheckpoint = nil end if source == "plugin_autofix" then setOutput("Auto-fix finished in Studio.\n\n" .. summary) else setOutput( "Studio Agent done!\n────────────────\n\n" .. summary .. "\n\nCheck Explorer > Workspace for new parts/scripts. Press Play to test gameplay.\nTip: click Undo in the chat header to roll back this prompt." ) end setPreview("Check Workspace in Explorer / viewport") setTab("preview") return finalText or "", true end local finalText = buffer if type(state.fullOutput) == "string" and state.fullOutput ~= "" then finalText = state.fullOutput end -- Save last project JSON (for Improve) only when decode is a real project payload. local blob, decoded = Build.safeDecodeProjectJson(finalText) if blob and decoded then setLastProjectJson(blob) end uiState.lastRawOutput = finalText uiState.lastProjectJson = blob uiState.lastDecoded = decoded local finalLuau = Build.tryExtractLuau(finalText) -- If we have a decoded project JSON, prefer applying that (instances + scripts), -- not the extracted Luau-only view of scripts. uiState.lastLuau = (decoded ~= nil) and nil or finalLuau local function decodedHasApplyableContent(d) if type(d) ~= "table" then return false end -- Instances (includes meshAssets) + scripts + terrain are the only applyable outputs. local instanceCount = 0 if type(d.instances) == "table" then instanceCount += #d.instances end if type(d.meshAssets) == "table" then instanceCount += #d.meshAssets end local scriptCount = 0 if type(d.scripts) == "table" then scriptCount = #d.scripts end local terrainOps = 0 if type(d.terrain) == "table" then local tr = d.terrain if type(tr.fillBlocks) == "table" then terrainOps += #tr.fillBlocks end if type(tr.fillBalls) == "table" then terrainOps += #tr.fillBalls end if type(tr.fillRegions) == "table" then terrainOps += #tr.fillRegions end end return (instanceCount > 0) or (scriptCount > 0) or (terrainOps > 0) end local canApplyDecoded = decodedHasApplyableContent(decoded) local canApplyLuau = (type(finalLuau) == "string" and trim(finalLuau) ~= "") uiState.canApply = canApplyDecoded or canApplyLuau if decoded and uiState.lastRoute == "visual_build" then maybeApplyLivePreview(decoded) end refreshActionStates() -- The API can intentionally return instances-only JSON with scripts=[] (e.g. "create a red block"). -- In that case, show a helpful summary instead of "no scripts found", since Apply will still work. local function finalDisplayText() if finalLuau and trim(finalLuau) ~= "" then return finalLuau end if decoded and uiState.lastRoute == "visual_build" then local preview = Build.formatVisualBuildSummary(decoded) local warn = "" local ins = decoded.instances if type(ins) ~= "table" or #ins == 0 then warn = "\n\nExpected visuals, but none were returned in instances. Regenerate with: 'include visible instances in JSON'." end return preview .. warn end if decoded then local preview = Build.formatPreview(decoded) local tailNote = "\n\n(Instances-only output - no scripts were required for this request.)" return preview .. tailNote end return "(done, but no code was found in the output)" end local tail = "" if uiState.canApply then tail = "\n\nPreview ready.\nFlow: Generate -> Validate -> Preview -> Apply.\nClick Preview to review, then Apply." else tail = "\n\nNothing to apply (no instances/scripts/terrain returned).\n" .. "Regenerate and include visible instances in JSON (example: 'include visible instances in JSON')." end -- Keep Code view Luau-only even when we have full JSON. setOutput(statusBanner("complete", "done") .. "\n────────────────\n\n" .. finalDisplayText() .. tail) setTab("preview") uiState.workflowStage = "preview" healthLabel.Text = Editor.formatPreviewPanel(uiState, uiState.lastHealth) if uiState.lastProjectJson and uiState.lastPrompt then local versionEntry = { id = Editor.makeVersionId(), prompt = uiState.lastPrompt, summary = (uiState.lastDecoded and uiState.lastDecoded.title) or "Build", validationScore = uiState.lastValidationScore, createdAt = os.date("!%Y-%m-%dT%H:%M:%SZ"), projectJson = uiState.lastProjectJson, planJson = uiState.lastPlanJson, health = uiState.lastHealth, } versionStore:add(versionEntry) uiState.selectedVersionId = versionEntry.id refreshHistoryPanel() pcall(function() httpJson(API_BASE .. "/builds/versions", "POST", { jobId = tostring(jobId), prompt = uiState.lastPrompt, summary = versionEntry.summary, validationScore = uiState.lastValidationScore, health = uiState.lastHealth, projectJson = uiState.lastProjectJson, planJson = uiState.lastPlanJson, mode = currentMode, format = "json", }) end) sessionRecovery:save({ lastJobId = jobId, lastPrompt = uiState.lastPrompt, lastRoute = uiState.lastRoute, lastProjectJson = uiState.lastProjectJson, lastDecoded = uiState.lastDecoded, lastLuau = uiState.lastLuau, lastContextSnapshot = uiState.lastContextSnapshot, lastPlanJson = uiState.lastPlanJson, lastValidationScore = uiState.lastValidationScore, lastHealth = uiState.lastHealth, workflowStage = uiState.workflowStage or "preview", }) end return finalText, uiState.canApply end if state.status == "error" then local resumeHint = "" if state.resumeable and uiState.lastJobId then resumeHint = "\n\nYou can resume this failed job from the output actions (Resume button) or re-run Generate." uiState.lastFailedJobId = uiState.lastJobId end setOutput( statusBanner(nil, "error") .. "\n────────────────\n\n" .. buffer .. "\n\nError: " .. tostring(state.error or "unknown") .. resumeHint ) return nil, false end if state.status == "cancelled" then setOutput(statusBanner(nil, "cancelled") .. "\n────────────────\n\n" .. buffer .. "\n\n-- Stopped --") return nil, false end if cancelled then break end task.wait(POLL_INTERVAL) end -- If the user clicked Stop, also cancel the server job so credits/time aren't wasted. pcall(function() httpJson(API_BASE .. "/jobs/" .. jobId .. "/cancel", "POST", {}) end) setOutput(statusBanner(nil, "cancelled") .. "\n────────────────\n\n" .. buffer .. "\n\n-- Stopped --") return nil, false end -- Forward declare so Generate can auto-apply after polling completes. local applyToGame -- Must be assigned before generate(): improve path captures workspace for /improve-job. -- Studio Agent parts often lack our CollectionService tag, so fall back to a Workspace scan. captureWorkspaceSnapshot = function() local snap = Editor.captureTaggedWorkspace(BB.Coll, TAG_NAME) if type(snap) ~= "table" then snap = { instances = {} } end if type(snap.instances) ~= "table" then snap.instances = {} end if #snap.instances > 0 then return snap end local extras = {} for _, inst in ipairs(workspace:GetDescendants()) do if inst:IsA("BasePart") and not inst:IsA("Terrain") then local parentPath = "Workspace" pcall(function() parentPath = Editor.instancePath(inst.Parent) end) table.insert(extras, { path = parentPath, kind = inst.ClassName, name = inst.Name, props = { size = { inst.Size.X, inst.Size.Y, inst.Size.Z }, position = { inst.Position.X, inst.Position.Y, inst.Position.Z }, anchored = inst.Anchored, color = { math.floor(inst.Color.R * 255), math.floor(inst.Color.G * 255), math.floor(inst.Color.B * 255), }, }, }) if #extras >= 40 then break end end end snap.instances = extras snap.source = "workspace_scan" return snap end restoreWorkspaceSnapshot = function(snapshot) if type(snapshot) ~= "table" then return end Editor.restoreTaggedWorkspace(snapshot, function() Build.clearGenerated() Build.deletePreviousGeneratedFolders() end, function(decoded) Build.insertInstances(decoded) end) end -- Own function scope: keeps main chunk under Luau's 200-local limit. captureUndoCheckpoint, undoLastAgentBuild = (function() local function collectKnownPaths() local known = {} local function markTree(root) if not root then return end known[root:GetFullName()] = true for _, d in ipairs(root:GetDescendants()) do known[d:GetFullName()] = true end end markTree(workspace) pcall(function() markTree(game:GetService("ServerScriptService")) end) pcall(function() markTree(game:GetService("StarterGui")) end) pcall(function() markTree(game:GetService("ReplicatedStorage")) end) pcall(function() markTree(game:GetService("StarterPlayer")) end) return known end local function capture(promptText) local snap = nil pcall(function() snap = captureWorkspaceSnapshot() end) return { kind = "pre_generate", prompt = promptText, projectJson = getLastProjectJson(), workspaceSnapshot = snap, knownPaths = collectKnownPaths(), } end local function undoBuild() local entry = commandHistory:undo() if not entry then showToast("Nothing to undo") return false end local known = entry.knownPaths if type(known) == "table" then local function scrubRoot(root) if not root then return end local list = {} for _, d in ipairs(root:GetDescendants()) do table.insert(list, d) end table.sort(list, function(a, b) return #a:GetFullName() > #b:GetFullName() end) for _, d in ipairs(list) do if not known[d:GetFullName()] then pcall(function() d:Destroy() end) end end end scrubRoot(workspace) pcall(function() scrubRoot(game:GetService("ServerScriptService")) end) pcall(function() scrubRoot(game:GetService("StarterGui")) end) pcall(function() scrubRoot(game:GetService("ReplicatedStorage")) end) end if entry.workspaceSnapshot then restoreWorkspaceSnapshot(entry.workspaceSnapshot) end if entry.projectJson then setLastProjectJson(entry.projectJson) end if entry.decoded then uiState.lastDecoded = entry.decoded uiState.canApply = true end if entry.luau then uiState.lastLuau = entry.luau uiState.canApply = true end if entry.prompt then uiState.lastPrompt = entry.prompt end showToast("Undone — last build removed") if chatHandles and chatHandles.appendLogText then chatHandles.appendLogText("Undone — restored place to before last prompt.") end refreshActionStates() return true end return capture, undoBuild end)() local function generate() cancelled = false if uiState.isGenerating then return end local now = tick() if uiState._lastGenClick and (now - uiState._lastGenClick) < 0.6 then return end uiState._lastGenClick = now if not isPluginEditMode() then setOutput("Stop Play first — BloxBuilder only runs in Edit mode.") return end -- Read prompt before any setOutput so we can freeze prior chat first (ChatGPT-style). if chatHandles and chatHandles.getPromptText then local fromChat = trim(chatHandles.getPromptText()) if fromChat ~= "" then promptBox.Text = fromChat end end local rawPrompt = trim(promptBox.Text) if rawPrompt == "" then setOutput("Type a prompt first.") return end if #rawPrompt > PROMPT_MAX_CHARS then rawPrompt = string.sub(rawPrompt, 1, PROMPT_MAX_CHARS) promptBox.Text = rawPrompt end -- Snapshot before we clear state (improve-job needs previous Luau / context). local snapshotLuau = uiState.lastLuau local trimmedLuauSnapshot = type(snapshotLuau) == "string" and trim(snapshotLuau) or "" local haveLuauPrevious = trimmedLuauSnapshot ~= "" if getToken() == "" then setOutput( "401 = missing token.\n\n" .. "Paste your token in the field above, or run in PowerShell:\n" .. '$b = @{ email="you@mail.com"; password="yourpass" } | ConvertTo-Json\n' .. "Invoke-RestMethod -Method Post -Uri " .. API_BASE .. "/auth -ContentType application/json -Body $b | % token\n" ) return end syncTokenToBridge(getToken()) task.wait(0.75) uiState.isGenerating = true uiState.lastJobId = nil -- until we have a real job id (prevents Stop from cancelling an old job during HTTP). uiState.canApply = false uiState.lastRawOutput = nil uiState.lastProjectJson = nil uiState.lastDecoded = nil uiState.lastLuau = nil uiState.lastRoute = nil -- Checkpoint so chat Undo can roll back Studio Agent edits from this prompt. uiState._preGenerateCheckpoint = captureUndoCheckpoint(rawPrompt) refreshActionStates() uiState.lastPrompt = rawPrompt -- Freeze prior turns + pin this "You:" line BEFORE live job text starts. if chatHandles and chatHandles.beginSteps then chatHandles.beginSteps(rawPrompt) end -- Clear both legacy promptBox and chat composer. Guard the promptBox→composer -- sync while generating so the legacy box cannot refill the chat input. promptBox.Text = "" if chatHandles and chatHandles.setPromptText then chatHandles.setPromptText("") end setOutput("Understanding your prompt...\n") setPreview("Understanding...") setTab("code") -- Follow the user's prompt literally by default. The Enhance button remains available when wanted. local enhancedPrompt = rawPrompt if cancelled then uiState.isGenerating = false refreshActionStates() if chatHandles and chatHandles.failStep then chatHandles.failStep() end setOutput("Stopped by you.") return end local projectJson = getLastProjectJson() local haveProject = projectJson ~= nil and trim(projectJson) ~= "" local haveContext = haveProject or haveLuauPrevious local requestFormat = Build.chooseRequestFormat(rawPrompt) local startFresh = true if Build.wantsExplicitFreshStart(rawPrompt) then startFresh = true elseif not haveContext then startFresh = true else startFresh = Build.shouldStartFresh(rawPrompt, haveContext) end if (not startFresh) and requestFormat == "luau" and not haveLuauPrevious then startFresh = true end -- Improve must use the same shape as what we have on disk (JSON project preferred over legacy Luau). if not startFresh then if haveProject then requestFormat = "json" elseif haveLuauPrevious then requestFormat = "luau" end end -- Follow-ups ("add X", "also…") must improve the existing Studio place, not force a fresh generate. -- If we only have a prior Studio-applied build (no JSON blob yet), keep generate but mark it additive. local routeForPrompt = Build.classifyGenerationRoute(rawPrompt) local xPrompt = trim(rawPrompt):lower() local addLike = (xPrompt:match("^add%s") ~= nil) or xPrompt:find("add a ", 1, true) or xPrompt:find("add an ", 1, true) or xPrompt:find("please add", 1, true) or xPrompt:find("can you add", 1, true) or (xPrompt:match("^also%s") ~= nil) or xPrompt:find("make the ", 1, true) or xPrompt:find("more ", 1, true) local hadStudioApply = uiState.hadStudioApply == true if addLike and (haveContext or hadStudioApply) and not Build.wantsExplicitFreshStart(rawPrompt) then startFresh = false if haveProject then requestFormat = "json" elseif haveLuauPrevious then requestFormat = "luau" else -- No saved JSON yet — still run as additive generate so the agent explores + appends. startFresh = true requestFormat = "json" end end local jobPrompt = enhancedPrompt if startFresh and (hadStudioApply or addLike) and not Build.wantsExplicitFreshStart(rawPrompt) then jobPrompt = "ADDITIVE FOLLOW-UP (do NOT wipe or rebuild the existing place):\n" .. "- Explore Studio first with search_game_tree.\n" .. "- Keep all existing Workspace/scripts unless the user asked to remove them.\n" .. "- Only add or modify what this request asks for.\n" .. "- Use only real Roblox Instance classes (never Instance.new(\"Sun\")).\n\n" .. "User request:\n" .. enhancedPrompt end setPreview("Starting...") local ok, body if startFresh then setOutput("Sending to your server...\n") lastJobSource = "plugin_generate" ok, body = httpJson(API_BASE .. "/generate-job", "POST", { prompt = jobPrompt, mode = currentMode, format = requestFormat, }) else lastJobSource = "plugin_improve" setOutput( "Sending to your server...\n\n" .. "Improve mode: only the text in the prompt box is sent as the change request. Your last Applied Luau (or saved JSON project) is the starting point - you do not need to paste the whole game again.\n" ) -- Capture a lightweight workspace summary so the API can detect Studio drift local workspaceSnapshotStr = nil pcall(function() local snap = captureWorkspaceSnapshot() if type(snap) == "table" then workspaceSnapshotStr = BB.Http:JSONEncode(snap) end end) if requestFormat == "luau" and haveLuauPrevious then ok, body = httpJson(API_BASE .. "/improve-job", "POST", { format = "luau", previousCode = trimmedLuauSnapshot, request = enhancedPrompt, mode = currentMode, }) else ok, body = httpJson(API_BASE .. "/improve-job", "POST", { format = "json", projectJson = projectJson, request = enhancedPrompt, mode = currentMode, workspaceSnapshot = workspaceSnapshotStr, }) end end if not ok then uiState.isGenerating = false refreshActionStates() if chatHandles and chatHandles.failStep then chatHandles.failStep() end if isPaymentRequired(body) then setOutOfCreditsOutput(body) elseif isBridgeRequired(body) then showUpgradeBanner(false) showBridgeRequiredOutput() else showUpgradeBanner(false) setOutput("Error: " .. tostring(body)) end return end showUpgradeBanner(false) local decoded = safeJsonDecode(body) local jobId = decoded and decoded.jobId or nil if not jobId then uiState.isGenerating = false refreshActionStates() if chatHandles and chatHandles.failStep then chatHandles.failStep() end setOutput("Error: missing jobId\n") return end -- Job accepted → planning done; stay here until real Studio tool chunks arrive. if chatHandles and chatHandles.setStep then chatHandles.setStep(2) end local r = decoded.route if r == "visual_build" or r == "gameplay" then uiState.lastRoute = r else uiState.lastRoute = Build.classifyGenerationRoute(rawPrompt) end local header = "" if startFresh then header = "Job #" .. tostring(jobId) .. " started (" .. string.upper(currentMode) .. " mode)." else header = "Improve job #" .. tostring(jobId) .. " started (" .. string.upper(currentMode) .. " mode)." end header ..= "\nFast: ~15-45s. Full: longer but richer." if uiState.lastRoute == "visual_build" then header ..= "\nMode: VisualBuild (instances)" elseif uiState.lastRoute == "gameplay" then header ..= "\nMode: Gameplay (instances + scripts)" else header ..= "\nMode: Gameplay (instances + scripts)" end uiState.lastJobId = jobId local _finalText, canApply = pollJob(jobId, header, startFresh and "plugin_generate" or "plugin_improve") uiState.isGenerating = false refreshActionStates() if canApply and not cancelled then -- Step 5: auto-updating your game (apply / preview). if chatHandles and chatHandles.setStep then chatHandles.setStep(5) end if uiState.lastRoute == "visual_build" then applyToGame() setTab("preview") healthLabel.Text = Editor.formatPreviewPanel(uiState, uiState.lastHealth) else uiState.workflowStage = "preview" showToast("Preview ready - click Apply when satisfied") setTab("preview") healthLabel.Text = Editor.formatPreviewPanel(uiState, uiState.lastHealth) end if chatHandles and chatHandles.completeSteps then chatHandles.completeSteps() end else uiState.workflowStage = "idle" if cancelled then if chatHandles and chatHandles.failStep then chatHandles.failStep() end elseif chatHandles and chatHandles.failStep then chatHandles.failStep() end if cancelled and chatHandles and chatHandles.appendLogText then chatHandles.appendLogText("Stopped — no changes from this prompt.") end end end local runPartialImprove runPartialImprove = function(scopeId, scopeLabel) cancelled = false if uiState.isGenerating then return end if getToken() == "" then setOutput("Paste API token first.") return end local projectJson = uiState.lastProjectJson or getLastProjectJson() if not projectJson or trim(projectJson) == "" then showToast("Generate a build first") return end local ctx = uiState.lastContextSnapshot if not ctx or trim(ctx) == "" then showToast("No pipeline context — run a full JSON generate first") return end uiState.isGenerating = true refreshActionStates() setOutput(("Partial improve: %s\n"):format(scopeLabel)) lastJobSource = "plugin_improve" local ok, body = httpJson(API_BASE .. "/improve-job", "POST", { format = "json", projectJson = projectJson, request = ("Improve %s: keep everything else unchanged."):format(scopeLabel), mode = currentMode, scope = scopeId, contextSnapshot = ctx, }) if not ok then uiState.isGenerating = false refreshActionStates() setOutput("Error: " .. tostring(body)) return end local decoded = safeJsonDecode(body) local jobId = decoded and decoded.jobId or nil uiState.lastJobId = jobId if not jobId then uiState.isGenerating = false refreshActionStates() setOutput("Error: missing jobId\n") return end local _t, canApply = pollJob(jobId, "Partial improve: " .. scopeLabel, "plugin_improve") uiState.isGenerating = false refreshActionStates() if canApply then showToast("Partial improve ready") end end local function resumeFailedJob() local failedId = uiState.lastFailedJobId or uiState.lastJobId if not failedId then showToast("No failed job to resume") return end local ok, body = httpJson(API_BASE .. "/jobs/" .. tostring(failedId) .. "/resume", "POST", {}) if not ok then showToast("Resume failed") return end local decoded = safeJsonDecode(body) local jobId = decoded and decoded.jobId or nil if not jobId then showToast("Resume failed") return end uiState.isGenerating = true refreshActionStates() pollJob(jobId, "Resuming failed job...", "plugin_generate") uiState.isGenerating = false refreshActionStates() end local function clearBuild() cancelled = false uiState.isGenerating = false uiState.canApply = false uiState.lastJobId = nil uiState.lastRawOutput = nil uiState.lastProjectJson = nil uiState.lastDecoded = nil uiState.lastLuau = nil uiState.lastPrompt = nil uiState.lastRoute = nil uiState.hadStudioApply = false uiState.previewApplied = false refreshActionStates() stopLogWatch() local removed = Build.clearGenerated() + Build.deletePreviousGeneratedFolders() setLastProjectJson("") setOutput("") setPreview("") showToast("Reset complete (new idea)") setOutput("Reset complete. Next Generate starts a new idea.") end local function stop() if not uiState.isGenerating then return end if uiState._stopAllowedAt and tick() < uiState._stopAllowedAt then showToast("Still starting - wait a moment") return end -- IMPORTANT: keep uiState.isGenerating true until pollJob returns — otherwise Generate re-enters, -- clears `cancelled`, and the old poll loop keeps running (Stop feels "broken"). cancelled = true local jobId = uiState.lastJobId if jobId then -- Synchronous cancel so the worker sees it before the next poll finishes. pcall(function() httpJson(API_BASE .. "/jobs/" .. tostring(jobId) .. "/cancel", "POST", {}) end) end showToast("Stopping...") end -- ServerScripts (InsertService, etc.) do not run in Studio Edit mode — only after Play. local function decodedUsesInsertServiceLoadAsset(decoded) if type(decoded) ~= "table" then return false end local scripts = decoded.scripts if type(scripts) ~= "table" then return false end for _, spec in ipairs(scripts) do if type(spec) == "table" then local code = tostring(spec.code or "") if string.find(code, "InsertService", 1, true) and string.find(code, "LoadAsset", 1, true) then return true end end end return false end local function collectStaticScriptErrors(decoded) local errors = {} if type(decoded) ~= "table" or type(decoded.scripts) ~= "table" then return errors end for i, spec in ipairs(decoded.scripts) do if type(spec) == "table" and type(spec.code) == "string" then local code = spec.code local opens = select(2, code:gsub("%f[%a]function%f[%A]", "")) local dos = select(2, code:gsub("%f[%a]do%f[%A]", "")) local ends = select(2, code:gsub("%f[%a]end%f[%A]", "")) if opens + dos > ends then table.insert(errors, ("Script %s:%s — possible missing 'end'"):format(tostring(spec.name or i), tostring(spec.path or ""))) end if code:find("Enum%.Material%.Gold") then table.insert(errors, ("Script %s uses invalid Enum.Material.Gold"):format(tostring(spec.name or i))) end end end return errors end runCompileValidationPass = function() if not uiState.lastDecoded then return {} end return collectStaticScriptErrors(uiState.lastDecoded) end applyToGame = function() if uiState.isGenerating then return end if not uiState.lastLuau and not uiState.lastDecoded then showToast("Nothing to apply yet") return end -- Pre-apply snapshot for rollback (workspace + editor state) local workspaceSnapshot = captureWorkspaceSnapshot() if uiState.lastProjectJson or uiState.lastDecoded or uiState.lastLuau then commandHistory:push({ kind = "pre_apply", projectJson = uiState.lastProjectJson, decoded = uiState.lastDecoded, luau = uiState.lastLuau, prompt = uiState.lastPrompt, workspaceSnapshot = workspaceSnapshot, }) pcall(function() httpJson(API_BASE .. "/builds/snapshots", "POST", { label = "Before Apply", kind = "pre_apply", projectJson = uiState.lastProjectJson, }) end) end local ok, msg = false, "Nothing to apply." -- Prefer applying decoded JSON project (instances + scripts) when present. if uiState.lastDecoded then ok, msg = Build.applyDecodedToGame(uiState.lastDecoded) elseif uiState.lastLuau then if replacePrevious then Build.deletePreviousGeneratedFolders() Build.clearGenerated() end ok, msg = Build.applyLuauToGame(uiState.lastLuau) end -- Safety net: live preview may have already placed instances; treat a no-op apply as success. if not ok then local m = tostring(msg or "") local isNoOp = string.find(m, "Nothing to apply", 1, true) ~= nil -- Sometimes the preview flag can be lost (plugin reload/state reset), but the scene is already placed. -- If the last decoded payload had instances and this is a no-op, treat it as success. local hadInstances = false if uiState.lastDecoded and type(uiState.lastDecoded.instances) == "table" and #uiState.lastDecoded.instances > 0 then hadInstances = true end if isNoOp and (uiState.previewApplied or (uiState.lastRoute == "visual_build" and hadInstances)) then ok, msg = true, "Applied (already placed)" end end if ok then uiState.workflowStage = "applied" local staticErrors = runCompileValidationPass() if #staticErrors > 0 then for _, line in ipairs(staticErrors) do table.insert(errRing, line) end lastErrorAt = tick() end showToast("Build inserted successfully") local currentOut = outBox.Text or "" local nextSteps = "\n\n-- Next steps --\n" .. "Play to test. Then type a short change -> Generate -> Apply.\n" if not string.find(currentOut, "-- Next steps --", 1, true) then setOutput(currentOut ~= "" and (currentOut .. nextSteps) or nextSteps) currentOut = outBox.Text or "" end if uiState.lastRoute == "gameplay" then local tip = "\n\n-- Studio tip --\n" .. "World parts appear in Edit mode. Game logic scripts run when you press Play.\n" .. "Check **BB.SSS**, **BB.RepStorage/VibeCoderAI_Data**, and **StarterPlayer** for generated scripts.\n" if uiState.lastLuau then tip = tip .. "Legacy Luau-only build: **BB.SSS/VibeCoderAI/VC_Main** runs on Play only.\n" end if not string.find(currentOut, "-- Studio tip --", 1, true) then setOutput(currentOut ~= "" and (currentOut .. tip) or tip) currentOut = outBox.Text or "" end end if uiState.lastDecoded and decodedUsesInsertServiceLoadAsset(uiState.lastDecoded) then local note = "\n\n-- Roblox Studio tip --\n" .. "This build calls **InsertService:LoadAsset** in a ServerScript. That code runs only after you press Play - not while editing.\n" .. "Right after Apply, Studio only shows what is in **instances** in the JSON. If trees or cars are missing here, press Play to run the script, or Generate again with: \"add visible stand-in Parts in instances\".\n" if not string.find(currentOut, "-- Roblox Studio tip --", 1, true) then setOutput(currentOut ~= "" and (currentOut .. note) or note) end end if AUTO_FIX_ENABLED then startLogWatch() task.delay(0.6, function() -- Give scripts a moment to start before evaluating errors. runAutoFixIfNeeded() end) end else showToast("Apply failed") setTab("code") setOutput((outBox.Text ~= "" and (outBox.Text .. "\n\n") or "") .. "Apply error: " .. tostring(msg)) end end genBtn.MouseButton1Click:Connect(generate) clearBtn.MouseButton1Click:Connect(clearBuild) improveBtn.MouseButton1Click:Connect(function() improveMenuOpen = not improveMenuOpen improveMenu.Visible = improveMenuOpen end) stopBtn.MouseButton1Click:Connect(stop) applyBtn.MouseButton1Click:Connect(applyToGame) previewBtn.MouseButton1Click:Connect(function() setTab("preview") healthLabel.Text = Editor.formatPreviewPanel(uiState, uiState.lastHealth) setPreview(Editor.formatPreviewPanel(uiState, uiState.lastHealth)) if uiState.lastDecoded then local decoded = Build.normalizeApplyPayload(uiState.lastDecoded) if type(decoded.instances) == "table" and #decoded.instances > 0 then Build.applyPreviewInstances(decoded) uiState.previewApplied = true uiState.lastDecoded = decoded uiState.canApply = true refreshActionStates() end end end) undoBtn.MouseButton1Click:Connect(function() undoLastAgentBuild() end) redoBtn.MouseButton1Click:Connect(function() local entry = commandHistory:redo() if not entry then showToast("Nothing to redo") return end if entry.projectJson then setLastProjectJson(entry.projectJson) end if entry.decoded then uiState.lastDecoded = entry.decoded; uiState.canApply = true end if entry.luau then uiState.lastLuau = entry.luau; uiState.canApply = true end if entry.prompt then uiState.lastPrompt = entry.prompt end applyToGame() showToast("Redone (re-applied)") refreshActionStates() end) autoFixBtn.MouseButton1Click:Connect(function() if not AUTO_FIX_ENABLED then showToast("Auto Fix disabled") return end startLogWatch() if triggerAutoFix(true) then showToast("Auto Fix running...") else showToast("No errors captured — press Play first, then Auto Fix") end end) snapshotBtn.MouseButton1Click:Connect(function() if not uiState.lastProjectJson then showToast("Nothing to snapshot") return end commandHistory:push({ kind = "manual_snapshot", projectJson = uiState.lastProjectJson, decoded = uiState.lastDecoded, luau = uiState.lastLuau, prompt = uiState.lastPrompt, }) pcall(function() httpJson(API_BASE .. "/builds/snapshots", "POST", { label = "Manual snapshot", kind = "manual", projectJson = uiState.lastProjectJson, }) end) showToast("Snapshot saved") end) -- Bridge status check on open (non-blocking; toast only — keep chat greeting clean) task.defer(function() if not isPluginEditMode() then return end syncTokenToBridge(getToken()) task.wait(0.75) local ok = checkBridgeStatus() if ok then showToast("Studio Bridge connected — agent ready") else task.wait(1) if not uiState.isGenerating then showToast("Download Bridge from bloxbuilder.org/bridge/download, then save your API token") end end end) -- ── Phase 1: Chat shell (own function scope — Luau 200-local limit) ── ;(function() local ChatUI = rawget(_G, "BloxBuilderChatUI") if not (ChatUI and type(ChatUI.mount) == "function") then return end local function syncComposerToPrompt() if chatHandles then promptBox.Text = chatHandles.getPromptText() end end local function loadChatHistory() local ok, raw = pcall(function() return plugin:GetSetting(CHAT_HISTORY_KEY) end) if not ok or raw == nil then return {} end -- Prefer native table storage (avoids JSONEncode crashes on huge/emoji logs). if type(raw) == "table" then local list = {} for i, chat in ipairs(raw) do if type(chat) == "table" then list[#list + 1] = { id = tostring(chat.id or (#list + 1)), title = tostring(chat.title or ""):sub(1, 120), log = tostring(chat.log or ""):sub(1, 40000), prompt = tostring(chat.prompt or ""):sub(1, 4000), createdAt = tostring(chat.createdAt or ""), updatedAt = tostring(chat.updatedAt or ""), } end end return list end if type(raw) ~= "string" or raw == "" then return {} end local ok2, list = pcall(function() return BB.Http:JSONDecode(raw) end) if ok2 and type(list) == "table" then return list end return {} end local function sanitizeHistoryForStore(list) local clean = {} if type(list) ~= "table" then return clean end for _, chat in ipairs(list) do if type(chat) == "table" then -- Strip control chars + keep ASCII-ish safe text so Store never blows up. local function scrub(s, maxLen) s = tostring(s or "") -- remove null/controls s = s:gsub("%z", ""):gsub("[\1-\8\11\12\14-\31]", "") -- drop unpaired/exotic bytes that break some Studio serializers s = s:gsub("[\128-\255]", function(ch) local b = string.byte(ch) -- keep common UTF-8 continuation by replacing non-ASCII with '?' return "?" end) if #s > maxLen then s = string.sub(s, 1, maxLen) end return s end clean[#clean + 1] = { id = scrub(chat.id or ("chat_" .. (#clean + 1)), 64), title = scrub(chat.title or "", 120), log = scrub(chat.log or "", 20000), prompt = scrub(chat.prompt or "", 2000), createdAt = scrub(chat.createdAt or "", 40), updatedAt = scrub(chat.updatedAt or "", 40), } end end return clean end local function saveChatHistory(list) -- Never JSONEncode here — Studio debugger breaks on JSONEncode errors even inside pcall. -- PluginSettings stores Lua tables natively. local clean = sanitizeHistoryForStore(list) local ok = pcall(function() plugin:SetSetting(CHAT_HISTORY_KEY, clean) end) if not ok then -- Absolute fallback: titles only local titlesOnly = {} for i, c in ipairs(clean) do titlesOnly[i] = { id = c.id, title = c.title, log = "", prompt = string.sub(c.prompt or "", 1, 200), createdAt = c.createdAt, updatedAt = c.updatedAt, } end pcall(function() plugin:SetSetting(CHAT_HISTORY_KEY, titlesOnly) end) end end chatHandles = ChatUI.mount(widget, { promptMaxChars = PROMPT_MAX_CHARS, loadChatHistory = loadChatHistory, saveChatHistory = saveChatHistory, getTokenText = function() return tokenBox.Text or "" end, saveToken = function(tok) tokenBox.Text = tostring(tok or "") pcall(function() plugin:SetSetting(TOKEN_SETTING_KEY, tokenBox.Text) end) syncTokenToBridge(tokenBox.Text) end, onTokenSaved = function(tok) showToast("Token saved") syncTokenToBridge(tok or tokenBox.Text) end, onStart = function() syncComposerToPrompt() generate() end, onStop = function() stop() end, onSend = function() syncComposerToPrompt() generate() end, onUndo = function() undoLastAgentBuild() end, onNewChat = function() if uiState.isGenerating then stop() end promptBox.Text = "" outBox.Text = "" setPreview("") uiState.lastPrompt = nil refreshActionStates() end, }) chatHandles.syncFromLegacyPrompt(promptBox) local _setOutput = setOutput setOutput = function(t) _setOutput(t) if chatHandles then chatHandles.setLogText(t or "") end end promptBox:GetPropertyChangedSignal("Text"):Connect(function() -- Don't push legacy prompt text back into the chat composer mid-run -- (generate() clears both; without this guard the clear is undone). if uiState.isGenerating then return end if chatHandles and chatHandles.getPromptText() ~= promptBox.Text then chatHandles.setPromptText(promptBox.Text) end end) rootScroll.Visible = false rootScroll.ZIndex = 10 backdrop.Visible = false backdrop.ZIndex = 0 end)() -- Session recovery on open task.defer(function() local session = sessionRecovery:load() if session and Editor.hydrateSession(session, uiState, setLastProjectJson) then if uiState.lastHealth then healthLabel.Text = Editor.formatHealthPanel(uiState.lastHealth) end refreshHistoryPanel() refreshActionStates() showToast("Recovered last session") end end) button.Click:Connect(function() widget.Enabled = not widget.Enabled end)