@firstpick/pi-package-webui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js ADDED
@@ -0,0 +1,1340 @@
1
+ const $ = (selector) => document.querySelector(selector);
2
+
3
+ const elements = {
4
+ sessionLine: $("#sessionLine"),
5
+ statusBar: $("#statusBar"),
6
+ widgetArea: $("#widgetArea"),
7
+ chat: $("#chat"),
8
+ composer: $("#composer"),
9
+ promptInput: $("#promptInput"),
10
+ commandSuggest: $("#commandSuggest"),
11
+ busyBehavior: $("#busyBehavior"),
12
+ steerButton: $("#steerButton"),
13
+ followUpButton: $("#followUpButton"),
14
+ abortButton: $("#abortButton"),
15
+ newSessionButton: $("#newSessionButton"),
16
+ compactButton: $("#compactButton"),
17
+ gitWorkflowButton: $("#gitWorkflowButton"),
18
+ gitWorkflowPanel: $("#gitWorkflowPanel"),
19
+ gitWorkflowTitle: $("#gitWorkflowTitle"),
20
+ gitWorkflowHint: $("#gitWorkflowHint"),
21
+ gitWorkflowSteps: $("#gitWorkflowSteps"),
22
+ gitWorkflowOutput: $("#gitWorkflowOutput"),
23
+ gitWorkflowActions: $("#gitWorkflowActions"),
24
+ gitWorkflowCancelButton: $("#gitWorkflowCancelButton"),
25
+ modelSelect: $("#modelSelect"),
26
+ setModelButton: $("#setModelButton"),
27
+ thinkingSelect: $("#thinkingSelect"),
28
+ setThinkingButton: $("#setThinkingButton"),
29
+ toggleSidePanelButton: $("#toggleSidePanelButton"),
30
+ sidePanelExpandButton: $("#sidePanelExpandButton"),
31
+ sidePanel: $("#sidePanel"),
32
+ stateDetails: $("#stateDetails"),
33
+ queueBox: $("#queueBox"),
34
+ commandsBox: $("#commandsBox"),
35
+ eventLog: $("#eventLog"),
36
+ dialog: $("#extensionDialog"),
37
+ dialogTitle: $("#dialogTitle"),
38
+ dialogMessage: $("#dialogMessage"),
39
+ dialogBody: $("#dialogBody"),
40
+ dialogActions: $("#dialogActions"),
41
+ };
42
+
43
+ let currentState = null;
44
+ let streamBubble = null;
45
+ let streamText = null;
46
+ let streamThinking = null;
47
+ let streamThinkingDetails = null;
48
+ let refreshMessagesTimer = null;
49
+ let refreshStateTimer = null;
50
+ let refreshFooterTimer = null;
51
+ let eventSource = null;
52
+ let activeDialog = null;
53
+ let availableCommands = [];
54
+ let commandSuggestions = [];
55
+ let commandSuggestIndex = 0;
56
+ let latestStats = null;
57
+ let latestWorkspace = null;
58
+ let latestMessages = [];
59
+ let currentRunStartedAt = null;
60
+ let currentRunStreamChars = 0;
61
+ let latestTokPerSecond = null;
62
+ const dialogQueue = [];
63
+ const SIDE_PANEL_STORAGE_KEY = "pi-webui-side-panel-collapsed";
64
+ const statusEntries = new Map();
65
+ const widgets = new Map();
66
+ const gitWorkflow = {
67
+ active: false,
68
+ step: "idle",
69
+ busy: false,
70
+ runId: 0,
71
+ output: "",
72
+ error: "",
73
+ message: null,
74
+ messageRequestedAt: 0,
75
+ };
76
+ const GIT_WORKFLOW_STEPS = ["Stage", "Message", "Commit", "Push"];
77
+ const GIT_WORKFLOW_ACTIVE_INDEX = {
78
+ add: 0,
79
+ generate: 1,
80
+ generating: 1,
81
+ message: 2,
82
+ committing: 2,
83
+ push: 3,
84
+ pushing: 3,
85
+ done: 4,
86
+ };
87
+
88
+ function make(tag, className, text) {
89
+ const node = document.createElement(tag);
90
+ if (className) node.className = className;
91
+ if (text !== undefined) node.textContent = text;
92
+ return node;
93
+ }
94
+
95
+ function setSidePanelCollapsed(collapsed) {
96
+ document.body.classList.toggle("side-panel-collapsed", collapsed);
97
+ elements.toggleSidePanelButton.setAttribute("aria-expanded", collapsed ? "false" : "true");
98
+ elements.toggleSidePanelButton.setAttribute("title", collapsed ? "Expand side panel" : "Collapse side panel");
99
+ elements.toggleSidePanelButton.setAttribute("aria-label", collapsed ? "Expand side panel" : "Collapse side panel");
100
+ elements.sidePanelExpandButton.setAttribute("aria-expanded", collapsed ? "false" : "true");
101
+ try {
102
+ localStorage.setItem(SIDE_PANEL_STORAGE_KEY, collapsed ? "1" : "0");
103
+ } catch {
104
+ // Ignore storage failures; the toggle should still work for this page load.
105
+ }
106
+ }
107
+
108
+ function restoreSidePanelState() {
109
+ try {
110
+ setSidePanelCollapsed(localStorage.getItem(SIDE_PANEL_STORAGE_KEY) === "1");
111
+ } catch {
112
+ setSidePanelCollapsed(false);
113
+ }
114
+ }
115
+
116
+ async function api(path, { method = "GET", body } = {}) {
117
+ const response = await fetch(path, {
118
+ method,
119
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
120
+ body: body === undefined ? undefined : JSON.stringify(body),
121
+ });
122
+ const data = await response.json().catch(() => ({}));
123
+ if (!response.ok) {
124
+ throw new Error(data.error || data.message || JSON.stringify(data));
125
+ }
126
+ return data;
127
+ }
128
+
129
+ function addEvent(message, level = "info") {
130
+ const line = make("div", `event ${level}`.trim());
131
+ const time = new Date().toLocaleTimeString();
132
+ line.textContent = `[${time}] ${message}`;
133
+ elements.eventLog.prepend(line);
134
+ while (elements.eventLog.children.length > 120) elements.eventLog.lastElementChild?.remove();
135
+ }
136
+
137
+ function formatDate(value) {
138
+ if (!value) return "";
139
+ const date = typeof value === "number" ? new Date(value) : new Date(String(value));
140
+ return Number.isNaN(date.getTime()) ? "" : date.toLocaleString();
141
+ }
142
+
143
+ function modelLabel(model) {
144
+ if (!model) return "none";
145
+ return `${model.provider}/${model.id}`;
146
+ }
147
+
148
+ function shortModelLabel(model) {
149
+ if (!model) return "unknown";
150
+ return `(${model.provider}) ${model.id}`;
151
+ }
152
+
153
+ function formatTokenCount(value) {
154
+ const n = Number(value);
155
+ if (!Number.isFinite(n)) return "?";
156
+ const abs = Math.abs(n);
157
+ const sign = n < 0 ? "-" : "";
158
+ if (abs >= 1_000_000_000) return `${sign}${(abs / 1_000_000_000).toFixed(abs >= 10_000_000_000 ? 0 : 1)}B`;
159
+ if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(abs >= 10_000_000 ? 0 : 1)}M`;
160
+ if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(abs >= 10_000 ? 0 : 1)}k`;
161
+ return `${Math.round(n)}`;
162
+ }
163
+
164
+ function formatCost(value) {
165
+ const n = Number(value);
166
+ if (!Number.isFinite(n) || n <= 0) return "$0.000";
167
+ if (n < 0.01) return `$${n.toFixed(4)}`;
168
+ if (n < 100) return `$${n.toFixed(3)}`;
169
+ return `$${n.toFixed(2)}`;
170
+ }
171
+
172
+ function formatDuration(ms) {
173
+ const totalSeconds = Math.max(0, Math.floor(Number(ms) / 1000));
174
+ const hours = Math.floor(totalSeconds / 3600);
175
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
176
+ if (hours > 0) return `${hours}h${minutes > 0 ? `${minutes}m` : ""}`;
177
+ if (minutes > 0) return `${minutes}m`;
178
+ return `${totalSeconds}s`;
179
+ }
180
+
181
+ function normalizeDisplayPath(value) {
182
+ return String(value || "").replace(/\\/g, "/");
183
+ }
184
+
185
+ function textFromContent(content) {
186
+ if (typeof content === "string") return content;
187
+ if (!Array.isArray(content)) return JSON.stringify(content ?? "");
188
+ return content
189
+ .map((part) => {
190
+ if (!part || typeof part !== "object") return String(part ?? "");
191
+ if (typeof part.text === "string") return part.text;
192
+ if (typeof part.thinking === "string") return part.thinking;
193
+ if (part.type === "toolCall") return JSON.stringify(part.arguments || {});
194
+ if (typeof part.content === "string") return part.content;
195
+ return "";
196
+ })
197
+ .join("\n");
198
+ }
199
+
200
+ function estimateMessageTokens(messages) {
201
+ let chars = 0;
202
+ for (const message of messages || []) {
203
+ chars += textFromContent(message.content).length;
204
+ if (message.role === "toolResult") chars += textFromContent(message.content).length;
205
+ if (message.role === "bashExecution") chars += String(message.command || "").length + String(message.output || "").length;
206
+ chars += 16;
207
+ }
208
+ return Math.round(chars / 4);
209
+ }
210
+
211
+ function estimatePiTokens() {
212
+ const contextTokens = latestStats?.contextUsage?.tokens;
213
+ if (!Number.isFinite(Number(contextTokens))) return null;
214
+ return Math.max(0, Number(contextTokens) - estimateMessageTokens(latestMessages));
215
+ }
216
+
217
+ function subscriptionSuffix() {
218
+ const provider = currentState?.model?.provider || "";
219
+ return /codex|copilot|chatgpt/i.test(provider) ? "sub" : "metered";
220
+ }
221
+
222
+ function footerMetric(icon, label, value, tone = "") {
223
+ const node = make("span", `footer-metric ${tone}`.trim());
224
+ node.append(make("span", "footer-metric-icon", icon), make("span", "footer-metric-label", label), make("span", "footer-metric-value", value));
225
+ node.title = `${label}: ${value}`;
226
+ return node;
227
+ }
228
+
229
+ function footerMeta(label, value, className = "") {
230
+ const node = make("span", `footer-meta ${className}`.trim());
231
+ node.append(make("span", "footer-meta-label", label), make("span", "footer-meta-value", value));
232
+ node.title = `${label}: ${value}`;
233
+ return node;
234
+ }
235
+
236
+ function renderFooter() {
237
+ const stats = latestStats;
238
+ const tokens = stats?.tokens || {};
239
+ const contextUsage = stats?.contextUsage || currentState?.contextUsage;
240
+ const piTokens = estimatePiTokens();
241
+ const speed = currentRunStartedAt
242
+ ? (Math.max(1, Math.round(currentRunStreamChars / 4)) / Math.max(0.5, (performance.now() - currentRunStartedAt) / 1000))
243
+ : latestTokPerSecond;
244
+ const speedLabel = Number.isFinite(speed) ? `${speed.toFixed(1)} tok/s` : "-- tok/s";
245
+ const contextLabel = contextUsage?.contextWindow
246
+ ? `${contextUsage.percent !== null && contextUsage.percent !== undefined ? `${Number(contextUsage.percent).toFixed(1)}% / ` : ""}${formatTokenCount(contextUsage.contextWindow)}`
247
+ : "?";
248
+
249
+ const git = latestWorkspace?.git;
250
+ const branchLabel = git?.isRepo ? git.branch || "detached" : "no repo";
251
+ const changeLabel = git?.isRepo ? `✎ ${git.changed ?? 0} ◌ ${git.untracked ?? 0}` : "no git";
252
+ const workspaceLabel = latestWorkspace?.displayCwd || "loading…";
253
+ const runtime = latestWorkspace?.uptimeMs ? formatDuration(latestWorkspace.uptimeMs) : "--";
254
+ const modelLine = `${shortModelLabel(currentState?.model)} · ${currentState?.thinkingLevel || "?"}`;
255
+
256
+ elements.statusBar.replaceChildren();
257
+ const row1 = make("div", "footer-line footer-line-main");
258
+ row1.append(
259
+ footerMetric("🪙", "tokens", `↑ ${formatTokenCount(tokens.input ?? 0)} ↓ ${formatTokenCount(tokens.output ?? 0)}`, "tone-pink"),
260
+ footerMetric("💾", "cache", `R ${formatTokenCount(tokens.cacheRead ?? 0)}${tokens.cacheWrite ? ` W ${formatTokenCount(tokens.cacheWrite)}` : ""}`, "tone-blue"),
261
+ footerMetric("π", "pi", piTokens === null ? "-- tok" : `~${formatTokenCount(piTokens)} tok`, "tone-mauve"),
262
+ footerMetric("⚡", "speed", speedLabel, "tone-yellow"),
263
+ footerMetric("💸", subscriptionSuffix(), formatCost(stats?.cost ?? 0), "tone-green"),
264
+ footerMetric("🧠", "context", contextLabel, "tone-teal"),
265
+ );
266
+
267
+ const row2 = make("div", "footer-line footer-line-meta");
268
+ row2.append(
269
+ footerMeta("cwd", workspaceLabel, "footer-workspace"),
270
+ footerMeta("git", branchLabel, "footer-branch"),
271
+ footerMeta("changes", changeLabel, "footer-changes"),
272
+ footerMeta("runtime", `⏱ ${runtime} · Agent`, "footer-runtime"),
273
+ footerMeta("model", modelLine, "footer-model"),
274
+ );
275
+ elements.statusBar.append(row1, row2);
276
+ }
277
+
278
+ function scheduleRefreshMessages(delay = 120) {
279
+ clearTimeout(refreshMessagesTimer);
280
+ refreshMessagesTimer = setTimeout(() => refreshMessages().catch((error) => addEvent(error.message, "error")), delay);
281
+ }
282
+
283
+ function scheduleRefreshState(delay = 120) {
284
+ clearTimeout(refreshStateTimer);
285
+ refreshStateTimer = setTimeout(() => refreshState().catch((error) => addEvent(error.message, "error")), delay);
286
+ }
287
+
288
+ function scheduleRefreshFooter(delay = 300) {
289
+ clearTimeout(refreshFooterTimer);
290
+ refreshFooterTimer = setTimeout(() => refreshFooterData().catch((error) => addEvent(error.message, "error")), delay);
291
+ }
292
+
293
+ function renderStatus() {
294
+ const state = currentState;
295
+ const running = state?.isStreaming ? "running" : "idle";
296
+ const compacting = state?.isCompacting ? " · compacting" : "";
297
+ const queue = state?.pendingMessageCount ? ` · queued ${state.pendingMessageCount}` : "";
298
+ const extra = [...statusEntries.entries()].map(([key, value]) => `${key}: ${value}`).join(" · ");
299
+
300
+ elements.sessionLine.textContent = `${running}${compacting}${queue}${extra ? ` · ${extra}` : ""} · ${modelLabel(state?.model)} · ${state?.sessionName || state?.sessionId || "session"}`;
301
+
302
+ elements.stateDetails.replaceChildren();
303
+ const details = {
304
+ Status: `${running}${compacting}`,
305
+ Model: modelLabel(state?.model),
306
+ Thinking: state?.thinkingLevel || "unknown",
307
+ Session: state?.sessionName || state?.sessionId || "unknown",
308
+ File: state?.sessionFile || "in-memory",
309
+ Messages: String(state?.messageCount ?? "?"),
310
+ Queue: String(state?.pendingMessageCount ?? 0),
311
+ "Auto compact": state?.autoCompactionEnabled ? "on" : "off",
312
+ };
313
+ for (const [key, value] of Object.entries(details)) {
314
+ elements.stateDetails.append(make("dt", undefined, key), make("dd", undefined, value));
315
+ }
316
+
317
+ if (state?.thinkingLevel) elements.thinkingSelect.value = state.thinkingLevel;
318
+ elements.compactButton.disabled = !!state?.isCompacting;
319
+ elements.compactButton.textContent = state?.isCompacting ? "Compacting…" : "Compact";
320
+ syncModelSelectToState();
321
+ renderFooter();
322
+ }
323
+
324
+ function renderWidgets() {
325
+ elements.widgetArea.replaceChildren();
326
+ for (const [key, value] of widgets) {
327
+ const node = make("div", "widget");
328
+ const lines = Array.isArray(value.widgetLines) ? value.widgetLines : [];
329
+ node.textContent = `${key}\n${lines.join("\n")}`;
330
+ elements.widgetArea.append(node);
331
+ }
332
+ }
333
+
334
+ function setGitWorkflow(patch) {
335
+ Object.assign(gitWorkflow, patch);
336
+ renderGitWorkflow();
337
+ }
338
+
339
+ function isCurrentGitWorkflowRun(runId) {
340
+ return gitWorkflow.active && gitWorkflow.runId === runId;
341
+ }
342
+
343
+ function appendGitWorkflowOutput(text) {
344
+ const next = `${gitWorkflow.output || ""}${gitWorkflow.output ? "\n" : ""}${text}`;
345
+ setGitWorkflow({ output: next.slice(-60000) });
346
+ }
347
+
348
+ function formatGitCommandResult(result) {
349
+ if (!result) return "";
350
+ const lines = [`$ ${result.command || "git"}`];
351
+ if (result.stdout?.trim()) lines.push("", result.stdout.trimEnd());
352
+ if (result.stderr?.trim()) lines.push("", result.stderr.trimEnd());
353
+ if (result.exitCode !== 0 || result.signal || result.timedOut || result.cancelled) {
354
+ lines.push("", `[exit: ${result.exitCode ?? result.signal ?? "unknown"}${result.timedOut ? ", timed out" : ""}${result.cancelled ? ", cancelled" : ""}]`);
355
+ }
356
+ return lines.join("\n");
357
+ }
358
+
359
+ function formatCommitMessagePreview(message) {
360
+ if (!message) return "No commit message loaded yet.";
361
+ return [`=== SHORT ===`, message.short || "(empty)", "", "=== LONG ===", message.long || "(empty)"].join("\n");
362
+ }
363
+
364
+ function addGitWorkflowAction(label, handler, className = "", disabled = gitWorkflow.busy) {
365
+ const button = make("button", className, label);
366
+ button.type = "button";
367
+ button.disabled = disabled;
368
+ button.addEventListener("click", handler);
369
+ elements.gitWorkflowActions.append(button);
370
+ return button;
371
+ }
372
+
373
+ function gitWorkflowTitle() {
374
+ switch (gitWorkflow.step) {
375
+ case "add": return "Stage all changes";
376
+ case "generate": return "Generate staged commit message";
377
+ case "generating": return "Waiting for /git-staged-msg";
378
+ case "message": return "Choose commit message";
379
+ case "committing": return "Committing";
380
+ case "push": return "Push commit";
381
+ case "pushing": return "Pushing";
382
+ case "done": return "Git workflow complete";
383
+ case "cancelled": return "Git workflow cancelled";
384
+ case "error": return "Git workflow needs attention";
385
+ default: return "Git workflow";
386
+ }
387
+ }
388
+
389
+ function gitWorkflowHint() {
390
+ switch (gitWorkflow.step) {
391
+ case "add": return "Step 1: run git add . in the current Pi working directory.";
392
+ case "generate": return "Step 2: run /git-staged-msg, then preview the generated files.";
393
+ case "generating": return "Pi is generating dev/COMMIT/staged-commit-short.txt and staged-commit-long.txt.";
394
+ case "message": return "Step 3/4: preview the native g-msg output and choose short or long commit.";
395
+ case "committing": return "Running native git commit from the generated message file.";
396
+ case "push": return "Step 5: push the new commit to the configured remote.";
397
+ case "pushing": return "Running git push. Cancel will request process termination.";
398
+ case "done": return "Push finished. Review the output below.";
399
+ case "cancelled": return "No further workflow steps will run.";
400
+ case "error": return gitWorkflow.error || "Fix the issue, then retry or restart.";
401
+ default: return "Stage changes, generate a commit message, commit, and push.";
402
+ }
403
+ }
404
+
405
+ function renderGitWorkflow() {
406
+ elements.gitWorkflowPanel.hidden = !gitWorkflow.active;
407
+ if (!gitWorkflow.active) return;
408
+
409
+ elements.gitWorkflowTitle.textContent = gitWorkflowTitle();
410
+ elements.gitWorkflowHint.textContent = gitWorkflowHint();
411
+ elements.gitWorkflowOutput.textContent = gitWorkflow.output || "Ready.";
412
+ elements.gitWorkflowSteps.replaceChildren();
413
+ elements.gitWorkflowActions.replaceChildren();
414
+
415
+ const activeIndex = GIT_WORKFLOW_ACTIVE_INDEX[gitWorkflow.step] ?? 0;
416
+ for (const [index, label] of GIT_WORKFLOW_STEPS.entries()) {
417
+ const item = make("span", "git-workflow-step", label);
418
+ if (gitWorkflow.step === "done" || index < activeIndex) item.classList.add("done");
419
+ if (index === activeIndex && !["done", "cancelled", "error"].includes(gitWorkflow.step)) item.classList.add("active");
420
+ elements.gitWorkflowSteps.append(item);
421
+ }
422
+
423
+ elements.gitWorkflowCancelButton.hidden = ["done", "cancelled"].includes(gitWorkflow.step);
424
+ elements.gitWorkflowCancelButton.disabled = false;
425
+
426
+ if (gitWorkflow.step === "add") {
427
+ addGitWorkflowAction("Run git add .", runGitAdd, "primary", false);
428
+ } else if (gitWorkflow.step === "generate") {
429
+ addGitWorkflowAction("Run /git-staged-msg", runGitMessagePrompt, "primary", false);
430
+ addGitWorkflowAction("Preview current message files", () => loadGitWorkflowMessage({ requireFresh: false }), "", false);
431
+ } else if (gitWorkflow.step === "generating") {
432
+ addGitWorkflowAction("Refresh message preview", () => loadGitWorkflowMessage({ requireFresh: true }), "", false);
433
+ } else if (gitWorkflow.step === "message") {
434
+ addGitWorkflowAction("Commit short", () => commitGitWorkflow("short"), "primary", false);
435
+ addGitWorkflowAction("Commit long", () => commitGitWorkflow("long"), "primary", false);
436
+ addGitWorkflowAction("Regenerate", runGitMessagePrompt, "", false);
437
+ } else if (gitWorkflow.step === "push") {
438
+ addGitWorkflowAction("Run git push", pushGitWorkflow, "primary", false);
439
+ } else if (gitWorkflow.step === "done") {
440
+ addGitWorkflowAction("Close", () => setGitWorkflow({ active: false }), "primary", false);
441
+ addGitWorkflowAction("Start another", startGitWorkflow, "", false);
442
+ } else if (["cancelled", "error"].includes(gitWorkflow.step)) {
443
+ addGitWorkflowAction("Close", () => setGitWorkflow({ active: false }), "primary", false);
444
+ addGitWorkflowAction("Restart", startGitWorkflow, "", false);
445
+ }
446
+ }
447
+
448
+ async function gitWorkflowRequest(path, { method = "POST", body = {}, runId = gitWorkflow.runId } = {}) {
449
+ const response = await api(path, method === "GET" ? { method } : { method, body });
450
+ if (!isCurrentGitWorkflowRun(runId)) return null;
451
+ if (!response.ok) {
452
+ const detail = response.data ? `\n\n${formatGitCommandResult(response.data)}` : "";
453
+ throw new Error(`${response.error || "Git workflow request failed"}${detail}`);
454
+ }
455
+ return response.data;
456
+ }
457
+
458
+ function failGitWorkflow(error, step = gitWorkflow.step) {
459
+ const message = error?.message || String(error);
460
+ setGitWorkflow({
461
+ step,
462
+ busy: false,
463
+ error: message,
464
+ output: `${gitWorkflow.output || ""}${gitWorkflow.output ? "\n\n" : ""}ERROR: ${message}`.slice(-60000),
465
+ });
466
+ }
467
+
468
+ function startGitWorkflow() {
469
+ if (gitWorkflow.active && !["done", "cancelled", "error"].includes(gitWorkflow.step) && !confirm("Restart the active git workflow?")) return;
470
+ gitWorkflow.runId += 1;
471
+ setGitWorkflow({
472
+ active: true,
473
+ step: "add",
474
+ busy: false,
475
+ output: "Ready to stage all changes with git add .\n\nNative mode is used for g-msg/g-short/g-long: dev/COMMIT message files are read directly and git commit is run without fish.",
476
+ error: "",
477
+ message: null,
478
+ messageRequestedAt: 0,
479
+ });
480
+ }
481
+
482
+ async function cancelGitWorkflow() {
483
+ const shouldAbortPi = gitWorkflow.step === "generating";
484
+ gitWorkflow.runId += 1;
485
+ setGitWorkflow({ step: "cancelled", busy: false, error: "", output: `${gitWorkflow.output || ""}${gitWorkflow.output ? "\n\n" : ""}Cancelled by user.` });
486
+ await Promise.allSettled([
487
+ api("/api/git-workflow/cancel", { method: "POST", body: {} }),
488
+ shouldAbortPi ? api("/api/abort", { method: "POST", body: {} }) : Promise.resolve(),
489
+ ]);
490
+ }
491
+
492
+ async function runGitAdd() {
493
+ const runId = gitWorkflow.runId;
494
+ setGitWorkflow({ step: "add", busy: true, error: "", output: "Running git add ." });
495
+ try {
496
+ const result = await gitWorkflowRequest("/api/git-workflow/add", { runId });
497
+ if (!result) return;
498
+ setGitWorkflow({ step: "generate", busy: false, output: `${formatGitCommandResult(result)}\n\nStaged. Next: run /git-staged-msg.` });
499
+ scheduleRefreshFooter();
500
+ } catch (error) {
501
+ if (isCurrentGitWorkflowRun(runId)) failGitWorkflow(error, "add");
502
+ }
503
+ }
504
+
505
+ async function runGitMessagePrompt() {
506
+ if (currentState?.isStreaming) {
507
+ failGitWorkflow(new Error("Pi is currently running. Wait for it to finish or abort before generating a staged commit message."), "generate");
508
+ return;
509
+ }
510
+ const runId = gitWorkflow.runId;
511
+ const requestedAt = Date.now();
512
+ setGitWorkflow({
513
+ step: "generating",
514
+ busy: true,
515
+ error: "",
516
+ messageRequestedAt: requestedAt,
517
+ output: "Sending /git-staged-msg to Pi.\n\nCancel will request Pi abort.",
518
+ });
519
+ try {
520
+ await api("/api/prompt", { method: "POST", body: { message: "/git-staged-msg" } });
521
+ if (!isCurrentGitWorkflowRun(runId)) return;
522
+ appendGitWorkflowOutput("/git-staged-msg accepted. Waiting for agent_end, then the message files will be loaded.");
523
+ scheduleRefreshState();
524
+ setTimeout(() => {
525
+ if (isCurrentGitWorkflowRun(runId) && gitWorkflow.step === "generating" && !currentState?.isStreaming) {
526
+ loadGitWorkflowMessage({ requireFresh: true, retries: 1, runId });
527
+ }
528
+ }, 2500);
529
+ } catch (error) {
530
+ if (isCurrentGitWorkflowRun(runId)) failGitWorkflow(error, "generate");
531
+ }
532
+ }
533
+
534
+ async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId = gitWorkflow.runId } = {}) {
535
+ try {
536
+ const message = await gitWorkflowRequest("/api/git-workflow/message", { method: "GET", runId });
537
+ if (!message) return;
538
+ const newestMtime = Math.max(message.shortMtimeMs || 0, message.longMtimeMs || 0);
539
+ if (requireFresh && gitWorkflow.messageRequestedAt && newestMtime + 10000 < gitWorkflow.messageRequestedAt) {
540
+ throw new Error("Generated message files have not refreshed yet.");
541
+ }
542
+ setGitWorkflow({
543
+ step: "message",
544
+ busy: false,
545
+ error: "",
546
+ message,
547
+ output: formatCommitMessagePreview(message),
548
+ });
549
+ } catch (error) {
550
+ if (!isCurrentGitWorkflowRun(runId)) return;
551
+ if (retries > 0) {
552
+ setTimeout(() => loadGitWorkflowMessage({ requireFresh, retries: retries - 1, runId }), 1400);
553
+ return;
554
+ }
555
+ failGitWorkflow(error, gitWorkflow.step === "generating" ? "generate" : gitWorkflow.step);
556
+ }
557
+ }
558
+
559
+ async function commitGitWorkflow(variant) {
560
+ const runId = gitWorkflow.runId;
561
+ setGitWorkflow({ step: "committing", busy: true, error: "", output: `${formatCommitMessagePreview(gitWorkflow.message)}\n\nRunning native ${variant} commit…` });
562
+ try {
563
+ const result = await gitWorkflowRequest("/api/git-workflow/commit", { body: { variant }, runId });
564
+ if (!result) return;
565
+ setGitWorkflow({ step: "push", busy: false, output: `${formatGitCommandResult(result)}\n\nCommit created. Next: git push.` });
566
+ scheduleRefreshFooter();
567
+ } catch (error) {
568
+ if (isCurrentGitWorkflowRun(runId)) failGitWorkflow(error, "message");
569
+ }
570
+ }
571
+
572
+ async function pushGitWorkflow() {
573
+ const runId = gitWorkflow.runId;
574
+ setGitWorkflow({ step: "pushing", busy: true, error: "", output: "Running git push…" });
575
+ try {
576
+ const result = await gitWorkflowRequest("/api/git-workflow/push", { runId });
577
+ if (!result) return;
578
+ setGitWorkflow({ step: "done", busy: false, output: formatGitCommandResult(result) || "git push finished." });
579
+ scheduleRefreshFooter();
580
+ } catch (error) {
581
+ if (isCurrentGitWorkflowRun(runId)) failGitWorkflow(error, "push");
582
+ }
583
+ }
584
+
585
+ function renderQueue(event) {
586
+ const steering = event?.steering || [];
587
+ const followUp = event?.followUp || [];
588
+ if (steering.length === 0 && followUp.length === 0) {
589
+ elements.queueBox.textContent = "No queued messages.";
590
+ elements.queueBox.classList.add("muted");
591
+ return;
592
+ }
593
+ elements.queueBox.classList.remove("muted");
594
+ const lines = [];
595
+ if (steering.length) lines.push(`Steering (${steering.length}):`, ...steering.map((item) => `• ${item}`));
596
+ if (followUp.length) lines.push(`Follow-up (${followUp.length}):`, ...followUp.map((item) => `• ${item}`));
597
+ elements.queueBox.textContent = lines.join("\n");
598
+ }
599
+
600
+ function appendText(parent, text, className = "text-block") {
601
+ const block = make("pre", className);
602
+ block.textContent = text || "";
603
+ parent.append(block);
604
+ return block;
605
+ }
606
+
607
+ function appendImage(parent, part) {
608
+ const wrapper = make("div", "image-block");
609
+ const img = document.createElement("img");
610
+ img.alt = "attached image";
611
+ img.loading = "lazy";
612
+ img.style.maxWidth = "100%";
613
+ img.style.borderRadius = "0.6rem";
614
+ img.src = `data:${part.mimeType || "image/png"};base64,${part.data || part.content || ""}`;
615
+ wrapper.append(img);
616
+ parent.append(wrapper);
617
+ }
618
+
619
+ function renderContent(parent, content) {
620
+ if (content === undefined || content === null) return;
621
+ if (typeof content === "string") {
622
+ appendText(parent, content);
623
+ return;
624
+ }
625
+ if (!Array.isArray(content)) {
626
+ appendText(parent, JSON.stringify(content, null, 2), "code-block");
627
+ return;
628
+ }
629
+
630
+ for (const part of content) {
631
+ if (!part || typeof part !== "object") {
632
+ appendText(parent, String(part));
633
+ continue;
634
+ }
635
+ if (part.type === "text") {
636
+ appendText(parent, part.text || "");
637
+ } else if (part.type === "thinking") {
638
+ const details = make("details", "thinking-block");
639
+ details.open = true;
640
+ details.append(make("summary", undefined, "thinking"));
641
+ appendText(details, part.thinking || "No thinking content was exposed by the provider.", "thinking-text");
642
+ parent.append(details);
643
+ } else if (part.type === "toolCall") {
644
+ const details = make("details");
645
+ details.open = true;
646
+ details.append(make("summary", undefined, `tool call: ${part.name || "unknown"}`));
647
+ appendText(details, JSON.stringify(part.arguments || {}, null, 2), "code-block");
648
+ parent.append(details);
649
+ } else if (part.type === "image") {
650
+ appendImage(parent, part);
651
+ } else {
652
+ appendText(parent, JSON.stringify(part, null, 2), "code-block");
653
+ }
654
+ }
655
+ }
656
+
657
+ function messageTitle(message) {
658
+ if (message.role === "toolResult") return `tool result: ${message.toolName || "unknown"}`;
659
+ if (message.role === "bashExecution") return `bash: ${message.command || ""}`;
660
+ return message.role || "message";
661
+ }
662
+
663
+ function appendMessage(message, { streaming = false } = {}) {
664
+ const role = String(message.role || "message");
665
+ const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
666
+ const bubble = make("article", `message ${safeRole}${streaming ? " streaming" : ""}`);
667
+ const isCollapsibleOutput = !streaming && (message.role === "toolResult" || message.role === "bashExecution");
668
+
669
+ const header = make(isCollapsibleOutput ? "summary" : "div", "message-header");
670
+ header.append(make("span", "message-role", messageTitle(message)));
671
+ header.append(make("span", "muted", formatDate(message.timestamp)));
672
+ const body = make("div", "message-body");
673
+
674
+ if (message.role === "bashExecution") {
675
+ appendText(body, `$ ${message.command || ""}\n\n${message.output || ""}`, "code-block");
676
+ } else if (message.role === "toolResult") {
677
+ renderContent(body, message.content);
678
+ if (message.isError) bubble.classList.add("error");
679
+ } else {
680
+ renderContent(body, message.content);
681
+ }
682
+
683
+ if (isCollapsibleOutput) {
684
+ const details = make("details", "message-collapse");
685
+ if (message.isError) details.open = true;
686
+ details.append(header, body);
687
+ bubble.append(details);
688
+ } else {
689
+ bubble.append(header, body);
690
+ }
691
+ elements.chat.append(bubble);
692
+ return { bubble, body };
693
+ }
694
+
695
+ function scrollChatToBottom() {
696
+ elements.chat.scrollTop = elements.chat.scrollHeight;
697
+ }
698
+
699
+ function renderMessages(messages) {
700
+ latestMessages = messages || [];
701
+ elements.chat.replaceChildren();
702
+ for (const message of latestMessages) appendMessage(message);
703
+ scrollChatToBottom();
704
+ renderFooter();
705
+ }
706
+
707
+ function ensureStreamBubble() {
708
+ if (streamBubble) return;
709
+ const created = appendMessage({ role: "assistant", timestamp: Date.now(), content: "" }, { streaming: true });
710
+ streamBubble = created.bubble;
711
+ streamText = appendText(created.body, "");
712
+ streamThinkingDetails = make("details", "thinking-block streaming-thinking");
713
+ streamThinkingDetails.hidden = true;
714
+ streamThinkingDetails.open = true;
715
+ streamThinkingDetails.append(make("summary", undefined, "thinking"));
716
+ streamThinking = appendText(streamThinkingDetails, "", "thinking-text");
717
+ created.body.prepend(streamThinkingDetails);
718
+ scrollChatToBottom();
719
+ }
720
+
721
+ function showStreamingThinking(placeholder = "Thinking…") {
722
+ ensureStreamBubble();
723
+ streamThinkingDetails.hidden = false;
724
+ streamThinkingDetails.open = true;
725
+ if (!streamThinking.textContent) streamThinking.textContent = placeholder;
726
+ }
727
+
728
+ function resetStreamBubble() {
729
+ streamBubble = null;
730
+ streamText = null;
731
+ streamThinking = null;
732
+ streamThinkingDetails = null;
733
+ }
734
+
735
+ function thinkingDeltaText(update) {
736
+ return update.delta || update.thinking || update.content || "";
737
+ }
738
+
739
+ function handleMessageUpdate(event) {
740
+ const update = event.assistantMessageEvent || {};
741
+ ensureStreamBubble();
742
+ if (update.type === "thinking_start") {
743
+ showStreamingThinking();
744
+ scrollChatToBottom();
745
+ } else if (update.type === "thinking_delta") {
746
+ const delta = thinkingDeltaText(update);
747
+ currentRunStreamChars += delta.length;
748
+ showStreamingThinking("");
749
+ if (streamThinking.textContent === "Thinking…") streamThinking.textContent = "";
750
+ streamThinking.textContent += delta;
751
+ renderFooter();
752
+ scrollChatToBottom();
753
+ } else if (update.type === "thinking_end") {
754
+ const finalThinking = thinkingDeltaText(update);
755
+ if (finalThinking && (!streamThinking.textContent || streamThinking.textContent === "Thinking…")) {
756
+ showStreamingThinking("");
757
+ streamThinking.textContent = finalThinking;
758
+ }
759
+ streamThinkingDetails?.classList.add("complete");
760
+ } else if (update.type === "text_delta") {
761
+ const delta = update.delta || "";
762
+ currentRunStreamChars += delta.length;
763
+ streamText.textContent += delta;
764
+ renderFooter();
765
+ scrollChatToBottom();
766
+ } else if (update.type === "toolcall_start") {
767
+ addEvent(`tool call started in assistant message`, "info");
768
+ } else if (update.type === "error") {
769
+ streamBubble.classList.add("error");
770
+ appendText(streamBubble.querySelector(".message-body"), update.reason || update.errorMessage || "assistant error", "code-block");
771
+ }
772
+ }
773
+
774
+ async function refreshState() {
775
+ const response = await api("/api/state");
776
+ currentState = response.data || null;
777
+ renderStatus();
778
+ }
779
+
780
+ async function refreshStats() {
781
+ const response = await api("/api/stats");
782
+ latestStats = response.data || null;
783
+ renderFooter();
784
+ }
785
+
786
+ async function refreshWorkspace() {
787
+ try {
788
+ const response = await api("/api/workspace");
789
+ latestWorkspace = response.data || null;
790
+ } catch (error) {
791
+ // Older webui server processes do not have /api/workspace. Fall back to /api/health,
792
+ // which has exposed cwd from the beginning, so the footer still shows the real path.
793
+ const health = await api("/api/health");
794
+ latestWorkspace = health.cwd
795
+ ? {
796
+ cwd: health.cwd,
797
+ displayCwd: normalizeDisplayPath(health.cwd),
798
+ uptimeMs: latestWorkspace?.uptimeMs || 0,
799
+ git: { isRepo: false },
800
+ }
801
+ : null;
802
+ }
803
+ renderFooter();
804
+ }
805
+
806
+ async function refreshFooterData() {
807
+ await Promise.allSettled([refreshStats(), refreshWorkspace()]);
808
+ }
809
+
810
+ async function refreshMessages() {
811
+ const response = await api("/api/messages");
812
+ latestMessages = response.data?.messages || [];
813
+ resetStreamBubble();
814
+ renderMessages(latestMessages);
815
+ renderFooter();
816
+ }
817
+
818
+ async function refreshModels() {
819
+ const response = await api("/api/models");
820
+ const models = response.data?.models || [];
821
+ elements.modelSelect.replaceChildren();
822
+ for (const model of models) {
823
+ const option = document.createElement("option");
824
+ option.value = JSON.stringify({ provider: model.provider, modelId: model.id });
825
+ option.textContent = `${model.provider}/${model.id}${model.name ? ` · ${model.name}` : ""}`;
826
+ elements.modelSelect.append(option);
827
+ }
828
+ syncModelSelectToState();
829
+ }
830
+
831
+ function syncModelSelectToState() {
832
+ if (!currentState?.model || !elements.modelSelect.options.length) return;
833
+ const value = JSON.stringify({ provider: currentState.model.provider, modelId: currentState.model.id });
834
+ for (const option of elements.modelSelect.options) {
835
+ if (option.value === value) {
836
+ elements.modelSelect.value = value;
837
+ break;
838
+ }
839
+ }
840
+ }
841
+
842
+ function normalizeCommands(commands) {
843
+ const seen = new Set();
844
+ return (commands || [])
845
+ .map((command) => ({
846
+ name: String(command.name || "").trim(),
847
+ description: String(command.description || "").trim(),
848
+ source: String(command.source || "command").trim(),
849
+ location: String(command.location || "").trim(),
850
+ }))
851
+ .filter((command) => {
852
+ if (!command.name || seen.has(command.name)) return false;
853
+ seen.add(command.name);
854
+ return true;
855
+ })
856
+ .sort((a, b) => a.name.localeCompare(b.name));
857
+ }
858
+
859
+ function commandSourceLabel(command) {
860
+ return [command.source, command.location].filter(Boolean).join(" · ") || "command";
861
+ }
862
+
863
+ function getCommandTrigger() {
864
+ const input = elements.promptInput;
865
+ const cursor = input.selectionStart ?? input.value.length;
866
+ const selectionEnd = input.selectionEnd ?? cursor;
867
+ if (cursor !== selectionEnd) return null;
868
+
869
+ const beforeCursor = input.value.slice(0, cursor);
870
+ const match = beforeCursor.match(/(^|[\s(])\/([^\s]*)$/);
871
+ if (!match) return null;
872
+
873
+ const query = match[2] || "";
874
+ return {
875
+ start: cursor - query.length - 1,
876
+ end: cursor,
877
+ query,
878
+ };
879
+ }
880
+
881
+ function scoreCommandSuggestion(command, query) {
882
+ if (!query) return 0;
883
+ const q = query.toLowerCase();
884
+ const name = command.name.toLowerCase();
885
+ const description = command.description.toLowerCase();
886
+ if (name === q) return 0;
887
+ if (name.startsWith(q)) return 1;
888
+ if (name.includes(q)) return 2;
889
+ if (description.includes(q)) return 3;
890
+ return Number.POSITIVE_INFINITY;
891
+ }
892
+
893
+ function getCommandMatches(query) {
894
+ return availableCommands
895
+ .map((command) => ({ command, score: scoreCommandSuggestion(command, query) }))
896
+ .filter((item) => Number.isFinite(item.score))
897
+ .sort((a, b) => a.score - b.score || a.command.name.localeCompare(b.command.name))
898
+ .slice(0, 12)
899
+ .map((item) => item.command);
900
+ }
901
+
902
+ function hideCommandSuggestions() {
903
+ elements.commandSuggest.hidden = true;
904
+ elements.commandSuggest.replaceChildren();
905
+ commandSuggestions = [];
906
+ commandSuggestIndex = 0;
907
+ }
908
+
909
+ function setActiveCommandSuggestion(index) {
910
+ if (!commandSuggestions.length) return;
911
+ commandSuggestIndex = (index + commandSuggestions.length) % commandSuggestions.length;
912
+ const items = [...elements.commandSuggest.querySelectorAll(".command-suggest-item")];
913
+ for (const [itemIndex, item] of items.entries()) {
914
+ const active = itemIndex === commandSuggestIndex;
915
+ item.classList.toggle("active", active);
916
+ item.setAttribute("aria-selected", active ? "true" : "false");
917
+ if (active) item.scrollIntoView({ block: "nearest" });
918
+ }
919
+ }
920
+
921
+ function renderCommandSuggestions({ keepIndex = false } = {}) {
922
+ const trigger = getCommandTrigger();
923
+ if (!trigger || document.activeElement !== elements.promptInput || availableCommands.length === 0) {
924
+ hideCommandSuggestions();
925
+ return;
926
+ }
927
+
928
+ commandSuggestions = getCommandMatches(trigger.query);
929
+ elements.commandSuggest.replaceChildren();
930
+
931
+ if (commandSuggestions.length === 0) {
932
+ elements.commandSuggest.append(make("div", "command-suggest-empty", `No command matches /${trigger.query}`));
933
+ elements.commandSuggest.hidden = false;
934
+ return;
935
+ }
936
+
937
+ for (const [index, command] of commandSuggestions.entries()) {
938
+ const item = make("button", "command-suggest-item");
939
+ item.type = "button";
940
+ item.setAttribute("role", "option");
941
+ item.addEventListener("mousedown", (event) => event.preventDefault());
942
+ item.addEventListener("mouseenter", () => setActiveCommandSuggestion(index));
943
+ item.addEventListener("click", () => insertCommandSuggestion(index));
944
+
945
+ item.append(
946
+ make("span", "command-suggest-name", `/${command.name}`),
947
+ make("span", "command-suggest-desc", command.description || "No description"),
948
+ make("span", "command-suggest-source", commandSourceLabel(command)),
949
+ );
950
+ elements.commandSuggest.append(item);
951
+ }
952
+
953
+ elements.commandSuggest.hidden = false;
954
+ setActiveCommandSuggestion(keepIndex ? commandSuggestIndex : 0);
955
+ }
956
+
957
+ function insertCommandSuggestion(index = commandSuggestIndex) {
958
+ const command = commandSuggestions[index];
959
+ const trigger = getCommandTrigger();
960
+ if (!command || !trigger) return false;
961
+
962
+ const input = elements.promptInput;
963
+ const value = input.value;
964
+ let tokenEnd = trigger.end;
965
+ while (tokenEnd < value.length && !/\s/.test(value[tokenEnd])) tokenEnd++;
966
+
967
+ const commandText = `/${command.name}`;
968
+ const suffix = value.slice(tokenEnd);
969
+ const separator = suffix && /^\s/.test(suffix) ? "" : " ";
970
+ input.value = `${value.slice(0, trigger.start)}${commandText}${separator}${suffix}`;
971
+
972
+ const whitespaceOffset = separator ? 1 : suffix && /^\s/.test(suffix) ? 1 : 0;
973
+ const cursor = trigger.start + commandText.length + whitespaceOffset;
974
+ input.setSelectionRange(cursor, cursor);
975
+ input.focus();
976
+ hideCommandSuggestions();
977
+ return true;
978
+ }
979
+
980
+ async function refreshCommands() {
981
+ const response = await api("/api/commands");
982
+ availableCommands = normalizeCommands(response.data?.commands || []);
983
+ elements.commandsBox.replaceChildren();
984
+ if (!availableCommands.length) {
985
+ elements.commandsBox.textContent = "No RPC-visible commands.";
986
+ elements.commandsBox.classList.add("muted");
987
+ hideCommandSuggestions();
988
+ return;
989
+ }
990
+ elements.commandsBox.classList.remove("muted");
991
+ for (const command of availableCommands.slice(0, 80)) {
992
+ const item = make("div", "command-item");
993
+ const code = make("code", undefined, `/${command.name}`);
994
+ item.append(code);
995
+ if (command.description) item.append(document.createTextNode(` — ${command.description}`));
996
+ elements.commandsBox.append(item);
997
+ }
998
+ renderCommandSuggestions();
999
+ }
1000
+
1001
+ async function refreshAll() {
1002
+ const results = await Promise.allSettled([refreshState(), refreshMessages(), refreshModels(), refreshCommands(), refreshStats(), refreshWorkspace()]);
1003
+ for (const result of results) {
1004
+ if (result.status === "rejected") addEvent(result.reason.message || String(result.reason), "error");
1005
+ }
1006
+ }
1007
+
1008
+ async function sendPrompt(kind = "prompt") {
1009
+ const message = elements.promptInput.value.trim();
1010
+ if (!message) return;
1011
+
1012
+ try {
1013
+ if (kind === "steer") {
1014
+ await api("/api/steer", { method: "POST", body: { message } });
1015
+ } else if (kind === "follow-up") {
1016
+ await api("/api/follow-up", { method: "POST", body: { message } });
1017
+ } else {
1018
+ const body = { message };
1019
+ if (currentState?.isStreaming) body.streamingBehavior = elements.busyBehavior.value || "followUp";
1020
+ await api("/api/prompt", { method: "POST", body });
1021
+ }
1022
+ elements.promptInput.value = "";
1023
+ hideCommandSuggestions();
1024
+ scheduleRefreshState();
1025
+ } catch (error) {
1026
+ addEvent(error.message, "error");
1027
+ }
1028
+ }
1029
+
1030
+ function handleExtensionUiRequest(request) {
1031
+ switch (request.method) {
1032
+ case "notify":
1033
+ addEvent(request.message || "notification", request.notifyType === "error" ? "error" : request.notifyType === "warning" ? "warn" : "info");
1034
+ return;
1035
+ case "setStatus":
1036
+ if (request.statusText) statusEntries.set(request.statusKey || "extension", request.statusText);
1037
+ else statusEntries.delete(request.statusKey || "extension");
1038
+ renderStatus();
1039
+ return;
1040
+ case "setWidget":
1041
+ if (Array.isArray(request.widgetLines)) widgets.set(request.widgetKey || request.id, request);
1042
+ else widgets.delete(request.widgetKey || request.id);
1043
+ renderWidgets();
1044
+ return;
1045
+ case "setTitle":
1046
+ if (request.title) document.title = request.title;
1047
+ return;
1048
+ case "set_editor_text":
1049
+ elements.promptInput.value = request.text || "";
1050
+ elements.promptInput.focus();
1051
+ renderCommandSuggestions();
1052
+ return;
1053
+ case "select":
1054
+ case "confirm":
1055
+ case "input":
1056
+ case "editor":
1057
+ dialogQueue.push(request);
1058
+ showNextDialog();
1059
+ return;
1060
+ default:
1061
+ addEvent(`Unsupported extension UI request: ${request.method}`, "warn");
1062
+ }
1063
+ }
1064
+
1065
+ async function sendDialogResponse(payload) {
1066
+ try {
1067
+ await api("/api/extension-ui-response", { method: "POST", body: payload });
1068
+ } catch (error) {
1069
+ addEvent(error.message, "error");
1070
+ } finally {
1071
+ elements.dialog.close();
1072
+ activeDialog = null;
1073
+ showNextDialog();
1074
+ }
1075
+ }
1076
+
1077
+ function addDialogButton(label, handler, className) {
1078
+ const button = make("button", className, label);
1079
+ button.type = "button";
1080
+ button.addEventListener("click", handler);
1081
+ elements.dialogActions.append(button);
1082
+ return button;
1083
+ }
1084
+
1085
+ function showNextDialog() {
1086
+ if (activeDialog || dialogQueue.length === 0) return;
1087
+ activeDialog = dialogQueue.shift();
1088
+ const request = activeDialog;
1089
+
1090
+ elements.dialogTitle.textContent = request.title || "Pi request";
1091
+ elements.dialogMessage.textContent = request.message || request.placeholder || "";
1092
+ elements.dialogBody.replaceChildren();
1093
+ elements.dialogActions.replaceChildren();
1094
+
1095
+ const cancel = () => sendDialogResponse({ type: "extension_ui_response", id: request.id, cancelled: true });
1096
+
1097
+ if (request.method === "select") {
1098
+ const options = make("div", "dialog-options");
1099
+ for (const option of request.options || []) {
1100
+ const button = make("button", undefined, String(option));
1101
+ button.type = "button";
1102
+ button.addEventListener("click", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: String(option) }));
1103
+ options.append(button);
1104
+ }
1105
+ elements.dialogBody.append(options);
1106
+ addDialogButton("Cancel", cancel);
1107
+ } else if (request.method === "confirm") {
1108
+ addDialogButton("Cancel", cancel);
1109
+ addDialogButton("No", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, confirmed: false }));
1110
+ addDialogButton("Yes", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, confirmed: true }), "primary");
1111
+ } else if (request.method === "input") {
1112
+ const input = make("input", "dialog-input");
1113
+ input.value = request.prefill || "";
1114
+ input.placeholder = request.placeholder || "";
1115
+ elements.dialogBody.append(input);
1116
+ addDialogButton("Cancel", cancel);
1117
+ addDialogButton("Submit", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: input.value }), "primary");
1118
+ setTimeout(() => input.focus(), 0);
1119
+ } else if (request.method === "editor") {
1120
+ const textarea = make("textarea", "dialog-editor");
1121
+ textarea.value = request.prefill || "";
1122
+ elements.dialogBody.append(textarea);
1123
+ addDialogButton("Cancel", cancel);
1124
+ addDialogButton("Submit", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: textarea.value }), "primary");
1125
+ setTimeout(() => textarea.focus(), 0);
1126
+ }
1127
+
1128
+ elements.dialog.showModal();
1129
+ }
1130
+
1131
+ function handleEvent(event) {
1132
+ switch (event.type) {
1133
+ case "webui_connected":
1134
+ addEvent(`connected to webui for ${event.cwd}`);
1135
+ break;
1136
+ case "pi_process_start":
1137
+ addEvent(`started pi rpc pid ${event.pid}`);
1138
+ break;
1139
+ case "pi_process_exit":
1140
+ addEvent(`pi rpc exited (${event.code ?? event.signal ?? "unknown"})`, "error");
1141
+ break;
1142
+ case "pi_process_error":
1143
+ addEvent(event.error || "pi rpc process error", "error");
1144
+ break;
1145
+ case "pi_stderr":
1146
+ addEvent(event.text.trim(), "warn");
1147
+ break;
1148
+ case "queue_update":
1149
+ renderQueue(event);
1150
+ scheduleRefreshState();
1151
+ break;
1152
+ case "agent_start":
1153
+ currentRunStartedAt = performance.now();
1154
+ currentRunStreamChars = 0;
1155
+ latestTokPerSecond = null;
1156
+ addEvent("agent started");
1157
+ scheduleRefreshState();
1158
+ renderFooter();
1159
+ break;
1160
+ case "agent_end":
1161
+ addEvent("agent finished");
1162
+ currentRunStartedAt = null;
1163
+ scheduleRefreshState();
1164
+ scheduleRefreshMessages();
1165
+ scheduleRefreshFooter();
1166
+ if (gitWorkflow.active && gitWorkflow.step === "generating") {
1167
+ loadGitWorkflowMessage({ requireFresh: true, retries: 3 });
1168
+ }
1169
+ break;
1170
+ case "message_start":
1171
+ if (event.message?.role === "assistant") resetStreamBubble();
1172
+ break;
1173
+ case "message_update":
1174
+ handleMessageUpdate(event);
1175
+ break;
1176
+ case "message_end":
1177
+ if (event.message?.role === "assistant" && currentRunStartedAt) {
1178
+ const elapsedSeconds = Math.max(0.5, (performance.now() - currentRunStartedAt) / 1000);
1179
+ const outputTokens = Number(event.message?.usage?.output ?? 0) || Math.max(1, Math.round(currentRunStreamChars / 4));
1180
+ latestTokPerSecond = outputTokens / elapsedSeconds;
1181
+ }
1182
+ scheduleRefreshMessages();
1183
+ scheduleRefreshState();
1184
+ scheduleRefreshFooter();
1185
+ break;
1186
+ case "tool_execution_start":
1187
+ addEvent(`tool ${event.toolName} started`);
1188
+ break;
1189
+ case "tool_execution_end":
1190
+ addEvent(`tool ${event.toolName} ${event.isError ? "failed" : "finished"}`, event.isError ? "error" : "info");
1191
+ scheduleRefreshMessages();
1192
+ scheduleRefreshFooter();
1193
+ break;
1194
+ case "compaction_start":
1195
+ addEvent(`compaction started (${event.reason})`);
1196
+ break;
1197
+ case "compaction_end":
1198
+ addEvent(`compaction ${event.aborted ? "aborted" : "finished"}`);
1199
+ scheduleRefreshMessages();
1200
+ break;
1201
+ case "extension_ui_request":
1202
+ handleExtensionUiRequest(event);
1203
+ break;
1204
+ case "response":
1205
+ if (event.success === false) addEvent(`${event.command} failed: ${event.error || "unknown error"}`, "error");
1206
+ else if (["set_model", "set_thinking_level", "new_session", "compact"].includes(event.command)) {
1207
+ scheduleRefreshState();
1208
+ scheduleRefreshMessages();
1209
+ scheduleRefreshFooter();
1210
+ }
1211
+ break;
1212
+ default:
1213
+ break;
1214
+ }
1215
+ }
1216
+
1217
+ function connectEvents() {
1218
+ eventSource?.close();
1219
+ eventSource = new EventSource("/api/events");
1220
+ eventSource.onmessage = (message) => {
1221
+ try {
1222
+ handleEvent(JSON.parse(message.data));
1223
+ } catch (error) {
1224
+ addEvent(error.message, "error");
1225
+ }
1226
+ };
1227
+ eventSource.onerror = () => addEvent("event stream disconnected; browser will retry", "warn");
1228
+ }
1229
+
1230
+ elements.composer.addEventListener("submit", (event) => {
1231
+ event.preventDefault();
1232
+ sendPrompt("prompt");
1233
+ });
1234
+ elements.steerButton.addEventListener("click", () => sendPrompt("steer"));
1235
+ elements.followUpButton.addEventListener("click", () => sendPrompt("follow-up"));
1236
+ elements.gitWorkflowButton.addEventListener("click", startGitWorkflow);
1237
+ elements.gitWorkflowCancelButton.addEventListener("click", cancelGitWorkflow);
1238
+ elements.abortButton.addEventListener("click", async () => {
1239
+ try {
1240
+ await api("/api/abort", { method: "POST", body: {} });
1241
+ } catch (error) {
1242
+ addEvent(error.message, "error");
1243
+ }
1244
+ });
1245
+ elements.newSessionButton.addEventListener("click", async () => {
1246
+ if (!confirm("Start a new Pi session?")) return;
1247
+ try {
1248
+ await api("/api/new-session", { method: "POST", body: {} });
1249
+ await refreshAll();
1250
+ } catch (error) {
1251
+ addEvent(error.message, "error");
1252
+ }
1253
+ });
1254
+ elements.compactButton.addEventListener("click", async () => {
1255
+ try {
1256
+ elements.compactButton.disabled = true;
1257
+ elements.compactButton.textContent = "Compacting…";
1258
+ addEvent("manual compaction requested");
1259
+ await api("/api/compact", { method: "POST", body: {} });
1260
+ scheduleRefreshState();
1261
+ scheduleRefreshMessages(600);
1262
+ scheduleRefreshFooter(600);
1263
+ } catch (error) {
1264
+ addEvent(error.message, "error");
1265
+ } finally {
1266
+ elements.compactButton.disabled = !!currentState?.isCompacting;
1267
+ elements.compactButton.textContent = currentState?.isCompacting ? "Compacting…" : "Compact";
1268
+ }
1269
+ });
1270
+ elements.setModelButton.addEventListener("click", async () => {
1271
+ if (!elements.modelSelect.value) return;
1272
+ try {
1273
+ const selected = JSON.parse(elements.modelSelect.value);
1274
+ await api("/api/model", { method: "POST", body: selected });
1275
+ await refreshState();
1276
+ } catch (error) {
1277
+ addEvent(error.message, "error");
1278
+ }
1279
+ });
1280
+ elements.setThinkingButton.addEventListener("click", async () => {
1281
+ try {
1282
+ await api("/api/thinking", { method: "POST", body: { level: elements.thinkingSelect.value } });
1283
+ await refreshState();
1284
+ } catch (error) {
1285
+ addEvent(error.message, "error");
1286
+ }
1287
+ });
1288
+ elements.toggleSidePanelButton.addEventListener("click", () => {
1289
+ setSidePanelCollapsed(true);
1290
+ });
1291
+ elements.sidePanelExpandButton.addEventListener("click", () => {
1292
+ setSidePanelCollapsed(false);
1293
+ });
1294
+
1295
+ elements.promptInput.addEventListener("keydown", (event) => {
1296
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
1297
+ event.preventDefault();
1298
+ hideCommandSuggestions();
1299
+ sendPrompt("prompt");
1300
+ return;
1301
+ }
1302
+
1303
+ if (!elements.commandSuggest.hidden) {
1304
+ if (event.key === "ArrowDown") {
1305
+ event.preventDefault();
1306
+ setActiveCommandSuggestion(commandSuggestIndex + 1);
1307
+ return;
1308
+ }
1309
+ if (event.key === "ArrowUp") {
1310
+ event.preventDefault();
1311
+ setActiveCommandSuggestion(commandSuggestIndex - 1);
1312
+ return;
1313
+ }
1314
+ if (event.key === "Tab" && commandSuggestions.length > 0) {
1315
+ event.preventDefault();
1316
+ insertCommandSuggestion();
1317
+ return;
1318
+ }
1319
+ if (event.key === "Escape") {
1320
+ event.preventDefault();
1321
+ hideCommandSuggestions();
1322
+ }
1323
+ }
1324
+ });
1325
+
1326
+ elements.promptInput.addEventListener("input", () => renderCommandSuggestions());
1327
+ elements.promptInput.addEventListener("click", () => renderCommandSuggestions());
1328
+ elements.promptInput.addEventListener("keyup", (event) => {
1329
+ if (["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"].includes(event.key)) return;
1330
+ renderCommandSuggestions({ keepIndex: true });
1331
+ });
1332
+ elements.promptInput.addEventListener("blur", () => {
1333
+ setTimeout(() => {
1334
+ if (document.activeElement !== elements.promptInput) hideCommandSuggestions();
1335
+ }, 120);
1336
+ });
1337
+
1338
+ restoreSidePanelState();
1339
+ connectEvents();
1340
+ refreshAll();