search-with-chatgpt-powered.../docs/superpowers/plans/2026-07-30-extension-options-panel.md
golem 8fcc93c541 doc: record the $-pattern and frame-focus corrections
The spec prescribed replaceAll's string form and the plan hard-coded it,
so the $-substitution defect originated in the design documents rather
than in execution. Both now specify the function replacer.

Also replaces the spec's frame-reporting idea with the document.hasFocus()
gate that shipped; frame provenance was only ever needed for auto-submit,
which Appendix B rejects.
2026-08-01 23:17:23 -06:00

51 KiB
Raw Blame History

Extension Options Panel Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add an options panel to this Firefox extension, reachable from about:addons, configuring two new entry points — a context-menu item on selected text and an Alt+Shift+G shortcut that acts on the selection.

Architecture: The extension is currently manifest-only with no JavaScript. This adds an MV2 ES-module background script (background.type: "module", supported from Firefox 112) that registers a context menu and a keyboard command, three pure-logic modules under lib/, and an options page rendered inline in about:addons. All URL-building logic is pure and unit-tested with node:test; the browser-facing code is a thin wiring layer around it.

Tech Stack: Firefox WebExtension Manifest V2, vanilla ES modules (no bundler, no framework), node:test for unit tests, web-ext 10.5.0 for lint and packaging.

Spec: docs/superpowers/specs/2026-07-29-extension-options-design.md (committed at 3d5ce6a). Read it before starting. Appendix B is a rejection record, not a backlog — do not implement auto-submit.

Branch: feat/extension-options-panel, already cut from develop @ 5edb288.

Global Constraints

Every task's requirements implicitly include this section. Values are copied verbatim from the spec.

  • Firefox Desktop only. Do not add Chrome/Chromium packaging or touch any sibling repository.
  • Manifest stays V2. Do not migrate to MV3. Keep the code MV3-portable: ES-module background, persistent: false, never browser_style: true.
  • Permissions are exactly ["storage", "menus", "activeTab"]. Do not add host permissions, optional_permissions, tabs, webNavigation, notifications, or contentScripts. All three chosen permissions add no install-prompt line; tabs and notifications do.
  • Never read tab.url, tab.title, or tab.favIconUrl. Reading them requires the tabs permission. Only tab.id may be read.
  • strict_min_version is omitted. Corrected during execution: the plan originally pinned "112.0" (the ES-module background floor), but the existing data_collection_permissions key requires Firefox 140 / Android 142, so addons-linter warns on any lower value and warningsAsErrors: true fails the build. Measured: 112.0 → 2 warnings, 140.0 → 1, 142.0 → clean, omitted → clean. 142.0 was rejected because it locks out ESR 140. Below Firefox 112 the gpt keyword still works and only the two new entry points are inert.
  • No new runtime or dev dependencies. node:test is built in.
  • lint.warningsAsErrors: true — a single addons-linter warning fails the build. npx web-ext lint must stay at 0 errors / 0 warnings / 0 notices.
  • Two-space indentation in JSON, YAML, and JavaScript. Lowercase, hyphenated filenames.
  • Base URL is the root path https://chatgpt.com/. Never chatgpt.com/search, which returns 404.
  • Send the q parameter only. Never prompt, never both.
  • MAX_URL_CHARS = 8000.
  • hints is hard-coded to the value search. No mode dropdown.
  • Copy rule: no user-facing text may say the query is searched, sent, or asked — only that ChatGPT opens with the query filled in. ChatGPT stopped auto-submitting URL-passed prompts; this is verified, not speculative.
  • Copy rule: the words private, incognito, and secure must never appear near the temporary-chat setting.
  • No user-facing error notifications. Every failure path degrades silently to something useful.
  • Commit format: <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.022.6, and CI pins the bare major "22".

Files:

  • Modify: package.json
  • Create: lib/defaults.js
  • Create: lib/settings.js
  • Test: test/settings.test.js

Interfaces:

  • Consumes: nothing.

  • Produces:

    • DEFAULT_SETTINGS: { model: string, temporaryChat: boolean, webSearch: boolean, openIn: string, promptTemplate: string } from lib/defaults.js
    • OPEN_IN_VALUES: string[] from lib/defaults.js
    • mergeSettings(stored: unknown) => Settings from lib/settings.js
    • load() => Promise<Settings> from lib/settings.js
    • save(partial: Partial<Settings>) => Promise<void> from lib/settings.js
  • Step 1: Add "type": "module" and the unit-test script to package.json

Replace the "private", "version", and "scripts" region so the file reads:

