Adds a live character counter to the ChatGPT message composer.
// ==UserScript==
// @name ChatGPT Character Counter
// @namespace http://tampermonkey.net/
// @version 2.2
// @description Adds a live character counter to the ChatGPT message composer.
// @author Emree.el on Instagram
// @match https://chatgpt.com/*
// @match https://www.chatgpt.com/*
// @match https://chat.openai.com/*
// @grant none
// @license MIT
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const LIMIT = 32732;
const COUNTER_ID = 'emree-chatgpt-char-counter';
let editor = null;
let counter = null;
function findEditor() {
// Current ChatGPT editor.
const proseMirror = document.querySelector(
'#prompt-textarea.ProseMirror[contenteditable="true"]'
);
if (proseMirror) return proseMirror;
// Fallback if ChatGPT changes the class structure.
const editable = document.querySelector(
'#prompt-textarea[contenteditable="true"]'
);
if (editable) return editable;
// Older/fallback textarea.
return document.querySelector(
'textarea[name="prompt-textarea"]'
);
}
function getCharacterCount() {
if (!editor) return 0;
if (editor instanceof HTMLTextAreaElement) {
return editor.value.length;
}
// ProseMirror / contenteditable editor.
return (editor.textContent || '').length;
}
function updateCounter() {
if (!editor || !counter || !editor.isConnected) return;
const count = getCharacterCount();
counter.textContent =
`${count.toLocaleString()}/${LIMIT.toLocaleString()}`;
if (count > LIMIT) {
counter.style.color = '#ff5555';
counter.style.textShadow = '0 0 8px rgba(255, 0, 0, 0.7)';
} else if (count > 0) {
counter.style.color = '#55ff88';
counter.style.textShadow = '0 0 8px rgba(0, 255, 100, 0.45)';
} else {
counter.style.color = '';
counter.style.textShadow = 'none';
}
}
function attach(editorElement) {
// Already attached to this editor.
if (editor === editorElement && counter?.isConnected) {
updateCounter();
return;
}
// Remove old counter.
document.getElementById(COUNTER_ID)?.remove();
editor = editorElement;
counter = document.createElement('div');
counter.id = COUNTER_ID;
Object.assign(counter.style, {
fontSize: '12px',
fontWeight: '600',
marginTop: '4px',
paddingRight: '8px',
textAlign: 'right',
opacity: '0.8',
pointerEvents: 'none',
userSelect: 'none'
});
const form = editor.closest('form');
if (form) {
form.insertAdjacentElement('afterend', counter);
} else {
editor.insertAdjacentElement('afterend', counter);
}
// The main event: catches typing, deleting and pasting.
editor.addEventListener('input', () => {
updateCounter();
// Let ProseMirror/React finish any DOM updates.
requestAnimationFrame(updateCounter);
});
// Extra fallback for keyboard input.
editor.addEventListener('keyup', () => {
requestAnimationFrame(updateCounter);
});
// Extra fallback for paste.
editor.addEventListener('paste', () => {
requestAnimationFrame(() => {
requestAnimationFrame(updateCounter);
});
});
updateCounter();
}
function checkEditor() {
const found = findEditor();
if (!found) return;
if (found !== editor || !counter?.isConnected) {
attach(found);
}
}
// Very lightweight SPA check.
setInterval(checkEditor, 1000);
checkEditor();
})();