@femtomc/mu-agent 26.2.107 → 26.2.108

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.
@@ -1,13 +1,24 @@
1
- import { normalizeUiDocs, parseUiDoc, } from "@femtomc/mu-core";
1
+ import { normalizeUiDocs, parseUiDoc, resolveUiStatusProfileName, uiStatusProfileWarnings, } from "@femtomc/mu-core";
2
+ import { matchesKey } from "@mariozechner/pi-tui";
2
3
  import { registerMuSubcommand } from "./mu-command-dispatcher.js";
3
4
  const UI_DISPLAY_DOCS_MAX = 16;
4
5
  const UI_WIDGET_COMPONENTS_MAX = 6;
5
6
  const UI_WIDGET_ACTIONS_MAX = 4;
7
+ const UI_PICKER_COMPONENTS_MAX = 8;
8
+ const UI_PICKER_LIST_ITEMS_MAX = 4;
9
+ const UI_PICKER_KEYVALUE_ROWS_MAX = 4;
6
10
  const UI_SESSION_KEY_FALLBACK = "__mu_ui_active_session__";
11
+ const UI_PROMPT_PREVIEW_MAX = 160;
12
+ const UI_INTERACT_SHORTCUT = "ctrl+shift+u";
7
13
  const STATE_BY_SESSION = new Map();
8
14
  const UI_STATE_TTL_MS = 30 * 60 * 1000; // keep session state for 30 minutes after last access
9
15
  function createState() {
10
- return { docsById: new Map() };
16
+ return {
17
+ docsById: new Map(),
18
+ pendingPrompt: null,
19
+ promptedRevisionKeys: new Set(),
20
+ awaitingUiIds: new Set(),
21
+ };
11
22
  }
