@firstpick/pi-package-webui 0.3.9 → 0.4.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 CHANGED
@@ -28,6 +28,12 @@ const elements = {
28
28
  widgetArea: $("#widgetArea"),
29
29
  stickyUserPromptButton: $("#stickyUserPromptButton"),
30
30
  chat: $("#chat"),
31
+ chatSearchBar: $("#chatSearchBar"),
32
+ chatSearchInput: $("#chatSearchInput"),
33
+ chatSearchCount: $("#chatSearchCount"),
34
+ chatSearchPrevButton: $("#chatSearchPrevButton"),
35
+ chatSearchNextButton: $("#chatSearchNextButton"),
36
+ chatSearchCloseButton: $("#chatSearchCloseButton"),
31
37
  feedbackTray: $("#feedbackTray"),
32
38
  feedbackTraySummary: $("#feedbackTraySummary"),
33
39
  sendFeedbackButton: $("#sendFeedbackButton"),
@@ -80,7 +86,9 @@ const elements = {
80
86
  optionsExportButton: $("#optionsExportButton"),
81
87
  optionsForkButton: $("#optionsForkButton"),
82
88
  optionsTreeButton: $("#optionsTreeButton"),
89
+ optionsStatsButton: $("#optionsStatsButton"),
83
90
  gitWorkflowPanel: $("#gitWorkflowPanel"),
91
+ gitWorkflowKicker: $("#gitWorkflowKicker"),
84
92
  gitWorkflowTitle: $("#gitWorkflowTitle"),
85
93
  gitWorkflowHint: $("#gitWorkflowHint"),
86
94
  gitWorkflowSteps: $("#gitWorkflowSteps"),
@@ -188,6 +196,15 @@ const elements = {
188
196
  appRunnerInfoDialog: $("#appRunnerInfoDialog"),
189
197
  appRunnerInfoBody: $("#appRunnerInfoBody"),
190
198
  appRunnerInfoCloseButton: $("#appRunnerInfoCloseButton"),
199
+ statsOverlayDialog: $("#statsOverlayDialog"),
200
+ statsOverlaySubtitle: $("#statsOverlaySubtitle"),
201
+ statsOverlayScope: $("#statsOverlayScope"),
202
+ statsOverlayCustomDays: $("#statsOverlayCustomDays"),
203
+ statsOverlayRefreshButton: $("#statsOverlayRefreshButton"),
204
+ statsOverlayStatus: $("#statsOverlayStatus"),
205
+ statsOverlayTabs: $("#statsOverlayTabs"),
206
+ statsOverlayBody: $("#statsOverlayBody"),
207
+ statsOverlayCloseButton: $("#statsOverlayCloseButton"),
191
208
  };
192
209
 
193
210
  let currentState = null;
@@ -257,6 +274,13 @@ let pathSuggestActiveQuery = null;
257
274
  let pathSuggestRequestSerial = 0;
258
275
  let pathSuggestAbortController = null;
259
276
  let latestStats = null;
277
+ let statsOverlayActiveTab = "overview";
278
+ let statsOverlayLoading = false;
279
+ let statsOverlayError = "";
280
+ let statsOverlayLastScope = "14";
281
+ let statsOverlayCalibrationMessage = "";
282
+ let statsOverlayCalibrationBusy = "";
283
+ let latestStatsOverlayPayload = null;
260
284
  let latestWorkspace = null;
261
285
  let latestNetwork = null;
262
286
  let webuiVersion = "";
@@ -356,6 +380,11 @@ const GIT_FOOTER_WEBUI_STATUS_KEY = "git-footer-webui";
356
380
  const GIT_FOOTER_WEBUI_PAYLOAD_TYPE = "firstpick.git-footer-status.footer";
357
381
  const GIT_FOOTER_WEBUI_PAYLOAD_VERSION = 1;
358
382
  const GIT_FOOTER_WEBUI_PAYLOAD_CACHE_KEY = "pi-webui-git-footer-webui-payload-cache";
383
+ const GIT_FOOTER_STATUS_SETUP_STORAGE_KEY = "pi-webui-git-footer-status-setup";
384
+ const GIT_INIT_STACK_STORAGE_KEY = "pi-webui-git-init-stack";
385
+ const STATS_WEBUI_STATUS_KEY = "stats-webui";
386
+ const STATS_WEBUI_PAYLOAD_TYPE = "firstpick.pi-extension-stats.overlay";
387
+ const STATS_WEBUI_PAYLOAD_VERSION = 1;
359
388
  const GIT_CHANGES_RENDER_ROW_LIMIT = 4000;
360
389
  const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
361
390
  const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history";
@@ -493,10 +522,10 @@ const OPTIONAL_FEATURES = [
493
522
  },
494
523
  {
495
524
  id: "statsCommand",
496
- label: "Stats command",
525
+ label: "Stats dashboard",
497
526
  packageName: "@firstpick/pi-extension-stats",
498
527
  capabilityLabel: "/stats",
499
- description: "Token and cost usage analytics commands.",
528
+ description: "Token and cost usage analytics commands plus the browser dashboard overlay.",
500
529
  },
501
530
  {
502
531
  id: "themeBundle",
@@ -521,6 +550,7 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
521
550
  ["todo-progress-status", "todoProgressWidget"],
522
551
  ]);
523
552
  const HIDDEN_COMMAND_NAMES = new Set(["webui-tree-navigate", "webui-helper"]);
553
+ HIDDEN_COMMAND_NAMES.add("stats-webui");
524
554
  const NATIVE_SELECTOR_COMMANDS = new Set(["model", "settings", "theme", "fork", "clone", "name", "resume", "tree", "login", "logout", "scoped-models", "tools", "skills"]);
525
555
  const SETTINGS_THINKING_OPTIONS = ["off", "minimal", "low", "medium", "high", "xhigh"];
526
556
  const SETTINGS_TRANSPORT_OPTIONS = ["sse", "websocket", "websocket-cached", "auto"];
@@ -545,9 +575,15 @@ const gitFooterPayloadRefreshInFlightByTab = new Set();
545
575
 
546
576
  function createGitWorkflowActionsDone(patch = {}) {
547
577
  return {
578
+ init: false,
579
+ stack: false,
580
+ readme: false,
581
+ gitignore: false,
548
582
  stage: false,
549
583
  message: false,
550
584
  commit: false,
585
+ branch: false,
586
+ remote: false,
551
587
  push: false,
552
588
  ...patch,
553
589
  };
@@ -564,13 +600,22 @@ function gitWorkflowActionDonePatch(workflow, process) {
564
600
  function createGitWorkflowState() {
565
601
  return {
566
602
  active: false,
603
+ mode: "standard",
567
604
  step: "idle",
568
605
  process: "stage",
569
606
  busy: false,
570
607
  runId: 0,
571
608
  output: "",
572
609
  error: "",
610
+ githubUsername: "",
611
+ repoName: "",
612
+ remoteUrl: "",
613
+ stack: "",
614
+ readmeRequestedAt: 0,
615
+ gitignoreRequestedAt: 0,
616
+ initFilesStatus: null,
573
617
  message: null,
618
+ manualCommitMessage: "",
574
619
  messageRequestedAt: 0,
575
620
  branchName: "",
576
621
  branchNameRequestedAt: 0,
@@ -629,6 +674,16 @@ const GIT_WORKFLOW_PROCESSES = [
629
674
  { value: "push", label: "Push" },
630
675
  ];
631
676
  const GIT_WORKFLOW_PROCESS_VALUES = new Set(GIT_WORKFLOW_PROCESSES.map((process) => process.value));
677
+ const GIT_INIT_WORKFLOW_PROCESSES = [
678
+ { value: "init", label: "Init" },
679
+ { value: "stack", label: "Stack" },
680
+ { value: "readme", label: "Files" },
681
+ { value: "commit", label: "Commit" },
682
+ { value: "branch", label: "Main" },
683
+ { value: "remote", label: "Remote" },
684
+ { value: "push", label: "Push" },
685
+ ];
686
+ const GIT_INIT_WORKFLOW_PROCESS_VALUES = new Set(GIT_INIT_WORKFLOW_PROCESSES.map((process) => process.value));
632
687
  const ACTION_FEEDBACK_REACTIONS = {
633
688
  up: { icon: "👍", label: "Good job", title: "Good job!" },
634
689
  down: { icon: "👎", label: "Avoid this", title: "Avoid this" },
@@ -650,13 +705,32 @@ const GIT_WORKFLOW_ACTIVE_INDEX = {
650
705
  prCreating: 3,
651
706
  done: 4,
652
707
  };
708
+ const GIT_INIT_WORKFLOW_ACTIVE_INDEX = {
709
+ initSetup: 0,
710
+ initRepo: 0,
711
+ initializingRepo: 0,
712
+ initStack: 1,
713
+ readme: 2,
714
+ readmeCreating: 2,
715
+ readmeGenerating: 2,
716
+ gitignoreGenerating: 2,
717
+ initialCommit: 3,
718
+ initialCommitting: 3,
719
+ mainBranch: 4,
720
+ mainBranching: 4,
721
+ remote: 5,
722
+ remoteAdding: 5,
723
+ initialPush: 6,
724
+ initialPushing: 6,
725
+ done: 7,
726
+ };
653
727
  const GIT_WORKFLOW_CREATE_PR_TOOLTIP = [
654
728
  "Create PR branch:",
655
729
  "1. Ask Pi to generate a type/feature-name branch from staged changes.",
656
730
  "2. Read dev/COMMIT/staged-branch-name.txt.",
657
731
  "3. Let you confirm or edit the generated branch name.",
658
732
  "4. Run git switch -c <branch>.",
659
- "5. Return here to commit short or long on that branch.",
733
+ "5. Return here to commit short, long, or typed input on that branch.",
660
734
  "6. Push and Create PR will push upstream, run /pr, let you review, then run gh pr create.",
661
735
  ].join("\n");
662
736
  const GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP = [
@@ -665,9 +739,40 @@ const GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP = [
665
739
  "2. Prefill a branch from the commit message if possible.",
666
740
  "3. Let you type or edit the type/feature-name branch name.",
667
741
  "4. Run git switch -c <branch>.",
668
- "5. Return here to commit short or long on that branch.",
742
+ "5. Return here to commit short, long, or typed input on that branch.",
669
743
  "6. Push and Create PR will push upstream, run /pr, let you review, then run gh pr create.",
670
744
  ].join("\n");
745
+ const GIT_FOOTER_STATUS_SETUP_TOOLTIP = [
746
+ "git-footer-status-setup:",
747
+ "Store the GitHub username used when the Web UI initializes a no-repo directory.",
748
+ "The remote URL is https://github.com/USERNAME/REPO_NAME.git.",
749
+ "The repository name is asked per initialization and defaults to the current folder name.",
750
+ ].join("\n");
751
+ const GIT_INIT_REMOTE_TOOLTIP = [
752
+ "Add origin remote:",
753
+ "1. Confirm the GitHub username from git-footer-status-setup.",
754
+ "2. Ask for the repository name if needed.",
755
+ "3. Run git remote add origin https://github.com/USERNAME/REPO_NAME.git.",
756
+ ].join("\n");
757
+ const GIT_INIT_STACK_OPTIONS = [
758
+ { value: "", label: "Auto-detect from codebase" },
759
+ { value: "Node.js / TypeScript", label: "Node.js / TypeScript" },
760
+ { value: "React / Vite", label: "React / Vite" },
761
+ { value: "Next.js", label: "Next.js" },
762
+ { value: "Python", label: "Python" },
763
+ { value: "Django", label: "Django" },
764
+ { value: "FastAPI", label: "FastAPI" },
765
+ { value: "Rust", label: "Rust" },
766
+ { value: "Go", label: "Go" },
767
+ { value: "Java / Gradle", label: "Java / Gradle" },
768
+ { value: "Docker", label: "Docker" },
769
+ { value: "Custom", label: "Custom…" },
770
+ ];
771
+ const GIT_INIT_STACK_TOOLTIP = [
772
+ "Repository stack:",
773
+ "Choose a known stack or type one. The value is saved in this browser.",
774
+ "If left blank, Pi will inspect the codebase and fall back to sane default .gitignore patterns.",
775
+ ].join("\n");
671
776
 
672
777
  function make(tag, className, text) {
673
778
  const node = document.createElement(tag);
@@ -2072,12 +2177,12 @@ function renderUpdateNotification(status = latestUpdateStatus, { force = false }
2072
2177
  if (elements.updateNotificationTitle) elements.updateNotificationTitle.textContent = items.length === 1 ? `${items[0]} available` : "Pi updates available";
2073
2178
  if (elements.updateNotificationMessage) {
2074
2179
  elements.updateNotificationMessage.textContent = canRunUpdate
2075
- ? "Run pi update now, then restart this Web UI server automatically."
2180
+ ? "Run Pi and Web UI package updates now, then restart this Web UI server automatically."
2076
2181
  : "Updates are available. Direct Web UI updates are only enabled from localhost on the host machine.";
2077
2182
  }
2078
2183
  const details = [
2079
2184
  items.join(" · "),
2080
- latestUpdateStatus.webuiDev && latestUpdateStatus.webui?.updateAvailable ? "The current Web UI is a dev checkout; pi update updates installed Pi packages, not this checkout." : "",
2185
+ latestUpdateStatus.webuiDev && latestUpdateStatus.webui?.updateAvailable ? "The current Web UI is a dev checkout; update also refreshes this checkout's Web UI/Pi package dependencies when possible." : "",
2081
2186
  latestUpdateStatus.packages?.note || "",
2082
2187
  ].filter(Boolean).join(" ");
2083
2188
  if (elements.updateNotificationDetail) elements.updateNotificationDetail.textContent = details;
@@ -2103,14 +2208,14 @@ function scheduleUpdateStatusRefresh() {
2103
2208
  clearTimeout(updateStatusRefreshTimer);
2104
2209
  updateStatusRefreshTimer = setTimeout(() => {
2105
2210
  updateStatusRefreshTimer = null;
2106
- refreshUpdateStatus({ force: true }).catch((error) => addEvent(`Pi update check failed: ${error.message || String(error)}`, "warn"));
2211
+ refreshUpdateStatus({ force: true }).catch((error) => addEvent(`Pi/Web UI update check failed: ${error.message || String(error)}`, "warn"));
2107
2212
  scheduleUpdateStatusRefresh();
2108
2213
  }, UPDATE_STATUS_REFRESH_MS);
2109
2214
  }
2110
2215
 
2111
2216
  function initializeUpdateNotifications() {
2112
2217
  setTimeout(() => {
2113
- refreshUpdateStatus().catch((error) => addEvent(`Pi update check failed: ${error.message || String(error)}`, "warn"));
2218
+ refreshUpdateStatus().catch((error) => addEvent(`Pi/Web UI update check failed: ${error.message || String(error)}`, "warn"));
2114
2219
  scheduleUpdateStatusRefresh();
2115
2220
  }, UPDATE_STATUS_INITIAL_DELAY_MS);
2116
2221
  }
@@ -2119,13 +2224,13 @@ function piUpdateConfirmationText() {
2119
2224
  const items = updateNotificationItems();
2120
2225
  const workingWarning = hasWorkingTab() ? "\n\nOne or more Pi tabs look busy or blocked. Finish or abort in-flight work before updating if you need to preserve it." : "";
2121
2226
  const versionText = items.length ? `\n\nDetected update: ${items.join(" · ")}.` : "";
2122
- return `Run pi update now?${versionText}\n\nThis will run \"pi update\" on the Web UI host. After it finishes, Pi Web UI will restart itself. Browser clients will briefly disconnect, and managed Pi tabs/RPC processes will be restarted from saved session state when possible.${workingWarning}`;
2227
+ return `Run Pi/Web UI package updates now?${versionText}\n\nThis will run \"pi update\" plus detected local and global Web UI/Pi package-manager updates on the Web UI host. After it finishes, Pi Web UI will restart itself. Browser clients will briefly disconnect, and managed Pi tabs/RPC processes will be restarted from saved session state when possible.${workingWarning}`;
2123
2228
  }
2124
2229
 
2125
2230
  async function runPiUpdateAndRestart() {
2126
2231
  if (updateRequestInProgress) return;
2127
2232
  if (latestUpdateStatus?.canRunUpdate === false) {
2128
- addEvent("Pi update can only be started from localhost on the Web UI host", "warn");
2233
+ addEvent("Pi/Web UI package updates can only be started from localhost on the Web UI host", "warn");
2129
2234
  renderUpdateNotification(latestUpdateStatus, { force: true });
2130
2235
  return;
2131
2236
  }
@@ -2134,11 +2239,11 @@ async function runPiUpdateAndRestart() {
2134
2239
  updateRequestInProgress = true;
2135
2240
  hideUpdateNotification();
2136
2241
  setServerActionBusy("Updating…");
2137
- setServerActionStatus("Running pi update. The server will restart after the update completes…", "warn");
2138
- setServerRestartOverlay(true, "Running pi update. The server will restart after the update completes…");
2242
+ setServerActionStatus("Running Pi/Web UI package updates. The server will restart after the update completes…", "warn");
2243
+ setServerRestartOverlay(true, "Running Pi/Web UI package updates. The server will restart after the update completes…");
2139
2244
  try {
2140
2245
  await api("/api/update", { method: "POST", scoped: false });
2141
- addEvent("Pi update completed; Pi Web UI server restart requested", "warn");
2246
+ addEvent("Pi/Web UI package updates completed; Pi Web UI server restart requested", "warn");
2142
2247
  } catch (error) {
2143
2248
  if (!error?.backendOffline) {
2144
2249
  updateRequestInProgress = false;
@@ -3034,7 +3139,7 @@ function renderOptionalFeatureDependentDisplays() {
3034
3139
  streamBubble = null;
3035
3140
  streamText = null;
3036
3141
  streamBubbleVisibleSince = 0;
3037
- renderAllMessages({ preserveScroll: true });
3142
+ renderAllMessages({ preserveScroll: true, forceRebuild: true });
3038
3143
  if (streamRawText) renderStreamingAssistantText();
3039
3144
  }
3040
3145
 
@@ -3661,6 +3766,7 @@ function resetActiveTabUi() {
3661
3766
  eventSource = null;
3662
3767
  currentState = null;
3663
3768
  latestStats = null;
3769
+ latestStatsOverlayPayload = null;
3664
3770
  latestWorkspace = null;
3665
3771
  latestMessages = [];
3666
3772
  clearRunIndicatorActivity({ render: false });
@@ -5239,7 +5345,10 @@ function renderGitFooterPayloadMeta(chip, tab) {
5239
5345
  if (chip.key === "cwd" && tab) {
5240
5346
  options.onClick = changeActiveTabCwd;
5241
5347
  action = `Click to change the working directory for ${tab.title}.`;
5242
- } else if (chip.key === "git" && chip.value !== "no repo") {
5348
+ } else if (chip.key === "git" && cleanFooterPayloadText(chip.value, "").toLowerCase() === "no repo") {
5349
+ options.onClick = () => startGitInitWorkflow();
5350
+ action = "No Git repository detected. Click to initialize a repo, create README.md, add origin, and push main.";
5351
+ } else if (chip.key === "git") {
5243
5352
  options.onClick = () => setFooterBranchPickerOpen(!footerBranchPickerOpen);
5244
5353
  action = "Click to switch to another local branch.";
5245
5354
  } else if (chip.key === "changes") {
@@ -5257,7 +5366,7 @@ function renderGitFooterPayloadMeta(chip, tab) {
5257
5366
  options.tooltipAlign = gitFooterTooltipAlign(chip);
5258
5367
  const node = footerMeta(chip.label, chip.value, footerMetaClassForPayload(chip), options);
5259
5368
  applyFooterChangedFilesDropdown(node, chip);
5260
- if (chip.key === "git" && options.onClick) {
5369
+ if (chip.key === "git" && options.onClick && cleanFooterPayloadText(chip.value, "").toLowerCase() !== "no repo") {
5261
5370
  node.setAttribute("aria-haspopup", "listbox");
5262
5371
  node.setAttribute("aria-expanded", footerBranchPickerOpen ? "true" : "false");
5263
5372
  }
@@ -7726,6 +7835,472 @@ function renderAppRunnerWidget() {
7726
7835
  return node;
7727
7836
  }
7728
7837
 
7838
+ const STATS_OVERLAY_TABS = [
7839
+ { id: "overview", label: "Overview" },
7840
+ { id: "daily", label: "Daily" },
7841
+ { id: "models", label: "Models" },
7842
+ { id: "sessions", label: "Sessions" },
7843
+ { id: "cost-cache", label: "Cost & cache" },
7844
+ { id: "prompt", label: "Prompt/context" },
7845
+ { id: "raw", label: "Command outputs" },
7846
+ ];
7847
+
7848
+ function statsNumber(value, fallback = 0) {
7849
+ const number = Number(value);
7850
+ return Number.isFinite(number) ? number : fallback;
7851
+ }
7852
+
7853
+ function formatStatsTokens(value) {
7854
+ const number = statsNumber(value);
7855
+ const abs = Math.abs(number);
7856
+ const sign = number < 0 ? "-" : "";
7857
+ if (abs >= 1_000_000_000) return `${sign}${(abs / 1_000_000_000).toFixed(2)}B`;
7858
+ if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(2)}M`;
7859
+ if (abs >= 10_000) return `${sign}${Math.round(abs / 1000)}k`;
7860
+ if (abs >= 1000) return `${sign}${(abs / 1000).toFixed(1)}k`;
7861
+ return `${number.toLocaleString()}`;
7862
+ }
7863
+
7864
+ function formatStatsCost(value) {
7865
+ const cost = statsNumber(value);
7866
+ if (cost <= 0) return "$0.000";
7867
+ if (cost < 0.01) return `$${cost.toFixed(4)}`;
7868
+ if (cost < 10) return `$${cost.toFixed(3)}`;
7869
+ return `$${cost.toFixed(2)}`;
7870
+ }
7871
+
7872
+ function formatStatsPercent(value) {
7873
+ return `${statsNumber(value).toFixed(1)}%`;
7874
+ }
7875
+
7876
+ function parseStatsWebuiPayloadRaw(raw) {
7877
+ if (!raw) return null;
7878
+ try {
7879
+ const parsed = JSON.parse(raw);
7880
+ if (!parsed || parsed.type !== STATS_WEBUI_PAYLOAD_TYPE || parsed.version !== STATS_WEBUI_PAYLOAD_VERSION) return null;
7881
+ return parsed;
7882
+ } catch {
7883
+ return null;
7884
+ }
7885
+ }
7886
+
7887
+ function currentStatsOverlayPayload() {
7888
+ if (isOptionalFeatureDisabled("statsCommand")) return null;
7889
+ return parseStatsWebuiPayloadRaw(statusEntries.get(STATS_WEBUI_STATUS_KEY)) || latestStatsOverlayPayload;
7890
+ }
7891
+
7892
+ function statsScopeDaysFromPayload(payload) {
7893
+ return payload?.scope?.mode === "range" && statsNumber(payload.scope.days) > 0 ? String(payload.scope.days) : "";
7894
+ }
7895
+
7896
+ function statsScopeValueFromPayload(payload) {
7897
+ if (payload?.scope?.mode === "all") return "all";
7898
+ const days = statsScopeDaysFromPayload(payload);
7899
+ if (!days) return statsOverlayLastScope;
7900
+ return ["14", "30", "90"].includes(days) ? days : "custom";
7901
+ }
7902
+
7903
+ function statsCustomDaysValue() {
7904
+ const fromInput = Number.parseInt(elements.statsOverlayCustomDays?.value || "", 10);
7905
+ if (Number.isFinite(fromInput) && fromInput > 0) return Math.max(1, Math.min(3650, fromInput));
7906
+ const fromPayload = Number.parseInt(statsScopeDaysFromPayload(currentStatsOverlayPayload()), 10);
7907
+ if (Number.isFinite(fromPayload) && fromPayload > 0) return Math.max(1, Math.min(3650, fromPayload));
7908
+ return 14;
7909
+ }
7910
+
7911
+ function statsScopeCommandArg() {
7912
+ const value = elements.statsOverlayScope?.value || statsOverlayLastScope || "14";
7913
+ if (value === "all") return "all";
7914
+ const days = value === "custom" ? statsCustomDaysValue() : Math.max(1, Math.min(3650, Number.parseInt(value, 10) || 14));
7915
+ return String(days);
7916
+ }
7917
+
7918
+ function syncStatsScopeControls(payload = currentStatsOverlayPayload()) {
7919
+ if (!elements.statsOverlayScope) return;
7920
+ const nextValue = payload ? statsScopeValueFromPayload(payload) : statsOverlayLastScope;
7921
+ elements.statsOverlayScope.value = ["14", "30", "90", "all", "custom"].includes(nextValue) ? nextValue : "custom";
7922
+ const custom = elements.statsOverlayScope.value === "custom";
7923
+ if (elements.statsOverlayCustomDays) {
7924
+ const payloadDays = statsScopeDaysFromPayload(payload);
7925
+ if (payloadDays && !["14", "30", "90"].includes(payloadDays)) elements.statsOverlayCustomDays.value = payloadDays;
7926
+ else if (!elements.statsOverlayCustomDays.value) elements.statsOverlayCustomDays.value = "14";
7927
+ elements.statsOverlayCustomDays.hidden = !custom;
7928
+ }
7929
+ }
7930
+
7931
+ function statsPromptEstimateSourceLabel(estimate = {}) {
7932
+ if (estimate.source === "export-html") return "export-backed";
7933
+ if (estimate.source === "fallback") return "live fallback";
7934
+ return estimate.source || "estimate";
7935
+ }
7936
+
7937
+ function statsMetricCard(label, value, detail = "", tone = "") {
7938
+ const node = make("div", `stats-overlay-card ${tone}`.trim());
7939
+ node.append(make("span", "stats-overlay-card-label", label), make("strong", undefined, value));
7940
+ if (detail) node.append(make("span", "stats-overlay-card-detail", detail));
7941
+ return node;
7942
+ }
7943
+
7944
+ function statsLineBlock(lines = []) {
7945
+ const pre = make("pre", "stats-overlay-lines");
7946
+ pre.textContent = (Array.isArray(lines) ? lines : []).map(stripAnsi).join("\n") || "No data.";
7947
+ return pre;
7948
+ }
7949
+
7950
+ function renderStatsTable(headers, rows, emptyText = "No data.") {
7951
+ if (!rows.length) return make("p", "stats-overlay-empty muted", emptyText);
7952
+ const wrapper = make("div", "stats-overlay-table-wrap");
7953
+ const table = make("table", "stats-overlay-table");
7954
+ const thead = make("thead");
7955
+ const headRow = make("tr");
7956
+ for (const header of headers) headRow.append(make("th", undefined, header));
7957
+ thead.append(headRow);
7958
+ const tbody = make("tbody");
7959
+ for (const row of rows) {
7960
+ const tr = make("tr");
7961
+ for (const cell of row) tr.append(make("td", undefined, cell));
7962
+ tbody.append(tr);
7963
+ }
7964
+ table.append(thead, tbody);
7965
+ wrapper.append(table);
7966
+ return wrapper;
7967
+ }
7968
+
7969
+ function renderStatsBarRows(daily = []) {
7970
+ const rows = daily.filter((row) => statsNumber(row.total) > 0 || statsNumber(row.cost) > 0);
7971
+ if (!rows.length) return make("p", "stats-overlay-empty muted", "No non-zero usage in this range.");
7972
+ const maxTokens = Math.max(1, ...rows.map((row) => statsNumber(row.total)));
7973
+ const list = make("div", "stats-overlay-bars");
7974
+ for (const row of rows) {
7975
+ const tokenRatio = Math.max(0.015, statsNumber(row.total) / maxTokens);
7976
+ const item = make("div", "stats-overlay-bar-row");
7977
+ const bar = make("span", "stats-overlay-bar");
7978
+ const fill = make("span", "stats-overlay-bar-fill");
7979
+ fill.style.width = `${Math.min(100, tokenRatio * 100)}%`;
7980
+ bar.append(fill);
7981
+ item.append(
7982
+ make("span", "stats-overlay-bar-day", row.day || "—"),
7983
+ bar,
7984
+ make("span", "stats-overlay-bar-value", `${formatStatsTokens(row.total)} tok`),
7985
+ make("span", "stats-overlay-bar-cost", formatStatsCost(row.cost)),
7986
+ );
7987
+ list.append(item);
7988
+ }
7989
+ return list;
7990
+ }
7991
+
7992
+ function renderStatsOverview(payload) {
7993
+ const node = make("div", "stats-overlay-pane stats-overlay-overview");
7994
+ const totals = payload?.totals || {};
7995
+ const summary = payload?.summary || {};
7996
+ const highest = summary.highestDay;
7997
+ const cards = make("div", "stats-overlay-cards");
7998
+ cards.append(
7999
+ statsMetricCard("Total tokens", formatStatsTokens(totals.total), `↑${formatStatsTokens(totals.input)} ↓${formatStatsTokens(totals.output)}`, "tone-blue"),
8000
+ statsMetricCard("Cost", formatStatsCost(totals.cost), `projected 30d ${formatStatsCost(summary.projected30DayCost)}`, "tone-green"),
8001
+ statsMetricCard("Messages", String(statsNumber(totals.messages)), `${payload?.sessionCount ?? 0} sessions`, "tone-mauve"),
8002
+ statsMetricCard("PI initial prompt", `~${formatStatsTokens(payload?.promptEstimate?.total)} tok`, `${statsPromptEstimateSourceLabel(payload?.promptEstimate)} · ${payload?.promptEstimate?.confidence || "estimate"}`, "tone-yellow"),
8003
+ statsMetricCard("Cache hit", formatStatsPercent(summary.cacheHitRate), `reads ${formatStatsTokens(totals.cacheRead)} · writes ${formatStatsTokens(totals.cacheWrite)}`, "tone-teal"),
8004
+ statsMetricCard("Active days", `${payload?.activeDayCount ?? 0}/${payload?.dayCount ?? 0}`, highest ? `peak ${highest.day} · ${formatStatsCost(highest.cost)}` : "no peak yet", "tone-pink"),
8005
+ );
8006
+ node.append(cards, make("h3", undefined, "Daily usage"), renderStatsBarRows(payload?.daily || []));
8007
+ return node;
8008
+ }
8009
+
8010
+ function renderStatsDaily(payload) {
8011
+ const node = make("div", "stats-overlay-pane");
8012
+ node.append(make("h3", undefined, "Daily token and cost trend"), renderStatsBarRows(payload?.daily || []));
8013
+ node.append(renderStatsTable(
8014
+ ["Day", "Tokens", "Cost", "Input", "Output", "Cache R/W", "Msgs"],
8015
+ (payload?.daily || []).map((row) => [
8016
+ row.day || "—",
8017
+ formatStatsTokens(row.total),
8018
+ formatStatsCost(row.cost),
8019
+ formatStatsTokens(row.input),
8020
+ formatStatsTokens(row.output),
8021
+ `${formatStatsTokens(row.cacheRead)} / ${formatStatsTokens(row.cacheWrite)}`,
8022
+ String(statsNumber(row.messages)),
8023
+ ]),
8024
+ ));
8025
+ return node;
8026
+ }
8027
+
8028
+ function renderStatsModels(payload) {
8029
+ return renderStatsTable(
8030
+ ["Model", "Tokens", "Token %", "Cost", "Spend %", "$/1M", "Avg out", "Msgs"],
8031
+ (payload?.models || []).map((model) => [
8032
+ model.model || "unknown",
8033
+ formatStatsTokens(model.tokens),
8034
+ formatStatsPercent(model.percent),
8035
+ formatStatsCost(model.cost),
8036
+ formatStatsPercent(model.costPercent),
8037
+ formatStatsCost(model.avgCostPerMillion),
8038
+ formatStatsTokens(Math.round(statsNumber(model.avgOutputTokens))),
8039
+ String(statsNumber(model.messages)),
8040
+ ]),
8041
+ "No model usage in this range.",
8042
+ );
8043
+ }
8044
+
8045
+ function renderStatsSessions(payload) {
8046
+ return renderStatsTable(
8047
+ ["Day", "Session", "Cost", "Tokens", "Model"],
8048
+ (payload?.expensiveSessions || []).map((session) => [
8049
+ session.day || "—",
8050
+ session.displayName || session.sessionId || "unknown",
8051
+ formatStatsCost(session.cost),
8052
+ formatStatsTokens(session.tokens),
8053
+ session.model || "unknown",
8054
+ ]),
8055
+ "No session usage in this range.",
8056
+ );
8057
+ }
8058
+
8059
+ function renderStatsCostCache(payload) {
8060
+ const node = make("div", "stats-overlay-pane");
8061
+ const totals = payload?.totals || {};
8062
+ const summary = payload?.summary || {};
8063
+ const cards = make("div", "stats-overlay-cards compact");
8064
+ cards.append(
8065
+ statsMetricCard("Avg/day", formatStatsCost(summary.calendarAvgCost), "calendar average", "tone-green"),
8066
+ statsMetricCard("Active avg", formatStatsCost(summary.activeAvgCost), "per active day", "tone-teal"),
8067
+ statsMetricCard("Non-cache", formatStatsTokens(summary.nonCacheTokens), `${formatStatsTokens(totals.total)} total`, "tone-blue"),
8068
+ statsMetricCard("Cache hit", formatStatsPercent(summary.cacheHitRate), `${formatStatsTokens(totals.cacheRead)} read tokens`, "tone-yellow"),
8069
+ );
8070
+ node.append(cards, make("h3", undefined, "Cost trend"), statsLineBlock(payload?.lines?.costTrend), make("h3", undefined, "Cache efficiency"), statsLineBlock(payload?.lines?.cache));
8071
+ return node;
8072
+ }
8073
+
8074
+ function statsCalibrationButton(label, mode, className = "") {
8075
+ const button = make("button", className, statsOverlayCalibrationBusy === mode ? "Running…" : label);
8076
+ button.type = "button";
8077
+ button.disabled = statsOverlayLoading || !!statsOverlayCalibrationBusy;
8078
+ button.addEventListener("click", () => runStatsCalibration(mode));
8079
+ return button;
8080
+ }
8081
+
8082
+ function renderStatsCalibrationPanel(payload) {
8083
+ const estimate = payload?.promptEstimate || {};
8084
+ const panel = make("section", "stats-overlay-calibration-panel");
8085
+ const text = make("div", "stats-overlay-calibration-copy");
8086
+ text.append(
8087
+ make("strong", undefined, "Calibration"),
8088
+ make("span", undefined, `${statsNumber(estimate.calibrationSamples)} sample${statsNumber(estimate.calibrationSamples) === 1 ? "" : "s"} · scale ×${statsNumber(estimate.calibrationMultiplier, 1).toFixed(2)} · ${statsPromptEstimateSourceLabel(estimate)}`),
8089
+ );
8090
+ if (estimate.warning) text.append(make("span", "warning", estimate.warning));
8091
+ if (statsOverlayCalibrationMessage) text.append(make("span", "muted", statsOverlayCalibrationMessage));
8092
+ const actions = make("div", "stats-overlay-calibration-actions");
8093
+ actions.append(
8094
+ statsCalibrationButton("Calibrate current", "current"),
8095
+ statsCalibrationButton("Start probe", "probe", "primary"),
8096
+ );
8097
+ panel.append(text, actions);
8098
+ return panel;
8099
+ }
8100
+
8101
+ function renderStatsPrompt(payload) {
8102
+ const node = make("div", "stats-overlay-pane");
8103
+ const cards = make("div", "stats-overlay-cards compact");
8104
+ cards.append(
8105
+ statsMetricCard("PI estimate", `~${formatStatsTokens(payload?.promptEstimate?.total)} tok`, `${statsPromptEstimateSourceLabel(payload?.promptEstimate)} · ${payload?.promptEstimate?.confidence || "estimate"}`, "tone-yellow"),
8106
+ statsMetricCard("Prompt chars", statsNumber(payload?.promptEstimate?.systemPromptChars).toLocaleString(), `${statsNumber(payload?.promptEstimate?.activeToolSchemas)} active tool schemas`, "tone-blue"),
8107
+ statsMetricCard("Calibration", `×${statsNumber(payload?.promptEstimate?.calibrationMultiplier, 1).toFixed(2)}`, `${statsNumber(payload?.promptEstimate?.calibrationSamples)} samples`, "tone-teal"),
8108
+ statsMetricCard("Attempts", String(statsNumber(payload?.promptEstimate?.attempts)), payload?.promptEstimate?.settled ? "settled" : "live fallback", "tone-mauve"),
8109
+ );
8110
+ node.append(
8111
+ cards,
8112
+ renderStatsCalibrationPanel(payload),
8113
+ make("h3", undefined, "PI prompt estimate"),
8114
+ statsLineBlock(payload?.lines?.promptInjection),
8115
+ make("h3", undefined, "Detailed prompt snapshot"),
8116
+ statsLineBlock(payload?.lines?.promptDetailed),
8117
+ make("h3", undefined, "Current context token breakdown"),
8118
+ statsLineBlock(payload?.lines?.tokenBreakdown),
8119
+ );
8120
+ return node;
8121
+ }
8122
+
8123
+ function statsCommandOutputSection(title, command, description, lines = []) {
8124
+ const section = make("section", "stats-overlay-command-section");
8125
+ const header = make("div", "stats-overlay-command-header");
8126
+ const text = make("div", "stats-overlay-command-title");
8127
+ text.append(make("h3", undefined, title), make("p", "muted", description));
8128
+ header.append(text, make("code", "stats-overlay-command-pill", command));
8129
+ section.append(header, statsLineBlock(lines));
8130
+ return section;
8131
+ }
8132
+
8133
+ function renderStatsRaw(payload) {
8134
+ const node = make("div", "stats-overlay-pane");
8135
+ node.append(
8136
+ statsCommandOutputSection("Daily usage graph", "/stats-last [days|all]", "Non-zero daily token/cost graph for the selected range.", payload?.lines?.graph),
8137
+ statsCommandOutputSection("Model comparison", "/stats-model-compare [days|all]", "Token share, spend share, average cost, and average output by model.", payload?.lines?.modelComparison),
8138
+ statsCommandOutputSection("Most expensive sessions", "/stats-most-expense [days|all]", "Highest-cost sessions in the selected range.", payload?.lines?.expensiveSessions),
8139
+ statsCommandOutputSection("Cost trend", "/stats-cost-trend [days|all]", "Daily averages, 30-day projection, highest day, and latest active day.", payload?.lines?.costTrend),
8140
+ statsCommandOutputSection("Cache efficiency", "/stats-cache [days|all]", "Cache hit rate, cache read/write tokens, estimated savings, and token mix.", payload?.lines?.cache),
8141
+ statsCommandOutputSection("PI prompt breakdown", "/stats-pi detailed", "Export-backed initial prompt estimate with detailed prompt snapshot sections.", [...(payload?.lines?.promptInjection || []), "", ...(payload?.lines?.promptDetailed || [])]),
8142
+ );
8143
+ return node;
8144
+ }
8145
+
8146
+ function renderStatsOverlayPane(payload) {
8147
+ if (!payload) return make("p", "stats-overlay-empty muted", statsOverlayLoading ? "Loading stats…" : "No stats payload loaded yet.");
8148
+ switch (statsOverlayActiveTab) {
8149
+ case "daily": return renderStatsDaily(payload);
8150
+ case "models": return renderStatsModels(payload);
8151
+ case "sessions": return renderStatsSessions(payload);
8152
+ case "cost-cache": return renderStatsCostCache(payload);
8153
+ case "prompt": return renderStatsPrompt(payload);
8154
+ case "raw": return renderStatsRaw(payload);
8155
+ default: return renderStatsOverview(payload);
8156
+ }
8157
+ }
8158
+
8159
+ function renderStatsOverlay() {
8160
+ const payload = currentStatsOverlayPayload();
8161
+ if (!elements.statsOverlayDialog) return;
8162
+
8163
+ if (payload) statsOverlayLastScope = statsScopeValueFromPayload(payload);
8164
+ syncStatsScopeControls(statsOverlayLoading ? null : payload);
8165
+
8166
+ const generated = payload?.generatedAt ? new Date(payload.generatedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "not loaded";
8167
+ elements.statsOverlaySubtitle.textContent = payload
8168
+ ? `${payload.scopeLabel || "stats"} · ${payload.sessionCount ?? 0} sessions · updated ${generated}`
8169
+ : "Run stats to load the browser dashboard.";
8170
+
8171
+ elements.statsOverlayStatus.textContent = statsOverlayError || (statsOverlayLoading ? "Loading stats from the Pi stats extension…" : statsOverlayCalibrationMessage || (payload ? "" : "No stats payload loaded yet."));
8172
+ elements.statsOverlayStatus.hidden = !elements.statsOverlayStatus.textContent;
8173
+ elements.statsOverlayStatus.classList.toggle("error", !!statsOverlayError);
8174
+
8175
+ elements.statsOverlayTabs.replaceChildren();
8176
+ for (const tab of STATS_OVERLAY_TABS) {
8177
+ const button = make("button", tab.id === statsOverlayActiveTab ? "active" : "", tab.label);
8178
+ button.type = "button";
8179
+ button.setAttribute("role", "tab");
8180
+ button.setAttribute("aria-selected", tab.id === statsOverlayActiveTab ? "true" : "false");
8181
+ button.addEventListener("click", () => {
8182
+ statsOverlayActiveTab = tab.id;
8183
+ renderStatsOverlay();
8184
+ });
8185
+ elements.statsOverlayTabs.append(button);
8186
+ }
8187
+
8188
+ elements.statsOverlayRefreshButton.disabled = statsOverlayLoading;
8189
+ elements.statsOverlayBody.replaceChildren(renderStatsOverlayPane(payload));
8190
+ }
8191
+
8192
+ function scheduleStatsRefreshAfterCalibration(tabContext, delays = [1200]) {
8193
+ for (const delayMs of delays) {
8194
+ setTimeout(() => {
8195
+ if (!isCurrentTabContext(tabContext)) return;
8196
+ requestStatsOverlayRefresh();
8197
+ }, delayMs);
8198
+ }
8199
+ }
8200
+
8201
+ async function runStatsCalibration(mode) {
8202
+ const tabContext = activeTabContext();
8203
+ if (!tabContext.tabId) return;
8204
+ const commandName = resolveAvailableCommandName("calibrate", { rpcOnly: true });
8205
+ if (!commandName) {
8206
+ statsOverlayError = "Calibration command unavailable: /calibrate is not loaded in this Pi tab.";
8207
+ renderStatsOverlay();
8208
+ return;
8209
+ }
8210
+ if (mode === "probe" && !confirm("Start an isolated calibration probe? This sends one tiny model request and may incur provider token usage.")) return;
8211
+
8212
+ const command = mode === "current" ? `/${commandName} current` : `/${commandName}`;
8213
+ statsOverlayCalibrationBusy = mode;
8214
+ statsOverlayCalibrationMessage = mode === "current"
8215
+ ? "Calibrating from the current session…"
8216
+ : "Starting isolated calibration probe…";
8217
+ statsOverlayError = "";
8218
+ renderStatsOverlay();
8219
+ try {
8220
+ await sendPrompt("prompt", command, { targetTabId: tabContext.tabId, throwOnError: true });
8221
+ statsOverlayCalibrationMessage = mode === "current"
8222
+ ? "Calibration command finished; refreshing stats…"
8223
+ : "Probe started; stats will refresh after the probe response is recorded…";
8224
+ statsOverlayCalibrationBusy = "";
8225
+ renderStatsOverlay();
8226
+ scheduleStatsRefreshAfterCalibration(tabContext, mode === "probe" ? [5000, 14000] : [1000]);
8227
+ } catch (error) {
8228
+ if (!isCurrentTabContext(tabContext)) return;
8229
+ statsOverlayCalibrationBusy = "";
8230
+ statsOverlayCalibrationMessage = "";
8231
+ statsOverlayError = error.message || String(error);
8232
+ renderStatsOverlay();
8233
+ }
8234
+ }
8235
+
8236
+ async function requestStatsOverlayRefresh() {
8237
+ const tabContext = activeTabContext();
8238
+ if (!tabContext.tabId) return;
8239
+ const statsWebuiCommand = resolveAvailableCommandName("stats-webui", { rpcOnly: true });
8240
+ const fallbackStatsCommand = resolveAvailableCommandName("stats", { rpcOnly: true });
8241
+ const scopeArg = statsScopeCommandArg();
8242
+ const command = statsWebuiCommand
8243
+ ? `/${statsWebuiCommand}${scopeArg ? ` ${scopeArg}` : ""}`
8244
+ : fallbackStatsCommand
8245
+ ? `/${fallbackStatsCommand}${scopeArg ? ` ${scopeArg}` : ""} --webui`
8246
+ : "";
8247
+ if (!command) {
8248
+ statsOverlayError = "Stats command unavailable: enable/install @firstpick/pi-extension-stats in Optional features.";
8249
+ statsOverlayLoading = false;
8250
+ renderStatsOverlay();
8251
+ return;
8252
+ }
8253
+
8254
+ statsOverlayLastScope = scopeArg;
8255
+ statsOverlayLoading = true;
8256
+ statsOverlayError = "";
8257
+ renderStatsOverlay();
8258
+ try {
8259
+ await sendPrompt("prompt", command, { targetTabId: tabContext.tabId, throwOnError: true });
8260
+ setTimeout(() => {
8261
+ if (!isCurrentTabContext(tabContext) || !statsOverlayLoading) return;
8262
+ statsOverlayLoading = false;
8263
+ if (!currentStatsOverlayPayload()) statsOverlayError = "Stats command returned without a WebUI payload. Try /reload, then open Stats again.";
8264
+ renderStatsOverlay();
8265
+ }, 2500);
8266
+ } catch (error) {
8267
+ if (!isCurrentTabContext(tabContext)) return;
8268
+ statsOverlayLoading = false;
8269
+ statsOverlayError = error.message || String(error);
8270
+ renderStatsOverlay();
8271
+ }
8272
+ }
8273
+
8274
+ function openStatsOverlay({ refresh = true } = {}) {
8275
+ setComposerActionsOpen(false);
8276
+ setPublishMenuOpen(false);
8277
+ setNativeCommandMenuOpen(false);
8278
+ setAppRunnerMenuOpen(false);
8279
+ setOptionsMenuOpen(false);
8280
+ statsOverlayError = "";
8281
+ if (!elements.statsOverlayDialog.open) elements.statsOverlayDialog.showModal();
8282
+ renderStatsOverlay();
8283
+ if (refresh || !currentStatsOverlayPayload()) requestStatsOverlayRefresh();
8284
+ }
8285
+
8286
+ function handleStatsWebuiStatus(statusText) {
8287
+ const payload = parseStatsWebuiPayloadRaw(statusText);
8288
+ if (!payload) {
8289
+ if (elements.statsOverlayDialog?.open) {
8290
+ statsOverlayLoading = false;
8291
+ renderStatsOverlay();
8292
+ }
8293
+ return;
8294
+ }
8295
+ latestStatsOverlayPayload = payload;
8296
+ statsOverlayLoading = false;
8297
+ statsOverlayError = "";
8298
+ statsOverlayCalibrationMessage = "";
8299
+ statsOverlayLastScope = statsScopeValueFromPayload(payload);
8300
+ if (payload.open && !elements.statsOverlayDialog?.open) elements.statsOverlayDialog?.showModal();
8301
+ if (payload.open || elements.statsOverlayDialog?.open) renderStatsOverlay();
8302
+ }
8303
+
7729
8304
  function renderWidgets() {
7730
8305
  elements.widgetArea.replaceChildren();
7731
8306
  const releaseOutput = renderReleaseNpmOutputWidget();
@@ -7798,6 +8373,10 @@ function formatCommitMessagePreview(message) {
7798
8373
  return [`=== SHORT ===`, message.short || "(empty)", "", "=== LONG ===", message.long || "(empty)"].join("\n");
7799
8374
  }
7800
8375
 
8376
+ function formatInputCommitMessagePreview(message) {
8377
+ return [`=== INPUT ===`, String(message || "").trim() || "(empty)"].join("\n");
8378
+ }
8379
+
7801
8380
  function gitWorkflowMessageTitle(message) {
7802
8381
  return String(message?.short || message?.long || "").split("\n").find((line) => line.trim())?.trim() || "Pull request";
7803
8382
  }
@@ -7827,6 +8406,148 @@ function formatGitPrPreview(pr) {
7827
8406
  return [...header, "", pr.body || "(empty)"].join("\n");
7828
8407
  }
7829
8408
 
8409
+ function readGitFooterStatusSetup() {
8410
+ try {
8411
+ const parsed = JSON.parse(localStorage.getItem(GIT_FOOTER_STATUS_SETUP_STORAGE_KEY) || "{}");
8412
+ const githubUsername = typeof parsed?.githubUsername === "string" ? parsed.githubUsername.trim() : "";
8413
+ return { githubUsername };
8414
+ } catch {
8415
+ return { githubUsername: "" };
8416
+ }
8417
+ }
8418
+
8419
+ function cleanGitHubUsernameInput(value) {
8420
+ const username = String(value || "").trim().replace(/^@+/, "");
8421
+ if (!username) throw new Error("GitHub username is required.");
8422
+ if (username.length > 39 || !/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(username) || username.includes("--")) {
8423
+ throw new Error("GitHub username must be 1-39 letters/numbers/hyphens, without leading/trailing or repeated hyphens.");
8424
+ }
8425
+ return username;
8426
+ }
8427
+
8428
+ function cleanGitHubRepoNameInput(value) {
8429
+ let repoName = String(value || "").trim();
8430
+ const githubUrlMatch = repoName.match(/github\.com[:/][^/\s]+\/([^/\s]+?)(?:\.git)?\/?$/i);
8431
+ if (githubUrlMatch) repoName = githubUrlMatch[1];
8432
+ if (repoName.includes("/")) repoName = repoName.split("/").filter(Boolean).pop() || "";
8433
+ repoName = repoName.replace(/\.git$/i, "");
8434
+ if (!repoName) throw new Error("GitHub repository name is required.");
8435
+ if (repoName.length > 100 || repoName === "." || repoName === ".." || !/^[A-Za-z0-9._-]+$/.test(repoName)) {
8436
+ throw new Error("GitHub repository name may only contain letters, numbers, dots, underscores, and hyphens.");
8437
+ }
8438
+ return repoName;
8439
+ }
8440
+
8441
+ function writeGitFooterStatusSetup(setup) {
8442
+ try {
8443
+ localStorage.setItem(GIT_FOOTER_STATUS_SETUP_STORAGE_KEY, JSON.stringify({ githubUsername: setup.githubUsername }));
8444
+ } catch {
8445
+ addEvent("Could not persist git-footer-status-setup in browser storage; it will be asked again next time.", "warn");
8446
+ }
8447
+ }
8448
+
8449
+ function cleanGitInitStack(value) {
8450
+ return String(value || "").replace(/\s+/g, " ").trim().slice(0, 160);
8451
+ }
8452
+
8453
+ function readStoredGitInitStack() {
8454
+ try {
8455
+ return cleanGitInitStack(localStorage.getItem(GIT_INIT_STACK_STORAGE_KEY) || "");
8456
+ } catch {
8457
+ return "";
8458
+ }
8459
+ }
8460
+
8461
+ function writeStoredGitInitStack(stack) {
8462
+ const cleanStack = cleanGitInitStack(stack);
8463
+ try {
8464
+ if (cleanStack) localStorage.setItem(GIT_INIT_STACK_STORAGE_KEY, cleanStack);
8465
+ else localStorage.removeItem(GIT_INIT_STACK_STORAGE_KEY);
8466
+ } catch {
8467
+ addEvent("Could not persist the repository stack in browser storage.", "warn");
8468
+ }
8469
+ return cleanStack;
8470
+ }
8471
+
8472
+ function gitInitStackDisplay(stack = "") {
8473
+ const cleanStack = cleanGitInitStack(stack);
8474
+ return cleanStack || "Auto-detect from codebase";
8475
+ }
8476
+
8477
+ function gitInitFilesStatusSummary(status) {
8478
+ if (!status) return "README.md and .gitignore status: not checked yet.";
8479
+ const readme = status.readmeExists ? "README.md exists; it will be staged without overwriting." : "README.md is missing; it will be created.";
8480
+ const gitignore = status.gitignoreExists ? ".gitignore exists; it will be staged without overwriting." : ".gitignore is missing; Pi will be prompted to generate it.";
8481
+ const detected = status.detectedStack ? `Detected stack hint: ${status.detectedStack}` : "Detected stack hint: none yet.";
8482
+ return [readme, gitignore, detected].join("\n");
8483
+ }
8484
+
8485
+ function configureGitFooterStatusSetup({ force = true } = {}) {
8486
+ const current = readGitFooterStatusSetup();
8487
+ if (current.githubUsername && !force) return current;
8488
+ const value = window.prompt("git-footer-status-setup: GitHub username for origin remotes", current.githubUsername || "");
8489
+ if (value === null) return null;
8490
+ try {
8491
+ const setup = { githubUsername: cleanGitHubUsernameInput(value) };
8492
+ writeGitFooterStatusSetup(setup);
8493
+ addEvent(`git-footer-status-setup saved GitHub username ${setup.githubUsername}`, "success");
8494
+ return setup;
8495
+ } catch (error) {
8496
+ addEvent(error.message || String(error), "error");
8497
+ return null;
8498
+ }
8499
+ }
8500
+
8501
+ function ensureGitFooterStatusSetup() {
8502
+ const setup = readGitFooterStatusSetup();
8503
+ if (setup.githubUsername) return setup;
8504
+ return configureGitFooterStatusSetup({ force: true });
8505
+ }
8506
+
8507
+ function defaultGitInitRepoName(tab = activeTab()) {
8508
+ const cwd = latestWorkspace?.cwd || tab?.cwd || latestWorkspace?.displayCwd || "";
8509
+ const lastPart = String(cwd).split(/[\\/]+/).filter(Boolean).pop() || "new-repo";
8510
+ try {
8511
+ return cleanGitHubRepoNameInput(lastPart);
8512
+ } catch {
8513
+ return "new-repo";
8514
+ }
8515
+ }
8516
+
8517
+ function gitInitRemoteUrl(username, repoName) {
8518
+ return `https://github.com/${username}/${repoName}.git`;
8519
+ }
8520
+
8521
+ function promptGitInitRepoName(workflow = gitWorkflow) {
8522
+ const fallback = workflow?.repoName || defaultGitInitRepoName();
8523
+ const value = window.prompt("GitHub repository name for origin remote", fallback);
8524
+ if (value === null) return null;
8525
+ try {
8526
+ return cleanGitHubRepoNameInput(value);
8527
+ } catch (error) {
8528
+ addEvent(error.message || String(error), "error");
8529
+ return null;
8530
+ }
8531
+ }
8532
+
8533
+ function ensureGitInitRemoteDetails(tabId = activeTabId) {
8534
+ const workflow = gitWorkflowForTab(tabId, { create: false }) || gitWorkflow;
8535
+ const setup = ensureGitFooterStatusSetup();
8536
+ if (!setup?.githubUsername) return null;
8537
+ const repoName = workflow.repoName ? cleanGitHubRepoNameInput(workflow.repoName) : promptGitInitRepoName(workflow);
8538
+ if (!repoName) return null;
8539
+ const remoteUrl = gitInitRemoteUrl(setup.githubUsername, repoName);
8540
+ setGitWorkflow({ githubUsername: setup.githubUsername, repoName, remoteUrl }, { tabId });
8541
+ return { username: setup.githubUsername, repoName, remoteUrl };
8542
+ }
8543
+
8544
+ function gitInitWorkflowSetupSummary(workflow = gitWorkflow) {
8545
+ const username = workflow.githubUsername || readGitFooterStatusSetup().githubUsername || "not set";
8546
+ const repoName = workflow.repoName || defaultGitInitRepoName();
8547
+ const remoteUrl = username !== "not set" ? gitInitRemoteUrl(username, repoName) : "not configured";
8548
+ return [`GitHub username: ${username}`, `Repository name: ${repoName}`, `Stack: ${gitInitStackDisplay(workflow.stack)}`, `Origin URL: ${remoteUrl}`].join("\n");
8549
+ }
8550
+
7830
8551
  function addGitWorkflowAction(label, handler, className = "", disabled = gitWorkflow.busy, tooltip = "") {
7831
8552
  const button = make("button", className, label);
7832
8553
  button.type = "button";
@@ -7841,6 +8562,110 @@ function addGitWorkflowAction(label, handler, className = "", disabled = gitWork
7841
8562
  return button;
7842
8563
  }
7843
8564
 
8565
+ function renderGitInitStackInput() {
8566
+ const tabId = gitWorkflowActionTabId();
8567
+ const workflow = gitWorkflowForTab(tabId, { create: false }) || gitWorkflow;
8568
+ const storedStack = workflow?.stack || readStoredGitInitStack();
8569
+ const row = make("div", "git-workflow-message-input-row git-workflow-stack-input-row");
8570
+
8571
+ const selectField = make("label", "git-workflow-message-input-field");
8572
+ selectField.setAttribute("for", "gitWorkflowStackSelect");
8573
+ selectField.append(make("span", "git-workflow-message-input-label", "Stack preset"));
8574
+ const select = make("select", "git-workflow-message-input");
8575
+ select.id = "gitWorkflowStackSelect";
8576
+ for (const option of GIT_INIT_STACK_OPTIONS) {
8577
+ const node = make("option", undefined, option.label);
8578
+ node.value = option.value;
8579
+ select.append(node);
8580
+ }
8581
+ const matching = GIT_INIT_STACK_OPTIONS.some((option) => option.value === storedStack);
8582
+ select.value = matching ? storedStack : storedStack ? "Custom" : "";
8583
+ selectField.append(select);
8584
+
8585
+ const inputField = make("label", "git-workflow-message-input-field");
8586
+ inputField.setAttribute("for", "gitWorkflowStackInput");
8587
+ inputField.append(make("span", "git-workflow-message-input-label", "Stack input"));
8588
+ const input = make("input", "git-workflow-message-input");
8589
+ input.id = "gitWorkflowStackInput";
8590
+ input.type = "text";
8591
+ input.value = storedStack;
8592
+ input.placeholder = "e.g. Node.js + Vite + React, Python + FastAPI, Rust CLI";
8593
+ input.autocomplete = "off";
8594
+ input.spellcheck = false;
8595
+ inputField.append(input);
8596
+
8597
+ const saveButton = make("button", "git-workflow-message-input-commit", "Save stack");
8598
+ saveButton.type = "button";
8599
+ saveButton.title = GIT_INIT_STACK_TOOLTIP;
8600
+ saveButton.dataset.tooltip = GIT_INIT_STACK_TOOLTIP;
8601
+ const saveStack = () => {
8602
+ const stack = writeStoredGitInitStack(input.value);
8603
+ const current = gitWorkflowForTab(tabId, { create: false });
8604
+ if (!current) return;
8605
+ setGitWorkflow({ step: "readme", stack, ...gitWorkflowActionDonePatch(current, "stack"), output: `Stack saved: ${gitInitStackDisplay(stack)}\n\nNext: check README.md and .gitignore before staging. ${stack ? "Pi will use this stack for .gitignore." : "Pi will inspect the codebase and fall back to sane defaults for .gitignore."}` }, { tabId });
8606
+ };
8607
+ select.addEventListener("change", () => {
8608
+ if (select.value === "Custom") {
8609
+ input.focus();
8610
+ input.select();
8611
+ } else {
8612
+ input.value = select.value;
8613
+ }
8614
+ });
8615
+ input.addEventListener("keydown", (event) => {
8616
+ if (event.key !== "Enter") return;
8617
+ event.preventDefault();
8618
+ saveStack();
8619
+ });
8620
+ saveButton.addEventListener("click", saveStack);
8621
+ row.append(selectField, inputField, saveButton);
8622
+ elements.gitWorkflowActions.append(row);
8623
+ }
8624
+
8625
+ function renderGitWorkflowManualCommitInput() {
8626
+ const tabId = gitWorkflowActionTabId();
8627
+ const workflow = gitWorkflowForTab(tabId, { create: false }) || gitWorkflow;
8628
+ const row = make("div", "git-workflow-message-input-row");
8629
+ const field = make("label", "git-workflow-message-input-field");
8630
+ field.setAttribute("for", "gitWorkflowManualCommitMessage");
8631
+ field.append(make("span", "git-workflow-message-input-label", "Input commit message"));
8632
+
8633
+ const input = make("input", "git-workflow-message-input");
8634
+ input.id = "gitWorkflowManualCommitMessage";
8635
+ input.type = "text";
8636
+ input.value = workflow?.manualCommitMessage || "";
8637
+ input.placeholder = "Type a commit message to use instead of short/long";
8638
+ input.autocomplete = "off";
8639
+ input.spellcheck = true;
8640
+
8641
+ const commitButton = make("button", "git-workflow-message-input-commit", "Commit input");
8642
+ commitButton.type = "button";
8643
+ const updateCommitState = () => {
8644
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
8645
+ commitButton.disabled = !currentWorkflow || !!currentWorkflow.busy || !input.value.trim();
8646
+ };
8647
+ input.addEventListener("input", () => {
8648
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
8649
+ if (currentWorkflow) currentWorkflow.manualCommitMessage = input.value;
8650
+ updateCommitState();
8651
+ });
8652
+ input.addEventListener("keydown", (event) => {
8653
+ if (event.key !== "Enter") return;
8654
+ event.preventDefault();
8655
+ if (!commitButton.disabled) commitGitWorkflow("input", tabId);
8656
+ });
8657
+ commitButton.addEventListener("click", () => {
8658
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
8659
+ if (currentWorkflow) currentWorkflow.manualCommitMessage = input.value;
8660
+ commitGitWorkflow("input", tabId);
8661
+ });
8662
+ updateCommitState();
8663
+
8664
+ field.append(input);
8665
+ row.append(field, commitButton);
8666
+ elements.gitWorkflowActions.append(row);
8667
+ }
8668
+
7844
8669
  function setGitPrDialogStatus(message = "", level = "muted") {
7845
8670
  if (!elements.gitPrStatus) return;
7846
8671
  elements.gitPrStatus.textContent = message;
@@ -7869,6 +8694,29 @@ function openGitPrReviewDialog(pr, { title = "" } = {}) {
7869
8694
 
7870
8695
  function gitWorkflowProcessForStep(step = gitWorkflow.step, fallback = gitWorkflow.process || "stage") {
7871
8696
  switch (step) {
8697
+ case "initSetup":
8698
+ case "initRepo":
8699
+ case "initializingRepo":
8700
+ return "init";
8701
+ case "initStack":
8702
+ return "stack";
8703
+ case "readme":
8704
+ case "readmeCreating":
8705
+ case "readmeGenerating":
8706
+ case "gitignoreGenerating":
8707
+ return "readme";
8708
+ case "initialCommit":
8709
+ case "initialCommitting":
8710
+ return "commit";
8711
+ case "mainBranch":
8712
+ case "mainBranching":
8713
+ return "branch";
8714
+ case "remote":
8715
+ case "remoteAdding":
8716
+ return "remote";
8717
+ case "initialPush":
8718
+ case "initialPushing":
8719
+ return "push";
7872
8720
  case "generate":
7873
8721
  case "generating":
7874
8722
  return "message";
@@ -7889,26 +8737,88 @@ function gitWorkflowProcessForStep(step = gitWorkflow.step, fallback = gitWorkfl
7889
8737
  return "stage";
7890
8738
  case "cancelled":
7891
8739
  case "error":
7892
- return GIT_WORKFLOW_PROCESS_VALUES.has(fallback) ? fallback : "stage";
8740
+ return GIT_WORKFLOW_PROCESS_VALUES.has(fallback) || GIT_INIT_WORKFLOW_PROCESS_VALUES.has(fallback) ? fallback : "stage";
7893
8741
  default:
7894
8742
  return "stage";
7895
8743
  }
7896
8744
  }
7897
8745
 
7898
- function selectGitWorkflowProcess(processValue, tabId = gitWorkflowActionTabId()) {
7899
- const workflow = gitWorkflowForTab(tabId);
7900
- if (!workflow) return;
7901
- const process = GIT_WORKFLOW_PROCESS_VALUES.has(processValue) ? processValue : "stage";
8746
+ function selectGitInitWorkflowProcess(processValue, tabId, workflow) {
8747
+ const process = GIT_INIT_WORKFLOW_PROCESS_VALUES.has(processValue) ? processValue : "init";
7902
8748
  workflow.runId += 1;
7903
- const runId = workflow.runId;
7904
- const base = { active: true, process, busy: false, error: "", messageRequestedAt: 0, branchName: "", branchNameRequestedAt: 0, prMode: false, prBranch: "", pr: null, prRequestedAt: 0 };
7905
-
7906
- if (process === "stage") {
7907
- setGitWorkflow({ ...base, step: "add", message: null, output: "Ready to stage all changes with git add ." }, { tabId });
7908
- return;
7909
- }
8749
+ const username = workflow.githubUsername || readGitFooterStatusSetup().githubUsername || "";
8750
+ const repoName = workflow.repoName || defaultGitInitRepoName();
8751
+ const remoteUrl = username ? gitInitRemoteUrl(username, repoName) : "";
8752
+ const stack = workflow.stack || readStoredGitInitStack();
8753
+ const base = {
8754
+ mode: "initRepo",
8755
+ active: true,
8756
+ process,
8757
+ busy: false,
8758
+ error: "",
8759
+ githubUsername: username,
8760
+ repoName,
8761
+ remoteUrl,
8762
+ stack,
8763
+ readmeRequestedAt: 0,
8764
+ gitignoreRequestedAt: 0,
8765
+ initFilesStatus: null,
8766
+ message: null,
8767
+ manualCommitMessage: "",
8768
+ messageRequestedAt: 0,
8769
+ branchName: "",
8770
+ branchNameRequestedAt: 0,
8771
+ prMode: false,
8772
+ prBranch: "",
8773
+ pr: null,
8774
+ prRequestedAt: 0,
8775
+ };
8776
+
8777
+ if (process === "init") {
8778
+ setGitWorkflow({ ...base, step: username ? "initRepo" : "initSetup", output: username ? `Ready to initialize a Git repository.\n\n${gitInitWorkflowSetupSummary(base)}` : "No GitHub username stored yet. Run git-footer-status-setup first." }, { tabId });
8779
+ return;
8780
+ }
8781
+ if (process === "stack") {
8782
+ setGitWorkflow({ ...base, step: "initStack", output: `Choose a repository stack before README/.gitignore preparation.\n\nCurrent stack: ${gitInitStackDisplay(stack)}` }, { tabId });
8783
+ return;
8784
+ }
8785
+ if (process === "readme") {
8786
+ setGitWorkflow({ ...base, step: "readme", output: "Ready to check README.md and .gitignore before staging them." }, { tabId });
8787
+ return;
8788
+ }
8789
+ if (process === "commit") {
8790
+ setGitWorkflow({ ...base, step: "initialCommit", output: "Ready to create the initial commit with message: Initial commit." }, { tabId });
8791
+ return;
8792
+ }
8793
+ if (process === "branch") {
8794
+ setGitWorkflow({ ...base, step: "mainBranch", output: "Ready to rename the current branch to main with git branch -M main." }, { tabId });
8795
+ return;
8796
+ }
8797
+ if (process === "remote") {
8798
+ setGitWorkflow({ ...base, step: "remote", output: `Ready to add origin.\n\n${gitInitWorkflowSetupSummary(base)}` }, { tabId });
8799
+ return;
8800
+ }
8801
+ setGitWorkflow({ ...base, step: "initialPush", output: "Ready to run git push -u origin main." }, { tabId });
8802
+ }
8803
+
8804
+ function selectGitWorkflowProcess(processValue, tabId = gitWorkflowActionTabId()) {
8805
+ const workflow = gitWorkflowForTab(tabId);
8806
+ if (!workflow) return;
8807
+ if (workflow.mode === "initRepo") {
8808
+ selectGitInitWorkflowProcess(processValue, tabId, workflow);
8809
+ return;
8810
+ }
8811
+ const process = GIT_WORKFLOW_PROCESS_VALUES.has(processValue) ? processValue : "stage";
8812
+ workflow.runId += 1;
8813
+ const runId = workflow.runId;
8814
+ const base = { mode: "standard", active: true, process, busy: false, error: "", githubUsername: "", repoName: "", remoteUrl: "", stack: "", readmeRequestedAt: 0, gitignoreRequestedAt: 0, initFilesStatus: null, manualCommitMessage: "", messageRequestedAt: 0, branchName: "", branchNameRequestedAt: 0, prMode: false, prBranch: "", pr: null, prRequestedAt: 0 };
8815
+
8816
+ if (process === "stage") {
8817
+ setGitWorkflow({ ...base, step: "add", message: null, output: "Ready to stage all changes with git add ." }, { tabId });
8818
+ return;
8819
+ }
7910
8820
  if (process === "message") {
7911
- setGitWorkflow({ ...base, step: "generate", message: null, output: "Ready to generate a commit message from the currently staged changes." }, { tabId });
8821
+ setGitWorkflow({ ...base, step: "generate", message: null, output: "Ready to generate a commit message from the currently staged changes, or type one and commit it directly." }, { tabId });
7912
8822
  return;
7913
8823
  }
7914
8824
  if (process === "commit") {
@@ -7921,6 +8831,22 @@ function selectGitWorkflowProcess(processValue, tabId = gitWorkflowActionTabId()
7921
8831
 
7922
8832
  function gitWorkflowTitle() {
7923
8833
  switch (gitWorkflow.step) {
8834
+ case "initSetup": return "Set up GitHub username";
8835
+ case "initRepo": return "Initialize Git repository";
8836
+ case "initializingRepo": return "Running git init";
8837
+ case "initStack": return "Choose repository stack";
8838
+ case "readme": return "Prepare README and .gitignore";
8839
+ case "readmeCreating": return "Preparing README and .gitignore";
8840
+ case "readmeGenerating": return "Waiting for README";
8841
+ case "gitignoreGenerating": return "Waiting for .gitignore";
8842
+ case "initialCommit": return "Create initial commit";
8843
+ case "initialCommitting": return "Committing initial files";
8844
+ case "mainBranch": return "Rename branch to main";
8845
+ case "mainBranching": return "Running git branch -M main";
8846
+ case "remote": return "Add origin remote";
8847
+ case "remoteAdding": return "Adding origin remote";
8848
+ case "initialPush": return "Push main upstream";
8849
+ case "initialPushing": return "Pushing main";
7924
8850
  case "add": return "Stage all changes";
7925
8851
  case "generate": return "Generate staged commit message";
7926
8852
  case "generating": return "Waiting for /git-staged-msg";
@@ -7933,31 +8859,107 @@ function gitWorkflowTitle() {
7933
8859
  case "prGenerating": return "Waiting for /pr";
7934
8860
  case "prReview": return "Review PR description";
7935
8861
  case "prCreating": return "Creating pull request";
7936
- case "done": return "Git workflow complete";
7937
- case "cancelled": return "Git workflow cancelled";
7938
- case "error": return "Git workflow needs attention";
7939
- default: return "Git workflow";
8862
+ case "done": return gitWorkflow.mode === "initRepo" ? "Git repository setup complete" : "Git workflow complete";
8863
+ case "cancelled": return gitWorkflow.mode === "initRepo" ? "Git repository setup cancelled" : "Git workflow cancelled";
8864
+ case "error": return gitWorkflow.mode === "initRepo" ? "Git repository setup needs attention" : "Git workflow needs attention";
8865
+ default: return gitWorkflow.mode === "initRepo" ? "Git repository setup" : "Git workflow";
7940
8866
  }
7941
8867
  }
7942
8868
 
7943
8869
  function gitWorkflowHint() {
7944
8870
  switch (gitWorkflow.step) {
8871
+ case "initSetup": return "First-time setup: save the GitHub username used in https://github.com/USERNAME/REPO_NAME.git.";
8872
+ case "initRepo": return "Step 1: run git init in the current Pi working directory.";
8873
+ case "initializingRepo": return "Running git init. Cancel will terminate the git command.";
8874
+ case "initStack": return "Step 2: choose a stack preset or type one; leave blank to let Pi infer it from the codebase.";
8875
+ case "readme": return "Step 3: check README.md and .gitignore, prompt Pi for missing files, then stage them.";
8876
+ case "readmeCreating": return "Checking README.md/.gitignore, creating missing files if needed, and staging them.";
8877
+ case "readmeGenerating": return "Pi is filling out README.md from the selected stack and repository contents.";
8878
+ case "gitignoreGenerating": return "Pi is generating .gitignore from the selected stack or by inspecting the codebase.";
8879
+ case "initialCommit": return "Step 4: run git commit -m \"Initial commit\".";
8880
+ case "initialCommitting": return "Creating the initial commit.";
8881
+ case "mainBranch": return "Step 5: run git branch -M main.";
8882
+ case "mainBranching": return "Renaming the current branch to main.";
8883
+ case "remote": return "Step 6: add origin as https://github.com/USERNAME/REPO_NAME.git.";
8884
+ case "remoteAdding": return "Adding the GitHub origin remote.";
8885
+ case "initialPush": return "Step 7: run git push -u origin main.";
8886
+ case "initialPushing": return "Pushing main upstream. Authentication must already be available to git.";
7945
8887
  case "add": return "Step 1: run git add . in the current Pi working directory.";
7946
- case "generate": return "Step 2: run /git-staged-msg, then preview the generated files.";
8888
+ case "generate": return "Step 2: run /git-staged-msg, or type a commit message and use Commit input.";
7947
8889
  case "generating": return "Pi is generating dev/COMMIT/staged-commit-short.txt and staged-commit-long.txt.";
7948
- case "message": return gitWorkflow.prMode ? `Branch ${gitWorkflow.prBranch || "created"}: choose short or long commit before opening a PR.` : "Step 3/4: preview the native g-msg output, commit here, or create a PR branch first.";
8890
+ case "message": return gitWorkflow.prMode ? `Branch ${gitWorkflow.prBranch || "created"}: choose short, long, or typed input before opening a PR.` : "Step 3/4: preview the native g-msg output, type a commit message if needed, commit here, or create a PR branch first.";
7949
8891
  case "branchNaming": return "Pi is generating dev/COMMIT/staged-branch-name.txt. Cancel will request Pi abort.";
7950
8892
  case "branching": return "Creating a new branch with git switch -c before committing.";
7951
- case "committing": return "Running native git commit from the generated message file.";
8893
+ case "committing": return "Running native git commit with the selected message.";
7952
8894
  case "push": return gitWorkflow.prMode ? "Push the PR branch, generate /pr, review the description, then create the pull request." : "Step 5: push the new commit to the configured remote.";
7953
8895
  case "pushing": return "Running git push. Cancel will request process termination.";
7954
8896
  case "prGenerating": return "Pi is generating dev/PR/<current-branch>.md with /pr.";
7955
8897
  case "prReview": return "Review or edit the generated PR description before creating the pull request.";
7956
8898
  case "prCreating": return "Running gh pr create with the confirmed description.";
7957
- case "done": return gitWorkflow.prMode ? "PR workflow finished. Review the output below." : "Push finished. Review the output below.";
8899
+ case "done": return gitWorkflow.mode === "initRepo" ? "Initial repository workflow finished. Review the output below." : gitWorkflow.prMode ? "PR workflow finished. Review the output below." : "Push finished. Review the output below.";
7958
8900
  case "cancelled": return "No further workflow steps will run.";
7959
8901
  case "error": return gitWorkflow.error || "Fix the issue, then retry or restart.";
7960
- default: return "Stage changes, generate a commit message, commit, and push.";
8902
+ default: return gitWorkflow.mode === "initRepo" ? "Initialize a repository, create README.md, commit, add origin, and push main." : "Stage changes, generate a commit message, commit, and push.";
8903
+ }
8904
+ }
8905
+
8906
+ function renderGitInitWorkflowActions() {
8907
+ if (gitWorkflow.step === "initSetup") {
8908
+ addGitWorkflowAction("git-footer-status-setup", () => {
8909
+ const setup = configureGitFooterStatusSetup({ force: true });
8910
+ if (!setup?.githubUsername) return;
8911
+ const repoName = gitWorkflow.repoName || defaultGitInitRepoName();
8912
+ const stack = gitWorkflow.stack || readStoredGitInitStack();
8913
+ setGitWorkflow({ step: "initRepo", githubUsername: setup.githubUsername, repoName, stack, remoteUrl: gitInitRemoteUrl(setup.githubUsername, repoName), output: `Ready to initialize a Git repository.\n\n${gitInitWorkflowSetupSummary({ githubUsername: setup.githubUsername, repoName, stack })}` });
8914
+ }, "primary", false, GIT_FOOTER_STATUS_SETUP_TOOLTIP);
8915
+ } else if (gitWorkflow.step === "initRepo") {
8916
+ addGitWorkflowAction("Run git init", () => runGitInitRepository(), "primary", false, "Run git init in the current Pi working directory.");
8917
+ } else if (gitWorkflow.step === "initializingRepo") {
8918
+ addGitWorkflowAction("Running git init…", () => {}, "primary", true);
8919
+ } else if (gitWorkflow.step === "initStack") {
8920
+ renderGitInitStackInput();
8921
+ addGitWorkflowAction("Skip / auto-detect", () => {
8922
+ const workflow = gitWorkflowForTab(gitWorkflowActionTabId(), { create: false }) || gitWorkflow;
8923
+ writeStoredGitInitStack("");
8924
+ setGitWorkflow({ step: "readme", stack: "", ...gitWorkflowActionDonePatch(workflow, "stack"), output: "Stack left blank. Pi will inspect the codebase for .gitignore and fallback to sane defaults.\n\nNext: check README.md and .gitignore before staging." });
8925
+ }, "", false, GIT_INIT_STACK_TOOLTIP);
8926
+ } else if (gitWorkflow.step === "readme") {
8927
+ addGitWorkflowAction("Check and prepare files", () => prepareGitInitFiles(), "primary", false, "Check README.md and .gitignore first; create missing README.md; prompt Pi to generate missing .gitignore.");
8928
+ } else if (gitWorkflow.step === "readmeCreating") {
8929
+ addGitWorkflowAction("Preparing files…", () => {}, "primary", true);
8930
+ } else if (gitWorkflow.step === "readmeGenerating") {
8931
+ addGitWorkflowAction("Waiting for Pi…", () => {}, "primary", true);
8932
+ } else if (gitWorkflow.step === "gitignoreGenerating") {
8933
+ addGitWorkflowAction("Waiting for Pi…", () => {}, "primary", true);
8934
+ } else if (gitWorkflow.step === "initialCommit") {
8935
+ addGitWorkflowAction("Commit initial files", () => commitGitInitialReadme(), "primary", false, "Run git commit -m \"Initial commit\".");
8936
+ } else if (gitWorkflow.step === "initialCommitting") {
8937
+ addGitWorkflowAction("Committing…", () => {}, "primary", true);
8938
+ } else if (gitWorkflow.step === "mainBranch") {
8939
+ addGitWorkflowAction("Run git branch -M main", () => branchGitInitMain(), "primary", false, "Rename the current branch to main.");
8940
+ } else if (gitWorkflow.step === "mainBranching") {
8941
+ addGitWorkflowAction("Renaming branch…", () => {}, "primary", true);
8942
+ } else if (gitWorkflow.step === "remote") {
8943
+ addGitWorkflowAction("Add origin remote", () => addGitInitRemote(), "primary", false, GIT_INIT_REMOTE_TOOLTIP);
8944
+ addGitWorkflowAction("git-footer-status-setup", () => {
8945
+ const setup = configureGitFooterStatusSetup({ force: true });
8946
+ if (!setup?.githubUsername) return;
8947
+ const repoName = gitWorkflow.repoName || defaultGitInitRepoName();
8948
+ const stack = gitWorkflow.stack || readStoredGitInitStack();
8949
+ setGitWorkflow({ githubUsername: setup.githubUsername, repoName, stack, remoteUrl: gitInitRemoteUrl(setup.githubUsername, repoName), output: `Ready to add origin.\n\n${gitInitWorkflowSetupSummary({ githubUsername: setup.githubUsername, repoName, stack })}` });
8950
+ }, "", false, GIT_FOOTER_STATUS_SETUP_TOOLTIP);
8951
+ } else if (gitWorkflow.step === "remoteAdding") {
8952
+ addGitWorkflowAction("Adding origin…", () => {}, "primary", true);
8953
+ } else if (gitWorkflow.step === "initialPush") {
8954
+ addGitWorkflowAction("Run git push -u origin main", () => pushGitInitWorkflow(), "primary", false, "Push main to origin and set upstream tracking.");
8955
+ } else if (gitWorkflow.step === "initialPushing") {
8956
+ addGitWorkflowAction("Pushing main…", () => {}, "primary", true);
8957
+ } else if (gitWorkflow.step === "done") {
8958
+ addGitWorkflowAction("Close", () => setGitWorkflow({ active: false }), "primary", false);
8959
+ addGitWorkflowAction("Initialize another", () => startGitInitWorkflow(), "", false);
8960
+ } else if (["cancelled", "error"].includes(gitWorkflow.step)) {
8961
+ addGitWorkflowAction("Close", () => setGitWorkflow({ active: false }), "primary", false);
8962
+ addGitWorkflowAction("Restart setup", () => startGitInitWorkflow(), "", false);
7961
8963
  }
7962
8964
  }
7963
8965
 
@@ -7965,15 +8967,19 @@ function renderGitWorkflow() {
7965
8967
  elements.gitWorkflowPanel.hidden = !gitWorkflow.active;
7966
8968
  if (!gitWorkflow.active) return;
7967
8969
 
8970
+ elements.gitWorkflowPanel.dataset.mode = gitWorkflow.mode || "standard";
8971
+ if (elements.gitWorkflowKicker) elements.gitWorkflowKicker.textContent = gitWorkflow.mode === "initRepo" ? "Git repository setup" : "Git workflow";
7968
8972
  elements.gitWorkflowTitle.textContent = gitWorkflowTitle();
7969
8973
  elements.gitWorkflowHint.textContent = gitWorkflowHint();
7970
8974
  elements.gitWorkflowOutput.textContent = gitWorkflow.output || "Ready.";
7971
8975
  elements.gitWorkflowSteps.replaceChildren();
7972
8976
  elements.gitWorkflowActions.replaceChildren();
7973
8977
 
7974
- const activeIndex = GIT_WORKFLOW_ACTIVE_INDEX[gitWorkflow.step] ?? 0;
8978
+ const processes = gitWorkflow.mode === "initRepo" ? GIT_INIT_WORKFLOW_PROCESSES : GIT_WORKFLOW_PROCESSES;
8979
+ const activeIndexMap = gitWorkflow.mode === "initRepo" ? GIT_INIT_WORKFLOW_ACTIVE_INDEX : GIT_WORKFLOW_ACTIVE_INDEX;
8980
+ const activeIndex = activeIndexMap[gitWorkflow.step] ?? 0;
7975
8981
  const activeProcess = gitWorkflowProcessForStep(gitWorkflow.step, gitWorkflow.process);
7976
- for (const [index, process] of GIT_WORKFLOW_PROCESSES.entries()) {
8982
+ for (const [index, process] of processes.entries()) {
7977
8983
  const item = make("button", "git-workflow-step", process.label);
7978
8984
  item.type = "button";
7979
8985
  item.dataset.gitWorkflowProcess = process.value;
@@ -7987,9 +8993,15 @@ function renderGitWorkflow() {
7987
8993
  elements.gitWorkflowCancelButton.hidden = ["done", "cancelled"].includes(gitWorkflow.step);
7988
8994
  elements.gitWorkflowCancelButton.disabled = false;
7989
8995
 
8996
+ if (gitWorkflow.mode === "initRepo") {
8997
+ renderGitInitWorkflowActions();
8998
+ return;
8999
+ }
9000
+
7990
9001
  if (gitWorkflow.step === "add") {
7991
9002
  addGitWorkflowAction("Run git add .", () => runGitAdd(), "primary", false);
7992
9003
  } else if (gitWorkflow.step === "generate") {
9004
+ renderGitWorkflowManualCommitInput();
7993
9005
  addGitWorkflowAction("Run /git-staged-msg", () => runGitMessagePrompt(), "primary", false);
7994
9006
  addGitWorkflowAction("Preview current message files", () => loadGitWorkflowMessage({ requireFresh: false }), "", false);
7995
9007
  } else if (gitWorkflow.step === "generating") {
@@ -7999,6 +9011,7 @@ function renderGitWorkflow() {
7999
9011
  addGitWorkflowAction("Create PR", () => createGitPrBranch(), "primary", false, GIT_WORKFLOW_CREATE_PR_TOOLTIP);
8000
9012
  addGitWorkflowAction("Manual branch", () => createGitPrBranchManually(), "", false, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP);
8001
9013
  }
9014
+ renderGitWorkflowManualCommitInput();
8002
9015
  addGitWorkflowAction("Commit short", () => commitGitWorkflow("short"), gitWorkflow.prMode ? "primary" : "", false);
8003
9016
  addGitWorkflowAction("Commit long", () => commitGitWorkflow("long"), gitWorkflow.prMode ? "primary" : "", false);
8004
9017
  addGitWorkflowAction("Regenerate", () => runGitMessagePrompt(), "", false);
@@ -8029,7 +9042,15 @@ function renderGitWorkflow() {
8029
9042
  async function gitWorkflowRequest(path, { method = "POST", body = {}, runId, tabId = activeTabId } = {}) {
8030
9043
  const workflow = gitWorkflowForTab(tabId, { create: false });
8031
9044
  const expectedRunId = runId ?? workflow?.runId;
8032
- const response = await api(path, method === "GET" ? { method, tabId } : { method, body, tabId });
9045
+ let response;
9046
+ try {
9047
+ response = await api(path, method === "GET" ? { method, tabId } : { method, body, tabId });
9048
+ } catch (error) {
9049
+ if (error?.statusCode === 404 && path.startsWith("/api/git-workflow/")) {
9050
+ throw new Error("Git workflow endpoint not found. Restart Pi Web UI so the browser and backend both load the latest git repository setup endpoints.");
9051
+ }
9052
+ throw error;
9053
+ }
8033
9054
  if (expectedRunId !== undefined && !isCurrentGitWorkflowRun(expectedRunId, tabId)) return null;
8034
9055
  if (!response.ok) {
8035
9056
  const detail = response.data ? `\n\n${formatGitCommandResult(response.data)}` : "";
@@ -8065,12 +9086,21 @@ function startGitWorkflow(tabId = activeTabId) {
8065
9086
  workflow.runId += 1;
8066
9087
  setGitWorkflow({
8067
9088
  active: true,
9089
+ mode: "standard",
8068
9090
  step: "add",
8069
9091
  process: "stage",
8070
9092
  busy: false,
8071
- 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. After the message is generated, use Create PR to switch to a new branch before committing.",
9093
+ 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. In the Message stage you can also type a commit message and use Commit input. After the message is generated, use Create PR to switch to a new branch before committing.",
8072
9094
  error: "",
9095
+ githubUsername: "",
9096
+ repoName: "",
9097
+ remoteUrl: "",
9098
+ stack: "",
9099
+ readmeRequestedAt: 0,
9100
+ gitignoreRequestedAt: 0,
9101
+ initFilesStatus: null,
8073
9102
  message: null,
9103
+ manualCommitMessage: "",
8074
9104
  messageRequestedAt: 0,
8075
9105
  branchName: "",
8076
9106
  branchNameRequestedAt: 0,
@@ -8082,11 +9112,308 @@ function startGitWorkflow(tabId = activeTabId) {
8082
9112
  }, { tabId });
8083
9113
  }
8084
9114
 
9115
+ function startGitInitWorkflow(tabId = activeTabId) {
9116
+ if (!tabId) return;
9117
+ const workflow = gitWorkflowForTab(tabId);
9118
+ if (workflow.active && !["done", "cancelled", "error"].includes(workflow.step) && !confirm("Restart the active git repository setup workflow?")) return;
9119
+ const setup = readGitFooterStatusSetup().githubUsername ? readGitFooterStatusSetup() : configureGitFooterStatusSetup({ force: true });
9120
+ const githubUsername = setup?.githubUsername || "";
9121
+ const repoName = defaultGitInitRepoName(tabs.find((tab) => tab.id === tabId) || activeTab());
9122
+ const stack = readStoredGitInitStack();
9123
+ workflow.runId += 1;
9124
+ setGitWorkflow({
9125
+ active: true,
9126
+ mode: "initRepo",
9127
+ step: githubUsername ? "initRepo" : "initSetup",
9128
+ process: "init",
9129
+ busy: false,
9130
+ output: githubUsername
9131
+ ? `Ready to initialize a Git repository.\n\n${gitInitWorkflowSetupSummary({ githubUsername, repoName, stack })}`
9132
+ : "No GitHub username stored yet. Run git-footer-status-setup to save the username for https://github.com/USERNAME/REPO_NAME.git.",
9133
+ error: "",
9134
+ githubUsername,
9135
+ repoName,
9136
+ remoteUrl: githubUsername ? gitInitRemoteUrl(githubUsername, repoName) : "",
9137
+ stack,
9138
+ readmeRequestedAt: 0,
9139
+ gitignoreRequestedAt: 0,
9140
+ initFilesStatus: null,
9141
+ message: null,
9142
+ manualCommitMessage: "",
9143
+ messageRequestedAt: 0,
9144
+ branchName: "",
9145
+ branchNameRequestedAt: 0,
9146
+ actionsDone: createGitWorkflowActionsDone(),
9147
+ prMode: false,
9148
+ prBranch: "",
9149
+ pr: null,
9150
+ prRequestedAt: 0,
9151
+ }, { tabId });
9152
+ }
9153
+
9154
+ async function runGitInitRepository(tabId = gitWorkflowActionTabId()) {
9155
+ const tabContext = activeTabContext(tabId);
9156
+ const workflow = gitWorkflowForTab(tabId, { create: false });
9157
+ if (!workflow) return;
9158
+ const runId = workflow.runId;
9159
+ setGitWorkflow({ step: "initializingRepo", busy: true, error: "", output: "Running git init…" }, { tabId });
9160
+ try {
9161
+ const result = await gitWorkflowRequest("/api/git-workflow/init", { runId, tabId });
9162
+ if (!result) return;
9163
+ const stack = workflow.stack || readStoredGitInitStack();
9164
+ setGitWorkflow({ step: "initStack", busy: false, stack, ...gitWorkflowActionDonePatch(workflow, "init"), output: `${formatGitCommandResult(result)}\n\nRepository initialized. Next: choose the stack used for .gitignore generation.\n\nCurrent stack: ${gitInitStackDisplay(stack)}` }, { tabId });
9165
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
9166
+ } catch (error) {
9167
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "initRepo", { tabId });
9168
+ }
9169
+ }
9170
+
9171
+ function gitInitFilesResultSummary(result) {
9172
+ const readme = result?.readme?.created
9173
+ ? "Created README.md."
9174
+ : result?.readme?.exists
9175
+ ? "README.md already existed; staged without overwriting."
9176
+ : "README.md was checked.";
9177
+ const gitignore = result?.gitignore?.created
9178
+ ? `Created .gitignore${result.gitignore.source ? ` (${result.gitignore.source})` : ""}.`
9179
+ : result?.gitignore?.exists
9180
+ ? ".gitignore already existed; staged without overwriting."
9181
+ : ".gitignore was checked.";
9182
+ return [readme, gitignore].join("\n");
9183
+ }
9184
+
9185
+ function gitInitReadmePromptMessage({ stack = "", status = null, repoName = "" } = {}) {
9186
+ const cleanStack = cleanGitInitStack(stack);
9187
+ return [
9188
+ "Create a useful README.md for this new repository.",
9189
+ "Write or update only README.md in the current repository root. Do not commit, push, or run git add.",
9190
+ repoName ? `Repository name: ${repoName}` : "",
9191
+ cleanStack
9192
+ ? `User-provided stack: ${cleanStack}`
9193
+ : "No user stack was provided. Inspect the codebase (package manifests, lockfiles, framework configs, file extensions, build files) and infer the project purpose/stack.",
9194
+ !cleanStack && status?.detectedStack ? `Web UI detected stack hint: ${status.detectedStack}` : "",
9195
+ "Include a clear title, short description, basic setup/install instructions, common development commands, and usage notes that match the detected repository.",
9196
+ "If the repository is mostly empty or unclear, create a concise README with placeholders/TODOs rather than inventing unsupported project details.",
9197
+ ].filter(Boolean).join("\n");
9198
+ }
9199
+
9200
+ function gitInitGitignorePromptMessage({ stack = "", status = null, repoName = "" } = {}) {
9201
+ const cleanStack = cleanGitInitStack(stack);
9202
+ return [
9203
+ "Create a practical .gitignore file for this repository initialization.",
9204
+ "Write or update only .gitignore in the current repository root. Do not commit, push, or run git add.",
9205
+ repoName ? `Repository name: ${repoName}` : "",
9206
+ cleanStack
9207
+ ? `User-provided stack: ${cleanStack}`
9208
+ : "No user stack was provided. Inspect the codebase (package manifests, lockfiles, framework configs, file extensions, build files) and infer the stack.",
9209
+ !cleanStack && status?.detectedStack ? `Web UI detected stack hint: ${status.detectedStack}` : "",
9210
+ "If the stack is still unclear, use sane defaults for OS/editor files, local env/secrets, dependency folders, build outputs, logs, caches, coverage, and temporary files.",
9211
+ "Keep useful generated/project files trackable; do not ignore source files or lockfiles by default.",
9212
+ ].filter(Boolean).join("\n");
9213
+ }
9214
+
9215
+ async function promptGitInitReadme(status, { runId, tabId }) {
9216
+ const tabContext = activeTabContext(tabId);
9217
+ const targetTab = tabs.find((tab) => tab.id === tabId);
9218
+ const targetBusy = tabId === activeTabId ? !!currentState?.isStreaming : activityForTab(targetTab).isWorking;
9219
+ if (targetBusy) {
9220
+ failGitWorkflow(new Error("Pi is currently running. Wait for it to finish or abort before filling out README.md."), "readme", { tabId });
9221
+ return;
9222
+ }
9223
+ const workflow = gitWorkflowForTab(tabId, { create: false });
9224
+ if (!workflow) return;
9225
+ const requestedAt = Date.now();
9226
+ const repoName = workflow.repoName || defaultGitInitRepoName();
9227
+ const stack = workflow.stack || "";
9228
+ setGitWorkflow({
9229
+ step: "readmeGenerating",
9230
+ busy: true,
9231
+ error: "",
9232
+ readmeRequestedAt: requestedAt,
9233
+ initFilesStatus: status,
9234
+ output: `${gitInitFilesStatusSummary(status)}\n\nSending README.md fill-out request to Pi.\n\nStack: ${gitInitStackDisplay(stack)}\nCancel will request Pi abort.`,
9235
+ }, { tabId });
9236
+ if (isCurrentTabContext(tabContext)) setRunIndicatorActivity("Sending README.md fill-out request to Pi…");
9237
+ try {
9238
+ await api("/api/prompt", { method: "POST", body: { message: gitInitReadmePromptMessage({ stack, status, repoName }) }, tabId });
9239
+ if (!isCurrentGitWorkflowRun(runId, tabId)) return;
9240
+ appendGitWorkflowOutput("README.md request accepted. Waiting for agent_end, then README.md/.gitignore will be checked and staged. If Pi does not create README.md, Web UI will use a minimal fallback README.", { tabId });
9241
+ if (isCurrentTabContext(tabContext)) scheduleRefreshState(120, tabContext);
9242
+ setTimeout(() => {
9243
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
9244
+ const targetStillBusy = tabId === activeTabId && currentState?.isStreaming;
9245
+ if (isCurrentGitWorkflowRun(runId, tabId) && currentWorkflow?.step === "readmeGenerating" && !targetStillBusy) {
9246
+ prepareGitInitFiles({ afterReadmePrompt: true, runId, tabId });
9247
+ }
9248
+ }, 2500);
9249
+ } catch (error) {
9250
+ if (isCurrentGitWorkflowRun(runId, tabId)) {
9251
+ if (isCurrentTabContext(tabContext)) clearRunIndicatorActivity();
9252
+ failGitWorkflow(error, "readme", { tabId });
9253
+ }
9254
+ }
9255
+ }
9256
+
9257
+ async function promptGitInitGitignore(status, { runId, tabId }) {
9258
+ const tabContext = activeTabContext(tabId);
9259
+ const targetTab = tabs.find((tab) => tab.id === tabId);
9260
+ const targetBusy = tabId === activeTabId ? !!currentState?.isStreaming : activityForTab(targetTab).isWorking;
9261
+ if (targetBusy) {
9262
+ failGitWorkflow(new Error("Pi is currently running. Wait for it to finish or abort before generating .gitignore."), "readme", { tabId });
9263
+ return;
9264
+ }
9265
+ const workflow = gitWorkflowForTab(tabId, { create: false });
9266
+ if (!workflow) return;
9267
+ const requestedAt = Date.now();
9268
+ const repoName = workflow.repoName || defaultGitInitRepoName();
9269
+ const stack = workflow.stack || "";
9270
+ setGitWorkflow({
9271
+ step: "gitignoreGenerating",
9272
+ busy: true,
9273
+ error: "",
9274
+ gitignoreRequestedAt: requestedAt,
9275
+ initFilesStatus: status,
9276
+ output: `${gitInitFilesStatusSummary(status)}\n\nSending .gitignore generation request to Pi.\n\nStack: ${gitInitStackDisplay(stack)}\nCancel will request Pi abort.`,
9277
+ }, { tabId });
9278
+ if (isCurrentTabContext(tabContext)) setRunIndicatorActivity("Sending .gitignore generation request to Pi…");
9279
+ try {
9280
+ await api("/api/prompt", { method: "POST", body: { message: gitInitGitignorePromptMessage({ stack, status, repoName }) }, tabId });
9281
+ if (!isCurrentGitWorkflowRun(runId, tabId)) return;
9282
+ appendGitWorkflowOutput(".gitignore request accepted. Waiting for agent_end, then README.md/.gitignore will be staged. If Pi does not create .gitignore, Web UI will use sane fallback patterns.", { tabId });
9283
+ if (isCurrentTabContext(tabContext)) scheduleRefreshState(120, tabContext);
9284
+ setTimeout(() => {
9285
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
9286
+ const targetStillBusy = tabId === activeTabId && currentState?.isStreaming;
9287
+ if (isCurrentGitWorkflowRun(runId, tabId) && currentWorkflow?.step === "gitignoreGenerating" && !targetStillBusy) {
9288
+ prepareGitInitFiles({ afterGitignorePrompt: true, runId, tabId });
9289
+ }
9290
+ }, 2500);
9291
+ } catch (error) {
9292
+ if (isCurrentGitWorkflowRun(runId, tabId)) {
9293
+ if (isCurrentTabContext(tabContext)) clearRunIndicatorActivity();
9294
+ failGitWorkflow(error, "readme", { tabId });
9295
+ }
9296
+ }
9297
+ }
9298
+
9299
+ async function prepareGitInitFiles({ afterReadmePrompt = false, afterGitignorePrompt = false, runId, tabId = gitWorkflowActionTabId() } = {}) {
9300
+ const tabContext = activeTabContext(tabId);
9301
+ const workflow = gitWorkflowForTab(tabId, { create: false });
9302
+ if (!workflow) return;
9303
+ const expectedRunId = runId ?? workflow.runId;
9304
+ const repoName = workflow.repoName || defaultGitInitRepoName();
9305
+ const stack = cleanGitInitStack(workflow.stack || readStoredGitInitStack());
9306
+ setGitWorkflow({ step: "readmeCreating", busy: true, error: "", output: afterReadmePrompt || afterGitignorePrompt ? "Checking generated files, preparing README.md/.gitignore, and staging files…" : "Checking whether README.md and .gitignore already exist…" }, { tabId });
9307
+ try {
9308
+ const status = await gitWorkflowRequest("/api/git-workflow/init-files-status", { method: "GET", runId: expectedRunId, tabId });
9309
+ if (!status) return;
9310
+ setGitWorkflow({ initFilesStatus: status, output: `${gitInitFilesStatusSummary(status)}\n\nStack: ${gitInitStackDisplay(stack)}` }, { tabId });
9311
+ if (!status.readmeExists && !afterReadmePrompt) {
9312
+ await promptGitInitReadme(status, { runId: expectedRunId, tabId });
9313
+ return;
9314
+ }
9315
+ if (!status.gitignoreExists && !afterGitignorePrompt) {
9316
+ await promptGitInitGitignore(status, { runId: expectedRunId, tabId });
9317
+ return;
9318
+ }
9319
+ const result = await gitWorkflowRequest("/api/git-workflow/readme", { body: { repoName, stack }, runId: expectedRunId, tabId });
9320
+ if (!result) return;
9321
+ const current = gitWorkflowForTab(tabId, { create: false }) || workflow;
9322
+ setGitWorkflow({
9323
+ step: "initialCommit",
9324
+ busy: false,
9325
+ repoName,
9326
+ stack,
9327
+ initFilesStatus: status,
9328
+ actionsDone: createGitWorkflowActionsDone({ ...current.actionsDone, readme: true, gitignore: true }),
9329
+ output: `${gitInitFilesStatusSummary(status)}\n\n${formatGitCommandResult(result)}\n\n${gitInitFilesResultSummary(result)}\nNext: commit the initial files.`,
9330
+ }, { tabId });
9331
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
9332
+ } catch (error) {
9333
+ if (isCurrentGitWorkflowRun(expectedRunId, tabId)) failGitWorkflow(error, "readme", { tabId });
9334
+ }
9335
+ }
9336
+
9337
+ async function commitGitInitialReadme(tabId = gitWorkflowActionTabId()) {
9338
+ const tabContext = activeTabContext(tabId);
9339
+ const workflow = gitWorkflowForTab(tabId, { create: false });
9340
+ if (!workflow) return;
9341
+ const runId = workflow.runId;
9342
+ setGitWorkflow({ step: "initialCommitting", busy: true, error: "", output: "Running git commit -m \"Initial commit\"…" }, { tabId });
9343
+ try {
9344
+ const result = await gitWorkflowRequest("/api/git-workflow/initial-commit", { runId, tabId });
9345
+ if (!result) return;
9346
+ setGitWorkflow({ step: "mainBranch", busy: false, ...gitWorkflowActionDonePatch(workflow, "commit"), output: `${formatGitCommandResult(result)}\n\nInitial commit created. Next: rename the branch to main.` }, { tabId });
9347
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
9348
+ } catch (error) {
9349
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "initialCommit", { tabId });
9350
+ }
9351
+ }
9352
+
9353
+ async function branchGitInitMain(tabId = gitWorkflowActionTabId()) {
9354
+ const tabContext = activeTabContext(tabId);
9355
+ const workflow = gitWorkflowForTab(tabId, { create: false });
9356
+ if (!workflow) return;
9357
+ const runId = workflow.runId;
9358
+ setGitWorkflow({ step: "mainBranching", busy: true, error: "", output: "Running git branch -M main…" }, { tabId });
9359
+ try {
9360
+ const result = await gitWorkflowRequest("/api/git-workflow/main-branch", { runId, tabId });
9361
+ if (!result) return;
9362
+ setGitWorkflow({ step: "remote", busy: false, ...gitWorkflowActionDonePatch(workflow, "branch"), output: `${formatGitCommandResult(result)}\n\nBranch is main. Next: add origin.\n\n${gitInitWorkflowSetupSummary(gitWorkflowForTab(tabId, { create: false }) || workflow)}` }, { tabId });
9363
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
9364
+ } catch (error) {
9365
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "mainBranch", { tabId });
9366
+ }
9367
+ }
9368
+
9369
+ async function addGitInitRemote(tabId = gitWorkflowActionTabId()) {
9370
+ const tabContext = activeTabContext(tabId);
9371
+ const workflow = gitWorkflowForTab(tabId, { create: false });
9372
+ if (!workflow) return;
9373
+ let details;
9374
+ try {
9375
+ details = ensureGitInitRemoteDetails(tabId);
9376
+ } catch (error) {
9377
+ addEvent(error.message || String(error), "error");
9378
+ return;
9379
+ }
9380
+ if (!details) {
9381
+ setGitWorkflow({ step: "remote", busy: false, output: `Origin setup cancelled.\n\n${gitInitWorkflowSetupSummary(workflow)}` }, { tabId });
9382
+ return;
9383
+ }
9384
+ const runId = workflow.runId;
9385
+ setGitWorkflow({ step: "remoteAdding", busy: true, error: "", githubUsername: details.username, repoName: details.repoName, remoteUrl: details.remoteUrl, output: `Running git remote add origin ${details.remoteUrl}…` }, { tabId });
9386
+ try {
9387
+ const result = await gitWorkflowRequest("/api/git-workflow/remote", { body: details, runId, tabId });
9388
+ if (!result) return;
9389
+ setGitWorkflow({ step: "initialPush", busy: false, ...gitWorkflowActionDonePatch(workflow, "remote"), output: `${formatGitCommandResult(result)}\n\nOrigin added: ${result.remoteUrl || details.remoteUrl}\nNext: git push -u origin main.` }, { tabId });
9390
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
9391
+ } catch (error) {
9392
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "remote", { tabId });
9393
+ }
9394
+ }
9395
+
9396
+ async function pushGitInitWorkflow(tabId = gitWorkflowActionTabId()) {
9397
+ const tabContext = activeTabContext(tabId);
9398
+ const workflow = gitWorkflowForTab(tabId, { create: false });
9399
+ if (!workflow) return;
9400
+ const runId = workflow.runId;
9401
+ setGitWorkflow({ step: "initialPushing", busy: true, error: "", output: "Running git push -u origin main…" }, { tabId });
9402
+ try {
9403
+ const result = await gitWorkflowRequest("/api/git-workflow/init-push", { runId, tabId });
9404
+ if (!result) return;
9405
+ setGitWorkflow({ step: "done", busy: false, ...gitWorkflowActionDonePatch(workflow, "push"), output: `${formatGitCommandResult(result)}\n\nInitial repository pushed to origin/main.` }, { tabId });
9406
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
9407
+ } catch (error) {
9408
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "initialPush", { tabId });
9409
+ }
9410
+ }
9411
+
8085
9412
  async function cancelGitWorkflow(tabId = gitWorkflowActionTabId()) {
8086
9413
  const tabContext = activeTabContext(tabId);
8087
9414
  const workflow = gitWorkflowForTab(tabId, { create: false });
8088
9415
  if (!workflow?.active) return;
8089
- const shouldAbortPi = workflow.step === "generating" || workflow.step === "branchNaming" || workflow.step === "prGenerating";
9416
+ const shouldAbortPi = workflow.step === "generating" || workflow.step === "branchNaming" || workflow.step === "prGenerating" || workflow.step === "readmeGenerating" || workflow.step === "gitignoreGenerating";
8090
9417
  if (activeGitPrDialogResolve) resolveGitPrDialog(null);
8091
9418
  workflow.runId += 1;
8092
9419
  setGitWorkflow({ step: "cancelled", busy: false, error: "", output: `${workflow.output || ""}${workflow.output ? "\n\n" : ""}Cancelled by user.` }, { tabId });
@@ -8300,7 +9627,7 @@ async function createGitPrBranchWithSuggestion(suggestion, tabId = gitWorkflowAc
8300
9627
  try {
8301
9628
  const result = await gitWorkflowRequest("/api/git-workflow/branch", { body: { branch }, runId, tabId });
8302
9629
  if (!result) return;
8303
- setGitWorkflow({ step: "message", prMode: true, prBranch: result.branch || branch, branchName: result.branch || branch, busy: false, output: `${formatGitCommandResult(result)}\n\nCreated PR branch ${result.branch || branch}. Choose Commit short or Commit long to commit on this branch.` }, { tabId });
9630
+ setGitWorkflow({ step: "message", prMode: true, prBranch: result.branch || branch, branchName: result.branch || branch, busy: false, output: `${formatGitCommandResult(result)}\n\nCreated PR branch ${result.branch || branch}. Choose Commit short, Commit long, or Commit input to commit on this branch.` }, { tabId });
8304
9631
  } catch (error) {
8305
9632
  if (isCurrentGitWorkflowRun(runId, tabId)) {
8306
9633
  setGitWorkflow({ prMode: false, prBranch: "" }, { tabId });
@@ -8314,15 +9641,26 @@ async function commitGitWorkflow(variant, tabId = gitWorkflowActionTabId()) {
8314
9641
  const workflow = gitWorkflowForTab(tabId, { create: false });
8315
9642
  if (!workflow) return;
8316
9643
  const runId = workflow.runId;
8317
- setGitWorkflow({ step: "committing", busy: true, error: "", output: `${formatCommitMessagePreview(workflow.message)}\n\nRunning native ${variant} commit…` }, { tabId });
9644
+ const failureStep = variant === "input" && workflow.step === "generate" ? "generate" : "message";
9645
+ const inputMessage = variant === "input" ? String(workflow.manualCommitMessage || "").trim() : "";
9646
+ if (variant === "input" && !inputMessage) {
9647
+ failGitWorkflow(new Error("Type a commit message before using Commit input."), failureStep, { tabId });
9648
+ return;
9649
+ }
9650
+ const preview = variant === "input" ? formatInputCommitMessagePreview(inputMessage) : formatCommitMessagePreview(workflow.message);
9651
+ setGitWorkflow({ step: "committing", busy: true, error: "", output: `${preview}\n\nRunning native ${variant} commit…` }, { tabId });
8318
9652
  try {
8319
- const result = await gitWorkflowRequest("/api/git-workflow/commit", { body: { variant }, runId, tabId });
9653
+ const body = variant === "input" ? { variant, message: inputMessage } : { variant };
9654
+ const result = await gitWorkflowRequest("/api/git-workflow/commit", { body, runId, tabId });
8320
9655
  if (!result) return;
8321
9656
  const nextAction = workflow.prMode ? "Push and Create PR." : "git push.";
8322
- setGitWorkflow({ step: "push", busy: false, ...gitWorkflowActionDonePatch(workflow, "commit"), output: `${formatGitCommandResult(result)}\n\nCommit created. Next: ${nextAction}` }, { tabId });
9657
+ const donePatch = variant === "input"
9658
+ ? { actionsDone: createGitWorkflowActionsDone({ ...workflow.actionsDone, message: true, commit: true }) }
9659
+ : gitWorkflowActionDonePatch(workflow, "commit");
9660
+ setGitWorkflow({ step: "push", busy: false, ...donePatch, output: `${formatGitCommandResult(result)}\n\nCommit created. Next: ${nextAction}` }, { tabId });
8323
9661
  if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
8324
9662
  } catch (error) {
8325
- if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "message", { tabId });
9663
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, failureStep, { tabId });
8326
9664
  }
8327
9665
  }
8328
9666
 
@@ -8487,6 +9825,22 @@ function resumeGitWorkflowForActiveTab(tabContext = activeTabContext()) {
8487
9825
  }
8488
9826
  loadGitWorkflowPr({ requireFresh: true, retries: 3, runId: gitWorkflow.runId, tabId: workflowTabId });
8489
9827
  }
9828
+ if (workflowTabId === tabContext.tabId && gitWorkflow.active && gitWorkflow.step === "readmeGenerating" && !currentState?.isStreaming) {
9829
+ const retryDelayMs = Math.max(0, 2500 - (Date.now() - (gitWorkflow.readmeRequestedAt || 0)));
9830
+ if (retryDelayMs > 0) {
9831
+ setTimeout(() => resumeGitWorkflowForActiveTab(tabContext), retryDelayMs);
9832
+ return;
9833
+ }
9834
+ prepareGitInitFiles({ afterReadmePrompt: true, runId: gitWorkflow.runId, tabId: workflowTabId });
9835
+ }
9836
+ if (workflowTabId === tabContext.tabId && gitWorkflow.active && gitWorkflow.step === "gitignoreGenerating" && !currentState?.isStreaming) {
9837
+ const retryDelayMs = Math.max(0, 2500 - (Date.now() - (gitWorkflow.gitignoreRequestedAt || 0)));
9838
+ if (retryDelayMs > 0) {
9839
+ setTimeout(() => resumeGitWorkflowForActiveTab(tabContext), retryDelayMs);
9840
+ return;
9841
+ }
9842
+ prepareGitInitFiles({ afterGitignorePrompt: true, runId: gitWorkflow.runId, tabId: workflowTabId });
9843
+ }
8490
9844
  }
8491
9845
 
8492
9846
  function normalizeQueuedMessages(event) {
@@ -9222,6 +10576,64 @@ function renderMarkdown(block, text) {
9222
10576
  renderMarkdownInto(block, text);
9223
10577
  }
9224
10578
 
10579
+ /**
10580
+ * Incremental renderer for streaming assistant markdown. The block-based
10581
+ * parser in renderMarkdownInto only ever closes a block at a blank line
10582
+ * outside a code fence, so everything before the last such boundary is
10583
+ * stable: it is parsed exactly once and its DOM is never rebuilt. Only the
10584
+ * open tail is re-parsed per streaming tick, keeping per-tick cost flat
10585
+ * instead of O(message length).
10586
+ */
10587
+ let streamMarkdownState = null;
10588
+
10589
+ function streamingMarkdownStableBoundary(text) {
10590
+ const lines = text.split("\n");
10591
+ let inFence = false;
10592
+ let boundary = 0;
10593
+ let offset = 0;
10594
+ // Exclude the final line: it may still be streaming in.
10595
+ for (let index = 0; index < lines.length - 1; index += 1) {
10596
+ const line = lines[index];
10597
+ if (inFence) {
10598
+ if (/^\s*```\s*$/.test(line)) inFence = false;
10599
+ } else if (/^\s*```\s*[\w.+-]*\s*$/.test(line)) {
10600
+ inFence = true;
10601
+ }
10602
+ offset += line.length + 1;
10603
+ if (!inFence && !line.trim()) boundary = offset;
10604
+ }
10605
+ return boundary;
10606
+ }
10607
+
10608
+ function renderStreamingMarkdown(block, text) {
10609
+ let state = streamMarkdownState;
10610
+ if (!state || state.block !== block) {
10611
+ block.replaceChildren();
10612
+ state = streamMarkdownState = { block, stableText: "", tailNodes: [] };
10613
+ }
10614
+ if (!text.startsWith(state.stableText)) {
10615
+ // Earlier content changed retroactively (e.g. todo-progress stripping);
10616
+ // fall back to a full re-render for correctness.
10617
+ block.replaceChildren();
10618
+ state.stableText = "";
10619
+ state.tailNodes = [];
10620
+ }
10621
+ for (const node of state.tailNodes) node.remove();
10622
+ state.tailNodes = [];
10623
+ const boundary = streamingMarkdownStableBoundary(text);
10624
+ if (boundary > state.stableText.length) {
10625
+ renderMarkdownInto(block, text.slice(state.stableText.length, boundary));
10626
+ state.stableText = text.slice(0, boundary);
10627
+ }
10628
+ const tail = text.slice(state.stableText.length);
10629
+ if (tail.trim()) {
10630
+ const fragment = document.createDocumentFragment();
10631
+ renderMarkdownInto(fragment, tail);
10632
+ state.tailNodes = [...fragment.childNodes];
10633
+ block.append(fragment);
10634
+ }
10635
+ }
10636
+
9225
10637
  function appendImage(parent, part) {
9226
10638
  const wrapper = make("div", "image-block");
9227
10639
  const img = document.createElement("img");
@@ -9852,6 +11264,7 @@ function stickyUserPromptViewportGap() {
9852
11264
 
9853
11265
  function resetChatOutput() {
9854
11266
  liveToolCards.clear();
11267
+ renderedTranscriptState = { epoch: "", entries: [] };
9855
11268
  const preservedNodes = [];
9856
11269
  if (elements.stickyUserPromptButton) preservedNodes.push(elements.stickyUserPromptButton);
9857
11270
  if (runIndicatorBubble?.parentElement === elements.chat) preservedNodes.push(runIndicatorBubble);
@@ -10536,9 +11949,12 @@ function jumpToStickyUserPrompt() {
10536
11949
  requestAnimationFrame(updateStickyUserPromptButton);
10537
11950
  }
10538
11951
 
10539
- function appendMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false, reusableToolCards = null } = {}) {
11952
+ function appendMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false, reusableToolCards = null, itemKey = "" } = {}) {
10540
11953
  const reused = reuseToolExecutionBubble(reusableToolCards, message, { streaming, messageIndex, transient });
10541
- if (reused) return reused;
11954
+ if (reused) {
11955
+ if (itemKey) reused.bubble.dataset.itemKey = itemKey;
11956
+ return reused;
11957
+ }
10542
11958
  const role = String(message.role || "message");
10543
11959
  const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
10544
11960
  const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}${animateEntry ? " action-enter" : ""}`);
@@ -10547,6 +11963,7 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
10547
11963
  bubble.dataset.messageIndex = String(messageIndex);
10548
11964
  if (role === "user") bubble.dataset.userPrompt = "true";
10549
11965
  }
11966
+ if (itemKey) bubble.dataset.itemKey = itemKey;
10550
11967
  const isCollapsibleOutput = !streaming && (message.role === "toolResult" || message.role === "bashExecution" || message.role === "compactionSummary");
10551
11968
 
10552
11969
  const hideMessageHeader = message.role === "assistant" && !isCollapsibleOutput;
@@ -10598,9 +12015,9 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
10598
12015
  return { bubble, body };
10599
12016
  }
10600
12017
 
10601
- function appendTranscriptMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false, reusableToolCards = null } = {}) {
12018
+ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false, reusableToolCards = null, itemKey = "" } = {}) {
10602
12019
  if (streaming || transient || message?.role !== "assistant") {
10603
- return appendMessage(message, { streaming, messageIndex, transient, animateEntry, reusableToolCards });
12020
+ return appendMessage(message, { streaming, messageIndex, transient, animateEntry, reusableToolCards, itemKey });
10604
12021
  }
10605
12022
 
10606
12023
  let finalOutput = null;
@@ -10630,6 +12047,7 @@ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1
10630
12047
  transient: false,
10631
12048
  animateEntry: animateEntry && isActionTranscriptMessage(transcriptMessage),
10632
12049
  reusableToolCards,
12050
+ itemKey,
10633
12051
  });
10634
12052
  if (transcriptMessage.role === "assistant") finalOutput = created;
10635
12053
  });
@@ -10901,20 +12319,169 @@ function orderedTranscriptItems() {
10901
12319
  return items.sort((a, b) => a.timestampMs - b.timestampMs || a.order - b.order);
10902
12320
  }
10903
12321
 
10904
- function renderAllMessages({ preserveScroll = false } = {}) {
12322
+ /**
12323
+ * Keyed transcript reconciliation state. Each transcript item gets a stable
12324
+ * key plus a cheap content signature; renders reuse the longest common
12325
+ * prefix of unchanged items and only rebuild DOM from the first divergence
12326
+ * (typically the last one or two items), instead of rebuilding every bubble.
12327
+ */
12328
+ let renderedTranscriptState = { epoch: "", entries: [] };
12329
+
12330
+ function transcriptRenderEpoch() {
12331
+ return `${activeTabId || ""}|${thinkingOutputVisible ? 1 : 0}`;
12332
+ }
12333
+
12334
+ function transcriptItemKey(item) {
12335
+ if (!item.transient) return `m:${item.messageIndex}`;
12336
+ if (item.messageIndex >= 0) return `t:${item.messageIndex}`;
12337
+ return `live:${item.message?.toolCallId || `o${item.order}`}`;
12338
+ }
12339
+
12340
+ function safeJsonLength(value) {
12341
+ if (value === undefined || value === null) return 0;
12342
+ try {
12343
+ return JSON.stringify(value).length;
12344
+ } catch {
12345
+ return -1;
12346
+ }
12347
+ }
12348
+
12349
+ function contentSignature(content) {
12350
+ if (content === undefined || content === null) return "";
12351
+ if (typeof content === "string") return `s${content.length}`;
12352
+ if (!Array.isArray(content)) return `o${safeJsonLength(content)}`;
12353
+ let sig = `a${content.length}`;
12354
+ for (const part of content) {
12355
+ if (!part || typeof part !== "object") {
12356
+ sig += ";x";
12357
+ continue;
12358
+ }
12359
+ const text = typeof part.text === "string" ? part.text
12360
+ : typeof part.thinking === "string" ? part.thinking
12361
+ : typeof part.data === "string" ? part.data
12362
+ : typeof part.content === "string" ? part.content : "";
12363
+ sig += `;${part.type || "?"}:${text.length}:${part.toolCallId || part.id || ""}`;
12364
+ }
12365
+ return sig;
12366
+ }
12367
+
12368
+ function toolCallLiveStateSignature(toolCallId) {
12369
+ if (!toolCallId) return "";
12370
+ const id = String(toolCallId);
12371
+ const result = toolResultForCallId(id);
12372
+ const run = liveToolRuns.get(id);
12373
+ let sig = "";
12374
+ if (result) sig += `r:${contentSignature(result.content)}:${result.isError ? "e" : ""}`;
12375
+ if (run) sig += `|l:${run.isPartial ? "p" : ""}${run.isError ? "e" : ""}:${run.endedAt || ""}:${contentSignature(run.result?.content)}:${safeJsonLength(run.arguments)}`;
12376
+ return sig;
12377
+ }
12378
+
12379
+ function actionFeedbackSignature(messageIndex) {
12380
+ const map = actionFeedbackByTab.get(activeTabId);
12381
+ if (!map?.size) return "";
12382
+ let sig = "";
12383
+ for (const entry of map.values()) {
12384
+ if (entry.messageIndex === messageIndex) sig += `${entry.key}=${entry.reaction};`;
12385
+ }
12386
+ return sig;
12387
+ }
12388
+
12389
+ // Cache of the message-object-derived part of an item signature. Session
12390
+ // messages are append-only and treated as immutable, and delta merges keep
12391
+ // previous object identities, so cached entries stay valid until a full
12392
+ // fetch replaces the array (when the cache simply rebuilds).
12393
+ const messageStaticSignatureCache = new WeakMap();
12394
+
12395
+ function messageStaticSignature(message) {
12396
+ const cacheable = message && typeof message === "object";
12397
+ if (cacheable) {
12398
+ const cached = messageStaticSignatureCache.get(message);
12399
+ if (cached !== undefined) return cached;
12400
+ }
12401
+ const sig = [
12402
+ message.role || "",
12403
+ String(message.timestamp || ""),
12404
+ message.level || "",
12405
+ String(message.title || ""),
12406
+ contentSignature(message.content),
12407
+ typeof message.command === "string" ? `c${message.command.length}` : "",
12408
+ typeof message.output === "string" ? `out${message.output.length}` : "",
12409
+ typeof message.summary === "string" ? `sum${message.summary.length}` : "",
12410
+ typeof message.thinking === "string" ? `th${message.thinking.length}` : "",
12411
+ ];
12412
+ if (message.role === "toolExecution") {
12413
+ sig.push(
12414
+ message.live ? "live" : "",
12415
+ message.isPartial ? "p" : "",
12416
+ message.isError ? "e" : "",
12417
+ String(message.startedAt || ""),
12418
+ String(message.endedAt || ""),
12419
+ contentSignature(message.result?.content),
12420
+ String(safeJsonLength(message.arguments)),
12421
+ );
12422
+ }
12423
+ const joined = sig.join("|");
12424
+ if (cacheable) messageStaticSignatureCache.set(message, joined);
12425
+ return joined;
12426
+ }
12427
+
12428
+ function transcriptItemSignature(item) {
12429
+ const message = item.message || {};
12430
+ const sig = [messageStaticSignature(message)];
12431
+ if (message.role === "toolExecution") sig.push(toolCallLiveStateSignature(message.toolCallId));
12432
+ if (message.role === "assistant" && Array.isArray(message.content)) {
12433
+ for (const part of message.content) {
12434
+ if (isAssistantToolCallPart(part)) sig.push(toolCallLiveStateSignature(assistantToolCallId(part)));
12435
+ }
12436
+ }
12437
+ if (!item.transient && item.messageIndex >= 0) sig.push(actionFeedbackSignature(item.messageIndex));
12438
+ return sig.join("|");
12439
+ }
12440
+
12441
+ function removeChatBubblesAfterPrefix(keptKeys) {
12442
+ for (const child of [...elements.chat.children]) {
12443
+ if (child === elements.stickyUserPromptButton || child === runIndicatorBubble) continue;
12444
+ const key = child.dataset?.itemKey;
12445
+ if (key && keptKeys.has(key)) continue;
12446
+ child.remove();
12447
+ }
12448
+ }
12449
+
12450
+ function pruneDisconnectedLiveToolCards() {
12451
+ for (const [id, bubble] of liveToolCards) {
12452
+ if (!bubble.isConnected) liveToolCards.delete(id);
12453
+ }
12454
+ }
12455
+
12456
+ function renderAllMessages({ preserveScroll = false, forceRebuild = false } = {}) {
10905
12457
  const shouldFollow = !preserveScroll && (autoFollowChat || isChatNearBottom());
10906
12458
  const previousScrollTop = elements.chat.scrollTop;
10907
- const reusableToolCards = captureReusableToolCards();
10908
- resetChatOutput();
10909
12459
  const transcriptItems = orderedTranscriptItems();
10910
- for (const item of transcriptItems) {
10911
- appendTranscriptMessage(item.message, {
10912
- messageIndex: item.messageIndex,
10913
- transient: item.transient,
10914
- animateEntry: shouldAnimateActionEntry(item),
12460
+ const epoch = transcriptRenderEpoch();
12461
+ const nextEntries = transcriptItems.map((item) => ({ item, key: transcriptItemKey(item), sig: transcriptItemSignature(item) }));
12462
+ let prefixLength = 0;
12463
+ if (!forceRebuild && epoch === renderedTranscriptState.epoch) {
12464
+ const previous = renderedTranscriptState.entries;
12465
+ const limit = Math.min(previous.length, nextEntries.length);
12466
+ while (prefixLength < limit && previous[prefixLength].key === nextEntries[prefixLength].key && previous[prefixLength].sig === nextEntries[prefixLength].sig) {
12467
+ prefixLength += 1;
12468
+ }
12469
+ }
12470
+ const reusableToolCards = captureReusableToolCards();
12471
+ if (prefixLength === 0) resetChatOutput();
12472
+ else removeChatBubblesAfterPrefix(new Set(nextEntries.slice(0, prefixLength).map((entry) => entry.key)));
12473
+ for (let index = prefixLength; index < nextEntries.length; index += 1) {
12474
+ const entry = nextEntries[index];
12475
+ appendTranscriptMessage(entry.item.message, {
12476
+ messageIndex: entry.item.messageIndex,
12477
+ transient: entry.item.transient,
12478
+ animateEntry: shouldAnimateActionEntry(entry.item),
10915
12479
  reusableToolCards,
12480
+ itemKey: entry.key,
10916
12481
  });
10917
12482
  }
12483
+ pruneDisconnectedLiveToolCards();
12484
+ renderedTranscriptState = { epoch, entries: nextEntries.map(({ key, sig }) => ({ key, sig })) };
10918
12485
  rememberActionEntries(transcriptItems);
10919
12486
  applyToolOutputExpansionToDom();
10920
12487
  renderRunIndicator({ scroll: false });
@@ -11319,6 +12886,17 @@ function renderOptionalFeaturePanel() {
11319
12886
  const packageLine = make("code", "optional-feature-package", feature.packageName);
11320
12887
  main.append(title, detail, description, packageLine);
11321
12888
 
12889
+ const actions = make("div", "optional-feature-actions");
12890
+ if (feature.id === "gitFooterStatus") {
12891
+ const setup = make("button", "optional-feature-action setup", "git-footer-status-setup");
12892
+ setup.type = "button";
12893
+ setup.title = GIT_FOOTER_STATUS_SETUP_TOOLTIP;
12894
+ setup.dataset.tooltip = GIT_FOOTER_STATUS_SETUP_TOOLTIP;
12895
+ setup.disabled = installing;
12896
+ setup.addEventListener("click", () => configureGitFooterStatusSetup({ force: true }));
12897
+ actions.append(setup);
12898
+ }
12899
+
11322
12900
  const action = make("button", "optional-feature-action");
11323
12901
  action.type = "button";
11324
12902
  action.disabled = installing;
@@ -11332,8 +12910,9 @@ function renderOptionalFeaturePanel() {
11332
12910
  action.classList.add("install");
11333
12911
  action.addEventListener("click", () => installOptionalFeature(feature.id));
11334
12912
  }
12913
+ actions.append(action);
11335
12914
 
11336
- row.append(main, action);
12915
+ row.append(main, actions);
11337
12916
  elements.optionalFeaturesBox.append(row);
11338
12917
  }
11339
12918
  }
@@ -11373,6 +12952,16 @@ function renderOptionalFeatureControls() {
11373
12952
  );
11374
12953
  if (!hasNativeCommandMenu && nativeCommandMenuOpen) setNativeCommandMenuOpen(false);
11375
12954
 
12955
+ const hasStatsCommand = isOptionalFeatureEnabled("statsCommand");
12956
+ if (elements.optionsStatsButton) {
12957
+ elements.optionsStatsButton.hidden = !hasStatsCommand;
12958
+ setOptionalControlState(
12959
+ elements.optionsStatsButton,
12960
+ hasStatsCommand,
12961
+ optionalFeatureUnavailableMessage("statsCommand"),
12962
+ );
12963
+ }
12964
+
11376
12965
  renderOptionalFeaturePanel();
11377
12966
  }
11378
12967
 
@@ -12472,7 +14061,7 @@ function renderStreamingAssistantText() {
12472
14061
  const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
12473
14062
  if (assistantText) {
12474
14063
  ensureStreamBubble();
12475
- renderMarkdown(streamText, assistantText);
14064
+ renderStreamingMarkdown(streamText, assistantText);
12476
14065
  } else {
12477
14066
  scheduleStreamBubbleHide();
12478
14067
  }
@@ -12525,6 +14114,7 @@ function resetStreamBubble() {
12525
14114
  streamBubble = null;
12526
14115
  streamText = null;
12527
14116
  streamRawText = "";
14117
+ streamMarkdownState = null;
12528
14118
  streamBubbleVisibleSince = 0;
12529
14119
  streamToolCallSeen = false;
12530
14120
  streamThinkingBubble = null;
@@ -12776,11 +14366,53 @@ async function refreshFooterData(tabContext = activeTabContext()) {
12776
14366
  await Promise.allSettled([refreshStats(tabContext), refreshWorkspace(tabContext)]);
12777
14367
  }
12778
14368
 
14369
+ // Session key of the last applied transcript fetch; deltas are only
14370
+ // attempted while the tab+session is unchanged.
14371
+ let latestMessagesSessionKey = "";
14372
+
14373
+ function messagesLookEqual(a, b) {
14374
+ return !!a && !!b && a.role === b.role && String(a.timestamp || "") === String(b.timestamp || "")
14375
+ && contentSignature(a.content) === contentSignature(b.content);
14376
+ }
14377
+
14378
+ /**
14379
+ * Merge a /api/messages?since= delta into the previous transcript. Returns
14380
+ * null whenever the delta cannot be applied safely — history shrank
14381
+ * (compaction), counts are inconsistent, or the one-message overlap no
14382
+ * longer matches (fork/resume/retroactive edit) — in which case the caller
14383
+ * falls back to a full fetch. Merged arrays keep previous message object
14384
+ * identities, which keeps the WeakMap signature cache hot.
14385
+ */
14386
+ function mergeMessagesDelta(previous, data) {
14387
+ if (!data || !Array.isArray(data.messages)) return null;
14388
+ const since = Number(data.since);
14389
+ const totalCount = Number(data.totalCount);
14390
+ if (!Number.isInteger(since) || !Number.isInteger(totalCount)) return null;
14391
+ if (since > previous.length || totalCount < previous.length) return null;
14392
+ if (totalCount !== since + data.messages.length) return null;
14393
+ if (since < previous.length && !messagesLookEqual(previous[since], data.messages[0])) return null;
14394
+ return previous.slice(0, since).concat(data.messages);
14395
+ }
14396
+
12779
14397
  async function refreshMessages(tabContext = activeTabContext()) {
12780
14398
  if (!tabContext.tabId) return;
12781
- const response = await api("/api/messages", { tabId: tabContext.tabId });
12782
- if (!isCurrentTabContext(tabContext)) return;
12783
- latestMessages = response.data?.messages || [];
14399
+ const previousMessages = latestMessages;
14400
+ const sessionKey = `${tabContext.tabId}|${currentState?.sessionId || ""}`;
14401
+ let nextMessages = null;
14402
+ if (previousMessages.length > 1 && sessionKey === latestMessagesSessionKey) {
14403
+ // Delta fetch with a one-message overlap: the last known message is
14404
+ // re-requested so retroactive changes are detected via mergeMessagesDelta.
14405
+ const response = await api(`/api/messages?since=${previousMessages.length - 1}`, { tabId: tabContext.tabId });
14406
+ if (!isCurrentTabContext(tabContext)) return;
14407
+ nextMessages = mergeMessagesDelta(previousMessages, response.data);
14408
+ }
14409
+ if (!nextMessages) {
14410
+ const response = await api("/api/messages", { tabId: tabContext.tabId });
14411
+ if (!isCurrentTabContext(tabContext)) return;
14412
+ nextMessages = response.data?.messages || [];
14413
+ }
14414
+ latestMessages = nextMessages;
14415
+ latestMessagesSessionKey = sessionKey;
12784
14416
  const preserveLiveStream = liveStreamRenderActive();
12785
14417
  if (!preserveLiveStream) resetStreamBubble();
12786
14418
  renderMessages(latestMessages);
@@ -13871,6 +15503,7 @@ function handleExtensionUiRequest(request) {
13871
15503
  } else {
13872
15504
  statusEntries.delete(statusKey);
13873
15505
  }
15506
+ if (statusKey === STATS_WEBUI_STATUS_KEY) handleStatsWebuiStatus(request.statusText);
13874
15507
  updateOptionalFeatureAvailability();
13875
15508
  renderStatus();
13876
15509
  return;
@@ -14174,7 +15807,9 @@ function handleEvent(event) {
14174
15807
  handleToolExecutionEnd(event);
14175
15808
  setRunIndicatorActivity(`Tool ${runIndicatorToolName(event.toolName)} ${event.isError ? "failed" : "finished"}; waiting for the agent's next step…`);
14176
15809
  addEvent(`tool ${event.toolName} ${event.isError ? "failed" : "finished"}`, event.isError ? "error" : "info");
14177
- scheduleRefreshMessages();
15810
+ // No transcript refresh here: the live tool card already shows the
15811
+ // result via renderLiveToolRun, and message_end/agent_end reconcile the
15812
+ // transcript. This avoids one fetch+render per tool call.
14178
15813
  scheduleRefreshFooter();
14179
15814
  break;
14180
15815
  case "compaction_start":
@@ -14564,6 +16199,25 @@ elements.optionsSettingsButton.addEventListener("click", () => runNativeCommandM
14564
16199
  elements.optionsExportButton.addEventListener("click", () => runNativeCommandMenu("/export"));
14565
16200
  elements.optionsForkButton.addEventListener("click", () => runNativeCommandMenu("/fork"));
14566
16201
  elements.optionsTreeButton.addEventListener("click", () => runNativeCommandMenu("/tree"));
16202
+ elements.optionsStatsButton?.addEventListener("click", () => openStatsOverlay({ refresh: true }));
16203
+ elements.statsOverlayRefreshButton?.addEventListener("click", () => requestStatsOverlayRefresh());
16204
+ elements.statsOverlayScope?.addEventListener("change", () => {
16205
+ const custom = elements.statsOverlayScope?.value === "custom";
16206
+ if (elements.statsOverlayCustomDays) elements.statsOverlayCustomDays.hidden = !custom;
16207
+ if (custom) {
16208
+ elements.statsOverlayCustomDays?.focus();
16209
+ return;
16210
+ }
16211
+ requestStatsOverlayRefresh();
16212
+ });
16213
+ elements.statsOverlayCustomDays?.addEventListener("change", () => requestStatsOverlayRefresh());
16214
+ elements.statsOverlayCustomDays?.addEventListener("keydown", (event) => {
16215
+ if (event.key !== "Enter") return;
16216
+ event.preventDefault();
16217
+ requestStatsOverlayRefresh();
16218
+ });
16219
+ elements.statsOverlayCloseButton?.addEventListener("click", () => elements.statsOverlayDialog?.close());
16220
+ elements.statsOverlayDialog?.querySelector("form")?.addEventListener("submit", (event) => event.preventDefault());
14567
16221
  elements.gitWorkflowSteps.addEventListener("click", (event) => {
14568
16222
  const target = event.target instanceof Element ? event.target : null;
14569
16223
  const button = target?.closest("[data-git-workflow-process]");
@@ -14900,6 +16554,118 @@ function handleNativeAppShortcut(event) {
14900
16554
  }
14901
16555
  }
14902
16556
 
16557
+ // --- Transcript search (Ctrl/Cmd+F) ---
16558
+ let chatSearchMatches = [];
16559
+ let chatSearchIndex = -1;
16560
+ let chatSearchTimer = null;
16561
+
16562
+ function chatSearchQueryText() {
16563
+ return (elements.chatSearchInput?.value || "").trim().toLowerCase();
16564
+ }
16565
+
16566
+ function collectChatSearchMatches(query) {
16567
+ if (!query) return [];
16568
+ const matches = [];
16569
+ for (const bubble of elements.chat.querySelectorAll(".message")) {
16570
+ if (bubble === runIndicatorBubble || bubble.classList.contains("runIndicator")) continue;
16571
+ if ((bubble.textContent || "").toLowerCase().includes(query)) matches.push(bubble);
16572
+ }
16573
+ return matches;
16574
+ }
16575
+
16576
+ function clearChatSearchHighlights() {
16577
+ for (const bubble of elements.chat.querySelectorAll(".message.search-current")) bubble.classList.remove("search-current");
16578
+ }
16579
+
16580
+ function updateChatSearchCount() {
16581
+ if (!elements.chatSearchCount) return;
16582
+ const query = chatSearchQueryText();
16583
+ elements.chatSearchCount.textContent = !query ? "" : chatSearchMatches.length === 0 ? "0/0" : `${chatSearchIndex + 1}/${chatSearchMatches.length}`;
16584
+ }
16585
+
16586
+ function focusChatSearchMatch() {
16587
+ const bubble = chatSearchMatches[chatSearchIndex];
16588
+ if (!bubble) return;
16589
+ if (!bubble.isConnected) {
16590
+ runChatSearch({ navigate: false });
16591
+ return;
16592
+ }
16593
+ clearChatSearchHighlights();
16594
+ bubble.classList.add("search-current");
16595
+ const query = chatSearchQueryText();
16596
+ for (const details of bubble.querySelectorAll("details")) {
16597
+ if (!details.open && (details.textContent || "").toLowerCase().includes(query)) details.open = true;
16598
+ }
16599
+ autoFollowChat = false;
16600
+ lastChatProgrammaticScrollAt = performance.now();
16601
+ bubble.scrollIntoView({ block: "center", behavior: "instant" });
16602
+ updateJumpToLatestButton();
16603
+ updateChatSearchCount();
16604
+ }
16605
+
16606
+ function runChatSearch({ navigate = false } = {}) {
16607
+ const query = chatSearchQueryText();
16608
+ clearChatSearchHighlights();
16609
+ chatSearchMatches = collectChatSearchMatches(query);
16610
+ if (chatSearchIndex >= chatSearchMatches.length || chatSearchIndex < 0) chatSearchIndex = chatSearchMatches.length - 1;
16611
+ updateChatSearchCount();
16612
+ if (navigate) focusChatSearchMatch();
16613
+ }
16614
+
16615
+ function stepChatSearch(step) {
16616
+ if (chatSearchMatches.some((bubble) => !bubble.isConnected)) runChatSearch();
16617
+ if (!chatSearchMatches.length) {
16618
+ runChatSearch();
16619
+ if (!chatSearchMatches.length) return;
16620
+ }
16621
+ chatSearchIndex = (chatSearchIndex + step + chatSearchMatches.length) % chatSearchMatches.length;
16622
+ focusChatSearchMatch();
16623
+ }
16624
+
16625
+ function openChatSearch() {
16626
+ if (!elements.chatSearchBar) return;
16627
+ elements.chatSearchBar.hidden = false;
16628
+ elements.chatSearchInput?.focus();
16629
+ elements.chatSearchInput?.select();
16630
+ if (chatSearchQueryText()) runChatSearch();
16631
+ }
16632
+
16633
+ function closeChatSearch() {
16634
+ if (!elements.chatSearchBar || elements.chatSearchBar.hidden) return;
16635
+ elements.chatSearchBar.hidden = true;
16636
+ clearChatSearchHighlights();
16637
+ chatSearchMatches = [];
16638
+ chatSearchIndex = -1;
16639
+ updateChatSearchCount();
16640
+ }
16641
+
16642
+ elements.chatSearchInput?.addEventListener("input", () => {
16643
+ clearTimeout(chatSearchTimer);
16644
+ chatSearchTimer = setTimeout(() => {
16645
+ chatSearchIndex = -1;
16646
+ runChatSearch({ navigate: true });
16647
+ }, 150);
16648
+ });
16649
+ elements.chatSearchInput?.addEventListener("keydown", (event) => {
16650
+ if (event.key === "Enter") {
16651
+ event.preventDefault();
16652
+ stepChatSearch(event.shiftKey ? -1 : 1);
16653
+ } else if (event.key === "Escape") {
16654
+ event.preventDefault();
16655
+ event.stopPropagation();
16656
+ closeChatSearch();
16657
+ }
16658
+ });
16659
+ elements.chatSearchPrevButton?.addEventListener("click", () => stepChatSearch(-1));
16660
+ elements.chatSearchNextButton?.addEventListener("click", () => stepChatSearch(1));
16661
+ elements.chatSearchCloseButton?.addEventListener("click", closeChatSearch);
16662
+ window.addEventListener("keydown", (event) => {
16663
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "f") {
16664
+ event.preventDefault();
16665
+ openChatSearch();
16666
+ }
16667
+ });
16668
+
14903
16669
  window.addEventListener("keydown", handleNativeAppShortcut, { capture: true });
14904
16670
  document.addEventListener("visibilitychange", () => {
14905
16671
  if (document.visibilityState === "visible") scheduleForegroundReconcile("visibility resume", 0);