Five tasks, each ending in an independently testable deliverable: tooling plus settings validation, the pure URL builder, manifest and background wiring, the options panel, then docs and release metadata. Complete code for every file, 27 unit tests written out in full, and exact commands with expected output. Global Constraints carries the spec's verified values verbatim -- the exact permission set, strict_min_version 112.0, MAX_URL_CHARS 8000, and the copy rules that keep user-facing text honest about ChatGPT only prefilling. Records the traps verification found so an implementer cannot walk into them: executeScript resolving silently to [null] on parent-process about: pages, commands.getAll() reporting a shortcut registered when Firefox has overridden it, and node --test exiting 0 with zero tests discovered. Closes with an out-of-scope list so a well-meaning implementer does not re-add auto-submit or omnibox, and a pre-PR reminder about the open AMO data-collection question. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
48 KiB
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, neverbrowser_style: true. - Permissions are exactly
["storage", "menus", "activeTab"]. Do not add host permissions,optional_permissions,tabs,webNavigation,notifications, orcontentScripts. All three chosen permissions add no install-prompt line;tabsandnotificationsdo. - Never read
tab.url,tab.title, ortab.favIconUrl. Reading them requires thetabspermission. Onlytab.idmay be read. strict_min_versionis"112.0".- No new runtime or dev dependencies.
node:testis built in. lint.warningsAsErrors: true— a single addons-linter warning fails the build.npx web-ext lintmust 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/. Neverchatgpt.com/search, which returns 404. - Send the
qparameter only. Neverprompt, never both. MAX_URL_CHARS = 8000.hintsis hard-coded to the valuesearch. 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:
<type>: <concise summary>, 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 }fromlib/defaults.jsOPEN_IN_VALUES: string[]fromlib/defaults.jsmergeSettings(stored: unknown) => Settingsfromlib/settings.jsload() => Promise<Settings>fromlib/settings.jssave(partial: Partial<Settings>) => Promise<void>fromlib/settings.js
-
Step 1: Add
"type": "module"and the unit-test script topackage.json
Replace the "private", "version", and "scripts" region so the file reads:
{
"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:
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
// 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
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
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_SETTINGSfromlib/defaults.js(tests only). -
Produces:
buildChatGptUrl(settings: Settings, rawQuery: unknown) => stringfromlib/build-url.jsMAX_URL_CHARS: number(8000) fromlib/build-url.js
-
Step 1: Write the failing test
Create test/build-url.test.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" <h> 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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
assert.equal(lonelySurrogate.test(queryOf(url)), false);
});
test("truncation never leaves a severed percent-escape", () => {
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
// 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
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()fromlib/settings.js;buildChatGptUrl()fromlib/build-url.js. -
Produces: the
ask-chatgptcommand name (referenced bymanifest.jsonand by thecommands.onCommandhandler) and theask-chatgpt-selectionmenu 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.
{
"name": "Search with ChatGPT powered by OpenAI!",
"description": "Adds a ChatGPT search engine",
"manifest_version": 2,
"author": "Mathew Guest <mat@zavage.net>",
"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 <query> is a deliberate decision to minimise AMO data-collection exposure (spec Risk 6).
- Step 2: Create
background/background.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:
- Select text on any ordinary web page, right-click → confirm an "Ask ChatGPT about …" item appears, showing the selected text.
- Click it → ChatGPT opens in a new tab with the selection in the composer.
- Right-click with nothing selected → confirm the item is absent.
- Press
Alt+Shift+Gwith text selected → same behaviour as the menu. - Press
Alt+Shift+Gwith nothing selected → a blank ChatGPT opens (no?q=). - Navigate to
about:config, pressAlt+Shift+G→ a blank ChatGPT opens. It must not fail silently with nothing happening, and must not log an unhandled rejection. - Select text inside an iframe (any page with an embedded frame) and press
Alt+Shift+G→ the iframe selection is used. - Confirm
gpt <query>in the address bar still works and the provider is still non-default inabout: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
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_SETTINGSfromlib/defaults.js;load()andsave()fromlib/settings.js. -
Produces: nothing consumed by later tasks.
-
Step 1: Create
options/options.html
The element ids must match the keys in DEFAULT_SETTINGS exactly — options.js relies on that.
<!-- Rendered inline inside about:addons. Keep it narrow-friendly and do not
rely on window sizing; a pref can degrade this to a standalone tab. -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Search with ChatGPT — Options</title>
<link rel="stylesheet" href="options.css" />
<form id="settings" autocomplete="off">
<div class="field">
<label for="model">Model <span class="tag">experimental</span></label>
<input type="text" id="model" placeholder="auto" spellcheck="false" />
<p class="hint">
ChatGPT may ignore this and use your account default. Leave blank to
always use your account default.
</p>
</div>
<div class="field checkbox">
<label><input type="checkbox" id="temporaryChat" /> Request a temporary chat</label>
<p class="hint">
ChatGPT may not honour this. Check ChatGPT's own indicator before sending
anything sensitive.
</p>
</div>
<div class="field checkbox">
<label><input type="checkbox" id="webSearch" /> Ask ChatGPT to use web search (hint only)</label>
<p class="hint">A hint to ChatGPT's interface, not a guarantee.</p>
</div>
<div class="field">
<label for="openIn">Open in</label>
<select id="openIn">
<option value="new-tab">A new tab</option>
<option value="background-tab">A new background tab</option>
<option value="new-window">A new window</option>
</select>
</div>
<div class="field">
<label for="promptTemplate">Prompt template</label>
<input type="text" id="promptTemplate" spellcheck="false" />
<p class="hint"><code>{query}</code> is replaced with your selected text.</p>
</div>
<div class="actions">
<button type="button" id="restore">Restore defaults</button>
<span id="status" role="status" aria-live="polite"></span>
</div>
</form>
<p class="note">
Selecting text and choosing <strong>Ask ChatGPT</strong> — or pressing
<kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd> — 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
<em>Manage Extension Shortcuts</em> in Add-ons.
</p>
<script type="module" src="options.js"></script>
- Step 2: Create
options/options.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
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
- 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. - Confirm all five controls plus Restore defaults render and are readable.
- Change each setting; confirm "Saved" appears briefly.
- Restart Firefox (or reload the extension) and confirm every value persisted.
- Click Restore defaults; confirm the fields reset and persist.
- Narrow the Firefox window as far as it goes; confirm nothing overflows horizontally and the actions row wraps.
- Toggle the OS/Firefox theme to dark (
about:preferences→ Language and Appearance, or setui.systemUsesDarkTheme); confirm text stays legible and inputs remain visible in both themes. - Set Prompt template to
Explain simply: {query}, then use the context menu; confirm the ChatGPT composer containsExplain simply: <selection>. - 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
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:
## 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
<kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd>. Rebind the shortcut under
**Manage Extension Shortcuts** in the Add-ons gear menu.
The `gpt <query>` 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 <query>` 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.mdprivacy section
Replace the existing ## Privacy Policy section:
## 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:
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-<size>x<size>.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:
- `npm run test:unit` runs the `node:test` unit tests over `test/`.
and replace the npm test line:
- `npm test` runs lint, unit tests, and build together; run it before every pull request.
Replace the first paragraph of Testing Guidelines:
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 <query>` 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:
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.mdentry
Insert directly below the # Changelog intro paragraph, above the ## [1.0.0] heading:
## [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 <query>` 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
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_overridesis unchanged. - Push the branch (
git push -u origin feat/extension-options-panel) and open the PR withtea 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, orwebNavigation. - A toolbar button; multiple named prompt templates; MV3 migration; Chrome packaging; user-facing error notifications.