cli-jaw 2.2.11 → 2.2.12

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 (115) hide show
  1. package/dist/bin/commands/doctor.js +20 -0
  2. package/dist/bin/commands/doctor.js.map +1 -1
  3. package/dist/bin/commands/init.js +7 -0
  4. package/dist/bin/commands/init.js.map +1 -1
  5. package/dist/bin/commands/slack.js +47 -5
  6. package/dist/bin/commands/slack.js.map +1 -1
  7. package/dist/bin/commands/tui/slash-command-runner.js +11 -2
  8. package/dist/bin/commands/tui/slash-command-runner.js.map +1 -1
  9. package/dist/server.js +18 -2
  10. package/dist/server.js.map +1 -1
  11. package/dist/src/cli/commands.js +68 -15
  12. package/dist/src/cli/commands.js.map +1 -1
  13. package/dist/src/cli/opencodex-models.js +67 -14
  14. package/dist/src/cli/opencodex-models.js.map +1 -1
  15. package/dist/src/cli/registry-live.js +55 -2
  16. package/dist/src/cli/registry-live.js.map +1 -1
  17. package/dist/src/cli/registry.js +8 -2
  18. package/dist/src/cli/registry.js.map +1 -1
  19. package/dist/src/core/config.js +26 -1
  20. package/dist/src/core/config.js.map +1 -1
  21. package/dist/src/messaging/channel-health.js +6 -0
  22. package/dist/src/messaging/channel-health.js.map +1 -1
  23. package/dist/src/messaging/channel-validate.js +85 -0
  24. package/dist/src/messaging/channel-validate.js.map +1 -0
  25. package/dist/src/routes/messaging.js +9 -0
  26. package/dist/src/routes/messaging.js.map +1 -1
  27. package/dist/src/routes/system.js +5 -0
  28. package/dist/src/routes/system.js.map +1 -1
  29. package/dist/src/slack/api.js +32 -3
  30. package/dist/src/slack/api.js.map +1 -1
  31. package/dist/src/slack/bot.js +37 -1
  32. package/dist/src/slack/bot.js.map +1 -1
  33. package/dist/src/slack/events.js +23 -0
  34. package/dist/src/slack/events.js.map +1 -1
  35. package/dist/src/slack/hot-notify.js +33 -0
  36. package/dist/src/slack/hot-notify.js.map +1 -0
  37. package/dist/src/slack/manifest.js +2 -1
  38. package/dist/src/slack/manifest.js.map +1 -1
  39. package/dist/src/slack/progress.js +98 -0
  40. package/dist/src/slack/progress.js.map +1 -0
  41. package/dist/src/slack/send-only-client.js +3 -2
  42. package/dist/src/slack/send-only-client.js.map +1 -1
  43. package/dist/src/slack/slack-file.js +4 -2
  44. package/dist/src/slack/slack-file.js.map +1 -1
  45. package/package.json +1 -1
  46. package/public/css/sidebar.css +200 -0
  47. package/public/dist/assets/Agent-BjbZVZ9p.js +1 -0
  48. package/public/dist/assets/{Heartbeat-vDGhPfct.js → Heartbeat-DDmZARWw.js} +1 -1
  49. package/public/dist/assets/{Memory-B_UZ-iCA.js → Memory-CUbviW7N.js} +1 -1
  50. package/public/dist/assets/ModelProvider-ngg8xTuB.js +1 -0
  51. package/public/dist/assets/{agent-meta-C8DqYXM6.js → agent-meta-Y3DE6sxh.js} +1 -1
  52. package/public/dist/assets/app-BzD_ZLvt.js +55 -0
  53. package/public/dist/assets/{app-Nu2VPaO5.css → app-DX4e6isX.css} +1 -1
  54. package/public/dist/assets/bgtask-badge-Dqbp-Y4e.js +1 -0
  55. package/public/dist/assets/{bgtask-badge-DXca8E7c.js → bgtask-badge-qMkKOZx0.js} +1 -1
  56. package/public/dist/assets/{constants-CSZ011-j.js → constants-Df7-7Rky.js} +1 -1
  57. package/public/dist/assets/{employees-BL4qMxJj.js → employees-BnobWM_2.js} +1 -1
  58. package/public/dist/assets/{iframe-renderer-DUI0LYBy.js → iframe-renderer-C5vRbbl9.js} +2 -2
  59. package/public/dist/assets/iframe-renderer-Df4KIVyx.js +1 -0
  60. package/public/dist/assets/{manager-B-gpf-3j.js → manager-DL1GygQL.js} +1 -1
  61. package/public/dist/assets/memory-D-bULTHl.js +1 -0
  62. package/public/dist/assets/{memory-B_Y0L0KD.js → memory-DF4kPwwC.js} +1 -1
  63. package/public/dist/assets/{message-history-Dl8dJzez.js → message-history-C4WOisGx.js} +1 -1
  64. package/public/dist/assets/message-history-ZBoza6ew.js +1 -0
  65. package/public/dist/assets/{render-BI1aqKoc.js → render-DYX6GRQ0.js} +1 -1
  66. package/public/dist/assets/{settings-B0Sj4sUH.js → settings-DbJpGoaX.js} +1 -1
  67. package/public/dist/assets/settings-SreihUje.js +1 -0
  68. package/public/dist/assets/settings-core-B1Y5Nb4Z.js +1 -0
  69. package/public/dist/assets/settings-core-DobZWIZ7.js +140 -0
  70. package/public/dist/assets/{sidebar-DOR9fTgN.js → sidebar-Cp22g0Gv.js} +3 -3
  71. package/public/dist/assets/skills-DwPIP08e.js +1 -0
  72. package/public/dist/assets/{skills-wt0fkV9Y.js → skills-IGquen_j.js} +1 -1
  73. package/public/dist/assets/slash-commands-BBVfwcnW.js +1 -0
  74. package/public/dist/assets/{slash-commands-BZbqTg3v.js → slash-commands-ZRDOL99E.js} +1 -1
  75. package/public/dist/assets/{trace-drawer-kMYzaVFW.js → trace-drawer-D8To8fYt.js} +1 -1
  76. package/public/dist/assets/ui-CAicpUZc.js +1 -0
  77. package/public/dist/assets/{ui-D4D5HUfo.js → ui-Hx_mNoDf.js} +1 -1
  78. package/public/dist/index.html +90 -47
  79. package/public/dist/manager/index.html +1 -1
  80. package/public/index.html +88 -45
  81. package/public/js/constants.ts +50 -0
  82. package/public/js/features/channel-onboarding-flow.ts +197 -0
  83. package/public/js/features/channel-onboarding.ts +322 -0
  84. package/public/js/features/channel-setup-guide.ts +26 -0
  85. package/public/js/features/channel-setup-rules.ts +32 -0
  86. package/public/js/features/notifications.ts +38 -0
  87. package/public/js/features/settings-channel.ts +4 -0
  88. package/public/js/features/settings-core.ts +73 -9
  89. package/public/js/features/settings-discord.ts +2 -0
  90. package/public/js/features/settings-slack.ts +60 -2
  91. package/public/js/features/settings-telegram.ts +2 -0
  92. package/public/js/features/settings-types.ts +1 -1
  93. package/public/js/features/settings.ts +1 -1
  94. package/public/js/features/transport-status-row.ts +7 -3
  95. package/public/js/main.ts +5 -1
  96. package/public/locales/en.json +65 -3
  97. package/public/locales/ja.json +76 -3
  98. package/public/locales/ko.json +65 -3
  99. package/public/locales/zh.json +76 -3
  100. package/public/manager/src/settings/pages/Agent.tsx +25 -4
  101. package/public/manager/src/settings/pages/components/PerCliRow.tsx +13 -5
  102. package/public/manager/src/settings/pages/components/agent/agent-meta.ts +98 -0
  103. package/public/dist/assets/Agent-BgkP4EnS.js +0 -1
  104. package/public/dist/assets/ModelProvider-CgUvs6pE.js +0 -1
  105. package/public/dist/assets/app-BGIAy4he.js +0 -55
  106. package/public/dist/assets/bgtask-badge-GRSiNILQ.js +0 -1
  107. package/public/dist/assets/iframe-renderer-BUSk0Rw9.js +0 -1
  108. package/public/dist/assets/memory-CX6YahTc.js +0 -1
  109. package/public/dist/assets/message-history-Det7u76S.js +0 -1
  110. package/public/dist/assets/settings-C7NutP6X.js +0 -1
  111. package/public/dist/assets/settings-core-CyX-px6_.js +0 -100
  112. package/public/dist/assets/settings-core-DVpFmrhY.js +0 -1
  113. package/public/dist/assets/skills-BWhKreQ2.js +0 -1
  114. package/public/dist/assets/slash-commands-Di4NDA4w.js +0 -1
  115. package/public/dist/assets/ui-BghexWrB.js +0 -1
