@worca/app 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +85 -6
  2. package/agents/clarify.meta.json +1 -0
  3. package/agents/memoryDefragmenter.meta.json +2 -1
  4. package/agents/reviewer.meta.json +60 -0
  5. package/agents/worca-cc-code-reviewer.md +33 -0
  6. package/agents/worca-cc-memory-defragmenter.md +5 -3
  7. package/agents/workspaceScanner.meta.json +1 -0
  8. package/package.json +14 -10
  9. package/scripts/git-diff.mjs +25 -0
  10. package/scripts/gitDiff.meta.json +18 -0
  11. package/scripts/js-inline.mjs +11 -0
  12. package/scripts/js.meta.json +22 -0
  13. package/scripts/py-inline.py +27 -0
  14. package/scripts/py.meta.json +22 -0
  15. package/scripts/shell.meta.json +24 -0
  16. package/skills/worca/SKILL.md +3 -2
  17. package/src/cli/models.mjs +247 -0
  18. package/src/cli/render.mjs +72 -4
  19. package/src/cli/schedule.mjs +494 -0
  20. package/src/cli/worca-cc.mjs +1001 -22
  21. package/src/core/agent-registry.mjs +75 -23
  22. package/src/core/agent-store.mjs +51 -2
  23. package/src/core/artifacts.mjs +73 -10
  24. package/src/core/ask/events.mjs +119 -1
  25. package/src/core/ask/limits.mjs +32 -4
  26. package/src/core/ask/mcp-stdio.mjs +12 -0
  27. package/src/core/ask/model-deps.mjs +126 -0
  28. package/src/core/ask/model-proposal.mjs +370 -0
  29. package/src/core/ask/models.mjs +12 -0
  30. package/src/core/ask/policy-deps.mjs +124 -0
  31. package/src/core/ask/policy-proposal.mjs +363 -0
  32. package/src/core/ask/prompt.mjs +74 -9
  33. package/src/core/ask/proposal.mjs +54 -5
  34. package/src/core/ask/schedule-deps.mjs +83 -0
  35. package/src/core/ask/schedule-spec.mjs +310 -0
  36. package/src/core/ask/script-deps.mjs +357 -0
  37. package/src/core/ask/source-deps.mjs +52 -0
  38. package/src/core/ask/source-spec.mjs +157 -0
  39. package/src/core/ask/spawn.mjs +1 -0
  40. package/src/core/ask/store.mjs +6 -3
  41. package/src/core/ask/tool-deps.mjs +4 -0
  42. package/src/core/ask/tools.mjs +657 -37
  43. package/src/core/ask/turn.mjs +109 -2
  44. package/src/core/ask-files.mjs +406 -0
  45. package/src/core/ask-forms.mjs +195 -0
  46. package/src/core/ask-projection.mjs +72 -0
  47. package/src/core/bridge/errors.mjs +84 -0
  48. package/src/core/bridge/provider-ops.mjs +281 -0
  49. package/src/core/bridge/providers/copilot.mjs +269 -0
  50. package/src/core/bridge/providers/endpoint.mjs +257 -0
  51. package/src/core/bridge/registry.mjs +88 -0
  52. package/src/core/bridge/semaphore.mjs +73 -0
  53. package/src/core/bridge/server.mjs +184 -0
  54. package/src/core/bridge/telemetry.mjs +53 -0
  55. package/src/core/bridge/translate/request.mjs +252 -0
  56. package/src/core/bridge/translate/response.mjs +82 -0
  57. package/src/core/bridge/translate/stream.mjs +242 -0
  58. package/src/core/bridge/upstream.mjs +209 -0
  59. package/src/core/chat/command-router.mjs +58 -4
  60. package/src/core/chat/notifier.mjs +14 -1
  61. package/src/core/chat/renderers.mjs +35 -0
  62. package/src/core/claude-runner.mjs +126 -19
  63. package/src/core/config.mjs +212 -31
  64. package/src/core/cost-budget.mjs +3 -2
  65. package/src/core/db.mjs +169 -15
  66. package/src/core/failure-policy.mjs +10 -0
  67. package/src/core/fs-browse.mjs +16 -4
  68. package/src/core/git-info.mjs +22 -0
  69. package/src/core/graph/builtin-workflows.mjs +3 -1
  70. package/src/core/graph/exec-io.mjs +71 -0
  71. package/src/core/graph/executor.mjs +139 -70
  72. package/src/core/graph/human-evidence.mjs +131 -0
  73. package/src/core/graph/python-probe.mjs +172 -0
  74. package/src/core/graph/registry-ports.mjs +10 -6
  75. package/src/core/graph/scheduler.mjs +39 -24
  76. package/src/core/graph/script-child.mjs +81 -0
  77. package/src/core/graph/script-runner.mjs +597 -0
  78. package/src/core/graph/worca_script.py +207 -0
  79. package/src/core/guardrail-store.mjs +16 -0
  80. package/src/core/human-backfill.mjs +108 -0
  81. package/src/core/human-rate.mjs +17 -0
  82. package/src/core/index-html.mjs +6 -2
  83. package/src/core/memory-defrag-model.mjs +112 -0
  84. package/src/core/memory-store.mjs +70 -18
  85. package/src/core/memory-sync.mjs +22 -11
  86. package/src/core/metrics/read.mjs +4 -1
  87. package/src/core/metrics/record.mjs +50 -2
  88. package/src/core/metrics/sync.mjs +6 -4
  89. package/src/core/model-env.mjs +149 -0
  90. package/src/core/model-test.mjs +14 -1
  91. package/src/core/notifications.mjs +128 -0
  92. package/src/core/onboarding.mjs +8 -2
  93. package/src/core/orchestrator.mjs +357 -26
  94. package/src/core/phases.mjs +95 -6
  95. package/src/core/plugin-api.mjs +24 -7
  96. package/src/core/plugin-manifest.mjs +184 -18
  97. package/src/core/plugin-models.mjs +1 -0
  98. package/src/core/plugin-script-cases.mjs +118 -0
  99. package/src/core/plugin-store.mjs +163 -17
  100. package/src/core/plugin-workflows.mjs +71 -17
  101. package/src/core/policy/cache.mjs +116 -0
  102. package/src/core/policy/effective.mjs +175 -0
  103. package/src/core/policy/gate.mjs +91 -0
  104. package/src/core/policy/local.mjs +145 -0
  105. package/src/core/policy/registry.mjs +330 -0
  106. package/src/core/policy/scope.mjs +61 -0
  107. package/src/core/policy/state.mjs +79 -0
  108. package/src/core/policy/sync.mjs +513 -0
  109. package/src/core/protocol.mjs +43 -0
  110. package/src/core/run-harness.mjs +421 -72
  111. package/src/core/scheduler.mjs +980 -0
  112. package/src/core/script-bench.mjs +628 -0
  113. package/src/core/script-registry.mjs +116 -0
  114. package/src/core/script-store.mjs +563 -0
  115. package/src/core/settings.mjs +489 -14
  116. package/src/core/stats.mjs +33 -2
  117. package/src/core/workflow-export.mjs +94 -3
  118. package/src/core/workflow-share.mjs +67 -18
  119. package/src/core/workflows.mjs +47 -11
  120. package/src/core/workspaces.mjs +18 -12
  121. package/src/shared/forms/answer.mjs +164 -0
  122. package/src/shared/forms/catalog.mjs +91 -0
  123. package/src/shared/forms/form-def.mjs +290 -0
  124. package/src/shared/forms/layout.mjs +67 -0
  125. package/src/shared/forms/paths.mjs +47 -0
  126. package/src/shared/forms/project.mjs +309 -0
  127. package/src/shared/forms/schema.mjs +205 -0
  128. package/src/shared/graph/agent-meta.mjs +55 -5
  129. package/src/shared/graph/constants.mjs +14 -2
  130. package/src/shared/graph/flow-layout.mjs +2 -1
  131. package/src/shared/graph/isomorphic.mjs +5 -3
  132. package/src/shared/graph/manifest.mjs +22 -13
  133. package/src/shared/graph/ports.mjs +45 -19
  134. package/src/shared/graph/script-cases.mjs +257 -0
  135. package/src/shared/graph/script-icons.mjs +46 -0
  136. package/src/shared/graph/script-infer.mjs +259 -0
  137. package/src/shared/graph/script-meta.mjs +408 -0
  138. package/src/shared/graph/script-templates.mjs +201 -0
  139. package/src/shared/graph/template.mjs +4 -4
  140. package/src/shared/graph/validate.mjs +89 -16
  141. package/src/shared/human-estimate.mjs +100 -0
  142. package/src/shared/schedule/recurrence.mjs +353 -0
  143. package/src/shared/team-metrics/aggregate.mjs +51 -11
  144. package/{scripts → tools}/install.mjs +3 -3
  145. package/ui/public/app.js +4390 -683
  146. package/ui/public/artifact-picker.mjs +189 -0
  147. package/ui/public/ask/dom.mjs +121 -0
  148. package/ui/public/ask/form-preview.mjs +55 -0
  149. package/ui/public/ask/form-renderer.mjs +250 -0
  150. package/ui/public/ask/registry.mjs +53 -0
  151. package/ui/public/ask/widgets-display.mjs +370 -0
  152. package/ui/public/ask/widgets-input.mjs +624 -0
  153. package/ui/public/ask/widgets-layout.mjs +90 -0
  154. package/ui/public/ask-panel.mjs +401 -27
  155. package/ui/public/ask-run-card.mjs +1 -1
  156. package/ui/public/bridge-view.mjs +694 -0
  157. package/ui/public/chat-settings-view.mjs +24 -0
  158. package/ui/public/code-editor.mjs +181 -0
  159. package/ui/public/getting-started.mjs +34 -7
  160. package/ui/public/graph/composer.mjs +138 -10
  161. package/ui/public/graph/inspector.mjs +61 -58
  162. package/ui/public/graph/palette.mjs +27 -9
  163. package/ui/public/graph/run-decor.mjs +34 -17
  164. package/ui/public/graph/run-hosts.mjs +7 -1
  165. package/ui/public/graph/save-dialog.mjs +3 -0
  166. package/ui/public/graph/view.mjs +23 -7
  167. package/ui/public/guardrails-view.mjs +15 -3
  168. package/ui/public/guide-spot.mjs +87 -9
  169. package/ui/public/index.html +480 -141
  170. package/ui/public/memory-view.mjs +22 -4
  171. package/ui/public/models-view.mjs +162 -17
  172. package/ui/public/node-tunables.mjs +33 -4
  173. package/ui/public/plugins-view.mjs +23 -1
  174. package/ui/public/results-view.mjs +4 -2
  175. package/ui/public/schedule-sheet.mjs +430 -0
  176. package/ui/public/schedules-view.mjs +432 -0
  177. package/ui/public/script-bench-view.mjs +1154 -0
  178. package/ui/public/script-forms.mjs +282 -0
  179. package/ui/public/script-wizard.mjs +529 -0
  180. package/ui/public/scripts-view.mjs +868 -0
  181. package/ui/public/stats-view.mjs +159 -52
  182. package/ui/public/style.css +1461 -44
  183. package/ui/public/team-metrics-surfaces.mjs +77 -16
  184. package/ui/public/team-metrics-view.mjs +68 -5
  185. package/ui/public/team-policy-view.mjs +1402 -0
  186. package/ui/public/ui-level.mjs +237 -0
  187. package/ui/server.mjs +1827 -73
