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.
@@ -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