diff --git a/docs/superpowers/plans/2026-07-30-extension-options-panel.md b/docs/superpowers/plans/2026-07-30-extension-options-panel.md new file mode 100644 index 0000000..b3673f7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-extension-options-panel.md @@ -0,0 +1,1348 @@ +# Extension Options Panel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an options panel to this Firefox extension, reachable from `about:addons`, configuring two new entry points — a context-menu item on selected text and an `Alt+Shift+G` shortcut that acts on the selection. + +**Architecture:** The extension is currently manifest-only with no JavaScript. This adds an MV2 ES-module background script (`background.type: "module"`, supported from Firefox 112) that registers a context menu and a keyboard command, three pure-logic modules under `lib/`, and an options page rendered inline in `about:addons`. All URL-building logic is pure and unit-tested with `node:test`; the browser-facing code is a thin wiring layer around it. + +**Tech Stack:** Firefox WebExtension Manifest V2, vanilla ES modules (no bundler, no framework), `node:test` for unit tests, `web-ext` 10.5.0 for lint and packaging. + +**Spec:** `docs/superpowers/specs/2026-07-29-extension-options-design.md` (committed at `3d5ce6a`). Read it before starting. **Appendix B is a rejection record, not a backlog** — do not implement auto-submit. + +**Branch:** `feat/extension-options-panel`, already cut from `develop` @ `5edb288`. + +## Global Constraints + +Every task's requirements implicitly include this section. Values are copied verbatim from the spec. + +- **Firefox Desktop only.** Do not add Chrome/Chromium packaging or touch any sibling repository. +- **Manifest stays V2.** Do not migrate to MV3. Keep the code MV3-portable: ES-module background, `persistent: false`, never `browser_style: true`. +- **Permissions are exactly `["storage", "menus", "activeTab"]`.** Do not add host permissions, `optional_permissions`, `tabs`, `webNavigation`, `notifications`, or `contentScripts`. All three chosen permissions add no install-prompt line; `tabs` and `notifications` do. +- **Never read `tab.url`, `tab.title`, or `tab.favIconUrl`.** Reading them requires the `tabs` permission. Only `tab.id` may be read. +- **`strict_min_version` is `"112.0"`.** +- **No new runtime or dev dependencies.** `node:test` is built in. +- **`lint.warningsAsErrors: true`** — a single addons-linter warning fails the build. `npx web-ext lint` must stay at 0 errors / 0 warnings / 0 notices. +- **Two-space indentation** in JSON, YAML, and JavaScript. Lowercase, hyphenated filenames. +- **Base URL is the root path `https://chatgpt.com/`.** Never `chatgpt.com/search`, which returns 404. +- **Send the `q` parameter only.** Never `prompt`, never both. +- **`MAX_URL_CHARS = 8000`.** +- **`hints` is hard-coded to the value `search`.** No mode dropdown. +- **Copy rule:** no user-facing text may say the query is *searched*, *sent*, or *asked* — only that ChatGPT opens **with the query filled in**. ChatGPT stopped auto-submitting URL-passed prompts; this is verified, not speculative. +- **Copy rule:** the words *private*, *incognito*, and *secure* must never appear near the temporary-chat setting. +- **No user-facing error notifications.** Every failure path degrades silently to something useful. +- **Commit format:** `: `, lowercase type (`feat:`, `test:`, `doc:`, `build:`). + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `lib/defaults.js` | **Create.** `DEFAULT_SETTINGS` and `OPEN_IN_VALUES` — the single source of truth for shape and defaults. No imports. | +| `lib/settings.js` | **Create.** Pure `mergeSettings()` (validation + defaulting) plus `load()`/`save()` wrapping `browser.storage.sync`. Only the pure part is unit-tested. | +| `lib/build-url.js` | **Create.** Pure `buildChatGptUrl(settings, rawQuery)`. Zero browser APIs. The tested core. | +| `background/background.js` | **Create.** Registers the context menu and command; reads the selection; opens the tab/window. Thin wiring only — no URL logic. | +| `options/options.html` | **Create.** The five controls plus Restore defaults. | +| `options/options.css` | **Create.** Hand-styled, responsive to ~400 px, light and dark via `prefers-color-scheme`. | +| `options/options.js` | **Create.** Renders settings, debounced auto-save, live re-sync on `storage.onChanged`. | +| `test/build-url.test.js` | **Create.** Unit tests for the URL builder. | +| `test/settings.test.js` | **Create.** Unit tests for `mergeSettings`. | +| `manifest.json` | **Modify.** Add `permissions`, `background`, `options_ui`, `commands`, `strict_min_version`; bump version. | +| `package.json` | **Modify.** Add `"type": "module"`, a `test:unit` script; bump version. | +| `README.md` | **Modify.** Options section, the double-Enter limitation, revised privacy wording. | +| `AGENTS.md` | **Modify.** Project structure, commands, manual test checklist. | +| `CHANGELOG.md` | **Modify.** 1.1.0 entry. | + +`web-ext-config.mjs` already excludes `docs/` and `test/` (done alongside the spec commit) — no change needed. + +--- + +### Task 1: Tooling foundation, defaults, and settings validation + +Establishes the test runner and the settings contract every later task depends on. `"type": "module"` is **required**: without it `node --test` relies on Node's module-syntax detection, which emits a permanent `MODULE_TYPELESS_PACKAGE_JSON` warning and **fails outright on Node 22.0–22.6**, and CI pins the bare major `"22"`. + +**Files:** +- Modify: `package.json` +- Create: `lib/defaults.js` +- Create: `lib/settings.js` +- Test: `test/settings.test.js` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `DEFAULT_SETTINGS: { model: string, temporaryChat: boolean, webSearch: boolean, openIn: string, promptTemplate: string }` from `lib/defaults.js` + - `OPEN_IN_VALUES: string[]` from `lib/defaults.js` + - `mergeSettings(stored: unknown) => Settings` from `lib/settings.js` + - `load() => Promise` from `lib/settings.js` + - `save(partial: Partial) => Promise` from `lib/settings.js` + +- [ ] **Step 1: Add `"type": "module"` and the unit-test script to `package.json`** + +Replace the `"private"`, `"version"`, and `"scripts"` region so the file reads: + +```json +{ + "name": "search-with-chatgpt-powered-by-openai-extension", + "version": "1.1.0", + "private": true, + "type": "module", + "description": "Development tooling for the Search with ChatGPT Firefox extension.", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "web-ext run", + "lint": "web-ext lint", + "test:unit": "node --test", + "build": "web-ext build", + "test": "npm run lint && npm run test:unit && npm run build" + }, + "devDependencies": { + "web-ext": "10.5.0" + } +} +``` + +- [ ] **Step 2: Write the failing test** + +Create `test/settings.test.js`: + +```js +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DEFAULT_SETTINGS } from "../lib/defaults.js"; +import { mergeSettings } from "../lib/settings.js"; + +test("empty object yields the defaults", () => { + assert.deepEqual(mergeSettings({}), DEFAULT_SETTINGS); +}); + +test("null and undefined yield the defaults", () => { + assert.deepEqual(mergeSettings(null), DEFAULT_SETTINGS); + assert.deepEqual(mergeSettings(undefined), DEFAULT_SETTINGS); +}); + +test("a non-object yields the defaults", () => { + assert.deepEqual(mergeSettings("nonsense"), DEFAULT_SETTINGS); + assert.deepEqual(mergeSettings(42), DEFAULT_SETTINGS); +}); + +test("valid stored values are preserved", () => { + assert.deepEqual( + mergeSettings({ + model: "auto", + temporaryChat: true, + webSearch: true, + openIn: "new-window", + promptTemplate: "Explain: {query}", + }), + { + model: "auto", + temporaryChat: true, + webSearch: true, + openIn: "new-window", + promptTemplate: "Explain: {query}", + }, + ); +}); + +test("missing keys fall back individually", () => { + const merged = mergeSettings({ model: "auto" }); + assert.equal(merged.model, "auto"); + assert.equal(merged.temporaryChat, DEFAULT_SETTINGS.temporaryChat); + assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate); +}); + +test("unknown keys are dropped", () => { + const merged = mergeSettings({ model: "auto", nope: "gone", openInn: "typo" }); + assert.deepEqual(Object.keys(merged).sort(), Object.keys(DEFAULT_SETTINGS).sort()); + assert.equal("nope" in merged, false); +}); + +test("wrong types fall back to the default", () => { + const merged = mergeSettings({ + model: 123, + temporaryChat: "yes", + webSearch: 1, + promptTemplate: [], + }); + assert.equal(merged.model, DEFAULT_SETTINGS.model); + assert.equal(merged.temporaryChat, DEFAULT_SETTINGS.temporaryChat); + assert.equal(merged.webSearch, DEFAULT_SETTINGS.webSearch); + assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate); +}); + +test("an out-of-range openIn falls back to the default", () => { + assert.equal(mergeSettings({ openIn: "teleport" }).openIn, DEFAULT_SETTINGS.openIn); + assert.equal(mergeSettings({ openIn: "" }).openIn, DEFAULT_SETTINGS.openIn); +}); + +test("each documented openIn value is accepted", () => { + for (const value of ["new-tab", "background-tab", "new-window"]) { + assert.equal(mergeSettings({ openIn: value }).openIn, value); + } +}); + +test("mergeSettings does not mutate DEFAULT_SETTINGS", () => { + mergeSettings({ model: "mutated" }); + assert.equal(DEFAULT_SETTINGS.model, ""); +}); +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `npm run test:unit` + +Expected: FAIL — `Cannot find module .../lib/defaults.js`. Confirm the summary reports a non-zero `fail` count. + +- [ ] **Step 4: Create `lib/defaults.js`** + +```js +// Single source of truth for the settings shape and their defaults. +// Defaults reproduce the extension's pre-1.1 behaviour exactly: an empty model +// and both toggles off mean the only URL parameter sent is `q`. + +export const OPEN_IN_VALUES = ["new-tab", "background-tab", "new-window"]; + +export const DEFAULT_SETTINGS = { + model: "", + temporaryChat: false, + webSearch: false, + openIn: "new-tab", + promptTemplate: "{query}", +}; +``` + +- [ ] **Step 5: Create `lib/settings.js`** + +```js +import { DEFAULT_SETTINGS, OPEN_IN_VALUES } from "./defaults.js"; + +// Pure. Layers stored values over the defaults, dropping unknown keys and +// falling back on wrong types or out-of-range values. A fresh or signed-out +// profile returns {} from storage, which yields the defaults. +export function mergeSettings(stored) { + const merged = { ...DEFAULT_SETTINGS }; + if (stored === null || typeof stored !== "object") { + return merged; + } + if (typeof stored.model === "string") { + merged.model = stored.model; + } + if (typeof stored.temporaryChat === "boolean") { + merged.temporaryChat = stored.temporaryChat; + } + if (typeof stored.webSearch === "boolean") { + merged.webSearch = stored.webSearch; + } + if (OPEN_IN_VALUES.includes(stored.openIn)) { + merged.openIn = stored.openIn; + } + if (typeof stored.promptTemplate === "string") { + merged.promptTemplate = stored.promptTemplate; + } + return merged; +} + +// storage.sync works with no Mozilla account (it behaves as local storage and +// syncs later if the user signs in), so no capability probe is needed. Any +// rejection falls back to defaults rather than blocking the user's action. +// Firefox sanitises all non-quota backend errors to a single generic string, so +// there is nothing useful to branch on. +export async function load() { + try { + const stored = await browser.storage.sync.get(Object.keys(DEFAULT_SETTINGS)); + return mergeSettings(stored); + } catch { + return mergeSettings(null); + } +} + +export async function save(partial) { + await browser.storage.sync.set(partial); +} +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `npm run test:unit` + +Expected: PASS, all 10 tests. Confirm the output shows `# fail 0` **and a non-zero `# tests` count** — `node --test` exits 0 when it discovers zero test files, so a zero count means the runner is misconfigured, not that everything passed. + +- [ ] **Step 7: Confirm the full pipeline is green and no stderr warning appeared** + +Run: `npm test 2>&1 | grep -i 'MODULE_TYPELESS\|warning' ; npm test` + +Expected: no `MODULE_TYPELESS_PACKAGE_JSON` line; lint reports `0 errors / 0 warnings / 0 notices`; unit tests pass; build writes the ZIP; exit code 0. + +- [ ] **Step 8: Verify `test/` is excluded from the package** + +Run: `unzip -l web-ext-artifacts/search-with-chatgpt-firefox-1.0.zip | grep -c 'test\|docs' || echo "0 matches - correct"` + +Expected: `0 matches - correct`. (The ZIP is still named `1.0` until Task 3 bumps the manifest version.) `lib/` legitimately appears once the background script imports it in Task 3. + +- [ ] **Step 9: Commit** + +```bash +git add package.json lib/defaults.js lib/settings.js test/settings.test.js +git commit -m "feat: add settings defaults and validation with unit tests" +``` + +--- + +### Task 2: The pure URL builder + +The core logic, isolated so it is fully testable without a browser. Every ChatGPT parameter here is undocumented and unreliable — the builder's job is to emit them cleanly and never depend on them being honoured. + +**Files:** +- Create: `lib/build-url.js` +- Test: `test/build-url.test.js` + +**Interfaces:** +- Consumes: `DEFAULT_SETTINGS` from `lib/defaults.js` (tests only). +- Produces: + - `buildChatGptUrl(settings: Settings, rawQuery: unknown) => string` from `lib/build-url.js` + - `MAX_URL_CHARS: number` (8000) from `lib/build-url.js` + +- [ ] **Step 1: Write the failing test** + +Create `test/build-url.test.js`: + +```js +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DEFAULT_SETTINGS } from "../lib/defaults.js"; +import { buildChatGptUrl, MAX_URL_CHARS } from "../lib/build-url.js"; + +const withSettings = (overrides) => ({ ...DEFAULT_SETTINGS, ...overrides }); +const queryOf = (url) => new URL(url).searchParams.get("q"); +const paramsOf = (url) => [...new URL(url).searchParams.keys()]; + +test("defaults produce only the q parameter on the root path", () => { + assert.equal( + buildChatGptUrl(DEFAULT_SETTINGS, "hello"), + "https://chatgpt.com/?q=hello", + ); +}); + +test("an empty or whitespace-only query omits q entirely", () => { + for (const raw of ["", " ", "\n\t", null, undefined]) { + const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); + assert.equal(paramsOf(url).includes("q"), false, `raw=${JSON.stringify(raw)}`); + assert.equal(url, "https://chatgpt.com/"); + } +}); + +test("model is sent only when non-empty after trimming", () => { + assert.equal(queryOf(buildChatGptUrl(withSettings({ model: "auto" }), "hi")), "hi"); + assert.equal(new URL(buildChatGptUrl(withSettings({ model: "auto" }), "hi")).searchParams.get("model"), "auto"); + assert.equal(new URL(buildChatGptUrl(withSettings({ model: " auto " }), "hi")).searchParams.get("model"), "auto"); + for (const model of ["", " "]) { + assert.equal(paramsOf(buildChatGptUrl(withSettings({ model }), "hi")).includes("model"), false); + } +}); + +test("temporaryChat and webSearch each appear only when enabled", () => { + assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("temporary-chat"), false); + assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("hints"), false); + + const temp = new URL(buildChatGptUrl(withSettings({ temporaryChat: true }), "hi")); + assert.equal(temp.searchParams.get("temporary-chat"), "true"); + + const search = new URL(buildChatGptUrl(withSettings({ webSearch: true }), "hi")); + assert.equal(search.searchParams.get("hints"), "search"); +}); + +test("all four URL-affecting settings combine, q first", () => { + const url = buildChatGptUrl( + withSettings({ + model: "auto", + temporaryChat: true, + webSearch: true, + promptTemplate: "Explain: {query}", + }), + "entropy", + ); + assert.deepEqual(paramsOf(url), ["q", "model", "temporary-chat", "hints"]); + assert.equal(queryOf(url), "Explain: entropy"); +}); + +test("openIn never influences the URL", () => { + const base = buildChatGptUrl(DEFAULT_SETTINGS, "hi"); + for (const openIn of ["new-tab", "background-tab", "new-window"]) { + assert.equal(buildChatGptUrl(withSettings({ openIn }), "hi"), base); + } +}); + +test("special characters survive encoding round-trip", () => { + const raw = 'a & b # c ? d = e + f "g" 100% 😀 日本語\nnewline'; + const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); + assert.equal(queryOf(url), raw); + assert.equal(url.includes(" "), false); + assert.equal(url.includes("#"), false); +}); + +test("the template replaces every {query} occurrence", () => { + const url = buildChatGptUrl(withSettings({ promptTemplate: "{query} / {query}" }), "x"); + assert.equal(queryOf(url), "x / x"); +}); + +test("a template without {query} is treated as a prefix, never dropping the query", () => { + const url = buildChatGptUrl(withSettings({ promptTemplate: "Be concise:" }), "why"); + assert.equal(queryOf(url), "Be concise: why"); +}); + +test("an empty or whitespace-only template yields the bare query", () => { + for (const promptTemplate of ["", " "]) { + assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate }), "why")), "why"); + } +}); + +test("a non-string template degrades to the bare query", () => { + assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate: null }), "why")), "why"); +}); + +test("an over-long prompt is truncated to fit the URL budget", () => { + const url = buildChatGptUrl(DEFAULT_SETTINGS, "x".repeat(20000)); + assert.ok(url.length <= MAX_URL_CHARS, `length was ${url.length}`); + assert.ok(queryOf(url).length > 0); + assert.ok(queryOf(url).length < 20000); +}); + +test("truncation accounts for the other parameters", () => { + const settings = withSettings({ model: "some-long-model-slug", temporaryChat: true, webSearch: true }); + const url = buildChatGptUrl(settings, "x".repeat(20000)); + assert.ok(url.length <= MAX_URL_CHARS, `length was ${url.length}`); + assert.equal(new URL(url).searchParams.get("model"), "some-long-model-slug"); + assert.equal(new URL(url).searchParams.get("hints"), "search"); +}); + +test("truncation never splits a surrogate pair", () => { + const url = buildChatGptUrl(DEFAULT_SETTINGS, "😀".repeat(9000)); + assert.ok(url.length <= MAX_URL_CHARS); + const lonelySurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + const url = buildChatGptUrl(DEFAULT_SETTINGS, "😀 ".repeat(9000)); + assert.equal(/%[0-9A-Fa-f]?$/.test(url), false); + assert.doesNotThrow(() => decodeURIComponent(new URL(url).search.slice(3))); +}); + +test("truncation prefers a whitespace boundary, so no word is cut in half", () => { + const url = buildChatGptUrl(DEFAULT_SETTINGS, "lorem ipsum ".repeat(2000)); + const tokens = queryOf(url).trim().split(/\s+/); + assert.ok(tokens.length > 1); + for (const token of tokens) { + assert.ok(token === "lorem" || token === "ipsum", `truncated mid-word: ${token}`); + } +}); + +test("a query needing no truncation is left byte-identical", () => { + const raw = "a modest question about thermodynamics"; + assert.equal(queryOf(buildChatGptUrl(DEFAULT_SETTINGS, raw)), raw); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `node --test test/build-url.test.js` + +Expected: FAIL — `Cannot find module .../lib/build-url.js`. + +- [ ] **Step 3: Create `lib/build-url.js`** + +```js +// Pure URL construction. No browser APIs, so node:test imports this directly. +// +// Every ChatGPT parameter below is undocumented by OpenAI and has broken before. +// This module's contract is only that it emits a well-formed URL; whether +// ChatGPT honours model / temporary-chat / hints is out of our control, which is +// why each is independently omittable and every label is hint-strength. +// +// The root path is mandatory: chatgpt.com/search began returning 404 in +// mid-2025, and the root form is what OpenAI's own extension uses. + +const BASE = "https://chatgpt.com/"; + +export const MAX_URL_CHARS = 8000; + +// Fraction of the kept text within which a whitespace cut is preferred. +const WHITESPACE_WINDOW = 0.9; + +function applyTemplate(template, query) { + if (query === "") { + return ""; + } + const tpl = typeof template === "string" ? template : ""; + if (tpl.includes("{query}")) { + return tpl.replaceAll("{query}", query); + } + const trimmed = tpl.trim(); + return trimmed === "" ? query : `${trimmed} ${query}`; +} + +function addOptionalParams(url, settings) { + const model = typeof settings.model === "string" ? settings.model.trim() : ""; + if (model !== "") { + url.searchParams.set("model", model); + } + if (settings.temporaryChat) { + url.searchParams.set("temporary-chat", "true"); + } + if (settings.webSearch) { + url.searchParams.set("hints", "search"); + } + return url; +} + +function lastWhitespaceIndex(text) { + for (let i = text.length - 1; i >= 0; i -= 1) { + if (/\s/.test(text[i])) { + return i; + } + } + return -1; +} + +// Returns the longest prefix of `prompt` whose finished URL fits the budget. +// Slicing happens by CODE POINT on the decoded string, so a surrogate pair can +// never be split and a percent-escape can never be severed (encoding happens +// afterwards, via URLSearchParams). +function fitToBudget(prompt, settings) { + const codePoints = Array.from(prompt); + + const urlLengthFor = (count) => { + const url = new URL(BASE); + url.searchParams.set("q", codePoints.slice(0, count).join("")); + addOptionalParams(url, settings); + return url.href.length; + }; + + if (urlLengthFor(codePoints.length) <= MAX_URL_CHARS) { + return prompt; + } + + let low = 0; + let high = codePoints.length; + while (low < high) { + const mid = Math.floor((low + high + 1) / 2); + if (urlLengthFor(mid) <= MAX_URL_CHARS) { + low = mid; + } else { + high = mid - 1; + } + } + + const kept = codePoints.slice(0, low).join(""); + const boundary = Math.floor(kept.length * WHITESPACE_WINDOW); + const cut = lastWhitespaceIndex(kept); + return cut >= boundary && cut > 0 ? kept.slice(0, cut) : kept; +} + +export function buildChatGptUrl(settings, rawQuery) { + const query = String(rawQuery ?? "").trim(); + const prompt = fitToBudget(applyTemplate(settings.promptTemplate, query), settings); + + // q first, matching the most-cited working examples. Total serialized length + // is order-independent, so measuring without q above is sound. + const url = new URL(BASE); + if (prompt !== "") { + url.searchParams.set("q", prompt); + } + addOptionalParams(url, settings); + return url.href; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `node --test test/build-url.test.js` + +Expected: PASS, all 17 tests, `# fail 0`. + +- [ ] **Step 5: Run the full suite** + +Run: `npm test` + +Expected: lint `0 errors / 0 warnings / 0 notices`; 27 unit tests passing across both files; build succeeds; exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add lib/build-url.js test/build-url.test.js +git commit -m "feat: add pure ChatGPT URL builder with unit tests" +``` + +--- + +### Task 3: Manifest wiring and the background script + +Turns the tested logic into a working extension. Manifest and background must land together — the manifest without the script registers nothing, and the script without the manifest is never loaded. + +**Files:** +- Modify: `manifest.json` +- Create: `background/background.js` + +**Interfaces:** +- Consumes: `load()` from `lib/settings.js`; `buildChatGptUrl()` from `lib/build-url.js`. +- Produces: the `ask-chatgpt` command name (referenced by `manifest.json` and by the `commands.onCommand` handler) and the `ask-chatgpt-selection` menu id. + +- [ ] **Step 1: Rewrite `manifest.json`** + +Replace the whole file. `browser_style: false` is set **explicitly** — omitting it defaults to `true` in MV2, and `true` is unsupported in MV3, so being explicit keeps the panel looking identical if the extension ever migrates. `is_default` stays `false`: setting it `true` triggers a second, separate consent doorhanger. + +```json +{ + "name": "Search with ChatGPT powered by OpenAI!", + "description": "Adds a ChatGPT search engine", + "manifest_version": 2, + "author": "Mathew Guest ", + "homepage_url": "https://zavage-software.com/portfolio/search-with-ChatGPT", + "version": "1.1", + "icons": { + "16": "icons/powered-by-openai-icon-16x16.png", + "32": "icons/powered-by-openai-icon-32x32.png", + "48": "icons/powered-by-openai-icon-48x48.png", + "64": "icons/powered-by-openai-icon-64x64.png", + "128": "icons/powered-by-openai-icon-128x128.png" + }, + "browser_specific_settings": { + "gecko": { + "id": "{24644f0c-bfcb-4ebd-a16b-bc8a7d154288}", + "strict_min_version": "112.0", + "data_collection_permissions": { + "required": [ + "none" + ] + } + } + }, + "permissions": [ + "storage", + "menus", + "activeTab" + ], + "background": { + "scripts": [ + "background/background.js" + ], + "type": "module", + "persistent": false + }, + "options_ui": { + "page": "options/options.html", + "open_in_tab": false, + "browser_style": false + }, + "commands": { + "ask-chatgpt": { + "suggested_key": { + "default": "Alt+Shift+G" + }, + "description": "Ask ChatGPT about the selected text" + } + }, + "chrome_settings_overrides": { + "search_provider": { + "name": "ChatGPT", + "search_url": "https://chatgpt.com?q={searchTerms}", + "keyword": "gpt", + "favicon_url": "https://zavage-software.com/static/ChatGPT-favicon.png", + "is_default": false, + "encoding": "UTF-8" + } + } +} +``` + +**Do not change `search_provider`.** Leaving Firefox as the transmitter of `gpt ` is a deliberate decision to minimise AMO data-collection exposure (spec Risk 6). + +- [ ] **Step 2: Create `background/background.js`** + +```js +import { load } from "../lib/settings.js"; +import { buildChatGptUrl } from "../lib/build-url.js"; + +const MENU_ID = "ask-chatgpt-selection"; +const COMMAND_ID = "ask-chatgpt"; + +// Reads the user's selection for the keyboard-shortcut path. The context menu +// does not need this — it receives info.selectionText directly. +// +// A commands invocation confers activeTab (ext-commands.js grants it before +// firing onCommand), so executeScript works with no host permission. +// +// Failure has three measured shapes, all handled here: a thrown +// "Missing host permission for the tab", a thrown variant naming frames, and a +// SILENT resolution to [null] on parent-process about: pages. Never index into +// a result without a null check. +async function readSelection() { + let tabs; + try { + tabs = await browser.tabs.query({ active: true, currentWindow: true }); + } catch { + return ""; + } + + // Only tab.id may be read. Reading tab.url or tab.title would require the + // "tabs" permission, which does add an install-prompt warning line. + const tabId = tabs?.[0]?.id; + if (typeof tabId !== "number") { + return ""; + } + + let results; + try { + results = await browser.tabs.executeScript(tabId, { + code: "window.getSelection().toString()", + allFrames: true, + matchAboutBlank: true, + }); + } catch { + return ""; + } + + if (!Array.isArray(results)) { + return ""; + } + for (const result of results) { + if (typeof result === "string" && result.trim() !== "") { + return result; + } + } + return ""; +} + +async function openChatGpt(query) { + const settings = await load(); + const url = buildChatGptUrl(settings, query); + + if (settings.openIn === "new-window") { + try { + await browser.windows.create({ url }); + return; + } catch { + // Fall through to a tab rather than doing nothing. + } + } + + await browser.tabs.create({ url, active: settings.openIn !== "background-tab" }); +} + +// removeAll() first, so a background event page waking up and re-running this +// module cannot throw on a duplicate menu id. +async function registerMenu() { + await browser.menus.removeAll(); + browser.menus.create({ + id: MENU_ID, + title: 'Ask ChatGPT about "%s"', + contexts: ["selection"], + }); +} + +browser.menus.onClicked.addListener((info) => { + if (info.menuItemId !== MENU_ID) { + return; + } + openChatGpt(info.selectionText ?? ""); +}); + +// browser.commands does not exist on Firefox for Android. This extension is +// desktop-only, but guard rather than throwing at startup. +if (browser.commands) { + browser.commands.onCommand.addListener(async (command) => { + if (command !== COMMAND_ID) { + return; + } + openChatGpt(await readSelection()); + }); +} + +registerMenu(); +``` + +- [ ] **Step 3: Verify lint and packaging still pass** + +Run: `npm test` + +Expected: `0 errors / 0 warnings / 0 notices`; 27 tests pass; the ZIP is now named `search-with-chatgpt-firefox-1.1.zip`. + +- [ ] **Step 4: Verify the package contents** + +Run: `unzip -l web-ext-artifacts/search-with-chatgpt-firefox-1.1.zip` + +Expected: `manifest.json`, `LICENSE.txt`, `icons/` + 5 PNGs, `background/background.js`, `lib/` + 3 modules. **No `test/`, no `docs/`, no `options/` yet** (Task 4 adds it), and no empty `test/`/`docs/` directory entries. + +- [ ] **Step 5: Manual smoke test in Firefox** + +Run: `npm run dev` + +In the temporary profile: +1. Select text on any ordinary web page, right-click → confirm an **"Ask ChatGPT about …"** item appears, showing the selected text. +2. Click it → ChatGPT opens in a new tab with the selection in the composer. +3. Right-click with **nothing** selected → confirm the item is **absent**. +4. Press `Alt+Shift+G` with text selected → same behaviour as the menu. +5. Press `Alt+Shift+G` with nothing selected → a blank ChatGPT opens (no `?q=`). +6. Navigate to `about:config`, press `Alt+Shift+G` → a blank ChatGPT opens. It must **not** fail silently with nothing happening, and must not log an unhandled rejection. +7. Select text inside an iframe (any page with an embedded frame) and press `Alt+Shift+G` → the iframe selection is used. +8. Confirm `gpt ` in the address bar still works and the provider is still non-default in `about:preferences#search`. + +**The shortcut must be verified by actually pressing the key.** `commands.getAll()` reports a `suggested_key` as registered even when Firefox has silently overridden it — `Ctrl+Shift+Y` was measured showing as registered and never firing. A green `getAll()` proves nothing. + +If `Alt+Shift+G` does not fire, check `about:addons` → gear → Manage Extension Shortcuts for a conflict before changing code. + +- [ ] **Step 6: Commit** + +```bash +git add manifest.json background/background.js +git commit -m "feat: add context menu and keyboard shortcut entry points" +``` + +--- + +### Task 4: The options panel + +The panel renders **inline inside `about:addons`**, which is a narrow, constrained surface that inherits that page's layout and follows the system theme. Because `browser_style` is off, it is hand-styled and must handle dark mode itself. + +**Files:** +- Create: `options/options.html` +- Create: `options/options.css` +- Create: `options/options.js` + +**Interfaces:** +- Consumes: `DEFAULT_SETTINGS` from `lib/defaults.js`; `load()` and `save()` from `lib/settings.js`. +- Produces: nothing consumed by later tasks. + +- [ ] **Step 1: Create `options/options.html`** + +The element `id`s must match the keys in `DEFAULT_SETTINGS` exactly — `options.js` relies on that. + +```html + + + +Search with ChatGPT — Options + + +
+
+ + +