@@ -0,0 +1,694 @@
1
+ // ui/public/bridge-view.mjs
2
+ // Pure DOM renderers for the model bridge (model-bridge-design.md §8): the
3
+ // Providers card, the Copilot sign-in block, the Import-from-Copilot sheet,
4
+ // the editor's Connection section, the terms notice text, and the card
5
+ // badges. Same contract as models-view.mjs: every function takes `doc` via
6
+ // opts and returns DETACHED elements; app.js owns endpoint calls, mounting
7
+ // and the delegated listeners. Interactive elements carry a routing class
8
+ // (mv-cp-*, mv-pv-*, mvi-*, mv-conn-*) plus data-provider / data-id.
9
+
10
+ function h(doc, tag, cls, text) {
11
+ const n = doc.createElement(tag);
12
+ if (cls) n.className = cls;
13
+ if (text != null) n.textContent = text;
14
+ return n;
15
+ }
16
+
17
+ /** Mirrors model-env.mjs PROVIDER_APIS — the server validates; this is the form. */
18
+ export const PROVIDER_APIS = { copilot: ['anthropic', 'openai-chat'], openai: ['openai-chat'], anthropic: ['anthropic'] };
19
+ export const PROVIDER_LABELS = { copilot: 'GitHub Copilot', openai: 'OpenAI-compatible', anthropic: 'Anthropic-compatible' };
20
+ export const API_LABELS = { anthropic: 'Anthropic Messages', 'openai-chat': 'OpenAI chat completions' };
21
+ export const CAPABILITY_FLAGS = [['toolCalls', 'Tool calls'], ['vision', 'Vision'], ['reasoning', 'Reasoning']];
22
+
23
+ /** The notice every Copilot sign-in must acknowledge (§8.2). One source of truth: the UI modal and the CLI print it. */
24
+ export const COPILOT_TERMS = Object.freeze({
25
+ title: 'Using GitHub Copilot from Worca',
26
+ body: [
27
+ "Worca will talk to GitHub Copilot through the same API GitHub's editor extensions use, identifying itself as an editor client. GitHub's terms allow Copilot only through supported clients, and GitHub has suspended Copilot access for automated or unsupported use. Pipelines are automated, high-volume use.",
28
+ '• Your GitHub account, not Worca, carries this risk.',
29
+ '• Worca caps concurrent requests (configurable) and never signs in without you.',
30
+ '• Premium-request quotas apply per your plan; Worca shows usage but cannot enforce it.',
31
+ ].join('\n\n'),
32
+ checkbox: 'I understand and want to continue',
33
+ confirm: 'Continue',
34
+ });
35
+
36
+ /** One-line degradation copy for a translated (openai-chat) model, '' otherwise (§8.5). */
37
+ export function degradationLine(m) {
38
+ if (!m || !m.upstream || m.upstream.api !== 'openai-chat') return '';
39
+ const caps = m.upstream.capabilities || {};
40
+ const limit = caps.maxPromptTokens ? `, prompt limit ~${Math.round(caps.maxPromptTokens / 1000)}k tokens` : '';
41
+ return `translated — no thinking blocks, no WebSearch/WebFetch${limit}`;
42
+ }
43
+
44
+ /** The `bridged: <provider>` badge for a catalog card, or null. */
45
+ export function bridgedBadge(m, { doc = globalThis.document } = {}) {
46
+ if (!m || !m.bridged) return null;
47
+ const b = h(doc, 'span', 'badge blue mv-bridged', `bridged: ${m.bridged}`);
48
+ b.title = `Worca's in-process bridge forwards this model's calls to ${PROVIDER_LABELS[m.bridged] || m.bridged}${m.upstream ? ` as ${m.upstream.model} (${API_LABELS[m.upstream.api] || m.upstream.api})` : ''}.`;
49
+ return b;
50
+ }
51
+
52
+ /** The blocking "needs sign-in" pill + button for a card, or null (§8.5). */
53
+ export function needsSignInPill(m, { doc = globalThis.document } = {}) {
54
+ if (!m || !m.needsSignIn) return null;
55
+ const wrap = h(doc, 'span', 'mv-needs-signin');
56
+ const b = h(doc, 'span', 'badge red mv-needs-signin-badge', m.signInReason === 'terms' ? 'needs acknowledgement' : m.signInReason === 'no_key' ? 'needs API key' : 'needs sign-in');
57
+ b.title = m.signInMessage || 'The provider behind this model is not usable yet.';
58
+ wrap.appendChild(b);
59
+ const btn = h(doc, 'button', 'btn-ghost mv-signin', m.signInReason === 'no_key' ? 'Set key' : 'Sign in');
60
+ btn.type = 'button';
61
+ btn.dataset.provider = m.bridged || '';
62
+ wrap.appendChild(btn);
63
+ return wrap;
64
+ }
65
+
66
+ // ── Providers card (§8.1) ────────────────────────────────────────────────────
67
+
68
+ function providerStatePill(doc, c) {
69
+ if (!c.termsCurrent) return h(doc, 'span', 'badge grey', 'sign-in blocked until acknowledged');
70
+ if (!c.connected) return h(doc, 'span', 'badge grey', 'not connected');
71
+ return h(doc, 'span', 'badge green', `connected${c.login ? ` as @${c.login}` : ''}`);
72
+ }
73
+
74
+ function quotaLine(q) {
75
+ if (!q) return '';
76
+ if (q.unlimited) return 'Premium requests: unlimited on this plan';
77
+ if (q.used != null && q.entitlement != null) return `Premium requests used this month: ${q.used} / ${q.entitlement}${q.resetDate ? ` (resets ${q.resetDate})` : ''}`;
78
+ if (q.remaining != null) return `Premium requests remaining this month: ${q.remaining}`;
79
+ return '';
80
+ }
81
+
82
+ function numberField(doc, cls, labelText, value, { min = 1, max = 64, hint = '', provider } = {}) {
83
+ const wrap = h(doc, 'label', 'mv-field');
84
+ wrap.appendChild(h(doc, 'span', 'mv-field-label', labelText));
85
+ const inp = h(doc, 'input', `input ${cls}`);
86
+ inp.type = 'number'; inp.min = String(min); inp.max = String(max); inp.step = '1';
87
+ inp.value = value == null ? '' : String(value);
88
+ if (provider) inp.dataset.provider = provider;
89
+ wrap.appendChild(inp);
90
+ if (hint) wrap.appendChild(h(doc, 'small', 'hint', hint));
91
+ return wrap;
92
+ }
93
+
94
+ /**
95
+ * The Providers card. `providers` is GET /api/providers' payload (never a
96
+ * token). `signIn` is an in-flight device-flow {userCode, verificationUri,
97
+ * status, error} or null; app.js keeps it across repaints.
98
+ */
99
+ export function renderProvidersCard(providers, { doc = globalThis.document, signIn = null, split = false } = {}) {
100
+ const p = providers || {};
101
+ const c = p.copilot || { connected: false, termsCurrent: false, accountType: 'individual', maxConcurrent: 4 };
102
+ // On the Providers TAB each provider is its own card: three unrelated accounts stacked in one
103
+ // card read as one long form, and the page has room for them now. Inside the Models view (the
104
+ // old home) it stays a single card. Either way the rows keep their shape, so every flow that
105
+ // finds .mv-pv-row[data-provider] is unaffected.
106
+ const root = h(doc, split ? 'div' : 'section', split ? 'mv-providers mv-providers-split' : 'card mv-providers');
107
+ if (!split) {
108
+ const head = h(doc, 'div', 'mv-head');
109
+ head.appendChild(h(doc, 'h3', 'mv-section-title', 'Providers'));
110
+ root.appendChild(head);
111
+ }
112
+ root.appendChild(h(doc, 'small', 'hint mv-providers-hint',
113
+ "Providers let Worca run models that don't speak the Anthropic API — through its own in-process bridge. Sign in or set a key once; then import or add models on the Models tab and pick them anywhere a model is picked."));
114
+ /** One provider's row, in its own card when the tab hosts it. */
115
+ const place = (row, title) => {
116
+ if (!split) { root.appendChild(row); return; }
117
+ const card = h(doc, 'section', 'card mv-pv-card');
118
+ const head = h(doc, 'div', 'mv-head');
119
+ head.appendChild(h(doc, 'h3', 'mv-section-title', title));
120
+ card.appendChild(head);
121
+ card.appendChild(row);
122
+ root.appendChild(card);
123
+ };
124
+
125
+ // ── GitHub Copilot ──
126
+ const cp = h(doc, 'div', 'mv-pv-row');
127
+ cp.dataset.provider = 'copilot';
128
+ const cpMain = h(doc, 'div', 'mv-pv-main');
129
+ const cpHead = h(doc, 'div', 'mv-head');
130
+ cpHead.appendChild(h(doc, 'b', 'mv-name', 'GitHub Copilot'));
131
+ cpHead.appendChild(providerStatePill(doc, c));
132
+ cpMain.appendChild(cpHead);
133
+ cpMain.appendChild(h(doc, 'small', 'hint',
134
+ c.acknowledgedTerms
135
+ ? `Notice acknowledged on ${String(c.acknowledgedTerms).slice(0, 10)}${c.termsCurrent ? '' : ' — the wording changed, please re-read'}. Claude models run through Copilot's native Anthropic endpoint (thinking intact); other vendors run through a translation layer.`
136
+ : 'Uses your Copilot subscription for GPT, Gemini, Grok and Claude models. Sign in reads the GitHub notice first.'));
137
+ if (c.tokenSource === 'env') cpMain.appendChild(h(doc, 'small', 'hint', `Token read from your shell env (${c.tokenRef}).`));
138
+ const flow = h(doc, 'div', 'mv-cp-flow');
139
+ if (signIn) flow.appendChild(renderCopilotSignIn(signIn, { doc }));
140
+ cpMain.appendChild(flow);
141
+ const quota = h(doc, 'small', 'hint mv-cp-quota', quotaLine(c.quota));
142
+ cpMain.appendChild(quota);
143
+ cpMain.appendChild(h(doc, 'small', 'hint mv-pv-msg'));
144
+ cp.appendChild(cpMain);
145
+
146
+ const cpCtl = h(doc, 'div', 'mv-pv-ctl');
147
+ const acct = h(doc, 'label', 'mv-field');
148
+ acct.appendChild(h(doc, 'span', 'mv-field-label', 'Account'));
149
+ const sel = h(doc, 'select', 'select mv-cp-account');
150
+ for (const [v, t] of [['individual', 'Individual'], ['business', 'Business'], ['enterprise', 'Enterprise']]) {
151
+ const o = h(doc, 'option', null, t); o.value = v; if (v === c.accountType) o.selected = true; sel.appendChild(o);
152
+ }
153
+ acct.appendChild(sel);
154
+ acct.appendChild(h(doc, 'small', 'hint', 'Picks the Copilot API host. The sign-in itself reports the host it was issued for and wins when it does.'));
155
+ cpCtl.appendChild(acct);
156
+ cpCtl.appendChild(numberField(doc, 'mv-pv-conc', 'Max concurrent requests', c.maxConcurrent, {
157
+ provider: 'copilot',
158
+ hint: "Fan-out children beyond this wait their turn. The one knob that lowers GitHub's abuse-detection risk — keep it low.",
159
+ }));
160
+ cp.appendChild(cpCtl);
161
+
162
+ const cpBtns = h(doc, 'div', 'mv-pv-btns');
163
+ const imp = h(doc, 'button', 'btn-ghost mv-cp-fetch-models', 'Import models…');
164
+ imp.type = 'button'; imp.disabled = !c.connected;
165
+ if (!c.connected) imp.title = 'Sign in first';
166
+ cpBtns.appendChild(imp);
167
+ const quotaBtn = h(doc, 'button', 'btn-ghost mv-cp-quota-refresh', 'Refresh usage');
168
+ quotaBtn.type = 'button'; quotaBtn.disabled = !c.connected;
169
+ cpBtns.appendChild(quotaBtn);
170
+ if (c.connected) {
171
+ const outBtn = h(doc, 'button', 'btn-ghost mv-cp-signout', 'Sign out');
172
+ outBtn.type = 'button';
173
+ cpBtns.appendChild(outBtn);
174
+ } else if (!signIn) {
175
+ const inBtn = h(doc, 'button', 'btn-go mv-cp-signin', 'Sign in…');
176
+ inBtn.type = 'button';
177
+ cpBtns.appendChild(inBtn);
178
+ }
179
+ const terms = h(doc, 'button', 'btn-ghost mv-cp-terms', c.acknowledgedTerms ? 'Re-read notice' : 'Read notice');
180
+ terms.type = 'button';
181
+ cpBtns.appendChild(terms);
182
+ cp.appendChild(cpBtns);
183
+ place(cp, 'GitHub Copilot');
184
+
185
+ // ── key-based providers ──
186
+ for (const name of ['openai', 'anthropic']) {
187
+ const k = p[name] || { configured: false, keySet: false, baseUrl: '', maxConcurrent: 8 };
188
+ const row = h(doc, 'div', 'mv-pv-row');
189
+ row.dataset.provider = name;
190
+ const main = h(doc, 'div', 'mv-pv-main');
191
+ const rh = h(doc, 'div', 'mv-head');
192
+ rh.appendChild(h(doc, 'b', 'mv-name', PROVIDER_LABELS[name]));
193
+ rh.appendChild(k.configured ? h(doc, 'span', 'badge green', 'key set')
194
+ : k.keySet ? h(doc, 'span', 'badge red', 'key ${VAR} not set')
195
+ : k.keyOptional ? h(doc, 'span', 'badge green', 'local — no key needed') : h(doc, 'span', 'badge grey', 'no key'));
196
+ main.appendChild(rh);
197
+ main.appendChild(h(doc, 'small', 'hint', name === 'openai'
198
+ ? 'OpenAI, Azure, Ollama, vLLM, Groq, an in-house gateway — anything with a /chat/completions endpoint. Models run through the translation layer (no thinking blocks, no web tools).'
199
+ : 'A gateway that already speaks the Anthropic Messages API but needs a key Worca holds for it. Calls pass through untouched.'));
200
+ if (k.keySource === 'env') main.appendChild(h(doc, 'small', 'hint', `Key read from your shell env (${k.keyRef}).`));
201
+ main.appendChild(h(doc, 'small', 'hint mv-pv-msg'));
202
+ row.appendChild(main);
203
+
204
+ const ctl = h(doc, 'div', 'mv-pv-ctl');
205
+ const bu = h(doc, 'label', 'mv-field');
206
+ bu.appendChild(h(doc, 'span', 'mv-field-label', 'Base URL'));
207
+ const buIn = h(doc, 'input', 'input mv-pv-baseurl');
208
+ buIn.type = 'text'; buIn.value = k.baseUrl || ''; buIn.dataset.provider = name;
209
+ buIn.placeholder = name === 'openai' ? 'https://api.openai.com/v1' : 'https://api.anthropic.com';
210
+ bu.appendChild(buIn);
211
+ ctl.appendChild(bu);
212
+ const key = h(doc, 'label', 'mv-field');
213
+ key.appendChild(h(doc, 'span', 'mv-field-label', 'API key'));
214
+ const keyIn = h(doc, 'input', 'input mv-pv-key');
215
+ keyIn.type = 'text'; keyIn.dataset.provider = name;
216
+ keyIn.value = k.keyRef || k.keyMasked || '';
217
+ if (keyIn.value) keyIn.dataset.original = keyIn.value;
218
+ keyIn.placeholder = 'sk-…, or ${VAR} to read your shell env';
219
+ key.appendChild(keyIn);
220
+ key.appendChild(h(doc, 'small', 'hint', 'Stored masked; leave masked to keep. A model can override this key on its Connection.'));
221
+ ctl.appendChild(key);
222
+ ctl.appendChild(numberField(doc, 'mv-pv-conc', 'Max concurrent requests', k.maxConcurrent, { provider: name }));
223
+ row.appendChild(ctl);
224
+
225
+ const btns = h(doc, 'div', 'mv-pv-btns');
226
+ // The verdict belongs NEXT TO the button that asks for it: the hint line under the description
227
+ // is in the other column, and a one-line grey answer there reads as "nothing happened".
228
+ btns.appendChild(h(doc, 'span', 'mv-pv-result', ''));
229
+ if (name === 'openai') {
230
+ // §8.4 for a server you run: ask the endpoint what it serves instead of typing model ids.
231
+ const browse = h(doc, 'button', 'btn-ghost mv-pv-browse', 'Import models…');
232
+ browse.type = 'button'; browse.dataset.provider = name;
233
+ browse.title = 'List what this endpoint serves (llama.cpp, Ollama, LM Studio, vLLM…) and import the ones you want';
234
+ btns.appendChild(browse);
235
+ }
236
+ const test = h(doc, 'button', 'btn-ghost mv-pv-test', 'Test connection');
237
+ test.type = 'button'; test.dataset.provider = name;
238
+ btns.appendChild(test);
239
+ const save = h(doc, 'button', 'btn-go mv-pv-save', 'Save');
240
+ save.type = 'button'; save.dataset.provider = name;
241
+ btns.appendChild(save);
242
+ row.appendChild(btns);
243
+ place(row, PROVIDER_LABELS[name]);
244
+ }
245
+ return root;
246
+ }
247
+
248
+ /** Collect a key-based provider row into a PATCH body (masked echo dropped). */
249
+ export function collectProviderRow(rootEl, name) {
250
+ const row = rootEl.querySelector(`.mv-pv-row[data-provider="${name}"]`);
251
+ if (!row) return null;
252
+ const body = {};
253
+ const bu = row.querySelector('.mv-pv-baseurl');
254
+ if (bu) body.baseUrl = bu.value.trim();
255
+ const key = row.querySelector('.mv-pv-key');
256
+ if (key) {
257
+ const v = key.value.trim();
258
+ if (v !== (key.dataset.original || '')) body.apiKey = v; // unchanged (masked or ref) = keep
259
+ }
260
+ const conc = row.querySelector('.mv-pv-conc');
261
+ if (conc && conc.value.trim() !== '') body.maxConcurrent = Number(conc.value);
262
+ return body;
263
+ }
264
+
265
+ /** The device-flow block: the code in large type, copy, the link, a live status line, cancel (§8.1). */
266
+ export function renderCopilotSignIn(flow, { doc = globalThis.document } = {}) {
267
+ const box = h(doc, 'div', 'mv-cp-signin-box');
268
+ box.appendChild(h(doc, 'small', 'hint', 'Enter this code on github.com to connect your Copilot account:'));
269
+ const codeRow = h(doc, 'div', 'mv-cp-code-row');
270
+ codeRow.appendChild(h(doc, 'code', 'mv-cp-code', flow.userCode || ''));
271
+ const copy = h(doc, 'button', 'btn-ghost mv-cp-copy', 'Copy code');
272
+ copy.type = 'button'; copy.dataset.code = flow.userCode || '';
273
+ codeRow.appendChild(copy);
274
+ const link = h(doc, 'a', 'btn-ghost mv-cp-open', 'Open github.com/login/device');
275
+ link.href = flow.verificationUri || 'https://github.com/login/device';
276
+ link.target = '_blank'; link.rel = 'noopener';
277
+ codeRow.appendChild(link);
278
+ box.appendChild(codeRow);
279
+ const status = h(doc, 'small', `hint mv-cp-status${flow.error ? ' err' : ''}`, flow.error || flow.status || 'Waiting for approval…');
280
+ box.appendChild(status);
281
+ const cancel = h(doc, 'button', 'btn-ghost mv-cp-cancel', flow.error ? 'Dismiss' : 'Cancel');
282
+ cancel.type = 'button';
283
+ box.appendChild(cancel);
284
+ return box;
285
+ }
286
+
287
+ // ── Import from Copilot (§8.4) ───────────────────────────────────────────────
288
+
289
+ const yesNo = (v) => (v ? '✓' : '—');
290
+ const fmtK = (n) => (n ? `${Math.round(n / 1000)}k` : '—');
291
+
292
+ /** The import sheet over GET /api/providers/copilot/models' list. */
293
+ export function renderImportSheet(models, { doc = globalThis.document } = {}) {
294
+ const root = h(doc, 'section', 'card mv-editor mvi');
295
+ root.appendChild(h(doc, 'h3', 'mv-editor-title', 'Import from GitHub Copilot'));
296
+ root.appendChild(h(doc, 'small', 'hint',
297
+ 'These run through your Copilot subscription. Claude models keep extended thinking; other vendors run through a translation layer (no thinking blocks, no web tools). Imported entries are priced free — Copilot bills premium requests, not tokens — and Worca counts requests per run.'));
298
+ const list = Array.isArray(models) ? models : [];
299
+ if (!list.length) {
300
+ root.appendChild(h(doc, 'div', 'hist-empty', 'Copilot returned no chat models for this account.'));
301
+ } else {
302
+ const tbl = h(doc, 'table', 'tm-tbl mvi-tbl');
303
+ const thead = h(doc, 'thead');
304
+ const hr = h(doc, 'tr');
305
+ const allTh = h(doc, 'th');
306
+ const all = h(doc, 'input', 'mvi-all'); all.type = 'checkbox'; all.title = 'Select all importable';
307
+ allTh.appendChild(all);
308
+ hr.appendChild(allTh);
309
+ for (const t of ['Model', 'Vendor', 'Context', 'Tools', 'Vision', 'Reasoning', 'Status']) hr.appendChild(h(doc, 'th', null, t));
310
+ thead.appendChild(hr);
311
+ tbl.appendChild(thead);
312
+ const tbody = h(doc, 'tbody');
313
+ for (const m of list) {
314
+ const tr = h(doc, 'tr');
315
+ tr.dataset.id = m.id;
316
+ const disabled = m.policyState && m.policyState !== 'enabled';
317
+ if (disabled) tr.className = 'mvi-disabled';
318
+ const cbTd = h(doc, 'td');
319
+ const cb = h(doc, 'input', 'mvi-cb'); cb.type = 'checkbox'; cb.value = m.id; cb.disabled = !!disabled;
320
+ cbTd.appendChild(cb);
321
+ tr.appendChild(cbTd);
322
+ const nameTd = h(doc, 'td');
323
+ nameTd.appendChild(h(doc, 'b', null, m.name || m.id));
324
+ nameTd.appendChild(h(doc, 'small', 'hint mvi-id', m.id));
325
+ tr.appendChild(nameTd);
326
+ tr.appendChild(h(doc, 'td', null, m.vendor || '—'));
327
+ tr.appendChild(h(doc, 'td', 'num', fmtK(m.contextWindow || m.maxPromptTokens)));
328
+ tr.appendChild(h(doc, 'td', null, yesNo(m.toolCalls)));
329
+ tr.appendChild(h(doc, 'td', null, yesNo(m.vision)));
330
+ tr.appendChild(h(doc, 'td', null, yesNo(m.reasoning)));
331
+ const status = [];
332
+ if (m.inCatalog) status.push('in catalog ✓');
333
+ if (m.preview) status.push('preview');
334
+ if (disabled) status.push('disabled — enable it in your GitHub Copilot settings');
335
+ tr.appendChild(h(doc, 'td', 'mvi-status', status.join(' · ') || '—'));
336
+ tbody.appendChild(tr);
337
+ }
338
+ tbl.appendChild(tbody);
339
+ root.appendChild(tbl);
340
+ }
341
+ const msg = h(doc, 'p', 'form-msg mvi-msg');
342
+ msg.setAttribute('aria-live', 'polite');
343
+ root.appendChild(msg);
344
+ const btns = h(doc, 'div', 'mv-editor-btns');
345
+ const go = h(doc, 'button', 'btn-go mvi-go', 'Import selected');
346
+ go.type = 'button'; go.disabled = !list.length;
347
+ const cancel = h(doc, 'button', 'btn-ghost mvi-cancel', 'Cancel');
348
+ cancel.type = 'button';
349
+ btns.appendChild(go); btns.appendChild(cancel);
350
+ root.appendChild(btns);
351
+ return root;
352
+ }
353
+
354
+ /**
355
+ * The import sheet over GET /api/providers/openai/models (§8.4): what one OpenAI-compatible
356
+ * endpoint serves. Same chrome and same checkbox contract as the Copilot sheet — collectImportSheet
357
+ * reads both — with the columns a local server decides a pipeline on: the window ONE request gets,
358
+ * tool calls, and whether the model is loaded.
359
+ */
360
+ export function renderEndpointSheet(payload, { doc = globalThis.document } = {}) {
361
+ const p = payload || {};
362
+ const list = Array.isArray(p.models) ? p.models : [];
363
+ const root = h(doc, 'section', 'card mv-editor mvi mvi-ep');
364
+ root.dataset.source = 'endpoint';
365
+ root.dataset.baseurl = p.baseUrl || '';
366
+ root.appendChild(h(doc, 'h3', 'mv-editor-title', `Import from ${p.serverLabel || 'this endpoint'}`));
367
+ root.appendChild(h(doc, 'small', 'hint', `${p.baseUrl || ''} — these run through the translation layer (no thinking blocks, no web tools) and are priced free: a model on your own machine bills nothing. Worca pins the prompt limit only when the server reports the window it really serves.`));
368
+ for (const w of Array.isArray(p.warnings) ? p.warnings : []) root.appendChild(h(doc, 'small', 'hint mvi-warn', w));
369
+ if (!list.length) {
370
+ root.appendChild(h(doc, 'div', 'hist-empty', 'This endpoint lists no models.'));
371
+ } else {
372
+ const tbl = h(doc, 'table', 'tm-tbl mvi-tbl');
373
+ const thead = h(doc, 'thead');
374
+ const hr = h(doc, 'tr');
375
+ const allTh = h(doc, 'th');
376
+ const all = h(doc, 'input', 'mvi-all'); all.type = 'checkbox'; all.title = 'Select all importable';
377
+ allTh.appendChild(all);
378
+ hr.appendChild(allTh);
379
+ for (const t of ['Model', 'Detail', 'Window', 'Tools', 'Vision', 'Status']) hr.appendChild(h(doc, 'th', null, t));
380
+ thead.appendChild(hr);
381
+ tbl.appendChild(thead);
382
+ const tbody = h(doc, 'tbody');
383
+ for (const m of list) {
384
+ const tr = h(doc, 'tr');
385
+ tr.dataset.id = m.id;
386
+ const blocked = m.importable === false;
387
+ if (blocked) tr.className = 'mvi-disabled';
388
+ const cbTd = h(doc, 'td');
389
+ const cb = h(doc, 'input', 'mvi-cb'); cb.type = 'checkbox'; cb.value = m.id; cb.disabled = blocked;
390
+ cbTd.appendChild(cb);
391
+ tr.appendChild(cbTd);
392
+ const nameTd = h(doc, 'td');
393
+ nameTd.appendChild(h(doc, 'b', null, m.name || m.id));
394
+ nameTd.appendChild(h(doc, 'small', 'hint mvi-id', m.catalogId || ''));
395
+ tr.appendChild(nameTd);
396
+ tr.appendChild(h(doc, 'td', null, m.detail || '—'));
397
+ // The served window is the one a prompt limit may be pinned from; the trained one is context.
398
+ const ctxTd = h(doc, 'td', 'num', fmtK(m.servedContext));
399
+ if (!m.servedContext && m.trainedContext) {
400
+ ctxTd.textContent = '';
401
+ ctxTd.appendChild(h(doc, 'span', 'mvi-ctx-unknown', `? · supports ${fmtK(m.trainedContext)}`));
402
+ }
403
+ tr.appendChild(ctxTd);
404
+ tr.appendChild(h(doc, 'td', null, m.toolCalls === null || m.toolCalls === undefined ? '?' : yesNo(m.toolCalls)));
405
+ tr.appendChild(h(doc, 'td', null, m.vision === null || m.vision === undefined ? '?' : yesNo(m.vision)));
406
+ const status = [];
407
+ if (m.inCatalog) status.push('in catalog ✓');
408
+ if (m.loaded === true) status.push('loaded');
409
+ else if (m.loaded === false) status.push('not loaded');
410
+ if (blocked) status.push(m.blocked || 'not importable');
411
+ tr.appendChild(h(doc, 'td', 'mvi-status', status.join(' · ') || '—'));
412
+ tbody.appendChild(tr);
413
+ }
414
+ tbl.appendChild(tbody);
415
+ root.appendChild(tbl);
416
+ }
417
+ const msg = h(doc, 'p', 'form-msg mvi-msg');
418
+ msg.setAttribute('aria-live', 'polite');
419
+ root.appendChild(msg);
420
+ const btns = h(doc, 'div', 'mv-editor-btns');
421
+ const go = h(doc, 'button', 'btn-go mvi-go', 'Import selected');
422
+ go.type = 'button'; go.disabled = !list.some((m) => m.importable !== false);
423
+ const cancel = h(doc, 'button', 'btn-ghost mvi-cancel', 'Cancel');
424
+ cancel.type = 'button';
425
+ btns.appendChild(go); btns.appendChild(cancel);
426
+ root.appendChild(btns);
427
+ return root;
428
+ }
429
+
430
+ /** The Copilot model ids ticked in an import sheet. */
431
+ export function collectImportSheet(rootEl) {
432
+ return [...rootEl.querySelectorAll('.mvi-cb')].filter((c) => c.checked && !c.disabled).map((c) => c.value);
433
+ }
434
+
435
+ /** The select-all box: tick every importable row. */
436
+ export function applyImportSelectAll(rootEl, on) {
437
+ for (const c of rootEl.querySelectorAll('.mvi-cb')) if (!c.disabled) c.checked = !!on;
438
+ }
439
+
440
+ // ── Editor: Connection section (§8.3) ───────────────────────────────────────
441
+
442
+ /**
443
+ * The Connection block for the model editor. `model` is the MASKED entry or
444
+ * null; `providers` the GET /api/providers payload (for the readiness hints);
445
+ * `copilotModels` an optional list for the upstream-id datalist.
446
+ */
447
+ export function renderConnectionSection(model, { doc = globalThis.document, providers = null, copilotModels = [] } = {}) {
448
+ const editing = !!model;
449
+ const upstream = editing && model.upstream ? model.upstream : null;
450
+ const groupName = `mv-conn-mode-${editing ? model.id : 'new'}`;
451
+ const wrap = h(doc, 'div', 'mv-conn');
452
+ wrap.dataset.provider = upstream ? upstream.provider : '';
453
+
454
+ const modes = h(doc, 'div', 'mv-conn-modes');
455
+ const startMode = upstream ? 'provider' : (editing && model.env && Object.keys(model.env).length ? 'env' : 'direct');
456
+ for (const [value, text, hint] of [
457
+ ['direct', 'Anthropic API / CLI default', "Today's behaviour: the claude CLI reaches the endpoint its own login or env names."],
458
+ ['env', 'Custom endpoint via env', 'For endpoints that already speak the Anthropic API — LiteLLM, Bedrock, Vertex, a gateway. Set the routing env below.'],
459
+ ['provider', 'Through a provider', "Worca's own bridge: GitHub Copilot, or an OpenAI-compatible endpoint. No LiteLLM needed."],
460
+ ]) {
461
+ const lab = h(doc, 'label', 'mv-conn-mode');
462
+ const rb = h(doc, 'input', 'mv-conn-mode-rb');
463
+ rb.type = 'radio'; rb.name = groupName; rb.value = value; rb.checked = value === startMode;
464
+ lab.appendChild(rb);
465
+ const txt = h(doc, 'span', 'mv-conn-mode-text');
466
+ txt.appendChild(h(doc, 'b', null, text));
467
+ txt.appendChild(h(doc, 'small', 'hint', hint));
468
+ lab.appendChild(txt);
469
+ modes.appendChild(lab);
470
+ }
471
+ wrap.appendChild(modes);
472
+
473
+ const body = h(doc, 'div', 'mv-conn-body');
474
+ const grid = h(doc, 'div', 'mv-conn-grid');
475
+ const field = (labelText, input, cls = '') => {
476
+ const f = h(doc, 'label', `mv-field${cls ? ` ${cls}` : ''}`);
477
+ f.appendChild(h(doc, 'span', 'mv-field-label', labelText));
478
+ f.appendChild(input);
479
+ return f;
480
+ };
481
+
482
+ const provSel = h(doc, 'select', 'select mv-conn-provider');
483
+ for (const name of ['copilot', 'openai', 'anthropic']) {
484
+ const o = h(doc, 'option', null, PROVIDER_LABELS[name]); o.value = name;
485
+ if (upstream ? upstream.provider === name : name === 'copilot') o.selected = true;
486
+ provSel.appendChild(o);
487
+ }
488
+ const provField = field('Provider', provSel);
489
+ provField.appendChild(h(doc, 'small', 'hint mv-conn-provider-hint'));
490
+ grid.appendChild(provField);
491
+
492
+ const apiSel = h(doc, 'select', 'select mv-conn-api');
493
+ if (upstream) apiSel.dataset.keep = upstream.api; // refilled by applyConnectionMode below
494
+ grid.appendChild(field('API', apiSel));
495
+
496
+ const modelIn = h(doc, 'input', 'input mv-conn-model');
497
+ modelIn.type = 'text'; modelIn.placeholder = 'gpt-5, claude-sonnet-4.5, …';
498
+ modelIn.value = upstream ? upstream.model : '';
499
+ if (Array.isArray(copilotModels) && copilotModels.length) {
500
+ const dl = h(doc, 'datalist');
501
+ dl.id = `mv-conn-copilot-models-${editing ? model.id : 'new'}`;
502
+ for (const m of copilotModels) {
503
+ const o = h(doc, 'option'); o.value = m.id; o.label = `${m.name || m.id} · ${m.vendor || ''}${m.contextWindow ? ` · ${fmtK(m.contextWindow)}` : ''}`;
504
+ dl.appendChild(o);
505
+ }
506
+ modelIn.setAttribute('list', dl.id);
507
+ grid.appendChild(dl);
508
+ }
509
+ const modelField = field('Upstream model id', modelIn);
510
+ modelField.appendChild(h(doc, 'small', 'hint', 'The id the endpoint expects. The model id above stays Worca’s handle.'));
511
+ grid.appendChild(modelField);
512
+ body.appendChild(grid);
513
+
514
+ // Advanced: per-entry overrides for the key-based providers.
515
+ const adv = h(doc, 'details', 'advanced mv-conn-adv');
516
+ const sum = h(doc, 'summary', null, 'Advanced — base URL, API key, extra headers');
517
+ adv.appendChild(sum);
518
+ const advBody = h(doc, 'div', 'advanced-body mv-conn-adv-body');
519
+ const bu = h(doc, 'input', 'input mv-conn-baseurl');
520
+ bu.type = 'text'; bu.placeholder = 'leave empty to use the provider’s base URL'; bu.value = upstream && upstream.baseUrl ? upstream.baseUrl : '';
521
+ advBody.appendChild(field('Base URL override', bu));
522
+ const key = h(doc, 'input', 'input mv-conn-key');
523
+ key.type = 'text'; key.placeholder = 'leave empty to use the provider’s key; or ${VAR}';
524
+ key.value = upstream && upstream.apiKey ? upstream.apiKey : '';
525
+ if (key.value) key.dataset.original = key.value;
526
+ advBody.appendChild(field('API key override', key));
527
+ const hdr = h(doc, 'textarea', 'textarea mv-conn-headers');
528
+ hdr.rows = 2; hdr.placeholder = 'One per line: Header-Name: value';
529
+ hdr.value = upstream && upstream.headers ? Object.entries(upstream.headers).map(([k, v]) => `${k}: ${v}`).join('\n') : '';
530
+ advBody.appendChild(field('Extra headers', hdr));
531
+ adv.appendChild(advBody);
532
+ body.appendChild(adv);
533
+
534
+ // Capabilities.
535
+ const caps = h(doc, 'div', 'mv-conn-caps');
536
+ const capsRow = h(doc, 'div', 'mv-conn-caps-row');
537
+ const stored = upstream && upstream.capabilities ? upstream.capabilities : {};
538
+ for (const [k, text] of CAPABILITY_FLAGS) {
539
+ const lab = h(doc, 'label', 'mv-conn-cap');
540
+ const cb = h(doc, 'input', 'mv-conn-cap-cb');
541
+ cb.type = 'checkbox'; cb.dataset.cap = k;
542
+ cb.checked = stored[k] === undefined ? (k === 'toolCalls') : !!stored[k];
543
+ lab.appendChild(cb);
544
+ lab.appendChild(h(doc, 'span', null, text));
545
+ capsRow.appendChild(lab);
546
+ }
547
+ for (const [k, text] of [['maxPromptTokens', 'Prompt limit (tokens)'], ['maxOutputTokens', 'Output limit (tokens)']]) {
548
+ const lab = h(doc, 'label', 'mv-conn-limit');
549
+ lab.appendChild(h(doc, 'span', null, text));
550
+ const inp = h(doc, 'input', 'input mv-conn-limit-in');
551
+ inp.type = 'number'; inp.min = '1'; inp.step = '1'; inp.dataset.limit = k;
552
+ inp.value = stored[k] ? String(stored[k]) : '';
553
+ lab.appendChild(inp);
554
+ capsRow.appendChild(lab);
555
+ }
556
+ caps.appendChild(capsRow);
557
+ caps.appendChild(h(doc, 'small', 'hint', 'Prefilled by Import from Copilot; edit for an unknown endpoint. Reasoning maps Worca’s effort to reasoning_effort; without it, only medium effort is offered.'));
558
+ body.appendChild(caps);
559
+ body.appendChild(h(doc, 'small', 'hint mv-conn-note'));
560
+ wrap.appendChild(body);
561
+
562
+ if (providers) wrap._providers = providers; // readiness hints (jsdom-safe expando)
563
+ applyConnectionMode(wrap);
564
+ return wrap;
565
+ }
566
+
567
+ /** The api options a provider allows; keeps the current pick when still legal. */
568
+ function refillApiSelect(sel, provider, keep) {
569
+ const apis = PROVIDER_APIS[provider] || [];
570
+ const cur = keep || sel.value;
571
+ sel.innerHTML = '';
572
+ for (const a of apis) {
573
+ const o = sel.ownerDocument.createElement('option');
574
+ o.value = a; o.textContent = API_LABELS[a] || a;
575
+ sel.appendChild(o);
576
+ }
577
+ sel.value = apis.includes(cur) ? cur : apis[0] || '';
578
+ sel.disabled = apis.length <= 1;
579
+ }
580
+
581
+ /**
582
+ * Apply the Connection rules to the form (§8.3): the provider block shows only
583
+ * in provider mode, Advanced only for key-based providers, the API list follows
584
+ * the provider, the readiness hint follows the provider's state, and the
585
+ * effort checkboxes in the SAME editor collapse to medium for a translated
586
+ * model without reasoning. The single place that knows these rules — the
587
+ * initial render and app.js's delegated `change` handler both call it.
588
+ * @param {Element} connEl the .mv-conn root (or any ancestor containing one)
589
+ */
590
+ export function applyConnectionMode(connEl) {
591
+ const conn = connEl && (connEl.classList && connEl.classList.contains('mv-conn') ? connEl : connEl.querySelector && connEl.querySelector('.mv-conn'));
592
+ if (!conn) return;
593
+ const mode = conn.querySelector('.mv-conn-mode-rb:checked')?.value || 'direct';
594
+ const body = conn.querySelector('.mv-conn-body');
595
+ if (body) body.hidden = mode !== 'provider';
596
+ const provider = conn.querySelector('.mv-conn-provider')?.value || 'copilot';
597
+ conn.dataset.provider = mode === 'provider' ? provider : '';
598
+ const apiSel = conn.querySelector('.mv-conn-api');
599
+ if (apiSel) refillApiSelect(apiSel, provider, apiSel.dataset.keep || apiSel.value);
600
+ delete apiSel?.dataset.keep;
601
+ const api = apiSel ? apiSel.value : '';
602
+ const adv = conn.querySelector('.mv-conn-adv');
603
+ if (adv) adv.hidden = provider === 'copilot';
604
+ const hint = conn.querySelector('.mv-conn-provider-hint');
605
+ const p = conn._providers;
606
+ if (hint) {
607
+ if (!p) hint.textContent = '';
608
+ else if (provider === 'copilot') hint.textContent = p.copilot?.connected ? `Connected${p.copilot.login ? ` as @${p.copilot.login}` : ''}.` : 'Not connected — sign in on the Providers card above, or Save is refused.';
609
+ else hint.textContent = p[provider]?.configured ? 'Provider key set.' : (p[provider]?.keySet ? 'The provider key’s ${VAR} is not set in Worca’s environment.' : p[provider]?.keyOptional ? 'Local endpoint — no key needed.' : 'No provider key — set one on the Providers card, or override it under Advanced.');
610
+ hint.className = `hint mv-conn-provider-hint${(provider === 'copilot' ? !p?.copilot?.connected : !(p?.[provider]?.configured || p?.[provider]?.keyOptional)) && p ? ' warn' : ''}`;
611
+ }
612
+ const note = conn.querySelector('.mv-conn-note');
613
+ if (note) {
614
+ note.textContent = mode !== 'provider' ? '' : api === 'openai-chat'
615
+ ? `Translated: no thinking blocks, WebSearch/WebFetch withheld, prompt limit per the capabilities above.${provider === 'copilot' ? ' Copilot bills premium requests, so Pricing defaults to Free.' : ''}`
616
+ : provider === 'copilot' ? 'Copilot’s native Anthropic endpoint: thinking blocks and cache accounting arrive intact. Copilot bills premium requests, so Pricing defaults to Free.'
617
+ : 'Passthrough: the request reaches the endpoint untouched; only auth and headers are added.';
618
+ }
619
+ const reasoning = !!conn.querySelector('.mv-conn-cap-cb[data-cap="reasoning"]')?.checked;
620
+ // Efforts live in the editor grid beside this block.
621
+ const editor = conn.closest ? conn.closest('.mv-editor') : null;
622
+ const effCbs = editor ? [...editor.querySelectorAll('.mv-effort-cb')] : [];
623
+ const effHint = editor ? editor.querySelector('.mv-efforts-hint') : null;
624
+ const translatedNoReasoning = mode === 'provider' && api === 'openai-chat' && !reasoning;
625
+ for (const cb of effCbs) {
626
+ if (translatedNoReasoning) {
627
+ cb.disabled = cb.value !== 'medium';
628
+ if (cb.value !== 'medium') cb.checked = false; else cb.checked = true;
629
+ } else cb.disabled = false;
630
+ }
631
+ if (effHint) {
632
+ effHint.textContent = translatedNoReasoning
633
+ ? 'This model has no reasoning control; effort would be ignored — only medium is offered.'
634
+ : mode === 'provider' && api === 'openai-chat' ? 'Maps to reasoning_effort low / medium / high — xhigh and max are not distinct.'
635
+ : 'All checked = every effort (the default).';
636
+ }
637
+ }
638
+
639
+ /** Load a stored upstream into a rendered Connection block (Edit a copy, import prefill). */
640
+ export function setModelUpstream(connEl, upstream) {
641
+ const conn = connEl.classList && connEl.classList.contains('mv-conn') ? connEl : connEl.querySelector('.mv-conn');
642
+ if (!conn) return;
643
+ const mode = upstream ? 'provider' : 'direct';
644
+ for (const rb of conn.querySelectorAll('.mv-conn-mode-rb')) rb.checked = rb.value === mode;
645
+ if (upstream) {
646
+ const prov = conn.querySelector('.mv-conn-provider'); if (prov) prov.value = upstream.provider;
647
+ const api = conn.querySelector('.mv-conn-api'); if (api) api.dataset.keep = upstream.api;
648
+ const model = conn.querySelector('.mv-conn-model'); if (model) model.value = upstream.model || '';
649
+ const bu = conn.querySelector('.mv-conn-baseurl'); if (bu) bu.value = upstream.baseUrl || '';
650
+ const key = conn.querySelector('.mv-conn-key'); if (key) { key.value = upstream.apiKey || ''; if (key.value) key.dataset.original = key.value; }
651
+ const hdr = conn.querySelector('.mv-conn-headers'); if (hdr) hdr.value = upstream.headers ? Object.entries(upstream.headers).map(([k, v]) => `${k}: ${v}`).join('\n') : '';
652
+ const caps = upstream.capabilities || {};
653
+ for (const cb of conn.querySelectorAll('.mv-conn-cap-cb')) cb.checked = caps[cb.dataset.cap] === undefined ? cb.dataset.cap === 'toolCalls' : !!caps[cb.dataset.cap];
654
+ for (const inp of conn.querySelectorAll('.mv-conn-limit-in')) inp.value = caps[inp.dataset.limit] ? String(caps[inp.dataset.limit]) : '';
655
+ }
656
+ applyConnectionMode(conn);
657
+ }
658
+
659
+ /**
660
+ * Collect the Connection block: `{ upstream }` where upstream is the object to
661
+ * store, or null to clear (the form shows the truth, like Pricing's 'cli').
662
+ * A masked key echo is sent as-is; the server treats it as "keep".
663
+ */
664
+ export function collectConnection(connEl) {
665
+ const conn = connEl.classList && connEl.classList.contains('mv-conn') ? connEl : connEl.querySelector('.mv-conn');
666
+ if (!conn) return { upstream: undefined };
667
+ const mode = conn.querySelector('.mv-conn-mode-rb:checked')?.value || 'direct';
668
+ if (mode !== 'provider') return { upstream: null };
669
+ const provider = conn.querySelector('.mv-conn-provider')?.value || 'copilot';
670
+ const api = conn.querySelector('.mv-conn-api')?.value || '';
671
+ const model = (conn.querySelector('.mv-conn-model')?.value || '').trim();
672
+ const upstream = { provider, api, model };
673
+ if (provider !== 'copilot') {
674
+ const bu = (conn.querySelector('.mv-conn-baseurl')?.value || '').trim();
675
+ if (bu) upstream.baseUrl = bu;
676
+ const key = (conn.querySelector('.mv-conn-key')?.value || '').trim();
677
+ if (key) upstream.apiKey = key;
678
+ const lines = (conn.querySelector('.mv-conn-headers')?.value || '').split('\n').map((l) => l.trim()).filter(Boolean);
679
+ const headers = {};
680
+ for (const l of lines) {
681
+ const i = l.indexOf(':');
682
+ if (i > 0) headers[l.slice(0, i).trim()] = l.slice(i + 1).trim();
683
+ }
684
+ if (Object.keys(headers).length) upstream.headers = headers;
685
+ }
686
+ const capabilities = {};
687
+ for (const cb of conn.querySelectorAll('.mv-conn-cap-cb')) capabilities[cb.dataset.cap] = !!cb.checked;
688
+ for (const inp of conn.querySelectorAll('.mv-conn-limit-in')) {
689
+ const v = (inp.value || '').trim();
690
+ if (v !== '') capabilities[inp.dataset.limit] = Number(v);
691
+ }
692
+ upstream.capabilities = capabilities;
693
+ return { upstream };
694
+ }