@femtomc/mu-agent 26.2.108 → 26.2.110

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.
package/README.md CHANGED
@@ -27,6 +27,7 @@ They are organized as category meta-skills plus subskills:
27
27
 
28
28
  - `core`
29
29
  - `mu`
30
+ - `programmable-ui`
30
31
  - `memory`
31
32
  - `tmux`
32
33
  - `code-mode`
@@ -48,6 +49,7 @@ They are organized as category meta-skills plus subskills:
48
49
 
49
50
  Starter skills are version-synced by CLI bootstrap. Initial bootstrap seeds missing
50
51
  skills; bundled-version changes refresh installed starter skill files.
52
+ Operator-facing workflow detail lives in the skill docs (for example, `core/programmable-ui`).
51
53
 
52
54
  ## Install
53
55
 
@@ -75,7 +77,7 @@ Current stack:
75
77
 
76
78
  - `brandingExtension` — mu compact header/footer branding + default theme
77
79
  - `eventLogExtension` — event tail + watch widget
78
- - `uiExtension` — programmable `UiDoc` surface (`/mu ui ...`, `mu_ui`) with terminal auto-prompt/awaiting behavior and deterministic action fallbacks
80
+ - `uiExtension` — programmable `UiDoc` surface (`/mu ui ...`, `mu_ui`)
79
81
 
80
82
  Default operator UI theme is `mu-gruvbox-dark`.
81
83
 
@@ -86,24 +88,7 @@ Default operator UI theme is `mu-gruvbox-dark`.
86
88
  - `/mu brand on|off|toggle` — enable/disable UI branding
87
89
  - `/mu ui ...` — inspect interactive `UiDoc`s (`status`/`snapshot`)
88
90
  - `/mu help` — dispatcher catalog of registered `/mu` subcommands
89
- - `ctrl+shift+u` — reopen local programmable-UI interaction flow (in-TUI doc/action picker, auto-fill payload-backed template values, prompt unresolved values, submit composed prompt)
90
-
91
- ## Programmable UI documents
92
-
93
- Skills can publish interactive UI state via the `mu_ui` tool. Rendered `UiDoc`s survive session reconnects
94
- (30 minute retention per session ID), respect revision/version bumps, and route action clicks/taps back to
95
- plain command text via `metadata.command_text` (the `/answer` flow is the reference pattern).
96
-
97
- Actions without `metadata.command_text` are treated as non-interactive and rendered as deterministic fallback rows.
98
-
99
- Current runtime behavior is channel-specific:
100
-
101
- - Slack renders rich blocks + interactive action buttons.
102
- - Discord/Telegram/Neovim render text-first docs; interactive actions are tokenized, while status-profile actions deterministically degrade to command-text fallback.
103
- - Terminal operator UI (`mu serve`) renders docs in-widget, auto-prompts when agent publishes new runnable actions, shows `awaiting` UI status/widget state until resolved, and supports manual reopen via `ctrl+shift+u` (in-TUI picker overlay + prompt composition).
104
- - When interactive controls cannot be rendered, adapters append deterministic text fallback.
105
-
106
- See the [Programmable UI substrate guide](../../docs/mu-ui.md) for the full support matrix and workflow.
91
+ - `ctrl+shift+u` — reopen local programmable-UI interaction flow
107
92
 
108
93
  ## Tooling model (CLI-first)
109
94
 