+ ChatGPT may ignore this and use your account default. Leave blank to + always use your account default. +

+
+ +
+ +

+ ChatGPT may not honour this. Check ChatGPT's own indicator before sending + anything sensitive. +

+
+ +
+ +

A hint to ChatGPT's interface, not a guarantee.

+
+ +
+ + +
+ +
+ + +

{query} is replaced with your selected text.

+
+ +
+ + +
+
+ +

+ Selecting text and choosing Ask ChatGPT — or pressing + Alt+Shift+G — opens ChatGPT with your text + filled in. ChatGPT no longer submits prompts that arrive from a link, so press + Enter there to send. Change the shortcut under + Manage Extension Shortcuts in Add-ons. +

+ + +``` + +- [ ] **Step 2: Create `options/options.css`** + +```css +:root { + --fg: #15141a; + --fg-dim: #5b5b66; + --bg: transparent; + --border: #8f8f9d; + --field-bg: #ffffff; + --accent: #0060df; +} + +@media (prefers-color-scheme: dark) { + :root { + --fg: #fbfbfe; + --fg-dim: #b1b1bd; + --border: #8f8f9d; + --field-bg: #2b2a33; + --accent: #0df; + } +} + +body { + margin: 0; + padding: 0.5rem 0; + color: var(--fg); + background: var(--bg); + font: message-box; + font-size: 1rem; + line-height: 1.4; + max-width: 42rem; +} + +.field { + margin-bottom: 1.15rem; +} + +label { + display: block; + font-weight: 600; + margin-bottom: 0.3rem; +} + +.checkbox label { + font-weight: 400; + display: flex; + align-items: baseline; + gap: 0.45rem; +} + +input[type="text"], +select { + width: 100%; + max-width: 100%; + box-sizing: border-box; + padding: 0.4rem 0.5rem; + color: var(--fg); + background: var(--field-bg); + border: 1px solid var(--border); + border-radius: 4px; + font: inherit; +} + +input[type="text"]:focus-visible, +select:focus-visible, +button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.hint { + margin: 0.3rem 0 0; + color: var(--fg-dim); + font-size: 0.85rem; +} + +.tag { + font-weight: 400; + font-size: 0.75rem; + color: var(--fg-dim); + border: 1px solid var(--border); + border-radius: 3px; + padding: 0 0.3rem; + vertical-align: 1px; +} + +.actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin-top: 1.5rem; +} + +button { + padding: 0.35rem 0.8rem; + color: var(--fg); + background: var(--field-bg); + border: 1px solid var(--border); + border-radius: 4px; + font: inherit; + cursor: pointer; +} + +#status { + color: var(--fg-dim); + font-size: 0.85rem; +} + +.note { + margin-top: 1.75rem; + padding-top: 1rem; + border-top: 1px solid var(--border); + color: var(--fg-dim); + font-size: 0.85rem; +} + +code, +kbd { + font-family: monospace; + font-size: 0.9em; +} + +/* The inline about:addons surface can be very narrow. */ +@media (max-width: 26rem) { + .actions { + align-items: flex-start; + flex-direction: column; + } +} +``` + +- [ ] **Step 3: Create `options/options.js`** + +```js +import { DEFAULT_SETTINGS } from "../lib/defaults.js"; +import { load, save } from "../lib/settings.js"; + +const SAVE_DEBOUNCE_MS = 400; +const STATUS_CLEAR_MS = 1500; + +const field = (id) => document.getElementById(id); + +let saveTimer = null; +let statusTimer = null; +// Suppresses the change/input handlers while we are programmatically writing +// values into the form, so a remote sync cannot trigger a save loop. +let applying = false; + +function render(settings) { + applying = true; + field("model").value = settings.model; + field("temporaryChat").checked = settings.temporaryChat; + field("webSearch").checked = settings.webSearch; + field("openIn").value = settings.openIn; + field("promptTemplate").value = settings.promptTemplate; + applying = false; +} + +function collect() { + return { + model: field("model").value, + temporaryChat: field("temporaryChat").checked, + webSearch: field("webSearch").checked, + openIn: field("openIn").value, + promptTemplate: field("promptTemplate").value, + }; +} + +function showStatus(message) { + field("status").textContent = message; + clearTimeout(statusTimer); + statusTimer = setTimeout(() => { + field("status").textContent = ""; + }, STATUS_CLEAR_MS); +} + +async function persist(settings) { + try { + await save(settings); + showStatus("Saved"); + } catch { + // storage.sync sanitises its errors, so there is nothing specific to report. + showStatus("Could not save settings"); + } +} + +// Debounced so typing in a text field cannot approach any write-rate quota. +function scheduleSave() { + if (applying) { + return; + } + clearTimeout(saveTimer); + saveTimer = setTimeout(() => persist(collect()), SAVE_DEBOUNCE_MS); +} + +// Module scripts are deferred, so the DOM is already parsed here. +render(await load()); + +for (const id of Object.keys(DEFAULT_SETTINGS)) { + field(id).addEventListener("input", scheduleSave); + field(id).addEventListener("change", scheduleSave); +} + +field("restore").addEventListener("click", () => { + clearTimeout(saveTimer); + render(DEFAULT_SETTINGS); + persist({ ...DEFAULT_SETTINGS }); +}); + +// A remote sync landing while the panel is open must update the controls rather +// than being clobbered by stale field values on the next edit. +browser.storage.onChanged.addListener(async (changes, area) => { + if (area !== "sync") { + return; + } + render(await load()); +}); +``` + +- [ ] **Step 4: Verify lint and packaging** + +Run: `npm test` + +Expected: `0 errors / 0 warnings / 0 notices`; 27 tests pass; build succeeds. A top-level `await` in `options.js` is valid in an ES module; if addons-linter objects, wrap the body in an `async function init() { … } init();` instead. + +- [ ] **Step 5: Confirm `options/` is packaged** + +Run: `unzip -l web-ext-artifacts/search-with-chatgpt-firefox-1.1.zip | grep options` + +Expected: `options/options.html`, `options/options.css`, `options/options.js`. + +- [ ] **Step 6: Manual test the panel** + +Run: `npm run dev` + +1. Open `about:addons` → Extensions → this extension → the **Options** tab (Windows) / **Preferences** tab (macOS, Linux). Firefox's label is platform-dependent; do not expect "Preferences" everywhere. +2. Confirm all five controls plus **Restore defaults** render and are readable. +3. Change each setting; confirm "Saved" appears briefly. +4. Restart Firefox (or reload the extension) and confirm every value persisted. +5. Click **Restore defaults**; confirm the fields reset and persist. +6. Narrow the Firefox window as far as it goes; confirm nothing overflows horizontally and the actions row wraps. +7. Toggle the OS/Firefox theme to dark (`about:preferences` → Language and Appearance, or set `ui.systemUsesDarkTheme`); confirm text stays legible and inputs remain visible in **both** themes. +8. Set **Prompt template** to `Explain simply: {query}`, then use the context menu; confirm the ChatGPT composer contains `Explain simply: `. +9. Set **Open in** to each of the three values and confirm each behaves correctly, including that the background-tab option does not steal focus. + +- [ ] **Step 7: Commit** + +```bash +git add options/options.html options/options.css options/options.js +git commit -m "feat: add options panel embedded in about:addons" +``` + +--- + +### Task 5: Documentation, changelog, and release metadata + +**Files:** +- Modify: `README.md` +- Modify: `AGENTS.md` +- Modify: `CHANGELOG.md` + +**Interfaces:** +- Consumes: nothing. +- Produces: nothing. + +- [ ] **Step 1: Add an Options section and the ChatGPT limitation to `README.md`** + +Insert after the existing intro paragraphs, before `## Browser Support`: + +```markdown +## Options + +Open `about:addons`, select this extension, and choose the **Options** tab +(**Preferences** on macOS and Linux). Available settings: + +- **Model** *(experimental)* — a model slug such as `auto`. ChatGPT may ignore + it and use your account default; leave blank to always use the account + default. +- **Request a temporary chat** — asks ChatGPT not to save the conversation. + ChatGPT may not honour this; check its own indicator before relying on it. +- **Ask ChatGPT to use web search** — a hint to ChatGPT's interface, not a + guarantee. +- **Open in** — a new tab, a new background tab, or a new window. +- **Prompt template** — wraps your text, for example `Explain simply: {query}`. + +These settings apply to the new entry points: selecting text and choosing +**Ask ChatGPT** from the right-click menu, or pressing +Alt+Shift+G. Rebind the shortcut under +**Manage Extension Shortcuts** in the Add-ons gear menu. + +The `gpt ` address-bar search is unchanged and does not read these +settings. + +## Why you press Enter twice + +ChatGPT no longer submits prompts that are passed to it in a URL — it fills them +into the composer and waits. So `gpt ` opens ChatGPT with your query +filled in, and you press Enter there to send it. + +This is a change OpenAI made to chatgpt.com, not a fault in this extension, and +it is outside our control. It was a deliberate security measure: automatically +running a prompt that arrived in a link let malicious links execute instructions +in a signed-in ChatGPT session. The extra keypress is the checkpoint that +prevents that. No API key, server, or intermediary is involved either way. +``` + +- [ ] **Step 2: Revise the `README.md` privacy section** + +Replace the existing `## Privacy Policy` section: + +```markdown +## Privacy Policy + +This extension has no server, no API key, and no intermediary. Nothing is +reported to Zavage Software. + +- Your settings are stored in your own browser profile. If you use Firefox Sync + with **Add-ons** enabled, they sync end-to-end encrypted through your own + Mozilla account. +- When you choose **Ask ChatGPT** or press the shortcut, the text you selected + is placed in the chatgpt.com URL the extension opens for you. It goes nowhere + else. +- The extension reads a page's selected text only at the moment you invoke it, + and never reads page addresses or titles. +``` + +Do **not** write "never leaves your device" unconditionally — with Sync enabled an encrypted blob does reach Mozilla's servers. Do not restore the bare claim "This extension collects zero user data": it is true in plain English, but AMO's definition of transmission is broader, and specific wording is safer. See spec Risk 6. + +- [ ] **Step 3: Update `AGENTS.md`** + +In **Project Structure & Module Organization**, replace the opening line and add entries: + +```markdown +This is a Firefox WebExtension (Manifest V2) with npm-based development tooling. + +- `manifest.json` defines the extension metadata, the ChatGPT search provider, + the background script, the options panel, and the keyboard command. +- `lib/` holds pure logic with no browser dependencies: `defaults.js`, + `build-url.js`, and `settings.js`. This is the unit-tested core. +- `background/background.js` is an ES-module background script that registers + the context menu and the keyboard command. +- `options/` is the options panel rendered inline in `about:addons`. +- `test/` holds `node:test` unit tests. Excluded from the packaged ZIP. +- `icons/` contains size-specific PNG artwork named `powered-by-openai-icon-x.png`. +- `web-ext-config.mjs` controls linting, local execution, and ZIP packaging. +- `docs/superpowers/` holds specs and plans. Excluded from the packaged ZIP. +- `.gitea/workflows/firefox-extension.yml` validates pull requests and protected branches. +- `README.md`, `CHANGELOG.md`, and `LICENSE.txt` cover usage, releases, and licensing. +``` + +In **Build, Test, and Development Commands**, add after the `npm run lint` line: + +```markdown +- `npm run test:unit` runs the `node:test` unit tests over `test/`. +``` + +and replace the `npm test` line: + +```markdown +- `npm test` runs lint, unit tests, and build together; run it before every pull request. +``` + +Replace the first paragraph of **Testing Guidelines**: + +```markdown +Unit tests cover the pure logic in `lib/` via `node:test`; automated checks +otherwise cover manifest validation and packaging, not browser behaviour. Note +`node --test` exits 0 when it discovers no test files, so confirm a non-zero +test count rather than trusting the exit code alone. + +After `npm test`, use `npm run dev` and confirm: + +- `gpt ` opens ChatGPT, the provider stays non-default, and Firefox + search settings can configure it. +- The Options/Preferences tab renders all five settings plus Restore defaults, + in both light and dark themes and at a narrow window width, and values + persist across a restart. +- The right-click **Ask ChatGPT** item appears only when text is selected. +- `Alt+Shift+G` acts on the selection, including a selection inside an iframe, + and opens a blank chat when nothing is selected or on a restricted page such + as `about:config`. Press the key to test this — `commands.getAll()` reports a + shortcut as registered even when Firefox has silently overridden it. + +Inspect ZIP contents whenever packaging rules or assets change; `test/` and +`docs/` must never appear, not even as empty directory entries. +``` + +Add a new subsection at the end of **Security & Privacy**: + +```markdown +Permissions are deliberately limited to `storage`, `menus`, and `activeTab`, +which add no line to Firefox's install prompt. Do not add `tabs`, +`notifications`, `webNavigation`, or any host permission without re-reading +`docs/superpowers/specs/2026-07-29-extension-options-design.md` — each was +considered and rejected there, and Appendix B records a feature rejected on +security grounds that must not be revived without clearing the bar documented +with it. +``` + +- [ ] **Step 4: Add the `CHANGELOG.md` entry** + +Insert directly below the `# Changelog` intro paragraph, above the `## [1.0.0]` heading: + +```markdown +## [1.1.0] (2026-07-30) + +Adds an options panel and two new ways to reach ChatGPT. + + * Options panel in `about:addons` with five settings: model (experimental), + temporary chat, web-search hint, where results open, and a prompt template. + * Right-click selected text and choose "Ask ChatGPT" to open ChatGPT with that + text filled in. + * Keyboard shortcut `Alt+Shift+G` does the same for the current selection, and + opens an empty chat when nothing is selected. Rebindable via Manage Extension + Shortcuts. + * Documented that ChatGPT no longer submits prompts passed in a URL, so a + second Enter is needed. This is an upstream change to chatgpt.com. + * `gpt ` address-bar search is unchanged. + * Permissions added: storage, menus, activeTab. None of them adds a line to + Firefox's install prompt. +``` + +- [ ] **Step 5: Verify the whole suite one last time** + +Run: `npm test && unzip -l web-ext-artifacts/search-with-chatgpt-firefox-1.1.zip` + +Expected: `0 errors / 0 warnings / 0 notices`; 27 tests pass; the ZIP contains exactly `manifest.json`, `LICENSE.txt`, `icons/` + 5 PNGs, `background/background.js`, `lib/` + 3 modules, `options/` + 3 files — and nothing else. + +- [ ] **Step 6: Commit** + +```bash +git add README.md AGENTS.md CHANGELOG.md +git commit -m "doc: document options panel, entry points, and the ChatGPT prefill limitation" +``` + +--- + +## Before opening the pull request + +- [ ] Re-read spec Risk 6. The PR description must flag the **open AMO + data-collection question** — whether `required: ["none"]` survives + extension code reading page text — and note it should be asked before + submission, not at submission. +- [ ] The PR description must list the manual Firefox checks actually performed, + call out the new permissions explicitly, and state that + `chrome_settings_overrides` is unchanged. +- [ ] Push the branch (`git push -u origin feat/extension-options-panel`) and + open the PR with `tea pr create`. Do not merge; hand the URL over for human + review. + +## Out of scope — do not implement + +Each was considered and rejected in the spec. Implementing any of them silently +would undo a deliberate decision: + +- Auto-submitting the prompt (spec Appendix B — rejected on security grounds). +- `browser.omnibox` (works, but increases AMO data-collection exposure). +- Any host permission, `optional_permissions`, or `webNavigation`. +- A toolbar button; multiple named prompt templates; MV3 migration; Chrome + packaging; user-facing error notifications.