ask-human 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ask_human/__init__.py +7 -0
- ask_human/__main__.py +6 -0
- ask_human/assets/agent-asks.icns +0 -0
- ask_human/assets/agent-asks.ico +0 -0
- ask_human/assets/agent-asks.png +0 -0
- ask_human/assets/telegram/icon-color-bg-blue.png +0 -0
- ask_human/assets/telegram/icon-color-codex-bevel-alt2.png +0 -0
- ask_human/assets/telegram/icon-color-codex-bevel-mac.png +0 -0
- ask_human/assets/telegram/icon-color-round-alt.png +0 -0
- ask_human/assets/telegram/icon-color-round.png +0 -0
- ask_human/broker_state.py +215 -0
- ask_human/dialogs.py +311 -0
- ask_human/prompt_formatting.py +168 -0
- ask_human/server.py +540 -0
- ask_human/telegram_broker.py +220 -0
- ask_human/telegram_broker_client.py +251 -0
- ask_human/telegram_client.py +865 -0
- ask_human/telegram_models.py +85 -0
- ask_human-0.3.0.dist-info/METADATA +630 -0
- ask_human-0.3.0.dist-info/RECORD +23 -0
- ask_human-0.3.0.dist-info/WHEEL +4 -0
- ask_human-0.3.0.dist-info/entry_points.txt +2 -0
- ask_human-0.3.0.dist-info/licenses/LICENSE +22 -0
ask_human/__init__.py
ADDED
ask_human/__main__.py
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Persistent state helpers for the local Telegram broker."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import socket
|
|
7
|
+
import sqlite3
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterator, Optional
|
|
13
|
+
|
|
14
|
+
from .telegram_models import TelegramConfig, resolve_telegram_target_key
|
|
15
|
+
|
|
16
|
+
DEFAULT_BROKER_STATE_DIR_NAME = "ask-human"
|
|
17
|
+
BROKER_STATE_DB_FILENAME = "telegram-broker.sqlite3"
|
|
18
|
+
BROKER_STARTUP_LOCK_FILENAME = "broker-startup.lock"
|
|
19
|
+
BROKER_TARGETS_DIRNAME = "targets"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class TelegramBrokerIdentity:
|
|
24
|
+
"""Stable identity and human-facing label for one broker installation."""
|
|
25
|
+
|
|
26
|
+
broker_id: str
|
|
27
|
+
broker_label: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class TelegramBrokerState:
|
|
32
|
+
"""Persisted state that local clients can use for broker discovery."""
|
|
33
|
+
|
|
34
|
+
identity: TelegramBrokerIdentity
|
|
35
|
+
listen_url: Optional[str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class TelegramBrokerHealth:
|
|
40
|
+
"""Health data returned by a running broker."""
|
|
41
|
+
|
|
42
|
+
broker_id: str
|
|
43
|
+
broker_label: str
|
|
44
|
+
listen_url: str
|
|
45
|
+
target_key: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_broker_state_dir(state_dir: Optional[str] = None) -> Path:
|
|
49
|
+
"""Resolve the broker state directory from CLI input or platform defaults."""
|
|
50
|
+
if state_dir is not None and state_dir.strip():
|
|
51
|
+
expanded = state_dir.replace("{cwd}", os.getcwd())
|
|
52
|
+
expanded = os.path.expandvars(expanded)
|
|
53
|
+
expanded = os.path.expanduser(expanded)
|
|
54
|
+
return Path(expanded).resolve()
|
|
55
|
+
|
|
56
|
+
system_name = platform.system()
|
|
57
|
+
if system_name == "Windows":
|
|
58
|
+
root = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
|
|
59
|
+
if root:
|
|
60
|
+
return (Path(root) / DEFAULT_BROKER_STATE_DIR_NAME).resolve()
|
|
61
|
+
return (Path.home() / "AppData" / "Local" / DEFAULT_BROKER_STATE_DIR_NAME).resolve()
|
|
62
|
+
|
|
63
|
+
if system_name == "Darwin":
|
|
64
|
+
return (
|
|
65
|
+
Path.home() / "Library" / "Application Support" / DEFAULT_BROKER_STATE_DIR_NAME
|
|
66
|
+
).resolve()
|
|
67
|
+
|
|
68
|
+
root = os.environ.get("XDG_STATE_HOME")
|
|
69
|
+
if root:
|
|
70
|
+
return (Path(root) / DEFAULT_BROKER_STATE_DIR_NAME).resolve()
|
|
71
|
+
return (Path.home() / ".local" / "state" / DEFAULT_BROKER_STATE_DIR_NAME).resolve()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_default_broker_label() -> str:
|
|
75
|
+
"""Choose a human-friendly default label for the local broker."""
|
|
76
|
+
hostname = socket.gethostname().strip()
|
|
77
|
+
return hostname or "local-broker"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_target_broker_state_dir(base_state_dir: Path, target: TelegramConfig) -> Path:
|
|
81
|
+
"""Map one Telegram target to its dedicated local broker state directory."""
|
|
82
|
+
target_key = resolve_telegram_target_key(target)
|
|
83
|
+
return (base_state_dir / BROKER_TARGETS_DIRNAME / target_key).resolve()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _ensure_state_db(state_dir: Path) -> sqlite3.Connection:
|
|
87
|
+
"""Open the broker state database and initialize its schema if needed."""
|
|
88
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
db_path = state_dir / BROKER_STATE_DB_FILENAME
|
|
90
|
+
connection = sqlite3.connect(db_path)
|
|
91
|
+
connection.execute("""
|
|
92
|
+
CREATE TABLE IF NOT EXISTS broker_state (
|
|
93
|
+
key TEXT PRIMARY KEY,
|
|
94
|
+
value TEXT NOT NULL
|
|
95
|
+
)
|
|
96
|
+
""")
|
|
97
|
+
connection.commit()
|
|
98
|
+
return connection
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _get_state_value(connection: sqlite3.Connection, key: str) -> Optional[str]:
|
|
102
|
+
"""Read a single broker state value by key."""
|
|
103
|
+
row = connection.execute(
|
|
104
|
+
"SELECT value FROM broker_state WHERE key = ?",
|
|
105
|
+
(key,),
|
|
106
|
+
).fetchone()
|
|
107
|
+
if row is None:
|
|
108
|
+
return None
|
|
109
|
+
return str(row[0])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _set_state_value(connection: sqlite3.Connection, key: str, value: str) -> None:
|
|
113
|
+
"""Persist a single broker state value."""
|
|
114
|
+
connection.execute(
|
|
115
|
+
"""
|
|
116
|
+
INSERT INTO broker_state (key, value)
|
|
117
|
+
VALUES (?, ?)
|
|
118
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
119
|
+
""",
|
|
120
|
+
(key, value),
|
|
121
|
+
)
|
|
122
|
+
connection.commit()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def load_or_create_broker_identity(
|
|
126
|
+
state_dir: Path,
|
|
127
|
+
broker_label: Optional[str] = None,
|
|
128
|
+
) -> TelegramBrokerIdentity:
|
|
129
|
+
"""Load a stable broker identity, creating and persisting one if absent."""
|
|
130
|
+
connection = _ensure_state_db(state_dir)
|
|
131
|
+
try:
|
|
132
|
+
broker_id = _get_state_value(connection, "broker_id")
|
|
133
|
+
stored_label = _get_state_value(connection, "broker_label")
|
|
134
|
+
|
|
135
|
+
if broker_id is None:
|
|
136
|
+
broker_id = uuid.uuid4().hex
|
|
137
|
+
_set_state_value(connection, "broker_id", broker_id)
|
|
138
|
+
|
|
139
|
+
resolved_label = broker_label.strip() if broker_label is not None else None
|
|
140
|
+
if not resolved_label:
|
|
141
|
+
resolved_label = stored_label or resolve_default_broker_label()
|
|
142
|
+
|
|
143
|
+
if stored_label != resolved_label:
|
|
144
|
+
_set_state_value(connection, "broker_label", resolved_label)
|
|
145
|
+
|
|
146
|
+
return TelegramBrokerIdentity(broker_id=broker_id, broker_label=resolved_label)
|
|
147
|
+
finally:
|
|
148
|
+
connection.close()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def persist_broker_listen_url(state_dir: Path, listen_url: str) -> None:
|
|
152
|
+
"""Store the broker's current listening URL for local discovery."""
|
|
153
|
+
connection = _ensure_state_db(state_dir)
|
|
154
|
+
try:
|
|
155
|
+
_set_state_value(connection, "listen_url", listen_url)
|
|
156
|
+
finally:
|
|
157
|
+
connection.close()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def load_broker_state(state_dir: Path) -> Optional[TelegramBrokerState]:
|
|
161
|
+
"""Read the persisted broker identity and listening URL, if available."""
|
|
162
|
+
connection = _ensure_state_db(state_dir)
|
|
163
|
+
try:
|
|
164
|
+
broker_id = _get_state_value(connection, "broker_id")
|
|
165
|
+
broker_label = _get_state_value(connection, "broker_label")
|
|
166
|
+
listen_url = _get_state_value(connection, "listen_url")
|
|
167
|
+
finally:
|
|
168
|
+
connection.close()
|
|
169
|
+
|
|
170
|
+
if broker_id is None or broker_label is None:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
identity = TelegramBrokerIdentity(broker_id=broker_id, broker_label=broker_label)
|
|
174
|
+
return TelegramBrokerState(identity=identity, listen_url=listen_url)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@contextlib.contextmanager
|
|
178
|
+
def acquire_startup_lock(
|
|
179
|
+
state_dir: Path,
|
|
180
|
+
*,
|
|
181
|
+
timeout_seconds: float = 15.0,
|
|
182
|
+
stale_after_seconds: float = 60.0,
|
|
183
|
+
) -> Iterator[None]:
|
|
184
|
+
"""Serialize local broker startup for one target with a simple lock file."""
|
|
185
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
lock_path = state_dir / BROKER_STARTUP_LOCK_FILENAME
|
|
187
|
+
deadline = time.monotonic() + timeout_seconds
|
|
188
|
+
|
|
189
|
+
while True:
|
|
190
|
+
try:
|
|
191
|
+
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
192
|
+
break
|
|
193
|
+
except FileExistsError:
|
|
194
|
+
try:
|
|
195
|
+
age_seconds = time.time() - lock_path.stat().st_mtime
|
|
196
|
+
except FileNotFoundError:
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
if age_seconds >= stale_after_seconds:
|
|
200
|
+
with contextlib.suppress(FileNotFoundError):
|
|
201
|
+
lock_path.unlink()
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
if time.monotonic() >= deadline:
|
|
205
|
+
raise TimeoutError("Timed out waiting for the broker startup lock.")
|
|
206
|
+
|
|
207
|
+
time.sleep(0.2)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
211
|
+
handle.write(str(os.getpid()))
|
|
212
|
+
yield
|
|
213
|
+
finally:
|
|
214
|
+
with contextlib.suppress(FileNotFoundError):
|
|
215
|
+
lock_path.unlink()
|
ask_human/dialogs.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Cross-platform GUI dialog handling for Ask Human prompts."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import platform
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional, cast
|
|
8
|
+
|
|
9
|
+
from .prompt_formatting import resolve_dialog_title
|
|
10
|
+
|
|
11
|
+
DEFAULT_DIALOG_TIMEOUT_SECONDS = 120
|
|
12
|
+
PACKAGE_ASSETS_DIR = Path(__file__).resolve().parent / "assets"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UserPromptCancelled(Exception):
|
|
16
|
+
"""Raised when user cancels the prompt or interrupts the process."""
|
|
17
|
+
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UserPromptError(Exception):
|
|
22
|
+
"""Generic error for user prompt operations."""
|
|
23
|
+
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GUIDialogHandler:
|
|
28
|
+
"""Cross-platform GUI dialog handler for asking humans for input.
|
|
29
|
+
|
|
30
|
+
Provides native GUI dialogs on macOS (osascript), Linux (zenity), and Windows (tkinter).
|
|
31
|
+
Falls back to terminal input if GUI is unavailable.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, dialog_title: Optional[str] = None) -> None:
|
|
35
|
+
"""Initialize the dialog handler with platform detection."""
|
|
36
|
+
self.platform = platform.system()
|
|
37
|
+
self.dialog_title = resolve_dialog_title(dialog_title)
|
|
38
|
+
|
|
39
|
+
async def get_user_input(
|
|
40
|
+
self,
|
|
41
|
+
question: str,
|
|
42
|
+
timeout: int = DEFAULT_DIALOG_TIMEOUT_SECONDS,
|
|
43
|
+
*,
|
|
44
|
+
cancel_event: Optional[asyncio.Event] = None,
|
|
45
|
+
run_in_thread: bool = False,
|
|
46
|
+
) -> Optional[str]:
|
|
47
|
+
"""Get user input via native GUI dialog with timeout."""
|
|
48
|
+
try:
|
|
49
|
+
if self.platform == "Darwin":
|
|
50
|
+
return await self._macos_dialog(question, timeout, cancel_event=cancel_event)
|
|
51
|
+
if self.platform == "Linux":
|
|
52
|
+
return await self._linux_dialog(question, timeout, cancel_event=cancel_event)
|
|
53
|
+
return await self._windows_dialog(
|
|
54
|
+
question,
|
|
55
|
+
timeout,
|
|
56
|
+
run_in_thread=run_in_thread,
|
|
57
|
+
)
|
|
58
|
+
except KeyboardInterrupt:
|
|
59
|
+
raise UserPromptCancelled("User interrupted the dialog with Ctrl+C")
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
raise UserPromptError(
|
|
62
|
+
f"GUI dialog failed: {exc}. Ensure osascript (macOS), zenity (Linux), or "
|
|
63
|
+
"tkinter (Windows) is available."
|
|
64
|
+
) from exc
|
|
65
|
+
|
|
66
|
+
async def _communicate_or_cancel(
|
|
67
|
+
self,
|
|
68
|
+
process: asyncio.subprocess.Process,
|
|
69
|
+
cancel_event: Optional[asyncio.Event],
|
|
70
|
+
) -> tuple[bytes, bytes, bool]:
|
|
71
|
+
"""Wait for a dialog subprocess or cancel it if another channel wins."""
|
|
72
|
+
communicate_task = asyncio.create_task(process.communicate())
|
|
73
|
+
cancel_task: Optional[asyncio.Task[bool]] = None
|
|
74
|
+
if cancel_event is not None:
|
|
75
|
+
cancel_task = asyncio.create_task(cancel_event.wait())
|
|
76
|
+
|
|
77
|
+
tasks: set[asyncio.Task[Any]] = {communicate_task}
|
|
78
|
+
if cancel_task is not None:
|
|
79
|
+
tasks.add(cancel_task)
|
|
80
|
+
|
|
81
|
+
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
82
|
+
|
|
83
|
+
for pending_task in pending:
|
|
84
|
+
pending_task.cancel()
|
|
85
|
+
with suppress(asyncio.CancelledError):
|
|
86
|
+
await pending_task
|
|
87
|
+
|
|
88
|
+
if cancel_task is not None and cancel_task in done and cancel_event is not None:
|
|
89
|
+
if process.returncode is None:
|
|
90
|
+
process.terminate()
|
|
91
|
+
try:
|
|
92
|
+
await asyncio.wait_for(process.wait(), timeout=2)
|
|
93
|
+
except asyncio.TimeoutError:
|
|
94
|
+
process.kill()
|
|
95
|
+
await process.wait()
|
|
96
|
+
|
|
97
|
+
communicate_task.cancel()
|
|
98
|
+
with suppress(asyncio.CancelledError):
|
|
99
|
+
await communicate_task
|
|
100
|
+
return b"", b"", True
|
|
101
|
+
|
|
102
|
+
stdout, stderr = await communicate_task
|
|
103
|
+
return stdout, stderr, False
|
|
104
|
+
|
|
105
|
+
def _enable_windows_dpi_awareness(self) -> None:
|
|
106
|
+
"""Enable crisp rendering for Windows dialogs on scaled displays."""
|
|
107
|
+
try:
|
|
108
|
+
import ctypes
|
|
109
|
+
|
|
110
|
+
windll = getattr(ctypes, "windll", None)
|
|
111
|
+
if windll is None:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
windll.shcore.SetProcessDpiAwareness(2)
|
|
116
|
+
return
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
windll.user32.SetProcessDPIAware()
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
def _configure_windows_tk_scaling(self, root: Any) -> None:
|
|
128
|
+
"""Match Tk scaling to the current monitor DPI when available."""
|
|
129
|
+
try:
|
|
130
|
+
import ctypes
|
|
131
|
+
|
|
132
|
+
windll = getattr(ctypes, "windll", None)
|
|
133
|
+
dpi = 0
|
|
134
|
+
try:
|
|
135
|
+
if windll is not None:
|
|
136
|
+
dpi = windll.user32.GetDpiForWindow(root.winfo_id())
|
|
137
|
+
except Exception:
|
|
138
|
+
try:
|
|
139
|
+
dpi = root.winfo_fpixels("1i")
|
|
140
|
+
except Exception:
|
|
141
|
+
dpi = 0
|
|
142
|
+
|
|
143
|
+
if dpi:
|
|
144
|
+
root.tk.call("tk", "scaling", float(dpi) / 72.0)
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
async def _macos_dialog(
|
|
149
|
+
self, question: str, timeout: int, *, cancel_event: Optional[asyncio.Event] = None
|
|
150
|
+
) -> Optional[str]:
|
|
151
|
+
"""macOS dialog using osascript."""
|
|
152
|
+
icon_path = PACKAGE_ASSETS_DIR / "agent-asks.icns"
|
|
153
|
+
|
|
154
|
+
if icon_path.exists():
|
|
155
|
+
icon_clause = f'with icon file (POSIX file "{icon_path}")'
|
|
156
|
+
else:
|
|
157
|
+
icon_clause = "with icon caution"
|
|
158
|
+
|
|
159
|
+
script = f"""
|
|
160
|
+
display dialog "{self._escape_for_applescript(question)}" ¬
|
|
161
|
+
default answer "" ¬
|
|
162
|
+
with title "{self._escape_for_applescript(self.dialog_title)}" ¬
|
|
163
|
+
{icon_clause} ¬
|
|
164
|
+
giving up after {timeout}
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
process = await asyncio.create_subprocess_exec(
|
|
169
|
+
"osascript",
|
|
170
|
+
"-e",
|
|
171
|
+
script,
|
|
172
|
+
stdout=asyncio.subprocess.PIPE,
|
|
173
|
+
stderr=asyncio.subprocess.PIPE,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
stdout, _stderr, was_cancelled = await self._communicate_or_cancel(
|
|
177
|
+
process, cancel_event
|
|
178
|
+
)
|
|
179
|
+
if was_cancelled:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
if process.returncode == 0:
|
|
183
|
+
output = stdout.decode().strip()
|
|
184
|
+
if "text returned:" in output:
|
|
185
|
+
text_part = output.split("text returned:")[1]
|
|
186
|
+
if ", " in text_part:
|
|
187
|
+
return text_part.split(", ")[0].strip()
|
|
188
|
+
return text_part.strip()
|
|
189
|
+
if "gave up:true" in output:
|
|
190
|
+
return None
|
|
191
|
+
if "button returned:" in output and "text returned:" not in output:
|
|
192
|
+
return ""
|
|
193
|
+
return None
|
|
194
|
+
except Exception:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
async def _linux_dialog(
|
|
198
|
+
self, question: str, timeout: int, *, cancel_event: Optional[asyncio.Event] = None
|
|
199
|
+
) -> Optional[str]:
|
|
200
|
+
"""Linux dialog using zenity."""
|
|
201
|
+
icon_args = self._get_linux_icon_args()
|
|
202
|
+
|
|
203
|
+
cmd = [
|
|
204
|
+
"zenity",
|
|
205
|
+
"--entry",
|
|
206
|
+
f"--title={self.dialog_title}",
|
|
207
|
+
f"--text={question}",
|
|
208
|
+
f"--timeout={timeout}",
|
|
209
|
+
] + icon_args
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
process = await asyncio.create_subprocess_exec(
|
|
213
|
+
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
stdout, _stderr, was_cancelled = await self._communicate_or_cancel(
|
|
217
|
+
process, cancel_event
|
|
218
|
+
)
|
|
219
|
+
if was_cancelled:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
if process.returncode == 0:
|
|
223
|
+
return stdout.decode().strip()
|
|
224
|
+
return None
|
|
225
|
+
except Exception:
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
async def _windows_dialog(
|
|
229
|
+
self,
|
|
230
|
+
question: str,
|
|
231
|
+
timeout: int,
|
|
232
|
+
*,
|
|
233
|
+
run_in_thread: bool = False,
|
|
234
|
+
) -> Optional[str]:
|
|
235
|
+
"""Windows dialog using tkinter."""
|
|
236
|
+
if run_in_thread:
|
|
237
|
+
return await asyncio.to_thread(self._windows_dialog_sync, question, timeout)
|
|
238
|
+
|
|
239
|
+
return self._windows_dialog_sync(question, timeout)
|
|
240
|
+
|
|
241
|
+
def _windows_dialog_sync(self, question: str, timeout: int) -> Optional[str]:
|
|
242
|
+
"""Blocking Windows dialog implementation for the current Tk/simpledialog UI."""
|
|
243
|
+
root = None
|
|
244
|
+
try:
|
|
245
|
+
import tkinter as tk
|
|
246
|
+
from tkinter import simpledialog
|
|
247
|
+
|
|
248
|
+
self._enable_windows_dpi_awareness()
|
|
249
|
+
root = tk.Tk()
|
|
250
|
+
self._configure_windows_tk_scaling(root)
|
|
251
|
+
root.withdraw()
|
|
252
|
+
self._set_windows_icon(root)
|
|
253
|
+
|
|
254
|
+
return self._ask_windows_string(
|
|
255
|
+
root,
|
|
256
|
+
simpledialog,
|
|
257
|
+
self.dialog_title,
|
|
258
|
+
question,
|
|
259
|
+
timeout,
|
|
260
|
+
)
|
|
261
|
+
except Exception:
|
|
262
|
+
return None
|
|
263
|
+
finally:
|
|
264
|
+
if root is not None:
|
|
265
|
+
try:
|
|
266
|
+
root.destroy()
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
def _ask_windows_string(
|
|
271
|
+
self,
|
|
272
|
+
root: Any,
|
|
273
|
+
simpledialog: Any,
|
|
274
|
+
title: str,
|
|
275
|
+
question: str,
|
|
276
|
+
timeout: int,
|
|
277
|
+
) -> Optional[str]:
|
|
278
|
+
"""Ask for a Windows string response and close it when timeout expires."""
|
|
279
|
+
timeout_id = root.after(timeout * 1000, root.destroy)
|
|
280
|
+
try:
|
|
281
|
+
return cast(Optional[str], simpledialog.askstring(title, question, parent=root))
|
|
282
|
+
finally:
|
|
283
|
+
try:
|
|
284
|
+
root.after_cancel(timeout_id)
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
def _escape_for_applescript(self, text: str) -> str:
|
|
289
|
+
"""Escape text for AppleScript."""
|
|
290
|
+
return text.replace('"', '\\"').replace("\\", "\\\\")
|
|
291
|
+
|
|
292
|
+
def _get_linux_icon_args(self) -> list[str]:
|
|
293
|
+
"""Get icon arguments for Linux zenity dialog."""
|
|
294
|
+
icon_path = PACKAGE_ASSETS_DIR / "agent-asks.png"
|
|
295
|
+
if icon_path.exists():
|
|
296
|
+
print(f"✅ Using Ask Human icon for Linux: {icon_path}")
|
|
297
|
+
return [f"--window-icon={icon_path}"]
|
|
298
|
+
|
|
299
|
+
return ["--question"]
|
|
300
|
+
|
|
301
|
+
def _set_windows_icon(self, root: Any) -> None:
|
|
302
|
+
"""Set icon for Windows tkinter dialog."""
|
|
303
|
+
icon_path = PACKAGE_ASSETS_DIR / "agent-asks.ico"
|
|
304
|
+
if not icon_path.exists():
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
print(f"✅ Using Ask Human icon for Windows: {icon_path}")
|
|
309
|
+
root.iconbitmap(icon_path)
|
|
310
|
+
except Exception:
|
|
311
|
+
pass
|