Binary file
@@ -1 +1 @@
1
- {"version":3,"file":"ui.d.ts","sourceRoot":"","sources":["../../src/extensions/ui.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,+BAA+B,CAAC;AA8+BpF,wBAAgB,WAAW,CAAC,EAAE,EAAE,YAAY,QA2N3C;AAED,eAAe,WAAW,CAAC"}
1
+ {"version":3,"file":"ui.d.ts","sourceRoot":"","sources":["../../src/extensions/ui.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,+BAA+B,CAAC;AAs/BpF,wBAAgB,WAAW,CAAC,EAAE,EAAE,YAAY,QA6N3C;AAED,eAAe,WAAW,CAAC"}
@@ -2,22 +2,29 @@ import { normalizeUiDocs, parseUiDoc, resolveUiStatusProfileName, uiStatusProfil
2
2
  import { matchesKey } from "@mariozechner/pi-tui";
3
3
  import { registerMuSubcommand } from "./mu-command-dispatcher.js";
4
4
  const UI_DISPLAY_DOCS_MAX = 16;
5
- const UI_WIDGET_COMPONENTS_MAX = 6;
6
- const UI_WIDGET_ACTIONS_MAX = 4;
7
5
  const UI_PICKER_COMPONENTS_MAX = 8;
8
6
  const UI_PICKER_LIST_ITEMS_MAX = 4;
9
7
  const UI_PICKER_KEYVALUE_ROWS_MAX = 4;
10
8
  const UI_SESSION_KEY_FALLBACK = "__mu_ui_active_session__";
11
9
  const UI_PROMPT_PREVIEW_MAX = 160;
12
10
  const UI_INTERACT_SHORTCUT = "ctrl+shift+u";
11
+ const UI_INTERACT_OVERLAY_OPTIONS = {
12
+ anchor: "top-left",
13
+ row: 0,
14
+ col: 0,
15
+ width: "100%",
16
+ maxHeight: "100%",
17
+ margin: 0,
18
+ };
13
19
  const STATE_BY_SESSION = new Map();
14
20
  const UI_STATE_TTL_MS = 30 * 60 * 1000; // keep session state for 30 minutes after last access
15
21
  function createState() {
16
22
  return {
17
23
  docsById: new Map(),
18
- pendingPrompt: null,
24
+ pendingPrompts: [],
19
25
  promptedRevisionKeys: new Set(),
20
26
  awaitingUiIds: new Set(),
27
+ interactionDepth: 0,
21
28
  };
22
29
  }
23
30
  function pruneStaleStates(nowMs) {
@@ -78,6 +85,38 @@ function retainAwaitingUiIdsForActiveDocs(state) {
78
85
  }
79
86
  }
80
87
  }
88
+ function retainPendingPromptsForActiveDocs(state) {
89
+ state.pendingPrompts = state.pendingPrompts.filter((pending) => {
90
+ const doc = state.docsById.get(pending.uiId);
91
+ if (!doc) {
92
+ return false;
93
+ }
94
+ if (pending.kind === "review") {
95
+ return isStatusProfileStatusVariant(doc);
96
+ }
97
+ const actions = runnableActions(doc);
98
+ if (actions.length === 0) {
99
+ return false;
100
+ }
101
+ if (!pending.actionId) {
102
+ return true;
103
+ }
104
+ return actions.some((action) => action.id === pending.actionId);
105
+ });
106
+ }
107
+ function removePendingPromptsForUiId(state, uiId) {
108
+ state.pendingPrompts = state.pendingPrompts.filter((pending) => pending.uiId !== uiId);
109
+ }
110
+ function enqueuePendingPrompt(state, pending) {
111
+ const duplicate = state.pendingPrompts.some((existing) => {
112
+ return (existing.kind === pending.kind &&
113
+ existing.uiId === pending.uiId &&
114
+ existing.actionId === pending.actionId);
115
+ });
116
+ if (!duplicate) {
117
+ state.pendingPrompts.push(pending);
118
+ }
119
+ }
81
120
  function armAutoPromptForUiDocs(state, changedUiIds) {
82
121
  if (changedUiIds.length === 0) {
83
122
  return;
@@ -89,26 +128,30 @@ function armAutoPromptForUiDocs(state, changedUiIds) {
89
128
  changedDocs.push(doc);
90
129
  }
91
130
  }
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) {
131
+ const unpromptedDocs = changedDocs.filter((doc) => !state.promptedRevisionKeys.has(docRevisionKey(doc)));
132
+ if (unpromptedDocs.length === 0) {
99
133
  return;
100
134
  }
101
- candidates.sort((left, right) => {
135
+ const byMostRecentRevision = (left, right) => {
102
136
  if (left.updated_at_ms !== right.updated_at_ms) {
103
137
  return right.updated_at_ms - left.updated_at_ms;
104
138
  }
105
139
  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));
140
+ };
141
+ const runnableCandidates = unpromptedDocs.filter((doc) => runnableActions(doc).length > 0).sort(byMostRecentRevision);
142
+ if (runnableCandidates.length > 0) {
143
+ const doc = runnableCandidates[0];
144
+ const actions = runnableActions(doc);
145
+ const actionId = actions.length === 1 ? actions[0].id : undefined;
146
+ enqueuePendingPrompt(state, { kind: "action", uiId: doc.ui_id, actionId });
147
+ state.promptedRevisionKeys.add(docRevisionKey(doc));
148
+ }
149
+ const statusCandidates = unpromptedDocs.filter((doc) => isStatusProfileStatusVariant(doc)).sort(byMostRecentRevision);
150
+ if (statusCandidates.length > 0) {
151
+ const doc = statusCandidates[0];
152
+ enqueuePendingPrompt(state, { kind: "review", uiId: doc.ui_id });
153
+ state.promptedRevisionKeys.add(docRevisionKey(doc));
154
+ }
112
155
  }
113
156
  function preferredDocForState(state, candidate) {
114
157
  const existing = state.docsById.get(candidate.ui_id);
@@ -122,6 +165,23 @@ function preferredDocForState(state, candidate) {
122
165
  function awaitingDocs(state, docs) {
123
166
  return docs.filter((doc) => state.awaitingUiIds.has(doc.ui_id) && runnableActions(doc).length > 0);
124
167
  }
168
+ function beginUiInteraction(ctx, state) {
169
+ state.interactionDepth += 1;
170
+ refreshUi(ctx);
171
+ }
172
+ function endUiInteraction(ctx, state) {
173
+ state.interactionDepth = Math.max(0, state.interactionDepth - 1);
174
+ refreshUi(ctx);
175
+ }
176
+ async function withUiInteraction(ctx, state, run) {
177
+ beginUiInteraction(ctx, state);
178
+ try {
179
+ return await run();
180
+ }
181
+ finally {
182
+ endUiInteraction(ctx, state);
183
+ }
184
+ }
125
185
  function short(text, max = 64) {
126
186
  const normalized = text.replace(/\s+/g, " ").trim();
127
187
  if (normalized.length <= max) {
@@ -430,9 +490,11 @@ function boundedIndex(index, length) {
430
490
  function pickerComponentLines(component) {
431
491
  switch (component.kind) {
432
492
  case "text":
433
- return [`text · ${component.text}`];
493
+ return [component.text];
434
494
  case "list": {
435
- const lines = [`list${component.title ? ` · ${component.title}` : ""}`];
495
+ const title = component.title?.trim();
496
+ const prefix = title && title.length > 0 ? `${title} · ` : "";
497
+ const lines = [`${prefix}${component.items.length} item(s)`];
436
498
  const visible = component.items.slice(0, UI_PICKER_LIST_ITEMS_MAX);
437
499
  for (const item of visible) {
438
500
  const detail = item.detail ? ` — ${item.detail}` : "";
@@ -444,7 +506,9 @@ function pickerComponentLines(component) {
444
506
  return lines;
445
507
  }
446
508
  case "key_value": {
447
- const lines = [`key_value${component.title ? ` · ${component.title}` : ""}`];
509
+ const title = component.title?.trim();
510
+ const prefix = title && title.length > 0 ? `${title} · ` : "";
511
+ const lines = [`${prefix}${component.rows.length} row(s)`];
448
512
  const visible = component.rows.slice(0, UI_PICKER_KEYVALUE_ROWS_MAX);
449
513
  for (const row of visible) {
450
514
  lines.push(`${row.key}: ${row.value}`);
@@ -455,7 +519,7 @@ function pickerComponentLines(component) {
455
519
  return lines;
456
520
  }
457
521
  case "divider":
458
- return ["divider"];
522
+ return [""];
459
523
  default:
460
524
  return ["component"];
461
525
  }
@@ -636,18 +700,14 @@ async function pickUiActionInteractively(opts) {
636
700
  initialActionId: opts.actionId,
637
701
  }), {
638
702
  overlay: true,
639
- overlayOptions: {
640
- anchor: "center",
641
- width: "78%",
642
- maxHeight: "70%",
643
- margin: 1,
644
- },
703
+ overlayOptions: UI_INTERACT_OVERLAY_OPTIONS,
645
704
  });
646
705
  return selected ?? null;
647
706
  }
648
707
  function applyUiAction(params, state) {
649
708
  retainPromptedRevisionKeysForActiveDocs(state);
650
709
  retainAwaitingUiIdsForActiveDocs(state);
710
+ retainPendingPromptsForActiveDocs(state);
651
711
  const docs = activeDocs(state);
652
712
  const awaitingCount = awaitingDocs(state, docs).length;
653
713
  switch (params.action) {
@@ -690,6 +750,7 @@ function applyUiAction(params, state) {
690
750
  }
691
751
  retainPromptedRevisionKeysForActiveDocs(state);
692
752
  retainAwaitingUiIdsForActiveDocs(state);
753
+ retainPendingPromptsForActiveDocs(state);
693
754
  return {
694
755
  ok: true,
695
756
  action: params.action,
@@ -713,6 +774,7 @@ function applyUiAction(params, state) {
713
774
  }
714
775
  retainPromptedRevisionKeysForActiveDocs(state);
715
776
  retainAwaitingUiIdsForActiveDocs(state);
777
+ retainPendingPromptsForActiveDocs(state);
716
778
  return {
717
779
  ok: true,
718
780
  action: "replace",
@@ -732,17 +794,16 @@ function applyUiAction(params, state) {
732
794
  if (!state.docsById.delete(uiId)) {
733
795
  return { ok: false, action: "remove", message: `UI doc not found: ${uiId}` };
734
796
  }
735
- if (state.pendingPrompt?.uiId === uiId) {
736
- state.pendingPrompt = null;
737
- }
797
+ removePendingPromptsForUiId(state, uiId);
738
798
  state.awaitingUiIds.delete(uiId);
739
799
  retainPromptedRevisionKeysForActiveDocs(state);
740
800
  retainAwaitingUiIdsForActiveDocs(state);
801
+ retainPendingPromptsForActiveDocs(state);
741
802
  return { ok: true, action: "remove", message: `UI doc removed: ${uiId}` };
742
803
  }
743
804
  case "clear":
744
805
  state.docsById.clear();
745
- state.pendingPrompt = null;
806
+ state.pendingPrompts = [];
746
807
  state.promptedRevisionKeys.clear();
747
808
  state.awaitingUiIds.clear();
748
809
  return { ok: true, action: "clear", message: "UI docs cleared." };
@@ -763,61 +824,6 @@ function buildToolResult(opts) {
763
824
  };
764
825
  return result;
765
826
  }
766
- function renderDocPreview(theme, doc, opts) {
767
- const lines = [];
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(" "));
773
- if (doc.summary) {
774
- lines.push(theme.fg("muted", short(doc.summary, 80)));
775
- }
776
- if (opts.awaitingCount > 0) {
777
- lines.push(theme.fg("accent", `Awaiting user response for ${opts.awaitingCount} UI doc(s).`));
778
- }
779
- const components = doc.components.slice(0, UI_WIDGET_COMPONENTS_MAX);
780
- if (components.length > 0) {
781
- lines.push(theme.fg("dim", "Components:"));
782
- for (const component of components) {
783
- lines.push(` ${componentPreview(component)}`);
784
- }
785
- }
786
- const interactiveActions = runnableActions(doc);
787
- if (interactiveActions.length > 0) {
788
- lines.push(theme.fg("muted", "Actions:"));
789
- const visibleActions = interactiveActions.slice(0, UI_WIDGET_ACTIONS_MAX);
790
- for (let idx = 0; idx < visibleActions.length; idx += 1) {
791
- const action = visibleActions[idx];
792
- lines.push(` ${idx + 1}. ${action.label}`);
793
- }
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."));
799
- }
800
- lines.push(theme.fg("dim", `Press ${UI_INTERACT_SHORTCUT} to compose and submit a prompt from actions.`));
801
- }
802
- else {
803
- lines.push(theme.fg("dim", "No interactive actions."));
804
- }
805
- return lines;
806
- }
807
- function componentPreview(component) {
808
- const { kind } = component;
809
- switch (kind) {
810
- case "text":
811
- return `text · ${short(component.text, 80)}`;
812
- case "list":
813
- return `list · ${component.title ?? kind} · ${component.items.length} item(s)`;
814
- case "key_value":
815
- return `key_value · ${component.title ?? kind} · ${component.rows.length} row(s)`;
816
- case "divider":
817
- return "divider";
818
- }
819
- return kind;
820
- }
821
827
  function refreshUi(ctx) {
822
828
  const key = sessionKey(ctx);
823
829
  const state = ensureState(key);
@@ -833,39 +839,33 @@ function refreshUi(ctx) {
833
839
  }
834
840
  const awaiting = awaitingDocs(state, docs);
835
841
  const labels = docs.map((doc) => doc.ui_id).join(", ");
842
+ const readiness = state.interactionDepth > 0
843
+ ? ctx.ui.theme.fg("accent", "prompting")
844
+ : awaiting.length > 0
845
+ ? ctx.ui.theme.fg("accent", `awaiting ${awaiting.length}`)
846
+ : ctx.ui.theme.fg("dim", "ready");
836
847
  ctx.ui.setStatus("mu-ui", [
837
848
  ctx.ui.theme.fg("dim", "ui"),
838
849
  ctx.ui.theme.fg("muted", "·"),
839
850
  ctx.ui.theme.fg("accent", `${docs.length}`),
840
851
  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"),
852
+ readiness,
844
853
  ctx.ui.theme.fg("muted", "·"),
845
854
  ctx.ui.theme.fg("text", labels),
846
855
  ].join(" "));
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" });
856
+ ctx.ui.setWidget("mu-ui", undefined);
852
857
  }
853
858
  export function uiExtension(pi) {
854
859
  const commandUsage = "/mu ui status|snapshot [compact|multiline]|interact [ui_id [action_id]]";
855
860
  const usage = `Usage: ${commandUsage}`;
856
- const runUiActionFromDoc = async (ctx, state, uiId, actionId) => {
861
+ const runUiActionFromDoc = async (ctx, state, uiId, actionId) => withUiInteraction(ctx, state, async () => {
857
862
  const docs = activeDocs(state, UI_DISPLAY_DOCS_MAX);
858
863
  if (docs.length === 0) {
859
864
  ctx.ui.notify("No UI docs are currently available.", "info");
860
865
  return;
861
866
  }
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
- }
867
+ const entries = docs.map((doc) => ({ doc, actions: runnableActions(doc) }));
868
+ const runnableEntries = entries.filter((entry) => entry.actions.length > 0);
869
869
  let selectedDoc = null;
870
870
  let selectedAction = null;
871
871
  const normalizedUiId = uiId?.trim() ?? "";
@@ -892,14 +892,19 @@ export function uiExtension(pi) {
892
892
  actionId: normalizedActionId.length > 0 ? normalizedActionId : undefined,
893
893
  });
894
894
  if (!picked) {
895
- ctx.ui.notify("UI interaction cancelled.", "info");
895
+ if (runnableEntries.length > 0) {
896
+ ctx.ui.notify("UI interaction cancelled.", "info");
897
+ }
896
898
  return;
897
899
  }
898
900
  selectedDoc = picked.doc;
899
901
  selectedAction = picked.action;
900
902
  }
901
903
  if (!selectedDoc || !selectedAction) {
902
- ctx.ui.notify("No UI action was selected.", "error");
904
+ if (runnableEntries.length === 0) {
905
+ return;
906
+ }
907
+ ctx.ui.notify("No UI action was selected.", "info");
903
908
  return;
904
909
  }
905
910
  const commandText = actionCommandText(selectedAction);
@@ -938,12 +943,11 @@ export function uiExtension(pi) {
938
943
  }
939
944
  pi.sendUserMessage(finalPrompt);
940
945
  state.awaitingUiIds.delete(selectedDoc.ui_id);
941
- if (state.pendingPrompt?.uiId === selectedDoc.ui_id) {
942
- state.pendingPrompt = null;
943
- }
946
+ removePendingPromptsForUiId(state, selectedDoc.ui_id);
944
947
  retainAwaitingUiIdsForActiveDocs(state);
948
+ retainPendingPromptsForActiveDocs(state);
945
949
  ctx.ui.notify(`Submitted prompt from ${selectedDoc.ui_id}/${selectedAction.id}.`, "info");
946
- };
950
+ });
947
951
  registerMuSubcommand(pi, {
948
952
  subcommand: "ui",
949
953
  summary: "Inspect and manage interactive UI docs",
@@ -982,7 +986,7 @@ export function uiExtension(pi) {
982
986
  },
983
987
  });
984
988
  pi.registerShortcut(UI_INTERACT_SHORTCUT, {
985
- description: "Interact with programmable UI docs and submit prompt",
989
+ description: "Open programmable UI modal and optionally submit prompt",
986
990
  handler: async (ctx) => {
987
991
  const key = sessionKey(ctx);
988
992
  const state = ensureState(key);
@@ -1033,12 +1037,14 @@ export function uiExtension(pi) {
1033
1037
  }
1034
1038
  const key = sessionKey(ctx);
1035
1039
  const state = ensureState(key);
1036
- const pending = state.pendingPrompt;
1040
+ retainPendingPromptsForActiveDocs(state);
1041
+ const pending = state.pendingPrompts.shift();
1037
1042
  if (!pending) {
1038
1043
  return;
1039
1044
  }
1040
- state.pendingPrompt = null;
1041
- ctx.ui.notify(`Agent requested input via ${pending.uiId}. Submit now or press ${UI_INTERACT_SHORTCUT} later.`, "info");
1045
+ if (pending.kind === "action") {
1046
+ ctx.ui.notify(`Agent requested input via ${pending.uiId}. Submit now or press ${UI_INTERACT_SHORTCUT} later.`, "info");
1047
+ }
1042
1048
  await runUiActionFromDoc(ctx, state, pending.uiId, pending.actionId);
1043
1049
  refreshUi(ctx);
1044
1050
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@femtomc/mu-agent",
3
- "version": "26.2.108",
3
+ "version": "26.2.110",
4
4
  "description": "Shared operator runtime for mu assistant sessions and serve extensions.",
5
5
  "keywords": [
6
6
  "mu",
@@ -25,7 +25,7 @@
25
25
  "themes/**"
26
26
  ],
27
27
  "dependencies": {
28
- "@femtomc/mu-core": "26.2.108",
28
+ "@femtomc/mu-core": "26.2.110",
29
29
  "@mariozechner/pi-agent-core": "^0.54.2",
30
30
  "@mariozechner/pi-ai": "^0.54.2",
31
31
  "@mariozechner/pi-coding-agent": "^0.54.2",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: core
3
- description: "Meta-skill for core mu operating primitives. Routes to mu, memory, tmux, and code-mode based on task shape."
3
+ description: "Meta-skill for core mu operating primitives. Routes to mu, programmable-ui, memory, tmux, and code-mode based on task shape."
4
4
  ---
5
5
 
6
6
  # core
@@ -10,6 +10,7 @@ Use this meta-skill when the user asks for general `mu` operation guidance and y
10
10
  ## Subskills
11
11
 
12
12
  - `mu` — default CLI-first operating workflow (inspect, mutate, verify, handoff).
13
+ - `programmable-ui` — canonical `mu_ui`/`UiDoc` workflow for publishing, inspecting, and debugging interactive UI docs.
13
14
  - `memory` — prior-context retrieval, timeline reconstruction, and memory-index maintenance.
14
15
  - `tmux` — persistent terminal/session substrate for bounded command execution and fan-out.
15
16
  - `code-mode` — tmux-backed REPL loops for iterative execution and context compression.
@@ -17,12 +18,14 @@ Use this meta-skill when the user asks for general `mu` operation guidance and y
17
18
  ## Selection guide
18
19
 
19
20
  1. Start with `mu` for most day-to-day operator work.
20
- 2. Add `memory` when prior context or timeline anchors are required.
21
- 3. Add `tmux` when durable shell state or parallel worker shells are needed.
22
- 4. Add `code-mode` when solving by live execution is cheaper than chat-only reasoning.
21
+ 2. Route to `programmable-ui` when the user asks about `mu_ui`, `/mu ui ...`, `UiDoc` payloads, action wiring, or interactive prompt behavior.
22
+ 3. Add `memory` when prior context or timeline anchors are required.
23
+ 4. Add `tmux` when durable shell state or parallel worker shells are needed.
24
+ 5. Add `code-mode` when solving by live execution is cheaper than chat-only reasoning.
23
25
 
24
26
  ## Common patterns
25
27
 
26
28
  - **Bounded investigation**: Use `mu` commands (`get`, `read`, `health`) to inspect current state, then use `memory` to find "when did this last work?" before attempting a fix.
29
+ - **Programmable UI scaffolding**: Route to `programmable-ui` to emit schema-valid `UiDoc` templates quickly, verify state with `/mu ui status|snapshot`, and close docs with `mu_ui remove|clear`.
27
30
  - **Context compression**: If a user asks for complex debugging that involves running code and printing huge errors, route to `code-mode`. The agent can spin up a REPL, iterate on a fix offline, and return only the root cause to the chat.
28
31
  - **Parallel fan-out**: If a command takes a long time, or needs to run across multiple directories, route to `tmux` to spawn parallel worker shells, keep them running in the background, and periodically read their output.
@@ -224,6 +224,7 @@ Keep operator↔human communication mu_ui-first across these skills:
224
224
  - one non-interactive status doc per active profile (`metadata.profile.variant: "status"`)
225
225
  - separate interactive prompt docs for decisions (`metadata.command_text` actions)
226
226
  - explicit `mu_ui remove` teardown for resolved prompts and completed passes
227
+ For focused `UiDoc` schema templates/action wiring/status diagnostics, use `programmable-ui`.
227
228
  For REPL-driven exploration and context compression, use `code-mode`.
228
229
  For persistent terminal sessions and worker fan-out mechanics, use `tmux`.
229
230
  For recurring bounded automation loops, use `heartbeats`.
@@ -247,6 +248,7 @@ For wall-clock schedules (one-shot, interval, cron-expression), use `crons`.
247
248
 
248
249
  - Prior-context retrieval and index maintenance: **`memory`**
249
250
  - Planning/decomposition and DAG review: **`planning`**
251
+ - Programmable UI schema/templates/action-routing diagnostics: **`programmable-ui`**
250
252
  - mu_ui-first status/prompt communication patterns for DAG work: **`planning`**, **`execution`**, **`control-flow`**, **`model-routing`**
251
253
  - Shared DAG semantics for planning + execution: **`protocol`**
252
254
  - Loop/termination policy overlays (review gates, retries, escalation): **`control-flow`**
@@ -0,0 +1,299 @@
1
+ ---
2
+ name: programmable-ui
3
+ description: "Builds and debugs mu_ui UiDocs with schema-valid payloads, interaction wiring, and status/snapshot verification."
4
+ ---
5
+
6
+ # programmable-ui
7
+
8
+ Use this skill when the task involves `mu_ui`, `UiDoc` payloads, interactive actions, or `/mu ui ...` inspection commands.
9
+
10
+ ## Contents
11
+
12
+ - [Core contract](#core-contract)
13
+ - [60-second quickstart](#60-second-quickstart)
14
+ - [UiDoc schema cheat sheet](#uidoc-schema-cheat-sheet)
15
+ - [mu_ui action semantics](#mu_ui-action-semantics)
16
+ - [Canonical templates](#canonical-templates)
17
+ - [Status-profile rules](#status-profile-rules)
18
+ - [Debugging playbook](#debugging-playbook)
19
+ - [Verify and teardown checklist](#verify-and-teardown-checklist)
20
+ - [Evaluation scenarios](#evaluation-scenarios)
21
+
22
+ ## Core contract
23
+
24
+ 1. **Publish schema-valid docs only**
25
+ - `mu_ui` accepts `doc: object`, but runtime validation is strict (`UiDoc` schema).
26
+ - Invalid payloads fail with `Invalid UiDoc.`.
27
+
28
+ 2. **Keep interaction command-driven**
29
+ - Interactive actions must set `action.metadata.command_text` (for example `/answer yes`).
30
+ - User clicks/taps are translated back into normal command turns.
31
+
32
+ 3. **Separate status and decisions**
33
+ - Keep one non-interactive status doc per active profile (`metadata.profile.variant: "status"`).
34
+ - Use separate interactive docs for user decisions.
35
+
36
+ 4. **Use monotonic revisions**
37
+ - Increment `revision.version` on each update for the same `ui_id`.
38
+ - Replays/reconnects keep the highest revision deterministically.
39
+
40
+ 5. **Read -> act -> verify**
41
+ - After each `set|update|replace|remove|clear`, check `/mu ui status` and `/mu ui snapshot`.
42
+
43
+ 6. **Close docs explicitly**
44
+ - Resolve prompts with `mu_ui remove` (preferred) or `mu_ui clear`.
45
+
46
+ ## 60-second quickstart
47
+
48
+ 1. Publish one interactive doc:
49
+
50
+ ```json
51
+ {
52
+ "action": "set",
53
+ "doc": {
54
+ "v": 1,
55
+ "ui_id": "ui:demo",
56
+ "title": "Demo",
57
+ "summary": "Minimal interactive panel",
58
+ "components": [
59
+ { "kind": "text", "id": "intro", "text": "Choose an option", "metadata": {} }
60
+ ],
61
+ "actions": [
62
+ {
63
+ "id": "ack",
64
+ "label": "Acknowledge",
65
+ "kind": "primary",
66
+ "payload": { "choice": "ack" },
67
+ "metadata": { "command_text": "/answer ack" }
68
+ }
69
+ ],
70
+ "revision": { "id": "rev:demo:1", "version": 1 },
71
+ "updated_at_ms": 1,
72
+ "metadata": {}
73
+ }
74
+ }
75
+ ```
76
+
77
+ 2. Verify live state:
78
+
79
+ ```text
80
+ /mu ui status
81
+ /mu ui snapshot compact
82
+ /mu ui snapshot multiline
83
+ ```
84
+
85
+ 3. Handle command (`/answer ack`) in normal skill logic.
86
+
87
+ 4. Remove prompt doc:
88
+
89
+ ```json
90
+ { "action": "remove", "ui_id": "ui:demo" }
91
+ ```
92
+
93
+ ## UiDoc schema cheat sheet
94
+
95
+ Required top-level fields for each doc:
96
+
97
+ - `v`: `1`
98
+ - `ui_id`: non-empty string (max 64)
99
+ - `title`: non-empty string
100
+ - `components`: non-empty array (`text|list|key_value|divider`)
101
+ - `revision`: `{ id: string, version: nonnegative int }`
102
+ - `updated_at_ms`: nonnegative integer
103
+
104
+ Common optional fields (recommended):
105
+
106
+ - `summary`: deterministic fallback summary
107
+ - `actions`: interactive options (empty for pure status)
108
+ - `metadata`: profile/snapshot metadata and custom annotations
109
+
110
+ Component minimums:
111
+
112
+ - `text`: `id`, `text`
113
+ - `list`: `id`, `items[]` (`id`, `label`, optional `detail`, optional `tone`)
114
+ - `key_value`: `id`, `rows[]` (`key`, `value`, optional `tone`)
115
+ - `divider`: `id`
116
+
117
+ Action minimums:
118
+
119
+ - `id`, `label`
120
+ - `metadata.command_text` required for interactive routing
121
+ - optional: `kind`, `description`, `payload`, `component_id`, `callback_token`
122
+
123
+ ## mu_ui action semantics
124
+
125
+ - `status`
126
+ - Returns count, `ui_id` list, status-profile counts/warnings, and awaiting counts.
127
+ - `snapshot`
128
+ - `snapshot_format`: `compact|multiline` (defaults to `compact`).
129
+ - `set`
130
+ - Upsert one doc by `ui_id`.
131
+ - `update`
132
+ - Same behavior as `set` (single-doc upsert).
133
+ - `replace`
134
+ - Replace entire active doc set with `docs[]`.
135
+ - `remove`
136
+ - Remove one doc by `ui_id`.
137
+ - `clear`
138
+ - Remove all docs for the session.
139
+
140
+ ## Canonical templates
141
+
142
+ ### 1) Interactive `/answer` prompt
143
+
144
+ ```json
145
+ {
146
+ "action": "set",
147
+ "doc": {
148
+ "v": 1,
149
+ "ui_id": "ui:answer",
150
+ "title": "Answer",
151
+ "summary": "Please choose yes or no",
152
+ "components": [
153
+ { "kind": "text", "id": "prompt", "text": "Choose an answer", "metadata": {} }
154
+ ],
155
+ "actions": [
156
+ {
157
+ "id": "answer_yes",
158
+ "label": "Yes",
159
+ "kind": "primary",
160
+ "payload": { "choice": "yes" },
161
+ "metadata": { "command_text": "/answer yes" }
162
+ },
163
+ {
164
+ "id": "answer_no",
165
+ "label": "No",
166
+ "kind": "secondary",
167
+ "payload": { "choice": "no" },
168
+ "metadata": { "command_text": "/answer no" }
169
+ }
170
+ ],
171
+ "revision": { "id": "rev:answer:1", "version": 1 },
172
+ "updated_at_ms": 1,
173
+ "metadata": {}
174
+ }
175
+ }
176
+ ```
177
+
178
+ Handler contract:
179
+
180
+ 1. Parse `/answer <choice>`.
181
+ 2. Validate choice.
182
+ 3. `mu_ui remove` (or `clear`) for `ui:answer`.
183
+ 4. Emit normal response.
184
+
185
+ ### 2) Status-profile doc (non-interactive)
186
+
187
+ ```json
188
+ {
189
+ "action": "set",
190
+ "doc": {
191
+ "v": 1,
192
+ "ui_id": "ui:planning",
193
+ "title": "Planning status",
194
+ "summary": "Drafting issue DAG",
195
+ "components": [
196
+ {
197
+ "kind": "key_value",
198
+ "id": "kv",
199
+ "rows": [
200
+ { "key": "phase", "value": "decomposition" },
201
+ { "key": "next", "value": "approval prompt" }
202
+ ],
203
+ "metadata": {}
204
+ },
205
+ {
206
+ "kind": "list",
207
+ "id": "milestones",
208
+ "items": [
209
+ { "id": "m1", "label": "Root issue captured" },
210
+ { "id": "m2", "label": "Leaf tasks drafted" }
211
+ ],
212
+ "metadata": {}
213
+ }
214
+ ],
215
+ "actions": [],
216
+ "revision": { "id": "rev:planning:12", "version": 12 },
217
+ "updated_at_ms": 1730000000000,
218
+ "metadata": {
219
+ "profile": {
220
+ "id": "planning",
221
+ "variant": "status",
222
+ "snapshot": {
223
+ "compact": "planning: DAG draft ready",
224
+ "multiline": "phase: decomposition\nnext: approval prompt"
225
+ }
226
+ }
227
+ }
228
+ }
229
+ }
230
+ ```
231
+
232
+ ### 3) Parameterized command text with payload defaults
233
+
234
+ ```json
235
+ {
236
+ "id": "approve",
237
+ "label": "Approve",
238
+ "payload": { "choice": "approve", "note": "looks good" },
239
+ "metadata": { "command_text": "/answer choice={{choice}} note={{note}}" }
240
+ }
241
+ ```
242
+
243
+ In terminal UI interaction flow, placeholders are auto-filled from `payload` when possible, and unresolved fields are prompted.
244
+
245
+ ## Status-profile rules
246
+
247
+ When `metadata.profile.id` is one of `planning|subagents|control-flow|model-routing` and variant is `status`:
248
+
249
+ - expected `ui_id` values:
250
+ - `planning` -> `ui:planning`
251
+ - `subagents` -> `ui:subagents`
252
+ - `control-flow` -> `ui:control-flow`
253
+ - `model-routing` -> `ui:model-routing`
254
+ - keep `actions: []` (status docs are non-interactive)
255
+ - include `summary` plus `metadata.profile.snapshot.compact`
256
+ - preferred components by profile:
257
+ - `planning`: `key_value` + `list`
258
+ - `subagents`: `key_value` + `list`
259
+ - `control-flow`: `key_value`
260
+ - `model-routing`: `key_value` + `list`
261
+
262
+ Use `/mu ui status` to catch profile warnings early.
263
+
264
+ ## Debugging playbook
265
+
266
+ - **`doc is required`**
267
+ - Missing `doc` parameter for `set|update`.
268
+ - **`Invalid UiDoc.`**
269
+ - Schema mismatch. Re-check required fields and component/action shapes.
270
+ - **`docs must be an array` / `docs[i]: invalid UiDoc`**
271
+ - `replace` payload malformed.
272
+ - **Action appears but cannot run**
273
+ - Missing/empty `metadata.command_text`, or status-profile doc (actions intentionally non-runnable).
274
+ - **`awaiting` stays non-zero**
275
+ - Prompt doc still active. Remove with `mu_ui remove` once handled.
276
+
277
+ ## Verify and teardown checklist
278
+
279
+ After each change:
280
+
281
+ 1. `/mu ui status` shows expected doc count and ids.
282
+ 2. `/mu ui snapshot compact` shows deterministic summary.
283
+ 3. `/mu ui snapshot multiline` shows readable panel/action projection.
284
+ 4. Prompt resolved? Remove/clear doc explicitly.
285
+ 5. Keep issue/forum truth in sync; `mu_ui` is communication state, not source-of-truth task state.
286
+
287
+ ## Evaluation scenarios
288
+
289
+ 1. **First-time interactive prompt**
290
+ - Publish `ui:answer`, click action, confirm `/answer ...` reaches normal handler, remove prompt doc.
291
+
292
+ 2. **Status + decision split**
293
+ - Keep `ui:planning` status-profile doc active; open separate interactive approval doc; verify status remains non-interactive.
294
+
295
+ 3. **Replay/reconnect safety**
296
+ - Publish revisions 1 then 2; replay stale rev 1; verify highest revision remains active.
297
+
298
+ 4. **Channel degrade resilience**
299
+ - Ensure every actionable row has deterministic `command_text` fallback so manual command entry always works.