plexi-sdk 0.4.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.
- plexi_sdk/__init__.py +477 -0
- plexi_sdk/_app.py +1077 -0
- plexi_sdk/_constants.py +52 -0
- plexi_sdk/_emitter.py +1466 -0
- plexi_sdk/_pipe.py +92 -0
- plexi_sdk/_protocol.py +48 -0
- plexi_sdk/_render_context.py +976 -0
- plexi_sdk/_types.py +139 -0
- plexi_sdk/midi.py +222 -0
- plexi_sdk/py.typed +1 -0
- plexi_sdk/templates/__init__.py +0 -0
- plexi_sdk/templates/app_init.py +72 -0
- plexi_sdk/testing.py +451 -0
- plexi_sdk/ui.py +1535 -0
- plexi_sdk/widgets/__init__.py +27 -0
- plexi_sdk/widgets/button.py +60 -0
- plexi_sdk/widgets/keymap.py +51 -0
- plexi_sdk/widgets/list_view.py +159 -0
- plexi_sdk/widgets/scroll.py +100 -0
- plexi_sdk/widgets/text_area.py +218 -0
- plexi_sdk/widgets/text_buffer.py +337 -0
- plexi_sdk/widgets/text_input.py +70 -0
- plexi_sdk-0.4.0.dist-info/METADATA +127 -0
- plexi_sdk-0.4.0.dist-info/RECORD +25 -0
- plexi_sdk-0.4.0.dist-info/WHEEL +4 -0
plexi_sdk/_emitter.py
ADDED
|
@@ -0,0 +1,1466 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import uuid
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Iterator
|
|
11
|
+
|
|
12
|
+
from ._protocol import AiResponse, MidiPortInfo, MidiDeviceList, AudioDeviceInfo, AudioDeviceList
|
|
13
|
+
from ._types import CapabilityDeniedError, VideoHandle
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ._app import App
|
|
17
|
+
from ._pipe import Pipe
|
|
18
|
+
|
|
19
|
+
# ── Reserved notification shortcuts ──────────────────────────────────────────
|
|
20
|
+
# Keys reserved by the notification overlay for navigation. The host strips
|
|
21
|
+
# these if an app sends them, and the SDK warns at call time.
|
|
22
|
+
_RESERVED_SHORTCUTS = frozenset("jkhl") | frozenset("123456789")
|
|
23
|
+
|
|
24
|
+
# ── Capability registry ───────────────────────────────────────────────────────
|
|
25
|
+
# Maps Emitter/RenderContext method names to the capability string they require.
|
|
26
|
+
# Used by --plexi-introspect mode to report what capabilities an app uses.
|
|
27
|
+
CAPABILITY_REGISTRY: dict[str, str] = {
|
|
28
|
+
"http_get": "net.http",
|
|
29
|
+
"http_request": "net.http",
|
|
30
|
+
"load_image": "net.http",
|
|
31
|
+
"ai_query": "ai.query",
|
|
32
|
+
"secret_get": "secrets.get",
|
|
33
|
+
"get_secret": "secrets.get",
|
|
34
|
+
"open_file_picker": "fs.pick",
|
|
35
|
+
"spawn_pane": "panes.spawn",
|
|
36
|
+
"open_midi_input": "midi.in",
|
|
37
|
+
"send_midi": "midi.out",
|
|
38
|
+
"open_video": "video.playback",
|
|
39
|
+
"set_video_state": "video.playback",
|
|
40
|
+
"close_video": "video.playback",
|
|
41
|
+
"audio_capture": "audio.record",
|
|
42
|
+
"stop_audio_capture": "audio.record",
|
|
43
|
+
"audio_play": "audio.playback",
|
|
44
|
+
"set_timer": "timer",
|
|
45
|
+
"cancel_timer": "timer",
|
|
46
|
+
"pipe_open": "pipe.open",
|
|
47
|
+
"pipe_open_directed": "pipe.open",
|
|
48
|
+
"request_linked_terminal": "terminal.bindings",
|
|
49
|
+
"run_in_linked_terminal": "terminal.bindings",
|
|
50
|
+
"insert_path_token": "terminal.bindings",
|
|
51
|
+
"request_command_preview": "terminal.bindings",
|
|
52
|
+
"open_artifact": "terminal.bindings",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# ── Internal helpers ──────────────────────────────────────────────────────────
|
|
56
|
+
_LOCK = threading.Lock()
|
|
57
|
+
|
|
58
|
+
# Per-thread sentinel: True while a *sync* hook is executing on the event loop
|
|
59
|
+
# thread. Blocking emit methods check this and raise immediately instead of
|
|
60
|
+
# silently returning an un-awaited coroutine object.
|
|
61
|
+
_SYNC_HOOK_LOCAL: threading.local = threading.local()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@contextmanager
|
|
65
|
+
def _sync_hook_scope() -> Iterator[None]:
|
|
66
|
+
"""Mark the current thread as executing a sync hook."""
|
|
67
|
+
old_active = getattr(_SYNC_HOOK_LOCAL, 'active', False)
|
|
68
|
+
_SYNC_HOOK_LOCAL.active = True
|
|
69
|
+
try:
|
|
70
|
+
yield
|
|
71
|
+
finally:
|
|
72
|
+
_SYNC_HOOK_LOCAL.active = old_active
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _blocking_emit_method(fn):
|
|
76
|
+
"""Decorator: raises TypeError if a blocking emit is called from a sync hook.
|
|
77
|
+
|
|
78
|
+
Async hooks use ``await``, so the wrapper body runs, sentinel is clear,
|
|
79
|
+
and the inner coroutine is returned for the caller to await normally.
|
|
80
|
+
Sync hooks call without ``await`` — wrapper body runs, sentinel is set,
|
|
81
|
+
TypeError fires before any I/O is attempted.
|
|
82
|
+
Background threads (run_sync path) are on a different thread, so the
|
|
83
|
+
thread-local sentinel is always clear for them.
|
|
84
|
+
"""
|
|
85
|
+
name = fn.__name__
|
|
86
|
+
|
|
87
|
+
@functools.wraps(fn)
|
|
88
|
+
def wrapper(self, *args, **kwargs):
|
|
89
|
+
if getattr(_SYNC_HOOK_LOCAL, 'active', False):
|
|
90
|
+
raise TypeError(
|
|
91
|
+
f"emit.{name}() must be called with 'await' from an 'async def' hook.\n"
|
|
92
|
+
f"Your hook is 'def' (sync) — blocking emit calls require 'async def'.\n"
|
|
93
|
+
f"Fix: change 'def on_init(self, ctx)' → 'async def on_init(self, ctx)'\n"
|
|
94
|
+
f" then call: result = await self.emit.{name}(...)"
|
|
95
|
+
)
|
|
96
|
+
return fn(self, *args, **kwargs)
|
|
97
|
+
|
|
98
|
+
return wrapper
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _log_background_task_error(task: "asyncio.Task[Any]") -> None:
|
|
102
|
+
"""Done callback for schedule_task — logs unhandled exceptions to stderr."""
|
|
103
|
+
try:
|
|
104
|
+
exc = task.exception()
|
|
105
|
+
except (asyncio.CancelledError, asyncio.InvalidStateError):
|
|
106
|
+
return
|
|
107
|
+
if exc is not None:
|
|
108
|
+
sys.stderr.write(f"plexi_sdk: unhandled exception in schedule_task: {exc}\n")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _emit(obj: dict) -> None:
|
|
112
|
+
"""Thread-safe JSON line write to stdout."""
|
|
113
|
+
with _LOCK:
|
|
114
|
+
sys.stdout.write(json.dumps(obj) + "\n")
|
|
115
|
+
sys.stdout.flush()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _make_async_queue() -> asyncio.Queue:
|
|
119
|
+
"""Return a fresh asyncio.Queue for one pending request slot."""
|
|
120
|
+
return asyncio.Queue(maxsize=1)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── Emitter (always available, even outside a frame) ─────────────────────────
|
|
124
|
+
|
|
125
|
+
class Emitter:
|
|
126
|
+
"""Emit out-of-frame commands to Plexi. Thread-safe."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, app: "App"):
|
|
129
|
+
self._app = app
|
|
130
|
+
|
|
131
|
+
# Logging
|
|
132
|
+
def log(self, level: str, message: str) -> None:
|
|
133
|
+
_emit({"type": "log", "level": level, "message": message})
|
|
134
|
+
|
|
135
|
+
def info(self, message: str) -> None: self.log("info", message)
|
|
136
|
+
def warn(self, message: str) -> None: self.log("warn", message)
|
|
137
|
+
def error(self, message: str) -> None: self.log("error", message)
|
|
138
|
+
def debug(self, message: str) -> None: self.log("debug", message)
|
|
139
|
+
|
|
140
|
+
# Notifications — kind = "message" (plain text, one Acknowledge button).
|
|
141
|
+
#
|
|
142
|
+
# `priority` is REQUIRED on every notify call. Higher = more urgent.
|
|
143
|
+
# The host uses it to pick the next front-most notification after
|
|
144
|
+
# dismiss and to order the Cmd+] / Cmd+[ preview traversal. Typical
|
|
145
|
+
# values: 0 (background info), 50 (normal), 100 (important), 200
|
|
146
|
+
# (critical). No default — apps must pick deliberately.
|
|
147
|
+
#
|
|
148
|
+
# Scope (window / context / global) is NOT set by the app. It's declared
|
|
149
|
+
# in manifest.toml under [launch] notification_scope and resolved by the
|
|
150
|
+
# host at dispatch time. Apps just call notify(); the manifest controls
|
|
151
|
+
# whether the notification crosses window or context boundaries.
|
|
152
|
+
def notify(self, title: str, priority: int, body: str = "",
|
|
153
|
+
level: str = "info",
|
|
154
|
+
actions: "list | None" = None,
|
|
155
|
+
image_inline: "dict | None" = None,
|
|
156
|
+
image_pipe_id: "str | None" = None,
|
|
157
|
+
timeout_secs: "int | None" = None,
|
|
158
|
+
on_dismiss: "str | None" = None) -> None:
|
|
159
|
+
"""Post a message notification. The modal shows title + body and a
|
|
160
|
+
single Acknowledge button; Enter / Space acknowledge, Esc dismisses
|
|
161
|
+
(unless required=True — use `notify_and_wait` for that flow).
|
|
162
|
+
|
|
163
|
+
`priority` is required (int, higher = more urgent).
|
|
164
|
+
`actions` is the legacy side-effect list (action_type =
|
|
165
|
+
resume_run | open_intent | run_command). It does NOT render UI.
|
|
166
|
+
`image_inline` (#74): {"mime": "image/png"|"image/jpeg",
|
|
167
|
+
"base64": str}. Decoded host-side; >50KB triggers a placeholder.
|
|
168
|
+
`image_pipe_id` (#74): pipe id of a binary ring carrying RGBA frames
|
|
169
|
+
prefixed with width/height u32-LE. Mutually exclusive with
|
|
170
|
+
`image_inline` — host warns + drops the pipe ref if both set.
|
|
171
|
+
"""
|
|
172
|
+
payload = {"type": "notify", "level": level, "title": title,
|
|
173
|
+
"body": body, "kind": "message",
|
|
174
|
+
"priority": priority,
|
|
175
|
+
"actions": actions or []}
|
|
176
|
+
if image_inline is not None:
|
|
177
|
+
payload["image_inline"] = image_inline
|
|
178
|
+
if image_pipe_id is not None:
|
|
179
|
+
payload["image_pipe_id"] = image_pipe_id
|
|
180
|
+
if timeout_secs is not None:
|
|
181
|
+
payload["timeout_secs"] = int(timeout_secs)
|
|
182
|
+
if on_dismiss is not None:
|
|
183
|
+
payload["on_dismiss"] = on_dismiss
|
|
184
|
+
_emit(payload)
|
|
185
|
+
|
|
186
|
+
def run_sync(self, coro: "Any") -> Any:
|
|
187
|
+
"""Run a coroutine from a background thread and return its result.
|
|
188
|
+
|
|
189
|
+
Use this when calling async Emitter methods from a non-async context
|
|
190
|
+
(e.g. inside a ``threading.Thread`` callback)::
|
|
191
|
+
|
|
192
|
+
def _worker(self) -> None:
|
|
193
|
+
result = self.emit.run_sync(self.emit.http_get(url))
|
|
194
|
+
|
|
195
|
+
Must NOT be called from within the event loop thread — it will deadlock.
|
|
196
|
+
Raises ``RuntimeError`` if there is no running event loop to dispatch to
|
|
197
|
+
(e.g. called before ``App.run()`` starts).
|
|
198
|
+
"""
|
|
199
|
+
if getattr(_SYNC_HOOK_LOCAL, 'active', False):
|
|
200
|
+
raise RuntimeError(
|
|
201
|
+
"emit.run_sync() called from a sync hook (e.g. on_render) — "
|
|
202
|
+
"this deadlocks the event loop. Use emit.schedule_task(coro) "
|
|
203
|
+
"instead when you don't need the return value, or change the "
|
|
204
|
+
"hook to 'async def' and use 'await self.emit.method()'."
|
|
205
|
+
)
|
|
206
|
+
loop = self._app._loop
|
|
207
|
+
if loop is None:
|
|
208
|
+
raise RuntimeError(
|
|
209
|
+
"emit.run_sync() called before the event loop started. "
|
|
210
|
+
"Only call it after App.run() is running (e.g. from a "
|
|
211
|
+
"background thread started inside on_init or later)."
|
|
212
|
+
)
|
|
213
|
+
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
|
214
|
+
return future.result()
|
|
215
|
+
|
|
216
|
+
def schedule_task(self, coro: "Any") -> Any:
|
|
217
|
+
"""Schedule a coroutine as a background asyncio task from any context.
|
|
218
|
+
|
|
219
|
+
Safe to call from sync hooks (on_render, on_key, etc.) or background
|
|
220
|
+
threads. Returns immediately without blocking. The coroutine runs in
|
|
221
|
+
the background; use this when you don't need the return value::
|
|
222
|
+
|
|
223
|
+
def on_render(self, ctx: RenderContext) -> None:
|
|
224
|
+
if self._btn.render(ctx):
|
|
225
|
+
self.emit.schedule_task(self._do_query())
|
|
226
|
+
|
|
227
|
+
Raises ``RuntimeError`` if the event loop hasn't started yet.
|
|
228
|
+
"""
|
|
229
|
+
loop = self._app._loop
|
|
230
|
+
if loop is None:
|
|
231
|
+
raise RuntimeError(
|
|
232
|
+
"emit.schedule_task() called before the event loop started. "
|
|
233
|
+
"Only call it after App.run() is running (e.g. from on_init or later)."
|
|
234
|
+
)
|
|
235
|
+
try:
|
|
236
|
+
on_loop_thread = asyncio.get_running_loop() is loop
|
|
237
|
+
except RuntimeError:
|
|
238
|
+
on_loop_thread = False
|
|
239
|
+
|
|
240
|
+
if not on_loop_thread:
|
|
241
|
+
return asyncio.run_coroutine_threadsafe(coro, loop)
|
|
242
|
+
task = loop.create_task(coro)
|
|
243
|
+
# Strong reference prevents GC before the task completes.
|
|
244
|
+
self._app._background_tasks.add(task)
|
|
245
|
+
task.add_done_callback(self._app._background_tasks.discard)
|
|
246
|
+
task.add_done_callback(_log_background_task_error)
|
|
247
|
+
return task
|
|
248
|
+
|
|
249
|
+
# kind = "choice"
|
|
250
|
+
@_blocking_emit_method
|
|
251
|
+
async def notify_choice(self, title: str, options: list, priority: int,
|
|
252
|
+
body: str = "",
|
|
253
|
+
level: str = "info", required: bool = False,
|
|
254
|
+
image_inline: "dict | None" = None,
|
|
255
|
+
image_pipe_id: "str | None" = None,
|
|
256
|
+
timeout_secs: "int | None" = None,
|
|
257
|
+
on_dismiss: "str | None" = None) -> str:
|
|
258
|
+
"""Post a choice notification and await until the user picks one.
|
|
259
|
+
|
|
260
|
+
`options` is a list of dicts: {"label": str, "value": str (optional),
|
|
261
|
+
"shortcut": str (optional, single char)}. If `value` is omitted, the
|
|
262
|
+
label is returned. Issue #74's structured-choice spec maps directly:
|
|
263
|
+
`key` → `shortcut`, `payload` → `value`.
|
|
264
|
+
|
|
265
|
+
`priority` is required (int, higher = more urgent).
|
|
266
|
+
`image_inline` (#74): {"mime", "base64"} — same shape as `notify`.
|
|
267
|
+
`image_pipe_id` (#74): pipe id of a binary ring carrying RGBA frames.
|
|
268
|
+
Returns the chosen option's value (or the label if no value set). If
|
|
269
|
+
`required=False` the user may cancel with Esc — this returns the string
|
|
270
|
+
`"__cancel__"`.
|
|
271
|
+
|
|
272
|
+
Await this from async hooks. From background threads use
|
|
273
|
+
``self.emit.run_sync(self.emit.notify_choice(...))``.
|
|
274
|
+
"""
|
|
275
|
+
# Warn if any option uses a reserved shortcut key.
|
|
276
|
+
import warnings
|
|
277
|
+
for opt in options:
|
|
278
|
+
sc = opt.get("shortcut", "")
|
|
279
|
+
if sc and sc.lower() in _RESERVED_SHORTCUTS:
|
|
280
|
+
warnings.warn(
|
|
281
|
+
f"notify_choice: shortcut {sc!r} on option {opt.get('label', '')!r} "
|
|
282
|
+
f"conflicts with notification navigation keys (j/k/h/l, 1-9). "
|
|
283
|
+
f"Use y/n or another letter. The host will strip it.",
|
|
284
|
+
stacklevel=2,
|
|
285
|
+
)
|
|
286
|
+
notify_id = str(uuid.uuid4())
|
|
287
|
+
q: asyncio.Queue[str] = _make_async_queue()
|
|
288
|
+
self._app._pending_notify[notify_id] = q
|
|
289
|
+
payload = {"type": "notify", "level": level, "title": title, "body": body,
|
|
290
|
+
"kind": "choice", "options": options, "required": required,
|
|
291
|
+
"priority": priority,
|
|
292
|
+
"notify_id": notify_id}
|
|
293
|
+
if image_inline is not None:
|
|
294
|
+
payload["image_inline"] = image_inline
|
|
295
|
+
if image_pipe_id is not None:
|
|
296
|
+
payload["image_pipe_id"] = image_pipe_id
|
|
297
|
+
if timeout_secs is not None:
|
|
298
|
+
payload["timeout_secs"] = int(timeout_secs)
|
|
299
|
+
if on_dismiss is not None:
|
|
300
|
+
payload["on_dismiss"] = on_dismiss
|
|
301
|
+
_emit(payload)
|
|
302
|
+
return await q.get()
|
|
303
|
+
|
|
304
|
+
# Convenience wrapper for the inline-image case (#74). Handles base64
|
|
305
|
+
# encoding + size-cap enforcement client-side so we never spend wire
|
|
306
|
+
# bytes on payloads the host will only reject.
|
|
307
|
+
@_blocking_emit_method
|
|
308
|
+
async def notify_with_image(self, title: str, body: str, image_bytes: bytes,
|
|
309
|
+
mime: str, priority: int,
|
|
310
|
+
level: str = "info",
|
|
311
|
+
choices: "list | None" = None) -> "str | None":
|
|
312
|
+
"""Post a notification with an inline base64-encoded image attachment.
|
|
313
|
+
|
|
314
|
+
`image_bytes` must be ≤ 50_000 bytes — anything larger raises
|
|
315
|
+
`ValueError` here so the app sees a fast, local failure instead of
|
|
316
|
+
having the host silently render a placeholder. Use the pipe-ref
|
|
317
|
+
path (`emit.notify(..., image_pipe_id=...)`) for larger images.
|
|
318
|
+
|
|
319
|
+
`mime` must be `"image/png"` or `"image/jpeg"`.
|
|
320
|
+
|
|
321
|
+
`choices` is optional. When None this posts a fire-and-forget
|
|
322
|
+
message-kind notification (returns None). When set, this routes to
|
|
323
|
+
`notify_choice` and awaits until the user picks one (returns the
|
|
324
|
+
chosen value, or `"__cancel__"`).
|
|
325
|
+
|
|
326
|
+
Await this from async hooks. From background threads use
|
|
327
|
+
``self.emit.run_sync(self.emit.notify_with_image(...))``.
|
|
328
|
+
"""
|
|
329
|
+
if mime not in ("image/png", "image/jpeg"):
|
|
330
|
+
raise ValueError(f"notify_with_image() mime must be image/png or image/jpeg, got {mime!r}")
|
|
331
|
+
# 50 KB cap: matches the host's `MAX_INLINE_IMAGE_BYTES`. Apps that
|
|
332
|
+
# exceed this should use the pipe-ref path. Failing locally is the
|
|
333
|
+
# least surprising behaviour.
|
|
334
|
+
MAX_INLINE_BYTES = 50 * 1024
|
|
335
|
+
if len(image_bytes) > MAX_INLINE_BYTES:
|
|
336
|
+
raise ValueError(
|
|
337
|
+
f"notify_with_image(): image is {len(image_bytes)} bytes, "
|
|
338
|
+
f"exceeds {MAX_INLINE_BYTES}-byte cap. Use image_pipe_id for large images."
|
|
339
|
+
)
|
|
340
|
+
import base64
|
|
341
|
+
encoded = base64.standard_b64encode(image_bytes).decode("ascii")
|
|
342
|
+
image_inline = {"mime": mime, "base64": encoded}
|
|
343
|
+
if choices is None:
|
|
344
|
+
self.notify(title=title, body=body, level=level,
|
|
345
|
+
priority=priority, image_inline=image_inline)
|
|
346
|
+
return None
|
|
347
|
+
return await self.notify_choice(title=title, options=choices, body=body,
|
|
348
|
+
level=level, priority=priority,
|
|
349
|
+
image_inline=image_inline)
|
|
350
|
+
|
|
351
|
+
# kind = "input"
|
|
352
|
+
@_blocking_emit_method
|
|
353
|
+
async def notify_input(self, title: str, priority: int,
|
|
354
|
+
prompt: str = "", body: str = "",
|
|
355
|
+
level: str = "info", required: bool = False,
|
|
356
|
+
timeout_secs: "int | None" = None,
|
|
357
|
+
on_dismiss: "str | None" = None) -> str:
|
|
358
|
+
"""Post an input notification and await until the user submits or
|
|
359
|
+
cancels. Returns the typed text (possibly empty), or "__cancel__" if
|
|
360
|
+
the user dismissed with Esc (only possible when required=False).
|
|
361
|
+
|
|
362
|
+
`priority` is required (int, higher = more urgent).
|
|
363
|
+
|
|
364
|
+
Await this from async hooks. From background threads use
|
|
365
|
+
``self.emit.run_sync(self.emit.notify_input(...))``.
|
|
366
|
+
"""
|
|
367
|
+
notify_id = str(uuid.uuid4())
|
|
368
|
+
q: asyncio.Queue[str] = _make_async_queue()
|
|
369
|
+
self._app._pending_notify[notify_id] = q
|
|
370
|
+
payload = {"type": "notify", "level": level, "title": title, "body": body,
|
|
371
|
+
"kind": "input", "input_prompt": prompt, "required": required,
|
|
372
|
+
"priority": priority,
|
|
373
|
+
"notify_id": notify_id}
|
|
374
|
+
if timeout_secs is not None:
|
|
375
|
+
payload["timeout_secs"] = int(timeout_secs)
|
|
376
|
+
if on_dismiss is not None:
|
|
377
|
+
payload["on_dismiss"] = on_dismiss
|
|
378
|
+
_emit(payload)
|
|
379
|
+
return await q.get()
|
|
380
|
+
|
|
381
|
+
@_blocking_emit_method
|
|
382
|
+
async def notify_and_wait(self, title: str, priority: int,
|
|
383
|
+
body: str = "", level: str = "info",
|
|
384
|
+
actions: "list | None" = None,
|
|
385
|
+
timeout_secs: "int | None" = None,
|
|
386
|
+
on_dismiss: "str | None" = None) -> str:
|
|
387
|
+
"""Post a message notification and await until the user acknowledges
|
|
388
|
+
or cancels. Returns "acknowledge" on Enter/Space/button, "cancel" on Esc.
|
|
389
|
+
|
|
390
|
+
For richer interaction, use `notify_choice` or `notify_input`.
|
|
391
|
+
`priority` is required (int, higher = more urgent).
|
|
392
|
+
`actions` is the legacy server-side side-effect list.
|
|
393
|
+
|
|
394
|
+
Await this from async hooks. From background threads use
|
|
395
|
+
``self.emit.run_sync(self.emit.notify_and_wait(...))``.
|
|
396
|
+
"""
|
|
397
|
+
notify_id = str(uuid.uuid4())
|
|
398
|
+
q: asyncio.Queue[str] = _make_async_queue()
|
|
399
|
+
self._app._pending_notify[notify_id] = q
|
|
400
|
+
payload = {"type": "notify", "level": level, "title": title, "body": body,
|
|
401
|
+
"kind": "message", "actions": actions or [],
|
|
402
|
+
"priority": priority,
|
|
403
|
+
"notify_id": notify_id}
|
|
404
|
+
if timeout_secs is not None:
|
|
405
|
+
payload["timeout_secs"] = int(timeout_secs)
|
|
406
|
+
if on_dismiss is not None:
|
|
407
|
+
payload["on_dismiss"] = on_dismiss
|
|
408
|
+
_emit(payload)
|
|
409
|
+
return await q.get()
|
|
410
|
+
|
|
411
|
+
# ── Non-blocking (callback-based) notify variants (#310) ─────────────────
|
|
412
|
+
# Each method returns immediately with the notify_id (str). The callback
|
|
413
|
+
# `on_response` fires on the event thread when the user interacts.
|
|
414
|
+
# Registry entry is removed after the first invocation.
|
|
415
|
+
|
|
416
|
+
def notify_async(self, title: str, priority: int, body: str = "",
|
|
417
|
+
level: str = "info",
|
|
418
|
+
actions: "list | None" = None,
|
|
419
|
+
image_inline: "dict | None" = None,
|
|
420
|
+
image_pipe_id: "str | None" = None,
|
|
421
|
+
timeout_secs: "int | None" = None,
|
|
422
|
+
on_dismiss: "str | None" = None,
|
|
423
|
+
on_response: "Any" = None) -> str:
|
|
424
|
+
"""Non-blocking notify_and_wait. Returns immediately with notify_id.
|
|
425
|
+
|
|
426
|
+
If on_response is None, behaves like fire-and-forget notify() — returns "".
|
|
427
|
+
If on_response is set, registers the callback; it receives "acknowledge"
|
|
428
|
+
or "__cancel__" when the user interacts.
|
|
429
|
+
|
|
430
|
+
`priority` is required (int, higher = more urgent).
|
|
431
|
+
"""
|
|
432
|
+
if on_response is None:
|
|
433
|
+
self.notify(title=title, priority=priority, body=body, level=level,
|
|
434
|
+
actions=actions, image_inline=image_inline,
|
|
435
|
+
image_pipe_id=image_pipe_id, timeout_secs=timeout_secs,
|
|
436
|
+
on_dismiss=on_dismiss)
|
|
437
|
+
return ""
|
|
438
|
+
notify_id = str(uuid.uuid4())
|
|
439
|
+
self._app._pending_notify_callbacks[notify_id] = on_response
|
|
440
|
+
self.info(f"notify_async: registered callback for {notify_id!r}")
|
|
441
|
+
payload: dict = {"type": "notify", "level": level, "title": title,
|
|
442
|
+
"body": body, "kind": "message", "actions": actions or [],
|
|
443
|
+
"priority": priority, "notify_id": notify_id}
|
|
444
|
+
if image_inline is not None:
|
|
445
|
+
payload["image_inline"] = image_inline
|
|
446
|
+
if image_pipe_id is not None:
|
|
447
|
+
payload["image_pipe_id"] = image_pipe_id
|
|
448
|
+
if timeout_secs is not None:
|
|
449
|
+
payload["timeout_secs"] = int(timeout_secs)
|
|
450
|
+
if on_dismiss is not None:
|
|
451
|
+
payload["on_dismiss"] = on_dismiss
|
|
452
|
+
_emit(payload)
|
|
453
|
+
return notify_id
|
|
454
|
+
|
|
455
|
+
def notify_choice_async(self, title: str, options: list, priority: int,
|
|
456
|
+
body: str = "",
|
|
457
|
+
level: str = "info", required: bool = False,
|
|
458
|
+
image_inline: "dict | None" = None,
|
|
459
|
+
image_pipe_id: "str | None" = None,
|
|
460
|
+
timeout_secs: "int | None" = None,
|
|
461
|
+
on_dismiss: "str | None" = None,
|
|
462
|
+
on_response: "Any" = None) -> str:
|
|
463
|
+
"""Non-blocking notify_choice. Returns immediately with notify_id.
|
|
464
|
+
|
|
465
|
+
`on_response` receives the chosen option's value (or label if no value
|
|
466
|
+
set), or "__cancel__" if the user dismissed (required=False only).
|
|
467
|
+
`priority` is required (int, higher = more urgent).
|
|
468
|
+
"""
|
|
469
|
+
import warnings
|
|
470
|
+
for opt in options:
|
|
471
|
+
sc = opt.get("shortcut", "")
|
|
472
|
+
if sc and sc.lower() in _RESERVED_SHORTCUTS:
|
|
473
|
+
warnings.warn(
|
|
474
|
+
f"notify_choice_async: shortcut {sc!r} on option "
|
|
475
|
+
f"{opt.get('label', '')!r} conflicts with notification "
|
|
476
|
+
f"navigation keys (j/k/h/l, 1-9). Use y/n or another letter.",
|
|
477
|
+
stacklevel=2,
|
|
478
|
+
)
|
|
479
|
+
notify_id = str(uuid.uuid4())
|
|
480
|
+
if on_response is not None:
|
|
481
|
+
self._app._pending_notify_callbacks[notify_id] = on_response
|
|
482
|
+
self.info(f"notify_choice_async: registered callback for {notify_id!r}")
|
|
483
|
+
payload: dict = {"type": "notify", "level": level, "title": title,
|
|
484
|
+
"body": body, "kind": "choice", "options": options,
|
|
485
|
+
"required": required, "priority": priority,
|
|
486
|
+
"notify_id": notify_id}
|
|
487
|
+
if image_inline is not None:
|
|
488
|
+
payload["image_inline"] = image_inline
|
|
489
|
+
if image_pipe_id is not None:
|
|
490
|
+
payload["image_pipe_id"] = image_pipe_id
|
|
491
|
+
if timeout_secs is not None:
|
|
492
|
+
payload["timeout_secs"] = int(timeout_secs)
|
|
493
|
+
if on_dismiss is not None:
|
|
494
|
+
payload["on_dismiss"] = on_dismiss
|
|
495
|
+
_emit(payload)
|
|
496
|
+
return notify_id
|
|
497
|
+
|
|
498
|
+
def notify_input_async(self, title: str, priority: int,
|
|
499
|
+
prompt: str = "", body: str = "",
|
|
500
|
+
level: str = "info", required: bool = False,
|
|
501
|
+
timeout_secs: "int | None" = None,
|
|
502
|
+
on_dismiss: "str | None" = None,
|
|
503
|
+
on_response: "Any" = None) -> str:
|
|
504
|
+
"""Non-blocking notify_input. Returns immediately with notify_id.
|
|
505
|
+
|
|
506
|
+
`on_response` receives the typed text (possibly empty), or "__cancel__"
|
|
507
|
+
if the user dismissed (required=False only).
|
|
508
|
+
`priority` is required (int, higher = more urgent).
|
|
509
|
+
"""
|
|
510
|
+
notify_id = str(uuid.uuid4())
|
|
511
|
+
if on_response is not None:
|
|
512
|
+
self._app._pending_notify_callbacks[notify_id] = on_response
|
|
513
|
+
self.info(f"notify_input_async: registered callback for {notify_id!r}")
|
|
514
|
+
payload: dict = {"type": "notify", "level": level, "title": title,
|
|
515
|
+
"body": body, "kind": "input", "input_prompt": prompt,
|
|
516
|
+
"required": required, "priority": priority,
|
|
517
|
+
"notify_id": notify_id}
|
|
518
|
+
if timeout_secs is not None:
|
|
519
|
+
payload["timeout_secs"] = int(timeout_secs)
|
|
520
|
+
if on_dismiss is not None:
|
|
521
|
+
payload["on_dismiss"] = on_dismiss
|
|
522
|
+
_emit(payload)
|
|
523
|
+
return notify_id
|
|
524
|
+
|
|
525
|
+
def notify_and_wait_async(self, title: str, priority: int,
|
|
526
|
+
body: str = "", level: str = "info",
|
|
527
|
+
actions: "list | None" = None,
|
|
528
|
+
timeout_secs: "int | None" = None,
|
|
529
|
+
on_dismiss: "str | None" = None,
|
|
530
|
+
on_response: "Any" = None) -> str:
|
|
531
|
+
"""Non-blocking notify_and_wait. Returns immediately with notify_id.
|
|
532
|
+
|
|
533
|
+
`on_response` receives "acknowledge" on Enter/Space/button, or
|
|
534
|
+
"__cancel__" on Esc.
|
|
535
|
+
`priority` is required (int, higher = more urgent).
|
|
536
|
+
"""
|
|
537
|
+
notify_id = str(uuid.uuid4())
|
|
538
|
+
if on_response is not None:
|
|
539
|
+
self._app._pending_notify_callbacks[notify_id] = on_response
|
|
540
|
+
self.info(f"notify_and_wait_async: registered callback for {notify_id!r}")
|
|
541
|
+
payload: dict = {"type": "notify", "level": level, "title": title,
|
|
542
|
+
"body": body, "kind": "message", "actions": actions or [],
|
|
543
|
+
"priority": priority, "notify_id": notify_id}
|
|
544
|
+
if timeout_secs is not None:
|
|
545
|
+
payload["timeout_secs"] = int(timeout_secs)
|
|
546
|
+
if on_dismiss is not None:
|
|
547
|
+
payload["on_dismiss"] = on_dismiss
|
|
548
|
+
_emit(payload)
|
|
549
|
+
return notify_id
|
|
550
|
+
|
|
551
|
+
# Terminal commands (legacy back-compat)
|
|
552
|
+
def run_in_terminal(self, command: str) -> None:
|
|
553
|
+
_emit({"type": "run_in_terminal", "command": command})
|
|
554
|
+
|
|
555
|
+
def cd(self, path: str) -> None:
|
|
556
|
+
_emit({"type": "cd", "path": path})
|
|
557
|
+
|
|
558
|
+
def status_summary(self, text: str) -> None:
|
|
559
|
+
_emit({"type": "status_summary", "text": text})
|
|
560
|
+
|
|
561
|
+
# ── Navigation stack (#392) ─────────────────────────────────────────────
|
|
562
|
+
|
|
563
|
+
def push_nav(self, view_id: str, title: str) -> None:
|
|
564
|
+
"""Push a new view onto the host navigation stack.
|
|
565
|
+
|
|
566
|
+
The host appends an entry keyed on `view_id` with display `title`
|
|
567
|
+
and shows a back arrow + `title` in the pane chrome while this view
|
|
568
|
+
is active. Cmd+[ (or clicking the back arrow) sends
|
|
569
|
+
``PlexiEvent::NavBack { view_id }`` back to the app, where
|
|
570
|
+
``view_id`` is the view being navigated *back to* (the entry below
|
|
571
|
+
the current top, or empty string for root).
|
|
572
|
+
|
|
573
|
+
The app is responsible for tracking its own internal view state and
|
|
574
|
+
calling ``pop_nav()`` after navigating back.
|
|
575
|
+
"""
|
|
576
|
+
_emit({"type": "push_nav", "view_id": view_id, "title": title})
|
|
577
|
+
|
|
578
|
+
def pop_nav(self) -> None:
|
|
579
|
+
"""Pop the current view off the host navigation stack.
|
|
580
|
+
|
|
581
|
+
Call this after the app has already rendered the previous view (e.g.
|
|
582
|
+
inside the ``on_nav_back`` handler). The host removes the top entry
|
|
583
|
+
from the stack; if the stack becomes empty the back arrow disappears.
|
|
584
|
+
"""
|
|
585
|
+
_emit({"type": "pop_nav"})
|
|
586
|
+
|
|
587
|
+
# ── File picker (#514) ──────────────────────────────────────────────────
|
|
588
|
+
|
|
589
|
+
def open_file_picker(
|
|
590
|
+
self,
|
|
591
|
+
request_id: str,
|
|
592
|
+
filter: "list[str] | None" = None,
|
|
593
|
+
multiple: bool = False,
|
|
594
|
+
) -> None:
|
|
595
|
+
"""Show a native macOS file picker dialog. Requires ``fs.pick`` capability.
|
|
596
|
+
|
|
597
|
+
The host responds asynchronously with either:
|
|
598
|
+
- ``on_file_picked(ctx, request_id, paths)`` — one or more files selected
|
|
599
|
+
- ``on_file_pick_cancelled(ctx, request_id)`` — user dismissed the dialog
|
|
600
|
+
|
|
601
|
+
Args:
|
|
602
|
+
request_id: Caller-supplied correlation ID echoed back in the response.
|
|
603
|
+
filter: File extension whitelist without leading dots
|
|
604
|
+
(e.g. ``["mp4", "mov"]``). ``None`` or empty = all files.
|
|
605
|
+
multiple: Allow picking more than one file.
|
|
606
|
+
"""
|
|
607
|
+
_emit({
|
|
608
|
+
"type": "open_file_picker",
|
|
609
|
+
"request_id": request_id,
|
|
610
|
+
"filter": filter or [],
|
|
611
|
+
"multiple": multiple,
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
def spawn_pane(
|
|
615
|
+
self,
|
|
616
|
+
type_id: str,
|
|
617
|
+
layout: str = "split_v",
|
|
618
|
+
args: "list[str] | None" = None,
|
|
619
|
+
pipe_id: "str | None" = None,
|
|
620
|
+
from_pane_id: "int | None" = None,
|
|
621
|
+
request_id: "str | None" = None,
|
|
622
|
+
target_context: "int | None" = None,
|
|
623
|
+
) -> None:
|
|
624
|
+
"""Request the host to open a new pane with the given app (#592).
|
|
625
|
+
|
|
626
|
+
Requires the ``panes.spawn`` capability. The host responds with
|
|
627
|
+
``on_pane_spawned(pane_id, request_id)`` on success or
|
|
628
|
+
``on_pane_spawn_error(reason, request_id)`` on failure.
|
|
629
|
+
|
|
630
|
+
Args:
|
|
631
|
+
type_id: App manifest id or ``"terminal"``.
|
|
632
|
+
layout: One of ``"split_v"`` (default, below), ``"split_h"`` (right),
|
|
633
|
+
``"split_above"``, ``"split_left"``, ``"overlay"``.
|
|
634
|
+
args: Extra argv passed to the spawned app.
|
|
635
|
+
pipe_id: Optional. If set, the host appends ``--pipe=<id>`` to the
|
|
636
|
+
spawned app's args so it can reply via ``emit.pipe_send()``.
|
|
637
|
+
from_pane_id: Optional pane_id to split relative to instead of the
|
|
638
|
+
currently focused pane.
|
|
639
|
+
request_id: Optional correlation id echoed back in on_pane_spawned /
|
|
640
|
+
on_pane_spawn_error.
|
|
641
|
+
target_context: Optional context_id to spawn the pane into. Must be
|
|
642
|
+
the calling pane's own context or a descendant (#1518).
|
|
643
|
+
"""
|
|
644
|
+
cmd: "dict[str, object]" = {
|
|
645
|
+
"type": "spawn_pane",
|
|
646
|
+
"type_id": type_id,
|
|
647
|
+
"layout": layout,
|
|
648
|
+
"args": args or [],
|
|
649
|
+
}
|
|
650
|
+
if pipe_id is not None:
|
|
651
|
+
cmd["pipe_id"] = pipe_id
|
|
652
|
+
if from_pane_id is not None:
|
|
653
|
+
cmd["from_pane_id"] = from_pane_id
|
|
654
|
+
if request_id is not None:
|
|
655
|
+
cmd["request_id"] = request_id
|
|
656
|
+
if target_context is not None:
|
|
657
|
+
cmd["target_context"] = target_context
|
|
658
|
+
_emit(cmd)
|
|
659
|
+
|
|
660
|
+
def query_context_state(self, context_id: int) -> None:
|
|
661
|
+
"""Query the state of a context (#1518).
|
|
662
|
+
|
|
663
|
+
The host responds with ``on_context_state(state)`` containing pane
|
|
664
|
+
count, child contexts, and aggregate status. The requesting pane must
|
|
665
|
+
be in the queried context itself or in an ancestor context (visibility
|
|
666
|
+
boundary). Non-descendant queries are silently denied.
|
|
667
|
+
|
|
668
|
+
Args:
|
|
669
|
+
context_id: The context to query.
|
|
670
|
+
"""
|
|
671
|
+
_emit({"type": "query_context_state", "context_id": context_id})
|
|
672
|
+
|
|
673
|
+
def cd_to(self, cwd: str) -> None:
|
|
674
|
+
"""Request the host to cd all terminals in the same pane group to `cwd`."""
|
|
675
|
+
_emit({"type": "cd_request", "cwd": cwd})
|
|
676
|
+
|
|
677
|
+
def copy_to_clipboard(self, text: str) -> None:
|
|
678
|
+
"""Write `text` to the OS clipboard via the host (issue #146).
|
|
679
|
+
|
|
680
|
+
Routed through `egui::Context::copy_text` so the platform backend
|
|
681
|
+
(NSPasteboard / X11 / Wayland / Win32) handles the actual write.
|
|
682
|
+
Synchronous from the app's perspective — no acknowledgement event.
|
|
683
|
+
No capability flag is required; clipboard writes are low-risk and
|
|
684
|
+
the app already chooses when to fire (key handler, button, etc.).
|
|
685
|
+
"""
|
|
686
|
+
_emit({"type": "copy_to_clipboard", "text": text})
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
# ── Canvas Terminal Binding Primitives (#78) ────────────────────────────
|
|
690
|
+
#
|
|
691
|
+
# All five gate on the manifest capability `terminal.bindings`. Apps
|
|
692
|
+
# without it get either a sentinel response (for the request/response
|
|
693
|
+
# primitives) or a silent drop (for fire-and-forget primitives) — the
|
|
694
|
+
# host logs `capability denied` either way so the bug is debuggable.
|
|
695
|
+
#
|
|
696
|
+
# The lifecycle: call `request_linked_terminal()` once at startup
|
|
697
|
+
# (typically inside `on_init`) and stash the returned pane id. Pass it
|
|
698
|
+
# to every subsequent `run_in_linked_terminal` / `insert_path_token` /
|
|
699
|
+
# `request_command_preview` call.
|
|
700
|
+
@_blocking_emit_method
|
|
701
|
+
async def request_linked_terminal(
|
|
702
|
+
self,
|
|
703
|
+
cwd: "str | None" = None,
|
|
704
|
+
label: "str | None" = None,
|
|
705
|
+
) -> int:
|
|
706
|
+
"""Ask the host to open a fresh terminal pane next to this app and
|
|
707
|
+
return its `terminal_pane_id` (an integer). Awaits until the host
|
|
708
|
+
emits `LinkedTerminalReady`.
|
|
709
|
+
|
|
710
|
+
Raises `CapabilityDeniedError` if the manifest doesn't declare
|
|
711
|
+
`terminal.bindings`.
|
|
712
|
+
|
|
713
|
+
Await this from async hooks. From background threads use
|
|
714
|
+
``self.emit.run_sync(self.emit.request_linked_terminal(...))``.
|
|
715
|
+
"""
|
|
716
|
+
req_id = str(uuid.uuid4())
|
|
717
|
+
q: asyncio.Queue[int] = _make_async_queue()
|
|
718
|
+
self._app._pending_linked_terminal[req_id] = q
|
|
719
|
+
_emit({
|
|
720
|
+
"type": "request_linked_terminal",
|
|
721
|
+
"request_id": req_id,
|
|
722
|
+
"cwd": cwd,
|
|
723
|
+
"label": label,
|
|
724
|
+
})
|
|
725
|
+
pane_id = await q.get()
|
|
726
|
+
if pane_id == 0:
|
|
727
|
+
raise CapabilityDeniedError(
|
|
728
|
+
"request_linked_terminal: capability 'terminal.bindings' "
|
|
729
|
+
"not declared in manifest"
|
|
730
|
+
)
|
|
731
|
+
return int(pane_id)
|
|
732
|
+
|
|
733
|
+
def run_in_linked_terminal(
|
|
734
|
+
self,
|
|
735
|
+
terminal_pane_id: int,
|
|
736
|
+
command: str,
|
|
737
|
+
echo: bool = True,
|
|
738
|
+
) -> None:
|
|
739
|
+
"""Run `command` in the linked terminal. With `echo=True` the user
|
|
740
|
+
sees the command typed into the terminal; with `echo=False` the
|
|
741
|
+
signalled intent is silent execution (PTY-level echo is shell-
|
|
742
|
+
controlled — the flag is best-effort observational).
|
|
743
|
+
|
|
744
|
+
Fire-and-forget — capability denial drops silently with a host
|
|
745
|
+
log line."""
|
|
746
|
+
_emit({
|
|
747
|
+
"type": "run_in_linked_terminal",
|
|
748
|
+
"terminal_pane_id": int(terminal_pane_id),
|
|
749
|
+
"command": command,
|
|
750
|
+
"echo": bool(echo),
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
def insert_path_token(
|
|
754
|
+
self,
|
|
755
|
+
terminal_pane_id: int,
|
|
756
|
+
path: str,
|
|
757
|
+
mode: str = "replace",
|
|
758
|
+
) -> None:
|
|
759
|
+
"""Inject `path` at the linked terminal's cursor.
|
|
760
|
+
|
|
761
|
+
`mode = "replace"` — Ctrl-W (kill-word) before the path so the
|
|
762
|
+
shell readline removes the partial token.
|
|
763
|
+
`mode = "append"` — write the path verbatim.
|
|
764
|
+
|
|
765
|
+
The host POSIX-quotes the path when it contains shell metacharacters.
|
|
766
|
+
Fire-and-forget — capability denial drops silently with a host log
|
|
767
|
+
line."""
|
|
768
|
+
if mode not in ("replace", "append"):
|
|
769
|
+
raise ValueError(
|
|
770
|
+
f"insert_path_token: mode must be 'replace' or 'append', got {mode!r}"
|
|
771
|
+
)
|
|
772
|
+
_emit({
|
|
773
|
+
"type": "insert_path_token",
|
|
774
|
+
"terminal_pane_id": int(terminal_pane_id),
|
|
775
|
+
"path": path,
|
|
776
|
+
"mode": mode,
|
|
777
|
+
})
|
|
778
|
+
|
|
779
|
+
@_blocking_emit_method
|
|
780
|
+
async def request_command_preview(
|
|
781
|
+
self,
|
|
782
|
+
terminal_pane_id: int,
|
|
783
|
+
command: str,
|
|
784
|
+
) -> "tuple[str, str]":
|
|
785
|
+
"""Return `(command, would_run_in_cwd)` for `command` in the linked
|
|
786
|
+
terminal. Doesn't execute. Useful for confirmation modals before
|
|
787
|
+
destructive operations.
|
|
788
|
+
|
|
789
|
+
If the host denies the request, `would_run_in_cwd` is empty string;
|
|
790
|
+
the SDK does NOT raise — apps that want to be loud should check for
|
|
791
|
+
empty cwd themselves (the most common failure mode is a missing
|
|
792
|
+
capability, which the host already logs).
|
|
793
|
+
|
|
794
|
+
Await this from async hooks. From background threads use
|
|
795
|
+
``self.emit.run_sync(self.emit.request_command_preview(...))``.
|
|
796
|
+
"""
|
|
797
|
+
req_id = str(uuid.uuid4())
|
|
798
|
+
q: asyncio.Queue[tuple[str, str]] = _make_async_queue()
|
|
799
|
+
self._app._pending_command_preview[req_id] = q
|
|
800
|
+
_emit({
|
|
801
|
+
"type": "request_command_preview",
|
|
802
|
+
"request_id": req_id,
|
|
803
|
+
"terminal_pane_id": int(terminal_pane_id),
|
|
804
|
+
"command": command,
|
|
805
|
+
})
|
|
806
|
+
return await q.get()
|
|
807
|
+
|
|
808
|
+
def open_artifact(
|
|
809
|
+
self,
|
|
810
|
+
path: str,
|
|
811
|
+
mode: str = "open_in_pane",
|
|
812
|
+
) -> None:
|
|
813
|
+
"""Open a workspace artifact via the host.
|
|
814
|
+
|
|
815
|
+
Modes:
|
|
816
|
+
- `"open_in_pane"` — directories open the file browser
|
|
817
|
+
next to the app; files open with
|
|
818
|
+
the OS default app (v3.5).
|
|
819
|
+
- `"reveal_in_finder"` — `open -R <path>` on macOS.
|
|
820
|
+
- `"open_with_default"` — `open <path>` on macOS.
|
|
821
|
+
|
|
822
|
+
Fire-and-forget. Capability denial drops silently with a host log
|
|
823
|
+
line."""
|
|
824
|
+
if mode not in ("open_in_pane", "reveal_in_finder", "open_with_default"):
|
|
825
|
+
raise ValueError(
|
|
826
|
+
f"open_artifact: mode must be one of "
|
|
827
|
+
f"open_in_pane / reveal_in_finder / open_with_default, "
|
|
828
|
+
f"got {mode!r}"
|
|
829
|
+
)
|
|
830
|
+
_emit({"type": "open_artifact", "path": path, "mode": mode})
|
|
831
|
+
|
|
832
|
+
def schedule_render(self, after_ms: int = 16) -> None:
|
|
833
|
+
"""Ask the host to send a new Render event after `after_ms` milliseconds.
|
|
834
|
+
Call at the end of on_render to sustain a game/animation loop.
|
|
835
|
+
16 ms ≈ 60 fps. 32 ms ≈ 30 fps."""
|
|
836
|
+
_emit({"type": "schedule_render", "after_ms": after_ms})
|
|
837
|
+
|
|
838
|
+
# Runs
|
|
839
|
+
def run_get(self, intent: str, payload: Any = None) -> str:
|
|
840
|
+
run_id = str(uuid.uuid4())
|
|
841
|
+
_emit({"type": "run_get", "intent": intent,
|
|
842
|
+
"payload": payload or {}})
|
|
843
|
+
return run_id
|
|
844
|
+
|
|
845
|
+
# ── Async blocking helpers ────────────────────────────────────────────────
|
|
846
|
+
# Each of these is a coroutine — await them from async hooks, or call
|
|
847
|
+
# self.emit.run_sync(self.emit.method(...)) from a background thread.
|
|
848
|
+
|
|
849
|
+
@_blocking_emit_method
|
|
850
|
+
async def capability_request(self, capability: str) -> None:
|
|
851
|
+
"""Request a runtime capability grant from the user. Returns None if granted.
|
|
852
|
+
|
|
853
|
+
Raises CapabilityDeniedError if the user denies the request. Callers should
|
|
854
|
+
wrap this in try/except CapabilityDeniedError to handle denial gracefully.
|
|
855
|
+
|
|
856
|
+
Await from async hooks. From background threads:
|
|
857
|
+
``self.emit.run_sync(self.emit.capability_request(capability))``.
|
|
858
|
+
"""
|
|
859
|
+
req_id = str(uuid.uuid4())
|
|
860
|
+
q: asyncio.Queue[bool] = _make_async_queue()
|
|
861
|
+
self._app._pending_capability[req_id] = q
|
|
862
|
+
_emit({"type": "capability_request", "request_id": req_id,
|
|
863
|
+
"capability": capability})
|
|
864
|
+
granted = await q.get()
|
|
865
|
+
if not granted:
|
|
866
|
+
raise CapabilityDeniedError(
|
|
867
|
+
f"capability_denied: user denied '{capability}'"
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
@_blocking_emit_method
|
|
871
|
+
async def secret_get(self, key: str) -> "str | None":
|
|
872
|
+
"""Await until host returns the secret value (or None if denied).
|
|
873
|
+
|
|
874
|
+
From background threads:
|
|
875
|
+
``self.emit.run_sync(self.emit.secret_get(key))``.
|
|
876
|
+
"""
|
|
877
|
+
q: asyncio.Queue[str | None] = _make_async_queue()
|
|
878
|
+
self._app._pending_secret[key] = q
|
|
879
|
+
_emit({"type": "secret_get", "key": key})
|
|
880
|
+
return await q.get()
|
|
881
|
+
|
|
882
|
+
@_blocking_emit_method
|
|
883
|
+
async def get_secret(self, key: str) -> "str | None":
|
|
884
|
+
"""Alias for secret_get(). Preferred name going forward."""
|
|
885
|
+
return await self.secret_get(key)
|
|
886
|
+
|
|
887
|
+
@_blocking_emit_method
|
|
888
|
+
async def http_get(self, url: str) -> str:
|
|
889
|
+
"""HTTP GET brokered through the host. Requires net.http capability.
|
|
890
|
+
Raises RuntimeError on failure.
|
|
891
|
+
|
|
892
|
+
From background threads:
|
|
893
|
+
``self.emit.run_sync(self.emit.http_get(url))``.
|
|
894
|
+
"""
|
|
895
|
+
return await self.http_request(url)
|
|
896
|
+
|
|
897
|
+
@_blocking_emit_method
|
|
898
|
+
async def http_request(self, url: str, method: str = "GET",
|
|
899
|
+
headers: "dict[str, str] | None" = None,
|
|
900
|
+
body: "str | None" = None) -> str:
|
|
901
|
+
"""HTTP request brokered through the host. Requires net.http capability.
|
|
902
|
+
Supports custom method, headers, and body. Raises RuntimeError on failure.
|
|
903
|
+
|
|
904
|
+
From background threads:
|
|
905
|
+
``self.emit.run_sync(self.emit.http_request(...))``.
|
|
906
|
+
"""
|
|
907
|
+
req_id = str(uuid.uuid4())
|
|
908
|
+
q: asyncio.Queue[tuple[str, str]] = _make_async_queue()
|
|
909
|
+
self._app._pending_http[req_id] = q
|
|
910
|
+
payload: dict = {"type": "http_request", "request_id": req_id,
|
|
911
|
+
"method": method, "url": url}
|
|
912
|
+
if headers:
|
|
913
|
+
payload["headers"] = headers
|
|
914
|
+
if body is not None:
|
|
915
|
+
payload["body"] = body
|
|
916
|
+
_emit(payload)
|
|
917
|
+
status, value = await q.get()
|
|
918
|
+
if status == "error":
|
|
919
|
+
raise RuntimeError(f"http_request {url!r}: {value}")
|
|
920
|
+
return value
|
|
921
|
+
|
|
922
|
+
@_blocking_emit_method
|
|
923
|
+
async def load_image(self, src: str) -> str:
|
|
924
|
+
"""Request an async image fetch brokered through the host. Requires net.http capability.
|
|
925
|
+
|
|
926
|
+
Returns a stable handle ID. Pass the handle to ``ctx.image(handle, x, y, w, h)``
|
|
927
|
+
to render. While loading, ``ctx.image()`` renders a placeholder rect. On load
|
|
928
|
+
completion the host fires ``PlexiEvent::ImageLoaded`` which triggers a re-render.
|
|
929
|
+
|
|
930
|
+
Raises ``RuntimeError`` immediately if ``net.http`` is not declared in the manifest.
|
|
931
|
+
Raises ``RuntimeError`` if the fetch fails (network error, bad URL, decode error).
|
|
932
|
+
|
|
933
|
+
From background threads:
|
|
934
|
+
``self.emit.run_sync(self.emit.load_image(url))``.
|
|
935
|
+
"""
|
|
936
|
+
if "net.http" not in self._app.capabilities:
|
|
937
|
+
raise RuntimeError(
|
|
938
|
+
"load_image: net.http capability not declared in manifest — "
|
|
939
|
+
"add 'net.http' to [capabilities] in manifest.toml"
|
|
940
|
+
)
|
|
941
|
+
handle = str(uuid.uuid4())
|
|
942
|
+
q: asyncio.Queue[tuple[str, "str | None"]] = _make_async_queue()
|
|
943
|
+
self._app._pending_image[handle] = q
|
|
944
|
+
_emit({"type": "load_image", "handle": handle, "src": src})
|
|
945
|
+
status, message = await q.get()
|
|
946
|
+
if status == "error":
|
|
947
|
+
raise RuntimeError(f"load_image {src!r}: {message}")
|
|
948
|
+
return handle
|
|
949
|
+
|
|
950
|
+
@_blocking_emit_method
|
|
951
|
+
async def measure_text_wrapped(self, text: str, font_size: float,
|
|
952
|
+
max_width: float,
|
|
953
|
+
max_lines: "int | None" = None) -> float:
|
|
954
|
+
"""Measure the height of text wrapped at max_width using real host font metrics.
|
|
955
|
+
|
|
956
|
+
If `max_lines` is set, clamps the result to that many rows.
|
|
957
|
+
Returns height in logical pixels. Requires a running app loop.
|
|
958
|
+
"""
|
|
959
|
+
request_id = str(uuid.uuid4())
|
|
960
|
+
q: asyncio.Queue[float] = _make_async_queue()
|
|
961
|
+
self._app._pending_measure_text_wrapped[request_id] = q
|
|
962
|
+
payload: dict = {"type": "measure_text_wrapped", "request_id": request_id,
|
|
963
|
+
"text": text, "font_size": font_size, "max_width": max_width}
|
|
964
|
+
if max_lines is not None:
|
|
965
|
+
payload["max_lines"] = max_lines
|
|
966
|
+
_emit(payload)
|
|
967
|
+
try:
|
|
968
|
+
return await asyncio.wait_for(q.get(), timeout=10.0)
|
|
969
|
+
except asyncio.TimeoutError:
|
|
970
|
+
self._app._pending_measure_text_wrapped.pop(request_id, None)
|
|
971
|
+
raise RuntimeError(
|
|
972
|
+
f"measure_text_wrapped timed out after 10s (request_id={request_id!r})"
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
@_blocking_emit_method
|
|
976
|
+
async def ai_query(self, model_tier: str, system: str,
|
|
977
|
+
messages: "list[dict]",
|
|
978
|
+
tools: "list[dict] | None" = None) -> AiResponse:
|
|
979
|
+
"""Call into the host's Plexi AI broker (#284). Requires the
|
|
980
|
+
`ai.query` capability declared in `manifest.toml`.
|
|
981
|
+
|
|
982
|
+
Args:
|
|
983
|
+
model_tier: one of "low" | "medium" | "high" — the host maps these
|
|
984
|
+
to Haiku / Sonnet / Opus respectively.
|
|
985
|
+
system: system prompt (may be empty string).
|
|
986
|
+
messages: list of `{"role": "user"|"assistant", "content": str}`
|
|
987
|
+
dicts. At least one message is required.
|
|
988
|
+
tools: optional extra tool defs to include in this query. Tools
|
|
989
|
+
exposed by other panes in the same context via ``@self.tool()``
|
|
990
|
+
or ``expose_tools()`` are automatically injected by the host —
|
|
991
|
+
you do not need to pass them here.
|
|
992
|
+
|
|
993
|
+
Returns:
|
|
994
|
+
AiResponse with `content`, `tokens_in`, `tokens_out`.
|
|
995
|
+
|
|
996
|
+
Raises:
|
|
997
|
+
CapabilityDeniedError: app didn't declare `ai.query` in its manifest
|
|
998
|
+
(or the host returned any other "capability denied" error).
|
|
999
|
+
RuntimeError: backend failed (e.g. missing API key, network error).
|
|
1000
|
+
|
|
1001
|
+
From background threads:
|
|
1002
|
+
``self.emit.run_sync(self.emit.ai_query(...))``.
|
|
1003
|
+
"""
|
|
1004
|
+
if model_tier not in ("low", "medium", "high"):
|
|
1005
|
+
raise ValueError(
|
|
1006
|
+
f"ai_query: model_tier must be one of low|medium|high, got {model_tier!r}"
|
|
1007
|
+
)
|
|
1008
|
+
req_id = str(uuid.uuid4())
|
|
1009
|
+
q: asyncio.Queue[dict] = _make_async_queue()
|
|
1010
|
+
self._app._pending_ai[req_id] = q
|
|
1011
|
+
payload: dict = {
|
|
1012
|
+
"type": "ai_query",
|
|
1013
|
+
"request_id": req_id,
|
|
1014
|
+
"model_tier": model_tier,
|
|
1015
|
+
"system": system,
|
|
1016
|
+
"messages": messages,
|
|
1017
|
+
"tools": tools or [],
|
|
1018
|
+
}
|
|
1019
|
+
_emit(payload)
|
|
1020
|
+
try:
|
|
1021
|
+
ev = await asyncio.wait_for(q.get(), timeout=35.0)
|
|
1022
|
+
except asyncio.TimeoutError:
|
|
1023
|
+
self._app._pending_ai.pop(req_id, None)
|
|
1024
|
+
raise RuntimeError(
|
|
1025
|
+
f"ai_query timed out after 35s (req_id={req_id!r}) — "
|
|
1026
|
+
"check OPENROUTER_API_KEY and network connectivity"
|
|
1027
|
+
)
|
|
1028
|
+
error = ev.get("error")
|
|
1029
|
+
if error is not None:
|
|
1030
|
+
if "capability denied" in error:
|
|
1031
|
+
raise CapabilityDeniedError(error)
|
|
1032
|
+
raise RuntimeError(f"ai_query failed: {error}")
|
|
1033
|
+
return AiResponse(
|
|
1034
|
+
content=ev.get("content") or "",
|
|
1035
|
+
tokens_in=int(ev.get("tokens_in", 0) or 0),
|
|
1036
|
+
tokens_out=int(ev.get("tokens_out", 0) or 0),
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
def mcp_tool_result(self, call_id: str, result: "dict | None" = None, error: "str | None" = None) -> None:
|
|
1040
|
+
"""Send a McpToolResult response for a pending McpToolCall.
|
|
1041
|
+
|
|
1042
|
+
One of ``result`` or ``error`` must be provided (not both, not neither).
|
|
1043
|
+
``result`` should be a MCP CallToolResult dict, e.g.:
|
|
1044
|
+
``{"content": [{"type": "text", "text": "done"}]}``.
|
|
1045
|
+
"""
|
|
1046
|
+
if result is None and error is None:
|
|
1047
|
+
raise ValueError("mcp_tool_result: either 'result' or 'error' must be provided")
|
|
1048
|
+
if result is not None and error is not None:
|
|
1049
|
+
raise ValueError("mcp_tool_result: 'result' and 'error' are mutually exclusive")
|
|
1050
|
+
_emit({
|
|
1051
|
+
"type": "mcp_tool_result",
|
|
1052
|
+
"call_id": call_id,
|
|
1053
|
+
"result": result,
|
|
1054
|
+
"error": error,
|
|
1055
|
+
})
|
|
1056
|
+
|
|
1057
|
+
def expose_tools(self, tools: "list[dict]") -> None:
|
|
1058
|
+
"""Declare callable tools to the host (#398, #399).
|
|
1059
|
+
|
|
1060
|
+
Each entry in `tools` must have:
|
|
1061
|
+
- ``name`` (str): unique tool identifier.
|
|
1062
|
+
- ``description`` (str): human-readable description for the LLM.
|
|
1063
|
+
- ``input_schema`` (dict): JSON Schema object (type=object).
|
|
1064
|
+
- ``timeout_ms`` (int, optional): max ms to wait for result (default 30 000).
|
|
1065
|
+
|
|
1066
|
+
The host registers these tools in the global registry. When an AI turn
|
|
1067
|
+
requests a tool call the host sends a ``PlexiEvent::ToolCall``; the SDK
|
|
1068
|
+
routes it to the handler registered via ``@app.tool(…)``.
|
|
1069
|
+
"""
|
|
1070
|
+
_emit({"type": "expose_tools", "tools": tools})
|
|
1071
|
+
|
|
1072
|
+
@_blocking_emit_method
|
|
1073
|
+
async def list_midi_devices(self, timeout: float = 5.0) -> "MidiDeviceList":
|
|
1074
|
+
"""Enumerate CoreMIDI input + output ports (#320).
|
|
1075
|
+
No capability gate — port names are publicly visible in Audio MIDI
|
|
1076
|
+
Setup. Requires `midi.in` / `midi.out` only on subsequent open/send.
|
|
1077
|
+
|
|
1078
|
+
Returns a MidiDeviceList with `inputs` and `outputs` lists of
|
|
1079
|
+
MidiPortInfo (id, name, default).
|
|
1080
|
+
|
|
1081
|
+
Raises RuntimeError on host enumeration failure or timeout.
|
|
1082
|
+
|
|
1083
|
+
From background threads:
|
|
1084
|
+
``self.emit.run_sync(self.emit.list_midi_devices())``.
|
|
1085
|
+
"""
|
|
1086
|
+
req_id = str(uuid.uuid4())
|
|
1087
|
+
q: asyncio.Queue[dict] = _make_async_queue()
|
|
1088
|
+
self._app._pending_midi_devices[req_id] = q
|
|
1089
|
+
_emit({"type": "list_midi_devices", "request_id": req_id})
|
|
1090
|
+
try:
|
|
1091
|
+
ev = await asyncio.wait_for(q.get(), timeout=timeout)
|
|
1092
|
+
except asyncio.TimeoutError:
|
|
1093
|
+
self._app._pending_midi_devices.pop(req_id, None)
|
|
1094
|
+
raise RuntimeError(
|
|
1095
|
+
f"list_midi_devices timed out after {timeout}s — host did not respond"
|
|
1096
|
+
)
|
|
1097
|
+
if ev.get("error"):
|
|
1098
|
+
raise RuntimeError(f"list_midi_devices failed: {ev.get('error')}")
|
|
1099
|
+
return MidiDeviceList(
|
|
1100
|
+
inputs=[
|
|
1101
|
+
MidiPortInfo(
|
|
1102
|
+
id=str(p.get("id", "")),
|
|
1103
|
+
name=str(p.get("name", "")),
|
|
1104
|
+
default=bool(p.get("default", False)),
|
|
1105
|
+
)
|
|
1106
|
+
for p in ev.get("inputs", [])
|
|
1107
|
+
],
|
|
1108
|
+
outputs=[
|
|
1109
|
+
MidiPortInfo(
|
|
1110
|
+
id=str(p.get("id", "")),
|
|
1111
|
+
name=str(p.get("name", "")),
|
|
1112
|
+
default=bool(p.get("default", False)),
|
|
1113
|
+
)
|
|
1114
|
+
for p in ev.get("outputs", [])
|
|
1115
|
+
],
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
@_blocking_emit_method
|
|
1119
|
+
async def list_audio_devices(self, timeout: float = 5.0) -> "AudioDeviceList":
|
|
1120
|
+
"""Enumerate CoreAudio input + output devices (#341).
|
|
1121
|
+
No capability gate — device names are publicly visible.
|
|
1122
|
+
Requires ``audio.record`` / ``audio.playback`` only on subsequent capture/play.
|
|
1123
|
+
|
|
1124
|
+
Returns an AudioDeviceList with ``inputs`` and ``outputs`` lists of
|
|
1125
|
+
AudioDeviceInfo (id, name, default).
|
|
1126
|
+
|
|
1127
|
+
Raises RuntimeError on host enumeration failure or timeout.
|
|
1128
|
+
|
|
1129
|
+
From background threads:
|
|
1130
|
+
``self.emit.run_sync(self.emit.list_audio_devices())``.
|
|
1131
|
+
"""
|
|
1132
|
+
req_id = str(uuid.uuid4())
|
|
1133
|
+
q: asyncio.Queue[dict] = _make_async_queue()
|
|
1134
|
+
self._app._pending_audio_devices[req_id] = q
|
|
1135
|
+
_emit({"type": "list_audio_devices", "request_id": req_id})
|
|
1136
|
+
try:
|
|
1137
|
+
ev = await asyncio.wait_for(q.get(), timeout=timeout)
|
|
1138
|
+
except asyncio.TimeoutError:
|
|
1139
|
+
self._app._pending_audio_devices.pop(req_id, None)
|
|
1140
|
+
raise RuntimeError(
|
|
1141
|
+
f"list_audio_devices timed out after {timeout}s — host did not respond"
|
|
1142
|
+
)
|
|
1143
|
+
if ev.get("error"):
|
|
1144
|
+
raise RuntimeError(f"list_audio_devices failed: {ev.get('error')}")
|
|
1145
|
+
return AudioDeviceList(
|
|
1146
|
+
inputs=[
|
|
1147
|
+
AudioDeviceInfo(
|
|
1148
|
+
id=str(p.get("id", "")),
|
|
1149
|
+
name=str(p.get("name", "")),
|
|
1150
|
+
default=bool(p.get("default", False)),
|
|
1151
|
+
)
|
|
1152
|
+
for p in ev.get("inputs", [])
|
|
1153
|
+
],
|
|
1154
|
+
outputs=[
|
|
1155
|
+
AudioDeviceInfo(
|
|
1156
|
+
id=str(p.get("id", "")),
|
|
1157
|
+
name=str(p.get("name", "")),
|
|
1158
|
+
default=bool(p.get("default", False)),
|
|
1159
|
+
)
|
|
1160
|
+
for p in ev.get("outputs", [])
|
|
1161
|
+
],
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
def open_midi_input(self, port_id: str, pipe_id: str) -> "Pipe":
|
|
1165
|
+
"""Open a CoreMIDI input port and pipe its MIDI byte streams into a
|
|
1166
|
+
binary pipe. Requires `midi.in` capability declared in manifest.toml.
|
|
1167
|
+
|
|
1168
|
+
Returns the Pipe handle. The host emits `PipeOpened` then
|
|
1169
|
+
`MidiInputOpened`; the Pipe auto-connects to its socket on
|
|
1170
|
+
`PipeOpened`. Apps then call `pipe.read_frame()` in a loop to
|
|
1171
|
+
receive 1–3 byte MIDI messages (channel-voice / system real-time).
|
|
1172
|
+
|
|
1173
|
+
Each pipe frame is one MIDI 1.0 message — apps don't need to parse
|
|
1174
|
+
message boundaries themselves.
|
|
1175
|
+
"""
|
|
1176
|
+
# Import here to avoid circular import at module load time.
|
|
1177
|
+
from ._pipe import Pipe
|
|
1178
|
+
# Open the binary pipe FIRST so the App's `_pipes` registry has it
|
|
1179
|
+
# before PipeOpened arrives. The host emits PipeOpened then
|
|
1180
|
+
# MidiInputOpened in order; both events are received by the event
|
|
1181
|
+
# loop and forwarded to the Pipe / on_midi_input_opened handler.
|
|
1182
|
+
p = Pipe(pipe_id=pipe_id, mode="binary", direction="in", app=self._app)
|
|
1183
|
+
self._app._pipes[pipe_id] = p
|
|
1184
|
+
_emit({
|
|
1185
|
+
"type": "open_midi_input",
|
|
1186
|
+
"port_id": port_id,
|
|
1187
|
+
"pipe_id": pipe_id,
|
|
1188
|
+
})
|
|
1189
|
+
return p
|
|
1190
|
+
|
|
1191
|
+
def close_midi_input(self, port_id: str) -> None:
|
|
1192
|
+
"""Close a previously-opened MIDI input port. The associated binary
|
|
1193
|
+
pipe drains and closes; the app should drop its Pipe handle."""
|
|
1194
|
+
_emit({"type": "close_midi_input", "port_id": port_id})
|
|
1195
|
+
|
|
1196
|
+
def send_midi(self, port_id: str, bytes_: "bytes | bytearray | list[int]") -> None:
|
|
1197
|
+
"""Fire-and-forget send of one MIDI 1.0 byte stream to `port_id`.
|
|
1198
|
+
Requires `midi.out` capability. The host opens the output port lazily
|
|
1199
|
+
on the first send and reuses the handle afterwards.
|
|
1200
|
+
|
|
1201
|
+
`bytes_` is a 1–3 byte sequence: NoteOn `[0x90+ch, note, vel]`,
|
|
1202
|
+
NoteOff `[0x80+ch, note, vel]`, CC `[0xB0+ch, num, val]`, clock
|
|
1203
|
+
pulse `[0xF8]`, etc. Use `plexi_sdk.midi` helpers to construct
|
|
1204
|
+
messages with named arguments.
|
|
1205
|
+
|
|
1206
|
+
Successful sends produce no event; failures arrive as a logged
|
|
1207
|
+
`midi_send_error` warning. Apps that need explicit error handling
|
|
1208
|
+
should validate the destination via `list_midi_devices()` first.
|
|
1209
|
+
"""
|
|
1210
|
+
if isinstance(bytes_, (bytes, bytearray)):
|
|
1211
|
+
data = list(bytes_)
|
|
1212
|
+
else:
|
|
1213
|
+
data = list(bytes_)
|
|
1214
|
+
# JSON wire shape: array of integers. The host accepts any ints in
|
|
1215
|
+
# 0..=255 and rejects empty arrays.
|
|
1216
|
+
_emit({"type": "send_midi", "port_id": port_id, "bytes": data})
|
|
1217
|
+
|
|
1218
|
+
@_blocking_emit_method
|
|
1219
|
+
async def open_video(
|
|
1220
|
+
self,
|
|
1221
|
+
source: str,
|
|
1222
|
+
pipe_id: str,
|
|
1223
|
+
timeout: float = 10.0,
|
|
1224
|
+
) -> "VideoHandle":
|
|
1225
|
+
"""Open a video decoder against `source` and return a VideoHandle
|
|
1226
|
+
carrying the negotiated width/height/fps/duration_ms plus the
|
|
1227
|
+
attached Pipe (#345). Requires `video.playback` capability.
|
|
1228
|
+
|
|
1229
|
+
**Pre-v1 note:** `video.playback` is not production-ready. The
|
|
1230
|
+
`file:///` decoder returns NotImplemented in all shipping builds
|
|
1231
|
+
until AVFoundation backing lands (#346). Only `mock://gradient`
|
|
1232
|
+
works today. Do not ship apps that depend on real video decoding.
|
|
1233
|
+
|
|
1234
|
+
Decoded frames flow as raw RGBA8 packets on the Pipe — one packet
|
|
1235
|
+
per video frame, length `width * height * 4`. Apps spin a reader
|
|
1236
|
+
thread that calls `handle.pipe.read_frame()` and either renders
|
|
1237
|
+
the bytes (if a frame-render API is available) or surfaces a
|
|
1238
|
+
frame counter.
|
|
1239
|
+
|
|
1240
|
+
Source schemes:
|
|
1241
|
+
- `mock://gradient` — procedural mock decoder (always works).
|
|
1242
|
+
- `file:///...` — production decoder; returns NotImplemented
|
|
1243
|
+
until #346 lands AVFoundation backing.
|
|
1244
|
+
|
|
1245
|
+
Raises CapabilityDeniedError if `video.playback` is not declared.
|
|
1246
|
+
Raises RuntimeError on any other failure (NotImplemented, bad
|
|
1247
|
+
source, decoder error, timeout).
|
|
1248
|
+
|
|
1249
|
+
From background threads:
|
|
1250
|
+
``self.emit.run_sync(self.emit.open_video(source, pipe_id))``.
|
|
1251
|
+
"""
|
|
1252
|
+
from ._pipe import Pipe
|
|
1253
|
+
from ._types import VideoHandle
|
|
1254
|
+
# Open the binary pipe FIRST so the App's `_pipes` registry has it
|
|
1255
|
+
# before PipeOpened arrives. The host emits PipeOpened, then
|
|
1256
|
+
# VideoOpenAck. The Pipe auto-connects to its socket on PipeOpened.
|
|
1257
|
+
pipe = Pipe(pipe_id=pipe_id, mode="binary", direction="in", app=self._app)
|
|
1258
|
+
self._app._pipes[pipe_id] = pipe
|
|
1259
|
+
|
|
1260
|
+
req_id = str(uuid.uuid4())
|
|
1261
|
+
q: asyncio.Queue[dict] = _make_async_queue()
|
|
1262
|
+
self._app._pending_video_open[req_id] = q
|
|
1263
|
+
_emit({
|
|
1264
|
+
"type": "open_video",
|
|
1265
|
+
"request_id": req_id,
|
|
1266
|
+
"source": source,
|
|
1267
|
+
"pipe_id": pipe_id,
|
|
1268
|
+
})
|
|
1269
|
+
try:
|
|
1270
|
+
ev = await asyncio.wait_for(q.get(), timeout=timeout)
|
|
1271
|
+
except asyncio.TimeoutError:
|
|
1272
|
+
self._app._pending_video_open.pop(req_id, None)
|
|
1273
|
+
raise RuntimeError(
|
|
1274
|
+
f"open_video timed out after {timeout}s — host did not respond"
|
|
1275
|
+
)
|
|
1276
|
+
error = ev.get("error")
|
|
1277
|
+
if error is not None:
|
|
1278
|
+
if "capability denied" in error:
|
|
1279
|
+
raise CapabilityDeniedError(error)
|
|
1280
|
+
raise RuntimeError(f"open_video failed: {error}")
|
|
1281
|
+
return VideoHandle(
|
|
1282
|
+
handle_id=int(ev.get("handle_id", 0)),
|
|
1283
|
+
width=int(ev.get("width", 0)),
|
|
1284
|
+
height=int(ev.get("height", 0)),
|
|
1285
|
+
fps=float(ev.get("fps", 0.0)),
|
|
1286
|
+
duration_ms=int(ev.get("duration_ms", 0)),
|
|
1287
|
+
pipe=pipe,
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
def set_video_state(self, handle_id: int, state: str, position_ms: int = 0) -> None:
|
|
1291
|
+
"""Drive playback for a video opened with `open_video` (#345).
|
|
1292
|
+
`state` is one of `"play"`, `"pause"`, `"seek"`. For `"seek"`,
|
|
1293
|
+
`position_ms` is the absolute target position in milliseconds.
|
|
1294
|
+
Fire-and-forget — no response event."""
|
|
1295
|
+
if state == "seek":
|
|
1296
|
+
payload: dict[str, Any] = {"kind": "seek", "position_ms": int(position_ms)}
|
|
1297
|
+
elif state == "play":
|
|
1298
|
+
payload = {"kind": "play"}
|
|
1299
|
+
elif state == "pause":
|
|
1300
|
+
payload = {"kind": "pause"}
|
|
1301
|
+
else:
|
|
1302
|
+
raise ValueError(
|
|
1303
|
+
f"set_video_state: unknown state {state!r} (expected play|pause|seek)"
|
|
1304
|
+
)
|
|
1305
|
+
_emit({
|
|
1306
|
+
"type": "set_video_state",
|
|
1307
|
+
"handle_id": int(handle_id),
|
|
1308
|
+
"state": payload,
|
|
1309
|
+
})
|
|
1310
|
+
|
|
1311
|
+
def close_video(self, handle_id: int) -> None:
|
|
1312
|
+
"""Close a previously-opened video handle (#345). The host tears down
|
|
1313
|
+
the decoder and drains the binary pipe. No response event."""
|
|
1314
|
+
_emit({"type": "close_video", "handle_id": int(handle_id)})
|
|
1315
|
+
|
|
1316
|
+
def audio_capture(
|
|
1317
|
+
self,
|
|
1318
|
+
pipe_id: str,
|
|
1319
|
+
sample_rate: int = 48000,
|
|
1320
|
+
buffer_size: int = 512,
|
|
1321
|
+
device_id: "str | None" = None,
|
|
1322
|
+
) -> "Pipe":
|
|
1323
|
+
"""Start mic capture (#277). PCM frames stream as raw f32 PCM on
|
|
1324
|
+
a binary pipe — interleaved per-channel, ``sample_rate`` per
|
|
1325
|
+
channel per second.
|
|
1326
|
+
|
|
1327
|
+
Returns the binary Pipe immediately (negotiated values arrive
|
|
1328
|
+
async via ``PlexiEvent::AudioCaptureStarted`` and the host log;
|
|
1329
|
+
the app reads ``handle.read_frame()`` once it connects). Failure
|
|
1330
|
+
arrives as ``PlexiEvent::AudioCaptureError``.
|
|
1331
|
+
|
|
1332
|
+
If ``pipe_id`` is already registered (i.e. the app is restarting
|
|
1333
|
+
capture), the old Pipe is closed before the new one is created —
|
|
1334
|
+
prevents OSError on the reader thread's recv() from a stale socket.
|
|
1335
|
+
|
|
1336
|
+
Requires ``audio.in`` capability. Raises ``CapabilityDeniedError``
|
|
1337
|
+
only when the gate fires synchronously at the wire layer; the
|
|
1338
|
+
usual TCC mic-permission denial surfaces async on the first frame
|
|
1339
|
+
attempt as an ``AudioCaptureError`` event.
|
|
1340
|
+
"""
|
|
1341
|
+
from ._pipe import Pipe
|
|
1342
|
+
# Close any existing pipe for this id before replacing — prevents
|
|
1343
|
+
# orphaned sockets when the app restarts capture with the same pipe_id.
|
|
1344
|
+
existing = self._app._pipes.pop(pipe_id, None)
|
|
1345
|
+
if existing is not None:
|
|
1346
|
+
existing.close()
|
|
1347
|
+
# Open the binary pipe FIRST so PipeOpened can attach when it
|
|
1348
|
+
# arrives — same shape as ``open_video``.
|
|
1349
|
+
pipe = Pipe(pipe_id=pipe_id, mode="binary", direction="in", app=self._app)
|
|
1350
|
+
self._app._pipes[pipe_id] = pipe
|
|
1351
|
+
_emit({
|
|
1352
|
+
"type": "audio_capture",
|
|
1353
|
+
"pipe_id": pipe_id,
|
|
1354
|
+
"device_id": device_id,
|
|
1355
|
+
"sample_rate": int(sample_rate),
|
|
1356
|
+
"buffer_size": int(buffer_size),
|
|
1357
|
+
})
|
|
1358
|
+
return pipe
|
|
1359
|
+
|
|
1360
|
+
def stop_audio_capture(self, pipe_id: str) -> None:
|
|
1361
|
+
"""Stop an active audio capture and close its pipe (#862).
|
|
1362
|
+
|
|
1363
|
+
Closes the Pipe registered under ``pipe_id`` and removes it from
|
|
1364
|
+
the internal registry. The host cleans up the stale session on the
|
|
1365
|
+
next ``AudioCapture`` for the same ``pipe_id``. No host command is
|
|
1366
|
+
needed — closing the socket is sufficient.
|
|
1367
|
+
|
|
1368
|
+
Apps should join any reader thread after calling this to avoid
|
|
1369
|
+
reading from a closed socket::
|
|
1370
|
+
|
|
1371
|
+
self.emit.stop_audio_capture("mic")
|
|
1372
|
+
self._reader_thread.join(timeout=2.0)
|
|
1373
|
+
|
|
1374
|
+
No-op if ``pipe_id`` is not registered.
|
|
1375
|
+
"""
|
|
1376
|
+
pipe = self._app._pipes.pop(pipe_id, None)
|
|
1377
|
+
if pipe is not None:
|
|
1378
|
+
pipe.close()
|
|
1379
|
+
|
|
1380
|
+
def audio_play(self, source: str, volume: float = 1.0) -> None:
|
|
1381
|
+
"""Play an audio file via the host (rodio). Requires audio.playback capability.
|
|
1382
|
+
|
|
1383
|
+
source: absolute path to audio file (mp3, wav, ogg, etc.)
|
|
1384
|
+
volume: playback volume 0.0–1.0 (default 1.0)
|
|
1385
|
+
|
|
1386
|
+
Fire-and-forget — no response event. Playback stops when the pane
|
|
1387
|
+
closes; there is no explicit stop API yet.
|
|
1388
|
+
"""
|
|
1389
|
+
_emit({
|
|
1390
|
+
"type": "audio_play",
|
|
1391
|
+
"source": source,
|
|
1392
|
+
"state": "playing",
|
|
1393
|
+
"volume": float(volume),
|
|
1394
|
+
})
|
|
1395
|
+
|
|
1396
|
+
def set_timer(self, timer_id: str, after_ms: int) -> None:
|
|
1397
|
+
"""Fire PlexiEvent::Timer after after_ms milliseconds. Requires timer capability."""
|
|
1398
|
+
_emit({"type": "set_timer", "timer_id": timer_id, "after_ms": after_ms})
|
|
1399
|
+
|
|
1400
|
+
def cancel_timer(self, timer_id: str) -> None:
|
|
1401
|
+
"""Cancel a pending timer set with set_timer()."""
|
|
1402
|
+
_emit({"type": "cancel_timer", "timer_id": timer_id})
|
|
1403
|
+
|
|
1404
|
+
def set_mouse_tracking(self, enabled: bool) -> None:
|
|
1405
|
+
"""Enable or disable PlexiEvent::MouseMove delivery for this pane.
|
|
1406
|
+
|
|
1407
|
+
MouseMove is off by default to avoid flooding apps that don't need
|
|
1408
|
+
continuous pointer tracking. Call set_mouse_tracking(True) after
|
|
1409
|
+
on_init to start receiving on_mouse_move callbacks. Call with False
|
|
1410
|
+
to stop.
|
|
1411
|
+
"""
|
|
1412
|
+
_emit({"type": "set_mouse_tracking", "enabled": enabled})
|
|
1413
|
+
|
|
1414
|
+
# Binary pipe
|
|
1415
|
+
def pipe_open(self, pipe_id: str, mode: str = "binary",
|
|
1416
|
+
direction: str = "in") -> "Pipe":
|
|
1417
|
+
"""Open a typed pipe. Returns a Pipe handle. mode: json|binary. direction: in|out|duplex."""
|
|
1418
|
+
from ._pipe import Pipe
|
|
1419
|
+
p = Pipe(pipe_id=pipe_id, mode=mode, direction=direction, app=self._app)
|
|
1420
|
+
self._app._pipes[pipe_id] = p
|
|
1421
|
+
_emit({"type": "pipe_open", "pipe_id": pipe_id,
|
|
1422
|
+
"mode": mode, "direction": direction})
|
|
1423
|
+
return p
|
|
1424
|
+
|
|
1425
|
+
def pipe_open_directed(self, pipe_id: str, target_pane_id: int) -> "Pipe":
|
|
1426
|
+
"""Open a *directed* JSON pipe to one specific target pane (#286).
|
|
1427
|
+
|
|
1428
|
+
Mirrors `pipe_open` but the host scopes `PipeMessage` delivery so
|
|
1429
|
+
only the caller and `target_pane_id` see traffic on `pipe_id` —
|
|
1430
|
+
peers that coincidentally `pipe_open` the same id stay isolated.
|
|
1431
|
+
Always JSON-mode duplex; `pipe.send(payload)` is symmetric on
|
|
1432
|
+
either end.
|
|
1433
|
+
|
|
1434
|
+
Capability: `pipe.open` (same as `pipe_open`).
|
|
1435
|
+
"""
|
|
1436
|
+
from ._pipe import Pipe
|
|
1437
|
+
p = Pipe(pipe_id=pipe_id, mode="json", direction="duplex", app=self._app)
|
|
1438
|
+
self._app._pipes[pipe_id] = p
|
|
1439
|
+
_emit({
|
|
1440
|
+
"type": "pipe_open_directed",
|
|
1441
|
+
"pipe_id": pipe_id,
|
|
1442
|
+
"target_pane_id": int(target_pane_id),
|
|
1443
|
+
})
|
|
1444
|
+
return p
|
|
1445
|
+
|
|
1446
|
+
def pipe_send(self, pipe_id: str, payload: Any) -> None:
|
|
1447
|
+
"""Send a JSON payload on an already-open pipe by id (#286).
|
|
1448
|
+
|
|
1449
|
+
Use this when you don't own a `Pipe` handle locally — for example
|
|
1450
|
+
when replying on a directed pipe a *peer* opened (you're the target;
|
|
1451
|
+
the host subscribed you, but the SDK doesn't auto-build a handle).
|
|
1452
|
+
Sends a `pipe_send` DrawCommand; the host routes per the existing
|
|
1453
|
+
directed-pipe pair table.
|
|
1454
|
+
"""
|
|
1455
|
+
_emit({"type": "pipe_send", "pipe_id": pipe_id, "payload": payload})
|
|
1456
|
+
|
|
1457
|
+
def close_self(self) -> None:
|
|
1458
|
+
"""Ask the host to close this app's pane gracefully.
|
|
1459
|
+
|
|
1460
|
+
Prefer this over sys.exit() — sys.exit() exits the process but the
|
|
1461
|
+
host marks the pane as Crashed and restart-loops watched apps.
|
|
1462
|
+
close_self() sends ControlCommand::CloseSelf; the host closes the
|
|
1463
|
+
pane via the normal wants_close path on the next frame.
|
|
1464
|
+
"""
|
|
1465
|
+
_emit({"type": "close_self"})
|
|
1466
|
+
|