@@ -0,0 +1,322 @@
1
+ // ── Channel Onboarding Wizard (UI shell) ──
2
+ // Four numbered steps for every channel: 안내 → 자격 증명 입력 → 연결 검증 →
3
+ // 저장. Each step is gated by channel-onboarding-flow.ts, so "다음" refuses to
4
+ // move while that step's own contract is unmet, and draft values survive
5
+ // moving back and forth. This module owns rendering and IO only.
6
+ import { api, apiJson } from '../api.js';
7
+ import { t } from './i18n.js';
8
+ import { escapeHtml } from '../render.js';
9
+ import { refreshTransportStatusRow } from './transport-status-row.js';
10
+ import {
11
+ TOTAL_STEPS,
12
+ ISSUER_URLS,
13
+ advance,
14
+ applyValidation,
15
+ blockerForStep,
16
+ canAdvance,
17
+ createFlow,
18
+ fieldsFor,
19
+ goBack,
20
+ markSaved,
21
+ setField,
22
+ settingsPatch,
23
+ validationPayload,
24
+ type FlowState,
25
+ type OnboardChannel,
26
+ } from './channel-onboarding-flow.js';
27
+ import { maybeRequestNotificationPermission } from './notifications.js';
28
+
29
+ export type { OnboardChannel };
30
+
31
+ let overlay: HTMLDivElement | null = null;
32
+ let flow: FlowState | null = null;
33
+ let validating = false;
34
+ let saving = false;
35
+ /**
36
+ * Bumped whenever the wizard is (re)opened. An in-flight validation compares
37
+ * it on return: switching to another channel — or reopening the same one —
38
+ * must not let a stale response mark the new flow as verified.
39
+ */
40
+ let flowGeneration = 0;
41
+
42
+ export function initChannelOnboarding(): void {
43
+ document.addEventListener('click', (ev) => {
44
+ const btn = (ev.target as HTMLElement | null)?.closest('[data-onboard-channel]') as HTMLElement | null;
45
+ if (!btn) return;
46
+ ev.preventDefault();
47
+ openChannelOnboarding(btn.getAttribute('data-onboard-channel') as OnboardChannel);
48
+ });
49
+ // Capture phase, matching help-dialog: whichever overlay is open handles
50
+ // Escape and stops it there, so the two never both react.
51
+ document.addEventListener('keydown', (ev) => {
52
+ if (ev.key !== 'Escape' || !flow) return;
53
+ ev.preventDefault();
54
+ ev.stopImmediatePropagation();
55
+ close();
56
+ }, true);
57
+ // Focus trap: a modal that lets Tab wander into the page behind it is a
58
+ // keyboard dead end — the user cannot see what is focused and cannot get
59
+ // back without a mouse.
60
+ document.addEventListener('keydown', (ev) => {
61
+ if (ev.key !== 'Tab' || !flow || !overlay) return;
62
+ const focusable = [...overlay.querySelectorAll<HTMLElement>(
63
+ 'button:not([disabled]), input:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])',
64
+ )];
65
+ if (!focusable.length) return;
66
+ const first = focusable[0]!;
67
+ const last = focusable[focusable.length - 1]!;
68
+ const active = document.activeElement as HTMLElement | null;
69
+ if (ev.shiftKey && (active === first || !overlay.contains(active))) {
70
+ ev.preventDefault();
71
+ last.focus();
72
+ } else if (!ev.shiftKey && active === last) {
73
+ ev.preventDefault();
74
+ first.focus();
75
+ }
76
+ }, true);
77
+ }
78
+
79
+ export function openChannelOnboarding(channel: OnboardChannel): void {
80
+ if (channel !== 'telegram' && channel !== 'discord' && channel !== 'slack') return;
81
+ // Seed the draft from whatever the settings section already holds, so an
82
+ // existing token does not have to be retyped.
83
+ const draft: Record<string, string> = {};
84
+ for (const field of fieldsFor(channel)) {
85
+ draft[field.key] = readSettingsInput(field.settingsId);
86
+ }
87
+ flow = createFlow(channel, draft);
88
+ validating = false;
89
+ saving = false;
90
+ flowGeneration += 1;
91
+ ensureOverlay();
92
+ render();
93
+ overlay?.classList.add('open');
94
+ }
95
+
96
+ function close(): void {
97
+ overlay?.classList.remove('open');
98
+ flow = null;
99
+ }
100
+
101
+ function readSettingsInput(id: string): string {
102
+ return (document.getElementById(id) as HTMLInputElement | null)?.value.trim() || '';
103
+ }
104
+
105
+ function writeSettingsInput(id: string, value: string): void {
106
+ const el = document.getElementById(id) as HTMLInputElement | null;
107
+ if (el) el.value = value;
108
+ }
109
+
110
+ function ensureOverlay(): void {
111
+ if (overlay) return;
112
+ overlay = document.createElement('div');
113
+ overlay.className = 'modal-overlay onboarding-overlay';
114
+ overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); });
115
+ document.body.appendChild(overlay);
116
+ }
117
+
118
+ function stepBody(state: FlowState): string {
119
+ if (state.step === 1) {
120
+ return `
121
+ <p class="onboarding-guide">${escapeHtml(t(`onboarding.guide.${state.channel}`))}</p>
122
+ <div class="onboarding-actions">
123
+ <button type="button" class="perm-btn" data-onboard-issuer="1">
124
+ ${escapeHtml(t('onboarding.openIssuer'))}
125
+ </button>
126
+ </div>`;
127
+ }
128
+ if (state.step === 2) {
129
+ return fieldsFor(state.channel).map((field) => {
130
+ const label = t(`onboarding.token.${field.key}`) + (field.optional ? ` (${t('onboarding.optional')})` : '');
131
+ return `
132
+ <div class="onboarding-field">
133
+ <label for="onboard-${field.key}">${escapeHtml(label)}</label>
134
+ <input id="onboard-${field.key}" data-onboard-field="${field.key}" class="input-sm"
135
+ type="${field.secret ? 'password' : 'text'}"
136
+ placeholder="${escapeHtml(field.example)}"
137
+ autocomplete="off" spellcheck="false"
138
+ value="${escapeHtml(state.draft[field.key] || '')}">
139
+ <span class="onboarding-hint">${escapeHtml(t(`onboarding.hint.${state.channel}.${field.key}`))}</span>
140
+ </div>`;
141
+ }).join('');
142
+ }
143
+ if (state.step === 3) {
144
+ const status = validating
145
+ ? `<p class="onboarding-guide">${escapeHtml(t('onboarding.validating'))}</p>`
146
+ : state.validatedIdentity
147
+ ? `<p class="onboarding-identity">${escapeHtml(t('onboarding.valid', { identity: state.validatedIdentity }))}</p>`
148
+ : `<p class="onboarding-guide">${escapeHtml(t('onboarding.validateHint'))}</p>`;
149
+ return `${status}
150
+ <div class="onboarding-actions">
151
+ <button type="button" class="perm-btn" data-onboard-validate="1" ${validating ? 'disabled' : ''}>
152
+ ${escapeHtml(t('onboarding.validate'))}
153
+ </button>
154
+ </div>`;
155
+ }
156
+ return `<p class="onboarding-next">${escapeHtml(t(
157
+ state.saved ? `onboarding.next.${state.channel}` : 'onboarding.saveHint',
158
+ ))}</p>`;
159
+ }
160
+
161
+ function footer(state: FlowState): string {
162
+ if (state.step === TOTAL_STEPS && state.saved) {
163
+ return `<button type="button" class="perm-btn active" data-onboard-close="1">${escapeHtml(t('onboarding.close'))}</button>`;
164
+ }
165
+ const back = state.step > 1
166
+ ? `<button type="button" class="perm-btn" data-onboard-back="1">${escapeHtml(t('onboarding.back'))}</button>`
167
+ : '';
168
+ const primary = state.step === TOTAL_STEPS
169
+ ? `<button type="button" class="perm-btn active" data-onboard-save="1">${escapeHtml(t('onboarding.save'))}</button>`
170
+ : `<button type="button" class="perm-btn ${canAdvance(state) ? 'active' : ''}" data-onboard-next="1">${escapeHtml(t('onboarding.next'))}</button>`;
171
+ return `${back}${primary}`;
172
+ }
173
+
174
+ function render(): void {
175
+ if (!overlay || !flow) return;
176
+ const state = flow;
177
+ overlay.innerHTML = `
178
+ <div class="modal-box onboarding-box" role="dialog" aria-modal="true"
179
+ aria-labelledby="onboarding-title" aria-describedby="onboarding-step-title">
180
+ <div class="onboarding-head">
181
+ <span class="onboarding-title" id="onboarding-title">${escapeHtml(t(`onboarding.title.${state.channel}`))}</span>
182
+ <span class="onboarding-progress" role="status" aria-live="polite"
183
+ aria-label="${escapeHtml(t('onboarding.progressLabel', { step: state.step, total: TOTAL_STEPS }))}">${state.step}/${TOTAL_STEPS}</span>
184
+ <button type="button" class="help-trigger onboarding-close" data-onboard-close="1"
185
+ aria-label="${escapeHtml(t('onboarding.close'))}">✕</button>
186
+ </div>
187
+ <div class="onboarding-steps" aria-hidden="true">
188
+ ${Array.from({ length: TOTAL_STEPS }, (_, i) =>
189
+ `<span class="onboarding-dot${i + 1 === state.step ? ' is-active' : ''}${i + 1 < state.step ? ' is-done' : ''}"></span>`).join('')}
190
+ </div>
191
+ <div class="onboarding-step-title" id="onboarding-step-title">${escapeHtml(t(`onboarding.step.${state.step}`))}</div>
192
+ ${stepBody(state)}
193
+ <div class="onboarding-error" role="alert" style="display:${state.error ? '' : 'none'}">
194
+ ${state.error ? escapeHtml(t(`onboarding.error.${state.error}`)) : ''}
195
+ ${state.missingScopes.length ? `<span class="onboarding-scopes">${escapeHtml(state.missingScopes.join(', '))}</span>` : ''}
196
+ </div>
197
+ <div class="onboarding-actions onboarding-footer">${footer(state)}</div>
198
+ </div>`;
199
+
200
+ overlay.querySelectorAll('[data-onboard-close]').forEach(el => el.addEventListener('click', close));
201
+ overlay.querySelector('[data-onboard-issuer]')?.addEventListener('click', () => {
202
+ if (flow) window.open(ISSUER_URLS[flow.channel], '_blank', 'noopener');
203
+ });
204
+ overlay.querySelector('[data-onboard-back]')?.addEventListener('click', () => {
205
+ if (!flow) return;
206
+ flow = goBack(flow);
207
+ render();
208
+ });
209
+ overlay.querySelector('[data-onboard-next]')?.addEventListener('click', () => {
210
+ if (!flow) return;
211
+ captureInputs();
212
+ flow = advance(flow);
213
+ render();
214
+ });
215
+ overlay.querySelector('[data-onboard-validate]')?.addEventListener('click', () => { void runValidation(); });
216
+ overlay.querySelector('[data-onboard-save]')?.addEventListener('click', () => { void runSave(); });
217
+ // Live capture keeps the draft authoritative even if the user closes the
218
+ // step without pressing 다음.
219
+ overlay.querySelectorAll('[data-onboard-field]').forEach(el => {
220
+ el.addEventListener('input', () => {
221
+ if (!flow) return;
222
+ const key = (el as HTMLElement).getAttribute('data-onboard-field') || '';
223
+ flow = setField(flow, key, (el as HTMLInputElement).value);
224
+ });
225
+ // Enter submits the step instead of doing nothing — the wizard is a
226
+ // form, and a form that ignores Enter reads as broken.
227
+ el.addEventListener('keydown', (ev) => {
228
+ const key = (ev as KeyboardEvent).key;
229
+ if (key !== 'Enter' || (ev as KeyboardEvent).shiftKey || (ev as KeyboardEvent).isComposing) return;
230
+ ev.preventDefault();
231
+ primaryAction();
232
+ });
233
+ });
234
+
235
+ focusFirstEmptyField();
236
+ }
237
+
238
+ /** Whatever the current step's primary button does — Enter mirrors it. */
239
+ function primaryAction(): void {
240
+ if (!flow) return;
241
+ if (flow.step === 3) { void runValidation(); return; }
242
+ if (flow.step === TOTAL_STEPS) { if (!flow.saved) void runSave(); return; }
243
+ captureInputs();
244
+ flow = advance(flow);
245
+ render();
246
+ }
247
+
248
+ /**
249
+ * render() replaces innerHTML, so focus is lost on every state change. Restore
250
+ * it to the first field still needing input (or the first field at all), which
251
+ * is also what a freshly opened step-2 should focus.
252
+ */
253
+ function focusFirstEmptyField(): void {
254
+ const inputs = [...(overlay?.querySelectorAll('[data-onboard-field]') ?? [])] as HTMLInputElement[];
255
+ if (!inputs.length) return;
256
+ const target = inputs.find(input => !input.value.trim()) ?? inputs[0];
257
+ target?.focus();
258
+ // Caret at the end so an existing value can be corrected, not overwritten.
259
+ const end = target?.value.length ?? 0;
260
+ target?.setSelectionRange?.(end, end);
261
+ }
262
+
263
+ function captureInputs(): void {
264
+ if (!flow) return;
265
+ for (const el of overlay?.querySelectorAll('[data-onboard-field]') ?? []) {
266
+ const key = el.getAttribute('data-onboard-field') || '';
267
+ flow = setField(flow, key, (el as HTMLInputElement).value);
268
+ }
269
+ }
270
+
271
+ async function runValidation(): Promise<void> {
272
+ if (!flow || validating) return;
273
+ const generation = flowGeneration;
274
+ validating = true;
275
+ render();
276
+ const res = await api<{ ok?: boolean; identity?: string; teamId?: string; error?: string; missing?: string[] }>(
277
+ '/api/channels/validate',
278
+ {
279
+ method: 'POST',
280
+ headers: { 'Content-Type': 'application/json' },
281
+ body: JSON.stringify(validationPayload(flow)),
282
+ },
283
+ );
284
+ // The wizard may have been closed or pointed at another channel while the
285
+ // request was in flight; applying this result then would verify the wrong
286
+ // credentials.
287
+ if (generation !== flowGeneration) return;
288
+ validating = false;
289
+ if (!flow) return;
290
+ flow = applyValidation(flow, res || { ok: false, error: 'network' });
291
+ render();
292
+ }
293
+
294
+ async function runSave(): Promise<void> {
295
+ if (!flow || saving) return;
296
+ const blocker = blockerForStep({ ...flow, step: 3 });
297
+ if (blocker) {
298
+ flow = { ...flow, step: 3, error: blocker };
299
+ render();
300
+ return;
301
+ }
302
+ // Mirror into the settings section so the existing load/save surfaces and
303
+ // the transport status row stay consistent with the wizard.
304
+ for (const field of fieldsFor(flow.channel)) {
305
+ writeSettingsInput(field.settingsId, (flow.draft[field.key] || '').trim());
306
+ }
307
+ const generation = flowGeneration;
308
+ saving = true;
309
+ try {
310
+ await apiJson('/api/settings', 'PUT', settingsPatch(flow));
311
+ } finally {
312
+ saving = false;
313
+ }
314
+ if (generation !== flowGeneration || !flow) return;
315
+ flow = markSaved(flow);
316
+ render();
317
+ void refreshTransportStatusRow();
318
+ // The save click is a user gesture, which is the only moment a browser
319
+ // will honour a notification request — and the only moment it is relevant,
320
+ // since a channel just started delivering messages.
321
+ void maybeRequestNotificationPermission();
322
+ }
@@ -0,0 +1,26 @@
1
+ // ── Channel Setup Guide Popup ──
2
+ // When a user points cli-jaw at a channel that has no credentials, open the
3
+ // existing help dialog's setup guide instead of leaving a silently dead
4
+ // channel. Fires only on explicit user intent: switching the active channel
5
+ // or enabling a channel toggle — never on page load, so a settings visit
6
+ // does not pop a dialog the user did not ask for.
7
+ import { openChannelOnboarding } from './channel-onboarding.js';
8
+ import { isTelegramConfigured, isDiscordConfigured, isSlackConfigured } from './channel-setup-rules.js';
9
+
10
+ type SetupChannel = 'telegram' | 'discord' | 'slack';
11
+
12
+ function inputValue(id: string): string {
13
+ return (document.getElementById(id) as HTMLInputElement | null)?.value ?? '';
14
+ }
15
+
16
+ /** Open the channel's setup guide when its required credentials are missing. */
17
+ export function openSetupGuideIfUnconfigured(ch: SetupChannel): void {
18
+ const configured =
19
+ ch === 'telegram' ? isTelegramConfigured(inputValue('tgToken')) :
20
+ ch === 'discord' ? isDiscordConfigured(inputValue('dcToken'), inputValue('dcGuildId')) :
21
+ isSlackConfigured(inputValue('slBotToken'));
22
+ // The wizard supersedes the read-only help dialog here: it carries the
23
+ // same guidance AND lets the user finish setup (validate + save) inside
24
+ // the popup. The "?" help trigger remains for deep reference.
25
+ if (!configured) openChannelOnboarding(ch);
26
+ }
@@ -0,0 +1,32 @@
1
+ // ── Channel "configured?" rules (data-only) ──
2
+ // Kept import-free and DOM-free so unit tests can pull them without the
3
+ // help-dialog module graph — same separation as help-content vs help-dialog.
4
+ //
5
+ // The rules mirror doctor's hard failures, NOT its warnings: telegram needs
6
+ // a token; discord needs token + guildId (doctor additionally hard-fails on
7
+ // missing channelIds, but the popup targets zero-credentials, not partial
8
+ // config — guild+token is enough to receive); slack needs only botToken —
9
+ // a missing appToken is outbound-only, a legitimate state the doctor WARNs
10
+ // about rather than fails.
11
+
12
+ export function isTelegramConfigured(token: string): boolean {
13
+ return token.trim().length > 0;
14
+ }
15
+
16
+ export function isDiscordConfigured(token: string, guildId: string): boolean {
17
+ return token.trim().length > 0 && guildId.trim().length > 0;
18
+ }
19
+
20
+ export function isSlackConfigured(botToken: string): boolean {
21
+ return botToken.trim().length > 0;
22
+ }
23
+
24
+ // Token-shape hints for inline field validation. Prefixes are how Slack
25
+ // namespaces its tokens; a wrong prefix is almost always a swapped paste.
26
+ export function hasSlackBotTokenPrefix(token: string): boolean {
27
+ return token.trim().startsWith('xoxb-');
28
+ }
29
+
30
+ export function hasSlackAppTokenPrefix(token: string): boolean {
31
+ return token.trim().startsWith('xapp-');
32
+ }
@@ -0,0 +1,38 @@
1
+ // ── Browser notification permission ──
2
+ // Asked ONCE, and only from a real user gesture. Browsers reject unprompted
3
+ // requests, and a permission prompt on page load is the fastest way to earn a
4
+ // permanent "block" — so the ask rides the wizard's save step, where the user
5
+ // just connected a channel and inbound messages are about to arrive.
6
+
7
+ const ASKED_KEY = 'jaw:notificationsAsked';
8
+
9
+ export function notificationsAsked(): boolean {
10
+ try {
11
+ return localStorage.getItem(ASKED_KEY) === '1';
12
+ } catch {
13
+ // Private mode / disabled storage: treat as asked so a blocked write
14
+ // cannot turn into a prompt on every save.
15
+ return true;
16
+ }
17
+ }
18
+
19
+ function markAsked(): void {
20
+ try { localStorage.setItem(ASKED_KEY, '1'); } catch { /* best-effort */ }
21
+ }
22
+
23
+ /**
24
+ * Request notification permission if it has never been asked in this browser.
25
+ * Returns the resulting permission, or null when nothing was asked.
26
+ * A denial is remembered too: never nag.
27
+ */
28
+ export async function maybeRequestNotificationPermission(): Promise<NotificationPermission | null> {
29
+ if (typeof Notification === 'undefined') return null;
30
+ if (Notification.permission !== 'default') return Notification.permission;
31
+ if (notificationsAsked()) return null;
32
+ markAsked();
33
+ try {
34
+ return await Notification.requestPermission();
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
@@ -5,6 +5,7 @@ import { getCliMeta } from '../constants.js';
5
5
  import { providerLabel } from '../provider-icons.js';
6
6
  import { t } from './i18n.js';
7
7
  import { refreshTransportStatusRow } from './transport-status-row.js';
8
+ import { openSetupGuideIfUnconfigured } from './channel-setup-guide.js';
8
9
  import type { SettingsData } from './settings-types.js';
9
10
 
10
11
  export async function setActiveChannel(ch: 'telegram' | 'discord' | 'slack'): Promise<void> {
@@ -16,6 +17,9 @@ export async function setActiveChannel(ch: 'telegram' | 'discord' | 'slack'): Pr
16
17
  document.getElementById('channelSlackSettings')?.style.setProperty('display', ch === 'slack' ? '' : 'none');
17
18
  await apiJson('/api/settings', 'PUT', { channel: ch });
18
19
  await refreshTransportStatusRow();
20
+ // Switching to a channel with no credentials leaves a silently dead
21
+ // channel — open its setup guide right away.
22
+ openSetupGuideIfUnconfigured(ch);
19
23
  }
20
24
 
21
25
  export function loadActiveChannel(s: SettingsData): void {
@@ -1,5 +1,6 @@
1
1
  // ── Settings Core ──
2
2
  import { MODEL_MAP, loadCliRegistry, getCliKeys, getCliMeta, PRIMARY_CLIS } from '../constants.js';
3
+ import type { CliEntry } from '../constants.js';
3
4
  import { escapeHtml } from '../render.js';
4
5
  import { syncStoredLocale } from '../locale.js';
5
6
  import { t } from './i18n.js';
@@ -190,6 +191,41 @@ function normalizeModelForDisplay(_cli: string, model: string): string {
190
191
  return (model || '').trim();
191
192
  }
192
193
 
194
+ /**
195
+ * Effort choices for one model.
196
+ *
197
+ * A live opencodex catalog advertises a different effort set per model
198
+ * (`gpt-5.6-sol` reaches `ultra`, `gpt-5.6-luna` stops at `max`, routed models
199
+ * take none), and the chosen value is forwarded to the wire, so the per-model
200
+ * set wins over the provider/registry lists. An entry that EXISTS but is empty
201
+ * means "no effort for this model" and must not fall back.
202
+ */
203
+ function resolveEffortChoices(
204
+ meta: CliEntry | null,
205
+ model: string,
206
+ providerEfforts: string[] | null,
207
+ provider?: string,
208
+ ): string[] {
209
+ const key = (model || '').trim();
210
+ // Provider-scoped map wins for provider-split runtimes (ai-e): the same model
211
+ // id can allow different efforts per provider. A missing model key falls back
212
+ // to the provider list rather than inventing an empty "no effort" set.
213
+ if (provider) {
214
+ const scoped = meta?.effortsByModelByProvider?.[provider]?.[key];
215
+ if (scoped) return scoped;
216
+ }
217
+ const byModel = meta?.effortsByModel?.[key];
218
+ if (byModel) return byModel;
219
+ return providerEfforts || meta?.efforts || [];
220
+ }
221
+
222
+ function resolveDefaultEffort(meta: CliEntry | null, model: string, provider?: string): string {
223
+ const key = (model || '').trim();
224
+ return (provider ? meta?.defaultEffortByModelByProvider?.[provider]?.[key] : undefined)
225
+ ?? meta?.defaultEffortByModel?.[key]
226
+ ?? '';
227
+ }
228
+
193
229
  function syncPerCliModelAndEffortControls(settings: SettingsData | null = null): void {
194
230
  for (const cli of getCliKeys()) {
195
231
  const meta = getCliMeta(cli);
@@ -224,8 +260,15 @@ function syncPerCliModelAndEffortControls(settings: SettingsData | null = null):
224
260
  const providerEfforts = cliProvider
225
261
  ? (meta?.effortsByProvider?.[cliProvider] || [])
226
262
  : null;
227
- const options = [''].concat(providerEfforts || meta?.efforts || []);
228
- const selected = settings?.perCli?.[cli]?.effort ?? effortSel.value ?? '';
263
+ const selectedModel = normalizeModelForDisplay(cli, settings?.perCli?.[cli]?.model || getModelSelect(cli)?.value || '');
264
+ const effortsList = resolveEffortChoices(meta, selectedModel, providerEfforts, cliProvider || undefined);
265
+ const options = [''].concat(effortsList);
266
+ const saved = settings?.perCli?.[cli]?.effort ?? effortSel.value ?? '';
267
+ // Drop a saved effort the current model does not support — it would
268
+ // otherwise reach the wire as `-c model_reasoning_effort=<bad>`.
269
+ const selected = !saved || effortsList.includes(saved)
270
+ ? saved
271
+ : resolveDefaultEffort(meta, selectedModel, cliProvider || undefined);
229
272
  const unique = [...new Set(options)];
230
273
  const noneLabel = (unique.length === 1 && !unique[0] && meta?.effortNote) ? meta.effortNote : '— none';
231
274
  effortSel.innerHTML = unique.map(v => {
@@ -233,13 +276,18 @@ function syncPerCliModelAndEffortControls(settings: SettingsData | null = null):
233
276
  return `<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`;
234
277
  }).join('');
235
278
  if (meta?.effortNote) effortSel.title = meta.effortNote;
236
- effortSel.disabled = (unique.length === 1 && !unique[0] && !!meta?.effortNote);
279
+ // A model with an explicitly EMPTY effort set takes no effort at all.
280
+ effortSel.disabled = (unique.length === 1 && !unique[0]);
237
281
  if (Array.from(effortSel.options).some(o => o.value === selected)) effortSel.value = selected;
238
282
  }
239
283
  }
240
284
  }
241
285
 
242
- function syncActiveEffortOptions(cli: string, selected = ''): void {
286
+ function getActiveEffortValue(): string {
287
+ return (document.getElementById('selEffort') as HTMLSelectElement | null)?.value || '';
288
+ }
289
+
290
+ function syncActiveEffortOptions(cli: string, selected = '', model?: string): void {
243
291
  const selEffort = document.getElementById('selEffort') as HTMLSelectElement | null;
244
292
  if (!selEffort) return;
245
293
  const meta = getCliMeta(cli);
@@ -247,10 +295,15 @@ function syncActiveEffortOptions(cli: string, selected = ''): void {
247
295
  const providerEfforts = cliProvider
248
296
  ? (meta?.effortsByProvider?.[cliProvider] || [])
249
297
  : null;
250
- const effortsList = providerEfforts || meta?.efforts || [];
251
- if (meta?.effortNote && effortsList.length === 0) {
252
- selEffort.innerHTML = `<option value="">${escapeHtml(meta.effortNote)}</option>`;
253
- selEffort.title = meta.effortNote;
298
+ const activeModel = normalizeModelForDisplay(
299
+ cli,
300
+ model ?? (document.getElementById('selModel') as HTMLSelectElement | null)?.value ?? '',
301
+ );
302
+ const effortsList = resolveEffortChoices(meta, activeModel, providerEfforts, cliProvider || undefined);
303
+ if (effortsList.length === 0) {
304
+ const note = meta?.effortNote || '— none';
305
+ selEffort.innerHTML = `<option value="">${escapeHtml(note)}</option>`;
306
+ selEffort.title = note;
254
307
  selEffort.disabled = true;
255
308
  return;
256
309
  }
@@ -262,7 +315,12 @@ function syncActiveEffortOptions(cli: string, selected = ''): void {
262
315
  }).join('');
263
316
  selEffort.disabled = false;
264
317
  selEffort.title = meta?.effortNote || '';
265
- if (Array.from(selEffort.options).some(o => o.value === selected)) selEffort.value = selected;
318
+ // Coerce a saved effort the active model does not support, so the value that
319
+ // reaches `-c model_reasoning_effort=` is always one the model advertises.
320
+ const resolved = !selected || effortsList.includes(selected)
321
+ ? selected
322
+ : resolveDefaultEffort(meta, activeModel, cliProvider || undefined);
323
+ if (Array.from(selEffort.options).some(o => o.value === resolved)) selEffort.value = resolved;
266
324
  }
267
325
 
268
326
  function syncAiEProviderOptions(select: HTMLSelectElement | null, current: string, providers: string[]): string {
@@ -543,6 +601,9 @@ export function onCliChange(save = true): void {
543
601
  appendCustomOption(modelSel, val);
544
602
  modelSel.value = val;
545
603
  (this as HTMLInputElement).style.display = 'none';
604
+ // A custom model has no advertised effort set; re-resolve so the picker
605
+ // falls back to the registry list instead of keeping the old model's.
606
+ syncActiveEffortOptions(cli, getActiveEffortValue(), val);
546
607
  saveActiveCliSettings();
547
608
  };
548
609
  if (!modelSel) { if (save) updateSettings(); return; }
@@ -553,6 +614,9 @@ export function onCliChange(save = true): void {
553
614
  inp.focus();
554
615
  } else {
555
616
  inp.style.display = 'none';
617
+ // Efforts are per-model on a live opencodex catalog, so the picker
618
+ // must follow the model rather than keep the previous model's set.
619
+ syncActiveEffortOptions(cli, getActiveEffortValue(), (this as HTMLSelectElement).value);
556
620
  saveActiveCliSettings();
557
621
  }
558
622
  };
@@ -1,5 +1,6 @@
1
1
  // ── Discord Settings ──
2
2
  import { apiJson } from '../api.js';
3
+ import { openSetupGuideIfUnconfigured } from './channel-setup-guide.js';
3
4
  import type { SettingsData } from './settings-types.js';
4
5
 
5
6
  export async function saveDiscordSettings(): Promise<void> {
@@ -16,6 +17,7 @@ export async function setDiscord(enabled: boolean): Promise<void> {
16
17
  document.getElementById('dcOn')?.classList.toggle('active', enabled);
17
18
  document.getElementById('dcOff')?.classList.toggle('active', !enabled);
18
19
  await apiJson('/api/settings', 'PUT', { discord: { enabled } });
20
+ if (enabled) openSetupGuideIfUnconfigured('discord');
19
21
  }
20
22
 
21
23
  export async function setDiscordForwardAll(enabled: boolean): Promise<void> {