mirror of
https://git.zavage.net/Zavage-Software/search-with-chatgpt-powered-by-openai-extension.git
synced 2026-08-10 21:50:29 -06:00
Five tasks, each ending in an independently testable deliverable: tooling plus settings validation, the pure URL builder, manifest and background wiring, the options panel, then docs and release metadata. Complete code for every file, 27 unit tests written out in full, and exact commands with expected output. Global Constraints carries the spec's verified values verbatim -- the exact permission set, strict_min_version 112.0, MAX_URL_CHARS 8000, and the copy rules that keep user-facing text honest about ChatGPT only prefilling. Records the traps verification found so an implementer cannot walk into them: executeScript resolving silently to [null] on parent-process about: pages, commands.getAll() reporting a shortcut registered when Firefox has overridden it, and node --test exiting 0 with zero tests discovered. Closes with an out-of-scope list so a well-meaning implementer does not re-add auto-submit or omnibox, and a pre-PR reminder about the open AMO data-collection question. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1349 lines
48 KiB
Markdown
1349 lines
48 KiB
Markdown
# Extension Options Panel Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add an options panel to this Firefox extension, reachable from `about:addons`, configuring two new entry points — a context-menu item on selected text and an `Alt+Shift+G` shortcut that acts on the selection.
|
||
|
||
**Architecture:** The extension is currently manifest-only with no JavaScript. This adds an MV2 ES-module background script (`background.type: "module"`, supported from Firefox 112) that registers a context menu and a keyboard command, three pure-logic modules under `lib/`, and an options page rendered inline in `about:addons`. All URL-building logic is pure and unit-tested with `node:test`; the browser-facing code is a thin wiring layer around it.
|
||
|
||
**Tech Stack:** Firefox WebExtension Manifest V2, vanilla ES modules (no bundler, no framework), `node:test` for unit tests, `web-ext` 10.5.0 for lint and packaging.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-07-29-extension-options-design.md` (committed at `3d5ce6a`). Read it before starting. **Appendix B is a rejection record, not a backlog** — do not implement auto-submit.
|
||
|
||
**Branch:** `feat/extension-options-panel`, already cut from `develop` @ `5edb288`.
|
||
|
||
## Global Constraints
|
||
|
||
Every task's requirements implicitly include this section. Values are copied verbatim from the spec.
|
||
|
||
- **Firefox Desktop only.** Do not add Chrome/Chromium packaging or touch any sibling repository.
|
||
- **Manifest stays V2.** Do not migrate to MV3. Keep the code MV3-portable: ES-module background, `persistent: false`, never `browser_style: true`.
|
||
- **Permissions are exactly `["storage", "menus", "activeTab"]`.** Do not add host permissions, `optional_permissions`, `tabs`, `webNavigation`, `notifications`, or `contentScripts`. All three chosen permissions add no install-prompt line; `tabs` and `notifications` do.
|
||
- **Never read `tab.url`, `tab.title`, or `tab.favIconUrl`.** Reading them requires the `tabs` permission. Only `tab.id` may be read.
|
||
- **`strict_min_version` is `"112.0"`.**
|
||
- **No new runtime or dev dependencies.** `node:test` is built in.
|
||
- **`lint.warningsAsErrors: true`** — a single addons-linter warning fails the build. `npx web-ext lint` must stay at 0 errors / 0 warnings / 0 notices.
|
||
- **Two-space indentation** in JSON, YAML, and JavaScript. Lowercase, hyphenated filenames.
|
||
- **Base URL is the root path `https://chatgpt.com/`.** Never `chatgpt.com/search`, which returns 404.
|
||
- **Send the `q` parameter only.** Never `prompt`, never both.
|
||
- **`MAX_URL_CHARS = 8000`.**
|
||
- **`hints` is hard-coded to the value `search`.** No mode dropdown.
|
||
- **Copy rule:** no user-facing text may say the query is *searched*, *sent*, or *asked* — only that ChatGPT opens **with the query filled in**. ChatGPT stopped auto-submitting URL-passed prompts; this is verified, not speculative.
|
||
- **Copy rule:** the words *private*, *incognito*, and *secure* must never appear near the temporary-chat setting.
|
||
- **No user-facing error notifications.** Every failure path degrades silently to something useful.
|
||
- **Commit format:** `<type>: <concise summary>`, lowercase type (`feat:`, `test:`, `doc:`, `build:`).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
| File | Responsibility |
|
||
|---|---|
|
||
| `lib/defaults.js` | **Create.** `DEFAULT_SETTINGS` and `OPEN_IN_VALUES` — the single source of truth for shape and defaults. No imports. |
|
||
| `lib/settings.js` | **Create.** Pure `mergeSettings()` (validation + defaulting) plus `load()`/`save()` wrapping `browser.storage.sync`. Only the pure part is unit-tested. |
|
||
| `lib/build-url.js` | **Create.** Pure `buildChatGptUrl(settings, rawQuery)`. Zero browser APIs. The tested core. |
|
||
| `background/background.js` | **Create.** Registers the context menu and command; reads the selection; opens the tab/window. Thin wiring only — no URL logic. |
|
||
| `options/options.html` | **Create.** The five controls plus Restore defaults. |
|
||
| `options/options.css` | **Create.** Hand-styled, responsive to ~400 px, light and dark via `prefers-color-scheme`. |
|
||
| `options/options.js` | **Create.** Renders settings, debounced auto-save, live re-sync on `storage.onChanged`. |
|
||
| `test/build-url.test.js` | **Create.** Unit tests for the URL builder. |
|
||
| `test/settings.test.js` | **Create.** Unit tests for `mergeSettings`. |
|
||
| `manifest.json` | **Modify.** Add `permissions`, `background`, `options_ui`, `commands`, `strict_min_version`; bump version. |
|
||
| `package.json` | **Modify.** Add `"type": "module"`, a `test:unit` script; bump version. |
|
||
| `README.md` | **Modify.** Options section, the double-Enter limitation, revised privacy wording. |
|
||
| `AGENTS.md` | **Modify.** Project structure, commands, manual test checklist. |
|
||
| `CHANGELOG.md` | **Modify.** 1.1.0 entry. |
|
||
|
||
`web-ext-config.mjs` already excludes `docs/` and `test/` (done alongside the spec commit) — no change needed.
|
||
|
||
---
|
||
|
||
### Task 1: Tooling foundation, defaults, and settings validation
|
||
|
||
Establishes the test runner and the settings contract every later task depends on. `"type": "module"` is **required**: without it `node --test` relies on Node's module-syntax detection, which emits a permanent `MODULE_TYPELESS_PACKAGE_JSON` warning and **fails outright on Node 22.0–22.6**, and CI pins the bare major `"22"`.
|
||
|
||
**Files:**
|
||
- Modify: `package.json`
|
||
- Create: `lib/defaults.js`
|
||
- Create: `lib/settings.js`
|
||
- Test: `test/settings.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing.
|
||
- Produces:
|
||
- `DEFAULT_SETTINGS: { model: string, temporaryChat: boolean, webSearch: boolean, openIn: string, promptTemplate: string }` 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:
|
||
|
||
```json
|
||
{
|
||
"name": "search-with-chatgpt-powered-by-openai-extension",
|
||
"version": "1.1.0",
|
||
"private": true,
|
||
"type": "module",
|
||
"description": "Development tooling for the Search with ChatGPT Firefox extension.",
|
||
"engines": {
|
||
"node": ">=22"
|
||
},
|
||
"scripts": {
|
||
"dev": "web-ext run",
|
||
"lint": "web-ext lint",
|
||
"test:unit": "node --test",
|
||
"build": "web-ext build",
|
||
"test": "npm run lint && npm run test:unit && npm run build"
|
||
},
|
||
"devDependencies": {
|
||
"web-ext": "10.5.0"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing test**
|
||
|
||
Create `test/settings.test.js`:
|
||
|
||
```js
|
||
import test from "node:test";
|
||
import assert from "node:assert/strict";
|
||
|
||
import { DEFAULT_SETTINGS } from "../lib/defaults.js";
|
||
import { mergeSettings } from "../lib/settings.js";
|
||
|
||
test("empty object yields the defaults", () => {
|
||
assert.deepEqual(mergeSettings({}), DEFAULT_SETTINGS);
|
||
});
|
||
|
||
test("null and undefined yield the defaults", () => {
|
||
assert.deepEqual(mergeSettings(null), DEFAULT_SETTINGS);
|
||
assert.deepEqual(mergeSettings(undefined), DEFAULT_SETTINGS);
|
||
});
|
||
|
||
test("a non-object yields the defaults", () => {
|
||
assert.deepEqual(mergeSettings("nonsense"), DEFAULT_SETTINGS);
|
||
assert.deepEqual(mergeSettings(42), DEFAULT_SETTINGS);
|
||
});
|
||
|
||
test("valid stored values are preserved", () => {
|
||
assert.deepEqual(
|
||
mergeSettings({
|
||
model: "auto",
|
||
temporaryChat: true,
|
||
webSearch: true,
|
||
openIn: "new-window",
|
||
promptTemplate: "Explain: {query}",
|
||
}),
|
||
{
|
||
model: "auto",
|
||
temporaryChat: true,
|
||
webSearch: true,
|
||
openIn: "new-window",
|
||
promptTemplate: "Explain: {query}",
|
||
},
|
||
);
|
||
});
|
||
|
||
test("missing keys fall back individually", () => {
|
||
const merged = mergeSettings({ model: "auto" });
|
||
assert.equal(merged.model, "auto");
|
||
assert.equal(merged.temporaryChat, DEFAULT_SETTINGS.temporaryChat);
|
||
assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate);
|
||
});
|
||
|
||
test("unknown keys are dropped", () => {
|
||
const merged = mergeSettings({ model: "auto", nope: "gone", openInn: "typo" });
|
||
assert.deepEqual(Object.keys(merged).sort(), Object.keys(DEFAULT_SETTINGS).sort());
|
||
assert.equal("nope" in merged, false);
|
||
});
|
||
|
||
test("wrong types fall back to the default", () => {
|
||
const merged = mergeSettings({
|
||
model: 123,
|
||
temporaryChat: "yes",
|
||
webSearch: 1,
|
||
promptTemplate: [],
|
||
});
|
||
assert.equal(merged.model, DEFAULT_SETTINGS.model);
|
||
assert.equal(merged.temporaryChat, DEFAULT_SETTINGS.temporaryChat);
|
||
assert.equal(merged.webSearch, DEFAULT_SETTINGS.webSearch);
|
||
assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate);
|
||
});
|
||
|
||
test("an out-of-range openIn falls back to the default", () => {
|
||
assert.equal(mergeSettings({ openIn: "teleport" }).openIn, DEFAULT_SETTINGS.openIn);
|
||
assert.equal(mergeSettings({ openIn: "" }).openIn, DEFAULT_SETTINGS.openIn);
|
||
});
|
||
|
||
test("each documented openIn value is accepted", () => {
|
||
for (const value of ["new-tab", "background-tab", "new-window"]) {
|
||
assert.equal(mergeSettings({ openIn: value }).openIn, value);
|
||
}
|
||
});
|
||
|
||
test("mergeSettings does not mutate DEFAULT_SETTINGS", () => {
|
||
mergeSettings({ model: "mutated" });
|
||
assert.equal(DEFAULT_SETTINGS.model, "");
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run the test to verify it fails**
|
||
|
||
Run: `npm run test:unit`
|
||
|
||
Expected: FAIL — `Cannot find module .../lib/defaults.js`. Confirm the summary reports a non-zero `fail` count.
|
||
|
||
- [ ] **Step 4: Create `lib/defaults.js`**
|
||
|
||
```js
|
||
// Single source of truth for the settings shape and their defaults.
|
||
// Defaults reproduce the extension's pre-1.1 behaviour exactly: an empty model
|
||
// and both toggles off mean the only URL parameter sent is `q`.
|
||
|
||
export const OPEN_IN_VALUES = ["new-tab", "background-tab", "new-window"];
|
||
|
||
export const DEFAULT_SETTINGS = {
|
||
model: "",
|
||
temporaryChat: false,
|
||
webSearch: false,
|
||
openIn: "new-tab",
|
||
promptTemplate: "{query}",
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 5: Create `lib/settings.js`**
|
||
|
||
```js
|
||
import { DEFAULT_SETTINGS, OPEN_IN_VALUES } from "./defaults.js";
|
||
|
||
// Pure. Layers stored values over the defaults, dropping unknown keys and
|
||
// falling back on wrong types or out-of-range values. A fresh or signed-out
|
||
// profile returns {} from storage, which yields the defaults.
|
||
export function mergeSettings(stored) {
|
||
const merged = { ...DEFAULT_SETTINGS };
|
||
if (stored === null || typeof stored !== "object") {
|
||
return merged;
|
||
}
|
||
if (typeof stored.model === "string") {
|
||
merged.model = stored.model;
|
||
}
|
||
if (typeof stored.temporaryChat === "boolean") {
|
||
merged.temporaryChat = stored.temporaryChat;
|
||
}
|
||
if (typeof stored.webSearch === "boolean") {
|
||
merged.webSearch = stored.webSearch;
|
||
}
|
||
if (OPEN_IN_VALUES.includes(stored.openIn)) {
|
||
merged.openIn = stored.openIn;
|
||
}
|
||
if (typeof stored.promptTemplate === "string") {
|
||
merged.promptTemplate = stored.promptTemplate;
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
// storage.sync works with no Mozilla account (it behaves as local storage and
|
||
// syncs later if the user signs in), so no capability probe is needed. Any
|
||
// rejection falls back to defaults rather than blocking the user's action.
|
||
// Firefox sanitises all non-quota backend errors to a single generic string, so
|
||
// there is nothing useful to branch on.
|
||
export async function load() {
|
||
try {
|
||
const stored = await browser.storage.sync.get(Object.keys(DEFAULT_SETTINGS));
|
||
return mergeSettings(stored);
|
||
} catch {
|
||
return mergeSettings(null);
|
||
}
|
||
}
|
||
|
||
export async function save(partial) {
|
||
await browser.storage.sync.set(partial);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run the test to verify it passes**
|
||
|
||
Run: `npm run test:unit`
|
||
|
||
Expected: PASS, all 10 tests. Confirm the output shows `# fail 0` **and a non-zero `# tests` count** — `node --test` exits 0 when it discovers zero test files, so a zero count means the runner is misconfigured, not that everything passed.
|
||
|
||
- [ ] **Step 7: Confirm the full pipeline is green and no stderr warning appeared**
|
||
|
||
Run: `npm test 2>&1 | grep -i 'MODULE_TYPELESS\|warning' ; npm test`
|
||
|
||
Expected: no `MODULE_TYPELESS_PACKAGE_JSON` line; lint reports `0 errors / 0 warnings / 0 notices`; unit tests pass; build writes the ZIP; exit code 0.
|
||
|
||
- [ ] **Step 8: Verify `test/` is excluded from the package**
|
||
|
||
Run: `unzip -l web-ext-artifacts/search-with-chatgpt-firefox-1.0.zip | grep -c 'test\|docs' || echo "0 matches - correct"`
|
||
|
||
Expected: `0 matches - correct`. (The ZIP is still named `1.0` until Task 3 bumps the manifest version.) `lib/` legitimately appears once the background script imports it in Task 3.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add package.json lib/defaults.js lib/settings.js test/settings.test.js
|
||
git commit -m "feat: add settings defaults and validation with unit tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: The pure URL builder
|
||
|
||
The core logic, isolated so it is fully testable without a browser. Every ChatGPT parameter here is undocumented and unreliable — the builder's job is to emit them cleanly and never depend on them being honoured.
|
||
|
||
**Files:**
|
||
- Create: `lib/build-url.js`
|
||
- Test: `test/build-url.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DEFAULT_SETTINGS` from `lib/defaults.js` (tests only).
|
||
- Produces:
|
||
- `buildChatGptUrl(settings: Settings, rawQuery: unknown) => string` from `lib/build-url.js`
|
||
- `MAX_URL_CHARS: number` (8000) from `lib/build-url.js`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `test/build-url.test.js`:
|
||
|
||
```js
|
||
import test from "node:test";
|
||
import assert from "node:assert/strict";
|
||
|
||
import { DEFAULT_SETTINGS } from "../lib/defaults.js";
|
||
import { buildChatGptUrl, MAX_URL_CHARS } from "../lib/build-url.js";
|
||
|
||
const withSettings = (overrides) => ({ ...DEFAULT_SETTINGS, ...overrides });
|
||
const queryOf = (url) => new URL(url).searchParams.get("q");
|
||
const paramsOf = (url) => [...new URL(url).searchParams.keys()];
|
||
|
||
test("defaults produce only the q parameter on the root path", () => {
|
||
assert.equal(
|
||
buildChatGptUrl(DEFAULT_SETTINGS, "hello"),
|
||
"https://chatgpt.com/?q=hello",
|
||
);
|
||
});
|
||
|
||
test("an empty or whitespace-only query omits q entirely", () => {
|
||
for (const raw of ["", " ", "\n\t", null, undefined]) {
|
||
const url = buildChatGptUrl(DEFAULT_SETTINGS, raw);
|
||
assert.equal(paramsOf(url).includes("q"), false, `raw=${JSON.stringify(raw)}`);
|
||
assert.equal(url, "https://chatgpt.com/");
|
||
}
|
||
});
|
||
|
||
test("model is sent only when non-empty after trimming", () => {
|
||
assert.equal(queryOf(buildChatGptUrl(withSettings({ model: "auto" }), "hi")), "hi");
|
||
assert.equal(new URL(buildChatGptUrl(withSettings({ model: "auto" }), "hi")).searchParams.get("model"), "auto");
|
||
assert.equal(new URL(buildChatGptUrl(withSettings({ model: " auto " }), "hi")).searchParams.get("model"), "auto");
|
||
for (const model of ["", " "]) {
|
||
assert.equal(paramsOf(buildChatGptUrl(withSettings({ model }), "hi")).includes("model"), false);
|
||
}
|
||
});
|
||
|
||
test("temporaryChat and webSearch each appear only when enabled", () => {
|
||
assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("temporary-chat"), false);
|
||
assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("hints"), false);
|
||
|
||
const temp = new URL(buildChatGptUrl(withSettings({ temporaryChat: true }), "hi"));
|
||
assert.equal(temp.searchParams.get("temporary-chat"), "true");
|
||
|
||
const search = new URL(buildChatGptUrl(withSettings({ webSearch: true }), "hi"));
|
||
assert.equal(search.searchParams.get("hints"), "search");
|
||
});
|
||
|
||
test("all four URL-affecting settings combine, q first", () => {
|
||
const url = buildChatGptUrl(
|
||
withSettings({
|
||
model: "auto",
|
||
temporaryChat: true,
|
||
webSearch: true,
|
||
promptTemplate: "Explain: {query}",
|
||
}),
|
||
"entropy",
|
||
);
|
||
assert.deepEqual(paramsOf(url), ["q", "model", "temporary-chat", "hints"]);
|
||
assert.equal(queryOf(url), "Explain: entropy");
|
||
});
|
||
|
||
test("openIn never influences the URL", () => {
|
||
const base = buildChatGptUrl(DEFAULT_SETTINGS, "hi");
|
||
for (const openIn of ["new-tab", "background-tab", "new-window"]) {
|
||
assert.equal(buildChatGptUrl(withSettings({ openIn }), "hi"), base);
|
||
}
|
||
});
|
||
|
||
test("special characters survive encoding round-trip", () => {
|
||
const raw = 'a & b # c ? d = e + f "g" <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`**
|
||
|
||
```js
|
||
// Pure URL construction. No browser APIs, so node:test imports this directly.
|
||
//
|
||
// Every ChatGPT parameter below is undocumented by OpenAI and has broken before.
|
||
// This module's contract is only that it emits a well-formed URL; whether
|
||
// ChatGPT honours model / temporary-chat / hints is out of our control, which is
|
||
// why each is independently omittable and every label is hint-strength.
|
||
//
|
||
// The root path is mandatory: chatgpt.com/search began returning 404 in
|
||
// mid-2025, and the root form is what OpenAI's own extension uses.
|
||
|
||
const BASE = "https://chatgpt.com/";
|
||
|
||
export const MAX_URL_CHARS = 8000;
|
||
|
||
// Fraction of the kept text within which a whitespace cut is preferred.
|
||
const WHITESPACE_WINDOW = 0.9;
|
||
|
||
function applyTemplate(template, query) {
|
||
if (query === "") {
|
||
return "";
|
||
}
|
||
const tpl = typeof template === "string" ? template : "";
|
||
if (tpl.includes("{query}")) {
|
||
return tpl.replaceAll("{query}", query);
|
||
}
|
||
const trimmed = tpl.trim();
|
||
return trimmed === "" ? query : `${trimmed} ${query}`;
|
||
}
|
||
|
||
function addOptionalParams(url, settings) {
|
||
const model = typeof settings.model === "string" ? settings.model.trim() : "";
|
||
if (model !== "") {
|
||
url.searchParams.set("model", model);
|
||
}
|
||
if (settings.temporaryChat) {
|
||
url.searchParams.set("temporary-chat", "true");
|
||
}
|
||
if (settings.webSearch) {
|
||
url.searchParams.set("hints", "search");
|
||
}
|
||
return url;
|
||
}
|
||
|
||
function lastWhitespaceIndex(text) {
|
||
for (let i = text.length - 1; i >= 0; i -= 1) {
|
||
if (/\s/.test(text[i])) {
|
||
return i;
|
||
}
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
// Returns the longest prefix of `prompt` whose finished URL fits the budget.
|
||
// Slicing happens by CODE POINT on the decoded string, so a surrogate pair can
|
||
// never be split and a percent-escape can never be severed (encoding happens
|
||
// afterwards, via URLSearchParams).
|
||
function fitToBudget(prompt, settings) {
|
||
const codePoints = Array.from(prompt);
|
||
|
||
const urlLengthFor = (count) => {
|
||
const url = new URL(BASE);
|
||
url.searchParams.set("q", codePoints.slice(0, count).join(""));
|
||
addOptionalParams(url, settings);
|
||
return url.href.length;
|
||
};
|
||
|
||
if (urlLengthFor(codePoints.length) <= MAX_URL_CHARS) {
|
||
return prompt;
|
||
}
|
||
|
||
let low = 0;
|
||
let high = codePoints.length;
|
||
while (low < high) {
|
||
const mid = Math.floor((low + high + 1) / 2);
|
||
if (urlLengthFor(mid) <= MAX_URL_CHARS) {
|
||
low = mid;
|
||
} else {
|
||
high = mid - 1;
|
||
}
|
||
}
|
||
|
||
const kept = codePoints.slice(0, low).join("");
|
||
const boundary = Math.floor(kept.length * WHITESPACE_WINDOW);
|
||
const cut = lastWhitespaceIndex(kept);
|
||
return cut >= boundary && cut > 0 ? kept.slice(0, cut) : kept;
|
||
}
|
||
|
||
export function buildChatGptUrl(settings, rawQuery) {
|
||
const query = String(rawQuery ?? "").trim();
|
||
const prompt = fitToBudget(applyTemplate(settings.promptTemplate, query), settings);
|
||
|
||
// q first, matching the most-cited working examples. Total serialized length
|
||
// is order-independent, so measuring without q above is sound.
|
||
const url = new URL(BASE);
|
||
if (prompt !== "") {
|
||
url.searchParams.set("q", prompt);
|
||
}
|
||
addOptionalParams(url, settings);
|
||
return url.href;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
Run: `node --test test/build-url.test.js`
|
||
|
||
Expected: PASS, all 17 tests, `# fail 0`.
|
||
|
||
- [ ] **Step 5: Run the full suite**
|
||
|
||
Run: `npm test`
|
||
|
||
Expected: lint `0 errors / 0 warnings / 0 notices`; 27 unit tests passing across both files; build succeeds; exit 0.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add lib/build-url.js test/build-url.test.js
|
||
git commit -m "feat: add pure ChatGPT URL builder with unit tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Manifest wiring and the background script
|
||
|
||
Turns the tested logic into a working extension. Manifest and background must land together — the manifest without the script registers nothing, and the script without the manifest is never loaded.
|
||
|
||
**Files:**
|
||
- Modify: `manifest.json`
|
||
- Create: `background/background.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `load()` from `lib/settings.js`; `buildChatGptUrl()` from `lib/build-url.js`.
|
||
- Produces: the `ask-chatgpt` command name (referenced by `manifest.json` and by the `commands.onCommand` handler) and the `ask-chatgpt-selection` menu id.
|
||
|
||
- [ ] **Step 1: Rewrite `manifest.json`**
|
||
|
||
Replace the whole file. `browser_style: false` is set **explicitly** — omitting it defaults to `true` in MV2, and `true` is unsupported in MV3, so being explicit keeps the panel looking identical if the extension ever migrates. `is_default` stays `false`: setting it `true` triggers a second, separate consent doorhanger.
|
||
|
||
```json
|
||
{
|
||
"name": "Search with ChatGPT powered by OpenAI!",
|
||
"description": "Adds a ChatGPT search engine",
|
||
"manifest_version": 2,
|
||
"author": "Mathew Guest <mat@zavage.net>",
|
||
"homepage_url": "https://zavage-software.com/portfolio/search-with-ChatGPT",
|
||
"version": "1.1",
|
||
"icons": {
|
||
"16": "icons/powered-by-openai-icon-16x16.png",
|
||
"32": "icons/powered-by-openai-icon-32x32.png",
|
||
"48": "icons/powered-by-openai-icon-48x48.png",
|
||
"64": "icons/powered-by-openai-icon-64x64.png",
|
||
"128": "icons/powered-by-openai-icon-128x128.png"
|
||
},
|
||
"browser_specific_settings": {
|
||
"gecko": {
|
||
"id": "{24644f0c-bfcb-4ebd-a16b-bc8a7d154288}",
|
||
"strict_min_version": "112.0",
|
||
"data_collection_permissions": {
|
||
"required": [
|
||
"none"
|
||
]
|
||
}
|
||
}
|
||
},
|
||
"permissions": [
|
||
"storage",
|
||
"menus",
|
||
"activeTab"
|
||
],
|
||
"background": {
|
||
"scripts": [
|
||
"background/background.js"
|
||
],
|
||
"type": "module",
|
||
"persistent": false
|
||
},
|
||
"options_ui": {
|
||
"page": "options/options.html",
|
||
"open_in_tab": false,
|
||
"browser_style": false
|
||
},
|
||
"commands": {
|
||
"ask-chatgpt": {
|
||
"suggested_key": {
|
||
"default": "Alt+Shift+G"
|
||
},
|
||
"description": "Ask ChatGPT about the selected text"
|
||
}
|
||
},
|
||
"chrome_settings_overrides": {
|
||
"search_provider": {
|
||
"name": "ChatGPT",
|
||
"search_url": "https://chatgpt.com?q={searchTerms}",
|
||
"keyword": "gpt",
|
||
"favicon_url": "https://zavage-software.com/static/ChatGPT-favicon.png",
|
||
"is_default": false,
|
||
"encoding": "UTF-8"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Do not change `search_provider`.** Leaving Firefox as the transmitter of `gpt <query>` is a deliberate decision to minimise AMO data-collection exposure (spec Risk 6).
|
||
|
||
- [ ] **Step 2: Create `background/background.js`**
|
||
|
||
```js
|
||
import { load } from "../lib/settings.js";
|
||
import { buildChatGptUrl } from "../lib/build-url.js";
|
||
|
||
const MENU_ID = "ask-chatgpt-selection";
|
||
const COMMAND_ID = "ask-chatgpt";
|
||
|
||
// Reads the user's selection for the keyboard-shortcut path. The context menu
|
||
// does not need this — it receives info.selectionText directly.
|
||
//
|
||
// A commands invocation confers activeTab (ext-commands.js grants it before
|
||
// firing onCommand), so executeScript works with no host permission.
|
||
//
|
||
// Failure has three measured shapes, all handled here: a thrown
|
||
// "Missing host permission for the tab", a thrown variant naming frames, and a
|
||
// SILENT resolution to [null] on parent-process about: pages. Never index into
|
||
// a result without a null check.
|
||
async function readSelection() {
|
||
let tabs;
|
||
try {
|
||
tabs = await browser.tabs.query({ active: true, currentWindow: true });
|
||
} catch {
|
||
return "";
|
||
}
|
||
|
||
// Only tab.id may be read. Reading tab.url or tab.title would require the
|
||
// "tabs" permission, which does add an install-prompt warning line.
|
||
const tabId = tabs?.[0]?.id;
|
||
if (typeof tabId !== "number") {
|
||
return "";
|
||
}
|
||
|
||
let results;
|
||
try {
|
||
results = await browser.tabs.executeScript(tabId, {
|
||
code: "window.getSelection().toString()",
|
||
allFrames: true,
|
||
matchAboutBlank: true,
|
||
});
|
||
} catch {
|
||
return "";
|
||
}
|
||
|
||
if (!Array.isArray(results)) {
|
||
return "";
|
||
}
|
||
for (const result of results) {
|
||
if (typeof result === "string" && result.trim() !== "") {
|
||
return result;
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
async function openChatGpt(query) {
|
||
const settings = await load();
|
||
const url = buildChatGptUrl(settings, query);
|
||
|
||
if (settings.openIn === "new-window") {
|
||
try {
|
||
await browser.windows.create({ url });
|
||
return;
|
||
} catch {
|
||
// Fall through to a tab rather than doing nothing.
|
||
}
|
||
}
|
||
|
||
await browser.tabs.create({ url, active: settings.openIn !== "background-tab" });
|
||
}
|
||
|
||
// removeAll() first, so a background event page waking up and re-running this
|
||
// module cannot throw on a duplicate menu id.
|
||
async function registerMenu() {
|
||
await browser.menus.removeAll();
|
||
browser.menus.create({
|
||
id: MENU_ID,
|
||
title: 'Ask ChatGPT about "%s"',
|
||
contexts: ["selection"],
|
||
});
|
||
}
|
||
|
||
browser.menus.onClicked.addListener((info) => {
|
||
if (info.menuItemId !== MENU_ID) {
|
||
return;
|
||
}
|
||
openChatGpt(info.selectionText ?? "");
|
||
});
|
||
|
||
// browser.commands does not exist on Firefox for Android. This extension is
|
||
// desktop-only, but guard rather than throwing at startup.
|
||
if (browser.commands) {
|
||
browser.commands.onCommand.addListener(async (command) => {
|
||
if (command !== COMMAND_ID) {
|
||
return;
|
||
}
|
||
openChatGpt(await readSelection());
|
||
});
|
||
}
|
||
|
||
registerMenu();
|
||
```
|
||
|
||
- [ ] **Step 3: Verify lint and packaging still pass**
|
||
|
||
Run: `npm test`
|
||
|
||
Expected: `0 errors / 0 warnings / 0 notices`; 27 tests pass; the ZIP is now named `search-with-chatgpt-firefox-1.1.zip`.
|
||
|
||
- [ ] **Step 4: Verify the package contents**
|
||
|
||
Run: `unzip -l web-ext-artifacts/search-with-chatgpt-firefox-1.1.zip`
|
||
|
||
Expected: `manifest.json`, `LICENSE.txt`, `icons/` + 5 PNGs, `background/background.js`, `lib/` + 3 modules. **No `test/`, no `docs/`, no `options/` yet** (Task 4 adds it), and no empty `test/`/`docs/` directory entries.
|
||
|
||
- [ ] **Step 5: Manual smoke test in Firefox**
|
||
|
||
Run: `npm run dev`
|
||
|
||
In the temporary profile:
|
||
1. Select text on any ordinary web page, right-click → confirm an **"Ask ChatGPT about …"** item appears, showing the selected text.
|
||
2. Click it → ChatGPT opens in a new tab with the selection in the composer.
|
||
3. Right-click with **nothing** selected → confirm the item is **absent**.
|
||
4. Press `Alt+Shift+G` with text selected → same behaviour as the menu.
|
||
5. Press `Alt+Shift+G` with nothing selected → a blank ChatGPT opens (no `?q=`).
|
||
6. Navigate to `about:config`, press `Alt+Shift+G` → a blank ChatGPT opens. It must **not** fail silently with nothing happening, and must not log an unhandled rejection.
|
||
7. Select text inside an iframe (any page with an embedded frame) and press `Alt+Shift+G` → the iframe selection is used.
|
||
8. Confirm `gpt <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**
|
||
|
||
```bash
|
||
git add manifest.json background/background.js
|
||
git commit -m "feat: add context menu and keyboard shortcut entry points"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: The options panel
|
||
|
||
The panel renders **inline inside `about:addons`**, which is a narrow, constrained surface that inherits that page's layout and follows the system theme. Because `browser_style` is off, it is hand-styled and must handle dark mode itself.
|
||
|
||
**Files:**
|
||
- Create: `options/options.html`
|
||
- Create: `options/options.css`
|
||
- Create: `options/options.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DEFAULT_SETTINGS` from `lib/defaults.js`; `load()` and `save()` from `lib/settings.js`.
|
||
- Produces: nothing consumed by later tasks.
|
||
|
||
- [ ] **Step 1: Create `options/options.html`**
|
||
|
||
The element `id`s must match the keys in `DEFAULT_SETTINGS` exactly — `options.js` relies on that.
|
||
|
||
```html
|
||
<!-- 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`**
|
||
|
||
```css
|
||
:root {
|
||
--fg: #15141a;
|
||
--fg-dim: #5b5b66;
|
||
--bg: transparent;
|
||
--border: #8f8f9d;
|
||
--field-bg: #ffffff;
|
||
--accent: #0060df;
|
||
}
|
||
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--fg: #fbfbfe;
|
||
--fg-dim: #b1b1bd;
|
||
--border: #8f8f9d;
|
||
--field-bg: #2b2a33;
|
||
--accent: #0df;
|
||
}
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
padding: 0.5rem 0;
|
||
color: var(--fg);
|
||
background: var(--bg);
|
||
font: message-box;
|
||
font-size: 1rem;
|
||
line-height: 1.4;
|
||
max-width: 42rem;
|
||
}
|
||
|
||
.field {
|
||
margin-bottom: 1.15rem;
|
||
}
|
||
|
||
label {
|
||
display: block;
|
||
font-weight: 600;
|
||
margin-bottom: 0.3rem;
|
||
}
|
||
|
||
.checkbox label {
|
||
font-weight: 400;
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.45rem;
|
||
}
|
||
|
||
input[type="text"],
|
||
select {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
box-sizing: border-box;
|
||
padding: 0.4rem 0.5rem;
|
||
color: var(--fg);
|
||
background: var(--field-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
font: inherit;
|
||
}
|
||
|
||
input[type="text"]:focus-visible,
|
||
select:focus-visible,
|
||
button:focus-visible {
|
||
outline: 2px solid var(--accent);
|
||
outline-offset: 1px;
|
||
}
|
||
|
||
.hint {
|
||
margin: 0.3rem 0 0;
|
||
color: var(--fg-dim);
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.tag {
|
||
font-weight: 400;
|
||
font-size: 0.75rem;
|
||
color: var(--fg-dim);
|
||
border: 1px solid var(--border);
|
||
border-radius: 3px;
|
||
padding: 0 0.3rem;
|
||
vertical-align: 1px;
|
||
}
|
||
|
||
.actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
flex-wrap: wrap;
|
||
margin-top: 1.5rem;
|
||
}
|
||
|
||
button {
|
||
padding: 0.35rem 0.8rem;
|
||
color: var(--fg);
|
||
background: var(--field-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
}
|
||
|
||
#status {
|
||
color: var(--fg-dim);
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.note {
|
||
margin-top: 1.75rem;
|
||
padding-top: 1rem;
|
||
border-top: 1px solid var(--border);
|
||
color: var(--fg-dim);
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
code,
|
||
kbd {
|
||
font-family: monospace;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
/* The inline about:addons surface can be very narrow. */
|
||
@media (max-width: 26rem) {
|
||
.actions {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `options/options.js`**
|
||
|
||
```js
|
||
import { DEFAULT_SETTINGS } from "../lib/defaults.js";
|
||
import { load, save } from "../lib/settings.js";
|
||
|
||
const SAVE_DEBOUNCE_MS = 400;
|
||
const STATUS_CLEAR_MS = 1500;
|
||
|
||
const field = (id) => document.getElementById(id);
|
||
|
||
let saveTimer = null;
|
||
let statusTimer = null;
|
||
// Suppresses the change/input handlers while we are programmatically writing
|
||
// values into the form, so a remote sync cannot trigger a save loop.
|
||
let applying = false;
|
||
|
||
function render(settings) {
|
||
applying = true;
|
||
field("model").value = settings.model;
|
||
field("temporaryChat").checked = settings.temporaryChat;
|
||
field("webSearch").checked = settings.webSearch;
|
||
field("openIn").value = settings.openIn;
|
||
field("promptTemplate").value = settings.promptTemplate;
|
||
applying = false;
|
||
}
|
||
|
||
function collect() {
|
||
return {
|
||
model: field("model").value,
|
||
temporaryChat: field("temporaryChat").checked,
|
||
webSearch: field("webSearch").checked,
|
||
openIn: field("openIn").value,
|
||
promptTemplate: field("promptTemplate").value,
|
||
};
|
||
}
|
||
|
||
function showStatus(message) {
|
||
field("status").textContent = message;
|
||
clearTimeout(statusTimer);
|
||
statusTimer = setTimeout(() => {
|
||
field("status").textContent = "";
|
||
}, STATUS_CLEAR_MS);
|
||
}
|
||
|
||
async function persist(settings) {
|
||
try {
|
||
await save(settings);
|
||
showStatus("Saved");
|
||
} catch {
|
||
// storage.sync sanitises its errors, so there is nothing specific to report.
|
||
showStatus("Could not save settings");
|
||
}
|
||
}
|
||
|
||
// Debounced so typing in a text field cannot approach any write-rate quota.
|
||
function scheduleSave() {
|
||
if (applying) {
|
||
return;
|
||
}
|
||
clearTimeout(saveTimer);
|
||
saveTimer = setTimeout(() => persist(collect()), SAVE_DEBOUNCE_MS);
|
||
}
|
||
|
||
// Module scripts are deferred, so the DOM is already parsed here.
|
||
render(await load());
|
||
|
||
for (const id of Object.keys(DEFAULT_SETTINGS)) {
|
||
field(id).addEventListener("input", scheduleSave);
|
||
field(id).addEventListener("change", scheduleSave);
|
||
}
|
||
|
||
field("restore").addEventListener("click", () => {
|
||
clearTimeout(saveTimer);
|
||
render(DEFAULT_SETTINGS);
|
||
persist({ ...DEFAULT_SETTINGS });
|
||
});
|
||
|
||
// A remote sync landing while the panel is open must update the controls rather
|
||
// than being clobbered by stale field values on the next edit.
|
||
browser.storage.onChanged.addListener(async (changes, area) => {
|
||
if (area !== "sync") {
|
||
return;
|
||
}
|
||
render(await load());
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Verify lint and packaging**
|
||
|
||
Run: `npm test`
|
||
|
||
Expected: `0 errors / 0 warnings / 0 notices`; 27 tests pass; build succeeds. A top-level `await` in `options.js` is valid in an ES module; if addons-linter objects, wrap the body in an `async function init() { … } init();` instead.
|
||
|
||
- [ ] **Step 5: Confirm `options/` is packaged**
|
||
|
||
Run: `unzip -l web-ext-artifacts/search-with-chatgpt-firefox-1.1.zip | grep options`
|
||
|
||
Expected: `options/options.html`, `options/options.css`, `options/options.js`.
|
||
|
||
- [ ] **Step 6: Manual test the panel**
|
||
|
||
Run: `npm run dev`
|
||
|
||
1. Open `about:addons` → Extensions → this extension → the **Options** tab (Windows) / **Preferences** tab (macOS, Linux). Firefox's label is platform-dependent; do not expect "Preferences" everywhere.
|
||
2. Confirm all five controls plus **Restore defaults** render and are readable.
|
||
3. Change each setting; confirm "Saved" appears briefly.
|
||
4. Restart Firefox (or reload the extension) and confirm every value persisted.
|
||
5. Click **Restore defaults**; confirm the fields reset and persist.
|
||
6. Narrow the Firefox window as far as it goes; confirm nothing overflows horizontally and the actions row wraps.
|
||
7. Toggle the OS/Firefox theme to dark (`about:preferences` → Language and Appearance, or set `ui.systemUsesDarkTheme`); confirm text stays legible and inputs remain visible in **both** themes.
|
||
8. Set **Prompt template** to `Explain simply: {query}`, then use the context menu; confirm the ChatGPT composer contains `Explain simply: <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**
|
||
|
||
```bash
|
||
git add options/options.html options/options.css options/options.js
|
||
git commit -m "feat: add options panel embedded in about:addons"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Documentation, changelog, and release metadata
|
||
|
||
**Files:**
|
||
- Modify: `README.md`
|
||
- Modify: `AGENTS.md`
|
||
- Modify: `CHANGELOG.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing.
|
||
- Produces: nothing.
|
||
|
||
- [ ] **Step 1: Add an Options section and the ChatGPT limitation to `README.md`**
|
||
|
||
Insert after the existing intro paragraphs, before `## Browser Support`:
|
||
|
||
```markdown
|
||
## Options
|
||
|
||
Open `about:addons`, select this extension, and choose the **Options** tab
|
||
(**Preferences** on macOS and Linux). Available settings:
|
||
|
||
- **Model** *(experimental)* — a model slug such as `auto`. ChatGPT may ignore
|
||
it and use your account default; leave blank to always use the account
|
||
default.
|
||
- **Request a temporary chat** — asks ChatGPT not to save the conversation.
|
||
ChatGPT may not honour this; check its own indicator before relying on it.
|
||
- **Ask ChatGPT to use web search** — a hint to ChatGPT's interface, not a
|
||
guarantee.
|
||
- **Open in** — a new tab, a new background tab, or a new window.
|
||
- **Prompt template** — wraps your text, for example `Explain simply: {query}`.
|
||
|
||
These settings apply to the new entry points: selecting text and choosing
|
||
**Ask ChatGPT** from the right-click menu, or pressing
|
||
<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:
|
||
|
||
```markdown
|
||
## Privacy Policy
|
||
|
||
This extension has no server, no API key, and no intermediary. Nothing is
|
||
reported to Zavage Software.
|
||
|
||
- Your settings are stored in your own browser profile. If you use Firefox Sync
|
||
with **Add-ons** enabled, they sync end-to-end encrypted through your own
|
||
Mozilla account.
|
||
- When you choose **Ask ChatGPT** or press the shortcut, the text you selected
|
||
is placed in the chatgpt.com URL the extension opens for you. It goes nowhere
|
||
else.
|
||
- The extension reads a page's selected text only at the moment you invoke it,
|
||
and never reads page addresses or titles.
|
||
```
|
||
|
||
Do **not** write "never leaves your device" unconditionally — with Sync enabled an encrypted blob does reach Mozilla's servers. Do not restore the bare claim "This extension collects zero user data": it is true in plain English, but AMO's definition of transmission is broader, and specific wording is safer. See spec Risk 6.
|
||
|
||
- [ ] **Step 3: Update `AGENTS.md`**
|
||
|
||
In **Project Structure & Module Organization**, replace the opening line and add entries:
|
||
|
||
```markdown
|
||
This is a Firefox WebExtension (Manifest V2) with npm-based development tooling.
|
||
|
||
- `manifest.json` defines the extension metadata, the ChatGPT search provider,
|
||
the background script, the options panel, and the keyboard command.
|
||
- `lib/` holds pure logic with no browser dependencies: `defaults.js`,
|
||
`build-url.js`, and `settings.js`. This is the unit-tested core.
|
||
- `background/background.js` is an ES-module background script that registers
|
||
the context menu and the keyboard command.
|
||
- `options/` is the options panel rendered inline in `about:addons`.
|
||
- `test/` holds `node:test` unit tests. Excluded from the packaged ZIP.
|
||
- `icons/` contains size-specific PNG artwork named `powered-by-openai-icon-<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:
|
||
|
||
```markdown
|
||
- `npm run test:unit` runs the `node:test` unit tests over `test/`.
|
||
```
|
||
|
||
and replace the `npm test` line:
|
||
|
||
```markdown
|
||
- `npm test` runs lint, unit tests, and build together; run it before every pull request.
|
||
```
|
||
|
||
Replace the first paragraph of **Testing Guidelines**:
|
||
|
||
```markdown
|
||
Unit tests cover the pure logic in `lib/` via `node:test`; automated checks
|
||
otherwise cover manifest validation and packaging, not browser behaviour. Note
|
||
`node --test` exits 0 when it discovers no test files, so confirm a non-zero
|
||
test count rather than trusting the exit code alone.
|
||
|
||
After `npm test`, use `npm run dev` and confirm:
|
||
|
||
- `gpt <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**:
|
||
|
||
```markdown
|
||
Permissions are deliberately limited to `storage`, `menus`, and `activeTab`,
|
||
which add no line to Firefox's install prompt. Do not add `tabs`,
|
||
`notifications`, `webNavigation`, or any host permission without re-reading
|
||
`docs/superpowers/specs/2026-07-29-extension-options-design.md` — each was
|
||
considered and rejected there, and Appendix B records a feature rejected on
|
||
security grounds that must not be revived without clearing the bar documented
|
||
with it.
|
||
```
|
||
|
||
- [ ] **Step 4: Add the `CHANGELOG.md` entry**
|
||
|
||
Insert directly below the `# Changelog` intro paragraph, above the `## [1.0.0]` heading:
|
||
|
||
```markdown
|
||
## [1.1.0] (2026-07-30)
|
||
|
||
Adds an options panel and two new ways to reach ChatGPT.
|
||
|
||
* Options panel in `about:addons` with five settings: model (experimental),
|
||
temporary chat, web-search hint, where results open, and a prompt template.
|
||
* Right-click selected text and choose "Ask ChatGPT" to open ChatGPT with that
|
||
text filled in.
|
||
* Keyboard shortcut `Alt+Shift+G` does the same for the current selection, and
|
||
opens an empty chat when nothing is selected. Rebindable via Manage Extension
|
||
Shortcuts.
|
||
* Documented that ChatGPT no longer submits prompts passed in a URL, so a
|
||
second Enter is needed. This is an upstream change to chatgpt.com.
|
||
* `gpt <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**
|
||
|
||
```bash
|
||
git add README.md AGENTS.md CHANGELOG.md
|
||
git commit -m "doc: document options panel, entry points, and the ChatGPT prefill limitation"
|
||
```
|
||
|
||
---
|
||
|
||
## Before opening the pull request
|
||
|
||
- [ ] Re-read spec Risk 6. The PR description must flag the **open AMO
|
||
data-collection question** — whether `required: ["none"]` survives
|
||
extension code reading page text — and note it should be asked before
|
||
submission, not at submission.
|
||
- [ ] The PR description must list the manual Firefox checks actually performed,
|
||
call out the new permissions explicitly, and state that
|
||
`chrome_settings_overrides` is unchanged.
|
||
- [ ] Push the branch (`git push -u origin feat/extension-options-panel`) and
|
||
open the PR with `tea pr create`. Do not merge; hand the URL over for human
|
||
review.
|
||
|
||
## Out of scope — do not implement
|
||
|
||
Each was considered and rejected in the spec. Implementing any of them silently
|
||
would undo a deliberate decision:
|
||
|
||
- Auto-submitting the prompt (spec Appendix B — rejected on security grounds).
|
||
- `browser.omnibox` (works, but increases AMO data-collection exposure).
|
||
- Any host permission, `optional_permissions`, or `webNavigation`.
|
||
- A toolbar button; multiple named prompt templates; MV3 migration; Chrome
|
||
packaging; user-facing error notifications.
|