Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a628a77fb | ||
|
|
8679267d53 | ||
|
|
ff61aff5b9 | ||
|
|
f022f5e20d | ||
|
|
1866846d3b | ||
|
|
82fdf88b80 | ||
|
|
3f4b64374b | ||
|
|
cf5e397d53 | ||
|
|
8052c87c23 | ||
|
|
9f8a295a4f | ||
|
|
5a12663c92 | ||
|
|
384eca7589 | ||
|
|
d96277ebd9 | ||
|
|
cc68fe98b5 | ||
|
|
39a3ac74f4 | ||
|
|
cb7da3a538 | ||
|
|
519131c21f |
@@ -2,13 +2,31 @@ name: Firefox Extension CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# edited: removing the WIP: prefix starts the real run.
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
# The gate and concurrency use the github.* context, Gitea's alias for gitea.*:
|
||||
# it is the form the pattern was canaried with and the one pr-flow detects.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
# Cancel superseded PR runs, never a push run: main's push uploads the package.
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
# WIP gate: skip PRs titled WIP: so early PRs cost no run per push. Gate on
|
||||
# the title and nothing else, since a skipped job reports success on the
|
||||
# required check (see ~/.claude/docs/gitea-ci-pattern.md).
|
||||
if: >-
|
||||
github.event_name != 'pull_request' || (
|
||||
!startsWith(github.event.pull_request.title, 'WIP:') &&
|
||||
!startsWith(github.event.pull_request.title, '[WIP]')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
|
||||
@@ -35,6 +35,17 @@ Firefox Desktop is the only current implementation and release target. Do not ad
|
||||
|
||||
Use two-space indentation in JSON, YAML, and JavaScript configuration. Preserve logical key grouping in `manifest.json`. Use lowercase, hyphenated filenames and include dimensions in icon names. Keep changes focused and avoid adding runtime dependencies without a concrete need.
|
||||
|
||||
## Options Page
|
||||
|
||||
- Keep every setting. Customization in a lightweight extension is what sets
|
||||
this one apart, so simplify wording and layout, but never remove a setting
|
||||
without the maintainer's agreement. Less-used settings can sit under
|
||||
**More options**.
|
||||
- Write hotkeys as `<Ctrl>+L`: named keys in angle brackets, single characters
|
||||
plain. `options/shortcut.js` swaps in Mac key names.
|
||||
- Say that ChatGPT opens with the text filled in, never that the text is sent,
|
||||
searched, or asked. The options design spec explains why.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Unit tests cover the pure logic in `lib/` via `node:test`; automated checks
|
||||
@@ -46,10 +57,12 @@ 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 the settings plus Restore defaults,
|
||||
- The Options/Preferences tab renders the drawings, every setting (including
|
||||
those under More options), and Restore defaults,
|
||||
in both light and dark themes and at a narrow window width, and values
|
||||
persist across a restart.
|
||||
- **Context menu → Simple** shows the direct **Ask ChatGPT** item only for
|
||||
persist across a restart. Expanding and collapsing More options and the
|
||||
collapsed sections resizes the inline panel without clipping or scrolling.
|
||||
- **Right-click menu → Simple** shows the direct **Ask ChatGPT** item only for
|
||||
selected text. **Advanced** shows the contextual submenu; selection takes
|
||||
precedence over links. **Off** hides the menu. Switch modes without restarting
|
||||
and confirm Alt+Shift+G still works in every mode. Restore defaults returns
|
||||
@@ -74,7 +87,7 @@ After `npm test`, use `npm run dev` and confirm:
|
||||
stale cross-origin frame silently seeding the prompt was the worse failure.
|
||||
- Click the **Ask ChatGPT** toolbar button with no selection, a top-frame
|
||||
selection, and an iframe selection after a stale top-frame selection.
|
||||
Check restricted-page fallback, context menu Off, saved prompt and opening
|
||||
Check restricted-page fallback, right-click menu Off, saved prompt and opening
|
||||
preferences, and consent before shortening long selections. Test both the
|
||||
pinned toolbar button and its entry in Firefox’s extensions menu.
|
||||
|
||||
|
||||
+27
-10
@@ -2,28 +2,45 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version).
|
||||
|
||||
## Unreleased
|
||||
## [1.3.0] (2026-10-18)
|
||||
|
||||
- Use the extension's bundled icon for the ChatGPT search engine.
|
||||
- Rewrite the options page for first-time users: usage instructions with a
|
||||
small drawing for each way to use the extension, a preview of the Advanced
|
||||
right-click menu, plainer wording, and the prompt template, temporary chat,
|
||||
and web search settings under More options. Hotkeys are written as
|
||||
`<Ctrl>+L`, with Mac key names on a Mac.
|
||||
- Correct the keyword instructions: a custom keyword works alongside `gpt`
|
||||
rather than replacing it.
|
||||
|
||||
## [1.2.0] (2026-10-18)
|
||||
|
||||
The first update on addons.mozilla.org since 1.0. It also delivers everything
|
||||
in 1.1.0, which was never published there.
|
||||
|
||||
- Add an Ask ChatGPT toolbar button: use selected text or open a blank chat,
|
||||
honoring saved prompt and tab/window preferences. No new permissions.
|
||||
Requires Firefox 112 or newer, like the other new entry points.
|
||||
- Add Off / Simple / Advanced context-menu modes with migration from the old
|
||||
visibility preference. Advanced provides selection presets and link/page URL
|
||||
actions, with optional translation and a preferred language or language dialog.
|
||||
- Preserve dedicated action instructions when shortening selections; ask before
|
||||
sending shortened text and reject URLs that cannot be sent intact.
|
||||
- Stay responsive with very large selections from the shortcut or toolbar: only
|
||||
the part that can fit in the ChatGPT link is processed.
|
||||
- Add accessible action dialogs with cancellation and retry, remove the temporary
|
||||
model/effort URL probe, and document URL sharing. No new permissions.
|
||||
- Show up front, in the options panel and README, that ChatGPT may require
|
||||
signing in and that an unsent prompt needs Enter or Send.
|
||||
|
||||
|
||||
* Options instructions now show the configured keyboard shortcut, update when
|
||||
it changes, and indicate when no shortcut is assigned. Added a button to
|
||||
open Firefox’s shortcut editor where supported.
|
||||
* Shortened the context menu label to “Ask ChatGPT”.
|
||||
* Added quick usage instructions and guidance for changing the search keyword
|
||||
- Limit the prompt template to 1,000 characters, so an oversized template can
|
||||
no longer stop every other setting from saving.
|
||||
- Show the configured keyboard shortcut in the options instructions, updating
|
||||
when it changes and indicating when none is assigned, with a button to open
|
||||
Firefox’s shortcut editor where supported.
|
||||
- Shorten the context menu label to “Ask ChatGPT”.
|
||||
- Add quick usage instructions and guidance for changing the search keyword
|
||||
and default engine in Firefox to the options panel.
|
||||
* Added a “Feedback or report a bug” link to the options panel and README.
|
||||
- Add a “Feedback or report a bug” link to the options panel and README.
|
||||
|
||||
## [1.1.0] (2026-07-30)
|
||||
|
||||
@@ -44,7 +61,7 @@ Adds an options panel and two new ways to reach ChatGPT.
|
||||
* Permissions added: storage, menus, activeTab. None of them adds a line to
|
||||
Firefox's install prompt.
|
||||
|
||||
## [1.0.0]) (2024-06-09)
|
||||
## [1.0.0] (2024-06-09)
|
||||
|
||||
First release of software.
|
||||
|
||||
|
||||
@@ -31,23 +31,24 @@ Click the extension’s **Ask ChatGPT** toolbar button to use selected text or,
|
||||
with nothing selected, open a blank chat. On pages Firefox does not allow
|
||||
extensions to read, the button opens a blank chat. You can pin the button to
|
||||
the toolbar from Firefox’s extensions menu. It uses your saved prompt and
|
||||
tab/window preferences, and works even when the context menu is Off.
|
||||
tab/window preferences, and works even when the right-click menu is Off.
|
||||
|
||||
The options panel shows your currently configured shortcut and refreshes when
|
||||
it changes or you return to the panel. If you remove it, the panel shows
|
||||
The options panel's collapsed **Change the keyboard shortcut** section shows
|
||||
your currently configured shortcut and refreshes when it changes or you return
|
||||
to the panel. If you remove it, the panel shows
|
||||
“No keyboard shortcut assigned”. Its **Change keyboard shortcut** button opens
|
||||
Firefox’s shortcut editor when supported; otherwise follow the menu path above.
|
||||
**Restore defaults** does not change your keyboard shortcut.
|
||||
|
||||
To customize the prompt, set **Prompt template** to something like
|
||||
To customize the prompt, open **More options** and set **Prompt template** to something like
|
||||
`Explain simply: {query}` or `Translate into Spanish: {query}`. The Simple menu action, Ask normally, toolbar button, and shortcut use your template.
|
||||
Other advanced actions use their own dedicated prompts.
|
||||
|
||||
## Search keyword and default engine
|
||||
|
||||
Open Firefox’s **Settings → Search**, or paste `about:preferences#search` into
|
||||
the address bar. Under **Search Shortcuts**, edit ChatGPT’s keyword to replace
|
||||
`gpt` with your preferred prefix. Under **Default Search Engine**, choose
|
||||
the address bar. Under **Search Shortcuts**, edit ChatGPT’s keyword to add
|
||||
your own prefix; `gpt` keeps working alongside it. Under **Default Search Engine**, choose
|
||||
ChatGPT to search without a prefix, or choose your previous engine to switch
|
||||
back. See [Firefox’s search settings guide](https://support.mozilla.org/en-US/kb/change-your-default-search-settings-firefox)
|
||||
for illustrated instructions.
|
||||
@@ -60,21 +61,25 @@ not change them.
|
||||
Open `about:addons`, select this extension, and choose the **Options** tab
|
||||
(**Preferences** on macOS and Linux). Available settings:
|
||||
|
||||
- **Context menu** — **Simple** (default) keeps the direct selection action;
|
||||
- **Right-click menu** — **Simple** (default) keeps the direct selection action;
|
||||
**Advanced** offers contextual presets; **Off** hides the menu. The toolbar
|
||||
button and keyboard shortcut work in every mode. Existing enabled/disabled preferences migrate
|
||||
to Simple/Off. Changes apply without restarting Firefox.
|
||||
- **Show Translate in advanced menu** — off by default. When enabled, choose
|
||||
- **Show Translate in the Advanced menu** — off by default. When enabled, choose
|
||||
**Ask each time** for a language dialog, or enter a **Preferred language**
|
||||
to translate directly. An empty or invalid preferred language falls back to
|
||||
asking. Enter continues; Escape or Cancel opens nothing.
|
||||
- **Open ChatGPT in** — a new tab, a new background tab, or a new window.
|
||||
|
||||
Under **More options**, which opens by itself once you have changed one of
|
||||
them:
|
||||
|
||||
- **Prompt template** — wraps your text, for example `Explain simply: {query}`.
|
||||
Up to 1,000 characters.
|
||||
- **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}`.
|
||||
Up to 1,000 characters.
|
||||
|
||||
Restore defaults resets extension preferences to Simple mode with translation
|
||||
hidden; it does not change Firefox's search engine or shortcut settings.
|
||||
|
||||
@@ -96,6 +96,21 @@ send. The hypothesis that address-bar navigation might still be favoured
|
||||
(`Sec-Fetch-Site: none` being the most-trusted value) **does not hold in
|
||||
practice**.
|
||||
|
||||
**Correction, 2026-10-18: `q` auto-submits again.**
|
||||
- **Signed out:** in headless Firefox 157, `chatgpt.com/?q=` sent the prompt
|
||||
and ChatGPT answered with no keypress. This happened even when an unrelated
|
||||
page navigated to the URL (`Sec-Fetch-Site: cross-site`).
|
||||
- **Maintainer's Firefox:** the same day, the maintainer reported that prompts
|
||||
were sent without pressing Enter.
|
||||
- **A week earlier:** on 2026-10-12 the signed-out path showed a verification
|
||||
error and the signed-in path still needed Enter
|
||||
(`docs/toolbar-verification.md`).
|
||||
|
||||
So this changed within a week and may differ between accounts. Treat both
|
||||
behaviours as possible. The copy rule below still applies, and the README
|
||||
already says ChatGPT decides whether a prompt is sent. Appendix B covers what
|
||||
this means for selection smuggling.
|
||||
|
||||
No copy in the options panel, README, or AMO listing may say the query is
|
||||
*searched*, *sent*, or *asked* — only that ChatGPT opens **with the query filled
|
||||
in**. The two-keypress behaviour is an upstream ChatGPT change, not a defect in
|
||||
@@ -698,7 +713,7 @@ by running commands against copies under `/tmp`; the repo itself was unmodified.
|
||||
| Parameter | Rating | Basis |
|
||||
|---|---|---|
|
||||
| `q` prefills | **Solid** | Dated 2026 sources; maintained tooling; OpenAI's own extension uses it; a live probe returned HTTP 200 with all parameters preserved. |
|
||||
| `q` auto-submits | **Refuted, and confirmed by first-hand test** | Prefill-only in every dated report since 2025-11-11. Tenable TRA-2025-22 documents OpenAI gating auto-submit on `Sec-Fetch-Site`. Reproduced 2026-07-29 in the maintainer's logged-in Firefox via this extension's own search-provider path. |
|
||||
| `q` auto-submits | **Varies; auto-submitting again as of 2026-10-18** | Prefill-only in every dated report from 2025-11-11, when Tenable TRA-2025-22 documented OpenAI gating auto-submit on `Sec-Fetch-Site`, and reproduced 2026-07-29 in the maintainer's logged-in Firefox. On 2026-10-18 it auto-submitted signed out, including after a cross-site navigation, and the maintainer reported the same in their own Firefox. See the correction under the `q` section. |
|
||||
| `model` honoured alongside `q` | **Uncertain** | An unresolved 2024-12-28 report says `q` forces GPT-4o regardless; counter-evidence exists from 2024–2025; no 2026 source positively confirms. Maintained tooling grades it experimental. |
|
||||
| Valid `model` slugs | **Uncertain** | No authoritative list; sightings rot (`gpt-4`, `o1-preview`, `gpt-4o`, `gpt-5-thinking`, `auto`, `instant`, `thinking`). |
|
||||
| `temporary-chat=true` | **Uncertain** | Community-confirmed originally; multiple reports of it not applying or reverting after the first message; OpenAI reworking the feature. |
|
||||
@@ -790,6 +805,16 @@ composer until a human presses Enter. **Auto-submit deletes that checkpoint.**
|
||||
This is also why the context menu and shortcut remain safe *as long as they only
|
||||
prefill*.
|
||||
|
||||
> **2026-10-18:** ChatGPT itself now auto-submits `?q=` prompts, at least for
|
||||
> some accounts (see the correction under the `q` section). While that holds,
|
||||
> the composer checkpoint above is gone for the context menu, toolbar button and
|
||||
> shortcut alike: hidden text in a selection can reach an answered prompt
|
||||
> without review. The extension still adds no submission of its own, and this
|
||||
> appendix's rejection stands. 1.2.0 ships with the behaviour known. Possible
|
||||
> mitigations, such as framing the default Ask prompt as data the way the preset
|
||||
> actions already do, or an option to review page-sourced text first, are open
|
||||
> follow-ups rather than part of this design.
|
||||
|
||||
**`webNavigation.transitionType` is not a security boundary.** The signal looks
|
||||
right — a `gpt` search emits `transitionType: "generated"` with
|
||||
`transitionQualifiers: ["from_address_bar"]`, and content cannot forge those
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Options page for first-time users
|
||||
|
||||
Status: implemented on `feat/options-page-cleanup` (PR #23).
|
||||
|
||||
## Goal
|
||||
|
||||
Make the options page usable by people who are not comfortable with computers,
|
||||
without losing any setting. Customization in a lightweight extension is what
|
||||
sets this one apart, so the page simplifies wording and layout, never features.
|
||||
|
||||
## Layout
|
||||
|
||||
1. **Description**, no heading: "This extension adds ChatGPT as a Firefox
|
||||
search engine."
|
||||
2. **Usage instructions**: one labelled line per way to open ChatGPT, each with
|
||||
one drawing:
|
||||
- Address bar
|
||||
- Right-click
|
||||
- Keyboard (hidden when no shortcut is assigned)
|
||||
- Toolbar
|
||||
|
||||
A small tip follows: ChatGPT opens with the text filled in, press
|
||||
`<Enter>` if it isn't sent, and sign-in may be needed.
|
||||
3. **Settings**:
|
||||
- Visible: **Right-click menu** (renamed from "Context menu"), its
|
||||
Translate sub-settings, and **Open ChatGPT in**.
|
||||
- Choosing **Advanced** reveals a drawing of the Ask ChatGPT submenu. It
|
||||
sits in the block that only shows for Advanced, so it previews the one
|
||||
setting newcomers find hardest to picture, at no cost to anyone else.
|
||||
- Under **More options**: prompt template, temporary chat, and web search.
|
||||
The summary names all three. The section opens on load, or when a sync
|
||||
changes one of them, if any differs from its default. That way a
|
||||
customised setting never looks lost.
|
||||
- **Restore defaults** sits on the right in red, because it discards every
|
||||
customised setting at once.
|
||||
4. Two collapsed sections:
|
||||
- **Use your own keyword or make ChatGPT your default search engine**, as
|
||||
numbered steps.
|
||||
- **Change the keyboard shortcut**.
|
||||
5. Project links, centred, stacked on narrow panels.
|
||||
|
||||
## Drawings, not screenshots
|
||||
|
||||
Each drawing is HTML, CSS, and a little inline SVG. The only image is the
|
||||
bundled extension icon. The drawings are `aria-hidden`; the text next to them
|
||||
carries the instructions.
|
||||
|
||||
Real screenshots were considered and rejected for the page:
|
||||
- they go stale when Firefox redesigns its address bar or menus;
|
||||
- the right-click menu looks different on Windows, macOS, and Linux;
|
||||
- they need separate light and dark versions;
|
||||
- they must be bundled, since loading them remotely would repeat the problem
|
||||
fixed in #20.
|
||||
|
||||
Size was not the issue. Screenshots would have added roughly 50–150 KB. The
|
||||
whole rewrite, drawings included, added under 4 KB; the package went from
|
||||
52.8 KB to 56.5 KB.
|
||||
Real screenshots belong on the AMO listing instead, which has none.
|
||||
|
||||
The address-bar drawing shows a ChatGPT label after `gpt` and a space. The
|
||||
basis is the code shipped in Firefox 157.0.1, read from its `omni.ja`; it has
|
||||
not yet been checked on screen:
|
||||
- `UrlbarProviderAliasEngines` gives keyword results a `keyword` payload;
|
||||
- `UrlbarInputBase.onFirstResult` enters search mode for such a result.
|
||||
|
||||
If Firefox shows no label, the drawing should show plain text instead.
|
||||
|
||||
The keyboard drawing draws key caps for the user's actual shortcut, which
|
||||
`options/shortcut.js` builds from `commands.getAll()`.
|
||||
|
||||
## Hotkeys
|
||||
|
||||
Hotkeys are written as `<Ctrl>+L`: named keys in angle brackets, single
|
||||
characters plain. This is the maintainer's own convention from the AMO
|
||||
listing. It applies to the static text and to the live shortcut.
|
||||
|
||||
On macOS the page uses Mac key names, detected with
|
||||
`runtime.getPlatformInfo()`, which needs no permission:
|
||||
- `Ctrl` and `Command` become `Cmd`; Firefox reads a manifest's `Ctrl` as
|
||||
Command there (`ShortcutUtils.chromeModifierKeyMap`).
|
||||
- `MacCtrl` becomes `Ctrl`.
|
||||
- `Alt` becomes `Option`.
|
||||
- `Enter` becomes `Return`.
|
||||
|
||||
Shortcuts set in Firefox's shortcut editor on macOS are stored with
|
||||
`MacCtrl`, `Alt`, `Command`, and `Shift` (`addon-shortcuts.mjs`,
|
||||
`getShortcutForEvent`).
|
||||
|
||||
## Copy rules kept
|
||||
|
||||
These come from the
|
||||
[options design](2026-07-29-extension-options-design.md):
|
||||
- Copy says ChatGPT opens with the text filled in, never that it is sent,
|
||||
searched, or asked.
|
||||
- The temporary-chat label and warning are unchanged, with no "private",
|
||||
"incognito", or "secure" near them.
|
||||
- Web search stays labelled as a hint.
|
||||
|
||||
## Testing
|
||||
|
||||
`npm test` covers:
|
||||
- the key formatter, including the macOS key names;
|
||||
- the static hotkeys in `options.html` matching the formatter;
|
||||
- the key caps drawn for the live shortcut.
|
||||
|
||||
Manual checks are listed in `AGENTS.md`.
|
||||
|
||||
To preview the page outside the extension:
|
||||
1. Copy `options/`, `lib/`, and `icons/` to a scratch folder.
|
||||
2. Add a script before `options.js` that defines a stand-in `browser` with
|
||||
`runtime.getPlatformInfo`, `commands`, and `storage`.
|
||||
3. Serve the folder on localhost.
|
||||
4. Run `firefox --headless -no-remote -profile <tmp> --screenshot out.png
|
||||
--window-size=680,1600 <url>`.
|
||||
|
||||
For dark mode, give the throwaway profile
|
||||
`layout.css.prefers-color-scheme.content-override` = 0 and
|
||||
`ui.systemUsesDarkTheme` = 1. This renders with Firefox's own engine, but not
|
||||
inside `about:addons`.
|
||||
|
||||
## Not in scope
|
||||
|
||||
- Screenshots and an updated description for the AMO listing.
|
||||
- Pinning the toolbar button by default (`browser_action.default_area`).
|
||||
- Rewriting the README's hotkeys in the `<Alt>+<Shift>+G` style.
|
||||
@@ -13,7 +13,6 @@ const TITLES = {
|
||||
};
|
||||
|
||||
export function getContext(info) {
|
||||
if (info.inputType === 'password') return null;
|
||||
if (typeof info.selectionText === 'string' && info.selectionText.trim()) {
|
||||
return { kind: 'selection', source: info.selectionText.trim() };
|
||||
}
|
||||
|
||||
+3
-62
@@ -12,9 +12,6 @@ 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;
|
||||
|
||||
export function applyTemplate(template, query) {
|
||||
if (query === "") {
|
||||
return "";
|
||||
@@ -42,65 +39,9 @@ function addOptionalParams(url, settings) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Encode a complete prompt without silently shortening it.
|
||||
// Encode a complete prompt without silently shortening it; prepareAction owns
|
||||
// the length budget and asks before shortening. q goes first, matching the
|
||||
// most-cited working examples.
|
||||
export function serializePrompt(settings, prompt) {
|
||||
const url = new URL(BASE);
|
||||
if (prompt) url.searchParams.set("q", prompt);
|
||||
|
||||
+12
-1
@@ -25,12 +25,23 @@ export function prepareAction(request) {
|
||||
}
|
||||
const resolved = { ...request, language };
|
||||
const serialize = text => serializePrompt(request.settings, renderPrompt(resolved, text));
|
||||
// Each code point adds at least one URL character, so none past the first
|
||||
// MAX_URL_CHARS can fit. Capping first keeps a select-all on a huge page from
|
||||
// costing seconds of re-encoding. A capped source is never sent as ready: its
|
||||
// kept prefix may trim down to something that fits and silently drop the rest.
|
||||
const points = [];
|
||||
let capped = false;
|
||||
for (const point of source) {
|
||||
if (points.length === MAX_URL_CHARS) { capped = true; break; }
|
||||
points.push(point);
|
||||
}
|
||||
if (!capped) {
|
||||
const url = serialize(source);
|
||||
if (url.length <= MAX_URL_CHARS) return { kind: 'ready', url };
|
||||
}
|
||||
if (isUrl) return error('This URL is too long to send intact. No part of it has been sent.');
|
||||
const marker = '\n[Selected text shortened]';
|
||||
if (serialize(marker).length >= MAX_URL_CHARS) return error('The prompt instructions are too long. Shorten your prompt template and try again.');
|
||||
const points = Array.from(source);
|
||||
let low = 0, high = points.length;
|
||||
while (low < high) {
|
||||
const mid = Math.ceil((low + high) / 2);
|
||||
|
||||
+2
-3
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "Search with ChatGPT powered by OpenAI!",
|
||||
"description": "Adds a ChatGPT search engine",
|
||||
"description": "Adds a ChatGPT search engine, plus a right-click menu, toolbar button and shortcut that open ChatGPT with your selected text.",
|
||||
"manifest_version": 2,
|
||||
"author": "Mathew Guest <mat@zavage.net>",
|
||||
"homepage_url": "https://zavage-software.com/portfolio/search-with-ChatGPT",
|
||||
"version": "1.1",
|
||||
"version": "1.3",
|
||||
"icons": {
|
||||
"16": "icons/powered-by-openai-icon-16x16.png",
|
||||
"32": "icons/powered-by-openai-icon-32x32.png",
|
||||
@@ -59,7 +59,6 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
+351
-17
@@ -5,6 +5,11 @@
|
||||
--border: #8f8f9d;
|
||||
--field-bg: #ffffff;
|
||||
--accent: #0060df;
|
||||
/* Backdrop for the drawings and inline code. */
|
||||
--surface: #f0f0f4;
|
||||
--tooltip-bg: #15141a;
|
||||
--tooltip-fg: #fbfbfe;
|
||||
--danger: #d70022;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -15,6 +20,10 @@
|
||||
--border: #8f8f9d;
|
||||
--field-bg: #2b2a33;
|
||||
--accent: #0df;
|
||||
--surface: #1c1b22;
|
||||
--tooltip-bg: #52525e;
|
||||
--tooltip-fg: #fbfbfe;
|
||||
--danger: #ff848b;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +39,270 @@ body {
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0 0 1.5rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.ways {
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ways > li {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.inline-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
vertical-align: -3px;
|
||||
}
|
||||
|
||||
/* Simplified drawings of Firefox's address bar and right-click menu. They are
|
||||
not screenshots, so they follow the theme and never go out of date. */
|
||||
.drawing {
|
||||
max-width: 24rem;
|
||||
margin: 0.45rem auto 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
font-size: 0.85rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.drawing img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.drawing-urlbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
background: var(--field-bg);
|
||||
outline: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.drawing-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--fg) 10%, var(--field-bg));
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawing-typed {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* The text cursor. */
|
||||
.drawing-typed::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 1.1em;
|
||||
margin-inline-start: 1px;
|
||||
background: currentColor;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.drawing-page {
|
||||
margin: 0 0 0.3rem;
|
||||
}
|
||||
|
||||
.drawing mark {
|
||||
color: inherit;
|
||||
background: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.drawing-toolbar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.drawing-address {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
background: var(--field-bg);
|
||||
color: var(--fg-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.drawing-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.drawing-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.drawing-button-hover {
|
||||
background: color-mix(in srgb, var(--fg) 14%, transparent);
|
||||
}
|
||||
|
||||
/* The mouse pointer resting on the hovered button. */
|
||||
.drawing-button svg.drawing-pointer {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 17px;
|
||||
width: 12px;
|
||||
height: 18px;
|
||||
fill: #fff;
|
||||
stroke: #000;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.drawing-tooltip {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.65rem);
|
||||
right: 0;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
background: var(--tooltip-bg);
|
||||
color: var(--tooltip-fg);
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
/* Top padding keeps the heading clear of the tooltip at narrow widths. */
|
||||
.drawing-content {
|
||||
margin: 0.6rem 0 0;
|
||||
padding: 1.9rem 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
background: var(--field-bg);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.drawing-menu {
|
||||
width: 11rem;
|
||||
/* Opens at the pointer, just under the selection. */
|
||||
margin-inline-start: 3.5rem;
|
||||
padding: 0.25rem 0;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||
border-radius: 6px;
|
||||
background: var(--field-bg);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
.drawing-menu > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
/* Line the plain items up with the text of the item that has an icon. */
|
||||
padding: 0.2rem 0.75rem 0.2rem calc(0.75rem + 16px + 0.45rem);
|
||||
}
|
||||
|
||||
.drawing-menu > .drawing-menu-active {
|
||||
padding-inline-start: 0.75rem;
|
||||
background: color-mix(in srgb, var(--accent) 20%, var(--field-bg));
|
||||
}
|
||||
|
||||
.drawing-menu hr {
|
||||
margin: 0.25rem 0;
|
||||
border: 0;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
|
||||
}
|
||||
|
||||
/* The Advanced submenu, open beside the highlighted Ask ChatGPT item. */
|
||||
.drawing-advanced {
|
||||
margin-bottom: 1.15rem;
|
||||
}
|
||||
|
||||
.drawing-menus {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.drawing-menus > .drawing-menu {
|
||||
flex: none;
|
||||
width: 10rem;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
.drawing-menus > .drawing-submenu {
|
||||
width: 8.5rem;
|
||||
/* Lines its first item up with Ask ChatGPT, overlapping the menu edge. */
|
||||
margin-top: 3.75rem;
|
||||
margin-inline-start: -0.3rem;
|
||||
}
|
||||
|
||||
/* Submenu items have no icon, so the highlighted one keeps the icon gap. */
|
||||
.drawing-submenu > .drawing-menu-active {
|
||||
padding-inline-start: calc(0.75rem + 16px + 0.45rem);
|
||||
}
|
||||
|
||||
.drawing-submenu-arrow {
|
||||
margin-inline-start: auto;
|
||||
font-size: 1.2em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* The shortcut's keys, held down. shortcut.js fills these in. */
|
||||
.drawing-keycaps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0;
|
||||
color: var(--fg-dim);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.drawing-key {
|
||||
min-width: 1.4rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 55%, var(--border));
|
||||
border-bottom-width: 3px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--field-bg));
|
||||
color: var(--fg);
|
||||
text-align: center;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.hint.intro-hint {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.usage,
|
||||
.settings {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.hint.settings-scope {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
@@ -65,7 +337,8 @@ select {
|
||||
|
||||
input[type="text"]:focus-visible,
|
||||
select:focus-visible,
|
||||
button:focus-visible {
|
||||
button:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
@@ -76,18 +349,30 @@ button:focus-visible {
|
||||
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;
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
details[open] > summary {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.more-options {
|
||||
margin-bottom: 1.15rem;
|
||||
}
|
||||
|
||||
.summary-detail {
|
||||
font-weight: 400;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
/* Reversed so the button stays at the right edge and "Saved" appears beside
|
||||
it without moving it. */
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
@@ -104,35 +389,69 @@ button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Restore defaults discards every customised setting at once. */
|
||||
button.danger {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
color: var(--bg);
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
#status {
|
||||
color: var(--fg-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 1.75rem;
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--fg-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
code,
|
||||
kbd {
|
||||
.note summary {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0 0.25em;
|
||||
border-radius: 3px;
|
||||
background: var(--surface);
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.note h2 {
|
||||
/* Written as <Ctrl>+L, so the keys read as words rather than code. */
|
||||
kbd {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage h2,
|
||||
.settings h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--fg);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.note p {
|
||||
.note p,
|
||||
.note ol {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.note ol {
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
|
||||
.note li {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.note code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -140,8 +459,9 @@ kbd {
|
||||
.project-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem 0.65rem;
|
||||
margin-top: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.project-link {
|
||||
@@ -167,9 +487,23 @@ a:focus-visible {
|
||||
/* The inline about:addons surface can be very narrow. */
|
||||
@media (max-width: 26rem) {
|
||||
.actions {
|
||||
align-items: flex-start;
|
||||
align-items: flex-end;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drawing-menu {
|
||||
margin-inline-start: 1rem;
|
||||
}
|
||||
|
||||
/* Stack the links rather than wrap one onto a line that starts with "|". */
|
||||
.project-links {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.link-separator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
+155
-54
@@ -7,37 +7,120 @@
|
||||
<link rel="stylesheet" href="options.css" />
|
||||
|
||||
<p class="intro">
|
||||
This extension adds <a href="https://chatgpt.com/" target="_blank" rel="noopener noreferrer">ChatGPT</a> as a Firefox search engine. Type
|
||||
<code>gpt your question</code> in the address bar, select text on a webpage and
|
||||
choose <strong>Ask ChatGPT</strong> from the right-click menu.<span id="shortcut-intro"></span>
|
||||
This extension adds <a href="https://chatgpt.com/" target="_blank" rel="noopener noreferrer">ChatGPT</a> as a Firefox search engine.
|
||||
</p>
|
||||
|
||||
<p class="intro">
|
||||
Click the <strong>Ask ChatGPT</strong> toolbar button to ask about selected
|
||||
text, or open a blank chat when nothing is selected. The button works in
|
||||
every context-menu mode and follows your settings below.
|
||||
</p>
|
||||
<section class="usage" aria-labelledby="usage-title">
|
||||
<h2 id="usage-title">Usage instructions</h2>
|
||||
|
||||
<p class="intro">
|
||||
ChatGPT may require you to sign in. If your prompt is not sent automatically,
|
||||
press <strong>Enter</strong> or click <strong>Send</strong> in ChatGPT.
|
||||
<!-- Hotkeys are written <Ctrl>+L: named keys in angle brackets, letters plain.
|
||||
shortcut.js rewrites each [data-shortcut] for the user's platform. The
|
||||
drawings are decorative; the text beside them carries the instructions. -->
|
||||
<ul class="ways">
|
||||
<li>
|
||||
<strong>Address bar:</strong> press <kbd data-shortcut="Ctrl+L"><Ctrl>+L</kbd>,
|
||||
type <code>gpt</code> and a space, then your question, and press
|
||||
<kbd data-shortcut="Enter"><Enter></kbd>.
|
||||
<div class="drawing" aria-hidden="true">
|
||||
<div class="drawing-urlbar">
|
||||
<span class="drawing-chip"><img src="../icons/powered-by-openai-icon-16x16.png" srcset="../icons/powered-by-openai-icon-32x32.png 2x" alt="" />ChatGPT</span>
|
||||
<span class="drawing-typed">how do magnets work?</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Right-click:</strong> select text on a webpage, right-click it, and
|
||||
choose <strong>Ask ChatGPT</strong>.
|
||||
<div class="drawing" aria-hidden="true">
|
||||
<p class="drawing-page">Fridge <mark>magnets stick</mark> to steel because…</p>
|
||||
<div class="drawing-menu">
|
||||
<div>Copy</div>
|
||||
<div>Select All</div>
|
||||
<hr />
|
||||
<div class="drawing-menu-active"><img src="../icons/powered-by-openai-icon-16x16.png" srcset="../icons/powered-by-openai-icon-32x32.png 2x" alt="" />Ask ChatGPT</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li id="shortcut-intro" hidden>
|
||||
<strong>Keyboard:</strong> select text and press <kbd id="shortcut-intro-key"></kbd>.
|
||||
With nothing selected, it opens a blank chat.
|
||||
<div class="drawing" aria-hidden="true">
|
||||
<div class="drawing-keycaps" id="shortcut-keycaps"></div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Toolbar:</strong> select text and click the
|
||||
<img class="inline-icon" src="../icons/powered-by-openai-icon-16x16.png" srcset="../icons/powered-by-openai-icon-32x32.png 2x" alt="" />
|
||||
<strong>Ask ChatGPT</strong> button. With nothing selected, it opens a
|
||||
blank chat. Can’t see the button? Look in the puzzle-piece Extensions menu.
|
||||
<div class="drawing" aria-hidden="true">
|
||||
<div class="drawing-toolbar">
|
||||
<span class="drawing-address">example.com</span>
|
||||
<span class="drawing-button">
|
||||
<svg viewBox="0 0 16 16"><path d="M2 5.5h3a2 2 0 1 1 3.2 0h3.3v2.8a2 2 0 1 1 0 3.2v3H2z" /></svg>
|
||||
</span>
|
||||
<span class="drawing-button drawing-button-hover">
|
||||
<img src="../icons/powered-by-openai-icon-16x16.png" srcset="../icons/powered-by-openai-icon-32x32.png 2x" alt="" />
|
||||
<svg class="drawing-pointer" viewBox="0 0 12 18"><path d="M1 1v14.5l3.6-3.4 2.6 5.6 2.2-1-2.6-5.5H11z" /></svg>
|
||||
</span>
|
||||
<span class="drawing-button">
|
||||
<svg viewBox="0 0 16 16"><path d="M3 4.5h10M3 8h10M3 11.5h10" /></svg>
|
||||
</span>
|
||||
<span class="drawing-tooltip">Ask ChatGPT</span>
|
||||
</div>
|
||||
<p class="drawing-content"><mark>Why fridge magnets stick</mark></p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="hint intro-hint">
|
||||
ChatGPT opens with your text filled in. If it isn’t sent automatically, press
|
||||
<kbd data-shortcut="Enter"><Enter></kbd> or click Send. ChatGPT may ask you to sign in first.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="settings" aria-labelledby="settings-title">
|
||||
<h2 id="settings-title">Settings</h2>
|
||||
<p class="hint settings-scope">
|
||||
These apply to the right-click menu, toolbar button, and keyboard shortcut.
|
||||
Typing <code>gpt</code> in the address bar isn’t affected.
|
||||
</p>
|
||||
|
||||
<form id="settings" autocomplete="off">
|
||||
<div class="field">
|
||||
<label for="contextMenuMode">Context menu</label>
|
||||
<label for="contextMenuMode">Right-click menu</label>
|
||||
<select id="contextMenuMode">
|
||||
<option value="off">Off</option>
|
||||
<option value="simple">Simple</option>
|
||||
<option value="advanced">Advanced</option>
|
||||
</select>
|
||||
<p class="hint">Simple asks about selected text. Advanced adds Explain, Summarize, Debug code, and link/page actions.</p>
|
||||
<p class="hint" id="shortcut-hint">The keyboard shortcut always runs your default action.</p>
|
||||
<p class="hint">
|
||||
Simple shows Ask ChatGPT. Advanced adds Explain, Summarize, Debug code,
|
||||
and more. With Off, the toolbar button and keyboard shortcut still work.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="advanced-settings" class="dependent-settings" hidden>
|
||||
<!-- Shown only with Advanced, so the drawing previews what it adds. -->
|
||||
<div class="drawing drawing-advanced" aria-hidden="true">
|
||||
<div class="drawing-menus">
|
||||
<div class="drawing-menu">
|
||||
<div>Copy</div>
|
||||
<div>Select All</div>
|
||||
<hr />
|
||||
<div class="drawing-menu-active"><img src="../icons/powered-by-openai-icon-16x16.png" srcset="../icons/powered-by-openai-icon-32x32.png 2x" alt="" />Ask ChatGPT<span class="drawing-submenu-arrow">›</span></div>
|
||||
</div>
|
||||
<div class="drawing-menu drawing-submenu">
|
||||
<div>Ask normally</div>
|
||||
<hr />
|
||||
<div class="drawing-menu-active">Explain</div>
|
||||
<div>Summarize</div>
|
||||
<div>Debug code</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field checkbox">
|
||||
<label><input type="checkbox" id="showTranslate"> Show Translate in advanced menu</label>
|
||||
<label><input type="checkbox" id="showTranslate"> Show Translate in the Advanced menu</label>
|
||||
</div>
|
||||
<div id="translation-settings" hidden>
|
||||
<div class="field">
|
||||
@@ -56,6 +139,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="openIn">Open ChatGPT 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>
|
||||
|
||||
<!-- Opens on load when one of these differs from its default (options.js),
|
||||
so a customised setting never looks lost. -->
|
||||
<details id="more-options" class="more-options">
|
||||
<summary>More options: <span class="summary-detail">prompt template, temporary chat, web search</span></summary>
|
||||
|
||||
<div class="field">
|
||||
<label for="promptTemplate">Prompt template</label>
|
||||
<input type="text" id="promptTemplate" spellcheck="false" maxlength="1000" />
|
||||
<p class="hint">
|
||||
<code>{query}</code> is replaced with your selected text, as in
|
||||
<code>Explain simply: {query}</code>. Used by Ask ChatGPT, Ask normally,
|
||||
the toolbar button, and the keyboard shortcut; other Advanced actions
|
||||
such as Summarize use their own prompts. Up to 1,000 characters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field checkbox">
|
||||
<label><input type="checkbox" id="temporaryChat" /> Request a temporary chat</label>
|
||||
<p class="hint">
|
||||
@@ -68,56 +176,49 @@
|
||||
<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" maxlength="1000" />
|
||||
<p class="hint"><code>{query}</code> is replaced with your selected text. Used by Ask normally, Simple mode, the toolbar button, and the keyboard shortcut. Other advanced actions use their own prompts. Up to 1,000 characters.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" id="restore">Restore defaults</button>
|
||||
<button type="button" id="restore" class="danger">Restore defaults</button>
|
||||
<span id="status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="note" aria-labelledby="search-settings-title">
|
||||
<h2 id="search-settings-title">Search keyword and default engine</h2>
|
||||
<p>
|
||||
Open Firefox’s <strong>Settings → Search</strong>, or paste
|
||||
<code>about:preferences#search</code> into the address bar.
|
||||
Under <strong>Search Shortcuts</strong>, edit ChatGPT’s keyword to change
|
||||
<code>gpt</code>. Under <strong>Default Search Engine</strong>, choose
|
||||
ChatGPT to use it for address-bar searches without a prefix. Choose your
|
||||
previous engine there to switch back.
|
||||
<a href="https://support.mozilla.org/en-US/kb/change-your-default-search-settings-firefox" target="_blank" rel="noopener noreferrer">Firefox search settings guide</a>.
|
||||
</p>
|
||||
<p>
|
||||
The settings above apply to the right-click menu, toolbar button, and keyboard shortcut;
|
||||
address-bar searches use Firefox’s search engine settings.
|
||||
Restore defaults only resets extension settings, including the prompt
|
||||
template; it does not change Firefox’s search engine or keyboard shortcut.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="note" aria-labelledby="shortcut-title">
|
||||
<h2 id="shortcut-title">Keyboard shortcut</h2>
|
||||
<details class="note">
|
||||
<summary>Use your own keyword or make ChatGPT your default search engine</summary>
|
||||
<ol>
|
||||
<li>
|
||||
Open Firefox’s <strong>Settings → Search</strong>, or paste
|
||||
<code>about:preferences#search</code> into the address bar.
|
||||
</li>
|
||||
<li>
|
||||
Under <strong>Search Shortcuts</strong>, edit ChatGPT’s keyword to add
|
||||
your own. <code>gpt</code> keeps working too.
|
||||
</li>
|
||||
<li>
|
||||
Under <strong>Default Search Engine</strong>, choose ChatGPT to use it
|
||||
without typing <code>gpt</code>. Choose your previous engine there to
|
||||
switch back.
|
||||
</li>
|
||||
</ol>
|
||||
<p>
|
||||
These are Firefox settings, so <strong>Restore defaults</strong> doesn’t
|
||||
change them.
|
||||
<a href="https://support.mozilla.org/en-US/kb/change-your-default-search-settings-firefox" target="_blank" rel="noopener noreferrer">Firefox search settings guide</a>
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details class="note">
|
||||
<summary>Change the keyboard shortcut</summary>
|
||||
<p id="shortcut-current" role="status" aria-live="polite">Checking keyboard shortcut…</p>
|
||||
<button type="button" id="change-shortcut" hidden>Change keyboard shortcut</button>
|
||||
<p class="hint">
|
||||
You can also open Add-ons, then the gear menu →
|
||||
<em>Manage Extension Shortcuts</em> to change or remove the shortcut.
|
||||
Or open Add-ons, click the gear menu, and choose
|
||||
<em>Manage Extension Shortcuts</em>. <strong>Restore defaults</strong>
|
||||
doesn’t change your shortcut.
|
||||
</p>
|
||||
</section>
|
||||
</details>
|
||||
|
||||
<nav class="project-links" aria-label="Project links">
|
||||
<a href="https://zavage-software.com/portfolio/search-with-ChatGPT" target="_blank" rel="noopener noreferrer">Project website</a>
|
||||
|
||||
+31
-4
@@ -1,13 +1,27 @@
|
||||
import { DEFAULT_SETTINGS } from "../lib/defaults.js";
|
||||
import { load, save } from "../lib/settings.js";
|
||||
import { initializeMenuSettings, MENU_SETTING_KEYS } from "./menu-settings.js";
|
||||
import { initializeShortcut } from "./shortcut.js";
|
||||
import { initializeShortcut, renderStaticShortcuts } from "./shortcut.js";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
const STATUS_CLEAR_MS = 1500;
|
||||
|
||||
const field = (id) => document.getElementById(id);
|
||||
|
||||
// The settings inside the collapsed "More options" section, read from the page
|
||||
// so moving a setting there needs no change here.
|
||||
const MORE_OPTION_KEYS = [...field("more-options").querySelectorAll("[id]")]
|
||||
.map(({ id }) => id)
|
||||
.filter((id) => Object.hasOwn(DEFAULT_SETTINGS, id));
|
||||
|
||||
// A customised setting should never look lost inside a closed section. This
|
||||
// only ever opens it, so it never undoes the user collapsing it.
|
||||
function revealCustomised(settings, keys = MORE_OPTION_KEYS) {
|
||||
if (keys.some((key) => settings[key] !== DEFAULT_SETTINGS[key])) {
|
||||
field("more-options").open = true;
|
||||
}
|
||||
}
|
||||
|
||||
let saveTimer = null;
|
||||
let statusTimer = null;
|
||||
|
||||
@@ -66,8 +80,14 @@ function scheduleSave() {
|
||||
}
|
||||
|
||||
// Module scripts are deferred, so the DOM is already parsed here.
|
||||
void initializeShortcut({ commands: browser.commands, document, window });
|
||||
render(await load());
|
||||
const [{ os }, initial] = await Promise.all([
|
||||
browser.runtime.getPlatformInfo().catch(() => ({})),
|
||||
load(),
|
||||
]);
|
||||
renderStaticShortcuts(document, os);
|
||||
void initializeShortcut({ commands: browser.commands, document, window, os });
|
||||
render(initial);
|
||||
revealCustomised(initial);
|
||||
|
||||
for (const id of Object.keys(DEFAULT_SETTINGS)) {
|
||||
if (MENU_SETTING_KEYS.includes(id)) continue;
|
||||
@@ -93,5 +113,12 @@ browser.storage.onChanged.addListener(async (changes, area) => {
|
||||
if (area !== "sync") {
|
||||
return;
|
||||
}
|
||||
render(await load());
|
||||
const settings = await load();
|
||||
render(settings);
|
||||
// Only keys whose value really changed, such as a sync from another device:
|
||||
// this page saves every key at once, so checking them all would re-open the
|
||||
// section whenever any other setting is edited.
|
||||
revealCustomised(settings, MORE_OPTION_KEYS.filter(
|
||||
(key) => key in changes && changes[key].newValue !== changes[key].oldValue,
|
||||
));
|
||||
});
|
||||
|
||||
+42
-8
@@ -1,18 +1,52 @@
|
||||
// Firefox stores Mac shortcuts with Command, MacCtrl, and Alt, and reads a
|
||||
// manifest's Ctrl as Command there (ShortcutUtils' chromeModifierKeyMap).
|
||||
const MAC_KEY_NAMES = { Ctrl: "Cmd", Command: "Cmd", MacCtrl: "Ctrl", Alt: "Option", Enter: "Return" };
|
||||
const OTHER_KEY_NAMES = { Command: "Ctrl", MacCtrl: "Ctrl" };
|
||||
|
||||
// The keys of a Firefox shortcut such as "Alt+Shift+G", named for the platform.
|
||||
export function shortcutKeys(shortcut, os) {
|
||||
const names = os === "mac" ? MAC_KEY_NAMES : OTHER_KEY_NAMES;
|
||||
return shortcut.split("+").map((key) => names[key] ?? key);
|
||||
}
|
||||
|
||||
// Writes a shortcut the way the page does: named keys in angle brackets and
|
||||
// single characters plain, "<Alt>+<Shift>+G".
|
||||
export function formatShortcut(shortcut, os) {
|
||||
return shortcutKeys(shortcut, os)
|
||||
.map((name) => (name.length > 1 ? `<${name}>` : name))
|
||||
.join("+");
|
||||
}
|
||||
|
||||
// The page's static hotkeys are written for Windows and Linux.
|
||||
export function renderStaticShortcuts(document, os) {
|
||||
for (const element of document.querySelectorAll("[data-shortcut]")) {
|
||||
element.textContent = formatShortcut(element.dataset.shortcut, os);
|
||||
}
|
||||
}
|
||||
|
||||
// Firefox owns shortcut preferences; never copy them into extension storage.
|
||||
export async function initializeShortcut({ commands, document, window }) {
|
||||
export async function initializeShortcut({ commands, document, window, os }) {
|
||||
const field = (id) => document.getElementById(id);
|
||||
let refreshId = 0;
|
||||
|
||||
// One key cap per key for the drawing, joined by plain "+" text.
|
||||
function keycaps(shortcut) {
|
||||
return shortcutKeys(shortcut, os).flatMap((name, index) => {
|
||||
const key = document.createElement("kbd");
|
||||
key.className = "drawing-key";
|
||||
key.textContent = name;
|
||||
return index ? ["+", key] : [key];
|
||||
});
|
||||
}
|
||||
|
||||
function render(shortcut) {
|
||||
field("shortcut-intro").textContent = shortcut
|
||||
? ` Or press ${shortcut} to use your selection. With nothing selected, the shortcut opens a blank chat.`
|
||||
: "";
|
||||
field("shortcut-hint").textContent = shortcut
|
||||
? `${shortcut} runs your default action even when the context menu is off.`
|
||||
: "The keyboard shortcut runs your default action independently of the context menu.";
|
||||
const keys = shortcut ? formatShortcut(shortcut, os) : "";
|
||||
field("shortcut-intro").hidden = !keys;
|
||||
field("shortcut-intro-key").textContent = keys;
|
||||
field("shortcut-keycaps").replaceChildren(...(keys ? keycaps(shortcut) : []));
|
||||
field("shortcut-current").textContent = shortcut === null
|
||||
? "Could not read the keyboard shortcut. Check Manage Extension Shortcuts in Add-ons."
|
||||
: shortcut ? `Current shortcut: ${shortcut}` : "No keyboard shortcut assigned.";
|
||||
: keys ? `Current shortcut: ${keys}` : "No keyboard shortcut assigned.";
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "search-with-chatgpt-powered-by-openai-extension",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "search-with-chatgpt-powered-by-openai-extension",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.0",
|
||||
"devDependencies": {
|
||||
"web-ext": "10.5.0"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "search-with-chatgpt-powered-by-openai-extension",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Development tooling for the Search with ChatGPT Firefox extension.",
|
||||
|
||||
@@ -9,7 +9,6 @@ test('context prioritizes selection over link and excludes unrelated targets', (
|
||||
assert.deepEqual(actions.getContext({selectionText:' ',linkUrl:'https://example.com'}), {kind:'link',source:'https://example.com'});
|
||||
assert.equal(actions.getContext({mediaType:'image',pageUrl:'https://example.com'}), null);
|
||||
assert.equal(actions.getContext({editable:true,pageUrl:'https://example.com'}), null);
|
||||
assert.equal(actions.getContext({selectionText:'secret',inputType:'password'}), null);
|
||||
assert.equal(actions.getContext({pageUrl:'https://example.com'}).kind, 'page');
|
||||
});
|
||||
test('selection actions and optional translation reflect settings', () => {
|
||||
|
||||
+28
-110
@@ -2,168 +2,86 @@ 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";
|
||||
import { applyTemplate, serializePrompt } 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",
|
||||
);
|
||||
assert.equal(serializePrompt(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("an empty prompt omits q entirely", () => {
|
||||
assert.equal(serializePrompt(DEFAULT_SETTINGS, ""), "https://chatgpt.com/");
|
||||
for (const template of ["{query}", "Explain: {query}", "Be concise:", ""]) {
|
||||
assert.equal(applyTemplate(template, ""), "", `template=${JSON.stringify(template)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy model values never add a model URL parameter", () => {
|
||||
assert.equal(buildChatGptUrl(withSettings({ model: "gpt-5.6-sol" }), "hi"),
|
||||
buildChatGptUrl(DEFAULT_SETTINGS, "hi"));
|
||||
assert.equal(serializePrompt(withSettings({ model: "gpt-5.6-sol" }), "hi"),
|
||||
serializePrompt(DEFAULT_SETTINGS, "hi"));
|
||||
});
|
||||
|
||||
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);
|
||||
assert.deepEqual(paramsOf(serializePrompt(DEFAULT_SETTINGS, "hi")), ["q"]);
|
||||
|
||||
const temp = new URL(buildChatGptUrl(withSettings({ temporaryChat: true }), "hi"));
|
||||
const temp = new URL(serializePrompt(withSettings({ temporaryChat: true }), "hi"));
|
||||
assert.equal(temp.searchParams.get("temporary-chat"), "true");
|
||||
|
||||
const search = new URL(buildChatGptUrl(withSettings({ webSearch: true }), "hi"));
|
||||
const search = new URL(serializePrompt(withSettings({ webSearch: true }), "hi"));
|
||||
assert.equal(search.searchParams.get("hints"), "search");
|
||||
});
|
||||
|
||||
test("all three URL-affecting settings combine, q first", () => {
|
||||
const url = buildChatGptUrl(
|
||||
withSettings({
|
||||
temporaryChat: true,
|
||||
webSearch: true,
|
||||
promptTemplate: "Explain: {query}",
|
||||
}),
|
||||
"entropy",
|
||||
);
|
||||
test("all URL-affecting settings combine, q first", () => {
|
||||
const settings = withSettings({ temporaryChat: true, webSearch: true, promptTemplate: "Explain: {query}" });
|
||||
const url = serializePrompt(settings, applyTemplate(settings.promptTemplate, "entropy"));
|
||||
assert.deepEqual(paramsOf(url), ["q", "temporary-chat", "hints"]);
|
||||
assert.equal(queryOf(url), "Explain: entropy");
|
||||
});
|
||||
|
||||
test("openIn never influences the URL", () => {
|
||||
const base = buildChatGptUrl(DEFAULT_SETTINGS, "hi");
|
||||
const base = serializePrompt(DEFAULT_SETTINGS, "hi");
|
||||
for (const openIn of ["new-tab", "background-tab", "new-window"]) {
|
||||
assert.equal(buildChatGptUrl(withSettings({ openIn }), "hi"), base);
|
||||
assert.equal(serializePrompt(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);
|
||||
const url = serializePrompt(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");
|
||||
assert.equal(applyTemplate("{query} / {query}", "x"), "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");
|
||||
assert.equal(applyTemplate("Be concise:", "why"), "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");
|
||||
for (const template of ["", " "]) {
|
||||
assert.equal(applyTemplate(template, "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({ 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("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);
|
||||
assert.equal(applyTemplate(null, "why"), "why");
|
||||
});
|
||||
|
||||
// String.prototype.replaceAll gives $&, $`, $', and $$ special meaning in the
|
||||
// REPLACEMENT string, and `query` (arbitrary page-selected text) is exactly
|
||||
// that argument. These selections are ordinary shell one-liners, not
|
||||
// adversarial input, and must round-trip byte-identically into `q`.
|
||||
test("a selection containing $& survives the default {query} template byte-identically", () => {
|
||||
const raw = "awk print $& here";
|
||||
const url = buildChatGptUrl(DEFAULT_SETTINGS, raw);
|
||||
assert.equal(queryOf(url), raw);
|
||||
});
|
||||
|
||||
test("a selection containing $& survives a prefix template byte-identically", () => {
|
||||
const raw = "awk print $& here";
|
||||
const url = buildChatGptUrl(withSettings({ promptTemplate: "Explain: {query}" }), raw);
|
||||
assert.equal(queryOf(url), `Explain: ${raw}`);
|
||||
});
|
||||
|
||||
test("a selection containing $` and $' survives the default {query} template byte-identically", () => {
|
||||
const raw = "shell $` and $' here";
|
||||
const url = buildChatGptUrl(DEFAULT_SETTINGS, raw);
|
||||
assert.equal(queryOf(url), raw);
|
||||
});
|
||||
|
||||
test("a selection containing $` and $' survives a prefix template byte-identically", () => {
|
||||
const raw = "shell $` and $' here";
|
||||
const url = buildChatGptUrl(withSettings({ promptTemplate: "Explain: {query}" }), raw);
|
||||
assert.equal(queryOf(url), `Explain: ${raw}`);
|
||||
});
|
||||
|
||||
test("a selection containing $$ survives the default {query} template byte-identically", () => {
|
||||
const raw = "PID is $$ in bash";
|
||||
const url = buildChatGptUrl(DEFAULT_SETTINGS, raw);
|
||||
assert.equal(queryOf(url), raw);
|
||||
});
|
||||
|
||||
test("a selection containing $$ survives a prefix template byte-identically", () => {
|
||||
const raw = "PID is $$ in bash";
|
||||
const url = buildChatGptUrl(withSettings({ promptTemplate: "Explain: {query}" }), raw);
|
||||
assert.equal(queryOf(url), `Explain: ${raw}`);
|
||||
for (const raw of ["awk print $& here", "shell $` and $' here", "PID is $$ in bash"]) {
|
||||
test(`a selection containing ${raw.match(/\$./g).join(" and ")} survives templating byte-identically`, () => {
|
||||
assert.equal(applyTemplate("{query}", raw), raw);
|
||||
assert.equal(applyTemplate("Explain: {query}", raw), `Explain: ${raw}`);
|
||||
assert.equal(queryOf(serializePrompt(DEFAULT_SETTINGS, applyTemplate("{query}", raw))), raw);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DEFAULT_SETTINGS } from '../lib/defaults.js';
|
||||
import { MAX_URL_CHARS } from '../lib/build-url.js';
|
||||
const { prepareAction } = await import('../lib/prepare-action.js').catch(()=>({}));
|
||||
const req = (overrides={}) => ({actionId:'ask',source:'Hello',settings:{...DEFAULT_SETTINGS},...overrides});
|
||||
test('ready actions preserve source and hints, translation asks only when needed', () => {
|
||||
@@ -23,6 +24,17 @@ test('URLs are validated and preserved whole, never shortened', () => {
|
||||
assert.equal(out.kind,'ready');
|
||||
assert.ok(new URL(out.url).searchParams.get('q').endsWith(source));
|
||||
});
|
||||
test('shortened selections end on a whole word and decode cleanly', () => {
|
||||
assert.equal(typeof prepareAction, 'function');
|
||||
const out=prepareAction(req({source:'lorem ipsum '.repeat(2000)}));
|
||||
assert.equal(out.kind,'shortened');
|
||||
const words=new URL(out.url).searchParams.get('q').replace('[Selected text shortened]','').trim().split(/\s+/);
|
||||
assert.ok(words.length>1);
|
||||
for (const word of words) assert.ok(word==='lorem'||word==='ipsum',`cut mid-word: ${word}`);
|
||||
const emoji=prepareAction(req({source:'😀 '.repeat(9000)}));
|
||||
assert.equal(emoji.kind,'shortened');
|
||||
assert.doesNotThrow(()=>decodeURIComponent(new URL(emoji.url).search.slice(1)));
|
||||
});
|
||||
test('long selections preserve instructions, repetition and Unicode with explicit consent', () => {
|
||||
assert.equal(typeof prepareAction, 'function');
|
||||
const settings={...DEFAULT_SETTINGS,promptTemplate:'START {query} AGAIN {query} END',temporaryChat:true,webSearch:true};
|
||||
@@ -38,3 +50,22 @@ test('long selections preserve instructions, repetition and Unicode with explici
|
||||
const translated=prepareAction(req({actionId:'translate',language:'Spanish',source:'x'.repeat(20000)}));
|
||||
assert.equal(translated.kind,'shortened');assert.match(new URL(translated.url).searchParams.get('q'), /Spanish/);
|
||||
});
|
||||
test('huge selections are capped before shortening, with identical output', () => {
|
||||
assert.equal(typeof prepareAction, 'function');
|
||||
// Select-all on a very large page; shortening this took over a second uncapped.
|
||||
const source='😀 word '.repeat(625000);
|
||||
const started=performance.now();
|
||||
const out=prepareAction(req({source}));
|
||||
const elapsed=performance.now()-started;
|
||||
assert.equal(out.kind,'shortened');
|
||||
assert.ok(elapsed<250,`took ${elapsed.toFixed(0)} ms`);
|
||||
const capped=Array.from(source).slice(0,MAX_URL_CHARS).join('');
|
||||
assert.deepEqual(prepareAction(req({source:capped})),out);
|
||||
});
|
||||
test('a selection too long to send whole always needs consent, even if its start would fit', () => {
|
||||
assert.equal(typeof prepareAction, 'function');
|
||||
// The first MAX_URL_CHARS code points trim down to "a"; sending just that would drop the rest unasked.
|
||||
const out=prepareAction(req({source:'a'+' '.repeat(MAX_URL_CHARS)+'b'}));
|
||||
assert.equal(out.kind,'shortened');
|
||||
assert.ok(new URL(out.url).searchParams.get('q').includes('[Selected text shortened]'));
|
||||
});
|
||||
|
||||
+70
-12
@@ -1,32 +1,87 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { initializeShortcut } from "../options/shortcut.js";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { formatShortcut, initializeShortcut, renderStaticShortcuts } from "../options/shortcut.js";
|
||||
|
||||
function setup(commands) {
|
||||
function setup(commands, os) {
|
||||
const elements = Object.fromEntries([
|
||||
"shortcut-intro", "shortcut-hint", "shortcut-current", "change-shortcut",
|
||||
].map((id) => [id, Object.assign(new EventTarget(), { textContent: "", hidden: true })]));
|
||||
"shortcut-intro", "shortcut-intro-key", "shortcut-keycaps", "shortcut-current", "change-shortcut",
|
||||
].map((id) => [id, Object.assign(new EventTarget(), {
|
||||
textContent: "",
|
||||
hidden: true,
|
||||
children: [],
|
||||
replaceChildren(...children) { this.children = children; },
|
||||
})]));
|
||||
const document = Object.assign(new EventTarget(), {
|
||||
visibilityState: "visible",
|
||||
getElementById: (id) => elements[id],
|
||||
createElement: (tagName) => ({ tagName, className: "", textContent: "" }),
|
||||
});
|
||||
const window = new EventTarget();
|
||||
const ready = initializeShortcut({ commands, document, window });
|
||||
const ready = initializeShortcut({ commands, document, window, os });
|
||||
return { elements, document, window, ready };
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
const command = (shortcut) => ({ name: "ask-chatgpt", description: "Ask ChatGPT", shortcut });
|
||||
// The drawing's key caps and the "+" text between them, in order.
|
||||
const drawn = (element) => element.children.map((child) => child.textContent ?? child);
|
||||
|
||||
test("shows the configured command rather than another command or the default", async () => {
|
||||
const { elements, ready } = setup({ getAll: async () => [
|
||||
{ name: "other", description: "Other", shortcut: "Alt+O" }, command("Ctrl+Shift+Y"),
|
||||
] });
|
||||
await ready;
|
||||
for (const id of ["shortcut-intro", "shortcut-hint", "shortcut-current"]) {
|
||||
assert.match(elements[id].textContent, /Ctrl\+Shift\+Y/);
|
||||
assert.doesNotMatch(elements[id].textContent, /Alt\+Shift\+G|Alt\+O/);
|
||||
assert.equal(elements["shortcut-intro"].hidden, false);
|
||||
assert.equal(elements["shortcut-intro-key"].textContent, "<Ctrl>+<Shift>+Y");
|
||||
assert.equal(elements["shortcut-current"].textContent, "Current shortcut: <Ctrl>+<Shift>+Y");
|
||||
assert.deepEqual(drawn(elements["shortcut-keycaps"]), ["Ctrl", "+", "Shift", "+", "Y"]);
|
||||
assert.ok(elements["shortcut-keycaps"].children
|
||||
.filter((child) => typeof child !== "string")
|
||||
.every(({ tagName, className }) => tagName === "kbd" && className === "drawing-key"));
|
||||
});
|
||||
|
||||
test("writes keys the way the page does, with Mac names on a Mac", () => {
|
||||
assert.equal(formatShortcut("Alt+Shift+G", "linux"), "<Alt>+<Shift>+G");
|
||||
assert.equal(formatShortcut("Ctrl+L", "win"), "<Ctrl>+L");
|
||||
assert.equal(formatShortcut("Enter", undefined), "<Enter>");
|
||||
assert.equal(formatShortcut("Ctrl+Shift+F5", "win"), "<Ctrl>+<Shift>+<F5>");
|
||||
// Firefox names punctuation and media keys rather than using the character.
|
||||
assert.equal(formatShortcut("Ctrl+Shift+Comma", "win"), "<Ctrl>+<Shift>+<Comma>");
|
||||
assert.equal(formatShortcut("MediaPlayPause", "linux"), "<MediaPlayPause>");
|
||||
assert.equal(formatShortcut("Alt+Shift+G", "mac"), "<Option>+<Shift>+G");
|
||||
// Firefox reads a manifest's Ctrl as Command on a Mac, and stores shortcuts
|
||||
// the user sets there with Command and MacCtrl.
|
||||
assert.equal(formatShortcut("Ctrl+L", "mac"), "<Cmd>+L");
|
||||
assert.equal(formatShortcut("Command+Shift+Y", "mac"), "<Cmd>+<Shift>+Y");
|
||||
assert.equal(formatShortcut("MacCtrl+Alt+Y", "mac"), "<Ctrl>+<Option>+Y");
|
||||
assert.equal(formatShortcut("Enter", "mac"), "<Return>");
|
||||
});
|
||||
|
||||
test("a Mac shows its own key names for the configured shortcut", async () => {
|
||||
const { elements, ready } = setup({ getAll: async () => [command("Alt+Shift+G")] }, "mac");
|
||||
await ready;
|
||||
assert.equal(elements["shortcut-intro-key"].textContent, "<Option>+<Shift>+G");
|
||||
assert.deepEqual(drawn(elements["shortcut-keycaps"]), ["Option", "+", "Shift", "+", "G"]);
|
||||
});
|
||||
|
||||
test("the page's written hotkeys match the formatter and are rewritten per platform", () => {
|
||||
const html = readFileSync(new URL("../options/options.html", import.meta.url), "utf8");
|
||||
const written = [...html.matchAll(/data-shortcut="([^"]+)">([^<]*)</g)];
|
||||
assert.ok(written.length >= 2);
|
||||
// Every hotkey must match the pattern above, or this test would skip it.
|
||||
assert.equal(written.length, html.split("data-shortcut=").length - 1);
|
||||
for (const [, shortcut, text] of written) {
|
||||
assert.equal(text.replaceAll("<", "<").replaceAll(">", ">"), formatShortcut(shortcut, "linux"));
|
||||
}
|
||||
|
||||
const elements = written.map(([, shortcut]) => ({ dataset: { shortcut }, textContent: "" }));
|
||||
renderStaticShortcuts({ querySelectorAll: () => elements }, "mac");
|
||||
assert.deepEqual(
|
||||
elements.map(({ textContent }) => textContent),
|
||||
written.map(([, shortcut]) => formatShortcut(shortcut, "mac")),
|
||||
);
|
||||
assert.ok(elements.some(({ textContent }) => textContent === "<Cmd>+L"));
|
||||
});
|
||||
|
||||
test("updates an open page when the shortcut is removed", async () => {
|
||||
@@ -41,7 +96,9 @@ test("updates an open page when the shortcut is removed", async () => {
|
||||
changed({ name: "ask-chatgpt", newShortcut: "", oldShortcut: "Alt+Shift+G" });
|
||||
await flush();
|
||||
assert.match(elements["shortcut-current"].textContent, /No keyboard shortcut assigned/);
|
||||
assert.doesNotMatch(elements["shortcut-intro"].textContent, /press|Alt\+Shift\+G/i);
|
||||
assert.equal(elements["shortcut-intro"].hidden, true);
|
||||
assert.equal(elements["shortcut-intro-key"].textContent, "");
|
||||
assert.deepEqual(elements["shortcut-keycaps"].children, []);
|
||||
});
|
||||
|
||||
test("refreshes on focus and visibility without the change event API", async () => {
|
||||
@@ -51,11 +108,11 @@ test("refreshes on focus and visibility without the change event API", async ()
|
||||
shortcut = "Ctrl+Shift+Y";
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
await flush();
|
||||
assert.match(elements["shortcut-current"].textContent, /Ctrl\+Shift\+Y/);
|
||||
assert.match(elements["shortcut-current"].textContent, /<Ctrl>\+<Shift>\+Y/);
|
||||
shortcut = "Alt+J";
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
await flush();
|
||||
assert.match(elements["shortcut-current"].textContent, /Alt\+J/);
|
||||
assert.match(elements["shortcut-current"].textContent, /<Alt>\+J/);
|
||||
});
|
||||
|
||||
test("missing commands and failed reads never claim the default is assigned", async () => {
|
||||
@@ -64,7 +121,8 @@ test("missing commands and failed reads never claim the default is assigned", as
|
||||
}]) {
|
||||
const { elements, ready } = setup(commands);
|
||||
await ready;
|
||||
assert.doesNotMatch(elements["shortcut-current"].textContent, /Alt\+Shift\+G/);
|
||||
assert.doesNotMatch(elements["shortcut-current"].textContent, /<Alt>\+<Shift>\+G|Alt\+Shift\+G/);
|
||||
assert.equal(elements["shortcut-intro"].hidden, true);
|
||||
assert.equal(elements["change-shortcut"].hidden, true);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user