@el4cteo/rbx-studio-mcp 0.4.6 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,436 @@
1
+ --!strict
2
+ --[[
3
+ The console's command line.
4
+
5
+ The panel was read-only for its whole life, and the footer even carried a
6
+ note explaining why it had no cursor: a blinking block promises that
7
+ something accepts input, and nothing did. That is what this changes. One row
8
+ under the header, always visible -- it does not ride the `visuals` toggle,
9
+ because a control that disappears with a decoration is a control nobody
10
+ trusts -- holding a caret and a field.
11
+
12
+ Two kinds of thing go in it and the row does not distinguish them, on
13
+ purpose. A word it knows is a command and runs here in milliseconds; anything
14
+ else is a sentence, and sentences go to an agent. Making the user mark which
15
+ is which would be asking them to know our implementation before they can
16
+ type.
17
+
18
+ History and completion are the two things that separate a prompt from a text
19
+ box, so both are here. Keyboard input inside a plugin widget is delivered to
20
+ the GuiObjects themselves rather than through UserInputService, which is why
21
+ the arrows and Tab are read off the field's own InputBegan.
22
+ ]]
23
+
24
+ local Themes = require(script.Parent.Themes)
25
+
26
+ local Prompt = {}
27
+
28
+ -- Tall enough for a 12pt line with air around it, short enough that it does not
29
+ -- read as a second header. The log gives up exactly this much.
30
+ Prompt.HEIGHT = 30
31
+
32
+ -- Matches the log's own left inset, so the caret sits in the same column as the
33
+ -- sigils below it and the whole panel reads as one grid.
34
+ local INSET = 12
35
+ local CARET_WIDTH = 14
36
+
37
+ --[[
38
+ How many past lines are kept.
39
+
40
+ A ring rather than a session log: this is for getting back to the thing you
41
+ typed a moment ago, and anyone scrolling past fifty entries wanted the log,
42
+ not the history.
43
+ ]]
44
+ local MAX_HISTORY = 50
45
+
46
+ type Runtime = {
47
+ row: Frame?,
48
+ caret: TextLabel?,
49
+ field: TextBox?,
50
+ hint: TextLabel?,
51
+ rule: Frame?,
52
+ --[[
53
+ Newest last. The entry being edited is NOT in here -- it is in the field
54
+ -- so the list holds only lines that were actually submitted.
55
+ ]]
56
+ history: { string },
57
+ --[[
58
+ Where in the history the user is, counted from the end. Zero means "at
59
+ the live line", which is the only state in which the field's contents
60
+ belong to the user rather than to a recalled entry.
61
+ ]]
62
+ offset: number,
63
+ -- What was being typed before the arrows took the field over, so coming back
64
+ -- down from the history returns it rather than an empty line.
65
+ draft: string,
66
+ busy: boolean,
67
+ --[[
68
+ What is selected in Studio right now, already worded for display.
69
+
70
+ Kept here because the prompt is where it earns its place: half of what
71
+ anyone types into this box is deictic -- "make this bigger", "delete
72
+ that" -- and the word only means something if you can see what it points
73
+ at while you are writing it. Studio's own Explorer is often scrolled
74
+ somewhere else, or behind the panel.
75
+ ]]
76
+ selection: string,
77
+ complete: ((string) -> { string })?,
78
+ submit: ((string) -> ())?,
79
+ }
80
+
81
+ local runtime: Runtime = {
82
+ row = nil,
83
+ caret = nil,
84
+ field = nil,
85
+ hint = nil,
86
+ rule = nil,
87
+ history = {},
88
+ offset = 0,
89
+ draft = "",
90
+ busy = false,
91
+ selection = "",
92
+ complete = nil,
93
+ submit = nil,
94
+ }
95
+
96
+ --[[
97
+ The caret's colour, which is the row's only status light.
98
+
99
+ Violet at rest is the project's accent and says "this is ours". Amber while
100
+ an agent runs says something is happening without stealing the caption's
101
+ line, which is already reporting what.
102
+ ]]
103
+ local function paintCaret()
104
+ local caret = runtime.caret
105
+ if caret == nil then
106
+ return
107
+ end
108
+ local palette = Themes.palette()
109
+ caret.TextColor3 = if runtime.busy then palette.amber else palette.violet
110
+ end
111
+
112
+ local function placeholder(): string
113
+ return if runtime.busy
114
+ then "running -- type stop to cancel"
115
+ else "type a command, or ask for something"
116
+ end
117
+
118
+ --[[
119
+ The greyed-out remainder of the command being typed.
120
+
121
+ Drawn as a separate label positioned after the text rather than inside the
122
+ field, because a TextBox that writes its own guess into itself has to undo
123
+ that on every keystroke and gets it wrong the moment the user types fast.
124
+ This only ever suggests; Tab is what accepts.
125
+ ]]
126
+ local function showHint()
127
+ local field = runtime.field
128
+ local hint = runtime.hint
129
+ local complete = runtime.complete
130
+ if field == nil or hint == nil or complete == nil then
131
+ return
132
+ end
133
+
134
+ --[[
135
+ One slot, two things to say, and typing wins.
136
+
137
+ The completion is about the keystroke happening right now; the selection
138
+ is standing context. Giving them separate slots would put two competing
139
+ readouts on one 30-pixel row, so they share it and the more urgent one
140
+ takes it.
141
+ ]]
142
+ local typed = field.Text
143
+ -- Only the first word completes. Everything after it is an argument, and
144
+ -- guessing at arguments is how a prompt starts fighting the person using it.
145
+ if typed ~= "" and string.find(typed, "%s") == nil then
146
+ local matches = complete(typed)
147
+ if #matches == 1 and matches[1] ~= typed then
148
+ hint.Text = string.format("%s tab", matches[1])
149
+ return
150
+ elseif #matches > 1 then
151
+ hint.Text = table.concat(matches, " ")
152
+ return
153
+ end
154
+ end
155
+
156
+ hint.Text = runtime.selection
157
+ end
158
+
159
+ --[[
160
+ Puts a past line back in the field.
161
+
162
+ `offset` counts backwards from the newest entry, so 1 is the last thing
163
+ typed. Zero is the live line, which is the draft rather than any entry.
164
+ ]]
165
+ local function recall(offset: number)
166
+ local field = runtime.field
167
+ if field == nil then
168
+ return
169
+ end
170
+ local total = #runtime.history
171
+ local wanted = math.clamp(offset, 0, total)
172
+
173
+ -- Stepping off the live line for the first time: keep what was there, or
174
+ -- walking up and back down silently eats a half-typed command.
175
+ if runtime.offset == 0 and wanted > 0 then
176
+ runtime.draft = field.Text
177
+ end
178
+
179
+ runtime.offset = wanted
180
+ field.Text = if wanted == 0 then runtime.draft else runtime.history[total - wanted + 1]
181
+ field.CursorPosition = #field.Text + 1
182
+ showHint()
183
+ end
184
+
185
+ local function remember(line: string)
186
+ local history = runtime.history
187
+ -- A line repeated back to back is one entry. Pressing up should walk through
188
+ -- what was done, not through how many times it was retried.
189
+ if history[#history] ~= line then
190
+ table.insert(history, line)
191
+ end
192
+ while #history > MAX_HISTORY do
193
+ table.remove(history, 1)
194
+ end
195
+ runtime.offset = 0
196
+ runtime.draft = ""
197
+ end
198
+
199
+ --[[
200
+ Accepts the suggestion, when there is exactly one.
201
+
202
+ Several matches print themselves in the hint and do nothing, which is what
203
+ every shell does: completing to a common prefix saves a keystroke and costs
204
+ the user the list they were about to read.
205
+ ]]
206
+ local function acceptHint()
207
+ local field = runtime.field
208
+ local complete = runtime.complete
209
+ if field == nil or complete == nil or field.Text == "" then
210
+ return
211
+ end
212
+ local matches = complete(field.Text)
213
+ if #matches ~= 1 then
214
+ return
215
+ end
216
+ field.Text = matches[1] .. " "
217
+ field.CursorPosition = #field.Text + 1
218
+ showHint()
219
+ end
220
+
221
+ --[[
222
+ Builds the row. Called once from `Console.mount`, positioned by it.
223
+ ]]
224
+ function Prompt.mount(parent: Instance, handlers: { submit: (string) -> (), complete: (string) -> { string } })
225
+ local palette = Themes.palette()
226
+ runtime.submit = handlers.submit
227
+ runtime.complete = handlers.complete
228
+
229
+ local row = Instance.new("Frame")
230
+ row.Name = "Prompt"
231
+ row.BackgroundColor3 = palette.background
232
+ row.BorderSizePixel = 0
233
+ row.Parent = parent
234
+ runtime.row = row
235
+
236
+ local caret = Instance.new("TextLabel")
237
+ caret.Name = "Caret"
238
+ caret.Text = "\u{203A}"
239
+ caret.Font = Enum.Font.Code
240
+ caret.TextSize = 14
241
+ caret.TextColor3 = palette.violet
242
+ caret.TextXAlignment = Enum.TextXAlignment.Left
243
+ caret.BackgroundTransparency = 1
244
+ caret.Position = UDim2.fromOffset(INSET, 0)
245
+ caret.Size = UDim2.new(0, CARET_WIDTH, 1, 0)
246
+ caret.Parent = row
247
+ runtime.caret = caret
248
+
249
+ local left = INSET + CARET_WIDTH
250
+
251
+ --[[
252
+ The hint sits at the right end rather than immediately after the text.
253
+
254
+ Following the cursor means measuring the text every keystroke, and a
255
+ suggestion that jitters horizontally while you type is worse than one
256
+ parked somewhere predictable.
257
+ ]]
258
+ local hint = Instance.new("TextLabel")
259
+ hint.Name = "Hint"
260
+ hint.Text = ""
261
+ hint.Font = Enum.Font.Code
262
+ hint.TextSize = 11
263
+ hint.TextColor3 = palette.dim
264
+ hint.TextXAlignment = Enum.TextXAlignment.Right
265
+ hint.TextTruncate = Enum.TextTruncate.AtEnd
266
+ hint.BackgroundTransparency = 1
267
+ hint.AnchorPoint = Vector2.new(1, 0)
268
+ hint.Position = UDim2.new(1, -INSET, 0, 0)
269
+ hint.Size = UDim2.new(0.5, 0, 1, 0)
270
+ hint.Parent = row
271
+ runtime.hint = hint
272
+
273
+ local field = Instance.new("TextBox")
274
+ field.Name = "Field"
275
+ field.Text = ""
276
+ field.PlaceholderText = placeholder()
277
+ field.PlaceholderColor3 = palette.dim
278
+ field.Font = Enum.Font.Code
279
+ field.TextSize = 12
280
+ field.TextColor3 = palette.text
281
+ field.TextXAlignment = Enum.TextXAlignment.Left
282
+ field.BackgroundTransparency = 1
283
+ field.BorderSizePixel = 0
284
+ -- Cleared on submit, not on focus: clicking away to read a line above and
285
+ -- clicking back must not cost what was typed.
286
+ field.ClearTextOnFocus = false
287
+ field.ClipsDescendants = true
288
+ field.Position = UDim2.fromOffset(left, 0)
289
+ field.Size = UDim2.new(1, -(left + INSET), 1, 0)
290
+ field.Parent = row
291
+ runtime.field = field
292
+
293
+ -- A hairline ABOVE the row, because the row sits at the foot of the panel:
294
+ -- the edge worth drawing is the one between the log and the input, and a
295
+ -- second line just above the status bar would only crowd it.
296
+ local rule = Instance.new("Frame")
297
+ rule.Name = "Rule"
298
+ rule.AnchorPoint = Vector2.new(0, 0)
299
+ rule.Position = UDim2.fromScale(0, 0)
300
+ rule.Size = UDim2.new(1, 0, 0, 1)
301
+ rule.BackgroundColor3 = palette.rule
302
+ rule.BackgroundTransparency = 0.4
303
+ rule.BorderSizePixel = 0
304
+ rule.Parent = row
305
+ runtime.rule = rule
306
+
307
+ field:GetPropertyChangedSignal("Text"):Connect(function()
308
+ -- Typing puts the user back on the live line. Without this, editing a
309
+ -- recalled entry and pressing down would throw the edit away.
310
+ if runtime.offset ~= 0 then
311
+ runtime.offset = 0
312
+ runtime.draft = ""
313
+ end
314
+ showHint()
315
+ end)
316
+
317
+ field.FocusLost:Connect(function(enterPressed)
318
+ if not enterPressed then
319
+ return
320
+ end
321
+ local line = string.match(field.Text, "^%s*(.-)%s*$") or ""
322
+ field.Text = ""
323
+ if runtime.hint then
324
+ (runtime.hint :: TextLabel).Text = ""
325
+ end
326
+ if line == "" then
327
+ return
328
+ end
329
+ remember(line)
330
+ local run = runtime.submit
331
+ if run ~= nil then
332
+ -- Spawned so a slow command -- one that waits on the bridge -- cannot
333
+ -- hold the field unresponsive while it runs.
334
+ task.spawn(run, line)
335
+ end
336
+ -- Re-focused on the next frame so a burst of commands can be typed
337
+ -- without reaching for the mouse. Enter releases focus first, so this
338
+ -- has to happen after Roblox is done taking it away.
339
+ task.defer(function()
340
+ if field.Parent ~= nil then
341
+ field:CaptureFocus()
342
+ end
343
+ end)
344
+ end)
345
+
346
+ field.InputBegan:Connect(function(input)
347
+ if input.UserInputType ~= Enum.UserInputType.Keyboard then
348
+ return
349
+ end
350
+ if input.KeyCode == Enum.KeyCode.Up then
351
+ recall(runtime.offset + 1)
352
+ elseif input.KeyCode == Enum.KeyCode.Down then
353
+ recall(runtime.offset - 1)
354
+ elseif input.KeyCode == Enum.KeyCode.Tab then
355
+ acceptHint()
356
+ end
357
+ end)
358
+ end
359
+
360
+ --[[
361
+ Puts the cursor in the field, for the toolbar button and for `help`.
362
+
363
+ Guarded: the widget can be closed while a command is still finishing, and
364
+ capturing focus on a field whose window is gone throws.
365
+ ]]
366
+ function Prompt.focus()
367
+ local field = runtime.field
368
+ if field ~= nil and field.Parent ~= nil then
369
+ pcall(function()
370
+ field:CaptureFocus()
371
+ end)
372
+ end
373
+ end
374
+
375
+ --[[
376
+ Reports that an agent is running, in the one place the user is looking.
377
+
378
+ The log says what the agent is doing and the band says how long it is
379
+ taking; this says only that the line they are about to type will queue behind
380
+ something. That is a fact about the input, so it belongs to the input.
381
+ ]]
382
+ function Prompt.setBusy(busy: boolean)
383
+ runtime.busy = busy
384
+ paintCaret()
385
+ local field = runtime.field
386
+ if field ~= nil then
387
+ field.PlaceholderText = placeholder()
388
+ end
389
+ end
390
+
391
+ --[[
392
+ Whether an agent started from this prompt is still working.
393
+
394
+ Read by the console before it announces a silence. The flag lives here
395
+ because the caret is what displays it, and one owner beats two that have to
396
+ be kept in step.
397
+ ]]
398
+ --[[
399
+ Reports what Studio has selected, as a phrase rather than a list.
400
+
401
+ Called from the selection watcher, which has already debounced and worded
402
+ it. This only decides whether it is on screen, which is `showHint`'s job
403
+ anyway -- so the value is stored and the one renderer is asked to run again.
404
+ ]]
405
+ function Prompt.setSelection(text: string)
406
+ runtime.selection = text
407
+ showHint()
408
+ end
409
+
410
+ function Prompt.isBusy(): boolean
411
+ return runtime.busy
412
+ end
413
+
414
+ function Prompt.applyTheme()
415
+ local palette = Themes.palette()
416
+ local row = runtime.row
417
+ if row then
418
+ row.BackgroundColor3 = palette.background
419
+ end
420
+ local field = runtime.field
421
+ if field then
422
+ field.TextColor3 = palette.text
423
+ field.PlaceholderColor3 = palette.dim
424
+ end
425
+ local hint = runtime.hint
426
+ if hint then
427
+ hint.TextColor3 = palette.dim
428
+ end
429
+ local rule = runtime.rule
430
+ if rule then
431
+ rule.BackgroundColor3 = palette.rule
432
+ end
433
+ paintCaret()
434
+ end
435
+
436
+ return Prompt
@@ -52,13 +52,60 @@ end
52
52
  Raises SCRIPT_LOCKED when the write is refused, which normally means the
53
53
  script is a package member or is owned by another Team Create session.
54
54
  ]]
