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
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.
1397 lines
51 KiB
Markdown
1397 lines
51 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 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.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}")) {
|
||
// 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**
|
||
|
||
```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}",
|
||
"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, {
|
||
// 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**
|
||
|
||
```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
|
||
<!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`**
|
||
|
||
```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;
|
||
|
||
// 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**
|
||
|
||
```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.
|