@firstpick/pi-package-webui 0.3.2 → 0.3.4

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,12 +71,20 @@ 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"),
73
83
  setThinkingButton: $("#setThinkingButton"),
74
84
  thinkingVisibilityToggle: $("#thinkingVisibilityToggle"),
75
85
  thinkingVisibilityStatus: $("#thinkingVisibilityStatus"),
86
+ terminalTabsLayoutSelect: $("#terminalTabsLayoutSelect"),
87
+ terminalTabsLayoutStatus: $("#terminalTabsLayoutStatus"),
76
88
  themeSelect: $("#themeSelect"),
77
89
  backgroundInput: $("#backgroundInput"),
78
90
  backgroundChooseButton: $("#backgroundChooseButton"),
@@ -113,6 +125,13 @@ const elements = {
113
125
  promptListDialogLoadButton: $("#promptListDialogLoadButton"),
114
126
  promptListSaveButton: $("#promptListSaveButton"),
115
127
  promptListRunListButton: $("#promptListRunListButton"),
128
+ attachmentTextDialog: $("#attachmentTextDialog"),
129
+ attachmentTextTitle: $("#attachmentTextTitle"),
130
+ attachmentTextMeta: $("#attachmentTextMeta"),
131
+ attachmentTextEditor: $("#attachmentTextEditor"),
132
+ attachmentTextStatus: $("#attachmentTextStatus"),
133
+ attachmentTextCancelButton: $("#attachmentTextCancelButton"),
134
+ attachmentTextSaveButton: $("#attachmentTextSaveButton"),
116
135
  commandSearchInput: $("#commandSearchInput"),
117
136
  commandsBox: $("#commandsBox"),
118
137
  eventLog: $("#eventLog"),
@@ -143,6 +162,9 @@ const elements = {
143
162
  nativeCommandBody: $("#nativeCommandBody"),
144
163
  nativeCommandError: $("#nativeCommandError"),
145
164
  nativeCommandActions: $("#nativeCommandActions"),
165
+ appRunnerInfoDialog: $("#appRunnerInfoDialog"),
166
+ appRunnerInfoBody: $("#appRunnerInfoBody"),
167
+ appRunnerInfoCloseButton: $("#appRunnerInfoCloseButton"),
146
168
  };
147
169
 
148
170
  let currentState = null;
@@ -151,6 +173,7 @@ let activeTabId = null;
151
173
  let activeTabGeneration = 0;
152
174
  let tabDrafts = new Map();
153
175
  let tabAttachments = new Map();
176
+ let activeTextAttachmentEditor = null;
154
177
  let tabActivities = new Map();
155
178
  let tabSeenCompletionSerials = new Map();
156
179
  let streamBubble = null;
@@ -178,8 +201,10 @@ let refreshTabsTimer = null;
178
201
  let foregroundReconcileTimer = null;
179
202
  let eventSource = null;
180
203
  let activeDialog = null;
204
+ let activeGitPrDialogResolve = null;
181
205
  let nativeCommandTabId = null;
182
206
  let pathPickerState = null;
207
+ let firstTerminalCwdPromptShown = false;
183
208
  let pathFastPicks = [];
184
209
  let pathFastPicksReady = false;
185
210
  let pathFastPicksLoadPromise = null;
@@ -187,6 +212,9 @@ let mobileTabsExpanded = false;
187
212
  let openTerminalTabGroupKey = null;
188
213
  let newTabMenuOpen = false;
189
214
  let nativeCommandMenuOpen = false;
215
+ let appRunnerMenuOpen = false;
216
+ let appRunnerCustomDraft = { id: "", label: "", command: "./", path: "", args: "" };
217
+ let appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
190
218
  let optionsMenuOpen = false;
191
219
  let availableCommands = [];
192
220
  let rawAvailableCommands = [];
@@ -225,6 +253,7 @@ let blockedTabNotificationPermissionRequested = false;
225
253
  let blockedTabNotificationFallbackNoted = false;
226
254
  let agentDoneNotificationsEnabled = false;
227
255
  let thinkingOutputVisible = true;
256
+ let terminalTabsLayout = "top";
228
257
  let webuiSettings = {};
229
258
  let busyPromptBehavior = "followUp";
230
259
  let autocompleteMaxVisible = 12;
@@ -244,6 +273,7 @@ let customBackgroundLoading = false;
244
273
  let footerScopedModels = [];
245
274
  let footerScopedModelPatterns = [];
246
275
  let footerScopedModelSource = "none";
276
+ const contextUsageUnknownAfterCompactionByTab = new Map();
247
277
  let autoFollowChat = true;
248
278
  let chatFollowFrame = null;
249
279
  let chatFollowSettleTimer = null;
@@ -269,6 +299,7 @@ const TAB_STORAGE_KEY = "pi-webui-active-tab";
269
299
  const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
270
300
  const AGENT_DONE_NOTIFICATIONS_STORAGE_KEY = "pi-webui-agent-done-notifications";
271
301
  const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible";
302
+ const TERMINAL_TABS_LAYOUT_STORAGE_KEY = "pi-webui-terminal-tabs-layout";
272
303
  const TOOL_OUTPUT_EXPANDED_STORAGE_KEY = "pi-webui-tool-output-expanded";
273
304
  const THEME_STORAGE_KEY = "pi-webui-theme";
274
305
  const CUSTOM_BACKGROUND_STORAGE_KEY = "pi-webui-custom-background";
@@ -293,10 +324,15 @@ const ATTACHMENT_MAX_FILE_BYTES = 64 * 1024 * 1024;
293
324
  const ATTACHMENT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
294
325
  const ATTACHMENT_INLINE_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
295
326
  const ATTACHMENT_INLINE_IMAGE_TOTAL_MAX_BYTES = 16 * 1024 * 1024;
327
+ const LONG_INPUT_ATTACHMENT_LINE_THRESHOLD = 20;
328
+ const LONG_INPUT_ATTACHMENT_MIME_TYPE = "text/plain";
296
329
  const INLINE_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
297
330
  const BACKGROUND_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
298
331
  const DEFAULT_THEME_NAME = "catppuccin-mocha";
332
+ const TERMINAL_TABS_LAYOUTS = new Set(["top", "left"]);
333
+ const TERMINAL_TABS_LAYOUT_LABELS = { top: "Top bar", left: "Left sidebar" };
299
334
  const MOBILE_VIEW_QUERY = "(max-width: 720px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
335
+ const SIDE_PANEL_OVERLAY_QUERY = "(max-width: 1050px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
300
336
  const CHAT_BOTTOM_THRESHOLD_PX = 96;
301
337
  const STICKY_USER_PROMPT_PREVIEW_LIMIT = 220;
302
338
  const STICKY_USER_PROMPT_TOP_GAP_PX = 12;
@@ -325,10 +361,12 @@ const BLOCKED_TAB_NOTIFICATION_TAG_PREFIX = "pi-webui-blocked-tab";
325
361
  const AGENT_DONE_NOTIFICATION_TAG_PREFIX = "pi-webui-agent-done";
326
362
  const BLOCKED_TAB_NOTIFICATION_ICON = "/icon-192.png";
327
363
  const mobileViewMedia = window.matchMedia?.(MOBILE_VIEW_QUERY) || null;
364
+ const sidePanelOverlayMedia = window.matchMedia?.(SIDE_PANEL_OVERLAY_QUERY) || mobileViewMedia;
328
365
  const statusEntries = new Map();
329
366
  const widgets = new Map();
330
367
  const todoProgressWidgetExpandedByTab = new Map();
331
368
  const releaseNpmOutputExpandedByTab = new Map();
369
+ const appRunnerDataByTab = new Map();
332
370
  const liveToolRuns = new Map();
333
371
  const liveToolCards = new Map();
334
372
  const liveToolRenderQueue = new Map();
@@ -415,6 +453,8 @@ const OPTIONAL_FEATURES = [
415
453
  const OPTIONAL_FEATURE_BY_ID = new Map(OPTIONAL_FEATURES.map((feature) => [feature.id, feature]));
416
454
  const OPTIONAL_COMMAND_FEATURES = new Map([
417
455
  ["git-staged-msg", "gitWorkflow"],
456
+ ["git-branch-name", "gitWorkflow"],
457
+ ["pr", "gitWorkflow"],
418
458
  ["release-npm", "releaseNpm"],
419
459
  ["release-aur", "releaseAur"],
420
460
  ["skills", "tuiSkillsCommand"],
@@ -446,16 +486,42 @@ const SETTINGS_AUTOCOMPLETE_OPTIONS = ["3", "5", "7", "10", "15", "20"];
446
486
  const optionalFeatureInstallInProgress = new Set();
447
487
  const gitFooterPayloadRefreshInFlightByTab = new Set();
448
488
 
489
+ function createGitWorkflowActionsDone(patch = {}) {
490
+ return {
491
+ stage: false,
492
+ message: false,
493
+ commit: false,
494
+ push: false,
495
+ ...patch,
496
+ };
497
+ }
498
+
499
+ function gitWorkflowActionDone(workflow, process) {
500
+ return !!createGitWorkflowActionsDone(workflow?.actionsDone)[process];
501
+ }
502
+
503
+ function gitWorkflowActionDonePatch(workflow, process) {
504
+ return { actionsDone: createGitWorkflowActionsDone({ ...workflow?.actionsDone, [process]: true }) };
505
+ }
506
+
449
507
  function createGitWorkflowState() {
450
508
  return {
451
509
  active: false,
452
510
  step: "idle",
511
+ process: "stage",
453
512
  busy: false,
454
513
  runId: 0,
455
514
  output: "",
456
515
  error: "",
457
516
  message: null,
458
517
  messageRequestedAt: 0,
518
+ branchName: "",
519
+ branchNameRequestedAt: 0,
520
+ actionsDone: createGitWorkflowActionsDone(),
521
+ prMode: false,
522
+ prBranch: "",
523
+ pr: null,
524
+ prRequestedAt: 0,
459
525
  };
460
526
  }
461
527
 
@@ -499,7 +565,13 @@ function clearGitWorkflowForTab(tabId) {
499
565
  }
500
566
  }
501
567
 
502
- const GIT_WORKFLOW_STEPS = ["Stage", "Message", "Commit", "Push"];
568
+ const GIT_WORKFLOW_PROCESSES = [
569
+ { value: "stage", label: "Stage" },
570
+ { value: "message", label: "Message" },
571
+ { value: "commit", label: "Commit" },
572
+ { value: "push", label: "Push" },
573
+ ];
574
+ const GIT_WORKFLOW_PROCESS_VALUES = new Set(GIT_WORKFLOW_PROCESSES.map((process) => process.value));
503
575
  const ACTION_FEEDBACK_REACTIONS = {
504
576
  up: { icon: "👍", label: "Good job", title: "Good job!" },
505
577
  down: { icon: "👎", label: "Avoid this", title: "Avoid this" },
@@ -511,11 +583,32 @@ const GIT_WORKFLOW_ACTIVE_INDEX = {
511
583
  generate: 1,
512
584
  generating: 1,
513
585
  message: 2,
586
+ branchNaming: 2,
587
+ branching: 2,
514
588
  committing: 2,
515
589
  push: 3,
516
590
  pushing: 3,
591
+ prGenerating: 3,
592
+ prReview: 3,
593
+ prCreating: 3,
517
594
  done: 4,
518
595
  };
596
+ const GIT_WORKFLOW_CREATE_PR_TOOLTIP = [
597
+ "Create PR:",
598
+ "1. Ask Pi to generate a type/feature-name branch from staged changes.",
599
+ "2. Read dev/COMMIT/staged-branch-name.txt.",
600
+ "3. Let you confirm or edit the generated branch name.",
601
+ "4. Run git switch -c <branch>.",
602
+ "5. Return here so you can choose Commit short or Commit long on that branch.",
603
+ ].join("\n");
604
+ const GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP = [
605
+ "Manual branch:",
606
+ "1. Skip agent branch-name generation.",
607
+ "2. Prefill a branch from the commit message if possible.",
608
+ "3. Let you type or edit the type/feature-name branch name.",
609
+ "4. Run git switch -c <branch>.",
610
+ "5. Return here so you can choose Commit short or Commit long on that branch.",
611
+ ].join("\n");
519
612
 
520
613
  function make(tag, className, text) {
521
614
  const node = document.createElement(tag);
@@ -532,6 +625,10 @@ function isMobileView() {
532
625
  return mobileViewMedia?.matches || false;
533
626
  }
534
627
 
628
+ function isSidePanelOverlayView() {
629
+ return sidePanelOverlayMedia?.matches || false;
630
+ }
631
+
535
632
  function readStoredSidePanelCollapsed() {
536
633
  try {
537
634
  const stored = localStorage.getItem(SIDE_PANEL_STORAGE_KEY);
@@ -698,6 +795,26 @@ function persistThinkingOutputVisible(visible) {
698
795
  }
699
796
  }
700
797
 
798
+ function normalizeTerminalTabsLayout(value) {
799
+ return TERMINAL_TABS_LAYOUTS.has(value) ? value : "top";
800
+ }
801
+
802
+ function readStoredTerminalTabsLayout() {
803
+ try {
804
+ return normalizeTerminalTabsLayout(localStorage.getItem(TERMINAL_TABS_LAYOUT_STORAGE_KEY));
805
+ } catch {
806
+ return "top";
807
+ }
808
+ }
809
+
810
+ function persistTerminalTabsLayout(layout) {
811
+ try {
812
+ localStorage.setItem(TERMINAL_TABS_LAYOUT_STORAGE_KEY, normalizeTerminalTabsLayout(layout));
813
+ } catch {
814
+ // Ignore storage failures; the layout control should still work for this page load.
815
+ }
816
+ }
817
+
701
818
  function readStoredToolOutputExpanded() {
702
819
  try {
703
820
  return localStorage.getItem(TOOL_OUTPUT_EXPANDED_STORAGE_KEY) === "1";
@@ -725,6 +842,30 @@ function renderThinkingVisibilityToggle() {
725
842
  if (elements.thinkingVisibilityStatus) elements.thinkingVisibilityStatus.textContent = thinkingVisibilityStatusText();
726
843
  }
727
844
 
845
+ function terminalTabsLayoutStatusText(layout = terminalTabsLayout) {
846
+ return TERMINAL_TABS_LAYOUT_LABELS[normalizeTerminalTabsLayout(layout)] || TERMINAL_TABS_LAYOUT_LABELS.top;
847
+ }
848
+
849
+ function renderTerminalTabsLayoutControl() {
850
+ const layout = normalizeTerminalTabsLayout(terminalTabsLayout);
851
+ if (elements.terminalTabsLayoutSelect) elements.terminalTabsLayoutSelect.value = layout;
852
+ if (elements.terminalTabsLayoutStatus) elements.terminalTabsLayoutStatus.textContent = terminalTabsLayoutStatusText(layout);
853
+ }
854
+
855
+ function setTerminalTabsLayout(layout, { persist = true, announce = false } = {}) {
856
+ const next = normalizeTerminalTabsLayout(layout);
857
+ terminalTabsLayout = next;
858
+ document.body.classList.toggle("terminal-tabs-left", next === "left");
859
+ if (next === "left" && mobileTabsExpanded) setMobileTabsExpanded(false);
860
+ if (persist) persistTerminalTabsLayout(next);
861
+ renderTerminalTabsLayoutControl();
862
+ if (announce) addEvent(`terminal tabs layout changed to ${terminalTabsLayoutStatusText(next).toLowerCase()}`);
863
+ }
864
+
865
+ function restoreTerminalTabsLayoutSetting() {
866
+ setTerminalTabsLayout(readStoredTerminalTabsLayout(), { persist: false });
867
+ }
868
+
728
869
  function removeStreamingThinkingBubble() {
729
870
  streamThinkingBubble?.remove();
730
871
  streamThinkingBubble = null;
@@ -793,6 +934,7 @@ function setComposerActionsOpen(open) {
793
934
  if (!shouldOpen) {
794
935
  setPublishMenuOpen(false);
795
936
  setNativeCommandMenuOpen(false);
937
+ setAppRunnerMenuOpen(false);
796
938
  setOptionsMenuOpen(false);
797
939
  }
798
940
  }
@@ -928,7 +1070,7 @@ function setMobileTabsExpanded(expanded) {
928
1070
  }
929
1071
 
930
1072
  function syncMobileSidePanelState(collapsed) {
931
- const showBackdrop = !collapsed && isMobileView();
1073
+ const showBackdrop = !collapsed && isSidePanelOverlayView();
932
1074
  elements.sidePanelBackdrop.hidden = !showBackdrop;
933
1075
  if (showBackdrop) elements.sidePanel.setAttribute("aria-modal", "true");
934
1076
  else elements.sidePanel.removeAttribute("aria-modal");
@@ -942,11 +1084,11 @@ function setSidePanelCollapsed(collapsed, { persist = true, focusPanel = false }
942
1084
  elements.sidePanelExpandButton.setAttribute("aria-expanded", collapsed ? "false" : "true");
943
1085
  syncMobileSidePanelState(collapsed);
944
1086
 
945
- if (!collapsed && focusPanel && isMobileView()) {
1087
+ if (!collapsed && focusPanel && isSidePanelOverlayView()) {
946
1088
  requestAnimationFrame(() => elements.toggleSidePanelButton.focus());
947
1089
  }
948
1090
 
949
- if (!persist || isMobileView()) return;
1091
+ if (!persist || isSidePanelOverlayView()) return;
950
1092
  try {
951
1093
  localStorage.setItem(SIDE_PANEL_STORAGE_KEY, collapsed ? "1" : "0");
952
1094
  } catch {
@@ -955,7 +1097,7 @@ function setSidePanelCollapsed(collapsed, { persist = true, focusPanel = false }
955
1097
  }
956
1098
 
957
1099
  function restoreSidePanelState() {
958
- if (isMobileView()) {
1100
+ if (isSidePanelOverlayView()) {
959
1101
  setSidePanelCollapsed(true, { persist: false });
960
1102
  return;
961
1103
  }
@@ -969,7 +1111,7 @@ function bindMobileViewChanges() {
969
1111
  setComposerActionsOpen(false);
970
1112
  setMobileFooterExpanded(false);
971
1113
  setMobileTabsExpanded(false);
972
- if (event.matches) {
1114
+ if (event.matches || isSidePanelOverlayView()) {
973
1115
  setSidePanelCollapsed(true, { persist: false });
974
1116
  return;
975
1117
  }
@@ -980,6 +1122,20 @@ function bindMobileViewChanges() {
980
1122
  else mobileViewMedia.addListener?.(syncForViewport);
981
1123
  }
982
1124
 
1125
+ function bindSidePanelOverlayViewChanges() {
1126
+ if (!sidePanelOverlayMedia || sidePanelOverlayMedia === mobileViewMedia) return;
1127
+ const syncForViewport = (event) => {
1128
+ if (event.matches) {
1129
+ setSidePanelCollapsed(true, { persist: false });
1130
+ return;
1131
+ }
1132
+ const stored = readStoredSidePanelCollapsed();
1133
+ setSidePanelCollapsed(stored ?? false, { persist: false });
1134
+ };
1135
+ if (typeof sidePanelOverlayMedia.addEventListener === "function") sidePanelOverlayMedia.addEventListener("change", syncForViewport);
1136
+ else sidePanelOverlayMedia.addListener?.(syncForViewport);
1137
+ }
1138
+
983
1139
  function updateVisualViewportVars() {
984
1140
  const viewport = window.visualViewport;
985
1141
  const viewportHeight = viewport?.height || window.innerHeight || document.documentElement.clientHeight;
@@ -1055,8 +1211,7 @@ function currentPortArg() {
1055
1211
  }
1056
1212
 
1057
1213
  function serverStartCommandText() {
1058
- const cwd = readStoredServerStartCwd() || ".";
1059
- return `pi-webui --cwd ${quoteCommandArg(cwd)}${currentPortArg()}`;
1214
+ return `pi-webui${currentPortArg()}`;
1060
1215
  }
1061
1216
 
1062
1217
  function serverStartSlashCommandText() {
@@ -1376,6 +1531,46 @@ function attachmentIcon(kind) {
1376
1531
  return kind === "image" ? "🖼️" : kind === "video" ? "🎞️" : kind === "audio" ? "🎵" : kind === "doc" ? "📄" : "📎";
1377
1532
  }
1378
1533
 
1534
+ function normalizeTextAttachmentContent(text) {
1535
+ return String(text || "").replace(/\r\n?/g, "\n");
1536
+ }
1537
+
1538
+ function textLineCount(text) {
1539
+ const normalized = normalizeTextAttachmentContent(text);
1540
+ return normalized ? normalized.split("\n").length : 0;
1541
+ }
1542
+
1543
+ function shouldAttachTextInsteadOfComposerInput(text) {
1544
+ const normalized = normalizeTextAttachmentContent(text);
1545
+ return normalized.trim().length > 0 && textLineCount(normalized) > LONG_INPUT_ATTACHMENT_LINE_THRESHOLD;
1546
+ }
1547
+
1548
+ function longInputAttachmentFileName() {
1549
+ const stamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z").replace(/:/g, "-");
1550
+ return `webui-input-${stamp}.txt`;
1551
+ }
1552
+
1553
+ function makeTextAttachmentFile(text, name = longInputAttachmentFileName(), mimeType = LONG_INPUT_ATTACHMENT_MIME_TYPE) {
1554
+ const normalized = normalizeTextAttachmentContent(text);
1555
+ const fileName = String(name || longInputAttachmentFileName());
1556
+ const type = String(mimeType || LONG_INPUT_ATTACHMENT_MIME_TYPE);
1557
+ if (typeof File === "function") return new File([normalized], fileName, { type });
1558
+ const blob = new Blob([normalized], { type });
1559
+ try {
1560
+ blob.name = fileName;
1561
+ blob.lastModified = Date.now();
1562
+ } catch {
1563
+ // Older browsers may expose non-extensible Blob instances; the attachment record still carries the name.
1564
+ }
1565
+ return blob;
1566
+ }
1567
+
1568
+ function isEditableTextAttachment(attachment) {
1569
+ const name = String(attachment?.name || "");
1570
+ const mimeType = String(attachment?.mimeType || attachment?.file?.type || inferMimeTypeFromName(name)).split(";", 1)[0].trim().toLowerCase();
1571
+ 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);
1572
+ }
1573
+
1379
1574
  function attachmentsForTab(tabId = activeTabId) {
1380
1575
  return tabId ? tabAttachments.get(tabId) || [] : [];
1381
1576
  }
@@ -1404,11 +1599,19 @@ function renderAttachmentTray() {
1404
1599
  const icon = make("span", "attachment-pill-icon", attachmentIcon(attachment.kind));
1405
1600
  const name = make("span", "attachment-pill-name", attachment.name);
1406
1601
  const meta = make("span", "attachment-pill-meta", `${attachment.kind} · ${formatBytes(attachment.size)}`);
1602
+ const edit = isEditableTextAttachment(attachment) ? make("button", "attachment-edit-button", "Edit") : null;
1603
+ if (edit) {
1604
+ edit.type = "button";
1605
+ edit.setAttribute("aria-label", `Open and edit ${attachment.name}`);
1606
+ edit.addEventListener("click", () => openTextAttachmentEditor(attachment.id));
1607
+ }
1407
1608
  const remove = make("button", "attachment-remove-button", "×");
1408
1609
  remove.type = "button";
1409
1610
  remove.setAttribute("aria-label", `Remove ${attachment.name}`);
1410
1611
  remove.addEventListener("click", () => removeAttachment(attachment.id));
1411
- pill.append(icon, name, meta, remove);
1612
+ pill.append(icon, name, meta);
1613
+ if (edit) pill.append(edit);
1614
+ pill.append(remove);
1412
1615
  tray.append(pill);
1413
1616
  }
1414
1617
  }
@@ -1419,6 +1622,7 @@ function removeAttachment(id, tabId = activeTabId) {
1419
1622
  if (index === -1) return;
1420
1623
  const [removed] = attachments.splice(index, 1);
1421
1624
  if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl);
1625
+ if (activeTextAttachmentEditor?.tabId === tabId && activeTextAttachmentEditor?.attachmentId === id) closeTextAttachmentEditor();
1422
1626
  if (attachments.length === 0) tabAttachments.delete(tabId);
1423
1627
  if (tabId === activeTabId) renderAttachmentTray();
1424
1628
  }
@@ -1428,15 +1632,16 @@ function clearAttachments(tabId = activeTabId) {
1428
1632
  for (const attachment of attachments) {
1429
1633
  if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
1430
1634
  }
1635
+ if (activeTextAttachmentEditor?.tabId === tabId) closeTextAttachmentEditor();
1431
1636
  if (tabId) tabAttachments.delete(tabId);
1432
1637
  if (tabId === activeTabId) renderAttachmentTray();
1433
1638
  }
1434
1639
 
1435
1640
  function addAttachmentFiles(fileList, source = "picker") {
1436
1641
  const files = Array.from(fileList || []).filter(Boolean);
1437
- if (!files.length) return;
1642
+ if (!files.length) return { added: 0, skipped: [] };
1438
1643
  const attachments = ensureAttachmentsForTab();
1439
- if (!attachments.length && !activeTabId) return;
1644
+ if (!attachments.length && !activeTabId) return { added: 0, skipped: ["no active tab"] };
1440
1645
  let totalBytes = attachments.reduce((sum, attachment) => sum + attachment.size, 0);
1441
1646
  let added = 0;
1442
1647
  const skipped = [];
@@ -1474,6 +1679,129 @@ function addAttachmentFiles(fileList, source = "picker") {
1474
1679
  renderAttachmentTray();
1475
1680
  if (added) addEvent(`attached ${added} ${added === 1 ? "file" : "files"} from ${source}`, "info");
1476
1681
  if (skipped.length) addEvent(`skipped attachments: ${skipped.join("; ")}`, "warn");
1682
+ return { added, skipped };
1683
+ }
1684
+
1685
+ function attachLongTextAsFile(text, source = "input text") {
1686
+ if (!shouldAttachTextInsteadOfComposerInput(text)) return false;
1687
+ const normalized = normalizeTextAttachmentContent(text);
1688
+ const lineCount = textLineCount(normalized);
1689
+ const result = addAttachmentFiles([makeTextAttachmentFile(normalized)], `${lineCount}-line ${source}`);
1690
+ return result.added > 0;
1691
+ }
1692
+
1693
+ function moveLongPromptInputToAttachment() {
1694
+ const text = elements.promptInput.value || "";
1695
+ if (!attachLongTextAsFile(text, "input text")) return false;
1696
+ elements.promptInput.value = "";
1697
+ resizePromptInput();
1698
+ hideCommandSuggestions();
1699
+ return true;
1700
+ }
1701
+
1702
+ function attachmentById(tabId, id) {
1703
+ return attachmentsForTab(tabId).find((attachment) => attachment.id === id) || null;
1704
+ }
1705
+
1706
+ function closeTextAttachmentEditor() {
1707
+ if (elements.attachmentTextDialog?.open) elements.attachmentTextDialog.close();
1708
+ else activeTextAttachmentEditor = null;
1709
+ }
1710
+
1711
+ function setAttachmentTextStatus(message = "", level = "muted") {
1712
+ if (!elements.attachmentTextStatus) return;
1713
+ elements.attachmentTextStatus.textContent = message;
1714
+ elements.attachmentTextStatus.className = `attachment-text-status ${level || "muted"}`;
1715
+ }
1716
+
1717
+ function renderTextAttachmentEditorMeta() {
1718
+ if (!activeTextAttachmentEditor || !elements.attachmentTextMeta) return;
1719
+ const attachment = attachmentById(activeTextAttachmentEditor.tabId, activeTextAttachmentEditor.attachmentId);
1720
+ if (!attachment) {
1721
+ elements.attachmentTextMeta.textContent = "Attachment no longer exists.";
1722
+ return;
1723
+ }
1724
+ const text = elements.attachmentTextEditor?.value || "";
1725
+ const lineCount = textLineCount(text);
1726
+ elements.attachmentTextMeta.textContent = `${attachment.name} · ${attachment.mimeType} · ${formatBytes(attachment.size)} · ${lineCount} ${lineCount === 1 ? "line" : "lines"}`;
1727
+ }
1728
+
1729
+ function readFileAsText(file) {
1730
+ if (typeof file?.text === "function") return file.text();
1731
+ return new Promise((resolve, reject) => {
1732
+ const reader = new FileReader();
1733
+ reader.onerror = () => reject(reader.error || new Error("Failed to read text attachment"));
1734
+ reader.onload = () => resolve(String(reader.result || ""));
1735
+ reader.readAsText(file);
1736
+ });
1737
+ }
1738
+
1739
+ async function openTextAttachmentEditor(attachmentId, tabId = activeTabId) {
1740
+ const attachment = attachmentById(tabId, attachmentId);
1741
+ if (!attachment) return;
1742
+ if (!isEditableTextAttachment(attachment)) {
1743
+ addEvent(`${attachment.name || "attachment"} is not editable text`, "warn");
1744
+ return;
1745
+ }
1746
+
1747
+ activeTextAttachmentEditor = { tabId, attachmentId };
1748
+ if (elements.attachmentTextTitle) elements.attachmentTextTitle.textContent = `Edit ${attachment.name || "text attachment"}`;
1749
+ if (elements.attachmentTextEditor) elements.attachmentTextEditor.value = "";
1750
+ if (elements.attachmentTextSaveButton) elements.attachmentTextSaveButton.disabled = true;
1751
+ renderTextAttachmentEditorMeta();
1752
+ setAttachmentTextStatus("Loading text attachment…", "muted");
1753
+ if (elements.attachmentTextDialog && !elements.attachmentTextDialog.open) elements.attachmentTextDialog.showModal();
1754
+
1755
+ try {
1756
+ const text = await readFileAsText(attachment.file);
1757
+ if (activeTextAttachmentEditor?.tabId !== tabId || activeTextAttachmentEditor?.attachmentId !== attachmentId) return;
1758
+ if (elements.attachmentTextEditor) elements.attachmentTextEditor.value = normalizeTextAttachmentContent(text);
1759
+ if (elements.attachmentTextSaveButton) elements.attachmentTextSaveButton.disabled = false;
1760
+ renderTextAttachmentEditorMeta();
1761
+ setAttachmentTextStatus("Edit the text, then save it back to the attachment.", "muted");
1762
+ queueMicrotask(() => elements.attachmentTextEditor?.focus());
1763
+ } catch (error) {
1764
+ if (elements.attachmentTextSaveButton) elements.attachmentTextSaveButton.disabled = true;
1765
+ setAttachmentTextStatus(`Failed to open text attachment: ${error.message || String(error)}`, "error");
1766
+ }
1767
+ }
1768
+
1769
+ function totalAttachmentBytesWithReplacement(tabId, attachmentId, nextSize) {
1770
+ return attachmentsForTab(tabId).reduce((sum, attachment) => sum + (attachment.id === attachmentId ? nextSize : attachment.size || 0), 0);
1771
+ }
1772
+
1773
+ function saveTextAttachmentEdit() {
1774
+ if (!activeTextAttachmentEditor) return;
1775
+ const { tabId, attachmentId } = activeTextAttachmentEditor;
1776
+ const attachment = attachmentById(tabId, attachmentId);
1777
+ if (!attachment) {
1778
+ setAttachmentTextStatus("Attachment no longer exists.", "error");
1779
+ return;
1780
+ }
1781
+
1782
+ const text = elements.attachmentTextEditor?.value || "";
1783
+ const name = attachment.name || longInputAttachmentFileName();
1784
+ const mimeType = attachment.mimeType || inferMimeTypeFromName(name) || LONG_INPUT_ATTACHMENT_MIME_TYPE;
1785
+ const nextFile = makeTextAttachmentFile(text, name, mimeType);
1786
+ if (nextFile.size > ATTACHMENT_MAX_FILE_BYTES) {
1787
+ setAttachmentTextStatus(`Edited file is larger than ${formatBytes(ATTACHMENT_MAX_FILE_BYTES)}.`, "error");
1788
+ return;
1789
+ }
1790
+ if (totalAttachmentBytesWithReplacement(tabId, attachmentId, nextFile.size) > ATTACHMENT_MAX_TOTAL_BYTES) {
1791
+ setAttachmentTextStatus(`Edited attachments exceed ${formatBytes(ATTACHMENT_MAX_TOTAL_BYTES)} total.`, "error");
1792
+ return;
1793
+ }
1794
+
1795
+ if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
1796
+ attachment.file = nextFile;
1797
+ attachment.name = name;
1798
+ attachment.mimeType = nextFile.type || mimeType;
1799
+ attachment.size = nextFile.size || 0;
1800
+ attachment.kind = attachmentKind(attachment.mimeType, attachment.name);
1801
+ attachment.previewUrl = undefined;
1802
+ if (tabId === activeTabId) renderAttachmentTray();
1803
+ addEvent(`updated text attachment ${attachment.name} (${formatBytes(attachment.size)})`, "info");
1804
+ closeTextAttachmentEditor();
1477
1805
  }
1478
1806
 
1479
1807
  function clipboardFiles(dataTransfer) {
@@ -1501,9 +1829,15 @@ function clipboardFiles(dataTransfer) {
1501
1829
 
1502
1830
  function handleAttachmentPaste(event) {
1503
1831
  const files = clipboardFiles(event.clipboardData);
1504
- if (!files.length) return;
1832
+ if (files.length) {
1833
+ event.preventDefault();
1834
+ addAttachmentFiles(files, "clipboard");
1835
+ return;
1836
+ }
1837
+
1838
+ const text = event.clipboardData?.getData("text/plain") || "";
1839
+ if (!attachLongTextAsFile(text, "clipboard text")) return;
1505
1840
  event.preventDefault();
1506
- addAttachmentFiles(files, "clipboard");
1507
1841
  }
1508
1842
 
1509
1843
  function isFileDrag(event) {
@@ -2592,7 +2926,7 @@ function restoreActiveDraft() {
2592
2926
 
2593
2927
  function focusPromptInput({ defer = false } = {}) {
2594
2928
  const focus = () => {
2595
- if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.nativeCommandDialog.open || elements.promptListDialog?.open || document.visibilityState === "hidden") return;
2929
+ 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
2930
  try {
2597
2931
  elements.promptInput.focus({ preventScroll: true });
2598
2932
  } catch {
@@ -2666,6 +3000,7 @@ function resetActiveTabUi() {
2666
3000
  else renderQueue({ tabId: activeTabId, steering: [], followUp: [] });
2667
3001
  elements.commandsBox.textContent = "Loading…";
2668
3002
  elements.commandsBox.classList.add("muted");
3003
+ renderAppRunnerControls();
2669
3004
  renderWidgets();
2670
3005
  renderGitWorkflow();
2671
3006
  renderFooter();
@@ -2888,6 +3223,7 @@ function setNewTabMenuOpen(open) {
2888
3223
  function openNewTabMenu() {
2889
3224
  setPublishMenuOpen(false);
2890
3225
  setNativeCommandMenuOpen(false);
3226
+ setAppRunnerMenuOpen(false);
2891
3227
  setOptionsMenuOpen(false);
2892
3228
  setNewTabMenuOpen(true);
2893
3229
  }
@@ -2973,10 +3309,15 @@ function currentDirectoryForNewTab() {
2973
3309
  async function createTerminalTab(cwd = currentDirectoryForNewTab(), { triggerButton = elements.newTabButton } = {}) {
2974
3310
  setMobileTabsExpanded(false);
2975
3311
  setNewTabMenuOpen(false);
3312
+ const resolvedCwd = cwd || currentDirectoryForNewTab();
3313
+ if (!resolvedCwd && tabs.length === 0) {
3314
+ await createTerminalTabFromChosenDirectory({ triggerButton });
3315
+ return;
3316
+ }
2976
3317
  const disabledButtons = new Set([elements.newTabButton, triggerButton].filter(Boolean));
2977
3318
  for (const button of disabledButtons) button.disabled = true;
2978
3319
  try {
2979
- const response = await api("/api/tabs", { method: "POST", body: { cwd: cwd || currentDirectoryForNewTab() }, scoped: false });
3320
+ const response = await api("/api/tabs", { method: "POST", body: { cwd: resolvedCwd }, scoped: false });
2980
3321
  tabs = response.data?.tabs || tabs;
2981
3322
  syncTabMetadata(tabs);
2982
3323
  const tab = response.data?.tab;
@@ -3002,6 +3343,17 @@ async function createTerminalTabFromChosenDirectory({ triggerButton = elements.n
3002
3343
  await createTerminalTab(cwd, { triggerButton });
3003
3344
  }
3004
3345
 
3346
+ async function createFirstTerminalTabFromChosenDirectory() {
3347
+ if (firstTerminalCwdPromptShown || tabs.length > 0) return;
3348
+ firstTerminalCwdPromptShown = true;
3349
+ const cwd = await pickCwd({ id: "first-terminal", title: "first terminal" }, "", { title: "Choose CWD for first terminal" });
3350
+ if (!cwd) {
3351
+ addEvent("choose a CWD to start the first terminal", "warn");
3352
+ return;
3353
+ }
3354
+ await createTerminalTab(cwd, { triggerButton: null });
3355
+ }
3356
+
3005
3357
  function tabHasActiveAgent(tab) {
3006
3358
  const activity = activityForTab(tab);
3007
3359
  const indicator = tabIndicator(tab);
@@ -3051,6 +3403,7 @@ async function closeTerminalTabs(tabIds, { label = "selected terminal tabs" } =
3051
3403
  tabDrafts.delete(id);
3052
3404
  clearAttachments(id);
3053
3405
  clearGitWorkflowForTab(id);
3406
+ appRunnerDataByTab.delete(id);
3054
3407
  }
3055
3408
  clearOpenTerminalTabGroup(null, { force: true });
3056
3409
 
@@ -3093,10 +3446,14 @@ async function closeAllTerminalTabs() {
3093
3446
  }
3094
3447
 
3095
3448
  async function initializeTabs() {
3096
- await refreshTabs({ selectStored: true });
3449
+ const loadedTabs = await refreshTabs({ selectStored: true });
3097
3450
  resetActiveTabUi();
3098
3451
  renderTabs();
3099
3452
  restoreActiveDraft();
3453
+ if (!loadedTabs.length) {
3454
+ await createFirstTerminalTabFromChosenDirectory();
3455
+ return;
3456
+ }
3100
3457
  focusPromptInput({ defer: true });
3101
3458
  const tabContext = activeTabContext();
3102
3459
  connectEvents(tabContext);
@@ -3582,6 +3939,50 @@ function footerCostAuthLabel() {
3582
3939
  return /codex|copilot|chatgpt/i.test(provider) ? "sub" : "api";
3583
3940
  }
3584
3941
 
3942
+ function contextWindowFromSources(...sources) {
3943
+ for (const source of sources) {
3944
+ const value = typeof source === "object" && source !== null ? source.contextWindow : source;
3945
+ const contextWindow = Number(value);
3946
+ if (Number.isFinite(contextWindow) && contextWindow > 0) return contextWindow;
3947
+ }
3948
+ return 0;
3949
+ }
3950
+
3951
+ function contextUsageUnknownAfterCompaction(tabId = activeTabId) {
3952
+ return !!tabId && contextUsageUnknownAfterCompactionByTab.has(tabId);
3953
+ }
3954
+
3955
+ function unknownFooterContextText(contextUsage = null) {
3956
+ const contextWindow = contextWindowFromSources(
3957
+ contextUsage,
3958
+ latestStats?.contextUsage,
3959
+ currentState?.contextUsage,
3960
+ currentState?.model?.contextWindow,
3961
+ );
3962
+ return contextWindow ? `?/${formatFooterTokenCount(contextWindow)}` : "?";
3963
+ }
3964
+
3965
+ function contextUsageWithUnknownPercent(contextUsage = null) {
3966
+ return {
3967
+ ...(contextUsage || {}),
3968
+ percent: null,
3969
+ contextWindow: contextWindowFromSources(contextUsage, latestStats?.contextUsage, currentState?.contextUsage, currentState?.model?.contextWindow),
3970
+ };
3971
+ }
3972
+
3973
+ function markContextUsageUnknownAfterCompaction(tabId = activeTabId) {
3974
+ if (!tabId) return;
3975
+ contextUsageUnknownAfterCompactionByTab.set(tabId, Date.now());
3976
+ if (tabId !== activeTabId) return;
3977
+ if (currentState) currentState = { ...currentState, contextUsage: contextUsageWithUnknownPercent(currentState.contextUsage) };
3978
+ if (latestStats) latestStats = { ...latestStats, contextUsage: contextUsageWithUnknownPercent(latestStats.contextUsage) };
3979
+ }
3980
+
3981
+ function clearContextUsageUnknownAfterCompaction(tabId = activeTabId) {
3982
+ if (!tabId) return;
3983
+ contextUsageUnknownAfterCompactionByTab.delete(tabId);
3984
+ }
3985
+
3585
3986
  function footerStatsTokensDisplay(stats = latestStats) {
3586
3987
  const tokens = stats?.tokens;
3587
3988
  if (!tokens) return "";
@@ -3602,7 +4003,8 @@ function footerContextDisplayWithAuto(value, state = currentState) {
3602
4003
 
3603
4004
  function footerStatsContextDisplay(stats = latestStats) {
3604
4005
  const usage = stats?.contextUsage || currentState?.contextUsage;
3605
- const contextWindow = usage?.contextWindow ?? currentState?.model?.contextWindow ?? 0;
4006
+ const contextWindow = contextWindowFromSources(usage, currentState?.model?.contextWindow);
4007
+ if (contextUsageUnknownAfterCompaction()) return footerContextDisplayWithAuto(unknownFooterContextText(usage));
3606
4008
  if (!contextWindow) return "";
3607
4009
  const rawPercent = Number(usage?.percent);
3608
4010
  const percent = Number.isFinite(rawPercent) ? `${rawPercent.toFixed(1)}%` : "?";
@@ -3901,8 +4303,10 @@ function footerPayloadWithLiveModel(payload) {
3901
4303
  const effort = footerThinkingDisplay();
3902
4304
  const hasThinkingChip = [...payload.main, ...payload.meta].some((chip) => chip?.key === "thinking");
3903
4305
  const contextChip = (chip) => {
3904
- const value = footerContextDisplayWithAuto(chip?.value);
3905
- return { ...chip, value, title: `context: ${value}` };
4306
+ const usageUnknown = contextUsageUnknownAfterCompaction();
4307
+ const value = usageUnknown ? footerContextDisplayWithAuto(unknownFooterContextText(chip?.contextUsage)) : footerContextDisplayWithAuto(chip?.value);
4308
+ const contextUsage = usageUnknown ? contextUsageWithUnknownPercent(chip?.contextUsage) : chip?.contextUsage;
4309
+ return { ...chip, value, title: `context: ${value}`, ...(contextUsage ? { contextUsage } : {}) };
3906
4310
  };
3907
4311
  const effortChip = (chip) => ({ ...chip, key: "thinking", label: "effort", value: effort, title: `effort: ${effort}`, tone: "mauve" });
3908
4312
  const splitChip = (chip) => {
@@ -5246,10 +5650,534 @@ function renderReleaseAurLogWidget() {
5246
5650
  const logLines = lines.slice(2).filter((line, index) => index > 0 || stripAnsi(line).trim());
5247
5651
  const streamHeader = releaseNpmStreamHeader("Saved AUR output stream", logLines.length);
5248
5652
  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);
5653
+ for (const line of logLines) {
5654
+ appendReleaseNpmTerminalLine(terminal, line);
5655
+ }
5656
+ const outputDetails = renderReleaseNpmOutputDetails("release-aur:logs", streamHeader, terminal);
5657
+ node.append(header, outputDetails);
5658
+ requestAnimationFrame(() => { if (outputDetails.open) terminal.scrollTop = terminal.scrollHeight; });
5659
+ return node;
5660
+ }
5661
+
5662
+ function activeAppRunnerData() {
5663
+ return activeTabId ? appRunnerDataByTab.get(activeTabId) || { runners: [], activeRun: null } : { runners: [], activeRun: null };
5664
+ }
5665
+
5666
+ function setAppRunnerData(tabId, data = {}) {
5667
+ if (!tabId) return;
5668
+ const previous = appRunnerDataByTab.get(tabId) || { runners: [], activeRun: null, customRunnerConfig: null };
5669
+ appRunnerDataByTab.set(tabId, {
5670
+ cwd: data.cwd || previous.cwd || "",
5671
+ runners: Array.isArray(data.runners) ? data.runners : previous.runners || [],
5672
+ customRunnerConfig: data.customRunnerConfig || previous.customRunnerConfig || null,
5673
+ activeRun: Object.prototype.hasOwnProperty.call(data, "activeRun") ? data.activeRun : previous.activeRun || null,
5674
+ });
5675
+ }
5676
+
5677
+ function appRunnerIsRunning(run) {
5678
+ return run?.status === "running" || run?.stopping === true;
5679
+ }
5680
+
5681
+ function appRunnerStatusLabel(run) {
5682
+ if (run?.stopping && run.status === "running") return "stopping";
5683
+ if (run?.status === "done") return "exit 0";
5684
+ if (run?.status === "failed") return run.signal ? `signal ${run.signal}` : `exit ${run.exitCode ?? "?"}`;
5685
+ if (run?.status === "error") return "error";
5686
+ return run?.status || "running";
5687
+ }
5688
+
5689
+ function appRunnerElapsedLabel(run) {
5690
+ const startedAt = Date.parse(run?.startedAt || "");
5691
+ if (!Number.isFinite(startedAt)) return "";
5692
+ const endedAt = Date.parse(run?.endedAt || "");
5693
+ const end = Number.isFinite(endedAt) ? endedAt : Date.now();
5694
+ return formatDuration(end - startedAt);
5695
+ }
5696
+
5697
+ function appRunnerActionButton(label, handler, className = "") {
5698
+ const button = make("button", `release-npm-action ${className}`.trim(), label);
5699
+ button.type = "button";
5700
+ button.addEventListener("click", handler);
5701
+ return button;
5702
+ }
5703
+
5704
+ async function refreshAppRunners(tabContext = activeTabContext()) {
5705
+ if (!tabContext.tabId) return;
5706
+ const response = await api("/api/app-runners", { tabId: tabContext.tabId });
5707
+ if (!isCurrentTabContext(tabContext)) return;
5708
+ setAppRunnerData(tabContext.tabId, response.data || {});
5709
+ renderAppRunnerControls();
5710
+ renderWidgets();
5711
+ }
5712
+
5713
+ async function runAppRunner(runnerId) {
5714
+ const tabContext = activeTabContext();
5715
+ if (!tabContext.tabId || !runnerId) return;
5716
+ setComposerActionsOpen(false);
5717
+ setAppRunnerMenuOpen(false);
5718
+ try {
5719
+ const response = await api("/api/app-runner", { method: "POST", body: { runnerId }, tabId: tabContext.tabId });
5720
+ if (!isCurrentTabContext(tabContext)) return;
5721
+ setAppRunnerData(tabContext.tabId, response.data || {});
5722
+ renderAppRunnerControls();
5723
+ renderWidgets();
5724
+ const command = response.data?.activeRun?.displayCommand || "app runner";
5725
+ addEvent(`started ${command}`, "info");
5726
+ } catch (error) {
5727
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
5728
+ }
5729
+ }
5730
+
5731
+ async function stopAppRunner() {
5732
+ const tabContext = activeTabContext();
5733
+ if (!tabContext.tabId) return;
5734
+ try {
5735
+ const response = await api("/api/app-runner/stop", { method: "POST", body: {}, tabId: tabContext.tabId });
5736
+ if (!isCurrentTabContext(tabContext)) return;
5737
+ setAppRunnerData(tabContext.tabId, response.data || {});
5738
+ renderAppRunnerControls();
5739
+ renderWidgets();
5740
+ addEvent("app runner stop requested", "warn");
5741
+ } catch (error) {
5742
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
5743
+ }
5744
+ }
5745
+
5746
+ async function clearAppRunner() {
5747
+ const tabContext = activeTabContext();
5748
+ if (!tabContext.tabId) return;
5749
+ try {
5750
+ const response = await api("/api/app-runner/clear", { method: "POST", body: {}, tabId: tabContext.tabId });
5751
+ if (!isCurrentTabContext(tabContext)) return;
5752
+ setAppRunnerData(tabContext.tabId, response.data || {});
5753
+ renderAppRunnerControls();
5754
+ renderWidgets();
5755
+ } catch (error) {
5756
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
5757
+ }
5758
+ }
5759
+
5760
+ function appRunnerOutputText(run) {
5761
+ const lines = Array.isArray(run?.lines) ? run.lines : [];
5762
+ return lines.join("\n").trimEnd();
5763
+ }
5764
+
5765
+ async function copyAppRunnerOutput(run) {
5766
+ const text = appRunnerOutputText(run);
5767
+ if (!text.trim()) {
5768
+ addEvent("app runner output is empty", "warn");
5769
+ return;
5770
+ }
5771
+ try {
5772
+ await copyText(text);
5773
+ addEvent("copied app runner output", "info");
5774
+ } catch (error) {
5775
+ addEvent(`app runner output copy failed: ${error.message || String(error)}`, "warn");
5776
+ }
5777
+ }
5778
+
5779
+ const APP_RUNNER_SUPPORTED_ITEMS = [
5780
+ "Project-local custom runners from .pi-webui-runners.json",
5781
+ "package.json scripts: bun/npm/pnpm/yarn dev, start, serve",
5782
+ "npx frameworks: Vite, Next, Astro, Storybook",
5783
+ "Rust: cargo run",
5784
+ "Python: uv run or python entry files such as Main.py, main.py, src/main.py",
5785
+ "Go/Golang: go run",
5786
+ "Zig: zig build run or zig run",
5787
+ "C/C++: CMake, cc/c++ main files",
5788
+ "Docker Compose: docker compose up",
5789
+ "Shell scripts: bash/zsh/fish in root, dev/, scripts/, dev/scripts/",
5790
+ "Deno, make, just, and plain Node entry files",
5791
+ ];
5792
+ const APP_RUNNER_SUPPORTED_TOOLTIP = [
5793
+ "No app runner detected for this tab cwd.",
5794
+ "",
5795
+ "Currently supported:",
5796
+ ...APP_RUNNER_SUPPORTED_ITEMS.map((item) => `• ${item}`),
5797
+ ].join("\n");
5798
+
5799
+ function appRunnerMenuCanOpen() {
5800
+ const data = activeAppRunnerData();
5801
+ return Array.isArray(data.runners) && data.runners.length > 0 && !appRunnerIsRunning(data.activeRun);
5802
+ }
5803
+
5804
+ function activeAppRunnerCustomConfig() {
5805
+ return activeAppRunnerData().customRunnerConfig || { runners: [], projectRoot: "", displayProjectRoot: "", displayConfigFile: "" };
5806
+ }
5807
+
5808
+ function resetAppRunnerCustomDraft() {
5809
+ appRunnerCustomDraft = { id: "", label: "", command: "./", path: "", args: "" };
5810
+ appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
5811
+ }
5812
+
5813
+ function appRunnerRelativeDir(filePath) {
5814
+ const normalized = String(filePath || "").replace(/\\/g, "/").replace(/^\.\/+/, "");
5815
+ const index = normalized.lastIndexOf("/");
5816
+ return index === -1 ? "" : normalized.slice(0, index);
5817
+ }
5818
+
5819
+ function appRunnerCustomArgsText(args) {
5820
+ return Array.isArray(args) ? args.join(" ") : String(args || "");
5821
+ }
5822
+
5823
+ function appRunnerCustomDraftPayload() {
5824
+ return {
5825
+ id: appRunnerCustomDraft.id || undefined,
5826
+ label: appRunnerCustomDraft.label.trim(),
5827
+ command: appRunnerCustomDraft.command.trim() || "./",
5828
+ path: appRunnerCustomDraft.path.trim(),
5829
+ args: appRunnerCustomDraft.args.trim(),
5830
+ };
5831
+ }
5832
+
5833
+ function updateAppRunnerCustomDraftFrom(container) {
5834
+ if (!container) return;
5835
+ appRunnerCustomDraft = {
5836
+ id: appRunnerCustomDraft.id || "",
5837
+ label: container.querySelector("#appRunnerCustomLabelInput")?.value || "",
5838
+ command: container.querySelector("#appRunnerCustomCommandInput")?.value || "./",
5839
+ path: container.querySelector("#appRunnerCustomPathInput")?.value || "",
5840
+ args: container.querySelector("#appRunnerCustomArgsInput")?.value || "",
5841
+ };
5842
+ }
5843
+
5844
+ function appRunnerInputField({ id, label, value, placeholder = "", hint = "" }) {
5845
+ const field = make("label", "app-runner-custom-field");
5846
+ field.setAttribute("for", id);
5847
+ field.append(make("span", "", label));
5848
+ const input = make("input", "dialog-input");
5849
+ input.id = id;
5850
+ input.type = "text";
5851
+ input.value = value || "";
5852
+ input.placeholder = placeholder;
5853
+ input.autocomplete = "off";
5854
+ input.spellcheck = false;
5855
+ field.append(input);
5856
+ if (hint) field.append(make("small", "muted", hint));
5857
+ input.addEventListener("input", () => updateAppRunnerCustomDraftFrom(field.closest(".app-runner-custom-form")));
5858
+ input.addEventListener("keydown", (event) => {
5859
+ if (event.key !== "Enter") return;
5860
+ event.preventDefault();
5861
+ saveAppRunnerCustomRunner(field.closest(".app-runner-custom-form"));
5862
+ });
5863
+ return { field, input };
5864
+ }
5865
+
5866
+ async function saveAppRunnerCustomRunner(form) {
5867
+ updateAppRunnerCustomDraftFrom(form);
5868
+ const payload = appRunnerCustomDraftPayload();
5869
+ if (!payload.path) {
5870
+ addEvent("custom app runner path is required", "warn");
5871
+ form?.querySelector("#appRunnerCustomPathInput")?.focus();
5872
+ return;
5873
+ }
5874
+ const tabContext = activeTabContext();
5875
+ try {
5876
+ const response = await api("/api/app-runner-config", { method: "POST", body: { runner: payload }, tabId: tabContext.tabId });
5877
+ if (!isCurrentTabContext(tabContext)) return;
5878
+ setAppRunnerData(tabContext.tabId, response.data || {});
5879
+ resetAppRunnerCustomDraft();
5880
+ renderAppRunnerControls();
5881
+ renderWidgets();
5882
+ renderAppRunnerInfoDialog();
5883
+ addEvent("saved custom app runner", "info");
5884
+ } catch (error) {
5885
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
5886
+ }
5887
+ }
5888
+
5889
+ async function deleteAppRunnerCustomRunner(id) {
5890
+ const tabContext = activeTabContext();
5891
+ try {
5892
+ const response = await api("/api/app-runner-config", { method: "DELETE", body: { id }, tabId: tabContext.tabId });
5893
+ if (!isCurrentTabContext(tabContext)) return;
5894
+ setAppRunnerData(tabContext.tabId, response.data || {});
5895
+ if (appRunnerCustomDraft.id === id) resetAppRunnerCustomDraft();
5896
+ renderAppRunnerControls();
5897
+ renderWidgets();
5898
+ renderAppRunnerInfoDialog();
5899
+ addEvent("deleted custom app runner", "warn");
5900
+ } catch (error) {
5901
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
5902
+ }
5903
+ }
5904
+
5905
+ async function loadAppRunnerFileBrowser(relativePath = "") {
5906
+ const tabContext = activeTabContext();
5907
+ const path = String(relativePath || "").replace(/^\.\/+/, "").replace(/\/+$/g, "");
5908
+ appRunnerFileBrowserState = { open: true, loading: true, path, data: null, error: "" };
5909
+ renderAppRunnerInfoDialog();
5910
+ try {
5911
+ const response = await api(`/api/app-runner-files?path=${encodeURIComponent(path)}`, { tabId: tabContext.tabId });
5912
+ if (!isCurrentTabContext(tabContext)) return;
5913
+ appRunnerFileBrowserState = { open: true, loading: false, path, data: response.data || {}, error: "" };
5914
+ renderAppRunnerInfoDialog();
5915
+ } catch (error) {
5916
+ if (!isCurrentTabContext(tabContext)) return;
5917
+ appRunnerFileBrowserState = { open: true, loading: false, path, data: null, error: error.message || String(error) };
5918
+ renderAppRunnerInfoDialog();
5919
+ }
5920
+ }
5921
+
5922
+ function renderAppRunnerFileBrowser() {
5923
+ if (!appRunnerFileBrowserState.open) return null;
5924
+ const browser = make("div", "app-runner-file-browser");
5925
+ if (appRunnerFileBrowserState.loading) {
5926
+ browser.append(make("div", "muted", "Loading project files…"));
5927
+ return browser;
5928
+ }
5929
+ if (appRunnerFileBrowserState.error) {
5930
+ browser.append(make("div", "path-picker-error", appRunnerFileBrowserState.error));
5931
+ return browser;
5932
+ }
5933
+ const data = appRunnerFileBrowserState.data || {};
5934
+ const header = make("div", "app-runner-file-browser-header");
5935
+ header.append(make("strong", "", data.displayRelativeDir || "."));
5936
+ const close = make("button", "app-runner-file-browser-close", "Hide browser");
5937
+ close.type = "button";
5938
+ close.addEventListener("click", () => {
5939
+ appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
5940
+ renderAppRunnerInfoDialog();
5941
+ });
5942
+ header.append(close);
5943
+ browser.append(header);
5944
+
5945
+ const roots = make("div", "path-picker-roots app-runner-file-browser-roots");
5946
+ if (data.parent !== null && data.parent !== undefined) roots.append(pathPickerButton("↑ Parent", data.parent || ".", () => loadAppRunnerFileBrowser(data.parent || ""), "path-picker-root-button"));
5947
+ roots.append(pathPickerButton("Project root", data.displayProjectRoot || "Project root", () => loadAppRunnerFileBrowser(""), "path-picker-root-button"));
5948
+ browser.append(roots);
5949
+
5950
+ const list = make("div", "path-picker-list app-runner-file-browser-list");
5951
+ const directories = Array.isArray(data.directories) ? data.directories : [];
5952
+ const files = Array.isArray(data.files) ? data.files : [];
5953
+ for (const directory of directories) {
5954
+ const button = pathPickerButton(`${directory.name}/`, directory.path, () => loadAppRunnerFileBrowser(directory.path), `path-picker-directory${directory.hidden ? " hidden-directory" : ""}`);
5955
+ list.append(button);
5956
+ }
5957
+ for (const file of files) {
5958
+ const button = pathPickerButton(file.name, file.path, () => {
5959
+ appRunnerCustomDraft.path = file.path;
5960
+ appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
5961
+ renderAppRunnerInfoDialog();
5962
+ }, `path-picker-directory app-runner-file-choice${file.hidden ? " hidden-directory" : ""}`);
5963
+ list.append(button);
5964
+ }
5965
+ if (!directories.length && !files.length) list.append(make("div", "path-picker-empty muted", "No files in this directory."));
5966
+ browser.append(list);
5967
+ if (data.truncated) browser.append(make("div", "path-picker-error", "Showing the first project entries only."));
5968
+ return browser;
5969
+ }
5970
+
5971
+ function renderAppRunnerCustomSection() {
5972
+ const config = activeAppRunnerCustomConfig();
5973
+ const section = make("section", "app-runner-info-section app-runner-custom-section");
5974
+ const titleRow = make("div", "app-runner-section-title-row");
5975
+ titleRow.append(make("h3", "", "Custom project runners"));
5976
+ if (config.displayConfigFile) titleRow.append(make("code", "", config.displayConfigFile));
5977
+ section.append(titleRow);
5978
+ 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."));
5979
+
5980
+ const existing = make("div", "app-runner-custom-list");
5981
+ const customRunners = Array.isArray(config.runners) ? config.runners : [];
5982
+ if (!customRunners.length) {
5983
+ existing.append(make("div", "app-runner-custom-empty muted", "No custom runners saved for this project yet."));
5984
+ } else {
5985
+ for (const runner of customRunners) {
5986
+ const row = make("div", "app-runner-custom-item");
5987
+ const details = make("div", "app-runner-custom-item-details");
5988
+ details.append(make("strong", "", runner.label || runner.path || "custom runner"), make("code", "", runner.displayCommand || runner.path || ""));
5989
+ const actions = make("div", "app-runner-custom-item-actions");
5990
+ const edit = make("button", "", "Edit");
5991
+ edit.type = "button";
5992
+ edit.addEventListener("click", () => {
5993
+ appRunnerCustomDraft = { id: runner.id || "", label: runner.label || "", command: runner.command || "./", path: runner.path || "", args: appRunnerCustomArgsText(runner.args) };
5994
+ appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
5995
+ renderAppRunnerInfoDialog();
5996
+ });
5997
+ const remove = make("button", "danger", "Delete");
5998
+ remove.type = "button";
5999
+ remove.addEventListener("click", () => {
6000
+ if (!confirm(`Delete custom app runner “${runner.label || runner.path || runner.id}”?`)) return;
6001
+ deleteAppRunnerCustomRunner(runner.id);
6002
+ });
6003
+ actions.append(edit, remove);
6004
+ row.append(details, actions);
6005
+ existing.append(row);
6006
+ }
6007
+ }
6008
+ section.append(existing);
6009
+
6010
+ const form = make("div", "app-runner-custom-form");
6011
+ const labelField = appRunnerInputField({ id: "appRunnerCustomLabelInput", label: "Label", value: appRunnerCustomDraft.label, placeholder: "My app" });
6012
+ 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." });
6013
+ const pathField = appRunnerInputField({ id: "appRunnerCustomPathInput", label: "Path to file", value: appRunnerCustomDraft.path, placeholder: "dev/scripts/start.sh" });
6014
+ const pathRow = make("div", "app-runner-custom-path-row");
6015
+ pathRow.append(pathField.field);
6016
+ const browse = make("button", "app-runner-custom-browse", "Browse…");
6017
+ browse.type = "button";
6018
+ browse.addEventListener("click", () => {
6019
+ updateAppRunnerCustomDraftFrom(form);
6020
+ loadAppRunnerFileBrowser(appRunnerRelativeDir(appRunnerCustomDraft.path));
6021
+ });
6022
+ pathRow.append(browse);
6023
+ const argsField = appRunnerInputField({ id: "appRunnerCustomArgsInput", label: "Args", value: appRunnerCustomDraft.args, placeholder: "--port 3000", hint: "Optional extra args, space-separated." });
6024
+ form.append(labelField.field, commandField.field, pathRow, argsField.field);
6025
+ const formActions = make("div", "app-runner-custom-form-actions");
6026
+ const save = make("button", "primary", appRunnerCustomDraft.id ? "Save changes" : "Add runner");
6027
+ save.type = "button";
6028
+ save.addEventListener("click", () => saveAppRunnerCustomRunner(form));
6029
+ const reset = make("button", "", "Reset");
6030
+ reset.type = "button";
6031
+ reset.addEventListener("click", () => { resetAppRunnerCustomDraft(); renderAppRunnerInfoDialog(); });
6032
+ formActions.append(save, reset);
6033
+ form.append(formActions);
6034
+ const browser = renderAppRunnerFileBrowser();
6035
+ if (browser) form.append(browser);
6036
+ section.append(form);
6037
+ return section;
6038
+ }
6039
+
6040
+ function renderAppRunnerControls() {
6041
+ const menu = elements.appRunnerMenu;
6042
+ const button = elements.appRunnerMenuButton;
6043
+ const panel = elements.appRunnerMenuPanel;
6044
+ if (!menu || !button || !panel) return;
6045
+ const data = activeAppRunnerData();
6046
+ const runners = Array.isArray(data.runners) ? data.runners : [];
6047
+ const activeRun = data.activeRun;
6048
+ const running = appRunnerIsRunning(activeRun);
6049
+ menu.hidden = false;
6050
+ menu.classList.toggle("has-runners", runners.length > 0);
6051
+ if (elements.appRunnerInfoButton) {
6052
+ elements.appRunnerInfoButton.hidden = runners.length === 0;
6053
+ elements.appRunnerInfoButton.disabled = runners.length === 0;
6054
+ }
6055
+ button.disabled = running;
6056
+ button.title = running
6057
+ ? `App runner already running: ${activeRun.displayCommand || activeRun.label || "runner"}`
6058
+ : runners.length
6059
+ ? "Run a detected app runner"
6060
+ : "No app runners detected in this tab working directory";
6061
+ button.dataset.tooltip = runners.length ? "App runners: run detected project commands in this tab's working directory." : APP_RUNNER_SUPPORTED_TOOLTIP;
6062
+ button.setAttribute("aria-label", button.title);
6063
+ if (!runners.length || running) setAppRunnerMenuOpen(false);
6064
+
6065
+ panel.replaceChildren();
6066
+ for (const runner of runners) {
6067
+ const item = make("button", "composer-publish-menu-item composer-app-runner-menu-item");
6068
+ item.type = "button";
6069
+ item.setAttribute("role", "menuitem");
6070
+ const runnerDisplayCommand = runner.shortDisplayCommand || runner.displayCommand;
6071
+ item.title = runner.description ? `${runnerDisplayCommand}\n${runner.description}` : runnerDisplayCommand;
6072
+ item.addEventListener("click", () => runAppRunner(runner.id));
6073
+ const label = make("span", "app-runner-menu-item-label", runner.label || runnerDisplayCommand);
6074
+ const command = make("span", "app-runner-menu-item-command", runnerDisplayCommand);
6075
+ item.append(label, command);
6076
+ panel.append(item);
6077
+ }
6078
+ }
6079
+
6080
+ function renderAppRunnerInfoDialog() {
6081
+ const body = elements.appRunnerInfoBody;
6082
+ if (!body) return;
6083
+ const data = activeAppRunnerData();
6084
+ const runners = Array.isArray(data.runners) ? data.runners : [];
6085
+ body.replaceChildren();
6086
+
6087
+ const current = make("section", "app-runner-info-section");
6088
+ current.append(make("h3", "", "Detected in this tab"));
6089
+ if (runners.length) {
6090
+ const list = make("ul", "app-runner-info-list app-runner-info-detected-list");
6091
+ for (const runner of runners) {
6092
+ const command = runner.shortDisplayCommand || runner.displayCommand || runner.command || runner.id;
6093
+ const item = make("li");
6094
+ item.append(
6095
+ make("strong", "", runner.label || command || "runner"),
6096
+ make("code", "", command || "detected command"),
6097
+ );
6098
+ if (runner.description) item.append(make("span", "", runner.description));
6099
+ list.append(item);
6100
+ }
6101
+ current.append(list);
6102
+ } else {
6103
+ current.append(make("p", "muted", "No runners are currently detected for this tab working directory."));
6104
+ }
6105
+
6106
+ const how = make("section", "app-runner-info-section");
6107
+ how.append(make("h3", "", "How it works"));
6108
+ const howList = make("ul", "app-runner-info-list");
6109
+ for (const line of [
6110
+ "Detection is scoped to the active terminal tab's current working directory.",
6111
+ "Only commands/files that exist and runner binaries available on this system are shown.",
6112
+ "Starting a runner keeps live output pinned above the chat/terminal area.",
6113
+ "Only one app runner can be active per tab; Close/Stop terminates the process/server.",
6114
+ ]) howList.append(make("li", "", line));
6115
+ how.append(howList);
6116
+
6117
+ const supported = make("section", "app-runner-info-section");
6118
+ supported.append(make("h3", "", "Supported runner types"));
6119
+ const supportedList = make("ul", "app-runner-info-list app-runner-info-supported-list");
6120
+ for (const itemText of APP_RUNNER_SUPPORTED_ITEMS) supportedList.append(make("li", "", itemText));
6121
+ supported.append(supportedList);
6122
+
6123
+ body.append(current, renderAppRunnerCustomSection(), how, supported);
6124
+ }
6125
+
6126
+ function openAppRunnerInfoDialog() {
6127
+ if (!elements.appRunnerInfoDialog) return;
6128
+ renderAppRunnerInfoDialog();
6129
+ setAppRunnerMenuOpen(false);
6130
+ if (!elements.appRunnerInfoDialog.open) elements.appRunnerInfoDialog.showModal();
6131
+ }
6132
+
6133
+ function closeAppRunnerInfoDialog() {
6134
+ if (elements.appRunnerInfoDialog?.open) elements.appRunnerInfoDialog.close();
6135
+ }
6136
+
6137
+ function renderAppRunnerWidget() {
6138
+ const data = activeAppRunnerData();
6139
+ const run = data.activeRun;
6140
+ if (!run) return null;
6141
+ const running = appRunnerIsRunning(run);
6142
+ const status = appRunnerStatusLabel(run);
6143
+ const node = make("section", `widget release-npm-widget app-runner-widget${running ? " app-runner-live-widget" : " app-runner-log-widget"}`);
6144
+ node.setAttribute("aria-label", "app runner output");
6145
+
6146
+ const header = make("div", "release-npm-header");
6147
+ const titleWrap = make("div", "release-npm-title-wrap");
6148
+ titleWrap.append(make("span", "release-npm-kicker", "app runner"), make("strong", "release-npm-title", run.label || run.displayCommand || "app runner"));
6149
+
6150
+ const elapsed = appRunnerElapsedLabel(run);
6151
+ header.append(titleWrap);
6152
+
6153
+ const lines = Array.isArray(run.lines) && run.lines.length ? run.lines : [run.displayCommand ? `$ ${run.displayCommand}` : "Waiting for app runner output..."];
6154
+ const streamHeader = releaseNpmStreamHeader(running ? "Live app output" : "App output", run.lineCount || lines.length, { live: running });
6155
+ const terminal = make("div", "release-npm-terminal");
6156
+ terminal.setAttribute("role", "log");
6157
+ terminal.setAttribute("aria-live", running ? "polite" : "off");
6158
+ for (const line of lines) appendReleaseNpmTerminalLine(terminal, line);
6159
+
6160
+ const controlParts = [run.displayCommand, run.cwd, run.truncated ? "output truncated" : ""].map(cleanStatusText).filter(Boolean);
6161
+ const controls = make("div", "release-npm-controls app-runner-output-controls");
6162
+ const actions = make("div", "app-runner-output-actions");
6163
+ const closeButton = appRunnerActionButton("Close", running ? stopAppRunner : clearAppRunner, running ? "danger app-runner-close-action" : "app-runner-close-action");
6164
+ closeButton.title = running ? "Stop this app runner/process/server" : "Close app runner output";
6165
+ const copyButton = appRunnerActionButton("Copy output", () => copyAppRunnerOutput(run), "app-runner-copy-action");
6166
+ copyButton.title = "Copy app runner output";
6167
+ actions.append(closeButton, copyButton);
6168
+ if (running) {
6169
+ actions.append(appRunnerActionButton("Stop", stopAppRunner, "danger"));
6170
+ } else {
6171
+ const canRunAgain = (data.runners || []).some((runner) => runner.id === run.runnerId);
6172
+ if (canRunAgain) actions.append(appRunnerActionButton("Run again", () => runAppRunner(run.runnerId)));
6173
+ actions.append(appRunnerActionButton("Clear", clearAppRunner));
6174
+ }
6175
+ const pills = make("div", "app-runner-output-pills");
6176
+ if (run.kind) pills.append(make("span", "release-npm-pill", run.kind));
6177
+ pills.append(make("span", `release-npm-pill app-runner-status ${run.status || "running"}`.trim(), status));
6178
+ if (elapsed) pills.append(make("span", "release-npm-pill elapsed", elapsed));
6179
+ controls.append(actions, pills, make("span", "app-runner-output-meta", controlParts.join(" · ")));
6180
+ const outputDetails = renderReleaseNpmOutputDetails(`app-runner:${run.id || run.runnerId || "active"}`, streamHeader, terminal, controls);
5253
6181
  node.append(header, outputDetails);
5254
6182
  requestAnimationFrame(() => { if (outputDetails.open) terminal.scrollTop = terminal.scrollHeight; });
5255
6183
  return node;
@@ -5265,6 +6193,8 @@ function renderWidgets() {
5265
6193
  if (releaseAurOutput) elements.widgetArea.append(releaseAurOutput);
5266
6194
  const releaseAurLog = renderReleaseAurLogWidget();
5267
6195
  if (releaseAurLog) elements.widgetArea.append(releaseAurLog);
6196
+ const appRunnerWidget = renderAppRunnerWidget();
6197
+ if (appRunnerWidget) elements.widgetArea.append(appRunnerWidget);
5268
6198
 
5269
6199
  for (const [key, value] of widgets) {
5270
6200
  const widgetFeatureId = optionalFeatureWidgetFeatureId(key);
@@ -5288,6 +6218,8 @@ function setGitWorkflow(patch, { tabId = activeTabId } = {}) {
5288
6218
  const workflow = gitWorkflowForTab(tabId);
5289
6219
  if (!workflow) return null;
5290
6220
  Object.assign(workflow, patch);
6221
+ workflow.actionsDone = createGitWorkflowActionsDone(workflow.actionsDone);
6222
+ if (patch.step && !("process" in patch)) workflow.process = gitWorkflowProcessForStep(workflow.step, workflow.process);
5291
6223
  if (tabId === activeTabId) {
5292
6224
  gitWorkflow = workflow;
5293
6225
  renderGitWorkflow();
@@ -5323,24 +6255,141 @@ function formatCommitMessagePreview(message) {
5323
6255
  return [`=== SHORT ===`, message.short || "(empty)", "", "=== LONG ===", message.long || "(empty)"].join("\n");
5324
6256
  }
5325
6257
 
5326
- function addGitWorkflowAction(label, handler, className = "", disabled = gitWorkflow.busy) {
6258
+ function gitWorkflowMessageTitle(message) {
6259
+ return String(message?.short || message?.long || "").split("\n").find((line) => line.trim())?.trim() || "Pull request";
6260
+ }
6261
+
6262
+ function slugifyGitBranchPart(value) {
6263
+ return String(value || "")
6264
+ .normalize("NFKD")
6265
+ .replace(/[\u0300-\u036f]/g, "")
6266
+ .toLowerCase()
6267
+ .replace(/[^a-z0-9]+/g, "-")
6268
+ .replace(/^-+|-+$/g, "")
6269
+ .slice(0, 48);
6270
+ }
6271
+
6272
+ function defaultGitPrBranchName(message = gitWorkflow.message) {
6273
+ const title = gitWorkflowMessageTitle(message);
6274
+ const match = title.match(/^([a-z][a-z0-9-]*)(?:\([^)]*\))?:\s*(.+)$/i);
6275
+ const type = slugifyGitBranchPart(match?.[1] || "feat") || "feat";
6276
+ const summary = slugifyGitBranchPart(match?.[2] || title) || "feature";
6277
+ return `${type}/${summary}`;
6278
+ }
6279
+
6280
+ function formatGitPrPreview(pr) {
6281
+ if (!pr) return "No PR description loaded yet.";
6282
+ const header = [`=== PR DESCRIPTION ===`, `Branch: ${pr.branch || gitWorkflow.prBranch || "current branch"}`];
6283
+ if (pr.path) header.push(`File: ${pr.path}`);
6284
+ return [...header, "", pr.body || "(empty)"].join("\n");
6285
+ }
6286
+
6287
+ function addGitWorkflowAction(label, handler, className = "", disabled = gitWorkflow.busy, tooltip = "") {
5327
6288
  const button = make("button", className, label);
5328
6289
  button.type = "button";
5329
6290
  button.disabled = disabled;
6291
+ if (tooltip) {
6292
+ button.title = tooltip;
6293
+ button.dataset.tooltip = tooltip;
6294
+ button.setAttribute("aria-label", `${label}. ${tooltip.replace(/\s+/g, " ")}`);
6295
+ }
5330
6296
  button.addEventListener("click", handler);
5331
6297
  elements.gitWorkflowActions.append(button);
5332
6298
  return button;
5333
6299
  }
5334
6300
 
6301
+ function setGitPrDialogStatus(message = "", level = "muted") {
6302
+ if (!elements.gitPrStatus) return;
6303
+ elements.gitPrStatus.textContent = message;
6304
+ elements.gitPrStatus.className = `git-pr-status ${level || "muted"}`;
6305
+ }
6306
+
6307
+ function resolveGitPrDialog(value) {
6308
+ const resolve = activeGitPrDialogResolve;
6309
+ activeGitPrDialogResolve = null;
6310
+ if (elements.gitPrDialog?.open) elements.gitPrDialog.close();
6311
+ if (resolve) resolve(value);
6312
+ }
6313
+
6314
+ function openGitPrReviewDialog(pr, { title = "" } = {}) {
6315
+ if (!elements.gitPrDialog || !elements.gitPrTitleInput || !elements.gitPrBodyEditor) return Promise.resolve(null);
6316
+ if (activeGitPrDialogResolve) resolveGitPrDialog(null);
6317
+ elements.gitPrTitleInput.value = title || gitWorkflowMessageTitle(gitWorkflow.message);
6318
+ elements.gitPrBodyEditor.value = pr?.body || "";
6319
+ setGitPrDialogStatus(`Review ${pr?.path || "the generated PR description"}. Edit if needed, then create the pull request.`);
6320
+ return new Promise((resolve) => {
6321
+ activeGitPrDialogResolve = resolve;
6322
+ elements.gitPrDialog.showModal();
6323
+ queueMicrotask(() => elements.gitPrBodyEditor.focus());
6324
+ });
6325
+ }
6326
+
6327
+ function gitWorkflowProcessForStep(step = gitWorkflow.step, fallback = gitWorkflow.process || "stage") {
6328
+ switch (step) {
6329
+ case "generate":
6330
+ case "generating":
6331
+ return "message";
6332
+ case "message":
6333
+ case "branchNaming":
6334
+ case "branching":
6335
+ case "committing":
6336
+ return "commit";
6337
+ case "push":
6338
+ case "pushing":
6339
+ case "prGenerating":
6340
+ case "prReview":
6341
+ case "prCreating":
6342
+ case "done":
6343
+ return "push";
6344
+ case "add":
6345
+ case "idle":
6346
+ return "stage";
6347
+ case "cancelled":
6348
+ case "error":
6349
+ return GIT_WORKFLOW_PROCESS_VALUES.has(fallback) ? fallback : "stage";
6350
+ default:
6351
+ return "stage";
6352
+ }
6353
+ }
6354
+
6355
+ function selectGitWorkflowProcess(processValue, tabId = gitWorkflowActionTabId()) {
6356
+ const workflow = gitWorkflowForTab(tabId);
6357
+ if (!workflow) return;
6358
+ const process = GIT_WORKFLOW_PROCESS_VALUES.has(processValue) ? processValue : "stage";
6359
+ workflow.runId += 1;
6360
+ const runId = workflow.runId;
6361
+ const base = { active: true, process, busy: false, error: "", messageRequestedAt: 0, branchName: "", branchNameRequestedAt: 0, prMode: false, prBranch: "", pr: null, prRequestedAt: 0 };
6362
+
6363
+ if (process === "stage") {
6364
+ setGitWorkflow({ ...base, step: "add", message: null, output: "Ready to stage all changes with git add ." }, { tabId });
6365
+ return;
6366
+ }
6367
+ if (process === "message") {
6368
+ setGitWorkflow({ ...base, step: "generate", message: null, output: "Ready to generate a commit message from the currently staged changes." }, { tabId });
6369
+ return;
6370
+ }
6371
+ if (process === "commit") {
6372
+ setGitWorkflow({ ...base, step: "message", message: null, output: "Loading current generated commit message files…" }, { tabId });
6373
+ loadGitWorkflowMessage({ requireFresh: false, runId, tabId });
6374
+ return;
6375
+ }
6376
+ setGitWorkflow({ ...base, step: "push", output: "Ready to run git push for the current branch." }, { tabId });
6377
+ }
6378
+
5335
6379
  function gitWorkflowTitle() {
5336
6380
  switch (gitWorkflow.step) {
5337
6381
  case "add": return "Stage all changes";
5338
6382
  case "generate": return "Generate staged commit message";
5339
6383
  case "generating": return "Waiting for /git-staged-msg";
5340
- case "message": return "Choose commit message";
6384
+ case "message": return gitWorkflow.prMode ? "Choose PR branch commit message" : "Choose commit message";
6385
+ case "branchNaming": return "Waiting for branch name";
6386
+ case "branching": return "Creating PR branch";
5341
6387
  case "committing": return "Committing";
5342
- case "push": return "Push commit";
6388
+ case "push": return gitWorkflow.prMode ? "Push branch and create PR" : "Push commit";
5343
6389
  case "pushing": return "Pushing";
6390
+ case "prGenerating": return "Waiting for /pr";
6391
+ case "prReview": return "Review PR description";
6392
+ case "prCreating": return "Creating pull request";
5344
6393
  case "done": return "Git workflow complete";
5345
6394
  case "cancelled": return "Git workflow cancelled";
5346
6395
  case "error": return "Git workflow needs attention";
@@ -5353,11 +6402,16 @@ function gitWorkflowHint() {
5353
6402
  case "add": return "Step 1: run git add . in the current Pi working directory.";
5354
6403
  case "generate": return "Step 2: run /git-staged-msg, then preview the generated files.";
5355
6404
  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.";
6405
+ 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.";
6406
+ case "branchNaming": return "Pi is generating dev/COMMIT/staged-branch-name.txt. Cancel will request Pi abort.";
6407
+ case "branching": return "Creating a new branch with git switch -c before committing.";
5357
6408
  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.";
6409
+ 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
6410
  case "pushing": return "Running git push. Cancel will request process termination.";
5360
- case "done": return "Push finished. Review the output below.";
6411
+ case "prGenerating": return "Pi is generating dev/PR/<current-branch>.md with /pr.";
6412
+ case "prReview": return "Review or edit the generated PR description before creating the pull request.";
6413
+ case "prCreating": return "Running gh pr create with the confirmed description.";
6414
+ case "done": return gitWorkflow.prMode ? "PR workflow finished. Review the output below." : "Push finished. Review the output below.";
5361
6415
  case "cancelled": return "No further workflow steps will run.";
5362
6416
  case "error": return gitWorkflow.error || "Fix the issue, then retry or restart.";
5363
6417
  default: return "Stage changes, generate a commit message, commit, and push.";
@@ -5375,9 +6429,14 @@ function renderGitWorkflow() {
5375
6429
  elements.gitWorkflowActions.replaceChildren();
5376
6430
 
5377
6431
  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");
6432
+ const activeProcess = gitWorkflowProcessForStep(gitWorkflow.step, gitWorkflow.process);
6433
+ for (const [index, process] of GIT_WORKFLOW_PROCESSES.entries()) {
6434
+ const item = make("button", "git-workflow-step", process.label);
6435
+ item.type = "button";
6436
+ item.dataset.gitWorkflowProcess = process.value;
6437
+ item.disabled = !!gitWorkflow.busy;
6438
+ item.setAttribute("aria-pressed", String(process.value === activeProcess));
6439
+ if (gitWorkflowActionDone(gitWorkflow, process.value)) item.classList.add("done");
5381
6440
  if (index === activeIndex && !["done", "cancelled", "error"].includes(gitWorkflow.step)) item.classList.add("active");
5382
6441
  elements.gitWorkflowSteps.append(item);
5383
6442
  }
@@ -5393,11 +6452,28 @@ function renderGitWorkflow() {
5393
6452
  } else if (gitWorkflow.step === "generating") {
5394
6453
  addGitWorkflowAction("Refresh message preview", () => loadGitWorkflowMessage({ requireFresh: true }), "", false);
5395
6454
  } else if (gitWorkflow.step === "message") {
5396
- addGitWorkflowAction("Commit short", () => commitGitWorkflow("short"), "primary", false);
5397
- addGitWorkflowAction("Commit long", () => commitGitWorkflow("long"), "primary", false);
6455
+ if (!gitWorkflow.prMode) {
6456
+ addGitWorkflowAction("Create PR", () => createGitPrBranch(), "primary", false, GIT_WORKFLOW_CREATE_PR_TOOLTIP);
6457
+ addGitWorkflowAction("Manual branch", () => createGitPrBranchManually(), "", false, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP);
6458
+ }
6459
+ addGitWorkflowAction("Commit short", () => commitGitWorkflow("short"), gitWorkflow.prMode ? "primary" : "", false);
6460
+ addGitWorkflowAction("Commit long", () => commitGitWorkflow("long"), gitWorkflow.prMode ? "primary" : "", false);
5398
6461
  addGitWorkflowAction("Regenerate", () => runGitMessagePrompt(), "", false);
6462
+ } else if (gitWorkflow.step === "branchNaming") {
6463
+ addGitWorkflowAction("Refresh branch name", () => loadGitWorkflowBranchName({ requireFresh: true }), "", false);
6464
+ addGitWorkflowAction("Manual branch", () => createGitPrBranchManually(), "", !!gitWorkflow.busy, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP);
6465
+ } else if (gitWorkflow.step === "branching") {
6466
+ addGitWorkflowAction("Creating branch…", () => {}, "primary", true);
5399
6467
  } else if (gitWorkflow.step === "push") {
5400
- addGitWorkflowAction("Run git push", () => pushGitWorkflow(), "primary", false);
6468
+ if (gitWorkflow.prMode) addGitWorkflowAction("Push and Create PR", () => pushAndCreatePrGitWorkflow(), "primary", false);
6469
+ else addGitWorkflowAction("Run git push", () => pushGitWorkflow(), "primary", false);
6470
+ } else if (gitWorkflow.step === "prGenerating") {
6471
+ addGitWorkflowAction("Refresh PR description", () => loadGitWorkflowPr({ requireFresh: true }), "", false);
6472
+ } else if (gitWorkflow.step === "prReview") {
6473
+ addGitWorkflowAction("Create PR", () => createGitPrFromReview(), "primary", false);
6474
+ addGitWorkflowAction("Regenerate /pr", () => runGitPrPrompt(), "", false);
6475
+ } else if (gitWorkflow.step === "prCreating") {
6476
+ addGitWorkflowAction("Creating PR…", () => {}, "primary", true);
5401
6477
  } else if (gitWorkflow.step === "done") {
5402
6478
  addGitWorkflowAction("Close", () => setGitWorkflow({ active: false }), "primary", false);
5403
6479
  addGitWorkflowAction("Start another", () => startGitWorkflow(), "", false);
@@ -5447,11 +6523,19 @@ function startGitWorkflow(tabId = activeTabId) {
5447
6523
  setGitWorkflow({
5448
6524
  active: true,
5449
6525
  step: "add",
6526
+ process: "stage",
5450
6527
  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.",
6528
+ 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
6529
  error: "",
5453
6530
  message: null,
5454
6531
  messageRequestedAt: 0,
6532
+ branchName: "",
6533
+ branchNameRequestedAt: 0,
6534
+ actionsDone: createGitWorkflowActionsDone(),
6535
+ prMode: false,
6536
+ prBranch: "",
6537
+ pr: null,
6538
+ prRequestedAt: 0,
5455
6539
  }, { tabId });
5456
6540
  }
5457
6541
 
@@ -5459,7 +6543,8 @@ async function cancelGitWorkflow(tabId = gitWorkflowActionTabId()) {
5459
6543
  const tabContext = activeTabContext(tabId);
5460
6544
  const workflow = gitWorkflowForTab(tabId, { create: false });
5461
6545
  if (!workflow?.active) return;
5462
- const shouldAbortPi = workflow.step === "generating";
6546
+ const shouldAbortPi = workflow.step === "generating" || workflow.step === "branchNaming" || workflow.step === "prGenerating";
6547
+ if (activeGitPrDialogResolve) resolveGitPrDialog(null);
5463
6548
  workflow.runId += 1;
5464
6549
  setGitWorkflow({ step: "cancelled", busy: false, error: "", output: `${workflow.output || ""}${workflow.output ? "\n\n" : ""}Cancelled by user.` }, { tabId });
5465
6550
  if (shouldAbortPi && isCurrentTabContext(tabContext)) setRunIndicatorActivity("Abort requested; checking whether Pi stopped…");
@@ -5479,7 +6564,7 @@ async function runGitAdd(tabId = gitWorkflowActionTabId()) {
5479
6564
  try {
5480
6565
  const result = await gitWorkflowRequest("/api/git-workflow/add", { runId, tabId });
5481
6566
  if (!result) return;
5482
- setGitWorkflow({ step: "generate", busy: false, output: `${formatGitCommandResult(result)}\n\nStaged. Next: run /git-staged-msg.` }, { tabId });
6567
+ setGitWorkflow({ step: "generate", busy: false, ...gitWorkflowActionDonePatch(workflow, "stage"), output: `${formatGitCommandResult(result)}\n\nStaged. Next: run /git-staged-msg.` }, { tabId });
5483
6568
  if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
5484
6569
  } catch (error) {
5485
6570
  if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "add", { tabId });
@@ -5543,6 +6628,7 @@ async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId
5543
6628
  busy: false,
5544
6629
  error: "",
5545
6630
  message,
6631
+ ...(requireFresh && currentWorkflow.messageRequestedAt ? gitWorkflowActionDonePatch(currentWorkflow, "message") : {}),
5546
6632
  output: formatCommitMessagePreview(message),
5547
6633
  }, { tabId });
5548
6634
  } catch (error) {
@@ -5556,6 +6642,130 @@ async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId
5556
6642
  }
5557
6643
  }
5558
6644
 
6645
+ function gitBranchNamePromptMessage() {
6646
+ if (hasAvailableCommand("git-branch-name")) return "/git-branch-name";
6647
+ return [
6648
+ "Generate one PR branch name for the current staged work.",
6649
+ "Inspect only staged changes (`git diff --cached`) and the generated commit message files if present:",
6650
+ "- dev/COMMIT/staged-commit-short.txt",
6651
+ "- dev/COMMIT/staged-commit-long.txt",
6652
+ "",
6653
+ "Write exactly one line to dev/COMMIT/staged-branch-name.txt in this format:",
6654
+ "<type>/<short-feature-name>",
6655
+ "",
6656
+ "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.",
6657
+ ].join("\n");
6658
+ }
6659
+
6660
+ async function createGitPrBranch(tabId = gitWorkflowActionTabId()) {
6661
+ await runGitBranchNamePrompt(tabId);
6662
+ }
6663
+
6664
+ async function createGitPrBranchManually(tabId = gitWorkflowActionTabId()) {
6665
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6666
+ if (!workflow) return;
6667
+ await createGitPrBranchWithSuggestion(workflow.branchName || defaultGitPrBranchName(workflow.message), tabId);
6668
+ }
6669
+
6670
+ async function runGitBranchNamePrompt(tabId = gitWorkflowActionTabId()) {
6671
+ const tabContext = activeTabContext(tabId);
6672
+ const targetTab = tabs.find((tab) => tab.id === tabId);
6673
+ const targetBusy = tabId === activeTabId ? !!currentState?.isStreaming : activityForTab(targetTab).isWorking;
6674
+ if (targetBusy) {
6675
+ failGitWorkflow(new Error("Pi is currently running. Wait for it to finish or abort before generating a branch name."), "message", { tabId });
6676
+ return;
6677
+ }
6678
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6679
+ if (!workflow) return;
6680
+ const runId = workflow.runId;
6681
+ const requestedAt = Date.now();
6682
+ setGitWorkflow({
6683
+ step: "branchNaming",
6684
+ busy: true,
6685
+ error: "",
6686
+ branchNameRequestedAt: requestedAt,
6687
+ output: "Sending branch-name request to Pi.\n\nCancel will request Pi abort.",
6688
+ }, { tabId });
6689
+ if (isCurrentTabContext(tabContext)) setRunIndicatorActivity("Sending branch-name request to Pi…");
6690
+ try {
6691
+ await api("/api/prompt", { method: "POST", body: { message: gitBranchNamePromptMessage() }, tabId });
6692
+ if (!isCurrentGitWorkflowRun(runId, tabId)) return;
6693
+ appendGitWorkflowOutput("Branch-name request accepted. Waiting for agent_end, then the branch name will be loaded.", { tabId });
6694
+ if (isCurrentTabContext(tabContext)) scheduleRefreshState(120, tabContext);
6695
+ setTimeout(() => {
6696
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6697
+ const targetStillBusy = tabId === activeTabId && currentState?.isStreaming;
6698
+ if (isCurrentGitWorkflowRun(runId, tabId) && currentWorkflow?.step === "branchNaming" && !targetStillBusy) {
6699
+ loadGitWorkflowBranchName({ requireFresh: true, retries: 1, runId, tabId });
6700
+ }
6701
+ }, 2500);
6702
+ } catch (error) {
6703
+ if (isCurrentGitWorkflowRun(runId, tabId)) {
6704
+ if (isCurrentTabContext(tabContext)) clearRunIndicatorActivity();
6705
+ failGitWorkflow(error, "message", { tabId });
6706
+ }
6707
+ }
6708
+ }
6709
+
6710
+ async function loadGitWorkflowBranchName({ requireFresh = false, retries = 0, runId, tabId = activeTabId } = {}) {
6711
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6712
+ const expectedRunId = runId ?? workflow?.runId;
6713
+ try {
6714
+ const branchName = await gitWorkflowRequest("/api/git-workflow/branch-name", { method: "GET", runId: expectedRunId, tabId });
6715
+ if (!branchName) return;
6716
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6717
+ if (!currentWorkflow) return;
6718
+ if (requireFresh && currentWorkflow.branchNameRequestedAt && (branchName.mtimeMs || 0) + 10000 < currentWorkflow.branchNameRequestedAt) {
6719
+ throw new Error("Generated branch name has not refreshed yet.");
6720
+ }
6721
+ const branch = branchName.branch || defaultGitPrBranchName(currentWorkflow.message);
6722
+ setGitWorkflow({
6723
+ step: "message",
6724
+ busy: false,
6725
+ error: "",
6726
+ branchName: branch,
6727
+ output: `${formatCommitMessagePreview(currentWorkflow.message)}\n\nGenerated branch name: ${branch}`,
6728
+ }, { tabId });
6729
+ await createGitPrBranchWithSuggestion(branch, tabId, expectedRunId);
6730
+ } catch (error) {
6731
+ if (!isCurrentGitWorkflowRun(expectedRunId, tabId)) return;
6732
+ if (retries > 0) {
6733
+ setTimeout(() => loadGitWorkflowBranchName({ requireFresh, retries: retries - 1, runId: expectedRunId, tabId }), 1400);
6734
+ return;
6735
+ }
6736
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6737
+ failGitWorkflow(error, currentWorkflow?.step === "branchNaming" ? "message" : currentWorkflow?.step, { tabId });
6738
+ }
6739
+ }
6740
+
6741
+ async function createGitPrBranchWithSuggestion(suggestion, tabId = gitWorkflowActionTabId(), expectedRunId) {
6742
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6743
+ if (!workflow) return;
6744
+ const proposedBranch = prompt("New PR branch name (example: type/feature-name)", suggestion || defaultGitPrBranchName(workflow.message));
6745
+ if (expectedRunId !== undefined && !isCurrentGitWorkflowRun(expectedRunId, tabId)) return;
6746
+ if (proposedBranch === null) {
6747
+ 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 });
6748
+ return;
6749
+ }
6750
+ const branch = proposedBranch.trim();
6751
+ if (!branch) {
6752
+ failGitWorkflow(new Error("Branch name is required to create a PR branch."), "message", { tabId });
6753
+ return;
6754
+ }
6755
+ const runId = workflow.runId;
6756
+ setGitWorkflow({ step: "branching", prMode: true, prBranch: branch, branchName: branch, busy: true, error: "", output: `${formatCommitMessagePreview(workflow.message)}\n\nRunning git switch -c ${branch}…` }, { tabId });
6757
+ try {
6758
+ const result = await gitWorkflowRequest("/api/git-workflow/branch", { body: { branch }, runId, tabId });
6759
+ if (!result) return;
6760
+ 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 });
6761
+ } catch (error) {
6762
+ if (isCurrentGitWorkflowRun(runId, tabId)) {
6763
+ setGitWorkflow({ prMode: false, prBranch: "" }, { tabId });
6764
+ failGitWorkflow(error, "message", { tabId });
6765
+ }
6766
+ }
6767
+ }
6768
+
5559
6769
  async function commitGitWorkflow(variant, tabId = gitWorkflowActionTabId()) {
5560
6770
  const tabContext = activeTabContext(tabId);
5561
6771
  const workflow = gitWorkflowForTab(tabId, { create: false });
@@ -5565,7 +6775,8 @@ async function commitGitWorkflow(variant, tabId = gitWorkflowActionTabId()) {
5565
6775
  try {
5566
6776
  const result = await gitWorkflowRequest("/api/git-workflow/commit", { body: { variant }, runId, tabId });
5567
6777
  if (!result) return;
5568
- setGitWorkflow({ step: "push", busy: false, output: `${formatGitCommandResult(result)}\n\nCommit created. Next: git push.` }, { tabId });
6778
+ const nextAction = workflow.prMode ? "Push and Create PR." : "git push.";
6779
+ setGitWorkflow({ step: "push", busy: false, ...gitWorkflowActionDonePatch(workflow, "commit"), output: `${formatGitCommandResult(result)}\n\nCommit created. Next: ${nextAction}` }, { tabId });
5569
6780
  if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
5570
6781
  } catch (error) {
5571
6782
  if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "message", { tabId });
@@ -5581,13 +6792,129 @@ async function pushGitWorkflow(tabId = gitWorkflowActionTabId()) {
5581
6792
  try {
5582
6793
  const result = await gitWorkflowRequest("/api/git-workflow/push", { runId, tabId });
5583
6794
  if (!result) return;
5584
- setGitWorkflow({ step: "done", busy: false, output: formatGitCommandResult(result) || "git push finished." }, { tabId });
6795
+ setGitWorkflow({ step: "done", busy: false, ...gitWorkflowActionDonePatch(workflow, "push"), output: formatGitCommandResult(result) || "git push finished." }, { tabId });
6796
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
6797
+ } catch (error) {
6798
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "push", { tabId });
6799
+ }
6800
+ }
6801
+
6802
+ async function runGitPrPrompt(tabId = gitWorkflowActionTabId(), { prefixOutput = "" } = {}) {
6803
+ const tabContext = activeTabContext(tabId);
6804
+ const targetTab = tabs.find((tab) => tab.id === tabId);
6805
+ const targetBusy = tabId === activeTabId ? !!currentState?.isStreaming : activityForTab(targetTab).isWorking;
6806
+ if (targetBusy) {
6807
+ failGitWorkflow(new Error("Pi is currently running. Wait for it to finish or abort before generating a PR description."), "push", { tabId });
6808
+ return;
6809
+ }
6810
+ if (!hasAvailableCommand("pr")) {
6811
+ failGitWorkflow(new Error(commandUnavailableMessage("pr")), "push", { tabId });
6812
+ return;
6813
+ }
6814
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6815
+ if (!workflow) return;
6816
+ const runId = workflow.runId;
6817
+ const requestedAt = Date.now();
6818
+ setGitWorkflow({
6819
+ step: "prGenerating",
6820
+ busy: true,
6821
+ error: "",
6822
+ prRequestedAt: requestedAt,
6823
+ output: `${prefixOutput ? `${prefixOutput}\n\n` : ""}Sending /pr to Pi.\n\nCancel will request Pi abort.`,
6824
+ }, { tabId });
6825
+ if (isCurrentTabContext(tabContext)) setRunIndicatorActivity("Sending /pr to Pi…");
6826
+ try {
6827
+ await api("/api/prompt", { method: "POST", body: { message: "/pr" }, tabId });
6828
+ if (!isCurrentGitWorkflowRun(runId, tabId)) return;
6829
+ appendGitWorkflowOutput("/pr accepted. Waiting for agent_end, then the PR description will be loaded.", { tabId });
6830
+ if (isCurrentTabContext(tabContext)) scheduleRefreshState(120, tabContext);
6831
+ setTimeout(() => {
6832
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6833
+ const targetStillBusy = tabId === activeTabId && currentState?.isStreaming;
6834
+ if (isCurrentGitWorkflowRun(runId, tabId) && currentWorkflow?.step === "prGenerating" && !targetStillBusy) {
6835
+ loadGitWorkflowPr({ requireFresh: true, retries: 1, runId, tabId });
6836
+ }
6837
+ }, 2500);
6838
+ } catch (error) {
6839
+ if (isCurrentGitWorkflowRun(runId, tabId)) {
6840
+ if (isCurrentTabContext(tabContext)) clearRunIndicatorActivity();
6841
+ failGitWorkflow(error, "push", { tabId });
6842
+ }
6843
+ }
6844
+ }
6845
+
6846
+ async function loadGitWorkflowPr({ requireFresh = false, retries = 0, runId, tabId = activeTabId } = {}) {
6847
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6848
+ const expectedRunId = runId ?? workflow?.runId;
6849
+ try {
6850
+ const pr = await gitWorkflowRequest("/api/git-workflow/pr-description", { method: "GET", runId: expectedRunId, tabId });
6851
+ if (!pr) return;
6852
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6853
+ if (!currentWorkflow) return;
6854
+ if (requireFresh && currentWorkflow.prRequestedAt && (pr.mtimeMs || 0) + 10000 < currentWorkflow.prRequestedAt) {
6855
+ throw new Error("Generated PR description has not refreshed yet.");
6856
+ }
6857
+ setGitWorkflow({
6858
+ step: "prReview",
6859
+ busy: false,
6860
+ error: "",
6861
+ pr,
6862
+ prBranch: pr.branch || currentWorkflow.prBranch,
6863
+ output: formatGitPrPreview(pr),
6864
+ }, { tabId });
6865
+ } catch (error) {
6866
+ if (!isCurrentGitWorkflowRun(expectedRunId, tabId)) return;
6867
+ if (retries > 0) {
6868
+ setTimeout(() => loadGitWorkflowPr({ requireFresh, retries: retries - 1, runId: expectedRunId, tabId }), 1400);
6869
+ return;
6870
+ }
6871
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6872
+ failGitWorkflow(error, currentWorkflow?.step === "prGenerating" ? "push" : currentWorkflow?.step, { tabId });
6873
+ }
6874
+ }
6875
+
6876
+ async function pushAndCreatePrGitWorkflow(tabId = gitWorkflowActionTabId()) {
6877
+ const tabContext = activeTabContext(tabId);
6878
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6879
+ if (!workflow) return;
6880
+ const runId = workflow.runId;
6881
+ const branch = workflow.prBranch || "current branch";
6882
+ setGitWorkflow({ step: "pushing", busy: true, error: "", output: `Pushing PR branch ${branch}…` }, { tabId });
6883
+ try {
6884
+ const result = await gitWorkflowRequest("/api/git-workflow/push", { body: { setUpstream: true, branch: workflow.prBranch }, runId, tabId });
6885
+ if (!result) return;
6886
+ setGitWorkflow({ ...gitWorkflowActionDonePatch(workflow, "push") }, { tabId });
5585
6887
  if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
6888
+ await runGitPrPrompt(tabId, { prefixOutput: `${formatGitCommandResult(result)}\n\nPushed PR branch ${result.branch || branch}.` });
5586
6889
  } catch (error) {
5587
6890
  if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "push", { tabId });
5588
6891
  }
5589
6892
  }
5590
6893
 
6894
+ async function createGitPrFromReview(tabId = gitWorkflowActionTabId()) {
6895
+ const tabContext = activeTabContext(tabId);
6896
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6897
+ if (!workflow?.pr) return;
6898
+ const runId = workflow.runId;
6899
+ const review = await openGitPrReviewDialog(workflow.pr, { title: gitWorkflowMessageTitle(workflow.message) });
6900
+ if (!isCurrentGitWorkflowRun(runId, tabId)) return;
6901
+ if (!review) {
6902
+ setGitWorkflow({ step: "prReview", busy: false, output: `${formatGitPrPreview(workflow.pr)}\n\nPR creation cancelled. Edit the description, regenerate /pr, or press Create PR again.` }, { tabId });
6903
+ return;
6904
+ }
6905
+ const title = review.title.trim();
6906
+ const body = review.body.trimEnd();
6907
+ setGitWorkflow({ step: "prCreating", busy: true, error: "", output: `${formatGitPrPreview({ ...workflow.pr, body })}\n\nCreating pull request with gh pr create…` }, { tabId });
6908
+ try {
6909
+ const result = await gitWorkflowRequest("/api/git-workflow/create-pr", { body: { title, body }, runId, tabId });
6910
+ if (!result) return;
6911
+ setGitWorkflow({ step: "done", busy: false, ...gitWorkflowActionDonePatch(workflow, "push"), output: `${formatGitCommandResult(result)}\n\nPull request created.` }, { tabId });
6912
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
6913
+ } catch (error) {
6914
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "prReview", { tabId });
6915
+ }
6916
+ }
6917
+
5591
6918
  function resumeGitWorkflowForActiveTab(tabContext = activeTabContext()) {
5592
6919
  if (!isCurrentTabContext(tabContext)) return;
5593
6920
  bindGitWorkflowToActiveTab();
@@ -5601,6 +6928,22 @@ function resumeGitWorkflowForActiveTab(tabContext = activeTabContext()) {
5601
6928
  }
5602
6929
  loadGitWorkflowMessage({ requireFresh: true, retries: 3, runId: gitWorkflow.runId, tabId: workflowTabId });
5603
6930
  }
6931
+ if (workflowTabId === tabContext.tabId && gitWorkflow.active && gitWorkflow.step === "branchNaming" && !currentState?.isStreaming) {
6932
+ const retryDelayMs = Math.max(0, 2500 - (Date.now() - (gitWorkflow.branchNameRequestedAt || 0)));
6933
+ if (retryDelayMs > 0) {
6934
+ setTimeout(() => resumeGitWorkflowForActiveTab(tabContext), retryDelayMs);
6935
+ return;
6936
+ }
6937
+ loadGitWorkflowBranchName({ requireFresh: true, retries: 3, runId: gitWorkflow.runId, tabId: workflowTabId });
6938
+ }
6939
+ if (workflowTabId === tabContext.tabId && gitWorkflow.active && gitWorkflow.step === "prGenerating" && !currentState?.isStreaming) {
6940
+ const retryDelayMs = Math.max(0, 2500 - (Date.now() - (gitWorkflow.prRequestedAt || 0)));
6941
+ if (retryDelayMs > 0) {
6942
+ setTimeout(() => resumeGitWorkflowForActiveTab(tabContext), retryDelayMs);
6943
+ return;
6944
+ }
6945
+ loadGitWorkflowPr({ requireFresh: true, retries: 3, runId: gitWorkflow.runId, tabId: workflowTabId });
6946
+ }
5604
6947
  }
5605
6948
 
5606
6949
  function normalizeQueuedMessages(event) {
@@ -8188,6 +9531,13 @@ function setNativeCommandMenuOpen(open) {
8188
9531
  elements.nativeCommandMenuButton.parentElement?.classList.toggle("open", nativeCommandMenuOpen);
8189
9532
  }
8190
9533
 
9534
+ function setAppRunnerMenuOpen(open) {
9535
+ appRunnerMenuOpen = !!open;
9536
+ elements.appRunnerMenuButton?.setAttribute("aria-expanded", appRunnerMenuOpen ? "true" : "false");
9537
+ elements.appRunnerMenuButton?.classList.toggle("menu-open", appRunnerMenuOpen);
9538
+ elements.appRunnerMenuButton?.parentElement?.classList.toggle("open", appRunnerMenuOpen);
9539
+ }
9540
+
8191
9541
  function setOptionsMenuOpen(open) {
8192
9542
  optionsMenuOpen = !!open;
8193
9543
  elements.optionsMenuButton.setAttribute("aria-expanded", optionsMenuOpen ? "true" : "false");
@@ -8435,6 +9785,7 @@ async function installOptionalFeature(featureId) {
8435
9785
  function runPublishWorkflow(command) {
8436
9786
  setComposerActionsOpen(false);
8437
9787
  setPublishMenuOpen(false);
9788
+ setAppRunnerMenuOpen(false);
8438
9789
  setOptionsMenuOpen(false);
8439
9790
  const commandName = String(command || "").replace(/^\//, "").split(/\s+/)[0];
8440
9791
  const featureId = OPTIONAL_COMMAND_FEATURES.get(commandName);
@@ -8453,6 +9804,7 @@ async function runNativeCommandMenu(command) {
8453
9804
  setComposerActionsOpen(false);
8454
9805
  setPublishMenuOpen(false);
8455
9806
  setNativeCommandMenuOpen(false);
9807
+ setAppRunnerMenuOpen(false);
8456
9808
  setOptionsMenuOpen(false);
8457
9809
  const commandName = String(command || "").replace(/^\//, "").split(/\s+/)[0].toLowerCase();
8458
9810
  const featureId = optionalFeatureIdForCommand(commandName);
@@ -10130,6 +11482,7 @@ async function refreshAll(tabContext = activeTabContext()) {
10130
11482
  refreshCommands(tabContext),
10131
11483
  refreshStats(tabContext),
10132
11484
  refreshWorkspace(tabContext),
11485
+ refreshAppRunners(tabContext),
10133
11486
  refreshNativeSettings(tabContext),
10134
11487
  refreshNetworkStatus(),
10135
11488
  refreshWebuiVersion(),
@@ -10844,6 +12197,11 @@ function handleEvent(event) {
10844
12197
  case "webui_connected":
10845
12198
  setWebuiVersion(event.version);
10846
12199
  setWebuiDevServer(isWebuiDevMetadata(event));
12200
+ if (Object.prototype.hasOwnProperty.call(event, "activeRun")) {
12201
+ setAppRunnerData(event.tabId || activeTabId, { cwd: event.cwd, activeRun: event.activeRun });
12202
+ renderAppRunnerControls();
12203
+ renderWidgets();
12204
+ }
10847
12205
  addEvent(`connected to ${event.tabTitle || "terminal"} for ${event.cwd}`);
10848
12206
  scheduleForegroundReconcile("event stream reconnect", 0);
10849
12207
  break;
@@ -10865,6 +12223,7 @@ function handleEvent(event) {
10865
12223
  case "webui_tab_reloaded":
10866
12224
  addEvent(`${event.tabTitle || "terminal"} reloaded`);
10867
12225
  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" });
12226
+ clearContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
10868
12227
  statusEntries.clear();
10869
12228
  widgets.clear();
10870
12229
  resetOptionalFeatureAvailability();
@@ -10882,9 +12241,18 @@ function handleEvent(event) {
10882
12241
  removeQueuedDialogRequests(event.ids || []);
10883
12242
  addEvent(`cancelled ${event.ids?.length || 0} pending extension UI request(s)`, "warn");
10884
12243
  break;
12244
+ case "webui_app_runner_update":
12245
+ setAppRunnerData(event.tabId || activeTabId, { cwd: event.cwd, activeRun: event.activeRun });
12246
+ renderAppRunnerControls();
12247
+ renderWidgets();
12248
+ break;
10885
12249
  case "webui_cwd_changed":
10886
12250
  addEvent(`${event.tabTitle || "terminal"} cwd changed to ${event.cwd}`);
12251
+ setAppRunnerData(event.tabId || activeTabId, { cwd: event.cwd, activeRun: null, runners: [] });
12252
+ renderAppRunnerControls();
12253
+ renderWidgets();
10887
12254
  refreshTabs().catch((error) => addEvent(error.message, "error"));
12255
+ refreshAppRunners(tabContext).catch((error) => addEvent(error.message, "error"));
10888
12256
  scheduleRefreshFooter();
10889
12257
  break;
10890
12258
  case "webui_network_rebinding": {
@@ -10928,6 +12296,7 @@ function handleEvent(event) {
10928
12296
  case "agent_end":
10929
12297
  addEvent("agent finished");
10930
12298
  notifyAgentDone(event.tabId || activeTabId, { activity: event.tabActivity, tabTitle: event.tabTitle });
12299
+ clearContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
10931
12300
  if (currentState) currentState = { ...currentState, isStreaming: false };
10932
12301
  clearRunIndicatorActivity();
10933
12302
  markTabOutputSeen();
@@ -10941,6 +12310,10 @@ function handleEvent(event) {
10941
12310
  const workflow = gitWorkflowForTab(workflowTabId, { create: false });
10942
12311
  if (workflow?.active && workflow.step === "generating") {
10943
12312
  loadGitWorkflowMessage({ requireFresh: true, retries: 3, runId: workflow.runId, tabId: workflowTabId });
12313
+ } else if (workflow?.active && workflow.step === "branchNaming") {
12314
+ loadGitWorkflowBranchName({ requireFresh: true, retries: 3, runId: workflow.runId, tabId: workflowTabId });
12315
+ } else if (workflow?.active && workflow.step === "prGenerating") {
12316
+ loadGitWorkflowPr({ requireFresh: true, retries: 3, runId: workflow.runId, tabId: workflowTabId });
10944
12317
  }
10945
12318
  }
10946
12319
  break;
@@ -10979,15 +12352,22 @@ function handleEvent(event) {
10979
12352
  break;
10980
12353
  case "compaction_start":
10981
12354
  if (currentState) currentState = { ...currentState, isCompacting: true };
12355
+ markContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
10982
12356
  setRunIndicatorActivity(`Compacting context${event.reason ? ` (${event.reason})` : ""}…`);
10983
12357
  addEvent(`compaction started (${event.reason})`);
12358
+ renderStatus();
10984
12359
  break;
10985
12360
  case "compaction_end":
10986
12361
  if (currentState) currentState = { ...currentState, isCompacting: false };
12362
+ if (event.aborted) clearContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
12363
+ else markContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
10987
12364
  addEvent(`compaction ${event.aborted ? "aborted" : "finished"}`);
10988
12365
  if (!currentState?.isStreaming) clearRunIndicatorActivity();
10989
12366
  markTabOutputSeen();
12367
+ renderStatus();
12368
+ scheduleRefreshState();
10990
12369
  scheduleRefreshMessages();
12370
+ scheduleRefreshFooter();
10991
12371
  break;
10992
12372
  case "auto_retry_start": {
10993
12373
  const seconds = Math.max(0, Math.ceil(Number(event.delayMs || 0) / 1000));
@@ -11031,6 +12411,7 @@ function handleEvent(event) {
11031
12411
  applyOptimisticThinkingSelection(event.data, tabContext);
11032
12412
  } else if (event.command === "new_session") {
11033
12413
  const tabId = event.tabId || activeTabId;
12414
+ clearContextUsageUnknownAfterCompaction(tabId);
11034
12415
  forgetLastUserPrompt(tabId);
11035
12416
  resetGitWorkflowForTab(tabId);
11036
12417
  }
@@ -11087,6 +12468,23 @@ elements.promptListSaveButton?.addEventListener("click", saveDisplayedPromptList
11087
12468
  elements.promptListRunListButton?.addEventListener("click", () => runDisplayedPromptList());
11088
12469
  elements.promptListCloseButton?.addEventListener("click", () => elements.promptListDialog?.close());
11089
12470
  elements.promptListDialog?.querySelector("form")?.addEventListener("submit", (event) => event.preventDefault());
12471
+ elements.attachmentTextCancelButton?.addEventListener("click", closeTextAttachmentEditor);
12472
+ elements.attachmentTextSaveButton?.addEventListener("click", saveTextAttachmentEdit);
12473
+ elements.attachmentTextEditor?.addEventListener("input", () => {
12474
+ renderTextAttachmentEditorMeta();
12475
+ setAttachmentTextStatus("Unsaved attachment edits.", "warn");
12476
+ });
12477
+ elements.attachmentTextDialog?.addEventListener("close", () => {
12478
+ activeTextAttachmentEditor = null;
12479
+ if (elements.attachmentTextEditor) elements.attachmentTextEditor.value = "";
12480
+ setAttachmentTextStatus("");
12481
+ });
12482
+ elements.attachmentTextDialog?.addEventListener("keydown", (event) => {
12483
+ if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "s") return;
12484
+ event.preventDefault();
12485
+ if (!elements.attachmentTextSaveButton?.disabled) saveTextAttachmentEdit();
12486
+ });
12487
+ elements.attachmentTextDialog?.querySelector("form")?.addEventListener("submit", (event) => event.preventDefault());
11090
12488
  elements.sendFeedbackButton.addEventListener("click", () => submitQueuedActionFeedback());
11091
12489
  elements.composer.addEventListener("submit", (event) => {
11092
12490
  event.preventDefault();
@@ -11149,17 +12547,20 @@ elements.gitWorkflowButton.addEventListener("click", () => {
11149
12547
  const publishMenuContainer = elements.publishButton.parentElement;
11150
12548
  elements.publishButton.addEventListener("click", () => {
11151
12549
  setNativeCommandMenuOpen(false);
12550
+ setAppRunnerMenuOpen(false);
11152
12551
  setOptionsMenuOpen(false);
11153
12552
  setPublishMenuOpen(true);
11154
12553
  });
11155
12554
  publishMenuContainer?.addEventListener("pointerenter", () => {
11156
12555
  setNativeCommandMenuOpen(false);
12556
+ setAppRunnerMenuOpen(false);
11157
12557
  setOptionsMenuOpen(false);
11158
12558
  setPublishMenuOpen(true);
11159
12559
  });
11160
12560
  publishMenuContainer?.addEventListener("pointerleave", () => setPublishMenuOpen(false));
11161
12561
  publishMenuContainer?.addEventListener("focusin", () => {
11162
12562
  setNativeCommandMenuOpen(false);
12563
+ setAppRunnerMenuOpen(false);
11163
12564
  setOptionsMenuOpen(false);
11164
12565
  setPublishMenuOpen(true);
11165
12566
  });
@@ -11171,17 +12572,20 @@ publishMenuContainer?.addEventListener("focusout", () => {
11171
12572
  const nativeCommandMenuContainer = elements.nativeCommandMenuButton.parentElement;
11172
12573
  elements.nativeCommandMenuButton.addEventListener("click", () => {
11173
12574
  setPublishMenuOpen(false);
12575
+ setAppRunnerMenuOpen(false);
11174
12576
  setOptionsMenuOpen(false);
11175
12577
  setNativeCommandMenuOpen(true);
11176
12578
  });
11177
12579
  nativeCommandMenuContainer?.addEventListener("pointerenter", () => {
11178
12580
  setPublishMenuOpen(false);
12581
+ setAppRunnerMenuOpen(false);
11179
12582
  setOptionsMenuOpen(false);
11180
12583
  setNativeCommandMenuOpen(true);
11181
12584
  });
11182
12585
  nativeCommandMenuContainer?.addEventListener("pointerleave", () => setNativeCommandMenuOpen(false));
11183
12586
  nativeCommandMenuContainer?.addEventListener("focusin", () => {
11184
12587
  setPublishMenuOpen(false);
12588
+ setAppRunnerMenuOpen(false);
11185
12589
  setOptionsMenuOpen(false);
11186
12590
  setNativeCommandMenuOpen(true);
11187
12591
  });
@@ -11190,21 +12594,66 @@ nativeCommandMenuContainer?.addEventListener("focusout", () => {
11190
12594
  if (!nativeCommandMenuContainer?.contains(document.activeElement)) setNativeCommandMenuOpen(false);
11191
12595
  }, 0);
11192
12596
  });
12597
+ const appRunnerMenuContainer = elements.appRunnerMenuButton?.parentElement;
12598
+ elements.appRunnerInfoButton?.addEventListener("click", (event) => {
12599
+ event.preventDefault();
12600
+ event.stopPropagation();
12601
+ openAppRunnerInfoDialog();
12602
+ });
12603
+ elements.appRunnerInfoCloseButton?.addEventListener("click", closeAppRunnerInfoDialog);
12604
+ elements.appRunnerMenuButton?.addEventListener("click", async () => {
12605
+ setPublishMenuOpen(false);
12606
+ setNativeCommandMenuOpen(false);
12607
+ setOptionsMenuOpen(false);
12608
+ setAppRunnerMenuOpen(false);
12609
+ const tabContext = activeTabContext();
12610
+ try {
12611
+ await refreshAppRunners(tabContext);
12612
+ if (!isCurrentTabContext(tabContext)) return;
12613
+ if (appRunnerMenuCanOpen()) setAppRunnerMenuOpen(true);
12614
+ else if (!appRunnerIsRunning(activeAppRunnerData().activeRun)) openAppRunnerInfoDialog();
12615
+ } catch (error) {
12616
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
12617
+ }
12618
+ });
12619
+ appRunnerMenuContainer?.addEventListener("pointerenter", () => {
12620
+ if (elements.appRunnerMenuButton?.disabled || !appRunnerMenuCanOpen()) return;
12621
+ setPublishMenuOpen(false);
12622
+ setNativeCommandMenuOpen(false);
12623
+ setOptionsMenuOpen(false);
12624
+ setAppRunnerMenuOpen(true);
12625
+ });
12626
+ appRunnerMenuContainer?.addEventListener("pointerleave", () => setAppRunnerMenuOpen(false));
12627
+ appRunnerMenuContainer?.addEventListener("focusin", () => {
12628
+ if (elements.appRunnerMenuButton?.disabled || !appRunnerMenuCanOpen()) return;
12629
+ setPublishMenuOpen(false);
12630
+ setNativeCommandMenuOpen(false);
12631
+ setOptionsMenuOpen(false);
12632
+ setAppRunnerMenuOpen(true);
12633
+ });
12634
+ appRunnerMenuContainer?.addEventListener("focusout", () => {
12635
+ setTimeout(() => {
12636
+ if (!appRunnerMenuContainer?.contains(document.activeElement)) setAppRunnerMenuOpen(false);
12637
+ }, 0);
12638
+ });
11193
12639
  const optionsMenuContainer = elements.optionsMenuButton.parentElement;
11194
12640
  elements.optionsMenuButton.addEventListener("click", () => {
11195
12641
  setPublishMenuOpen(false);
11196
12642
  setNativeCommandMenuOpen(false);
12643
+ setAppRunnerMenuOpen(false);
11197
12644
  setOptionsMenuOpen(true);
11198
12645
  });
11199
12646
  optionsMenuContainer?.addEventListener("pointerenter", () => {
11200
12647
  setPublishMenuOpen(false);
11201
12648
  setNativeCommandMenuOpen(false);
12649
+ setAppRunnerMenuOpen(false);
11202
12650
  setOptionsMenuOpen(true);
11203
12651
  });
11204
12652
  optionsMenuContainer?.addEventListener("pointerleave", () => setOptionsMenuOpen(false));
11205
12653
  optionsMenuContainer?.addEventListener("focusin", () => {
11206
12654
  setPublishMenuOpen(false);
11207
12655
  setNativeCommandMenuOpen(false);
12656
+ setAppRunnerMenuOpen(false);
11208
12657
  setOptionsMenuOpen(true);
11209
12658
  });
11210
12659
  optionsMenuContainer?.addEventListener("focusout", () => {
@@ -11224,7 +12673,32 @@ elements.optionsSettingsButton.addEventListener("click", () => runNativeCommandM
11224
12673
  elements.optionsExportButton.addEventListener("click", () => runNativeCommandMenu("/export"));
11225
12674
  elements.optionsForkButton.addEventListener("click", () => runNativeCommandMenu("/fork"));
11226
12675
  elements.optionsTreeButton.addEventListener("click", () => runNativeCommandMenu("/tree"));
12676
+ elements.gitWorkflowSteps.addEventListener("click", (event) => {
12677
+ const target = event.target instanceof Element ? event.target : null;
12678
+ const button = target?.closest("[data-git-workflow-process]");
12679
+ if (!button || !elements.gitWorkflowSteps.contains(button) || button.disabled) return;
12680
+ selectGitWorkflowProcess(button.dataset.gitWorkflowProcess);
12681
+ });
11227
12682
  elements.gitWorkflowCancelButton.addEventListener("click", () => cancelGitWorkflow());
12683
+ elements.gitPrCancelButton?.addEventListener("click", () => resolveGitPrDialog(null));
12684
+ elements.gitPrCreateButton?.addEventListener("click", () => {
12685
+ const title = elements.gitPrTitleInput?.value.trim() || "";
12686
+ const body = elements.gitPrBodyEditor?.value.trimEnd() || "";
12687
+ if (!title) {
12688
+ setGitPrDialogStatus("PR title is required.", "error");
12689
+ elements.gitPrTitleInput?.focus();
12690
+ return;
12691
+ }
12692
+ if (!body.trim()) {
12693
+ setGitPrDialogStatus("PR description is required.", "error");
12694
+ elements.gitPrBodyEditor?.focus();
12695
+ return;
12696
+ }
12697
+ resolveGitPrDialog({ title, body });
12698
+ });
12699
+ elements.gitPrDialog?.addEventListener("close", () => {
12700
+ if (activeGitPrDialogResolve) resolveGitPrDialog(null);
12701
+ });
11228
12702
  elements.nativeCommandDialog.addEventListener("close", () => {
11229
12703
  elements.nativeCommandSearch.oninput = null;
11230
12704
  nativeCommandTabId = null;
@@ -11320,6 +12794,8 @@ elements.compactButton.addEventListener("click", async () => {
11320
12794
  elements.compactButton.textContent = "Compacting…";
11321
12795
  setRunIndicatorActivity("Requesting context compaction…");
11322
12796
  scrollChatToBottom({ force: true });
12797
+ markContextUsageUnknownAfterCompaction(tabContext.tabId);
12798
+ renderFooter();
11323
12799
  addEvent("manual compaction requested");
11324
12800
  await api("/api/compact", { method: "POST", body: {}, tabId: tabContext.tabId });
11325
12801
  if (!isCurrentTabContext(tabContext)) return;
@@ -11328,7 +12804,9 @@ elements.compactButton.addEventListener("click", async () => {
11328
12804
  scheduleRefreshFooter(600, tabContext);
11329
12805
  } catch (error) {
11330
12806
  if (isCurrentTabContext(tabContext)) {
12807
+ clearContextUsageUnknownAfterCompaction(tabContext.tabId);
11331
12808
  clearRunIndicatorActivity();
12809
+ renderFooter();
11332
12810
  addEvent(error.message, "error");
11333
12811
  }
11334
12812
  } finally {
@@ -11403,6 +12881,11 @@ if (elements.thinkingVisibilityToggle) {
11403
12881
  setThinkingOutputVisible(elements.thinkingVisibilityToggle.checked, { announce: true });
11404
12882
  });
11405
12883
  }
12884
+ if (elements.terminalTabsLayoutSelect) {
12885
+ elements.terminalTabsLayoutSelect.addEventListener("change", () => {
12886
+ setTerminalTabsLayout(elements.terminalTabsLayoutSelect.value, { announce: true });
12887
+ });
12888
+ }
11406
12889
  elements.toggleSidePanelButton.addEventListener("click", () => {
11407
12890
  setSidePanelCollapsed(true);
11408
12891
  });
@@ -11440,6 +12923,9 @@ document.addEventListener("pointerdown", (event) => {
11440
12923
  if (nativeCommandMenuOpen && !event.target?.closest?.(".composer-native-command-menu")) {
11441
12924
  setNativeCommandMenuOpen(false);
11442
12925
  }
12926
+ if (appRunnerMenuOpen && !event.target?.closest?.(".composer-app-runner-menu")) {
12927
+ setAppRunnerMenuOpen(false);
12928
+ }
11443
12929
  if (optionsMenuOpen && !event.target?.closest?.(".composer-options-menu")) {
11444
12930
  setOptionsMenuOpen(false);
11445
12931
  }
@@ -11467,7 +12953,7 @@ function isTextEntryTarget(target) {
11467
12953
 
11468
12954
  function shouldHandleNativeAppShortcut(event) {
11469
12955
  if (event.defaultPrevented) return false;
11470
- if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.nativeCommandDialog?.open) return false;
12956
+ if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.nativeCommandDialog?.open || elements.appRunnerInfoDialog?.open) return false;
11471
12957
  return event.target === elements.promptInput || !isTextEntryTarget(event.target);
11472
12958
  }
11473
12959
 
@@ -11535,6 +13021,10 @@ window.addEventListener("keydown", (event) => {
11535
13021
  setNativeCommandMenuOpen(false);
11536
13022
  return;
11537
13023
  }
13024
+ if (appRunnerMenuOpen) {
13025
+ setAppRunnerMenuOpen(false);
13026
+ return;
13027
+ }
11538
13028
  if (optionsMenuOpen) {
11539
13029
  setOptionsMenuOpen(false);
11540
13030
  return;
@@ -11571,7 +13061,7 @@ window.addEventListener("keydown", (event) => {
11571
13061
  }
11572
13062
  lastEmptyPromptEscapeTime = now;
11573
13063
  }
11574
- if (isMobileView() && !document.body.classList.contains("side-panel-collapsed")) {
13064
+ if (isSidePanelOverlayView() && !document.body.classList.contains("side-panel-collapsed")) {
11575
13065
  setSidePanelCollapsed(true);
11576
13066
  return;
11577
13067
  }
@@ -11664,6 +13154,7 @@ elements.promptInput.addEventListener("keydown", (event) => {
11664
13154
 
11665
13155
  elements.promptInput.addEventListener("input", () => {
11666
13156
  resetPromptHistoryNavigation();
13157
+ if (moveLongPromptInputToAttachment()) return;
11667
13158
  resizePromptInput();
11668
13159
  renderCommandSuggestions();
11669
13160
  });
@@ -11692,6 +13183,7 @@ resizePromptInput();
11692
13183
  focusPromptInput({ defer: true });
11693
13184
  updateComposerModeButtons();
11694
13185
  updateOptionalFeatureAvailability();
13186
+ renderAppRunnerControls();
11695
13187
  renderLoadedPromptListPreview();
11696
13188
  loadLastUserPromptCache();
11697
13189
  loadPromptHistoryCache();
@@ -11705,12 +13197,14 @@ initializeThemes().catch((error) => {
11705
13197
  initializeFastPicks().catch((error) => addEvent(`failed to initialize path fast picks: ${error.message}`, "error"));
11706
13198
  restoreAgentDoneNotificationsSetting();
11707
13199
  restoreThinkingVisibilitySetting();
13200
+ restoreTerminalTabsLayoutSetting();
11708
13201
  restoreToolOutputExpansionSetting();
11709
13202
  restoreSidePanelSectionState();
11710
13203
  bindSidePanelSectionToggles();
11711
13204
  restoreSidePanelState();
11712
13205
  initializeCodexUsage();
11713
13206
  bindMobileViewChanges();
13207
+ bindSidePanelOverlayViewChanges();
11714
13208
  registerPwaServiceWorker();
11715
13209
  renderServerOfflinePanel();
11716
13210
  initializeTabs().catch((error) => addEvent(error.message, "error"));