55
+ --[[
56
+ Makes sure a write that reported success actually stuck.
57
+
58
+ `UpdateSourceAsync` returns true and the editor buffer shows the new text --
59
+ and then, a third of a second later, the freshly opened document finishes
60
+ loading and overwrites the buffer with the source from disk. The write is
61
+ gone, nothing raised, and the tool reports the edit applied. Measured:
62
+
63
+ updateOk = true
64
+ immediately = "M.rate = 99" <- the write landed
65
+ after 0.5s = "M.rate = 60" <- and was thrown away
66
+
67
+ Reading back straight after the write therefore proves nothing; the first
68
+ version of this check did exactly that, saw a match, and returned happy
69
+ while the edit was still about to be discarded. The read has to happen after
70
+ the load could have finished.
71
+
72
+ Nothing here opens editor tabs any more, so the loading document that
73
+ caused this is only reachable when the USER opens one in the same moment we
74
+ write to it -- rare, and cheap enough to cover anyway. One settle, one read,
75
+ and a rewrite if it did not take.
76
+ ]]
77
+ local SETTLE = 0.2
78
+
79
+ local function confirm(target: LuaSourceContainer, produced: string?)
80
+ if produced == nil then
81
+ return
82
+ end
83
+
84
+ -- Three rounds: the race is with one load, so a single rewrite settles it,
85
+ -- and a script that discards three identical writes is failing for a reason
86
+ -- repeating will not fix.
87
+ for _ = 1, 3 do
88
+ task.wait(SETTLE)
89
+ if ScriptEdit.read(target) == produced then
90
+ return
91
+ end
92
+ pcall(function()
93
+ ScriptEditorService:UpdateSourceAsync(target, function()
94
+ return produced
95
+ end)
96
+ end)
97
+ end
98
+ end
99
+
55
100
  function ScriptEdit.write(target: LuaSourceContainer, transform: (string) -> string)
56
101
  local pending: any = nil
57
102
 
103
+ local produced: string? = nil
58
104
  local ok, err = pcall(function()
59
105
  ScriptEditorService:UpdateSourceAsync(target, function(source)
60
106
  local applied, result = pcall(transform, source)
61
107
  if applied then
108
+ produced = result
62
109
  return result
63
110
  end
64
111
  pending = result
@@ -70,6 +117,7 @@ function ScriptEdit.write(target: LuaSourceContainer, transform: (string) -> str
70
117
  error(pending, 0)
71
118
  end
72
119
  if ok then
120
+ confirm(target, produced)
73
121
  return
74
122
  end
75
123