Gemini Default Model Setter

Automatically selects a specific model and its Thinking Level for Gemini upon page load, URL change, or tab return. The target patterns and script state can be easily configured via the extension menu.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Gemini Default Model Setter
// @namespace    https://github.com/p65536
// @version      1.1.0
// @license      MIT
// @description  Automatically selects a specific model and its Thinking Level for Gemini upon page load, URL change, or tab return. The target patterns and script state can be easily configured via the extension menu.
// @icon         https://raw.githubusercontent.com/p65536/p65536/main/images/icons/gdms.svg
// @author       p65536
// @match        https://gemini.google.com/*
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.registerMenuCommand
// @grant        GM.unregisterMenuCommand
// @run-at       document-idle
// @noframes
// ==/UserScript==

(async function () {
  'use strict';

  // --- Common Script Definitions ---
  const OWNERID = 'p65536';
  const APPID = 'gdms';
  const APPNAME = 'Gemini Default Model Setter';
  const LOG_PREFIX = `[${APPID.toUpperCase()}]`;

  /**
   * @constant CONSTANTS
   * @description Common configuration constants to eliminate magic numbers.
   */
  const CONSTANTS = {
    STORAGE: {
      VISIBILITY_CHECK_KEY: `${APPID}-visibility-check-state`,
      TARGET_TEXT_KEY: `${APPID}-target-text-state`,
      TARGET_THINKING_TEXT_KEY: `${APPID}-target-thinking-text-state`,
    },
    SELECTORS: {
      CURRENT_MODE_LABEL: '[data-test-id="logo-pill-label-container"]',
      MENU_BUTTON: '[data-test-id="bard-mode-menu-button"]',
      MENU_ITEMS: '[data-test-id^="bard-mode-option-"]',
      THINKING_MENU_ITEM: '[value="thinking_level"]',
      ITEM_LABEL: '.label',
      THINKING_SUBLABEL: '.sublabel',
      INPUT_TEXT_FIELD_TARGET: 'rich-textarea .ql-editor',
      DISABLED_STATE: ':disabled, [aria-disabled="true"], [class*="disabled"]',
      BUTTON_TAG: 'button',
      MENU_ITEM_TAG: 'gem-menu-item',
    },
    ATTRIBUTES: {
      ARIA_EXPANDED: 'aria-expanded',
      TRUE: 'true',
    },
    TIMING: {
      MENU_POLL_INTERVAL_MS: 120,
      MENU_POLL_MAX_ATTEMPTS: 15,
      FOCUS_POLL_INTERVAL_MS: 60,
      FOCUS_POLL_MAX_ATTEMPTS: 20,
      FALLBACK_DELAYS_MS: [300, 800, 1500],
    },
    FOCUS_TARGETS: {
      MODEL: 'model',
      THINKING: 'thinking',
    },
    TARGET_TEXT: 'Flash$',
    TARGET_THINKING_TEXT: '', // Default is empty (Thinking level check disabled)
  };

  /**
   * @constant PALETTE
   * @description UI colors using Gemini system variables for consistent styling.
   */
  const PALETTE = {
    bg: 'var(--gem-sys-color--surface-container-highest)',
    input_bg: 'var(--gem-sys-color--surface-container-low)',
    text_primary: 'var(--gem-sys-color--on-surface)',
    text_secondary: 'var(--gem-sys-color--on-surface-variant)',
    border: 'var(--gem-sys-color--outline)',
    btn_bg: 'var(--gem-sys-color--surface-container-high)',
    btn_hover_bg: 'var(--gem-sys-color--surface-container-higher)',
    btn_text: 'var(--gem-sys-color--on-surface-variant)',
    danger_text: '#d93025',
    accent_text: 'var(--gem-sys-color--primary, #1a73e8)',
    success_text: '#188038',
  };

  /**
   * Wait for an element to appear in the DOM using Promise.withResolvers.
   * @param {string} selector - CSS selector to find the element.
   * @param {number} intervalMs - Polling interval in milliseconds.
   * @param {number} maxAttempts - Maximum number of polling attempts.
   * @param {AbortSignal} signal - Signal to abort the waiting process safely.
   * @returns {Promise<Element|null>} Resolves with the element, or null if timed out or aborted.
   */
  async function waitForElement(selector, intervalMs, maxAttempts, signal) {
    const { promise, resolve } = Promise.withResolvers();
    let attempts = 0;
    let timer = null;

    const cleanup = () => {
      if (timer) clearInterval(timer);
      // [DO NOT REFACTOR] Early Garbage Collection (GC)
      // Even though the listener is registered with { once: true }, manually removing it here
      // ensures that any closures (e.g., DOM references) are released to the GC immediately
      // when the element is found, rather than waiting for the abort signal to potentially fire later.
      signal.removeEventListener('abort', onAbort);
    };

    const onAbort = () => {
      cleanup();
      console.debug(`${LOG_PREFIX} waitForElement aborted for selector: ${selector}`);
      resolve(null);
    };

    if (signal.aborted) {
      console.debug(`${LOG_PREFIX} waitForElement immediately aborted for selector: ${selector}`);
      return null;
    }

    signal.addEventListener('abort', onAbort, { once: true });

    timer = setInterval(() => {
      attempts++;
      const el = document.querySelector(selector);

      if (el) {
        cleanup();
        resolve(el);
      } else if (attempts > maxAttempts) {
        cleanup();
        console.warn(`${LOG_PREFIX} waitForElement timeout for selector: ${selector}`);
        resolve(null);
      }
    }, intervalMs);

    return promise;
  }

  /**
   * @class Sentinel
   * @description Detects DOM node insertion using a shared, prefixed CSS animation trick.
   * @property {Map<string, Set<(element: Element) => void>>} listeners
   * @property {Set<string>} rules
   * @property {HTMLElement | null} styleElement
   * @property {CSSStyleSheet | null} sheet
   * @property {string[]} pendingRules
   * @property {WeakMap<CSSRule, string>} ruleSelectors
   */
  class Sentinel {
    /**
     * @param {string} prefix - A unique identifier for this Sentinel instance to avoid CSS conflicts. Required.
     */
    constructor(prefix) {
      if (!prefix) {
        throw new Error('[Sentinel] "prefix" argument is required to avoid CSS conflicts.');
      }

      // Validate prefix for CSS compatibility
      // 1. Must contain only alphanumeric characters, hyphens, or underscores.
      // 2. Cannot start with a digit.
      // 3. Cannot start with a hyphen followed by a digit.
      if (!/^[a-zA-Z0-9_-]+$/.test(prefix) || /^[0-9]|^-[0-9]/.test(prefix)) {
        throw new Error(`[Sentinel] Prefix "${prefix}" is invalid. It must contain only alphanumeric characters, hyphens, or underscores, and cannot start with a digit or a hyphen followed by a digit.`);
      }

      /** @type {Window & { __global_sentinel_instances__?: Record<string, Sentinel> }} */
      const globalScope = window;
      globalScope.__global_sentinel_instances__ ??= {};
      if (globalScope.__global_sentinel_instances__[prefix]) {
        return globalScope.__global_sentinel_instances__[prefix];
      }

      this.prefix = prefix;
      this.isDestroyed = false;
      this.isSuspended = false;
      this._initObserver = null;

      // Use a unique, prefixed animation name shared by all scripts in a project.
      this.animationName = `${prefix}-global-sentinel-animation`;
      this.styleId = `${prefix}-sentinel-global-rules`; // A single, unified style element
      this.listeners = new Map();
      this.rules = new Set(); // Tracks all active selectors
      this.styleElement = null; // Holds the reference to the single style element
      this.sheet = null; // Cache the CSSStyleSheet reference
      this.pendingRules = []; // Queue for rules requested before sheet is ready
      /** @type {WeakMap<CSSRule, string>} */
      this.ruleSelectors = new WeakMap(); // Tracks selector strings associated with CSSRule objects

      this._boundHandleAnimationStart = this._handleAnimationStart.bind(this);

      this._injectStyleElement();
      document.addEventListener('animationstart', this._boundHandleAnimationStart, true);

      globalScope.__global_sentinel_instances__[prefix] = this;
    }

    destroy() {
      if (this.isDestroyed) return;
      this.isDestroyed = true;

      document.removeEventListener('animationstart', this._boundHandleAnimationStart, true);

      if (this._initObserver) {
        this._initObserver.disconnect();
        this._initObserver = null;
      }

      if (this.styleElement) {
        this.styleElement.remove();
        this.styleElement = null;
      }

      this.sheet = null;
      this.listeners.clear();
      this.rules.clear();
      this.pendingRules = [];

      /** @type {Window & { __global_sentinel_instances__?: Record<string, Sentinel> }} */
      const globalScope = window;
      if (globalScope.__global_sentinel_instances__) {
        delete globalScope.__global_sentinel_instances__[this.prefix];
      }
    }

    _injectStyleElement() {
      // Ensure the style element is injected only once per project prefix.
      this.styleElement = document.getElementById(this.styleId);

      if (this.styleElement instanceof HTMLStyleElement) {
        this.styleElement.disabled = this.isSuspended;

        /** @type {HTMLStyleElement} */
        const styleNode = this.styleElement;
        const pollExisting = () => {
          if (this.isDestroyed) return;
          if (styleNode.sheet) {
            this.sheet = styleNode.sheet;
            this._flushPendingRules();
          } else {
            // Poll infinitely until sheet is ready
            setTimeout(pollExisting, 50);
          }
        };
        pollExisting();
        return;
      }

      // Create empty style element
      this.styleElement = document.createElement('style');
      this.styleElement.id = this.styleId;

      // CSP Fix: Try to fetch a valid nonce from existing scripts/styles
      // "nonce" property exists on HTMLScriptElement/HTMLStyleElement, not basic Element.
      let nonce;

      // 1. Try to get nonce from scripts collection
      const scripts = document.scripts;
      for (let i = 0; i < scripts.length; i++) {
        if (scripts[i].nonce) {
          nonce = scripts[i].nonce;
          break;
        }
      }

      // 2. Fallback: Using querySelector (content attribute)
      if (!nonce) {
        const style = document.querySelector('style[nonce]');
        const script = document.querySelector('script[nonce]');

        if (style instanceof HTMLStyleElement && style.nonce) {
          nonce = style.nonce;
        } else if (script instanceof HTMLScriptElement && script.nonce) {
          nonce = script.nonce;
        }
      }

      if (nonce) {
        this.styleElement.nonce = nonce;
      }

      if (this.styleElement instanceof HTMLStyleElement) {
        this.styleElement.disabled = this.isSuspended;
      }

      // Try to inject immediately.
      // If the document is not yet ready (e.g. extremely early document-start), wait for the root element.
      const target = document.head || document.documentElement;

      const initSheet = () => {
        if (this.isDestroyed) return;
        if (this.styleElement instanceof HTMLStyleElement) {
          /** @type {HTMLStyleElement} */
          const styleNode = this.styleElement;
          if (styleNode.sheet) {
            this.sheet = styleNode.sheet;
            // Insert the shared keyframes rule at index 0.
            try {
              const keyframes = `@keyframes ${this.animationName} { from { outline: 1px solid transparent;} to { outline: 0px solid transparent; } }`;
              this.sheet.insertRule(keyframes, 0);
            } catch (e) {
              console.error(`${LOG_PREFIX} [Sentinel] Failed to insert keyframes rule:`, e);
            }
            this._flushPendingRules();
          } else {
            // Poll infinitely until sheet is ready
            setTimeout(initSheet, 50);
          }
        }
      };

      if (target) {
        target.appendChild(this.styleElement);
        initSheet();
      } else {
        this._initObserver = new MutationObserver(() => {
          if (this.isDestroyed) return;
          const retryTarget = document.head || document.documentElement;
          if (retryTarget) {
            this._initObserver.disconnect();
            this._initObserver = null;

            retryTarget.appendChild(this.styleElement);
            initSheet();
          }
        });
        this._initObserver.observe(document, { childList: true });
      }
    }

    /**
     * Ensures the style element is connected to the DOM and restores rules if it was removed.
     */
    _ensureStyleGuard() {
      if (this.styleElement && !this.styleElement.isConnected) {
        const target = document.head || document.documentElement;
        if (target) {
          target.appendChild(this.styleElement);
          if (this.styleElement instanceof HTMLStyleElement && this.styleElement.sheet) {
            this.styleElement.disabled = this.isSuspended;
            this.sheet = this.styleElement.sheet;

            try {
              while (this.sheet.cssRules.length > 0) {
                this.sheet.deleteRule(0);
              }
              const keyframes = `@keyframes ${this.animationName} { from { outline: 1px solid transparent; } to { outline: 0px solid transparent; } }`;
              this.sheet.insertRule(keyframes, 0);
            } catch (e) {
              console.error(`${LOG_PREFIX} [Sentinel] Failed to clear or restore base rules:`, e);
            }

            this.pendingRules = [];

            this.rules.forEach((selector) => {
              this._insertRule(selector);
            });
          }
        }
      }
    }

    _flushPendingRules() {
      if (!this.sheet || this.pendingRules.length === 0) return;
      const rulesToInsert = [...this.pendingRules];
      this.pendingRules = [];

      rulesToInsert.forEach((selector) => {
        this._insertRule(selector);
      });
    }

    /**
     * Helper to insert a single rule into the stylesheet
     * @param {string} selector
     */
    _insertRule(selector) {
      try {
        const index = this.sheet.cssRules.length;
        const ruleText = `${selector} { animation-duration: 0.001s; animation-name: ${this.animationName}; }`;
        this.sheet.insertRule(ruleText, index);
        // Associate the inserted rule with the selector via WeakMap for safer removal later.
        // This mimics sentinel.js behavior to handle index shifts and selector normalization.
        const insertedRule = this.sheet.cssRules[index];
        if (insertedRule) {
          this.ruleSelectors.set(insertedRule, selector);
        }
      } catch (e) {
        console.error(`${LOG_PREFIX} [Sentinel] Failed to insert rule for selector "${selector}":`, e);
      }
    }

    _handleAnimationStart(event) {
      if (this.isDestroyed) return;

      // Check if the animation is the one we're listening for.
      if (event.animationName !== this.animationName) return;

      const target = event.target;
      if (!(target instanceof Element)) {
        return;
      }

      // Check if the target element matches any of this instance's selectors.
      for (const [selector, callbacks] of this.listeners.entries()) {
        if (target.matches(selector)) {
          // Use a copy of the callbacks Set in case a callback removes itself.
          [...callbacks].forEach((cb) => {
            try {
              cb(target);
            } catch (e) {
              console.error(`${LOG_PREFIX} [Sentinel] Listener error for selector "${selector}":`, e);
            }
          });
        }
      }
    }

    /**
     * @param {string} selector
     * @param {(element: Element) => void} callback
     */
    on(selector, callback) {
      if (this.isDestroyed) return;
      this._ensureStyleGuard();

      // Add callback to listeners

      if (!this.listeners.has(selector)) {
        this.listeners.set(selector, new Set());
      }
      this.listeners.get(selector).add(callback);
      // If selector is already registered in rules, do nothing
      if (this.rules.has(selector)) return;
      this.rules.add(selector);

      // Apply rule
      if (this.sheet) {
        this._insertRule(selector);
      } else {
        this.pendingRules.push(selector);
      }
    }

    /**
     * @param {string} selector
     * @param {(element: Element) => void} callback
     */
    off(selector, callback) {
      if (this.isDestroyed) return;
      const callbacks = this.listeners.get(selector);
      if (!callbacks) return;

      const wasDeleted = callbacks.delete(callback);
      if (!wasDeleted) {
        return;
        // Callback not found, do nothing.
      }

      if (callbacks.size === 0) {
        // Remove listener and rule
        this.listeners.delete(selector);
        this.rules.delete(selector);

        if (this.sheet) {
          // Iterate backwards to avoid index shifting issues during deletion
          for (let i = this.sheet.cssRules.length - 1; i >= 0; i--) {
            const rule = this.sheet.cssRules[i];
            // Check for recorded selector via WeakMap or fallback to selectorText match
            const recordedSelector = this.ruleSelectors.get(rule);
            if (recordedSelector === selector || (rule instanceof CSSStyleRule && rule.selectorText === selector)) {
              try {
                this.sheet.deleteRule(i);
              } catch (e) {
                console.error(`${LOG_PREFIX} [Sentinel] Failed to delete rule for selector "${selector}":`, e);
              }
              // We assume one rule per selector, so we can break after deletion
              break;
            }
          }
        }
      }
    }

    suspend() {
      if (this.isDestroyed) return;
      this.isSuspended = true;
      if (this.styleElement instanceof HTMLStyleElement) {
        this.styleElement.disabled = true;
      }
      console.debug(`${LOG_PREFIX} [Sentinel] Suspended.`);
    }

    resume() {
      if (this.isDestroyed) return;
      this.isSuspended = false;
      if (this.styleElement instanceof HTMLStyleElement) {
        this.styleElement.disabled = false;
      }
      console.debug(`${LOG_PREFIX} [Sentinel] Resumed.`);
    }
  }

  /**
   * @class AppController
   * @description Encapsulates global state and logic to enforce the target model.
   */
  class AppController {
    #isSetting = false;
    #isVisibilityCheckEnabled = false;
    #setFailedForCurrentContext = false;
    #isSettledForCurrentContext = false;
    #isThinkingSettledForCurrentContext = false;
    #targetText = CONSTANTS.TARGET_TEXT;
    #targetThinkingText = CONSTANTS.TARGET_THINKING_TEXT;
    #visibilityMenuCommandId = null;
    #targetMenuCommandId = null;
    #thinkingMenuCommandId = null;
    #abortController = null;
    #sentinel = null;

    /**
     * Validates if a given string is a valid regular expression.
     * @param {string} pattern
     * @returns {boolean}
     */
    #isValidRegex(pattern) {
      try {
        new RegExp(pattern);
        return true;
      } catch {
        return false;
      }
    }

    /**
     * Checks if the given text matches the target regex pattern (case-insensitive).
     * @param {string} text
     * @returns {boolean}
     */
    #isMatch(text) {
      try {
        const rx = new RegExp(this.#targetText, 'i');
        return rx.test(text);
      } catch {
        return false;
      }
    }

    /**
     * Checks if the given text matches the target thinking level regex pattern (case-insensitive).
     * @param {string} text
     * @returns {boolean}
     */
    #isThinkingMatch(text) {
      if (!this.#targetThinkingText) return true;
      try {
        const rx = new RegExp(this.#targetThinkingText, 'i');
        return rx.test(text);
      } catch {
        return false;
      }
    }

    /**
     * Schedules fallback state checks to handle delayed DOM updates in SPAs.
     */
    #scheduleFallbackChecks() {
      this.#setFailedForCurrentContext = false;
      CONSTANTS.TIMING.FALLBACK_DELAYS_MS.forEach((delay) => {
        setTimeout(() => this.#checkAndEnforce(), delay);
      });
    }

    /**
     * Initialize the setter state and set up event-driven observation.
     * Uses Sentinel (CSS Animation) for new elements and Navigation API for URL changes.
     */
    async init() {
      this.#isVisibilityCheckEnabled = await GM.getValue(CONSTANTS.STORAGE.VISIBILITY_CHECK_KEY, false);
      this.#targetText = await GM.getValue(CONSTANTS.STORAGE.TARGET_TEXT_KEY, CONSTANTS.TARGET_TEXT);
      this.#targetThinkingText = await GM.getValue(CONSTANTS.STORAGE.TARGET_THINKING_TEXT_KEY, CONSTANTS.TARGET_THINKING_TEXT);

      await this.#updateMenuCommand();

      // Initialize Sentinel for DOM insertion detection
      this.#sentinel = new Sentinel(OWNERID);

      // Trigger check whenever the mode label is freshly inserted into the DOM
      this.#sentinel.on(CONSTANTS.SELECTORS.CURRENT_MODE_LABEL, () => {
        console.debug(`${LOG_PREFIX} Element detected via Sentinel, checking state...`);
        this.#checkAndEnforce();
      });

      // Monitor URL changes
      const originalPushState = history.pushState;
      history.pushState = function (...args) {
        originalPushState.apply(this, args);
        window.dispatchEvent(new Event('locationchange'));
      };

      const originalReplaceState = history.replaceState;
      history.replaceState = function (...args) {
        originalReplaceState.apply(this, args);
        window.dispatchEvent(new Event('locationchange'));
      };

      window.addEventListener('popstate', () => {
        window.dispatchEvent(new Event('locationchange'));
      });

      let currentPath = window.location.pathname;

      window.addEventListener('locationchange', () => {
        const newPath = window.location.pathname;
        // Reset settled state and re-check only if the URL path actually changed
        if (newPath !== currentPath) {
          currentPath = newPath;
          this.#isSettledForCurrentContext = false;
          this.#isThinkingSettledForCurrentContext = false;
          this.#scheduleFallbackChecks();
        }
      });

      // Fail-safe: Re-check state when the page is re-focused or becomes visible.
      document.addEventListener('visibilitychange', () => {
        if (document.visibilityState === 'visible' && this.#isVisibilityCheckEnabled) {
          console.debug(`${LOG_PREFIX} Tab became visible, verifying state...`);
          this.#isSettledForCurrentContext = false;
          this.#isThinkingSettledForCurrentContext = false;
          this.#scheduleFallbackChecks();
        }
      });

      this.#checkAndEnforce();
    }

    /**
     * Update the Tampermonkey menu command label based on the current state
     */
    async #updateMenuCommand() {
      // Unregister existing commands concurrently to avoid race conditions and improve performance
      await Promise.all([
        this.#targetMenuCommandId !== null ? GM.unregisterMenuCommand(this.#targetMenuCommandId) : Promise.resolve(),
        this.#thinkingMenuCommandId !== null ? GM.unregisterMenuCommand(this.#thinkingMenuCommandId) : Promise.resolve(),
        this.#visibilityMenuCommandId !== null ? GM.unregisterMenuCommand(this.#visibilityMenuCommandId) : Promise.resolve(),
      ]);

      const visibilityStateText = this.#isVisibilityCheckEnabled ? `🟢 Auto-Check on Re-focus: ON` : `🔴 Auto-Check on Re-focus: OFF`;
      const visibilityTooltipText = this.#isVisibilityCheckEnabled ? 'Click to disable checking the model when returning to this page' : 'Click to enable checking the model when returning to this page';

      // Register all menu commands simultaneously via Promise.all to preserve execution and display order
      const [targetId, thinkingId, visibilityId] = await Promise.all([
        GM.registerMenuCommand(
          `⚙️ Set Target Model Name: ${this.#targetText}`,
          () => {
            this.#showSettingsModal(CONSTANTS.FOCUS_TARGETS.MODEL);
          },
          { title: 'Set the target model name to fix' }
        ),
        GM.registerMenuCommand(
          `⚙️ Set Thinking Model Name: ${this.#targetThinkingText || '(None)'}`,
          () => {
            this.#showSettingsModal(CONSTANTS.FOCUS_TARGETS.THINKING);
          },
          { title: 'Set the target thinking level to fix' }
        ),
        GM.registerMenuCommand(
          visibilityStateText,
          async () => {
            this.#isVisibilityCheckEnabled = !this.#isVisibilityCheckEnabled;
            await GM.setValue(CONSTANTS.STORAGE.VISIBILITY_CHECK_KEY, this.#isVisibilityCheckEnabled);
            console.info(`${LOG_PREFIX} Visibility check state changed: ${this.#isVisibilityCheckEnabled ? 'ON' : 'OFF'}`);
            await this.#updateMenuCommand();
          },
          { title: visibilityTooltipText }
        ),
      ]);

      this.#targetMenuCommandId = targetId;
      this.#thinkingMenuCommandId = thinkingId;
      this.#visibilityMenuCommandId = visibilityId;
    }

    /**
     * Check current state and enforce Pro mode if necessary
     */
    async #checkAndEnforce() {
      if (this.#isSetting || this.#setFailedForCurrentContext) return;

      const currentText = document.querySelector(CONSTANTS.SELECTORS.CURRENT_MODE_LABEL)?.textContent?.trim();

      if (!currentText) return;

      let needsModelChange = false;
      if (!this.#isSettledForCurrentContext) {
        if (this.#isMatch(currentText)) {
          this.#isSettledForCurrentContext = true;
        } else {
          needsModelChange = true;
        }
      }

      let needsThinkingChange = false;
      if (this.#targetThinkingText && !this.#isThinkingSettledForCurrentContext) {
        needsThinkingChange = true;
      } else {
        this.#isThinkingSettledForCurrentContext = true;
      }

      if (needsModelChange || needsThinkingChange) {
        await this.#applyTargetModel();
      }
    }

    /**
     * Shows a modal to configure the target model name.
     */
    #showSettingsModal(focusTarget) {
      const dialog = document.createElement('dialog');
      const style = document.createElement('style');
      style.textContent = `
.${APPID}-modal-btn {
background: ${PALETTE.btn_bg};
color: ${PALETTE.btn_text};
border: 1px solid ${PALETTE.border};
border-radius: 20px;
padding: 8px 16px;
cursor: pointer;
font-family: inherit;
font-size: 14px;
transition: background 0.2s;
flex: 1;
}
.${APPID}-modal-btn:hover:not(:disabled) {
background: ${PALETTE.btn_hover_bg};
}
.${APPID}-modal-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.${APPID}-modal-input {
width: 100%;
box-sizing: border-box;
padding: 10px;
border: 1px solid ${PALETTE.border};
border-radius: 8px;
background: ${PALETTE.input_bg};
color: ${PALETTE.text_primary};
font-size: 14px;
font-family: inherit;
margin-bottom: 8px;
}
.${APPID}-modal-input:focus {
outline: 2px solid ${PALETTE.accent_text};
outline-offset: -1px;
}
.${APPID}-modal-text-small {
font-size: 15px;
line-height: 1.5;
white-space: pre-wrap;
color: ${PALETTE.text_secondary};
margin-bottom: 16px;
display: block;
}
.${APPID}-modal-status {
font-size: 13px;
font-weight: bold;
margin-bottom: 24px;
min-height: 18px;
}
`;
      dialog.appendChild(style);

      dialog.style.cssText = `
padding: 24px;
border: 1px solid ${PALETTE.border};
border-radius: 12px;
box-shadow: 0 4px 6px rgb(0 0 0 / 0.1);
font-family: sans-serif;
background: ${PALETTE.bg};
color: ${PALETTE.text_primary};
width: 380px;
`;

      const title = document.createElement('h3');
      title.textContent = `${APPNAME}`;
      title.style.margin = '0 0 8px 0';
      dialog.appendChild(title);

      const desc = document.createElement('span');
      desc.className = `${APPID}-modal-text-small`;
      desc.textContent = 'Use Regular Expression (case-insensitive).\nJust enter the pattern itself (do not enclose in "/").';
      dialog.appendChild(desc);

      // --- Model ---
      const patternLabel = document.createElement('label');
      patternLabel.textContent = 'Model:';
      patternLabel.style.cssText = 'display:block; font-size:13px; margin-bottom:4px; font-weight:bold;';
      dialog.appendChild(patternLabel);

      const modelDesc = document.createElement('span');
      modelDesc.className = `${APPID}-modal-text-small`;
      modelDesc.style.cssText = `font-size: 13px; margin-bottom: 8px;`;
      modelDesc.textContent = 'e.g., Pro, Flash$';
      dialog.appendChild(modelDesc);

      const input = document.createElement('input');
      input.type = 'text';
      input.value = this.#targetText;
      input.className = `${APPID}-modal-input`;
      dialog.appendChild(input);

      // --- Thinking Level ---
      const thinkingPatternLabel = document.createElement('label');
      thinkingPatternLabel.textContent = 'Thinking Level:';
      thinkingPatternLabel.style.cssText = 'display:block; font-size:13px; margin-bottom:4px; font-weight:bold; margin-top:12px;';
      dialog.appendChild(thinkingPatternLabel);

      const thinkingDesc = document.createElement('span');
      thinkingDesc.className = `${APPID}-modal-text-small`;
      thinkingDesc.style.cssText = `font-size: 13px; margin-bottom: 8px;`;
      thinkingDesc.textContent = 'Leave blank to do nothing and keep current setting unchanged. (e.g., Extended, Deep Think)';
      dialog.appendChild(thinkingDesc);

      const thinkingInput = document.createElement('input');
      thinkingInput.type = 'text';
      thinkingInput.value = this.#targetThinkingText;
      thinkingInput.className = `${APPID}-modal-input`;
      dialog.appendChild(thinkingInput);

      const statusDisplay = document.createElement('div');
      statusDisplay.className = `${APPID}-modal-status`;
      dialog.appendChild(statusDisplay);

      const buttonContainer = document.createElement('div');
      buttonContainer.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
`;

      const defaultBtn = document.createElement('button');
      defaultBtn.textContent = 'Default';
      defaultBtn.className = `${APPID}-modal-btn`;
      defaultBtn.onclick = () => {
        input.value = CONSTANTS.TARGET_TEXT;
        thinkingInput.value = CONSTANTS.TARGET_THINKING_TEXT;
        updateStatus();
      };

      const cancelBtn = document.createElement('button');
      cancelBtn.textContent = 'Cancel';
      cancelBtn.className = `${APPID}-modal-btn`;
      cancelBtn.onclick = () => {
        dialog.close();
        dialog.remove();
      };

      const saveBtn = document.createElement('button');
      saveBtn.textContent = 'Save';
      saveBtn.className = `${APPID}-modal-btn`;

      const updateStatus = () => {
        const pattern = input.value;
        const thinkingPattern = thinkingInput.value;

        if (pattern.includes('/') || thinkingPattern.includes('/')) {
          statusDisplay.textContent = '⚠️ Do not use "/"';
          statusDisplay.style.color = PALETTE.danger_text;
          saveBtn.disabled = true;
          return;
        }

        if (!this.#isValidRegex(pattern) || (thinkingPattern && !this.#isValidRegex(thinkingPattern))) {
          statusDisplay.textContent = '⚠️ Invalid Regex';
          statusDisplay.style.color = PALETTE.danger_text;
          saveBtn.disabled = true;
          return;
        }

        saveBtn.disabled = false;
        statusDisplay.textContent = '✅ Valid format';
        statusDisplay.style.color = PALETTE.success_text;
      };

      input.addEventListener('input', updateStatus);
      thinkingInput.addEventListener('input', updateStatus);

      saveBtn.onclick = async () => {
        const newVal = input.value.trim();
        const newThinkingVal = thinkingInput.value.trim();

        if (newVal && this.#isValidRegex(newVal) && (!newThinkingVal || this.#isValidRegex(newThinkingVal))) {
          this.#targetText = newVal;
          this.#targetThinkingText = newThinkingVal;

          await GM.setValue(CONSTANTS.STORAGE.TARGET_TEXT_KEY, newVal);
          await GM.setValue(CONSTANTS.STORAGE.TARGET_THINKING_TEXT_KEY, newThinkingVal);

          console.info(`${LOG_PREFIX} Target model name updated to: ${newVal}, Thinking Level to: ${newThinkingVal || '(None)'}`);

          this.#setFailedForCurrentContext = false;
          this.#isSettledForCurrentContext = false;
          this.#isThinkingSettledForCurrentContext = false;

          await this.#updateMenuCommand();
          this.#checkAndEnforce();
          dialog.close();
          dialog.remove();
        }
      };

      updateStatus();

      buttonContainer.appendChild(defaultBtn);
      buttonContainer.appendChild(cancelBtn);
      buttonContainer.appendChild(saveBtn);
      dialog.appendChild(buttonContainer);

      document.body.appendChild(dialog);
      dialog.showModal();

      // Ensure focus on the target input field after the modal is displayed
      if (focusTarget === CONSTANTS.FOCUS_TARGETS.THINKING) {
        thinkingInput.focus();
        thinkingInput.select();
      } else {
        input.focus();
        input.select();
      }
    }

    /**
     * Open the menu, select the target model, and handle exceptions/refocus.
     * Enforces menu state checks to prevent toggling conflicts and logs missing elements.
     * @returns {Promise<void>}
     */
    async #applyTargetModel() {
      if (this.#isSetting) return;

      this.#isSetting = true;
      this.#abortController = new AbortController();
      const signal = this.#abortController.signal;

      const getMenuButton = () => document.querySelector(CONSTANTS.SELECTORS.MENU_BUTTON);
      const isMenuOpen = (btn) => btn?.getAttribute(CONSTANTS.ATTRIBUTES.ARIA_EXPANDED) === CONSTANTS.ATTRIBUTES.TRUE;

      try {
        const menuButton = getMenuButton();
        if (!(menuButton instanceof HTMLElement)) {
          console.debug(`${LOG_PREFIX} Menu button not found or not an HTMLElement.`);
          return;
        }

        // --- Step 1: Model check and enforce ---
        if (!this.#isSettledForCurrentContext) {
          if (!isMenuOpen(menuButton)) {
            menuButton.click();
          }

          // Wait for the menu to render ANY items (lazy rendering detection)
          let items = [];
          for (let i = 0; i < CONSTANTS.TIMING.MENU_POLL_MAX_ATTEMPTS; i++) {
            if (signal.aborted) return;
            // Ensure we only collect items that are physically visible on the DOM
            items = Array.from(document.querySelectorAll(CONSTANTS.SELECTORS.MENU_ITEMS)).filter((el) => el instanceof HTMLElement && el.offsetParent !== null);
            if (items.length > 0) break; // Menu has rendered
            await new Promise((resolve) => setTimeout(resolve, CONSTANTS.TIMING.MENU_POLL_INTERVAL_MS));
          }

          // Search for the target model among the rendered items using Regex
          const targetBtn = items.find((el) => {
            const labelEl = el.querySelector(CONSTANTS.SELECTORS.ITEM_LABEL);
            const textToMatch = labelEl ? labelEl.textContent : el.textContent;
            return this.#isMatch(textToMatch.trim());
          });

          // Fail-safe: If the target model button is not found (either menu didn't render or model doesn't exist),
          // abort and intentionally leave the menu open as a visual indicator of an invalid target.
          if (!(targetBtn instanceof HTMLElement)) {
            console.warn(`${LOG_PREFIX} Target model matching pattern "${this.#targetText}" not found in menu.`);
            this.#setFailedForCurrentContext = true;
            this.#isSettledForCurrentContext = true;
            return; // Abort thinking level check if the target model is not found
          }

          const btn = targetBtn.closest(CONSTANTS.SELECTORS.BUTTON_TAG) ?? targetBtn;

          if (btn instanceof HTMLElement && !btn.matches(CONSTANTS.SELECTORS.DISABLED_STATE)) {
            btn.click();

            const inputField = await waitForElement(CONSTANTS.SELECTORS.INPUT_TEXT_FIELD_TARGET, CONSTANTS.TIMING.FOCUS_POLL_INTERVAL_MS, CONSTANTS.TIMING.FOCUS_POLL_MAX_ATTEMPTS, signal);
            if (inputField instanceof HTMLElement && inputField.offsetParent !== null && !signal.aborted) {
              inputField.focus();
            }

            // Force re-check because changing the model resets the thinking level to default
            this.#isThinkingSettledForCurrentContext = false;
          } else if (btn instanceof HTMLElement) {
            btn.click(); // Close the menu if already selected
          }
          this.#isSettledForCurrentContext = true;
        }

        // --- Step 2: Thinking Level check and enforce ---
        if (this.#targetThinkingText && !this.#isThinkingSettledForCurrentContext) {
          if (!isMenuOpen(menuButton)) {
            menuButton.click();
          }

          let thinkingItem = null;
          for (let i = 0; i < CONSTANTS.TIMING.MENU_POLL_MAX_ATTEMPTS; i++) {
            if (signal.aborted) return;
            thinkingItem = document.querySelector(CONSTANTS.SELECTORS.THINKING_MENU_ITEM);
            if (thinkingItem instanceof HTMLElement && thinkingItem.offsetParent !== null) break;
            await new Promise((resolve) => setTimeout(resolve, CONSTANTS.TIMING.MENU_POLL_INTERVAL_MS));
          }

          if (thinkingItem instanceof HTMLElement) {
            const sublabel = thinkingItem.querySelector(CONSTANTS.SELECTORS.THINKING_SUBLABEL);
            const currentThinking = sublabel ? sublabel.textContent.trim() : '';

            if (this.#isThinkingMatch(currentThinking)) {
              // Close the menu without expanding further if the current value matches
              if (isMenuOpen(menuButton)) {
                menuButton.click();
              }
            } else {
              // Expand the sub-menu if it does not match
              thinkingItem.click();

              let subItems = [];
              for (let i = 0; i < CONSTANTS.TIMING.MENU_POLL_MAX_ATTEMPTS; i++) {
                if (signal.aborted) return;
                // Search for gem-menu-item excluding the parent (value="thinking_level") to avoid conflicts
                subItems = Array.from(document.querySelectorAll(`${CONSTANTS.SELECTORS.MENU_ITEM_TAG}:not(${CONSTANTS.SELECTORS.THINKING_MENU_ITEM})`)).filter((el) => {
                  if (!(el instanceof HTMLElement) || el.offsetParent === null) return false;
                  const labelEl = el.querySelector(CONSTANTS.SELECTORS.ITEM_LABEL);
                  const textToMatch = labelEl ? labelEl.textContent.trim() : el.textContent.trim();
                  return this.#isThinkingMatch(textToMatch);
                });
                if (subItems.length > 0) break;
                await new Promise((resolve) => setTimeout(resolve, CONSTANTS.TIMING.MENU_POLL_INTERVAL_MS));
              }

              if (subItems.length > 0) {
                const targetSubBtn = subItems[0].closest(CONSTANTS.SELECTORS.BUTTON_TAG) ?? subItems[0];
                if (targetSubBtn instanceof HTMLElement) {
                  targetSubBtn.click();

                  const inputField = await waitForElement(CONSTANTS.SELECTORS.INPUT_TEXT_FIELD_TARGET, CONSTANTS.TIMING.FOCUS_POLL_INTERVAL_MS, CONSTANTS.TIMING.FOCUS_POLL_MAX_ATTEMPTS, signal);
                  if (inputField instanceof HTMLElement && inputField.offsetParent !== null && !signal.aborted) {
                    inputField.focus();
                  }
                }
              } else {
                console.warn(`${LOG_PREFIX} Target thinking level matching pattern "${this.#targetThinkingText}" not found in sub-menu.`);
                // Close the menu if the target is not found
                if (isMenuOpen(menuButton)) {
                  menuButton.click();
                }
              }
            }
          }
          this.#isThinkingSettledForCurrentContext = true;
        }
      } finally {
        // Cleanup: Always release the fixing lock (#isFixing) and clear the AbortController,
        // regardless of whether the operation succeeded, failed, or threw an exception, to allow future executions.
        this.#isSetting = false;
        this.#abortController = null;
      }
    }
  }

  // --- Entry Point ---
  const setter = new AppController();
  setter.init();
})();