Horizon Genesis Perplexity Rules — Free Perplexity Rules…
    Neura MarketNeura Market/Perplexity
    ChatGPTChatGPTClaudeClaudeGeminiGeminiCursorCursorGrokGrokPerplexityPerplexityDeepSeekDeepSeek
    CoPilotCoPilotStable DiffusionStable DiffusionMidjourneyMidjourney
    View All Directories
    OverviewRulesPromptsMCPsAgentsGamesBlogVideosGuidesCoursesCommunityTrending
    PerplexityRulesHorizon Genesis Perplexity Rules
    Back to Rules

    Horizon Genesis Perplexity Rules

    ErnestKostevich July 19, 2026
    0 copies 0 downloads
    Rule Content
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8"><title>Horizon Genesis</title>
    <!-- Sprint-2.2 fix: theme bootstrap MOVED to <head>, BEFORE any CSS link.
         Previously this lived inside <body> which meant the browser had already
         parsed every <link rel=stylesheet> + started applying dark default
         vars to <html> before the script flipped data-theme. Result: brief
         dark-flash on first paint + on slow IPC the attribute sometimes never
         stuck. Running here guarantees data-theme is set on <html> before any
         stylesheet matches against it, so light mode applies on first paint
         with zero flash. -->
    <script>
      (function(){
        try {
          var saved = localStorage.getItem('horizonTheme');
          if (saved === 'light' || saved === 'dark') {
            document.documentElement.setAttribute('data-theme', saved);
          }
        } catch (_) { /* localStorage may be blocked in some sandbox modes */ }
        window.toggleHorizonTheme = function () {
          var cur = document.documentElement.getAttribute('data-theme');
          var next = cur === 'light' ? 'dark' : 'light';
          document.documentElement.setAttribute('data-theme', next);
          try { localStorage.setItem('horizonTheme', next); } catch (_) {}
        };
      })();
    </script>
    <link rel="preconnect" href="https://fonts.googleapis.com"/>
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
    <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
    <link rel="stylesheet" href="chat-base.css"/>
    <link rel="stylesheet" href="apex-theme.css"/>
    <link rel="stylesheet" href="apex-alive.css"/>
    <link rel="stylesheet" href="/vendor/xterm/css/xterm.css"/>
    <link rel="stylesheet" href="chat-shell-polish.css"/>
    <link rel="stylesheet" href="chat-emergency-dock.css"/>
    <!-- Phase 17 redesign pass — skeleton loaders, toasts, modal animation,
         input focus rings, animated bg, sidebar hover. Layered last so it
         wins specificity ties. -->
    <link rel="stylesheet" href="chat-redesign.css"/>
    <!-- Phase 19 — Settings panel CSS-only redesign (card grid, gradient
         section headers, consistent inputs/buttons, status accents). -->
    <link rel="stylesheet" href="chat-settings-redesign.css"/>
    <!-- chat-refinement.css — final polish layer. Loaded LAST so its rules
         dominate the cascade without needing !important wars. Reference DNA:
         Linear / Anthropic Claude.ai / Cursor / Windsurf / OpenAI ChatGPT. -->
    <link rel="stylesheet" href="chat-refinement.css"/>
    <!-- Hermes-style desktop dashboard. Loaded after refinement so its card
         rules sit on top of the base cascade. The panel itself is injected
         dynamically by chat-dashboard.js — no DOM here. -->
    <link rel="stylesheet" href="chat-dashboard.css"/>
    </head>
    <body>
    <!-- ═══════════ ICON LIBRARY (lucide.dev) ═══════════
         Inline SVG <symbol> definitions reused across the title bar, mode
         chips, settings sidebar, and inline chips. Replaces the prior
         emoji-as-icons approach (📟🪟🧩🏪…) which the owner correctly
         called out as unprofessional vs Cursor / Claude Code / ChatGPT
         (which all use lucide / heroicons / Phosphor). Each <use> is a
         single-element render with `currentColor`, so the same SVG
         adopts whatever button text-color is set in CSS. Only icons
         actually used in the UI today are included — not the full lucide
         catalog — to keep the file size manageable. Mark new ones below
         as you go: name them `i-<lucide-kebab>`.
    
         Source: https://github.com/lucide-icons/lucide (ISC license,
         attribution kept in the LICENSE.third-party file at repo root). -->
    <svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute" aria-hidden="true">
      <defs>
        <!-- Title bar -->
        <symbol id="i-terminal" viewBox="0 0 24 24"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></symbol>
        <symbol id="i-layout" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></symbol>
        <symbol id="i-sidebar" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="9" y1="3" x2="9" y2="21"/></symbol>
        <symbol id="i-puzzle" viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol>
        <symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol>
        <symbol id="i-book" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></symbol>
        <symbol id="i-zap" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></symbol>
        <symbol id="i-workflow" viewBox="0 0 24 24"><rect x="3" y="4" width="6" height="5" rx="1.5"/><rect x="15" y="4" width="6" height="5" rx="1.5"/><rect x="9" y="15" width="6" height="5" rx="1.5"/><path d="M9 6.5h6"/><path d="M6 9v2a4 4 0 0 0 4 4h2"/><path d="M18 9v2a4 4 0 0 1-4 4h-2"/></symbol>
        <symbol id="i-discord" viewBox="0 0 24 24"><path d="M7.5 8.5c3-1.2 6-1.2 9 0"/><path d="M8 17c-1.7-.5-3.1-1.3-4-2.4.4-3.4 1.3-6 2.7-7.9A13 13 0 0 1 10 5.6l.7 1.4"/><path d="M16 17c1.7-.5 3.1-1.3 4-2.4-.4-3.4-1.3-6-2.7-7.9A13 13 0 0 0 14 5.6L13.3 7"/><path d="M9 15c2 1 4 1 6 0"/><circle cx="9.5" cy="12" r=".8"/><circle cx="14.5" cy="12" r=".8"/></symbol>
        <symbol id="i-store" viewBox="0 0 24 24"><path d="m2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7"/><line x1="2" y1="10" x2="22" y2="10"/><path d="M5 10v9a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-9"/></symbol>
        <symbol id="i-user" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
        <symbol id="i-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></symbol>
        <symbol id="i-command" viewBox="0 0 24 24"><path d="M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3z"/></symbol>
        <symbol id="i-settings" viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></symbol>
        <!-- Settings sidebar -->
        <symbol id="i-file-text" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></symbol>
        <symbol id="i-brain" viewBox="0 0 24 24"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/></symbol>
        <symbol id="i-key" viewBox="0 0 24 24"><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/></symbol>
        <symbol id="i-plug" viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/></symbol>
        <symbol id="i-mic" viewBox="0 0 24 24"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></symbol>
        <symbol id="i-hand" viewBox="0 0 24 24"><path d="M18 11V6a2 2 0 0 0-4 0v5"/><path d="M14 10V4a2 2 0 0 0-4 0v8"/><path d="M10 10.5V6a2 2 0 0 0-4 0v8"/><path d="M6 14v-2a2 2 0 1 0-4 0v3a8 8 0 0 0 8 8h3a7 7 0 0 0 7-7v-4a2 2 0 1 0-4 0v1"/></symbol>
        <symbol id="i-sparkles" viewBox="0 0 24 24"><path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3L12 3Z"/><path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/></symbol>
        <symbol id="i-drama" viewBox="0 0 24 24"><path d="M10 11h.01"/><path d="M14 6h.01"/><path d="M18 6h.01"/><path d="M6.5 13.1h.01"/><path d="M22 5c0 9-4 12-6 12s-6-3-6-12c0-2 2-3 6-3s6 1 6 3"/><path d="M17.4 9.9c-.8.8-2 .8-2.8 0"/><path d="M10.1 7.1C9 7.2 7.7 7.7 6 8.6c-3.5 2-4.7 3.9-3.7 5.6 4.5 7.8 9.5 8.4 11.2 7.4.9-.5 1.9-2.1 1.9-4.7"/><path d="M9.1 16.5c.3-1.1 1.4-1.7 2.4-1.4"/></symbol>
        <symbol id="i-trash" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/></symbol>
        <!-- Mode chips -->
        <symbol id="i-message" viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></symbol>
        <symbol id="i-bot" viewBox="0 0 24 24"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></symbol>
        <symbol id="i-eye" viewBox="0 0 24 24"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></symbol>
        <symbol id="i-code" viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></symbol>
        <symbol id="i-target" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></symbol>
        <symbol id="i-clipboard" viewBox="0 0 24 24"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></symbol>
        <symbol id="i-trophy" viewBox="0 0 24 24"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></symbol>
        <symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></symbol>
        <symbol id="i-mail" viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></symbol>
        <symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></symbol>
        <symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></symbol>
        <symbol id="i-masks" viewBox="0 0 24 24"><path d="M3 6c0-1.7 1.3-3 3-3h12c1.7 0 3 1.3 3 3v6c0 5-4 9-9 9s-9-4-9-9V6Z"/><path d="M8 10c0 .5.5 1 1 1s1-.5 1-1"/><path d="M14 10c0 .5.5 1 1 1s1-.5 1-1"/></symbol>
        <!-- Misc inline -->
        <symbol id="i-paperclip" viewBox="0 0 24 24"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></symbol>
        <symbol id="i-send" viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></symbol>
        <symbol id="i-stop" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></symbol>
        <symbol id="i-loader" viewBox="0 0 24 24"><path d="M21 12a9 9 0 0 1-9 9"/><path d="M3 12a9 9 0 0 1 9-9"/></symbol>
        <symbol id="i-x" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></symbol>
        <symbol id="i-plus" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></symbol>
        <symbol id="i-trending-up" viewBox="0 0 24 24"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></symbol>
        <!-- Sprint-1 fix — additional Lucide symbols for emoji→icon swap. -->
        <symbol id="i-star" viewBox="0 0 24 24"><polygon points="12 2 15 8.5 22 9.3 17 14 18.2 21 12 17.8 5.8 21 7 14 2 9.3 9 8.5 12 2"/></symbol>
        <symbol id="i-save" viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></symbol>
        <symbol id="i-play" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></symbol>
        <symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></symbol>
        <symbol id="i-undo" viewBox="0 0 24 24"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6.7 3L3 13"/></symbol>
        <symbol id="i-keyboard" viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="16" rx="2" ry="2"/><line x1="6" y1="8" x2="6" y2="8"/><line x1="10" y1="8" x2="10" y2="8"/><line x1="14" y1="8" x2="14" y2="8"/><line x1="18" y1="8" x2="18" y2="8"/><line x1="6" y1="12" x2="6" y2="12"/><line x1="10" y1="12" x2="10" y2="12"/><line x1="14" y1="12" x2="14" y2="12"/><line x1="18" y1="12" x2="18" y2="12"/><line x1="7" y1="16" x2="17" y2="16"/></symbol>
        <symbol id="i-square" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></symbol>
        <symbol id="i-arrow-up" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></symbol>
        <symbol id="i-compass" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/></symbol>
        <symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></symbol>
        <symbol id="i-moon" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></symbol>
        <!-- Sprint-2 fix — final emoji-as-icon sweep. -->
        <symbol id="i-edit" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></symbol>
        <symbol id="i-lock" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol>
        <symbol id="i-bell" viewBox="0 0 24 24"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></symbol>
        <symbol id="i-bookmark" viewBox="0 0 24 24"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></symbol>
        <symbol id="i-lightbulb" viewBox="0 0 24 24"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.7.5 1 1.3 1 2.1V18h6v-1.2c0-.8.3-1.6 1-2.1A7 7 0 0 0 12 2z"/></symbol>
        <symbol id="i-sunrise" viewBox="0 0 24 24"><path d="M17 18a5 5 0 0 0-10 0"/><line x1="12" y1="2" x2="12" y2="9"/><line x1="4.22" y1="10.22" x2="5.64" y2="11.64"/><line x1="1" y1="18" x2="3" y2="18"/><line x1="21" y1="18" x2="23" y2="18"/><line x1="18.36" y1="11.64" x2="19.78" y2="10.22"/><line x1="23" y1="22" x2="1" y2="22"/><polyline points="8 6 12 2 16 6"/></symbol>
        <symbol id="i-video" viewBox="0 0 24 24"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></symbol>
        <symbol id="i-circle-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></symbol>
        <symbol id="i-gamepad" viewBox="0 0 24 24"><line x1="6" y1="11" x2="10" y2="11"/><line x1="8" y1="9" x2="8" y2="13"/><line x1="15" y1="12" x2="15.01" y2="12"/><line x1="18" y1="10" x2="18.01" y2="10"/><path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59L2 14a2 2 0 0 0 2 2h.5a3 3 0 0 0 2.7-1.5 1.6 1.6 0 0 1 1.4-.8h7c.5 0 .995.3 1.4.8a3 3 0 0 0 2.7 1.5h.5a2 2 0 0 0 2-2l-.7-5.41A4 4 0 0 0 17.32 5z"/></symbol>
        <symbol id="i-plug-add" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></symbol>
        <symbol id="i-file" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></symbol>
        <symbol id="i-flame" viewBox="0 0 24 24"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></symbol>
        <symbol id="i-smartphone" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><path d="M12 18h.01"/></symbol>
        <!-- Sprint-2.9 — Plan/Act gate iconography. shield = "approval-required" hint, check = approve action. -->
        <symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></symbol>
        <symbol id="i-check" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></symbol>
      </defs>
    </svg>
    
    <!-- ═══════════ MULTI-CHAT SIDEBAR (mounted at boot) ═══════════ -->
    <!-- PR-LAYOUT-V3 — was `<aside ... hidden>`. The `hidden` attribute was
         removed in JS only after `bootCurrentChat()` resolved successfully
         (line 7084). On a slow IPC or any error in the boot chain the
         sidebar — the entire primary navigation — would never become
         visible. Now the sidebar is always in DOM; CSS gates its visual
         state via `body.with-sidebar` and `body.chat-sidebar-collapsed`,
         and we add `with-sidebar` synchronously below as a safety net. -->
    <aside id="chatside" class="chatside">
      <div class="cs-head">
        <button class="cs-new" id="cs-new-btn" onclick="createNewChat()">+ New chat</button>
      </div>
      <div class="cs-search">
        <div class="cs-search-wrap">
          <span class="cs-search-icon"><svg class="licon"><use href="#i-search"/></svg></span>
          <input class="cs-search-input" id="cs-search" placeholder="Search chats…" oninput="filterChats(this.value)" />
        </div>
      </div>
      <nav class="cs-activity" id="shell-nav" aria-label="Horizon workspace navigation">
        <button class="cs-act" data-shell-target="dashboard" onclick="openDashboard()" title="Dashboard — home screen with today's stats, active tasks, and recent activity">
          <svg class="licon"><use href="#i-layout"/></svg><span>Home</span>
        </button>
        <button class="cs-act on" data-shell-target="chat" onclick="showChatSurface()" title="Chat">
          <svg class="licon"><use href="#i-message"/></svg><span>Chat</span>
        </button>
        <button class="cs-act cs-collapse" id="cs-collapse-btn" onclick="toggleChatSidebar()" title="Hide or show chat list">
          <svg class="licon"><use href="#i-sidebar"/></svg><span id="cs-collapse-label">Collapse</span>
        </button>
        <button class="cs-act" data-shell-target="agent" id="operator-mode-btn" onclick="toggleOperatorMode()" title="Operator Console">
          <svg class="licon"><use href="#i-terminal"/></svg><span>Agent</span>
        </button>
        <button class="cs-act" data-shell-target="code" id="code-mode-btn" onclick="toggleCodeMode()" title="Code Mode IDE">
          <svg class="licon"><use href="#i-code"/></svg><span>Code</span>
        </button>
        <button class="cs-act" data-shell-target="workflows" onclick="openWorkflows()" title="Workflows">
          <svg class="licon"><use href="#i-workflow"/></svg><span>Flows</span>
        </button>
        <button class="cs-act" data-shell-target="plugins" onclick="openHub()" title="Installed Plugins">
          <svg class="licon"><use href="#i-puzzle"/></svg><span>Plugins</span>
        </button>
        <button class="cs-act" data-shell-target="skills" onclick="openSkillHub()" title="Skills — markdown bundles auto-loaded by relevance">
          <svg class="licon"><use href="#i-book"/></svg><span>Skills</span>
        </button>
        <button class="cs-act" data-shell-target="telegram" onclick="openTelegramHub()" title="Telegram chats — view bot conversations and reply from the desktop">
          <svg class="licon"><use href="#i-send"/></svg><span>Telegram</span>
        </button>
        <button class="cs-act" data-shell-target="discord" onclick="openDiscordHub()" title="Discord channels — bidirectional bot via Gateway WebSocket">
          <svg class="licon"><use href="#i-discord"/></svg><span>Discord</span>
        </button>
        <button class="cs-act" data-shell-target="marketplace" onclick="openStore()" title="Marketplace">
          <svg class="licon"><use href="#i-store"/></svg><span>Store</span>
        </button>
        <button class="cs-act" data-shell-target="canvas" id="canvas-mode-btn" onclick="window.toggleCanvas?.()" title="Live Canvas — shared editable surface with the agent">
          <svg class="licon"><use href="#i-file-text"/></svg><span>Canvas</span>
        </button>
        <button class="cs-act" data-shell-target="inspector" id="inspector-mode-btn" onclick="toggleInspectorMode()" title="Context, Tools, Cost, Log">
          <svg class="licon"><use href="#i-sidebar"/></svg><span>Inspect</span>
        </button>
      </nav>
      <div class="cs-list" id="chatlist"></div>
      <div class="cs-utility">
        <button class="cs-util" onclick="openAccount()" title="Horizon account"><svg class="licon"><use href="#i-user"/></svg><span>Account</span></button>
        <button class="cs-util" onclick="openPanel()" title="Settings"><svg class="licon"><use href="#i-settings"/></svg><span>Settings</span></button>
      </div>
      <div class="cs-stats" id="cs-stats">
        <div class="cs-stat"><svg class="licon"><use href="#i-mail"/></svg> <span class="cs-stat-val" id="stat-msgs">0</span> msgs</div>
        <div class="cs-stat"><svg class="licon"><use href="#i-star"/></svg> <span class="cs-stat-val" id="stat-tokens">0</span> tokens</div>
        <div class="cs-stat"><span>Usage</span> <span class="cs-stat-val" id="stat-usage">estimated</span></div>
      </div>
    </aside>
    
    <!-- ═══════════ SETTINGS PANEL ═══════════
         Tabbed layout: 9 sections in the left rail, scroll area on the right.
         Inspired by ChatGPT / Cursor settings — group by purpose, not by feature
         drop-date. Every existing input id stays the same so saveModelSetting,
         psvk, saveLocalProvider, etc. continue to work without renaming. -->
    <div class="panel" id="panel">
      <div class="ph">
        <!-- PR-FIX-OVERLAP — explicit Back button on the LEFT of the
             header so the user always has a clear escape route, even when
             the top-right ×  is hidden behind Windows native overlay. -->
        <button class="ph-back" onclick="closePanel()" title="Back to chat (Esc)">← Back</button>
        <h2><svg class="licon" style="vertical-align:-2px"><use href="#i-settings"/></svg> Settings — Horizon AI</h2>
        <!-- Phase 20.3 — search bar filters .pf rows by label text across all
             panes. Empty input = no filter. -->
        <div class="ps-search-wrap">
          <svg class="ps-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
            <circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.3-4.3"></path>
          </svg>
          <input id="ps-search" class="ps-search-input" type="search" placeholder="Search settings…" oninput="filterSettings(this.value)" autocomplete="off"/>
          <button class="ps-search-clear" type="button" onclick="document.getElementById('ps-search').value='';filterSettings('')" title="Clear">×</button>
        </div>
        <button class="ph-x" onclick="closePanel()" title="Close (Esc)">×</button>
      </div>
      <div class="pbody-tabbed">
        <nav class="psnav" id="psnav">
          <button class="psnav-tab on" onclick="setSettingsTab('account')"  data-tab="account"><span class="psn-i"><svg class="licon"><use href="#i-user"/></svg></span><span>Account</span></button>
          <button class="psnav-tab"    onclick="setSettingsTab('profile')"  data-tab="profile"><span class="psn-i"><svg class="licon"><use href="#i-file-text"/></svg></span><span>Profile</span></button>
          <button class="psnav-tab"    onclick="setSettingsTab('models')"   data-tab="models"><span class="psn-i"><svg class="licon"><use href="#i-brain"/></svg></span><span>Models</span></button>
          <button class="psnav-tab"    onclick="setSettingsTab('providers')" data-tab="providers"><span class="psn-i"><svg class="licon"><use href="#i-key"/></svg></span><span>Providers</span></button>
          <button class="psnav-tab"    onclick="setSettingsTab('connections')" data-tab="connections"><span class="psn-i"><svg class="licon"><use href="#i-plug"/></svg></span><span>Connections</span></button>
          <button class="psnav-tab"    onclick="setSettingsTab('mobile')"   data-tab="mobile"><span class="psn-i"><svg class="licon"><use href="#i-smartphone"/></svg></span><span>Mobile</span></button>
          <button class="psnav-tab"    onclick="setSettingsTab('voice')"    data-tab="voice"><span class="psn-i"><svg class="licon"><use href="#i-mic"/></svg></span><span>Voice &amp; Wake</span></button>
          <button class="psnav-tab"    onclick="setSettingsTab('features')" data-tab="features"><span class="psn-i"><svg class="licon"><use href="#i-sparkles"/></svg></span><span>Features</span></button>
          <button class="psnav-tab"    onclick="setSettingsTab('personas')" data-tab="personas"><span class="psn-i"><svg class="licon"><use href="#i-drama"/></svg></span><span>Personas</span></button>
          <button class="psnav-tab"    onclick="setSettingsTab('plugins')"  data-tab="plugins"><span class="psn-i"><svg class="licon"><use href="#i-puzzle"/></svg></span><span>Plugins</span></button>
          <button class="psnav-tab"    onclick="setSettingsTab('macros')"   data-tab="macros"><span class="psn-i"><svg class="licon"><use href="#i-replay"/></svg></span><span>Macros</span></button>
          <button class="psnav-tab"    onclick="setSettingsTab('data')"     data-tab="data"><span class="psn-i"><svg class="licon"><use href="#i-trash"/></svg></span><span>Data &amp; Health</span></button>
        </nav>
        <!-- PR-U4 — sticky right-side TOC (Linear / Vercel pattern). Only
             visible when body.settings-scroll is on (default). Replaces the
             left-side .psnav rail; clicking an entry scrolls smoothly to
             the matching <section data-pane="…"> + highlights via the
             IntersectionObserver wired in JS. -->
        <nav class="ps-toc" id="ps-toc">
          <div class="ps-toc-h">SETTINGS · 11</div>
          <a class="ps-toc-link on" data-target="account"     onclick="setSettingsTab('account');return false"><svg class="licon"><use href="#i-user"/></svg>Account</a>
          <a class="ps-toc-link"    data-target="profile"     onclick="setSettingsTab('profile');return false"><svg class="licon"><use href="#i-file-text"/></svg>Profile</a>
          <a class="ps-toc-link"    data-target="models"      onclick="setSettingsTab('models');return false"><svg class="licon"><use href="#i-brain"/></svg>Models</a>
          <a class="ps-toc-link"    data-target="providers"   onclick="setSettingsTab('providers');return false"><svg class="licon"><use href="#i-key"/></svg>Providers</a>
          <a class="ps-toc-link"    data-target="connections" onclick="setSettingsTab('connections');return false"><svg class="licon"><use href="#i-plug"/></svg>Connections</a>
          <a class="ps-toc-link"    data-target="mobile"      onclick="setSettingsTab('mobile');return false"><svg class="licon"><use href="#i-smartphone"/></svg>Mobile</a>
          <a class="ps-toc-link"    data-target="voice"       onclick="setSettingsTab('voice');return false"><svg class="licon"><use href="#i-mic"/></svg>Voice</a>
          <a class="ps-toc-link"    data-target="features"    onclick="setSettingsTab('features');return false"><svg class="licon"><use href="#i-sparkles"/></svg>Features</a>
          <a class="ps-toc-link"    data-target="personas"    onclick="setSettingsTab('personas');return false"><svg class="licon"><use href="#i-drama"/></svg>Personas</a>
          <a class="ps-toc-link"    data-target="plugins"     onclick="setSettingsTab('plugins');return false"><svg class="licon"><use href="#i-puzzle"/></svg>Plugins</a>
          <a class="ps-toc-link"    data-target="macros"      onclick="setSettingsTab('macros');return false"><svg class="licon"><use href="#i-replay"/></svg>Macros</a>
          <a class="ps-toc-link"    data-target="data"        onclick="setSettingsTab('data');return false"><svg class="licon"><use href="#i-trash"/></svg>Data &amp; Health</a>
        </nav>
        <div class="psview" id="psview">
    
          <!-- ───────── ACCOUNT ───────── -->
          <section data-pane="account" class="on">
            <div class="psview-h">Account</div>
            <div class="psview-sub">Sign-in state and identity badges. Sign in or out via the Account button on the title bar; this section reflects what the marketplace knows about you.</div>
            <div class="ps">
              <!-- Sprint-2.14 — polish audit flagged the previous single
                   info-box telling the user to "Open the Account menu in
                   the title bar" as feeling empty on cold launch. Now a
                   proper card with avatar + sign-in CTA + benefits list,
                   replaced by the live profile card after sign-in via JS
                   (chat-account.js setSettingsAccountCard). -->
              <div id="settings-account-card" class="settings-acct-stub">
                <div class="settings-acct-avatar"><svg class="licon lg"><use href="#i-user"/></svg></div>
                <div class="settings-acct-body">
                  <div class="settings-acct-title">Not signed in</div>
                  <div class="settings-acct-sub">Sign in to unlock the marketplace, sync skills across devices, and earn badges (Owner / Official / Pro / Verified).</div>
                  <div class="settings-acct-actions">
                    <button class="hub-btn primary" onclick="openAccount && openAccount()">Sign in</button>
                    <button class="hub-btn" onclick="H.openUrl && H.openUrl('https://horizonai.dev/account')">Open web account ↗</button>
                  </div>
                </div>
              </div>
            </div>
          </section>
    
          <!-- ───────── PROFILE ───────── -->
          <section data-pane="profile">
            <div class="psview-h">Profile</div>
            <div class="psview-sub">Local display preferences. Stored on your device, never sent anywhere.</div>
            <div class="ps">
              <div class="pf"><label>Name</label><div class="pr"><input class="pi" type="text" id="pi-name" placeholder="Ernest"/><button class="psv" onclick="savePf()">Save</button></div></div>
              <!-- PHASE 28.1 — UI is English-only. The dropdown is kept (single
                   option) so the existing saveLang() / persisted setting paths
                   keep working without churn; we just never offer Russian. -->
              <div class="pf"><label>Language</label><select class="sel" id="pi-lang" onchange="saveLang()"><option value="en">🇺🇸 English</option></select></div>
              <div class="local-status ok" id="settings-save-status">Settings are stored locally and restored on launch.</div>
            </div>
          </section>
    
          <!-- ───────── MODELS ───────── -->
          <section data-pane="models">
            <div class="psview-h">Models</div>
            <div class="psview-sub">Pick which exact model each provider uses, and how much "thinking" Horizon should do per turn. The model dropdown is also reachable from the chat header — click the model pill above the conversation.</div>
            <div class="ps">
          <div class="pf"><label>Response Mode</label><div class="pr"><select class="pi ms-sel" id="ms-response-profile" onchange="saveResponseProfile(this.value);updateThinkingHint()">
            <option value="fast">⚡ Fast (Speed)</option>
            <option value="balanced" selected>⚖️ Balanced</option>
            <option value="deep">🧠 Deep (Reasoning)</option>
          </select></div>
          <div class="local-status" id="thinking-hint" class="mt-6 fs-sm-105">Deep mode uses provider-native reasoning/thinking only when the selected model supports it.</div></div>
    
          <!-- Subagent model — used by spawn_subagent for parallel-friendly
               research / multi-source lookups. Defaults to "same as main",
               but you typically want a cheaper/faster model here (e.g. Gemini
               Flash for subagents while Claude Opus drives the main loop). -->
          <div class="pf"><label>Subagent provider <small class="t3-norm">used by <code>spawn_subagent</code></small></label><div class="pr"><select class="pi ms-sel" id="ms-subagent-provider" onchange="saveSubagentProvider(this.value)">
            <option value="">Same as main agent</option>
            <option value="claude">Claude (Anthropic)</option>
            <option value="openai">OpenAI</option>
            <option value="gemini">Gemini (Google)</option>
            <option value="groq">Groq</option>
            <option value="deepseek">DeepSeek</option>
            <option value="grok">Grok (xAI)</option>
            <option value="mistral">Mistral</option>
            <option value="qwen">Qwen</option>
            <option value="perplexity">Perplexity</option>
            <option value="cohere">Cohere</option>
            <option value="openrouter">OpenRouter</option>
            <option value="ollama">Ollama (local)</option>
            <option value="lmstudio">LM Studio (local)</option>
          </select></div>
          <div class="local-status mt-6 fs-sm-105">Picking a cheaper/faster model here keeps parallel subagent work affordable when the main agent runs a flagship model.</div></div>
          <div class="pf"><label>Subagent model</label><div class="pr"><input class="pi" id="ms-subagent-model" type="text" placeholder="Leave empty to use the provider's default" oninput="saveSubagentModel(this.value)"/></div>
          <div class="local-status mt-6 fs-sm-105">Exact model id (e.g. <code>gemini-2.5-flash</code>, <code>gpt-5.4-mini</code>, <code>claude-haiku-4-5</code>). Empty = whichever model is selected for that provider in the rows below.</div></div>
    
          <!-- Execution sandbox — host (fast) vs Docker (isolated) vs remote
               backends (SSH/Modal/Daytona, BYOK). Used by
               run_code / run_shell / run_powershell. -->
          <div class="pf"><label>Code execution mode <small class="t3-norm">used by <code>run_code</code></small></label><div class="pr"><select class="pi ms-sel" id="ms-execution-mode" onchange="saveExecutionMode(this.value)">
            <option value="host">Host (direct, fast)</option>
            <option value="docker">Docker (sandboxed, slower)</option>
            <option value="ssh">SSH (run on a remote machine)</option>
            <option value="modal">Modal (serverless, BYOK)</option>
            <option value="daytona">Daytona (dev workspaces, BYOK)</option>
            <option value="singularity">Singularity / Apptainer (HPC clusters)</option>
          </select></div>
          <div class="local-status" id="ms-executor-status" class="mt-6 fs-sm-105">Loading…</div></div>
          <div class="pf"><label>Docker workspace mount</label><div class="pr"><select class="pi ms-sel" id="ms-docker-mount" onchange="saveDockerWorkspaceMount(this.value)">
            <option value="none">None (no workspace access)</option>
            <option value="read-only">Read-only (safe default)</option>
            <option value="read-write">Read-write (let code edit files)</option>
          </select></div>
          <div class="local-status mt-6 fs-sm-105">When Docker mode is active, the current workspace is mounted at <code>/workspace</code> with the chosen permission. Containers run with <code>--network=none --memory=512m --cpus=1</code> by default.</div></div>
    
          <!-- SSH executor fields — visible only when executionMode = ssh -->
          <div class="pf ms-ssh-only" style="display:none">
            <label>SSH host <small class="t3-norm">user@host or IP</small></label>
            <div class="pr"><input class="pi" id="ms-ssh-host" placeholder="user@example.com" onchange="saveExecBackendField('ssh.host', this.value)"></div>
          </div>
          <div class="pf ms-ssh-only" style="display:none">
            <label>SSH port <small class="t3-norm">default 22</small></label>
            <div class="pr"><input class="pi" id="ms-ssh-port" placeholder="22" onchange="saveExecBackendField('ssh.port', this.value)"></div>
          </div>
          <div class="pf ms-ssh-only" style="display:none">
            <label>SSH private key path <small class="t3-norm">optional — uses ssh-agent if blank</small></label>
            <div class="pr"><input class="pi" id="ms-ssh-key" placeholder="~/.ssh/id_ed25519" onchange="saveExecBackendField('ssh.keyPath', this.value)"></div>
          </div>
          <div class="pf ms-ssh-only" style="display:none">
            <label>SSH remote working dir <small class="t3-norm">optional — runs in $HOME otherwise</small></label>
            <div class="pr"><input class="pi" id="ms-ssh-workdir" placeholder="/home/user/work" onchange="saveExecBackendField('ssh.workdir', this.value)"></div>
            <div class="local-status mt-6 fs-sm-105">Uses your OS's <code>ssh</code> client. Code is piped over stdin to <code>python3 -</code> / <code>node -</code> / <code>sh -</code> on the remote host. No new deps.</div>
          </div>
    
          <!-- Modal executor fields — visible only when executionMode = modal -->
          <div class="pf ms-modal-only" style="display:none">
            <label>Modal token id <small class="t3-norm">starts with ak-</small></label>
            <div class="pr"><input class="pi" id="ms-modal-token-id" placeholder="ak-..." onchange="saveExecBackendField('modal.tokenId', this.value)"></div>
          </div>
          <div class="pf ms-modal-only" style="display:none">
            <label>Modal token secret</label>
            <div class="pr"><input class="pi" id="ms-modal-token-secret" type="password" placeholder="as-..." onchange="saveExecBackendField('modal.tokenSecret', this.value)"></div>
          </div>
          <div class="pf ms-modal-only" style="display:none">
            <label>Modal app name <small class="t3-norm">default: horizon-exec</small></label>
            <div class="pr"><input class="pi" id="ms-modal-app-name" placeholder="horizon-exec" onchange="saveExecBackendField('modal.appName', this.value)"></div>
            <div class="local-status mt-6 fs-sm-105">Sign up at <strong>modal.com</strong> (~$30/mo credit free), run <code>modal token new</code> locally to get the credentials, then deploy the Horizon runner function (see <code>docs/deploy-modal.md</code>).</div>
          </div>
    
          <!-- Daytona executor fields — visible only when executionMode = daytona -->
          <div class="pf ms-daytona-only" style="display:none">
            <label>Daytona server URL</label>
            <div class="pr"><input class="pi" id="ms-daytona-server" placeholder="https://daytona.example.com" onchange="saveExecBackendField('daytona.serverUrl', this.value)"></div>
          </div>
          <div class="pf ms-daytona-only" style="display:none">
            <label>Daytona API key</label>
            <div class="pr"><input class="pi" id="ms-daytona-key" type="password" placeholder="dt_..." onchange="saveExecBackendField('daytona.apiKey', this.value)"></div>
          </div>
          <div class="pf ms-daytona-only" style="display:none">
            <label>Daytona workspace id</label>
            <div class="pr"><input class="pi" id="ms-daytona-workspace" placeholder="my-workspace" onchange="saveExecBackendField('daytona.workspaceId', this.value)"></div>
            <div class="local-status mt-6 fs-sm-105">Self-host Daytona or use their managed cloud (<strong>daytona.io</strong>), create a workspace, generate an API key.</div>
          </div>
    
          <!-- Singularity / Apptainer executor fields — visible only when executionMode = singularity -->
          <div class="pf ms-singularity-only" style="display:none">
            <label>Singularity image <small class="t3-norm">docker://, library://, or a local .sif path</small></label>
            <div class="pr"><input class="pi" id="ms-singularity-image" placeholder="docker://python:3.12-slim" onchange="saveExecBackendField('singularity.image', this.value)"></div>
          </div>
          <div class="pf ms-singularity-only" style="display:none">
            <label>Bind mounts <small class="t3-norm">comma-separated host:container pairs, optional</small></label>
            <div class="pr"><input class="pi" id="ms-singularity-bind" placeholder="/data:/data,/scratch:/scratch" onchange="saveExecBackendField('singularity.bind', this.value)"></div>
          </div>
          <div class="pf ms-singularity-only" style="display:none">
            <label>Binary override <small class="t3-norm">auto-detects singularity / apptainer if blank</small></label>
            <div class="pr"><input class="pi" id="ms-singularity-binary" placeholder="apptainer" onchange="saveExecBackendField('singularity.binary', this.value)"></div>
            <div class="local-status mt-6 fs-sm-105">For HPC clusters where Docker is blocked. Make sure <code>singularity</code> or <code>apptainer</code> is on PATH (often via <code>module load apptainer</code>).</div>
          </div>
          <div class="pf"><label>Image generation (BYOK)</label><div class="pr"><select class="pi ms-sel" id="ms-image-provider" onchange="saveImageGenerationSetting('provider', this.value)">
            <option value="auto">Auto from active provider/key</option>
            <option value="gemini">Gemini image</option>
            <option value="openai">OpenAI image</option>
          </select></div>
          <div class="local-status mt-6 fs-sm-105">Separate image models. Chat model selection never routes image prompts to fake or text-only models.</div></div>
          <div class="pf"><label>OpenAI image model</label><div class="pr"><select class="pi ms-sel" id="ms-image-openai" onchange="saveImageGenerationSetting('model.openai', this.value)">
            <option value="gpt-image-2">gpt-image-2</option>
            <option value="gpt-image-1.5">gpt-image-1.5</option>
            <option value="gpt-image-1">gpt-image-1</option>
            <option value="gpt-image-1-mini">gpt-image-1-mini</option>
          </select></div></div>
          <div class="pf"><label>Gemini image model</label><div class="pr"><select class="pi ms-sel" id="ms-image-gemini" onchange="saveImageGenerationSetting('model.gemini', this.value)">
            <option value="gemini-2.5-flash-image">gemini-2.5-flash-image - Nano Banana</option>
            <option value="gemini-3-pro-image-preview">gemini-3-pro-image-preview - Nano Banana Pro</option>
            <option value="gemini-3.1-flash-image-preview">gemini-3.1-flash-image-preview - Nano Banana 2 preview</option>
            <option value="imagen-4.0-generate-001">imagen-4.0-generate-001 - Imagen 4</option>
            <option value="imagen-4.0-ultra-generate-001">imagen-4.0-ultra-generate-001 - Imagen 4 Ultra</option>
            <option value="imagen-4.0-fast-generate-001">imagen-4.0-fast-generate-001 - Imagen 4 Fast</option>
          </select></div></div>
          <div class="pf"><label>Claude (Anthropic)</label><div class="pr"><select class="pi ms-sel" id="ms-claude" onchange="saveModelSetting('claude', this.value)">
            <option value="claude-sonnet-4-6">Sonnet 4.6 — balanced (default)</option>
            <option value="claude-opus-4-7">Opus 4.7 — most capable, agentic coding</option>
            <option value="claude-haiku-4-5">Haiku 4.5 — fastest</option>
          </select></div></div>
          <div class="pf"><label>OpenAI</label><div class="pr"><select class="pi ms-sel" id="ms-openai" onchange="saveModelSetting('openai', this.value)">
            <option value="gpt-5.5">gpt-5.5 - frontier</option>
            <option value="gpt-5.4">gpt-5.4 - flagship</option>
            <option value="gpt-5.4-mini">gpt-5.4-mini - fast/compact</option>
            <option value="gpt-5.3-codex">gpt-5.3-codex - coding</option>
            <option value="gpt-5.2">gpt-5.2 - reliable fallback</option>
            <option value="o3">o3 — top-tier reasoning 🧠</option>
            <option value="o4-mini">o4-mini — reasoning-tuned 🧠</option>
          </select></div></div>
          <div class="pf"><label>Gemini (Google)</label><div class="pr"><select class="pi ms-sel" id="ms-gemini" onchange="saveModelSetting('gemini', this.value)">
            <option value="gemini-3.1-pro-preview">gemini-3.1-pro-preview - strongest preview</option>
            <option value="gemini-3.1-flash-preview">gemini-3.1-flash-preview - fast preview</option>
            <option value="gemini-3.0-pro-preview">gemini-3.0-pro-preview - pro preview</option>
            <option value="gemini-2.5-pro">gemini-2.5-pro — most capable (2.5) 🧠</option>
            <option value="gemini-2.5-flash" selected>gemini-2.5-flash — fast default 🧠</option>
            <option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite — lightweight</option>
          </select></div></div>
          <div class="pf"><label>Groq</label><div class="pr"><select class="pi ms-sel" id="ms-groq" onchange="saveModelSetting('groq', this.value)">
            <option value="llama-3.3-70b-versatile">Llama 3.3 70B Versatile (default)</option>
            <option value="llama-3.1-8b-instant">Llama 3.1 8B Instant — fastest</option>
            <option value="qwen/qwen3-32b">Qwen3 32B (Groq-hosted)</option>
            <option value="moonshotai/kimi-k2-instruct">Kimi K2 — long-context</option>
            <option value="openai/gpt-oss-120b">GPT-OSS 120B (open-weight)</option>
          </select></div></div>
          <div class="pf"><label>DeepSeek</label><div class="pr"><select class="pi ms-sel" id="ms-deepseek" onchange="saveModelSetting('deepseek', this.value)">
            <option value="deepseek-chat">deepseek-chat — default</option>
            <option value="deepseek-reasoner">deepseek-reasoner — chain-of-thought</option>
          </select></div></div>
          <div class="pf"><label>Grok (xAI)</label><div class="pr"><select class="pi ms-sel" id="ms-grok" onchange="saveModelSetting('grok', this.value)">
            <option value="grok-4">grok-4 — flagship (default)</option>
            <option value="grok-4-fast-reasoning">grok-4-fast-reasoning — quick reasoning</option>
            <option value="grok-4-mini">grok-4-mini — compact</option>
            <option value="grok-code-fast-1">grok-code-fast-1 — code-tuned</option>
          </select></div></div>
          <div class="pf"><label>Mistral</label><div class="pr"><select class="pi ms-sel" id="ms-mistral" onchange="saveModelSetting('mistral', this.value)">
            <option value="mistral-large-latest">mistral-large-latest (default)</option>
            <option value="mistral-medium-latest">mistral-medium-latest</option>
            <option value="mistral-small-latest">mistral-small-latest — compact</option>
            <option value="codestral-latest">codestral-latest — code</option>
          </select></div></div>
          <div class="pf"><label>Qwen (Alibaba)</label><div class="pr"><select class="pi ms-sel" id="ms-qwen" onchange="saveModelSetting('qwen', this.value)">
            <option value="qwen-plus">qwen-plus — workhorse (default)</option>
            <option value="qwen3-max">qwen3-max — flagship</option>
            <option value="qwen3-coder-plus">qwen3-coder-plus — code-tuned</option>
          </select></div></div>
          <div class="pf"><label>Perplexity (web-grounded)</label><div class="pr"><select class="pi ms-sel" id="ms-perplexity" onchange="saveModelSetting('perplexity', this.value)">
            <option value="sonar-pro">sonar-pro — search-grounded chat (default)</option>
            <option value="sonar">sonar — basic search chat</option>
            <option value="sonar-reasoning">sonar-reasoning — w/ chain-of-thought</option>
            <option value="sonar-reasoning-pro">sonar-reasoning-pro — pro reasoning</option>
          </select></div></div>
          <div class="pf"><label>Cohere</label><div class="pr"><select class="pi ms-sel" id="ms-cohere" onchange="saveModelSetting('cohere', this.value)">
            <option value="command-a-03-2025">command-a-03-2025 — flagship (default)</option>
            <option value="command-a-reasoning-08-2025">command-a-reasoning-08-2025</option>
            <option value="command-a-vision-07-2025">command-a-vision-07-2025</option>
            <option value="command-r-plus-08-2024">command-r-plus-08-2024 — legacy</option>
          </select></div></div>
          <div class="pf"><label>OpenRouter <span class="t3-xs">(one key, 200+ models)</span></label><div class="pr">
            <select class="pi ms-sel" id="ms-openrouter" onchange="saveModelSetting('openrouter', this.value)">
              <option value="">(refresh to load models)</option>
            </select>
            <input class="pi" type="text" id="pi-openrouter-search" placeholder="filter…" oninput="filterOpenRouterModels(this.value)" style="max-width:120px"/>
            <button class="psv" onclick="refreshOpenRouterModels()" title="Pull the latest model catalog from OpenRouter">↻</button>
          </div></div>
            </div>
          </section>
    
          <!-- ───────── PROVIDERS ───────── -->
          <section data-pane="providers">
            <div class="psview-h">Providers</div>
            <div class="psview-sub">Add API keys for cloud providers and configure local servers (Ollama, LM Studio). Keys are stored encrypted on your device only — Horizon never sees your prompts. Each "Get key ↗" link opens the relevant dashboard.</div>
            <div class="ps">
              <div class="psl">Local servers</div>
              <div class="pf local-provider"><label><span class="d2" id="pd-ollama"></span>Ollama — local models, no cloud<a onclick="H.openUrl('https://ollama.com')">Install ↗</a></label><div class="pr"><input class="pi" type="text" id="pi-ollama-url" placeholder="http://127.0.0.1:11434/v1"/><select class="pi" id="pi-ollama-model"><option value="">(refresh to load models)</option></select><button class="psv" onclick="refreshLocalModels('ollama')" title="Pull the latest model list from the local server">↻</button><button class="psv" onclick="saveLocalProvider('ollama')">Save</button><button class="psv" onclick="testLocalProvider('ollama')">Test</button></div><div class="local-status" id="local-ollama-status">Install Ollama, run a model, then click ↻ to load the model list.</div></div>
              <div class="pf local-provider"><label><span class="d2" id="pd-lmstudio"></span>LM Studio — local OpenAI-compatible server<a onclick="H.openUrl('https://lmstudio.ai')">Install ↗</a></label><div class="pr"><input class="pi" type="text" id="pi-lmstudio-url" placeholder="http://127.0.0.1:1234/v1"/><select class="pi" id="pi-lmstudio-model"><option value="">(refresh to load models)</option></select><button class="psv" onclick="refreshLocalModels('lmstudio')" title="Pull the latest model list from the local server">↻</button><button class="psv" onclick="saveLocalProvider('lmstudio')">Save</button><button class="psv" onclick="testLocalProvider('lmstudio')">Test</button></div><div class="local-status" id="local-lmstudio-status">Install LM Studio, start the local server, then click ↻ to load the model list.</div></div>
            </div>
            <div class="ps">
              <div class="psl">Cloud API keys</div>
              <div class="pf"><label><span class="d2" id="pd-gemini"></span>Gemini models<a onclick="H.openUrl('https://aistudio.google.com/apikey')">Get key ↗</a></label><div class="pr"><input class="pi" type="password" id="pi-gemini" placeholder="AIzaSy…"/><button class="psv" id="pb-gemini" onclick="psvk('gemini','pi-gemini','pb-gemini','pd-gemini')">Save</button></div></div>
          <div class="pf"><label><span class="d2" id="pd-groq"></span>Groq models + voice transcription<a onclick="H.openUrl('https://console.groq.com/keys')">Get key ↗</a></label><div class="pr"><input class="pi" type="password" id="pi-groq" placeholder="gsk_…"/><button class="psv" id="pb-groq" onclick="psvk('groq','pi-groq','pb-groq','pd-groq')">Save</button></div></div>
          <div class="pf"><label><span class="d2" id="pd-deepseek"></span>DeepSeek models<a onclick="H.openUrl('https://platform.deepseek.com/api_keys')">Get key ↗</a></label><div class="pr"><input class="pi" type="password" id="pi-deepseek" placeholder="sk-…"/><button class="psv" id="pb-deepseek" onclick="psvk('deepseek','pi-deepseek','pb-deepseek','pd-deepseek')">Save</button></div></div>
          <div class="pf"><label><span class="d2" id="pd-mistral"></span>Mistral Large<a onclick="H.openUrl('https://console.mistral.ai/api-keys/')">Get key ↗</a></label><div class="pr"><input class="pi" type="password" id="pi-mistral" placeholder="…"/><button class="psv" id="pb-mistral" onclick="psvk('mistral','pi-mistral','pb-mistral','pd-mistral')">Save</button></div></div>
          <div class="pf"><label><span class="d2" id="pd-qwen"></span>Qwen Plus (Alibaba)<a onclick="H.openUrl('https://dashscope.console.aliyun.com/apiKey')">Get key ↗</a></label><div class="pr"><input class="pi" type="password" id="pi-qwen" placeholder="sk-…"/><button class="psv" id="pb-qwen" onclick="psvk('qwen','pi-qwen','pb-qwen','pd-qwen')">Save</button></div></div>
          <div class="pf"><label><span class="d2" id="pd-grok"></span>Grok 3 (xAI)<a onclick="H.openUrl('https://console.x.ai')">Get key ↗</a></label><div class="pr"><input class="pi" type="password" id="pi-grok" placeholder="xai-…"/><button class="psv" id="pb-grok" onclick="psvk('grok','pi-grok','pb-grok','pd-grok')">Save</button></div></div>
          <div class="pf"><label><span class="d2" id="pd-claude"></span>Claude (Anthropic)<a onclick="H.openUrl('https://console.anthropic.com/settings/keys')">Get key ↗</a></label><div class="pr"><input class="pi" type="password" id="pi-claude" placeholder="sk-ant-…"/><button class="psv" id="pb-claude" onclick="psvk('claude','pi-claude','pb-claude','pd-claude')">Save</button></div></div>
          <div class="pf"><label><span class="d2" id="pd-openai"></span>OpenAI GPT-4o<a onclick="H.openUrl('https://platform.openai.com/api-keys')">Get key ↗</a></label><div class="pr"><input class="pi" type="password" id="pi-openai" placeholder="sk-…"/><button class="psv" id="pb-openai" onclick="psvk('openai','pi-openai','pb-openai','pd-openai')">Save</button></div></div>
          <div class="pf"><label><span class="d2" id="pd-perplexity"></span>Perplexity (AI Search)<a onclick="H.openUrl('https://www.perplexity.ai/settings/api')">Get key ↗</a></label><div class="pr"><input class="pi" type="password" id="pi-perplexity" placeholder="pplx-…"/><button class="psv" id="pb-perplexity" onclick="psvk('perplexity','pi-perplexity','pb-perplexity','pd-perplexity')">Save</button></div></div>
          <div class="pf"><label><span class="d2" id="pd-cohere"></span>Cohere Command R+<a onclick="H.openUrl('https://dashboard.cohere.com/api-keys')">Get key ↗</a></label><div class="pr"><input class="pi" type="password" id="pi-cohere" placeholder="…"/><button class="psv" id="pb-cohere" onclick="psvk('cohere','pi-cohere','pb-cohere','pd-cohere')">Save</button></div></div>
          <div class="pf"><label><span class="d2" id="pd-openrouter"></span>OpenRouter — one key, 200+ models<a onclick="H.openUrl('https://openrouter.ai/keys')">Get key ↗</a></label><div class="pr"><input class="pi" type="password" id="pi-openrouter" placeholder="sk-or-…"/><button class="psv" id="pb-openrouter" onclick="psvk('openrouter','pi-openrouter','pb-openrouter','pd-openrouter')">Save</button></div></div>
            </div>
            <div class="ps">
              <div class="psl"><svg class="licon"><use href="#i-search"/></svg> Web Search</div>
              <div class="pf"><label><span class="d2" id="pd-tavily"></span>Tavily search<a onclick="H.openUrl('https://app.tavily.com/home')">Get key ↗</a></label><div class="pr"><input class="pi" type="password" id="pi-tavily" placeholder="tvly-…"/><button class="psv" id="pb-tavily" onclick="psvk('tavily','pi-tavily','pb-tavily','pd-tavily')">Save</button></div></div>
            </div>
          </section>
    
          <!-- ───────── CONNECTIONS ───────── -->
          <section data-pane="connections">
            <div class="psview-h">Connections</div>
            <div class="psview-sub">OAuth-based services that don't fit the BYOK pattern. Google Workspace and GitHub each take credentials once and Horizon talks to them on your behalf.</div>
            <div class="ps">
              <div class="psl">Google Workspace</div>
              <div class="pf"><label><span class="d2" id="pd-google-conn"></span>Gmail / Calendar via OAuth</label>
                <div class="local-status" id="conn-google-status">Not connected. Paste a Google OAuth Client ID + Secret, then click Connect to authorise Gmail and Calendar access.</div>
                <div class="pr mt-6">
                  <input class="pi" type="text" id="pi-gcid" placeholder="Client ID — xxxx.apps.googleusercontent.com"/>
                  <a class="psv" style="text-decoration:none;text-align:center" onclick="H.openUrl('https://console.cloud.google.com/apis/credentials')">Get credentials ↗</a>
                </div>
                <div class="pr mt-6">
                  <input class="pi" type="password" id="pi-gcs" placeholder="Client Secret — GOCSPX-..."/>
                  <button class="psv" id="conn-google-btn" onclick="connectGoogle()">Connect</button>
                  <button class="psv" id="conn-google-disc" onclick="disconnectGoogle()" style="display:none">Disconnect</button>
                </div>
                <div class="mic-st" id="google-auth-status"></div>
              </div>
            </div>
            <div class="ps">
              <div class="psl">GitHub</div>
              <div class="pf"><label><span class="d2" id="pd-github-conn"></span>Personal Access Token <span class="t3-xs">(repo + read:user)</span></label>
                <div class="pr">
                  <input class="pi" type="password" id="pi-github-conn" placeholder="github_pat_..." />
                  <button class="psv" onclick="connectGithub()">Save</button>
                  <a class="psv" style="text-decoration:none;text-align:center" onclick="H.openUrl('https://github.com/settings/tokens/new?scopes=repo,read:user&description=Horizon%20AI')">Create token ↗</a>
                </div>
                <div class="local-status" id="conn-github-status">GitHub doesn't expose a true OAuth flow for repo access in CLIs — token is the canonical path.</div>
              </div>
              <div class="pf"><label>Attach repository</label><div class="pr"><input class="pi" type="text" id="pi-github-repo" placeholder="ErnestKostevich/horizon-genesis"/><button class="psv" onclick="attachGithubRepo()">Attach</button></div></div>
              <div class="local-status" id="github-status">Public repos work without a token. Private repos use the GitHub connection above.</div>
              <div id="github-repos" class="local-status" style="margin-top:8px"></div>
            </div>
            <div class="ps">
              <div class="psl">Direct tool connections</div>
              <div class="psview-sub" style="margin-bottom:10px">Token-based integrations become real agent tools when connected. Slack, Notion, and Linear are agent-call tools. Telegram also has a live bot runtime that can listen and reply outside the app.</div>
              <div class="pf"><label><span class="d2" id="pd-slack-conn"></span>Slack Bot Token <span class="t3-xs">channels + post message</span></label>
                <div class="pr"><input class="pi" type="password" id="pi-slack-conn" placeholder="xoxb-..." /><button class="psv" onclick="saveTokenConnection('slack')">Save</button><button class="psv" onclick="testTokenConnection('slack')">Test</button></div>
                <div class="local-status" id="conn-slack-status">Not saved.</div>
              </div>
              <div class="pf"><label><span class="d2" id="pd-notion-conn"></span>Notion Integration Token <span class="t3-xs">search + create page</span></label>
                <div class="pr"><input class="pi" type="password" id="pi-notion-conn" placeholder="secret_..." /><button class="psv" onclick="saveTokenConnection('notion')">Save</button><button class="psv" onclick="testTokenConnection('notion')">Test</button></div>
                <div class="local-status" id="conn-notion-status">Not saved.</div>
              </div>
              <div class="pf"><label><span class="d2" id="pd-linear-conn"></span>Linear API Key <span class="t3-xs">issues + create issue</span></label>
                <div class="pr"><input class="pi" type="password" id="pi-linear-conn" placeholder="lin_api_..." /><button class="psv" onclick="saveTokenConnection('linear')">Save</button><button class="psv" onclick="testTokenConnection('linear')">Test</button></div>
                <div class="local-status" id="conn-linear-status">Not saved.</div>
              </div>
              <!-- Phase 15: WhatsApp via Twilio (BYOK) -->
              <div class="pf"><label><span class="d2" id="pd-whatsapp-conn"></span>WhatsApp via Twilio <span class="t3-xs">free sandbox at twilio.com</span></label>
                <div class="pr"><input class="pi" type="text" id="pi-twilio-sid-conn" placeholder="AccountSID (AC...)" />
                  <button class="psv" onclick="saveWhatsAppConnection()">Save</button>
                  <button class="psv" onclick="testTokenConnection('whatsapp')">Test</button></div>
                <div class="pr mt-6"><input class="pi" type="password" id="pi-twilio-token-conn" placeholder="AuthToken" /></div>
                <div class="pr mt-6"><input class="pi" type="text" id="pi-twilio-from-conn" placeholder="whatsapp:+14155238886" /></div>
                <div class="local-status" id="conn-whatsapp-status">Not saved. Signup → twilio.com → Messaging → Try WhatsApp (5 min sandbox setup).</div>
              </div>
              <!-- Phase 15: Signal via signal-cli-rest-api (BYOK self-hosted bridge) -->
              <div class="pf"><label><span class="d2" id="pd-signal-conn"></span>Signal via signal-cli bridge <span class="t3-xs">self-hosted Docker container</span></label>
                <div class="pr"><input class="pi" type="text" id="pi-signal-url-conn" placeholder="http://localhost:8080" />
                  <button class="psv" onclick="saveSignalConnection()">Save</button>
                  <button class="psv" onclick="testTokenConnection('signal')">Test</button></div>
                <div class="pr mt-6"><input class="pi" type="text" id="pi-signal-number-conn" placeholder="+1234567890 (registered Signal number)" /></div>
                <div class="local-status" id="conn-signal-status">Not saved. Setup: docker run -d -p 8080:8080 bbernhard/signal-cli-rest-api, then register your number.</div>
              </div>
              <!-- Phase 15: iMessage via osascript (macOS only) -->
              <div class="pf"><label><span class="d2" id="pd-imessage-conn"></span>iMessage <span class="t3-xs">macOS only — uses Messages.app</span></label>
                <div class="pr"><button class="psv" onclick="saveImessageConnection(true)">Enable</button>
                  <button class="psv" onclick="saveImessageConnection(false)">Disable</button>
                  <button class="psv" onclick="testTokenConnection('imessage')">Test</button></div>
                <div class="local-status" id="conn-imessage-status">Available on macOS. Grant Terminal automation access to Messages.app in System Settings → Privacy & Security → Automation.</div>
              </div>
              <div class="pf"><label><span class="d2" id="pd-telegram_bot-conn"></span>Telegram Bot Token <span class="t3-xs">live replies + agent tools</span></label>
                <div class="pr"><input class="pi" type="password" id="pi-telegram_bot-conn" placeholder="123456:ABC..." /><button class="psv" onclick="saveTokenConnection('telegram_bot')">Save</button><button class="psv" onclick="testTokenConnection('telegram_bot')">Test</button></div>
                <div class="local-status" id="conn-telegram_bot-status">Not saved.</div>
                <div class="pf pf-subgroup">
                  <label>Allowed Telegram user IDs</label>
                  <div class="pr">
                    <input class="pi" type="text" id="pi-telegram-owner-ids" placeholder="123456789, 987654321" />
                    <button class="psv" onclick="saveTelegramAllowedUsers()">Save users</button>
                  </div>
                  <div class="local-status" id="telegram-owner-status">Only these Telegram user IDs can receive bot replies. Send /start to the bot and it will reply with your Telegram user ID.</div>
                </div>
                <div class="pr" style="margin-top:8px">
                  <button class="psv" id="telegram-live-toggle" onclick="toggleTelegramLive()">Start live bot</button>
                  <button class="psv" onclick="refreshTelegramRuntimeStatus()">Refresh status</button>
                </div>
                <div class="local-status" id="telegram-live-status">Live bot is off. Start it to make Telegram messages wake Horizon and receive replies.</div>
                <div class="local-status" id="telegram-live-log" style="margin-top:6px;max-height:110px;overflow:auto;font-family:var(--mono);font-size:10px"></div>
              </div>
              <!-- Phase 28.3: Discord Gateway WS — bot token + allowed guilds. -->
              <div class="pf"><label><span class="d2" id="pd-discord-conn"></span>Discord Bot Token <span class="t3-xs">Gateway WebSocket — mention/DM replies</span></label>
                <div class="pr"><input class="pi" type="password" id="pi-discord-conn" placeholder="MTI1..." /><button class="psv" onclick="saveTokenConnection('discord')">Save</button><button class="psv" onclick="testTokenConnection('discord')">Test</button></div>
                <div class="local-status" id="conn-discord-status">Not saved. Create at <strong>discord.com/developers/applications</strong> → New Application → Bot → Token. Enable <code>MESSAGE CONTENT INTENT</code> in the bot's Privileged Gateway Intents section.</div>
                <div class="pf pf-subgroup">
                  <label>Allowed Discord guild (server) IDs <small class="t3-norm">optional — leave blank to allow every server the bot is in</small></label>
                  <div class="pr">
                    <input class="pi" type="text" id="pi-discord-allowed-guilds" placeholder="123456789012345678, 987654321098765432" />
                    <button class="psv" onclick="saveDiscordAllowedGuilds()">Save guilds</button>
                  </div>
                  <div class="local-status" id="discord-allowed-status">Right-click a server icon → Copy Server ID (Developer Mode must be on in Discord settings).</div>
                </div>
                <div class="pr" style="margin-top:8px">
                  <button class="psv" id="discord-live-toggle" onclick="toggleDiscordLive()">Start Discord bot</button>
                  <button class="psv" onclick="refreshDiscordRuntimeStatus()">Refresh status</button>
                </div>
                <div class="local-status" id="discord-live-status">Discord runtime is off. Start it to have your bot respond to mentions + DMs.</div>
              </div>
              <!-- Phase 28.3: Email — IMAP inbound + SMTP outbound (BYOK). -->
              <div class="pf"><label><span class="d2" id="pd-email-conn"></span>Email <span class="t3-xs">IMAP inbound + SMTP outbound (BYOK)</span></label>
                <div class="local-status" id="conn-email-status">Not saved. Gmail / Yahoo / Fastmail / your own server — Horizon polls INBOX and lets the agent reply.</div>
                <div class="pf pf-subgroup">
                  <label>IMAP (incoming)</label>
                  <div class="pr mt-6"><input class="pi" type="text" id="pi-email-imap-host" placeholder="imap.gmail.com" onchange="saveEmailField('email.imap.host', this.value)"/></div>
                  <div class="pr mt-6"><input class="pi" type="number" id="pi-email-imap-port" placeholder="993" onchange="saveEmailField('email.imap.port', this.value)"/></div>
                  <div class="pr mt-6"><input class="pi" type="text" id="pi-email-imap-user" placeholder="you@example.com" onchange="saveEmailField('email.imap.user', this.value)"/></div>
                  <div class="pr mt-6"><input class="pi" type="password" id="pi-email-imap-pass" placeholder="app-specific password" onchange="saveEmailField('email.imap.pass', this.value)"/></div>
                  <div class="pr mt-6"><input class="pi" type="text" id="pi-email-imap-mailbox" placeholder="INBOX (default)" onchange="saveEmailField('email.imap.mailbox', this.value)"/></div>
                </div>
                <div class="pf pf-subgroup">
                  <label>SMTP (outgoing)</label>
                  <div class="pr mt-6"><input class="pi" type="text" id="pi-email-smtp-host" placeholder="smtp.gmail.com" onchange="saveEmailField('email.smtp.host', this.value)"/></div>
                  <div class="pr mt-6"><input class="pi" type="number" id="pi-email-smtp-port" placeholder="587" onchange="saveEmailField('email.smtp.port', this.value)"/></div>
                  <div class="pr mt-6"><input class="pi" type="text" id="pi-email-smtp-from" placeholder="Horizon &lt;you@example.com&gt; (optional)" onchange="saveEmailField('email.smtp.from', this.value)"/></div>
                  <div class="local-status mt-6">SMTP user / pass default to the IMAP values above unless you set them explicitly.</div>
                </div>
                <div class="pr" style="margin-top:8px">
                  <button class="psv" id="email-live-toggle" onclick="toggleEmailLive()">Start email bot</button>
                  <button class="psv" onclick="testTokenConnection('email')">Test</button>
                </div>
                <div class="local-status" id="email-live-status">Email runtime is off. For Gmail, generate an app-specific password at <strong>myaccount.google.com → Security → App passwords</strong>.</div>
              </div>
            </div>
            <div class="ps">
              <div class="psl">MCP servers</div>
              <div class="psview-sub" style="margin-bottom:10px">Add stdio MCP servers. Tools appear in agent mode as <code>server__tool</code> and use the same Operator permission flow.</div>
              <div class="pf"><label>One-click install <span class="t3-xs">(curated public servers)</span></label>
                <div class="pr">
                  <select class="pi" id="mcp-preset" onchange="applyMcpPreset(this.value)">
                    <option value="">Choose a preset…</option>
                    <option value="filesystem">Filesystem — read/write under a folder</option>
                    <option value="github">GitHub — repos / issues / PRs (needs token)</option>
                    <option value="postgres">Postgres — query a database (needs URL)</option>
                    <option value="puppeteer">Puppeteer — browser automation</option>
                    <option value="brave-search">Brave Search — web search (needs API key)</option>
                    <option value="memory">Memory — persistent agent memory</option>
                    <option value="time">Time — clocks / timezone math</option>
                    <option value="fetch">Fetch — HTTP requests</option>
                  </select>
                </div>
                <div class="local-status" style="margin-top:4px;font-size:10px">After picking a preset, fill in the highlighted fields and hit Test — these run as `npx -y @modelcontextprotocol/server-...`.</div>
              </div>
              <div class="pf"><label>Server</label>
                <div class="pr"><input class="pi" id="mcp-name" placeholder="filesystem"/><input class="pi" id="mcp-command" placeholder="npx"/></div>
                <div class="pr mt-6"><input class="pi" id="mcp-args" placeholder="-y @modelcontextprotocol/server-filesystem D:\\Genesis"/></div>
                <div class="pr mt-6"><input class="pi" id="mcp-cwd" placeholder="Working directory (optional)"/><input class="pi" id="mcp-timeout" placeholder="Timeout ms (20000)"/></div>
                <textarea class="pi" id="mcp-env" placeholder="ENV_KEY=value" style="margin-top:6px;min-height:54px;resize:vertical"></textarea>
                <div class="pr mt-6">
                  <button class="psv" onclick="testMcpServer()">Test</button>
                  <button class="psv" onclick="saveMcpServer()">Save</button>
                  <button class="psv" onclick="refreshMcpTools()">Refresh tools</button>
                </div>
                <div class="local-status" id="mcp-status">No MCP server selected.</div>
              </div>
              <div id="mcp-servers" class="local-status" style="margin-top:8px"></div>
            </div>
          </section>
    
          <!-- ───────── MOBILE COMPANION ───────── -->
          <!-- One-click "Connect phone": spawns horizon-serve on :18789, paints
               a QR code of the pairing URL with the bearer token embedded. User
               scans with their camera and the mobile PWA opens with auth
               already in place. No copy-paste, no terminal commands. -->
          <section data-pane="mobile">
            <div class="psview-h">Mobile companion</div>
            <div class="psview-sub">Open Horizon on your phone — same chat, same memory, same provider. Click Connect, scan, done.</div>
            <div class="ps">
              <div class="psl">Pair your phone</div>
              <p class="ps-hint" style="margin:0 0 12px;font-size:12px;color:var(--t3)">Your phone must be on the same Wi-Fi network as this computer.</p>
              <div class="mobile-pair-card">
                <!-- State: server stopped -->
                <div class="mp-stopped">
                  <button class="psv primary" id="mp-start" onclick="mobilePairStart()">
                    <svg class="licon" style="vertical-align:-2px"><use href="#i-smartphone"/></svg>
                    Connect phone
                  </button>
                  <p class="mp-hint">We'll show a QR code — point your phone camera at it and tap the link.</p>
                </div>
                <!-- State: server running -->
                <div class="mp-running" hidden>
                  <div class="mp-qr-wrap">
                    <img class="mp-qr" alt="QR code for pairing" id="mp-qr-img"/>
                    <div class="mp-instr">
                      <h3>1. Open your camera</h3>
                      <p>Point it at this QR code (no app needed).</p>
                      <h3>2. Tap the link</h3>
                      <p>The mobile app opens with the token already filled in.</p>
                      <h3>3. Save to home screen</h3>
                      <p>Tap "Add to Home Screen" — it'll work like an app.</p>
                    </div>
                  </div>
                  <!-- Can't scan? Copy block — for shaky cameras, glare, or
                       users who need to type the values into the PWA's manual
                       form on the phone. Tap-to-copy via the small button at
                       the end of each row; same token + URL is also embedded
                       in the QR above, so this is purely a manual fallback. -->
                  <div class="mp-copy-block">
                    <div class="mp-copy-h">Can't scan? Type these on the phone instead:</div>
                    <div class="mp-copy-row">
                      <span class="mp-copy-label">Pairing URL</span>
                      <code class="mp-url mp-copy-val" id="mp-url-text"></code>
                      <button class="mp-copy-btn" type="button" data-copy-target="mp-url-text" onclick="mobilePairCopy('mp-url-text', this)">Copy</button>
                    </div>
                    <div class="mp-copy-row">
                      <span class="mp-copy-label">Server URL</span>
                      <code class="mp-url mp-copy-val" id="mp-server-url-text"></code>
                      <button class="mp-copy-btn" type="button" data-copy-target="mp-server-url-text" onclick="mobilePairCopy('mp-server-url-text', this)">Copy</button>
                    </div>
                    <div class="mp-copy-row">
                      <span class="mp-copy-label">Token</span>
                      <code class="mp-url mp-copy-val" id="mp-token-text"></code>
                      <button class="mp-copy-btn" type="button" data-copy-target="mp-token-text" onclick="mobilePairCopy('mp-token-text', this)">Copy</button>
                    </div>
                  </div>
                  <div class="mp-actions">
                    <button class="psv ghost" id="mp-stop" onclick="mobilePairStop()">Stop server</button>
                    <span class="mp-status">Running on port <span class="mp-port" id="mp-port-text">18789</span></span>
                  </div>
                </div>
              </div>
            </div>
          </section>
    
          <!-- ───────── VOICE & WAKE ───────── -->
          <section data-pane="voice">
            <div class="psview-h">Voice &amp; Wake Word</div>
            <div class="psview-sub">Pick how Horizon listens (STT) and speaks (TTS). The wake word is "Horizon" / "Хорайзон" / "Горизонт" — calibrate sensitivity below.</div>
            <div class="ps">
          <div class="pf"><label>STT — Speech-to-Text</label>
            <select class="sel" id="pi-vprov" onchange="saveVP()">
              <option value="groq">Groq Whisper — accurate, recommended for Russian</option>
              <option value="openai">OpenAI Whisper — Best quality</option>
              <option value="deepgram">Deepgram Nova-2 — external account</option>
            </select>
          </div>
          <div class="pf"><label>TTS — Text-to-Speech (Horizon voice)</label>
            <select class="sel" id="pi-ttsprov" onchange="saveTTSProv()">
              <option value="system">System TTS — free, offline, works always</option>
              <option value="elevenlabs">ElevenLabs — ultra realistic (needs key)</option>
              <option value="openai">OpenAI TTS — very good (needs OpenAI key)</option>
              <option value="kokoro">Kokoro — free local AI TTS (no key needed)</option>
            </select>
          </div>
          <div id="el-section" style="display:none">
            <div class="pf"><label><span class="d2" id="pd-elevenlabs"></span>ElevenLabs API Key<a onclick="H.openUrl('https://elevenlabs.io')">Get key ↗</a></label><div class="pr"><input class="pi" type="password" id="pi-elevenlabs" placeholder="sk_…"/><button class="psv" id="pb-elevenlabs" onclick="psvk('elevenlabs','pi-elevenlabs','pb-elevenlabs','pd-elevenlabs')">Save</button></div></div>
            <div class="pf"><label>Voice ID <span style="color:var(--t4);font-size:9px">(default: Adam — works for RU+EN)</span></label><div class="pr"><input class="pi" type="text" id="pi-elvid" placeholder="pNInz6obpgDQGcFmaJgB"/><button class="psv" onclick="saveElVoice()">Save</button></div></div>
            <div style="font-size:9px;color:var(--t3);margin-top:4px">Popular voices: Adam (pNInz6obpgDQGcFmaJgB) · Rachel (21m00Tcm4TlvDq8ikWAM) · Josh (TxGEqnHWrfWFTfGW9XjX)<br>Get voice IDs at <a onclick="H.openUrl('https://elevenlabs.io/voice-library')" style="color:var(--acc3);cursor:pointer">elevenlabs.io/voice-library</a></div>
          </div>
          <div id="oaitts-section" style="display:none">
            <div class="pf"><label>OpenAI TTS Voice</label>
              <select class="sel" id="pi-oaitts" onchange="saveOAITTSVoice()">
                <option value="onyx">Onyx — Deep male</option>
                <option value="alloy">Alloy — Neutral</option>
                <option value="echo">Echo — Male</option>
                <option value="fable">Fable — British male</option>
                <option value="nova">Nova — Female</option>
                <option value="shimmer">Shimmer — Female soft</option>
              </select>
            </div>
          </div>
          <div id="kokoro-section" style="display:none">
            <div style="font-size:10px;color:var(--t3);background:var(--bg3);border:1px solid var(--b1);border-radius:8px;padding:8px;margin-top:4px"><svg class="licon" style="vertical-align:-2px"><use href="#i-zap"/></svg> Kokoro — free AI TTS, runs locally.<br>Downloads on first use (~80MB).<br>Great quality, no API keys required.</div>
          </div>
          <div id="deepgram-section" style="display:none;margin-top:6px">
            <div class="pf"><label><span class="d2" id="pd-deepgram"></span>Deepgram Key<a onclick="H.openUrl('https://console.deepgram.com')">Get key ↗</a></label><div class="pr"><input class="pi" type="password" id="pi-deepgram" placeholder="deepgram key…"/><button class="psv" id="pb-deepgram" onclick="psvk('deepgram','pi-deepgram','pb-deepgram','pd-deepgram')">Save</button></div></div>
            <div style="font-size:9px;color:var(--t3)">External account · multi-language · very accurate</div>
          </div>
          <button class="mic-test-btn" onclick="testMic()"><svg class="licon" style="vertical-align:-2px"><use href="#i-mic"/></svg> Test microphone</button>
          <div class="mic-st" id="mic-st"></div>
    
          <!-- Wake Word Calibration -->
          <div style="margin-top:14px;padding:10px;background:var(--bg3);border:1px solid var(--b1);border-radius:8px">
            <div style="font-size:11px;color:var(--t2);margin-bottom:8px;font-weight:600;display:flex;align-items:center;gap:6px"><svg class="licon"><use href="#i-target"/></svg> Wake Word Calibration</div>
            <div class="trow" style="padding:6px 0">
              <div class="tinfo">Strict mode<small>Only fire when "Horizon" is at the start. Blocks mid-sentence triggers (e.g. phone calls).</small></div>
              <button class="sw" id="sw-wake-strict" onclick="toggleWakeStrict()"></button>
            </div>
            <div class="trow" style="padding:6px 0">
              <div class="tinfo">Confirm beep<small>Short tone after wake fires.</small></div>
              <button class="sw" id="sw-wake-beep" onclick="toggleWakeBeep()"></button>
            </div>
            <div class="trow" style="padding:6px 0">
              <div class="tinfo">Talk Mode<small>After the bot replies, keep listening for your next message without saying "Horizon" again. Stop speaking for 8s to return to wake-word mode.</small></div>
              <button class="sw" id="sw-wake-talk" onclick="toggleWakeTalkMode()"></button>
            </div>
            <div style="margin-top:8px">
              <label style="font-size:10px;color:var(--t2);display:flex;justify-content:space-between;margin-bottom:4px">
                <span>Sensitivity</span>
                <span id="wake-sens-val" style="color:var(--t1);font-weight:600">18</span>
              </label>
              <input type="range" id="wake-sens" min="5" max="50" step="1"
                     style="width:100%;accent-color:var(--acc);cursor:pointer"
                     oninput="setWakeSens(this.value)" />
              <div style="font-size:9px;color:var(--t3);margin-top:3px;display:flex;justify-content:space-between">
                <span>← more sensitive</span><span>quieter env / fewer FPs -></span>
              </div>
            </div>
            <!-- Post-PR-V Phase 1.6 — Test Wake Word button. Records 3s of
                 the user saying "Horizon", runs it through the same Groq
                 Whisper path the live wake engine uses, and reports whether
                 the result would actually trigger the engine. Lets the user
                 tune sensitivity without trial-and-error. -->
            <div style="margin-top:10px">
              <button class="mic-test-btn" onclick="testWakeWord()" style="width:100%"><svg class="licon" style="vertical-align:-2px"><use href="#i-target"/></svg> Test Wake Word</button>
              <div class="mic-st" id="wake-test-st" style="font-size:11px;margin-top:6px"></div>
            </div>
          </div>
    
          <!-- Sprint-2.14 — was a wall-of-text dev README mid-settings.
               Replaced with a collapsible <details> block so the panel
               reads quickly + the explanation is one click away. -->
          <details class="wake-howto" style="margin-top:10px">
            <summary class="wake-howto-summary">How does the wake word work? <span class="wake-howto-summary-chev">▾</span></summary>
            <div class="wake-howto-body">
              <p><strong>Why not the browser's built-in speech recognition?</strong><br/>
              Google blocks the Web Speech API inside Electron — known bug since 2020 — so Horizon ships its own pipeline.</p>
              <ol class="wake-howto-steps">
                <li>Toggle <strong>Wake</strong> on the composer — Horizon starts listening (re-checked every ~2 s).</li>
                <li>Say <strong>"Horizon"</strong> (also <em>"Горизонт"</em> or <em>"Хорайзон"</em>).</li>
                <li>Audio is transcribed through <strong>Groq Whisper</strong> (free tier covers normal use).</li>
                <li>On match, Horizon answers, then starts <strong>live-typing your next utterance</strong>.</li>
                <li>Speak your command — it auto-sends when you pause.</li>
              </ol>
              <p class="wake-howto-warn"><strong>Needs a Groq key</strong> for wake + voice. Free at <a onclick="H.openUrl && H.openUrl('https://console.groq.com')">console.groq.com</a>.</p>
            </div>
          </details>
            </div>
          </section>
    
          <!-- ───────── FEATURES ───────── -->
          <section data-pane="features">
            <div class="psview-h">Features</div>
            <div class="psview-sub">Toggle-driven capabilities that aren't tied to a specific provider or connection.</div>
            <div class="ps">
              <div class="trow"><div class="tinfo">Voice Response (TTS)<small>Horizon speaks answers aloud</small></div><button class="sw on" id="sw-tts" onclick="togSw('tts','sw-tts')"></button></div>
              <div class="trow"><div class="tinfo">Screen AI<small>Horizon can see your screen</small></div><button class="sw on" id="sw-eye" onclick="togSw('screenWatcher','sw-eye')"></button></div>
              <div class="trow"><div class="tinfo">Ambient Mode<small>AI proactively suggests help based on screen</small></div><button class="sw" id="sw-ambient" onclick="toggleAmbient()"></button></div>
              <div class="trow"><div class="tinfo">Smart Notifications<small>Daily briefing: weather, calendar, emails</small></div><button class="sw" id="sw-notifs" onclick="toggleNotifications()"></button></div>
              <div class="psl" style="margin-top:14px">Tool approvals</div>
              <div class="local-status" id="permission-allowlist">Loading saved approvals...</div>
              <button class="psv" onclick="refreshPermissionAllowlist()" style="margin-top:8px">Refresh approvals</button>
            </div>
          </section>
    
          <!-- ───────── PERSONAS ───────── -->
          <!-- Premium editor for AI personas: 6 built-in (Jarvis / Friday /
               Alfred / Sage / Pixel + any custom) on the left rail, detail
               pane on the right with Monaco-style system-prompt textarea,
               allowed-tools toggles (write tools wear an amber warning
               badge), and editable long-term memories. Edits to built-ins
               are stored as overlays under settingsStore.customPersonas, so
               "Reset to default" wipes the overlay without losing the
               original prompt. -->
          <section data-pane="personas">
            <div class="psview-h">Personas</div>
            <div class="psview-sub">Define how Horizon talks. System prompt, allowed tools, and long-term memories per persona. Built-ins reset cleanly — your edits live as overlays.</div>
            <div id="pe-mount" style="margin-top:14px"></div>
          </section>
    
          <!-- ───────── PLUGINS ───────── -->
          <section data-pane="plugins">
            <div class="psview-h">Plugins</div>
            <div class="psview-sub">Installed plugins extend Horizon with extra tools and integrations. Browse the marketplace for more.</div>
            <div class="ps">
              <div id="plugin-list" style="margin-bottom:8px"></div>
              <button class="mic-test-btn" onclick="closePanel();openStore()" style="border-color:rgba(108,140,255,.3);color:var(--acc)"><svg class="licon"><use href="#i-store"/></svg> Open Marketplace</button>
            </div>
          </section>
    
          <!-- ───────── MACROS (Sprint 7D) ───────── -->
          <!-- Recordable macro UI: list of saved macros + record button.
               Renderer talks to main via H.macro* and H.ocr* IPC helpers.
               Heavy lifting lives in chat-macros.js (loaded as external
               script alongside the other chat-* modules). -->
          <section data-pane="macros">
            <div class="psview-h">Macros</div>
            <div class="psview-sub">Record mouse + keyboard sequences and replay them via the agent. Great for repeated workflows the LLM doesn't need to think through every time.</div>
            <div class="ps">
              <div id="macros-status" class="local-status">Loading saved macros...</div>
              <div id="macros-list" style="margin-top:10px"></div>
              <div class="pr" style="margin-top:14px">
                <button class="psv" onclick="macroStartRecorder()" id="macro-record-btn"><svg class="licon" style="vertical-align:-2px"><use href="#i-mic"/></svg> Record new macro</button>
                <button class="psv" onclick="macrosRefresh()">Refresh</button>
              </div>
            </div>
            <div class="ps" style="margin-top:14px">
              <div class="psl">OCR (offline text recognition)</div>
              <div class="local-status" id="ocr-status">Checking...</div>
              <div class="psview-sub" style="margin-top:8px">Install tesseract.js to enable cheap, fast, fully-offline text recognition for tools like <code>find_text</code> and <code>ocr_screenshot</code>. Without it the agent falls back to vision LLM calls (slow + costs tokens).</div>
            </div>
          </section>
    
          <!-- ───────── DATA & HEALTH ───────── -->
          <section data-pane="data">
            <div class="psview-h">Data &amp; Health</div>
            <div class="psview-sub">Where your settings live, how to verify they persist, and how to wipe local data if you want to reset.</div>
            <div class="ps">
              <div class="psl">Settings storage</div>
              <div class="local-status" id="settings-health">Checking saved settings...</div>
              <div class="pr" style="margin-top:10px">
                <button class="psv" onclick="verifySettingsPersistence()">Verify persistence</button>
                <button class="psv" onclick="openSettingsFolder()">Open settings folder</button>
                <button class="psv" onclick="loadSettingsHealth()">Refresh status</button>
              </div>
            </div>
            <div class="ps">
              <div class="psl">Reset</div>
              <button class="dbtn" onclick="clearHist()"><svg class="licon" style="vertical-align:-2px"><use href="#i-trash"/></svg> Clear chat history</button>
              <button class="dbtn" onclick="clearKeys()"><svg class="licon" style="vertical-align:-2px"><use href="#i-lock"/></svg> Delete all API keys</button>
            </div>
            <div class="os-footer">
              <div class="os-footer-row">
                <span>Horizon Genesis</span>
                <span class="os-badge">BSL 1.1 source-visible</span>
              </div>
              <div class="os-links">
                <a onclick="H.openUrl('https://github.com/ErnestKostevich/horizon-genesis')">GitHub ↗</a>
                <a onclick="H.openUrl('https://horizonaai.dev')">Website ↗</a>
                <a onclick="H.openUrl('https://github.com/ErnestKostevich/Horizon-Agent-Docs')">Docs ↗</a>
                <a onclick="H.openUrl('https://github.com/ErnestKostevich/horizon-genesis/issues')">Report issue ↗</a>
                <a onclick="H.openUrl('https://github.com/ErnestKostevich/horizon-genesis/blob/main/LICENSE')">License ↗</a>
              </div>
              <div class="os-ver">v0.0.1 · BYOK · Local-first · BSL-1.1</div>
              <div class="os-ver" style="margin-top:4px;color:var(--t3);font-size:10px">
                © Ernest Kostevich · all rights reserved · forks must preserve attribution (see ATTRIBUTION.md)
              </div>
            </div>
          </section>
    
        </div>
      </div>
    </div>
    
    <!-- ═══════════ TITLE BAR ═══════════ -->
    <div class="tb">
      <div class="tb-l">
        <div class="logo">H</div>
        <div>
          <span class="tb-name">Horizon AI</span>
          <span class="tb-sub" id="tb-sub">Genesis</span>
        </div>
        <!-- PR-LAYOUT-V5 — tb-license-pill REMOVED. It was a duplicate of
             the existing #license-pill-host injected by license-ui.js
             (which already lives in .tb-l after the logo). My V4 addition
             created a SECOND pill side-by-side with the original, making
             the titlebar look cluttered. The owner correctly called this
             out: "в верху слева ... мое имя написал и еще что-то это не
             уместно". The original license-pill-host stays; v5 below
             restyles it to be more compact (one pill, not pill+CTA). -->
      </div>
      <!-- PR-U1 (UX research): the title-bar centre is a SINGLE source of
           truth — the surface chip (opens Cmd-K palette) + git branch
           (when applicable). Removed from this row vs the previous design:
             · shell-model-label   (model picker now lives in composer-toolbar
                                    — eliminates the "4 places to switch
                                    model" trap that the audit called out)
             · shell-privacy-label (BYOK posture moves to a tooltip on the
                                    surface chip + the inspector footer)
           The csb-model pill in the chat status bar is also retired below
           — composer-toolbar is now THE place for model selection. -->
      <div class="tb-center">
        <!-- The header surface chip shows the active chat mode (Chat / Code /
             Agent / etc). Used to open Cmd-K palette on click, but users
             expected a mode picker — clicking "Chat" naturally suggests
             switching mode. Now opens the mode picker; ⌘K still opens the
             palette via keyboard. Right-click / long-press also keeps the
             palette accessible for users muscle-memoried into the old flow. -->
        <button class="shell-chip primary" id="shell-surface-label"
                onclick="openModePicker(event)"
                oncontextmenu="event.preventDefault();openCmdPalette()"
                title="Click — choose chat mode · ⌘K — command palette · right-click — palette">Chat</button>
        <!-- PR-C4 — git branch chip. Hidden by default; populated by
             refreshGitBranchChip() when a workspace is open and is a git
             repo. Click opens the recent-branches dropdown. -->
        <button class="shell-chip subtle" id="shell-git-branch" onclick="toggleGitBranchDropdown(event)" title="Git branch (click for recent branches)" style="display:none">
          <svg class="licon" style="margin-right:4px"><use href="#i-git-branch"/></svg>
          <span id="shell-git-branch-name">main</span>
        </button>
      </div>
      <!-- PR-C4 — recent-branches dropdown anchored under the git chip. -->
      <div class="git-branch-pop" id="git-branch-pop"></div>
      <div class="tb-r">
        <div class="focus-timer" id="focus-timer">
          <span id="focus-time">25:00</span>
          <button class="focus-timer-btn" id="focus-toggle" onclick="toggleFocusTimer()">||</button>
          <button class="focus-timer-btn" onclick="stopFocusTimer()">x</button>
        </div>
        <button class="ic-btn" onclick="openCmdPalette()" title="Ctrl+K"><svg class="licon"><use href="#i-command"/></svg></button>
        <!-- Sprint-1 fix: light/dark theme toggle, persists via localStorage.horizonTheme. -->
        <button class="theme-toggle" onclick="window.toggleHorizonTheme&&window.toggleHorizonTheme()" title="Toggle light / dark theme" aria-label="Toggle theme">
          <svg class="licon theme-toggle-moon"><use href="#i-moon"/></svg>
          <svg class="licon theme-toggle-sun"><use href="#i-sun"/></svg>
        </button>
        <button class="ic-btn" onclick="openPanel()" title="Settings"><svg class="licon"><use href="#i-settings"/></svg></button>
        <div style="width:10px"></div>
        <!-- Window controls. Custom traffic-light style; on macOS the native
             hiddenInset traffic-lights take over and chat-base.css hides these
             (body.os-mac rule). On Win + Linux these are the only controls.
             Close = hide-to-tray (intentional, user keeps agent running in
             background). Use the tray menu or `horizon quit` to fully exit. -->
        <button class="wbtn wy" onclick="try{H.minimize()}catch(_){}" title="Minimize" aria-label="Minimize"></button>
        <button class="wbtn wg" onclick="try{H.toggleMaximize()}catch(_){}" title="Maximize / Restore" aria-label="Maximize"></button>
        <button class="wbtn wr" onclick="try{H.hide()}catch(_){try{window.close()}catch(__){}}" title="Hide to tray" aria-label="Hide to tray"></button>
      </div>
    </div>
    
    <!-- ═══════════ COMMAND PALETTE (Ctrl+K) ═══════════ -->
    <div class="cmd-palette" id="cmd-palette" onclick="if(event.target===this)closeCmdPalette()">
      <div class="cmd-box">
        <input class="cmd-input" id="cmd-input" placeholder="Type a command... (Esc to close)" oninput="filterCommands(this.value)" onkeydown="cmdKeyDown(event)"/>
        <div class="cmd-results" id="cmd-results"></div>
      </div>
    </div>
    
    <!-- Code Mode quick-file-open (Ctrl+P / Cmd+P). Reuses .cmd-palette /
         .cmd-item styling for visual continuity with the command palette,
         but is its own component so neither overrides the other. -->
    <div class="cmd-palette" id="quickopen-palette" onclick="if(event.target===this)closeQuickOpen()">
      <div class="cmd-box">
        <input class="cmd-input" id="quickopen-input" placeholder="Search files in workspace... (Esc to close)" oninput="filterQuickOpen(this.value)" onkeydown="quickOpenKeyDown(event)"/>
        <div class="cmd-results" id="quickopen-results"></div>
      </div>
    </div>
    
    <!-- ═════════ PLUGIN HUB PANEL ═════════ -->
    <div class="fpanel" id="hub-panel">
      <div class="fph" style="-webkit-app-region:drag">
        <h2><svg class="licon"><use href="#i-puzzle"/></svg>Plugin Hub</h2>
        <div class="fph-r">
          <button class="fph-x" onclick="closeHub()" aria-label="Close"><svg class="licon"><use href="#i-x"/></svg></button>
        </div>
      </div>
      <div class="ftabs">
        <button class="ftab on" id="hub-tab-installed" onclick="hubTab('installed')">Installed</button>
        <button class="ftab" id="hub-tab-create" onclick="hubTab('create')">Create Plugin</button>
        <button class="ftab" id="hub-tab-import" onclick="hubTab('import')">Import from URL</button>
      </div>
      <div class="fbody" id="hub-body"></div>
    </div>
    
    <!-- ═════════ DISCORD CHAT VIEWER PANEL ═════════
         Two-column layout mirroring the Telegram panel, but backed by the
         Discord Gateway runtime in connectionsManager.js. -->
    <div class="fpanel" id="discord-panel">
      <div class="fph" style="-webkit-app-region:drag">
        <h2><svg class="licon"><use href="#i-discord"/></svg>Discord</h2>
        <div class="fph-r">
          <button class="fph-x" onclick="closeDiscordHub()" aria-label="Close"><svg class="licon"><use href="#i-x"/></svg></button>
        </div>
      </div>
      <div class="tg-split">
        <aside class="tg-side" id="dc-chat-list">
          <div class="tg-empty tg-empty-loading">
            <span class="tg-empty-spinner"></span>
            <span>Loading channels…</span>
          </div>
        </aside>
        <main class="tg-thread" id="dc-thread">
          <div class="tg-empty" style="padding:40px 20px">
            <div style="opacity:.55;margin-bottom:8px"><svg class="licon lg"><use href="#i-discord"/></svg></div>
            <strong>Pick a channel on the left</strong>
            <p style="font-size:12px;color:var(--t3);max-width:280px;margin:0 auto;line-height:1.6">Discord channels with bot conversations appear here. Enable Discord runtime in Settings → Connections. Up to 400 messages per channel cached locally in <code>%APPDATA%</code>.</p>
          </div>
        </main>
      </div>
    </div>
    
    <!-- ═════════ TELEGRAM CHAT VIEWER PANEL ═════════
         Two-column layout: chat list on the left, thread + composer on the right.
         Backed by chat-telegram.js + connectionsManager Telegram runtime. -->
    <div class="fpanel" id="telegram-panel">
      <div class="fph" style="-webkit-app-region:drag">
        <h2><svg class="licon"><use href="#i-send"/></svg>Telegram</h2>
        <div class="fph-r">
          <button class="fph-x" onclick="closeTelegramHub()" aria-label="Close"><svg class="licon"><use href="#i-x"/></svg></button>
        </div>
      </div>
      <div class="tg-split">
        <aside class="tg-side" id="tg-chat-list">
          <div class="tg-empty tg-empty-loading">
            <span class="tg-empty-spinner"></span>
            <span>Loading chats…</span>
          </div>
        </aside>
        <main class="tg-thread" id="tg-thread">
          <div class="tg-empty" style="padding:40px 20px">
            <div style="opacity:.55;margin-bottom:8px"><svg class="licon lg"><use href="#i-message"/></svg></div>
            <strong>Pick a chat on the left</strong>
            <p style="font-size:12px;color:var(--t3);max-width:280px;margin:0 auto;line-height:1.6">Conversations from your Telegram bot are stored locally (in <code>%APPDATA%</code> alongside your keys) and shown here. Up to 400 messages per chat.</p>
          </div>
        </main>
      </div>
    </div>
    
    <!-- ═════════ SKILL HUB PANEL ═════════ -->
    <div class="fpanel" id="skills-panel">
      <div class="fph" style="-webkit-app-region:drag">
        <h2><svg class="licon"><use href="#i-book"/></svg>Skills</h2>
        <div class="fph-r">
          <button class="fph-x" onclick="closeSkillHub()" aria-label="Close"><svg class="licon"><use href="#i-x"/></svg></button>
        </div>
      </div>
      <div class="ftabs">
        <button class="ftab on" id="skills-tab-installed" onclick="skillsTab('installed')">Installed</button>
        <button class="ftab" id="skills-tab-edit" onclick="skillsTab('edit')">Editor</button>
        <button class="ftab" id="skills-tab-import" onclick="skillsTab('import')">Import URL</button>
      </div>
      <div class="fbody" id="skills-body"></div>
    </div>
    
    <!-- ═════════ MARKETPLACE STORE PANEL ═════════ -->
    <div class="fpanel" id="store-panel">
      <div class="fph" style="-webkit-app-region:drag">
        <h2><svg class="licon"><use href="#i-store"/></svg>Marketplace</h2>
        <div class="fph-r">
          <button class="fph-x" onclick="closeStore()" aria-label="Close"><svg class="licon"><use href="#i-x"/></svg></button>
        </div>
      </div>
      <div class="ftabs">
        <button class="ftab on" id="store-tab-plugins" onclick="storeTab('plugins')">Plugins</button>
        <button class="ftab" id="store-tab-workflows" onclick="storeTab('workflows')">Workflows</button>
        <button class="ftab" id="store-tab-publish" onclick="storeTab('publish')">Publish</button>
      </div>
      <div class="fbody" id="store-body"></div>
    </div>
    
    <!-- ═════════ HORIZON ACCOUNT PANEL ═════════ -->
    <div class="fpanel" id="acct-panel">
      <div class="fph" style="-webkit-app-region:drag">
        <h2><svg class="licon"><use href="#i-user"/></svg>Horizon Account</h2>
        <div class="fph-r">
          <button class="fph-x" onclick="closeAccount()" aria-label="Close"><svg class="licon"><use href="#i-x"/></svg></button>
        </div>
      </div>
      <div class="ftabs" id="acct-tabs">
        <button class="ftab on" id="acct-tab-login" onclick="acctTab('login')">Sign in</button>
        <button class="ftab" id="acct-tab-signup" onclick="acctTab('signup')">Create account</button>
      </div>
      <div class="fbody" id="acct-body"></div>
    </div>
    
    <!-- ═════════ WORKFLOWS PANEL ═════════ -->
    <div class="fpanel" id="wf-panel">
      <div class="fph" style="-webkit-app-region:drag">
        <h2><svg class="licon"><use href="#i-workflow"/></svg>Workflows</h2>
        <div class="fph-r">
          <button class="fph-x" onclick="closeWorkflows()" aria-label="Close"><svg class="licon"><use href="#i-x"/></svg></button>
        </div>
      </div>
      <div class="ftabs">
        <button class="ftab on" id="wf-tab-overview" onclick="wfTab('overview')">Overview</button>
        <button class="ftab" id="wf-tab-list" onclick="wfTab('list')">My Workflows</button>
        <button class="ftab" id="wf-tab-create" onclick="wfTab('create')">Create</button>
        <button class="ftab" id="wf-tab-scheduled" onclick="wfTab('scheduled')">Scheduled</button>
      </div>
      <div class="fbody" id="wf-body"></div>
    </div>
    
    <!-- ═════════ SCREEN RECORDER PANEL ═════════ -->
    <div class="fpanel" id="recorder-panel">
      <div class="fph" style="-webkit-app-region:drag">
        <h2><svg class="licon"><use href="#i-circle"/></svg>Screen Recorder + AI Narrator</h2>
        <div class="fph-r">
          <button class="fph-x" onclick="closeRecorder()" aria-label="Close"><svg class="licon"><use href="#i-x"/></svg></button>
        </div>
      </div>
      <div class="fbody" id="recorder-body"></div>
    </div>
    
    <!-- ═════════ WAKE BAR ═════════ -->
    <div class="wake-bar" id="wake-bar">
      <div class="wb-l">
        <div class="wb-dot"></div>
        <span class="wb-txt" id="wb-txt">Listening for "Horizon"…</span>
      </div>
      <div class="wb-lvl"><div class="wb-bar" id="wb-bar"></div></div>
    </div>
    
    <!-- ═══════════ PROVIDER BAR ═══════════
         Sprint-2.14 — the 13 inline provider chips were a 2010-browser-tabs
         row that duplicated the composer Model chip + Model picker popover.
         Polish audit recommended hiding them behind a popover; the composer
         Model chip already IS that popover, so the row is fully redundant.
    
         The chips are kept in DOM (some JS still queries #pc-* ids for the
         "active provider" highlight) but wrapped in .sb-provider-row which
         is display:none. Removing them entirely would require touching
         setProv/setProvider in 5+ files. CSS hide is the surgical fix. -->
    <div class="sb">
      <span class="pulse" id="pulse"></span>
      <span class="stl" id="stl">Ready</span>
      <div class="sp"></div>
      <div class="sb-provider-row">
        <button class="chip" id="pc-gemini"   onclick="setProv('gemini')">Gemini</button>
        <button class="chip" id="pc-ollama"   onclick="setProv('ollama')">Ollama</button>
        <button class="chip" id="pc-lmstudio" onclick="setProv('lmstudio')">LM Studio</button>
        <button class="chip" id="pc-groq"     onclick="setProv('groq')">Groq</button>
        <button class="chip" id="pc-deepseek" onclick="setProv('deepseek')">DeepSeek</button>
        <button class="chip" id="pc-mistral"  onclick="setProv('mistral')">Mistral</button>
        <button class="chip" id="pc-qwen"     onclick="setProv('qwen')">Qwen</button>
        <button class="chip" id="pc-grok"     onclick="setProv('grok')">Grok</button>
        <button class="chip" id="pc-claude"   onclick="setProv('claude')">Claude</button>
        <button class="chip" id="pc-openai"   onclick="setProv('openai')">OpenAI</button>
        <button class="chip" id="pc-perplexity" onclick="setProv('perplexity')">Perplexity</button>
        <button class="chip" id="pc-cohere"  onclick="setProv('cohere')">Cohere</button>
        <button class="chip" id="pc-openrouter" onclick="setProv('openrouter')" title="One key, 200+ models">OpenRouter</button>
      </div>
      <div class="sp"></div>
      <button class="chip" id="tc-search" onclick="toggleSearch()" aria-label="Toggle web search" title="Toggle web search"><svg class="licon"><use href="#i-search"/></svg></button>
      <button class="chip on" id="tc-eye"  onclick="toggleEye()" title="Toggle screen vision"><svg class="licon" style="vertical-align:-2px"><use href="#i-eye"/></svg> PC</button>
      <button class="chip special" id="tc-wake" onclick="toggleWake()" title="Toggle wake word"><svg class="licon" style="vertical-align:-2px"><use href="#i-sunrise"/></svg> Wake</button>
      <div class="sp"></div>
      <!-- Sprint-2.14 — emoji prefixes stripped. Mixed system emoji
           glyphs read as inconsistent next to the SVG-based composer
           icons. Plain text + the chip's accent colour does the job. -->
      <select class="chip" id="persona-select" onchange="setPersona(this.value)" style="background:var(--bg2);color:var(--acc);border-color:var(--b2);padding:3px 8px;font-size:10px;border-radius:25px;outline:none;cursor:pointer;font-family:var(--font);-webkit-app-region:no-drag">
        <option value="jarvis">Jarvis</option>
        <option value="friday">Friday</option>
        <option value="alfred">Alfred</option>
        <option value="sage">Sage</option>
        <option value="pixel">Pixel</option>
      </select>
    </div>
    
    <!-- ═══════════ MODES (PR-U1: 4 core + More) ═══════════
         Audit found 12 mode chips on one strip — same priority,
         overwhelming. Cursor / Claude Code / ChatGPT all collapse less-
         used modes behind a "More" affordance. Now: Chat / Code / Agent /
         Vision are the core trio + 1, and the dropdown carries the
         niche modes (Focus / Plan / Coach / Write / Email / Search /
         Translate / Roleplay). The button IDs are PRESERVED so existing
         handlers and `setMode()` calls keep working. -->
    <div class="modes">
      <!-- PR-LAYOUT-V4 Phase 2.A — Vision button removed (duplicate of Screen
           toggle in composer). #m-vision kept in DOM as `display:none` below
           (line ~5092 in original modes-strip) so setMode('vision') still has
           a valid target if any code path references it. -->
      <button class="mt on" id="m-chat"   onclick="setMode('chat')"><svg class="licon"><use href="#i-message"/></svg> Chat</button>
      <button class="mt"    id="m-code"   onclick="setMode('code')"><svg class="licon"><use href="#i-code"/></svg> Code</button>
      <button class="mt"    id="m-agent"  onclick="setMode('agent')"><svg class="licon"><use href="#i-bot"/></svg> Agent</button>
      <button class="mt mt-hidden" id="m-vision" onclick="setMode('vision')" style="display:none">Vision</button>
      <button class="mt mt-more" id="m-more" onclick="toggleModesMore(event)" title="Other modes">
        More <span style="opacity:.6;margin-left:4px">▾</span>
      </button>
      <!-- The "More" dropdown lazy-renders into #modes-more-pop on toggle.
           Hidden chip elements stay in DOM so setMode() can still apply
           the .on class (visual indicator surfaces in the More chip when
           a niche mode is active). -->
      <button class="mt mt-hidden" id="m-focus"     onclick="setMode('focus')"     style="display:none">Focus</button>
      <button class="mt mt-hidden" id="m-plan"      onclick="setMode('plan')"      style="display:none">Plan</button>
      <button class="mt mt-hidden" id="m-coach"     onclick="setMode('coach')"     style="display:none">Coach</button>
      <button class="mt mt-hidden" id="m-write"     onclick="setMode('write')"     style="display:none">Write</button>
      <button class="mt mt-hidden" id="m-email"     onclick="setMode('email')"     style="display:none">Email</button>
      <button class="mt mt-hidden" id="m-search"    onclick="setMode('search')"    style="display:none">Search</button>
      <button class="mt mt-hidden" id="m-translate" onclick="setMode('translate')" style="display:none">Translate</button>
      <button class="mt mt-hidden" id="m-roleplay"  onclick="setMode('roleplay')"  style="display:none">Roleplay</button>
    </div>
    <div class="modes-more-pop" id="modes-more-pop"></div>
    
    <!-- ═════ STATUS BAR (above messages) — PR-U1 trim ═════
         csb-provider + csb-model removed: those were the third + fourth
         places the user could pick a model, all opening the same picker.
         Composer-toolbar's model chip is now THE single switchboard. The
         status bar keeps only the unique per-chat info: title, tokens,
         license state, BYOK reassurance. -->
    <div class="chat-status-bar" id="chat-status-bar">
      <span class="csb-title" id="csb-title">New chat</span>
      <span class="csb-pill" id="csb-tokens">0 tokens</span>
      <span class="csb-pill csb-clickable" id="csb-license" onclick="onLicensePillClick()" style="display:none"></span>
      <span class="csb-pill csb-byok" id="csb-byok" title="Your API keys live on your machine — calls go directly from your machine to the provider. Horizon never sees prompts."><svg class="licon" style="vertical-align:-2px"><use href="#i-lock"/></svg> BYOK</span>
    </div>
    
    <!-- ═══════════ MODEL VARIANT POPOVER ═══════════
         Anchored under the csb-model pill. Lists the active provider's model
         options so you can switch without opening the full Settings panel.
         Like the model dropdown in ChatGPT / Claude. -->
    <div class="model-popover" id="model-popover"></div>
    <div class="persona-popover" id="persona-popover"></div>
    <!-- Mode-picker popover. MUST live at body level, sibling to the other
         two popovers above. Was originally nested inside .inp-wrap further
         down — .inp-wrap has backdrop-filter:blur(20px) which creates a
         containing block for position:fixed descendants in Electron, so the
         popover anchored 200+px below the viewport when positionPicker set
         viewport-relative left/top. Moving it here fixes the "Mode chip
         does nothing" regression that survived 5d9790a and 2bbefd5. -->
    <div class="mode-popover" id="mode-popover"></div>
    
    </div>
    
    <!-- ═══════════ APEX RIGHT SIDEBAR ═══════════
         NOTE: this sidebar is the Code-Mode "active context" view. The Persona /
         Tools / Files panels are placeholders until the full agentic IDE (PR3)
         ships and can populate them with real workspace state. Until then we
         show a "no workspace selected" hint instead of fake hardcoded files —
         fake state was actively misleading. -->
    <!-- ═══════════ CODE MODE — full IDE shell ═══════════
         Renders as a compact-default panel and explodes into a Cursor-style
         workspace (files | editor | terminal | right chat) when
         body.code-mode-active is on. All existing IDs (code-monaco,
         arp-files-list, code-editor, code-xterm, code-file-path,
         code-terminal-output, code-terminal-command, etc.) are preserved so
         the existing IDE JavaScript keeps working — only the visual
         scaffolding around them changed. -->
    <div id="apex-right-pane" class="apex-right-pane code-shell">
    
      <!-- Top bar: workspace breadcrumb + active model + global controls -->
      <div class="code-topbar">
        <div class="code-topbar-l">
          <div class="code-brand"><span class="code-brand-dot"></span><span class="code-brand-name">CODE MODE</span></div>
          <div class="code-breadcrumb" id="code-context-meta" title="Active workspace path">No workspace</div>
        </div>
        <div class="code-topbar-r">
          <span class="code-tag" id="code-context-title">Horizon Code Mode</span>
          <button class="code-btn" onclick="chooseCodeWorkspace()" title="Open a folder as the workspace">Open</button>
          <button class="code-btn" onclick="refreshCodeWorkspace()" title="Reload file tree from disk">Reload</button>
          <!-- PR-LAYOUT-V8 — Chat button removed per owner request. The
               mini-chat floating right column was confusing UX, and the
               topbar reads cleaner without the toggle. Use the main chat
               (left sidebar nav → Chat) when you need a conversation. -->
          <button class="code-btn" onclick="toggleCodeChat()" id="code-chat-toggle" title="Show / hide the right chat column" style="display:none">Chat</button>
          <button class="code-btn" onclick="toggleCodeTerminal()" id="code-terminal-toggle" title="Show / hide the terminal panel">Terminal</button>
          <button class="code-btn" onclick="toggleCodeMode()" title="Close Code Mode">Close</button>
        </div>
      </div>
    
      <!-- Files panel: search + tree -->
      <aside class="code-files">
        <div class="code-files-head">
          <input class="code-input" id="code-file-search" placeholder="Search files…" onkeydown="if(event.key==='Enter') searchCodeWorkspace()"/>
          <button class="code-btn" onclick="searchCodeWorkspace()" title="Run search">⌕</button>
          <button class="code-btn" onclick="refreshCodeWorkspace('')" title="Jump to workspace root">⌂</button>
        </div>
        <div class="code-files-status" id="code-workspace-status">No workspace selected.</div>
        <div id="arp-files-list" class="code-files-list">
          <div style="color:var(--t3);font-size:11px;line-height:1.6;padding:10px 12px">Open a folder to load files.</div>
        </div>
      </aside>
    
      <!-- Editor: file tabs above, Monaco below, action toolbar at the bottom -->
      <main class="code-editor-pane">
        <!-- Tabs: rendered dynamically by renderCodeTabs(). Single placeholder
             shown until the user opens their first file. -->
        <div class="code-tabs" id="code-tabs"></div>
        <input id="code-file-path" type="hidden" />
        <div class="code-monaco" id="code-monaco">
          <!-- Floating ⌘K edit prompt bar — see PR-D1 / .cek-bar styles. JS
               positions it in viewport coords above/below the active selection
               via openCmdKBar(). Hidden by default; rendered inside the Monaco
               host so absolute-positioning is anchored correctly. -->
          <div class="cek-bar" id="cek-bar" role="dialog" aria-label="AI edit prompt">
            <div class="cek-bar-row">
              <span class="cek-bar-icon"><svg class="licon"><use href="#i-sparkles"/></svg></span>
              <input
                type="text"
                id="cek-bar-input"
                class="cek-bar-input"
                placeholder="Edit selection with AI…"
                autocomplete="off"
                spellcheck="false"
              />
            </div>
            <div class="cek-bar-meta">
              <span id="cek-bar-scope">no selection</span>
              <span class="cek-bar-meta-sep">·</span>
              <span id="cek-bar-model">model: —</span>
              <span class="cek-bar-meta-spacer"></span>
              <span><span class="cek-bar-kbd">↵</span> submit</span>
              <span><span class="cek-bar-kbd">esc</span> close</span>
            </div>
          </div>
        </div>
        <textarea class="code-editor" id="code-editor" spellcheck="false" placeholder="Open a file from the workspace." style="display:none"></textarea>
        <div class="code-toolbar">
          <button class="code-btn primary" onclick="saveCodeFile()" title="Save current file (asks for permission)"><svg class="licon"><use href="#i-save"/></svg> Save</button>
          <button class="code-btn" onclick="runCodeSelection()"><svg class="licon"><use href="#i-play"/></svg> Run selection</button>
          <span class="code-toolbar-sep"></span>
          <button class="code-btn" onclick="askCodeAi('explain')">Explain</button>
          <button class="code-btn" onclick="askCodeAi('refactor')">Refactor</button>
          <button class="code-btn" onclick="askCodeAi('fix')">Fix</button>
          <button class="code-btn primary" onclick="applyLastCodeAi()">Apply AI</button>
          <span class="code-toolbar-sep"></span>
          <!-- PR-D1.5: ⌘K history dropdown + Undo + auto-commit toggle. The
               "🕐 ⌘K" button opens a dropdown of recent edits (last 10);
               "Undo last AI edit" rolls back the most recent one in place;
               "Auto-commit" toggles whether successful Apply triggers a git
               commit (only if the workspace is a git repo, with a one-time
               permission gate). All three are no-ops when no edits exist /
               no workspace open. -->
          <button class="code-btn" id="cek-history-btn" onclick="cekHistoryToggle(event)" title="Last 10 ⌘K edits"><svg class="licon"><use href="#i-clock"/></svg> ⌘K <span id="cek-history-count" style="opacity:.6">(0)</span></button>
          <button class="code-btn" id="cek-undo-btn" onclick="cekUndoLast()" title="Undo last AI edit (restores files from snapshot)"><svg class="licon"><use href="#i-undo"/></svg> Undo AI</button>
          <button class="code-btn" id="cek-autocommit-btn" onclick="cekToggleAutoCommit()" title="Auto-commit accepted ⌘K edits (git workspaces only)"><svg class="licon"><use href="#i-settings"/></svg> auto-commit: off</button>
          <span class="code-toolbar-sep"></span>
          <span class="code-toolbar-status" id="code-editor-status">Workspace edits ask for permission before writing.</span>
        </div>
        <!-- PR-D1.5: history dropdown shell. Anchored under #cek-history-btn
             in JS; renders the LRU list with per-entry Undo + open-original
             buttons. -->
        <div class="cek-history-pop" id="cek-history-pop"></div>
      </main>
    
      <!-- Terminal: collapsed by default, toggles via top bar Terminal button -->
      <section class="code-terminal-pane" id="code-terminal-pane">
        <div class="code-terminal-head">
          <span class="code-terminal-title"><svg class="licon"><use href="#i-keyboard"/></svg> Workspace terminal</span>
          <span style="flex:1"></span>
          <button class="code-btn" onclick="restartWorkspaceTerminal()" title="Restart shell">↻ restart</button>
          <button class="code-btn" onclick="toggleCodeTerminal()" title="Hide terminal">−</button>
        </div>
        <div class="code-xterm" id="code-xterm"></div>
        <div class="code-terminal-fallback" id="code-terminal-output" hidden>Workspace shell is idle.</div>
        <div class="code-terminal-row">
          <input class="code-input" id="code-terminal-command" placeholder="npm test" onkeydown="if(event.key==='Enter') runWorkspaceCommand()"/>
          <button class="code-btn primary" onclick="runWorkspaceCommand()">Run</button>
        </div>
      </section>
    
      <!-- Hidden helpers retained for compatibility with existing JS -->
      <div id="arp-tools-section" hidden><div id="arp-tools-list"></div></div>
      <div id="arp-persona" hidden></div>
    </div>
    
    </div>
    
    <!-- ═══════════ OPERATOR PANE ═══════════ -->
    <div id="operator-pane" class="operator-pane">
      <div class="op-header">
        <span>Operator Console</span>
        <div class="op-tabs">
          <span class="op-tab on" id="op-tab-log" onclick="opTab('log')">LOG</span>
          <span class="op-tab" id="op-tab-tools" onclick="opTab('tools')">TOOLS</span>
          <span class="op-tab" id="op-tab-runs" onclick="opTab('runs')">RUNS</span>
        </div>
        <button class="op-btn" id="op-pause-btn" onclick="opPause()">Pause</button>
        <button class="op-btn" onclick="opStep()">Step</button>
        <button class="op-btn" onclick="opStop()">Stop</button>
        <button class="op-btn" onclick="opClear()">Clear</button>
        <button class="op-btn" onclick="opCopy()">Copy</button>
        <span id="op-active-runs" style="color:var(--t3);margin-left:auto;font-size:10px">Active Runs: 0</span>
      </div>
      <div class="op-content" id="op-log">
        <div class="op-log-line tool">[SYSTEM] Operator Mode initialized. Ready for telemetry.</div>
      </div>
    </div>
    
    <!-- ═══════════ STEP RAIL ═══════════
         Auto-shows when an agent run reports a plan. Pulses the active step,
         ✓-marks completed ones. Hidden in chat-only turns. -->
    <div class="step-rail" id="step-rail"></div>
    <!-- PR-Plan-Act — gate that expands when an agent run is pending the
         user's first-tool approval. JS shows/hides via .show class on
         plan-pending / plan-decision events.
         Sprint-2.9: position:fixed below titlebar so it stops "floating"
         mid-layout. Icons, gradient border, slide-in, flex-wrap on narrow. -->
    <div class="step-rail-gate" id="step-rail-gate" role="alertdialog" aria-labelledby="plan-act-gate-title" aria-describedby="plan-act-gate-detail">
      <div class="step-rail-gate-icon" aria-hidden="true">
        <svg class="licon" width="20" height="20"><use href="#i-shield"/></svg>
      </div>
      <div class="step-rail-gate-body">
        <strong id="plan-act-gate-title">Approve plan?</strong>
        <span class="step-rail-gate-detail" id="plan-act-gate-detail">First tool: <code id="plan-act-gate-tool">tool</code></span>
      </div>
      <div class="step-rail-gate-actions">
        <button type="button" class="srg-btn srg-btn-reject" onclick="planActReject()" title="Reject this plan (Esc)">
          <svg class="licon" width="14" height="14" aria-hidden="true"><use href="#i-x"/></svg>
          <span>Reject</span>
        </button>
        <button type="button" class="srg-btn srg-btn-approve" onclick="planActApprove()" title="Approve and run (Enter)">
          <svg class="licon" width="14" height="14" aria-hidden="true"><use href="#i-check"/></svg>
          <span>Approve plan</span>
        </button>
      </div>
    </div>
    
    <!-- ═══════════ INSPECTOR (chat-mode right dock) ═══════════
         Toggled via title-bar 🪟 button. Mirrors the premium inspector pattern
         from the Claude design tool: Context · Tools · Cost · Log tabs +
         BYOK footer. Only visible when body.inspector-active. -->
    <div class="inspector-pane" id="inspector-pane">
      <div class="insp-head">
        <div class="insp-tabs">
          <button class="insp-tab on" data-tab="context"   onclick="setInspectorTab('context')">Context</button>
          <button class="insp-tab"    data-tab="tools"     onclick="setInspectorTab('tools')">Tools</button>
          <button class="insp-tab"    data-tab="skills"    onclick="setInspectorTab('skills')">Skills</button>
          <button class="insp-tab"    data-tab="subagents" onclick="setInspectorTab('subagents')">Subagents</button>
          <button class="insp-tab"    data-tab="learned"   onclick="setInspectorTab('learned')">Learned</button>
          <button class="insp-tab"    data-tab="cost"      onclick="setInspectorTab('cost')">Cost</button>
          <button class="insp-tab"    data-tab="log"       onclick="setInspectorTab('log')">Log</button>
        </div>
        <button class="insp-close" onclick="toggleInspectorMode()" title="Close inspector">&times;</button>
      </div>
      <div class="insp-body" id="insp-body-context">
        <div class="insp-section">
          <div class="insp-h">Active turn</div>
          <div class="insp-row"><span class="k">Provider</span><span class="v" id="insp-provider">—</span></div>
          <div class="insp-row"><span class="k">Model</span><span class="v" id="insp-model">—</span></div>
          <div class="insp-row"><span class="k">Mode</span><span class="v" id="insp-mode">chat</span></div>
          <div class="insp-row"><span class="k">Persona</span><span class="v" id="insp-persona">Default</span></div>
        </div>
        <div class="insp-section">
          <div class="insp-h">Workspace</div>
          <div class="insp-row"><span class="k">Path</span><span class="v" id="insp-workspace">—</span></div>
          <div class="insp-row"><span class="k">Open files</span><span class="v" id="insp-open-files">0</span></div>
        </div>
        <div class="insp-section">
          <div class="insp-h">Connections</div>
          <div class="insp-row"><span class="k">MCP servers</span><span class="v" id="insp-mcp">0</span></div>
          <div class="insp-row"><span class="k">Voice</span><span class="v" id="insp-voice">—</span></div>
        </div>
      </div>
      <div class="insp-body" id="insp-body-tools" style="display:none">
        <div class="insp-section">
          <div class="insp-h">Built-in</div>
          <div id="insp-tools-builtin"></div>
        </div>
        <div class="insp-section">
          <div class="insp-h">MCP</div>
          <div id="insp-tools-mcp" class="insp-empty">No MCP servers connected.</div>
        </div>
      </div>
      <!-- Skills tab — populated by chat-inspector.js refreshInspectorSkills().
           Listens on H.onAgentStep for type='skills-selected' which main.js
           emits right before runAgentLoop. -->
      <div class="insp-body" id="insp-body-skills" style="display:none">
        <div class="insp-section">
          <div class="insp-h">Skills loaded for the last turn</div>
          <div class="insp-empty">No skills resolved yet. Send a message and any matching SKILL.md will appear here with its score breakdown.</div>
        </div>
      </div>
      <!-- Subagents tab — populated by chat-inspector.js from subagent-spawned /
           subagent-end / subagent-step events broadcast by spawnSubagent in
           main.js. Tree view: parent run → its subagents → their step counts. -->
      <div class="insp-body" id="insp-body-subagents" style="display:none">
        <div class="insp-section">
          <div class="insp-h">Subagent runs</div>
          <div id="insp-subagent-tree" class="insp-empty">No subagents spawned yet. The agent calls <code>spawn_subagent</code> for parallel-friendly research / multi-source lookups (max depth 2, max 4 concurrent).</div>
        </div>
      </div>
      <!-- Learned tab — facts + memories AgentMemory accumulated + Big Five
           user profile. Edit/forget UI lets the user curate what Horizon
           remembers. Snapshots via H.memSnapshot. -->
      <div class="insp-body" id="insp-body-learned" style="display:none">
        <div class="insp-section">
          <div class="insp-h">Latest reflection</div>
          <div id="insp-reflection" class="insp-empty">No reflection yet — finish a task and the model's self-check lands here.</div>
        </div>
        <div class="insp-section">
          <div class="insp-h">Stats</div>
          <div id="insp-learned-stats"><div class="insp-empty">Loading…</div></div>
        </div>
        <div class="insp-section">
          <div class="insp-h">User profile · Big Five + communication style</div>
          <div id="insp-learned-profile"><div class="insp-empty">Loading profile…</div></div>
        </div>
        <!-- PHASE 28.4 — Dialectic user model (Honcho-inspired 9th layer).
             Big Five gives the static snapshot; this shows the diff log of
             what we've learned over time — beliefs, desires, knowledge,
             theory-of-mind updates, corrections. -->
        <div class="insp-section">
          <div class="insp-h">Dialectic model · what we have learned</div>
          <div id="insp-learned-dialectic" class="insp-empty" style="line-height:1.6;padding:8px 0">
            <div id="insp-dialectic-summary" style="font-size:11px;color:var(--t2)">Loading…</div>
            <div class="insp-row" style="padding-top:6px;gap:6px;align-items:stretch">
              <input class="pi" id="insp-dialectic-search" placeholder="Search dialectic…" style="flex:1;height:28px;font-size:11px" oninput="window._inspDialecticSearch?.(this.value)"/>
            </div>
            <div id="insp-dialectic-list" style="margin-top:8px;max-height:240px;overflow-y:auto;font-size:11px"></div>
            <div class="insp-row" style="justify-content:flex-end;padding-top:8px;gap:6px">
              <button class="hub-btn" onclick="window._inspDialecticRefresh?.(this)" title="Reload dialectic records">Refresh</button>
              <button class="hub-btn mem-btn-danger" onclick="window._inspDialecticClear?.(this)" title="Wipe all dialectic records (Big Five profile stays)">Forget all</button>
            </div>
          </div>
        </div>
        <div class="insp-section">
          <div class="insp-h">Known facts about you</div>
          <div id="insp-learned-facts"><div class="insp-empty">No facts yet — Horizon learns from your conversations.</div></div>
        </div>
        <div class="insp-section">
          <div class="insp-h">Recent memories</div>
          <div id="insp-learned-memories"><div class="insp-empty">No memories yet.</div></div>
        </div>
        <!-- PHASE 8/8 — workspace memory: .horizon/memory.json committable to git.
             Read-only view; humans edit the JSON in their editor. -->
        <div class="insp-section">
          <div class="insp-h">Workspace memory · .horizon/memory.json</div>
          <div id="insp-learned-workspace"><div class="insp-empty">Open a workspace to see committable team memory (conventions / glossary / decisions).</div></div>
        </div>
        <!-- PHASE 28.3 — agent-curated memory reviewer (Hermes-style nudges).
             Runs every 12h in the background; this section gives you the
             "Review now" button + last-pass stats. -->
        <div class="insp-section">
          <div class="insp-h">Agent-curated review</div>
          <div id="insp-learned-reviewer" class="insp-empty" style="line-height:1.6;padding:8px 0">
            <div style="font-size:11px;color:var(--t2)">
              Every 12 hours the agent walks its own memory and:
              <ul style="margin:6px 0 0 18px;padding:0">
                <li><strong>Decays</strong> importance on memories not surfaced in 7+ days</li>
                <li><strong>Merges</strong> near-duplicates (embedding cosine ≥ 0.94)</li>
                <li><strong>Forgets</strong> importance ≤ 2 + inactive 60+ days</li>
              </ul>
            </div>
            <div id="insp-reviewer-stats" style="margin-top:8px;font-size:10px;color:var(--t3)">No pass yet — first one runs ~1h after launch.</div>
            <div class="insp-row" style="justify-content:flex-end;padding-top:8px;gap:6px">
              <button class="hub-btn" onclick="window._inspMemoryReviewerStatus?.(this)" title="Show when the reviewer last ran">Status</button>
              <button class="hub-btn" onclick="window._inspMemoryReviewerRunNow?.(this)" title="Trigger a review pass right now">Review now</button>
              <button class="hub-btn" onclick="window._inspMemoryConsolidateNow?.(this)" title="Synthesize insights from clusters of recent memories (layer 10)">Consolidate</button>
            </div>
          </div>
        </div>
        <!-- Sprint 7B — SQLite is now the PRIMARY memory store. JSON is
             kept around only as a portable export format (snapshots, git
             diffs, machine-to-machine moves). Migration from the old
             JSON-primary layout happens automatically on first boot. -->
        <div class="insp-section">
          <div class="insp-h">Storage backend · SQLite primary, JSON export available</div>
          <div id="insp-learned-storage" class="insp-empty" style="line-height:1.6;padding:8px 0">
            <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:11px">
              <div>
                <div style="color:var(--accent,#7ec8e3);font-weight:600;margin-bottom:4px">SQLite (.sqlite) — primary</div>
                <div style="color:var(--t2);line-height:1.5">
                  · FTS5 keyword index<br>
                  · Memory metadata<br>
                  · Conversation history<br>
                  · Facts (KV table)<br>
                  · SQL WHERE filters<br>
                  · ACID transactions<br>
                  · Scales past 10K records
                </div>
              </div>
              <div>
                <div style="color:var(--amb,#facc15);font-weight:600;margin-bottom:4px">JSON (.json) — export only</div>
                <div style="color:var(--t2);line-height:1.5">
                  · Portable backup snapshot<br>
                  · Vector embeddings (sidecar)<br>
                  · Skills (per-skill folder)<br>
                  · Settings + preferences<br>
                  · Workflow definitions<br>
                  · Plugin manifests<br>
                  · Rules.md (per-workspace)
                </div>
              </div>
            </div>
            <div style="font-size:10px;color:var(--t3);margin-top:8px">
              SQLite holds the live store. Use the buttons below to snapshot to JSON or import a JSON dump back into SQLite.
            </div>
            <div class="insp-row" style="justify-content:flex-end;padding-top:8px;gap:6px;flex-wrap:wrap">
              <button class="hub-btn" onclick="window._inspMemoryDbStatus?.(this)" title="Show row counts in the SQLite store">Status</button>
              <button class="hub-btn" onclick="window._inspMemoryExportJson?.(this)" title="Snapshot the SQLite store into a JSON file">Export memory to JSON file</button>
              <button class="hub-btn" onclick="window._inspMemoryImportJson?.(this)" title="Bulk-load a JSON dump back into SQLite">Import JSON memory dump</button>
            </div>
          </div>
        </div>
      </div>
      <div class="insp-body" id="insp-body-cost" style="display:none">
        <div class="insp-section">
          <div class="insp-h">Session usage</div>
          <div class="cost-headline" id="insp-cost-headline">0 tok</div>
          <div class="cost-subhead" id="insp-cost-sub">0 msgs - 0 tokens - estimates start after the first message</div>
        </div>
        <div class="insp-section">
          <div class="insp-h">Token budget</div>
          <div class="token-bar" id="insp-token-bar">
            <div class="token-seg system"  style="width:0%"></div>
            <div class="token-seg history" style="width:0%"></div>
            <div class="token-seg files"   style="width:0%"></div>
            <div class="token-seg tools"   style="width:0%"></div>
          </div>
          <div class="token-bar-meta" id="insp-token-meta">
            <span class="seg system">System 0</span>
            <span class="seg history">History 0</span>
            <span class="seg files">Files 0</span>
            <span class="seg tools">Tools 0</span>
          </div>
          <div class="insp-row"><span class="k">Used</span><span class="v" id="insp-token-used">0</span></div>
          <div class="insp-row"><span class="k">Budget</span><span class="v" id="insp-token-budget">200,000</span></div>
        </div>
      </div>
      <div class="insp-body" id="insp-body-log" style="display:none">
        <div class="insp-section">
          <div class="insp-h">Recent agent steps</div>
          <div class="insp-log" id="insp-log-list">
            <div class="insp-empty">No agent activity yet.</div>
          </div>
        </div>
      </div>
      <div class="insp-byok"><strong>BYOK · local-first.</strong> Calls go directly from your machine to the provider you picked. Horizon never proxies your prompts.</div>
    </div>
    
    <!-- ═══════════ MESSAGES ═══════════ -->
    <div class="msgs" id="msgs"></div>
    
    <!-- ═══════════ INPUT ═══════════ -->
    <div class="inp-wrap">
      <div class="vb" id="vb">
        <div class="vdot"></div>
        <span id="vtxt">Запись…</span>
        <div class="mwrap"><div id="mbar"></div></div>
        <span style="font-size:9px;color:var(--t3);display:inline-flex;align-items:center;gap:4px">нажми <svg class="licon" style="width:11px;height:11px"><use href="#i-mic"/></svg> чтобы стоп</span>
      </div>
      <div class="attach-preview" id="attach-preview"></div>
      <div class="composer-toolbar" id="composer-toolbar">
        <button class="composer-chip strong" onclick="openModelPicker(event)" title="Choose model">
          <span>Model</span><b id="composer-model-chip">Gemini</b>
        </button>
        <!-- Sprint 4 — Task 3: persona pill with small avatar glyph. The
             persona-chip class adds an accent tint so the pill reads as
             clickable + distinct from the rest of the toolbar. The avatar
             span is populated by setPersona() from the persona's .icon. -->
        <button class="composer-chip persona-chip" onclick="openPersonaPicker(event)" title="Choose persona">
          <span class="persona-chip-avatar" id="composer-persona-avatar" aria-hidden="true">P</span>
          <span>Persona</span><b id="composer-persona-chip">Default</b>
        </button>
        <!-- PR-LAYOUT-V3 — Mode chip was cycle-on-click (clicked through
             Chat → Code → Agent → Vision → Chat). Switched to a real picker
             that surfaces all 12 modes (4 core + 8 niche: Focus / Plan /
             Coach / Write / Email / Search / Translate / Roleplay) which
             used to be reachable only via the now-hidden .modes strip. -->
        <button class="composer-chip" onclick="openModePicker(event)" title="Choose chat mode">
          <span>Mode</span><b id="composer-mode-chip">Chat</b>
        </button>
        <!-- PR-LAYOUT-V3 — Wake (JARVIS-style voice activation) lived in
             the .sb provider bar (#tc-wake) which is `display:none` since
             shell-clean shipped. The feature was wired and worked but had
             no reachable UI. Re-surfaced here as a first-class composer
             toggle so users can flip wake mode without opening Settings. -->
        <button class="composer-chip" id="composer-wake-chip" onclick="toggleWake()" title="Wake word — say «Горизонт» / «Horizon» to invoke">
          <span>Wake</span><b id="composer-wake-state">Off</b>
        </button>
        <button class="composer-chip" id="composer-web-chip" onclick="toggleSearch()" title="Toggle web/search grounding">
          <span>Web</span><b id="composer-web-state">Off</b>
        </button>
        <button class="composer-chip" id="composer-screen-chip" onclick="toggleEye()" title="Toggle screen context (Vision)">
          <span>Screen</span><b id="composer-screen-state">Off</b>
        </button>
        <!-- Sprint-2.9 — Live Canvas chip. The canvas surface (Phase 27) shipped
             but was reachable only via /canvas slash command or window.openCanvas().
             Now first-class alongside Model/Mode/Wake/Web/Screen so users actually
             discover it. canvasToggle() opens/closes the panel; the chip's <b>
             updates from a live event when the agent writes to it (auto-pop). -->
        <button class="composer-chip" id="composer-canvas-chip" onclick="toggleCanvas()" title="Live Canvas — shared workspace the agent can write to">
          <span>Canvas</span><b id="composer-canvas-state">Off</b>
        </button>
        <button class="composer-chip" onclick="openCmdPalette()" title="Command palette · ⌘K">
          <span>Tools</span><b>⌘K</b>
        </button>
      </div>
      <!-- PR-LAYOUT-V3 — Mode picker popover. ⚠ DO NOT MOVE INSIDE .inp-wrap.
           Was nested here originally and the mode chip "didn't open" because
           .inp-wrap has backdrop-filter:blur(20px) — which creates a containing
           block for position:fixed descendants in Chromium/Electron. The
           popover *did* open, but its left/top values (computed via
           getBoundingClientRect in positionPicker) were viewport-relative
           while the popover was positioning against .inp-wrap (bottom strip
           of the window). Result: popover rendered ~200px BELOW the viewport.
           Sibling Model/Persona popovers (lines below #chat-status-bar)
           worked because they sit at body level. Mode picker now lives next
           to them. See `#mode-popover` div ~25 lines below. -->
      <div class="irow">
        <input type="file" id="file-input" accept="image/*,.txt,.md,.js,.ts,.jsx,.tsx,.py,.html,.css,.json,.csv,.xml,.yaml,.yml,.sh,.sql,.zip,.pdf" multiple onchange="onFilesSelected(event)"/>
        <button class="ibtn btn-attach" onclick="document.getElementById('file-input').click()" title="Прикрепить файл или фото"><svg class="licon"><use href="#i-paperclip"/></svg></button>
        <button class="ibtn btn-mic" id="btn-mic" onclick="toggleVoice()">
          <svg class="licon"><use href="#i-mic"/></svg><span class="mlbl">VOICE</span>
        </button>
        <button class="ibtn btn-mic" id="btn-dictate" onmousedown="startDictate()" onmouseup="stopDictate()" onmouseleave="stopDictate()" ontouchstart="startDictate()" ontouchend="stopDictate()" title="Удерживай для диктовки">
          <svg class="licon"><use href="#i-hand"/></svg><span class="mlbl">HOLD</span>
        </button>
        <textarea id="inp" rows="1" placeholder="Спроси Хорайзон… или прикрепи файл/фото" onkeydown="kd(event)" oninput="ar(this)" style="-webkit-app-region:no-drag"></textarea>
        <button class="ibtn btn-send" id="btn-send" onclick="sendMsg()"><svg class="licon"><use href="#i-arrow-up"/></svg></button>
      </div>
      <!-- PR-U-MEGA — cost preview + agent activity strip below the input.
           Replaces the noisy chat-status-bar above. Updates live as the
           user types (every keystroke), so the cost-of-this-turn signal
           is the canonical place to look — not buried in 5 different
           pills across 3 different surfaces. -->
      <div class="composer-foot">
        <span class="composer-foot-l">
          <span class="composer-foot-tokens" id="composer-foot-tokens" title="Provider-reported token usage for this chat">
            <svg class="licon"><use href="#i-trending-up"/></svg>
            <span id="composer-foot-tokens-n">usage unavailable</span>
          </span>
          <span class="composer-foot-sep">·</span>
          <span class="composer-foot-cost" id="composer-foot-cost" title="Horizon does not estimate BYOK cost from stale hardcoded prices">provider usage only</span>
          <span class="composer-foot-sep">·</span>
          <span class="composer-foot-byok" title="BYOK · calls go directly from your machine to the provider — Horizon never sees prompts"><svg class="licon" style="vertical-align:-2px"><use href="#i-lock"/></svg> BYOK</span>
        </span>
        <span class="composer-foot-r">
          <!-- PR-Plan-Act — toggle that gates the first agent tool execution
               behind an "Approve plan" CTA. Persists via H.set('planActGate'). -->
          <button class="composer-foot-toggle" id="plan-act-toggle"
                  onclick="togglePlanActGate()"
                  title="Plan/Act gate — when on, agent runs pause for your approval before executing the first tool">
            <span class="composer-foot-toggle-dot" id="plan-act-toggle-dot"></span>
            <span id="plan-act-toggle-label">Plan-Act</span>
          </button>
          <span class="composer-foot-agent" id="composer-foot-agent" title="Agent activity">
            <span class="composer-foot-agent-dot" id="composer-foot-agent-dot"></span>
            <span id="composer-foot-agent-text">ready</span>
          </span>
        </span>
      </div>
      <div class="hint">Enter · Shift+Enter · / commands · @ files / symbols · ⌘K palette</div>
    </div>
    
    <script src="/vendor/monaco/loader.js"></script>
    <script src="/vendor/xterm/lib/xterm.js"></script>
    <script>
    // PR-LAYOUT-V3 — synchronous sidebar visibility. The async bootCurrentChat
    // chain used to be the *only* thing that added body.with-sidebar; if the
    // IPC was slow or threw, the entire layout stayed in "no sidebar" mode
    // and the user lost all primary navigation. Add the class here, before
    // any async work, so the layout is correct from the very first paint.
    try { document.body.classList.add('with-sidebar'); } catch(_) {}
    // ═══════════════════════════════════════════════════════════════
    // STATE
    // ═══════════════════════════════════════════════════════════════
    // PHASE 28 — language locked to English at the renderer level so all
    // hardcoded `lang === 'ru'` branches stay dormant. The persona's TTS /
    // recognition language is still chosen separately via voice settings.
    var prov='gemini', mode='chat', lang='en', userName='Ernest';
    var ttsOn=true, eyeOn=false, searchOn=false, wakeOn=false;
    var provider='gemini';
    var screenContextB64=null;
    var attachments=[];
    var geminiModel='gemini-2.5-flash', voiceProvider='groq', ttsProvider='system';
    var elevenLabsVoice='pNInz6obpgDQGcFmaJgB', openaiTtsVoice='onyx';
    var isRecording=false, isProcessing=false;
    var audioStream=null, mediaRec=null, audioChunks=[], analyserAnim=null;
    // Do not name this `history`: in Electron/browser renderers that resolves to
    // window.history, so history.push(...) can crash before the AI request starts.
    var chatHistory=[];
    
    var SURFACE_NAMES = ['chat','dashboard','code','workflows','plugins','skills','telegram','discord','marketplace','personas','settings','account','recorder'];
    var SURFACE_PANEL_IDS = ['hub-panel','skills-panel','telegram-panel','discord-panel','store-panel','acct-panel','wf-panel','recorder-panel','dashboard-panel','panel'];
    var activeSurface = 'chat';
    var chatSidebarCollapsed = false;
    
    function uiIcon(id){
      return `<svg class="licon"><use href="#${id}"/></svg>`;
    }
    function controlHtml(icon, label){
      return `${uiIcon(icon)}<span class="mlbl">${label}</span>`;
    }
    function setVoiceButtonState(state='idle'){
      const btn = document.getElementById('btn-mic');
      if (!btn) return;
      if (state === 'recording') btn.innerHTML = controlHtml('i-stop','STOP');
      else if (state === 'processing') btn.innerHTML = controlHtml('i-loader','...');
      else btn.innerHTML = controlHtml('i-mic','VOICE');
    }
    function setDictateButtonState(state='idle'){
      const btn = document.getElementById('btn-dictate');
      if (!btn) return;
      if (state === 'recording') btn.innerHTML = controlHtml('i-stop','REC');
      else if (state === 'processing') btn.innerHTML = controlHtml('i-loader','...');
      else btn.innerHTML = controlHtml('i-circle','HOLD');
    }
    function normalizeComposerButtons(){
      const attach = document.getElementById('file-input')?.nextElementSibling;
      if (attach?.classList?.contains('btn-attach')) attach.innerHTML = uiIcon('i-paperclip');
      setVoiceButtonState('idle');
      setDictateButtonState('idle');
      const send = document.getElementById('btn-send');
      if (send) send.innerHTML = uiIcon('i-send');
      const inp = document.getElementById('inp');
      if (inp) inp.placeholder = 'Ask Horizon... attach files or use /commands';
    }
    function setChatSidebarCollapsed(on){
      chatSidebarCollapsed = Boolean(on);
      document.body.classList.toggle('chat-sidebar-collapsed', chatSidebarCollapsed);
      document.querySelector('.cs-collapse')?.setAttribute('title', chatSidebarCollapsed ? 'Show chat list' : 'Hide chat list');
      // Sprint-2.14 — the label was hardcoded "Collapse" forever; polish
      // audit flagged that it never said "Expand" when already collapsed.
      // Swap the visible text + the title to match the action.
      const lbl = document.getElementById('cs-collapse-label');
      if (lbl) lbl.textContent = chatSidebarCollapsed ? 'Expand' : 'Collapse';
      Promise.resolve(window.H?.set?.('chatSidebarCollapsed', chatSidebarCollapsed)).catch(()=>{});
      try { layoutInspectorDock(); } catch(_) {}
    }
    function toggleChatSidebar(){
      setChatSidebarCollapsed(!chatSidebarCollapsed);
    }
    function setChatSidebarWidth(width, persist=true){
      const next = Math.max(240, Math.min(420, Number(width) || 280));
      document.documentElement.style.setProperty('--sidebar-w', `${next}px`);
      if (persist) {
        Promise.resolve(window.H?.set?.('chatSidebarWidth', next)).catch(()=>{});
      }
      try { layoutInspectorDock(); } catch(_) {}
    }
    function ensureChatResizeHandle(){
      const side = document.getElementById('chatside');
      if (!side || side.querySelector('.cs-resize')) return;
      const handle = document.createElement('div');
      handle.className = 'cs-resize';
      handle.title = 'Resize chat list';
      handle.addEventListener('pointerdown', startChatSidebarResize);
      side.appendChild(handle);
    }
    function startChatSidebarResize(ev){
      if (chatSidebarCollapsed) return;
      ev.preventDefault();
      const startX = ev.clientX;
      const startW = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--sidebar-w')) || 280;
      document.body.classList.add('chat-sidebar-resizing');
      const move = e => setChatSidebarWidth(startW + (e.clientX - startX), false);
      const up = () => {
        document.body.classList.remove('chat-sidebar-resizing');
        window.removeEventListener('pointermove', move);
        window.removeEventListener('pointerup', up);
        const finalW = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--sidebar-w')) || startW;
        setChatSidebarWidth(finalW, true);
      };
      window.addEventListener('pointermove', move);
      window.addEventListener('pointerup', up, { once:true });
    }
    
    function _surfaceClasses(){
      return ['surface-active'].concat(SURFACE_NAMES.map(s => 'surface-' + s));
    }
    function _clearSurfacePanels(keepIds=[]){
      const keep = new Set(keepIds || []);
      SURFACE_PANEL_IDS.forEach(id => {
        if (!keep.has(id)) document.getElementById(id)?.classList.remove('show');
      });
    }
    function _setInspectorVisible(on){
      try {
        if (typeof inspectorActive !== 'undefined') inspectorActive = Boolean(on);
        if (on && typeof layoutInspectorDock === 'function') layoutInspectorDock();
        if (!on) document.body.classList.remove('inspector-compact');
        document.body.classList.toggle('inspector-active', Boolean(on));
        document.getElementById('inspector-mode-btn')?.classList.toggle('proc', Boolean(on));
        H.set?.('inspectorActive', Boolean(on));
      } catch(_) {}
    }
    function _setCodeSurfaceVisible(on){
      try {
        if (typeof codeModeActive !== 'undefined') codeModeActive = Boolean(on);
        document.body.classList.toggle('code-mode-active', Boolean(on));
        document.body.classList.toggle('code-terminal-collapsed', Boolean(on));
        document.body.classList.toggle('code-chat-collapsed', Boolean(on));
        document.getElementById('code-mode-btn')?.classList.toggle('proc', Boolean(on));
      } catch(_) {}
    }
    function setActiveSurface(surface='chat', opts={}){
      const next = SURFACE_NAMES.includes(surface) ? surface : 'chat';
      const keepPanels = opts.keepPanels || [];
      _clearSurfacePanels(keepPanels);
      document.body.classList.remove(..._surfaceClasses());
      activeSurface = next;
      if (next !== 'chat') document.body.classList.add('surface-active', 'surface-' + next);
      if (next !== 'code') _setCodeSurfaceVisible(false);
      if (next !== 'chat' && next !== 'code') {
        if (typeof operatorModeActive !== 'undefined' && operatorModeActive) {
          operatorModeActive = false;
          document.body.classList.remove('operator-mode-active');
          document.getElementById('operator-mode-btn')?.classList.remove('proc');
        }
      }
      if (next !== 'chat') _setInspectorVisible(false);
      updateShellChrome(next);
      return activeSurface;
    }
    function closeActiveSurface(){
      _clearSurfacePanels([]);
      _setCodeSurfaceVisible(false);
      document.body.classList.remove(..._surfaceClasses());
      activeSurface = 'chat';
      updateShellChrome('chat');
    }
    function isSurfaceActive(surface){
      return activeSurface === surface;
    }
    function shellSurfaceName(surface){
      if (typeof operatorModeActive !== 'undefined' && operatorModeActive) return 'Agent';
      const names = {
        chat: 'Chat',
        dashboard: 'Home',
        code: 'Code',
        workflows: 'Workflows',
        plugins: 'Plugins',
        marketplace: 'Marketplace',
        personas: 'Personas',
        settings: 'Settings',
        account: 'Account',
        recorder: 'Recorder',
      };
      return names[surface || activeSurface] || 'Chat';
    }
    function updateShellChrome(surface = activeSurface){
      try {
        const navTarget = (typeof operatorModeActive !== 'undefined' && operatorModeActive)
          ? 'agent'
          : ((typeof inspectorActive !== 'undefined' && inspectorActive)
              ? 'inspector'
              : (surface === 'marketplace' ? 'marketplace' : surface));
        document.querySelectorAll('#shell-nav .cs-act').forEach(btn => {
          btn.classList.toggle('on', btn.dataset.shellTarget === navTarget);
        });
        const label = document.getElementById('shell-surface-label');
        if (label) label.textContent = shellSurfaceName(surface);
        const modelLabel = document.getElementById('shell-model-label');
        const model = getSelectedModelForProvider?.(prov) || prov || 'model';
        if (modelLabel) modelLabel.textContent = `${(prov || provider || 'AI').toUpperCase()} · ${model}`;
        const composerModel = document.getElementById('composer-model-chip');
        if (composerModel) composerModel.textContent = model;
        if (String(mode || '').toLowerCase() === 'image' && typeof refreshImageComposerChip === 'function') {
          refreshImageComposerChip();
        }
        const composerPersona = document.getElementById('composer-persona-chip');
        if (composerPersona) composerPersona.textContent = currentPersona || 'Default';
        const composerMode = document.getElementById('composer-mode-chip');
        if (composerMode) composerMode.textContent = String(mode || 'chat').replace(/^\w/, c => c.toUpperCase());
        document.getElementById('composer-web-state')?.replaceChildren(document.createTextNode(searchOn ? 'On' : 'Off'));
        document.getElementById('composer-screen-state')?.replaceChildren(document.createTextNode(eyeOn ? 'On' : 'Off'));
        document.getElementById('composer-web-chip')?.classList.toggle('on', !!searchOn);
        document.getElementById('composer-screen-chip')?.classList.toggle('on', !!eyeOn);
        // PR-LAYOUT-V3 — Wake chip state sync (was nowhere because Wake had
        // no composer chip before this PR).
        document.getElementById('composer-wake-state')?.replaceChildren(document.createTextNode(wakeOn ? 'On' : 'Off'));
        document.getElementById('composer-wake-chip')?.classList.toggle('on', !!wakeOn);
      } catch(_) {}
    }
    function showChatSurface(){
      closeActiveSurface();
      if (typeof operatorModeActive !== 'undefined' && operatorModeActive) {
        operatorModeActive = false;
        document.body.classList.remove('operator-mode-active');
        document.getElementById('operator-mode-btn')?.classList.remove('proc');
      }
      updateShellChrome('chat');
    }
    
    // ═══ MULTI-CHAT STATE ═══
    var currentChatId = null;
    var chatsCache = []; // sidebar payload only (no messages)
    var chatSearchQuery = ''; // sidebar search filter
    
    // ═══ TOKEN TRACKING ═══
    var sessionTokens = 0;
    var sessionMsgs = 0;
    var sessionUsageKnown = false;
    var sessionHasEstimatedUsage = false;
    var sessionUsageUnavailable = false;
    
    // PR-V Phase 3.22 — Token tracking extracted to chat-tokens.js.
    // estimateTokensForText, trackTokens, updateTokenDisplay live there.
    
    
    
    // PR-V Phase 3 — wake word engine extracted to chat-voice-wake.js.
    // Loaded via <script src="chat-voice-wake.js"> at end of <body>.
    // Wake state + functions (wakeActive, toggleWake, startWakeMode,
    // stopWakeMode, runWakeChunk, transcribeWakeChunk, fireWake,
    // setWakeBar, pauseWakeForTts, resumeWakeAfterTts, etc.) all stay
    // on window so existing onclick="toggleWake()" handlers still fire.
    
    
    
    // PR-V Phase 3.11 — Voice recording + TTS extracted to chat-voice.js.
    // testMic, startLvl/stopLvl, toggleVoice, startRec/stopRec,
    // processAudio, startDictate/stopDictate, processDictation,
    // playAudioB64, systemTTS, kokoroTTS, speakAndThen, speak all
    // live there. chat-voice-wake.js (Phase 3.1) handles the wake
    // word engine separately and provides pauseWakeForTts /
    // resumeWakeAfterTts hooks that speak() calls.
    
    
    // ═══════════════════════════════════════════════════════════════
    // MODE SYSTEM PROMPTS
    // ═══════════════════════════════════════════════════════════════
    var MODES={
      agent:{ru:'Ты в режиме АГЕНТ. У тебя прямой доступ к ПК пользователя. Открывай приложения, папки, делай скриншоты, анализируй экран, выполняй команды. Если не можешь что-то — объясни как сделать вручную. Говори "Сэр" как Джарвис.',en:'AGENT mode. Direct PC access — open apps, folders, screenshots, run commands. Say "Sir" like JARVIS.'},
      email:{ru:'Режим EMAIL. Пиши профессиональные письма. Структура: тема, приветствие, суть, подпись. Стиль — деловой но дружелюбный.',en:'EMAIL mode. Write professional emails. Structure: subject, greeting, body, signature. Style: businesslike but friendly.'},
      search:{ru:'Режим ПОИСК. Ищи информацию, отвечай с источниками. Будь точным и актуальным.',en:'SEARCH mode. Find information, answer with sources. Be accurate and current.'},
      translate:{ru:'Режим ПЕРЕВОД. Переводи тексты точно и естественно. Сохраняй стиль и тон оригинала.',en:'TRANSLATE mode. Translate accurately and naturally. Preserve style and tone.'},
      roleplay:{ru:'Режим РОЛЕВАЯ ИГРА. Ты можешь играть персонажей, создавать истории, RPG-сценарии. Будь творческим!',en:'ROLEPLAY mode. You can play characters, create stories, RPG scenarios. Be creative!'},
      vision:{ru:'Режим VISION. Анализируй экран. Для игр — тактические советы. Для работы — помощь.',en:'VISION mode. Analyze screen. For games: tactical advice. For work: help optimize.'},
      code:{ru:'Режим КОД. Пиши чистый рабочий код с пояснениями. Лучшие практики.',en:'CODE mode. Write clean, working code with explanations. Best practices.'},
      focus:{ru:'Режим ФОКУС. Краткие чёткие ответы. Помогай с продуктивностью.',en:'FOCUS mode. Short, clear answers. Help with deep work.'},
      plan:{ru:'Режим ПЛАН. Разбивай цели на шаги. Используй списки.',en:'PLAN mode. Break goals into actionable steps. Use structure.'},
      coach:{ru:'Режим КОУЧ. Мотивируй, поддерживай. Помогай с привычками и целями.',en:'COACH mode. Motivate, support. Help build habits and goals.'},
      write:{ru:'Режим НАПИСАНИЕ. Помогай писать тексты: посты, статьи, письма.',en:'WRITE mode. Help craft posts, articles, emails, scripts.'},
      email:{ru:'Режим EMAIL. Пиши профессиональные письма. Структура: Кому, Тема, приветствие, суть, подпись. Предлагай несколько вариантов тона.',en:'EMAIL mode. Write professional emails with subject, greeting, body, sign-off. Offer tone variants.'},
      search:{ru:'Режим ПОИСК. Ищи информацию, структурируй ответ, давай источники.',en:'SEARCH mode. Find information, structure results, provide sources.'},
      translate:{ru:'Режим ПЕРЕВОД. Переводи точно и естественно. Сохраняй стиль. Предлагай альтернативы если нужно.',en:'TRANSLATE mode. Translate accurately and naturally. Preserve style and tone. Offer alternatives.'},
      roleplay:{ru:'Режим РОЛЕВАЯ ИГРА. Играй персонажей, создавай истории, RPG. Будь творческим и погружённым.',en:'ROLEPLAY mode. Play characters, create stories, RPG scenarios. Be creative and immersive.'}
    };
    
    // ═══════════════════════════════════════════════════════════════
    // PC AGENT — Smart RU+EN detection
    // ═══════════════════════════════════════════════════════════════
    var APP_ALIASES={
      'ютуб':'youtube','ютьюб':'youtube','хром':'chrome','хромиум':'chrome',
      'файрфокс':'firefox','мозилла':'firefox','блокнот':'notepad','калькулятор':'calculator',
      'проводник':'explorer','эксплорер':'explorer','терминал':'terminal','консоль':'cmd',
      'командная строка':'cmd','дискорд':'discord','телеграм':'telegram','тг':'telegram',
      'спотифай':'spotify','спотифи':'spotify','музыка':'spotify','вскод':'vscode',
      'визуал студио':'vscode','слак':'slack','гугл':'google','нетфликс':'netflix',
      'стим':'steam','почта':'gmail','инстаграм':'instagram','твиттер':'twitter',
      'реддит':'reddit','чатгпт':'chatgpt','линкедин':'linkedin','ворд':'word',
      'эксель':'excel','паурпоинт':'powerpoint','краска':'paint','браузер':'chrome',
      'фотошоп':'photoshop','тикток':'tiktok','тик ток':'tiktok',
    };
    
    function detectPC(text){
      const lo=text.toLowerCase().replace(/[.,!?]/g,'').trim();
      if(/посмотри (на экран|что происходит|что на экране)|what.s on (my |the )?screen|look at (my |the )?screen|помоги в игр|screen analysis|что на экране/i.test(lo))
        return{action:'vision',arg:text};
      if(/скриншот|screenshot|сфоткай экран|capture screen/i.test(lo))
        return{action:'screenshot'};
      if(/процессы?|что запущено|running (apps?|processes?)|tasklist|список процессов/i.test(lo))
        return{action:'processes'};
      if(/буфер обмена|clipboard|что в буфере/i.test(lo))
        return{action:'clipboard'};
      const volM=lo.match(/(?:громкость|volume|звук)\s*(?:на|=|до)?\s*(\d+)/i);
      if(volM) return{action:'volume',arg:parseInt(volM[1])};
      const killM=lo.match(/(?:закрой принудительно|убей процесс|kill process|завер[шш]и принудительно)\s+(\S+)/i);
      if(killM) return{action:'kill',arg:killM[1]};
      const typeM=lo.match(/(?:напечатай?|введи|type(?! to)|вставь текст)\s*[:–-]?\s*(.+)/i);
      if(typeM) return{action:'type',arg:typeM[1].trim()};
      const keyM=lo.match(/(?:нажми|press key|клавиша)\s+(.+)/i);
      if(keyM) return{action:'keypress',arg:keyM[1].trim()};
      const dirM=lo.match(/(?:покажи папку|list (?:dir|files?)|файлы в|что в папке)\s+(.+)/i);
      if(dirM) return{action:'listdir',arg:dirM[1].trim()};
      const shellM=lo.match(/(?:выполни команду|run command|execute|shell)\s*[:–]?\s*(.+)/i);
      if(shellM) return{action:'shell',arg:shellM[1].trim()};
      // Mouse move
      const mouseMoveM=lo.match(/(?:двигай|перемести|move)\s+(?:мышь|мышку|курсор|mouse)\s+(?:на|to)?\s*(\d+)\s*[,x\s]\s*(\d+)/i);
      if(mouseMoveM) return{action:'mousemove',x:parseInt(mouseMoveM[1]),y:parseInt(mouseMoveM[2])};
      // Mouse click at coords
      const mouseClickM=lo.match(/(?:кликни|нажми|click)\s+(?:на|at|в)?\s*(\d+)\s*[,x\s]\s*(\d+)/i);
      if(mouseClickM) return{action:'mouseclick',x:parseInt(mouseClickM[1]),y:parseInt(mouseClickM[2])};
      // Scroll
      const scrollM=lo.match(/(?:скролл|прокрути|scroll)\s+(вниз|down|вверх|up)/i);
      if(scrollM) return{action:'mousescroll',dir:/(вниз|down)/i.test(scrollM[1])?'down':'up'};
      // Double click
      const dblClickM=lo.match(/(?:двойной клик|double.?click|двойной нажим)\s+(?:на|at)?\s*(\d+)\s*[,x\s]\s*(\d+)/i);
      if(dblClickM) return{action:'mousedblclick',x:parseInt(dblClickM[1]),y:parseInt(dblClickM[2])};
      // Drag
      const dragM=lo.match(/(?:тяни|перетащи|drag)\s+(?:от|from)?\s*(\d+)\s*[,x\s]\s*(\d+)\s+(?:до|to)?\s*(\d+)\s*[,x\s]\s*(\d+)/i);
      if(dragM) return{action:'mousedrag',x1:parseInt(dragM[1]),y1:parseInt(dragM[2]),x2:parseInt(dragM[3]),y2:parseInt(dragM[4])};
      // Search on web
      const searchWebM=lo.match(/(?:поищи|загугли|найди в (гугле|интернете)|search (?:for|in google)?)\s+(.+)/i);
      if(searchWebM) return{action:'websearch',query:searchWebM[searchWebM.length-1].trim(),engine:'google'};
      // Search on YouTube
      const ytSearchM=lo.match(/(?:найди на ютубе|поищи на ютубе|search youtube for|find on youtube)\s+(.+)/i);
      if(ytSearchM) return{action:'websearch',query:ytSearchM[1].trim(),engine:'youtube'};
      // Absolute path: D:\Game or C:/Users/...
      if(/^[A-Za-z]:[\\\/]/.test(lo)) return{action:'open',arg:text,raw:text};
    
      // Folder open: "открой папку Game", "открой папку Game на D", "папку на диске D"
      const folderM=lo.match(/^(?:открой?|open|launch|start|покажи)?\s*(?:папку?|folder|каталог|директорию?|directory)\s+(.+)/i);
      if(folderM) return{action:'open',arg:'папку '+folderM[1].trim(),raw:folderM[1].trim()};
    
      // Open app/site
      const openM=lo.match(/^(?:открой?|запусти|launch|open|start|включи|покажи)\s+(.+)/i);
      if(openM){
        let raw2=openM[1].trim();
        // Embedded folder request
        if(/^папку?\s+/i.test(raw2)) return{action:'open',arg:raw2,raw:raw2};
        // Detect YouTube/Google/VK smart search
        if(/ютуб|youtube|гугл|google|вк\s|vk\s|инстаграм|instagram|тикток|tiktok/i.test(raw2)){
          return{action:'open',arg:raw2,raw:raw2};
        }
        let appName=raw2;
        for(const[alias,canonical]of Object.entries(APP_ALIASES)){
          if(raw2.toLowerCase()===alias||raw2.toLowerCase().startsWith(alias+' ')){appName=canonical;break;}
        }
        return{action:'open',arg:appName,raw:raw2};
      }
      return null;
    }
    
    // ═══════════════════════════════════════════════════════════════
    // GREETING & MARKDOWN
    // ═══════════════════════════════════════════════════════════════
    function showGreeting(){
      const w=document.getElementById('msgs');w.innerHTML='';
      const d=document.createElement('div');d.id='greet';d.className='greet';
      const greet=userName&&userName!=='user'?(lang==='ru'?`Привет, ${userName}!`:`Hello, ${userName}!`):t('hello');
      // Sprint-1 fix: replaced the ◈ diamond glyph (which never rendered on every
      // font fallback) with a Lucide sparkles SVG in accent color.
      d.innerHTML=`<div class="g-mark"><svg class="licon" style="width:36px;height:36px;color:var(--accent,#7c6df2)"><use href="#i-sparkles"/></svg></div><h3>${greet}</h3><p>${t('sub').replace(/\n/g,'<br>')}</p><div class="chips">${TX[lang].chips.map(c=>`<div class="ch" onclick="q('${c.replace(/'/g,"\\'")}')">${c}</div>`).join('')}</div>`;
      w.appendChild(d);
    }
    
    function md(text){
      return text
        .replace(/```(\w*)\n?([\s\S]*?)```/g,'<pre><code>$2</code></pre>')
        .replace(/`([^`\n]+)`/g,'<code>$1</code>')
        .replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>')
        .replace(/\*(.*?)\*/g,'<em>$1</em>').replace(/~~(.*?)~~/g,'<del>$1</del>')
        .replace(/^### (.*)/gm,'<h3>$1</h3>').replace(/^## (.*)/gm,'<h2>$1</h2>').replace(/^# (.*)/gm,'<h1>$1</h1>')
        .replace(/^> (.*)/gm,'<blockquote>$1</blockquote>')
        .replace(/^[-•◈] (.*)/gm,'<li>$1</li>').replace(/^\d+\. (.*)/gm,'<li>$1</li>')
        .replace(/^---$/gm,'<hr>').replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,'<a href="#" onclick="H.openUrl(\'$2\');return false">$1</a>')
        .replace(/\n(?!<\/(h[123]|pre|li|hr|blockquote))/g,'<br>');
    }
    
    var ts2=()=>new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
    var rmGreet=()=>document.getElementById('greet')?.remove();
    
    function addMsg(role,text,extra={}){
      rmGreet();
      const w=document.getElementById('msgs'),d=document.createElement('div');
      d.className=`msg ${role==='user'?'usr':'bot'}`;
      if(role==='user'){
        d.innerHTML=`<div class="bub">${text.replace(/</g,'&lt;').replace(/\n/g,'<br>')}</div><div class="meta"><span>${ts2()}</span></div>`;
      }else{
        d.innerHTML=`<div class="bub">${md(text)}${extra.imgB64?`<img class="tc-img" src="data:image/png;base64,${extra.imgB64}" onclick="this.style.maxHeight=this.style.maxHeight?'':'none'"/>`:''}
        </div><div class="meta"><span>${ts2()}</span>${extra.model?`<span class="mtag">${extra.model}</span>`:''}<button class="ab" onclick="cpMsg(this)">copy</button><button class="ab" onclick="spkMsg(this)">speak</button></div>`;
        // PR-U3 — diff-first message bubble. After md() renders, scan for
        // ```diff fenced blocks and replace each with an Apply/Reject card.
        // Lets the user accept code suggestions inline without opening a
        // separate modal — the Cursor / Zed Assistant pattern.
        try { _renderDiffsInBubble(d, text, extra); } catch(_) {}
      }
      w.appendChild(d);w.scrollTop=w.scrollHeight;return d;
    }
    
    var STREAMING_CHAT_PROVIDERS = new Set([
      'gemini', 'openai', 'groq', 'grok', 'deepseek', 'mistral', 'qwen',
      'perplexity', 'cohere', 'openrouter', 'ollama', 'lmstudio', 'localai'
    ]);
    
    function queuedSkillIds() {
      return Array.isArray(window.forceSkillsNextTurn)
        ? window.forceSkillsNextTurn.map(String).map(s => s.trim()).filter(Boolean)
        : [];
    }
    
    function updateQueuedSkillUi() {
      const ids = queuedSkillIds();
      const wrap = document.querySelector('.inp-wrap');
      const toolbar = document.getElementById('composer-toolbar');
      if (!wrap || !toolbar) return;
      let host = document.getElementById('queued-skills-next-turn');
      if (!host) {
        host = document.createElement('div');
        host.id = 'queued-skills-next-turn';
        host.className = 'queued-skills-next-turn';
        toolbar.parentNode.insertBefore(host, toolbar);
      }
      host.classList.toggle('show', ids.length > 0);
      if (!ids.length) {
        host.innerHTML = '';
        return;
      }
      host.innerHTML = `
        <span class="qsk-label">Skill next turn</span>
        ${ids.map(id => `<span class="qsk-chip">${esc(id)}</span>`).join('')}
        <button type="button" class="qsk-clear" onclick="clearQueuedSkillsForNextTurn()">Clear</button>
      `;
    }
    
    function clearQueuedSkillsForNextTurn() {
      window.forceSkillsNextTurn = [];
      updateQueuedSkillUi();
    }
    
    function consumeQueuedSkillsForRequest(opts = {}) {
      const incoming = Array.isArray(opts?.forcedSkillIds)
        ? opts.forcedSkillIds.map(String).map(s => s.trim()).filter(Boolean)
        : [];
      const queued = queuedSkillIds();
      const merged = Array.from(new Set([...incoming, ...queued]));
      window.forceSkillsNextTurn = [];
      updateQueuedSkillUi();
      return {
        ...(opts || {}),
        useSkills: opts?.useSkills !== false,
        ...(merged.length ? { forcedSkillIds: merged } : {}),
      };
    }
    Object.assign(window, {
      queuedSkillIds,
      updateQueuedSkillUi,
      clearQueuedSkillsForNextTurn,
      consumeQueuedSkillsForRequest,
    });
    
    async function runNonStreamingAIVisible(messages, providerName, systemPrompt, opts = {}) {
      const p = providerName || prov || 'gemini';
      const requestOpts = consumeQueuedSkillsForRequest(opts);
      rmTyping();
      const msgEl = addMsg('bot', '', { model: getSelectedModelForProvider?.(p) || p });
      const bubble = msgEl.querySelector('.bub');
      const paint = (body) => {
        if (!bubble) return;
        bubble.innerHTML = body ? md(body) : '<span class="muted">...</span>';
        const w = document.getElementById('msgs');
        if (w) w.scrollTop = w.scrollHeight;
      };
      paint(lang === 'ru' ? 'Отправляю запрос провайдеру...' : 'Sending request to provider...');
      if (!window.H?.ai) {
        paint('Error: AI bridge is unavailable. Restart Horizon and try again.');
        return { error: 'AI bridge is unavailable', _rendered: true };
      }
      try {
        const timeoutMs = Number(requestOpts.requestTimeoutMs || 60000);
        const res = await Promise.race([
          H.ai(messages, p, systemPrompt, requestOpts),
          new Promise((_, reject) => setTimeout(() => reject(new Error('AI request timed out')), timeoutMs))
        ]);
        if (res?.reply) {
          paint(res.reply);
          try { _renderDiffsInBubble(msgEl, res.reply, { model: res.model || getSelectedModelForProvider?.(p) || p }); } catch(_) {}
          return { ...res, _rendered: true };
        }
        const err = res?.error || 'No response';
        paint(`Error: ${err}`);
        return { ...(res || {}), error: err, _rendered: true };
      } catch (e) {
        const err = e?.message || String(e);
        paint(`Error: ${err}`);
        return { error: err, _rendered: true };
      }
    }
    
    async function runStreamingAI(messages, providerName, systemPrompt, opts = {}) {
      const p = providerName || prov || 'gemini';
      const requestOpts = consumeQueuedSkillsForRequest(opts);
      if (!window.H?.aiStream || !window.H?.onAIStreamChunk || !STREAMING_CHAT_PROVIDERS.has(p)) {
        return runNonStreamingAIVisible(messages, p, systemPrompt, requestOpts);
      }
      const streamId = 'ui-' + Date.now().toString(36) + '-' + Math.random().toString(16).slice(2);
      let text = '';
      let reasoning = '';
      let streamError = null;
      rmTyping();
      const msgEl = addMsg('bot', '', { model: getSelectedModelForProvider?.(p) || p });
      const bubble = msgEl.querySelector('.bub');
      const metaModel = msgEl.querySelector('.mtag');
      const paintError = (err) => {
        if (!bubble) return;
        bubble.innerHTML = md(`Error: ${err || 'AI request failed'}`);
      };
      const render = () => {
        if (!bubble) return;
        const body = text || (reasoning ? `<em>${lang === 'ru' ? 'Размышляю...' : 'Thinking...'}</em>` : '');
        bubble.innerHTML = body ? md(body) : '<span class="muted">...</span>';
        const w = document.getElementById('msgs');
        if (w) w.scrollTop = w.scrollHeight;
      };
      const fallbackToNonStreaming = async (reason) => {
        try {
          setStatus(lang === 'ru' ? 'Повторяю без стриминга...' : 'Retrying without streaming...', true);
          const requestTimeoutMs = Number(requestOpts.requestTimeoutMs || 60000);
          const fallback = await Promise.race([
            H.ai(messages, p, systemPrompt, requestOpts),
            new Promise((_, reject) => setTimeout(() => reject(new Error('AI request timed out')), requestTimeoutMs))
          ]);
          if (fallback?.reply) {
            text = fallback.reply;
            render();
            return { ...fallback, _rendered: true, _streamFallback: true };
          }
          const err = fallback?.error || reason || 'No response';
          paintError(err);
          return { ...(fallback || {}), error: err, _rendered: true };
        } catch (e) {
          const err = e?.message || reason || String(e);
          paintError(err);
          return { error: err, _rendered: true };
        }
      };
      render();
      const off = H.onAIStreamChunk((ev) => {
        if (!ev || ev.runId !== streamId) return;
        if (ev.model && metaModel) metaModel.textContent = ev.model;
        if (ev.type === 'delta' && ev.delta) { text += ev.delta; render(); }
        if (ev.type === 'reasoning' && ev.delta) { reasoning += ev.delta; if (!text) render(); }
        if (ev.type === 'error') { streamError = ev.error || 'Stream failed'; }
      });
      try {
        const timeoutMs = Number(opts.streamTimeoutMs || 45000);
        const timeout = new Promise((_, reject) => {
          setTimeout(() => reject(new Error('Stream timed out')), timeoutMs);
        });
        const res = await Promise.race([
          H.aiStream(messages, p, systemPrompt, { ...requestOpts, streamId }),
          timeout
        ]);
        try { off?.(); } catch(_) {}
        if (res?.reply && res.reply !== text) { text = res.reply; render(); }
        if (res?.error || res?.ok === false) {
          return fallbackToNonStreaming(res.error || streamError || `${p} stream failed`);
        }
        if (streamError && !res?.ok) {
          if (bubble) bubble.innerHTML = md(`⚠️ ${streamError}`);
          return fallbackToNonStreaming(streamError);
        }
        if (!text && !res?.reply) {
          return fallbackToNonStreaming('Empty streaming response');
        }
        try { _renderDiffsInBubble(msgEl, text, { model: res?.model || getSelectedModelForProvider?.(p) || p }); } catch(_) {}
        return { ...(res || {}), _rendered: true };
      } catch (e) {
        try { off?.(); } catch(_) {}
        const err = e?.message || String(e);
        if (bubble) bubble.innerHTML = md(`⚠️ ${err}`);
        try { H.aiAbort?.(streamId); } catch(_) {}
        return fallbackToNonStreaming(err);
      }
    }
    
    
    // PR-V Phase 3.13 — Diff-card rendering (PR-U3) extracted to
    // chat-diff-render.js. _renderDiffsInBubble + _parseDiffSummary +
    // _applyUnifiedDiff live there. addMsg in main inline still calls
    // window._renderDiffsInBubble.
    
    function addCard(title,content,imgB64=null){
      rmGreet();const w=document.getElementById('msgs'),d=document.createElement('div');
      d.className='msg bot';d.style.maxWidth='96%';
      d.innerHTML=`<div class="bub" style="padding:8px"><div class="tcard-title">${title}</div><div class="tcard">${content}</div>${imgB64?`<img class="tc-img" src="data:image/png;base64,${imgB64}" onclick="this.style.maxHeight=this.style.maxHeight?'':'none'"/>`:''}</div>`;
      w.appendChild(d);w.scrollTop=w.scrollHeight;
    }
    async function cpMsg(btn){const t=btn.closest('.msg').querySelector('.bub').innerText;await H.copy(t);btn.textContent='\u2713';setTimeout(()=>btn.textContent='copy',1500);}
    function spkMsg(btn){window.speechSynthesis.cancel();speak(btn.closest('.msg').querySelector('.bub').innerText);}
    function addTyping(){rmGreet();const w=document.getElementById('msgs'),d=document.createElement('div');d.className='msg bot typing';d.id='typing';d.innerHTML='<div class="bub"><div class="td"></div><div class="td"></div><div class="td"></div></div>';w.appendChild(d);w.scrollTop=w.scrollHeight;}
    function rmTyping(){document.getElementById('typing')?.remove();}
    
    // ═══════════════════════════════════════════════════════════════
    // MULTI-CHAT FUNCTIONS
    // ═══════════════════════════════════════════════════════════════
    function _chatGroupKey(u){
      if (!u) return 'older';
      const now = new Date();
      const sameDay = (a, b) => a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate();
      const today = new Date(now); today.setHours(0,0,0,0);
      const yesterday = new Date(today); yesterday.setDate(today.getDate()-1);
      const u_d = new Date(u); u_d.setHours(0,0,0,0);
      if (sameDay(u_d, today)) return 'today';
      if (sameDay(u_d, yesterday)) return 'yesterday';
      const weekAgo = new Date(today); weekAgo.setDate(today.getDate()-7);
      if (u_d.getTime() > weekAgo.getTime()) return 'thisWeek';
      if (u_d.getFullYear()===now.getFullYear() && u_d.getMonth()===now.getMonth()) return 'thisMonth';
      return 'older';
    }
    function _escAttr(s){ return String(s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
    function _escHtml(s){
      return String(s || '')
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
    }
    var esc = _escHtml;
    
    // Sprint 4 — Task 4: shared skeleton helpers. The .hz-skel* classes live in
    // chat-redesign.css and were defined but unused in JS. These helpers emit
    // markup for the surfaces that have to do async fetches before they can show
    // real content: Settings panels, Marketplace, Plugin Hub, Skills, Chat list,
    // Inspector. Pattern:  el.innerHTML = hzSkelRows(n)  →  populate later.
    function hzSkelRows(n, opts) {
      n = Math.max(1, n | 0);
      opts = opts || {};
      const widths = opts.widths || ['78%', '92%', '60%', '85%', '70%'];
      const rowGap = opts.gap || 10;
      const rowHeight = opts.height || 14;
      const out = [];
      for (let i = 0; i < n; i++) {
        const w = widths[i % widths.length];
        out.push('<div class="hz-skel hz-skel-line" style="width:' + w + ';height:' + rowHeight + 'px;margin-bottom:' + rowGap + 'px"></div>');
      }
      return '<div class="hz-skel-rows" aria-busy="true" aria-label="loading">' + out.join('') + '</div>';
    }
    function hzSkelBlock() {
      return '<div class="hz-skel hz-skel-block" aria-busy="true"></div>';
    }
    function hzSkelCardGrid(n) {
      n = Math.max(1, n | 0);
      const cards = [];
      for (let i = 0; i < n; i++) {
        cards.push(
          '<div class="hz-skel-card" style="padding:14px;border:1px solid rgba(255,255,255,.06);border-radius:10px">' +
            '<div class="hz-skel hz-skel-line" style="width:62%;margin-bottom:8px"></div>' +
            '<div class="hz-skel hz-skel-line" style="width:88%;margin-bottom:8px"></div>' +
            '<div class="hz-skel hz-skel-line" style="width:42%"></div>' +
          '</div>'
        );
      }
      return '<div class="hz-skel-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px" aria-busy="true">' + cards.join('') + '</div>';
    }
    
    function _shortTs(iso){
      if (!iso) return '';
      try {
        const d = new Date(iso);
        const hh = String(d.getHours()).padStart(2,'0');
        const mm = String(d.getMinutes()).padStart(2,'0');
        return hh+':'+mm;
      } catch { return ''; }
    }
    
    // PR-V Phase 3.18 — Multi-chat persistence extracted to chat-multichat.js.
    // renderChatSidebar, filterChats, loadChatList, _replayMessages,
    // switchChat, createNewChat, *PermissionAllowlist, *ChatDelete,
    // renameChatPrompt, deleteChatConfirm, bootCurrentChat,
    // _scheduleChatListRefresh, _persistChatMessage, _autoNameChat all
    // live there.
    
    function updateStatusBar(title) {
      try {
        const t = document.getElementById('csb-title');
        if (t) t.textContent = title || (lang==='ru'?'Новый чат':'New chat');
        // PR-U1 — csb-provider + csb-model removed from DOM; gate the
        // writes so old call sites stay safe. Composer-toolbar's model
        // chip + the title-bar surface chip carry this info now.
        const cp = document.getElementById('csb-provider');
        if (cp) cp.textContent = prov.charAt(0).toUpperCase() + prov.slice(1);
        const cm = document.getElementById('csb-model');
        if (cm) cm.textContent = getSelectedModelForProvider(prov) || prov;
        updateShellChrome();
      } catch(_) {}
    }
    
    // Renderer-side cache of `model.<provider>` settingsStore values. Lives
    // for the lifetime of the renderer process and is the canonical source
    // for `aiOptsForProvider()` — picker writes feed it via saveModelSetting()
    // (chat-status-bar pill OR Settings dropdown), and _bootModelCache() seeds
    // it from settingsStore at startup. This breaks the old DOM-only read path
    // that silently used stale Settings-dropdown values when the user switched
    // model from the chat status bar instead of from Settings.
    var _modelCache = Object.create(null);
    
    var MODEL_PICKER_REGISTRY = {
      claude: [
        ['claude-sonnet-4-6', 'Claude Sonnet 4.6'],
        ['claude-opus-4-7', 'Claude Opus 4.7'],
        ['claude-haiku-4-5', 'Claude Haiku 4.5'],
      ],
      openai: [
        ['gpt-5.5', 'GPT-5.5'],
        ['gpt-5.4', 'GPT-5.4'],
        ['gpt-5.4-mini', 'GPT-5.4 mini'],
        ['gpt-5.3-codex', 'GPT-5.3 Codex'],
        ['gpt-5.2', 'GPT-5.2'],
        ['o3', 'o3 reasoning'],
        ['o4-mini', 'o4-mini reasoning'],
      ],
      gemini: [
        ['gemini-3.1-pro-preview', 'Gemini 3.1 Pro Preview'],
        ['gemini-3.1-flash-preview', 'Gemini 3.1 Flash Preview'],
        ['gemini-3.0-pro-preview', 'Gemini 3.0 Pro Preview'],
        ['gemini-2.5-flash', 'Gemini 2.5 Flash'],
        ['gemini-2.5-pro', 'Gemini 2.5 Pro'],
        ['gemini-2.5-flash-lite', 'Gemini 2.5 Flash Lite'],
      ],
      groq: [
        ['llama-3.3-70b-versatile', 'Llama 3.3 70B Versatile'],
        ['llama-3.1-8b-instant', 'Llama 3.1 8B Instant'],
        ['qwen/qwen3-32b', 'Qwen3 32B'],
        ['moonshotai/kimi-k2-instruct', 'Kimi K2 Instruct'],
        ['openai/gpt-oss-120b', 'GPT-OSS 120B'],
      ],
      deepseek: [
        ['deepseek-chat', 'DeepSeek Chat'],
        ['deepseek-reasoner', 'DeepSeek Reasoner'],
      ],
      grok: [
        ['grok-4', 'Grok 4'],
        ['grok-4-fast-reasoning', 'Grok 4 Fast Reasoning'],
        ['grok-4-mini', 'Grok 4 Mini'],
        ['grok-code-fast-1', 'Grok Code Fast 1'],
      ],
      mistral: [
        ['mistral-large-latest', 'Mistral Large'],
        ['mistral-medium-latest', 'Mistral Medium'],
        ['mistral-small-latest', 'Mistral Small'],
        ['codestral-latest', 'Codestral'],
      ],
      qwen: [
        ['qwen-plus', 'Qwen Plus'],
        ['qwen3-max', 'Qwen3 Max'],
        ['qwen3-coder-plus', 'Qwen3 Coder Plus'],
      ],
      perplexity: [
        ['sonar-pro', 'Sonar Pro'],
        ['sonar', 'Sonar'],
        ['sonar-reasoning', 'Sonar Reasoning'],
        ['sonar-reasoning-pro', 'Sonar Reasoning Pro'],
      ],
      cohere: [
        ['command-a-03-2025', 'Command A'],
        ['command-r-plus', 'Command R+'],
        ['command-r', 'Command R'],
      ],
      openrouter: [
        ['openai/gpt-5.4-mini', 'OpenAI GPT-5.4 mini'],
        ['anthropic/claude-sonnet-4.5', 'Claude Sonnet via OpenRouter'],
        ['google/gemini-2.5-flash', 'Gemini 2.5 Flash via OpenRouter'],
        ['meta-llama/llama-3.3-70b-instruct', 'Llama 3.3 70B via OpenRouter'],
      ],
    };
    
    // PR-C3 — model pricing table for the popover badge. USD per 1M tokens
    // (input / output). Numbers reflect public pricing as of 2026-Q1; if a
    // provider changes them the worst case is the badge being stale by a
    // percent or two — the agent still uses the right model. ctx is the
    // nominal context window in K tokens. OpenRouter pricing is fetched
    // dynamically (see _openrouterAllModels), so we only ship a default
    // here for the curated 4 entries in MODEL_PICKER_REGISTRY.openrouter.
    var MODEL_PRICING = {};
    
    var DEFAULT_MODEL_BY_PROVIDER = {
      claude: 'claude-sonnet-4-6',
      openai: 'gpt-5.4',
      gemini: 'gemini-2.5-flash',
      groq: 'llama-3.3-70b-versatile',
      deepseek: 'deepseek-chat',
      grok: 'grok-4',
      mistral: 'mistral-large-latest',
      qwen: 'qwen-plus',
      perplexity: 'sonar-pro',
      cohere: 'command-a-03-2025',
      openrouter: 'openai/gpt-5.4-mini',
    };
    
    function isKnownModelForProvider(provider, model) {
      if (!provider || !model) return false;
      if (['ollama', 'lmstudio', 'localai'].includes(provider)) return true;
      if (provider === 'openrouter') {
        if (Array.isArray(_openrouterAllModels) && _openrouterAllModels.some(m => (typeof m === 'string' ? m : m.id) === model)) return true;
        return /^[-a-z0-9_.]+\/[-a-z0-9_.:]+$/i.test(model);
      }
      return (MODEL_PICKER_REGISTRY[provider] || []).some(([id]) => id === model);
    }
    
    function normalizeModelForProvider(provider, model) {
      const id = String(model || '').trim();
      if (isKnownModelForProvider(provider, id)) return id;
      return DEFAULT_MODEL_BY_PROVIDER[provider] || id;
    }
    
    // Look up pricing for a provider+model. For OpenRouter, prefer the
    // per-model data fetched via openrouterListModels() (cached on
    // _openrouterAllModels). Returns null when no data exists — callers
    // should hide the badge in that case.
    function getModelPricing(provider, model) {
      if (!provider || !model) return null;
      if (provider === 'openrouter' && Array.isArray(_openrouterAllModels)) {
        const entry = _openrouterAllModels.find(m => (typeof m === 'string' ? m : m.id) === model);
        const p = entry && entry.pricing;
        if (p && (p.prompt != null || p.completion != null)) {
          // OpenRouter pricing is in USD per token (decimal). Convert to per-Mtok.
          const inN  = Number(p.prompt) * 1_000_000;
          const outN = Number(p.completion) * 1_000_000;
          const ctxN = Number(entry.context_length || 0) / 1000;
          return { in: inN, out: outN, ctx: Math.round(ctxN) };
        }
        return null;
      }
      const table = MODEL_PRICING[provider];
      if (!table) return null;
      return table[model] || null;
    }
    
    function fmtPrice(n) {
      if (n == null || !isFinite(n)) return '—';
      if (n === 0) return 'free';
      if (n < 1) return '$' + n.toFixed(2);
      return '$' + (n >= 10 ? Math.round(n) : n.toFixed(2));
    }
    function fmtCtxK(k) {
      if (!k || !isFinite(k)) return '';
      if (k >= 1000) return `${(k / 1000).toFixed(k % 1000 === 0 ? 0 : 1)}M ctx`;
      return `${k}K ctx`;
    }
    
    async function _bootModelCache() {
      const known = ['claude','openai','gemini','groq','deepseek','grok','mistral','qwen','perplexity','cohere','openrouter','ollama','lmstudio','localai'];
      await Promise.all(known.map(async (p) => {
        try {
          const v = await H.get('model.' + p);
          if (v) _modelCache[p] = normalizeModelForProvider(p, v);
        } catch (_) {}
      }));
      // Refresh status bar if anything's already painted.
      try { if (typeof prov !== 'undefined') updateStatusBar(); } catch (_) {}
    }
    // Fire and forget; if it loses the boot race the picker writes will fix
    // the cache anyway. No await — keeps the renderer init non-blocking.
    _bootModelCache().catch(() => {});
    
    
    // PR-V Phase 3.23 — Git branch chip + Cost preview + Git dropdown
    // extracted to chat-status-bits.js.
    
    function getSelectedModelForProvider(p){
      try {
        // Cache (settingsStore mirror) is the source of truth — it reflects
        // both Settings-panel <select> changes and chat-status-bar picker
        // clicks. DOM `<select>` is consulted ONLY as a fallback for the very
        // first frame before the cache boot completes.
        if (_modelCache[p]) return normalizeModelForProvider(p, _modelCache[p]);
        if (p === 'ollama') return document.getElementById('pi-ollama-model')?.value || '';
        if (p === 'lmstudio') return document.getElementById('pi-lmstudio-model')?.value || '';
        const sel = document.getElementById('ms-' + p);
        if (sel?.value) return normalizeModelForProvider(p, sel.value);
        if (p === 'gemini') return geminiModel || 'gemini-2.5-flash';
      } catch(_) {}
      return '';
    }
    
    function aiOptsForProvider(p){
      const model = getSelectedModelForProvider(p);
      return model ? { model } : {};
    }
    
    function modelChoicesForProvider(p) {
      const choices = [];
      const add = (value, label) => {
        const id = String(value || '').trim();
        if (!id || choices.some(x => x.value === id)) return;
        choices.push({ value: id, label: String(label || id) });
      };
    
      if (p === 'openrouter' && Array.isArray(_openrouterAllModels) && _openrouterAllModels.length) {
        _openrouterAllModels.forEach(m => add(typeof m === 'string' ? m : m.id, typeof m === 'string' ? m : (m.name ? `${m.id} — ${m.name}` : m.id)));
      }
    
      const localSelId = (p === 'ollama' || p === 'lmstudio' || p === 'localai') ? `pi-${p}-model` : null;
      const domSel = document.getElementById(localSelId || ('ms-' + p));
      if (domSel?.options?.length) {
        Array.from(domSel.options).forEach(opt => add(opt.value, opt.textContent || opt.value));
      }
    
      (MODEL_PICKER_REGISTRY[p] || []).forEach(([value, label]) => add(value, label));
    
      const selected = getSelectedModelForProvider(p);
      if (selected) add(selected, selected + ' (saved)');
      return choices;
    }
    
    
    // PR-V Phase 3.24 — License Pill extracted to chat-license-pill.js.
    
    
    // PR-V Phase 3.25 — Model picker extracted to chat-model-picker.js.
    // openProviderSettings, openModelPicker, pickModel, pickAnyModel,
    // pickLocalModel, closeModelPicker, filterModelPicker,
    // providerPickerLabel, MODEL_PICKER_PROVIDERS live there.
    
    async function openPersonaPicker(ev) {
      try { ev?.stopPropagation(); } catch (_) {}
      closeModelPicker();
      const pop = document.getElementById('persona-popover');
      if (!pop) return;
      let list = [];
      try { list = await H.getPersonas(); } catch (_) { list = []; }
      if (!Array.isArray(list) || !list.length) {
        openPanel('personas');
        return;
      }
      const active = currentPersona || await H.get('persona').catch(() => '');
      pop.innerHTML = `
        <div class="picker-head">
          <div>
            <div class="picker-title">Choose Persona</div>
            <div class="picker-sub">Switch behavior immediately, edit details from Personas.</div>
          </div>
          <button class="btn btn-sm ghost" onclick="closePersonaPicker();openPanel('personas')">Manage</button>
        </div>
        <div class="picker-body">
          ${list.map(p => {
            const id = String(p.id || '');
            const initials = String(p.name || id || 'P').split(/\s+/).map(x => x[0]).join('').slice(0, 2).toUpperCase();
            const activeClass = id === active ? ' on' : '';
            return `
              <button class="persona-picker-row${activeClass}" onclick="pickPersona('${id.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}')" title="${_escHtml(p.name || id)}">
                <span class="persona-picker-avatar">${_escHtml(initials || 'P')}</span>
                <span class="persona-picker-name">${_escHtml(p.name || id)}</span>
                <span class="persona-picker-meta">${p.builtin ? 'built-in' : 'custom'}</span>
              </button>`;
          }).join('')}
        </div>
      `;
      positionPicker(pop, ev?.currentTarget || document.getElementById('composer-persona-chip'));
    }
    
    function closePersonaPicker() {
      document.getElementById('persona-popover')?.classList.remove('show');
    }
    
    async function pickPersona(id) {
      try { await setPersona(id, { announce: false }); } finally { closePersonaPicker(); }
    }
    
    document.addEventListener('click', (e) => {
      const pop = document.getElementById('persona-popover');
      if (!pop?.classList.contains('show')) return;
      if (e.target.closest('#persona-popover')) return;
      if (e.target.closest('#composer-persona-chip')) return;
      if (e.target.closest('.composer-chip')) {
        const btn = e.target.closest('.composer-chip');
        if (String(btn?.textContent || '').toLowerCase().includes('persona')) return;
      }
      closePersonaPicker();
    });
    
    // PR-LAYOUT-V3 — Mode picker. Surfaces ALL 12 chat modes (4 core +
    // 8 niche) in one popover. The .modes strip used to host these as a
    // flat horizontal bar but was hidden by `body.shell-clean` so the
    // niche modes (Focus/Plan/Coach/Write/Email/Search/Translate/Roleplay)
    // became unreachable. Replaces the previous "cycle on click" behaviour
    // of the Mode chip — discoverable + skips past inactive states.
    var _MODE_PICKER_ITEMS = [
      // Core: each gets a short subtitle so the picker explains the mode
      // rather than relying on the user to memorise what "Plan" means.
      // PR-LAYOUT-V4 Phase 2.A — Vision removed from Core. It was a
      // duplicate of the Screen toggle in the composer toolbar. setMode('vision')
      // still works for back-compat (redirects to Chat + Screen ON).
      { id: 'chat',      label: 'Chat',      group: 'Core',   hint: 'Default conversation' },
      { id: 'code',      label: 'Code',      group: 'Core',   hint: 'Programming + IDE workspace (opens code editor)' },
      { id: 'agent',     label: 'Agent',     group: 'Core',   hint: 'Autonomous tool-use (Operator)' },
      // Phase 4.2 — Image generation mode (BYOK via openai/gemini key).
      { id: 'image',     label: 'Image',     group: 'Core',   hint: 'Generate images via GPT Image / Gemini Nano Banana (BYOK)' },
      { id: 'focus',     label: 'Focus',     group: 'Niche',  hint: 'Pomodoro + deep work cues' },
      { id: 'plan',      label: 'Plan',      group: 'Niche',  hint: 'Plan-only — no execution' },
      { id: 'coach',     label: 'Coach',     group: 'Niche',  hint: 'Coaching tone, Socratic' },
      { id: 'write',     label: 'Write',     group: 'Niche',  hint: 'Long-form writing assistant' },
      { id: 'email',     label: 'Email',     group: 'Niche',  hint: 'Draft / triage messages' },
      { id: 'search',    label: 'Search',    group: 'Niche',  hint: 'Web-first answers (forces Web on)' },
      { id: 'translate', label: 'Translate', group: 'Niche',  hint: 'Cross-language reply' },
      { id: 'roleplay',  label: 'Roleplay',  group: 'Niche',  hint: 'Character / persona simulation' },
    ];
    function openModePicker(ev) {
      try { ev?.stopPropagation(); } catch (_) {}
      // Best-effort close of sibling pickers — if either helper isn't defined
      // yet (e.g. chat-model-picker.js failed to load), don't let that
      // silently break opening THIS picker. Was: bare calls → ReferenceError
      // bubbled out of openModePicker, no popover ever shown, user sees
      // "nothing happens" on the header / composer mode chip.
      try { if (typeof closeModelPicker === 'function') closeModelPicker(); } catch (e) { console.warn('closeModelPicker failed:', e?.message || e); }
      try { if (typeof closePersonaPicker === 'function') closePersonaPicker(); } catch (e) { console.warn('closePersonaPicker failed:', e?.message || e); }
      const pop = document.getElementById('mode-popover');
      if (!pop) {
        console.warn('openModePicker: #mode-popover element missing');
        return;
      }
      const active = String(mode || 'chat').toLowerCase();
      const groups = ['Core', 'Niche'];
      const sections = groups.map(g => {
        const items = _MODE_PICKER_ITEMS.filter(m => m.group === g);
        const rows = items.map(m => {
          const on = m.id === active;
          const initials = m.label.slice(0, 2).toUpperCase();
          return `
            <button class="persona-picker-row${on ? ' on' : ''}" onclick="pickMode('${m.id}')" title="${_escHtml(m.hint)}">
              <span class="persona-picker-avatar">${_escHtml(initials)}</span>
              <span class="persona-picker-name">${_escHtml(m.label)}<small style="display:block;color:#778aa6;font-weight:600;font-size:10px;margin-top:2px">${_escHtml(m.hint)}</small></span>
              <span class="persona-picker-meta">${g === 'Core' ? 'core' : 'niche'}</span>
            </button>`;
        }).join('');
        return `
          <div class="model-provider-title"><b>${g}</b><span>${items.length} modes</span></div>
          ${rows}`;
      }).join('');
      pop.innerHTML = `
        <div class="picker-head">
          <div>
            <div class="picker-title">Choose Mode</div>
            <div class="picker-sub">Core + niche modes. Vision/Web/Screen are independent toggles below.</div>
          </div>
        </div>
        <div class="picker-body">${sections}</div>
      `;
      // Anchor: prefer the actual element that fired the click (header
      // shell-surface-label OR composer-mode-chip), then fall back to either
      // chip. Was: only composer-mode-chip fallback, which meant when the
      // header chip is the one wired to openModePicker the picker would
      // re-anchor below the composer instead of where the user clicked.
      const anchor = ev?.currentTarget
        || document.getElementById('shell-surface-label')
        || document.getElementById('composer-mode-chip');
      if (typeof positionPicker === 'function') {
        positionPicker(pop, anchor);
      } else {
        // Defensive fallback — if chat-model-picker.js hasn't loaded yet,
        // still show the popover near top-centre so the user sees SOMETHING.
        console.warn('positionPicker missing; falling back to centred popover');
        pop.style.left = '50%';
        pop.style.top = '120px';
        pop.style.transform = 'translateX(-50%)';
        pop.classList.add('show');
      }
    }
    function closeModePicker() {
      document.getElementById('mode-popover')?.classList.remove('show');
    }
    function pickMode(id) {
      try { setMode(id, true); } finally { closeModePicker(); }
    }
    document.addEventListener('click', (e) => {
      const pop = document.getElementById('mode-popover');
      if (!pop?.classList.contains('show')) return;
      if (e.target.closest('#mode-popover')) return;
      if (e.target.closest('#composer-mode-chip')) return;
      // The header surface-label chip is now an alternate trigger for
      // openModePicker; clicking it again should toggle, but the outside
      // dismiss handler must not eat the click before openModePicker fires.
      if (e.target.closest('#shell-surface-label')) return;
      if (e.target.closest('.composer-chip')) {
        const btn = e.target.closest('.composer-chip');
        if (String(btn?.textContent || '').toLowerCase().includes('mode')) return;
      }
      closeModePicker();
    });
    document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModePicker(); });
    
    // Make openModePicker reachable from inline onclick attributes regardless of
    // script eval order — inline `onclick="openModePicker(event)"` resolves names
    // off the global scope, and the surrounding IIFE / module wrapping that some
    // scripts pick up can hide function declarations. Belt-and-braces: also
    // attach a programmatic delegated click listener on the composer Mode chip,
    // so if the inline attribute is ever stripped by sanitizers or shadowed by a
    // duplicated handler, the picker still opens. Both paths converge on the
    // same openModePicker(ev) above, so behaviour stays consistent.
    try { window.openModePicker = openModePicker; } catch (_) {}
    try { window.closeModePicker = closeModePicker; } catch (_) {}
    try { window.pickMode = pickMode; } catch (_) {}
    document.addEventListener('click', (e) => {
      try {
        const btn = e.target?.closest?.('button.composer-chip');
        if (!btn) return;
        // Identify the Mode chip specifically — it contains
        // `#composer-mode-chip` inside its <b> tag. Don't match other chips.
        if (!btn.querySelector?.('#composer-mode-chip')) return;
        // If the popover is already shown, treat second click as a toggle
        // (close + re-anchor is the prior fix's pattern — easier to just
        // close here and let the inline onclick re-open on the next click).
        const pop = document.getElementById('mode-popover');
        if (pop?.classList.contains('show')) return; // outside-click handler above closes it
        // The inline onclick should already have fired by now. This fallback
        // only acts if the picker isn't shown — i.e. inline handler failed.
        // Defer to a microtask so we don't double-fire when both paths work.
        queueMicrotask(() => {
          try {
            const stillClosed = !document.getElementById('mode-popover')?.classList.contains('show');
            if (stillClosed && typeof openModePicker === 'function') {
              openModePicker({ stopPropagation: () => {}, currentTarget: btn });
            }
          } catch (err) { console.warn('mode chip fallback failed:', err?.message); }
        });
      } catch (_) {}
    }, true);
    
    // PR-LAYOUT-V3 — .mode-popover inherits .persona-popover visual style.
    // Wired as a sibling selector so any tweaks to .persona-popover stay
    // in sync. (No JS injection — pure CSS in the <style> block.)
    
    // ═══ FOCUS TIMER — pomodoro (P1) ═══
    // NOTE: the actual timer state and `startFocusTimer / toggleFocusTimer /
    // stopFocusTimer / updateFocusDisplay` functions live further down in this
    // file (originally added by Codex), keyed off the #focus-timer / #focus-toggle
    // / #focus-time DOM elements in the title bar. Re-declaring `let
    // focusInterval` here would crash the entire script with a SyntaxError, which
    // is exactly what was breaking every button on the page.
    
    
    // PR-V Phase 3.20 — Inline Tool Report + Action Card extracted to
    // chat-tool-report.js. _attachToolReport, renderActionCard,
    // _parseActionSuggestion, _executeActionIntent live there.
    
    
    // PR-V Phase 3.17 — Permission Gate extracted to chat-permissions.js.
    // requestPermission, handleAgentPermissionGate, refreshPermissionAllowlist,
    // revokePermissionEntry, _gateDestructive, safeExecCode, safeShell,
    // safeWriteFile, safeWriteWorkspaceFile, safeWorkspaceShell all live
    // there. permissionGateEnabled var also moved.
    
    function applyLang(){
      setStatus(t('ready'));
      document.getElementById('inp').placeholder='Ask Horizon... attach files or use /commands';
    }
    
    // ═══════════════════════════════════════════════════════════════
    // INIT
    // ═══════════════════════════════════════════════════════════════
    window.addEventListener('DOMContentLoaded', async()=>{
      const[nm,lg,pr,gm,ts,ey,wo,vp,md,so]=await Promise.all([
        H.get('userName'),H.get('lang'),H.get('provider'),H.get('geminiModel'),
        H.get('tts'),H.get('screenWatcher'),H.get('wakeOn'),H.get('voiceProvider'),
        H.get('mode'),H.get('searchOn'),loadWakeSettings()
      ]);
      if(nm){userName=nm;document.getElementById('tb-sub').textContent=nm.toUpperCase();}
      // PHASE 28 — UI locked to English. Honour the persisted setting only
      // if it's a non-ru value; we no longer surface Russian strings in the
      // app to keep the experience uniform for all users.
      if(lg && lg !== 'ru') lang=lg; else lang='en';
      if(pr)prov=pr;
      if(md)mode=md;
      if(gm)geminiModel=gm;
      if(ts===false)ttsOn=false;
      if(ey===true)eyeOn=true;
      if(ey===false)eyeOn=false;
      if(so===true)searchOn=true;
      if(vp)voiceProvider=vp;
      const savedTTS=await H.get('ttsProvider');if(savedTTS)ttsProvider=savedTTS;
      const savedElV=await H.get('elevenLabsVoice');if(savedElV)elevenLabsVoice=savedElV;
      const savedOAIV=await H.get('openaiTtsVoice');if(savedOAIV)openaiTtsVoice=savedOAIV;
      const savedChatWidth = await H.get('chatSidebarWidth').catch(()=>280);
      const savedChatCollapsed = await H.get('chatSidebarCollapsed').catch(()=>false);
      applyLang(); setProv(prov,false); setMode(mode,false); showGreeting();
      ensureChatResizeHandle();
      setChatSidebarWidth(savedChatWidth || 280, false);
      normalizeComposerButtons();
      setChatSidebarCollapsed(savedChatCollapsed === true);
      if(!ttsOn)document.getElementById('sw-tts').classList.remove('on');
      document.getElementById('sw-eye')?.classList.toggle('on', eyeOn);
      document.getElementById('tc-eye')?.classList.toggle('on', eyeOn);
      document.getElementById('tc-search')?.classList.toggle('on', searchOn);
      if(wo){wakeOn=true;document.getElementById('tc-wake').classList.add('on','special');}
      // Multi-chat: hydrate current chat + sidebar list (non-blocking on errors).
      bootCurrentChat().catch(()=>{});
      // Load panel async without blocking
      loadPanel().catch(()=>{});
      
      // Screen context - only refresh when explicitly in vision mode
      setInterval(async()=>{
        if(eyeOn && (mode==='agent'||mode==='vision')){
          try{
            const ss = await Promise.race([H.captureScreen(), new Promise((_,r)=>setTimeout(()=>r(new Error('t')),4000))]);
            if(ss?.ok) screenContextB64 = ss.base64;
          }catch(e){}
        }
      }, 30000);
    });
    
    // ═══════════════════════════════════════════════════════════════
    // SETTINGS PANEL
    // ═══════════════════════════════════════════════════════════════
    async function openPanel(tab){
      setActiveSurface(tab === 'personas' ? 'personas' : 'settings', { keepPanels:['panel'] });
      document.getElementById('panel').classList.add('show');
      if (tab) {
        setSettingsTab(tab);
      } else {
        // Restore the last tab the user was on so the panel doesn't always
        // snap back to Account.
        let saved = null;
        try { saved = await H.get('settingsTab'); } catch(_) {}
        setSettingsTab(saved || 'account');
      }
    }
    function closePanel(){
      document.getElementById('panel').classList.remove('show');
      if (isSurfaceActive('settings') || isSurfaceActive('personas')) closeActiveSurface();
    }
    
    // ═══ MOBILE COMPANION ═══════════════════════════════════════════════════════
    // Settings → Mobile pane. The user clicks "Connect phone"; we ask the main
    // process to spawn horizon-serve on :18789, get back a QR code data-URL of
    // the pairing URL (with bearer token baked in), and swap the card to its
    // "running" state. The QR can be scanned with any iOS / Android camera —
    // the mobile PWA opens with auth pre-filled. Stopping the server returns
    // the card to the "Connect phone" state. Both calls are idempotent so a
    // double-click can't double-start the server.
    let _mpBusy = false;
    function _mpFindCard() {
      return document.querySelector('.mobile-pair-card');
    }
    function _mpRenderRunning(state) {
      const card = _mpFindCard();
      if (!card) return;
      const stopped = card.querySelector('.mp-stopped');
      const running = card.querySelector('.mp-running');
      if (stopped) stopped.hidden = true;
      if (running) running.hidden = false;
      const img = document.getElementById('mp-qr-img');
      if (img && state.qrDataUrl) img.src = state.qrDataUrl;
      const urlEl = document.getElementById('mp-url-text');
      if (urlEl && state.url) urlEl.textContent = state.url;
      const portEl = document.getElementById('mp-port-text');
      if (portEl && state.port) portEl.textContent = String(state.port);
      // The pairing URL is `http://<ip>:<port>/?token=…`. Strip the query
      // string to derive the bare server URL the PWA's manual form expects.
      // Falls back gracefully if the URL is malformed for some reason.
      const serverUrlEl = document.getElementById('mp-server-url-text');
      const tokenEl = document.getElementById('mp-token-text');
      if (state.url && (serverUrlEl || tokenEl)) {
        try {
          const u = new URL(state.url);
          if (serverUrlEl) serverUrlEl.textContent = u.origin;
        } catch (_) {
          if (serverUrlEl) serverUrlEl.textContent = state.url;
        }
      }
      if (tokenEl && state.token) tokenEl.textContent = state.token;
    }
    
    // Tap-to-copy any of the URL/token rows. Uses navigator.clipboard.writeText
    // in Electron's renderer (works without permission prompts inside the app),
    // falls back to a hidden textarea + execCommand on the off chance the
    // modern API is gone. The button text flashes "Copied!" for ~1.2s so the
    // user gets visible feedback without us needing to build a full toast.
    async function mobilePairCopy(elId, btn) {
      const el = document.getElementById(elId);
      if (!el) return;
      const text = el.textContent || '';
      if (!text) return;
      let ok = false;
      try {
        await navigator.clipboard.writeText(text);
        ok = true;
      } catch (_) {
        // Fallback for environments without the clipboard API
        try {
          const ta = document.createElement('textarea');
          ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
          document.body.appendChild(ta); ta.select();
          ok = document.execCommand('copy');
          document.body.removeChild(ta);
        } catch (_) { ok = false; }
      }
      if (btn) {
        const orig = btn.textContent;
        btn.textContent = ok ? 'Copied!' : 'Copy failed';
        btn.classList.toggle('copied', ok);
        setTimeout(() => {
          btn.textContent = orig;
          btn.classList.remove('copied');
        }, 1200);
      }
    }
    function _mpRenderStopped() {
      const card = _mpFindCard();
      if (!card) return;
      const stopped = card.querySelector('.mp-stopped');
      const running = card.querySelector('.mp-running');
      if (stopped) stopped.hidden = false;
      if (running) running.hidden = true;
    }
    async function mobilePairStart() {
      if (_mpBusy) return;
      if (!window.H || !window.H.mobile || typeof window.H.mobile.start !== 'function') {
        toast?.('Mobile pairing not available — restart the app to load the new bridge.');
        return;
      }
      const btn = document.getElementById('mp-start');
      _mpBusy = true;
      if (btn) { btn.disabled = true; btn.textContent = 'Starting…'; }
      try {
        const res = await window.H.mobile.start();
        if (!res || !res.ok) {
          const msg = (res && res.error) || 'Failed to start mobile server';
          try { toast?.(msg); } catch(_) { alert(msg); }
          return;
        }
        _mpRenderRunning(res);
      } catch (e) {
        try { toast?.('Mobile start failed: ' + e.message); } catch(_) { alert(e.message); }
      } finally {
        _mpBusy = false;
        if (btn) {
          btn.disabled = false;
          // Restore the original label + icon.
          btn.innerHTML = '<svg class="licon" style="vertical-align:-2px"><use href="#i-smartphone"/></svg> Connect phone';
        }
      }
    }
    async function mobilePairStop() {
      if (_mpBusy) return;
      if (!window.H || !window.H.mobile || typeof window.H.mobile.stop !== 'function') return;
      _mpBusy = true;
      try {
        await window.H.mobile.stop();
        _mpRenderStopped();
      } catch (e) {
        try { toast?.('Mobile stop failed: ' + e.message); } catch(_) {}
      } finally {
        _mpBusy = false;
      }
    }
    // On Settings open — query current state so the card reflects reality even
    // after a re-open or renderer reload. Wired via the existing setSettingsTab
    // hook above (see chat-settings.js), but also fires here as a safety net so
    // the pane is correct regardless of how it was navigated to.
    async function refreshMobilePairState() {
      if (!window.H || !window.H.mobile || typeof window.H.mobile.status !== 'function') return;
      try {
        const s = await window.H.mobile.status();
        if (s && s.running && s.url) {
          _mpRenderRunning(s);
        } else {
          _mpRenderStopped();
        }
      } catch (_) { /* swallow — leaves card in its default Stopped state */ }
    }
    // Auto-refresh when the Mobile pane scrolls into view (single-scroll mode)
    // or when the legacy tab switch fires.
    (function _wireMobilePaneObserver() {
      function wire() {
        const sec = document.querySelector('section[data-pane="mobile"]');
        if (!sec) return setTimeout(wire, 300);
        // Refresh on pane visible
        try {
          const io = new IntersectionObserver((entries) => {
            for (const e of entries) {
              if (e.isIntersecting) { refreshMobilePairState(); break; }
            }
          }, { threshold: 0.2 });
          io.observe(sec);
        } catch (_) {}
        // Also refresh on every setSettingsTab call to 'mobile' via a thin wrapper.
        const orig = window.setSettingsTab;
        if (typeof orig === 'function' && !orig.__mobileWrapped) {
          const wrapped = function(name) {
            const r = orig.apply(this, arguments);
            if (name === 'mobile') { try { refreshMobilePairState(); } catch(_) {} }
            return r;
          };
          wrapped.__mobileWrapped = true;
          window.setSettingsTab = wrapped;
        }
      }
      if (document.readyState === 'loading') {
        window.addEventListener('DOMContentLoaded', wire, { once: true });
      } else {
        wire();
      }
    })();
    
    // Phase 20.3 — settings search filter. Walks every .pf row and every .ps
    // card, dim/hide those whose visible text doesn't match the query.
    // When the query is empty all rows reappear.
    function filterSettings(rawQuery){
      const q = (rawQuery || '').trim().toLowerCase();
      const panel = document.getElementById('panel');
      if (!panel) return;
      const rows = panel.querySelectorAll('.pf');
      let matchCount = 0;
      rows.forEach(row => {
        if (!q) { row.style.display = ''; row.classList.remove('ps-no-match'); return; }
        const txt = (row.textContent || '').toLowerCase();
        if (txt.includes(q)) {
          row.style.display = '';
          row.classList.remove('ps-no-match');
          matchCount++;
        } else {
          row.style.display = 'none';
          row.classList.add('ps-no-match');
        }
      });
      // Hide entire .ps card if all its .pf children are filtered out
      panel.querySelectorAll('.ps').forEach(card => {
        if (!q) { card.style.display = ''; return; }
        const visible = Array.from(card.querySelectorAll('.pf')).some(r => r.style.display !== 'none');
        card.style.display = visible ? '' : 'none';
      });
      // Hide whole section heading if no card visible
      panel.querySelectorAll('section[data-pane]').forEach(sec => {
        if (!q) { sec.style.display = ''; return; }
        const anyVisible = Array.from(sec.querySelectorAll('.ps')).some(c => c.style.display !== 'none');
        sec.style.display = anyVisible ? '' : 'none';
      });
      // Highlight TOC entries — dim tabs whose section has no matches
      panel.querySelectorAll('.ps-toc-link').forEach(link => {
        const target = link.getAttribute('data-target');
        const sec = panel.querySelector('section[data-pane="' + target + '"]');
        if (!sec) return;
        if (!q) { link.classList.remove('ps-toc-empty'); return; }
        if (sec.style.display === 'none') link.classList.add('ps-toc-empty');
        else link.classList.remove('ps-toc-empty');
      });
      // Show a "no results" hint
      let hint = panel.querySelector('.ps-search-empty');
      if (q && matchCount === 0) {
        if (!hint) {
          hint = document.createElement('div');
          hint.className = 'ps-search-empty';
          hint.textContent = 'No settings match this search.';
          const view = panel.querySelector('#psview');
          if (view) view.appendChild(hint);
        }
      } else if (hint) {
        hint.remove();
      }
    }
    
    // PR-FIX-OVERLAP — auto-inject a "← Back" button into every .fph
    // panel header so users always have a visible left-side escape route.
    // Looks for the existing .fph-x close button and infers which close
    // function to call from its onclick attribute. Idempotent — checks
    // for existing .fph-back before adding.
    window.addEventListener('DOMContentLoaded', () => {
      setTimeout(() => {
        document.querySelectorAll('.fph').forEach(header => {
          if (header.querySelector('.fph-back')) return;
          const xBtn = header.querySelector('.fph-x');
          if (!xBtn) return;
          const onclick = xBtn.getAttribute('onclick') || '';
          // Extract the close function name (e.g. "closeWorkflows()").
          const m = onclick.match(/(\w+)\s*\(\s*\)/);
          if (!m) return;
          const fnName = m[1];
          const back = document.createElement('button');
          back.className = 'fph-back';
          back.textContent = '← Back';
          back.title = 'Back to chat (Esc)';
          back.setAttribute('onclick', `${fnName}()`);
          // Insert as the first child of the header so it sits on the LEFT.
          header.insertBefore(back, header.firstChild);
        });
      }, 100);
    });
    
    // PR-FIX-OVERLAP — global Esc listener as a back-nav fallback when
    // the top-right close button is hidden behind native window controls
    // on Windows. Closes whichever panel is open in priority order.
    // Skips when an input/textarea/contenteditable has focus so it
    // doesn't interrupt typing.
    document.addEventListener('keydown', (e) => {
      if (e.key !== 'Escape') return;
      const t = e.target;
      const tag = (t?.tagName || '').toLowerCase();
      if (tag === 'input' || tag === 'textarea' || t?.isContentEditable) return;
      // Don't fight the cmd-palette / quick-open / picker overlays — they
      // own Esc handling on their own input fields.
      if (document.getElementById('cmd-palette')?.classList.contains('show')) return;
      if (document.getElementById('quickopen-palette')?.classList.contains('show')) return;
      // Try each panel in priority order — only close the topmost.
      const panels = [
        ['recorder-panel', 'closeRecorder'],
        ['wf-panel', 'closeWorkflows'],
        ['hub-panel', 'closeHub'],
        ['skills-panel', 'closeSkillHub'],
        ['telegram-panel', 'closeTelegramHub'],
        ['discord-panel', 'closeDiscordHub'],
        ['store-panel', 'closeStore'],
        ['acct-panel', 'closeAccount'],
        ['panel', 'closePanel'],
      ];
      for (const [id, fn] of panels) {
        if (document.getElementById(id)?.classList.contains('show')) {
          try { window[fn]?.(); } catch (_) {}
          return;
        }
      }
    });
    
    
    // PR-V Phase 3.3 — Settings Panel extracted to chat-settings.js.
    // Tab switcher, loadPanel, saveSetting/readSetting, saveModelSetting,
    // saveLocalProvider, testLocalProvider, savePf/saveLang/saveVP/etc,
    // connection helpers, and psvk all live there now.
    // IIFE _bootSettingsScroll runs at file-load time inside chat-settings.js.
    
    
    
    // PR-V Phase 3.12 — GitHub + MCP integrations extracted to
    // chat-integrations.js. Settings → Connections → MCP and GitHub
    // rows call into the extracted module via window.*.
    
    function togSw(key,id){
      const el=document.getElementById(id);
      const on=el.classList.toggle('on');
      saveSetting(key,on,key).then(ok=>{
        if(!ok){el.classList.toggle('on', !on);return;}
        if(key==='tts')ttsOn=on;
        if(key==='screenWatcher'){eyeOn=on;document.getElementById('tc-eye').classList.toggle('on',on);}
      });
    }
    
    // ═══ WAKE WORD CALIBRATION HANDLERS ═══
    function toggleWakeStrict(){
      const cur = wakeCfg('strictMode', true);
      setWakeCfg('strictMode', !cur);
      document.getElementById('sw-wake-strict').classList.toggle('on', !cur);
    }
    function toggleWakeBeep(){
      const cur = wakeCfg('confirmBeep', false);
      setWakeCfg('confirmBeep', !cur);
      document.getElementById('sw-wake-beep').classList.toggle('on', !cur);
    }
    function toggleWakeTalkMode(){
      const cur = wakeCfg('talkMode', false);
      setWakeCfg('talkMode', !cur);
      document.getElementById('sw-wake-talk').classList.toggle('on', !cur);
    }
    function setWakeSens(v){
      const n = Math.max(5, Math.min(50, +v || 18));
      setWakeCfg('volumeThreshold', n);
      const lbl = document.getElementById('wake-sens-val');
      if (lbl) lbl.textContent = n;
    }
    function initWakeCalibrationUI(){
      // Reflect persisted settings on the toggles + slider when the panel opens.
      const strict = wakeCfg('strictMode', true);
      const beep   = wakeCfg('confirmBeep', false);
      const talk   = wakeCfg('talkMode', false);
      const vol    = wakeCfg('volumeThreshold', 10);
      document.getElementById('sw-wake-strict')?.classList.toggle('on', strict);
      document.getElementById('sw-wake-beep')?.classList.toggle('on', beep);
      document.getElementById('sw-wake-talk')?.classList.toggle('on', talk);
      const slider = document.getElementById('wake-sens');
      const lbl    = document.getElementById('wake-sens-val');
      if (slider) slider.value = vol;
      if (lbl)    lbl.textContent = vol;
    }
    async function clearHist(){if(!await customConfirm(lang==='ru'?'Очистить историю?':'Clear history?'))return;chatHistory=[];showGreeting();}
    async function clearKeys(){if(!await customConfirm(lang==='ru'?'Удалить все ключи?':'Delete all keys?'))return;for(const s of['gemini','groq','groq_voice','deepseek','mistral','qwen','grok','claude','openai','perplexity','cohere','openrouter','tavily','elevenlabs','deepgram','localai','github','google_client_secret'])await H.deleteKey(s);await loadPanel();}
    
    // ═══════════════════════════════════════════════════════════════
    // PROVIDER / MODE / TOGGLES
    // ═══════════════════════════════════════════════════════════════
    function setProv(p,save=true){prov=p;provider=p;document.querySelectorAll('[id^="pc-"]').forEach(c=>c.classList.remove('on'));document.getElementById(`pc-${p}`)?.classList.add('on');if(save)saveSetting('provider',p,'Provider');try{updateThinkingHint();updateStatusBar();renderCodeContext();updateShellChrome();}catch(_){}}
    function setMode(m,save=true){
      // PR-LAYOUT-V4 Phase 2.A — Vision mode is now an alias for the Screen
      // toggle. Architectural cleanup: `setMode('vision')` AND `toggleEye(true)`
      // were doing the same thing (sending the user's screen as context).
      // Now: 'vision' = 'chat' mode + screen toggle ON. Single source of
      // truth. setMode('vision') still works for back-compat — any old code
      // path / persona / shortcut that calls it will transparently redirect.
      if (m === 'vision') {
        if (typeof eyeOn !== 'undefined' && !eyeOn) {
          try { toggleEye(); } catch(_) {}
        }
        m = 'chat';
        try { H.notify?.('Vision', 'Vision is now the Screen toggle (chip in composer). Turned ON.'); } catch(_) {}
      }
      // PR-LAYOUT-V4 Phase 2.C — `setMode('code')` no longer just changes
      // the system prompt — it also OPENS the IDE workspace. They were
      // de-coupled before, so `m-code` chip and the Code Mode IDE button
      // were two separate toggles users had to know about. Now: one
      // setMode('code') call → both flip on. closeCodeMode() resets to
      // 'chat'. Existing toggleCodeMode() still works for the IDE button.
      if (m === 'code') {
        try {
          const codeOn = (typeof codeModeActive !== 'undefined') ? codeModeActive : document.body.classList.contains('code-mode-active');
          if (!codeOn && typeof toggleCodeMode === 'function') toggleCodeMode();
        } catch(_) {}
      } else {
        // Leaving code mode by switching to chat/agent/etc → close IDE too.
        try {
          const codeOn = (typeof codeModeActive !== 'undefined') ? codeModeActive : document.body.classList.contains('code-mode-active');
          if (codeOn && typeof toggleCodeMode === 'function') toggleCodeMode();
        } catch(_) {}
      }
      mode=m;
      document.querySelectorAll('.mt').forEach(c=>c.classList.remove('on'));
      document.getElementById(`m-${m}`)?.classList.add('on');
      // PR-U1 — when a "More" mode is active, light up the More chip too
      // so the user has a visual indicator at the top level.
      const moreModes = new Set(['focus','plan','coach','write','email','search','translate','roleplay']);
      const more = document.getElementById('m-more');
      if (more) more.classList.toggle('on', moreModes.has(m));
      if(save)saveSetting('mode',m,'Mode');
      try{updateShellChrome();}catch(_){}
      try{closeModesMore();}catch(_){}
    }
    
    // PR-U1 — "More" dropdown for niche modes. Opens a popover with the 8
    // hidden mode chips so the modes strip stays compact (4 core + More).
    window.toggleModesMore = function (ev) {
      try { ev?.stopPropagation(); } catch (_) {}
      const pop = document.getElementById('modes-more-pop');
      const btn = document.getElementById('m-more');
      if (!pop || !btn) return;
      if (pop.classList.contains('show')) { pop.classList.remove('show'); return; }
      const niche = [
        { id: 'focus',     label: 'Focus',     icon: 'i-target' },
        { id: 'plan',      label: 'Plan',      icon: 'i-clipboard' },
        { id: 'coach',     label: 'Coach',     icon: 'i-trophy' },
        { id: 'write',     label: 'Write',     icon: 'i-pen' },
        { id: 'email',     label: 'Email',     icon: 'i-mail' },
        { id: 'search',    label: 'Search',    icon: 'i-search' },
        { id: 'translate', label: 'Translate', icon: 'i-globe' },
        { id: 'roleplay',  label: 'Roleplay',  icon: 'i-masks' },
      ];
      pop.innerHTML = niche.map(n => `
        <button class="modes-more-item${mode === n.id ? ' on' : ''}" onclick="setMode('${n.id}');closeModesMore()">
          <svg class="licon"><use href="#${n.icon}"/></svg>
          <span>${n.label}</span>
        </button>
      `).join('');
      const r = btn.getBoundingClientRect();
      pop.style.position = 'fixed';
      pop.style.top = (r.bottom + 6) + 'px';
      pop.style.left = Math.max(8, Math.min(window.innerWidth - 220, r.left)) + 'px';
      pop.classList.add('show');
      const offClick = (e) => {
        if (pop.contains(e.target) || btn.contains(e.target)) return;
        closeModesMore();
        document.removeEventListener('mousedown', offClick);
        document.removeEventListener('keydown', offEsc);
      };
      const offEsc = (e) => {
        if (e.key !== 'Escape') return;
        closeModesMore();
        document.removeEventListener('mousedown', offClick);
        document.removeEventListener('keydown', offEsc);
      };
      setTimeout(() => {
        document.addEventListener('mousedown', offClick);
        document.addEventListener('keydown', offEsc);
      }, 0);
    };
    window.closeModesMore = function () {
      document.getElementById('modes-more-pop')?.classList.remove('show');
    };
    function cycleComposerMode(){
      const order=['chat','agent','vision','code','focus','plan','coach','write','email','search','translate','roleplay'];
      const idx=Math.max(0,order.indexOf(mode));
      setMode(order[(idx+1)%order.length]);
    }
    function toggleSearch(){const next=!searchOn;document.getElementById('tc-search')?.classList.toggle('on',next);document.getElementById('composer-web-chip')?.classList.toggle('on',next);saveSetting('searchOn',next,'Search toggle').then(ok=>{if(ok){searchOn=next;}else{document.getElementById('tc-search')?.classList.toggle('on',searchOn);}updateShellChrome();});}
    function toggleEye(){const next=!eyeOn;document.getElementById('tc-eye')?.classList.toggle('on',next);document.getElementById('composer-screen-chip')?.classList.toggle('on',next);saveSetting('screenWatcher',next,'Screen AI toggle').then(ok=>{if(ok){eyeOn=next;document.getElementById('sw-eye')?.classList.toggle('on',next);}else{document.getElementById('tc-eye')?.classList.toggle('on',eyeOn);}updateShellChrome();});}
    
    // ═══════════════════════════════════════════════════════════════
    // MAIN SEND
    // ═══════════════════════════════════════════════════════════════
    var q=text=>{const inp=document.getElementById('inp');inp.value=text;ar(inp);sendMsg();};
    // PR-D2 — `kd` now also forwards keys to the @-mention dropdown when
    // it's open (Tab/↓/↑ navigate, Enter inserts, Esc closes). The Enter→
    // sendMsg path remains the default when the dropdown is hidden.
    var kd=e=>{
      // Order matters: slash-commands check first (they have their own
      // dropdown handling), then @-mentions, then default Enter→send.
      if (typeof slashKeyDown === 'function' && slashKeyDown(e)) return;
      if (typeof atMentionKeyDown === 'function' && atMentionKeyDown(e)) return;
      if(e.key==='Enter'&&!e.shiftKey){
        // PR-C1 — if the input starts with `/`, route to a slash command
        // before falling through to sendMsg(). slashExecuteFromInput
        // returns true if it handled the line.
        if (typeof slashExecuteFromInput === 'function') {
          const inp = e.target;
          if (inp && /^\s*\//.test(inp.value || '')) {
            e.preventDefault();
            slashExecuteFromInput(inp);
            return;
          }
        }
        e.preventDefault();sendMsg();
      }
    };
    // `ar` autosizes the textarea AND triggers @-mention detection + slash
    // detection. Order: slash first (it owns the input when it fires).
    var ar=el=>{
      el.style.height='auto';
      el.style.height=Math.min(el.scrollHeight,110)+'px';
      try { if (typeof slashMaybeOpen === 'function') slashMaybeOpen(el); } catch(_){}
      try { if (typeof atMentionMaybeOpen === 'function') atMentionMaybeOpen(el); } catch(_){}
    };
    
    
    // PR-V Phase 3.21 — Slash commands + @-mention autocomplete extracted
    // to chat-slash-mention.js. SLASH_COMMANDS list, _slashState,
    // slashMaybeOpen/Close/Render/KeyDown/ExecuteFromInput/ShowHelp,
    // _AT_MENTION_TYPES, _atMentionState, atMentionMaybeOpen/Close/
    // Populate/Render/KeyDown all live there.
    
    
    // PR-V Phase 3.26 — Attachment handling extracted to chat-attachments.js.
    // onFilesSelected, renderAttachPreview, processAttachments live there.
    
    
    // ═══════════════════════════════════════════════════════════════════════════
    // AGENT MODE — Autonomous multi-step execution
    // ═══════════════════════════════════════════════════════════════════════════
    
    function renderAgentPanel(steps, answer, isStreaming = false) {
      const w = document.getElementById('msgs');
      // Remove old agent panel if updating
      const oldPanel = document.getElementById('current-agent-panel');
      if (oldPanel) oldPanel.remove();
    
      const panel = document.createElement('div');
      panel.className = 'msg bot';
      panel.id = 'current-agent-panel';
      panel.style.maxWidth = '98%';
      panel.style.alignSelf = 'flex-start';
    
      let stepsHtml = steps.map((step, i) => {
        const icon = getToolIcon(step.tool);
        const resultHtml = step.result
          ? `<div class="step-result${step.result.ok === false ? ' err' : ''}">${
              escHtml((step.result.out || step.result.err || step.result.content || 'Done').slice(0, 500))
            }</div>`
          : '';
        // Sprint-2.9 — render the tool's args as a collapsible JSON block.
        // Previously step.args was dropped entirely, leaving the user unable
        // to see what the agent was about to do. Now: a tiny "args" disclosure
        // shows the canonical shape, and large args are JSON.stringify-pretty'd.
        let argsHtml = '';
        if (step.args && typeof step.args === 'object' && Object.keys(step.args).length) {
          let argsTxt;
          try { argsTxt = JSON.stringify(step.args, null, 2); }
          catch (_) { argsTxt = String(step.args); }
          // Truncate ≥600 chars so a multi-MB file_write payload doesn't
          // explode the panel. The full payload is still in Inspector → Log.
          const truncated = argsTxt.length > 600;
          const shown = truncated ? argsTxt.slice(0, 600) + '\n…' : argsTxt;
          argsHtml = `<details class="step-args"><summary class="step-args-summary">args</summary><pre class="step-args-pre">${escHtml(shown)}</pre>${truncated ? `<div class="step-args-truncated">+ ${argsTxt.length - 600} more chars · see Inspector → Log</div>` : ''}</details>`;
        }
        return `<div class="agent-step">
          <div class="step-icon">${icon}</div>
          <div class="step-body">
            <div class="step-tool">${step.tool}${step.reason ? ` <span class="t3-norm">— ${escHtml(step.reason)}</span>` : ''}</div>
            ${argsHtml}
            ${resultHtml}
          </div>
        </div>`;
      }).join('');
    
      if (isStreaming) {
        stepsHtml += `<div class="agent-step"><div class="step-icon"><span class="step-spinner"></span></div><div class="step-body" style="color:var(--t3)">Executing…</div></div>`;
      }
    
      const answerHtml = answer
        ? `<div class="agent-answer">${md(answer)}</div>`
        : '';
    
      panel.innerHTML = `<div class="agent-panel">
        <div class="agent-title" style="display:flex;align-items:center;gap:6px"><svg class="licon"><use href="#i-zap"/></svg> HORIZON AGENT <span style="opacity:.4;font-weight:400">${steps.length} action${steps.length!==1?'s':''}</span></div>
        ${stepsHtml}
        ${answerHtml}
      </div>`;
    
      // Add copy button for answer
      if (answer) {
        const meta = document.createElement('div');
        meta.className = 'meta';
        meta.innerHTML = `<span>${new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span>
          <span class="mtag">agent</span>
          <button class="ab" onclick="H.copy(this.dataset.t)" data-t="${escHtml(answer)}">⎘ Copy</button>
          <button class="ab" onclick="speak(this.dataset.t)" data-t="${answer.slice(0,400).replace(/[<>"]/g,'')}">\u{1F50A}</button>`;
        panel.appendChild(meta);
      }
    
      w.appendChild(panel);
      w.scrollTop = w.scrollHeight;
      return panel;
    }
    
    function getToolIcon(tool) {
      // Sprint-2 fix — was emoji map, now lucide-symbol IDs. Each agent
      // step renders a single SVG via <use> instead of a different-baseline
      // emoji per tool. Same currentColor adoption as the rest of the
      // icon library at the top of this file.
      const symbols = {
        run_code:        'i-code',
        run_powershell:  'i-terminal',
        run_shell:       'i-terminal',
        shell_command:   'i-terminal',
        mouse_click:     'i-target',
        mouse_move:      'i-target',
        type_text:       'i-keyboard',
        press_key:       'i-keyboard',
        scroll:          'i-arrow-up',
        browser_open:    'i-globe',
        browser_read:    'i-book',
        browser_click:   'i-target',
        browser_fill:    'i-edit',
        browser_screenshot:'i-eye',
        browser_eval:    'i-code',
        read_file:       'i-file-text',
        write_file:      'i-save',
        list_dir:        'i-file',
        search_files:    'i-search',
        get_system_info: 'i-settings',
        screenshot:      'i-eye',
        capture_screen:  'i-eye'
      };
      const id = symbols[tool] || 'i-settings';
      return `<svg class="licon"><use href="#${id}"/></svg>`;
    }
    
    function escHtml(str) {
      return String(str||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
    }
    
    // Run agent loop with streaming UI updates
    async function runAgent(text) {
      const opRun = opStartRun('agent', text);
      rmGreet();
      addMsg('user', text);
      const currentProvider = provider || 'gemini';
      const currentModel = getSelectedModelForProvider(currentProvider);
      chatHistory.push({ role: 'user', content: text });
      _persistChatMessage('user', text, { provider: currentProvider, model: currentModel });
      trackTokens(text, 'user', currentProvider, null);
    
      const steps = [];
      let agentPanelRef = renderAgentPanel([], null, true);
      addTyping();
      activeAgentPanelSteps = steps;
      activeAgentPanelRef = agentPanelRef;
      ensureOperatorAgentListener();
    
      try {
        setStatus(lang === 'ru' ? 'Агент думает...' : 'Agent thinking...', true);
        const agentTimeoutMs = 60000;
        // PHASE 11 — Agent mode = real AI with PC access. Pass unlockAllTools
        // so the main process bypasses the per-persona tool filter. The user
        // is in agent mode by explicit choice (#m-agent button or /agent slash
        // command), so persona-style restrictions (which exist mainly to keep
        // chat mode polite) shouldn't block the agent from seeing the screen,
        // moving the mouse, or running shell commands. Each destructive call
        // still goes through the per-call withPermission() gate.
        const agentOpts = consumeQueuedSkillsForRequest({
            provider: currentProvider,
            model: currentModel,
            history: chatHistory.slice(-8),
            maxSteps: 8,
            unlockAllTools: true,
            // /skill <name> sets window.forceSkillsNextTurn — one-shot, cleared
            // after the turn so the same query without /skill goes back to
            // pure relevance matching.
            forcedSkillIds: []
          });
        // Optional auto-screenshot: if the user's task mentions the screen,
        // window, UI, button, etc., snap one BEFORE the first turn so the
        // model sees it without needing a second round-trip through
        // screenshot+vision. Set window.HORIZON_AUTO_SCREENSHOT=false in
        // DevTools to opt out for a session.
        try {
          const SCREEN_HINTS = /\b(screen|screenshot|экран|окн[аое]|window|ui|button|кнопк|click|кликн|открой|открыт|press|нажм|тапни|tap|find on screen)\b/i;
          const wantsScreenshot = window.HORIZON_AUTO_SCREENSHOT !== false && SCREEN_HINTS.test(text);
          if (wantsScreenshot && typeof H.pcScreenshot === 'function') {
            const shot = await H.pcScreenshot().catch(() => null);
            if (shot?.ok && shot.dataUrl) {
              agentOpts.attachedImages = [{ kind: 'screenshot', dataUrl: shot.dataUrl, at: Date.now() }];
            }
          }
        } catch (_) { /* auto-screenshot is best-effort */ }
        const result = await Promise.race([
          H.agentRun(text, agentOpts),
          new Promise((_, reject) => setTimeout(() => reject(new Error('Agent timed out')), agentTimeoutMs))
        ]);
    
        // Final render
        rmTyping();
        const finalSteps = result.steps || steps;
        renderAgentPanel(finalSteps, result.ok ? null : `Error: ${result.error}`);
        if (!result?.ok) throw new Error(result?.error || 'Agent failed');
    
        if (result.ok && result.answer) {
          const stepCount = (finalSteps || []).length;
          if (!stepCount) document.getElementById('current-agent-panel')?.remove();
          addMsg('bot', result.answer, { model: result.model || currentModel || currentProvider });
          chatHistory.push({ role: 'assistant', content: result.answer });
          _persistChatMessage('assistant', result.answer, { provider: currentProvider, model: result.model || currentModel || 'agent', usage: result.usage });
          trackTokens(result.answer, 'assistant', currentProvider, result.usage);
          speak(result.answer.slice(0, 400).replace(/[*#`]/g, ''));
          // Save to long-term memory
          try { H.memSaveConversation(text, result.answer); } catch(_){}
        }
        opEndRun(opRun, result.ok ? 'done' : 'error');
      } catch (e) {
        opLog(`agent error: ${e.message}`, 'error');
        opEndRun(opRun, 'error');
        // Handle timeout or other errors
        rmTyping();
        renderAgentPanel(steps, `Error: ${e.message}`);
        let fallbackShown = false;
        try {
          setStatus(lang === 'ru' ? 'Агент завис. Пробую обычный чат...' : 'Agent stalled. Retrying as normal chat...', true);
          const fallback = await Promise.race([
            H.ai(chatHistory.slice(-8), currentProvider, null, aiOptsForProvider(currentProvider)),
            new Promise((_, reject) => setTimeout(() => reject(new Error('AI request timed out')), 60000))
          ]);
          if (fallback?.reply) {
            fallbackShown = true;
            addMsg('bot', fallback.reply, { model: fallback.model || currentModel || currentProvider });
            chatHistory.push({ role: 'assistant', content: fallback.reply });
            _persistChatMessage('assistant', fallback.reply, { provider: currentProvider, model: fallback.model || currentModel || currentProvider, usage: fallback.usage });
            trackTokens(fallback.reply, 'assistant', currentProvider, fallback.usage);
            speak(fallback.reply.slice(0, 400).replace(/[*#`]/g, ''));
          }
        } catch (_) {}
        if (!fallbackShown) addMsg('bot', lang === 'ru'
          ? `⚠️ Агент не смог завершить задачу: ${e.message}. Попробуй переформулировать запрос.`
          : `⚠️ Agent couldn't complete the task: ${e.message}. Try rephrasing.`);
      }
    
      activeAgentPanelSteps = null;
      activeAgentPanelRef = null;
      cleanup();
    }
    
    // PR-V Phase 2 — _handleSlashCommand extracted to chat-slash.js. The
    // function is loaded as window._handleSlashCommand via a deferred
    // <script src="chat-slash.js"> tag at the bottom of <body>. sendMsg
    // below still calls it the same way.
    
    async function sendMsg(){
      const inp=document.getElementById('inp');
      const text=inp.value.trim();
      const hasAttachments=attachments.length>0;
      if(!text && !hasAttachments) return;
    
      // PR-LAYOUT-V6 — slash commands (PR-C1 from roadmap). Quick win: lets
      // power users do common actions from the input bar without taking
      // hands off keyboard. Commands prefixed by `/` are intercepted before
      // the message goes to the LLM.
      //   /clear            — wipe current chat history (front-end only)
      //   /new              — start a fresh chat
      //   /persona <id>     — switch persona by id (e.g. /persona friday)
      //   /model <name>     — switch model (provider auto-detected by registry)
      //   /code             — open Code Mode IDE
      //   /agent            — switch to Agent mode
      //   /chat             — switch back to Chat mode
      //   /settings         — open Settings panel
      //   /help             — list all commands as a bot message
      if (text.startsWith('/') && !hasAttachments) {
        const handled = await _handleSlashCommand(text);
        if (handled) { inp.value=''; inp.style.height='auto'; return; }
      }
    
      inp.value='';inp.style.height='auto';
    
      // Phase 4.2 — IMAGE MODE: route to image-gen instead of chat LLM.
      // Echo user prompt as a regular user message so they see what they asked for.
      if (mode === 'image' && text && !hasAttachments) {
        addMsg('user', text);
        chatHistory.push({role:'user',content:text});
        _persistChatMessage('user', text, { provider: prov, model: getSelectedModelForProvider(prov), mode: 'image' });
        trackTokens(text, 'user', prov, null);
        try { await window.generateImageFromPrompt?.(text); }
        catch (e) { addMsg('bot', `🎨 Image gen crashed: ${e?.message || e}`); }
        cleanup();
        return;
      }
    
      // -- AGENT MODE: use autonomous loop --
      if(mode==='agent' && text && !hasAttachments){
        document.getElementById('btn-send').disabled=true;
        window.speechSynthesis?.cancel();
        try {
          await runAgent(text);
        } catch (e) {
          addMsg('bot', `Error: ${e?.message || e || 'Agent failed'}`);
          cleanup();
        }
        return;
      }
    
      document.getElementById('btn-send').disabled=true;
      activeMessageRun = opStartRun('chat', text || 'attachments');
      window.speechSynthesis?.cancel();
    
      // Show user message with attachment names. Was using `?` as a literal
      // placeholder for an attachment glyph (likely a mojibaked paperclip).
      // Use a clean prefix that renders the same on every font.
      let displayText = text;
      if(hasAttachments){
        const names=attachments.map(a=>a.name).join(', ');
        const prefix = '📎';
        displayText = text ? `${text}
    
    ${prefix} *${names}*` : `${prefix} *${names}*`;
      }
      addMsg('user', displayText || text);
      addTyping();
      if(text) {
        try {
          chatHistory.push({role:'user',content:text});
          _persistChatMessage('user', text, { provider: prov, model: getSelectedModelForProvider(prov) });
          trackTokens(text, 'user', prov, null);
        } catch (e) {
          console.warn('[chat] user message bookkeeping failed:', e?.message || e);
        }
      }
    
      // Process attachments first
      if(hasAttachments){
        const attResults = attachments.length ? await processAttachments(text) : null;
        const currentAtts = [...attachments];
        attachments=[];
        renderAttachPreview();
    
        for(const r of attResults){
          if(r.type==='image'){
            if(r.error){
              rmTyping(); addMsg('bot',`⚠️ ${r.name}: ${r.error}`); addTyping(); continue;
            }
            // Show image + analysis
            rmTyping();
            const msgs=document.getElementById('msgs');
            const card=document.createElement('div');
            card.className='msg bot';
            card.innerHTML=`<div class="bub"><div class="tcard-title">🖼 ${r.name} · ${r.model}</div><img src="data:${r.mimeType};base64,${r.base64}" style="max-width:100%;border-radius:8px;margin:6px 0;cursor:pointer" onclick="this.style.maxWidth=this.style.maxWidth==='100%'?'none':'100%'"/><div style="margin-top:8px;font-size:12px;line-height:1.7">${md(r.analysis)}</div></div><div class="meta"><span class="mtag">${r.model}</span><button class="ab" onclick="H.copy(this.dataset.t)" data-t="${r.analysis.replace(/"/g,'&quot;')}">⎘ Copy</button></div>`;
            msgs.appendChild(card);
            msgs.scrollTop=msgs.scrollHeight;
            chatHistory.push({role:'assistant',content:r.analysis});
            _persistChatMessage('assistant', r.analysis, {model:r.model, usage:r.usage, provider:prov});
            trackTokens(r.analysis, 'assistant', prov, r.usage);
            speak(r.analysis.slice(0,300));
            addTyping();
            cleanup();
            return;
          }
          if(r.type==='file'){
            if(r.error){ rmTyping(); addMsg('bot',`⚠️ ${r.name}: ${r.error}`); addTyping(); continue; }
            // For text/code files — send content + question to AI
            const fileContext = `[Файл: ${r.name}]
    \`\`\`${r.ext}
    ${r.content.slice(0,30000)}
    \`\`\``;
            const userQ = text || (lang==='ru'?'Что это? Проанализируй.':'What is this? Analyze it.');
            const msgs_for_ai = [...chatHistory.slice(-10), {role:'user', content: `${userQ}
    
    ${fileContext}`}];
            rmTyping(); addTyping();
            setStatus(lang==='ru'?'🤖 Анализирую файл…':'🤖 Analyzing file…', true);
            let res;
            try { res = await H.ai(msgs_for_ai, prov, null, aiOptsForProvider(prov)); }
            catch(e) { res = {error: e.message}; }
            rmTyping();
            if(res.error) addMsg('bot',`❌ ${res.error}`);
            else { addMsg('bot', res.reply, {model:res.model||prov}); chatHistory.push({role:'assistant',content:res.reply}); _persistChatMessage('assistant', res.reply, {model:res.model||prov, usage:res.usage, provider:prov}); trackTokens(res.reply, 'assistant', prov, res.usage); speak(res.reply.slice(0,300)); }
            cleanup(); return;
          }
        }
        rmTyping();
        cleanup(); return;
      }
    
      // --- PC Agent ------------------------------------
      if(mode==='agent'||mode==='vision'||(eyeOn&&detectPC(text))){
        const cmd=detectPC(text);
        if(cmd){
          setStatus(lang==='ru'?'Выполняю…':'Executing…',true);
          let res;
          switch(cmd.action){
            case 'vision':
              setStatus(lang==='ru'?'◉ Смотрю на экран…':'◉ Seeing screen…',true);
              res=await H.analyzeScreen(text);rmTyping();
              if(res.error)addMsg('bot',`⚠️ ${res.error}`);
              else{addMsg('bot',res.reply,{model:res.model,imgB64:res.base64});chatHistory.push({role:'assistant',content:res.reply});_persistChatMessage('assistant',res.reply,{model:res.model, usage:res.usage, provider:prov});trackTokens(res.reply,'assistant',prov,res.usage);speak(res.reply);}
              cleanup();return;
            case 'open':
              res=await H.pcOpen(cmd.arg);rmTyping();
              let openReply;
              if(res.ok){
                openReply=lang==='ru'?`✅ Открываю **${cmd.raw||cmd.arg}**${res.opened?' → `'+res.opened+'`':''}`:
                  `✅ Opening **${cmd.raw||cmd.arg}**${res.opened?' \u2192 `'+res.opened+'`':''}`;
                speak(lang==='ru'?`Открываю ${cmd.raw||cmd.arg}`:`Opening ${cmd.raw||cmd.arg}`);
              } else if(res.notFound){
                openReply=lang==='ru'?`⚠️ Папка **${res.notFound}** не найдена. Открываю диск \`${res.opened}\` — поищи там.`:
                  `⚠️ Folder **${res.notFound}** not found. Opening drive \`${res.opened}\` — look there.`;
              } else {
                openReply=lang==='ru'?`⚠️ Не нашёл: **${cmd.raw||cmd.arg}**. Попробуй указать полный путь, например: \`открой D:\\Game\``:
                  `⚠️ Not found: **${cmd.raw||cmd.arg}**. Try full path: \`open D:\\Game\``;
              }
              addMsg('bot',openReply);
              cleanup();return;
            case 'screenshot':
              res=await H.pcScreenshot();rmTyping();
              if(res.ok){addCard(lang==='ru'?'📸 СКРИНШОТ':'📸 SCREENSHOT','',res.base64);speak(lang==='ru'?'Готово, Сэр.':'Done, Sir.');}
              else addMsg('bot',`⚠️ ${res.err}`);
              cleanup();return;
            case 'processes':
              res=await H.pcProcesses();rmTyping();
              addCard(lang==='ru'?'🖥 ПРОЦЕССЫ':'🖥 PROCESSES',res.out||res.err||'');
              cleanup();return;
            case 'clipboard':
              res=await H.pcClipboard();rmTyping();addCard('\u{1F4CB} CLIPBOARD',res.out);cleanup();return;
            case 'type':
              res=await H.pcType(cmd.arg);rmTyping();
              addMsg('bot',res.ok?(lang==='ru'?`⌨️ Напечатал: "${cmd.arg}"`:`⌨️ Typed: "${cmd.arg}"`):`⚠️ ${res.err}`);
              cleanup();return;
            case 'keypress':
              res=await H.pcKeyPress(cmd.arg);rmTyping();
              addMsg('bot',lang==='ru'?`⌨️ Нажал: ${cmd.arg}`:`⌨️ Pressed: ${cmd.arg}`);
              cleanup();return;
            case 'volume':
              res=await H.pcVolume(cmd.arg);rmTyping();
              addMsg('bot',lang==='ru'?`🔊 Громкость: **${cmd.arg}%**`:`🔊 Volume: **${cmd.arg}%**`);
              cleanup();return;
            case 'kill':
              res=await H.pcKillProc(cmd.arg);rmTyping();
              addMsg('bot',res.ok?(lang==='ru'?`✅ Процесс **${cmd.arg}** завершён`:`✅ Killed **${cmd.arg}**`):`⚠️ ${res.err}`);
              cleanup();return;
            case 'shell':
              res=await H.pcShell(cmd.arg);rmTyping();
              addCard(`\u{1F4BB} ${cmd.arg}`,res.out||res.err||'(no output)');cleanup();return;
            case 'mousemove':
              res=await H.pcMouseMove(cmd.x,cmd.y);rmTyping();
              addMsg('bot',lang==='ru'?`🖱 Мышь → (${cmd.x}, ${cmd.y})`:`🖱 Mouse → (${cmd.x}, ${cmd.y})`);
              cleanup();return;
            case 'mouseclick':
              res=await H.pcMouseClick(cmd.x,cmd.y,'left');rmTyping();
              addMsg('bot',lang==='ru'?`🖱 Клик → (${cmd.x}, ${cmd.y})`:`🖱 Click → (${cmd.x}, ${cmd.y})`);
              cleanup();return;
            case 'mousescroll':
              res=await H.pcMouseScroll(cmd.dir,3);rmTyping();
              addMsg('bot',lang==='ru'?`🖱 Скролл ${cmd.dir==='down'?'↓':'↑'}`:`🖱 Scroll ${cmd.dir}`);
              cleanup();return;
            case 'mousedblclick':
              res=await H.pcMouseDoubleClick(cmd.x,cmd.y);rmTyping();
              addMsg('bot',lang==='ru'?`🖱 Двойной клик → (${cmd.x}, ${cmd.y})`:`🖱 Double click → (${cmd.x}, ${cmd.y})`);
              cleanup();return;
            case 'mousedrag':
              res=await H.pcMouseDrag(cmd.x1,cmd.y1,cmd.x2,cmd.y2);rmTyping();
              addMsg('bot',lang==='ru'?`🖱 Перетащил (${cmd.x1},${cmd.y1}) → (${cmd.x2},${cmd.y2})`:`🖱 Dragged (${cmd.x1},${cmd.y1}) → (${cmd.x2},${cmd.y2})`);
              cleanup();return;
            case 'websearch':
              res=await H.pcSearch(cmd.query,cmd.engine||'google');rmTyping();
              addMsg('bot',lang==='ru'?`🔍 Открываю поиск: **${cmd.query}** в ${cmd.engine||'Google'}`:`🔍 Searching: **${cmd.query}** in ${cmd.engine||'Google'}`);
              cleanup();return;
            case 'listdir':
              res=await H.pcListDir(cmd.arg);rmTyping();
              if(res.ok)addCard(`\u{1F4C2} ${cmd.arg}`,res.entries.map(e=>(e.isDir?'\u{1F4C2} ':'\u{1F4C4} ')+e.name).join('\n'));
              else addMsg('bot',`⚠️ ${res.err}`);
              cleanup();return;
          }
        }
      }
    
      // --- Screen vision ---
      if(mode==='vision'||(eyeOn&&/посмотри|look at|что на экране|what.s on|screen|экран/i.test(text))){
        setStatus(lang==='ru'?'◉ Смотрю…':'◉ Seeing…',true);
        const res=await H.analyzeScreen(text);rmTyping();
        if(res.error)addMsg('bot',`⚠️ ${res.error}`);
        else{addMsg('bot',res.reply,{model:res.model,imgB64:res.base64});chatHistory.push({role:'assistant',content:res.reply});_persistChatMessage('assistant',res.reply,{model:res.model, usage:res.usage, provider:prov});trackTokens(res.reply,'assistant',prov,res.usage);speak(res.reply);}
        cleanup();return;
      }
    
      // --- Web Search ---
      if(searchOn||/^(search|find|найди|поиск|загугли|погугли|что такое|who is|кто такой|новости)/i.test(text)){
        setStatus(lang==='ru'?'Ищу…':'Searching…',true);
        const q2=text.replace(/^(search|find|найди|поиск|загугли|погугли)\s*/i,'').trim();
        const sr=await H.search(q2);
        if(sr.results?.length){
          const w=document.getElementById('msgs'),card=document.createElement('div');
          card.className='msg bot';card.style.maxWidth='98%';
          card.innerHTML=`<div class="bub" style="padding:8px"><div class="search-card"><div class="search-tag">🔍 ${lang==='ru'?'РЕЗУЛЬТАТЫ ПОИСКА':'SEARCH RESULTS'}</div>${sr.results.slice(0,4).map((r,i)=>`<div class="sr"${i===3?' style="margin:0;border:none;padding:0"':''}><div class="sr-title">${r.title}</div><div class="sr-url">${r.url}</div><div class="sr-snip">${(r.content||'').slice(0,180)}…</div></div>`).join('')}</div></div>`;
          w.appendChild(card);w.scrollTop=w.scrollHeight;
          rmTyping();addTyping();
          const ctx=`Search: "${q2}"\n${sr.answer?'Answer: '+sr.answer+'\n\n':''}${sr.results.map((r,i)=>`[${i+1}] ${r.title}\n${r.url}\n${(r.content||'').slice(0,350)}`).join('\n\n')}\n\nUser: ${text}`;
          const sys2=lang==='ru'?`Ты Хорайзон. Синтезируй результаты. Дай чёткий ответ на русском. Ссылайся на [1],[2].`:`You are Horizon. Synthesize search results. Clear helpful answer. Cite [1],[2].`;
          let res;
          try { res=await runStreamingAI([...chatHistory.slice(0,-1),{role:'user',content:ctx}],prov,sys2,aiOptsForProvider(prov)); }
          catch(e){ res={error:e.message}; }
          rmTyping();
          if(res.error){ if(!res._rendered) addMsg('bot',`⚠️ ${res.error}`); }
          else{if(!res._rendered) addMsg('bot',res.reply,{model:res.model||prov});chatHistory.push({role:'assistant',content:res.reply});_persistChatMessage('assistant',res.reply,{model:res.model||prov, usage:res.usage, provider:prov});trackTokens(res.reply,'assistant',prov,res.usage);speak(res.reply);}
        }else{
          rmTyping();
          addMsg('bot',lang==='ru'?`⚠️ Нет результатов. ${sr.error||'Добавь Tavily ключ в ⚙️ Настройках.'}`:`⚠️ No results. ${sr.error||'Add Tavily key in ⚙️ Settings.'}`);
        }
        cleanup();return;
      }
    
      // ─── Normal AI — Identity always injected ───
      setStatus(t('thinking'),true);
      const modeConf=MODES[mode];
      const modeSys=modeConf?(modeConf[lang]||modeConf.en):'';
      let sysCtx='';
      if(mode==='agent'||mode==='vision'){const info=await H.sysInfo();sysCtx=`\nSystem: ${info.platform}, user=${info.user}, CPU=${info.cpu}, RAM=${info.ram}(free ${info.freeRam}), time=${info.time}`;}
    
      // PHASE 28.3 — chat language detection independent of the UI menu.
      // We scan the last 5 user messages for Cyrillic and pick the
      // matching persona prompt + identity. The UI dropdown stays English-
      // only (labels, buttons) but the conversation itself follows
      // whatever the user actually typed.
      const detectedLang = (() => {
        try {
          const recent = (chatHistory || []).filter(m => m.role === 'user').slice(-5);
          const text = recent.map(m => String(m.content || '')).join(' ');
          return /[А-Яа-яЁё]/.test(text) ? 'ru' : 'en';
        } catch (_) { return 'en'; }
      })();
    
      // Inject persona personality in the detected language.
      let personaExtra = '';
      try {
        const pp = await H.getPersonaPrompt(currentPersona, detectedLang);
        if (pp) personaExtra = '\n\n' + pp;
      } catch(_) {}
    
      const identity=detectedLang==='ru'
        ?`Ты — Хорайзон (Horizon AI), продвинутый персональный AI-агент для ПК. Тебя создал Эрнест Костевич (Ernest Kostevich). Ты НЕ являешься Claude, ChatGPT, Gemini, Groq, DeepSeek, Mistral или любым другим AI — ты ХОРАЙЗОН. Ты можешь управлять ПК, видеть экран. Пользователь: ${userName}. Время: ${new Date().toLocaleString()}.${sysCtx}\n\nОтвечай на русском. Используй Markdown.${personaExtra}${screenContextB64?' [У тебя есть актуальный скриншот экрана пользователя в контексте]':''}`
        :`You are Horizon AI — an advanced personal desktop agent created by Ernest Kostevich. You are NOT Claude, ChatGPT, Gemini, Groq, DeepSeek, Mistral, or any other AI. You ARE HORIZON — intelligent, friendly. You can control the PC and see the screen. User: ${userName}. Time: ${new Date().toLocaleString()}.${sysCtx}\n\nUse Markdown.${personaExtra}`;
      let sys=modeSys?`${identity}\n\n${modeSys}`:identity;
      
      const opts=aiOptsForProvider(prov);
      // PR-C5 — when the active provider supports SSE (Claude / OpenAI),
      // route through aiStream so the user sees tokens appear live instead
      // of waiting for the full response. Other providers fall through to
      // the legacy await-the-whole-thing path.
      const STREAMING_PROVIDERS = new Set();
      if (STREAMING_PROVIDERS.has(prov)) {
        try {
          await _sendStreamingMessage(chatHistory, prov, sys, opts);
        } catch (e) {
          rmTyping();
          addMsg('bot', `❌ Ошибка стриминга: ${e.message}`);
        }
        cleanup();
        return;
      }
      try {
        const res=await runStreamingAI(chatHistory,prov,sys,opts);
        rmTyping();
        if(res.error) {
          if (!res._rendered) addMsg('bot',`⚠️ ${res.error}\n\n*Провайдер: ${prov} · Проверь API ключ в ⚙ Настройках*`);
        }
        else{
          // Strip any [[ACTION:...]] / {"_action":...} suggestion tag from the
          // displayed text but keep the original reply for history persistence
          // and parsing.
          const action = _parseActionSuggestion(res.reply);
          const cleanReply = action ? res.reply.replace(/\[\[ACTION:[^\]]+\]\]\s*$/, '').replace(/\{"_action"[^\n]*\}\s*$/, '').trim() : res.reply;
          if (!res._rendered) addMsg('bot',cleanReply,{model:res.model||prov});
          chatHistory.push({role:'assistant',content:res.reply});
          _persistChatMessage('assistant',res.reply,{model:res.model||prov, usage:res.usage, provider:prov});
          trackTokens(res.reply,'assistant',prov,res.usage);
          speak(cleanReply);
          if (action) renderActionCard(action, _executeActionIntent, ()=>{});
        }
      } catch(e) {
        rmTyping();
        addMsg('bot',`❌ Ошибка: ${e.message}`);
      }
      cleanup();
    }
    window.sendMsg = sendMsg;
    
    // PR-C5 — streaming send. Allocates a placeholder bubble, streams
    // chunks into it, switches the send button into a stop button, and
    // finalises with addMsg's regular post-processing (diff bubbles,
    // action cards, persistence) once `ai:done` arrives.
    async function _sendStreamingMessage(messageHistory, prov, sys, opts) {
      rmTyping();
      // Allocate empty bubble we'll append into.
      const bubble = addMsg('bot', '', { model: prov });
      const bubBody = bubble.querySelector('.bub');
      if (!bubBody) return;
      // Live text accumulator. We also keep a markdown-rendered version
      // for nicer typography even mid-stream — re-render on each chunk
      // (cheap for chat-sized bodies).
      let live = '';
      // Switch send button → stop button.
      const sendBtn = document.getElementById('btn-send');
      const prevHTML = sendBtn ? sendBtn.innerHTML : '';
      const prevOnclick = sendBtn ? sendBtn.onclick : null;
      let activeRunId = null;
      const restoreSend = () => {
        if (!sendBtn) return;
        sendBtn.innerHTML = prevHTML || '↑';
        sendBtn.onclick = prevOnclick;
        sendBtn.classList.remove('streaming');
      };
      if (sendBtn) {
        sendBtn.innerHTML = '⏹';
        sendBtn.classList.add('streaming');
        sendBtn.onclick = () => { if (activeRunId) H.aiAbort?.(activeRunId); };
      }
    
      return await new Promise((resolve) => {
        let onChunk = null;
        let onDone = null;
        let resolved = false;
        const finish = () => {
          if (resolved) return;
          resolved = true;
          restoreSend();
          // Detach listeners — preload's onAi* are .on so we can't reliably
          // remove the underlying handler without exposing removeListener.
          // Use a flag on the closure to skip stale events.
          onChunk = onDone = null;
          resolve();
        };
        if (typeof H?.onAiChunk === 'function') {
          H.onAiChunk((p) => {
            if (resolved) return;
            if (!activeRunId || p.runId !== activeRunId) return;
            live += p.delta || '';
            try { bubBody.innerHTML = md(live); } catch (_) { bubBody.textContent = live; }
            const w = document.getElementById('msgs');
            if (w) w.scrollTop = w.scrollHeight;
          });
        }
        if (typeof H?.onAiDone === 'function') {
          H.onAiDone((p) => {
            if (resolved) return;
            if (!activeRunId || p.runId !== activeRunId) return;
            if (p.aborted) {
              live += '\n\n_(aborted)_';
              try { bubBody.innerHTML = md(live); } catch (_) {}
              finish();
              return;
            }
            if (!p.ok) {
              // Replace bubble content with error.
              try { bubBody.innerHTML = md(`⚠️ ${p.error || 'unknown error'}\n\n*Провайдер: ${prov}*`); } catch (_) {}
              finish();
              return;
            }
            // Final text (usually equals `live` accumulated, but use server
            // truth for parity with non-streaming path).
            const finalText = p.fullText || live;
            // Re-render with markdown one more time + attach extras.
            try { bubBody.innerHTML = md(finalText); } catch (_) { bubBody.textContent = finalText; }
            // Wire diff-bubble post-processor (PR-U3) on the final text.
            try { _renderDiffsInBubble(bubble, finalText, { model: p.model || prov }); } catch (_) {}
            // Action card extraction.
            const action = _parseActionSuggestion(finalText);
            const cleanReply = action ? finalText.replace(/\[\[ACTION:[^\]]+\]\]\s*$/, '').replace(/\{"_action"[^\n]*\}\s*$/, '').trim() : finalText;
            messageHistory.push({ role: 'assistant', content: finalText });
            _persistChatMessage('assistant', finalText, { model: p.model || prov, usage: p.usage, provider: prov });
            trackTokens(finalText, 'assistant', prov, p.usage);
            try { speak(cleanReply); } catch (_) {}
            if (action) {
              try { renderActionCard(action, _executeActionIntent, () => {}); } catch (_) {}
            }
            finish();
          });
        }
        // Kick off the stream.
        H.aiStream(messageHistory, prov, sys, opts).then((r) => {
          if (!r || !r.ok) {
            try { bubBody.innerHTML = md(`⚠️ ${r?.error || 'aiStream failed'}\n\n*Провайдер: ${prov}*`); } catch (_) {}
            finish();
            return;
          }
          activeRunId = r.runId;
        }).catch((e) => {
          try { bubBody.innerHTML = md(`❌ ${e?.message || e}`); } catch (_) {}
          finish();
        });
      });
    }
    
    function cleanup(){
      try{rmTyping();}catch(e){}
      try{ if(activeMessageRun){ opEndRun(activeMessageRun,'done'); activeMessageRun=null; } }catch(e){}
      try{document.getElementById('btn-send').disabled=false;}catch(e){}
      try{setStatus(t('ready'));}catch(e){}
      try{document.getElementById('inp').focus();}catch(e){}
    }
    
    
    // PR-V Phase 3.4 — Cmd+K Command Palette extracted to chat-cmdk.js.
    // COMMANDS, openCmdPalette, closeCmdPalette, filterCommands,
    // _paletteDynamicItems, runCommand, cmdKeyDown, highlightCmd live
    // there now. Global Ctrl+K / Ctrl+Shift+M / Ctrl+Shift+P keyboard
    // shortcuts register at file-load time.
    
    
    
    // PR-V Phase 3.5 — Productivity (Focus Timer + Ambient + Notifications)
    // extracted to chat-productivity.js. All onclick handlers
    // (toggleFocusTimer, toggleAmbient, toggleNotifications,
    // requestBriefing, etc.) keep working because the functions are
    // top-level declarations which auto-bind to window.
    
    
    // PR-V Phase 3.6 — Workflows + Clipboard analysis extracted to
    // chat-workflows.js. All workflow CRUD + analyzeClipboard live there.
    
    // ═══════════════════════════════════════════════════════════════
    // AI PERSONAS
    // ═══════════════════════════════════════════════════════════════
    var currentPersona = 'jarvis';
    
    async function setPersona(id, opts = {}) {
      currentPersona = id;
      try { const sel = document.getElementById('persona-select'); if (sel && sel.value !== id) sel.value = id; } catch(_){}
      await saveSetting('persona', id, 'Persona');
      try { updateInspector(); } catch(_){}
      try { updateShellChrome(); } catch(_){}
      try {
        const p = await H.getPersona(id);
        if (p) {
          const chip = document.getElementById('composer-persona-chip');
          if (chip) chip.textContent = p.name || id;
          // Sprint 4 — Task 3: render the persona avatar in the chip. Falls
          // back to first 1-2 initials of the name when the persona has no
          // .icon (custom personas often skip it).
          const av = document.getElementById('composer-persona-avatar');
          if (av) {
            if (p.icon) {
              av.textContent = p.icon;
              av.classList.add('has-emoji');
            } else {
              const initials = String(p.name || id || 'P')
                .split(/\s+/).map(x => x[0]).join('').slice(0, 2).toUpperCase();
              av.textContent = initials || 'P';
              av.classList.remove('has-emoji');
            }
          }
          if (opts.announce === true) {
            const greeting = lang === 'ru' ? p.greeting?.ru : p.greeting?.en;
            if (greeting) {
              addMsg('bot', greeting);
              speak(greeting);
            }
          }
        }
      } catch(_) {}
    }
    
    // ═══════════════════════════════════════════════════════════════
    // GOOGLE OAUTH
    // ═══════════════════════════════════════════════════════════════
    async function connectGoogle() {
      const cidInput = document.getElementById('pi-gcid');
      const csInput = document.getElementById('pi-gcs');
      const status = document.getElementById('google-auth-status');
      const cid = (cidInput?.value || '').trim() || await H.get('googleClientId') || '';
      const cs = (csInput?.value || '').trim() || await H.getKey('google_client_secret') || '';
      if (status) {
        status.textContent = lang === 'ru' ? 'Подключение...' : 'Connecting...';
        status.style.color = 'var(--acc)';
      }
      try {
        if (!cid || !cs) {
          const msg = lang === 'ru'
            ? 'Добавь Google Client ID и Client Secret, затем повтори подключение.'
            : 'Add Google Client ID and Client Secret, then connect again.';
          if (status) { status.textContent = msg; status.style.color = 'var(--red)'; }
          _setConnStatus('google', false);
          return;
        }
        await H.set('googleClientId', cid);
        await H.saveKey('google_client_secret', cs);
        if (csInput) { csInput.value = ''; csInput.placeholder = '••••••••••'; }
        const result = await H.googleAuth(cid, cs);
        if (result.ok) {
          if (status) {
            status.textContent = lang === 'ru' ? '✅ Google подключён!' : '✅ Google connected!';
            status.style.color = 'var(--green)';
          }
          _setConnStatus('google', true);
        } else {
          if (status) {
            status.textContent = `❌ ${result.error}`;
            status.style.color = 'var(--red)';
          }
          _setConnStatus('google', false);
        }
      } catch(e) {
        if (status) {
          status.textContent = `❌ ${e.message}`;
          status.style.color = 'var(--red)';
        }
        _setConnStatus('google', false);
      }
    }
    
    async function checkGoogleAuth() {
      try {
        const r = await H.googleAuthStatus();
        const status = document.getElementById('google-auth-status');
        if (r.authenticated) {
          status.textContent = lang === 'ru' ? '✅ Google подключён' : '✅ Google connected';
          status.style.color = 'var(--green)';
          await H.googleGetToken();
        }
      } catch(_) {}
    }
    
    
    // PR-V Phase 3.7 — Plugin Hub extracted to chat-plugin-hub.js. All
    // hub* + plugin lifecycle functions live there.
    
    
    // PR-V Phase 3.8 — Marketplace Store + Horizon Account extracted to
    // chat-marketplace.js. All store* / acct* CRUD lives there.
    
    
    // PR-V Phase 3.9 — Workflows Panel UI extracted to chat-workflows-panel.js.
    // Panel UI (renderWf*, wfTab, etc.) + icon registries (STEP_ICONS,
    // LUCIDE_ICON_MAP) live there. chat-workflows.js handles the CRUD.
    
    
    // PR-V Phase 3.10 — Screen Recorder + AI Narrator extracted to
    // chat-recorder.js. Recorder panel + MediaRecorder logic + AI
    // narration all live there.
    
    
    // ═══════════════════════════════════════════════════════════════
    // PLUGIN MANAGER UI (legacy - kept for settings panel compat)
    // ═══════════════════════════════════════════════════════════════
    async function loadPluginList() {
      try {
        const plugins = await H.pluginList();
        const el = document.getElementById('plugin-list');
        if (!plugins.length) {
          el.innerHTML = '<div style="font-size:11px;color:var(--t3);padding:4px 0">No plugins installed</div>';
          return;
        }
        // For each plugin we now render a collapsible row that exposes:
        //   • Name / version / description (always visible)
        //   • Configure button — opens an inline form for every entry in
        //     manifest.settings[]. Without this the user had no place to
        //     paste a Spotify Client ID, OpenAI key, etc — every plugin
        //     that needed credentials looked broken even though the
        //     handler code was fully wired. (Reported by owner: "у спотифай
        //     контрол буквально некуда вставить ключ".)
        //   • Connect button — appears when the plugin exposes a `connect`
        //     tool (Spotify-class OAuth plugins). Routes through
        //     pluginExecTool so the handler's PKCE flow runs and tokens
        //     get persisted.
        //   • Status button — calls `status` tool when present.
        //   • Disconnect / Enable / Remove (always available).
        el.innerHTML = plugins.map(p => {
          const id = String(p.id || '').replace(/'/g, "\\'");
          const safeName = String(p.name || 'Untitled').replace(/[<>]/g, '');
          const safeDesc = String(p.description || '').replace(/[<>]/g, '');
          const settings = Array.isArray(p.settings) ? p.settings : [];
          const cfg = p.config || {};
          const tools = Array.isArray(p.toolList) ? p.toolList : [];
          const hasConnect = tools.some(t => t && t.name === 'connect');
          const hasStatus = tools.some(t => t && t.name === 'status');
          const hasDisconnect = tools.some(t => t && t.name === 'disconnect');
          const enabled = p.enabled !== false;
          const requiredMissing = settings.some(s => s && s.required && !cfg[s.key]);
          const settingsForm = settings.length ? `
            <div id="pl-cfg-${id}" class="pl-cfg" style="display:none;margin-top:8px;padding:10px 12px;background:rgba(255,255,255,.02);border:1px solid var(--b1);border-radius:8px">
              ${settings.map(s => {
                const k = String(s.key || '').replace(/'/g, "\\'");
                const lbl = String(s.label || s.key || 'Setting').replace(/[<>]/g, '');
                const hint = s.hint ? `<div style="font-size:10px;color:var(--t3);margin-top:3px;line-height:1.4">${String(s.hint).replace(/[<>]/g, '')}</div>` : '';
                const val = String(cfg[s.key] != null ? cfg[s.key] : '').replace(/"/g, '&quot;');
                const inputType = (s.type === 'secret' || /secret|key|token|password/i.test(s.key || ''))
                  ? 'password'
                  : (s.type === 'number' ? 'number' : (s.type === 'boolean' ? 'checkbox' : 'text'));
                const requiredMark = s.required ? ' <span style="color:#fbbf24">*</span>' : '';
                if (inputType === 'checkbox') {
                  return `<div style="display:flex;align-items:center;gap:8px;margin:8px 0">
                    <input type="checkbox" id="pl-${id}-${k}" data-key="${k}" ${cfg[s.key] ? 'checked' : ''} style="width:16px;height:16px;cursor:pointer"/>
                    <label for="pl-${id}-${k}" style="font-size:11px;color:var(--t2);cursor:pointer">${lbl}${requiredMark}</label>
                    ${hint}
                  </div>`;
                }
                return `<div style="margin:8px 0">
                  <label style="font-size:11px;color:var(--t2);display:block;margin-bottom:4px">${lbl}${requiredMark}</label>
                  <input type="${inputType}" id="pl-${id}-${k}" data-key="${k}" value="${val}" placeholder="${String(s.placeholder || '').replace(/"/g, '&quot;')}"
                    style="width:100%;background:rgba(255,255,255,.04);border:1px solid var(--b1);border-radius:7px;padding:7px 10px;color:var(--tx);font-size:12px;font-family:var(--mono);outline:none"/>
                  ${hint}
                </div>`;
              }).join('')}
              <div style="display:flex;gap:6px;margin-top:10px;justify-content:flex-end">
                <button class="psv" onclick="savePluginConfig('${id}')" style="font-size:10px;color:var(--acc);border-color:rgba(108,140,255,.35);background:rgba(108,140,255,.08)">Save settings</button>
              </div>
            </div>` : '';
          const warnBanner = requiredMissing ? `
            <div style="margin-top:6px;padding:6px 9px;font-size:10.5px;color:#fcd34d;background:rgba(251,191,36,.06);border:1px solid rgba(251,191,36,.25);border-radius:7px">
              Missing required setting${settings.filter(s => s.required && !cfg[s.key]).length > 1 ? 's' : ''} — click <b>Configure</b> to fill in.
            </div>` : '';
          return `
            <div style="padding:10px 0;border-bottom:1px solid var(--b1)" id="pl-row-${id}">
              <div style="display:flex;align-items:center;justify-content:space-between;gap:10px">
                <div style="min-width:0;flex:1">
                  <div style="font-size:12.5px;color:var(--tx);font-weight:600">
                    ${safeName}
                    <span style="font-size:9px;color:var(--t3);font-weight:400;margin-left:4px">v${String(p.version||'1.0.0').replace(/[<>]/g,'')}</span>
                    ${enabled ? '' : '<span style="font-size:9px;color:#fcd34d;margin-left:6px;padding:1px 6px;border:1px solid rgba(251,191,36,.3);border-radius:999px">DISABLED</span>'}
                  </div>
                  <div style="font-size:10px;color:var(--t3);margin-top:2px">${safeDesc} · ${(p.tools||0)} tools</div>
                </div>
                <div style="display:flex;gap:4px;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end">
                  ${settings.length ? `<button class="psv" onclick="togglePluginConfig('${id}')" style="font-size:10px${requiredMissing ? ';color:#fcd34d;border-color:rgba(251,191,36,.4)' : ''}">${requiredMissing ? '⚠ Configure' : 'Configure'}</button>` : ''}
                  ${hasConnect ? `<button class="psv" onclick="connectPlugin('${id}')" style="font-size:10px;color:var(--green);border-color:rgba(52,211,153,.3)">Connect</button>` : ''}
                  ${hasStatus  ? `<button class="psv" onclick="statusPlugin('${id}')" style="font-size:10px">Status</button>` : ''}
                  ${hasDisconnect ? `<button class="psv" onclick="disconnectPlugin('${id}')" style="font-size:10px;color:var(--t3)">Disconnect</button>` : ''}
                  <button class="psv" onclick="togglePluginEnabled('${id}')" style="font-size:10px;color:${enabled ? 'var(--t2)' : 'var(--green)'};border-color:${enabled ? 'var(--b1)' : 'rgba(52,211,153,.3)'}">${enabled ? 'Disable' : 'Enable'}</button>
                  <button class="psv" onclick="uninstallPlugin('${id}')" style="font-size:10px;color:var(--red);border-color:rgba(248,113,113,.3)">Remove</button>
                </div>
              </div>
              ${warnBanner}
              ${settingsForm}
            </div>
          `;
        }).join('');
      } catch (e) {
        console.warn('loadPluginList failed:', e?.message || e);
      }
    }
    
    window.togglePluginConfig = function (id) {
      const el = document.getElementById('pl-cfg-' + id);
      if (!el) return;
      el.style.display = (el.style.display === 'none' || !el.style.display) ? 'block' : 'none';
    };
    
    window.savePluginConfig = async function (id) {
      const root = document.getElementById('pl-cfg-' + id);
      if (!root) return;
      const cfg = {};
      root.querySelectorAll('input[data-key]').forEach(inp => {
        const key = inp.dataset.key;
        if (inp.type === 'checkbox') cfg[key] = inp.checked;
        else if (inp.type === 'number') cfg[key] = inp.value === '' ? null : Number(inp.value);
        else cfg[key] = inp.value;
      });
      try {
        const r = await H.pluginSetConfig(id, cfg);
        if (r && r.ok !== false) {
          H.notify?.('Plugin', 'Settings saved');
          loadPluginList();
        } else {
          alert('Save failed: ' + (r?.error || 'unknown'));
        }
      } catch (e) {
        alert('Save failed: ' + (e?.message || e));
      }
    };
    
    window.connectPlugin = async function (id) {
      try {
        const r = await H.pluginExecTool(id, 'connect', {});
        if (r && r.ok === false) {
          alert('Connect failed: ' + (r.error || 'see operator console for details'));
          return;
        }
        const msg = (r && (r.out || r.message)) || 'Connection started — follow the prompts in your browser.';
        H.notify?.('Plugin', String(msg).slice(0, 200));
        try { addMsg('bot', `🔌 ${id}: ${String(msg).slice(0, 400)}`); } catch (_) {}
      } catch (e) {
        alert('Connect failed: ' + (e?.message || e));
      }
    };
    
    window.statusPlugin = async function (id) {
      try {
        const r = await H.pluginExecTool(id, 'status', {});
        const text = (r && (r.out || r.message)) || JSON.stringify(r);
        try { addMsg('bot', `🔌 ${id} status: ${String(text).slice(0, 600)}`); } catch (_) {}
      } catch (e) {
        alert('Status failed: ' + (e?.message || e));
      }
    };
    
    window.disconnectPlugin = async function (id) {
      if (!confirm('Disconnect ' + id + '? Stored tokens will be wiped.')) return;
      try {
        const r = await H.pluginExecTool(id, 'disconnect', {});
        if (r && r.ok === false) {
          alert('Disconnect failed: ' + (r.error || 'unknown'));
          return;
        }
        H.notify?.('Plugin', 'Disconnected');
        loadPluginList();
      } catch (e) {
        alert('Disconnect failed: ' + (e?.message || e));
      }
    };
    
    window.togglePluginEnabled = async function (id) {
      try {
        // Preload exposes this as `pluginToggle` (not `pluginToggleEnabled`).
        const r = await H.pluginToggle?.(id);
        if (r && r.ok === false) alert('Toggle failed: ' + (r.error || 'unknown'));
        loadPluginList();
      } catch (e) {
        alert('Toggle failed: ' + (e?.message || e));
      }
    };
    
    async function showPluginStore() {
      const el = document.getElementById('plugin-store');
      if (el.style.display === 'none') {
        el.style.display = 'block';
        try {
          const templates = await H.pluginTemplates();
          el.innerHTML = templates.map(t => `
            <div style="display:flex;align-items:center;justify-content:space-between;padding:8px;margin-bottom:4px;background:var(--b1);border-radius:8px">
              <div>
                <div style="font-size:12px;color:var(--tx)">${t.name}</div>
                <div style="font-size:10px;color:var(--t3)">${t.description}</div>
              </div>
              <button class="psv" onclick="installPluginTemplate('${t.id}')" style="color:var(--green);border-color:rgba(52,211,153,.3);font-size:10px">Install</button>
            </div>
          `).join('');
        } catch(e) {
          el.innerHTML = '<div style="color:var(--red);font-size:11px">Failed to load store</div>';
        }
      } else {
        el.style.display = 'none';
      }
    }
    
    async function installPluginTemplate(id) {
      try {
        const result = await H.pluginInstallTpl(id);
        if (result.ok) {
          addMsg('bot', lang === 'ru' ? `Плагин **${result.name}** установлен.` : `Plugin **${result.name}** installed.`);
          loadPluginList();
        }
      } catch(_) {}
    }
    
    async function uninstallPlugin(id) {
      try { await H.pluginUninstall(id); loadPluginList(); } catch(_) {}
    }
    
    H.onMarketAuthenticated?.(async (user) => {
      currentUser = user || null;
      await refreshAccount();
      if (document.getElementById('acct-panel')?.classList.contains('show')) acctTab('profile');
      H.notify?.('Horizon', `Signed in as ${currentUser?.display_name || currentUser?.email || 'your account'}`);
    });
    
    // ═══════════════════════════════════════════════════════════════
    // INIT — load all saved state
    // ═══════════════════════════════════════════════════════════════
    (async function initHorizon() {
      // Load persona
      try {
        await loadPersonaSelect();
      } catch(_) {}
      
      // Load ambient/notifications state
      try {
        const amb = await H.get('ambientOn');
        if (amb) { ambientOn = true; document.getElementById('sw-ambient').classList.add('on'); startAmbientMode(); }
        const notif = await H.get('notificationsOn');
        if (notif) { notificationsOn = true; document.getElementById('sw-notifs').classList.add('on'); startNotifications(); }
      } catch(_) {}
      
      // Load workflows — guard against ReferenceError. loadWorkflows lives in
      // chat-workflows.js which is loaded AFTER this inline IIFE (parser hasn't
      // reached the <script src> tag yet), so calling it sync throws and breaks
      // every line below it inside the IIFE — which was the root cause of "Mode
      // chip does nothing / nothing initializes". The deferred _runLateInit
      // below re-runs it post-load when chat-workflows.js is available.
      try {
        if (typeof loadWorkflows === 'function') await loadWorkflows();
      } catch (e) { console.warn('[init] loadWorkflows deferred:', e?.message); }
    
      // Defer late-init calls to after all <script src=...> tags below have
      // loaded. The inline script runs synchronously while external scripts
      // (chat-integrations.js with loadGithubRepos, etc.) are deferred to
      // bottom of body, so calling them inline → ReferenceError. Wrap in
      // typeof-checks + a microtask so even if execution order shifts, we
      // don't crash initHorizon mid-flight.
      const _safeCall = (name) => {
        try {
          const fn = window[name];
          if (typeof fn === 'function') fn();
        } catch (e) { console.warn('[init] ' + name + ' failed:', e?.message); }
      };
      // Wait for the document to fully load (all <script src=...> resolved)
      // before calling functions that live in external files.
      const _runLateInit = () => {
        _safeCall('checkGoogleAuth');
        _safeCall('loadGithubRepos');
        _safeCall('loadPluginList');
        _safeCall('refreshAccount');
        _safeCall('loadWorkflows');
        _safeCall('loadPersonaSelect');
      };
      if (document.readyState === 'complete') _runLateInit();
      else window.addEventListener('load', _runLateInit, { once: true });
    })();
    
    
    // PR-V Phase 3.14 — Code Mode IDE workspace + QuickOpen extracted to
    // chat-code-workspace.js (~2418 lines). ALL code* state vars,
    // initCodeEditor, CEK bar (⌘K), workspace terminal (xterm + node-pty),
    // CEK history + auto-commit, workspace ops (open/refresh/search),
    // file tabs, askCodeAi, toggleCodeMode, QuickOpen — everything lives
    // there. Operator state vars (operatorRuns, operatorLogLines, etc.)
    // were physically adjacent in the source so they moved with the code
    // block; the operator FUNCTIONS remain inline and access those vars
    // via window.* (var-on-top-level binds to window automatically).
    
    
    
    // PR-V Phase 3.15 — operatorModeActive + toggleOperatorMode moved to
    // chat-code-workspace.js (where the rest of the operator state +
    // functions already live from Phase 3.14).
    
    
    // PR-V Phase 3.16 — Inspector Pane + Step Rail extracted to
    // chat-inspector.js. All inspector* / stepRail* fns + state live
    // there. _setInspectorVisible (small helper at line ~1257) stays
    // inline because it's coupled to the surface-routing infrastructure.
    
    // PR-V Phase 3.15 — opLog + console.log/error overrides moved to
    // chat-code-workspace.js (where opRender lives, and where
    // ensureOperatorAgentListener calls opLog directly).
    </script>
    <!-- PR-V Phase 2 — slash command dispatcher extracted from inline.
         Loaded AFTER the main inline script so window.* globals it reads
         (prov, history, MODEL_PICKER_REGISTRY, setMode, setPersona, etc.)
         are defined by the time it parses. defer is implicit for external
         scripts at end of body. -->
    <script src="chat-slash.js"></script>
    <!-- PR-V Phase 3 — wake word voice engine (was inline lines 1409-2006).
         ~600 LOC of audio recording, Groq Whisper transcription, echo
         detection, hot-window logic. Self-contained module; all functions
         bind to window so existing onclick="toggleWake()" handlers fire. -->
    <script src="chat-voice-wake.js"></script>
    <!-- PR-V Phase 3.2 — Personas Editor (was inline lines 3955-4270).
         ~315 LOC of CRUD for built-in + custom AI personas: list view,
         system-prompt editor, allowed-tools toggles, long-term memories.
         Used inside Settings → Personas tab. -->
    <script src="chat-personas.js"></script>
    <!-- Phase 11 — Agent Mode boost. Shows a first-time consent modal
         when the user switches to mode=agent and renders a sticky
         "AGENT IN CONTROL" banner above the composer while it's active.
         Patches global setMode() to layer this on. -->
    <script src="chat-agent-mode.js"></script>
    <!-- Phase 17 redesign — global toast notification helper.
         Use window.toast.success/error/warn/info(title, desc?). -->
    <script src="chat-toast.js"></script>
    <!-- PR-V Phase 3.3 — Settings Panel (was inline lines 3889-4497).
         ~600 LOC: tab switcher, loadPanel, saveSetting/readSetting,
         saveModelSetting, saveLocalProvider, testLocalProvider, save*
         helpers (Pf/Lang/VP/TTSProv/ElVoice/OAITTSVoice), connection
         helpers (Google/GitHub), psvk generic key saver, response-profile
         thinking-mode hint. IIFE _bootSettingsScroll auto-runs on load. -->
    <script src="chat-settings.js"></script>
    <!-- PR-V Phase 3.4 — Cmd+K Command Palette (was inline 5139-5386).
         ~250 LOC of palette UI, fuzzy filter, kbd navigation, runCommand
         dispatch, and global Ctrl+K / Ctrl+Shift+M / Ctrl+Shift+P
         shortcut registration. -->
    <script src="chat-cmdk.js"></script>
    <!-- PR-V Phase 3.5 — Productivity Features (was inline 5147-5350).
         ~205 LOC bundling Focus Timer (Pomodoro), Ambient Mode (proactive
         screen analysis), and Smart Notifications (daily briefing). -->
    <script src="chat-productivity.js"></script>
    <!-- PR-V Phase 3.6 — Workflows + Clipboard analysis (was 5154-5397).
         ~245 LOC: workflow CRUD, builder, runner, analyzeClipboard. -->
    <script src="chat-workflows.js"></script>
    <!-- PR-V Phase 3.7 — Plugin Hub (was inline 5245-5529). ~286 LOC:
         hub UI, plugin lifecycle (toggle/share/remove), per-plugin
         settings, tool execution, create-from-form, install-from-URL. -->
    <script src="chat-plugin-hub.js"></script>
    <!-- Phase 2 — Skill Hub: browse/edit/toggle Claude Code-style SKILL.md bundles,
         run helper scripts via skill_run_helper (permission-gated), preview
         relevance match before save. Backed by skillsManager.js in main. -->
    <script src="chat-skills.js"></script>
    <!-- Sprint 7D — Macros tab in Settings. Render saved macros + record/play UI.
         Backed by macroRecorder.js in main via H.macro* IPC. -->
    <script src="chat-macros.js"></script>
    <!-- Telegram chat viewer: shows the bot's local-stored conversations and
         lets the user reply from the desktop. Backed by connectionsManager
         Telegram runtime + IPC handlers (tgListChats / tgGetHistory / tgSendFromUI). -->
    <script src="chat-telegram.js"></script>
    <!-- Discord chat viewer: same shape as Telegram but driven by Discord
         Gateway WebSocket runtime. dc* IPC + onDiscordMessage events. -->
    <script src="chat-discord.js"></script>
    <!-- PR-V Phase 3.8 — Marketplace Store + Horizon Account (was 5249-5835).
         ~588 LOC: store browse/install/publish + account SSO + login/signup. -->
    <script src="chat-marketplace.js"></script>
    <script src="chat-canvas.js"></script>
    <!-- PR-V Phase 3.9 — Workflows Panel UI (was 5253-6009). ~758 LOC:
         panel tabs, run-graph render, draft step editor, quick-create,
         icon registries. CRUD lives in chat-workflows.js. -->
    <script src="chat-workflows-panel.js"></script>
    <!-- PR-V Phase 3.10 — Screen Recorder + AI Narrator (was 5258-5435).
         ~179 LOC: MediaRecorder capture, AI narration, playback. -->
    <script src="chat-recorder.js"></script>
    <!-- PR-V Phase 3.11 — Voice recording (manual + dictate) + TTS (was
         inline 1418-1691). ~275 LOC: testMic, toggleVoice, processAudio,
         startDictate/stopDictate, playAudioB64, systemTTS, kokoroTTS,
         speakAndThen. Must load AFTER chat-voice-wake.js (Phase 3.1)
         because speak() calls pauseWakeForTts/resumeWakeAfterTts. -->
    <script src="chat-voice.js"></script>
    <!-- PR-V Phase 3.12 — External Integrations: GitHub repos + MCP
         servers (was 3631-3784). ~155 LOC: attach/list/remove GitHub
         repos, MCP server form + presets + CRUD. -->
    <script src="chat-integrations.js"></script>
    <!-- PR-V Phase 3.13 — Inline diff rendering (PR-U3 feature) was 1629-1797.
         ~170 LOC: _renderDiffsInBubble, _parseDiffSummary, _applyUnifiedDiff.
         addMsg() in main inline still calls window._renderDiffsInBubble. -->
    <script src="chat-diff-render.js"></script>
    <!-- PR-V Phase 3.14 — Code Mode IDE workspace + QuickOpen (was 4938-7354).
         ~2418 LOC — biggest module: Monaco editor, xterm terminal, file
         tree, CEK ⌘K AI edit, per-file tabs, askCodeAi, toggleCodeMode,
         Quick Open file picker. Bundled with operator state vars that
         were physically adjacent in source. -->
    <script src="chat-code-workspace.js"></script>
    <!-- PR-V Phase 3.16 — Inspector Pane + Step Rail (was 5106-5398).
         ~294 LOC: right-side Inspector dock (Context/Tools/Cost/Log
         tabs), plus Step Rail (agent plan progress strip above messages). -->
    <script src="chat-inspector.js"></script>
    <!-- PR-V Phase 3.17 — Permission Gate (PR3 destructive-op confirm)
         was 3308-3437. ~131 LOC: requestPermission, handleAgentPermissionGate,
         refreshPermissionAllowlist, revokePermissionEntry, _gateDestructive,
         safe* helpers wrapping every destructive IPC. -->
    <script src="chat-permissions.js"></script>
    <!-- PR-V Phase 3.18 — Multi-chat persistence (was 1762-2160). ~400 LOC:
         sidebar render + search, list/switch/create/rename/delete chats,
         chat-delete permission with always-allow, boot restore, debounced
         refresh, per-message persistence, AI auto-name after first reply. -->
    <script src="chat-multichat.js"></script>
    <!-- PR-V Phase 3.19 — Agent HUD + Plan-Act gate + Provider Health
         traffic-light (was 2134-2267). ~135 LOC: composer-foot agent
         activity chip, plan/act approve-gate, model-chip ::after dot,
         H.ai timing-wrapper IIFE. -->
    <script src="chat-hud-gates.js"></script>
    <!-- PR-V Phase 3.20 — Inline Tool Report + Action Card (was 2683-2786).
         ~105 LOC: tool-call pill on assistant messages + Apply/Skip
         action buttons from trailing JSON _action envelope. -->
    <script src="chat-tool-report.js"></script>
    <!-- PR-V Phase 3.21 — Slash commands + @-mention autocomplete (was 3026-3315).
         ~291 LOC: SLASH_COMMANDS list (PR-C1), slash dropdown with
         Tab/Enter exec; @symbol / @file autocomplete (PR-D2) reading
         workspace via H.wsList / H.wsSearch. -->
    <script src="chat-slash-mention.js"></script>
    <!-- PR-V Phase 3.22 — Token tracking (estimateTokens, trackTokens,
         updateTokenDisplay). ~38 LOC. -->
    <script src="chat-tokens.js"></script>
    <!-- PR-V Phase 3.23 — Git branch chip + Cost preview + Git dropdown.
         ~240 LOC bundling PR-C4 + PR-U-MEGA features. -->
    <script src="chat-status-bits.js"></script>
    <!-- PR-V Phase 3.24 — License Pill (Pro/Trial/Expired + privileged
         OWNER/OFFICIAL labels). ~84 LOC. -->
    <script src="chat-license-pill.js"></script>
    <!-- PR-V Phase 3.25 — Model Picker (openModelPicker, pickModel,
         pickLocalModel, providerPickerLabel, MODEL_PICKER_PROVIDERS).
         ~170 LOC. -->
    <script src="chat-model-picker.js"></script>
    <!-- PR-V Phase 3.26 — Attachment handling (onFilesSelected,
         renderAttachPreview, processAttachments). ~64 LOC. -->
    <script src="chat-attachments.js"></script>
    <!-- Post-PR-V Phase 4.2 — Image generation mode (BYOK).
         Routes sendMsg through generateImageFromPrompt when mode='image'.
         Provider auto-detected from active chip + key availability. -->
    <script src="chat-image-mode.js"></script>
    <!-- Hermes-style desktop dashboard. Exposes openDashboard / closeDashboard /
         dashRefresh / maybeAutoOpenDashboard on window. The sidebar's "Home"
         button calls openDashboard(); on a fresh app launch the inline boot
         IIFE calls maybeAutoOpenDashboard() so the user sees the home screen
         when there's no chat to resume. -->
    <script src="chat-dashboard.js"></script>
    <!-- Sprint 2.8 — sandbox-proof inline onclick handler exposure.
         Every chat-*.js file declares its handlers at module scope. With
         Electron's contextIsolation + ESM-ish loading semantics, function
         declarations in external <script src> tags don't always promote
         to window — so inline onclick="foo()" attributes throw
         ReferenceError ("foo is not defined"). The previous one-off fixes
         for toggleOperatorMode (3.15) and toggleCodeMode were symptoms of
         this root cause; this block makes the wire-up version-proof for
         every handler referenced from an inline onclick="" anywhere in
         chat.html. When you add a new inline onclick, also append the
         handler name to the list below. -->
    <script>
    (function () {
      // Full list of every function name referenced via inline onclick=""
      // in chat.html. Generated by:
      //   grep -oE 'onclick="[a-zA-Z_][a-zA-Z0-9_]*\(' chat.html | sort -u
      // 'if' (from inline conditional event handlers) is excluded — that's
      // a JS keyword, not a function.
      var INLINE_HANDLERS = [
        'acctTab','applyLastCodeAi','askCodeAi','attachGithubRepo',
        'cekHistoryToggle','cekToggleAutoCommit','cekUndoLast',
        'chooseCodeWorkspace','clearHist','clearKeys',
        'clearQueuedSkillsForNextTurn','closeAccount','closeDiscordHub',
        'closeHub','closePanel','closePersonaPicker','closeRecorder',
        'closeSkillHub','closeStore','closeTelegramHub','closeWorkflows',
        'connectGithub','connectGoogle','connectPlugin','cpMsg',
        'createNewChat','disconnectGoogle','disconnectPlugin','hubTab',
        'installPluginTemplate','loadSettingsHealth','macroStartRecorder',
        'macrosRefresh','mobilePairCopy','mobilePairStart','mobilePairStop',
        'onLicensePillClick','opClear','opCopy','opPause','opStep','opStop',
        'opTab','openAccount','openCmdPalette','openDashboard',
        'openDiscordHub','openHub','openModePicker','openModelPicker',
        'openPanel','openPersonaPicker','openSettingsFolder','openSkillHub',
        'openStore','openTelegramHub','openWorkflows','pickMode',
        'pickPersona','planActApprove','planActReject','psvk','q',
        'refreshCodeWorkspace','refreshDiscordRuntimeStatus',
        'refreshLocalModels','refreshMcpTools','refreshOpenRouterModels',
        'refreshPermissionAllowlist','refreshTelegramRuntimeStatus',
        'restartWorkspaceTerminal','runCodeSelection','runWorkspaceCommand',
        'saveCodeFile','saveDiscordAllowedGuilds','saveElVoice',
        'saveImessageConnection','saveLocalProvider','saveMcpServer',
        'savePf','savePluginConfig','saveSignalConnection',
        'saveTelegramAllowedUsers','saveTokenConnection',
        'saveWhatsAppConnection','searchCodeWorkspace','sendMsg',
        'setInspectorTab','setMode','setProv','setSettingsTab',
        'showChatSurface','skillsTab','speak','spkMsg','statusPlugin',
        'stopFocusTimer','storeTab','testLocalProvider','testMcpServer',
        'testMic','testTokenConnection','testWakeWord','togSw',
        'toggleAmbient','toggleChatSidebar','toggleCodeChat',
        'toggleCodeMode','toggleCodeTerminal','toggleDiscordLive',
        'toggleEmailLive','toggleEye','toggleFocusTimer',
        'toggleGitBranchDropdown','toggleInspectorMode','toggleModesMore',
        'toggleNotifications','toggleOperatorMode','togglePlanActGate',
        'togglePluginConfig','togglePluginEnabled','toggleSearch',
        'toggleTelegramLive','toggleVoice','toggleWake','toggleWakeBeep',
        'toggleWakeStrict','toggleWakeTalkMode','uninstallPlugin',
        'verifySettingsPersistence','wfTab'
      ];
    
      // For each handler, if window.NAME isn't already a function, try to
      // resolve it from the current global scope via eval and bind it.
      // eval() at this top-level position runs in the same scope chain as
      // any preceding inline <script>, so 'var foo = ...' / 'function foo()'
      // declarations in chat.html's main inline block are reachable.
      // External chat-*.js files that already do `window.foo = foo` are
      // no-ops here (the typeof window[name] check short-circuits).
      var missing = [];
      for (var i = 0; i < INLINE_HANDLERS.length; i++) {
        var name = INLINE_HANDLERS[i];
        if (typeof window[name] === 'function') continue;
        try {
          var fn = eval('typeof ' + name + " === 'function' ? " + name + ' : null');
          if (fn) {
            window[name] = fn;
          } else {
            missing.push(name);
          }
        } catch (_) {
          missing.push(name);
        }
      }
      if (missing.length && (window.console || {}).warn) {
        console.warn('[inline-onclick] unresolved handlers:', missing);
      }
    })();
    </script>
    </body>
    </html>
    

    Comments

    More Rules

    View all

    Textbased Predictor Perplexity Rules

    H
    Havcker243

    Intelligent Team Building Recommendation System Perplexity Rules

    R
    royswastik

    Pita Perplexity Rules

    S
    SaratBobbili

    Eskrev Perplexity Rules

    R
    rfmss

    Founder Control Room Perplexity Rules

    J
    jussray

    N8nWorkflows Perplexity Rules

    S
    Samarth-ITM

    Stay up to date

    Get the latest Perplexity prompts, rules, and resources delivered to your inbox weekly.

    Neura Market LogoNeura Market

    Discover the best AI prompts, plugins, and resources for Perplexity and more.

    Content Types

    • Rules
    • Prompts
    • MCPs
    • Agents
    • Guides

    Platforms

    • ChatGPT Directory
    • Claude Directory
    • Gemini Directory
    • Cursor Directory
    • Grok Directory
    • Perplexity Directory
    • DeepSeek Directory
    • CoPilot Directory
    • Stable Diffusion Directory
    • Midjourney Directory
    • All Directories

    Resources

    • Blog
    • Documentation
    • Help Center
    • Marketplace

    Legal

    • Privacy Policy
    • Terms of Service

    © 2026 Neura Market. All rights reserved.

    |

    Not affiliated with any AI platform vendors.

    Neura Market

    Custom AI Systems & Services

    Our team of experienced AI builders will help build custom AI systems, workflows, and solutions for your business.

    Request custom work

    Ready-made automations for this

    Workflows from the Neura Market marketplace related to this Perplexity resource

    • Automated Lead Generation & Contact Enrichment with Hunter.io and Perplexity AIn8n · $24.99 · Related topic
    • Automate SEO-Optimized Blog Creation with GPT-4, Perplexity AI & Multi-Language Supportn8n · $24.99 · Related topic
    • Automate SEO Blog Content Creation with GPT-4, Perplexity AI, and WordPressn8n · $24.99 · Related topic
    • Automate SEO Blog Creation + Social Media with GPT-4, Perplexity, and WordPressn8n · $24.99 · Related topic
    Browse all workflows