@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 +118 -0
- package/package.json +1 -1
- package/public/app.js +552 -3
- package/public/index.html +35 -0
- package/public/styles.css +211 -0
- package/tests/mobile-static.test.mjs +40 -1
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.
|
|
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
|
-
|
|
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
|
|
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");
|