@flowdrop/flowdrop 1.3.0 → 1.5.0

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 (114) hide show
  1. package/README.md +68 -24
  2. package/dist/adapters/WorkflowAdapter.js +2 -22
  3. package/dist/adapters/agentspec/autoLayout.d.ts +51 -5
  4. package/dist/adapters/agentspec/autoLayout.js +120 -23
  5. package/dist/chat/commandClassifier.d.ts +19 -0
  6. package/dist/chat/commandClassifier.js +30 -0
  7. package/dist/chat/index.d.ts +27 -0
  8. package/dist/chat/index.js +32 -0
  9. package/dist/chat/responseParser.d.ts +21 -0
  10. package/dist/chat/responseParser.js +87 -0
  11. package/dist/commands/batch.d.ts +18 -0
  12. package/dist/commands/batch.js +56 -0
  13. package/dist/commands/executor.d.ts +37 -0
  14. package/dist/commands/executor.js +1044 -0
  15. package/dist/commands/index.d.ts +14 -0
  16. package/dist/commands/index.js +17 -0
  17. package/dist/commands/parser.d.ts +16 -0
  18. package/dist/commands/parser.js +278 -0
  19. package/dist/commands/positioner.d.ts +19 -0
  20. package/dist/commands/positioner.js +33 -0
  21. package/dist/commands/storeIntegration.svelte.d.ts +16 -0
  22. package/dist/commands/storeIntegration.svelte.js +67 -0
  23. package/dist/commands/types.d.ts +343 -0
  24. package/dist/commands/types.js +45 -0
  25. package/dist/components/App.svelte +431 -17
  26. package/dist/components/App.svelte.d.ts +10 -0
  27. package/dist/components/CanvasBanner.stories.svelte +6 -2
  28. package/dist/components/CanvasController.svelte +38 -0
  29. package/dist/components/CanvasController.svelte.d.ts +32 -0
  30. package/dist/components/ConfigMappingRow.svelte +130 -0
  31. package/dist/components/ConfigMappingRow.svelte.d.ts +8 -0
  32. package/dist/components/ConfigPanel.svelte +56 -7
  33. package/dist/components/ConfigPanel.svelte.d.ts +2 -0
  34. package/dist/components/FlowDropEdge.svelte +8 -57
  35. package/dist/components/Logo.svelte +14 -14
  36. package/dist/components/LogsSidebar.svelte +5 -5
  37. package/dist/components/Navbar.svelte +58 -10
  38. package/dist/components/Navbar.svelte.d.ts +7 -0
  39. package/dist/components/NodeSidebar.svelte +238 -362
  40. package/dist/components/NodeSwapPicker.svelte +537 -0
  41. package/dist/components/NodeSwapPicker.svelte.d.ts +16 -0
  42. package/dist/components/PortMappingRow.svelte +209 -0
  43. package/dist/components/PortMappingRow.svelte.d.ts +12 -0
  44. package/dist/components/SwapMappingEditor.svelte +550 -0
  45. package/dist/components/SwapMappingEditor.svelte.d.ts +12 -0
  46. package/dist/components/WorkflowEditor.svelte +99 -4
  47. package/dist/components/WorkflowEditor.svelte.d.ts +8 -0
  48. package/dist/components/chat/AIChatPanel.svelte +658 -0
  49. package/dist/components/chat/AIChatPanel.svelte.d.ts +13 -0
  50. package/dist/components/chat/CommandPreview.svelte +184 -0
  51. package/dist/components/chat/CommandPreview.svelte.d.ts +9 -0
  52. package/dist/components/console/CommandConsole.stories.svelte +93 -0
  53. package/dist/components/console/CommandConsole.stories.svelte.d.ts +27 -0
  54. package/dist/components/console/CommandConsole.svelte +259 -0
  55. package/dist/components/console/CommandConsole.svelte.d.ts +11 -0
  56. package/dist/components/console/ConsoleAutocomplete.svelte +139 -0
  57. package/dist/components/console/ConsoleAutocomplete.svelte.d.ts +21 -0
  58. package/dist/components/console/ConsoleInput.svelte +712 -0
  59. package/dist/components/console/ConsoleInput.svelte.d.ts +16 -0
  60. package/dist/components/console/ConsoleOutput.svelte +121 -0
  61. package/dist/components/console/ConsoleOutput.svelte.d.ts +11 -0
  62. package/dist/components/console/formatters.d.ts +26 -0
  63. package/dist/components/console/formatters.js +118 -0
  64. package/dist/components/interrupt/index.d.ts +1 -0
  65. package/dist/components/interrupt/index.js +1 -0
  66. package/dist/components/nodes/SimpleNode.stories.svelte +64 -0
  67. package/dist/components/nodes/SimpleNode.svelte +27 -11
  68. package/dist/components/nodes/SquareNode.stories.svelte +45 -0
  69. package/dist/components/nodes/SquareNode.svelte +27 -11
  70. package/dist/components/nodes/WorkflowNode.stories.svelte +63 -0
  71. package/dist/config/endpoints.d.ts +8 -0
  72. package/dist/config/endpoints.js +5 -0
  73. package/dist/core/index.d.ts +5 -0
  74. package/dist/core/index.js +9 -0
  75. package/dist/editor/index.d.ts +3 -1
  76. package/dist/editor/index.js +4 -2
  77. package/dist/helpers/proximityConnect.js +8 -1
  78. package/dist/helpers/workflowEditorHelper.d.ts +3 -53
  79. package/dist/helpers/workflowEditorHelper.js +13 -228
  80. package/dist/playground/index.d.ts +1 -1
  81. package/dist/playground/index.js +1 -1
  82. package/dist/schemas/v1/workflow.schema.json +107 -22
  83. package/dist/services/chatService.d.ts +65 -0
  84. package/dist/services/chatService.js +131 -0
  85. package/dist/services/historyService.d.ts +6 -4
  86. package/dist/services/historyService.js +21 -6
  87. package/dist/skins/slate.js +16 -0
  88. package/dist/stores/interruptStore.svelte.js +6 -1
  89. package/dist/stores/playgroundStore.svelte.d.ts +1 -1
  90. package/dist/stores/playgroundStore.svelte.js +11 -2
  91. package/dist/stores/portCoordinateStore.svelte.d.ts +4 -0
  92. package/dist/stores/portCoordinateStore.svelte.js +20 -26
  93. package/dist/stores/workflowStore.svelte.d.ts +31 -2
  94. package/dist/stores/workflowStore.svelte.js +84 -64
  95. package/dist/stories/EdgeDecorator.svelte +4 -4
  96. package/dist/styles/base.css +48 -0
  97. package/dist/svelte-app.d.ts +7 -1
  98. package/dist/svelte-app.js +4 -1
  99. package/dist/types/chat.d.ts +63 -0
  100. package/dist/types/chat.js +9 -0
  101. package/dist/types/events.d.ts +28 -2
  102. package/dist/types/events.js +1 -0
  103. package/dist/types/index.d.ts +8 -0
  104. package/dist/types/settings.d.ts +6 -0
  105. package/dist/types/settings.js +3 -0
  106. package/dist/utils/edgeStyling.d.ts +42 -0
  107. package/dist/utils/edgeStyling.js +176 -0
  108. package/dist/utils/nodeIds.d.ts +31 -0
  109. package/dist/utils/nodeIds.js +42 -0
  110. package/dist/utils/nodeSwap.d.ts +221 -0
  111. package/dist/utils/nodeSwap.js +686 -0
  112. package/package.json +6 -1
  113. package/dist/helpers/nodeLayoutHelper.d.ts +0 -14
  114. package/dist/helpers/nodeLayoutHelper.js +0 -19
