From 519131c21ff81d7be8dced32b3fee729dcadebf5 Mon Sep 17 00:00:00 2001 From: golem Date: Sun, 18 Oct 2026 01:32:22 -0600 Subject: [PATCH] fix: cap huge selections before shortening The shortcut and toolbar read window.getSelection() without a limit, so a select-all on a very large page handed prepareAction millions of characters. Every binary-search probe re-encoded the whole candidate prefix: about 1.1 s for 5M characters, paid again by the dialog. Each code point adds at least one character to the URL, so nothing past the first MAX_URL_CHARS can fit. Cap there before any encoding. A capped source never takes the ready path: renderPrompt trims, so a prefix of mostly whitespace could fit and silently drop the rest without consent. Output is unchanged: 2,000 random cases matched develop exactly. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ lib/prepare-action.js | 17 ++++++++++++++--- test/prepare-action.test.js | 20 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08b2120..507b5e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ All notable changes to this project will be documented in this file. See [commit 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 diff --git a/lib/prepare-action.js b/lib/prepare-action.js index 8726a2c..53ee9f7 100644 --- a/lib/prepare-action.js +++ b/lib/prepare-action.js @@ -25,12 +25,23 @@ export function prepareAction(request) { } const resolved = { ...request, language }; const serialize = text => serializePrompt(request.settings, renderPrompt(resolved, text)); - const url = serialize(source); - if (url.length <= MAX_URL_CHARS) return { kind: 'ready', url }; + // 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); diff --git a/test/prepare-action.test.js b/test/prepare-action.test.js index 840b339..759d353 100644 --- a/test/prepare-action.test.js +++ b/test/prepare-action.test.js @@ -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', () => { @@ -38,3 +39,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]')); +});