Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Full env / prefs: [SETUP.md](SETUP.md). Never commit tokens.
- Remembers last dialog size (`prefs.window`; position on Windows; size-only
on typical Wayland)
- Windows: scrollable option list
- Danger chrome; OK/Enter briefly armed (~1s / ~4s)
- Danger chrome; OK/Enter briefly armed (~1s)
- Something else is always available (type, or Speak→STT when configured)
- Works text-only without TTS/STT; lean JSON results by default
- Optional TTS / mic answers / acks (auto-listen and acks **off** until opted in)
Expand Down
23 changes: 14 additions & 9 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ skill via `ask-question-install --skill` (`~/.cursor/skills/ask-multiple-choice`
| `recommended_ids` | string[] \| null | no | Multi-select preferred ids |
| `allow_multiple` | bool | no | default `false` (radio); `true` = checklist |
| `allow_other` | bool | no | **Ignored** — Something else is always appended when missing |
| `dangerous` | bool | no | Danger chrome; OK/Enter armed ~4s (`ASK_QUESTION_DANGER_ARM_MS`). Normal MCQs arm ~1s (`ASK_QUESTION_ARM_MS`). |
| `dangerous` | bool | no | Danger chrome; OK/Enter armed ~1s (`ASK_QUESTION_DANGER_ARM_MS`, same default as normal). Normal MCQs arm ~1s (`ASK_QUESTION_ARM_MS`). |
| `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]` |
Expand All @@ -82,11 +82,15 @@ skill via `ask-question-install --skill` (`~/.cursor/skills/ask-multiple-choice`
**Images in the dialog (Linux Gtk):** 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 (~70%+ of the monitor); click the preview to
toggle compact (~320px) vs large, and use the header maximize button or **F**
for a near-fullscreen window. 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).
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).

### Example (single choice)

