@mjasnikovs/pi-task 0.13.6 → 0.13.7

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/dist/remote/ui.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { STYLES } from './ui-styles.js';
2
+ import { clientScript } from './ui-script.js';
1
3
  export function html(wsUrl) {
2
4
  const iconSvg = encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180">`
3
5
  + `<rect width="180" height="180" rx="38" fill="#1e1e2e"/>`
@@ -25,208 +27,7 @@ export function html(wsUrl) {
25
27
  <link rel="manifest" href="data:application/manifest+json,${manifest}">
26
28
  <title>pi-task remote</title>
27
29
  <style>
28
- :root {
29
- --base: #1e1e2e; --mantle: #181825; --crust: #11111b;
30
- --surface0: #313244; --surface1: #45475a; --surface2: #585b70;
31
- --text: #cdd6f4; --subtext1: #a6adc8; --subtext0: #7f849c;
32
- --mauve: #cba6f7; --blue: #89b4fa; --green: #a6e3a1; --red: #f38ba8;
33
- --yellow: #f9e2af; --peach: #fab387; --teal: #94e2d5;
34
- }
35
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
36
- body {
37
- background: var(--base); color: var(--text);
38
- font-family: ui-monospace, monospace; height: 100dvh;
39
- display: flex; flex-direction: column; overflow: hidden;
40
- padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px)
41
- 0px env(safe-area-inset-left, 0px);
42
- }
43
- #context-bar { height: 4px; background: var(--surface0); flex-shrink: 0; }
44
- #context-bar-fill { height: 100%; background: var(--mauve); width: 0%; transition: width 0.4s ease; }
45
- #header {
46
- background: var(--mantle); padding: 8px 16px;
47
- display: flex; justify-content: space-between; align-items: center;
48
- font-size: 13px; flex-shrink: 0; border-bottom: 1px solid var(--surface0);
49
- }
50
- #header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em;
51
- position: relative; animation: glitch 5s steps(1) infinite; }
52
- @keyframes glitch {
53
- 0%, 88%, 100% { text-shadow: none; transform: translate(0, 0); }
54
- 90% { text-shadow: -1px 0 var(--red), 1px 0 var(--teal); transform: translate(1px, -1px); }
55
- 92% { text-shadow: 1px 0 var(--red), -1px 0 var(--blue); transform: translate(-1px, 1px); }
56
- 94% { text-shadow: -1px 0 var(--blue), 1px 0 var(--red); transform: translate(1px, 0); }
57
- 96% { text-shadow: 1px 0 var(--teal), -1px 0 var(--red); transform: translate(-1px, 0); }
58
- }
59
- @media (prefers-reduced-motion: reduce) { #header .title { animation: none; } }
60
- #header .hgroup { display: flex; align-items: center; gap: 10px; }
61
- #bell {
62
- background: none; border: none; color: var(--subtext1); cursor: pointer;
63
- font-size: 15px; line-height: 1; padding: 2px; font-family: inherit;
64
- }
65
- #bell:hover { color: var(--text); }
66
- #bell.on { color: var(--mauve); }
67
- #chat-log {
68
- flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 16px;
69
- display: flex; flex-direction: column; gap: 8px;
70
- }
71
- #chat-log::-webkit-scrollbar { width: 6px; }
72
- #chat-log::-webkit-scrollbar-track { background: transparent; }
73
- #chat-log::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 3px; }
74
- .bubble {
75
- max-width: 82%; padding: 8px 12px; border-radius: 8px;
76
- line-height: 1.6; white-space: pre-wrap; word-break: break-word; font-size: 13px;
77
- }
78
- .bubble.user { background: var(--surface1); color: var(--text); align-self: flex-end; }
79
- .bubble.assistant { background: var(--surface0); color: var(--text); align-self: flex-start; }
80
- .bubble.error {
81
- background: var(--crust); color: var(--red); align-self: stretch;
82
- max-width: 100%; border: 1px solid var(--red); font-size: 12px;
83
- }
84
- /* Persistent inline system note (e.g. context compaction) — a muted centered
85
- divider, distinct from chat bubbles. */
86
- .sysnote {
87
- align-self: center; color: var(--subtext0); font-size: 11px;
88
- font-family: ui-monospace, monospace; letter-spacing: 0.5px;
89
- padding: 2px 10px; opacity: 0.85;
90
- }
91
- .bubble.thinking {
92
- display: flex; gap: 5px; align-items: center; padding: 10px 14px;
93
- }
94
- .bubble.thinking .spinner {
95
- color: var(--mauve); font-size: 15px; line-height: 1;
96
- font-family: ui-monospace, monospace;
97
- }
98
- .tool-call {
99
- background: var(--crust); border-radius: 6px; align-self: flex-start;
100
- max-width: 90%; font-size: 12px; border: 1px solid var(--surface0);
101
- }
102
- .tool-call summary {
103
- padding: 6px 10px; color: var(--subtext1); cursor: pointer;
104
- user-select: none; list-style: none;
105
- overflow-wrap: anywhere; word-break: break-word;
106
- }
107
- .tool-call summary::-webkit-details-marker { display: none; }
108
- .tool-call summary::before { content: "▶ "; }
109
- .tool-call[open] > summary::before { content: "▼ "; }
110
- .tool-call.error > summary { color: var(--red); }
111
- .tool-call pre {
112
- padding: 8px 12px; overflow-y: auto;
113
- color: var(--subtext1); font-size: 11px; max-height: 280px;
114
- border-top: 1px solid var(--surface0);
115
- white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;
116
- }
117
- .tool-spin { color: var(--mauve); margin-left: 6px; font-family: ui-monospace, monospace; font-size: 13px; }
118
- .code-block {
119
- background: var(--crust); border: 1px solid var(--surface0);
120
- border-radius: 6px; overflow: hidden; margin: 4px 0;
121
- align-self: stretch; max-width: 100%; font-size: 12px;
122
- }
123
- .code-lang {
124
- background: var(--surface0); color: var(--subtext0);
125
- font-size: 10px; padding: 3px 10px; letter-spacing: 0.05em;
126
- }
127
- .code-block code {
128
- display: block; padding: 10px 12px; overflow-x: auto;
129
- color: var(--text); white-space: pre; line-height: 1.55;
130
- }
131
- .hl-kw { color: var(--mauve); }
132
- .hl-str { color: var(--green); }
133
- .hl-cmt { color: var(--subtext0); font-style: italic; }
134
- .hl-num { color: var(--blue); }
135
- .hl-fn { color: var(--yellow); }
136
- #input-bar {
137
- background: var(--mantle); padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));
138
- display: flex; gap: 8px; flex-shrink: 0;
139
- border-top: 1px solid var(--surface0);
140
- position: relative;
141
- }
142
- #cmd-suggestions {
143
- display: none; position: absolute; bottom: 100%; left: 16px; right: 16px;
144
- background: var(--mantle); border: 1px solid var(--surface1);
145
- border-bottom: none; border-radius: 8px 8px 0 0;
146
- overflow: hidden; z-index: 10;
147
- }
148
- .cmd-item {
149
- display: flex; align-items: baseline; gap: 10px;
150
- padding: 7px 12px; cursor: pointer; font-size: 12px;
151
- border-bottom: 1px solid var(--surface0);
152
- }
153
- .cmd-item:last-child { border-bottom: none; }
154
- .cmd-item:hover, .cmd-item.active { background: var(--surface0); }
155
- .cmd-item .cmd-name { color: var(--blue); font-weight: bold; flex-shrink: 0; }
156
- .cmd-item .cmd-desc { color: var(--subtext0); }
157
- #input {
158
- flex: 1; background: var(--surface0); color: var(--text);
159
- border: none; border-radius: 6px; padding: 8px 12px;
160
- font-family: inherit; font-size: 13px; resize: none;
161
- outline: none; line-height: 1.5; min-height: 36px; max-height: 120px;
162
- }
163
- #input::placeholder { color: var(--subtext0); }
164
- #input:focus { box-shadow: 0 0 0 1px var(--mauve); }
165
- #send {
166
- background: var(--blue); color: var(--crust); border: none;
167
- border-radius: 6px; padding: 8px 16px; font-weight: bold;
168
- cursor: pointer; font-size: 13px; font-family: inherit;
169
- white-space: nowrap; align-self: flex-end;
170
- }
171
- #send:disabled, #input:disabled { opacity: 0.45; cursor: not-allowed; }
172
- #reconnect-overlay {
173
- display: none; position: fixed; inset: 0;
174
- background: rgba(30,30,46,0.88); color: var(--subtext1);
175
- justify-content: center; align-items: center;
176
- font-size: 13px; z-index: 100; letter-spacing: 0.03em;
177
- }
178
- #reconnect-overlay.visible { display: flex; }
179
- /* Trailing stream indicator: the same braille spinner as the thinking bubble,
180
- inline at the end of the streaming text (not a green blinking block). */
181
- .cursor {
182
- color: var(--mauve); margin-left: 2px;
183
- font-family: ui-monospace, monospace;
184
- }
185
- #status-panel { padding: 6px 12px; border-bottom: 1px solid var(--surface1);
186
- color: var(--subtext1); white-space: pre-wrap; font-size: 13px; display: none; }
187
- #prompt-card { position: fixed; left: 0; right: 0; bottom: 0; background: var(--mantle);
188
- border-top: 2px solid var(--mauve); padding: 16px 14px calc(16px + env(safe-area-inset-bottom, 0px));
189
- display: none; z-index: 50; max-height: 80dvh; overflow-y: auto; }
190
- #prompt-card .q-label { color: var(--mauve); font-size: 11px; font-weight: 700;
191
- text-transform: uppercase; letter-spacing: .6px; margin-bottom: 6px; }
192
- #prompt-card .q { color: var(--text); margin-bottom: 12px; white-space: pre-wrap;
193
- font-size: 15px; line-height: 1.5; }
194
- #prompt-card .rec-panel { background: var(--surface0); border-left: 3px solid var(--green);
195
- border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }
196
- #prompt-card .rec-label { color: var(--green); font-size: 11px; font-weight: 700;
197
- text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }
198
- #prompt-card .rec-text { color: var(--text); font-size: 15px; line-height: 1.5;
199
- white-space: pre-wrap; overflow-wrap: anywhere; }
200
- #prompt-card textarea { width: 100%; background: var(--surface0); color: var(--text);
201
- border: 1px solid var(--surface2); border-radius: 6px; padding: 10px; font-size: 15px;
202
- font-family: inherit; line-height: 1.5; resize: vertical; margin-bottom: 4px; }
203
- #prompt-card .row { display: flex; gap: 8px; margin-top: 12px; align-items: stretch;
204
- flex-wrap: wrap; }
205
- /* Recommendation answers can be long sentences, so stack them as a readable list. */
206
- #prompt-card .row.stacked { flex-direction: column; align-items: stretch; }
207
- #prompt-card .row.stacked button { flex: none; text-align: left; }
208
- #prompt-card .row.stacked button.cancel { align-self: center; text-align: center; }
209
- #prompt-card button { padding: 11px 16px; border-radius: 8px; border: none; cursor: pointer;
210
- font-family: inherit; font-size: 14px; font-weight: 600; transition: filter .15s ease; }
211
- #prompt-card button:hover { filter: brightness(1.08); }
212
- #prompt-card button.primary { background: var(--green); color: var(--crust);
213
- font-weight: 700; flex: 1; min-width: 160px; }
214
- #prompt-card button.secondary { background: var(--surface1); color: var(--text);
215
- flex: 1; min-width: 160px; }
216
- #prompt-card button.cancel { margin-left: auto; align-self: center; background: transparent;
217
- color: var(--subtext0); font-size: 12px; font-weight: 500; padding: 8px 10px; }
218
- #prompt-card button.cancel:hover { color: var(--red); filter: none; }
219
- #prompt-card button.cancel.armed { background: var(--red); color: var(--crust); font-weight: 700; }
220
- .toast { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 12px);
221
- right: calc(env(safe-area-inset-right, 0px) + 12px); max-width: calc(100vw - 24px);
222
- padding: 8px 12px; border-radius: 6px; overflow-wrap: anywhere; word-break: break-word;
223
- background: var(--surface1); color: var(--text); z-index: 60; }
224
- .toast.warning { background: var(--peach); color: var(--crust); }
225
- .toast.error { background: var(--red); color: var(--crust); }
226
- #viewer { position: fixed; inset: 24px; background: var(--mantle); border: 1px solid var(--surface2);
227
- border-radius: 8px; padding: 16px; overflow: auto; white-space: pre-wrap;
228
- overflow-wrap: anywhere; word-break: break-word; display: none; z-index: 70; }
229
- #viewer .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: var(--subtext0); }
30
+ ${STYLES}
230
31
  </style>
