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,340 @@
|
|
|
1
|
+
"""Read an image off the system clipboard, or out of a paste.
|
|
2
|
+
|
|
3
|
+
A terminal cannot hand an application the bytes of a pasted image, so the shell
|
|
4
|
+
asks the desktop instead. macOS hands the pasteboard over in-process through the
|
|
5
|
+
Objective-C runtime, with ``osascript`` kept as the fallback that can coerce a
|
|
6
|
+
format the pasteboard does not store directly; Linux asks ``wl-paste`` or
|
|
7
|
+
``xclip`` for whichever image type the clipboard advertises. A machine with
|
|
8
|
+
neither tool — or a clipboard holding text — simply has no image to attach, and
|
|
9
|
+
a terminal that pastes the picture *as text* instead (VS Code sends base64) is
|
|
10
|
+
covered by :func:`image_from_paste` further down.
|
|
11
|
+
|
|
12
|
+
The bytes are read back into memory, because that is what the runtime's
|
|
13
|
+
multimodal message wants: nothing is left behind in the workspace.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import binascii
|
|
20
|
+
import ctypes
|
|
21
|
+
import ctypes.util
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
from collections.abc import Callable, Sequence
|
|
27
|
+
from ctypes import c_char_p, c_ulong, c_void_p
|
|
28
|
+
from functools import lru_cache
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from subprocess import CompletedProcess
|
|
31
|
+
from typing import Any, TypeAlias
|
|
32
|
+
|
|
33
|
+
#: What one clipboard command looks like: argv in, its result out.
|
|
34
|
+
Runner: TypeAlias = Callable[[Sequence[str]], CompletedProcess[bytes]]
|
|
35
|
+
|
|
36
|
+
#: Media type of an image the desktop path had to normalise: PNG is what both
|
|
37
|
+
#: platforms write, and the conversion on macOS exists to keep that true.
|
|
38
|
+
PNG = "image/png"
|
|
39
|
+
|
|
40
|
+
#: The image types a multimodal message accepts, and the bytes each one starts
|
|
41
|
+
#: with. A clipboard can offer more formats than this; the rest are ignored
|
|
42
|
+
#: rather than sent to a model that would reject them.
|
|
43
|
+
SIGNATURES: tuple[tuple[bytes, str], ...] = (
|
|
44
|
+
(b"\x89PNG\r\n\x1a\n", "image/png"),
|
|
45
|
+
(b"\xff\xd8\xff", "image/jpeg"),
|
|
46
|
+
(b"GIF87a", "image/gif"),
|
|
47
|
+
(b"GIF89a", "image/gif"),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
#: macOS pasteboard flavours, preferred first. ``PNGf`` is what a screenshot is;
|
|
51
|
+
#: a browser often offers only TIFF. Asking the system for a flavour makes it
|
|
52
|
+
#: coerce whatever it holds, so a HEIC or BMP screen capture still arrives as a
|
|
53
|
+
#: PNG.
|
|
54
|
+
_MACOS_FLAVOURS = ("PNGf", "TIFF")
|
|
55
|
+
|
|
56
|
+
#: Pasteboard types read straight out of the running process, most widely
|
|
57
|
+
#: accepted first. ``dataForType:`` returns a type only when it is really there,
|
|
58
|
+
#: which is what makes this path fast but incomplete: anything else falls back
|
|
59
|
+
#: to AppleScript, which coerces.
|
|
60
|
+
_PASTEBOARD_UTIS = ("public.png", "public.jpeg", "public.webp", "com.compuserve.gif", "public.tiff")
|
|
61
|
+
|
|
62
|
+
#: The magic bytes a TIFF starts with, either endianness.
|
|
63
|
+
_TIFF_SIGNATURES = (b"II*\x00", b"MM\x00*")
|
|
64
|
+
|
|
65
|
+
#: ``osascript`` prints AppleScript data as ``«data <four-char code><hex>»``.
|
|
66
|
+
_APPLE_DATA = re.compile(r"^«data [0-9A-Za-z]{4}([0-9A-Fa-f]+)»$")
|
|
67
|
+
|
|
68
|
+
#: Image types tried on Linux, most widely accepted first.
|
|
69
|
+
_LINUX_TYPES = ("image/png", "image/jpeg", "image/webp", "image/gif")
|
|
70
|
+
|
|
71
|
+
#: A ``data:`` URI that carries an image as base64, the way a page or an editor
|
|
72
|
+
#: copies one into a text clipboard.
|
|
73
|
+
_DATA_URI = re.compile(r"^data:image/[\w.+-]+;base64,", re.IGNORECASE)
|
|
74
|
+
|
|
75
|
+
#: Shorter base64 cannot hold a real image, so it is left as text even when it
|
|
76
|
+
#: happens to decode.
|
|
77
|
+
_MIN_BASE64 = 64
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def read_image(*, runner: Runner | None = None, system: str | None = None) -> tuple[bytes, str] | None:
|
|
81
|
+
"""Return the clipboard's image as ``(bytes, media type)``, or None.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
runner: Runs one clipboard command; injectable so tests can describe a
|
|
85
|
+
desktop instead of owning one. Injecting one also keeps the
|
|
86
|
+
in-process macOS pasteboard reader out of the way, so a test never
|
|
87
|
+
depends on the machine's real clipboard.
|
|
88
|
+
system: Platform to read from; ``sys.platform`` by default.
|
|
89
|
+
"""
|
|
90
|
+
run = _run if runner is None else runner
|
|
91
|
+
platform = sys.platform if system is None else system
|
|
92
|
+
if platform == "darwin":
|
|
93
|
+
return _macos_image(run, native=runner is None)
|
|
94
|
+
if platform.startswith("linux"):
|
|
95
|
+
return _x11_image(run)
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
#: Seconds a clipboard tool may take. The read happens on the event loop, in a key handler, so a
|
|
100
|
+
#: tool that never answers — ``xclip`` against a forwarded X display whose connection went away
|
|
101
|
+
#: is the usual one — would freeze the whole interface; a few seconds is far longer than a
|
|
102
|
+
#: working one needs.
|
|
103
|
+
TOOL_TIMEOUT = 3.0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _run(argv: Sequence[str]) -> CompletedProcess[bytes]:
|
|
107
|
+
"""Run one clipboard command, capturing its output; any failure is a nonzero result.
|
|
108
|
+
|
|
109
|
+
The callers read ``returncode`` and nothing else, and they cannot tell a tool that ran and
|
|
110
|
+
found nothing from a tool that could not run, so neither is an exception. That is not a
|
|
111
|
+
detail: ``subprocess.run`` reports a missing executable by *raising*, not by returning
|
|
112
|
+
nonzero, so a server with neither ``wl-paste`` nor ``xclip`` — most of them — used to take
|
|
113
|
+
the whole session down on the first Ctrl-V.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The finished process; ``127`` when the program is not there or cannot be run, ``124``
|
|
117
|
+
when it did not answer in time (the codes a shell uses for the same two things).
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
return subprocess.run(list(argv), capture_output=True, check=False, timeout=TOOL_TIMEOUT)
|
|
121
|
+
except subprocess.TimeoutExpired:
|
|
122
|
+
return CompletedProcess(list(argv), 124, b"", b"timed out")
|
|
123
|
+
except OSError as error:
|
|
124
|
+
# The tool is named here rather than left to the error's own text: POSIX puts the file name
|
|
125
|
+
# in a FileNotFoundError and Windows ("[WinError 2] The system cannot find the file
|
|
126
|
+
# specified") does not, and a reason that does not say which tool is not much of one.
|
|
127
|
+
return CompletedProcess(list(argv), 127, b"", f"{argv[0]}: {error}".encode())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _macos_image(runner: Runner, *, native: bool) -> tuple[bytes, str] | None:
|
|
131
|
+
"""Return whatever picture the pasteboard holds, fastest way first.
|
|
132
|
+
|
|
133
|
+
The in-process reader hands back a flavour exactly as it is stored, which
|
|
134
|
+
takes about a millisecond. AppleScript is asked only when that finds
|
|
135
|
+
nothing, because its clipboard coercion also covers formats the pasteboard
|
|
136
|
+
does not expose as a raw type — a HEIC or BMP capture — at the price of
|
|
137
|
+
starting a helper program.
|
|
138
|
+
"""
|
|
139
|
+
if native:
|
|
140
|
+
reader = _pasteboard_reader()
|
|
141
|
+
if reader is not None:
|
|
142
|
+
data = reader()
|
|
143
|
+
if data is not None:
|
|
144
|
+
image = _image_from_bytes(data, runner)
|
|
145
|
+
if image is not None:
|
|
146
|
+
return image
|
|
147
|
+
# Data of a flavour this build cannot read: let AppleScript
|
|
148
|
+
# coerce it rather than reporting no picture at all.
|
|
149
|
+
return _script_image(runner)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _script_image(runner: Runner) -> tuple[bytes, str] | None:
|
|
153
|
+
"""Ask AppleScript for the clipboard's picture and sniff what comes back.
|
|
154
|
+
|
|
155
|
+
AppleScript is asked for the *data*, not told to write a file: ``write``
|
|
156
|
+
copies a multi-megabyte image through AppleScript's own file API and takes
|
|
157
|
+
seconds, while ``get the clipboard as`` returns it in one step.
|
|
158
|
+
"""
|
|
159
|
+
for flavour in _MACOS_FLAVOURS:
|
|
160
|
+
data = _apple_data(runner, flavour)
|
|
161
|
+
if data is None:
|
|
162
|
+
continue
|
|
163
|
+
image = _image_from_bytes(data, runner)
|
|
164
|
+
if image is not None:
|
|
165
|
+
return image
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _image_from_bytes(data: bytes, runner: Runner) -> tuple[bytes, str] | None:
|
|
170
|
+
"""Return the image these bytes are, converting a TIFF the model will not take."""
|
|
171
|
+
sniffed = _sniff(data)
|
|
172
|
+
if sniffed is not None:
|
|
173
|
+
return sniffed
|
|
174
|
+
if data.startswith(_TIFF_SIGNATURES):
|
|
175
|
+
return _convert_tiff(runner, data)
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _apple_data(runner: Runner, flavour: str) -> bytes | None:
|
|
180
|
+
"""Return one clipboard flavour as raw bytes, or None when it is absent."""
|
|
181
|
+
result = runner(["osascript", "-e", f"get the clipboard as \u00abclass {flavour}\u00bb"])
|
|
182
|
+
if result.returncode != 0:
|
|
183
|
+
return None
|
|
184
|
+
# The guillemets around the data are non-ASCII, so this is UTF-8, not ASCII.
|
|
185
|
+
match = _APPLE_DATA.match(result.stdout.decode("utf-8", "replace").strip())
|
|
186
|
+
if match is None:
|
|
187
|
+
return None
|
|
188
|
+
try:
|
|
189
|
+
return bytes.fromhex(match.group(1))
|
|
190
|
+
except ValueError:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _convert_tiff(runner: Runner, data: bytes) -> tuple[bytes, str] | None:
|
|
195
|
+
"""Turn a TIFF the clipboard offered into a PNG the model will take."""
|
|
196
|
+
with tempfile.TemporaryDirectory(prefix="zettcode-clipboard-") as directory:
|
|
197
|
+
source = Path(directory) / "clipboard.tiff"
|
|
198
|
+
source.write_bytes(data)
|
|
199
|
+
converted = Path(directory) / "converted.png"
|
|
200
|
+
if runner(["sips", "-s", "format", "png", str(source), "--out", str(converted)]).returncode != 0:
|
|
201
|
+
return None
|
|
202
|
+
if not converted.is_file():
|
|
203
|
+
return None
|
|
204
|
+
return converted.read_bytes(), PNG
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@lru_cache(maxsize=1)
|
|
208
|
+
def _pasteboard_reader() -> Callable[[], bytes | None] | None:
|
|
209
|
+
"""Return the in-process macOS pasteboard reader, binding it on first use.
|
|
210
|
+
|
|
211
|
+
``osascript`` costs a third of a second even for a tiny picture — the
|
|
212
|
+
helper program and AppleScript's clipboard coercion are the price, not the
|
|
213
|
+
bytes — while the Objective-C runtime is already in the process. Nothing is
|
|
214
|
+
guaranteed to be there, so a reader that cannot be bound is ``None`` and
|
|
215
|
+
the caller falls back to the script.
|
|
216
|
+
"""
|
|
217
|
+
if sys.platform != "darwin":
|
|
218
|
+
return None
|
|
219
|
+
library = ctypes.util.find_library("objc")
|
|
220
|
+
if library is None:
|
|
221
|
+
return None
|
|
222
|
+
try:
|
|
223
|
+
runtime = ctypes.CDLL(library)
|
|
224
|
+
# AppKit has to be loaded before its classes can be looked up.
|
|
225
|
+
ctypes.CDLL("/System/Library/Frameworks/AppKit.framework/AppKit")
|
|
226
|
+
runtime.objc_getClass.restype = c_void_p
|
|
227
|
+
runtime.objc_getClass.argtypes = [c_char_p]
|
|
228
|
+
runtime.sel_registerName.restype = c_void_p
|
|
229
|
+
runtime.sel_registerName.argtypes = [c_char_p]
|
|
230
|
+
runtime.objc_autoreleasePoolPush.restype = c_void_p
|
|
231
|
+
runtime.objc_autoreleasePoolPush.argtypes = []
|
|
232
|
+
runtime.objc_autoreleasePoolPop.restype = None
|
|
233
|
+
runtime.objc_autoreleasePoolPop.argtypes = [c_void_p]
|
|
234
|
+
except (AttributeError, OSError):
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
def send(
|
|
238
|
+
receiver: Any, selector: bytes, *args: Any, restype: Any = c_void_p, argtypes: tuple[Any, ...] = ()
|
|
239
|
+
) -> Any:
|
|
240
|
+
"""Send one Objective-C message with the signature that call needs."""
|
|
241
|
+
runtime.objc_msgSend.restype = restype
|
|
242
|
+
runtime.objc_msgSend.argtypes = (c_void_p, c_void_p, *argtypes)
|
|
243
|
+
return runtime.objc_msgSend(receiver, runtime.sel_registerName(selector), *args)
|
|
244
|
+
|
|
245
|
+
def read() -> bytes | None:
|
|
246
|
+
"""Return the first image flavour the pasteboard holds, or None."""
|
|
247
|
+
# Autoreleased objects — the NSData that is handed back, the strings
|
|
248
|
+
# asked for — are freed here instead of piling up in a long-running
|
|
249
|
+
# process with no pool of its own.
|
|
250
|
+
pool = runtime.objc_autoreleasePoolPush()
|
|
251
|
+
try:
|
|
252
|
+
board = send(runtime.objc_getClass(b"NSPasteboard"), b"generalPasteboard")
|
|
253
|
+
for uti in _PASTEBOARD_UTIS:
|
|
254
|
+
name = send(
|
|
255
|
+
runtime.objc_getClass(b"NSString"),
|
|
256
|
+
b"stringWithUTF8String:",
|
|
257
|
+
uti.encode(),
|
|
258
|
+
argtypes=(c_char_p,),
|
|
259
|
+
)
|
|
260
|
+
data = send(board, b"dataForType:", name, argtypes=(c_void_p,))
|
|
261
|
+
if not data:
|
|
262
|
+
continue
|
|
263
|
+
length = send(data, b"length", restype=c_ulong)
|
|
264
|
+
return ctypes.string_at(send(data, b"bytes"), length)
|
|
265
|
+
return None
|
|
266
|
+
finally:
|
|
267
|
+
runtime.objc_autoreleasePoolPop(pool)
|
|
268
|
+
|
|
269
|
+
return read
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _x11_image(runner: Runner) -> tuple[bytes, str] | None:
|
|
273
|
+
"""Read the first image an X11 or Wayland clipboard offers.
|
|
274
|
+
|
|
275
|
+
``wl-paste`` needs a Wayland session and ``xclip`` an X server, so both are
|
|
276
|
+
tried. Each clipboard advertises the types it holds, which is what lets a
|
|
277
|
+
JPEG or WebP be read as itself instead of being asked for a PNG and giving
|
|
278
|
+
up; a tool that cannot list types is still asked for PNG.
|
|
279
|
+
"""
|
|
280
|
+
for base, list_argv, flag in (
|
|
281
|
+
(["wl-paste", "--no-newline"], ["wl-paste", "--list-types"], "--type"),
|
|
282
|
+
(
|
|
283
|
+
["xclip", "-selection", "clipboard", "-o"],
|
|
284
|
+
["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"],
|
|
285
|
+
"-t",
|
|
286
|
+
),
|
|
287
|
+
):
|
|
288
|
+
for media_type in _offered_types(runner, list_argv) or (PNG,):
|
|
289
|
+
if media_type not in _LINUX_TYPES:
|
|
290
|
+
continue
|
|
291
|
+
result = runner([*base, flag, media_type])
|
|
292
|
+
if result.returncode != 0 or not result.stdout:
|
|
293
|
+
continue
|
|
294
|
+
return _sniff(result.stdout) or (result.stdout, media_type)
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _offered_types(runner: Runner, argv: Sequence[str]) -> tuple[str, ...]:
|
|
299
|
+
"""Return the image types a clipboard tool says are on the clipboard."""
|
|
300
|
+
result = runner(argv)
|
|
301
|
+
if result.returncode != 0:
|
|
302
|
+
return ()
|
|
303
|
+
text = result.stdout.decode("utf-8", "replace")
|
|
304
|
+
return tuple(line.strip() for line in text.splitlines() if line.strip().startswith("image/"))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def image_from_paste(text: str) -> tuple[bytes, str] | None:
|
|
308
|
+
"""Return the image a pasted payload carries, if it carries one.
|
|
309
|
+
|
|
310
|
+
A terminal cannot paste an image as bytes, but it can paste one as text:
|
|
311
|
+
VS Code turns a clipboard image into base64, warns about the size, and
|
|
312
|
+
sends the characters, and a copied ``data:image/...;base64,`` URI is the
|
|
313
|
+
same idea. Recognising both turns one paste key into the same ``[image #N]``
|
|
314
|
+
chip as :func:`read_image`, instead of a wall of characters standing in for
|
|
315
|
+
a picture.
|
|
316
|
+
|
|
317
|
+
Only a payload whose decoded bytes start like a known image format counts,
|
|
318
|
+
so ordinary text that merely looks like base64 stays text.
|
|
319
|
+
"""
|
|
320
|
+
payload = text.strip()
|
|
321
|
+
if _DATA_URI.match(payload):
|
|
322
|
+
payload = payload.split(",", 1)[1]
|
|
323
|
+
payload = "".join(payload.split())
|
|
324
|
+
if len(payload) < _MIN_BASE64:
|
|
325
|
+
return None
|
|
326
|
+
try:
|
|
327
|
+
data = base64.b64decode(payload, validate=True)
|
|
328
|
+
except (binascii.Error, ValueError):
|
|
329
|
+
return None
|
|
330
|
+
return _sniff(data)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _sniff(data: bytes) -> tuple[bytes, str] | None:
|
|
334
|
+
"""Return ``data`` and its media type when it starts like a known image."""
|
|
335
|
+
for signature, media_type in SIGNATURES:
|
|
336
|
+
if data.startswith(signature):
|
|
337
|
+
return data, media_type
|
|
338
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
339
|
+
return data, "image/webp"
|
|
340
|
+
return None
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""The shell's own slash commands: the ones that preview as they are walked or edit a form of their own.
|
|
2
|
+
|
|
3
|
+
They live outside :class:`~zettcode.app.ui.app.ZettCodeApp` so that class stays
|
|
4
|
+
the wiring \u2014 widget tree, keymap, pages \u2014 rather than the command set. Every
|
|
5
|
+
handler is a method here, returns a
|
|
6
|
+
:class:`~zettcode.plugin_api.commands.CommandResult`, and never paints directly: the
|
|
7
|
+
shell applies the result (see ``ZettCodeApp._apply_result``).
|
|
8
|
+
|
|
9
|
+
The commands that only need the shell's narrow public surface (``/resume``, ``/model``, ``/context`` and the rest)
|
|
10
|
+
are built-in plugins instead, in :mod:`zettcode.commands.handlers`. What stays here is what cannot be written
|
|
11
|
+
against that surface yet: ``/theme`` and ``/pet`` call the shell's previews as the menu is walked, and ``/config``
|
|
12
|
+
and ``/statusline`` open panels that edit state the shell owns; self-compaction is a `/plugins` switch now.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
from zettcode.config_edit import ConfigEditError
|
|
21
|
+
from zettcode.plugin_api.commands import (
|
|
22
|
+
Command,
|
|
23
|
+
CommandContext,
|
|
24
|
+
CommandProvider,
|
|
25
|
+
CommandResult,
|
|
26
|
+
Subcommand,
|
|
27
|
+
subcommand_words,
|
|
28
|
+
)
|
|
29
|
+
from zettcode.presenters.statusline import NAMES, Part
|
|
30
|
+
|
|
31
|
+
from .widgets import (
|
|
32
|
+
PetPage,
|
|
33
|
+
StatuslinePage,
|
|
34
|
+
bottom_panel,
|
|
35
|
+
pet_rows,
|
|
36
|
+
statusline_rows,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
#: One-line description per built-in palette, shown next to its name.
|
|
40
|
+
THEME_NOTES = {
|
|
41
|
+
"dark": "green-leaning palette for dark terminals",
|
|
42
|
+
"light": "green-leaning palette for light terminals",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#: What ``/config`` takes first, as its description lists them.
|
|
46
|
+
CONFIG_WORDS = (
|
|
47
|
+
Subcommand("add", "add a model: a provider, then what it needs", needs_more=True),
|
|
48
|
+
Subcommand("remove", "remove a model by number or name", needs_more=True),
|
|
49
|
+
Subcommand("list", "show the models in the file"),
|
|
50
|
+
Subcommand("providers", "show the providers and where to get a key"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _missing_file(error: BaseException) -> bool:
|
|
55
|
+
"""Return whether a failed write failed because there is no file yet, however deeply the error was wrapped.
|
|
56
|
+
|
|
57
|
+
The file editor raises ``ConfigEditError("… does not exist") from`` the ``FileNotFoundError``, and the SDK
|
|
58
|
+
wraps that again in ``ConfigurationError``, so the cause is the only reliable part of the answer. Note what
|
|
59
|
+
this cannot tell apart: a *read* of a missing file inside a directory that cannot be written also raises
|
|
60
|
+
``FileNotFoundError``, so the machine is promised a file that ``/config`` will then fail to write — with its
|
|
61
|
+
own error, which is the one place that knows why.
|
|
62
|
+
"""
|
|
63
|
+
seen: set[int] = set()
|
|
64
|
+
current: BaseException | None = error
|
|
65
|
+
while current is not None and id(current) not in seen:
|
|
66
|
+
seen.add(id(current))
|
|
67
|
+
if isinstance(current, FileNotFoundError):
|
|
68
|
+
return True
|
|
69
|
+
current = current.__cause__ or current.__context__
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if TYPE_CHECKING: # pragma: no cover - only needed for the annotation
|
|
74
|
+
from .app import ZettCodeApp
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ShellCommands(CommandProvider):
|
|
78
|
+
"""The commands the shell keeps for itself.
|
|
79
|
+
|
|
80
|
+
It is one provider among several: the shell merges it with the built-in plugins' commands, the agent's and the
|
|
81
|
+
installed plugins' through a registry, so every source of commands is the same shape and the first to claim a
|
|
82
|
+
name wins.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, shell: ZettCodeApp) -> None:
|
|
86
|
+
"""Keep the shell the handlers act on; ``shell.app`` is its ``TuiApp``."""
|
|
87
|
+
self.shell = shell
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def items(self) -> Sequence[Command]:
|
|
91
|
+
"""Return the shell's own commands, in the order ``/help`` lists them."""
|
|
92
|
+
return (
|
|
93
|
+
# ``when_busy="now"`` below is for what only looks at the session or sets something the
|
|
94
|
+
# running request reads when it needs it. What it touches while a request runs - the
|
|
95
|
+
# transcript it writes into, the session, the model it started with and the window that
|
|
96
|
+
# model's compaction is sized to - stays refused: /config, and the plugins' /model, /clear,
|
|
97
|
+
# /resume, /fork, plus the agent's /new, /compact and /export.
|
|
98
|
+
Command(
|
|
99
|
+
"/config",
|
|
100
|
+
"add, change or remove models: /config [add|remove|list|providers]",
|
|
101
|
+
"app",
|
|
102
|
+
self.config,
|
|
103
|
+
subcommands=CONFIG_WORDS,
|
|
104
|
+
),
|
|
105
|
+
Command(
|
|
106
|
+
"/theme",
|
|
107
|
+
"choose the palette: /theme <name> (the menu previews each)",
|
|
108
|
+
"app",
|
|
109
|
+
self.theme,
|
|
110
|
+
when_busy="now",
|
|
111
|
+
subcommands=self.theme_words,
|
|
112
|
+
preview=self.shell.preview_theme,
|
|
113
|
+
),
|
|
114
|
+
Command(
|
|
115
|
+
"/pet",
|
|
116
|
+
"choose the pet: /pet opens a panel, /pet <name> picks one",
|
|
117
|
+
"app",
|
|
118
|
+
self.pet,
|
|
119
|
+
when_busy="now",
|
|
120
|
+
),
|
|
121
|
+
Command(
|
|
122
|
+
"/statusline",
|
|
123
|
+
"choose which parts of the header and status line show: /statusline opens a panel",
|
|
124
|
+
"app",
|
|
125
|
+
self.statusline,
|
|
126
|
+
when_busy="now",
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _offer(self, command: str) -> CommandResult:
|
|
131
|
+
"""Put the command and a space in the draft, which opens the menu of the words it takes.
|
|
132
|
+
|
|
133
|
+
A command that is a choice among a few fixed words has no page of its own: the menu is the choice,
|
|
134
|
+
so typing the bare command only moves the reader to it.
|
|
135
|
+
"""
|
|
136
|
+
self.shell.offer(command)
|
|
137
|
+
return CommandResult()
|
|
138
|
+
|
|
139
|
+
def theme_words(self) -> tuple[Subcommand, ...]:
|
|
140
|
+
"""Offer every palette by name: the built-in ones, then the plugins' and the reader's own, with their notes."""
|
|
141
|
+
catalog = self.shell.agent.themes
|
|
142
|
+
notes = {**THEME_NOTES, **catalog.notes()}
|
|
143
|
+
return subcommand_words(catalog.names(), notes, self.shell.app.theme.name)
|
|
144
|
+
|
|
145
|
+
async def config(self, context: CommandContext) -> CommandResult:
|
|
146
|
+
"""Open the model list, or act on it from one line.
|
|
147
|
+
|
|
148
|
+
With nothing after it the command opens the panel, which asks one question at a time and
|
|
149
|
+
is the way to type a token without it being echoed. The words after it do the same job
|
|
150
|
+
on one line: ``add`` takes a provider and whatever of its settings the line gives, and
|
|
151
|
+
asks for the rest; ``remove`` takes a number or a name; ``list`` and ``providers`` only
|
|
152
|
+
show. A refusal \u2014 a bad address, a file that cannot be edited \u2014 is the command's error and
|
|
153
|
+
nothing is written.
|
|
154
|
+
"""
|
|
155
|
+
word, _, rest = context.argument.partition(" ")
|
|
156
|
+
shell = self.shell
|
|
157
|
+
if not word:
|
|
158
|
+
return CommandResult(widget=shell.config_page())
|
|
159
|
+
if word == "list":
|
|
160
|
+
return CommandResult(message=shell.config_listing())
|
|
161
|
+
if word == "providers":
|
|
162
|
+
return CommandResult(message=shell.config_providers_listing())
|
|
163
|
+
if word == "add":
|
|
164
|
+
done = shell.config_add_from_line(rest)
|
|
165
|
+
return CommandResult(notification=done, relayout=True) if done else CommandResult()
|
|
166
|
+
if word == "remove":
|
|
167
|
+
return CommandResult(notification=shell.config_remove_from_line(rest), relayout=True)
|
|
168
|
+
raise ValueError("Usage: /config [add <provider> ... | remove <number|name> | list | providers]")
|
|
169
|
+
|
|
170
|
+
async def theme(self, context: CommandContext) -> CommandResult:
|
|
171
|
+
"""Switch straight to a named palette and remember it, or open the menu of them.
|
|
172
|
+
|
|
173
|
+
The choice is written to ``[ui] theme`` in the config file so the next start opens in it. A file that
|
|
174
|
+
cannot take the line does not undo the switch: the palette is on, and the reader is told it will not be
|
|
175
|
+
there next time and why.
|
|
176
|
+
"""
|
|
177
|
+
if not context.argument:
|
|
178
|
+
return self._offer("/theme")
|
|
179
|
+
name = context.argument.strip()
|
|
180
|
+
self.shell.apply_theme(name)
|
|
181
|
+
try:
|
|
182
|
+
self.shell.agent.remember_theme(name)
|
|
183
|
+
except ConfigEditError as error:
|
|
184
|
+
self._unkept(f"theme {name}", error, applied=True)
|
|
185
|
+
return CommandResult(relayout=True)
|
|
186
|
+
return CommandResult(relayout=True, notification=f"theme {name} \u2014 remembered for next time")
|
|
187
|
+
|
|
188
|
+
async def pet(self, context: CommandContext) -> CommandResult:
|
|
189
|
+
"""Open the panel of pets, or switch straight to a named one; either way the choice is remembered.
|
|
190
|
+
|
|
191
|
+
The panel lists the pets with the marked one shown beside the list and over the conversation; Enter
|
|
192
|
+
chooses and Escape puts the old one back. The choice is written to ``[ui] pet`` in the config file so the
|
|
193
|
+
next start opens with it. A file that cannot take the line does not undo the switch: the pet is there,
|
|
194
|
+
and the reader is told it will not be next time and why. A pet that is switched off in ``/statusline`` is
|
|
195
|
+
still chosen, and the reader is told why nothing appeared. With no pet offered at all, either way, the reader
|
|
196
|
+
is told that and why, instead of being shown a panel with nothing in it or an "unknown pet" for a name that
|
|
197
|
+
could never exist.
|
|
198
|
+
"""
|
|
199
|
+
shell = self.shell
|
|
200
|
+
if not shell.agent.pets.names():
|
|
201
|
+
# No plugin offers a pet: there is nothing to choose from, and a panel of no rows has nowhere for the mark
|
|
202
|
+
# to stand. What the reader can do depends on why, so the config says which of the two it is.
|
|
203
|
+
if shell.agent.pets_switched_off:
|
|
204
|
+
return CommandResult(message="The pets plugin is switched off. Turn it on with /plugins.")
|
|
205
|
+
return CommandResult(message="No pets are installed. They come with the program: reinstall zettcode.")
|
|
206
|
+
if not context.argument:
|
|
207
|
+
|
|
208
|
+
def choose(name: str) -> None:
|
|
209
|
+
shell.close_page()
|
|
210
|
+
try:
|
|
211
|
+
shell.apply_pet(name)
|
|
212
|
+
shell.agent.remember_pet(name)
|
|
213
|
+
except ConfigEditError as error:
|
|
214
|
+
# Before ValueError: the file editor's refusal is a ValueError too, and it is the one a
|
|
215
|
+
# machine with no file yet should hear about without the word "failure".
|
|
216
|
+
self._unkept(f"pet {name}", error, applied=True)
|
|
217
|
+
return
|
|
218
|
+
except ValueError as error:
|
|
219
|
+
shell.transcript.error(str(error))
|
|
220
|
+
return
|
|
221
|
+
shell.transcript.notice(self._pet_chosen(name))
|
|
222
|
+
|
|
223
|
+
def cancel() -> None:
|
|
224
|
+
shell.preview_pet(None)
|
|
225
|
+
shell.close_page()
|
|
226
|
+
|
|
227
|
+
catalog = shell.agent.pets
|
|
228
|
+
page = PetPage(catalog, shell.pet.pet.name, on_move=shell.preview_pet, on_select=choose, on_cancel=cancel)
|
|
229
|
+
return CommandResult(widget=bottom_panel(page, rows=pet_rows(len(catalog.names()), shell.app.height)))
|
|
230
|
+
name = context.argument.strip()
|
|
231
|
+
shell.apply_pet(name)
|
|
232
|
+
try:
|
|
233
|
+
shell.agent.remember_pet(name)
|
|
234
|
+
except ConfigEditError as error:
|
|
235
|
+
self._unkept(f"pet {name}", error, applied=True)
|
|
236
|
+
return CommandResult(relayout=True)
|
|
237
|
+
return CommandResult(relayout=True, notification=self._pet_chosen(name))
|
|
238
|
+
|
|
239
|
+
def _pet_chosen(self, name: str) -> str:
|
|
240
|
+
"""Say which pet was chosen, and why nothing shows if the reader has switched the pet off."""
|
|
241
|
+
if "pet" in self.shell.agent.statusline_hidden:
|
|
242
|
+
return f"pet {name} \u2014 remembered, but the pet is hidden: /statusline show pet"
|
|
243
|
+
return f"pet {name} \u2014 remembered for next time"
|
|
244
|
+
|
|
245
|
+
def _unwritten_config(self, what: str, error: Exception, *, applied: bool) -> str | None:
|
|
246
|
+
"""Return the sentence for a setting the missing file could not keep, or ``None`` when it is another failure.
|
|
247
|
+
|
|
248
|
+
A machine that has never been set up has no config file at all, and none of the commands that write one
|
|
249
|
+
line creates it: a file holding a palette and no models is worse than none, and ``/config`` is what writes
|
|
250
|
+
it. So on that machine the line names what brings the file rather than reporting a missing file as a
|
|
251
|
+
failure, and the branch is chosen on *why* the write failed — a file that is not there, however deeply the
|
|
252
|
+
error was wrapped — rather than on what the path looks like now.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
what: The setting, as a noun phrase the sentence can start with ("theme nord", "the status line").
|
|
256
|
+
error: What the write raised; anything not caused by a missing file is the caller's failure to report.
|
|
257
|
+
applied: Whether the change is in force for this run. ``/theme`` and ``/pet`` apply before they write,
|
|
258
|
+
so they are on now; the compaction default and the status line write first and are not, and their
|
|
259
|
+
sentence must not claim a change the run does not have. ``/config`` writes the models, not this
|
|
260
|
+
setting, so what it brings is the file the setting can be saved into.
|
|
261
|
+
"""
|
|
262
|
+
if self.shell.model is not None or not _missing_file(error):
|
|
263
|
+
return None
|
|
264
|
+
path = self.shell.client.config_file
|
|
265
|
+
if applied:
|
|
266
|
+
return f"{what} is on for this run; it is kept for next time once `/config` writes {path}"
|
|
267
|
+
return f"{what} is not saved; `/config` writes {path}, and it can be set again then"
|
|
268
|
+
|
|
269
|
+
def _unkept(self, what: str, error: Exception, *, applied: bool) -> None:
|
|
270
|
+
"""Say what became of a setting the file could not keep: a notice on a fresh machine, else the failure.
|
|
271
|
+
|
|
272
|
+
The failure follows ``applied`` for the same reason the fresh branch does: "will not be remembered" says
|
|
273
|
+
the change is in force and only its memory failed, which is untrue of the pair that writes first.
|
|
274
|
+
"""
|
|
275
|
+
unwritten = self._unwritten_config(what, error, applied=applied)
|
|
276
|
+
if unwritten is not None:
|
|
277
|
+
self.shell.transcript.notice(unwritten)
|
|
278
|
+
elif applied:
|
|
279
|
+
self.shell.transcript.error(f"{what} will not be remembered: {error}")
|
|
280
|
+
else:
|
|
281
|
+
self.shell.transcript.error(f"{what} is not saved: {error}")
|
|
282
|
+
|
|
283
|
+
def _or_reason(self, what: str, error: Exception, *, applied: bool) -> str:
|
|
284
|
+
"""The sentence a command reports a failed setting with: the fresh machine's, or what became of it."""
|
|
285
|
+
unwritten = self._unwritten_config(what, error, applied=applied)
|
|
286
|
+
if unwritten is not None:
|
|
287
|
+
return unwritten
|
|
288
|
+
return f"{what} will not be remembered: {error}" if applied else f"{what} is not saved: {error}"
|
|
289
|
+
|
|
290
|
+
async def statusline(self, context: CommandContext) -> CommandResult:
|
|
291
|
+
"""Hide or show parts from the words it takes, or open the panel with a switch for each part.
|
|
292
|
+
|
|
293
|
+
The switches only move; Enter writes ``[statusline]`` into the config file, and the rows follow at
|
|
294
|
+
the next paint. Escape applies nothing. A config file that cannot be written is reported and the rows
|
|
295
|
+
stay as they were.
|
|
296
|
+
"""
|
|
297
|
+
shell = self.shell
|
|
298
|
+
if context.argument:
|
|
299
|
+
try:
|
|
300
|
+
message = shell.agent.change_statusline(context.argument)
|
|
301
|
+
except ConfigEditError as error:
|
|
302
|
+
return CommandResult(message=self._or_reason("The status line", error, applied=False))
|
|
303
|
+
except ValueError as error:
|
|
304
|
+
return CommandResult(message=str(error))
|
|
305
|
+
return CommandResult(message=message, relayout=True)
|
|
306
|
+
|
|
307
|
+
def apply(shown: dict[str, bool]) -> None:
|
|
308
|
+
shell.close_page()
|
|
309
|
+
try:
|
|
310
|
+
shell.agent.remember_statusline(shown)
|
|
311
|
+
except ConfigEditError as error:
|
|
312
|
+
# Written before it applies, like the compaction default: the sentence says it is not saved.
|
|
313
|
+
self._unkept("The status line", error, applied=False)
|
|
314
|
+
return
|
|
315
|
+
except ValueError as error:
|
|
316
|
+
shell.transcript.error(str(error))
|
|
317
|
+
return
|
|
318
|
+
shell.transcript.notice(shell.agent.statusline_summary())
|
|
319
|
+
|
|
320
|
+
# The program's own items are in the panel already; a plugin's are added beside them.
|
|
321
|
+
extra = tuple(Part(item.name, item.summary) for item in shell.agent.turn_segments if item.name not in NAMES)
|
|
322
|
+
page = StatuslinePage(shell.agent.statusline_hidden, extra, on_apply=apply, on_cancel=shell.close_page)
|
|
323
|
+
return CommandResult(widget=bottom_panel(page, rows=statusline_rows(shell.app.height)))
|