Records the doctype, the render() activeElement guard, and the blur in the Restore handler. storage.onChanged fires for this page's own writes, so every autosave re-entered the listener and could overwrite in-progress typing; the guard closes that.
50 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 omitted. Corrected during execution: the plan originally pinned"112.0"(the ES-module background floor), but the existingdata_collection_permissionskey requires Firefox 140 / Android 142, soaddons-linterwarns on any lower value andwarningsAsErrors: truefails the build. Measured:112.0→ 2 warnings,140.0→ 1,142.0→ clean, omitted → clean.142.0was rejected because it locks out ESR 140. Below Firefox 112 thegptkeyword still works and only the two new entry points are inert.- 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; 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}",
"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 "";
}
// Corrected during execution: the whole body is wrapped, because both call
// sites invoke this without awaiting. Without the outer catch a rejection from
// the final tabs.create surfaces as an unhandled promise rejection, which the
// "degrade silently" constraint above forbids. The inner catch stays nested so
// a windows.create failure still falls through to a tab.
async function openChatGpt(query) {
try {
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" });
} catch {
// Nothing useful to say and nowhere useful to say it: the user expects a
// tab or nothing at all.
}
}
// 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());
});
}
// Corrected during execution: menus.removeAll() is promise-based in Firefox, so
// an unguarded call here can log an unhandled rejection. (menus.create() is
// callback-based and cannot reject.)
registerMenu().catch(() => {
// A failed re-registration cannot be retried usefully from here.
});
- 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.
<!doctype html>
<!-- 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;
// Corrected during execution: the activeElement guard is required because
// storage.onChanged fires for this page's OWN writes, so every debounced
// autosave re-entered the listener below and could overwrite whatever the user
// had typed since the save started.
function render(settings) {
applying = true;
for (const [id, value] of Object.entries(settings)) {
const element = field(id);
// Never overwrite the control the user is actively editing — this page's
// own saves echo back through storage.onChanged (see below).
if (element === document.activeElement) {
continue;
}
if (typeof value === "boolean") {
element.checked = value;
} else {
element.value = value;
}
}
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);
// Restoring defaults is an explicit user action, so it should reset every
// field even if one currently has focus. Blur first so render()'s
// activeElement guard (see below) has nothing to skip.
document.activeElement?.blur();
render(DEFAULT_SETTINGS);
persist({ ...DEFAULT_SETTINGS });
});
// browser.storage.onChanged fires for every write to the "sync" area, not just
// a remote sync landing on another device — this page's own debounced
// autosaves and its Restore-defaults save re-enter this listener too, which
// render()'s activeElement guard makes safe to react to unconditionally.
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.