Expand Down Expand Up @@ -151,9 +155,10 @@ Routine forks stay short (no referent dump):
}
```

OK and Enter stay locked briefly after open (countdown on OK): **~1s** normal
(`ASK_QUESTION_ARM_MS`), **~4s** when `dangerous` (`ASK_QUESTION_DANGER_ARM_MS`).
Set either env to `0` to disable. Cancel / Escape always work immediately.
OK and Enter stay locked briefly after open (countdown on OK): **~1s** for both
normal (`ASK_QUESTION_ARM_MS`) and `dangerous` (`ASK_QUESTION_DANGER_ARM_MS`).
(Dangerous used to be ~4s; shortened 2026-08-01.) Set either env to `0` to
disable. Cancel / Escape always work immediately.

## Dialog UX (humans)

Expand Down
2 changes: 1 addition & 1 deletion docs/WINDOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Canonical install steps: this file (and a one-line pointer from the
9. Smoke **dangerous** — ask for an irreversible choice (`dangerous=true`). Expect:
- Window title / options prefixed with **⛔** (no-entry)
- Pink **Confirm** banner with the question
- Red **OK** that stays disabled ~4s (`OK (Ns)`) before confirm
- Red **OK** that stays disabled ~1s (`OK (Ns)`) before confirm
10. Resize the dialog, OK, reopen — size (and position) should roughly match.
11. When nudged for platform feedback: choose **works** (or open a GitHub issue) so
maintainers can flip the README matrix row to **Verified**.
Expand Down
163 changes: 162 additions & 1 deletion scripts/test_dialog_ergonomics.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from __future__ import annotations

import importlib.util
import sys
from pathlib import Path

Expand All @@ -17,6 +18,15 @@
)


def _load_gtk4_list_ask():
path = ROOT / "src" / "ask_question_mcp" / "gtk4_list_ask.py"
spec = importlib.util.spec_from_file_location("gtk4_list_ask_under_test", path)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod


def test_hotkeys() -> None:
assert option_hotkey_index(0x031) == 0
assert option_hotkey_index(0x038) == 7
Expand Down Expand Up @@ -59,10 +69,161 @@ def test_window_geometry(tmp_path: Path | None = None) -> None:
prefs_mod._PREFS_PATH = old


def test_pick_sizing_monitor_wh() -> None:
"""Dual-monitor: defaults follow primary or smallest panel, not 4K."""
mod = _load_gtk4_list_ask()

laptop = (1803, 1202)
external = (3840, 2160)
assert mod._pick_sizing_monitor_wh([laptop, external]) == laptop
assert mod._pick_sizing_monitor_wh([external, laptop]) == laptop
# Explicit preferred = primary (even if not the smallest).
assert mod._pick_sizing_monitor_wh(
[laptop, external], preferred=laptop
) == laptop
assert mod._pick_sizing_monitor_wh(
[laptop, external], preferred=external
) == external
assert mod._pick_sizing_monitor_wh([], preferred=None) == (1280, 800)

# Pointer helper still works for diagnostics; sizing no longer uses it.
rects = [(0, 1386, 1803, 1202), (1803, 0, 3840, 2160)]
import subprocess as sp

real_check = sp.check_output

def fake_check(cmd, **kwargs): # type: ignore[no-untyped-def]
if cmd and cmd[0] == "xdotool":
return "X=200\nY=1500\nSCREEN=0\nWINDOW=0\n"
return real_check(cmd, **kwargs)

sp.check_output = fake_check # type: ignore[assignment]
try:
assert mod._monitor_wh_under_pointer(rects) == laptop
finally:
sp.check_output = real_check # type: ignore[assignment]

def fake_check_big(cmd, **kwargs): # type: ignore[no-untyped-def]
if cmd and cmd[0] == "xdotool":
return "X=2500\nY=500\nSCREEN=0\nWINDOW=0\n"
return real_check(cmd, **kwargs)

sp.check_output = fake_check_big # type: ignore[assignment]
try:
assert mod._monitor_wh_under_pointer(rects) == external
finally:
sp.check_output = real_check # type: ignore[assignment]


def test_image_mcq_sizing_fits_laptop() -> None:
"""Preview + chrome + default window must fit usable eDP, not overflow."""
mod = _load_gtk4_list_ask()
laptop = (1803, 1202)
external = (3840, 2160)

uw, uh = mod._usable_monitor_wh(laptop)
assert uw == 1803 - mod._EDGE_MARGIN_W
assert uh == 1202 - mod._PANEL_RESERVE_H

stack_w, stack_h = mod._preview_stack_max_wh(
laptop, maximized=False, expanded=True
)
assert stack_h + mod._IMAGE_MCQ_CHROME_H <= uh
assert stack_w <= uw
assert stack_h < int(uh * 0.55) # ~50% usable, not old 62% raw

exp_w, exp_h = mod._preview_max_wh(
laptop, maximized=False, expanded=True, n_images=1
)
assert exp_w == stack_w and exp_h == stack_h

compact = mod._preview_max_wh(
laptop, maximized=False, expanded=False, n_images=1
)
assert compact[1] <= 280
assert compact[0] <= 640

geom_w, geom_h = mod._image_mcq_default_size(laptop)
assert geom_w <= uw and geom_h <= uh
assert geom_h >= stack_h + mod._IMAGE_MCQ_CHROME_H - 1
# Must not use absolute floors that exceed a small panel.
tiny = (800, 600)
tw, th = mod._image_mcq_default_size(tiny)
tuw, tuh = mod._usable_monitor_wh(tiny)
assert tw <= tuw and th <= tuh

# 4K must not drive default when sizing monitor is laptop.
g4k = mod._image_mcq_default_size(external)
assert g4k[0] > geom_w # larger host → larger default
# But pick_sizing prefers laptop when both present (covered elsewhere).

max_w, max_h = mod._preview_max_wh(
laptop, maximized=True, expanded=True, n_images=1
)
assert max_w > exp_w and max_h > exp_h
assert max_h == 1202 - mod._IMAGE_MCQ_CHROME_H

# Maximize on host 4K while defaults were laptop-sized.
big_w, big_h = mod._preview_max_wh(
external, maximized=True, expanded=True, n_images=1
)
assert big_w == 3840 - mod._EDGE_MARGIN_W
assert big_h == 2160 - mod._IMAGE_MCQ_CHROME_H
assert big_w > max_w

text_w, text_h = mod._text_mcq_default_size(
laptop, prefs_w=520, prefs_h=480, question_len=40, n_options=3
)
assert text_w <= 900 and text_h <= 480


def test_multi_image_stack_fits_primary() -> None:
"""Fake primary 1803×1202 + 2–3 large stills → window/stack ≤ usable.

Regression: each preview used the full single-image height size_request,
so N stacked images summed past the Framework eDP primary.
"""
mod = _load_gtk4_list_ask()
primary = (1803, 1202)
uw, uh = mod._usable_monitor_wh(primary)
stack_w, stack_h = mod._preview_stack_max_wh(
primary, maximized=False, expanded=True
)

for n in (2, 3, 4):
per_w, per_h = mod._preview_max_wh(
primary, maximized=False, expanded=True, n_images=n
)
assert per_w <= stack_w <= uw
req_h = mod._multi_image_stack_request_h(
primary, maximized=False, expanded=True, n_images=n
)
assert req_h <= stack_h
assert per_h * n + mod._IMAGE_STACK_GAP * (n - 1) == req_h
# Old bug: N × ~50% usable ≫ primary height.
assert n * per_h < uh
geom_w, geom_h = mod._image_mcq_default_size(primary, n_images=n)
assert geom_w <= uw and geom_h <= uh
assert geom_h <= uh

# Compact multi-image also divides — 3×280 must not win.
c_req = mod._multi_image_stack_request_h(
primary, maximized=False, expanded=False, n_images=3
)
_cw, c_stack = mod._preview_stack_max_wh(
primary, maximized=False, expanded=False
)
assert c_req <= c_stack <= 280
assert c_req + mod._IMAGE_MCQ_CHROME_H <= uh


def main() -> None:
test_hotkeys()
test_window_geometry()
print("OK dialog ergonomics (hotkeys + window geometry)")
test_pick_sizing_monitor_wh()
test_image_mcq_sizing_fits_laptop()
test_multi_image_stack_fits_primary()
print("OK dialog ergonomics (hotkeys + window geometry + sizing)")


if __name__ == "__main__":
Expand Down
10 changes: 6 additions & 4 deletions skills/ask-multiple-choice/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,12 @@ choose among options the human must decide.
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 (~70%+ monitor);
human can **click the preview** (large ↔ compact ~320px) and **maximize**
(header button or **F**). Text-only MCQs stay compact. Pattern:
`mcq-with-image`.
pass the path into the dialog. Linux Gtk: opens large on the **primary**
usable workarea (not the largest secondary); human can **click the preview**
(large ↔ compact ~320px) and **maximize** (header button or **F**).
**P0 — multi-image must never exceed primary usable resolution** (stack
shares one height budget / scrolls inside). Text-only MCQs stay compact.
Pattern: `mcq-with-image`.
8. Wait for the JSON result. On cancel → stop. On freeform → use **`freeform_text`**.

Humans use the dialog keyboard (**1–8**, Enter, Esc; **F** maximize when images);
Expand Down
13 changes: 6 additions & 7 deletions src/ask_question_mcp/danger_arm.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
previous keystroke (or mid-typing) cannot dismiss the dialog.

- Normal MCQs: ``ASK_QUESTION_ARM_MS`` (default **1000**). Set ``0`` to disable.
- Dangerous (no-entry mark): ``ASK_QUESTION_DANGER_ARM_MS`` (default **4000**).
Set ``0`` to disable the danger-only longer arm (safe arm still applies
unless also 0).
- Dangerous (no-entry mark): ``ASK_QUESTION_DANGER_ARM_MS`` (default **1000**,
same as safe — Alex 2026-08-01; was 4000). Set ``0`` to disable.
"""

from __future__ import annotations
Expand All @@ -17,7 +16,7 @@
DANGER_MARK = "⛔"

DEFAULT_SAFE_ARM_MS = 1000
DEFAULT_DANGER_ARM_MS = 4000
DEFAULT_DANGER_ARM_MS = 1000
ENV_SAFE_ARM_MS = "ASK_QUESTION_ARM_MS"
ENV_DANGER_ARM_MS = "ASK_QUESTION_DANGER_ARM_MS"
_MAX_ARM_MS = 60_000
Expand Down Expand Up @@ -56,9 +55,9 @@ def _parse_arm_ms(env_name: str, default: int) -> int:
def danger_arm_ms(*, dangerous: bool = True) -> int:
"""Milliseconds to block OK / Enter after the dialog opens.

Dangerous dialogs use the longer danger arm (default 4s). Normal dialogs
use the safe arm (default 1s) so accidental Return while typing does not
confirm.
Dangerous and normal dialogs both default to 1s so accidental Return
while typing does not confirm. Override with ``ASK_QUESTION_DANGER_ARM_MS``
/ ``ASK_QUESTION_ARM_MS`` if a longer danger arm is wanted.
"""
if dangerous:
return _parse_arm_ms(ENV_DANGER_ARM_MS, DEFAULT_DANGER_ARM_MS)
Expand Down
Loading