Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54eaf95096 | ||
|
|
b59e46e7e6 | ||
|
|
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:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
# edited: removing the WIP: prefix starts the real run.
|
||||||
|
types: [opened, synchronize, reopened, edited]
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- develop
|
- develop
|
||||||
- main
|
- 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:
|
jobs:
|
||||||
validate:
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- 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.
|
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
|
## Testing Guidelines
|
||||||
|
|
||||||
Unit tests cover the pure logic in `lib/` via `node:test`; automated checks
|
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
|
- `gpt <query>` opens ChatGPT, the provider stays non-default, and Firefox
|
||||||
search settings can configure it.
|
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
|
in both light and dark themes and at a narrow window width, and values
|
||||||
persist across a restart.
|
persist across a restart. Expanding and collapsing More options and the
|
||||||
- **Context menu → Simple** shows the direct **Ask ChatGPT** item only for
|
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
|
selected text. **Advanced** shows the contextual submenu; selection takes
|
||||||
precedence over links. **Off** hides the menu. Switch modes without restarting
|
precedence over links. **Off** hides the menu. Switch modes without restarting
|
||||||
and confirm Alt+Shift+G still works in every mode. Restore defaults returns
|
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.
|
stale cross-origin frame silently seeding the prompt was the worse failure.
|
||||||
- Click the **Ask ChatGPT** toolbar button with no selection, a top-frame
|
- Click the **Ask ChatGPT** toolbar button with no selection, a top-frame
|
||||||
selection, and an iframe selection after a stale top-frame selection.
|
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
|
preferences, and consent before shortening long selections. Test both the
|
||||||
pinned toolbar button and its entry in Firefox’s extensions menu.
|
pinned toolbar button and its entry in Firefox’s extensions menu.
|
||||||
|
|
||||||
|
|||||||
+28
-11
@@ -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).
|
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,
|
- Add an Ask ChatGPT toolbar button: use selected text or open a blank chat,
|
||||||
honoring saved prompt and tab/window preferences. No new permissions.
|
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
|
- Add Off / Simple / Advanced context-menu modes with migration from the old
|
||||||
visibility preference. Advanced provides selection presets and link/page URL
|
visibility preference. Advanced provides selection presets and link/page URL
|
||||||
actions, with optional translation and a preferred language or language dialog.
|
actions, with optional translation and a preferred language or language dialog.
|
||||||
- Preserve dedicated action instructions when shortening selections; ask before
|
- Preserve dedicated action instructions when shortening selections; ask before
|
||||||
sending shortened text and reject URLs that cannot be sent intact.
|
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
|
- Add accessible action dialogs with cancellation and retry, remove the temporary
|
||||||
model/effort URL probe, and document URL sharing. No new permissions.
|
model/effort URL probe, and document URL sharing. No new permissions.
|
||||||
- Show up front, in the options panel and README, that ChatGPT may require
|
- Show up front, in the options panel and README, that ChatGPT may require
|
||||||
signing in and that an unsent prompt needs Enter or Send.
|
signing in and that an unsent prompt needs Enter or Send.
|
||||||
|
- Limit the prompt template to 1,000 characters, so an oversized template can
|
||||||
|
no longer stop every other setting from saving.
|
||||||
* Options instructions now show the configured keyboard shortcut, update when
|
- Show the configured keyboard shortcut in the options instructions, updating
|
||||||
it changes, and indicate when no shortcut is assigned. Added a button to
|
when it changes and indicating when none is assigned, with a button to open
|
||||||
open Firefox’s shortcut editor where supported.
|
Firefox’s shortcut editor where supported.
|
||||||
* Shortened the context menu label to “Ask ChatGPT”.
|
- Shorten the context menu label to “Ask ChatGPT”.
|
||||||
* Added quick usage instructions and guidance for changing the search keyword
|
- Add quick usage instructions and guidance for changing the search keyword
|
||||||
and default engine in Firefox to the options panel.
|
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)
|
## [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
|
* Permissions added: storage, menus, activeTab. None of them adds a line to
|
||||||
Firefox's install prompt.
|
Firefox's install prompt.
|
||||||
|
|
||||||
## [1.0.0]) (2024-06-09)
|
## [1.0.0] (2024-06-09)
|
||||||
|
|
||||||
First release of software.
|
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
|
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
|
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
|
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
|
The options panel's collapsed **Change the keyboard shortcut** section shows
|
||||||
it changes or you return to the panel. If you remove it, the panel 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
|
“No keyboard shortcut assigned”. Its **Change keyboard shortcut** button opens
|
||||||
Firefox’s shortcut editor when supported; otherwise follow the menu path above.
|
Firefox’s shortcut editor when supported; otherwise follow the menu path above.
|
||||||
**Restore defaults** does not change your keyboard shortcut.
|
**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.
|
`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.
|
Other advanced actions use their own dedicated prompts.
|
||||||
|
|
||||||
## Search keyword and default engine
|
## Search keyword and default engine
|
||||||
|
|
||||||
Open Firefox’s **Settings → Search**, or paste `about:preferences#search` into
|
Open Firefox’s **Settings → Search**, or paste `about:preferences#search` into
|
||||||
the address bar. Under **Search Shortcuts**, edit ChatGPT’s keyword to replace
|
the address bar. Under **Search Shortcuts**, edit ChatGPT’s keyword to add
|
||||||
`gpt` with your preferred prefix. Under **Default Search Engine**, choose
|
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
|
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)
|
back. See [Firefox’s search settings guide](https://support.mozilla.org/en-US/kb/change-your-default-search-settings-firefox)
|
||||||
for illustrated instructions.
|
for illustrated instructions.
|
||||||
@@ -60,21 +61,25 @@ not change them.
|
|||||||
Open `about:addons`, select this extension, and choose the **Options** tab
|
Open `about:addons`, select this extension, and choose the **Options** tab
|
||||||
(**Preferences** on macOS and Linux). Available settings:
|
(**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
|
**Advanced** offers contextual presets; **Off** hides the menu. The toolbar
|
||||||
button and keyboard shortcut work in every mode. Existing enabled/disabled preferences migrate
|
button and keyboard shortcut work in every mode. Existing enabled/disabled preferences migrate
|
||||||
to Simple/Off. Changes apply without restarting Firefox.
|
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**
|
**Ask each time** for a language dialog, or enter a **Preferred language**
|
||||||
to translate directly. An empty or invalid preferred language falls back to
|
to translate directly. An empty or invalid preferred language falls back to
|
||||||
asking. Enter continues; Escape or Cancel opens nothing.
|
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.
|
- **Request a temporary chat** — asks ChatGPT not to save the conversation.
|
||||||
ChatGPT may not honour this; check its own indicator before relying on it.
|
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
|
- **Ask ChatGPT to use web search** — a hint to ChatGPT's interface, not a
|
||||||
guarantee.
|
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
|
Restore defaults resets extension preferences to Simple mode with translation
|
||||||
hidden; it does not change Firefox's search engine or shortcut settings.
|
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
|
(`Sec-Fetch-Site: none` being the most-trusted value) **does not hold in
|
||||||
practice**.
|
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
|
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
|
*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
|
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 |
|
| 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` 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. |
|
| `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`). |
|
| 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. |
|
| `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
|
This is also why the context menu and shortcut remain safe *as long as they only
|
||||||
prefill*.
|
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
|
**`webNavigation.transitionType` is not a security boundary.** The signal looks
|
||||||
right — a `gpt` search emits `transitionType: "generated"` with
|
right — a `gpt` search emits `transitionType: "generated"` with
|
||||||
`transitionQualifiers: ["from_address_bar"]`, and content cannot forge those
|
`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.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Add a privacy policy
|
||||||
|
|
||||||
|
Status: design approved on 2026-10-21. The policy isn't written yet, and it
|
||||||
|
will be published after the release that fixes #16. Researched, but not legal
|
||||||
|
advice.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
- The AMO listing has no privacy policy. On 2026-10-21 the API reported
|
||||||
|
`has_privacy_policy: false`.
|
||||||
|
- The README has a short "Privacy Policy" section. The website's page for the
|
||||||
|
extension says only "Extension collects zero user information."
|
||||||
|
- The owner wants a policy that says the extension collects nothing and that
|
||||||
|
data will never be sold: "basically the most privacy respecting as possible".
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Say in plain English what happens to data, with every claim checkable
|
||||||
|
against the code.
|
||||||
|
2. Make clear that Zavage Software receives nothing and will never sell data.
|
||||||
|
3. Make clear that ChatGPT is a separate service under OpenAI's own privacy
|
||||||
|
policy.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **Plain English, organised by where data goes.** A one-line "we collect
|
||||||
|
nothing" would hide the hand-off to ChatGPT. A formal GDPR-style template is
|
||||||
|
the legalese Mozilla advises against (Extension Workshop, "Create an appealing
|
||||||
|
listing").
|
||||||
|
- **The extension only.** One line says the policy doesn't cover the website or
|
||||||
|
email, although the options page links to the website and its contact form.
|
||||||
|
The policy ends with mat@zavage.net for questions. The owner chose this over
|
||||||
|
also covering messages or the whole website.
|
||||||
|
- **The current version only.** The policy describes the current version and
|
||||||
|
says so.
|
||||||
|
- **#16 is fixed first.** Up to 1.3, a chat started in a private window can open
|
||||||
|
in a normal window. The owner chose to fix that before publishing rather than
|
||||||
|
disclose it, so the policy doesn't mention it.
|
||||||
|
- **Promises.** Nothing is ever sold. Any future collection would be opt-in, and
|
||||||
|
the policy is updated before any version that changes how data is handled.
|
||||||
|
- **No "can't".** Nothing in the code sends data to Zavage Software, but that is
|
||||||
|
a fact about the current code. The policy says so and points to the public
|
||||||
|
source instead of claiming the extension is unable to send data.
|
||||||
|
- **No claim that Mozilla agreed.** #17 asks whether the manifest's "no data
|
||||||
|
collection" declaration covers the hand-off to ChatGPT, and it is still open.
|
||||||
|
The policy describes what happens and doesn't say Mozilla confirmed anything.
|
||||||
|
- **`PRIVACY.md` is the only copy.** It is pasted into AMO unchanged. The README
|
||||||
|
section shrinks to a summary and a link. AGENTS.md names `PRIVACY.md` as the
|
||||||
|
file every data-handling change must update.
|
||||||
|
- **Not packaged.** `web-ext-config.mjs` ignores `PRIVACY.md`, so AMO and the
|
||||||
|
repository hold it.
|
||||||
|
- **Its own pull request,** separate from the license change in #33.
|
||||||
|
- **Review.** Three readers: one checks each claim against the code, one checks
|
||||||
|
Mozilla's policies and privacy law, and one reads it cold for plain English.
|
||||||
|
|
||||||
|
## What each claim rests on
|
||||||
|
|
||||||
|
| Claim | Evidence |
|
||||||
|
| --- | --- |
|
||||||
|
| Zavage Software receives nothing | The code has no `fetch`, `XMLHttpRequest`, `sendBeacon`, `WebSocket` or `EventSource`, and the manifest has no `favicon_url`. |
|
||||||
|
| Settings stay in Firefox and sync only through Firefox Sync | `lib/settings.js:37-60` uses `storage.sync`, the only storage API in the code. |
|
||||||
|
| Three permissions, and no host access | `manifest.json:25`: `storage`, `menus` and `activeTab`. |
|
||||||
|
| The manifest declares no data collection | `manifest.json:18` |
|
||||||
|
| The selection is read only when the toolbar button or shortcut is used | `background/background.js:41-50`: `activeTab` and `tabs.executeScript`. |
|
||||||
|
| The right-click menu gets the selection, link or page address from Firefox at the click | `lib/actions.js:16-21` |
|
||||||
|
| ChatGPT receives the text in the address of the page the extension opens | `lib/build-url.js:11` and `:45-47`; `background/open-chat.js:1-9` |
|
||||||
|
| Temporary chat and web search are only hints in that address | `lib/build-url.js:32-37` |
|
||||||
|
| Dialogs hold pending text in memory only | `background/dialogs.js:1` and `:20` |
|
||||||
|
| The `gpt` search sends nothing until Enter | `manifest.json:58-61` has no `suggest_url`. |
|
||||||
|
| No remote code | Manifest V2's default content security policy runs only the extension's own scripts. |
|
||||||
|
| Links to other sites open only when clicked | `options/options.html` |
|
||||||
|
|
||||||
|
## AMO's privacy policy field
|
||||||
|
|
||||||
|
- Unlike the license field, it renders a restricted Markdown. The field is
|
||||||
|
`privacy_policy = PurifiedMarkdownField(...)` in addons-server's
|
||||||
|
`addons/models.py`, as Mozilla's blog post of 3 March 2025 describes.
|
||||||
|
- The allowed tags are `a`, `abbr`, `acronym`, `b`, `blockquote`, `code`, `em`,
|
||||||
|
`i`, `li`, `ol`, `strong` and `ul`. Headings are dropped, so section labels
|
||||||
|
are bold lines.
|
||||||
|
- Tags are stripped before the Markdown is rendered, so anything in angle
|
||||||
|
brackets disappears.
|
||||||
|
- Bare URLs become links through Mozilla's redirector. The email address may
|
||||||
|
link to AMO's home page, as it does in the license.
|
||||||
|
|
||||||
|
## Publishing on AMO (untested, 2026-10-21)
|
||||||
|
|
||||||
|
Publish after the release that fixes #16.
|
||||||
|
|
||||||
|
1. In the Developer Hub, open the add-on's **Manage Authors & License** page,
|
||||||
|
where the license is set too.
|
||||||
|
2. Tick "This add-on has a Privacy Policy" and paste `PRIVACY.md` unchanged.
|
||||||
|
3. Save, then check:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -s https://addons.mozilla.org/api/v5/addons/addon/search-with-chatgpt/ | jq .has_privacy_policy
|
||||||
|
curl -s https://addons.mozilla.org/api/v5/addons/addon/search-with-chatgpt/eula_policy/ | jq .privacy_policy
|
||||||
|
```
|
||||||
|
|
||||||
|
The policy then appears at
|
||||||
|
https://addons.mozilla.org/firefox/addon/search-with-chatgpt/privacy/.
|
||||||
|
|
||||||
|
## Outside the policy
|
||||||
|
|
||||||
|
- Point the website's "Extension collects zero user information" at the new
|
||||||
|
policy.
|
||||||
|
- #17 is still open.
|
||||||
|
- The Chrome repository has no privacy policy.
|
||||||
@@ -13,7 +13,6 @@ const TITLES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function getContext(info) {
|
export function getContext(info) {
|
||||||
if (info.inputType === 'password') return null;
|
|
||||||
if (typeof info.selectionText === 'string' && info.selectionText.trim()) {
|
if (typeof info.selectionText === 'string' && info.selectionText.trim()) {
|
||||||
return { kind: 'selection', source: 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;
|
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) {
|
export function applyTemplate(template, query) {
|
||||||
if (query === "") {
|
if (query === "") {
|
||||||
return "";
|
return "";
|
||||||
@@ -42,65 +39,9 @@ function addOptionalParams(url, settings) {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
function lastWhitespaceIndex(text) {
|
// Encode a complete prompt without silently shortening it; prepareAction owns
|
||||||
for (let i = text.length - 1; i >= 0; i -= 1) {
|
// the length budget and asks before shortening. q goes first, matching the
|
||||||
if (/\s/.test(text[i])) {
|
// most-cited working examples.
|
||||||
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.
|
|
||||||
export function serializePrompt(settings, prompt) {
|
export function serializePrompt(settings, prompt) {
|
||||||
const url = new URL(BASE);
|
const url = new URL(BASE);
|
||||||
if (prompt) url.searchParams.set("q", prompt);
|
if (prompt) url.searchParams.set("q", prompt);
|
||||||
|
|||||||
+14
-3
@@ -25,12 +25,23 @@ export function prepareAction(request) {
|
|||||||
}
|
}
|
||||||
const resolved = { ...request, language };
|
const resolved = { ...request, language };
|
||||||
const serialize = text => serializePrompt(request.settings, renderPrompt(resolved, text));
|
const serialize = text => serializePrompt(request.settings, renderPrompt(resolved, text));
|
||||||
const url = serialize(source);
|
// Each code point adds at least one URL character, so none past the first
|
||||||
if (url.length <= MAX_URL_CHARS) return { kind: 'ready', url };
|
// 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.');
|
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]';
|
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.');
|
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;
|
let low = 0, high = points.length;
|
||||||
while (low < high) {
|
while (low < high) {
|
||||||
const mid = Math.ceil((low + high) / 2);
|
const mid = Math.ceil((low + high) / 2);
|
||||||
|
|||||||
+2
-3
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "Search with ChatGPT powered by OpenAI!",
|
"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,
|
"manifest_version": 2,
|
||||||
"author": "Mathew Guest <mat@zavage.net>",
|
"author": "Mathew Guest <mat@zavage.net>",
|
||||||
"homepage_url": "https://zavage-software.com/portfolio/search-with-ChatGPT",
|
"homepage_url": "https://zavage-software.com/portfolio/search-with-ChatGPT",
|
||||||
"version": "1.1",
|
"version": "1.3",
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/powered-by-openai-icon-16x16.png",
|
"16": "icons/powered-by-openai-icon-16x16.png",
|
||||||
"32": "icons/powered-by-openai-icon-32x32.png",
|
"32": "icons/powered-by-openai-icon-32x32.png",
|
||||||
@@ -59,7 +59,6 @@
|
|||||||
"name": "ChatGPT",
|
"name": "ChatGPT",
|
||||||
"search_url": "https://chatgpt.com?q={searchTerms}",
|
"search_url": "https://chatgpt.com?q={searchTerms}",
|
||||||
"keyword": "gpt",
|
"keyword": "gpt",
|
||||||
"favicon_url": "https://zavage-software.com/static/ChatGPT-favicon.png",
|
|
||||||
"is_default": false,
|
"is_default": false,
|
||||||
"encoding": "UTF-8"
|
"encoding": "UTF-8"
|
||||||
}
|
}
|
||||||
|
|||||||
+351
-17
@@ -5,6 +5,11 @@
|
|||||||
--border: #8f8f9d;
|
--border: #8f8f9d;
|
||||||
--field-bg: #ffffff;
|
--field-bg: #ffffff;
|
||||||
--accent: #0060df;
|
--accent: #0060df;
|
||||||
|
/* Backdrop for the drawings and inline code. */
|
||||||
|
--surface: #f0f0f4;
|
||||||
|
--tooltip-bg: #15141a;
|
||||||
|
--tooltip-fg: #fbfbfe;
|
||||||
|
--danger: #d70022;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@@ -15,6 +20,10 @@
|
|||||||
--border: #8f8f9d;
|
--border: #8f8f9d;
|
||||||
--field-bg: #2b2a33;
|
--field-bg: #2b2a33;
|
||||||
--accent: #0df;
|
--accent: #0df;
|
||||||
|
--surface: #1c1b22;
|
||||||
|
--tooltip-bg: #52525e;
|
||||||
|
--tooltip-fg: #fbfbfe;
|
||||||
|
--danger: #ff848b;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +39,270 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.intro {
|
.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 {
|
.field {
|
||||||
@@ -65,7 +337,8 @@ select {
|
|||||||
|
|
||||||
input[type="text"]:focus-visible,
|
input[type="text"]:focus-visible,
|
||||||
select:focus-visible,
|
select:focus-visible,
|
||||||
button:focus-visible {
|
button:focus-visible,
|
||||||
|
summary:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
@@ -76,18 +349,30 @@ button:focus-visible {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag {
|
summary {
|
||||||
font-weight: 400;
|
cursor: pointer;
|
||||||
font-size: 0.75rem;
|
font-weight: 600;
|
||||||
color: var(--fg-dim);
|
color: var(--fg);
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 0 0.3rem;
|
|
||||||
vertical-align: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -104,35 +389,69 @@ button {
|
|||||||
cursor: pointer;
|
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 {
|
#status {
|
||||||
color: var(--fg-dim);
|
color: var(--fg-dim);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note {
|
.note {
|
||||||
margin-top: 1.75rem;
|
margin-top: 1.25rem;
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
color: var(--fg-dim);
|
color: var(--fg-dim);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
code,
|
.note summary {
|
||||||
kbd {
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 0 0.25em;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--surface);
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.9em;
|
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;
|
margin: 0 0 0.5rem;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note p {
|
.note p,
|
||||||
|
.note ol {
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note ol {
|
||||||
|
padding-inline-start: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note li {
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
.note code {
|
.note code {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
@@ -140,8 +459,9 @@ kbd {
|
|||||||
.project-links {
|
.project-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
gap: 0.5rem 0.65rem;
|
gap: 0.5rem 0.65rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-link {
|
.project-link {
|
||||||
@@ -167,9 +487,23 @@ a:focus-visible {
|
|||||||
/* The inline about:addons surface can be very narrow. */
|
/* The inline about:addons surface can be very narrow. */
|
||||||
@media (max-width: 26rem) {
|
@media (max-width: 26rem) {
|
||||||
.actions {
|
.actions {
|
||||||
align-items: flex-start;
|
align-items: flex-end;
|
||||||
flex-direction: column;
|
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; }
|
[hidden] { display: none !important; }
|
||||||
|
|||||||
+159
-58
@@ -7,37 +7,120 @@
|
|||||||
<link rel="stylesheet" href="options.css" />
|
<link rel="stylesheet" href="options.css" />
|
||||||
|
|
||||||
<p class="intro">
|
<p class="intro">
|
||||||
This extension adds <a href="https://chatgpt.com/" target="_blank" rel="noopener noreferrer">ChatGPT</a> as a Firefox search engine. Type
|
This extension adds <a href="https://chatgpt.com/" target="_blank" rel="noopener noreferrer">ChatGPT</a> as a Firefox search engine.
|
||||||
<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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="intro">
|
<section class="usage" aria-labelledby="usage-title">
|
||||||
Click the <strong>Ask ChatGPT</strong> toolbar button to ask about selected
|
<h2 id="usage-title">Usage instructions</h2>
|
||||||
text, or open a blank chat when nothing is selected. The button works in
|
|
||||||
every context-menu mode and follows your settings below.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="intro">
|
<!-- Hotkeys are written <Ctrl>+L: named keys in angle brackets, letters plain.
|
||||||
ChatGPT may require you to sign in. If your prompt is not sent automatically,
|
shortcut.js rewrites each [data-shortcut] for the user's platform. The
|
||||||
press <strong>Enter</strong> or click <strong>Send</strong> in ChatGPT.
|
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>
|
</p>
|
||||||
|
|
||||||
<form id="settings" autocomplete="off">
|
<form id="settings" autocomplete="off">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="contextMenuMode">Context menu</label>
|
<label for="contextMenuMode">Right-click menu</label>
|
||||||
<select id="contextMenuMode">
|
<select id="contextMenuMode">
|
||||||
<option value="off">Off</option>
|
<option value="off">Off</option>
|
||||||
<option value="simple">Simple</option>
|
<option value="simple">Simple</option>
|
||||||
<option value="advanced">Advanced</option>
|
<option value="advanced">Advanced</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="hint">Simple asks about selected text. Advanced adds Explain, Summarize, Debug code, and link/page actions.</p>
|
<p class="hint">
|
||||||
<p class="hint" id="shortcut-hint">The keyboard shortcut always runs your default action.</p>
|
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>
|
||||||
|
|
||||||
<div id="advanced-settings" class="dependent-settings" hidden>
|
<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">
|
<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>
|
||||||
<div id="translation-settings" hidden>
|
<div id="translation-settings" hidden>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -56,21 +139,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label for="openIn">Open in</label>
|
<label for="openIn">Open ChatGPT in</label>
|
||||||
<select id="openIn">
|
<select id="openIn">
|
||||||
<option value="new-tab">A new tab</option>
|
<option value="new-tab">A new tab</option>
|
||||||
<option value="background-tab">A new background tab</option>
|
<option value="background-tab">A new background tab</option>
|
||||||
@@ -78,46 +148,77 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<!-- Opens on load when one of these differs from its default (options.js),
|
||||||
<label for="promptTemplate">Prompt template</label>
|
so a customised setting never looks lost. -->
|
||||||
<input type="text" id="promptTemplate" spellcheck="false" maxlength="1000" />
|
<details id="more-options" class="more-options">
|
||||||
<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>
|
<summary>More options: <span class="summary-detail">prompt template, temporary chat, web search</span></summary>
|
||||||
</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, 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">
|
||||||
|
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>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div class="actions">
|
<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>
|
<span id="status" role="status" aria-live="polite"></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
||||||
|
|
||||||
<section class="note" aria-labelledby="shortcut-title">
|
<details class="note">
|
||||||
<h2 id="shortcut-title">Keyboard shortcut</h2>
|
<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>
|
<p id="shortcut-current" role="status" aria-live="polite">Checking keyboard shortcut…</p>
|
||||||
<button type="button" id="change-shortcut" hidden>Change keyboard shortcut</button>
|
<button type="button" id="change-shortcut" hidden>Change keyboard shortcut</button>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
You can also open Add-ons, then the gear menu →
|
Or open Add-ons, click the gear menu, and choose
|
||||||
<em>Manage Extension Shortcuts</em> to change or remove the shortcut.
|
<em>Manage Extension Shortcuts</em>. <strong>Restore defaults</strong>
|
||||||
|
doesn’t change your shortcut.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</details>
|
||||||
|
|
||||||
<nav class="project-links" aria-label="Project links">
|
<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>
|
<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 { DEFAULT_SETTINGS } from "../lib/defaults.js";
|
||||||
import { load, save } from "../lib/settings.js";
|
import { load, save } from "../lib/settings.js";
|
||||||
import { initializeMenuSettings, MENU_SETTING_KEYS } from "./menu-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 SAVE_DEBOUNCE_MS = 400;
|
||||||
const STATUS_CLEAR_MS = 1500;
|
const STATUS_CLEAR_MS = 1500;
|
||||||
|
|
||||||
const field = (id) => document.getElementById(id);
|
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 saveTimer = null;
|
||||||
let statusTimer = null;
|
let statusTimer = null;
|
||||||
|
|
||||||
@@ -66,8 +80,14 @@ function scheduleSave() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Module scripts are deferred, so the DOM is already parsed here.
|
// Module scripts are deferred, so the DOM is already parsed here.
|
||||||
void initializeShortcut({ commands: browser.commands, document, window });
|
const [{ os }, initial] = await Promise.all([
|
||||||
render(await load());
|
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)) {
|
for (const id of Object.keys(DEFAULT_SETTINGS)) {
|
||||||
if (MENU_SETTING_KEYS.includes(id)) continue;
|
if (MENU_SETTING_KEYS.includes(id)) continue;
|
||||||
@@ -93,5 +113,12 @@ browser.storage.onChanged.addListener(async (changes, area) => {
|
|||||||
if (area !== "sync") {
|
if (area !== "sync") {
|
||||||
return;
|
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.
|
// 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);
|
const field = (id) => document.getElementById(id);
|
||||||
let refreshId = 0;
|
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) {
|
function render(shortcut) {
|
||||||
field("shortcut-intro").textContent = shortcut
|
const keys = shortcut ? formatShortcut(shortcut, os) : "";
|
||||||
? ` Or press ${shortcut} to use your selection. With nothing selected, the shortcut opens a blank chat.`
|
field("shortcut-intro").hidden = !keys;
|
||||||
: "";
|
field("shortcut-intro-key").textContent = keys;
|
||||||
field("shortcut-hint").textContent = shortcut
|
field("shortcut-keycaps").replaceChildren(...(keys ? keycaps(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.";
|
|
||||||
field("shortcut-current").textContent = shortcut === null
|
field("shortcut-current").textContent = shortcut === null
|
||||||
? "Could not read the keyboard shortcut. Check Manage Extension Shortcuts in Add-ons."
|
? "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() {
|
async function refresh() {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "search-with-chatgpt-powered-by-openai-extension",
|
"name": "search-with-chatgpt-powered-by-openai-extension",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "search-with-chatgpt-powered-by-openai-extension",
|
"name": "search-with-chatgpt-powered-by-openai-extension",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"web-ext": "10.5.0"
|
"web-ext": "10.5.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "search-with-chatgpt-powered-by-openai-extension",
|
"name": "search-with-chatgpt-powered-by-openai-extension",
|
||||||
"version": "1.1.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Development tooling for the Search with ChatGPT Firefox extension.",
|
"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.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({mediaType:'image',pageUrl:'https://example.com'}), null);
|
||||||
assert.equal(actions.getContext({editable:true,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');
|
assert.equal(actions.getContext({pageUrl:'https://example.com'}).kind, 'page');
|
||||||
});
|
});
|
||||||
test('selection actions and optional translation reflect settings', () => {
|
test('selection actions and optional translation reflect settings', () => {
|
||||||
|
|||||||
+29
-111
@@ -2,168 +2,86 @@ import test from "node:test";
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { DEFAULT_SETTINGS } from "../lib/defaults.js";
|
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 withSettings = (overrides) => ({ ...DEFAULT_SETTINGS, ...overrides });
|
||||||
const queryOf = (url) => new URL(url).searchParams.get("q");
|
const queryOf = (url) => new URL(url).searchParams.get("q");
|
||||||
const paramsOf = (url) => [...new URL(url).searchParams.keys()];
|
const paramsOf = (url) => [...new URL(url).searchParams.keys()];
|
||||||
|
|
||||||
test("defaults produce only the q parameter on the root path", () => {
|
test("defaults produce only the q parameter on the root path", () => {
|
||||||
assert.equal(
|
assert.equal(serializePrompt(DEFAULT_SETTINGS, "hello"), "https://chatgpt.com/?q=hello");
|
||||||
buildChatGptUrl(DEFAULT_SETTINGS, "hello"),
|
|
||||||
"https://chatgpt.com/?q=hello",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an empty or whitespace-only query omits q entirely", () => {
|
test("an empty prompt omits q entirely", () => {
|
||||||
for (const raw of ["", " ", "\n\t", null, undefined]) {
|
assert.equal(serializePrompt(DEFAULT_SETTINGS, ""), "https://chatgpt.com/");
|
||||||
const url = buildChatGptUrl(DEFAULT_SETTINGS, raw);
|
for (const template of ["{query}", "Explain: {query}", "Be concise:", ""]) {
|
||||||
assert.equal(paramsOf(url).includes("q"), false, `raw=${JSON.stringify(raw)}`);
|
assert.equal(applyTemplate(template, ""), "", `template=${JSON.stringify(template)}`);
|
||||||
assert.equal(url, "https://chatgpt.com/");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("legacy model values never add a model URL parameter", () => {
|
test("legacy model values never add a model URL parameter", () => {
|
||||||
assert.equal(buildChatGptUrl(withSettings({ model: "gpt-5.6-sol" }), "hi"),
|
assert.equal(serializePrompt(withSettings({ model: "gpt-5.6-sol" }), "hi"),
|
||||||
buildChatGptUrl(DEFAULT_SETTINGS, "hi"));
|
serializePrompt(DEFAULT_SETTINGS, "hi"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("temporaryChat and webSearch each appear only when enabled", () => {
|
test("temporaryChat and webSearch each appear only when enabled", () => {
|
||||||
assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("temporary-chat"), false);
|
assert.deepEqual(paramsOf(serializePrompt(DEFAULT_SETTINGS, "hi")), ["q"]);
|
||||||
assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("hints"), false);
|
|
||||||
|
|
||||||
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");
|
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");
|
assert.equal(search.searchParams.get("hints"), "search");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("all three URL-affecting settings combine, q first", () => {
|
test("all URL-affecting settings combine, q first", () => {
|
||||||
const url = buildChatGptUrl(
|
const settings = withSettings({ temporaryChat: true, webSearch: true, promptTemplate: "Explain: {query}" });
|
||||||
withSettings({
|
const url = serializePrompt(settings, applyTemplate(settings.promptTemplate, "entropy"));
|
||||||
temporaryChat: true,
|
|
||||||
webSearch: true,
|
|
||||||
promptTemplate: "Explain: {query}",
|
|
||||||
}),
|
|
||||||
"entropy",
|
|
||||||
);
|
|
||||||
assert.deepEqual(paramsOf(url), ["q", "temporary-chat", "hints"]);
|
assert.deepEqual(paramsOf(url), ["q", "temporary-chat", "hints"]);
|
||||||
assert.equal(queryOf(url), "Explain: entropy");
|
assert.equal(queryOf(url), "Explain: entropy");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("openIn never influences the URL", () => {
|
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"]) {
|
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", () => {
|
test("special characters survive encoding round-trip", () => {
|
||||||
const raw = 'a & b # c ? d = e + f "g" <h> 100% 😀 日本語\nnewline';
|
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(queryOf(url), raw);
|
||||||
assert.equal(url.includes(" "), false);
|
assert.equal(url.includes(" "), false);
|
||||||
assert.equal(url.includes("#"), false);
|
assert.equal(url.includes("#"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the template replaces every {query} occurrence", () => {
|
test("the template replaces every {query} occurrence", () => {
|
||||||
const url = buildChatGptUrl(withSettings({ promptTemplate: "{query} / {query}" }), "x");
|
assert.equal(applyTemplate("{query} / {query}", "x"), "x / x");
|
||||||
assert.equal(queryOf(url), "x / x");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a template without {query} is treated as a prefix, never dropping the query", () => {
|
test("a template without {query} is treated as a prefix, never dropping the query", () => {
|
||||||
const url = buildChatGptUrl(withSettings({ promptTemplate: "Be concise:" }), "why");
|
assert.equal(applyTemplate("Be concise:", "why"), "Be concise: why");
|
||||||
assert.equal(queryOf(url), "Be concise: why");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an empty or whitespace-only template yields the bare query", () => {
|
test("an empty or whitespace-only template yields the bare query", () => {
|
||||||
for (const promptTemplate of ["", " "]) {
|
for (const template of ["", " "]) {
|
||||||
assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate }), "why")), "why");
|
assert.equal(applyTemplate(template, "why"), "why");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a non-string template degrades to the bare query", () => {
|
test("a non-string template degrades to the bare query", () => {
|
||||||
assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate: null }), "why")), "why");
|
assert.equal(applyTemplate(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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// String.prototype.replaceAll gives $&, $`, $', and $$ special meaning in the
|
// String.prototype.replaceAll gives $&, $`, $', and $$ special meaning in the
|
||||||
// REPLACEMENT string, and `query` (arbitrary page-selected text) is exactly
|
// REPLACEMENT string, and `query` (arbitrary page-selected text) is exactly
|
||||||
// that argument. These selections are ordinary shell one-liners, not
|
// that argument. These selections are ordinary shell one-liners, not
|
||||||
// adversarial input, and must round-trip byte-identically into `q`.
|
// adversarial input, and must round-trip byte-identically into `q`.
|
||||||
test("a selection containing $& survives the default {query} template byte-identically", () => {
|
for (const raw of ["awk print $& here", "shell $` and $' here", "PID is $$ in bash"]) {
|
||||||
const raw = "awk print $& here";
|
test(`a selection containing ${raw.match(/\$./g).join(" and ")} survives templating byte-identically`, () => {
|
||||||
const url = buildChatGptUrl(DEFAULT_SETTINGS, raw);
|
assert.equal(applyTemplate("{query}", raw), raw);
|
||||||
assert.equal(queryOf(url), raw);
|
assert.equal(applyTemplate("Explain: {query}", raw), `Explain: ${raw}`);
|
||||||
});
|
assert.equal(queryOf(serializePrompt(DEFAULT_SETTINGS, applyTemplate("{query}", raw))), 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}`);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { DEFAULT_SETTINGS } from '../lib/defaults.js';
|
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 { prepareAction } = await import('../lib/prepare-action.js').catch(()=>({}));
|
||||||
const req = (overrides={}) => ({actionId:'ask',source:'Hello',settings:{...DEFAULT_SETTINGS},...overrides});
|
const req = (overrides={}) => ({actionId:'ask',source:'Hello',settings:{...DEFAULT_SETTINGS},...overrides});
|
||||||
test('ready actions preserve source and hints, translation asks only when needed', () => {
|
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.equal(out.kind,'ready');
|
||||||
assert.ok(new URL(out.url).searchParams.get('q').endsWith(source));
|
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', () => {
|
test('long selections preserve instructions, repetition and Unicode with explicit consent', () => {
|
||||||
assert.equal(typeof prepareAction, 'function');
|
assert.equal(typeof prepareAction, 'function');
|
||||||
const settings={...DEFAULT_SETTINGS,promptTemplate:'START {query} AGAIN {query} END',temporaryChat:true,webSearch:true};
|
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)}));
|
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/);
|
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 test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
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([
|
const elements = Object.fromEntries([
|
||||||
"shortcut-intro", "shortcut-hint", "shortcut-current", "change-shortcut",
|
"shortcut-intro", "shortcut-intro-key", "shortcut-keycaps", "shortcut-current", "change-shortcut",
|
||||||
].map((id) => [id, Object.assign(new EventTarget(), { textContent: "", hidden: true })]));
|
].map((id) => [id, Object.assign(new EventTarget(), {
|
||||||
|
textContent: "",
|
||||||
|
hidden: true,
|
||||||
|
children: [],
|
||||||
|
replaceChildren(...children) { this.children = children; },
|
||||||
|
})]));
|
||||||
const document = Object.assign(new EventTarget(), {
|
const document = Object.assign(new EventTarget(), {
|
||||||
visibilityState: "visible",
|
visibilityState: "visible",
|
||||||
getElementById: (id) => elements[id],
|
getElementById: (id) => elements[id],
|
||||||
|
createElement: (tagName) => ({ tagName, className: "", textContent: "" }),
|
||||||
});
|
});
|
||||||
const window = new EventTarget();
|
const window = new EventTarget();
|
||||||
const ready = initializeShortcut({ commands, document, window });
|
const ready = initializeShortcut({ commands, document, window, os });
|
||||||
return { elements, document, window, ready };
|
return { elements, document, window, ready };
|
||||||
}
|
}
|
||||||
|
|
||||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
const command = (shortcut) => ({ name: "ask-chatgpt", description: "Ask ChatGPT", shortcut });
|
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 () => {
|
test("shows the configured command rather than another command or the default", async () => {
|
||||||
const { elements, ready } = setup({ getAll: async () => [
|
const { elements, ready } = setup({ getAll: async () => [
|
||||||
{ name: "other", description: "Other", shortcut: "Alt+O" }, command("Ctrl+Shift+Y"),
|
{ name: "other", description: "Other", shortcut: "Alt+O" }, command("Ctrl+Shift+Y"),
|
||||||
] });
|
] });
|
||||||
await ready;
|
await ready;
|
||||||
for (const id of ["shortcut-intro", "shortcut-hint", "shortcut-current"]) {
|
assert.equal(elements["shortcut-intro"].hidden, false);
|
||||||
assert.match(elements[id].textContent, /Ctrl\+Shift\+Y/);
|
assert.equal(elements["shortcut-intro-key"].textContent, "<Ctrl>+<Shift>+Y");
|
||||||
assert.doesNotMatch(elements[id].textContent, /Alt\+Shift\+G|Alt\+O/);
|
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 () => {
|
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" });
|
changed({ name: "ask-chatgpt", newShortcut: "", oldShortcut: "Alt+Shift+G" });
|
||||||
await flush();
|
await flush();
|
||||||
assert.match(elements["shortcut-current"].textContent, /No keyboard shortcut assigned/);
|
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 () => {
|
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";
|
shortcut = "Ctrl+Shift+Y";
|
||||||
window.dispatchEvent(new Event("focus"));
|
window.dispatchEvent(new Event("focus"));
|
||||||
await flush();
|
await flush();
|
||||||
assert.match(elements["shortcut-current"].textContent, /Ctrl\+Shift\+Y/);
|
assert.match(elements["shortcut-current"].textContent, /<Ctrl>\+<Shift>\+Y/);
|
||||||
shortcut = "Alt+J";
|
shortcut = "Alt+J";
|
||||||
document.dispatchEvent(new Event("visibilitychange"));
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
await flush();
|
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 () => {
|
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);
|
const { elements, ready } = setup(commands);
|
||||||
await ready;
|
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);
|
assert.equal(elements["change-shortcut"].hidden, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user