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