@firstpick/pi-package-webui 0.3.3 → 0.3.5

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
@@ -31,6 +31,16 @@ const elements = {
31
31
  composerActionsButton: $("#composerActionsButton"),
32
32
  composerActionsPanel: $("#composerActionsPanel"),
33
33
  promptInput: $("#promptInput"),
34
+ busyPromptBehaviorTag: $("#busyPromptBehaviorTag"),
35
+ busyPromptBehaviorMenu: $("#busyPromptBehaviorMenu"),
36
+ sessionSkillTags: $("#sessionSkillTags"),
37
+ skillEditorDialog: $("#skillEditorDialog"),
38
+ skillEditorTitle: $("#skillEditorTitle"),
39
+ skillEditorMeta: $("#skillEditorMeta"),
40
+ skillEditorText: $("#skillEditorText"),
41
+ skillEditorStatus: $("#skillEditorStatus"),
42
+ skillEditorCancelButton: $("#skillEditorCancelButton"),
43
+ skillEditorSaveButton: $("#skillEditorSaveButton"),
34
44
  sendButton: $("#sendButton"),
35
45
  commandSuggest: $("#commandSuggest"),
36
46
  attachmentTray: $("#attachmentTray"),
@@ -83,6 +93,8 @@ const elements = {
83
93
  setThinkingButton: $("#setThinkingButton"),
84
94
  thinkingVisibilityToggle: $("#thinkingVisibilityToggle"),
85
95
  thinkingVisibilityStatus: $("#thinkingVisibilityStatus"),
96
+ terminalTabsLayoutSelect: $("#terminalTabsLayoutSelect"),
97
+ terminalTabsLayoutStatus: $("#terminalTabsLayoutStatus"),
86
98
  themeSelect: $("#themeSelect"),
87
99
  backgroundInput: $("#backgroundInput"),
88
100
  backgroundChooseButton: $("#backgroundChooseButton"),
@@ -172,6 +184,7 @@ let activeTabGeneration = 0;
172
184
  let tabDrafts = new Map();
173
185
  let tabAttachments = new Map();
174
186
  let activeTextAttachmentEditor = null;
187
+ let activeSkillEditor = null;
175
188
  let tabActivities = new Map();
176
189
  let tabSeenCompletionSerials = new Map();
177
190
  let streamBubble = null;
@@ -211,6 +224,8 @@ let openTerminalTabGroupKey = null;
211
224
  let newTabMenuOpen = false;
212
225
  let nativeCommandMenuOpen = false;
213
226
  let appRunnerMenuOpen = false;
227
+ let busyPromptBehaviorMenuOpen = false;
228
+ const skillUsageByTab = new Map();
214
229
  let appRunnerCustomDraft = { id: "", label: "", command: "./", path: "", args: "" };
215
230
  let appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
216
231
  let optionsMenuOpen = false;
@@ -251,6 +266,7 @@ let blockedTabNotificationPermissionRequested = false;
251
266
  let blockedTabNotificationFallbackNoted = false;
252
267
  let agentDoneNotificationsEnabled = false;
253
268
  let thinkingOutputVisible = true;
269
+ let terminalTabsLayout = "top";
254
270
  let webuiSettings = {};
255
271
  let busyPromptBehavior = "followUp";
256
272
  let autocompleteMaxVisible = 12;
@@ -296,6 +312,9 @@ const TAB_STORAGE_KEY = "pi-webui-active-tab";
296
312
  const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
297
313
  const AGENT_DONE_NOTIFICATIONS_STORAGE_KEY = "pi-webui-agent-done-notifications";
298
314
  const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible";
315
+ const BUSY_PROMPT_BEHAVIOR_STORAGE_KEY = "pi-webui-busy-prompt-behavior";
316
+ const SKILL_USAGE_STORAGE_KEY = "pi-webui-skill-usage-v1";
317
+ const TERMINAL_TABS_LAYOUT_STORAGE_KEY = "pi-webui-terminal-tabs-layout";
299
318
  const TOOL_OUTPUT_EXPANDED_STORAGE_KEY = "pi-webui-tool-output-expanded";
300
319
  const THEME_STORAGE_KEY = "pi-webui-theme";
301
320
  const CUSTOM_BACKGROUND_STORAGE_KEY = "pi-webui-custom-background";
@@ -325,6 +344,12 @@ const LONG_INPUT_ATTACHMENT_MIME_TYPE = "text/plain";
325
344
  const INLINE_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
326
345
  const BACKGROUND_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
327
346
  const DEFAULT_THEME_NAME = "catppuccin-mocha";
347
+ const TERMINAL_TABS_LAYOUTS = new Set(["top", "left"]);
348
+ const TERMINAL_TABS_LAYOUT_LABELS = { top: "Top bar", left: "Left sidebar" };
349
+ const BUSY_PROMPT_BEHAVIOR_VALUES = new Set(["followUp", "steer"]);
350
+ const BUSY_PROMPT_BEHAVIOR_LABELS = { followUp: "Follow-up", steer: "Steer" };
351
+ const SKILL_TAG_MAX_VISIBLE = 6;
352
+ const SKILL_USAGE_LIMIT_PER_TAB = 32;
328
353
  const MOBILE_VIEW_QUERY = "(max-width: 720px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
329
354
  const SIDE_PANEL_OVERLAY_QUERY = "(max-width: 1050px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
330
355
  const CHAT_BOTTOM_THRESHOLD_PX = 96;
@@ -789,6 +814,26 @@ function persistThinkingOutputVisible(visible) {
789
814
  }
790
815
  }
791
816
 
817
+ function normalizeTerminalTabsLayout(value) {
818
+ return TERMINAL_TABS_LAYOUTS.has(value) ? value : "top";
819
+ }
820
+
821
+ function readStoredTerminalTabsLayout() {
822
+ try {
823
+ return normalizeTerminalTabsLayout(localStorage.getItem(TERMINAL_TABS_LAYOUT_STORAGE_KEY));
824
+ } catch {
825
+ return "top";
826
+ }
827
+ }
828
+
829
+ function persistTerminalTabsLayout(layout) {
830
+ try {
831
+ localStorage.setItem(TERMINAL_TABS_LAYOUT_STORAGE_KEY, normalizeTerminalTabsLayout(layout));
832
+ } catch {
833
+ // Ignore storage failures; the layout control should still work for this page load.
834
+ }
835
+ }
836
+
792
837
  function readStoredToolOutputExpanded() {
793
838
  try {
794
839
  return localStorage.getItem(TOOL_OUTPUT_EXPANDED_STORAGE_KEY) === "1";
@@ -816,6 +861,30 @@ function renderThinkingVisibilityToggle() {
816
861
  if (elements.thinkingVisibilityStatus) elements.thinkingVisibilityStatus.textContent = thinkingVisibilityStatusText();
817
862
  }
818
863
 
864
+ function terminalTabsLayoutStatusText(layout = terminalTabsLayout) {
865
+ return TERMINAL_TABS_LAYOUT_LABELS[normalizeTerminalTabsLayout(layout)] || TERMINAL_TABS_LAYOUT_LABELS.top;
866
+ }
867
+
868
+ function renderTerminalTabsLayoutControl() {
869
+ const layout = normalizeTerminalTabsLayout(terminalTabsLayout);
870
+ if (elements.terminalTabsLayoutSelect) elements.terminalTabsLayoutSelect.value = layout;
871
+ if (elements.terminalTabsLayoutStatus) elements.terminalTabsLayoutStatus.textContent = terminalTabsLayoutStatusText(layout);
872
+ }
873
+
874
+ function setTerminalTabsLayout(layout, { persist = true, announce = false } = {}) {
875
+ const next = normalizeTerminalTabsLayout(layout);
876
+ terminalTabsLayout = next;
877
+ document.body.classList.toggle("terminal-tabs-left", next === "left");
878
+ if (next === "left" && mobileTabsExpanded) setMobileTabsExpanded(false);
879
+ if (persist) persistTerminalTabsLayout(next);
880
+ renderTerminalTabsLayoutControl();
881
+ if (announce) addEvent(`terminal tabs layout changed to ${terminalTabsLayoutStatusText(next).toLowerCase()}`);
882
+ }
883
+
884
+ function restoreTerminalTabsLayoutSetting() {
885
+ setTerminalTabsLayout(readStoredTerminalTabsLayout(), { persist: false });
886
+ }
887
+
819
888
  function removeStreamingThinkingBubble() {
820
889
  streamThinkingBubble?.remove();
821
890
  streamThinkingBubble = null;
@@ -855,6 +924,454 @@ function restoreThinkingVisibilitySetting() {
855
924
  renderThinkingVisibilityToggle();
856
925
  }
857
926
 
927
+ function normalizeBusyPromptBehavior(value) {
928
+ const normalized = String(value || "").trim();
929
+ if (normalized === "follow-up" || normalized.toLowerCase() === "followup") return "followUp";
930
+ return BUSY_PROMPT_BEHAVIOR_VALUES.has(normalized) ? normalized : "followUp";
931
+ }
932
+
933
+ function readStoredBusyPromptBehavior() {
934
+ try {
935
+ return normalizeBusyPromptBehavior(localStorage.getItem(BUSY_PROMPT_BEHAVIOR_STORAGE_KEY));
936
+ } catch {
937
+ return "followUp";
938
+ }
939
+ }
940
+
941
+ function persistBusyPromptBehavior(behavior) {
942
+ try {
943
+ localStorage.setItem(BUSY_PROMPT_BEHAVIOR_STORAGE_KEY, normalizeBusyPromptBehavior(behavior));
944
+ } catch {
945
+ // Ignore storage failures; the setting should still work for this page load.
946
+ }
947
+ }
948
+
949
+ function busyPromptBehaviorMenuItems() {
950
+ return Array.from(elements.busyPromptBehaviorMenu?.querySelectorAll("[data-busy-prompt-behavior]") || []);
951
+ }
952
+
953
+ function renderBusyPromptBehaviorMenu() {
954
+ const behavior = normalizeBusyPromptBehavior(busyPromptBehavior);
955
+ for (const item of busyPromptBehaviorMenuItems()) {
956
+ const checked = normalizeBusyPromptBehavior(item.dataset.busyPromptBehavior) === behavior;
957
+ item.setAttribute("aria-checked", checked ? "true" : "false");
958
+ item.classList.toggle("active", checked);
959
+ }
960
+ }
961
+
962
+ function normalizeSkillName(value) {
963
+ const raw = String(value || "").trim().replace(/^\/?skill:/i, "");
964
+ if (!raw) return "";
965
+ const match = raw.match(/^[a-z0-9][a-z0-9._-]{0,63}$/i);
966
+ return match ? match[0].toLowerCase() : "";
967
+ }
968
+
969
+ function skillUsageMapForTab(tabId = activeTabId, { create = true } = {}) {
970
+ if (!tabId) return null;
971
+ let map = skillUsageByTab.get(tabId);
972
+ if (!map && create) {
973
+ map = new Map();
974
+ skillUsageByTab.set(tabId, map);
975
+ }
976
+ return map || null;
977
+ }
978
+
979
+ function clearSkillUsageForTab(tabId = activeTabId) {
980
+ if (!tabId) return;
981
+ skillUsageByTab.delete(tabId);
982
+ persistSkillUsage();
983
+ if (tabId === activeTabId) renderSessionSkillTags(tabId);
984
+ }
985
+
986
+ function sortedSkillUsageEntries(tabId = activeTabId) {
987
+ const map = skillUsageMapForTab(tabId, { create: false });
988
+ if (!map) return [];
989
+ return [...map.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt || a.name.localeCompare(b.name));
990
+ }
991
+
992
+ function serializeSkillUsageEntry(entry) {
993
+ const name = normalizeSkillName(entry?.name || "");
994
+ if (!name) return null;
995
+ const kinds = entry?.kinds instanceof Set ? [...entry.kinds] : Array.isArray(entry?.kinds) ? entry.kinds : [];
996
+ const paths = entry?.paths instanceof Set ? [...entry.paths] : Array.isArray(entry?.paths) ? entry.paths : [];
997
+ const sources = entry?.sources instanceof Set ? [...entry.sources] : Array.isArray(entry?.sources) ? entry.sources : [];
998
+ const path = entry?.path || paths[paths.length - 1] || "";
999
+ return {
1000
+ name,
1001
+ firstSeenAt: Number.isFinite(entry?.firstSeenAt) ? entry.firstSeenAt : Date.now(),
1002
+ lastSeenAt: Number.isFinite(entry?.lastSeenAt) ? entry.lastSeenAt : Date.now(),
1003
+ kinds: kinds.includes("read") ? kinds : [...kinds, "read"],
1004
+ sources: sources.slice(-12),
1005
+ path,
1006
+ paths: [...new Set([path, ...paths].filter(Boolean))].slice(-8),
1007
+ };
1008
+ }
1009
+
1010
+ function persistSkillUsage() {
1011
+ try {
1012
+ const storedTabs = {};
1013
+ for (const [tabId, map] of skillUsageByTab.entries()) {
1014
+ const entries = [...map.values()]
1015
+ .filter((entry) => entry?.kinds?.has("read"))
1016
+ .map(serializeSkillUsageEntry)
1017
+ .filter(Boolean)
1018
+ .slice(0, SKILL_USAGE_LIMIT_PER_TAB);
1019
+ if (entries.length) storedTabs[tabId] = entries;
1020
+ }
1021
+ localStorage.setItem(SKILL_USAGE_STORAGE_KEY, JSON.stringify({ version: 1, tabs: storedTabs }));
1022
+ } catch {
1023
+ // Ignore storage failures; tags still work for the current page lifetime.
1024
+ }
1025
+ }
1026
+
1027
+ function restoreStoredSkillUsage() {
1028
+ try {
1029
+ const parsed = JSON.parse(localStorage.getItem(SKILL_USAGE_STORAGE_KEY) || "{}");
1030
+ const storedTabs = parsed?.tabs && typeof parsed.tabs === "object" ? parsed.tabs : {};
1031
+ for (const [tabId, entries] of Object.entries(storedTabs)) {
1032
+ if (!tabId || !Array.isArray(entries)) continue;
1033
+ const map = skillUsageMapForTab(tabId);
1034
+ if (!map) continue;
1035
+ for (const stored of entries.slice(0, SKILL_USAGE_LIMIT_PER_TAB)) {
1036
+ const name = normalizeSkillName(stored?.name || "");
1037
+ if (!name) continue;
1038
+ const kinds = new Set(Array.isArray(stored?.kinds) ? stored.kinds : ["read"]);
1039
+ if (!kinds.has("read")) continue;
1040
+ const paths = new Set(Array.isArray(stored?.paths) ? stored.paths.filter(Boolean) : []);
1041
+ if (stored?.path) paths.add(stored.path);
1042
+ map.set(name, {
1043
+ name,
1044
+ firstSeenAt: Number.isFinite(stored?.firstSeenAt) ? stored.firstSeenAt : Date.now(),
1045
+ lastSeenAt: Number.isFinite(stored?.lastSeenAt) ? stored.lastSeenAt : Date.now(),
1046
+ kinds,
1047
+ sources: new Set(Array.isArray(stored?.sources) ? stored.sources : ["stored"]),
1048
+ path: stored?.path || [...paths].at(-1) || "",
1049
+ paths,
1050
+ });
1051
+ }
1052
+ }
1053
+ } catch {
1054
+ // Ignore corrupt stored tag data.
1055
+ }
1056
+ }
1057
+
1058
+ function pruneSkillUsageForKnownTabs(tabIds) {
1059
+ let changed = false;
1060
+ for (const tabId of skillUsageByTab.keys()) {
1061
+ if (tabIds.has(tabId)) continue;
1062
+ skillUsageByTab.delete(tabId);
1063
+ changed = true;
1064
+ }
1065
+ if (changed) persistSkillUsage();
1066
+ }
1067
+
1068
+ function skillInfoFromPath(pathText) {
1069
+ const normalized = String(pathText || "").trim().replace(/\\/g, "/");
1070
+ const match = normalized.match(/\/skills\/([^/]+)\/SKILL\.md$/i);
1071
+ const name = normalizeSkillName(match?.[1] || "");
1072
+ return name ? { name, path: normalized } : null;
1073
+ }
1074
+
1075
+ function skillNameFromPath(pathText) {
1076
+ return skillInfoFromPath(pathText)?.name || "";
1077
+ }
1078
+
1079
+ function skillNamesFromSlashCommands(text) {
1080
+ const names = new Set();
1081
+ for (const match of String(text || "").matchAll(/\/skill:([a-z0-9][a-z0-9._-]{0,63})/gi)) {
1082
+ const normalized = normalizeSkillName(match[1]);
1083
+ if (normalized) names.add(normalized);
1084
+ }
1085
+ return [...names];
1086
+ }
1087
+
1088
+ function skillKindsLabel(entry) {
1089
+ return entry?.kinds?.has("read") ? "context read" : "tracked";
1090
+ }
1091
+
1092
+ function renderSessionSkillTags(tabId = activeTabId) {
1093
+ const container = elements.sessionSkillTags;
1094
+ if (!container) return;
1095
+ const entries = sortedSkillUsageEntries(tabId).filter((entry) => entry.kinds.has("read"));
1096
+ container.replaceChildren();
1097
+ if (!entries.length) {
1098
+ container.hidden = true;
1099
+ return;
1100
+ }
1101
+ const visible = entries.slice(0, SKILL_TAG_MAX_VISIBLE);
1102
+ for (const entry of visible) {
1103
+ const classes = ["composer-skill-tag", "read"];
1104
+ const tag = make("button", classes.join(" "), entry.name);
1105
+ tag.type = "button";
1106
+ tag.dataset.skillName = entry.name;
1107
+ tag.dataset.skillPath = skillPathForEntry(entry);
1108
+ tag.title = `Open and edit skill ${entry.name} (${skillKindsLabel(entry)}) tracked in this tab/session.`;
1109
+ tag.setAttribute("aria-label", `Open skill ${entry.name}`);
1110
+ tag.addEventListener("click", () => openSkillEditor(entry));
1111
+ container.append(tag);
1112
+ }
1113
+ if (entries.length > visible.length) {
1114
+ const overflow = make("span", "composer-skill-tag overflow", `+${entries.length - visible.length}`);
1115
+ overflow.title = `${entries.length - visible.length} more tracked skill${entries.length - visible.length === 1 ? "" : "s"}.`;
1116
+ container.append(overflow);
1117
+ }
1118
+ container.hidden = false;
1119
+ }
1120
+
1121
+ function trackSkillUsage(tabId, skillName, kind = "used", source = "", details = {}) {
1122
+ const name = normalizeSkillName(skillName);
1123
+ if (!tabId || !name) return;
1124
+ const map = skillUsageMapForTab(tabId);
1125
+ if (!map) return;
1126
+ const now = Date.now();
1127
+ const entry = map.get(name) || { name, firstSeenAt: now, lastSeenAt: now, kinds: new Set(), sources: new Set(), paths: new Set() };
1128
+ entry.lastSeenAt = now;
1129
+ if (["used", "loaded", "read"].includes(kind)) entry.kinds.add(kind);
1130
+ else entry.kinds.add("used");
1131
+ if (source) entry.sources.add(source);
1132
+ if (details?.path) {
1133
+ entry.path = details.path;
1134
+ entry.paths ||= new Set();
1135
+ entry.paths.add(details.path);
1136
+ }
1137
+ map.set(name, entry);
1138
+ if (map.size > SKILL_USAGE_LIMIT_PER_TAB) {
1139
+ const keep = [...map.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt).slice(0, SKILL_USAGE_LIMIT_PER_TAB);
1140
+ map.clear();
1141
+ for (const item of keep) map.set(item.name, item);
1142
+ }
1143
+ persistSkillUsage();
1144
+ if (tabId === activeTabId) renderSessionSkillTags(tabId);
1145
+ }
1146
+
1147
+ function trackSkillsFromText(tabId, text, { kind = "used", source = "" } = {}) {
1148
+ // Intentionally do not tag /skill:name mentions. A skill tag means the
1149
+ // agent read that skill's full SKILL.md context, not only its command/name.
1150
+ }
1151
+
1152
+ function trackSkillsFromValue(tabId, value, { keyHint = "", kind = "used", source = "", depth = 0 } = {}) {
1153
+ if (!tabId || value === undefined || value === null || depth > 5) return;
1154
+ if (Array.isArray(value)) {
1155
+ for (const item of value) trackSkillsFromValue(tabId, item, { keyHint, kind, source, depth: depth + 1 });
1156
+ return;
1157
+ }
1158
+ if (typeof value === "string") {
1159
+ const skillInfo = skillInfoFromPath(value);
1160
+ if (skillInfo) trackSkillUsage(tabId, skillInfo.name, "read", source, { path: skillInfo.path });
1161
+ return;
1162
+ }
1163
+ if (typeof value !== "object") return;
1164
+ for (const [key, nested] of Object.entries(value)) {
1165
+ const hint = String(key || "").toLowerCase();
1166
+ trackSkillsFromValue(tabId, nested, { keyHint: hint, kind, source, depth: depth + 1 });
1167
+ }
1168
+ }
1169
+
1170
+ function trackSkillsFromToolInvocation(tabId, toolName, args, { sourcePrefix = "tool" } = {}) {
1171
+ if (!tabId) return;
1172
+ const name = String(toolName || "").trim();
1173
+ if (name.toLowerCase() !== "read") return;
1174
+ const source = `${sourcePrefix}:${name}`;
1175
+ trackSkillsFromValue(tabId, args, { kind: "read", source });
1176
+ }
1177
+
1178
+ function trackSkillsFromMessage(tabId, message) {
1179
+ if (!tabId || !message) return;
1180
+ const role = String(message.role || "");
1181
+ if (role === "toolExecution" || role === "toolCall") {
1182
+ trackSkillsFromToolInvocation(tabId, message.toolName || message.name, message.arguments ?? message.args ?? {}, { sourcePrefix: `message:${role}` });
1183
+ return;
1184
+ }
1185
+ if (role === "user" || role === "assistant" || role === "assistantEvent" || role === "native") {
1186
+ trackSkillsFromText(tabId, textFromContent(message.content), { kind: "used", source: `message:${role}` });
1187
+ return;
1188
+ }
1189
+ if (role === "bashExecution") {
1190
+ trackSkillsFromText(tabId, `${message.command || ""}\n${message.output || ""}`, { kind: "used", source: "message:bash" });
1191
+ }
1192
+ }
1193
+
1194
+ function trackSkillsFromMessages(messages = latestMessages, tabId = activeTabId) {
1195
+ for (const message of messages || []) trackSkillsFromMessage(tabId, message);
1196
+ }
1197
+
1198
+ function trackSkillsFromEvent(event) {
1199
+ const tabId = event?.tabId || activeTabId;
1200
+ if (!tabId || !event) return;
1201
+ if (["tool_execution_start", "tool_execution_update", "tool_execution_end"].includes(event.type)) {
1202
+ trackSkillsFromToolInvocation(tabId, event.toolName, event.args, { sourcePrefix: `event:${event.type}` });
1203
+ return;
1204
+ }
1205
+ if (event.type === "message_update") {
1206
+ const update = event.assistantMessageEvent || {};
1207
+ if (update.type === "toolcall_start") {
1208
+ trackSkillsFromToolInvocation(tabId, update.name || update.toolName || update.toolCall?.name, update.arguments || update.args || update.toolCall?.arguments || {}, { sourcePrefix: "event:message_update" });
1209
+ }
1210
+ return;
1211
+ }
1212
+ if (event.type === "response" && event.command === "new_session") {
1213
+ clearSkillUsageForTab(tabId);
1214
+ }
1215
+ }
1216
+
1217
+ function skillPathForEntry(entry) {
1218
+ if (entry?.path) return entry.path;
1219
+ if (entry?.paths instanceof Set && entry.paths.size) {
1220
+ const paths = [...entry.paths];
1221
+ return paths[paths.length - 1] || "";
1222
+ }
1223
+ return "";
1224
+ }
1225
+
1226
+ function skillEditorApiPath({ name = "", path = "" } = {}) {
1227
+ const params = new URLSearchParams();
1228
+ if (name) params.set("name", name);
1229
+ if (path) params.set("path", path);
1230
+ const query = params.toString();
1231
+ return query ? `/api/skill-file?${query}` : "/api/skill-file";
1232
+ }
1233
+
1234
+ function setSkillEditorStatus(message = "", level = "muted") {
1235
+ const status = elements.skillEditorStatus;
1236
+ if (!status) return;
1237
+ status.textContent = message;
1238
+ status.className = `skill-editor-status ${level || "muted"}`;
1239
+ status.hidden = !message;
1240
+ }
1241
+
1242
+ function closeSkillEditor() {
1243
+ if (elements.skillEditorDialog?.open) elements.skillEditorDialog.close();
1244
+ else activeSkillEditor = null;
1245
+ }
1246
+
1247
+ function updateSkillEditorMeta(data = activeSkillEditor || {}) {
1248
+ if (!elements.skillEditorMeta) return;
1249
+ const parts = [data.name ? `Skill: ${data.name}` : "Skill", data.path || "path unavailable"].filter(Boolean);
1250
+ elements.skillEditorMeta.textContent = parts.join(" · ");
1251
+ }
1252
+
1253
+ async function openSkillEditor(entry) {
1254
+ const name = normalizeSkillName(entry?.name || "");
1255
+ const path = skillPathForEntry(entry);
1256
+ if (!name || !elements.skillEditorDialog || !elements.skillEditorText) return;
1257
+ const tabId = activeTabId;
1258
+ activeSkillEditor = { name, path, tabId, mtimeMs: null };
1259
+ if (elements.skillEditorTitle) elements.skillEditorTitle.textContent = `Edit skill: ${name}`;
1260
+ if (elements.skillEditorText) elements.skillEditorText.value = "";
1261
+ if (elements.skillEditorSaveButton) elements.skillEditorSaveButton.disabled = true;
1262
+ updateSkillEditorMeta(activeSkillEditor);
1263
+ setSkillEditorStatus("Loading skill context…", "muted");
1264
+ if (!elements.skillEditorDialog.open) elements.skillEditorDialog.showModal();
1265
+
1266
+ try {
1267
+ const response = await api(skillEditorApiPath({ name, path }), { tabId });
1268
+ if (activeSkillEditor?.tabId !== tabId || activeSkillEditor?.name !== name) return;
1269
+ const data = response.data || {};
1270
+ activeSkillEditor = { name: normalizeSkillName(data.name || name), path: data.path || path, tabId, mtimeMs: data.mtimeMs };
1271
+ if (elements.skillEditorTitle) elements.skillEditorTitle.textContent = `Edit skill: ${activeSkillEditor.name}`;
1272
+ if (elements.skillEditorText) elements.skillEditorText.value = data.content || "";
1273
+ if (elements.skillEditorSaveButton) elements.skillEditorSaveButton.disabled = false;
1274
+ updateSkillEditorMeta(activeSkillEditor);
1275
+ setSkillEditorStatus("Edit this SKILL.md, then save. Reload the tab if title/description metadata should refresh immediately.", "muted");
1276
+ queueMicrotask(() => elements.skillEditorText?.focus());
1277
+ } catch (error) {
1278
+ if (elements.skillEditorSaveButton) elements.skillEditorSaveButton.disabled = true;
1279
+ setSkillEditorStatus(`Failed to open skill: ${error.message || String(error)}`, "error");
1280
+ }
1281
+ }
1282
+
1283
+ async function saveSkillEditor() {
1284
+ if (!activeSkillEditor || !elements.skillEditorText || !elements.skillEditorSaveButton) return;
1285
+ const editor = activeSkillEditor;
1286
+ const previousLabel = elements.skillEditorSaveButton.textContent;
1287
+ elements.skillEditorSaveButton.disabled = true;
1288
+ elements.skillEditorSaveButton.textContent = "Saving…";
1289
+ setSkillEditorStatus("Saving skill…", "muted");
1290
+ try {
1291
+ const response = await api("/api/skill-file", {
1292
+ method: "POST",
1293
+ tabId: editor.tabId,
1294
+ body: {
1295
+ name: editor.name,
1296
+ path: editor.path,
1297
+ mtimeMs: editor.mtimeMs,
1298
+ content: elements.skillEditorText.value,
1299
+ },
1300
+ });
1301
+ const data = response.data || {};
1302
+ const savedName = normalizeSkillName(data.name || editor.name);
1303
+ activeSkillEditor = { name: savedName, path: data.path || editor.path, tabId: editor.tabId, mtimeMs: data.mtimeMs };
1304
+ const map = skillUsageMapForTab(editor.tabId, { create: false });
1305
+ if (map && savedName !== editor.name) map.delete(editor.name);
1306
+ trackSkillUsage(editor.tabId, savedName, "read", "skill-editor", { path: activeSkillEditor.path });
1307
+ if (elements.skillEditorTitle) elements.skillEditorTitle.textContent = `Edit skill: ${savedName}`;
1308
+ updateSkillEditorMeta(activeSkillEditor);
1309
+ setSkillEditorStatus("Saved SKILL.md. Reload/restart affected tabs before relying on updated skill metadata or newly loaded instructions.", "ok");
1310
+ } catch (error) {
1311
+ setSkillEditorStatus(`Failed to save skill: ${error.message || String(error)}`, "error");
1312
+ } finally {
1313
+ elements.skillEditorSaveButton.textContent = previousLabel || "Save skill";
1314
+ elements.skillEditorSaveButton.disabled = false;
1315
+ }
1316
+ }
1317
+
1318
+ function renderBusyPromptBehaviorTag() {
1319
+ const tag = elements.busyPromptBehaviorTag;
1320
+ if (!tag) return;
1321
+ const behavior = normalizeBusyPromptBehavior(busyPromptBehavior);
1322
+ const label = BUSY_PROMPT_BEHAVIOR_LABELS[behavior] || BUSY_PROMPT_BEHAVIOR_LABELS.followUp;
1323
+ tag.textContent = label;
1324
+ tag.classList.toggle("follow-up", behavior === "followUp");
1325
+ tag.classList.toggle("steer", behavior === "steer");
1326
+ tag.title = behavior === "steer"
1327
+ ? "While Pi is running, normal prompt submit steers the active run. Click to change."
1328
+ : "While Pi is running, normal prompt submit queues a follow-up. Click to change.";
1329
+ tag.setAttribute("aria-label", tag.title);
1330
+ renderBusyPromptBehaviorMenu();
1331
+ renderSessionSkillTags(activeTabId);
1332
+ }
1333
+
1334
+ function setBusyPromptBehaviorMenuOpen(open, { focusCurrent = false } = {}) {
1335
+ busyPromptBehaviorMenuOpen = !!open;
1336
+ elements.busyPromptBehaviorTag?.setAttribute("aria-expanded", busyPromptBehaviorMenuOpen ? "true" : "false");
1337
+ elements.busyPromptBehaviorTag?.classList.toggle("menu-open", busyPromptBehaviorMenuOpen);
1338
+ if (elements.busyPromptBehaviorMenu) elements.busyPromptBehaviorMenu.hidden = !busyPromptBehaviorMenuOpen;
1339
+ if (!busyPromptBehaviorMenuOpen) return;
1340
+ renderBusyPromptBehaviorMenu();
1341
+ if (focusCurrent) {
1342
+ requestAnimationFrame(() => {
1343
+ const current = busyPromptBehaviorMenuItems().find((item) => item.getAttribute("aria-checked") === "true") || busyPromptBehaviorMenuItems()[0];
1344
+ current?.focus({ preventScroll: true });
1345
+ });
1346
+ }
1347
+ }
1348
+
1349
+ function focusBusyPromptBehaviorMenuItem(direction = 1) {
1350
+ const items = busyPromptBehaviorMenuItems();
1351
+ if (!items.length) return;
1352
+ const currentIndex = Math.max(0, items.indexOf(document.activeElement));
1353
+ const nextIndex = (currentIndex + direction + items.length) % items.length;
1354
+ items[nextIndex].focus({ preventScroll: true });
1355
+ }
1356
+
1357
+ function chooseBusyPromptBehaviorFromMenu(value) {
1358
+ setBusyPromptBehavior(value);
1359
+ setBusyPromptBehaviorMenuOpen(false);
1360
+ focusPromptInput({ defer: true });
1361
+ }
1362
+
1363
+ function setBusyPromptBehavior(value, { persist = true } = {}) {
1364
+ const next = normalizeBusyPromptBehavior(value);
1365
+ busyPromptBehavior = next;
1366
+ webuiSettings = { ...webuiSettings, busyPromptBehavior: next };
1367
+ if (persist) persistBusyPromptBehavior(next);
1368
+ renderBusyPromptBehaviorTag();
1369
+ }
1370
+
1371
+ function restoreBusyPromptBehaviorSetting() {
1372
+ setBusyPromptBehavior(readStoredBusyPromptBehavior(), { persist: false });
1373
+ }
1374
+
858
1375
  function clampAutocompleteMaxVisible(value) {
859
1376
  const number = Number(value);
860
1377
  if (!Number.isFinite(number)) return 12;
@@ -867,6 +1384,7 @@ function applyNativeSettingsForBrowser(settings = {}, { syncThinkingVisibility =
867
1384
  if (settings.autocompleteMaxVisible !== undefined) autocompleteMaxVisible = clampAutocompleteMaxVisible(settings.autocompleteMaxVisible);
868
1385
  if (SETTINGS_DOUBLE_ESCAPE_OPTIONS.some((option) => option.value === settings.doubleEscapeAction)) doubleEscapeAction = settings.doubleEscapeAction;
869
1386
  if (SETTINGS_TREE_FILTER_OPTIONS.includes(settings.treeFilterMode)) treeFilterMode = settings.treeFilterMode;
1387
+ if (BUSY_PROMPT_BEHAVIOR_VALUES.has(settings.busyPromptBehavior)) setBusyPromptBehavior(settings.busyPromptBehavior);
870
1388
  if (syncThinkingVisibility && typeof settings.hideThinkingBlock === "boolean") setThinkingOutputVisible(!settings.hideThinkingBlock);
871
1389
  }
872
1390
 
@@ -886,6 +1404,7 @@ function setComposerActionsOpen(open) {
886
1404
  setNativeCommandMenuOpen(false);
887
1405
  setAppRunnerMenuOpen(false);
888
1406
  setOptionsMenuOpen(false);
1407
+ setBusyPromptBehaviorMenuOpen(false);
889
1408
  }
890
1409
  }
891
1410
 
@@ -943,6 +1462,7 @@ function updateComposerModeButtons() {
943
1462
  elements.abortButton.textContent = abortRequestInFlight ? "Aborting…" : "Abort";
944
1463
  elements.abortButton.title = abortAvailable ? "Abort the active Pi run (Esc or hold)" : "Abort is available while Pi is running";
945
1464
  elements.abortButton.setAttribute("aria-label", elements.abortButton.title);
1465
+ renderBusyPromptBehaviorTag();
946
1466
  document.body.classList.toggle("pi-run-active", runActive || abortAvailable);
947
1467
  }
948
1468
 
@@ -2667,9 +3187,11 @@ function syncTabMetadata(nextTabs = []) {
2667
3187
  tabActivities.delete(tabId);
2668
3188
  tabSeenCompletionSerials.delete(tabId);
2669
3189
  actionFeedbackByTab.delete(tabId);
3190
+ skillUsageByTab.delete(tabId);
2670
3191
  clearGitWorkflowForTab(tabId);
2671
3192
  }
2672
3193
  }
3194
+ pruneSkillUsageForKnownTabs(liveIds);
2673
3195
  }
2674
3196
 
2675
3197
  function applyTabMetadata(tab) {
@@ -2876,7 +3398,7 @@ function restoreActiveDraft() {
2876
3398
 
2877
3399
  function focusPromptInput({ defer = false } = {}) {
2878
3400
  const focus = () => {
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;
3401
+ if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.nativeCommandDialog.open || elements.appRunnerInfoDialog?.open || elements.promptListDialog?.open || elements.attachmentTextDialog?.open || elements.skillEditorDialog?.open || document.visibilityState === "hidden") return;
2880
3402
  try {
2881
3403
  elements.promptInput.focus({ preventScroll: true });
2882
3404
  } catch {
@@ -3231,6 +3753,7 @@ async function refreshTabs({ selectStored = false } = {}) {
3231
3753
  setActiveTabId((requested && tabs.some((tab) => tab.id === requested) ? requested : stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null, { remember: true });
3232
3754
  }
3233
3755
  rememberServerStartCwd(tabs.find((tab) => tab.id === activeTabId)?.cwd || tabs[0]?.cwd);
3756
+ renderSessionSkillTags(activeTabId);
3234
3757
  renderTabs();
3235
3758
  return tabs;
3236
3759
  }
@@ -10155,7 +10678,7 @@ async function openNativeSettingsDialog() {
10155
10678
  if (controls.steering.select.value !== (state.steeringMode || "one-at-a-time")) requests.push(nativeCommandApi("/api/steering-mode", { method: "POST", body: { mode: controls.steering.select.value } }));
10156
10679
  if (controls.followUp.select.value !== (state.followUpMode || "one-at-a-time")) requests.push(nativeCommandApi("/api/follow-up-mode", { method: "POST", body: { mode: controls.followUp.select.value } }));
10157
10680
  if (controls.autoCompact.input.checked !== (state.autoCompactionEnabled !== false)) requests.push(nativeCommandApi("/api/auto-compaction", { method: "POST", body: { enabled: controls.autoCompact.input.checked } }));
10158
- busyPromptBehavior = controls.busyBehavior.select.value;
10681
+ setBusyPromptBehavior(controls.busyBehavior.select.value);
10159
10682
  if (controls.thinkingOutput.input.checked !== thinkingOutputVisible) setThinkingOutputVisible(controls.thinkingOutput.input.checked);
10160
10683
  if (controls.doneNotifications.input.checked !== agentDoneNotificationsEnabled) await setAgentDoneNotificationsEnabled(controls.doneNotifications.input.checked);
10161
10684
  await Promise.all(requests);
@@ -10628,6 +11151,7 @@ function renderMessages(messages) {
10628
11151
  cleanupLiveToolRunsForMessages(latestMessages);
10629
11152
  syncLastUserPromptFromMessages(latestMessages);
10630
11153
  syncPromptHistoryFromMessages(latestMessages);
11154
+ trackSkillsFromMessages(latestMessages, activeTabId);
10631
11155
  renderAllMessages();
10632
11156
  renderFooter();
10633
11157
  renderFeedbackTray();
@@ -11877,7 +12401,7 @@ async function sendPrompt(kind = "prompt", explicitMessage, { targetTabId = acti
11877
12401
  }
11878
12402
 
11879
12403
  const targetWasStreaming = !!currentState?.isStreaming;
11880
- const busyBehavior = busyPromptBehavior || "followUp";
12404
+ const busyBehavior = normalizeBusyPromptBehavior(busyPromptBehavior);
11881
12405
  const startsRun = kind === "prompt" && !targetWasStreaming;
11882
12406
  autoFollowChat = true;
11883
12407
  updateJumpToLatestButton();
@@ -12138,6 +12662,7 @@ function handleInactiveTabEvent(event) {
12138
12662
 
12139
12663
  function handleEvent(event) {
12140
12664
  ingestEventTabActivity(event);
12665
+ trackSkillsFromEvent(event);
12141
12666
  if (!eventTargetsActiveTab(event)) {
12142
12667
  handleInactiveTabEvent(event);
12143
12668
  return;
@@ -12435,6 +12960,20 @@ elements.attachmentTextDialog?.addEventListener("keydown", (event) => {
12435
12960
  if (!elements.attachmentTextSaveButton?.disabled) saveTextAttachmentEdit();
12436
12961
  });
12437
12962
  elements.attachmentTextDialog?.querySelector("form")?.addEventListener("submit", (event) => event.preventDefault());
12963
+ elements.skillEditorCancelButton?.addEventListener("click", closeSkillEditor);
12964
+ elements.skillEditorSaveButton?.addEventListener("click", saveSkillEditor);
12965
+ elements.skillEditorText?.addEventListener("input", () => setSkillEditorStatus("Unsaved skill edits.", "warn"));
12966
+ elements.skillEditorDialog?.addEventListener("close", () => {
12967
+ activeSkillEditor = null;
12968
+ if (elements.skillEditorText) elements.skillEditorText.value = "";
12969
+ setSkillEditorStatus("");
12970
+ });
12971
+ elements.skillEditorDialog?.addEventListener("keydown", (event) => {
12972
+ if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "s") return;
12973
+ event.preventDefault();
12974
+ if (!elements.skillEditorSaveButton?.disabled) saveSkillEditor();
12975
+ });
12976
+ elements.skillEditorDialog?.querySelector("form")?.addEventListener("submit", (event) => event.preventDefault());
12438
12977
  elements.sendFeedbackButton.addEventListener("click", () => submitQueuedActionFeedback());
12439
12978
  elements.composer.addEventListener("submit", (event) => {
12440
12979
  event.preventDefault();
@@ -12443,6 +12982,56 @@ elements.composer.addEventListener("submit", (event) => {
12443
12982
  elements.composerActionsButton.addEventListener("click", () => {
12444
12983
  setComposerActionsOpen(!document.body.classList.contains("composer-actions-open"));
12445
12984
  });
12985
+ elements.busyPromptBehaviorTag?.addEventListener("click", (event) => {
12986
+ event.preventDefault();
12987
+ const nextOpen = !busyPromptBehaviorMenuOpen;
12988
+ setPublishMenuOpen(false);
12989
+ setNativeCommandMenuOpen(false);
12990
+ setAppRunnerMenuOpen(false);
12991
+ setOptionsMenuOpen(false);
12992
+ setComposerActionsOpen(false);
12993
+ setBusyPromptBehaviorMenuOpen(nextOpen);
12994
+ });
12995
+ elements.busyPromptBehaviorTag?.addEventListener("keydown", (event) => {
12996
+ if (!["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) return;
12997
+ event.preventDefault();
12998
+ const focusPrevious = event.key === "ArrowUp";
12999
+ setBusyPromptBehaviorMenuOpen(true);
13000
+ requestAnimationFrame(() => {
13001
+ const items = busyPromptBehaviorMenuItems();
13002
+ if (!items.length) return;
13003
+ const currentIndex = Math.max(0, items.findIndex((item) => item.getAttribute("aria-checked") === "true"));
13004
+ const targetIndex = focusPrevious ? (currentIndex - 1 + items.length) % items.length : currentIndex;
13005
+ items[targetIndex]?.focus({ preventScroll: true });
13006
+ });
13007
+ });
13008
+ elements.busyPromptBehaviorMenu?.addEventListener("click", (event) => {
13009
+ const item = event.target?.closest?.("[data-busy-prompt-behavior]");
13010
+ if (!item) return;
13011
+ chooseBusyPromptBehaviorFromMenu(item.dataset.busyPromptBehavior);
13012
+ });
13013
+ elements.busyPromptBehaviorMenu?.addEventListener("keydown", (event) => {
13014
+ if (event.key === "Escape") {
13015
+ event.preventDefault();
13016
+ setBusyPromptBehaviorMenuOpen(false);
13017
+ elements.busyPromptBehaviorTag?.focus({ preventScroll: true });
13018
+ return;
13019
+ }
13020
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
13021
+ event.preventDefault();
13022
+ focusBusyPromptBehaviorMenuItem(event.key === "ArrowDown" ? 1 : -1);
13023
+ return;
13024
+ }
13025
+ if (event.key === "Home" || event.key === "End") {
13026
+ event.preventDefault();
13027
+ const items = busyPromptBehaviorMenuItems();
13028
+ items[event.key === "Home" ? 0 : items.length - 1]?.focus({ preventScroll: true });
13029
+ return;
13030
+ }
13031
+ if (event.key === "Tab") {
13032
+ setBusyPromptBehaviorMenuOpen(false);
13033
+ }
13034
+ });
12446
13035
  elements.steerButton.addEventListener("click", () => sendPromptFromModeButton("steer", elements.steerButton));
12447
13036
  elements.followUpButton.addEventListener("click", () => sendPromptFromModeButton("follow-up", elements.followUpButton));
12448
13037
  elements.terminalTabsToggleButton.addEventListener("click", () => {
@@ -12831,6 +13420,11 @@ if (elements.thinkingVisibilityToggle) {
12831
13420
  setThinkingOutputVisible(elements.thinkingVisibilityToggle.checked, { announce: true });
12832
13421
  });
12833
13422
  }
13423
+ if (elements.terminalTabsLayoutSelect) {
13424
+ elements.terminalTabsLayoutSelect.addEventListener("change", () => {
13425
+ setTerminalTabsLayout(elements.terminalTabsLayoutSelect.value, { announce: true });
13426
+ });
13427
+ }
12834
13428
  elements.toggleSidePanelButton.addEventListener("click", () => {
12835
13429
  setSidePanelCollapsed(true);
12836
13430
  });
@@ -12874,6 +13468,9 @@ document.addEventListener("pointerdown", (event) => {
12874
13468
  if (optionsMenuOpen && !event.target?.closest?.(".composer-options-menu")) {
12875
13469
  setOptionsMenuOpen(false);
12876
13470
  }
13471
+ if (busyPromptBehaviorMenuOpen && !event.target?.closest?.(".composer-context-tags, .composer-busy-mode-menu")) {
13472
+ setBusyPromptBehaviorMenuOpen(false);
13473
+ }
12877
13474
  if (document.body.classList.contains("mobile-tabs-expanded") && !elements.tabBar.contains(event.target) && !elements.terminalTabsToggleButton.contains(event.target)) {
12878
13475
  setNewTabMenuOpen(false);
12879
13476
  setMobileTabsExpanded(false);
@@ -12974,6 +13571,11 @@ window.addEventListener("keydown", (event) => {
12974
13571
  setOptionsMenuOpen(false);
12975
13572
  return;
12976
13573
  }
13574
+ if (busyPromptBehaviorMenuOpen) {
13575
+ setBusyPromptBehaviorMenuOpen(false);
13576
+ elements.busyPromptBehaviorTag?.focus({ preventScroll: true });
13577
+ return;
13578
+ }
12977
13579
  if (newTabMenuOpen) {
12978
13580
  setNewTabMenuOpen(false);
12979
13581
  return;
@@ -13126,6 +13728,8 @@ elements.promptInput.addEventListener("blur", () => {
13126
13728
 
13127
13729
  resizePromptInput();
13128
13730
  focusPromptInput({ defer: true });
13731
+ restoreStoredSkillUsage();
13732
+ restoreBusyPromptBehaviorSetting();
13129
13733
  updateComposerModeButtons();
13130
13734
  updateOptionalFeatureAvailability();
13131
13735
  renderAppRunnerControls();
@@ -13142,6 +13746,7 @@ initializeThemes().catch((error) => {
13142
13746
  initializeFastPicks().catch((error) => addEvent(`failed to initialize path fast picks: ${error.message}`, "error"));
13143
13747
  restoreAgentDoneNotificationsSetting();
13144
13748
  restoreThinkingVisibilitySetting();
13749
+ restoreTerminalTabsLayoutSetting();
13145
13750
  restoreToolOutputExpansionSetting();
13146
13751
  restoreSidePanelSectionState();
13147
13752
  bindSidePanelSectionToggles();