texpander-ai

AI-powered text expander with Gemini integration

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         texpander-ai
// @namespace    https://github.com/quantavil/texpander-ai
// @version      3.0.0
// @author       quantavil
// @description  AI-powered text expander with Gemini integration
// @license      MIT
// @match        *://*/*
// @connect      generativelanguage.googleapis.com
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  var _GM_addStyle = (() => typeof GM_addStyle != "undefined" ? GM_addStyle : void 0)();
  var _GM_getValue = (() => typeof GM_getValue != "undefined" ? GM_getValue : void 0)();
  var _GM_registerMenuCommand = (() => typeof GM_registerMenuCommand != "undefined" ? GM_registerMenuCommand : void 0)();
  var _GM_setValue = (() => typeof GM_setValue != "undefined" ? GM_setValue : void 0)();
  var _GM_xmlhttpRequest = (() => typeof GM_xmlhttpRequest != "undefined" ? GM_xmlhttpRequest : void 0)();
  const STORE_KEYS = {
    dict: "sae.dict.v1",
    keys: "sae.keys.v1",
    apiKey: "sae.gemini.apiKey.v1",
    customPrompts: "sae.prompts.v1",
    disabledBuiltins: "sae.disabledBuiltins.v1"
  };
  const CONFIG = {
    trigger: { code: "Space", shift: true },
    palette: { code: "KeyP", alt: true },
    aiMenu: { code: "KeyG", alt: true },
    maxAbbrevLen: 80,
    styleId: "sae-styles",
    clipboardReadTimeoutMs: 350,
    searchDebounceMs: 50,
    toast: { defaultMs: 2200, shortMs: 1200 },
    ui: {
      menuWidth: 360,
      menuHeight: 260,
      previewMaxChars: 120,
      spacing: { sm: 8, md: 16 },
      inlinePrompts: 4
    },
    gemini: {
      endpoint: "https://generativelanguage.googleapis.com/v1beta/models",
      model: "gemini-2.5-flash-lite",
      temperature: 0.15,
      timeoutMs: 2e4,
      maxInputChars: 32e3
    }
  };
  const BUILTIN_PROMPTS = [
    { id: "grammar", label: "Fix Grammar", prompt: "Fix grammar, spelling, and punctuation. Improve clarity. Preserve meaning and original language. Return only the corrected text, no explanations." },
    { id: "expand", label: "Expand", prompt: "Expand this text with more detail, examples, and depth. Maintain the original tone. Return only the expanded text." },
    { id: "summarize", label: "Summarize", prompt: "Summarize this text concisely in 2-3 sentences. Capture key points. Return only the summary." },
    { id: "formal", label: "Formal", prompt: "Rewrite in a formal, professional tone suitable for business communication. Return only the rewritten text." },
    { id: "friendly", label: "Friendly", prompt: "Rewrite in a warm, friendly, conversational tone. Return only the rewritten text." },
    { id: "concise", label: "Concise", prompt: "Make this shorter and more direct. Remove unnecessary words. Return only the concise text." }
  ];
  const DEFAULT_DICT = {
    brb: "Be right back.",
    ty: "Thank you!",
    hth: "Hope this helps!",
    opt: "Optional: {{cursor}}",
    log: "Log Entry - {{date:iso}} {{time}}: {{cursor}}",
    track: "The tracking number for your order is {{clipboard}}. {{cursor}}",
    dt: "Today is {{day}}, {{date:long}} at {{time}}."
  };
  const GMX = {
    get: (key, def) => _GM_getValue(key, def),
    set: (key, val) => _GM_setValue(key, val),
    menu: (title, fn) => _GM_registerMenuCommand(title, fn),
    request: (opts) => new Promise((resolve, reject) => {
      _GM_xmlhttpRequest({
        method: opts.method ?? "GET",
        url: opts.url,
        headers: opts.headers ?? {},
        data: opts.data,
        timeout: opts.timeout ?? CONFIG.gemini.timeoutMs,
        onload: (r) => resolve({ status: r.status, text: r.responseText }),
        onerror: () => reject(new Error("Network error")),
        ontimeout: () => reject(new Error("Timeout"))
      });
    })
  };
  const state = {
    dict: {},
    apiKey: "",
    apiKeyIndex: 0,
    customPrompts: [],
    disabledBuiltins: [],
    lastEditableEl: null,
    paletteIndex: 0,
    aiMenuIndex: 0,
    hotkeys: { palette: { ...CONFIG.palette }, aiMenu: { ...CONFIG.aiMenu } }
  };
  const isBuiltinEnabled = (id) => !state.disabledBuiltins.includes(id);
  const isCustomEnabled = (p) => p.enabled !== false;
  const getAllPrompts = () => [...BUILTIN_PROMPTS, ...state.customPrompts];
  function normalizeDict(obj) {
    if (!obj || typeof obj !== "object") return {};
    return Object.fromEntries(
      Object.entries(obj).filter(([k, v]) => typeof k === "string" && typeof v === "string" && k.trim()).map(([k, v]) => [k.trim().toLowerCase(), v])
    );
  }
  function loadState() {
    state.dict = normalizeDict(GMX.get(STORE_KEYS.dict, DEFAULT_DICT));
    if (!Object.keys(state.dict).length) state.dict = normalizeDict(DEFAULT_DICT);
    state.apiKey = GMX.get(STORE_KEYS.apiKey, "");
    state.customPrompts = GMX.get(STORE_KEYS.customPrompts, []).map((p) => ({ ...p, enabled: p.enabled !== false }));
    state.disabledBuiltins = GMX.get(STORE_KEYS.disabledBuiltins, []);
    const saved = GMX.get(STORE_KEYS.keys, {});
    if (saved.palette) state.hotkeys.palette = saved.palette;
    if (saved.aiMenu) state.hotkeys.aiMenu = saved.aiMenu;
  }
  const $ = (s, r = document) => r.querySelector(s);
  const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
  const debounce = (fn, ms) => {
    let t;
    return (...a) => {
      clearTimeout(t);
      t = setTimeout(() => fn(...a), ms);
    };
  };
  const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
  const genId = () => "c" + Math.random().toString(36).slice(2, 9);
  const p2 = (n) => String(n).padStart(2, "0");
  const ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
  const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ESC[c] ?? c);
  const isWord = (c) => /[\p{L}\p{N}_-]/u.test(c);
  const safeFocus = (el) => {
    if (!el) return false;
    try {
      el.focus({ preventScroll: true });
      return true;
    } catch {
      return false;
    }
  };
  function dispatchInput(el, data) {
    el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertReplacementText", data }));
  }
  const EDITABLE_TYPES = new Set(["text", "search", "url", "email", "tel"]);
  function getEditable(el) {
    if (!el || !(el instanceof HTMLElement)) return null;
    if (el instanceof HTMLTextAreaElement) return el;
    if (el instanceof HTMLInputElement) return EDITABLE_TYPES.has((el.type || "text").toLowerCase()) ? el : null;
    let curr = el;
    while (curr && curr !== document.documentElement) {
      if (curr.nodeType === 1 && curr.isContentEditable) return curr;
      curr = curr.parentElement ?? (curr.parentNode instanceof ShadowRoot ? curr.parentNode.host : null);
    }
    return null;
  }
  function captureContext() {
    let active = document.activeElement;
    while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
    const el = getEditable(active);
    if (!el) return null;
    if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement)
      return { kind: "input", el, start: el.selectionStart ?? 0, end: el.selectionEnd ?? 0 };
    const sel = window.getSelection();
    if (!sel?.rangeCount) return null;
    return { kind: "ce", root: el, range: sel.getRangeAt(0).cloneRange() };
  }
  function getContextOrFallback() {
    const ctx = captureContext();
    if (ctx) return ctx;
    if (state.lastEditableEl?.isConnected) {
      safeFocus(state.lastEditableEl);
      return captureContext();
    }
    return null;
  }
  function makeEditor(ctx) {
    if (!ctx) return null;
    if (ctx.kind === "input") {
      const el = ctx.el;
      return {
        getText() {
          const { selectionStart: s, selectionEnd: e } = el;
          return s !== e ? el.value.slice(s, e) : el.value;
        },
        replace(text) {
          const s = el.selectionStart ?? 0, e = el.selectionEnd ?? 0;
          const [start, end] = s !== e ? [s, e] : [0, el.value.length];
          el.setRangeText(text, start, end, "end");
          dispatchInput(el, text);
        }
      };
    }
    const root = ctx.root;
    return {
      getText() {
        const sel = window.getSelection();
        if (sel?.rangeCount && !sel.isCollapsed) return sel.toString();
        const r = document.createRange();
        r.selectNodeContents(root);
        return r.toString();
      },
      replace(text) {
        const sel = window.getSelection();
        if (!sel) return;
        if (sel.isCollapsed) {
          const r = document.createRange();
          r.selectNodeContents(root);
          sel.removeAllRanges();
          sel.addRange(r);
        }
        document.execCommand("insertText", false, text);
        dispatchInput(root, text);
      }
    };
  }
  const fmtDate = (d, a = "iso") => {
    const f = a.toLowerCase();
    if (f === "long") return d.toLocaleDateString(void 0, { year: "numeric", month: "long", day: "numeric" });
    if (f === "short") return d.toLocaleDateString();
    if (f === "mdy" || f === "us") return `${p2(d.getMonth() + 1)}/${p2(d.getDate())}/${d.getFullYear()}`;
    if (f === "dmy") return `${p2(d.getDate())}/${p2(d.getMonth() + 1)}/${d.getFullYear()}`;
    return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`;
  };
  const fmtTime = (d, a = "12") => {
    if (a === "24" || a === "24h") return `${p2(d.getHours())}:${p2(d.getMinutes())}`;
    let h = d.getHours();
    const ap = h >= 12 ? "PM" : "AM";
    h = h % 12 || 12;
    return `${p2(h)}:${p2(d.getMinutes())} ${ap}`;
  };
  async function readClip() {
    try {
      return await Promise.race([
        navigator.clipboard?.readText() ?? Promise.resolve(""),
        new Promise((r) => setTimeout(() => r(""), CONFIG.clipboardReadTimeoutMs))
      ]);
    } catch {
      return "";
    }
  }
  const TAGS = {
    cursor: async () => ({ text: "", cursor: true }),
    date: async (a, n) => ({ text: fmtDate(n, a) }),
    time: async (a, n) => ({ text: fmtTime(n, a) }),
    day: async (a, n) => ({ text: n.toLocaleDateString(void 0, { weekday: a === "short" ? "short" : "long" }) }),
    clipboard: async () => ({ text: await readClip() })
  };
  async function renderTemplate(tmpl) {
    const re = /\{\{\s*(\w+)(?::([^}]+))?\s*\}\}/g;
    const matches = [...tmpl.matchAll(re)];
    if (!matches.length) return { text: tmpl, cursor: tmpl.length };
    const now = new Date();
    const results = await Promise.all(matches.map((m) => {
      const handler = TAGS[m[1].toLowerCase()];
      return handler ? handler((m[2] || "").trim(), now) : null;
    }));
    let out = "", cursor = -1, idx = 0;
    for (let i = 0; i < matches.length; i++) {
      const m = matches[i];
      out += tmpl.slice(idx, m.index);
      idx = m.index + m[0].length;
      const res = results[i];
      if (!res) {
        out += m[0];
        continue;
      }
      if (res.cursor && cursor < 0) cursor = out.length;
      out += res.text;
    }
    out += tmpl.slice(idx);
    return { text: out, cursor: cursor >= 0 ? cursor : out.length };
  }
  function getTextBefore(root, range, max) {
    const r = document.createRange();
    r.setStart(root, 0);
    r.setEnd(range.startContainer, range.startOffset);
    return r.toString().slice(-max);
  }
  function scanToken(text, maxLen) {
    let i = text.length;
    while (i > 0 && isWord(text[i - 1]) && text.length - i < maxLen) i--;
    return text.slice(i);
  }
  function textPosToNode(root, pos) {
    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
    let remaining = pos;
    let node;
    while (node = walker.nextNode()) {
      const len = node.nodeValue.length;
      if (remaining <= len) return { node, offset: remaining };
      remaining -= len;
    }
    return null;
  }
  function findToken(ctx) {
    if (ctx.kind === "input") {
      if (ctx.start !== ctx.end) return null;
      const text2 = ctx.el.value.slice(0, ctx.start);
      const token2 = scanToken(text2, CONFIG.maxAbbrevLen);
      return token2 ? { token: token2, tokenStart: ctx.start - token2.length } : null;
    }
    const sel = window.getSelection();
    if (!sel?.rangeCount || !sel.isCollapsed) return null;
    const r = sel.getRangeAt(0);
    const text = getTextBefore(ctx.root, r, CONFIG.maxAbbrevLen);
    const token = scanToken(text, CONFIG.maxAbbrevLen);
    if (!token) return null;
    const fullText = getTextBefore(ctx.root, r, Infinity);
    const tokenStartPos = fullText.length - token.length;
    const tokenEndPos = fullText.length;
    const start = textPosToNode(ctx.root, tokenStartPos);
    const end = textPosToNode(ctx.root, tokenEndPos);
    if (!start || !end) return null;
    const tokenRange = document.createRange();
    tokenRange.setStart(start.node, start.offset);
    tokenRange.setEnd(end.node, end.offset);
    return { token, tokenRange };
  }
  function peekToken(ctx) {
    const m = findToken(ctx);
    return m?.token && m.token.length <= CONFIG.maxAbbrevLen ? m.token : null;
  }
  async function doExpansion(preCtx) {
    const ctx = preCtx ?? captureContext();
    if (!ctx) return;
    const match = findToken(ctx);
    if (!match) return;
    const { token } = match;
    const tmpl = state.dict[token.toLowerCase()];
    if (!tmpl) return;
    const snapshot = ctx.kind === "input" ? ctx.el.value : null;
    const rendered = await renderTemplate(tmpl);
    if (ctx.kind === "input") {
      if (ctx.el.value !== snapshot) return;
      const start = match.tokenStart;
      ctx.el.setRangeText(rendered.text, start, ctx.start, "end");
      ctx.el.selectionStart = ctx.el.selectionEnd = start + rendered.cursor;
      dispatchInput(ctx.el, rendered.text);
    } else if (match.tokenRange) {
      const sel = window.getSelection();
      sel.removeAllRanges();
      sel.addRange(match.tokenRange);
      document.execCommand("insertText", false, rendered.text);
      if (rendered.cursor < rendered.text.length) {
        const curSel = window.getSelection();
        if (curSel?.rangeCount) {
          const r = curSel.getRangeAt(0);
          const fullText = getTextBefore(ctx.root, r, Infinity);
          const targetPos = fullText.length - (rendered.text.length - rendered.cursor);
          const pos = textPosToNode(ctx.root, targetPos);
          if (pos) {
            r.setStart(pos.node, pos.offset);
            r.collapse(true);
            curSel.removeAllRanges();
            curSel.addRange(r);
          }
        }
      }
      dispatchInput(ctx.root, rendered.text);
    }
  }
  const matchHotkey = (e, spec) => e.code === (spec.code || "Space") && e.shiftKey === !!spec.shift && e.altKey === !!spec.alt && e.ctrlKey === !!spec.ctrl && e.metaKey === !!spec.meta;
  const hotkeyStr = (spec) => [
    spec.ctrl && "Ctrl",
    spec.meta && "Cmd",
    spec.alt && "Alt",
    spec.shift && "Shift",
    spec.code?.replace(/^Key/, "").replace(/^Digit/, "") || "Space"
  ].filter(Boolean).join("+");
  function captureHotkey() {
    return new Promise((resolve) => {
      const ac = new AbortController();
      document.addEventListener("keydown", (e) => {
        if (e.key === "Escape") {
          ac.abort();
          resolve(null);
          return;
        }
        if (["Shift", "Control", "Alt", "Meta"].includes(e.key)) return;
        e.preventDefault();
        ac.abort();
        resolve({ code: e.code, shift: e.shiftKey, alt: e.altKey, ctrl: e.ctrlKey, meta: e.metaKey });
      }, { capture: true, signal: ac.signal });
    });
  }
  const cssVars = `
  --sae-bg: #151517;
  --sae-bg-light: #18181a;
  --sae-bg-hover: rgba(255, 255, 255, 0.04);
  --sae-border: rgba(255, 255, 255, 0.04);
  --sae-border-light: rgba(255, 255, 255, 0.02);
  --sae-border-focus: #84a59d;
  --sae-text: #d4d4d8;
  --sae-text-muted: #a1a1aa;
  --sae-text-dim: #71717a;
  --sae-accent: #84a59d;
  --sae-accent-bg: rgba(132, 165, 157, 0.1);
  --sae-success: #84a59d;
  --sae-danger: #e28484;
  --sae-r: 10px;
  --sae-r-sm: 6px;
  --sae-z: 2147483647;
`;
  const scroll = `scrollbar-width:none;`;
  const field = `
  background: rgba(255, 255, 255, 0.03);
  color: var(--sae-text);
  border: 1px solid transparent;
  border-radius: var(--sae-r);
  font: inherit;
  padding: 10px 14px;
`;
  const focus = `border-color:var(--sae-border-focus);outline:none;background:rgba(255,255,255,0.05)`;
  const STYLES = `
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Newsreader:ital,opsz,wght@1,6..72,400;1,6..72,500&display=swap');
  
  .sae-palette *,.sae-ai-menu *,.sae-toast{box-sizing:border-box}
  :root{${cssVars}}

  /* Toast */
  .sae-toast{
    position:fixed;z-index:var(--sae-z);right:24px;bottom:24px;
    max-width:min(400px,85vw);border-radius:12px;
    background:rgba(21, 21, 23, 0.95);backdrop-filter:blur(24px);
    color:var(--sae-text);padding:14px 20px;
    font:14px/1.5 'Inter',system-ui,sans-serif;
    border:1px solid var(--sae-border);
    box-shadow:0 10px 40px -10px rgba(0,0,0,0.5);
    white-space:pre-wrap;font-weight:400;letter-spacing:0.3px;
  }

  /* Overlay */
  .sae-palette{
    all:initial;position:fixed;z-index:var(--sae-z);inset:0;
    display:none;align-items:center;justify-content:center;
    background:rgba(0,0,0,0.5);backdrop-filter:blur(5px);
    font-family:'Inter',system-ui,sans-serif;
  }
  .sae-palette.open{display:flex}

  /* Panel */
  .sae-panel{
    width:min(640px,94vw);max-height:85vh;overflow:hidden;
    background:rgba(21, 21, 23, 0.95);backdrop-filter:blur(20px);
    color:var(--sae-text);border:1px solid var(--sae-border);
    border-radius:16px;box-shadow:0 30px 80px -20px rgba(0,0,0,0.7);
    display:flex;flex-direction:column;font-size:14px;line-height:1.5;
  }
  .sae-panel-header{
    display:flex;align-items:center;gap:12px;
    padding:16px 20px;border-bottom:1px solid var(--sae-border-light);
  }
  .sae-icon-btn[data-action="settings"],
  .sae-icon-btn[data-action="back"] {
    margin-left: auto;
  }

  /* Hide scrollbars globally for custom scroll areas */
  .sae-list::-webkit-scrollbar, .sae-settings::-webkit-scrollbar, .sae-ai-menu::-webkit-scrollbar { display: none; }

  /* Fields */
  .sae-search,.sae-input,.sae-textarea,.sae-item.editing input{${field}}
  .sae-search:focus,.sae-input:focus,.sae-textarea:focus,.sae-item.editing input:focus{${focus}}
  .sae-search{flex:1;width:auto;font-size:15px}
  .sae-textarea{min-height:90px;resize:vertical;width:100%;max-width:100%}
  .sae-item.editing input{padding:8px 12px;flex:1;min-width:80px;border-radius:var(--sae-r-sm)}

  /* Icon Btn */
  .sae-icon-btn{
    padding:8px;border-radius:var(--sae-r);
    border:none;background:transparent;
    color:var(--sae-text-dim);cursor:pointer;display:flex;
    align-items:center;justify-content:center;
    transition:all .2s;flex-shrink:0;
  }
  .sae-icon-btn:hover{background:var(--sae-bg-hover);color:var(--sae-text)}
  .sae-icon-btn svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}

  /* List */
  .sae-list{flex:1;overflow:auto;padding:12px;${scroll}}

  /* Item */
  .sae-item{
    display:grid;grid-template-columns:140px 1fr auto;gap:16px;
    padding:14px 16px;border-radius:var(--sae-r);
    border:1px solid transparent;cursor:pointer;
    align-items:center;
    margin-bottom:4px;
  }
  .sae-item.active{background:var(--sae-bg-hover)}
  .sae-key{font-weight:500;color:var(--sae-text);word-break:break-all;font-size:13px;letter-spacing:0.2px;}
  .sae-item.active .sae-key{font-weight:600;color:#fff}
  .sae-val{color:var(--sae-text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px}
  .sae-item-actions{display:flex;gap:8px;opacity:0;transition:opacity .05s}
  .sae-item.active .sae-item-actions{opacity:1}
  .sae-item-actions button{
    padding:4px 10px;border-radius:var(--sae-r-sm);
    border:none;background:var(--sae-bg-light);
    color:var(--sae-text-dim);cursor:pointer;font-size:12px;transition:all .05s;
  }
  .sae-item-actions button:hover{background:var(--sae-bg-hover);color:var(--sae-text)}
  
  .sae-item.editing{
    background:rgba(255,255,255,0.02);border-color:var(--sae-border-light);
    display:flex;flex-wrap:wrap;gap:12px;padding:16px;
  }

  .sae-add-new{padding:16px;text-align:center}
  .sae-add-new button{
    padding:10px 24px;border-radius:var(--sae-r);
    border:1px solid transparent;background:var(--sae-accent-bg);
    color:var(--sae-accent);cursor:pointer;font-weight:500;font-size:13px;
    letter-spacing:0.3px;transition:all .2s;
  }
  .sae-add-new button:hover{background:rgba(132, 165, 157, 0.15)}

  .sae-footer{
    padding:16px 20px;
    color:var(--sae-text-dim);font-size:11px;letter-spacing:1px;
    text-transform:uppercase;text-align:center;
  }

  /* Settings toggle */
  .sae-panel.settings-open .sae-list,
  .sae-panel.settings-open .sae-add-new,
  .sae-panel.settings-open .sae-search{display:none}
  .sae-settings{display:none;flex:1;overflow:auto;padding:24px;${scroll}}
  .sae-panel.settings-open .sae-settings{display:block}

  /* Settings — Neo Zen unboxed sections */
  .sae-s-card{
    background:transparent;border:none;
    padding:0 0 32px 0;margin:0;
  }
  .sae-s-title{
    font-size:10px;color:var(--sae-text-dim);text-transform:uppercase;
    letter-spacing:2px;margin-bottom:16px;font-weight:600;
  }
  .sae-s-row{display:flex;gap:16px;align-items:center;flex-wrap:wrap}
  .sae-s-row+.sae-s-row{margin-top:16px}
  .sae-s-row input{flex:1}
  .sae-s-label{color:var(--sae-text-muted);font-size:13px;min-width:80px}
  .sae-s-hint{font-size:12px;color:var(--sae-text-dim);margin-top:10px;font-style:italic}
  .sae-s-sep{height:1px;background:var(--sae-border-light);margin:32px 0}
  .sae-chip{
    display:inline-flex;align-items:center;padding:8px 16px;
    border-radius:var(--sae-r);${field}
    color:var(--sae-text);font-size:13px;font-family:monospace;letter-spacing:0.5px;
    min-width:120px;justify-content:center;
  }

  /* Button */
  .sae-btn{
    padding:10px 16px;border-radius:var(--sae-r);
    border:none;background:var(--sae-bg-light);
    color:var(--sae-text);cursor:pointer;font-size:13px;font-weight:500;
    transition:all .2s;white-space:nowrap;flex-shrink:0;letter-spacing:0.3px;
  }
  .sae-btn:hover{background:var(--sae-bg-hover)}
  .sae-btn.primary{background:var(--sae-accent-bg);color:var(--sae-accent)}
  .sae-btn.primary:hover{background:rgba(132,165,157,0.2)}
  .sae-btn.danger{background:rgba(226,132,132,0.1);color:var(--sae-danger)}
  .sae-btn.danger:hover{background:rgba(226,132,132,0.2)}
  .sae-btn.sm{padding:6px 12px;font-size:12px}
  .sae-btn:disabled{opacity:.4;cursor:not-allowed}

  /* Prompt list */
  .sae-p-list{display:flex;flex-direction:column;gap:8px}
  .sae-p-item{
    display:flex;align-items:center;gap:16px;padding:12px 16px;
    border-radius:var(--sae-r);border:none;background:rgba(255,255,255,0.015);
    transition:background .2s;
  }
  .sae-p-item:hover{background:var(--sae-bg-hover)}
  .sae-p-item .p-icon{display:none}
  .sae-p-item .p-name{font-size:13px;font-weight:500;min-width:100px;color:var(--sae-text);letter-spacing:0.3px}
  .sae-p-item .p-text{
    flex:1;color:var(--sae-text-dim);font-size:12px;
    white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
  }
  .sae-p-item .p-acts{display:flex;gap:12px;align-items:center;margin-left:auto}
  .sae-p-item.disabled{opacity:.4}
  .sae-p-item.editing{flex-direction:column;align-items:stretch;gap:16px;background:rgba(255,255,255,0.03)}

  .sae-p-edit{display:flex;flex-direction:column;gap:12px;width:100%}
  .sae-p-edit-r{display:flex;gap:12px;align-items:center}
  .sae-p-edit-r .icon-input{display:none}
  .sae-p-edit-r .label-input{flex:1}
  .sae-p-edit-a{display:flex;gap:12px;justify-content:flex-end}

  /* Toggle (Neo Zen) */
  .sae-toggle{
    position:relative;width:34px;height:20px;background:rgba(255,255,255,0.1);
    border-radius:10px;cursor:pointer;transition:background .3s;
    flex-shrink:0;border:none;
  }
  .sae-toggle.on{background:var(--sae-success)}
  .sae-toggle::after{
    content:'';position:absolute;top:3px;left:3px;
    width:14px;height:14px;background:var(--sae-bg);
    border-radius:50%;transition:transform .3s cubic-bezier(0.4, 0.0, 0.2, 1);
  }
  .sae-toggle.on::after{transform:translateX(14px);background:var(--sae-bg)}

  .sae-empty{
    color:var(--sae-text-dim);padding:24px;text-align:center;font-size:13px;
    border:none;font-style:italic;
  }

  /* ── AI Menu ── */
  .sae-ai-menu{
    position:fixed;z-index:var(--sae-z);
    background:rgba(24, 24, 26, 0.85);backdrop-filter:blur(24px);
    border:1px solid var(--sae-border-light);
    border-radius:16px;box-shadow:0 24px 60px -10px rgba(0,0,0,0.7);
    padding:12px;font:14px/1.5 'Inter',system-ui,sans-serif;
    width:360px;max-width:90vw;max-height:80vh;overflow-y:auto;
    opacity:0;pointer-events:none;
    transform:scale(.98) translateY(-4px);
    transition:opacity .2s ease-out,transform .2s ease-out;
    ${scroll}
  }
  .sae-ai-menu.open{opacity:1;pointer-events:auto;transform:scale(1) translateY(0)}
  .sae-ai-menu.above{transform-origin:bottom center}
  .sae-ai-menu.below{transform-origin:top center}

  .sae-ai-preview{
    padding:16px 20px;margin:0 -12px 12px -12px;
    background:transparent;
    color:var(--sae-text);font-size:15px;line-height:1.6;
    border-bottom:1px solid var(--sae-border-light);
    word-break:break-word;font-family:'Newsreader',serif;font-style:italic;
    letter-spacing:0.2px;
  }

  .sae-ai-pills{display:flex;flex-direction:column;gap:2px;margin-bottom:8px}
  .sae-ai-pill{
    display:flex;align-items:center;justify-content:space-between;
    padding:10px 14px;border-radius:var(--sae-r);
    background:transparent;border:none;
    color:var(--sae-text);cursor:pointer;font-size:13px;
    font-weight:400;
  }
  .sae-ai-pill.active{
    background:rgba(255,255,255,0.03);
  }
  .sae-ai-pill .icon{display:none} /* Hide emojis */
  .sae-ai-pill .label-text{letter-spacing:0.4px;}
  .sae-ai-pill.active .label-text{font-weight:600;color:#fff;}
  .sae-ai-pill .key{
    color:var(--sae-text-dim);font-size:10px;font-family:monospace;
    font-weight:500;opacity:0;transition:opacity .05s;
  }
  .sae-ai-pill.active .key{opacity:1;color:var(--sae-text-muted)}

  .sae-ai-divider{height:1px;background:var(--sae-border-light);margin:12px 0}
  .sae-ai-toggle{
    display:flex;align-items:center;justify-content:center;
    padding:10px;color:var(--sae-text-dim);cursor:pointer;font-size:11px;
    border-radius:var(--sae-r);transition:all .2s;border:none;
    text-transform:uppercase;letter-spacing:1px;
  }
  .sae-ai-toggle:hover{color:var(--sae-text)}
  .sae-ai-custom-label{font-size:9px;color:var(--sae-text-dim);padding:0 14px 8px 14px;text-transform:uppercase;letter-spacing:2px}

  .sae-ai-menu.loading .sae-ai-pills{opacity:.4;pointer-events:none}
  .sae-ai-loading{display:none;align-items:center;gap:12px;padding:16px;color:var(--sae-text-muted);justify-content:center;font-size:13px;letter-spacing:0.5px}
  .sae-ai-menu.loading .sae-ai-loading{display:flex}
  .sae-ai-spinner{
    width:16px;height:16px;
    border:2px solid transparent;border-top-color:var(--sae-text-muted);border-left-color:var(--sae-text-muted);
    border-radius:50%;animation:sae-spin 1s cubic-bezier(0.5, 0, 0.5, 1) infinite;
  }
`;
  let toastEl = null;
  let toastTimer = null;
  function close() {
    if (toastTimer) clearTimeout(toastTimer);
    toastEl?.remove();
    toastEl = toastTimer = null;
  }
  function toast(msg, ms = CONFIG.toast.defaultMs) {
    close();
    toastEl = document.createElement("div");
    toastEl.className = "sae-toast";
    toastEl.setAttribute("role", "alert");
    toastEl.setAttribute("aria-live", "polite");
    toastEl.textContent = msg;
    document.documentElement.appendChild(toastEl);
    toastTimer = setTimeout(close, ms);
  }
  const notify = { toast, close };
  function cleanResponse(s) {
    let out = s.trim();
    const m = out.match(/^```\w*\n?([\s\S]*?)\n?```$/);
    if (m) out = m[1].trim();
    if (out[0] === '"' && out.at(-1) === '"' || out[0] === "'" && out.at(-1) === "'")
      out = out.slice(1, -1);
    return out;
  }
  async function callGemini(systemPrompt, userText) {
    const keys = state.apiKey.split(";").map((k) => k.trim()).filter(Boolean);
    if (!keys.length) return null;
    const prompt = `${systemPrompt}

Text:
${userText.slice(0, CONFIG.gemini.maxInputChars)}`;
    const { endpoint, model, temperature } = CONFIG.gemini;
    for (let i = 0; i < keys.length; i++) {
      const idx = (state.apiKeyIndex + i) % keys.length;
      try {
        const res = await GMX.request({
          method: "POST",
          url: `${endpoint}/${model}:generateContent?key=${encodeURIComponent(keys[idx])}`,
          headers: { "Content-Type": "application/json" },
          data: JSON.stringify({
            contents: [{ role: "user", parts: [{ text: prompt }] }],
            generationConfig: { temperature }
          })
        });
        if (res.status >= 200 && res.status < 300) {
          const text = JSON.parse(res.text).candidates?.[0]?.content?.parts?.[0]?.text?.trim();
          if (text) {
            state.apiKeyIndex = (idx + 1) % keys.length;
            return cleanResponse(text);
          }
        }
      } catch {
        continue;
      }
    }
    return null;
  }
  async function verifyApiKey(key) {
    try {
      return (await GMX.request({
        method: "GET",
        url: `${CONFIG.gemini.endpoint}?key=${encodeURIComponent(key)}`,
        timeout: 5e3
      })).status < 300;
    } catch {
      return false;
    }
  }
  const VALID_KEY = /^[\w-]+$/i;
  function mountAbbrevEditor(container, key, val, onSave, onCancel) {
    container.innerHTML = "";
    const ki = Object.assign(document.createElement("input"), {
      className: "sae-input",
      placeholder: "abbreviation",
      value: key,
      ariaLabel: "Abbreviation"
    });
    ki.style.maxWidth = "140px";
    const vi = Object.assign(document.createElement("input"), {
      className: "sae-input",
      placeholder: "expansion (supports {{templates}})",
      value: val,
      ariaLabel: "Expansion"
    });
    const acts = document.createElement("div");
    acts.className = "sae-item-actions";
    const sBtn = Object.assign(document.createElement("button"), { textContent: "Save" });
    const cBtn = Object.assign(document.createElement("button"), { textContent: "Cancel" });
    acts.append(sBtn, cBtn);
    container.append(ki, vi, acts);
    const save2 = () => {
      const k = ki.value.trim().toLowerCase();
      if (!VALID_KEY.test(k)) {
        notify.toast("Invalid: letters, numbers, -, _ only");
        return;
      }
      onSave(k, vi.value);
    };
    sBtn.onclick = save2;
    cBtn.onclick = onCancel;
    const onKey = (e) => {
      if (e.key === "Enter") {
        e.preventDefault();
        e.target === ki ? vi.focus() : save2();
      }
      if (e.key === "Escape") {
        e.preventDefault();
        onCancel();
      }
    };
    ki.addEventListener("keydown", onKey);
    vi.addEventListener("keydown", onKey);
    requestAnimationFrame(() => {
      ki.focus();
      ki.select?.();
    });
  }
  const settingsHTML = (apiKey, paletteHk, aiMenuHk) => `
  <div class="sae-s-card">
    <div class="sae-s-title">API Key</div>
    <div class="sae-s-row">
      <input class="sae-input" id="sae-api" type="password" placeholder="key1;key2..." value="${escHtml(apiKey)}" />
      <button class="sae-btn primary" id="sae-verify">Verify</button>
    </div>
    <div class="sae-s-hint">Semicolon-separated keys rotate on rate limits</div>
  </div>
  <div class="sae-s-card">
    <div class="sae-s-title">Hotkeys</div>
    <div class="sae-s-row">
      <span class="sae-s-label">Palette</span>
      <span class="sae-chip">${paletteHk}</span>
      <button class="sae-btn sm" data-hk="palette">Change</button>
    </div>
    <div class="sae-s-row">
      <span class="sae-s-label">AI Menu</span>
      <span class="sae-chip">${aiMenuHk}</span>
      <button class="sae-btn sm" data-hk="aiMenu">Change</button>
    </div>
  </div>
  <div class="sae-s-card">
    <div class="sae-s-title">Prompts</div>
    <div class="sae-p-list" id="sae-builtins"></div>
    <div class="sae-s-sep"></div>
    <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
      <span style="font-size:10px;color:var(--sae-text-dim);text-transform:uppercase;letter-spacing:.4px">Custom</span>
      <button class="sae-btn sm primary" id="sae-add-prompt">+ Add</button>
    </div>
    <div class="sae-p-list" id="sae-customs"></div>
  </div>
  <div class="sae-s-card">
    <div class="sae-s-title">Dictionary</div>
    <div class="sae-s-row">
      <button class="sae-btn" id="sae-export">Export</button>
      <button class="sae-btn" id="sae-import">Import</button>
      <button class="sae-btn danger" id="sae-reset">Reset</button>
    </div>
  </div>`;
  const promptItemHTML = (p, idx, builtin, on) => `
  <div class="sae-p-item${builtin ? " bi" : ""}${on ? "" : " disabled"}" ${builtin ? `data-id="${escHtml(p.id)}"` : `data-idx="${idx}"`}>
    <span class="p-name">${escHtml(p.label)}</span>
    <span class="p-text">${escHtml(p.prompt)}</span>
    <div class="p-acts">
      <div class="sae-toggle${on ? " on" : ""}" data-toggle role="switch" tabindex="0"></div>
      ${builtin ? "" : '<button class="sae-btn sm" data-edit>Edit</button><button class="sae-btn sm danger" data-del>×</button>'}
    </div>
  </div>`;
  const promptEditFormHTML = (label, prompt) => `
  <div class="sae-p-edit">
    <div class="sae-p-edit-r">
      <input class="sae-input label-input" placeholder="Name" value="${escHtml(label)}" id="pl"/>
    </div>
    <textarea class="sae-textarea" placeholder="Prompt..." id="pp">${escHtml(prompt)}</textarea>
    <div class="sae-p-edit-a">
      <button class="sae-btn" id="pc">Cancel</button>
      <button class="sae-btn primary" id="ps">Save</button>
    </div>
  </div>`;
  const paletteHTML = () => `
  <div class="sae-panel" role="dialog" aria-label="Text Expander Palette">
    <div class="sae-panel-header">
      <input class="sae-search" type="search" placeholder="Search..." aria-label="Search"/>
      <button class="sae-icon-btn" data-action="settings" title="Settings">
        <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
      </button>
      <button class="sae-icon-btn" data-action="back" title="Back" style="display:none">
        <svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
      </button>
      <button class="sae-icon-btn" data-action="close" title="Close">
        <svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
      </button>
    </div>
    <div class="sae-list" role="listbox"></div>
    <div class="sae-settings"></div>
    <div class="sae-add-new"><button data-action="add">+ Add Abbreviation</button></div>
    <div class="sae-footer">Shift+Space expand · Alt+G AI</div>
  </div>`;
  const aiMenuHTML = () => `
  <div class="sae-ai-preview"></div>
  <div class="sae-ai-pills primary" role="menu"></div>
  <div class="sae-ai-more" style="display:none">
    <div class="sae-ai-divider"></div>
    <div class="sae-ai-pills secondary" role="menu"></div>
    <div class="sae-ai-custom" style="display:none">
      <div class="sae-ai-divider"></div>
      <div class="sae-ai-custom-label">Custom</div>
      <div class="sae-ai-pills custom" role="menu"></div>
    </div>
  </div>
  <div class="sae-ai-toggle" role="button" tabindex="0">▾ More</div>
  <div class="sae-ai-loading"><div class="sae-ai-spinner"></div><span>Processing...</span></div>`;
  let paletteEl = null;
  let prevOverflow = "";
  let listEl, settingsEl, searchEl, panelEl, backBtn, settingsBtn;
  function renderList(filter = "") {
    const q = filter.toLowerCase();
    const keys = Object.keys(state.dict).sort();
    const items = q ? keys.filter((k) => k.includes(q) || state.dict[k].toLowerCase().includes(q)) : keys;
    state.paletteIndex = clamp(state.paletteIndex, 0, Math.max(0, items.length - 1));
    if (!items.length) {
      listEl.innerHTML = '<div class="sae-empty">No abbreviations found</div>';
      return;
    }
    listEl.innerHTML = items.map((k, i) => `
    <div class="sae-item${i === state.paletteIndex ? " active" : ""}" data-key="${escHtml(k)}" role="option">
      <div class="sae-key">${escHtml(k)}</div>
      <div class="sae-val">${escHtml(state.dict[k])}</div>
      <div class="sae-item-actions">
        <button data-action="edit">Edit</button>
        <button data-action="delete">Del</button>
      </div>
    </div>`).join("");
  }
  function updateActive(items, scroll2 = true) {
    items.forEach((el, i) => el.classList.toggle("active", i === state.paletteIndex));
    if (scroll2) items[state.paletteIndex]?.scrollIntoView({ block: "nearest" });
  }
  function save(msg) {
    GMX.set(STORE_KEYS.dict, state.dict);
    renderList(searchEl.value);
    notify.toast(msg);
  }
  function addNew() {
    searchEl.value = "";
    renderList();
    const t = document.createElement("div");
    t.className = "sae-item editing";
    listEl.insertBefore(t, listEl.firstChild);
    mountAbbrevEditor(t, "", "", (k, v) => {
      if (!k || !v) {
        notify.toast("Both fields required");
        return;
      }
      state.dict[k] = v;
      save("Added");
    }, () => {
      t.remove();
      renderList();
    });
  }
  function editAbbrev(item, key) {
    item.classList.add("editing");
    mountAbbrevEditor(item, key, state.dict[key], (nk, nv) => {
      if (!nk || !nv) {
        notify.toast("Both fields required");
        return;
      }
      if (nk !== key) delete state.dict[key];
      state.dict[nk] = nv;
      save("Saved");
    }, () => renderList(searchEl.value));
  }
  function deleteAbbrev(key) {
    if (!confirm(`Delete "${key}"?`)) return;
    delete state.dict[key];
    save("Deleted");
  }
  async function insertAbbrev(key) {
    closePalette();
    const tmpl = state.dict[key];
    if (!tmpl) return;
    const ctx = getContextOrFallback();
    if (!ctx) {
      notify.toast("No editable field");
      return;
    }
    makeEditor(captureContext() || ctx)?.replace((await renderTemplate(tmpl)).text);
  }
  function showSettings(show) {
    panelEl.classList.toggle("settings-open", show);
    backBtn.style.display = show ? "flex" : "none";
    settingsBtn.style.display = show ? "none" : "flex";
    show ? renderSettings() : searchEl.focus();
  }
  function renderSettings() {
    settingsEl.innerHTML = settingsHTML(state.apiKey, hotkeyStr(state.hotkeys.palette), hotkeyStr(state.hotkeys.aiMenu));
    const apiIn = $("#sae-api", settingsEl);
    const verBtn = $("#sae-verify", settingsEl);
    apiIn.onchange = () => {
      state.apiKey = apiIn.value.trim();
      GMX.set(STORE_KEYS.apiKey, state.apiKey);
    };
    verBtn.onclick = async () => {
      const k = apiIn.value.split(";")[0]?.trim();
      if (!k) {
        notify.toast("Enter key first");
        return;
      }
      verBtn.disabled = true;
      verBtn.textContent = "...";
      const ok = await verifyApiKey(k);
      verBtn.textContent = ok ? "✓" : "✗";
      if (ok) {
        state.apiKey = apiIn.value.trim();
        GMX.set(STORE_KEYS.apiKey, state.apiKey);
        notify.toast("Valid");
      } else notify.toast("Invalid");
      setTimeout(() => {
        verBtn.textContent = "Verify";
        verBtn.disabled = false;
      }, 1800);
    };
    $$("[data-hk]", settingsEl).forEach((btn) => {
      btn.onclick = async () => {
        notify.toast("Press new hotkey...");
        const spec = await captureHotkey();
        if (!spec) return;
        const name = btn.dataset.hk;
        state.hotkeys[name] = spec;
        const keys = GMX.get(STORE_KEYS.keys, {});
        keys[name] = spec;
        GMX.set(STORE_KEYS.keys, keys);
        renderSettings();
        notify.toast(`Set: ${hotkeyStr(spec)}`);
      };
    });
    renderBuiltins();
    renderCustoms();
    $("#sae-add-prompt", settingsEl).onclick = addPrompt;
    $("#sae-export", settingsEl).onclick = exportDict;
    $("#sae-import", settingsEl).onclick = importDict;
    $("#sae-reset", settingsEl).onclick = resetDict;
  }
  function wireToggles(container, getIdx, toggle) {
    container.querySelectorAll("[data-toggle]").forEach((t) => {
      t.onclick = () => {
        toggle(getIdx(t.closest(".sae-p-item")));
      };
    });
  }
  function renderBuiltins() {
    const c = $("#sae-builtins", settingsEl);
    c.innerHTML = BUILTIN_PROMPTS.map((p) => promptItemHTML(p, 0, true, isBuiltinEnabled(p.id))).join("");
    wireToggles(c, (el) => el.dataset.id, (id) => {
      const i = state.disabledBuiltins.indexOf(id);
      i >= 0 ? state.disabledBuiltins.splice(i, 1) : state.disabledBuiltins.push(id);
      GMX.set(STORE_KEYS.disabledBuiltins, state.disabledBuiltins);
      renderBuiltins();
    });
  }
  function renderCustoms() {
    const c = $("#sae-customs", settingsEl);
    if (!state.customPrompts.length) {
      c.innerHTML = '<div class="sae-empty">No custom prompts</div>';
      return;
    }
    c.innerHTML = state.customPrompts.map((p, i) => promptItemHTML(p, i, false, p.enabled !== false)).join("");
    wireToggles(c, (el) => el.dataset.idx, (idx) => {
      state.customPrompts[+idx].enabled = !state.customPrompts[+idx].enabled;
      GMX.set(STORE_KEYS.customPrompts, state.customPrompts);
      renderCustoms();
    });
    c.querySelectorAll("[data-edit]").forEach((b) => {
      b.onclick = () => editPrompt(+b.closest(".sae-p-item").dataset.idx);
    });
    c.querySelectorAll("[data-del]").forEach((b) => {
      b.onclick = () => {
        const i = +b.closest(".sae-p-item").dataset.idx;
        if (!confirm(`Delete "${state.customPrompts[i].label}"?`)) return;
        state.customPrompts.splice(i, 1);
        GMX.set(STORE_KEYS.customPrompts, state.customPrompts);
        renderCustoms();
        notify.toast("Deleted");
      };
    });
  }
  function addPrompt() {
    const c = $("#sae-customs", settingsEl);
    c.querySelector(".sae-empty")?.remove();
    const el = document.createElement("div");
    el.className = "sae-p-item editing";
    el.innerHTML = promptEditFormHTML("", "");
    c.insertBefore(el, c.firstChild);
    setupPromptForm(el, (label, prompt) => {
      state.customPrompts.push({ id: genId(), label, prompt, enabled: true });
      GMX.set(STORE_KEYS.customPrompts, state.customPrompts);
      renderCustoms();
      notify.toast("Added");
    });
  }
  function editPrompt(idx) {
    const p = state.customPrompts[idx];
    if (!p) return;
    const el = $(`[data-idx="${idx}"]`, settingsEl);
    el.className = "sae-p-item editing";
    el.innerHTML = promptEditFormHTML(p.label, p.prompt);
    setupPromptForm(el, (label, prompt) => {
      Object.assign(p, { label, prompt });
      GMX.set(STORE_KEYS.customPrompts, state.customPrompts);
      renderCustoms();
      notify.toast("Saved");
    });
  }
  function setupPromptForm(el, onSave) {
    const [lb, pr] = [$("#pl", el), $("#pp", el)];
    $("#ps", el).onclick = () => {
      const l = lb.value.trim(), p = pr.value.trim();
      if (!l || !p) {
        notify.toast("Required");
        return;
      }
      onSave(l, p);
    };
    $("#pc", el).onclick = () => renderCustoms();
    requestAnimationFrame(() => lb.focus());
  }
  function exportDict() {
    const a = document.createElement("a");
    a.href = URL.createObjectURL(new Blob([JSON.stringify(state.dict, null, 2)], { type: "application/json" }));
    a.download = `texpander-${( new Date()).toISOString().slice(0, 10)}.json`;
    a.click();
    notify.toast("Exported");
  }
  async function importDict() {
    const inp = Object.assign(document.createElement("input"), { type: "file", accept: ".json" });
    inp.onchange = async () => {
      try {
        let o = JSON.parse(await inp.files[0].text());
        if (o.dict) o = o.dict;
        const imp = normalizeDict(o);
        if (!Object.keys(imp).length) {
          notify.toast("No entries");
          return;
        }
        Object.assign(state.dict, imp);
        GMX.set(STORE_KEYS.dict, state.dict);
        renderList();
        notify.toast(`Imported ${Object.keys(imp).length}`);
      } catch {
        notify.toast("Invalid JSON");
      }
    };
    inp.click();
  }
  function resetDict() {
    if (!confirm("Reset to defaults?")) return;
    state.dict = normalizeDict(DEFAULT_DICT);
    GMX.set(STORE_KEYS.dict, state.dict);
    renderList();
    notify.toast("Reset");
  }
  function ensurePalette() {
    if (paletteEl) return paletteEl;
    paletteEl = document.createElement("div");
    paletteEl.className = "sae-palette";
    paletteEl.innerHTML = paletteHTML();
    document.documentElement.appendChild(paletteEl);
    panelEl = $(".sae-panel", paletteEl);
    searchEl = $(".sae-search", paletteEl);
    listEl = $(".sae-list", paletteEl);
    settingsEl = $(".sae-settings", paletteEl);
    backBtn = $('[data-action="back"]', paletteEl);
    settingsBtn = $('[data-action="settings"]', paletteEl);
    paletteEl.addEventListener("click", (e) => {
      if (e.target === paletteEl) closePalette();
    });
    $('[data-action="close"]', paletteEl).onclick = closePalette;
    settingsBtn.onclick = () => showSettings(true);
    backBtn.onclick = () => showSettings(false);
    $('[data-action="add"]', paletteEl).onclick = addNew;
    searchEl.addEventListener("input", debounce(() => renderList(searchEl.value), CONFIG.searchDebounceMs));
    paletteEl.addEventListener("keydown", handlePaletteKey);
    listEl.addEventListener("click", handleListClick);
    listEl.addEventListener("pointermove", (e) => {
      if (e.movementX === 0 && e.movementY === 0) return;
      const item = e.target.closest(".sae-item:not(.editing)");
      if (!item || item.classList.contains("active")) return;
      const items = $$(".sae-item:not(.editing)", listEl);
      const idx = items.indexOf(item);
      if (idx >= 0 && state.paletteIndex !== idx) {
        state.paletteIndex = idx;
        updateActive(items, false);
      }
    });
    return paletteEl;
  }
  function handlePaletteKey(e) {
    if (panelEl.classList.contains("settings-open")) {
      if (e.key === "Escape") {
        e.preventDefault();
        showSettings(false);
      }
      return;
    }
    if (e.target.closest(".sae-item.editing")) return;
    if (e.key === "Escape") {
      e.preventDefault();
      closePalette();
      return;
    }
    if (e.key === "Enter") {
      e.preventDefault();
      const a = listEl.querySelector(".sae-item.active:not(.editing)");
      if (a?.dataset.key) insertAbbrev(a.dataset.key);
      return;
    }
    const items = $$(".sae-item:not(.editing)", listEl);
    if (!items.length) return;
    if (e.key === "ArrowDown") {
      e.preventDefault();
      state.paletteIndex = Math.min(items.length - 1, state.paletteIndex + 1);
      updateActive(items);
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      state.paletteIndex = Math.max(0, state.paletteIndex - 1);
      updateActive(items);
    }
  }
  function handleListClick(e) {
    const item = e.target.closest(".sae-item");
    if (!item || item.classList.contains("editing")) return;
    const action = e.target.closest("[data-action]")?.dataset.action;
    const key = item.dataset.key;
    if (action === "edit") editAbbrev(item, key);
    else if (action === "delete") deleteAbbrev(key);
    else insertAbbrev(key);
  }
  function openPalette() {
    const p = ensurePalette();
    p.classList.add("open");
    prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    showSettings(false);
    searchEl.value = "";
    state.paletteIndex = 0;
    renderList();
    searchEl.focus();
  }
  function closePalette() {
    if (!paletteEl) return;
    paletteEl.classList.remove("open");
    document.body.style.overflow = prevOverflow;
    prevOverflow = "";
  }
  const isPaletteOpen = () => paletteEl?.classList.contains("open") ?? false;
  let menuEl = null;
  let menuState = null;
  let keyH = null;
  let clickH = null;
  let scrollH = null;
  function truncMid(s, max) {
    if (s.length <= max) return s;
    const h = max * 0.6 | 0;
    return s.slice(0, h) + " … " + s.slice(-45);
  }
  function ensureMenu() {
    if (menuEl) return menuEl;
    menuEl = document.createElement("div");
    menuEl.className = "sae-ai-menu";
    menuEl.setAttribute("role", "menu");
    menuEl.innerHTML = aiMenuHTML();
    document.documentElement.appendChild(menuEl);
    clickH = (e) => {
      if (menuEl?.classList.contains("open") && !menuEl.contains(e.target)) closeAIMenu();
    };
    document.addEventListener("mousedown", clickH, true);
    menuEl.addEventListener("pointermove", (e) => {
      if (e.movementX === 0 && e.movementY === 0) return;
      const pill = e.target.closest(".sae-ai-pill");
      if (!pill || pill.classList.contains("active")) return;
      const v = pills();
      const idx = v.indexOf(pill);
      if (idx >= 0 && state.aiMenuIndex !== idx) {
        state.aiMenuIndex = idx;
        markActive(false);
      }
    });
    return menuEl;
  }
  function pills() {
    if (!menuEl || !menuState) return [];
    const p = [...menuEl.querySelectorAll(".sae-ai-pills.primary .sae-ai-pill")];
    if (!menuState.expanded) return p;
    return [
      ...p,
      ...menuEl.querySelectorAll(".sae-ai-pills.secondary .sae-ai-pill"),
      ...menuEl.querySelectorAll(".sae-ai-pills.custom .sae-ai-pill")
    ];
  }
  function mkPill(p, i) {
    const b = document.createElement("button");
    b.className = "sae-ai-pill";
    b.dataset.id = p.id;
    b.setAttribute("role", "menuitem");
    b.innerHTML = `<span class="label-text">${p.label}</span><span class="key">${i}</span>`;
    b.onclick = () => exec(p.id);
    return b;
  }
  function render() {
    if (!menuEl || !menuState) return;
    $(".sae-ai-preview", menuEl).textContent = truncMid(menuState.text, CONFIG.ui.previewMaxChars);
    const pri = $(".sae-ai-pills.primary", menuEl);
    const sec = $(".sae-ai-pills.secondary", menuEl);
    const cw = $(".sae-ai-custom", menuEl);
    const cp = $(".sae-ai-pills.custom", menuEl);
    const more = $(".sae-ai-more", menuEl);
    const tog = $(".sae-ai-toggle", menuEl);
    const bi = BUILTIN_PROMPTS.filter((p) => isBuiltinEnabled(p.id));
    const cu = state.customPrompts.filter(isCustomEnabled);
    const n = CONFIG.ui.inlinePrompts;
    let idx = 1;
    pri.replaceChildren(...bi.slice(0, n).map((p) => mkPill(p, idx++)));
    sec.replaceChildren(...bi.slice(n).map((p) => mkPill(p, idx++)));
    if (cu.length) {
      cp.replaceChildren(...cu.map((p) => mkPill(p, idx++)));
      cw.style.display = "block";
    } else {
      cp.innerHTML = "";
      cw.style.display = "none";
    }
    const mc = bi.length - n + cu.length;
    tog.style.display = mc > 0 ? "flex" : "none";
    more.style.display = menuState.expanded ? "block" : "none";
    tog.textContent = menuState.expanded ? "▴ Less" : `▾ More (${mc})`;
    tog.onclick = () => {
      if (!menuState) return;
      menuState.expanded = !menuState.expanded;
      more.style.display = menuState.expanded ? "block" : "none";
      tog.textContent = menuState.expanded ? "▴ Less" : `▾ More (${mc})`;
      markActive();
    };
    markActive();
  }
  function markActive(scroll2 = true) {
    if (!menuEl) return;
    const v = pills();
    menuEl.querySelectorAll(".sae-ai-pill").forEach((p) => p.classList.remove("active"));
    const activePill = v[state.aiMenuIndex];
    if (activePill) {
      activePill.classList.add("active");
      if (scroll2) activePill.scrollIntoView({ block: "nearest" });
    }
  }
  function handleKey(e) {
    if (!menuEl || !menuState) return;
    if (e.key === "Escape") {
      e.preventDefault();
      e.stopPropagation();
      closeAIMenu();
      return;
    }
    const v = pills(), num = +e.key;
    if (num >= 1 && num <= 9 && v[num - 1]) {
      e.preventDefault();
      e.stopPropagation();
      exec(v[num - 1].dataset.id);
      return;
    }
    if (e.key === "ArrowDown" || e.key === "ArrowRight") {
      e.preventDefault();
      e.stopPropagation();
      state.aiMenuIndex = Math.min(v.length - 1, state.aiMenuIndex + 1);
      markActive();
    } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
      e.preventDefault();
      e.stopPropagation();
      state.aiMenuIndex = Math.max(0, state.aiMenuIndex - 1);
      markActive();
    } else if (e.key === "Enter") {
      e.preventDefault();
      e.stopPropagation();
      if (v[state.aiMenuIndex]) exec(v[state.aiMenuIndex].dataset.id);
    } else if (e.key === "Tab") {
      e.preventDefault();
      e.stopPropagation();
      menuState.expanded = !menuState.expanded;
      render();
    }
  }
  function position(ctx) {
    if (!menuEl) return;
    const rect = ctx.kind === "input" ? ctx.el.getBoundingClientRect() : window.getSelection?.()?.rangeCount ? window.getSelection().getRangeAt(0).getBoundingClientRect() : ctx.root.getBoundingClientRect();
    const { menuWidth: w, menuHeight: h, spacing: sp } = CONFIG.ui;
    let top = rect.bottom + sp.sm, left = Math.max(sp.sm, rect.left);
    if (top + h > innerHeight - sp.md) {
      top = Math.max(sp.sm, rect.top - h - sp.sm);
      menuEl.classList.add("above");
      menuEl.classList.remove("below");
    } else {
      menuEl.classList.add("below");
      menuEl.classList.remove("above");
    }
    if (left + w > innerWidth - sp.md) left = innerWidth - w - sp.md;
    menuEl.style.top = `${top}px`;
    menuEl.style.left = `${left}px`;
  }
  async function exec(id) {
    if (!menuEl || !menuState) return;
    const p = getAllPrompts().find((x) => x.id === id);
    if (!p) return;
    const { ctx, text } = menuState;
    menuEl.classList.add("loading");
    $(".sae-ai-loading span", menuEl).textContent = `${p.label}...`;
    try {
      const r = await callGemini(p.prompt, text);
      if (r) {
        closeAIMenu();
        safeFocus(ctx.kind === "input" ? ctx.el : ctx.root);
        makeEditor(captureContext() || ctx)?.replace(r);
        notify.toast(`Done`, CONFIG.toast.shortMs);
      } else {
        menuEl?.classList.remove("loading");
        notify.toast("Set API key in Settings");
      }
    } catch (err) {
      console.warn("[texpander] AI err:", err);
      menuEl?.classList.remove("loading");
      notify.toast("AI failed");
    }
  }
  function openAIMenu(ctx) {
    const m = ensureMenu(), ed = makeEditor(ctx);
    if (!ed) return;
    const text = ed.getText().trim();
    if (!text) {
      notify.toast("No text");
      return;
    }
    state.aiMenuIndex = 0;
    menuState = { ctx, text, expanded: false };
    render();
    position(ctx);
    m.classList.add("open");
    m.classList.remove("loading");
    keyH = handleKey;
    document.addEventListener("keydown", keyH, true);
    scrollH = (e) => {
      if (!menuEl?.contains(e.target)) closeAIMenu();
    };
    window.addEventListener("scroll", scrollH, true);
  }
  function closeAIMenu() {
    if (!menuEl) return;
    menuEl.classList.remove("open", "loading");
    if (keyH) {
      document.removeEventListener("keydown", keyH, true);
      keyH = null;
    }
    if (scrollH) {
      window.removeEventListener("scroll", scrollH, true);
      scrollH = null;
    }
    menuState = null;
  }
  const isAIMenuOpen = () => menuEl?.classList.contains("open") ?? false;
  function handleGlobalKey(e) {
    if (e.isComposing) return;
    const target = e.composedPath()[0] ?? e.target;
    if (e.key === "Escape") {
      if (isAIMenuOpen()) {
        e.preventDefault();
        e.stopPropagation();
        closeAIMenu();
      } else if (isPaletteOpen()) {
        e.preventDefault();
        e.stopPropagation();
        closePalette();
      }
      return;
    }
    if (isAIMenuOpen() || isPaletteOpen()) return;
    if (matchHotkey(e, state.hotkeys.palette)) {
      e.preventDefault();
      e.stopPropagation();
      openPalette();
      return;
    }
    if (matchHotkey(e, state.hotkeys.aiMenu) && getEditable(target)) {
      e.preventDefault();
      e.stopPropagation();
      const ctx = captureContext();
      ctx ? openAIMenu(ctx) : notify.toast("No editable field");
      return;
    }
    if (matchHotkey(e, CONFIG.trigger) && getEditable(target)) {
      const ctx = captureContext();
      if (!ctx) return;
      const token = peekToken(ctx);
      if (!token || !state.dict[token.toLowerCase()]) return;
      e.preventDefault();
      e.stopPropagation();
      doExpansion(ctx);
    }
  }
  function init() {
    _GM_addStyle(STYLES);
    loadState();
    window.addEventListener("focusin", (e) => {
      const t = e.composedPath()[0] ?? e.target;
      if (t?.closest?.(".sae-palette, .sae-ai-menu")) return;
      const el = getEditable(t);
      if (el) state.lastEditableEl = el;
    }, true);
    GMX.menu("Open Palette", openPalette);
    GMX.menu("AI Actions", () => {
      const ctx = getContextOrFallback();
      ctx ? openAIMenu(ctx) : notify.toast("No editable field");
    });
    window.addEventListener("keydown", handleGlobalKey, true);
    console.log("[texpander-ai] loaded");
  }
  init();

})();