@yemi33/minions 0.1.2410 → 0.1.2411

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.
@@ -144,7 +144,7 @@ async function openSettings() {
144
144
  '</div>' +
145
145
  '<div data-search="default model fleet">' +
146
146
  '<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Default Model</label>' +
147
- '<div id="set-defaultModel-wrap"><input id="set-defaultModel" value="' + escHtml(e.defaultModel || '') + '" placeholder="Default (CLI chooses)" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)"></div>' +
147
+ '<div id="set-defaultModel-wrap"><input id="set-defaultModel" value="' + escHtml(e.defaultModel || '') + '" placeholder="Default (CLI chooses)" style="' + MODEL_FIELD_INPUT_STYLE + '"></div>' +
148
148
  '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">Empty = let the runtime pick its own default</div>' +
149
149
  '</div>' +
150
150
  '</div>' +
@@ -168,7 +168,7 @@ async function openSettings() {
168
168
  // swaps THIS element. Without the wrap it would wipe the label
169
169
  // and the hint below, breaking vertical alignment with the
170
170
  // sibling CC CLI / Effort columns.
171
- '<div id="set-ccModel-wrap"><input id="set-ccModel" value="' + escHtml(e.ccModel || '') + '" placeholder="(inherits Default Model)" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)"></div>' +
171
+ '<div id="set-ccModel-wrap"><input id="set-ccModel" value="' + escHtml(e.ccModel || '') + '" placeholder="(inherits Default Model)" style="' + MODEL_FIELD_INPUT_STYLE + '"></div>' +
172
172
  '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">Empty = inherit Default Model</div>' +
173
173
  '</div>' +
174
174
  '<div data-search="cc effort reasoning">' +
@@ -995,6 +995,133 @@ async function initRuntimeFleetUI(engineCfg, agentsCfg) {
995
995
  });
996
996
  }
997
997
 
