@firstpick/pi-package-webui 0.1.7 โ†’ 0.1.9

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
@@ -63,6 +63,8 @@ const elements = {
63
63
  agentDoneNotificationsToggle: $("#agentDoneNotificationsToggle"),
64
64
  agentDoneNotificationsStatus: $("#agentDoneNotificationsStatus"),
65
65
  optionalFeaturesBox: $("#optionalFeaturesBox"),
66
+ codexUsageBox: $("#codexUsageBox"),
67
+ refreshCodexUsageButton: $("#refreshCodexUsageButton"),
66
68
  toggleSidePanelButton: $("#toggleSidePanelButton"),
67
69
  sidePanelExpandButton: $("#sidePanelExpandButton"),
68
70
  sidePanelBackdrop: $("#sidePanelBackdrop"),
@@ -146,6 +148,11 @@ let pathSuggestAbortController = null;
146
148
  let latestStats = null;
147
149
  let latestWorkspace = null;
148
150
  let latestNetwork = null;
151
+ let latestCodexUsage = null;
152
+ let codexUsageError = null;
153
+ let codexUsageLoading = false;
154
+ let refreshCodexUsageTimer = null;
155
+ let codexUsageRenderTimer = null;
149
156
  let backendOffline = false;
150
157
  let backendOfflineNoticeShown = false;
151
158
  let latestMessages = [];
@@ -220,6 +227,8 @@ const STICKY_USER_PROMPT_TOP_GAP_PX = 12;
220
227
  const CHAT_FOLLOW_SETTLE_DELAY_MS = 80;
221
228
  const CHAT_PROGRAMMATIC_SCROLL_GRACE_MS = 500;
222
229
  const CHAT_USER_SCROLL_INTENT_MS = 700;
230
+ const CODEX_USAGE_REFRESH_MS = 5 * 60 * 1000;
231
+ const CODEX_USAGE_RENDER_TICK_MS = 30 * 1000;
223
232
  const RUN_INDICATOR_TICK_MS = 1000;
224
233
  const RUN_INDICATOR_START_GRACE_MS = 2500;
225
234
  const RUN_INDICATOR_STATE_RECHECK_MS = 5000;
@@ -319,16 +328,56 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
319
328
  const HIDDEN_COMMAND_NAMES = new Set(["webui-tree-navigate"]);
320
329
  const NATIVE_SELECTOR_COMMANDS = new Set(["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models"]);
321
330
  const optionalFeatureInstallInProgress = new Set();
322
- const gitWorkflow = {
323
- active: false,
324
- step: "idle",
325
- busy: false,
326
- runId: 0,
327
- output: "",
328
- error: "",
329
- message: null,
330
- messageRequestedAt: 0,
331
- };
331
+
332
+ function createGitWorkflowState() {
333
+ return {
334
+ active: false,
335
+ step: "idle",
336
+ busy: false,
337
+ runId: 0,
338
+ output: "",
339
+ error: "",
340
+ message: null,
341
+ messageRequestedAt: 0,
342
+ };
343
+ }
344
+
345
+ const gitWorkflowsByTab = new Map();
346
+ let gitWorkflow = createGitWorkflowState();
347
+
348
+ function gitWorkflowForTab(tabId = activeTabId, { create = true } = {}) {
349
+ if (!tabId) return null;
350
+ let workflow = gitWorkflowsByTab.get(tabId);
351
+ if (!workflow && create) {
352
+ workflow = createGitWorkflowState();
353
+ gitWorkflowsByTab.set(tabId, workflow);
354
+ }
355
+ return workflow || null;
356
+ }
357
+
358
+ function bindGitWorkflowToActiveTab() {
359
+ gitWorkflow = gitWorkflowForTab(activeTabId) || createGitWorkflowState();
360
+ return gitWorkflow;
361
+ }
362
+
363
+ function resetGitWorkflowForTab(tabId = activeTabId) {
364
+ if (!tabId) return;
365
+ gitWorkflowsByTab.set(tabId, createGitWorkflowState());
366
+ if (tabId === activeTabId) {
367
+ bindGitWorkflowToActiveTab();
368
+ renderGitWorkflow();
369
+ }
370
+ }
371
+
372
+ function clearGitWorkflowForTab(tabId) {
373
+ if (!tabId) return;
374
+ gitWorkflowsByTab.delete(tabId);
375
+ if (tabId === activeTabId) {
376
+ bindGitWorkflowToActiveTab();
377
+ renderGitWorkflow();
378
+ }
379
+ }
380
+
332
381
  const GIT_WORKFLOW_STEPS = ["Stage", "Message", "Commit", "Push"];
333
382
  const ACTION_FEEDBACK_REACTIONS = {
334
383
  up: { icon: "๐Ÿ‘", label: "Good job", title: "Good job!" },
@@ -1838,6 +1887,7 @@ function setActiveTabId(tabId, { remember = false } = {}) {
1838
1887
  const nextTabId = tabId || null;
1839
1888
  if (nextTabId !== activeTabId) activeTabGeneration += 1;
1840
1889
  activeTabId = nextTabId;
1890
+ bindGitWorkflowToActiveTab();
1841
1891
  if (remember) rememberActiveTab();
1842
1892
  return activeTabContext(nextTabId);
1843
1893
  }
@@ -1914,6 +1964,7 @@ function syncTabMetadata(nextTabs = []) {
1914
1964
  tabActivities.delete(tabId);
1915
1965
  tabSeenCompletionSerials.delete(tabId);
1916
1966
  actionFeedbackByTab.delete(tabId);
1967
+ clearGitWorkflowForTab(tabId);
1917
1968
  }
1918
1969
  }
1919
1970
  }
@@ -2094,6 +2145,15 @@ function restoreStoredTabId() {
2094
2145
  }
2095
2146
  }
2096
2147
 
