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,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
|