From 366ddfa0165f622341e8241dcc894cee633ff1a9 Mon Sep 17 00:00:00 2001 From: Alex J Lennon Date: Sun, 9 Aug 2026 20:27:44 +0100 Subject: [PATCH] =?UTF-8?q?fix=20Linux=20Nebula=20known-goods:=20drag,=20?= =?UTF-8?q?=E2=86=91/=E2=86=93,=20audio=20checkbox,=20TTS=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore WebKit Nebula routing on Linux and stop ASK_QUESTION_AUDIO=1 from overriding the dialog mute checkbox. Chrome drag uses Gdk begin_move; arrow keys move option focus. Keep pasted images as JSON base64 for Cursor. Co-authored-by: Cursor --- README.md | 16 +- docs/AGENTS.md | 42 +- scripts/smoke_nebula_bridge.py | 273 +++ scripts/test_mcq_pasted.py | 108 ++ scripts/test_nebula_layout.py | 49 + scripts/test_prefs_audio_env.py | 43 + skills/ask-multiple-choice/SKILL.md | 105 +- src/ask_question_mcp/assets/dialog/dialog.css | 248 ++- src/ask_question_mcp/assets/dialog/dialog.js | 601 ++++++- src/ask_question_mcp/assets/dialog/index.html | 68 +- src/ask_question_mcp/assets/gtk/nebula.css | 192 +++ src/ask_question_mcp/dialog_keys.py | 2 +- src/ask_question_mcp/gtk4_list_ask.py | 273 ++- src/ask_question_mcp/linux_webview_ask.py | 1480 +++++++++++++++++ src/ask_question_mcp/linux_webview_voice.py | 925 +++++++++++ src/ask_question_mcp/mcq_pasted.py | 96 ++ src/ask_question_mcp/prefs.py | 13 +- src/ask_question_mcp/server.py | 42 +- src/ask_question_mcp/win_list_ask.py | 12 +- src/ask_question_mcp/zenity_ask.py | 62 +- 20 files changed, 4489 insertions(+), 161 deletions(-) create mode 100644 scripts/smoke_nebula_bridge.py create mode 100644 scripts/test_mcq_pasted.py create mode 100644 scripts/test_nebula_layout.py create mode 100644 scripts/test_prefs_audio_env.py create mode 100644 src/ask_question_mcp/assets/gtk/nebula.css create mode 100644 src/ask_question_mcp/linux_webview_ask.py create mode 100644 src/ask_question_mcp/linux_webview_voice.py create mode 100644 src/ask_question_mcp/mcq_pasted.py diff --git a/README.md b/README.md index 1917627..44c299a 100644 --- a/README.md +++ b/README.md @@ -85,13 +85,20 @@ Full env / prefs: [SETUP.md](SETUP.md). Never commit tokens. ## Features - Radiolist / checklist; recommended option first; options capped at **8** -- Keyboard: **1–8** select · **Enter** OK · **Esc** cancel (footer hint; - see [docs/AGENTS.md — Dialog UX](docs/AGENTS.md#dialog-ux-humans)) +- Keyboard: **1–8** select · **Enter** OK · **Esc** cancel · **Ctrl+V** image + (footer hint; see [docs/AGENTS.md — Dialog UX](docs/AGENTS.md#dialog-ux-humans)) - **Readable Confirm bodies:** first line (the ask) always fully visible; Command / To+body / notes scroll underneath — so tall gates do not hide the payload or push Cancel/OK off-screen - Optional **image / images** preview in the dialog (Linux Gtk; click to - enlarge, **F** / header to maximize; primary monitor only) + enlarge, **F** / header / **double-click title** to maximize; primary + monitor only). Multi-image is a **carousel** (one still at a time; click + left/right of the still, Prev/Next, or ←/→) +- **Ctrl+V paste references** (Linux Gtk Nebula): thumbnails in-dialog; + returned as MCP image blocks (max 4, no lasting files) +- **Idle timeout hold:** default `timeout_sec=0` (waits). If a timeout is set, + typing / paste / select cancels auto-close until OK/Cancel +- Linux aesthetic: **Nebula** dark glass (inspired by the Windows WebView dialog) - Remembers last dialog size (`prefs.window`; position on Windows; size-only on typical Wayland) - Windows: scrollable option list + same lead/detail Confirm layout @@ -127,8 +134,7 @@ 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 + WebView2 | Cursor | Text (Nebula UI) | No | Phase 2 UI — **Anthony laptop** | -| Windows 10/11 + tkinter | Cursor | Text | No | Fallback if pywebview missing | +| Windows 10/11 + tkinter | Cursor | Text | No | Phase 1 — **not yet reported** | | macOS / headless CI | — | No | No | Unsupported / N/A | More rows & how to report: [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 913a842..0021978 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -76,23 +76,25 @@ skill via `ask-question-install --skill` (`~/.cursor/skills/ask-multiple-choice` | `speak` | bool | no | default `true` (honours mute env / missing TTS) | | `title` | string | no | default `"Decide"` — short noun phrase | | `agent` | string \| null | **strongly yes** | Window title prefix `[agent]` | -| `timeout_sec` | int | no | default `300`; `0` = no timeout | +| `timeout_sec` | int | no | default `0` (no idle auto-close — waits for the human). Positive = soft idle; typing / paste / select **holds** until OK/Cancel. Parent also respects engagement (absolute ceiling ~4h). | | `entry_seed` | string \| null | no | Prefill Something else / entry | -| `image` | string \| null | no | Local PNG/JPEG (etc.) path or `file://` URI — Gtk preview above the question (Linux). Missing/unsupported files are skipped. | -| `images` | string[] \| null | no | Same as `image`, up to 4 paths (combined with `image`, deduped). Prefer one clear still when possible. | +| `image` | string \| null | no | Local PNG/JPEG (etc.) path or `file://` URI — preview above the question (Linux Gtk + Nebula). Missing/unsupported files are skipped. | +| `images` | string[] \| null | no | Same as `image`, up to 4 paths (combined with `image`, deduped). Gtk shows a **carousel** (one still at a time; click left/right of the still, Prev/Next, or ←/→). Prefer one clear still when possible. | -**Images in the dialog (Linux Gtk):** pass an absolute path or `file://` URI so +**Images in the dialog (Linux Gtk + Nebula):** pass an absolute path or `file://` URI so Alex sees the still *inside* the MCQ (not only in chat). Chat `Read` of a PNG does not put pixels in the dialog — use `image` / `images`. When images are present the window opens large on the **primary** usable workarea (not the largest / secondary 4K); click the preview to toggle compact (~320px) vs large, -and use the header maximize button or **F** for a soft-fill on the host panel. -**Multi-image (`images=`, max 4): the whole stack must fit ≤ primary usable -resolution** — previews share one height budget and scroll inside; never open a -window taller/wider than the primary (or smaller host) display. Text-only MCQs -stay compact. Windows Phase 1 ignores these args (text-only). Pattern: -`mcq-with-image` (signed-off — agents **must** pass `image=`/`images=` when the -human must judge a still). +and use the header maximize button, **F**, or **double-click the title bar** +for a soft-fill on the host panel. +**Multi-image (`images=`, max 4): carousel — one still visible at a time** +(click left/right of the still, Prev/Next, or ←/→); each still uses the full +single-image height budget. Do **not** dump several tiny stacked previews. +Text-only MCQs stay compact. Windows Phase 1 ignores these args (text-only). +Patterns: `mcq-with-image` + `mcq-images-one-at-a-time` (agents **must** pass +`image=`/`images=` when the human must judge a still; multi via carousel or +sequential single-image MCQs — never rely on reading chat pixels). ### Example (single choice) @@ -172,12 +174,17 @@ footer hint. Useful when coaching a human or writing host docs. | Input | Behaviour | |-------|-----------| | **1–8** (top row or keypad) | Select that option (1-based). Labels show `1 · …`. Multi-select **toggles**. Ignored while the Something else entry is focused. | +| **↑ / ↓** | Move highlight among options; **Enter** confirms (single-select also selects as you move). | | **Enter** | Confirm OK after the arm delay (same as clicking OK). | | **Esc** / window close | Cancel. | -| **Audio** (footer checkbox) | Persistent mute for TTS/STT (`prefs.audio_enabled`). Env `ASK_QUESTION_AUDIO=0` also mutes. | +| **Audio** (footer checkbox) | Persistent mute for TTS/STT (`prefs.audio_enabled`). Env `ASK_QUESTION_AUDIO=0` hard-mutes; `=1` does **not** override the checkbox. | | **R** / **L** | Replay question / Listen (Linux voice only, when configured). | -| **Click preview** (image MCQs) | Toggle large vs compact (~320px) image scale. | -| **F** / header maximize (image MCQs) | Maximize / restore the window so the still can use most of the screen. | +| **Click preview** (image MCQs) | Single still: toggle large vs compact (~320px). Multi-image: **left half → previous**, **right half → next**. | +| **F** / header maximize / **double-click title bar** (image MCQs) | Maximize / restore the window so the still can use most of the screen. | +| **← / →** or **Prev / Next** (multi-image) | Carousel: show previous / next still (one visible at a time). | +| **Ctrl+V** (Linux Gtk + Nebula; Windows Nebula) | Paste clipboard images as in-dialog **References** (max 4). No lasting local files — pixels return in JSON `pasted_images`. | +| **Linux Nebula** | Visual SoT: Anthony’s Windows fork (`theoriginalcheese/ask-question-mcp`). Hosted by `linux_webview_ask.py` (WebKit). Frameless chrome drag uses bridge `begin_move` → `Gdk.Toplevel.begin_move` (WebKit ignores `pywebview-drag-region`). Freeform/refs stay **inside** scrolling `
` so Cancel/OK never clip. Voice via `linux_webview_voice.py`; Audio checkbox wins over `ASK_QUESTION_AUDIO=1` (`=0` remains hard mute). Listen needs `ASK_QUESTION_STT_URL`. | +| **Typing / paste / select** | First freeform keystroke, image paste, or option pick **cancels idle `timeout_sec` auto-close** until OK / Cancel / Esc. | ### Reading the question (lead + detail) @@ -223,6 +230,11 @@ Parse the string before branching. **Freeform:** same shape plus `"freeform": true` and `"freeform_text": "…"`. +**Human-pasted references (Ctrl+V):** lean JSON may include +`pasted_image_count` (and optional `pasted_image_notes`). The tool result then +includes MCP **Image** content blocks after the JSON string so the model can +see the stills. + **Cancelled:** ```json @@ -284,7 +296,7 @@ Env cheat sheet: [SETUP.md](../SETUP.md#5-env-cheat-sheet). |---------|------------| | Tool missing / won’t start | Absolute `uv` path; check `REPO_ROOT`; reload; `check_setup` | | No dialog | `check_setup` → `display` / `gtk_*`; host must inherit `DISPLAY` | -| Hang / timeout | Human must click; raise `timeout_sec`; off-screen window? | +| Hang / timeout | Default waits forever (`timeout_sec=0`). If you set a positive timeout, typing/paste/select holds it. Off-screen window? | | Speaks without TTS URL | Local Piper / notify path — mute with Audio / `ASK_QUESTION_AUDIO=0` | | No speech / no mic | `setup_guide` topic `tts` / `stt`, or mute env | | Works in terminal, not IDE | Absolute `uv`; restart IDE after install | diff --git a/scripts/smoke_nebula_bridge.py b/scripts/smoke_nebula_bridge.py new file mode 100644 index 0000000..8f6fefa --- /dev/null +++ b/scripts/smoke_nebula_bridge.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""Automated smoke: launch Linux Nebula, wait for bridge, submit, verify result. + +Uses system Python (/usr/bin/python3) for WebKitGTK. Does not need a human click. +Optional screenshot when ImageMagick ``import`` or ``gnome-screenshot`` exists. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "src" / "ask_question_mcp" / "linux_webview_ask.py" +GTK_PY = "/usr/bin/python3" + + +def _post(origin: str, path: str, body: dict) -> dict: + req = urllib.request.Request( + f"{origin}{path}", + data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5) as resp: + return json.loads(resp.read().decode("utf-8") or "{}") + + +def _try_screenshot(out: Path) -> bool: + env = {**os.environ, "DISPLAY": os.environ.get("DISPLAY", ":0")} + for cmd in ( + ["gnome-screenshot", "-f", str(out)], + ["import", "-window", "root", str(out)], + ): + try: + r = subprocess.run(cmd, env=env, capture_output=True, timeout=8) + if r.returncode == 0 and out.is_file() and out.stat().st_size > 1000: + return True + except (OSError, subprocess.TimeoutExpired): + continue + return False + + +def main() -> int: + if not Path(GTK_PY).is_file(): + print("FAIL: /usr/bin/python3 missing", file=sys.stderr) + return 2 + display = os.environ.get("DISPLAY", ":0") + runtime = Path(tempfile.mkdtemp(prefix="askq-nebula-smoke-")) + result_path = runtime / "result.json" + payload = { + "question": "Nebula bridge smoke — automated submit (Anthony Windows port).", + "title": "Nebula smoke", + "ids": ["yes", "no", "other"], + "labels": { + "yes": "Looks good (recommended)", + "no": "Still broken", + "other": "Something else", + }, + "preselect": ["yes"], + "recommended_ids": ["yes"], + "danger_ids": [], + "dangerous": False, + "allow_multiple": False, + "allow_other": True, + "timeout_sec": 60, + "theme": "glass", + "arm_ms": 250, + "speak_enabled": False, + "voice_answer": False, + "result_path": str(result_path), + } + env = { + **os.environ, + "DISPLAY": display, + "GDK_BACKEND": "x11", + "ASK_QUESTION_GDK_BACKEND": "x11", + "ASK_QUESTION_LINUX_UI": "nebula", + "ASK_QUESTION_AUDIO": "0", + "WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS": "1", + "WEBKIT_DISABLE_DMABUF_RENDERER": "1", + } + print(f"smoke: launching {SCRIPT} display={display}", flush=True) + proc = subprocess.Popen( + [GTK_PY, str(SCRIPT)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + assert proc.stdin is not None + proc.stdin.write(json.dumps(payload)) + proc.stdin.close() + + origin = "" + stderr_buf = "" + deadline = time.time() + 20 + while time.time() < deadline and proc.poll() is None: + # Non-blocking-ish read of stderr lines + assert proc.stderr is not None + line = proc.stderr.readline() + if line: + stderr_buf += line + sys.stderr.write(line) + sys.stderr.flush() + m = re.search(r"nebula: bridge=(http://127\.0\.0\.1:\d+)", line) + if m: + origin = m.group(1) + break + else: + time.sleep(0.05) + + if not origin: + # Drain a bit more + time.sleep(0.5) + try: + rest = proc.stderr.read() if proc.stderr else "" + except Exception: + rest = "" + stderr_buf += rest or "" + m = re.search(r"nebula: bridge=(http://127\.0\.0\.1:\d+)", stderr_buf) + origin = m.group(1) if m else "" + + if not origin: + proc.kill() + print("FAIL: no bridge URL in stderr", file=sys.stderr) + print(stderr_buf[-2000:], file=sys.stderr) + return 1 + + # Wait until get_payload works (page loaded + JS can talk). + ready = False + for _ in range(60): + if proc.poll() is not None: + break + try: + data = _post(origin, "/api", {"name": "get_payload", "args": []}) + if data.get("result") and data["result"].get("ids"): + ready = True + break + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError): + pass + time.sleep(0.15) + + if not ready: + proc.kill() + print("FAIL: bridge never served payload", file=sys.stderr) + return 1 + + # Give paint a beat; probe DOM layout (screenshots are flaky on Wayland). + time.sleep(0.8) + try: + _post(origin, "/event", {"name": "content_ready", "args": []}) + except Exception: + pass + time.sleep(0.4) + probe = {} + try: + probe = _post(origin, "/api", {"name": "dom_probe", "args": []}) or {} + if isinstance(probe, dict) and "result" in probe: + # _post already returns parsed body; handler wraps in result. + pass + except Exception as exc: + print(f"FAIL: dom_probe error {exc}", file=sys.stderr) + proc.kill() + return 1 + + # apiCall-style responses are {"result": ...}; our _post returns full body. + layout = probe.get("result") if isinstance(probe, dict) else None + if layout is None and isinstance(probe, dict) and "ready" in probe: + layout = probe + print(f"smoke: layout={json.dumps(layout, ensure_ascii=False)}", flush=True) + if not isinstance(layout, dict) or layout.get("error"): + print(f"FAIL: bad layout probe {layout}", file=sys.stderr) + proc.kill() + return 1 + checks = { + "ready": layout.get("ready") is True, + "theme_glass": layout.get("theme") == "glass", + "stars": int(layout.get("stars") or 0) >= 20, + "freeformInBody": layout.get("freeformInBody") is True, + "footerVisible": layout.get("footerVisible") is True, + "okVisible": layout.get("okVisible") is True, + } + bad = [k for k, ok in checks.items() if not ok] + if bad: + print(f"FAIL: layout checks failed: {bad} full={layout}", file=sys.stderr) + proc.kill() + return 1 + print("smoke: layout ok", flush=True) + + try: + _post( + origin, + "/api", + {"name": "submit", "args": [["yes"], None, []]}, + ) + except Exception as exc: + print(f"FAIL: submit error {exc}", file=sys.stderr) + proc.kill() + return 1 + + # stdin already closed; stderr partially consumed — wait + drain leftovers. + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + proc.kill() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + print("FAIL: dialog hung after submit", file=sys.stderr) + return 1 + + stdout = "" + try: + if proc.stdout is not None: + stdout = proc.stdout.read() or "" + except Exception: + stdout = "" + try: + if proc.stderr is not None: + stderr_buf += proc.stderr.read() or "" + except Exception: + pass + + # Result file may appear slightly after process exit on hard-bail races. + file_raw = "" + for _ in range(20): + if result_path.is_file() and result_path.stat().st_size > 2: + file_raw = result_path.read_text(encoding="utf-8").strip() + break + time.sleep(0.1) + + answer = None + for raw in (file_raw, (stdout or "").strip().splitlines()[-1] if stdout else ""): + if not raw: + continue + try: + answer = json.loads(raw) + break + except json.JSONDecodeError: + continue + + print(f"smoke: rc={proc.returncode}", flush=True) + print(f"smoke: stdout={stdout!r}", flush=True) + print(f"smoke: result_file={file_raw!r}", flush=True) + + if not answer: + print("FAIL: no JSON answer", file=sys.stderr) + print(stderr_buf[-1500:], file=sys.stderr) + return 1 + if answer.get("cancelled"): + print(f"FAIL: cancelled {answer}", file=sys.stderr) + return 1 + if answer.get("ids") != ["yes"]: + print(f"FAIL: unexpected ids {answer}", file=sys.stderr) + return 1 + + print("smoke_nebula_bridge: ok", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_mcq_pasted.py b/scripts/test_mcq_pasted.py new file mode 100644 index 0000000..05a9a72 --- /dev/null +++ b/scripts/test_mcq_pasted.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Unit tests for human-pasted MCQ image normalization (no UI).""" + +from __future__ import annotations + +import base64 +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from ask_question_mcp.mcq_pasted import ( # noqa: E402 + lean_pasted_fields, + normalize_pasted_images, +) + + +def _b64(data: bytes) -> str: + return base64.b64encode(data).decode("ascii") + + +def test_normalize_pasted_images() -> None: + png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 + accepted, notes = normalize_pasted_images( + [{"mime": "image/png", "data": _b64(png)}] + ) + assert len(accepted) == 1, accepted + assert accepted[0]["format"] == "png" + assert accepted[0]["data"] == png + assert notes == [] + + # data-URL prefix ok + accepted2, _ = normalize_pasted_images( + [{"mime": "image/jpeg", "data": f"data:image/jpeg;base64,{_b64(b'JFIF')}"}] + ) + assert len(accepted2) == 1 + assert accepted2[0]["format"] == "jpeg" + + # bad mime / empty / not a list + bad, notes_bad = normalize_pasted_images( + [ + {"mime": "text/plain", "data": _b64(b"x")}, + {"mime": "image/png", "data": ""}, + "nope", + ] + ) + assert bad == [] + assert len(notes_bad) >= 2 + + none_ok, none_notes = normalize_pasted_images(None) + assert none_ok == [] and none_notes == [] + + # cap at 4 + many = [{"mime": "image/png", "data": _b64(png)} for _ in range(6)] + capped, cap_notes = normalize_pasted_images(many) + assert len(capped) == 4 + assert any("capped" in n for n in cap_notes) + + # oversize skipped + huge = b"x" * (8 * 1024 * 1024 + 1) + over, over_notes = normalize_pasted_images( + [{"mime": "image/png", "data": _b64(huge)}] + ) + assert over == [] + assert any("bytes" in n for n in over_notes) + + lean = lean_pasted_fields(accepted, ["note"]) + assert lean["pasted_image_count"] == 1 + assert lean["pasted_image_notes"] == ["note"] + assert "data" not in lean + + +def test_mcq_tool_result_images() -> None: + import json + + from ask_question_mcp.server import _mcq_tool_result + + plain = _mcq_tool_result({"id": "a", "cancelled": False}) + assert isinstance(plain, str) + assert '"id": "a"' in plain or '"id":"a"' in plain + + # Cursor MCP host cannot serialize FastMCP Image — keep base64 in JSON. + mixed = _mcq_tool_result( + { + "id": "a", + "cancelled": False, + "pasted_image_count": 1, + "_pasted_image_blobs": [{"format": "png", "data": b"\x89PNG"}], + } + ) + assert isinstance(mixed, str) + data = json.loads(mixed) + assert data["pasted_image_count"] == 1 + assert "_pasted_image_blobs" not in data + assert data["pasted_images"][0]["format"] == "png" + assert data["pasted_images"][0]["data"] + + +def main() -> int: + test_normalize_pasted_images() + test_mcq_tool_result_images() + print("test_mcq_pasted: ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_nebula_layout.py b/scripts/test_nebula_layout.py new file mode 100644 index 0000000..2a958bd --- /dev/null +++ b/scripts/test_nebula_layout.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Guard: Linux Nebula keeps freeform inside scrolling
(anti-clip). + +Visual SoT is theoriginalcheese Windows Nebula. The only deliberate Linux +structural deviation is moving #refs + #freeform inside
+so Adw/WebKit does not clip Cancel/OK. +""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +INDEX = ROOT / "src" / "ask_question_mcp" / "assets" / "dialog" / "index.html" + + +def test_freeform_inside_main_body() -> None: + html = INDEX.read_text(encoding="utf-8") + main_start = html.find('
') + main_end = html.find("
") + freeform = html.find('id="freeform"') + refs = html.find('id="refs"') + footer = html.find('class="footer"') + assert main_start != -1 and main_end != -1, 'missing
' + assert freeform != -1, "missing freeform" + assert refs != -1, "missing refs" + assert main_start < freeform < main_end, ( + "REGRESSION: #freeform must stay inside
" + "(outside clips Cancel/OK on Linux WebKit)" + ) + assert main_start < refs < main_end, ( + "REGRESSION: #refs must stay inside
" + ) + assert main_end < footer, "footer should follow
" + assert 'id="question"' in html, "Anthony Nebula uses #question" + # Voice chrome must live in footer (not
) — anti-clip for Cancel/OK. + voice_bar = html.find('id="voice-bar"') + assert voice_bar != -1, "missing voice-bar" + assert main_end < voice_bar, ( + "REGRESSION: #voice-bar must stay in
, not
" + ) + assert 'id="audio-chk"' in html + assert 'id="replay-btn"' in html + assert 'id="listen-btn"' in html + + +if __name__ == "__main__": + test_freeform_inside_main_body() + print("test_nebula_layout: ok") diff --git a/scripts/test_prefs_audio_env.py b/scripts/test_prefs_audio_env.py new file mode 100644 index 0000000..834c89a --- /dev/null +++ b/scripts/test_prefs_audio_env.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Known-good: ASK_QUESTION_AUDIO=0 hard-mutes; =1 does not override prefs.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from ask_question_mcp import prefs # noqa: E402 + + +def main() -> int: + # Isolate from the developer's real prefs file. + tmp = Path(os.environ.get("TMPDIR") or "/tmp") / "ask-question-mcp-test-prefs" + tmp.mkdir(parents=True, exist_ok=True) + prefs_path = tmp / "prefs.json" + prefs_path.write_text('{"audio_enabled": false}\n', encoding="utf-8") + prefs._PREFS_PATH = prefs_path # type: ignore[attr-defined] + + os.environ.pop("ASK_QUESTION_AUDIO", None) + assert prefs.get_audio_enabled() is False, "prefs false must mute" + + prefs_path.write_text('{"audio_enabled": true}\n', encoding="utf-8") + assert prefs.get_audio_enabled() is True, "prefs true must unmute" + + os.environ["ASK_QUESTION_AUDIO"] = "1" + prefs_path.write_text('{"audio_enabled": false}\n', encoding="utf-8") + assert prefs.get_audio_enabled() is False, "=1 must not override checkbox/prefs" + + os.environ["ASK_QUESTION_AUDIO"] = "0" + prefs_path.write_text('{"audio_enabled": true}\n', encoding="utf-8") + assert prefs.get_audio_enabled() is False, "=0 must hard-mute" + + print("PASS test_prefs_audio_env") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/ask-multiple-choice/SKILL.md b/skills/ask-multiple-choice/SKILL.md index 6ac5020..649f39d 100644 --- a/skills/ask-multiple-choice/SKILL.md +++ b/skills/ask-multiple-choice/SKILL.md @@ -1,27 +1,19 @@ --- name: ask-multiple-choice description: >- - 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. + 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. --- # Ask multiple choice (desktop MCP) -Cursor stand-in for Claude Code **AskUserQuestion**. **Need-band:** freedom — -faster decisions without chat A/B/C noise. +**Need-band:** freedom — faster decisions without chat A/B/C noise. -## When (auto — do not wait to be asked) +## When (auto) -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 +Any **decision fork**: ship/wait, pick a path, confirm irreversible work, +choose among options the human must decide. ## Do @@ -35,36 +27,71 @@ Any fork that needs **Anthony's opinion**, including when you are **stuck**: (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:** state **what** will happen **and why** (one - sentence each is enough). Opaque “proceed?” without purpose is not enough. +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. Pattern: `mcq-permission-what-and-why`. -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). +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, **F**, or + **double-click the title bar**). + **Multi-image:** Gtk shows a **carousel** (one still at a time; click + left/right of the still, Prev/Next, or ←/→) — never tiny stacked stills. + Prefer one clear still when possible; + `images=` is fine for related stills the human must flip through. + Pattern: `mcq-with-image` + `mcq-images-one-at-a-time`. +8. Wait for the JSON result (and any MCP **Image** blocks if the human pasted + references). On cancel → stop. On freeform → use **`freeform_text`**. + Default `timeout_sec=0` waits for the human — do not invent a choice if they + take a long time. + +Humans use the dialog keyboard (**1–8**, Enter, Esc; **Ctrl+V** paste images; +**F** maximize when images; **←/→** when multi-image carousel); 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`. ## Don't - Markdown A/B/C, numbered lists, or host AskQuestion when this MCP is available -- 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 +- 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=`** +- Expecting several `images=` to appear as a readable grid/stack — Gtk shows a + **carousel** (one at a time); use that, or sequential single-`image=` MCQs +- “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`) - `check_setup` before routine MCQs (only first enable, dialog failure, or before voice) - Invent a choice after `cancelled: true` -- Skip the dialog because the skill wasn't @-mentioned — the always-on user rule - still applies when the MCP is loaded +- Ambiguous **I/you** in MCQ question or option labels (use Alex / Briar) ## Setup (humans) @@ -73,4 +100,4 @@ cd /path/to/ask-question-mcp && uv sync uv run ask-question-install --host cursor --skill ``` -Then reload the host. Windows: see `docs/WINDOWS.md`. Detail: `docs/AGENTS.md`. +Then reload the host. Detail: repo `docs/AGENTS.md`. diff --git a/src/ask_question_mcp/assets/dialog/dialog.css b/src/ask_question_mcp/assets/dialog/dialog.css index bd30353..ea19800 100644 --- a/src/ask_question_mcp/assets/dialog/dialog.css +++ b/src/ask_question_mcp/assets/dialog/dialog.css @@ -316,13 +316,16 @@ body { } .body { - flex: 1; + flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: 16px; padding: 6px 18px 0; - overflow: hidden; + /* Options scroll when References appear — footer/Cancel/OK stay pinned. + WebView ignores JS resize_to, so flex shrink is the only safe path. */ + overflow-x: hidden; + overflow-y: auto; } .question { @@ -419,6 +422,16 @@ body { transform: translateY(0); } +.option-shell.is-focused:not(.is-selected) { + outline: 2px solid color-mix(in srgb, var(--accent, #7dd3fc) 70%, transparent); + outline-offset: 2px; +} + +.option-shell.is-focused.is-selected { + outline: 2px solid color-mix(in srgb, var(--accent, #7dd3fc) 55%, transparent); + outline-offset: 2px; +} + .option-shell.is-selected { background: rgb(var(--accent-rgb) / 0.14); border-color: rgb(var(--accent-rgb) / 0.32); @@ -526,13 +539,105 @@ body { opacity: 0.9; } +.refs { + flex: 0 0 auto; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 8px; + /* Nested in .body on Linux — body already has 18px horizontal padding. */ + padding: 12px 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: 140ms; +} + +.app.is-ready .refs:not([hidden]) { + opacity: 1; + transform: translateY(0); +} + +.refs[hidden] { + display: none !important; +} + +.refs-label { + font-size: 10.5px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-eyebrow); +} + +.refs-strip { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: flex-start; + max-height: 160px; + overflow: auto; +} + +.ref-tile { + position: relative; + flex: 0 0 auto; + width: 96px; + height: 72px; + padding: 2px; + border-radius: calc(var(--r-tile-inner) + 2px); + background: rgb(var(--hairline-rgb) / 0.045); + border: 1px solid rgb(var(--hairline-rgb) / 0.1); + overflow: hidden; +} + +.ref-tile img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: var(--r-tile-inner); + background: rgb(var(--card-core-rgb) / 0.9); + pointer-events: none; +} + +.ref-remove { + position: absolute; + top: 4px; + right: 4px; + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: 999px; + background: rgb(var(--ground-rgb) / 0.72); + color: var(--text-secondary); + font: inherit; + font-size: 12px; + line-height: 1; + cursor: pointer; + display: grid; + place-items: center; + transition: + background var(--hover-ms) var(--ease), + color var(--hover-ms) var(--ease); +} + +.ref-remove:hover { + background: rgb(var(--ember-rgb) / 0.85); + color: #fff; +} + .freeform { flex: 0 0 auto; flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; - padding: 12px 18px 0; + /* Nested in .body on Linux — body already has 18px horizontal padding. */ + padding: 12px 0 8px; border-top: 1px solid rgb(var(--hairline-rgb) / 0.06); opacity: 0; transform: translateY(10px); @@ -542,6 +647,12 @@ body { transition-delay: 160ms; } +/* When References is showing, drop the double hairline and keep equal gap. */ +.refs:not([hidden]) + .freeform { + border-top: 0; + padding-top: 0; +} + .app.is-ready .freeform { opacity: 1; transform: translateY(0); @@ -593,12 +704,20 @@ body { .footer { flex: 0 0 auto; + /* Never shrink — Cancel/OK must stay visible when options/refs grow. */ flex-shrink: 0; + z-index: 5; display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 14px 18px 16px; + flex-direction: column; + align-items: stretch; + gap: 8px; + padding: 10px 18px 20px; + background: linear-gradient( + 180deg, + rgb(var(--ground-rgb) / 0) 0%, + rgb(var(--ground-rgb) / 0.55) 35%, + rgb(var(--ground-rgb) / 0.92) 100% + ); opacity: 0; transform: translateY(10px); transition: @@ -612,16 +731,131 @@ body { transform: translateY(0); } +.footer-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.voice-bar { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.voice-status { + font-size: 12px; + font-weight: 550; + letter-spacing: 0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-secondary); +} + +.voice-status[data-state="speaking"] { + color: rgb(120 180 255); +} + +.voice-status[data-state="listening"] { + color: rgb(90 220 160); +} + +.voice-status[data-state="analysing"] { + color: rgb(220 190 90); +} + +.voice-status[data-state="heard"] { + color: rgb(140 220 180); +} + +.voice-status[data-state="error"] { + color: rgb(255 120 140); +} + +.voice-recover { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 10px; + border-radius: 12px; + background: rgb(var(--hairline-rgb) / 0.08); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04); +} + +.voice-recover-label { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.35; +} + +.voice-recover-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.voice-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + min-width: 0; +} + +.voice-check { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; + user-select: none; +} + +.voice-check input { + accent-color: rgb(var(--accent-rgb)); +} + +.btn-voice { + height: 32px; + padding: 0 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 550; + background: rgb(var(--hairline-rgb) / 0.08); + color: var(--text-secondary); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.05); +} + +.btn-voice:hover:not(:disabled) { + background: rgb(var(--hairline-rgb) / 0.14); + color: var(--text-primary); +} + +.btn-voice.is-accent { + background: rgb(var(--accent-rgb) / 0.22); + color: var(--text-primary); +} + .hint { font-size: 11px; color: var(--text-tertiary); letter-spacing: 0.01em; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .actions { display: flex; align-items: center; gap: 8px; + flex-shrink: 0; } .btn { diff --git a/src/ask_question_mcp/assets/dialog/dialog.js b/src/ask_question_mcp/assets/dialog/dialog.js index 0099d84..a3a9a31 100644 --- a/src/ask_question_mcp/assets/dialog/dialog.js +++ b/src/ask_question_mcp/assets/dialog/dialog.js @@ -1,10 +1,23 @@ (() => { + // Port of theoriginalcheese Windows Nebula dialog.js, with Linux-only + // bridge fixes (boot TDZ, native paste inject, snappy closing, paste cap). const OTHER_IDS = new Set(["other", "something_else", "something-else"]); + const MAX_PASTED = 4; + // Keep clipboard stills small — huge data-URLs blank/freeze WebView2 under Cursor. + const PASTE_MAX_EDGE = 1280; + const PASTE_JPEG_QUALITY = 0.82; + const PASTE_MAX_B64_CHARS = 1_800_000; // ~1.3 MiB decoded const state = { payload: null, selected: new Set(), + focusIdx: 0, armed: false, typing: false, + pasted: [], + timeoutId: null, + engaged: false, + closing: false, + voice: null, }; const $ = (sel) => document.querySelector(sel); @@ -21,11 +34,22 @@ return String(label || "").replace(/^\d+\s*[·.]\s*/, "").trim(); } + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + async function apiCall(name, ...args) { - // Edge --app / localhost bridge (no pywebview — killable, no destroy hang). + // Edge --app / Linux WebKit localhost bridge (no pywebview — killable). const bridge = window.__ASK_BRIDGE__; if (bridge && typeof bridge === "string") { - if (name === "content_ready" || name === "resize_to") { + if ( + name === "content_ready" || + name === "resize_to" || + name === "hold_timeout" || + name === "closing" || + name === "begin_move" || + name === "maximize" + ) { try { await fetch(`${bridge}/event`, { method: "POST", @@ -49,11 +73,15 @@ return data.result; } - const api = window.pywebview && window.pywebview.api; - if (!api || typeof api[name] !== "function") { - throw new Error(`pywebview api.${name} unavailable`); + // pywebview injects api:{} before finish.js binds methods — retry briefly. + for (let i = 0; i < 80; i += 1) { + const api = window.pywebview && window.pywebview.api; + if (api && typeof api[name] === "function") { + return api[name](...args); + } + await sleep(25); } - return api[name](...args); + throw new Error(`pywebview api.${name} unavailable`); } function setArmed(armed) { @@ -172,13 +200,19 @@ } function syncSelectionUi() { + const ids = state.payload?.ids || []; document.querySelectorAll(".option-shell").forEach((el) => { el.classList.toggle("is-selected", state.selected.has(el.dataset.id)); + const idx = ids.indexOf(el.dataset.id); + el.classList.toggle("is-focused", idx === state.focusIdx); }); } function onPick(id) { const multi = !!state.payload.allow_multiple; + const ids = state.payload.ids || []; + const idx = ids.indexOf(id); + if (idx >= 0) state.focusIdx = idx; if (multi) { if (state.selected.has(id)) state.selected.delete(id); else state.selected.add(id); @@ -188,19 +222,204 @@ syncSelectionUi(); } + function moveOptionFocus(delta) { + const ids = state.payload?.ids || []; + if (!ids.length) return; + let idx = state.focusIdx; + if (idx == null || idx < 0 || idx >= ids.length) { + const cur = [...state.selected][0]; + idx = Math.max(0, ids.indexOf(cur)); + } + idx = Math.max(0, Math.min(ids.length - 1, idx + delta)); + state.focusIdx = idx; + if (!state.payload.allow_multiple) { + state.selected = new Set([ids[idx]]); + } + syncSelectionUi(); + const shells = document.querySelectorAll(".option-shell"); + const shell = shells[idx]; + if (shell) shell.scrollIntoView({ block: "nearest" }); + } + function freeformText() { return ($("#freeform-input")?.value || "").trim(); } - async function leaveGently() { + function markEngaged() { + if (state.engaged) return; + state.engaged = true; + if (state.timeoutId != null) { + clearTimeout(state.timeoutId); + state.timeoutId = null; + } + apiCall("hold_timeout").catch(() => {}); + } + + function dataUrlToPayload(dataUrl) { + const m = /^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i.exec(dataUrl || ""); + if (!m) return null; + let mime = m[1].toLowerCase(); + if (mime === "image/jpg") mime = "image/jpeg"; + if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(mime)) { + return null; + } + const data = m[2]; + if (data.length > PASTE_MAX_B64_CHARS) return null; + return { mime, data }; + } + + function loadImageFromUrl(url) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error("image decode failed")); + img.src = url; + }); + } + + async function compactFileToDataUrl(file) { + // Decode via blob URL (not a giant data: string) then JPEG-downscale. + const blobUrl = URL.createObjectURL(file); + try { + const img = await loadImageFromUrl(blobUrl); + let w = img.naturalWidth || img.width || 0; + let h = img.naturalHeight || img.height || 0; + if (w < 1 || h < 1) return null; + const scale = Math.min(1, PASTE_MAX_EDGE / Math.max(w, h)); + w = Math.max(1, Math.round(w * scale)); + h = Math.max(1, Math.round(h * scale)); + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + ctx.drawImage(img, 0, 0, w, h); + let out = canvas.toDataURL("image/jpeg", PASTE_JPEG_QUALITY); + let q = PASTE_JPEG_QUALITY; + while (out.length > PASTE_MAX_B64_CHARS && q > 0.45) { + q -= 0.12; + out = canvas.toDataURL("image/jpeg", q); + } + if (out.length > PASTE_MAX_B64_CHARS) return null; + return out; + } catch (_) { + return null; + } finally { + try { + URL.revokeObjectURL(blobUrl); + } catch (_) { + /* ignore */ + } + } + } + + function renderRefs() { + const box = $("#refs"); + const strip = $("#refs-strip"); + if (!box || !strip) return; + strip.innerHTML = ""; + if (!state.pasted.length) { + box.hidden = true; + requestAnimationFrame(() => fitWindow()); + return; + } + box.hidden = false; + state.pasted.forEach((item, i) => { + const tile = document.createElement("div"); + tile.className = "ref-tile"; + tile.setAttribute("role", "listitem"); + const img = document.createElement("img"); + img.src = item.dataUrl; + img.alt = `Reference ${i + 1}`; + const rm = document.createElement("button"); + rm.type = "button"; + rm.className = "ref-remove"; + rm.setAttribute("aria-label", `Remove reference ${i + 1}`); + rm.textContent = "×"; + rm.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + state.pasted.splice(i, 1); + renderRefs(); + }); + tile.appendChild(img); + tile.appendChild(rm); + strip.appendChild(tile); + }); + // Options area flex-shrinks; footer stays. Still ask host to grow when possible. + requestAnimationFrame(() => fitWindow()); + } + + function addPastedDataUrl(dataUrl) { + if (state.pasted.length >= MAX_PASTED) return false; + const payload = dataUrlToPayload(dataUrl); + if (!payload) return false; + state.pasted.push({ ...payload, dataUrl }); + markEngaged(); + renderRefs(); + return true; + } + + async function onPaste(e) { + const cd = e.clipboardData; + if (!cd) return; + const files = []; + if (cd.files && cd.files.length) { + for (const f of cd.files) { + if (f && String(f.type || "").startsWith("image/")) files.push(f); + } + } + if (!files.length && cd.items) { + for (const item of cd.items) { + if (item.kind === "file" && String(item.type || "").startsWith("image/")) { + const f = item.getAsFile(); + if (f) files.push(f); + } + } + } + if (!files.length) return; + e.preventDefault(); + for (const file of files) { + if (state.pasted.length >= MAX_PASTED) break; + try { + const compact = await compactFileToDataUrl(file); + if (!compact) continue; + addPastedDataUrl(compact); + } catch (_) { + /* skip unreadable clipboard items */ + } + } + } + + function markLeaving() { const app = $("#app"); - if (!app || app.classList.contains("is-leaving")) return; - app.classList.add("is-leaving"); - await new Promise((r) => setTimeout(r, 280)); + if (app) app.classList.add("is-leaving"); + } + + function pastedPayload() { + const items = state.pasted.map(({ mime, data }) => ({ mime, data })); + // Linux HTTP bridge can carry more than Win pywebview; keep as many as fit. + const maxChars = 700000; + const kept = []; + for (const item of items) { + const trial = JSON.stringify([...kept, item]).length; + if (trial > maxChars) { + console.warn( + `dropping pasted still(s) after ${kept.length}; bridge payload would be ${trial} chars`, + ); + const hint = $("#hint"); + if (hint) { + hint.textContent = `Paste too large — kept ${kept.length}/${items.length} · Enter OK`; + } + break; + } + kept.push(item); + } + return kept; } async function submit() { - if (!state.armed) return; + if (!state.armed || state.closing) return; const typed = freeformText(); let ids = [...state.selected]; if (typed) { @@ -209,9 +428,12 @@ if (!other) ids = ids.length ? ids : ["other"]; } if (!ids.length) return; - await leaveGently(); + state.closing = true; + // Instant visual + host hide — do not await closing (felt like laggy Enter). + markLeaving(); + apiCall("closing").catch(() => {}); try { - await apiCall("submit", ids, typed || null); + await apiCall("submit", ids, typed || null, pastedPayload()); } catch (err) { console.error(err); } @@ -223,7 +445,10 @@ } async function cancel(reason = "user cancelled") { - await leaveGently(); + if (state.closing) return; + state.closing = true; + markLeaving(); + apiCall("closing").catch(() => {}); try { await apiCall("cancel", reason); } catch (err) { @@ -236,6 +461,110 @@ } } + function applyVoiceUi(snap) { + if (!snap || typeof snap !== "object") return; + state.voice = { ...(state.voice || {}), ...snap }; + const bar = $("#voice-bar"); + const status = $("#voice-status"); + const recover = $("#voice-recover"); + const recoverLbl = $("#voice-recover-label"); + const useThis = $("#voice-use-this-btn"); + const audioChk = $("#audio-chk"); + const alwaysLbl = $("#always-listen-label"); + const alwaysChk = $("#always-listen-chk"); + const replay = $("#replay-btn"); + const listen = $("#listen-btn"); + const showBar = !!(snap.speak_enabled || snap.voice_answer || audioChk); + if (bar) bar.hidden = !showBar; + if (status) { + const text = String(snap.status_text || ""); + status.hidden = !text; + status.textContent = text; + status.dataset.state = snap.status_state || "idle"; + status.title = text; + } + if (recover) { + recover.hidden = !snap.recover_visible; + } + if (recoverLbl && snap.recover_label != null) { + recoverLbl.textContent = String(snap.recover_label || ""); + } + if (useThis) { + useThis.hidden = !snap.use_this_visible; + } + if (audioChk && typeof snap.audio_enabled === "boolean") { + audioChk.checked = snap.audio_enabled; + } + if (replay) { + replay.hidden = !snap.speak_enabled; + } + if (listen) { + listen.hidden = !snap.voice_answer; + } + if (alwaysLbl) { + alwaysLbl.hidden = !snap.voice_answer; + } + if (alwaysChk && typeof snap.always_listen === "boolean") { + alwaysChk.checked = snap.always_listen; + } + if (snap.select_id) { + onPick(String(snap.select_id)); + } + if (snap.freeform_text != null) { + const input = $("#freeform-input"); + if (input) input.value = String(snap.freeform_text); + } + if (snap.request_submit) { + submit(); + } + updateHint(); + requestAnimationFrame(() => fitWindow()); + } + + function wireVoiceControls() { + const audioChk = $("#audio-chk"); + const alwaysChk = $("#always-listen-chk"); + const replay = $("#replay-btn"); + const listen = $("#listen-btn"); + const repeat = $("#voice-repeat-btn"); + const useThis = $("#voice-use-this-btn"); + if (audioChk) { + audioChk.addEventListener("change", () => { + apiCall("set_audio_enabled", !!audioChk.checked).catch(() => {}); + }); + } + if (alwaysChk) { + alwaysChk.addEventListener("change", () => { + apiCall("set_always_listen", !!alwaysChk.checked).catch(() => {}); + }); + } + if (replay) { + replay.addEventListener("click", () => { + markEngaged(); + apiCall("voice_replay").catch(() => {}); + }); + } + if (listen) { + listen.addEventListener("click", () => { + markEngaged(); + apiCall("voice_listen").catch(() => {}); + }); + } + if (repeat) { + repeat.addEventListener("click", () => { + markEngaged(); + apiCall("voice_recover_repeat").catch(() => {}); + }); + } + if (useThis) { + useThis.addEventListener("click", () => { + markEngaged(); + apiCall("voice_use_this").catch(() => {}); + }); + } + window.__ASK_VOICE_UPDATE__ = (snap) => applyVoiceUi(snap || {}); + } + function onKey(e) { const typing = document.activeElement && @@ -259,6 +588,18 @@ return; } if (typing) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + markEngaged(); + moveOptionFocus(1); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + markEngaged(); + moveOptionFocus(-1); + return; + } if (/^[1-8]$/.test(e.key)) { const idx = Number(e.key) - 1; const id = state.payload.ids?.[idx]; @@ -266,9 +607,58 @@ e.preventDefault(); onPick(id); } + return; + } + const v = state.voice || {}; + if (v.speak_enabled && (e.key === "r" || e.key === "R")) { + e.preventDefault(); + markEngaged(); + apiCall("voice_replay").catch(() => {}); + return; + } + if (v.voice_answer && (e.key === "l" || e.key === "L")) { + e.preventDefault(); + markEngaged(); + apiCall("voice_listen").catch(() => {}); } } + function updateHint() { + const n = Math.min(8, (state.payload.ids || []).length); + const hint = $("#hint"); + if (!hint) return; + const v = state.voice || {}; + let base = + n <= 1 + ? "Enter OK · Esc cancel" + : `↑↓ / 1–${n} select · Enter OK · Esc cancel · Shift+Enter newline`; + if (v.speak_enabled) base += " · R replay"; + if (v.voice_answer) base += " · L listen"; + hint.textContent = `${base} · Ctrl+V image`; + } + + function wireChromeDrag() { + const chrome = document.querySelector(".chrome"); + if (!chrome || chrome.dataset.dragWired === "1") return; + chrome.dataset.dragWired = "1"; + chrome.addEventListener("pointerdown", (e) => { + if (e.button !== 0) return; + if (e.target.closest("button, a, input, textarea, select, label")) return; + // WebKitGTK ignores pywebview-drag-region — ask the Gtk host to move. + if (window.__ASK_BRIDGE__) { + e.preventDefault(); + // GDK button numbers are 1-based (left = 1). + apiCall("begin_move", e.button + 1, e.screenX, e.screenY).catch( + () => {}, + ); + } + }); + chrome.addEventListener("dblclick", (e) => { + if (e.target.closest("button, a, input, textarea, select, label")) return; + apiCall("maximize").catch(() => {}); + }); + } + function mount(payload) { state.payload = payload; spawnDots(); @@ -283,6 +673,9 @@ state.selected = new Set( pre.length ? pre.map(String) : payload.ids?.[0] ? [payload.ids[0]] : [], ); + const ids = payload.ids || []; + const focusId = [...state.selected][0]; + state.focusIdx = Math.max(0, ids.indexOf(focusId)); const dangerous = !!(payload.dangerous || (payload.danger_ids || []).length); $("#eyebrow").textContent = dangerous ? "Confirm" : "Decide"; @@ -302,29 +695,58 @@ 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(); - } - }); + const input = $("#freeform-input"); + if (input) { + if (payload.entry_seed) input.value = String(payload.entry_seed); + input.addEventListener("input", () => { + markEngaged(); + 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`; + syncSelectionUi(); + wireVoiceControls(); + wireChromeDrag(); + const hasVoiceHost = + payload.voice_ui != null || + typeof payload.audio_enabled === "boolean" || + !!payload.speak_enabled || + !!payload.voice_answer; + const bar = $("#voice-bar"); + if (bar) bar.hidden = !hasVoiceHost; + if (hasVoiceHost) { + const voiceUi = payload.voice_ui || { + speak_enabled: !!payload.speak_enabled, + voice_answer: !!payload.voice_answer, + audio_enabled: + typeof payload.audio_enabled === "boolean" + ? payload.audio_enabled + : true, + always_listen: !!payload.always_listen, + status_state: "idle", + status_text: "", + recover_visible: false, + recover_label: "", + use_this_visible: false, + }; + applyVoiceUi(voiceUi); } + updateHint(); document.addEventListener("keydown", onKey); + document.addEventListener("paste", (e) => { + onPaste(e).catch(() => {}); + }); + // Native Linux host injects clipboard textures when WebKit paste is empty. + window.__ASK_ADD_PASTED__ = (dataUrl) => addPastedDataUrl(String(dataUrl || "")); $("#cancel-btn").addEventListener("click", () => cancel()); $("#close-btn").addEventListener("click", () => cancel()); $("#ok-btn").addEventListener("click", () => submit()); @@ -344,9 +766,18 @@ apiCall("content_ready").catch(() => {}); apiCall("debug", "mount:content_ready_sent").catch(() => {}); requestAnimationFrame(() => fitWindow()); + // Poll once in case push arrived before the page wired __ASK_VOICE_UPDATE__. + apiCall("get_voice_state") + .then((snap) => { + if (snap) applyVoiceUi(snap); + }) + .catch(() => {}); if (payload.timeout_sec > 0) { - setTimeout(() => cancel("timeout"), payload.timeout_sec * 1000); + state.timeoutId = setTimeout( + () => cancel("timeout"), + payload.timeout_sec * 1000, + ); } } @@ -355,60 +786,91 @@ const banner = document.getElementById("banner"); const question = document.getElementById("question"); const options = document.getElementById("options"); + const refs = document.getElementById("refs"); const freeform = document.getElementById("freeform"); const footer = document.querySelector(".footer"); - let h = 28; - [chrome, question, freeform, footer].forEach((el) => { + // Titlebar + breathing room; too-tight budgets clip OK/Cancel on scaled monitors. + let h = 36; + [chrome, question, refs, freeform, footer].forEach((el) => { if (el && !el.hidden) h += el.offsetHeight; }); + // Voice recover chrome can grow after mount — keep Cancel/OK on-screen. + const voiceBar = document.getElementById("voice-bar"); + if (voiceBar && !voiceBar.hidden && footer && !footer.contains(voiceBar)) { + h += voiceBar.offsetHeight; + } if (banner && banner.classList.contains("is-on")) { h += banner.offsetHeight + 8; } - // Gaps in .body - h += 42; + // Gaps in .body + slack so the last option isn't flush with freeform. + h += 56; 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); + h += Math.min(optsH, 480); } - const w = Math.max(520, Math.min(760, window.outerWidth || 560)); - apiCall("resize_to", w, Math.ceil(h)).catch(() => {}); + const w = Math.max(560, Math.min(760, window.outerWidth || 600)); + // Never ask the host for a window taller than the usable screen — laptop + // / scaled monitors used to clip OK/Cancel under the taskbar. + const avail = Math.max( + 420, + Math.floor((window.screen && window.screen.availHeight) || 900) - 48, + ); + apiCall("resize_to", w, Math.min(Math.ceil(h) + 8, avail)).catch(() => {}); + } + + function bridgeReady() { + if (window.__ASK_BRIDGE__) return true; + // pywebview sets api:{} early; methods appear only after finish.js _createApi. + const api = window.pywebview && window.pywebview.api; + return !!(api && typeof api.get_payload === "function"); } async function boot() { const waitApi = () => new Promise((resolve, reject) => { + let settled = false; + // Declare before done() — Linux __ASK_BRIDGE__ is ready immediately, so + // tryNow→done runs before setInterval assigns iv (TDZ crash). + let iv = null; + const done = (ok, err) => { + if (settled) return; + settled = true; + if (iv != null) clearInterval(iv); + window.removeEventListener("pywebviewready", onReady); + if (ok) resolve(); + else reject(err || new Error("dialog bridge unavailable (pywebview / Edge)")); + }; const tryNow = () => { - if (window.__ASK_BRIDGE__) { - resolve(); - return true; - } - if (window.pywebview && window.pywebview.api) { - resolve(); + if (bridgeReady()) { + done(true); return true; } return false; }; + const onReady = () => tryNow(); if (tryNow()) return; - window.addEventListener("pywebviewready", () => tryNow()); + window.addEventListener("pywebviewready", onReady); let n = 0; - const iv = setInterval(() => { + iv = setInterval(() => { n += 1; - if (tryNow()) { - clearInterval(iv); - return; - } - if (n > 100) { - clearInterval(iv); - reject(new Error("dialog bridge unavailable (pywebview / Edge)")); + if (tryNow()) return; + // ~10s — WebView2 cold start under Cursor can be slow. + if (n > 200) { + done(false, new Error("dialog bridge unavailable (pywebview / Edge)")); } }, 50); }); await waitApi(); + try { + sessionStorage.removeItem("askq_boot_reloads"); + } catch (_) { + /* ignore */ + } apiCall("debug", "boot:api_ready").catch(() => {}); const payload = await apiCall("get_payload"); apiCall("debug", `boot:payload n=${(payload && payload.ids || []).length}`).catch( @@ -420,8 +882,31 @@ boot().catch((err) => { console.error(err); - document.body.innerHTML = `
${esc(
-      String(err),
-    )}
`; + // Keep trying — a hard pink error page makes MCQs look "broken" when the + // bridge was only a beat late. Reload once after a short pause. + const msg = esc(String(err)); + let reloads = 0; + try { + reloads = Number(sessionStorage.getItem("askq_boot_reloads") || "0") || 0; + } catch (_) { + /* ignore */ + } + if (reloads < 2) { + try { + sessionStorage.setItem("askq_boot_reloads", String(reloads + 1)); + } catch (_) { + /* ignore */ + } + document.body.innerHTML = `
${msg}\n\nRetrying…
`; + setTimeout(() => { + try { + location.reload(); + } catch (_) { + /* ignore */ + } + }, 400); + return; + } + document.body.innerHTML = `
${msg}
`; }); -})(); \ No newline at end of file +})(); diff --git a/src/ask_question_mcp/assets/dialog/index.html b/src/ask_question_mcp/assets/dialog/index.html index d9d3863..637eff1 100644 --- a/src/ask_question_mcp/assets/dialog/index.html +++ b/src/ask_question_mcp/assets/dialog/index.html @@ -39,27 +39,61 @@

-
-
- -
- + + -
+ +
+ +
+ +
+
+
-
Select · Enter OK · Esc cancel
-
- - + + +
diff --git a/src/ask_question_mcp/assets/gtk/nebula.css b/src/ask_question_mcp/assets/gtk/nebula.css new file mode 100644 index 0000000..a9f3a4a --- /dev/null +++ b/src/ask_question_mcp/assets/gtk/nebula.css @@ -0,0 +1,192 @@ +/* Nebula-inspired Gtk theme (glass) — ported from Windows WebView dialog tokens. */ + +window.ask-q-nebula { + background-color: #0c0a14; + color: #f7f5ff; +} + +window.ask-q-nebula.ask-q-danger { + box-shadow: 0 0 0 2px #ff5c7a; +} + +/* Confirm card */ +window.ask-q-nebula box.ask-q-banner { + background-color: rgba(255, 92, 122, 0.14); + padding: 14px 16px 16px 16px; + border-radius: 12px; + border: 1px solid rgba(255, 92, 122, 0.35); +} + +window.ask-q-nebula label.ask-q-banner-title { + color: #ffb3c1; + font-weight: 700; + font-size: 1.05em; + letter-spacing: 0.12em; +} + +window.ask-q-nebula label.ask-q-banner-lead { + color: #f7f5ff; + font-weight: 600; + margin-top: 4px; + line-height: 1.35; +} + +window.ask-q-nebula label.ask-q-banner-body { + color: #a39bc9; + margin-top: 4px; + line-height: 1.35; +} + +window.ask-q-nebula label.ask-q-question-lead { + color: #f7f5ff; + font-weight: 600; + font-size: 1.15em; + line-height: 1.35; +} + +window.ask-q-nebula label.ask-q-question-detail { + color: #a39bc9; + line-height: 1.35; +} + +/* Option tiles */ +window.ask-q-nebula listbox.ask-q-options { + background: transparent; + border: none; +} + +window.ask-q-nebula listbox.ask-q-options row { + margin: 4px 0; + padding: 2px; + border-radius: 16px; + background: rgba(245, 243, 255, 0.04); + border: 1px solid rgba(245, 243, 255, 0.07); +} + +window.ask-q-nebula listbox.ask-q-options row:hover { + background: rgba(155, 140, 255, 0.10); + border-color: rgba(155, 140, 255, 0.22); +} + +window.ask-q-nebula listbox.ask-q-options row checkbutton { + padding: 10px 12px; + border-radius: 13px; + background: rgba(22, 18, 42, 0.88); + color: #f7f5ff; +} + +window.ask-q-nebula listbox.ask-q-options row checkbutton:checked { + background: rgba(155, 140, 255, 0.18); + color: #c9bfff; +} + +window.ask-q-nebula button.suggested-action { + background: #9b8cff; + color: #0c0a14; + border-color: #7a6ce0; + border-radius: 12px; + font-weight: 650; +} + +window.ask-q-nebula button.suggested-action.ask-q-danger-ok { + background: #ff5c7a; + color: #ffffff; + border-color: #c43d58; +} + +window.ask-q-nebula button { + border-radius: 12px; + color: #f7f5ff; +} + +window.ask-q-nebula entry { + background: rgba(22, 18, 42, 0.92); + color: #f7f5ff; + border-radius: 12px; + border: 1px solid rgba(245, 243, 255, 0.10); + padding: 8px 10px; +} + +window.ask-q-nebula entry:focus { + border-color: rgba(155, 140, 255, 0.45); +} + +window.ask-q-nebula label.dim-label, +window.ask-q-nebula .dim-label { + color: #8b84b8; +} + +window.ask-q-nebula box.ask-q-footer { + padding: 10px 16px 16px 16px; + border-top: 1px solid rgba(245, 243, 255, 0.06); + background-color: rgba(18, 16, 31, 0.92); +} + +/* References (human Ctrl+V paste) */ +window.ask-q-nebula box.ask-q-refs { + padding: 10px 16px; + border-top: 1px solid rgba(245, 243, 255, 0.06); +} + +window.ask-q-nebula label.ask-q-refs-label { + color: #7a72ad; + font-size: 0.85em; + letter-spacing: 0.12em; + font-weight: 600; +} + +window.ask-q-nebula box.ask-q-ref-tile { + border-radius: 12px; + background: rgba(245, 243, 255, 0.045); + border: 1px solid rgba(245, 243, 255, 0.10); + padding: 2px; +} + +window.ask-q-nebula button.ask-q-ref-remove { + min-width: 22px; + min-height: 22px; + padding: 0; + border-radius: 11px; + background: rgba(12, 10, 20, 0.72); + color: #f7f5ff; +} + +/* Voice status on dark */ +window.ask-q-nebula label.ask-q-status-idle { + color: rgba(247, 245, 255, 0.55); +} + +window.ask-q-nebula label.ask-q-status-speaking { + color: #c9bfff; + background-color: rgba(155, 140, 255, 0.16); + border: 1px solid rgba(155, 140, 255, 0.35); +} + +window.ask-q-nebula label.ask-q-status-listening { + color: #ffb3c1; + background-color: rgba(255, 92, 122, 0.14); + border: 1px solid rgba(255, 92, 122, 0.35); +} + +window.ask-q-nebula label.ask-q-status-analysing { + color: #9bd6ff; + background-color: rgba(60, 180, 200, 0.14); + border: 1px solid rgba(60, 180, 200, 0.35); +} + +window.ask-q-nebula label.ask-q-status-heard { + color: #b8f0c8; + background-color: rgba(80, 180, 120, 0.14); + border: 1px solid rgba(80, 180, 120, 0.35); +} + +window.ask-q-nebula label.ask-q-status-error { + color: #ffcc80; + background-color: rgba(255, 160, 60, 0.14); + border: 1px solid rgba(255, 160, 60, 0.35); +} + +window.ask-q-nebula box.ask-q-voice-recover { + background-color: rgba(255, 160, 60, 0.12); + border: 1px solid rgba(255, 160, 60, 0.35); +} diff --git a/src/ask_question_mcp/dialog_keys.py b/src/ask_question_mcp/dialog_keys.py index 57922c2..a26892b 100644 --- a/src/ask_question_mcp/dialog_keys.py +++ b/src/ask_question_mcp/dialog_keys.py @@ -64,7 +64,7 @@ def label_with_hotkey(index: int, label: str) -> str: return f"{n} · {label}" -KEYBOARD_HINT = "1–8 select · Enter OK · Esc cancel" +KEYBOARD_HINT = "1–8 select · Enter OK · Esc cancel · Ctrl+V image" def format_confirm_body(question: str) -> str: diff --git a/src/ask_question_mcp/gtk4_list_ask.py b/src/ask_question_mcp/gtk4_list_ask.py index 48880d8..03b2907 100644 --- a/src/ask_question_mcp/gtk4_list_ask.py +++ b/src/ask_question_mcp/gtk4_list_ask.py @@ -11,6 +11,7 @@ from __future__ import annotations +import base64 import json import os import signal @@ -20,6 +21,10 @@ from pathlib import Path from typing import Any +_MAX_PASTED = 4 +_PASTE_MAX_EDGE = 1280 +_PASTE_MAX_BYTES = 1_800_000 # ~rough bridge / MCP soft cap after encode + # Sibling helpers (system python — not the MCP venv package). sys.path.insert(0, str(Path(__file__).resolve().parent)) try: @@ -565,7 +570,7 @@ def _main() -> int: gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") gi.require_version("GdkPixbuf", "2.0") - from gi.repository import Adw, Gdk, GdkPixbuf, GLib, Gtk, Pango + from gi.repository import Adw, Gdk, GdkPixbuf, Gio, GLib, Gtk, Pango question = str(payload.get("question") or "").strip() title = str(payload.get("title") or "Decide") @@ -578,6 +583,7 @@ def _main() -> int: dangerous = bool(payload.get("dangerous")) allow_multiple = bool(payload.get("allow_multiple")) timeout_sec = int(payload.get("timeout_sec") or 0) + engaged_path_s = str(payload.get("engaged_path") or "").strip() image_paths = [ str(x).strip() for x in (payload.get("images") or []) @@ -760,11 +766,50 @@ def _build_and_run(application: Adw.Application) -> None: picture.ask-q-image-preview { cursor: pointer; } + box.ask-q-refs { + padding: 8px 16px; + } + label.ask-q-refs-label { + font-weight: 600; + letter-spacing: 0.08em; + } + box.ask-q-ref-tile { + border-radius: 10px; + border: 1px solid alpha(currentColor, 0.18); + padding: 2px; + } + button.ask-q-ref-remove { + min-width: 22px; + min-height: 22px; + padding: 0; + } """ ) Gtk.StyleContext.add_provider_for_display( win.get_display(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ) + # Nebula glass (Windows WebView aesthetic) — force dark + overlay CSS. + try: + Adw.StyleManager.get_default().set_color_scheme( + Adw.ColorScheme.FORCE_DARK + ) + except Exception: # noqa: BLE001 + pass + win.add_css_class("ask-q-nebula") + nebula_css_path = ( + Path(__file__).resolve().parent / "assets" / "gtk" / "nebula.css" + ) + if nebula_css_path.is_file(): + try: + nebula = Gtk.CssProvider() + nebula.load_from_path(str(nebula_css_path)) + Gtk.StyleContext.add_provider_for_display( + win.get_display(), + nebula, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 1, + ) + except Exception: # noqa: BLE001 + pass if dangerous: win.add_css_class("ask-q-danger") @@ -774,6 +819,31 @@ def _build_and_run(application: Adw.Application) -> None: listen_gen = {"n": 0} voice_retries = {"n": 0} closed = {"v": False} + # Idle auto-close source; cleared on first freeform keystroke / paste / select. + timeout_src: dict[str, int | None] = {"id": None} + # Human Ctrl+V reference stills (in-memory; returned as MCP images). + pasted_items: list[dict[str, Any]] = [] + + def hold_idle_timeout() -> None: + """Cancel idle auto-close after the human starts interacting.""" + sid = timeout_src["id"] + if sid is None: + return + try: + GLib.source_remove(sid) + except Exception: # noqa: BLE001 + pass + timeout_src["id"] = None + + def mark_engaged() -> None: + """Hold idle timeout + signal parent not to hard-kill the dialog.""" + hold_idle_timeout() + if engaged_path_s: + try: + Path(engaged_path_s).touch() + except OSError: + pass + # Unmatched speech → confirm as freeform (Something else / Use this). freeform_pending = {"text": ""} # Surfaced to MCP/chat so the agent sees what STT heard. @@ -1294,7 +1364,7 @@ def _append_question_block(parent: Gtk.Widget) -> None: list_box = Gtk.ListBox() list_box.set_selection_mode(Gtk.SelectionMode.NONE) - list_box.add_css_class("boxed-list") + list_box.add_css_class("ask-q-options") list_box.set_valign(Gtk.Align.START) scroll.set_child(list_box) root.append(scroll) @@ -1353,6 +1423,7 @@ def on_row_activated(_lb: Gtk.ListBox, activated: Gtk.ListBoxRow) -> None: child = activated.get_child() if not isinstance(child, Gtk.CheckButton): return + mark_engaged() if allow_multiple: child.set_active(not child.get_active()) else: @@ -1360,6 +1431,178 @@ def on_row_activated(_lb: Gtk.ListBox, activated: Gtk.ListBoxRow) -> None: list_box.connect("row-activated", on_row_activated) + # References strip (Ctrl+V clipboard images) — between options and freeform. + refs_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + refs_box.add_css_class("ask-q-refs") + refs_box.set_margin_start(0) + refs_box.set_margin_end(0) + refs_box.set_vexpand(False) + refs_box.set_visible(False) + refs_lbl = Gtk.Label(label="REFERENCES") + refs_lbl.set_xalign(0.0) + refs_lbl.add_css_class("ask-q-refs-label") + refs_lbl.add_css_class("dim-label") + refs_strip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + refs_strip.set_hexpand(True) + refs_box.append(refs_lbl) + refs_box.append(refs_strip) + root.append(refs_box) + + def _render_refs() -> None: + while True: + child = refs_strip.get_first_child() + if child is None: + break + refs_strip.remove(child) + if not pasted_items: + refs_box.set_visible(False) + return + refs_box.set_visible(True) + for i, item in enumerate(list(pasted_items)): + tile = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + tile.add_css_class("ask-q-ref-tile") + tile.set_size_request(96, 72) + overlay = Gtk.Overlay() + picture = Gtk.Picture() + picture.set_content_fit(Gtk.ContentFit.COVER) + picture.set_size_request(92, 68) + try: + raw = base64.b64decode(item.get("data") or "", validate=False) + loader = GdkPixbuf.PixbufLoader() + loader.write(raw) + loader.close() + pix = loader.get_pixbuf() + if pix is not None: + picture.set_pixbuf(pix) + except Exception: # noqa: BLE001 + pass + overlay.set_child(picture) + rm = Gtk.Button(label="×") + rm.add_css_class("ask-q-ref-remove") + rm.set_halign(Gtk.Align.END) + rm.set_valign(Gtk.Align.START) + rm.set_margin_top(2) + rm.set_margin_end(2) + rm.set_focusable(False) + rm.set_tooltip_text(f"Remove reference {i + 1}") + + def _make_remove(idx: int): + def _on_rm(*_a: object) -> None: + if 0 <= idx < len(pasted_items): + pasted_items.pop(idx) + _render_refs() + + return _on_rm + + rm.connect("clicked", _make_remove(i)) + overlay.add_overlay(rm) + tile.append(overlay) + refs_strip.append(tile) + + def _texture_to_pasted(texture: Gdk.Texture) -> dict[str, Any] | None: + """Compact clipboard texture → jpeg/png payload for MCP.""" + try: + gbytes = texture.save_to_png_bytes() + png = bytes(gbytes.get_data()) + except Exception: # noqa: BLE001 + return None + if not png: + return None + try: + loader = GdkPixbuf.PixbufLoader.new_with_type("png") + loader.write(png) + loader.close() + pix = loader.get_pixbuf() + except Exception: # noqa: BLE001 + if len(png) > _PASTE_MAX_BYTES: + return None + return { + "mime": "image/png", + "data": base64.b64encode(png).decode("ascii"), + } + if pix is None: + return None + w, h = int(pix.get_width()), int(pix.get_height()) + if w < 1 or h < 1: + return None + scale = min(1.0, float(_PASTE_MAX_EDGE) / float(max(w, h))) + if scale < 1.0: + nw = max(1, int(round(w * scale))) + nh = max(1, int(round(h * scale))) + pix = pix.scale_simple(nw, nh, GdkPixbuf.InterpType.BILINEAR) + mime = "image/jpeg" + try: + ok, buf = pix.save_to_bufferv("jpeg", ["quality"], ["82"]) + data = bytes(buf) if ok else b"" + except Exception: # noqa: BLE001 + data = b"" + if not data: + try: + ok, buf = pix.save_to_bufferv("png", [], []) + data = bytes(buf) if ok else b"" + mime = "image/png" + except Exception: # noqa: BLE001 + return None + if not data or len(data) > _PASTE_MAX_BYTES: + return None + return { + "mime": mime, + "data": base64.b64encode(data).decode("ascii"), + } + + def _add_pasted_from_texture(texture: Gdk.Texture) -> bool: + if len(pasted_items) >= _MAX_PASTED: + set_status("error", f"References capped at {_MAX_PASTED}") + return False + payload_img = _texture_to_pasted(texture) + if not payload_img: + return False + pasted_items.append(payload_img) + mark_engaged() + _render_refs() + return True + + def _try_paste_clipboard_image() -> bool: + """Return True when clipboard looks like an image (async consume).""" + display = win.get_display() + if display is None: + return False + clipboard = display.get_clipboard() + formats = clipboard.get_formats() + try: + has_tex = formats.contain_gtype(Gdk.Texture.__gtype__) + except Exception: # noqa: BLE001 + has_tex = False + has_img = False + try: + for mime in ( + "image/png", + "image/jpeg", + "image/jpg", + "image/webp", + "image/gif", + ): + if formats.contain_mime_type(mime): + has_img = True + break + except Exception: # noqa: BLE001 + has_img = False + if not (has_tex or has_img): + return False + + def _on_texture(clip: Gdk.Clipboard, result: Gio.AsyncResult) -> None: + try: + texture = clip.read_texture_finish(result) + except Exception: # noqa: BLE001 + return + if texture is None or closed["v"]: + return + _add_pasted_from_texture(texture) + + # Gio is pulled in via gi.repository below if needed. + clipboard.read_texture_async(None, _on_texture) + return True + other_id = next( (i for i in ids if i in {"other", "something_else", "something-else"}), None, @@ -1369,11 +1612,11 @@ def on_row_activated(_lb: Gtk.ListBox, activated: Gtk.ListBoxRow) -> None: freeform_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) freeform_box.set_margin_top(4) freeform_box.set_margin_bottom(12) - freeform_lbl = Gtk.Label(label="Or type something else:") + freeform_lbl = Gtk.Label(label="Or type something else") freeform_lbl.set_xalign(0.0) freeform_lbl.add_css_class("dim-label") freeform_entry = Gtk.Entry() - freeform_entry.set_placeholder_text("Type a different answer…") + freeform_entry.set_placeholder_text("Something else… · Ctrl+V image") freeform_entry.set_hexpand(True) freeform_box.append(freeform_lbl) freeform_box.append(freeform_entry) @@ -1401,6 +1644,7 @@ def _select_other(*, from_entry: bool = False) -> None: _selecting_from_entry["v"] = False def on_freeform_changed(_entry: Gtk.Entry) -> None: + mark_engaged() if (freeform_entry.get_text() or "").strip(): _select_other(from_entry=True) @@ -1572,7 +1816,8 @@ def on_always_listen_toggled(btn: Gtk.CheckButton) -> None: hint.set_ellipsize(Pango.EllipsizeMode.END) hint.set_tooltip_text( "Number keys select an option (toggle in multi-select). " - "Enter confirms after the short arm delay. Escape cancels." + "Enter confirms after the short arm delay. Escape cancels. " + "Ctrl+V pastes clipboard images as References (max 4)." ) btn_row.append(hint) cancel_btn = Gtk.Button(label="Cancel") @@ -1729,6 +1974,12 @@ def finish_ok(*_args: object) -> None: voice_trace["transcript"] = freeform_text voice_trace["matched_option_id"] = other_id result["voice"] = _voice_payload() + if pasted_items: + result["pasted_images"] = [ + {"mime": p.get("mime"), "data": p.get("data")} + for p in pasted_items + if p.get("mime") and p.get("data") + ] quit_app() def finish_voice_freeform(text: str) -> None: @@ -2246,8 +2497,14 @@ def on_key( _c: Gtk.EventControllerKey, keyval: int, _keycode: int, - _state: object, + state: Gdk.ModifierType, ) -> bool: + ctrl = bool(state & Gdk.ModifierType.CONTROL_MASK) + if ctrl and keyval in (Gdk.KEY_v, Gdk.KEY_V): + # Image paste → References; text paste still reaches the Entry. + if _try_paste_clipboard_image(): + return True + return False if keyval == Gdk.KEY_Escape: finish_cancel() return True @@ -2279,6 +2536,7 @@ def on_key( oid = ids[idx] btn = checks.get(oid) if btn is not None: + mark_engaged() if allow_multiple: btn.set_active(not btn.get_active()) else: @@ -2311,10 +2569,11 @@ def on_key( if timeout_sec > 0: def on_timeout() -> bool: + timeout_src["id"] = None finish_cancel("timed out") return GLib.SOURCE_REMOVE - GLib.timeout_add_seconds(timeout_sec, on_timeout) + timeout_src["id"] = GLib.timeout_add_seconds(timeout_sec, on_timeout) win.present() # Focus an option row — never OK — so Space toggles/selects, Return confirms. diff --git a/src/ask_question_mcp/linux_webview_ask.py b/src/ask_question_mcp/linux_webview_ask.py new file mode 100644 index 0000000..09bfea4 --- /dev/null +++ b/src/ask_question_mcp/linux_webview_ask.py @@ -0,0 +1,1480 @@ +#!/usr/bin/env python3 +"""Linux WebKitGTK Nebula MCQ dialog (same HTML/CSS/JS as Windows WebView). + +Serves ``assets/dialog`` over localhost and embeds WebKit 6 in a Gtk4 window. +Uses ``window.__ASK_BRIDGE__`` so ``dialog.js`` works unchanged (paste, idle +hold, glass theme). Falls back is handled by the parent (``zenity_ask``). +""" + +from __future__ import annotations + +import base64 +import json +import os +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +_MAX_PASTED = 4 +_PASTE_MAX_EDGE = 1280 +_PASTE_MAX_BYTES = 1_800_000 + +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] +try: + import linux_webview_voice as _voice +except ImportError: # pragma: no cover + _voice = None # type: ignore[assignment] + +_DIALOG_DIR = Path(__file__).resolve().parent / "assets" / "dialog" +_INDEX = _DIALOG_DIR / "index.html" + +_STATE: dict[str, Any] = { + "payload": {}, + "result": None, + "done": threading.Event(), + "ready": threading.Event(), + "engaged": threading.Event(), + "want_size": None, + "hits": [], + "bail_armed": False, + "agent_images": [], # list[Path] — served at /agent-image/ + "dom_probe": None, + "dom_probe_event": threading.Event(), + "voice": None, # NebulaVoiceSession | None +} + + +def _write_result_file(result_path: str | None, result: dict[str, Any]) -> None: + """Atomic write so the parent never reads a partial JSON answer.""" + if not result_path: + return + path = Path(result_path) + text = json.dumps(result, ensure_ascii=False) + "\n" + tmp = path.with_suffix(path.suffix + ".tmp") + try: + tmp.write_text(text, encoding="utf-8") + tmp.replace(path) + except OSError: + try: + path.write_text(text, encoding="utf-8") + except OSError: + pass + + +def _emit(result: dict[str, Any], result_path: str | None = None) -> None: + text = json.dumps(result, ensure_ascii=False) + _write_result_file(result_path, result) + print(text, flush=True) + + +def _touch_engaged(result_path: str | None, engaged_path: str | None) -> None: + paths = [] + if engaged_path: + paths.append(Path(engaged_path)) + if result_path: + paths.append(Path(str(result_path) + ".engaged")) + for path in paths: + try: + path.write_text("1", encoding="utf-8") + except OSError: + pass + + +def _texture_to_data_url(texture: Any) -> str | None: + """Compact Gdk.Texture → data URL for injection into dialog.js.""" + try: + from gi.repository import GdkPixbuf # type: ignore + except Exception: # noqa: BLE001 + GdkPixbuf = None # type: ignore[assignment] + try: + gbytes = texture.save_to_png_bytes() + png = bytes(gbytes.get_data()) + except Exception: # noqa: BLE001 + return None + if not png: + return None + pix = None + if GdkPixbuf is not None: + try: + loader = GdkPixbuf.PixbufLoader.new_with_type("png") + loader.write(png) + loader.close() + pix = loader.get_pixbuf() + except Exception: # noqa: BLE001 + pix = None + if pix is None: + if len(png) > _PASTE_MAX_BYTES: + return None + b64 = base64.b64encode(png).decode("ascii") + return f"data:image/png;base64,{b64}" + w, h = int(pix.get_width()), int(pix.get_height()) + if w < 1 or h < 1: + return None + scale = min(1.0, float(_PASTE_MAX_EDGE) / float(max(w, h))) + if scale < 1.0: + nw = max(1, int(round(w * scale))) + nh = max(1, int(round(h * scale))) + pix = pix.scale_simple(nw, nh, GdkPixbuf.InterpType.BILINEAR) + mime = "image/jpeg" + data = b"" + try: + ok, buf = pix.save_to_bufferv("jpeg", ["quality"], ["82"]) + data = bytes(buf) if ok else b"" + except Exception: # noqa: BLE001 + data = b"" + if not data: + try: + ok, buf = pix.save_to_bufferv("png", [], []) + data = bytes(buf) if ok else b"" + mime = "image/png" + except Exception: # noqa: BLE001 + return None + if not data or len(data) > _PASTE_MAX_BYTES: + return None + b64 = base64.b64encode(data).decode("ascii") + return f"data:{mime};base64,{b64}" + + +class _Handler(BaseHTTPRequestHandler): + bridge_origin = "" + result_path: str | None = None + engaged_path: str | None = None + # Set from main() — must be a zero-arg callable (or accept *args). + quit_cb: Any = None + hide_cb: Any = None + maximize_cb: Any = None + begin_move_cb: Any = None + + def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003 + return + + def _request_quit(self) -> None: + """Schedule UI teardown from the HTTP thread (never block the response). + + Always arm a hard ``os._exit`` bail — WebKit can wedge the Gtk loop so + ``GLib.idle_add(app.quit)`` never runs, which left MCP "Running…" after + the human already answered. + """ + cb = _Handler.quit_cb + if not callable(cb): + self._arm_hard_bail() + return + + def _safe() -> None: + try: + cb() + except Exception as exc: # noqa: BLE001 + try: + sys.stderr.write(f"nebula: quit_cb failed: {exc}\n") + sys.stderr.flush() + except OSError: + pass + + # Prefer GLib idle (thread-safe); fall back to a bare timer. + try: + from gi.repository import GLib + + def _idle() -> bool: + _safe() + return False + + GLib.idle_add(_idle) + except Exception: # noqa: BLE001 + threading.Timer(0.05, _safe).start() + # Independent of whether idle/quit runs. + self._arm_hard_bail() + + @staticmethod + def _arm_hard_bail() -> None: + """Force-exit soon after result is written if app.quit hangs. + + Always re-emit the result before ``os._exit`` — otherwise parents that + read stdout (and smokes without ``result_path``) see an empty answer + when WebKit wedges the Gtk main loop. + """ + if _STATE.get("bail_armed"): + return + _STATE["bail_armed"] = True + + def _bail() -> None: + # Give GLib idle + app.quit a fair chance before killing the process. + # 0.35s was too aggressive: destroy still in flight → empty stdout. + time.sleep(1.2) + if not _STATE["done"].is_set(): + return + result = _STATE.get("result") or { + "cancelled": True, + "reason": "hard bail with no result", + } + # Ensure parents can always recover the answer. + try: + _write_result_file(_Handler.result_path, result) + except Exception: # noqa: BLE001 + pass + try: + line = json.dumps(result, ensure_ascii=False) + sys.stdout.write(line + "\n") + sys.stdout.flush() + except OSError: + pass + try: + sys.stderr.write("nebula: hard bail after 1.2s\n") + sys.stderr.flush() + except OSError: + pass + try: + os._exit(0 if not result.get("cancelled") else 1) + except Exception: # noqa: BLE001 + os._exit(1) + + threading.Thread(target=_bail, daemon=True, name="nebula-hard-bail").start() + + 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 = parsed.path or "/" + if path.startswith("/agent-image/"): + try: + idx = int(path.rsplit("/", 1)[-1]) + except ValueError: + self.send_error(404) + return + images = _STATE.get("agent_images") or [] + if idx < 0 or idx >= len(images): + self.send_error(404) + return + target = Path(images[idx]) + try: + data = target.read_bytes() + except OSError: + self.send_error(404) + return + suffix = target.suffix.lower() + ctype = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".bmp": "image/bmp", + }.get(suffix, "application/octet-stream") + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self._cors() + self.end_headers() + self.wfile.write(data) + return + if path in {"/", "/index.html"}: + html = _INDEX.read_text(encoding="utf-8") + inject = ( + f"" + ) + if "" in html: + html = html.replace("", f"\n {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.send_header("Cache-Control", "no-store") + 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": + ctype = "application/manifest+json" + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + # WebKit otherwise keeps stale dialog.js across MCQs (missed lead/detail). + self.send_header("Cache-Control", "no-store") + 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) + if parsed.path == "/event": + name = str(body.get("name") or "") + if name == "content_ready": + _STATE["ready"].set() + shot = (os.environ.get("ASK_QUESTION_NEBULA_SHOT") or "").strip() + if shot and not _STATE.get("shot_done"): + _STATE["shot_done"] = True + + def _shot_later() -> bool: + win = _STATE.get("win") + if win is not None: + _maybe_shot_window(win, shot) + return False + + try: + from gi.repository import GLib + + GLib.timeout_add(700, _shot_later) + except Exception: # noqa: BLE001 + pass + elif name == "dom_probe_result": + args = body.get("args") or [] + data = args[0] if args else {} + if isinstance(data, dict): + _STATE["dom_probe"] = data + else: + _STATE["dom_probe"] = {"error": "bad probe payload"} + _STATE["dom_probe_event"].set() + elif name == "hold_timeout": + _STATE["engaged"].set() + _touch_engaged(self.result_path, self.engaged_path) + elif name == "closing": + # Instant hide on Enter/OK — before submit JSON finishes. + hide = _Handler.hide_cb + if callable(hide): + try: + from gi.repository import GLib + + def _idle() -> bool: + try: + hide() + except Exception: # noqa: BLE001 + pass + return False + + GLib.idle_add(_idle) + except Exception: # noqa: BLE001 + try: + hide() + except Exception: # noqa: BLE001 + pass + elif name == "maximize": + mx = _Handler.maximize_cb + if callable(mx): + try: + from gi.repository import GLib + + def _idle_mx() -> bool: + try: + mx() + except Exception: # noqa: BLE001 + pass + return False + + GLib.idle_add(_idle_mx) + except Exception: # noqa: BLE001 + try: + mx() + except Exception: # noqa: BLE001 + pass + elif name == "begin_move": + # Frameless WebKit: JS chrome drag → Gdk.Toplevel.begin_move. + bm = _Handler.begin_move_cb + args = body.get("args") or [] + if callable(bm): + try: + button = int(args[0]) if args else 1 + except (TypeError, ValueError): + button = 1 + try: + x = float(args[1]) if len(args) > 1 else 0.0 + y = float(args[2]) if len(args) > 2 else 0.0 + except (TypeError, ValueError): + x, y = 0.0, 0.0 + try: + from gi.repository import GLib + + def _idle_bm( + b: int = button, sx: float = x, sy: float = y + ) -> bool: + try: + bm(b, sx, sy) + except Exception: # noqa: BLE001 + pass + return False + + GLib.idle_add(_idle_bm) + except Exception: # noqa: BLE001 + try: + bm(button, x, y) + except Exception: # noqa: BLE001 + pass + elif name == "resize_to": + args = body.get("args") or [] + try: + w = max(400, min(900, int(args[0] if args else 0))) + h = max(360, min(980, int(args[1] if len(args) > 1 else 0))) + _STATE["want_size"] = (w, h) + except (TypeError, ValueError): + pass + 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 [] + + if name == "get_payload": + payload_obj = dict(_STATE["payload"] or {}) + voice = _STATE.get("voice") + if voice is not None: + payload_obj["voice_ui"] = voice.ui_snapshot() + payload = json.dumps( + {"result": payload_obj}, 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 == "debug": + self.send_response(200) + self._cors() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"result":null}') + return + + if name == "get_voice_state": + voice = _STATE.get("voice") + snap = voice.ui_snapshot() if voice is not None else {"speak_enabled": False} + body = json.dumps({"result": snap}, 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(body))) + self.end_headers() + self.wfile.write(body) + return + + if name == "voice_replay": + voice = _STATE.get("voice") + if voice is not None: + voice.on_replay() + self.send_response(200) + self._cors() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"result":null}') + return + + if name == "voice_listen": + voice = _STATE.get("voice") + if voice is not None: + voice.on_listen() + self.send_response(200) + self._cors() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"result":null}') + return + + if name == "voice_recover_repeat": + voice = _STATE.get("voice") + if voice is not None: + voice.on_recover_repeat() + self.send_response(200) + self._cors() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"result":null}') + return + + if name == "voice_use_this": + voice = _STATE.get("voice") + if voice is not None: + voice.on_use_this() + self.send_response(200) + self._cors() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"result":null}') + return + + if name == "set_audio_enabled": + voice = _STATE.get("voice") + enabled = bool(args[0]) if args else False + if voice is not None: + voice.set_audio_enabled(enabled) + elif _prefs is not None: + _prefs.set_audio_enabled(enabled) + self.send_response(200) + self._cors() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"result":null}') + return + + if name == "set_always_listen": + voice = _STATE.get("voice") + enabled = bool(args[0]) if args else False + if voice is not None: + voice.set_always_listen(enabled) + elif _prefs is not None: + _prefs.set_always_listen(enabled) + self.send_response(200) + self._cors() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"result":null}') + return + + if name == "submit": + ids = args[0] if args else [] + freeform = args[1] if len(args) > 1 else None + pasted = args[2] if len(args) > 2 else None + chosen = [str(x) for x in (ids or []) if str(x).strip()] + voice = _STATE.get("voice") + if voice is not None: + voice.stop_on_answer() + out: dict[str, Any] = {"ids": chosen} + typed = (str(freeform) if freeform is not None else "").strip() + if typed: + out["freeform_text"] = typed + if isinstance(pasted, list) and pasted: + try: + approx = len(json.dumps(pasted)) + except (TypeError, ValueError): + approx = 10**9 + if approx <= 700_000: + out["pasted_images"] = pasted + else: + # Prefer returning the answer without stills over stalling. + sys.stderr.write( + f"nebula: dropping pasted_images ({approx} chars)\n" + ) + sys.stderr.flush() + if voice is not None: + out["voice"] = voice.voice_payload() + if not chosen: + out = {"cancelled": True, "reason": "empty selection"} + if voice is not None: + out["voice"] = voice.voice_payload() + 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) + _STATE["result"] = out + _STATE["done"].set() + # Write result file before teardown — GApplication can exit hard. + _write_result_file(self.result_path, out) + self._request_quit() + return + + if name == "cancel": + reason = str(args[0] if args else "user cancelled") + voice = _STATE.get("voice") + if voice is not None: + voice.stop_on_answer() + 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) + out = {"cancelled": True, "reason": reason} + if voice is not None: + out["voice"] = voice.voice_payload() + _STATE["result"] = out + _STATE["done"].set() + _write_result_file(self.result_path, out) + self._request_quit() + return + + if name == "resize_to": + try: + w = max(400, min(900, int(args[0] if args else 0))) + h = max(360, min(980, int(args[1] if len(args) > 1 else 0))) + _STATE["want_size"] = (w, h) + except (TypeError, ValueError): + pass + self.send_response(200) + self._cors() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"result":null}') + return + + if name == "dom_probe": + # Ask the page to POST layout facts back via /event (WebKit JS finish + # value extraction is flaky across gir versions). + view = _STATE.get("view") + _STATE["dom_probe"] = None + _STATE["dom_probe_event"].clear() + bridge = json.dumps(self.bridge_origin) + script = f""" +(() => {{ + const app = document.getElementById('app'); + const footer = document.querySelector('.footer'); + const freeform = document.getElementById('freeform'); + const ok = document.getElementById('ok-btn'); + const cancel = document.getElementById('cancel-btn'); + const question = document.getElementById('question'); + const stars = document.querySelectorAll('.star-dot').length; + const fr = footer ? footer.getBoundingClientRect() : null; + const okr = ok ? ok.getBoundingClientRect() : null; + const vh = window.innerHeight || 0; + const vw = window.innerWidth || 0; + const payload = {{ + ready: !!(app && app.classList.contains('is-ready')), + theme: app ? (app.dataset.theme || '') : '', + stars: stars, + freeformInBody: !!(freeform && freeform.closest('main.body')), + refsInBody: !!(document.getElementById('refs') && + document.getElementById('refs').closest('main.body')), + questionText: question ? (question.textContent || '').slice(0, 80) : '', + footerVisible: !!(fr && fr.height > 0 && fr.bottom <= vh + 2), + okVisible: !!(okr && okr.height > 0 && okr.bottom <= vh + 2), + cancelLabel: cancel ? (cancel.textContent || '').trim() : '', + okLabel: ok ? (ok.textContent || '').replace(/\\s+/g, ' ').trim() : '', + vh: vh, + vw: vw, + footerBottom: fr ? fr.bottom : null, + okBottom: okr ? okr.bottom : null + }}; + fetch({bridge} + '/event', {{ + method: 'POST', + headers: {{'Content-Type': 'application/json'}}, + body: JSON.stringify({{name: 'dom_probe_result', args: [payload]}}) + }}).catch(() => {{}}); + return true; +}})() +""" + + def _run_probe() -> bool: + v = _STATE.get("view") + if v is None: + _STATE["dom_probe"] = {"error": "no webview"} + _STATE["dom_probe_event"].set() + return False + try: + v.evaluate_javascript( + script, -1, None, None, None, None, None + ) + except Exception as exc: # noqa: BLE001 + _STATE["dom_probe"] = {"error": f"eval: {exc}"} + _STATE["dom_probe_event"].set() + return False + + if view is None: + payload = json.dumps({"result": {"error": "no webview"}}).encode() + else: + try: + from gi.repository import GLib + + GLib.idle_add(_run_probe) + _STATE["dom_probe_event"].wait(timeout=3.0) + except Exception as exc: # noqa: BLE001 + _STATE["dom_probe"] = {"error": str(exc)} + payload = json.dumps( + {"result": _STATE.get("dom_probe") or {"error": "timeout"}}, + 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 + + 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 _webkit_available() -> bool: + try: + import gi + + gi.require_version("Gtk", "4.0") + gi.require_version("Adw", "1") + gi.require_version("WebKit", "6.0") + from gi.repository import WebKit # noqa: F401 + + return True + except Exception: # noqa: BLE001 + return False + + +def _maybe_shot_window(win: Any, path: str) -> None: + """Best-effort PNG of the dialog surface (ASK_QUESTION_NEBULA_SHOT=path).""" + if not path: + return + try: + from gi.repository import Gdk, GdkPixbuf # type: ignore + except Exception: # noqa: BLE001 + return + try: + surface = win.get_surface() + if surface is None: + return + w = max(1, int(win.get_width() or 600)) + h = max(1, int(win.get_height() or 700)) + # GTK4: paintable snapshot via render_texture when available. + texture = None + try: + texture = Gdk.Texture.new_for_pixbuf( + GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, w, h) + ) + except Exception: # noqa: BLE001 + texture = None + # Prefer widget paintable if present (Gtk 4.14+). + try: + paintable = win.get_paintable() # type: ignore[attr-defined] + except Exception: # noqa: BLE001 + paintable = None + if paintable is not None: + try: + texture = paintable.get_current_image() # type: ignore[attr-defined] + except Exception: # noqa: BLE001 + try: + texture = paintable # type: ignore[assignment] + except Exception: # noqa: BLE001 + pass + if texture is None: + # Fallback: grab the whole monitor via root — often black on Wayland. + display = win.get_display() + if display is None: + return + return + out = Path(path) + out.parent.mkdir(parents=True, exist_ok=True) + try: + texture.save_to_png(str(out)) # type: ignore[attr-defined] + sys.stderr.write(f"nebula: wrote shot {out} ({out.stat().st_size} bytes)\n") + sys.stderr.flush() + except Exception as exc: # noqa: BLE001 + sys.stderr.write(f"nebula: shot failed: {exc}\n") + sys.stderr.flush() + except Exception as exc: # noqa: BLE001 + try: + sys.stderr.write(f"nebula: shot error: {exc}\n") + sys.stderr.flush() + except OSError: + pass + + +def main() -> int: + 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 + engaged_path = str(payload.get("engaged_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 + if not _webkit_available(): + _emit( + { + "cancelled": True, + "reason": "WebKit 6 unavailable — install gir1.2-webkit-6.0", + }, + result_path, + ) + return 1 + + # Localhost-only dialog assets — sandbox bwrap often fails under Cursor / + # nested namespaces (uid map Permission denied → Trace/breakpoint trap). + os.environ.setdefault("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1") + os.environ.setdefault("WEBKIT_DISABLE_DMABUF_RENDERER", "1") + # Force X11/XWayland so the dialog reliably maps under GNOME+Cursor + # (inherited GDK_BACKEND=wayland often leaves WebKit with no visible surface). + if (os.environ.get("ASK_QUESTION_GDK_BACKEND") or "").strip(): + os.environ["GDK_BACKEND"] = os.environ["ASK_QUESTION_GDK_BACKEND"].strip() + else: + os.environ["GDK_BACKEND"] = "x11" + + import gi + + gi.require_version("Gtk", "4.0") + gi.require_version("Adw", "1") + gi.require_version("WebKit", "6.0") + gi.require_version("Gdk", "4.0") + from gi.repository import Adw, Gdk, Gio, GLib, Gtk, WebKit + + sys.stderr.write( + f"nebula: boot display={os.environ.get('DISPLAY')!r} " + f"gdk={os.environ.get('GDK_BACKEND')!r} pid={os.getpid()}\n" + ) + sys.stderr.flush() + + 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 + # Nebula aims to feel instant: 250ms safe arm (was 1s). Override with + # ASK_QUESTION_NEBULA_ARM_MS (0 = off). Dangerous still uses danger_arm. + nebula_arm_raw = os.environ.get("ASK_QUESTION_NEBULA_ARM_MS", "").strip() + if nebula_arm_raw: + try: + ui_payload["arm_ms"] = max(0, min(60_000, int(nebula_arm_raw))) + except ValueError: + ui_payload["arm_ms"] = 250 + elif ui_payload["dangerous"] and _danger_arm is not None: + ui_payload["arm_ms"] = int(_danger_arm.danger_arm_ms(dangerous=True)) + else: + ui_payload["arm_ms"] = 250 + 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) + if payload.get("entry_seed") is not None: + ui_payload["entry_seed"] = str(payload.get("entry_seed") or "") + + # Resolve agent preview paths (served after the bridge port is known). + agent_paths: list[Path] = [] + for raw in payload.get("images") or []: + p = Path(str(raw)).expanduser() + try: + p = p.resolve(strict=False) + except OSError: + continue + if p.is_file() and p.suffix.lower() in { + ".png", + ".jpg", + ".jpeg", + ".webp", + ".gif", + ".bmp", + }: + agent_paths.append(p) + if len(agent_paths) >= 4: + break + _STATE["agent_images"] = agent_paths + + geom = ( + _prefs.get_window_geometry() + if _prefs is not None + else {"w": 600, "h": 700} + ) + width = max(520, min(760, int(geom.get("w") or 600))) + # Voice footer (status + Audio/Replay/Listen) needs extra pinned height. + voice_pad = 72 if bool(payload.get("speak_enabled") or payload.get("voice_answer")) else 0 + height = max( + 560 + (40 if voice_pad else 0), + min(900, int(geom.get("h") or (760 if agent_paths else 680)) + voice_pad), + ) + + 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 + _Handler.engaged_path = engaged_path + try: + sys.stderr.write(f"nebula: bridge={origin}\n") + sys.stderr.flush() + except OSError: + pass + # Bridge-relative URLs so WebKit never needs file:// access. + ui_payload["agent_images"] = [ + f"{origin}/agent-image/{i}" for i in range(len(agent_paths)) + ] + ui_payload["images"] = list(ui_payload["agent_images"]) + + speak_enabled = bool(payload.get("speak_enabled")) + speak_text = str(payload.get("speak_text") or "").strip() + speak_python = str(payload.get("speak_python") or "").strip() + if not (speak_enabled and speak_text and speak_python): + speak_enabled = False + speak_pgid_file = payload.get("speak_pgid_file") + speak_pgid_file_s = str(speak_pgid_file) if speak_pgid_file else None + voice_answer = bool(payload.get("voice_answer")) + audio_mode = str(payload.get("audio_mode") or "").strip() or ( + "full" if speak_enabled else "text_only" + ) + ui_payload["speak_enabled"] = speak_enabled + ui_payload["voice_answer"] = voice_answer + ui_payload["audio_mode"] = audio_mode + ui_payload["audio_enabled"] = ( + bool(_prefs.get_audio_enabled()) if _prefs is not None else True + ) + ui_payload["always_listen"] = ( + bool(_prefs.get_always_listen()) if _prefs is not None else False + ) + _STATE["payload"] = ui_payload + threading.Thread(target=server.serve_forever, daemon=True).start() + + # NON_UNIQUE — parallel MCP agents must each get their own dialog. + app = Adw.Application( + application_id="uk.dynamicdevices.ask-question-nebula", + flags=Gio.ApplicationFlags.NON_UNIQUE, + ) + win_holder: dict[str, Any] = {"win": None, "app": app, "view": None} + + def quit_ui(*_args: Any) -> None: + """Close the Adw app. Accept *args — GLib/Timer may pass a leftover.""" + + def _q() -> bool: + try: + if _prefs is not None and win_holder["win"] is not None: + w = int(win_holder["win"].get_width() or 0) + h = int(win_holder["win"].get_height() or 0) + if w > 0 and h > 0: + _prefs.set_window_geometry( + w=min(900, max(200, w)), + h=min(900, max(200, h)), + ) + except Exception: # noqa: BLE001 + pass + try: + app.quit() + except Exception: # noqa: BLE001 + pass + return False + + try: + GLib.idle_add(_q) + except Exception: # noqa: BLE001 + try: + app.quit() + except Exception: # noqa: BLE001 + pass + # Hard bail is armed from _request_quit (independent of this idle). + + _Handler.quit_cb = quit_ui + + def _schedule_ui(fn: Any) -> None: + try: + from gi.repository import GLib + + def _idle() -> bool: + try: + fn() + except Exception: # noqa: BLE001 + pass + return False + + GLib.idle_add(_idle) + except Exception: # noqa: BLE001 + try: + fn() + except Exception: # noqa: BLE001 + pass + + def _push_voice_js(snap: dict[str, Any]) -> None: + view = win_holder.get("view") or _STATE.get("view") + if view is None: + return + payload_js = json.dumps(snap, ensure_ascii=False) + script = ( + "(() => { const fn = window.__ASK_VOICE_UPDATE__; " + f"if (typeof fn === 'function') fn({payload_js}); }})();" + ) + + def _run() -> None: + try: + view.evaluate_javascript(script, -1, None, None, None, None) + except Exception: # noqa: BLE001 + pass + + _schedule_ui(_run) + + def hide_ui() -> None: + """Hide immediately on Enter — quit follows after result is written.""" + win = win_holder.get("win") + if win is None: + return + try: + win.set_visible(False) + except Exception: # noqa: BLE001 + try: + win.hide() + except Exception: # noqa: BLE001 + pass + + _Handler.hide_cb = hide_ui + maximized = {"v": False} + + def maximize_ui() -> None: + win = win_holder.get("win") + if win is None: + return + try: + if maximized["v"]: + win.unmaximize() + maximized["v"] = False + else: + win.maximize() + maximized["v"] = True + except Exception: # noqa: BLE001 + pass + + _Handler.maximize_cb = maximize_ui + + def begin_move_ui(button: int = 1, x: float = 0.0, y: float = 0.0) -> None: + """Start a compositor window move from the in-page chrome (frameless).""" + win = win_holder.get("win") + if win is None: + return + try: + surface = win.get_surface() + if surface is None: + return + display = win.get_display() + if display is None: + return + seat = display.get_default_seat() + if seat is None: + return + device = seat.get_pointer() + if device is None: + return + # Gdk.Toplevel.begin_move(device, button, x, y, timestamp) + Gdk.Toplevel.begin_move( + surface, device, int(button or 1), float(x), float(y), Gdk.CURRENT_TIME + ) + except Exception: # noqa: BLE001 + try: + sys.stderr.write("nebula: begin_move failed\n") + sys.stderr.flush() + except OSError: + pass + + _Handler.begin_move_cb = begin_move_ui + + def _voice_auto_ok(oid: str | None, freeform: str | None) -> None: + if _STATE["done"].is_set(): + return + voice = _STATE.get("voice") + chosen = [oid] if oid else [] + out: dict[str, Any] = {"ids": chosen, "cancelled": False} + if freeform: + out["freeform_text"] = freeform + if voice is not None: + out["voice"] = voice.voice_payload() + if not chosen: + out = {"cancelled": True, "reason": "empty voice selection"} + if voice is not None: + out["voice"] = voice.voice_payload() + _STATE["result"] = out + _STATE["done"].set() + _write_result_file(result_path, out) + try: + hide_ui() + except Exception: # noqa: BLE001 + pass + quit_ui() + + def _voice_auto_cancel(reason: str) -> None: + if _STATE["done"].is_set(): + return + voice = _STATE.get("voice") + if voice is not None and not voice.closed: + voice.stop_on_answer() + out: dict[str, Any] = {"cancelled": True, "reason": reason} + if voice is not None: + out["voice"] = voice.voice_payload() + _STATE["result"] = out + _STATE["done"].set() + _write_result_file(result_path, out) + quit_ui() + + if _voice is not None and (speak_enabled or voice_answer): + session = _voice.NebulaVoiceSession( + ids=ids, + labels=ui_payload["labels"], + recommended_ids=ui_payload["recommended_ids"], + allow_multiple=bool(ui_payload["allow_multiple"]), + allow_other=bool(ui_payload["allow_other"]), + dangerous=bool(ui_payload["dangerous"]), + speak_enabled=speak_enabled, + speak_text=speak_text, + speak_python=speak_python, + speak_pgid_file=speak_pgid_file_s, + voice_answer=voice_answer, + audio_mode=audio_mode, + on_ui=_push_voice_js, + on_auto_ok=_voice_auto_ok, + on_auto_cancel=_voice_auto_cancel, + schedule=_schedule_ui, + ) + _STATE["voice"] = session + ui_payload["voice_ui"] = session.ui_snapshot() + _STATE["payload"] = ui_payload + + def on_activate(application: Adw.Application) -> None: + win = Adw.ApplicationWindow(application=application) + win_holder["win"] = win + _STATE["win"] = win + win.set_title(title) + win.set_default_size(width, height) + win.set_size_request(420, 360) + # Match Windows frameless Nebula chrome (in-page header + close). + try: + win.set_decorated(False) + except Exception: # noqa: BLE001 + pass + + view = WebKit.WebView() + win_holder["view"] = view + _STATE["view"] = view + # Start Always-listen / speak-phase poll once the page can receive pushes. + voice_sess = _STATE.get("voice") + if voice_sess is not None: + + def _start_voice_later() -> bool: + try: + voice_sess.start_if_always_listen() + except Exception: # noqa: BLE001 + pass + return False + + GLib.timeout_add(400, _start_voice_later) + try: + view.set_hexpand(True) + view.set_vexpand(True) + except Exception: # noqa: BLE001 + pass + settings = view.get_settings() + try: + settings.set_enable_developer_extras( + os.environ.get("ASK_QUESTION_DEBUG", "").strip().lower() + in {"1", "true", "yes", "on"} + ) + except Exception: # noqa: BLE001 + pass + # Required for navigator.clipboard / DOM paste of images in WebKitGTK. + try: + settings.set_javascript_can_access_clipboard(True) + except Exception: # noqa: BLE001 + pass + + def on_permission(_v: WebKit.WebView, request: Any) -> bool: + try: + request.allow() + except Exception: # noqa: BLE001 + pass + return True + + try: + view.connect("permission-request", on_permission) + except Exception: # noqa: BLE001 + pass + + def on_load(_v: WebKit.WebView, event: WebKit.LoadEvent) -> None: + if event == WebKit.LoadEvent.FINISHED: + _STATE["ready"].set() + try: + sys.stderr.write("nebula: load finished\n") + sys.stderr.flush() + except OSError: + pass + + def on_fail(_v: WebKit.WebView, _event: WebKit.LoadEvent, failing_uri: str, error: Any) -> bool: + try: + sys.stderr.write(f"nebula: load failed uri={failing_uri} err={error}\n") + sys.stderr.flush() + except OSError: + pass + return False + + view.connect("load-changed", on_load) + try: + view.connect("load-failed", on_fail) + except Exception: # noqa: BLE001 + pass + + pasted_count = {"n": 0} + + def _inject_data_url(data_url: str) -> None: + if pasted_count["n"] >= _MAX_PASTED: + return + # JSON-encode so quotes/newlines cannot break the script. + payload = json.dumps(data_url) + script = ( + "(() => { const fn = window.__ASK_ADD_PASTED__; " + f"if (typeof fn === 'function') fn({payload}); }})();" + ) + try: + view.evaluate_javascript(script, -1, None, None, None, None) + pasted_count["n"] += 1 + _STATE["engaged"].set() + _touch_engaged(result_path, engaged_path) + sys.stderr.write("nebula: native paste injected\n") + sys.stderr.flush() + except Exception as exc: # noqa: BLE001 + try: + sys.stderr.write(f"nebula: paste inject failed: {exc}\n") + sys.stderr.flush() + except OSError: + pass + + def _try_native_paste() -> bool: + """Gdk clipboard → JS refs (WebKit often omits image clipboardData).""" + display = win.get_display() + if display is None: + return False + clipboard = display.get_clipboard() + formats = clipboard.get_formats() + try: + has_tex = formats.contain_gtype(Gdk.Texture.__gtype__) + except Exception: # noqa: BLE001 + has_tex = False + has_img = False + try: + for mime in ( + "image/png", + "image/jpeg", + "image/jpg", + "image/webp", + "image/gif", + "image/bmp", + ): + if formats.contain_mime_type(mime): + has_img = True + break + except Exception: # noqa: BLE001 + has_img = False + if not (has_tex or has_img): + return False + + def _on_texture(clip: Gdk.Clipboard, result: Gio.AsyncResult) -> None: + try: + texture = clip.read_texture_finish(result) + except Exception: # noqa: BLE001 + return + if texture is None or _STATE["done"].is_set(): + return + data_url = _texture_to_data_url(texture) + if data_url: + _inject_data_url(data_url) + + clipboard.read_texture_async(None, _on_texture) + return True + + # Capture Ctrl+V at the Gtk layer — more reliable than WebKit paste alone. + key_ctrl = Gtk.EventControllerKey() + + def on_key( + _ctrl: Gtk.EventControllerKey, + keyval: int, + _keycode: int, + state: Gdk.ModifierType, + ) -> bool: + ctrl = bool(state & Gdk.ModifierType.CONTROL_MASK) + if ctrl and keyval in (Gdk.KEY_v, Gdk.KEY_V): + if _try_native_paste(): + return True + return False + + key_ctrl.connect("key-pressed", on_key) + win.add_controller(key_ctrl) + # Also on the view — focus often sits in the WebView. + key_ctrl_view = Gtk.EventControllerKey() + key_ctrl_view.connect("key-pressed", on_key) + try: + view.add_controller(key_ctrl_view) + except Exception: # noqa: BLE001 + pass + + view.load_uri(f"{origin}/") + win.set_content(view) + + def on_close(*_a: object) -> bool: + voice = _STATE.get("voice") + if voice is not None: + try: + voice.stop_on_answer() + except Exception: # noqa: BLE001 + pass + if not _STATE["done"].is_set(): + out: dict[str, Any] = { + "cancelled": True, + "reason": "window closed", + } + if voice is not None: + out["voice"] = voice.voice_payload() + _STATE["result"] = out + _STATE["done"].set() + _write_result_file(result_path, out) + quit_ui() + return False + + win.connect("close-request", on_close) + + # Soft idle timeout in-dialog (JS); parent also watches engaged_path. + # Extra Python watchdog if JS never mounts. + if timeout_sec > 0: + + def on_idle_timeout() -> bool: + if _STATE["done"].is_set() or _STATE["engaged"].is_set(): + return GLib.SOURCE_REMOVE + _STATE["result"] = {"cancelled": True, "reason": "timeout"} + _STATE["done"].set() + quit_ui() + return GLib.SOURCE_REMOVE + + GLib.timeout_add_seconds(timeout_sec, on_idle_timeout) + + def poll_resize() -> bool: + if _STATE["done"].is_set(): + return GLib.SOURCE_REMOVE + want = _STATE.get("want_size") + if want: + _STATE["want_size"] = None + try: + w = int(want[0]) + h = int(want[1]) + h = max(h, 580 if agent_paths else 540) + # set_default_size alone often won't grow a mapped Gtk4 window. + win.set_default_size(w, h) + try: + win.unmaximize() + except Exception: # noqa: BLE001 + pass + try: + # Nudge allocate so the WebView actually gains the pixels. + win.set_size_request(0, 0) + win.set_default_size(w, h) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + return GLib.SOURCE_CONTINUE + + GLib.timeout_add(100, poll_resize) + + def _raise() -> bool: + try: + win.present() + win.set_focus(view) + except Exception: # noqa: BLE001 + pass + return GLib.SOURCE_REMOVE + + win.present() + GLib.timeout_add(50, _raise) + GLib.timeout_add(250, _raise) + + app.connect("activate", on_activate) + app.run([]) + + try: + server.shutdown() + except Exception: # noqa: BLE001 + pass + + result = _STATE.get("result") or { + "cancelled": True, + "reason": "no selection", + } + # Stdout once (parent reads). Avoid double-print if already emitted. + print(json.dumps(result, ensure_ascii=False), flush=True) + if result_path: + try: + Path(result_path).write_text( + json.dumps(result, ensure_ascii=False) + "\n", encoding="utf-8" + ) + except OSError: + pass + 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"linux_webview crash: {exc}"}) + raise SystemExit(1) from exc diff --git a/src/ask_question_mcp/linux_webview_voice.py b/src/ask_question_mcp/linux_webview_voice.py new file mode 100644 index 0000000..728aa4c --- /dev/null +++ b/src/ask_question_mcp/linux_webview_voice.py @@ -0,0 +1,925 @@ +"""Voice host for Linux Nebula (WebKit) — mirrors Gtk TTS/STT behaviour. + +Reuses ``voice_answer`` / ``prefs`` / speak IPC. UI updates are pushed via a +callback (status, recover chrome, select/submit). System Python imports this +as a sibling of ``linux_webview_ask.py``. +""" + +from __future__ import annotations + +import json +import os +import signal +import subprocess +import threading +import time +from pathlib import Path +from typing import Any, Callable + +try: + import voice_answer as _voice_answer +except ImportError: # pragma: no cover + _voice_answer = None # type: ignore[assignment] +try: + import prefs as _prefs +except ImportError: # pragma: no cover + _prefs = None # type: ignore[assignment] +try: + import session_ipc as _session_ipc +except ImportError: # pragma: no cover + _session_ipc = None # type: ignore[assignment] +try: + import audio_duck as _audio_duck +except ImportError: # pragma: no cover + _audio_duck = None # type: ignore[assignment] + + +UiPush = Callable[[dict[str, Any]], None] + + +def _ipc_root() -> Path: + if _session_ipc is not None: + return _session_ipc.ipc_dir() + return Path.home() / ".cache" / "ask-question-mcp" + + +def _force_unduck_media() -> None: + if _audio_duck is None: + return + try: + release_orphaned = getattr(_audio_duck, "release_orphaned_playback_duck", None) + if callable(release_orphaned): + release_orphaned() + _audio_duck.release_duck_hold(ramp=False, force=True) + _audio_duck.restore_other_audio(ramp=False, force=True) + except Exception: # noqa: BLE001 + pass + + +def stop_question_audio(pgid_file: str | None) -> None: + path = Path(pgid_file) if pgid_file else (_ipc_root() / "speak.pgid") + try: + raw = path.read_text(encoding="utf-8").strip() if path.is_file() else "" + pgid = int(raw) if raw else 0 + except (OSError, ValueError): + pgid = 0 + try: + path.unlink(missing_ok=True) + except OSError: + pass + if pgid > 0: + try: + os.killpg(pgid, signal.SIGKILL) + except (OSError, ProcessLookupError): + try: + os.kill(pgid, signal.SIGKILL) + except (OSError, ProcessLookupError): + pass + + +def snapshot_ack_and_invalidate() -> None: + root = _ipc_root() + gen_f = root / "speak.gen" + done_f = root / "speak.done" + ack_f = root / "speak.ack_ok" + try: + root.mkdir(parents=True, exist_ok=True) + try: + gen = int(gen_f.read_text(encoding="utf-8").strip() or "0") if gen_f.is_file() else 0 + except (OSError, ValueError): + gen = 0 + try: + done = int(done_f.read_text(encoding="utf-8").strip() or "0") if done_f.is_file() else -1 + except (OSError, ValueError): + done = -1 + allowed = done > 0 and done == gen + gen_f.write_text(str(gen + 1), encoding="utf-8") + try: + done_f.unlink(missing_ok=True) + except OSError: + pass + ack_f.write_text("1" if allowed else "0", encoding="utf-8") + except OSError: + pass + + +def on_answer_stop_audio(pgid_file: str | None) -> None: + snapshot_ack_and_invalidate() + stop_question_audio(pgid_file) + if _voice_answer is not None: + try: + _voice_answer.flush_a2dp_restore() + except Exception: # noqa: BLE001 + pass + + +def interrupt_question_for_early_listen(pgid_file: str | None) -> None: + root = _ipc_root() + gen_f = root / "speak.gen" + done_f = root / "speak.done" + ack_f = root / "speak.ack_ok" + phase_f = root / "speak.phase" + already_done = False + try: + root.mkdir(parents=True, exist_ok=True) + try: + gen = int(gen_f.read_text(encoding="utf-8").strip() or "0") if gen_f.is_file() else 0 + except (OSError, ValueError): + gen = 0 + try: + done = int(done_f.read_text(encoding="utf-8").strip() or "0") if done_f.is_file() else -1 + except (OSError, ValueError): + done = -1 + already_done = done > 0 and done == gen and gen > 0 + except OSError: + already_done = False + + stop_question_audio(pgid_file) + if already_done: + return + try: + root.mkdir(parents=True, exist_ok=True) + try: + gen = int(gen_f.read_text(encoding="utf-8").strip() or "0") if gen_f.is_file() else 0 + except (OSError, ValueError): + gen = 0 + gen_f.write_text(str(gen + 1), encoding="utf-8") + done_f.unlink(missing_ok=True) + phase_f.unlink(missing_ok=True) + ack_f.write_text("0", encoding="utf-8") + except OSError: + pass + + +def replay_question_speak( + *, + speak_text: str, + speak_python: str, + speak_pgid_file: str | None, +) -> None: + text = " ".join((speak_text or "").split()) + py = (speak_python or "").strip() + if not text or not py or not Path(py).is_file(): + return + stop_question_audio(speak_pgid_file) + try: + root = _ipc_root() + (root / "speak.done").unlink(missing_ok=True) + (root / "speak.ack_ok").unlink(missing_ok=True) + except OSError: + pass + try: + subprocess.Popen( + [ + py, + "-c", + ( + "from ask_question_mcp.voice_acks import speak_async; " + "import sys; speak_async(sys.argv[1])" + ), + text, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError: + pass + + +class NebulaVoiceSession: + """Host-side voice for one Nebula MCQ dialog.""" + + def __init__( + self, + *, + ids: list[str], + labels: dict[str, str], + recommended_ids: list[str], + allow_multiple: bool, + allow_other: bool, + dangerous: bool, + speak_enabled: bool, + speak_text: str, + speak_python: str, + speak_pgid_file: str | None, + voice_answer: bool, + audio_mode: str, + on_ui: UiPush, + on_auto_ok: Callable[[str | None, str | None], None], + on_auto_cancel: Callable[[str], None], + schedule: Callable[[Callable[[], None]], None], + ) -> None: + self.ids = list(ids) + self.labels = dict(labels) + self.recommended_ids = list(recommended_ids) + self.allow_multiple = bool(allow_multiple) + self.allow_other = bool(allow_other) + self.dangerous = bool(dangerous) + self.speak_enabled = bool(speak_enabled and speak_text and speak_python) + self.speak_text = speak_text + self.speak_python = speak_python + self.speak_pgid_file = speak_pgid_file + self.audio_mode = audio_mode or ("full" if self.speak_enabled else "text_only") + self._on_ui = on_ui + self._on_auto_ok = on_auto_ok + self._on_auto_cancel = on_auto_cancel + self._schedule = schedule + + self.voice_answer_on = bool(voice_answer) and self.speak_enabled and ( + _voice_answer is not None + ) + if self.voice_answer_on and _voice_answer is not None: + self.voice_answer_on = _voice_answer.voice_answer_enabled(speak_enabled=True) + + self.listen_gen = 0 + self.voice_retries = 0 + self.closed = False + self.freeform_pending = "" + self.status_state = "idle" + self.status_text = "" + self.recover_visible = False + self.recover_label = "" + self.use_this_visible = False + self.voice_trace: dict[str, Any] = { + "enabled": bool(self.voice_answer_on), + "used": False, + "freeform_voice": False, + "transcript": "", + "error": None, + "source": None, + "peak_rms": None, + "matched_option_id": None, + "attempts": [], + } + self._phase_poll_stop = threading.Event() + self._init_status() + + def _audio_on(self) -> bool: + if _prefs is None: + return True + return bool(_prefs.get_audio_enabled()) + + def _always_listen(self) -> bool: + if _prefs is None: + return False + return bool(_prefs.get_always_listen()) + + def _init_status(self) -> None: + stt_missing = False + if self.speak_enabled and _voice_answer is not None: + try: + stt_missing = not bool(_voice_answer.stt_url()) + except Exception: # noqa: BLE001 + stt_missing = True + if self.voice_answer_on: + self._set_status("speaking", "● Waiting for question audio…") + elif self.audio_mode == "text_only" or not self.speak_enabled: + self._set_status("idle", "") + elif stt_missing: + self._set_status( + "idle", + "● Speak on — STT unset (click / type; set ASK_QUESTION_STT_URL)", + ) + else: + self._set_status( + "idle", + "● Speak on — use click / type to answer (no STT).", + ) + + def _set_status(self, state: str, text: str) -> None: + self.status_state = state + self.status_text = text + self._push() + + def _push(self, **extra: Any) -> None: + snap = self.ui_snapshot() + snap.update(extra) + try: + self._on_ui(snap) + except Exception: # noqa: BLE001 + pass + + def ui_snapshot(self) -> dict[str, Any]: + return { + "speak_enabled": self.speak_enabled, + "voice_answer": self.voice_answer_on and not self.allow_multiple, + "audio_enabled": self._audio_on(), + "always_listen": self._always_listen(), + "status_state": self.status_state, + "status_text": self.status_text, + "recover_visible": self.recover_visible, + "recover_label": self.recover_label, + "use_this_visible": self.use_this_visible, + "stt_configured": bool( + _voice_answer is not None and getattr(_voice_answer, "stt_url", lambda: "")() + ), + } + + def voice_payload(self) -> dict[str, Any]: + payload = { + "enabled": self.voice_trace["enabled"], + "used": self.voice_trace["used"], + "freeform_voice": bool(self.voice_trace.get("freeform_voice")), + "transcript": self.voice_trace.get("transcript") or "", + "error": self.voice_trace.get("error"), + "source": self.voice_trace.get("source"), + "peak_rms": self.voice_trace.get("peak_rms"), + "matched_option_id": self.voice_trace.get("matched_option_id"), + "attempts": list(self.voice_trace.get("attempts") or [])[-6:], + } + try: + root = _ipc_root() + root.mkdir(parents=True, exist_ok=True) + blob = json.dumps(payload, ensure_ascii=False, indent=2) + side = root / "voice.last.json" + side.write_text(blob, encoding="utf-8") + side.chmod(0o600) + if _session_ipc is not None: + mirror = _session_ipc.voice_last_mirror_path() + mirror.parent.mkdir(parents=True, exist_ok=True) + mirror.write_text(blob, encoding="utf-8") + mirror.chmod(0o600) + except OSError: + pass + return payload + + def _note_voice_attempt(self, out: dict[str, Any]) -> None: + att = { + "ok": bool(out.get("ok")), + "transcript": str(out.get("transcript") or ""), + "error": out.get("error"), + "option_id": out.get("option_id"), + "source": out.get("source"), + "peak_rms": out.get("peak_rms"), + } + self.voice_trace["attempts"].append(att) + self.voice_trace["transcript"] = att["transcript"] + self.voice_trace["error"] = att["error"] + self.voice_trace["source"] = att["source"] + self.voice_trace["peak_rms"] = att["peak_rms"] + if att["ok"] and att["option_id"]: + self.voice_trace["matched_option_id"] = att["option_id"] + self.voice_trace["used"] = True + self.voice_trace["error"] = None + + def stop_on_answer(self) -> None: + self.closed = True + self.listen_gen += 1 + self._phase_poll_stop.set() + on_answer_stop_audio(self.speak_pgid_file) + + def set_audio_enabled(self, enabled: bool) -> None: + if _prefs is not None: + _prefs.set_audio_enabled(bool(enabled)) + if not enabled: + stop_question_audio(self.speak_pgid_file) + self.listen_gen += 1 + self.recover_visible = False + self.use_this_visible = False + _force_unduck_media() + if not self.closed: + self._set_status("idle", "") + else: + self._push() + + def set_always_listen(self, enabled: bool) -> None: + if _prefs is not None: + _prefs.set_always_listen(bool(enabled)) + self._push() + if enabled and not self.closed and self._audio_on(): + self.start_voice_listen_thread() + + def on_replay(self) -> None: + if not self.speak_enabled: + return + if not self._audio_on(): + self._set_status("idle", "Audio off — enable Audio to replay") + return + self.voice_retries = 0 + replay_question_speak( + speak_text=self.speak_text, + speak_python=self.speak_python, + speak_pgid_file=self.speak_pgid_file, + ) + if self.voice_answer_on and not self.allow_multiple: + self._set_status("speaking", "● Replaying… then listening") + self.start_voice_listen_thread() + else: + self._set_status("speaking", "● Replaying…") + + def on_listen(self) -> None: + if not self.voice_answer_on or self.allow_multiple: + if self.speak_enabled and not self.voice_answer_on: + stt = "" + if _voice_answer is not None: + try: + stt = _voice_answer.stt_url() or "" + except Exception: # noqa: BLE001 + stt = "" + if not stt: + self._set_status( + "idle", + "STT unset — set ASK_QUESTION_STT_URL to Listen", + ) + else: + self._set_status( + "idle", + "Listen unavailable — use click / type", + ) + return + if not self._audio_on(): + self._set_status("idle", "Audio off — enable Audio to listen") + return + self.voice_retries = 0 + self.recover_visible = False + self.freeform_pending = "" + self.use_this_visible = False + interrupt_question_for_early_listen(self.speak_pgid_file) + self.start_voice_listen_thread_skip_speak() + + def on_recover_repeat(self) -> None: + self.start_voice_listen_thread_skip_speak() + + def on_use_this(self) -> None: + text = (self.freeform_pending or "").strip() + if not text: + return + self._finish_voice_freeform(text) + + def _other_id(self) -> str | None: + for i in self.ids: + if i in {"other", "something_else", "something-else"}: + return i + return None + + def _finish_voice_freeform(self, text: str) -> None: + cleaned = " ".join((text or "").split()).strip() + if not cleaned: + return + other = self._other_id() + if other is None or not self.allow_other: + return + self.stop_on_answer() + self.voice_trace["used"] = True + self.voice_trace["freeform_voice"] = True + self.voice_trace["transcript"] = cleaned + self.voice_trace["matched_option_id"] = other + self.voice_trace["error"] = None + self.freeform_pending = "" + self._on_auto_ok(other, cleaned) + + def _apply_voice_match(self, oid: str, transcript: str) -> None: + if self.closed or oid not in self.ids: + return + if oid in {"other", "something_else", "something-else"} and self.allow_other: + self._enter_voice_recover(str(transcript or "").strip(), "something_else") + return + self.recover_visible = False + self.freeform_pending = "" + self.use_this_visible = False + shown = transcript.strip() or oid + lab = self.labels.get(oid, oid) + self.voice_trace["used"] = True + self.voice_trace["transcript"] = shown + self.voice_trace["matched_option_id"] = oid + self.voice_trace["error"] = None + if self.dangerous or self.allow_multiple: + self._set_status( + "heard", + f"Heard “{shown}” → {lab} — press OK to confirm", + ) + self._push(select_id=oid) + else: + self._set_status("heard", f"Heard “{shown}” → {lab}") + self.stop_on_answer() + self._on_auto_ok(oid, None) + + def _enter_voice_recover(self, heard: str, err: str) -> None: + self.recover_visible = True + heard_s = (heard or "").strip() + control = False + if heard_s and _voice_answer is not None: + try: + control = bool( + _voice_answer.match_voice_recovery(heard_s) + or _voice_answer.match_voice_freeform_confirm(heard_s) + ) + except Exception: # noqa: BLE001 + control = False + if (not heard_s or control) and self.freeform_pending: + heard_s = self.freeform_pending + + bare_other = err == "something_else" + if heard_s and _voice_answer is not None: + try: + norm = " ".join(heard_s.split()) + low = norm.casefold() + if low in { + "something else", + "none of the above", + "none of those", + "none of them", + "none of these", + "other option", + "free form", + "freeform", + }: + bare_other = True + elif _voice_answer._SOMETHING_ELSE_RE.fullmatch(norm) is not None: + bare_other = True + except Exception: # noqa: BLE001 + pass + + can_freeform = bool( + self.allow_other + and heard_s + and not bare_other + and self._other_id() is not None + ) + self.freeform_pending = ( + heard_s + if can_freeform + else ("" if bare_other else (self.freeform_pending or "")) + ) + self.use_this_visible = bool(self.freeform_pending) + + other = self._other_id() + select_id = None + freeform_seed = None + if other and (can_freeform or bare_other or err == "something_else"): + select_id = other + if self.freeform_pending: + freeform_seed = self.freeform_pending + elif bare_other: + freeform_seed = "" + + if self.freeform_pending: + self.recover_label = ( + f"Heard: “{self.freeform_pending}” — " + "Use this if that’s your answer, or Repeat / edit below / OK" + ) + self._set_status("heard", "Not sure which option — check Heard above") + elif bare_other or err == "something_else": + self.recover_label = ( + "Something else — type your answer below, then OK " + "(or Repeat / Cancel below)" + ) + self._set_status("heard", "Type something else below") + elif heard_s: + self.recover_label = ( + f"Heard: “{heard_s}” — didn’t match an option — " + "Repeat, or OK / Cancel below" + ) + self._set_status("error", "Didn’t match — Repeat or pick an option") + elif err in {"no speech", "empty transcript"}: + self.recover_label = ( + "Didn’t catch any speech — Repeat, or OK / Cancel below" + ) + self._set_status("error", "No speech — Repeat or pick an option") + else: + self.recover_label = ( + f"Voice problem ({err}) — Repeat, or OK / Cancel below" + ) + self._set_status("error", f"Voice problem ({err})") + + extra: dict[str, Any] = {} + if select_id: + extra["select_id"] = select_id + if freeform_seed is not None: + extra["freeform_text"] = freeform_seed + if extra: + self._push(**extra) + self.start_voice_recovery_listen() + + def start_voice_listen_thread(self) -> None: + if not self.voice_answer_on or _voice_answer is None or self.allow_multiple: + return + if not self._audio_on(): + return + self.recover_visible = False + self.listen_gen += 1 + my_gen = self.listen_gen + + def abort() -> bool: + return self.closed or self.listen_gen != my_gen + + def worker() -> None: + def _speak_phase(phase: str) -> None: + if abort(): + return + + def _ui() -> None: + if abort(): + return + cur = self.status_text or "" + if cur.startswith("● Listening") or cur.startswith("Analysing"): + return + if phase == "playing": + self._set_status("speaking", "● Speaking…") + else: + self._set_status("speaking", "● Waiting for question audio…") + + self._schedule(_ui) + + if not _voice_answer.wait_for_speak_done( + timeout_sec=120.0, + should_abort=abort, + on_phase=_speak_phase, + ): + if not abort(): + + def _stale() -> None: + if not abort(): + self._enter_voice_recover("", "question audio not finished") + + self._schedule(_stale) + return + if abort(): + return + + src_hint = "default mic" + try: + tgt = _voice_answer.resolve_record_target() + src_hint = _voice_answer.record_source_label(tgt) + if ( + tgt is None + and _voice_answer.prefer_bluetooth_mic() + and _voice_answer.bluetooth_audio_connected() + ): + src_hint = "Bluetooth mic" + except Exception: # noqa: BLE001 + pass + + def _listening() -> None: + if not abort(): + self._set_status( + "listening", + f"● Listening — say an option ({src_hint})", + ) + + self._schedule(_listening) + time.sleep(0.35) + if abort(): + return + + def _phase(name: str) -> None: + if abort(): + return + if name == "analysing": + self._schedule( + lambda: self._set_status("analysing", "Analysing…") + ) + + out = _voice_answer.listen_transcribe_match( + ids=self.ids, + labels=self.labels, + recommended_ids=self.recommended_ids, + should_abort=abort, + on_phase=_phase, + ) + if abort(): + return + self._note_voice_attempt(out) + + def _apply() -> None: + if abort(): + return + if out.get("ok") and out.get("option_id"): + self._apply_voice_match( + str(out["option_id"]), + str(out.get("transcript") or ""), + ) + return + self._enter_voice_recover( + str(out.get("transcript") or "").strip(), + str(out.get("error") or "unclear"), + ) + + self._schedule(_apply) + + threading.Thread(target=worker, name="askq-nebula-voice", daemon=True).start() + + def start_voice_recovery_listen(self) -> None: + if not self.voice_answer_on or _voice_answer is None or self.allow_multiple: + return + self.listen_gen += 1 + my_gen = self.listen_gen + + def abort() -> bool: + return self.closed or self.listen_gen != my_gen + + def worker() -> None: + def _listening() -> None: + if not abort(): + hint = "Repeat, or OK / Cancel below" + if self.freeform_pending: + hint = "Use this, Repeat, OK, or Cancel" + self._set_status("listening", f"● Listening — say {hint}") + + self._schedule(_listening) + time.sleep(0.25) + if abort(): + return + + def _phase(name: str) -> None: + if abort(): + return + if name == "analysing": + self._schedule( + lambda: self._set_status("analysing", "Analysing…") + ) + + out = _voice_answer.listen_transcribe_match( + ids=self.ids, + labels=self.labels, + recommended_ids=self.recommended_ids, + should_abort=abort, + on_phase=_phase, + ) + if abort(): + return + self._note_voice_attempt(out) + + def _apply() -> None: + if abort(): + return + heard = str(out.get("transcript") or "").strip() + if self.freeform_pending and ( + _voice_answer.match_voice_freeform_confirm(heard) + ): + self._finish_voice_freeform(self.freeform_pending) + return + recovery = _voice_answer.match_voice_recovery(heard) + if recovery == "repeat": + self.voice_retries = 0 + self.recover_visible = False + self.freeform_pending = "" + self.use_this_visible = False + self._push() + self.start_voice_listen_thread_skip_speak() + return + if recovery == "ok": + # Let the page submit its current selection. + self.stop_on_answer() + self._push(request_submit=True) + return + if recovery == "cancel": + self.stop_on_answer() + self._on_auto_cancel("user cancelled") + return + if out.get("ok") and out.get("option_id"): + self._apply_voice_match(str(out["option_id"]), heard) + return + self._enter_voice_recover( + heard, str(out.get("error") or "unclear") + ) + + self._schedule(_apply) + + threading.Thread( + target=worker, name="askq-nebula-voice-recover", daemon=True + ).start() + + def start_voice_listen_thread_skip_speak(self) -> None: + if not self.voice_answer_on or _voice_answer is None or self.allow_multiple: + return + if not self._audio_on(): + return + self.recover_visible = False + self.listen_gen += 1 + my_gen = self.listen_gen + + def abort() -> bool: + return self.closed or self.listen_gen != my_gen + + def worker() -> None: + src_hint = "default mic" + try: + tgt = _voice_answer.resolve_record_target() + src_hint = _voice_answer.record_source_label(tgt) + if ( + tgt is None + and _voice_answer.prefer_bluetooth_mic() + and _voice_answer.bluetooth_audio_connected() + ): + src_hint = "Bluetooth mic" + except Exception: # noqa: BLE001 + pass + + def _listening() -> None: + if not abort(): + self._set_status( + "listening", + f"● Listening — say an option ({src_hint})", + ) + + self._schedule(_listening) + time.sleep(0.2) + if abort(): + return + + def _phase(name: str) -> None: + if abort(): + return + if name == "analysing": + self._schedule( + lambda: self._set_status("analysing", "Analysing…") + ) + + out = _voice_answer.listen_transcribe_match( + ids=self.ids, + labels=self.labels, + recommended_ids=self.recommended_ids, + should_abort=abort, + on_phase=_phase, + ) + if abort(): + return + self._note_voice_attempt(out) + + def _apply() -> None: + if abort(): + return + if out.get("ok") and out.get("option_id"): + self._apply_voice_match( + str(out["option_id"]), + str(out.get("transcript") or ""), + ) + return + self._enter_voice_recover( + str(out.get("transcript") or "").strip(), + str(out.get("error") or "unclear"), + ) + + self._schedule(_apply) + + threading.Thread( + target=worker, name="askq-nebula-voice-retry", daemon=True + ).start() + + def start_phase_poll(self) -> None: + if not self.voice_answer_on or self.allow_multiple or _voice_answer is None: + return + + def poll() -> None: + while not self._phase_poll_stop.wait(0.15): + if self.closed: + return + cur = self.status_text or "" + if not ( + cur.startswith("● Waiting") + or cur.startswith("Waiting") + or cur.startswith("● Speaking") + or cur.startswith("● Replaying") + or cur.startswith("Replaying") + ): + continue + try: + if _voice_answer.question_speak_completed(): + if ( + cur.startswith("● Waiting") + or cur.startswith("Waiting") + or cur.startswith("● Speaking") + ): + + def _ready() -> None: + if not self.closed: + self._set_status( + "idle", + "Ready — Listen or pick an option", + ) + + self._schedule(_ready) + return + phase = _voice_answer.read_speak_phase() + except Exception: # noqa: BLE001 + continue + if phase == "playing": + + def _playing() -> None: + if not self.closed: + self._set_status("speaking", "● Speaking…") + + self._schedule(_playing) + elif phase == "generating": + + def _gen() -> None: + if not self.closed: + self._set_status( + "speaking", "● Waiting for question audio…" + ) + + self._schedule(_gen) + + threading.Thread(target=poll, name="askq-nebula-phase", daemon=True).start() + + def start_if_always_listen(self) -> None: + self.start_phase_poll() + if ( + self.voice_answer_on + and not self.allow_multiple + and self._always_listen() + and self._audio_on() + ): + self.start_voice_listen_thread() diff --git a/src/ask_question_mcp/mcq_pasted.py b/src/ask_question_mcp/mcq_pasted.py new file mode 100644 index 0000000..7c7caa3 --- /dev/null +++ b/src/ask_question_mcp/mcq_pasted.py @@ -0,0 +1,96 @@ +"""Normalize human-pasted MCQ images (clipboard → dialog → MCP Image). + +Images stay in-memory (base64 across the dialog bridge). No lasting files. +""" + +from __future__ import annotations + +import base64 +import binascii +from typing import Any + +_MAX_IMAGES = 4 +# Soft cap per still so a huge screenshot cannot blow MCP context. +_MAX_BYTES = 8 * 1024 * 1024 +_MIME_TO_FORMAT = { + "image/png": "png", + "image/jpeg": "jpeg", + "image/jpg": "jpeg", + "image/webp": "webp", + "image/gif": "gif", +} +_ALLOWED_MIMES = frozenset(_MIME_TO_FORMAT) + + +def normalize_pasted_images( + raw: Any, + *, + max_images: int = _MAX_IMAGES, + max_bytes: int = _MAX_BYTES, +) -> tuple[list[dict[str, Any]], list[str]]: + """Decode dialog ``pasted_images`` into ``[{mime, format, data: bytes}, …]``. + + Returns ``(accepted, notes)``. Invalid / oversize / excess items are skipped + with a short note — the MCQ answer still stands. + """ + notes: list[str] = [] + if raw is None: + return [], notes + if not isinstance(raw, list): + notes.append("pasted_images ignored (not a list)") + return [], notes + + out: list[dict[str, Any]] = [] + for i, item in enumerate(raw): + if len(out) >= max_images: + notes.append(f"pasted_images capped at {max_images}") + break + if not isinstance(item, dict): + notes.append(f"paste[{i}] skipped (not an object)") + continue + mime = str(item.get("mime") or item.get("mimeType") or "").strip().lower() + if mime == "image/jpg": + mime = "image/jpeg" + if mime not in _ALLOWED_MIMES: + notes.append(f"paste[{i}] skipped (unsupported mime)") + continue + b64 = item.get("data") or item.get("base64") or "" + if not isinstance(b64, str) or not b64.strip(): + notes.append(f"paste[{i}] skipped (empty data)") + continue + # Allow data-URL prefix if the bridge passed one through. + payload = b64.strip() + if payload.startswith("data:") and "," in payload: + payload = payload.split(",", 1)[1] + try: + data = base64.b64decode(payload, validate=False) + except (binascii.Error, ValueError): + notes.append(f"paste[{i}] skipped (bad base64)") + continue + if not data: + notes.append(f"paste[{i}] skipped (empty decode)") + continue + if len(data) > max_bytes: + notes.append(f"paste[{i}] skipped (>{max_bytes} bytes)") + continue + out.append( + { + "mime": mime, + "format": _MIME_TO_FORMAT[mime], + "data": data, + } + ) + return out, notes + + +def lean_pasted_fields( + accepted: list[dict[str, Any]], + notes: list[str] | None = None, +) -> dict[str, Any]: + """Fields safe to put in the lean JSON result (no pixel payloads).""" + fields: dict[str, Any] = {} + if accepted: + fields["pasted_image_count"] = len(accepted) + if notes: + fields["pasted_image_notes"] = list(notes) + return fields diff --git a/src/ask_question_mcp/prefs.py b/src/ask_question_mcp/prefs.py index 057a2e3..0cc89b9 100644 --- a/src/ask_question_mcp/prefs.py +++ b/src/ask_question_mcp/prefs.py @@ -12,7 +12,8 @@ Env overrides: -- ``ASK_QUESTION_AUDIO=0|1`` — master TTS+STT kill switch (``audio_enabled``) +- ``ASK_QUESTION_AUDIO=0`` — hard mute (ops / text-only). ``=1`` does **not** + override the dialog Audio checkbox / ``prefs.audio_enabled`` (checkbox wins). - ``ASK_QUESTION_DUCK=0|1`` — lower other apps while speaking/listening (``duck_enabled``) - ``ASK_QUESTION_ACK=0|1`` — spoken ack after OK (``ack_enabled``; default off) - ``ASK_QUESTION_ALWAYS_LISTEN=0|1`` — auto mic after speak (default off) @@ -93,10 +94,14 @@ def _env_bool(name: str) -> bool | None: def get_audio_enabled() -> bool: - """Master switch for TTS speak + STT listen (both off when False).""" + """Master switch for TTS speak + STT listen (both off when False). + + ``ASK_QUESTION_AUDIO=0`` hard-mutes. ``ASK_QUESTION_AUDIO=1`` is ignored so + the footer checkbox / prefs can mute without fighting mcp.json env. + """ env = _env_bool("ASK_QUESTION_AUDIO") - if env is not None: - return env + if env is False: + return False return bool(load_prefs().get("audio_enabled", True)) diff --git a/src/ask_question_mcp/server.py b/src/ask_question_mcp/server.py index 6820820..9d14873 100644 --- a/src/ask_question_mcp/server.py +++ b/src/ask_question_mcp/server.py @@ -2,7 +2,9 @@ from __future__ import annotations +import base64 import json +from typing import Any from mcp.server.fastmcp import FastMCP @@ -24,6 +26,40 @@ ) +def _mcq_tool_result(result: dict[str, Any]) -> str: + """Lean JSON string. Pasted stills are embedded as base64 in JSON. + + Cursor's MCP host raises on FastMCP ``Image`` / mixed content-block lists + (``Unable to serialize unknown type: Image``), so we keep a single string + return. Agents can still read ``pasted_images`` from the JSON. + """ + blobs = result.pop("_pasted_image_blobs", None) or [] + pasted: list[dict[str, str]] = [] + for blob in blobs: + if not isinstance(blob, dict): + continue + data = blob.get("data") + if not isinstance(data, (bytes, bytearray)) or not data: + continue + mime = str(blob.get("mime") or "image/png").strip().lower() or "image/png" + fmt = str(blob.get("format") or "png").strip().lower() or "png" + pasted.append( + { + "mime": mime, + "format": fmt, + "data": base64.b64encode(bytes(data)).decode("ascii"), + } + ) + if pasted: + result["pasted_images"] = pasted + notes = result.get("capabilities") + if isinstance(notes, dict): + nlist = list(notes.get("notes") or []) + nlist.append(f"Human pasted {len(pasted)} reference image(s) (base64 in pasted_images).") + notes["notes"] = nlist + return json.dumps(result, ensure_ascii=False) + + @mcp.tool() def check_setup(want_voice: bool | None = None) -> str: """Diagnose DISPLAY/Gtk/TTS/STT. Use only on first enable, errors, or before voice — not before every MCQ.""" @@ -61,11 +97,11 @@ def ask_multiple_choice( title: str = "Decide", agent: str | None = None, entry_seed: str | None = None, - timeout_sec: int = 300, + timeout_sec: int = 0, image: str | None = None, images: list[str] | None = None, ) -> str: - """Desktop MCQ for every decision fork — never markdown A/B/C when available. agent=LANE.id; recommended in label + recommended_id; Something else always; optional image/images (local path or file://) for Gtk preview; dangerous arms OK ~1s. On cancel/errors → check_setup once.""" + """Desktop MCQ for every decision fork — never markdown A/B/C when available. agent=LANE.id; recommended in label + recommended_id; Something else always; optional image/images (local path or file://) for Gtk+Nebula preview; human Ctrl+V paste returns pasted_images base64 in JSON; default timeout_sec=0 (waits); dangerous arms OK ~1s. On cancel/errors → check_setup once.""" try: result = ask_zenity( question, @@ -83,7 +119,7 @@ def ask_multiple_choice( image=image, images=images, ) - return json.dumps(result, ensure_ascii=False) + return _mcq_tool_result(result) except AskCancelled as exc: payload: dict = { "cancelled": True, diff --git a/src/ask_question_mcp/win_list_ask.py b/src/ask_question_mcp/win_list_ask.py index f76e10e..c1268ec 100644 --- a/src/ask_question_mcp/win_list_ask.py +++ b/src/ask_question_mcp/win_list_ask.py @@ -80,6 +80,7 @@ def main() -> int: result: dict[str, Any] = {"cancelled": True, "reason": "no selection"} closed = {"v": False} + timeout_after: dict[str, Any] = {"id": None} root = tk.Tk() root.title(title) @@ -267,6 +268,14 @@ def _on_mousewheel(event: tk.Event) -> None: # type: ignore[name-defined] freeform_entry.grid(row=1, column=0, sticky="ew", pady=(4, 0)) def on_ff_key(_event: object = None) -> None: + # First freeform keystroke cancels idle auto-close. + tid = timeout_after.get("id") + if tid is not None: + try: + root.after_cancel(tid) + except Exception: # noqa: BLE001 + pass + timeout_after["id"] = None text = freeform_var.get().strip() if not text: return @@ -461,9 +470,10 @@ def on_digit(event: tk.Event) -> str | None: # type: ignore[name-defined] if timeout_sec > 0: def on_timeout() -> None: + timeout_after["id"] = None finish({"cancelled": True, "reason": "timeout"}) - root.after(timeout_sec * 1000, on_timeout) + timeout_after["id"] = root.after(timeout_sec * 1000, on_timeout) # Restore size/position when known; else centre roughly. root.update_idletasks() diff --git a/src/ask_question_mcp/zenity_ask.py b/src/ask_question_mcp/zenity_ask.py index 82095e3..fec59f6 100644 --- a/src/ask_question_mcp/zenity_ask.py +++ b/src/ask_question_mcp/zenity_ask.py @@ -44,6 +44,8 @@ # 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") +# Linux Nebula WebKit dialog (same HTML/CSS/JS as Windows WebView). +_LINUX_WEBVIEW_ASK = Path(__file__).resolve().with_name("linux_webview_ask.py") # 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( @@ -54,6 +56,39 @@ _WIN_ENTRY_ASK = Path(__file__).resolve().with_name("win_entry_ask.py") + +def _linux_ui_backend() -> str: + """``nebula`` (WebKit), ``gtk``, or ``auto`` (prefer Nebula).""" + raw = (os.environ.get("ASK_QUESTION_LINUX_UI") or "auto").strip().lower() + if raw in {"nebula", "webview", "webkit", "glass"}: + return "nebula" + if raw in {"gtk", "gtk4", "adw"}: + return "gtk" + return "auto" + + +def _linux_webview_usable() -> bool: + if not _LINUX_WEBVIEW_ASK.is_file(): + return False + try: + gtk_py = _resolve_gtk_python() + probe = subprocess.run( + [ + gtk_py, + "-c", + "import gi; gi.require_version('WebKit','6.0'); " + "from gi.repository import WebKit; print('ok')", + ], + check=False, + capture_output=True, + text=True, + timeout=8, + ) + return probe.returncode == 0 and "ok" in (probe.stdout or "") + except Exception: + return False + + def _win_webview2_env() -> dict[str, str]: """Env for Windows dialog children — isolated Edge profile, unbuffered IO.""" env = dict(os.environ) @@ -816,12 +851,23 @@ def _ask_list( return chosen[:1], voice_meta, freeform_text return chosen, voice_meta, freeform_text - if not _GTK4_LIST_ASK.is_file(): + if not _GTK4_LIST_ASK.is_file() and not _LINUX_WEBVIEW_ASK.is_file(): raise RuntimeError(f"missing gtk4 list dialog: {_GTK4_LIST_ASK}") gtk_py = _resolve_gtk_python() speak_on = bool(speak_enabled and speak_text.strip()) listen_on = bool(voice_answer and speak_on and not allow_multiple) + backend = _linux_ui_backend() + use_nebula = backend == "nebula" or ( + backend == "auto" and _linux_webview_usable() + ) + list_script = _LINUX_WEBVIEW_ASK if use_nebula else _GTK4_LIST_ASK + if use_nebula and not list_script.is_file(): + list_script = _GTK4_LIST_ASK + use_nebula = False + if not list_script.is_file(): + raise RuntimeError(f"missing list dialog: {list_script}") + payload = { "question": question.strip(), "title": title, @@ -841,17 +887,25 @@ def _ask_list( ), "speak_enabled": speak_on, "speak_text": speak_text.strip(), - # MCP / uv venv python — gtk runs under system python without the package. + # MCP / uv venv python — gtk/nebula run under system python without the package. "speak_python": sys.executable if speak_on else "", "voice_answer": listen_on, "audio_mode": audio_mode, "capability_notes": list(capability_notes or []), "images": image_paths, + "theme": (os.environ.get("ASK_QUESTION_THEME") or "glass").strip().lower() + or "glass", } env = {**os.environ, "DISPLAY": display} + if use_nebula: + env["WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS"] = "1" + env["WEBKIT_DISABLE_DMABUF_RENDERER"] = "1" + env["GDK_BACKEND"] = ( + (os.environ.get("ASK_QUESTION_GDK_BACKEND") or "x11").strip() or "x11" + ) try: proc = subprocess.run( - [gtk_py, str(_GTK4_LIST_ASK)], + [gtk_py, str(list_script)], input=json.dumps(payload), check=False, capture_output=True, @@ -860,7 +914,7 @@ def _ask_list( env=env, ) except subprocess.TimeoutExpired as exc: - raise AskCancelled("gtk4 list timed out") from exc + raise AskCancelled("linux list dialog timed out") from exc raw = (proc.stdout or "").strip() if not raw: