agentweaver 0.1.15 → 0.1.17

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 (110) hide show
  1. package/README.md +76 -19
  2. package/dist/artifact-manifest.js +219 -0
  3. package/dist/artifacts.js +88 -3
  4. package/dist/doctor/checks/env-diagnostics.js +25 -0
  5. package/dist/doctor/checks/executors.js +2 -2
  6. package/dist/doctor/checks/flow-readiness.js +15 -18
  7. package/dist/flow-state.js +212 -15
  8. package/dist/index.js +539 -209
  9. package/dist/interactive/blessed-session.js +361 -0
  10. package/dist/interactive/controller.js +1326 -0
  11. package/dist/interactive/create-interactive-session.js +5 -0
  12. package/dist/interactive/ink/index.js +597 -0
  13. package/dist/interactive/progress.js +245 -0
  14. package/dist/interactive/selectors.js +14 -0
  15. package/dist/interactive/session.js +1 -0
  16. package/dist/interactive/state.js +34 -0
  17. package/dist/interactive/tree.js +155 -0
  18. package/dist/interactive/types.js +1 -0
  19. package/dist/interactive/view-model.js +1 -0
  20. package/dist/interactive-ui.js +159 -194
  21. package/dist/pipeline/auto-flow.js +9 -6
  22. package/dist/pipeline/context.js +7 -5
  23. package/dist/pipeline/declarative-flow-runner.js +212 -6
  24. package/dist/pipeline/declarative-flows.js +63 -17
  25. package/dist/pipeline/execution-routing-config.js +15 -0
  26. package/dist/pipeline/flow-catalog.js +50 -12
  27. package/dist/pipeline/flow-run-resume.js +29 -0
  28. package/dist/pipeline/flow-specs/auto-common.json +90 -360
  29. package/dist/pipeline/flow-specs/auto-golang.json +81 -360
  30. package/dist/pipeline/flow-specs/auto-simple.json +141 -0
  31. package/dist/pipeline/flow-specs/bugz/bug-analyze.json +2 -0
  32. package/dist/pipeline/flow-specs/bugz/bug-fix.json +1 -0
  33. package/dist/pipeline/flow-specs/design-review/design-review-loop.json +316 -0
  34. package/dist/pipeline/flow-specs/design-review.json +10 -0
  35. package/dist/pipeline/flow-specs/gitlab/gitlab-diff-review.json +11 -0
  36. package/dist/pipeline/flow-specs/gitlab/gitlab-review.json +2 -0
  37. package/dist/pipeline/flow-specs/gitlab/mr-description.json +1 -0
  38. package/dist/pipeline/flow-specs/go/run-go-linter-loop.json +2 -0
  39. package/dist/pipeline/flow-specs/go/run-go-tests-loop.json +2 -0
  40. package/dist/pipeline/flow-specs/implement.json +13 -6
  41. package/dist/pipeline/flow-specs/instant-task.json +177 -0
  42. package/dist/pipeline/flow-specs/normalize-task-source.json +311 -0
  43. package/dist/pipeline/flow-specs/plan-revise.json +7 -1
  44. package/dist/pipeline/flow-specs/plan.json +51 -71
  45. package/dist/pipeline/flow-specs/review/review-fix.json +24 -4
  46. package/dist/pipeline/flow-specs/review/review-loop.json +351 -45
  47. package/dist/pipeline/flow-specs/review/review-project-loop.json +590 -0
  48. package/dist/pipeline/flow-specs/review/review-project.json +12 -0
  49. package/dist/pipeline/flow-specs/review/review.json +37 -31
  50. package/dist/pipeline/flow-specs/task-describe.json +2 -0
  51. package/dist/pipeline/flow-specs/task-source/jira-fetch.json +70 -0
  52. package/dist/pipeline/flow-specs/task-source/manual-input.json +216 -0
  53. package/dist/pipeline/launch-profile-config.js +30 -18
  54. package/dist/pipeline/node-contract.js +1 -0
  55. package/dist/pipeline/node-registry.js +115 -6
  56. package/dist/pipeline/node-runner.js +3 -2
  57. package/dist/pipeline/nodes/build-review-fix-prompt-node.js +5 -1
  58. package/dist/pipeline/nodes/clear-ready-to-merge-node.js +11 -0
  59. package/dist/pipeline/nodes/commit-message-form-node.js +8 -0
  60. package/dist/pipeline/nodes/design-review-verdict-node.js +36 -0
  61. package/dist/pipeline/nodes/ensure-summary-json-node.js +13 -2
  62. package/dist/pipeline/nodes/fetch-gitlab-diff-node.js +19 -2
  63. package/dist/pipeline/nodes/fetch-gitlab-review-node.js +19 -2
  64. package/dist/pipeline/nodes/flow-run-node.js +242 -8
  65. package/dist/pipeline/nodes/git-commit-form-node.js +8 -0
  66. package/dist/pipeline/nodes/gitlab-review-artifacts-node.js +19 -2
  67. package/dist/pipeline/nodes/jira-fetch-node.js +50 -4
  68. package/dist/pipeline/nodes/llm-prompt-node.js +38 -36
  69. package/dist/pipeline/nodes/planning-bundle-node.js +10 -0
  70. package/dist/pipeline/nodes/review-verdict-node.js +86 -0
  71. package/dist/pipeline/nodes/select-files-form-node.js +8 -0
  72. package/dist/pipeline/nodes/structured-summary-node.js +24 -0
  73. package/dist/pipeline/nodes/user-input-node.js +38 -3
  74. package/dist/pipeline/nodes/write-selection-file-node.js +20 -4
  75. package/dist/pipeline/plugin-loader.js +389 -0
  76. package/dist/pipeline/plugin-types.js +1 -0
  77. package/dist/pipeline/prompt-registry.js +3 -1
  78. package/dist/pipeline/prompt-runtime.js +4 -1
  79. package/dist/pipeline/registry.js +71 -4
  80. package/dist/pipeline/review-iteration.js +26 -0
  81. package/dist/pipeline/spec-compiler.js +3 -0
  82. package/dist/pipeline/spec-loader.js +14 -0
  83. package/dist/pipeline/spec-types.js +3 -0
  84. package/dist/pipeline/spec-validator.js +20 -0
  85. package/dist/pipeline/value-resolver.js +76 -2
  86. package/dist/plugin-sdk.js +1 -0
  87. package/dist/prompts.js +36 -14
  88. package/dist/review-severity.js +45 -0
  89. package/dist/runtime/artifact-registry.js +405 -0
  90. package/dist/runtime/design-review-input-contract.js +17 -16
  91. package/dist/runtime/env-loader.js +3 -0
  92. package/dist/runtime/execution-routing-store.js +134 -0
  93. package/dist/runtime/execution-routing.js +233 -0
  94. package/dist/runtime/interactive-execution-routing.js +471 -0
  95. package/dist/runtime/plan-revise-input-contract.js +35 -32
  96. package/dist/runtime/planning-bundle.js +123 -0
  97. package/dist/runtime/ready-to-merge.js +22 -1
  98. package/dist/runtime/review-input-contract.js +100 -0
  99. package/dist/structured-artifact-schema-registry.js +9 -0
  100. package/dist/structured-artifact-schemas.json +140 -1
  101. package/dist/structured-artifacts.js +77 -6
  102. package/dist/user-input.js +70 -3
  103. package/docs/example/.flows/examples/claude-example.json +50 -0
  104. package/docs/example/.plugins/claude-example-plugin/index.js +149 -0
  105. package/docs/example/.plugins/claude-example-plugin/plugin.json +8 -0
  106. package/docs/examples/.flows/claude-example.json +50 -0
  107. package/docs/examples/.plugins/claude-example-plugin/index.js +149 -0
  108. package/docs/examples/.plugins/claude-example-plugin/plugin.json +8 -0
  109. package/docs/plugin-sdk.md +731 -0
  110. package/package.json +11 -4
