plexi-sdk 0.1.4__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 +71 -0
- plexi_sdk/_app.py +1516 -0
- plexi_sdk/_constants.py +41 -0
- plexi_sdk/_emitter.py +1920 -0
- plexi_sdk/_pipe.py +92 -0
- plexi_sdk/_protocol.py +48 -0
- plexi_sdk/_render_context.py +1094 -0
- plexi_sdk/_state.py +47 -0
- plexi_sdk/_theme.py +150 -0
- plexi_sdk/_types.py +145 -0
- plexi_sdk/midi.py +222 -0
- plexi_sdk/py.typed +1 -0
- plexi_sdk/templates/__init__.py +0 -0
- plexi_sdk/templates/agent_init.py +49 -0
- plexi_sdk/templates/app_init.py +38 -0
- plexi_sdk/testing.py +574 -0
- plexi_sdk/ui.py +2128 -0
- plexi_sdk/widgets/__init__.py +27 -0
- plexi_sdk/widgets/button.py +63 -0
- plexi_sdk/widgets/keymap.py +51 -0
- plexi_sdk/widgets/list_view.py +170 -0
- plexi_sdk/widgets/scroll.py +100 -0
- plexi_sdk/widgets/text_area.py +219 -0
- plexi_sdk/widgets/text_buffer.py +337 -0
- plexi_sdk/widgets/text_input.py +70 -0
- plexi_sdk-0.1.4.dist-info/METADATA +117 -0
- plexi_sdk-0.1.4.dist-info/RECORD +28 -0
- plexi_sdk-0.1.4.dist-info/WHEEL +4 -0
plexi_sdk/_app.py
ADDED
|
@@ -0,0 +1,1516 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import traceback
|
|
8
|
+
from typing import Any, Coroutine
|
|
9
|
+
|
|
10
|
+
from ._protocol import PROTOCOL_VERSION
|
|
11
|
+
from ._constants import _SDK_VERSION
|
|
12
|
+
from ._emitter import Emitter, _emit, _sync_hook_scope
|
|
13
|
+
from ._pipe import Pipe
|
|
14
|
+
from ._render_context import RenderContext
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── Arg descriptor ────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
_MISSING: Any = object()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Arg:
|
|
23
|
+
"""Declare a typed launch argument on an App subclass.
|
|
24
|
+
|
|
25
|
+
The SDK parses sys.argv before on_init runs and sets the resolved value as
|
|
26
|
+
an instance attribute with the same name as the class-level declaration.
|
|
27
|
+
|
|
28
|
+
Usage::
|
|
29
|
+
|
|
30
|
+
class MyApp(App):
|
|
31
|
+
repo_dir: Arg[str | None] = Arg("--repo-dir", default=lambda ctx: ctx.workspace_root)
|
|
32
|
+
limit: Arg[int] = Arg("--limit", type=int, default=100)
|
|
33
|
+
count: Arg[int] = Arg(positional=True, type=int, default=10)
|
|
34
|
+
|
|
35
|
+
async def on_init(self):
|
|
36
|
+
print(self.repo_dir) # already resolved
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*flags: str,
|
|
42
|
+
positional: bool = False,
|
|
43
|
+
type: Any = None,
|
|
44
|
+
default: Any = _MISSING,
|
|
45
|
+
dest: "str | None" = None,
|
|
46
|
+
nargs: Any = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.flags = flags
|
|
49
|
+
self.positional = positional
|
|
50
|
+
self.arg_type = type
|
|
51
|
+
self.default = default
|
|
52
|
+
self.dest = dest
|
|
53
|
+
self.nargs = nargs
|
|
54
|
+
|
|
55
|
+
def __class_getitem__(cls, _item: Any) -> "type[Arg]":
|
|
56
|
+
return cls
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _log_task_exception(task: asyncio.Task) -> None:
|
|
60
|
+
"""Done callback for background tasks — logs unhandled exceptions."""
|
|
61
|
+
try:
|
|
62
|
+
exc = task.exception()
|
|
63
|
+
except (asyncio.CancelledError, asyncio.InvalidStateError):
|
|
64
|
+
return
|
|
65
|
+
if exc is not None:
|
|
66
|
+
sys.stderr.write(f"plexi_sdk: unhandled exception in background task: {exc}\n")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _emit_fatal_error(exc: BaseException) -> None:
|
|
70
|
+
"""Report an unrecoverable SDK/app exception over PGAP before exiting."""
|
|
71
|
+
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
72
|
+
message = f"{type(exc).__name__}: {exc}"
|
|
73
|
+
try:
|
|
74
|
+
_emit({"type": "fatal_error", "message": message, "traceback": tb})
|
|
75
|
+
finally:
|
|
76
|
+
sys.stderr.write(tb)
|
|
77
|
+
sys.stderr.flush()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# Map egui's Debug-format key names to the documented canonical SDK names.
|
|
81
|
+
# The host sends "ArrowLeft" etc. (egui Key::ArrowLeft Debug repr); apps
|
|
82
|
+
# should use "left"/"right"/"up"/"down" as documented. Normalizing here
|
|
83
|
+
# means both forms work correctly — agents and humans can use the documented
|
|
84
|
+
# names without knowing egui's internal representation.
|
|
85
|
+
_KEY_ALIASES: "dict[str, str]" = {
|
|
86
|
+
"ArrowLeft": "left",
|
|
87
|
+
"ArrowRight": "right",
|
|
88
|
+
"ArrowUp": "up",
|
|
89
|
+
"ArrowDown": "down",
|
|
90
|
+
# egui Debug-format names → SDK canonical names
|
|
91
|
+
"Enter": "return",
|
|
92
|
+
"Escape": "escape",
|
|
93
|
+
"Backspace": "backspace",
|
|
94
|
+
"Tab": "tab",
|
|
95
|
+
# Space arrives as Event::Text(" "), not Event::Key
|
|
96
|
+
" ": "space",
|
|
97
|
+
# Printable symbols arrive as raw chars via Event::Text
|
|
98
|
+
"-": "minus",
|
|
99
|
+
"=": "equals",
|
|
100
|
+
"+": "plus",
|
|
101
|
+
"[": "open_bracket",
|
|
102
|
+
"]": "close_bracket",
|
|
103
|
+
"\\": "backslash",
|
|
104
|
+
";": "semicolon",
|
|
105
|
+
"'": "quote",
|
|
106
|
+
"`": "backtick",
|
|
107
|
+
",": "comma",
|
|
108
|
+
".": "period",
|
|
109
|
+
"/": "slash",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _normalize_key(key: str) -> str:
|
|
114
|
+
return _KEY_ALIASES.get(key, key)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── Host-persisted state proxy ─────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
class _AppStateProxy:
|
|
120
|
+
"""Returned by App.state — read/write the host-persisted state dict."""
|
|
121
|
+
__slots__ = ("_app",)
|
|
122
|
+
|
|
123
|
+
def __init__(self, app: "App") -> None:
|
|
124
|
+
self._app = app
|
|
125
|
+
|
|
126
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
127
|
+
return self._app._app_state.get(key, default)
|
|
128
|
+
|
|
129
|
+
def all(self) -> dict:
|
|
130
|
+
return dict(self._app._app_state)
|
|
131
|
+
|
|
132
|
+
def save(self, payload: dict) -> None:
|
|
133
|
+
self._app._app_state = dict(payload)
|
|
134
|
+
_emit({"type": "save_app_state", "payload": payload})
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ── App base class ────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
class App:
|
|
140
|
+
"""
|
|
141
|
+
Base class for Plexi v3 apps. Subclass and override event handlers.
|
|
142
|
+
|
|
143
|
+
Override any of:
|
|
144
|
+
|
|
145
|
+
Awaited (block the event loop until they return):
|
|
146
|
+
on_init() — after Init handshake
|
|
147
|
+
on_render(ctx) — on each Render event (ctx IS the drawing surface)
|
|
148
|
+
on_suspend() — on Suspend
|
|
149
|
+
on_resume() — on Resume
|
|
150
|
+
on_shutdown() — on Shutdown
|
|
151
|
+
|
|
152
|
+
Task (dispatched as asyncio tasks — event loop continues):
|
|
153
|
+
on_key(key, mods) — on Key event
|
|
154
|
+
on_click(x, y, button) — on Click event
|
|
155
|
+
on_mouse_down(x, y, button, mods={}) — on MouseDown event
|
|
156
|
+
on_mouse_up(x, y, button, mods={}) — on MouseUp event
|
|
157
|
+
on_mouse_move(x, y, buttons, mods={}) — on MouseMove event
|
|
158
|
+
on_command(text) — on Command event
|
|
159
|
+
on_paste(text) — on Paste event
|
|
160
|
+
on_component_event(node_id, event_type, payload)
|
|
161
|
+
— on L1 ComponentEvent (button click, input change)
|
|
162
|
+
on_text_changed(id, text) — on TextInput live edit
|
|
163
|
+
on_text_input_key(id, key, mods) — on TextInput Tab/up/down/Escape
|
|
164
|
+
on_text_submitted(id, text) — on TextInput Enter press
|
|
165
|
+
on_pipe_message(pipe_id, payload) — on PipeMessage
|
|
166
|
+
on_path_changed(cwd) — on PathChanged
|
|
167
|
+
on_inject(payload) — on Inject event
|
|
168
|
+
on_nav_back(view_id) — on NavBack event
|
|
169
|
+
on_timer(timer_id) — on Timer event
|
|
170
|
+
on_scroll(id, offset_y) — on Scroll event
|
|
171
|
+
on_file_picked(request_id, paths) — on FilePicked event
|
|
172
|
+
on_file_pick_cancelled(request_id) — on FilePickCancelled
|
|
173
|
+
on_mcp_call(tool_name, arguments) — on MCP tool call
|
|
174
|
+
|
|
175
|
+
Fire-and-forget (no RenderContext — called outside a render frame):
|
|
176
|
+
on_pane_spawned(pane_id, request_id) — pane spawn succeeded
|
|
177
|
+
on_pane_spawn_error(reason, request_id) — pane spawn failed
|
|
178
|
+
on_context_state(state) — context state query result
|
|
179
|
+
on_midi_input_opened(pipe_id, port_id, port_name) — MIDI input opened
|
|
180
|
+
|
|
181
|
+
Task handlers are dispatched as asyncio tasks — the event loop does not
|
|
182
|
+
wait for them to complete before processing the next event. Declare them
|
|
183
|
+
``async def`` whenever they do any I/O. Never call blocking operations
|
|
184
|
+
(time.sleep, requests.get, etc.) directly from these handlers; use
|
|
185
|
+
``await asyncio.to_thread(fn)`` or ``threading.Thread`` + ``emit.run_sync()``.
|
|
186
|
+
|
|
187
|
+
Awaited handlers block the event loop until they return. Use ``await``-able
|
|
188
|
+
Emitter helpers freely; they do not deadlock because the stdin reader runs
|
|
189
|
+
as a concurrent task.
|
|
190
|
+
|
|
191
|
+
Fire-and-forget handlers do not receive a RenderContext because they are
|
|
192
|
+
dispatched outside a render frame.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
# Populated by __init_subclass__ when Arg fields are declared on a subclass.
|
|
196
|
+
_arg_specs: "list[tuple[str, Arg]]" = []
|
|
197
|
+
|
|
198
|
+
# Background color applied automatically before each on_render call.
|
|
199
|
+
# Set to None to disable the default background and manage clearing manually.
|
|
200
|
+
# "__theme__" (default) resolves to ctx.theme.bg at render time.
|
|
201
|
+
default_background: "str | None" = "__theme__"
|
|
202
|
+
|
|
203
|
+
def __init__(self) -> None:
|
|
204
|
+
self._sdk_initialized: bool = True
|
|
205
|
+
self.app_id: str = ""
|
|
206
|
+
self.workspace_root: str = ""
|
|
207
|
+
self.capabilities: list[str] = []
|
|
208
|
+
self.feature_flags: list[str] = []
|
|
209
|
+
self.launch_args: list[str] = []
|
|
210
|
+
self._rect: dict = {"x": 0.0, "y": 0.0, "w": 800.0, "h": 600.0}
|
|
211
|
+
self._compact_threshold: float = 280.0
|
|
212
|
+
self._regular_threshold: float = 480.0
|
|
213
|
+
# The running asyncio event loop. Set by run() before hooks are called.
|
|
214
|
+
# Background threads use this via emit.run_sync() to schedule coroutines.
|
|
215
|
+
self._loop: "asyncio.AbstractEventLoop | None" = None
|
|
216
|
+
# All pending-response maps now hold asyncio.Queue so the event loop
|
|
217
|
+
# coroutine can await them without blocking the stdin reader.
|
|
218
|
+
self._pending_capability: "dict[str, asyncio.Queue]" = {}
|
|
219
|
+
self._pending_secret: "dict[str, asyncio.Queue]" = {}
|
|
220
|
+
self._app_state: dict = {}
|
|
221
|
+
self._pending_http: "dict[str, asyncio.Queue]" = {}
|
|
222
|
+
# v3.x async image loading (#1354): awaits PlexiEvent::ImageLoaded keyed
|
|
223
|
+
# on handle UUID. Each entry is consumed by a single load_image() call.
|
|
224
|
+
self._pending_image: "dict[str, asyncio.Queue]" = {}
|
|
225
|
+
# v3.3 ai.query broker (#284): awaits PlexiEvent::AiResponse keyed
|
|
226
|
+
# on request_id. Each entry is consumed by a single ai_query() call.
|
|
227
|
+
self._pending_ai: "dict[str, asyncio.Queue]" = {}
|
|
228
|
+
# v3.4 CoreMIDI (#320): awaits PlexiEvent::MidiDevicesListed keyed
|
|
229
|
+
# on request_id. Each entry is consumed by a single list_midi_devices().
|
|
230
|
+
self._pending_midi_devices: "dict[str, asyncio.Queue]" = {}
|
|
231
|
+
# v3.4 audio device enumeration (#341): awaits PlexiEvent::AudioDevicesListed keyed
|
|
232
|
+
# on request_id. Each entry is consumed by a single list_audio_devices() call.
|
|
233
|
+
self._pending_audio_devices: "dict[str, asyncio.Queue]" = {}
|
|
234
|
+
# v3.4 video substrate (#345): awaits PlexiEvent::VideoOpenAck /
|
|
235
|
+
# VideoOpenError keyed on request_id. Each entry is consumed by a
|
|
236
|
+
# single open_video() call.
|
|
237
|
+
self._pending_video_open: "dict[str, asyncio.Queue]" = {}
|
|
238
|
+
self._pending_notify: "dict[str, asyncio.Queue]" = {}
|
|
239
|
+
# #310: non-blocking notify_*_async callbacks. Keyed on notify_id;
|
|
240
|
+
# each callable is invoked on the event thread when NotifyAction arrives.
|
|
241
|
+
self._pending_notify_callbacks: "dict[str, Any]" = {}
|
|
242
|
+
# v3.5 Canvas Terminal Binding Primitives (#78). Two response shapes:
|
|
243
|
+
# `linked_terminal_ready` carries an int pane_id; `command_preview`
|
|
244
|
+
# carries (command, would_run_in_cwd). Each async helper awaits
|
|
245
|
+
# its own keyed queue.
|
|
246
|
+
self._pending_linked_terminal: "dict[str, asyncio.Queue]" = {}
|
|
247
|
+
self._pending_command_preview: "dict[str, asyncio.Queue]" = {}
|
|
248
|
+
# RenderContext.measure_text: awaits PlexiEvent::TextMeasured keyed on request_id.
|
|
249
|
+
self._pending_measure_text: "dict[str, asyncio.Queue]" = {}
|
|
250
|
+
# RenderContext.measure_text_wrapped: awaits PlexiEvent::TextWrappedMeasured.
|
|
251
|
+
self._pending_measure_text_wrapped: "dict[str, asyncio.Queue]" = {}
|
|
252
|
+
self._pipes: dict[str, Pipe] = {}
|
|
253
|
+
self._last_render_time: "float | None" = None
|
|
254
|
+
self._consecutive_render_errors: int = 0
|
|
255
|
+
# Strong references to background asyncio tasks created by
|
|
256
|
+
# _dispatch_hook_task. Without this, CPython may GC a task before it
|
|
257
|
+
# completes. The done callback removes each task from the set.
|
|
258
|
+
self._background_tasks: "set[asyncio.Task]" = set()
|
|
259
|
+
# Pending text-input submissions keyed on TextInput `id`. The
|
|
260
|
+
# event-loop coroutine fills this when `PlexiEvent::TextSubmitted`
|
|
261
|
+
# arrives; `RenderContext.text_input` drains it during render.
|
|
262
|
+
# One pending value per id — a second submit before the app
|
|
263
|
+
# consumes the first overwrites (apps poll every frame, so
|
|
264
|
+
# this only matters in a perverse scheduling case).
|
|
265
|
+
self._text_submissions: dict[str, str] = {}
|
|
266
|
+
# v3.7 tool protocol (#399): tool_name → handler callable.
|
|
267
|
+
# Registered via @app.tool(...) decorator.
|
|
268
|
+
self._tool_handlers: dict[str, Any] = {}
|
|
269
|
+
# Keep the full declared tool set so repeated @app.tool decorator
|
|
270
|
+
# calls expose the cumulative list instead of replacing prior tools.
|
|
271
|
+
self._tool_defs: dict[str, dict] = {}
|
|
272
|
+
self.emit = Emitter(self)
|
|
273
|
+
# v3.x button primitive (#255): last known mouse position and buffered
|
|
274
|
+
# click events for ctx.button() hit-testing during on_render.
|
|
275
|
+
self._mx: float = 0.0
|
|
276
|
+
self._my: float = 0.0
|
|
277
|
+
self._click_buf: list[tuple[float, float]] = []
|
|
278
|
+
# Hold timer for ctx.button() active_fill (#1083): maps button id →
|
|
279
|
+
# monotonic timestamp at which the active state expires.
|
|
280
|
+
self._btn_active_until: dict[str, float] = {}
|
|
281
|
+
# Scroll consumers registered by components during the last completed
|
|
282
|
+
# render frame. Populated from ctx._scroll_consumers after on_render (#1802).
|
|
283
|
+
self._scroll_consumers: list = []
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def w(self) -> float:
|
|
287
|
+
return self._rect["w"]
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def h(self) -> float:
|
|
291
|
+
return self._rect["h"]
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def state(self) -> "_AppStateProxy":
|
|
295
|
+
"""Host-persisted state. Use self.state.get/save instead of ctx.load_state/save_state."""
|
|
296
|
+
return _AppStateProxy(self)
|
|
297
|
+
|
|
298
|
+
def set_pip_status(self, status: str) -> None:
|
|
299
|
+
"""Report this app's pip status — the pane's activity dot.
|
|
300
|
+
|
|
301
|
+
``status`` is ``"green"``, ``"yellow"``, or ``"red"``. Fire-and-forget;
|
|
302
|
+
overrides the host's derived activity for this pane until you set it
|
|
303
|
+
again. No capability required; an unknown value is dropped with a warning.
|
|
304
|
+
"""
|
|
305
|
+
self.emit.set_pip_status(status)
|
|
306
|
+
|
|
307
|
+
def __init_subclass__(cls, **kwargs: object) -> None:
|
|
308
|
+
super().__init_subclass__(**kwargs)
|
|
309
|
+
|
|
310
|
+
# Collect Arg descriptors declared directly on this class
|
|
311
|
+
own_specs: "list[tuple[str, Arg]]" = [
|
|
312
|
+
(name, value)
|
|
313
|
+
for name, value in cls.__dict__.items()
|
|
314
|
+
if isinstance(value, Arg)
|
|
315
|
+
]
|
|
316
|
+
# Merge with parent's specs (own overrides parent entries with the same name)
|
|
317
|
+
parent_specs: "list[tuple[str, Arg]]" = list(getattr(cls, "_arg_specs", []))
|
|
318
|
+
if own_specs:
|
|
319
|
+
own_names = {n for n, _ in own_specs}
|
|
320
|
+
cls._arg_specs = [(n, s) for n, s in parent_specs if n not in own_names] + own_specs
|
|
321
|
+
else:
|
|
322
|
+
cls._arg_specs = parent_specs
|
|
323
|
+
|
|
324
|
+
orig_init = cls.__dict__.get("__init__")
|
|
325
|
+
if orig_init is not None:
|
|
326
|
+
def wrapped(self_inner: "App", *args: Any, _orig: Any = orig_init, **kw: Any) -> None:
|
|
327
|
+
if not getattr(self_inner, "_sdk_initialized", False):
|
|
328
|
+
App.__init__(self_inner)
|
|
329
|
+
_orig(self_inner, *args, **kw)
|
|
330
|
+
cls.__init__ = wrapped # type: ignore[assignment]
|
|
331
|
+
|
|
332
|
+
# ── Override these ──────────────────────────────────────────────────────
|
|
333
|
+
# All hooks may be overridden as either `def` (sync) or `async def`.
|
|
334
|
+
# _dispatch_hook detects the type at call time — both are valid.
|
|
335
|
+
# Return type is `Coroutine[Any, Any, None] | None` so Pyright accepts
|
|
336
|
+
# both sync (`def` → returns None) and async (`async def` → returns
|
|
337
|
+
# Coroutine) overrides without reportIncompatibleMethodOverride.
|
|
338
|
+
def on_init(self) -> "Coroutine[Any, Any, None] | None": return None
|
|
339
|
+
def on_render(self, _ctx: RenderContext) -> None: pass
|
|
340
|
+
def on_key(self, _key: str, _mods: dict) -> "Coroutine[Any, Any, None] | None":
|
|
341
|
+
return None
|
|
342
|
+
def on_escape(self) -> "bool | Coroutine[Any, Any, bool]":
|
|
343
|
+
"""Called when Escape is pressed. Return True if you handled it (e.g.
|
|
344
|
+
dismissed a modal, exited a sub-page). Return False to let the
|
|
345
|
+
framework close the app. Default: False (close)."""
|
|
346
|
+
return False
|
|
347
|
+
def on_click(self, _x: float, _y: float, _button: str) -> "Coroutine[Any, Any, None] | None": return None
|
|
348
|
+
def on_mouse_down(self, _x: float, _y: float, _button: str, _mods: dict = {}) -> "Coroutine[Any, Any, None] | None": return None
|
|
349
|
+
def on_mouse_up(self, _x: float, _y: float, _button: str, _mods: dict = {}) -> "Coroutine[Any, Any, None] | None": return None
|
|
350
|
+
def on_mouse_move(self, _x: float, _y: float, _buttons: list, _mods: dict = {}) -> "Coroutine[Any, Any, None] | None": return None
|
|
351
|
+
def on_command(self, _text: str) -> "Coroutine[Any, Any, None] | None": return None
|
|
352
|
+
def on_paste(self, _text: str) -> "Coroutine[Any, Any, None] | None": return None
|
|
353
|
+
def on_pipe_message(self, _pipe_id: str, _payload: Any) -> "Coroutine[Any, Any, None] | None": return None
|
|
354
|
+
def on_path_changed(self, _cwd: str) -> "Coroutine[Any, Any, None] | None": return None
|
|
355
|
+
def on_inject(self, _payload: Any) -> "Coroutine[Any, Any, None] | None": return None
|
|
356
|
+
def on_nav_back(self, _view_id: str) -> "Coroutine[Any, Any, None] | None":
|
|
357
|
+
"""Called when the host emits ``NavBack`` — user pressed Cmd+[ or the
|
|
358
|
+
back arrow in the pane chrome. ``view_id`` is the view being navigated
|
|
359
|
+
*back to* (the new top of stack, or empty string for root).
|
|
360
|
+
|
|
361
|
+
The app should update its own view state to show ``view_id``, then call
|
|
362
|
+
``self.emit.pop_nav()`` to remove the entry from the host stack.
|
|
363
|
+
"""
|
|
364
|
+
return None
|
|
365
|
+
def on_app_spawned(self, _pane_id: int, _type_id: str) -> None: pass
|
|
366
|
+
def on_pane_spawned(self, _pane_id: int, _request_id: "str | None" = None) -> None:
|
|
367
|
+
"""Called when a SpawnPane request succeeded (#592). Override to track the spawned pane."""
|
|
368
|
+
|
|
369
|
+
def on_pane_spawn_error(self, _reason: str, _request_id: "str | None" = None) -> None:
|
|
370
|
+
"""Called when a SpawnPane request failed (#592). Override to handle the error."""
|
|
371
|
+
|
|
372
|
+
def on_context_state(self, _state: dict) -> None:
|
|
373
|
+
"""Called when a QueryContextState response arrives (#1518).
|
|
374
|
+
|
|
375
|
+
``_state`` is a dict with keys: context_id, name, path, status,
|
|
376
|
+
pane_count, panes (list of pane summaries), children (list of child
|
|
377
|
+
context ids).
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
def on_timer(self, _timer_id: str) -> "Coroutine[Any, Any, None] | None": return None
|
|
381
|
+
def on_scroll(self, _id: str, _offset_y: float) -> "Coroutine[Any, Any, None] | None": return None
|
|
382
|
+
def on_scroll_delta(self, _delta_y: float) -> "Coroutine[Any, Any, None] | None": return None
|
|
383
|
+
def on_list_select(self, _id: str, _index: int) -> "Coroutine[Any, Any, None] | None":
|
|
384
|
+
"""Called when a list_view selection changes via j/k/up/down."""
|
|
385
|
+
return None
|
|
386
|
+
def on_list_activate(self, _id: str, _index: int) -> "Coroutine[Any, Any, None] | None":
|
|
387
|
+
"""Called when Enter is pressed on a selected list_view item."""
|
|
388
|
+
return None
|
|
389
|
+
def on_component_event(self, _node_id: str, _event_type: str, _payload: Any) -> "Coroutine[Any, Any, None] | None": return None
|
|
390
|
+
def on_text_changed(self, _id: str, _text: str) -> "Coroutine[Any, Any, None] | None": return None
|
|
391
|
+
def on_text_input_key(self, _id: str, _key: str, _mods: dict) -> "Coroutine[Any, Any, None] | None": return None
|
|
392
|
+
def on_text_submitted(self, _id: str, _text: str) -> "Coroutine[Any, Any, None] | None": return None
|
|
393
|
+
def on_file_picked(self, _request_id: str, _paths: "list[str]") -> "Coroutine[Any, Any, None] | None":
|
|
394
|
+
"""Called when the user selected one or more files in the picker.
|
|
395
|
+
|
|
396
|
+
``_request_id`` matches the id passed to ``self.emit.open_file_picker``.
|
|
397
|
+
``_paths`` is a list of absolute file paths chosen by the user.
|
|
398
|
+
"""
|
|
399
|
+
return None
|
|
400
|
+
def on_file_pick_cancelled(self, _request_id: str) -> "Coroutine[Any, Any, None] | None":
|
|
401
|
+
"""Called when the user dismissed the file picker without selecting a file,
|
|
402
|
+
or if the ``fs.pick`` capability was not declared.
|
|
403
|
+
"""
|
|
404
|
+
return None
|
|
405
|
+
"""Called when the host updates the scroll offset for a BeginScroll region.
|
|
406
|
+
|
|
407
|
+
`id` matches the id passed to `ctx.begin_scroll`. `offset_y` is the new
|
|
408
|
+
vertical offset in logical pixels. Override to re-render content at the
|
|
409
|
+
new position.
|
|
410
|
+
"""
|
|
411
|
+
def on_mcp_call(self, _tool_name: str, _arguments: dict) -> "dict | Coroutine[Any, Any, dict] | None":
|
|
412
|
+
"""Called when an external MCP client calls a tool declared in [app.mcp].
|
|
413
|
+
|
|
414
|
+
Override to handle the call and return a result dict. The result should
|
|
415
|
+
follow MCP CallToolResult schema: ``{"content": [{"type": "text", "text": "..."}]}``.
|
|
416
|
+
Return ``None`` to respond with a generic 'not implemented' error.
|
|
417
|
+
"""
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
def on_ai_stream_chunk(self, _request_id: str, _delta: str, _done: bool) -> None:
|
|
421
|
+
"""Called for each incremental token chunk from a streaming ai_query response.
|
|
422
|
+
|
|
423
|
+
Fired before the final ``ai_response`` event so apps can display tokens
|
|
424
|
+
as they arrive. ``_delta`` is the incremental text; ``_done`` is ``True``
|
|
425
|
+
on the last chunk before ``AiResponse`` fires.
|
|
426
|
+
|
|
427
|
+
Override to stream tokens into a display buffer::
|
|
428
|
+
|
|
429
|
+
def on_ai_stream_chunk(self, request_id, delta, done):
|
|
430
|
+
self.streaming_text += delta
|
|
431
|
+
self.emit.schedule_render()
|
|
432
|
+
|
|
433
|
+
Default: no-op. Apps that only care about the final result can ignore this.
|
|
434
|
+
"""
|
|
435
|
+
|
|
436
|
+
def on_ai_thinking_chunk(self, _request_id: str, _delta: str, _done: bool) -> None:
|
|
437
|
+
"""Called for each incremental reasoning ("thinking") chunk from a
|
|
438
|
+
streaming ai_query response against a reasoning model.
|
|
439
|
+
|
|
440
|
+
``_delta`` is incremental reasoning text, carried separately from the
|
|
441
|
+
answer text delivered via :meth:`on_ai_stream_chunk`. Override to show
|
|
442
|
+
a live thinking indicator::
|
|
443
|
+
|
|
444
|
+
def on_ai_thinking_chunk(self, request_id, delta, done):
|
|
445
|
+
self.thinking_text += delta
|
|
446
|
+
self.emit.schedule_render()
|
|
447
|
+
|
|
448
|
+
Default: no-op.
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
def on_midi_input_opened(
|
|
452
|
+
self,
|
|
453
|
+
_pipe_id: str,
|
|
454
|
+
_port_id: str,
|
|
455
|
+
_port_name: str,
|
|
456
|
+
) -> None:
|
|
457
|
+
"""Override to react to a successful OpenMidiInput. Apps that just
|
|
458
|
+
want the byte stream typically read directly from the binary pipe
|
|
459
|
+
opened alongside this event — Plexi sends `pipe_opened` first."""
|
|
460
|
+
pass
|
|
461
|
+
|
|
462
|
+
# ── Tool protocol (#398, #399) ──────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
def tool(self, name: str, description: str, schema: dict,
|
|
465
|
+
timeout_ms: "int | None" = None) -> Any:
|
|
466
|
+
"""Decorator — register a method as an AI-callable tool and expose it.
|
|
467
|
+
|
|
468
|
+
Tools are functions that AI agents can invoke by name. Each tool has a description
|
|
469
|
+
and a JSON schema defining its parameters.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
name: Tool identifier; used by agents to invoke this tool
|
|
473
|
+
description: Human-readable description of what the tool does
|
|
474
|
+
schema: JSON schema object for tool parameters (type: "object" with properties)
|
|
475
|
+
timeout_ms: Optional timeout in milliseconds; None = no timeout
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
A decorator function that registers the method as a tool.
|
|
479
|
+
|
|
480
|
+
Example::
|
|
481
|
+
|
|
482
|
+
@app.tool("increment", description="Increment counter", schema={
|
|
483
|
+
"type": "object",
|
|
484
|
+
"properties": {"n": {"type": "integer", "description": "Amount to increment"}},
|
|
485
|
+
"required": ["n"],
|
|
486
|
+
})
|
|
487
|
+
async def handle_increment(self, args):
|
|
488
|
+
n = args.get("n", 0)
|
|
489
|
+
self.counter += n
|
|
490
|
+
return {"new_value": self.counter}
|
|
491
|
+
|
|
492
|
+
The decorated method receives a dict of parsed arguments and can return any JSON-serializable
|
|
493
|
+
value or raise an exception (which becomes the tool's error response).
|
|
494
|
+
"""
|
|
495
|
+
def decorator(fn: Any) -> Any:
|
|
496
|
+
self._tool_handlers[name] = fn
|
|
497
|
+
tool_def: dict = {
|
|
498
|
+
"name": name,
|
|
499
|
+
"description": description,
|
|
500
|
+
"input_schema": schema,
|
|
501
|
+
}
|
|
502
|
+
if timeout_ms is not None:
|
|
503
|
+
tool_def["timeout_ms"] = timeout_ms
|
|
504
|
+
self._tool_defs[name] = tool_def
|
|
505
|
+
self.emit.expose_tools(list(self._tool_defs.values()))
|
|
506
|
+
return fn
|
|
507
|
+
return decorator
|
|
508
|
+
|
|
509
|
+
def view(self) -> object | None:
|
|
510
|
+
"""Override to return a declarative component tree.
|
|
511
|
+
|
|
512
|
+
Return ``None`` (the default) to use the flat draw-command path via
|
|
513
|
+
``on_render``. When overridden, the host renderer will walk the returned
|
|
514
|
+
tree instead of calling ``on_render``.
|
|
515
|
+
|
|
516
|
+
``UiNode`` is defined in ``plexi_sdk.ui`` (epic #1897, task A2).
|
|
517
|
+
"""
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
def on_suspend(self) -> None: pass
|
|
521
|
+
def on_resume(self) -> None: pass
|
|
522
|
+
def on_shutdown(self) -> None: pass
|
|
523
|
+
|
|
524
|
+
# ── Undo flow (docs/prm/undo-and-app-events.md) ─────────────────────────
|
|
525
|
+
|
|
526
|
+
def on_rollback_verify(self, _checkpoint_id: str, _resource_id: str,
|
|
527
|
+
_expected_revision: str) -> "str | None":
|
|
528
|
+
"""Answer a host rollback verification with the resource's *current*
|
|
529
|
+
revision string. Apps that emit reversible events (``rollback_token``)
|
|
530
|
+
MUST override this; returning ``None`` (the default) reports an empty
|
|
531
|
+
revision, which never matches — rollback is safely blocked."""
|
|
532
|
+
return None
|
|
533
|
+
|
|
534
|
+
def on_rollback_apply(self, _checkpoint_id: str, _resource_id: str,
|
|
535
|
+
_rollback_token: str) -> "Coroutine[Any, Any, None] | None":
|
|
536
|
+
"""Apply a verified rollback: undo the mutation identified by
|
|
537
|
+
``rollback_token`` and emit the matching reversal event
|
|
538
|
+
(e.g. ``move.undone``). Default: no-op."""
|
|
539
|
+
return None
|
|
540
|
+
|
|
541
|
+
# ── Internal ────────────────────────────────────────────────────────────
|
|
542
|
+
|
|
543
|
+
async def _handle_rollback_verify(self, ev: dict) -> None:
|
|
544
|
+
"""Dispatch ``PlexiEvent::RollbackVerify`` and answer the host with
|
|
545
|
+
``AppRequest::RollbackVerifyResult``."""
|
|
546
|
+
checkpoint_id = str(ev.get("checkpoint_id", ""))
|
|
547
|
+
resource_id = str(ev.get("resource_id", ""))
|
|
548
|
+
expected = str(ev.get("expected_revision", ""))
|
|
549
|
+
try:
|
|
550
|
+
import inspect as _inspect
|
|
551
|
+
result = self.on_rollback_verify(checkpoint_id, resource_id, expected)
|
|
552
|
+
if _inspect.iscoroutine(result):
|
|
553
|
+
result = await result
|
|
554
|
+
except Exception as exc:
|
|
555
|
+
self.emit.error(f"on_rollback_verify failed: {exc}")
|
|
556
|
+
result = None
|
|
557
|
+
if result is None:
|
|
558
|
+
self.emit.warn(
|
|
559
|
+
f"rollback_verify for {checkpoint_id!r}: on_rollback_verify not "
|
|
560
|
+
"implemented — reporting empty revision (rollback will be blocked)"
|
|
561
|
+
)
|
|
562
|
+
result = ""
|
|
563
|
+
self.emit.rollback_verify_result(checkpoint_id, str(result))
|
|
564
|
+
|
|
565
|
+
async def _handle_tool_call(self, ev: dict) -> None:
|
|
566
|
+
"""Dispatch a ``PlexiEvent::ToolCall`` to the registered handler.
|
|
567
|
+
|
|
568
|
+
Sends ``DrawCommand::ToolResult`` with the return value (JSON-serialised)
|
|
569
|
+
or with an error string if the handler raises or is not registered.
|
|
570
|
+
"""
|
|
571
|
+
call_id: str = ev.get("call_id", "")
|
|
572
|
+
name: str = ev.get("name", "")
|
|
573
|
+
input_json: str = ev.get("input_json", "{}")
|
|
574
|
+
|
|
575
|
+
try:
|
|
576
|
+
args = json.loads(input_json) if input_json else {}
|
|
577
|
+
except json.JSONDecodeError as exc:
|
|
578
|
+
_emit({
|
|
579
|
+
"type": "tool_result",
|
|
580
|
+
"call_id": call_id,
|
|
581
|
+
"output_json": None,
|
|
582
|
+
"error": f"tool_input_parse_error: {exc}",
|
|
583
|
+
})
|
|
584
|
+
return
|
|
585
|
+
|
|
586
|
+
handler = self._tool_handlers.get(name)
|
|
587
|
+
if handler is None:
|
|
588
|
+
_emit({
|
|
589
|
+
"type": "tool_result",
|
|
590
|
+
"call_id": call_id,
|
|
591
|
+
"output_json": None,
|
|
592
|
+
"error": f"tool_not_found: no handler registered for tool {name!r}",
|
|
593
|
+
})
|
|
594
|
+
return
|
|
595
|
+
|
|
596
|
+
# Events emitted while this handler runs carry the caller's broker
|
|
597
|
+
# identity as `caused_by` (see _emitter._current_tool_caller) so the
|
|
598
|
+
# host can attribute them to this tool call.
|
|
599
|
+
from ._emitter import _current_tool_caller
|
|
600
|
+
caller_token = _current_tool_caller.set(ev.get("caller_id") or None)
|
|
601
|
+
try:
|
|
602
|
+
import inspect as _inspect
|
|
603
|
+
if _inspect.iscoroutinefunction(handler):
|
|
604
|
+
result = await handler(args)
|
|
605
|
+
else:
|
|
606
|
+
result = handler(args)
|
|
607
|
+
output_json = json.dumps(result) if result is not None else json.dumps({})
|
|
608
|
+
_emit({
|
|
609
|
+
"type": "tool_result",
|
|
610
|
+
"call_id": call_id,
|
|
611
|
+
"output_json": output_json,
|
|
612
|
+
"error": None,
|
|
613
|
+
})
|
|
614
|
+
except Exception as exc:
|
|
615
|
+
import traceback as _tb
|
|
616
|
+
_tb.print_exc()
|
|
617
|
+
_emit({
|
|
618
|
+
"type": "tool_result",
|
|
619
|
+
"call_id": call_id,
|
|
620
|
+
"output_json": None,
|
|
621
|
+
"error": f"tool_handler_error: {exc}",
|
|
622
|
+
})
|
|
623
|
+
finally:
|
|
624
|
+
_current_tool_caller.reset(caller_token)
|
|
625
|
+
|
|
626
|
+
async def _handle_mcp_tool_call(self, ev: dict) -> None:
|
|
627
|
+
"""Dispatch a PlexiEvent::McpToolCall to on_mcp_call."""
|
|
628
|
+
call_id: str = ev.get("call_id", "")
|
|
629
|
+
tool_name: str = ev.get("tool_name", "")
|
|
630
|
+
arguments: dict = ev.get("arguments", {})
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
import inspect as _inspect
|
|
634
|
+
if _inspect.iscoroutinefunction(self.on_mcp_call):
|
|
635
|
+
result = await self.on_mcp_call(tool_name, arguments)
|
|
636
|
+
else:
|
|
637
|
+
result = self.on_mcp_call(tool_name, arguments)
|
|
638
|
+
|
|
639
|
+
if result is None:
|
|
640
|
+
_emit({
|
|
641
|
+
"type": "mcp_tool_result",
|
|
642
|
+
"call_id": call_id,
|
|
643
|
+
"result": None,
|
|
644
|
+
"error": f"tool_not_implemented: {tool_name!r}",
|
|
645
|
+
})
|
|
646
|
+
else:
|
|
647
|
+
_emit({
|
|
648
|
+
"type": "mcp_tool_result",
|
|
649
|
+
"call_id": call_id,
|
|
650
|
+
"result": result,
|
|
651
|
+
"error": None,
|
|
652
|
+
})
|
|
653
|
+
except Exception as exc:
|
|
654
|
+
import traceback as _tb
|
|
655
|
+
_tb.print_exc()
|
|
656
|
+
_emit({
|
|
657
|
+
"type": "mcp_tool_result",
|
|
658
|
+
"call_id": call_id,
|
|
659
|
+
"result": None,
|
|
660
|
+
"error": f"mcp_tool_handler_error: {exc}",
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
def _take_text_submission(self, id: str) -> "str | None":
|
|
664
|
+
"""Pop the most recent submission for `id` if one is queued, else None.
|
|
665
|
+
|
|
666
|
+
Called by `RenderContext.text_input` to surface a buffered
|
|
667
|
+
`TextSubmitted` value into the current frame's render call.
|
|
668
|
+
"""
|
|
669
|
+
return self._text_submissions.pop(id, None)
|
|
670
|
+
|
|
671
|
+
def _make_ctx(self, frame_id: int = 0, elapsed: float = 0.0,
|
|
672
|
+
clicks: "list[tuple[float, float]] | None" = None) -> RenderContext:
|
|
673
|
+
ctx = RenderContext(
|
|
674
|
+
frame_id=frame_id,
|
|
675
|
+
rect=self._rect,
|
|
676
|
+
workspace_root=self.workspace_root,
|
|
677
|
+
capabilities=self.capabilities,
|
|
678
|
+
feature_flags=self.feature_flags,
|
|
679
|
+
app=self,
|
|
680
|
+
elapsed=elapsed,
|
|
681
|
+
clicks=clicks or [],
|
|
682
|
+
)
|
|
683
|
+
ctx._compact_threshold = self._compact_threshold
|
|
684
|
+
ctx._regular_threshold = self._regular_threshold
|
|
685
|
+
return ctx
|
|
686
|
+
|
|
687
|
+
def _parse_launch_args(self, ctx: RenderContext) -> None:
|
|
688
|
+
"""Parse sys.argv against declared Arg specs and set resolved instance attributes.
|
|
689
|
+
|
|
690
|
+
Called automatically before on_init when the class declares Arg fields.
|
|
691
|
+
Lambda defaults receive ctx and are resolved here.
|
|
692
|
+
"""
|
|
693
|
+
import argparse as _argparse
|
|
694
|
+
specs: "list[tuple[str, Arg]]" = type(self)._arg_specs
|
|
695
|
+
if not specs:
|
|
696
|
+
return
|
|
697
|
+
|
|
698
|
+
parser = _argparse.ArgumentParser(add_help=False)
|
|
699
|
+
lambda_defaults: "dict[str, Any]" = {}
|
|
700
|
+
|
|
701
|
+
for attr_name, arg_spec in specs:
|
|
702
|
+
dest = arg_spec.dest or attr_name
|
|
703
|
+
is_lambda = callable(arg_spec.default) and not isinstance(arg_spec.default, type)
|
|
704
|
+
if is_lambda:
|
|
705
|
+
raw_default: Any = None
|
|
706
|
+
lambda_defaults[attr_name] = arg_spec.default
|
|
707
|
+
elif arg_spec.default is _MISSING:
|
|
708
|
+
raw_default = None
|
|
709
|
+
else:
|
|
710
|
+
raw_default = arg_spec.default
|
|
711
|
+
|
|
712
|
+
if arg_spec.positional:
|
|
713
|
+
pkw: "dict[str, Any]" = {}
|
|
714
|
+
if arg_spec.nargs is not None:
|
|
715
|
+
pkw["nargs"] = arg_spec.nargs
|
|
716
|
+
pkw["default"] = raw_default if raw_default is not None else []
|
|
717
|
+
elif arg_spec.default is not _MISSING:
|
|
718
|
+
# Explicit default → optional positional
|
|
719
|
+
pkw["nargs"] = "?"
|
|
720
|
+
pkw["default"] = raw_default
|
|
721
|
+
# else: no nargs, no default → required positional (argparse enforces)
|
|
722
|
+
if arg_spec.arg_type is not None and arg_spec.arg_type is not bool:
|
|
723
|
+
pkw["type"] = arg_spec.arg_type
|
|
724
|
+
parser.add_argument(dest, **pkw)
|
|
725
|
+
else:
|
|
726
|
+
if not arg_spec.flags:
|
|
727
|
+
continue
|
|
728
|
+
okw: "dict[str, Any]" = {"dest": dest, "default": raw_default}
|
|
729
|
+
if arg_spec.arg_type is bool:
|
|
730
|
+
okw["action"] = "store_true"
|
|
731
|
+
elif arg_spec.arg_type is not None:
|
|
732
|
+
okw["type"] = arg_spec.arg_type
|
|
733
|
+
if arg_spec.nargs is not None:
|
|
734
|
+
okw["nargs"] = arg_spec.nargs
|
|
735
|
+
parser.add_argument(*arg_spec.flags, **okw)
|
|
736
|
+
|
|
737
|
+
argv = [a for a in sys.argv[1:] if a != "--plexi-introspect"]
|
|
738
|
+
try:
|
|
739
|
+
ns, _ = parser.parse_known_args(argv)
|
|
740
|
+
except SystemExit as e:
|
|
741
|
+
sys.exit(e.code)
|
|
742
|
+
|
|
743
|
+
for attr_name, arg_spec in specs:
|
|
744
|
+
dest = arg_spec.dest or attr_name
|
|
745
|
+
value = getattr(ns, dest, None)
|
|
746
|
+
|
|
747
|
+
if value is None and arg_spec.default is _MISSING and not arg_spec.positional:
|
|
748
|
+
flag = arg_spec.flags[0] if arg_spec.flags else attr_name
|
|
749
|
+
sys.stderr.write(f"{flag}: required argument missing\n")
|
|
750
|
+
sys.exit(1)
|
|
751
|
+
|
|
752
|
+
if value is None and attr_name in lambda_defaults:
|
|
753
|
+
value = lambda_defaults[attr_name](ctx)
|
|
754
|
+
|
|
755
|
+
setattr(self, attr_name, value)
|
|
756
|
+
self.emit.info(
|
|
757
|
+
f"args: parsed {len(specs)} arg(s): "
|
|
758
|
+
+ ", ".join(f"{n}={getattr(self, n)!r}" for n, _ in specs)
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
def run(self) -> None:
|
|
762
|
+
"""Start the PGAP v3 asyncio event loop. Blocks until Shutdown.
|
|
763
|
+
|
|
764
|
+
This is the entry point for all Plexi apps. Call it from your main block:
|
|
765
|
+
|
|
766
|
+
if __name__ == '__main__':
|
|
767
|
+
app = MyApp()
|
|
768
|
+
app.run()
|
|
769
|
+
"""
|
|
770
|
+
if not getattr(self, "_sdk_initialized", False):
|
|
771
|
+
raise RuntimeError(
|
|
772
|
+
f"{type(self).__name__}.run() called but SDK was not initialized. "
|
|
773
|
+
"Your __init__ must call super().__init__() first, or omit __init__ entirely. "
|
|
774
|
+
"Example:\n"
|
|
775
|
+
" class MyApp(App):\n"
|
|
776
|
+
" def __init__(self):\n"
|
|
777
|
+
" super().__init__()\n"
|
|
778
|
+
" self.my_state = {}"
|
|
779
|
+
)
|
|
780
|
+
if "--plexi-introspect" in sys.argv:
|
|
781
|
+
self._run_introspect()
|
|
782
|
+
return
|
|
783
|
+
sys.stdout.reconfigure(line_buffering=True) # type: ignore[union-attr]
|
|
784
|
+
asyncio.run(self._async_main())
|
|
785
|
+
|
|
786
|
+
def _run_introspect(self) -> None:
|
|
787
|
+
"""Static capability check mode — called when launched with --plexi-introspect.
|
|
788
|
+
|
|
789
|
+
Inspects method bodies of this App subclass for emit.* / ctx.* calls
|
|
790
|
+
using AST analysis (not regex) to avoid false positives in docstrings.
|
|
791
|
+
Only scans methods defined in the subclass's own module (not base class).
|
|
792
|
+
Prints {"required_capabilities": [...]} to stdout, then exits.
|
|
793
|
+
"""
|
|
794
|
+
import ast
|
|
795
|
+
import inspect
|
|
796
|
+
import json
|
|
797
|
+
import textwrap
|
|
798
|
+
from ._emitter import CAPABILITY_REGISTRY
|
|
799
|
+
|
|
800
|
+
required: set[str] = set()
|
|
801
|
+
app_module = type(self).__module__
|
|
802
|
+
|
|
803
|
+
for _name, method in inspect.getmembers(type(self), predicate=inspect.isfunction):
|
|
804
|
+
if getattr(method, "__module__", None) != app_module:
|
|
805
|
+
continue
|
|
806
|
+
try:
|
|
807
|
+
source = textwrap.dedent(inspect.getsource(method))
|
|
808
|
+
tree = ast.parse(source)
|
|
809
|
+
except (OSError, TypeError, SyntaxError, IndentationError):
|
|
810
|
+
continue
|
|
811
|
+
for node in ast.walk(tree):
|
|
812
|
+
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
|
|
813
|
+
continue
|
|
814
|
+
func = node.func
|
|
815
|
+
base: "str | None" = None
|
|
816
|
+
if isinstance(func.value, ast.Name):
|
|
817
|
+
base = func.value.id
|
|
818
|
+
elif (
|
|
819
|
+
isinstance(func.value, ast.Attribute)
|
|
820
|
+
and isinstance(func.value.value, ast.Name)
|
|
821
|
+
and func.value.value.id == "self"
|
|
822
|
+
):
|
|
823
|
+
base = f"self.{func.value.attr}"
|
|
824
|
+
if base in ("self.emit", "self.ctx", "ctx", "emit"):
|
|
825
|
+
method_name = func.attr
|
|
826
|
+
if method_name in CAPABILITY_REGISTRY:
|
|
827
|
+
required.add(CAPABILITY_REGISTRY[method_name])
|
|
828
|
+
|
|
829
|
+
print(json.dumps({"required_capabilities": sorted(required)}), flush=True)
|
|
830
|
+
sys.exit(0)
|
|
831
|
+
|
|
832
|
+
async def _async_main(self) -> None:
|
|
833
|
+
"""Asyncio entry point — two concurrent tasks to eliminate deadlocks.
|
|
834
|
+
|
|
835
|
+
The root cause of the old single-loop design: when the dispatcher
|
|
836
|
+
awaited a hook (e.g. on_init) that itself called a blocking helper
|
|
837
|
+
(e.g. request_linked_terminal), the event loop had no concurrent
|
|
838
|
+
stdin reader in flight. Nothing could deliver the response event
|
|
839
|
+
while the hook was suspended, causing a permanent deadlock.
|
|
840
|
+
|
|
841
|
+
Fix: split into two tasks that run concurrently on the same event loop.
|
|
842
|
+
|
|
843
|
+
_reader — always has a run_in_executor(readline) in flight.
|
|
844
|
+
Handles response events inline (put_nowait into pending
|
|
845
|
+
queues) so they can unblock awaiting hooks even while the
|
|
846
|
+
dispatcher is suspended.
|
|
847
|
+
|
|
848
|
+
_dispatcher — drains a hook_q, dispatches hook events sequentially.
|
|
849
|
+
Can safely await hooks because _reader is always running
|
|
850
|
+
alongside it and will deliver response events.
|
|
851
|
+
|
|
852
|
+
Response events MUST be handled inline in _reader — never enqueued —
|
|
853
|
+
so that hooks awaiting on pending queues can be unblocked even when
|
|
854
|
+
the dispatcher is suspended mid-hook.
|
|
855
|
+
"""
|
|
856
|
+
loop = asyncio.get_running_loop()
|
|
857
|
+
self._loop = loop
|
|
858
|
+
hook_q: asyncio.Queue = asyncio.Queue()
|
|
859
|
+
|
|
860
|
+
async def _reader() -> None:
|
|
861
|
+
while True:
|
|
862
|
+
raw = await loop.run_in_executor(None, sys.stdin.readline)
|
|
863
|
+
if not raw:
|
|
864
|
+
# EOF — host closed stdin; signal dispatcher to shut down.
|
|
865
|
+
await hook_q.put({"type": "shutdown"})
|
|
866
|
+
return
|
|
867
|
+
raw = raw.strip()
|
|
868
|
+
if not raw:
|
|
869
|
+
continue
|
|
870
|
+
try:
|
|
871
|
+
ev = json.loads(raw)
|
|
872
|
+
except json.JSONDecodeError:
|
|
873
|
+
continue
|
|
874
|
+
|
|
875
|
+
t = ev.get("type", "")
|
|
876
|
+
|
|
877
|
+
# ── Response events: handled inline so they can unblock ──────
|
|
878
|
+
# hooks suspended in the dispatcher. These must NEVER go on
|
|
879
|
+
# hook_q — that would leave awaiting coroutines stuck forever.
|
|
880
|
+
|
|
881
|
+
if t == "capability_decision":
|
|
882
|
+
req_id = ev.get("request_id", "")
|
|
883
|
+
granted = ev.get("granted", False)
|
|
884
|
+
q = self._pending_capability.pop(req_id, None)
|
|
885
|
+
if q:
|
|
886
|
+
q.put_nowait(granted)
|
|
887
|
+
if granted:
|
|
888
|
+
cap = ev.get("capability", "")
|
|
889
|
+
if cap and cap not in self.capabilities:
|
|
890
|
+
self.capabilities.append(cap)
|
|
891
|
+
|
|
892
|
+
elif t == "secret_value":
|
|
893
|
+
key = ev.get("key", "")
|
|
894
|
+
value = ev.get("value")
|
|
895
|
+
q = self._pending_secret.pop(key, None)
|
|
896
|
+
if q:
|
|
897
|
+
q.put_nowait(value)
|
|
898
|
+
|
|
899
|
+
elif t == "http_response":
|
|
900
|
+
req_id = ev.get("request_id", "")
|
|
901
|
+
q = self._pending_http.pop(req_id, None)
|
|
902
|
+
if q:
|
|
903
|
+
if ev.get("error"):
|
|
904
|
+
q.put_nowait(("error", ev["error"]))
|
|
905
|
+
else:
|
|
906
|
+
q.put_nowait(("ok", ev.get("body", "")))
|
|
907
|
+
|
|
908
|
+
elif t == "image_loaded":
|
|
909
|
+
handle = ev.get("handle", "")
|
|
910
|
+
q = self._pending_image.pop(handle, None)
|
|
911
|
+
if q:
|
|
912
|
+
status = ev.get("status", "error")
|
|
913
|
+
message = ev.get("message")
|
|
914
|
+
q.put_nowait((status, message))
|
|
915
|
+
|
|
916
|
+
elif t == "ai_stream_chunk":
|
|
917
|
+
# Incremental chunk from a streaming ai_query response.
|
|
918
|
+
# Reasoning ("thinking") deltas dispatch to
|
|
919
|
+
# on_ai_thinking_chunk; text deltas to on_ai_stream_chunk.
|
|
920
|
+
reasoning = ev.get("reasoning")
|
|
921
|
+
if reasoning is not None:
|
|
922
|
+
if type(self).on_ai_thinking_chunk is not App.on_ai_thinking_chunk:
|
|
923
|
+
self._dispatch_hook_task(
|
|
924
|
+
self.on_ai_thinking_chunk,
|
|
925
|
+
str(ev.get("request_id", "")),
|
|
926
|
+
str(reasoning),
|
|
927
|
+
bool(ev.get("done", False)),
|
|
928
|
+
)
|
|
929
|
+
elif type(self).on_ai_stream_chunk is not App.on_ai_stream_chunk:
|
|
930
|
+
self._dispatch_hook_task(
|
|
931
|
+
self.on_ai_stream_chunk,
|
|
932
|
+
str(ev.get("request_id", "")),
|
|
933
|
+
str(ev.get("delta", "")),
|
|
934
|
+
bool(ev.get("done", False)),
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
elif t == "ai_response":
|
|
938
|
+
# v3.3 ai.query broker (#284). Hand the whole event dict to
|
|
939
|
+
# `Emitter.ai_query` so it can split error vs success and
|
|
940
|
+
# attach token counts.
|
|
941
|
+
req_id = ev.get("request_id", "")
|
|
942
|
+
q = self._pending_ai.pop(req_id, None)
|
|
943
|
+
if q:
|
|
944
|
+
q.put_nowait(ev)
|
|
945
|
+
else:
|
|
946
|
+
import logging as _logging
|
|
947
|
+
_logging.warning(
|
|
948
|
+
f"ai_response: no pending request for req_id={req_id!r} — "
|
|
949
|
+
"response dropped (query may have timed out already)"
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
elif t == "midi_devices_listed":
|
|
953
|
+
# v3.4 CoreMIDI (#320). Forward to Emitter.list_midi_devices.
|
|
954
|
+
req_id = ev.get("request_id", "")
|
|
955
|
+
q = self._pending_midi_devices.pop(req_id, None)
|
|
956
|
+
if q:
|
|
957
|
+
q.put_nowait(ev)
|
|
958
|
+
|
|
959
|
+
elif t == "audio_devices_listed":
|
|
960
|
+
# v3.4 audio device enumeration (#341). Forward to Emitter.list_audio_devices.
|
|
961
|
+
req_id = ev.get("request_id", "")
|
|
962
|
+
q = self._pending_audio_devices.pop(req_id, None)
|
|
963
|
+
if q:
|
|
964
|
+
q.put_nowait(ev)
|
|
965
|
+
|
|
966
|
+
elif t == "video_open_ack":
|
|
967
|
+
# v3.4 video substrate (#345). Forward to Emitter.open_video().
|
|
968
|
+
req_id = str(ev.get("request_id", ""))
|
|
969
|
+
q = self._pending_video_open.pop(req_id, None)
|
|
970
|
+
if q:
|
|
971
|
+
q.put_nowait(ev)
|
|
972
|
+
|
|
973
|
+
elif t == "video_open_error":
|
|
974
|
+
# OpenVideo failed (capability denied, NotImplemented from the
|
|
975
|
+
# production stub, bad source). Forward the error event so
|
|
976
|
+
# `open_video()` can raise CapabilityDeniedError / RuntimeError.
|
|
977
|
+
req_id = str(ev.get("request_id", ""))
|
|
978
|
+
q = self._pending_video_open.pop(req_id, None)
|
|
979
|
+
if q:
|
|
980
|
+
q.put_nowait(ev)
|
|
981
|
+
|
|
982
|
+
elif t == "linked_terminal_ready":
|
|
983
|
+
# v3.5 #78. Forward the terminal_pane_id (int) to the
|
|
984
|
+
# awaiting helper. 0 = capability denied — the helper
|
|
985
|
+
# raises CapabilityDeniedError when it sees that.
|
|
986
|
+
req_id = ev.get("request_id", "")
|
|
987
|
+
q = self._pending_linked_terminal.pop(req_id, None)
|
|
988
|
+
if q:
|
|
989
|
+
q.put_nowait(int(ev.get("terminal_pane_id", 0)))
|
|
990
|
+
|
|
991
|
+
elif t == "command_preview":
|
|
992
|
+
# v3.5 #78. Forward (command, would_run_in_cwd) tuple to the
|
|
993
|
+
# awaiting helper. would_run_in_cwd is "" on capability denial.
|
|
994
|
+
req_id = ev.get("request_id", "")
|
|
995
|
+
q = self._pending_command_preview.pop(req_id, None)
|
|
996
|
+
if q:
|
|
997
|
+
q.put_nowait((
|
|
998
|
+
str(ev.get("command", "")),
|
|
999
|
+
str(ev.get("would_run_in_cwd", "")),
|
|
1000
|
+
))
|
|
1001
|
+
|
|
1002
|
+
elif t == "notify_action":
|
|
1003
|
+
# notify_choice / notify_input: put the value back.
|
|
1004
|
+
# notify / notify_and_wait: put action_label back.
|
|
1005
|
+
# Esc cancel: return "__cancel__" so callers can check easily.
|
|
1006
|
+
notify_id = ev.get("notify_id", "")
|
|
1007
|
+
action_label = ev.get("action_label", "")
|
|
1008
|
+
value = ev.get("value")
|
|
1009
|
+
if action_label == "cancel":
|
|
1010
|
+
result = "__cancel__"
|
|
1011
|
+
elif value is not None:
|
|
1012
|
+
result = value
|
|
1013
|
+
else:
|
|
1014
|
+
result = action_label or "acknowledge"
|
|
1015
|
+
q = self._pending_notify.pop(notify_id, None)
|
|
1016
|
+
if q:
|
|
1017
|
+
q.put_nowait(result)
|
|
1018
|
+
else:
|
|
1019
|
+
cb = self._pending_notify_callbacks.pop(notify_id, None)
|
|
1020
|
+
if cb is not None:
|
|
1021
|
+
self.emit.info(f"notify_async: dispatching callback for {notify_id!r}")
|
|
1022
|
+
try:
|
|
1023
|
+
cb(result)
|
|
1024
|
+
except Exception as exc:
|
|
1025
|
+
sys.stderr.write(f"plexi_sdk: notify_async callback raised: {exc}\n")
|
|
1026
|
+
|
|
1027
|
+
elif t == "text_measured":
|
|
1028
|
+
# Response to RenderContext.measure_text(). Forward (width, height)
|
|
1029
|
+
# to the awaiting coroutine keyed on request_id.
|
|
1030
|
+
req_id = ev.get("request_id", "")
|
|
1031
|
+
q = self._pending_measure_text.pop(req_id, None)
|
|
1032
|
+
if q:
|
|
1033
|
+
q.put_nowait((
|
|
1034
|
+
float(ev.get("width", 0.0)),
|
|
1035
|
+
float(ev.get("height", 0.0)),
|
|
1036
|
+
))
|
|
1037
|
+
|
|
1038
|
+
elif t == "text_wrapped_measured":
|
|
1039
|
+
req_id = ev.get("request_id", "")
|
|
1040
|
+
q = self._pending_measure_text_wrapped.pop(req_id, None)
|
|
1041
|
+
if q:
|
|
1042
|
+
q.put_nowait(float(ev.get("height", 0.0)))
|
|
1043
|
+
|
|
1044
|
+
# ── Inline non-hook events (fast, no user code) ──────────────
|
|
1045
|
+
|
|
1046
|
+
elif t == "pipe_opened":
|
|
1047
|
+
pipe_id = ev.get("pipe_id", "")
|
|
1048
|
+
socket_path = ev.get("socket_path", "")
|
|
1049
|
+
p = self._pipes.get(pipe_id)
|
|
1050
|
+
if p:
|
|
1051
|
+
p._on_opened(socket_path)
|
|
1052
|
+
|
|
1053
|
+
elif t == "pipe_overrun":
|
|
1054
|
+
self.emit.warn(
|
|
1055
|
+
f"pipe overrun pipe_id={ev.get('pipe_id')} "
|
|
1056
|
+
f"dropped={ev.get('dropped_frames')}"
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
elif t == "midi_input_error":
|
|
1060
|
+
# OpenMidiInput failed (capability denied, port_id not found,
|
|
1061
|
+
# CoreMIDI error). Apps log this; the typical recovery is to
|
|
1062
|
+
# surface the error in-pane and let the user pick a different
|
|
1063
|
+
# port from list_midi_devices.
|
|
1064
|
+
self.emit.warn(
|
|
1065
|
+
f"midi_input_error pipe_id={ev.get('pipe_id')} "
|
|
1066
|
+
f"error={ev.get('error')}"
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
elif t == "midi_send_error":
|
|
1070
|
+
# SendMidi failed. Surfaces only on capability denial / open
|
|
1071
|
+
# failure / coremidi error — successful sends produce no event.
|
|
1072
|
+
self.emit.warn(
|
|
1073
|
+
f"midi_send_error port_id={ev.get('port_id')} "
|
|
1074
|
+
f"error={ev.get('error')}"
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
elif t == "text_submitted":
|
|
1078
|
+
# Host-owned text input: the user pressed Enter on a
|
|
1079
|
+
# `DrawCommand::TextInput` field. Stash the value keyed
|
|
1080
|
+
# on the input id; `RenderContext.text_input(...)` will
|
|
1081
|
+
# drain it on the next frame the app polls.
|
|
1082
|
+
# Also dispatch on_text_submitted as a hook task if the
|
|
1083
|
+
# app has overridden it — apps that use the event-handler
|
|
1084
|
+
# path can do I/O cleanly without side effects in on_render.
|
|
1085
|
+
tid = ev.get("id", "")
|
|
1086
|
+
if tid:
|
|
1087
|
+
value = ev.get("value", "")
|
|
1088
|
+
self._text_submissions[tid] = value
|
|
1089
|
+
if type(self).on_text_submitted is not App.on_text_submitted:
|
|
1090
|
+
self._dispatch_hook_task(
|
|
1091
|
+
self.on_text_submitted, tid, value
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
elif t == "text_changed":
|
|
1095
|
+
tid = ev.get("id", "")
|
|
1096
|
+
if tid and type(self).on_text_changed is not App.on_text_changed:
|
|
1097
|
+
self._dispatch_hook_task(
|
|
1098
|
+
self.on_text_changed, tid, ev.get("value", "")
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
elif t == "text_input_key":
|
|
1102
|
+
tid = ev.get("id", "")
|
|
1103
|
+
if tid and type(self).on_text_input_key is not App.on_text_input_key:
|
|
1104
|
+
self._dispatch_hook_task(
|
|
1105
|
+
self.on_text_input_key,
|
|
1106
|
+
tid,
|
|
1107
|
+
_normalize_key(ev.get("key", "")),
|
|
1108
|
+
ev.get("modifiers", {}),
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
elif t == "run_update":
|
|
1112
|
+
pass # apps can override on_run_update if needed
|
|
1113
|
+
|
|
1114
|
+
# ── Hook events: forwarded to the dispatcher ─────────────────
|
|
1115
|
+
else:
|
|
1116
|
+
await hook_q.put(ev)
|
|
1117
|
+
|
|
1118
|
+
async def _dispatcher() -> None:
|
|
1119
|
+
while True:
|
|
1120
|
+
ev = await hook_q.get()
|
|
1121
|
+
t = ev.get("type", "")
|
|
1122
|
+
|
|
1123
|
+
if t == "init":
|
|
1124
|
+
proto = ev.get("protocol", "")
|
|
1125
|
+
if not proto.startswith(PROTOCOL_VERSION):
|
|
1126
|
+
sys.stderr.write(
|
|
1127
|
+
f"plexi_sdk: unsupported protocol {proto!r}, expected {PROTOCOL_VERSION}\n"
|
|
1128
|
+
)
|
|
1129
|
+
sys.exit(1)
|
|
1130
|
+
self.app_id = ev.get("app_id", "")
|
|
1131
|
+
self.workspace_root = ev.get("workspace_root", "")
|
|
1132
|
+
self.capabilities = ev.get("capabilities", [])
|
|
1133
|
+
self.feature_flags = ev.get("feature_flags", [])
|
|
1134
|
+
self.launch_args = ev.get("args", [])
|
|
1135
|
+
self._compact_threshold = ev.get("compact_threshold", 280.0)
|
|
1136
|
+
self._regular_threshold = ev.get("regular_threshold", 480.0)
|
|
1137
|
+
# Apply host theme (light/dark + user overrides) so app-drawn
|
|
1138
|
+
# chrome matches the host. Mutates the shared singleton in place.
|
|
1139
|
+
from ._theme import theme as _theme
|
|
1140
|
+
_theme.update_from(ev.get("theme"))
|
|
1141
|
+
# Send Ready
|
|
1142
|
+
features_used = [f for f in self.feature_flags
|
|
1143
|
+
if f in ("pane_groups_v1",)]
|
|
1144
|
+
_emit({"type": "ready", "sdk": SDK_ID, "features_used": features_used})
|
|
1145
|
+
self.emit.info(f"sdk: default_background={self.default_background!r}")
|
|
1146
|
+
# Pre-populate app state if the host provided it (headless --state injection)
|
|
1147
|
+
init_state = ev.get("state")
|
|
1148
|
+
if init_state and isinstance(init_state, dict):
|
|
1149
|
+
self._app_state = dict(init_state)
|
|
1150
|
+
if getattr(type(self), "_arg_specs", None):
|
|
1151
|
+
self._parse_launch_args(self._make_ctx())
|
|
1152
|
+
await self._dispatch_hook(self.on_init)
|
|
1153
|
+
|
|
1154
|
+
elif t == "render":
|
|
1155
|
+
import time as _time
|
|
1156
|
+
now = _time.monotonic()
|
|
1157
|
+
elapsed = (now - self._last_render_time) if self._last_render_time is not None else 0.0
|
|
1158
|
+
self._last_render_time = now
|
|
1159
|
+
frame_id = ev.get("frame_id", 0)
|
|
1160
|
+
if "rect" in ev:
|
|
1161
|
+
self._rect = ev["rect"]
|
|
1162
|
+
pending_clicks = list(self._click_buf)
|
|
1163
|
+
self._click_buf.clear()
|
|
1164
|
+
ctx = self._make_ctx(frame_id, elapsed=elapsed, clicks=pending_clicks)
|
|
1165
|
+
if self.default_background is not None:
|
|
1166
|
+
bg = ctx.theme.bg if self.default_background == "__theme__" else self.default_background
|
|
1167
|
+
ctx.clear(bg)
|
|
1168
|
+
try:
|
|
1169
|
+
if type(self).view is not App.view:
|
|
1170
|
+
tree = self.view()
|
|
1171
|
+
if tree is not None:
|
|
1172
|
+
ctx.render(tree)
|
|
1173
|
+
else:
|
|
1174
|
+
await self._dispatch_hook(self.on_render, ctx)
|
|
1175
|
+
self._consecutive_render_errors = 0
|
|
1176
|
+
except Exception as e:
|
|
1177
|
+
self._consecutive_render_errors += 1
|
|
1178
|
+
ctx.error(f"on_render exception: {e}")
|
|
1179
|
+
if self._consecutive_render_errors >= 3:
|
|
1180
|
+
import traceback as _tb
|
|
1181
|
+
_tb.print_exc()
|
|
1182
|
+
raise
|
|
1183
|
+
# Snapshot scroll consumers registered during this frame (#1802).
|
|
1184
|
+
self._scroll_consumers = list(ctx._scroll_consumers)
|
|
1185
|
+
ctx.frame_done()
|
|
1186
|
+
|
|
1187
|
+
elif t == "key":
|
|
1188
|
+
key = _normalize_key(ev.get("key", ""))
|
|
1189
|
+
if key == "escape":
|
|
1190
|
+
result = self.on_escape()
|
|
1191
|
+
if inspect.isawaitable(result):
|
|
1192
|
+
handled = await result
|
|
1193
|
+
else:
|
|
1194
|
+
handled = result
|
|
1195
|
+
if not handled:
|
|
1196
|
+
self.emit.close_self()
|
|
1197
|
+
else:
|
|
1198
|
+
self._dispatch_hook_task(self.on_key, key, ev.get("modifiers", {}))
|
|
1199
|
+
|
|
1200
|
+
elif t == "click":
|
|
1201
|
+
self._click_buf.append((ev.get("x", 0.0), ev.get("y", 0.0)))
|
|
1202
|
+
self._dispatch_hook_task(self.on_click, ev.get("x", 0.0), ev.get("y", 0.0),
|
|
1203
|
+
ev.get("button", "primary"))
|
|
1204
|
+
|
|
1205
|
+
elif t == "mouse_down":
|
|
1206
|
+
await self._dispatch_hook(self.on_mouse_down, ev.get("x", 0.0), ev.get("y", 0.0),
|
|
1207
|
+
ev.get("button", "primary"), ev.get("modifiers", {}))
|
|
1208
|
+
|
|
1209
|
+
elif t == "mouse_up":
|
|
1210
|
+
await self._dispatch_hook(self.on_mouse_up, ev.get("x", 0.0), ev.get("y", 0.0),
|
|
1211
|
+
ev.get("button", "primary"), ev.get("modifiers", {}))
|
|
1212
|
+
|
|
1213
|
+
elif t == "mouse_move":
|
|
1214
|
+
self._mx = ev.get("x", 0.0)
|
|
1215
|
+
self._my = ev.get("y", 0.0)
|
|
1216
|
+
await self._dispatch_hook(self.on_mouse_move, ev.get("x", 0.0), ev.get("y", 0.0),
|
|
1217
|
+
ev.get("buttons", []), ev.get("modifiers", {}))
|
|
1218
|
+
|
|
1219
|
+
elif t == "command":
|
|
1220
|
+
self._dispatch_hook_task(self.on_command, ev.get("text", ""))
|
|
1221
|
+
|
|
1222
|
+
elif t == "paste":
|
|
1223
|
+
self._dispatch_hook_task(self.on_paste, ev.get("text", ""))
|
|
1224
|
+
|
|
1225
|
+
elif t == "pipe_message":
|
|
1226
|
+
self._dispatch_hook_task(self.on_pipe_message, ev.get("pipe_id", ""), ev.get("payload"))
|
|
1227
|
+
|
|
1228
|
+
elif t == "path_changed":
|
|
1229
|
+
self._dispatch_hook_task(self.on_path_changed, ev.get("cwd", ""))
|
|
1230
|
+
|
|
1231
|
+
elif t == "suspend":
|
|
1232
|
+
await self._dispatch_hook(self.on_suspend)
|
|
1233
|
+
|
|
1234
|
+
elif t == "resume":
|
|
1235
|
+
await self._dispatch_hook(self.on_resume)
|
|
1236
|
+
|
|
1237
|
+
elif t == "shutdown":
|
|
1238
|
+
# Cancel any pending ai_query waiters so their coroutines
|
|
1239
|
+
# unblock immediately instead of waiting up to 35s for a
|
|
1240
|
+
# response that will never arrive.
|
|
1241
|
+
if self._pending_ai:
|
|
1242
|
+
import logging as _logging
|
|
1243
|
+
_logging.warning(
|
|
1244
|
+
f"shutdown: cancelling {len(self._pending_ai)} in-flight "
|
|
1245
|
+
f"ai_query request(s): {list(self._pending_ai.keys())}"
|
|
1246
|
+
)
|
|
1247
|
+
for _pending_q in self._pending_ai.values():
|
|
1248
|
+
_pending_q.put_nowait(
|
|
1249
|
+
{"error": "ai_query cancelled: app is shutting down"}
|
|
1250
|
+
)
|
|
1251
|
+
self._pending_ai.clear()
|
|
1252
|
+
await self._dispatch_hook(self.on_shutdown)
|
|
1253
|
+
return
|
|
1254
|
+
|
|
1255
|
+
elif t == "inject_state":
|
|
1256
|
+
self._app_state = ev.get("payload") or {}
|
|
1257
|
+
self._dispatch_hook_task(self.on_inject, ev.get("payload", {}))
|
|
1258
|
+
|
|
1259
|
+
elif t == "midi_input_opened":
|
|
1260
|
+
# Confirms an OpenMidiInput call landed a CoreMIDI source.
|
|
1261
|
+
# Apps that care about "the port is now wired to my pipe"
|
|
1262
|
+
# see this event after the corresponding PipeOpened — they
|
|
1263
|
+
# can override on_midi_input_opened to react.
|
|
1264
|
+
self._dispatch_hook_task(
|
|
1265
|
+
self.on_midi_input_opened,
|
|
1266
|
+
str(ev.get("pipe_id", "")),
|
|
1267
|
+
str(ev.get("port_id", "")),
|
|
1268
|
+
str(ev.get("port_name", "")),
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
elif t == "timer":
|
|
1272
|
+
timer_id = ev.get("timer_id", "")
|
|
1273
|
+
self._dispatch_hook_task(self.on_timer, timer_id)
|
|
1274
|
+
|
|
1275
|
+
elif t == "scroll_offset":
|
|
1276
|
+
# Host-managed scroll region (#446): the user scrolled inside
|
|
1277
|
+
# a BeginScroll viewport. Forward to on_scroll so the app can
|
|
1278
|
+
# store the new offset and re-render at the translated position.
|
|
1279
|
+
scroll_id = ev.get("id", "")
|
|
1280
|
+
offset_y = float(ev.get("offset_y", 0.0))
|
|
1281
|
+
try:
|
|
1282
|
+
await self._dispatch_hook(self.on_scroll, scroll_id, offset_y)
|
|
1283
|
+
except Exception as e:
|
|
1284
|
+
sys.stderr.write(f"on_scroll handler raised: {e}\n")
|
|
1285
|
+
|
|
1286
|
+
elif t == "scroll":
|
|
1287
|
+
# Raw wheel delta for SDK Scrollable containers (#1794).
|
|
1288
|
+
# Fires when the cursor is over the app pane but not over a
|
|
1289
|
+
# host-managed BeginScroll region or ListView.
|
|
1290
|
+
#
|
|
1291
|
+
# Component routing (#1802): if any component registered scroll
|
|
1292
|
+
# interest during the last render, dispatch to each and schedule
|
|
1293
|
+
# a re-render. Falls through to on_scroll_delta only when no
|
|
1294
|
+
# components are registered, preserving backward compatibility.
|
|
1295
|
+
delta_y = float(ev.get("delta_y", 0.0))
|
|
1296
|
+
if self._scroll_consumers:
|
|
1297
|
+
for _consumer in self._scroll_consumers:
|
|
1298
|
+
try:
|
|
1299
|
+
_consumer.handle_scroll(delta_y)
|
|
1300
|
+
except Exception as e:
|
|
1301
|
+
sys.stderr.write(
|
|
1302
|
+
f"scroll consumer {type(_consumer).__name__} handle_scroll raised: {e}\n"
|
|
1303
|
+
)
|
|
1304
|
+
self.emit.schedule_render()
|
|
1305
|
+
else:
|
|
1306
|
+
try:
|
|
1307
|
+
await self._dispatch_hook(self.on_scroll_delta, delta_y)
|
|
1308
|
+
except Exception as e:
|
|
1309
|
+
sys.stderr.write(f"on_scroll_delta handler raised: {e}\n")
|
|
1310
|
+
|
|
1311
|
+
elif t == "theme":
|
|
1312
|
+
from ._theme import theme as _theme
|
|
1313
|
+
colors = ev.get("colors")
|
|
1314
|
+
_theme.update_from(colors)
|
|
1315
|
+
self.emit.info(f"theme: applied update with {len(colors or {})} role override(s)")
|
|
1316
|
+
|
|
1317
|
+
elif t == "list_select":
|
|
1318
|
+
_lid = ev.get("id")
|
|
1319
|
+
_lidx = ev.get("index")
|
|
1320
|
+
if _lid is None or _lidx is None:
|
|
1321
|
+
sys.stderr.write(f"list_select event missing required fields: {ev}\n")
|
|
1322
|
+
else:
|
|
1323
|
+
self._dispatch_hook_task(self.on_list_select, _lid, _lidx)
|
|
1324
|
+
|
|
1325
|
+
elif t == "list_activate":
|
|
1326
|
+
_lid = ev.get("id")
|
|
1327
|
+
_lidx = ev.get("index")
|
|
1328
|
+
if _lid is None or _lidx is None:
|
|
1329
|
+
sys.stderr.write(f"list_activate event missing required fields: {ev}\n")
|
|
1330
|
+
else:
|
|
1331
|
+
self._dispatch_hook_task(self.on_list_activate, _lid, _lidx)
|
|
1332
|
+
|
|
1333
|
+
elif t == "app_spawned":
|
|
1334
|
+
# Confirmation that a SpawnApp request succeeded. Apps that
|
|
1335
|
+
# want to track the spawned pane can override on_app_spawned.
|
|
1336
|
+
self._dispatch_hook_task(
|
|
1337
|
+
self.on_app_spawned,
|
|
1338
|
+
int(ev.get("pane_id", 0)),
|
|
1339
|
+
str(ev.get("type_id", "")),
|
|
1340
|
+
)
|
|
1341
|
+
|
|
1342
|
+
elif t == "pane_spawned":
|
|
1343
|
+
req_id = ev.get("request_id")
|
|
1344
|
+
self._dispatch_hook_task(
|
|
1345
|
+
self.on_pane_spawned,
|
|
1346
|
+
int(ev.get("pane_id", 0)),
|
|
1347
|
+
str(req_id) if req_id is not None else None,
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
elif t == "pane_spawn_error":
|
|
1351
|
+
req_id = ev.get("request_id")
|
|
1352
|
+
self._dispatch_hook_task(
|
|
1353
|
+
self.on_pane_spawn_error,
|
|
1354
|
+
str(ev.get("reason", "")),
|
|
1355
|
+
str(req_id) if req_id is not None else None,
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
elif t == "context_state_response":
|
|
1359
|
+
self._dispatch_hook_task(
|
|
1360
|
+
self.on_context_state,
|
|
1361
|
+
ev.get("state", {}),
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
elif t == "nav_back":
|
|
1365
|
+
# Navigation stack back event (#392). The host pops the top
|
|
1366
|
+
# nav entry and sends this with the view_id the app should
|
|
1367
|
+
# navigate back to (empty string = root view).
|
|
1368
|
+
await self._dispatch_hook(
|
|
1369
|
+
self.on_nav_back, str(ev.get("view_id", ""))
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
elif t == "file_picked":
|
|
1373
|
+
# File picker result (#514) — user selected one or more files.
|
|
1374
|
+
request_id = str(ev.get("request_id", ""))
|
|
1375
|
+
paths: list[str] = list(ev.get("paths", []))
|
|
1376
|
+
await self._dispatch_hook(self.on_file_picked, request_id, paths)
|
|
1377
|
+
|
|
1378
|
+
elif t == "file_pick_cancelled":
|
|
1379
|
+
# File picker cancelled (#514) — dialog dismissed or capability denied.
|
|
1380
|
+
request_id = str(ev.get("request_id", ""))
|
|
1381
|
+
await self._dispatch_hook(self.on_file_pick_cancelled, request_id)
|
|
1382
|
+
|
|
1383
|
+
elif t == "component_event":
|
|
1384
|
+
self._dispatch_hook_task(
|
|
1385
|
+
self.on_component_event,
|
|
1386
|
+
ev.get("node_id", ""),
|
|
1387
|
+
ev.get("event_type", ""),
|
|
1388
|
+
ev.get("payload"),
|
|
1389
|
+
)
|
|
1390
|
+
|
|
1391
|
+
elif t == "tool_call":
|
|
1392
|
+
# v3.7 tool protocol (#399). Host asks this pane to execute
|
|
1393
|
+
# a registered tool. Dispatched as a background task so it
|
|
1394
|
+
# doesn't block the event loop while the handler runs.
|
|
1395
|
+
self._dispatch_hook_task(self._handle_tool_call, ev)
|
|
1396
|
+
|
|
1397
|
+
elif t == "mcp_tool_call":
|
|
1398
|
+
# MCP bridge (#958). External MCP client called a tool — dispatch to on_mcp_call.
|
|
1399
|
+
self._dispatch_hook_task(self._handle_mcp_tool_call, ev)
|
|
1400
|
+
|
|
1401
|
+
elif t == "rollback_verify":
|
|
1402
|
+
# Undo flow (docs/prm/undo-and-app-events.md). Host asks
|
|
1403
|
+
# whether the resource is still at the checkpoint's
|
|
1404
|
+
# expected revision; the answer gates RollbackApply.
|
|
1405
|
+
self._dispatch_hook_task(self._handle_rollback_verify, ev)
|
|
1406
|
+
|
|
1407
|
+
elif t == "rollback_apply":
|
|
1408
|
+
# Verified rollback instruction — apply the app-owned
|
|
1409
|
+
# rollback identified by rollback_token.
|
|
1410
|
+
self._dispatch_hook_task(
|
|
1411
|
+
self.on_rollback_apply,
|
|
1412
|
+
ev.get("checkpoint_id", ""),
|
|
1413
|
+
ev.get("resource_id", ""),
|
|
1414
|
+
ev.get("rollback_token", ""),
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
reader_task = asyncio.create_task(_reader())
|
|
1418
|
+
exit_code = 0
|
|
1419
|
+
try:
|
|
1420
|
+
await _dispatcher()
|
|
1421
|
+
except SystemExit as e:
|
|
1422
|
+
exit_code = int(e.code) if isinstance(e.code, int) else 1
|
|
1423
|
+
if exit_code != 0:
|
|
1424
|
+
_emit_fatal_error(e)
|
|
1425
|
+
except BaseException as e:
|
|
1426
|
+
exit_code = 1
|
|
1427
|
+
_emit_fatal_error(e)
|
|
1428
|
+
finally:
|
|
1429
|
+
reader_task.cancel()
|
|
1430
|
+
for p in self._pipes.values():
|
|
1431
|
+
p.close()
|
|
1432
|
+
# _reader is blocked in run_in_executor(sys.stdin.readline) which
|
|
1433
|
+
# cannot be interrupted by task cancellation — the executor thread
|
|
1434
|
+
# stays alive until stdin EOF, which the host may not send within
|
|
1435
|
+
# the 2s shutdown window. os._exit() terminates immediately without
|
|
1436
|
+
# waiting for threads, avoiding the SIGTERM that would otherwise fire.
|
|
1437
|
+
import os as _os
|
|
1438
|
+
_os._exit(exit_code)
|
|
1439
|
+
|
|
1440
|
+
async def _dispatch_hook(self, hook: "Any", *args: Any) -> None:
|
|
1441
|
+
"""Dispatch a lifecycle hook and await its completion.
|
|
1442
|
+
|
|
1443
|
+
Use this for hooks where ordering matters — on_render (FrameDone must
|
|
1444
|
+
follow all draw commands), on_init (startup must complete before the
|
|
1445
|
+
first render), on_shutdown (clean-up must finish before exit).
|
|
1446
|
+
|
|
1447
|
+
Async hooks are awaited directly; they may call any ``await``-able
|
|
1448
|
+
Emitter helper without deadlock because _reader runs concurrently as
|
|
1449
|
+
a separate task and will deliver response events while this hook is
|
|
1450
|
+
suspended.
|
|
1451
|
+
|
|
1452
|
+
Sync hooks run on the event loop thread. They are safe for
|
|
1453
|
+
pure-compute / draw-command work (on_render). A sync hook that calls
|
|
1454
|
+
any blocking operation (time.sleep, requests.get, etc.) will freeze
|
|
1455
|
+
the entire event loop — use _dispatch_hook_task for input events where
|
|
1456
|
+
blocking is a realistic concern, or move blocking work to a thread via
|
|
1457
|
+
``threading.Thread`` + ``emit.run_sync()``.
|
|
1458
|
+
"""
|
|
1459
|
+
try:
|
|
1460
|
+
if inspect.iscoroutinefunction(hook):
|
|
1461
|
+
await hook(*args)
|
|
1462
|
+
else:
|
|
1463
|
+
with _sync_hook_scope():
|
|
1464
|
+
hook(*args)
|
|
1465
|
+
except TypeError as exc:
|
|
1466
|
+
if "must be called with 'await' from an 'async def' hook" in str(exc):
|
|
1467
|
+
raise
|
|
1468
|
+
hook_name = getattr(hook, "__qualname__", getattr(hook, "__name__", repr(hook)))
|
|
1469
|
+
raise TypeError(f"{hook_name} failed: {exc}") from exc
|
|
1470
|
+
except BaseException as exc:
|
|
1471
|
+
hook_name = getattr(hook, "__qualname__", getattr(hook, "__name__", repr(hook)))
|
|
1472
|
+
raise RuntimeError(f"{hook_name} failed: {exc}") from exc
|
|
1473
|
+
|
|
1474
|
+
def _dispatch_hook_task(self, hook: "Any", *args: Any) -> None:
|
|
1475
|
+
"""Dispatch a lifecycle hook as a non-blocking background task.
|
|
1476
|
+
|
|
1477
|
+
Use this for input-driven hooks (on_key, on_click, on_command, etc.)
|
|
1478
|
+
where a slow or async handler must not stall the stdin reader or delay
|
|
1479
|
+
the next Render event.
|
|
1480
|
+
|
|
1481
|
+
Async hooks are scheduled as asyncio tasks via create_task — the
|
|
1482
|
+
dispatcher returns immediately and the hook runs concurrently on the
|
|
1483
|
+
same event loop. All ``await``-able Emitter helpers work normally.
|
|
1484
|
+
|
|
1485
|
+
Sync hooks that do not block are called directly on the event loop
|
|
1486
|
+
thread (zero overhead, same as before). Sync hooks that *do* block
|
|
1487
|
+
(time.sleep, requests.get, urllib calls, etc.) are the root cause of
|
|
1488
|
+
the deadlock described in issue #393. The correct fix is to declare
|
|
1489
|
+
the handler ``async def`` and use ``await asyncio.to_thread(fn)`` or
|
|
1490
|
+
``await self.emit.http_get(url)`` for any I/O, or to kick off a
|
|
1491
|
+
``threading.Thread`` and use ``emit.run_sync(...)`` to bridge back.
|
|
1492
|
+
Sync blocking is logged as a warning so the problem is surfaced at
|
|
1493
|
+
runtime rather than silently freezing the app.
|
|
1494
|
+
|
|
1495
|
+
Note: because tasks run concurrently, a queued on_key task may still
|
|
1496
|
+
be running when on_render fires. Apps with shared mutable state should
|
|
1497
|
+
use asyncio locks or confine mutations to on_render (the poll pattern).
|
|
1498
|
+
"""
|
|
1499
|
+
if inspect.iscoroutinefunction(hook):
|
|
1500
|
+
task = asyncio.create_task(hook(*args))
|
|
1501
|
+
# Keep a strong reference so the GC doesn't collect the task before
|
|
1502
|
+
# it finishes. The done callback removes it from the set.
|
|
1503
|
+
self._background_tasks.add(task)
|
|
1504
|
+
task.add_done_callback(self._background_tasks.discard)
|
|
1505
|
+
task.add_done_callback(_log_task_exception)
|
|
1506
|
+
else:
|
|
1507
|
+
try:
|
|
1508
|
+
with _sync_hook_scope():
|
|
1509
|
+
hook(*args)
|
|
1510
|
+
except Exception as e:
|
|
1511
|
+
sys.stderr.write(f"plexi_sdk: sync hook {getattr(hook, '__name__', hook)!r} raised: {e}\n")
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
# SDK_ID used in the ready handshake. Derived from the version constant so
|
|
1515
|
+
# __init__.py and _app.py stay in sync without a circular import.
|
|
1516
|
+
SDK_ID = f"plexi-sdk-py/{_SDK_VERSION}"
|