// src/utils/dom.ts
function escapeHtml(s) {
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function injectScopedStyle(id, css) {
  if (typeof document === "undefined") return false;
  if (document.getElementById(id)) return false;
  const style = document.createElement("style");
  style.id = id;
  style.textContent = css;
  document.head.appendChild(style);
  return true;
}

// src/ui/placement.ts
var PLACEMENT_ATTR = "data-dddk-slot";
var STYLE_ID = "dddk-placement-style";
var PLACEMENT_CSS = `
  /* Z-index ladder, low \u2192 high:
     fab(9200) < banner(9300) < dock(9400) < palette(9900) <
     modal(9700) < subtitle(9950) < indicator(9960) < toast(9999)

     Note: subtitle sits ABOVE palette / modal. The agent narrates
     into the subtitle bar while the user interacts with palette / form
     surfaces \u2014 both must stay visible. If subtitle were below the
     palette backdrop the user would lose the narration the moment the
     agent opens a palette / modal. */

  [${PLACEMENT_ATTR}="subtitle"] {
    position: fixed;
    left: 50%;
    bottom: var(--dddk-bar-bottom, 24px);
    transform: translateX(-50%);
    max-width: var(--dddk-bar-max-width, 720px);
    width: calc(100% - 48px);
    z-index: var(--dddk-z-bar, 9950);
    box-sizing: border-box;
  }

  [${PLACEMENT_ATTR}="indicator"] {
    position: fixed;
    left: 50%;
    bottom: var(--dddk-indicator-bottom, 88px);
    transform: translateX(-50%);
    z-index: calc(var(--dddk-z-bar, 9950) + 10);
    box-sizing: border-box;
  }

  [${PLACEMENT_ATTR}="palette"] {
    position: fixed;
    top: var(--dddk-palette-top, 12%);
    left: 50%;
    transform: translateX(-50%);
    width: min(var(--dddk-palette-max-width, 640px), 92vw);
    z-index: var(--dddk-z-palette, 9600);
    box-sizing: border-box;
  }

  [${PLACEMENT_ATTR}="dock"] {
    position: fixed;
    top: 0;
    right: 0;
    height: 100vh;
    width: min(var(--dddk-dock-width, 380px), 92vw);
    z-index: var(--dddk-z-dock, 9400);
    box-sizing: border-box;
  }

  [${PLACEMENT_ATTR}="modal"] {
    position: fixed;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--dddk-modal-backdrop, rgba(0, 0, 0, 0.5));
    z-index: var(--dddk-z-modal, 9700);
  }

  [${PLACEMENT_ATTR}="banner"] {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: var(--dddk-z-banner, 9300);
    box-sizing: border-box;
  }

  [${PLACEMENT_ATTR}="fab"] {
    position: fixed;
    right: var(--dddk-fab-right, 18px);
    bottom: var(--dddk-fab-bottom, 22px);
    z-index: var(--dddk-z-fab, 9200);
  }

  [${PLACEMENT_ATTR}="toast"] {
    position: fixed;
    top: var(--dddk-toast-top, 24px);
    left: 50%;
    transform: translateX(-50%);
    z-index: var(--dddk-z-toast, 9800);
    box-sizing: border-box;
  }
`;
var stylesEnsured = false;
function ensurePlacementStyles() {
  if (stylesEnsured) return;
  stylesEnsured = true;
  injectScopedStyle(STYLE_ID, PLACEMENT_CSS);
}
function applyPlacement(el, slot) {
  ensurePlacementStyles();
  el.setAttribute(PLACEMENT_ATTR, slot);
}

// src/utils/sdk-i18n.ts
var STRINGS = {
  en: {
    "agent.thinking": "Thinking\u2026",
    "agent.running": "Agent running\u2026",
    "agent.processing": "Working\u2026",
    "agent.loading": "Loading the page\u2026",
    "agent.done": "\u2713 Done",
    "agent.press_space_close": "press space to close",
    "agent.press_space_continue": "space \u2192 next \xB7 double space \u2192 exit",
    "agent.tap_to_continue": "tap \u2192 next \xB7 double-tap \u2192 exit",
    "agent.space_continue_reject": "space \u2192 next \xB7 double space \u2192 exit",
    "agent.tap_to_dismiss": "tap to dismiss",
    "agent.stop_close": "Agent stopped \u2014 say something new when you're ready",
    "agent.stop_esc": "Esc received \u2014 agent stopped",
    "agent.stop_reject": "Double-tap reject received \u2014 agent stopped",
    // Confirmation copy for destructive / requireConfirmation tool calls.
    // `{target}` / `{path}` / `{action}` placeholders interpolated at call time.
    "agent.confirm.suffix": "\u2014 press space to confirm",
    "agent.confirm.navigate.with_path": "Take you to {path}",
    "agent.confirm.navigate.no_path": "Switch page",
    "agent.confirm.click.with_target": "Click {target}",
    "agent.confirm.click.no_target": "Click",
    "agent.confirm.fill_input.with_target": "Fill {target}",
    "agent.confirm.fill_input.no_target": "Fill field",
    "agent.confirm.delete.with_target": "Delete {target}",
    "agent.confirm.delete.no_target": "Delete",
    "agent.confirm.generic.with_target": "Run {action} \u2192 {target}",
    "agent.confirm.generic.no_target": "Run {action}",
    "palette.theme.light": "Theme: light",
    "palette.theme.dark": "Theme: dark",
    "palette.theme.system": "Theme: follow system",
    "palette.language.current": "\u2713 Active",
    "palette.onboarding.replay": "Replay tour",
    "form.required": "This field is required",
    "form.submit": "Submit",
    "form.assist.fill": "\u2726 AI fill",
    "proactive.yes": "Yes",
    "proactive.no": "No",
    "fallback.empty": "No data",
    "fallback.error": "Error",
    "palette.placeholder": "Search commands or page\u2026",
    "palette.footer.navigate": "to navigate",
    "palette.footer.select": "to select",
    "palette.footer.close": "to close",
    "palette.footer.back": "to go back",
    "palette.section.settings": "Settings",
    "palette.section.go_to": "Go to",
    "palette.section.ai": "AI",
    "palette.section.commands": "Commands",
    "palette.section.docs": "Docs"
  },
  "zh-TW": {
    "agent.thinking": "\u601D\u8003\u4E2D\u2026",
    "agent.running": "Agent \u57F7\u884C\u4E2D\u2026",
    "agent.processing": "\u8655\u7406\u4E2D\u2026",
    "agent.loading": "\u9801\u9762\u8F09\u5165\u4E2D\u2026",
    "agent.done": "\u2713 \u57F7\u884C\u5B8C\u7562",
    "agent.press_space_close": "\u6309 space \u95DC\u9589",
    "agent.press_space_continue": "space \u4E0B\u4E00\u6B65 \uFF5C \u5169\u4E0B space \u76F4\u63A5\u7D50\u675F",
    "agent.tap_to_continue": "\u9EDE\u4E00\u4E0B\u4E0B\u4E00\u6B65 \uFF5C \u5169\u4E0B\u76F4\u63A5\u7D50\u675F",
    "agent.space_continue_reject": "space \u4E0B\u4E00\u6B65 \uFF5C \u5169\u4E0B space \u76F4\u63A5\u7D50\u675F",
    "agent.tap_to_dismiss": "\u9EDE\u4E00\u4E0B\u95DC\u9589",
    "agent.stop_close": "Agent \u5DF2\u505C\u4E0B\uFF0C\u60F3\u5230\u65B0\u7684\u5C31\u76F4\u63A5\u6253\u6216\u6309\u4F4F space",
    "agent.stop_esc": "\u6536\u5230 esc\uFF0Cagent \u5DF2\u505C\u4E0B",
    "agent.stop_reject": "\u6536\u5230\u96D9\u64CA space \u62D2\u7D55\uFF0Cagent \u5DF2\u505C\u4E0B",
    "agent.confirm.suffix": "\u2014 \u6309 space \u78BA\u8A8D",
    "agent.confirm.navigate.with_path": "\u5E36\u4F60\u5230 {path}",
    "agent.confirm.navigate.no_path": "\u5207\u63DB\u9801\u9762",
    "agent.confirm.click.with_target": "\u9EDE\u64CA {target}",
    "agent.confirm.click.no_target": "\u9EDE\u64CA",
    "agent.confirm.fill_input.with_target": "\u586B\u5BEB {target}",
    "agent.confirm.fill_input.no_target": "\u586B\u5BEB\u6B04\u4F4D",
    "agent.confirm.delete.with_target": "\u522A\u9664 {target}",
    "agent.confirm.delete.no_target": "\u522A\u9664",
    "agent.confirm.generic.with_target": "\u57F7\u884C {action} \u2192 {target}",
    "agent.confirm.generic.no_target": "\u57F7\u884C {action}",
    "palette.theme.light": "\u4E3B\u984C:\u4EAE\u8272",
    "palette.theme.dark": "\u4E3B\u984C:\u6697\u8272",
    "palette.theme.system": "\u4E3B\u984C:\u8DDF\u96A8\u7CFB\u7D71",
    "palette.language.current": "\u2713 \u76EE\u524D",
    "palette.onboarding.replay": "\u91CD\u770B\u5C0E\u89BD",
    "form.required": "\u6B64\u6B04\u4F4D\u5FC5\u586B",
    "form.submit": "\u5132\u5B58",
    "form.assist.fill": "\u2726 AI \u586B\u5BEB",
    "proactive.yes": "\u662F",
    "proactive.no": "\u5426",
    "fallback.empty": "\u7121\u8CC7\u6599",
    "fallback.error": "\u932F\u8AA4",
    "palette.placeholder": "\u641C\u5C0B\u6307\u4EE4\u6216\u9801\u9762\u2026",
    "palette.footer.navigate": "\u4E0A\u4E0B\u79FB\u52D5",
    "palette.footer.select": "\u9078\u53D6",
    "palette.footer.close": "\u95DC\u9589",
    "palette.footer.back": "\u4E0A\u4E00\u9801",
    "palette.section.settings": "\u8A2D\u5B9A",
    "palette.section.go_to": "\u524D\u5F80",
    "palette.section.ai": "AI",
    "palette.section.commands": "\u6307\u4EE4",
    "palette.section.docs": "\u6587\u4EF6"
  }
};
function sdkString(locale, key, vars) {
  const dict = STRINGS[locale ?? "en"];
  let s = dict?.[key] ?? STRINGS.en[key];
  if (vars) {
    for (const [k, v] of Object.entries(vars)) {
      s = s.replaceAll(`{${k}}`, String(v));
    }
  }
  return s;
}

// src/ui/subtitle.ts
var UI_ATTR = "data-dddk-ui";
var STYLE_ID2 = "dddk-subtitle-style";
var Subtitle = class {
  el = null;
  indicator = null;
  autoHideTimer = null;
  currentOpts = null;
  delegatedHandler = null;
  /** Extra teardown for the multi-choice mode (keyboard listener on document). */
  choiceTeardown = null;
  /** Click-anywhere / any-key dismiss teardown for info subtitles. */
  dismissTeardown = null;
  locale;
  /** Indicator request that was suppressed because a subtitle was
   *  visible. Materialises when the subtitle hides — that way an
   *  agent loop's 'thinking' indicator gets queued behind the current
   *  subtitle instead of stacking on top of it. */
  pendingIndicator = null;
  constructor(opts = {}) {
    this.locale = opts.locale ?? "en";
  }
  setLocale(locale) {
    this.locale = locale;
  }
  /** Set by the orchestrator so it can flip GestureManager.hasSuggestion
   *  whenever a subtitle becomes visible / hidden. Without this hookup a
   *  single space tap on a proactive prompt would be a no-op (space-tap
   *  → accept only fires when `hasSuggestion` is true). */
  onVisibilityChange;
  setVisibilityListener(fn) {
    this.onVisibilityChange = fn;
  }
  /**
   * Pluggable TTS hook. Every subtitle copy in dddk is written to be
   * naturally readable aloud (no debug log lines, no JSON blobs — that
   * is the editorial rule, see project memory). If the host wires up a
   * TTSProvider via `setTTSProvider`, every `subtitle.show()` call also
   * sends the text through it for real-time speech synthesis. The host
   * decides what provider (free Web Speech `speechSynthesis` / OpenAI
   * TTS / Gemini TTS / Azure / ElevenLabs) and how to gate it with a
   * user pref toggle.
   *
   * The provider is called fire-and-forget — failures don't block the
   * subtitle render, they just mean no audio for that line.
   */
  ttsProvider;
  setTTSProvider(fn) {
    this.ttsProvider = fn ?? void 0;
  }
  /**
   * Wire a host-supplied handler for the bar's × close button (and
   * any equivalent "user dismissed without committing" gestures). The
   * orchestrator typically wires this to stop the agent loop + surface
   * a brief "stopped — say something new" feedback subtitle.
   */
  setCloseHandler(fn) {
    this.closeHandler = fn;
  }
  /** Multi-page state for a single `show()` call. When the caller's text
   *  exceeds maxCharsPerPage we paginate; Space advances. The final
   *  page's accept callback fires the original onAccept. */
  pages = null;
  pageIdx = 0;
  /** Host-supplied "user explicitly closed the bar" handler (typically
   *  wired to stop the agent + show a brief stopped-feedback subtitle). */
  closeHandler = null;
  // ─── streaming mode state ───────────────────────────────────────
  /** Live text node the streaming appendStreamed() writes into. */
  streamingTextNode = null;
  /** Cursor element shown after the live text node (blinking caret). */
  streamingCursor = null;
  /** Untranscribed text held back for sentence-boundary TTS. */
  streamingTtsBuffer = "";
  /** Raw accumulated streaming text (for replaceStreamed / introspection). */
  streamingFullText = "";
  /** Active "pause hint" callbacks — set by applyStreamingPauseHint
   *  when the agent pauses mid-stream. Space invokeAccept fires
   *  accept (advances the agent), double-tap fires reject (stops it).
   *  Both clear the hint and the callbacks. */
  streamingPauseAccept = null;
  streamingPauseReject = null;
  show(opts) {
    if (typeof document === "undefined") return;
    ensureStyles();
    if (this.indicator) {
      const state = this.indicator.getAttribute("data-state") ?? "processing";
      const labelEl = this.indicator.querySelector(`[${UI_ATTR}="indicator-label"]`);
      this.pendingIndicator = { state, label: labelEl?.textContent ?? void 0 };
      this.indicator.remove();
      this.indicator = null;
    }
    if (this.autoHideTimer) {
      clearTimeout(this.autoHideTimer);
      this.autoHideTimer = null;
    }
    this.streamingTextNode = null;
    this.streamingCursor = null;
    this.streamingFullText = "";
    this.streamingTtsBuffer = "";
    const wasVisible = this.el !== null;
    if (!this.el) {
      this.el = document.createElement("div");
      this.el.setAttribute(UI_ATTR, "bar");
      applyPlacement(this.el, "subtitle");
      document.body.appendChild(this.el);
      this.bindTouchTapGestures(this.el);
    } else {
      this.el.removeAttribute("data-dddk-bar-mode");
    }
    if (!wasVisible) this.onVisibilityChange?.(true);
    const maxPerPage = opts.maxCharsPerPage ?? 220;
    if (maxPerPage > 0 && opts.text.length > maxPerPage) {
      this.pages = splitIntoPages(opts.text, maxPerPage);
      this.pageIdx = 0;
    } else {
      this.pages = null;
      this.pageIdx = 0;
    }
    this.currentOpts = opts;
    this.renderCurrentPage();
    document.body.dataset.dddkActive = "true";
    if (this.ttsProvider) {
      try {
        this.ttsProvider(opts.text, { locale: this.locale, type: opts.type });
      } catch {
      }
    }
    const DEFAULT_AUTO_HIDE = 0;
    const autoHideMs = opts.autoHide ?? DEFAULT_AUTO_HIDE;
    if (autoHideMs > 0) {
      this.autoHideTimer = setTimeout(() => this.hide(), autoHideMs);
    }
    this.installInteractionDismiss(opts);
  }
  /**
   * Click-anywhere / any-key dismiss handler.
   *
   * Applies to EVERY subtitle — info or action:
   *  - Click OUTSIDE the bar dismisses (info: hide; action: same, no
   *    accept/reject fired so it's just "ignore the prompt").
   *  - Click INSIDE the bar is ignored — that's a button click and the
   *    button's own handler should fire (wireButtons attaches that).
   *  - Any non-modifier key press dismisses (Space / double-Space are
   *    handled by the gesture manager BEFORE this listener fires, so
   *    Accept / Reject still work).
   *
   * 120ms attach delay so the click that OPENED the subtitle (a palette
   * row click etc.) doesn't immediately dismiss it.
   */
  installInteractionDismiss(opts) {
    if (this.dismissTeardown) {
      this.dismissTeardown();
      this.dismissTeardown = null;
    }
    if (opts.persistent) return;
    const ac = new AbortController();
    setTimeout(() => {
      if (ac.signal.aborted) return;
      const onClick = (e) => {
        const target = e.target;
        if (target && this.el && this.el.contains(target)) return;
        const hasAction = !!(this.currentOpts?.onAccept || this.currentOpts?.onReject);
        const isStreaming = this.el?.getAttribute("data-dddk-bar-mode") === "streaming";
        if (hasAction || isStreaming) return;
        this.handleCloseClick();
      };
      const onKey = (e) => {
        if (["Control", "Shift", "Alt", "Meta", "Tab"].includes(e.key)) return;
        if (e.key === " " || e.key === "Spacebar" || e.key === "Escape") return;
        const isPaged = this.pages !== null && this.pages.length > 1;
        const hasAction = !!(this.currentOpts?.onAccept || this.currentOpts?.onReject);
        if (isPaged || hasAction) return;
        this.hide();
      };
      document.addEventListener("click", onClick, true);
      document.addEventListener("keydown", onKey, true);
      this.dismissTeardown = () => {
        document.removeEventListener("click", onClick, true);
        document.removeEventListener("keydown", onKey, true);
      };
    }, 120);
    this.dismissTeardown = () => {
      ac.abort();
    };
  }
  update(text) {
    if (!this.el) return;
    const textEl = this.el.querySelector(`[${UI_ATTR}="bar-text"]`);
    if (textEl) textEl.innerHTML = renderInlineMarkdown(text);
    if (this.currentOpts) this.currentOpts.text = text;
  }
  // ─── streaming mode ────────────────────────────────────────────────
  //
  // Used by the agent loop: the LLM streams text between tool calls;
  // each delta is appended to a live Text node so the bar "types itself".
  // The bar persists across SPA navigation (it sits on document.body, the
  // same as the rest of subtitle UI), so a continuous narrative reads
  // across page changes without flicker.
  /**
   * Append a text delta to the streaming bar. First call materialises
   * the bar in streaming mode. Sentence-boundary TTS is buffered and
   * flushed automatically — see `flushStreamingTts`.
   */
  appendStreamed(delta) {
    if (typeof document === "undefined" || !delta) return;
    this.ensureStreamingBar();
    const cleaned = delta.replace(/\*\*/g, "");
    if (this.streamingTextNode) {
      this.streamingTextNode.appendData(cleaned);
      this.streamingFullText += cleaned;
      this.autoScrollStream();
    }
    this.streamingTtsBuffer += cleaned;
    this.flushStreamingTts(false);
  }
  /**
   * Wipe the streaming bar's current text and replace with `text`.
   * Use when the host wants a "明確一步一步" refresh between agent
   * steps. Passing an empty string clears the bar but keeps it visible.
   */
  replaceStreamed(text) {
    if (typeof document === "undefined") return;
    this.flushStreamingTts(true);
    this.ensureStreamingBar();
    if (this.streamingTextNode) {
      this.streamingTextNode.data = text;
      this.streamingFullText = text;
    }
    this.streamingTtsBuffer = text;
    this.flushStreamingTts(false);
  }
  /**
   * Mark the streaming bar as "settled" — remove the typing cursor and
   * apply standard dismiss + autoHide. After this the bar behaves like
   * any other `show()` info subtitle (any-key / click dismiss).
   */
  finalizeStreamed(opts) {
    this.flushStreamingTts(true);
    this.pendingIndicator = null;
    if (!this.el) return;
    if (!this.streamingFullText.trim()) {
      this.hide();
      return;
    }
    this.el.setAttribute("data-dddk-bar-mode", "streaming-done");
    if (this.streamingCursor) {
      this.streamingCursor.remove();
      this.streamingCursor = null;
    }
    const barText = this.el.querySelector(`[${UI_ATTR}="bar-text"]`);
    if (barText && this.streamingFullText) {
      barText.innerHTML = renderInlineMarkdown(this.streamingFullText);
    }
    const scroll = this.el.querySelector(`[${UI_ATTR}="bar-scroll"]`);
    if (scroll && !scroll.querySelector(`[${UI_ATTR}="bar-hints"]`)) {
      const hintText = sdkString(this.locale, "agent.press_space_close");
      const hintEl = document.createElement("div");
      hintEl.setAttribute(UI_ATTR, "bar-hints");
      hintEl.textContent = hintText;
      scroll.appendChild(hintEl);
    }
    const dismissOpts = {
      text: this.streamingFullText,
      type: "agent"
    };
    this.currentOpts = dismissOpts;
    this.installInteractionDismiss(dismissOpts);
    const autoHideMs = opts?.autoHide;
    if (typeof autoHideMs === "number" && autoHideMs > 0) {
      if (this.autoHideTimer) clearTimeout(this.autoHideTimer);
      this.autoHideTimer = setTimeout(() => this.hide(), autoHideMs);
    }
  }
  /**
   * Tear down the streaming bar immediately. Used when the agent run
   * stops mid-stream (cancel / error). Distinct from `hide()` only in
   * that it always resets streaming state — `hide()` is the universal
   * "bar gone" used by every mode.
   */
  clearStreamed() {
    this.flushStreamingTts(true);
    this.streamingTextNode = null;
    this.streamingCursor = null;
    this.streamingFullText = "";
    this.streamingTtsBuffer = "";
    this.hide();
  }
  isStreaming() {
    return this.streamingTextNode !== null && this.el?.getAttribute("data-dddk-bar-mode") === "streaming";
  }
  /**
   * Attach a "press space to continue" pause UI to the active
   * streaming bar — without replacing the bar or wiping the text the
   * agent just streamed. The hint sits BELOW the streamed text so the
   * user can finish reading before deciding.
   *
   * Wiring:
   *   - Space tap (invokeAccept) → fires opts.onAccept, clears hint.
   *   - Double-tap Space (invokeReject) → fires opts.onReject, clears
   *     hint. Host typically stops the agent on reject.
   *   - × button → behaves like double-tap (fires reject + close).
   */
  applyStreamingPauseHint(opts) {
    if (!this.el) return;
    this.clearStreamingPauseHint();
    if (this.streamingCursor) {
      this.streamingCursor.style.opacity = "0.35";
      this.streamingCursor.style.animation = "none";
    }
    const scroll = this.el.querySelector(`[${UI_ATTR}="bar-scroll"]`);
    if (!scroll) return;
    const hintEl = document.createElement("div");
    hintEl.setAttribute(UI_ATTR, "streaming-pause");
    hintEl.innerHTML = `
      <div ${UI_ATTR}="streaming-pause-text">${escapeHtml(opts.hint)}</div>
      <div ${UI_ATTR}="streaming-pause-hints">${escapeHtml(opts.rejectHint ?? this.defaultPauseRejectHint())}</div>
    `;
    scroll.appendChild(hintEl);
    this.autoScrollStream();
    this.streamingPauseAccept = opts.onAccept;
    this.streamingPauseReject = opts.onReject;
  }
  /**
   * Tear down the pause hint and restore the cursor. Called on accept
   * (the agent will continue with new deltas) and on reject. Safe to
   * call when no hint is active.
   */
  clearStreamingPauseHint() {
    if (!this.el) return;
    const hintEl = this.el.querySelector(`[${UI_ATTR}="streaming-pause"]`);
    if (hintEl) hintEl.remove();
    this.streamingPauseAccept = null;
    this.streamingPauseReject = null;
    if (this.streamingCursor) {
      this.streamingCursor.style.opacity = "";
      this.streamingCursor.style.animation = "";
    }
  }
  defaultPauseRejectHint() {
    return sdkString(
      this.locale,
      isTouchOnlyDevice() ? "agent.tap_to_continue" : "agent.space_continue_reject"
    );
  }
  /** Keep the scrollable inner pinned to the bottom as new text streams
   *  in — but only if the user hasn't scrolled up to re-read. If they
   *  scrolled up, leave them there. */
  autoScrollStream() {
    if (!this.el) return;
    const scroll = this.el.querySelector(`[${UI_ATTR}="bar-scroll"]`);
    if (!scroll) return;
    const distFromBottom = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight;
    if (distFromBottom < 80) {
      scroll.scrollTop = scroll.scrollHeight;
    }
  }
  ensureStreamingBar() {
    if (this.streamingTextNode && this.el?.getAttribute("data-dddk-bar-mode") === "streaming") return;
    ensureStyles();
    if (this.indicator) {
      this.indicator.remove();
      this.indicator = null;
      this.pendingIndicator = null;
    }
    if (this.dismissTeardown) {
      this.dismissTeardown();
      this.dismissTeardown = null;
    }
    if (this.autoHideTimer) {
      clearTimeout(this.autoHideTimer);
      this.autoHideTimer = null;
    }
    const wasVisible = this.el !== null;
    if (!this.el) {
      this.el = document.createElement("div");
      this.el.setAttribute(UI_ATTR, "bar");
      applyPlacement(this.el, "subtitle");
      document.body.appendChild(this.el);
      this.bindTouchTapGestures(this.el);
    }
    this.el.setAttribute("data-dddk-bar-type", "agent");
    this.el.setAttribute("data-dddk-bar-mode", "streaming");
    this.el.innerHTML = this.wrapBarShell(`<div ${UI_ATTR}="bar-text"></div>`);
    this.wireClose();
    const textWrap = this.el.querySelector(`[${UI_ATTR}="bar-text"]`);
    if (!textWrap) return;
    const textNode = document.createTextNode("");
    const cursor = document.createElement("span");
    cursor.setAttribute(UI_ATTR, "streaming-cursor");
    cursor.textContent = "\u258D";
    textWrap.appendChild(textNode);
    textWrap.appendChild(cursor);
    this.streamingTextNode = textNode;
    this.streamingCursor = cursor;
    this.streamingFullText = "";
    this.streamingTtsBuffer = "";
    this.currentOpts = null;
    document.body.dataset.dddkActive = "true";
    if (!wasVisible) this.onVisibilityChange?.(true);
  }
  /**
   * Send buffered streaming text through TTS at sentence boundaries.
   * `force = true` flushes whatever's left regardless of punctuation
   * (used on finalize / replace).
   *
   * Sentence-end characters covered: `。 . ！ ! ？ ? \n`. To avoid
   * choppy mid-clause reads, we also require either a sentence-end OR
   * buffer length ≥ 80 characters before flushing in non-forced mode.
   */
  flushStreamingTts(force) {
    if (!this.ttsProvider) {
      this.streamingTtsBuffer = "";
      return;
    }
    const buf = this.streamingTtsBuffer;
    if (!buf) return;
    if (force) {
      try {
        this.ttsProvider(buf, { locale: this.locale, type: "agent" });
      } catch {
      }
      this.streamingTtsBuffer = "";
      return;
    }
    const SENTENCE_END = /[。．.！!？?\n]/g;
    let lastIdx = -1;
    let m;
    while ((m = SENTENCE_END.exec(buf)) !== null) lastIdx = m.index;
    if (lastIdx < 0) {
      if (buf.length < 80) return;
      const ws = buf.lastIndexOf(" ");
      const cut = ws > 30 ? ws + 1 : buf.length;
      const head2 = buf.slice(0, cut);
      this.streamingTtsBuffer = buf.slice(cut);
      try {
        this.ttsProvider(head2, { locale: this.locale, type: "agent" });
      } catch {
      }
      return;
    }
    const head = buf.slice(0, lastIdx + 1);
    this.streamingTtsBuffer = buf.slice(lastIdx + 1);
    try {
      this.ttsProvider(head, { locale: this.locale, type: "agent" });
    } catch {
    }
  }
  /**
   * Multi-choice variant of `show()`. Renders the question + numbered
   * option list inside the subtitle bar. The user picks via click, digit
   * key (1..N), or — if `allowFreeText` is true (default) — by typing
   * into the inline free-text input and pressing Enter.
   *
   * The subtitle bar is replaced wholesale (any current single-line
   * subtitle is dismissed first). Esc invokes `onCancel`.
   *
   * Designed to mirror Claude Code's AskUserQuestion envelope (2–4
   * options + optional free-text fallback), but rendered into the
   * standard subtitle slot so a host that already styles the subtitle
   * gets a consistent look for both yes/no and multi-choice prompts.
   */
  showChoice(opts) {
    if (typeof document === "undefined") return;
    ensureStyles();
    if (this.autoHideTimer) {
      clearTimeout(this.autoHideTimer);
      this.autoHideTimer = null;
    }
    const allowFreeText = opts.allowFreeText !== false;
    const freeTextLabel = opts.freeTextLabel ?? (this.locale === "zh-TW" ? "\u5176\u4ED6 (\u6309 Enter \u9001\u51FA)" : "Other (press Enter)");
    const wasVisible = this.el !== null;
    if (!this.el) {
      this.el = document.createElement("div");
      this.el.setAttribute(UI_ATTR, "bar");
      applyPlacement(this.el, "subtitle");
      document.body.appendChild(this.el);
      this.bindTouchTapGestures(this.el);
    }
    if (!wasVisible) this.onVisibilityChange?.(true);
    const cancelPath = () => {
      const cb = opts.onCancel;
      this.hide();
      cb?.();
    };
    this.currentOpts = {
      text: opts.question,
      type: "agent",
      onCancel: cancelPath,
      onReject: cancelPath
    };
    this.el.setAttribute("data-dddk-bar-type", "agent");
    this.el.setAttribute("data-dddk-bar-mode", "choice");
    const optionsHtml = opts.options.map((label, i) => {
      const safe = escapeHtml(label);
      return `<button ${UI_ATTR}="choice-row" data-dddk-action="choose" data-choice-index="${i}">
          <kbd ${UI_ATTR}="choice-key">${i + 1}</kbd>
          <span ${UI_ATTR}="choice-label">${safe}</span>
        </button>`;
    }).join("");
    const freeTextHtml = allowFreeText ? `<div ${UI_ATTR}="choice-freetext">
          <input type="text" ${UI_ATTR}="choice-input" placeholder="${escapeHtml(freeTextLabel)}" />
        </div>` : "";
    const hintText = this.locale === "zh-TW" ? `\u6309\u6578\u5B57\u9375\u9078 \uFF5C \u9EDE\u64CA\u4E5F\u53EF\u4EE5 \uFF5C esc \u53D6\u6D88${allowFreeText ? " \uFF5C Other \u5340\u53EF\u4EE5\u76F4\u63A5\u6253\u5B57" : ""}` : `digit to pick \xB7 click works too \xB7 esc cancels${allowFreeText ? " \xB7 type into Other to free-text" : ""}`;
    this.el.innerHTML = this.wrapBarShell(`
      <div ${UI_ATTR}="bar-text">${renderInlineMarkdown(opts.question)}</div>
      <div ${UI_ATTR}="choice-list">${optionsHtml}</div>
      ${freeTextHtml}
      <div ${UI_ATTR}="bar-hints">${hintText}</div>
    `);
    this.wireChoice(opts, allowFreeText);
    this.wireClose();
    document.body.dataset.dddkActive = "true";
    if (this.ttsProvider) {
      try {
        this.ttsProvider(opts.question, { locale: this.locale, type: "agent" });
      } catch {
      }
    }
    const DEFAULT_AUTO_HIDE = 0;
    const autoHideMs = opts.autoHide ?? DEFAULT_AUTO_HIDE;
    if (autoHideMs > 0) {
      this.autoHideTimer = setTimeout(() => this.hide(), autoHideMs);
    }
    this.installChoiceDismiss(opts);
  }
  installChoiceDismiss(opts) {
    if (this.dismissTeardown) {
      this.dismissTeardown();
      this.dismissTeardown = null;
    }
    const ac = new AbortController();
    setTimeout(() => {
      if (ac.signal.aborted) return;
      const cancelAndHide = () => {
        const cb = opts.onCancel;
        this.hide();
        cb?.();
        this.closeHandler?.();
      };
      const onClick = (e) => {
        const target = e.target;
        if (target && this.el && this.el.contains(target)) return;
        cancelAndHide();
      };
      const onKey = (e) => {
        if (e.isComposing || e.keyCode === 229) return;
        if (["Control", "Shift", "Alt", "Meta", "Tab"].includes(e.key)) return;
        if (e.key === " " || e.key === "Spacebar" || e.code === "Space") return;
        const target = e.target;
        if (target && this.el && this.el.contains(target)) return;
        if (e.key === "Escape") {
          e.preventDefault();
          cancelAndHide();
          return;
        }
        if (/^[1-9]$/.test(e.key)) return;
        cancelAndHide();
      };
      document.addEventListener("click", onClick, true);
      document.addEventListener("keydown", onKey, true);
      this.dismissTeardown = () => {
        document.removeEventListener("click", onClick, true);
        document.removeEventListener("keydown", onKey, true);
      };
    }, 120);
    this.dismissTeardown = () => {
      ac.abort();
    };
  }
  hide() {
    const wasVisible = this.el !== null;
    if (this.el && this.delegatedHandler) {
      this.el.removeEventListener("click", this.delegatedHandler);
      this.delegatedHandler = null;
    }
    if (this.choiceTeardown) {
      this.choiceTeardown();
      this.choiceTeardown = null;
    }
    if (this.dismissTeardown) {
      this.dismissTeardown();
      this.dismissTeardown = null;
    }
    if (this.el) {
      this.el.remove();
      this.el = null;
    }
    this.currentOpts = null;
    this.streamingTextNode = null;
    this.streamingCursor = null;
    this.streamingFullText = "";
    this.streamingTtsBuffer = "";
    delete document.body.dataset.dddkActive;
    if (this.autoHideTimer) {
      clearTimeout(this.autoHideTimer);
      this.autoHideTimer = null;
    }
    if (wasVisible) this.onVisibilityChange?.(false);
    if (this.pendingIndicator) {
      const p = this.pendingIndicator;
      this.pendingIndicator = null;
      this.showIndicator(p.state, p.label);
    }
  }
  isVisible() {
    return this.el !== null;
  }
  /**
   * Touch-only tap routing on the subtitle bar itself — single tap calls
   * `invokeAccept()` and double tap calls `invokeReject()`, so phone /
   * tablet users without a physical Space key can advance the streaming
   * pause, accept a confirm gate, etc. by tapping the bar.
   *
   * Mouse clicks pass through unchanged (single-tap-to-accept on a
   * desktop bar would be a footgun — users misclick). We feature-detect
   * `pointerType === 'touch'` rather than viewport size so a laptop in
   * tablet mode also gets the gesture.
   *
   * Taps on the embedded × button bubble up to here too — we ignore
   * them by checking the event target's role.
   */
  bindTouchTapGestures(el) {
    let lastTapAt = 0;
    let singleTapTimer = null;
    const doubleTapMs = 320;
    el.addEventListener("pointerup", (e) => {
      if (e.pointerType !== "touch") return;
      const target = e.target;
      if (target?.closest('button, input, textarea, a, [role="button"]')) return;
      const now = Date.now();
      if (now - lastTapAt < doubleTapMs) {
        if (singleTapTimer !== null) {
          clearTimeout(singleTapTimer);
          singleTapTimer = null;
        }
        lastTapAt = 0;
        document.dispatchEvent(new CustomEvent("dddk:bar-tap-reject"));
        return;
      }
      lastTapAt = now;
      singleTapTimer = setTimeout(() => {
        singleTapTimer = null;
        document.dispatchEvent(new CustomEvent("dddk:bar-tap-accept"));
      }, doubleTapMs);
    });
  }
  /**
   * Programmatically trigger the current subtitle's accept/reject/cancel
   * callback. Used by the orchestrator's gesture manager — single space
   * tap → `invokeAccept()`, double tap → `invokeReject()`, Esc →
   * `invokeCancel()`. Without these the subtitle bar would only respond
   * to mouse clicks on its buttons.
   *
   * No-ops when no subtitle is currently shown.
   */
  invokeAccept() {
    if (this.pages && this.pageIdx < this.pages.length - 1) {
      this.pageIdx += 1;
      this.renderCurrentPage();
      return true;
    }
    if (this.streamingPauseAccept) {
      const cb2 = this.streamingPauseAccept;
      this.clearStreamingPauseHint();
      cb2();
      return true;
    }
    if (this.isStreaming()) {
      this.handleCloseClick();
      return true;
    }
    if (this.el && !this.currentOpts?.onAccept) {
      this.hide();
      return true;
    }
    const cb = this.currentOpts?.onAccept;
    if (!cb) {
      if (this.pages) {
        this.hide();
        return true;
      }
      return false;
    }
    this.hide();
    this.showIndicator("processing", this.resolveRunningLabel());
    cb();
    return true;
  }
  invokeReject() {
    if (this.streamingPauseReject) {
      const cb2 = this.streamingPauseReject;
      this.clearStreamingPauseHint();
      cb2();
      return true;
    }
    const cb = this.currentOpts?.onReject;
    if (!cb) return false;
    this.hide();
    this.showIndicator("processing", this.resolveRunningLabel());
    cb();
    return true;
  }
  invokeCancel() {
    if (this.currentOpts?.persistent) return false;
    const cb = this.currentOpts?.onCancel;
    if (!cb) return false;
    this.hide();
    cb();
    return true;
  }
  /**
   * Override the "running" indicator label shown immediately after the
   * user taps Space accept / reject. The default is locale-aware
   * ("處理中…" / "Working…") via `setLocale()`. Hosts that want a
   * different word ("Thinking…", "Running automation…") set their own.
   * Pass `null` to fall back to the bundled default.
   */
  runningLabel = null;
  setRunningLabel(label) {
    this.runningLabel = label;
  }
  resolveRunningLabel() {
    if (this.runningLabel) return this.runningLabel;
    if (this.locale === "zh-TW") return "Agent \u57F7\u884C\u4E2D\u2026";
    return "Agent running\u2026";
  }
  showIndicator(state, label) {
    if (this.el) {
      this.pendingIndicator = { state, label };
      return;
    }
    ensureStyles();
    if (!this.indicator) {
      this.indicator = document.createElement("div");
      this.indicator.setAttribute(UI_ATTR, "indicator");
      applyPlacement(this.indicator, "indicator");
      document.body.appendChild(this.indicator);
    }
    this.indicator.setAttribute("data-state", state);
    const visual = state === "done" ? `<div ${UI_ATTR}="indicator-check">\u2713</div>` : `<div ${UI_ATTR}="indicator-dots"><span></span><span></span><span></span></div>`;
    this.indicator.innerHTML = `
      ${visual}
      ${label ? `<div ${UI_ATTR}="indicator-label">${escapeHtml(label)}</div>` : ""}
    `;
  }
  hideIndicator() {
    this.indicator?.remove();
    this.indicator = null;
    this.pendingIndicator = null;
  }
  // ─── private ────────────────────────────────────────────────────
  /**
   * Render whichever page of `pages` we're on right now into the bar.
   * When pages is null we just render the original opts.text. Each
   * intermediate page hides the accept-callback presence so wireButtons
   * doesn't double-fire; the final page restores it. We also append a
   * `(n/N · space →)` paging hint so the user knows there's more.
   */
  renderCurrentPage() {
    if (!this.el || !this.currentOpts) return;
    const opts = this.currentOpts;
    const isPaged = this.pages !== null;
    const isLastPage = !isPaged || this.pageIdx === this.pages.length - 1;
    const pageText = isPaged ? this.pages[this.pageIdx] : opts.text;
    const effectiveOpts = isLastPage ? opts : { ...opts, onAccept: void 0, onReject: void 0, onCopy: void 0 };
    const pageHint = isPaged ? this.formatPageHint(this.pageIdx + 1, this.pages.length, isLastPage) : "";
    const hints = pageHint || (isLastPage ? this.renderHints(effectiveOpts) : "");
    this.el.setAttribute("data-dddk-bar-type", opts.type);
    this.el.innerHTML = this.wrapBarShell(`
      <div ${UI_ATTR}="bar-text">${renderInlineMarkdown(pageText)}</div>
      ${hints.startsWith(`<div ${UI_ATTR}="bar-hints"`) ? hints : hints ? `<div ${UI_ATTR}="bar-hints">${escapeHtml(hints)}</div>` : ""}
      ${this.renderButtons(effectiveOpts)}
    `);
    this.wireButtons(effectiveOpts);
    this.wireClose();
  }
  /**
   * Wrap any bar inner-HTML in a frame that gives us:
   *   1. a non-scrolling × close button pinned to the top-right corner,
   *      always reachable even when long content scrolls
   *   2. a `bar-scroll` inner container so the content can be max-height
   *      capped + scrolled without losing the close button
   *
   * The × button is skipped entirely when the current opts are
   * `persistent: true` — the host has declared the user MUST answer
   * before the bar can go (e.g. end-of-loop feedback).
   */
  wrapBarShell(innerHtml) {
    const persistent = this.currentOpts?.persistent === true;
    const closeBtn = persistent ? "" : `<button ${UI_ATTR}="bar-close" data-dddk-action="close" aria-label="${escapeHtml(this.closeLabel())}" title="${escapeHtml(this.closeLabel())}">\xD7</button>`;
    return `
      ${closeBtn}
      <div ${UI_ATTR}="bar-scroll">${innerHtml}</div>
    `;
  }
  closeLabel() {
    return this.locale === "zh-TW" ? "\u95DC\u9589" : "Close";
  }
  /**
   * Wire the × close button's click to hide() + fire any host-supplied
   * close handler. Idempotent — safe to call after any render path that
   * uses `wrapBarShell`.
   */
  wireClose() {
    if (!this.el) return;
    const btn = this.el.querySelector(`button[${UI_ATTR}="bar-close"]`);
    if (!btn) return;
    btn.addEventListener("click", (e) => {
      e.stopPropagation();
      this.handleCloseClick();
    });
  }
  handleCloseClick() {
    const cancel = this.currentOpts?.onCancel;
    this.pendingIndicator = null;
    this.hide();
    this.hideIndicator();
    cancel?.();
    this.closeHandler?.();
  }
  formatPageHint(idx, total, isLast) {
    const zh = this.locale === "zh-TW";
    const counter = `${idx}/${total}`;
    const action = isLast ? zh ? "space \u4E0B\u4E00\u6B65 \uFF5C \u5169\u4E0B space \u76F4\u63A5\u7D50\u675F" : "space \u2192 next \xB7 double space \u2192 exit" : zh ? "space \u2192 \u4E0B\u4E00\u6BB5 \uFF5C \u5169\u4E0B space \u76F4\u63A5\u7D50\u675F" : "space \u2192 next page \xB7 double space \u2192 exit";
    return `<div ${UI_ATTR}="bar-hints" style="display:flex;justify-content:space-between;gap:16px;"><span>${counter}</span><span>${escapeHtml(action)}</span></div>`;
  }
  renderHints(opts) {
    if (opts.hints) return `<div ${UI_ATTR}="bar-hints">${escapeHtml(opts.hints)}</div>`;
    const isDecisionless = !opts.onAccept && !opts.onReject;
    if (isDecisionless && opts.type !== "agent") {
      const text2 = sdkString(
        this.locale,
        isTouchOnlyDevice() ? "agent.tap_to_dismiss" : "agent.press_space_close"
      );
      return `<div ${UI_ATTR}="bar-hints">${text2}</div>`;
    }
    const touch = isTouchOnlyDevice();
    const defaults = touch ? {
      "zh-TW": {
        voice: "\u9EDE\u4E00\u4E0B\u7E7C\u7E8C \uFF5C \u96D9\u64CA\u7D50\u675F",
        selection: "\u9EDE\u4E00\u4E0B\u63A5\u53D7 \uFF5C \u96D9\u64CA\u62D2\u7D55",
        agent: "\u9EDE\u4E00\u4E0B\u7E7C\u7E8C \uFF5C \u96D9\u64CA\u7D50\u675F",
        post: "\u9EDE\u4E00\u4E0B\u63A5\u53D7 \uFF5C \u96D9\u64CA\u62D2\u7D55",
        info: "\u9EDE\u4E00\u4E0B\u95DC\u9589"
      },
      en: {
        voice: "tap to continue \xB7 double-tap to exit",
        selection: "tap to accept \xB7 double-tap to reject",
        agent: "tap to continue \xB7 double-tap to exit",
        post: "tap to accept \xB7 double-tap to reject",
        info: "tap to dismiss"
      }
    } : {
      "zh-TW": {
        voice: "Tab \u4E00\u884C \uFF5C space \u540C\u610F \uFF5C \u5169\u4E0B space \u62D2\u7D55",
        selection: "space \u63A5\u53D7 \uFF5C \u5169\u4E0B space \u62D2\u7D55",
        agent: "space \u4E0B\u4E00\u6B65 \uFF5C \u5169\u4E0B space \u76F4\u63A5\u7D50\u675F",
        post: "space \u63A5\u53D7 \uFF5C \u5169\u4E0B space \u62D2\u7D55",
        info: "\u6309 space \u95DC\u9589"
      },
      en: {
        voice: "Tab line \xB7 space to accept \xB7 double space to reject",
        selection: "space to accept \xB7 double space to reject",
        agent: "space \u2192 next \xB7 double space \u2192 exit",
        post: "space to accept \xB7 double space to reject",
        info: "press space to close"
      }
    };
    const dict = defaults[this.locale] ?? defaults.en;
    const text = dict[opts.type];
    return text ? `<div ${UI_ATTR}="bar-hints">${text}</div>` : "";
  }
  renderButtons(opts) {
    if (opts.type === "info") return "";
    const showCopy = opts.onCopy !== void 0;
    const touch = isTouchOnlyDevice();
    return `
      <div ${UI_ATTR}="bar-buttons">
        ${showCopy ? `<button data-dddk-action="copy" aria-label="Copy">\u2398</button>` : ""}
        ${!touch && opts.onAccept ? `<button data-dddk-action="accept" aria-label="Accept">\u2713</button>` : ""}
        ${!touch && opts.onReject ? `<button data-dddk-action="reject" aria-label="Reject">\u2715</button>` : ""}
      </div>
    `;
  }
  /**
   * Single delegated listener (vs three direct addEventListeners). Cheaper
   * to install per show() and means we never accumulate dangling listeners
   * if innerHTML is replaced mid-life by update() variants.
   */
  wireButtons(opts) {
    if (!this.el) return;
    if (this.delegatedHandler) {
      this.el.removeEventListener("click", this.delegatedHandler);
      this.delegatedHandler = null;
    }
    const handler = (e) => {
      const btn = e.target?.closest(
        "button[data-dddk-action]"
      );
      if (!btn) return;
      const action = btn.dataset.dddkAction;
      if (action === "accept") opts.onAccept?.();
      else if (action === "reject") opts.onReject?.();
      else if (action === "copy") opts.onCopy?.();
    };
    this.el.addEventListener("click", handler);
    this.delegatedHandler = handler;
  }
  /**
   * Wire keyboard + click for the multi-choice bar.
   *  - 1..N digit keys pick the corresponding option and call onChoose.
   *  - Click on any `[data-dddk-action="choose"]` row picks that option.
   *  - When free-text is allowed, Enter inside the input submits the
   *    typed value with `index === -1`.
   *
   * The keyboard listener is installed on `document` (capture phase) so
   * digit keys are caught even when focus is elsewhere. We remove it on
   * `hide()` via the existing delegatedHandler cleanup path.
   */
  wireChoice(opts, allowFreeText) {
    if (!this.el) return;
    if (this.delegatedHandler) {
      this.el.removeEventListener("click", this.delegatedHandler);
      this.delegatedHandler = null;
    }
    if (this.choiceTeardown) {
      this.choiceTeardown();
      this.choiceTeardown = null;
    }
    const input = allowFreeText ? this.el.querySelector(`[${UI_ATTR}="choice-input"]`) : null;
    let resolved = false;
    const resolve = (value, index) => {
      if (resolved) return;
      resolved = true;
      this.hide();
      opts.onChoose(value, index);
    };
    const onClick = (e) => {
      const btn = e.target?.closest(
        'button[data-dddk-action="choose"]'
      );
      if (!btn) return;
      const idx = Number(btn.dataset.choiceIndex);
      if (Number.isNaN(idx)) return;
      resolve(opts.options[idx] ?? "", idx);
    };
    const onKey = (e) => {
      if (e.isComposing || e.keyCode === 229) return;
      if (e.target === input) {
        if (e.key === "Enter" && input) {
          e.preventDefault();
          const v = input.value.trim();
          if (v) resolve(v, -1);
        }
        return;
      }
      const active = document.activeElement;
      if (active && active !== document.body) {
        const tag = active.tagName.toLowerCase();
        if (tag === "input" || tag === "textarea" || tag === "select") return;
        if (active.isContentEditable) return;
      }
      if (/^[1-9]$/.test(e.key)) {
        const idx = Number(e.key) - 1;
        if (idx < opts.options.length) {
          e.preventDefault();
          resolve(opts.options[idx] ?? "", idx);
        }
      }
    };
    this.el.addEventListener("click", onClick);
    document.addEventListener("keydown", onKey, true);
    this.delegatedHandler = onClick;
    this.choiceTeardown = () => {
      document.removeEventListener("keydown", onKey, true);
    };
  }
};
function isTouchOnlyDevice() {
  if (typeof window === "undefined" || typeof navigator === "undefined") return false;
  const hasTouch = "ontouchstart" in window || (navigator.maxTouchPoints ?? 0) > 0;
  if (!hasTouch) return false;
  try {
    return window.matchMedia("(pointer: coarse)").matches;
  } catch {
    return hasTouch;
  }
}
function splitIntoPages(text, maxChars) {
  const out = [];
  let remaining = text.trim();
  while (remaining.length > maxChars) {
    const window2 = remaining.slice(0, maxChars);
    let cut = window2.lastIndexOf("\n\n");
    if (cut < maxChars * 0.3) {
      const marks = [". ", "! ", "? ", "\u3002", "\uFF01", "\uFF1F", "\n"];
      for (const m of marks) {
        const idx = window2.lastIndexOf(m);
        if (idx > cut) cut = idx + m.length;
      }
    } else {
      cut += 2;
    }
    if (cut < maxChars * 0.3) cut = maxChars;
    out.push(remaining.slice(0, cut).trim());
    remaining = remaining.slice(cut).trim();
  }
  if (remaining) out.push(remaining);
  return out.length > 0 ? out : [text];
}
function renderInlineMarkdown(raw) {
  let html = escapeHtml(raw);
  html = html.replace(
    /(?:^|\n)((?:[-*]\s.+\n?)+)/g,
    (_match, group) => {
      const items = group.trim().split("\n").map((line) => line.replace(/^[-*]\s+/, "")).map((item) => `<li>${item.trim()}</li>`).join("");
      return `
<ul>${items}</ul>`;
    }
  );
  html = html.replace(
    /(?:^|\n)((?:\d+\.\s.+\n?)+)/g,
    (_match, group) => {
      const items = group.trim().split("\n").map((line) => line.replace(/^\d+\.\s+/, "")).map((item) => `<li>${item.trim()}</li>`).join("");
      return `
<ol>${items}</ol>`;
    }
  );
  const codeSpans = [];
  html = html.replace(/`([^`\n]+)`/g, (_m, code) => {
    codeSpans.push(code);
    return `\0CODE${codeSpans.length - 1}\0`;
  });
  html = html.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
  html = html.replace(/__([^_\n]+)__/g, "<strong>$1</strong>");
  html = html.replace(/(?<!\w)\*([^*\n]+)\*(?!\w)/g, "<em>$1</em>");
  html = html.replace(/(?<!\w)_([^_\n]+)_(?!\w)/g, "<em>$1</em>");
  html = html.replace(
    /(https?:\/\/[^\s<]+[^\s<.,!?])/g,
    '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>'
  );
  html = html.replace(/\x00CODE(\d+)\x00/g, (_m, idx) => {
    return `<code>${codeSpans[Number(idx)] ?? ""}</code>`;
  });
  html = html.replace(/\n/g, "<br>");
  return html;
}
function ensureStyles() {
  injectScopedStyle(STYLE_ID2, `
    /* Positioning (fixed / center-bottom / z-index / max-width) lives in
     * placement.ts \u2014 the rules below only style the bar's chrome
     * (background / radius / padding / blur).
     *
     * Theming: prefer host-bridged tokens (--dddk-bg-elevated / --dddk-text
     * / --dddk-border) so a host that defines a dark theme via those
     * tokens gets a properly-themed bar automatically \u2014 no separate
     * --dddk-bar-bg override needed, no "white-on-black inverted" look.
     * The hard-coded fallback at the end of each chain is only for hosts
     * that haven't bridged anything yet. */
    [${UI_ATTR}="bar"] {
      position: relative;
      background: var(--dddk-bar-bg, var(--dddk-bg-elevated, rgba(255, 255, 255, 0.985)));
      color: var(--dddk-bar-text, var(--dddk-text, #1a1a1a));
      border: 1px solid var(--dddk-bar-border, var(--dddk-border, rgba(0, 0, 0, 0.08)));
      border-radius: var(--dddk-bar-radius, 16px);
      box-shadow: var(--dddk-bar-shadow, 0 18px 48px -12px rgba(0, 0, 0, 0.32), 0 4px 14px rgba(0, 0, 0, 0.08));
      padding: 0;
      font-family: var(--dddk-bar-font, var(--dddk-font, system-ui, -apple-system, "Segoe UI", "PingFang TC", "Microsoft JhengHei", sans-serif));
      font-size: var(--dddk-bar-font-size, 15.5px);
      line-height: 1.65;
      letter-spacing: 0.005em;
      backdrop-filter: blur(var(--dddk-blur, 18px));
      -webkit-backdrop-filter: blur(var(--dddk-blur, 18px));
    }
    [${UI_ATTR}="bar-scroll"] {
      max-height: var(--dddk-bar-max-height, min(52vh, 460px));
      overflow-y: auto;
      overscroll-behavior: contain;
      padding: 18px 56px 18px 22px;
      display: flex; flex-direction: column; gap: 10px;
      scrollbar-width: thin;
      scrollbar-color: var(--dddk-border, rgba(0,0,0,0.18)) transparent;
    }
    [${UI_ATTR}="bar-scroll"]::-webkit-scrollbar { width: 8px; }
    [${UI_ATTR}="bar-scroll"]::-webkit-scrollbar-track { background: transparent; }
    [${UI_ATTR}="bar-scroll"]::-webkit-scrollbar-thumb {
      background: var(--dddk-border, rgba(0,0,0,0.18));
      border-radius: 4px;
    }
    [${UI_ATTR}="bar-close"] {
      position: absolute;
      top: 10px; right: 10px;
      width: 32px; height: 32px;
      display: inline-flex; align-items: center; justify-content: center;
      background: var(--dddk-close-bg, rgba(0,0,0,0.05));
      color: var(--dddk-close-fg, var(--dddk-text, #1a1a1a));
      border: 1px solid var(--dddk-close-border, rgba(0,0,0,0.08));
      border-radius: 10px;
      font-size: 18px; line-height: 1;
      font-weight: 500;
      cursor: pointer;
      padding: 0;
      transition: background 0.12s, color 0.12s, border-color 0.12s, transform 0.08s;
      z-index: 2;
      -webkit-tap-highlight-color: transparent;
    }
    [${UI_ATTR}="bar-close"]:hover {
      background: var(--dddk-close-bg-hover, rgba(0,0,0,0.1));
      border-color: var(--dddk-close-border-hover, rgba(0,0,0,0.16));
    }
    [${UI_ATTR}="bar-close"]:focus-visible {
      outline: 2px solid var(--dddk-accent, #6366f1);
      outline-offset: 2px;
    }
    [${UI_ATTR}="bar-close"]:active { transform: scale(0.92); }
    [${UI_ATTR}="bar-hints"] {
      color: var(--dddk-bar-hints, var(--dddk-text-muted, #6b7280));
      font-size: var(--dddk-font-size-sm, 12.5px);
      margin-top: 2px;
    }
    [${UI_ATTR}="bar-text"] { line-height: 1.65; font-size: inherit; }
    /* Streaming mode: the bar shows live LLM output token-by-token. A
       blinking block cursor sits at the tail of the text node so users
       know more text is on the way. Once the agent loop ends and
       finalizeStreamed runs, the bar flips to streaming-done and the
       cursor is removed. */
    [${UI_ATTR}="bar"][data-dddk-bar-mode="streaming"] [${UI_ATTR}="bar-text"] {
      white-space: pre-wrap;
      word-break: break-word;
    }
    [${UI_ATTR}="streaming-cursor"] {
      display: inline-block;
      margin-left: 2px;
      color: var(--dddk-accent, #6366f1);
      animation: dddk-cursor-blink 1s steps(2) infinite;
      font-weight: 400;
      transform: translateY(-1px);
    }
    [${UI_ATTR}="bar"][data-dddk-bar-mode="streaming-done"] [${UI_ATTR}="bar-text"] {
      white-space: pre-wrap;
      word-break: break-word;
    }
    @keyframes dddk-cursor-blink {
      0%, 49% { opacity: 1; }
      50%, 100% { opacity: 0; }
    }
    /* Pause hint \u2014 attached BELOW the streamed text without replacing
       the bar. Visually a soft divider + a hint line so the user can
       still read what just streamed before deciding to continue. */
    [${UI_ATTR}="streaming-pause"] {
      margin-top: 12px;
      padding-top: 12px;
      border-top: 1px dashed var(--dddk-border, rgba(0, 0, 0, 0.12));
      display: flex; flex-direction: column; gap: 4px;
    }
    [${UI_ATTR}="streaming-pause-text"] {
      font-size: var(--dddk-font-size-md, 14.5px);
      color: var(--dddk-text, #1a1a1a);
    }
    [${UI_ATTR}="streaming-pause-hints"] {
      font-size: var(--dddk-font-size-sm, 12.5px);
      color: var(--dddk-text-muted, #6b7280);
    }
    /* Markdown rendering inside subtitle text \u2014 LLMs emit **bold** and
       bullet lists by default, so the bar needs to render them properly. */
    [${UI_ATTR}="bar-text"] strong { font-weight: 700; }
    [${UI_ATTR}="bar-text"] em { font-style: italic; }
    [${UI_ATTR}="bar-text"] code {
      font-family: var(--dddk-mono, ui-monospace, "SF Mono", Menlo, monospace);
      font-size: 0.92em;
      padding: 1px 5px;
      background: var(--dddk-row-hover, rgba(0,0,0,0.06));
      border-radius: 4px;
    }
    [${UI_ATTR}="bar-text"] ul,
    [${UI_ATTR}="bar-text"] ol {
      margin: 6px 0 6px 0;
      padding-left: 20px;
    }
    [${UI_ATTR}="bar-text"] li { margin: 2px 0; }
    [${UI_ATTR}="bar-text"] a {
      color: var(--dddk-accent, #6366f1);
      text-decoration: underline;
    }
    [${UI_ATTR}="bar-hints"] {
      font-size: var(--dddk-font-size-sm, 12px);
    }
    [${UI_ATTR}="bar-buttons"] {
      display: flex; gap: 8px; justify-content: flex-end;
      margin-top: 4px;
    }
    [${UI_ATTR}="bar-buttons"] button {
      border: 0;
      /* Tap target \u2014 44x44 minimum on mobile per WCAG. Desktop reads the
         same comfortable size; no harm having a 44px button up there. */
      min-width: 44px; min-height: 36px;
      padding: 6px 16px;
      border-radius: var(--dddk-radius-sm, 6px);
      background: var(--dddk-accent, #ec4899);
      color: var(--dddk-text-on-accent, #fff);
      cursor: pointer;
      font-size: var(--dddk-font-size-md, 14px);
      font-weight: 600;
      display: inline-flex; align-items: center; justify-content: center; gap: 4px;
      -webkit-tap-highlight-color: transparent;
      transition: background 0.1s, transform 0.08s;
    }
    [${UI_ATTR}="bar-buttons"] button:hover {
      background: var(--dddk-accent-hover, #db2777);
    }
    [${UI_ATTR}="bar-buttons"] button:active {
      transform: scale(0.96);
    }
    /* Reject button gets a softer treatment so accept reads as primary. */
    [${UI_ATTR}="bar-buttons"] button[data-dddk-action="reject"] {
      background: transparent;
      color: var(--dddk-text-muted, #6b6b6b);
      border: 1px solid var(--dddk-border, rgba(0,0,0,0.15));
    }
    [${UI_ATTR}="bar-buttons"] button[data-dddk-action="reject"]:hover {
      background: var(--dddk-row-hover, rgba(0,0,0,0.04));
      color: var(--dddk-text, #1a1a1a);
    }
    /* \u2500\u2500 multi-choice mode \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
       Triggered by Subtitle.showChoice(); the bar gets
       [data-dddk-bar-mode="choice"]. Layout:
         question (bar-text \u2014 reuses single-line styles)
         \u2193 choice-list with N rows
         \u2193 optional free-text input
         \u2193 hint line (reuses bar-hints) */
    [${UI_ATTR}="choice-list"] {
      display: flex; flex-direction: column; gap: 4px;
      margin: 4px 0 2px;
    }
    [${UI_ATTR}="choice-row"] {
      display: flex; align-items: center; gap: 10px;
      width: 100%;
      padding: 7px 10px;
      border: 1px solid transparent;
      border-radius: var(--dddk-radius-sm, 6px);
      background: var(--dddk-row-bg, transparent);
      color: inherit;
      font: inherit;
      cursor: pointer;
      text-align: left;
    }
    [${UI_ATTR}="choice-row"]:hover,
    [${UI_ATTR}="choice-row"]:focus-visible {
      background: var(--dddk-row-hover, rgba(0,0,0,0.05));
      border-color: var(--dddk-border-subtle, rgba(0,0,0,0.08));
      outline: none;
    }
    [${UI_ATTR}="choice-key"] {
      display: inline-flex; align-items: center; justify-content: center;
      min-width: 22px; height: 20px;
      padding: 0 6px;
      border-radius: 4px;
      background: var(--dddk-kbd-bg, rgba(0,0,0,0.06));
      color: var(--dddk-text-muted, #6b6b6b);
      font-size: 11px;
      font-family: var(--dddk-mono, ui-monospace, monospace);
      font-weight: 600;
    }
    [${UI_ATTR}="choice-label"] { font-size: var(--dddk-font-size-md, 14px); }
    [${UI_ATTR}="choice-freetext"] { margin-top: 4px; }
    [${UI_ATTR}="choice-input"] {
      width: 100%;
      padding: 7px 10px;
      border: 1px solid var(--dddk-border, rgba(0,0,0,0.12));
      border-radius: var(--dddk-radius-sm, 6px);
      background: var(--dddk-input-bg, transparent);
      color: inherit;
      font: inherit;
    }
    [${UI_ATTR}="choice-input"]:focus {
      outline: none;
      border-color: var(--dddk-accent, #ec4899);
    }
    /* Listening / processing indicator chrome. Positioning comes from
       placement.ts; below is only the visual styling.

       Background is the ACCENT colour (not bg-elevated) so the indicator
       is clearly visible on any theme \u2014 dark sites kept getting an
       invisible "black on black" indicator with the previous bg-elevated
       fallback. Text + dots are on-accent for high contrast.

       The "listening" state also gets a pulsing ring (box-shadow keyframe)
       to make it OBVIOUS that the mic is hot. The previous version had
       only the bouncing dots, which was easy to miss. */
    [${UI_ATTR}="indicator"] {
      background: var(--dddk-accent, #6366f1);
      color: var(--dddk-text-on-accent, #fff);
      border-radius: var(--dddk-radius-pill, 999px);
      padding: 10px 20px;
      box-shadow: var(--dddk-shadow-md, 0 6px 20px rgba(0,0,0,0.25));
      display: flex; align-items: center; gap: 10px;
      font-size: var(--dddk-font-size-md, 14px);
      font-weight: 600;
      border: 1px solid rgba(255,255,255,0.15);
    }
    /* Mobile: pill was visually too tall \u2014 the desktop 10/20 padding
       wasted vertical space on narrow viewports. Tighter padding +
       smaller dots keeps the capsule readable but less obtrusive. */
    @media (max-width: 480px) {
      [${UI_ATTR}="indicator"] {
        padding: 6px 14px;
        gap: 8px;
        font-size: var(--dddk-font-size-sm, 13px);
      }
      [${UI_ATTR}="indicator-dots"] span {
        width: 6px; height: 6px;
      }
      [${UI_ATTR}="indicator-check"] {
        font-size: 14px;
      }
    }
    [${UI_ATTR}="indicator"][data-state="listening"] {
      animation: dddk-indicator-pulse 1.4s ease-in-out infinite;
    }
    [${UI_ATTR}="indicator"][data-state="done"] {
      background: var(--dddk-success, #16a34a);
    }
    [${UI_ATTR}="indicator-check"] {
      font-size: 16px; line-height: 1;
    }
    [${UI_ATTR}="indicator-dots"] {
      display: inline-flex; gap: 4px;
    }
    [${UI_ATTR}="indicator-dots"] span {
      width: 7px; height: 7px; border-radius: 50%;
      background: var(--dddk-text-on-accent, #fff);
      animation: dddk-dot-bounce 1.2s infinite ease-in-out;
    }
    [${UI_ATTR}="indicator-dots"] span:nth-child(2) { animation-delay: 0.15s; }
    [${UI_ATTR}="indicator-dots"] span:nth-child(3) { animation-delay: 0.3s; }
    [${UI_ATTR}="indicator-label"] { letter-spacing: 0.02em; }
    @keyframes dddk-dot-bounce {
      0%, 80%, 100% { transform: scale(0.7); opacity: 0.55; }
      40% { transform: scale(1); opacity: 1; }
    }
    @keyframes dddk-indicator-pulse {
      0%, 100% {
        box-shadow:
          var(--dddk-shadow-md, 0 6px 20px rgba(0,0,0,0.25)),
          0 0 0 0 rgba(99, 102, 241, 0.55);
      }
      50% {
        box-shadow:
          var(--dddk-shadow-md, 0 6px 20px rgba(0,0,0,0.25)),
          0 0 0 12px rgba(99, 102, 241, 0);
      }
    }
  `);
}
export {
  Subtitle
};
