@el4cteo/rbx-studio-mcp 0.6.5 → 0.6.7

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.
Files changed (46) hide show
  1. package/README.md +28 -2
  2. package/dist/bridge/console.js +182 -0
  3. package/dist/bridge/console.js.map +1 -1
  4. package/dist/index.js +4 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/cloudassets.js +233 -0
  7. package/dist/lib/cloudassets.js.map +1 -0
  8. package/dist/lib/credentials.js +180 -0
  9. package/dist/lib/credentials.js.map +1 -0
  10. package/dist/lib/livedata.js +325 -0
  11. package/dist/lib/livedata.js.map +1 -0
  12. package/dist/lib/liveluau.js +83 -0
  13. package/dist/lib/liveluau.js.map +1 -0
  14. package/dist/lib/liveops.js +358 -0
  15. package/dist/lib/liveops.js.map +1 -0
  16. package/dist/lib/opencloud.js +235 -0
  17. package/dist/lib/opencloud.js.map +1 -0
  18. package/dist/tools/audio.js +96 -0
  19. package/dist/tools/audio.js.map +1 -0
  20. package/dist/tools/data.js +122 -3
  21. package/dist/tools/data.js.map +1 -1
  22. package/dist/tools/exec.js +48 -1
  23. package/dist/tools/exec.js.map +1 -1
  24. package/dist/tools/scripts.js +112 -4
  25. package/dist/tools/scripts.js.map +1 -1
  26. package/dist/tools/spatial.js +135 -0
  27. package/dist/tools/spatial.js.map +1 -0
  28. package/dist/tools/universe.js +177 -0
  29. package/dist/tools/universe.js.map +1 -0
  30. package/dist/tools/upload.js +294 -0
  31. package/dist/tools/upload.js.map +1 -0
  32. package/dist/tools/world.js +361 -10
  33. package/dist/tools/world.js.map +1 -1
  34. package/package.json +74 -74
  35. package/plugin/src/Commands.luau +646 -622
  36. package/plugin/src/Config.luau +1 -1
  37. package/plugin/src/Phrase.luau +816 -766
  38. package/plugin/src/Prompt.luau +965 -961
  39. package/plugin/src/Secret.luau +86 -0
  40. package/plugin/src/Serialize.luau +759 -499
  41. package/plugin/src/handlers/Assets.luau +636 -587
  42. package/plugin/src/handlers/Audio.luau +411 -0
  43. package/plugin/src/handlers/Geometry.luau +722 -577
  44. package/plugin/src/handlers/Instances.luau +84 -4
  45. package/plugin/src/handlers/Spatial.luau +334 -0
  46. package/plugin/src/init.server.luau +883 -879
