@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.
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/package.json +44 -0
- package/pip-core.esm.js +2210 -0
- package/providers/anthropic.esm.js +120 -0
- package/providers/openai.esm.js +164 -0
- package/providers/transformers.esm.js +273 -0
- package/runtime.esm.js +386 -0
package/pip-core.esm.js
ADDED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
865
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
|
+
}
|