@@ -0,0 +1,712 @@
1
+ <!--
2
+ ConsoleInput Component
3
+ Single-line command input with prompt prefix, Enter-to-submit, Escape-to-close
4
+ Includes autocomplete for command verbs and node type IDs
5
+ Styled with BEM syntax matching CommandConsole pattern
6
+ -->
7
+
8
+ <script lang="ts">
9
+ import type { NodeMetadata } from "../../types/index.js";
10
+ import { getWorkflowStore } from "../../stores/workflowStore.svelte.js";
11
+ import { toShortId, resolveNode } from "../../commands/index.js";
12
+ import ConsoleAutocomplete, { type Suggestion } from "./ConsoleAutocomplete.svelte";
13
+
14
+ interface Props {
15
+ /** Whether the console is currently visible/open */
16
+ open: boolean;
17
+ /** Available node types for autocomplete suggestions */
18
+ nodeTypes?: NodeMetadata[];
19
+ /** Called when user submits a command (Enter key) */
20
+ onSubmit: (value: string) => void;
21
+ /** Called when user pastes multiple lines */
22
+ onBatchSubmit?: (lines: string[]) => void;
23
+ /** Called when user presses Escape to close the console */
24
+ onClose: () => void;
25
+ }
26
+
27
+ let { open, nodeTypes = [], onSubmit, onBatchSubmit, onClose }: Props = $props();
28
+
29
+ let inputValue = $state("");
30
+ let inputElement: HTMLInputElement | undefined = $state();
31
+
32
+ // Multiline value entry state
33
+ let multilineMode = $state(false);
34
+ let multilinePrefix = $state(""); // e.g. "set node1:prompt"
35
+ let textareaValue = $state("");
36
+ let textareaElement: HTMLTextAreaElement | undefined = $state();
37
+
38
+ // Command history state
39
+ const MAX_HISTORY = 100;
40
+ let history: string[] = $state([]);
41
+ let historyIndex = $state(-1);
42
+ let savedInput = $state("");
43
+
44
+ // Autocomplete state
45
+ let acVisible = $state(false);
46
+ let acSelectedIndex = $state(0);
47
+ let acSuggestions: Suggestion[] = $state([]);
48
+
49
+ const COMMAND_VERBS = [
50
+ "add", "delete", "rename", "set", "get", "info", "config", "select",
51
+ "connect", "disconnect", "list", "undo", "redo", "help", "clear", "cls",
52
+ "swap", "move", "layout", "canvas",
53
+ ];
54
+
55
+ /** Verbs that take a nodeId as their first argument */
56
+ const NODE_ID_VERBS = [
57
+ "delete", "rename", "info", "config", "select", "set", "get",
58
+ "disconnect", "swap", "move",
59
+ ];
60
+
61
+ $effect(() => {
62
+ if (open && inputElement) {
63
+ inputElement.focus();
64
+ }
65
+ });
66
+
67
+ /**
68
+ * Get node suggestions from the live workflow store.
69
+ * Returns suggestions with short IDs and labels.
70
+ */
71
+ function getWorkflowNodeSuggestions(prefix: string): Suggestion[] {
72
+ const workflow = getWorkflowStore();
73
+ if (!workflow) return [];
74
+
75
+ const lowerPrefix = prefix.toLowerCase();
76
+ return workflow.nodes
77
+ .map((node) => {
78
+ const shortId = toShortId(node.id);
79
+ return {
80
+ value: shortId,
81
+ label: shortId,
82
+ detail: node.data.label,
83
+ };
84
+ })
85
+ .filter((s) => s.value.toLowerCase().startsWith(lowerPrefix))
86
+ .slice(0, 50);
87
+ }
88
+
89
+ /**
90
+ * Detect if cursor is at a position expecting a node ID.
91
+ * Returns the partial text typed so far, or null if not at a nodeId position.
92
+ */
93
+ function getNodeIdContext(value: string): { partial: string; type: "nodeId" | "connectSource" | "connectTarget" } | null {
94
+ // "connect <source> to <partial>" — target node ID
95
+ const connectToMatch = value.match(/^connect\s+\S+\s+to\s+(.*)$/i);
96
+ if (connectToMatch) return { partial: connectToMatch[1], type: "connectTarget" };
97
+
98
+ // "connect <partial>" — source node ID (only if no "to" keyword yet)
99
+ const connectMatch = value.match(/^connect\s+(?!.*\bto\b)(.*)$/i);
100
+ if (connectMatch) return { partial: connectMatch[1], type: "connectSource" };
101
+
102
+ // Verbs that take nodeId as first arg: "verb <partial>"
103
+ for (const verb of NODE_ID_VERBS) {
104
+ const regex = new RegExp(`^${verb}\\s+(.*)$`, "i");
105
+ const match = value.match(regex);
106
+ if (match) {
107
+ // Only suggest if the partial doesn't already contain a space
108
+ // (user has moved past the nodeId arg to further args)
109
+ const partial = match[1];
110
+ if (!partial.includes(" ") && !partial.includes(":")) {
111
+ return { partial, type: "nodeId" };
112
+ }
113
+ return null;
114
+ }
115
+ }
116
+
117
+ return null;
118
+ }
119
+
120
+ /**
121
+ * Detect if cursor is after a "<nodeId>:" pattern, indicating port or config key position.
122
+ * Returns the nodeId, partial text after colon, and context type.
123
+ */
124
+ function getPortOrConfigContext(value: string): { nodeId: string; partial: string; type: "outputPort" | "inputPort" | "configKey" | "port" } | null {
125
+ // "connect <nodeId>:<partial>" — output ports (source position, no "to" yet)
126
+ const connectSourcePort = value.match(/^connect\s+(\S+?):(\S*)$/i);
127
+ if (connectSourcePort && !/\bto\b/i.test(value)) {
128
+ return { nodeId: connectSourcePort[1], partial: connectSourcePort[2], type: "outputPort" };
129
+ }
130
+
131
+ // "connect <source> to <nodeId>:<partial>" — input ports (target position)
132
+ const connectTargetPort = value.match(/^connect\s+\S+\s+to\s+(\S+?):(\S*)$/i);
133
+ if (connectTargetPort) {
134
+ return { nodeId: connectTargetPort[1], partial: connectTargetPort[2], type: "inputPort" };
135
+ }
136
+
137
+ // "set <nodeId>:<partial>" — config keys
138
+ const setMatch = value.match(/^set\s+(\S+?):(\S*)$/i);
139
+ if (setMatch) {
140
+ return { nodeId: setMatch[1], partial: setMatch[2], type: "configKey" };
141
+ }
142
+
143
+ // "get <nodeId>:<partial>" — config keys
144
+ const getMatch = value.match(/^get\s+(\S+?):(\S*)$/i);
145
+ if (getMatch) {
146
+ return { nodeId: getMatch[1], partial: getMatch[2], type: "configKey" };
147
+ }
148
+
149
+ // "disconnect <nodeId>:<partial>" — all ports
150
+ const disconnectMatch = value.match(/^disconnect\s+(\S+?):(\S*)$/i);
151
+ if (disconnectMatch) {
152
+ return { nodeId: disconnectMatch[1], partial: disconnectMatch[2], type: "port" };
153
+ }
154
+
155
+ return null;
156
+ }
157
+
158
+ /**
159
+ * Get port suggestions for a resolved node, filtered by direction and prefix.
160
+ */
161
+ function getPortSuggestions(nodeId: string, partial: string, filter: "input" | "output" | "all"): Suggestion[] {
162
+ const workflow = getWorkflowStore();
163
+ if (!workflow) return [];
164
+
165
+ const node = resolveNode(nodeId, workflow.nodes);
166
+ if (!node) return [];
167
+
168
+ const metadata = node.data.metadata;
169
+ if (!metadata) return [];
170
+
171
+ const lowerPartial = partial.toLowerCase();
172
+ const ports = [
173
+ ...(filter === "input" || filter === "all" ? metadata.inputs : []),
174
+ ...(filter === "output" || filter === "all" ? metadata.outputs : []),
175
+ ];
176
+
177
+ const staticSuggestions = ports
178
+ .filter((p) => p.id.toLowerCase().startsWith(lowerPartial))
179
+ .map((p) => ({
180
+ value: p.id,
181
+ label: p.id,
182
+ detail: `${p.name} (${p.dataType})`,
183
+ }));
184
+
185
+ // Gateway nodes (e.g. if_else) have dynamic branch outputs stored in config, not metadata
186
+ const branchSuggestions: Suggestion[] = [];
187
+ if ((filter === "output" || filter === "all") && metadata.type === "gateway") {
188
+ const branches = node.data.config?.branches as Array<{ name: string; label?: string }> | undefined;
189
+ if (branches) {
190
+ for (const branch of branches) {
191
+ if (branch.name.toLowerCase().startsWith(lowerPartial)) {
192
+ branchSuggestions.push({
193
+ value: branch.name,
194
+ label: branch.name,
195
+ detail: branch.label ? `branch: ${branch.label}` : "branch",
196
+ });
197
+ }
198
+ }
199
+ }
200
+ }
201
+
202
+ return [...staticSuggestions, ...branchSuggestions].slice(0, 50);
203
+ }
204
+
205
+ /**
206
+ * Get config key suggestions for a resolved node, filtered by prefix.
207
+ */
208
+ function getConfigKeySuggestions(nodeId: string, partial: string): Suggestion[] {
209
+ const workflow = getWorkflowStore();
210
+ if (!workflow) return [];
211
+
212
+ const node = resolveNode(nodeId, workflow.nodes);
213
+ if (!node) return [];
214
+
215
+ const metadata = node.data.metadata;
216
+ if (!metadata?.configSchema?.properties) return [];
217
+
218
+ const lowerPartial = partial.toLowerCase();
219
+ return Object.entries(metadata.configSchema.properties)
220
+ .filter(([key]) => key.toLowerCase().startsWith(lowerPartial))
221
+ .map(([key, prop]) => ({
222
+ value: key,
223
+ label: key,
224
+ detail: typeof prop === "object" && "type" in prop ? String(prop.type) : undefined,
225
+ }))
226
+ .slice(0, 50);
227
+ }
228
+
229
+ /** Sub-commands for verbs that have them */
230
+ const SUBCOMMAND_MAP: Record<string, Array<{ value: string; detail?: string }>> = {
231
+ layout: [
232
+ { value: "auto", detail: "Re-arrange all nodes from scratch" },
233
+ { value: "beautify", detail: "Normalize spacing, preserve arrangement" },
234
+ ],
235
+ list: [
236
+ { value: "nodes", detail: "List all workflow nodes" },
237
+ { value: "edges", detail: "List all connections" },
238
+ { value: "types", detail: "List available node types" },
239
+ ],
240
+ canvas: [
241
+ { value: "fitview", detail: "Fit all nodes into the viewport" },
242
+ { value: "zoom in", detail: "Zoom in on the canvas" },
243
+ { value: "zoom out", detail: "Zoom out on the canvas" },
244
+ { value: "zoom", detail: "Set zoom level (e.g. canvas zoom 1.5)" },
245
+ { value: "pan", detail: "Pan to position (e.g. canvas pan 100,200)" },
246
+ { value: "reset", detail: "Reset viewport to default" },
247
+ ],
248
+ };
249
+
250
+ function computeSuggestions(value: string): Suggestion[] {
251
+ if (!value) return [];
252
+
253
+ // Check if we're after "<nodeId>:" — port names or config keys
254
+ const portConfigCtx = getPortOrConfigContext(value);
255
+ if (portConfigCtx !== null) {
256
+ if (portConfigCtx.type === "configKey") {
257
+ return getConfigKeySuggestions(portConfigCtx.nodeId, portConfigCtx.partial);
258
+ }
259
+ const filter = portConfigCtx.type === "outputPort" ? "output"
260
+ : portConfigCtx.type === "inputPort" ? "input"
261
+ : "all";
262
+ return getPortSuggestions(portConfigCtx.nodeId, portConfigCtx.partial, filter);
263
+ }
264
+
265
+ // Check if we're at a sub-command position (e.g. "layout <partial>")
266
+ const subCmdContext = getSubcommandContext(value);
267
+ if (subCmdContext !== null) {
268
+ const prefix = subCmdContext.partial.toLowerCase();
269
+ return subCmdContext.options
270
+ .filter((opt) => opt.value.toLowerCase().startsWith(prefix))
271
+ .map((opt) => ({ value: opt.value, label: opt.value, detail: opt.detail }));
272
+ }
273
+
274
+ // Check if we're in a position where node type IDs should be suggested
275
+ const nodeTypeContext = getNodeTypeContext(value);
276
+ if (nodeTypeContext !== null) {
277
+ const prefix = nodeTypeContext.toLowerCase();
278
+ return nodeTypes
279
+ .filter((nt) => nt.id.toLowerCase().startsWith(prefix))
280
+ .map((nt) => ({
281
+ value: nt.id,
282
+ label: nt.id,
283
+ detail: `${nt.name} (${nt.category})`,
284
+ }))
285
+ .slice(0, 50);
286
+ }
287
+
288
+ // Check if we're at a position expecting a node ID from the workflow
289
+ const nodeIdContext = getNodeIdContext(value);
290
+ if (nodeIdContext !== null) {
291
+ return getWorkflowNodeSuggestions(nodeIdContext.partial);
292
+ }
293
+
294
+ // Check if we're at verb position (no space in input yet)
295
+ if (!value.includes(" ")) {
296
+ const prefix = value.toLowerCase();
297
+ return COMMAND_VERBS
298
+ .filter((v) => v.startsWith(prefix))
299
+ .map((v) => ({ value: v, label: v }));
300
+ }
301
+
302
+ return [];
303
+ }
304
+
305
+ /**
306
+ * Detect if cursor is at a sub-command position (e.g. "layout <partial>").
307
+ * Returns the verb's sub-command options and the partial typed so far, or null.
308
+ */
309
+ function getSubcommandContext(value: string): { partial: string; options: Array<{ value: string; detail?: string }> } | null {
310
+ const match = value.match(/^(\w+)\s+(.*)$/i);
311
+ if (!match) return null;
312
+ const verb = match[1].toLowerCase();
313
+ const partial = match[2];
314
+ const options = SUBCOMMAND_MAP[verb];
315
+ if (!options) return null;
316
+ // Only suggest if the partial has no further spaces (still on sub-command)
317
+ if (partial.includes(" ")) return null;
318
+ return { partial, options };
319
+ }
320
+
321
+ /**
322
+ * Returns the partial text after "add " or "swap <nodeId> with " if at a
323
+ * node-type position, or null otherwise.
324
+ */
325
+ function getNodeTypeContext(value: string): string | null {
326
+ // "add <partial>"
327
+ const addMatch = value.match(/^add\s+(.*)$/i);
328
+ if (addMatch) return addMatch[1];
329
+
330
+ // "swap <nodeId> with <partial>"
331
+ const swapMatch = value.match(/^swap\s+\S+\s+with\s+(.*)$/i);
332
+ if (swapMatch) return swapMatch[1];
333
+
334
+ return null;
335
+ }
336
+
337
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
338
+
339
+ function updateAutocomplete() {
340
+ clearTimeout(debounceTimer);
341
+ debounceTimer = setTimeout(() => {
342
+ const suggestions = computeSuggestions(inputValue);
343
+ acSuggestions = suggestions;
344
+ acSelectedIndex = 0;
345
+ acVisible = suggestions.length > 0;
346
+ }, 100);
347
+ }
348
+
349
+ function dismissAutocomplete() {
350
+ acVisible = false;
351
+ acSuggestions = [];
352
+ acSelectedIndex = 0;
353
+ }
354
+
355
+ function acceptSuggestion(suggestion: Suggestion) {
356
+ // Replace the relevant part of input with the suggestion value
357
+ const portConfigCtx = getPortOrConfigContext(inputValue);
358
+ if (portConfigCtx !== null) {
359
+ // Replace the partial after the colon
360
+ const prefixEnd = inputValue.length - portConfigCtx.partial.length;
361
+ inputValue = inputValue.slice(0, prefixEnd) + suggestion.value;
362
+ dismissAutocomplete();
363
+ inputElement?.focus();
364
+ return;
365
+ }
366
+
367
+ const subCmdContext = getSubcommandContext(inputValue);
368
+ if (subCmdContext !== null) {
369
+ // Replace the partial after the verb
370
+ const prefixEnd = inputValue.length - subCmdContext.partial.length;
371
+ inputValue = inputValue.slice(0, prefixEnd) + suggestion.value;
372
+ } else {
373
+ const nodeTypeContext = getNodeTypeContext(inputValue);
374
+ if (nodeTypeContext !== null) {
375
+ // Replace the partial after the command prefix (node type context)
376
+ const prefixEnd = inputValue.length - nodeTypeContext.length;
377
+ inputValue = inputValue.slice(0, prefixEnd) + suggestion.value;
378
+ } else {
379
+ const nodeIdContext = getNodeIdContext(inputValue);
380
+ if (nodeIdContext !== null) {
381
+ // Replace the partial after the command prefix (node ID context)
382
+ const prefixEnd = inputValue.length - nodeIdContext.partial.length;
383
+ inputValue = inputValue.slice(0, prefixEnd) + suggestion.value;
384
+ } else {
385
+ // Replace the whole input (verb position)
386
+ inputValue = suggestion.value;
387
+ }
388
+ }
389
+ }
390
+ dismissAutocomplete();
391
+ inputElement?.focus();
392
+ }
393
+
394
+ function addToHistory(command: string) {
395
+ // Don't store duplicate consecutive commands
396
+ if (history.length > 0 && history[history.length - 1] === command) {
397
+ return;
398
+ }
399
+ history.push(command);
400
+ // Drop oldest when full
401
+ if (history.length > MAX_HISTORY) {
402
+ history.shift();
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Enter multiline textarea mode for the given set-command prefix.
408
+ * prefixText: e.g. "set node1:prompt", initialValue: partial value already typed
409
+ */
410
+ function enterMultilineMode(prefixText: string, initialValue: string) {
411
+ multilinePrefix = prefixText;
412
+ textareaValue = initialValue;
413
+ multilineMode = true;
414
+ inputValue = "";
415
+ dismissAutocomplete();
416
+ setTimeout(() => textareaElement?.focus(), 0);
417
+ }
418
+
419
+ function exitMultilineMode() {
420
+ multilineMode = false;
421
+ multilinePrefix = "";
422
+ textareaValue = "";
423
+ setTimeout(() => inputElement?.focus(), 0);
424
+ }
425
+
426
+ function submitMultilineValue() {
427
+ const value = textareaValue;
428
+ const prefix = multilinePrefix; // capture before exitMultilineMode clears it
429
+ exitMultilineMode();
430
+ if (!value.trim()) return;
431
+ const command = `${prefix} """\n${value}\n"""`;
432
+ addToHistory(command);
433
+ onSubmit(command);
434
+ }
435
+
436
+ function handleTextareaKeydown(event: KeyboardEvent) {
437
+ if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
438
+ event.preventDefault();
439
+ submitMultilineValue();
440
+ } else if (event.key === "Escape") {
441
+ event.preventDefault();
442
+ exitMultilineMode();
443
+ }
444
+ }
445
+
446
+ function handlePaste(event: ClipboardEvent) {
447
+ const text = event.clipboardData?.getData("text/plain");
448
+ if (!text || !text.includes("\n")) return;
449
+
450
+ // If already typing a set command, treat multiline paste as the value
451
+ const setMatch = inputValue.match(/^(set\s+\S+?:\S+)\s*(.*)$/i);
452
+ if (setMatch) {
453
+ event.preventDefault();
454
+ enterMultilineMode(setMatch[1], (setMatch[2] ? setMatch[2] + "\n" : "") + text);
455
+ historyIndex = -1;
456
+ savedInput = "";
457
+ return;
458
+ }
459
+
460
+ // Multi-line paste: prevent default and batch-submit
461
+ event.preventDefault();
462
+ const lines = text
463
+ .split("\n")
464
+ .map((l) => l.trim())
465
+ .filter((l) => l.length > 0);
466
+ if (lines.length <= 1) return;
467
+
468
+ // Add each line to history
469
+ for (const line of lines) {
470
+ addToHistory(line);
471
+ }
472
+
473
+ if (onBatchSubmit) {
474
+ onBatchSubmit(lines);
475
+ }
476
+
477
+ inputValue = "";
478
+ historyIndex = -1;
479
+ savedInput = "";
480
+ dismissAutocomplete();
481
+ }
482
+
483
+ function handleKeydown(event: KeyboardEvent) {
484
+ // When autocomplete is visible, intercept navigation keys
485
+ if (acVisible && acSuggestions.length > 0) {
486
+ if (event.key === "ArrowUp") {
487
+ event.preventDefault();
488
+ acSelectedIndex = acSelectedIndex > 0
489
+ ? acSelectedIndex - 1
490
+ : acSuggestions.length - 1;
491
+ return;
492
+ }
493
+ if (event.key === "ArrowDown") {
494
+ event.preventDefault();
495
+ acSelectedIndex = acSelectedIndex < acSuggestions.length - 1
496
+ ? acSelectedIndex + 1
497
+ : 0;
498
+ return;
499
+ }
500
+ if (event.key === "Tab") {
501
+ event.preventDefault();
502
+ acceptSuggestion(acSuggestions[acSelectedIndex]);
503
+ return;
504
+ }
505
+ if (event.key === "Enter") {
506
+ const selected = acSuggestions[acSelectedIndex];
507
+ // If accepting the suggestion wouldn't change the input, execute directly
508
+ const before = inputValue;
509
+ acceptSuggestion(selected);
510
+ if (inputValue !== before) {
511
+ event.preventDefault();
512
+ return;
513
+ }
514
+ // Input unchanged — fall through to submit handler below
515
+ }
516
+ if (event.key === "Escape") {
517
+ event.preventDefault();
518
+ dismissAutocomplete();
519
+ return;
520
+ }
521
+ }
522
+
523
+ if (event.key === "Enter" && event.shiftKey) {
524
+ // Shift+Enter on a set command → expand to multiline textarea
525
+ const setMatch = inputValue.match(/^(set\s+\S+?:\S+)\s*(.*)$/i);
526
+ if (setMatch) {
527
+ event.preventDefault();
528
+ enterMultilineMode(setMatch[1], setMatch[2]);
529
+ return;
530
+ }
531
+ }
532
+
533
+ if (event.key === "Enter" && !event.shiftKey) {
534
+ event.preventDefault();
535
+ const value = inputValue.trim();
536
+ if (value) {
537
+ addToHistory(value);
538
+ onSubmit(value);
539
+ inputValue = "";
540
+ historyIndex = -1;
541
+ savedInput = "";
542
+ dismissAutocomplete();
543
+ }
544
+ } else if (event.key === "Escape") {
545
+ event.preventDefault();
546
+ onClose();
547
+ } else if (event.key === "ArrowUp") {
548
+ event.preventDefault();
549
+ if (history.length === 0) return;
550
+ if (historyIndex === -1) {
551
+ // Save current input before navigating history
552
+ savedInput = inputValue;
553
+ historyIndex = history.length - 1;
554
+ } else if (historyIndex > 0) {
555
+ historyIndex--;
556
+ }
557
+ inputValue = history[historyIndex];
558
+ dismissAutocomplete();
559
+ } else if (event.key === "ArrowDown") {
560
+ event.preventDefault();
561
+ if (historyIndex === -1) return;
562
+ if (historyIndex < history.length - 1) {
563
+ historyIndex++;
564
+ inputValue = history[historyIndex];
565
+ } else {
566
+ // Past newest entry — return to saved input
567
+ historyIndex = -1;
568
+ inputValue = savedInput;
569
+ }
570
+ dismissAutocomplete();
571
+ }
572
+ }
573
+
574
+ function handleInput() {
575
+ // Typing resets history navigation
576
+ historyIndex = -1;
577
+ updateAutocomplete();
578
+ }
579
+ </script>
580
+
581
+ <div class="console-input" class:console-input--multiline={multilineMode}>
582
+ <span class="console-input__prompt">&gt;</span>
583
+ <div class="console-input__wrapper">
584
+ {#if multilineMode}
585
+ <div class="console-input__multiline-header">
586
+ <span class="console-input__multiline-label">{multilinePrefix}</span>
587
+ <span class="console-input__multiline-hint">Ctrl+Enter to submit · Esc to cancel</span>
588
+ </div>
589
+ <textarea
590
+ bind:this={textareaElement}
591
+ bind:value={textareaValue}
592
+ class="console-input__textarea"
593
+ rows={5}
594
+ spellcheck={false}
595
+ onkeydown={handleTextareaKeydown}
596
+ ></textarea>
597
+ {:else}
598
+ <ConsoleAutocomplete
599
+ suggestions={acSuggestions}
600
+ visible={acVisible}
601
+ selectedIndex={acSelectedIndex}
602
+ onAccept={acceptSuggestion}
603
+ />
604
+ <input
605
+ bind:this={inputElement}
606
+ bind:value={inputValue}
607
+ class="console-input__field"
608
+ type="text"
609
+ placeholder="Type a command... (set node:key + Shift+Enter for multiline)"
610
+ spellcheck="false"
611
+ autocomplete="off"
612
+ role="combobox"
613
+ aria-expanded={acVisible}
614
+ aria-controls="console-autocomplete-listbox"
615
+ aria-activedescendant={acVisible && acSuggestions.length > 0 ? `console-autocomplete-option-${acSelectedIndex}` : undefined}
616
+ onkeydown={handleKeydown}
617
+ oninput={handleInput}
618
+ onpaste={handlePaste}
619
+ onblur={() => dismissAutocomplete()}
620
+ />
621
+ {/if}
622
+ </div>
623
+ </div>
624
+
625
+ <style>
626
+ .console-input {
627
+ display: flex;
628
+ align-items: center;
629
+ padding: 0.5rem 1rem;
630
+ border-top: 1px solid var(--fd-border-muted);
631
+ background-color: var(--fd-background);
632
+ flex-shrink: 0;
633
+ }
634
+
635
+ .console-input__prompt {
636
+ font-family: monospace;
637
+ font-size: 0.875rem;
638
+ color: var(--fd-muted-foreground);
639
+ margin-right: 0.5rem;
640
+ user-select: none;
641
+ }
642
+
643
+ .console-input__wrapper {
644
+ flex: 1;
645
+ position: relative;
646
+ }
647
+
648
+ .console-input__field {
649
+ width: 100%;
650
+ background: none;
651
+ border: none;
652
+ outline: none;
653
+ font-family: monospace;
654
+ font-size: 0.875rem;
655
+ color: var(--fd-foreground);
656
+ padding: 0;
657
+ line-height: 1.5;
658
+ }
659
+
660
+ .console-input__field::placeholder {
661
+ color: var(--fd-muted-foreground);
662
+ opacity: 0.6;
663
+ }
664
+
665
+ .console-input--multiline {
666
+ align-items: flex-start;
667
+ }
668
+
669
+ .console-input--multiline .console-input__prompt {
670
+ margin-top: 0.125rem;
671
+ }
672
+
673
+ .console-input__multiline-header {
674
+ display: flex;
675
+ justify-content: space-between;
676
+ align-items: baseline;
677
+ margin-bottom: 0.25rem;
678
+ }
679
+
680
+ .console-input__multiline-label {
681
+ font-family: monospace;
682
+ font-size: 0.875rem;
683
+ color: var(--fd-foreground);
684
+ font-weight: 500;
685
+ }
686
+
687
+ .console-input__multiline-hint {
688
+ font-family: monospace;
689
+ font-size: 0.75rem;
690
+ color: var(--fd-muted-foreground);
691
+ opacity: 0.7;
692
+ }
693
+
694
+ .console-input__textarea {
695
+ width: 100%;
696
+ background: none;
697
+ border: 1px solid var(--fd-border-muted);
698
+ border-radius: var(--fd-radius-sm);
699
+ outline: none;
700
+ font-family: monospace;
701
+ font-size: 0.875rem;
702
+ color: var(--fd-foreground);
703
+ padding: 0.375rem 0.5rem;
704
+ line-height: 1.5;
705
+ resize: vertical;
706
+ min-height: 5rem;
707
+ }
708
+
709
+ .console-input__textarea:focus {
710
+ border-color: var(--fd-border);
711
+ }
712
+ </style>