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,430 @@
1
+ """Workbench slash commands — the pickers, display blocks, and help surfaces.
2
+
3
+ Port of TS ``src/console/slash/commands/workbench.ts``. This group covers
4
+ the overlays a user reaches for to inspect or retune the session: the model
5
+ pickers, the per-scope routing editor, the settings list, the command
6
+ palette (``/help``), the keyboard map (``/keys``), the project changelog
7
+ (``/whats-new``), and a diagnostics dump (``/debug``). The picker handlers
8
+ stay thin — they raise an overlay through ``SlashContext.open_modal`` or
9
+ flip a reducer flag — while the help, keys, whats-new, and debug handlers do
10
+ real work: they read the live slash catalog, the grounded keymap, a
11
+ ``CHANGELOG.md`` on disk, and the conductor's session statistics, then
12
+ surface the result as a display block or a written file rather than a dead
13
+ placeholder.
14
+
15
+ ``/help`` catalog sourcing — the cycle-killer (plan analysis 03 §6.5/§7):
16
+ the TS build reached the assembled ``SLASH_COMMANDS`` through a *call-time
17
+ dynamic import* of ``builtins.ts``, because ``builtins`` imports this very
18
+ module to assemble the catalog. The Python port replaces that with a
19
+ module-level **late-bound provider**: the catalog assembler installs the
20
+ fully-assembled registry via :func:`set_help_registry_provider` at console
21
+ mount (so ``/help`` always sees dynamic skill/template rows too — fixing the
22
+ TS quirk that dynamic rows were frozen at process start), and until one is
23
+ installed ``/help`` falls back to the static transcript + workbench groups.
24
+ The :class:`~induscode.console_slash.SlashContext` contract is untouched.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import tempfile
32
+ import time
33
+ from collections.abc import Callable, Sequence
34
+ from datetime import datetime, timezone
35
+ from pathlib import Path
36
+ from typing import Final, TypeAlias
37
+
38
+ from indusagi.react_ink import UiDisplayBlock
39
+
40
+ from induscode.conductor import message_to_dict
41
+ from induscode.console_slash import (
42
+ FAMILY,
43
+ HANDLED,
44
+ SlashCommand,
45
+ SlashContext,
46
+ SlashOutcome,
47
+ SubCommand,
48
+ family_runner,
49
+ info,
50
+ warn,
51
+ )
52
+ from induscode.console.slash_commands.transcript import transcript_commands
53
+
54
+ __all__ = [
55
+ "HelpRegistryProvider",
56
+ "set_help_registry_provider",
57
+ "workbench_commands",
58
+ ]
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Scoped-model routing family
63
+ # ---------------------------------------------------------------------------
64
+
65
+
66
+ async def _scoped_edit(ctx: SlashContext, rest: str) -> SlashOutcome:
67
+ ctx.open_modal("scopedModels")
68
+ return HANDLED
69
+
70
+
71
+ async def _scoped_show(ctx: SlashContext, rest: str) -> SlashOutcome:
72
+ ctx.open_modal("scopedModels", {"focus": "summary"})
73
+ return HANDLED
74
+
75
+
76
+ async def _scoped_reset(ctx: SlashContext, rest: str) -> SlashOutcome:
77
+ ctx.open_modal("scopedModels", {"intent": "reset"})
78
+ ctx.set_status(info("Per-scope model overrides cleared."))
79
+ return HANDLED
80
+
81
+
82
+ #: The verb table backing ``/models-for``. Each verb routes to the same
83
+ #: per-scope picker overlay with a different intent payload, so the picker
84
+ #: can open straight onto the relevant facet rather than its landing view.
85
+ _SCOPED_MODEL_VERBS: Final[tuple[SubCommand, ...]] = (
86
+ SubCommand(
87
+ verb="edit",
88
+ describe="open the per-scope routing editor",
89
+ run=_scoped_edit,
90
+ ),
91
+ SubCommand(
92
+ verb="show",
93
+ describe="review the active per-scope assignments",
94
+ run=_scoped_show,
95
+ ),
96
+ SubCommand(
97
+ verb="reset",
98
+ describe="clear every per-scope override",
99
+ run=_scoped_reset,
100
+ ),
101
+ )
102
+
103
+ _run_scoped_models = family_runner(FAMILY.scoped_models, _SCOPED_MODEL_VERBS)
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Display-block helper
108
+ # ---------------------------------------------------------------------------
109
+
110
+ _BASE36_DIGITS: Final = "0123456789abcdefghijklmnopqrstuvwxyz"
111
+
112
+
113
+ def _base36(value: int) -> str:
114
+ """Render a non-negative int in base 36 (TS ``Number.toString(36)``)."""
115
+ if value <= 0:
116
+ return "0"
117
+ digits: list[str] = []
118
+ while value:
119
+ value, rem = divmod(value, 36)
120
+ digits.append(_BASE36_DIGITS[rem])
121
+ return "".join(reversed(digits))
122
+
123
+
124
+ def _now_ms() -> int:
125
+ """Epoch milliseconds (TS ``Date.now()``)."""
126
+ return int(time.time() * 1000)
127
+
128
+
129
+ def _block(slug: str, title: str, markdown: str) -> UiDisplayBlock:
130
+ """Mint a display block with a fresh id and timestamp.
131
+
132
+ The framework's display-block surface carries a single markdown body
133
+ under the ``changelog`` kind, so every text panel the workbench drops —
134
+ the command palette, the hotkey map, and the changelog itself — rides
135
+ that one block kind with its own title. The id slug keeps successive
136
+ invocations distinct rows.
137
+
138
+ :param slug: short id prefix identifying which panel this is
139
+ :param title: the human-facing heading the block renders
140
+ :param markdown: the panel body, in markdown
141
+ """
142
+ return UiDisplayBlock(
143
+ id=f"{slug}-{_base36(_now_ms())}",
144
+ title=title,
145
+ markdown=markdown,
146
+ timestamp=_now_ms(),
147
+ )
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Command palette (/help)
152
+ # ---------------------------------------------------------------------------
153
+
154
+ #: A zero-argument callable yielding the live slash catalog ``/help``
155
+ #: renders — typically a closure over the console's assembled registry.
156
+ HelpRegistryProvider: TypeAlias = Callable[[], Sequence[SlashCommand]]
157
+
158
+ #: The late-bound catalog source. Installed once per console mount by the
159
+ #: catalog assembler; ``None`` selects the static-groups fallback.
160
+ _help_registry_provider: HelpRegistryProvider | None = None
161
+
162
+
163
+ def set_help_registry_provider(provider: HelpRegistryProvider | None) -> None:
164
+ """Install the catalog source ``/help`` renders (``None`` restores the
165
+ static fallback — useful for tests). See the module docstring for why
166
+ this replaces the TS dynamic-import cycle-breaker."""
167
+ global _help_registry_provider
168
+ _help_registry_provider = provider
169
+
170
+
171
+ def _help_catalog() -> Sequence[SlashCommand]:
172
+ """The catalog ``/help`` lists: the installed provider's registry when
173
+ one is bound, otherwise the static transcript + workbench groups."""
174
+ if _help_registry_provider is not None:
175
+ return _help_registry_provider()
176
+ return (*transcript_commands, *workbench_commands)
177
+
178
+
179
+ def _help_block() -> UiDisplayBlock:
180
+ """Render the full flat command list as a markdown block.
181
+
182
+ Reads the live catalog through :func:`_help_catalog` so every command —
183
+ built-in and any dynamic skill/template rows spliced in at assembly
184
+ time — appears with its one-line summary.
185
+ """
186
+ catalog = _help_catalog()
187
+ lines = []
188
+ for command in catalog:
189
+ alts = f" _(/{', /'.join(command.aliases)})_" if command.aliases else ""
190
+ lines.append(f"- **/{command.name}**{alts} — {command.summary}")
191
+ body = "\n".join(
192
+ [f"{len(catalog)} commands available. Type `/` to complete.", "", *lines]
193
+ )
194
+ return _block("help", "Commands", body)
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # Keyboard map (/keys)
199
+ # ---------------------------------------------------------------------------
200
+
201
+ #: The real keyboard shortcuts, grounded in the console keymap and surface.
202
+ #: Each row is a ``(chord, effect)`` pair the hotkey block renders as a table.
203
+ HOTKEYS: Final[tuple[tuple[str, str], ...]] = (
204
+ ("Enter", "Submit the current turn"),
205
+ ("Shift+Enter", "Insert a soft newline in the composer"),
206
+ ("Esc", "Dismiss an overlay / abort the in-flight turn"),
207
+ ("Esc Esc", "Open the transcript-tree navigator"),
208
+ ("Shift+Tab", "Step the reasoning-effort (thinking) level"),
209
+ ("Tab", "Accept the highlighted completion"),
210
+ ("Ctrl+C", "Interrupt the running turn"),
211
+ ("Ctrl+T", "Toggle reasoning / thinking rows"),
212
+ ("Ctrl+V", "Attach an image from the clipboard"),
213
+ ("Ctrl+G", "Edit the composer in your external editor"),
214
+ ("Ctrl+O", "Expand or collapse full tool output"),
215
+ ("Ctrl+L", "Open the model picker"),
216
+ ("Ctrl+R", "Open the session resume list"),
217
+ ("Ctrl+N / Ctrl+P", "Cycle the active model"),
218
+ ("Ctrl+A / Ctrl+E", "Jump to start / end of the line"),
219
+ ("Ctrl+U", "Clear the composer line (twice: exit)"),
220
+ ("Ctrl+J", "Insert a newline"),
221
+ ("Ctrl+Z", "Suspend the process"),
222
+ ("Alt+Up", "Pull the newest queued input back into the composer"),
223
+ ("! <cmd>", "Run a shell command (kept in context)"),
224
+ ("!! <cmd>", "Run a shell command (excluded from context)"),
225
+ )
226
+
227
+
228
+ def _hotkeys_block() -> UiDisplayBlock:
229
+ """Render the hotkey map as a markdown block."""
230
+ rows = [f"| `{chord}` | {effect} |" for chord, effect in HOTKEYS]
231
+ body = "\n".join(["| Shortcut | Action |", "| --- | --- |", *rows])
232
+ return _block("hotkeys", "Keyboard shortcuts", body)
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Changelog (/whats-new)
237
+ # ---------------------------------------------------------------------------
238
+
239
+ #: Candidate locations a project changelog may live at, relative to the cwd.
240
+ _CHANGELOG_NAMES: Final[tuple[str, ...]] = (
241
+ "CHANGELOG.md",
242
+ "CHANGELOG",
243
+ "changelog.md",
244
+ )
245
+
246
+ #: The fallback body shown when no ``CHANGELOG.md`` is present on disk.
247
+ _CHANGELOG_FALLBACK: Final = "\n".join(
248
+ [
249
+ "No `CHANGELOG.md` was found at the workspace root.",
250
+ "",
251
+ "Recent highlights in this build:",
252
+ "- Model and per-scope routing pickers reachable from the composer.",
253
+ "- `/help` and `/keys` render live command and shortcut references.",
254
+ "- Colour scheme switches inline between **midnight** and **daylight**.",
255
+ ]
256
+ )
257
+
258
+
259
+ def _read_changelog(cwd: str) -> str | None:
260
+ """Read the project changelog from disk, returning its markdown when one
261
+ of the candidate files is present and readable, or ``None`` when none is.
262
+
263
+ :param cwd: the workspace directory to resolve the candidates against
264
+ """
265
+ for name in _CHANGELOG_NAMES:
266
+ try:
267
+ text = Path(cwd, name).read_text(encoding="utf-8")
268
+ except (OSError, UnicodeDecodeError):
269
+ # Not present or unreadable here — try the next candidate.
270
+ continue
271
+ if text.strip():
272
+ return text
273
+ return None
274
+
275
+
276
+ async def _run_changelog(ctx: SlashContext) -> SlashOutcome:
277
+ """Drop the changelog block, reading a real ``CHANGELOG.md`` when present
278
+ and falling back to the build highlights otherwise."""
279
+ markdown = _read_changelog(os.getcwd())
280
+ ctx.append_block(
281
+ _block("changelog", "Changelog", markdown)
282
+ if markdown is not None
283
+ else _block("changelog", "What's new", _CHANGELOG_FALLBACK)
284
+ )
285
+ return HANDLED
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # Diagnostics dump (/debug)
290
+ # ---------------------------------------------------------------------------
291
+
292
+
293
+ def _iso_now() -> str:
294
+ """UTC now in the TS ``Date.toISOString()`` shape (millisecond ``Z``)."""
295
+ return (
296
+ datetime.now(timezone.utc)
297
+ .isoformat(timespec="milliseconds")
298
+ .replace("+00:00", "Z")
299
+ )
300
+
301
+
302
+ async def _run_debug(ctx: SlashContext) -> SlashOutcome:
303
+ """Write a JSONL diagnostics log for the active session and report its
304
+ path.
305
+
306
+ Each line is one JSON record: a header carrying the ``SessionStats``
307
+ tally plus the active model, followed by one record per live transcript
308
+ message (``role``/``content`` read via duck-typing and projected through
309
+ the one message⇄dict codec — plan cross-cutting rule 2). Writing it to a
310
+ fresh temp directory makes the dump a real artifact a user can open,
311
+ attach to a bug report, or ``tail``, rather than a transient toast. The
312
+ diagnostics view toggle is flipped as well so on-screen verbosity tracks
313
+ it.
314
+ """
315
+ ctx.dispatch({"type": "toggle:reasoning"})
316
+
317
+ stats = ctx.conductor.stats()
318
+ messages = ctx.conductor.messages()
319
+ snapshot = ctx.conductor.snapshot()
320
+
321
+ records: list[dict[str, object]] = [
322
+ {
323
+ "type": "session",
324
+ "at": _iso_now(),
325
+ "sessionId": stats.sessionId,
326
+ "modelId": snapshot.modelId,
327
+ "phase": snapshot.phase,
328
+ "stats": message_to_dict(stats),
329
+ }
330
+ ]
331
+ for index, message in enumerate(messages):
332
+ role = getattr(message, "role", None)
333
+ record: dict[str, object] = {
334
+ "type": "message",
335
+ "index": index,
336
+ "role": "unknown" if role is None else message_to_dict(role),
337
+ }
338
+ content = getattr(message, "content", None)
339
+ if content is not None:
340
+ # parity: JSON.stringify dropped an undefined `content` key.
341
+ record["content"] = message_to_dict(content)
342
+ records.append(record)
343
+ jsonl = "\n".join(json.dumps(record) for record in records) + "\n"
344
+
345
+ try:
346
+ directory = tempfile.mkdtemp(prefix="indus-debug-")
347
+ path = os.path.join(directory, f"session-{stats.sessionId or 'unknown'}.jsonl")
348
+ Path(path).write_text(jsonl, encoding="utf-8")
349
+ ctx.set_status(info(f"Diagnostics written to {path}"))
350
+ except Exception as cause:
351
+ ctx.set_status(warn(f"Could not write the diagnostics log ({cause})."))
352
+ return HANDLED
353
+
354
+
355
+ # ---------------------------------------------------------------------------
356
+ # Thin picker handlers
357
+ # ---------------------------------------------------------------------------
358
+
359
+
360
+ async def _run_model(ctx: SlashContext) -> SlashOutcome:
361
+ ctx.open_modal("models")
362
+ return HANDLED
363
+
364
+
365
+ async def _run_settings(ctx: SlashContext) -> SlashOutcome:
366
+ ctx.open_modal("settings")
367
+ return HANDLED
368
+
369
+
370
+ async def _run_help(ctx: SlashContext) -> SlashOutcome:
371
+ ctx.append_block(_help_block())
372
+ return HANDLED
373
+
374
+
375
+ async def _run_keys(ctx: SlashContext) -> SlashOutcome:
376
+ ctx.append_block(_hotkeys_block())
377
+ return HANDLED
378
+
379
+
380
+ # ---------------------------------------------------------------------------
381
+ # The catalog
382
+ # ---------------------------------------------------------------------------
383
+
384
+ #: The workbench command rows, in listing order. Pickers and help surfaces
385
+ #: open an overlay; the changelog drops a display block; debug flips a
386
+ #: toggle and reports it.
387
+ workbench_commands: list[SlashCommand] = [
388
+ SlashCommand(
389
+ name="model",
390
+ summary="Switch the model bound to this session",
391
+ run=_run_model,
392
+ aliases=("models",),
393
+ ),
394
+ SlashCommand(
395
+ name="models-for",
396
+ summary="Edit, show, or reset per-scope model routing",
397
+ run=_run_scoped_models,
398
+ aliases=("scoped-models",),
399
+ family=FAMILY.scoped_models,
400
+ takes_args=True,
401
+ ),
402
+ SlashCommand(
403
+ name="settings",
404
+ summary="Open the settings overlay",
405
+ run=_run_settings,
406
+ ),
407
+ SlashCommand(
408
+ name="help",
409
+ summary="List the available commands",
410
+ run=_run_help,
411
+ aliases=("?",),
412
+ ),
413
+ SlashCommand(
414
+ name="keys",
415
+ summary="Show the keyboard shortcut map",
416
+ run=_run_keys,
417
+ aliases=("hotkeys",),
418
+ ),
419
+ SlashCommand(
420
+ name="whats-new",
421
+ summary="Show what changed in this build",
422
+ run=_run_changelog,
423
+ aliases=("changelog",),
424
+ ),
425
+ SlashCommand(
426
+ name="debug",
427
+ summary="Write a session diagnostics log and toggle verbose view",
428
+ run=_run_debug,
429
+ ),
430
+ ]