ChatGPT Model Selector

After entering text and pressing Enter, you can select a model before sending.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         ChatGPT Model Selector
// @namespace    https://openai.com/
// @version      2.1.1
// @description  After entering text and pressing Enter, you can select a model before sending.
// @author       81standard
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const STATE = {
    overlay: null,
    panel: null,
    selectedIndex: 0,
    isOpen: false,
    applying: false,
    actionTaken: false,
    lastComposer: null,
    hotkeyCooldownUntil: 0,
    toastTimer: null,
    dialogThreadId: null,
    lastKnownHref: location.href,
    threadSyncToken: 0,
    pendingThreadModel: null,
  };

  const I18N = {
    thought: ['思考', 'thinking'],
    extendedThought: ['じっくり思考', 'extended thinking'],
    standard: ['標準', 'standard'],
    extended: ['拡張', 'extended'],
  };

  const OPTIONS = [
    { key: 'instant', label: 'GPT-5', modelDataTestId: 'model-switcher-gpt-5-3' },
    { key: 'thinking-standard', label: 'GPT Thinking 標準', modelDataTestId: 'model-switcher-gpt-5-4-thinking', effortLabels: I18N.standard },
    { key: 'thinking-extended', label: 'GPT Thinking 拡張', modelDataTestId: 'model-switcher-gpt-5-4-thinking', effortLabels: I18N.extended },
  ];

  const STORAGE_KEY = 'tm-chatgpt-model-selector.thread-models.v1';
  const DEFAULT_MODEL_KEY = OPTIONS[0].key;

  function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

  function visible(el) {
    if (!el || !el.isConnected) return false;
    const st = getComputedStyle(el);
    if (st.display === 'none' || st.visibility === 'hidden') return false;
    const r = el.getBoundingClientRect();
    return r.width > 0 && r.height > 0;
  }

  function norm(s) {
    return (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
  }

  function textOf(el) {
    return norm((el?.innerText || el?.textContent || '') + ' ' + (el?.getAttribute?.('aria-label') || ''));
  }

  function includesAny(text, words) {
    const t = norm(text);
    return (words || []).some(word => t.includes(norm(word)));
  }

  function thoughtLike(text) {
    return includesAny(text, I18N.thought) || includesAny(text, I18N.extendedThought);
  }

  function extendedLike(text) {
    return includesAny(text, I18N.extended) || includesAny(text, I18N.extendedThought);
  }

  function normalizeModel(model) {
    const key = typeof model === 'string' ? model : model?.key;
    return OPTIONS.some(option => option.key === key) ? key : null;
  }

  function getOptionByModel(model) {
    const key = normalizeModel(model) || DEFAULT_MODEL_KEY;
    return OPTIONS.find(option => option.key === key) || OPTIONS[0];
  }

  function getOptionIndexByModel(model) {
    const key = normalizeModel(model) || DEFAULT_MODEL_KEY;
    const index = OPTIONS.findIndex(option => option.key === key);
    return index >= 0 ? index : 0;
  }

  function readThreadModelStore() {
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      if (!raw) return {};
      const parsed = JSON.parse(raw);
      return parsed && typeof parsed === 'object' ? parsed : {};
    } catch {
      return {};
    }
  }

  function writeThreadModelStore(store) {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(store || {}));
    } catch {}
  }

  function getCurrentThreadId() {
    try {
      const url = new URL(location.href);
      const pathMatch = url.pathname.match(/\/c\/([A-Za-z0-9-]+)/);
      if (pathMatch?.[1]) return `thread:${pathMatch[1]}`;

      for (const key of ['conversationId', 'conversation_id', 'threadId', 'thread_id']) {
        const value = url.searchParams.get(key);
        if (value) return `thread:${value}`;
      }

      const normalizedPath = url.pathname.replace(/\/+$/, '') || '/';
      return `route:${normalizedPath}${url.search}`;
    } catch {
      return 'route:/';
    }
  }

  function isEphemeralThreadId(threadId) {
    return !String(threadId || '').startsWith('thread:');
  }

  function loadThreadModel(threadId) {
    if (!threadId) return null;
    const store = readThreadModelStore();
    return normalizeModel(store[threadId]);
  }

  function saveThreadModel(threadId, model) {
    const key = normalizeModel(model);
    if (!threadId || !key) return key;
    const store = readThreadModelStore();
    store[threadId] = key;
    writeThreadModelStore(store);
    return key;
  }

  function getComposer() {
    const selectors = [
      'form textarea',
      'textarea[placeholder]',
      'textarea',
      '[contenteditable="true"][data-lexical-editor="true"]',
      '[contenteditable="true"][role="textbox"]',
      '[contenteditable="true"]',
    ];
    for (const sel of selectors) {
      const els = Array.from(document.querySelectorAll(sel)).filter(visible);
      if (els.length) return els[els.length - 1];
    }
    const ae = document.activeElement;
    if (ae && ae.matches?.('textarea,[contenteditable="true"],[role="textbox"]')) return ae;
    return null;
  }

  function getComposerForm() {
    const composer = getComposer();
    return composer ? composer.closest('form') : null;
  }

  function focusComposer() {
    const el = STATE.lastComposer || getComposer();
    if (!el) return false;
    try {
      el.focus({ preventScroll: true });
      if (typeof el.value === 'string' && typeof el.setSelectionRange === 'function') {
        const end = el.value.length;
        el.setSelectionRange(end, end);
      } else if (el.isContentEditable) {
        const range = document.createRange();
        range.selectNodeContents(el);
        range.collapse(false);
        const sel = window.getSelection();
        sel.removeAllRanges();
        sel.addRange(range);
      }
      return true;
    } catch {
      try { el.focus(); return true; } catch {}
    }
    return false;
  }

  function getUiLang() {
    const htmlLang = (document.documentElement.getAttribute('lang') || '').toLowerCase();
    const navLang = (navigator.language || '').toLowerCase();
    const lang = htmlLang || navLang;
    return lang.startsWith('ja') ? 'ja' : 'en';
  }

  const UI_TEXT = {
    ja: {
      title: '送信前にモデルを選択',
      hint: 'Enter: 送信 / Esc: 反映のみ / 外クリック: 反映のみ',
      option_gpt5: 'GPT-5',
      option_thinking_standard: 'GPT Thinking 標準',
      option_thinking_extended: 'GPT Thinking 拡張',
      toast_apply: '反映',
      toast_send: '送信',
      toast_failed: '切り替え失敗',
    },
    en: {
      title: 'Select model before sending',
      hint: 'Enter: Send / Esc: Apply only / Click outside: Apply only',
      option_gpt5: 'GPT-5',
      option_thinking_standard: 'GPT Thinking Standard',
      option_thinking_extended: 'GPT Thinking Extended',
      toast_apply: 'Applied',
      toast_send: 'Sent',
      toast_failed: 'Switch failed',
    }
  };

  function ui(key) {
    const lang = getUiLang();
    return (UI_TEXT[lang] && UI_TEXT[lang][key]) || UI_TEXT.en[key] || key;
  }

  function getOptionLabel(option) {
    if (option.key === 'instant') return ui('option_gpt5');
    if (option.key === 'thinking-standard') return ui('option_thinking_standard');
    if (option.key === 'thinking-extended') return ui('option_thinking_extended');
    return option.label || option.key;
  }

  function showToast(msg, ok = true) {
    clearTimeout(STATE.toastTimer);
    let el = document.getElementById('tm-chatgpt-model-toast');
    if (!el) {
      el = document.createElement('div');
      el.id = 'tm-chatgpt-model-toast';
      Object.assign(el.style, {
        position: 'fixed',
        right: '16px',
        bottom: '16px',
        zIndex: '2147483647',
        padding: '10px 14px',
        borderRadius: '12px',
        color: '#fff',
        fontSize: '14px',
        lineHeight: '1.3',
        boxShadow: '0 8px 24px rgba(0,0,0,.25)',
        backdropFilter: 'blur(6px)',
        pointerEvents: 'none',
      });
      document.body.appendChild(el);
    }
    el.style.background = ok ? 'rgba(24,24,27,.92)' : 'rgba(127,29,29,.96)';
    el.textContent = msg;
    STATE.toastTimer = setTimeout(() => { try { el.remove(); } catch {} }, 1800);
  }

  function clickSequence(el) {
    if (!el) return false;
    const r = el.getBoundingClientRect();
    const cx = r.left + Math.max(1, r.width / 2);
    const cy = r.top + Math.max(1, r.height / 2);

    const dispatch = (target, type, Ctor, extra = {}) => {
      const ev = new Ctor(type, {
        bubbles: true,
        cancelable: true,
        composed: true,
        view: window,
        clientX: cx,
        clientY: cy,
        button: 0,
        buttons: (type === 'mouseup' || type === 'click' || type === 'pointerup') ? 0 : 1,
        ...extra,
      });
      return target.dispatchEvent(ev);
    };

    try { el.focus?.({ preventScroll: true }); } catch {}
    try { dispatch(el, 'pointerdown', PointerEvent, { pointerId: 1, pointerType: 'mouse', isPrimary: true }); } catch {}
    try { dispatch(el, 'mousedown', MouseEvent); } catch {}
    try { dispatch(el, 'pointerup', PointerEvent, { pointerId: 1, pointerType: 'mouse', isPrimary: true }); } catch {}
    try { dispatch(el, 'mouseup', MouseEvent); } catch {}
    try { dispatch(el, 'click', MouseEvent); } catch {}
    return true;
  }

  function clickSingle(el) {
    if (!el) return false;
    try { el.focus?.({ preventScroll: true }); } catch {}
    try { el.click(); return true; } catch {}
    try {
      el.dispatchEvent(new MouseEvent('click', {
        bubbles: true,
        cancelable: true,
        composed: true,
        view: window,
        button: 0,
        buttons: 0,
      }));
      return true;
    } catch {}
    return false;
  }

  function clickElementAtCenter(el) {
    if (!el) return false;
    const r = el.getBoundingClientRect();
    const cx = r.left + Math.max(1, r.width / 2);
    const cy = r.top + Math.max(1, r.height / 2);
    const target = document.elementFromPoint(cx, cy);
    if (!target) return false;
    return clickSequence(target);
  }

  function findHeaderModelButton() {
    return Array.from(document.querySelectorAll('button[data-testid="model-switcher-dropdown-button"]'))
      .find(visible) || null;
  }

  function getModelMenuItems() {
    return Array.from(document.querySelectorAll('[data-testid^="model-switcher-"]'))
      .filter(visible)
      .filter(el => (el.getAttribute('data-testid') || '') !== 'model-switcher-dropdown-button');
  }

  function getEffortMenuItems() {
    return Array.from(document.querySelectorAll('[role="menuitemradio"]')).filter(visible);
  }

  async function openModelMenu() {
    const btn = findHeaderModelButton();
    if (!btn) throw new Error('model button not found');
    clickSequence(btn);
    for (let i = 0; i < 10; i++) {
      if (getModelMenuItems().length) return btn;
      await sleep(80);
    }
    throw new Error('model menu not opened');
  }

  async function chooseModel(dataTestId) {
    for (let i = 0; i < 8; i++) {
      const item = getModelMenuItems().find(el => (el.getAttribute('data-testid') || '') === dataTestId);
      if (item) {
        clickSingle(item);
        await sleep(120);
        return true;
      }
      await sleep(60);
    }
    return false;
  }

  function getThoughtPillButtons() {
    const form = getComposerForm();
    const scope = form || document;
    return Array.from(scope.querySelectorAll('button'))
      .filter(visible)
      .filter(el => {
        const cls = String(el.className || '');
        if (cls.includes('__composer-pill-remove')) return false;
        if (!cls.includes('__composer-pill')) return false;
        const t = textOf(el);
        return thoughtLike(t) || includesAny(t, I18N.standard) || includesAny(t, I18N.extended);
      });
  }

  function findThoughtPillButton() {
    const candidates = getThoughtPillButtons();
    candidates.sort((a, b) => {
      const ar = a.getBoundingClientRect();
      const br = b.getBoundingClientRect();
      const topDiff = br.top - ar.top;
      if (topDiff !== 0) return topDiff;
      return br.width * br.height - ar.width * ar.height;
    });
    return candidates[0] || null;
  }

  function effortAlreadyApplied(labels) {
    const t = norm(getComposerForm()?.innerText || '');
    const wantsStandard = includesAny(labels?.join(' '), I18N.standard);
    const wantsExtended = includesAny(labels?.join(' '), I18N.extended);

    if (wantsStandard) {
      return includesAny(t, I18N.standard) || (thoughtLike(t) && !extendedLike(t));
    }
    if (wantsExtended) {
      return extendedLike(t);
    }
    return false;
  }

  async function waitForThoughtPill() {
    for (let i = 0; i < 18; i++) {
      const pill = findThoughtPillButton();
      if (pill) return pill;
      await sleep(80);
    }
    return null;
  }

  async function openThoughtMenu() {
    if (getEffortMenuItems().length) return true;

    for (let i = 0; i < 10; i++) {
      const pill = await waitForThoughtPill();
      if (!pill) {
        await sleep(80);
        continue;
      }

      clickSingle(pill);
      for (let j = 0; j < 8; j++) {
        if (getEffortMenuItems().length) return true;
        await sleep(60);
      }

      clickSequence(pill);
      for (let j = 0; j < 8; j++) {
        if (getEffortMenuItems().length) return true;
        await sleep(60);
      }

      clickElementAtCenter(pill);
      for (let j = 0; j < 8; j++) {
        if (getEffortMenuItems().length) return true;
        await sleep(60);
      }

      await sleep(80);
    }

    throw new Error('thought menu not opened');
  }

  async function chooseEffort(labels) {
    for (let i = 0; i < 10; i++) {
      const item = getEffortMenuItems().find(el => includesAny(textOf(el), labels));
      if (item) {
        clickSingle(item);
        await sleep(120);
        return true;
      }
      await sleep(60);
    }
    return false;
  }

  function detectCurrentModelFromUi() {
    const texts = [
      textOf(findThoughtPillButton()),
      textOf(getComposerForm()),
      textOf(findHeaderModelButton()),
    ].filter(Boolean);

    if (!texts.length) return null;
    const combined = norm(texts.join(' '));

    if (extendedLike(combined)) return 'thinking-extended';
    if (includesAny(combined, I18N.standard) || thoughtLike(combined)) return 'thinking-standard';
    if (combined.includes('gpt-5')) return 'instant';
    return null;
  }

  function getCurrentModelForThread(threadId) {
    return loadThreadModel(threadId) || detectCurrentModelFromUi() || DEFAULT_MODEL_KEY;
  }

  async function waitForApplied(option) {
    for (let i = 0; i < 12; i++) {
      const detected = detectCurrentModelFromUi();
      if (detected === option.key) return true;

      const t = norm(getComposerForm()?.innerText || '');
      if (option.key === 'instant') {
        if (!thoughtLike(t) && !includesAny(t, I18N.standard) && !includesAny(t, I18N.extended)) return true;
      } else if (option.key === 'thinking-standard') {
        if (includesAny(t, I18N.standard) || (thoughtLike(t) && !extendedLike(t))) return true;
      } else if (option.key === 'thinking-extended') {
        if (extendedLike(t)) return true;
      }
      await sleep(80);
    }
    return false;
  }

  async function applyOption(option) {
    STATE.applying = true;
    try {
      await openModelMenu();
      const okModel = await chooseModel(option.modelDataTestId);
      if (!okModel) throw new Error('モデル切り替え失敗');

      if (option.effortLabels) {
        await waitForThoughtPill();
        if (!effortAlreadyApplied(option.effortLabels)) {
          await openThoughtMenu();
          const okEffort = await chooseEffort(option.effortLabels);
          if (!okEffort) throw new Error('思考モード切り替え失敗');
        }
      }

      await waitForApplied(option);
      return true;
    } finally {
      STATE.applying = false;
    }
  }

  async function applyModelToComposer(threadId, model) {
    const option = getOptionByModel(model);
    await applyOption(option);
    saveThreadModel(threadId, option.key);
    STATE.selectedIndex = getOptionIndexByModel(option.key);
    return option.key;
  }

  function findSendButton() {
    const form = getComposerForm();
    const scope = form || document;
    const selectors = [
      'button[data-testid="send-button"]',
      'button[data-testid="composer-submit-button"]',
      'button[aria-label*="プロンプトを送信"]',
      'button[aria-label*="送信"]',
      'button[aria-label*="Send"]',
      'button[aria-label*="send"]',
      'button[type="submit"]',
    ];
    for (const sel of selectors) {
      const buttons = Array.from(scope.querySelectorAll(sel)).filter(visible);
      const enabled = buttons.filter(b => !b.disabled && b.getAttribute('aria-disabled') !== 'true');
      if (enabled.length) return enabled[enabled.length - 1];
    }
    return null;
  }

  async function sendOnce() {
    const btn = findSendButton();
    if (btn) {
      clickSingle(btn);
      return true;
    }
    const form = getComposerForm();
    if (form && typeof form.requestSubmit === 'function') {
      form.requestSubmit();
      return true;
    }
    return false;
  }

  function cleanupDialogListeners() {
    if (STATE.overlay && typeof STATE.overlay._cleanup === 'function') {
      STATE.overlay._cleanup();
    }
  }

  function closeDialog() {
    cleanupDialogListeners();
    try { STATE.overlay?.remove(); } catch {}
    STATE.overlay = null;
    STATE.panel = null;
    STATE.isOpen = false;
    STATE.actionTaken = false;
    STATE.dialogThreadId = null;
    requestAnimationFrame(() => setTimeout(() => focusComposer(), 10));
  }

  async function closeWithoutSendingButSaveModel() {
    if (STATE.actionTaken) return;
    STATE.actionTaken = true;
    const option = OPTIONS[STATE.selectedIndex];
    const threadId = STATE.dialogThreadId || getCurrentThreadId();
    try {
      await applyModelToComposer(threadId, option.key);
      showToast(`${ui('toast_apply')}: ${getOptionLabel(option)}`, true);
    } catch (err) {
      console.error(err);
      showToast(`${ui('toast_failed')}: ${getOptionLabel(option)}`, false);
    } finally {
      closeDialog();
    }
  }

  async function sendWithSelectedModel() {
    if (STATE.actionTaken) return;
    STATE.actionTaken = true;
    const option = OPTIONS[STATE.selectedIndex];
    const threadId = STATE.dialogThreadId || getCurrentThreadId();
    try {
      await applyModelToComposer(threadId, option.key);
      if (isEphemeralThreadId(threadId)) {
        STATE.pendingThreadModel = option.key;
      }
      const sent = await sendOnce();
      if (!sent) throw new Error('送信失敗');
      STATE.hotkeyCooldownUntil = Date.now() + 1500;
      showToast(`${ui('toast_send')}: ${getOptionLabel(option)}`, true);
    } catch (err) {
      console.error(err);
      showToast(`${ui('toast_failed')}: ${getOptionLabel(option)}`, false);
    } finally {
      closeDialog();
    }
  }

  function renderDialog() {
    if (STATE.isOpen) return;

    STATE.lastComposer = getComposer() || document.activeElement;
    STATE.isOpen = true;
    STATE.actionTaken = false;

    const overlay = document.createElement('div');
    STATE.overlay = overlay;
    Object.assign(overlay.style, {
      position: 'fixed',
      inset: '0',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      background: 'rgba(0,0,0,.22)',
      zIndex: '2147483646',
    });

    const panel = document.createElement('div');
    STATE.panel = panel;
    Object.assign(panel.style, {
      width: '360px',
      maxWidth: 'calc(100vw - 32px)',
      background: '#fff',
      color: '#111',
      borderRadius: '16px',
      boxShadow: '0 18px 60px rgba(0,0,0,.30)',
      padding: '14px',
      fontFamily: 'system-ui, sans-serif',
    });
    overlay.appendChild(panel);

    const title = document.createElement('div');
    title.textContent = ui('title');
    Object.assign(title.style, {
      fontSize: '18px',
      fontWeight: '700',
      marginBottom: '8px',
    });
    panel.appendChild(title);

    const hint = document.createElement('div');
    hint.textContent = ui('hint');
    Object.assign(hint.style, {
      fontSize: '12px',
      color: '#555',
      marginBottom: '10px',
    });
    panel.appendChild(hint);

    function paintSelection() {
      Array.from(panel.querySelectorAll('[data-index]')).forEach((el, i) => {
        const active = i === STATE.selectedIndex;
        el.style.background = active ? '#e8f0fe' : '#fff';
        el.style.borderColor = active ? '#8ab4f8' : '#e5e7eb';
      });
    }

    OPTIONS.forEach((opt, idx) => {
      const row = document.createElement('div');
      row.dataset.index = String(idx);
      row.textContent = getOptionLabel(opt);
      Object.assign(row.style, {
        padding: '12px 14px',
        borderRadius: '12px',
        cursor: 'pointer',
        userSelect: 'none',
        marginTop: idx ? '6px' : '0',
        border: '1px solid #e5e7eb',
      });
      row.addEventListener('mouseenter', () => {
        STATE.selectedIndex = idx;
        paintSelection();
      });
      row.addEventListener('click', async (e) => {
        e.preventDefault();
        e.stopPropagation();
        if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
        STATE.selectedIndex = idx;
        paintSelection();
        await sendWithSelectedModel();
      });
      panel.appendChild(row);
    });

    paintSelection();

    overlay.addEventListener('mousedown', async (e) => {
      if (e.target !== overlay) return;
      e.preventDefault();
      e.stopPropagation();
      if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
      await closeWithoutSendingButSaveModel();
    }, true);

    function onDialogKeydown(e) {
      if (!STATE.isOpen || STATE.applying) return;

      if (e.key === 'ArrowUp') {
        e.preventDefault();
        e.stopPropagation();
        if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
        STATE.selectedIndex = (STATE.selectedIndex + OPTIONS.length - 1) % OPTIONS.length;
        paintSelection();
      } else if (e.key === 'ArrowDown') {
        e.preventDefault();
        e.stopPropagation();
        if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
        STATE.selectedIndex = (STATE.selectedIndex + 1) % OPTIONS.length;
        paintSelection();
      } else if (e.key === 'Enter') {
        e.preventDefault();
        e.stopPropagation();
        if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
        sendWithSelectedModel();
      } else if (e.key === 'Escape') {
        e.preventDefault();
        e.stopPropagation();
        if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
        closeWithoutSendingButSaveModel();
      }
    }

    document.addEventListener('keydown', onDialogKeydown, true);
    overlay._cleanup = () => document.removeEventListener('keydown', onDialogKeydown, true);

    document.body.appendChild(overlay);
  }

  function openDialogWithThreadState() {
    if (STATE.isOpen) return;
    const threadId = getCurrentThreadId();
    STATE.dialogThreadId = threadId;
    STATE.selectedIndex = getOptionIndexByModel(getCurrentModelForThread(threadId));
    renderDialog();
  }

  async function waitForComposerSurface() {
    for (let i = 0; i < 25; i++) {
      if (findHeaderModelButton() && getComposer()) return true;
      await sleep(120);
    }
    return false;
  }

  async function syncThreadModelState() {
    if (STATE.isOpen || STATE.applying) return;

    const token = ++STATE.threadSyncToken;
    const threadId = getCurrentThreadId();

    if (STATE.pendingThreadModel && !isEphemeralThreadId(threadId)) {
      saveThreadModel(threadId, STATE.pendingThreadModel);
      STATE.pendingThreadModel = null;
    }

    const savedModel = loadThreadModel(threadId);
    const fallbackModel = savedModel || detectCurrentModelFromUi() || DEFAULT_MODEL_KEY;
    STATE.selectedIndex = getOptionIndexByModel(fallbackModel);

    if (!savedModel) return;

    const ready = await waitForComposerSurface();
    if (!ready) return;
    if (token !== STATE.threadSyncToken) return;
    if (STATE.isOpen || STATE.applying) return;
    if (getCurrentThreadId() !== threadId) return;

    const currentUiModel = detectCurrentModelFromUi();
    if (currentUiModel === savedModel) return;
    if (!currentUiModel && savedModel === DEFAULT_MODEL_KEY) return;

    try {
      await applyModelToComposer(threadId, savedModel);
    } catch (err) {
      console.error(err);
    }
  }

  function startThreadWatcher() {
    setInterval(() => {
      if (location.href === STATE.lastKnownHref) return;
      STATE.lastKnownHref = location.href;
      syncThreadModelState().catch(err => console.error(err));
    }, 300);
  }

  window.tmChatGPTModelSelectorDebug = {
    dumpAll() {
      const obj = {
        threadId: getCurrentThreadId(),
        savedThreadModel: loadThreadModel(getCurrentThreadId()),
        detectedThreadModel: detectCurrentModelFromUi(),
        modelTrigger: findHeaderModelButton(),
        thoughtPill: findThoughtPillButton(),
        thoughtPillButtons: getThoughtPillButtons().map(el => ({
          text: (el.innerText || '').trim(),
          ariaLabel: el.getAttribute('aria-label'),
          className: String(el.className || ''),
          visible: visible(el),
        })),
        sendButton: findSendButton(),
        modelMenuItems: getModelMenuItems().map(el => ({
          text: (el.innerText || '').trim(),
          dataTestid: el.getAttribute('data-testid'),
          role: el.getAttribute('role'),
          visible: visible(el),
        })),
        effortMenuItems: getEffortMenuItems().map(el => ({
          text: (el.innerText || '').trim(),
          ariaChecked: el.getAttribute('aria-checked'),
          role: el.getAttribute('role'),
          visible: visible(el),
        })),
      };
      console.log(obj);
      return obj;
    }
  };

  document.addEventListener('keydown', (e) => {
    if (e.defaultPrevented) return;
    if (STATE.isOpen) return;
    if (Date.now() < STATE.hotkeyCooldownUntil) return;

    if (e.key === 'Enter' && e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
      const composer = getComposer();
      if (!composer) return;
      e.preventDefault();
      e.stopPropagation();
      if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
      openDialogWithThreadState();
    }
  }, true);

  startThreadWatcher();
  syncThreadModelState().catch(err => console.error(err));
})();