zettcode-app 0.4.2__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 (81) hide show
  1. zettcode/app/__init__.py +63 -0
  2. zettcode/app/agent/__init__.py +47 -0
  3. zettcode/app/agent/agent/__init__.py +84 -0
  4. zettcode/app/agent/agent/commands.py +269 -0
  5. zettcode/app/agent/agent/settings.py +132 -0
  6. zettcode/app/agent/conversation/__init__.py +7 -0
  7. zettcode/app/agent/conversation/changes.py +64 -0
  8. zettcode/app/agent/conversation/effort_gauge.py +126 -0
  9. zettcode/app/agent/conversation/projection.py +265 -0
  10. zettcode/app/agent/conversation/rendering.py +837 -0
  11. zettcode/app/agent/conversation/replay.py +198 -0
  12. zettcode/app/agent/conversation/shimmer.py +256 -0
  13. zettcode/app/agent/conversation/stops.py +57 -0
  14. zettcode/app/agent/conversation/tips.py +86 -0
  15. zettcode/app/agent/conversation/transcript/__init__.py +80 -0
  16. zettcode/app/agent/conversation/transcript/fold.py +57 -0
  17. zettcode/app/agent/conversation/transcript/log.py +182 -0
  18. zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
  19. zettcode/app/agent/conversation/transcript/stream.py +187 -0
  20. zettcode/app/agent/conversation/transcript/turn.py +323 -0
  21. zettcode/app/commandline/__init__.py +47 -0
  22. zettcode/app/commandline/arguments.py +169 -0
  23. zettcode/app/commandline/completion.py +262 -0
  24. zettcode/app/commandline/history.py +305 -0
  25. zettcode/app/commandline/runner.py +365 -0
  26. zettcode/app/errors.py +12 -0
  27. zettcode/app/plugins/__init__.py +110 -0
  28. zettcode/app/plugins/builtins.py +33 -0
  29. zettcode/app/plugins/groups.py +503 -0
  30. zettcode/app/plugins/loader.py +268 -0
  31. zettcode/app/py.typed +0 -0
  32. zettcode/app/ui/__init__.py +63 -0
  33. zettcode/app/ui/app.py +1507 -0
  34. zettcode/app/ui/bars.py +290 -0
  35. zettcode/app/ui/boot.py +131 -0
  36. zettcode/app/ui/clipboard.py +340 -0
  37. zettcode/app/ui/commands.py +323 -0
  38. zettcode/app/ui/config_form.py +343 -0
  39. zettcode/app/ui/demo.py +180 -0
  40. zettcode/app/ui/followups.py +430 -0
  41. zettcode/app/ui/gc_policy.py +147 -0
  42. zettcode/app/ui/keys.py +462 -0
  43. zettcode/app/ui/messaging.py +260 -0
  44. zettcode/app/ui/notices.py +195 -0
  45. zettcode/app/ui/providers.py +626 -0
  46. zettcode/app/ui/sessions.py +422 -0
  47. zettcode/app/ui/settings.py +161 -0
  48. zettcode/app/ui/shell.py +361 -0
  49. zettcode/app/ui/turns.py +115 -0
  50. zettcode/app/ui/updating.py +204 -0
  51. zettcode/app/ui/widgets/__init__.py +161 -0
  52. zettcode/app/ui/widgets/approval_page.py +284 -0
  53. zettcode/app/ui/widgets/ask_page.py +386 -0
  54. zettcode/app/ui/widgets/bash_page.py +90 -0
  55. zettcode/app/ui/widgets/completer.py +100 -0
  56. zettcode/app/ui/widgets/composer.py +529 -0
  57. zettcode/app/ui/widgets/document_page.py +159 -0
  58. zettcode/app/ui/widgets/effort_page.py +294 -0
  59. zettcode/app/ui/widgets/follow_up.py +168 -0
  60. zettcode/app/ui/widgets/form_page.py +380 -0
  61. zettcode/app/ui/widgets/input_page.py +175 -0
  62. zettcode/app/ui/widgets/model_page.py +63 -0
  63. zettcode/app/ui/widgets/model_settings_page.py +653 -0
  64. zettcode/app/ui/widgets/pet.py +247 -0
  65. zettcode/app/ui/widgets/pet_page.py +347 -0
  66. zettcode/app/ui/widgets/plugins_page.py +442 -0
  67. zettcode/app/ui/widgets/root.py +68 -0
  68. zettcode/app/ui/widgets/self_compact_page.py +363 -0
  69. zettcode/app/ui/widgets/send_kind_page.py +193 -0
  70. zettcode/app/ui/widgets/sessions_page.py +511 -0
  71. zettcode/app/ui/widgets/statusline_page.py +427 -0
  72. zettcode/app/ui/widgets/steering.py +126 -0
  73. zettcode/app/ui/widgets/transcript.py +287 -0
  74. zettcode/app/ui/widgets/update_page.py +65 -0
  75. zettcode/app/ui/widgets/welcome.py +24 -0
  76. zettcode/collector.py +54 -0
  77. zettcode/update.py +519 -0
  78. zettcode_app-0.4.2.dist-info/METADATA +26 -0
  79. zettcode_app-0.4.2.dist-info/RECORD +81 -0
  80. zettcode_app-0.4.2.dist-info/WHEEL +4 -0
  81. zettcode_app-0.4.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,503 @@
