@firstpick/pi-package-webui 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -50,6 +50,10 @@ const elements = {
50
50
  nativeCommandMenu: $("#nativeCommandMenu"),
51
51
  nativeSkillsButton: $("#nativeSkillsButton"),
52
52
  nativeToolsButton: $("#nativeToolsButton"),
53
+ appRunnerMenu: $("#appRunnerMenu"),
54
+ appRunnerInfoButton: $("#appRunnerInfoButton"),
55
+ appRunnerMenuButton: $("#appRunnerMenuButton"),
56
+ appRunnerMenuPanel: $("#appRunnerMenuPanel"),
53
57
  optionsMenuButton: $("#optionsMenuButton"),
54
58
  optionsMenu: $("#optionsMenu"),
55
59
  optionsResumeButton: $("#optionsResumeButton"),
@@ -67,6 +71,12 @@ const elements = {
67
71
  gitWorkflowOutput: $("#gitWorkflowOutput"),
68
72
  gitWorkflowActions: $("#gitWorkflowActions"),
69
73
  gitWorkflowCancelButton: $("#gitWorkflowCancelButton"),
74
+ gitPrDialog: $("#gitPrDialog"),
75
+ gitPrTitleInput: $("#gitPrTitleInput"),
76
+ gitPrBodyEditor: $("#gitPrBodyEditor"),
77
+ gitPrStatus: $("#gitPrStatus"),
78
+ gitPrCancelButton: $("#gitPrCancelButton"),
79
+ gitPrCreateButton: $("#gitPrCreateButton"),
70
80
  modelSelect: $("#modelSelect"),
71
81
  setModelButton: $("#setModelButton"),
72
82
  thinkingSelect: $("#thinkingSelect"),
@@ -113,6 +123,13 @@ const elements = {
113
123
  promptListDialogLoadButton: $("#promptListDialogLoadButton"),
114
124
  promptListSaveButton: $("#promptListSaveButton"),
115
125
  promptListRunListButton: $("#promptListRunListButton"),
126
+ attachmentTextDialog: $("#attachmentTextDialog"),
127
+ attachmentTextTitle: $("#attachmentTextTitle"),
128
+ attachmentTextMeta: $("#attachmentTextMeta"),
129
+ attachmentTextEditor: $("#attachmentTextEditor"),
130
+ attachmentTextStatus: $("#attachmentTextStatus"),
131
+ attachmentTextCancelButton: $("#attachmentTextCancelButton"),
132
+ attachmentTextSaveButton: $("#attachmentTextSaveButton"),
116
133
  commandSearchInput: $("#commandSearchInput"),
117
134
  commandsBox: $("#commandsBox"),
118
135
  eventLog: $("#eventLog"),
@@ -143,6 +160,9 @@ const elements = {
143
160
  nativeCommandBody: $("#nativeCommandBody"),
144
161
  nativeCommandError: $("#nativeCommandError"),
145
162
  nativeCommandActions: $("#nativeCommandActions"),
163
+ appRunnerInfoDialog: $("#appRunnerInfoDialog"),
164
+ appRunnerInfoBody: $("#appRunnerInfoBody"),
165
+ appRunnerInfoCloseButton: $("#appRunnerInfoCloseButton"),
146
166
  };
147
167
 
148
168
  let currentState = null;
@@ -151,6 +171,7 @@ let activeTabId = null;
151
171
  let activeTabGeneration = 0;
152
172
  let tabDrafts = new Map();
153
173
  let tabAttachments = new Map();
174
+ let activeTextAttachmentEditor = null;
154
175
  let tabActivities = new Map();
155
176
  let tabSeenCompletionSerials = new Map();
156
177
  let streamBubble = null;
@@ -178,8 +199,10 @@ let refreshTabsTimer = null;
178
199
  let foregroundReconcileTimer = null;
179
200
  let eventSource = null;
180
201
  let activeDialog = null;
202
+ let activeGitPrDialogResolve = null;
181
203
  let nativeCommandTabId = null;
182
204
  let pathPickerState = null;
205
+ let firstTerminalCwdPromptShown = false;
183
206
  let pathFastPicks = [];
184
207
  let pathFastPicksReady = false;
185
208
  let pathFastPicksLoadPromise = null;
@@ -187,6 +210,9 @@ let mobileTabsExpanded = false;
187
210
  let openTerminalTabGroupKey = null;
188
211
  let newTabMenuOpen = false;
189
212
  let nativeCommandMenuOpen = false;
213
+ let appRunnerMenuOpen = false;
214
+ let appRunnerCustomDraft = { id: "", label: "", command: "./", path: "", args: "" };
215
+ let appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
190
216
  let optionsMenuOpen = false;
191
217
  let availableCommands = [];
192
218
  let rawAvailableCommands = [];
@@ -244,6 +270,7 @@ let customBackgroundLoading = false;
244
270
  let footerScopedModels = [];
245
271
  let footerScopedModelPatterns = [];
246
272
  let footerScopedModelSource = "none";
273
+ const contextUsageUnknownAfterCompactionByTab = new Map();
247
274
  let autoFollowChat = true;
248
275
  let chatFollowFrame = null;
249
276
  let chatFollowSettleTimer = null;
@@ -293,10 +320,13 @@ const ATTACHMENT_MAX_FILE_BYTES = 64 * 1024 * 1024;
293
320
  const ATTACHMENT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
294
321
  const ATTACHMENT_INLINE_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
295
322
  const ATTACHMENT_INLINE_IMAGE_TOTAL_MAX_BYTES = 16 * 1024 * 1024;
323
+ const LONG_INPUT_ATTACHMENT_LINE_THRESHOLD = 20;
324
+ const LONG_INPUT_ATTACHMENT_MIME_TYPE = "text/plain";
296
325
  const INLINE_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
297
326
  const BACKGROUND_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
298
327
  const DEFAULT_THEME_NAME = "catppuccin-mocha";
299
328
  const MOBILE_VIEW_QUERY = "(max-width: 720px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
329
+ const SIDE_PANEL_OVERLAY_QUERY = "(max-width: 1050px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
300
330
  const CHAT_BOTTOM_THRESHOLD_PX = 96;
301
331
  const STICKY_USER_PROMPT_PREVIEW_LIMIT = 220;
302
332
  const STICKY_USER_PROMPT_TOP_GAP_PX = 12;
@@ -325,10 +355,12 @@ const BLOCKED_TAB_NOTIFICATION_TAG_PREFIX = "pi-webui-blocked-tab";
325
355
  const AGENT_DONE_NOTIFICATION_TAG_PREFIX = "pi-webui-agent-done";
326
356
  const BLOCKED_TAB_NOTIFICATION_ICON = "/icon-192.png";
327
357
  const mobileViewMedia = window.matchMedia?.(MOBILE_VIEW_QUERY) || null;
358
+ const sidePanelOverlayMedia = window.matchMedia?.(SIDE_PANEL_OVERLAY_QUERY) || mobileViewMedia;
328
359
  const statusEntries = new Map();
329
360
  const widgets = new Map();
330
361
  const todoProgressWidgetExpandedByTab = new Map();
331
362
  const releaseNpmOutputExpandedByTab = new Map();
363
+ const appRunnerDataByTab = new Map();
332
364
  const liveToolRuns = new Map();
333
365
  const liveToolCards = new Map();
334
366
  const liveToolRenderQueue = new Map();
@@ -415,6 +447,8 @@ const OPTIONAL_FEATURES = [
415
447
  const OPTIONAL_FEATURE_BY_ID = new Map(OPTIONAL_FEATURES.map((feature) => [feature.id, feature]));
416
448
  const OPTIONAL_COMMAND_FEATURES = new Map([
417
449
  ["git-staged-msg", "gitWorkflow"],
450
+ ["git-branch-name", "gitWorkflow"],
451
+ ["pr", "gitWorkflow"],
418
452
  ["release-npm", "releaseNpm"],
419
453
  ["release-aur", "releaseAur"],
420
454
  ["skills", "tuiSkillsCommand"],
@@ -446,16 +480,42 @@ const SETTINGS_AUTOCOMPLETE_OPTIONS = ["3", "5", "7", "10", "15", "20"];
446
480
  const optionalFeatureInstallInProgress = new Set();
447
481
  const gitFooterPayloadRefreshInFlightByTab = new Set();
448
482
 
483
+ function createGitWorkflowActionsDone(patch = {}) {
484
+ return {
485
+ stage: false,
486
+ message: false,
487
+ commit: false,
488
+ push: false,
489
+ ...patch,
490
+ };
491
+ }
492
+
493
+ function gitWorkflowActionDone(workflow, process) {
494
+ return !!createGitWorkflowActionsDone(workflow?.actionsDone)[process];
495
+ }
496
+
497
+ function gitWorkflowActionDonePatch(workflow, process) {
498
+ return { actionsDone: createGitWorkflowActionsDone({ ...workflow?.actionsDone, [process]: true }) };
499
+ }
500
+
449
501
  function createGitWorkflowState() {
450
502
  return {
451
503
  active: false,
452
504
  step: "idle",
505
+ process: "stage",
453
506
  busy: false,
454
507
  runId: 0,
455
508
  output: "",
456
509
  error: "",
457
510
  message: null,
458
511
  messageRequestedAt: 0,
512
+ branchName: "",
513
+ branchNameRequestedAt: 0,
514
+ actionsDone: createGitWorkflowActionsDone(),
515
+ prMode: false,
516
+ prBranch: "",
517
+ pr: null,
518
+ prRequestedAt: 0,
459
519
  };
460
520
  }
461
521
 
@@ -499,7 +559,13 @@ function clearGitWorkflowForTab(tabId) {
499
559
  }
500
560
  }
501
561
 
502
- const GIT_WORKFLOW_STEPS = ["Stage", "Message", "Commit", "Push"];
562
+ const GIT_WORKFLOW_PROCESSES = [
563
+ { value: "stage", label: "Stage" },
564
+ { value: "message", label: "Message" },
565
+ { value: "commit", label: "Commit" },
566
+ { value: "push", label: "Push" },
567
+ ];
568
+ const GIT_WORKFLOW_PROCESS_VALUES = new Set(GIT_WORKFLOW_PROCESSES.map((process) => process.value));
503
569
  const ACTION_FEEDBACK_REACTIONS = {
504
570
  up: { icon: "👍", label: "Good job", title: "Good job!" },
505
571
  down: { icon: "👎", label: "Avoid this", title: "Avoid this" },
@@ -511,11 +577,32 @@ const GIT_WORKFLOW_ACTIVE_INDEX = {
511
577
  generate: 1,
512
578
  generating: 1,
513
579
  message: 2,
580
+ branchNaming: 2,
581
+ branching: 2,
514
582
  committing: 2,
515
583
  push: 3,
516
584
  pushing: 3,
585
+ prGenerating: 3,
586
+ prReview: 3,
587
+ prCreating: 3,
517
588
  done: 4,
518
589
  };
590
+ const GIT_WORKFLOW_CREATE_PR_TOOLTIP = [
591
+ "Create PR:",
592
+ "1. Ask Pi to generate a type/feature-name branch from staged changes.",
593
+ "2. Read dev/COMMIT/staged-branch-name.txt.",
594
+ "3. Let you confirm or edit the generated branch name.",
595
+ "4. Run git switch -c <branch>.",
596
+ "5. Return here so you can choose Commit short or Commit long on that branch.",
597
+ ].join("\n");
598
+ const GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP = [
599
+ "Manual branch:",
600
+ "1. Skip agent branch-name generation.",
601
+ "2. Prefill a branch from the commit message if possible.",
602
+ "3. Let you type or edit the type/feature-name branch name.",
603
+ "4. Run git switch -c <branch>.",
604
+ "5. Return here so you can choose Commit short or Commit long on that branch.",
605
+ ].join("\n");
519
606
 
520
607
  function make(tag, className, text) {
521
608
  const node = document.createElement(tag);
@@ -532,6 +619,10 @@ function isMobileView() {
532
619
  return mobileViewMedia?.matches || false;
533
620
  }
534
621
 
622
+ function isSidePanelOverlayView() {
623
+ return sidePanelOverlayMedia?.matches || false;
624
+ }
625
+
535
626
  function readStoredSidePanelCollapsed() {
536
627
  try {
537
628
  const stored = localStorage.getItem(SIDE_PANEL_STORAGE_KEY);
@@ -793,6 +884,7 @@ function setComposerActionsOpen(open) {
793
884
  if (!shouldOpen) {
794
885
  setPublishMenuOpen(false);
795
886
  setNativeCommandMenuOpen(false);
887
+ setAppRunnerMenuOpen(false);
796
888
  setOptionsMenuOpen(false);
797
889
  }
798
890
  }
@@ -928,7 +1020,7 @@ function setMobileTabsExpanded(expanded) {
928
1020
  }
929
1021
 
930
1022
  function syncMobileSidePanelState(collapsed) {
931
- const showBackdrop = !collapsed && isMobileView();
1023
+ const showBackdrop = !collapsed && isSidePanelOverlayView();
932
1024
  elements.sidePanelBackdrop.hidden = !showBackdrop;
933
1025
  if (showBackdrop) elements.sidePanel.setAttribute("aria-modal", "true");
934
1026
  else elements.sidePanel.removeAttribute("aria-modal");
@@ -942,11 +1034,11 @@ function setSidePanelCollapsed(collapsed, { persist = true, focusPanel = false }
942
1034
  elements.sidePanelExpandButton.setAttribute("aria-expanded", collapsed ? "false" : "true");
943
1035
  syncMobileSidePanelState(collapsed);
944
1036
 
945
- if (!collapsed && focusPanel && isMobileView()) {
1037
+ if (!collapsed && focusPanel && isSidePanelOverlayView()) {
946
1038
  requestAnimationFrame(() => elements.toggleSidePanelButton.focus());
947
1039
  }
948
1040
 
949
- if (!persist || isMobileView()) return;
1041
+ if (!persist || isSidePanelOverlayView()) return;
950
1042
  try {
951
1043
  localStorage.setItem(SIDE_PANEL_STORAGE_KEY, collapsed ? "1" : "0");
952
1044
  } catch {
@@ -955,7 +1047,7 @@ function setSidePanelCollapsed(collapsed, { persist = true, focusPanel = false }
955
1047
  }
956
1048
 
957
1049
  function restoreSidePanelState() {
958
- if (isMobileView()) {
1050
+ if (isSidePanelOverlayView()) {
959
1051
  setSidePanelCollapsed(true, { persist: false });
960
1052
  return;
961
1053
  }
@@ -969,7 +1061,7 @@ function bindMobileViewChanges() {
969
1061
  setComposerActionsOpen(false);
970
1062
  setMobileFooterExpanded(false);
971
1063
  setMobileTabsExpanded(false);
972
- if (event.matches) {
1064
+ if (event.matches || isSidePanelOverlayView()) {
973
1065
  setSidePanelCollapsed(true, { persist: false });
974
1066
  return;
975
1067
  }
@@ -980,6 +1072,20 @@ function bindMobileViewChanges() {
980
1072
  else mobileViewMedia.addListener?.(syncForViewport);
981
1073
  }
982
1074
 
1075
+ function bindSidePanelOverlayViewChanges() {
1076
+ if (!sidePanelOverlayMedia || sidePanelOverlayMedia === mobileViewMedia) return;
1077
+ const syncForViewport = (event) => {
1078
+ if (event.matches) {
1079
+ setSidePanelCollapsed(true, { persist: false });
1080
+ return;
1081
+ }
1082
+ const stored = readStoredSidePanelCollapsed();
1083
+ setSidePanelCollapsed(stored ?? false, { persist: false });
1084
+ };
1085
+ if (typeof sidePanelOverlayMedia.addEventListener === "function") sidePanelOverlayMedia.addEventListener("change", syncForViewport);
1086
+ else sidePanelOverlayMedia.addListener?.(syncForViewport);
1087
+ }
1088
+
983
1089
  function updateVisualViewportVars() {
984
1090
  const viewport = window.visualViewport;
985
1091
  const viewportHeight = viewport?.height || window.innerHeight || document.documentElement.clientHeight;
@@ -1055,8 +1161,7 @@ function currentPortArg() {
1055
1161
  }
1056
1162
 
1057
1163
  function serverStartCommandText() {
1058
- const cwd = readStoredServerStartCwd() || ".";
1059
- return `pi-webui --cwd ${quoteCommandArg(cwd)}${currentPortArg()}`;
1164
+ return `pi-webui${currentPortArg()}`;
1060
1165
  }
1061
1166
 
1062
1167
  function serverStartSlashCommandText() {
@@ -1376,6 +1481,46 @@ function attachmentIcon(kind) {
1376
1481
  return kind === "image" ? "🖼️" : kind === "video" ? "🎞️" : kind === "audio" ? "🎵" : kind === "doc" ? "📄" : "📎";
1377
1482
  }
1378
1483
 
1484
+ function normalizeTextAttachmentContent(text) {
1485
+ return String(text || "").replace(/\r\n?/g, "\n");
1486
+ }
1487
+
1488
+ function textLineCount(text) {
1489
+ const normalized = normalizeTextAttachmentContent(text);
1490
+ return normalized ? normalized.split("\n").length : 0;
1491
+ }
1492
+
1493
+ function shouldAttachTextInsteadOfComposerInput(text) {
1494
+ const normalized = normalizeTextAttachmentContent(text);
1495
+ return normalized.trim().length > 0 && textLineCount(normalized) > LONG_INPUT_ATTACHMENT_LINE_THRESHOLD;
1496
+ }
1497
+
1498
+ function longInputAttachmentFileName() {
1499
+ const stamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z").replace(/:/g, "-");
1500
+ return `webui-input-${stamp}.txt`;
1501
+ }
1502
+
1503
+ function makeTextAttachmentFile(text, name = longInputAttachmentFileName(), mimeType = LONG_INPUT_ATTACHMENT_MIME_TYPE) {
1504
+ const normalized = normalizeTextAttachmentContent(text);
1505
+ const fileName = String(name || longInputAttachmentFileName());
1506
+ const type = String(mimeType || LONG_INPUT_ATTACHMENT_MIME_TYPE);
1507
+ if (typeof File === "function") return new File([normalized], fileName, { type });
1508
+ const blob = new Blob([normalized], { type });
1509
+ try {
1510
+ blob.name = fileName;
1511
+ blob.lastModified = Date.now();
1512
+ } catch {
1513
+ // Older browsers may expose non-extensible Blob instances; the attachment record still carries the name.
1514
+ }
1515
+ return blob;
1516
+ }
1517
+
1518
+ function isEditableTextAttachment(attachment) {
1519
+ const name = String(attachment?.name || "");
1520
+ const mimeType = String(attachment?.mimeType || attachment?.file?.type || inferMimeTypeFromName(name)).split(";", 1)[0].trim().toLowerCase();
1521
+ return mimeType.startsWith("text/") || /(?:json|xml|yaml|toml|markdown|csv)/i.test(mimeType) || /\.(?:txt|md|markdown|csv|json|xml|ya?ml|toml|ini|log)$/i.test(name);
1522
+ }
1523
+
1379
1524
  function attachmentsForTab(tabId = activeTabId) {
1380
1525
  return tabId ? tabAttachments.get(tabId) || [] : [];
1381
1526
  }
@@ -1404,11 +1549,19 @@ function renderAttachmentTray() {
1404
1549
  const icon = make("span", "attachment-pill-icon", attachmentIcon(attachment.kind));
1405
1550
  const name = make("span", "attachment-pill-name", attachment.name);
1406
1551
  const meta = make("span", "attachment-pill-meta", `${attachment.kind} · ${formatBytes(attachment.size)}`);
1552
+ const edit = isEditableTextAttachment(attachment) ? make("button", "attachment-edit-button", "Edit") : null;
1553
+ if (edit) {
1554
+ edit.type = "button";
1555
+ edit.setAttribute("aria-label", `Open and edit ${attachment.name}`);
1556
+ edit.addEventListener("click", () => openTextAttachmentEditor(attachment.id));
1557
+ }
1407
1558
  const remove = make("button", "attachment-remove-button", "×");
1408
1559
  remove.type = "button";
1409
1560
  remove.setAttribute("aria-label", `Remove ${attachment.name}`);
1410
1561
  remove.addEventListener("click", () => removeAttachment(attachment.id));
1411
- pill.append(icon, name, meta, remove);
1562
+ pill.append(icon, name, meta);
1563
+ if (edit) pill.append(edit);
1564
+ pill.append(remove);
1412
1565
  tray.append(pill);
1413
1566
  }
1414
1567
  }
@@ -1419,6 +1572,7 @@ function removeAttachment(id, tabId = activeTabId) {
1419
1572
  if (index === -1) return;
1420
1573
  const [removed] = attachments.splice(index, 1);
1421
1574
  if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl);
1575
+ if (activeTextAttachmentEditor?.tabId === tabId && activeTextAttachmentEditor?.attachmentId === id) closeTextAttachmentEditor();
1422
1576
  if (attachments.length === 0) tabAttachments.delete(tabId);
1423
1577
  if (tabId === activeTabId) renderAttachmentTray();
1424
1578
  }
@@ -1428,15 +1582,16 @@ function clearAttachments(tabId = activeTabId) {
1428
1582
  for (const attachment of attachments) {
1429
1583
  if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
1430
1584
  }
1585
+ if (activeTextAttachmentEditor?.tabId === tabId) closeTextAttachmentEditor();
1431
1586
  if (tabId) tabAttachments.delete(tabId);
1432
1587
  if (tabId === activeTabId) renderAttachmentTray();
1433
1588
  }
1434
1589
 
1435
1590
  function addAttachmentFiles(fileList, source = "picker") {
1436
1591
  const files = Array.from(fileList || []).filter(Boolean);
1437
- if (!files.length) return;
1592
+ if (!files.length) return { added: 0, skipped: [] };
1438
1593
  const attachments = ensureAttachmentsForTab();
1439
- if (!attachments.length && !activeTabId) return;
1594
+ if (!attachments.length && !activeTabId) return { added: 0, skipped: ["no active tab"] };
1440
1595
  let totalBytes = attachments.reduce((sum, attachment) => sum + attachment.size, 0);
1441
1596
  let added = 0;
1442
1597
  const skipped = [];
@@ -1474,6 +1629,129 @@ function addAttachmentFiles(fileList, source = "picker") {
1474
1629
  renderAttachmentTray();
1475
1630
  if (added) addEvent(`attached ${added} ${added === 1 ? "file" : "files"} from ${source}`, "info");
1476
1631
  if (skipped.length) addEvent(`skipped attachments: ${skipped.join("; ")}`, "warn");
1632
+ return { added, skipped };
1633
+ }
1634
+
1635
+ function attachLongTextAsFile(text, source = "input text") {
1636
+ if (!shouldAttachTextInsteadOfComposerInput(text)) return false;
1637
+ const normalized = normalizeTextAttachmentContent(text);
1638
+ const lineCount = textLineCount(normalized);
1639
+ const result = addAttachmentFiles([makeTextAttachmentFile(normalized)], `${lineCount}-line ${source}`);
1640
+ return result.added > 0;
1641
+ }
1642
+
1643
+ function moveLongPromptInputToAttachment() {
1644
+ const text = elements.promptInput.value || "";
1645
+ if (!attachLongTextAsFile(text, "input text")) return false;
1646
+ elements.promptInput.value = "";
1647
+ resizePromptInput();
1648
+ hideCommandSuggestions();
1649
+ return true;
1650
+ }
1651
+
1652
+ function attachmentById(tabId, id) {
1653
+ return attachmentsForTab(tabId).find((attachment) => attachment.id === id) || null;
1654
+ }
1655
+
1656
+ function closeTextAttachmentEditor() {
1657
+ if (elements.attachmentTextDialog?.open) elements.attachmentTextDialog.close();
1658
+ else activeTextAttachmentEditor = null;
1659
+ }
1660
+
1661
+ function setAttachmentTextStatus(message = "", level = "muted") {
1662
+ if (!elements.attachmentTextStatus) return;
1663
+ elements.attachmentTextStatus.textContent = message;
1664
+ elements.attachmentTextStatus.className = `attachment-text-status ${level || "muted"}`;
1665
+ }
1666
+
1667
+ function renderTextAttachmentEditorMeta() {
1668
+ if (!activeTextAttachmentEditor || !elements.attachmentTextMeta) return;
1669
+ const attachment = attachmentById(activeTextAttachmentEditor.tabId, activeTextAttachmentEditor.attachmentId);
1670
+ if (!attachment) {
1671
+ elements.attachmentTextMeta.textContent = "Attachment no longer exists.";
1672
+ return;
1673
+ }
1674
+ const text = elements.attachmentTextEditor?.value || "";
1675
+ const lineCount = textLineCount(text);
1676
+ elements.attachmentTextMeta.textContent = `${attachment.name} · ${attachment.mimeType} · ${formatBytes(attachment.size)} · ${lineCount} ${lineCount === 1 ? "line" : "lines"}`;
1677
+ }
1678
+
1679
+ function readFileAsText(file) {
1680
+ if (typeof file?.text === "function") return file.text();
1681
+ return new Promise((resolve, reject) => {
1682
+ const reader = new FileReader();
1683
+ reader.onerror = () => reject(reader.error || new Error("Failed to read text attachment"));
1684
+ reader.onload = () => resolve(String(reader.result || ""));
1685
+ reader.readAsText(file);
1686
+ });
1687
+ }
1688
+
1689
+ async function openTextAttachmentEditor(attachmentId, tabId = activeTabId) {
1690
+ const attachment = attachmentById(tabId, attachmentId);
1691
+ if (!attachment) return;
1692
+ if (!isEditableTextAttachment(attachment)) {
1693
+ addEvent(`${attachment.name || "attachment"} is not editable text`, "warn");
1694
+ return;
1695
+ }
1696
+
1697
+ activeTextAttachmentEditor = { tabId, attachmentId };
1698
+ if (elements.attachmentTextTitle) elements.attachmentTextTitle.textContent = `Edit ${attachment.name || "text attachment"}`;
1699
+ if (elements.attachmentTextEditor) elements.attachmentTextEditor.value = "";
1700
+ if (elements.attachmentTextSaveButton) elements.attachmentTextSaveButton.disabled = true;
1701
+ renderTextAttachmentEditorMeta();
1702
+ setAttachmentTextStatus("Loading text attachment…", "muted");
1703
+ if (elements.attachmentTextDialog && !elements.attachmentTextDialog.open) elements.attachmentTextDialog.showModal();
1704
+
1705
+ try {
1706
+ const text = await readFileAsText(attachment.file);
1707
+ if (activeTextAttachmentEditor?.tabId !== tabId || activeTextAttachmentEditor?.attachmentId !== attachmentId) return;
1708
+ if (elements.attachmentTextEditor) elements.attachmentTextEditor.value = normalizeTextAttachmentContent(text);
1709
+ if (elements.attachmentTextSaveButton) elements.attachmentTextSaveButton.disabled = false;
1710
+ renderTextAttachmentEditorMeta();
1711
+ setAttachmentTextStatus("Edit the text, then save it back to the attachment.", "muted");
1712
+ queueMicrotask(() => elements.attachmentTextEditor?.focus());
1713
+ } catch (error) {
1714
+ if (elements.attachmentTextSaveButton) elements.attachmentTextSaveButton.disabled = true;
1715
+ setAttachmentTextStatus(`Failed to open text attachment: ${error.message || String(error)}`, "error");
1716
+ }
1717
+ }
1718
+
1719
+ function totalAttachmentBytesWithReplacement(tabId, attachmentId, nextSize) {
1720
+ return attachmentsForTab(tabId).reduce((sum, attachment) => sum + (attachment.id === attachmentId ? nextSize : attachment.size || 0), 0);
1721
+ }
1722
+
1723
+ function saveTextAttachmentEdit() {
1724
+ if (!activeTextAttachmentEditor) return;
1725
+ const { tabId, attachmentId } = activeTextAttachmentEditor;
1726
+ const attachment = attachmentById(tabId, attachmentId);
1727
+ if (!attachment) {
1728
+ setAttachmentTextStatus("Attachment no longer exists.", "error");
1729
+ return;
1730
+ }
1731
+
1732
+ const text = elements.attachmentTextEditor?.value || "";
1733
+ const name = attachment.name || longInputAttachmentFileName();
1734
+ const mimeType = attachment.mimeType || inferMimeTypeFromName(name) || LONG_INPUT_ATTACHMENT_MIME_TYPE;
1735
+ const nextFile = makeTextAttachmentFile(text, name, mimeType);
1736
+ if (nextFile.size > ATTACHMENT_MAX_FILE_BYTES) {
1737
+ setAttachmentTextStatus(`Edited file is larger than ${formatBytes(ATTACHMENT_MAX_FILE_BYTES)}.`, "error");
1738
+ return;
1739
+ }
1740
+ if (totalAttachmentBytesWithReplacement(tabId, attachmentId, nextFile.size) > ATTACHMENT_MAX_TOTAL_BYTES) {
1741
+ setAttachmentTextStatus(`Edited attachments exceed ${formatBytes(ATTACHMENT_MAX_TOTAL_BYTES)} total.`, "error");
1742
+ return;
1743
+ }
1744
+
1745
+ if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
1746
+ attachment.file = nextFile;
1747
+ attachment.name = name;
1748
+ attachment.mimeType = nextFile.type || mimeType;
1749
+ attachment.size = nextFile.size || 0;
1750
+ attachment.kind = attachmentKind(attachment.mimeType, attachment.name);
1751
+ attachment.previewUrl = undefined;
1752
+ if (tabId === activeTabId) renderAttachmentTray();
1753
+ addEvent(`updated text attachment ${attachment.name} (${formatBytes(attachment.size)})`, "info");
1754
+ closeTextAttachmentEditor();
1477
1755
  }
1478
1756
 
1479
1757
  function clipboardFiles(dataTransfer) {
@@ -1501,9 +1779,15 @@ function clipboardFiles(dataTransfer) {
1501
1779
 
1502
1780
  function handleAttachmentPaste(event) {
1503
1781
  const files = clipboardFiles(event.clipboardData);
1504
- if (!files.length) return;
1782
+ if (files.length) {
1783
+ event.preventDefault();
1784
+ addAttachmentFiles(files, "clipboard");
1785
+ return;
1786
+ }
1787
+
1788
+ const text = event.clipboardData?.getData("text/plain") || "";
1789
+ if (!attachLongTextAsFile(text, "clipboard text")) return;
1505
1790
  event.preventDefault();
1506
- addAttachmentFiles(files, "clipboard");
1507
1791
  }
1508
1792
 
1509
1793
  function isFileDrag(event) {
@@ -2592,7 +2876,7 @@ function restoreActiveDraft() {
2592
2876
 
2593
2877
  function focusPromptInput({ defer = false } = {}) {
2594
2878
  const focus = () => {
2595
- if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.nativeCommandDialog.open || elements.promptListDialog?.open || document.visibilityState === "hidden") return;
2879
+ if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.nativeCommandDialog.open || elements.appRunnerInfoDialog?.open || elements.promptListDialog?.open || elements.attachmentTextDialog?.open || document.visibilityState === "hidden") return;
2596
2880
  try {
2597
2881
  elements.promptInput.focus({ preventScroll: true });
2598
2882
  } catch {
@@ -2666,6 +2950,7 @@ function resetActiveTabUi() {
2666
2950
  else renderQueue({ tabId: activeTabId, steering: [], followUp: [] });
2667
2951
  elements.commandsBox.textContent = "Loading…";
2668
2952
  elements.commandsBox.classList.add("muted");
2953
+ renderAppRunnerControls();
2669
2954
  renderWidgets();
2670
2955
  renderGitWorkflow();
2671
2956
  renderFooter();
@@ -2888,6 +3173,7 @@ function setNewTabMenuOpen(open) {
2888
3173
  function openNewTabMenu() {
2889
3174
  setPublishMenuOpen(false);
2890
3175
  setNativeCommandMenuOpen(false);
3176
+ setAppRunnerMenuOpen(false);
2891
3177
  setOptionsMenuOpen(false);
2892
3178
  setNewTabMenuOpen(true);
2893
3179
  }
@@ -2973,10 +3259,15 @@ function currentDirectoryForNewTab() {
2973
3259
  async function createTerminalTab(cwd = currentDirectoryForNewTab(), { triggerButton = elements.newTabButton } = {}) {
2974
3260
  setMobileTabsExpanded(false);
2975
3261
  setNewTabMenuOpen(false);
3262
+ const resolvedCwd = cwd || currentDirectoryForNewTab();
3263
+ if (!resolvedCwd && tabs.length === 0) {
3264
+ await createTerminalTabFromChosenDirectory({ triggerButton });
3265
+ return;
3266
+ }
2976
3267
  const disabledButtons = new Set([elements.newTabButton, triggerButton].filter(Boolean));
2977
3268
  for (const button of disabledButtons) button.disabled = true;
2978
3269
  try {
2979
- const response = await api("/api/tabs", { method: "POST", body: { cwd: cwd || currentDirectoryForNewTab() }, scoped: false });
3270
+ const response = await api("/api/tabs", { method: "POST", body: { cwd: resolvedCwd }, scoped: false });
2980
3271
  tabs = response.data?.tabs || tabs;
2981
3272
  syncTabMetadata(tabs);
2982
3273
  const tab = response.data?.tab;
@@ -3002,6 +3293,17 @@ async function createTerminalTabFromChosenDirectory({ triggerButton = elements.n
3002
3293
  await createTerminalTab(cwd, { triggerButton });
3003
3294
  }
3004
3295
 
3296
+ async function createFirstTerminalTabFromChosenDirectory() {
3297
+ if (firstTerminalCwdPromptShown || tabs.length > 0) return;
3298
+ firstTerminalCwdPromptShown = true;
3299
+ const cwd = await pickCwd({ id: "first-terminal", title: "first terminal" }, "", { title: "Choose CWD for first terminal" });
3300
+ if (!cwd) {
3301
+ addEvent("choose a CWD to start the first terminal", "warn");
3302
+ return;
3303
+ }
3304
+ await createTerminalTab(cwd, { triggerButton: null });
3305
+ }
3306
+
3005
3307
  function tabHasActiveAgent(tab) {
3006
3308
  const activity = activityForTab(tab);
3007
3309
  const indicator = tabIndicator(tab);
@@ -3051,6 +3353,7 @@ async function closeTerminalTabs(tabIds, { label = "selected terminal tabs" } =
3051
3353
  tabDrafts.delete(id);
3052
3354
  clearAttachments(id);
3053
3355
  clearGitWorkflowForTab(id);
3356
+ appRunnerDataByTab.delete(id);
3054
3357
  }
3055
3358
  clearOpenTerminalTabGroup(null, { force: true });
3056
3359
 
@@ -3093,10 +3396,14 @@ async function closeAllTerminalTabs() {
3093
3396
  }
3094
3397
 
3095
3398
  async function initializeTabs() {
3096
- await refreshTabs({ selectStored: true });
3399
+ const loadedTabs = await refreshTabs({ selectStored: true });
3097
3400
  resetActiveTabUi();
3098
3401
  renderTabs();
3099
3402
  restoreActiveDraft();
3403
+ if (!loadedTabs.length) {
3404
+ await createFirstTerminalTabFromChosenDirectory();
3405
+ return;
3406
+ }
3100
3407
  focusPromptInput({ defer: true });
3101
3408
  const tabContext = activeTabContext();
3102
3409
  connectEvents(tabContext);
@@ -3582,6 +3889,50 @@ function footerCostAuthLabel() {
3582
3889
  return /codex|copilot|chatgpt/i.test(provider) ? "sub" : "api";
3583
3890
  }
3584
3891
 
3892
+ function contextWindowFromSources(...sources) {
3893
+ for (const source of sources) {
3894
+ const value = typeof source === "object" && source !== null ? source.contextWindow : source;
3895
+ const contextWindow = Number(value);
3896
+ if (Number.isFinite(contextWindow) && contextWindow > 0) return contextWindow;
3897
+ }
3898
+ return 0;
3899
+ }
3900
+
3901
+ function contextUsageUnknownAfterCompaction(tabId = activeTabId) {
3902
+ return !!tabId && contextUsageUnknownAfterCompactionByTab.has(tabId);
3903
+ }
3904
+
3905
+ function unknownFooterContextText(contextUsage = null) {
3906
+ const contextWindow = contextWindowFromSources(
3907
+ contextUsage,
3908
+ latestStats?.contextUsage,
3909
+ currentState?.contextUsage,
3910
+ currentState?.model?.contextWindow,
3911
+ );
3912
+ return contextWindow ? `?/${formatFooterTokenCount(contextWindow)}` : "?";
3913
+ }
3914
+
3915
+ function contextUsageWithUnknownPercent(contextUsage = null) {
3916
+ return {
3917
+ ...(contextUsage || {}),
3918
+ percent: null,
3919
+ contextWindow: contextWindowFromSources(contextUsage, latestStats?.contextUsage, currentState?.contextUsage, currentState?.model?.contextWindow),
3920
+ };
3921
+ }
3922
+
3923
+ function markContextUsageUnknownAfterCompaction(tabId = activeTabId) {
3924
+ if (!tabId) return;
3925
+ contextUsageUnknownAfterCompactionByTab.set(tabId, Date.now());
3926
+ if (tabId !== activeTabId) return;
3927
+ if (currentState) currentState = { ...currentState, contextUsage: contextUsageWithUnknownPercent(currentState.contextUsage) };
3928
+ if (latestStats) latestStats = { ...latestStats, contextUsage: contextUsageWithUnknownPercent(latestStats.contextUsage) };
3929
+ }
3930
+
3931
+ function clearContextUsageUnknownAfterCompaction(tabId = activeTabId) {
3932
+ if (!tabId) return;
3933
+ contextUsageUnknownAfterCompactionByTab.delete(tabId);
3934
+ }
3935
+
3585
3936
  function footerStatsTokensDisplay(stats = latestStats) {
3586
3937
  const tokens = stats?.tokens;
3587
3938
  if (!tokens) return "";
@@ -3602,7 +3953,8 @@ function footerContextDisplayWithAuto(value, state = currentState) {
3602
3953
 
3603
3954
  function footerStatsContextDisplay(stats = latestStats) {
3604
3955
  const usage = stats?.contextUsage || currentState?.contextUsage;
3605
- const contextWindow = usage?.contextWindow ?? currentState?.model?.contextWindow ?? 0;
3956
+ const contextWindow = contextWindowFromSources(usage, currentState?.model?.contextWindow);
3957
+ if (contextUsageUnknownAfterCompaction()) return footerContextDisplayWithAuto(unknownFooterContextText(usage));
3606
3958
  if (!contextWindow) return "";
3607
3959
  const rawPercent = Number(usage?.percent);
3608
3960
  const percent = Number.isFinite(rawPercent) ? `${rawPercent.toFixed(1)}%` : "?";
@@ -3901,8 +4253,10 @@ function footerPayloadWithLiveModel(payload) {
3901
4253
  const effort = footerThinkingDisplay();
3902
4254
  const hasThinkingChip = [...payload.main, ...payload.meta].some((chip) => chip?.key === "thinking");
3903
4255
  const contextChip = (chip) => {
3904
- const value = footerContextDisplayWithAuto(chip?.value);
3905
- return { ...chip, value, title: `context: ${value}` };
4256
+ const usageUnknown = contextUsageUnknownAfterCompaction();
4257
+ const value = usageUnknown ? footerContextDisplayWithAuto(unknownFooterContextText(chip?.contextUsage)) : footerContextDisplayWithAuto(chip?.value);
4258
+ const contextUsage = usageUnknown ? contextUsageWithUnknownPercent(chip?.contextUsage) : chip?.contextUsage;
4259
+ return { ...chip, value, title: `context: ${value}`, ...(contextUsage ? { contextUsage } : {}) };
3906
4260
  };
3907
4261
  const effortChip = (chip) => ({ ...chip, key: "thinking", label: "effort", value: effort, title: `effort: ${effort}`, tone: "mauve" });
3908
4262
  const splitChip = (chip) => {
@@ -5243,13 +5597,537 @@ function renderReleaseAurLogWidget() {
5243
5597
  actions.append(releaseNpmActionButton("Close log", "/release-aur logs close"));
5244
5598
  header.append(titleWrap, meta, actions);
5245
5599
 
5246
- const logLines = lines.slice(2).filter((line, index) => index > 0 || stripAnsi(line).trim());
5247
- const streamHeader = releaseNpmStreamHeader("Saved AUR output stream", logLines.length);
5600
+ const logLines = lines.slice(2).filter((line, index) => index > 0 || stripAnsi(line).trim());
5601
+ const streamHeader = releaseNpmStreamHeader("Saved AUR output stream", logLines.length);
5602
+ const terminal = make("div", "release-npm-terminal");
5603
+ for (const line of logLines) {
5604
+ appendReleaseNpmTerminalLine(terminal, line);
5605
+ }
5606
+ const outputDetails = renderReleaseNpmOutputDetails("release-aur:logs", streamHeader, terminal);
5607
+ node.append(header, outputDetails);
5608
+ requestAnimationFrame(() => { if (outputDetails.open) terminal.scrollTop = terminal.scrollHeight; });
5609
+ return node;
5610
+ }
5611
+
5612
+ function activeAppRunnerData() {
5613
+ return activeTabId ? appRunnerDataByTab.get(activeTabId) || { runners: [], activeRun: null } : { runners: [], activeRun: null };
5614
+ }
5615
+
5616
+ function setAppRunnerData(tabId, data = {}) {
5617
+ if (!tabId) return;
5618
+ const previous = appRunnerDataByTab.get(tabId) || { runners: [], activeRun: null, customRunnerConfig: null };
5619
+ appRunnerDataByTab.set(tabId, {
5620
+ cwd: data.cwd || previous.cwd || "",
5621
+ runners: Array.isArray(data.runners) ? data.runners : previous.runners || [],
5622
+ customRunnerConfig: data.customRunnerConfig || previous.customRunnerConfig || null,
5623
+ activeRun: Object.prototype.hasOwnProperty.call(data, "activeRun") ? data.activeRun : previous.activeRun || null,
5624
+ });
5625
+ }
5626
+
5627
+ function appRunnerIsRunning(run) {
5628
+ return run?.status === "running" || run?.stopping === true;
5629
+ }
5630
+
5631
+ function appRunnerStatusLabel(run) {
5632
+ if (run?.stopping && run.status === "running") return "stopping";
5633
+ if (run?.status === "done") return "exit 0";
5634
+ if (run?.status === "failed") return run.signal ? `signal ${run.signal}` : `exit ${run.exitCode ?? "?"}`;
5635
+ if (run?.status === "error") return "error";
5636
+ return run?.status || "running";
5637
+ }
5638
+
5639
+ function appRunnerElapsedLabel(run) {
5640
+ const startedAt = Date.parse(run?.startedAt || "");
5641
+ if (!Number.isFinite(startedAt)) return "";
5642
+ const endedAt = Date.parse(run?.endedAt || "");
5643
+ const end = Number.isFinite(endedAt) ? endedAt : Date.now();
5644
+ return formatDuration(end - startedAt);
5645
+ }
5646
+
5647
+ function appRunnerActionButton(label, handler, className = "") {
5648
+ const button = make("button", `release-npm-action ${className}`.trim(), label);
5649
+ button.type = "button";
5650
+ button.addEventListener("click", handler);
5651
+ return button;
5652
+ }
5653
+
5654
+ async function refreshAppRunners(tabContext = activeTabContext()) {
5655
+ if (!tabContext.tabId) return;
5656
+ const response = await api("/api/app-runners", { tabId: tabContext.tabId });
5657
+ if (!isCurrentTabContext(tabContext)) return;
5658
+ setAppRunnerData(tabContext.tabId, response.data || {});
5659
+ renderAppRunnerControls();
5660
+ renderWidgets();
5661
+ }
5662
+
5663
+ async function runAppRunner(runnerId) {
5664
+ const tabContext = activeTabContext();
5665
+ if (!tabContext.tabId || !runnerId) return;
5666
+ setComposerActionsOpen(false);
5667
+ setAppRunnerMenuOpen(false);
5668
+ try {
5669
+ const response = await api("/api/app-runner", { method: "POST", body: { runnerId }, tabId: tabContext.tabId });
5670
+ if (!isCurrentTabContext(tabContext)) return;
5671
+ setAppRunnerData(tabContext.tabId, response.data || {});
5672
+ renderAppRunnerControls();
5673
+ renderWidgets();
5674
+ const command = response.data?.activeRun?.displayCommand || "app runner";
5675
+ addEvent(`started ${command}`, "info");
5676
+ } catch (error) {
5677
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
5678
+ }
5679
+ }
5680
+
5681
+ async function stopAppRunner() {
5682
+ const tabContext = activeTabContext();
5683
+ if (!tabContext.tabId) return;
5684
+ try {
5685
+ const response = await api("/api/app-runner/stop", { method: "POST", body: {}, tabId: tabContext.tabId });
5686
+ if (!isCurrentTabContext(tabContext)) return;
5687
+ setAppRunnerData(tabContext.tabId, response.data || {});
5688
+ renderAppRunnerControls();
5689
+ renderWidgets();
5690
+ addEvent("app runner stop requested", "warn");
5691
+ } catch (error) {
5692
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
5693
+ }
5694
+ }
5695
+
5696
+ async function clearAppRunner() {
5697
+ const tabContext = activeTabContext();
5698
+ if (!tabContext.tabId) return;
5699
+ try {
5700
+ const response = await api("/api/app-runner/clear", { method: "POST", body: {}, tabId: tabContext.tabId });
5701
+ if (!isCurrentTabContext(tabContext)) return;
5702
+ setAppRunnerData(tabContext.tabId, response.data || {});
5703
+ renderAppRunnerControls();
5704
+ renderWidgets();
5705
+ } catch (error) {
5706
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
5707
+ }
5708
+ }
5709
+
5710
+ function appRunnerOutputText(run) {
5711
+ const lines = Array.isArray(run?.lines) ? run.lines : [];
5712
+ return lines.join("\n").trimEnd();
5713
+ }
5714
+
5715
+ async function copyAppRunnerOutput(run) {
5716
+ const text = appRunnerOutputText(run);
5717
+ if (!text.trim()) {
5718
+ addEvent("app runner output is empty", "warn");
5719
+ return;
5720
+ }
5721
+ try {
5722
+ await copyText(text);
5723
+ addEvent("copied app runner output", "info");
5724
+ } catch (error) {
5725
+ addEvent(`app runner output copy failed: ${error.message || String(error)}`, "warn");
5726
+ }
5727
+ }
5728
+
5729
+ const APP_RUNNER_SUPPORTED_ITEMS = [
5730
+ "Project-local custom runners from .pi-webui-runners.json",
5731
+ "package.json scripts: bun/npm/pnpm/yarn dev, start, serve",
5732
+ "npx frameworks: Vite, Next, Astro, Storybook",
5733
+ "Rust: cargo run",
5734
+ "Python: uv run or python entry files such as Main.py, main.py, src/main.py",
5735
+ "Go/Golang: go run",
5736
+ "Zig: zig build run or zig run",
5737
+ "C/C++: CMake, cc/c++ main files",
5738
+ "Docker Compose: docker compose up",
5739
+ "Shell scripts: bash/zsh/fish in root, dev/, scripts/, dev/scripts/",
5740
+ "Deno, make, just, and plain Node entry files",
5741
+ ];
5742
+ const APP_RUNNER_SUPPORTED_TOOLTIP = [
5743
+ "No app runner detected for this tab cwd.",
5744
+ "",
5745
+ "Currently supported:",
5746
+ ...APP_RUNNER_SUPPORTED_ITEMS.map((item) => `• ${item}`),
5747
+ ].join("\n");
5748
+
5749
+ function appRunnerMenuCanOpen() {
5750
+ const data = activeAppRunnerData();
5751
+ return Array.isArray(data.runners) && data.runners.length > 0 && !appRunnerIsRunning(data.activeRun);
5752
+ }
5753
+
5754
+ function activeAppRunnerCustomConfig() {
5755
+ return activeAppRunnerData().customRunnerConfig || { runners: [], projectRoot: "", displayProjectRoot: "", displayConfigFile: "" };
5756
+ }
5757
+
5758
+ function resetAppRunnerCustomDraft() {
5759
+ appRunnerCustomDraft = { id: "", label: "", command: "./", path: "", args: "" };
5760
+ appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
5761
+ }
5762
+
5763
+ function appRunnerRelativeDir(filePath) {
5764
+ const normalized = String(filePath || "").replace(/\\/g, "/").replace(/^\.\/+/, "");
5765
+ const index = normalized.lastIndexOf("/");
5766
+ return index === -1 ? "" : normalized.slice(0, index);
5767
+ }
5768
+
5769
+ function appRunnerCustomArgsText(args) {
5770
+ return Array.isArray(args) ? args.join(" ") : String(args || "");
5771
+ }
5772
+
5773
+ function appRunnerCustomDraftPayload() {
5774
+ return {
5775
+ id: appRunnerCustomDraft.id || undefined,
5776
+ label: appRunnerCustomDraft.label.trim(),
5777
+ command: appRunnerCustomDraft.command.trim() || "./",
5778
+ path: appRunnerCustomDraft.path.trim(),
5779
+ args: appRunnerCustomDraft.args.trim(),
5780
+ };
5781
+ }
5782
+
5783
+ function updateAppRunnerCustomDraftFrom(container) {
5784
+ if (!container) return;
5785
+ appRunnerCustomDraft = {
5786
+ id: appRunnerCustomDraft.id || "",
5787
+ label: container.querySelector("#appRunnerCustomLabelInput")?.value || "",
5788
+ command: container.querySelector("#appRunnerCustomCommandInput")?.value || "./",
5789
+ path: container.querySelector("#appRunnerCustomPathInput")?.value || "",
5790
+ args: container.querySelector("#appRunnerCustomArgsInput")?.value || "",
5791
+ };
5792
+ }
5793
+
5794
+ function appRunnerInputField({ id, label, value, placeholder = "", hint = "" }) {
5795
+ const field = make("label", "app-runner-custom-field");
5796
+ field.setAttribute("for", id);
5797
+ field.append(make("span", "", label));
5798
+ const input = make("input", "dialog-input");
5799
+ input.id = id;
5800
+ input.type = "text";
5801
+ input.value = value || "";
5802
+ input.placeholder = placeholder;
5803
+ input.autocomplete = "off";
5804
+ input.spellcheck = false;
5805
+ field.append(input);
5806
+ if (hint) field.append(make("small", "muted", hint));
5807
+ input.addEventListener("input", () => updateAppRunnerCustomDraftFrom(field.closest(".app-runner-custom-form")));
5808
+ input.addEventListener("keydown", (event) => {
5809
+ if (event.key !== "Enter") return;
5810
+ event.preventDefault();
5811
+ saveAppRunnerCustomRunner(field.closest(".app-runner-custom-form"));
5812
+ });
5813
+ return { field, input };
5814
+ }
5815
+
5816
+ async function saveAppRunnerCustomRunner(form) {
5817
+ updateAppRunnerCustomDraftFrom(form);
5818
+ const payload = appRunnerCustomDraftPayload();
5819
+ if (!payload.path) {
5820
+ addEvent("custom app runner path is required", "warn");
5821
+ form?.querySelector("#appRunnerCustomPathInput")?.focus();
5822
+ return;
5823
+ }
5824
+ const tabContext = activeTabContext();
5825
+ try {
5826
+ const response = await api("/api/app-runner-config", { method: "POST", body: { runner: payload }, tabId: tabContext.tabId });
5827
+ if (!isCurrentTabContext(tabContext)) return;
5828
+ setAppRunnerData(tabContext.tabId, response.data || {});
5829
+ resetAppRunnerCustomDraft();
5830
+ renderAppRunnerControls();
5831
+ renderWidgets();
5832
+ renderAppRunnerInfoDialog();
5833
+ addEvent("saved custom app runner", "info");
5834
+ } catch (error) {
5835
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
5836
+ }
5837
+ }
5838
+
5839
+ async function deleteAppRunnerCustomRunner(id) {
5840
+ const tabContext = activeTabContext();
5841
+ try {
5842
+ const response = await api("/api/app-runner-config", { method: "DELETE", body: { id }, tabId: tabContext.tabId });
5843
+ if (!isCurrentTabContext(tabContext)) return;
5844
+ setAppRunnerData(tabContext.tabId, response.data || {});
5845
+ if (appRunnerCustomDraft.id === id) resetAppRunnerCustomDraft();
5846
+ renderAppRunnerControls();
5847
+ renderWidgets();
5848
+ renderAppRunnerInfoDialog();
5849
+ addEvent("deleted custom app runner", "warn");
5850
+ } catch (error) {
5851
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
5852
+ }
5853
+ }
5854
+
5855
+ async function loadAppRunnerFileBrowser(relativePath = "") {
5856
+ const tabContext = activeTabContext();
5857
+ const path = String(relativePath || "").replace(/^\.\/+/, "").replace(/\/+$/g, "");
5858
+ appRunnerFileBrowserState = { open: true, loading: true, path, data: null, error: "" };
5859
+ renderAppRunnerInfoDialog();
5860
+ try {
5861
+ const response = await api(`/api/app-runner-files?path=${encodeURIComponent(path)}`, { tabId: tabContext.tabId });
5862
+ if (!isCurrentTabContext(tabContext)) return;
5863
+ appRunnerFileBrowserState = { open: true, loading: false, path, data: response.data || {}, error: "" };
5864
+ renderAppRunnerInfoDialog();
5865
+ } catch (error) {
5866
+ if (!isCurrentTabContext(tabContext)) return;
5867
+ appRunnerFileBrowserState = { open: true, loading: false, path, data: null, error: error.message || String(error) };
5868
+ renderAppRunnerInfoDialog();
5869
+ }
5870
+ }
5871
+
5872
+ function renderAppRunnerFileBrowser() {
5873
+ if (!appRunnerFileBrowserState.open) return null;
5874
+ const browser = make("div", "app-runner-file-browser");
5875
+ if (appRunnerFileBrowserState.loading) {
5876
+ browser.append(make("div", "muted", "Loading project files…"));
5877
+ return browser;
5878
+ }
5879
+ if (appRunnerFileBrowserState.error) {
5880
+ browser.append(make("div", "path-picker-error", appRunnerFileBrowserState.error));
5881
+ return browser;
5882
+ }
5883
+ const data = appRunnerFileBrowserState.data || {};
5884
+ const header = make("div", "app-runner-file-browser-header");
5885
+ header.append(make("strong", "", data.displayRelativeDir || "."));
5886
+ const close = make("button", "app-runner-file-browser-close", "Hide browser");
5887
+ close.type = "button";
5888
+ close.addEventListener("click", () => {
5889
+ appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
5890
+ renderAppRunnerInfoDialog();
5891
+ });
5892
+ header.append(close);
5893
+ browser.append(header);
5894
+
5895
+ const roots = make("div", "path-picker-roots app-runner-file-browser-roots");
5896
+ if (data.parent !== null && data.parent !== undefined) roots.append(pathPickerButton("↑ Parent", data.parent || ".", () => loadAppRunnerFileBrowser(data.parent || ""), "path-picker-root-button"));
5897
+ roots.append(pathPickerButton("Project root", data.displayProjectRoot || "Project root", () => loadAppRunnerFileBrowser(""), "path-picker-root-button"));
5898
+ browser.append(roots);
5899
+
5900
+ const list = make("div", "path-picker-list app-runner-file-browser-list");
5901
+ const directories = Array.isArray(data.directories) ? data.directories : [];
5902
+ const files = Array.isArray(data.files) ? data.files : [];
5903
+ for (const directory of directories) {
5904
+ const button = pathPickerButton(`${directory.name}/`, directory.path, () => loadAppRunnerFileBrowser(directory.path), `path-picker-directory${directory.hidden ? " hidden-directory" : ""}`);
5905
+ list.append(button);
5906
+ }
5907
+ for (const file of files) {
5908
+ const button = pathPickerButton(file.name, file.path, () => {
5909
+ appRunnerCustomDraft.path = file.path;
5910
+ appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
5911
+ renderAppRunnerInfoDialog();
5912
+ }, `path-picker-directory app-runner-file-choice${file.hidden ? " hidden-directory" : ""}`);
5913
+ list.append(button);
5914
+ }
5915
+ if (!directories.length && !files.length) list.append(make("div", "path-picker-empty muted", "No files in this directory."));
5916
+ browser.append(list);
5917
+ if (data.truncated) browser.append(make("div", "path-picker-error", "Showing the first project entries only."));
5918
+ return browser;
5919
+ }
5920
+
5921
+ function renderAppRunnerCustomSection() {
5922
+ const config = activeAppRunnerCustomConfig();
5923
+ const section = make("section", "app-runner-info-section app-runner-custom-section");
5924
+ const titleRow = make("div", "app-runner-section-title-row");
5925
+ titleRow.append(make("h3", "", "Custom project runners"));
5926
+ if (config.displayConfigFile) titleRow.append(make("code", "", config.displayConfigFile));
5927
+ section.append(titleRow);
5928
+ section.append(make("p", "muted", "Add project-local runners saved in .pi-webui-runners.json. Command defaults to ./, so a selected file runs as ./path/to/file."));
5929
+
5930
+ const existing = make("div", "app-runner-custom-list");
5931
+ const customRunners = Array.isArray(config.runners) ? config.runners : [];
5932
+ if (!customRunners.length) {
5933
+ existing.append(make("div", "app-runner-custom-empty muted", "No custom runners saved for this project yet."));
5934
+ } else {
5935
+ for (const runner of customRunners) {
5936
+ const row = make("div", "app-runner-custom-item");
5937
+ const details = make("div", "app-runner-custom-item-details");
5938
+ details.append(make("strong", "", runner.label || runner.path || "custom runner"), make("code", "", runner.displayCommand || runner.path || ""));
5939
+ const actions = make("div", "app-runner-custom-item-actions");
5940
+ const edit = make("button", "", "Edit");
5941
+ edit.type = "button";
5942
+ edit.addEventListener("click", () => {
5943
+ appRunnerCustomDraft = { id: runner.id || "", label: runner.label || "", command: runner.command || "./", path: runner.path || "", args: appRunnerCustomArgsText(runner.args) };
5944
+ appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
5945
+ renderAppRunnerInfoDialog();
5946
+ });
5947
+ const remove = make("button", "danger", "Delete");
5948
+ remove.type = "button";
5949
+ remove.addEventListener("click", () => {
5950
+ if (!confirm(`Delete custom app runner “${runner.label || runner.path || runner.id}”?`)) return;
5951
+ deleteAppRunnerCustomRunner(runner.id);
5952
+ });
5953
+ actions.append(edit, remove);
5954
+ row.append(details, actions);
5955
+ existing.append(row);
5956
+ }
5957
+ }
5958
+ section.append(existing);
5959
+
5960
+ const form = make("div", "app-runner-custom-form");
5961
+ const labelField = appRunnerInputField({ id: "appRunnerCustomLabelInput", label: "Label", value: appRunnerCustomDraft.label, placeholder: "My app" });
5962
+ const commandField = appRunnerInputField({ id: "appRunnerCustomCommandInput", label: "Command", value: appRunnerCustomDraft.command || "./", placeholder: "./", hint: "Use ./ to execute the selected file directly, or use bash, python3, node, bun, uv run, etc." });
5963
+ const pathField = appRunnerInputField({ id: "appRunnerCustomPathInput", label: "Path to file", value: appRunnerCustomDraft.path, placeholder: "dev/scripts/start.sh" });
5964
+ const pathRow = make("div", "app-runner-custom-path-row");
5965
+ pathRow.append(pathField.field);
5966
+ const browse = make("button", "app-runner-custom-browse", "Browse…");
5967
+ browse.type = "button";
5968
+ browse.addEventListener("click", () => {
5969
+ updateAppRunnerCustomDraftFrom(form);
5970
+ loadAppRunnerFileBrowser(appRunnerRelativeDir(appRunnerCustomDraft.path));
5971
+ });
5972
+ pathRow.append(browse);
5973
+ const argsField = appRunnerInputField({ id: "appRunnerCustomArgsInput", label: "Args", value: appRunnerCustomDraft.args, placeholder: "--port 3000", hint: "Optional extra args, space-separated." });
5974
+ form.append(labelField.field, commandField.field, pathRow, argsField.field);
5975
+ const formActions = make("div", "app-runner-custom-form-actions");
5976
+ const save = make("button", "primary", appRunnerCustomDraft.id ? "Save changes" : "Add runner");
5977
+ save.type = "button";
5978
+ save.addEventListener("click", () => saveAppRunnerCustomRunner(form));
5979
+ const reset = make("button", "", "Reset");
5980
+ reset.type = "button";
5981
+ reset.addEventListener("click", () => { resetAppRunnerCustomDraft(); renderAppRunnerInfoDialog(); });
5982
+ formActions.append(save, reset);
5983
+ form.append(formActions);
5984
+ const browser = renderAppRunnerFileBrowser();
5985
+ if (browser) form.append(browser);
5986
+ section.append(form);
5987
+ return section;
5988
+ }
5989
+
5990
+ function renderAppRunnerControls() {
5991
+ const menu = elements.appRunnerMenu;
5992
+ const button = elements.appRunnerMenuButton;
5993
+ const panel = elements.appRunnerMenuPanel;
5994
+ if (!menu || !button || !panel) return;
5995
+ const data = activeAppRunnerData();
5996
+ const runners = Array.isArray(data.runners) ? data.runners : [];
5997
+ const activeRun = data.activeRun;
5998
+ const running = appRunnerIsRunning(activeRun);
5999
+ menu.hidden = false;
6000
+ menu.classList.toggle("has-runners", runners.length > 0);
6001
+ if (elements.appRunnerInfoButton) {
6002
+ elements.appRunnerInfoButton.hidden = runners.length === 0;
6003
+ elements.appRunnerInfoButton.disabled = runners.length === 0;
6004
+ }
6005
+ button.disabled = running;
6006
+ button.title = running
6007
+ ? `App runner already running: ${activeRun.displayCommand || activeRun.label || "runner"}`
6008
+ : runners.length
6009
+ ? "Run a detected app runner"
6010
+ : "No app runners detected in this tab working directory";
6011
+ button.dataset.tooltip = runners.length ? "App runners: run detected project commands in this tab's working directory." : APP_RUNNER_SUPPORTED_TOOLTIP;
6012
+ button.setAttribute("aria-label", button.title);
6013
+ if (!runners.length || running) setAppRunnerMenuOpen(false);
6014
+
6015
+ panel.replaceChildren();
6016
+ for (const runner of runners) {
6017
+ const item = make("button", "composer-publish-menu-item composer-app-runner-menu-item");
6018
+ item.type = "button";
6019
+ item.setAttribute("role", "menuitem");
6020
+ const runnerDisplayCommand = runner.shortDisplayCommand || runner.displayCommand;
6021
+ item.title = runner.description ? `${runnerDisplayCommand}\n${runner.description}` : runnerDisplayCommand;
6022
+ item.addEventListener("click", () => runAppRunner(runner.id));
6023
+ const label = make("span", "app-runner-menu-item-label", runner.label || runnerDisplayCommand);
6024
+ const command = make("span", "app-runner-menu-item-command", runnerDisplayCommand);
6025
+ item.append(label, command);
6026
+ panel.append(item);
6027
+ }
6028
+ }
6029
+
6030
+ function renderAppRunnerInfoDialog() {
6031
+ const body = elements.appRunnerInfoBody;
6032
+ if (!body) return;
6033
+ const data = activeAppRunnerData();
6034
+ const runners = Array.isArray(data.runners) ? data.runners : [];
6035
+ body.replaceChildren();
6036
+
6037
+ const current = make("section", "app-runner-info-section");
6038
+ current.append(make("h3", "", "Detected in this tab"));
6039
+ if (runners.length) {
6040
+ const list = make("ul", "app-runner-info-list app-runner-info-detected-list");
6041
+ for (const runner of runners) {
6042
+ const command = runner.shortDisplayCommand || runner.displayCommand || runner.command || runner.id;
6043
+ const item = make("li");
6044
+ item.append(
6045
+ make("strong", "", runner.label || command || "runner"),
6046
+ make("code", "", command || "detected command"),
6047
+ );
6048
+ if (runner.description) item.append(make("span", "", runner.description));
6049
+ list.append(item);
6050
+ }
6051
+ current.append(list);
6052
+ } else {
6053
+ current.append(make("p", "muted", "No runners are currently detected for this tab working directory."));
6054
+ }
6055
+
6056
+ const how = make("section", "app-runner-info-section");
6057
+ how.append(make("h3", "", "How it works"));
6058
+ const howList = make("ul", "app-runner-info-list");
6059
+ for (const line of [
6060
+ "Detection is scoped to the active terminal tab's current working directory.",
6061
+ "Only commands/files that exist and runner binaries available on this system are shown.",
6062
+ "Starting a runner keeps live output pinned above the chat/terminal area.",
6063
+ "Only one app runner can be active per tab; Close/Stop terminates the process/server.",
6064
+ ]) howList.append(make("li", "", line));
6065
+ how.append(howList);
6066
+
6067
+ const supported = make("section", "app-runner-info-section");
6068
+ supported.append(make("h3", "", "Supported runner types"));
6069
+ const supportedList = make("ul", "app-runner-info-list app-runner-info-supported-list");
6070
+ for (const itemText of APP_RUNNER_SUPPORTED_ITEMS) supportedList.append(make("li", "", itemText));
6071
+ supported.append(supportedList);
6072
+
6073
+ body.append(current, renderAppRunnerCustomSection(), how, supported);
6074
+ }
6075
+
6076
+ function openAppRunnerInfoDialog() {
6077
+ if (!elements.appRunnerInfoDialog) return;
6078
+ renderAppRunnerInfoDialog();
6079
+ setAppRunnerMenuOpen(false);
6080
+ if (!elements.appRunnerInfoDialog.open) elements.appRunnerInfoDialog.showModal();
6081
+ }
6082
+
6083
+ function closeAppRunnerInfoDialog() {
6084
+ if (elements.appRunnerInfoDialog?.open) elements.appRunnerInfoDialog.close();
6085
+ }
6086
+
6087
+ function renderAppRunnerWidget() {
6088
+ const data = activeAppRunnerData();
6089
+ const run = data.activeRun;
6090
+ if (!run) return null;
6091
+ const running = appRunnerIsRunning(run);
6092
+ const status = appRunnerStatusLabel(run);
6093
+ const node = make("section", `widget release-npm-widget app-runner-widget${running ? " app-runner-live-widget" : " app-runner-log-widget"}`);
6094
+ node.setAttribute("aria-label", "app runner output");
6095
+
6096
+ const header = make("div", "release-npm-header");
6097
+ const titleWrap = make("div", "release-npm-title-wrap");
6098
+ titleWrap.append(make("span", "release-npm-kicker", "app runner"), make("strong", "release-npm-title", run.label || run.displayCommand || "app runner"));
6099
+
6100
+ const elapsed = appRunnerElapsedLabel(run);
6101
+ header.append(titleWrap);
6102
+
6103
+ const lines = Array.isArray(run.lines) && run.lines.length ? run.lines : [run.displayCommand ? `$ ${run.displayCommand}` : "Waiting for app runner output..."];
6104
+ const streamHeader = releaseNpmStreamHeader(running ? "Live app output" : "App output", run.lineCount || lines.length, { live: running });
5248
6105
  const terminal = make("div", "release-npm-terminal");
5249
- for (const line of logLines) {
5250
- appendReleaseNpmTerminalLine(terminal, line);
5251
- }
5252
- const outputDetails = renderReleaseNpmOutputDetails("release-aur:logs", streamHeader, terminal);
6106
+ terminal.setAttribute("role", "log");
6107
+ terminal.setAttribute("aria-live", running ? "polite" : "off");
6108
+ for (const line of lines) appendReleaseNpmTerminalLine(terminal, line);
6109
+
6110
+ const controlParts = [run.displayCommand, run.cwd, run.truncated ? "output truncated" : ""].map(cleanStatusText).filter(Boolean);
6111
+ const controls = make("div", "release-npm-controls app-runner-output-controls");
6112
+ const actions = make("div", "app-runner-output-actions");
6113
+ const closeButton = appRunnerActionButton("Close", running ? stopAppRunner : clearAppRunner, running ? "danger app-runner-close-action" : "app-runner-close-action");
6114
+ closeButton.title = running ? "Stop this app runner/process/server" : "Close app runner output";
6115
+ const copyButton = appRunnerActionButton("Copy output", () => copyAppRunnerOutput(run), "app-runner-copy-action");
6116
+ copyButton.title = "Copy app runner output";
6117
+ actions.append(closeButton, copyButton);
6118
+ if (running) {
6119
+ actions.append(appRunnerActionButton("Stop", stopAppRunner, "danger"));
6120
+ } else {
6121
+ const canRunAgain = (data.runners || []).some((runner) => runner.id === run.runnerId);
6122
+ if (canRunAgain) actions.append(appRunnerActionButton("Run again", () => runAppRunner(run.runnerId)));
6123
+ actions.append(appRunnerActionButton("Clear", clearAppRunner));
6124
+ }
6125
+ const pills = make("div", "app-runner-output-pills");
6126
+ if (run.kind) pills.append(make("span", "release-npm-pill", run.kind));
6127
+ pills.append(make("span", `release-npm-pill app-runner-status ${run.status || "running"}`.trim(), status));
6128
+ if (elapsed) pills.append(make("span", "release-npm-pill elapsed", elapsed));
6129
+ controls.append(actions, pills, make("span", "app-runner-output-meta", controlParts.join(" · ")));
6130
+ const outputDetails = renderReleaseNpmOutputDetails(`app-runner:${run.id || run.runnerId || "active"}`, streamHeader, terminal, controls);
5253
6131
  node.append(header, outputDetails);
5254
6132
  requestAnimationFrame(() => { if (outputDetails.open) terminal.scrollTop = terminal.scrollHeight; });
5255
6133
  return node;
@@ -5265,6 +6143,8 @@ function renderWidgets() {
5265
6143
  if (releaseAurOutput) elements.widgetArea.append(releaseAurOutput);
5266
6144
  const releaseAurLog = renderReleaseAurLogWidget();
5267
6145
  if (releaseAurLog) elements.widgetArea.append(releaseAurLog);
6146
+ const appRunnerWidget = renderAppRunnerWidget();
6147
+ if (appRunnerWidget) elements.widgetArea.append(appRunnerWidget);
5268
6148
 
5269
6149
  for (const [key, value] of widgets) {
5270
6150
  const widgetFeatureId = optionalFeatureWidgetFeatureId(key);
@@ -5288,6 +6168,8 @@ function setGitWorkflow(patch, { tabId = activeTabId } = {}) {
5288
6168
  const workflow = gitWorkflowForTab(tabId);
5289
6169
  if (!workflow) return null;
5290
6170
  Object.assign(workflow, patch);
6171
+ workflow.actionsDone = createGitWorkflowActionsDone(workflow.actionsDone);
6172
+ if (patch.step && !("process" in patch)) workflow.process = gitWorkflowProcessForStep(workflow.step, workflow.process);
5291
6173
  if (tabId === activeTabId) {
5292
6174
  gitWorkflow = workflow;
5293
6175
  renderGitWorkflow();
@@ -5323,24 +6205,141 @@ function formatCommitMessagePreview(message) {
5323
6205
  return [`=== SHORT ===`, message.short || "(empty)", "", "=== LONG ===", message.long || "(empty)"].join("\n");
5324
6206
  }
5325
6207
 
5326
- function addGitWorkflowAction(label, handler, className = "", disabled = gitWorkflow.busy) {
6208
+ function gitWorkflowMessageTitle(message) {
6209
+ return String(message?.short || message?.long || "").split("\n").find((line) => line.trim())?.trim() || "Pull request";
6210
+ }
6211
+
6212
+ function slugifyGitBranchPart(value) {
6213
+ return String(value || "")
6214
+ .normalize("NFKD")
6215
+ .replace(/[\u0300-\u036f]/g, "")
6216
+ .toLowerCase()
6217
+ .replace(/[^a-z0-9]+/g, "-")
6218
+ .replace(/^-+|-+$/g, "")
6219
+ .slice(0, 48);
6220
+ }
6221
+
6222
+ function defaultGitPrBranchName(message = gitWorkflow.message) {
6223
+ const title = gitWorkflowMessageTitle(message);
6224
+ const match = title.match(/^([a-z][a-z0-9-]*)(?:\([^)]*\))?:\s*(.+)$/i);
6225
+ const type = slugifyGitBranchPart(match?.[1] || "feat") || "feat";
6226
+ const summary = slugifyGitBranchPart(match?.[2] || title) || "feature";
6227
+ return `${type}/${summary}`;
6228
+ }
6229
+
6230
+ function formatGitPrPreview(pr) {
6231
+ if (!pr) return "No PR description loaded yet.";
6232
+ const header = [`=== PR DESCRIPTION ===`, `Branch: ${pr.branch || gitWorkflow.prBranch || "current branch"}`];
6233
+ if (pr.path) header.push(`File: ${pr.path}`);
6234
+ return [...header, "", pr.body || "(empty)"].join("\n");
6235
+ }
6236
+
6237
+ function addGitWorkflowAction(label, handler, className = "", disabled = gitWorkflow.busy, tooltip = "") {
5327
6238
  const button = make("button", className, label);
5328
6239
  button.type = "button";
5329
6240
  button.disabled = disabled;
6241
+ if (tooltip) {
6242
+ button.title = tooltip;
6243
+ button.dataset.tooltip = tooltip;
6244
+ button.setAttribute("aria-label", `${label}. ${tooltip.replace(/\s+/g, " ")}`);
6245
+ }
5330
6246
  button.addEventListener("click", handler);
5331
6247
  elements.gitWorkflowActions.append(button);
5332
6248
  return button;
5333
6249
  }
5334
6250
 
6251
+ function setGitPrDialogStatus(message = "", level = "muted") {
6252
+ if (!elements.gitPrStatus) return;
6253
+ elements.gitPrStatus.textContent = message;
6254
+ elements.gitPrStatus.className = `git-pr-status ${level || "muted"}`;
6255
+ }
6256
+
6257
+ function resolveGitPrDialog(value) {
6258
+ const resolve = activeGitPrDialogResolve;
6259
+ activeGitPrDialogResolve = null;
6260
+ if (elements.gitPrDialog?.open) elements.gitPrDialog.close();
6261
+ if (resolve) resolve(value);
6262
+ }
6263
+
6264
+ function openGitPrReviewDialog(pr, { title = "" } = {}) {
6265
+ if (!elements.gitPrDialog || !elements.gitPrTitleInput || !elements.gitPrBodyEditor) return Promise.resolve(null);
6266
+ if (activeGitPrDialogResolve) resolveGitPrDialog(null);
6267
+ elements.gitPrTitleInput.value = title || gitWorkflowMessageTitle(gitWorkflow.message);
6268
+ elements.gitPrBodyEditor.value = pr?.body || "";
6269
+ setGitPrDialogStatus(`Review ${pr?.path || "the generated PR description"}. Edit if needed, then create the pull request.`);
6270
+ return new Promise((resolve) => {
6271
+ activeGitPrDialogResolve = resolve;
6272
+ elements.gitPrDialog.showModal();
6273
+ queueMicrotask(() => elements.gitPrBodyEditor.focus());
6274
+ });
6275
+ }
6276
+
6277
+ function gitWorkflowProcessForStep(step = gitWorkflow.step, fallback = gitWorkflow.process || "stage") {
6278
+ switch (step) {
6279
+ case "generate":
6280
+ case "generating":
6281
+ return "message";
6282
+ case "message":
6283
+ case "branchNaming":
6284
+ case "branching":
6285
+ case "committing":
6286
+ return "commit";
6287
+ case "push":
6288
+ case "pushing":
6289
+ case "prGenerating":
6290
+ case "prReview":
6291
+ case "prCreating":
6292
+ case "done":
6293
+ return "push";
6294
+ case "add":
6295
+ case "idle":
6296
+ return "stage";
6297
+ case "cancelled":
6298
+ case "error":
6299
+ return GIT_WORKFLOW_PROCESS_VALUES.has(fallback) ? fallback : "stage";
6300
+ default:
6301
+ return "stage";
6302
+ }
6303
+ }
6304
+
6305
+ function selectGitWorkflowProcess(processValue, tabId = gitWorkflowActionTabId()) {
6306
+ const workflow = gitWorkflowForTab(tabId);
6307
+ if (!workflow) return;
6308
+ const process = GIT_WORKFLOW_PROCESS_VALUES.has(processValue) ? processValue : "stage";
6309
+ workflow.runId += 1;
6310
+ const runId = workflow.runId;
6311
+ const base = { active: true, process, busy: false, error: "", messageRequestedAt: 0, branchName: "", branchNameRequestedAt: 0, prMode: false, prBranch: "", pr: null, prRequestedAt: 0 };
6312
+
6313
+ if (process === "stage") {
6314
+ setGitWorkflow({ ...base, step: "add", message: null, output: "Ready to stage all changes with git add ." }, { tabId });
6315
+ return;
6316
+ }
6317
+ if (process === "message") {
6318
+ setGitWorkflow({ ...base, step: "generate", message: null, output: "Ready to generate a commit message from the currently staged changes." }, { tabId });
6319
+ return;
6320
+ }
6321
+ if (process === "commit") {
6322
+ setGitWorkflow({ ...base, step: "message", message: null, output: "Loading current generated commit message files…" }, { tabId });
6323
+ loadGitWorkflowMessage({ requireFresh: false, runId, tabId });
6324
+ return;
6325
+ }
6326
+ setGitWorkflow({ ...base, step: "push", output: "Ready to run git push for the current branch." }, { tabId });
6327
+ }
6328
+
5335
6329
  function gitWorkflowTitle() {
5336
6330
  switch (gitWorkflow.step) {
5337
6331
  case "add": return "Stage all changes";
5338
6332
  case "generate": return "Generate staged commit message";
5339
6333
  case "generating": return "Waiting for /git-staged-msg";
5340
- case "message": return "Choose commit message";
6334
+ case "message": return gitWorkflow.prMode ? "Choose PR branch commit message" : "Choose commit message";
6335
+ case "branchNaming": return "Waiting for branch name";
6336
+ case "branching": return "Creating PR branch";
5341
6337
  case "committing": return "Committing";
5342
- case "push": return "Push commit";
6338
+ case "push": return gitWorkflow.prMode ? "Push branch and create PR" : "Push commit";
5343
6339
  case "pushing": return "Pushing";
6340
+ case "prGenerating": return "Waiting for /pr";
6341
+ case "prReview": return "Review PR description";
6342
+ case "prCreating": return "Creating pull request";
5344
6343
  case "done": return "Git workflow complete";
5345
6344
  case "cancelled": return "Git workflow cancelled";
5346
6345
  case "error": return "Git workflow needs attention";
@@ -5353,11 +6352,16 @@ function gitWorkflowHint() {
5353
6352
  case "add": return "Step 1: run git add . in the current Pi working directory.";
5354
6353
  case "generate": return "Step 2: run /git-staged-msg, then preview the generated files.";
5355
6354
  case "generating": return "Pi is generating dev/COMMIT/staged-commit-short.txt and staged-commit-long.txt.";
5356
- case "message": return "Step 3/4: preview the native g-msg output and choose short or long commit.";
6355
+ case "message": return gitWorkflow.prMode ? `Branch ${gitWorkflow.prBranch || "created"}: choose short or long commit before opening a PR.` : "Step 3/4: preview the native g-msg output, commit here, or create a PR branch first.";
6356
+ case "branchNaming": return "Pi is generating dev/COMMIT/staged-branch-name.txt. Cancel will request Pi abort.";
6357
+ case "branching": return "Creating a new branch with git switch -c before committing.";
5357
6358
  case "committing": return "Running native git commit from the generated message file.";
5358
- case "push": return "Step 5: push the new commit to the configured remote.";
6359
+ 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.";
5359
6360
  case "pushing": return "Running git push. Cancel will request process termination.";
5360
- case "done": return "Push finished. Review the output below.";
6361
+ case "prGenerating": return "Pi is generating dev/PR/<current-branch>.md with /pr.";
6362
+ case "prReview": return "Review or edit the generated PR description before creating the pull request.";
6363
+ case "prCreating": return "Running gh pr create with the confirmed description.";
6364
+ case "done": return gitWorkflow.prMode ? "PR workflow finished. Review the output below." : "Push finished. Review the output below.";
5361
6365
  case "cancelled": return "No further workflow steps will run.";
5362
6366
  case "error": return gitWorkflow.error || "Fix the issue, then retry or restart.";
5363
6367
  default: return "Stage changes, generate a commit message, commit, and push.";
@@ -5375,9 +6379,14 @@ function renderGitWorkflow() {
5375
6379
  elements.gitWorkflowActions.replaceChildren();
5376
6380
 
5377
6381
  const activeIndex = GIT_WORKFLOW_ACTIVE_INDEX[gitWorkflow.step] ?? 0;
5378
- for (const [index, label] of GIT_WORKFLOW_STEPS.entries()) {
5379
- const item = make("span", "git-workflow-step", label);
5380
- if (gitWorkflow.step === "done" || index < activeIndex) item.classList.add("done");
6382
+ const activeProcess = gitWorkflowProcessForStep(gitWorkflow.step, gitWorkflow.process);
6383
+ for (const [index, process] of GIT_WORKFLOW_PROCESSES.entries()) {
6384
+ const item = make("button", "git-workflow-step", process.label);
6385
+ item.type = "button";
6386
+ item.dataset.gitWorkflowProcess = process.value;
6387
+ item.disabled = !!gitWorkflow.busy;
6388
+ item.setAttribute("aria-pressed", String(process.value === activeProcess));
6389
+ if (gitWorkflowActionDone(gitWorkflow, process.value)) item.classList.add("done");
5381
6390
  if (index === activeIndex && !["done", "cancelled", "error"].includes(gitWorkflow.step)) item.classList.add("active");
5382
6391
  elements.gitWorkflowSteps.append(item);
5383
6392
  }
@@ -5393,11 +6402,28 @@ function renderGitWorkflow() {
5393
6402
  } else if (gitWorkflow.step === "generating") {
5394
6403
  addGitWorkflowAction("Refresh message preview", () => loadGitWorkflowMessage({ requireFresh: true }), "", false);
5395
6404
  } else if (gitWorkflow.step === "message") {
5396
- addGitWorkflowAction("Commit short", () => commitGitWorkflow("short"), "primary", false);
5397
- addGitWorkflowAction("Commit long", () => commitGitWorkflow("long"), "primary", false);
6405
+ if (!gitWorkflow.prMode) {
6406
+ addGitWorkflowAction("Create PR", () => createGitPrBranch(), "primary", false, GIT_WORKFLOW_CREATE_PR_TOOLTIP);
6407
+ addGitWorkflowAction("Manual branch", () => createGitPrBranchManually(), "", false, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP);
6408
+ }
6409
+ addGitWorkflowAction("Commit short", () => commitGitWorkflow("short"), gitWorkflow.prMode ? "primary" : "", false);
6410
+ addGitWorkflowAction("Commit long", () => commitGitWorkflow("long"), gitWorkflow.prMode ? "primary" : "", false);
5398
6411
  addGitWorkflowAction("Regenerate", () => runGitMessagePrompt(), "", false);
6412
+ } else if (gitWorkflow.step === "branchNaming") {
6413
+ addGitWorkflowAction("Refresh branch name", () => loadGitWorkflowBranchName({ requireFresh: true }), "", false);
6414
+ addGitWorkflowAction("Manual branch", () => createGitPrBranchManually(), "", !!gitWorkflow.busy, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP);
6415
+ } else if (gitWorkflow.step === "branching") {
6416
+ addGitWorkflowAction("Creating branch…", () => {}, "primary", true);
5399
6417
  } else if (gitWorkflow.step === "push") {
5400
- addGitWorkflowAction("Run git push", () => pushGitWorkflow(), "primary", false);
6418
+ if (gitWorkflow.prMode) addGitWorkflowAction("Push and Create PR", () => pushAndCreatePrGitWorkflow(), "primary", false);
6419
+ else addGitWorkflowAction("Run git push", () => pushGitWorkflow(), "primary", false);
6420
+ } else if (gitWorkflow.step === "prGenerating") {
6421
+ addGitWorkflowAction("Refresh PR description", () => loadGitWorkflowPr({ requireFresh: true }), "", false);
6422
+ } else if (gitWorkflow.step === "prReview") {
6423
+ addGitWorkflowAction("Create PR", () => createGitPrFromReview(), "primary", false);
6424
+ addGitWorkflowAction("Regenerate /pr", () => runGitPrPrompt(), "", false);
6425
+ } else if (gitWorkflow.step === "prCreating") {
6426
+ addGitWorkflowAction("Creating PR…", () => {}, "primary", true);
5401
6427
  } else if (gitWorkflow.step === "done") {
5402
6428
  addGitWorkflowAction("Close", () => setGitWorkflow({ active: false }), "primary", false);
5403
6429
  addGitWorkflowAction("Start another", () => startGitWorkflow(), "", false);
@@ -5447,11 +6473,19 @@ function startGitWorkflow(tabId = activeTabId) {
5447
6473
  setGitWorkflow({
5448
6474
  active: true,
5449
6475
  step: "add",
6476
+ process: "stage",
5450
6477
  busy: false,
5451
- 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.",
6478
+ 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.",
5452
6479
  error: "",
5453
6480
  message: null,
5454
6481
  messageRequestedAt: 0,
6482
+ branchName: "",
6483
+ branchNameRequestedAt: 0,
6484
+ actionsDone: createGitWorkflowActionsDone(),
6485
+ prMode: false,
6486
+ prBranch: "",
6487
+ pr: null,
6488
+ prRequestedAt: 0,
5455
6489
  }, { tabId });
5456
6490
  }
5457
6491
 
@@ -5459,7 +6493,8 @@ async function cancelGitWorkflow(tabId = gitWorkflowActionTabId()) {
5459
6493
  const tabContext = activeTabContext(tabId);
5460
6494
  const workflow = gitWorkflowForTab(tabId, { create: false });
5461
6495
  if (!workflow?.active) return;
5462
- const shouldAbortPi = workflow.step === "generating";
6496
+ const shouldAbortPi = workflow.step === "generating" || workflow.step === "branchNaming" || workflow.step === "prGenerating";
6497
+ if (activeGitPrDialogResolve) resolveGitPrDialog(null);
5463
6498
  workflow.runId += 1;
5464
6499
  setGitWorkflow({ step: "cancelled", busy: false, error: "", output: `${workflow.output || ""}${workflow.output ? "\n\n" : ""}Cancelled by user.` }, { tabId });
5465
6500
  if (shouldAbortPi && isCurrentTabContext(tabContext)) setRunIndicatorActivity("Abort requested; checking whether Pi stopped…");
@@ -5479,7 +6514,7 @@ async function runGitAdd(tabId = gitWorkflowActionTabId()) {
5479
6514
  try {
5480
6515
  const result = await gitWorkflowRequest("/api/git-workflow/add", { runId, tabId });
5481
6516
  if (!result) return;
5482
- setGitWorkflow({ step: "generate", busy: false, output: `${formatGitCommandResult(result)}\n\nStaged. Next: run /git-staged-msg.` }, { tabId });
6517
+ setGitWorkflow({ step: "generate", busy: false, ...gitWorkflowActionDonePatch(workflow, "stage"), output: `${formatGitCommandResult(result)}\n\nStaged. Next: run /git-staged-msg.` }, { tabId });
5483
6518
  if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
5484
6519
  } catch (error) {
5485
6520
  if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "add", { tabId });
@@ -5543,6 +6578,7 @@ async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId
5543
6578
  busy: false,
5544
6579
  error: "",
5545
6580
  message,
6581
+ ...(requireFresh && currentWorkflow.messageRequestedAt ? gitWorkflowActionDonePatch(currentWorkflow, "message") : {}),
5546
6582
  output: formatCommitMessagePreview(message),
5547
6583
  }, { tabId });
5548
6584
  } catch (error) {
@@ -5556,6 +6592,130 @@ async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId
5556
6592
  }
5557
6593
  }
5558
6594
 
6595
+ function gitBranchNamePromptMessage() {
6596
+ if (hasAvailableCommand("git-branch-name")) return "/git-branch-name";
6597
+ return [
6598
+ "Generate one PR branch name for the current staged work.",
6599
+ "Inspect only staged changes (`git diff --cached`) and the generated commit message files if present:",
6600
+ "- dev/COMMIT/staged-commit-short.txt",
6601
+ "- dev/COMMIT/staged-commit-long.txt",
6602
+ "",
6603
+ "Write exactly one line to dev/COMMIT/staged-branch-name.txt in this format:",
6604
+ "<type>/<short-feature-name>",
6605
+ "",
6606
+ "Rules: use lowercase kebab-case, no spaces/underscores/uppercase/trailing punctuation, 2-5 words after the slash, and no extra lines or prose in the file.",
6607
+ ].join("\n");
6608
+ }
6609
+
6610
+ async function createGitPrBranch(tabId = gitWorkflowActionTabId()) {
6611
+ await runGitBranchNamePrompt(tabId);
6612
+ }
6613
+
6614
+ async function createGitPrBranchManually(tabId = gitWorkflowActionTabId()) {
6615
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6616
+ if (!workflow) return;
6617
+ await createGitPrBranchWithSuggestion(workflow.branchName || defaultGitPrBranchName(workflow.message), tabId);
6618
+ }
6619
+
6620
+ async function runGitBranchNamePrompt(tabId = gitWorkflowActionTabId()) {
6621
+ const tabContext = activeTabContext(tabId);
6622
+ const targetTab = tabs.find((tab) => tab.id === tabId);
6623
+ const targetBusy = tabId === activeTabId ? !!currentState?.isStreaming : activityForTab(targetTab).isWorking;
6624
+ if (targetBusy) {
6625
+ failGitWorkflow(new Error("Pi is currently running. Wait for it to finish or abort before generating a branch name."), "message", { tabId });
6626
+ return;
6627
+ }
6628
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6629
+ if (!workflow) return;
6630
+ const runId = workflow.runId;
6631
+ const requestedAt = Date.now();
6632
+ setGitWorkflow({
6633
+ step: "branchNaming",
6634
+ busy: true,
6635
+ error: "",
6636
+ branchNameRequestedAt: requestedAt,
6637
+ output: "Sending branch-name request to Pi.\n\nCancel will request Pi abort.",
6638
+ }, { tabId });
6639
+ if (isCurrentTabContext(tabContext)) setRunIndicatorActivity("Sending branch-name request to Pi…");
6640
+ try {
6641
+ await api("/api/prompt", { method: "POST", body: { message: gitBranchNamePromptMessage() }, tabId });
6642
+ if (!isCurrentGitWorkflowRun(runId, tabId)) return;
6643
+ appendGitWorkflowOutput("Branch-name request accepted. Waiting for agent_end, then the branch name will be loaded.", { tabId });
6644
+ if (isCurrentTabContext(tabContext)) scheduleRefreshState(120, tabContext);
6645
+ setTimeout(() => {
6646
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6647
+ const targetStillBusy = tabId === activeTabId && currentState?.isStreaming;
6648
+ if (isCurrentGitWorkflowRun(runId, tabId) && currentWorkflow?.step === "branchNaming" && !targetStillBusy) {
6649
+ loadGitWorkflowBranchName({ requireFresh: true, retries: 1, runId, tabId });
6650
+ }
6651
+ }, 2500);
6652
+ } catch (error) {
6653
+ if (isCurrentGitWorkflowRun(runId, tabId)) {
6654
+ if (isCurrentTabContext(tabContext)) clearRunIndicatorActivity();
6655
+ failGitWorkflow(error, "message", { tabId });
6656
+ }
6657
+ }
6658
+ }
6659
+
6660
+ async function loadGitWorkflowBranchName({ requireFresh = false, retries = 0, runId, tabId = activeTabId } = {}) {
6661
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6662
+ const expectedRunId = runId ?? workflow?.runId;
6663
+ try {
6664
+ const branchName = await gitWorkflowRequest("/api/git-workflow/branch-name", { method: "GET", runId: expectedRunId, tabId });
6665
+ if (!branchName) return;
6666
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6667
+ if (!currentWorkflow) return;
6668
+ if (requireFresh && currentWorkflow.branchNameRequestedAt && (branchName.mtimeMs || 0) + 10000 < currentWorkflow.branchNameRequestedAt) {
6669
+ throw new Error("Generated branch name has not refreshed yet.");
6670
+ }
6671
+ const branch = branchName.branch || defaultGitPrBranchName(currentWorkflow.message);
6672
+ setGitWorkflow({
6673
+ step: "message",
6674
+ busy: false,
6675
+ error: "",
6676
+ branchName: branch,
6677
+ output: `${formatCommitMessagePreview(currentWorkflow.message)}\n\nGenerated branch name: ${branch}`,
6678
+ }, { tabId });
6679
+ await createGitPrBranchWithSuggestion(branch, tabId, expectedRunId);
6680
+ } catch (error) {
6681
+ if (!isCurrentGitWorkflowRun(expectedRunId, tabId)) return;
6682
+ if (retries > 0) {
6683
+ setTimeout(() => loadGitWorkflowBranchName({ requireFresh, retries: retries - 1, runId: expectedRunId, tabId }), 1400);
6684
+ return;
6685
+ }
6686
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6687
+ failGitWorkflow(error, currentWorkflow?.step === "branchNaming" ? "message" : currentWorkflow?.step, { tabId });
6688
+ }
6689
+ }
6690
+
6691
+ async function createGitPrBranchWithSuggestion(suggestion, tabId = gitWorkflowActionTabId(), expectedRunId) {
6692
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6693
+ if (!workflow) return;
6694
+ const proposedBranch = prompt("New PR branch name (example: type/feature-name)", suggestion || defaultGitPrBranchName(workflow.message));
6695
+ if (expectedRunId !== undefined && !isCurrentGitWorkflowRun(expectedRunId, tabId)) return;
6696
+ if (proposedBranch === null) {
6697
+ setGitWorkflow({ step: "message", busy: false, output: `${formatCommitMessagePreview(workflow.message)}\n\nPR branch creation cancelled. Use Create PR to generate a branch name again or Manual branch to type one.` }, { tabId });
6698
+ return;
6699
+ }
6700
+ const branch = proposedBranch.trim();
6701
+ if (!branch) {
6702
+ failGitWorkflow(new Error("Branch name is required to create a PR branch."), "message", { tabId });
6703
+ return;
6704
+ }
6705
+ const runId = workflow.runId;
6706
+ setGitWorkflow({ step: "branching", prMode: true, prBranch: branch, branchName: branch, busy: true, error: "", output: `${formatCommitMessagePreview(workflow.message)}\n\nRunning git switch -c ${branch}…` }, { tabId });
6707
+ try {
6708
+ const result = await gitWorkflowRequest("/api/git-workflow/branch", { body: { branch }, runId, tabId });
6709
+ if (!result) return;
6710
+ setGitWorkflow({ step: "message", prMode: true, prBranch: result.branch || branch, branchName: result.branch || branch, busy: false, output: `${formatGitCommandResult(result)}\n\nCreated PR branch ${result.branch || branch}. Choose Commit short or Commit long to commit on this branch.` }, { tabId });
6711
+ } catch (error) {
6712
+ if (isCurrentGitWorkflowRun(runId, tabId)) {
6713
+ setGitWorkflow({ prMode: false, prBranch: "" }, { tabId });
6714
+ failGitWorkflow(error, "message", { tabId });
6715
+ }
6716
+ }
6717
+ }
6718
+
5559
6719
  async function commitGitWorkflow(variant, tabId = gitWorkflowActionTabId()) {
5560
6720
  const tabContext = activeTabContext(tabId);
5561
6721
  const workflow = gitWorkflowForTab(tabId, { create: false });
@@ -5565,7 +6725,8 @@ async function commitGitWorkflow(variant, tabId = gitWorkflowActionTabId()) {
5565
6725
  try {
5566
6726
  const result = await gitWorkflowRequest("/api/git-workflow/commit", { body: { variant }, runId, tabId });
5567
6727
  if (!result) return;
5568
- setGitWorkflow({ step: "push", busy: false, output: `${formatGitCommandResult(result)}\n\nCommit created. Next: git push.` }, { tabId });
6728
+ const nextAction = workflow.prMode ? "Push and Create PR." : "git push.";
6729
+ setGitWorkflow({ step: "push", busy: false, ...gitWorkflowActionDonePatch(workflow, "commit"), output: `${formatGitCommandResult(result)}\n\nCommit created. Next: ${nextAction}` }, { tabId });
5569
6730
  if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
5570
6731
  } catch (error) {
5571
6732
  if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "message", { tabId });
@@ -5581,13 +6742,129 @@ async function pushGitWorkflow(tabId = gitWorkflowActionTabId()) {
5581
6742
  try {
5582
6743
  const result = await gitWorkflowRequest("/api/git-workflow/push", { runId, tabId });
5583
6744
  if (!result) return;
5584
- setGitWorkflow({ step: "done", busy: false, output: formatGitCommandResult(result) || "git push finished." }, { tabId });
6745
+ setGitWorkflow({ step: "done", busy: false, ...gitWorkflowActionDonePatch(workflow, "push"), output: formatGitCommandResult(result) || "git push finished." }, { tabId });
6746
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
6747
+ } catch (error) {
6748
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "push", { tabId });
6749
+ }
6750
+ }
6751
+
6752
+ async function runGitPrPrompt(tabId = gitWorkflowActionTabId(), { prefixOutput = "" } = {}) {
6753
+ const tabContext = activeTabContext(tabId);
6754
+ const targetTab = tabs.find((tab) => tab.id === tabId);
6755
+ const targetBusy = tabId === activeTabId ? !!currentState?.isStreaming : activityForTab(targetTab).isWorking;
6756
+ if (targetBusy) {
6757
+ failGitWorkflow(new Error("Pi is currently running. Wait for it to finish or abort before generating a PR description."), "push", { tabId });
6758
+ return;
6759
+ }
6760
+ if (!hasAvailableCommand("pr")) {
6761
+ failGitWorkflow(new Error(commandUnavailableMessage("pr")), "push", { tabId });
6762
+ return;
6763
+ }
6764
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6765
+ if (!workflow) return;
6766
+ const runId = workflow.runId;
6767
+ const requestedAt = Date.now();
6768
+ setGitWorkflow({
6769
+ step: "prGenerating",
6770
+ busy: true,
6771
+ error: "",
6772
+ prRequestedAt: requestedAt,
6773
+ output: `${prefixOutput ? `${prefixOutput}\n\n` : ""}Sending /pr to Pi.\n\nCancel will request Pi abort.`,
6774
+ }, { tabId });
6775
+ if (isCurrentTabContext(tabContext)) setRunIndicatorActivity("Sending /pr to Pi…");
6776
+ try {
6777
+ await api("/api/prompt", { method: "POST", body: { message: "/pr" }, tabId });
6778
+ if (!isCurrentGitWorkflowRun(runId, tabId)) return;
6779
+ appendGitWorkflowOutput("/pr accepted. Waiting for agent_end, then the PR description will be loaded.", { tabId });
6780
+ if (isCurrentTabContext(tabContext)) scheduleRefreshState(120, tabContext);
6781
+ setTimeout(() => {
6782
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6783
+ const targetStillBusy = tabId === activeTabId && currentState?.isStreaming;
6784
+ if (isCurrentGitWorkflowRun(runId, tabId) && currentWorkflow?.step === "prGenerating" && !targetStillBusy) {
6785
+ loadGitWorkflowPr({ requireFresh: true, retries: 1, runId, tabId });
6786
+ }
6787
+ }, 2500);
6788
+ } catch (error) {
6789
+ if (isCurrentGitWorkflowRun(runId, tabId)) {
6790
+ if (isCurrentTabContext(tabContext)) clearRunIndicatorActivity();
6791
+ failGitWorkflow(error, "push", { tabId });
6792
+ }
6793
+ }
6794
+ }
6795
+
6796
+ async function loadGitWorkflowPr({ requireFresh = false, retries = 0, runId, tabId = activeTabId } = {}) {
6797
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6798
+ const expectedRunId = runId ?? workflow?.runId;
6799
+ try {
6800
+ const pr = await gitWorkflowRequest("/api/git-workflow/pr-description", { method: "GET", runId: expectedRunId, tabId });
6801
+ if (!pr) return;
6802
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6803
+ if (!currentWorkflow) return;
6804
+ if (requireFresh && currentWorkflow.prRequestedAt && (pr.mtimeMs || 0) + 10000 < currentWorkflow.prRequestedAt) {
6805
+ throw new Error("Generated PR description has not refreshed yet.");
6806
+ }
6807
+ setGitWorkflow({
6808
+ step: "prReview",
6809
+ busy: false,
6810
+ error: "",
6811
+ pr,
6812
+ prBranch: pr.branch || currentWorkflow.prBranch,
6813
+ output: formatGitPrPreview(pr),
6814
+ }, { tabId });
6815
+ } catch (error) {
6816
+ if (!isCurrentGitWorkflowRun(expectedRunId, tabId)) return;
6817
+ if (retries > 0) {
6818
+ setTimeout(() => loadGitWorkflowPr({ requireFresh, retries: retries - 1, runId: expectedRunId, tabId }), 1400);
6819
+ return;
6820
+ }
6821
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6822
+ failGitWorkflow(error, currentWorkflow?.step === "prGenerating" ? "push" : currentWorkflow?.step, { tabId });
6823
+ }
6824
+ }
6825
+
6826
+ async function pushAndCreatePrGitWorkflow(tabId = gitWorkflowActionTabId()) {
6827
+ const tabContext = activeTabContext(tabId);
6828
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6829
+ if (!workflow) return;
6830
+ const runId = workflow.runId;
6831
+ const branch = workflow.prBranch || "current branch";
6832
+ setGitWorkflow({ step: "pushing", busy: true, error: "", output: `Pushing PR branch ${branch}…` }, { tabId });
6833
+ try {
6834
+ const result = await gitWorkflowRequest("/api/git-workflow/push", { body: { setUpstream: true, branch: workflow.prBranch }, runId, tabId });
6835
+ if (!result) return;
6836
+ setGitWorkflow({ ...gitWorkflowActionDonePatch(workflow, "push") }, { tabId });
5585
6837
  if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
6838
+ await runGitPrPrompt(tabId, { prefixOutput: `${formatGitCommandResult(result)}\n\nPushed PR branch ${result.branch || branch}.` });
5586
6839
  } catch (error) {
5587
6840
  if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "push", { tabId });
5588
6841
  }
5589
6842
  }
5590
6843
 
6844
+ async function createGitPrFromReview(tabId = gitWorkflowActionTabId()) {
6845
+ const tabContext = activeTabContext(tabId);
6846
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6847
+ if (!workflow?.pr) return;
6848
+ const runId = workflow.runId;
6849
+ const review = await openGitPrReviewDialog(workflow.pr, { title: gitWorkflowMessageTitle(workflow.message) });
6850
+ if (!isCurrentGitWorkflowRun(runId, tabId)) return;
6851
+ if (!review) {
6852
+ setGitWorkflow({ step: "prReview", busy: false, output: `${formatGitPrPreview(workflow.pr)}\n\nPR creation cancelled. Edit the description, regenerate /pr, or press Create PR again.` }, { tabId });
6853
+ return;
6854
+ }
6855
+ const title = review.title.trim();
6856
+ const body = review.body.trimEnd();
6857
+ setGitWorkflow({ step: "prCreating", busy: true, error: "", output: `${formatGitPrPreview({ ...workflow.pr, body })}\n\nCreating pull request with gh pr create…` }, { tabId });
6858
+ try {
6859
+ const result = await gitWorkflowRequest("/api/git-workflow/create-pr", { body: { title, body }, runId, tabId });
6860
+ if (!result) return;
6861
+ setGitWorkflow({ step: "done", busy: false, ...gitWorkflowActionDonePatch(workflow, "push"), output: `${formatGitCommandResult(result)}\n\nPull request created.` }, { tabId });
6862
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
6863
+ } catch (error) {
6864
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "prReview", { tabId });
6865
+ }
6866
+ }
6867
+
5591
6868
  function resumeGitWorkflowForActiveTab(tabContext = activeTabContext()) {
5592
6869
  if (!isCurrentTabContext(tabContext)) return;
5593
6870
  bindGitWorkflowToActiveTab();
@@ -5601,6 +6878,22 @@ function resumeGitWorkflowForActiveTab(tabContext = activeTabContext()) {
5601
6878
  }
5602
6879
  loadGitWorkflowMessage({ requireFresh: true, retries: 3, runId: gitWorkflow.runId, tabId: workflowTabId });
5603
6880
  }
6881
+ if (workflowTabId === tabContext.tabId && gitWorkflow.active && gitWorkflow.step === "branchNaming" && !currentState?.isStreaming) {
6882
+ const retryDelayMs = Math.max(0, 2500 - (Date.now() - (gitWorkflow.branchNameRequestedAt || 0)));
6883
+ if (retryDelayMs > 0) {
6884
+ setTimeout(() => resumeGitWorkflowForActiveTab(tabContext), retryDelayMs);
6885
+ return;
6886
+ }
6887
+ loadGitWorkflowBranchName({ requireFresh: true, retries: 3, runId: gitWorkflow.runId, tabId: workflowTabId });
6888
+ }
6889
+ if (workflowTabId === tabContext.tabId && gitWorkflow.active && gitWorkflow.step === "prGenerating" && !currentState?.isStreaming) {
6890
+ const retryDelayMs = Math.max(0, 2500 - (Date.now() - (gitWorkflow.prRequestedAt || 0)));
6891
+ if (retryDelayMs > 0) {
6892
+ setTimeout(() => resumeGitWorkflowForActiveTab(tabContext), retryDelayMs);
6893
+ return;
6894
+ }
6895
+ loadGitWorkflowPr({ requireFresh: true, retries: 3, runId: gitWorkflow.runId, tabId: workflowTabId });
6896
+ }
5604
6897
  }
5605
6898
 
5606
6899
  function normalizeQueuedMessages(event) {
@@ -8188,6 +9481,13 @@ function setNativeCommandMenuOpen(open) {
8188
9481
  elements.nativeCommandMenuButton.parentElement?.classList.toggle("open", nativeCommandMenuOpen);
8189
9482
  }
8190
9483
 
9484
+ function setAppRunnerMenuOpen(open) {
9485
+ appRunnerMenuOpen = !!open;
9486
+ elements.appRunnerMenuButton?.setAttribute("aria-expanded", appRunnerMenuOpen ? "true" : "false");
9487
+ elements.appRunnerMenuButton?.classList.toggle("menu-open", appRunnerMenuOpen);
9488
+ elements.appRunnerMenuButton?.parentElement?.classList.toggle("open", appRunnerMenuOpen);
9489
+ }
9490
+
8191
9491
  function setOptionsMenuOpen(open) {
8192
9492
  optionsMenuOpen = !!open;
8193
9493
  elements.optionsMenuButton.setAttribute("aria-expanded", optionsMenuOpen ? "true" : "false");
@@ -8435,6 +9735,7 @@ async function installOptionalFeature(featureId) {
8435
9735
  function runPublishWorkflow(command) {
8436
9736
  setComposerActionsOpen(false);
8437
9737
  setPublishMenuOpen(false);
9738
+ setAppRunnerMenuOpen(false);
8438
9739
  setOptionsMenuOpen(false);
8439
9740
  const commandName = String(command || "").replace(/^\//, "").split(/\s+/)[0];
8440
9741
  const featureId = OPTIONAL_COMMAND_FEATURES.get(commandName);
@@ -8453,6 +9754,7 @@ async function runNativeCommandMenu(command) {
8453
9754
  setComposerActionsOpen(false);
8454
9755
  setPublishMenuOpen(false);
8455
9756
  setNativeCommandMenuOpen(false);
9757
+ setAppRunnerMenuOpen(false);
8456
9758
  setOptionsMenuOpen(false);
8457
9759
  const commandName = String(command || "").replace(/^\//, "").split(/\s+/)[0].toLowerCase();
8458
9760
  const featureId = optionalFeatureIdForCommand(commandName);
@@ -10130,6 +11432,7 @@ async function refreshAll(tabContext = activeTabContext()) {
10130
11432
  refreshCommands(tabContext),
10131
11433
  refreshStats(tabContext),
10132
11434
  refreshWorkspace(tabContext),
11435
+ refreshAppRunners(tabContext),
10133
11436
  refreshNativeSettings(tabContext),
10134
11437
  refreshNetworkStatus(),
10135
11438
  refreshWebuiVersion(),
@@ -10844,6 +12147,11 @@ function handleEvent(event) {
10844
12147
  case "webui_connected":
10845
12148
  setWebuiVersion(event.version);
10846
12149
  setWebuiDevServer(isWebuiDevMetadata(event));
12150
+ if (Object.prototype.hasOwnProperty.call(event, "activeRun")) {
12151
+ setAppRunnerData(event.tabId || activeTabId, { cwd: event.cwd, activeRun: event.activeRun });
12152
+ renderAppRunnerControls();
12153
+ renderWidgets();
12154
+ }
10847
12155
  addEvent(`connected to ${event.tabTitle || "terminal"} for ${event.cwd}`);
10848
12156
  scheduleForegroundReconcile("event stream reconnect", 0);
10849
12157
  break;
@@ -10865,6 +12173,7 @@ function handleEvent(event) {
10865
12173
  case "webui_tab_reloaded":
10866
12174
  addEvent(`${event.tabTitle || "terminal"} reloaded`);
10867
12175
  addTransientMessage({ role: "native", title: "/reload", content: `${event.tabTitle || "terminal"} reloaded. Keybindings, extensions, skills, prompts, and themes were refreshed by restarting the RPC tab${event.sessionFile ? ` and resuming ${event.sessionFile}` : ""}.`, level: "info" });
12176
+ clearContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
10868
12177
  statusEntries.clear();
10869
12178
  widgets.clear();
10870
12179
  resetOptionalFeatureAvailability();
@@ -10882,9 +12191,18 @@ function handleEvent(event) {
10882
12191
  removeQueuedDialogRequests(event.ids || []);
10883
12192
  addEvent(`cancelled ${event.ids?.length || 0} pending extension UI request(s)`, "warn");
10884
12193
  break;
12194
+ case "webui_app_runner_update":
12195
+ setAppRunnerData(event.tabId || activeTabId, { cwd: event.cwd, activeRun: event.activeRun });
12196
+ renderAppRunnerControls();
12197
+ renderWidgets();
12198
+ break;
10885
12199
  case "webui_cwd_changed":
10886
12200
  addEvent(`${event.tabTitle || "terminal"} cwd changed to ${event.cwd}`);
12201
+ setAppRunnerData(event.tabId || activeTabId, { cwd: event.cwd, activeRun: null, runners: [] });
12202
+ renderAppRunnerControls();
12203
+ renderWidgets();
10887
12204
  refreshTabs().catch((error) => addEvent(error.message, "error"));
12205
+ refreshAppRunners(tabContext).catch((error) => addEvent(error.message, "error"));
10888
12206
  scheduleRefreshFooter();
10889
12207
  break;
10890
12208
  case "webui_network_rebinding": {
@@ -10928,6 +12246,7 @@ function handleEvent(event) {
10928
12246
  case "agent_end":
10929
12247
  addEvent("agent finished");
10930
12248
  notifyAgentDone(event.tabId || activeTabId, { activity: event.tabActivity, tabTitle: event.tabTitle });
12249
+ clearContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
10931
12250
  if (currentState) currentState = { ...currentState, isStreaming: false };
10932
12251
  clearRunIndicatorActivity();
10933
12252
  markTabOutputSeen();
@@ -10941,6 +12260,10 @@ function handleEvent(event) {
10941
12260
  const workflow = gitWorkflowForTab(workflowTabId, { create: false });
10942
12261
  if (workflow?.active && workflow.step === "generating") {
10943
12262
  loadGitWorkflowMessage({ requireFresh: true, retries: 3, runId: workflow.runId, tabId: workflowTabId });
12263
+ } else if (workflow?.active && workflow.step === "branchNaming") {
12264
+ loadGitWorkflowBranchName({ requireFresh: true, retries: 3, runId: workflow.runId, tabId: workflowTabId });
12265
+ } else if (workflow?.active && workflow.step === "prGenerating") {
12266
+ loadGitWorkflowPr({ requireFresh: true, retries: 3, runId: workflow.runId, tabId: workflowTabId });
10944
12267
  }
10945
12268
  }
10946
12269
  break;
@@ -10979,15 +12302,22 @@ function handleEvent(event) {
10979
12302
  break;
10980
12303
  case "compaction_start":
10981
12304
  if (currentState) currentState = { ...currentState, isCompacting: true };
12305
+ markContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
10982
12306
  setRunIndicatorActivity(`Compacting context${event.reason ? ` (${event.reason})` : ""}…`);
10983
12307
  addEvent(`compaction started (${event.reason})`);
12308
+ renderStatus();
10984
12309
  break;
10985
12310
  case "compaction_end":
10986
12311
  if (currentState) currentState = { ...currentState, isCompacting: false };
12312
+ if (event.aborted) clearContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
12313
+ else markContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
10987
12314
  addEvent(`compaction ${event.aborted ? "aborted" : "finished"}`);
10988
12315
  if (!currentState?.isStreaming) clearRunIndicatorActivity();
10989
12316
  markTabOutputSeen();
12317
+ renderStatus();
12318
+ scheduleRefreshState();
10990
12319
  scheduleRefreshMessages();
12320
+ scheduleRefreshFooter();
10991
12321
  break;
10992
12322
  case "auto_retry_start": {
10993
12323
  const seconds = Math.max(0, Math.ceil(Number(event.delayMs || 0) / 1000));
@@ -11031,6 +12361,7 @@ function handleEvent(event) {
11031
12361
  applyOptimisticThinkingSelection(event.data, tabContext);
11032
12362
  } else if (event.command === "new_session") {
11033
12363
  const tabId = event.tabId || activeTabId;
12364
+ clearContextUsageUnknownAfterCompaction(tabId);
11034
12365
  forgetLastUserPrompt(tabId);
11035
12366
  resetGitWorkflowForTab(tabId);
11036
12367
  }
@@ -11087,6 +12418,23 @@ elements.promptListSaveButton?.addEventListener("click", saveDisplayedPromptList
11087
12418
  elements.promptListRunListButton?.addEventListener("click", () => runDisplayedPromptList());
11088
12419
  elements.promptListCloseButton?.addEventListener("click", () => elements.promptListDialog?.close());
11089
12420
  elements.promptListDialog?.querySelector("form")?.addEventListener("submit", (event) => event.preventDefault());
12421
+ elements.attachmentTextCancelButton?.addEventListener("click", closeTextAttachmentEditor);
12422
+ elements.attachmentTextSaveButton?.addEventListener("click", saveTextAttachmentEdit);
12423
+ elements.attachmentTextEditor?.addEventListener("input", () => {
12424
+ renderTextAttachmentEditorMeta();
12425
+ setAttachmentTextStatus("Unsaved attachment edits.", "warn");
12426
+ });
12427
+ elements.attachmentTextDialog?.addEventListener("close", () => {
12428
+ activeTextAttachmentEditor = null;
12429
+ if (elements.attachmentTextEditor) elements.attachmentTextEditor.value = "";
12430
+ setAttachmentTextStatus("");
12431
+ });
12432
+ elements.attachmentTextDialog?.addEventListener("keydown", (event) => {
12433
+ if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "s") return;
12434
+ event.preventDefault();
12435
+ if (!elements.attachmentTextSaveButton?.disabled) saveTextAttachmentEdit();
12436
+ });
12437
+ elements.attachmentTextDialog?.querySelector("form")?.addEventListener("submit", (event) => event.preventDefault());
11090
12438
  elements.sendFeedbackButton.addEventListener("click", () => submitQueuedActionFeedback());
11091
12439
  elements.composer.addEventListener("submit", (event) => {
11092
12440
  event.preventDefault();
@@ -11149,17 +12497,20 @@ elements.gitWorkflowButton.addEventListener("click", () => {
11149
12497
  const publishMenuContainer = elements.publishButton.parentElement;
11150
12498
  elements.publishButton.addEventListener("click", () => {
11151
12499
  setNativeCommandMenuOpen(false);
12500
+ setAppRunnerMenuOpen(false);
11152
12501
  setOptionsMenuOpen(false);
11153
12502
  setPublishMenuOpen(true);
11154
12503
  });
11155
12504
  publishMenuContainer?.addEventListener("pointerenter", () => {
11156
12505
  setNativeCommandMenuOpen(false);
12506
+ setAppRunnerMenuOpen(false);
11157
12507
  setOptionsMenuOpen(false);
11158
12508
  setPublishMenuOpen(true);
11159
12509
  });
11160
12510
  publishMenuContainer?.addEventListener("pointerleave", () => setPublishMenuOpen(false));
11161
12511
  publishMenuContainer?.addEventListener("focusin", () => {
11162
12512
  setNativeCommandMenuOpen(false);
12513
+ setAppRunnerMenuOpen(false);
11163
12514
  setOptionsMenuOpen(false);
11164
12515
  setPublishMenuOpen(true);
11165
12516
  });
@@ -11171,17 +12522,20 @@ publishMenuContainer?.addEventListener("focusout", () => {
11171
12522
  const nativeCommandMenuContainer = elements.nativeCommandMenuButton.parentElement;
11172
12523
  elements.nativeCommandMenuButton.addEventListener("click", () => {
11173
12524
  setPublishMenuOpen(false);
12525
+ setAppRunnerMenuOpen(false);
11174
12526
  setOptionsMenuOpen(false);
11175
12527
  setNativeCommandMenuOpen(true);
11176
12528
  });
11177
12529
  nativeCommandMenuContainer?.addEventListener("pointerenter", () => {
11178
12530
  setPublishMenuOpen(false);
12531
+ setAppRunnerMenuOpen(false);
11179
12532
  setOptionsMenuOpen(false);
11180
12533
  setNativeCommandMenuOpen(true);
11181
12534
  });
11182
12535
  nativeCommandMenuContainer?.addEventListener("pointerleave", () => setNativeCommandMenuOpen(false));
11183
12536
  nativeCommandMenuContainer?.addEventListener("focusin", () => {
11184
12537
  setPublishMenuOpen(false);
12538
+ setAppRunnerMenuOpen(false);
11185
12539
  setOptionsMenuOpen(false);
11186
12540
  setNativeCommandMenuOpen(true);
11187
12541
  });
@@ -11190,21 +12544,66 @@ nativeCommandMenuContainer?.addEventListener("focusout", () => {
11190
12544
  if (!nativeCommandMenuContainer?.contains(document.activeElement)) setNativeCommandMenuOpen(false);
11191
12545
  }, 0);
11192
12546
  });
12547
+ const appRunnerMenuContainer = elements.appRunnerMenuButton?.parentElement;
12548
+ elements.appRunnerInfoButton?.addEventListener("click", (event) => {
12549
+ event.preventDefault();
12550
+ event.stopPropagation();
12551
+ openAppRunnerInfoDialog();
12552
+ });
12553
+ elements.appRunnerInfoCloseButton?.addEventListener("click", closeAppRunnerInfoDialog);
12554
+ elements.appRunnerMenuButton?.addEventListener("click", async () => {
12555
+ setPublishMenuOpen(false);
12556
+ setNativeCommandMenuOpen(false);
12557
+ setOptionsMenuOpen(false);
12558
+ setAppRunnerMenuOpen(false);
12559
+ const tabContext = activeTabContext();
12560
+ try {
12561
+ await refreshAppRunners(tabContext);
12562
+ if (!isCurrentTabContext(tabContext)) return;
12563
+ if (appRunnerMenuCanOpen()) setAppRunnerMenuOpen(true);
12564
+ else if (!appRunnerIsRunning(activeAppRunnerData().activeRun)) openAppRunnerInfoDialog();
12565
+ } catch (error) {
12566
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
12567
+ }
12568
+ });
12569
+ appRunnerMenuContainer?.addEventListener("pointerenter", () => {
12570
+ if (elements.appRunnerMenuButton?.disabled || !appRunnerMenuCanOpen()) return;
12571
+ setPublishMenuOpen(false);
12572
+ setNativeCommandMenuOpen(false);
12573
+ setOptionsMenuOpen(false);
12574
+ setAppRunnerMenuOpen(true);
12575
+ });
12576
+ appRunnerMenuContainer?.addEventListener("pointerleave", () => setAppRunnerMenuOpen(false));
12577
+ appRunnerMenuContainer?.addEventListener("focusin", () => {
12578
+ if (elements.appRunnerMenuButton?.disabled || !appRunnerMenuCanOpen()) return;
12579
+ setPublishMenuOpen(false);
12580
+ setNativeCommandMenuOpen(false);
12581
+ setOptionsMenuOpen(false);
12582
+ setAppRunnerMenuOpen(true);
12583
+ });
12584
+ appRunnerMenuContainer?.addEventListener("focusout", () => {
12585
+ setTimeout(() => {
12586
+ if (!appRunnerMenuContainer?.contains(document.activeElement)) setAppRunnerMenuOpen(false);
12587
+ }, 0);
12588
+ });
11193
12589
  const optionsMenuContainer = elements.optionsMenuButton.parentElement;
11194
12590
  elements.optionsMenuButton.addEventListener("click", () => {
11195
12591
  setPublishMenuOpen(false);
11196
12592
  setNativeCommandMenuOpen(false);
12593
+ setAppRunnerMenuOpen(false);
11197
12594
  setOptionsMenuOpen(true);
11198
12595
  });
11199
12596
  optionsMenuContainer?.addEventListener("pointerenter", () => {
11200
12597
  setPublishMenuOpen(false);
11201
12598
  setNativeCommandMenuOpen(false);
12599
+ setAppRunnerMenuOpen(false);
11202
12600
  setOptionsMenuOpen(true);
11203
12601
  });
11204
12602
  optionsMenuContainer?.addEventListener("pointerleave", () => setOptionsMenuOpen(false));
11205
12603
  optionsMenuContainer?.addEventListener("focusin", () => {
11206
12604
  setPublishMenuOpen(false);
11207
12605
  setNativeCommandMenuOpen(false);
12606
+ setAppRunnerMenuOpen(false);
11208
12607
  setOptionsMenuOpen(true);
11209
12608
  });
11210
12609
  optionsMenuContainer?.addEventListener("focusout", () => {
@@ -11224,7 +12623,32 @@ elements.optionsSettingsButton.addEventListener("click", () => runNativeCommandM
11224
12623
  elements.optionsExportButton.addEventListener("click", () => runNativeCommandMenu("/export"));
11225
12624
  elements.optionsForkButton.addEventListener("click", () => runNativeCommandMenu("/fork"));
11226
12625
  elements.optionsTreeButton.addEventListener("click", () => runNativeCommandMenu("/tree"));
12626
+ elements.gitWorkflowSteps.addEventListener("click", (event) => {
12627
+ const target = event.target instanceof Element ? event.target : null;
12628
+ const button = target?.closest("[data-git-workflow-process]");
12629
+ if (!button || !elements.gitWorkflowSteps.contains(button) || button.disabled) return;
12630
+ selectGitWorkflowProcess(button.dataset.gitWorkflowProcess);
12631
+ });
11227
12632
  elements.gitWorkflowCancelButton.addEventListener("click", () => cancelGitWorkflow());
12633
+ elements.gitPrCancelButton?.addEventListener("click", () => resolveGitPrDialog(null));
12634
+ elements.gitPrCreateButton?.addEventListener("click", () => {
12635
+ const title = elements.gitPrTitleInput?.value.trim() || "";
12636
+ const body = elements.gitPrBodyEditor?.value.trimEnd() || "";
12637
+ if (!title) {
12638
+ setGitPrDialogStatus("PR title is required.", "error");
12639
+ elements.gitPrTitleInput?.focus();
12640
+ return;
12641
+ }
12642
+ if (!body.trim()) {
12643
+ setGitPrDialogStatus("PR description is required.", "error");
12644
+ elements.gitPrBodyEditor?.focus();
12645
+ return;
12646
+ }
12647
+ resolveGitPrDialog({ title, body });
12648
+ });
12649
+ elements.gitPrDialog?.addEventListener("close", () => {
12650
+ if (activeGitPrDialogResolve) resolveGitPrDialog(null);
12651
+ });
11228
12652
  elements.nativeCommandDialog.addEventListener("close", () => {
11229
12653
  elements.nativeCommandSearch.oninput = null;
11230
12654
  nativeCommandTabId = null;
@@ -11320,6 +12744,8 @@ elements.compactButton.addEventListener("click", async () => {
11320
12744
  elements.compactButton.textContent = "Compacting…";
11321
12745
  setRunIndicatorActivity("Requesting context compaction…");
11322
12746
  scrollChatToBottom({ force: true });
12747
+ markContextUsageUnknownAfterCompaction(tabContext.tabId);
12748
+ renderFooter();
11323
12749
  addEvent("manual compaction requested");
11324
12750
  await api("/api/compact", { method: "POST", body: {}, tabId: tabContext.tabId });
11325
12751
  if (!isCurrentTabContext(tabContext)) return;
@@ -11328,7 +12754,9 @@ elements.compactButton.addEventListener("click", async () => {
11328
12754
  scheduleRefreshFooter(600, tabContext);
11329
12755
  } catch (error) {
11330
12756
  if (isCurrentTabContext(tabContext)) {
12757
+ clearContextUsageUnknownAfterCompaction(tabContext.tabId);
11331
12758
  clearRunIndicatorActivity();
12759
+ renderFooter();
11332
12760
  addEvent(error.message, "error");
11333
12761
  }
11334
12762
  } finally {
@@ -11440,6 +12868,9 @@ document.addEventListener("pointerdown", (event) => {
11440
12868
  if (nativeCommandMenuOpen && !event.target?.closest?.(".composer-native-command-menu")) {
11441
12869
  setNativeCommandMenuOpen(false);
11442
12870
  }
12871
+ if (appRunnerMenuOpen && !event.target?.closest?.(".composer-app-runner-menu")) {
12872
+ setAppRunnerMenuOpen(false);
12873
+ }
11443
12874
  if (optionsMenuOpen && !event.target?.closest?.(".composer-options-menu")) {
11444
12875
  setOptionsMenuOpen(false);
11445
12876
  }
@@ -11467,7 +12898,7 @@ function isTextEntryTarget(target) {
11467
12898
 
11468
12899
  function shouldHandleNativeAppShortcut(event) {
11469
12900
  if (event.defaultPrevented) return false;
11470
- if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.nativeCommandDialog?.open) return false;
12901
+ if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.nativeCommandDialog?.open || elements.appRunnerInfoDialog?.open) return false;
11471
12902
  return event.target === elements.promptInput || !isTextEntryTarget(event.target);
11472
12903
  }
11473
12904
 
@@ -11535,6 +12966,10 @@ window.addEventListener("keydown", (event) => {
11535
12966
  setNativeCommandMenuOpen(false);
11536
12967
  return;
11537
12968
  }
12969
+ if (appRunnerMenuOpen) {
12970
+ setAppRunnerMenuOpen(false);
12971
+ return;
12972
+ }
11538
12973
  if (optionsMenuOpen) {
11539
12974
  setOptionsMenuOpen(false);
11540
12975
  return;
@@ -11571,7 +13006,7 @@ window.addEventListener("keydown", (event) => {
11571
13006
  }
11572
13007
  lastEmptyPromptEscapeTime = now;
11573
13008
  }
11574
- if (isMobileView() && !document.body.classList.contains("side-panel-collapsed")) {
13009
+ if (isSidePanelOverlayView() && !document.body.classList.contains("side-panel-collapsed")) {
11575
13010
  setSidePanelCollapsed(true);
11576
13011
  return;
11577
13012
  }
@@ -11664,6 +13099,7 @@ elements.promptInput.addEventListener("keydown", (event) => {
11664
13099
 
11665
13100
  elements.promptInput.addEventListener("input", () => {
11666
13101
  resetPromptHistoryNavigation();
13102
+ if (moveLongPromptInputToAttachment()) return;
11667
13103
  resizePromptInput();
11668
13104
  renderCommandSuggestions();
11669
13105
  });
@@ -11692,6 +13128,7 @@ resizePromptInput();
11692
13128
  focusPromptInput({ defer: true });
11693
13129
  updateComposerModeButtons();
11694
13130
  updateOptionalFeatureAvailability();
13131
+ renderAppRunnerControls();
11695
13132
  renderLoadedPromptListPreview();
11696
13133
  loadLastUserPromptCache();
11697
13134
  loadPromptHistoryCache();
@@ -11711,6 +13148,7 @@ bindSidePanelSectionToggles();
11711
13148
  restoreSidePanelState();
11712
13149
  initializeCodexUsage();
11713
13150
  bindMobileViewChanges();
13151
+ bindSidePanelOverlayViewChanges();
11714
13152
  registerPwaServiceWorker();
11715
13153
  renderServerOfflinePanel();
11716
13154
  initializeTabs().catch((error) => addEvent(error.message, "error"));