@@ -0,0 +1,1326 @@
1
+ import path from "node:path";
2
+ import { FlowInterruptedError, TaskRunnerError } from "../errors.js";
3
+ import { renderMarkdownToTerminal } from "../markdown.js";
4
+ import { setOutputAdapter, stripAnsi } from "../tui.js";
5
+ import { buildInitialUserInputValues, normalizeUserInputFieldValue, resolveFieldDefinition, validateUserInputValues, } from "../user-input.js";
6
+ import { buildProgressViewModel } from "./progress.js";
7
+ import { selectHeaderLabel } from "./selectors.js";
8
+ import { createInitialInteractiveState } from "./state.js";
9
+ import { buildFlowTree, collectInitiallyExpandedFolderKeys, computeVisibleFlowItems, makeFlowKey, makeFolderKey } from "./tree.js";
10
+ const HELP_TEXT = renderMarkdownToTerminal([
11
+ "AgentWeaver interactive mode",
12
+ "",
13
+ "Keys:",
14
+ "Up / Down select folder or flow",
15
+ "Right expand folder",
16
+ "Left collapse folder or go to parent",
17
+ "Enter expand folder or launch flow",
18
+ "Enter confirm launch in modal",
19
+ "Esc close help/modal or interrupt running flow",
20
+ "F1 open or close help",
21
+ "Tab switch pane",
22
+ "Ctrl+L clear log",
23
+ "q / Ctrl+C exit",
24
+ ].join("\n"));
25
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
26
+ const SPINNER_INTERVAL_MS = 200;
27
+ const LOG_FLUSH_INTERVAL_MS = 120;
28
+ function clamp(value, min, max) {
29
+ return Math.min(max, Math.max(min, value));
30
+ }
31
+ function isPrintableCharacter(ch, key) {
32
+ return Boolean(ch) && !key.ctrl && !key.meta && !/^[\x00-\x1f\x7f]$/.test(ch);
33
+ }
34
+ function isReverseTabKey(key) {
35
+ return key.name === "backtab" || (key.name === "tab" && key.shift === true);
36
+ }
37
+ function textIndexToLineColumn(value, index) {
38
+ const boundedIndex = clamp(index, 0, value.length);
39
+ const beforeCursor = value.slice(0, boundedIndex);
40
+ const lines = beforeCursor.split("\n");
41
+ return {
42
+ line: Math.max(0, lines.length - 1),
43
+ column: (lines[lines.length - 1] ?? "").length,
44
+ };
45
+ }
46
+ function textLineColumnToIndex(value, line, column) {
47
+ const lines = value.split("\n");
48
+ const boundedLine = clamp(line, 0, Math.max(0, lines.length - 1));
49
+ let index = 0;
50
+ for (let currentLine = 0; currentLine < boundedLine; currentLine += 1) {
51
+ index += (lines[currentLine] ?? "").length + 1;
52
+ }
53
+ const targetLine = lines[boundedLine] ?? "";
54
+ return index + clamp(column, 0, targetLine.length);
55
+ }
56
+ function insertCursor(value, index) {
57
+ const boundedIndex = clamp(index, 0, value.length);
58
+ return `${value.slice(0, boundedIndex)}│${value.slice(boundedIndex)}`;
59
+ }
60
+ function wrapTextLine(line, width) {
61
+ if (line.length === 0) {
62
+ return [""];
63
+ }
64
+ const chunks = [];
65
+ for (let index = 0; index < line.length; index += width) {
66
+ chunks.push(line.slice(index, index + width));
67
+ }
68
+ return chunks;
69
+ }
70
+ function buildTextInputBox(value, cursorIndex, requestedWidth) {
71
+ const rendered = insertCursor(value, cursorIndex);
72
+ const naturalWidth = rendered.split("\n").reduce((max, line) => Math.max(max, line.length), 0);
73
+ const width = requestedWidth !== undefined
74
+ ? Math.max(8, requestedWidth)
75
+ : clamp(naturalWidth, 18, 52);
76
+ const renderedLines = rendered
77
+ .split("\n")
78
+ .flatMap((line) => wrapTextLine(line, width));
79
+ return [
80
+ `┌${"─".repeat(width + 2)}┐`,
81
+ ...renderedLines.map((line) => `│ ${line.padEnd(width, " ")} │`),
82
+ `└${"─".repeat(width + 2)}┘`,
83
+ ];
84
+ }
85
+ function normalizeLogText(text) {
86
+ const normalized = text
87
+ .split("\n")
88
+ .map((line) => line.replace(/\t/g, " "))
89
+ .join("\n")
90
+ .trimEnd();
91
+ if (!normalized) {
92
+ return [""];
93
+ }
94
+ return normalized.split("\n");
95
+ }
96
+ export class InteractiveSessionController {
97
+ options;
98
+ listeners = new Set();
99
+ flowMap;
100
+ flowTree;
101
+ expandedFlowFolders = new Set();
102
+ visibleFlowItems;
103
+ state;
104
+ logLines = [];
105
+ pendingLogLines = [];
106
+ logText = "";
107
+ logFlushTimer = null;
108
+ helpVisible = false;
109
+ confirmSession = null;
110
+ activeFormSession = null;
111
+ spinnerTimer = null;
112
+ mounted = false;
113
+ constructor(options) {
114
+ this.options = options;
115
+ if (options.flows.length === 0) {
116
+ throw new Error("Interactive UI requires at least one flow.");
117
+ }
118
+ this.state = createInitialInteractiveState(options);
119
+ this.flowMap = new Map(options.flows.map((flow) => [flow.id, flow]));
120
+ this.flowTree = buildFlowTree(options.flows);
121
+ collectInitiallyExpandedFolderKeys(this.flowTree).forEach((key) => this.expandedFlowFolders.add(key));
122
+ this.visibleFlowItems = computeVisibleFlowItems(this.flowTree, this.expandedFlowFolders);
123
+ }
124
+ subscribe(listener) {
125
+ this.listeners.add(listener);
126
+ return () => {
127
+ this.listeners.delete(listener);
128
+ };
129
+ }
130
+ mount() {
131
+ if (this.mounted) {
132
+ return;
133
+ }
134
+ this.mounted = true;
135
+ setOutputAdapter(this.createAdapter());
136
+ this.focusPane("flows");
137
+ this.emitChange();
138
+ }
139
+ destroy() {
140
+ if (this.logFlushTimer) {
141
+ clearTimeout(this.logFlushTimer);
142
+ this.logFlushTimer = null;
143
+ }
144
+ if (this.spinnerTimer) {
145
+ clearInterval(this.spinnerTimer);
146
+ this.spinnerTimer = null;
147
+ }
148
+ if (this.activeFormSession) {
149
+ this.activeFormSession.reject(new TaskRunnerError(`User cancelled form '${this.activeFormSession.form.formId}'.`));
150
+ this.activeFormSession = null;
151
+ }
152
+ this.confirmSession = null;
153
+ this.helpVisible = false;
154
+ this.mounted = false;
155
+ setOutputAdapter(null);
156
+ this.emitChange();
157
+ }
158
+ requestUserInput(form) {
159
+ if (this.activeFormSession) {
160
+ return Promise.reject(new TaskRunnerError("Another user input form is already active."));
161
+ }
162
+ if (form.fields.length === 0) {
163
+ return Promise.resolve({
164
+ formId: form.formId,
165
+ submittedAt: new Date().toISOString(),
166
+ values: {},
167
+ });
168
+ }
169
+ return new Promise((resolve, reject) => {
170
+ const values = buildInitialUserInputValues(form.fields);
171
+ const firstField = form.fields[0];
172
+ const initialCursorIndex = firstField?.type === "text" ? String(values[firstField.id] ?? "").length : 0;
173
+ const initialOptionIndex = firstField?.type === "single-select"
174
+ ? Math.max(0, firstField.options.findIndex((option) => option.value === String(values[firstField.id] ?? "")))
175
+ : firstField?.type === "multi-select"
176
+ ? Math.max(0, firstField.options.findIndex((option) => Array.isArray(values[firstField.id]) && values[firstField.id].includes(option.value)))
177
+ : 0;
178
+ this.activeFormSession = {
179
+ form,
180
+ values,
181
+ currentFieldIndex: 0,
182
+ currentOptionIndex: initialOptionIndex,
183
+ currentTextCursorIndex: initialCursorIndex,
184
+ previewScrollOffset: 0,
185
+ resolve,
186
+ reject,
187
+ };
188
+ this.confirmSession = null;
189
+ this.helpVisible = false;
190
+ this.emitChange();
191
+ });
192
+ }
193
+ setSummary(markdown) {
194
+ this.state.summaryText = markdown.trim();
195
+ this.state.summaryVisible = this.state.summaryText.length > 0;
196
+ if (!this.state.summaryVisible && this.state.focusedPane === "summary") {
197
+ this.state.focusedPane = "log";
198
+ }
199
+ this.emitChange();
200
+ }
201
+ clearSummary() {
202
+ this.state.summaryText = "";
203
+ this.state.summaryVisible = false;
204
+ if (this.state.focusedPane === "summary") {
205
+ this.state.focusedPane = "log";
206
+ }
207
+ this.emitChange();
208
+ }
209
+ setScope(scopeKey, jiraIssueKey) {
210
+ this.state.scopeKey = scopeKey;
211
+ this.state.jiraIssueKey = jiraIssueKey ?? null;
212
+ this.emitChange();
213
+ }
214
+ appendLog(text) {
215
+ const lines = normalizeLogText(text);
216
+ this.logLines.push(...lines);
217
+ this.pendingLogLines.push(...lines);
218
+ this.logText = this.logText.length > 0 ? `${this.logText}\n${lines.join("\n")}` : lines.join("\n");
219
+ this.state.logScrollOffset = Math.max(0, this.logLines.length - 1);
220
+ this.scheduleLogFlush();
221
+ }
222
+ clearLog() {
223
+ this.logLines.length = 0;
224
+ this.pendingLogLines.length = 0;
225
+ this.logText = "";
226
+ if (this.logFlushTimer) {
227
+ clearTimeout(this.logFlushTimer);
228
+ this.logFlushTimer = null;
229
+ }
230
+ this.state.logScrollOffset = 0;
231
+ this.emitChange({ type: "render", syncLog: true });
232
+ this.appendLog("Log cleared.");
233
+ }
234
+ setFlowFailed(flowId) {
235
+ this.state.failedFlowId = flowId;
236
+ this.emitChange();
237
+ }
238
+ interruptActiveForm(message = "Flow interrupted by user.") {
239
+ if (!this.activeFormSession) {
240
+ return;
241
+ }
242
+ const session = this.activeFormSession;
243
+ this.activeFormSession = null;
244
+ session.reject(new FlowInterruptedError(message));
245
+ this.focusPane("flows");
246
+ this.emitChange();
247
+ }
248
+ async handleKeypress(ch, key) {
249
+ if (this.activeFormSession) {
250
+ this.handleActiveFormKey(ch, key);
251
+ return;
252
+ }
253
+ if (this.confirmSession) {
254
+ await this.handleConfirmKey(key);
255
+ return;
256
+ }
257
+ if (this.helpVisible) {
258
+ this.handleHelpKey(key);
259
+ return;
260
+ }
261
+ if (key.ctrl && key.name === "c") {
262
+ this.openExitConfirm();
263
+ return;
264
+ }
265
+ if (key.ctrl && key.name === "l") {
266
+ this.clearLog();
267
+ return;
268
+ }
269
+ if (key.name === "q") {
270
+ this.openExitConfirm();
271
+ return;
272
+ }
273
+ if (key.name === "f1" || key.name === "h" || key.name === "?") {
274
+ this.helpVisible = true;
275
+ this.emitChange();
276
+ return;
277
+ }
278
+ if (key.name === "escape") {
279
+ if (this.state.busy) {
280
+ this.openInterruptConfirm();
281
+ }
282
+ return;
283
+ }
284
+ if (isReverseTabKey(key)) {
285
+ this.cycleFocus(-1);
286
+ return;
287
+ }
288
+ if (key.name === "tab") {
289
+ this.cycleFocus(1);
290
+ return;
291
+ }
292
+ if (this.state.focusedPane === "flows") {
293
+ await this.handleFlowKey(key);
294
+ return;
295
+ }
296
+ if (this.state.focusedPane === "progress") {
297
+ this.handleScrollKey("progress", key);
298
+ return;
299
+ }
300
+ if (this.state.focusedPane === "summary") {
301
+ this.handleScrollKey("summary", key);
302
+ return;
303
+ }
304
+ this.handleScrollKey("log", key);
305
+ }
306
+ selectFlowIndex(index) {
307
+ const selectedItem = this.visibleFlowItems[index];
308
+ if (!selectedItem) {
309
+ return;
310
+ }
311
+ this.state.selectedFlowItemKey = selectedItem.key;
312
+ if (selectedItem.kind === "flow") {
313
+ this.state.selectedFlowId = selectedItem.flow.id;
314
+ }
315
+ this.emitChange();
316
+ }
317
+ getViewModel(layout) {
318
+ const selectedItem = this.selectedFlowTreeItem();
319
+ const activeFlowId = this.activeFlowId();
320
+ const selectedFlow = selectedItem?.kind === "flow" ? selectedItem.flow : null;
321
+ const progressFlow = this.state.busy ? this.flowMap.get(activeFlowId) ?? null : selectedFlow;
322
+ const progressState = progressFlow && this.state.flowState.flowId === progressFlow.id
323
+ ? this.state.flowState.executionState
324
+ : progressFlow && this.state.currentFlowId === progressFlow.id
325
+ ? this.state.flowState.executionState
326
+ : null;
327
+ const progressViewModel = buildProgressViewModel(progressFlow, progressState);
328
+ const helpText = `${HELP_TEXT}\n\nAvailable flows:\n${this.options.flows.map((flow) => `- ${flow.treePath.join("/")}`).join("\n")}`;
329
+ return {
330
+ header: this.buildHeaderText(),
331
+ title: `AgentWeaver ${this.state.scopeKey}`,
332
+ footer: this.buildFooterText(),
333
+ helpVisible: this.helpVisible,
334
+ helpText,
335
+ helpScrollOffset: this.state.helpScrollOffset,
336
+ flowListTitle: this.panelTitle("Flows", "flows"),
337
+ flowItems: this.visibleFlowItems.map((item) => ({
338
+ key: item.key,
339
+ label: this.renderFlowTreeLabel(item),
340
+ })),
341
+ selectedFlowIndex: Math.max(0, this.visibleFlowItems.findIndex((item) => item.key === this.state.selectedFlowItemKey)),
342
+ progressTitle: this.panelTitle("Current Flow", "progress"),
343
+ progressText: this.renderProgress(progressViewModel),
344
+ progressScrollOffset: this.state.progressScrollOffset,
345
+ descriptionText: this.renderDescription(selectedItem),
346
+ statusText: this.renderStatusText(),
347
+ summaryVisible: this.state.summaryVisible,
348
+ summaryTitle: this.panelTitle("Task Summary", "summary"),
349
+ summaryText: renderMarkdownToTerminal(stripAnsi(this.state.summaryText || "Task summary is not available yet.")),
350
+ summaryScrollOffset: this.state.summaryScrollOffset,
351
+ logTitle: this.panelTitle("Activity", "log"),
352
+ logText: this.logText,
353
+ logScrollOffset: this.state.logScrollOffset,
354
+ confirmText: this.renderConfirmText(),
355
+ form: this.renderFormView(layout),
356
+ };
357
+ }
358
+ emitChange(event = { type: "render" }) {
359
+ for (const listener of this.listeners) {
360
+ listener(event);
361
+ }
362
+ }
363
+ createAdapter() {
364
+ return {
365
+ writeStdout: (text) => {
366
+ this.appendLog(stripAnsi(text).replace(/\r/g, ""));
367
+ },
368
+ writeStderr: (text) => {
369
+ this.appendLog(stripAnsi(text).replace(/\r/g, ""));
370
+ },
371
+ supportsTransientStatus: false,
372
+ supportsPassthrough: false,
373
+ renderAuxiliaryOutput: true,
374
+ renderPanelsAsPlainText: true,
375
+ setExecutionState: (next) => {
376
+ this.state.currentNode = next.node;
377
+ this.state.currentExecutor = next.executor;
378
+ this.ensureSpinnerState();
379
+ this.emitChange();
380
+ },
381
+ setFlowState: (next) => {
382
+ this.state.flowState = {
383
+ flowId: next.flowId,
384
+ executionState: next.executionState,
385
+ };
386
+ if (next.flowId) {
387
+ this.state.currentFlowId = next.flowId;
388
+ }
389
+ this.ensureSpinnerState();
390
+ this.emitChange();
391
+ },
392
+ };
393
+ }
394
+ buildHeaderText() {
395
+ const current = this.state.currentFlowId ?? selectHeaderLabel(this.selectedFlowTreeItem(), this.state.selectedFlowId);
396
+ const pathParts = this.options.cwd.split(path.sep).filter(Boolean);
397
+ const folderName = pathParts.slice(-3).join("/") || this.options.cwd;
398
+ const branchLabel = this.options.gitBranchName ? this.options.gitBranchName : "detached-head";
399
+ const runningSuffix = this.state.busy ? " [running]" : "";
400
+ const versionLabel = this.state.version ? ` | Version ${this.state.version}` : "";
401
+ const jiraLabel = this.state.jiraIssueKey ? ` | Jira ${this.state.jiraIssueKey}` : "";
402
+ return `AgentWeaver | Scope ${this.state.scopeKey}${versionLabel}${jiraLabel} | Flow ${current}${runningSuffix} | Location ${folderName} • ${branchLabel}`;
403
+ }
404
+ buildFooterText() {
405
+ if (this.activeFormSession) {
406
+ const formView = this.renderFormView();
407
+ return formView?.footer ?? "Form active";
408
+ }
409
+ if (this.confirmSession) {
410
+ return "Confirm: Left/Right or Tab choose | Enter confirm | Esc cancel";
411
+ }
412
+ if (this.helpVisible) {
413
+ return "Help: Esc close | Up/Down/PageUp/PageDown scroll";
414
+ }
415
+ return `Focus: ${this.state.focusedPane} | Up/Down select or scroll | Left/Right fold | Enter run | h help | Esc interrupt | Tab switch | q exit`;
416
+ }
417
+ panelTitle(title, pane) {
418
+ return this.state.focusedPane === pane ? `▶ ${title}` : title;
419
+ }
420
+ renderFlowTreeLabel(item) {
421
+ const indent = " ".repeat(item.depth);
422
+ if (item.kind === "folder") {
423
+ const expanded = this.expandedFlowFolders.has(item.key);
424
+ return `${indent}${expanded ? "▾" : "▸"} ${item.name}`;
425
+ }
426
+ return `${indent}• ${item.name}`;
427
+ }
428
+ renderDescription(selectedItem) {
429
+ if (!selectedItem) {
430
+ return "Flow structure is not available.";
431
+ }
432
+ if (selectedItem.kind === "folder") {
433
+ const rootName = selectedItem.pathSegments[0];
434
+ const kindLabel = rootName === "custom" ? "project-local" : rootName === "global" ? "global" : "built-in";
435
+ return [
436
+ `Flow folder '${selectedItem.pathSegments.join("/")}'.`,
437
+ "",
438
+ `Source: ${kindLabel}`,
439
+ `State: ${this.expandedFlowFolders.has(selectedItem.key) ? "expanded" : "collapsed"}`,
440
+ ].join("\n");
441
+ }
442
+ const { flow } = selectedItem;
443
+ const description = flow.description?.trim() || "No description available for this flow.";
444
+ const details = [
445
+ `Path: ${flow.treePath.join("/")}`,
446
+ `Source: ${flow.source === "project-local" ? "project-local" : flow.source === "global" ? "global" : "built-in"}`,
447
+ flow.source !== "built-in" && flow.sourcePath ? `File: ${flow.sourcePath}` : "",
448
+ ]
449
+ .filter((line) => line.length > 0)
450
+ .join("\n");
451
+ return renderMarkdownToTerminal(stripAnsi(details ? `${description}\n\n${details}` : description));
452
+ }
453
+ renderProgress(progressViewModel) {
454
+ if (!progressViewModel.flow) {
455
+ return "Select a flow in the tree to see its progress.";
456
+ }
457
+ const lines = [progressViewModel.flow.label, ""];
458
+ for (const item of progressViewModel.items) {
459
+ if (item.kind === "termination") {
460
+ const symbol = item.status === "done" ? "✓" : "■";
461
+ lines.push(`${symbol} ${item.label}`);
462
+ lines.push(item.detail);
463
+ continue;
464
+ }
465
+ const indent = " ".repeat(item.depth);
466
+ lines.push(`${indent}${this.symbolForStatus(progressViewModel.flow.id, item.status)} ${item.label}`);
467
+ }
468
+ return lines.join("\n").trimEnd();
469
+ }
470
+ renderStatusText() {
471
+ const running = this.state.busy || this.state.currentNode !== null || this.state.currentExecutor !== null;
472
+ const spinner = running ? SPINNER_FRAMES[this.state.spinnerFrame] ?? "•" : "•";
473
+ const stateText = running ? `${spinner} running` : "idle";
474
+ return [
475
+ `State: ${stateText}`,
476
+ `Time: ${this.formatElapsed(running ? Date.now() : null)}`,
477
+ `Node: ${this.state.currentNode ?? "-"}`,
478
+ `Executor: ${this.state.currentExecutor ?? "-"}`,
479
+ ].join("\n");
480
+ }
481
+ renderConfirmText() {
482
+ const session = this.confirmSession;
483
+ if (!session) {
484
+ return null;
485
+ }
486
+ const flow = session.flowId ? this.flowMap.get(session.flowId) : null;
487
+ const actions = this.confirmActions();
488
+ const actionLabels = actions
489
+ .map((action) => {
490
+ const label = action === "stop"
491
+ ? "Stop"
492
+ : action === "resume"
493
+ ? "Resume"
494
+ : action === "continue"
495
+ ? "Continue"
496
+ : action === "restart"
497
+ ? "Restart"
498
+ : action === "ok"
499
+ ? "OK"
500
+ : "Cancel";
501
+ return session.selectedAction === action ? `[ ${label} ]` : ` ${label} `;
502
+ })
503
+ .join(" ");
504
+ const lines = [session.kind === "interrupt"
505
+ ? `Interrupt flow "${flow?.label ?? session.flowId ?? "-"}"?`
506
+ : session.kind === "exit"
507
+ ? "Exit AgentWeaver?"
508
+ : `Run flow "${flow?.label ?? session.flowId ?? "-"}"?`];
509
+ if (session.details?.trim()) {
510
+ lines.push("", session.details.trim());
511
+ }
512
+ lines.push("", actionLabels, "", "Left/Right or Tab: choose Enter: confirm Esc: cancel");
513
+ return lines.join("\n");
514
+ }
515
+ renderFormView(layout) {
516
+ const session = this.activeFormSession;
517
+ const field = this.currentFormField();
518
+ if (!session || !field) {
519
+ return null;
520
+ }
521
+ const isLastField = session.currentFieldIndex >= session.form.fields.length - 1;
522
+ const lines = [session.form.title];
523
+ if (session.form.description?.trim()) {
524
+ lines.push("", session.form.description.trim());
525
+ }
526
+ lines.push("", `Field ${session.currentFieldIndex + 1}/${session.form.fields.length}`, field.label);
527
+ if (field.help?.trim()) {
528
+ lines.push(field.help.trim());
529
+ }
530
+ let footer = `Form: Enter ${isLastField ? "submit" : "next"} | Tab switch | Esc cancel`;
531
+ if (field.type === "boolean") {
532
+ lines.push("", `${session.values[field.id] === true ? "[x]" : "[ ]"} ${field.label}`);
533
+ footer = `Form: Space toggle | Enter ${isLastField ? "submit" : "next"} | Tab switch | Esc cancel`;
534
+ }
535
+ else if (field.type === "text") {
536
+ const current = String(session.values[field.id] ?? "");
537
+ lines.push("", "Text input:");
538
+ lines.push(...buildTextInputBox(current, session.currentTextCursorIndex, layout?.formContentWidth));
539
+ if (!current && field.placeholder?.trim()) {
540
+ lines.push(`Hint: ${field.placeholder.trim()}`);
541
+ }
542
+ footer = field.multiline
543
+ ? "Form: Enter newline | Tab switch | Ctrl+S submit | Esc cancel"
544
+ : `Form: Type text | Enter ${isLastField ? "submit" : "next"} | Tab switch | Esc cancel`;
545
+ }
546
+ else {
547
+ const preview = session.form.preview?.trim() ?? "";
548
+ if (preview) {
549
+ const previewLines = preview.split("\n");
550
+ const previewHeight = 10;
551
+ const maxOffset = Math.max(0, previewLines.length - previewHeight);
552
+ session.previewScrollOffset = clamp(session.previewScrollOffset, 0, maxOffset);
553
+ const visibleLines = previewLines.slice(session.previewScrollOffset, session.previewScrollOffset + previewHeight);
554
+ lines.push("", "Preview:", ...visibleLines);
555
+ if (maxOffset > 0) {
556
+ lines.push(`Preview ${session.previewScrollOffset + 1}-${session.previewScrollOffset + visibleLines.length} of ${previewLines.length}`);
557
+ }
558
+ }
559
+ lines.push("", "Options:");
560
+ const selectedValues = field.type === "single-select"
561
+ ? [String(session.values[field.id] ?? "")]
562
+ : Array.isArray(session.values[field.id]) ? session.values[field.id] : [];
563
+ field.options.forEach((option, index) => {
564
+ const pointer = index === session.currentOptionIndex ? ">" : " ";
565
+ const marker = selectedValues.includes(option.value) ? "[x]" : "[ ]";
566
+ lines.push(`${pointer} ${marker} ${option.label}`);
567
+ });
568
+ footer = preview
569
+ ? `Form: Up/Down move | PageUp/PageDown preview | Enter ${isLastField ? "submit" : "next"} | Tab switch | Esc cancel`
570
+ : `Form: Up/Down move | Enter ${isLastField ? "submit" : "next"} | Tab switch | Esc cancel`;
571
+ }
572
+ return {
573
+ title: "User Input",
574
+ content: lines.join("\n"),
575
+ footer,
576
+ };
577
+ }
578
+ currentFormField() {
579
+ if (!this.activeFormSession) {
580
+ return null;
581
+ }
582
+ const field = this.activeFormSession.form.fields[this.activeFormSession.currentFieldIndex] ?? null;
583
+ if (!field) {
584
+ return null;
585
+ }
586
+ normalizeUserInputFieldValue(field, this.activeFormSession.values);
587
+ return resolveFieldDefinition(field, this.activeFormSession.values);
588
+ }
589
+ handleHelpKey(key) {
590
+ if (key.name === "escape" || key.name === "f1" || key.name === "h" || key.name === "?") {
591
+ this.helpVisible = false;
592
+ this.emitChange();
593
+ return;
594
+ }
595
+ this.handleScrollKey("help", key);
596
+ }
597
+ async handleConfirmKey(key) {
598
+ if (key.name === "escape") {
599
+ this.confirmSession = null;
600
+ this.emitChange();
601
+ return;
602
+ }
603
+ if (key.name === "left" || isReverseTabKey(key)) {
604
+ this.moveConfirmSelection(-1);
605
+ return;
606
+ }
607
+ if (key.name === "right" || key.name === "tab") {
608
+ this.moveConfirmSelection(1);
609
+ return;
610
+ }
611
+ if (key.name === "enter") {
612
+ await this.acceptConfirm();
613
+ }
614
+ }
615
+ async handleFlowKey(key) {
616
+ if (key.name === "up") {
617
+ this.moveSelectedFlow(-1);
618
+ return;
619
+ }
620
+ if (key.name === "down") {
621
+ this.moveSelectedFlow(1);
622
+ return;
623
+ }
624
+ if (key.name === "home") {
625
+ this.selectFlowIndex(0);
626
+ return;
627
+ }
628
+ if (key.name === "end") {
629
+ this.selectFlowIndex(Math.max(0, this.visibleFlowItems.length - 1));
630
+ return;
631
+ }
632
+ if (key.name === "pageup") {
633
+ this.moveSelectedFlow(-10);
634
+ return;
635
+ }
636
+ if (key.name === "pagedown") {
637
+ this.moveSelectedFlow(10);
638
+ return;
639
+ }
640
+ if (key.name === "right") {
641
+ this.expandSelectedFlowFolder();
642
+ return;
643
+ }
644
+ if (key.name === "left") {
645
+ this.collapseSelectedFlowFolderOrSelectParent();
646
+ return;
647
+ }
648
+ if (key.name === "enter") {
649
+ if (this.state.busy) {
650
+ return;
651
+ }
652
+ const selectedItem = this.selectedFlowTreeItem();
653
+ if (selectedItem?.kind === "folder") {
654
+ this.toggleFlowFolder(selectedItem.key);
655
+ return;
656
+ }
657
+ await this.openConfirm();
658
+ }
659
+ }
660
+ handleScrollKey(panel, key) {
661
+ const maxOffset = this.panelMaxScroll(panel);
662
+ const current = this.scrollOffsetFor(panel);
663
+ if (key.name === "up") {
664
+ this.setScrollOffset(panel, current - 1, maxOffset);
665
+ return;
666
+ }
667
+ if (key.name === "down") {
668
+ this.setScrollOffset(panel, current + 1, maxOffset);
669
+ return;
670
+ }
671
+ if (key.name === "pageup") {
672
+ this.setScrollOffset(panel, current - 10, maxOffset);
673
+ return;
674
+ }
675
+ if (key.name === "pagedown") {
676
+ this.setScrollOffset(panel, current + 10, maxOffset);
677
+ return;
678
+ }
679
+ if (key.name === "home") {
680
+ this.setScrollOffset(panel, 0, maxOffset);
681
+ return;
682
+ }
683
+ if (key.name === "end") {
684
+ this.setScrollOffset(panel, maxOffset, maxOffset);
685
+ }
686
+ }
687
+ moveSelectedFlow(delta) {
688
+ const currentIndex = Math.max(0, this.visibleFlowItems.findIndex((item) => item.key === this.state.selectedFlowItemKey));
689
+ this.selectFlowIndex(clamp(currentIndex + delta, 0, Math.max(0, this.visibleFlowItems.length - 1)));
690
+ }
691
+ focusPane(pane) {
692
+ this.state.focusedPane = pane;
693
+ }
694
+ cycleFocus(direction) {
695
+ const panes = this.state.summaryVisible ? ["flows", "progress", "summary", "log"] : ["flows", "progress", "log"];
696
+ const currentIndex = panes.indexOf(this.state.focusedPane);
697
+ const nextIndex = (currentIndex + direction + panes.length) % panes.length;
698
+ this.focusPane(panes[nextIndex] ?? "flows");
699
+ this.emitChange();
700
+ }
701
+ toggleFlowFolder(folderKey) {
702
+ if (this.expandedFlowFolders.has(folderKey)) {
703
+ this.expandedFlowFolders.delete(folderKey);
704
+ }
705
+ else {
706
+ this.expandedFlowFolders.add(folderKey);
707
+ }
708
+ this.refreshVisibleFlowItems();
709
+ this.emitChange();
710
+ }
711
+ expandSelectedFlowFolder() {
712
+ const selectedItem = this.selectedFlowTreeItem();
713
+ if (!selectedItem || selectedItem.kind !== "folder" || this.expandedFlowFolders.has(selectedItem.key)) {
714
+ return;
715
+ }
716
+ this.toggleFlowFolder(selectedItem.key);
717
+ }
718
+ collapseSelectedFlowFolderOrSelectParent() {
719
+ const selectedItem = this.selectedFlowTreeItem();
720
+ if (!selectedItem) {
721
+ return;
722
+ }
723
+ if (selectedItem.kind === "folder" && this.expandedFlowFolders.has(selectedItem.key)) {
724
+ this.toggleFlowFolder(selectedItem.key);
725
+ return;
726
+ }
727
+ const parentPath = selectedItem.pathSegments.slice(0, -1);
728
+ if (parentPath.length === 0) {
729
+ return;
730
+ }
731
+ const parentKey = makeFolderKey(parentPath);
732
+ if (!this.visibleFlowItems.some((item) => item.key === parentKey)) {
733
+ return;
734
+ }
735
+ this.state.selectedFlowItemKey = parentKey;
736
+ this.refreshVisibleFlowItems();
737
+ this.emitChange();
738
+ }
739
+ refreshVisibleFlowItems() {
740
+ this.visibleFlowItems = computeVisibleFlowItems(this.flowTree, this.expandedFlowFolders);
741
+ if (!this.visibleFlowItems.some((item) => item.key === this.state.selectedFlowItemKey)) {
742
+ this.state.selectedFlowItemKey = this.visibleFlowItems[0]?.key ?? makeFlowKey(this.state.selectedFlowId);
743
+ }
744
+ const selectedItem = this.selectedFlowTreeItem();
745
+ if (selectedItem?.kind === "flow") {
746
+ this.state.selectedFlowId = selectedItem.flow.id;
747
+ }
748
+ }
749
+ selectedFlowTreeItem() {
750
+ return this.visibleFlowItems.find((item) => item.key === this.state.selectedFlowItemKey);
751
+ }
752
+ async openConfirm() {
753
+ if (this.state.busy || this.confirmSession) {
754
+ return;
755
+ }
756
+ const selectedItem = this.selectedFlowTreeItem();
757
+ if (!selectedItem || selectedItem.kind !== "flow") {
758
+ return;
759
+ }
760
+ const confirmation = await this.options.getRunConfirmation(selectedItem.flow.id);
761
+ if (this.state.busy || this.confirmSession) {
762
+ return;
763
+ }
764
+ this.confirmSession = {
765
+ kind: "run",
766
+ flowId: selectedItem.flow.id,
767
+ availability: {
768
+ hasExistingState: confirmation.hasExistingState,
769
+ resume: confirmation.resume.available,
770
+ continue: confirmation.continue.available,
771
+ restart: confirmation.restart.available,
772
+ },
773
+ details: confirmation.details ?? null,
774
+ selectedAction: confirmation.resume.available
775
+ ? "resume"
776
+ : confirmation.continue.available
777
+ ? "continue"
778
+ : confirmation.restart.available
779
+ ? "restart"
780
+ : "ok",
781
+ };
782
+ this.emitChange();
783
+ }
784
+ openInterruptConfirm() {
785
+ const flowId = this.state.currentFlowId;
786
+ if (!flowId || this.confirmSession) {
787
+ return;
788
+ }
789
+ this.confirmSession = {
790
+ kind: "interrupt",
791
+ flowId,
792
+ availability: {
793
+ hasExistingState: true,
794
+ resume: true,
795
+ continue: false,
796
+ restart: false,
797
+ },
798
+ details: "The current flow will be stopped. State will be saved and can be continued via Resume.",
799
+ selectedAction: "stop",
800
+ };
801
+ this.emitChange();
802
+ }
803
+ openExitConfirm() {
804
+ if (this.confirmSession) {
805
+ return;
806
+ }
807
+ const details = this.state.busy
808
+ ? "A flow is currently running. Exiting will close the interactive UI."
809
+ : "The interactive session will be closed.";
810
+ this.confirmSession = {
811
+ kind: "exit",
812
+ flowId: null,
813
+ availability: {
814
+ hasExistingState: false,
815
+ resume: false,
816
+ continue: false,
817
+ restart: false,
818
+ },
819
+ details,
820
+ selectedAction: "ok",
821
+ };
822
+ this.emitChange();
823
+ }
824
+ confirmActions() {
825
+ if (!this.confirmSession) {
826
+ return ["cancel"];
827
+ }
828
+ if (this.confirmSession.kind === "interrupt") {
829
+ return ["stop", "cancel"];
830
+ }
831
+ if (this.confirmSession.kind === "exit") {
832
+ return ["ok", "cancel"];
833
+ }
834
+ const actions = [];
835
+ if (this.confirmSession.availability.resume) {
836
+ actions.push("resume");
837
+ }
838
+ if (this.confirmSession.availability.continue) {
839
+ actions.push("continue");
840
+ }
841
+ if (this.confirmSession.availability.restart) {
842
+ actions.push("restart");
843
+ }
844
+ return actions.length > 0 ? [...actions, "cancel"] : ["ok", "cancel"];
845
+ }
846
+ moveConfirmSelection(delta) {
847
+ if (!this.confirmSession) {
848
+ return;
849
+ }
850
+ const actions = this.confirmActions();
851
+ const currentIndex = actions.indexOf(this.confirmSession.selectedAction);
852
+ const nextIndex = (currentIndex + delta + actions.length) % actions.length;
853
+ this.confirmSession.selectedAction = (actions[nextIndex] ?? "cancel");
854
+ this.emitChange();
855
+ }
856
+ async acceptConfirm() {
857
+ const session = this.confirmSession;
858
+ if (!session) {
859
+ return;
860
+ }
861
+ if (session.selectedAction === "cancel") {
862
+ this.confirmSession = null;
863
+ this.emitChange();
864
+ return;
865
+ }
866
+ if (session.kind === "interrupt") {
867
+ const flowId = session.flowId;
868
+ this.confirmSession = null;
869
+ this.emitChange();
870
+ if (flowId) {
871
+ await this.options.onInterrupt(flowId);
872
+ }
873
+ return;
874
+ }
875
+ if (session.kind === "exit") {
876
+ this.confirmSession = null;
877
+ this.emitChange();
878
+ this.options.onExit();
879
+ return;
880
+ }
881
+ const flowId = session.flowId ?? this.state.selectedFlowId;
882
+ const launchMode = session.selectedAction === "resume"
883
+ ? "resume"
884
+ : session.selectedAction === "continue"
885
+ ? "continue"
886
+ : "restart";
887
+ this.confirmSession = null;
888
+ this.setBusy(true, flowId);
889
+ this.clearFlowFailure(flowId);
890
+ this.state.flowState = {
891
+ flowId,
892
+ executionState: null,
893
+ };
894
+ this.emitChange();
895
+ try {
896
+ await this.options.onRun(flowId, launchMode);
897
+ }
898
+ finally {
899
+ this.setBusy(false);
900
+ this.focusPane("flows");
901
+ this.emitChange();
902
+ }
903
+ }
904
+ clearFlowFailure(flowId) {
905
+ if (this.state.failedFlowId === flowId) {
906
+ this.state.failedFlowId = null;
907
+ }
908
+ }
909
+ setBusy(busy, flowId) {
910
+ this.state.busy = busy;
911
+ if (flowId !== undefined) {
912
+ this.state.currentFlowId = flowId;
913
+ }
914
+ if (busy && this.state.runningStartedAt === null) {
915
+ this.state.runningStartedAt = Date.now();
916
+ }
917
+ if (!busy && this.state.currentNode === null && this.state.currentExecutor === null) {
918
+ this.state.runningStartedAt = null;
919
+ }
920
+ this.ensureSpinnerState();
921
+ }
922
+ ensureSpinnerState() {
923
+ const running = this.state.busy || this.state.currentNode !== null || this.state.currentExecutor !== null;
924
+ if (running && this.spinnerTimer === null) {
925
+ if (this.state.runningStartedAt === null) {
926
+ this.state.runningStartedAt = Date.now();
927
+ }
928
+ this.spinnerTimer = setInterval(() => {
929
+ this.state.spinnerFrame = (this.state.spinnerFrame + 1) % SPINNER_FRAMES.length;
930
+ this.emitChange();
931
+ }, SPINNER_INTERVAL_MS);
932
+ return;
933
+ }
934
+ if (!running && this.spinnerTimer) {
935
+ clearInterval(this.spinnerTimer);
936
+ this.spinnerTimer = null;
937
+ this.state.spinnerFrame = 0;
938
+ this.state.runningStartedAt = null;
939
+ }
940
+ }
941
+ activeFlowId() {
942
+ return this.state.currentFlowId ?? this.state.selectedFlowId;
943
+ }
944
+ symbolForStatus(flowId, status) {
945
+ if (status === "done") {
946
+ return "✓";
947
+ }
948
+ if (status === "skipped") {
949
+ return "·";
950
+ }
951
+ if (status === "running") {
952
+ if (this.state.failedFlowId === flowId && !this.state.busy) {
953
+ return "×";
954
+ }
955
+ return SPINNER_FRAMES[this.state.spinnerFrame] ?? "▶";
956
+ }
957
+ return "○";
958
+ }
959
+ formatElapsed(now) {
960
+ if (this.state.runningStartedAt === null || now === null) {
961
+ return "00:00:00";
962
+ }
963
+ const totalSeconds = Math.max(0, Math.floor((now - this.state.runningStartedAt) / 1000));
964
+ const hours = String(Math.floor(totalSeconds / 3600)).padStart(2, "0");
965
+ const minutes = String((totalSeconds % 3600) / 60 | 0).padStart(2, "0");
966
+ const seconds = String(totalSeconds % 60).padStart(2, "0");
967
+ return `${hours}:${minutes}:${seconds}`;
968
+ }
969
+ scrollOffsetFor(panel) {
970
+ if (panel === "progress") {
971
+ return this.state.progressScrollOffset;
972
+ }
973
+ if (panel === "summary") {
974
+ return this.state.summaryScrollOffset;
975
+ }
976
+ if (panel === "help") {
977
+ return this.state.helpScrollOffset;
978
+ }
979
+ return this.state.logScrollOffset;
980
+ }
981
+ setScrollOffset(panel, value, maxOffset) {
982
+ const next = clamp(value, 0, maxOffset);
983
+ if (panel === "progress") {
984
+ this.state.progressScrollOffset = next;
985
+ }
986
+ else if (panel === "summary") {
987
+ this.state.summaryScrollOffset = next;
988
+ }
989
+ else if (panel === "help") {
990
+ this.state.helpScrollOffset = next;
991
+ }
992
+ else {
993
+ this.state.logScrollOffset = next;
994
+ }
995
+ this.emitChange();
996
+ }
997
+ panelMaxScroll(panel) {
998
+ const content = panel === "progress"
999
+ ? this.getViewModel().progressText
1000
+ : panel === "summary"
1001
+ ? this.getViewModel().summaryText
1002
+ : panel === "help"
1003
+ ? this.getViewModel().helpText
1004
+ : this.getViewModel().logText;
1005
+ return Math.max(0, content.split("\n").length - 1);
1006
+ }
1007
+ moveActiveFormField(delta) {
1008
+ const session = this.activeFormSession;
1009
+ if (!session) {
1010
+ return;
1011
+ }
1012
+ this.syncActiveSelectFieldValue();
1013
+ const nextIndex = clamp(session.currentFieldIndex + delta, 0, Math.max(0, session.form.fields.length - 1));
1014
+ session.currentFieldIndex = nextIndex;
1015
+ const nextField = session.form.fields[nextIndex];
1016
+ if (nextField?.type === "text") {
1017
+ const current = String(session.values[nextField.id] ?? "");
1018
+ session.currentTextCursorIndex = current.length;
1019
+ session.currentOptionIndex = 0;
1020
+ }
1021
+ else if (nextField?.type === "single-select" || nextField?.type === "multi-select") {
1022
+ session.currentTextCursorIndex = 0;
1023
+ session.currentOptionIndex = this.selectedOptionIndexForField(nextField);
1024
+ }
1025
+ else {
1026
+ session.currentTextCursorIndex = 0;
1027
+ session.currentOptionIndex = 0;
1028
+ }
1029
+ session.previewScrollOffset = 0;
1030
+ this.emitChange();
1031
+ }
1032
+ selectedOptionIndexForField(field) {
1033
+ const session = this.activeFormSession;
1034
+ if (!session || field.options.length === 0) {
1035
+ return 0;
1036
+ }
1037
+ if (field.type === "single-select") {
1038
+ const selectedValue = String(session.values[field.id] ?? "");
1039
+ const selectedIndex = field.options.findIndex((option) => option.value === selectedValue);
1040
+ return selectedIndex >= 0 ? selectedIndex : 0;
1041
+ }
1042
+ const selectedValues = Array.isArray(session.values[field.id]) ? session.values[field.id] : [];
1043
+ const selectedIndex = field.options.findIndex((option) => selectedValues.includes(option.value));
1044
+ return selectedIndex >= 0 ? selectedIndex : 0;
1045
+ }
1046
+ syncActiveSelectFieldValue() {
1047
+ const session = this.activeFormSession;
1048
+ const field = this.currentFormField();
1049
+ if (!session || !field || (field.type !== "single-select" && field.type !== "multi-select")) {
1050
+ return;
1051
+ }
1052
+ session.currentOptionIndex = clamp(session.currentOptionIndex, 0, Math.max(0, field.options.length - 1));
1053
+ }
1054
+ toggleActiveFormValue() {
1055
+ const session = this.activeFormSession;
1056
+ const field = this.currentFormField();
1057
+ if (!session || !field) {
1058
+ return;
1059
+ }
1060
+ if (field.type === "boolean") {
1061
+ session.values[field.id] = session.values[field.id] !== true;
1062
+ this.emitChange();
1063
+ return;
1064
+ }
1065
+ if (field.type !== "single-select" && field.type !== "multi-select") {
1066
+ return;
1067
+ }
1068
+ this.syncActiveSelectFieldValue();
1069
+ const option = field.options[session.currentOptionIndex];
1070
+ if (!option) {
1071
+ return;
1072
+ }
1073
+ if (field.type === "single-select") {
1074
+ session.values[field.id] = option.value;
1075
+ this.emitChange();
1076
+ return;
1077
+ }
1078
+ const current = Array.isArray(session.values[field.id]) ? [...session.values[field.id]] : [];
1079
+ session.values[field.id] = current.includes(option.value)
1080
+ ? current.filter((item) => item !== option.value)
1081
+ : [...current, option.value];
1082
+ this.emitChange();
1083
+ }
1084
+ confirmActiveFormField() {
1085
+ const session = this.activeFormSession;
1086
+ if (!session) {
1087
+ return;
1088
+ }
1089
+ this.syncActiveSelectFieldValue();
1090
+ if (session.currentFieldIndex >= session.form.fields.length - 1) {
1091
+ this.submitActiveForm();
1092
+ return;
1093
+ }
1094
+ this.moveActiveFormField(1);
1095
+ }
1096
+ submitActiveForm() {
1097
+ const session = this.activeFormSession;
1098
+ if (!session) {
1099
+ return;
1100
+ }
1101
+ this.syncActiveSelectFieldValue();
1102
+ try {
1103
+ validateUserInputValues(session.form, session.values);
1104
+ const result = {
1105
+ formId: session.form.formId,
1106
+ submittedAt: new Date().toISOString(),
1107
+ values: session.values,
1108
+ };
1109
+ this.activeFormSession = null;
1110
+ this.focusPane("flows");
1111
+ session.resolve(result);
1112
+ this.emitChange();
1113
+ }
1114
+ catch (error) {
1115
+ this.appendLog(error.message);
1116
+ }
1117
+ }
1118
+ cancelActiveForm() {
1119
+ const session = this.activeFormSession;
1120
+ if (!session) {
1121
+ return;
1122
+ }
1123
+ this.activeFormSession = null;
1124
+ this.focusPane("flows");
1125
+ session.reject(new TaskRunnerError(`User cancelled form '${session.form.formId}'.`));
1126
+ this.emitChange();
1127
+ }
1128
+ handleActiveFormKey(ch, key) {
1129
+ const field = this.currentFormField();
1130
+ if (!field) {
1131
+ return;
1132
+ }
1133
+ if (key.ctrl && key.name === "s") {
1134
+ this.submitActiveForm();
1135
+ return;
1136
+ }
1137
+ if (key.name === "escape") {
1138
+ this.cancelActiveForm();
1139
+ return;
1140
+ }
1141
+ if (isReverseTabKey(key)) {
1142
+ this.moveActiveFormField(-1);
1143
+ return;
1144
+ }
1145
+ if (key.name === "tab") {
1146
+ this.moveActiveFormField(1);
1147
+ return;
1148
+ }
1149
+ if (field.type === "text") {
1150
+ this.handleTextFormKey(field, ch, key);
1151
+ return;
1152
+ }
1153
+ if (field.type === "boolean") {
1154
+ if (key.name === "space" || key.name === "left" || key.name === "right") {
1155
+ this.toggleActiveFormValue();
1156
+ return;
1157
+ }
1158
+ if (key.name === "enter") {
1159
+ this.confirmActiveFormField();
1160
+ }
1161
+ return;
1162
+ }
1163
+ this.handleSelectFormKey(key);
1164
+ }
1165
+ handleTextFormKey(field, ch, key) {
1166
+ const session = this.activeFormSession;
1167
+ if (!session) {
1168
+ return;
1169
+ }
1170
+ const current = String(session.values[field.id] ?? "");
1171
+ if (!field.multiline) {
1172
+ if (key.name === "enter") {
1173
+ this.confirmActiveFormField();
1174
+ return;
1175
+ }
1176
+ if (key.name === "left") {
1177
+ session.currentTextCursorIndex = Math.max(0, session.currentTextCursorIndex - 1);
1178
+ this.emitChange();
1179
+ return;
1180
+ }
1181
+ if (key.name === "right") {
1182
+ session.currentTextCursorIndex = Math.min(current.length, session.currentTextCursorIndex + 1);
1183
+ this.emitChange();
1184
+ return;
1185
+ }
1186
+ if (key.name === "home") {
1187
+ session.currentTextCursorIndex = 0;
1188
+ this.emitChange();
1189
+ return;
1190
+ }
1191
+ if (key.name === "end") {
1192
+ session.currentTextCursorIndex = current.length;
1193
+ this.emitChange();
1194
+ return;
1195
+ }
1196
+ if (key.name === "backspace" && session.currentTextCursorIndex > 0) {
1197
+ session.values[field.id] = `${current.slice(0, session.currentTextCursorIndex - 1)}${current.slice(session.currentTextCursorIndex)}`;
1198
+ session.currentTextCursorIndex -= 1;
1199
+ this.emitChange();
1200
+ return;
1201
+ }
1202
+ if (key.name === "delete" && session.currentTextCursorIndex < current.length) {
1203
+ session.values[field.id] = `${current.slice(0, session.currentTextCursorIndex)}${current.slice(session.currentTextCursorIndex + 1)}`;
1204
+ this.emitChange();
1205
+ return;
1206
+ }
1207
+ if (isPrintableCharacter(ch, key)) {
1208
+ session.values[field.id] = `${current.slice(0, session.currentTextCursorIndex)}${ch}${current.slice(session.currentTextCursorIndex)}`;
1209
+ session.currentTextCursorIndex += ch.length;
1210
+ this.emitChange();
1211
+ }
1212
+ return;
1213
+ }
1214
+ if (key.name === "enter") {
1215
+ session.values[field.id] = `${current.slice(0, session.currentTextCursorIndex)}\n${current.slice(session.currentTextCursorIndex)}`;
1216
+ session.currentTextCursorIndex += 1;
1217
+ this.emitChange();
1218
+ return;
1219
+ }
1220
+ if (key.name === "left") {
1221
+ session.currentTextCursorIndex = Math.max(0, session.currentTextCursorIndex - 1);
1222
+ this.emitChange();
1223
+ return;
1224
+ }
1225
+ if (key.name === "right") {
1226
+ session.currentTextCursorIndex = Math.min(current.length, session.currentTextCursorIndex + 1);
1227
+ this.emitChange();
1228
+ return;
1229
+ }
1230
+ if (key.name === "home") {
1231
+ const { line } = textIndexToLineColumn(current, session.currentTextCursorIndex);
1232
+ session.currentTextCursorIndex = textLineColumnToIndex(current, line, 0);
1233
+ this.emitChange();
1234
+ return;
1235
+ }
1236
+ if (key.name === "end") {
1237
+ const { line } = textIndexToLineColumn(current, session.currentTextCursorIndex);
1238
+ const lineText = current.split("\n")[line] ?? "";
1239
+ session.currentTextCursorIndex = textLineColumnToIndex(current, line, lineText.length);
1240
+ this.emitChange();
1241
+ return;
1242
+ }
1243
+ if (key.name === "up") {
1244
+ const { line, column } = textIndexToLineColumn(current, session.currentTextCursorIndex);
1245
+ session.currentTextCursorIndex = textLineColumnToIndex(current, Math.max(0, line - 1), column);
1246
+ this.emitChange();
1247
+ return;
1248
+ }
1249
+ if (key.name === "down") {
1250
+ const { line, column } = textIndexToLineColumn(current, session.currentTextCursorIndex);
1251
+ session.currentTextCursorIndex = textLineColumnToIndex(current, line + 1, column);
1252
+ this.emitChange();
1253
+ return;
1254
+ }
1255
+ if (key.name === "backspace" && session.currentTextCursorIndex > 0) {
1256
+ session.values[field.id] = `${current.slice(0, session.currentTextCursorIndex - 1)}${current.slice(session.currentTextCursorIndex)}`;
1257
+ session.currentTextCursorIndex -= 1;
1258
+ this.emitChange();
1259
+ return;
1260
+ }
1261
+ if (key.name === "delete" && session.currentTextCursorIndex < current.length) {
1262
+ session.values[field.id] = `${current.slice(0, session.currentTextCursorIndex)}${current.slice(session.currentTextCursorIndex + 1)}`;
1263
+ this.emitChange();
1264
+ return;
1265
+ }
1266
+ if (isPrintableCharacter(ch, key)) {
1267
+ session.values[field.id] = `${current.slice(0, session.currentTextCursorIndex)}${ch}${current.slice(session.currentTextCursorIndex)}`;
1268
+ session.currentTextCursorIndex += ch.length;
1269
+ this.emitChange();
1270
+ }
1271
+ }
1272
+ handleSelectFormKey(key) {
1273
+ const session = this.activeFormSession;
1274
+ const field = this.currentFormField();
1275
+ if (!session || !field || (field.type !== "single-select" && field.type !== "multi-select")) {
1276
+ return;
1277
+ }
1278
+ if (key.name === "up") {
1279
+ session.currentOptionIndex = clamp(session.currentOptionIndex - 1, 0, Math.max(0, field.options.length - 1));
1280
+ this.emitChange();
1281
+ return;
1282
+ }
1283
+ if (key.name === "down") {
1284
+ session.currentOptionIndex = clamp(session.currentOptionIndex + 1, 0, Math.max(0, field.options.length - 1));
1285
+ this.emitChange();
1286
+ return;
1287
+ }
1288
+ if (key.name === "pageup" || key.name === "pagedown") {
1289
+ const previewLines = (session.form.preview?.trim() ?? "").split("\n").filter((line) => line.length > 0);
1290
+ if (previewLines.length > 10) {
1291
+ session.previewScrollOffset = clamp(session.previewScrollOffset + (key.name === "pageup" ? -10 : 10), 0, Math.max(0, previewLines.length - 10));
1292
+ this.emitChange();
1293
+ }
1294
+ return;
1295
+ }
1296
+ if (key.name === "space") {
1297
+ this.toggleActiveFormValue();
1298
+ return;
1299
+ }
1300
+ if (key.name === "enter") {
1301
+ if (field.type === "single-select" && field.options[session.currentOptionIndex]) {
1302
+ session.values[field.id] = field.options[session.currentOptionIndex]?.value ?? "";
1303
+ }
1304
+ this.confirmActiveFormField();
1305
+ }
1306
+ }
1307
+ scheduleLogFlush() {
1308
+ if (this.logFlushTimer) {
1309
+ return;
1310
+ }
1311
+ this.logFlushTimer = setTimeout(() => {
1312
+ this.logFlushTimer = null;
1313
+ this.flushPendingLogLines();
1314
+ }, LOG_FLUSH_INTERVAL_MS);
1315
+ }
1316
+ flushPendingLogLines() {
1317
+ if (this.pendingLogLines.length === 0) {
1318
+ return;
1319
+ }
1320
+ const appendedLines = this.pendingLogLines.splice(0, this.pendingLogLines.length);
1321
+ this.emitChange({
1322
+ type: "log",
1323
+ appendedLines,
1324
+ });
1325
+ }
1326
+ }