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/_app.py
ADDED
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, Coroutine
|
|
8
|
+
|
|
9
|
+
from ._protocol import PROTOCOL_VERSION
|
|
10
|
+
from ._constants import _SDK_VERSION, BG as _DEFAULT_BG
|
|
11
|
+
from ._emitter import Emitter, _emit, _sync_hook_scope
|
|
12
|
+
from ._pipe import Pipe
|
|
13
|
+
from ._render_context import RenderContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _log_task_exception(task: asyncio.Task) -> None:
|
|
17
|
+
"""Done callback for background tasks — logs unhandled exceptions."""
|
|
18
|
+
try:
|
|
19
|
+
exc = task.exception()
|
|
20
|
+
except (asyncio.CancelledError, asyncio.InvalidStateError):
|
|
21
|
+
return
|
|
22
|
+
if exc is not None:
|
|
23
|
+
sys.stderr.write(f"plexi_sdk: unhandled exception in background task: {exc}\n")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Map egui's Debug-format key names to the documented canonical SDK names.
|
|
27
|
+
# The host sends "ArrowLeft" etc. (egui Key::ArrowLeft Debug repr); apps
|
|
28
|
+
# should use "left"/"right"/"up"/"down" as documented. Normalizing here
|
|
29
|
+
# means both forms work correctly — agents and humans can use the documented
|
|
30
|
+
# names without knowing egui's internal representation.
|
|
31
|
+
_KEY_ALIASES: "dict[str, str]" = {
|
|
32
|
+
"ArrowLeft": "left",
|
|
33
|
+
"ArrowRight": "right",
|
|
34
|
+
"ArrowUp": "up",
|
|
35
|
+
"ArrowDown": "down",
|
|
36
|
+
# egui Debug-format names → SDK canonical names
|
|
37
|
+
"Enter": "return",
|
|
38
|
+
"Escape": "escape",
|
|
39
|
+
"Backspace": "backspace",
|
|
40
|
+
"Tab": "tab",
|
|
41
|
+
# Space arrives as Event::Text(" "), not Event::Key
|
|
42
|
+
" ": "space",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _normalize_key(key: str) -> str:
|
|
47
|
+
return _KEY_ALIASES.get(key, key)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── App base class ────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
class App:
|
|
53
|
+
"""
|
|
54
|
+
Base class for Plexi v3 apps. Subclass and override event handlers.
|
|
55
|
+
|
|
56
|
+
Override any of:
|
|
57
|
+
|
|
58
|
+
Awaited (block the event loop until they return):
|
|
59
|
+
on_init(ctx) — after Init handshake
|
|
60
|
+
on_render(ctx) — on each Render event
|
|
61
|
+
on_suspend() — on Suspend
|
|
62
|
+
on_resume() — on Resume
|
|
63
|
+
on_shutdown() — on Shutdown
|
|
64
|
+
|
|
65
|
+
Task (dispatched as asyncio tasks — event loop continues):
|
|
66
|
+
on_key(ctx, key, mods) — on Key event
|
|
67
|
+
on_click(ctx, x, y, button) — on Click event
|
|
68
|
+
on_mouse_down(ctx, x, y, button) — on MouseDown event
|
|
69
|
+
on_mouse_up(ctx, x, y, button) — on MouseUp event
|
|
70
|
+
on_mouse_move(ctx, x, y, buttons) — on MouseMove event
|
|
71
|
+
on_command(ctx, text) — on Command event
|
|
72
|
+
on_paste(ctx, text) — on Paste event
|
|
73
|
+
on_text_submitted(ctx, id, text) — on TextInput Enter press
|
|
74
|
+
on_pipe_message(ctx, pipe_id, payload) — on PipeMessage
|
|
75
|
+
on_path_changed(ctx, cwd) — on PathChanged
|
|
76
|
+
on_inject(ctx, payload) — on Inject event
|
|
77
|
+
on_nav_back(ctx, view_id) — on NavBack event
|
|
78
|
+
on_timer(ctx, timer_id) — on Timer event
|
|
79
|
+
on_scroll(ctx, id, offset_y) — on Scroll event
|
|
80
|
+
on_file_picked(ctx, request_id, paths) — on FilePicked event
|
|
81
|
+
on_file_pick_cancelled(ctx, request_id) — on FilePickCancelled
|
|
82
|
+
on_mcp_call(ctx, tool_name, arguments) — on MCP tool call
|
|
83
|
+
|
|
84
|
+
Fire-and-forget (no RenderContext — called outside a render frame):
|
|
85
|
+
on_pane_spawned(pane_id, request_id) — pane spawn succeeded
|
|
86
|
+
on_pane_spawn_error(reason, request_id) — pane spawn failed
|
|
87
|
+
on_context_state(state) — context state query result
|
|
88
|
+
on_midi_input_opened(pipe_id, port_id, port_name) — MIDI input opened
|
|
89
|
+
|
|
90
|
+
Task handlers are dispatched as asyncio tasks — the event loop does not
|
|
91
|
+
wait for them to complete before processing the next event. Declare them
|
|
92
|
+
``async def`` whenever they do any I/O. Never call blocking operations
|
|
93
|
+
(time.sleep, requests.get, etc.) directly from these handlers; use
|
|
94
|
+
``await asyncio.to_thread(fn)`` or ``threading.Thread`` + ``emit.run_sync()``.
|
|
95
|
+
|
|
96
|
+
Awaited handlers block the event loop until they return. Use ``await``-able
|
|
97
|
+
Emitter helpers freely; they do not deadlock because the stdin reader runs
|
|
98
|
+
as a concurrent task.
|
|
99
|
+
|
|
100
|
+
Fire-and-forget handlers do not receive a RenderContext because they are
|
|
101
|
+
dispatched outside a render frame.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
# Background color applied automatically before each on_render call.
|
|
105
|
+
# Set to None to disable the default background and manage clearing manually.
|
|
106
|
+
default_background: "str | None" = _DEFAULT_BG
|
|
107
|
+
|
|
108
|
+
def __init__(self) -> None:
|
|
109
|
+
self._sdk_initialized: bool = True
|
|
110
|
+
self.app_id: str = ""
|
|
111
|
+
self.args: list[str] = sys.argv[1:]
|
|
112
|
+
self.workspace_root: str = ""
|
|
113
|
+
self.capabilities: list[str] = []
|
|
114
|
+
self.feature_flags: list[str] = []
|
|
115
|
+
self._rect: dict = {"x": 0.0, "y": 0.0, "w": 800.0, "h": 600.0}
|
|
116
|
+
self._compact_threshold: float = 280.0
|
|
117
|
+
self._regular_threshold: float = 480.0
|
|
118
|
+
# The running asyncio event loop. Set by run() before hooks are called.
|
|
119
|
+
# Background threads use this via emit.run_sync() to schedule coroutines.
|
|
120
|
+
self._loop: "asyncio.AbstractEventLoop | None" = None
|
|
121
|
+
# All pending-response maps now hold asyncio.Queue so the event loop
|
|
122
|
+
# coroutine can await them without blocking the stdin reader.
|
|
123
|
+
self._pending_capability: "dict[str, asyncio.Queue]" = {}
|
|
124
|
+
self._pending_secret: "dict[str, asyncio.Queue]" = {}
|
|
125
|
+
self._app_state: dict = {}
|
|
126
|
+
self._pending_http: "dict[str, asyncio.Queue]" = {}
|
|
127
|
+
# v3.x async image loading (#1354): awaits PlexiEvent::ImageLoaded keyed
|
|
128
|
+
# on handle UUID. Each entry is consumed by a single load_image() call.
|
|
129
|
+
self._pending_image: "dict[str, asyncio.Queue]" = {}
|
|
130
|
+
# v3.3 ai.query broker (#284): awaits PlexiEvent::AiResponse keyed
|
|
131
|
+
# on request_id. Each entry is consumed by a single ai_query() call.
|
|
132
|
+
self._pending_ai: "dict[str, asyncio.Queue]" = {}
|
|
133
|
+
# v3.4 CoreMIDI (#320): awaits PlexiEvent::MidiDevicesListed keyed
|
|
134
|
+
# on request_id. Each entry is consumed by a single list_midi_devices().
|
|
135
|
+
self._pending_midi_devices: "dict[str, asyncio.Queue]" = {}
|
|
136
|
+
# v3.4 audio device enumeration (#341): awaits PlexiEvent::AudioDevicesListed keyed
|
|
137
|
+
# on request_id. Each entry is consumed by a single list_audio_devices() call.
|
|
138
|
+
self._pending_audio_devices: "dict[str, asyncio.Queue]" = {}
|
|
139
|
+
# v3.4 video substrate (#345): awaits PlexiEvent::VideoOpenAck /
|
|
140
|
+
# VideoOpenError keyed on request_id. Each entry is consumed by a
|
|
141
|
+
# single open_video() call.
|
|
142
|
+
self._pending_video_open: "dict[str, asyncio.Queue]" = {}
|
|
143
|
+
self._pending_notify: "dict[str, asyncio.Queue]" = {}
|
|
144
|
+
# #310: non-blocking notify_*_async callbacks. Keyed on notify_id;
|
|
145
|
+
# each callable is invoked on the event thread when NotifyAction arrives.
|
|
146
|
+
self._pending_notify_callbacks: "dict[str, Any]" = {}
|
|
147
|
+
# v3.5 Canvas Terminal Binding Primitives (#78). Two response shapes:
|
|
148
|
+
# `linked_terminal_ready` carries an int pane_id; `command_preview`
|
|
149
|
+
# carries (command, would_run_in_cwd). Each async helper awaits
|
|
150
|
+
# its own keyed queue.
|
|
151
|
+
self._pending_linked_terminal: "dict[str, asyncio.Queue]" = {}
|
|
152
|
+
self._pending_command_preview: "dict[str, asyncio.Queue]" = {}
|
|
153
|
+
# RenderContext.measure_text: awaits PlexiEvent::TextMeasured keyed on request_id.
|
|
154
|
+
self._pending_measure_text: "dict[str, asyncio.Queue]" = {}
|
|
155
|
+
# RenderContext.measure_text_wrapped: awaits PlexiEvent::TextWrappedMeasured.
|
|
156
|
+
self._pending_measure_text_wrapped: "dict[str, asyncio.Queue]" = {}
|
|
157
|
+
self._pipes: dict[str, Pipe] = {}
|
|
158
|
+
self._last_render_time: "float | None" = None
|
|
159
|
+
self._consecutive_render_errors: int = 0
|
|
160
|
+
# Strong references to background asyncio tasks created by
|
|
161
|
+
# _dispatch_hook_task. Without this, CPython may GC a task before it
|
|
162
|
+
# completes. The done callback removes each task from the set.
|
|
163
|
+
self._background_tasks: "set[asyncio.Task]" = set()
|
|
164
|
+
# Pending text-input submissions keyed on TextInput `id`. The
|
|
165
|
+
# event-loop coroutine fills this when `PlexiEvent::TextSubmitted`
|
|
166
|
+
# arrives; `RenderContext.text_input` drains it during render.
|
|
167
|
+
# One pending value per id — a second submit before the app
|
|
168
|
+
# consumes the first overwrites (apps poll every frame, so
|
|
169
|
+
# this only matters in a perverse scheduling case).
|
|
170
|
+
self._text_submissions: dict[str, str] = {}
|
|
171
|
+
# v3.7 tool protocol (#399): tool_name → handler callable.
|
|
172
|
+
# Registered via @app.tool(...) decorator.
|
|
173
|
+
self._tool_handlers: dict[str, Any] = {}
|
|
174
|
+
# Keep the full declared tool set so repeated @app.tool decorator
|
|
175
|
+
# calls expose the cumulative list instead of replacing prior tools.
|
|
176
|
+
self._tool_defs: dict[str, dict] = {}
|
|
177
|
+
self.emit = Emitter(self)
|
|
178
|
+
# v3.x button primitive (#255): last known mouse position and buffered
|
|
179
|
+
# click events for ctx.button() hit-testing during on_render.
|
|
180
|
+
self._mx: float = 0.0
|
|
181
|
+
self._my: float = 0.0
|
|
182
|
+
self._click_buf: list[tuple[float, float]] = []
|
|
183
|
+
# Hold timer for ctx.button() active_fill (#1083): maps button id →
|
|
184
|
+
# monotonic timestamp at which the active state expires.
|
|
185
|
+
self._btn_active_until: dict[str, float] = {}
|
|
186
|
+
|
|
187
|
+
def __init_subclass__(cls, **kwargs: object) -> None:
|
|
188
|
+
super().__init_subclass__(**kwargs)
|
|
189
|
+
orig_init = cls.__dict__.get("__init__")
|
|
190
|
+
if orig_init is not None:
|
|
191
|
+
def wrapped(self_inner: "App", *args: Any, _orig: Any = orig_init, **kw: Any) -> None:
|
|
192
|
+
if not getattr(self_inner, "_sdk_initialized", False):
|
|
193
|
+
App.__init__(self_inner)
|
|
194
|
+
_orig(self_inner, *args, **kw)
|
|
195
|
+
cls.__init__ = wrapped # type: ignore[assignment]
|
|
196
|
+
|
|
197
|
+
# ── Override these ──────────────────────────────────────────────────────
|
|
198
|
+
# All hooks may be overridden as either `def` (sync) or `async def`.
|
|
199
|
+
# _dispatch_hook detects the type at call time — both are valid.
|
|
200
|
+
# Return type is `Coroutine[Any, Any, None] | None` so Pyright accepts
|
|
201
|
+
# both sync (`def` → returns None) and async (`async def` → returns
|
|
202
|
+
# Coroutine) overrides without reportIncompatibleMethodOverride.
|
|
203
|
+
def on_init(self, _ctx: RenderContext) -> "Coroutine[Any, Any, None] | None": return None
|
|
204
|
+
def on_render(self, _ctx: RenderContext) -> None: pass
|
|
205
|
+
def on_key(self, _ctx: RenderContext, _key: str, _mods: dict) -> "Coroutine[Any, Any, None] | None": return None
|
|
206
|
+
def on_click(self, _ctx: RenderContext, _x: float, _y: float, _button: str) -> "Coroutine[Any, Any, None] | None": return None
|
|
207
|
+
def on_mouse_down(self, _ctx: RenderContext, _x: float, _y: float, _button: str) -> "Coroutine[Any, Any, None] | None": return None
|
|
208
|
+
def on_mouse_up(self, _ctx: RenderContext, _x: float, _y: float, _button: str) -> "Coroutine[Any, Any, None] | None": return None
|
|
209
|
+
def on_mouse_move(self, _ctx: RenderContext, _x: float, _y: float, _buttons: list) -> "Coroutine[Any, Any, None] | None": return None
|
|
210
|
+
def on_command(self, _ctx: RenderContext, _text: str) -> "Coroutine[Any, Any, None] | None": return None
|
|
211
|
+
def on_paste(self, _ctx: RenderContext, _text: str) -> "Coroutine[Any, Any, None] | None": return None
|
|
212
|
+
def on_pipe_message(self, _ctx: RenderContext, _pipe_id: str, _payload: Any) -> "Coroutine[Any, Any, None] | None": return None
|
|
213
|
+
def on_path_changed(self, _ctx: RenderContext, _cwd: str) -> "Coroutine[Any, Any, None] | None": return None
|
|
214
|
+
def on_inject(self, _ctx: RenderContext, _payload: Any) -> "Coroutine[Any, Any, None] | None": return None
|
|
215
|
+
def on_nav_back(self, _ctx: RenderContext, _view_id: str) -> "Coroutine[Any, Any, None] | None":
|
|
216
|
+
"""Called when the host emits ``NavBack`` — user pressed Cmd+[ or the
|
|
217
|
+
back arrow in the pane chrome. ``view_id`` is the view being navigated
|
|
218
|
+
*back to* (the new top of stack, or empty string for root).
|
|
219
|
+
|
|
220
|
+
The app should update its own view state to show ``view_id``, then call
|
|
221
|
+
``ctx.emit.pop_nav()`` to remove the entry from the host stack.
|
|
222
|
+
"""
|
|
223
|
+
return None
|
|
224
|
+
def on_app_spawned(self, _pane_id: int, _type_id: str) -> None: pass
|
|
225
|
+
def on_pane_spawned(self, _pane_id: int, _request_id: "str | None" = None) -> None:
|
|
226
|
+
"""Called when a SpawnPane request succeeded (#592). Override to track the spawned pane."""
|
|
227
|
+
|
|
228
|
+
def on_pane_spawn_error(self, _reason: str, _request_id: "str | None" = None) -> None:
|
|
229
|
+
"""Called when a SpawnPane request failed (#592). Override to handle the error."""
|
|
230
|
+
|
|
231
|
+
def on_context_state(self, _state: dict) -> None:
|
|
232
|
+
"""Called when a QueryContextState response arrives (#1518).
|
|
233
|
+
|
|
234
|
+
``_state`` is a dict with keys: context_id, name, path, status,
|
|
235
|
+
pane_count, panes (list of pane summaries), children (list of child
|
|
236
|
+
context ids).
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
def on_timer(self, _ctx: RenderContext, _timer_id: str) -> "Coroutine[Any, Any, None] | None": return None
|
|
240
|
+
def on_scroll(self, _ctx: RenderContext, _id: str, _offset_y: float) -> "Coroutine[Any, Any, None] | None": return None
|
|
241
|
+
def on_list_select(self, _ctx: "RenderContext", _id: str, _index: int) -> "Coroutine[Any, Any, None] | None":
|
|
242
|
+
"""Called when a list_view selection changes via j/k/up/down."""
|
|
243
|
+
return None
|
|
244
|
+
def on_list_activate(self, _ctx: "RenderContext", _id: str, _index: int) -> "Coroutine[Any, Any, None] | None":
|
|
245
|
+
"""Called when Enter is pressed on a selected list_view item."""
|
|
246
|
+
return None
|
|
247
|
+
def on_text_submitted(self, _ctx: RenderContext, _id: str, _text: str) -> "Coroutine[Any, Any, None] | None": return None
|
|
248
|
+
def on_file_picked(self, _ctx: RenderContext, _request_id: str, _paths: "list[str]") -> "Coroutine[Any, Any, None] | None":
|
|
249
|
+
"""Called when the user selected one or more files in the picker.
|
|
250
|
+
|
|
251
|
+
``_request_id`` matches the id passed to ``ctx.emit.open_file_picker``.
|
|
252
|
+
``_paths`` is a list of absolute file paths chosen by the user.
|
|
253
|
+
"""
|
|
254
|
+
return None
|
|
255
|
+
def on_file_pick_cancelled(self, _ctx: RenderContext, _request_id: str) -> "Coroutine[Any, Any, None] | None":
|
|
256
|
+
"""Called when the user dismissed the file picker without selecting a file,
|
|
257
|
+
or if the ``fs.pick`` capability was not declared.
|
|
258
|
+
"""
|
|
259
|
+
return None
|
|
260
|
+
"""Called when the host updates the scroll offset for a BeginScroll region.
|
|
261
|
+
|
|
262
|
+
`id` matches the id passed to `ctx.begin_scroll`. `offset_y` is the new
|
|
263
|
+
vertical offset in logical pixels. Override to re-render content at the
|
|
264
|
+
new position.
|
|
265
|
+
"""
|
|
266
|
+
def on_mcp_call(self, _ctx: RenderContext, _tool_name: str, _arguments: dict) -> "dict | Coroutine[Any, Any, dict] | None":
|
|
267
|
+
"""Called when an external MCP client calls a tool declared in [app.mcp].
|
|
268
|
+
|
|
269
|
+
Override to handle the call and return a result dict. The result should
|
|
270
|
+
follow MCP CallToolResult schema: ``{"content": [{"type": "text", "text": "..."}]}``.
|
|
271
|
+
Return ``None`` to respond with a generic 'not implemented' error.
|
|
272
|
+
"""
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
def on_midi_input_opened(
|
|
276
|
+
self,
|
|
277
|
+
_pipe_id: str,
|
|
278
|
+
_port_id: str,
|
|
279
|
+
_port_name: str,
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Override to react to a successful OpenMidiInput. Apps that just
|
|
282
|
+
want the byte stream typically read directly from the binary pipe
|
|
283
|
+
opened alongside this event — Plexi sends `pipe_opened` first."""
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
# ── Tool protocol (#398, #399) ──────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
def tool(self, name: str, description: str, schema: dict,
|
|
289
|
+
timeout_ms: "int | None" = None) -> Any:
|
|
290
|
+
"""Decorator — register a method as an AI-callable tool and expose it.
|
|
291
|
+
|
|
292
|
+
Tools are functions that AI agents can invoke by name. Each tool has a description
|
|
293
|
+
and a JSON schema defining its parameters.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
name: Tool identifier; used by agents to invoke this tool
|
|
297
|
+
description: Human-readable description of what the tool does
|
|
298
|
+
schema: JSON schema object for tool parameters (type: "object" with properties)
|
|
299
|
+
timeout_ms: Optional timeout in milliseconds; None = no timeout
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
A decorator function that registers the method as a tool.
|
|
303
|
+
|
|
304
|
+
Example::
|
|
305
|
+
|
|
306
|
+
@app.tool("increment", description="Increment counter", schema={
|
|
307
|
+
"type": "object",
|
|
308
|
+
"properties": {"n": {"type": "integer", "description": "Amount to increment"}},
|
|
309
|
+
"required": ["n"],
|
|
310
|
+
})
|
|
311
|
+
async def handle_increment(self, args):
|
|
312
|
+
n = args.get("n", 0)
|
|
313
|
+
self.counter += n
|
|
314
|
+
return {"new_value": self.counter}
|
|
315
|
+
|
|
316
|
+
The decorated method receives a dict of parsed arguments and can return any JSON-serializable
|
|
317
|
+
value or raise an exception (which becomes the tool's error response).
|
|
318
|
+
"""
|
|
319
|
+
def decorator(fn: Any) -> Any:
|
|
320
|
+
self._tool_handlers[name] = fn
|
|
321
|
+
tool_def: dict = {
|
|
322
|
+
"name": name,
|
|
323
|
+
"description": description,
|
|
324
|
+
"input_schema": schema,
|
|
325
|
+
}
|
|
326
|
+
if timeout_ms is not None:
|
|
327
|
+
tool_def["timeout_ms"] = timeout_ms
|
|
328
|
+
self._tool_defs[name] = tool_def
|
|
329
|
+
self.emit.expose_tools(list(self._tool_defs.values()))
|
|
330
|
+
return fn
|
|
331
|
+
return decorator
|
|
332
|
+
|
|
333
|
+
def on_suspend(self) -> None: pass
|
|
334
|
+
def on_resume(self) -> None: pass
|
|
335
|
+
def on_shutdown(self) -> None: pass
|
|
336
|
+
|
|
337
|
+
# ── Internal ────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
async def _handle_tool_call(self, ev: dict) -> None:
|
|
340
|
+
"""Dispatch a ``PlexiEvent::ToolCall`` to the registered handler.
|
|
341
|
+
|
|
342
|
+
Sends ``DrawCommand::ToolResult`` with the return value (JSON-serialised)
|
|
343
|
+
or with an error string if the handler raises or is not registered.
|
|
344
|
+
"""
|
|
345
|
+
call_id: str = ev.get("call_id", "")
|
|
346
|
+
name: str = ev.get("name", "")
|
|
347
|
+
input_json: str = ev.get("input_json", "{}")
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
args = json.loads(input_json) if input_json else {}
|
|
351
|
+
except json.JSONDecodeError as exc:
|
|
352
|
+
_emit({
|
|
353
|
+
"type": "tool_result",
|
|
354
|
+
"call_id": call_id,
|
|
355
|
+
"output_json": None,
|
|
356
|
+
"error": f"tool_input_parse_error: {exc}",
|
|
357
|
+
})
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
handler = self._tool_handlers.get(name)
|
|
361
|
+
if handler is None:
|
|
362
|
+
_emit({
|
|
363
|
+
"type": "tool_result",
|
|
364
|
+
"call_id": call_id,
|
|
365
|
+
"output_json": None,
|
|
366
|
+
"error": f"tool_not_found: no handler registered for tool {name!r}",
|
|
367
|
+
})
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
import inspect as _inspect
|
|
372
|
+
if _inspect.iscoroutinefunction(handler):
|
|
373
|
+
result = await handler(args)
|
|
374
|
+
else:
|
|
375
|
+
result = handler(args)
|
|
376
|
+
output_json = json.dumps(result) if result is not None else json.dumps({})
|
|
377
|
+
_emit({
|
|
378
|
+
"type": "tool_result",
|
|
379
|
+
"call_id": call_id,
|
|
380
|
+
"output_json": output_json,
|
|
381
|
+
"error": None,
|
|
382
|
+
})
|
|
383
|
+
except Exception as exc:
|
|
384
|
+
import traceback as _tb
|
|
385
|
+
_tb.print_exc()
|
|
386
|
+
_emit({
|
|
387
|
+
"type": "tool_result",
|
|
388
|
+
"call_id": call_id,
|
|
389
|
+
"output_json": None,
|
|
390
|
+
"error": f"tool_handler_error: {exc}",
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
async def _handle_mcp_tool_call(self, ev: dict) -> None:
|
|
394
|
+
"""Dispatch a PlexiEvent::McpToolCall to on_mcp_call."""
|
|
395
|
+
call_id: str = ev.get("call_id", "")
|
|
396
|
+
tool_name: str = ev.get("tool_name", "")
|
|
397
|
+
arguments: dict = ev.get("arguments", {})
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
import inspect as _inspect
|
|
401
|
+
if _inspect.iscoroutinefunction(self.on_mcp_call):
|
|
402
|
+
result = await self.on_mcp_call(self._make_ctx(), tool_name, arguments)
|
|
403
|
+
else:
|
|
404
|
+
result = self.on_mcp_call(self._make_ctx(), tool_name, arguments)
|
|
405
|
+
|
|
406
|
+
if result is None:
|
|
407
|
+
_emit({
|
|
408
|
+
"type": "mcp_tool_result",
|
|
409
|
+
"call_id": call_id,
|
|
410
|
+
"result": None,
|
|
411
|
+
"error": f"tool_not_implemented: {tool_name!r}",
|
|
412
|
+
})
|
|
413
|
+
else:
|
|
414
|
+
_emit({
|
|
415
|
+
"type": "mcp_tool_result",
|
|
416
|
+
"call_id": call_id,
|
|
417
|
+
"result": result,
|
|
418
|
+
"error": None,
|
|
419
|
+
})
|
|
420
|
+
except Exception as exc:
|
|
421
|
+
import traceback as _tb
|
|
422
|
+
_tb.print_exc()
|
|
423
|
+
_emit({
|
|
424
|
+
"type": "mcp_tool_result",
|
|
425
|
+
"call_id": call_id,
|
|
426
|
+
"result": None,
|
|
427
|
+
"error": f"mcp_tool_handler_error: {exc}",
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
def _take_text_submission(self, id: str) -> "str | None":
|
|
431
|
+
"""Pop the most recent submission for `id` if one is queued, else None.
|
|
432
|
+
|
|
433
|
+
Called by `RenderContext.text_input` to surface a buffered
|
|
434
|
+
`TextSubmitted` value into the current frame's render call.
|
|
435
|
+
"""
|
|
436
|
+
return self._text_submissions.pop(id, None)
|
|
437
|
+
|
|
438
|
+
def _make_ctx(self, frame_id: int = 0, elapsed: float = 0.0,
|
|
439
|
+
clicks: "list[tuple[float, float]] | None" = None) -> RenderContext:
|
|
440
|
+
ctx = RenderContext(
|
|
441
|
+
frame_id=frame_id,
|
|
442
|
+
rect=self._rect,
|
|
443
|
+
workspace_root=self.workspace_root,
|
|
444
|
+
capabilities=self.capabilities,
|
|
445
|
+
feature_flags=self.feature_flags,
|
|
446
|
+
app=self,
|
|
447
|
+
elapsed=elapsed,
|
|
448
|
+
clicks=clicks or [],
|
|
449
|
+
)
|
|
450
|
+
ctx._compact_threshold = self._compact_threshold
|
|
451
|
+
ctx._regular_threshold = self._regular_threshold
|
|
452
|
+
return ctx
|
|
453
|
+
|
|
454
|
+
def run(self) -> None:
|
|
455
|
+
"""Start the PGAP v3 asyncio event loop. Blocks until Shutdown.
|
|
456
|
+
|
|
457
|
+
This is the entry point for all Plexi apps. Call it from your main block:
|
|
458
|
+
|
|
459
|
+
if __name__ == '__main__':
|
|
460
|
+
app = MyApp()
|
|
461
|
+
app.run()
|
|
462
|
+
"""
|
|
463
|
+
if "--plexi-introspect" in sys.argv:
|
|
464
|
+
self._run_introspect()
|
|
465
|
+
return
|
|
466
|
+
sys.stdout.reconfigure(line_buffering=True) # type: ignore[union-attr]
|
|
467
|
+
asyncio.run(self._async_main())
|
|
468
|
+
|
|
469
|
+
def _run_introspect(self) -> None:
|
|
470
|
+
"""Static capability check mode — called when launched with --plexi-introspect.
|
|
471
|
+
|
|
472
|
+
Inspects method bodies of this App subclass for emit.* / ctx.* calls
|
|
473
|
+
using AST analysis (not regex) to avoid false positives in docstrings.
|
|
474
|
+
Only scans methods defined in the subclass's own module (not base class).
|
|
475
|
+
Prints {"required_capabilities": [...]} to stdout, then exits.
|
|
476
|
+
"""
|
|
477
|
+
import ast
|
|
478
|
+
import inspect
|
|
479
|
+
import json
|
|
480
|
+
import textwrap
|
|
481
|
+
from ._emitter import CAPABILITY_REGISTRY
|
|
482
|
+
|
|
483
|
+
required: set[str] = set()
|
|
484
|
+
app_module = type(self).__module__
|
|
485
|
+
|
|
486
|
+
for _name, method in inspect.getmembers(type(self), predicate=inspect.isfunction):
|
|
487
|
+
if getattr(method, "__module__", None) != app_module:
|
|
488
|
+
continue
|
|
489
|
+
try:
|
|
490
|
+
source = textwrap.dedent(inspect.getsource(method))
|
|
491
|
+
tree = ast.parse(source)
|
|
492
|
+
except (OSError, TypeError, SyntaxError, IndentationError):
|
|
493
|
+
continue
|
|
494
|
+
for node in ast.walk(tree):
|
|
495
|
+
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
|
|
496
|
+
continue
|
|
497
|
+
func = node.func
|
|
498
|
+
base: "str | None" = None
|
|
499
|
+
if isinstance(func.value, ast.Name):
|
|
500
|
+
base = func.value.id
|
|
501
|
+
elif (
|
|
502
|
+
isinstance(func.value, ast.Attribute)
|
|
503
|
+
and isinstance(func.value.value, ast.Name)
|
|
504
|
+
and func.value.value.id == "self"
|
|
505
|
+
):
|
|
506
|
+
base = f"self.{func.value.attr}"
|
|
507
|
+
if base in ("self.emit", "self.ctx", "ctx", "emit"):
|
|
508
|
+
method_name = func.attr
|
|
509
|
+
if method_name in CAPABILITY_REGISTRY:
|
|
510
|
+
required.add(CAPABILITY_REGISTRY[method_name])
|
|
511
|
+
|
|
512
|
+
print(json.dumps({"required_capabilities": sorted(required)}), flush=True)
|
|
513
|
+
sys.exit(0)
|
|
514
|
+
|
|
515
|
+
async def _async_main(self) -> None:
|
|
516
|
+
"""Asyncio entry point — two concurrent tasks to eliminate deadlocks.
|
|
517
|
+
|
|
518
|
+
The root cause of the old single-loop design: when the dispatcher
|
|
519
|
+
awaited a hook (e.g. on_init) that itself called a blocking helper
|
|
520
|
+
(e.g. request_linked_terminal), the event loop had no concurrent
|
|
521
|
+
stdin reader in flight. Nothing could deliver the response event
|
|
522
|
+
while the hook was suspended, causing a permanent deadlock.
|
|
523
|
+
|
|
524
|
+
Fix: split into two tasks that run concurrently on the same event loop.
|
|
525
|
+
|
|
526
|
+
_reader — always has a run_in_executor(readline) in flight.
|
|
527
|
+
Handles response events inline (put_nowait into pending
|
|
528
|
+
queues) so they can unblock awaiting hooks even while the
|
|
529
|
+
dispatcher is suspended.
|
|
530
|
+
|
|
531
|
+
_dispatcher — drains a hook_q, dispatches hook events sequentially.
|
|
532
|
+
Can safely await hooks because _reader is always running
|
|
533
|
+
alongside it and will deliver response events.
|
|
534
|
+
|
|
535
|
+
Response events MUST be handled inline in _reader — never enqueued —
|
|
536
|
+
so that hooks awaiting on pending queues can be unblocked even when
|
|
537
|
+
the dispatcher is suspended mid-hook.
|
|
538
|
+
"""
|
|
539
|
+
loop = asyncio.get_running_loop()
|
|
540
|
+
self._loop = loop
|
|
541
|
+
hook_q: asyncio.Queue = asyncio.Queue()
|
|
542
|
+
|
|
543
|
+
async def _reader() -> None:
|
|
544
|
+
while True:
|
|
545
|
+
raw = await loop.run_in_executor(None, sys.stdin.readline)
|
|
546
|
+
if not raw:
|
|
547
|
+
# EOF — host closed stdin; signal dispatcher to shut down.
|
|
548
|
+
await hook_q.put({"type": "shutdown"})
|
|
549
|
+
return
|
|
550
|
+
raw = raw.strip()
|
|
551
|
+
if not raw:
|
|
552
|
+
continue
|
|
553
|
+
try:
|
|
554
|
+
ev = json.loads(raw)
|
|
555
|
+
except json.JSONDecodeError:
|
|
556
|
+
continue
|
|
557
|
+
|
|
558
|
+
t = ev.get("type", "")
|
|
559
|
+
|
|
560
|
+
# ── Response events: handled inline so they can unblock ──────
|
|
561
|
+
# hooks suspended in the dispatcher. These must NEVER go on
|
|
562
|
+
# hook_q — that would leave awaiting coroutines stuck forever.
|
|
563
|
+
|
|
564
|
+
if t == "capability_decision":
|
|
565
|
+
req_id = ev.get("request_id", "")
|
|
566
|
+
granted = ev.get("granted", False)
|
|
567
|
+
q = self._pending_capability.pop(req_id, None)
|
|
568
|
+
if q:
|
|
569
|
+
q.put_nowait(granted)
|
|
570
|
+
|
|
571
|
+
elif t == "secret_value":
|
|
572
|
+
key = ev.get("key", "")
|
|
573
|
+
value = ev.get("value")
|
|
574
|
+
q = self._pending_secret.pop(key, None)
|
|
575
|
+
if q:
|
|
576
|
+
q.put_nowait(value)
|
|
577
|
+
|
|
578
|
+
elif t == "http_response":
|
|
579
|
+
req_id = ev.get("request_id", "")
|
|
580
|
+
q = self._pending_http.pop(req_id, None)
|
|
581
|
+
if q:
|
|
582
|
+
if ev.get("error"):
|
|
583
|
+
q.put_nowait(("error", ev["error"]))
|
|
584
|
+
else:
|
|
585
|
+
q.put_nowait(("ok", ev.get("body", "")))
|
|
586
|
+
|
|
587
|
+
elif t == "image_loaded":
|
|
588
|
+
handle = ev.get("handle", "")
|
|
589
|
+
q = self._pending_image.pop(handle, None)
|
|
590
|
+
if q:
|
|
591
|
+
status = ev.get("status", "error")
|
|
592
|
+
message = ev.get("message")
|
|
593
|
+
q.put_nowait((status, message))
|
|
594
|
+
|
|
595
|
+
elif t == "ai_response":
|
|
596
|
+
# v3.3 ai.query broker (#284). Hand the whole event dict to
|
|
597
|
+
# `Emitter.ai_query` so it can split error vs success and
|
|
598
|
+
# attach token counts.
|
|
599
|
+
req_id = ev.get("request_id", "")
|
|
600
|
+
q = self._pending_ai.pop(req_id, None)
|
|
601
|
+
if q:
|
|
602
|
+
q.put_nowait(ev)
|
|
603
|
+
else:
|
|
604
|
+
import logging as _logging
|
|
605
|
+
_logging.warning(
|
|
606
|
+
f"ai_response: no pending request for req_id={req_id!r} — "
|
|
607
|
+
"response dropped (query may have timed out already)"
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
elif t == "midi_devices_listed":
|
|
611
|
+
# v3.4 CoreMIDI (#320). Forward to Emitter.list_midi_devices.
|
|
612
|
+
req_id = ev.get("request_id", "")
|
|
613
|
+
q = self._pending_midi_devices.pop(req_id, None)
|
|
614
|
+
if q:
|
|
615
|
+
q.put_nowait(ev)
|
|
616
|
+
|
|
617
|
+
elif t == "audio_devices_listed":
|
|
618
|
+
# v3.4 audio device enumeration (#341). Forward to Emitter.list_audio_devices.
|
|
619
|
+
req_id = ev.get("request_id", "")
|
|
620
|
+
q = self._pending_audio_devices.pop(req_id, None)
|
|
621
|
+
if q:
|
|
622
|
+
q.put_nowait(ev)
|
|
623
|
+
|
|
624
|
+
elif t == "video_open_ack":
|
|
625
|
+
# v3.4 video substrate (#345). Forward to Emitter.open_video().
|
|
626
|
+
req_id = str(ev.get("request_id", ""))
|
|
627
|
+
q = self._pending_video_open.pop(req_id, None)
|
|
628
|
+
if q:
|
|
629
|
+
q.put_nowait(ev)
|
|
630
|
+
|
|
631
|
+
elif t == "video_open_error":
|
|
632
|
+
# OpenVideo failed (capability denied, NotImplemented from the
|
|
633
|
+
# production stub, bad source). Forward the error event so
|
|
634
|
+
# `open_video()` can raise CapabilityDeniedError / RuntimeError.
|
|
635
|
+
req_id = str(ev.get("request_id", ""))
|
|
636
|
+
q = self._pending_video_open.pop(req_id, None)
|
|
637
|
+
if q:
|
|
638
|
+
q.put_nowait(ev)
|
|
639
|
+
|
|
640
|
+
elif t == "linked_terminal_ready":
|
|
641
|
+
# v3.5 #78. Forward the terminal_pane_id (int) to the
|
|
642
|
+
# awaiting helper. 0 = capability denied — the helper
|
|
643
|
+
# raises CapabilityDeniedError when it sees that.
|
|
644
|
+
req_id = ev.get("request_id", "")
|
|
645
|
+
q = self._pending_linked_terminal.pop(req_id, None)
|
|
646
|
+
if q:
|
|
647
|
+
q.put_nowait(int(ev.get("terminal_pane_id", 0)))
|
|
648
|
+
|
|
649
|
+
elif t == "command_preview":
|
|
650
|
+
# v3.5 #78. Forward (command, would_run_in_cwd) tuple to the
|
|
651
|
+
# awaiting helper. would_run_in_cwd is "" on capability denial.
|
|
652
|
+
req_id = ev.get("request_id", "")
|
|
653
|
+
q = self._pending_command_preview.pop(req_id, None)
|
|
654
|
+
if q:
|
|
655
|
+
q.put_nowait((
|
|
656
|
+
str(ev.get("command", "")),
|
|
657
|
+
str(ev.get("would_run_in_cwd", "")),
|
|
658
|
+
))
|
|
659
|
+
|
|
660
|
+
elif t == "notify_action":
|
|
661
|
+
# notify_choice / notify_input: put the value back.
|
|
662
|
+
# notify / notify_and_wait: put action_label back.
|
|
663
|
+
# Esc cancel: return "__cancel__" so callers can check easily.
|
|
664
|
+
notify_id = ev.get("notify_id", "")
|
|
665
|
+
action_label = ev.get("action_label", "")
|
|
666
|
+
value = ev.get("value")
|
|
667
|
+
if action_label == "cancel":
|
|
668
|
+
result = "__cancel__"
|
|
669
|
+
elif value is not None:
|
|
670
|
+
result = value
|
|
671
|
+
else:
|
|
672
|
+
result = action_label or "acknowledge"
|
|
673
|
+
q = self._pending_notify.pop(notify_id, None)
|
|
674
|
+
if q:
|
|
675
|
+
q.put_nowait(result)
|
|
676
|
+
else:
|
|
677
|
+
cb = self._pending_notify_callbacks.pop(notify_id, None)
|
|
678
|
+
if cb is not None:
|
|
679
|
+
self.emit.info(f"notify_async: dispatching callback for {notify_id!r}")
|
|
680
|
+
try:
|
|
681
|
+
cb(result)
|
|
682
|
+
except Exception as exc:
|
|
683
|
+
sys.stderr.write(f"plexi_sdk: notify_async callback raised: {exc}\n")
|
|
684
|
+
|
|
685
|
+
elif t == "text_measured":
|
|
686
|
+
# Response to RenderContext.measure_text(). Forward (width, height)
|
|
687
|
+
# to the awaiting coroutine keyed on request_id.
|
|
688
|
+
req_id = ev.get("request_id", "")
|
|
689
|
+
q = self._pending_measure_text.pop(req_id, None)
|
|
690
|
+
if q:
|
|
691
|
+
q.put_nowait((
|
|
692
|
+
float(ev.get("width", 0.0)),
|
|
693
|
+
float(ev.get("height", 0.0)),
|
|
694
|
+
))
|
|
695
|
+
|
|
696
|
+
elif t == "text_wrapped_measured":
|
|
697
|
+
req_id = ev.get("request_id", "")
|
|
698
|
+
q = self._pending_measure_text_wrapped.pop(req_id, None)
|
|
699
|
+
if q:
|
|
700
|
+
q.put_nowait(float(ev.get("height", 0.0)))
|
|
701
|
+
|
|
702
|
+
# ── Inline non-hook events (fast, no user code) ──────────────
|
|
703
|
+
|
|
704
|
+
elif t == "pipe_opened":
|
|
705
|
+
pipe_id = ev.get("pipe_id", "")
|
|
706
|
+
socket_path = ev.get("socket_path", "")
|
|
707
|
+
p = self._pipes.get(pipe_id)
|
|
708
|
+
if p:
|
|
709
|
+
p._on_opened(socket_path)
|
|
710
|
+
|
|
711
|
+
elif t == "pipe_overrun":
|
|
712
|
+
self.emit.warn(
|
|
713
|
+
f"pipe overrun pipe_id={ev.get('pipe_id')} "
|
|
714
|
+
f"dropped={ev.get('dropped_frames')}"
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
elif t == "midi_input_error":
|
|
718
|
+
# OpenMidiInput failed (capability denied, port_id not found,
|
|
719
|
+
# CoreMIDI error). Apps log this; the typical recovery is to
|
|
720
|
+
# surface the error in-pane and let the user pick a different
|
|
721
|
+
# port from list_midi_devices.
|
|
722
|
+
self.emit.warn(
|
|
723
|
+
f"midi_input_error pipe_id={ev.get('pipe_id')} "
|
|
724
|
+
f"error={ev.get('error')}"
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
elif t == "midi_send_error":
|
|
728
|
+
# SendMidi failed. Surfaces only on capability denial / open
|
|
729
|
+
# failure / coremidi error — successful sends produce no event.
|
|
730
|
+
self.emit.warn(
|
|
731
|
+
f"midi_send_error port_id={ev.get('port_id')} "
|
|
732
|
+
f"error={ev.get('error')}"
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
elif t == "text_submitted":
|
|
736
|
+
# Host-owned text input: the user pressed Enter on a
|
|
737
|
+
# `DrawCommand::TextInput` field. Stash the value keyed
|
|
738
|
+
# on the input id; `RenderContext.text_input(...)` will
|
|
739
|
+
# drain it on the next frame the app polls.
|
|
740
|
+
# Also dispatch on_text_submitted as a hook task if the
|
|
741
|
+
# app has overridden it — apps that use the event-handler
|
|
742
|
+
# path can do I/O cleanly without side effects in on_render.
|
|
743
|
+
tid = ev.get("id", "")
|
|
744
|
+
if tid:
|
|
745
|
+
value = ev.get("value", "")
|
|
746
|
+
self._text_submissions[tid] = value
|
|
747
|
+
if type(self).on_text_submitted is not App.on_text_submitted:
|
|
748
|
+
self._dispatch_hook_task(
|
|
749
|
+
self.on_text_submitted, self._make_ctx(), tid, value
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
elif t == "run_update":
|
|
753
|
+
pass # apps can override on_run_update if needed
|
|
754
|
+
|
|
755
|
+
# ── Hook events: forwarded to the dispatcher ─────────────────
|
|
756
|
+
else:
|
|
757
|
+
await hook_q.put(ev)
|
|
758
|
+
|
|
759
|
+
async def _dispatcher() -> None:
|
|
760
|
+
while True:
|
|
761
|
+
ev = await hook_q.get()
|
|
762
|
+
t = ev.get("type", "")
|
|
763
|
+
|
|
764
|
+
if t == "init":
|
|
765
|
+
proto = ev.get("protocol", "")
|
|
766
|
+
if not proto.startswith(PROTOCOL_VERSION):
|
|
767
|
+
sys.stderr.write(
|
|
768
|
+
f"plexi_sdk: unsupported protocol {proto!r}, expected {PROTOCOL_VERSION}\n"
|
|
769
|
+
)
|
|
770
|
+
sys.exit(1)
|
|
771
|
+
self.app_id = ev.get("app_id", "")
|
|
772
|
+
self.workspace_root = ev.get("workspace_root", "")
|
|
773
|
+
self.capabilities = ev.get("capabilities", [])
|
|
774
|
+
self.feature_flags = ev.get("feature_flags", [])
|
|
775
|
+
self._compact_threshold = ev.get("compact_threshold", 280.0)
|
|
776
|
+
self._regular_threshold = ev.get("regular_threshold", 480.0)
|
|
777
|
+
# Send Ready
|
|
778
|
+
features_used = [f for f in self.feature_flags
|
|
779
|
+
if f in ("pane_groups_v1",)]
|
|
780
|
+
_emit({"type": "ready", "sdk": SDK_ID, "features_used": features_used})
|
|
781
|
+
self.emit.info(f"sdk: default_background={self.default_background!r}")
|
|
782
|
+
await self._dispatch_hook(self.on_init, self._make_ctx())
|
|
783
|
+
|
|
784
|
+
elif t == "render":
|
|
785
|
+
import time as _time
|
|
786
|
+
now = _time.monotonic()
|
|
787
|
+
elapsed = (now - self._last_render_time) if self._last_render_time is not None else 0.0
|
|
788
|
+
self._last_render_time = now
|
|
789
|
+
frame_id = ev.get("frame_id", 0)
|
|
790
|
+
if "rect" in ev:
|
|
791
|
+
self._rect = ev["rect"]
|
|
792
|
+
elif "width" in ev:
|
|
793
|
+
# legacy compat
|
|
794
|
+
self._rect = {"x": 0.0, "y": 0.0,
|
|
795
|
+
"w": ev["width"], "h": ev["height"]}
|
|
796
|
+
pending_clicks = list(self._click_buf)
|
|
797
|
+
self._click_buf.clear()
|
|
798
|
+
ctx = self._make_ctx(frame_id, elapsed=elapsed, clicks=pending_clicks)
|
|
799
|
+
if self.default_background is not None:
|
|
800
|
+
ctx.clear(self.default_background)
|
|
801
|
+
try:
|
|
802
|
+
await self._dispatch_hook(self.on_render, ctx)
|
|
803
|
+
self._consecutive_render_errors = 0
|
|
804
|
+
except Exception as e:
|
|
805
|
+
self._consecutive_render_errors += 1
|
|
806
|
+
ctx.error(f"on_render exception: {e}")
|
|
807
|
+
if self._consecutive_render_errors >= 3:
|
|
808
|
+
import traceback as _tb
|
|
809
|
+
_tb.print_exc()
|
|
810
|
+
raise
|
|
811
|
+
ctx.frame_done()
|
|
812
|
+
|
|
813
|
+
elif t == "key":
|
|
814
|
+
ctx = self._make_ctx()
|
|
815
|
+
self._dispatch_hook_task(self.on_key, ctx, _normalize_key(ev.get("key", "")), ev.get("modifiers", {}))
|
|
816
|
+
|
|
817
|
+
elif t == "click":
|
|
818
|
+
self._click_buf.append((ev.get("x", 0.0), ev.get("y", 0.0)))
|
|
819
|
+
ctx = self._make_ctx()
|
|
820
|
+
self._dispatch_hook_task(self.on_click, ctx, ev.get("x", 0.0), ev.get("y", 0.0),
|
|
821
|
+
ev.get("button", "primary"))
|
|
822
|
+
|
|
823
|
+
elif t == "mouse_down":
|
|
824
|
+
ctx = self._make_ctx()
|
|
825
|
+
await self._dispatch_hook(self.on_mouse_down, ctx, ev.get("x", 0.0), ev.get("y", 0.0),
|
|
826
|
+
ev.get("button", "primary"))
|
|
827
|
+
|
|
828
|
+
elif t == "mouse_up":
|
|
829
|
+
ctx = self._make_ctx()
|
|
830
|
+
await self._dispatch_hook(self.on_mouse_up, ctx, ev.get("x", 0.0), ev.get("y", 0.0),
|
|
831
|
+
ev.get("button", "primary"))
|
|
832
|
+
|
|
833
|
+
elif t == "mouse_move":
|
|
834
|
+
self._mx = ev.get("x", 0.0)
|
|
835
|
+
self._my = ev.get("y", 0.0)
|
|
836
|
+
ctx = self._make_ctx()
|
|
837
|
+
await self._dispatch_hook(self.on_mouse_move, ctx, ev.get("x", 0.0), ev.get("y", 0.0),
|
|
838
|
+
ev.get("buttons", []))
|
|
839
|
+
|
|
840
|
+
elif t == "command":
|
|
841
|
+
ctx = self._make_ctx()
|
|
842
|
+
self._dispatch_hook_task(self.on_command, ctx, ev.get("text", ""))
|
|
843
|
+
|
|
844
|
+
elif t == "paste":
|
|
845
|
+
ctx = self._make_ctx()
|
|
846
|
+
self._dispatch_hook_task(self.on_paste, ctx, ev.get("text", ""))
|
|
847
|
+
|
|
848
|
+
elif t == "pipe_message":
|
|
849
|
+
ctx = self._make_ctx()
|
|
850
|
+
self._dispatch_hook_task(self.on_pipe_message, ctx, ev.get("pipe_id", ""), ev.get("payload"))
|
|
851
|
+
|
|
852
|
+
elif t == "path_changed":
|
|
853
|
+
ctx = self._make_ctx()
|
|
854
|
+
self._dispatch_hook_task(self.on_path_changed, ctx, ev.get("cwd", ""))
|
|
855
|
+
|
|
856
|
+
elif t == "suspend":
|
|
857
|
+
await self._dispatch_hook(self.on_suspend)
|
|
858
|
+
|
|
859
|
+
elif t == "resume":
|
|
860
|
+
await self._dispatch_hook(self.on_resume)
|
|
861
|
+
|
|
862
|
+
elif t == "shutdown":
|
|
863
|
+
# Cancel any pending ai_query waiters so their coroutines
|
|
864
|
+
# unblock immediately instead of waiting up to 35s for a
|
|
865
|
+
# response that will never arrive.
|
|
866
|
+
if self._pending_ai:
|
|
867
|
+
import logging as _logging
|
|
868
|
+
_logging.warning(
|
|
869
|
+
f"shutdown: cancelling {len(self._pending_ai)} in-flight "
|
|
870
|
+
f"ai_query request(s): {list(self._pending_ai.keys())}"
|
|
871
|
+
)
|
|
872
|
+
for _pending_q in self._pending_ai.values():
|
|
873
|
+
_pending_q.put_nowait(
|
|
874
|
+
{"error": "ai_query cancelled: app is shutting down"}
|
|
875
|
+
)
|
|
876
|
+
self._pending_ai.clear()
|
|
877
|
+
await self._dispatch_hook(self.on_shutdown)
|
|
878
|
+
return
|
|
879
|
+
|
|
880
|
+
elif t == "inject_state":
|
|
881
|
+
self._app_state = ev.get("payload") or {}
|
|
882
|
+
ctx = self._make_ctx()
|
|
883
|
+
self._dispatch_hook_task(self.on_inject, ctx, ev.get("payload", {}))
|
|
884
|
+
|
|
885
|
+
elif t == "midi_input_opened":
|
|
886
|
+
# Confirms an OpenMidiInput call landed a CoreMIDI source.
|
|
887
|
+
# Apps that care about "the port is now wired to my pipe"
|
|
888
|
+
# see this event after the corresponding PipeOpened — they
|
|
889
|
+
# can override on_midi_input_opened to react.
|
|
890
|
+
self._dispatch_hook_task(
|
|
891
|
+
self.on_midi_input_opened,
|
|
892
|
+
str(ev.get("pipe_id", "")),
|
|
893
|
+
str(ev.get("port_id", "")),
|
|
894
|
+
str(ev.get("port_name", "")),
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
elif t == "timer":
|
|
898
|
+
timer_id = ev.get("timer_id", "")
|
|
899
|
+
ctx = self._make_ctx()
|
|
900
|
+
self._dispatch_hook_task(self.on_timer, ctx, timer_id)
|
|
901
|
+
|
|
902
|
+
elif t == "scroll_offset":
|
|
903
|
+
# Host-managed scroll region (#446): the user scrolled inside
|
|
904
|
+
# a BeginScroll viewport. Forward to on_scroll so the app can
|
|
905
|
+
# store the new offset and re-render at the translated position.
|
|
906
|
+
scroll_id = ev.get("id", "")
|
|
907
|
+
offset_y = float(ev.get("offset_y", 0.0))
|
|
908
|
+
ctx = self._make_ctx()
|
|
909
|
+
try:
|
|
910
|
+
await self._dispatch_hook(self.on_scroll, ctx, scroll_id, offset_y)
|
|
911
|
+
except Exception as e:
|
|
912
|
+
sys.stderr.write(f"on_scroll handler raised: {e}\n")
|
|
913
|
+
|
|
914
|
+
elif t == "list_select":
|
|
915
|
+
_lid = ev.get("id")
|
|
916
|
+
_lidx = ev.get("index")
|
|
917
|
+
if _lid is None or _lidx is None:
|
|
918
|
+
sys.stderr.write(f"list_select event missing required fields: {ev}\n")
|
|
919
|
+
else:
|
|
920
|
+
ctx = self._make_ctx()
|
|
921
|
+
self._dispatch_hook_task(self.on_list_select, ctx, _lid, _lidx)
|
|
922
|
+
|
|
923
|
+
elif t == "list_activate":
|
|
924
|
+
_lid = ev.get("id")
|
|
925
|
+
_lidx = ev.get("index")
|
|
926
|
+
if _lid is None or _lidx is None:
|
|
927
|
+
sys.stderr.write(f"list_activate event missing required fields: {ev}\n")
|
|
928
|
+
else:
|
|
929
|
+
ctx = self._make_ctx()
|
|
930
|
+
self._dispatch_hook_task(self.on_list_activate, ctx, _lid, _lidx)
|
|
931
|
+
|
|
932
|
+
elif t == "app_spawned":
|
|
933
|
+
# Confirmation that a SpawnApp request succeeded. Apps that
|
|
934
|
+
# want to track the spawned pane can override on_app_spawned.
|
|
935
|
+
self._dispatch_hook_task(
|
|
936
|
+
self.on_app_spawned,
|
|
937
|
+
int(ev.get("pane_id", 0)),
|
|
938
|
+
str(ev.get("type_id", "")),
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
elif t == "pane_spawned":
|
|
942
|
+
req_id = ev.get("request_id")
|
|
943
|
+
self._dispatch_hook_task(
|
|
944
|
+
self.on_pane_spawned,
|
|
945
|
+
int(ev.get("pane_id", 0)),
|
|
946
|
+
str(req_id) if req_id is not None else None,
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
elif t == "pane_spawn_error":
|
|
950
|
+
req_id = ev.get("request_id")
|
|
951
|
+
self._dispatch_hook_task(
|
|
952
|
+
self.on_pane_spawn_error,
|
|
953
|
+
str(ev.get("reason", "")),
|
|
954
|
+
str(req_id) if req_id is not None else None,
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
elif t == "context_state_response":
|
|
958
|
+
self._dispatch_hook_task(
|
|
959
|
+
self.on_context_state,
|
|
960
|
+
ev.get("state", {}),
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
elif t == "nav_back":
|
|
964
|
+
# Navigation stack back event (#392). The host pops the top
|
|
965
|
+
# nav entry and sends this with the view_id the app should
|
|
966
|
+
# navigate back to (empty string = root view).
|
|
967
|
+
ctx = self._make_ctx()
|
|
968
|
+
await self._dispatch_hook(
|
|
969
|
+
self.on_nav_back, ctx, str(ev.get("view_id", ""))
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
elif t == "file_picked":
|
|
973
|
+
# File picker result (#514) — user selected one or more files.
|
|
974
|
+
request_id = str(ev.get("request_id", ""))
|
|
975
|
+
paths: list[str] = list(ev.get("paths", []))
|
|
976
|
+
ctx = self._make_ctx()
|
|
977
|
+
await self._dispatch_hook(self.on_file_picked, ctx, request_id, paths)
|
|
978
|
+
|
|
979
|
+
elif t == "file_pick_cancelled":
|
|
980
|
+
# File picker cancelled (#514) — dialog dismissed or capability denied.
|
|
981
|
+
request_id = str(ev.get("request_id", ""))
|
|
982
|
+
ctx = self._make_ctx()
|
|
983
|
+
await self._dispatch_hook(self.on_file_pick_cancelled, ctx, request_id)
|
|
984
|
+
|
|
985
|
+
elif t == "tool_call":
|
|
986
|
+
# v3.7 tool protocol (#399). Host asks this pane to execute
|
|
987
|
+
# a registered tool. Dispatched as a background task so it
|
|
988
|
+
# doesn't block the event loop while the handler runs.
|
|
989
|
+
self._dispatch_hook_task(self._handle_tool_call, ev)
|
|
990
|
+
|
|
991
|
+
elif t == "mcp_tool_call":
|
|
992
|
+
# MCP bridge (#958). External MCP client called a tool — dispatch to on_mcp_call.
|
|
993
|
+
self._dispatch_hook_task(self._handle_mcp_tool_call, ev)
|
|
994
|
+
|
|
995
|
+
reader_task = asyncio.create_task(_reader())
|
|
996
|
+
try:
|
|
997
|
+
await _dispatcher()
|
|
998
|
+
finally:
|
|
999
|
+
reader_task.cancel()
|
|
1000
|
+
for p in self._pipes.values():
|
|
1001
|
+
p.close()
|
|
1002
|
+
# _reader is blocked in run_in_executor(sys.stdin.readline) which
|
|
1003
|
+
# cannot be interrupted by task cancellation — the executor thread
|
|
1004
|
+
# stays alive until stdin EOF, which the host may not send within
|
|
1005
|
+
# the 2s shutdown window. os._exit() terminates immediately without
|
|
1006
|
+
# waiting for threads, avoiding the SIGTERM that would otherwise fire.
|
|
1007
|
+
import os as _os
|
|
1008
|
+
_os._exit(0)
|
|
1009
|
+
|
|
1010
|
+
async def _dispatch_hook(self, hook: "Any", *args: Any) -> None:
|
|
1011
|
+
"""Dispatch a lifecycle hook and await its completion.
|
|
1012
|
+
|
|
1013
|
+
Use this for hooks where ordering matters — on_render (FrameDone must
|
|
1014
|
+
follow all draw commands), on_init (startup must complete before the
|
|
1015
|
+
first render), on_shutdown (clean-up must finish before exit).
|
|
1016
|
+
|
|
1017
|
+
Async hooks are awaited directly; they may call any ``await``-able
|
|
1018
|
+
Emitter helper without deadlock because _reader runs concurrently as
|
|
1019
|
+
a separate task and will deliver response events while this hook is
|
|
1020
|
+
suspended.
|
|
1021
|
+
|
|
1022
|
+
Sync hooks run on the event loop thread. They are safe for
|
|
1023
|
+
pure-compute / draw-command work (on_render). A sync hook that calls
|
|
1024
|
+
any blocking operation (time.sleep, requests.get, etc.) will freeze
|
|
1025
|
+
the entire event loop — use _dispatch_hook_task for input events where
|
|
1026
|
+
blocking is a realistic concern, or move blocking work to a thread via
|
|
1027
|
+
``threading.Thread`` + ``emit.run_sync()``.
|
|
1028
|
+
"""
|
|
1029
|
+
if inspect.iscoroutinefunction(hook):
|
|
1030
|
+
await hook(*args)
|
|
1031
|
+
else:
|
|
1032
|
+
with _sync_hook_scope():
|
|
1033
|
+
hook(*args)
|
|
1034
|
+
|
|
1035
|
+
def _dispatch_hook_task(self, hook: "Any", *args: Any) -> None:
|
|
1036
|
+
"""Dispatch a lifecycle hook as a non-blocking background task.
|
|
1037
|
+
|
|
1038
|
+
Use this for input-driven hooks (on_key, on_click, on_command, etc.)
|
|
1039
|
+
where a slow or async handler must not stall the stdin reader or delay
|
|
1040
|
+
the next Render event.
|
|
1041
|
+
|
|
1042
|
+
Async hooks are scheduled as asyncio tasks via create_task — the
|
|
1043
|
+
dispatcher returns immediately and the hook runs concurrently on the
|
|
1044
|
+
same event loop. All ``await``-able Emitter helpers work normally.
|
|
1045
|
+
|
|
1046
|
+
Sync hooks that do not block are called directly on the event loop
|
|
1047
|
+
thread (zero overhead, same as before). Sync hooks that *do* block
|
|
1048
|
+
(time.sleep, requests.get, urllib calls, etc.) are the root cause of
|
|
1049
|
+
the deadlock described in issue #393. The correct fix is to declare
|
|
1050
|
+
the handler ``async def`` and use ``await asyncio.to_thread(fn)`` or
|
|
1051
|
+
``await self.emit.http_get(url)`` for any I/O, or to kick off a
|
|
1052
|
+
``threading.Thread`` and use ``emit.run_sync(...)`` to bridge back.
|
|
1053
|
+
Sync blocking is logged as a warning so the problem is surfaced at
|
|
1054
|
+
runtime rather than silently freezing the app.
|
|
1055
|
+
|
|
1056
|
+
Note: because tasks run concurrently, a queued on_key task may still
|
|
1057
|
+
be running when on_render fires. Apps with shared mutable state should
|
|
1058
|
+
use asyncio locks or confine mutations to on_render (the poll pattern).
|
|
1059
|
+
"""
|
|
1060
|
+
if inspect.iscoroutinefunction(hook):
|
|
1061
|
+
task = asyncio.create_task(hook(*args))
|
|
1062
|
+
# Keep a strong reference so the GC doesn't collect the task before
|
|
1063
|
+
# it finishes. The done callback removes it from the set.
|
|
1064
|
+
self._background_tasks.add(task)
|
|
1065
|
+
task.add_done_callback(self._background_tasks.discard)
|
|
1066
|
+
task.add_done_callback(_log_task_exception)
|
|
1067
|
+
else:
|
|
1068
|
+
try:
|
|
1069
|
+
with _sync_hook_scope():
|
|
1070
|
+
hook(*args)
|
|
1071
|
+
except Exception as e:
|
|
1072
|
+
sys.stderr.write(f"plexi_sdk: sync hook {getattr(hook, '__name__', hook)!r} raised: {e}\n")
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
# SDK_ID used in the ready handshake. Derived from the version constant so
|
|
1076
|
+
# __init__.py and _app.py stay in sync without a circular import.
|
|
1077
|
+
SDK_ID = f"plexi-sdk-py/{_SDK_VERSION}"
|