@firstpick/pi-package-webui 0.1.0 → 0.1.1

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 CHANGED
@@ -2,6 +2,8 @@ const $ = (selector) => document.querySelector(selector);
2
2
 
3
3
  const elements = {
4
4
  sessionLine: $("#sessionLine"),
5
+ tabBar: $("#tabBar"),
6
+ newTabButton: $("#newTabButton"),
5
7
  statusBar: $("#statusBar"),
6
8
  widgetArea: $("#widgetArea"),
7
9
  chat: $("#chat"),
@@ -26,6 +28,8 @@ const elements = {
26
28
  setModelButton: $("#setModelButton"),
27
29
  thinkingSelect: $("#thinkingSelect"),
28
30
  setThinkingButton: $("#setThinkingButton"),
31
+ networkStatus: $("#networkStatus"),
32
+ openNetworkButton: $("#openNetworkButton"),
29
33
  toggleSidePanelButton: $("#toggleSidePanelButton"),
30
34
  sidePanelExpandButton: $("#sidePanelExpandButton"),
31
35
  sidePanel: $("#sidePanel"),
@@ -38,9 +42,22 @@ const elements = {
38
42
  dialogMessage: $("#dialogMessage"),
39
43
  dialogBody: $("#dialogBody"),
40
44
  dialogActions: $("#dialogActions"),
45
+ pathPickerDialog: $("#pathPickerDialog"),
46
+ pathPickerTitle: $("#pathPickerTitle"),
47
+ pathPickerCurrent: $("#pathPickerCurrent"),
48
+ pathPickerAddFastPickButton: $("#pathPickerAddFastPickButton"),
49
+ pathPickerFastPicks: $("#pathPickerFastPicks"),
50
+ pathPickerRoots: $("#pathPickerRoots"),
51
+ pathPickerList: $("#pathPickerList"),
52
+ pathPickerError: $("#pathPickerError"),
53
+ pathPickerCancelButton: $("#pathPickerCancelButton"),
54
+ pathPickerChooseButton: $("#pathPickerChooseButton"),
41
55
  };
42
56
 
43
57
  let currentState = null;
58
+ let tabs = [];
59
+ let activeTabId = null;
60
+ let tabDrafts = new Map();
44
61
  let streamBubble = null;
45
62
  let streamText = null;
46
63
  let streamThinking = null;
@@ -50,17 +67,21 @@ let refreshStateTimer = null;
50
67
  let refreshFooterTimer = null;
51
68
  let eventSource = null;
52
69
  let activeDialog = null;
70
+ let pathPickerState = null;
53
71
  let availableCommands = [];
54
72
  let commandSuggestions = [];
55
73
  let commandSuggestIndex = 0;
56
74
  let latestStats = null;
57
75
  let latestWorkspace = null;
76
+ let latestNetwork = null;
58
77
  let latestMessages = [];
59
78
  let currentRunStartedAt = null;
60
79
  let currentRunStreamChars = 0;
61
80
  let latestTokPerSecond = null;
62
81
  const dialogQueue = [];
63
82
  const SIDE_PANEL_STORAGE_KEY = "pi-webui-side-panel-collapsed";
83
+ const TAB_STORAGE_KEY = "pi-webui-active-tab";
84
+ const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
64
85
  const statusEntries = new Map();
65
86
  const widgets = new Map();
66
87
  const gitWorkflow = {
@@ -92,6 +113,10 @@ function make(tag, className, text) {
92
113
  return node;
93
114
  }
94
115
 
116
+ function delay(ms) {
117
+ return new Promise((resolve) => setTimeout(resolve, ms));
118
+ }
119
+
95
120
  function setSidePanelCollapsed(collapsed) {
96
121
  document.body.classList.toggle("side-panel-collapsed", collapsed);
97
122
  elements.toggleSidePanelButton.setAttribute("aria-expanded", collapsed ? "false" : "true");
@@ -113,8 +138,15 @@ function restoreSidePanelState() {
113
138
  }
114
139
  }
115
140
 
116
- async function api(path, { method = "GET", body } = {}) {
117
- const response = await fetch(path, {
141
+ function scopedApiPath(path, tabId = activeTabId) {
142
+ if (!tabId || !path.startsWith("/api/") || path === "/api/tabs" || path.startsWith("/api/tabs?") || path.startsWith("/api/tabs/")) return path;
143
+ const url = new URL(path, window.location.origin);
144
+ url.searchParams.set("tab", tabId);
145
+ return `${url.pathname}${url.search}${url.hash}`;
146
+ }
147
+
148
+ async function api(path, { method = "GET", body, tabId = activeTabId, scoped = true } = {}) {
149
+ const response = await fetch(scoped ? scopedApiPath(path, tabId) : path, {
118
150
  method,
119
151
  headers: body === undefined ? undefined : { "content-type": "application/json" },
120
152
  body: body === undefined ? undefined : JSON.stringify(body),
@@ -126,6 +158,218 @@ async function api(path, { method = "GET", body } = {}) {
126
158
  return data;
127
159
  }
128
160
 
161
+ function activeTab() {
162
+ return tabs.find((tab) => tab.id === activeTabId) || null;
163
+ }
164
+
165
+ function rememberActiveTab() {
166
+ try {
167
+ if (activeTabId) localStorage.setItem(TAB_STORAGE_KEY, activeTabId);
168
+ } catch {
169
+ // Ignore storage failures; tabs still work for this page load.
170
+ }
171
+ }
172
+
173
+ function restoreStoredTabId() {
174
+ try {
175
+ return localStorage.getItem(TAB_STORAGE_KEY) || null;
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+
181
+ function updateDocumentTitle() {
182
+ const tab = activeTab();
183
+ document.title = tab ? `Pi Web UI · ${tab.title}` : "Pi Web UI";
184
+ }
185
+
186
+ function saveActiveDraft() {
187
+ if (activeTabId) tabDrafts.set(activeTabId, elements.promptInput.value || "");
188
+ }
189
+
190
+ function restoreActiveDraft() {
191
+ elements.promptInput.value = activeTabId ? tabDrafts.get(activeTabId) || "" : "";
192
+ renderCommandSuggestions();
193
+ }
194
+
195
+ function clearRefreshTimers() {
196
+ clearTimeout(refreshMessagesTimer);
197
+ clearTimeout(refreshStateTimer);
198
+ clearTimeout(refreshFooterTimer);
199
+ refreshMessagesTimer = null;
200
+ refreshStateTimer = null;
201
+ refreshFooterTimer = null;
202
+ }
203
+
204
+ function cancelPendingDialogs() {
205
+ const pending = activeDialog ? [activeDialog] : [];
206
+ pending.push(...dialogQueue.splice(0));
207
+ for (const request of pending) {
208
+ if (!request?.id || !["select", "confirm", "input", "editor"].includes(request.method)) continue;
209
+ api("/api/extension-ui-response", {
210
+ method: "POST",
211
+ body: { type: "extension_ui_response", id: request.id, cancelled: true },
212
+ tabId: request.tabId || activeTabId,
213
+ }).catch((error) => console.warn("failed to cancel stale extension dialog", error));
214
+ }
215
+ activeDialog = null;
216
+ if (elements.dialog.open) elements.dialog.close();
217
+ }
218
+
219
+ function resetActiveTabUi() {
220
+ clearRefreshTimers();
221
+ eventSource?.close();
222
+ eventSource = null;
223
+ currentState = null;
224
+ latestStats = null;
225
+ latestWorkspace = null;
226
+ latestMessages = [];
227
+ currentRunStartedAt = null;
228
+ currentRunStreamChars = 0;
229
+ latestTokPerSecond = null;
230
+ statusEntries.clear();
231
+ widgets.clear();
232
+ availableCommands = [];
233
+ commandSuggestions = [];
234
+ commandSuggestIndex = 0;
235
+ resetStreamBubble();
236
+ hideCommandSuggestions();
237
+ cancelPendingDialogs();
238
+ Object.assign(gitWorkflow, {
239
+ active: false,
240
+ step: "idle",
241
+ busy: false,
242
+ output: "",
243
+ error: "",
244
+ message: null,
245
+ messageRequestedAt: 0,
246
+ });
247
+ elements.chat.replaceChildren();
248
+ elements.stateDetails.replaceChildren();
249
+ elements.eventLog.replaceChildren();
250
+ elements.queueBox.textContent = "No queued messages.";
251
+ elements.queueBox.classList.add("muted");
252
+ elements.commandsBox.textContent = "Loading…";
253
+ elements.commandsBox.classList.add("muted");
254
+ elements.sessionLine.textContent = activeTab() ? "Connecting…" : "No terminal tabs.";
255
+ renderWidgets();
256
+ renderGitWorkflow();
257
+ renderFooter();
258
+ }
259
+
260
+ function renderTabs() {
261
+ elements.tabBar.replaceChildren();
262
+ for (const tab of tabs) {
263
+ const isActive = tab.id === activeTabId;
264
+ const wrapper = make("div", `terminal-tab${isActive ? " active" : ""}${tab.running ? "" : " stopped"}`);
265
+ const button = make("button", "terminal-tab-button");
266
+ button.type = "button";
267
+ button.setAttribute("role", "tab");
268
+ button.setAttribute("aria-selected", isActive ? "true" : "false");
269
+ button.title = `${tab.title}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"}`;
270
+ button.append(
271
+ make("span", "terminal-tab-title", tab.title),
272
+ make("span", "terminal-tab-meta", tab.running ? `pid ${tab.pid || "…"}` : "stopped"),
273
+ );
274
+ button.addEventListener("click", () => switchTab(tab.id));
275
+ wrapper.append(button);
276
+
277
+ if (tabs.length > 1) {
278
+ const close = make("button", "terminal-tab-close", "×");
279
+ close.type = "button";
280
+ close.title = `Close ${tab.title}`;
281
+ close.setAttribute("aria-label", `Close ${tab.title}`);
282
+ close.addEventListener("click", (event) => {
283
+ event.stopPropagation();
284
+ closeTerminalTab(tab.id);
285
+ });
286
+ wrapper.append(close);
287
+ }
288
+
289
+ elements.tabBar.append(wrapper);
290
+ }
291
+ updateDocumentTitle();
292
+ }
293
+
294
+ async function refreshTabs({ selectStored = false } = {}) {
295
+ const response = await api("/api/tabs", { scoped: false });
296
+ tabs = response.data?.tabs || [];
297
+ const stored = selectStored ? restoreStoredTabId() : null;
298
+ if (!activeTabId || !tabs.some((tab) => tab.id === activeTabId)) {
299
+ activeTabId = (stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null;
300
+ rememberActiveTab();
301
+ }
302
+ renderTabs();
303
+ return tabs;
304
+ }
305
+
306
+ async function switchTab(tabId) {
307
+ if (!tabId || tabId === activeTabId || !tabs.some((tab) => tab.id === tabId)) return;
308
+ saveActiveDraft();
309
+ activeTabId = tabId;
310
+ rememberActiveTab();
311
+ resetActiveTabUi();
312
+ renderTabs();
313
+ restoreActiveDraft();
314
+ connectEvents();
315
+ await refreshAll();
316
+ }
317
+
318
+ async function createTerminalTab() {
319
+ elements.newTabButton.disabled = true;
320
+ try {
321
+ const response = await api("/api/tabs", { method: "POST", body: { cwd: activeTab()?.cwd }, scoped: false });
322
+ tabs = response.data?.tabs || tabs;
323
+ const tab = response.data?.tab;
324
+ renderTabs();
325
+ if (tab?.id) {
326
+ await switchTab(tab.id);
327
+ addEvent(`created isolated terminal ${tab.title}`, "info");
328
+ }
329
+ } catch (error) {
330
+ addEvent(error.message, "error");
331
+ } finally {
332
+ elements.newTabButton.disabled = false;
333
+ }
334
+ }
335
+
336
+ async function closeTerminalTab(tabId) {
337
+ const tab = tabs.find((item) => item.id === tabId);
338
+ if (!tab || tabs.length <= 1) return;
339
+ if (!confirm(`Close ${tab.title}? This terminates its isolated Pi process.`)) return;
340
+
341
+ const wasActive = tabId === activeTabId;
342
+ const fallbackTabId = tabs.find((item) => item.id !== tabId)?.id || null;
343
+ try {
344
+ if (wasActive) eventSource?.close();
345
+ const response = await api(`/api/tabs/${encodeURIComponent(tabId)}`, { method: "DELETE", scoped: false });
346
+ tabs = response.data?.tabs || tabs.filter((item) => item.id !== tabId);
347
+ tabDrafts.delete(tabId);
348
+ if (wasActive) {
349
+ activeTabId = (fallbackTabId && tabs.some((item) => item.id === fallbackTabId) ? fallbackTabId : tabs[0]?.id) || null;
350
+ rememberActiveTab();
351
+ resetActiveTabUi();
352
+ renderTabs();
353
+ restoreActiveDraft();
354
+ connectEvents();
355
+ if (activeTabId) await refreshAll();
356
+ } else {
357
+ renderTabs();
358
+ }
359
+ } catch (error) {
360
+ addEvent(error.message, "error");
361
+ }
362
+ }
363
+
364
+ async function initializeTabs() {
365
+ await refreshTabs({ selectStored: true });
366
+ resetActiveTabUi();
367
+ renderTabs();
368
+ restoreActiveDraft();
369
+ connectEvents();
370
+ if (activeTabId) await refreshAll();
371
+ }
372
+
129
373
  function addEvent(message, level = "info") {
130
374
  const line = make("div", `event ${level}`.trim());
131
375
  const time = new Date().toLocaleTimeString();
@@ -226,13 +470,215 @@ function footerMetric(icon, label, value, tone = "") {
226
470
  return node;
227
471
  }
228
472
 
229
- function footerMeta(label, value, className = "") {
230
- const node = make("span", `footer-meta ${className}`.trim());
473
+ function footerMeta(label, value, className = "", options = {}) {
474
+ const isAction = typeof options.onClick === "function";
475
+ const node = make(isAction ? "button" : "span", `footer-meta ${className}${isAction ? " footer-meta-action" : ""}`.trim());
476
+ if (isAction) {
477
+ node.type = "button";
478
+ node.addEventListener("click", options.onClick);
479
+ }
231
480
  node.append(make("span", "footer-meta-label", label), make("span", "footer-meta-value", value));
232
- node.title = `${label}: ${value}`;
481
+ node.title = options.title || `${label}: ${value}`;
233
482
  return node;
234
483
  }
235
484
 
485
+ function pathPickerButton(label, title, onClick, className = "") {
486
+ const button = make("button", className, label);
487
+ button.type = "button";
488
+ button.title = title || label;
489
+ button.addEventListener("click", onClick);
490
+ return button;
491
+ }
492
+
493
+ function setPathPickerError(message) {
494
+ elements.pathPickerError.textContent = message || "";
495
+ elements.pathPickerError.hidden = !message;
496
+ }
497
+
498
+ function normalizeFastPicks(value) {
499
+ const items = Array.isArray(value) ? value : [];
500
+ const seen = new Set();
501
+ const picks = [];
502
+ for (const item of items) {
503
+ const cwd = typeof item === "string" ? item : String(item?.cwd || "");
504
+ if (!cwd || seen.has(cwd)) continue;
505
+ seen.add(cwd);
506
+ picks.push({ cwd, displayCwd: String(item?.displayCwd || cwd) });
507
+ }
508
+ return picks.slice(0, 30);
509
+ }
510
+
511
+ function loadFastPicks() {
512
+ try {
513
+ return normalizeFastPicks(JSON.parse(localStorage.getItem(PATH_FAST_PICKS_STORAGE_KEY) || "[]"));
514
+ } catch {
515
+ return [];
516
+ }
517
+ }
518
+
519
+ function saveFastPicks(picks) {
520
+ try {
521
+ localStorage.setItem(PATH_FAST_PICKS_STORAGE_KEY, JSON.stringify(normalizeFastPicks(picks)));
522
+ } catch (error) {
523
+ addEvent(`failed to save path fast picks: ${error.message}`, "error");
524
+ }
525
+ }
526
+
527
+ function fastPickLabel(pick) {
528
+ const cwd = String(pick.cwd || pick.displayCwd || "");
529
+ const trimmed = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
530
+ if (!trimmed) return cwd || "directory";
531
+ return trimmed.split("/").filter(Boolean).pop() || trimmed;
532
+ }
533
+
534
+ function currentFastPick() {
535
+ if (!pathPickerState?.cwd) return null;
536
+ return { cwd: pathPickerState.cwd, displayCwd: elements.pathPickerCurrent.textContent || pathPickerState.cwd };
537
+ }
538
+
539
+ function updateAddFastPickButton() {
540
+ const pick = currentFastPick();
541
+ const exists = !!pick && loadFastPicks().some((item) => item.cwd === pick.cwd);
542
+ elements.pathPickerAddFastPickButton.disabled = !pick || exists;
543
+ elements.pathPickerAddFastPickButton.textContent = exists ? "Fast pick added" : "Add fast pick";
544
+ }
545
+
546
+ function renderFastPicks() {
547
+ const picks = loadFastPicks();
548
+ elements.pathPickerFastPicks.replaceChildren();
549
+ if (picks.length === 0) {
550
+ elements.pathPickerFastPicks.append(make("div", "path-picker-fast-picks-empty muted", "No fast picks yet."));
551
+ updateAddFastPickButton();
552
+ return;
553
+ }
554
+
555
+ for (const pick of picks) {
556
+ const item = make("span", "path-picker-fast-pick");
557
+ const jump = pathPickerButton(fastPickLabel(pick), pick.cwd, () => loadPathPickerDirectory(pick.cwd), "path-picker-fast-pick-button");
558
+ const remove = pathPickerButton("×", `Remove fast pick ${pick.cwd}`, () => {
559
+ saveFastPicks(loadFastPicks().filter((item) => item.cwd !== pick.cwd));
560
+ renderFastPicks();
561
+ }, "path-picker-fast-pick-remove");
562
+ item.append(jump, remove);
563
+ elements.pathPickerFastPicks.append(item);
564
+ }
565
+ updateAddFastPickButton();
566
+ }
567
+
568
+ function addCurrentFastPick() {
569
+ const pick = currentFastPick();
570
+ if (!pick) return;
571
+ const picks = loadFastPicks().filter((item) => item.cwd !== pick.cwd);
572
+ picks.unshift(pick);
573
+ saveFastPicks(picks);
574
+ renderFastPicks();
575
+ }
576
+
577
+ function renderPathPicker(data) {
578
+ if (!pathPickerState) return;
579
+ pathPickerState.cwd = data.cwd;
580
+ elements.pathPickerCurrent.textContent = data.displayCwd || data.cwd;
581
+ elements.pathPickerCurrent.title = data.cwd;
582
+ elements.pathPickerChooseButton.disabled = false;
583
+ elements.pathPickerChooseButton.textContent = "Use this directory";
584
+ setPathPickerError(data.truncated ? "Showing the first 500 directories." : "");
585
+ renderFastPicks();
586
+
587
+ elements.pathPickerRoots.replaceChildren();
588
+ if (data.parent) {
589
+ elements.pathPickerRoots.append(pathPickerButton("↑ Parent", data.parent, () => loadPathPickerDirectory(data.parent), "path-picker-root-button"));
590
+ }
591
+ for (const root of data.roots || []) {
592
+ elements.pathPickerRoots.append(pathPickerButton(root.label, root.cwd, () => loadPathPickerDirectory(root.cwd), "path-picker-root-button"));
593
+ }
594
+
595
+ elements.pathPickerList.replaceChildren();
596
+ if (!data.directories?.length) {
597
+ elements.pathPickerList.append(make("div", "path-picker-empty muted", "No subdirectories."));
598
+ return;
599
+ }
600
+
601
+ for (const directory of data.directories) {
602
+ const button = pathPickerButton(`${directory.name}/`, directory.cwd, () => loadPathPickerDirectory(directory.cwd), `path-picker-directory${directory.hidden ? " hidden-directory" : ""}`);
603
+ button.setAttribute("role", "option");
604
+ elements.pathPickerList.append(button);
605
+ }
606
+ }
607
+
608
+ async function loadPathPickerDirectory(cwd) {
609
+ if (!pathPickerState) return;
610
+ const requestId = ++pathPickerState.requestId;
611
+ elements.pathPickerAddFastPickButton.disabled = true;
612
+ elements.pathPickerChooseButton.disabled = true;
613
+ elements.pathPickerCurrent.textContent = "Loading…";
614
+ setPathPickerError("");
615
+
616
+ try {
617
+ const query = cwd ? `?path=${encodeURIComponent(cwd)}` : "";
618
+ const response = await api(`/api/directories${query}`);
619
+ if (!pathPickerState || pathPickerState.requestId !== requestId) return;
620
+ renderPathPicker(response.data || {});
621
+ } catch (error) {
622
+ if (!pathPickerState || pathPickerState.requestId !== requestId) return;
623
+ elements.pathPickerChooseButton.disabled = false;
624
+ elements.pathPickerCurrent.textContent = pathPickerState.cwd || "Unable to load directory";
625
+ setPathPickerError(error.message);
626
+ updateAddFastPickButton();
627
+ }
628
+ }
629
+
630
+ function closePathPicker(cwd) {
631
+ const state = pathPickerState;
632
+ if (!state) return;
633
+ pathPickerState = null;
634
+ if (elements.pathPickerDialog.open) elements.pathPickerDialog.close();
635
+ state.resolve(cwd || null);
636
+ }
637
+
638
+ function pickCwd(tab, initialCwd) {
639
+ if (pathPickerState) return Promise.resolve(null);
640
+
641
+ return new Promise((resolve) => {
642
+ pathPickerState = { tabId: tab.id, cwd: initialCwd, requestId: 0, resolve };
643
+ elements.pathPickerTitle.textContent = `Choose CWD for ${tab.title}`;
644
+ elements.pathPickerCurrent.textContent = "Loading…";
645
+ elements.pathPickerFastPicks.replaceChildren();
646
+ elements.pathPickerRoots.replaceChildren();
647
+ elements.pathPickerList.replaceChildren();
648
+ setPathPickerError("");
649
+ elements.pathPickerAddFastPickButton.disabled = true;
650
+ elements.pathPickerChooseButton.disabled = true;
651
+ elements.pathPickerDialog.showModal();
652
+ loadPathPickerDirectory(initialCwd);
653
+ });
654
+ }
655
+
656
+ async function changeActiveTabCwd() {
657
+ const tab = activeTab();
658
+ if (!tab) return;
659
+
660
+ const currentCwd = latestWorkspace?.cwd || tab.cwd || "";
661
+ const cwd = await pickCwd(tab, currentCwd);
662
+ if (!cwd || cwd === currentCwd) return;
663
+ if (!window.confirm(`Restart ${tab.title} in:\n${cwd}\n\nCurrent in-flight work in this tab will be stopped.`)) return;
664
+
665
+ saveActiveDraft();
666
+ try {
667
+ const response = await api(`/api/tabs/${encodeURIComponent(tab.id)}`, { method: "PATCH", body: { cwd }, scoped: false });
668
+ tabs = response.data?.tabs || tabs;
669
+ activeTabId = response.data?.tab?.id || activeTabId;
670
+ resetActiveTabUi();
671
+ renderTabs();
672
+ restoreActiveDraft();
673
+ connectEvents();
674
+ await refreshAll();
675
+ const changedCwd = response.data?.tab?.cwd || cwd;
676
+ addEvent(response.data?.changed === false ? `cwd unchanged: ${changedCwd}` : `changed ${tab.title} cwd to ${changedCwd}`, "info");
677
+ } catch (error) {
678
+ addEvent(error.message, "error");
679
+ }
680
+ }
681
+
236
682
  function renderFooter() {
237
683
  const stats = latestStats;
238
684
  const tokens = stats?.tokens || {};
@@ -246,10 +692,11 @@ function renderFooter() {
246
692
  ? `${contextUsage.percent !== null && contextUsage.percent !== undefined ? `${Number(contextUsage.percent).toFixed(1)}% / ` : ""}${formatTokenCount(contextUsage.contextWindow)}`
247
693
  : "?";
248
694
 
695
+ const tab = activeTab();
249
696
  const git = latestWorkspace?.git;
250
697
  const branchLabel = git?.isRepo ? git.branch || "detached" : "no repo";
251
698
  const changeLabel = git?.isRepo ? `✎ ${git.changed ?? 0} ◌ ${git.untracked ?? 0}` : "no git";
252
- const workspaceLabel = latestWorkspace?.displayCwd || "loading…";
699
+ const workspaceLabel = latestWorkspace?.displayCwd || (tab?.cwd ? normalizeDisplayPath(tab.cwd) : "loading…");
253
700
  const runtime = latestWorkspace?.uptimeMs ? formatDuration(latestWorkspace.uptimeMs) : "--";
254
701
  const modelLine = `${shortModelLabel(currentState?.model)} · ${currentState?.thinkingLevel || "?"}`;
255
702
 
@@ -266,7 +713,10 @@ function renderFooter() {
266
713
 
267
714
  const row2 = make("div", "footer-line footer-line-meta");
268
715
  row2.append(
269
- footerMeta("cwd", workspaceLabel, "footer-workspace"),
716
+ footerMeta("cwd", workspaceLabel, "footer-workspace", tab ? {
717
+ onClick: changeActiveTabCwd,
718
+ title: `Change cwd for ${tab.title}: ${workspaceLabel}`,
719
+ } : {}),
270
720
  footerMeta("git", branchLabel, "footer-branch"),
271
721
  footerMeta("changes", changeLabel, "footer-changes"),
272
722
  footerMeta("runtime", `⏱ ${runtime} · Agent`, "footer-runtime"),
@@ -803,6 +1253,31 @@ async function refreshWorkspace() {
803
1253
  renderFooter();
804
1254
  }
805
1255
 
1256
+ function renderNetworkStatus() {
1257
+ const network = latestNetwork;
1258
+ const open = !!network?.open;
1259
+ const opening = !!network?.opening;
1260
+ const url = network?.networkUrls?.[0] || (open ? network?.localUrl : "");
1261
+ elements.networkStatus.className = `network-status ${opening ? "opening" : open ? "open" : "closed"}`;
1262
+ elements.networkStatus.textContent = opening ? "Opening to local network…" : open ? `Open to LAN${url ? ` · ${url}` : ""}` : "Closed · local only";
1263
+ elements.networkStatus.title = open
1264
+ ? `Reachable on local network${(network?.networkUrls || []).length ? `:\n${network.networkUrls.join("\n")}` : " (no LAN address detected)"}`
1265
+ : "Only reachable from this machine";
1266
+ elements.openNetworkButton.disabled = opening || open;
1267
+ elements.openNetworkButton.textContent = opening ? "Opening…" : open ? "Open to network" : "Open to network";
1268
+ }
1269
+
1270
+ async function refreshNetworkStatus() {
1271
+ try {
1272
+ const response = await api("/api/network", { scoped: false });
1273
+ latestNetwork = response.data || null;
1274
+ } catch {
1275
+ const health = await api("/api/health", { scoped: false });
1276
+ latestNetwork = health.network || { open: false, opening: false, localUrl: window.location.origin };
1277
+ }
1278
+ renderNetworkStatus();
1279
+ }
1280
+
806
1281
  async function refreshFooterData() {
807
1282
  await Promise.allSettled([refreshStats(), refreshWorkspace()]);
808
1283
  }
@@ -999,12 +1474,42 @@ async function refreshCommands() {
999
1474
  }
1000
1475
 
1001
1476
  async function refreshAll() {
1002
- const results = await Promise.allSettled([refreshState(), refreshMessages(), refreshModels(), refreshCommands(), refreshStats(), refreshWorkspace()]);
1477
+ const results = await Promise.allSettled([refreshState(), refreshMessages(), refreshModels(), refreshCommands(), refreshStats(), refreshWorkspace(), refreshNetworkStatus()]);
1003
1478
  for (const result of results) {
1004
1479
  if (result.status === "rejected") addEvent(result.reason.message || String(result.reason), "error");
1005
1480
  }
1006
1481
  }
1007
1482
 
1483
+ async function openToNetwork() {
1484
+ if (latestNetwork?.open) return;
1485
+ if (!confirm("Open Pi Web UI to your local network?\n\nThe Web UI has no authentication and can control Pi/tools. Only do this on a trusted LAN.")) return;
1486
+
1487
+ elements.openNetworkButton.disabled = true;
1488
+ elements.openNetworkButton.textContent = "Opening…";
1489
+ try {
1490
+ await api("/api/network/open", { method: "POST", body: {}, scoped: false });
1491
+ addEvent("opening webui to local network", "warn");
1492
+ for (let attempt = 0; attempt < 20; attempt++) {
1493
+ await delay(350);
1494
+ try {
1495
+ await refreshNetworkStatus();
1496
+ if (latestNetwork?.open) {
1497
+ const url = latestNetwork.networkUrls?.[0];
1498
+ addEvent(`webui open to local network${url ? `: ${url}` : ""}`, "warn");
1499
+ return;
1500
+ }
1501
+ } catch {
1502
+ // The listener briefly drops while rebinding; retry.
1503
+ }
1504
+ }
1505
+ await refreshNetworkStatus();
1506
+ } catch (error) {
1507
+ addEvent(error.message, "error");
1508
+ } finally {
1509
+ renderNetworkStatus();
1510
+ }
1511
+ }
1512
+
1008
1513
  async function sendPrompt(kind = "prompt") {
1009
1514
  const message = elements.promptInput.value.trim();
1010
1515
  if (!message) return;
@@ -1028,6 +1533,7 @@ async function sendPrompt(kind = "prompt") {
1028
1533
  }
1029
1534
 
1030
1535
  function handleExtensionUiRequest(request) {
1536
+ request.tabId ||= activeTabId;
1031
1537
  switch (request.method) {
1032
1538
  case "notify":
1033
1539
  addEvent(request.message || "notification", request.notifyType === "error" ? "error" : request.notifyType === "warning" ? "warn" : "info");
@@ -1063,8 +1569,9 @@ function handleExtensionUiRequest(request) {
1063
1569
  }
1064
1570
 
1065
1571
  async function sendDialogResponse(payload) {
1572
+ const { tabId = activeTabId, ...body } = payload;
1066
1573
  try {
1067
- await api("/api/extension-ui-response", { method: "POST", body: payload });
1574
+ await api("/api/extension-ui-response", { method: "POST", body, tabId });
1068
1575
  } catch (error) {
1069
1576
  addEvent(error.message, "error");
1070
1577
  } finally {
@@ -1092,36 +1599,36 @@ function showNextDialog() {
1092
1599
  elements.dialogBody.replaceChildren();
1093
1600
  elements.dialogActions.replaceChildren();
1094
1601
 
1095
- const cancel = () => sendDialogResponse({ type: "extension_ui_response", id: request.id, cancelled: true });
1602
+ const cancel = () => sendDialogResponse({ type: "extension_ui_response", id: request.id, cancelled: true, tabId: request.tabId });
1096
1603
 
1097
1604
  if (request.method === "select") {
1098
1605
  const options = make("div", "dialog-options");
1099
1606
  for (const option of request.options || []) {
1100
1607
  const button = make("button", undefined, String(option));
1101
1608
  button.type = "button";
1102
- button.addEventListener("click", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: String(option) }));
1609
+ button.addEventListener("click", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: String(option), tabId: request.tabId }));
1103
1610
  options.append(button);
1104
1611
  }
1105
1612
  elements.dialogBody.append(options);
1106
1613
  addDialogButton("Cancel", cancel);
1107
1614
  } else if (request.method === "confirm") {
1108
1615
  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");
1616
+ addDialogButton("No", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, confirmed: false, tabId: request.tabId }));
1617
+ addDialogButton("Yes", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, confirmed: true, tabId: request.tabId }), "primary");
1111
1618
  } else if (request.method === "input") {
1112
1619
  const input = make("input", "dialog-input");
1113
1620
  input.value = request.prefill || "";
1114
1621
  input.placeholder = request.placeholder || "";
1115
1622
  elements.dialogBody.append(input);
1116
1623
  addDialogButton("Cancel", cancel);
1117
- addDialogButton("Submit", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: input.value }), "primary");
1624
+ addDialogButton("Submit", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: input.value, tabId: request.tabId }), "primary");
1118
1625
  setTimeout(() => input.focus(), 0);
1119
1626
  } else if (request.method === "editor") {
1120
1627
  const textarea = make("textarea", "dialog-editor");
1121
1628
  textarea.value = request.prefill || "";
1122
1629
  elements.dialogBody.append(textarea);
1123
1630
  addDialogButton("Cancel", cancel);
1124
- addDialogButton("Submit", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: textarea.value }), "primary");
1631
+ addDialogButton("Submit", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: textarea.value, tabId: request.tabId }), "primary");
1125
1632
  setTimeout(() => textarea.focus(), 0);
1126
1633
  }
1127
1634
 
@@ -1131,16 +1638,33 @@ function showNextDialog() {
1131
1638
  function handleEvent(event) {
1132
1639
  switch (event.type) {
1133
1640
  case "webui_connected":
1134
- addEvent(`connected to webui for ${event.cwd}`);
1641
+ addEvent(`connected to ${event.tabTitle || "terminal"} for ${event.cwd}`);
1642
+ refreshTabs().catch((error) => addEvent(error.message, "error"));
1135
1643
  break;
1136
1644
  case "pi_process_start":
1137
1645
  addEvent(`started pi rpc pid ${event.pid}`);
1646
+ refreshTabs().catch((error) => addEvent(error.message, "error"));
1647
+ break;
1648
+ case "webui_tab_restarting":
1649
+ addEvent(`restarting ${event.tabTitle || "terminal"} in ${event.cwd}`);
1650
+ break;
1651
+ case "webui_cwd_changed":
1652
+ addEvent(`${event.tabTitle || "terminal"} cwd changed to ${event.cwd}`);
1653
+ refreshTabs().catch((error) => addEvent(error.message, "error"));
1654
+ scheduleRefreshFooter();
1655
+ break;
1656
+ case "webui_network_rebinding":
1657
+ addEvent(`webui network listener rebinding on ${event.host}:${event.port}; event stream will reconnect`, "warn");
1658
+ latestNetwork = { ...(latestNetwork || {}), opening: true };
1659
+ renderNetworkStatus();
1138
1660
  break;
1139
1661
  case "pi_process_exit":
1140
1662
  addEvent(`pi rpc exited (${event.code ?? event.signal ?? "unknown"})`, "error");
1663
+ refreshTabs().catch((error) => addEvent(error.message, "error"));
1141
1664
  break;
1142
1665
  case "pi_process_error":
1143
1666
  addEvent(event.error || "pi rpc process error", "error");
1667
+ refreshTabs().catch((error) => addEvent(error.message, "error"));
1144
1668
  break;
1145
1669
  case "pi_stderr":
1146
1670
  addEvent(event.text.trim(), "warn");
@@ -1216,7 +1740,8 @@ function handleEvent(event) {
1216
1740
 
1217
1741
  function connectEvents() {
1218
1742
  eventSource?.close();
1219
- eventSource = new EventSource("/api/events");
1743
+ if (!activeTabId) return;
1744
+ eventSource = new EventSource(`/api/events?tab=${encodeURIComponent(activeTabId)}`);
1220
1745
  eventSource.onmessage = (message) => {
1221
1746
  try {
1222
1747
  handleEvent(JSON.parse(message.data));
@@ -1233,6 +1758,7 @@ elements.composer.addEventListener("submit", (event) => {
1233
1758
  });
1234
1759
  elements.steerButton.addEventListener("click", () => sendPrompt("steer"));
1235
1760
  elements.followUpButton.addEventListener("click", () => sendPrompt("follow-up"));
1761
+ elements.newTabButton.addEventListener("click", createTerminalTab);
1236
1762
  elements.gitWorkflowButton.addEventListener("click", startGitWorkflow);
1237
1763
  elements.gitWorkflowCancelButton.addEventListener("click", cancelGitWorkflow);
1238
1764
  elements.abortButton.addEventListener("click", async () => {
@@ -1285,6 +1811,7 @@ elements.setThinkingButton.addEventListener("click", async () => {
1285
1811
  addEvent(error.message, "error");
1286
1812
  }
1287
1813
  });
1814
+ elements.openNetworkButton.addEventListener("click", openToNetwork);
1288
1815
  elements.toggleSidePanelButton.addEventListener("click", () => {
1289
1816
  setSidePanelCollapsed(true);
1290
1817
  });
@@ -1292,6 +1819,17 @@ elements.sidePanelExpandButton.addEventListener("click", () => {
1292
1819
  setSidePanelCollapsed(false);
1293
1820
  });
1294
1821
 
1822
+ elements.pathPickerAddFastPickButton.addEventListener("click", addCurrentFastPick);
1823
+ elements.pathPickerCancelButton.addEventListener("click", () => closePathPicker(null));
1824
+ elements.pathPickerChooseButton.addEventListener("click", () => closePathPicker(pathPickerState?.cwd || null));
1825
+ elements.pathPickerDialog.addEventListener("cancel", (event) => {
1826
+ event.preventDefault();
1827
+ closePathPicker(null);
1828
+ });
1829
+ elements.pathPickerDialog.addEventListener("close", () => {
1830
+ if (pathPickerState) closePathPicker(null);
1831
+ });
1832
+
1295
1833
  elements.promptInput.addEventListener("keydown", (event) => {
1296
1834
  if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
1297
1835
  event.preventDefault();
@@ -1336,5 +1874,4 @@ elements.promptInput.addEventListener("blur", () => {
1336
1874
  });
1337
1875
 
1338
1876
  restoreSidePanelState();
1339
- connectEvents();
1340
- refreshAll();
1877
+ initializeTabs().catch((error) => addEvent(error.message, "error"));