@firstpick/pi-package-webui 0.3.8 → 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/README.md +9 -7
- package/bin/pi-webui.mjs +716 -37
- package/package.json +11 -22
- package/public/app.js +1839 -67
- package/public/index.html +41 -3
- package/public/service-worker.js +1 -1
- package/public/styles.css +415 -0
- package/tests/fixtures/fake-pi.mjs +10 -1
- package/tests/http-endpoints-harness.test.mjs +99 -2
- package/tests/mobile-static.test.mjs +80 -23
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
|
|
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
|
|
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
|
|
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
|
|
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;
|
|
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
|
|
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
|
|
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
|
|
2138
|
-
setServerRestartOverlay(true, "Running
|
|
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
|
|
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 });
|
|
@@ -4955,7 +5061,7 @@ const GIT_FOOTER_TOOLTIP_COPY = {
|
|
|
4955
5061
|
git: "Current Git branch. detached means HEAD is not on a branch; no repo means the cwd is outside a Git work tree.",
|
|
4956
5062
|
"git-state": "Active Git operation or detached state. Finish or abort rebase/merge/cherry-pick/revert/bisect before normal commits.",
|
|
4957
5063
|
sync: "Remote tracking divergence. ↑ means local commits ahead; ↓ means remote commits to pull.",
|
|
4958
|
-
changes: "Working tree summary. 🟢 staged, ✏️ modified unstaged, ➕ untracked, ⚠️ conflicted; ✅ means no changes.",
|
|
5064
|
+
changes: "Working tree and fetched remote summary. 🟢 staged, ✏️ modified unstaged, ➕ untracked, ⚠️ conflicted; ⬇️ means fetched remote commits to pull; 🔄/✓/⚠️ fetch shows the tab git fetch state; ✅ means no changes.",
|
|
4959
5065
|
"git-extra": "Extra Git signals. 📦 stash, 🧩 dirty submodules, 🌳 worktrees, 🏷️ tag at HEAD, 🕒 last commit age, 🔓 signing mismatch.",
|
|
4960
5066
|
model: "Scoped model for this tab.",
|
|
4961
5067
|
thinking: "Reasoning/thinking effort for this tab.",
|
|
@@ -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
|
|
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
|
}
|
|
@@ -5414,10 +5523,14 @@ function gitChangesChip(label, value, className = "") {
|
|
|
5414
5523
|
function renderGitChangesOverview(data) {
|
|
5415
5524
|
const summary = data?.summary || {};
|
|
5416
5525
|
const untrackedCount = Array.isArray(data?.untracked) ? data.untracked.length : Number(summary.untracked || 0);
|
|
5526
|
+
const ahead = Number(summary.ahead || 0) || 0;
|
|
5527
|
+
const behind = Number(summary.behind || 0) || 0;
|
|
5417
5528
|
const overview = make("div", "git-changes-overview");
|
|
5418
5529
|
overview.append(
|
|
5419
5530
|
gitChangesChip("repo", data?.root || "—", "wide"),
|
|
5420
5531
|
gitChangesChip("branch", data?.branch || "detached"),
|
|
5532
|
+
gitChangesChip("ahead", ahead > 0 ? `↑${ahead}` : 0, ahead > 0 ? "warning" : "muted"),
|
|
5533
|
+
gitChangesChip("remote", behind > 0 ? `↓${behind}` : 0, behind > 0 ? "danger" : "muted"),
|
|
5421
5534
|
gitChangesChip("staged", summary.staged || 0, "success"),
|
|
5422
5535
|
gitChangesChip("modified", summary.unstaged || 0, "warning"),
|
|
5423
5536
|
gitChangesChip("untracked", untrackedCount, "muted"),
|
|
@@ -6558,6 +6671,8 @@ function scheduleRefreshFooter(delay = 300, tabContext = activeTabContext()) {
|
|
|
6558
6671
|
function formatCodexPlanType(value) {
|
|
6559
6672
|
const text = String(value || "").trim();
|
|
6560
6673
|
if (!text) return "unknown plan";
|
|
6674
|
+
const normalized = text.replace(/[\s_-]+/g, "").toLowerCase();
|
|
6675
|
+
if (normalized === "prolite") return "Usage";
|
|
6561
6676
|
return text.replace(/[_-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
6562
6677
|
}
|
|
6563
6678
|
|
|
@@ -7720,6 +7835,472 @@ function renderAppRunnerWidget() {
|
|
|
7720
7835
|
return node;
|
|
7721
7836
|
}
|
|
7722
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
|
+
|
|
7723
8304
|
function renderWidgets() {
|
|
7724
8305
|
elements.widgetArea.replaceChildren();
|
|
7725
8306
|
const releaseOutput = renderReleaseNpmOutputWidget();
|
|
@@ -7792,6 +8373,10 @@ function formatCommitMessagePreview(message) {
|
|
|
7792
8373
|
return [`=== SHORT ===`, message.short || "(empty)", "", "=== LONG ===", message.long || "(empty)"].join("\n");
|
|
7793
8374
|
}
|
|
7794
8375
|
|
|
8376
|
+
function formatInputCommitMessagePreview(message) {
|
|
8377
|
+
return [`=== INPUT ===`, String(message || "").trim() || "(empty)"].join("\n");
|
|
8378
|
+
}
|
|
8379
|
+
|
|
7795
8380
|
function gitWorkflowMessageTitle(message) {
|
|
7796
8381
|
return String(message?.short || message?.long || "").split("\n").find((line) => line.trim())?.trim() || "Pull request";
|
|
7797
8382
|
}
|
|
@@ -7821,6 +8406,148 @@ function formatGitPrPreview(pr) {
|
|
|
7821
8406
|
return [...header, "", pr.body || "(empty)"].join("\n");
|
|
7822
8407
|
}
|
|
7823
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
|
+
|
|
7824
8551
|
function addGitWorkflowAction(label, handler, className = "", disabled = gitWorkflow.busy, tooltip = "") {
|
|
7825
8552
|
const button = make("button", className, label);
|
|
7826
8553
|
button.type = "button";
|
|
@@ -7835,6 +8562,110 @@ function addGitWorkflowAction(label, handler, className = "", disabled = gitWork
|
|
|
7835
8562
|
return button;
|
|
7836
8563
|
}
|
|
7837
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
|
+
|
|
7838
8669
|
function setGitPrDialogStatus(message = "", level = "muted") {
|
|
7839
8670
|
if (!elements.gitPrStatus) return;
|
|
7840
8671
|
elements.gitPrStatus.textContent = message;
|
|
@@ -7863,6 +8694,29 @@ function openGitPrReviewDialog(pr, { title = "" } = {}) {
|
|
|
7863
8694
|
|
|
7864
8695
|
function gitWorkflowProcessForStep(step = gitWorkflow.step, fallback = gitWorkflow.process || "stage") {
|
|
7865
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";
|
|
7866
8720
|
case "generate":
|
|
7867
8721
|
case "generating":
|
|
7868
8722
|
return "message";
|
|
@@ -7883,26 +8737,88 @@ function gitWorkflowProcessForStep(step = gitWorkflow.step, fallback = gitWorkfl
|
|
|
7883
8737
|
return "stage";
|
|
7884
8738
|
case "cancelled":
|
|
7885
8739
|
case "error":
|
|
7886
|
-
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";
|
|
7887
8741
|
default:
|
|
7888
8742
|
return "stage";
|
|
7889
8743
|
}
|
|
7890
8744
|
}
|
|
7891
8745
|
|
|
7892
|
-
function
|
|
7893
|
-
const
|
|
7894
|
-
if (!workflow) return;
|
|
7895
|
-
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";
|
|
7896
8748
|
workflow.runId += 1;
|
|
7897
|
-
const
|
|
7898
|
-
const
|
|
7899
|
-
|
|
7900
|
-
|
|
7901
|
-
|
|
7902
|
-
|
|
7903
|
-
|
|
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
|
+
}
|
|
7904
8820
|
if (process === "message") {
|
|
7905
|
-
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 });
|
|
7906
8822
|
return;
|
|
7907
8823
|
}
|
|
7908
8824
|
if (process === "commit") {
|
|
@@ -7915,6 +8831,22 @@ function selectGitWorkflowProcess(processValue, tabId = gitWorkflowActionTabId()
|
|
|
7915
8831
|
|
|
7916
8832
|
function gitWorkflowTitle() {
|
|
7917
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";
|
|
7918
8850
|
case "add": return "Stage all changes";
|
|
7919
8851
|
case "generate": return "Generate staged commit message";
|
|
7920
8852
|
case "generating": return "Waiting for /git-staged-msg";
|
|
@@ -7927,31 +8859,107 @@ function gitWorkflowTitle() {
|
|
|
7927
8859
|
case "prGenerating": return "Waiting for /pr";
|
|
7928
8860
|
case "prReview": return "Review PR description";
|
|
7929
8861
|
case "prCreating": return "Creating pull request";
|
|
7930
|
-
case "done": return "Git workflow complete";
|
|
7931
|
-
case "cancelled": return "Git workflow cancelled";
|
|
7932
|
-
case "error": return "Git workflow needs attention";
|
|
7933
|
-
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";
|
|
7934
8866
|
}
|
|
7935
8867
|
}
|
|
7936
8868
|
|
|
7937
8869
|
function gitWorkflowHint() {
|
|
7938
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.";
|
|
7939
8887
|
case "add": return "Step 1: run git add . in the current Pi working directory.";
|
|
7940
|
-
case "generate": return "Step 2: run /git-staged-msg,
|
|
8888
|
+
case "generate": return "Step 2: run /git-staged-msg, or type a commit message and use Commit input.";
|
|
7941
8889
|
case "generating": return "Pi is generating dev/COMMIT/staged-commit-short.txt and staged-commit-long.txt.";
|
|
7942
|
-
case "message": return gitWorkflow.prMode ? `Branch ${gitWorkflow.prBranch || "created"}: choose short or
|
|
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.";
|
|
7943
8891
|
case "branchNaming": return "Pi is generating dev/COMMIT/staged-branch-name.txt. Cancel will request Pi abort.";
|
|
7944
8892
|
case "branching": return "Creating a new branch with git switch -c before committing.";
|
|
7945
|
-
case "committing": return "Running native git commit
|
|
8893
|
+
case "committing": return "Running native git commit with the selected message.";
|
|
7946
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.";
|
|
7947
8895
|
case "pushing": return "Running git push. Cancel will request process termination.";
|
|
7948
8896
|
case "prGenerating": return "Pi is generating dev/PR/<current-branch>.md with /pr.";
|
|
7949
8897
|
case "prReview": return "Review or edit the generated PR description before creating the pull request.";
|
|
7950
8898
|
case "prCreating": return "Running gh pr create with the confirmed description.";
|
|
7951
|
-
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.";
|
|
7952
8900
|
case "cancelled": return "No further workflow steps will run.";
|
|
7953
8901
|
case "error": return gitWorkflow.error || "Fix the issue, then retry or restart.";
|
|
7954
|
-
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);
|
|
7955
8963
|
}
|
|
7956
8964
|
}
|
|
7957
8965
|
|
|
@@ -7959,15 +8967,19 @@ function renderGitWorkflow() {
|
|
|
7959
8967
|
elements.gitWorkflowPanel.hidden = !gitWorkflow.active;
|
|
7960
8968
|
if (!gitWorkflow.active) return;
|
|
7961
8969
|
|
|
8970
|
+
elements.gitWorkflowPanel.dataset.mode = gitWorkflow.mode || "standard";
|
|
8971
|
+
if (elements.gitWorkflowKicker) elements.gitWorkflowKicker.textContent = gitWorkflow.mode === "initRepo" ? "Git repository setup" : "Git workflow";
|
|
7962
8972
|
elements.gitWorkflowTitle.textContent = gitWorkflowTitle();
|
|
7963
8973
|
elements.gitWorkflowHint.textContent = gitWorkflowHint();
|
|
7964
8974
|
elements.gitWorkflowOutput.textContent = gitWorkflow.output || "Ready.";
|
|
7965
8975
|
elements.gitWorkflowSteps.replaceChildren();
|
|
7966
8976
|
elements.gitWorkflowActions.replaceChildren();
|
|
7967
8977
|
|
|
7968
|
-
const
|
|
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;
|
|
7969
8981
|
const activeProcess = gitWorkflowProcessForStep(gitWorkflow.step, gitWorkflow.process);
|
|
7970
|
-
for (const [index, process] of
|
|
8982
|
+
for (const [index, process] of processes.entries()) {
|
|
7971
8983
|
const item = make("button", "git-workflow-step", process.label);
|
|
7972
8984
|
item.type = "button";
|
|
7973
8985
|
item.dataset.gitWorkflowProcess = process.value;
|
|
@@ -7981,9 +8993,15 @@ function renderGitWorkflow() {
|
|
|
7981
8993
|
elements.gitWorkflowCancelButton.hidden = ["done", "cancelled"].includes(gitWorkflow.step);
|
|
7982
8994
|
elements.gitWorkflowCancelButton.disabled = false;
|
|
7983
8995
|
|
|
8996
|
+
if (gitWorkflow.mode === "initRepo") {
|
|
8997
|
+
renderGitInitWorkflowActions();
|
|
8998
|
+
return;
|
|
8999
|
+
}
|
|
9000
|
+
|
|
7984
9001
|
if (gitWorkflow.step === "add") {
|
|
7985
9002
|
addGitWorkflowAction("Run git add .", () => runGitAdd(), "primary", false);
|
|
7986
9003
|
} else if (gitWorkflow.step === "generate") {
|
|
9004
|
+
renderGitWorkflowManualCommitInput();
|
|
7987
9005
|
addGitWorkflowAction("Run /git-staged-msg", () => runGitMessagePrompt(), "primary", false);
|
|
7988
9006
|
addGitWorkflowAction("Preview current message files", () => loadGitWorkflowMessage({ requireFresh: false }), "", false);
|
|
7989
9007
|
} else if (gitWorkflow.step === "generating") {
|
|
@@ -7993,6 +9011,7 @@ function renderGitWorkflow() {
|
|
|
7993
9011
|
addGitWorkflowAction("Create PR", () => createGitPrBranch(), "primary", false, GIT_WORKFLOW_CREATE_PR_TOOLTIP);
|
|
7994
9012
|
addGitWorkflowAction("Manual branch", () => createGitPrBranchManually(), "", false, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP);
|
|
7995
9013
|
}
|
|
9014
|
+
renderGitWorkflowManualCommitInput();
|
|
7996
9015
|
addGitWorkflowAction("Commit short", () => commitGitWorkflow("short"), gitWorkflow.prMode ? "primary" : "", false);
|
|
7997
9016
|
addGitWorkflowAction("Commit long", () => commitGitWorkflow("long"), gitWorkflow.prMode ? "primary" : "", false);
|
|
7998
9017
|
addGitWorkflowAction("Regenerate", () => runGitMessagePrompt(), "", false);
|
|
@@ -8023,7 +9042,15 @@ function renderGitWorkflow() {
|
|
|
8023
9042
|
async function gitWorkflowRequest(path, { method = "POST", body = {}, runId, tabId = activeTabId } = {}) {
|
|
8024
9043
|
const workflow = gitWorkflowForTab(tabId, { create: false });
|
|
8025
9044
|
const expectedRunId = runId ?? workflow?.runId;
|
|
8026
|
-
|
|
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
|
+
}
|
|
8027
9054
|
if (expectedRunId !== undefined && !isCurrentGitWorkflowRun(expectedRunId, tabId)) return null;
|
|
8028
9055
|
if (!response.ok) {
|
|
8029
9056
|
const detail = response.data ? `\n\n${formatGitCommandResult(response.data)}` : "";
|
|
@@ -8059,12 +9086,21 @@ function startGitWorkflow(tabId = activeTabId) {
|
|
|
8059
9086
|
workflow.runId += 1;
|
|
8060
9087
|
setGitWorkflow({
|
|
8061
9088
|
active: true,
|
|
9089
|
+
mode: "standard",
|
|
8062
9090
|
step: "add",
|
|
8063
9091
|
process: "stage",
|
|
8064
9092
|
busy: false,
|
|
8065
|
-
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.",
|
|
8066
9094
|
error: "",
|
|
9095
|
+
githubUsername: "",
|
|
9096
|
+
repoName: "",
|
|
9097
|
+
remoteUrl: "",
|
|
9098
|
+
stack: "",
|
|
9099
|
+
readmeRequestedAt: 0,
|
|
9100
|
+
gitignoreRequestedAt: 0,
|
|
9101
|
+
initFilesStatus: null,
|
|
8067
9102
|
message: null,
|
|
9103
|
+
manualCommitMessage: "",
|
|
8068
9104
|
messageRequestedAt: 0,
|
|
8069
9105
|
branchName: "",
|
|
8070
9106
|
branchNameRequestedAt: 0,
|
|
@@ -8076,11 +9112,308 @@ function startGitWorkflow(tabId = activeTabId) {
|
|
|
8076
9112
|
}, { tabId });
|
|
8077
9113
|
}
|
|
8078
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
|
+
|
|
8079
9412
|
async function cancelGitWorkflow(tabId = gitWorkflowActionTabId()) {
|
|
8080
9413
|
const tabContext = activeTabContext(tabId);
|
|
8081
9414
|
const workflow = gitWorkflowForTab(tabId, { create: false });
|
|
8082
9415
|
if (!workflow?.active) return;
|
|
8083
|
-
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";
|
|
8084
9417
|
if (activeGitPrDialogResolve) resolveGitPrDialog(null);
|
|
8085
9418
|
workflow.runId += 1;
|
|
8086
9419
|
setGitWorkflow({ step: "cancelled", busy: false, error: "", output: `${workflow.output || ""}${workflow.output ? "\n\n" : ""}Cancelled by user.` }, { tabId });
|
|
@@ -8294,7 +9627,7 @@ async function createGitPrBranchWithSuggestion(suggestion, tabId = gitWorkflowAc
|
|
|
8294
9627
|
try {
|
|
8295
9628
|
const result = await gitWorkflowRequest("/api/git-workflow/branch", { body: { branch }, runId, tabId });
|
|
8296
9629
|
if (!result) return;
|
|
8297
|
-
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
|
|
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 });
|
|
8298
9631
|
} catch (error) {
|
|
8299
9632
|
if (isCurrentGitWorkflowRun(runId, tabId)) {
|
|
8300
9633
|
setGitWorkflow({ prMode: false, prBranch: "" }, { tabId });
|
|
@@ -8308,15 +9641,26 @@ async function commitGitWorkflow(variant, tabId = gitWorkflowActionTabId()) {
|
|
|
8308
9641
|
const workflow = gitWorkflowForTab(tabId, { create: false });
|
|
8309
9642
|
if (!workflow) return;
|
|
8310
9643
|
const runId = workflow.runId;
|
|
8311
|
-
|
|
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 });
|
|
8312
9652
|
try {
|
|
8313
|
-
const
|
|
9653
|
+
const body = variant === "input" ? { variant, message: inputMessage } : { variant };
|
|
9654
|
+
const result = await gitWorkflowRequest("/api/git-workflow/commit", { body, runId, tabId });
|
|
8314
9655
|
if (!result) return;
|
|
8315
9656
|
const nextAction = workflow.prMode ? "Push and Create PR." : "git push.";
|
|
8316
|
-
|
|
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 });
|
|
8317
9661
|
if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
|
|
8318
9662
|
} catch (error) {
|
|
8319
|
-
if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error,
|
|
9663
|
+
if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, failureStep, { tabId });
|
|
8320
9664
|
}
|
|
8321
9665
|
}
|
|
8322
9666
|
|
|
@@ -8481,6 +9825,22 @@ function resumeGitWorkflowForActiveTab(tabContext = activeTabContext()) {
|
|
|
8481
9825
|
}
|
|
8482
9826
|
loadGitWorkflowPr({ requireFresh: true, retries: 3, runId: gitWorkflow.runId, tabId: workflowTabId });
|
|
8483
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
|
+
}
|
|
8484
9844
|
}
|
|
8485
9845
|
|
|
8486
9846
|
function normalizeQueuedMessages(event) {
|
|
@@ -9216,6 +10576,64 @@ function renderMarkdown(block, text) {
|
|
|
9216
10576
|
renderMarkdownInto(block, text);
|
|
9217
10577
|
}
|
|
9218
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
|
+
|
|
9219
10637
|
function appendImage(parent, part) {
|
|
9220
10638
|
const wrapper = make("div", "image-block");
|
|
9221
10639
|
const img = document.createElement("img");
|
|
@@ -9846,6 +11264,7 @@ function stickyUserPromptViewportGap() {
|
|
|
9846
11264
|
|
|
9847
11265
|
function resetChatOutput() {
|
|
9848
11266
|
liveToolCards.clear();
|
|
11267
|
+
renderedTranscriptState = { epoch: "", entries: [] };
|
|
9849
11268
|
const preservedNodes = [];
|
|
9850
11269
|
if (elements.stickyUserPromptButton) preservedNodes.push(elements.stickyUserPromptButton);
|
|
9851
11270
|
if (runIndicatorBubble?.parentElement === elements.chat) preservedNodes.push(runIndicatorBubble);
|
|
@@ -10530,9 +11949,12 @@ function jumpToStickyUserPrompt() {
|
|
|
10530
11949
|
requestAnimationFrame(updateStickyUserPromptButton);
|
|
10531
11950
|
}
|
|
10532
11951
|
|
|
10533
|
-
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 = "" } = {}) {
|
|
10534
11953
|
const reused = reuseToolExecutionBubble(reusableToolCards, message, { streaming, messageIndex, transient });
|
|
10535
|
-
if (reused)
|
|
11954
|
+
if (reused) {
|
|
11955
|
+
if (itemKey) reused.bubble.dataset.itemKey = itemKey;
|
|
11956
|
+
return reused;
|
|
11957
|
+
}
|
|
10536
11958
|
const role = String(message.role || "message");
|
|
10537
11959
|
const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
|
|
10538
11960
|
const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}${animateEntry ? " action-enter" : ""}`);
|
|
@@ -10541,6 +11963,7 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
|
|
|
10541
11963
|
bubble.dataset.messageIndex = String(messageIndex);
|
|
10542
11964
|
if (role === "user") bubble.dataset.userPrompt = "true";
|
|
10543
11965
|
}
|
|
11966
|
+
if (itemKey) bubble.dataset.itemKey = itemKey;
|
|
10544
11967
|
const isCollapsibleOutput = !streaming && (message.role === "toolResult" || message.role === "bashExecution" || message.role === "compactionSummary");
|
|
10545
11968
|
|
|
10546
11969
|
const hideMessageHeader = message.role === "assistant" && !isCollapsibleOutput;
|
|
@@ -10592,9 +12015,9 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
|
|
|
10592
12015
|
return { bubble, body };
|
|
10593
12016
|
}
|
|
10594
12017
|
|
|
10595
|
-
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 = "" } = {}) {
|
|
10596
12019
|
if (streaming || transient || message?.role !== "assistant") {
|
|
10597
|
-
return appendMessage(message, { streaming, messageIndex, transient, animateEntry, reusableToolCards });
|
|
12020
|
+
return appendMessage(message, { streaming, messageIndex, transient, animateEntry, reusableToolCards, itemKey });
|
|
10598
12021
|
}
|
|
10599
12022
|
|
|
10600
12023
|
let finalOutput = null;
|
|
@@ -10624,6 +12047,7 @@ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1
|
|
|
10624
12047
|
transient: false,
|
|
10625
12048
|
animateEntry: animateEntry && isActionTranscriptMessage(transcriptMessage),
|
|
10626
12049
|
reusableToolCards,
|
|
12050
|
+
itemKey,
|
|
10627
12051
|
});
|
|
10628
12052
|
if (transcriptMessage.role === "assistant") finalOutput = created;
|
|
10629
12053
|
});
|
|
@@ -10895,20 +12319,169 @@ function orderedTranscriptItems() {
|
|
|
10895
12319
|
return items.sort((a, b) => a.timestampMs - b.timestampMs || a.order - b.order);
|
|
10896
12320
|
}
|
|
10897
12321
|
|
|
10898
|
-
|
|
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 } = {}) {
|
|
10899
12457
|
const shouldFollow = !preserveScroll && (autoFollowChat || isChatNearBottom());
|
|
10900
12458
|
const previousScrollTop = elements.chat.scrollTop;
|
|
10901
|
-
const reusableToolCards = captureReusableToolCards();
|
|
10902
|
-
resetChatOutput();
|
|
10903
12459
|
const transcriptItems = orderedTranscriptItems();
|
|
10904
|
-
|
|
10905
|
-
|
|
10906
|
-
|
|
10907
|
-
|
|
10908
|
-
|
|
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),
|
|
10909
12479
|
reusableToolCards,
|
|
12480
|
+
itemKey: entry.key,
|
|
10910
12481
|
});
|
|
10911
12482
|
}
|
|
12483
|
+
pruneDisconnectedLiveToolCards();
|
|
12484
|
+
renderedTranscriptState = { epoch, entries: nextEntries.map(({ key, sig }) => ({ key, sig })) };
|
|
10912
12485
|
rememberActionEntries(transcriptItems);
|
|
10913
12486
|
applyToolOutputExpansionToDom();
|
|
10914
12487
|
renderRunIndicator({ scroll: false });
|
|
@@ -11313,6 +12886,17 @@ function renderOptionalFeaturePanel() {
|
|
|
11313
12886
|
const packageLine = make("code", "optional-feature-package", feature.packageName);
|
|
11314
12887
|
main.append(title, detail, description, packageLine);
|
|
11315
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
|
+
|
|
11316
12900
|
const action = make("button", "optional-feature-action");
|
|
11317
12901
|
action.type = "button";
|
|
11318
12902
|
action.disabled = installing;
|
|
@@ -11326,8 +12910,9 @@ function renderOptionalFeaturePanel() {
|
|
|
11326
12910
|
action.classList.add("install");
|
|
11327
12911
|
action.addEventListener("click", () => installOptionalFeature(feature.id));
|
|
11328
12912
|
}
|
|
12913
|
+
actions.append(action);
|
|
11329
12914
|
|
|
11330
|
-
row.append(main,
|
|
12915
|
+
row.append(main, actions);
|
|
11331
12916
|
elements.optionalFeaturesBox.append(row);
|
|
11332
12917
|
}
|
|
11333
12918
|
}
|
|
@@ -11367,6 +12952,16 @@ function renderOptionalFeatureControls() {
|
|
|
11367
12952
|
);
|
|
11368
12953
|
if (!hasNativeCommandMenu && nativeCommandMenuOpen) setNativeCommandMenuOpen(false);
|
|
11369
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
|
+
|
|
11370
12965
|
renderOptionalFeaturePanel();
|
|
11371
12966
|
}
|
|
11372
12967
|
|
|
@@ -12466,7 +14061,7 @@ function renderStreamingAssistantText() {
|
|
|
12466
14061
|
const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
|
|
12467
14062
|
if (assistantText) {
|
|
12468
14063
|
ensureStreamBubble();
|
|
12469
|
-
|
|
14064
|
+
renderStreamingMarkdown(streamText, assistantText);
|
|
12470
14065
|
} else {
|
|
12471
14066
|
scheduleStreamBubbleHide();
|
|
12472
14067
|
}
|
|
@@ -12519,6 +14114,7 @@ function resetStreamBubble() {
|
|
|
12519
14114
|
streamBubble = null;
|
|
12520
14115
|
streamText = null;
|
|
12521
14116
|
streamRawText = "";
|
|
14117
|
+
streamMarkdownState = null;
|
|
12522
14118
|
streamBubbleVisibleSince = 0;
|
|
12523
14119
|
streamToolCallSeen = false;
|
|
12524
14120
|
streamThinkingBubble = null;
|
|
@@ -12770,11 +14366,53 @@ async function refreshFooterData(tabContext = activeTabContext()) {
|
|
|
12770
14366
|
await Promise.allSettled([refreshStats(tabContext), refreshWorkspace(tabContext)]);
|
|
12771
14367
|
}
|
|
12772
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
|
+
|
|
12773
14397
|
async function refreshMessages(tabContext = activeTabContext()) {
|
|
12774
14398
|
if (!tabContext.tabId) return;
|
|
12775
|
-
const
|
|
12776
|
-
|
|
12777
|
-
|
|
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;
|
|
12778
14416
|
const preserveLiveStream = liveStreamRenderActive();
|
|
12779
14417
|
if (!preserveLiveStream) resetStreamBubble();
|
|
12780
14418
|
renderMessages(latestMessages);
|
|
@@ -13865,6 +15503,7 @@ function handleExtensionUiRequest(request) {
|
|
|
13865
15503
|
} else {
|
|
13866
15504
|
statusEntries.delete(statusKey);
|
|
13867
15505
|
}
|
|
15506
|
+
if (statusKey === STATS_WEBUI_STATUS_KEY) handleStatsWebuiStatus(request.statusText);
|
|
13868
15507
|
updateOptionalFeatureAvailability();
|
|
13869
15508
|
renderStatus();
|
|
13870
15509
|
return;
|
|
@@ -14168,7 +15807,9 @@ function handleEvent(event) {
|
|
|
14168
15807
|
handleToolExecutionEnd(event);
|
|
14169
15808
|
setRunIndicatorActivity(`Tool ${runIndicatorToolName(event.toolName)} ${event.isError ? "failed" : "finished"}; waiting for the agent's next step…`);
|
|
14170
15809
|
addEvent(`tool ${event.toolName} ${event.isError ? "failed" : "finished"}`, event.isError ? "error" : "info");
|
|
14171
|
-
|
|
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.
|
|
14172
15813
|
scheduleRefreshFooter();
|
|
14173
15814
|
break;
|
|
14174
15815
|
case "compaction_start":
|
|
@@ -14558,6 +16199,25 @@ elements.optionsSettingsButton.addEventListener("click", () => runNativeCommandM
|
|
|
14558
16199
|
elements.optionsExportButton.addEventListener("click", () => runNativeCommandMenu("/export"));
|
|
14559
16200
|
elements.optionsForkButton.addEventListener("click", () => runNativeCommandMenu("/fork"));
|
|
14560
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());
|
|
14561
16221
|
elements.gitWorkflowSteps.addEventListener("click", (event) => {
|
|
14562
16222
|
const target = event.target instanceof Element ? event.target : null;
|
|
14563
16223
|
const button = target?.closest("[data-git-workflow-process]");
|
|
@@ -14894,6 +16554,118 @@ function handleNativeAppShortcut(event) {
|
|
|
14894
16554
|
}
|
|
14895
16555
|
}
|
|
14896
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
|
+
|
|
14897
16669
|
window.addEventListener("keydown", handleNativeAppShortcut, { capture: true });
|
|
14898
16670
|
document.addEventListener("visibilitychange", () => {
|
|
14899
16671
|
if (document.visibilityState === "visible") scheduleForegroundReconcile("visibility resume", 0);
|