induscode 0.1.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.
Files changed (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,503 @@
1
+ """Auth overlays — the sign-in / sign-out, OAuth-flow, and plugin dialogs.
2
+
3
+ Port of TS ``src/console/overlays/auth.tsx``. This module owns the four modal
4
+ kinds tied to credentials and host-supplied overlays: the provider sign-in
5
+ launcher (``signIn``), the sign-out confirmation (``signOut``), the in-flight
6
+ OAuth device/redirect flow (``oauth``), and a plugin-supplied overlay
7
+ (``plugin``).
8
+
9
+ Dialog-API inversion (port plan analysis 02, risk 1) — the redesigned spots:
10
+
11
+ - **Sign-in hand-off.** TS dispatched ``modal:open { kind: "oauth" }`` from
12
+ inside the sign-in dialog and let the host re-route. Here the chain is an
13
+ *awaited sub-flow*: :func:`run_sign_in` awaits the provider pick (or skips
14
+ it for a ``/login <provider>`` payload) and then awaits
15
+ :func:`_run_auth_entry` directly, so the reducer sees exactly one
16
+ ``modal:open``/``modal:close`` pair per user-raised overlay.
17
+ - **The parked resolver.** TS parked a ``pending.current`` callback ref the
18
+ dialog's submit fulfilled. Here the framework
19
+ :class:`~indusagi.react_ink.OAuthDialog` *is* the prompt seam: its
20
+ ``ask()`` parks an :class:`asyncio.Future` that Enter resolves, and the
21
+ whole login choreography runs as a ``flow`` worker the dialog drives —
22
+ the launch adapter's :class:`~induscode.launch.OAuthLoginCallbacks` are
23
+ bridged onto the dialog's ``update_state``/``ask`` seams.
24
+ - **Completion.** TS closed the modal from inside the callback chain; here
25
+ the flow returns exit code 0, the dialog dismisses itself with that code,
26
+ and this module's post-await code binds a model + returns the success
27
+ status event. A failed flow posts the TS ``"Sign-in did not complete."``
28
+ progress line and keeps the dialog open (Esc then cancels).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ from dataclasses import dataclass
35
+ from typing import TYPE_CHECKING, Any, Final
36
+
37
+ from rich.text import Text
38
+ from textual.binding import Binding
39
+ from textual.screen import ModalScreen
40
+ from textual.widgets import Static
41
+
42
+ from indusagi.react_ink import (
43
+ AuthRedirectInfo,
44
+ DialogFrame,
45
+ LoginDialog,
46
+ LoginProviderOption,
47
+ OAuthDialog,
48
+ OAuthOverlayState,
49
+ SavedAccountOption,
50
+ )
51
+
52
+ from induscode.console.contract import (
53
+ ConsoleEvent,
54
+ OverlayServices,
55
+ StatusMessage,
56
+ StatusSet,
57
+ )
58
+ from induscode.launch import OAuthAuthorization, OAuthLoginCallbacks, OAuthPrompt
59
+
60
+ if TYPE_CHECKING:
61
+ from textual.app import App, ComposeResult
62
+
63
+ __all__ = [
64
+ "PluginOverlayScreen",
65
+ "PluginRequest",
66
+ "login_provider_rows",
67
+ "read_entry_mode",
68
+ "read_plugin_request",
69
+ "read_provider_id",
70
+ "read_requested_provider",
71
+ "run_oauth_flow",
72
+ "run_plugin",
73
+ "run_sign_in",
74
+ "run_sign_out",
75
+ "saved_account_rows",
76
+ "seed_oauth_state",
77
+ "select_provider_model",
78
+ ]
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Payload narrowing (TS readRequestedProvider / readProviderId / readEntryMode)
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ def read_requested_provider(payload: object | None) -> str | None:
87
+ """Narrow the opaque sign-in payload to a requested provider id, when one
88
+ was carried (from ``/login <provider>``). ``None`` for a bare launcher."""
89
+ if isinstance(payload, dict):
90
+ provider_id = payload.get("providerId")
91
+ if isinstance(provider_id, str) and len(provider_id) > 0:
92
+ return provider_id
93
+ return None
94
+
95
+
96
+ def read_provider_id(payload: object | None) -> str:
97
+ """Narrow the opaque oauth payload to the provider id it carries."""
98
+ if isinstance(payload, dict):
99
+ provider_id = payload.get("providerId")
100
+ if isinstance(provider_id, str):
101
+ return provider_id
102
+ return ""
103
+
104
+
105
+ def read_entry_mode(payload: object | None) -> str:
106
+ """Narrow the opaque payload to its entry mode; defaults to the OAuth
107
+ flow (``"apiKey"`` is the only other recognised mode)."""
108
+ if isinstance(payload, dict) and payload.get("mode") == "apiKey":
109
+ return "apiKey"
110
+ return "oauth"
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Directory / vault projections
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ def login_provider_rows(services: OverlayServices) -> list[LoginProviderOption]:
119
+ """The merged login directory as framework dialog rows, tolerant of any
120
+ directory fault (the TS provider-list try/catch)."""
121
+ try:
122
+ return [
123
+ LoginProviderOption(id=entry.id, label=entry.label, authKind=entry.auth_kind)
124
+ for entry in services.list_login_providers()
125
+ ]
126
+ except Exception:
127
+ return []
128
+
129
+
130
+ async def saved_account_rows(services: OverlayServices) -> list[SavedAccountOption]:
131
+ """Every saved account across the known providers, as sign-out rows.
132
+
133
+ A provider whose vault read fails contributes nothing; a directory fault
134
+ yields the rows gathered so far (TS semantics verbatim, label included:
135
+ ``"{provider label} · {accountId}"``).
136
+ """
137
+ rows: list[SavedAccountOption] = []
138
+ try:
139
+ providers = services.list_login_providers()
140
+ except Exception:
141
+ return rows
142
+ for provider in providers:
143
+ try:
144
+ names = await services.vault.list_accounts(provider.id)
145
+ except Exception:
146
+ names = []
147
+ for account_id in names:
148
+ rows.append(
149
+ SavedAccountOption(
150
+ provider=provider.id,
151
+ accountId=account_id,
152
+ label=f"{provider.label} · {account_id}",
153
+ )
154
+ )
155
+ return rows
156
+
157
+
158
+ def _provider_name(services: OverlayServices, provider_id: str) -> str:
159
+ """The human label for a provider id, falling back to the id itself."""
160
+ try:
161
+ for entry in services.list_login_providers():
162
+ if entry.id == provider_id:
163
+ return entry.label
164
+ except Exception:
165
+ pass
166
+ return provider_id
167
+
168
+
169
+ def select_provider_model(services: OverlayServices, provider_id: str) -> None:
170
+ """Bind the session to a model from a freshly-authenticated provider, so
171
+ a chat turn works immediately after sign-in without a manual ``/model``
172
+ round-trip.
173
+
174
+ Best-effort: a provider with no catalog entry, or a conductor that
175
+ rejects the switch, is silently left on the current model. The catalog
176
+ lists a provider's models oldest-first and some early entries are retired
177
+ (they 404), so the *newest* entry is preferred.
178
+ """
179
+ try:
180
+ cards = [
181
+ card
182
+ for card in services.conductor.available_models()
183
+ if card.provider == provider_id
184
+ ]
185
+ if cards:
186
+ services.conductor.select_model(cards[-1].id)
187
+ except Exception:
188
+ # Leave the session on its current model.
189
+ pass
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # signIn — the provider launcher
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ async def run_sign_in(
198
+ app: "App[Any]",
199
+ payload: object | None,
200
+ services: OverlayServices | None,
201
+ ) -> tuple[ConsoleEvent, ...]:
202
+ """The provider sign-in launcher flow.
203
+
204
+ Lists the merged login directory and routes the chosen entry by its auth
205
+ kind into :func:`_run_auth_entry` — a browser provider opens the OAuth
206
+ choreography, an api-key provider opens the inline key input. When the
207
+ payload names a known provider (``/login anthropic``), the picker is
208
+ skipped entirely and that provider's entry flow opens straight away.
209
+ """
210
+ if services is None:
211
+ return ()
212
+ providers = login_provider_rows(services)
213
+
214
+ requested = read_requested_provider(payload)
215
+ direct = (
216
+ next((p for p in providers if p.id == requested), None)
217
+ if requested is not None
218
+ else None
219
+ )
220
+ if direct is not None:
221
+ mode = "oauth" if direct.authKind == "oauth" else "apiKey"
222
+ return await _run_auth_entry(app, services, direct.id, mode)
223
+
224
+ chosen = await app.push_screen_wait(LoginDialog(mode="login", providers=providers))
225
+ if not isinstance(chosen, LoginProviderOption):
226
+ return ()
227
+ mode = "oauth" if chosen.authKind == "oauth" else "apiKey"
228
+ return await _run_auth_entry(app, services, chosen.id, mode)
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # signOut — the saved-account remover
233
+ # ---------------------------------------------------------------------------
234
+
235
+
236
+ async def run_sign_out(
237
+ app: "App[Any]",
238
+ payload: object | None,
239
+ services: OverlayServices | None,
240
+ ) -> tuple[ConsoleEvent, ...]:
241
+ """The sign-out confirmation flow: enumerate every saved account across
242
+ the known providers and remove the chosen one through the vault."""
243
+ if services is None:
244
+ return ()
245
+ accounts = await saved_account_rows(services)
246
+ chosen = await app.push_screen_wait(LoginDialog(mode="logout", accounts=accounts))
247
+ if isinstance(chosen, SavedAccountOption):
248
+ try:
249
+ await services.vault.remove(chosen.provider, chosen.accountId)
250
+ except Exception:
251
+ # Swallow: closing the overlay is the right end state regardless.
252
+ pass
253
+ return ()
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # oauth — the in-flight browser / api-key entry flow
258
+ # ---------------------------------------------------------------------------
259
+
260
+
261
+ def seed_oauth_state(provider_id: str, provider_name: str, mode: str) -> OAuthOverlayState:
262
+ """Seed a fresh overlay state for a provider, before the flow reports
263
+ anything (the TS ``seedOAuthState`` verbatim)."""
264
+ entry_mode = "apiKey" if mode == "apiKey" else "oauth"
265
+ return OAuthOverlayState(
266
+ providerId=provider_id,
267
+ providerName=provider_name,
268
+ mode=entry_mode,
269
+ inputLabel=(
270
+ f"{provider_name} API key" if entry_mode == "apiKey" else "Authorization code"
271
+ ),
272
+ inputValue="",
273
+ accountId="default",
274
+ accountName="Default",
275
+ )
276
+
277
+
278
+ async def run_oauth_flow(
279
+ app: "App[Any]",
280
+ payload: object | None,
281
+ services: OverlayServices | None,
282
+ ) -> tuple[ConsoleEvent, ...]:
283
+ """The ``oauth`` modal kind: narrow the payload and run the entry flow."""
284
+ if services is None:
285
+ return ()
286
+ return await _run_auth_entry(
287
+ app, services, read_provider_id(payload), read_entry_mode(payload)
288
+ )
289
+
290
+
291
+ async def _run_auth_entry(
292
+ app: "App[Any]",
293
+ services: OverlayServices,
294
+ provider_id: str,
295
+ mode: str,
296
+ ) -> tuple[ConsoleEvent, ...]:
297
+ """The shared credential-entry flow behind ``signIn`` and ``oauth``."""
298
+ provider_name = _provider_name(services, provider_id)
299
+ if mode == "apiKey":
300
+ return await _run_api_key_entry(app, services, provider_id, provider_name)
301
+ return await _run_browser_login(app, services, provider_id, provider_name)
302
+
303
+
304
+ async def _run_api_key_entry(
305
+ app: "App[Any]",
306
+ services: OverlayServices,
307
+ provider_id: str,
308
+ provider_name: str,
309
+ ) -> tuple[ConsoleEvent, ...]:
310
+ """Collect an API key through the entry dialog and persist it.
311
+
312
+ The TS ``saveApiKey`` ignored an empty submit and kept the dialog open;
313
+ the Python dialog dismisses on every Enter, so an empty submit re-raises
314
+ the dialog (same observable behaviour: nothing is stored until a
315
+ non-empty key is entered or the user cancels).
316
+ """
317
+ state = seed_oauth_state(provider_id, provider_name, "apiKey")
318
+ key = ""
319
+ while True:
320
+ result = await app.push_screen_wait(OAuthDialog(state))
321
+ if result is None:
322
+ return ()
323
+ key = result.value.strip()
324
+ if len(key) > 0:
325
+ break
326
+
327
+ account = state.accountId or "default"
328
+ try:
329
+ await services.vault.put_api_key(provider_id, account, key, True)
330
+ select_provider_model(services, provider_id)
331
+ status = StatusMessage(kind="success", text=f"Saved your {provider_name} API key.")
332
+ except Exception:
333
+ status = StatusMessage(
334
+ kind="error", text=f"Could not save the {provider_name} API key."
335
+ )
336
+ return (StatusSet(status=status),)
337
+
338
+
339
+ async def _run_browser_login(
340
+ app: "App[Any]",
341
+ services: OverlayServices,
342
+ provider_id: str,
343
+ provider_name: str,
344
+ ) -> tuple[ConsoleEvent, ...]:
345
+ """Drive the launch adapter's browser sign-in through the entry dialog.
346
+
347
+ The :class:`~induscode.launch.OAuthLoginCallbacks` bag is bridged onto
348
+ the dialog's seams (risk-1 redesign; module docstring):
349
+
350
+ - ``on_auth`` → ``update_state(authInfo=..., progress=...)`` plus the
351
+ automatic Chrome-first browser launch via ``services.open_login_url``
352
+ (the dialog renders the instructions and URL on their own rows, so the
353
+ progress line carries a DISTINCT status — never a copy of the
354
+ instructions);
355
+ - ``on_prompt`` → the dialog's ``ask()`` (a parked ``asyncio.Future``
356
+ resolved by Enter — the TS ``pending.current`` resolver);
357
+ - ``on_progress`` → ``update_state(progress=...)``.
358
+
359
+ Success persists through the vault inside ``start_oauth_login``; this
360
+ flow then binds a model for the provider and returns the success status.
361
+ """
362
+ state = seed_oauth_state(provider_id, provider_name, "oauth")
363
+
364
+ async def flow(io: OAuthDialog) -> int:
365
+ def on_progress(message: str) -> None:
366
+ io.update_state(progress=message)
367
+
368
+ async def on_auth(info: OAuthAuthorization) -> None:
369
+ io.update_state(
370
+ authInfo=AuthRedirectInfo(url=info.url, instructions=info.instructions),
371
+ progress="Opening your browser to sign in…",
372
+ )
373
+ opened = await services.open_login_url(info.url)
374
+ io.update_state(
375
+ progress=(
376
+ "Opened the login URL in your browser. Finish there, then paste "
377
+ "the code if prompted."
378
+ if opened
379
+ else "Could not open a browser automatically — open the URL above, "
380
+ "then paste the code if prompted."
381
+ )
382
+ )
383
+
384
+ async def on_prompt(prompt: OAuthPrompt) -> str:
385
+ io.update_state(inputLabel=prompt.message)
386
+ return await io.ask(prompt.message)
387
+
388
+ callbacks = OAuthLoginCallbacks(
389
+ on_auth=on_auth, on_prompt=on_prompt, on_progress=on_progress
390
+ )
391
+ try:
392
+ await services.start_oauth_login(provider_id, callbacks, services.vault)
393
+ except asyncio.CancelledError:
394
+ raise
395
+ except Exception:
396
+ io.update_state(progress="Sign-in did not complete.")
397
+ return 1
398
+ return 0
399
+
400
+ result = await app.push_screen_wait(OAuthDialog(state, flow=flow))
401
+ if result is not None and result.exit_code == 0:
402
+ select_provider_model(services, provider_id)
403
+ return (
404
+ StatusSet(
405
+ status=StatusMessage(kind="success", text=f"Signed in to {provider_name}.")
406
+ ),
407
+ )
408
+ return ()
409
+
410
+
411
+ # ---------------------------------------------------------------------------
412
+ # plugin — the host-supplied overlay
413
+ # ---------------------------------------------------------------------------
414
+
415
+
416
+ @dataclass(frozen=True, slots=True)
417
+ class PluginRequest:
418
+ """The narrowed shape of a plugin overlay request (TS ``PluginRequest``)."""
419
+
420
+ #: The surface name (``mcp``, ``memory``, ``composio``, …).
421
+ surface: str
422
+ #: A human title for the frame, when the command supplied one.
423
+ title: str | None = None
424
+ #: The real, command-gathered body text to render.
425
+ text: str | None = None
426
+
427
+
428
+ def read_plugin_request(payload: object | None) -> PluginRequest:
429
+ """Narrow the opaque plugin payload into a :class:`PluginRequest`."""
430
+ if isinstance(payload, dict):
431
+ surface = payload.get("surface")
432
+ title = payload.get("title")
433
+ text = payload.get("text")
434
+ return PluginRequest(
435
+ surface=surface if isinstance(surface, str) else "plugin",
436
+ title=title if isinstance(title, str) else None,
437
+ text=text if isinstance(text, str) else None,
438
+ )
439
+ return PluginRequest(surface="plugin")
440
+
441
+
442
+ #: The TS plugin-dialog footer, string identical.
443
+ _PLUGIN_FOOTER: Final[str] = "esc to close"
444
+
445
+
446
+ class PluginOverlayScreen(ModalScreen[None]):
447
+ """A plugin-supplied overlay: the command-gathered body text rendered
448
+ line-by-line in a labelled :class:`~indusagi.react_ink.DialogFrame`.
449
+
450
+ A request that carries no text (an unknown surface) falls back to naming
451
+ the surface so the frame is never empty. Esc dismisses with ``None``.
452
+ """
453
+
454
+ BINDINGS = [Binding("escape", "close_overlay", "Close", show=False)]
455
+
456
+ DEFAULT_CSS = """
457
+ PluginOverlayScreen {
458
+ align: center middle;
459
+ }
460
+ PluginOverlayScreen .plugin-line {
461
+ height: auto;
462
+ }
463
+ """
464
+
465
+ def __init__(
466
+ self,
467
+ request: PluginRequest,
468
+ *,
469
+ name: str | None = None,
470
+ id: str | None = None,
471
+ classes: str | None = None,
472
+ ) -> None:
473
+ super().__init__(name=name, id=id, classes=classes)
474
+ self._request = request
475
+
476
+ def compose(self) -> "ComposeResult":
477
+ request = self._request
478
+ body = (
479
+ request.text
480
+ if request.text is not None
481
+ else f"No data available for the {request.surface} surface."
482
+ )
483
+ with DialogFrame(
484
+ title=request.title if request.title is not None else "Plugin surface",
485
+ subtitle=request.surface,
486
+ footer=_PLUGIN_FOOTER,
487
+ ):
488
+ for line in body.split("\n"):
489
+ yield Static(Text(line if len(line) > 0 else " "), classes="plugin-line")
490
+
491
+ def action_close_overlay(self) -> None:
492
+ self.dismiss(None)
493
+
494
+
495
+ async def run_plugin(
496
+ app: "App[Any]",
497
+ payload: object | None,
498
+ services: OverlayServices | None,
499
+ ) -> tuple[ConsoleEvent, ...]:
500
+ """The plugin overlay flow. The only kind that renders without runtime
501
+ services (the TS group special-cased it the same way)."""
502
+ await app.push_screen_wait(PluginOverlayScreen(read_plugin_request(payload)))
503
+ return ()