@firstpick/pi-package-webui 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -5,7 +5,11 @@ const elements = {
5
5
  webuiDevBadge: $("#webuiDevBadge"),
6
6
  tabBar: $("#tabBar"),
7
7
  terminalTabsToggleButton: $("#terminalTabsToggleButton"),
8
+ newTabMenu: $("#newTabMenu"),
8
9
  newTabButton: $("#newTabButton"),
10
+ newTabMenuPanel: $("#newTabMenuPanel"),
11
+ newTabCurrentDirectoryButton: $("#newTabCurrentDirectoryButton"),
12
+ newTabChooseDirectoryButton: $("#newTabChooseDirectoryButton"),
9
13
  closeAllTabsButton: $("#closeAllTabsButton"),
10
14
  statusBar: $("#statusBar"),
11
15
  serverOfflinePanel: $("#serverOfflinePanel"),
@@ -46,6 +50,10 @@ const elements = {
46
50
  nativeCommandMenu: $("#nativeCommandMenu"),
47
51
  nativeSkillsButton: $("#nativeSkillsButton"),
48
52
  nativeToolsButton: $("#nativeToolsButton"),
53
+ appRunnerMenu: $("#appRunnerMenu"),
54
+ appRunnerInfoButton: $("#appRunnerInfoButton"),
55
+ appRunnerMenuButton: $("#appRunnerMenuButton"),
56
+ appRunnerMenuPanel: $("#appRunnerMenuPanel"),
49
57
  optionsMenuButton: $("#optionsMenuButton"),
50
58
  optionsMenu: $("#optionsMenu"),
51
59
  optionsResumeButton: $("#optionsResumeButton"),
@@ -63,6 +71,12 @@ const elements = {
63
71
  gitWorkflowOutput: $("#gitWorkflowOutput"),
64
72
  gitWorkflowActions: $("#gitWorkflowActions"),
65
73
  gitWorkflowCancelButton: $("#gitWorkflowCancelButton"),
74
+ gitPrDialog: $("#gitPrDialog"),
75
+ gitPrTitleInput: $("#gitPrTitleInput"),
76
+ gitPrBodyEditor: $("#gitPrBodyEditor"),
77
+ gitPrStatus: $("#gitPrStatus"),
78
+ gitPrCancelButton: $("#gitPrCancelButton"),
79
+ gitPrCreateButton: $("#gitPrCreateButton"),
66
80
  modelSelect: $("#modelSelect"),
67
81
  setModelButton: $("#setModelButton"),
68
82
  thinkingSelect: $("#thinkingSelect"),
@@ -109,6 +123,13 @@ const elements = {
109
123
  promptListDialogLoadButton: $("#promptListDialogLoadButton"),
110
124
  promptListSaveButton: $("#promptListSaveButton"),
111
125
  promptListRunListButton: $("#promptListRunListButton"),
126
+ attachmentTextDialog: $("#attachmentTextDialog"),
127
+ attachmentTextTitle: $("#attachmentTextTitle"),
128
+ attachmentTextMeta: $("#attachmentTextMeta"),
129
+ attachmentTextEditor: $("#attachmentTextEditor"),
130
+ attachmentTextStatus: $("#attachmentTextStatus"),
131
+ attachmentTextCancelButton: $("#attachmentTextCancelButton"),
132
+ attachmentTextSaveButton: $("#attachmentTextSaveButton"),
112
133
  commandSearchInput: $("#commandSearchInput"),
113
134
  commandsBox: $("#commandsBox"),
114
135
  eventLog: $("#eventLog"),
@@ -139,6 +160,9 @@ const elements = {
139
160
  nativeCommandBody: $("#nativeCommandBody"),
140
161
  nativeCommandError: $("#nativeCommandError"),
141
162
  nativeCommandActions: $("#nativeCommandActions"),
163
+ appRunnerInfoDialog: $("#appRunnerInfoDialog"),
164
+ appRunnerInfoBody: $("#appRunnerInfoBody"),
165
+ appRunnerInfoCloseButton: $("#appRunnerInfoCloseButton"),
142
166
  };
143
167
 
144
168
  let currentState = null;
@@ -147,6 +171,7 @@ let activeTabId = null;
147
171
  let activeTabGeneration = 0;
148
172
  let tabDrafts = new Map();
149
173
  let tabAttachments = new Map();
174
+ let activeTextAttachmentEditor = null;
150
175
  let tabActivities = new Map();
151
176
  let tabSeenCompletionSerials = new Map();
152
177
  let streamBubble = null;
@@ -174,14 +199,20 @@ let refreshTabsTimer = null;
174
199
  let foregroundReconcileTimer = null;
175
200
  let eventSource = null;
176
201
  let activeDialog = null;
202
+ let activeGitPrDialogResolve = null;
177
203
  let nativeCommandTabId = null;
178
204
  let pathPickerState = null;
205
+ let firstTerminalCwdPromptShown = false;
179
206
  let pathFastPicks = [];
180
207
  let pathFastPicksReady = false;
181
208
  let pathFastPicksLoadPromise = null;
182
209
  let mobileTabsExpanded = false;
183
210
  let openTerminalTabGroupKey = null;
211
+ let newTabMenuOpen = false;
184
212
  let nativeCommandMenuOpen = false;
213
+ let appRunnerMenuOpen = false;
214
+ let appRunnerCustomDraft = { id: "", label: "", command: "./", path: "", args: "" };
215
+ let appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
185
216
  let optionsMenuOpen = false;
186
217
  let availableCommands = [];
187
218
  let rawAvailableCommands = [];
@@ -239,6 +270,7 @@ let customBackgroundLoading = false;
239
270
  let footerScopedModels = [];
240
271
  let footerScopedModelPatterns = [];
241
272
  let footerScopedModelSource = "none";
273
+ const contextUsageUnknownAfterCompactionByTab = new Map();
242
274
  let autoFollowChat = true;
243
275
  let chatFollowFrame = null;
244
276
  let chatFollowSettleTimer = null;
@@ -288,10 +320,13 @@ const ATTACHMENT_MAX_FILE_BYTES = 64 * 1024 * 1024;
288
320
  const ATTACHMENT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
289
321
  const ATTACHMENT_INLINE_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
290
322
  const ATTACHMENT_INLINE_IMAGE_TOTAL_MAX_BYTES = 16 * 1024 * 1024;
323
+ const LONG_INPUT_ATTACHMENT_LINE_THRESHOLD = 20;
324
+ const LONG_INPUT_ATTACHMENT_MIME_TYPE = "text/plain";
291
325
  const INLINE_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
292
326
  const BACKGROUND_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
293
327
  const DEFAULT_THEME_NAME = "catppuccin-mocha";
294
328
  const MOBILE_VIEW_QUERY = "(max-width: 720px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
329
+ const SIDE_PANEL_OVERLAY_QUERY = "(max-width: 1050px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
295
330
  const CHAT_BOTTOM_THRESHOLD_PX = 96;
296
331
  const STICKY_USER_PROMPT_PREVIEW_LIMIT = 220;
297
332
  const STICKY_USER_PROMPT_TOP_GAP_PX = 12;
@@ -320,10 +355,12 @@ const BLOCKED_TAB_NOTIFICATION_TAG_PREFIX = "pi-webui-blocked-tab";
320
355
  const AGENT_DONE_NOTIFICATION_TAG_PREFIX = "pi-webui-agent-done";
321
356
  const BLOCKED_TAB_NOTIFICATION_ICON = "/icon-192.png";
322
357
  const mobileViewMedia = window.matchMedia?.(MOBILE_VIEW_QUERY) || null;
358
+ const sidePanelOverlayMedia = window.matchMedia?.(SIDE_PANEL_OVERLAY_QUERY) || mobileViewMedia;
323
359
  const statusEntries = new Map();
324
360
  const widgets = new Map();
325
361
  const todoProgressWidgetExpandedByTab = new Map();
326
362
  const releaseNpmOutputExpandedByTab = new Map();
363
+ const appRunnerDataByTab = new Map();
327
364
  const liveToolRuns = new Map();
328
365
  const liveToolCards = new Map();
329
366
  const liveToolRenderQueue = new Map();
@@ -410,6 +447,8 @@ const OPTIONAL_FEATURES = [
410
447
  const OPTIONAL_FEATURE_BY_ID = new Map(OPTIONAL_FEATURES.map((feature) => [feature.id, feature]));
411
448
  const OPTIONAL_COMMAND_FEATURES = new Map([
412
449
  ["git-staged-msg", "gitWorkflow"],
450
+ ["git-branch-name", "gitWorkflow"],
451
+ ["pr", "gitWorkflow"],
413
452
  ["release-npm", "releaseNpm"],
414
453
  ["release-aur", "releaseAur"],
415
454
  ["skills", "tuiSkillsCommand"],
@@ -441,16 +480,42 @@ const SETTINGS_AUTOCOMPLETE_OPTIONS = ["3", "5", "7", "10", "15", "20"];
441
480
  const optionalFeatureInstallInProgress = new Set();
442
481
  const gitFooterPayloadRefreshInFlightByTab = new Set();
443
482
 
483
+ function createGitWorkflowActionsDone(patch = {}) {
484
+ return {
485
+ stage: false,
486
+ message: false,
487
+ commit: false,
488
+ push: false,
489
+ ...patch,
490
+ };
491
+ }
492
+
493
+ function gitWorkflowActionDone(workflow, process) {
494
+ return !!createGitWorkflowActionsDone(workflow?.actionsDone)[process];
495
+ }
496
+
497
+ function gitWorkflowActionDonePatch(workflow, process) {
498
+ return { actionsDone: createGitWorkflowActionsDone({ ...workflow?.actionsDone, [process]: true }) };
499
+ }
500
+
444
501
  function createGitWorkflowState() {
445
502
  return {
446
503
  active: false,
447
504
  step: "idle",
505
+ process: "stage",
448
506
  busy: false,
449
507
  runId: 0,
450
508
  output: "",
451
509
  error: "",
452
510
  message: null,
453
511
  messageRequestedAt: 0,
512
+ branchName: "",
513
+ branchNameRequestedAt: 0,
514
+ actionsDone: createGitWorkflowActionsDone(),
515
+ prMode: false,
516
+ prBranch: "",
517
+ pr: null,
518
+ prRequestedAt: 0,
454
519
  };
455
520
  }
456
521
 
@@ -494,7 +559,13 @@ function clearGitWorkflowForTab(tabId) {
494
559
  }
495
560
  }
496
561
 
497
- const GIT_WORKFLOW_STEPS = ["Stage", "Message", "Commit", "Push"];
562
+ const GIT_WORKFLOW_PROCESSES = [
563
+ { value: "stage", label: "Stage" },
564
+ { value: "message", label: "Message" },
565
+ { value: "commit", label: "Commit" },
566
+ { value: "push", label: "Push" },
567
+ ];
568
+ const GIT_WORKFLOW_PROCESS_VALUES = new Set(GIT_WORKFLOW_PROCESSES.map((process) => process.value));
498
569
  const ACTION_FEEDBACK_REACTIONS = {
499
570
  up: { icon: "👍", label: "Good job", title: "Good job!" },
500
571
  down: { icon: "👎", label: "Avoid this", title: "Avoid this" },
@@ -506,11 +577,32 @@ const GIT_WORKFLOW_ACTIVE_INDEX = {
506
577
  generate: 1,
507
578
  generating: 1,
508
579
  message: 2,
580
+ branchNaming: 2,
581
+ branching: 2,
509
582
  committing: 2,
510
583
  push: 3,
511
584
  pushing: 3,
585
+ prGenerating: 3,
586
+ prReview: 3,
587
+ prCreating: 3,
512
588
  done: 4,
513
589
  };
590
+ const GIT_WORKFLOW_CREATE_PR_TOOLTIP = [
591
+ "Create PR:",
592
+ "1. Ask Pi to generate a type/feature-name branch from staged changes.",
593
+ "2. Read dev/COMMIT/staged-branch-name.txt.",
594
+ "3. Let you confirm or edit the generated branch name.",
595
+ "4. Run git switch -c <branch>.",
596
+ "5. Return here so you can choose Commit short or Commit long on that branch.",
597
+ ].join("\n");
598
+ const GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP = [
599
+ "Manual branch:",
600
+ "1. Skip agent branch-name generation.",
601
+ "2. Prefill a branch from the commit message if possible.",
602
+ "3. Let you type or edit the type/feature-name branch name.",
603
+ "4. Run git switch -c <branch>.",
604
+ "5. Return here so you can choose Commit short or Commit long on that branch.",
605
+ ].join("\n");
514
606
 
515
607
  function make(tag, className, text) {
516
608
  const node = document.createElement(tag);
@@ -527,6 +619,10 @@ function isMobileView() {
527
619
  return mobileViewMedia?.matches || false;
528
620
  }
529
621
 
622
+ function isSidePanelOverlayView() {
623
+ return sidePanelOverlayMedia?.matches || false;
624
+ }
625
+
530
626
  function readStoredSidePanelCollapsed() {
531
627
  try {
532
628
  const stored = localStorage.getItem(SIDE_PANEL_STORAGE_KEY);
@@ -788,6 +884,7 @@ function setComposerActionsOpen(open) {
788
884
  if (!shouldOpen) {
789
885
  setPublishMenuOpen(false);
790
886
  setNativeCommandMenuOpen(false);
887
+ setAppRunnerMenuOpen(false);
791
888
  setOptionsMenuOpen(false);
792
889
  }
793
890
  }
@@ -923,7 +1020,7 @@ function setMobileTabsExpanded(expanded) {
923
1020
  }
924
1021
 
925
1022
  function syncMobileSidePanelState(collapsed) {
926
- const showBackdrop = !collapsed && isMobileView();
1023
+ const showBackdrop = !collapsed && isSidePanelOverlayView();
927
1024
  elements.sidePanelBackdrop.hidden = !showBackdrop;
928
1025
  if (showBackdrop) elements.sidePanel.setAttribute("aria-modal", "true");
929
1026
  else elements.sidePanel.removeAttribute("aria-modal");
@@ -937,11 +1034,11 @@ function setSidePanelCollapsed(collapsed, { persist = true, focusPanel = false }
937
1034
  elements.sidePanelExpandButton.setAttribute("aria-expanded", collapsed ? "false" : "true");
938
1035
  syncMobileSidePanelState(collapsed);
939
1036
 
940
- if (!collapsed && focusPanel && isMobileView()) {
1037
+ if (!collapsed && focusPanel && isSidePanelOverlayView()) {
941
1038
  requestAnimationFrame(() => elements.toggleSidePanelButton.focus());
942
1039
  }
943
1040
 
944
- if (!persist || isMobileView()) return;
1041
+ if (!persist || isSidePanelOverlayView()) return;
945
1042
  try {
946
1043
  localStorage.setItem(SIDE_PANEL_STORAGE_KEY, collapsed ? "1" : "0");
947
1044
  } catch {
@@ -950,7 +1047,7 @@ function setSidePanelCollapsed(collapsed, { persist = true, focusPanel = false }
950
1047
  }
951
1048
 
952
1049
  function restoreSidePanelState() {
953
- if (isMobileView()) {
1050
+ if (isSidePanelOverlayView()) {
954
1051
  setSidePanelCollapsed(true, { persist: false });
955
1052
  return;
956
1053
  }
@@ -964,7 +1061,7 @@ function bindMobileViewChanges() {
964
1061
  setComposerActionsOpen(false);
965
1062
  setMobileFooterExpanded(false);
966
1063
  setMobileTabsExpanded(false);
967
- if (event.matches) {
1064
+ if (event.matches || isSidePanelOverlayView()) {
968
1065
  setSidePanelCollapsed(true, { persist: false });
969
1066
  return;
970
1067
  }
@@ -975,6 +1072,20 @@ function bindMobileViewChanges() {
975
1072
  else mobileViewMedia.addListener?.(syncForViewport);
976
1073
  }
977
1074
 
1075
+ function bindSidePanelOverlayViewChanges() {
1076
+ if (!sidePanelOverlayMedia || sidePanelOverlayMedia === mobileViewMedia) return;
1077
+ const syncForViewport = (event) => {
1078
+ if (event.matches) {
1079
+ setSidePanelCollapsed(true, { persist: false });
1080
+ return;
1081
+ }
1082
+ const stored = readStoredSidePanelCollapsed();
1083
+ setSidePanelCollapsed(stored ?? false, { persist: false });
1084
+ };
1085
+ if (typeof sidePanelOverlayMedia.addEventListener === "function") sidePanelOverlayMedia.addEventListener("change", syncForViewport);
1086
+ else sidePanelOverlayMedia.addListener?.(syncForViewport);
1087
+ }
1088
+
978
1089
  function updateVisualViewportVars() {
979
1090
  const viewport = window.visualViewport;
980
1091
  const viewportHeight = viewport?.height || window.innerHeight || document.documentElement.clientHeight;
@@ -1050,8 +1161,7 @@ function currentPortArg() {
1050
1161
  }
1051
1162
 
1052
1163
  function serverStartCommandText() {
1053
- const cwd = readStoredServerStartCwd() || ".";
1054
- return `pi-webui --cwd ${quoteCommandArg(cwd)}${currentPortArg()}`;
1164
+ return `pi-webui${currentPortArg()}`;
1055
1165
  }
1056
1166
 
1057
1167
  function serverStartSlashCommandText() {
@@ -1371,6 +1481,46 @@ function attachmentIcon(kind) {
1371
1481
  return kind === "image" ? "🖼️" : kind === "video" ? "🎞️" : kind === "audio" ? "🎵" : kind === "doc" ? "📄" : "📎";
1372
1482
  }
1373
1483
 
1484
+ function normalizeTextAttachmentContent(text) {
1485
+ return String(text || "").replace(/\r\n?/g, "\n");
1486
+ }
1487
+
1488
+ function textLineCount(text) {
1489
+ const normalized = normalizeTextAttachmentContent(text);
1490
+ return normalized ? normalized.split("\n").length : 0;
1491
+ }
1492
+
1493
+ function shouldAttachTextInsteadOfComposerInput(text) {
1494
+ const normalized = normalizeTextAttachmentContent(text);
1495
+ return normalized.trim().length > 0 && textLineCount(normalized) > LONG_INPUT_ATTACHMENT_LINE_THRESHOLD;
1496
+ }
1497
+
1498
+ function longInputAttachmentFileName() {
1499
+ const stamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z").replace(/:/g, "-");
1500
+ return `webui-input-${stamp}.txt`;
1501
+ }
1502
+
1503
+ function makeTextAttachmentFile(text, name = longInputAttachmentFileName(), mimeType = LONG_INPUT_ATTACHMENT_MIME_TYPE) {
1504
+ const normalized = normalizeTextAttachmentContent(text);
1505
+ const fileName = String(name || longInputAttachmentFileName());
1506
+ const type = String(mimeType || LONG_INPUT_ATTACHMENT_MIME_TYPE);
1507
+ if (typeof File === "function") return new File([normalized], fileName, { type });
1508
+ const blob = new Blob([normalized], { type });
1509
+ try {
1510
+ blob.name = fileName;
1511
+ blob.lastModified = Date.now();
1512
+ } catch {
1513
+ // Older browsers may expose non-extensible Blob instances; the attachment record still carries the name.
1514
+ }
1515
+ return blob;
1516
+ }
1517
+
1518
+ function isEditableTextAttachment(attachment) {
1519
+ const name = String(attachment?.name || "");
1520
+ const mimeType = String(attachment?.mimeType || attachment?.file?.type || inferMimeTypeFromName(name)).split(";", 1)[0].trim().toLowerCase();
1521
+ return mimeType.startsWith("text/") || /(?:json|xml|yaml|toml|markdown|csv)/i.test(mimeType) || /\.(?:txt|md|markdown|csv|json|xml|ya?ml|toml|ini|log)$/i.test(name);
1522
+ }
1523
+
1374
1524
  function attachmentsForTab(tabId = activeTabId) {
1375
1525
  return tabId ? tabAttachments.get(tabId) || [] : [];
1376
1526
  }
@@ -1399,11 +1549,19 @@ function renderAttachmentTray() {
1399
1549
  const icon = make("span", "attachment-pill-icon", attachmentIcon(attachment.kind));
1400
1550
  const name = make("span", "attachment-pill-name", attachment.name);
1401
1551
  const meta = make("span", "attachment-pill-meta", `${attachment.kind} · ${formatBytes(attachment.size)}`);
1552
+ const edit = isEditableTextAttachment(attachment) ? make("button", "attachment-edit-button", "Edit") : null;
1553
+ if (edit) {
1554
+ edit.type = "button";
1555
+ edit.setAttribute("aria-label", `Open and edit ${attachment.name}`);
1556
+ edit.addEventListener("click", () => openTextAttachmentEditor(attachment.id));
1557
+ }
1402
1558
  const remove = make("button", "attachment-remove-button", "×");
1403
1559
  remove.type = "button";
1404
1560
  remove.setAttribute("aria-label", `Remove ${attachment.name}`);
1405
1561
  remove.addEventListener("click", () => removeAttachment(attachment.id));
1406
- pill.append(icon, name, meta, remove);
1562
+ pill.append(icon, name, meta);
1563
+ if (edit) pill.append(edit);
1564
+ pill.append(remove);
1407
1565
  tray.append(pill);
1408
1566
  }
1409
1567
  }
@@ -1414,6 +1572,7 @@ function removeAttachment(id, tabId = activeTabId) {
1414
1572
  if (index === -1) return;
1415
1573
  const [removed] = attachments.splice(index, 1);
1416
1574
  if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl);
1575
+ if (activeTextAttachmentEditor?.tabId === tabId && activeTextAttachmentEditor?.attachmentId === id) closeTextAttachmentEditor();
1417
1576
  if (attachments.length === 0) tabAttachments.delete(tabId);
1418
1577
  if (tabId === activeTabId) renderAttachmentTray();
1419
1578
  }
@@ -1423,15 +1582,16 @@ function clearAttachments(tabId = activeTabId) {
1423
1582
  for (const attachment of attachments) {
1424
1583
  if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
1425
1584
  }
1585
+ if (activeTextAttachmentEditor?.tabId === tabId) closeTextAttachmentEditor();
1426
1586
  if (tabId) tabAttachments.delete(tabId);
1427
1587
  if (tabId === activeTabId) renderAttachmentTray();
1428
1588
  }
1429
1589
 
1430
1590
  function addAttachmentFiles(fileList, source = "picker") {
1431
1591
  const files = Array.from(fileList || []).filter(Boolean);
1432
- if (!files.length) return;
1592
+ if (!files.length) return { added: 0, skipped: [] };
1433
1593
  const attachments = ensureAttachmentsForTab();
1434
- if (!attachments.length && !activeTabId) return;
1594
+ if (!attachments.length && !activeTabId) return { added: 0, skipped: ["no active tab"] };
1435
1595
  let totalBytes = attachments.reduce((sum, attachment) => sum + attachment.size, 0);
1436
1596
  let added = 0;
1437
1597
  const skipped = [];
@@ -1469,6 +1629,129 @@ function addAttachmentFiles(fileList, source = "picker") {
1469
1629
  renderAttachmentTray();
1470
1630
  if (added) addEvent(`attached ${added} ${added === 1 ? "file" : "files"} from ${source}`, "info");
1471
1631
  if (skipped.length) addEvent(`skipped attachments: ${skipped.join("; ")}`, "warn");
1632
+ return { added, skipped };
1633
+ }
1634
+
1635
+ function attachLongTextAsFile(text, source = "input text") {
1636
+ if (!shouldAttachTextInsteadOfComposerInput(text)) return false;
1637
+ const normalized = normalizeTextAttachmentContent(text);
1638
+ const lineCount = textLineCount(normalized);
1639
+ const result = addAttachmentFiles([makeTextAttachmentFile(normalized)], `${lineCount}-line ${source}`);
1640
+ return result.added > 0;
1641
+ }
1642
+
1643
+ function moveLongPromptInputToAttachment() {
1644
+ const text = elements.promptInput.value || "";
1645
+ if (!attachLongTextAsFile(text, "input text")) return false;
1646
+ elements.promptInput.value = "";
1647
+ resizePromptInput();
1648
+ hideCommandSuggestions();
1649
+ return true;
1650
+ }
1651
+
1652
+ function attachmentById(tabId, id) {
1653
+ return attachmentsForTab(tabId).find((attachment) => attachment.id === id) || null;
1654
+ }
1655
+
1656
+ function closeTextAttachmentEditor() {
1657
+ if (elements.attachmentTextDialog?.open) elements.attachmentTextDialog.close();
1658
+ else activeTextAttachmentEditor = null;
1659
+ }
1660
+
1661
+ function setAttachmentTextStatus(message = "", level = "muted") {
1662
+ if (!elements.attachmentTextStatus) return;
1663
+ elements.attachmentTextStatus.textContent = message;
1664
+ elements.attachmentTextStatus.className = `attachment-text-status ${level || "muted"}`;
1665
+ }
1666
+
1667
+ function renderTextAttachmentEditorMeta() {
1668
+ if (!activeTextAttachmentEditor || !elements.attachmentTextMeta) return;
1669
+ const attachment = attachmentById(activeTextAttachmentEditor.tabId, activeTextAttachmentEditor.attachmentId);
1670
+ if (!attachment) {
1671
+ elements.attachmentTextMeta.textContent = "Attachment no longer exists.";
1672
+ return;
1673
+ }
1674
+ const text = elements.attachmentTextEditor?.value || "";
1675
+ const lineCount = textLineCount(text);
1676
+ elements.attachmentTextMeta.textContent = `${attachment.name} · ${attachment.mimeType} · ${formatBytes(attachment.size)} · ${lineCount} ${lineCount === 1 ? "line" : "lines"}`;
1677
+ }
1678
+
1679
+ function readFileAsText(file) {
1680
+ if (typeof file?.text === "function") return file.text();
1681
+ return new Promise((resolve, reject) => {
1682
+ const reader = new FileReader();
1683
+ reader.onerror = () => reject(reader.error || new Error("Failed to read text attachment"));
1684
+ reader.onload = () => resolve(String(reader.result || ""));
1685
+ reader.readAsText(file);
1686
+ });
1687
+ }
1688
+
1689
+ async function openTextAttachmentEditor(attachmentId, tabId = activeTabId) {
1690
+ const attachment = attachmentById(tabId, attachmentId);
1691
+ if (!attachment) return;
1692
+ if (!isEditableTextAttachment(attachment)) {
1693
+ addEvent(`${attachment.name || "attachment"} is not editable text`, "warn");
1694
+ return;
1695
+ }
1696
+
1697
+ activeTextAttachmentEditor = { tabId, attachmentId };
1698
+ if (elements.attachmentTextTitle) elements.attachmentTextTitle.textContent = `Edit ${attachment.name || "text attachment"}`;
1699
+ if (elements.attachmentTextEditor) elements.attachmentTextEditor.value = "";
1700
+ if (elements.attachmentTextSaveButton) elements.attachmentTextSaveButton.disabled = true;
1701
+ renderTextAttachmentEditorMeta();
1702
+ setAttachmentTextStatus("Loading text attachment…", "muted");
1703
+ if (elements.attachmentTextDialog && !elements.attachmentTextDialog.open) elements.attachmentTextDialog.showModal();
1704
+
1705
+ try {
1706
+ const text = await readFileAsText(attachment.file);
1707
+ if (activeTextAttachmentEditor?.tabId !== tabId || activeTextAttachmentEditor?.attachmentId !== attachmentId) return;
1708
+ if (elements.attachmentTextEditor) elements.attachmentTextEditor.value = normalizeTextAttachmentContent(text);
1709
+ if (elements.attachmentTextSaveButton) elements.attachmentTextSaveButton.disabled = false;
1710
+ renderTextAttachmentEditorMeta();
1711
+ setAttachmentTextStatus("Edit the text, then save it back to the attachment.", "muted");
1712
+ queueMicrotask(() => elements.attachmentTextEditor?.focus());
1713
+ } catch (error) {
1714
+ if (elements.attachmentTextSaveButton) elements.attachmentTextSaveButton.disabled = true;
1715
+ setAttachmentTextStatus(`Failed to open text attachment: ${error.message || String(error)}`, "error");
1716
+ }
1717
+ }
1718
+
1719
+ function totalAttachmentBytesWithReplacement(tabId, attachmentId, nextSize) {
1720
+ return attachmentsForTab(tabId).reduce((sum, attachment) => sum + (attachment.id === attachmentId ? nextSize : attachment.size || 0), 0);
1721
+ }
1722
+
1723
+ function saveTextAttachmentEdit() {
1724
+ if (!activeTextAttachmentEditor) return;
1725
+ const { tabId, attachmentId } = activeTextAttachmentEditor;
1726
+ const attachment = attachmentById(tabId, attachmentId);
1727
+ if (!attachment) {
1728
+ setAttachmentTextStatus("Attachment no longer exists.", "error");
1729
+ return;
1730
+ }
1731
+
1732
+ const text = elements.attachmentTextEditor?.value || "";
1733
+ const name = attachment.name || longInputAttachmentFileName();
1734
+ const mimeType = attachment.mimeType || inferMimeTypeFromName(name) || LONG_INPUT_ATTACHMENT_MIME_TYPE;
1735
+ const nextFile = makeTextAttachmentFile(text, name, mimeType);
1736
+ if (nextFile.size > ATTACHMENT_MAX_FILE_BYTES) {
1737
+ setAttachmentTextStatus(`Edited file is larger than ${formatBytes(ATTACHMENT_MAX_FILE_BYTES)}.`, "error");
1738
+ return;
1739
+ }
1740
+ if (totalAttachmentBytesWithReplacement(tabId, attachmentId, nextFile.size) > ATTACHMENT_MAX_TOTAL_BYTES) {
1741
+ setAttachmentTextStatus(`Edited attachments exceed ${formatBytes(ATTACHMENT_MAX_TOTAL_BYTES)} total.`, "error");
1742
+ return;
1743
+ }
1744
+
1745
+ if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
1746
+ attachment.file = nextFile;
1747
+ attachment.name = name;
1748
+ attachment.mimeType = nextFile.type || mimeType;
1749
+ attachment.size = nextFile.size || 0;
1750
+ attachment.kind = attachmentKind(attachment.mimeType, attachment.name);
1751
+ attachment.previewUrl = undefined;
1752
+ if (tabId === activeTabId) renderAttachmentTray();
1753
+ addEvent(`updated text attachment ${attachment.name} (${formatBytes(attachment.size)})`, "info");
1754
+ closeTextAttachmentEditor();
1472
1755
  }
1473
1756
 
1474
1757
  function clipboardFiles(dataTransfer) {
@@ -1496,9 +1779,15 @@ function clipboardFiles(dataTransfer) {
1496
1779
 
1497
1780
  function handleAttachmentPaste(event) {
1498
1781
  const files = clipboardFiles(event.clipboardData);
1499
- if (!files.length) return;
1782
+ if (files.length) {
1783
+ event.preventDefault();
1784
+ addAttachmentFiles(files, "clipboard");
1785
+ return;
1786
+ }
1787
+
1788
+ const text = event.clipboardData?.getData("text/plain") || "";
1789
+ if (!attachLongTextAsFile(text, "clipboard text")) return;
1500
1790
  event.preventDefault();
1501
- addAttachmentFiles(files, "clipboard");
1502
1791
  }
1503
1792
 
1504
1793
  function isFileDrag(event) {
@@ -2587,7 +2876,7 @@ function restoreActiveDraft() {
2587
2876
 
2588
2877
  function focusPromptInput({ defer = false } = {}) {
2589
2878
  const focus = () => {
2590
- if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.nativeCommandDialog.open || elements.promptListDialog?.open || document.visibilityState === "hidden") return;
2879
+ if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.nativeCommandDialog.open || elements.appRunnerInfoDialog?.open || elements.promptListDialog?.open || elements.attachmentTextDialog?.open || document.visibilityState === "hidden") return;
2591
2880
  try {
2592
2881
  elements.promptInput.focus({ preventScroll: true });
2593
2882
  } catch {
@@ -2661,6 +2950,7 @@ function resetActiveTabUi() {
2661
2950
  else renderQueue({ tabId: activeTabId, steering: [], followUp: [] });
2662
2951
  elements.commandsBox.textContent = "Loading…";
2663
2952
  elements.commandsBox.classList.add("muted");
2953
+ renderAppRunnerControls();
2664
2954
  renderWidgets();
2665
2955
  renderGitWorkflow();
2666
2956
  renderFooter();
@@ -2873,6 +3163,35 @@ function clearOpenTerminalTabGroup(groupKey, { force = false } = {}) {
2873
3163
  syncTabPolling();
2874
3164
  }
2875
3165
 
3166
+ function setNewTabMenuOpen(open) {
3167
+ newTabMenuOpen = !!open;
3168
+ elements.newTabButton?.setAttribute("aria-expanded", newTabMenuOpen ? "true" : "false");
3169
+ elements.newTabButton?.classList.toggle("menu-open", newTabMenuOpen);
3170
+ elements.newTabMenu?.classList.toggle("open", newTabMenuOpen);
3171
+ }
3172
+
3173
+ function openNewTabMenu() {
3174
+ setPublishMenuOpen(false);
3175
+ setNativeCommandMenuOpen(false);
3176
+ setAppRunnerMenuOpen(false);
3177
+ setOptionsMenuOpen(false);
3178
+ setNewTabMenuOpen(true);
3179
+ }
3180
+
3181
+ function focusNewTabMenuItem(direction = "first") {
3182
+ const items = [elements.newTabCurrentDirectoryButton, elements.newTabChooseDirectoryButton].filter(Boolean);
3183
+ const item = direction === "last" ? items.at(-1) : items[0];
3184
+ item?.focus({ preventScroll: true });
3185
+ }
3186
+
3187
+ function moveNewTabMenuFocus(delta) {
3188
+ const items = [elements.newTabCurrentDirectoryButton, elements.newTabChooseDirectoryButton].filter(Boolean);
3189
+ if (!items.length) return;
3190
+ const currentIndex = Math.max(0, items.indexOf(document.activeElement));
3191
+ const nextIndex = (currentIndex + delta + items.length) % items.length;
3192
+ items[nextIndex].focus({ preventScroll: true });
3193
+ }
3194
+
2876
3195
  function renderTabs() {
2877
3196
  const active = activeTab();
2878
3197
  const activeIndicator = active ? tabIndicator(active) : null;
@@ -2891,7 +3210,7 @@ function renderTabs() {
2891
3210
  for (const tab of group.tabs) elements.tabBar.append(renderTerminalTab(tab));
2892
3211
  }
2893
3212
  }
2894
- elements.tabBar.append(elements.newTabButton);
3213
+ elements.tabBar.append(elements.newTabMenu);
2895
3214
  elements.closeAllTabsButton.disabled = tabs.length === 0;
2896
3215
  updateTerminalTabGroupOpenState();
2897
3216
  setMobileTabsExpanded(mobileTabsExpanded);
@@ -2933,12 +3252,22 @@ async function switchTab(tabId) {
2933
3252
  if (isCurrentTabContext(tabContext)) markTabOutputSeen();
2934
3253
  }
2935
3254
 
2936
- async function createTerminalTab(cwd = activeTab()?.cwd, { triggerButton = elements.newTabButton } = {}) {
3255
+ function currentDirectoryForNewTab() {
3256
+ return latestWorkspace?.cwd || activeTab()?.cwd || "";
3257
+ }
3258
+
3259
+ async function createTerminalTab(cwd = currentDirectoryForNewTab(), { triggerButton = elements.newTabButton } = {}) {
2937
3260
  setMobileTabsExpanded(false);
3261
+ setNewTabMenuOpen(false);
3262
+ const resolvedCwd = cwd || currentDirectoryForNewTab();
3263
+ if (!resolvedCwd && tabs.length === 0) {
3264
+ await createTerminalTabFromChosenDirectory({ triggerButton });
3265
+ return;
3266
+ }
2938
3267
  const disabledButtons = new Set([elements.newTabButton, triggerButton].filter(Boolean));
2939
3268
  for (const button of disabledButtons) button.disabled = true;
2940
3269
  try {
2941
- const response = await api("/api/tabs", { method: "POST", body: { cwd: cwd || activeTab()?.cwd }, scoped: false });
3270
+ const response = await api("/api/tabs", { method: "POST", body: { cwd: resolvedCwd }, scoped: false });
2942
3271
  tabs = response.data?.tabs || tabs;
2943
3272
  syncTabMetadata(tabs);
2944
3273
  const tab = response.data?.tab;
@@ -2954,6 +3283,27 @@ async function createTerminalTab(cwd = activeTab()?.cwd, { triggerButton = eleme
2954
3283
  }
2955
3284
  }
2956
3285
 
3286
+ async function createTerminalTabFromChosenDirectory({ triggerButton = elements.newTabChooseDirectoryButton } = {}) {
3287
+ const sourceTab = activeTab();
3288
+ const initialCwd = currentDirectoryForNewTab();
3289
+ setMobileTabsExpanded(false);
3290
+ setNewTabMenuOpen(false);
3291
+ const cwd = await pickCwd(sourceTab || { id: "new-tab", title: "new tab" }, initialCwd, { title: "Choose CWD for new tab" });
3292
+ if (!cwd) return;
3293
+ await createTerminalTab(cwd, { triggerButton });
3294
+ }
3295
+
3296
+ async function createFirstTerminalTabFromChosenDirectory() {
3297
+ if (firstTerminalCwdPromptShown || tabs.length > 0) return;
3298
+ firstTerminalCwdPromptShown = true;
3299
+ const cwd = await pickCwd({ id: "first-terminal", title: "first terminal" }, "", { title: "Choose CWD for first terminal" });
3300
+ if (!cwd) {
3301
+ addEvent("choose a CWD to start the first terminal", "warn");
3302
+ return;
3303
+ }
3304
+ await createTerminalTab(cwd, { triggerButton: null });
3305
+ }
3306
+
2957
3307
  function tabHasActiveAgent(tab) {
2958
3308
  const activity = activityForTab(tab);
2959
3309
  const indicator = tabIndicator(tab);
@@ -3003,6 +3353,7 @@ async function closeTerminalTabs(tabIds, { label = "selected terminal tabs" } =
3003
3353
  tabDrafts.delete(id);
3004
3354
  clearAttachments(id);
3005
3355
  clearGitWorkflowForTab(id);
3356
+ appRunnerDataByTab.delete(id);
3006
3357
  }
3007
3358
  clearOpenTerminalTabGroup(null, { force: true });
3008
3359
 
@@ -3045,10 +3396,14 @@ async function closeAllTerminalTabs() {
3045
3396
  }
3046
3397
 
3047
3398
  async function initializeTabs() {
3048
- await refreshTabs({ selectStored: true });
3399
+ const loadedTabs = await refreshTabs({ selectStored: true });
3049
3400
  resetActiveTabUi();
3050
3401
  renderTabs();
3051
3402
  restoreActiveDraft();
3403
+ if (!loadedTabs.length) {
3404
+ await createFirstTerminalTabFromChosenDirectory();
3405
+ return;
3406
+ }
3052
3407
  focusPromptInput({ defer: true });
3053
3408
  const tabContext = activeTabContext();
3054
3409
  connectEvents(tabContext);
@@ -3534,6 +3889,50 @@ function footerCostAuthLabel() {
3534
3889
  return /codex|copilot|chatgpt/i.test(provider) ? "sub" : "api";
3535
3890
  }
3536
3891
 
3892
+ function contextWindowFromSources(...sources) {
3893
+ for (const source of sources) {
3894
+ const value = typeof source === "object" && source !== null ? source.contextWindow : source;
3895
+ const contextWindow = Number(value);
3896
+ if (Number.isFinite(contextWindow) && contextWindow > 0) return contextWindow;
3897
+ }
3898
+ return 0;
3899
+ }
3900
+
3901
+ function contextUsageUnknownAfterCompaction(tabId = activeTabId) {
3902
+ return !!tabId && contextUsageUnknownAfterCompactionByTab.has(tabId);
3903
+ }
3904
+
3905
+ function unknownFooterContextText(contextUsage = null) {
3906
+ const contextWindow = contextWindowFromSources(
3907
+ contextUsage,
3908
+ latestStats?.contextUsage,
3909
+ currentState?.contextUsage,
3910
+ currentState?.model?.contextWindow,
3911
+ );
3912
+ return contextWindow ? `?/${formatFooterTokenCount(contextWindow)}` : "?";
3913
+ }
3914
+
3915
+ function contextUsageWithUnknownPercent(contextUsage = null) {
3916
+ return {
3917
+ ...(contextUsage || {}),
3918
+ percent: null,
3919
+ contextWindow: contextWindowFromSources(contextUsage, latestStats?.contextUsage, currentState?.contextUsage, currentState?.model?.contextWindow),
3920
+ };
3921
+ }
3922
+
3923
+ function markContextUsageUnknownAfterCompaction(tabId = activeTabId) {
3924
+ if (!tabId) return;
3925
+ contextUsageUnknownAfterCompactionByTab.set(tabId, Date.now());
3926
+ if (tabId !== activeTabId) return;
3927
+ if (currentState) currentState = { ...currentState, contextUsage: contextUsageWithUnknownPercent(currentState.contextUsage) };
3928
+ if (latestStats) latestStats = { ...latestStats, contextUsage: contextUsageWithUnknownPercent(latestStats.contextUsage) };
3929
+ }
3930
+
3931
+ function clearContextUsageUnknownAfterCompaction(tabId = activeTabId) {
3932
+ if (!tabId) return;
3933
+ contextUsageUnknownAfterCompactionByTab.delete(tabId);
3934
+ }
3935
+
3537
3936
  function footerStatsTokensDisplay(stats = latestStats) {
3538
3937
  const tokens = stats?.tokens;
3539
3938
  if (!tokens) return "";
@@ -3554,7 +3953,8 @@ function footerContextDisplayWithAuto(value, state = currentState) {
3554
3953
 
3555
3954
  function footerStatsContextDisplay(stats = latestStats) {
3556
3955
  const usage = stats?.contextUsage || currentState?.contextUsage;
3557
- const contextWindow = usage?.contextWindow ?? currentState?.model?.contextWindow ?? 0;
3956
+ const contextWindow = contextWindowFromSources(usage, currentState?.model?.contextWindow);
3957
+ if (contextUsageUnknownAfterCompaction()) return footerContextDisplayWithAuto(unknownFooterContextText(usage));
3558
3958
  if (!contextWindow) return "";
3559
3959
  const rawPercent = Number(usage?.percent);
3560
3960
  const percent = Number.isFinite(rawPercent) ? `${rawPercent.toFixed(1)}%` : "?";
@@ -3853,8 +4253,10 @@ function footerPayloadWithLiveModel(payload) {
3853
4253
  const effort = footerThinkingDisplay();
3854
4254
  const hasThinkingChip = [...payload.main, ...payload.meta].some((chip) => chip?.key === "thinking");
3855
4255
  const contextChip = (chip) => {
3856
- const value = footerContextDisplayWithAuto(chip?.value);
3857
- return { ...chip, value, title: `context: ${value}` };
4256
+ const usageUnknown = contextUsageUnknownAfterCompaction();
4257
+ const value = usageUnknown ? footerContextDisplayWithAuto(unknownFooterContextText(chip?.contextUsage)) : footerContextDisplayWithAuto(chip?.value);
4258
+ const contextUsage = usageUnknown ? contextUsageWithUnknownPercent(chip?.contextUsage) : chip?.contextUsage;
4259
+ return { ...chip, value, title: `context: ${value}`, ...(contextUsage ? { contextUsage } : {}) };
3858
4260
  };
3859
4261
  const effortChip = (chip) => ({ ...chip, key: "thinking", label: "effort", value: effort, title: `effort: ${effort}`, tone: "mauve" });
3860
4262
  const splitChip = (chip) => {
@@ -4469,12 +4871,13 @@ function closePathPicker(cwd) {
4469
4871
  state.resolve(cwd || null);
4470
4872
  }
4471
4873
 
4472
- function pickCwd(tab, initialCwd) {
4874
+ function pickCwd(tab, initialCwd, { title } = {}) {
4473
4875
  if (pathPickerState) return Promise.resolve(null);
4474
4876
 
4475
4877
  return new Promise((resolve) => {
4476
- pathPickerState = { tabId: tab.id, cwd: initialCwd, requestId: 0, loading: false, creatingDirectory: false, directories: [], filteredDirectories: [], resolve };
4477
- elements.pathPickerTitle.textContent = `Choose CWD for ${tab.title}`;
4878
+ const pickerTab = tab || { id: "path-picker", title: "tab" };
4879
+ pathPickerState = { tabId: pickerTab.id, cwd: initialCwd, requestId: 0, loading: false, creatingDirectory: false, directories: [], filteredDirectories: [], resolve };
4880
+ elements.pathPickerTitle.textContent = title || `Choose CWD for ${pickerTab.title}`;
4478
4881
  elements.pathPickerCurrent.textContent = "Loading…";
4479
4882
  elements.pathPickerCreateNameInput.value = "";
4480
4883
  elements.pathPickerSearchInput.value = "";
@@ -5206,6 +5609,530 @@ function renderReleaseAurLogWidget() {
5206
5609
  return node;
5207
5610
  }
5208
5611
 
5612
+ function activeAppRunnerData() {
5613
+ return activeTabId ? appRunnerDataByTab.get(activeTabId) || { runners: [], activeRun: null } : { runners: [], activeRun: null };
5614
+ }
5615
+
5616
+ function setAppRunnerData(tabId, data = {}) {
5617
+ if (!tabId) return;
5618
+ const previous = appRunnerDataByTab.get(tabId) || { runners: [], activeRun: null, customRunnerConfig: null };
5619
+ appRunnerDataByTab.set(tabId, {
5620
+ cwd: data.cwd || previous.cwd || "",
5621
+ runners: Array.isArray(data.runners) ? data.runners : previous.runners || [],
5622
+ customRunnerConfig: data.customRunnerConfig || previous.customRunnerConfig || null,
5623
+ activeRun: Object.prototype.hasOwnProperty.call(data, "activeRun") ? data.activeRun : previous.activeRun || null,
5624
+ });
5625
+ }
5626
+
5627
+ function appRunnerIsRunning(run) {
5628
+ return run?.status === "running" || run?.stopping === true;
5629
+ }
5630
+
5631
+ function appRunnerStatusLabel(run) {
5632
+ if (run?.stopping && run.status === "running") return "stopping";
5633
+ if (run?.status === "done") return "exit 0";
5634
+ if (run?.status === "failed") return run.signal ? `signal ${run.signal}` : `exit ${run.exitCode ?? "?"}`;
5635
+ if (run?.status === "error") return "error";
5636
+ return run?.status || "running";
5637
+ }
5638
+
5639
+ function appRunnerElapsedLabel(run) {
5640
+ const startedAt = Date.parse(run?.startedAt || "");
5641
+ if (!Number.isFinite(startedAt)) return "";
5642
+ const endedAt = Date.parse(run?.endedAt || "");
5643
+ const end = Number.isFinite(endedAt) ? endedAt : Date.now();
5644
+ return formatDuration(end - startedAt);
5645
+ }
5646
+
5647
+ function appRunnerActionButton(label, handler, className = "") {
5648
+ const button = make("button", `release-npm-action ${className}`.trim(), label);
5649
+ button.type = "button";
5650
+ button.addEventListener("click", handler);
5651
+ return button;
5652
+ }
5653
+
5654
+ async function refreshAppRunners(tabContext = activeTabContext()) {
5655
+ if (!tabContext.tabId) return;
5656
+ const response = await api("/api/app-runners", { tabId: tabContext.tabId });
5657
+ if (!isCurrentTabContext(tabContext)) return;
5658
+ setAppRunnerData(tabContext.tabId, response.data || {});
5659
+ renderAppRunnerControls();
5660
+ renderWidgets();
5661
+ }
5662
+
5663
+ async function runAppRunner(runnerId) {
5664
+ const tabContext = activeTabContext();
5665
+ if (!tabContext.tabId || !runnerId) return;
5666
+ setComposerActionsOpen(false);
5667
+ setAppRunnerMenuOpen(false);
5668
+ try {
5669
+ const response = await api("/api/app-runner", { method: "POST", body: { runnerId }, tabId: tabContext.tabId });
5670
+ if (!isCurrentTabContext(tabContext)) return;
5671
+ setAppRunnerData(tabContext.tabId, response.data || {});
5672
+ renderAppRunnerControls();
5673
+ renderWidgets();
5674
+ const command = response.data?.activeRun?.displayCommand || "app runner";
5675
+ addEvent(`started ${command}`, "info");
5676
+ } catch (error) {
5677
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
5678
+ }
5679
+ }
5680
+
5681
+ async function stopAppRunner() {
5682
+ const tabContext = activeTabContext();
5683
+ if (!tabContext.tabId) return;
5684
+ try {
5685
+ const response = await api("/api/app-runner/stop", { method: "POST", body: {}, tabId: tabContext.tabId });
5686
+ if (!isCurrentTabContext(tabContext)) return;
5687
+ setAppRunnerData(tabContext.tabId, response.data || {});
5688
+ renderAppRunnerControls();
5689
+ renderWidgets();
5690
+ addEvent("app runner stop requested", "warn");
5691
+ } catch (error) {
5692
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
5693
+ }
5694
+ }
5695
+
5696
+ async function clearAppRunner() {
5697
+ const tabContext = activeTabContext();
5698
+ if (!tabContext.tabId) return;
5699
+ try {
5700
+ const response = await api("/api/app-runner/clear", { method: "POST", body: {}, tabId: tabContext.tabId });
5701
+ if (!isCurrentTabContext(tabContext)) return;
5702
+ setAppRunnerData(tabContext.tabId, response.data || {});
5703
+ renderAppRunnerControls();
5704
+ renderWidgets();
5705
+ } catch (error) {
5706
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
5707
+ }
5708
+ }
5709
+
5710
+ function appRunnerOutputText(run) {
5711
+ const lines = Array.isArray(run?.lines) ? run.lines : [];
5712
+ return lines.join("\n").trimEnd();
5713
+ }
5714
+
5715
+ async function copyAppRunnerOutput(run) {
5716
+ const text = appRunnerOutputText(run);
5717
+ if (!text.trim()) {
5718
+ addEvent("app runner output is empty", "warn");
5719
+ return;
5720
+ }
5721
+ try {
5722
+ await copyText(text);
5723
+ addEvent("copied app runner output", "info");
5724
+ } catch (error) {
5725
+ addEvent(`app runner output copy failed: ${error.message || String(error)}`, "warn");
5726
+ }
5727
+ }
5728
+
5729
+ const APP_RUNNER_SUPPORTED_ITEMS = [
5730
+ "Project-local custom runners from .pi-webui-runners.json",
5731
+ "package.json scripts: bun/npm/pnpm/yarn dev, start, serve",
5732
+ "npx frameworks: Vite, Next, Astro, Storybook",
5733
+ "Rust: cargo run",
5734
+ "Python: uv run or python entry files such as Main.py, main.py, src/main.py",
5735
+ "Go/Golang: go run",
5736
+ "Zig: zig build run or zig run",
5737
+ "C/C++: CMake, cc/c++ main files",
5738
+ "Docker Compose: docker compose up",
5739
+ "Shell scripts: bash/zsh/fish in root, dev/, scripts/, dev/scripts/",
5740
+ "Deno, make, just, and plain Node entry files",
5741
+ ];
5742
+ const APP_RUNNER_SUPPORTED_TOOLTIP = [
5743
+ "No app runner detected for this tab cwd.",
5744
+ "",
5745
+ "Currently supported:",
5746
+ ...APP_RUNNER_SUPPORTED_ITEMS.map((item) => `• ${item}`),
5747
+ ].join("\n");
5748
+
5749
+ function appRunnerMenuCanOpen() {
5750
+ const data = activeAppRunnerData();
5751
+ return Array.isArray(data.runners) && data.runners.length > 0 && !appRunnerIsRunning(data.activeRun);
5752
+ }
5753
+
5754
+ function activeAppRunnerCustomConfig() {
5755
+ return activeAppRunnerData().customRunnerConfig || { runners: [], projectRoot: "", displayProjectRoot: "", displayConfigFile: "" };
5756
+ }
5757
+
5758
+ function resetAppRunnerCustomDraft() {
5759
+ appRunnerCustomDraft = { id: "", label: "", command: "./", path: "", args: "" };
5760
+ appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
5761
+ }
5762
+
5763
+ function appRunnerRelativeDir(filePath) {
5764
+ const normalized = String(filePath || "").replace(/\\/g, "/").replace(/^\.\/+/, "");
5765
+ const index = normalized.lastIndexOf("/");
5766
+ return index === -1 ? "" : normalized.slice(0, index);
5767
+ }
5768
+
5769
+ function appRunnerCustomArgsText(args) {
5770
+ return Array.isArray(args) ? args.join(" ") : String(args || "");
5771
+ }
5772
+
5773
+ function appRunnerCustomDraftPayload() {
5774
+ return {
5775
+ id: appRunnerCustomDraft.id || undefined,
5776
+ label: appRunnerCustomDraft.label.trim(),
5777
+ command: appRunnerCustomDraft.command.trim() || "./",
5778
+ path: appRunnerCustomDraft.path.trim(),
5779
+ args: appRunnerCustomDraft.args.trim(),
5780
+ };
5781
+ }
5782
+
5783
+ function updateAppRunnerCustomDraftFrom(container) {
5784
+ if (!container) return;
5785
+ appRunnerCustomDraft = {
5786
+ id: appRunnerCustomDraft.id || "",
5787
+ label: container.querySelector("#appRunnerCustomLabelInput")?.value || "",
5788
+ command: container.querySelector("#appRunnerCustomCommandInput")?.value || "./",
5789
+ path: container.querySelector("#appRunnerCustomPathInput")?.value || "",
5790
+ args: container.querySelector("#appRunnerCustomArgsInput")?.value || "",
5791
+ };
5792
+ }
5793
+
5794
+ function appRunnerInputField({ id, label, value, placeholder = "", hint = "" }) {
5795
+ const field = make("label", "app-runner-custom-field");
5796
+ field.setAttribute("for", id);
5797
+ field.append(make("span", "", label));
5798
+ const input = make("input", "dialog-input");
5799
+ input.id = id;
5800
+ input.type = "text";
5801
+ input.value = value || "";
5802
+ input.placeholder = placeholder;
5803
+ input.autocomplete = "off";
5804
+ input.spellcheck = false;
5805
+ field.append(input);
5806
+ if (hint) field.append(make("small", "muted", hint));
5807
+ input.addEventListener("input", () => updateAppRunnerCustomDraftFrom(field.closest(".app-runner-custom-form")));
5808
+ input.addEventListener("keydown", (event) => {
5809
+ if (event.key !== "Enter") return;
5810
+ event.preventDefault();
5811
+ saveAppRunnerCustomRunner(field.closest(".app-runner-custom-form"));
5812
+ });
5813
+ return { field, input };
5814
+ }
5815
+
5816
+ async function saveAppRunnerCustomRunner(form) {
5817
+ updateAppRunnerCustomDraftFrom(form);
5818
+ const payload = appRunnerCustomDraftPayload();
5819
+ if (!payload.path) {
5820
+ addEvent("custom app runner path is required", "warn");
5821
+ form?.querySelector("#appRunnerCustomPathInput")?.focus();
5822
+ return;
5823
+ }
5824
+ const tabContext = activeTabContext();
5825
+ try {
5826
+ const response = await api("/api/app-runner-config", { method: "POST", body: { runner: payload }, tabId: tabContext.tabId });
5827
+ if (!isCurrentTabContext(tabContext)) return;
5828
+ setAppRunnerData(tabContext.tabId, response.data || {});
5829
+ resetAppRunnerCustomDraft();
5830
+ renderAppRunnerControls();
5831
+ renderWidgets();
5832
+ renderAppRunnerInfoDialog();
5833
+ addEvent("saved custom app runner", "info");
5834
+ } catch (error) {
5835
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
5836
+ }
5837
+ }
5838
+
5839
+ async function deleteAppRunnerCustomRunner(id) {
5840
+ const tabContext = activeTabContext();
5841
+ try {
5842
+ const response = await api("/api/app-runner-config", { method: "DELETE", body: { id }, tabId: tabContext.tabId });
5843
+ if (!isCurrentTabContext(tabContext)) return;
5844
+ setAppRunnerData(tabContext.tabId, response.data || {});
5845
+ if (appRunnerCustomDraft.id === id) resetAppRunnerCustomDraft();
5846
+ renderAppRunnerControls();
5847
+ renderWidgets();
5848
+ renderAppRunnerInfoDialog();
5849
+ addEvent("deleted custom app runner", "warn");
5850
+ } catch (error) {
5851
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
5852
+ }
5853
+ }
5854
+
5855
+ async function loadAppRunnerFileBrowser(relativePath = "") {
5856
+ const tabContext = activeTabContext();
5857
+ const path = String(relativePath || "").replace(/^\.\/+/, "").replace(/\/+$/g, "");
5858
+ appRunnerFileBrowserState = { open: true, loading: true, path, data: null, error: "" };
5859
+ renderAppRunnerInfoDialog();
5860
+ try {
5861
+ const response = await api(`/api/app-runner-files?path=${encodeURIComponent(path)}`, { tabId: tabContext.tabId });
5862
+ if (!isCurrentTabContext(tabContext)) return;
5863
+ appRunnerFileBrowserState = { open: true, loading: false, path, data: response.data || {}, error: "" };
5864
+ renderAppRunnerInfoDialog();
5865
+ } catch (error) {
5866
+ if (!isCurrentTabContext(tabContext)) return;
5867
+ appRunnerFileBrowserState = { open: true, loading: false, path, data: null, error: error.message || String(error) };
5868
+ renderAppRunnerInfoDialog();
5869
+ }
5870
+ }
5871
+
5872
+ function renderAppRunnerFileBrowser() {
5873
+ if (!appRunnerFileBrowserState.open) return null;
5874
+ const browser = make("div", "app-runner-file-browser");
5875
+ if (appRunnerFileBrowserState.loading) {
5876
+ browser.append(make("div", "muted", "Loading project files…"));
5877
+ return browser;
5878
+ }
5879
+ if (appRunnerFileBrowserState.error) {
5880
+ browser.append(make("div", "path-picker-error", appRunnerFileBrowserState.error));
5881
+ return browser;
5882
+ }
5883
+ const data = appRunnerFileBrowserState.data || {};
5884
+ const header = make("div", "app-runner-file-browser-header");
5885
+ header.append(make("strong", "", data.displayRelativeDir || "."));
5886
+ const close = make("button", "app-runner-file-browser-close", "Hide browser");
5887
+ close.type = "button";
5888
+ close.addEventListener("click", () => {
5889
+ appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
5890
+ renderAppRunnerInfoDialog();
5891
+ });
5892
+ header.append(close);
5893
+ browser.append(header);
5894
+
5895
+ const roots = make("div", "path-picker-roots app-runner-file-browser-roots");
5896
+ if (data.parent !== null && data.parent !== undefined) roots.append(pathPickerButton("↑ Parent", data.parent || ".", () => loadAppRunnerFileBrowser(data.parent || ""), "path-picker-root-button"));
5897
+ roots.append(pathPickerButton("Project root", data.displayProjectRoot || "Project root", () => loadAppRunnerFileBrowser(""), "path-picker-root-button"));
5898
+ browser.append(roots);
5899
+
5900
+ const list = make("div", "path-picker-list app-runner-file-browser-list");
5901
+ const directories = Array.isArray(data.directories) ? data.directories : [];
5902
+ const files = Array.isArray(data.files) ? data.files : [];
5903
+ for (const directory of directories) {
5904
+ const button = pathPickerButton(`${directory.name}/`, directory.path, () => loadAppRunnerFileBrowser(directory.path), `path-picker-directory${directory.hidden ? " hidden-directory" : ""}`);
5905
+ list.append(button);
5906
+ }
5907
+ for (const file of files) {
5908
+ const button = pathPickerButton(file.name, file.path, () => {
5909
+ appRunnerCustomDraft.path = file.path;
5910
+ appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
5911
+ renderAppRunnerInfoDialog();
5912
+ }, `path-picker-directory app-runner-file-choice${file.hidden ? " hidden-directory" : ""}`);
5913
+ list.append(button);
5914
+ }
5915
+ if (!directories.length && !files.length) list.append(make("div", "path-picker-empty muted", "No files in this directory."));
5916
+ browser.append(list);
5917
+ if (data.truncated) browser.append(make("div", "path-picker-error", "Showing the first project entries only."));
5918
+ return browser;
5919
+ }
5920
+
5921
+ function renderAppRunnerCustomSection() {
5922
+ const config = activeAppRunnerCustomConfig();
5923
+ const section = make("section", "app-runner-info-section app-runner-custom-section");
5924
+ const titleRow = make("div", "app-runner-section-title-row");
5925
+ titleRow.append(make("h3", "", "Custom project runners"));
5926
+ if (config.displayConfigFile) titleRow.append(make("code", "", config.displayConfigFile));
5927
+ section.append(titleRow);
5928
+ section.append(make("p", "muted", "Add project-local runners saved in .pi-webui-runners.json. Command defaults to ./, so a selected file runs as ./path/to/file."));
5929
+
5930
+ const existing = make("div", "app-runner-custom-list");
5931
+ const customRunners = Array.isArray(config.runners) ? config.runners : [];
5932
+ if (!customRunners.length) {
5933
+ existing.append(make("div", "app-runner-custom-empty muted", "No custom runners saved for this project yet."));
5934
+ } else {
5935
+ for (const runner of customRunners) {
5936
+ const row = make("div", "app-runner-custom-item");
5937
+ const details = make("div", "app-runner-custom-item-details");
5938
+ details.append(make("strong", "", runner.label || runner.path || "custom runner"), make("code", "", runner.displayCommand || runner.path || ""));
5939
+ const actions = make("div", "app-runner-custom-item-actions");
5940
+ const edit = make("button", "", "Edit");
5941
+ edit.type = "button";
5942
+ edit.addEventListener("click", () => {
5943
+ appRunnerCustomDraft = { id: runner.id || "", label: runner.label || "", command: runner.command || "./", path: runner.path || "", args: appRunnerCustomArgsText(runner.args) };
5944
+ appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
5945
+ renderAppRunnerInfoDialog();
5946
+ });
5947
+ const remove = make("button", "danger", "Delete");
5948
+ remove.type = "button";
5949
+ remove.addEventListener("click", () => {
5950
+ if (!confirm(`Delete custom app runner “${runner.label || runner.path || runner.id}”?`)) return;
5951
+ deleteAppRunnerCustomRunner(runner.id);
5952
+ });
5953
+ actions.append(edit, remove);
5954
+ row.append(details, actions);
5955
+ existing.append(row);
5956
+ }
5957
+ }
5958
+ section.append(existing);
5959
+
5960
+ const form = make("div", "app-runner-custom-form");
5961
+ const labelField = appRunnerInputField({ id: "appRunnerCustomLabelInput", label: "Label", value: appRunnerCustomDraft.label, placeholder: "My app" });
5962
+ const commandField = appRunnerInputField({ id: "appRunnerCustomCommandInput", label: "Command", value: appRunnerCustomDraft.command || "./", placeholder: "./", hint: "Use ./ to execute the selected file directly, or use bash, python3, node, bun, uv run, etc." });
5963
+ const pathField = appRunnerInputField({ id: "appRunnerCustomPathInput", label: "Path to file", value: appRunnerCustomDraft.path, placeholder: "dev/scripts/start.sh" });
5964
+ const pathRow = make("div", "app-runner-custom-path-row");
5965
+ pathRow.append(pathField.field);
5966
+ const browse = make("button", "app-runner-custom-browse", "Browse…");
5967
+ browse.type = "button";
5968
+ browse.addEventListener("click", () => {
5969
+ updateAppRunnerCustomDraftFrom(form);
5970
+ loadAppRunnerFileBrowser(appRunnerRelativeDir(appRunnerCustomDraft.path));
5971
+ });
5972
+ pathRow.append(browse);
5973
+ const argsField = appRunnerInputField({ id: "appRunnerCustomArgsInput", label: "Args", value: appRunnerCustomDraft.args, placeholder: "--port 3000", hint: "Optional extra args, space-separated." });
5974
+ form.append(labelField.field, commandField.field, pathRow, argsField.field);
5975
+ const formActions = make("div", "app-runner-custom-form-actions");
5976
+ const save = make("button", "primary", appRunnerCustomDraft.id ? "Save changes" : "Add runner");
5977
+ save.type = "button";
5978
+ save.addEventListener("click", () => saveAppRunnerCustomRunner(form));
5979
+ const reset = make("button", "", "Reset");
5980
+ reset.type = "button";
5981
+ reset.addEventListener("click", () => { resetAppRunnerCustomDraft(); renderAppRunnerInfoDialog(); });
5982
+ formActions.append(save, reset);
5983
+ form.append(formActions);
5984
+ const browser = renderAppRunnerFileBrowser();
5985
+ if (browser) form.append(browser);
5986
+ section.append(form);
5987
+ return section;
5988
+ }
5989
+
5990
+ function renderAppRunnerControls() {
5991
+ const menu = elements.appRunnerMenu;
5992
+ const button = elements.appRunnerMenuButton;
5993
+ const panel = elements.appRunnerMenuPanel;
5994
+ if (!menu || !button || !panel) return;
5995
+ const data = activeAppRunnerData();
5996
+ const runners = Array.isArray(data.runners) ? data.runners : [];
5997
+ const activeRun = data.activeRun;
5998
+ const running = appRunnerIsRunning(activeRun);
5999
+ menu.hidden = false;
6000
+ menu.classList.toggle("has-runners", runners.length > 0);
6001
+ if (elements.appRunnerInfoButton) {
6002
+ elements.appRunnerInfoButton.hidden = runners.length === 0;
6003
+ elements.appRunnerInfoButton.disabled = runners.length === 0;
6004
+ }
6005
+ button.disabled = running;
6006
+ button.title = running
6007
+ ? `App runner already running: ${activeRun.displayCommand || activeRun.label || "runner"}`
6008
+ : runners.length
6009
+ ? "Run a detected app runner"
6010
+ : "No app runners detected in this tab working directory";
6011
+ button.dataset.tooltip = runners.length ? "App runners: run detected project commands in this tab's working directory." : APP_RUNNER_SUPPORTED_TOOLTIP;
6012
+ button.setAttribute("aria-label", button.title);
6013
+ if (!runners.length || running) setAppRunnerMenuOpen(false);
6014
+
6015
+ panel.replaceChildren();
6016
+ for (const runner of runners) {
6017
+ const item = make("button", "composer-publish-menu-item composer-app-runner-menu-item");
6018
+ item.type = "button";
6019
+ item.setAttribute("role", "menuitem");
6020
+ const runnerDisplayCommand = runner.shortDisplayCommand || runner.displayCommand;
6021
+ item.title = runner.description ? `${runnerDisplayCommand}\n${runner.description}` : runnerDisplayCommand;
6022
+ item.addEventListener("click", () => runAppRunner(runner.id));
6023
+ const label = make("span", "app-runner-menu-item-label", runner.label || runnerDisplayCommand);
6024
+ const command = make("span", "app-runner-menu-item-command", runnerDisplayCommand);
6025
+ item.append(label, command);
6026
+ panel.append(item);
6027
+ }
6028
+ }
6029
+
6030
+ function renderAppRunnerInfoDialog() {
6031
+ const body = elements.appRunnerInfoBody;
6032
+ if (!body) return;
6033
+ const data = activeAppRunnerData();
6034
+ const runners = Array.isArray(data.runners) ? data.runners : [];
6035
+ body.replaceChildren();
6036
+
6037
+ const current = make("section", "app-runner-info-section");
6038
+ current.append(make("h3", "", "Detected in this tab"));
6039
+ if (runners.length) {
6040
+ const list = make("ul", "app-runner-info-list app-runner-info-detected-list");
6041
+ for (const runner of runners) {
6042
+ const command = runner.shortDisplayCommand || runner.displayCommand || runner.command || runner.id;
6043
+ const item = make("li");
6044
+ item.append(
6045
+ make("strong", "", runner.label || command || "runner"),
6046
+ make("code", "", command || "detected command"),
6047
+ );
6048
+ if (runner.description) item.append(make("span", "", runner.description));
6049
+ list.append(item);
6050
+ }
6051
+ current.append(list);
6052
+ } else {
6053
+ current.append(make("p", "muted", "No runners are currently detected for this tab working directory."));
6054
+ }
6055
+
6056
+ const how = make("section", "app-runner-info-section");
6057
+ how.append(make("h3", "", "How it works"));
6058
+ const howList = make("ul", "app-runner-info-list");
6059
+ for (const line of [
6060
+ "Detection is scoped to the active terminal tab's current working directory.",
6061
+ "Only commands/files that exist and runner binaries available on this system are shown.",
6062
+ "Starting a runner keeps live output pinned above the chat/terminal area.",
6063
+ "Only one app runner can be active per tab; Close/Stop terminates the process/server.",
6064
+ ]) howList.append(make("li", "", line));
6065
+ how.append(howList);
6066
+
6067
+ const supported = make("section", "app-runner-info-section");
6068
+ supported.append(make("h3", "", "Supported runner types"));
6069
+ const supportedList = make("ul", "app-runner-info-list app-runner-info-supported-list");
6070
+ for (const itemText of APP_RUNNER_SUPPORTED_ITEMS) supportedList.append(make("li", "", itemText));
6071
+ supported.append(supportedList);
6072
+
6073
+ body.append(current, renderAppRunnerCustomSection(), how, supported);
6074
+ }
6075
+
6076
+ function openAppRunnerInfoDialog() {
6077
+ if (!elements.appRunnerInfoDialog) return;
6078
+ renderAppRunnerInfoDialog();
6079
+ setAppRunnerMenuOpen(false);
6080
+ if (!elements.appRunnerInfoDialog.open) elements.appRunnerInfoDialog.showModal();
6081
+ }
6082
+
6083
+ function closeAppRunnerInfoDialog() {
6084
+ if (elements.appRunnerInfoDialog?.open) elements.appRunnerInfoDialog.close();
6085
+ }
6086
+
6087
+ function renderAppRunnerWidget() {
6088
+ const data = activeAppRunnerData();
6089
+ const run = data.activeRun;
6090
+ if (!run) return null;
6091
+ const running = appRunnerIsRunning(run);
6092
+ const status = appRunnerStatusLabel(run);
6093
+ const node = make("section", `widget release-npm-widget app-runner-widget${running ? " app-runner-live-widget" : " app-runner-log-widget"}`);
6094
+ node.setAttribute("aria-label", "app runner output");
6095
+
6096
+ const header = make("div", "release-npm-header");
6097
+ const titleWrap = make("div", "release-npm-title-wrap");
6098
+ titleWrap.append(make("span", "release-npm-kicker", "app runner"), make("strong", "release-npm-title", run.label || run.displayCommand || "app runner"));
6099
+
6100
+ const elapsed = appRunnerElapsedLabel(run);
6101
+ header.append(titleWrap);
6102
+
6103
+ const lines = Array.isArray(run.lines) && run.lines.length ? run.lines : [run.displayCommand ? `$ ${run.displayCommand}` : "Waiting for app runner output..."];
6104
+ const streamHeader = releaseNpmStreamHeader(running ? "Live app output" : "App output", run.lineCount || lines.length, { live: running });
6105
+ const terminal = make("div", "release-npm-terminal");
6106
+ terminal.setAttribute("role", "log");
6107
+ terminal.setAttribute("aria-live", running ? "polite" : "off");
6108
+ for (const line of lines) appendReleaseNpmTerminalLine(terminal, line);
6109
+
6110
+ const controlParts = [run.displayCommand, run.cwd, run.truncated ? "output truncated" : ""].map(cleanStatusText).filter(Boolean);
6111
+ const controls = make("div", "release-npm-controls app-runner-output-controls");
6112
+ const actions = make("div", "app-runner-output-actions");
6113
+ const closeButton = appRunnerActionButton("Close", running ? stopAppRunner : clearAppRunner, running ? "danger app-runner-close-action" : "app-runner-close-action");
6114
+ closeButton.title = running ? "Stop this app runner/process/server" : "Close app runner output";
6115
+ const copyButton = appRunnerActionButton("Copy output", () => copyAppRunnerOutput(run), "app-runner-copy-action");
6116
+ copyButton.title = "Copy app runner output";
6117
+ actions.append(closeButton, copyButton);
6118
+ if (running) {
6119
+ actions.append(appRunnerActionButton("Stop", stopAppRunner, "danger"));
6120
+ } else {
6121
+ const canRunAgain = (data.runners || []).some((runner) => runner.id === run.runnerId);
6122
+ if (canRunAgain) actions.append(appRunnerActionButton("Run again", () => runAppRunner(run.runnerId)));
6123
+ actions.append(appRunnerActionButton("Clear", clearAppRunner));
6124
+ }
6125
+ const pills = make("div", "app-runner-output-pills");
6126
+ if (run.kind) pills.append(make("span", "release-npm-pill", run.kind));
6127
+ pills.append(make("span", `release-npm-pill app-runner-status ${run.status || "running"}`.trim(), status));
6128
+ if (elapsed) pills.append(make("span", "release-npm-pill elapsed", elapsed));
6129
+ controls.append(actions, pills, make("span", "app-runner-output-meta", controlParts.join(" · ")));
6130
+ const outputDetails = renderReleaseNpmOutputDetails(`app-runner:${run.id || run.runnerId || "active"}`, streamHeader, terminal, controls);
6131
+ node.append(header, outputDetails);
6132
+ requestAnimationFrame(() => { if (outputDetails.open) terminal.scrollTop = terminal.scrollHeight; });
6133
+ return node;
6134
+ }
6135
+
5209
6136
  function renderWidgets() {
5210
6137
  elements.widgetArea.replaceChildren();
5211
6138
  const releaseOutput = renderReleaseNpmOutputWidget();
@@ -5216,6 +6143,8 @@ function renderWidgets() {
5216
6143
  if (releaseAurOutput) elements.widgetArea.append(releaseAurOutput);
5217
6144
  const releaseAurLog = renderReleaseAurLogWidget();
5218
6145
  if (releaseAurLog) elements.widgetArea.append(releaseAurLog);
6146
+ const appRunnerWidget = renderAppRunnerWidget();
6147
+ if (appRunnerWidget) elements.widgetArea.append(appRunnerWidget);
5219
6148
 
5220
6149
  for (const [key, value] of widgets) {
5221
6150
  const widgetFeatureId = optionalFeatureWidgetFeatureId(key);
@@ -5239,6 +6168,8 @@ function setGitWorkflow(patch, { tabId = activeTabId } = {}) {
5239
6168
  const workflow = gitWorkflowForTab(tabId);
5240
6169
  if (!workflow) return null;
5241
6170
  Object.assign(workflow, patch);
6171
+ workflow.actionsDone = createGitWorkflowActionsDone(workflow.actionsDone);
6172
+ if (patch.step && !("process" in patch)) workflow.process = gitWorkflowProcessForStep(workflow.step, workflow.process);
5242
6173
  if (tabId === activeTabId) {
5243
6174
  gitWorkflow = workflow;
5244
6175
  renderGitWorkflow();
@@ -5274,24 +6205,141 @@ function formatCommitMessagePreview(message) {
5274
6205
  return [`=== SHORT ===`, message.short || "(empty)", "", "=== LONG ===", message.long || "(empty)"].join("\n");
5275
6206
  }
5276
6207
 
5277
- function addGitWorkflowAction(label, handler, className = "", disabled = gitWorkflow.busy) {
6208
+ function gitWorkflowMessageTitle(message) {
6209
+ return String(message?.short || message?.long || "").split("\n").find((line) => line.trim())?.trim() || "Pull request";
6210
+ }
6211
+
6212
+ function slugifyGitBranchPart(value) {
6213
+ return String(value || "")
6214
+ .normalize("NFKD")
6215
+ .replace(/[\u0300-\u036f]/g, "")
6216
+ .toLowerCase()
6217
+ .replace(/[^a-z0-9]+/g, "-")
6218
+ .replace(/^-+|-+$/g, "")
6219
+ .slice(0, 48);
6220
+ }
6221
+
6222
+ function defaultGitPrBranchName(message = gitWorkflow.message) {
6223
+ const title = gitWorkflowMessageTitle(message);
6224
+ const match = title.match(/^([a-z][a-z0-9-]*)(?:\([^)]*\))?:\s*(.+)$/i);
6225
+ const type = slugifyGitBranchPart(match?.[1] || "feat") || "feat";
6226
+ const summary = slugifyGitBranchPart(match?.[2] || title) || "feature";
6227
+ return `${type}/${summary}`;
6228
+ }
6229
+
6230
+ function formatGitPrPreview(pr) {
6231
+ if (!pr) return "No PR description loaded yet.";
6232
+ const header = [`=== PR DESCRIPTION ===`, `Branch: ${pr.branch || gitWorkflow.prBranch || "current branch"}`];
6233
+ if (pr.path) header.push(`File: ${pr.path}`);
6234
+ return [...header, "", pr.body || "(empty)"].join("\n");
6235
+ }
6236
+
6237
+ function addGitWorkflowAction(label, handler, className = "", disabled = gitWorkflow.busy, tooltip = "") {
5278
6238
  const button = make("button", className, label);
5279
6239
  button.type = "button";
5280
6240
  button.disabled = disabled;
6241
+ if (tooltip) {
6242
+ button.title = tooltip;
6243
+ button.dataset.tooltip = tooltip;
6244
+ button.setAttribute("aria-label", `${label}. ${tooltip.replace(/\s+/g, " ")}`);
6245
+ }
5281
6246
  button.addEventListener("click", handler);
5282
6247
  elements.gitWorkflowActions.append(button);
5283
6248
  return button;
5284
6249
  }
5285
6250
 
6251
+ function setGitPrDialogStatus(message = "", level = "muted") {
6252
+ if (!elements.gitPrStatus) return;
6253
+ elements.gitPrStatus.textContent = message;
6254
+ elements.gitPrStatus.className = `git-pr-status ${level || "muted"}`;
6255
+ }
6256
+
6257
+ function resolveGitPrDialog(value) {
6258
+ const resolve = activeGitPrDialogResolve;
6259
+ activeGitPrDialogResolve = null;
6260
+ if (elements.gitPrDialog?.open) elements.gitPrDialog.close();
6261
+ if (resolve) resolve(value);
6262
+ }
6263
+
6264
+ function openGitPrReviewDialog(pr, { title = "" } = {}) {
6265
+ if (!elements.gitPrDialog || !elements.gitPrTitleInput || !elements.gitPrBodyEditor) return Promise.resolve(null);
6266
+ if (activeGitPrDialogResolve) resolveGitPrDialog(null);
6267
+ elements.gitPrTitleInput.value = title || gitWorkflowMessageTitle(gitWorkflow.message);
6268
+ elements.gitPrBodyEditor.value = pr?.body || "";
6269
+ setGitPrDialogStatus(`Review ${pr?.path || "the generated PR description"}. Edit if needed, then create the pull request.`);
6270
+ return new Promise((resolve) => {
6271
+ activeGitPrDialogResolve = resolve;
6272
+ elements.gitPrDialog.showModal();
6273
+ queueMicrotask(() => elements.gitPrBodyEditor.focus());
6274
+ });
6275
+ }
6276
+
6277
+ function gitWorkflowProcessForStep(step = gitWorkflow.step, fallback = gitWorkflow.process || "stage") {
6278
+ switch (step) {
6279
+ case "generate":
6280
+ case "generating":
6281
+ return "message";
6282
+ case "message":
6283
+ case "branchNaming":
6284
+ case "branching":
6285
+ case "committing":
6286
+ return "commit";
6287
+ case "push":
6288
+ case "pushing":
6289
+ case "prGenerating":
6290
+ case "prReview":
6291
+ case "prCreating":
6292
+ case "done":
6293
+ return "push";
6294
+ case "add":
6295
+ case "idle":
6296
+ return "stage";
6297
+ case "cancelled":
6298
+ case "error":
6299
+ return GIT_WORKFLOW_PROCESS_VALUES.has(fallback) ? fallback : "stage";
6300
+ default:
6301
+ return "stage";
6302
+ }
6303
+ }
6304
+
6305
+ function selectGitWorkflowProcess(processValue, tabId = gitWorkflowActionTabId()) {
6306
+ const workflow = gitWorkflowForTab(tabId);
6307
+ if (!workflow) return;
6308
+ const process = GIT_WORKFLOW_PROCESS_VALUES.has(processValue) ? processValue : "stage";
6309
+ workflow.runId += 1;
6310
+ const runId = workflow.runId;
6311
+ const base = { active: true, process, busy: false, error: "", messageRequestedAt: 0, branchName: "", branchNameRequestedAt: 0, prMode: false, prBranch: "", pr: null, prRequestedAt: 0 };
6312
+
6313
+ if (process === "stage") {
6314
+ setGitWorkflow({ ...base, step: "add", message: null, output: "Ready to stage all changes with git add ." }, { tabId });
6315
+ return;
6316
+ }
6317
+ if (process === "message") {
6318
+ setGitWorkflow({ ...base, step: "generate", message: null, output: "Ready to generate a commit message from the currently staged changes." }, { tabId });
6319
+ return;
6320
+ }
6321
+ if (process === "commit") {
6322
+ setGitWorkflow({ ...base, step: "message", message: null, output: "Loading current generated commit message files…" }, { tabId });
6323
+ loadGitWorkflowMessage({ requireFresh: false, runId, tabId });
6324
+ return;
6325
+ }
6326
+ setGitWorkflow({ ...base, step: "push", output: "Ready to run git push for the current branch." }, { tabId });
6327
+ }
6328
+
5286
6329
  function gitWorkflowTitle() {
5287
6330
  switch (gitWorkflow.step) {
5288
6331
  case "add": return "Stage all changes";
5289
6332
  case "generate": return "Generate staged commit message";
5290
6333
  case "generating": return "Waiting for /git-staged-msg";
5291
- case "message": return "Choose commit message";
6334
+ case "message": return gitWorkflow.prMode ? "Choose PR branch commit message" : "Choose commit message";
6335
+ case "branchNaming": return "Waiting for branch name";
6336
+ case "branching": return "Creating PR branch";
5292
6337
  case "committing": return "Committing";
5293
- case "push": return "Push commit";
6338
+ case "push": return gitWorkflow.prMode ? "Push branch and create PR" : "Push commit";
5294
6339
  case "pushing": return "Pushing";
6340
+ case "prGenerating": return "Waiting for /pr";
6341
+ case "prReview": return "Review PR description";
6342
+ case "prCreating": return "Creating pull request";
5295
6343
  case "done": return "Git workflow complete";
5296
6344
  case "cancelled": return "Git workflow cancelled";
5297
6345
  case "error": return "Git workflow needs attention";
@@ -5304,11 +6352,16 @@ function gitWorkflowHint() {
5304
6352
  case "add": return "Step 1: run git add . in the current Pi working directory.";
5305
6353
  case "generate": return "Step 2: run /git-staged-msg, then preview the generated files.";
5306
6354
  case "generating": return "Pi is generating dev/COMMIT/staged-commit-short.txt and staged-commit-long.txt.";
5307
- case "message": return "Step 3/4: preview the native g-msg output and choose short or long commit.";
6355
+ case "message": return gitWorkflow.prMode ? `Branch ${gitWorkflow.prBranch || "created"}: choose short or long commit before opening a PR.` : "Step 3/4: preview the native g-msg output, commit here, or create a PR branch first.";
6356
+ case "branchNaming": return "Pi is generating dev/COMMIT/staged-branch-name.txt. Cancel will request Pi abort.";
6357
+ case "branching": return "Creating a new branch with git switch -c before committing.";
5308
6358
  case "committing": return "Running native git commit from the generated message file.";
5309
- case "push": return "Step 5: push the new commit to the configured remote.";
6359
+ case "push": return gitWorkflow.prMode ? "Push the PR branch, generate /pr, review the description, then create the pull request." : "Step 5: push the new commit to the configured remote.";
5310
6360
  case "pushing": return "Running git push. Cancel will request process termination.";
5311
- case "done": return "Push finished. Review the output below.";
6361
+ case "prGenerating": return "Pi is generating dev/PR/<current-branch>.md with /pr.";
6362
+ case "prReview": return "Review or edit the generated PR description before creating the pull request.";
6363
+ case "prCreating": return "Running gh pr create with the confirmed description.";
6364
+ case "done": return gitWorkflow.prMode ? "PR workflow finished. Review the output below." : "Push finished. Review the output below.";
5312
6365
  case "cancelled": return "No further workflow steps will run.";
5313
6366
  case "error": return gitWorkflow.error || "Fix the issue, then retry or restart.";
5314
6367
  default: return "Stage changes, generate a commit message, commit, and push.";
@@ -5326,9 +6379,14 @@ function renderGitWorkflow() {
5326
6379
  elements.gitWorkflowActions.replaceChildren();
5327
6380
 
5328
6381
  const activeIndex = GIT_WORKFLOW_ACTIVE_INDEX[gitWorkflow.step] ?? 0;
5329
- for (const [index, label] of GIT_WORKFLOW_STEPS.entries()) {
5330
- const item = make("span", "git-workflow-step", label);
5331
- if (gitWorkflow.step === "done" || index < activeIndex) item.classList.add("done");
6382
+ const activeProcess = gitWorkflowProcessForStep(gitWorkflow.step, gitWorkflow.process);
6383
+ for (const [index, process] of GIT_WORKFLOW_PROCESSES.entries()) {
6384
+ const item = make("button", "git-workflow-step", process.label);
6385
+ item.type = "button";
6386
+ item.dataset.gitWorkflowProcess = process.value;
6387
+ item.disabled = !!gitWorkflow.busy;
6388
+ item.setAttribute("aria-pressed", String(process.value === activeProcess));
6389
+ if (gitWorkflowActionDone(gitWorkflow, process.value)) item.classList.add("done");
5332
6390
  if (index === activeIndex && !["done", "cancelled", "error"].includes(gitWorkflow.step)) item.classList.add("active");
5333
6391
  elements.gitWorkflowSteps.append(item);
5334
6392
  }
@@ -5344,11 +6402,28 @@ function renderGitWorkflow() {
5344
6402
  } else if (gitWorkflow.step === "generating") {
5345
6403
  addGitWorkflowAction("Refresh message preview", () => loadGitWorkflowMessage({ requireFresh: true }), "", false);
5346
6404
  } else if (gitWorkflow.step === "message") {
5347
- addGitWorkflowAction("Commit short", () => commitGitWorkflow("short"), "primary", false);
5348
- addGitWorkflowAction("Commit long", () => commitGitWorkflow("long"), "primary", false);
6405
+ if (!gitWorkflow.prMode) {
6406
+ addGitWorkflowAction("Create PR", () => createGitPrBranch(), "primary", false, GIT_WORKFLOW_CREATE_PR_TOOLTIP);
6407
+ addGitWorkflowAction("Manual branch", () => createGitPrBranchManually(), "", false, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP);
6408
+ }
6409
+ addGitWorkflowAction("Commit short", () => commitGitWorkflow("short"), gitWorkflow.prMode ? "primary" : "", false);
6410
+ addGitWorkflowAction("Commit long", () => commitGitWorkflow("long"), gitWorkflow.prMode ? "primary" : "", false);
5349
6411
  addGitWorkflowAction("Regenerate", () => runGitMessagePrompt(), "", false);
6412
+ } else if (gitWorkflow.step === "branchNaming") {
6413
+ addGitWorkflowAction("Refresh branch name", () => loadGitWorkflowBranchName({ requireFresh: true }), "", false);
6414
+ addGitWorkflowAction("Manual branch", () => createGitPrBranchManually(), "", !!gitWorkflow.busy, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP);
6415
+ } else if (gitWorkflow.step === "branching") {
6416
+ addGitWorkflowAction("Creating branch…", () => {}, "primary", true);
5350
6417
  } else if (gitWorkflow.step === "push") {
5351
- addGitWorkflowAction("Run git push", () => pushGitWorkflow(), "primary", false);
6418
+ if (gitWorkflow.prMode) addGitWorkflowAction("Push and Create PR", () => pushAndCreatePrGitWorkflow(), "primary", false);
6419
+ else addGitWorkflowAction("Run git push", () => pushGitWorkflow(), "primary", false);
6420
+ } else if (gitWorkflow.step === "prGenerating") {
6421
+ addGitWorkflowAction("Refresh PR description", () => loadGitWorkflowPr({ requireFresh: true }), "", false);
6422
+ } else if (gitWorkflow.step === "prReview") {
6423
+ addGitWorkflowAction("Create PR", () => createGitPrFromReview(), "primary", false);
6424
+ addGitWorkflowAction("Regenerate /pr", () => runGitPrPrompt(), "", false);
6425
+ } else if (gitWorkflow.step === "prCreating") {
6426
+ addGitWorkflowAction("Creating PR…", () => {}, "primary", true);
5352
6427
  } else if (gitWorkflow.step === "done") {
5353
6428
  addGitWorkflowAction("Close", () => setGitWorkflow({ active: false }), "primary", false);
5354
6429
  addGitWorkflowAction("Start another", () => startGitWorkflow(), "", false);
@@ -5398,11 +6473,19 @@ function startGitWorkflow(tabId = activeTabId) {
5398
6473
  setGitWorkflow({
5399
6474
  active: true,
5400
6475
  step: "add",
6476
+ process: "stage",
5401
6477
  busy: false,
5402
- output: "Ready to stage all changes with git add .\n\nNative mode is used for g-msg/g-short/g-long: dev/COMMIT message files are read directly and git commit is run without fish.",
6478
+ output: "Ready to stage all changes with git add .\n\nNative mode is used for g-msg/g-short/g-long: dev/COMMIT message files are read directly and git commit is run without fish. After the message is generated, use Create PR to switch to a new branch before committing.",
5403
6479
  error: "",
5404
6480
  message: null,
5405
6481
  messageRequestedAt: 0,
6482
+ branchName: "",
6483
+ branchNameRequestedAt: 0,
6484
+ actionsDone: createGitWorkflowActionsDone(),
6485
+ prMode: false,
6486
+ prBranch: "",
6487
+ pr: null,
6488
+ prRequestedAt: 0,
5406
6489
  }, { tabId });
5407
6490
  }
5408
6491
 
@@ -5410,7 +6493,8 @@ async function cancelGitWorkflow(tabId = gitWorkflowActionTabId()) {
5410
6493
  const tabContext = activeTabContext(tabId);
5411
6494
  const workflow = gitWorkflowForTab(tabId, { create: false });
5412
6495
  if (!workflow?.active) return;
5413
- const shouldAbortPi = workflow.step === "generating";
6496
+ const shouldAbortPi = workflow.step === "generating" || workflow.step === "branchNaming" || workflow.step === "prGenerating";
6497
+ if (activeGitPrDialogResolve) resolveGitPrDialog(null);
5414
6498
  workflow.runId += 1;
5415
6499
  setGitWorkflow({ step: "cancelled", busy: false, error: "", output: `${workflow.output || ""}${workflow.output ? "\n\n" : ""}Cancelled by user.` }, { tabId });
5416
6500
  if (shouldAbortPi && isCurrentTabContext(tabContext)) setRunIndicatorActivity("Abort requested; checking whether Pi stopped…");
@@ -5430,7 +6514,7 @@ async function runGitAdd(tabId = gitWorkflowActionTabId()) {
5430
6514
  try {
5431
6515
  const result = await gitWorkflowRequest("/api/git-workflow/add", { runId, tabId });
5432
6516
  if (!result) return;
5433
- setGitWorkflow({ step: "generate", busy: false, output: `${formatGitCommandResult(result)}\n\nStaged. Next: run /git-staged-msg.` }, { tabId });
6517
+ setGitWorkflow({ step: "generate", busy: false, ...gitWorkflowActionDonePatch(workflow, "stage"), output: `${formatGitCommandResult(result)}\n\nStaged. Next: run /git-staged-msg.` }, { tabId });
5434
6518
  if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
5435
6519
  } catch (error) {
5436
6520
  if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "add", { tabId });
@@ -5494,6 +6578,7 @@ async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId
5494
6578
  busy: false,
5495
6579
  error: "",
5496
6580
  message,
6581
+ ...(requireFresh && currentWorkflow.messageRequestedAt ? gitWorkflowActionDonePatch(currentWorkflow, "message") : {}),
5497
6582
  output: formatCommitMessagePreview(message),
5498
6583
  }, { tabId });
5499
6584
  } catch (error) {
@@ -5507,6 +6592,130 @@ async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId
5507
6592
  }
5508
6593
  }
5509
6594
 
6595
+ function gitBranchNamePromptMessage() {
6596
+ if (hasAvailableCommand("git-branch-name")) return "/git-branch-name";
6597
+ return [
6598
+ "Generate one PR branch name for the current staged work.",
6599
+ "Inspect only staged changes (`git diff --cached`) and the generated commit message files if present:",
6600
+ "- dev/COMMIT/staged-commit-short.txt",
6601
+ "- dev/COMMIT/staged-commit-long.txt",
6602
+ "",
6603
+ "Write exactly one line to dev/COMMIT/staged-branch-name.txt in this format:",
6604
+ "<type>/<short-feature-name>",
6605
+ "",
6606
+ "Rules: use lowercase kebab-case, no spaces/underscores/uppercase/trailing punctuation, 2-5 words after the slash, and no extra lines or prose in the file.",
6607
+ ].join("\n");
6608
+ }
6609
+
6610
+ async function createGitPrBranch(tabId = gitWorkflowActionTabId()) {
6611
+ await runGitBranchNamePrompt(tabId);
6612
+ }
6613
+
6614
+ async function createGitPrBranchManually(tabId = gitWorkflowActionTabId()) {
6615
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6616
+ if (!workflow) return;
6617
+ await createGitPrBranchWithSuggestion(workflow.branchName || defaultGitPrBranchName(workflow.message), tabId);
6618
+ }
6619
+
6620
+ async function runGitBranchNamePrompt(tabId = gitWorkflowActionTabId()) {
6621
+ const tabContext = activeTabContext(tabId);
6622
+ const targetTab = tabs.find((tab) => tab.id === tabId);
6623
+ const targetBusy = tabId === activeTabId ? !!currentState?.isStreaming : activityForTab(targetTab).isWorking;
6624
+ if (targetBusy) {
6625
+ failGitWorkflow(new Error("Pi is currently running. Wait for it to finish or abort before generating a branch name."), "message", { tabId });
6626
+ return;
6627
+ }
6628
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6629
+ if (!workflow) return;
6630
+ const runId = workflow.runId;
6631
+ const requestedAt = Date.now();
6632
+ setGitWorkflow({
6633
+ step: "branchNaming",
6634
+ busy: true,
6635
+ error: "",
6636
+ branchNameRequestedAt: requestedAt,
6637
+ output: "Sending branch-name request to Pi.\n\nCancel will request Pi abort.",
6638
+ }, { tabId });
6639
+ if (isCurrentTabContext(tabContext)) setRunIndicatorActivity("Sending branch-name request to Pi…");
6640
+ try {
6641
+ await api("/api/prompt", { method: "POST", body: { message: gitBranchNamePromptMessage() }, tabId });
6642
+ if (!isCurrentGitWorkflowRun(runId, tabId)) return;
6643
+ appendGitWorkflowOutput("Branch-name request accepted. Waiting for agent_end, then the branch name will be loaded.", { tabId });
6644
+ if (isCurrentTabContext(tabContext)) scheduleRefreshState(120, tabContext);
6645
+ setTimeout(() => {
6646
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6647
+ const targetStillBusy = tabId === activeTabId && currentState?.isStreaming;
6648
+ if (isCurrentGitWorkflowRun(runId, tabId) && currentWorkflow?.step === "branchNaming" && !targetStillBusy) {
6649
+ loadGitWorkflowBranchName({ requireFresh: true, retries: 1, runId, tabId });
6650
+ }
6651
+ }, 2500);
6652
+ } catch (error) {
6653
+ if (isCurrentGitWorkflowRun(runId, tabId)) {
6654
+ if (isCurrentTabContext(tabContext)) clearRunIndicatorActivity();
6655
+ failGitWorkflow(error, "message", { tabId });
6656
+ }
6657
+ }
6658
+ }
6659
+
6660
+ async function loadGitWorkflowBranchName({ requireFresh = false, retries = 0, runId, tabId = activeTabId } = {}) {
6661
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6662
+ const expectedRunId = runId ?? workflow?.runId;
6663
+ try {
6664
+ const branchName = await gitWorkflowRequest("/api/git-workflow/branch-name", { method: "GET", runId: expectedRunId, tabId });
6665
+ if (!branchName) return;
6666
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6667
+ if (!currentWorkflow) return;
6668
+ if (requireFresh && currentWorkflow.branchNameRequestedAt && (branchName.mtimeMs || 0) + 10000 < currentWorkflow.branchNameRequestedAt) {
6669
+ throw new Error("Generated branch name has not refreshed yet.");
6670
+ }
6671
+ const branch = branchName.branch || defaultGitPrBranchName(currentWorkflow.message);
6672
+ setGitWorkflow({
6673
+ step: "message",
6674
+ busy: false,
6675
+ error: "",
6676
+ branchName: branch,
6677
+ output: `${formatCommitMessagePreview(currentWorkflow.message)}\n\nGenerated branch name: ${branch}`,
6678
+ }, { tabId });
6679
+ await createGitPrBranchWithSuggestion(branch, tabId, expectedRunId);
6680
+ } catch (error) {
6681
+ if (!isCurrentGitWorkflowRun(expectedRunId, tabId)) return;
6682
+ if (retries > 0) {
6683
+ setTimeout(() => loadGitWorkflowBranchName({ requireFresh, retries: retries - 1, runId: expectedRunId, tabId }), 1400);
6684
+ return;
6685
+ }
6686
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6687
+ failGitWorkflow(error, currentWorkflow?.step === "branchNaming" ? "message" : currentWorkflow?.step, { tabId });
6688
+ }
6689
+ }
6690
+
6691
+ async function createGitPrBranchWithSuggestion(suggestion, tabId = gitWorkflowActionTabId(), expectedRunId) {
6692
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6693
+ if (!workflow) return;
6694
+ const proposedBranch = prompt("New PR branch name (example: type/feature-name)", suggestion || defaultGitPrBranchName(workflow.message));
6695
+ if (expectedRunId !== undefined && !isCurrentGitWorkflowRun(expectedRunId, tabId)) return;
6696
+ if (proposedBranch === null) {
6697
+ setGitWorkflow({ step: "message", busy: false, output: `${formatCommitMessagePreview(workflow.message)}\n\nPR branch creation cancelled. Use Create PR to generate a branch name again or Manual branch to type one.` }, { tabId });
6698
+ return;
6699
+ }
6700
+ const branch = proposedBranch.trim();
6701
+ if (!branch) {
6702
+ failGitWorkflow(new Error("Branch name is required to create a PR branch."), "message", { tabId });
6703
+ return;
6704
+ }
6705
+ const runId = workflow.runId;
6706
+ setGitWorkflow({ step: "branching", prMode: true, prBranch: branch, branchName: branch, busy: true, error: "", output: `${formatCommitMessagePreview(workflow.message)}\n\nRunning git switch -c ${branch}…` }, { tabId });
6707
+ try {
6708
+ const result = await gitWorkflowRequest("/api/git-workflow/branch", { body: { branch }, runId, tabId });
6709
+ if (!result) return;
6710
+ setGitWorkflow({ step: "message", prMode: true, prBranch: result.branch || branch, branchName: result.branch || branch, busy: false, output: `${formatGitCommandResult(result)}\n\nCreated PR branch ${result.branch || branch}. Choose Commit short or Commit long to commit on this branch.` }, { tabId });
6711
+ } catch (error) {
6712
+ if (isCurrentGitWorkflowRun(runId, tabId)) {
6713
+ setGitWorkflow({ prMode: false, prBranch: "" }, { tabId });
6714
+ failGitWorkflow(error, "message", { tabId });
6715
+ }
6716
+ }
6717
+ }
6718
+
5510
6719
  async function commitGitWorkflow(variant, tabId = gitWorkflowActionTabId()) {
5511
6720
  const tabContext = activeTabContext(tabId);
5512
6721
  const workflow = gitWorkflowForTab(tabId, { create: false });
@@ -5516,7 +6725,8 @@ async function commitGitWorkflow(variant, tabId = gitWorkflowActionTabId()) {
5516
6725
  try {
5517
6726
  const result = await gitWorkflowRequest("/api/git-workflow/commit", { body: { variant }, runId, tabId });
5518
6727
  if (!result) return;
5519
- setGitWorkflow({ step: "push", busy: false, output: `${formatGitCommandResult(result)}\n\nCommit created. Next: git push.` }, { tabId });
6728
+ const nextAction = workflow.prMode ? "Push and Create PR." : "git push.";
6729
+ setGitWorkflow({ step: "push", busy: false, ...gitWorkflowActionDonePatch(workflow, "commit"), output: `${formatGitCommandResult(result)}\n\nCommit created. Next: ${nextAction}` }, { tabId });
5520
6730
  if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
5521
6731
  } catch (error) {
5522
6732
  if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "message", { tabId });
@@ -5532,13 +6742,129 @@ async function pushGitWorkflow(tabId = gitWorkflowActionTabId()) {
5532
6742
  try {
5533
6743
  const result = await gitWorkflowRequest("/api/git-workflow/push", { runId, tabId });
5534
6744
  if (!result) return;
5535
- setGitWorkflow({ step: "done", busy: false, output: formatGitCommandResult(result) || "git push finished." }, { tabId });
6745
+ setGitWorkflow({ step: "done", busy: false, ...gitWorkflowActionDonePatch(workflow, "push"), output: formatGitCommandResult(result) || "git push finished." }, { tabId });
6746
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
6747
+ } catch (error) {
6748
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "push", { tabId });
6749
+ }
6750
+ }
6751
+
6752
+ async function runGitPrPrompt(tabId = gitWorkflowActionTabId(), { prefixOutput = "" } = {}) {
6753
+ const tabContext = activeTabContext(tabId);
6754
+ const targetTab = tabs.find((tab) => tab.id === tabId);
6755
+ const targetBusy = tabId === activeTabId ? !!currentState?.isStreaming : activityForTab(targetTab).isWorking;
6756
+ if (targetBusy) {
6757
+ failGitWorkflow(new Error("Pi is currently running. Wait for it to finish or abort before generating a PR description."), "push", { tabId });
6758
+ return;
6759
+ }
6760
+ if (!hasAvailableCommand("pr")) {
6761
+ failGitWorkflow(new Error(commandUnavailableMessage("pr")), "push", { tabId });
6762
+ return;
6763
+ }
6764
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6765
+ if (!workflow) return;
6766
+ const runId = workflow.runId;
6767
+ const requestedAt = Date.now();
6768
+ setGitWorkflow({
6769
+ step: "prGenerating",
6770
+ busy: true,
6771
+ error: "",
6772
+ prRequestedAt: requestedAt,
6773
+ output: `${prefixOutput ? `${prefixOutput}\n\n` : ""}Sending /pr to Pi.\n\nCancel will request Pi abort.`,
6774
+ }, { tabId });
6775
+ if (isCurrentTabContext(tabContext)) setRunIndicatorActivity("Sending /pr to Pi…");
6776
+ try {
6777
+ await api("/api/prompt", { method: "POST", body: { message: "/pr" }, tabId });
6778
+ if (!isCurrentGitWorkflowRun(runId, tabId)) return;
6779
+ appendGitWorkflowOutput("/pr accepted. Waiting for agent_end, then the PR description will be loaded.", { tabId });
6780
+ if (isCurrentTabContext(tabContext)) scheduleRefreshState(120, tabContext);
6781
+ setTimeout(() => {
6782
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6783
+ const targetStillBusy = tabId === activeTabId && currentState?.isStreaming;
6784
+ if (isCurrentGitWorkflowRun(runId, tabId) && currentWorkflow?.step === "prGenerating" && !targetStillBusy) {
6785
+ loadGitWorkflowPr({ requireFresh: true, retries: 1, runId, tabId });
6786
+ }
6787
+ }, 2500);
6788
+ } catch (error) {
6789
+ if (isCurrentGitWorkflowRun(runId, tabId)) {
6790
+ if (isCurrentTabContext(tabContext)) clearRunIndicatorActivity();
6791
+ failGitWorkflow(error, "push", { tabId });
6792
+ }
6793
+ }
6794
+ }
6795
+
6796
+ async function loadGitWorkflowPr({ requireFresh = false, retries = 0, runId, tabId = activeTabId } = {}) {
6797
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6798
+ const expectedRunId = runId ?? workflow?.runId;
6799
+ try {
6800
+ const pr = await gitWorkflowRequest("/api/git-workflow/pr-description", { method: "GET", runId: expectedRunId, tabId });
6801
+ if (!pr) return;
6802
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6803
+ if (!currentWorkflow) return;
6804
+ if (requireFresh && currentWorkflow.prRequestedAt && (pr.mtimeMs || 0) + 10000 < currentWorkflow.prRequestedAt) {
6805
+ throw new Error("Generated PR description has not refreshed yet.");
6806
+ }
6807
+ setGitWorkflow({
6808
+ step: "prReview",
6809
+ busy: false,
6810
+ error: "",
6811
+ pr,
6812
+ prBranch: pr.branch || currentWorkflow.prBranch,
6813
+ output: formatGitPrPreview(pr),
6814
+ }, { tabId });
6815
+ } catch (error) {
6816
+ if (!isCurrentGitWorkflowRun(expectedRunId, tabId)) return;
6817
+ if (retries > 0) {
6818
+ setTimeout(() => loadGitWorkflowPr({ requireFresh, retries: retries - 1, runId: expectedRunId, tabId }), 1400);
6819
+ return;
6820
+ }
6821
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
6822
+ failGitWorkflow(error, currentWorkflow?.step === "prGenerating" ? "push" : currentWorkflow?.step, { tabId });
6823
+ }
6824
+ }
6825
+
6826
+ async function pushAndCreatePrGitWorkflow(tabId = gitWorkflowActionTabId()) {
6827
+ const tabContext = activeTabContext(tabId);
6828
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6829
+ if (!workflow) return;
6830
+ const runId = workflow.runId;
6831
+ const branch = workflow.prBranch || "current branch";
6832
+ setGitWorkflow({ step: "pushing", busy: true, error: "", output: `Pushing PR branch ${branch}…` }, { tabId });
6833
+ try {
6834
+ const result = await gitWorkflowRequest("/api/git-workflow/push", { body: { setUpstream: true, branch: workflow.prBranch }, runId, tabId });
6835
+ if (!result) return;
6836
+ setGitWorkflow({ ...gitWorkflowActionDonePatch(workflow, "push") }, { tabId });
5536
6837
  if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
6838
+ await runGitPrPrompt(tabId, { prefixOutput: `${formatGitCommandResult(result)}\n\nPushed PR branch ${result.branch || branch}.` });
5537
6839
  } catch (error) {
5538
6840
  if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "push", { tabId });
5539
6841
  }
5540
6842
  }
5541
6843
 
6844
+ async function createGitPrFromReview(tabId = gitWorkflowActionTabId()) {
6845
+ const tabContext = activeTabContext(tabId);
6846
+ const workflow = gitWorkflowForTab(tabId, { create: false });
6847
+ if (!workflow?.pr) return;
6848
+ const runId = workflow.runId;
6849
+ const review = await openGitPrReviewDialog(workflow.pr, { title: gitWorkflowMessageTitle(workflow.message) });
6850
+ if (!isCurrentGitWorkflowRun(runId, tabId)) return;
6851
+ if (!review) {
6852
+ setGitWorkflow({ step: "prReview", busy: false, output: `${formatGitPrPreview(workflow.pr)}\n\nPR creation cancelled. Edit the description, regenerate /pr, or press Create PR again.` }, { tabId });
6853
+ return;
6854
+ }
6855
+ const title = review.title.trim();
6856
+ const body = review.body.trimEnd();
6857
+ setGitWorkflow({ step: "prCreating", busy: true, error: "", output: `${formatGitPrPreview({ ...workflow.pr, body })}\n\nCreating pull request with gh pr create…` }, { tabId });
6858
+ try {
6859
+ const result = await gitWorkflowRequest("/api/git-workflow/create-pr", { body: { title, body }, runId, tabId });
6860
+ if (!result) return;
6861
+ setGitWorkflow({ step: "done", busy: false, ...gitWorkflowActionDonePatch(workflow, "push"), output: `${formatGitCommandResult(result)}\n\nPull request created.` }, { tabId });
6862
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
6863
+ } catch (error) {
6864
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "prReview", { tabId });
6865
+ }
6866
+ }
6867
+
5542
6868
  function resumeGitWorkflowForActiveTab(tabContext = activeTabContext()) {
5543
6869
  if (!isCurrentTabContext(tabContext)) return;
5544
6870
  bindGitWorkflowToActiveTab();
@@ -5552,6 +6878,22 @@ function resumeGitWorkflowForActiveTab(tabContext = activeTabContext()) {
5552
6878
  }
5553
6879
  loadGitWorkflowMessage({ requireFresh: true, retries: 3, runId: gitWorkflow.runId, tabId: workflowTabId });
5554
6880
  }
6881
+ if (workflowTabId === tabContext.tabId && gitWorkflow.active && gitWorkflow.step === "branchNaming" && !currentState?.isStreaming) {
6882
+ const retryDelayMs = Math.max(0, 2500 - (Date.now() - (gitWorkflow.branchNameRequestedAt || 0)));
6883
+ if (retryDelayMs > 0) {
6884
+ setTimeout(() => resumeGitWorkflowForActiveTab(tabContext), retryDelayMs);
6885
+ return;
6886
+ }
6887
+ loadGitWorkflowBranchName({ requireFresh: true, retries: 3, runId: gitWorkflow.runId, tabId: workflowTabId });
6888
+ }
6889
+ if (workflowTabId === tabContext.tabId && gitWorkflow.active && gitWorkflow.step === "prGenerating" && !currentState?.isStreaming) {
6890
+ const retryDelayMs = Math.max(0, 2500 - (Date.now() - (gitWorkflow.prRequestedAt || 0)));
6891
+ if (retryDelayMs > 0) {
6892
+ setTimeout(() => resumeGitWorkflowForActiveTab(tabContext), retryDelayMs);
6893
+ return;
6894
+ }
6895
+ loadGitWorkflowPr({ requireFresh: true, retries: 3, runId: gitWorkflow.runId, tabId: workflowTabId });
6896
+ }
5555
6897
  }
5556
6898
 
5557
6899
  function normalizeQueuedMessages(event) {
@@ -8139,6 +9481,13 @@ function setNativeCommandMenuOpen(open) {
8139
9481
  elements.nativeCommandMenuButton.parentElement?.classList.toggle("open", nativeCommandMenuOpen);
8140
9482
  }
8141
9483
 
9484
+ function setAppRunnerMenuOpen(open) {
9485
+ appRunnerMenuOpen = !!open;
9486
+ elements.appRunnerMenuButton?.setAttribute("aria-expanded", appRunnerMenuOpen ? "true" : "false");
9487
+ elements.appRunnerMenuButton?.classList.toggle("menu-open", appRunnerMenuOpen);
9488
+ elements.appRunnerMenuButton?.parentElement?.classList.toggle("open", appRunnerMenuOpen);
9489
+ }
9490
+
8142
9491
  function setOptionsMenuOpen(open) {
8143
9492
  optionsMenuOpen = !!open;
8144
9493
  elements.optionsMenuButton.setAttribute("aria-expanded", optionsMenuOpen ? "true" : "false");
@@ -8386,6 +9735,7 @@ async function installOptionalFeature(featureId) {
8386
9735
  function runPublishWorkflow(command) {
8387
9736
  setComposerActionsOpen(false);
8388
9737
  setPublishMenuOpen(false);
9738
+ setAppRunnerMenuOpen(false);
8389
9739
  setOptionsMenuOpen(false);
8390
9740
  const commandName = String(command || "").replace(/^\//, "").split(/\s+/)[0];
8391
9741
  const featureId = OPTIONAL_COMMAND_FEATURES.get(commandName);
@@ -8404,6 +9754,7 @@ async function runNativeCommandMenu(command) {
8404
9754
  setComposerActionsOpen(false);
8405
9755
  setPublishMenuOpen(false);
8406
9756
  setNativeCommandMenuOpen(false);
9757
+ setAppRunnerMenuOpen(false);
8407
9758
  setOptionsMenuOpen(false);
8408
9759
  const commandName = String(command || "").replace(/^\//, "").split(/\s+/)[0].toLowerCase();
8409
9760
  const featureId = optionalFeatureIdForCommand(commandName);
@@ -10081,6 +11432,7 @@ async function refreshAll(tabContext = activeTabContext()) {
10081
11432
  refreshCommands(tabContext),
10082
11433
  refreshStats(tabContext),
10083
11434
  refreshWorkspace(tabContext),
11435
+ refreshAppRunners(tabContext),
10084
11436
  refreshNativeSettings(tabContext),
10085
11437
  refreshNetworkStatus(),
10086
11438
  refreshWebuiVersion(),
@@ -10795,6 +12147,11 @@ function handleEvent(event) {
10795
12147
  case "webui_connected":
10796
12148
  setWebuiVersion(event.version);
10797
12149
  setWebuiDevServer(isWebuiDevMetadata(event));
12150
+ if (Object.prototype.hasOwnProperty.call(event, "activeRun")) {
12151
+ setAppRunnerData(event.tabId || activeTabId, { cwd: event.cwd, activeRun: event.activeRun });
12152
+ renderAppRunnerControls();
12153
+ renderWidgets();
12154
+ }
10798
12155
  addEvent(`connected to ${event.tabTitle || "terminal"} for ${event.cwd}`);
10799
12156
  scheduleForegroundReconcile("event stream reconnect", 0);
10800
12157
  break;
@@ -10816,6 +12173,7 @@ function handleEvent(event) {
10816
12173
  case "webui_tab_reloaded":
10817
12174
  addEvent(`${event.tabTitle || "terminal"} reloaded`);
10818
12175
  addTransientMessage({ role: "native", title: "/reload", content: `${event.tabTitle || "terminal"} reloaded. Keybindings, extensions, skills, prompts, and themes were refreshed by restarting the RPC tab${event.sessionFile ? ` and resuming ${event.sessionFile}` : ""}.`, level: "info" });
12176
+ clearContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
10819
12177
  statusEntries.clear();
10820
12178
  widgets.clear();
10821
12179
  resetOptionalFeatureAvailability();
@@ -10833,9 +12191,18 @@ function handleEvent(event) {
10833
12191
  removeQueuedDialogRequests(event.ids || []);
10834
12192
  addEvent(`cancelled ${event.ids?.length || 0} pending extension UI request(s)`, "warn");
10835
12193
  break;
12194
+ case "webui_app_runner_update":
12195
+ setAppRunnerData(event.tabId || activeTabId, { cwd: event.cwd, activeRun: event.activeRun });
12196
+ renderAppRunnerControls();
12197
+ renderWidgets();
12198
+ break;
10836
12199
  case "webui_cwd_changed":
10837
12200
  addEvent(`${event.tabTitle || "terminal"} cwd changed to ${event.cwd}`);
12201
+ setAppRunnerData(event.tabId || activeTabId, { cwd: event.cwd, activeRun: null, runners: [] });
12202
+ renderAppRunnerControls();
12203
+ renderWidgets();
10838
12204
  refreshTabs().catch((error) => addEvent(error.message, "error"));
12205
+ refreshAppRunners(tabContext).catch((error) => addEvent(error.message, "error"));
10839
12206
  scheduleRefreshFooter();
10840
12207
  break;
10841
12208
  case "webui_network_rebinding": {
@@ -10879,6 +12246,7 @@ function handleEvent(event) {
10879
12246
  case "agent_end":
10880
12247
  addEvent("agent finished");
10881
12248
  notifyAgentDone(event.tabId || activeTabId, { activity: event.tabActivity, tabTitle: event.tabTitle });
12249
+ clearContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
10882
12250
  if (currentState) currentState = { ...currentState, isStreaming: false };
10883
12251
  clearRunIndicatorActivity();
10884
12252
  markTabOutputSeen();
@@ -10892,6 +12260,10 @@ function handleEvent(event) {
10892
12260
  const workflow = gitWorkflowForTab(workflowTabId, { create: false });
10893
12261
  if (workflow?.active && workflow.step === "generating") {
10894
12262
  loadGitWorkflowMessage({ requireFresh: true, retries: 3, runId: workflow.runId, tabId: workflowTabId });
12263
+ } else if (workflow?.active && workflow.step === "branchNaming") {
12264
+ loadGitWorkflowBranchName({ requireFresh: true, retries: 3, runId: workflow.runId, tabId: workflowTabId });
12265
+ } else if (workflow?.active && workflow.step === "prGenerating") {
12266
+ loadGitWorkflowPr({ requireFresh: true, retries: 3, runId: workflow.runId, tabId: workflowTabId });
10895
12267
  }
10896
12268
  }
10897
12269
  break;
@@ -10930,15 +12302,22 @@ function handleEvent(event) {
10930
12302
  break;
10931
12303
  case "compaction_start":
10932
12304
  if (currentState) currentState = { ...currentState, isCompacting: true };
12305
+ markContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
10933
12306
  setRunIndicatorActivity(`Compacting context${event.reason ? ` (${event.reason})` : ""}…`);
10934
12307
  addEvent(`compaction started (${event.reason})`);
12308
+ renderStatus();
10935
12309
  break;
10936
12310
  case "compaction_end":
10937
12311
  if (currentState) currentState = { ...currentState, isCompacting: false };
12312
+ if (event.aborted) clearContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
12313
+ else markContextUsageUnknownAfterCompaction(event.tabId || activeTabId);
10938
12314
  addEvent(`compaction ${event.aborted ? "aborted" : "finished"}`);
10939
12315
  if (!currentState?.isStreaming) clearRunIndicatorActivity();
10940
12316
  markTabOutputSeen();
12317
+ renderStatus();
12318
+ scheduleRefreshState();
10941
12319
  scheduleRefreshMessages();
12320
+ scheduleRefreshFooter();
10942
12321
  break;
10943
12322
  case "auto_retry_start": {
10944
12323
  const seconds = Math.max(0, Math.ceil(Number(event.delayMs || 0) / 1000));
@@ -10982,6 +12361,7 @@ function handleEvent(event) {
10982
12361
  applyOptimisticThinkingSelection(event.data, tabContext);
10983
12362
  } else if (event.command === "new_session") {
10984
12363
  const tabId = event.tabId || activeTabId;
12364
+ clearContextUsageUnknownAfterCompaction(tabId);
10985
12365
  forgetLastUserPrompt(tabId);
10986
12366
  resetGitWorkflowForTab(tabId);
10987
12367
  }
@@ -11038,6 +12418,23 @@ elements.promptListSaveButton?.addEventListener("click", saveDisplayedPromptList
11038
12418
  elements.promptListRunListButton?.addEventListener("click", () => runDisplayedPromptList());
11039
12419
  elements.promptListCloseButton?.addEventListener("click", () => elements.promptListDialog?.close());
11040
12420
  elements.promptListDialog?.querySelector("form")?.addEventListener("submit", (event) => event.preventDefault());
12421
+ elements.attachmentTextCancelButton?.addEventListener("click", closeTextAttachmentEditor);
12422
+ elements.attachmentTextSaveButton?.addEventListener("click", saveTextAttachmentEdit);
12423
+ elements.attachmentTextEditor?.addEventListener("input", () => {
12424
+ renderTextAttachmentEditorMeta();
12425
+ setAttachmentTextStatus("Unsaved attachment edits.", "warn");
12426
+ });
12427
+ elements.attachmentTextDialog?.addEventListener("close", () => {
12428
+ activeTextAttachmentEditor = null;
12429
+ if (elements.attachmentTextEditor) elements.attachmentTextEditor.value = "";
12430
+ setAttachmentTextStatus("");
12431
+ });
12432
+ elements.attachmentTextDialog?.addEventListener("keydown", (event) => {
12433
+ if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "s") return;
12434
+ event.preventDefault();
12435
+ if (!elements.attachmentTextSaveButton?.disabled) saveTextAttachmentEdit();
12436
+ });
12437
+ elements.attachmentTextDialog?.querySelector("form")?.addEventListener("submit", (event) => event.preventDefault());
11041
12438
  elements.sendFeedbackButton.addEventListener("click", () => submitQueuedActionFeedback());
11042
12439
  elements.composer.addEventListener("submit", (event) => {
11043
12440
  event.preventDefault();
@@ -11051,7 +12448,47 @@ elements.followUpButton.addEventListener("click", () => sendPromptFromModeButton
11051
12448
  elements.terminalTabsToggleButton.addEventListener("click", () => {
11052
12449
  setMobileTabsExpanded(!document.body.classList.contains("mobile-tabs-expanded"));
11053
12450
  });
11054
- elements.newTabButton.addEventListener("click", () => createTerminalTab());
12451
+ elements.newTabButton.addEventListener("click", (event) => {
12452
+ event.stopPropagation();
12453
+ openNewTabMenu();
12454
+ });
12455
+ elements.newTabButton.addEventListener("keydown", (event) => {
12456
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Enter" && event.key !== " ") return;
12457
+ event.preventDefault();
12458
+ openNewTabMenu();
12459
+ focusNewTabMenuItem(event.key === "ArrowUp" ? "last" : "first");
12460
+ });
12461
+ elements.newTabMenuPanel?.addEventListener("keydown", (event) => {
12462
+ if (event.key === "Escape") {
12463
+ event.preventDefault();
12464
+ setNewTabMenuOpen(false);
12465
+ elements.newTabButton.focus({ preventScroll: true });
12466
+ } else if (event.key === "ArrowDown") {
12467
+ event.preventDefault();
12468
+ moveNewTabMenuFocus(1);
12469
+ } else if (event.key === "ArrowUp") {
12470
+ event.preventDefault();
12471
+ moveNewTabMenuFocus(-1);
12472
+ } else if (event.key === "Home") {
12473
+ event.preventDefault();
12474
+ focusNewTabMenuItem("first");
12475
+ } else if (event.key === "End") {
12476
+ event.preventDefault();
12477
+ focusNewTabMenuItem("last");
12478
+ }
12479
+ });
12480
+ elements.newTabMenu?.addEventListener("pointerenter", () => openNewTabMenu());
12481
+ elements.newTabMenu?.addEventListener("pointerleave", () => {
12482
+ if (!elements.newTabMenu?.contains(document.activeElement)) setNewTabMenuOpen(false);
12483
+ });
12484
+ elements.newTabMenu?.addEventListener("focusin", () => openNewTabMenu());
12485
+ elements.newTabMenu?.addEventListener("focusout", () => {
12486
+ setTimeout(() => {
12487
+ if (!elements.newTabMenu?.contains(document.activeElement)) setNewTabMenuOpen(false);
12488
+ }, 0);
12489
+ });
12490
+ elements.newTabCurrentDirectoryButton?.addEventListener("click", () => createTerminalTab(currentDirectoryForNewTab(), { triggerButton: elements.newTabCurrentDirectoryButton }));
12491
+ elements.newTabChooseDirectoryButton?.addEventListener("click", () => createTerminalTabFromChosenDirectory({ triggerButton: elements.newTabChooseDirectoryButton }));
11055
12492
  elements.closeAllTabsButton.addEventListener("click", () => closeAllTerminalTabs());
11056
12493
  elements.gitWorkflowButton.addEventListener("click", () => {
11057
12494
  setComposerActionsOpen(false);
@@ -11060,17 +12497,20 @@ elements.gitWorkflowButton.addEventListener("click", () => {
11060
12497
  const publishMenuContainer = elements.publishButton.parentElement;
11061
12498
  elements.publishButton.addEventListener("click", () => {
11062
12499
  setNativeCommandMenuOpen(false);
12500
+ setAppRunnerMenuOpen(false);
11063
12501
  setOptionsMenuOpen(false);
11064
12502
  setPublishMenuOpen(true);
11065
12503
  });
11066
12504
  publishMenuContainer?.addEventListener("pointerenter", () => {
11067
12505
  setNativeCommandMenuOpen(false);
12506
+ setAppRunnerMenuOpen(false);
11068
12507
  setOptionsMenuOpen(false);
11069
12508
  setPublishMenuOpen(true);
11070
12509
  });
11071
12510
  publishMenuContainer?.addEventListener("pointerleave", () => setPublishMenuOpen(false));
11072
12511
  publishMenuContainer?.addEventListener("focusin", () => {
11073
12512
  setNativeCommandMenuOpen(false);
12513
+ setAppRunnerMenuOpen(false);
11074
12514
  setOptionsMenuOpen(false);
11075
12515
  setPublishMenuOpen(true);
11076
12516
  });
@@ -11082,17 +12522,20 @@ publishMenuContainer?.addEventListener("focusout", () => {
11082
12522
  const nativeCommandMenuContainer = elements.nativeCommandMenuButton.parentElement;
11083
12523
  elements.nativeCommandMenuButton.addEventListener("click", () => {
11084
12524
  setPublishMenuOpen(false);
12525
+ setAppRunnerMenuOpen(false);
11085
12526
  setOptionsMenuOpen(false);
11086
12527
  setNativeCommandMenuOpen(true);
11087
12528
  });
11088
12529
  nativeCommandMenuContainer?.addEventListener("pointerenter", () => {
11089
12530
  setPublishMenuOpen(false);
12531
+ setAppRunnerMenuOpen(false);
11090
12532
  setOptionsMenuOpen(false);
11091
12533
  setNativeCommandMenuOpen(true);
11092
12534
  });
11093
12535
  nativeCommandMenuContainer?.addEventListener("pointerleave", () => setNativeCommandMenuOpen(false));
11094
12536
  nativeCommandMenuContainer?.addEventListener("focusin", () => {
11095
12537
  setPublishMenuOpen(false);
12538
+ setAppRunnerMenuOpen(false);
11096
12539
  setOptionsMenuOpen(false);
11097
12540
  setNativeCommandMenuOpen(true);
11098
12541
  });
@@ -11101,21 +12544,66 @@ nativeCommandMenuContainer?.addEventListener("focusout", () => {
11101
12544
  if (!nativeCommandMenuContainer?.contains(document.activeElement)) setNativeCommandMenuOpen(false);
11102
12545
  }, 0);
11103
12546
  });
12547
+ const appRunnerMenuContainer = elements.appRunnerMenuButton?.parentElement;
12548
+ elements.appRunnerInfoButton?.addEventListener("click", (event) => {
12549
+ event.preventDefault();
12550
+ event.stopPropagation();
12551
+ openAppRunnerInfoDialog();
12552
+ });
12553
+ elements.appRunnerInfoCloseButton?.addEventListener("click", closeAppRunnerInfoDialog);
12554
+ elements.appRunnerMenuButton?.addEventListener("click", async () => {
12555
+ setPublishMenuOpen(false);
12556
+ setNativeCommandMenuOpen(false);
12557
+ setOptionsMenuOpen(false);
12558
+ setAppRunnerMenuOpen(false);
12559
+ const tabContext = activeTabContext();
12560
+ try {
12561
+ await refreshAppRunners(tabContext);
12562
+ if (!isCurrentTabContext(tabContext)) return;
12563
+ if (appRunnerMenuCanOpen()) setAppRunnerMenuOpen(true);
12564
+ else if (!appRunnerIsRunning(activeAppRunnerData().activeRun)) openAppRunnerInfoDialog();
12565
+ } catch (error) {
12566
+ if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
12567
+ }
12568
+ });
12569
+ appRunnerMenuContainer?.addEventListener("pointerenter", () => {
12570
+ if (elements.appRunnerMenuButton?.disabled || !appRunnerMenuCanOpen()) return;
12571
+ setPublishMenuOpen(false);
12572
+ setNativeCommandMenuOpen(false);
12573
+ setOptionsMenuOpen(false);
12574
+ setAppRunnerMenuOpen(true);
12575
+ });
12576
+ appRunnerMenuContainer?.addEventListener("pointerleave", () => setAppRunnerMenuOpen(false));
12577
+ appRunnerMenuContainer?.addEventListener("focusin", () => {
12578
+ if (elements.appRunnerMenuButton?.disabled || !appRunnerMenuCanOpen()) return;
12579
+ setPublishMenuOpen(false);
12580
+ setNativeCommandMenuOpen(false);
12581
+ setOptionsMenuOpen(false);
12582
+ setAppRunnerMenuOpen(true);
12583
+ });
12584
+ appRunnerMenuContainer?.addEventListener("focusout", () => {
12585
+ setTimeout(() => {
12586
+ if (!appRunnerMenuContainer?.contains(document.activeElement)) setAppRunnerMenuOpen(false);
12587
+ }, 0);
12588
+ });
11104
12589
  const optionsMenuContainer = elements.optionsMenuButton.parentElement;
11105
12590
  elements.optionsMenuButton.addEventListener("click", () => {
11106
12591
  setPublishMenuOpen(false);
11107
12592
  setNativeCommandMenuOpen(false);
12593
+ setAppRunnerMenuOpen(false);
11108
12594
  setOptionsMenuOpen(true);
11109
12595
  });
11110
12596
  optionsMenuContainer?.addEventListener("pointerenter", () => {
11111
12597
  setPublishMenuOpen(false);
11112
12598
  setNativeCommandMenuOpen(false);
12599
+ setAppRunnerMenuOpen(false);
11113
12600
  setOptionsMenuOpen(true);
11114
12601
  });
11115
12602
  optionsMenuContainer?.addEventListener("pointerleave", () => setOptionsMenuOpen(false));
11116
12603
  optionsMenuContainer?.addEventListener("focusin", () => {
11117
12604
  setPublishMenuOpen(false);
11118
12605
  setNativeCommandMenuOpen(false);
12606
+ setAppRunnerMenuOpen(false);
11119
12607
  setOptionsMenuOpen(true);
11120
12608
  });
11121
12609
  optionsMenuContainer?.addEventListener("focusout", () => {
@@ -11135,7 +12623,32 @@ elements.optionsSettingsButton.addEventListener("click", () => runNativeCommandM
11135
12623
  elements.optionsExportButton.addEventListener("click", () => runNativeCommandMenu("/export"));
11136
12624
  elements.optionsForkButton.addEventListener("click", () => runNativeCommandMenu("/fork"));
11137
12625
  elements.optionsTreeButton.addEventListener("click", () => runNativeCommandMenu("/tree"));
12626
+ elements.gitWorkflowSteps.addEventListener("click", (event) => {
12627
+ const target = event.target instanceof Element ? event.target : null;
12628
+ const button = target?.closest("[data-git-workflow-process]");
12629
+ if (!button || !elements.gitWorkflowSteps.contains(button) || button.disabled) return;
12630
+ selectGitWorkflowProcess(button.dataset.gitWorkflowProcess);
12631
+ });
11138
12632
  elements.gitWorkflowCancelButton.addEventListener("click", () => cancelGitWorkflow());
12633
+ elements.gitPrCancelButton?.addEventListener("click", () => resolveGitPrDialog(null));
12634
+ elements.gitPrCreateButton?.addEventListener("click", () => {
12635
+ const title = elements.gitPrTitleInput?.value.trim() || "";
12636
+ const body = elements.gitPrBodyEditor?.value.trimEnd() || "";
12637
+ if (!title) {
12638
+ setGitPrDialogStatus("PR title is required.", "error");
12639
+ elements.gitPrTitleInput?.focus();
12640
+ return;
12641
+ }
12642
+ if (!body.trim()) {
12643
+ setGitPrDialogStatus("PR description is required.", "error");
12644
+ elements.gitPrBodyEditor?.focus();
12645
+ return;
12646
+ }
12647
+ resolveGitPrDialog({ title, body });
12648
+ });
12649
+ elements.gitPrDialog?.addEventListener("close", () => {
12650
+ if (activeGitPrDialogResolve) resolveGitPrDialog(null);
12651
+ });
11139
12652
  elements.nativeCommandDialog.addEventListener("close", () => {
11140
12653
  elements.nativeCommandSearch.oninput = null;
11141
12654
  nativeCommandTabId = null;
@@ -11231,6 +12744,8 @@ elements.compactButton.addEventListener("click", async () => {
11231
12744
  elements.compactButton.textContent = "Compacting…";
11232
12745
  setRunIndicatorActivity("Requesting context compaction…");
11233
12746
  scrollChatToBottom({ force: true });
12747
+ markContextUsageUnknownAfterCompaction(tabContext.tabId);
12748
+ renderFooter();
11234
12749
  addEvent("manual compaction requested");
11235
12750
  await api("/api/compact", { method: "POST", body: {}, tabId: tabContext.tabId });
11236
12751
  if (!isCurrentTabContext(tabContext)) return;
@@ -11239,7 +12754,9 @@ elements.compactButton.addEventListener("click", async () => {
11239
12754
  scheduleRefreshFooter(600, tabContext);
11240
12755
  } catch (error) {
11241
12756
  if (isCurrentTabContext(tabContext)) {
12757
+ clearContextUsageUnknownAfterCompaction(tabContext.tabId);
11242
12758
  clearRunIndicatorActivity();
12759
+ renderFooter();
11243
12760
  addEvent(error.message, "error");
11244
12761
  }
11245
12762
  } finally {
@@ -11339,6 +12856,9 @@ document.addEventListener("pointerdown", (event) => {
11339
12856
  if (openTerminalTabGroupKey && !event.target?.closest?.(".terminal-tab-group")) {
11340
12857
  clearOpenTerminalTabGroup(openTerminalTabGroupKey);
11341
12858
  }
12859
+ if (newTabMenuOpen && !event.target?.closest?.(".terminal-new-tab-menu")) {
12860
+ setNewTabMenuOpen(false);
12861
+ }
11342
12862
  if (document.body.classList.contains("composer-actions-open") && !elements.composer.contains(event.target)) {
11343
12863
  setComposerActionsOpen(false);
11344
12864
  }
@@ -11348,10 +12868,14 @@ document.addEventListener("pointerdown", (event) => {
11348
12868
  if (nativeCommandMenuOpen && !event.target?.closest?.(".composer-native-command-menu")) {
11349
12869
  setNativeCommandMenuOpen(false);
11350
12870
  }
12871
+ if (appRunnerMenuOpen && !event.target?.closest?.(".composer-app-runner-menu")) {
12872
+ setAppRunnerMenuOpen(false);
12873
+ }
11351
12874
  if (optionsMenuOpen && !event.target?.closest?.(".composer-options-menu")) {
11352
12875
  setOptionsMenuOpen(false);
11353
12876
  }
11354
12877
  if (document.body.classList.contains("mobile-tabs-expanded") && !elements.tabBar.contains(event.target) && !elements.terminalTabsToggleButton.contains(event.target)) {
12878
+ setNewTabMenuOpen(false);
11355
12879
  setMobileTabsExpanded(false);
11356
12880
  }
11357
12881
  if (isFooterPickerOpen() && !elements.statusBar.contains(event.target)) {
@@ -11374,7 +12898,7 @@ function isTextEntryTarget(target) {
11374
12898
 
11375
12899
  function shouldHandleNativeAppShortcut(event) {
11376
12900
  if (event.defaultPrevented) return false;
11377
- if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.nativeCommandDialog?.open) return false;
12901
+ if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.nativeCommandDialog?.open || elements.appRunnerInfoDialog?.open) return false;
11378
12902
  return event.target === elements.promptInput || !isTextEntryTarget(event.target);
11379
12903
  }
11380
12904
 
@@ -11442,15 +12966,24 @@ window.addEventListener("keydown", (event) => {
11442
12966
  setNativeCommandMenuOpen(false);
11443
12967
  return;
11444
12968
  }
12969
+ if (appRunnerMenuOpen) {
12970
+ setAppRunnerMenuOpen(false);
12971
+ return;
12972
+ }
11445
12973
  if (optionsMenuOpen) {
11446
12974
  setOptionsMenuOpen(false);
11447
12975
  return;
11448
12976
  }
12977
+ if (newTabMenuOpen) {
12978
+ setNewTabMenuOpen(false);
12979
+ return;
12980
+ }
11449
12981
  if (document.body.classList.contains("composer-actions-open")) {
11450
12982
  setComposerActionsOpen(false);
11451
12983
  return;
11452
12984
  }
11453
12985
  if (document.body.classList.contains("mobile-tabs-expanded")) {
12986
+ setNewTabMenuOpen(false);
11454
12987
  setMobileTabsExpanded(false);
11455
12988
  return;
11456
12989
  }
@@ -11473,7 +13006,7 @@ window.addEventListener("keydown", (event) => {
11473
13006
  }
11474
13007
  lastEmptyPromptEscapeTime = now;
11475
13008
  }
11476
- if (isMobileView() && !document.body.classList.contains("side-panel-collapsed")) {
13009
+ if (isSidePanelOverlayView() && !document.body.classList.contains("side-panel-collapsed")) {
11477
13010
  setSidePanelCollapsed(true);
11478
13011
  return;
11479
13012
  }
@@ -11566,6 +13099,7 @@ elements.promptInput.addEventListener("keydown", (event) => {
11566
13099
 
11567
13100
  elements.promptInput.addEventListener("input", () => {
11568
13101
  resetPromptHistoryNavigation();
13102
+ if (moveLongPromptInputToAttachment()) return;
11569
13103
  resizePromptInput();
11570
13104
  renderCommandSuggestions();
11571
13105
  });
@@ -11594,6 +13128,7 @@ resizePromptInput();
11594
13128
  focusPromptInput({ defer: true });
11595
13129
  updateComposerModeButtons();
11596
13130
  updateOptionalFeatureAvailability();
13131
+ renderAppRunnerControls();
11597
13132
  renderLoadedPromptListPreview();
11598
13133
  loadLastUserPromptCache();
11599
13134
  loadPromptHistoryCache();
@@ -11613,6 +13148,7 @@ bindSidePanelSectionToggles();
11613
13148
  restoreSidePanelState();
11614
13149
  initializeCodexUsage();
11615
13150
  bindMobileViewChanges();
13151
+ bindSidePanelOverlayViewChanges();
11616
13152
  registerPwaServiceWorker();
11617
13153
  renderServerOfflinePanel();
11618
13154
  initializeTabs().catch((error) => addEvent(error.message, "error"));