@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/push.d.ts +12 -3
- package/dist/remote/push.js +63 -9
- package/dist/remote/ui-script.d.ts +3 -0
- package/dist/remote/ui-script.js +804 -0
- package/dist/remote/ui-styles.d.ts +1 -0
- package/dist/remote/ui-styles.js +202 -0
- package/dist/remote/ui.js +4 -1000
- package/dist/shared/child-process.d.ts +27 -0
- package/dist/shared/child-process.js +151 -139
- package/dist/task/auto-orchestrator.js +3 -6
- package/dist/task/child-runner.js +1 -1
- package/dist/task/context-usage.d.ts +16 -0
- package/dist/task/context-usage.js +22 -0
- package/dist/task/external-context.d.ts +27 -0
- package/dist/task/external-context.js +93 -0
- package/dist/task/failure-classifier.js +1 -1
- package/dist/task/orchestrator.js +7 -13
- package/dist/task/parsers.d.ts +1 -15
- package/dist/task/parsers.js +17 -84
- package/dist/task/phases.d.ts +5 -7
- package/dist/task/phases.js +29 -84
- package/dist/task/prompts.d.ts +1 -0
- package/dist/task/prompts.js +9 -0
- package/dist/task/spec-validation.d.ts +23 -0
- package/dist/task/spec-validation.js +90 -0
- package/dist/task/widget.d.ts +1 -1
- package/dist/task/widget.js +1 -1
- package/dist/workers/pi-worker-docs.js +69 -58
- package/dist/workers/pi-worker-fetch.js +25 -21
- package/dist/workers/pi-worker-search.js +7 -13
- package/dist/workers/pi-worker.js +8 -14
- package/dist/workers/shared.d.ts +40 -0
- package/dist/workers/shared.js +31 -0
- package/package.json +1 -1
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
|
-
|
|
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">✕</span><div id="viewer-body"></div></div>
|
|
259
60
|
<script>
|
|
260
|
-
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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>`;
|