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.
- zettcode/app/__init__.py +63 -0
- zettcode/app/agent/__init__.py +47 -0
- zettcode/app/agent/agent/__init__.py +84 -0
- zettcode/app/agent/agent/commands.py +269 -0
- zettcode/app/agent/agent/settings.py +132 -0
- zettcode/app/agent/conversation/__init__.py +7 -0
- zettcode/app/agent/conversation/changes.py +64 -0
- zettcode/app/agent/conversation/effort_gauge.py +126 -0
- zettcode/app/agent/conversation/projection.py +265 -0
- zettcode/app/agent/conversation/rendering.py +837 -0
- zettcode/app/agent/conversation/replay.py +198 -0
- zettcode/app/agent/conversation/shimmer.py +256 -0
- zettcode/app/agent/conversation/stops.py +57 -0
- zettcode/app/agent/conversation/tips.py +86 -0
- zettcode/app/agent/conversation/transcript/__init__.py +80 -0
- zettcode/app/agent/conversation/transcript/fold.py +57 -0
- zettcode/app/agent/conversation/transcript/log.py +182 -0
- zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
- zettcode/app/agent/conversation/transcript/stream.py +187 -0
- zettcode/app/agent/conversation/transcript/turn.py +323 -0
- zettcode/app/commandline/__init__.py +47 -0
- zettcode/app/commandline/arguments.py +169 -0
- zettcode/app/commandline/completion.py +262 -0
- zettcode/app/commandline/history.py +305 -0
- zettcode/app/commandline/runner.py +365 -0
- zettcode/app/errors.py +12 -0
- zettcode/app/plugins/__init__.py +110 -0
- zettcode/app/plugins/builtins.py +33 -0
- zettcode/app/plugins/groups.py +503 -0
- zettcode/app/plugins/loader.py +268 -0
- zettcode/app/py.typed +0 -0
- zettcode/app/ui/__init__.py +63 -0
- zettcode/app/ui/app.py +1507 -0
- zettcode/app/ui/bars.py +290 -0
- zettcode/app/ui/boot.py +131 -0
- zettcode/app/ui/clipboard.py +340 -0
- zettcode/app/ui/commands.py +323 -0
- zettcode/app/ui/config_form.py +343 -0
- zettcode/app/ui/demo.py +180 -0
- zettcode/app/ui/followups.py +430 -0
- zettcode/app/ui/gc_policy.py +147 -0
- zettcode/app/ui/keys.py +462 -0
- zettcode/app/ui/messaging.py +260 -0
- zettcode/app/ui/notices.py +195 -0
- zettcode/app/ui/providers.py +626 -0
- zettcode/app/ui/sessions.py +422 -0
- zettcode/app/ui/settings.py +161 -0
- zettcode/app/ui/shell.py +361 -0
- zettcode/app/ui/turns.py +115 -0
- zettcode/app/ui/updating.py +204 -0
- zettcode/app/ui/widgets/__init__.py +161 -0
- zettcode/app/ui/widgets/approval_page.py +284 -0
- zettcode/app/ui/widgets/ask_page.py +386 -0
- zettcode/app/ui/widgets/bash_page.py +90 -0
- zettcode/app/ui/widgets/completer.py +100 -0
- zettcode/app/ui/widgets/composer.py +529 -0
- zettcode/app/ui/widgets/document_page.py +159 -0
- zettcode/app/ui/widgets/effort_page.py +294 -0
- zettcode/app/ui/widgets/follow_up.py +168 -0
- zettcode/app/ui/widgets/form_page.py +380 -0
- zettcode/app/ui/widgets/input_page.py +175 -0
- zettcode/app/ui/widgets/model_page.py +63 -0
- zettcode/app/ui/widgets/model_settings_page.py +653 -0
- zettcode/app/ui/widgets/pet.py +247 -0
- zettcode/app/ui/widgets/pet_page.py +347 -0
- zettcode/app/ui/widgets/plugins_page.py +442 -0
- zettcode/app/ui/widgets/root.py +68 -0
- zettcode/app/ui/widgets/self_compact_page.py +363 -0
- zettcode/app/ui/widgets/send_kind_page.py +193 -0
- zettcode/app/ui/widgets/sessions_page.py +511 -0
- zettcode/app/ui/widgets/statusline_page.py +427 -0
- zettcode/app/ui/widgets/steering.py +126 -0
- zettcode/app/ui/widgets/transcript.py +287 -0
- zettcode/app/ui/widgets/update_page.py +65 -0
- zettcode/app/ui/widgets/welcome.py +24 -0
- zettcode/collector.py +54 -0
- zettcode/update.py +519 -0
- zettcode_app-0.4.2.dist-info/METADATA +26 -0
- zettcode_app-0.4.2.dist-info/RECORD +81 -0
- zettcode_app-0.4.2.dist-info/WHEEL +4 -0
- 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
|