tampermonkey // scripts

Small collection of user scripts for the browser.

ChatGPT Modelswitcher

Adds hotkeys to switch between ChatGPT models (macos only, alt+t, alt+a).

Show source
// ==UserScript==
// @name         ChatGPT Reasoning Mode Hotkeys (Auto/Think) — CSP-safe + Focus Restore
// @description  ⌥A=Auto, ⌥T=Think, ⌥`=Toggle. ⌥⇧D dump; ⌥⇧H highlight; ⌥⇧O open menu. Restores focus to composer after mode change. Works under strict CSP.
// @match        https://chat.openai.com/*
// @match        https://chatgpt.com/*
// @inject-into  content
// @run-at       document-start
// @noframes
// @version      1.3
// ==/UserScript==
(() => {
    const DEBUG = false;
  
    // Use e.code so macOS Option combos don't turn into å/† and miss-match.
    const KEY_AUTO   = { alt:true, code:'KeyA' };
    const KEY_THINK  = { alt:true, code:'KeyT' };
    const KEY_TOGGLE = { alt:true, code:'Backquote' };
    const KEY_DUMP   = { alt:true, shift:true, code:'KeyD' };
    const KEY_HILITE = { alt:true, shift:true, code:'KeyH' };
    const KEY_OPEN   = { alt:true, shift:true, code:'KeyO' };
  
    const labels = {
      auto:  ['auto','automático','automatic'],
      think: ['think','thinking','reasoning','razonamiento','pensar','pensamiento']
    };
  
    const dbg = (...a) => { if (DEBUG) console.log('[ModeDbg]', ...a); };
  
    const toastBoxId = '__cgpt_mode_toast__';
    function toast(msg) {
      let box = document.getElementById(toastBoxId);
      if (!box) {
        box = document.createElement('div');
        box.id = toastBoxId;
        Object.assign(box.style, {
          position:'fixed', top:'10px', right:'12px', zIndex: 2147483647,
          font:'12px system-ui,-apple-system,Segoe UI,Roboto,sans-serif'
        });
        document.body.appendChild(box);
      }
      const el = document.createElement('div');
      Object.assign(el.style, {
        background:'#111', color:'#fff', padding:'6px 8px', marginTop:'8px',
        borderRadius:'6px', boxShadow:'0 2px 8px rgba(0,0,0,.35)', opacity:'0.95'
      });
      el.textContent = '[Mode] ' + msg;
      box.appendChild(el);
      setTimeout(() => el.remove(), 2200);
    }
  
    const norm = s => (s||'').replace(/\s+/g,' ').trim().toLowerCase();
    const vis  = el => el && el.isConnected && (()=>{const r=el.getBoundingClientRect(),cs=getComputedStyle(el);return r.width>0&&r.height>0&&cs.visibility!=='hidden'&&cs.display!=='none'})();
  
    // Boundary-insensitive "startsWith": matches "autoDecides..." too
    const startsWith = (text, lbl) => norm(text).startsWith(lbl);
    const matchAnyStart = (text, arr) => arr.some(lbl => startsWith(text, lbl));
  
    function pointerClick(el){
      try{
        const o={bubbles:true,cancelable:true,composed:true};
        el.dispatchEvent(new PointerEvent('pointerdown',o));
        el.dispatchEvent(new MouseEvent('mousedown',o));
        el.dispatchEvent(new PointerEvent('pointerup',o));
        el.dispatchEvent(new MouseEvent('mouseup',o));
        el.dispatchEvent(new MouseEvent('click',o));
        return true;
      }catch(e){ try{ el.click(); return true; }catch{} }
      return false;
    }
  
    function topFixedOrZ(){
      return Array.from(document.querySelectorAll('body *')).filter(n=>{
        const cs=getComputedStyle(n);
        return vis(n) && (cs.position==='fixed' || parseInt(cs.zIndex||'0',10)>1000);
      });
    }
  
    function allClickable(scope=document){
      const sel = [
        'button',
        '[role="button"]',
        '[role^="menuitem"]',
        '[role="option"]',
        '[role="tab"]',
        '[role="radio"]',
        // Radix-like menu items:
        'div.__menu-item','div.group.__menu-item','[class*="__menu-item"]','[class*="menu-item"]'
      ].join(',');
      return Array.from(scope.querySelectorAll(sel)).filter(vis);
    }
  
    function findByLabels(arr){
      // Try high z-index popovers first
      const pops = topFixedOrZ();
      for(const p of pops){
        const hit = allClickable(p).find(el => matchAnyStart(el.textContent||'', arr));
        if (hit) return hit;
      }
      return allClickable(document).find(el => matchAnyStart(el.textContent||'', arr)) || null;
    }
  
    // === Focus restore helpers ===
    function focusComposer(retries=10){
      const tryFocus = () => {
        const selectors = [
          '#prompt-textarea',
          'textarea[placeholder*="Message"]',
          'form textarea',
          '[data-testid*="composer"] [contenteditable="true"]',
          '[contenteditable="true"][role="textbox"]',
          '#composer [contenteditable="true"]',
          'textarea'
        ];
        let candidates=[];
        selectors.forEach(q => candidates.push(...document.querySelectorAll(q)));
        const el = candidates.find(vis);
        if (!el) return false;
  
        // Focus + move caret to end
        try {
          if (el.matches('textarea,input')) {
            el.focus({ preventScroll:true });
            const n = el.value?.length ?? 0;
            if (typeof el.setSelectionRange === 'function') el.setSelectionRange(n, n);
          } else {
            el.focus({ preventScroll:true });
            const range = document.createRange();
            range.selectNodeContents(el);
            range.collapse(false);
            const sel = window.getSelection();
            sel.removeAllRanges();
            sel.addRange(range);
          }
          return true;
        } catch { return false; }
      };
  
      if (tryFocus()) return;
      const id = setInterval(() => {
        if (tryFocus() || --retries <= 0) clearInterval(id);
      }, 120);
    }
    function queueFocus(){
      // Focus after UI settles; do a couple of attempts.
      setTimeout(focusComposer, 100);
      setTimeout(focusComposer, 400);
    }
  
    // Visual highlight
    const HL_CLS='__cgpt_mode_hl__', HL_STYLE_ID='__cgpt_mode_hl_style__';
    function ensureHLStyle(){
      if(document.getElementById(HL_STYLE_ID)) return;
      const st = document.createElement('style');
      st.id = HL_STYLE_ID;
      st.textContent = `
        .${HL_CLS}{outline:3px solid rgba(0,160,255,.95)!important;border-radius:8px!important;animation:cgptPulse 1s ease-in-out infinite}
        @keyframes cgptPulse{0%{outline-offset:0}50%{outline-offset:3px}100%{outline-offset:0}}
      `;
      document.documentElement.appendChild(st);
    }
    function mark(el,label=''){ ensureHLStyle(); try{ el.classList.add(HL_CLS); setTimeout(()=>el.classList.remove(HL_CLS),1500);}catch{} if(label)dbg('HL:',label,el); }
  
    function openModeMenu(){
      // Try icon/aria buttons near composer first
      const cands = Array.from(document.querySelectorAll('button,[role="button"],[aria-haspopup="menu"],[aria-controls]'))
        .filter(vis)
        .filter(el=>{
          const t = `${el.textContent||''} ${el.getAttribute('aria-label')||''}`.toLowerCase();
          return /auto|think|reason|mode|instant|thinking|reasoning/.test(t);
        });
  
      // Prefer the one closest to the composer
      const composer = document.querySelector('#composer,[data-testid*="composer"],form textarea,[contenteditable="true"]');
      if (composer) {
        cands.sort((a,b)=>distance(a,composer)-distance(b,composer));
      }
      const btn = cands[0];
      if(btn){ mark(btn,'open-menu'); pointerClick(btn); dbg('OpenMenu via',btn); return true; }
      dbg('OpenMenu: no trigger found');
      return false;
    }
    function distance(a,b){ try{const ra=a.getBoundingClientRect(),rb=b.getBoundingClientRect();return Math.hypot((ra.left+ra.right)/2-(rb.left+rb.right)/2,(ra.top+ra.bottom)/2-(rb.top+rb.bottom)/2);}catch{return 1e9;} }
  
    async function selectMode(which){
      const want = which==='auto'?labels.auto:labels.think;
      dbg('selectMode', which);
  
      // Segmented toggle case
      const nodes = allClickable(document);
      const autoBtn  = nodes.find(n=>matchAnyStart(n.textContent||'',labels.auto));
      const thinkBtn = nodes.find(n=>matchAnyStart(n.textContent||'',labels.think));
      if (autoBtn && thinkBtn && autoBtn.parentElement && autoBtn.parentElement===thinkBtn.parentElement){
        const target = which==='auto'?autoBtn:thinkBtn;
        toast(`Selecting ${which} (segmented)`);
        mark(target,'segmented');
        pointerClick(target);
        queueFocus();
        return true;
      }
  
      // Dropdown/menu case
      const tryMenu = () => {
        const item = findByLabels(want);
        if (item) {
          toast(`Selecting ${which} (menu)`);
          mark(item,'menu-item');
          pointerClick(item);
          queueFocus();
          return true;
        }
        return false;
      };
  
      if (tryMenu()) return true;
      openModeMenu();
  
      // Poll briefly
      return await new Promise(res=>{
        let n=0, id=setInterval(()=>{
          if (tryMenu()){ clearInterval(id); res(true); }
          else if(++n>=15){ clearInterval(id); toast(`Failed to find "${which}"`); res(false); }
        }, 90);
      });
    }
  
    function dumpCandidates(highlight=false){
      const scopes = [document, ...topFixedOrZ()];
      let total = 0;
      scopes.forEach((s,idx)=>{
        const nodes = allClickable(s); total += nodes.length;
        dbg(`--- DUMP scope[${idx}] nodes=${nodes.length} ---`);
        nodes.forEach((n,i)=>{
          const text=(n.textContent||'').replace(/\s+/g,' ').trim();
          console.log(`[${idx}:${i}]`, { text, node:n });
          if (highlight && (matchAnyStart(text, labels.auto) || matchAnyStart(text, labels.think))) mark(n,text);
        });
      });
      toast(`Dumped ${total} nodes`);
    }
  
    function matchHotkey(e,s){
      return (!!s.alt   === !!e.altKey)   &&
             (!!s.ctrl  === !!e.ctrlKey)  &&
             (!!s.shift === !!e.shiftKey) &&
             (!!s.meta  === !!e.metaKey)  &&
             (s.code ? e.code === s.code : (s.key && e.key.toLowerCase()===s.key.toLowerCase()));
    }
  
    // Capture even in the composer; stop propagation so characters don't appear.
    const handler = async (e) => {
      const isCombo =
        matchHotkey(e,KEY_AUTO) || matchHotkey(e,KEY_THINK) || matchHotkey(e,KEY_TOGGLE) ||
        matchHotkey(e,KEY_DUMP) || matchHotkey(e,KEY_HILITE) || matchHotkey(e,KEY_OPEN);
  
      if (!isCombo) return;
  
      e.preventDefault();
      e.stopImmediatePropagation();
      e.stopPropagation();
  
      if (matchHotkey(e,KEY_AUTO))      { dbg('HK AUTO');   await selectMode('auto'); }
      else if (matchHotkey(e,KEY_THINK)){ dbg('HK THINK');  await selectMode('think'); }
      else if (matchHotkey(e,KEY_TOGGLE)){
        dbg('HK TOGGLE');
        // heuristic toggle: open menu, then pick the other item
        openModeMenu();
        setTimeout(async ()=>{
          const thinkItem = findByLabels(labels.think);
          if (thinkItem && thinkItem.getAttribute('data-state')!=='on') await selectMode('think');
          else await selectMode('auto');
        }, 120);
      }
      else if (matchHotkey(e,KEY_DUMP))  { dbg('HK DUMP');  dumpCandidates(false); }
      else if (matchHotkey(e,KEY_HILITE)){ dbg('HK HILITE');dumpCandidates(true); }
      else if (matchHotkey(e,KEY_OPEN))  { dbg('HK OPEN');  openModeMenu(); toast('Tried to open menu'); }
    };
  
    window.addEventListener('keydown', handler, { capture:true });
    document.addEventListener('keydown', handler, { capture:true });
  
    // First probe (after DOM ready enough to have body)
    const ready = () => document.body ? dumpCandidates(false) : setTimeout(ready, 300);
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ready, { once:true });
    else ready();
  
    // Log when popovers appear
    new MutationObserver(() => {
      const pops = topFixedOrZ();
      if (pops.length) dbg('Mutation: high-z containers present:', pops.length);
    }).observe(document.documentElement, { childList:true, subtree:true });
  })();