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,526 @@
1
+ """Picker overlays — the selection dialogs raised over the transcript.
2
+
3
+ Port of TS ``src/console/overlays/pickers.tsx``. This module owns the four
4
+ modal kinds that present a list to pick from: the single-model picker
5
+ (``models``), the per-scope model picker (``scopedModels``), the
6
+ colour-scheme picker (``theme``), and the settings list (``settings``).
7
+
8
+ Dialog-API inversion (port plan analysis 02, risk 1) — the TS group was an
9
+ always-mounted component routed by ``ModalState.kind``, feeding callback
10
+ props (``onSelect``/``onSave``/``onClose``) into dialogs that stayed mounted
11
+ until the host dropped the modal state. The Python framework dialogs are
12
+ ``ModalScreen[Result]`` values dismissed *with* a result, so each kind here
13
+ is an awaited ``push_screen_wait`` flow instead:
14
+
15
+ - every flow is ``async def run_*(app, payload, services) -> events`` — it
16
+ pushes the framework dialog, awaits the dismissal result, applies the side
17
+ effects (conductor / preference-store writes) *after* the await, and
18
+ returns the reducer events the App folds once the overlay closes;
19
+ - the TS ``useEffect`` one-shots (the scoped-models ``{intent: "reset"}``
20
+ fire-on-mount path) become a plain early return before any screen is
21
+ pushed;
22
+ - the theme picker's preview-before-commit machinery (TS: ``onHighlight`` →
23
+ ``scheme:set`` dispatch re-rendering the whole surface) collapses into the
24
+ framework ``ThemeDialog`` itself — highlight sets ``app.theme`` live,
25
+ Esc restores the captured original, Enter dismisses with the committed
26
+ scheme token; only the *persist + reducer bookkeeping* remains here;
27
+ - the auth-probe race the TS model picker tolerated (``null`` until the
28
+ vault probe settled → "show all") collapses: the probe is awaited before
29
+ the dialog ever opens;
30
+ - the TS settings rows dispatched ``toggle:*`` / ``scheme:set`` live while
31
+ the dialog was up; here each row writes the preference store immediately
32
+ and appends the matching event to an accumulator the flow returns, folded
33
+ by the App after the dialog closes (the modal covers the surface in the
34
+ meantime; the colour-scheme row still re-themes live via ``app.theme``).
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from typing import TYPE_CHECKING, Any, Final, cast
40
+
41
+ from indusagi.llmgateway.contract import CostSheet, ModelCard
42
+ from indusagi.react_ink import (
43
+ ModelDialog,
44
+ ScopedModelsDialog,
45
+ SettingsDialog,
46
+ SettingsDialogItem,
47
+ ThemeDialog,
48
+ ThemeDialogItem,
49
+ )
50
+
51
+ from induscode.console.contract import (
52
+ ConsoleEvent,
53
+ OverlayServices,
54
+ SchemeSet,
55
+ StatusMessage,
56
+ StatusSet,
57
+ ToggleImages,
58
+ ToggleReasoning,
59
+ is_theme_scheme,
60
+ )
61
+ from induscode.settings import DELIVERY_MODES, is_delivery_mode
62
+
63
+ if TYPE_CHECKING:
64
+ from textual.app import App
65
+
66
+ from induscode.conductor import ModelCardRef
67
+
68
+ __all__ = [
69
+ "THEME_CHOICES",
70
+ "THEME_NAMES",
71
+ "TOGGLE_VALUES",
72
+ "authenticated_providers",
73
+ "build_settings_items",
74
+ "card_catalog_id",
75
+ "list_model_refs",
76
+ "read_scoped_payload",
77
+ "ref_to_card",
78
+ "run_model_picker",
79
+ "run_scoped_models",
80
+ "run_settings_picker",
81
+ "run_theme_picker",
82
+ ]
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Theme choices (TS THEME_CHOICES verbatim, already in the dialog item shape)
87
+ # ---------------------------------------------------------------------------
88
+
89
+ #: The colour schemes the theme picker offers, in listing order — the two
90
+ #: base schemes followed by their daltonized (color-blind-safe) counterparts.
91
+ THEME_CHOICES: Final[tuple[ThemeDialogItem, ...]] = (
92
+ ThemeDialogItem(id="midnight", label="Midnight", description="dark terminals"),
93
+ ThemeDialogItem(id="daylight", label="Daylight", description="light terminals"),
94
+ ThemeDialogItem(
95
+ id="midnight-cb",
96
+ label="Midnight (color-blind)",
97
+ description="deuteran-safe, dark",
98
+ ),
99
+ ThemeDialogItem(
100
+ id="daylight-cb",
101
+ label="Daylight (color-blind)",
102
+ description="deuteran-safe, light",
103
+ ),
104
+ )
105
+
106
+ #: The scheme tokens the theme picker offers, derived from THEME_CHOICES.
107
+ THEME_NAMES: Final[tuple[str, ...]] = tuple(choice.id for choice in THEME_CHOICES)
108
+
109
+ #: The on/off vocabulary the boolean settings rows toggle between.
110
+ TOGGLE_VALUES: Final[tuple[str, ...]] = ("on", "off")
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Catalog → framework-card mapping (TS refToModel)
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ def ref_to_card(ref: "ModelCardRef") -> ModelCard:
119
+ """Lift a catalog :class:`ModelCardRef` into the framework
120
+ :class:`ModelCard` shape the model dialogs render.
121
+
122
+ The dialogs read identity/label/reasoning and hand the value back through
123
+ their dismissal result; the numeric/capability fields are seeded to inert
124
+ defaults since the picker never consults them (the TS ``refToModel``
125
+ seeded the same way). The card carries the provider-scoped ``modelId`` as
126
+ its ``id`` so the framework row label renders ``provider/modelId`` —
127
+ :func:`card_catalog_id` reassembles the canonical catalog key.
128
+ """
129
+ return ModelCard(
130
+ id=ref.modelId,
131
+ provider=cast(Any, ref.provider),
132
+ api=cast(Any, ""),
133
+ display_name=ref.name,
134
+ context_window=0,
135
+ max_output_tokens=0,
136
+ modalities=("text",),
137
+ reasoning=ref.reasoning,
138
+ cost=CostSheet(input_per_mtok=0.0, output_per_mtok=0.0),
139
+ # An empty wire_id would be normalized to ``id``; seed it explicitly
140
+ # so the inert card stays inert.
141
+ wire_id=ref.modelId,
142
+ )
143
+
144
+
145
+ def card_catalog_id(card: ModelCard) -> str:
146
+ """The canonical ``provider/modelId`` catalog key for a picker card —
147
+ exactly how the conductor catalog builds :attr:`ModelCardRef.id`."""
148
+ return f"{card.provider}/{card.id}"
149
+
150
+
151
+ def list_model_refs(services: OverlayServices) -> list["ModelCardRef"]:
152
+ """Read the catalog as :class:`ModelCardRef` rows, tolerant of any fault
153
+ (the TS ``listModels`` try/catch)."""
154
+ try:
155
+ return list(services.conductor.available_models())
156
+ except Exception:
157
+ return []
158
+
159
+
160
+ async def authenticated_providers(services: OverlayServices) -> set[str]:
161
+ """The set of provider ids the user has authenticated (any saved account
162
+ in the vault), probed live so a mid-session ``/login`` is reflected.
163
+
164
+ Port delta (risk-1 redesign): the TS hook returned ``null`` until the
165
+ async probe settled and the picker showed the whole catalog meanwhile;
166
+ here the probe is simply awaited before the dialog opens, so the race
167
+ disappears. A provider that cannot be read is not counted; a directory
168
+ fault yields the set gathered so far.
169
+ """
170
+ found: set[str] = set()
171
+ try:
172
+ providers = services.list_login_providers()
173
+ except Exception:
174
+ return found
175
+ for provider in providers:
176
+ try:
177
+ if len(await services.vault.list_accounts(provider.id)) > 0:
178
+ found.add(provider.id)
179
+ except Exception:
180
+ # A provider that cannot be read is simply not counted.
181
+ continue
182
+ return found
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # models — the single-model picker
187
+ # ---------------------------------------------------------------------------
188
+
189
+
190
+ async def run_model_picker(
191
+ app: "App[Any]",
192
+ payload: object | None,
193
+ services: OverlayServices | None,
194
+ ) -> tuple[ConsoleEvent, ...]:
195
+ """The single-model picker flow.
196
+
197
+ Restricted to models from providers the user has signed into (so the
198
+ list shows only callable models); if none are authenticated, the whole
199
+ catalog is offered rather than an empty picker. Selecting a card binds it
200
+ on the conductor; Esc leaves the session untouched.
201
+ """
202
+ if services is None:
203
+ return ()
204
+ refs = list_model_refs(services)
205
+ authed = await authenticated_providers(services)
206
+ pool = [ref for ref in refs if ref.provider in authed] if authed else refs
207
+ chosen = await app.push_screen_wait(ModelDialog([ref_to_card(ref) for ref in pool]))
208
+ if chosen is not None:
209
+ try:
210
+ services.conductor.select_model(card_catalog_id(chosen))
211
+ except Exception:
212
+ # Selection faults must not crash the overlay.
213
+ pass
214
+ return ()
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # scopedModels — the per-scope model picker
219
+ # ---------------------------------------------------------------------------
220
+
221
+
222
+ def read_scoped_payload(payload: object | None) -> dict[str, str]:
223
+ """Narrow the opaque modal payload to the per-scope picker's known fields.
224
+
225
+ The ``/scoped-models`` sub-verbs raise the picker with: ``show`` →
226
+ ``{"focus": "summary"}``; ``reset`` → ``{"intent": "reset"}``; ``edit``
227
+ (and a bare chord) → nothing. Unknown shapes narrow to ``{}``.
228
+ """
229
+ if not isinstance(payload, dict):
230
+ return {}
231
+ out: dict[str, str] = {}
232
+ if payload.get("focus") == "summary":
233
+ out["focus"] = "summary"
234
+ if payload.get("intent") == "reset":
235
+ out["intent"] = "reset"
236
+ return out
237
+
238
+
239
+ async def run_scoped_models(
240
+ app: "App[Any]",
241
+ payload: object | None,
242
+ services: OverlayServices | None,
243
+ ) -> tuple[ConsoleEvent, ...]:
244
+ """The per-scope model picker flow.
245
+
246
+ The current enabled-models preference seeds the selection (empty means
247
+ every model); saving persists the chosen ids. The ``{"intent": "reset"}``
248
+ payload clears the override, persists, reports, and returns *without*
249
+ presenting the list (the TS fire-once ``useEffect`` becomes this early
250
+ return); ``{"focus": "summary"}`` opens the picker normally (parity: the
251
+ dialog exposes no read-only summary affordance to target).
252
+ """
253
+ if services is None:
254
+ return ()
255
+ request = read_scoped_payload(payload)
256
+
257
+ if request.get("intent") == "reset":
258
+ try:
259
+ services.settings.set("enabledModels", [])
260
+ services.settings.save()
261
+ status = StatusMessage(kind="info", text="Per-scope model overrides cleared.")
262
+ except Exception:
263
+ status = StatusMessage(
264
+ kind="error", text="Could not clear per-scope model overrides."
265
+ )
266
+ return (StatusSet(status=status),)
267
+
268
+ refs = list_model_refs(services)
269
+ try:
270
+ enabled = list(services.settings.get("enabledModels"))
271
+ except Exception:
272
+ enabled = []
273
+ selected = enabled if len(enabled) > 0 else [ref.id for ref in refs]
274
+
275
+ ids = await app.push_screen_wait(
276
+ ScopedModelsDialog([ref_to_card(ref) for ref in refs], selected_ids=selected)
277
+ )
278
+ if ids is not None:
279
+ try:
280
+ # Everything selected means "no restriction" — store empty.
281
+ next_ids = [] if len(ids) == len(refs) else list(ids)
282
+ services.settings.set("enabledModels", next_ids)
283
+ services.settings.save()
284
+ except Exception:
285
+ # Persistence faults must not crash the overlay.
286
+ pass
287
+ return ()
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # theme — the colour-scheme picker with preview-before-commit
292
+ # ---------------------------------------------------------------------------
293
+
294
+
295
+ async def run_theme_picker(
296
+ app: "App[Any]",
297
+ payload: object | None,
298
+ services: OverlayServices | None,
299
+ ) -> tuple[ConsoleEvent, ...]:
300
+ """The colour-scheme picker flow, preview-before-commit (TS item #13).
301
+
302
+ The TS select-and-persist split into open/highlight/Enter/Esc moments
303
+ maps onto the framework :class:`ThemeDialog` natively (risk-1 redesign):
304
+
305
+ - **open** — the dialog captures ``app.theme`` as the revert target
306
+ on its first highlight (it, not the settings file, is what was on
307
+ screen — better fidelity than the TS ``currentScheme`` settings read);
308
+ - **highlight** — the dialog sets ``app.theme`` immediately (Textual
309
+ themes re-skin live; only registered schemes are applied);
310
+ - **Enter** — the dialog dismisses with the scheme token; *this flow*
311
+ persists it and returns the ``scheme:set`` event for the reducer;
312
+ - **Esc** — the dialog restores the original ``app.theme`` and
313
+ dismisses ``None``; nothing is written, no event is returned.
314
+ """
315
+ if services is None:
316
+ return ()
317
+ token = await app.push_screen_wait(ThemeDialog(list(THEME_CHOICES)))
318
+ if isinstance(token, str) and is_theme_scheme(token):
319
+ try:
320
+ services.settings.set("colourScheme", token)
321
+ services.settings.save()
322
+ except Exception:
323
+ # Persistence faults must not crash the overlay.
324
+ pass
325
+ return (SchemeSet(scheme=token),)
326
+ return ()
327
+
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # settings — the settings list
331
+ # ---------------------------------------------------------------------------
332
+
333
+
334
+ def build_settings_items(
335
+ services: OverlayServices,
336
+ events: list[ConsoleEvent],
337
+ app: "App[Any] | None" = None,
338
+ ) -> list[SettingsDialogItem]:
339
+ """The settings rows, mapped onto the framework :class:`SettingsDialogItem`.
340
+
341
+ Each row reads its current value from the preference store and its
342
+ ``on_change`` writes the chosen value back, persisting immediately.
343
+ Reducer-backed rows also append their event to ``events`` (the TS live
344
+ ``dispatch`` becomes this accumulator, folded by the App after the dialog
345
+ closes); the colour-scheme row additionally re-themes ``app`` live when a
346
+ registered scheme is chosen. Row ids, labels, descriptions, ordering and
347
+ the on/off vocabulary are the TS rows verbatim.
348
+ """
349
+ settings = services.settings
350
+
351
+ def on_off(flag: object) -> str:
352
+ return "on" if flag else "off"
353
+
354
+ def toggle_row(
355
+ id: str,
356
+ key: str,
357
+ label: str,
358
+ description: str,
359
+ live: ConsoleEvent | None = None,
360
+ ) -> SettingsDialogItem:
361
+ def on_change(value: str) -> None:
362
+ settings.set(key, value == "on")
363
+ settings.save()
364
+ if live is not None:
365
+ events.append(live)
366
+
367
+ return SettingsDialogItem(
368
+ id=id,
369
+ label=label,
370
+ description=description,
371
+ value=on_off(settings.get(key)),
372
+ values=list(TOGGLE_VALUES),
373
+ on_change=on_change,
374
+ )
375
+
376
+ def delivery_row(id: str, key: str, label: str, description: str) -> SettingsDialogItem:
377
+ def on_change(value: str) -> None:
378
+ if is_delivery_mode(value):
379
+ settings.set(key, value)
380
+ settings.save()
381
+
382
+ return SettingsDialogItem(
383
+ id=id,
384
+ label=label,
385
+ description=description,
386
+ value=str(settings.get(key)),
387
+ values=list(DELIVERY_MODES),
388
+ on_change=on_change,
389
+ )
390
+
391
+ def on_scheme_change(value: str) -> None:
392
+ settings.set("colourScheme", value)
393
+ settings.save()
394
+ if is_theme_scheme(value):
395
+ if app is not None and app.get_theme(value) is not None:
396
+ app.theme = value
397
+ events.append(SchemeSet(scheme=value))
398
+
399
+ def on_padding_change(value: str) -> None:
400
+ try:
401
+ parsed = int(value, 10)
402
+ except ValueError:
403
+ return
404
+ settings.set("editorPaddingX", parsed)
405
+ settings.save()
406
+
407
+ return [
408
+ SettingsDialogItem(
409
+ id="colour-scheme",
410
+ label="Colour scheme",
411
+ description="The terminal colour scheme the console renders in.",
412
+ value=str(settings.get("colourScheme")),
413
+ values=list(THEME_NAMES),
414
+ on_change=on_scheme_change,
415
+ ),
416
+ toggle_row(
417
+ "show-images",
418
+ "showImages",
419
+ "Show images",
420
+ "Render inline image content in the transcript.",
421
+ live=ToggleImages(),
422
+ ),
423
+ toggle_row(
424
+ "image-auto-resize",
425
+ "imageAutoResize",
426
+ "Auto-resize images",
427
+ "Shrink oversized images to fit before handing them to a provider.",
428
+ ),
429
+ toggle_row(
430
+ "block-images",
431
+ "blockImages",
432
+ "Block images",
433
+ "Withhold image content from providers entirely.",
434
+ ),
435
+ toggle_row(
436
+ "show-reasoning",
437
+ "showReasoning",
438
+ "Show reasoning",
439
+ "Stream the model's reasoning / thinking text as it arrives.",
440
+ live=ToggleReasoning(),
441
+ ),
442
+ toggle_row(
443
+ "skill-commands",
444
+ "enableSkillCommands",
445
+ "Skill commands",
446
+ "Surface discovered skills as their own slash commands.",
447
+ ),
448
+ delivery_row(
449
+ "steering-mode",
450
+ "steeringMode",
451
+ "Steering mode",
452
+ "Whether queued steering corrections release one at a time or all at once.",
453
+ ),
454
+ delivery_row(
455
+ "follow-up-mode",
456
+ "followUpMode",
457
+ "Follow-up mode",
458
+ "Whether queued follow-up prompts release one at a time or all at once.",
459
+ ),
460
+ toggle_row(
461
+ "auto-compact",
462
+ "autoCompact",
463
+ "Auto-compact",
464
+ "Condense the transcript automatically as it nears the window.",
465
+ ),
466
+ toggle_row(
467
+ "collapse-changelog",
468
+ "collapseChangelog",
469
+ "Collapse changelog",
470
+ "Prefer a condensed changelog after the console updates itself.",
471
+ ),
472
+ toggle_row(
473
+ "quiet-startup",
474
+ "quietStartup",
475
+ "Quiet startup",
476
+ "Suppress the banner and tips shown on a normal interactive launch.",
477
+ ),
478
+ toggle_row(
479
+ "logo-sweep",
480
+ "logoSweep",
481
+ "Logo sweep",
482
+ "Tint the startup wordmark and emblem along a static colour gradient.",
483
+ ),
484
+ toggle_row(
485
+ "reduced-motion",
486
+ "reducedMotion",
487
+ "Reduced motion",
488
+ "Suppress motion-flavoured flourishes such as the logo colour sweep.",
489
+ ),
490
+ toggle_row(
491
+ "hardware-cursor",
492
+ "showHardwareCursor",
493
+ "Hardware cursor",
494
+ "Reveal the terminal's own cursor instead of the software-drawn caret.",
495
+ ),
496
+ SettingsDialogItem(
497
+ id="editor-padding",
498
+ label="Editor padding",
499
+ description="Horizontal padding, in columns, around the prompt editor.",
500
+ value=str(settings.get("editorPaddingX")),
501
+ values=["0", "1", "2", "3"],
502
+ on_change=on_padding_change,
503
+ ),
504
+ ]
505
+
506
+
507
+ async def run_settings_picker(
508
+ app: "App[Any]",
509
+ payload: object | None,
510
+ services: OverlayServices | None,
511
+ ) -> tuple[ConsoleEvent, ...]:
512
+ """The settings-list flow: build the rows, present the framework dialog
513
+ (Enter/Left/Right cycle a row, Esc closes), and return the reducer events
514
+ the row edits accumulated while the dialog was up.
515
+
516
+ A row-building fault yields no dialog at all (the TS ``catch → null``).
517
+ """
518
+ if services is None:
519
+ return ()
520
+ events: list[ConsoleEvent] = []
521
+ try:
522
+ items = build_settings_items(services, events, app)
523
+ except Exception:
524
+ return ()
525
+ await app.push_screen_wait(SettingsDialog(items))
526
+ return tuple(events)
@@ -0,0 +1,129 @@
1
+ """Overlay router — the single dispatch point for every modal overlay.
2
+
3
+ Port of TS ``src/console/overlays/host.tsx`` under the dialog-API inversion
4
+ (port plan analysis 02, risk 1). The TS ``OverlayHost`` mounted three group
5
+ components unconditionally and routed by ``ModalState.kind``, with each
6
+ dialog driving callback props until the host dropped the modal state. The
7
+ Python framework dialogs are ``ModalScreen[Result]`` values, so the router
8
+ becomes a *table of awaited flows*: :func:`open_overlay` looks the requested
9
+ :data:`~induscode.console.contract.ModalKind` up in
10
+ :data:`OVERLAY_HANDLERS`, awaits the matching ``push_screen_wait`` flow, and
11
+ boxes whatever reducer events the flow accumulated into a typed
12
+ :class:`OverlayOutcome`.
13
+
14
+ The reducer stays the modal bookkeeping authority: the App dispatches
15
+ ``modal:open`` *before* awaiting :func:`open_overlay` and ``modal:close``
16
+ after folding the outcome's events, so :class:`ConsoleState.modal` mirrors
17
+ the screen stack exactly as the TS reducer mirrored the mounted dialog —
18
+ one open/close pair per user-raised overlay, even for the TS flows that
19
+ re-dispatched mid-dialog (sign-in → oauth is an awaited sub-flow now; see
20
+ ``auth.py``).
21
+
22
+ ``push_screen_wait`` requires an active Textual worker, so the App awaits
23
+ :func:`open_overlay` from a worker (``app.run_worker``), never from a
24
+ message handler directly.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass
30
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Final, Mapping, TypeAlias
31
+
32
+ from induscode.console.contract import ConsoleEvent, ModalKind, OverlayServices
33
+
34
+ from .auth import run_oauth_flow, run_plugin, run_sign_in, run_sign_out
35
+ from .pickers import (
36
+ run_model_picker,
37
+ run_scoped_models,
38
+ run_settings_picker,
39
+ run_theme_picker,
40
+ )
41
+ from .sessions import run_prior_turns, run_session_picker, run_tree_navigator
42
+
43
+ if TYPE_CHECKING:
44
+ from textual.app import App
45
+
46
+ __all__ = [
47
+ "OVERLAY_HANDLERS",
48
+ "OverlayFlow",
49
+ "OverlayOutcome",
50
+ "open_overlay",
51
+ ]
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class OverlayOutcome:
56
+ """What one overlay flow settles with — the typed result the App folds.
57
+
58
+ ``kind`` names the overlay that ran; ``events`` are the reducer events
59
+ the flow accumulated (a committed ``scheme:set``, settings ``toggle:*``
60
+ flips, sign-in ``status:set`` reports — never ``modal:*``, which stay the
61
+ App's bookkeeping around the await).
62
+ """
63
+
64
+ #: The modal kind this outcome settles.
65
+ kind: ModalKind
66
+ #: Reducer events to fold once the overlay has closed, oldest first.
67
+ events: tuple[ConsoleEvent, ...] = ()
68
+
69
+
70
+ #: One overlay flow: push the dialog(s) for a kind, await dismissal, apply
71
+ #: side effects, and return the reducer events to fold.
72
+ OverlayFlow: TypeAlias = Callable[
73
+ ["App[Any]", object | None, OverlayServices | None],
74
+ Awaitable[tuple[ConsoleEvent, ...]],
75
+ ]
76
+
77
+
78
+ async def _run_none(
79
+ app: "App[Any]",
80
+ payload: object | None,
81
+ services: OverlayServices | None,
82
+ ) -> tuple[ConsoleEvent, ...]:
83
+ """The inert ``none`` kind: nothing to raise (the TS host's
84
+ ``kind === "none"`` short-circuit)."""
85
+ return ()
86
+
87
+
88
+ #: The closed kind → flow dispatch table (TS routed by union exhaustiveness;
89
+ #: the key-coverage test pins this table against ``MODAL_KINDS``).
90
+ OVERLAY_HANDLERS: Final[Mapping[ModalKind, OverlayFlow]] = {
91
+ "none": _run_none,
92
+ "settings": run_settings_picker,
93
+ "models": run_model_picker,
94
+ "scopedModels": run_scoped_models,
95
+ "theme": run_theme_picker,
96
+ "sessions": run_session_picker,
97
+ "tree": run_tree_navigator,
98
+ "userTurns": run_prior_turns,
99
+ "signIn": run_sign_in,
100
+ "signOut": run_sign_out,
101
+ "oauth": run_oauth_flow,
102
+ "plugin": run_plugin,
103
+ }
104
+
105
+
106
+ async def open_overlay(
107
+ app: "App[Any]",
108
+ kind: ModalKind,
109
+ payload: object | None = None,
110
+ services: OverlayServices | None = None,
111
+ ) -> OverlayOutcome:
112
+ """Run the overlay flow for ``kind`` and settle with its outcome.
113
+
114
+ The single entry point the App's modal plumbing awaits (from a worker —
115
+ ``push_screen_wait`` demands one). An unknown kind, the ``none`` kind,
116
+ and any kind opened without the runtime ``services`` it needs all settle
117
+ inert — the TS groups rendered nothing for exactly those cases.
118
+
119
+ :param app: the Textual app the dialogs are pushed onto
120
+ :param kind: which overlay to raise
121
+ :param payload: the opaque per-modal payload, narrowed by each flow
122
+ :param services: the runtime handles overlays drive; absent on headless
123
+ mount paths
124
+ """
125
+ handler = OVERLAY_HANDLERS.get(kind)
126
+ if handler is None:
127
+ return OverlayOutcome(kind=kind)
128
+ events = await handler(app, payload, services)
129
+ return OverlayOutcome(kind=kind, events=events)