@femtomc/mu-agent 26.2.107 → 26.2.109

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