2148
+ function requestedTabIdFromUrl() {
2149
+ try {
2150
+ const params = new URLSearchParams(window.location.search);
2151
+ return params.get("tab") || params.get("tabId") || null;
2152
+ } catch {
2153
+ return null;
2154
+ }
2155
+ }
2156
+
2097
2157
  function updateDocumentTitle() {
2098
2158
  const tab = activeTab();
2099
2159
  document.title = tab ? `Pi Web UI ยท ${tab.title}` : "Pi Web UI";
@@ -2179,15 +2239,7 @@ function resetActiveTabUi() {
2179
2239
  cancelPendingDialogs();
2180
2240
  if (elements.nativeCommandDialog.open) closeNativeCommandDialog();
2181
2241
  if (pathPickerState) closePathPicker(null);
2182
- Object.assign(gitWorkflow, {
2183
- active: false,
2184
- step: "idle",
2185
- busy: false,
2186
- output: "",
2187
- error: "",
2188
- message: null,
2189
- messageRequestedAt: 0,
2190
- });
2242
+ bindGitWorkflowToActiveTab();
2191
2243
  resetChatOutput();
2192
2244
  elements.stateDetails.replaceChildren();
2193
2245
  elements.eventLog.replaceChildren();
@@ -2438,9 +2490,10 @@ async function refreshTabs({ selectStored = false } = {}) {
2438
2490
  syncTabMetadata(tabs);
2439
2491
  syncBlockedTabNotificationsFromTabs(tabs, previousTabs);
2440
2492
  syncAgentDoneNotificationsFromTabs(tabs, previousTabs);
2493
+ const requested = selectStored ? requestedTabIdFromUrl() : null;
2441
2494
  const stored = selectStored ? restoreStoredTabId() : null;
2442
2495
  if (!activeTabId || !tabs.some((tab) => tab.id === activeTabId)) {
2443
- setActiveTabId((stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null, { remember: true });
2496
+ setActiveTabId((requested && tabs.some((tab) => tab.id === requested) ? requested : stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null, { remember: true });
2444
2497
  }
2445
2498
  rememberServerStartCwd(tabs.find((tab) => tab.id === activeTabId)?.cwd || tabs[0]?.cwd);
2446
2499
  renderTabs();
@@ -2532,6 +2585,7 @@ async function closeTerminalTabs(tabIds, { label = "selected terminal tabs" } =
2532
2585
  for (const id of closedIds) {
2533
2586
  tabDrafts.delete(id);
2534
2587
  clearAttachments(id);
2588
+ clearGitWorkflowForTab(id);
2535
2589
  }
2536
2590
  clearOpenTerminalTabGroup(null, { force: true });
2537
2591
 
@@ -3436,6 +3490,7 @@ async function changeActiveTabCwd() {
3436
3490
  return;
3437
3491
  }
3438
3492
  const nextContext = setActiveTabId(response.data?.tab?.id || activeTabId);
3493
+ resetGitWorkflowForTab(nextContext.tabId);
3439
3494
  resetActiveTabUi();
3440
3495
  renderTabs();
3441
3496
  restoreActiveDraft();
@@ -3538,6 +3593,197 @@ function scheduleRefreshFooter(delay = 300, tabContext = activeTabContext()) {
3538
3593
  }, delay);
3539
3594
  }
3540
3595
 
3596
+ function formatCodexPlanType(value) {
3597
+ const text = String(value || "").trim();
3598
+ if (!text) return "unknown plan";
3599
+ return text.replace(/[_-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
3600
+ }
3601
+
3602
+ function formatCodexPercent(value) {
3603
+ const number = Number(value);
3604
+ return Number.isFinite(number) ? `${Math.max(0, Math.min(100, Math.round(number)))}%` : "โ€”";
3605
+ }
3606
+
3607
+ function codexWindowDurationMinutes(window) {
3608
+ const minutes = Number(window?.windowDurationMins);
3609
+ if (Number.isFinite(minutes) && minutes > 0) return minutes;
3610
+ const seconds = Number(window?.windowDurationSeconds);
3611
+ return Number.isFinite(seconds) && seconds > 0 ? seconds / 60 : null;
3612
+ }
3613
+
3614
+ function formatCodexWindowDuration(window) {
3615
+ const minutes = codexWindowDurationMinutes(window);
3616
+ if (!minutes) return "window";
3617
+ if (minutes >= 280 && minutes <= 320) return "5h window";
3618
+ if (minutes >= 9500 && minutes <= 10550) return "weekly window";
3619
+ if (minutes >= 60 * 24) {
3620
+ const days = minutes / (60 * 24);
3621
+ return `${days >= 10 ? Math.round(days) : Number(days.toFixed(1))}d window`;
3622
+ }
3623
+ if (minutes >= 60) {
3624
+ const hours = minutes / 60;
3625
+ return `${Number.isInteger(hours) ? hours : Number(hours.toFixed(1))}h window`;
3626
+ }
3627
+ return `${Math.round(minutes)}m window`;
3628
+ }
3629
+
3630
+ function formatDurationParts(milliseconds) {
3631
+ if (!Number.isFinite(Number(milliseconds))) return "now";
3632
+ const totalMinutes = Math.max(0, Math.ceil(Number(milliseconds) / 60000));
3633
+ if (totalMinutes <= 1) return "<1m";
3634
+ if (totalMinutes < 60) return `${totalMinutes}m`;
3635
+ const hours = Math.floor(totalMinutes / 60);
3636
+ const minutes = totalMinutes % 60;
3637
+ if (hours < 48) return minutes ? `${hours}h ${minutes}m` : `${hours}h`;
3638
+ const days = Math.floor(hours / 24);
3639
+ const remHours = hours % 24;
3640
+ return remHours ? `${days}d ${remHours}h` : `${days}d`;
3641
+ }
3642
+
3643
+ function codexWindowResetDate(window) {
3644
+ const resetAt = window?.resetsAt ? new Date(window.resetsAt) : null;
3645
+ if (resetAt && Number.isFinite(resetAt.getTime())) return resetAt;
3646
+ const resetAfterSeconds = Number(window?.resetAfterSeconds);
3647
+ if (Number.isFinite(resetAfterSeconds) && resetAfterSeconds >= 0) return new Date(Date.now() + resetAfterSeconds * 1000);
3648
+ return null;
3649
+ }
3650
+
3651
+ function formatCodexReset(window) {
3652
+ const resetDate = codexWindowResetDate(window);
3653
+ if (!resetDate) return "reset unknown";
3654
+ const diff = resetDate.getTime() - Date.now();
3655
+ if (diff <= 0) return "resetting now";
3656
+ return `resets in ${formatDurationParts(diff)}`;
3657
+ }
3658
+
3659
+ function codexSnapshotName(snapshot) {
3660
+ return snapshot?.limitName || snapshot?.limitId || "codex";
3661
+ }
3662
+
3663
+ function codexUsageBuckets(data) {
3664
+ const buckets = [];
3665
+ const selected = data?.selected || data?.rateLimits || null;
3666
+ const snapshots = Array.isArray(data?.snapshots) ? data.snapshots : selected ? [selected] : [];
3667
+ const selectedKey = selected?.limitId || selected?.limitName || "codex";
3668
+ const pushWindow = (snapshot, kind, window, { prefix } = {}) => {
3669
+ if (!window) return;
3670
+ const durationLabel = formatCodexWindowDuration(window);
3671
+ const baseLabel = kind === "secondary" && durationLabel === "window" ? "secondary window" : durationLabel;
3672
+ buckets.push({
3673
+ key: `${snapshot?.limitId || snapshot?.limitName || buckets.length}-${kind}`,
3674
+ label: prefix ? `${prefix} ยท ${baseLabel}` : baseLabel,
3675
+ window,
3676
+ });
3677
+ };
3678
+
3679
+ if (selected) {
3680
+ pushWindow(selected, "primary", selected.primary);
3681
+ pushWindow(selected, "secondary", selected.secondary);
3682
+ }
3683
+ for (const snapshot of snapshots) {
3684
+ const key = snapshot?.limitId || snapshot?.limitName;
3685
+ if (!snapshot || snapshot === selected || key === selectedKey) continue;
3686
+ const name = codexSnapshotName(snapshot);
3687
+ pushWindow(snapshot, "primary", snapshot.primary, { prefix: name });
3688
+ pushWindow(snapshot, "secondary", snapshot.secondary, { prefix: name });
3689
+ }
3690
+ return buckets.slice(0, 6);
3691
+ }
3692
+
3693
+ function renderCodexUsage() {
3694
+ const box = elements.codexUsageBox;
3695
+ if (!box) return;
3696
+ if (elements.refreshCodexUsageButton) {
3697
+ elements.refreshCodexUsageButton.disabled = codexUsageLoading;
3698
+ elements.refreshCodexUsageButton.textContent = codexUsageLoading ? "Refreshingโ€ฆ" : "Refresh usage";
3699
+ }
3700
+
3701
+ box.replaceChildren();
3702
+ box.classList.toggle("muted", !latestCodexUsage);
3703
+
3704
+ if (!latestCodexUsage && codexUsageLoading) {
3705
+ box.textContent = "Checking Codex usageโ€ฆ";
3706
+ return;
3707
+ }
3708
+ if (!latestCodexUsage && codexUsageError) {
3709
+ const title = make("div", "codex-usage-unavailable", "Usage unavailable");
3710
+ const detail = make("div", "codex-usage-detail", codexUsageError.message || String(codexUsageError));
3711
+ box.append(title, detail);
3712
+ return;
3713
+ }
3714
+ if (!latestCodexUsage) {
3715
+ box.textContent = "Codex usage has not loaded yet.";
3716
+ return;
3717
+ }
3718
+
3719
+ const header = make("div", "codex-usage-summary");
3720
+ header.append(
3721
+ make("span", "codex-usage-plan", formatCodexPlanType(latestCodexUsage.planType)),
3722
+ make("span", "codex-usage-fetched", latestCodexUsage.fetchedAt ? `updated ${formatDurationParts(Date.now() - new Date(latestCodexUsage.fetchedAt).getTime())} ago` : "updated now"),
3723
+ );
3724
+ box.append(header);
3725
+
3726
+ const buckets = codexUsageBuckets(latestCodexUsage);
3727
+ if (buckets.length === 0) {
3728
+ box.append(make("div", "codex-usage-detail", "No Codex rate-limit windows were returned."));
3729
+ } else {
3730
+ for (const bucket of buckets) {
3731
+ const usedPercent = Number(bucket.window?.usedPercent);
3732
+ const fillPercent = Number.isFinite(usedPercent) ? Math.max(0, Math.min(100, usedPercent)) : 0;
3733
+ const item = make("div", "codex-usage-bucket");
3734
+ const row = make("div", "codex-usage-row");
3735
+ row.append(
3736
+ make("span", "codex-usage-label", bucket.label),
3737
+ make("strong", "codex-usage-percent", formatCodexPercent(bucket.window?.usedPercent)),
3738
+ );
3739
+ const meter = make("div", "codex-usage-meter");
3740
+ const fill = make("span", "codex-usage-meter-fill");
3741
+ fill.style.width = `${fillPercent}%`;
3742
+ meter.append(fill);
3743
+ item.append(row, meter, make("div", "codex-usage-reset", formatCodexReset(bucket.window)));
3744
+ box.append(item);
3745
+ }
3746
+ }
3747
+
3748
+ if (latestCodexUsage.rateLimitReachedType) {
3749
+ box.append(make("div", "codex-usage-warning", `Limit status: ${latestCodexUsage.rateLimitReachedType}`));
3750
+ }
3751
+ if (codexUsageError) {
3752
+ box.append(make("div", "codex-usage-detail", `Latest refresh failed: ${codexUsageError.message || codexUsageError}`));
3753
+ }
3754
+ }
3755
+
3756
+ async function refreshCodexUsage({ forceAuthRefresh = false } = {}) {
3757
+ if (codexUsageLoading) return;
3758
+ codexUsageLoading = true;
3759
+ renderCodexUsage();
3760
+ try {
3761
+ const suffix = forceAuthRefresh ? "?refresh=1" : "";
3762
+ const response = await api(`/api/codex-usage${suffix}`, { scoped: false });
3763
+ latestCodexUsage = response.data || null;
3764
+ codexUsageError = null;
3765
+ } catch (error) {
3766
+ codexUsageError = error;
3767
+ } finally {
3768
+ codexUsageLoading = false;
3769
+ renderCodexUsage();
3770
+ }
3771
+ }
3772
+
3773
+ function scheduleRefreshCodexUsage(delay = CODEX_USAGE_REFRESH_MS) {
3774
+ clearTimeout(refreshCodexUsageTimer);
3775
+ refreshCodexUsageTimer = setTimeout(() => {
3776
+ refreshCodexUsage().finally(() => scheduleRefreshCodexUsage());
3777
+ }, delay);
3778
+ }
3779
+
3780
+ function initializeCodexUsage() {
3781
+ renderCodexUsage();
3782
+ refreshCodexUsage().finally(() => scheduleRefreshCodexUsage());
3783
+ clearInterval(codexUsageRenderTimer);
3784
+ codexUsageRenderTimer = setInterval(renderCodexUsage, CODEX_USAGE_RENDER_TICK_MS);
3785
+ }
3786
+
3541
3787
  function renderStatus() {
3542
3788
  const state = currentState;
3543
3789
  updateComposerModeButtons();
@@ -3964,18 +4210,27 @@ function renderWidgets() {
3964
4210
  }
3965
4211
  }
3966
4212
 
3967
- function setGitWorkflow(patch) {
3968
- Object.assign(gitWorkflow, patch);
3969
- renderGitWorkflow();
4213
+ function setGitWorkflow(patch, { tabId = activeTabId } = {}) {
4214
+ const workflow = gitWorkflowForTab(tabId);
4215
+ if (!workflow) return null;
4216
+ Object.assign(workflow, patch);
4217
+ if (tabId === activeTabId) {
4218
+ gitWorkflow = workflow;
4219
+ renderGitWorkflow();
4220
+ }
4221
+ return workflow;
3970
4222
  }
3971
4223
 
3972
- function isCurrentGitWorkflowRun(runId) {
3973
- return gitWorkflow.active && gitWorkflow.runId === runId;
4224
+ function isCurrentGitWorkflowRun(runId, tabId = activeTabId) {
4225
+ const workflow = gitWorkflowForTab(tabId, { create: false });
4226
+ return !!workflow?.active && workflow.runId === runId;
3974
4227
  }
3975
4228
 
3976
- function appendGitWorkflowOutput(text) {
3977
- const next = `${gitWorkflow.output || ""}${gitWorkflow.output ? "\n" : ""}${text}`;
3978
- setGitWorkflow({ output: next.slice(-60000) });
4229
+ function appendGitWorkflowOutput(text, { tabId = activeTabId } = {}) {
4230
+ const workflow = gitWorkflowForTab(tabId);
4231
+ if (!workflow) return;
4232
+ const next = `${workflow.output || ""}${workflow.output ? "\n" : ""}${text}`;
4233
+ setGitWorkflow({ output: next.slice(-60000) }, { tabId });
3979
4234
  }
3980
4235
 
3981
4236
  function formatGitCommandResult(result) {
@@ -4078,9 +4333,11 @@ function renderGitWorkflow() {
4078
4333
  }
4079
4334
  }
4080
4335
 
4081
- async function gitWorkflowRequest(path, { method = "POST", body = {}, runId = gitWorkflow.runId } = {}) {
4082
- const response = await api(path, method === "GET" ? { method } : { method, body });
4083
- if (!isCurrentGitWorkflowRun(runId)) return null;
4336
+ async function gitWorkflowRequest(path, { method = "POST", body = {}, runId, tabId = activeTabId } = {}) {
4337
+ const workflow = gitWorkflowForTab(tabId, { create: false });
4338
+ const expectedRunId = runId ?? workflow?.runId;
4339
+ const response = await api(path, method === "GET" ? { method, tabId } : { method, body, tabId });
4340
+ if (expectedRunId !== undefined && !isCurrentGitWorkflowRun(expectedRunId, tabId)) return null;
4084
4341
  if (!response.ok) {
4085
4342
  const detail = response.data ? `\n\n${formatGitCommandResult(response.data)}` : "";
4086
4343
  throw new Error(`${response.error || "Git workflow request failed"}${detail}`);
@@ -4088,27 +4345,32 @@ async function gitWorkflowRequest(path, { method = "POST", body = {}, runId = gi
4088
4345
  return response.data;
4089
4346
  }
4090
4347
 
4091
- function failGitWorkflow(error, step = gitWorkflow.step) {
4348
+ function failGitWorkflow(error, step, { tabId = activeTabId } = {}) {
4349
+ const workflow = gitWorkflowForTab(tabId);
4350
+ if (!workflow) return;
4092
4351
  const message = error?.message || String(error);
4093
4352
  setGitWorkflow({
4094
- step,
4353
+ step: step || workflow.step || "error",
4095
4354
  busy: false,
4096
4355
  error: message,
4097
- output: `${gitWorkflow.output || ""}${gitWorkflow.output ? "\n\n" : ""}ERROR: ${message}`.slice(-60000),
4098
- });
4356
+ output: `${workflow.output || ""}${workflow.output ? "\n\n" : ""}ERROR: ${message}`.slice(-60000),
4357
+ }, { tabId });
4099
4358
  }
4100
4359
 
4101
4360
  function startGitWorkflow() {
4361
+ const tabId = activeTabId;
4362
+ if (!tabId) return;
4102
4363
  if (!isOptionalFeatureEnabled("gitWorkflow")) {
4103
- const tabContext = activeTabContext();
4364
+ const tabContext = activeTabContext(tabId);
4104
4365
  addEvent(commandUnavailableMessage("git-staged-msg"), "warn");
4105
4366
  refreshCommands(tabContext).catch((error) => {
4106
4367
  if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
4107
4368
  });
4108
4369
  return;
4109
4370
  }
4110
- if (gitWorkflow.active && !["done", "cancelled", "error"].includes(gitWorkflow.step) && !confirm("Restart the active git workflow?")) return;
4111
- gitWorkflow.runId += 1;
4371
+ const workflow = gitWorkflowForTab(tabId);
4372
+ if (workflow.active && !["done", "cancelled", "error"].includes(workflow.step) && !confirm("Restart the active git workflow?")) return;
4373
+ workflow.runId += 1;
4112
4374
  setGitWorkflow({
4113
4375
  active: true,
4114
4376
  step: "add",
@@ -4117,40 +4379,52 @@ function startGitWorkflow() {
4117
4379
  error: "",
4118
4380
  message: null,
4119
4381
  messageRequestedAt: 0,
4120
- });
4382
+ }, { tabId });
4121
4383
  }
4122
4384
 
4123
4385
  async function cancelGitWorkflow() {
4124
- const shouldAbortPi = gitWorkflow.step === "generating";
4125
- gitWorkflow.runId += 1;
4126
- setGitWorkflow({ step: "cancelled", busy: false, error: "", output: `${gitWorkflow.output || ""}${gitWorkflow.output ? "\n\n" : ""}Cancelled by user.` });
4127
- if (shouldAbortPi) setRunIndicatorActivity("Abort requested; checking whether Pi stoppedโ€ฆ");
4386
+ const tabId = activeTabId;
4387
+ const tabContext = activeTabContext(tabId);
4388
+ const workflow = gitWorkflowForTab(tabId, { create: false });
4389
+ if (!workflow?.active) return;
4390
+ const shouldAbortPi = workflow.step === "generating";
4391
+ workflow.runId += 1;
4392
+ setGitWorkflow({ step: "cancelled", busy: false, error: "", output: `${workflow.output || ""}${workflow.output ? "\n\n" : ""}Cancelled by user.` }, { tabId });
4393
+ if (shouldAbortPi && isCurrentTabContext(tabContext)) setRunIndicatorActivity("Abort requested; checking whether Pi stoppedโ€ฆ");
4128
4394
  await Promise.allSettled([
4129
- api("/api/git-workflow/cancel", { method: "POST", body: {} }),
4130
- shouldAbortPi ? api("/api/abort", { method: "POST", body: {} }) : Promise.resolve(),
4395
+ api("/api/git-workflow/cancel", { method: "POST", body: {}, tabId }),
4396
+ shouldAbortPi ? api("/api/abort", { method: "POST", body: {}, tabId }) : Promise.resolve(),
4131
4397
  ]);
4132
- if (shouldAbortPi) scheduleAbortStateChecks();
4398
+ if (shouldAbortPi && isCurrentTabContext(tabContext)) scheduleAbortStateChecks();
4133
4399
  }
4134
4400
 
4135
4401
  async function runGitAdd() {
4136
- const runId = gitWorkflow.runId;
4137
- setGitWorkflow({ step: "add", busy: true, error: "", output: "Running git add ." });
4402
+ const tabId = activeTabId;
4403
+ const tabContext = activeTabContext(tabId);
4404
+ const workflow = gitWorkflowForTab(tabId, { create: false });
4405
+ if (!workflow) return;
4406
+ const runId = workflow.runId;
4407
+ setGitWorkflow({ step: "add", busy: true, error: "", output: "Running git add ." }, { tabId });
4138
4408
  try {
4139
- const result = await gitWorkflowRequest("/api/git-workflow/add", { runId });
4409
+ const result = await gitWorkflowRequest("/api/git-workflow/add", { runId, tabId });
4140
4410
  if (!result) return;
4141
- setGitWorkflow({ step: "generate", busy: false, output: `${formatGitCommandResult(result)}\n\nStaged. Next: run /git-staged-msg.` });
4142
- scheduleRefreshFooter();
4411
+ setGitWorkflow({ step: "generate", busy: false, output: `${formatGitCommandResult(result)}\n\nStaged. Next: run /git-staged-msg.` }, { tabId });
4412
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
4143
4413
  } catch (error) {
4144
- if (isCurrentGitWorkflowRun(runId)) failGitWorkflow(error, "add");
4414
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "add", { tabId });
4145
4415
  }
4146
4416
  }
4147
4417
 
4148
4418
  async function runGitMessagePrompt() {
4419
+ const tabId = activeTabId;
4420
+ const tabContext = activeTabContext(tabId);
4149
4421
  if (currentState?.isStreaming) {
4150
- failGitWorkflow(new Error("Pi is currently running. Wait for it to finish or abort before generating a staged commit message."), "generate");
4422
+ failGitWorkflow(new Error("Pi is currently running. Wait for it to finish or abort before generating a staged commit message."), "generate", { tabId });
4151
4423
  return;
4152
4424
  }
4153
- const runId = gitWorkflow.runId;
4425
+ const workflow = gitWorkflowForTab(tabId, { create: false });
4426
+ if (!workflow) return;
4427
+ const runId = workflow.runId;
4154
4428
  const requestedAt = Date.now();
4155
4429
  setGitWorkflow({
4156
4430
  step: "generating",
@@ -4158,32 +4432,37 @@ async function runGitMessagePrompt() {
4158
4432
  error: "",
4159
4433
  messageRequestedAt: requestedAt,
4160
4434
  output: "Sending /git-staged-msg to Pi.\n\nCancel will request Pi abort.",
4161
- });
4162
- setRunIndicatorActivity("Sending /git-staged-msg to Piโ€ฆ");
4435
+ }, { tabId });
4436
+ if (isCurrentTabContext(tabContext)) setRunIndicatorActivity("Sending /git-staged-msg to Piโ€ฆ");
4163
4437
  try {
4164
- await api("/api/prompt", { method: "POST", body: { message: "/git-staged-msg" } });
4165
- if (!isCurrentGitWorkflowRun(runId)) return;
4166
- appendGitWorkflowOutput("/git-staged-msg accepted. Waiting for agent_end, then the message files will be loaded.");
4167
- scheduleRefreshState();
4438
+ await api("/api/prompt", { method: "POST", body: { message: "/git-staged-msg" }, tabId });
4439
+ if (!isCurrentGitWorkflowRun(runId, tabId)) return;
4440
+ appendGitWorkflowOutput("/git-staged-msg accepted. Waiting for agent_end, then the message files will be loaded.", { tabId });
4441
+ if (isCurrentTabContext(tabContext)) scheduleRefreshState(120, tabContext);
4168
4442
  setTimeout(() => {
4169
- if (isCurrentGitWorkflowRun(runId) && gitWorkflow.step === "generating" && !currentState?.isStreaming) {
4170
- loadGitWorkflowMessage({ requireFresh: true, retries: 1, runId });
4443
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
4444
+ if (isCurrentTabContext(tabContext) && isCurrentGitWorkflowRun(runId, tabId) && currentWorkflow?.step === "generating" && !currentState?.isStreaming) {
4445
+ loadGitWorkflowMessage({ requireFresh: true, retries: 1, runId, tabId });
4171
4446
  }
4172
4447
  }, 2500);
4173
4448
  } catch (error) {
4174
- if (isCurrentGitWorkflowRun(runId)) {
4175
- clearRunIndicatorActivity();
4176
- failGitWorkflow(error, "generate");
4449
+ if (isCurrentGitWorkflowRun(runId, tabId)) {
4450
+ if (isCurrentTabContext(tabContext)) clearRunIndicatorActivity();
4451
+ failGitWorkflow(error, "generate", { tabId });
4177
4452
  }
4178
4453
  }
4179
4454
  }
4180
4455
 
4181
- async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId = gitWorkflow.runId } = {}) {
4456
+ async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId, tabId = activeTabId } = {}) {
4457
+ const workflow = gitWorkflowForTab(tabId, { create: false });
4458
+ const expectedRunId = runId ?? workflow?.runId;
4182
4459
  try {
4183
- const message = await gitWorkflowRequest("/api/git-workflow/message", { method: "GET", runId });
4460
+ const message = await gitWorkflowRequest("/api/git-workflow/message", { method: "GET", runId: expectedRunId, tabId });
4184
4461
  if (!message) return;
4462
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
4463
+ if (!currentWorkflow) return;
4185
4464
  const newestMtime = Math.max(message.shortMtimeMs || 0, message.longMtimeMs || 0);
4186
- if (requireFresh && gitWorkflow.messageRequestedAt && newestMtime + 10000 < gitWorkflow.messageRequestedAt) {
4465
+ if (requireFresh && currentWorkflow.messageRequestedAt && newestMtime + 10000 < currentWorkflow.messageRequestedAt) {
4187
4466
  throw new Error("Generated message files have not refreshed yet.");
4188
4467
  }
4189
4468
  setGitWorkflow({
@@ -4192,40 +4471,63 @@ async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId
4192
4471
  error: "",
4193
4472
  message,
4194
4473
  output: formatCommitMessagePreview(message),
4195
- });
4474
+ }, { tabId });
4196
4475
  } catch (error) {
4197
- if (!isCurrentGitWorkflowRun(runId)) return;
4476
+ if (!isCurrentGitWorkflowRun(expectedRunId, tabId)) return;
4198
4477
  if (retries > 0) {
4199
- setTimeout(() => loadGitWorkflowMessage({ requireFresh, retries: retries - 1, runId }), 1400);
4478
+ setTimeout(() => loadGitWorkflowMessage({ requireFresh, retries: retries - 1, runId: expectedRunId, tabId }), 1400);
4200
4479
  return;
4201
4480
  }
4202
- failGitWorkflow(error, gitWorkflow.step === "generating" ? "generate" : gitWorkflow.step);
4481
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
4482
+ failGitWorkflow(error, currentWorkflow?.step === "generating" ? "generate" : currentWorkflow?.step, { tabId });
4203
4483
  }
4204
4484
  }
4205
4485
 
4206
4486
  async function commitGitWorkflow(variant) {
4207
- const runId = gitWorkflow.runId;
4208
- setGitWorkflow({ step: "committing", busy: true, error: "", output: `${formatCommitMessagePreview(gitWorkflow.message)}\n\nRunning native ${variant} commitโ€ฆ` });
4487
+ const tabId = activeTabId;
4488
+ const tabContext = activeTabContext(tabId);
4489
+ const workflow = gitWorkflowForTab(tabId, { create: false });
4490
+ if (!workflow) return;
4491
+ const runId = workflow.runId;
4492
+ setGitWorkflow({ step: "committing", busy: true, error: "", output: `${formatCommitMessagePreview(workflow.message)}\n\nRunning native ${variant} commitโ€ฆ` }, { tabId });
4209
4493
  try {
4210
- const result = await gitWorkflowRequest("/api/git-workflow/commit", { body: { variant }, runId });
4494
+ const result = await gitWorkflowRequest("/api/git-workflow/commit", { body: { variant }, runId, tabId });
4211
4495
  if (!result) return;
4212
- setGitWorkflow({ step: "push", busy: false, output: `${formatGitCommandResult(result)}\n\nCommit created. Next: git push.` });
4213
- scheduleRefreshFooter();
4496
+ setGitWorkflow({ step: "push", busy: false, output: `${formatGitCommandResult(result)}\n\nCommit created. Next: git push.` }, { tabId });
4497
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
4214
4498
  } catch (error) {
4215
- if (isCurrentGitWorkflowRun(runId)) failGitWorkflow(error, "message");
4499
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "message", { tabId });
4216
4500
  }
4217
4501
  }
4218
4502
 
4219
4503
  async function pushGitWorkflow() {
4220
- const runId = gitWorkflow.runId;
4221
- setGitWorkflow({ step: "pushing", busy: true, error: "", output: "Running git pushโ€ฆ" });
4504
+ const tabId = activeTabId;
4505
+ const tabContext = activeTabContext(tabId);
4506
+ const workflow = gitWorkflowForTab(tabId, { create: false });
4507
+ if (!workflow) return;
4508
+ const runId = workflow.runId;
4509
+ setGitWorkflow({ step: "pushing", busy: true, error: "", output: "Running git pushโ€ฆ" }, { tabId });
4222
4510
  try {
4223
- const result = await gitWorkflowRequest("/api/git-workflow/push", { runId });
4511
+ const result = await gitWorkflowRequest("/api/git-workflow/push", { runId, tabId });
4224
4512
  if (!result) return;
4225
- setGitWorkflow({ step: "done", busy: false, output: formatGitCommandResult(result) || "git push finished." });
4226
- scheduleRefreshFooter();
4513
+ setGitWorkflow({ step: "done", busy: false, output: formatGitCommandResult(result) || "git push finished." }, { tabId });
4514
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
4227
4515
  } catch (error) {
4228
- if (isCurrentGitWorkflowRun(runId)) failGitWorkflow(error, "push");
4516
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "push", { tabId });
4517
+ }
4518
+ }
4519
+
4520
+ function resumeGitWorkflowForActiveTab(tabContext = activeTabContext()) {
4521
+ if (!isCurrentTabContext(tabContext)) return;
4522
+ bindGitWorkflowToActiveTab();
4523
+ renderGitWorkflow();
4524
+ if (gitWorkflow.active && gitWorkflow.step === "generating" && !currentState?.isStreaming) {
4525
+ const retryDelayMs = Math.max(0, 2500 - (Date.now() - (gitWorkflow.messageRequestedAt || 0)));
4526
+ if (retryDelayMs > 0) {
4527
+ setTimeout(() => resumeGitWorkflowForActiveTab(tabContext), retryDelayMs);
4528
+ return;
4529
+ }
4530
+ loadGitWorkflowMessage({ requireFresh: true, retries: 3, runId: gitWorkflow.runId, tabId: tabContext.tabId });
4229
4531
  }
4230
4532
  }
4231
4533
 
@@ -5486,8 +5788,39 @@ function restoreToolDetailsOpenState(root, state) {
5486
5788
  }
5487
5789
  }
5488
5790
 
5791
+ function captureReusableToolCards() {
5792
+ const cards = new Map();
5793
+ for (const bubble of elements.chat.querySelectorAll(".message.toolExecution[data-tool-call-id]")) {
5794
+ const id = bubble.dataset.toolCallId;
5795
+ if (id) cards.set(id, bubble);
5796
+ }
5797
+ return cards;
5798
+ }
5799
+
5800
+ function reuseToolExecutionBubble(reusableToolCards, message, { streaming = false, messageIndex = -1, transient = false } = {}) {
5801
+ if (streaming || message?.role !== "toolExecution" || !message.toolCallId || !reusableToolCards) return null;
5802
+ const id = String(message.toolCallId);
5803
+ const bubble = reusableToolCards.get(id);
5804
+ if (!bubble) return null;
5805
+ reusableToolCards.delete(id);
5806
+ const body = bubble.querySelector(":scope > .message-body");
5807
+ if (!body || !updateLiveToolCard(bubble, message)) return null;
5808
+ bubble.classList.remove("action-enter", "streaming", "has-action-feedback");
5809
+ bubble.querySelector(":scope > .action-feedback-controls")?.remove();
5810
+ if (!transient && messageIndex >= 0) {
5811
+ bubble.dataset.messageIndex = String(messageIndex);
5812
+ bubble.removeAttribute("data-user-prompt");
5813
+ } else {
5814
+ bubble.removeAttribute("data-message-index");
5815
+ bubble.removeAttribute("data-user-prompt");
5816
+ }
5817
+ if (!streaming && !transient) renderActionFeedbackControls(bubble, message, messageIndex);
5818
+ elements.chat.append(bubble);
5819
+ return { bubble, body };
5820
+ }
5821
+
5489
5822
  function updateLiveToolCard(bubble, message) {
5490
- if (!bubble?.isConnected) return false;
5823
+ if (!bubble) return false;
5491
5824
  const header = bubble.querySelector(":scope > .message-header");
5492
5825
  const body = bubble.querySelector(":scope > .message-body");
5493
5826
  if (!body) return false;
@@ -5544,6 +5877,7 @@ function renderLiveToolRun(run, { scroll = true } = {}) {
5544
5877
  const existingConnected = !!(existing?.isConnected && existing.parentElement === elements.chat);
5545
5878
  const shouldFollow = scroll && (autoFollowChat || isChatNearBottom());
5546
5879
  const message = liveToolRunMessage(run);
5880
+ rememberActionEntries([{ message, messageIndex: -1, transient: true }]);
5547
5881
  if (existingConnected && updateLiveToolCard(existing, message)) {
5548
5882
  renderRunIndicator({ scroll: false });
5549
5883
  if (shouldFollow) scrollChatToBottom();
@@ -5616,7 +5950,9 @@ function jumpToStickyUserPrompt() {
5616
5950
  requestAnimationFrame(updateStickyUserPromptButton);
5617
5951
  }
5618
5952
 
5619
- function appendMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false } = {}) {
5953
+ function appendMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false, reusableToolCards = null } = {}) {
5954
+ const reused = reuseToolExecutionBubble(reusableToolCards, message, { streaming, messageIndex, transient });
5955
+ if (reused) return reused;
5620
5956
  const role = String(message.role || "message");
5621
5957
  const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
5622
5958
  const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}${animateEntry ? " action-enter" : ""}`);
@@ -5674,9 +6010,9 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
5674
6010
  return { bubble, body };
5675
6011
  }
5676
6012
 
5677
- function appendTranscriptMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false } = {}) {
6013
+ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false, reusableToolCards = null } = {}) {
5678
6014
  if (streaming || transient || message?.role !== "assistant") {
5679
- return appendMessage(message, { streaming, messageIndex, transient, animateEntry });
6015
+ return appendMessage(message, { streaming, messageIndex, transient, animateEntry, reusableToolCards });
5680
6016
  }
5681
6017
 
5682
6018
  let finalOutput = null;
@@ -5705,6 +6041,7 @@ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1
5705
6041
  messageIndex: ["assistant", "toolExecution"].includes(transcriptMessage.role) ? messageIndex : -1,
5706
6042
  transient: false,
5707
6043
  animateEntry: animateEntry && isActionTranscriptMessage(transcriptMessage),
6044
+ reusableToolCards,
5708
6045
  });
5709
6046
  if (transcriptMessage.role === "assistant") finalOutput = created;
5710
6047
  });
@@ -5923,16 +6260,17 @@ function actionEntrySeenKeys(tabId = activeTabId) {
5923
6260
 
5924
6261
  function actionEntryKey(item) {
5925
6262
  const message = item?.message || {};
6263
+ const keyedToolExecution = message.role === "toolExecution" && message.toolCallId;
5926
6264
  return [
5927
- item?.transient ? "transient" : "message",
5928
- item?.messageIndex ?? -1,
6265
+ keyedToolExecution ? "toolExecution" : item?.transient ? "transient" : "message",
6266
+ keyedToolExecution ? "" : (item?.messageIndex ?? -1),
5929
6267
  message.role || "message",
5930
6268
  message.toolName || "",
5931
6269
  message.toolCallId || "",
5932
- message.command || "",
5933
- message.title || "",
5934
- message.timestamp || "",
5935
- textFromContent(message.content).slice(0, 240),
6270
+ keyedToolExecution ? "" : message.command || "",
6271
+ keyedToolExecution ? "" : message.title || "",
6272
+ keyedToolExecution ? "" : message.timestamp || "",
6273
+ keyedToolExecution ? "" : textFromContent(message.content).slice(0, 240),
5936
6274
  ].join("|");
5937
6275
  }
5938
6276
 
@@ -5974,6 +6312,7 @@ function orderedTranscriptItems() {
5974
6312
  function renderAllMessages({ preserveScroll = false } = {}) {
5975
6313
  const shouldFollow = !preserveScroll && (autoFollowChat || isChatNearBottom());
5976
6314
  const previousScrollTop = elements.chat.scrollTop;
6315
+ const reusableToolCards = captureReusableToolCards();
5977
6316
  resetChatOutput();
5978
6317
  const transcriptItems = orderedTranscriptItems();
5979
6318
  for (const item of transcriptItems) {
@@ -5981,6 +6320,7 @@ function renderAllMessages({ preserveScroll = false } = {}) {
5981
6320
  messageIndex: item.messageIndex,
5982
6321
  transient: item.transient,
5983
6322
  animateEntry: shouldAnimateActionEntry(item),
6323
+ reusableToolCards,
5984
6324
  });
5985
6325
  }
5986
6326
  rememberActionEntries(transcriptItems);
@@ -7589,6 +7929,7 @@ async function refreshAll(tabContext = activeTabContext()) {
7589
7929
  for (const result of results) {
7590
7930
  if (result.status === "rejected") addEvent(result.reason.message || String(result.reason), "error");
7591
7931
  }
7932
+ resumeGitWorkflowForActiveTab(tabContext);
7592
7933
  }
7593
7934
 
7594
7935
  async function openToNetwork() {
@@ -8025,9 +8366,14 @@ function handleEvent(event) {
8025
8366
  scheduleRefreshState();
8026
8367
  scheduleRefreshMessages();
8027
8368
  scheduleRefreshFooter();
8369
+ scheduleRefreshCodexUsage(2200);
8028
8370
  renderFeedbackTray();
8029
- if (gitWorkflow.active && gitWorkflow.step === "generating") {
8030
- loadGitWorkflowMessage({ requireFresh: true, retries: 3 });
8371
+ {
8372
+ const workflowTabId = event.tabId || activeTabId;
8373
+ const workflow = gitWorkflowForTab(workflowTabId, { create: false });
8374
+ if (workflow?.active && workflow.step === "generating") {
8375
+ loadGitWorkflowMessage({ requireFresh: true, retries: 3, runId: workflow.runId, tabId: workflowTabId });
8376
+ }
8031
8377
  }
8032
8378
  break;
8033
8379
  case "message_start":
@@ -8114,7 +8460,11 @@ function handleEvent(event) {
8114
8460
  syncRunIndicatorFromState(currentState);
8115
8461
  renderStatus();
8116
8462
  } else if (["set_model", "set_thinking_level", "new_session", "compact"].includes(event.command)) {
8117
- if (event.command === "new_session") forgetLastUserPrompt(event.tabId || activeTabId);
8463
+ if (event.command === "new_session") {
8464
+ const tabId = event.tabId || activeTabId;
8465
+ forgetLastUserPrompt(tabId);
8466
+ resetGitWorkflowForTab(tabId);
8467
+ }
8118
8468
  scheduleRefreshState();
8119
8469
  scheduleRefreshMessages();
8120
8470
  scheduleRefreshFooter();
@@ -8252,6 +8602,7 @@ elements.newSessionButton.addEventListener("click", async () => {
8252
8602
  const response = await api("/api/new-session", { method: "POST", body: {}, tabId: tabContext.tabId });
8253
8603
  applyResponseTab(response);
8254
8604
  forgetLastUserPrompt(tabContext.tabId);
8605
+ resetGitWorkflowForTab(tabContext.tabId);
8255
8606
  if (!isCurrentTabContext(tabContext)) return;
8256
8607
  await refreshAll(tabContext);
8257
8608
  if (isCurrentTabContext(tabContext)) focusPromptInput({ defer: true });
@@ -8411,6 +8762,9 @@ window.addEventListener("keydown", (event) => {
8411
8762
  }
8412
8763
  });
8413
8764
 
8765
+ elements.refreshCodexUsageButton?.addEventListener("click", () => {
8766
+ refreshCodexUsage({ forceAuthRefresh: true }).finally(() => scheduleRefreshCodexUsage());
8767
+ });
8414
8768
  elements.pathPickerAddFastPickButton.addEventListener("click", () => addCurrentFastPick().catch((error) => addEvent(error.message, "error")));
8415
8769
  elements.pathPickerCancelButton.addEventListener("click", () => closePathPicker(null));
8416
8770
  elements.pathPickerChooseButton.addEventListener("click", () => closePathPicker(pathPickerState?.cwd || null));
@@ -8507,6 +8861,7 @@ restoreThinkingVisibilitySetting();
8507
8861
  restoreSidePanelSectionState();
8508
8862
  bindSidePanelSectionToggles();
8509
8863
  restoreSidePanelState();
8864
+ initializeCodexUsage();
8510
8865
  bindMobileViewChanges();
8511
8866
  registerPwaServiceWorker();
8512
8867
  renderServerOfflinePanel();