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,385 @@
1
+ """The conversation's entries: plain data, one class for each way a row can look.
2
+
3
+ An entry says *what happened* — a user asked, a tool ran, the model is thinking —
4
+ and nothing about how that is drawn. The drawing is ``presenters.py``, which looks
5
+ an entry up by its :class:`EntryKind`; the list of entries, and what the agent
6
+ does to it, is ``transcript.py``.
7
+
8
+ Two base classes carry what the concrete ones share. :class:`Entry` is identity
9
+ and a place for the presenter to leave its rendered rows; :class:`TimedEntry`
10
+ is an entry that takes time — the wait row, a reasoning span, a tool call — and
11
+ so has a status, a start, and a duration.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+
18
+ from zettcode._compat import StrEnum
19
+ from zettcode.tui import LineSource, Markdown
20
+
21
+ #: What a thinking row says until the model has said what it is doing.
22
+ THINKING_LABEL = "Thinking"
23
+
24
+
25
+ class EntryKind(StrEnum):
26
+ """What a row is, which is what picks the presenter that draws it.
27
+
28
+ The value is the word the rest of the application and its tests use for the
29
+ row, so an entry's ``kind`` still compares equal to the plain string.
30
+
31
+ Attributes:
32
+ WELCOME: The banner shown once at startup.
33
+ USER: A message the reader sent, or a side question.
34
+ NOTICE: A muted one-off status line, or an error.
35
+ ANNOUNCEMENT: A change worth a centred line between rules.
36
+ ANSWER: The model's reply, streamed in as it is written.
37
+ MESSAGE: Markdown that is not a reply, such as a command's output.
38
+ WAIT: The pinned row that says a request is running, and settles into
39
+ the line it ended on.
40
+ THINKING: Reasoning, or a compaction's summary, with its own clock.
41
+ TOOL: One tool call and its bounded result.
42
+ CHANGES: The files a request changed, said once when it ends.
43
+ RELAY: A message that crossed between two sessions, in either direction.
44
+ """
45
+
46
+ WELCOME = "welcome"
47
+ USER = "user"
48
+ NOTICE = "notice"
49
+ ANNOUNCEMENT = "announcement"
50
+ ANSWER = "answer"
51
+ MESSAGE = "message"
52
+ WAIT = "wait"
53
+ THINKING = "thinking"
54
+ TOOL = "tool"
55
+ CHANGES = "changes"
56
+ RELAY = "relay"
57
+
58
+
59
+ class EntryStatus(StrEnum):
60
+ """Lifecycle of a row that takes time: a wait, a reasoning span, a tool call."""
61
+
62
+ RUNNING = "running"
63
+ COMPLETED = "completed"
64
+ FAILED = "failed"
65
+ SKIPPED = "skipped"
66
+
67
+
68
+ class NoticeLevel(StrEnum):
69
+ """How a notice is meant: a remark, or a failure."""
70
+
71
+ INFO = "info"
72
+ ERROR = "error"
73
+
74
+
75
+ @dataclass(slots=True, kw_only=True)
76
+ class Entry:
77
+ """Identity, a kind, and the slots a presenter caches its rendered rows in.
78
+
79
+ ``block`` and ``block_key`` belong to ``presenters.block_for``: it keeps the
80
+ rows it last produced here, with the key they were produced under, so a row
81
+ that has not changed is not rendered again. They live on the entry only
82
+ because an entry's lifetime is exactly the cache's; nothing else reads them.
83
+ """
84
+
85
+ id: int
86
+ kind: EntryKind
87
+ block: LineSource | None = field(default=None, init=False, repr=False)
88
+ block_key: tuple[object, ...] | None = field(default=None, init=False, repr=False)
89
+
90
+ def __post_init__(self) -> None:
91
+ """Accept a kind as its plain word, reject one that is not a kind."""
92
+ self.kind = EntryKind(self.kind)
93
+ self.validate()
94
+
95
+ def validate(self) -> None:
96
+ """Convert and check what a subclass adds; the base has nothing to check."""
97
+
98
+
99
+ @dataclass(slots=True, kw_only=True)
100
+ class TimedEntry(Entry):
101
+ """An entry that takes time, and so has a status, a start, and a duration.
102
+
103
+ ``started_at`` is the clock reading the row opened on and ``duration`` how long
104
+ it has run, which is what the row's timer shows: the transcript brings both up
105
+ while a row is :attr:`running` and stops when it is not.
106
+ """
107
+
108
+ status: EntryStatus = EntryStatus.RUNNING
109
+ started_at: float | None = None
110
+ duration: float | None = None
111
+
112
+ def validate(self) -> None:
113
+ """Accept a status as its plain word, reject one that is not a status."""
114
+ self.status = EntryStatus(self.status)
115
+
116
+ @property
117
+ def running(self) -> bool:
118
+ """Return whether the row is still going, which is what its timer follows."""
119
+ return self.status is EntryStatus.RUNNING
120
+
121
+ def tick(self, now: float) -> None:
122
+ """Bring the duration up to ``now``; a row that never recorded a start has none."""
123
+ if self.started_at is not None:
124
+ self.duration = now - self.started_at
125
+
126
+ def finish(self, status: EntryStatus, now: float) -> None:
127
+ """End the row with an outcome, and stamp how long it ran.
128
+
129
+ Args:
130
+ status: How it ended: completed, failed, or skipped.
131
+ now: The clock reading it ended on.
132
+ """
133
+ self.status = status
134
+ self.tick(now)
135
+
136
+
137
+ _TEXT_KINDS = frozenset({EntryKind.WELCOME, EntryKind.USER, EntryKind.NOTICE, EntryKind.ANNOUNCEMENT})
138
+ _MARKDOWN_KINDS = frozenset({EntryKind.ANSWER, EntryKind.MESSAGE})
139
+
140
+
141
+ @dataclass(slots=True, kw_only=True)
142
+ class TextEntry(Entry):
143
+ """A row of plain text: the welcome banner, a user message, a notice, an announcement."""
144
+
145
+ text: str
146
+ level: NoticeLevel = NoticeLevel.INFO
147
+ #: Side question: an answer that never joined the conversation.
148
+ side: bool = False
149
+
150
+ def validate(self) -> None:
151
+ """Keep the kind to the plain-text ones, and the level to a known one."""
152
+ if self.kind not in _TEXT_KINDS:
153
+ raise ValueError(f"A {self.kind.value} row is not plain text")
154
+ self.level = NoticeLevel(self.level)
155
+
156
+
157
+ @dataclass(slots=True, kw_only=True)
158
+ class MarkdownEntry(Entry):
159
+ """The model's reply as it streams in, or Markdown command output.
160
+
161
+ The parsed ``markdown`` is the row's own state: it is appended to as the reply
162
+ arrives and serves its lines incrementally, so a token landing costs one row's
163
+ work instead of a re-render of the answer.
164
+ """
165
+
166
+ text: str = ""
167
+ markdown: Markdown = field(default_factory=Markdown)
168
+
169
+ def validate(self) -> None:
170
+ """Keep the kind to the Markdown ones."""
171
+ if self.kind not in _MARKDOWN_KINDS:
172
+ raise ValueError(f"A {self.kind.value} row is not Markdown")
173
+
174
+
175
+ @dataclass(slots=True, kw_only=True)
176
+ class WaitEntry(TimedEntry):
177
+ """The pinned wait row: a model call is running, and how long it has taken.
178
+
179
+ It is the last entry of the transcript for as long as the request runs, and
180
+ the first one written when the request is over: the line it is settled with
181
+ says what happened — ``Processed for 12s · 22:53`` — so a stopped request
182
+ leaves a record instead of an empty place where a row used to be.
183
+ """
184
+
185
+ kind: EntryKind = field(default=EntryKind.WAIT, init=False)
186
+ title: str = "Processing"
187
+ started_at: float
188
+ #: The clock reading of the last thing the agent said (a delta, a tool, a step), which is when the model
189
+ #: was last heard from; ``None`` until it has said anything.
190
+ heard_at: float | None = None
191
+ #: Seconds the model has said nothing while nothing else was running, brought up with the timers. Zero
192
+ #: while a tool, a reasoning row or a compaction is the thing being waited for: a ``pytest`` that takes two
193
+ #: minutes is not the model going quiet.
194
+ quiet: float = 0.0
195
+ #: The tips this request may show, in the order it shows them (see :mod:`.tips`); none when they are off.
196
+ tips: tuple[str, ...] = ()
197
+ #: The line the request ended on, written when the row is settled.
198
+ text: str = ""
199
+ #: The items of the line, one string each, written with the line; the presenter joins them with the separator
200
+ #: and breaks the row between two, never inside one. Empty means the line is ``text`` alone.
201
+ parts: tuple[str, ...] = ()
202
+
203
+
204
+ @dataclass(slots=True, kw_only=True)
205
+ class ThinkingEntry(TimedEntry):
206
+ """Expandable reasoning with its own clock and completion state.
207
+
208
+ The same row carries a compaction: the summarizer is a model call whose
209
+ progress deserves the same row and timer, so the label is part of the entry
210
+ rather than something the presenter decides.
211
+ """
212
+
213
+ kind: EntryKind = field(default=EntryKind.THINKING, init=False)
214
+ title: str = THINKING_LABEL
215
+ text: str = ""
216
+ expanded: bool = False
217
+
218
+
219
+ @dataclass(slots=True, kw_only=True)
220
+ class ToolEntry(TimedEntry):
221
+ """One tool call and its bounded result: what every tool has, and nothing only some do.
222
+
223
+ A tool whose row shows something of its own has an entry of its own that adds it
224
+ (:class:`ReadEntry`, :class:`WriteEntry`, :class:`EditEntry`); they are all ``tool`` rows,
225
+ folded and timed alike, and the presenter registry finds the one that draws each by its class.
226
+ Which class a call gets is decided by the render handler that describes it
227
+ (:class:`~.rendering.ToolRow`), so nothing here, and nothing in the transcript, names a tool.
228
+ """
229
+
230
+ kind: EntryKind = field(default=EntryKind.TOOL, init=False)
231
+ call_id: str
232
+ tool: str
233
+ title: str
234
+ text: str = ""
235
+ expanded: bool = False
236
+
237
+ @property
238
+ def foldable(self) -> bool:
239
+ """Return whether there is a body to expand and collapse."""
240
+ return not self.running and bool(self.text)
241
+
242
+
243
+ @dataclass(slots=True, kw_only=True)
244
+ class ReadEntry(ToolEntry):
245
+ """A file that was read: its body is source code, drawn as the language it is.
246
+
247
+ ``language`` names the scanner for the output, or is ``None`` for a file that is not code. ``lines`` is how many
248
+ lines came back, once a completed read has one; it is a field of the row rather than part of its title, so the
249
+ title being restated or cut at the width cannot lose it. ``picture`` marks a path that looks like a picture — the suffix the read tool
250
+ sends as an image — since a row is opened from the arguments, before the result exists; a file whose content is an
251
+ image under another name is counted as text until the result itself says otherwise.
252
+ """
253
+
254
+ language: str | None = None
255
+ lines: int | None = None
256
+ picture: bool = False
257
+
258
+
259
+ @dataclass(slots=True, kw_only=True)
260
+ class FileChangeEntry(ToolEntry):
261
+ """A tool that changed a file, and the change it made, as rows.
262
+
263
+ ``path`` is the file, as the call named it. ``added`` is the number of lines the change adds, drawn
264
+ beside the title in the colour an addition has. ``diff`` is the change itself (see :data:`~.rendering.DiffRow`), which the row shows in place
265
+ of the tool's one-line answer once the call has succeeded: the answer says "Wrote 120 bytes", the
266
+ diff says what was written.
267
+ """
268
+
269
+ path: str = ""
270
+ added: int = 0
271
+ diff: tuple[tuple[str, str], ...] = ()
272
+
273
+ @property
274
+ def shows_diff(self) -> bool:
275
+ """Return whether the row's body is its diff: there is one, and the call succeeded.
276
+
277
+ A call that failed did not change the file, so a diff of what it meant to do would
278
+ describe something that did not happen; its error is the body then.
279
+ """
280
+ return bool(self.diff) and self.status is EntryStatus.COMPLETED
281
+
282
+ @property
283
+ def foldable(self) -> bool:
284
+ """Return whether there is a body to expand: the tool's answer, or the diff."""
285
+ return not self.running and (bool(self.text) or self.shows_diff)
286
+
287
+
288
+ @dataclass(slots=True, kw_only=True)
289
+ class WriteEntry(FileChangeEntry):
290
+ """A file that was written whole: every line of it is an addition.
291
+
292
+ The tool overwrites, and what it overwrote is not in the call, so there is nothing to take away
293
+ from: the diff is the new content as additions, and there is no ``removed`` to count.
294
+ """
295
+
296
+
297
+ @dataclass(slots=True, kw_only=True)
298
+ class EditEntry(FileChangeEntry):
299
+ """A file that was edited in place: the lines the edits add and remove, and the change.
300
+
301
+ ``removed`` is counted from the call's arguments when the row opens, as ``added`` is.
302
+ """
303
+
304
+ removed: int = 0
305
+
306
+
307
+ @dataclass(slots=True, kw_only=True)
308
+ class DeleteEntry(ToolEntry):
309
+ """A file that was deleted; ``path`` is the file, as the call named it."""
310
+
311
+ path: str = ""
312
+
313
+
314
+ @dataclass(slots=True, kw_only=True)
315
+ class MessageEntry(ToolEntry):
316
+ """A message the agent sent to another session: who it was for, and every word of it.
317
+
318
+ The tool's own answer is one line (``Delivered to work …``); what the reader wants to see is what
319
+ was said, which the call's arguments hold and a one-line title cannot, so the entry keeps it and the
320
+ row shows it — a few lines folded, the whole message expanded.
321
+ """
322
+
323
+ to: str = ""
324
+ message: str = ""
325
+ #: The group chat it was written to (without the ``#``), or empty for one session: the row says which.
326
+ group: str = ""
327
+
328
+ @property
329
+ def foldable(self) -> bool:
330
+ """Return whether there is more to see once the call is over: what was said, or how it ended."""
331
+ return not self.running and bool(self.message or self.text)
332
+
333
+
334
+ @dataclass(frozen=True, slots=True)
335
+ class FileSummary:
336
+ """What one request did to one file, in total.
337
+
338
+ Attributes:
339
+ path: The file, relative to the workspace when it is inside it.
340
+ added: Lines added across every edit and write of it.
341
+ removed: Lines removed across every edit of it.
342
+ deleted: The file's last change was its deletion.
343
+ """
344
+
345
+ path: str
346
+ added: int = 0
347
+ removed: int = 0
348
+ deleted: bool = False
349
+
350
+
351
+ @dataclass(slots=True, kw_only=True)
352
+ class ChangesEntry(Entry):
353
+ """The files a request changed, listed once when it ends.
354
+
355
+ Never made for a request that changed nothing: a row that says "no changes" would be said after
356
+ every question.
357
+ """
358
+
359
+ kind: EntryKind = field(default=EntryKind.CHANGES, init=False)
360
+ files: tuple[FileSummary, ...] = ()
361
+
362
+
363
+ @dataclass(slots=True, kw_only=True)
364
+ class RelayEntry(Entry):
365
+ """A message between two sessions: one this session received, or one its reader sent.
366
+
367
+ Attributes:
368
+ peer: The other session's handle.
369
+ text: The message.
370
+ outgoing: This session sent it; otherwise it arrived.
371
+ by_agent: The other session's agent wrote it (received), or this session's reader chose to
372
+ send it with ``@`` (sent, so ``False``).
373
+ external: It came from a program that is not a ZettCode session, whose name nobody has checked.
374
+ note: What became of a sent message, such as ``queued``; empty for a received one.
375
+ """
376
+
377
+ kind: EntryKind = field(default=EntryKind.RELAY, init=False)
378
+ peer: str
379
+ text: str
380
+ outgoing: bool = False
381
+ by_agent: bool = True
382
+ external: bool = False
383
+ note: str = ""
384
+ #: The group chat it was written in (without the ``#``); empty for a one-to-one message.
385
+ group: str = ""
@@ -0,0 +1,36 @@
1
+ """The way from a plugin to the shell, which does not exist yet when the plugin is activated."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING: # pragma: no cover - annotations only
8
+ from zettcode.plugin_api.commands import CommandHost
9
+
10
+ #: Provided by the terminal's loader to every plugin: the link a plugin follows to the shell once there is one.
11
+ HOST_LINK = "host-link"
12
+
13
+
14
+ class HostLink:
15
+ """The way from a plugin to the shell, which does not exist yet when the plugin is activated.
16
+
17
+ A plugin is activated before the shell is built, so a command cannot capture the shell the way it captures a
18
+ setting. What it needs *while it runs* it gets from ``CommandContext.host``; what it needs *before it runs* — the
19
+ words its completion menu offers, which depend on the models configured now and not when the program started —
20
+ it reads through this link, which the shell attaches itself to once it is built.
21
+
22
+ ::
23
+
24
+ link = container.service(HOST_LINK, HostLink)
25
+ container.register_command("model", "choose a model", handler, subcommands=lambda: words(link.host))
26
+
27
+ Attributes:
28
+ host: The shell, or ``None`` until it is attached (and in a program that has no shell, such as a test).
29
+ """
30
+
31
+ def __init__(self) -> None:
32
+ self.host: CommandHost | None = None
33
+
34
+ def attach(self, host: CommandHost) -> None:
35
+ """Point the link at the shell."""
36
+ self.host = host
@@ -0,0 +1,67 @@
1
+ """The shell's row hooks a terminal plugin can implement.
2
+
3
+ :class:`PluginUiMixin` is this application's row group: the four slots of the header and the status line. Every hook
4
+ defaults to doing nothing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING: # pragma: no cover - annotations only, so the imports stay lazy
12
+ from zettcode.plugin_api.state import ShellContext, UiBuilder, UiBuilderResult, UiRegion, UiSide
13
+
14
+ #: ``(method, region, side)`` for every slot the UI mixin exposes, in paint
15
+ #: order. The method name minus its prefix is the segment name a plugin's
16
+ #: override registers under.
17
+ UI_SLOTS: tuple[tuple[str, UiRegion, UiSide], ...] = (
18
+ ("render_header_left", "header", "left"),
19
+ ("render_header_right", "header", "right"),
20
+ ("render_status_left", "status", "left"),
21
+ ("render_status_right", "status", "right"),
22
+ )
23
+
24
+
25
+ class PluginUiMixin:
26
+ """The four shell slots a plugin can fill.
27
+
28
+ Each method is one named slot: the header's and the status line's left and
29
+ right sides. Override one and it is registered under that slot's name, so
30
+ overriding the method a builtin already implements replaces that segment
31
+ rather than adding a second one. A plugin that needs another segment beside
32
+ the builtin's, or decides what to register from the configuration, uses
33
+ ``ctx.tui.register_*`` in :meth:`TuiPlugin.activate`.
34
+
35
+ The names are deliberately specific, so a plugin's own ``status`` or
36
+ ``header`` helper cannot be mistaken for a contribution. A builder returns
37
+ the segment, or ``(segment, True)`` to take the side over and drop what the
38
+ segments before it painted.
39
+ """
40
+
41
+ def render_header_left(self, context: ShellContext) -> UiBuilderResult:
42
+ """Return the header's left segment, or ``(segment, True)`` to take the side over."""
43
+ return None
44
+
45
+ def render_header_right(self, context: ShellContext) -> UiBuilderResult:
46
+ """Return the header's right segment, or ``(segment, True)`` to take the side over."""
47
+ return None
48
+
49
+ def render_status_left(self, context: ShellContext) -> UiBuilderResult:
50
+ """Return the status line's left segment, or ``(segment, True)`` to take the side over."""
51
+ return None
52
+
53
+ def render_status_right(self, context: ShellContext) -> UiBuilderResult:
54
+ """Return the status line's right segment, or ``(segment, True)`` to take the side over."""
55
+ return None
56
+
57
+ def ui_slots(self) -> tuple[tuple[UiRegion, UiSide, str, UiBuilder], ...]:
58
+ """Return the slots this plugin overrides, ready to register.
59
+
60
+ The segment name is the method's slot name, which is what makes an
61
+ override land on the builtin segment instead of beside it.
62
+ """
63
+ declared: list[tuple[UiRegion, UiSide, str, UiBuilder]] = []
64
+ for name, region, side in UI_SLOTS:
65
+ if getattr(type(self), name) is not getattr(PluginUiMixin, name):
66
+ declared.append((region, side, name.removeprefix("render_"), getattr(self, name)))
67
+ return tuple(declared)
@@ -0,0 +1,39 @@
1
+ """Bottom-panel presentation for the shell's own pages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from zettcode.tui import Anchor, KeyEvent, Overlay, OverlaySlot, TextEvent, Widget
6
+
7
+
8
+ def bottom_panel(widget: Widget, *, rows: int) -> Widget:
9
+ """Anchor one widget to the bottom of the screen as a panel.
10
+
11
+ The caller owns the decision: a command handler that wants a panel wraps its
12
+ page in this and hands the result to :class:`~zettcode.plugin_api.commands.CommandResult`,
13
+ while one that wants the whole screen hands over the bare widget.
14
+
15
+ The panel is not framed: a page fills its own rectangle with the raised
16
+ surface, which is what separates it from the conversation above without a
17
+ border eating two rows of content on every side.
18
+
19
+ Args:
20
+ widget: Content of the panel.
21
+ rows: Height of the panel in cells.
22
+ """
23
+ anchor = Anchor(horizontal="stretch", vertical="end", height=rows)
24
+ return Overlay([OverlaySlot(widget, anchor)])
25
+
26
+
27
+ def pressed(event: object) -> str | None:
28
+ """Return the key one press names, whether the terminal sent it as a key or as text.
29
+
30
+ A terminal has no "y" key: it sends the character, and the decoder reports a printable
31
+ character as text, while arrows, Enter and Escape are named keys. A page that lists
32
+ ``(y)`` beside a row has to answer both, or the shortcut it advertises only works for the
33
+ tests that send a key event. Alt and Ctrl chords are not shortcuts and are left alone.
34
+ """
35
+ if isinstance(event, KeyEvent):
36
+ return None if event.alt or event.control else event.key
37
+ if isinstance(event, TextEvent) and len(event.text) == 1 and not (event.alt or event.control):
38
+ return event.text
39
+ return None