diff options
| author | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-31 14:38:38 -0400 |
|---|---|---|
| committer | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-31 14:38:38 -0400 |
| commit | d57dc6583953fcb652db5bd222698d58d3090e20 (patch) | |
| tree | e7fedbbecbd13f8b24058f50febf9b5222bf8f43 /extensions | |
| parent | d21ebf7b96c90943ab48ffcd6d5b02c6f7438c04 (diff) | |
0.15.30: chrome_tab grouping — auto-group Pi tabs, group/ungroup actions, merge by title
Diffstat (limited to 'extensions')
3 files changed, 91 insertions, 14 deletions
diff --git a/extensions/chrome-profile-bridge/browser-extension/manifest.json b/extensions/chrome-profile-bridge/browser-extension/manifest.json index 55e9152..01ac3b3 100644 --- a/extensions/chrome-profile-bridge/browser-extension/manifest.json +++ b/extensions/chrome-profile-bridge/browser-extension/manifest.json @@ -1,10 +1,11 @@ { "manifest_version": 3, "name": "Pi Chrome Connector", - "version": "0.15.29", + "version": "0.15.30", "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.", "permissions": [ "tabs", + "tabGroups", "scripting", "storage", "activeTab", diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js index 850784f..eb54dce 100644 --- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js +++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js @@ -1,6 +1,9 @@ const BRIDGE_URL = "http://127.0.0.1:17318"; const CLIENT_NAME = `Pi Chrome Connector ${chrome.runtime.id}`; const POLL_ERROR_BACKOFF_MS = 2000; +const DEFAULT_GROUP_COLOR = "blue"; +const PI_GROUP_RE = /^Pi(\b|\s*-)/i; +const VALID_GROUP_COLORS = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]); let polling = false; // =================== Chrome input (CDP) layer =================== @@ -709,6 +712,58 @@ function isVersionOlder(a, b) { return false; } +function cleanGroupTitle(value) { + const text = String(value || "Pi").replace(/\s+/g, " ").trim().slice(0, 80); + return text || "Pi"; +} + +function cleanGroupColor(value) { + const color = String(value || DEFAULT_GROUP_COLOR).toLowerCase(); + return VALID_GROUP_COLORS.has(color) ? color : DEFAULT_GROUP_COLOR; +} + +async function groupRecord(groupId) { + if (typeof groupId !== "number" || groupId < 0 || !chrome.tabGroups) return null; + const group = await chrome.tabGroups.get(groupId).catch(() => null); + if (!group) return null; + return { + id: group.id, + title: group.title || "", + color: group.color || "", + collapsed: Boolean(group.collapsed), + windowId: group.windowId, + piGroup: Boolean(group.title && PI_GROUP_RE.test(group.title)), + }; +} + +// Find an existing tab group in `windowId` whose title matches `title` (case-insensitive). +// Used so all Pi-opened tabs collect into one group per window instead of spawning new ones. +async function findGroupByTitle(windowId, title) { + if (!chrome.tabGroups) return null; + const wanted = cleanGroupTitle(title).toLowerCase(); + const groups = await chrome.tabGroups.query({ windowId }).catch(() => []); + const match = groups.find((g) => (g.title || "").trim().toLowerCase() === wanted); + return match ? match.id : null; +} + +// Add `tab` to a tab group, then set title/color. If the tab is ungrouped, reuse an +// existing same-title group in its window when present, otherwise create a new group. +async function groupTab(tab, title, color) { + if (!chrome.tabGroups) throw new Error("chrome.tabGroups API unavailable; reload the extension after granting the tabGroups permission"); + if (!tab || typeof tab.id !== "number") throw new Error("No tab to group"); + const groupTitle = cleanGroupTitle(title); + let groupId = tab.groupId; + if (typeof groupId !== "number" || groupId < 0) { + const existing = await findGroupByTitle(tab.windowId, groupTitle); + groupId = existing !== null + ? await chrome.tabs.group({ groupId: existing, tabIds: [tab.id] }) + : await chrome.tabs.group({ tabIds: [tab.id] }); + } + await chrome.tabGroups.update(groupId, { title: groupTitle, color: cleanGroupColor(color), collapsed: false }); + const grouped = await chrome.tabs.get(tab.id); + return { tab: await formatTab(grouped), group: await groupRecord(groupId) }; +} + async function dispatch(action, params) { switch (action) { case "tab.version": @@ -718,17 +773,31 @@ async function dispatch(action, params) { bridgeUrl: BRIDGE_URL, userAgent: navigator.userAgent, }; - case "tab.list": - return (await chrome.tabs.query({})).map(formatTab); + case "tab.list": { + const tabs = await chrome.tabs.query({}); + return Promise.all(tabs.map(formatTab)); + } case "tab.new": { const tab = await chrome.tabs.create({ url: params.url || "about:blank", active: true }); - return formatTab(tab); + // Every Pi-opened tab joins a group by default. Pass groupTitle:"" (or group:false) to opt out. + const optOut = params.groupTitle === "" || params.group === false; + if (optOut && !params.groupColor) return formatTab(tab); + return groupTab(tab, params.groupTitle || "Pi", params.groupColor); } case "tab.activate": { const tab = await getTabByParams(params); await chrome.windows.update(tab.windowId, { focused: true }); return formatTab(await chrome.tabs.update(tab.id, { active: true })); } + case "tab.group": { + const tab = await getTabByParams(params); + return groupTab(tab, params.groupTitle || "Pi", params.groupColor); + } + case "tab.ungroup": { + const tab = await getTabByParams(params); + if (typeof tab.groupId === "number" && tab.groupId >= 0) await chrome.tabs.ungroup(tab.id); + return formatTab(await chrome.tabs.get(tab.id)); + } case "tab.close": { const tab = await getTabByParams(params); await chrome.tabs.remove(tab.id); @@ -811,7 +880,7 @@ async function dispatch(action, params) { } finally { if (params.initScript) await unregisterInitScript(tab.id).catch(() => undefined); } - return formatTab(await chrome.tabs.get(updated.id)); + return await formatTab(await chrome.tabs.get(updated.id)); } case "page.screenshot": return takeScreenshot(params); @@ -820,7 +889,7 @@ async function dispatch(action, params) { } } -function formatTab(tab) { +async function formatTab(tab) { return { id: tab.id, windowId: tab.windowId, @@ -831,6 +900,8 @@ function formatTab(tab) { status: tab.status, pinned: tab.pinned, incognito: tab.incognito, + groupId: typeof tab.groupId === "number" ? tab.groupId : -1, + group: await groupRecord(tab.groupId), }; } @@ -1095,7 +1166,7 @@ async function takeScreenshot(params) { await executeInTab({ ...params, foreground: false }, scrollToY, [tiles.originalScrollY]); return { fullPage: true, - tab: formatTab(tab), + tab: await formatTab(tab), dimensions: { width: tiles.width, height: tiles.height, viewportHeight: tiles.viewportHeight, dpr: tiles.dpr }, tiles: captured, }; @@ -1104,7 +1175,7 @@ async function takeScreenshot(params) { format: params.format || "png", quality: params.format === "jpeg" ? params.quality : undefined, }); - return { dataUrl, tab: formatTab(tab) }; + return { dataUrl, tab: await formatTab(tab) }; } finally { if (previousActiveId !== undefined && previousActiveId !== tab.id) { await chrome.tabs.update(previousActiveId, { active: true }).catch(() => undefined); diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts index 21dec6d..7e34887 100644 --- a/extensions/chrome-profile-bridge/index.ts +++ b/extensions/chrome-profile-bridge/index.ts @@ -500,7 +500,7 @@ class ChromeProfileBridge { } } -const tabActionValues = ["list", "new", "activate", "close", "version"] as const; +const tabActionValues = ["list", "new", "activate", "close", "group", "ungroup", "version"] as const; const imageFormatValues = ["png", "jpeg"] as const; const waitForValues = ["selector", "expression"] as const; const CHROME_TOOL_NAMES = [ @@ -1029,20 +1029,25 @@ Usage rules: pi.registerTool({ name: "chrome_tab", label: "Chrome Tab", - description: "List, create, activate, close, or inspect tabs in the user's existing Chrome profile via the companion extension.", - promptSnippet: "List/open/activate/close existing Chrome tabs through the companion extension.", + description: "List, create, activate, close, group, ungroup, or inspect tabs in the user's existing Chrome profile via the companion extension.", + promptSnippet: "List/open/activate/close/group existing Chrome tabs through the companion extension.", parameters: Type.Object({ action: StringEnum(tabActionValues), url: Type.Optional(Type.String({ description: "URL for action=new." })), - targetId: Type.Optional(Type.String({ description: "Chrome tab id for activate/close." })), + targetId: Type.Optional(Type.String({ description: "Chrome tab id for activate/close/group/ungroup." })), + urlIncludes: Type.Optional(Type.String({ description: "Match the target tab by URL substring for activate/close/group/ungroup." })), + titleIncludes: Type.Optional(Type.String({ description: "Match the target tab by title substring for activate/close/group/ungroup." })), + group: Type.Optional(Type.Boolean({ description: "action=new only: pass false to open an ungrouped tab. By default every Pi-opened tab joins the window's 'Pi' tab group." })), + groupTitle: Type.Optional(Type.String({ description: "Tab group title for action=group (or action=new to open into a named group). Defaults to 'Pi'. Pass an empty string on action=new to opt out of grouping." })), + groupColor: Type.Optional(Type.String({ description: "Tab group color for action=group/new: grey, blue, red, yellow, green, pink, purple, cyan, or orange. Defaults to blue." })), host: Type.Optional(Type.String()), port: Type.Optional(Type.Number()), }), async execute(_id, params, signal): Promise<ToolTextResult> { const result = await authorizedBridgeSend(`tab.${params.action}`, params, DEFAULT_TIMEOUT_MS, signal); if (params.action === "list") { - const tabs = result as Array<{ id: number; title: string; url: string; active: boolean; windowId: number }>; - const text = tabs.map((tab) => `${tab.id}\t${tab.active ? "*" : " "}\t${tab.title || "(untitled)"}\t${tab.url}`).join("\n") || "No tabs."; + const tabs = result as Array<{ id: number; title: string; url: string; active: boolean; windowId: number; group?: { title?: string } | null }>; + const text = tabs.map((tab) => `${tab.id}\t${tab.active ? "*" : " "}\t${tab.group?.title ? `[${tab.group.title}] ` : ""}${tab.title || "(untitled)"}\t${tab.url}`).join("\n") || "No tabs."; return { content: [{ type: "text", text }], details: { tabs } }; } return { content: [{ type: "text", text: safeJson(result) }], details: { result: result as Json } }; |