{
  "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 countnode --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_SETTINGS from lib/defaults.js (tests only).

  • Produces:

    • buildChatGptUrl(settings: Settings, rawQuery: unknown) => string from lib/build-url.js
    • MAX_URL_CHARS: number (8000) from lib/build-url.js
  • Step 1: Write the failing test

Create test/build-url.test.js:

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}")) {
    // Corrected during execution: the replacer MUST be a function. In the
    // string form, $&, $`, $' and $$ are substitution patterns in the
    // replacement — and the replacement is arbitrary page-selected text.
    // Measured on the default {query} template: "awk print $& here" produced
    // "awk print {query} here".
    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() from lib/settings.js; buildChatGptUrl() from lib/build-url.js.

  • Produces: the ask-chatgpt command name (referenced by manifest.json and by the commands.onCommand handler) and the ask-chatgpt-selection menu id.

  • Step 1: Rewrite manifest.json

Replace the whole file. browser_style: false is set explicitly — omitting it defaults to true in MV2, and true is unsupported in MV3, so being explicit keeps the panel looking identical if the extension ever migrates. is_default stays false: setting it true triggers a second, separate consent doorhanger.

{
  "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, {
      // Corrected during execution: gated on document.hasFocus(). Each frame
      // owns an independent Selection that is not cleared when the user
      // selects elsewhere, so an unfocused (possibly cross-origin) frame's
      // stale selection could otherwise win on unspecified result ordering.
      code: "document.hasFocus() ? 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:

  1. Select text on any ordinary web page, right-click → confirm an "Ask ChatGPT about …" item appears, showing the selected text.
  2. Click it → ChatGPT opens in a new tab with the selection in the composer.
  3. Right-click with nothing selected → confirm the item is absent.
  4. Press Alt+Shift+G with text selected → same behaviour as the menu.
  5. Press Alt+Shift+G with nothing selected → a blank ChatGPT opens (no ?q=).
  6. Navigate to about:config, press Alt+Shift+G → a blank ChatGPT opens. It must not fail silently with nothing happening, and must not log an unhandled rejection.
  7. Select text inside an iframe (any page with an embedded frame) and press Alt+Shift+G → the iframe selection is used.
  8. Confirm gpt <query> in the address bar still works and the provider is still non-default in about:preferences#search.

The shortcut must be verified by actually pressing the key. commands.getAll() reports a suggested_key as registered even when Firefox has silently overridden it — Ctrl+Shift+Y was measured showing as registered and never firing. A green getAll() proves nothing.

If Alt+Shift+G does not fire, check about:addons → gear → Manage Extension Shortcuts for a conflict before changing code.

  • Step 6: Commit
git add manifest.json background/background.js
git commit -m "feat: add context menu and keyboard shortcut entry points"

Task 4: The options panel

The panel renders inline inside about:addons, which is a narrow, constrained surface that inherits that page's layout and follows the system theme. Because browser_style is off, it is hand-styled and must handle dark mode itself.

Files:

  • Create: options/options.html
  • Create: options/options.css
  • Create: options/options.js

Interfaces:

  • Consumes: DEFAULT_SETTINGS from lib/defaults.js; load() and save() from lib/settings.js.

  • Produces: nothing consumed by later tasks.

  • Step 1: Create options/options.html

The element 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

  1. Open about:addons → Extensions → this extension → the Options tab (Windows) / Preferences tab (macOS, Linux). Firefox's label is platform-dependent; do not expect "Preferences" everywhere.
  2. Confirm all five controls plus Restore defaults render and are readable.
  3. Change each setting; confirm "Saved" appears briefly.
  4. Restart Firefox (or reload the extension) and confirm every value persisted.
  5. Click Restore defaults; confirm the fields reset and persist.
  6. Narrow the Firefox window as far as it goes; confirm nothing overflows horizontally and the actions row wraps.
  7. Toggle the OS/Firefox theme to dark (about:preferences → Language and Appearance, or set ui.systemUsesDarkTheme); confirm text stays legible and inputs remain visible in both themes.
  8. Set Prompt template to Explain simply: {query}, then use the context menu; confirm the ChatGPT composer contains Explain simply: <selection>.
  9. Set Open in to each of the three values and confirm each behaves correctly, including that the background-tab option does not steal focus.
  • Step 7: Commit
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.md privacy 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.md entry

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_overrides is unchanged.
  • Push the branch (git push -u origin feat/extension-options-panel) and open the PR with tea pr create. Do not merge; hand the URL over for human review.

Out of scope — do not implement

Each was considered and rejected in the spec. Implementing any of them silently would undo a deliberate decision:

  • Auto-submitting the prompt (spec Appendix B — rejected on security grounds).
  • browser.omnibox (works, but increases AMO data-collection exposure).
  • Any host permission, optional_permissions, or webNavigation.
  • A toolbar button; multiple named prompt templates; MV3 migration; Chrome packaging; user-facing error notifications.