12
23
  function pruneStaleStates(nowMs) {
13
24
  for (const [key, entry] of STATE_BY_SESSION.entries()) {
@@ -45,6 +56,60 @@ function touchState(key) {
45
56
  function activeDocs(state, maxDocs = UI_DISPLAY_DOCS_MAX) {
46
57
  return normalizeUiDocs([...state.docsById.values()], { maxDocs });
47
58
  }
59
+ function docRevisionKey(doc) {
60
+ return `${doc.ui_id}:${doc.revision.id}:${doc.revision.version}`;
61
+ }
62
+ function retainPromptedRevisionKeysForActiveDocs(state) {
63
+ const activeRevisionKeys = new Set();
64
+ for (const doc of state.docsById.values()) {
65
+ activeRevisionKeys.add(docRevisionKey(doc));
66
+ }
67
+ for (const key of [...state.promptedRevisionKeys]) {
68
+ if (!activeRevisionKeys.has(key)) {
69
+ state.promptedRevisionKeys.delete(key);
70
+ }
71
+ }
72
+ }
73
+ function retainAwaitingUiIdsForActiveDocs(state) {
74
+ for (const uiId of [...state.awaitingUiIds]) {
75
+ const doc = state.docsById.get(uiId);
76
+ if (!doc || runnableActions(doc).length === 0) {
77
+ state.awaitingUiIds.delete(uiId);
78
+ }
79
+ }
80
+ }
81
+ function armAutoPromptForUiDocs(state, changedUiIds) {
82
+ if (changedUiIds.length === 0) {
83
+ return;
84
+ }
85
+ const changedDocs = [];
86
+ for (const uiId of changedUiIds) {
87
+ const doc = state.docsById.get(uiId);
88
+ if (doc) {
89
+ changedDocs.push(doc);
90
+ }
91
+ }
92
+ const candidates = changedDocs.filter((doc) => {
93
+ if (runnableActions(doc).length === 0) {
94
+ return false;
95
+ }
96
+ return !state.promptedRevisionKeys.has(docRevisionKey(doc));
97
+ });
98
+ if (candidates.length === 0) {
99
+ return;
100
+ }
101
+ candidates.sort((left, right) => {
102
+ if (left.updated_at_ms !== right.updated_at_ms) {
103
+ return right.updated_at_ms - left.updated_at_ms;
104
+ }
105
+ return left.ui_id.localeCompare(right.ui_id);
106
+ });
107
+ const doc = candidates[0];
108
+ const actions = runnableActions(doc);
109
+ const actionId = actions.length === 1 ? actions[0].id : undefined;
110
+ state.pendingPrompt = { uiId: doc.ui_id, actionId };
111
+ state.promptedRevisionKeys.add(docRevisionKey(doc));
112
+ }
48
113
  function preferredDocForState(state, candidate) {
49
114
  const existing = state.docsById.get(candidate.ui_id);
50
115
  if (!existing) {
@@ -54,6 +119,9 @@ function preferredDocForState(state, candidate) {
54
119
  const chosen = merged.find((doc) => doc.ui_id === candidate.ui_id);
55
120
  return chosen ?? candidate;
56
121
  }
122
+ function awaitingDocs(state, docs) {
123
+ return docs.filter((doc) => state.awaitingUiIds.has(doc.ui_id) && runnableActions(doc).length > 0);
124
+ }
57
125
  function short(text, max = 64) {
58
126
  const normalized = text.replace(/\s+/g, " ").trim();
59
127
  if (normalized.length <= max) {
@@ -64,27 +132,119 @@ function short(text, max = 64) {
64
132
  }
65
133
  return `${normalized.slice(0, max - 1)}…`;
66
134
  }
67
- function statusSummary(docs) {
135
+ function statusProfileId(doc) {
136
+ return resolveUiStatusProfileName(doc);
137
+ }
138
+ function statusProfileMetadata(doc) {
139
+ const profile = doc.metadata.profile;
140
+ if (!isPlainObject(profile)) {
141
+ return null;
142
+ }
143
+ return profile;
144
+ }
145
+ function statusProfileVariant(doc) {
146
+ const profile = statusProfileMetadata(doc);
147
+ const rawVariant = typeof profile?.variant === "string" ? profile.variant.trim().toLowerCase() : "";
148
+ return rawVariant.length > 0 ? rawVariant : "status";
149
+ }
150
+ function isStatusProfileStatusVariant(doc) {
151
+ return statusProfileId(doc) !== null && statusProfileVariant(doc) === "status";
152
+ }
153
+ function statusProfileSnapshot(doc, format) {
154
+ if (!isStatusProfileStatusVariant(doc)) {
155
+ return null;
156
+ }
157
+ const profile = statusProfileMetadata(doc);
158
+ if (!profile) {
159
+ return null;
160
+ }
161
+ const snapshot = profile.snapshot;
162
+ if (!isPlainObject(snapshot)) {
163
+ return null;
164
+ }
165
+ const raw = snapshot[format];
166
+ if (typeof raw !== "string") {
167
+ return null;
168
+ }
169
+ const normalized = raw.trim();
170
+ return normalized.length > 0 ? normalized : null;
171
+ }
172
+ function compactSnapshotValue(doc) {
173
+ const profileCompact = statusProfileSnapshot(doc, "compact");
174
+ if (profileCompact) {
175
+ return short(profileCompact, 96);
176
+ }
177
+ if (doc.summary) {
178
+ return short(doc.summary, 64);
179
+ }
180
+ return `${short(doc.title, 32)} (${doc.revision.version})`;
181
+ }
182
+ function statusProfileDocCount(docs) {
183
+ return docs.reduce((count, doc) => count + (isStatusProfileStatusVariant(doc) ? 1 : 0), 0);
184
+ }
185
+ function statusProfileWarningCount(docs) {
186
+ return docs.reduce((count, doc) => count + uiStatusProfileWarnings(doc).length, 0);
187
+ }
188
+ function statusSummary(docs, awaitingCount = 0) {
68
189
  const ids = docs.map((doc) => doc.ui_id).join(", ") || "(none)";
69
- return [`UI docs: ${docs.length}`, `ids: ${ids}`].join(" · ");
190
+ const parts = [`UI docs: ${docs.length}`, `ids: ${ids}`];
191
+ const statusProfiles = statusProfileDocCount(docs);
192
+ if (statusProfiles > 0) {
193
+ parts.push(`status_profiles: ${statusProfiles}`);
194
+ }
195
+ const warningCount = statusProfileWarningCount(docs);
196
+ if (warningCount > 0) {
197
+ parts.push(`profile_warnings: ${warningCount}`);
198
+ }
199
+ if (awaitingCount > 0) {
200
+ parts.push(`awaiting: ${awaitingCount}`);
201
+ }
202
+ return parts.join(" · ");
70
203
  }
71
204
  function snapshotText(docs, format) {
72
205
  if (docs.length === 0) {
73
206
  return "(no UI docs)";
74
207
  }
75
208
  if (format === "compact") {
76
- return docs
77
- .map((doc) => `${doc.ui_id}: ${short(doc.title, 32)} (${doc.revision.version})`)
78
- .join(" | ");
209
+ return docs.map((doc) => `${doc.ui_id}: ${compactSnapshotValue(doc)}`).join(" | ");
79
210
  }
80
211
  const lines = [];
81
212
  docs.slice(0, 8).forEach((doc, idx) => {
82
- lines.push(`${idx + 1}. ${doc.title} [${doc.ui_id}]`);
83
- if (doc.summary) {
213
+ const profileId = statusProfileId(doc);
214
+ lines.push(`${idx + 1}. ${doc.title} [${doc.ui_id}]${profileId ? ` profile=${profileId}` : ""}`);
215
+ const profileMultiline = statusProfileSnapshot(doc, "multiline");
216
+ if (profileMultiline) {
217
+ const snapshotLines = profileMultiline
218
+ .split(/\r?\n/)
219
+ .map((line) => line.trim())
220
+ .filter((line) => line.length > 0);
221
+ if (snapshotLines.length > 0) {
222
+ for (const line of snapshotLines) {
223
+ lines.push(` ${short(line, 120)}`);
224
+ }
225
+ }
226
+ else {
227
+ lines.push(` snapshot: ${compactSnapshotValue(doc)}`);
228
+ }
229
+ }
230
+ else if (isStatusProfileStatusVariant(doc)) {
231
+ lines.push(` snapshot: ${compactSnapshotValue(doc)}`);
232
+ }
233
+ else if (doc.summary) {
84
234
  lines.push(` summary: ${short(doc.summary, 120)}`);
85
235
  }
236
+ if (isStatusProfileStatusVariant(doc)) {
237
+ if (doc.actions.length > 0) {
238
+ lines.push(` actions omitted for status profile (${doc.actions.length})`);
239
+ }
240
+ return;
241
+ }
86
242
  if (doc.actions.length > 0) {
87
- lines.push(` actions: ${doc.actions.map((action) => action.label).join(", ")}`);
243
+ const labels = [...doc.actions]
244
+ .sort((left, right) => left.id.localeCompare(right.id))
245
+ .map((action) => action.label)
246
+ .join(", ");
247
+ lines.push(` actions: ${labels}`);
88
248
  }
89
249
  });
90
250
  if (docs.length > 8) {
@@ -116,22 +276,402 @@ function parseDocListInput(value) {
116
276
  }
117
277
  return { ok: true, docs: normalizeUiDocs(docs, { maxDocs: UI_DISPLAY_DOCS_MAX }) };
118
278
  }
279
+ function statusProfileWarningsExtraForDoc(doc) {
280
+ const warnings = uiStatusProfileWarnings(doc);
281
+ if (warnings.length === 0) {
282
+ return {};
283
+ }
284
+ return { profile_warnings: warnings };
285
+ }
286
+ function statusProfileWarningsExtraForDocs(docs) {
287
+ const byUiId = {};
288
+ for (const doc of docs) {
289
+ const warnings = uiStatusProfileWarnings(doc);
290
+ if (warnings.length > 0) {
291
+ byUiId[doc.ui_id] = warnings;
292
+ }
293
+ }
294
+ return Object.keys(byUiId).length > 0 ? { profile_warnings: byUiId } : {};
295
+ }
119
296
  function parseSnapshotFormat(raw) {
120
297
  const normalized = (raw ?? "compact").trim().toLowerCase();
121
298
  return normalized === "multiline" ? "multiline" : "compact";
122
299
  }
300
+ function isPlainObject(value) {
301
+ return typeof value === "object" && value !== null && !Array.isArray(value);
302
+ }
303
+ function actionCommandText(action) {
304
+ const raw = typeof action.metadata.command_text === "string" ? action.metadata.command_text.trim() : "";
305
+ return raw.length > 0 ? raw : null;
306
+ }
307
+ function extractTemplateKeys(text) {
308
+ const keys = [];
309
+ const seen = new Set();
310
+ const re = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
311
+ let match;
312
+ while ((match = re.exec(text)) !== null) {
313
+ const key = (match[1] ?? "").trim();
314
+ if (!key || seen.has(key)) {
315
+ continue;
316
+ }
317
+ seen.add(key);
318
+ keys.push(key);
319
+ }
320
+ return keys;
321
+ }
322
+ function escapeRegExp(value) {
323
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
324
+ }
325
+ function replaceTemplateValues(template, values) {
326
+ let out = template;
327
+ for (const [key, value] of Object.entries(values)) {
328
+ const keyRe = new RegExp(`\\{\\{\\s*${escapeRegExp(key)}\\s*\\}\\}`, "g");
329
+ out = out.replace(keyRe, value);
330
+ }
331
+ return out;
332
+ }
333
+ function primitiveTemplateDefault(value) {
334
+ switch (typeof value) {
335
+ case "string":
336
+ return value;
337
+ case "number":
338
+ case "boolean":
339
+ return String(value);
340
+ default:
341
+ return value === null ? "" : null;
342
+ }
343
+ }
344
+ function valueAtTemplatePath(root, key) {
345
+ if (!key) {
346
+ return undefined;
347
+ }
348
+ const segments = key.split(".").filter((segment) => segment.length > 0);
349
+ if (segments.length === 0) {
350
+ return undefined;
351
+ }
352
+ let current = root;
353
+ for (const segment of segments) {
354
+ if (Array.isArray(current)) {
355
+ const index = Number.parseInt(segment, 10);
356
+ if (!Number.isInteger(index) || index < 0 || index >= current.length) {
357
+ return undefined;
358
+ }
359
+ current = current[index];
360
+ continue;
361
+ }
362
+ if (!isPlainObject(current)) {
363
+ return undefined;
364
+ }
365
+ current = current[segment];
366
+ }
367
+ return current;
368
+ }
369
+ function templateDefaultsForAction(action, keys) {
370
+ if (!isPlainObject(action.payload) || keys.length === 0) {
371
+ return {};
372
+ }
373
+ const payload = action.payload;
374
+ const out = {};
375
+ for (const key of keys) {
376
+ const fallback = primitiveTemplateDefault(valueAtTemplatePath(payload, key));
377
+ if (fallback !== null) {
378
+ out[key] = fallback;
379
+ }
380
+ }
381
+ return out;
382
+ }
383
+ async function collectTemplateValues(opts) {
384
+ if (opts.templateKeys.length === 0) {
385
+ return {};
386
+ }
387
+ const defaults = templateDefaultsForAction(opts.action, opts.templateKeys);
388
+ const out = { ...defaults };
389
+ for (const key of opts.templateKeys) {
390
+ const defaultValue = defaults[key];
391
+ if (typeof defaultValue === "string" && defaultValue.trim().length > 0) {
392
+ continue;
393
+ }
394
+ const placeholder = typeof defaultValue === "string" ? defaultValue : `value for ${key}`;
395
+ const entered = await opts.ctx.ui.input(`UI field: ${key}`, placeholder);
396
+ if (entered === undefined) {
397
+ return null;
398
+ }
399
+ out[key] = entered;
400
+ }
401
+ return out;
402
+ }
403
+ function composePromptFromAction(opts) {
404
+ const rendered = replaceTemplateValues(opts.commandText, opts.templateValues).trim();
405
+ const unresolvedKeys = extractTemplateKeys(rendered);
406
+ if (unresolvedKeys.length === 0) {
407
+ return rendered;
408
+ }
409
+ const unresolvedLines = unresolvedKeys.map((key) => `- ${key}: (missing)`);
410
+ return [rendered, "", "Missing template values:", ...unresolvedLines].join("\n");
411
+ }
412
+ function runnableActions(doc) {
413
+ if (isStatusProfileStatusVariant(doc)) {
414
+ return [];
415
+ }
416
+ return doc.actions.filter((action) => actionCommandText(action) !== null);
417
+ }
418
+ function boundedIndex(index, length) {
419
+ if (length <= 0) {
420
+ return 0;
421
+ }
422
+ if (index < 0) {
423
+ return 0;
424
+ }
425
+ if (index >= length) {
426
+ return length - 1;
427
+ }
428
+ return index;
429
+ }
430
+ function pickerComponentLines(component) {
431
+ switch (component.kind) {
432
+ case "text":
433
+ return [`text · ${component.text}`];
434
+ case "list": {
435
+ const lines = [`list${component.title ? ` · ${component.title}` : ""}`];
436
+ const visible = component.items.slice(0, UI_PICKER_LIST_ITEMS_MAX);
437
+ for (const item of visible) {
438
+ const detail = item.detail ? ` — ${item.detail}` : "";
439
+ lines.push(`• ${item.label}${detail}`);
440
+ }
441
+ if (component.items.length > visible.length) {
442
+ lines.push(`... (+${component.items.length - visible.length} more items)`);
443
+ }
444
+ return lines;
445
+ }
446
+ case "key_value": {
447
+ const lines = [`key_value${component.title ? ` · ${component.title}` : ""}`];
448
+ const visible = component.rows.slice(0, UI_PICKER_KEYVALUE_ROWS_MAX);
449
+ for (const row of visible) {
450
+ lines.push(`${row.key}: ${row.value}`);
451
+ }
452
+ if (component.rows.length > visible.length) {
453
+ lines.push(`... (+${component.rows.length - visible.length} more rows)`);
454
+ }
455
+ return lines;
456
+ }
457
+ case "divider":
458
+ return ["divider"];
459
+ default:
460
+ return ["component"];
461
+ }
462
+ }
463
+ class UiActionPickerComponent {
464
+ #entries;
465
+ #theme;
466
+ #done;
467
+ #mode = "doc";
468
+ #docIndex = 0;
469
+ #actionIndex = 0;
470
+ constructor(opts) {
471
+ this.#entries = opts.entries;
472
+ this.#theme = opts.theme;
473
+ this.#done = opts.done;
474
+ if (opts.initialUiId && opts.initialUiId.trim().length > 0) {
475
+ const initialDocIndex = this.#entries.findIndex((entry) => entry.doc.ui_id === opts.initialUiId);
476
+ if (initialDocIndex >= 0) {
477
+ this.#docIndex = initialDocIndex;
478
+ }
479
+ }
480
+ const actions = this.#currentActions();
481
+ if (opts.initialActionId && opts.initialActionId.trim().length > 0) {
482
+ const initialActionIndex = actions.findIndex((action) => action.id === opts.initialActionId);
483
+ if (initialActionIndex >= 0) {
484
+ this.#actionIndex = initialActionIndex;
485
+ this.#mode = "action";
486
+ }
487
+ }
488
+ }
489
+ #currentEntry() {
490
+ return this.#entries[this.#docIndex];
491
+ }
492
+ #currentActions() {
493
+ return this.#currentEntry().actions;
494
+ }
495
+ #currentAction() {
496
+ const actions = this.#currentActions();
497
+ return actions.length > 0 ? actions[boundedIndex(this.#actionIndex, actions.length)] : null;
498
+ }
499
+ #moveDoc(delta) {
500
+ this.#docIndex = boundedIndex(this.#docIndex + delta, this.#entries.length);
501
+ this.#actionIndex = boundedIndex(this.#actionIndex, this.#currentActions().length);
502
+ }
503
+ #moveAction(delta) {
504
+ const actions = this.#currentActions();
505
+ if (actions.length === 0) {
506
+ this.#actionIndex = 0;
507
+ return;
508
+ }
509
+ this.#actionIndex = boundedIndex(this.#actionIndex + delta, actions.length);
510
+ }
511
+ #submit() {
512
+ const action = this.#currentAction();
513
+ if (!action) {
514
+ this.#done(null);
515
+ return;
516
+ }
517
+ this.#done({
518
+ doc: this.#currentEntry().doc,
519
+ action,
520
+ });
521
+ }
522
+ handleInput(data) {
523
+ if (matchesKey(data, "escape")) {
524
+ this.#done(null);
525
+ return;
526
+ }
527
+ if (matchesKey(data, "tab") || matchesKey(data, "right")) {
528
+ this.#mode = "action";
529
+ this.#actionIndex = boundedIndex(this.#actionIndex, this.#currentActions().length);
530
+ return;
531
+ }
532
+ if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
533
+ this.#mode = "doc";
534
+ return;
535
+ }
536
+ if (matchesKey(data, "up")) {
537
+ if (this.#mode === "doc") {
538
+ this.#moveDoc(-1);
539
+ }
540
+ else {
541
+ this.#moveAction(-1);
542
+ }
543
+ return;
544
+ }
545
+ if (matchesKey(data, "down")) {
546
+ if (this.#mode === "doc") {
547
+ this.#moveDoc(1);
548
+ }
549
+ else {
550
+ this.#moveAction(1);
551
+ }
552
+ return;
553
+ }
554
+ if (matchesKey(data, "enter") || matchesKey(data, "return")) {
555
+ if (this.#mode === "doc") {
556
+ const actions = this.#currentActions();
557
+ if (actions.length <= 1) {
558
+ this.#submit();
559
+ }
560
+ else {
561
+ this.#mode = "action";
562
+ }
563
+ return;
564
+ }
565
+ this.#submit();
566
+ }
567
+ }
568
+ invalidate() {
569
+ // No cached state.
570
+ }
571
+ render(width) {
572
+ const maxWidth = Math.max(24, width - 2);
573
+ const lines = [];
574
+ lines.push(this.#theme.fg("accent", short("Programmable UI", maxWidth)));
575
+ lines.push(this.#theme.fg("dim", short("↑/↓ move · tab switch · enter select/submit · esc cancel", maxWidth)));
576
+ lines.push("");
577
+ lines.push(this.#theme.fg(this.#mode === "doc" ? "accent" : "dim", short(`Documents (${this.#entries.length})`, maxWidth)));
578
+ for (let idx = 0; idx < this.#entries.length; idx += 1) {
579
+ const entry = this.#entries[idx];
580
+ const active = idx === this.#docIndex;
581
+ const marker = active ? (this.#mode === "doc" ? "▶" : "▸") : " ";
582
+ const label = `${marker} ${entry.doc.ui_id} · ${entry.doc.title}`;
583
+ lines.push(this.#theme.fg(active ? "accent" : "muted", short(label, maxWidth)));
584
+ }
585
+ const selectedDoc = this.#currentEntry().doc;
586
+ if (selectedDoc.summary) {
587
+ lines.push("");
588
+ lines.push(this.#theme.fg("dim", short(`Summary: ${selectedDoc.summary}`, maxWidth)));
589
+ }
590
+ lines.push("");
591
+ lines.push(this.#theme.fg("dim", short(`Components (${selectedDoc.components.length})`, maxWidth)));
592
+ const visibleComponents = selectedDoc.components.slice(0, UI_PICKER_COMPONENTS_MAX);
593
+ for (const component of visibleComponents) {
594
+ const componentLines = pickerComponentLines(component);
595
+ for (let idx = 0; idx < componentLines.length; idx += 1) {
596
+ const line = componentLines[idx];
597
+ const prefix = idx === 0 ? " " : " ";
598
+ lines.push(this.#theme.fg("text", short(`${prefix}${line}`, maxWidth)));
599
+ }
600
+ }
601
+ if (selectedDoc.components.length > visibleComponents.length) {
602
+ lines.push(this.#theme.fg("muted", short(` ... (+${selectedDoc.components.length - visibleComponents.length} more components)`, maxWidth)));
603
+ }
604
+ const actions = this.#currentActions();
605
+ lines.push("");
606
+ lines.push(this.#theme.fg(this.#mode === "action" ? "accent" : "dim", short(`Actions (${actions.length})`, maxWidth)));
607
+ for (let idx = 0; idx < actions.length; idx += 1) {
608
+ const action = actions[idx];
609
+ const active = idx === this.#actionIndex;
610
+ const marker = active ? (this.#mode === "action" ? "▶" : "▸") : " ";
611
+ const label = `${marker} ${action.id} · ${action.label}`;
612
+ lines.push(this.#theme.fg(active ? "accent" : "text", short(label, maxWidth)));
613
+ }
614
+ const action = this.#currentAction();
615
+ if (action?.description) {
616
+ lines.push("");
617
+ lines.push(this.#theme.fg("dim", short(`Ask: ${action.description}`, maxWidth)));
618
+ }
619
+ if (action?.component_id) {
620
+ lines.push(this.#theme.fg("dim", short(`Targets component: ${action.component_id}`, maxWidth)));
621
+ }
622
+ const commandText = action ? actionCommandText(action) : null;
623
+ if (commandText) {
624
+ lines.push("");
625
+ lines.push(this.#theme.fg("dim", short(`Prompt template: ${commandText}`, maxWidth)));
626
+ }
627
+ return lines;
628
+ }
629
+ }
630
+ async function pickUiActionInteractively(opts) {
631
+ const selected = await opts.ctx.ui.custom((_tui, theme, _keybindings, done) => new UiActionPickerComponent({
632
+ entries: opts.entries,
633
+ theme: theme,
634
+ done,
635
+ initialUiId: opts.uiId,
636
+ initialActionId: opts.actionId,
637
+ }), {
638
+ overlay: true,
639
+ overlayOptions: {
640
+ anchor: "center",
641
+ width: "78%",
642
+ maxHeight: "70%",
643
+ margin: 1,
644
+ },
645
+ });
646
+ return selected ?? null;
647
+ }
123
648
  function applyUiAction(params, state) {
649
+ retainPromptedRevisionKeysForActiveDocs(state);
650
+ retainAwaitingUiIdsForActiveDocs(state);
124
651
  const docs = activeDocs(state);
652
+ const awaitingCount = awaitingDocs(state, docs).length;
125
653
  switch (params.action) {
126
654
  case "status":
127
- return { ok: true, action: "status", message: statusSummary(docs) };
655
+ return {
656
+ ok: true,
657
+ action: "status",
658
+ message: statusSummary(docs, awaitingCount),
659
+ extra: {
660
+ status_profile_count: statusProfileDocCount(docs),
661
+ ...statusProfileWarningsExtraForDocs(docs),
662
+ },
663
+ };
128
664
  case "snapshot": {
129
665
  const format = parseSnapshotFormat(params.snapshot_format);
130
666
  return {
131
667
  ok: true,
132
668
  action: "snapshot",
133
669
  message: snapshotText(docs, format),
134
- extra: { snapshot_format: format },
670
+ extra: {
671
+ snapshot_format: format,
672
+ status_profile_count: statusProfileDocCount(docs),
673
+ ...statusProfileWarningsExtraForDocs(docs),
674
+ },
135
675
  };
136
676
  }
137
677
  case "set":
@@ -142,11 +682,20 @@ function applyUiAction(params, state) {
142
682
  }
143
683
  const preferred = preferredDocForState(state, parsed.doc);
144
684
  state.docsById.set(parsed.doc.ui_id, preferred);
685
+ if (runnableActions(preferred).length > 0) {
686
+ state.awaitingUiIds.add(parsed.doc.ui_id);
687
+ }
688
+ else {
689
+ state.awaitingUiIds.delete(parsed.doc.ui_id);
690
+ }
691
+ retainPromptedRevisionKeysForActiveDocs(state);
692
+ retainAwaitingUiIdsForActiveDocs(state);
145
693
  return {
146
694
  ok: true,
147
695
  action: params.action,
148
696
  message: `UI doc set: ${parsed.doc.ui_id}`,
149
- extra: { ui_id: parsed.doc.ui_id },
697
+ extra: { ui_id: parsed.doc.ui_id, ...statusProfileWarningsExtraForDoc(preferred) },
698
+ changedUiIds: [parsed.doc.ui_id],
150
699
  };
151
700
  }
152
701
  case "replace": {
@@ -155,14 +704,24 @@ function applyUiAction(params, state) {
155
704
  return { ok: false, action: "replace", message: parsed.error };
156
705
  }
157
706
  state.docsById.clear();
707
+ state.awaitingUiIds.clear();
158
708
  for (const doc of parsed.docs) {
159
709
  state.docsById.set(doc.ui_id, doc);
710
+ if (runnableActions(doc).length > 0) {
711
+ state.awaitingUiIds.add(doc.ui_id);
712
+ }
160
713
  }
714
+ retainPromptedRevisionKeysForActiveDocs(state);
715
+ retainAwaitingUiIdsForActiveDocs(state);
161
716
  return {
162
717
  ok: true,
163
718
  action: "replace",
164
719
  message: `UI docs replaced (${parsed.docs.length}).`,
165
- extra: { doc_count: parsed.docs.length },
720
+ extra: {
721
+ doc_count: parsed.docs.length,
722
+ ...statusProfileWarningsExtraForDocs(parsed.docs),
723
+ },
724
+ changedUiIds: parsed.docs.map((doc) => doc.ui_id),
166
725
  };
167
726
  }
168
727
  case "remove": {
@@ -173,10 +732,19 @@ function applyUiAction(params, state) {
173
732
  if (!state.docsById.delete(uiId)) {
174
733
  return { ok: false, action: "remove", message: `UI doc not found: ${uiId}` };
175
734
  }
735
+ if (state.pendingPrompt?.uiId === uiId) {
736
+ state.pendingPrompt = null;
737
+ }
738
+ state.awaitingUiIds.delete(uiId);
739
+ retainPromptedRevisionKeysForActiveDocs(state);
740
+ retainAwaitingUiIdsForActiveDocs(state);
176
741
  return { ok: true, action: "remove", message: `UI doc removed: ${uiId}` };
177
742
  }
178
743
  case "clear":
179
744
  state.docsById.clear();
745
+ state.pendingPrompt = null;
746
+ state.promptedRevisionKeys.clear();
747
+ state.awaitingUiIds.clear();
180
748
  return { ok: true, action: "clear", message: "UI docs cleared." };
181
749
  }
182
750
  }
@@ -195,12 +763,19 @@ function buildToolResult(opts) {
195
763
  };
196
764
  return result;
197
765
  }
198
- function renderDocPreview(theme, doc) {
766
+ function renderDocPreview(theme, doc, opts) {
199
767
  const lines = [];
200
- lines.push(`${theme.fg("accent", doc.title)} ${theme.fg("muted", `[${doc.ui_id}]`)}`);
768
+ const headerParts = [theme.fg("accent", doc.title), theme.fg("muted", `[${doc.ui_id}]`)];
769
+ if (opts.awaitingResponse) {
770
+ headerParts.push(theme.fg("accent", "awaiting-response"));
771
+ }
772
+ lines.push(headerParts.join(" "));
201
773
  if (doc.summary) {
202
774
  lines.push(theme.fg("muted", short(doc.summary, 80)));
203
775
  }
776
+ if (opts.awaitingCount > 0) {
777
+ lines.push(theme.fg("accent", `Awaiting user response for ${opts.awaitingCount} UI doc(s).`));
778
+ }
204
779
  const components = doc.components.slice(0, UI_WIDGET_COMPONENTS_MAX);
205
780
  if (components.length > 0) {
206
781
  lines.push(theme.fg("dim", "Components:"));
@@ -208,17 +783,21 @@ function renderDocPreview(theme, doc) {
208
783
  lines.push(` ${componentPreview(component)}`);
209
784
  }
210
785
  }
211
- if (doc.actions.length > 0) {
786
+ const interactiveActions = runnableActions(doc);
787
+ if (interactiveActions.length > 0) {
212
788
  lines.push(theme.fg("muted", "Actions:"));
213
- const visibleActions = doc.actions.slice(0, UI_WIDGET_ACTIONS_MAX);
789
+ const visibleActions = interactiveActions.slice(0, UI_WIDGET_ACTIONS_MAX);
214
790
  for (let idx = 0; idx < visibleActions.length; idx += 1) {
215
791
  const action = visibleActions[idx];
216
792
  lines.push(` ${idx + 1}. ${action.label}`);
217
793
  }
218
- if (doc.actions.length > visibleActions.length) {
219
- lines.push(` ... (+${doc.actions.length - visibleActions.length} more actions)`);
794
+ if (interactiveActions.length > visibleActions.length) {
795
+ lines.push(` ... (+${interactiveActions.length - visibleActions.length} more actions)`);
796
+ }
797
+ if (opts.awaitingResponse) {
798
+ lines.push(theme.fg("accent", "Awaiting your response. Select an action to continue."));
220
799
  }
221
- lines.push(theme.fg("dim", "Actions are handled through channel-native callbacks."));
800
+ lines.push(theme.fg("dim", `Press ${UI_INTERACT_SHORTCUT} to compose and submit a prompt from actions.`));
222
801
  }
223
802
  else {
224
803
  lines.push(theme.fg("dim", "No interactive actions."));
@@ -245,27 +824,130 @@ function refreshUi(ctx) {
245
824
  if (!ctx.hasUI) {
246
825
  return;
247
826
  }
827
+ retainAwaitingUiIdsForActiveDocs(state);
248
828
  const docs = activeDocs(state);
249
829
  if (docs.length === 0) {
250
830
  ctx.ui.setStatus("mu-ui", undefined);
251
831
  ctx.ui.setWidget("mu-ui", undefined);
252
832
  return;
253
833
  }
834
+ const awaiting = awaitingDocs(state, docs);
254
835
  const labels = docs.map((doc) => doc.ui_id).join(", ");
255
836
  ctx.ui.setStatus("mu-ui", [
256
837
  ctx.ui.theme.fg("dim", "ui"),
257
838
  ctx.ui.theme.fg("muted", "·"),
258
839
  ctx.ui.theme.fg("accent", `${docs.length}`),
259
840
  ctx.ui.theme.fg("muted", "·"),
841
+ awaiting.length > 0
842
+ ? ctx.ui.theme.fg("accent", `awaiting ${awaiting.length}`)
843
+ : ctx.ui.theme.fg("dim", "ready"),
844
+ ctx.ui.theme.fg("muted", "·"),
260
845
  ctx.ui.theme.fg("text", labels),
261
846
  ].join(" "));
262
- ctx.ui.setWidget("mu-ui", renderDocPreview(ctx.ui.theme, docs[0]), { placement: "belowEditor" });
847
+ const primaryDoc = awaiting[0] ?? docs[0];
848
+ ctx.ui.setWidget("mu-ui", renderDocPreview(ctx.ui.theme, primaryDoc, {
849
+ awaitingResponse: state.awaitingUiIds.has(primaryDoc.ui_id),
850
+ awaitingCount: awaiting.length,
851
+ }), { placement: "belowEditor" });
263
852
  }
264
853
  export function uiExtension(pi) {
854
+ const commandUsage = "/mu ui status|snapshot [compact|multiline]|interact [ui_id [action_id]]";
855
+ const usage = `Usage: ${commandUsage}`;
856
+ const runUiActionFromDoc = async (ctx, state, uiId, actionId) => {
857
+ const docs = activeDocs(state, UI_DISPLAY_DOCS_MAX);
858
+ if (docs.length === 0) {
859
+ ctx.ui.notify("No UI docs are currently available.", "info");
860
+ return;
861
+ }
862
+ const entries = docs
863
+ .map((doc) => ({ doc, actions: runnableActions(doc) }))
864
+ .filter((entry) => entry.actions.length > 0);
865
+ if (entries.length === 0) {
866
+ ctx.ui.notify("No runnable UI actions are currently available.", "error");
867
+ return;
868
+ }
869
+ let selectedDoc = null;
870
+ let selectedAction = null;
871
+ const normalizedUiId = uiId?.trim() ?? "";
872
+ const normalizedActionId = actionId?.trim() ?? "";
873
+ if (normalizedUiId.length > 0 && normalizedActionId.length > 0) {
874
+ const entry = entries.find((candidate) => candidate.doc.ui_id === normalizedUiId) ?? null;
875
+ if (!entry) {
876
+ ctx.ui.notify(`UI doc not found: ${normalizedUiId}`, "error");
877
+ return;
878
+ }
879
+ const action = entry.actions.find((candidate) => candidate.id === normalizedActionId) ?? null;
880
+ if (!action) {
881
+ ctx.ui.notify(`Action not found: ${normalizedActionId} in ${normalizedUiId}`, "error");
882
+ return;
883
+ }
884
+ selectedDoc = entry.doc;
885
+ selectedAction = action;
886
+ }
887
+ else {
888
+ const picked = await pickUiActionInteractively({
889
+ ctx,
890
+ entries,
891
+ uiId: normalizedUiId.length > 0 ? normalizedUiId : undefined,
892
+ actionId: normalizedActionId.length > 0 ? normalizedActionId : undefined,
893
+ });
894
+ if (!picked) {
895
+ ctx.ui.notify("UI interaction cancelled.", "info");
896
+ return;
897
+ }
898
+ selectedDoc = picked.doc;
899
+ selectedAction = picked.action;
900
+ }
901
+ if (!selectedDoc || !selectedAction) {
902
+ ctx.ui.notify("No UI action was selected.", "error");
903
+ return;
904
+ }
905
+ const commandText = actionCommandText(selectedAction);
906
+ if (!commandText) {
907
+ ctx.ui.notify(`Action ${selectedAction.id} is missing metadata.command_text.`, "error");
908
+ return;
909
+ }
910
+ const templateKeys = extractTemplateKeys(commandText);
911
+ const templateValues = await collectTemplateValues({
912
+ ctx,
913
+ action: selectedAction,
914
+ templateKeys,
915
+ });
916
+ if (!templateValues) {
917
+ ctx.ui.notify("UI interaction cancelled.", "info");
918
+ return;
919
+ }
920
+ const composed = composePromptFromAction({
921
+ commandText,
922
+ templateValues,
923
+ });
924
+ const edited = await ctx.ui.editor(`Review prompt (${selectedDoc.ui_id}/${selectedAction.id})`, composed);
925
+ if (edited === undefined) {
926
+ ctx.ui.notify("UI submit cancelled.", "info");
927
+ return;
928
+ }
929
+ const finalPrompt = edited.trim();
930
+ if (finalPrompt.length === 0) {
931
+ ctx.ui.notify("Cannot submit an empty prompt.", "error");
932
+ return;
933
+ }
934
+ const confirmed = await ctx.ui.confirm("Submit UI prompt", short(finalPrompt, UI_PROMPT_PREVIEW_MAX));
935
+ if (!confirmed) {
936
+ ctx.ui.notify("UI submit cancelled.", "info");
937
+ return;
938
+ }
939
+ pi.sendUserMessage(finalPrompt);
940
+ state.awaitingUiIds.delete(selectedDoc.ui_id);
941
+ if (state.pendingPrompt?.uiId === selectedDoc.ui_id) {
942
+ state.pendingPrompt = null;
943
+ }
944
+ retainAwaitingUiIdsForActiveDocs(state);
945
+ ctx.ui.notify(`Submitted prompt from ${selectedDoc.ui_id}/${selectedAction.id}.`, "info");
946
+ };
265
947
  registerMuSubcommand(pi, {
266
948
  subcommand: "ui",
267
- summary: "Inspect interactive UI docs",
268
- usage: "/mu ui status|snapshot [compact|multiline]",
949
+ summary: "Inspect and manage interactive UI docs",
950
+ usage: commandUsage,
269
951
  handler: async (args, ctx) => {
270
952
  const tokens = args
271
953
  .trim()
@@ -285,7 +967,27 @@ export function uiExtension(pi) {
285
967
  ctx.ui.notify(result.message, result.ok ? "info" : "error");
286
968
  return;
287
969
  }
288
- ctx.ui.notify("Usage: /mu ui status|snapshot [compact|multiline]", "info");
970
+ if (subcommand === "interact") {
971
+ if (tokens.length > 3) {
972
+ ctx.ui.notify(usage, "info");
973
+ return;
974
+ }
975
+ const uiId = tokens[1];
976
+ const actionId = tokens[2];
977
+ await runUiActionFromDoc(ctx, state, uiId, actionId);
978
+ refreshUi(ctx);
979
+ return;
980
+ }
981
+ ctx.ui.notify(usage, "info");
982
+ },
983
+ });
984
+ pi.registerShortcut(UI_INTERACT_SHORTCUT, {
985
+ description: "Interact with programmable UI docs and submit prompt",
986
+ handler: async (ctx) => {
987
+ const key = sessionKey(ctx);
988
+ const state = ensureState(key);
989
+ await runUiActionFromDoc(ctx, state);
990
+ refreshUi(ctx);
289
991
  },
290
992
  });
291
993
  pi.registerTool({
@@ -312,6 +1014,9 @@ export function uiExtension(pi) {
312
1014
  const state = ensureState(key);
313
1015
  const params = paramsRaw;
314
1016
  const result = applyUiAction(params, state);
1017
+ if (ctx.hasUI && result.ok && result.changedUiIds && result.changedUiIds.length > 0) {
1018
+ armAutoPromptForUiDocs(state, result.changedUiIds);
1019
+ }
315
1020
  refreshUi(ctx);
316
1021
  return buildToolResult({ state, ...result });
317
1022
  },
@@ -322,6 +1027,21 @@ export function uiExtension(pi) {
322
1027
  pi.on("session_switch", (_event, ctx) => {
323
1028
  refreshUi(ctx);
324
1029
  });
1030
+ pi.on("agent_end", async (_event, ctx) => {
1031
+ if (!ctx.hasUI) {
1032
+ return;
1033
+ }
1034
+ const key = sessionKey(ctx);
1035
+ const state = ensureState(key);
1036
+ const pending = state.pendingPrompt;
1037
+ if (!pending) {
1038
+ return;
1039
+ }
1040
+ state.pendingPrompt = null;
1041
+ ctx.ui.notify(`Agent requested input via ${pending.uiId}. Submit now or press ${UI_INTERACT_SHORTCUT} later.`, "info");
1042
+ await runUiActionFromDoc(ctx, state, pending.uiId, pending.actionId);
1043
+ refreshUi(ctx);
1044
+ });
325
1045
  pi.on("session_shutdown", (_event, ctx) => {
326
1046
  const key = sessionKey(ctx);
327
1047
  touchState(key);