zettcode-plugin-api 0.4.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.
- zettcode/plugin_api/__init__.py +330 -0
- zettcode/plugin_api/commands.py +383 -0
- zettcode/plugin_api/entries.py +385 -0
- zettcode/plugin_api/host.py +36 -0
- zettcode/plugin_api/mixins.py +67 -0
- zettcode/plugin_api/panels.py +39 -0
- zettcode/plugin_api/pets.py +224 -0
- zettcode/plugin_api/plugin.py +76 -0
- zettcode/plugin_api/presenters.py +277 -0
- zettcode/plugin_api/py.typed +0 -0
- zettcode/plugin_api/rows.py +91 -0
- zettcode/plugin_api/state.py +251 -0
- zettcode/plugin_api/switch.py +87 -0
- zettcode_plugin_api-0.4.0.dist-info/METADATA +21 -0
- zettcode_plugin_api-0.4.0.dist-info/RECORD +16 -0
- zettcode_plugin_api-0.4.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Slash commands shared by the application and coding agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Literal, Protocol, TypeAlias
|
|
10
|
+
|
|
11
|
+
from zettcode.plugin_api.entries import ThinkingEntry
|
|
12
|
+
from zettcode.registry import Provider
|
|
13
|
+
from zettcode.tui import Widget
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only; the agent and the config are not imported by this module
|
|
16
|
+
from zettcode.engine.core import ZettCodeEngine
|
|
17
|
+
from zettcode.sdk.client import Client
|
|
18
|
+
from zettcode.sdk.types import ModelInfo
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CommandUi(ABC):
|
|
22
|
+
"""The surfaces a command may write to while it runs.
|
|
23
|
+
|
|
24
|
+
A handler used to describe everything it wanted to show in the
|
|
25
|
+
:class:`CommandResult` it returned, which meant a long command could not say
|
|
26
|
+
anything until it finished. With this it can report as it goes — a heading
|
|
27
|
+
before the slow part, a failure the moment one happens — and still return a
|
|
28
|
+
result for whatever is left to do (a page to open, a re-layout).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def markdown(self, text: str) -> None:
|
|
33
|
+
"""Append Markdown to the transcript, parsed like an answer."""
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def notice(self, text: str) -> None:
|
|
37
|
+
"""Append a muted one-line remark."""
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def error(self, text: str) -> None:
|
|
41
|
+
"""Append a one-line failure, painted as an error."""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def notify(self, text: str, *, level: str = "info") -> None:
|
|
45
|
+
"""Show a one-off toast, replacing any that is still on screen."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CommandAgent(Protocol):
|
|
49
|
+
"""What a command may read of the application agent, without importing the terminal that implements it.
|
|
50
|
+
|
|
51
|
+
A command needs to know which session it is in and where that session works; the rest of the agent is the
|
|
52
|
+
shell's, and naming it here would put the terminal above the package every plugin depends on.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def session_id(self) -> str:
|
|
57
|
+
"""Return the session this shell is working in."""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def workspace(self) -> Path:
|
|
61
|
+
"""Return the directory the session works in."""
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def runtime(self) -> ZettCodeEngine:
|
|
65
|
+
"""Return the runtime: the agent, its configuration and the session store."""
|
|
66
|
+
|
|
67
|
+
def require_session(self) -> None:
|
|
68
|
+
"""Refuse to go on when the active session is another process's.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
SessionLocked: The lock is not held, which the shell shows as a line in the conversation.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class CommandTranscript(Protocol):
|
|
76
|
+
"""What a command may add to the conversation: a note, an error, or a row of Markdown."""
|
|
77
|
+
|
|
78
|
+
def notice(self, text: str) -> object:
|
|
79
|
+
"""Add a line that says something happened."""
|
|
80
|
+
|
|
81
|
+
def error(self, text: str) -> object:
|
|
82
|
+
"""Add a line that says something failed."""
|
|
83
|
+
|
|
84
|
+
def markdown(self, text: str) -> object:
|
|
85
|
+
"""Add a row of Markdown, which a command's own answer is."""
|
|
86
|
+
|
|
87
|
+
def start_compaction(self) -> ThinkingEntry:
|
|
88
|
+
"""Open the row that shows a compaction working."""
|
|
89
|
+
|
|
90
|
+
def complete_compaction(self, text: str = "") -> None:
|
|
91
|
+
"""Close the running compaction row and stamp its elapsed time."""
|
|
92
|
+
|
|
93
|
+
def clear(self) -> None:
|
|
94
|
+
"""Forget every row: the conversation starts over on screen, as a new session's does."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class CommandHost(Protocol):
|
|
98
|
+
"""What a command that acts on the application, and not only on the agent, may ask of the shell.
|
|
99
|
+
|
|
100
|
+
A command that opens a panel, switches the session or changes the model needs more than a place to write:
|
|
101
|
+
it needs the agent, the transcript it is drawn into, and the few shell operations that change what is on
|
|
102
|
+
screen. This is that list and no more. The shell is what a plugin's command gets (it is the object the
|
|
103
|
+
context calls ``host``), so a plugin can provide ``/resume`` or ``/model`` without importing the shell, and
|
|
104
|
+
the shell stays free to change everything that is not named here.
|
|
105
|
+
|
|
106
|
+
Attributes:
|
|
107
|
+
agent: The application agent: models, sessions, effort, permission, the context report.
|
|
108
|
+
client: The SDK client over that agent, which is how a command reaches what the SDK offers.
|
|
109
|
+
transcript: The conversation as drawn; a command reports into it with ``notice`` and ``error``.
|
|
110
|
+
commands: Every command the shell knows, in the order ``/help`` lists them.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
client: Client
|
|
114
|
+
commands: tuple[Command, ...]
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def agent(self) -> CommandAgent:
|
|
118
|
+
"""Return the application agent; read-only, so the terminal's own class satisfies it."""
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def transcript(self) -> CommandTranscript:
|
|
122
|
+
"""Return the conversation as drawn; read-only, for the same reason."""
|
|
123
|
+
|
|
124
|
+
def close_page(self) -> None:
|
|
125
|
+
"""Close the page a command presented and return to the composer."""
|
|
126
|
+
|
|
127
|
+
def request_layout(self) -> None:
|
|
128
|
+
"""Ask for the screen to be laid out again, after a change in what it shows."""
|
|
129
|
+
|
|
130
|
+
async def wait_for_frame(self) -> None:
|
|
131
|
+
"""Wait until the next frame has reached the terminal.
|
|
132
|
+
|
|
133
|
+
A command that has just put a row on screen and is about to start work
|
|
134
|
+
with no ``await`` in it asks for this first: the row is drawn before the
|
|
135
|
+
work begins instead of after it.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def offer(self, command: str) -> None:
|
|
139
|
+
"""Put ``command`` and a space in the draft, which opens the menu of the words it takes."""
|
|
140
|
+
|
|
141
|
+
def quit(self) -> None:
|
|
142
|
+
"""Leave the application."""
|
|
143
|
+
|
|
144
|
+
def select_session(self, session_id: str) -> None:
|
|
145
|
+
"""Switch to a session picked in a panel: close the panel and resume behind the interface."""
|
|
146
|
+
|
|
147
|
+
async def resume_session(self, session_id: str) -> None:
|
|
148
|
+
"""Restore a stored session without holding the event loop for the work."""
|
|
149
|
+
|
|
150
|
+
async def fork_session(self) -> str:
|
|
151
|
+
"""Carry this conversation into a new session and continue there; return the new id."""
|
|
152
|
+
|
|
153
|
+
async def rename_session(self, title: str) -> str:
|
|
154
|
+
"""Name the active session and return the name as stored."""
|
|
155
|
+
|
|
156
|
+
async def run_continue(self) -> None:
|
|
157
|
+
"""Carry on the request that was interrupted."""
|
|
158
|
+
|
|
159
|
+
async def run_side_question(self, question: str) -> None:
|
|
160
|
+
"""Answer a question that is kept out of the conversation."""
|
|
161
|
+
|
|
162
|
+
async def prompt(self, text: str, *, shown: str | None = None, cache: str | None = None) -> bool:
|
|
163
|
+
"""Run ``text`` as one turn of this conversation, exactly as if the reader had typed it.
|
|
164
|
+
|
|
165
|
+
The text reaches the model the way a draft does: stored in the session, streamed as it is answered, and
|
|
166
|
+
settled by the same row. ``@`` references are resolved.
|
|
167
|
+
|
|
168
|
+
A command whose instructions are longer than what it wants to show passes the two apart: ``text`` is what
|
|
169
|
+
the model reads, ``shown`` is what the transcript draws in its place. A reader who typed ``/code-review``
|
|
170
|
+
sees that, not the brief it stands for — while the model reads the brief with that turn, then and in every
|
|
171
|
+
later turn, because the brief is part of the message the session keeps. Only its display is short.
|
|
172
|
+
|
|
173
|
+
The turn is awaited, so the command that asked for it holds the request slot until it has been answered,
|
|
174
|
+
and the row the shell shows is the command's own.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
text: What to ask. With no ``shown``, this is also what the reader sees.
|
|
178
|
+
shown: What the transcript shows, when it differs from ``text``. The message the session keeps is
|
|
179
|
+
the same either way: ``text`` is in it, and ``shown`` is what a reader is shown of it.
|
|
180
|
+
|
|
181
|
+
cache: How the turn may use the provider's prompt cache: ``"read_write"`` (the default) extends it for
|
|
182
|
+
the next turn, ``"read"`` reuses a warm prefix without marking this turn's new content, and
|
|
183
|
+
``"off"`` sends no cache directive. A one-off report a reader is not going to follow is worth
|
|
184
|
+
``"read"``.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Whether a turn was started and has ended. A request already running, a blank text and a session held
|
|
188
|
+
by another process answer ``False``, and nothing is started. A command that means to be usable
|
|
189
|
+
mid-request says ``when_busy="queue"``, and this then runs when the request in flight has ended.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def change_model(self, name: ModelInfo | str) -> ModelInfo:
|
|
193
|
+
"""Switch models and announce the change in the transcript."""
|
|
194
|
+
|
|
195
|
+
def select_model(self, name: ModelInfo) -> None:
|
|
196
|
+
"""Apply a model picked in a panel and close the panel."""
|
|
197
|
+
|
|
198
|
+
def change_effort(self, name: str) -> str:
|
|
199
|
+
"""Switch reasoning effort and announce the change."""
|
|
200
|
+
|
|
201
|
+
def change_permission(self, name: str) -> str:
|
|
202
|
+
"""Set how much of the shell may run unasked and announce the change."""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True, slots=True)
|
|
206
|
+
class CommandContext:
|
|
207
|
+
"""What one command handler is handed.
|
|
208
|
+
|
|
209
|
+
Attributes:
|
|
210
|
+
argument: Trimmed text after the command name; ``""`` when none.
|
|
211
|
+
ui: Surfaces the command may write to while it runs.
|
|
212
|
+
host: The shell, for a command that acts on the application itself; ``None`` where there is no shell,
|
|
213
|
+
such as a handler called directly in a test. Use :meth:`require_host` in a command that needs it.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
argument: str
|
|
217
|
+
ui: CommandUi
|
|
218
|
+
host: CommandHost | None = None
|
|
219
|
+
|
|
220
|
+
def require_host(self) -> CommandHost:
|
|
221
|
+
"""Return the shell, or raise ``RuntimeError`` when the command was run without one."""
|
|
222
|
+
if self.host is None:
|
|
223
|
+
raise RuntimeError("This command acts on the application and needs the shell to run it")
|
|
224
|
+
return self.host
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
#: Signature of a slash-command handler: its context in, what to show next out.
|
|
228
|
+
CommandHandler: TypeAlias = Callable[[CommandContext], Awaitable["CommandResult"]]
|
|
229
|
+
|
|
230
|
+
#: What the shell does with a command typed while a request is in flight.
|
|
231
|
+
#:
|
|
232
|
+
#: ``"refuse"`` is the default: the draft is turned away, exactly as a prompt
|
|
233
|
+
#: that cannot steer is. ``"queue"`` takes the draft and runs it the moment the
|
|
234
|
+
#: request in flight finishes — for a command whose whole point is to be usable
|
|
235
|
+
#: mid-reply, such as ``/btw``. ``"now"`` runs it at once, beside the request: for a
|
|
236
|
+
#: command that only looks at the session or changes a setting the request reads when it
|
|
237
|
+
#: needs it, so waiting for the request to end would be waiting for nothing (``/context``,
|
|
238
|
+
#: ``/title``, ``/theme``). A command that touches what a running request is built on — the
|
|
239
|
+
#: transcript it is writing into, the session, the model it started with — must not say
|
|
240
|
+
#: ``"now"``; it never gets the busy state to itself, because it runs without taking it.
|
|
241
|
+
BusyPolicy: TypeAlias = Literal["refuse", "queue", "now"]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass(frozen=True, slots=True)
|
|
245
|
+
class CommandResult:
|
|
246
|
+
"""What a command wants the application to display or open.
|
|
247
|
+
|
|
248
|
+
A handler returns this instead of touching widgets, so a command describes
|
|
249
|
+
its effect and the application decides when and how to paint it. Every field
|
|
250
|
+
is optional; the default result changes nothing.
|
|
251
|
+
|
|
252
|
+
Attributes:
|
|
253
|
+
message: Markdown appended to the transcript when the command wants to
|
|
254
|
+
report something. It is parsed and rendered like an answer, so a
|
|
255
|
+
command can use headings, lists, and inline code instead of plain
|
|
256
|
+
text.
|
|
257
|
+
notification: One-line toast shown after a successful run; ``None``
|
|
258
|
+
shows no toast.
|
|
259
|
+
widget: Widget to present over the conversation after the result. The
|
|
260
|
+
handler composes it, including its own panel or full-screen layout,
|
|
261
|
+
and the application only pushes it as a screen.
|
|
262
|
+
relayout: Re-measure the widget tree after applying the result. Set it
|
|
263
|
+
when the change affects geometry a fixed-height row already
|
|
264
|
+
measured, such as the header showing a new model name.
|
|
265
|
+
intro: What a result that is still working is doing, in one line. The shell shows it in the pinned row
|
|
266
|
+
while it waits, which is how a command that takes seconds says so.
|
|
267
|
+
run: The rest of a result, when the command has not finished. The shell opens the pinned row with
|
|
268
|
+
``intro``, awaits this, and shows what it returned; Ctrl-C, Escape, sending the next line, and Enter on an
|
|
269
|
+
empty draft stop it — a command that is working does not hold the composer. A command returns
|
|
270
|
+
this instead of awaiting the slow part itself, so the reader sees it working. The other fields of
|
|
271
|
+
*this* result are then names to the wind: only what ``run`` answers is applied.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
message: str = ""
|
|
275
|
+
notification: str | None = None
|
|
276
|
+
widget: Widget | None = None
|
|
277
|
+
relayout: bool = False
|
|
278
|
+
intro: str = ""
|
|
279
|
+
run: Awaitable[CommandResult] | None = None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(frozen=True, slots=True)
|
|
283
|
+
class Subcommand:
|
|
284
|
+
"""One word a command takes first, offered by the completion menu once the command is typed.
|
|
285
|
+
|
|
286
|
+
Attributes:
|
|
287
|
+
name: The word as typed (``"add"``); it replaces what the reader has typed after the command.
|
|
288
|
+
description: One-line summary shown beside it in the menu.
|
|
289
|
+
needs_more: The word is only the start of an argument (``/config add`` still asks which
|
|
290
|
+
provider). Enter on it fills it in and leaves the rest to the reader; without this,
|
|
291
|
+
Enter on it runs the command with it, as for a model or a level that says everything.
|
|
292
|
+
current: The word names what is in force now. The menu marks it and opens with it highlighted,
|
|
293
|
+
so Enter straight after the space changes nothing.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
name: str
|
|
297
|
+
description: str = ""
|
|
298
|
+
needs_more: bool = False
|
|
299
|
+
current: bool = False
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
#: What a command offers after its name: a fixed list, or a function asked each time the menu opens, for a
|
|
303
|
+
#: list that depends on the session (the models configured, the reasoning levels the model takes). A bare
|
|
304
|
+
#: string is a subcommand with no description.
|
|
305
|
+
Subcommands: TypeAlias = Sequence[Subcommand | str] | Callable[[], Sequence[Subcommand | str]]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def subcommand_words(names: Sequence[str], notes: dict[str, str], current: str = "") -> tuple[Subcommand, ...]:
|
|
309
|
+
"""Offer each name a command takes, with its note and a mark on the one in force."""
|
|
310
|
+
return tuple(Subcommand(name, notes.get(name, ""), current=name == current) for name in names)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@dataclass(frozen=True, slots=True)
|
|
314
|
+
class Command:
|
|
315
|
+
"""One slash command: how the menu shows it and what runs it.
|
|
316
|
+
|
|
317
|
+
Attributes:
|
|
318
|
+
name: The command as typed, including the leading slash (``"/model"``);
|
|
319
|
+
it is also the completion value inserted into the composer.
|
|
320
|
+
description: One-line summary shown in the completion menu and in
|
|
321
|
+
``/help``.
|
|
322
|
+
type: Which layer owns the command. ``"app"`` is a local UI action such
|
|
323
|
+
as ``/theme`` or ``/quit``; ``"agent"`` acts on the conversation,
|
|
324
|
+
its sessions, or the model, and is handled by
|
|
325
|
+
:class:`~zettcode.app.agent.TerminalAgent`; ``"plugin"`` comes from
|
|
326
|
+
an installed plugin, registered through
|
|
327
|
+
:class:`~zettcode.engine.plugins.context.PluginContext`.
|
|
328
|
+
handler: Runs the command with a :class:`CommandContext` — the trimmed
|
|
329
|
+
argument and the UI surfaces it may write to while it works — and
|
|
330
|
+
returns a :class:`CommandResult` describing anything left to show.
|
|
331
|
+
Raising ``ValueError`` reports a usage error instead of changing
|
|
332
|
+
state.
|
|
333
|
+
when_busy: What the shell does when the command is typed while a
|
|
334
|
+
request is in flight: ``"refuse"`` (the default) turns the draft
|
|
335
|
+
away, ``"queue"`` holds it and runs it as soon as that request ends,
|
|
336
|
+
and ``"now"`` runs it at once without disturbing the request (see
|
|
337
|
+
:data:`BusyPolicy` for what that is safe for).
|
|
338
|
+
subcommands: The words the command takes first (``/config add``), offered by the
|
|
339
|
+
completion menu once the command and a space are typed. A function is asked each
|
|
340
|
+
time the menu opens. They only suggest: the handler still receives whatever was
|
|
341
|
+
typed as its ``argument`` and decides what it means.
|
|
342
|
+
preview: Called with the highlighted word as the reader walks the subcommand menu, to show its effect without
|
|
343
|
+
choosing it (``/theme`` repaints in the palette), and with ``None`` when the menu goes away without the
|
|
344
|
+
word having been run, to put things back. Only the menu's own movement calls it; a command typed out in
|
|
345
|
+
full is simply run.
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
name: str
|
|
349
|
+
description: str
|
|
350
|
+
type: Literal["app", "agent", "plugin"]
|
|
351
|
+
handler: CommandHandler
|
|
352
|
+
when_busy: BusyPolicy = "refuse"
|
|
353
|
+
subcommands: Subcommands = ()
|
|
354
|
+
preview: Callable[[str | None], None] | None = None
|
|
355
|
+
|
|
356
|
+
def options(self) -> tuple[Subcommand, ...]:
|
|
357
|
+
"""Return the subcommands to offer now."""
|
|
358
|
+
source = self.subcommands
|
|
359
|
+
return tuple(
|
|
360
|
+
Subcommand(item) if isinstance(item, str) else item for item in (source() if callable(source) else source)
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class CommandProvider(Provider[Command]):
|
|
365
|
+
"""One source of slash commands, merged with the others by the shell."""
|
|
366
|
+
|
|
367
|
+
@property
|
|
368
|
+
def commands(self) -> tuple[Command, ...]:
|
|
369
|
+
"""Return this source's commands, in display order."""
|
|
370
|
+
return tuple(self.items)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class CommandList(CommandProvider):
|
|
374
|
+
"""A provider over commands that already exist."""
|
|
375
|
+
|
|
376
|
+
def __init__(self, commands: Sequence[Command] = ()) -> None:
|
|
377
|
+
"""Keep the commands exactly as given, order included."""
|
|
378
|
+
self._commands = tuple(commands)
|
|
379
|
+
|
|
380
|
+
@property
|
|
381
|
+
def items(self) -> Sequence[Command]:
|
|
382
|
+
"""Return the stored commands."""
|
|
383
|
+
return self._commands
|