@@ -1,961 +1,965 @@
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
- Above the row sits a scrolling menu of the commands matching what is being
19
- typed -- all of them, while the field is empty. The placeholder used to say
20
- "type a command" and then leave the reader to work out which commands
21
- existed, which is a prompt keeping a secret. The list is the answer, and it
22
- is shown for the same reason a shell shows you its completions rather than
23
- describing them.
24
-
25
- WHY THE MENU IS DRIVEN BY THE MOUSE AND BY TYPING, AND NOT BY ARROW KEYS.
26
-
27
- Because Studio does not deliver keyboard input to plugin widgets at all.
28
- UserInputService and ContextActionService are documented as not working
29
- inside a PluginGui, and a GuiObject's own InputBegan never sees a key either
30
- -- the request asking Roblox for a way to read keys in a plugin window has
31
- been open since 2020 and was bumped again a month ago:
32
-
33
- https://devforum.roblox.com/t/plugins-need-a-way-to-listen-to-app-input/479597
34
- https://devforum.roblox.com/t/text-editor-make-editing-text-fields-much-easier/684771
35
-
36
- This was learned the expensive way: an arrow-key highlight was written, then
37
- rewritten against UserInputService, then wired to both at once, and all three
38
- were dead on arrival. Mouse input works -- that is what the theme drawer runs
39
- on -- and the TextBox reports its own text and its own Enter. Between them
40
- they are enough, so the menu offers exactly those and the caption promises
41
- nothing else. A key that a caption advertises and the platform cannot deliver
42
- is worse than no key at all.
43
- ]]
44
-
45
- local Themes = require(script.Parent.Themes)
46
-
47
- local Prompt = {}
48
-
49
- -- Tall enough for a 12pt line with air around it, short enough that it does not
50
- -- read as a second header. The log gives up exactly this much.
51
- Prompt.HEIGHT = 30
52
-
53
- -- Matches the log's own left inset, so the caret sits in the same column as the
54
- -- sigils below it and the whole panel reads as one grid.
55
- local INSET = 12
56
- local CARET_WIDTH = 14
57
-
58
- --[[
59
- How many past lines are kept.
60
-
61
- A ring rather than a session log: this is for getting back to the thing you
62
- typed a moment ago, and anyone scrolling past fifty entries wanted the log,
63
- not the history.
64
- ]]
65
- local MAX_HISTORY = 50
66
-
67
- --[[
68
- The command menu's shape.
69
-
70
- Seven rows is about as tall as this can be before it stops being a hint over
71
- the log and starts being a page covering it. Everything past that is reached
72
- by scrolling, which is the mouse's job here rather than a key's.
73
- ]]
74
- local MENU_ROW_HEIGHT = 20
75
- local MENU_MAX_ROWS = 7
76
- local MENU_PAD = 6
77
- local MENU_CAPTION_HEIGHT = 16
78
- local MENU_SCROLLBAR = 4
79
- -- Gap between the menu's bottom edge and the top of the prompt row.
80
- local MENU_LIFT = 2
81
- -- Where the summary column starts, measured from the menu's left edge. Wide
82
- -- enough for `agent [use <id>|new]`, the longest usage line there is.
83
- local MENU_SUMMARY_X = 140
84
-
85
- --[[
86
- Explicit depths, because a plugin widget draws with ZIndexBehavior.Global
87
- and a child does not inherit its parent's. Above the log and the CRT flourish
88
- that sit at 20-22; below the preset drawer at 30, which is modal and must
89
- stay clickable when it is open.
90
- ]]
91
- local Z_MENU = 24
92
- local Z_MENU_SCROLL = 25
93
- local Z_MENU_ROW = 26
94
- local Z_MENU_TEXT = 27
95
-
96
- type Suggestion = {
97
- name: string,
98
- usage: string,
99
- summary: string,
100
- }
101
-
102
- type MenuRow = {
103
- button: TextButton,
104
- usage: TextLabel,
105
- summary: TextLabel,
106
- --[[
107
- The command this row is currently showing, and whether its usage line
108
- mentions an argument.
109
-
110
- Held on the row rather than looked up when it is clicked. The lookup was
111
- a real bug: clicking takes focus off the field, the focus handler emptied
112
- the match list, and by the time the click arrived there was nothing left
113
- to resolve -- so every click did nothing at all.
114
- ]]
115
- entry: string,
116
- takesArgument: boolean,
117
- }
118
-
119
- type Runtime = {
120
- row: Frame?,
121
- caret: TextLabel?,
122
- field: TextBox?,
123
- hint: TextLabel?,
124
- rule: Frame?,
125
- menu: Frame?,
126
- menuScroll: ScrollingFrame?,
127
- menuCaption: TextLabel?,
128
- menuRows: { MenuRow },
129
- --[[
130
- What the menu is currently offering. Empty means it is closed; there is
131
- no separate flag, because two ways to ask whether a list is showing is
132
- one way too many.
133
- ]]
134
- matches: { Suggestion },
135
- -- Which row the pointer is over, 1-based into `matches`; 0 for none.
136
- hovered: number,
137
- -- Whether the field currently holds the keyboard. The menu is a thing you
138
- -- type at, so it has no business being on screen when nobody is typing.
139
- focused: boolean,
140
- --[[
141
- Whether the pointer is over the menu.
142
-
143
- Checked before the menu is taken down on focus loss, and that ordering is
144
- the whole reason it exists: pressing the mouse on a row releases the
145
- field's focus BEFORE the click completes, so a menu that closes on focus
146
- loss deletes the button mid-press and the click never happens.
147
- ]]
148
- hovering: boolean,
149
- --[[
150
- Newest last. The entry being edited is NOT in here -- it is in the field
151
- -- so the list holds only lines that were actually submitted.
152
- ]]
153
- history: { string },
154
- --[[
155
- Where in the history the user is, counted from the end. Zero means "at
156
- the live line", which is the only state in which the field's contents
157
- belong to the user rather than to a recalled entry.
158
- ]]
159
- offset: number,
160
- -- What was being typed before the history took the field over, so coming
161
- -- back returns it rather than an empty line.
162
- draft: string,
163
- busy: boolean,
164
- --[[
165
- What is selected in Studio right now, already worded for display.
166
-
167
- Kept here because the prompt is where it earns its place: half of what
168
- anyone types into this box is deictic -- "make this bigger", "delete
169
- that" -- and the word only means something if you can see what it points
170
- at while you are writing it. Studio's own Explorer is often scrolled
171
- somewhere else, or behind the panel.
172
- ]]
173
- selection: string,
174
- complete: ((string) -> { string })?,
175
- suggest: ((string) -> { Suggestion })?,
176
- submit: ((string) -> ())?,
177
- }
178
-
179
- local runtime: Runtime = {
180
- row = nil,
181
- caret = nil,
182
- field = nil,
183
- hint = nil,
184
- rule = nil,
185
- menu = nil,
186
- menuScroll = nil,
187
- menuCaption = nil,
188
- menuRows = {},
189
- matches = {},
190
- hovered = 0,
191
- focused = false,
192
- hovering = false,
193
- history = {},
194
- offset = 0,
195
- draft = "",
196
- busy = false,
197
- selection = "",
198
- complete = nil,
199
- suggest = nil,
200
- submit = nil,
201
- }
202
-
203
- --[[
204
- The caret's colour, which is the row's only status light.
205
-
206
- Violet at rest is the project's accent and says "this is ours". Amber while
207
- an agent runs says something is happening without stealing the caption's
208
- line, which is already reporting what.
209
- ]]
210
- local function paintCaret()
211
- local caret = runtime.caret
212
- if caret == nil then
213
- return
214
- end
215
- local palette = Themes.palette()
216
- caret.TextColor3 = if runtime.busy then palette.amber else palette.violet
217
- end
218
-
219
- local function placeholder(): string
220
- return if runtime.busy
221
- then "running -- type stop to cancel"
222
- else "type a command, or ask for something"
223
- end
224
-
225
- --[[
226
- Whether the command menu is on screen.
227
-
228
- Derived from the match list rather than from a flag of its own: the menu is
229
- the list, and a second source of truth for "is it showing" is a second thing
230
- that can be wrong.
231
- ]]
232
- local function isMenuOpen(): boolean
233
- local menu = runtime.menu
234
- return menu ~= nil and menu.Visible and #runtime.matches > 0
235
- end
236
-
237
- --[[
238
- Draws the current matches into the pre-built rows.
239
-
240
- The rows exist from `mount` -- one per command, since that number only
241
- changes when the source does -- and are only ever shown, hidden and
242
- relabelled. Building them per keystroke would mean four `Instance.new` calls
243
- per visible command on every letter typed, and a menu that flickers as it is
244
- rebuilt is worse than no menu.
245
- ]]
246
- local function renderMenu()
247
- local menu = runtime.menu
248
- local scroll = runtime.menuScroll
249
- local caption = runtime.menuCaption
250
- if menu == nil or scroll == nil or caption == nil then
251
- return
252
- end
253
-
254
- local total = #runtime.matches
255
- if total == 0 then
256
- menu.Visible = false
257
- return
258
- end
259
-
260
- local palette = Themes.palette()
261
-
262
- for position, row in runtime.menuRows do
263
- local entry = runtime.matches[position]
264
- if entry == nil then
265
- row.button.Visible = false
266
- row.entry = ""
267
- continue
268
- end
269
-
270
- row.button.Visible = true
271
- row.button.LayoutOrder = position
272
- row.button.BackgroundColor3 = palette.surface
273
- row.button.BackgroundTransparency = if position == runtime.hovered then 0.25 else 1
274
- row.usage.Text = entry.usage
275
- row.usage.TextColor3 = if position == runtime.hovered then palette.violet else palette.text
276
- row.summary.Text = entry.summary
277
- row.summary.TextColor3 = palette.dim
278
- row.entry = entry.name
279
- -- A usage line that is more than the bare name -- `theme [name]`,
280
- -- `use <number|id>` -- is a command that wants an argument, and those
281
- -- are the only ones worth leaving a space after.
282
- row.takesArgument = entry.usage ~= entry.name
283
- end
284
-
285
- --[[
286
- Only as tall as it needs to be, up to seven rows. The rest is reached by
287
- scrolling, and the scrollbar appearing is what says there is more.
288
- ]]
289
- local visible = math.min(total, MENU_MAX_ROWS)
290
- scroll.Size = UDim2.new(1, 0, 0, visible * MENU_ROW_HEIGHT)
291
- scroll.ScrollBarImageColor3 = palette.dim
292
-
293
- caption.Text = if total == 1
294
- then "1 command click it, or press enter to run"
295
- else string.format("%d commands click one, or keep typing", total)
296
- caption.TextColor3 = palette.dim
297
- -- Indented to the same column as the rows above it. This used to be flush
298
- -- against the frame's edge, which made it read as something that had fallen
299
- -- out of the menu rather than as part of it.
300
- caption.Position = UDim2.fromOffset(MENU_PAD, MENU_PAD + visible * MENU_ROW_HEIGHT)
301
-
302
- menu.BackgroundColor3 = palette.background
303
- menu.Size =
304
- UDim2.new(1, -(INSET * 2), 0, MENU_PAD * 2 + visible * MENU_ROW_HEIGHT + MENU_CAPTION_HEIGHT)
305
- menu.Visible = true
306
- end
307
-
308
- local function closeMenu()
309
- runtime.matches = {}
310
- runtime.hovered = 0
311
- local menu = runtime.menu
312
- if menu then
313
- menu.Visible = false
314
- end
315
- end
316
-
317
- --[[
318
- The greyed-out remainder of the command being typed.
319
-
320
- Drawn as a separate label positioned after the text rather than inside the
321
- field, because a TextBox that writes its own guess into itself has to undo
322
- that on every keystroke and gets it wrong the moment the user types fast.
323
- ]]
324
- local function showHint()
325
- local field = runtime.field
326
- local hint = runtime.hint
327
- local complete = runtime.complete
328
- if field == nil or hint == nil or complete == nil then
329
- return
330
- end
331
-
332
- --[[
333
- The menu says it better, when the menu is up.
334
-
335
- This row used to carry the whole match list crammed into half a line of
336
- 11pt text. Now that the same matches are listed properly above the field,
337
- repeating them here would be noise competing with the thing it duplicates
338
- -- so the slot goes back to the selection, which has nowhere else to be.
339
- ]]
340
- if isMenuOpen() then
341
- hint.Text = runtime.selection
342
- return
343
- end
344
-
345
- local typed = field.Text
346
- -- Only the first word completes. Everything after it is an argument, and
347
- -- guessing at arguments is how a prompt starts fighting the person using it.
348
- if typed ~= "" and string.find(typed, "%s") == nil then
349
- local matches = complete(typed)
350
- if #matches == 1 and matches[1] ~= typed then
351
- hint.Text = matches[1]
352
- return
353
- end
354
- end
355
-
356
- hint.Text = runtime.selection
357
- end
358
-
359
- --[[
360
- Recomputes what the menu offers, from what is in the field right now.
361
-
362
- One rule, and only one: the list is up whenever the bar has the cursor and
363
- holds at most one word. There used to be two more -- a "dismissed" flag and a
364
- "resuming" flag -- and between them they were the bug that would not die:
365
- clicking the empty bar after running a command showed nothing, because a flag
366
- set on the way out of the last command was still holding the list shut and
367
- only a keystroke cleared it. Flags that guess at intent are how that happens.
368
- State this small should be derivable from what is on screen, so now it is.
369
-
370
- The text is trimmed before it is judged, because whitespace is not content. A
371
- bar holding nothing but a space is an empty bar, and it was being treated as a
372
- line with an argument in it -- which closed the menu and left no way to tell
373
- why.
374
- ]]
375
- local function refreshMenu()
376
- local field = runtime.field
377
- local suggest = runtime.suggest
378
- if field == nil or suggest == nil then
379
- return
380
- end
381
-
382
- local trimmed = string.match(field.Text, "^%s*(.-)%s*$") or ""
383
-
384
- -- A space INSIDE the line means the first word is settled and an argument is
385
- -- being written, which is the point at which a list of commands stops being
386
- -- what the person typing needs to see.
387
- if not runtime.focused or string.find(trimmed, "%s") ~= nil then
388
- closeMenu()
389
- showHint()
390
- return
391
- end
392
-
393
- runtime.matches = suggest(trimmed)
394
- -- Nothing is highlighted until the pointer picks something. A highlight that
395
- -- appears on its own is a selection the user did not make, and clicking is
396
- -- the only way to act on it anyway.
397
- runtime.hovered = 0
398
-
399
- --[[
400
- Back to the top, but only here.
401
-
402
- This used to live in `renderMenu`, which also runs on every hover -- so
403
- scrolling to the bottom of the list and then moving the mouse a pixel
404
- snapped it straight back to the first row. The scroll position is only
405
- meaningless when the list underneath it has changed, and that is exactly
406
- what this function is for.
407
- ]]
408
- local scroll = runtime.menuScroll
409
- if scroll then
410
- scroll.CanvasPosition = Vector2.new(0, 0)
411
- end
412
-
413
- renderMenu()
414
- showHint()
415
- end
416
-
417
- --[[
418
- Puts a command in the field, without running it.
419
-
420
- A trailing space only where the command takes an argument. It used to be
421
- added unconditionally, which left `clear ` sitting in the bar with a space
422
- the user then had to notice and delete -- reported as exactly that. A command
423
- that takes nothing is complete the moment its name is there, and Enter runs
424
- it.
425
- ]]
426
- local function fillWith(name: string, takesArgument: boolean)
427
- local field = runtime.field
428
- if field == nil or name == "" then
429
- return
430
- end
431
- closeMenu()
432
- field.Text = if takesArgument then name .. " " else name
433
- field.CursorPosition = #field.Text + 1
434
- showHint()
435
- end
436
-
437
- --[[
438
- Puts a past line back in the field.
439
-
440
- `offset` counts backwards from the newest entry, so 1 is the last thing
441
- typed. Zero is the live line, which is the draft rather than any entry.
442
- ]]
443
- local function recall(offset: number)
444
- local field = runtime.field
445
- if field == nil then
446
- return
447
- end
448
- local total = #runtime.history
449
- local wanted = math.clamp(offset, 0, total)
450
-
451
- -- Stepping off the live line for the first time: keep what was there, or
452
- -- walking up and back down silently eats a half-typed command.
453
- if runtime.offset == 0 and wanted > 0 then
454
- runtime.draft = field.Text
455
- end
456
-
457
- runtime.offset = wanted
458
- field.Text = if wanted == 0 then runtime.draft else runtime.history[total - wanted + 1]
459
- field.CursorPosition = #field.Text + 1
460
- showHint()
461
- end
462
-
463
- local function remember(line: string)
464
- local history = runtime.history
465
- -- A line repeated back to back is one entry. Pressing up should walk through
466
- -- what was done, not through how many times it was retried.
467
- if history[#history] ~= line then
468
- table.insert(history, line)
469
- end
470
- while #history > MAX_HISTORY do
471
- table.remove(history, 1)
472
- end
473
- runtime.offset = 0
474
- runtime.draft = ""
475
- end
476
-
477
- --[[
478
- Builds the menu's rows, one per command.
479
-
480
- Sized from the full command list rather than from the seven that fit, so
481
- filtering is a matter of hiding rows instead of rebuilding them. A
482
- UIListLayout stacks whatever is left and `AutomaticCanvasSize` turns that
483
- into a scrollable canvas, which is what makes the eighth command reachable.
484
- ]]
485
- local function buildRows(scroll: ScrollingFrame, count: number)
486
- local palette = Themes.palette()
487
-
488
- local layout = Instance.new("UIListLayout")
489
- layout.FillDirection = Enum.FillDirection.Vertical
490
- layout.SortOrder = Enum.SortOrder.LayoutOrder
491
- layout.Parent = scroll
492
-
493
- for slot = 1, count do
494
- --[[
495
- A TextButton rather than a Frame with a button over it. The mouse is
496
- the only way into this list, so the row IS the control, and one
497
- instance answering for the hover and the click beats two that can
498
- fall out of step.
499
- ]]
500
- local button = Instance.new("TextButton")
501
- button.Name = string.format("Row%d", slot)
502
- button.Text = ""
503
- button.AutoButtonColor = false
504
- button.BackgroundColor3 = palette.surface
505
- button.BackgroundTransparency = 1
506
- button.BorderSizePixel = 0
507
- button.Visible = false
508
- button.Size = UDim2.new(1, -MENU_SCROLLBAR, 0, MENU_ROW_HEIGHT)
509
- button.ZIndex = Z_MENU_ROW
510
- button.Parent = scroll
511
-
512
- local usage = Instance.new("TextLabel")
513
- usage.Name = "Usage"
514
- usage.Font = Enum.Font.Code
515
- usage.TextSize = 12
516
- usage.TextColor3 = palette.text
517
- usage.TextXAlignment = Enum.TextXAlignment.Left
518
- usage.TextTruncate = Enum.TextTruncate.AtEnd
519
- usage.BackgroundTransparency = 1
520
- usage.Position = UDim2.fromOffset(MENU_PAD, 0)
521
- usage.Size = UDim2.new(0, MENU_SUMMARY_X - MENU_PAD * 2, 1, 0)
522
- usage.ZIndex = Z_MENU_TEXT
523
- usage.Parent = button
524
-
525
- local summary = Instance.new("TextLabel")
526
- summary.Name = "Summary"
527
- summary.Font = Enum.Font.Code
528
- summary.TextSize = 11
529
- summary.TextColor3 = palette.dim
530
- summary.TextXAlignment = Enum.TextXAlignment.Left
531
- summary.TextTruncate = Enum.TextTruncate.AtEnd
532
- summary.BackgroundTransparency = 1
533
- summary.Position = UDim2.fromOffset(MENU_SUMMARY_X, 0)
534
- summary.Size = UDim2.new(1, -(MENU_SUMMARY_X + MENU_PAD), 1, 0)
535
- summary.ZIndex = Z_MENU_TEXT
536
- summary.Parent = button
537
-
538
- local record: MenuRow = {
539
- button = button,
540
- usage = usage,
541
- summary = summary,
542
- entry = "",
543
- takesArgument = false,
544
- }
545
- table.insert(runtime.menuRows, record)
546
-
547
- -- The highlight follows the pointer. With no keyboard to drive a
548
- -- selection, hover IS the selection.
549
- button.MouseEnter:Connect(function()
550
- if runtime.hovered == slot then
551
- return
552
- end
553
- runtime.hovered = slot
554
- renderMenu()
555
- end)
556
- button.MouseLeave:Connect(function()
557
- if runtime.hovered ~= slot then
558
- return
559
- end
560
- runtime.hovered = 0
561
- renderMenu()
562
- end)
563
-
564
- --[[
565
- Fills the bar from what this row is showing, not from the match list,
566
- which the focus loss that comes with the click may already have
567
- emptied. The row knows its own command; that is enough.
568
- ]]
569
- button.MouseButton1Click:Connect(function()
570
- fillWith(record.entry, record.takesArgument)
571
- Prompt.focus()
572
- end)
573
- end
574
- end
575
-
576
- --[[
577
- Builds the row. Called once from `Console.mount`, positioned by it.
578
- ]]
579
- function Prompt.mount(
580
- parent: Instance,
581
- handlers: {
582
- submit: (string) -> (),
583
- complete: (string) -> { string },
584
- suggest: (string) -> { Suggestion },
585
- }
586
- )
587
- local palette = Themes.palette()
588
- runtime.submit = handlers.submit
589
- runtime.complete = handlers.complete
590
- runtime.suggest = handlers.suggest
591
-
592
- local row = Instance.new("Frame")
593
- row.Name = "Prompt"
594
- row.BackgroundColor3 = palette.background
595
- row.BorderSizePixel = 0
596
- row.Parent = parent
597
- runtime.row = row
598
-
599
- local caret = Instance.new("TextLabel")
600
- caret.Name = "Caret"
601
- caret.Text = "\u{203A}"
602
- caret.Font = Enum.Font.Code
603
- caret.TextSize = 14
604
- caret.TextColor3 = palette.violet
605
- caret.TextXAlignment = Enum.TextXAlignment.Left
606
- caret.BackgroundTransparency = 1
607
- caret.Position = UDim2.fromOffset(INSET, 0)
608
- caret.Size = UDim2.new(0, CARET_WIDTH, 1, 0)
609
- caret.Parent = row
610
- runtime.caret = caret
611
-
612
- local left = INSET + CARET_WIDTH
613
-
614
- --[[
615
- The hint sits at the right end rather than immediately after the text.
616
-
617
- Following the cursor means measuring the text every keystroke, and a
618
- suggestion that jitters horizontally while you type is worse than one
619
- parked somewhere predictable.
620
- ]]
621
- local hint = Instance.new("TextLabel")
622
- hint.Name = "Hint"
623
- hint.Text = ""
624
- hint.Font = Enum.Font.Code
625
- hint.TextSize = 11
626
- hint.TextColor3 = palette.dim
627
- hint.TextXAlignment = Enum.TextXAlignment.Right
628
- hint.TextTruncate = Enum.TextTruncate.AtEnd
629
- hint.BackgroundTransparency = 1
630
- hint.AnchorPoint = Vector2.new(1, 0)
631
- hint.Position = UDim2.new(1, -INSET, 0, 0)
632
- hint.Size = UDim2.new(0.5, 0, 1, 0)
633
- hint.Parent = row
634
- runtime.hint = hint
635
-
636
- local field = Instance.new("TextBox")
637
- field.Name = "Field"
638
- field.Text = ""
639
- field.PlaceholderText = placeholder()
640
- field.PlaceholderColor3 = palette.dim
641
- field.Font = Enum.Font.Code
642
- field.TextSize = 12
643
- field.TextColor3 = palette.text
644
- field.TextXAlignment = Enum.TextXAlignment.Left
645
- field.BackgroundTransparency = 1
646
- field.BorderSizePixel = 0
647
- -- Cleared on submit, not on focus: clicking away to read a line above and
648
- -- clicking back must not cost what was typed.
649
- field.ClearTextOnFocus = false
650
- field.ClipsDescendants = true
651
- field.Position = UDim2.fromOffset(left, 0)
652
- field.Size = UDim2.new(1, -(left + INSET), 1, 0)
653
- field.Parent = row
654
- runtime.field = field
655
-
656
- -- A hairline ABOVE the row, because the row sits at the foot of the panel:
657
- -- the edge worth drawing is the one between the log and the input, and a
658
- -- second line just above the status bar would only crowd it.
659
- local rule = Instance.new("Frame")
660
- rule.Name = "Rule"
661
- rule.AnchorPoint = Vector2.new(0, 0)
662
- rule.Position = UDim2.fromScale(0, 0)
663
- rule.Size = UDim2.new(1, 0, 0, 1)
664
- rule.BackgroundColor3 = palette.rule
665
- rule.BackgroundTransparency = 0.4
666
- rule.BorderSizePixel = 0
667
- rule.Parent = row
668
- runtime.rule = rule
669
-
670
- --[[
671
- The menu, parented to the row rather than to the panel.
672
-
673
- Anchored to its own bottom edge with a negative Y offset, so it grows
674
- upward out of a row that is already sitting on the status bar. The row
675
- does not clip, so drawing outside it is allowed; the explicit ZIndex is
676
- what puts it over the log, since a plugin widget draws with
677
- ZIndexBehavior.Global and a child does not inherit its parent's depth.
678
- ]]
679
- local menu = Instance.new("Frame")
680
- menu.Name = "Menu"
681
- menu.BackgroundColor3 = palette.background
682
- menu.BorderSizePixel = 0
683
- menu.Visible = false
684
- menu.AnchorPoint = Vector2.new(0, 1)
685
- menu.Position = UDim2.new(0, INSET, 0, -MENU_LIFT)
686
- menu.Size = UDim2.new(1, -(INSET * 2), 0, 0)
687
- menu.ZIndex = Z_MENU
688
- menu.Parent = row
689
- runtime.menu = menu
690
-
691
- --[[
692
- Whether the pointer is inside. Read by FocusLost, which must not take the
693
- menu down while someone is in the middle of clicking a row.
694
- ]]
695
- menu.MouseEnter:Connect(function()
696
- runtime.hovering = true
697
- end)
698
- menu.MouseLeave:Connect(function()
699
- runtime.hovering = false
700
- -- Leaving the menu with the field no longer focused means the click that
701
- -- was coming never came, so there is nothing left holding it open.
702
- if not runtime.focused then
703
- closeMenu()
704
- end
705
- end)
706
-
707
- local menuStroke = Instance.new("UIStroke")
708
- menuStroke.Color = palette.rule
709
- menuStroke.Transparency = 0.4
710
- menuStroke.Thickness = 1
711
- menuStroke.Parent = menu
712
-
713
- local menuCorner = Instance.new("UICorner")
714
- menuCorner.CornerRadius = UDim.new(0, 4)
715
- menuCorner.Parent = menu
716
-
717
- --[[
718
- The scrolling part. Seventeen commands do not fit above a 30px row and
719
- there is no key that could page through them here, so the wheel is how
720
- the rest is reached -- and the scrollbar is what says there IS a rest.
721
- ]]
722
- local scroll = Instance.new("ScrollingFrame")
723
- scroll.Name = "Rows"
724
- scroll.BackgroundTransparency = 1
725
- scroll.BorderSizePixel = 0
726
- scroll.Position = UDim2.fromOffset(0, MENU_PAD)
727
- scroll.Size = UDim2.new(1, 0, 0, MENU_MAX_ROWS * MENU_ROW_HEIGHT)
728
- scroll.CanvasSize = UDim2.new()
729
- scroll.AutomaticCanvasSize = Enum.AutomaticSize.Y
730
- scroll.ScrollingDirection = Enum.ScrollingDirection.Y
731
- scroll.ScrollBarThickness = MENU_SCROLLBAR
732
- scroll.ScrollBarImageColor3 = palette.dim
733
- scroll.ZIndex = Z_MENU_SCROLL
734
- scroll.Parent = menu
735
- runtime.menuScroll = scroll
736
-
737
- -- One row per command, asked of the same function that will later filter
738
- -- them, so the pool can never be smaller than the list it has to show.
739
- runtime.menuRows = {}
740
- buildRows(scroll, #handlers.suggest(""))
741
-
742
- local caption = Instance.new("TextLabel")
743
- caption.Name = "Caption"
744
- caption.Font = Enum.Font.Code
745
- caption.TextSize = 10
746
- caption.TextColor3 = palette.dim
747
- caption.TextXAlignment = Enum.TextXAlignment.Left
748
- caption.TextTruncate = Enum.TextTruncate.AtEnd
749
- caption.BackgroundTransparency = 1
750
- caption.Position = UDim2.fromOffset(MENU_PAD, 0)
751
- caption.Size = UDim2.new(1, -(MENU_PAD * 2), 0, MENU_CAPTION_HEIGHT)
752
- caption.ZIndex = Z_MENU_TEXT
753
- caption.Parent = menu
754
- runtime.menuCaption = caption
755
-
756
- field.Focused:Connect(function()
757
- runtime.focused = true
758
- refreshMenu()
759
- end)
760
-
761
- --[[
762
- A click on the bar asks for the list, even when the bar already has the
763
- cursor.
764
-
765
- Belt and braces next to `Focused`, which is what normally opens the menu.
766
- This covers the case where the field never lost focus in the first place,
767
- and `Focused` therefore has nothing to fire about. Mouse input is the one
768
- kind a plugin widget does deliver, which is why this can be read at all.
769
- ]]
770
- field.InputBegan:Connect(function(input)
771
- if input.UserInputType ~= Enum.UserInputType.MouseButton1 then
772
- return
773
- end
774
- runtime.focused = true
775
- refreshMenu()
776
- end)
777
-
778
- field:GetPropertyChangedSignal("Text"):Connect(function()
779
- -- Typing puts the user back on the live line. Without this, editing a
780
- -- recalled entry and pressing down would throw the edit away.
781
- if runtime.offset ~= 0 then
782
- runtime.offset = 0
783
- runtime.draft = ""
784
- end
785
- --[[
786
- Tab never belongs in this field.
787
-
788
- Studio delivers no key events to a plugin widget, so Tab cannot be
789
- intercepted -- but the TextBox still inserts one, and an invisible
790
- character on the end of `help` stops it matching anything. There is
791
- no refusing the keystroke, so it is taken back out here.
792
- ]]
793
- if string.find(field.Text, "\t") ~= nil then
794
- local cleaned = (string.gsub(field.Text, "\t", ""))
795
- field.Text = cleaned
796
- field.CursorPosition = #cleaned + 1
797
- -- The assignment re-enters this handler with the clean text, which
798
- -- does the rest of the work.
799
- return
800
- end
801
- -- Refreshes the hint itself, so there is no second call here.
802
- refreshMenu()
803
- end)
804
-
805
- field.FocusLost:Connect(function(enterPressed)
806
- runtime.focused = false
807
-
808
- --[[
809
- The menu stays up while the pointer is on it.
810
-
811
- Focus is released on mouse-DOWN and the click only completes on
812
- mouse-up. Closing here regardless deleted the row being clicked in
813
- between the two, which is why clicking a command used to do nothing.
814
- ]]
815
- -- Enter always takes it down, hover or not: the line has been submitted,
816
- -- so a list still standing over the reply is a leftover.
817
- if enterPressed or not runtime.hovering then
818
- closeMenu()
819
- end
820
- showHint()
821
-
822
- if not enterPressed then
823
- return
824
- end
825
-
826
- local line = string.match(field.Text, "^%s*(.-)%s*$") or ""
827
- field.Text = ""
828
- if runtime.hint then
829
- (runtime.hint :: TextLabel).Text = ""
830
- end
831
- if line == "" then
832
- return
833
- end
834
- remember(line)
835
- local run = runtime.submit
836
- if run ~= nil then
837
- -- Spawned so a slow command -- one that waits on the bridge -- cannot
838
- -- hold the field unresponsive while it runs.
839
- task.spawn(run, line)
840
- end
841
- --[[
842
- The cursor is NOT taken back.
843
-
844
- It used to be, on the next frame, so a burst of commands could be
845
- typed without reaching for the mouse. Two things were wrong with
846
- that. The answer to the command arrives at the bottom of the log,
847
- exactly where a re-opened menu would sit, so the panel covered the
848
- thing it had just been asked for -- and suppressing the menu to avoid
849
- that is what made clicking the bar afterwards do nothing, since a
850
- field that never lost focus cannot be focused again.
851
-
852
- Letting go costs one click before the next command and buys a log you
853
- can actually read, and a bar that behaves the same way every time it
854
- is clicked.
855
- ]]
856
- end)
857
- end
858
-
859
- --[[
860
- Puts the cursor in the field, for the toolbar button, for `help`, and for a
861
- click on the menu.
862
-
863
- Guarded: the widget can be closed while a command is still finishing, and
864
- capturing focus on a field whose window is gone throws.
865
- ]]
866
- function Prompt.focus()
867
- local field = runtime.field
868
- if field ~= nil and field.Parent ~= nil then
869
- pcall(function()
870
- field:CaptureFocus()
871
- end)
872
- end
873
- end
874
-
875
- --[[
876
- Steps through the history, for whatever can reach a key.
877
-
878
- Kept because the ring is still recorded and still worth having the moment
879
- there is a way to drive it; nothing in a plugin widget can today. See the
880
- note at the top of this file.
881
- ]]
882
- function Prompt.step(direction: number)
883
- recall(runtime.offset + direction)
884
- end
885
-
886
- --[[
887
- Reports that an agent is running, in the one place the user is looking.
888
-
889
- The log says what the agent is doing and the band says how long it is
890
- taking; this says only that the line they are about to type will queue behind
891
- something. That is a fact about the input, so it belongs to the input.
892
- ]]
893
- function Prompt.setBusy(busy: boolean)
894
- runtime.busy = busy
895
- paintCaret()
896
- local field = runtime.field
897
- if field ~= nil then
898
- field.PlaceholderText = placeholder()
899
- end
900
- end
901
-
902
- --[[
903
- Reports what Studio has selected, as a phrase rather than a list.
904
-
905
- Called from the selection watcher, which has already debounced and worded
906
- it. This only decides whether it is on screen, which is `showHint`'s job
907
- anyway -- so the value is stored and the one renderer is asked to run again.
908
- ]]
909
- function Prompt.setSelection(text: string)
910
- runtime.selection = text
911
- showHint()
912
- end
913
-
914
- --[[
915
- Whether an agent started from this prompt is still working.
916
-
917
- Read by the console before it announces a silence. The flag lives here
918
- because the caret is what displays it, and one owner beats two that have to
919
- be kept in step.
920
- ]]
921
- function Prompt.isBusy(): boolean
922
- return runtime.busy
923
- end
924
-
925
- function Prompt.applyTheme()
926
- local palette = Themes.palette()
927
- local row = runtime.row
928
- if row then
929
- row.BackgroundColor3 = palette.background
930
- end
931
- local field = runtime.field
932
- if field then
933
- field.TextColor3 = palette.text
934
- field.PlaceholderColor3 = palette.dim
935
- end
936
- local hint = runtime.hint
937
- if hint then
938
- hint.TextColor3 = palette.dim
939
- end
940
- local rule = runtime.rule
941
- if rule then
942
- rule.BackgroundColor3 = palette.rule
943
- end
944
- local menu = runtime.menu
945
- if menu then
946
- menu.BackgroundColor3 = palette.background
947
- local stroke = menu:FindFirstChildOfClass("UIStroke")
948
- if stroke then
949
- stroke.Color = palette.rule
950
- end
951
- end
952
- -- Repainted through the renderer rather than field by field: the hover
953
- -- decides half of these colours, and only one function knows which row has
954
- -- it.
955
- if isMenuOpen() then
956
- renderMenu()
957
- end
958
- paintCaret()
959
- end
960
-
961
- return Prompt
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
+ Above the row sits a scrolling menu of the commands matching what is being
19
+ typed -- all of them, while the field is empty. The placeholder used to say
20
+ "type a command" and then leave the reader to work out which commands
21
+ existed, which is a prompt keeping a secret. The list is the answer, and it
22
+ is shown for the same reason a shell shows you its completions rather than
23
+ describing them.
24
+
25
+ WHY THE MENU IS DRIVEN BY THE MOUSE AND BY TYPING, AND NOT BY ARROW KEYS.
26
+
27
+ Because Studio does not deliver keyboard input to plugin widgets at all.
28
+ UserInputService and ContextActionService are documented as not working
29
+ inside a PluginGui, and a GuiObject's own InputBegan never sees a key either
30
+ -- the request asking Roblox for a way to read keys in a plugin window has
31
+ been open since 2020 and was bumped again a month ago:
32
+
33
+ https://devforum.roblox.com/t/plugins-need-a-way-to-listen-to-app-input/479597
34
+ https://devforum.roblox.com/t/text-editor-make-editing-text-fields-much-easier/684771
35
+
36
+ This was learned the expensive way: an arrow-key highlight was written, then
37
+ rewritten against UserInputService, then wired to both at once, and all three
38
+ were dead on arrival. Mouse input works -- that is what the theme drawer runs
39
+ on -- and the TextBox reports its own text and its own Enter. Between them
40
+ they are enough, so the menu offers exactly those and the caption promises
41
+ nothing else. A key that a caption advertises and the platform cannot deliver
42
+ is worse than no key at all.
43
+ ]]
44
+
45
+ local Themes = require(script.Parent.Themes)
46
+ local Secret = require(script.Parent.Secret)
47
+
48
+ local Prompt = {}
49
+
50
+ -- Tall enough for a 12pt line with air around it, short enough that it does not
51
+ -- read as a second header. The log gives up exactly this much.
52
+ Prompt.HEIGHT = 30
53
+
54
+ -- Matches the log's own left inset, so the caret sits in the same column as the
55
+ -- sigils below it and the whole panel reads as one grid.
56
+ local INSET = 12
57
+ local CARET_WIDTH = 14
58
+
59
+ --[[
60
+ How many past lines are kept.
61
+
62
+ A ring rather than a session log: this is for getting back to the thing you
63
+ typed a moment ago, and anyone scrolling past fifty entries wanted the log,
64
+ not the history.
65
+ ]]
66
+ local MAX_HISTORY = 50
67
+
68
+ --[[
69
+ The command menu's shape.
70
+
71
+ Seven rows is about as tall as this can be before it stops being a hint over
72
+ the log and starts being a page covering it. Everything past that is reached
73
+ by scrolling, which is the mouse's job here rather than a key's.
74
+ ]]
75
+ local MENU_ROW_HEIGHT = 20
76
+ local MENU_MAX_ROWS = 7
77
+ local MENU_PAD = 6
78
+ local MENU_CAPTION_HEIGHT = 16
79
+ local MENU_SCROLLBAR = 4
80
+ -- Gap between the menu's bottom edge and the top of the prompt row.
81
+ local MENU_LIFT = 2
82
+ -- Where the summary column starts, measured from the menu's left edge. Wide
83
+ -- enough for `agent [use <id>|new]`, the longest usage line there is.
84
+ local MENU_SUMMARY_X = 140
85
+
86
+ --[[
87
+ Explicit depths, because a plugin widget draws with ZIndexBehavior.Global
88
+ and a child does not inherit its parent's. Above the log and the CRT flourish
89
+ that sit at 20-22; below the preset drawer at 30, which is modal and must
90
+ stay clickable when it is open.
91
+ ]]
92
+ local Z_MENU = 24
93
+ local Z_MENU_SCROLL = 25
94
+ local Z_MENU_ROW = 26
95
+ local Z_MENU_TEXT = 27
96
+
97
+ type Suggestion = {
98
+ name: string,
99
+ usage: string,
100
+ summary: string,
101
+ }
102
+
103
+ type MenuRow = {
104
+ button: TextButton,
105
+ usage: TextLabel,
106
+ summary: TextLabel,
107
+ --[[
108
+ The command this row is currently showing, and whether its usage line
109
+ mentions an argument.
110
+
111
+ Held on the row rather than looked up when it is clicked. The lookup was
112
+ a real bug: clicking takes focus off the field, the focus handler emptied
113
+ the match list, and by the time the click arrived there was nothing left
114
+ to resolve -- so every click did nothing at all.
115
+ ]]
116
+ entry: string,
117
+ takesArgument: boolean,
118
+ }
119
+
120
+ type Runtime = {
121
+ row: Frame?,
122
+ caret: TextLabel?,
123
+ field: TextBox?,
124
+ hint: TextLabel?,
125
+ rule: Frame?,
126
+ menu: Frame?,
127
+ menuScroll: ScrollingFrame?,
128
+ menuCaption: TextLabel?,
129
+ menuRows: { MenuRow },
130
+ --[[
131
+ What the menu is currently offering. Empty means it is closed; there is
132
+ no separate flag, because two ways to ask whether a list is showing is
133
+ one way too many.
134
+ ]]
135
+ matches: { Suggestion },
136
+ -- Which row the pointer is over, 1-based into `matches`; 0 for none.
137
+ hovered: number,
138
+ -- Whether the field currently holds the keyboard. The menu is a thing you
139
+ -- type at, so it has no business being on screen when nobody is typing.
140
+ focused: boolean,
141
+ --[[
142
+ Whether the pointer is over the menu.
143
+
144
+ Checked before the menu is taken down on focus loss, and that ordering is
145
+ the whole reason it exists: pressing the mouse on a row releases the
146
+ field's focus BEFORE the click completes, so a menu that closes on focus
147
+ loss deletes the button mid-press and the click never happens.
148
+ ]]
149
+ hovering: boolean,
150
+ --[[
151
+ Newest last. The entry being edited is NOT in here -- it is in the field
152
+ -- so the list holds only lines that were actually submitted.
153
+ ]]
154
+ history: { string },
155
+ --[[
156
+ Where in the history the user is, counted from the end. Zero means "at
157
+ the live line", which is the only state in which the field's contents
158
+ belong to the user rather than to a recalled entry.
159
+ ]]
160
+ offset: number,
161
+ -- What was being typed before the history took the field over, so coming
162
+ -- back returns it rather than an empty line.
163
+ draft: string,
164
+ busy: boolean,
165
+ --[[
166
+ What is selected in Studio right now, already worded for display.
167
+
168
+ Kept here because the prompt is where it earns its place: half of what
169
+ anyone types into this box is deictic -- "make this bigger", "delete
170
+ that" -- and the word only means something if you can see what it points
171
+ at while you are writing it. Studio's own Explorer is often scrolled
172
+ somewhere else, or behind the panel.
173
+ ]]
174
+ selection: string,
175
+ complete: ((string) -> { string })?,
176
+ suggest: ((string) -> { Suggestion })?,
177
+ submit: ((string) -> ())?,
178
+ }
179
+
180
+ local runtime: Runtime = {
181
+ row = nil,
182
+ caret = nil,
183
+ field = nil,
184
+ hint = nil,
185
+ rule = nil,
186
+ menu = nil,
187
+ menuScroll = nil,
188
+ menuCaption = nil,
189
+ menuRows = {},
190
+ matches = {},
191
+ hovered = 0,
192
+ focused = false,
193
+ hovering = false,
194
+ history = {},
195
+ offset = 0,
196
+ draft = "",
197
+ busy = false,
198
+ selection = "",
199
+ complete = nil,
200
+ suggest = nil,
201
+ submit = nil,
202
+ }
203
+
204
+ --[[
205
+ The caret's colour, which is the row's only status light.
206
+
207
+ Violet at rest is the project's accent and says "this is ours". Amber while
208
+ an agent runs says something is happening without stealing the caption's
209
+ line, which is already reporting what.
210
+ ]]
211
+ local function paintCaret()
212
+ local caret = runtime.caret
213
+ if caret == nil then
214
+ return
215
+ end
216
+ local palette = Themes.palette()
217
+ caret.TextColor3 = if runtime.busy then palette.amber else palette.violet
218
+ end
219
+
220
+ local function placeholder(): string
221
+ return if runtime.busy
222
+ then "running -- type stop to cancel"
223
+ else "type a command, or ask for something"
224
+ end
225
+
226
+ --[[
227
+ Whether the command menu is on screen.
228
+
229
+ Derived from the match list rather than from a flag of its own: the menu is
230
+ the list, and a second source of truth for "is it showing" is a second thing
231
+ that can be wrong.
232
+ ]]
233
+ local function isMenuOpen(): boolean
234
+ local menu = runtime.menu
235
+ return menu ~= nil and menu.Visible and #runtime.matches > 0
236
+ end
237
+
238
+ --[[
239
+ Draws the current matches into the pre-built rows.
240
+
241
+ The rows exist from `mount` -- one per command, since that number only
242
+ changes when the source does -- and are only ever shown, hidden and
243
+ relabelled. Building them per keystroke would mean four `Instance.new` calls
244
+ per visible command on every letter typed, and a menu that flickers as it is
245
+ rebuilt is worse than no menu.
246
+ ]]
247
+ local function renderMenu()
248
+ local menu = runtime.menu
249
+ local scroll = runtime.menuScroll
250
+ local caption = runtime.menuCaption
251
+ if menu == nil or scroll == nil or caption == nil then
252
+ return
253
+ end
254
+
255
+ local total = #runtime.matches
256
+ if total == 0 then
257
+ menu.Visible = false
258
+ return
259
+ end
260
+
261
+ local palette = Themes.palette()
262
+
263
+ for position, row in runtime.menuRows do
264
+ local entry = runtime.matches[position]
265
+ if entry == nil then
266
+ row.button.Visible = false
267
+ row.entry = ""
268
+ continue
269
+ end
270
+
271
+ row.button.Visible = true
272
+ row.button.LayoutOrder = position
273
+ row.button.BackgroundColor3 = palette.surface
274
+ row.button.BackgroundTransparency = if position == runtime.hovered then 0.25 else 1
275
+ row.usage.Text = entry.usage
276
+ row.usage.TextColor3 = if position == runtime.hovered then palette.violet else palette.text
277
+ row.summary.Text = entry.summary
278
+ row.summary.TextColor3 = palette.dim
279
+ row.entry = entry.name
280
+ -- A usage line that is more than the bare name -- `theme [name]`,
281
+ -- `use <number|id>` -- is a command that wants an argument, and those
282
+ -- are the only ones worth leaving a space after.
283
+ row.takesArgument = entry.usage ~= entry.name
284
+ end
285
+
286
+ --[[
287
+ Only as tall as it needs to be, up to seven rows. The rest is reached by
288
+ scrolling, and the scrollbar appearing is what says there is more.
289
+ ]]
290
+ local visible = math.min(total, MENU_MAX_ROWS)
291
+ scroll.Size = UDim2.new(1, 0, 0, visible * MENU_ROW_HEIGHT)
292
+ scroll.ScrollBarImageColor3 = palette.dim
293
+
294
+ caption.Text = if total == 1
295
+ then "1 command click it, or press enter to run"
296
+ else string.format("%d commands click one, or keep typing", total)
297
+ caption.TextColor3 = palette.dim
298
+ -- Indented to the same column as the rows above it. This used to be flush
299
+ -- against the frame's edge, which made it read as something that had fallen
300
+ -- out of the menu rather than as part of it.
301
+ caption.Position = UDim2.fromOffset(MENU_PAD, MENU_PAD + visible * MENU_ROW_HEIGHT)
302
+
303
+ menu.BackgroundColor3 = palette.background
304
+ menu.Size =
305
+ UDim2.new(1, -(INSET * 2), 0, MENU_PAD * 2 + visible * MENU_ROW_HEIGHT + MENU_CAPTION_HEIGHT)
306
+ menu.Visible = true
307
+ end
308
+
309
+ local function closeMenu()
310
+ runtime.matches = {}
311
+ runtime.hovered = 0
312
+ local menu = runtime.menu
313
+ if menu then
314
+ menu.Visible = false
315
+ end
316
+ end
317
+
318
+ --[[
319
+ The greyed-out remainder of the command being typed.
320
+
321
+ Drawn as a separate label positioned after the text rather than inside the
322
+ field, because a TextBox that writes its own guess into itself has to undo
323
+ that on every keystroke and gets it wrong the moment the user types fast.
324
+ ]]
325
+ local function showHint()
326
+ local field = runtime.field
327
+ local hint = runtime.hint
328
+ local complete = runtime.complete
329
+ if field == nil or hint == nil or complete == nil then
330
+ return
331
+ end
332
+
333
+ --[[
334
+ The menu says it better, when the menu is up.
335
+
336
+ This row used to carry the whole match list crammed into half a line of
337
+ 11pt text. Now that the same matches are listed properly above the field,
338
+ repeating them here would be noise competing with the thing it duplicates
339
+ -- so the slot goes back to the selection, which has nowhere else to be.
340
+ ]]
341
+ if isMenuOpen() then
342
+ hint.Text = runtime.selection
343
+ return
344
+ end
345
+
346
+ local typed = field.Text
347
+ -- Only the first word completes. Everything after it is an argument, and
348
+ -- guessing at arguments is how a prompt starts fighting the person using it.
349
+ if typed ~= "" and string.find(typed, "%s") == nil then
350
+ local matches = complete(typed)
351
+ if #matches == 1 and matches[1] ~= typed then
352
+ hint.Text = matches[1]
353
+ return
354
+ end
355
+ end
356
+
357
+ hint.Text = runtime.selection
358
+ end
359
+
360
+ --[[
361
+ Recomputes what the menu offers, from what is in the field right now.
362
+
363
+ One rule, and only one: the list is up whenever the bar has the cursor and
364
+ holds at most one word. There used to be two more -- a "dismissed" flag and a
365
+ "resuming" flag -- and between them they were the bug that would not die:
366
+ clicking the empty bar after running a command showed nothing, because a flag
367
+ set on the way out of the last command was still holding the list shut and
368
+ only a keystroke cleared it. Flags that guess at intent are how that happens.
369
+ State this small should be derivable from what is on screen, so now it is.
370
+
371
+ The text is trimmed before it is judged, because whitespace is not content. A
372
+ bar holding nothing but a space is an empty bar, and it was being treated as a
373
+ line with an argument in it -- which closed the menu and left no way to tell
374
+ why.
375
+ ]]
376
+ local function refreshMenu()
377
+ local field = runtime.field
378
+ local suggest = runtime.suggest
379
+ if field == nil or suggest == nil then
380
+ return
381
+ end
382
+
383
+ local trimmed = string.match(field.Text, "^%s*(.-)%s*$") or ""
384
+
385
+ -- A space INSIDE the line means the first word is settled and an argument is
386
+ -- being written, which is the point at which a list of commands stops being
387
+ -- what the person typing needs to see.
388
+ if not runtime.focused or string.find(trimmed, "%s") ~= nil then
389
+ closeMenu()
390
+ showHint()
391
+ return
392
+ end
393
+
394
+ runtime.matches = suggest(trimmed)
395
+ -- Nothing is highlighted until the pointer picks something. A highlight that
396
+ -- appears on its own is a selection the user did not make, and clicking is
397
+ -- the only way to act on it anyway.
398
+ runtime.hovered = 0
399
+
400
+ --[[
401
+ Back to the top, but only here.
402
+
403
+ This used to live in `renderMenu`, which also runs on every hover -- so
404
+ scrolling to the bottom of the list and then moving the mouse a pixel
405
+ snapped it straight back to the first row. The scroll position is only
406
+ meaningless when the list underneath it has changed, and that is exactly
407
+ what this function is for.
408
+ ]]
409
+ local scroll = runtime.menuScroll
410
+ if scroll then
411
+ scroll.CanvasPosition = Vector2.new(0, 0)
412
+ end
413
+
414
+ renderMenu()
415
+ showHint()
416
+ end
417
+
418
+ --[[
419
+ Puts a command in the field, without running it.
420
+
421
+ A trailing space only where the command takes an argument. It used to be
422
+ added unconditionally, which left `clear ` sitting in the bar with a space
423
+ the user then had to notice and delete -- reported as exactly that. A command
424
+ that takes nothing is complete the moment its name is there, and Enter runs
425
+ it.
426
+ ]]
427
+ local function fillWith(name: string, takesArgument: boolean)
428
+ local field = runtime.field
429
+ if field == nil or name == "" then
430
+ return
431
+ end
432
+ closeMenu()
433
+ field.Text = if takesArgument then name .. " " else name
434
+ field.CursorPosition = #field.Text + 1
435
+ showHint()
436
+ end
437
+
438
+ --[[
439
+ Puts a past line back in the field.
440
+
441
+ `offset` counts backwards from the newest entry, so 1 is the last thing
442
+ typed. Zero is the live line, which is the draft rather than any entry.
443
+ ]]
444
+ local function recall(offset: number)
445
+ local field = runtime.field
446
+ if field == nil then
447
+ return
448
+ end
449
+ local total = #runtime.history
450
+ local wanted = math.clamp(offset, 0, total)
451
+
452
+ -- Stepping off the live line for the first time: keep what was there, or
453
+ -- walking up and back down silently eats a half-typed command.
454
+ if runtime.offset == 0 and wanted > 0 then
455
+ runtime.draft = field.Text
456
+ end
457
+
458
+ runtime.offset = wanted
459
+ field.Text = if wanted == 0 then runtime.draft else runtime.history[total - wanted + 1]
460
+ field.CursorPosition = #field.Text + 1
461
+ showHint()
462
+ end
463
+
464
+ local function remember(raw: string)
465
+ -- Masked on the way in, so a key cannot be recalled with the up arrow by
466
+ -- whoever is at the machine next. See `Secret`.
467
+ local line = Secret.mask(raw)
468
+ local history = runtime.history
469
+ -- A line repeated back to back is one entry. Pressing up should walk through
470
+ -- what was done, not through how many times it was retried.
471
+ if history[#history] ~= line then
472
+ table.insert(history, line)
473
+ end
474
+ while #history > MAX_HISTORY do
475
+ table.remove(history, 1)
476
+ end
477
+ runtime.offset = 0
478
+ runtime.draft = ""
479
+ end
480
+
481
+ --[[
482
+ Builds the menu's rows, one per command.
483
+
484
+ Sized from the full command list rather than from the seven that fit, so
485
+ filtering is a matter of hiding rows instead of rebuilding them. A
486
+ UIListLayout stacks whatever is left and `AutomaticCanvasSize` turns that
487
+ into a scrollable canvas, which is what makes the eighth command reachable.
488
+ ]]
489
+ local function buildRows(scroll: ScrollingFrame, count: number)
490
+ local palette = Themes.palette()
491
+
492
+ local layout = Instance.new("UIListLayout")
493
+ layout.FillDirection = Enum.FillDirection.Vertical
494
+ layout.SortOrder = Enum.SortOrder.LayoutOrder
495
+ layout.Parent = scroll
496
+
497
+ for slot = 1, count do
498
+ --[[
499
+ A TextButton rather than a Frame with a button over it. The mouse is
500
+ the only way into this list, so the row IS the control, and one
501
+ instance answering for the hover and the click beats two that can
502
+ fall out of step.
503
+ ]]
504
+ local button = Instance.new("TextButton")
505
+ button.Name = string.format("Row%d", slot)
506
+ button.Text = ""
507
+ button.AutoButtonColor = false
508
+ button.BackgroundColor3 = palette.surface
509
+ button.BackgroundTransparency = 1
510
+ button.BorderSizePixel = 0
511
+ button.Visible = false
512
+ button.Size = UDim2.new(1, -MENU_SCROLLBAR, 0, MENU_ROW_HEIGHT)
513
+ button.ZIndex = Z_MENU_ROW
514
+ button.Parent = scroll
515
+
516
+ local usage = Instance.new("TextLabel")
517
+ usage.Name = "Usage"
518
+ usage.Font = Enum.Font.Code
519
+ usage.TextSize = 12
520
+ usage.TextColor3 = palette.text
521
+ usage.TextXAlignment = Enum.TextXAlignment.Left
522
+ usage.TextTruncate = Enum.TextTruncate.AtEnd
523
+ usage.BackgroundTransparency = 1
524
+ usage.Position = UDim2.fromOffset(MENU_PAD, 0)
525
+ usage.Size = UDim2.new(0, MENU_SUMMARY_X - MENU_PAD * 2, 1, 0)
526
+ usage.ZIndex = Z_MENU_TEXT
527
+ usage.Parent = button
528
+
529
+ local summary = Instance.new("TextLabel")
530
+ summary.Name = "Summary"
531
+ summary.Font = Enum.Font.Code
532
+ summary.TextSize = 11
533
+ summary.TextColor3 = palette.dim
534
+ summary.TextXAlignment = Enum.TextXAlignment.Left
535
+ summary.TextTruncate = Enum.TextTruncate.AtEnd
536
+ summary.BackgroundTransparency = 1
537
+ summary.Position = UDim2.fromOffset(MENU_SUMMARY_X, 0)
538
+ summary.Size = UDim2.new(1, -(MENU_SUMMARY_X + MENU_PAD), 1, 0)
539
+ summary.ZIndex = Z_MENU_TEXT
540
+ summary.Parent = button
541
+
542
+ local record: MenuRow = {
543
+ button = button,
544
+ usage = usage,
545
+ summary = summary,
546
+ entry = "",
547
+ takesArgument = false,
548
+ }
549
+ table.insert(runtime.menuRows, record)
550
+
551
+ -- The highlight follows the pointer. With no keyboard to drive a
552
+ -- selection, hover IS the selection.
553
+ button.MouseEnter:Connect(function()
554
+ if runtime.hovered == slot then
555
+ return
556
+ end
557
+ runtime.hovered = slot
558
+ renderMenu()
559
+ end)
560
+ button.MouseLeave:Connect(function()
561
+ if runtime.hovered ~= slot then
562
+ return
563
+ end
564
+ runtime.hovered = 0
565
+ renderMenu()
566
+ end)
567
+
568
+ --[[
569
+ Fills the bar from what this row is showing, not from the match list,
570
+ which the focus loss that comes with the click may already have
571
+ emptied. The row knows its own command; that is enough.
572
+ ]]
573
+ button.MouseButton1Click:Connect(function()
574
+ fillWith(record.entry, record.takesArgument)
575
+ Prompt.focus()
576
+ end)
577
+ end
578
+ end
579
+
580
+ --[[
581
+ Builds the row. Called once from `Console.mount`, positioned by it.
582
+ ]]
583
+ function Prompt.mount(
584
+ parent: Instance,
585
+ handlers: {
586
+ submit: (string) -> (),
587
+ complete: (string) -> { string },
588
+ suggest: (string) -> { Suggestion },
589
+ }
590
+ )
591
+ local palette = Themes.palette()
592
+ runtime.submit = handlers.submit
593
+ runtime.complete = handlers.complete
594
+ runtime.suggest = handlers.suggest
595
+
596
+ local row = Instance.new("Frame")
597
+ row.Name = "Prompt"
598
+ row.BackgroundColor3 = palette.background
599
+ row.BorderSizePixel = 0
600
+ row.Parent = parent
601
+ runtime.row = row
602
+
603
+ local caret = Instance.new("TextLabel")
604
+ caret.Name = "Caret"
605
+ caret.Text = "\u{203A}"
606
+ caret.Font = Enum.Font.Code
607
+ caret.TextSize = 14
608
+ caret.TextColor3 = palette.violet
609
+ caret.TextXAlignment = Enum.TextXAlignment.Left
610
+ caret.BackgroundTransparency = 1
611
+ caret.Position = UDim2.fromOffset(INSET, 0)
612
+ caret.Size = UDim2.new(0, CARET_WIDTH, 1, 0)
613
+ caret.Parent = row
614
+ runtime.caret = caret
615
+
616
+ local left = INSET + CARET_WIDTH
617
+
618
+ --[[
619
+ The hint sits at the right end rather than immediately after the text.
620
+
621
+ Following the cursor means measuring the text every keystroke, and a
622
+ suggestion that jitters horizontally while you type is worse than one
623
+ parked somewhere predictable.
624
+ ]]
625
+ local hint = Instance.new("TextLabel")
626
+ hint.Name = "Hint"
627
+ hint.Text = ""
628
+ hint.Font = Enum.Font.Code
629
+ hint.TextSize = 11
630
+ hint.TextColor3 = palette.dim
631
+ hint.TextXAlignment = Enum.TextXAlignment.Right
632
+ hint.TextTruncate = Enum.TextTruncate.AtEnd
633
+ hint.BackgroundTransparency = 1
634
+ hint.AnchorPoint = Vector2.new(1, 0)
635
+ hint.Position = UDim2.new(1, -INSET, 0, 0)
636
+ hint.Size = UDim2.new(0.5, 0, 1, 0)
637
+ hint.Parent = row
638
+ runtime.hint = hint
639
+
640
+ local field = Instance.new("TextBox")
641
+ field.Name = "Field"
642
+ field.Text = ""
643
+ field.PlaceholderText = placeholder()
644
+ field.PlaceholderColor3 = palette.dim
645
+ field.Font = Enum.Font.Code
646
+ field.TextSize = 12
647
+ field.TextColor3 = palette.text
648
+ field.TextXAlignment = Enum.TextXAlignment.Left
649
+ field.BackgroundTransparency = 1
650
+ field.BorderSizePixel = 0
651
+ -- Cleared on submit, not on focus: clicking away to read a line above and
652
+ -- clicking back must not cost what was typed.
653
+ field.ClearTextOnFocus = false
654
+ field.ClipsDescendants = true
655
+ field.Position = UDim2.fromOffset(left, 0)
656
+ field.Size = UDim2.new(1, -(left + INSET), 1, 0)
657
+ field.Parent = row
658
+ runtime.field = field
659
+
660
+ -- A hairline ABOVE the row, because the row sits at the foot of the panel:
661
+ -- the edge worth drawing is the one between the log and the input, and a
662
+ -- second line just above the status bar would only crowd it.
663
+ local rule = Instance.new("Frame")
664
+ rule.Name = "Rule"
665
+ rule.AnchorPoint = Vector2.new(0, 0)
666
+ rule.Position = UDim2.fromScale(0, 0)
667
+ rule.Size = UDim2.new(1, 0, 0, 1)
668
+ rule.BackgroundColor3 = palette.rule
669
+ rule.BackgroundTransparency = 0.4
670
+ rule.BorderSizePixel = 0
671
+ rule.Parent = row
672
+ runtime.rule = rule
673
+
674
+ --[[
675
+ The menu, parented to the row rather than to the panel.
676
+
677
+ Anchored to its own bottom edge with a negative Y offset, so it grows
678
+ upward out of a row that is already sitting on the status bar. The row
679
+ does not clip, so drawing outside it is allowed; the explicit ZIndex is
680
+ what puts it over the log, since a plugin widget draws with
681
+ ZIndexBehavior.Global and a child does not inherit its parent's depth.
682
+ ]]
683
+ local menu = Instance.new("Frame")
684
+ menu.Name = "Menu"
685
+ menu.BackgroundColor3 = palette.background
686
+ menu.BorderSizePixel = 0
687
+ menu.Visible = false
688
+ menu.AnchorPoint = Vector2.new(0, 1)
689
+ menu.Position = UDim2.new(0, INSET, 0, -MENU_LIFT)
690
+ menu.Size = UDim2.new(1, -(INSET * 2), 0, 0)
691
+ menu.ZIndex = Z_MENU
692
+ menu.Parent = row
693
+ runtime.menu = menu
694
+
695
+ --[[
696
+ Whether the pointer is inside. Read by FocusLost, which must not take the
697
+ menu down while someone is in the middle of clicking a row.
698
+ ]]
699
+ menu.MouseEnter:Connect(function()
700
+ runtime.hovering = true
701
+ end)
702
+ menu.MouseLeave:Connect(function()
703
+ runtime.hovering = false
704
+ -- Leaving the menu with the field no longer focused means the click that
705
+ -- was coming never came, so there is nothing left holding it open.
706
+ if not runtime.focused then
707
+ closeMenu()
708
+ end
709
+ end)
710
+
711
+ local menuStroke = Instance.new("UIStroke")
712
+ menuStroke.Color = palette.rule
713
+ menuStroke.Transparency = 0.4
714
+ menuStroke.Thickness = 1
715
+ menuStroke.Parent = menu
716
+
717
+ local menuCorner = Instance.new("UICorner")
718
+ menuCorner.CornerRadius = UDim.new(0, 4)
719
+ menuCorner.Parent = menu
720
+
721
+ --[[
722
+ The scrolling part. Seventeen commands do not fit above a 30px row and
723
+ there is no key that could page through them here, so the wheel is how
724
+ the rest is reached -- and the scrollbar is what says there IS a rest.
725
+ ]]
726
+ local scroll = Instance.new("ScrollingFrame")
727
+ scroll.Name = "Rows"
728
+ scroll.BackgroundTransparency = 1
729
+ scroll.BorderSizePixel = 0
730
+ scroll.Position = UDim2.fromOffset(0, MENU_PAD)
731
+ scroll.Size = UDim2.new(1, 0, 0, MENU_MAX_ROWS * MENU_ROW_HEIGHT)
732
+ scroll.CanvasSize = UDim2.new()
733
+ scroll.AutomaticCanvasSize = Enum.AutomaticSize.Y
734
+ scroll.ScrollingDirection = Enum.ScrollingDirection.Y
735
+ scroll.ScrollBarThickness = MENU_SCROLLBAR
736
+ scroll.ScrollBarImageColor3 = palette.dim
737
+ scroll.ZIndex = Z_MENU_SCROLL
738
+ scroll.Parent = menu
739
+ runtime.menuScroll = scroll
740
+
741
+ -- One row per command, asked of the same function that will later filter
742
+ -- them, so the pool can never be smaller than the list it has to show.
743
+ runtime.menuRows = {}
744
+ buildRows(scroll, #handlers.suggest(""))
745
+
746
+ local caption = Instance.new("TextLabel")
747
+ caption.Name = "Caption"
748
+ caption.Font = Enum.Font.Code
749
+ caption.TextSize = 10
750
+ caption.TextColor3 = palette.dim
751
+ caption.TextXAlignment = Enum.TextXAlignment.Left
752
+ caption.TextTruncate = Enum.TextTruncate.AtEnd
753
+ caption.BackgroundTransparency = 1
754
+ caption.Position = UDim2.fromOffset(MENU_PAD, 0)
755
+ caption.Size = UDim2.new(1, -(MENU_PAD * 2), 0, MENU_CAPTION_HEIGHT)
756
+ caption.ZIndex = Z_MENU_TEXT
757
+ caption.Parent = menu
758
+ runtime.menuCaption = caption
759
+
760
+ field.Focused:Connect(function()
761
+ runtime.focused = true
762
+ refreshMenu()
763
+ end)
764
+
765
+ --[[
766
+ A click on the bar asks for the list, even when the bar already has the
767
+ cursor.
768
+
769
+ Belt and braces next to `Focused`, which is what normally opens the menu.
770
+ This covers the case where the field never lost focus in the first place,
771
+ and `Focused` therefore has nothing to fire about. Mouse input is the one
772
+ kind a plugin widget does deliver, which is why this can be read at all.
773
+ ]]
774
+ field.InputBegan:Connect(function(input)
775
+ if input.UserInputType ~= Enum.UserInputType.MouseButton1 then
776
+ return
777
+ end
778
+ runtime.focused = true
779
+ refreshMenu()
780
+ end)
781
+
782
+ field:GetPropertyChangedSignal("Text"):Connect(function()
783
+ -- Typing puts the user back on the live line. Without this, editing a
784
+ -- recalled entry and pressing down would throw the edit away.
785
+ if runtime.offset ~= 0 then
786
+ runtime.offset = 0
787
+ runtime.draft = ""
788
+ end
789
+ --[[
790
+ Tab never belongs in this field.
791
+
792
+ Studio delivers no key events to a plugin widget, so Tab cannot be
793
+ intercepted -- but the TextBox still inserts one, and an invisible
794
+ character on the end of `help` stops it matching anything. There is
795
+ no refusing the keystroke, so it is taken back out here.
796
+ ]]
797
+ if string.find(field.Text, "\t") ~= nil then
798
+ local cleaned = (string.gsub(field.Text, "\t", ""))
799
+ field.Text = cleaned
800
+ field.CursorPosition = #cleaned + 1
801
+ -- The assignment re-enters this handler with the clean text, which
802
+ -- does the rest of the work.
803
+ return
804
+ end
805
+ -- Refreshes the hint itself, so there is no second call here.
806
+ refreshMenu()
807
+ end)
808
+
809
+ field.FocusLost:Connect(function(enterPressed)
810
+ runtime.focused = false
811
+
812
+ --[[
813
+ The menu stays up while the pointer is on it.
814
+
815
+ Focus is released on mouse-DOWN and the click only completes on
816
+ mouse-up. Closing here regardless deleted the row being clicked in
817
+ between the two, which is why clicking a command used to do nothing.
818
+ ]]
819
+ -- Enter always takes it down, hover or not: the line has been submitted,
820
+ -- so a list still standing over the reply is a leftover.
821
+ if enterPressed or not runtime.hovering then
822
+ closeMenu()
823
+ end
824
+ showHint()
825
+
826
+ if not enterPressed then
827
+ return
828
+ end
829
+
830
+ local line = string.match(field.Text, "^%s*(.-)%s*$") or ""
831
+ field.Text = ""
832
+ if runtime.hint then
833
+ (runtime.hint :: TextLabel).Text = ""
834
+ end
835
+ if line == "" then
836
+ return
837
+ end
838
+ remember(line)
839
+ local run = runtime.submit
840
+ if run ~= nil then
841
+ -- Spawned so a slow command -- one that waits on the bridge -- cannot
842
+ -- hold the field unresponsive while it runs.
843
+ task.spawn(run, line)
844
+ end
845
+ --[[
846
+ The cursor is NOT taken back.
847
+
848
+ It used to be, on the next frame, so a burst of commands could be
849
+ typed without reaching for the mouse. Two things were wrong with
850
+ that. The answer to the command arrives at the bottom of the log,
851
+ exactly where a re-opened menu would sit, so the panel covered the
852
+ thing it had just been asked for -- and suppressing the menu to avoid
853
+ that is what made clicking the bar afterwards do nothing, since a
854
+ field that never lost focus cannot be focused again.
855
+
856
+ Letting go costs one click before the next command and buys a log you
857
+ can actually read, and a bar that behaves the same way every time it
858
+ is clicked.
859
+ ]]
860
+ end)
861
+ end
862
+
863
+ --[[
864
+ Puts the cursor in the field, for the toolbar button, for `help`, and for a
865
+ click on the menu.
866
+
867
+ Guarded: the widget can be closed while a command is still finishing, and
868
+ capturing focus on a field whose window is gone throws.
869
+ ]]
870
+ function Prompt.focus()
871
+ local field = runtime.field
872
+ if field ~= nil and field.Parent ~= nil then
873
+ pcall(function()
874
+ field:CaptureFocus()
875
+ end)
876
+ end
877
+ end
878
+
879
+ --[[
880
+ Steps through the history, for whatever can reach a key.
881
+
882
+ Kept because the ring is still recorded and still worth having the moment
883
+ there is a way to drive it; nothing in a plugin widget can today. See the
884
+ note at the top of this file.
885
+ ]]
886
+ function Prompt.step(direction: number)
887
+ recall(runtime.offset + direction)
888
+ end
889
+
890
+ --[[
891
+ Reports that an agent is running, in the one place the user is looking.
892
+
893
+ The log says what the agent is doing and the band says how long it is
894
+ taking; this says only that the line they are about to type will queue behind
895
+ something. That is a fact about the input, so it belongs to the input.
896
+ ]]
897
+ function Prompt.setBusy(busy: boolean)
898
+ runtime.busy = busy
899
+ paintCaret()
900
+ local field = runtime.field
901
+ if field ~= nil then
902
+ field.PlaceholderText = placeholder()
903
+ end
904
+ end
905
+
906
+ --[[
907
+ Reports what Studio has selected, as a phrase rather than a list.
908
+
909
+ Called from the selection watcher, which has already debounced and worded
910
+ it. This only decides whether it is on screen, which is `showHint`'s job
911
+ anyway -- so the value is stored and the one renderer is asked to run again.
912
+ ]]
913
+ function Prompt.setSelection(text: string)
914
+ runtime.selection = text
915
+ showHint()
916
+ end
917
+
918
+ --[[
919
+ Whether an agent started from this prompt is still working.
920
+
921
+ Read by the console before it announces a silence. The flag lives here
922
+ because the caret is what displays it, and one owner beats two that have to
923
+ be kept in step.
924
+ ]]
925
+ function Prompt.isBusy(): boolean
926
+ return runtime.busy
927
+ end
928
+
929
+ function Prompt.applyTheme()
930
+ local palette = Themes.palette()
931
+ local row = runtime.row
932
+ if row then
933
+ row.BackgroundColor3 = palette.background
934
+ end
935
+ local field = runtime.field
936
+ if field then
937
+ field.TextColor3 = palette.text
938
+ field.PlaceholderColor3 = palette.dim
939
+ end
940
+ local hint = runtime.hint
941
+ if hint then
942
+ hint.TextColor3 = palette.dim
943
+ end
944
+ local rule = runtime.rule
945
+ if rule then
946
+ rule.BackgroundColor3 = palette.rule
947
+ end
948
+ local menu = runtime.menu
949
+ if menu then
950
+ menu.BackgroundColor3 = palette.background
951
+ local stroke = menu:FindFirstChildOfClass("UIStroke")
952
+ if stroke then
953
+ stroke.Color = palette.rule
954
+ end
955
+ end
956
+ -- Repainted through the renderer rather than field by field: the hover
957
+ -- decides half of these colours, and only one function knows which row has
958
+ -- it.
959
+ if isMenuOpen() then
960
+ renderMenu()
961
+ end
962
+ paintCaret()
963
+ end
964
+
965
+ return Prompt