@nevescloud/pip 2.11.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.
@@ -0,0 +1,2210 @@
1
+ const CSS = `
2
+ .pip-bubble {
3
+ position: fixed;
4
+ left: auto; top: auto;
5
+ right: max(20px, env(safe-area-inset-right));
6
+ bottom: max(20px, env(safe-area-inset-bottom));
7
+ margin: 0;
8
+ width: 44px; height: 44px;
9
+ min-width: 44px; min-height: 44px;
10
+ padding: 0;
11
+ border: none;
12
+ background: transparent;
13
+ color: var(--pip-ink, currentColor);
14
+ color-scheme: light dark;
15
+ /* Smooth the awake↔sleep tonal shift. Other state changes (responding
16
+ hue-sweep, etc.) override color via animation, so this only runs
17
+ during the sleeping handover. */
18
+ transition: color 500ms ease;
19
+ font-size: 36px;
20
+ line-height: 1;
21
+ align-items: center;
22
+ justify-content: center;
23
+ cursor: pointer;
24
+ z-index: 20;
25
+ transition: transform 0.15s ease-out;
26
+ overflow: visible;
27
+ /* Icon, not text. A drag (joypad, long-press, scribble) passing over the
28
+ bubble would otherwise grab the SVG as a selected image — a persistent
29
+ highlight users can't clear. Inert to selection + iOS callout. */
30
+ user-select: none;
31
+ -webkit-user-select: none;
32
+ -webkit-touch-callout: none;
33
+ }
34
+ .pip-bubble:popover-open { display: inline-flex; }
35
+ .pip-bubble:hover { transform: translateY(-3px); }
36
+ .pip-bubble:active { transform: translateY(0); }
37
+ .pip-bubble.responding { color: var(--pip-accent, #d4b24e); }
38
+ .pip-bubble .pip-robot-icon { width: 100%; height: 100%; display: block; overflow: visible; }
39
+
40
+ /* Transitions on the properties that the .sleeping class toggles —
41
+ when the class drops, the animations resume and the declared values
42
+ crossfade smoothly back to awake. (Animations override the declared
43
+ value while running, so transitions only run during the awake↔sleep
44
+ handover, not during the per-frame animation work.) */
45
+ .pip-bubble .robot-eyes { animation: pip-robot-idle 8s step-end infinite; transform-origin: 12px 14px; transition: transform 500ms ease; }
46
+ .pip-bubble .robot-antenna-l { opacity: 1; animation: pip-antenna-l-idle 8s step-end infinite; transition: opacity 500ms ease; }
47
+ .pip-bubble .robot-antenna-s { opacity: 0; animation: pip-antenna-s-idle 8s step-end infinite; transition: opacity 500ms ease; }
48
+ .pip-bubble .robot-antenna-r { opacity: 0; }
49
+ .pip-bubble .robot-spark { opacity: 0; animation: pip-spark-flash 8s step-end infinite; transition: opacity 500ms ease; }
50
+ /* Sleep Z glyphs are hidden by default — surfaced only when the host
51
+ marks the bubble as sleeping via setSleeping(true). Each Z has its
52
+ own fade-and-drift cycle, staggered to read as breathing rhythm. */
53
+ .pip-bubble .robot-zzz-1,
54
+ .pip-bubble .robot-zzz-2,
55
+ .pip-bubble .robot-zzz-3 { opacity: 0; transition: opacity 500ms ease; }
56
+
57
+ @keyframes pip-antenna-l-idle { 0%, 100% { opacity: 1; } 58% { opacity: 0; } 67% { opacity: 1; } }
58
+ @keyframes pip-antenna-s-idle { 0%, 100% { opacity: 0; } 58% { opacity: 1; } 67% { opacity: 0; } }
59
+ @keyframes pip-spark-flash { 0%, 56%, 67%, 100% { opacity: 0; } 60% { opacity: 1; } 63% { opacity: 0.5; } }
60
+ @keyframes pip-robot-idle {
61
+ 0%, 32% { transform: scaleY(1) translateX(0); }
62
+ 34% { transform: scaleY(0.15) translateX(0); }
63
+ 37%, 56% { transform: scaleY(1) translateX(0); }
64
+ 59%, 66% { transform: scaleY(1) translateX(0.8px); }
65
+ 69%, 78% { transform: scaleY(1) translateX(0); }
66
+ 80% { transform: scaleY(0.15) translateX(0); }
67
+ 83%, 91% { transform: scaleY(1) translateX(0); }
68
+ 93%, 96% { transform: scaleY(1) translateX(-0.6px); }
69
+ 98%, 100% { transform: scaleY(1) translateX(0); }
70
+ }
71
+ .pip-bubble.responding .robot-eyes { animation: pip-robot-speak-eyes 2.4s step-end infinite; }
72
+ .pip-bubble.responding .robot-antenna-l { animation: pip-antenna-l-speak 1.2s step-end infinite; }
73
+ .pip-bubble.responding .robot-antenna-s { animation: pip-antenna-s-speak 1.2s step-end infinite; }
74
+ .pip-bubble.responding .robot-antenna-r { animation: pip-antenna-r-speak 1.2s step-end infinite; }
75
+ .pip-bubble.responding .robot-spark { animation: none; opacity: 0; }
76
+ @keyframes pip-robot-speak-eyes {
77
+ 0%, 50%, 100% { transform: scaleY(1) translateX(0); }
78
+ 20% { transform: scaleY(1) translateX(0.4px); }
79
+ 28% { transform: scaleY(0.2) translateX(0.4px); }
80
+ 32% { transform: scaleY(1) translateX(0.4px); }
81
+ 70% { transform: scaleY(1) translateX(-0.4px); }
82
+ 78% { transform: scaleY(0.2) translateX(-0.4px); }
83
+ 82% { transform: scaleY(1) translateX(-0.4px); }
84
+ }
85
+ @keyframes pip-antenna-l-speak { 0%, 100% { opacity: 1; } 25% { opacity: 0; } }
86
+ @keyframes pip-antenna-s-speak { 0%, 100% { opacity: 0; } 25% { opacity: 1; } 50% { opacity: 0; } 75% { opacity: 1; } }
87
+ @keyframes pip-antenna-r-speak { 0%, 100% { opacity: 0; } 50% { opacity: 1; } 75% { opacity: 0; } }
88
+
89
+ /* Sleeping — host marks the bubble as having no provider available
90
+ (offline operator, unconfigured LLM). Eyes freeze in their blink-
91
+ frame shape (scaleY(0.15)) — same visual as mid-blink, just held —
92
+ idle/spark animations stop, antenna goes still, and three Z's of
93
+ descending size drift up above the head on staggered cycles. The
94
+ bubble stays clickable; the panel still opens. */
95
+ .pip-bubble.sleeping .robot-eyes,
96
+ .pip-bubble.sleeping .robot-antenna-l,
97
+ .pip-bubble.sleeping .robot-antenna-s,
98
+ .pip-bubble.sleeping .robot-antenna-r,
99
+ .pip-bubble.sleeping .robot-spark { animation: none; }
100
+ .pip-bubble.sleeping .robot-eyes { transform: scaleY(0.15); }
101
+ .pip-bubble.sleeping .robot-spark,
102
+ .pip-bubble.sleeping .robot-antenna-s,
103
+ .pip-bubble.sleeping .robot-antenna-r { opacity: 0; }
104
+ /* Mute the robot via a solid color shift, not opacity. stroke-opacity
105
+ would compound visibly where strokes overlap (antenna joints, ear-
106
+ to-rect seams), so the same overpainted pixel reads darker than its
107
+ neighbors. A solid muted color paints overlapping pixels the same
108
+ shade — no compounding. The bubble's color cascades to stroke and
109
+ to the Z fill, so the whole icon recedes together. Hosts can
110
+ override --pip-ink-sleeping for theme-specific muting. */
111
+ .pip-bubble.sleeping { color: var(--pip-ink-sleeping, light-dark(#999, #6a6e74)); }
112
+ .pip-bubble.sleeping .robot-zzz-1,
113
+ .pip-bubble.sleeping .robot-zzz-2,
114
+ .pip-bubble.sleeping .robot-zzz-3 {
115
+ transform-box: fill-box;
116
+ transform-origin: center;
117
+ animation: pip-zzz 2.4s ease-out infinite;
118
+ }
119
+ .pip-bubble.sleeping .robot-zzz-2 { animation-delay: 0.8s; }
120
+ .pip-bubble.sleeping .robot-zzz-3 { animation-delay: 1.6s; }
121
+ @keyframes pip-zzz {
122
+ 0% { opacity: 0; transform: translate(0, 0) scale(0.7); }
123
+ 20% { opacity: 0.7; }
124
+ 100% { opacity: 0; transform: translate(2px, -6px) scale(1); }
125
+ }
126
+
127
+ @media (prefers-reduced-motion: reduce) {
128
+ .pip-bubble .robot-eyes,
129
+ .pip-bubble .robot-spark,
130
+ .pip-bubble .robot-antenna-l,
131
+ .pip-bubble .robot-antenna-s,
132
+ .pip-bubble .robot-antenna-r { animation: none; }
133
+ .pip-bubble .robot-spark,
134
+ .pip-bubble .robot-antenna-s,
135
+ .pip-bubble .robot-antenna-r { opacity: 0; }
136
+ .pip-bubble.responding .robot-eyes,
137
+ .pip-bubble.responding .robot-antenna-l,
138
+ .pip-bubble.responding .robot-antenna-s,
139
+ .pip-bubble.responding .robot-antenna-r { animation: none; }
140
+ .pip-bubble.sleeping .robot-zzz-1,
141
+ .pip-bubble.sleeping .robot-zzz-2,
142
+ .pip-bubble.sleeping .robot-zzz-3 { animation: none; opacity: 0.5; transform: none; }
143
+ .pip-panel.fading { transition: none; }
144
+ .pip-panel.pip-panel--ghosting { animation: none; opacity: 0; }
145
+ }
146
+
147
+ .pip-panel {
148
+ position: fixed;
149
+ right: max(20px, env(safe-area-inset-right));
150
+ bottom: calc(72px + env(safe-area-inset-bottom));
151
+ left: auto; top: auto;
152
+ width: min(420px, calc(100vw - 40px));
153
+ max-height: calc(100dvh - 96px - env(safe-area-inset-bottom) - env(safe-area-inset-top));
154
+ overflow: visible;
155
+ margin: 0;
156
+ padding: 14px;
157
+ /* Opt this scope into light-dark() so var() fallbacks below render
158
+ correctly when the host page hasn't set --pip-surface etc. */
159
+ color-scheme: light dark;
160
+ border: 1px solid var(--pip-border, light-dark(rgba(0,0,0,0.10), rgba(255,255,255,0.12)));
161
+ border-radius: 14px;
162
+ background: var(--pip-surface, light-dark(#fff, #212529));
163
+ color: var(--pip-ink, inherit);
164
+ box-shadow: 0 10px 30px rgba(0,0,0,0.15);
165
+ transform-origin: bottom right;
166
+ }
167
+ /* Must be scoped to :popover-open — author-origin display:flex otherwise
168
+ overrides the UA rule that hides un-open popovers, leaking the panel. */
169
+ .pip-panel:popover-open { display: flex; flex-direction: column; }
170
+ .pip-panel.fading { opacity: 0; transition: opacity 3s ease-out; }
171
+ /* Ghost-out — applied just before auto-close hides the popover, so the
172
+ panel dissipates softly instead of vanishing. Soft scale + opacity,
173
+ anchored bottom-right to feel like it's settling back toward the
174
+ bubble. Manual closes (click outside, escape, bubble click) are
175
+ user-driven and stay instant — only system-initiated closes ghost. */
176
+ .pip-panel.pip-panel--ghosting {
177
+ animation: pip-panel-ghost-out 360ms ease-out forwards;
178
+ }
179
+ @keyframes pip-panel-ghost-out {
180
+ to { opacity: 0; transform: translateY(-4px) scale(0.97); filter: blur(1px); }
181
+ }
182
+
183
+ /* Slash key cap — opens the slash dropdown by seeding "/" in the input.
184
+ Rendered as a small monospace key (kbd) so it reads as a keyboard
185
+ shortcut, not a generic icon. Positioned at the left edge of the
186
+ input (form is position: relative when slashHint is on). Auto-hides
187
+ when the input has any text. */
188
+ .pip-form--slash { position: relative; }
189
+ /* Padding only while the slash key is actually visible. When hidden
190
+ (input has text, user took over), the input reclaims the full width
191
+ so there's no ghost gutter where the button used to be. */
192
+ .pip-form--slash:has(.pip-slash-key:not([hidden])) .pip-input {
193
+ padding-left: 36px;
194
+ }
195
+ .pip-slash-key {
196
+ position: absolute;
197
+ left: 6px;
198
+ /* Vertical center via top + half-height offset, NOT transform: translateY.
199
+ Keeps the transform property free for host themes — a host that applies
200
+ a press-style scale on plain button would otherwise clobber centering.
201
+ Same convention on .pip-send-btn / .pip-stop-btn. */
202
+ top: calc(50% - 12px);
203
+ width: 24px;
204
+ height: 24px;
205
+ min-width: 24px;
206
+ min-height: 0;
207
+ padding: 0;
208
+ margin: 0;
209
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
210
+ font-size: 13px;
211
+ font-weight: 700;
212
+ line-height: 1;
213
+ display: inline-flex;
214
+ align-items: center;
215
+ justify-content: center;
216
+ background: transparent;
217
+ color: var(--pip-ink-muted, light-dark(#6e6e73, rgba(255,255,255,0.55)));
218
+ border: 1px solid var(--pip-border, light-dark(rgba(0,0,0,0.12), rgba(255,255,255,0.15)));
219
+ border-radius: 5px;
220
+ cursor: pointer;
221
+ z-index: 1;
222
+ }
223
+ .pip-slash-key:hover {
224
+ background: var(--pip-surface, light-dark(rgba(0,0,0,0.04), rgba(255,255,255,0.08)));
225
+ color: var(--pip-ink, inherit);
226
+ }
227
+ /* Press: deepen the hover tint rather than filter:brightness, which on a
228
+ transparent-bg button in dark mode would darken into the floor. */
229
+ .pip-slash-key:active {
230
+ background: light-dark(rgba(0,0,0,0.08), rgba(255,255,255,0.14));
231
+ color: var(--pip-ink, inherit);
232
+ }
233
+ /* The class rule's display:inline-flex would otherwise tie with the UA's
234
+ [hidden]{display:none} on specificity and win on source order, leaving
235
+ the cap rendered (transparent-looking) over typed text. Make hidden
236
+ explicit. */
237
+ .pip-slash-key[hidden] { display: none; }
238
+
239
+ /* Inner scroll region — min-height:0 is the flex-child escape hatch that
240
+ lets overflow-y actually scroll instead of pushing the form out. Scroll
241
+ moves here, not the outer .pip-panel, so the speech-bubble ::after tail
242
+ (bottom:-7px) stays outside the clipping box. */
243
+ .pip-scroll {
244
+ flex: 1 1 auto;
245
+ min-height: 0;
246
+ overflow-y: auto;
247
+ scrollbar-gutter: stable;
248
+ scrollbar-width: thin;
249
+ scrollbar-color: var(--pip-border, light-dark(rgba(0,0,0,0.10), rgba(255,255,255,0.12))) transparent;
250
+ overscroll-behavior: contain;
251
+ -webkit-overflow-scrolling: touch;
252
+ }
253
+ .pip-scroll::-webkit-scrollbar { width: 6px; }
254
+ .pip-scroll::-webkit-scrollbar-track { background: transparent; }
255
+ .pip-scroll::-webkit-scrollbar-thumb { background: var(--pip-border, light-dark(rgba(0,0,0,0.10), rgba(255,255,255,0.12))); border-radius: 3px; }
256
+
257
+ .pip-notify {
258
+ font-size: var(--pip-t-caption, 12px);
259
+ color: var(--pip-ink-muted, light-dark(#6e6e73, #adb5bd));
260
+ margin: 0 32px 10px 0;
261
+ white-space: pre-wrap;
262
+ word-break: break-word;
263
+ line-height: 1.45;
264
+ max-height: 200px;
265
+ overflow: hidden;
266
+ transition: opacity 0.4s ease, max-height 0.4s ease,
267
+ margin-bottom 0.4s ease;
268
+ }
269
+ .pip-notify[hidden] { display: none; }
270
+ .pip-notify.ai-generated { color: var(--pip-accent, #d4b24e); }
271
+ .pip-notify.dismissing {
272
+ opacity: 0;
273
+ max-height: 0;
274
+ margin-bottom: 0;
275
+ }
276
+ @media (prefers-reduced-motion: reduce) { .pip-notify { transition: none; } }
277
+
278
+ .pip-turns { display: flex; flex-direction: column; gap: 12px; }
279
+ .pip-turn { margin: 0; }
280
+ .pip-turn:first-child > .pip-echo { margin-right: 32px; }
281
+ .pip-echo {
282
+ font-size: var(--pip-t-caption, 12px);
283
+ font-style: italic;
284
+ color: var(--pip-ink-muted, light-dark(#6e6e73, #adb5bd));
285
+ margin: 0 0 6px;
286
+ padding-left: 8px;
287
+ border-left: 2px solid var(--pip-border, light-dark(rgba(0,0,0,0.10), rgba(255,255,255,0.12)));
288
+ white-space: pre-wrap;
289
+ word-break: break-word;
290
+ }
291
+ .pip-reply { margin: 0; font-size: var(--pip-t-body, 14px); line-height: 1.5; }
292
+ .pip-reply.ai-generated { color: var(--pip-accent, #d4b24e); }
293
+ /* Past turns within a session: only the latest reply keeps the active
294
+ amber tint. Older replies fade to ambient reading-text — they're
295
+ reference, not the live exchange. Same color treatment that user-input
296
+ echoes already use, so user history and assistant history read as one
297
+ "background" stratum and only the current turn pops. */
298
+ .pip-turn:not(:last-child) .pip-reply.ai-generated,
299
+ .pip-panel--quieted .pip-reply.ai-generated {
300
+ color: var(--pip-ink-muted, light-dark(#6e6e73, #adb5bd));
301
+ }
302
+ .pip-turn:not(:last-child) .pip-reply.ai-generated code,
303
+ .pip-panel--quieted .pip-reply.ai-generated code,
304
+ .pip-turn:not(:last-child) .pip-reply.ai-generated pre,
305
+ .pip-panel--quieted .pip-reply.ai-generated pre {
306
+ background: color-mix(in srgb, currentColor 8%, transparent);
307
+ }
308
+ .pip-reply.ai-generated p { margin: 0 0 8px; }
309
+ .pip-reply.ai-generated p:last-child { margin-bottom: 0; }
310
+ .pip-reply.ai-generated code {
311
+ font-family: "SF Mono", ui-monospace, Menlo, monospace;
312
+ font-size: var(--pip-t-caption, 12px);
313
+ padding: 1px 5px;
314
+ border-radius: 3px;
315
+ background: color-mix(in srgb, var(--pip-accent, #d4b24e) 14%, transparent);
316
+ }
317
+ .pip-reply.ai-generated pre {
318
+ margin: 8px 0;
319
+ padding: 8px 10px;
320
+ border-radius: 6px;
321
+ background: color-mix(in srgb, var(--pip-accent, #d4b24e) 10%, transparent);
322
+ overflow-x: auto;
323
+ }
324
+ .pip-reply.ai-generated pre code { padding: 0; background: transparent; font-size: var(--pip-t-caption, 12px); }
325
+ .pip-reply.ai-generated strong { font-weight: 600; }
326
+ .pip-reply.ai-generated em { font-style: italic; }
327
+ .pip-reply.ai-generated ul,
328
+ .pip-reply.ai-generated ol { margin: 6px 0; padding-left: 20px; }
329
+ .pip-reply.ai-generated li { margin: 2px 0; }
330
+
331
+ /* Form margin-top adds breathing room between the last turn and the
332
+ input — but ONLY when scroll has content. With empty chat, the margin
333
+ asymmetrically pads the top of the panel and makes the input look
334
+ bottom-heavy. :has detects content presence, so the margin auto-
335
+ collapses on empty state and the panel reads symmetric. */
336
+ .pip-form { margin: 0; position: relative; }
337
+ .pip-scroll:has(.pip-turns > *) ~ .pip-form,
338
+ .pip-scroll:has(.pip-notify:not([hidden])) ~ .pip-form {
339
+ margin-top: 10px;
340
+ }
341
+ .pip-input {
342
+ width: 100%;
343
+ box-sizing: border-box;
344
+ padding: 8px 40px 8px 12px;
345
+ border: 1px solid var(--pip-border, light-dark(rgba(0,0,0,0.10), rgba(255,255,255,0.12)));
346
+ border-radius: 8px;
347
+ /* Subtle elevation off the panel surface so the input reads as a
348
+ distinct field, not blended chrome. Hosts override --pip-input-bg
349
+ for full control. */
350
+ background: var(--pip-input-bg, light-dark(#f5f5f7, #2a2f33));
351
+ color: var(--pip-ink, inherit);
352
+ font: inherit;
353
+ font-size: var(--pip-t-input, 16px); /* mobile: keep ≥ 16px or iOS Safari zooms on focus */
354
+ }
355
+ /* Desktop has no iOS zoom concern, and 16px reads heavier than chat replies.
356
+ Drop the default to 14px on mouse-pointer devices; hosts overriding
357
+ --pip-t-input still control both branches. */
358
+ @media (hover: hover) and (pointer: fine) {
359
+ .pip-input { font-size: var(--pip-t-input, 14px); }
360
+ }
361
+ .pip-input:focus { outline: none; border-color: color-mix(in srgb, var(--pip-accent, #d4b24e) 50%, var(--pip-border, light-dark(rgba(0,0,0,0.10), rgba(255,255,255,0.12)))); }
362
+ .pip-input:disabled { opacity: 0.6; cursor: progress; }
363
+ /* Send / stop button — shares one slot at the right edge of the input.
364
+ Square (rounded-corner) reads as "send a message", matches the Hatch /
365
+ Claude.ai composer pattern. Visible only when there's text to send (or
366
+ while the host is responding, in which case it morphs into a stop
367
+ button).
368
+
369
+ Fixed 24×24 (the button doesn't scale with input height — too big
370
+ reads as a primary CTA, not a chat send). Vertical centering via
371
+ top:50% + translateY. Right offset is a CSS variable so hosts that
372
+ override .pip-input to a tighter geometry (smaller font-size or
373
+ padding) can shrink the inset to match the auto-computed vertical
374
+ gap. Default 6px works for pip-core's default input (~35px tall →
375
+ ~5.5px vertical gap). */
376
+ .pip-send-btn,
377
+ .pip-stop-btn {
378
+ position: absolute;
379
+ /* Vertical center — see .pip-slash-key for the no-transform rationale. */
380
+ top: calc(50% - 12px);
381
+ right: var(--pip-btn-right, 6px);
382
+ width: 24px;
383
+ height: 24px;
384
+ min-width: 0;
385
+ min-height: 0;
386
+ box-sizing: border-box;
387
+ margin: 0;
388
+ padding: 0;
389
+ border: none;
390
+ border-radius: 6px;
391
+ background: var(--pip-accent, #d4b24e);
392
+ color: var(--pip-on-accent, #fff);
393
+ cursor: pointer;
394
+ display: inline-flex;
395
+ align-items: center;
396
+ justify-content: center;
397
+ }
398
+ .pip-send-btn:hover,
399
+ .pip-stop-btn:hover { filter: brightness(1.08); }
400
+ /* Press is fine as a brightness shift here — the fill is the accent color,
401
+ not a dark surface, so brightness(0.92) reads as "pressed" instead of
402
+ "dimmed into the floor." */
403
+ .pip-send-btn:active,
404
+ .pip-stop-btn:active { filter: brightness(0.92); }
405
+ .pip-send-btn:disabled,
406
+ .pip-stop-btn:disabled { opacity: 0.5; cursor: progress; }
407
+ .pip-send-btn[hidden],
408
+ .pip-stop-btn[hidden] { display: none; }
409
+
410
+ .pip-slash-suggest {
411
+ list-style: none;
412
+ margin: 6px 0;
413
+ padding: 4px;
414
+ border: 1px solid var(--pip-border, light-dark(rgba(0,0,0,0.10), rgba(255,255,255,0.12)));
415
+ border-radius: 8px;
416
+ background: var(--pip-surface, light-dark(#fff, #212529));
417
+ max-height: 180px;
418
+ overflow-y: auto;
419
+ font-size: var(--pip-t-caption, 12px);
420
+ }
421
+ .pip-slash-suggest li {
422
+ display: flex;
423
+ gap: 8px;
424
+ align-items: baseline;
425
+ padding: 5px 8px;
426
+ border-radius: 5px;
427
+ cursor: pointer;
428
+ color: var(--pip-ink, inherit);
429
+ }
430
+ .pip-slash-suggest li.selected {
431
+ background: var(--pip-accent, #d4b24e);
432
+ color: light-dark(#000, #fff);
433
+ }
434
+ .pip-slash-suggest .name {
435
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
436
+ font-weight: 500;
437
+ flex-shrink: 0;
438
+ }
439
+ .pip-slash-suggest .desc {
440
+ color: var(--pip-ink-muted, light-dark(rgba(0,0,0,0.55), rgba(255,255,255,0.55)));
441
+ overflow: hidden;
442
+ text-overflow: ellipsis;
443
+ white-space: nowrap;
444
+ }
445
+ .pip-slash-suggest li.selected .desc {
446
+ color: light-dark(rgba(0,0,0,0.7), rgba(255,255,255,0.85));
447
+ }
448
+
449
+ /* During slash-command mode (input starts with /), the chat history steps
450
+ back so the slash menu and status panel can be the focus. Dim + blur
451
+ preserves spatial continuity without pulling the eye toward history the
452
+ user isn't consulting. The transition lives on .pip-turns so leaving
453
+ slash mode also fades smoothly. */
454
+ .pip-turns {
455
+ transition: opacity 0.18s ease, filter 0.18s ease;
456
+ }
457
+ .pip-panel--slash-mode .pip-turns {
458
+ opacity: 0.2;
459
+ filter: blur(2px);
460
+ pointer-events: none;
461
+ }
462
+
463
+ /* Status panel — sits above the slash autocomplete and the input. Surfaces
464
+ ambient state (signed-in user, current model) so /auth and /model don't
465
+ need to print confirmations into chat history. Visible only when the
466
+ input starts with /. Hosts opt in by registering items via
467
+ pip.registerStatusItem(); the panel auto-hides when no items want to
468
+ show. */
469
+ .pip-status-panel {
470
+ margin: 6px 0;
471
+ padding: 6px 8px;
472
+ border: 1px solid var(--pip-border, light-dark(rgba(0,0,0,0.10), rgba(255,255,255,0.12)));
473
+ border-radius: 10px;
474
+ background: var(--pip-surface, light-dark(rgba(255,255,255,0.92), rgba(33,37,41,0.92)));
475
+ backdrop-filter: blur(8px) saturate(150%);
476
+ -webkit-backdrop-filter: blur(8px) saturate(150%);
477
+ display: flex;
478
+ flex-direction: column;
479
+ gap: 2px;
480
+ font-size: var(--pip-t-caption, 12px);
481
+ animation: pip-status-fade 0.16s ease-out;
482
+ }
483
+ @keyframes pip-status-fade {
484
+ from { opacity: 0; transform: translateY(3px); }
485
+ to { opacity: 1; transform: translateY(0); }
486
+ }
487
+ .pip-status-panel[hidden] { display: none; }
488
+ .pip-status-item {
489
+ display: flex;
490
+ align-items: baseline;
491
+ gap: 8px;
492
+ padding: 4px 6px;
493
+ border-radius: 6px;
494
+ color: var(--pip-ink, inherit);
495
+ }
496
+ .pip-status-item.clickable { cursor: pointer; }
497
+ .pip-status-item.clickable:hover {
498
+ background: var(--pip-hover, light-dark(rgba(0,0,0,0.04), rgba(255,255,255,0.06)));
499
+ }
500
+ .pip-status-label {
501
+ color: var(--pip-ink-muted, light-dark(rgba(0,0,0,0.55), rgba(255,255,255,0.55)));
502
+ font-size: 10px;
503
+ font-weight: 600;
504
+ text-transform: uppercase;
505
+ letter-spacing: 0.04em;
506
+ flex-shrink: 0;
507
+ min-width: 48px;
508
+ }
509
+ .pip-status-value {
510
+ color: var(--pip-ink, inherit);
511
+ font-weight: 500;
512
+ flex: 1;
513
+ overflow: hidden;
514
+ text-overflow: ellipsis;
515
+ white-space: nowrap;
516
+ }
517
+ .pip-status-sublabel {
518
+ color: var(--pip-ink-muted, light-dark(rgba(0,0,0,0.55), rgba(255,255,255,0.55)));
519
+ font-weight: 400;
520
+ margin-left: 6px;
521
+ }
522
+
523
+ /* Speech-bubble tail — rotated 14px square; top half hidden by panel, bottom corner sticks out. */
524
+ .pip-panel::after {
525
+ content: "";
526
+ position: absolute;
527
+ bottom: -7px;
528
+ right: 16px;
529
+ width: 14px;
530
+ height: 14px;
531
+ background: var(--pip-surface, light-dark(#fff, #212529));
532
+ border-right: 1px solid var(--pip-border, light-dark(rgba(0,0,0,0.10), rgba(255,255,255,0.12)));
533
+ border-bottom: 1px solid var(--pip-border, light-dark(rgba(0,0,0,0.10), rgba(255,255,255,0.12)));
534
+ transform: rotate(45deg);
535
+ pointer-events: none;
536
+ }
537
+
538
+ /* Inline ask — question + option buttons (or free-text input), answered
539
+ in-place by the user. Used by ask_human tool-shape flows and by
540
+ continue/stop budget prompts. Container border is subdued so it
541
+ doesn't compete with the parent turn's amber tint. */
542
+ .pip-ask {
543
+ margin: 8px 0;
544
+ padding: 8px 10px;
545
+ border: 1px solid color-mix(in srgb, var(--pip-accent, #d4b24e) 25%, transparent);
546
+ border-radius: 6px;
547
+ background: color-mix(in srgb, var(--pip-accent, #d4b24e) 4%, transparent);
548
+ }
549
+ .pip-ask-q { font-size: var(--pip-t-caption, 12px); margin-bottom: 6px; color: var(--pip-ink, inherit); }
550
+ .pip-ask-options { display: flex; flex-wrap: wrap; gap: 6px; }
551
+ .pip-ask-form { display: flex; gap: 6px; align-items: stretch; }
552
+ .pip-ask-input {
553
+ flex: 1;
554
+ font-size: var(--pip-t-caption, 12px);
555
+ padding: 4px 8px;
556
+ border: 1px solid var(--pip-border, light-dark(rgba(0,0,0,0.10), rgba(255,255,255,0.12)));
557
+ border-radius: 4px;
558
+ background: var(--pip-surface, light-dark(#fff, #212529));
559
+ color: var(--pip-ink, inherit);
560
+ min-width: 0;
561
+ }
562
+ /* Inline action buttons. Sized for chat — 12px text, 26px height — not
563
+ touch-target chrome. min-height: 0 + box-sizing override host pages
564
+ that set a project-wide button { min-height: 44px }. First option in
565
+ the row is primary (filled accent); the rest are secondary (subtle
566
+ border). Caller orders options primary-first. */
567
+ .pip-ask-btn {
568
+ font: inherit;
569
+ font-size: var(--pip-t-caption, 12px);
570
+ height: 26px;
571
+ min-height: 0;
572
+ padding: 0 10px;
573
+ margin: 0;
574
+ box-sizing: border-box;
575
+ border-radius: 6px;
576
+ border: 1px solid var(--pip-border, light-dark(rgba(0,0,0,0.18), rgba(255,255,255,0.20)));
577
+ background: transparent;
578
+ color: var(--pip-ink, inherit);
579
+ cursor: pointer;
580
+ line-height: 1;
581
+ }
582
+ .pip-ask-btn:hover {
583
+ background: color-mix(in srgb, currentColor 8%, transparent);
584
+ }
585
+ .pip-ask-btn:active {
586
+ background: color-mix(in srgb, currentColor 14%, transparent);
587
+ }
588
+ .pip-ask-btn--primary {
589
+ background: var(--pip-accent, #d4b24e);
590
+ border-color: var(--pip-accent, #d4b24e);
591
+ color: var(--pip-on-accent, #1a1d20);
592
+ }
593
+ .pip-ask-btn--primary:hover {
594
+ filter: brightness(1.08);
595
+ background: var(--pip-accent, #d4b24e);
596
+ }
597
+ .pip-ask-btn--primary:active {
598
+ filter: brightness(0.92);
599
+ background: var(--pip-accent, #d4b24e);
600
+ }
601
+
602
+ /* Loading bar — generic per-turn progress affordance for hosts that
603
+ stream long-running work (model downloads, tool calls, large fetches)
604
+ into a turn before a reply is ready. Inserted above .pip-reply via
605
+ showLoading(turnEl, label, pct); cleared by hideLoading(turnEl). */
606
+ .pip-loading {
607
+ margin: 6px 0 0;
608
+ font-size: var(--pip-t-caption, 12px);
609
+ color: var(--pip-ink-muted, light-dark(#6e6e73, #adb5bd));
610
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
611
+ }
612
+ .pip-loading-bar {
613
+ width: 100%;
614
+ height: 3px;
615
+ background: var(--pip-border, light-dark(rgba(0,0,0,0.10), rgba(255,255,255,0.12)));
616
+ border-radius: 2px;
617
+ overflow: hidden;
618
+ margin-top: 4px;
619
+ }
620
+ .pip-loading-fill {
621
+ height: 100%;
622
+ width: 0%;
623
+ background: var(--pip-accent, #d4b24e);
624
+ transition: width 0.3s ease;
625
+ }
626
+ @media (prefers-reduced-motion: reduce) {
627
+ .pip-loading-fill { transition: none; }
628
+ }
629
+
630
+ /* Mic button — Web Speech dictation. Sits at the right edge of the form
631
+ just inside the send-button slot. Send is at right:4 with width 24;
632
+ mic at right:32 leaves a 4px gap so both fit without overlap. Mounted
633
+ when createPip({mic: true | {...}}) is set AND Web Speech is supported;
634
+ otherwise the button isn't inserted (no broken affordance). */
635
+ .pip-mic-btn {
636
+ position: absolute;
637
+ right: 32px;
638
+ top: 50%;
639
+ transform: translateY(-50%);
640
+ width: 24px; height: 24px;
641
+ min-height: 0;
642
+ padding: 0;
643
+ display: inline-flex;
644
+ align-items: center; justify-content: center;
645
+ border: none;
646
+ border-radius: 0;
647
+ background: transparent;
648
+ color: var(--pip-ink-muted, light-dark(#6e6e73, #adb5bd));
649
+ cursor: pointer;
650
+ z-index: 1;
651
+ }
652
+ .pip-mic-btn:hover { color: var(--pip-ink, inherit); }
653
+ .pip-mic-btn:active { color: var(--pip-ink, inherit); opacity: 0.7; transform: translateY(-50%); }
654
+ .pip-mic-btn.listening { color: #d93b3b; }
655
+ /* Muted-while-suspended: dimmed grey, no listening pulse. Distinct from
656
+ idle (default neutral) AND from listening (red) — the consumer's
657
+ suspendMic() call lands here, so the user sees "intentionally
658
+ suspended" instead of guessing whether the mic is dead. */
659
+ .pip-mic-btn.muted-tts {
660
+ color: light-dark(#888, #888);
661
+ opacity: 0.55;
662
+ }
663
+ .pip-mic-btn.muted-tts.listening {
664
+ /* If somehow both flags land, mute wins so the visual matches state. */
665
+ color: light-dark(#888, #888);
666
+ opacity: 0.55;
667
+ }
668
+ /* Extra right-padding on the input so typed text doesn't slide under
669
+ the mic. Send is at 4..28px from the right edge, mic at 32..56px —
670
+ 64px clears both. */
671
+ .pip-form--mic .pip-input { padding-right: 64px; }
672
+ /* When the mic is present, keep the send button rendered even when
673
+ pip-core hides it (empty input) — the empty slot to the mic's right
674
+ reads as "broken layout" otherwise. Disabled-looking when not usable.
675
+
676
+ :has() gate is critical: send and stop both pin at right:4, same
677
+ slot. When pip is responding, pip-core flips stopBtn[hidden] off and
678
+ hides send so the user can cancel. Without this guard the force-show
679
+ would overlay stop, and stopping a turn becomes impossible. */
680
+ .pip-form--mic:has(.pip-stop-btn[hidden]) .pip-send-btn[hidden] {
681
+ display: inline-flex !important;
682
+ opacity: 0.35;
683
+ pointer-events: none;
684
+ }
685
+
686
+ /* Mic-status notice — rendered inline in the chat for permission/
687
+ no-speech/etc. states the operator wouldn't otherwise see. Cyan to
688
+ distinguish "input-side feedback" from amber assistant replies and
689
+ anything red the host might surface for errors. Bounded to ONE
690
+ active notice at a time — re-calling replaces the previous so the
691
+ chat doesn't accumulate stale "Didn't catch that" lines. */
692
+ .pip-mic-notice {
693
+ display: flex;
694
+ align-items: center;
695
+ gap: 6px;
696
+ margin: 6px 0;
697
+ padding: 4px 8px;
698
+ border-radius: 6px;
699
+ background: color-mix(in srgb, #4eb5d4 14%, transparent);
700
+ color: color-mix(in srgb, var(--pip-ink, currentColor) 80%, #4eb5d4);
701
+ font-size: 11px;
702
+ line-height: 1.4;
703
+ }
704
+ .pip-mic-notice svg { flex-shrink: 0; color: #4eb5d4; }
705
+
706
+ /* Multi-bubble reply iteration. createPip exposes appendReplyBubble()
707
+ so hosts running tool-using turns can interleave text + pills inside
708
+ a single .pip-turn (instead of pip-core's default single .pip-reply).
709
+ The class chain pip-iter-reply + pip-reply + ai-generated pulls in
710
+ pip-core's markdown styling for free — these rules only tweak the
711
+ inter-bubble margin so consecutive blocks don't fuse. */
712
+ .pip-iter-reply { margin: 4px 0; }
713
+ .pip-iter-reply:first-child { margin-top: 0; }
714
+ .pip-iter-reply:last-child { margin-bottom: 0; }
715
+
716
+ /* Tool-call pill. Rendered inline inside the active turn by
717
+ appendToolPill(). State machine: .running (border + opacity ahead
718
+ of result) → .error (red palette) or completed (default). Clicking
719
+ .pip-step-toggle expands .pip-step-detail with the args + result
720
+ pre — useful for debugging without leaving the chat. */
721
+ .pip-step {
722
+ display: flex;
723
+ flex-direction: column;
724
+ margin: 2px 0;
725
+ padding: 4px 8px;
726
+ background: color-mix(in srgb, var(--pip-ink, currentColor) 3%, transparent);
727
+ border: 1px solid color-mix(in srgb, var(--pip-ink, currentColor) 6%, transparent);
728
+ border-radius: 8px;
729
+ font-family: "SF Mono", ui-monospace, Menlo, monospace;
730
+ font-size: 12px;
731
+ line-height: 1.4;
732
+ color: var(--pip-ink-muted, light-dark(#6e6e73, #adb5bd));
733
+ }
734
+ .pip-step.running {
735
+ border-color: color-mix(in srgb, var(--pip-accent, #d4b24e) 55%, transparent);
736
+ opacity: 0.75;
737
+ }
738
+ .pip-step.error {
739
+ background: color-mix(in srgb, light-dark(#c33, #f88) 7%, transparent);
740
+ border-color: color-mix(in srgb, light-dark(#c33, #f88) 25%, transparent);
741
+ color: light-dark(#c33, #f88);
742
+ }
743
+ .pip-step-head {
744
+ display: flex;
745
+ align-items: center;
746
+ gap: 6px;
747
+ min-width: 0;
748
+ }
749
+ .pip-step-chevron {
750
+ opacity: 0.55;
751
+ flex-shrink: 0;
752
+ transition: transform 120ms ease;
753
+ }
754
+ .pip-step.expanded .pip-step-chevron { transform: rotate(90deg); }
755
+ .pip-step-label {
756
+ flex: 1;
757
+ min-width: 0;
758
+ overflow: hidden;
759
+ text-overflow: ellipsis;
760
+ white-space: nowrap;
761
+ }
762
+ .pip-step-elapsed {
763
+ color: color-mix(in srgb, var(--pip-ink-muted, light-dark(#6e6e73, #adb5bd)) 65%, transparent);
764
+ font-size: 11px;
765
+ flex-shrink: 0;
766
+ }
767
+ .pip-step-toggle {
768
+ min-height: 0;
769
+ background: none;
770
+ border: 0;
771
+ padding: 0 2px;
772
+ font: inherit;
773
+ font-size: 11px;
774
+ color: var(--pip-accent, #d4b24e);
775
+ cursor: pointer;
776
+ flex-shrink: 0;
777
+ }
778
+ .pip-step-toggle:hover { text-decoration: underline; }
779
+ .pip-step-toggle:active { transform: none; filter: none; }
780
+ .pip-step-detail {
781
+ display: flex;
782
+ flex-direction: column;
783
+ gap: 4px;
784
+ margin-top: 4px;
785
+ padding-top: 4px;
786
+ border-top: 1px solid color-mix(in srgb, var(--pip-ink, currentColor) 6%, transparent);
787
+ }
788
+ .pip-step-detail-label {
789
+ font-size: 9.5px;
790
+ letter-spacing: .04em;
791
+ text-transform: uppercase;
792
+ color: var(--pip-ink-muted, light-dark(#6e6e73, #adb5bd));
793
+ }
794
+ .pip-step-pre {
795
+ margin: 2px 0 0;
796
+ padding: 6px 8px;
797
+ background: color-mix(in srgb, var(--pip-ink, currentColor) 4%, transparent);
798
+ border-radius: 4px;
799
+ font-size: 10.5px;
800
+ line-height: 1.45;
801
+ white-space: pre-wrap;
802
+ word-break: break-word;
803
+ max-height: 240px;
804
+ overflow: auto;
805
+ }
806
+
807
+ /* Inline image inside a turn. Hosts use appendTurnImage() to drop a
808
+ camera frame / screenshot next to the tool pill that triggered it
809
+ (matches the Anthropic computer-use UX where every screenshot lands
810
+ inline beside the action). Capped at 220px tall so a single frame
811
+ doesn't push the rest of the turn off-screen. */
812
+ .pip-tool-image {
813
+ display: block;
814
+ max-width: 100%;
815
+ max-height: 220px;
816
+ border-radius: 6px;
817
+ margin: 6px 0;
818
+ border: 1px solid color-mix(in srgb, var(--pip-ink, currentColor) 12%, transparent);
819
+ object-fit: contain;
820
+ }
821
+ `;
822
+
823
+ const ROBOT_SVG = `
824
+ <svg class="pip-robot-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
825
+ <path class="robot-spark" d="M12 1v1.5 M11.25 1.75h1.5"/>
826
+ <g class="robot-antenna">
827
+ <path class="robot-antenna-l" d="M12 8V4H8"/>
828
+ <path class="robot-antenna-s" d="M12 8V2"/>
829
+ <path class="robot-antenna-r" d="M12 8V4H16"/>
830
+ </g>
831
+ <rect width="16" height="12" x="4" y="8" rx="2"/>
832
+ <path d="M2 14h2"/>
833
+ <path d="M20 14h2"/>
834
+ <g class="robot-eyes">
835
+ <path class="robot-eye robot-eye-l" d="M9 13v2"/>
836
+ <path class="robot-eye robot-eye-r" d="M15 13v2"/>
837
+ </g>
838
+ <!-- Sleep glyphs — three Z's of descending size, anchored above the
839
+ head and staggered in time. Each Z fades up and drifts away on
840
+ its own cycle. Visible only when .sleeping is set on the bubble.
841
+ Anchor x is shifted right of center to match where the antenna
842
+ leans; y baseline sits just above the antenna tip. -->
843
+ <g class="robot-zzz" fill="currentColor" stroke="none" font-family="system-ui, -apple-system, sans-serif" font-weight="700">
844
+ <text class="robot-zzz-1" x="15" y="6" font-size="9">Z</text>
845
+ <text class="robot-zzz-2" x="15" y="6" font-size="7">Z</text>
846
+ <text class="robot-zzz-3" x="15" y="6" font-size="5">Z</text>
847
+ </g>
848
+ </svg>`.trim();
849
+
850
+ let _cssInjected = false;
851
+ function injectCss() {
852
+ if (_cssInjected) return;
853
+ const style = document.createElement("style");
854
+ style.setAttribute("data-pip-core", "");
855
+ style.textContent = CSS;
856
+ // Prepend to <head> so host stylesheets (loaded later in document order)
857
+ // cascade-win over the module's defaults on equal specificity.
858
+ document.head.insertBefore(style, document.head.firstChild);
859
+ _cssInjected = true;
860
+ }
861
+
862
+ function escHtml(s) {
863
+ return String(s)
864
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
865
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
866
+ }
867
+
868
+ // Bold/italic/inline code, fenced code blocks, bullet+ordered lists, paragraphs.
869
+ // Safety: escHtml first, then only a fixed tag vocabulary is inserted — no
870
+ // generic HTML passthrough, no link parsing (not needed; would invite sanitation).
871
+ // Per-turn loading affordance. DOM-keyed (operates on a turnEl returned
872
+ // by createPip().startTurn) so it doesn't depend on a pip instance and
873
+ // is shareable across providers. Idempotent: repeated showLoading() calls
874
+ // update label/percent in place; hideLoading() removes the element.
875
+ export function showLoading(turnEl, label, pct) {
876
+ if (!turnEl) return;
877
+ let el = turnEl.querySelector(".pip-loading");
878
+ if (!el) {
879
+ el = document.createElement("div");
880
+ el.className = "pip-loading";
881
+ el.innerHTML =
882
+ '<span class="pip-loading-text"></span>' +
883
+ '<div class="pip-loading-bar"><div class="pip-loading-fill"></div></div>';
884
+ const reply = turnEl.querySelector(".pip-reply");
885
+ turnEl.insertBefore(el, reply || null);
886
+ }
887
+ el.querySelector(".pip-loading-text").textContent = label;
888
+ el.querySelector(".pip-loading-fill").style.width =
889
+ `${Math.max(0, Math.min(100, pct))}%`;
890
+ }
891
+ export function hideLoading(turnEl) {
892
+ const el = turnEl?.querySelector?.(".pip-loading");
893
+ if (el) el.remove();
894
+ }
895
+
896
+ export function renderMd(text) {
897
+ if (text == null) return "";
898
+ let src = escHtml(text);
899
+ src = src.replace(/```(?:[\w-]*)\n?([\s\S]*?)```/g, (_m, code) =>
900
+ `<pre><code>${code.replace(/\n$/, "")}</code></pre>`);
901
+ const lines = src.split("\n");
902
+ const out = [];
903
+ let listTag = null;
904
+ const closeList = () => { if (listTag) { out.push(`</${listTag}>`); listTag = null; } };
905
+ for (const line of lines) {
906
+ const ul = /^\s*[-*]\s+(.*)$/.exec(line);
907
+ const ol = /^\s*\d+\.\s+(.*)$/.exec(line);
908
+ if (ul) {
909
+ if (listTag !== "ul") { closeList(); out.push("<ul>"); listTag = "ul"; }
910
+ out.push(`<li>${ul[1]}</li>`);
911
+ } else if (ol) {
912
+ if (listTag !== "ol") { closeList(); out.push("<ol>"); listTag = "ol"; }
913
+ out.push(`<li>${ol[1]}</li>`);
914
+ } else {
915
+ closeList();
916
+ out.push(line);
917
+ }
918
+ }
919
+ closeList();
920
+ src = out.join("\n");
921
+ src = src
922
+ .replace(/`([^`\n]+)`/g, "<code>$1</code>")
923
+ .replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
924
+ .replace(/\*([^*\n]+)\*/g, "<em>$1</em>");
925
+ const blocks = src.split(/\n{2,}/).map(b => {
926
+ const trimmed = b.trim();
927
+ if (!trimmed) return "";
928
+ if (/^<(pre|ul|ol|p)\b/.test(trimmed)) return trimmed;
929
+ // Inline-content-then-block (e.g. `**Heading**\n- list`) hits this
930
+ // branch as one trimmed block. Wrapping the whole thing in <p> nests
931
+ // the <ul>, which browsers split into two padded <p>s. Detect the
932
+ // boundary and emit the inline head + block tail separately.
933
+ const m = /^([\s\S]+?)\n(<(?:pre|ul|ol|p)\b[\s\S]*)$/.exec(trimmed);
934
+ if (m) {
935
+ const head = m[1].trim();
936
+ const tail = m[2];
937
+ return (head ? `<p>${head.replace(/\n/g, "<br>")}</p>` : "") + tail;
938
+ }
939
+ return `<p>${trimmed.replace(/\n/g, "<br>")}</p>`;
940
+ });
941
+ return blocks.filter(Boolean).join("\n");
942
+ }
943
+
944
+ // Web Speech dictation — counterpart to whatever TTS the host uses. SR
945
+ // shape is a thin lifecycle wrapper around webkitSpeechRecognition: start
946
+ // once, fire interim+final events, stop on caller's signal or after
947
+ // `silenceMs` of no transcript activity (Chrome's own ~10s idle timeout
948
+ // is too long for short commands). Returns { stop({ cancel }) }; reasons
949
+ // passed to onEnd are "auto" (Chrome timed out), "user" (we stopped on
950
+ // silence or caller stopped), "cancel" (caller stopped with cancel:true).
951
+ const _SR = typeof window !== "undefined"
952
+ ? (window.SpeechRecognition || window.webkitSpeechRecognition)
953
+ : null;
954
+ function isSpeechRecognitionSupported() { return !!_SR; }
955
+ function startDictation({ onInterim, onFinal, onFinalChunk, onError, onEnd, lang = "en-US", silenceMs = 1200 } = {}) {
956
+ if (!_SR) { onError?.("not-supported"); onEnd?.({ reason: "error" }); return { stop: () => {} }; }
957
+ const rec = new _SR();
958
+ rec.continuous = true;
959
+ rec.interimResults = true;
960
+ rec.lang = lang;
961
+
962
+ let finalText = "";
963
+ let stopped = false;
964
+ let cancelled = false;
965
+ let silenceTimer = null;
966
+ const armSilenceTimer = () => {
967
+ if (!silenceMs || stopped || cancelled) return;
968
+ if (silenceTimer) clearTimeout(silenceTimer);
969
+ silenceTimer = setTimeout(() => {
970
+ if (stopped || cancelled) return;
971
+ stopped = true;
972
+ try { rec.stop(); } catch {}
973
+ }, silenceMs);
974
+ };
975
+
976
+ rec.onresult = (e) => {
977
+ let interim = "";
978
+ let promoted = false;
979
+ for (let i = e.resultIndex; i < e.results.length; i++) {
980
+ const r = e.results[i];
981
+ if (r.isFinal) { finalText += r[0].transcript; promoted = true; }
982
+ else interim += r[0].transcript;
983
+ }
984
+ onInterim?.(finalText + interim);
985
+ if (promoted) onFinalChunk?.(finalText.trim());
986
+ armSilenceTimer();
987
+ };
988
+ rec.onerror = (e) => {
989
+ if (silenceTimer) { clearTimeout(silenceTimer); silenceTimer = null; }
990
+ onError?.(e.error || "unknown");
991
+ };
992
+ rec.onend = () => {
993
+ if (silenceTimer) { clearTimeout(silenceTimer); silenceTimer = null; }
994
+ const reason = cancelled ? "cancel" : stopped ? "user" : "auto";
995
+ if (finalText && reason !== "cancel") onFinal?.(finalText.trim(), { reason });
996
+ onEnd?.({ reason });
997
+ };
998
+
999
+ try { rec.start(); }
1000
+ catch (err) {
1001
+ onError?.(`start-failed: ${err.message || err}`);
1002
+ onEnd?.({ reason: "error" });
1003
+ return { stop: () => {} };
1004
+ }
1005
+ return {
1006
+ stop: ({ cancel = false } = {}) => {
1007
+ stopped = true;
1008
+ cancelled = !!cancel;
1009
+ if (silenceTimer) { clearTimeout(silenceTimer); silenceTimer = null; }
1010
+ try { rec.stop(); } catch {}
1011
+ },
1012
+ };
1013
+ }
1014
+
1015
+ // SVG glyphs for the mic button. 14×14 outer with a 24-viewBox internal
1016
+ // path (matches send/slash sizing). The notice icon uses a smaller 11×11
1017
+ // outer for the inline chat indicator.
1018
+ const MIC_BTN_SVG = `<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
1019
+ <path d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3zM19 11a7 7 0 0 1-14 0M12 18v3M8 21h8"
1020
+ stroke="currentColor" stroke-width="1.6" fill="none"
1021
+ stroke-linecap="round" stroke-linejoin="round"/>
1022
+ </svg>`;
1023
+ const MIC_NOTICE_SVG = `<svg viewBox="0 0 24 24" width="11" height="11" aria-hidden="true">
1024
+ <path d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z" stroke="currentColor" stroke-width="1.6" fill="none"/>
1025
+ </svg>`;
1026
+
1027
+ // 12×12 chevron used by tool-pill disclosure. Matches send/slash button
1028
+ // sizing (stroke-width 1.6) so the pill's visual weight aligns with the
1029
+ // rest of pip's chrome.
1030
+ const PIP_STEP_CHEVRON_SVG =
1031
+ `<svg class="pip-step-chevron" viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">` +
1032
+ `<path d="M4.5 3 L8 6 L4.5 9" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/>` +
1033
+ `</svg>`;
1034
+
1035
+ // JSON.stringify wrapper that truncates strings > maxStr chars — keeps
1036
+ // the Details disclosure readable when a tool returns a long blob.
1037
+ function _safeJson(obj, maxStr = 240) {
1038
+ return JSON.stringify(obj, (_k, v) => {
1039
+ if (typeof v === "string" && v.length > maxStr) return v.slice(0, maxStr) + `… (${v.length} chars)`;
1040
+ return v;
1041
+ }, 2);
1042
+ }
1043
+
1044
+ function _escHtml(s) {
1045
+ return String(s).replace(/[&<>"']/g, c =>
1046
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
1047
+ }
1048
+
1049
+ // createPip — mounts bubble + panel into `container`, wires open/close/dismiss,
1050
+ // returns handles + methods for host-layer composition (ambient notify, proactive
1051
+ // events, tool tracing, slash commands). All transport (ask, tools, executor)
1052
+ // is injected by the host so this module has no backend knowledge.
1053
+ export function createPip(opts = {}) {
1054
+ const {
1055
+ container = document.body,
1056
+ ask,
1057
+ systemPrompt = "",
1058
+ historyLimit = 10,
1059
+ introText = "",
1060
+ introDismissMs = 7000,
1061
+ autoOpen = false,
1062
+ autoOpenDelayMs = 700,
1063
+ // Auto-close the panel this many ms after an auto-open if the visitor
1064
+ // hasn't engaged. Mirror of autoOpen — if the system pulled attention
1065
+ // in, the system should release it back when the promotion is done.
1066
+ // Cancelled by any panel-side engagement (focus, hover, pointerdown).
1067
+ // null/0 disables. Manual opens are never auto-closed.
1068
+ panelAutoCloseMs = null,
1069
+ onSubmit = null, // optional host handler; receives (text, turnApi) — if present, bypasses `ask`
1070
+ onSlash = null, // optional (text) -> { reply?, clearedUI? } | null — fallback after registered commands miss
1071
+ slashSource = null, // optional () -> [{ name, description?, complete? }] — overrides built-in source from registerSlash()
1072
+ placeholder = "Ask Pip…",
1073
+ maxLength = 4000,
1074
+ // Optional meta-row content. Either or both render a header strip
1075
+ // between the close button and the chat scroll. modelLabel: a small
1076
+ // pill (e.g. the active backend or model name); update via the
1077
+ // returned setModelLabel(). slashHint: a key-cap "/" button that
1078
+ // seeds the input with a slash and opens the autocomplete — a
1079
+ // discoverable affordance for slash commands.
1080
+ modelLabel = "",
1081
+ slashHint = true,
1082
+ openHotkey = "/", // global key to open + focus pip; "" or false to disable
1083
+ // Recovery copy: shown when the host returned null (no model output) or
1084
+ // an empty string (model returned nothing usable). Hosts override these
1085
+ // to point at recovery commands they actually expose, e.g. `/model tiny`.
1086
+ fallbackReply = "Can't think right now — try again?",
1087
+ emptyReply = "I don't have a good answer for that — tell me more?",
1088
+ onOpen = null,
1089
+ onClose = null,
1090
+ // Called when the user clicks the stop button while pip is responding.
1091
+ // Hosts wire it to their "abort the in-flight LLM call" path. If
1092
+ // omitted, the stop button is still rendered (for visual consistency
1093
+ // with the responding state) but click is a no-op + disables itself.
1094
+ onAbort = null,
1095
+ // Mic: Web Speech-backed dictation. When true (or an object), mounts
1096
+ // a mic button next to the send button and wires the lifecycle:
1097
+ // click toggles sticky-mode + dictation; final transcripts stuff
1098
+ // into the input + requestSubmit (same as the user typing); Escape
1099
+ // cancels. No-op when Web Speech isn't supported in this browser.
1100
+ //
1101
+ // Pass an object for advanced hooks:
1102
+ // onChunk(text) → boolean — fires on every Web Speech "final chunk"
1103
+ // before the silence-commit window. Return true to consume the
1104
+ // chunk (mic stops + input clears; no submit). Use for safety-verb
1105
+ // instant-fire that needs sub-second response while the user
1106
+ // keeps talking.
1107
+ // onFinal(text) → boolean — called when Web Speech promotes a chunk
1108
+ // to final and the silence-commit timer fires. Return true if
1109
+ // consumer handled the transcript (skip pip's default submit) —
1110
+ // e.g. injecting into an already-in-flight turn.
1111
+ //
1112
+ // suspendMic() / resumeMic() (returned methods) drive the muted-tts
1113
+ // visual state for events the gate can't see (host's TTS playback,
1114
+ // anything else). suspend() drops any active session + lights the
1115
+ // mute icon; resume() lets sticky re-engage on next idle.
1116
+ mic = false,
1117
+ } = opts;
1118
+ if (!ask && !onSubmit) throw new Error("createPip: require ask() or onSubmit()");
1119
+
1120
+ injectCss();
1121
+
1122
+ const bubble = document.createElement("button");
1123
+ bubble.type = "button";
1124
+ bubble.className = "pip-bubble";
1125
+ bubble.setAttribute("popover", "manual");
1126
+ bubble.setAttribute("aria-label", "Open assistant");
1127
+ bubble.innerHTML = ROBOT_SVG;
1128
+
1129
+ const panel = document.createElement("div");
1130
+ panel.className = "pip-panel";
1131
+ panel.setAttribute("popover", "manual");
1132
+ panel.setAttribute("role", "dialog");
1133
+ panel.setAttribute("aria-label", "Assistant");
1134
+ const scroll = document.createElement("div");
1135
+ scroll.className = "pip-scroll";
1136
+
1137
+ const notify = document.createElement("div");
1138
+ notify.className = "pip-notify";
1139
+ if (introText) notify.textContent = introText;
1140
+ else notify.hidden = true;
1141
+
1142
+ const turns = document.createElement("div");
1143
+ turns.className = "pip-turns";
1144
+
1145
+ scroll.appendChild(notify);
1146
+ scroll.appendChild(turns);
1147
+
1148
+ const form = document.createElement("form");
1149
+ form.className = "pip-form";
1150
+ const input = document.createElement("input");
1151
+ input.type = "text";
1152
+ input.className = "pip-input";
1153
+ input.autocomplete = "off";
1154
+ input.maxLength = maxLength;
1155
+ form.appendChild(input);
1156
+
1157
+ // Send + stop buttons share one slot at the right edge of the input.
1158
+ // Send is type=submit (form.submit fires the same path Enter triggers);
1159
+ // stop is wired to onAbort. Visibility: send shows when input has text
1160
+ // and pip isn't responding; stop shows while responding; both hidden
1161
+ // when input is empty + idle.
1162
+ const sendBtn = document.createElement("button");
1163
+ sendBtn.type = "submit";
1164
+ sendBtn.className = "pip-send-btn";
1165
+ sendBtn.setAttribute("aria-label", "Send");
1166
+ sendBtn.innerHTML = '<svg viewBox="0 0 12 12" width="12" height="12" aria-hidden="true"><path d="M6 10 L6 2 M2.5 5.5 L6 2 L9.5 5.5" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>';
1167
+ sendBtn.hidden = true;
1168
+ form.appendChild(sendBtn);
1169
+
1170
+ const stopBtn = document.createElement("button");
1171
+ stopBtn.type = "button";
1172
+ stopBtn.className = "pip-stop-btn";
1173
+ stopBtn.setAttribute("aria-label", "Stop");
1174
+ stopBtn.innerHTML = '<svg viewBox="0 0 12 12" width="11" height="11" aria-hidden="true"><rect width="12" height="12" rx="2" fill="currentColor"/></svg>';
1175
+ stopBtn.hidden = true;
1176
+ stopBtn.addEventListener("click", () => {
1177
+ stopBtn.disabled = true;
1178
+ if (onAbort) try { onAbort(); } catch (e) { console.warn("[pip] onAbort threw", e); }
1179
+ });
1180
+ form.appendChild(stopBtn);
1181
+
1182
+ function syncSendVisibility() {
1183
+ const hasText = input.value.trim().length > 0;
1184
+ const responding = !stopBtn.hidden;
1185
+ sendBtn.hidden = responding || !hasText;
1186
+ }
1187
+ input.addEventListener("input", syncSendVisibility);
1188
+
1189
+ // modelLabel goes into the input placeholder (`"Ask Pip… · github"`)
1190
+ // rather than a separate header element — Apple HIG / hatch pattern.
1191
+ // Visible exactly when the user needs verification (empty input,
1192
+ // about to type); disappears once they commit. /model response is
1193
+ // the active "switched" confirmation; this is the ambient indicator.
1194
+ const basePlaceholder = placeholder;
1195
+ let _modelLabel = modelLabel || "";
1196
+ function syncPlaceholder() {
1197
+ input.placeholder = _modelLabel
1198
+ ? `${basePlaceholder} · ${_modelLabel}`
1199
+ : basePlaceholder;
1200
+ input.setAttribute("aria-label", input.placeholder);
1201
+ }
1202
+ syncPlaceholder();
1203
+
1204
+ // Status panel — ambient state above the input. Hosts register items via
1205
+ // pip.registerStatusItem({ key, label, render, onClick? }); the panel
1206
+ // shows when the input is focused and empty or starts with `/`, and hides
1207
+ // automatically when no item wants to render.
1208
+ const statusPanel = document.createElement("div");
1209
+ statusPanel.className = "pip-status-panel";
1210
+ statusPanel.hidden = true;
1211
+
1212
+ // Autocomplete dropdown for slash commands. Sits between scroll and form
1213
+ // so it visually floats above the input. Hidden until the input starts
1214
+ // with `/` and slashSource yields matches.
1215
+ const slashList = document.createElement("ul");
1216
+ slashList.className = "pip-slash-suggest";
1217
+ slashList.setAttribute("role", "listbox");
1218
+ slashList.hidden = true;
1219
+
1220
+ // slashHint mounts as an inline button at the input's left edge — closer
1221
+ // to where the user's attention is when about to type. Visible only when
1222
+ // the input is empty (auto-hides once the user types anything, including
1223
+ // a literal "/" since they've taken over the slash flow themselves).
1224
+ if (slashHint) {
1225
+ form.classList.add("pip-form--slash");
1226
+ const slashKey = document.createElement("button");
1227
+ slashKey.type = "button";
1228
+ slashKey.className = "pip-slash-key";
1229
+ slashKey.setAttribute("aria-label", "Slash commands");
1230
+ slashKey.title = "Slash commands";
1231
+ slashKey.textContent = "/";
1232
+ slashKey.addEventListener("click", () => {
1233
+ input.focus();
1234
+ input.value = "/";
1235
+ input.setSelectionRange(1, 1);
1236
+ input.dispatchEvent(new Event("input", { bubbles: true }));
1237
+ });
1238
+ form.insertBefore(slashKey, input);
1239
+ const syncSlashKey = () => { slashKey.hidden = input.value.length > 0; };
1240
+ input.addEventListener("input", syncSlashKey);
1241
+ syncSlashKey();
1242
+ }
1243
+
1244
+ panel.appendChild(scroll);
1245
+ panel.appendChild(statusPanel);
1246
+ panel.appendChild(slashList);
1247
+ panel.appendChild(form);
1248
+
1249
+ // Mic state — lives at createPip closure scope so suspendMic/resumeMic
1250
+ // (returned methods) can reach in. _micArmed mirrors better-robotics's
1251
+ // _micSticky: when true, dictation auto-restarts after every commit.
1252
+ let _dictation = null;
1253
+ let _micArmed = false;
1254
+ let _micConsecutiveNoSpeech = 0;
1255
+ let _micSuspended = false;
1256
+ let _micBtn = null;
1257
+ const _micOpts = (mic && typeof mic === "object") ? mic : {};
1258
+ const _micEnabled = !!mic && isSpeechRecognitionSupported();
1259
+ if (_micEnabled) wireMic();
1260
+
1261
+ container.appendChild(bubble);
1262
+ container.appendChild(panel);
1263
+
1264
+ function surfaceMicNotice(text) {
1265
+ scroll.querySelectorAll(".pip-mic-notice").forEach(n => n.remove());
1266
+ const el = document.createElement("div");
1267
+ el.className = "pip-mic-notice";
1268
+ el.innerHTML = MIC_NOTICE_SVG + " " + _escHtml(text);
1269
+ scroll.appendChild(el);
1270
+ scroll.scrollTop = scroll.scrollHeight;
1271
+ }
1272
+
1273
+ function scrollToBottom() { scroll.scrollTop = scroll.scrollHeight; }
1274
+
1275
+ // Tool-using turn primitives. Hosts running a multi-step LLM loop call
1276
+ // these to interleave running-pill / completed-pill / text-bubble /
1277
+ // image inside one .pip-turn in arrival order — same shape every
1278
+ // Anthropic computer-use / hatch / Codex-style UI converges on. The
1279
+ // alternative (single .pip-reply per turn) collapses multi-step work
1280
+ // into one block, hiding the step-by-step reasoning users want to see.
1281
+ //
1282
+ // appendToolPill returns { el, finish({...}) } so the caller can hold
1283
+ // a handle across the tool-call's await without tracking the DOM
1284
+ // element separately. finish() fills the args/result disclosure and
1285
+ // flips the running/error state.
1286
+ function appendToolPill(turnEl, name, { label = name } = {}) {
1287
+ const el = document.createElement("div");
1288
+ el.className = "pip-step running";
1289
+ el.innerHTML =
1290
+ `<div class="pip-step-head">` +
1291
+ PIP_STEP_CHEVRON_SVG +
1292
+ `<span class="pip-step-label">${_escHtml(label)} …</span>` +
1293
+ `<span class="pip-step-elapsed"></span>` +
1294
+ `<button class="pip-step-toggle" type="button" hidden>Details</button>` +
1295
+ `</div>` +
1296
+ `<div class="pip-step-detail" hidden></div>`;
1297
+ turnEl.appendChild(el);
1298
+ const toggle = el.querySelector(".pip-step-toggle");
1299
+ const detail = el.querySelector(".pip-step-detail");
1300
+ toggle.addEventListener("click", () => {
1301
+ const open = detail.hidden;
1302
+ detail.hidden = !open;
1303
+ el.classList.toggle("expanded", open);
1304
+ toggle.textContent = open ? "Hide" : "Details";
1305
+ });
1306
+ scrollToBottom();
1307
+ return {
1308
+ el,
1309
+ // finish({label, input, result, error, durationMs}) — host
1310
+ // computes the final label (often a per-tool summary like
1311
+ // "view_robot_frame · primary"); we render whatever string is
1312
+ // passed without imposing a format.
1313
+ finish({ label: finalLabel, input, result, error, durationMs } = {}) {
1314
+ el.classList.remove("running");
1315
+ if (error) el.classList.add("error");
1316
+ if (finalLabel != null) el.querySelector(".pip-step-label").textContent = finalLabel;
1317
+ if (durationMs != null) {
1318
+ const ms = Math.round(durationMs);
1319
+ el.querySelector(".pip-step-elapsed").textContent =
1320
+ ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
1321
+ }
1322
+ const sections = [];
1323
+ if (input != null) sections.push(`<div class="pip-step-section"><span class="pip-step-detail-label">args</span><pre class="pip-step-pre">${_escHtml(_safeJson(input))}</pre></div>`);
1324
+ if (error) sections.push(`<div class="pip-step-section"><span class="pip-step-detail-label">error</span><pre class="pip-step-pre">${_escHtml(String(error?.message || error))}</pre></div>`);
1325
+ else if (result != null) sections.push(`<div class="pip-step-section"><span class="pip-step-detail-label">result</span><pre class="pip-step-pre">${_escHtml(_safeJson(result))}</pre></div>`);
1326
+ if (sections.length) {
1327
+ detail.innerHTML = sections.join("");
1328
+ toggle.hidden = false;
1329
+ }
1330
+ scrollToBottom();
1331
+ },
1332
+ };
1333
+ }
1334
+
1335
+ // Append a fresh reply bubble to the turn. Multiple bubbles per turn
1336
+ // stack vertically; tool pills land between them per arrival order.
1337
+ // setText(md) renders markdown via the host-bundled renderMd (same
1338
+ // renderer pip-core uses for its default reply). setHtml escapes
1339
+ // nothing — caller is responsible for sanitization.
1340
+ function appendReplyBubble(turnEl) {
1341
+ const el = document.createElement("div");
1342
+ el.className = "pip-iter-reply pip-reply ai-generated";
1343
+ turnEl.appendChild(el);
1344
+ return {
1345
+ el,
1346
+ setText(text) { el.innerHTML = renderMd(text); scrollToBottom(); },
1347
+ setHtml(html) { el.innerHTML = html; scrollToBottom(); },
1348
+ };
1349
+ }
1350
+
1351
+ // Inline image (camera frame, screenshot) inside a turn. Captioned by
1352
+ // `alt`. Lazy-loaded so off-screen frames don't decode eagerly.
1353
+ function appendTurnImage(turnEl, { src, alt = "" } = {}) {
1354
+ const el = document.createElement("img");
1355
+ el.className = "pip-tool-image";
1356
+ el.src = src;
1357
+ el.alt = alt;
1358
+ el.loading = "lazy";
1359
+ turnEl.appendChild(el);
1360
+ scrollToBottom();
1361
+ return el;
1362
+ }
1363
+
1364
+ // Mic lifecycle. Click toggles sticky + dictation; final transcripts go
1365
+ // through pip's input + form.requestSubmit() so they flow through the
1366
+ // existing onSubmit / ask path. Hooks (onChunk / onFinal) let the host
1367
+ // intercept for safety-verb instant-fire or in-flight-turn injection.
1368
+ function wireMic() {
1369
+ _micBtn = document.createElement("button");
1370
+ _micBtn.type = "button";
1371
+ _micBtn.className = "pip-mic-btn";
1372
+ _micBtn.setAttribute("aria-label", "Voice input");
1373
+ _micBtn.title = "Voice input — click on; stays on across commands (click again or Escape to stop)";
1374
+ _micBtn.innerHTML = MIC_BTN_SVG;
1375
+ form.appendChild(_micBtn);
1376
+ form.classList.add("pip-form--mic");
1377
+
1378
+ const setListening = (on) => {
1379
+ _micBtn.classList.toggle("listening", on);
1380
+ _micBtn.setAttribute("aria-pressed", String(!!on));
1381
+ };
1382
+
1383
+ // Snapshot the input value when dictation starts so the transcript
1384
+ // appends to existing text rather than replacing it. Cancel restores
1385
+ // this prefix so the pre-dictation draft survives.
1386
+ let prefix = "";
1387
+ // _consumed: when onChunk returned true, the host took over for this
1388
+ // session. SR.stop() can still fire a late onresult before onend
1389
+ // (Chrome buffers the in-flight finalization), so we use a flag to
1390
+ // skip both the late writeTranscript and the onEnd default-submit
1391
+ // path. Reset on every new dictation session.
1392
+ let _consumed = false;
1393
+ const writeTranscript = (text) => {
1394
+ if (_consumed) return;
1395
+ input.value = (prefix ? prefix + " " : "") + text;
1396
+ input.dispatchEvent(new Event("input", { bubbles: true }));
1397
+ };
1398
+
1399
+ const stop = ({ cancel = false } = {}) => {
1400
+ if (!_dictation) return;
1401
+ _dictation.stop({ cancel });
1402
+ _dictation = null;
1403
+ setListening(false);
1404
+ };
1405
+
1406
+ const start = () => {
1407
+ if (_dictation || _micSuspended) return;
1408
+ prefix = input.value.trim();
1409
+ setListening(true);
1410
+ _consumed = false;
1411
+ _micBtn.classList.toggle("sticky", _micArmed);
1412
+ _dictation = startDictation({
1413
+ onInterim: writeTranscript,
1414
+ onFinal: (final) => { if (final) writeTranscript(final); },
1415
+ onFinalChunk: async (chunkedFinal) => {
1416
+ if (!_micOpts.onChunk) return;
1417
+ const consumed = await _micOpts.onChunk(chunkedFinal);
1418
+ if (consumed) {
1419
+ _consumed = true;
1420
+ stop();
1421
+ input.value = "";
1422
+ input.dispatchEvent(new Event("input", { bubbles: true }));
1423
+ }
1424
+ },
1425
+ onError: (err) => {
1426
+ if (err === "not-allowed") {
1427
+ input.placeholder = "Microphone permission denied — check Site settings.";
1428
+ _micArmed = false;
1429
+ surfaceMicNotice("Microphone permission denied — click the mic icon in the address bar to allow.");
1430
+ return;
1431
+ }
1432
+ if (err === "no-speech") {
1433
+ _micConsecutiveNoSpeech++;
1434
+ if (_micConsecutiveNoSpeech >= 2) {
1435
+ // Two flakes in a row — recognizer is probably stuck. Disarm
1436
+ // sticky so the dead-loop ends; user has to re-click.
1437
+ _micArmed = false;
1438
+ surfaceMicNotice("Didn't hear anything twice in a row — click the mic again to retry.");
1439
+ } else {
1440
+ surfaceMicNotice("Didn't catch that — try again.");
1441
+ }
1442
+ }
1443
+ },
1444
+ onEnd: async ({ reason }) => {
1445
+ _dictation = null;
1446
+ setListening(false);
1447
+ const restartIfArmed = () => {
1448
+ if (!_micArmed || _micSuspended) return;
1449
+ setTimeout(() => { if (_micArmed && !_dictation && !_micSuspended) start(); }, 400);
1450
+ };
1451
+ if (reason === "cancel") {
1452
+ _micArmed = false;
1453
+ input.value = prefix;
1454
+ input.dispatchEvent(new Event("input", { bubbles: true }));
1455
+ input.focus();
1456
+ return;
1457
+ }
1458
+ // onChunk already took over for this session — skip the default
1459
+ // submit path. Sticky still applies; user expects the mic to
1460
+ // ready up again for the next utterance.
1461
+ if (_consumed) { input.focus(); restartIfArmed(); return; }
1462
+ const text = input.value.trim();
1463
+ if (!text) { input.focus(); restartIfArmed(); return; }
1464
+ _micConsecutiveNoSpeech = 0;
1465
+ if (_micOpts.onFinal) {
1466
+ const consumed = await _micOpts.onFinal(text);
1467
+ if (consumed) {
1468
+ input.value = "";
1469
+ input.dispatchEvent(new Event("input", { bubbles: true }));
1470
+ input.focus();
1471
+ restartIfArmed();
1472
+ return;
1473
+ }
1474
+ }
1475
+ // Default: submit the transcript through the input (same as the
1476
+ // user typing + pressing send). Lets the input event flush
1477
+ // before submit so the user sees the final transcript briefly
1478
+ // in the field.
1479
+ requestAnimationFrame(() => form.requestSubmit?.());
1480
+ restartIfArmed();
1481
+ },
1482
+ });
1483
+ };
1484
+
1485
+ _micBtn.addEventListener("click", () => {
1486
+ if (_dictation) {
1487
+ _micArmed = false;
1488
+ stop();
1489
+ } else {
1490
+ _micArmed = true;
1491
+ start();
1492
+ }
1493
+ });
1494
+ document.addEventListener("keydown", (e) => {
1495
+ if (e.key === "Escape" && _dictation) {
1496
+ _micArmed = false;
1497
+ stop({ cancel: true });
1498
+ }
1499
+ });
1500
+
1501
+ // Expose stop/start to the closure-scoped suspend/resume so they can
1502
+ // drop the active session and re-arm respectively.
1503
+ wireMic._stop = stop;
1504
+ wireMic._start = start;
1505
+ }
1506
+
1507
+ function suspendMic() {
1508
+ if (!_micBtn) return;
1509
+ _micSuspended = true;
1510
+ _micBtn.classList.add("muted-tts");
1511
+ _micBtn.title = "Muted — will resume when unsuspended";
1512
+ if (_dictation) wireMic._stop({ cancel: true });
1513
+ }
1514
+ function resumeMic() {
1515
+ if (!_micBtn) return;
1516
+ _micSuspended = false;
1517
+ _micBtn.classList.remove("muted-tts");
1518
+ _micBtn.title = "Voice input — click on; stays on across commands (click again or Escape to stop)";
1519
+ if (_micArmed && !_dictation) {
1520
+ setTimeout(() => { if (_micArmed && !_dictation && !_micSuspended) wireMic._start(); }, 300);
1521
+ }
1522
+ }
1523
+ // Slash entrypoint — host can wire /voice or similar to programmatically
1524
+ // toggle dictation. Same start/stop semantics as the button.
1525
+ function toggleMic() {
1526
+ if (!_micBtn) return;
1527
+ _micBtn.click();
1528
+ }
1529
+
1530
+ let pending = false;
1531
+ // collectInputValue: when set, the next non-slash submission is captured
1532
+ // as the resolved value of the returned promise instead of routing to
1533
+ // onSubmit. Lets hosts repurpose the main input for one-shot arg
1534
+ // collection (API key paste, name entry) without spawning a modal or a
1535
+ // second input field. Slash commands during collection cancel the
1536
+ // collection (resolves null) and run as normal.
1537
+ let _inputCollector = null;
1538
+ let introHandled = false;
1539
+ let introTimer = null;
1540
+ const history = [];
1541
+
1542
+ // Built-in slash-command registry. Hosts call pip.registerSlash({name,
1543
+ // description, handler, complete?}) — dispatched in submit() before
1544
+ // built-ins (/help, /clear) and the legacy onSlash callback. Keeps the
1545
+ // single mental model: one place to declare "what / commands does this
1546
+ // app expose?", and the autocomplete picks them up automatically.
1547
+ const _slashCommands = new Map();
1548
+ function registerSlashCmd(s) {
1549
+ if (!s || !s.name || typeof s.handler !== "function") {
1550
+ throw new Error("registerSlash: { name, handler } required");
1551
+ }
1552
+ _slashCommands.set(s.name.toLowerCase(), s);
1553
+ }
1554
+ function unregisterSlashCmd(name) {
1555
+ _slashCommands.delete(String(name).toLowerCase());
1556
+ }
1557
+
1558
+ // Status panel registry — { key, label?, render, onClick? }. render() returns
1559
+ // a string, { text, sublabel? }, or null/undefined to opt out of showing.
1560
+ // Insertion-ordered (Map preserves insertion order); hosts can re-register
1561
+ // with the same key to update.
1562
+ const _statusItems = new Map();
1563
+ function registerStatusItemFn(item) {
1564
+ if (!item || !item.key || typeof item.render !== "function") {
1565
+ throw new Error("registerStatusItem: { key, render } required");
1566
+ }
1567
+ _statusItems.set(item.key, item);
1568
+ renderStatusPanel();
1569
+ }
1570
+ function unregisterStatusItemFn(key) {
1571
+ _statusItems.delete(key);
1572
+ renderStatusPanel();
1573
+ }
1574
+ function inSlashMode() {
1575
+ // True while the user is composing a slash command. Drives both the
1576
+ // status panel visibility and the slash-mode dimming on the chat
1577
+ // history. Plain focus is not slash mode, so neither effect fires
1578
+ // until the user types /.
1579
+ return input.value.startsWith("/");
1580
+ }
1581
+ function renderStatusPanel() {
1582
+ const slashMode = inSlashMode();
1583
+ panel.classList.toggle("pip-panel--slash-mode", slashMode);
1584
+ if (_statusItems.size === 0 || !slashMode) {
1585
+ statusPanel.hidden = true;
1586
+ return;
1587
+ }
1588
+ const frag = document.createDocumentFragment();
1589
+ let shown = 0;
1590
+ for (const item of _statusItems.values()) {
1591
+ let value;
1592
+ try { value = item.render(); } catch (e) { console.warn("[pip] status render threw", item.key, e); continue; }
1593
+ if (value == null) continue;
1594
+ const row = document.createElement("div");
1595
+ row.className = "pip-status-item";
1596
+ if (item.onClick) row.classList.add("clickable");
1597
+ if (item.label) {
1598
+ const lbl = document.createElement("span");
1599
+ lbl.className = "pip-status-label";
1600
+ lbl.textContent = item.label;
1601
+ row.appendChild(lbl);
1602
+ }
1603
+ const val = document.createElement("span");
1604
+ val.className = "pip-status-value";
1605
+ if (typeof value === "string") {
1606
+ val.textContent = value;
1607
+ } else {
1608
+ val.textContent = value.text || "";
1609
+ if (value.sublabel) {
1610
+ const sub = document.createElement("span");
1611
+ sub.className = "pip-status-sublabel";
1612
+ sub.textContent = value.sublabel;
1613
+ val.appendChild(sub);
1614
+ }
1615
+ }
1616
+ row.appendChild(val);
1617
+ if (item.onClick) {
1618
+ row.addEventListener("mousedown", (e) => {
1619
+ e.preventDefault(); // keep input focus
1620
+ try { item.onClick(); } catch (err) { console.warn("[pip] status onClick threw", item.key, err); }
1621
+ });
1622
+ }
1623
+ frag.appendChild(row);
1624
+ shown++;
1625
+ }
1626
+ statusPanel.innerHTML = "";
1627
+ if (shown === 0) { statusPanel.hidden = true; return; }
1628
+ statusPanel.appendChild(frag);
1629
+ statusPanel.hidden = false;
1630
+ }
1631
+ input.addEventListener("focus", renderStatusPanel);
1632
+ input.addEventListener("blur", renderStatusPanel);
1633
+ input.addEventListener("input", renderStatusPanel);
1634
+ function defaultSlashSource() {
1635
+ const out = [];
1636
+ for (const s of _slashCommands.values()) out.push({ name: s.name, description: s.description, complete: s.complete });
1637
+ if (!_slashCommands.has("help")) out.push({ name: "help", description: "list commands" });
1638
+ if (!_slashCommands.has("clear")) out.push({ name: "clear", description: "clear chat history" });
1639
+ return out;
1640
+ }
1641
+ function dispatchBuiltinSlash(cmd) {
1642
+ if (cmd === "help" || cmd === "?") {
1643
+ const lines = [];
1644
+ for (const s of _slashCommands.values()) lines.push(`- \`/${s.name}\` — ${s.description || ""}`);
1645
+ lines.push("- `/clear` — clear chat history");
1646
+ lines.push("- `/help` — list commands");
1647
+ return { reply: lines.join("\n") };
1648
+ }
1649
+ if (cmd === "clear") {
1650
+ history.length = 0;
1651
+ turns.innerHTML = "";
1652
+ return { clearedUI: true };
1653
+ }
1654
+ return null;
1655
+ }
1656
+ function dispatchSlash(text) {
1657
+ const slice = text.slice(1);
1658
+ const sp = slice.indexOf(" ");
1659
+ const cmd = (sp === -1 ? slice : slice.slice(0, sp)).toLowerCase();
1660
+ const args = sp === -1 ? "" : slice.slice(sp + 1);
1661
+ const reg = _slashCommands.get(cmd);
1662
+ if (reg) {
1663
+ try { return reg.handler(args) ?? null; }
1664
+ catch (e) { return { reply: `\`/${cmd}\` failed: ${e.message || e}` }; }
1665
+ }
1666
+ return dispatchBuiltinSlash(cmd);
1667
+ }
1668
+ // Resolve the source used by the autocomplete dropdown — host override
1669
+ // wins; otherwise enumerate the registered + built-in set.
1670
+ const getSlashSource = () => (slashSource ? slashSource() : defaultSlashSource()) || [];
1671
+
1672
+ const setResponding = (on) => {
1673
+ bubble.classList.toggle("responding", !!on);
1674
+ // Morph the right-edge slot: stop while in-flight, send (or hidden) while idle.
1675
+ stopBtn.hidden = !on;
1676
+ if (on) stopBtn.disabled = false;
1677
+ syncSendVisibility();
1678
+ };
1679
+ // Sleep state — host signals "no provider available right now" (operator
1680
+ // offline, no onSubmit wired). The bubble stays clickable; visuals
1681
+ // communicate that an answer isn't currently coming. Automatic when
1682
+ // onSubmit was never passed; otherwise drive from the host's status.
1683
+ const setSleeping = (on) => {
1684
+ bubble.classList.toggle("sleeping", !!on);
1685
+ };
1686
+ if (typeof onSubmit !== "function") setSleeping(true);
1687
+
1688
+ // Panel auto-close — armed only by auto-open, cancelled the moment the
1689
+ // visitor engages with the panel. Idempotent: re-arming clears any
1690
+ // prior pending close.
1691
+ let panelAutoCloseTimer = null;
1692
+ function cancelPanelAutoClose() {
1693
+ if (panelAutoCloseTimer) {
1694
+ clearTimeout(panelAutoCloseTimer);
1695
+ panelAutoCloseTimer = null;
1696
+ }
1697
+ // If the ghost-out animation is already mid-flight when the visitor
1698
+ // engages, abort it so the panel snaps back to fully visible. The
1699
+ // class drop reverts the forwards-filled animation styles instantly.
1700
+ if (panel.classList.contains("pip-panel--ghosting")) {
1701
+ panel.classList.remove("pip-panel--ghosting");
1702
+ }
1703
+ }
1704
+ function armPanelAutoClose() {
1705
+ if (!panelAutoCloseMs) return;
1706
+ cancelPanelAutoClose();
1707
+ panelAutoCloseTimer = setTimeout(() => {
1708
+ panelAutoCloseTimer = null;
1709
+ if (!panel.matches(":popover-open")) return;
1710
+ // Ghost-out: ride a 360ms scale+fade+blur animation, then hide the
1711
+ // popover. Manual closes (escape, click-outside, bubble-tap) stay
1712
+ // instant — only this system-initiated path ceremonies the dismiss.
1713
+ panel.classList.add("pip-panel--ghosting");
1714
+ const onEnd = () => {
1715
+ panel.classList.remove("pip-panel--ghosting");
1716
+ close();
1717
+ };
1718
+ panel.addEventListener("animationend", onEnd, { once: true });
1719
+ // Fallback in case animationend doesn't fire (motion disabled,
1720
+ // popover suddenly hidden by other code) — match the keyframe
1721
+ // duration plus a small buffer.
1722
+ setTimeout(() => {
1723
+ if (panel.classList.contains("pip-panel--ghosting")) onEnd();
1724
+ }, 500);
1725
+ }, panelAutoCloseMs);
1726
+ }
1727
+
1728
+ function dismissIntro() {
1729
+ if (!notify || notify.hidden || notify.classList.contains("dismissing")) return;
1730
+ notify.classList.add("dismissing");
1731
+ setTimeout(() => {
1732
+ notify.hidden = true;
1733
+ notify.classList.remove("dismissing");
1734
+ }, 420);
1735
+ }
1736
+ function armIntroTimer() {
1737
+ if (introHandled || notify.hidden) return;
1738
+ introHandled = true;
1739
+ introTimer = setTimeout(dismissIntro, introDismissMs);
1740
+ }
1741
+ function killIntro() {
1742
+ if (introTimer) { clearTimeout(introTimer); introTimer = null; }
1743
+ introHandled = true;
1744
+ dismissIntro();
1745
+ }
1746
+
1747
+ function open({ focus = true } = {}) {
1748
+ if (!panel.matches(":popover-open")) panel.showPopover();
1749
+ bubble.classList.add("open");
1750
+ armIntroTimer();
1751
+ if (focus) setTimeout(() => input.focus(), 0);
1752
+ if (onOpen) onOpen();
1753
+ }
1754
+ function close() {
1755
+ cancelPanelAutoClose();
1756
+ if (panel.matches(":popover-open")) panel.hidePopover();
1757
+ bubble.classList.remove("open");
1758
+ setResponding(false);
1759
+ // Quiet the panel: any prior amber-tinted replies fade to reading-
1760
+ // muted so reopening doesn't surface stale chat as "active". Removed
1761
+ // again the moment the user starts a new turn.
1762
+ panel.classList.add("pip-panel--quieted");
1763
+ if (onClose) onClose();
1764
+ }
1765
+
1766
+ bubble.addEventListener("click", () => {
1767
+ if (panel.matches(":popover-open")) close();
1768
+ else open();
1769
+ });
1770
+
1771
+ document.addEventListener("keydown", (e) => {
1772
+ if (e.key === "Escape" && panel.matches(":popover-open")) close();
1773
+ if (openHotkey && e.key === openHotkey && !e.metaKey && !e.ctrlKey && !e.altKey) {
1774
+ const t = e.target;
1775
+ const typing = t && (t === input || t.isContentEditable ||
1776
+ (t.tagName === "INPUT" && !/^(button|checkbox|radio|submit|reset|file|range|color)$/i.test(t.type)) ||
1777
+ t.tagName === "TEXTAREA" || t.tagName === "SELECT");
1778
+ if (typing) return;
1779
+ // Hotkey opens + focuses the panel for any kind of input. Don't seed
1780
+ // the keystroke into the input — users who want a slash command can
1781
+ // type / again (or click the slash-key cap). Keeps the shortcut
1782
+ // single-purpose: "open chat", not "open + start slash command".
1783
+ e.preventDefault();
1784
+ open();
1785
+ }
1786
+ });
1787
+ // Capture phase so stopPropagation elsewhere can't swallow the dismiss.
1788
+ document.addEventListener("click", (e) => {
1789
+ if (!panel.matches(":popover-open")) return;
1790
+ if (panel.contains(e.target)) return;
1791
+ if (bubble.contains(e.target)) return;
1792
+ close();
1793
+ }, true);
1794
+
1795
+ function speak(text, { fromAI = false } = {}) {
1796
+ if (fromAI) notify.innerHTML = renderMd(text);
1797
+ else notify.textContent = text;
1798
+ notify.classList.toggle("ai-generated", !!fromAI);
1799
+ notify.classList.remove("dismissing");
1800
+ notify.hidden = false;
1801
+ open({ focus: false });
1802
+ }
1803
+
1804
+ function startTurn({ echo = null } = {}) {
1805
+ // New turn = active conversation; un-quiet the panel so the new reply
1806
+ // gets the live amber treatment (older replies still fade via
1807
+ // :not(:last-child) since they're no longer the latest).
1808
+ panel.classList.remove("pip-panel--quieted");
1809
+ const t = document.createElement("div");
1810
+ t.className = "pip-turn";
1811
+ if (echo != null) {
1812
+ const e = document.createElement("div");
1813
+ e.className = "pip-echo";
1814
+ e.textContent = echo;
1815
+ t.appendChild(e);
1816
+ }
1817
+ const reply = document.createElement("div");
1818
+ reply.className = "pip-reply";
1819
+ t.appendChild(reply);
1820
+ turns.appendChild(t);
1821
+ scrollToBottom();
1822
+ return t;
1823
+ }
1824
+
1825
+ function setReplyText(turnEl, text, fromAI = false) {
1826
+ const reply = turnEl.querySelector(".pip-reply");
1827
+ if (!reply) return;
1828
+ if (fromAI) reply.innerHTML = renderMd(text);
1829
+ else reply.textContent = text;
1830
+ reply.classList.toggle("ai-generated", !!fromAI);
1831
+ scrollToBottom();
1832
+ }
1833
+
1834
+ // Inline ask — render question + option buttons (or free-text input) inside
1835
+ // the given turn, await user interaction, remove the block, resolve answer.
1836
+ // Host wires this to tool-shape "ask_human" flows + iteration-budget prompts.
1837
+ function askInChat({ question, options = [] }, turnEl = null) {
1838
+ return new Promise((resolve) => {
1839
+ const host = turnEl || turns.lastElementChild;
1840
+ if (!host) { resolve(null); return; }
1841
+ const block = document.createElement("div");
1842
+ block.className = "pip-ask";
1843
+
1844
+ const q = document.createElement("div");
1845
+ q.className = "pip-ask-q";
1846
+ q.textContent = question;
1847
+ block.appendChild(q);
1848
+
1849
+ const finalize = (answer) => { block.remove(); resolve(answer); };
1850
+
1851
+ if (options.length > 0) {
1852
+ const row = document.createElement("div");
1853
+ row.className = "pip-ask-options";
1854
+ options.forEach((opt, i) => {
1855
+ const btn = document.createElement("button");
1856
+ btn.type = "button";
1857
+ // First option is primary (filled accent); the rest secondary.
1858
+ // Caller orders options primary-first.
1859
+ btn.className = i === 0 ? "pip-ask-btn pip-ask-btn--primary" : "pip-ask-btn";
1860
+ btn.textContent = opt;
1861
+ btn.addEventListener("click", () => finalize(opt));
1862
+ row.appendChild(btn);
1863
+ });
1864
+ block.appendChild(row);
1865
+ } else {
1866
+ const af = document.createElement("form");
1867
+ af.className = "pip-ask-form";
1868
+ const ainput = document.createElement("input");
1869
+ ainput.type = "text";
1870
+ ainput.className = "pip-ask-input";
1871
+ ainput.placeholder = "Your answer…";
1872
+ const send = document.createElement("button");
1873
+ send.type = "submit";
1874
+ send.textContent = "Send";
1875
+ af.appendChild(ainput);
1876
+ af.appendChild(send);
1877
+ af.addEventListener("submit", (e) => {
1878
+ e.preventDefault();
1879
+ const v = ainput.value.trim();
1880
+ if (v) finalize(v);
1881
+ });
1882
+ block.appendChild(af);
1883
+ setTimeout(() => ainput.focus(), 0);
1884
+ }
1885
+
1886
+ const reply = host.querySelector(".pip-reply");
1887
+ host.insertBefore(block, reply || null);
1888
+ scrollToBottom();
1889
+ });
1890
+ }
1891
+
1892
+ // Thin convenience over collectInputValue for the common API-key /
1893
+ // password / token flow. Standardizes the placeholder copy ("Paste your
1894
+ // X (format)"), masks the paste, and trims/nulls empty submissions so
1895
+ // callers don't repeat that boilerplate. label/format are optional;
1896
+ // hosts can fall back to collectInputValue directly for fully custom
1897
+ // copy.
1898
+ function collectSecret({ label, format } = {}) {
1899
+ const parts = ["Paste your", label || "secret"];
1900
+ if (format) parts.push(`(${format})`);
1901
+ return collectInputValue({ placeholder: parts.join(" "), type: "password" })
1902
+ .then(v => (v && v.trim()) ? v.trim() : null);
1903
+ }
1904
+
1905
+ function collectInputValue({ placeholder, type = "text" } = {}) {
1906
+ return new Promise((resolve) => {
1907
+ // If a previous collector is still pending, cancel it — only one
1908
+ // outstanding collection at a time.
1909
+ if (_inputCollector) _inputCollector.cancel();
1910
+ const prev = {
1911
+ placeholder: input.placeholder,
1912
+ type: input.type,
1913
+ aria: input.getAttribute("aria-label"),
1914
+ };
1915
+ input.placeholder = placeholder || "Enter value…";
1916
+ input.type = type;
1917
+ input.setAttribute("aria-label", placeholder || "Enter value…");
1918
+ input.value = "";
1919
+ input.focus();
1920
+ const restore = () => {
1921
+ input.placeholder = prev.placeholder;
1922
+ input.type = prev.type;
1923
+ if (prev.aria != null) input.setAttribute("aria-label", prev.aria);
1924
+ else input.removeAttribute("aria-label");
1925
+ input.value = "";
1926
+ };
1927
+ _inputCollector = {
1928
+ resolve: (value) => { _inputCollector = null; restore(); resolve(value); },
1929
+ cancel: () => { _inputCollector = null; restore(); resolve(null); },
1930
+ };
1931
+ });
1932
+ }
1933
+
1934
+ async function submit(text) {
1935
+ if (!text || pending) return;
1936
+ // Input-collection mode: route the submission to the awaiting promise
1937
+ // rather than to onSubmit. Slash commands cancel the collection and
1938
+ // run normally — slash always works regardless of mode.
1939
+ if (_inputCollector) {
1940
+ if (text.startsWith("/")) {
1941
+ _inputCollector.cancel();
1942
+ } else {
1943
+ const fn = _inputCollector.resolve;
1944
+ input.value = "";
1945
+ fn(text);
1946
+ return;
1947
+ }
1948
+ }
1949
+ if (text.startsWith("/")) {
1950
+ // Order: registered commands → built-ins → legacy onSlash callback.
1951
+ // Registry-first so hosts can override built-ins (e.g. a custom
1952
+ // /clear that also clears server-side history) by registering with
1953
+ // the same name.
1954
+ let r = dispatchSlash(text);
1955
+ if (!r && onSlash) r = onSlash(text);
1956
+ if (r && !r.passThrough) {
1957
+ if (r.clearedUI) { scrollToBottom(); return; }
1958
+ const t = startTurn({ echo: text });
1959
+ if (r.reply) setReplyText(t, r.reply, true);
1960
+ return;
1961
+ }
1962
+ }
1963
+ pending = true;
1964
+ input.disabled = true;
1965
+ killIntro();
1966
+ history.push({ role: "user", content: text });
1967
+ if (history.length > historyLimit * 2) history.splice(0, history.length - historyLimit * 2);
1968
+ const turnEl = startTurn({ echo: text });
1969
+ setResponding(true);
1970
+
1971
+ let reply = null;
1972
+ try {
1973
+ if (onSubmit) {
1974
+ reply = await onSubmit(text, { turnEl, history, systemPrompt, setReplyText, askInChat });
1975
+ } else {
1976
+ reply = await ask(text, { history: history.slice(0, -1), systemPrompt, turnEl, askInChat });
1977
+ }
1978
+ } catch (err) {
1979
+ reply = `(couldn't reach the model: ${err?.message || err})`;
1980
+ }
1981
+
1982
+ const final = reply == null ? fallbackReply : reply || emptyReply;
1983
+ setReplyText(turnEl, final, reply != null && reply !== "");
1984
+ history.push({ role: "assistant", content: final });
1985
+ if (history.length > historyLimit * 2) history.splice(0, history.length - historyLimit * 2);
1986
+
1987
+ setResponding(false);
1988
+ input.disabled = false;
1989
+ pending = false;
1990
+ input.focus();
1991
+ }
1992
+
1993
+ // ── Slash autocomplete ────────────────────────────────────────────────
1994
+ // Two modes driven by what the user has typed after `/`:
1995
+ // no space yet → suggest command names matching the prefix
1996
+ // space typed → call the selected command's complete(partial) for arg suggestions
1997
+ let slashCurrent = []; // [{ name, description?, isArg? }] currently shown
1998
+ let slashSelected = 0;
1999
+ let slashCmdContext = null; // when in arg mode, the command we're completing for
2000
+
2001
+ function renderSlashList() {
2002
+ slashList.innerHTML = "";
2003
+ if (!slashCurrent.length) { slashList.hidden = true; return; }
2004
+ slashCurrent.forEach((item, i) => {
2005
+ const li = document.createElement("li");
2006
+ li.setAttribute("role", "option");
2007
+ if (i === slashSelected) li.classList.add("selected");
2008
+ const name = document.createElement("span");
2009
+ name.className = "name";
2010
+ name.textContent = item.isArg ? item.name : `/${item.name}`;
2011
+ li.appendChild(name);
2012
+ if (item.description) {
2013
+ const desc = document.createElement("span");
2014
+ desc.className = "desc";
2015
+ desc.textContent = item.description;
2016
+ li.appendChild(desc);
2017
+ }
2018
+ // Mouse hover promotes the row to the keyboard selection so Enter
2019
+ // and arrow keys always agree with the visible highlight. Without
2020
+ // this, hover and keyboard-cursor can land on different rows and
2021
+ // the user can't predict which one Enter will pick.
2022
+ li.addEventListener("mouseenter", () => {
2023
+ if (slashSelected === i) return;
2024
+ slashSelected = i;
2025
+ for (const sib of slashList.children) sib.classList.remove("selected");
2026
+ li.classList.add("selected");
2027
+ });
2028
+ li.addEventListener("mousedown", (e) => {
2029
+ e.preventDefault(); // keep input focus
2030
+ slashSelected = i;
2031
+ acceptSlashSuggest();
2032
+ });
2033
+ slashList.appendChild(li);
2034
+ });
2035
+ slashList.hidden = false;
2036
+ }
2037
+
2038
+ function closeSlashSuggest() {
2039
+ slashCurrent = [];
2040
+ slashSelected = 0;
2041
+ slashCmdContext = null;
2042
+ slashList.hidden = true;
2043
+ slashList.innerHTML = "";
2044
+ }
2045
+
2046
+ function updateSlashSuggest() {
2047
+ // No early-return on missing slashSource — the built-in source ensures
2048
+ // even hosts that didn't wire anything still see /help and /clear.
2049
+ const value = input.value;
2050
+ if (!value.startsWith("/")) return closeSlashSuggest();
2051
+ const slice = value.slice(1);
2052
+ const sp = slice.indexOf(" ");
2053
+ if (sp === -1) {
2054
+ // Command-name mode.
2055
+ const prefix = slice.toLowerCase();
2056
+ const all = getSlashSource();
2057
+ slashCurrent = all
2058
+ .filter((s) => s.name.toLowerCase().startsWith(prefix))
2059
+ .map((s) => ({ name: s.name, description: s.description }));
2060
+ slashCmdContext = null;
2061
+ } else {
2062
+ // Argument mode — look up the selected command and ask its complete().
2063
+ const cmd = slice.slice(0, sp).toLowerCase();
2064
+ const partial = slice.slice(sp + 1);
2065
+ const all = getSlashSource();
2066
+ const match = all.find((s) => s.name.toLowerCase() === cmd);
2067
+ slashCmdContext = match || null;
2068
+ if (match && typeof match.complete === "function") {
2069
+ let suggestions = [];
2070
+ try { suggestions = match.complete(partial) || []; } catch {}
2071
+ slashCurrent = suggestions.map((s) =>
2072
+ typeof s === "string" ? { name: s, isArg: true } : { name: s.name, description: s.description, isArg: true }
2073
+ );
2074
+ } else {
2075
+ slashCurrent = [];
2076
+ }
2077
+ }
2078
+ slashSelected = 0;
2079
+ renderSlashList();
2080
+ }
2081
+
2082
+ function acceptSlashSuggest() {
2083
+ const item = slashCurrent[slashSelected];
2084
+ if (!item) return false;
2085
+ if (item.isArg) {
2086
+ const value = input.value;
2087
+ const sp = value.indexOf(" ");
2088
+ input.value = value.slice(0, sp + 1) + item.name;
2089
+ } else {
2090
+ input.value = `/${item.name} `;
2091
+ }
2092
+ input.setSelectionRange(input.value.length, input.value.length);
2093
+ updateSlashSuggest();
2094
+ return true;
2095
+ }
2096
+
2097
+ input.addEventListener("input", updateSlashSuggest);
2098
+ input.addEventListener("focus", updateSlashSuggest);
2099
+ input.addEventListener("blur", () => {
2100
+ // Defer so a click on a suggestion still gets to mousedown handler.
2101
+ // Re-check activeElement at fire time — if focus came back to the
2102
+ // input (e.g. the slashHint key cap clicks input.focus() to reopen),
2103
+ // skip the close. Without this, clicking slashHint would briefly
2104
+ // open the dropdown then unconditionally close it 100ms later.
2105
+ setTimeout(() => {
2106
+ if (document.activeElement !== input) closeSlashSuggest();
2107
+ }, 100);
2108
+ });
2109
+ input.addEventListener("keydown", (e) => {
2110
+ if (slashList.hidden || !slashCurrent.length) return;
2111
+ if (e.key === "ArrowDown") {
2112
+ e.preventDefault();
2113
+ slashSelected = (slashSelected + 1) % slashCurrent.length;
2114
+ renderSlashList();
2115
+ } else if (e.key === "ArrowUp") {
2116
+ e.preventDefault();
2117
+ slashSelected = (slashSelected - 1 + slashCurrent.length) % slashCurrent.length;
2118
+ renderSlashList();
2119
+ } else if (e.key === "Tab") {
2120
+ e.preventDefault();
2121
+ acceptSlashSuggest();
2122
+ } else if (e.key === "Escape") {
2123
+ e.preventDefault();
2124
+ closeSlashSuggest();
2125
+ } else if (e.key === "Enter") {
2126
+ // Cmd-name mode (no space yet): accept the highlighted command and
2127
+ // stop — user may still want to type or pick args.
2128
+ // Arg mode (space typed): accept the highlighted arg into the input,
2129
+ // then let the form's default Enter→submit fire so the just-mutated
2130
+ // value lands in submit(). Without this, Enter would submit whatever
2131
+ // partial the user had typed, losing the keyboard selection — click
2132
+ // worked only because mousedown ran acceptSlashSuggest before submit.
2133
+ // The early-return at the top of this handler guarantees a visible
2134
+ // dropdown with at least one item, so accept always has a target.
2135
+ const value = input.value;
2136
+ const inCmdMode = value.startsWith("/") && !value.includes(" ");
2137
+ if (inCmdMode) { e.preventDefault(); acceptSlashSuggest(); }
2138
+ else acceptSlashSuggest();
2139
+ }
2140
+ });
2141
+
2142
+ form.addEventListener("submit", (e) => {
2143
+ e.preventDefault();
2144
+ const text = input.value.trim();
2145
+ input.value = "";
2146
+ closeSlashSuggest();
2147
+ renderStatusPanel(); // input cleared programmatically — input event won't fire
2148
+ submit(text);
2149
+ });
2150
+
2151
+ bubble.showPopover(); // hoist into top layer so the bubble floats above any modal dialog
2152
+ if (autoOpen) {
2153
+ setTimeout(() => {
2154
+ open({ focus: false });
2155
+ armPanelAutoClose();
2156
+ }, autoOpenDelayMs);
2157
+ }
2158
+ // Any panel-side engagement cancels a pending auto-close — once the
2159
+ // visitor has reached for the panel, it's theirs to dismiss. Listeners
2160
+ // are no-ops when no timer is armed, so cost is structural only.
2161
+ panel.addEventListener("focusin", cancelPanelAutoClose);
2162
+ panel.addEventListener("pointerdown", cancelPanelAutoClose);
2163
+ panel.addEventListener("mouseenter", cancelPanelAutoClose);
2164
+
2165
+ // Live-update the model label in the placeholder (e.g. when /model
2166
+ // switches the active backend). Empty/null reverts to the bare
2167
+ // placeholder so consumers can clear without rebuilding the panel.
2168
+ function setModelLabel(label) {
2169
+ _modelLabel = label || "";
2170
+ syncPlaceholder();
2171
+ }
2172
+
2173
+ return {
2174
+ bubble, panel, notify, turns, input, form, scroll,
2175
+ history,
2176
+ open, close,
2177
+ speak,
2178
+ ask: submit, // programmatic chat-turn entry
2179
+ startTurn,
2180
+ setReplyText,
2181
+ setResponding,
2182
+ setSleeping,
2183
+ setModelLabel,
2184
+ askInChat,
2185
+ collectInputValue,
2186
+ collectSecret,
2187
+ registerSlash: registerSlashCmd,
2188
+ unregisterSlash: unregisterSlashCmd,
2189
+ registerStatusItem: registerStatusItemFn,
2190
+ unregisterStatusItem: unregisterStatusItemFn,
2191
+ refreshStatusPanel: renderStatusPanel,
2192
+ dismissIntro: killIntro,
2193
+ // Mic controls — wired only when createPip({mic}) is set AND Web
2194
+ // Speech is supported. Otherwise they're no-ops.
2195
+ suspendMic, resumeMic, toggleMic, surfaceMicNotice,
2196
+ micSupported: isSpeechRecognitionSupported(),
2197
+ // Tool-using turn primitives. Hosts running multi-step LLM loops
2198
+ // (computer-use, tool dispatch) call these to interleave running
2199
+ // pills + completed pills + text bubbles + inline images inside
2200
+ // one .pip-turn in arrival order. See appendToolPill above for the
2201
+ // full pill lifecycle.
2202
+ appendToolPill, appendReplyBubble, appendTurnImage, scrollToBottom,
2203
+ isOpen: () => panel.matches(":popover-open"),
2204
+ isPending: () => pending,
2205
+ destroy() {
2206
+ bubble.remove();
2207
+ panel.remove();
2208
+ },
2209
+ };
2210
+ }