DevPause

How DevPause types into ChatGPT, Claude and Gemini's input box — three editors, one function

DevPause's prompt-template constructor builds a block of text and then has to put it where the user can send it: the chat input box. That sounds like one line of code — el.value = text — and on a plain HTML form it would be. ChatGPT, Claude and Gemini do not use plain HTML forms for their input box. ChatGPT's is a real <textarea>, but one whose value React tracks internally; Claude's and Gemini's are contenteditable <div>s driven by two different rich-text editor frameworks, ProseMirror and Quill, which keep their own model of the document independent of whatever is in the DOM. Three sites, three different reasons the obvious one-liner fails, and insert.js has to pick the right fix for each without knowing in advance which site it is running on beyond a hostname check.

Textarea: the value exists twice

A React-controlled <textarea> has two copies of its value: the DOM property, and the piece of React state that last rendered it. Setting el.value = text changes only the first one. React never finds out, so on the very next keystroke — or sooner, on its own re-render — it overwrites the DOM value back to whatever state still holds. The fix is to bypass the property React patched and call the browser's original setter directly, then fire the event React is actually listening for:

if (el.tagName === "TEXTAREA") {
  // через родной сеттер, иначе React не заметит изменения
  const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value").set;
  const cur = String(el.value || "").replace(/\s+$/, "");
  set.call(el, cur ? `${cur}\n\n${text}` : text);
  el.dispatchEvent(new Event("input", { bubbles: true }));
  return { ok: true, how: "textarea" };
}

Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value").set reaches past whatever property descriptor React (or any other framework) installed on the specific element instance and calls the one the browser itself defines on the prototype. Calling that setter still updates the real DOM value, but critically it does not trigger React's own synthetic setter, so the dispatched input event is the only thing React sees — exactly the signal it expects from a real keystroke. The existing text is kept and the template is appended after a blank line rather than replacing it, so using the constructor mid-draft does not throw away what the user already typed.

Contenteditable: there is no .value at all

Claude's and Gemini's input boxes are <div contenteditable="true"> elements with no value property to set in the first place; the only representation of their content is the DOM subtree itself, which ProseMirror and Quill each keep in sync with their own internal document model through a long chain of mutation observers and selection tracking. Writing to innerHTML or innerText changes the pixels but leaves the editor's internal model out of sync with what is on screen — the next keystroke can produce corrupted formatting, a broken undo stack, or the editor silently reverting the DOM to its last known-good state. The safer path is to make the browser believe a real insertion happened, by placing the caret at the end of the editor and asking the browser itself to insert text at that caret:

const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
let done = false;
try {
  done = document.execCommand("insertText", false, text);
} catch {
  done = false;
}

document.execCommand("insertText", ...) is formally deprecated, but it remains the one API that both inserts through the same code path a real paste or keystroke would use and that every major rich-text framework, including ProseMirror and Quill, listens for and updates its model from — which is precisely why it is still the recommended escape hatch for extensions that need to type into someone else's editor rather than their own. Collapsing the range to the end of the element first (range.collapse(false)) means the insertion always lands after whatever the user already wrote, matching the append-not-replace behavior of the textarea branch above.

When execCommand itself is gone

Deprecated APIs occasionally get removed outright, and a future browser build or a site's own content-security tightening could make execCommand return false or throw. insert.js treats that as expected rather than fatal, with a fallback that builds the same result a different way — appending real paragraph elements and firing the input event the frameworks' own mutation watchers listen for:

if (!done) {
  // запасной путь: абзацы напрямую + событие ввода
  for (const line of text.split("\n")) {
    const p = document.createElement("p");
    p.textContent = line || " ";
    el.appendChild(p);
  }
  el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: text }));
}

This path is visually correct — the text appears, split into paragraphs the way the editor already expects it block content to be structured — but it does not go through the editor's own insertion logic, so it is the fallback, used only when the primary path reports failure, not the default.

Finding the field without knowing the page

Each site changes its own markup often enough that a single CSS selector is not safe to hardcode permanently, so the lookup tries a short, ordered list of candidates per site and returns the first one that exists, with a generic div[contenteditable="true"] as the last resort on both rich-text sites:

const FIELDS = {
  "chatgpt.com": ["#prompt-textarea", 'div[contenteditable="true"].ProseMirror', "form textarea"],
  "claude.ai": ['div[contenteditable="true"].ProseMirror', 'fieldset div[contenteditable="true"]', 'div[contenteditable="true"]'],
  "gemini.google.com": ['div.ql-editor[contenteditable="true"]', 'rich-textarea div[contenteditable="true"]', 'div[contenteditable="true"]'],
};

The background script can also push a fresher set of selectors at runtime (a field-selectors message) if a site redesign breaks the built-in list before the next extension update ships, with the hardcoded ones kept as a trailing fallback rather than replaced outright. Before the constructor window ever offers an "Insert" button, it sends a lightweight ping message and only shows the button if the content script reports back that a field actually exists on the current page — so there is nothing to click that would silently do nothing.

Frequently asked questions

Does this read anything already typed in the box, or only add to it?

It only adds to it. The textarea branch reads the current value solely to decide whether to prepend a blank line before appending; the contenteditable branch collapses the caret to the end before inserting. Neither branch is triggered by anything other than the user explicitly clicking Insert in the extension's own window.

Why not just always use the DOM-fallback path and skip the native-setter and execCommand complexity?

Because it skips the live editor's own insertion logic, which means it does not register as a normal edit for purposes like undo (Ctrl+Z after a DOM-fallback insert can behave oddly, since the editor never "saw" the change as typing). It is kept strictly as the last resort for the rare case where the primary path is unavailable.

What happens if a site stops exposing contenteditable entirely and switches to, say, a canvas-based editor?

Then field() finds nothing, ping reports hasField: false, the constructor hides the Insert button, and the generated text is still available to copy manually — the feature degrades to "nothing to click" rather than failing silently mid-insert.

DevPause is a free Chrome extension that adds utility buttons under ChatGPT, Claude and Gemini answers — clean copy, shorter, simpler, as a table, continue, and a prompt-template constructor that uses the insertion logic described above. It is ad-supported (one small text line while an answer streams) rather than subscription-based, and the code discussed here is the real, current content script it ships with.

Install DevPause