1
+ """The group a plugin registers what only the terminal can hold into: ``ctx.tui``.
2
+
3
+ The terminal is the one front end with a screen, so its contributions live apart from what every process shares: the
4
+ commands that open a page or need the shell, the palettes, the pets, the presenters, the header and status rows, the
5
+ line a request ends on, and the command providers that add commands dynamically. A process without a terminal has
6
+ ``ctx.tui is None``, and a plugin that only adds these should start its ``activate`` with ``if ctx.tui is None:
7
+ return``.
8
+
9
+ The terminal's loader attaches one of these to the context it builds, so the context type itself says nothing about a
10
+ screen: :class:`~zettcode.engine.plugins.context.PluginContext` is the same object everywhere, and this is the group
11
+ only a terminal fills.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from zettcode.engine.plugins.context import PluginContext
20
+ from zettcode.plugin_api.commands import Command, CommandList, CommandProvider
21
+ from zettcode.plugin_api.pets import PET_NAME, Pet, PetSource
22
+ from zettcode.plugin_api.presenters import Presenter
23
+ from zettcode.plugin_api.state import TurnBuilder, TurnSegment, UiBuilder, UiRegion, UiRow, UiSegment, UiSide
24
+ from zettcode.plugin_tui import TuiGroup
25
+ from zettcode.presenters.statusline import TURN_PREFIX
26
+ from zettcode.tui import RESERVED_THEMES, THEME_NAME, Theme, ThemeSource, file_theme
27
+
28
+ #: Rows and sides in paint order; the shell's frame, fixed by the application.
29
+ UI_REGIONS: tuple[UiRegion, ...] = ("header", "status")
30
+ UI_SIDES: tuple[UiSide, ...] = ("left", "right")
31
+
32
+ if TYPE_CHECKING: # pragma: no cover - annotations only
33
+ from pathlib import Path
34
+
35
+ from zettcode.plugin_api.state import UiBuilder, UiRegion
36
+
37
+
38
+ class UiSlots:
39
+ """Named segments per row side, where a repeated name replaces in place."""
40
+
41
+ def __init__(self) -> None:
42
+ """Start with every slot empty."""
43
+ self._slots: dict[tuple[UiRegion, UiSide], list[UiSegment]] = {}
44
+ self._counter = 0
45
+
46
+ def add(
47
+ self,
48
+ region: UiRegion,
49
+ side: UiSide,
50
+ name: str,
51
+ builder: UiBuilder,
52
+ *,
53
+ override: bool = False,
54
+ ) -> tuple[UiSegment, bool]:
55
+ """Put one segment in a row, reporting whether it took a place.
56
+
57
+ Args:
58
+ region: Row the segment belongs to.
59
+ side: Side used unless the name replaces a segment that has one.
60
+ name: Identity within the row; blank means "always append".
61
+ builder: Called once per paint with the current ``ShellContext``.
62
+ override: Empty this side first, so the segment is the only one
63
+ there. The other side of the row is untouched.
64
+
65
+ Returns:
66
+ The registered segment and whether it displaced another one: a
67
+ replaced name or a cleared side counts, a plain append does not.
68
+ """
69
+ if override:
70
+ displaced = bool(self._slots.get((region, side)))
71
+ self._slots[(region, side)] = [UiSegment(name=name or self._auto_name(), builder=builder)]
72
+ return self._slots[(region, side)][0], displaced
73
+ if name:
74
+ existing = self._find(region, name)
75
+ if existing is not None:
76
+ slots, index = existing
77
+ slots[index] = UiSegment(name=name, builder=builder)
78
+ return slots[index], True
79
+ segment = UiSegment(name=name or self._auto_name(), builder=builder)
80
+ self._slots.setdefault((region, side), []).append(segment)
81
+ return segment, False
82
+
83
+ def _auto_name(self) -> str:
84
+ """Return the next name for a segment registered without one."""
85
+ self._counter += 1
86
+ return f"segment-{self._counter}"
87
+
88
+ def rows(self) -> tuple[UiRow, ...]:
89
+ """Return both rows with their sides in paint order, or nothing if empty."""
90
+ if not self._slots:
91
+ return ()
92
+ return tuple(
93
+ UiRow(
94
+ region,
95
+ tuple(self._slots.get((region, "left"), ())),
96
+ tuple(self._slots.get((region, "right"), ())),
97
+ )
98
+ for region in UI_REGIONS
99
+ )
100
+
101
+ def take_from(self, other: UiSlots) -> None:
102
+ """Start from a copy of ``other``, so later edits leave it untouched.
103
+
104
+ The loader hands each plugin a registry seeded with what is already
105
+ loaded: names and ``override`` then act on the real rows, and a plugin
106
+ that fails can be dropped without disturbing them.
107
+ """
108
+ self._slots = {slot: list(segments) for slot, segments in other._slots.items()}
109
+ self._counter = other._counter
110
+
111
+ def _find(self, region: UiRegion, name: str) -> tuple[list[UiSegment], int] | None:
112
+ """Return the slot holding ``name`` in ``region``, if any."""
113
+ for (slot_region, _), segments in self._slots.items():
114
+ if slot_region != region:
115
+ continue
116
+ for index, segment in enumerate(segments):
117
+ if segment.name == name:
118
+ return segments, index
119
+ return None
120
+
121
+
122
+ def require_tui(ctx: PluginContext) -> TuiGroup:
123
+ """Return the terminal's group, or raise when this process has no screen.
124
+
125
+ For a plugin whose whole purpose is the terminal — every builtin here — registering nothing silently would leave a
126
+ session with no commands and no explanation. A third-party plugin that is useful in more than one process reads
127
+ ``ctx.tui`` and returns early instead; this one is the loud version.
128
+ """
129
+ if ctx.tui is None:
130
+ raise RuntimeError("this plugin needs the terminal, and this process has no screen")
131
+ return ctx.tui
132
+
133
+
134
+ class TuiGroups(TuiGroup):
135
+ """What only the terminal can hold: the registries behind ``ctx.tui``.
136
+
137
+ Args:
138
+ context: The context this plugin is activating with; the config and services are read through it, and the
139
+ registries below belong to this activation.
140
+ """
141
+
142
+ def __init__(self, context: PluginContext) -> None:
143
+ """Open the terminal's registries, all empty, for one plugin's activation."""
144
+ self._context = context
145
+ self._command_providers: list[CommandProvider] = []
146
+ self._themes: list[ThemeSource] = []
147
+ self._pets: list[PetSource] = []
148
+ self._presenters: list[Presenter[Any]] = []
149
+ self._slots = UiSlots()
150
+ self._turn_segments: dict[str, TurnSegment] = {}
151
+ self._focus_handlers: list[Callable[[bool], None]] = []
152
+ self._turn_start_handlers: list[Callable[[], None]] = []
153
+ self._turn_end_handlers: list[Callable[[bool], None]] = []
154
+
155
+ @property
156
+ def focus_handlers(self) -> tuple[Callable[[bool], None], ...]:
157
+ """Return the handlers told when the terminal window gains or loses focus."""
158
+ return tuple(self._focus_handlers)
159
+
160
+ def register_focus_handler(self, handler: Callable[[bool], None]) -> Callable[[bool], None]:
161
+ """Be told when the terminal gains (``True``) or loses (``False``) focus.
162
+
163
+ The terminal asks for focus reports when it starts, so this is called for a reader who switched to another
164
+ window and for one who came back; a terminal that does not report focus never calls it. It is a hook, not a
165
+ row: what it does with the news — pause, notify, summarise — is the plugin's.
166
+ """
167
+ self._focus_handlers.append(handler)
168
+ return handler
169
+
170
+ @property
171
+ def turn_start_handlers(self) -> tuple[Callable[[], None], ...]:
172
+ """Return the handlers told when a turn of the conversation starts."""
173
+ return tuple(self._turn_start_handlers)
174
+
175
+ def register_turn_start_handler(self, handler: Callable[[], None]) -> Callable[[], None]:
176
+ """Be told when a turn of the conversation starts, before its request is sent.
177
+
178
+ The counterpart of :meth:`register_turn_end_handler`: a plugin that waits for the reader to stop asking
179
+ cancels that wait here and arms it again when the turn ends. A side question and a resumed request are not
180
+ turns; a turn a command starts with ``host.prompt`` is. A handler that raises is told once and the shell
181
+ carries on.
182
+ """
183
+ self._turn_start_handlers.append(handler)
184
+ return handler
185
+
186
+ @property
187
+ def turn_end_handlers(self) -> tuple[Callable[[bool], None], ...]:
188
+ """Return the handlers told when a turn of the conversation ends."""
189
+ return tuple(self._turn_end_handlers)
190
+
191
+ def register_turn_end_handler(self, handler: Callable[[bool], None]) -> Callable[[bool], None]:
192
+ """Be told when a turn of the conversation ends, after the row it ended on is settled.
193
+
194
+ ``handler(completed)`` says whether the request was answered or was stopped or failed. A request the reader
195
+ sent is a turn, and so is one a command started with ``host.prompt``; running a command is not itself a turn,
196
+ and neither is a side question or a resumed request. This is called from inside the run that just ended,
197
+ before the shell is free again, and nothing guards two turns at once: a handler that starts work schedules it
198
+ rather than running a turn from here. A handler that raises is told once and the shell carries on.
199
+ """
200
+ self._turn_end_handlers.append(handler)
201
+ return handler
202
+
203
+ @property
204
+ def turn_segments(self) -> tuple[TurnSegment, ...]:
205
+ """Return the items of the line a request ends on registered so far, in the order they stand."""
206
+ return tuple(self._turn_segments.values())
207
+
208
+ def register_turn_segment(self, name: str, summary: str, builder: TurnBuilder) -> TurnSegment:
209
+ """Add one item to the line a request ends on (``Processed for 12s \u00b7 22:53 \u00b7 ...``), after those already there.
210
+
211
+ Each item is a segment of its own, joined to the others by the line's separator, and a switch of its own:
212
+ ``[statusline] <name> = false`` and ``/statusline hide <name>`` leave it out. A name that exists replaces
213
+ that item where it stands, which is how a plugin changes one of the program's.
214
+
215
+ Args:
216
+ name: Starts with ``turn-``; the switch's name.
217
+ summary: What it shows, in one line, for the ``/statusline`` panel.
218
+ builder: Called when a request ends with a :class:`~zettcode.plugin_api.state.TurnContext`; returns the
219
+ text, or nothing to leave the item out this time. One that raises is left out.
220
+
221
+ Raises:
222
+ ValueError: The name does not start with ``turn-``.
223
+ """
224
+ if not name.startswith(TURN_PREFIX) or name == TURN_PREFIX:
225
+ raise ValueError(f"A turn segment is named {TURN_PREFIX}<something>, not {name!r}")
226
+ segment = TurnSegment(name, summary, builder)
227
+ self._turn_segments[name] = segment
228
+ return segment
229
+
230
+ @property
231
+ def registered_commands(self) -> tuple[Command, ...]:
232
+ """Return the commands registered so far, in registration order."""
233
+ return tuple(command for provider in self._command_providers for command in provider.items)
234
+
235
+ @property
236
+ def command_providers(self) -> tuple[CommandProvider, ...]:
237
+ """Return the command sources registered so far, in registration order."""
238
+ return tuple(self._command_providers)
239
+
240
+ @property
241
+ def slots(self) -> UiSlots:
242
+ """Return the slot registry, so the loader can merge declared segments in."""
243
+ return self._slots
244
+
245
+ @property
246
+ def presenters(self) -> tuple[Presenter[Any], ...]:
247
+ """Return the presenters registered so far, in registration order."""
248
+ return tuple(self._presenters)
249
+
250
+ @property
251
+ def themes(self) -> tuple[ThemeSource, ...]:
252
+ """Return the palettes registered so far, in registration order."""
253
+ return tuple(self._themes)
254
+
255
+ @property
256
+ def pets(self) -> tuple[PetSource, ...]:
257
+ """Return the pets registered so far, in registration order."""
258
+ return tuple(self._pets)
259
+
260
+ @property
261
+ def rows(self) -> tuple[UiRow, ...]:
262
+ """Return both rows with the segments registered so far."""
263
+ return self._slots.rows()
264
+
265
+ def register_header_left(
266
+ self, builder: UiBuilder, *, name: str = "", override: bool = False
267
+ ) -> tuple[UiSegment, bool]:
268
+ """Draw a segment on the header's left side.
269
+
270
+ Returns:
271
+ The segment, and whether it displaced one already there.
272
+ """
273
+ return self._slots.add("header", "left", name, builder, override=override)
274
+
275
+ def register_header_right(
276
+ self, builder: UiBuilder, *, name: str = "", override: bool = False
277
+ ) -> tuple[UiSegment, bool]:
278
+ """Draw a segment on the header's right side.
279
+
280
+ Returns:
281
+ The segment, and whether it displaced one already there.
282
+ """
283
+ return self._slots.add("header", "right", name, builder, override=override)
284
+
285
+ def register_status_left(
286
+ self, builder: UiBuilder, *, name: str = "", override: bool = False
287
+ ) -> tuple[UiSegment, bool]:
288
+ """Draw a segment on the status line's left side.
289
+
290
+ Returns:
291
+ The segment, and whether it displaced one already there.
292
+ """
293
+ return self._slots.add("status", "left", name, builder, override=override)
294
+
295
+ def register_status_right(
296
+ self, builder: UiBuilder, *, name: str = "", override: bool = False
297
+ ) -> tuple[UiSegment, bool]:
298
+ """Draw a segment on the status line's right side.
299
+
300
+ Returns:
301
+ The segment, and whether it displaced one already there.
302
+ """
303
+ return self._slots.add("status", "right", name, builder, override=override)
304
+
305
+ def register_command(
306
+ self,
307
+ name: str,
308
+ description: str,
309
+ handler: Any,
310
+ *,
311
+ when_busy: Any = "refuse",
312
+ subcommands: Any = (),
313
+ ) -> Any:
314
+ """Register one slash command the shell will offer and run.
315
+
316
+ The leading slash is optional, so ``"deploy"`` and ``"/deploy"`` are the
317
+ same command. ``description`` is the one line shown by the completion
318
+ menu and ``/help``. ``handler`` is handed a
319
+ :class:`~zettcode.plugin_api.commands.CommandContext`: the trimmed argument and
320
+ the UI surfaces it may write to while it works
321
+ (``context.ui.markdown``, ``.notice``, ``.error``, ``.notify``). It
322
+ returns a :class:`~zettcode.plugin_api.commands.CommandResult` for anything left
323
+ to show — a page to open, a re-layout. Raising ``ValueError`` reports a
324
+ usage error instead of changing state.
325
+
326
+ Args:
327
+ name: Command as typed; a leading ``/`` is added when missing.
328
+ description: One-line summary shown in the menu and in ``/help``.
329
+ handler: Coroutine taking a
330
+ :class:`~zettcode.plugin_api.commands.CommandContext` and returning the
331
+ result.
332
+ when_busy: What happens when the command is typed while a request is
333
+ running. ``"refuse"`` (the default) turns it away; ``"queue"`` runs
334
+ it when the request ends; ``"now"`` runs it at once, beside the
335
+ request. Say ``"now"`` only for a command that does not touch what
336
+ a running request is built on — it reads or reports, or changes a
337
+ setting the request reads when it needs it — because it is run
338
+ without the busy state to itself.
339
+ subcommands: The words the command takes first, offered by the completion menu
340
+ once the command and a space are typed: names, or
341
+ :class:`~zettcode.plugin_api.commands.Subcommand` for a name with a description. A
342
+ function returning them is asked each time the menu opens. They only suggest;
343
+ the handler gets whatever was typed as ``context.argument``.
344
+
345
+ Returns:
346
+ The registered command, in case the plugin wants to keep it.
347
+
348
+ Raises:
349
+ ValueError: When the name is blank, or a command with that name was
350
+ already registered by this plugin.
351
+ """
352
+ name = name.strip()
353
+ if not name or name == "/":
354
+ raise ValueError("Command name cannot be empty")
355
+ name = name if name.startswith("/") else f"/{name}"
356
+ if any(command.name == name for command in self.registered_commands):
357
+ raise ValueError(f"Command already registered: {name}")
358
+ return self.register_command_provider(
359
+ CommandList(
360
+ (
361
+ Command(
362
+ name=name,
363
+ description=description,
364
+ type="plugin",
365
+ handler=handler,
366
+ when_busy=when_busy,
367
+ subcommands=subcommands,
368
+ ),
369
+ )
370
+ )
371
+ ).items[0]
372
+
373
+ def register_command_provider(self, provider: CommandProvider) -> CommandProvider:
374
+ """Register one source of slash commands.
375
+
376
+ The singular :meth:`register_command` is the common case; a plugin that
377
+ builds its commands together registers them as one provider instead.
378
+
379
+ Args:
380
+ provider: Source whose commands the shell will offer and run.
381
+
382
+ Returns:
383
+ The registered provider, in case the plugin wants to keep it.
384
+
385
+ Raises:
386
+ ValueError: When a command name is blank, or already registered by
387
+ this plugin.
388
+ """
389
+ registered = {command.name for command in self.registered_commands}
390
+ for command in provider.items:
391
+ if not command.name.strip() or command.name == "/":
392
+ raise ValueError("Command name cannot be empty")
393
+ if command.name in registered:
394
+ raise ValueError(f"Command already registered: {command.name}")
395
+ self._command_providers.append(provider)
396
+ return provider
397
+
398
+ def register_theme(
399
+ self,
400
+ name: str,
401
+ theme: Theme | Callable[[], Theme] | Path,
402
+ *,
403
+ description: str | Callable[[], str] = "",
404
+ ) -> ThemeSource:
405
+ """Offer a palette by name, so ``/theme <name>`` switches to it and the menu lists and previews it.
406
+
407
+ The palette is a :class:`~zettcode.tui.Theme`, a function that builds one (called when the palette is
408
+ first asked for, so offering many costs nothing until one is picked), or the path of a theme ``.toml``
409
+ file, read the same way. A file of the reader's in ``~/.zettcode/themes`` with the same name replaces it.
410
+ The name is one word and cannot be ``dark`` or ``light``, which are the program's own.
411
+
412
+ Args:
413
+ name: What ``/theme`` is typed with: letters, digits, ``.``, ``_`` and ``-``.
414
+ theme: The palette, a function that returns it, or a path to its file.
415
+ description: One line shown beside the name in the menu (or a function returning it). A path's own
416
+ ``description`` key is used when this is left out.
417
+
418
+ Returns:
419
+ The registered source, in case the plugin wants to keep it.
420
+
421
+ Raises:
422
+ ValueError: When the name is not one word, is reserved, or this plugin already registered it.
423
+ """
424
+ name = name.strip()
425
+ if not THEME_NAME.fullmatch(name):
426
+ raise ValueError(f"Theme name must be one word of letters, digits, '.', '_' or '-': {name!r}")
427
+ if name in RESERVED_THEMES:
428
+ raise ValueError(f"Theme name is reserved: {name}")
429
+ if any(existing.name == name for existing in self._themes):
430
+ raise ValueError(f"Theme already registered: {name}")
431
+ if isinstance(theme, Theme):
432
+ fixed = theme
433
+ source = ThemeSource(name, lambda: fixed, description)
434
+ elif callable(theme):
435
+ source = ThemeSource(name, theme, description)
436
+ else:
437
+ source = file_theme(name, theme)
438
+ if description:
439
+ source = ThemeSource(name, source.load, description)
440
+ self._themes.append(source)
441
+ return source
442
+
443
+ def register_pet(
444
+ self,
445
+ name: str,
446
+ pet: Pet | Callable[[], Pet],
447
+ *,
448
+ description: str | Callable[[], str] = "",
449
+ ) -> PetSource:
450
+ """Offer a pet by name, so ``/pet <name>`` switches to it and the menu lists and previews it.
451
+
452
+ The pet is a :class:`~zettcode.app.pets.Pet`, or a function that builds one (called when the pet is
453
+ first asked for, so offering many costs nothing until one is picked). The name is one word, and the
454
+ ones the program ships (``slime``, ``cat``, ``sprout``) are taken: a pet that shares a name with an
455
+ earlier one is not offered.
456
+
457
+ Args:
458
+ name: What ``/pet`` is typed with: letters, digits, ``.``, ``_`` and ``-``.
459
+ pet: The pet, or a function that returns it.
460
+ description: One line shown beside the name in the menu (or a function returning it).
461
+
462
+ Returns:
463
+ The registered source, in case the plugin wants to keep it.
464
+
465
+ Raises:
466
+ ValueError: When the name is not one word, or this plugin already registered it.
467
+ """
468
+ name = name.strip()
469
+ if not PET_NAME.fullmatch(name):
470
+ raise ValueError(f"Pet name must be one word of letters, digits, '.', '_' or '-': {name!r}")
471
+ if any(existing.name == name for existing in self._pets):
472
+ raise ValueError(f"Pet already registered: {name}")
473
+ if isinstance(pet, Pet):
474
+ fixed = pet
475
+ source = PetSource(name, lambda: fixed, description)
476
+ elif callable(pet):
477
+ source = PetSource(name, pet, description)
478
+ else:
479
+ raise ValueError("A pet must be a zettcode.plugin_api.pets.Pet or a function that returns one")
480
+ self._pets.append(source)
481
+ return source
482
+
483
+ def register_presenter(self, presenter: Presenter[Any]) -> Presenter[Any]:
484
+ """Draw rows with a presenter of the plugin's own instead of the way they are drawn now.
485
+
486
+ The presenter takes part in the chain through its own ``handle``: it claims an entry by returning itself,
487
+ or calls ``next(entry)`` to leave it to the presenters registered after it. It is placed by its own
488
+ :attr:`Presenter.priority`: the highest wins, and an equal priority goes to the later registration, so the
489
+ built-in presenters (priority 0, registered first) are the default any plugin's presenter replaces.
490
+
491
+ Args:
492
+ presenter: The presenter.
493
+
494
+ Returns:
495
+ The presenter, in case the plugin wants to keep it.
496
+
497
+ Raises:
498
+ ValueError: When the object is not a presenter.
499
+ """
500
+ if not isinstance(presenter, Presenter):
501
+ raise ValueError("A presenter must be a zettcode.plugin_api.presenters.Presenter")
502
+ self._presenters.append(presenter)
503
+ return presenter