231
32
  </head>
232
33
  <body>
@@ -257,804 +58,7 @@ export function html(wsUrl) {
257
58
  </div>
258
59
  <div id="viewer"><span class="close" id="viewer-close">&#x2715;</span><div id="viewer-body"></div></div>
259
60
  <script>
260
- // Connect the WebSocket back to whatever host served this page (LAN or
261
- // Tailscale), not a server-baked IP — otherwise opening the LAN URL on a
262
- // non-Tailscale device tries to reach the Tailscale IP and hangs. Fall back
263
- // to the server-provided URL only if location is somehow unavailable.
264
- const FALLBACK_WS_URL = ${JSON.stringify(wsUrl)};
265
- const WS_URL = (location && location.host)
266
- ? (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws'
267
- : FALLBACK_WS_URL;
268
- const chatLog = document.getElementById('chat-log');
269
- const inputEl = document.getElementById('input');
270
- const sendBtn = document.getElementById('send');
271
- const contextFill = document.getElementById('context-bar-fill');
272
- function setContextBar(usage) {
273
- if (usage && usage.percent != null) contextFill.style.width = usage.percent + '%';
274
- }
275
- const reconnectOverlay = document.getElementById('reconnect-overlay');
276
- const reconnectMsg = document.getElementById('reconnect-msg');
277
- const cmdSuggestions = document.getElementById('cmd-suggestions');
278
- const statusPanel = document.getElementById('status-panel');
279
- // Widgets are keyed (e.g. 'pi-tasks', 'pi-task-auto'); track them per key so a
280
- // clear for one key can't be masked by a stale message from another.
281
- // Single authoritative task-widget slot. The snapshot and the live 'widget'
282
- // delta both set this; null hides the panel. (No more per-key map that could
283
- // strand an orphaned widget on screen.)
284
- let taskWidgetLines = null;
285
- function renderWidgets() {
286
- if (taskWidgetLines && taskWidgetLines.length) {
287
- statusPanel.textContent = taskWidgetLines.join('\\n');
288
- statusPanel.style.display = 'block';
289
- } else {
290
- statusPanel.style.display = 'none';
291
- }
292
- }
293
- const promptCard = document.getElementById('prompt-card');
294
- const promptQ = document.getElementById('prompt-q');
295
- const promptRec = document.getElementById('prompt-rec');
296
- const promptRecText = document.getElementById('prompt-rec-text');
297
- const promptInput = document.getElementById('prompt-input');
298
- const promptButtons = document.getElementById('prompt-buttons');
299
- const viewer = document.getElementById('viewer');
300
- const viewerBody = document.getElementById('viewer-body');
301
- document.getElementById('viewer-close').onclick = function () { viewer.style.display = 'none'; };
302
- let activePromptId = null;
303
- let activeRecommended = '';
304
- let activeRecommended2 = '';
305
- let cancelArmTimer = null;
306
- const toolCallMap = {};
307
- let currentBubble = null;
308
- let streamText = '';
309
- let autoScroll = true;
310
- let reconnectDelay = 1000;
311
- let reconnectAnim = null;
312
- let ws = null;
313
-
314
- const BT = String.fromCharCode(96);
315
- const JS_LANGS = new Set(['js','jsx','mjs','cjs','javascript','ts','tsx','typescript']);
316
- const JS_KW = new Set(['break','case','catch','class','const','continue','debugger',
317
- 'default','delete','do','else','export','extends','finally','for','from','function',
318
- 'if','import','in','instanceof','let','new','of','return','static','super','switch',
319
- 'this','throw','try','typeof','var','void','while','with','yield','async','await',
320
- 'type','interface','enum','implements','abstract','as','declare','namespace',
321
- 'readonly','undefined','null','true','false','override','satisfies']);
322
-
323
- function escHtml(s) {
324
- return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
325
- }
326
-
327
- function syntaxHighlight(code, lang) {
328
- if (!JS_LANGS.has((lang || '').toLowerCase())) return escHtml(code);
329
- let r = '', i = 0;
330
- while (i < code.length) {
331
- const ch = code[i];
332
- // Template literal
333
- if (ch === BT) {
334
- let j = i + 1;
335
- while (j < code.length) {
336
- if (code[j] === '\\\\') { j += 2; continue; }
337
- if (code[j] === BT) { j++; break; }
338
- j++;
339
- }
340
- r += '<span class="hl-str">' + escHtml(code.slice(i, j)) + '</span>';
341
- i = j; continue;
342
- }
343
- // Single / double quoted string
344
- if (ch === '"' || ch === "'") {
345
- let j = i + 1;
346
- while (j < code.length) {
347
- if (code[j] === '\\\\') { j += 2; continue; }
348
- if (code[j] === ch || code[j] === '\\n') break;
349
- j++;
350
- }
351
- if (code[j] === ch) j++;
352
- r += '<span class="hl-str">' + escHtml(code.slice(i, j)) + '</span>';
353
- i = j; continue;
354
- }
355
- // Line comment
356
- if (ch === '/' && code[i + 1] === '/') {
357
- let j = i + 2;
358
- while (j < code.length && code[j] !== '\\n') j++;
359
- r += '<span class="hl-cmt">' + escHtml(code.slice(i, j)) + '</span>';
360
- i = j; continue;
361
- }
362
- // Block comment
363
- if (ch === '/' && code[i + 1] === '*') {
364
- let j = i + 2;
365
- while (j < code.length && !(code[j] === '*' && code[j + 1] === '/')) j++;
366
- j += 2;
367
- r += '<span class="hl-cmt">' + escHtml(code.slice(i, j)) + '</span>';
368
- i = j; continue;
369
- }
370
- // Number
371
- if (ch >= '0' && ch <= '9') {
372
- let j = i;
373
- if (code[i] === '0' && /[xXoObB]/.test(code[i + 1] || '')) {
374
- j += 2; while (j < code.length && /[0-9a-fA-F_]/.test(code[j])) j++;
375
- } else {
376
- while (j < code.length && (code[j] >= '0' && code[j] <= '9' || code[j] === '_')) j++;
377
- if (code[j] === '.') { j++; while (j < code.length && code[j] >= '0' && code[j] <= '9') j++; }
378
- if (code[j] === 'e' || code[j] === 'E') {
379
- j++; if (code[j] === '+' || code[j] === '-') j++;
380
- while (j < code.length && code[j] >= '0' && code[j] <= '9') j++;
381
- }
382
- if (code[j] === 'n') j++;
383
- }
384
- r += '<span class="hl-num">' + escHtml(code.slice(i, j)) + '</span>';
385
- i = j; continue;
386
- }
387
- // Identifier / keyword / function call
388
- if (/[a-zA-Z_$]/.test(ch)) {
389
- let j = i;
390
- while (j < code.length && /[a-zA-Z0-9_$]/.test(code[j])) j++;
391
- const word = code.slice(i, j);
392
- if (JS_KW.has(word)) {
393
- r += '<span class="hl-kw">' + word + '</span>';
394
- } else if (code[j] === '(') {
395
- r += '<span class="hl-fn">' + escHtml(word) + '</span>';
396
- } else {
397
- r += escHtml(word);
398
- }
399
- i = j; continue;
400
- }
401
- r += escHtml(ch); i++;
402
- }
403
- return r;
404
- }
405
-
406
- function setContent(el, text) {
407
- el.innerHTML = '';
408
- const BT3 = BT + BT + BT;
409
- const re = new RegExp(BT3 + '([^\\n' + BT + ']*)\\n([\\s\\S]*?)' + BT3, 'g');
410
- let last = 0, m;
411
- while ((m = re.exec(text)) !== null) {
412
- if (m.index > last) el.appendChild(document.createTextNode(text.slice(last, m.index)));
413
- const lang = m[1].trim();
414
- const code = m[2];
415
- const wrap = document.createElement('div');
416
- wrap.className = 'code-block';
417
- if (lang) { const lb = document.createElement('div'); lb.className = 'code-lang'; lb.textContent = lang; wrap.appendChild(lb); }
418
- const pre = document.createElement('pre');
419
- const codeEl = document.createElement('code');
420
- codeEl.innerHTML = syntaxHighlight(code, lang);
421
- pre.appendChild(codeEl);
422
- wrap.appendChild(pre);
423
- el.appendChild(wrap);
424
- last = m.index + m[0].length;
425
- }
426
- if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
427
- }
428
-
429
- const COMMANDS = [
430
- { name: '/task', desc: 'Start a new task' },
431
- { name: '/task-list', desc: 'List tasks in this project' },
432
- { name: '/task-resume', desc: 'Resume a task' },
433
- { name: '/task-cancel', desc: 'Cancel the currently running task' },
434
- { name: '/task-auto', desc: 'Plan a feature into tasks and run them' },
435
- { name: '/task-auto-resume', desc: 'Resume the active /task-auto run' },
436
- { name: '/task-auto-cancel', desc: 'Stop the running /task-auto loop after the current task' },
437
- { name: '/new', desc: 'Start a new session' },
438
- { name: '/clear', desc: 'Clear the conversation' },
439
- { name: '/compact', desc: 'Compact context to save tokens' },
440
- { name: '/help', desc: 'Show available commands' },
441
- { name: '/fast', desc: 'Toggle fast mode' },
442
- { name: '/remote stop', desc: 'Stop the remote server' },
443
- ];
444
- let cmdActive = [];
445
- let cmdIndex = -1;
446
-
447
- function renderSuggestions() {
448
- if (cmdActive.length === 0) { cmdSuggestions.style.display = 'none'; return; }
449
- cmdSuggestions.style.display = 'block';
450
- cmdSuggestions.innerHTML = '';
451
- cmdActive.forEach((cmd, i) => {
452
- const el = document.createElement('div');
453
- el.className = 'cmd-item' + (i === cmdIndex ? ' active' : '');
454
- el.innerHTML = '<span class="cmd-name">' + cmd.name + '</span><span class="cmd-desc">' + cmd.desc + '</span>';
455
- el.addEventListener('mousedown', (e) => { e.preventDefault(); pickCmd(i); });
456
- cmdSuggestions.appendChild(el);
457
- });
458
- }
459
-
460
- function updateSuggestions() {
461
- const val = inputEl.value;
462
- if (!val.startsWith('/')) { cmdActive = []; cmdIndex = -1; renderSuggestions(); return; }
463
- cmdActive = COMMANDS.filter(c => c.name.startsWith(val));
464
- cmdIndex = cmdActive.length === 1 ? 0 : -1;
465
- renderSuggestions();
466
- }
467
-
468
- function pickCmd(i) {
469
- if (!cmdActive[i]) return;
470
- inputEl.value = cmdActive[i].name + ' ';
471
- cmdActive = []; cmdIndex = -1; renderSuggestions();
472
- inputEl.focus();
473
- }
474
-
475
- chatLog.addEventListener('scroll', () => {
476
- const { scrollTop, scrollHeight, clientHeight } = chatLog;
477
- autoScroll = scrollTop + clientHeight >= scrollHeight - 24;
478
- });
479
-
480
- function scrollBottom() { if (autoScroll) chatLog.scrollTop = chatLog.scrollHeight; }
481
-
482
- function addBubble(role, text) {
483
- const el = document.createElement('div');
484
- el.className = 'bubble ' + role;
485
- setContent(el, text);
486
- chatLog.appendChild(el);
487
- scrollBottom();
488
- return el;
489
- }
490
-
491
- let thinkingEl = null;
492
- let spinTimer = null;
493
- let spinIdx = 0;
494
- const SPIN = '\\u280B\\u2819\\u2839\\u2838\\u283C\\u2834\\u2826\\u2827\\u2807\\u280F';
495
- // One braille ticker drives every '.spin' element — the thinking bubble AND the
496
- // trailing stream cursor — so they share the same frame and look identical.
497
- function spinPaint() {
498
- const g = SPIN[spinIdx % SPIN.length];
499
- const els = document.getElementsByClassName('spin');
500
- for (let i = 0; i < els.length; i++) els[i].textContent = g;
501
- }
502
- function startSpin() {
503
- spinPaint();
504
- if (spinTimer) return;
505
- spinTimer = setInterval(function () {
506
- spinIdx = (spinIdx + 1) % SPIN.length;
507
- spinPaint();
508
- }, 90);
509
- }
510
- // Stop the ticker once nothing on screen needs spinning.
511
- function stopSpinIfIdle() {
512
- if (spinTimer && !document.querySelector('.spin')) {
513
- clearInterval(spinTimer); spinTimer = null;
514
- }
515
- }
516
- function showThinking() {
517
- if (!thinkingEl) {
518
- thinkingEl = document.createElement('div');
519
- thinkingEl.className = 'bubble assistant thinking';
520
- thinkingEl.innerHTML = '<span class="spinner spin"></span>';
521
- }
522
- chatLog.appendChild(thinkingEl); // append (or move) to bottom
523
- startSpin();
524
- scrollBottom();
525
- }
526
- function hideThinking() {
527
- if (thinkingEl) thinkingEl.remove();
528
- stopSpinIfIdle();
529
- }
530
-
531
- function addToolCall(toolName, argsStr, isError) {
532
- // argsStr can be undefined (no args / JSON.stringify(undefined)); don't let
533
- // that render as the literal "name: undefined" in the collapsed summary.
534
- const label = (toolName + (argsStr ? ': ' + argsStr : '')).slice(0, 64);
535
- const d = document.createElement('details');
536
- d.className = 'tool-call' + (isError ? ' error' : '');
537
- const s = document.createElement('summary');
538
- s.textContent = label;
539
- d.appendChild(s);
540
- chatLog.appendChild(d);
541
- scrollBottom();
542
- return d;
543
- }
544
-
545
- function setEnabled(on) {
546
- const allow = on && activePromptId === null;
547
- inputEl.disabled = !allow;
548
- sendBtn.disabled = !allow;
549
- }
550
-
551
- // Stringify a tool result safely. A null/undefined result (e.g. a tool that
552
- // hasn't produced output) must NOT become the JS value undefined, whose
553
- // .slice() throws — a throw here aborts the whole snapshot rebuild after the
554
- // log was already cleared, blanking the transcript on reconnect.
555
- function toolResultText(result) {
556
- if (result == null) return '';
557
- const r = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
558
- return (r == null ? '' : r).slice(0, 8000);
559
- }
560
-
561
- // Render one tool part from the ordered parts list (running or finished).
562
- function renderToolPart(p) {
563
- const argsStr = typeof p.args === 'string' ? p.args : JSON.stringify(p.args);
564
- const d = addToolCall(p.toolName, argsStr, p.isError);
565
- if (p.done) {
566
- const pre = document.createElement('pre');
567
- pre.textContent = toolResultText(p.result);
568
- d.appendChild(pre);
569
- } else {
570
- const sp = document.createElement('span');
571
- sp.className = 'tool-spin spin';
572
- d.querySelector('summary').appendChild(sp);
573
- startSpin();
574
- toolCallMap[p.toolCallId] = d;
575
- }
576
- return d;
577
- }
578
-
579
- // A muted, centered system note (e.g. "Context compacted").
580
- function addSystemLine(text) {
581
- const el = document.createElement('div');
582
- el.className = 'sysnote';
583
- el.textContent = text;
584
- chatLog.appendChild(el);
585
- scrollBottom();
586
- return el;
587
- }
588
-
589
- // Render one committed transcript turn. Assistant turns are an ordered list of
590
- // parts (text segments + tool calls), so the layout matches the terminal's
591
- // interleaving instead of one merged blob with tools dumped at the end.
592
- function renderTurn(t) {
593
- if (t.error) { addBubble('error', t.text); return; }
594
- if (t.role === 'system') { addSystemLine(t.text); return; }
595
- if (t.role === 'user') { addBubble('user', t.text); return; }
596
- for (const p of (t.parts || [])) {
597
- if (p.kind === 'text') { if (p.text) addBubble('assistant', p.text); }
598
- else renderToolPart(p);
599
- }
600
- }
601
-
602
- // Render the in-progress assistant turn from a snapshot, preserving order. The
603
- // trailing OPEN text segment becomes the live streaming bubble (cursor + spin)
604
- // so subsequent text_delta frames keep flowing into it.
605
- function renderLiveTurn(live) {
606
- const parts = live.parts || [];
607
- for (let i = 0; i < parts.length; i++) {
608
- const p = parts[i];
609
- const last = i === parts.length - 1;
610
- if (p.kind === 'text') {
611
- if (last && live.textOpen) {
612
- currentBubble = document.createElement('div');
613
- currentBubble.className = 'bubble assistant';
614
- const cursor = document.createElement('span');
615
- cursor.className = 'cursor spin';
616
- currentBubble.appendChild(cursor);
617
- if (p.text) currentBubble.insertBefore(document.createTextNode(p.text), cursor);
618
- chatLog.appendChild(currentBubble);
619
- streamText = p.text || '';
620
- startSpin();
621
- scrollBottom();
622
- } else if (p.text) {
623
- addBubble('assistant', p.text);
624
- }
625
- } else {
626
- renderToolPart(p);
627
- }
628
- }
629
- }
630
-
631
- function showToast(message, level) {
632
- const t = document.createElement('div');
633
- t.className = 'toast ' + (level || 'info');
634
- t.textContent = message;
635
- document.body.appendChild(t);
636
- setTimeout(function () { t.remove(); }, 4000);
637
- }
638
-
639
- const bell = document.getElementById('bell');
640
- const NOTIFY_KEY = 'piRemoteNotify';
641
-
642
- function notifyEnabled() {
643
- return localStorage.getItem(NOTIFY_KEY) === '1'
644
- && typeof Notification !== 'undefined'
645
- && Notification.permission === 'granted';
646
- }
647
-
648
- function updateBell() {
649
- // ◉ (mauve) when armed, ◯ (dim) when off/unavailable.
650
- var on = notifyEnabled();
651
- bell.textContent = on ? '\\u25C9' : '\\u25EF';
652
- bell.classList.toggle('on', on);
653
- }
654
-
655
- // Why notifications can't be enabled here, or null if they can.
656
- function notifyEnvIssue() {
657
- if (typeof Notification === 'undefined') return "This browser doesn't support notifications.";
658
- if (!window.isSecureContext) return 'Notifications need HTTPS. Open the Tailscale https:// URL, or open via localhost.';
659
- const isIOS = /iP(hone|ad|od)/i.test(navigator.userAgent);
660
- const standalone = navigator.standalone === true
661
- || (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches);
662
- if (isIOS && !standalone) return 'On iOS: Share \\u2192 Add to Home Screen first, then enable notifications.';
663
- return null;
664
- }
665
-
666
- bell.addEventListener('click', function () {
667
- // Turning OFF always works regardless of environment.
668
- if (localStorage.getItem(NOTIFY_KEY) === '1') {
669
- localStorage.setItem(NOTIFY_KEY, '0'); updateBell(); return;
670
- }
671
- const issue = notifyEnvIssue();
672
- if (issue) { showToast(issue, 'warning'); return; }
673
- if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
674
- showToast('This browser doesn\\u2019t support push notifications.', 'warning'); return;
675
- }
676
- Notification.requestPermission().then(function (perm) {
677
- if (perm !== 'granted') {
678
- showToast('Notifications blocked in browser settings.', 'warning');
679
- updateBell(); return;
680
- }
681
- subscribePush().then(function (ok) {
682
- if (ok) { localStorage.setItem(NOTIFY_KEY, '1'); showToast('Notifications on.', 'info'); }
683
- else { showToast('Could not register for notifications.', 'warning'); }
684
- updateBell();
685
- }).catch(function (e) {
686
- showToast('Notification setup failed: ' + (e && e.message ? e.message : e), 'warning');
687
- updateBell();
688
- });
689
- });
690
- });
691
-
692
- // VAPID public key (base64url) -> Uint8Array for applicationServerKey.
693
- function urlB64ToUint8Array(base64) {
694
- const pad = '='.repeat((4 - base64.length % 4) % 4);
695
- const b64 = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
696
- const raw = atob(b64);
697
- const arr = new Uint8Array(raw.length);
698
- for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
699
- return arr;
700
- }
701
-
702
- // Register the service worker, subscribe via the Push API, and hand the
703
- // subscription to the server. The server (not the page) sends notifications,
704
- // so they arrive even when this PWA is backgrounded/suspended on iOS.
705
- function subscribePush() {
706
- return navigator.serviceWorker.register('/sw.js')
707
- .then(function () { return navigator.serviceWorker.ready; })
708
- .then(function (reg) {
709
- return fetch('/push-key').then(function (r) { return r.text(); }).then(function (key) {
710
- return reg.pushManager.getSubscription().then(function (existing) {
711
- return existing || reg.pushManager.subscribe({
712
- userVisibleOnly: true,
713
- applicationServerKey: urlB64ToUint8Array(key.trim())
714
- });
715
- });
716
- });
717
- })
718
- .then(function (subscription) {
719
- return fetch('/subscribe', {
720
- method: 'POST',
721
- headers: { 'Content-Type': 'application/json' },
722
- body: JSON.stringify(subscription)
723
- }).then(function (res) { return res.ok; });
724
- });
725
- }
726
-
727
- updateBell();
728
- // Self-heal: if notifications were enabled before, re-register the
729
- // subscription on load (the server keeps subscriptions in memory and may
730
- // have restarted, and browsers can rotate the subscription).
731
- if (notifyEnabled()) { subscribePush().catch(function () {}); }
732
-
733
- function answer(value) {
734
- if (activePromptId === null) return;
735
- ws.send(JSON.stringify({ type: 'prompt_answer', id: activePromptId, value: value }));
736
- closePrompt();
737
- }
738
-
739
- function closePrompt() {
740
- activePromptId = null;
741
- promptCard.style.display = 'none';
742
- promptInput.value = '';
743
- promptInput.style.display = 'none';
744
- promptRec.style.display = 'none';
745
- activeRecommended2 = '';
746
- if (cancelArmTimer) { clearTimeout(cancelArmTimer); cancelArmTimer = null; }
747
- setEnabled(true);
748
- }
749
-
750
- function makeBtn(label, cls, onClick) {
751
- const btn = document.createElement('button');
752
- btn.textContent = label;
753
- if (cls) btn.className = cls;
754
- btn.onclick = onClick;
755
- return btn;
756
- }
757
-
758
- // "Cancel task" aborts the whole run, so it's deliberately small and needs a
759
- // two-step confirm: first tap arms it, second tap (within 3s) confirms.
760
- function makeCancelBtn() {
761
- const btn = makeBtn('Cancel task', 'cancel', null);
762
- let armed = false;
763
- btn.onclick = function () {
764
- if (armed) { answer(undefined); return; }
765
- armed = true;
766
- btn.classList.add('armed');
767
- btn.textContent = 'Tap again to cancel';
768
- if (cancelArmTimer) clearTimeout(cancelArmTimer);
769
- cancelArmTimer = setTimeout(function () {
770
- armed = false;
771
- btn.classList.remove('armed');
772
- btn.textContent = 'Cancel task';
773
- cancelArmTimer = null;
774
- }, 3000);
775
- };
776
- return btn;
777
- }
778
-
779
- function renderButtons(buttons, stacked) {
780
- promptButtons.className = stacked ? 'row stacked' : 'row';
781
- promptButtons.innerHTML = '';
782
- for (let i = 0; i < buttons.length; i++) promptButtons.appendChild(buttons[i]);
783
- promptButtons.appendChild(makeCancelBtn());
784
- }
785
-
786
- // Manual-entry view: empty textarea + Submit, reachable from the
787
- // recommendation view via "Manual answer".
788
- function showManualEntry() {
789
- promptRec.style.display = 'none';
790
- promptInput.style.display = 'block';
791
- promptInput.value = '';
792
- renderButtons([
793
- makeBtn('Submit', 'primary', function () { answer(promptInput.value); }),
794
- makeBtn('← Back', 'secondary', function () { showRecommendation(); })
795
- ]);
796
- promptInput.focus();
797
- }
798
-
799
- // Recommendation view: 2-button mode when both options present, panel mode for one.
800
- function showRecommendation() {
801
- promptInput.style.display = 'none';
802
- const buttons = [];
803
- if (activeRecommended2) {
804
- // Two-option mode: each recommendation is a direct-accept button.
805
- promptRec.style.display = 'none';
806
- buttons.push(makeBtn(activeRecommended, 'primary', function () { answer(activeRecommended); }));
807
- buttons.push(makeBtn(activeRecommended2, 'secondary', function () { answer(activeRecommended2); }));
808
- buttons.push(makeBtn('✎ Manual answer', 'secondary', function () { showManualEntry(); }));
809
- // Answer buttons hold full sentences — stack them so long text stays readable.
810
- renderButtons(buttons, true);
811
- return;
812
- }
813
- // Single recommendation: show it in the green panel.
814
- promptRec.style.display = 'block';
815
- buttons.push(makeBtn('✓ Accept', 'primary', function () { answer(activeRecommended); }));
816
- buttons.push(makeBtn('✎ Manual answer', 'secondary', function () { showManualEntry(); }));
817
- renderButtons(buttons);
818
- }
819
-
820
- function showPrompt(msg) {
821
- activePromptId = msg.id;
822
- promptQ.textContent = msg.question;
823
- activeRecommended = msg.recommended || '';
824
- activeRecommended2 = msg.recommended2 || '';
825
- if (msg.recommended) {
826
- // Mode A: recommendation(s) present.
827
- promptRecText.textContent = msg.recommended;
828
- showRecommendation();
829
- } else {
830
- // Mode B: no recommendation — the user must type an answer (or skip).
831
- promptRec.style.display = 'none';
832
- promptInput.style.display = 'block';
833
- promptInput.value = '';
834
- const buttons = [makeBtn('Submit', 'primary', function () { answer(promptInput.value); })];
835
- if (msg.allowSkip) {
836
- buttons.push(makeBtn('Skip', 'secondary', function () { answer(''); }));
837
- }
838
- renderButtons(buttons);
839
- promptInput.focus();
840
- }
841
- promptCard.style.display = 'block';
842
- setEnabled(false);
843
- }
844
-
845
- function handleMsg(msg) {
846
- switch (msg.type) {
847
- case 'snapshot': {
848
- // Authoritative full state on every (re)connect: replace the WHOLE view.
849
- // This is what kills duplicated transcript / stale-orphaned widgets —
850
- // whatever was on screen is discarded and rebuilt from server truth.
851
- chatLog.innerHTML = '';
852
- closePrompt();
853
- hideThinking();
854
- currentBubble = null; streamText = '';
855
- for (const k in toolCallMap) delete toolCallMap[k];
856
- // Per-turn try/catch: one malformed turn must never abort the rebuild
857
- // and leave the (already-cleared) transcript blank.
858
- for (const t of (msg.turns || [])) { try { renderTurn(t); } catch (e) {} }
859
- if (msg.live) { try { renderLiveTurn(msg.live); } catch (e) {} }
860
- taskWidgetLines = (msg.taskWidget && msg.taskWidget.length) ? msg.taskWidget : null;
861
- renderWidgets();
862
- if (msg.context) setContextBar(msg.context); else contextFill.style.width = '0%';
863
- if (msg.prompt) showPrompt(msg.prompt);
864
- setEnabled(!msg.agentRunning && !msg.prompt);
865
- if (msg.agentRunning && !msg.live) showThinking();
866
- break;
867
- }
868
- case 'agent_start':
869
- autoScroll = true;
870
- streamText = '';
871
- currentBubble = null;
872
- setEnabled(false);
873
- showThinking();
874
- break;
875
- case 'text_delta':
876
- if (!currentBubble) {
877
- hideThinking();
878
- currentBubble = document.createElement('div');
879
- currentBubble.className = 'bubble assistant';
880
- const cursor = document.createElement('span');
881
- cursor.className = 'cursor spin';
882
- currentBubble.appendChild(cursor);
883
- chatLog.appendChild(currentBubble);
884
- startSpin();
885
- }
886
- streamText += msg.delta;
887
- {
888
- const c = currentBubble.querySelector('.cursor');
889
- currentBubble.insertBefore(document.createTextNode(msg.delta), c);
890
- }
891
- scrollBottom();
892
- break;
893
- case 'text_end':
894
- if (currentBubble) {
895
- const c = currentBubble.querySelector('.cursor');
896
- if (c) c.remove();
897
- if (streamText) setContent(currentBubble, streamText);
898
- // Close this message's bubble so the next text segment (after a tool or
899
- // the next message) starts a fresh bubble — matching the terminal.
900
- currentBubble = null;
901
- streamText = '';
902
- stopSpinIfIdle();
903
- }
904
- break;
905
- case 'tool_start': {
906
- hideThinking();
907
- const argsStr = typeof msg.args === 'string' ? msg.args : JSON.stringify(msg.args);
908
- const d = addToolCall(msg.toolName, argsStr, false);
909
- const sp = document.createElement('span');
910
- sp.className = 'tool-spin spin';
911
- d.querySelector('summary').appendChild(sp);
912
- startSpin();
913
- toolCallMap[msg.toolCallId] = d;
914
- break;
915
- }
916
- case 'tool_end': {
917
- const d = toolCallMap[msg.toolCallId];
918
- if (d) {
919
- const sp = d.querySelector('.tool-spin');
920
- if (sp) { sp.remove(); stopSpinIfIdle(); }
921
- if (msg.isError) d.classList.add('error');
922
- const pre = document.createElement('pre');
923
- pre.textContent = toolResultText(msg.result);
924
- d.appendChild(pre);
925
- delete toolCallMap[msg.toolCallId];
926
- }
927
- // Tool finished: model is thinking about the result next.
928
- currentBubble = null;
929
- streamText = '';
930
- showThinking();
931
- break;
932
- }
933
- case 'user_message':
934
- addBubble('user', msg.text);
935
- break;
936
- case 'system_note':
937
- addSystemLine(msg.text);
938
- break;
939
- case 'agent_error':
940
- hideThinking();
941
- if (currentBubble) {
942
- const c = currentBubble.querySelector('.cursor');
943
- if (c) c.remove();
944
- if (streamText) setContent(currentBubble, streamText);
945
- currentBubble = null;
946
- streamText = '';
947
- stopSpinIfIdle();
948
- }
949
- addBubble('error', msg.message || 'Error');
950
- setEnabled(true);
951
- break;
952
- case 'agent_end':
953
- hideThinking();
954
- currentBubble = null;
955
- streamText = '';
956
- setEnabled(true);
957
- setContextBar(msg.contextUsage);
958
- break;
959
- case 'context':
960
- // Seeds the bar for a client that joined mid-session.
961
- setContextBar(msg.contextUsage);
962
- break;
963
- case 'prompt':
964
- showPrompt(msg);
965
- break;
966
- case 'prompt_resolved':
967
- if (activePromptId === msg.id) closePrompt();
968
- break;
969
- case 'widget':
970
- taskWidgetLines = (msg.lines && msg.lines.length) ? msg.lines : null;
971
- renderWidgets();
972
- break;
973
- case 'notify':
974
- showToast(msg.message, msg.level);
975
- break;
976
- case 'viewer':
977
- viewerBody.textContent = msg.text;
978
- viewer.style.display = 'block';
979
- break;
980
- case 'reset':
981
- // A new session started — wipe the previous session's transcript.
982
- chatLog.innerHTML = '';
983
- hideThinking();
984
- currentBubble = null; streamText = '';
985
- closePrompt();
986
- taskWidgetLines = null;
987
- renderWidgets();
988
- contextFill.style.width = '0%';
989
- break;
990
- }
991
- }
992
-
993
- function sendMessage() {
994
- const text = inputEl.value.trim();
995
- if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
996
- ws.send(JSON.stringify({ type: 'message', text }));
997
- inputEl.value = '';
998
- inputEl.style.height = 'auto';
999
- cmdActive = []; cmdIndex = -1; renderSuggestions();
1000
- // Slash commands are handled server-side and produce no chat turn.
1001
- if (text.startsWith('/')) return;
1002
- // The server records the message via addUserTurn and broadcasts a
1003
- // user_message back to every client (us included), which renders the
1004
- // bubble. Don't render it here too, or the sender sees it twice.
1005
- setEnabled(false);
1006
- showThinking();
1007
- }
1008
-
1009
- sendBtn.addEventListener('click', sendMessage);
1010
- inputEl.addEventListener('keydown', (e) => {
1011
- if (cmdActive.length > 0) {
1012
- if (e.key === 'ArrowDown') { e.preventDefault(); cmdIndex = Math.min(cmdIndex + 1, cmdActive.length - 1); renderSuggestions(); return; }
1013
- if (e.key === 'ArrowUp') { e.preventDefault(); cmdIndex = Math.max(cmdIndex - 1, 0); renderSuggestions(); return; }
1014
- if (e.key === 'Tab') { e.preventDefault(); pickCmd(cmdIndex >= 0 ? cmdIndex : 0); return; }
1015
- if (e.key === 'Escape') { cmdActive = []; cmdIndex = -1; renderSuggestions(); return; }
1016
- if (e.key === 'Enter' && !e.shiftKey && cmdIndex >= 0) { e.preventDefault(); pickCmd(cmdIndex); return; }
1017
- }
1018
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
1019
- });
1020
- inputEl.addEventListener('input', () => {
1021
- inputEl.style.height = 'auto';
1022
- inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
1023
- updateSuggestions();
1024
- });
1025
-
1026
- function connect() {
1027
- ws = new WebSocket(WS_URL);
1028
- ws.addEventListener('open', () => {
1029
- if (reconnectAnim) { clearInterval(reconnectAnim); reconnectAnim = null; }
1030
- reconnectOverlay.classList.remove('visible');
1031
- reconnectDelay = 1000;
1032
- setEnabled(true);
1033
- });
1034
- ws.addEventListener('message', (e) => {
1035
- try { handleMsg(JSON.parse(e.data)); } catch {}
1036
- });
1037
- ws.addEventListener('close', () => {
1038
- setEnabled(false);
1039
- reconnectOverlay.classList.add('visible');
1040
- // Animate the same braille spinner used elsewhere, with a live countdown.
1041
- const until = Date.now() + reconnectDelay;
1042
- let frame = 0;
1043
- const paint = () => {
1044
- const left = Math.max(0, Math.ceil((until - Date.now()) / 1000));
1045
- const glyph = SPIN[frame++ % SPIN.length];
1046
- reconnectMsg.textContent = left > 0
1047
- ? glyph + ' connection lost — retrying in ' + left + 's'
1048
- : glyph + ' reconnecting…';
1049
- };
1050
- if (reconnectAnim) clearInterval(reconnectAnim);
1051
- paint();
1052
- reconnectAnim = setInterval(paint, 90);
1053
- setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, 30000); connect(); }, reconnectDelay);
1054
- });
1055
- }
1056
-
1057
- connect();
61
+ ${clientScript(wsUrl)}
1058
62
  </script>
1059
63
  </body>
1060
64
  </html>`;