998
+ // ── Model field styling (W-mrvdup4h000fc0a2) ─────────────────────────────
999
+ // Default Model / CC Model in the Runtime pane are datalist-backed <input>
1000
+ // comboboxes (staged/custom model IDs must stay typeable, so they can't be
1001
+ // closed native <select>s). These shared styles make the input match the box
1002
+ // metrics of the sibling native <select> dropdowns (Default CLI / CC CLI /
1003
+ // Effort / Font Size) and add a chevron so it reads as a dropdown.
1004
+ // box-sizing:border-box keeps the bordered box the same rendered size as a
1005
+ // width:100% <select>; the chevron is drawn just inside the input's right edge.
1006
+ const MODEL_FIELD_INPUT_BOX = 'box-sizing:border-box;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)';
1007
+ const MODEL_FIELD_CHEVRON = "background-image:url('data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%2210%22%20height=%226%22%3E%3Cpath%20d=%22M1%201l4%204%204-4%22%20fill=%22none%22%20stroke=%22%238b949e%22%20stroke-width=%221.5%22/%3E%3C/svg%3E');background-repeat:no-repeat;background-position:right 8px center;padding-right:22px";
1008
+ // Full-width variant for the initial (pre-discovery) placeholder inputs.
1009
+ const MODEL_FIELD_INPUT_STYLE = 'width:100%;' + MODEL_FIELD_INPUT_BOX + ';' + MODEL_FIELD_CHEVRON;
1010
+
1011
+ // ── Model-picker dropdown (W-mrvdup4h000fc0a2 / PR #916) ──────────────────
1012
+ // The Default Model / CC Model fields must stay editable <input>s so staged
1013
+ // and custom model IDs remain typeable — they can't be closed native
1014
+ // <select>s. Discovery used to be surfaced through a browser <datalist>, but
1015
+ // the datalist autocomplete popup is browser chrome and reads visibly
1016
+ // different from the native <select> dropdowns of the sibling controls
1017
+ // (Default CLI / CC CLI / Effort) — the mismatch the reviewer flagged: the
1018
+ // "dialog that it launches doesn't match". This wires a dashboard-themed
1019
+ // popover (.model-combo-popup) onto the input so the launched suggestion list
1020
+ // matches those dropdowns while keeping the field free-typeable.
1021
+ //
1022
+ // inputEl : the editable <input> (kept typeable; free text is preserved)
1023
+ // popupEl : the (initially empty) .model-combo-popup container to render into
1024
+ // models : array of { id, name } discovery results (may be empty)
1025
+ //
1026
+ // The popup is positioned with position:fixed at open time (see the CSS) so it
1027
+ // escapes the .settings-content / .settings-body overflow clipping and floats
1028
+ // like a native dropdown. All listeners are element-scoped so they are GC'd
1029
+ // with the input when loadModelsForRuntime re-renders (no global leak).
1030
+ function _wireModelCombobox(inputEl, popupEl, models) {
1031
+ if (!inputEl || !popupEl) return;
1032
+ const options = (models || [])
1033
+ .map(function(m) {
1034
+ const id = m.id || m.name || '';
1035
+ const label = (m.name && m.name !== id) ? m.name : '';
1036
+ return { id: id, label: label };
1037
+ })
1038
+ .filter(function(o) { return o.id; });
1039
+ if (options.length === 0) return;
1040
+ let visible = [];
1041
+ let activeIdx = -1;
1042
+
1043
+ function position() {
1044
+ const r = inputEl.getBoundingClientRect();
1045
+ popupEl.style.left = r.left + 'px';
1046
+ popupEl.style.top = r.bottom + 'px';
1047
+ popupEl.style.width = r.width + 'px';
1048
+ }
1049
+ function render() {
1050
+ const q = (inputEl.value || '').toLowerCase();
1051
+ // A value that exactly equals an option shows the full list, so re-focusing
1052
+ // a filled field still offers every model (matching native <select>).
1053
+ const exact = options.some(function(o) { return o.id.toLowerCase() === q; });
1054
+ visible = (!q || exact) ? options.slice()
1055
+ : options.filter(function(o) {
1056
+ return o.id.toLowerCase().indexOf(q) !== -1 || (o.label && o.label.toLowerCase().indexOf(q) !== -1);
1057
+ });
1058
+ if (visible.length === 0) { hide(); return; }
1059
+ if (activeIdx >= visible.length) activeIdx = -1;
1060
+ // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: model id, model label)
1061
+ popupEl.innerHTML = visible.map(function(o, i) {
1062
+ return '<div class="model-combo-option' + (i === activeIdx ? ' active' : '') + '" data-idx="' + i + '">' +
1063
+ '<span class="model-combo-id">' + escHtml(o.id) + '</span>' +
1064
+ (o.label ? '<span class="model-combo-label">' + escHtml(o.label) + '</span>' : '') +
1065
+ '</div>';
1066
+ }).join('');
1067
+ position();
1068
+ popupEl.classList.add('visible');
1069
+ inputEl.setAttribute('aria-expanded', 'true');
1070
+ }
1071
+ function hide() {
1072
+ popupEl.classList.remove('visible');
1073
+ inputEl.setAttribute('aria-expanded', 'false');
1074
+ activeIdx = -1;
1075
+ }
1076
+ function pick(o) {
1077
+ if (!o) return;
1078
+ inputEl.value = o.id;
1079
+ hide();
1080
+ }
1081
+ function scrollActive() {
1082
+ const el = popupEl.querySelector('.model-combo-option.active');
1083
+ if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
1084
+ }
1085
+
1086
+ inputEl.addEventListener('focus', render);
1087
+ // A click on an already-focused input does not re-fire `focus`, so after a
1088
+ // selection (pick → hide) or Escape (hide) the still-focused input could not
1089
+ // be reopened by clicking — it only stays a dead field until blur+refocus.
1090
+ // Reopen the popup on click whenever it is currently hidden (matches how a
1091
+ // native <select> reopens on click). No-op on the focus-triggering click,
1092
+ // which already opened it, so there is no open/close flicker.
1093
+ inputEl.addEventListener('click', function() {
1094
+ if (!popupEl.classList.contains('visible')) render();
1095
+ });
1096
+ inputEl.addEventListener('input', function() { activeIdx = -1; render(); });
1097
+ inputEl.addEventListener('keydown', function(ev) {
1098
+ const open = popupEl.classList.contains('visible');
1099
+ if (ev.key === 'ArrowDown') {
1100
+ ev.preventDefault();
1101
+ if (!open) { render(); return; }
1102
+ activeIdx = Math.min(activeIdx + 1, visible.length - 1);
1103
+ render(); scrollActive();
1104
+ } else if (ev.key === 'ArrowUp') {
1105
+ if (!open) return;
1106
+ ev.preventDefault();
1107
+ activeIdx = Math.max(activeIdx - 1, 0);
1108
+ render(); scrollActive();
1109
+ } else if (ev.key === 'Enter') {
1110
+ if (open && activeIdx >= 0) { ev.preventDefault(); pick(visible[activeIdx]); }
1111
+ } else if (ev.key === 'Escape') {
1112
+ if (open) { ev.stopPropagation(); hide(); }
1113
+ }
1114
+ });
1115
+ // mousedown (not click) so the pick fires before the input's blur hides it.
1116
+ popupEl.addEventListener('mousedown', function(ev) {
1117
+ const row = ev.target.closest('.model-combo-option');
1118
+ if (!row) return;
1119
+ ev.preventDefault();
1120
+ pick(visible[Number(row.getAttribute('data-idx'))]);
1121
+ });
1122
+ inputEl.addEventListener('blur', function() { setTimeout(hide, 120); });
1123
+ }
1124
+
998
1125
  /**
999
1126
  * Render an editable model field backed by discovered-model suggestions.
1000
1127
  * Discovery is advisory: staged/future/custom model IDs must remain typeable.
@@ -1005,7 +1132,7 @@ async function loadModelsForRuntime(runtimeName, inputId, currentValue, forceRef
1005
1132
  const token = _nextModelLoadToken('runtime', inputId);
1006
1133
  if (!runtimeName) {
1007
1134
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: currentValue; inputId is an internal fixed DOM id)
1008
- wrap.innerHTML = '<input id="' + inputId + '" value="' + escHtml(currentValue || '') + '" placeholder="(no runtime selected)" disabled style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:var(--text-md)">';
1135
+ wrap.innerHTML = '<input id="' + inputId + '" value="' + escHtml(currentValue || '') + '" placeholder="(no runtime selected)" disabled style="' + MODEL_FIELD_INPUT_STYLE + ';color:var(--muted)">';
1009
1136
  return;
1010
1137
  }
1011
1138
  let payload = { models: null };
@@ -1013,17 +1140,14 @@ async function loadModelsForRuntime(runtimeName, inputId, currentValue, forceRef
1013
1140
 
1014
1141
  if (!_isCurrentModelLoad('runtime', inputId, token)) return;
1015
1142
  const models = Array.isArray(payload.models) ? payload.models : null;
1016
- const listId = inputId + '-options';
1017
- let opts = '';
1018
- for (const m of (models || [])) {
1019
- const id = m.id || m.name || '';
1020
- if (!id) continue;
1021
- const label = m.name && m.name !== id ? m.name : '';
1022
- opts += '<option value="' + escHtml(id) + '"' + (label ? ' label="' + escHtml(label) + '"' : '') + '></option>';
1023
- }
1024
1143
  const title = forceRefresh ? 'Model list refreshed' : 'Refresh models from the selected CLI';
1025
- // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml()
1026
- wrap.innerHTML = '<div style="display:flex;gap:4px"><input id="' + inputId + '" list="' + listId + '" value="' + escHtml(currentValue || '') + '" placeholder="Default (CLI chooses)" autocomplete="off" style="min-width:0;flex:1;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)"><button type="button" data-model-refresh title="' + escHtml(title) + '" aria-label="Refresh model list" style="padding:3px 8px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--muted);cursor:pointer">↻</button></div><datalist id="' + listId + '">' + opts + '</datalist>';
1144
+ // Editable model input + themed suggestion popover (see _wireModelCombobox).
1145
+ // The field stays free-typeable for staged/custom IDs; discovered models are
1146
+ // offered through .model-combo-popup so the launched list matches the sibling
1147
+ // native <select> dropdowns instead of the browser's <datalist> chrome.
1148
+ // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: currentValue, title; inputId is an internal fixed DOM id)
1149
+ wrap.innerHTML = '<div class="model-combo" style="width:100%"><div style="display:flex;gap:4px;width:100%"><input id="' + inputId + '" value="' + escHtml(currentValue || '') + '" placeholder="Default (CLI chooses)" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" style="min-width:0;flex:1;' + MODEL_FIELD_INPUT_BOX + ';' + MODEL_FIELD_CHEVRON + '"><button type="button" data-model-refresh title="' + escHtml(title) + '" aria-label="Refresh model list" style="padding:3px 8px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--muted);cursor:pointer">↻</button></div><div class="model-combo-popup" data-model-popup></div></div>';
1150
+ _wireModelCombobox(document.getElementById(inputId), wrap.querySelector('[data-model-popup]'), models || []);
1027
1151
  const refresh = wrap.querySelector('[data-model-refresh]');
1028
1152
  if (refresh) {
1029
1153
  refresh.addEventListener('click', async function() {
@@ -889,6 +889,33 @@
889
889
  .cmd-mention-item .mention-name { font-weight: 600; color: var(--purple); }
890
890
  .cmd-mention-item .mention-role { color: var(--muted); font-size: var(--text-base); }
891
891
 
892
+ /* Model-picker combobox (Settings → Agents Runtime & Models, PR #916).
893
+ The Default Model / CC Model fields stay editable <input>s (staged/custom
894
+ model IDs must remain typeable) but the launched suggestion list is a
895
+ dashboard-themed popover instead of the browser's native <datalist>
896
+ chrome, so it matches the sibling native <select> dropdowns. Positioned
897
+ with position:fixed (coords set in JS at open time) so it escapes the
898
+ .settings-content / .settings-body overflow clipping and floats like a
899
+ native dropdown. z-index sits above modal content (.modal-bg is 400). */
900
+ .model-combo { position: relative; }
901
+ .model-combo-popup {
902
+ display: none; position: fixed; margin-top: 2px;
903
+ background: var(--surface); border: 1px solid var(--border); border-radius: 4px;
904
+ padding: 2px 0; box-shadow: var(--shadow-lg); z-index: 500;
905
+ max-height: 260px; overflow-y: auto;
906
+ scrollbar-width: thin; scrollbar-color: var(--border) transparent;
907
+ }
908
+ .model-combo-popup.visible { display: block; }
909
+ .model-combo-popup::-webkit-scrollbar { width: 4px; }
910
+ .model-combo-popup::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
911
+ .model-combo-option {
912
+ padding: 4px 8px; font-size: var(--text-md); color: var(--text); cursor: pointer;
913
+ display: flex; align-items: baseline; gap: 6px; white-space: nowrap;
914
+ }
915
+ .model-combo-option:hover { background: var(--surface2); }
916
+ .model-combo-option.active { background: rgba(88,166,255,0.15); }
917
+ .model-combo-label { color: var(--muted); font-size: var(--text-xs); }
918
+
892
919
  /* Hints bar */
