@firstpick/pi-package-webui 0.3.4 → 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/bin/pi-webui.mjs CHANGED
@@ -33,6 +33,7 @@ const CODEX_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
33
33
  const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
34
34
  const OPENAI_CODEX_USAGE_ENDPOINT = process.env.PI_WEBUI_CODEX_USAGE_URL || "https://chatgpt.com/backend-api/wham/usage";
35
35
  const BODY_LIMIT_BYTES = 1024 * 1024;
36
+ const SKILL_FILE_BODY_LIMIT_BYTES = 2 * 1024 * 1024;
36
37
  const PROMPT_BODY_LIMIT_BYTES = 24 * 1024 * 1024;
37
38
  const UPLOAD_BODY_LIMIT_BYTES = 96 * 1024 * 1024;
38
39
  const ATTACHMENT_UPLOAD_MAX_FILES = 12;
@@ -4242,6 +4243,109 @@ async function getMergedSkillConfigData(tab) {
4242
4243
  return { skills: mergeRuntimeAndResolvedSkills(runtime.skills || [], resolved.skills || []) };
4243
4244
  }
4244
4245
 
4246
+ function normalizeSkillRequestName(value) {
4247
+ return String(value || "").trim().replace(/^skill:/i, "").toLowerCase();
4248
+ }
4249
+
4250
+ function skillFileRequestParts(source = {}) {
4251
+ return {
4252
+ name: normalizeSkillRequestName(source.name || source.skillName),
4253
+ filePath: String(source.path || source.filePath || "").trim(),
4254
+ };
4255
+ }
4256
+
4257
+ function sameResolvedPath(left, right) {
4258
+ if (!left || !right) return false;
4259
+ return path.resolve(left) === path.resolve(right);
4260
+ }
4261
+
4262
+ function skillFilePathInside(root, target) {
4263
+ if (!root || !target) return false;
4264
+ const relative = path.relative(path.resolve(root), path.resolve(target));
4265
+ return !relative || (!relative.startsWith("..") && !path.isAbsolute(relative));
4266
+ }
4267
+
4268
+ function skillNameFromSkillFilePath(filePath) {
4269
+ const normalized = String(filePath || "").replace(/\\/g, "/");
4270
+ const match = normalized.match(/\/skills\/([^/]+)\/SKILL\.md$/i);
4271
+ return normalizeSkillRequestName(match?.[1] || "");
4272
+ }
4273
+
4274
+ async function resolveExplicitSkillFilePath(tab, filePath, requestedName = "") {
4275
+ const resolvedPath = path.resolve(filePath || "");
4276
+ const pathSkillName = skillNameFromSkillFilePath(resolvedPath);
4277
+ if (!pathSkillName) throw makeHttpError(400, "Skill path must point to /skills/<name>/SKILL.md");
4278
+ if (requestedName && requestedName !== pathSkillName) throw makeHttpError(400, "Skill name does not match the requested SKILL.md path");
4279
+ const allowedRoots = [agentDir, path.join(tab?.cwd || options.cwd, ".pi")];
4280
+ if (!allowedRoots.some((root) => skillFilePathInside(root, resolvedPath))) {
4281
+ throw makeHttpError(403, "Skill path is outside allowed Pi skill locations");
4282
+ }
4283
+ const info = await stat(resolvedPath).catch(() => null);
4284
+ if (!info?.isFile()) throw makeHttpError(404, `Skill file not found: ${resolvedPath}`);
4285
+ return {
4286
+ name: pathSkillName,
4287
+ description: "",
4288
+ filePath: resolvedPath,
4289
+ enabled: true,
4290
+ fileStats: info,
4291
+ };
4292
+ }
4293
+
4294
+ async function resolveEditableSkillFile(tab, request = {}) {
4295
+ const { name, filePath } = skillFileRequestParts(request);
4296
+ if (!name && !filePath) throw makeHttpError(400, "Skill name or path is required");
4297
+ const { skills } = await resolveSkillResources(tab);
4298
+ const skill = skills.find((item) => (
4299
+ filePath ? sameResolvedPath(item.filePath, filePath) : name && normalizeSkillRequestName(item.name) === name
4300
+ ));
4301
+ if (skill?.filePath) {
4302
+ if (path.basename(skill.filePath) !== "SKILL.md") throw makeHttpError(400, "Only SKILL.md files can be edited from skill tags");
4303
+ const info = await stat(skill.filePath).catch(() => null);
4304
+ if (!info?.isFile()) throw makeHttpError(404, `Skill file not found: ${skill.filePath}`);
4305
+ return { ...skill, filePath: path.resolve(skill.filePath), fileStats: info };
4306
+ }
4307
+ if (filePath) return resolveExplicitSkillFilePath(tab, filePath, name);
4308
+ throw makeHttpError(404, "Skill is not configured in this Pi tab");
4309
+ }
4310
+
4311
+ async function getSkillFileData(tab, request = {}) {
4312
+ const skill = await resolveEditableSkillFile(tab, request);
4313
+ const content = await readFile(skill.filePath, "utf8");
4314
+ return {
4315
+ name: parseSkillFrontmatter(content, skill.filePath).name || skill.name,
4316
+ description: skill.description || "",
4317
+ path: skill.filePath,
4318
+ content,
4319
+ mtimeMs: skill.fileStats.mtimeMs,
4320
+ size: skill.fileStats.size,
4321
+ enabled: skill.enabled === true,
4322
+ };
4323
+ }
4324
+
4325
+ async function saveSkillFileData(tab, body = {}) {
4326
+ if (typeof body.content !== "string") throw makeHttpError(400, "Skill content must be a string");
4327
+ if (body.content.includes("\0")) throw makeHttpError(400, "Skill content cannot contain null bytes");
4328
+ if (Buffer.byteLength(body.content, "utf8") > SKILL_FILE_BODY_LIMIT_BYTES) throw makeHttpError(413, `Skill file is too large (limit ${formatBytes(SKILL_FILE_BODY_LIMIT_BYTES)})`);
4329
+ const skill = await resolveEditableSkillFile(tab, body);
4330
+ const expectedMtimeMs = Number(body.mtimeMs);
4331
+ if (Number.isFinite(expectedMtimeMs) && Math.abs(skill.fileStats.mtimeMs - expectedMtimeMs) > 5) {
4332
+ throw makeHttpError(409, "Skill file changed on disk after it was opened. Reopen it before saving.");
4333
+ }
4334
+ const tmpFile = `${skill.filePath}.${process.pid}.${Date.now()}.tmp`;
4335
+ await writeFile(tmpFile, body.content, { encoding: "utf8", mode: skill.fileStats.mode & 0o777 });
4336
+ await rename(tmpFile, skill.filePath);
4337
+ const nextStats = await stat(skill.filePath);
4338
+ const metadata = parseSkillFrontmatter(body.content, skill.filePath);
4339
+ return {
4340
+ name: metadata.name || skill.name,
4341
+ description: metadata.description || skill.description || "",
4342
+ path: skill.filePath,
4343
+ mtimeMs: nextStats.mtimeMs,
4344
+ size: nextStats.size,
4345
+ enabled: skill.enabled === true,
4346
+ };
4347
+ }
4348
+
4245
4349
  function getResourcePatternForSkill(tab, skill) {
4246
4350
  const info = skill.sourceInfo || {};
4247
4351
  const baseDir = info.baseDir || (info.scope === "project" ? path.join(tab?.cwd || options.cwd, ".pi") : agentDir);
@@ -5685,6 +5789,20 @@ const server = createServer(async (req, res) => {
5685
5789
  return;
5686
5790
  }
5687
5791
 
5792
+ if (url.pathname === "/api/skill-file" && req.method === "GET") {
5793
+ const tab = getRequestedTab(req, url);
5794
+ sendJson(res, 200, { ok: true, data: await getSkillFileData(tab, { name: url.searchParams.get("name"), path: url.searchParams.get("path") }) });
5795
+ return;
5796
+ }
5797
+
5798
+ if (url.pathname === "/api/skill-file" && req.method === "POST") {
5799
+ if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Saving skill files is only allowed from localhost");
5800
+ const body = await readJsonBody(req, { limitBytes: SKILL_FILE_BODY_LIMIT_BYTES });
5801
+ const tab = getRequestedTab(req, url, body);
5802
+ sendJson(res, 200, { ok: true, data: await saveSkillFileData(tab, body) });
5803
+ return;
5804
+ }
5805
+
5688
5806
  if (url.pathname === "/api/settings" && req.method === "GET") {
5689
5807
  const tab = getRequestedTab(req, url);
5690
5808
  sendJson(res, 200, { ok: true, data: nativeSettingsPayload(settingsManagerForTab(tab)) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-package-webui#readme",
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"),
@@ -174,6 +184,7 @@ let activeTabGeneration = 0;
174
184
  let tabDrafts = new Map();
175
185
  let tabAttachments = new Map();
176
186
  let activeTextAttachmentEditor = null;
187
+ let activeSkillEditor = null;
177
188
  let tabActivities = new Map();
178
189
  let tabSeenCompletionSerials = new Map();
179
190
  let streamBubble = null;
@@ -213,6 +224,8 @@ let openTerminalTabGroupKey = null;
213
224
  let newTabMenuOpen = false;
214
225
  let nativeCommandMenuOpen = false;
215
226
  let appRunnerMenuOpen = false;
227
+ let busyPromptBehaviorMenuOpen = false;
228
+ const skillUsageByTab = new Map();
216
229
  let appRunnerCustomDraft = { id: "", label: "", command: "./", path: "", args: "" };
217
230
  let appRunnerFileBrowserState = { open: false, loading: false, path: "", data: null, error: "" };
218
231
  let optionsMenuOpen = false;
@@ -299,6 +312,8 @@ const TAB_STORAGE_KEY = "pi-webui-active-tab";
299
312
  const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
300
313
  const AGENT_DONE_NOTIFICATIONS_STORAGE_KEY = "pi-webui-agent-done-notifications";
301
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";
302
317
  const TERMINAL_TABS_LAYOUT_STORAGE_KEY = "pi-webui-terminal-tabs-layout";
303
318
  const TOOL_OUTPUT_EXPANDED_STORAGE_KEY = "pi-webui-tool-output-expanded";
304
319
  const THEME_STORAGE_KEY = "pi-webui-theme";
@@ -331,6 +346,10 @@ const BACKGROUND_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/w
331
346
  const DEFAULT_THEME_NAME = "catppuccin-mocha";
332
347
  const TERMINAL_TABS_LAYOUTS = new Set(["top", "left"]);
333
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;
334
353
  const MOBILE_VIEW_QUERY = "(max-width: 720px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
335
354
  const SIDE_PANEL_OVERLAY_QUERY = "(max-width: 1050px), (max-device-width: 720px), (pointer: coarse) and (hover: none)";
336
355
  const CHAT_BOTTOM_THRESHOLD_PX = 96;
@@ -905,6 +924,454 @@ function restoreThinkingVisibilitySetting() {
905
924
  renderThinkingVisibilityToggle();
906
925
  }
907
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
+
908
1375
  function clampAutocompleteMaxVisible(value) {
909
1376
  const number = Number(value);
910
1377
  if (!Number.isFinite(number)) return 12;
@@ -917,6 +1384,7 @@ function applyNativeSettingsForBrowser(settings = {}, { syncThinkingVisibility =
917
1384
  if (settings.autocompleteMaxVisible !== undefined) autocompleteMaxVisible = clampAutocompleteMaxVisible(settings.autocompleteMaxVisible);
918
1385
  if (SETTINGS_DOUBLE_ESCAPE_OPTIONS.some((option) => option.value === settings.doubleEscapeAction)) doubleEscapeAction = settings.doubleEscapeAction;
919
1386
  if (SETTINGS_TREE_FILTER_OPTIONS.includes(settings.treeFilterMode)) treeFilterMode = settings.treeFilterMode;
1387
+ if (BUSY_PROMPT_BEHAVIOR_VALUES.has(settings.busyPromptBehavior)) setBusyPromptBehavior(settings.busyPromptBehavior);
920
1388
  if (syncThinkingVisibility && typeof settings.hideThinkingBlock === "boolean") setThinkingOutputVisible(!settings.hideThinkingBlock);
921
1389
  }
922
1390
 
@@ -936,6 +1404,7 @@ function setComposerActionsOpen(open) {
936
1404
  setNativeCommandMenuOpen(false);
937
1405
  setAppRunnerMenuOpen(false);
938
1406
  setOptionsMenuOpen(false);
1407
+ setBusyPromptBehaviorMenuOpen(false);
939
1408
  }
940
1409
  }
941
1410
 
@@ -993,6 +1462,7 @@ function updateComposerModeButtons() {
993
1462
  elements.abortButton.textContent = abortRequestInFlight ? "Aborting…" : "Abort";
994
1463
  elements.abortButton.title = abortAvailable ? "Abort the active Pi run (Esc or hold)" : "Abort is available while Pi is running";
995
1464
  elements.abortButton.setAttribute("aria-label", elements.abortButton.title);
1465
+ renderBusyPromptBehaviorTag();
996
1466
  document.body.classList.toggle("pi-run-active", runActive || abortAvailable);
997
1467
  }
998
1468
 
@@ -2717,9 +3187,11 @@ function syncTabMetadata(nextTabs = []) {
2717
3187
  tabActivities.delete(tabId);
2718
3188
  tabSeenCompletionSerials.delete(tabId);
2719
3189
  actionFeedbackByTab.delete(tabId);
3190
+ skillUsageByTab.delete(tabId);
2720
3191
  clearGitWorkflowForTab(tabId);
2721
3192
  }
2722
3193
  }
3194
+ pruneSkillUsageForKnownTabs(liveIds);
2723
3195
  }
2724
3196
 
2725
3197
  function applyTabMetadata(tab) {
@@ -2926,7 +3398,7 @@ function restoreActiveDraft() {
2926
3398
 
2927
3399
  function focusPromptInput({ defer = false } = {}) {
2928
3400
  const focus = () => {
2929
- if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.nativeCommandDialog.open || elements.appRunnerInfoDialog?.open || elements.promptListDialog?.open || elements.attachmentTextDialog?.open || document.visibilityState === "hidden") return;
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;
2930
3402
  try {
2931
3403
  elements.promptInput.focus({ preventScroll: true });
2932
3404
  } catch {
@@ -3281,6 +3753,7 @@ async function refreshTabs({ selectStored = false } = {}) {
3281
3753
  setActiveTabId((requested && tabs.some((tab) => tab.id === requested) ? requested : stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null, { remember: true });
3282
3754
  }
3283
3755
  rememberServerStartCwd(tabs.find((tab) => tab.id === activeTabId)?.cwd || tabs[0]?.cwd);
3756
+ renderSessionSkillTags(activeTabId);
3284
3757
  renderTabs();
3285
3758
  return tabs;
3286
3759
  }
@@ -10205,7 +10678,7 @@ async function openNativeSettingsDialog() {
10205
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 } }));
10206
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 } }));
10207
10680
  if (controls.autoCompact.input.checked !== (state.autoCompactionEnabled !== false)) requests.push(nativeCommandApi("/api/auto-compaction", { method: "POST", body: { enabled: controls.autoCompact.input.checked } }));
10208
- busyPromptBehavior = controls.busyBehavior.select.value;
10681
+ setBusyPromptBehavior(controls.busyBehavior.select.value);
10209
10682
  if (controls.thinkingOutput.input.checked !== thinkingOutputVisible) setThinkingOutputVisible(controls.thinkingOutput.input.checked);
10210
10683
  if (controls.doneNotifications.input.checked !== agentDoneNotificationsEnabled) await setAgentDoneNotificationsEnabled(controls.doneNotifications.input.checked);
10211
10684
  await Promise.all(requests);
@@ -10678,6 +11151,7 @@ function renderMessages(messages) {
10678
11151
  cleanupLiveToolRunsForMessages(latestMessages);
10679
11152
  syncLastUserPromptFromMessages(latestMessages);
10680
11153
  syncPromptHistoryFromMessages(latestMessages);
11154
+ trackSkillsFromMessages(latestMessages, activeTabId);
10681
11155
  renderAllMessages();
10682
11156
  renderFooter();
10683
11157
  renderFeedbackTray();
@@ -11927,7 +12401,7 @@ async function sendPrompt(kind = "prompt", explicitMessage, { targetTabId = acti
11927
12401
  }
11928
12402
 
11929
12403
  const targetWasStreaming = !!currentState?.isStreaming;
11930
- const busyBehavior = busyPromptBehavior || "followUp";
12404
+ const busyBehavior = normalizeBusyPromptBehavior(busyPromptBehavior);
11931
12405
  const startsRun = kind === "prompt" && !targetWasStreaming;
11932
12406
  autoFollowChat = true;
11933
12407
  updateJumpToLatestButton();
@@ -12188,6 +12662,7 @@ function handleInactiveTabEvent(event) {
12188
12662
 
12189
12663
  function handleEvent(event) {
12190
12664
  ingestEventTabActivity(event);
12665
+ trackSkillsFromEvent(event);
12191
12666
  if (!eventTargetsActiveTab(event)) {
12192
12667
  handleInactiveTabEvent(event);
12193
12668
  return;
@@ -12485,6 +12960,20 @@ elements.attachmentTextDialog?.addEventListener("keydown", (event) => {
12485
12960
  if (!elements.attachmentTextSaveButton?.disabled) saveTextAttachmentEdit();
12486
12961
  });
12487
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());
12488
12977
  elements.sendFeedbackButton.addEventListener("click", () => submitQueuedActionFeedback());
12489
12978
  elements.composer.addEventListener("submit", (event) => {
12490
12979
  event.preventDefault();
@@ -12493,6 +12982,56 @@ elements.composer.addEventListener("submit", (event) => {
12493
12982
  elements.composerActionsButton.addEventListener("click", () => {
12494
12983
  setComposerActionsOpen(!document.body.classList.contains("composer-actions-open"));
12495
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
+ });
12496
13035
  elements.steerButton.addEventListener("click", () => sendPromptFromModeButton("steer", elements.steerButton));
12497
13036
  elements.followUpButton.addEventListener("click", () => sendPromptFromModeButton("follow-up", elements.followUpButton));
12498
13037
  elements.terminalTabsToggleButton.addEventListener("click", () => {
@@ -12929,6 +13468,9 @@ document.addEventListener("pointerdown", (event) => {
12929
13468
  if (optionsMenuOpen && !event.target?.closest?.(".composer-options-menu")) {
12930
13469
  setOptionsMenuOpen(false);
12931
13470
  }
13471
+ if (busyPromptBehaviorMenuOpen && !event.target?.closest?.(".composer-context-tags, .composer-busy-mode-menu")) {
13472
+ setBusyPromptBehaviorMenuOpen(false);
13473
+ }
12932
13474
  if (document.body.classList.contains("mobile-tabs-expanded") && !elements.tabBar.contains(event.target) && !elements.terminalTabsToggleButton.contains(event.target)) {
12933
13475
  setNewTabMenuOpen(false);
12934
13476
  setMobileTabsExpanded(false);
@@ -13029,6 +13571,11 @@ window.addEventListener("keydown", (event) => {
13029
13571
  setOptionsMenuOpen(false);
13030
13572
  return;
13031
13573
  }
13574
+ if (busyPromptBehaviorMenuOpen) {
13575
+ setBusyPromptBehaviorMenuOpen(false);
13576
+ elements.busyPromptBehaviorTag?.focus({ preventScroll: true });
13577
+ return;
13578
+ }
13032
13579
  if (newTabMenuOpen) {
13033
13580
  setNewTabMenuOpen(false);
13034
13581
  return;
@@ -13181,6 +13728,8 @@ elements.promptInput.addEventListener("blur", () => {
13181
13728
 
13182
13729
  resizePromptInput();
13183
13730
  focusPromptInput({ defer: true });
13731
+ restoreStoredSkillUsage();
13732
+ restoreBusyPromptBehaviorSetting();
13184
13733
  updateComposerModeButtons();
13185
13734
  updateOptionalFeatureAvailability();
13186
13735
  renderAppRunnerControls();
package/public/index.html CHANGED
@@ -85,6 +85,28 @@
85
85
  </section>
86
86
  <form id="composer" class="composer">
87
87
  <div class="composer-input-row">
88
+ <div class="composer-context-tags">
89
+ <button
90
+ id="busyPromptBehaviorTag"
91
+ class="composer-busy-mode-tag"
92
+ type="button"
93
+ aria-haspopup="menu"
94
+ aria-expanded="false"
95
+ aria-controls="busyPromptBehaviorMenu"
96
+ aria-live="polite"
97
+ >Follow-up</button>
98
+ <div id="sessionSkillTags" class="composer-skill-tags" aria-live="polite" hidden></div>
99
+ </div>
100
+ <div id="busyPromptBehaviorMenu" class="composer-busy-mode-menu" role="menu" aria-labelledby="busyPromptBehaviorTag" hidden>
101
+ <button class="composer-busy-mode-menu-item" type="button" role="menuitemradio" data-busy-prompt-behavior="followUp" aria-checked="true">
102
+ <span class="composer-busy-mode-menu-label">Follow-up</span>
103
+ <span class="composer-busy-mode-menu-description">Queue as the next prompt after this run.</span>
104
+ </button>
105
+ <button class="composer-busy-mode-menu-item" type="button" role="menuitemradio" data-busy-prompt-behavior="steer" aria-checked="false">
106
+ <span class="composer-busy-mode-menu-label">Steer</span>
107
+ <span class="composer-busy-mode-menu-description">Guide the active run immediately.</span>
108
+ </button>
109
+ </div>
88
110
  <textarea id="promptInput" rows="1" enterkeyhint="enter" placeholder="Ask Pi…" autofocus></textarea>
89
111
  <button
90
112
  id="attachButton"
@@ -508,6 +530,19 @@
508
530
  </form>
509
531
  </dialog>
510
532
 
533
+ <dialog id="skillEditorDialog" class="extension-dialog skill-editor-dialog">
534
+ <form method="dialog">
535
+ <h2 id="skillEditorTitle">Edit skill</h2>
536
+ <p id="skillEditorMeta" class="skill-editor-meta muted"></p>
537
+ <textarea id="skillEditorText" class="dialog-editor skill-editor-text" spellcheck="false" aria-label="Skill file contents"></textarea>
538
+ <p id="skillEditorStatus" class="skill-editor-status muted" role="status" aria-live="polite" hidden></p>
539
+ <menu>
540
+ <button id="skillEditorCancelButton" type="button">Cancel</button>
541
+ <button id="skillEditorSaveButton" class="primary" type="button">Save skill</button>
542
+ </menu>
543
+ </form>
544
+ </dialog>
545
+
511
546
  <dialog id="promptListDialog" class="extension-dialog prompt-list-dialog">
512
547
  <form method="dialog">
513
548
  <h2 id="promptListDialogTitle">Create prompt list</h2>
package/public/styles.css CHANGED
@@ -3329,11 +3329,182 @@ summary { cursor: pointer; color: var(--warning); }
3329
3329
  opacity: 0.8;
3330
3330
  }
3331
3331
  .composer-input-row {
3332
+ position: relative;
3332
3333
  display: grid;
3333
3334
  grid-template-columns: minmax(0, 1fr) auto;
3334
3335
  gap: 0.55rem;
3335
3336
  align-items: stretch;
3336
3337
  }
3338
+ .composer-context-tags {
3339
+ position: absolute;
3340
+ top: -0.48rem;
3341
+ left: 0.75rem;
3342
+ z-index: 3;
3343
+ display: inline-flex;
3344
+ align-items: center;
3345
+ gap: 0.32rem;
3346
+ max-width: calc(100% - 4.5rem);
3347
+ }
3348
+ .composer-busy-mode-tag {
3349
+ position: relative;
3350
+ flex: 0 1 auto;
3351
+ display: inline-flex;
3352
+ align-items: center;
3353
+ max-width: min(12rem, 100%);
3354
+ overflow: hidden;
3355
+ min-width: 0;
3356
+ min-height: 0;
3357
+ margin: 0;
3358
+ padding: 0.14rem 0.52rem;
3359
+ border: 1px solid rgba(137, 180, 250, 0.34);
3360
+ border-radius: 999px;
3361
+ color: var(--ctp-blue);
3362
+ background:
3363
+ linear-gradient(120deg, rgba(137, 180, 250, 0.24), rgba(137, 180, 250, 0.08)),
3364
+ var(--ctp-crust);
3365
+ box-shadow: 0 0.42rem 1rem rgba(var(--ctp-crust-rgb), 0.42), 0 0 0.8rem rgba(137, 180, 250, 0.14);
3366
+ cursor: pointer;
3367
+ font-size: 0.62rem;
3368
+ font-weight: 900;
3369
+ letter-spacing: 0.08em;
3370
+ line-height: 1.1;
3371
+ text-overflow: ellipsis;
3372
+ text-transform: uppercase;
3373
+ white-space: nowrap;
3374
+ }
3375
+ .composer-busy-mode-tag:hover,
3376
+ .composer-busy-mode-tag:focus-visible,
3377
+ .composer-busy-mode-tag.menu-open {
3378
+ border-color: rgba(137, 180, 250, 0.62);
3379
+ box-shadow: 0 0.42rem 1rem rgba(var(--ctp-crust-rgb), 0.42), 0 0 1rem rgba(137, 180, 250, 0.24);
3380
+ transform: translateY(-1px);
3381
+ }
3382
+ .composer-busy-mode-tag.steer {
3383
+ color: var(--ctp-mauve);
3384
+ border-color: rgba(203, 166, 247, 0.38);
3385
+ background:
3386
+ linear-gradient(120deg, rgba(203, 166, 247, 0.26), rgba(203, 166, 247, 0.08)),
3387
+ var(--ctp-crust);
3388
+ box-shadow: 0 0.42rem 1rem rgba(var(--ctp-crust-rgb), 0.42), 0 0 0.8rem rgba(203, 166, 247, 0.16);
3389
+ }
3390
+ .composer-busy-mode-tag.steer:hover,
3391
+ .composer-busy-mode-tag.steer:focus-visible,
3392
+ .composer-busy-mode-tag.steer.menu-open {
3393
+ border-color: rgba(203, 166, 247, 0.66);
3394
+ box-shadow: 0 0.42rem 1rem rgba(var(--ctp-crust-rgb), 0.42), 0 0 1rem rgba(203, 166, 247, 0.26);
3395
+ }
3396
+ .composer-busy-mode-tag.follow-up {
3397
+ color: var(--ctp-blue);
3398
+ border-color: rgba(137, 180, 250, 0.38);
3399
+ }
3400
+ .composer-skill-tags {
3401
+ display: inline-flex;
3402
+ align-items: center;
3403
+ gap: 0.28rem;
3404
+ min-width: 0;
3405
+ overflow: hidden;
3406
+ }
3407
+ .composer-skill-tags[hidden] {
3408
+ display: none !important;
3409
+ }
3410
+ .composer-skill-tag {
3411
+ display: inline-flex;
3412
+ align-items: center;
3413
+ min-width: 0;
3414
+ min-height: 0;
3415
+ max-width: 9.4rem;
3416
+ overflow: hidden;
3417
+ padding: 0.14rem 0.44rem;
3418
+ border: 1px solid rgba(249, 226, 175, 0.34);
3419
+ border-radius: 999px;
3420
+ color: var(--ctp-yellow);
3421
+ background:
3422
+ linear-gradient(120deg, rgba(249, 226, 175, 0.24), rgba(166, 227, 161, 0.08)),
3423
+ var(--ctp-crust);
3424
+ box-shadow: 0 0.35rem 0.9rem rgba(var(--ctp-crust-rgb), 0.40), 0 0 0.6rem rgba(249, 226, 175, 0.14);
3425
+ font-size: 0.58rem;
3426
+ font-weight: 900;
3427
+ letter-spacing: 0.06em;
3428
+ line-height: 1.1;
3429
+ text-align: left;
3430
+ text-overflow: ellipsis;
3431
+ text-transform: uppercase;
3432
+ white-space: nowrap;
3433
+ }
3434
+ button.composer-skill-tag:hover,
3435
+ button.composer-skill-tag:focus-visible {
3436
+ border-color: rgba(148, 226, 213, 0.68);
3437
+ box-shadow: 0 0.35rem 0.9rem rgba(var(--ctp-crust-rgb), 0.40), 0 0 0.9rem rgba(148, 226, 213, 0.24);
3438
+ transform: translateY(-1px);
3439
+ }
3440
+ .composer-skill-tag.read {
3441
+ color: var(--ctp-teal);
3442
+ border-color: rgba(148, 226, 213, 0.36);
3443
+ }
3444
+ .composer-skill-tag.loaded {
3445
+ color: var(--ctp-green);
3446
+ border-color: rgba(166, 227, 161, 0.36);
3447
+ }
3448
+ .composer-skill-tag.overflow {
3449
+ color: var(--ctp-subtext0);
3450
+ border-color: rgba(166, 173, 200, 0.30);
3451
+ background: linear-gradient(120deg, rgba(166, 173, 200, 0.22), rgba(166, 173, 200, 0.08)), var(--ctp-crust);
3452
+ }
3453
+ .composer-busy-mode-menu {
3454
+ position: absolute;
3455
+ top: auto;
3456
+ bottom: calc(100% + 0.22rem);
3457
+ left: 0.75rem;
3458
+ z-index: 120;
3459
+ display: flex;
3460
+ flex-direction: column;
3461
+ gap: 0.34rem;
3462
+ width: min(18rem, calc(100% - 4.5rem));
3463
+ padding: 0.42rem;
3464
+ border: 1px solid rgba(137, 180, 250, 0.32);
3465
+ border-radius: 0.95rem;
3466
+ background: var(--ctp-crust);
3467
+ box-shadow: 0 0.9rem 2.2rem rgba(var(--ctp-crust-rgb), 0.72), 0 0 1rem rgba(137, 180, 250, 0.14), inset 0 1px 0 rgba(255,255,255,0.05);
3468
+ }
3469
+ .composer-busy-mode-menu[hidden] {
3470
+ display: none !important;
3471
+ }
3472
+ .composer-busy-mode-menu-item {
3473
+ display: grid;
3474
+ gap: 0.1rem;
3475
+ width: 100%;
3476
+ margin: 0;
3477
+ padding: 0.45rem 0.58rem;
3478
+ border-color: rgba(137, 180, 250, 0.24);
3479
+ border-radius: 0.72rem;
3480
+ color: var(--ctp-text);
3481
+ background:
3482
+ linear-gradient(120deg, rgba(137, 180, 250, 0.08), rgba(203, 166, 247, 0.06)),
3483
+ var(--ctp-mantle);
3484
+ text-align: left;
3485
+ }
3486
+ .composer-busy-mode-menu-item:hover,
3487
+ .composer-busy-mode-menu-item:focus-visible {
3488
+ border-color: rgba(148, 226, 213, 0.48);
3489
+ box-shadow: 0 0 0.8rem rgba(148, 226, 213, 0.14);
3490
+ transform: translateY(-1px);
3491
+ }
3492
+ .composer-busy-mode-menu-item[aria-checked="true"] {
3493
+ border-color: rgba(148, 226, 213, 0.52);
3494
+ background:
3495
+ linear-gradient(120deg, rgba(148, 226, 213, 0.18), rgba(137, 180, 250, 0.10)),
3496
+ var(--ctp-mantle);
3497
+ }
3498
+ .composer-busy-mode-menu-label {
3499
+ font-size: 0.76rem;
3500
+ font-weight: 900;
3501
+ letter-spacing: 0.04em;
3502
+ }
3503
+ .composer-busy-mode-menu-description {
3504
+ color: var(--muted);
3505
+ font-size: 0.68rem;
3506
+ line-height: 1.25;
3507
+ }
3337
3508
  .composer-icon-button {
3338
3509
  display: inline-flex;
3339
3510
  align-items: center;
@@ -4129,6 +4300,46 @@ summary { cursor: pointer; color: var(--warning); }
4129
4300
  font-size: 0.72rem;
4130
4301
  font-weight: 800;
4131
4302
  }
4303
+ .extension-dialog.skill-editor-dialog {
4304
+ --skill-editor-size: min(152rem, calc(100vw - 1.5rem), calc(var(--visual-viewport-height, 100dvh) - 1.5rem));
4305
+ width: var(--skill-editor-size);
4306
+ height: var(--skill-editor-size);
4307
+ max-width: calc(100vw - 1.5rem);
4308
+ max-height: calc(var(--visual-viewport-height, 100dvh) - 1.5rem);
4309
+ aspect-ratio: 1 / 1;
4310
+ }
4311
+ .skill-editor-dialog form {
4312
+ display: grid;
4313
+ grid-template-rows: auto auto minmax(0, 1fr) auto auto;
4314
+ gap: 0.78rem;
4315
+ height: 100%;
4316
+ min-height: 0;
4317
+ }
4318
+ .skill-editor-meta {
4319
+ margin: 0;
4320
+ overflow-wrap: anywhere;
4321
+ font-size: 0.82rem;
4322
+ }
4323
+ .skill-editor-text {
4324
+ min-height: 0;
4325
+ resize: none;
4326
+ overflow: auto;
4327
+ overflow-x: hidden;
4328
+ overflow-wrap: anywhere;
4329
+ word-break: break-word;
4330
+ font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
4331
+ font-size: 0.82rem;
4332
+ line-height: 1.45;
4333
+ white-space: pre-wrap;
4334
+ }
4335
+ .skill-editor-status {
4336
+ margin: 0;
4337
+ min-height: 1.2rem;
4338
+ font-size: 0.84rem;
4339
+ }
4340
+ .skill-editor-status.ok { color: var(--ctp-green); }
4341
+ .skill-editor-status.warn { color: var(--ctp-yellow); }
4342
+ .skill-editor-status.error { color: var(--ctp-red); }
4132
4343
  .prompt-list-dialog {
4133
4344
  width: min(58rem, calc(100vw - 2rem));
4134
4345
  }
@@ -106,6 +106,11 @@ assert.match(html, /id="stickyUserPromptButton"/, "chat should expose a fixed la
106
106
  assert.match(html, /id="feedbackTray"/, "chat should expose a queued action-feedback tray");
107
107
  assert.match(html, /id="sendFeedbackButton"/, "action feedback should be submittable after the agent finishes");
108
108
  assert.match(html, /<textarea id="promptInput"[^>]*rows="1"[^>]*enterkeyhint="enter"/, "prompt textarea should start at one row and hint that Return inserts a newline");
109
+ assert.match(html, /id="busyPromptBehaviorTag"[\s\S]*class="composer-busy-mode-tag"[\s\S]*aria-controls="busyPromptBehaviorMenu"/, "composer should expose a clickable busy prompt behavior tag on the input frame");
110
+ assert.doesNotMatch(html, /Busy send:/i, "busy prompt behavior tag should show only the current mode label");
111
+ assert.match(html, /id="sessionSkillTags" class="composer-skill-tags"[\s\S]*hidden/, "composer should expose a hidden-until-used skill tag strip beside the busy mode tag");
112
+ assert.match(html, /id="skillEditorDialog"[\s\S]*id="skillEditorText"[\s\S]*id="skillEditorSaveButton"/, "skill tags should have an in-Web UI SKILL.md editing dialog");
113
+ assert.match(html, /id="busyPromptBehaviorMenu"[\s\S]*data-busy-prompt-behavior="followUp"[\s\S]*data-busy-prompt-behavior="steer"/, "busy prompt behavior dropdown should expose follow-up and steer choices");
109
114
  assert.match(app, /const LONG_INPUT_ATTACHMENT_LINE_THRESHOLD = 20/, "long composer text should use a 20-line threshold before becoming an attachment");
110
115
  assert.match(app, /function attachLongTextAsFile\(text, source = "input text"\)/, "long composer text should be attachable as a generated text file");
111
116
  assert.match(app, /function handleAttachmentPaste\(event\)[\s\S]*attachLongTextAsFile\(text, "clipboard text"\)/, "long pasted text should be attached instead of inserted into the prompt textarea");
@@ -173,6 +178,14 @@ assert.match(css, /\.composer-abort-button\.long-pressing::after[\s\S]*?animatio
173
178
  assert.match(css, /body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-abort-button:not\(\[hidden\]\) \{ grid-column: span 2; \}/, "active mobile runs should keep Abort beside Send in the bottom controls");
174
179
  assert.match(css, /#promptInput \{[\s\S]*?min-height:\s*calc\(1\.5em \+ 1\.8rem\)/, "prompt input should default to a compact single-line height");
175
180
  assert.match(css, /#promptInput \{[\s\S]*?overflow-y:\s*hidden/, "prompt input should be JS-resized instead of showing a scrollbar by default");
181
+ assert.match(css, /\.composer-context-tags \{[\s\S]*?top:\s*-0\.48rem;[\s\S]*?left:\s*0\.75rem;/, "busy prompt behavior and skill tags should sit at the top-left of the input frame");
182
+ assert.match(css, /\.composer-busy-mode-tag \{[\s\S]*?var\(--ctp-crust\)/, "busy prompt behavior tag should use an opaque base background");
183
+ assert.match(css, /\.composer-skill-tag \{[\s\S]*?var\(--ctp-crust\)/, "skill tags should use an opaque base background");
184
+ assert.match(css, /button\.composer-skill-tag:hover,[\s\S]*?button\.composer-skill-tag:focus-visible/, "skill tags should be styled as clickable controls");
185
+ assert.match(css, /\.extension-dialog\.skill-editor-dialog \{[\s\S]*?--skill-editor-size:\s*min\(152rem[\s\S]*?width:\s*var\(--skill-editor-size\);[\s\S]*?height:\s*var\(--skill-editor-size\);[\s\S]*?aspect-ratio:\s*1 \/ 1/, "skill editor should use a square viewport-bounded modal layout");
186
+ assert.match(css, /\.skill-editor-dialog form \{[\s\S]*?height:\s*100%;[\s\S]*?min-height:\s*0/, "skill editor form should fill the square modal without forcing overflow");
187
+ assert.match(css, /\.skill-editor-text \{[\s\S]*?overflow-x:\s*hidden;[\s\S]*?overflow-wrap:\s*anywhere;[\s\S]*?white-space:\s*pre-wrap/, "skill editor text should wrap long lines instead of horizontal scrolling");
188
+ assert.match(css, /\.composer-busy-mode-menu \{[\s\S]*?bottom:\s*calc\(100% \+ 0\.22rem\);[\s\S]*?background:\s*var\(--ctp-crust\)/, "busy prompt behavior dropdown should expand above the tag with an opaque background");
176
189
  assert.match(css, /\.sticky-user-prompt-button \{[\s\S]*?grid-template-columns:\s*auto minmax\(0, 1fr\) auto/, "last-user-prompt jump control should render as a fixed transcript header");
177
190
  assert.match(css, /\.message\.extension,[\s\S]*?\.message\.native/, "extension and native command output should have visible transcript styling");
178
191
  assert.match(css, /\.message\.run-indicator-message \{[\s\S]*?border-color/, "active agent runs should render a visible transcript indicator card");
@@ -396,7 +409,7 @@ assert.match(app, /function syncMobileChatToBottomForInput\(\)/, "mobile input f
396
409
  assert.match(app, /function focusPromptInput\(\{ defer = false \} = \{\}\)/, "frontend should focus the prompt composer programmatically after tab/app startup");
397
410
  assert.match(app, /async function switchTab\(tabId\)[\s\S]*?restoreActiveDraft\(\);\n\s+focusPromptInput\(\{ defer: true \}\);/, "switching to a newly opened tab should focus the prompt input immediately");
398
411
  assert.match(app, /async function initializeTabs\(\)[\s\S]*?restoreActiveDraft\(\);[\s\S]*if \(!loadedTabs\.length\)[\s\S]*focusPromptInput\(\{ defer: true \}\);/, "starting the Web UI should prompt for cwd when needed and focus active tabs");
399
- assert.match(app, /resizePromptInput\(\);\nfocusPromptInput\(\{ defer: true \}\);\nupdateComposerModeButtons\(\);/, "startup should request prompt focus before waiting for tab state refreshes");
412
+ assert.match(app, /resizePromptInput\(\);\nfocusPromptInput\(\{ defer: true \}\);\nrestoreStoredSkillUsage\(\);\nrestoreBusyPromptBehaviorSetting\(\);\nupdateComposerModeButtons\(\);/, "startup should request prompt focus and restore skill tags before waiting for tab state refreshes");
400
413
  assert.match(app, /elements\.promptInput\.addEventListener\("focus", \(\) => \{\n\s+syncMobileChatToBottomForInput\(\);/, "focusing mobile input should scroll output to bottom");
401
414
  assert.match(app, /navigator\.serviceWorker\.register\("\/service-worker\.js"\)/, "PWA service worker should be registered by the app");
402
415
  assert.match(app, /function serverStartCommandText\(\)[\s\S]*return `pi-webui\$\{currentPortArg\(\)\}`/, "PWA/offline shell should build a pathless pi-webui recovery command");
@@ -683,9 +696,29 @@ assert.match(app, /function updateComposerModeButtons\(\)/, "composer should rel
683
696
  assert.match(app, /const target = runActive \? elements\.composerRow : elements\.composerActionsPanel/, "Steer and Follow-up should move into the bottom row only while an agent run is active");
684
697
  assert.match(app, /const before = runActive \? elements\.abortButton : null/, "active Steer and Follow-up controls should sit before Abort and Send");
685
698
  assert.match(app, /button\.hidden = !runActive;\n\s+button\.disabled = !runActive;/, "Steer and Follow-up should be hidden and disabled when the agent is not running");
699
+ assert.match(app, /renderBusyPromptBehaviorTag\(\);\n\s+document\.body\.classList\.toggle\("pi-run-active", runActive \|\| abortAvailable\)/, "composer mode refresh should keep the busy prompt behavior tag current");
686
700
  assert.match(app, /elements\.abortButton\.hidden = !abortAvailable;\n\s+elements\.abortButton\.disabled = !abortAvailable \|\| abortRequestInFlight;/, "Abort should only be exposed in the bottom bar while a run can be aborted");
687
701
  assert.match(app, /document\.body\.classList\.toggle\("pi-run-active", runActive \|\| abortAvailable\)/, "run-active or abort-available state should be reflected in CSS for mobile composer layout");
688
702
  assert.match(app, /function showComposerButtonTooltip\(button\)/, "empty mode-button taps should show the usage tooltip");
703
+ assert.match(app, /function renderBusyPromptBehaviorTag\(\)[\s\S]*?tag\.textContent = label/, "busy prompt behavior tag should render only the current follow-up\/steer setting");
704
+ assert.doesNotMatch(app, /Busy send: \$\{label\}/, "busy prompt behavior tag should not prefix the current mode label");
705
+ assert.match(app, /function renderSessionSkillTags\(tabId = activeTabId\)[\s\S]*?filter\(\(entry\) => entry\.kinds\.has\("read"\)\)[\s\S]*?make\("button", classes\.join\(" "\), entry\.name\)[\s\S]*?openSkillEditor\(entry\)/, "skill tags should render as clickable buttons only after the full skill context was read");
706
+ assert.ok(app.includes('normalized.match(/\\/skills\\/([^/]+)\\/SKILL\\.md$/i)'), "skill context tracking should require SKILL.md paths");
707
+ assert.match(app, /function trackSkillsFromToolInvocation\(tabId, toolName[\s\S]*?name\.toLowerCase\(\) !== "read"\) return;[\s\S]*?kind: "read"/, "skill context tracking should only follow read-tool invocations");
708
+ assert.match(app, /function trackSkillUsage\(tabId, skillName[\s\S]*?persistSkillUsage\(\);[\s\S]*?renderSessionSkillTags\(tabId\)/, "skill tags should persist and live-update when a read skill is tracked");
709
+ assert.match(app, /const SKILL_USAGE_STORAGE_KEY = "pi-webui-skill-usage-v1"/, "read skill tags should have browser storage for hard-refresh and restart restore");
710
+ assert.match(app, /function persistSkillUsage\(\)[\s\S]*?localStorage\.setItem\(SKILL_USAGE_STORAGE_KEY/, "read skill tags should be persisted to browser storage");
711
+ assert.match(app, /function restoreStoredSkillUsage\(\)[\s\S]*?localStorage\.getItem\(SKILL_USAGE_STORAGE_KEY/, "read skill tags should restore from browser storage");
712
+ assert.match(app, /restoreStoredSkillUsage\(\);[\s\S]*?initializeTabs\(\)/, "stored read skill tags should be restored before tabs initialize");
713
+ assert.match(app, /trackSkillsFromEvent\(event\);[\s\S]*?if \(!eventTargetsActiveTab\(event\)\)/, "skill usage should be tracked as soon as tab events arrive");
714
+ assert.doesNotMatch(app, /trackSkillsFromCommands\(rawAvailableCommands, tabContext\.tabId\)/, "loaded skill commands alone should not populate skill tags");
715
+ assert.match(app, /function openSkillEditor\(entry\)[\s\S]*?api\(skillEditorApiPath\(\{ name, path \}\), \{ tabId \}\)/, "clicking a skill tag should load the corresponding SKILL.md into the editor dialog");
716
+ assert.match(app, /function saveSkillEditor\(\)[\s\S]*?api\("\/api\/skill-file", \{[\s\S]*?method: "POST"[\s\S]*?content: elements\.skillEditorText\.value/, "skill editor should save changed SKILL.md contents through the API");
717
+ assert.match(app, /skillEditorDialog\?\.addEventListener\("keydown"[\s\S]*?saveSkillEditor\(\)/, "skill editor should support Ctrl\/Cmd+S saving");
718
+ assert.match(app, /function setBusyPromptBehaviorMenuOpen\(open,[\s\S]*aria-expanded[\s\S]*busyPromptBehaviorMenu\.hidden/, "busy prompt behavior tag should control a dropdown menu");
719
+ assert.match(app, /busyPromptBehaviorTag\?\.addEventListener\("click"[\s\S]*setBusyPromptBehaviorMenuOpen\(nextOpen\)/, "clicking the busy prompt behavior tag should toggle its dropdown");
720
+ assert.match(app, /busyPromptBehaviorMenu\?\.addEventListener\("click"[\s\S]*chooseBusyPromptBehaviorFromMenu/, "busy prompt behavior dropdown choices should update the setting");
721
+ assert.match(app, /setBusyPromptBehavior\(controls\.busyBehavior\.select\.value\)/, "native settings should update the busy prompt behavior tag immediately");
689
722
  assert.match(app, /sendPromptFromModeButton\("steer", elements\.steerButton\)/, "Steer should show tooltip instead of silently doing nothing when input is empty");
690
723
  assert.match(app, /sendPromptFromModeButton\("follow-up", elements\.followUpButton\)/, "Follow-up should show tooltip instead of silently doing nothing when input is empty");
691
724
  assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*?sendPrompt\("prompt", command\)/, "Publish workflows should send slash commands directly without replacing the draft");
@@ -969,6 +1002,12 @@ assert.match(server, /if \(webuiDevServer\) return installRoot/, "source-checkou
969
1002
  assert.match(server, /Could not determine a safe optional feature install root/, "optional feature installs should fail closed when no declared package root can be found");
970
1003
  assert.match(server, /url\.pathname === "\/api\/optional-feature-install" && req\.method === "POST"/, "server should expose optional feature install endpoint");
971
1004
  assert.match(server, /Installing optional Web UI features is only allowed from localhost/, "optional feature install endpoint should be localhost-only");
1005
+ assert.match(server, /url\.pathname === "\/api\/skill-file" && req\.method === "GET"[\s\S]*?getSkillFileData/, "server should expose GET /api/skill-file for editable skill content");
1006
+ assert.match(server, /url\.pathname === "\/api\/skill-file" && req\.method === "POST"[\s\S]*?Saving skill files is only allowed from localhost[\s\S]*?saveSkillFileData/, "server should expose localhost-only POST /api/skill-file for saving skill content");
1007
+ assert.match(server, /function resolveEditableSkillFile\(tab, request = \{\}\)[\s\S]*?path\.basename\(skill\.filePath\) !== "SKILL\.md"/, "skill file API should validate that edits target resolved SKILL.md resources");
1008
+ assert.match(server, /function resolveExplicitSkillFilePath\(tab, filePath, requestedName = ""\)[\s\S]*?Skill path must point to \/skills\/<name>\/SKILL\.md[\s\S]*?allowedRoots/, "skill file API should allow exact read SKILL.md paths from trusted Pi skill roots");
1009
+ assert.match(server, /Skill path is outside allowed Pi skill locations/, "explicit skill path fallback should reject paths outside Pi skill roots");
1010
+ assert.match(server, /writeFile\(tmpFile, body\.content[\s\S]*?rename\(tmpFile, skill\.filePath\)/, "skill file saves should use an atomic temp-file rename");
972
1011
  assert.match(server, /url\.pathname === "\/api\/themes" && req\.method === "GET"/, "server should expose GET /api/themes");
973
1012
  assert.match(server, /readBundledThemes\(\)/, "server should read bundled theme JSON files for the browser");
974
1013
  assert.match(server, /"apple-touch-icon\.png", "icon-192\.png"/, "server should serve the conventional apple touch icon path");