From 4d84afd65be80918dd6b56ae027f44a540671319 Mon Sep 17 00:00:00 2001
From: theoriginalcheese <41898282+theoriginalcheese@users.noreply.github.com>
Date: Wed, 5 Aug 2026 19:54:28 +0100
Subject: [PATCH] Windows Nebula WebView2 MCQ (glass default, Cursor-safe).
Frameless pywebview dialog with glass theme + starfield; freeze hardening under Cursor; Edge/tk fallbacks; docs and skill updates for Windows.
---
README.md | 3 +-
docs/WINDOWS.md | 41 +-
pyproject.toml | 1 +
scripts/test_windows_backend.py | 9 +-
skills/ask-multiple-choice/SKILL.md | 97 +--
src/ask_question_mcp/assets/dialog/dialog.css | 795 ++++++++++++++++++
src/ask_question_mcp/assets/dialog/dialog.js | 427 ++++++++++
src/ask_question_mcp/assets/dialog/entry.html | 133 +++
src/ask_question_mcp/assets/dialog/index.html | 69 ++
.../assets/dialog/manifest.webmanifest | 10 +
src/ask_question_mcp/assets/dialog/tokens.css | 102 +++
src/ask_question_mcp/capabilities.py | 5 +-
src/ask_question_mcp/doctor.py | 135 ++-
src/ask_question_mcp/platform_info.py | 2 +-
src/ask_question_mcp/win_edge_ask.py | 747 ++++++++++++++++
src/ask_question_mcp/win_list_ask.py | 39 +-
src/ask_question_mcp/win_webview_ask.py | 448 ++++++++++
src/ask_question_mcp/win_webview_entry_ask.py | 264 ++++++
src/ask_question_mcp/zenity_ask.py | 398 +++++++--
uv.lock | 192 ++++-
20 files changed, 3748 insertions(+), 169 deletions(-)
create mode 100644 src/ask_question_mcp/assets/dialog/dialog.css
create mode 100644 src/ask_question_mcp/assets/dialog/dialog.js
create mode 100644 src/ask_question_mcp/assets/dialog/entry.html
create mode 100644 src/ask_question_mcp/assets/dialog/index.html
create mode 100644 src/ask_question_mcp/assets/dialog/manifest.webmanifest
create mode 100644 src/ask_question_mcp/assets/dialog/tokens.css
create mode 100644 src/ask_question_mcp/win_edge_ask.py
create mode 100644 src/ask_question_mcp/win_webview_ask.py
create mode 100644 src/ask_question_mcp/win_webview_entry_ask.py
diff --git a/README.md b/README.md
index f3f5b2b..1917627 100644
--- a/README.md
+++ b/README.md
@@ -127,7 +127,8 @@ Full table: [Dialog UX (humans)](docs/AGENTS.md#dialog-ux-humans).
|-------|------|----|-------|--------|
| Ubuntu 24.04 + GNOME + PipeWire | Cursor | Yes | Yes | **Verified** (2026-07) |
| Same stack | Claude Code | Yes | Text-only | **Verified** (2026-07) — [@jackghx](https://github.com/jackghx) |
-| Windows 10/11 + tkinter | Cursor | Text | No | Phase 1 — **not yet reported** |
+| Windows 10/11 + WebView2 | Cursor | Text (Nebula UI) | No | Phase 2 UI — **Anthony laptop** |
+| Windows 10/11 + tkinter | Cursor | Text | No | Fallback if pywebview missing |
| macOS / headless CI | — | No | No | Unsupported / N/A |
More rows & how to report: [CONTRIBUTING.md](CONTRIBUTING.md).
diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md
index c1db673..8ebf0bd 100644
--- a/docs/WINDOWS.md
+++ b/docs/WINDOWS.md
@@ -1,4 +1,4 @@
-# Windows (Anthony / Cursor) — Phase 1 handoff
+# Windows (Anthony / Cursor) — WebView2 Nebula dialog
Target user: **Anthony** ([@TheRealCheese](https://github.com/TheRealCheese)).
Goal: text-only `ask_multiple_choice` on **Cursor for Windows** without WSL.
@@ -6,19 +6,28 @@ Goal: text-only `ask_multiple_choice` on **Cursor for Windows** without WSL.
Canonical install steps: this file (and a one-line pointer from the
[README](../README.md#quick-start-linux)).
+## UI backend
+
+| Priority | Backend | Notes |
+| --- | --- | --- |
+| 1 (default) | **WebView2 + pywebview** | Nebula-styled floating dialog (`win_webview_ask.py`) |
+| 2 | tkinter | Fallback (`win_list_ask.py`) if pywebview missing |
+
+Force with env: `ASK_QUESTION_WIN_UI=webview` or `ASK_QUESTION_WIN_UI=tk`.
+
## Checklist for Anthony
-1. Python 3.12+ from python.org with **tcl/tk** → `python -c "import tkinter; print('ok')"`.
+1. Python 3.12+ (python.org). Edge WebView2 is already on Windows 11.
2. Install uv → `where uv` (absolute path to `uv.exe`).
-3. `git clone https://github.com/DynamicDevices/ask-question-mcp.git` then:
+3. In the checkout:
```bat
uv sync
uv run ask-question-install --host cursor --skill
```
- (or `git pull` + the same if you already have a clone).
4. Cursor → **Developer: Reload Window**.
-5. Ask the agent: call **`check_setup`** (expect `ready.ui` / `ready.text_mcq` true; `audio_mode` text_only).
-6. Smoke **`ask_multiple_choice`** — dialog should appear on top; pick an option.
+5. Ask the agent: call **`check_setup`** (expect `ready.ui` / `ready.text_mcq` true;
+ `webview` ok; `audio_mode` text_only).
+6. Smoke **`ask_multiple_choice`** — Nebula WebView2 dialog on top; pick an option.
7. Smoke **keyboard** — labels show `1 · …`; press **2**, wait for OK to arm,
**Enter**. **Esc** cancels. Footer hint: `1–8 select · Enter OK · Esc cancel`.
8. Smoke **Something else** — every MCQ should include a freeform row / entry; typing
@@ -37,30 +46,22 @@ Manual mcp.json edit is still fine if you skip the installer — use absolute
## Behaviour parity (vs Linux)
-Shared path (`zenity_ask` → `win_list_ask.py`):
-
-| Behaviour | Windows Phase 1 |
+| Behaviour | Windows |
| --- | --- |
| Something else always offered | Yes (same as Linux; `allow_other` ignored) |
| Danger mark **⛔** + confirm arm | Yes (`danger_arm.py`) |
| Danger banner wording | **⛔ Confirm** + lead ask (pink banner) |
-| Lead / detail (ask visible; tall referent scrolls) | Yes (`split_lead_detail`) |
+| Lead / detail (ask visible; tall referent scrolls) | Yes (`split_lead_detail` on tk; WebView body scrolls) |
| Red OK on danger | Yes |
| Voice / duck / STT | No (text-only) |
-| Image / images preview | No (ignored; text-only) |
-| Gtk footer / scroll layout fixes | N/A (tkinter layout) |
+| Image / images preview | No (ignored; text-only for now) |
| 1–8 hotkeys + Enter / Esc | Yes |
| Remember size/position | Yes (`prefs.window`) |
-| Scrollable options | Yes |
+| Aesthetic | Nebula glass WebView2 (frameless); tk fallback; optional Edge `--app` |
-## Out of scope (Phase 1)
+## Out of scope
- Spoken questions / mic answers / media duck
- WSL as the supported path
- macOS
-
-## After Anthony verifies
-
-Maintainers: update README Tested platforms Windows row to **Verified**, and
-optionally add a `VERIFIED_PLATFORMS` entry with `"system": "windows"` in
-[`platform_info.py`](../src/ask_question_mcp/platform_info.py).
+- Hosting the dialog *inside* the Cursor chat chrome (MCP has no custom panel API yet)
diff --git a/pyproject.toml b/pyproject.toml
index a1160ca..0739489 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,6 +11,7 @@ authors = [
]
dependencies = [
"mcp[cli]>=1.9.0",
+ "pywebview>=5.0",
]
[project.urls]
diff --git a/scripts/test_windows_backend.py b/scripts/test_windows_backend.py
index aa00b60..5f08973 100644
--- a/scripts/test_windows_backend.py
+++ b/scripts/test_windows_backend.py
@@ -15,6 +15,8 @@
from ask_question_mcp.zenity_ask import (
_WIN_ENTRY_ASK,
_WIN_LIST_ASK,
+ _WIN_WEBVIEW_ASK,
+ _WIN_WEBVIEW_ENTRY_ASK,
ui_backend,
)
@@ -22,6 +24,8 @@
def main() -> None:
assert _WIN_LIST_ASK.is_file(), _WIN_LIST_ASK
assert _WIN_ENTRY_ASK.is_file(), _WIN_ENTRY_ASK
+ assert _WIN_WEBVIEW_ASK.is_file(), _WIN_WEBVIEW_ASK
+ assert _WIN_WEBVIEW_ENTRY_ASK.is_file(), _WIN_WEBVIEW_ENTRY_ASK
if sys.platform == "win32":
assert ui_backend() == "win"
@@ -75,7 +79,10 @@ def main() -> None:
assert caps.audio_mode == "text_only"
assert caps.speak_active is False
assert caps.listen_active is False
- assert any("Windows Phase 1" in n for n in caps.notes)
+ assert any(
+ "Windows" in n and ("WebView2" in n or "Phase 1" in n or "tkinter" in n)
+ for n in caps.notes
+ )
# Ensure Linux UI-ready still requires DISPLAY when not Windows.
if sys.platform != "win32":
diff --git a/skills/ask-multiple-choice/SKILL.md b/skills/ask-multiple-choice/SKILL.md
index f5df775..6ac5020 100644
--- a/skills/ask-multiple-choice/SKILL.md
+++ b/skills/ask-multiple-choice/SKILL.md
@@ -1,19 +1,27 @@
---
name: ask-multiple-choice
description: >-
- Prefer desktop MCP ask_multiple_choice for decision forks — not markdown
- A/B/C. Use when choosing or confirming. Pass image=/images= when the human
- must judge a still in the dialog.
+ Desktop MCP ask_multiple_choice for every decision fork — Anthony's opinion
+ when stuck, choosing paths, confirming work, or picking options. Prefer this
+ over markdown A/B/C or host AskQuestion. Supports multi-select and optional
+ image= / images= previews. Use whenever the ask-question MCP is available
+ and a human choice is needed.
---
# Ask multiple choice (desktop MCP)
-**Need-band:** freedom — faster decisions without chat A/B/C noise.
+Cursor stand-in for Claude Code **AskUserQuestion**. **Need-band:** freedom —
+faster decisions without chat A/B/C noise.
-## When (auto)
+## When (auto — do not wait to be asked)
-Any **decision fork**: ship/wait, pick a path, confirm irreversible work,
-choose among options the human must decide.
+Any fork that needs **Anthony's opinion**, including when you are **stuck**:
+
+- ship / wait / pick a path
+- confirm irreversible or risky work
+- choose among concrete options
+- several answers may apply together → multi-select
+- preference unknown — ask; do not guess
## Do
@@ -27,63 +35,36 @@ choose among options the human must decide.
(always fully visible); put Command/To+body/path **before** meta notes.
Do **not** dump process templates, PATTERN blocks, or long meta into routine
forks. No meta about dialogs/voice.
-4. **Permission / action asks (Alex 2026-07-30):** state **what** Briar will do
- **and why** (one sentence each is enough). Opaque “run Task agents?” /
- “proceed?” without purpose is not enough — Alex must understand the aim.
+4. **Permission / action asks:** state **what** will happen **and why** (one
+ sentence each is enough). Opaque “proceed?” without purpose is not enough.
Pattern: `mcq-permission-what-and-why`.
-5. Mark preferred only as **`Label (recommended)`** + **`recommended_id`**.
-6. **`dangerous=true`** for irreversible / high-risk forks.
-7. **Images the human must judge** (Alex loves this — signed-off 2026-08-03):
- pass **`image=`** (one path / `file://` URI) or **`images=`** (list, max 4).
- Chat `Read` of a PNG does **not** put pixels in the MCQ — agents **must**
- pass the path into the dialog. Linux Gtk: opens large on the **primary**
- usable workarea (not the largest secondary); human can **click the preview**
- (large ↔ compact ~320px) and **maximize** (header button or **F**).
- **P0 — multi-image must never exceed primary usable resolution** (stack
- shares one height budget / scrolls inside). Text-only MCQs stay compact.
- Pattern: `mcq-with-image`.
-8. Wait for the JSON result. On cancel → stop. On freeform → use **`freeform_text`**.
-
-Humans use the dialog keyboard (**1–8**, Enter, Esc; **F** maximize when images);
-do not put hotkey instructions in `question`. Detail: repo `docs/AGENTS.md`
-(Dialog UX).
-
-## Voice — who is “I”? (Alex 2026-07-30)
-
-MCQs must not use ambiguous **I** / **you** for actions.
-
-| Role | How to refer |
-|------|----------------|
-| Human | **Alex** (or “Alex will…”) |
-| This assistant | **Briar** (or “Briar will…”) |
-
-**Do:** `Briar will restart the webhook` · `Alex approves the send` · option
-labels like `Briar sends now` / `Alex will edit first`.
-**Don't:** `I'll capture…` / `you refresh…` / `I mean the agent…` when either
-party could be “I”.
-
-Casual chat outside MCQs may still use normal I/you; **MCQ question + option
-labels** stay role-named. Pattern: `mcq-named-roles-alex-briar`.
+5. Mark preferred only as **`Label (recommended)`** + **`recommended_id`**
+ (or **`recommended_ids`** for multi).
+6. Set **`allow_multiple=true`** when more than one option can be correct together
+ (checklist). Default single-select otherwise.
+7. **`dangerous=true`** for irreversible / high-risk forks.
+8. **Images the human must judge:** pass **`image=`** (one path / `file://`) or
+ **`images=`** (list). Chat `Read` of a PNG does **not** put pixels in the MCQ.
+ Linux Gtk shows the preview in-dialog; Windows WebView path may ignore images
+ until wired.
+9. Wait for the JSON result.
+ - Cancel → **stop** (do not invent a choice).
+ - Freeform → honour **`freeform_text`**.
+ - Multi → use **`ids`** / **`labels`**.
+
+Humans use dialog keys (**1–8**, Enter, Esc); do not put hotkey text in
+`question`. Detail: repo `docs/AGENTS.md` (Dialog UX).
## Don't
- Markdown A/B/C, numbered lists, or host AskQuestion when this MCP is available
-- Desktop MCQ for clarifications / next steps when the ask originated over
- **WhatsApp admin inbound** — keep those on WhatsApp (Charlize voice or short
- text) per `cursor-pa-whatsapp` SOUL; desktop MCQ is for Cursor-session forks
- not originated on WA (unless Alex opts in). P0 send-gates to third parties
- may still use desktop MCQ.
-- Asking Alex to judge a still that exists only in chat when the dialog can take
- **`image=`** / **`images=`**
-- “Send now?” / “Ship it?” with no body/path when the human has not seen the draft
-- Permission MCQs that name a tool/action but omit **why** (Task agents, long
- scans, enable MCP, restart services, etc.)
-- Stuffing PATTERN/PROPOSAL/OWNS walls into every MCQ
-- Soft MCQs before a send-gate (“draft OK?”, “ready?”, “shall Briar send?”) —
- draft in chat, then **one** send-gate only (`email-one-send-gate`)
+- Asking the human to judge a still that exists only in chat when the dialog can
+ take **`image=`** / **`images=`**
+- Soft MCQs before a real send-gate — draft in chat, then one confirm
- `check_setup` before routine MCQs (only first enable, dialog failure, or before voice)
- Invent a choice after `cancelled: true`
-- Ambiguous **I/you** in MCQ question or option labels (use Alex / Briar)
+- Skip the dialog because the skill wasn't @-mentioned — the always-on user rule
+ still applies when the MCP is loaded
## Setup (humans)
@@ -92,4 +73,4 @@ cd /path/to/ask-question-mcp && uv sync
uv run ask-question-install --host cursor --skill
```
-Then reload the host. Detail: repo `docs/AGENTS.md`.
+Then reload the host. Windows: see `docs/WINDOWS.md`. Detail: `docs/AGENTS.md`.
diff --git a/src/ask_question_mcp/assets/dialog/dialog.css b/src/ask_question_mcp/assets/dialog/dialog.css
new file mode 100644
index 0000000..bd30353
--- /dev/null
+++ b/src/ask_question_mcp/assets/dialog/dialog.css
@@ -0,0 +1,795 @@
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ background: var(--ground);
+ font-family: var(--font-ui);
+ color: var(--text);
+ color-scheme: dark;
+ -webkit-font-smoothing: antialiased;
+ user-select: none;
+ -webkit-user-select: none;
+ scrollbar-width: none;
+}
+
+*::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+ display: none;
+}
+
+body {
+ display: grid;
+ place-items: stretch;
+ position: relative;
+}
+
+/* Atmosphere lives inside .app so stars show through translucent .core. */
+.aura {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ z-index: 0;
+ overflow: hidden;
+}
+
+.aura-grain {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ z-index: 2;
+ opacity: 0.04;
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
+ mix-blend-mode: overlay;
+}
+
+.aura::before,
+.aura::after {
+ content: "";
+ position: absolute;
+ border-radius: 50%;
+ filter: blur(64px);
+ opacity: 0.35;
+ will-change: transform;
+ animation: aura-drift 18s var(--ease-spring) infinite alternate;
+}
+
+.aura::before {
+ width: 55vmin;
+ height: 55vmin;
+ top: -12%;
+ right: -8%;
+ background: rgb(var(--orb-a) / 0.45);
+}
+
+.aura::after {
+ width: 48vmin;
+ height: 48vmin;
+ bottom: -18%;
+ left: -12%;
+ background: rgb(var(--orb-b) / 0.28);
+ animation-delay: -6s;
+ animation-duration: 22s;
+}
+
+[data-theme="ink"] .aura::before,
+[data-theme="ink"] .aura::after {
+ opacity: 0.12;
+ filter: blur(80px);
+}
+
+@keyframes aura-drift {
+ from {
+ transform: translate(0, 0) scale(1);
+ }
+ to {
+ transform: translate(-4%, 6%) scale(1.08);
+ }
+}
+
+/* Nebula star dust — one box-shadow field per layer, shared wind gust
+ (from nebula/spike/web). Height 200% + path to -50% Y for seamless wrap. */
+.stars {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 200%;
+ pointer-events: none;
+ animation: wind-drift var(--star-speed-near, 90s) linear infinite;
+}
+
+.stars.near {
+ animation-duration: var(--star-speed-near, 90s);
+}
+
+.stars.far {
+ animation-duration: var(--star-speed-far, 150s);
+ animation-delay: calc(var(--star-speed-far, 150s) * -0.32);
+ opacity: 0.78;
+}
+
+@keyframes wind-drift {
+ 0% {
+ transform: translate3d(0, 0, 0);
+ }
+ 18% {
+ transform: translate3d(18px, -9%, 0);
+ }
+ 38% {
+ transform: translate3d(32px, -19%, 0);
+ }
+ 58% {
+ transform: translate3d(20px, -31%, 0);
+ }
+ 78% {
+ transform: translate3d(3px, -41%, 0);
+ }
+ 100% {
+ transform: translate3d(-10px, -50%, 0);
+ }
+}
+
+.star {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 1px;
+ height: 1px;
+ background: transparent;
+ border-radius: 50%;
+}
+
+.star-dot {
+ position: absolute;
+ width: 3px;
+ height: 3px;
+ border-radius: 999px;
+ background: rgb(210 200 255 / 0.9);
+ box-shadow: 0 0 10px rgb(180 160 255 / 0.65);
+ opacity: 0.9;
+}
+
+.star-dot.is-far {
+ width: 2px;
+ height: 2px;
+ opacity: 0.55;
+ box-shadow: 0 0 6px rgb(180 160 255 / 0.4);
+}
+
+.star-dot.is-bright {
+ width: 4px;
+ height: 4px;
+ background: #fff;
+ box-shadow: 0 0 14px rgb(200 180 255 / 0.85);
+ opacity: 1;
+}
+
+.app {
+ position: relative;
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ background: transparent;
+ border: 0;
+ border-radius: 0;
+ opacity: 0;
+ transform: translateY(16px);
+ transition:
+ opacity var(--enter-ms) var(--ease),
+ transform var(--enter-ms) var(--ease);
+}
+
+.app.is-ready {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.app.is-leaving {
+ opacity: 0;
+ transform: translateY(12px) scale(0.985);
+ pointer-events: none;
+ transition:
+ opacity var(--leave-ms) var(--ease),
+ transform var(--leave-ms) var(--ease);
+}
+
+.core {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ border-radius: 0;
+ background:
+ linear-gradient(180deg, rgb(255 255 255 / 0.035), transparent 32%),
+ rgb(var(--ground-rgb) / 0.55);
+ box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06);
+ overflow: hidden;
+}
+
+.chrome {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 16px 18px 10px;
+ cursor: default;
+ background: transparent;
+ opacity: 0;
+ transform: translateY(8px);
+ transition:
+ opacity 640ms var(--ease),
+ transform 640ms var(--ease);
+ transition-delay: 40ms;
+}
+
+.app.is-ready .chrome {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.chrome-left {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.eyebrow {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 11px;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--accent-text);
+ background: rgb(var(--accent-rgb) / 0.12);
+ border: 1px solid rgb(var(--accent-rgb) / 0.22);
+ box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.08);
+}
+
+.eyebrow.is-danger {
+ color: var(--danger-text);
+ background: var(--danger-bg);
+ border-color: var(--danger-border);
+}
+
+.agent {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12.5px;
+ font-weight: 500;
+ letter-spacing: 0.01em;
+ color: var(--text-secondary);
+}
+
+.close-btn {
+ width: 30px;
+ height: 30px;
+ border: 0;
+ border-radius: 999px;
+ background: rgb(var(--hairline-rgb) / 0.06);
+ color: var(--text-secondary);
+ cursor: pointer;
+ display: grid;
+ place-items: center;
+ box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06);
+ transition:
+ background var(--hover-ms) var(--ease),
+ color var(--hover-ms) var(--ease),
+ transform var(--press-ms) var(--ease);
+}
+
+.close-btn:hover {
+ background: rgb(var(--hairline-rgb) / 0.12);
+ color: var(--text);
+}
+
+.close-btn:active {
+ transform: scale(var(--press-scale));
+}
+
+.close-btn svg {
+ width: 12px;
+ height: 12px;
+ stroke: currentColor;
+ stroke-width: 1.15;
+ fill: none;
+ stroke-linecap: round;
+}
+
+.body {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 6px 18px 0;
+ overflow: hidden;
+}
+
+.question {
+ margin: 0;
+ font-size: 19px;
+ font-weight: 560;
+ letter-spacing: -0.03em;
+ line-height: 1.32;
+ color: var(--text);
+ opacity: 0;
+ transform: translateY(12px);
+ transition:
+ opacity 720ms var(--ease),
+ transform 720ms var(--ease);
+ transition-delay: 90ms;
+}
+
+.app.is-ready .question {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+[data-theme="ink"] .question,
+[data-theme="hybrid"] .question {
+ font-weight: 520;
+ letter-spacing: -0.035em;
+}
+
+.banner {
+ display: none;
+ align-items: stretch;
+ gap: 0;
+ padding: 2px;
+ border-radius: calc(var(--r-tile) + 2px);
+ background: rgb(var(--ember-rgb) / 0.12);
+ border: 1px solid rgb(var(--ember-rgb) / 0.22);
+ overflow: hidden;
+}
+
+.banner.is-on {
+ display: flex;
+}
+
+.banner-inner {
+ display: flex;
+ flex: 1;
+ border-radius: var(--r-tile);
+ overflow: hidden;
+ background: var(--danger-bg);
+}
+
+.banner-accent {
+ width: 3px;
+ background: var(--ember);
+ flex: 0 0 auto;
+}
+
+.banner-copy {
+ padding: 11px 13px;
+ font-size: 12.5px;
+ line-height: 1.4;
+ color: var(--danger-text);
+}
+
+.options {
+ flex: 1 1 auto;
+ min-height: 48px;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 2px 2px 8px;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+/* Double-bezel option tiles */
+.option-shell {
+ padding: 2px;
+ border-radius: calc(var(--r-tile) + 2px);
+ background: rgb(var(--hairline-rgb) / 0.04);
+ border: 1px solid rgb(var(--hairline-rgb) / 0.07);
+ opacity: 0;
+ transform: translateY(14px);
+ transition:
+ opacity 700ms var(--ease),
+ transform 700ms var(--ease),
+ border-color var(--hover-ms) var(--ease),
+ background var(--hover-ms) var(--ease);
+}
+
+.app.is-ready .option-shell {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.option-shell.is-selected {
+ background: rgb(var(--accent-rgb) / 0.14);
+ border-color: rgb(var(--accent-rgb) / 0.32);
+}
+
+.option-shell.is-danger.is-selected {
+ background: rgb(var(--ember-rgb) / 0.12);
+ border-color: rgb(var(--ember-rgb) / 0.32);
+}
+
+.option {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ padding: 13px 14px;
+ border: 0;
+ border-radius: var(--r-tile-inner);
+ background: rgb(var(--card-core-rgb) / 0.88);
+ color: var(--text);
+ text-align: left;
+ cursor: pointer;
+ box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.07);
+ transition:
+ background var(--hover-ms) var(--ease),
+ transform var(--press-ms) var(--ease),
+ box-shadow var(--hover-ms) var(--ease);
+}
+
+.option:hover {
+ background: rgb(var(--raised-rgb) / 0.72);
+}
+
+.option:active {
+ transform: scale(var(--press-scale));
+}
+
+.option-shell.is-selected .option {
+ background: rgb(var(--accent-rgb) / 0.14);
+ box-shadow:
+ inset 0 1px 0 rgb(255 255 255 / 0.1),
+ 0 0 0 1px rgb(var(--accent-rgb) / 0.08);
+}
+
+[data-theme="signal"] .option-shell.is-selected .option {
+ box-shadow:
+ inset 0 1px 0 rgb(255 255 255 / 0.1),
+ 0 0 24px rgb(var(--accent-rgb) / 0.18);
+}
+
+.option-shell.is-danger.is-selected .option {
+ background: rgb(var(--ember-rgb) / 0.12);
+}
+
+.hotkey {
+ flex: 0 0 auto;
+ width: 24px;
+ height: 24px;
+ border-radius: 8px;
+ display: grid;
+ place-items: center;
+ font-size: 11px;
+ font-weight: 600;
+ font-family: var(--font-mono);
+ color: var(--text-tertiary);
+ background: rgb(var(--hairline-rgb) / 0.05);
+ border: 1px solid rgb(var(--hairline-rgb) / 0.08);
+ transition:
+ color var(--hover-ms) var(--ease),
+ background var(--hover-ms) var(--ease),
+ border-color var(--hover-ms) var(--ease);
+}
+
+.option-shell.is-selected .hotkey {
+ color: var(--accent-text);
+ background: rgb(var(--accent-rgb) / 0.18);
+ border-color: rgb(var(--accent-rgb) / 0.28);
+}
+
+.option-label {
+ flex: 1;
+ min-width: 0;
+ font-size: 13.5px;
+ line-height: 1.35;
+ letter-spacing: -0.01em;
+}
+
+.rec-pill {
+ flex: 0 0 auto;
+ padding: 4px 9px;
+ border-radius: 999px;
+ font-size: 9px;
+ font-weight: 650;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--accent-text);
+ background: rgb(var(--accent-rgb) / 0.14);
+ border: 1px solid rgb(var(--accent-rgb) / 0.22);
+}
+
+.mark {
+ flex: 0 0 auto;
+ font-size: 12px;
+ opacity: 0.9;
+}
+
+.freeform {
+ flex: 0 0 auto;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px 18px 0;
+ border-top: 1px solid rgb(var(--hairline-rgb) / 0.06);
+ opacity: 0;
+ transform: translateY(10px);
+ transition:
+ opacity 680ms var(--ease),
+ transform 680ms var(--ease);
+ transition-delay: 160ms;
+}
+
+.app.is-ready .freeform {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.freeform-label {
+ font-size: 10.5px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--text-eyebrow);
+}
+
+.freeform-shell {
+ padding: 3px;
+ border-radius: calc(var(--r-control) + 3px);
+ background: rgb(var(--hairline-rgb) / 0.045);
+ border: 1px solid rgb(var(--hairline-rgb) / 0.08);
+}
+
+.freeform-input {
+ width: 100%;
+ min-height: 58px;
+ max-height: 120px;
+ padding: 11px 13px;
+ border: 0;
+ border-radius: var(--r-control);
+ background: rgb(var(--card-core-rgb) / 0.92);
+ color: var(--text);
+ font: inherit;
+ font-size: 13px;
+ line-height: 1.45;
+ outline: none;
+ resize: vertical;
+ box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.05);
+ transition: box-shadow var(--hover-ms) var(--ease);
+ user-select: text;
+ -webkit-user-select: text;
+}
+
+.freeform-input::placeholder {
+ color: var(--text-tertiary);
+}
+
+.freeform-input:focus {
+ box-shadow:
+ inset 0 1px 0 rgb(255 255 255 / 0.05),
+ 0 0 0 2px rgb(var(--accent-rgb) / 0.32);
+}
+
+.footer {
+ flex: 0 0 auto;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 18px 16px;
+ opacity: 0;
+ transform: translateY(10px);
+ transition:
+ opacity 680ms var(--ease),
+ transform 680ms var(--ease);
+ transition-delay: 200ms;
+}
+
+.app.is-ready .footer {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.hint {
+ font-size: 11px;
+ color: var(--text-tertiary);
+ letter-spacing: 0.01em;
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ height: 42px;
+ padding: 0 18px 0 20px;
+ border: 0;
+ border-radius: 999px;
+ font: inherit;
+ font-size: 13px;
+ font-weight: 550;
+ letter-spacing: -0.01em;
+ cursor: pointer;
+ transition:
+ background var(--hover-ms) var(--ease),
+ color var(--hover-ms) var(--ease),
+ transform var(--press-ms) var(--ease),
+ opacity var(--hover-ms) var(--ease),
+ box-shadow var(--hover-ms) var(--ease);
+}
+
+.btn:active:not(:disabled) {
+ transform: scale(var(--press-scale));
+}
+
+.btn:disabled {
+ opacity: 0.42;
+ cursor: default;
+}
+
+.btn-ghost {
+ background: rgb(var(--hairline-rgb) / 0.06);
+ color: var(--text-secondary);
+ box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.05);
+}
+
+.btn-ghost:hover:not(:disabled) {
+ background: rgb(var(--hairline-rgb) / 0.1);
+ color: var(--text);
+}
+
+.option-check {
+ flex: 0 0 auto;
+ width: 18px;
+ height: 18px;
+ border-radius: 999px;
+ border: 1px solid rgb(var(--hairline-rgb) / 0.14);
+ background: transparent;
+ display: grid;
+ place-items: center;
+ opacity: 0;
+ transform: scale(0.7);
+ transition:
+ opacity var(--hover-ms) var(--ease),
+ transform var(--hover-ms) var(--ease),
+ background var(--hover-ms) var(--ease),
+ border-color var(--hover-ms) var(--ease);
+}
+
+.option-check svg {
+ width: 10px;
+ height: 10px;
+ stroke: var(--ground-deep);
+ stroke-width: 2;
+ fill: none;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.option-shell.is-selected .option-check {
+ opacity: 1;
+ transform: scale(1);
+ background: var(--accent-text);
+ border-color: transparent;
+}
+
+.btn-primary {
+ position: relative;
+ overflow: hidden;
+ background: var(--text);
+ color: var(--ground-deep);
+ box-shadow: 0 8px 28px rgb(0 0 0 / 0.22);
+}
+
+.btn-primary .arm-fill {
+ position: absolute;
+ inset: 0 auto 0 0;
+ width: 0%;
+ background: rgb(var(--accent-rgb) / 0.22);
+ pointer-events: none;
+ transition: width 200ms linear;
+}
+
+.btn-primary.is-arming .arm-fill {
+ transition: width linear;
+}
+
+.btn-primary .btn-label,
+.btn-primary .btn-icon {
+ position: relative;
+ z-index: 1;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background: #fff;
+}
+
+.btn-primary.is-danger {
+ background: var(--ember);
+ color: #1a0610;
+}
+
+.btn-primary.is-danger:hover:not(:disabled) {
+ background: #ff7a94;
+}
+
+.btn-icon {
+ width: 28px;
+ height: 28px;
+ margin-right: -8px;
+ border-radius: 999px;
+ display: grid;
+ place-items: center;
+ background: rgb(0 0 0 / 0.12);
+ transition: transform var(--hover-ms) var(--ease);
+}
+
+.btn-primary:hover:not(:disabled) .btn-icon {
+ transform: translate(1px, -1px) scale(1.06);
+}
+
+.btn-icon svg {
+ width: 12px;
+ height: 12px;
+ stroke: currentColor;
+ stroke-width: 1.35;
+ fill: none;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .aura::before,
+ .aura::after,
+ .stars,
+ .app,
+ .chrome,
+ .question,
+ .option-shell,
+ .freeform,
+ .footer,
+ .btn,
+ .btn-icon,
+ .close-btn,
+ .option {
+ animation: none !important;
+ transition: none !important;
+ }
+
+ .app,
+ .chrome,
+ .question,
+ .option-shell,
+ .freeform,
+ .footer {
+ opacity: 1;
+ transform: none;
+ }
+}
diff --git a/src/ask_question_mcp/assets/dialog/dialog.js b/src/ask_question_mcp/assets/dialog/dialog.js
new file mode 100644
index 0000000..0099d84
--- /dev/null
+++ b/src/ask_question_mcp/assets/dialog/dialog.js
@@ -0,0 +1,427 @@
+(() => {
+ const OTHER_IDS = new Set(["other", "something_else", "something-else"]);
+ const state = {
+ payload: null,
+ selected: new Set(),
+ armed: false,
+ typing: false,
+ };
+
+ const $ = (sel) => document.querySelector(sel);
+
+ function esc(s) {
+ return String(s)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+ }
+
+ function stripHotkeyPrefix(label) {
+ return String(label || "").replace(/^\d+\s*[·.]\s*/, "").trim();
+ }
+
+ async function apiCall(name, ...args) {
+ // Edge --app / localhost bridge (no pywebview — killable, no destroy hang).
+ const bridge = window.__ASK_BRIDGE__;
+ if (bridge && typeof bridge === "string") {
+ if (name === "content_ready" || name === "resize_to") {
+ try {
+ await fetch(`${bridge}/event`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name, args }),
+ });
+ } catch (_) {
+ /* ignore */
+ }
+ return null;
+ }
+ const res = await fetch(`${bridge}/api`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name, args }),
+ });
+ if (!res.ok) {
+ throw new Error(`bridge api.${name} HTTP ${res.status}`);
+ }
+ const data = await res.json();
+ return data.result;
+ }
+
+ const api = window.pywebview && window.pywebview.api;
+ if (!api || typeof api[name] !== "function") {
+ throw new Error(`pywebview api.${name} unavailable`);
+ }
+ return api[name](...args);
+ }
+
+ function setArmed(armed) {
+ state.armed = armed;
+ const ok = $("#ok-btn");
+ if (!ok) return;
+ ok.disabled = !armed;
+ ok.classList.toggle("is-arming", !armed);
+ const danger = ok.classList.contains("is-danger");
+ if (armed) {
+ const fill = ok.querySelector(".arm-fill");
+ if (fill) fill.style.width = "100%";
+ ok.innerHTML = `
+
+ OK
+
+ `;
+ if (danger) ok.classList.add("is-danger");
+ }
+ }
+
+ function armCountdown(ms) {
+ const ok = $("#ok-btn");
+ if (ms <= 0) {
+ setArmed(true);
+ return;
+ }
+ setArmed(false);
+ ok.innerHTML = `
+
+ OK
+
+ `;
+ const fill = ok.querySelector(".arm-fill");
+ const started = performance.now();
+ const tick = (now) => {
+ if (state.armed) return;
+ const t = Math.min(1, (now - started) / ms);
+ if (fill) fill.style.width = `${(t * 100).toFixed(1)}%`;
+ if (t >= 1) {
+ setArmed(true);
+ return;
+ }
+ requestAnimationFrame(tick);
+ };
+ requestAnimationFrame(tick);
+ }
+
+ function spawnDots() {
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
+ return;
+ }
+ const layers = [
+ { sel: "#stars-near", n: 48, cls: "star-dot is-near" },
+ { sel: "#stars-far", n: 36, cls: "star-dot is-far" },
+ ];
+ layers.forEach(({ sel, n, cls }) => {
+ const el = $(sel);
+ if (!el) {
+ apiCall("debug", `stars missing ${sel}`).catch(() => {});
+ return;
+ }
+ el.innerHTML = "";
+ for (let i = 0; i < n; i += 1) {
+ const d = document.createElement("span");
+ d.className = cls;
+ if (i % 9 === 0) d.classList.add("is-bright");
+ d.style.left = `${(Math.random() * 100).toFixed(2)}%`;
+ d.style.top = `${(Math.random() * 100).toFixed(2)}%`;
+ el.appendChild(d);
+ }
+ apiCall("debug", `stars baked ${sel} n=${n}`).catch(() => {});
+ });
+ }
+
+ function renderOptions() {
+ const p = state.payload;
+ const box = $("#options");
+ box.innerHTML = "";
+ const dangerIds = new Set(p.danger_ids || []);
+ const recommended = new Set(p.recommended_ids || []);
+
+ (p.ids || []).forEach((id, i) => {
+ const shell = document.createElement("div");
+ shell.className = "option-shell";
+ shell.dataset.id = id;
+ shell.style.transitionDelay = `${100 + i * 70}ms`;
+ if (dangerIds.has(id)) shell.classList.add("is-danger");
+ if (state.selected.has(id)) shell.classList.add("is-selected");
+
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "option";
+ btn.dataset.id = id;
+ if (dangerIds.has(id)) btn.classList.add("is-danger");
+
+ const rawLabel = stripHotkeyPrefix(p.labels?.[id] || id);
+ const raw = rawLabel.replace(/\s*\(recommended\)\s*/gi, " ").trim() || rawLabel;
+ const mark = dangerIds.has(id) ? '⛔' : "";
+ const rec = recommended.has(id)
+ ? 'Recommended'
+ : "";
+ btn.innerHTML = `
+ ${i + 1}
+ ${esc(raw)}
+ ${rec}
+ ${mark}
+
+
+
+ `;
+ btn.addEventListener("click", () => onPick(id));
+ shell.appendChild(btn);
+ box.appendChild(shell);
+ });
+ }
+
+ function syncSelectionUi() {
+ document.querySelectorAll(".option-shell").forEach((el) => {
+ el.classList.toggle("is-selected", state.selected.has(el.dataset.id));
+ });
+ }
+
+ function onPick(id) {
+ const multi = !!state.payload.allow_multiple;
+ if (multi) {
+ if (state.selected.has(id)) state.selected.delete(id);
+ else state.selected.add(id);
+ } else {
+ state.selected = new Set([id]);
+ }
+ syncSelectionUi();
+ }
+
+ function freeformText() {
+ return ($("#freeform-input")?.value || "").trim();
+ }
+
+ async function leaveGently() {
+ const app = $("#app");
+ if (!app || app.classList.contains("is-leaving")) return;
+ app.classList.add("is-leaving");
+ await new Promise((r) => setTimeout(r, 280));
+ }
+
+ async function submit() {
+ if (!state.armed) return;
+ const typed = freeformText();
+ let ids = [...state.selected];
+ if (typed) {
+ const other = (state.payload.ids || []).find((id) => OTHER_IDS.has(id));
+ if (other && !ids.includes(other)) ids = [other];
+ if (!other) ids = ids.length ? ids : ["other"];
+ }
+ if (!ids.length) return;
+ await leaveGently();
+ try {
+ await apiCall("submit", ids, typed || null);
+ } catch (err) {
+ console.error(err);
+ }
+ try {
+ window.close();
+ } catch (_) {
+ /* Edge --app may ignore */
+ }
+ }
+
+ async function cancel(reason = "user cancelled") {
+ await leaveGently();
+ try {
+ await apiCall("cancel", reason);
+ } catch (err) {
+ console.error(err);
+ }
+ try {
+ window.close();
+ } catch (_) {
+ /* ignore */
+ }
+ }
+
+ function onKey(e) {
+ const typing =
+ document.activeElement &&
+ document.activeElement.id === "freeform-input";
+ if (e.key === "Escape") {
+ e.preventDefault();
+ cancel();
+ return;
+ }
+ if (e.key === "Enter" && !e.shiftKey) {
+ if (typing) {
+ // Enter submits; Shift+Enter inserts a newline in the textarea.
+ e.preventDefault();
+ const other = (state.payload.ids || []).find((id) => OTHER_IDS.has(id));
+ if (freeformText() && other) state.selected = new Set([other]);
+ submit();
+ return;
+ }
+ e.preventDefault();
+ submit();
+ return;
+ }
+ if (typing) return;
+ if (/^[1-8]$/.test(e.key)) {
+ const idx = Number(e.key) - 1;
+ const id = state.payload.ids?.[idx];
+ if (id) {
+ e.preventDefault();
+ onPick(id);
+ }
+ }
+ }
+
+ function mount(payload) {
+ state.payload = payload;
+ spawnDots();
+ const theme = String(payload.theme || "glass").toLowerCase();
+ const app = $("#app");
+ if (app) {
+ app.dataset.theme = ["glass", "ink", "signal", "hybrid"].includes(theme)
+ ? theme
+ : "glass";
+ }
+ const pre = payload.preselect || payload.recommended_ids || [];
+ state.selected = new Set(
+ pre.length ? pre.map(String) : payload.ids?.[0] ? [payload.ids[0]] : [],
+ );
+
+ const dangerous = !!(payload.dangerous || (payload.danger_ids || []).length);
+ $("#eyebrow").textContent = dangerous ? "Confirm" : "Decide";
+ $("#eyebrow").classList.toggle("is-danger", dangerous);
+ $("#title-agent").textContent = payload.agent_hint || payload.title || "";
+ $("#question").textContent = payload.question || "";
+
+ const banner = $("#banner");
+ banner.classList.toggle("is-on", dangerous);
+ $("#banner-copy").textContent = dangerous
+ ? `⛔ Confirm — ${payload.question || ""}`
+ : "";
+
+ const ok = $("#ok-btn");
+ ok.classList.toggle("is-danger", dangerous);
+
+ const showOther = payload.allow_other !== false;
+ $("#freeform").hidden = !showOther;
+ if (showOther) {
+ $("#freeform-input").addEventListener("input", () => {
+ const typed = freeformText();
+ if (!typed) return;
+ const other = (payload.ids || []).find((id) => OTHER_IDS.has(id));
+ if (other) {
+ state.selected = new Set([other]);
+ syncSelectionUi();
+ }
+ });
+ }
+
+ renderOptions();
+
+ const n = Math.min(8, (payload.ids || []).length);
+ const hint = $("#hint");
+ if (hint) {
+ hint.textContent =
+ n <= 1
+ ? "Enter OK · Esc cancel"
+ : `1–${n} select · Enter OK · Esc cancel · Shift+Enter newline`;
+ }
+
+ document.addEventListener("keydown", onKey);
+ $("#cancel-btn").addEventListener("click", () => cancel());
+ $("#close-btn").addEventListener("click", () => cancel());
+ $("#ok-btn").addEventListener("click", () => submit());
+
+ const armMs =
+ typeof payload.arm_ms === "number"
+ ? payload.arm_ms
+ : dangerous
+ ? 4000
+ : 1000;
+ armCountdown(armMs);
+ apiCall("debug", `mount:arm_ms=${armMs}`).catch(() => {});
+
+ // Visible before raise — opacity:0 + raise looked like a black void,
+ // and Edge --app throttles rAF when unfocused.
+ $("#app").classList.add("is-ready");
+ apiCall("content_ready").catch(() => {});
+ apiCall("debug", "mount:content_ready_sent").catch(() => {});
+ requestAnimationFrame(() => fitWindow());
+
+ if (payload.timeout_sec > 0) {
+ setTimeout(() => cancel("timeout"), payload.timeout_sec * 1000);
+ }
+ }
+
+ function fitWindow() {
+ const chrome = document.querySelector(".chrome");
+ const banner = document.getElementById("banner");
+ const question = document.getElementById("question");
+ const options = document.getElementById("options");
+ const freeform = document.getElementById("freeform");
+ const footer = document.querySelector(".footer");
+ let h = 28;
+ [chrome, question, freeform, footer].forEach((el) => {
+ if (el && !el.hidden) h += el.offsetHeight;
+ });
+ if (banner && banner.classList.contains("is-on")) {
+ h += banner.offsetHeight + 8;
+ }
+ // Gaps in .body
+ h += 42;
+ let optsH = 0;
+ if (options) {
+ options.querySelectorAll(".option").forEach((o) => {
+ optsH += o.offsetHeight + 8;
+ });
+ // Scroll options beyond this — keep freeform + footer on screen.
+ h += Math.min(optsH, 420);
+ }
+ const w = Math.max(520, Math.min(760, window.outerWidth || 560));
+ apiCall("resize_to", w, Math.ceil(h)).catch(() => {});
+ }
+
+ async function boot() {
+ const waitApi = () =>
+ new Promise((resolve, reject) => {
+ const tryNow = () => {
+ if (window.__ASK_BRIDGE__) {
+ resolve();
+ return true;
+ }
+ if (window.pywebview && window.pywebview.api) {
+ resolve();
+ return true;
+ }
+ return false;
+ };
+ if (tryNow()) return;
+ window.addEventListener("pywebviewready", () => tryNow());
+ let n = 0;
+ const iv = setInterval(() => {
+ n += 1;
+ if (tryNow()) {
+ clearInterval(iv);
+ return;
+ }
+ if (n > 100) {
+ clearInterval(iv);
+ reject(new Error("dialog bridge unavailable (pywebview / Edge)"));
+ }
+ }, 50);
+ });
+
+ await waitApi();
+ apiCall("debug", "boot:api_ready").catch(() => {});
+ const payload = await apiCall("get_payload");
+ apiCall("debug", `boot:payload n=${(payload && payload.ids || []).length}`).catch(
+ () => {},
+ );
+ mount(payload || {});
+ apiCall("debug", "boot:mounted").catch(() => {});
+ }
+
+ boot().catch((err) => {
+ console.error(err);
+ document.body.innerHTML = `
${esc(
+ String(err),
+ )}`;
+ });
+})();
\ No newline at end of file
diff --git a/src/ask_question_mcp/assets/dialog/entry.html b/src/ask_question_mcp/assets/dialog/entry.html
new file mode 100644
index 0000000..b5a293b
--- /dev/null
+++ b/src/ask_question_mcp/assets/dialog/entry.html
@@ -0,0 +1,133 @@
+
+
+
+
+
+ Something else
+
+
+
+
+
+
+
+
+
+ Something else
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ask_question_mcp/assets/dialog/index.html b/src/ask_question_mcp/assets/dialog/index.html
new file mode 100644
index 0000000..d9d3863
--- /dev/null
+++ b/src/ask_question_mcp/assets/dialog/index.html
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+ Decide
+
+
+
+
+
+
+
+
+
+
+ Decide
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ask_question_mcp/assets/dialog/manifest.webmanifest b/src/ask_question_mcp/assets/dialog/manifest.webmanifest
new file mode 100644
index 0000000..1990833
--- /dev/null
+++ b/src/ask_question_mcp/assets/dialog/manifest.webmanifest
@@ -0,0 +1,10 @@
+{
+ "name": "Decide",
+ "short_name": "Decide",
+ "display": "standalone",
+ "display_override": ["window-controls-overlay", "standalone"],
+ "background_color": "#100d1c",
+ "theme_color": "#100d1c",
+ "start_url": "/",
+ "icons": []
+}
diff --git a/src/ask_question_mcp/assets/dialog/tokens.css b/src/ask_question_mcp/assets/dialog/tokens.css
new file mode 100644
index 0000000..f05f421
--- /dev/null
+++ b/src/ask_question_mcp/assets/dialog/tokens.css
@@ -0,0 +1,102 @@
+/* Nebula dialog tokens — Ethereal glass base + theme overrides. */
+:root,
+[data-theme="glass"],
+[data-theme="hybrid"] {
+ --ground: #0c0a14;
+ --panel: #12101f;
+ --card-core: #16122a;
+ --raised: #241e44;
+ --accent: #9b8cff;
+ --accent-text: #c9bfff;
+ --ember: #ff5c7a;
+ --text: #f7f5ff;
+ --text-secondary: #a39bc9;
+ --text-tertiary: #8b84b8;
+ --text-eyebrow: #7a72ad;
+ --ground-deep: #07050e;
+ --ground-rgb: 12 10 20;
+ --accent-rgb: 155 140 255;
+ --ember-rgb: 255 92 122;
+ --card-core-rgb: 22 18 42;
+ --panel-rgb: 18 16 31;
+ --raised-rgb: 36 30 68;
+ --hairline-rgb: 245 243 255;
+ --orb-a: 120 90 255;
+ --orb-b: 60 180 200;
+ --font-ui: "Segoe UI Variable", "Segoe UI", system-ui, sans-serif;
+ --font-mono: "Cascadia Mono", Consolas, monospace;
+ --r-shell: 0px;
+ --r-core: 0px;
+ --r-tile: 16px;
+ --r-tile-inner: 13px;
+ --r-control: 12px;
+ --pad-shell: 0px;
+ --ease: cubic-bezier(0.32, 0.72, 0, 1);
+ --ease-spring: cubic-bezier(0.22, 1, 0.36, 1);
+ --hover-ms: 480ms;
+ --press-ms: 140ms;
+ --enter-ms: 780ms;
+ --leave-ms: 280ms;
+ --press-scale: 0.985;
+ --danger-bg: rgb(255 92 122 / 0.14);
+ --danger-border: rgb(255 92 122 / 0.35);
+ --danger-text: #ffb3c1;
+}
+
+/* Hybrid = glass colour + slightly sharper question weight */
+[data-theme="hybrid"] {
+ --orb-a: 110 95 255;
+ --orb-b: 70 160 210;
+}
+
+/* Quiet ink — less glow, sharper type, denser calm. */
+[data-theme="ink"] {
+ --ground: #0e0e12;
+ --panel: #14141a;
+ --card-core: #18181f;
+ --raised: #22222c;
+ --accent: #d4d0e8;
+ --accent-text: #eeeaf8;
+ --ember: #ff6b81;
+ --text: #f2f2f5;
+ --text-secondary: #9a9aa8;
+ --text-tertiary: #7a7a88;
+ --text-eyebrow: #6a6a78;
+ --ground-deep: #08080c;
+ --ground-rgb: 14 14 18;
+ --accent-rgb: 212 208 232;
+ --ember-rgb: 255 107 129;
+ --card-core-rgb: 24 24 31;
+ --panel-rgb: 20 20 26;
+ --raised-rgb: 34 34 44;
+ --orb-a: 80 80 100;
+ --orb-b: 40 40 55;
+ --r-tile: 10px;
+ --r-tile-inner: 8px;
+}
+
+/* Signal — accent-forward selection bloom. */
+[data-theme="signal"] {
+ --ground: #100818;
+ --panel: #1a1028;
+ --card-core: #1e1432;
+ --raised: #2e1f4e;
+ --accent: #a78bfa;
+ --accent-text: #ddd6fe;
+ --ember: #fb7185;
+ --text: #faf5ff;
+ --text-secondary: #c4b5fd;
+ --text-tertiary: #a78bfa;
+ --text-eyebrow: #8b5cf6;
+ --ground-deep: #0a0410;
+ --ground-rgb: 16 8 24;
+ --accent-rgb: 167 139 250;
+ --ember-rgb: 251 113 133;
+ --card-core-rgb: 30 20 50;
+ --panel-rgb: 26 16 40;
+ --raised-rgb: 46 31 78;
+ --orb-a: 168 85 247;
+ --orb-b: 236 72 153;
+ --r-tile: 18px;
+ --r-tile-inner: 15px;
+}
diff --git a/src/ask_question_mcp/capabilities.py b/src/ask_question_mcp/capabilities.py
index d00bd88..417e503 100644
--- a/src/ask_question_mcp/capabilities.py
+++ b/src/ask_question_mcp/capabilities.py
@@ -109,10 +109,11 @@ def resolve_voice_capabilities(*, speak_requested: bool) -> VoiceCapabilities:
tts = bool(tts_base_url())
stt = bool(stt_transcribe_url())
- # Windows Phase 1: tkinter text MCQ only — no duck / STT / local speak path.
+ # Windows: Nebula WebView2 text MCQ — no duck / STT / local speak path yet.
if sys.platform == "win32":
win_notes = [
- "Windows Phase 1: text-only MCQ (tkinter) — speak/listen not supported yet.",
+ "Windows: text-only MCQ (frameless Nebula WebView2; tk/Edge fallback) "
+ "— speak/listen not supported yet.",
]
if tts or stt:
win_notes.append(
diff --git a/src/ask_question_mcp/doctor.py b/src/ask_question_mcp/doctor.py
index ab957e1..426af74 100644
--- a/src/ask_question_mcp/doctor.py
+++ b/src/ask_question_mcp/doctor.py
@@ -152,7 +152,7 @@ def run_checks(*, want_voice: bool | None = None) -> list[Check]:
"display",
"Desktop",
"ok",
- "Windows desktop (tkinter Phase 1 text-only)",
+ "Windows desktop (Nebula via Edge --app; tkinter fallback)",
docs=[DOCS_README, DOCS_DEPS],
)
)
@@ -160,23 +160,40 @@ def run_checks(*, want_voice: bool | None = None) -> list[Check]:
os.environ.get("ASK_QUESTION_WIN_PYTHON", "").strip()
or sys.executable
)
- try:
- r = subprocess.run(
- [win_py, "-c", "import tkinter; print('ok')"],
- capture_output=True,
- text=True,
- timeout=8,
- check=False,
- )
- tk_ok = r.returncode == 0 and "ok" in (r.stdout or "")
- tk_detail = (
- f"{win_py} has tkinter"
- if tk_ok
- else ((r.stderr or r.stdout or "").strip()[:200] or f"exit {r.returncode}")
- )
- except Exception as exc: # noqa: BLE001
- tk_ok = False
- tk_detail = str(exc)
+ # Prefer importlib presence checks — full import probes hang when
+ # spawned as subprocesses under Cursor's MCP stdio host on Windows.
+ import importlib.util
+
+ wv_ok = importlib.util.find_spec("webview") is not None
+ wv_detail = (
+ f"{win_py} has pywebview"
+ if wv_ok
+ else f"pywebview not installed for {win_py}"
+ )
+ if wv_ok:
+ checks.append(
+ Check("webview", "pywebview", "ok", wv_detail, docs=[DOCS_DEPS])
+ )
+ else:
+ checks.append(
+ Check(
+ "webview",
+ "pywebview",
+ "fail",
+ f"pywebview missing on {win_py}: {wv_detail}",
+ fix="From the ask-question-mcp checkout: uv sync "
+ "(pywebview is a dependency). Edge WebView2 runtime is "
+ "usually already on Windows 11.",
+ docs=[DOCS_DEPS, "docs/WINDOWS.md"],
+ )
+ )
+
+ tk_ok = importlib.util.find_spec("tkinter") is not None
+ tk_detail = (
+ f"{win_py} has tkinter (fallback)"
+ if tk_ok
+ else f"tkinter not importable on {win_py}"
+ )
if tk_ok:
checks.append(
Check("tkinter", "tkinter", "ok", tk_detail, docs=[DOCS_DEPS])
@@ -186,26 +203,56 @@ def run_checks(*, want_voice: bool | None = None) -> list[Check]:
Check(
"tkinter",
"tkinter",
- "fail",
+ "warn" if wv_ok else "fail",
f"tkinter missing on {win_py}: {tk_detail}",
- fix="Install Python from https://www.python.org/downloads/ "
- "with tcl/tk enabled (not a Store build without Tk). "
- "Optional: set ASK_QUESTION_WIN_PYTHON.",
+ fix="Optional fallback. Install Python from "
+ "https://www.python.org/downloads/ with tcl/tk, or rely "
+ "on pywebview. Optional: set ASK_QUESTION_WIN_PYTHON.",
docs=[DOCS_DEPS],
)
)
+
+ win_wv = Path(__file__).resolve().with_name("win_webview_ask.py")
+ if win_wv.is_file():
+ checks.append(
+ Check(
+ "win_webview_script",
+ "win_webview_ask.py",
+ "ok",
+ str(win_wv),
+ docs=[DOCS_DEPS, "docs/WINDOWS.md"],
+ )
+ )
+ else:
+ checks.append(
+ Check(
+ "win_webview_script",
+ "win_webview_ask.py",
+ "fail",
+ f"Missing dialog script: {win_wv}",
+ fix="Re-clone or repair the ask-question-mcp checkout.",
+ docs=[DOCS_README, DOCS_DEPS],
+ )
+ )
+
win_list = Path(__file__).resolve().with_name("win_list_ask.py")
if win_list.is_file():
checks.append(
- Check("win_script", "win_list_ask.py", "ok", str(win_list), docs=[DOCS_DEPS])
+ Check(
+ "win_script",
+ "win_list_ask.py",
+ "ok",
+ f"{win_list} (tk fallback)",
+ docs=[DOCS_DEPS],
+ )
)
else:
checks.append(
Check(
"win_script",
"win_list_ask.py",
- "fail",
- f"Missing dialog script: {win_list}",
+ "warn" if win_wv.is_file() else "fail",
+ f"Missing tk fallback script: {win_list}",
fix="Re-clone or repair the ask-question-mcp checkout; "
"MCP --directory must point at the repo root.",
docs=[DOCS_README, DOCS_DEPS],
@@ -487,7 +534,15 @@ def summarize(checks: list[Check]) -> dict[str, Any]:
ok = not fails
soft_fail_ids = {c.id for c in checks if c.severity == "fail"}
if sys.platform == "win32":
- ready_software = not soft_fail_ids.intersection({"tkinter", "win_script"})
+ # Ready when WebView2 path works, or tk fallback is intact.
+ webview_ready = (
+ "webview" not in soft_fail_ids
+ and "win_webview_script" not in soft_fail_ids
+ )
+ tk_ready = (
+ "tkinter" not in soft_fail_ids and "win_script" not in soft_fail_ids
+ )
+ ready_software = webview_ready or tk_ready
else:
ready_software = not soft_fail_ids.intersection({"gtk_python", "gtk_script"})
ready_host = not soft_fail_ids.intersection({"uv", "python"})
@@ -509,13 +564,25 @@ def summarize(checks: list[Check]) -> dict[str, Any]:
if sys.platform == "win32":
b_ui = {
"required": True,
- "items": ["Windows desktop", "tkinter", "win_list_ask.py"],
+ "items": [
+ "Windows desktop",
+ "pywebview + WebView2 (preferred)",
+ "win_webview_ask.py",
+ "tkinter fallback optional",
+ ],
"windows_note": (
- "Install Python 3.12+ from python.org with tcl/tk enabled "
- "(avoid Store builds without Tk)."
+ "uv sync installs pywebview. Edge WebView2 is standard on "
+ "Windows 11. tkinter remains an optional fallback "
+ "(ASK_QUESTION_WIN_UI=tk)."
),
"status": {
"display": by_id["display"].severity if "display" in by_id else "skip",
+ "webview": by_id["webview"].severity if "webview" in by_id else "skip",
+ "win_webview_script": (
+ by_id["win_webview_script"].severity
+ if "win_webview_script" in by_id
+ else "skip"
+ ),
"tkinter": by_id["tkinter"].severity if "tkinter" in by_id else "skip",
"win_script": by_id["win_script"].severity if "win_script" in by_id else "skip",
},
@@ -580,8 +647,8 @@ def summarize(checks: list[Check]) -> dict[str, Any]:
"debian_ubuntu_audio": apt_audio,
"python_package": "uv sync",
"windows_ui": (
- "Install Python 3.12+ from https://www.python.org/downloads/ "
- "with tcl/tk; then: winget install astral-sh.uv (or uv install script)"
+ "uv sync (installs pywebview). Edge WebView2 is standard on "
+ "Windows 11. Optional tk fallback: python.org build with tcl/tk."
),
},
}
@@ -780,12 +847,12 @@ def setup_guide(topic: str) -> dict[str, Any]:
"debian_ubuntu_ui": "sudo apt install -y python3 python3-gi gir1.2-gtk-4.0 gir1.2-adw-1 zenity",
"debian_ubuntu_audio": "sudo apt install -y pipewire pipewire-pulse pipewire-audio-client-libraries",
"windows_ui": (
- "Install Python 3.12+ from python.org with tcl/tk; "
- "winget install astral-sh.uv"
+ "uv sync (pywebview). Edge WebView2 on Windows 11; "
+ "tkinter optional fallback."
),
},
"verify": (
- "check_setup: display + tkinter + win_script ok; ready.text_mcq true."
+ "check_setup: display + webview + win_webview_script ok; ready.text_mcq true."
if sys.platform == "win32"
else "check_setup: display + gtk_python + gtk_script ok; ready.text_mcq true."
),
diff --git a/src/ask_question_mcp/platform_info.py b/src/ask_question_mcp/platform_info.py
index 58845ba..b9db8f0 100644
--- a/src/ask_question_mcp/platform_info.py
+++ b/src/ask_question_mcp/platform_info.py
@@ -212,7 +212,7 @@ def classify_platform(host: dict[str, Any] | None = None) -> dict[str, Any]:
"matched": None,
"host": host,
"summary": (
- "Unverified platform (Windows + tkinter text MCQ). "
+ "Unverified platform (Windows + WebView2 Nebula MCQ). "
"Please tell us if the dialog works — GitHub issue or README table PR."
),
"docs": [README_PLATFORMS],
diff --git a/src/ask_question_mcp/win_edge_ask.py b/src/ask_question_mcp/win_edge_ask.py
new file mode 100644
index 0000000..7d65e09
--- /dev/null
+++ b/src/ask_question_mcp/win_edge_ask.py
@@ -0,0 +1,747 @@
+#!/usr/bin/env python3
+"""Windows Nebula MCQ via Edge ``--app`` + localhost bridge.
+
+Replaces embedded pywebview/WebView2 (which blanked and hung destroy under
+Cursor). Real Edge is killable with ``taskkill /T``; the dialog HTML/CSS/JS
+is the same Nebula assets.
+
+Stdin: JSON payload. Stdout / result_path: ``{"ids": [...]}`` or cancelled.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from typing import Any
+from urllib.parse import unquote, urlparse
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+try:
+ import prefs as _prefs
+except ImportError: # pragma: no cover
+ _prefs = None # type: ignore[assignment]
+try:
+ import danger_arm as _danger_arm
+except ImportError: # pragma: no cover
+ _danger_arm = None # type: ignore[assignment]
+
+_DIALOG_DIR = Path(__file__).resolve().parent / "assets" / "dialog"
+_INDEX = _DIALOG_DIR / "index.html"
+
+_emitted = False
+_STATE: dict[str, Any] = {
+ "payload": None,
+ "result": None,
+ "done": threading.Event(),
+ "ready": threading.Event(),
+ "edge_pid": None,
+ "hits": [],
+}
+
+
+def _log(msg: str) -> None:
+ try:
+ path = Path(tempfile.gettempdir()) / "ask-question-mcp-edge.log"
+ with path.open("a", encoding="utf-8") as fh:
+ fh.write(f"{time.strftime('%H:%M:%S')} {msg}\n")
+ except OSError:
+ pass
+
+
+def _emit(result: dict[str, Any], result_path: str | None = None) -> None:
+ global _emitted
+ if _emitted:
+ return
+ _emitted = True
+ line = json.dumps(result, ensure_ascii=False)
+ if result_path:
+ try:
+ Path(result_path).write_text(line + "\n", encoding="utf-8")
+ except OSError:
+ pass
+ try:
+ sys.stdout.write(line + "\n")
+ sys.stdout.flush()
+ except OSError:
+ pass
+
+
+def _find_edge() -> str | None:
+ env = os.environ.get("ASK_QUESTION_EDGE", "").strip()
+ if env and Path(env).is_file():
+ return env
+ which = shutil.which("msedge") or shutil.which("msedge.exe")
+ if which:
+ return which
+ for cand in (
+ Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"))
+ / "Microsoft"
+ / "Edge"
+ / "Application"
+ / "msedge.exe",
+ Path(os.environ.get("ProgramFiles", r"C:\Program Files"))
+ / "Microsoft"
+ / "Edge"
+ / "Application"
+ / "msedge.exe",
+ Path(os.environ.get("LOCALAPPDATA", ""))
+ / "Microsoft"
+ / "Edge"
+ / "Application"
+ / "msedge.exe",
+ ):
+ if cand.is_file():
+ return str(cand)
+ return None
+
+
+def _kill_tree(pid: int | None) -> None:
+ if not pid:
+ return
+ try:
+ subprocess.run(
+ ["taskkill", "/F", "/T", "/PID", str(pid)],
+ capture_output=True,
+ text=True,
+ timeout=8,
+ check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired):
+ pass
+
+
+def _raise_edge() -> None:
+ """Best-effort bring Edge --app above Cursor — no TOPMOST (taskbar flash)."""
+ try:
+ import ctypes
+ from ctypes import wintypes
+
+ user32 = ctypes.windll.user32
+ kernel32 = ctypes.windll.kernel32
+ pid = _STATE.get("edge_pid")
+ if not pid:
+ return
+
+ get_pid = user32.GetWindowThreadProcessId
+ is_visible = user32.IsWindowVisible
+ get_parent = user32.GetParent
+ found: list[tuple[int, int]] = [] # (area, hwnd)
+
+ @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
+ def _cb(hwnd, _lp): # type: ignore[no-untyped-def]
+ proc = wintypes.DWORD()
+ get_pid(hwnd, ctypes.byref(proc))
+ if proc.value != pid:
+ return True
+ if not is_visible(hwnd):
+ return True
+ if get_parent(hwnd):
+ return True
+ rect = wintypes.RECT()
+ user32.GetWindowRect(hwnd, ctypes.byref(rect))
+ area = max(0, rect.right - rect.left) * max(0, rect.bottom - rect.top)
+ if area < 200 * 200:
+ return True
+ found.append((area, hwnd))
+ return True
+
+ user32.EnumWindows(_cb, 0)
+ if not found:
+ _log("raise: no hwnd for edge pid")
+ return
+ found.sort(reverse=True)
+ hwnd = found[0][1]
+ _STATE["edge_hwnd"] = hwnd
+ _log(f"raise hwnd={hwnd} area={found[0][0]}")
+ # Chromium redraws its caption — strip repeatedly + force dark frame.
+ def _strip() -> None:
+ try:
+ GWL_STYLE = -16
+ GWL_EXSTYLE = -20
+ WS_POPUP = 0x80000000
+ WS_VISIBLE = 0x10000000
+ WS_CLIPSIBLINGS = 0x04000000
+ WS_CLIPCHILDREN = 0x02000000
+ WS_CAPTION = 0x00C00000
+ WS_THICKFRAME = 0x00040000
+ WS_SYSMENU = 0x00080000
+ WS_MINIMIZEBOX = 0x00020000
+ WS_MAXIMIZEBOX = 0x00010000
+ WS_BORDER = 0x00800000
+ WS_DLGFRAME = 0x00400000
+ WS_EX_APPWINDOW = 0x00040000
+ WS_EX_WINDOWEDGE = 0x00000100
+ WS_EX_CLIENTEDGE = 0x00000200
+ WS_EX_DLGMODALFRAME = 0x00000001
+ SWP_FRAMECHANGED = 0x0020
+ SWP_NOMOVE = 0x0002
+ SWP_NOSIZE = 0x0001
+ SWP_NOZORDER = 0x0004
+ SWP_SHOWWINDOW = 0x0040
+
+ # Frameless popup — kills Chromium/Edge --app caption chrome.
+ style = (
+ WS_POPUP
+ | WS_VISIBLE
+ | WS_CLIPSIBLINGS
+ | WS_CLIPCHILDREN
+ )
+ user32.SetWindowLongW(hwnd, GWL_STYLE, style)
+ ex = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
+ ex &= ~(
+ WS_EX_WINDOWEDGE
+ | WS_EX_CLIENTEDGE
+ | WS_EX_DLGMODALFRAME
+ )
+ ex |= WS_EX_APPWINDOW
+ user32.SetWindowLongW(hwnd, GWL_EXSTYLE, ex)
+ user32.SetWindowPos(
+ hwnd,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ SWP_NOMOVE
+ | SWP_NOSIZE
+ | SWP_NOZORDER
+ | SWP_FRAMECHANGED
+ | SWP_SHOWWINDOW,
+ )
+ try:
+ DWMWA_USE_IMMERSIVE_DARK_MODE = 20
+ DWMWA_CAPTION_COLOR = 35
+ DWMWA_BORDER_COLOR = 34
+ DWMWA_WINDOW_CORNER_PREFERENCE = 33
+ DWMWCP_ROUND = 2
+ value = ctypes.c_int(1)
+ ctypes.windll.dwmapi.DwmSetWindowAttribute(
+ hwnd,
+ DWMWA_USE_IMMERSIVE_DARK_MODE,
+ ctypes.byref(value),
+ ctypes.sizeof(value),
+ )
+ color = ctypes.c_uint(0x001C0D10)
+ ctypes.windll.dwmapi.DwmSetWindowAttribute(
+ hwnd,
+ DWMWA_CAPTION_COLOR,
+ ctypes.byref(color),
+ ctypes.sizeof(color),
+ )
+ ctypes.windll.dwmapi.DwmSetWindowAttribute(
+ hwnd,
+ DWMWA_BORDER_COLOR,
+ ctypes.byref(color),
+ ctypes.sizeof(color),
+ )
+ corner = ctypes.c_int(DWMWCP_ROUND)
+ ctypes.windll.dwmapi.DwmSetWindowAttribute(
+ hwnd,
+ DWMWA_WINDOW_CORNER_PREFERENCE,
+ ctypes.byref(corner),
+ ctypes.sizeof(corner),
+ )
+ except Exception: # noqa: BLE001
+ pass
+ except Exception as exc: # noqa: BLE001
+ _log(f"strip caption failed: {exc}")
+
+ _strip()
+ threading.Timer(0.2, _strip).start()
+ threading.Timer(0.6, _strip).start()
+ threading.Timer(1.2, _strip).start()
+ # Chromium sometimes restores caption after navigation — keep stripping.
+ def _keep_stripping() -> None:
+ while not _STATE["done"].is_set():
+ _strip()
+ time.sleep(0.75)
+
+ threading.Thread(target=_keep_stripping, daemon=True).start()
+ user32.ShowWindow(hwnd, 9)
+ foreground = user32.GetForegroundWindow()
+ if foreground:
+ other_tid = user32.GetWindowThreadProcessId(foreground, None)
+ our_tid = kernel32.GetCurrentThreadId()
+ user32.AttachThreadInput(other_tid, our_tid, True)
+ user32.BringWindowToTop(hwnd)
+ user32.SetForegroundWindow(hwnd)
+ user32.AttachThreadInput(other_tid, our_tid, False)
+ else:
+ user32.BringWindowToTop(hwnd)
+ user32.SetForegroundWindow(hwnd)
+
+ shot = os.environ.get("ASK_QUESTION_EDGE_SCREENSHOT", "").strip()
+ _log(f"shot env={shot!r}")
+ if shot:
+ _capture_hwnd_png(hwnd, shot)
+ except Exception as exc: # noqa: BLE001
+ _log(f"raise failed: {exc}")
+
+
+def _capture_hwnd_png(hwnd: int, path: str) -> None:
+ """PrintWindow the Edge --app HWND (not the whole desktop)."""
+ try:
+ import ctypes
+ from ctypes import wintypes
+
+ user32 = ctypes.windll.user32
+ gdi32 = ctypes.windll.gdi32
+ rect = wintypes.RECT()
+ user32.GetWindowRect(hwnd, ctypes.byref(rect))
+ w = max(1, rect.right - rect.left)
+ h = max(1, rect.bottom - rect.top)
+ hwnd_dc = user32.GetWindowDC(hwnd)
+ mem_dc = gdi32.CreateCompatibleDC(hwnd_dc)
+ bmp = gdi32.CreateCompatibleBitmap(hwnd_dc, w, h)
+ old = gdi32.SelectObject(mem_dc, bmp)
+ # PW_RENDERFULLCONTENT = 2
+ user32.PrintWindow(hwnd, mem_dc, 2)
+
+ class BITMAPINFOHEADER(ctypes.Structure):
+ _fields_ = [
+ ("biSize", wintypes.DWORD),
+ ("biWidth", wintypes.LONG),
+ ("biHeight", wintypes.LONG),
+ ("biPlanes", wintypes.WORD),
+ ("biBitCount", wintypes.WORD),
+ ("biCompression", wintypes.DWORD),
+ ("biSizeImage", wintypes.DWORD),
+ ("biXPelsPerMeter", wintypes.LONG),
+ ("biYPelsPerMeter", wintypes.LONG),
+ ("biClrUsed", wintypes.DWORD),
+ ("biClrImportant", wintypes.DWORD),
+ ]
+
+ bi = BITMAPINFOHEADER()
+ bi.biSize = ctypes.sizeof(BITMAPINFOHEADER)
+ bi.biWidth = w
+ bi.biHeight = -h # top-down
+ bi.biPlanes = 1
+ bi.biBitCount = 32
+ bi.biCompression = 0
+ buf_size = w * h * 4
+ buf = (ctypes.c_char * buf_size)()
+ gdi32.GetDIBits(mem_dc, bmp, 0, h, buf, ctypes.byref(bi), 0)
+
+ gdi32.SelectObject(mem_dc, old)
+ gdi32.DeleteObject(bmp)
+ gdi32.DeleteDC(mem_dc)
+ user32.ReleaseDC(hwnd, hwnd_dc)
+
+ # Minimal PNG via stdlib only — write BMP then hope; prefer raw RGBA PNG
+ # with a tiny uncompressed writer.
+ try:
+ import struct
+ import zlib
+
+ def _png(rgba: bytes, width: int, height: int) -> bytes:
+ def chunk(tag: bytes, data: bytes) -> bytes:
+ return (
+ struct.pack(">I", len(data))
+ + tag
+ + data
+ + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
+ )
+
+ raw = b"".join(
+ b"\x00" + rgba[y * width * 4 : (y + 1) * width * 4]
+ for y in range(height)
+ )
+ # BGRA → RGBA
+ px = bytearray(len(rgba))
+ for i in range(0, len(rgba), 4):
+ px[i] = rgba[i + 2]
+ px[i + 1] = rgba[i + 1]
+ px[i + 2] = rgba[i]
+ px[i + 3] = rgba[i + 3]
+ raw = b"".join(
+ b"\x00" + bytes(px[y * width * 4 : (y + 1) * width * 4])
+ for y in range(height)
+ )
+ return b"".join(
+ [
+ b"\x89PNG\r\n\x1a\n",
+ chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)),
+ chunk(b"IDAT", zlib.compress(raw, 9)),
+ chunk(b"IEND", b""),
+ ]
+ )
+
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
+ Path(path).write_bytes(_png(bytes(buf), w, h))
+ _log(f"screenshot {path} {w}x{h}")
+ except Exception as exc: # noqa: BLE001
+ _log(f"screenshot png failed: {exc}")
+ except Exception as exc: # noqa: BLE001
+ _log(f"screenshot failed: {exc}")
+
+
+def _finish(result: dict[str, Any], result_path: str | None) -> None:
+ if _STATE["done"].is_set():
+ return
+ _STATE["result"] = result
+ _STATE["done"].set()
+ _emit(result, result_path)
+ threading.Thread(
+ target=lambda: (_kill_tree(_STATE.get("edge_pid")), None),
+ daemon=True,
+ ).start()
+
+
+class _Handler(BaseHTTPRequestHandler):
+ bridge_origin = ""
+ result_path: str | None = None
+
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A003
+ return
+
+ def _cors(self) -> None:
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
+
+ def do_OPTIONS(self) -> None: # noqa: N802
+ self.send_response(204)
+ self._cors()
+ self.end_headers()
+
+ def do_GET(self) -> None: # noqa: N802
+ parsed = urlparse(self.path)
+ path = unquote(parsed.path)
+ _STATE["hits"].append(f"GET {path}")
+ _log(f"GET {path}")
+ if path in {"/", "/index.html"}:
+ html = _INDEX.read_text(encoding="utf-8")
+ # Soften Edge --app title bar: blank so OS chrome isn't a
+ # black "Decide" strip fighting the in-page Nebula header.
+ html = html.replace("Decide", "\u200b", 1)
+ inject = (
+ f""
+ )
+ if "" in html:
+ html = html.replace("", "" + inject, 1)
+ else:
+ html = inject + html
+ data = html.encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(data)))
+ self._cors()
+ self.end_headers()
+ self.wfile.write(data)
+ return
+
+ rel = path.lstrip("/").replace("\\", "/")
+ if ".." in rel or rel.startswith("/"):
+ self.send_error(404)
+ return
+ target = (_DIALOG_DIR / rel).resolve()
+ try:
+ target.relative_to(_DIALOG_DIR.resolve())
+ except ValueError:
+ self.send_error(404)
+ return
+ if not target.is_file():
+ self.send_error(404)
+ return
+ data = target.read_bytes()
+ ctype = "application/octet-stream"
+ if target.suffix == ".css":
+ ctype = "text/css; charset=utf-8"
+ elif target.suffix == ".js":
+ ctype = "text/javascript; charset=utf-8"
+ elif target.suffix == ".html":
+ ctype = "text/html; charset=utf-8"
+ elif target.suffix == ".webmanifest" or target.name.endswith(
+ "manifest.json"
+ ):
+ ctype = "application/manifest+json"
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(data)))
+ self._cors()
+ self.end_headers()
+ self.wfile.write(data)
+
+ def do_POST(self) -> None: # noqa: N802
+ length = int(self.headers.get("Content-Length") or 0)
+ raw = self.rfile.read(length) if length else b"{}"
+ try:
+ body = json.loads(raw.decode("utf-8") or "{}")
+ except json.JSONDecodeError:
+ body = {}
+
+ parsed = urlparse(self.path)
+ _STATE["hits"].append(f"POST {parsed.path}")
+ _log(f"POST {parsed.path} {raw[:200]!r}")
+ if parsed.path == "/event":
+ name = str(body.get("name") or "")
+ if name == "content_ready":
+ _STATE["ready"].set()
+ threading.Thread(target=_raise_edge, daemon=True).start()
+ threading.Timer(0.35, _raise_edge).start()
+ self.send_response(200)
+ self._cors()
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(b'{"ok":true}')
+ return
+
+ if parsed.path != "/api":
+ self.send_error(404)
+ return
+
+ name = str(body.get("name") or "")
+ args = body.get("args") or []
+ result: Any = None
+
+ if name == "get_payload":
+ result = _STATE["payload"]
+ payload = json.dumps({"result": result}, ensure_ascii=False).encode(
+ "utf-8"
+ )
+ self.send_response(200)
+ self._cors()
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(payload)))
+ self.end_headers()
+ self.wfile.write(payload)
+ return
+
+ if name == "submit":
+ ids = args[0] if args else []
+ freeform = args[1] if len(args) > 1 else None
+ chosen = [str(x) for x in (ids or []) if str(x).strip()]
+ out: dict[str, Any] = {"ids": chosen}
+ typed = (str(freeform) if freeform is not None else "").strip()
+ if typed:
+ out["freeform_text"] = typed
+ if not chosen:
+ out = {"cancelled": True, "reason": "empty selection"}
+ # Respond BEFORE killing Edge — otherwise Enter feels stuck.
+ ack = b'{"result":null}'
+ self.send_response(200)
+ self._cors()
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(ack)))
+ self.end_headers()
+ self.wfile.write(ack)
+ threading.Timer(
+ 0.05, lambda: _finish(out, self.result_path)
+ ).start()
+ return
+
+ if name == "cancel":
+ reason = str(args[0] if args else "user cancelled")
+ ack = b'{"result":null}'
+ self.send_response(200)
+ self._cors()
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(ack)))
+ self.end_headers()
+ self.wfile.write(ack)
+ threading.Timer(
+ 0.05,
+ lambda: _finish(
+ {"cancelled": True, "reason": reason}, self.result_path
+ ),
+ ).start()
+ return
+
+ self.send_response(400)
+ self._cors()
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps({"error": f"unknown api {name}"}).encode("utf-8")
+ )
+
+
+def main() -> int:
+ result_path: str | None = None
+ try:
+ payload = json.load(sys.stdin)
+ except json.JSONDecodeError as exc:
+ _emit({"cancelled": True, "reason": f"bad json: {exc}"})
+ return 1
+
+ result_path = str(payload.get("result_path") or "").strip() or None
+ question = str(payload.get("question") or "").strip()
+ title = str(payload.get("title") or "Decide")
+ ids = [str(x) for x in (payload.get("ids") or [])]
+ if not question or len(ids) < 2:
+ _emit({"cancelled": True, "reason": "invalid payload"}, result_path)
+ return 1
+ if not _INDEX.is_file():
+ _emit(
+ {"cancelled": True, "reason": f"missing dialog assets: {_INDEX}"},
+ result_path,
+ )
+ return 1
+
+ edge = _find_edge()
+ if not edge:
+ _emit(
+ {
+ "cancelled": True,
+ "reason": "Microsoft Edge not found — set ASK_QUESTION_EDGE "
+ "or install Edge",
+ },
+ result_path,
+ )
+ return 1
+
+ ui_payload = dict(payload)
+ ui_payload["question"] = question
+ ui_payload["title"] = title
+ ui_payload["ids"] = ids
+ ui_payload["labels"] = {
+ str(k): str(v) for k, v in (payload.get("labels") or {}).items()
+ }
+ ui_payload["preselect"] = [str(x) for x in (payload.get("preselect") or [])]
+ ui_payload["recommended_ids"] = [
+ str(x) for x in (payload.get("recommended_ids") or [])
+ ]
+ ui_payload["danger_ids"] = [str(x) for x in (payload.get("danger_ids") or [])]
+ ui_payload["dangerous"] = bool(
+ payload.get("dangerous") or ui_payload["danger_ids"]
+ )
+ ui_payload["allow_multiple"] = bool(payload.get("allow_multiple"))
+ ui_payload["allow_other"] = bool(payload.get("allow_other", True))
+ timeout_sec = int(payload.get("timeout_sec") or 0)
+ ui_payload["timeout_sec"] = timeout_sec
+ ui_payload["agent_hint"] = title
+ if ui_payload["dangerous"] and _danger_arm is not None:
+ title = _danger_arm.prefix_danger_mark(title)
+
+ _STATE["payload"] = ui_payload
+
+ geom = (
+ _prefs.get_window_geometry()
+ if _prefs is not None
+ else {"w": 560, "h": 640}
+ )
+ width = max(480, min(720, int(geom.get("w") or 560)))
+ height = max(560, min(920, int(geom.get("h") or 640)))
+
+ server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
+ port = server.server_address[1]
+ origin = f"http://127.0.0.1:{port}"
+ _Handler.bridge_origin = origin
+ _Handler.result_path = result_path
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+
+ # Stable profile (not per-PID temp) — avoids Edge "we're syncing" / first-run
+ # toasts on every MCQ. Still isolated from the human's main Edge profile.
+ profile = (
+ Path(os.environ.get("LOCALAPPDATA") or tempfile.gettempdir())
+ / "ask-question-mcp"
+ / "edge-profile"
+ )
+ profile.mkdir(parents=True, exist_ok=True)
+ url = f"{origin}/"
+ cmd = [
+ edge,
+ f"--user-data-dir={profile}",
+ "--no-first-run",
+ "--no-default-browser-check",
+ "--disable-extensions",
+ "--disable-sync",
+ "--disable-background-networking",
+ "--disable-client-side-phishing-detection",
+ "--disable-component-update",
+ "--disable-default-apps",
+ "--disable-domain-reliability",
+ "--force-dark-mode",
+ "--disable-features="
+ "TranslateUI,InterestFeedContentSuggestions,"
+ "msEdgeAccountSignInPromo,msEdgeIdentityBubble,SyncPromo,EdgeSigninPromo",
+ "--no-pings",
+ "--password-store=basic",
+ f"--app={url}",
+ f"--window-size={width},{height}",
+ ]
+ _log(f"server {origin} edge={edge}")
+ try:
+ proc = subprocess.Popen(
+ cmd,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
+ )
+ except OSError as exc:
+ server.shutdown()
+ _emit({"cancelled": True, "reason": f"Edge launch failed: {exc}"}, result_path)
+ return 1
+
+ _STATE["edge_pid"] = proc.pid
+ _log(f"edge pid={proc.pid} cmd={cmd}")
+
+ # Dev/smoke: auto-dismiss once content paints (never leave a stuck Edge).
+ if os.environ.get("ASK_QUESTION_EDGE_AUTO", "").strip() in {
+ "1",
+ "true",
+ "yes",
+ "cancel",
+ }:
+
+ def _auto() -> None:
+ if not _STATE["ready"].wait(timeout=8.0):
+ return
+ # Let screenshot / raise settle before auto-dismiss.
+ time.sleep(1.8 if os.environ.get("ASK_QUESTION_EDGE_SCREENSHOT") else 0.5)
+ if not _STATE["done"].is_set():
+ _finish(
+ {"cancelled": True, "reason": "edge_auto_ok"},
+ result_path,
+ )
+
+ threading.Thread(target=_auto, daemon=True).start()
+
+ # If content never paints, kill Edge and report blank (parent → tk).
+ def _blank() -> None:
+ if _STATE["ready"].is_set() or _STATE["done"].is_set():
+ return
+ _finish(
+ {
+ "cancelled": True,
+ "reason": "edge blank / content failed to load",
+ },
+ result_path,
+ )
+
+ threading.Timer(12.0, _blank).start()
+
+ wait = timeout_sec if timeout_sec > 0 else 300
+ finished = _STATE["done"].wait(timeout=float(wait))
+ if not finished:
+ _finish({"cancelled": True, "reason": "timeout"}, result_path)
+
+ time.sleep(0.3)
+ _kill_tree(proc.pid)
+ try:
+ server.shutdown()
+ except Exception: # noqa: BLE001
+ pass
+
+ result = _STATE.get("result") or {"cancelled": True, "reason": "no selection"}
+ _emit(result, result_path)
+ return 0 if not result.get("cancelled") else 1
+
+
+if __name__ == "__main__":
+ try:
+ raise SystemExit(main())
+ except SystemExit:
+ raise
+ except Exception as exc: # noqa: BLE001
+ _emit({"cancelled": True, "reason": f"win_edge crash: {exc}"})
+ raise SystemExit(1) from exc
diff --git a/src/ask_question_mcp/win_list_ask.py b/src/ask_question_mcp/win_list_ask.py
index 3597cfd..f76e10e 100644
--- a/src/ask_question_mcp/win_list_ask.py
+++ b/src/ask_question_mcp/win_list_ask.py
@@ -84,13 +84,48 @@ def main() -> int:
root = tk.Tk()
root.title(title)
root.resizable(True, True)
- # Cursor-spawned MCP often has no console; keep dialog visible.
+ # Cursor-spawned MCP often has no console; keep dialog visible + front.
try:
root.attributes("-topmost", True)
- root.after(400, lambda: root.attributes("-topmost", False))
+ root.lift()
+ root.focus_force()
except tk.TclError:
pass
+ def _raise_win32() -> None:
+ try:
+ import ctypes
+
+ hwnd = int(root.winfo_id())
+ # On Windows, winfo_id is the HWND for the Tk frame; climb to top.
+ user32 = ctypes.windll.user32
+ GA_ROOT = 2
+ top = user32.GetAncestor(hwnd, GA_ROOT) or hwnd
+ user32.ShowWindow(top, 9)
+ foreground = user32.GetForegroundWindow()
+ if foreground:
+ other_tid = user32.GetWindowThreadProcessId(foreground, None)
+ our_tid = ctypes.windll.kernel32.GetCurrentThreadId()
+ user32.AttachThreadInput(other_tid, our_tid, True)
+ user32.BringWindowToTop(top)
+ user32.SetForegroundWindow(top)
+ user32.AttachThreadInput(other_tid, our_tid, False)
+ else:
+ user32.BringWindowToTop(top)
+ user32.SetForegroundWindow(top)
+ except Exception: # noqa: BLE001
+ pass
+
+ def _release_topmost() -> None:
+ try:
+ root.attributes("-topmost", False)
+ except tk.TclError:
+ pass
+
+ root.after(50, _raise_win32)
+ root.after(300, _raise_win32)
+ root.after(1200, _release_topmost)
+
outer = ttk.Frame(root, padding=12)
outer.grid(row=0, column=0, sticky="nsew")
root.columnconfigure(0, weight=1)
diff --git a/src/ask_question_mcp/win_webview_ask.py b/src/ask_question_mcp/win_webview_ask.py
new file mode 100644
index 0000000..c4e6840
--- /dev/null
+++ b/src/ask_question_mcp/win_webview_ask.py
@@ -0,0 +1,448 @@
+#!/usr/bin/env python3
+"""Windows WebView2 (pywebview) MCQ dialog — Nebula aesthetic.
+
+Freeze rules (learned the hard way under Cursor):
+- Never ``on_top`` / ``AttachThreadInput`` — locks Electron + WebView2.
+- Never ``window.resize`` / ``show`` / ``destroy`` from the JS-API thread —
+ those deadlocks WinForms. Bridge only sets flags; a side thread + ``os._exit``
+ bail handle lifecycle.
+- Size the HWND in Python before ``create_window`` (JS ``resize_to`` is a no-op).
+- ``file://`` via ``Path.as_uri()``; stable-ish per-PID Edge profile.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+from typing import Any
+
+_WV2_DIR = (
+ Path(os.environ.get("LOCALAPPDATA") or tempfile.gettempdir())
+ / "ask-question-mcp"
+ / "webview2-profile"
+)
+_WV2_DIR.mkdir(parents=True, exist_ok=True)
+os.environ["WEBVIEW2_USER_DATA_FOLDER"] = str(_WV2_DIR)
+
+try:
+ (Path(tempfile.gettempdir()) / "ask-question-mcp-webview-boot.log").write_text(
+ f"pid={os.getpid()} py={sys.executable}\nwebview2={_WV2_DIR}\n",
+ encoding="utf-8",
+ )
+except OSError:
+ pass
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+try:
+ import prefs as _prefs
+except ImportError: # pragma: no cover
+ _prefs = None # type: ignore[assignment]
+try:
+ import danger_arm as _danger_arm
+except ImportError: # pragma: no cover
+ _danger_arm = None # type: ignore[assignment]
+
+_DIALOG_DIR = Path(__file__).resolve().parent / "assets" / "dialog"
+_INDEX = _DIALOG_DIR / "index.html"
+
+_emitted = False
+_t0 = time.perf_counter()
+_HWND: int | None = None
+_DEBUG = os.environ.get("ASK_QUESTION_DEBUG", "1").strip().lower() not in {
+ "0",
+ "false",
+ "no",
+ "off",
+}
+_DEBUG_LOG = Path(tempfile.gettempdir()) / "ask-question-mcp-webview-debug.log"
+_debug_lock = threading.Lock()
+
+
+def _dbg(msg: str, **extra: Any) -> None:
+ if not _DEBUG:
+ return
+ ms = int((time.perf_counter() - _t0) * 1000)
+ thread = threading.current_thread().name
+ line = f"+{ms:5d}ms [{thread}] {msg}"
+ if extra:
+ try:
+ line += " " + json.dumps(extra, ensure_ascii=False, default=str)
+ except Exception: # noqa: BLE001
+ line += f" {extra!r}"
+ try:
+ with _debug_lock:
+ with _DEBUG_LOG.open("a", encoding="utf-8") as fh:
+ fh.write(line + "\n")
+ except OSError:
+ pass
+ try:
+ sys.stderr.write(line + "\n")
+ sys.stderr.flush()
+ except OSError:
+ pass
+
+
+def _emit(result: dict[str, Any], result_path: str | None = None) -> None:
+ global _emitted
+ if _emitted:
+ return
+ _emitted = True
+ _dbg("emit", result=result, result_path=result_path)
+ line = json.dumps(result, ensure_ascii=False)
+ if result_path:
+ try:
+ Path(result_path).write_text(line + "\n", encoding="utf-8")
+ except OSError:
+ pass
+ try:
+ sys.stdout.write(line + "\n")
+ sys.stdout.flush()
+ except OSError:
+ pass
+
+
+class _Bridge:
+ def __init__(self, payload: dict[str, Any], result_path: str | None = None) -> None:
+ self._payload = payload
+ self._result_path = result_path
+ self._result: dict[str, Any] = {
+ "cancelled": True,
+ "reason": "no selection",
+ }
+ self._window = None
+ self._closed = False
+ self._content_ready = threading.Event()
+ self._close_requested = threading.Event()
+
+ def get_payload(self) -> dict[str, Any]:
+ _dbg("api get_payload")
+ return self._payload
+
+ def content_ready(self) -> None:
+ """JS painted — flag only. Do not touch the HWND from this thread."""
+ _dbg("api content_ready")
+ self._content_ready.set()
+
+ def debug(self, message: str = "", **_kwargs: Any) -> None:
+ """JS → Python debug breadcrumb."""
+ _dbg(f"js: {message}")
+
+ def submit(self, ids: list[Any] | None = None, freeform_text: str | None = None) -> None:
+ chosen = [str(x) for x in (ids or []) if str(x).strip()]
+ out: dict[str, Any] = {"ids": chosen}
+ typed = (freeform_text or "").strip()
+ if typed:
+ out["freeform_text"] = typed
+ if not chosen:
+ self._result = {"cancelled": True, "reason": "empty selection"}
+ else:
+ self._result = out
+ _dbg("api submit", out=out)
+ self._request_close()
+
+ def cancel(self, reason: str = "user cancelled") -> None:
+ self._result = {"cancelled": True, "reason": str(reason or "user cancelled")}
+ _dbg("api cancel", reason=reason)
+ self._request_close()
+
+ def resize_to(self, width: int = 0, height: int = 0) -> None:
+ """No-op — resizing from the JS bridge deadlocks WebView2 under Cursor."""
+ _dbg("api resize_to ignored", width=width, height=height)
+ return
+
+ def _request_close(self) -> None:
+ if self._closed:
+ return
+ self._closed = True
+ _emit(self._result, self._result_path)
+ self._close_requested.set()
+ _dbg("close requested — hide + bail")
+ # Hide immediately (ctypes only — safe from JS thread) so close feels instant.
+ global _HWND
+ if _HWND:
+ try:
+ import ctypes
+
+ ctypes.windll.user32.ShowWindow(_HWND, 0) # SW_HIDE
+ _dbg("hwnd hidden", hwnd=_HWND)
+ except Exception as exc: # noqa: BLE001
+ _dbg("hwnd hide failed", error=str(exc))
+
+ def _bail() -> None:
+ time.sleep(0.05)
+ _dbg("os._exit bail")
+ os._exit(0 if not self._result.get("cancelled") else 1)
+
+ threading.Thread(target=_bail, daemon=True, name="bail").start()
+
+
+def _soft_foreground(window: Any) -> None:
+ """Bring to front without AttachThreadInput / pywebview on_top (those freeze)."""
+ _dbg("soft_foreground begin")
+ try:
+ import ctypes
+ from ctypes import wintypes
+
+ hwnd = int(window.native.Handle.ToInt64())
+ global _HWND
+ _HWND = hwnd
+ user32 = ctypes.windll.user32
+
+ SW_RESTORE = 9
+ HWND_TOPMOST = -1
+ HWND_NOTOPMOST = -2
+ SWP_NOMOVE = 0x0002
+ SWP_NOSIZE = 0x0001
+ SWP_SHOWWINDOW = 0x0040
+ KEYEVENTF_KEYUP = 0x0002
+ VK_MENU = 0x12 # Alt
+
+ user32.ShowWindow(hwnd, SW_RESTORE)
+ # Brief TOPMOST via Win32 (not window.on_top) — then drop it.
+ user32.SetWindowPos(
+ hwnd,
+ HWND_TOPMOST,
+ 0,
+ 0,
+ 0,
+ 0,
+ SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW,
+ )
+ # Alt tap unlocks SetForegroundWindow when Windows blocks focus steal.
+ user32.keybd_event(VK_MENU, 0, 0, 0)
+ ok = bool(user32.SetForegroundWindow(hwnd))
+ user32.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0)
+ user32.BringWindowToTop(hwnd)
+ user32.SetWindowPos(
+ hwnd,
+ HWND_NOTOPMOST,
+ 0,
+ 0,
+ 0,
+ 0,
+ SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW,
+ )
+ fg = int(user32.GetForegroundWindow() or 0)
+ _dbg(
+ "soft_foreground done",
+ hwnd=hwnd,
+ set_fg=ok,
+ foreground_now=fg,
+ we_are_fg=(fg == hwnd),
+ )
+ except Exception as exc: # noqa: BLE001
+ _dbg("soft_foreground failed", error=str(exc))
+
+
+def _lifecycle(bridge: _Bridge, window: Any, result_path: str | None) -> None:
+ """Side thread: soft-raise after paint; exit after close request."""
+ _dbg("lifecycle waiting for content_ready")
+ if bridge._content_ready.wait(timeout=12.0):
+ _dbg("lifecycle content_ready observed")
+ time.sleep(0.2)
+ if not bridge._closed:
+ _soft_foreground(window)
+ time.sleep(0.4)
+ if not bridge._closed:
+ _soft_foreground(window)
+ else:
+ _dbg("lifecycle blank timeout")
+ if not bridge._closed and not _emitted:
+ bridge._result = {
+ "cancelled": True,
+ "reason": "webview blank / content failed to load",
+ }
+ _emit(bridge._result, result_path)
+ os._exit(1)
+
+ _dbg("lifecycle waiting for close")
+ bridge._close_requested.wait(timeout=3600)
+ _dbg("lifecycle close seen — destroy attempt")
+ time.sleep(0.2)
+
+ def _destroy() -> None:
+ try:
+ window.destroy()
+ _dbg("destroy returned")
+ except Exception as exc: # noqa: BLE001
+ _dbg("destroy failed", error=str(exc))
+
+ threading.Thread(target=_destroy, daemon=True, name="destroy").start()
+ time.sleep(0.8)
+ if not _emitted:
+ _emit(bridge._result, result_path)
+ _dbg("lifecycle os._exit")
+ os._exit(0 if not bridge._result.get("cancelled") else 1)
+
+
+def _arm_watchdog(bridge: _Bridge, timeout_sec: int, result_path: str | None) -> None:
+ if timeout_sec <= 0:
+ return
+
+ def _fire() -> None:
+ if bridge._closed or _emitted:
+ return
+ bridge._result = {"cancelled": True, "reason": "timeout"}
+ _emit(bridge._result, result_path)
+ bridge._close_requested.set()
+ time.sleep(0.3)
+ os._exit(1)
+
+ threading.Timer(float(timeout_sec), _fire).start()
+
+
+def main() -> int:
+ result_path: str | None = None
+ try:
+ payload = json.load(sys.stdin)
+ except json.JSONDecodeError as exc:
+ _emit({"cancelled": True, "reason": f"bad json: {exc}"})
+ return 1
+
+ result_path = str(payload.get("result_path") or "").strip() or None
+
+ question = str(payload.get("question") or "").strip()
+ title = str(payload.get("title") or "Decide")
+ ids: list[str] = [str(x) for x in (payload.get("ids") or [])]
+ if not question or len(ids) < 2:
+ _emit({"cancelled": True, "reason": "invalid payload"}, result_path)
+ return 1
+
+ if not _INDEX.is_file():
+ _emit(
+ {"cancelled": True, "reason": f"missing dialog assets: {_INDEX}"},
+ result_path,
+ )
+ return 1
+
+ try:
+ import webview
+ except ImportError as exc:
+ _emit(
+ {
+ "cancelled": True,
+ "reason": f"pywebview unavailable: {exc}. Run: uv sync",
+ },
+ result_path,
+ )
+ return 1
+
+ ui_payload = dict(payload)
+ ui_payload["question"] = question
+ ui_payload["title"] = title
+ ui_payload["ids"] = ids
+ ui_payload["labels"] = {
+ str(k): str(v) for k, v in (payload.get("labels") or {}).items()
+ }
+ ui_payload["preselect"] = [str(x) for x in (payload.get("preselect") or [])]
+ ui_payload["recommended_ids"] = [
+ str(x) for x in (payload.get("recommended_ids") or [])
+ ]
+ ui_payload["danger_ids"] = [str(x) for x in (payload.get("danger_ids") or [])]
+ ui_payload["dangerous"] = bool(
+ payload.get("dangerous") or ui_payload["danger_ids"]
+ )
+ ui_payload["allow_multiple"] = bool(payload.get("allow_multiple"))
+ ui_payload["allow_other"] = bool(payload.get("allow_other", True))
+ timeout_sec = int(payload.get("timeout_sec") or 0)
+ ui_payload["timeout_sec"] = timeout_sec
+ ui_payload["agent_hint"] = title
+ ui_payload["arm_ms"] = (
+ int(_danger_arm.danger_arm_ms(dangerous=ui_payload["dangerous"]))
+ if _danger_arm is not None
+ else (4000 if ui_payload["dangerous"] else 1000)
+ )
+ # Aesthetic theme: glass | ink | signal (CSS data-theme).
+ theme = str(
+ payload.get("theme") or os.environ.get("ASK_QUESTION_THEME") or "glass"
+ )
+ ui_payload["theme"] = theme.strip().lower() or "glass"
+
+ if ui_payload["dangerous"] and _danger_arm is not None:
+ title = _danger_arm.prefix_danger_mark(title)
+
+ geom = (
+ _prefs.get_window_geometry()
+ if _prefs is not None
+ else {"w": 560, "h": 640}
+ )
+ n_opts = len(ids)
+ label_chars = sum(len(ui_payload["labels"].get(i, i)) for i in ids)
+ wrap_lines = max(n_opts, (label_chars // 42) + n_opts)
+ needed = 300 + wrap_lines * 36
+ if ui_payload["allow_other"]:
+ needed += 150
+ if ui_payload["dangerous"]:
+ needed += 72
+ needed = max(620, min(needed, 920))
+ remembered = int(geom.get("h") or 0)
+ height = min(remembered, 920) if remembered >= needed else needed
+ width = max(480, min(720, int(geom.get("w") or 560)))
+
+ bridge = _Bridge(ui_payload, result_path=result_path)
+ index_url = _INDEX.resolve().as_uri()
+ try:
+ _DEBUG_LOG.write_text(
+ f"=== webview debug pid={os.getpid()} ===\n", encoding="utf-8"
+ )
+ except OSError:
+ pass
+ _dbg(
+ "create_window",
+ url=index_url,
+ width=width,
+ height=height,
+ title=title,
+ n_opts=n_opts,
+ )
+ window = webview.create_window(
+ title,
+ url=index_url,
+ js_api=bridge,
+ width=width,
+ height=height,
+ min_size=(400, max(360, min(needed, 560))),
+ frameless=True,
+ easy_drag=True,
+ on_top=False,
+ shadow=True,
+ focus=True,
+ hidden=False,
+ background_color="#100D1C",
+ text_select=True,
+ )
+ bridge._window = window
+ _arm_watchdog(bridge, timeout_sec, result_path)
+ threading.Thread(
+ target=_lifecycle,
+ args=(bridge, window, result_path),
+ daemon=True,
+ name="lifecycle",
+ ).start()
+
+ _dbg("webview.start begin")
+ try:
+ webview.start(private_mode=False)
+ _dbg("webview.start returned")
+ except Exception as exc: # noqa: BLE001
+ _dbg("webview.start failed", error=str(exc))
+ _emit({"cancelled": True, "reason": f"webview failed: {exc}"}, result_path)
+ return 1
+
+ _emit(bridge._result, result_path)
+ return 0 if not bridge._result.get("cancelled") else 1
+
+
+if __name__ == "__main__":
+ try:
+ raise SystemExit(main())
+ except SystemExit:
+ raise
+ except Exception as exc: # noqa: BLE001
+ _emit({"cancelled": True, "reason": f"win_webview crash: {exc}"})
+ raise SystemExit(1) from exc
diff --git a/src/ask_question_mcp/win_webview_entry_ask.py b/src/ask_question_mcp/win_webview_entry_ask.py
new file mode 100644
index 0000000..adcb5b7
--- /dev/null
+++ b/src/ask_question_mcp/win_webview_entry_ask.py
@@ -0,0 +1,264 @@
+#!/usr/bin/env python3
+"""Windows WebView2 freeform entry — Nebula aesthetic.
+
+Same blank/freeze hardening as ``win_webview_ask.py``.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+from typing import Any
+
+_WV2_DIR = Path(tempfile.gettempdir()) / f"ask-question-mcp-webview2-{os.getpid()}"
+_WV2_DIR.mkdir(parents=True, exist_ok=True)
+os.environ["WEBVIEW2_USER_DATA_FOLDER"] = str(_WV2_DIR)
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+try:
+ import prefs as _prefs
+except ImportError: # pragma: no cover
+ _prefs = None # type: ignore[assignment]
+
+_DIALOG_DIR = Path(__file__).resolve().parent / "assets" / "dialog"
+_ENTRY = _DIALOG_DIR / "entry.html"
+
+_emitted = False
+
+
+def _emit(result: dict[str, Any], result_path: str | None = None) -> None:
+ global _emitted
+ if _emitted:
+ return
+ _emitted = True
+ line = json.dumps(result, ensure_ascii=False)
+ if result_path:
+ try:
+ Path(result_path).write_text(line + "\n", encoding="utf-8")
+ except OSError:
+ pass
+ try:
+ sys.stdout.write(line + "\n")
+ sys.stdout.flush()
+ except OSError:
+ pass
+
+
+class _Bridge:
+ def __init__(self, payload: dict[str, Any], result_path: str | None = None) -> None:
+ self._payload = payload
+ self._result_path = result_path
+ self._result: dict[str, Any] = {
+ "cancelled": True,
+ "reason": "no text",
+ }
+ self._window = None
+ self._closed = False
+ self._content_ready = threading.Event()
+
+ def get_payload(self) -> dict[str, Any]:
+ return self._payload
+
+ def content_ready(self) -> None:
+ if self._content_ready.is_set():
+ return
+ self._content_ready.set()
+ win = self._window
+ if win is not None:
+ try:
+ win.show()
+ except Exception: # noqa: BLE001
+ pass
+ _raise_to_front(win)
+
+ def submit(self, text: str | None = None) -> None:
+ value = (text or "").strip()
+ if not value:
+ self._result = {"cancelled": True, "reason": "empty freeform entry"}
+ else:
+ self._result = {"text": value}
+ self._close()
+
+ def cancel(self, reason: str = "entry cancelled") -> None:
+ self._result = {"cancelled": True, "reason": str(reason or "entry cancelled")}
+ self._close()
+
+ def _close(self) -> None:
+ if self._closed:
+ return
+ self._closed = True
+ _emit(self._result, self._result_path)
+
+ def _destroy() -> None:
+ win = self._window
+ if win is None:
+ return
+ try:
+ win.destroy()
+ except Exception: # noqa: BLE001
+ pass
+
+ threading.Thread(target=_destroy, daemon=True).start()
+
+ def _bail() -> None:
+ os._exit(0 if not self._result.get("cancelled") else 1)
+
+ threading.Timer(1.2, _bail).start()
+
+
+def _force_exit(bridge: _Bridge, result_path: str | None) -> None:
+ _emit(bridge._result, result_path)
+ threading.Thread(target=bridge._close, daemon=True).start()
+ time.sleep(0.4)
+ os._exit(1)
+
+
+def _arm_watchdog(bridge: _Bridge, timeout_sec: int, result_path: str | None) -> None:
+ if timeout_sec <= 0:
+ return
+
+ def _fire() -> None:
+ if bridge._closed or _emitted:
+ return
+ bridge._result = {"cancelled": True, "reason": "timeout"}
+ _force_exit(bridge, result_path)
+
+ threading.Timer(float(timeout_sec), _fire).start()
+
+
+def _arm_blank_watchdog(bridge: _Bridge, result_path: str | None, sec: float = 4.0) -> None:
+ def _fire() -> None:
+ if bridge._content_ready.is_set() or bridge._closed or _emitted:
+ return
+ bridge._result = {
+ "cancelled": True,
+ "reason": "webview blank / content failed to load",
+ }
+ _force_exit(bridge, result_path)
+
+ threading.Timer(sec, _fire).start()
+
+
+def _raise_to_front(window: Any) -> None:
+ try:
+ window.on_top = True
+ except Exception: # noqa: BLE001
+ pass
+ for meth in ("show", "restore"):
+ try:
+ fn = getattr(window, meth, None)
+ if callable(fn):
+ fn()
+ except Exception: # noqa: BLE001
+ pass
+
+ def _win32() -> None:
+ try:
+ import ctypes
+
+ hwnd = int(window.native.Handle.ToInt64())
+ user32 = ctypes.windll.user32
+ user32.ShowWindow(hwnd, 9)
+ foreground = user32.GetForegroundWindow()
+ if foreground:
+ other_tid = user32.GetWindowThreadProcessId(foreground, None)
+ our_tid = ctypes.windll.kernel32.GetCurrentThreadId()
+ user32.AttachThreadInput(other_tid, our_tid, True)
+ user32.BringWindowToTop(hwnd)
+ user32.SetForegroundWindow(hwnd)
+ user32.AttachThreadInput(other_tid, our_tid, False)
+ else:
+ user32.BringWindowToTop(hwnd)
+ user32.SetForegroundWindow(hwnd)
+ except Exception: # noqa: BLE001
+ pass
+
+ def _release() -> None:
+ try:
+ window.on_top = False
+ except Exception: # noqa: BLE001
+ pass
+
+ threading.Timer(0.05, _win32).start()
+ threading.Timer(0.35, _win32).start()
+ threading.Timer(1.0, _release).start()
+
+
+def main() -> int:
+ result_path: str | None = None
+ try:
+ payload = json.load(sys.stdin)
+ except json.JSONDecodeError as exc:
+ _emit({"cancelled": True, "reason": f"bad json: {exc}"})
+ return 1
+
+ result_path = str(payload.get("result_path") or "").strip() or None
+
+ if not _ENTRY.is_file():
+ _emit({"cancelled": True, "reason": f"missing {_ENTRY}"}, result_path)
+ return 1
+
+ try:
+ import webview
+ except ImportError as exc:
+ _emit(
+ {"cancelled": True, "reason": f"pywebview unavailable: {exc}"},
+ result_path,
+ )
+ return 1
+
+ title = str(payload.get("title") or "Something else")
+ prompt = str(payload.get("prompt") or "Type your answer")
+ initial = str(payload.get("initial_text") or "")
+ timeout_sec = int(payload.get("timeout_sec") or 0)
+
+ ui = {
+ "title": title,
+ "prompt": prompt,
+ "initial_text": initial,
+ "timeout_sec": timeout_sec,
+ }
+ bridge = _Bridge(ui, result_path=result_path)
+ window = webview.create_window(
+ title,
+ url=_ENTRY.resolve().as_uri(),
+ js_api=bridge,
+ width=480,
+ height=320,
+ min_size=(360, 240),
+ frameless=True,
+ easy_drag=False,
+ on_top=False,
+ shadow=True,
+ focus=False,
+ hidden=True,
+ background_color="#100D1C",
+ text_select=True,
+ )
+ bridge._window = window
+ _arm_watchdog(bridge, timeout_sec, result_path)
+ _arm_blank_watchdog(bridge, result_path, sec=4.0)
+
+ try:
+ webview.start(private_mode=False)
+ except Exception as exc: # noqa: BLE001
+ _emit({"cancelled": True, "reason": f"webview failed: {exc}"}, result_path)
+ return 1
+
+ _emit(bridge._result, result_path)
+ return 0 if not bridge._result.get("cancelled") else 1
+
+
+if __name__ == "__main__":
+ try:
+ raise SystemExit(main())
+ except SystemExit:
+ raise
+ except Exception as exc: # noqa: BLE001
+ _emit({"cancelled": True, "reason": f"win_webview_entry crash: {exc}"})
+ raise SystemExit(1) from exc
diff --git a/src/ask_question_mcp/zenity_ask.py b/src/ask_question_mcp/zenity_ask.py
index 87a78af..82095e3 100644
--- a/src/ask_question_mcp/zenity_ask.py
+++ b/src/ask_question_mcp/zenity_ask.py
@@ -1,4 +1,4 @@
-"""Present MCQs via a desktop list dialog (Gtk4/Adw on Linux; tkinter on Windows).
+"""Present MCQs via a desktop list dialog (Gtk4/Adw on Linux; WebView2/tk on Windows).
Linux: Zenity 4 ``--list`` always attaches a GtkSearchBar with key-capture on
the column view, so typing filters the list even when the search entry is
@@ -6,8 +6,10 @@
PyGObject). Freeform ``Something else`` / ``opens_entry`` options use
``gtk4_entry_ask.py`` (type + Listen / STT); zenity ``--entry`` is fallback.
-Windows (Phase 1): ``win_list_ask.py`` / ``win_entry_ask.py`` (tkinter) —
-text click/type only; no speak/duck/STT.
+Windows: prefer frameless Nebula WebView2 (``win_webview_ask.py``, theme
+``glass`` by default); then Edge ``--app`` (``win_edge_ask.py``); then
+tkinter. Override with ``ASK_QUESTION_WIN_UI=pywebview|edge|tk|auto`` and
+``ASK_QUESTION_THEME=glass|ink|signal|hybrid``.
Recommended options are listed first and pre-selected. Dangerous decisions
get danger chrome. Window title includes the raising agent/lane. On Linux,
@@ -21,6 +23,7 @@
import shutil
import subprocess
import sys
+import time
from pathlib import Path
from typing import Any
@@ -41,11 +44,120 @@
# Standalone Gtk4 dialogs (must run under system python with gi/Adw).
_GTK4_LIST_ASK = Path(__file__).resolve().with_name("gtk4_list_ask.py")
_GTK4_ENTRY_ASK = Path(__file__).resolve().with_name("gtk4_entry_ask.py")
-# Windows tkinter dialogs (may run under uv venv python if tkinter works).
+# Windows dialogs — WebView2 (glass) → Edge --app → tkinter.
+_WIN_WEBVIEW_ASK = Path(__file__).resolve().with_name("win_webview_ask.py")
+_WIN_WEBVIEW_ENTRY_ASK = Path(__file__).resolve().with_name(
+ "win_webview_entry_ask.py"
+)
+_WIN_EDGE_ASK = Path(__file__).resolve().with_name("win_edge_ask.py")
_WIN_LIST_ASK = Path(__file__).resolve().with_name("win_list_ask.py")
_WIN_ENTRY_ASK = Path(__file__).resolve().with_name("win_entry_ask.py")
+def _win_webview2_env() -> dict[str, str]:
+ """Env for Windows dialog children — isolated Edge profile, unbuffered IO."""
+ env = dict(os.environ)
+ env["PYTHONUNBUFFERED"] = "1"
+ # Per-spawn folder (parent pid + nonce) so we never collide with Cursor's
+ # WebView2 or a previous orphaned dialog.
+ nonce = f"{os.getpid()}-{time.time_ns()}"
+ folder = (
+ Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".")
+ / f"ask-question-mcp-webview2-{nonce}"
+ )
+ try:
+ folder.mkdir(parents=True, exist_ok=True)
+ except OSError:
+ pass
+ env["WEBVIEW2_USER_DATA_FOLDER"] = str(folder)
+ return env
+
+
+def _kill_process_tree(pid: int) -> None:
+ """Force-kill dialog + WebView2 grandchildren (Windows)."""
+ if pid <= 0:
+ return
+ try:
+ subprocess.run(
+ ["taskkill", "/F", "/T", "/PID", str(pid)],
+ check=False,
+ capture_output=True,
+ timeout=8,
+ )
+ except (OSError, subprocess.TimeoutExpired):
+ try:
+ os.kill(pid, 9)
+ except OSError:
+ pass
+
+
+def _run_win_dialog(
+ win_py: str,
+ script: Path,
+ payload: dict[str, Any],
+ *,
+ timeout_sec: int,
+ grace_sec: int = 15,
+) -> tuple[str, int, str]:
+ """Spawn Windows MCQ/entry dialog; never leave orphans on timeout.
+
+ Returns ``(stdout, returncode, stderr)``. Do **not** use
+ ``CREATE_BREAKAWAY_FROM_JOB`` — that orphaned the GUI under Cursor MCP
+ (parent saw empty exit 0 while the dialog kept freezing the desktop).
+ """
+ result_path = (
+ Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".")
+ / f"ask-question-mcp-result-{os.getpid()}-{id(payload)}.json"
+ )
+ try:
+ if result_path.is_file():
+ result_path.unlink()
+ except OSError:
+ pass
+
+ body = dict(payload)
+ body["result_path"] = str(result_path)
+ wait = (timeout_sec + grace_sec) if timeout_sec > 0 else None
+ proc = subprocess.Popen(
+ [win_py, "-u", str(script)],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ env=_win_webview2_env(),
+ # New process group aids taskkill /T without BREAKAWAY_FROM_JOB
+ # (breakaway orphaned dialogs under Cursor and froze the UI).
+ creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
+ )
+ try:
+ stdout, stderr = proc.communicate(
+ input=json.dumps(body),
+ timeout=wait,
+ )
+ except subprocess.TimeoutExpired:
+ _kill_process_tree(proc.pid)
+ try:
+ stdout, stderr = proc.communicate(timeout=5)
+ except (subprocess.TimeoutExpired, ValueError, OSError):
+ stdout, stderr = "", ""
+ raise AskCancelled(
+ f"Windows dialog timed out (killed pid={proc.pid} script={script.name})"
+ ) from None
+
+ raw = (stdout or "").strip()
+ if not raw and result_path.is_file():
+ try:
+ raw = result_path.read_text(encoding="utf-8").strip()
+ except OSError:
+ raw = ""
+ try:
+ if result_path.is_file():
+ result_path.unlink()
+ except OSError:
+ pass
+ return raw, int(proc.returncode or 0), (stderr or "")
+
+
def _truthy_env(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
@@ -165,9 +277,17 @@ def _probe_gi_adw(py: str) -> tuple[bool, str]:
def _probe_tkinter(py: str) -> tuple[bool, str]:
+ # Prefer a cheap importlib check — spawning ``import tkinter`` as a
+ # subprocess from the MCP stdio host often hangs on Windows.
+ if Path(py).resolve() == Path(sys.executable).resolve():
+ import importlib.util
+
+ if importlib.util.find_spec("tkinter") is not None:
+ return True, f"{py} (tkinter present)"
+ return False, f"tkinter not importable on {py}"
try:
r = subprocess.run(
- [py, "-c", "import tkinter; print('ok')"],
+ [py, "-c", "import importlib.util; print('ok' if importlib.util.find_spec('tkinter') else 'no')"],
capture_output=True,
text=True,
timeout=8,
@@ -181,26 +301,161 @@ def _probe_tkinter(py: str) -> tuple[bool, str]:
return False, str(exc)
-def _ensure_ui_ready() -> str:
- """Require a working dialog stack before any speak / duck / STT.
+def _probe_webview(py: str) -> tuple[bool, str]:
+ # Same trap as tkinter: full ``import webview`` under MCP stdio can hang
+ # (pythonnet). Presence check is enough; the dialog subprocess loads it.
+ if Path(py).resolve() == Path(sys.executable).resolve():
+ import importlib.util
- Linux: DISPLAY + Gtk4/Adw. Windows: win_* scripts + tkinter.
- Returns a display token (DISPLAY value on Linux; ``win32`` on Windows).
- """
- if _is_windows():
- if not _WIN_LIST_ASK.is_file():
+ if importlib.util.find_spec("webview") is not None:
+ return True, f"{py} (pywebview present)"
+ return False, f"pywebview not installed on {py}"
+ try:
+ r = subprocess.run(
+ [
+ py,
+ "-c",
+ "import importlib.util; print('ok' if importlib.util.find_spec('webview') else 'no')",
+ ],
+ capture_output=True,
+ text=True,
+ timeout=8,
+ check=False,
+ )
+ if r.returncode == 0 and "ok" in (r.stdout or ""):
+ return True, py
+ err = (r.stderr or r.stdout or "").strip()[:200]
+ return False, err or f"exit {r.returncode}"
+ except Exception as exc: # noqa: BLE001
+ return False, str(exc)
+
+
+def _find_edge_browser() -> str | None:
+ """Path to msedge.exe if present (shared with win_edge_ask)."""
+ env = os.environ.get("ASK_QUESTION_EDGE", "").strip()
+ if env and Path(env).is_file():
+ return env
+ which = shutil.which("msedge") or shutil.which("msedge.exe")
+ if which:
+ return which
+ for cand in (
+ Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"))
+ / "Microsoft"
+ / "Edge"
+ / "Application"
+ / "msedge.exe",
+ Path(os.environ.get("ProgramFiles", r"C:\Program Files"))
+ / "Microsoft"
+ / "Edge"
+ / "Application"
+ / "msedge.exe",
+ ):
+ if cand.is_file():
+ return str(cand)
+ return None
+
+
+def _win_ui_preference() -> str:
+ """``pywebview`` / ``nebula`` (frameless WebView2), ``edge``, ``tk``, or ``auto``."""
+ raw = os.environ.get("ASK_QUESTION_WIN_UI", "auto").strip().lower()
+ if raw in {"pywebview", "webview", "web", "nebula", "webview2"}:
+ return "pywebview"
+ if raw in {"edge"}:
+ return "edge"
+ if raw in {"tk", "tkinter", "tcl"}:
+ return "tk"
+ return "auto"
+
+
+def _resolve_win_list_script(win_py: str) -> Path:
+ pref = _win_ui_preference()
+
+ if pref == "edge":
+ if not _WIN_EDGE_ASK.is_file():
+ raise RuntimeError(f"missing {_WIN_EDGE_ASK}")
+ if not _find_edge_browser():
raise RuntimeError(
- f"missing Windows list dialog: {_WIN_LIST_ASK}. "
- "Point mcp.json --directory at the ask-question-mcp checkout."
+ "Microsoft Edge not found. Install Edge or set ASK_QUESTION_EDGE, "
+ "or ASK_QUESTION_WIN_UI=pywebview|tk."
)
- win_py = _resolve_win_python()
+ return _WIN_EDGE_ASK
+
+ if pref == "pywebview":
+ if not _WIN_WEBVIEW_ASK.is_file():
+ raise RuntimeError(f"missing {_WIN_WEBVIEW_ASK}")
+ ok, detail = _probe_webview(win_py)
+ if not ok:
+ raise RuntimeError(
+ f"pywebview unavailable on {win_py}: {detail}. Run: uv sync"
+ )
+ return _WIN_WEBVIEW_ASK
+
+ if pref == "tk":
+ if not _WIN_LIST_ASK.is_file():
+ raise RuntimeError(f"missing Windows list dialog: {_WIN_LIST_ASK}")
+ ok, detail = _probe_tkinter(win_py)
+ if not ok:
+ raise RuntimeError(f"tkinter unavailable on {win_py}: {detail}")
+ return _WIN_LIST_ASK
+
+ # auto: frameless WebView2 → Edge --app → tk
+ if _WIN_WEBVIEW_ASK.is_file():
+ wv_ok, _ = _probe_webview(win_py)
+ if wv_ok:
+ return _WIN_WEBVIEW_ASK
+ if _WIN_EDGE_ASK.is_file() and _find_edge_browser():
+ return _WIN_EDGE_ASK
+ if _WIN_LIST_ASK.is_file():
ok, detail = _probe_tkinter(win_py)
+ if ok:
+ return _WIN_LIST_ASK
+ else:
+ detail = "missing win_list_ask.py"
+ raise RuntimeError(
+ f"No working Windows UI backend on {win_py}: {detail}. "
+ "Install pywebview (uv sync), Edge, or Python tcl/tk."
+ )
+
+
+def _resolve_win_entry_script(win_py: str) -> Path:
+ """Freeform entry — tk (Edge list dialog already has inline freeform)."""
+ pref = _win_ui_preference()
+ if pref == "pywebview":
+ if not _WIN_WEBVIEW_ENTRY_ASK.is_file():
+ raise RuntimeError(f"missing {_WIN_WEBVIEW_ENTRY_ASK}")
+ ok, detail = _probe_webview(win_py)
if not ok:
raise RuntimeError(
- f"tkinter unavailable on {win_py}: {detail}. "
- "Install Python from python.org with tcl/tk (DEPENDENCIES.md "
- "tier B Windows)."
+ f"pywebview unavailable on {win_py}: {detail}. Run: uv sync"
)
+ return _WIN_WEBVIEW_ENTRY_ASK
+
+ if not _WIN_ENTRY_ASK.is_file():
+ raise RuntimeError(f"missing Windows entry dialog: {_WIN_ENTRY_ASK}")
+ ok, detail = _probe_tkinter(win_py)
+ if ok:
+ return _WIN_ENTRY_ASK
+
+ if _WIN_WEBVIEW_ENTRY_ASK.is_file():
+ wv_ok, _ = _probe_webview(win_py)
+ if wv_ok:
+ return _WIN_WEBVIEW_ENTRY_ASK
+
+ raise RuntimeError(
+ f"No working Windows entry backend on {win_py}: {detail}"
+ )
+
+
+def _ensure_ui_ready() -> str:
+ """Require a working dialog stack before any speak / duck / STT.
+
+ Linux: DISPLAY + Gtk4/Adw. Windows: WebView2 (pywebview) or tkinter.
+ Returns a display token (DISPLAY value on Linux; ``win32`` on Windows).
+ """
+ if _is_windows():
+ win_py = _resolve_win_python()
+ # Resolving the list script probes webview then tk as needed.
+ _resolve_win_list_script(win_py)
return "win32"
display = os.environ.get("DISPLAY", "").strip()
@@ -302,9 +557,11 @@ def _entry_text(
Returns ``(text, voice_meta)``.
"""
if _is_windows():
- if not _WIN_ENTRY_ASK.is_file():
- raise AskCancelled(f"missing Windows entry dialog: {_WIN_ENTRY_ASK}")
win_py = _resolve_win_python()
+ try:
+ entry_script = _resolve_win_entry_script(win_py)
+ except RuntimeError as exc:
+ raise AskCancelled(str(exc)) from exc
payload = {
"title": title,
"prompt": prompt,
@@ -312,20 +569,18 @@ def _entry_text(
"timeout_sec": timeout_sec,
}
try:
- proc = subprocess.run(
- [win_py, str(_WIN_ENTRY_ASK)],
- input=json.dumps(payload),
- check=False,
- capture_output=True,
- text=True,
- timeout=timeout_sec + 30 if timeout_sec > 0 else None,
+ raw, rc, err = _run_win_dialog(
+ win_py,
+ entry_script,
+ payload,
+ timeout_sec=timeout_sec,
+ grace_sec=30,
)
- except subprocess.TimeoutExpired as exc:
- raise AskCancelled("entry timed out") from exc
- raw = (proc.stdout or "").strip()
+ except AskCancelled:
+ raise
if not raw:
- err = (proc.stderr or "").strip()
- raise AskCancelled(err or "Windows entry produced no output")
+ detail = (err or "").strip() or f"exit {rc} script={entry_script.name}"
+ raise AskCancelled(f"Windows entry produced no output ({detail})")
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
@@ -437,6 +692,7 @@ def _ask_list(
ids: list[str],
labels: dict[str, str],
preselect: set[str],
+ recommended: set[str] | None = None,
danger_ids: set[str],
dangerous: bool,
allow_multiple: bool,
@@ -450,24 +706,24 @@ def _ask_list(
capability_notes: list[str] | None = None,
images: list[str] | None = None,
) -> tuple[list[str], dict[str, Any], str | None]:
- """Radiolist/checklist via Gtk (Linux) or tkinter (Windows).
+ """Radiolist/checklist via Gtk (Linux) or WebView2/tk (Windows).
Returns ``(chosen_ids, voice_meta, freeform_text_or_None)``. When the
dialog already confirmed a spoken/typed freeform answer, ``freeform_text``
is set and the entry step is skipped.
"""
image_paths = [str(p) for p in (images or []) if str(p).strip()]
+ rec_ids = sorted(recommended or set())
if _is_windows():
- if not _WIN_LIST_ASK.is_file():
- raise RuntimeError(f"missing Windows list dialog: {_WIN_LIST_ASK}")
win_py = _resolve_win_python()
+ list_script = _resolve_win_list_script(win_py)
payload = {
"question": question.strip(),
"title": title,
"ids": ids,
"labels": labels,
"preselect": sorted(preselect),
- "recommended_ids": sorted(preselect),
+ "recommended_ids": rec_ids,
"danger_ids": sorted(danger_ids),
"dangerous": bool(dangerous or danger_ids),
"allow_multiple": allow_multiple,
@@ -482,21 +738,59 @@ def _ask_list(
"images": image_paths,
}
try:
- proc = subprocess.run(
- [win_py, str(_WIN_LIST_ASK)],
- input=json.dumps(payload),
- check=False,
- capture_output=True,
- text=True,
- timeout=timeout_sec + 15 if timeout_sec > 0 else None,
+ raw, rc, err = _run_win_dialog(
+ win_py,
+ list_script,
+ payload,
+ timeout_sec=timeout_sec,
+ grace_sec=15,
)
- except subprocess.TimeoutExpired as exc:
- raise AskCancelled("Windows list timed out") from exc
+ except AskCancelled:
+ raise
+
+ # Edge/WebView blank → fall back to tk so Cursor never sits on a dead HWND.
+ blank = False
+ if list_script in {_WIN_WEBVIEW_ASK, _WIN_EDGE_ASK}:
+ if not (raw or "").strip():
+ blank = True
+ else:
+ try:
+ probe = json.loads(raw)
+ reason = str(probe.get("reason") or "").casefold()
+ if probe.get("cancelled") and (
+ "blank" in reason or "failed to load" in reason
+ or "edge not found" in reason
+ ):
+ blank = True
+ except json.JSONDecodeError:
+ blank = True
+ if blank and _WIN_LIST_ASK.is_file():
+ try:
+ raw, rc, err = _run_win_dialog(
+ win_py,
+ _WIN_LIST_ASK,
+ payload,
+ timeout_sec=timeout_sec,
+ grace_sec=15,
+ )
+ except AskCancelled:
+ raise
- raw = (proc.stdout or "").strip()
if not raw:
- err = (proc.stderr or "").strip()
- raise AskCancelled(err or "Windows list produced no output")
+ detail = (err or "").strip() or f"exit {rc} script={list_script.name}"
+ try:
+ log = (
+ Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".")
+ / "ask-question-mcp-win-list.log"
+ )
+ log.write_text(
+ f"py={win_py}\nscript={list_script}\n"
+ f"rc={rc}\nstderr={err!r}\n",
+ encoding="utf-8",
+ )
+ except OSError:
+ pass
+ raise AskCancelled(f"Windows list produced no output ({detail})")
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
@@ -534,7 +828,7 @@ def _ask_list(
"ids": ids,
"labels": labels,
"preselect": sorted(preselect),
- "recommended_ids": sorted(preselect),
+ "recommended_ids": rec_ids,
"danger_ids": sorted(danger_ids),
"dangerous": bool(dangerous or danger_ids),
"allow_multiple": allow_multiple,
@@ -711,15 +1005,18 @@ def ask_zenity(
entry_ids = set(opens_entry_ids) | (OTHER_IDS & set(ids))
preselect: set[str] = set()
+ recommended: set[str] = set()
if recommended_ids:
for rid in recommended_ids:
if rid not in labels:
raise ValueError(f"recommended_ids entry {rid!r} not in options")
preselect.add(rid)
+ recommended.add(rid)
if recommended_id is not None:
if recommended_id not in labels:
raise ValueError(f"recommended_id {recommended_id!r} not in options")
preselect.add(recommended_id)
+ recommended.add(recommended_id)
other_tail = [i for i in ids if i in OTHER_IDS]
core = [i for i in ids if i not in OTHER_IDS]
@@ -728,6 +1025,8 @@ def ask_zenity(
i for i in core if i not in preselect
]
elif not allow_multiple and core:
+ # Focus first option for keyboard UX — do NOT mark it Recommended
+ # unless the agent passed recommended_id / recommended_ids.
preselect.add(core[0])
ids = core + other_tail
@@ -803,6 +1102,7 @@ def _release_session_duck() -> None:
ids=ids,
labels=labels,
preselect=preselect,
+ recommended=recommended,
danger_ids=danger_ids,
dangerous=whole_danger,
allow_multiple=allow_multiple,
diff --git a/uv.lock b/uv.lock
index 61a68d1..3d6eb72 100644
--- a/uv.lock
+++ b/uv.lock
@@ -45,10 +45,14 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "mcp", extra = ["cli"] },
+ { name = "pywebview" },
]
[package.metadata]
-requires-dist = [{ name = "mcp", extras = ["cli"], specifier = ">=1.9.0" }]
+requires-dist = [
+ { name = "mcp", extras = ["cli"], specifier = ">=1.9.0" },
+ { name = "pywebview", specifier = ">=5.0" },
+]
[[package]]
name = "attrs"
@@ -59,6 +63,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
+[[package]]
+name = "bottle"
+version = "0.13.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" },
+]
+
[[package]]
name = "certifi"
version = "2026.7.22"
@@ -165,6 +178,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
]
+[[package]]
+name = "clr-loader"
+version = "0.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/46/7eea92b6aa2d68af78e049cbecec5f757f1aad44ecdecdc16bbad7eead51/clr_loader-0.3.1.tar.gz", hash = "sha256:2e073e9aaf49d1ae2f56ecba27987ad5fb68be4bcd9dd34a5bed8f0e4e128366", size = 86805, upload-time = "2026-04-18T17:49:44.287Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5e/da/ec1a6e36624000b6df0dd61183c42342ee5814c073315e802cadaad04d2f/clr_loader-0.3.1-py3-none-any.whl", hash = "sha256:cbad189de20d202a7d621956b0fc38049e13c9bf7ca2923441eff725cd121aa1", size = 55730, upload-time = "2026-04-18T17:49:42.99Z" },
+]
+
[[package]]
name = "colorama"
version = "0.4.6"
@@ -358,6 +383,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
+[[package]]
+name = "packaging"
+version = "26.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
+]
+
+[[package]]
+name = "proxy-tools"
+version = "0.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/cf/77d3e19b7fabd03895caca7857ef51e4c409e0ca6b37ee6e9f7daa50b642/proxy_tools-0.1.0.tar.gz", hash = "sha256:ccb3751f529c047e2d8a58440d86b205303cf0fe8146f784d1cbcd94f0a28010", size = 2978, upload-time = "2014-05-05T21:02:24.606Z" }
+
[[package]]
name = "pycparser"
version = "3.0"
@@ -494,6 +534,109 @@ crypto = [
{ name = "cryptography" },
]
+[[package]]
+name = "pyobjc-core"
+version = "12.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b", size = 6433347, upload-time = "2026-06-19T16:04:49.341Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" },
+ { url = "https://files.pythonhosted.org/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" },
+ { url = "https://files.pythonhosted.org/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" },
+]
+
+[[package]]
+name = "pyobjc-framework-cocoa"
+version = "12.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyobjc-core" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080", size = 388113, upload-time = "2026-06-19T16:07:38.9Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" },
+ { url = "https://files.pythonhosted.org/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" },
+]
+
+[[package]]
+name = "pyobjc-framework-quartz"
+version = "12.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyobjc-core" },
+ { name = "pyobjc-framework-cocoa" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/fc/d7c7b3134cdbd1a487f3f77b5be125d87a6c9e7d9411035739d99335cc0c/pyobjc_framework_quartz-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9", size = 219000, upload-time = "2026-06-19T16:16:04.29Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4b/861f91a1565d3189ee899e177b915551fb9a7e2ca25414025a8974f04e74/pyobjc_framework_quartz-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54c9bc7f507192691841ee4eba5bf36990b259df83ac728efed2d7ea1cd021e4", size = 219403, upload-time = "2026-06-19T16:16:05.645Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/b5/b27010d2f288737f627f74be6d5549f49c841542365c84b9a3011fe39ce7/pyobjc_framework_quartz-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:bfc0d2badd819823d21df8069dcf9544ce360ed747a8895c51bdb25d8d125f45", size = 224458, upload-time = "2026-06-19T16:16:07.252Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/5d/85ffd9d433989205d572a50d625c63b29c05e0c5235a725f15ae1023672c/pyobjc_framework_quartz-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ceb56939c337b36d9d81185ade31f77dc52c85cf79bb16e53e9b32f54b6bb3f5", size = 219769, upload-time = "2026-06-19T16:16:08.814Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/d6/b917e4b63d72ea84a27121076f3033f23f6497c0e6ce8d304766c899897f/pyobjc_framework_quartz-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8105c98b798f2bf81c05c54bddeeadbf62f0b5dfec13bd6e719dd2cdf7e1cddf", size = 224717, upload-time = "2026-06-19T16:16:10.215Z" },
+ { url = "https://files.pythonhosted.org/packages/04/e2/f3c1ed3228f7430ef5ade23db6f1fcbae99290f177ce5653348fd9e05f4d/pyobjc_framework_quartz-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:bbc214f1a216b5d3651bc832d0ac4589f029f3f37cd6cbb370aac12a7c77942c", size = 219825, upload-time = "2026-06-19T16:16:11.433Z" },
+ { url = "https://files.pythonhosted.org/packages/66/2a/2c99a5ad2fe0a11600ea123b8e9a08ff138fcb2ad1e13e376f4bd4aa1d96/pyobjc_framework_quartz-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:ca61624a0b0e6286d8a0f97f47eb9011e4e81e9a339db436d48af527e7065bb1", size = 224770, upload-time = "2026-06-19T16:16:13.035Z" },
+]
+
+[[package]]
+name = "pyobjc-framework-security"
+version = "12.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyobjc-core" },
+ { name = "pyobjc-framework-cocoa" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/44/b8/4267b802d8dba6de468e7d0765b05cc4e146fa376ed9f55e0b6461016bef/pyobjc_framework_security-12.2.1.tar.gz", hash = "sha256:d7831b1537f4346892e7f2f0e2b09d79bee98919b0767f4061278d0e03028f2d", size = 181065, upload-time = "2026-06-19T16:21:40.151Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4e/5b/2719bc4062e6c27083191fd20e365ae02d0bf1c22f4d1a88211e3d96b369/pyobjc_framework_security-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76ff6e44e62d3e15651540493879bf16687d862c4f10f3cadade757811c8b8d0", size = 41300, upload-time = "2026-06-19T16:17:17.702Z" },
+ { url = "https://files.pythonhosted.org/packages/15/90/dccd4cd6877ef208957dc1f3675287d8614a4dcd2a3ee0a5e56f5fb5a1ba/pyobjc_framework_security-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:990013baba29d6f985d8950b23701129b2597b3d16f628b785fe97596d8a8de3", size = 41299, upload-time = "2026-06-19T16:17:18.511Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/af/f9e8040e0c3ef6a50392a46ad1df482a666aa615180d40730b00282ff81f/pyobjc_framework_security-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:066a3e5e9d368e7a6ba8dd52be2077a634ef12a54fbfcc78b3b8154a8f988a1d", size = 42179, upload-time = "2026-06-19T16:17:19.48Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/3c/76e2a8bb8d5fe48f0e8e25c6abec1609f3667cc39935017badfe9e9603f2/pyobjc_framework_security-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:5319ae49b8874363ab51c6ff4d85d4ea0cfa6d836fe0306e901ba9ae560b880d", size = 41370, upload-time = "2026-06-19T16:17:20.501Z" },
+ { url = "https://files.pythonhosted.org/packages/14/6e/7120956e9833b2c70757eec1f65f57c191e00662cf74c4545d88315643fa/pyobjc_framework_security-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:21618431e0dbfbd3d4029445e3118af88e5d7e52ddecf9a2d17c759c51628d85", size = 42926, upload-time = "2026-06-19T16:17:21.425Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/ff/0bafc557523e5755f74dd5363386a1e9b03f611e2e36df0737a508cd5ab4/pyobjc_framework_security-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:fa192e9df479375e6242adcadb9a44f32907dd7fe1207608710cd3af65fe3c84", size = 41376, upload-time = "2026-06-19T16:17:22.337Z" },
+ { url = "https://files.pythonhosted.org/packages/47/33/33d266117e46fef148caa4f986b3d896cb9bfd76bef48bd761cb60c758ee/pyobjc_framework_security-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:07cd044a7996f9a897040c49055fa3bdf565acac4a25b834a72e60602376146d", size = 42944, upload-time = "2026-06-19T16:17:23.371Z" },
+]
+
+[[package]]
+name = "pyobjc-framework-uniformtypeidentifiers"
+version = "12.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyobjc-core" },
+ { name = "pyobjc-framework-cocoa" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/74/a1/108fa1e5a3dd8aff626f98fb97de370323b290404b04ffa2ef9420665ed3/pyobjc_framework_uniformtypeidentifiers-12.2.1.tar.gz", hash = "sha256:1fb89d13aa3c2df8e6d6536f6df3493fe5a6caefd2a5adebf17c5af3b29ed4a2", size = 20679, upload-time = "2026-06-19T16:21:55.739Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e3/44/18a7b3c3b4f9f6784fddf64ed5a2c148577d0300705a50e8ab81da8fc71d/pyobjc_framework_uniformtypeidentifiers-12.2.1-py2.py3-none-any.whl", hash = "sha256:ea08413ad895a7dfea13670e26548bcf5b00154084cdfb5d8f96603320e77cf3", size = 5042, upload-time = "2026-06-19T16:18:54.085Z" },
+]
+
+[[package]]
+name = "pyobjc-framework-webkit"
+version = "12.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyobjc-core" },
+ { name = "pyobjc-framework-cocoa" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/11/d2/b230c594f70ecb970b4cef67bae2648d1bfa5b381e9b7e3710bf24ec8887/pyobjc_framework_webkit-12.2.1.tar.gz", hash = "sha256:a56acae55b50d549b20dff2921ad1099add8fbc377d0de09ddc2ba50957f7def", size = 332374, upload-time = "2026-06-19T16:22:01.988Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/47/7a2099eb2e062c6230a9440f1795cf34056ca5e16ef25c8aad7c059b8734/pyobjc_framework_webkit-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7e04dcc08cdc59380113ea1232af75a0a04c2426418ebe967b4c0045c973f776", size = 50372, upload-time = "2026-06-19T16:19:31.581Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/a4/202ec288808011d3f459d000d593e88b1118f2d1d5a4dfaaf5232f2c2ac2/pyobjc_framework_webkit-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:23bee8bf7077f91da4e3ae54a00c7f5e4414319e15f98be8584dbd67c4043fae", size = 50387, upload-time = "2026-06-19T16:19:32.522Z" },
+ { url = "https://files.pythonhosted.org/packages/95/a4/f796e94b43a66704b6ae17c747c7b97fd4b79348f1cfa9bef7b008aaa718/pyobjc_framework_webkit-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:00ffb254f97e9ffdd0a82c1faa61a07f6072ba900fa8aba70c83c21198b52e4e", size = 50853, upload-time = "2026-06-19T16:19:33.43Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/f6/d24716fef19ccc3d880e99029458803f0174c05df310d991eb97ea3a0799/pyobjc_framework_webkit-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:67030258c3cd66e8495ccfccef3d2d58010ff0209284c5115e5afdb0e9fd6de1", size = 50499, upload-time = "2026-06-19T16:19:34.45Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/6c/817119a52efcc229a30ceff56a0641005a431806a1f555e0571626ba313a/pyobjc_framework_webkit-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5d91527c9950c79269dd0d70f2bb8668c298dd06930637c1c063ce5f274a87e5", size = 50967, upload-time = "2026-06-19T16:19:35.474Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/59/5fac0754d53b2a72aed6f424dfc72e5fa245f83cb57c2e00d02e45390fca/pyobjc_framework_webkit-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:657825081484c9920c50b76b469b9583f116225b0449c9d95c46cbc8c640adc8", size = 50498, upload-time = "2026-06-19T16:19:36.397Z" },
+ { url = "https://files.pythonhosted.org/packages/da/0c/e997e33d99d4ad91da2cf70f0e51ac39b03c58ba210548e9e944bbb421be/pyobjc_framework_webkit-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:f46adcc6227873f2b14d74b2e789c937f227722274ab59b9fa3c04c6ecb46dd5", size = 50958, upload-time = "2026-06-19T16:19:37.424Z" },
+]
+
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -512,6 +655,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
+[[package]]
+name = "pythonnet"
+version = "3.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "clr-loader" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/05/57/da1992e44663b71365c6e842c8d7fa453d4ec45fb99a68cfee5b7e944d3c/pythonnet-3.1.0.tar.gz", hash = "sha256:7b34c382905d10a371509ffafd64cae0416305c28817738a9cd138336f4e9991", size = 250599, upload-time = "2026-05-23T20:30:21.578Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ac/4b/52414f442624d2589f5374a48c08d5ae94f24bea67fc13a20a752884e5b7/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-any.whl", hash = "sha256:698dd88edc198819ad63b624a6ebe76208c7b46e4fe13626f65e484f0358d6ba", size = 217578, upload-time = "2026-05-23T20:30:19.527Z" },
+ { url = "https://files.pythonhosted.org/packages/db/67/031124fdcb937c266a3265118525bbf6dc13b8c79786d6a7290aecb6e7bb/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-win32.win_amd64.whl", hash = "sha256:7bdd4de03df3547a48122a3989265c8b31d5be0d19dadffa009eec7df8085e0b", size = 1644898, upload-time = "2026-05-23T20:30:16.213Z" },
+]
+
+[[package]]
+name = "pywebview"
+version = "6.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "bottle" },
+ { name = "proxy-tools" },
+ { name = "pyobjc-core", marker = "sys_platform == 'darwin'" },
+ { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" },
+ { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" },
+ { name = "pyobjc-framework-security", marker = "sys_platform == 'darwin'" },
+ { name = "pyobjc-framework-uniformtypeidentifiers", marker = "sys_platform == 'darwin'" },
+ { name = "pyobjc-framework-webkit", marker = "sys_platform == 'darwin'" },
+ { name = "pythonnet", marker = "sys_platform == 'win32'" },
+ { name = "qtpy", marker = "sys_platform == 'openbsd6'" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/59/4a/05307135dafba67778669d194bd1a3822a7685ec9ee8a6d7e70856c1a551/pywebview-6.2.1.tar.gz", hash = "sha256:71b7136752e40824655304d938efb62014218d1a90bd8e87e1cbdb1ce9c466af", size = 513126, upload-time = "2026-04-15T09:02:16.595Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3d/25/9491695c22c4842c5b3903b4dc172e0eecf67a27c0af34a71512c9b76a0a/pywebview-6.2.1-py3-none-any.whl", hash = "sha256:9d07275f53894ab4d5e2e0e996227193e7187dec276d9b624dccbce029216b46", size = 525463, upload-time = "2026-04-15T09:02:10.186Z" },
+]
+
[[package]]
name = "pywin32"
version = "312"
@@ -531,6 +709,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" },
]
+[[package]]
+name = "qtpy"
+version = "2.4.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" },
+]
+
[[package]]
name = "referencing"
version = "0.37.0"