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("<title>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"