ChatGPT Model Selector

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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));
})();