893
920
  .cmd-hints {
894
921
  display: flex; gap: var(--space-6); margin-top: var(--space-2); font-size: var(--text-sm); color: var(--muted);
@@ -767,10 +767,17 @@ const KNOWN_EVENT_TYPES = new Set([
767
767
  * Returns { text, usage, sessionId, model } — same shape as the Claude adapter
768
768
  * so engine/lifecycle.js can consume both transparently.
769
769
  *
770
- * - text: concatenation of answer `assistant.message.data.content`
771
- * values, plus trailing deltas if the CLI exits before a final
772
- * `assistant.message`. Narration attached to tool requests is
773
- * progress text and is excluded from the final answer.
770
+ * - text: the TRAILING text segment of the turn — answer
771
+ * `assistant.message.data.content` values plus trailing deltas,
772
+ * but only those that arrived AFTER the last real (non-
773
+ * `task_complete`) tool boundary. Narration attached to tool
774
+ * requests is progress text and is excluded from the final
775
+ * answer, and a real tool call resets the accumulators so pre-
776
+ * tool text never welds onto the post-tool answer. This mirrors
777
+ * the stream consumer's `_resetTrailingSegment` and Claude's
778
+ * result-carries-last-message shape, so the terminal
779
+ * reconciliation in engine/llm.js#finalize agrees with the live
780
+ * stream instead of re-welding split blocks (W-mrw87290000b2965).
774
781
  * - usage: mapped from the terminal `result` event. Copilot doesn't
775
782
  * report cost/tokens — those fields are NULL, not 0, so the
776
783
  * dashboard can distinguish "Copilot didn't tell us" from
@@ -809,10 +816,15 @@ function parseOutput(raw, { maxTextLength = 0 } = {}) {
809
816
  const content = obj.data?.content;
810
817
  const toolRequests = obj.data?.toolRequests;
811
818
  const hasToolRequests = Array.isArray(toolRequests) && toolRequests.length > 0;
819
+ let sawRealTool = false;
812
820
  if (hasToolRequests) {
813
821
  for (const tr of toolRequests) {
814
- const summary = tr?.name === 'task_complete' ? tr.arguments?.summary || tr.intentionSummary : '';
815
- if (typeof summary === 'string' && summary) taskCompleteSummary = summary;
822
+ if (tr?.name === 'task_complete') {
823
+ const summary = tr.arguments?.summary || tr.intentionSummary;
824
+ if (typeof summary === 'string' && summary) taskCompleteSummary = summary;
825
+ } else if (tr?.name) {
826
+ sawRealTool = true;
827
+ }
816
828
  }
817
829
  }
818
830
  if (typeof content === 'string') {
@@ -821,9 +833,32 @@ function parseOutput(raw, { maxTextLength = 0 } = {}) {
821
833
  }
822
834
  const ot = obj.data?.outputTokens;
823
835
  if (typeof ot === 'number') outputTokensTotal += ot;
836
+ // A real (non-task_complete) tool call closes the trailing text segment:
837
+ // the final answer is whatever text arrives AFTER it. Reset the trailing
838
+ // accumulators so pre-tool narration — whether it arrived as a finalized
839
+ // assistant.message or as raw deltas — does not weld onto the post-tool
840
+ // answer. Without this, `text = messageContents.join('') + pendingDeltaContent`
841
+ // re-welds `<pre-tool><post-tool>` in the terminal reconciliation
842
+ // (engine/llm.js#finalize -> runtime.parseOutput), and the CC SSE `done`
843
+ // payload re-introduces the weld the stream consumer already split apart
844
+ // (W-mrw87290000b2965). Mirrors the stream consumer's _resetTrailingSegment
845
+ // and Claude, whose result event carries only the last assistant message.
846
+ if (sawRealTool) {
847
+ messageContents.length = 0;
848
+ pendingDeltaContent = '';
849
+ }
824
850
  } else if (type === 'tool.execution_start') {
825
- const summary = obj.data?.toolName === 'task_complete' ? obj.data?.arguments?.summary : '';
826
- if (typeof summary === 'string' && summary) taskCompleteSummary = summary;
851
+ const toolName = obj.data?.toolName;
852
+ if (toolName === 'task_complete') {
853
+ const summary = obj.data?.arguments?.summary;
854
+ if (typeof summary === 'string' && summary) taskCompleteSummary = summary;
855
+ } else if (typeof toolName === 'string' && toolName) {
856
+ // Same real-tool-boundary reset as the toolRequests branch above —
857
+ // `tool.execution_start` is the standalone event shape (delta -> tool
858
+ // -> delta) that produced the original weld repro.
859
+ messageContents.length = 0;
860
+ pendingDeltaContent = '';
861
+ }
827
862
  } else if (type === 'session.task_complete') {
828
863
  const summary = obj.data?.summary;
829
864
  if (typeof summary === 'string' && summary) taskCompleteSummary = summary;
@@ -1196,6 +1231,17 @@ function createStreamConsumer(ctx) {
1196
1231
  function _resetTrailingSegment() {
1197
1232
  trailingText = '';
1198
1233
  _hadPriorTrailingMessage = false;
1234
+ // A real tool call closes the trailing text segment, so the live
1235
+ // `assistant.message_delta` accumulator must reset too — otherwise, when
1236
+ // the pre-tool text arrived ONLY as deltas (no finalizing non-tool
1237
+ // `assistant.message` to zero the buffer, e.g. delta(s) → tool.execution_start
1238
+ // → delta(s)), the post-tool deltas append onto the pre-tool text and
1239
+ // `ctx.pushText` emits the welded whole-turn buffer under the new (post-tool)
1240
+ // segmentId. The dashboard then renders the post-tool block as
1241
+ // `<pre-tool>.<post-tool>` with no separator (W-mrw87290 — CC weld across a
1242
+ // tool call). Claude's adapter resets its accumulator on tool_use for the
1243
+ // same reason (engine/runtimes/claude.js).
1244
+ copilotMessageBuffer = '';
1199
1245
  }
1200
1246
 
1201
1247
  function _captureTaskComplete(summary, success = true, { clearBuffer = false } = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2410",
3
+ "version": "0.1.2411",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"