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,529 @@
|
|
|
1
|
+
"""The composer: a text area that stands a large paste in for itself.
|
|
2
|
+
|
|
3
|
+
The editor itself is generic — it edits lines of text and knows nothing about
|
|
4
|
+
where they came from. This subclass adds the one thing the shell wants: a block
|
|
5
|
+
of code pasted from an editor should not push the rest of the draft out of view,
|
|
6
|
+
so a large paste is replaced by a chip that the reader can delete in one press
|
|
7
|
+
and that is put back when the draft is submitted.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
|
|
15
|
+
from zettcode.mention import MENTION
|
|
16
|
+
from zettcode.prompt import MentionPart, PromptPart, plain_text_part
|
|
17
|
+
from zettcode.tui import Canvas, KeyEvent, Point, Span, Style, TextArea
|
|
18
|
+
from zettcode.tui.core.events import AnyEvent
|
|
19
|
+
from zettcode.tui.core.host import Host
|
|
20
|
+
from zettcode.tui.render import display_width
|
|
21
|
+
|
|
22
|
+
from ..clipboard import image_from_paste
|
|
23
|
+
|
|
24
|
+
#: A slash command as typed: a leading ``/`` at a word boundary, then a name.
|
|
25
|
+
#: The lookbehind keeps a path such as ``src/foo`` from matching.
|
|
26
|
+
COMMAND = re.compile(r"(?<![\w/])/[a-z][a-z0-9_-]*")
|
|
27
|
+
#: The same tokens, anchored to the end of the text before the cursor.
|
|
28
|
+
MENTION_END = re.compile(MENTION.pattern + r"\Z")
|
|
29
|
+
COMMAND_END = re.compile(COMMAND.pattern + r"\Z")
|
|
30
|
+
#: The label an image chip carries, at the end of the text that was written before its picture.
|
|
31
|
+
IMAGE_LABEL_END = re.compile(r"\[image #\d+\]\Z")
|
|
32
|
+
|
|
33
|
+
#: What a locked composer shows in the middle of its row when it is too small for :data:`LOCK_ART`, and in the
|
|
34
|
+
#: prompt's place when a command is being typed: a circled slash, one cell wide and in the theme's colours, where
|
|
35
|
+
#: the padlock emoji was a coloured picture of a different width in every terminal.
|
|
36
|
+
LOCK = "\u2298"
|
|
37
|
+
|
|
38
|
+
#: A padlock three rows tall and five cells wide, drawn in half blocks so it takes the theme's colour. A cell is
|
|
39
|
+
#: about twice as tall as it is wide, so each row is two pixels of a five-by-six picture: a hollow shackle above a
|
|
40
|
+
#: wider body with a keyhole notch. The spaces are transparent, so the band shows through them.
|
|
41
|
+
LOCK_ART = (" \u2588\u2580\u2588 ", "\u2584\u2588\u2584\u2588\u2584", "\u2588\u2588\u2584\u2588\u2588")
|
|
42
|
+
|
|
43
|
+
#: What is written beside the padlock, longest first; the first that fits the row is used.
|
|
44
|
+
LOCK_CAPTIONS = (
|
|
45
|
+
"read-only \u00b7 open in another ZettCode process",
|
|
46
|
+
"read-only \u00b7 open in another process",
|
|
47
|
+
"read-only",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
#: The same, for a window that is installing a new version: the reason the reader is looking at a padlock is not
|
|
51
|
+
#: another process, and saying so would be false.
|
|
52
|
+
UPDATING_CAPTIONS = (
|
|
53
|
+
"updating ZettCode \u00b7 no input until it is done",
|
|
54
|
+
"updating ZettCode",
|
|
55
|
+
"updating",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
#: Cells between the padlock and its caption, and kept clear on either side of the pair.
|
|
59
|
+
LOCK_GAP = 2
|
|
60
|
+
LOCK_MARGIN = 2
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _split_mentions(parts: Sequence[PromptPart]) -> tuple[PromptPart, ...]:
|
|
64
|
+
"""Split every text run at its ``@`` tokens, keeping the order intact."""
|
|
65
|
+
split: list[PromptPart] = []
|
|
66
|
+
for part in parts:
|
|
67
|
+
if not isinstance(part, str):
|
|
68
|
+
split.append(part)
|
|
69
|
+
continue
|
|
70
|
+
cursor = 0
|
|
71
|
+
for match in MENTION.finditer(part):
|
|
72
|
+
if match.start() > cursor:
|
|
73
|
+
split.append(part[cursor : match.start()])
|
|
74
|
+
split.append(MentionPart(match.group(0)))
|
|
75
|
+
cursor = match.end()
|
|
76
|
+
if cursor < len(part):
|
|
77
|
+
split.append(part[cursor:])
|
|
78
|
+
return tuple(split)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Composer(TextArea):
|
|
82
|
+
"""A :class:`TextArea` whose large pastes read as a chip.
|
|
83
|
+
|
|
84
|
+
Shape::
|
|
85
|
+
|
|
86
|
+
> rework this [pasted text 6012 chars] please
|
|
87
|
+
^^^^^^^^^^^ <- draft text as typed
|
|
88
|
+
^^^^^^^^^^^^^^^^^^^^^^^^ <- chip, amber and bold; one
|
|
89
|
+
Backspace removes it whole
|
|
90
|
+
|
|
91
|
+
> compare [image #1] with [image #2]
|
|
92
|
+
^^^^^^^^^^ ^^^^^^^^^^ <- image chips, same rules
|
|
93
|
+
|
|
94
|
+
A paste becomes a chip when it is longer than :data:`PASTE_CHIP_CHARS`
|
|
95
|
+
characters or taller than the editor grows (:attr:`TextArea.max_height`
|
|
96
|
+
rows), because either way it would hide the draft it was pasted into.
|
|
97
|
+
|
|
98
|
+
Attributes:
|
|
99
|
+
images: The clipboard attachments each image chip stands for, by label.
|
|
100
|
+
pastes: The text each chip stands for, by chip label. A label that is no
|
|
101
|
+
longer in the draft simply never matches, so editing into a chip
|
|
102
|
+
turns it back into literal text; the map is only emptied when the
|
|
103
|
+
draft is replaced wholesale, which is what lets an undo put a chip
|
|
104
|
+
back with the text it stood for.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
#: A paste longer than this many characters becomes a chip.
|
|
108
|
+
PASTE_CHIP_CHARS = 512
|
|
109
|
+
|
|
110
|
+
def __init__(self, **kwargs: object) -> None:
|
|
111
|
+
"""Build the editor and start with no pasted text standing in."""
|
|
112
|
+
self._locked = False
|
|
113
|
+
self._lock_captions: tuple[str, ...] = LOCK_CAPTIONS
|
|
114
|
+
self._free_prompt = ""
|
|
115
|
+
self._free_placeholder = ""
|
|
116
|
+
#: Called when Enter is pressed on a locked composer with nothing typed: the reader asking to try again.
|
|
117
|
+
self.on_locked_enter: Callable[[], None] | None = None
|
|
118
|
+
super().__init__(**kwargs) # type: ignore[arg-type]
|
|
119
|
+
self.pastes: dict[str, str] = {}
|
|
120
|
+
self.images: dict[str, tuple[bytes, str]] = {}
|
|
121
|
+
#: Whether a typed ``/name`` is a command that exists, and whether a typed ``@token`` is a
|
|
122
|
+
#: resource that exists. They decide what is painted as one and what Backspace takes whole:
|
|
123
|
+
#: a word that merely *looks* like a command or a reference is just a word, and painting it
|
|
124
|
+
#: as one tells the reader something false. Left alone they accept everything, which is
|
|
125
|
+
#: what an editor that knows no commands has to do; the shell sets them.
|
|
126
|
+
self.is_command: Callable[[str], bool] = lambda name: True
|
|
127
|
+
self.is_mention: Callable[[str], bool] = lambda token: True
|
|
128
|
+
self._hits: tuple[str, tuple[tuple[int, int, str], ...]] | None = None
|
|
129
|
+
|
|
130
|
+
# -- locked ---------------------------------------------------------------------------------
|
|
131
|
+
@property
|
|
132
|
+
def locked(self) -> bool:
|
|
133
|
+
"""Return whether the session is another process's, so nothing can be written to it from here."""
|
|
134
|
+
return self._locked
|
|
135
|
+
|
|
136
|
+
@locked.setter
|
|
137
|
+
def locked(self, value: bool) -> None:
|
|
138
|
+
self._locked = value
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def lock_captions(self) -> tuple[str, ...]:
|
|
142
|
+
"""Return what a locked row says beside the padlock, longest first."""
|
|
143
|
+
return self._lock_captions
|
|
144
|
+
|
|
145
|
+
@lock_captions.setter
|
|
146
|
+
def lock_captions(self, value: tuple[str, ...]) -> None:
|
|
147
|
+
"""Say why the row is locked: the shell sets this when the reason is not another process."""
|
|
148
|
+
self._lock_captions = tuple(value)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def prompt(self) -> str:
|
|
152
|
+
"""Return the arrow, or — locked — nothing, or the lock where a command being typed has its arrow."""
|
|
153
|
+
if not self._locked:
|
|
154
|
+
return self._free_prompt
|
|
155
|
+
return f"{LOCK} " if self.text else ""
|
|
156
|
+
|
|
157
|
+
@prompt.setter
|
|
158
|
+
def prompt(self, value: str) -> None:
|
|
159
|
+
self._free_prompt = value
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def placeholder(self) -> str:
|
|
163
|
+
"""Return the hint, which a locked composer does not give: the lock is what it shows."""
|
|
164
|
+
return "" if self._locked else self._free_placeholder
|
|
165
|
+
|
|
166
|
+
@placeholder.setter
|
|
167
|
+
def placeholder(self, value: str) -> None:
|
|
168
|
+
self._free_placeholder = value
|
|
169
|
+
|
|
170
|
+
def render(self, canvas: Canvas) -> None:
|
|
171
|
+
"""Paint the editor; locked and empty, a padlock and what it means in the middle, and no arrow.
|
|
172
|
+
|
|
173
|
+
A composer three rows or more high gets the drawn padlock with its caption beside it; a smaller one gets
|
|
174
|
+
the one-cell mark alone, because a picture that does not fit is worse than a symbol that does.
|
|
175
|
+
"""
|
|
176
|
+
super().render(canvas)
|
|
177
|
+
if not (self._locked and not self.text) or self.rect.empty:
|
|
178
|
+
return
|
|
179
|
+
rect = self.rect
|
|
180
|
+
band = self.theme.surface_alt if self.surface else None
|
|
181
|
+
lock = Style(foreground=self.theme.warning, background=band)
|
|
182
|
+
art_width = len(LOCK_ART[0])
|
|
183
|
+
if rect.height >= len(LOCK_ART) and rect.width >= art_width:
|
|
184
|
+
top = rect.y + (rect.height - len(LOCK_ART)) // 2
|
|
185
|
+
room = rect.width - art_width - LOCK_GAP - 2 * LOCK_MARGIN
|
|
186
|
+
caption = next((words for words in self._lock_captions if display_width(words) <= room), "")
|
|
187
|
+
block = art_width + (LOCK_GAP + display_width(caption) if caption else 0)
|
|
188
|
+
column = rect.x + (rect.width - block) // 2
|
|
189
|
+
for offset, art in enumerate(LOCK_ART):
|
|
190
|
+
canvas.draw_text(column, top + offset, art, lock, max_width=art_width)
|
|
191
|
+
if caption:
|
|
192
|
+
words = Style(foreground=self.theme.muted, background=band)
|
|
193
|
+
canvas.draw_text(column + art_width + LOCK_GAP, top + 1, caption, words, max_width=room)
|
|
194
|
+
return
|
|
195
|
+
banded = self.surface and rect.height >= 3
|
|
196
|
+
inner = rect.height - (2 if banded else 0)
|
|
197
|
+
row = rect.y + (1 if banded else 0) + (inner - 1) // 2
|
|
198
|
+
column = rect.x + max(0, (rect.width - display_width(LOCK)) // 2)
|
|
199
|
+
canvas.draw_text(column, row, LOCK, lock, max_width=2)
|
|
200
|
+
|
|
201
|
+
def cursor(self) -> Point | None:
|
|
202
|
+
"""Show no cursor over the lock: there is nothing here to type into."""
|
|
203
|
+
if self._locked and not self.text:
|
|
204
|
+
return None
|
|
205
|
+
return super().cursor()
|
|
206
|
+
|
|
207
|
+
def handle(self, event: AnyEvent, host: Host) -> bool:
|
|
208
|
+
"""Edit as usual; locked, let through only what a command is made of, and make Enter a request to retry.
|
|
209
|
+
|
|
210
|
+
What a locked draft may become is checked after the editor has acted on the key, and the draft put back
|
|
211
|
+
if it may not be: every way of changing it (typing, a paste, history, undo, a completion) goes through
|
|
212
|
+
here, so none needs its own guard. Deleting is always allowed, which is how a draft left over from the
|
|
213
|
+
session that was left can be cleared.
|
|
214
|
+
"""
|
|
215
|
+
if not self._locked:
|
|
216
|
+
return super().handle(event, host)
|
|
217
|
+
if (
|
|
218
|
+
isinstance(event, KeyEvent)
|
|
219
|
+
and event.key == "enter"
|
|
220
|
+
and not event.alt
|
|
221
|
+
and not self.text.strip()
|
|
222
|
+
and host.focused_widget() is self
|
|
223
|
+
):
|
|
224
|
+
if self.on_locked_enter is not None:
|
|
225
|
+
self.on_locked_enter()
|
|
226
|
+
return True
|
|
227
|
+
before = (self.text, self.position)
|
|
228
|
+
handled = super().handle(event, host)
|
|
229
|
+
if self.text != before[0] and not self._allowed_when_locked(before[0], self.text):
|
|
230
|
+
self.text, self.position = before
|
|
231
|
+
return handled
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _allowed_when_locked(before: str, after: str) -> bool:
|
|
235
|
+
"""Return whether a locked draft may change from ``before`` to ``after``: to a command, to nothing, or shorter."""
|
|
236
|
+
return after == "" or after.lstrip().startswith("/") or len(after) < len(before)
|
|
237
|
+
|
|
238
|
+
def attach_image(self, data: bytes, media_type: str) -> str:
|
|
239
|
+
"""Stand one clipboard image in for itself and return its chip label; a locked composer takes none."""
|
|
240
|
+
if self._locked:
|
|
241
|
+
return ""
|
|
242
|
+
return self._attach_image(data, media_type)
|
|
243
|
+
|
|
244
|
+
def paste(self, text: str) -> None:
|
|
245
|
+
"""Insert a paste: an image as a picture chip, a large block as a text chip.
|
|
246
|
+
|
|
247
|
+
A terminal cannot paste image bytes, but some paste an image as base64
|
|
248
|
+
text; that is read back into a picture so one paste key produces the
|
|
249
|
+
same ``[image #N]`` chip whether the bytes came from the desktop
|
|
250
|
+
clipboard or from the terminal.
|
|
251
|
+
"""
|
|
252
|
+
if not text:
|
|
253
|
+
return
|
|
254
|
+
image = image_from_paste(text)
|
|
255
|
+
if image is not None:
|
|
256
|
+
self.attach_image(*image)
|
|
257
|
+
return
|
|
258
|
+
self._insert(self._remember_paste(text) if self.too_large(text) else text)
|
|
259
|
+
|
|
260
|
+
def too_large(self, text: str) -> bool:
|
|
261
|
+
"""Return whether a text is too big to show in the draft as it is.
|
|
262
|
+
|
|
263
|
+
Either dimension is enough: a long line pushes the draft sideways and a
|
|
264
|
+
tall one scrolls it away, and both hide what the reader was writing.
|
|
265
|
+
"""
|
|
266
|
+
return len(text) > self.PASTE_CHIP_CHARS or text.count("\n") + 1 > self.max_height
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def value(self) -> str:
|
|
270
|
+
"""Return the draft as it should be submitted, with pasted text put back."""
|
|
271
|
+
value = ""
|
|
272
|
+
cursor = 0
|
|
273
|
+
for start, end, label in self._chip_ranges():
|
|
274
|
+
value += self.text[cursor:start]
|
|
275
|
+
cursor = end
|
|
276
|
+
value += self.pastes.get(label, label)
|
|
277
|
+
return value + self.text[cursor:]
|
|
278
|
+
|
|
279
|
+
def parts(self) -> tuple[PromptPart, ...]:
|
|
280
|
+
"""Return the draft as the ordered parts of one user turn.
|
|
281
|
+
|
|
282
|
+
Every chip is put back as what it stands for: a pasted-text chip becomes
|
|
283
|
+
its text, an image chip becomes the bytes it holds. Text and images
|
|
284
|
+
therefore reach the model in the order they were written — a picture
|
|
285
|
+
sits where its chip sits, not after the whole prompt — and the label
|
|
286
|
+
stays in front of it so the two line up. A chip that was deleted, or
|
|
287
|
+
edited into something else, is simply not part of the turn.
|
|
288
|
+
"""
|
|
289
|
+
parts: list[PromptPart] = []
|
|
290
|
+
run = ""
|
|
291
|
+
cursor = 0
|
|
292
|
+
for start, end, label in self._chip_ranges():
|
|
293
|
+
run += self.text[cursor:start]
|
|
294
|
+
cursor = end
|
|
295
|
+
payload = self.pastes.get(label)
|
|
296
|
+
if payload is not None:
|
|
297
|
+
run += payload
|
|
298
|
+
continue
|
|
299
|
+
image = self.images.get(label)
|
|
300
|
+
if image is None:
|
|
301
|
+
continue
|
|
302
|
+
run += label
|
|
303
|
+
if run:
|
|
304
|
+
parts.append(run)
|
|
305
|
+
parts.append(image)
|
|
306
|
+
run = ""
|
|
307
|
+
run += self.text[cursor:]
|
|
308
|
+
if run:
|
|
309
|
+
parts.append(run)
|
|
310
|
+
return _split_mentions(parts)
|
|
311
|
+
|
|
312
|
+
def restore(self, parts: Sequence[PromptPart]) -> None:
|
|
313
|
+
"""Replace the draft with a message's parts: text as it was written, pictures as chips again.
|
|
314
|
+
|
|
315
|
+
The inverse of :meth:`parts`, for taking a message back out of a queue to edit it, or to
|
|
316
|
+
hand it back after it was never sent. A picture's chip label is the last thing in the text
|
|
317
|
+
before it, so it is taken off and the picture is given the next label of this draft —
|
|
318
|
+
which is what keeps two messages that were both written as ``[image #1]`` from colliding
|
|
319
|
+
when they are put back into one.
|
|
320
|
+
|
|
321
|
+
Text with no picture in it goes through :meth:`set_text`, so a large block is a chip again.
|
|
322
|
+
A draft with pictures is not chipped: the pictures are labels *inside* the text, and a
|
|
323
|
+
chip over them would hide the labels the pictures are known by.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
parts: The ordered parts of one or more messages, as :meth:`parts` returned them.
|
|
327
|
+
"""
|
|
328
|
+
text = ""
|
|
329
|
+
images: dict[str, tuple[bytes, str]] = {}
|
|
330
|
+
for part in parts:
|
|
331
|
+
if isinstance(part, (str, MentionPart)):
|
|
332
|
+
text += plain_text_part(part)
|
|
333
|
+
continue
|
|
334
|
+
text = IMAGE_LABEL_END.sub("", text)
|
|
335
|
+
label = f"[image #{len(images) + 1}]"
|
|
336
|
+
images[label] = part
|
|
337
|
+
text += label
|
|
338
|
+
if not images:
|
|
339
|
+
self.set_text(text)
|
|
340
|
+
return
|
|
341
|
+
self.pastes.clear()
|
|
342
|
+
self.images.clear()
|
|
343
|
+
TextArea.show(self, text) # the draft as it is: no chip over the labels the pictures are known by
|
|
344
|
+
self.images.update(images)
|
|
345
|
+
self._leave_history()
|
|
346
|
+
self._changed()
|
|
347
|
+
|
|
348
|
+
def _attach_image(self, data: bytes, media_type: str) -> str:
|
|
349
|
+
"""Stand one clipboard image in for itself and return its chip label.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
data: Encoded image bytes, exactly as the clipboard held them.
|
|
353
|
+
media_type: MIME type of ``data``.
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
The label inserted into the draft, such as ``[image #1]``.
|
|
357
|
+
"""
|
|
358
|
+
label = f"[image #{len(self.images) + 1}]"
|
|
359
|
+
self.images[label] = (data, media_type)
|
|
360
|
+
self._insert(label)
|
|
361
|
+
return label
|
|
362
|
+
|
|
363
|
+
def spans_for(self, line: str, start: int, body: Style) -> tuple[Span, ...]:
|
|
364
|
+
"""Paint the chips, ``@`` resources, and slash commands on a row.
|
|
365
|
+
|
|
366
|
+
The tokens are found in the draft, so a chip that was edited away simply
|
|
367
|
+
stops being painted, and a mention or command is styled wherever it sits.
|
|
368
|
+
"""
|
|
369
|
+
chip = Style(foreground=self.theme.warning, background=body.background, bold=True)
|
|
370
|
+
styles = {
|
|
371
|
+
"mention": Style(foreground=self.theme.accent_bright, background=body.background),
|
|
372
|
+
"command": Style(foreground=self.theme.accent, background=body.background),
|
|
373
|
+
}
|
|
374
|
+
ranges = [(begin, end, chip) for begin, end, _ in self._chip_ranges()]
|
|
375
|
+
ranges += [(begin, end, styles[kind]) for begin, end, kind in self._token_hits()]
|
|
376
|
+
return self._row_spans(line, start, body, self._first_wins(ranges))
|
|
377
|
+
|
|
378
|
+
def _token_hits(self) -> tuple[tuple[int, int, str], ...]:
|
|
379
|
+
"""Return the ``@`` references and slash commands in the draft that are really there.
|
|
380
|
+
|
|
381
|
+
A reference is painted, and taken whole by Backspace, only when it names a resource that
|
|
382
|
+
exists; a command only when it names one the shell has *and* it opens the draft, which is
|
|
383
|
+
the one place a command is recognised at all. Everything else is ordinary text.
|
|
384
|
+
|
|
385
|
+
Each row of the draft asks, and each ``@`` is a lookup on disk, so the answer is kept for
|
|
386
|
+
as long as the draft is the same text.
|
|
387
|
+
"""
|
|
388
|
+
if self._hits is not None and self._hits[0] == self.text:
|
|
389
|
+
return self._hits[1]
|
|
390
|
+
hits: list[tuple[int, int, str]] = []
|
|
391
|
+
for match in MENTION.finditer(self.text):
|
|
392
|
+
if self.is_mention(match.group(0)):
|
|
393
|
+
hits.append((match.start(), match.end(), "mention"))
|
|
394
|
+
opening = COMMAND.match(self.text.lstrip())
|
|
395
|
+
if opening is not None:
|
|
396
|
+
begin = len(self.text) - len(self.text.lstrip()) + opening.start()
|
|
397
|
+
if self.is_command(opening.group(0)):
|
|
398
|
+
hits.append((begin, begin + len(opening.group(0)), "command"))
|
|
399
|
+
found = tuple(hits)
|
|
400
|
+
self._hits = (self.text, found)
|
|
401
|
+
return found
|
|
402
|
+
|
|
403
|
+
@staticmethod
|
|
404
|
+
def _first_wins(ranges: Sequence[tuple[int, int, Style]]) -> list[tuple[int, int, Style]]:
|
|
405
|
+
"""Drop overlaid ranges, keeping the earliest and widest of each start."""
|
|
406
|
+
ordered = sorted(ranges, key=lambda item: (item[0], -(item[1] - item[0])))
|
|
407
|
+
kept: list[tuple[int, int, Style]] = []
|
|
408
|
+
cursor = 0
|
|
409
|
+
for item in ordered:
|
|
410
|
+
if item[0] < cursor:
|
|
411
|
+
continue
|
|
412
|
+
kept.append(item)
|
|
413
|
+
cursor = item[1]
|
|
414
|
+
return kept
|
|
415
|
+
|
|
416
|
+
def backspace(self) -> None:
|
|
417
|
+
"""Remove a whole chip, ``@`` reference, or command when the cursor follows one.
|
|
418
|
+
|
|
419
|
+
These stand for something larger than their characters — a chip for a
|
|
420
|
+
paste or a picture, a token for a resource or a command — so one press
|
|
421
|
+
takes the whole thing instead of peeling it apart.
|
|
422
|
+
"""
|
|
423
|
+
chip = self._chip_before(self.position)
|
|
424
|
+
if chip is None:
|
|
425
|
+
token = self._token_before()
|
|
426
|
+
if token is None:
|
|
427
|
+
super().backspace()
|
|
428
|
+
return
|
|
429
|
+
chip = token
|
|
430
|
+
self._delete(self.position - len(chip), self.position)
|
|
431
|
+
|
|
432
|
+
def _token_before(self) -> str | None:
|
|
433
|
+
"""Return the ``@`` reference or slash command ending at the cursor, if it is a real one.
|
|
434
|
+
|
|
435
|
+
The same test the painting uses, so what Backspace takes whole is what was painted as a
|
|
436
|
+
unit: a word that was not highlighted is peeled one character at a time like any other.
|
|
437
|
+
"""
|
|
438
|
+
for begin, end, _ in self._token_hits():
|
|
439
|
+
if end == self.position:
|
|
440
|
+
return self.text[begin:end]
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
def show(self, value: str) -> None:
|
|
444
|
+
"""Replace the draft, chipping a value too large to show plainly."""
|
|
445
|
+
self.pastes.clear()
|
|
446
|
+
self.images.clear()
|
|
447
|
+
if self.too_large(value):
|
|
448
|
+
value = self._remember_paste(value)
|
|
449
|
+
super().show(value)
|
|
450
|
+
|
|
451
|
+
def clear(self) -> None:
|
|
452
|
+
"""Drop the draft, its pasted text, and its attachments."""
|
|
453
|
+
self.pastes.clear()
|
|
454
|
+
self.images.clear()
|
|
455
|
+
super().clear()
|
|
456
|
+
|
|
457
|
+
def _remember_paste(self, text: str) -> str:
|
|
458
|
+
"""Store one pasted payload and return the unique chip that stands for it."""
|
|
459
|
+
label = f"[pasted text {len(text)} chars]"
|
|
460
|
+
index = 2
|
|
461
|
+
while label in self.pastes:
|
|
462
|
+
label = f"[pasted text {len(text)} chars #{index}]"
|
|
463
|
+
index += 1
|
|
464
|
+
self.pastes[label] = text
|
|
465
|
+
return label
|
|
466
|
+
|
|
467
|
+
def _labels(self) -> tuple[str, ...]:
|
|
468
|
+
"""Return every chip label, the longest first so overlaps resolve sanely."""
|
|
469
|
+
return tuple(sorted({*self.pastes, *self.images}, key=len, reverse=True))
|
|
470
|
+
|
|
471
|
+
def _chip_before(self, position: int) -> str | None:
|
|
472
|
+
"""Return the chip label ending exactly at ``position``, if one does."""
|
|
473
|
+
return next((label for label in self._labels() if self.text[:position].endswith(label)), None)
|
|
474
|
+
|
|
475
|
+
def _paste_ranges(self) -> list[tuple[int, int]]:
|
|
476
|
+
"""Return the draft ranges the chips occupy, in order."""
|
|
477
|
+
return [(start, end) for start, end, _ in self._chip_ranges()]
|
|
478
|
+
|
|
479
|
+
def _chip_ranges(self) -> list[tuple[int, int, str]]:
|
|
480
|
+
"""Return every chip the draft still holds as ``(start, end, label)``.
|
|
481
|
+
|
|
482
|
+
Chips are found in the draft rather than remembered, so one that was
|
|
483
|
+
deleted — or edited into something else — just never matches, and undo
|
|
484
|
+
brings it back by bringing its label back. Labels are scanned longest
|
|
485
|
+
first and overlaps are dropped, so a short label that sits inside a
|
|
486
|
+
longer one cannot cut a chip in half.
|
|
487
|
+
"""
|
|
488
|
+
found: list[tuple[int, int, str]] = []
|
|
489
|
+
for label in self._labels():
|
|
490
|
+
start = self.text.find(label)
|
|
491
|
+
while start >= 0:
|
|
492
|
+
found.append((start, start + len(label), label))
|
|
493
|
+
start = self.text.find(label, start + len(label))
|
|
494
|
+
found.sort(key=lambda item: (item[0], item[0] - item[1]))
|
|
495
|
+
chips: list[tuple[int, int, str]] = []
|
|
496
|
+
cursor = 0
|
|
497
|
+
for start, end, label in found:
|
|
498
|
+
if start < cursor:
|
|
499
|
+
continue
|
|
500
|
+
chips.append((start, end, label))
|
|
501
|
+
cursor = end
|
|
502
|
+
return chips
|
|
503
|
+
|
|
504
|
+
def _row_spans(
|
|
505
|
+
self,
|
|
506
|
+
line: str,
|
|
507
|
+
start: int,
|
|
508
|
+
body: Style,
|
|
509
|
+
ranges: Sequence[tuple[int, int, Style]],
|
|
510
|
+
) -> tuple[Span, ...]:
|
|
511
|
+
"""Split one wrapped row into body text and the styled ranges it covers.
|
|
512
|
+
|
|
513
|
+
A range wider than the row is painted on each row it reaches, so a
|
|
514
|
+
wrapped chip still reads as a single aside.
|
|
515
|
+
"""
|
|
516
|
+
end = start + len(line)
|
|
517
|
+
spans: list[Span] = []
|
|
518
|
+
cursor = start
|
|
519
|
+
for range_start, range_end, style in ranges:
|
|
520
|
+
if range_end <= start or range_start >= end:
|
|
521
|
+
continue
|
|
522
|
+
if range_start > cursor:
|
|
523
|
+
spans.append(Span(self.text[cursor:range_start], body))
|
|
524
|
+
overlap = (max(range_start, start), min(range_end, end))
|
|
525
|
+
spans.append(Span(self.text[overlap[0] : overlap[1]], style))
|
|
526
|
+
cursor = overlap[1]
|
|
527
|
+
if cursor < end:
|
|
528
|
+
spans.append(Span(self.text[cursor:end], body))
|
|
529
|
+
return tuple(spans)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""The page a plugin's Markdown document opens: rendered as Markdown, scrolled, closed with Escape.
|
|
2
|
+
|
|
3
|
+
A command that answers with a document rather than a line puts it here instead of into the conversation: it is a
|
|
4
|
+
thing to read on its own, not a message between two turns. Nothing of it joins the session or reaches the model.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
from zettcode.tui import SEPARATOR, KeyEvent, Markdown, MouseAction, MouseEvent, Style, Widget
|
|
12
|
+
|
|
13
|
+
#: Rows a wheel notch moves the document.
|
|
14
|
+
WHEEL_ROWS = 3
|
|
15
|
+
|
|
16
|
+
#: What the footer says when the document is longer than the page.
|
|
17
|
+
SCROLL_HINT = f"\u2191 \u2193 scroll {SEPARATOR} esc back"
|
|
18
|
+
|
|
19
|
+
#: What it says when the whole document is already on screen: there is nothing to scroll.
|
|
20
|
+
SHORT_HINT = "esc back"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DocumentPage(Widget):
|
|
24
|
+
"""A Markdown document from a command, drawn a screen at a time.
|
|
25
|
+
|
|
26
|
+
Shape::
|
|
27
|
+
|
|
28
|
+
Release notes <- the title, when the command gave one
|
|
29
|
+
<the document, rendered as Markdown> <- scrolls
|
|
30
|
+
↑ ↓ scroll · esc back <- footer
|
|
31
|
+
|
|
32
|
+
The text is parsed once per width and theme by :class:`zettcode.tui.Markdown`, so a long document is not
|
|
33
|
+
re-rendered on every keystroke, and it is the same renderer the conversation uses, so a plugin's document looks
|
|
34
|
+
like an answer rather than like a second kind of text.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
markdown: The document, as Markdown.
|
|
38
|
+
title: What the header says; the document is drawn under it.
|
|
39
|
+
on_cancel: Called on Escape and ``q`` instead of the page popping itself, for an owner that keeps its own
|
|
40
|
+
screen stack; most callers leave it out.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
markdown: str,
|
|
46
|
+
*,
|
|
47
|
+
title: str = "",
|
|
48
|
+
on_cancel: Callable[[], None] | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Keep the document and start at its first line."""
|
|
51
|
+
super().__init__()
|
|
52
|
+
self.title = title.strip()
|
|
53
|
+
self.on_cancel = on_cancel
|
|
54
|
+
self.document = Markdown(markdown)
|
|
55
|
+
self.offset = 0
|
|
56
|
+
self._body_rows = 0
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def focusable(self) -> bool:
|
|
60
|
+
"""Take the keyboard when the page opens, so its keys work without a click."""
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
"""Leave the page: through the owner's callback when it gave one, else by popping itself."""
|
|
65
|
+
if self.on_cancel is not None:
|
|
66
|
+
self.on_cancel()
|
|
67
|
+
elif self.app is not None and self.app.screens.top.widget is self:
|
|
68
|
+
self.app.pop_screen()
|
|
69
|
+
self.app.request_layout()
|
|
70
|
+
|
|
71
|
+
def capture_event(self, event, host) -> bool:
|
|
72
|
+
"""Close on Escape before anything else can take the key."""
|
|
73
|
+
if isinstance(event, KeyEvent) and event.key == "escape":
|
|
74
|
+
self.close()
|
|
75
|
+
return True
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
def handle(self, event, host) -> bool:
|
|
79
|
+
"""Scroll the document, or close it."""
|
|
80
|
+
if isinstance(event, MouseEvent):
|
|
81
|
+
if event.action is MouseAction.SCROLL_UP:
|
|
82
|
+
self._scroll(-WHEEL_ROWS)
|
|
83
|
+
elif event.action is MouseAction.SCROLL_DOWN:
|
|
84
|
+
self._scroll(WHEEL_ROWS)
|
|
85
|
+
else:
|
|
86
|
+
return False
|
|
87
|
+
host.invalidate()
|
|
88
|
+
return True
|
|
89
|
+
key = self._key_of(event)
|
|
90
|
+
if key is None:
|
|
91
|
+
return False
|
|
92
|
+
if key in ("up", "k"):
|
|
93
|
+
self._scroll(-1)
|
|
94
|
+
elif key in ("down", "j"):
|
|
95
|
+
self._scroll(1)
|
|
96
|
+
elif key == "page_up":
|
|
97
|
+
self._scroll(-max(1, self._body_rows - 1))
|
|
98
|
+
elif key == "page_down":
|
|
99
|
+
self._scroll(max(1, self._body_rows - 1))
|
|
100
|
+
elif key == "home":
|
|
101
|
+
self.offset = 0
|
|
102
|
+
elif key == "end":
|
|
103
|
+
self._scroll(10**9)
|
|
104
|
+
elif key in ("q", "escape"):
|
|
105
|
+
self.close()
|
|
106
|
+
else:
|
|
107
|
+
return False
|
|
108
|
+
host.invalidate()
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _key_of(event: object) -> str | None:
|
|
113
|
+
"""Return the key an event names, whether it came as a key or as typed text."""
|
|
114
|
+
if isinstance(event, KeyEvent):
|
|
115
|
+
return None if event.alt or event.control else event.key
|
|
116
|
+
text = getattr(event, "text", None)
|
|
117
|
+
return text if isinstance(text, str) and len(text) == 1 else None
|
|
118
|
+
|
|
119
|
+
def _scroll(self, rows: int) -> None:
|
|
120
|
+
"""Move the document, never past its first line or its last."""
|
|
121
|
+
total = self.document.line_count(self._width())
|
|
122
|
+
furthest = max(0, total - self._body_rows)
|
|
123
|
+
self.offset = min(max(0, self.offset + rows), furthest)
|
|
124
|
+
|
|
125
|
+
def _width(self) -> int:
|
|
126
|
+
"""Return the columns the document is rendered in."""
|
|
127
|
+
return max(1, self.rect.width - 4)
|
|
128
|
+
|
|
129
|
+
def render(self, canvas) -> None:
|
|
130
|
+
"""Fill the page, then draw the title, the document, and the footer."""
|
|
131
|
+
if self.rect.empty:
|
|
132
|
+
return
|
|
133
|
+
theme = self.theme
|
|
134
|
+
rect = self.rect
|
|
135
|
+
canvas.fill(rect.x, rect.y, rect.width, rect.height, Style(background=theme.surface_alt))
|
|
136
|
+
room = self._width()
|
|
137
|
+
self.document.theme = theme
|
|
138
|
+
top = rect.y + 1
|
|
139
|
+
if self.title:
|
|
140
|
+
canvas.draw_text(
|
|
141
|
+
rect.x + 2, rect.y + 1, self.title, Style(foreground=theme.text, bold=True), max_width=room
|
|
142
|
+
)
|
|
143
|
+
top = rect.y + 3
|
|
144
|
+
self._body_rows = max(0, rect.bottom - 2 - top)
|
|
145
|
+
lines = self.document.lines(room)
|
|
146
|
+
self.offset = min(self.offset, max(0, len(lines) - self._body_rows))
|
|
147
|
+
for row, line in enumerate(lines[self.offset : self.offset + self._body_rows]):
|
|
148
|
+
canvas.draw_spans(rect.x + 2, top + row, line.spans, max_width=room)
|
|
149
|
+
hint = SCROLL_HINT if len(lines) > self._body_rows else SHORT_HINT
|
|
150
|
+
canvas.draw_text(
|
|
151
|
+
rect.x + 2,
|
|
152
|
+
rect.bottom - 1,
|
|
153
|
+
hint,
|
|
154
|
+
Style(foreground=theme.muted, background=theme.surface_alt),
|
|
155
|
+
max_width=room,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
__all__ = ["DocumentPage"]
|