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,653 @@
|
|
|
1
|
+
"""The bottom panel ``/config`` opens for one model: every setting on a row, the name on the left and its value on the right."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from time import monotonic
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from zettcode.plugin_api.panels import pressed
|
|
11
|
+
from zettcode.plugin_api.switch import SWITCH_WIDTH, Rail, draw_switch
|
|
12
|
+
from zettcode.tui import (
|
|
13
|
+
ARROW_DOWN,
|
|
14
|
+
ARROW_UP,
|
|
15
|
+
PROMPT,
|
|
16
|
+
SEPARATOR,
|
|
17
|
+
AnyEvent,
|
|
18
|
+
Canvas,
|
|
19
|
+
Host,
|
|
20
|
+
MouseAction,
|
|
21
|
+
MouseEvent,
|
|
22
|
+
Rect,
|
|
23
|
+
Style,
|
|
24
|
+
Widget,
|
|
25
|
+
event_key,
|
|
26
|
+
)
|
|
27
|
+
from zettcode.tui.core.geometry import Point
|
|
28
|
+
from zettcode.tui.render import blend, display_width, truncate
|
|
29
|
+
|
|
30
|
+
from .form_page import LineArea, SecretLine
|
|
31
|
+
|
|
32
|
+
#: What an answer is: the trimmed text of a typed row, whether a switch is on, the word a choice is on.
|
|
33
|
+
Answer = str | bool
|
|
34
|
+
|
|
35
|
+
#: The columns kept free at each side, the width of the marker before a row's label, and of the label itself.
|
|
36
|
+
MARGIN = 2
|
|
37
|
+
MARKER_WIDTH = 2
|
|
38
|
+
LABEL_WIDTH = 16
|
|
39
|
+
|
|
40
|
+
#: Rows besides the settings: the title, the reason a save was refused, a blank above the line that explains the row
|
|
41
|
+
#: the cursor is in, that line, and the keys.
|
|
42
|
+
CHROME_ROWS = 5
|
|
43
|
+
|
|
44
|
+
#: The gap between a switch's rail and its word, and between the words of a choice.
|
|
45
|
+
WORD_GAP = 1
|
|
46
|
+
CHOICE_GAP = 3
|
|
47
|
+
|
|
48
|
+
#: The marks of a choice: the word it is on, and the others.
|
|
49
|
+
ON, OFF = "\u25c9", "\u25cb"
|
|
50
|
+
|
|
51
|
+
Kind = Literal["text", "secret", "switch", "choice"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class Setting:
|
|
56
|
+
"""One row of the panel: a value of the model that can be changed.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
key: What the row is called in the answers handed to the page's owner.
|
|
60
|
+
label: What the reader sees on the left.
|
|
61
|
+
kind: ``text`` and ``secret`` are typed; ``switch`` is a rail with a knob between two words; ``choice`` is
|
|
62
|
+
one word out of several.
|
|
63
|
+
initial: The text a typed row starts with.
|
|
64
|
+
placeholder: Shown, muted, while a typed row is empty.
|
|
65
|
+
hint: One sentence shown under the rows while the cursor is in this one.
|
|
66
|
+
required: Refuse a save while a typed row is empty; the label then carries a star.
|
|
67
|
+
words: For a switch, the word of off and then of on; for a choice, its words.
|
|
68
|
+
value: For a switch or a choice, the index of the word it starts on.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
key: str
|
|
72
|
+
label: str
|
|
73
|
+
kind: Kind = "text"
|
|
74
|
+
initial: str = ""
|
|
75
|
+
placeholder: str = ""
|
|
76
|
+
hint: str = ""
|
|
77
|
+
required: bool = False
|
|
78
|
+
words: tuple[str, ...] = ()
|
|
79
|
+
value: int = 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class Action:
|
|
84
|
+
"""One thing to do with the model, drawn as a row of its own under the settings.
|
|
85
|
+
|
|
86
|
+
Attributes:
|
|
87
|
+
key: What the page's owner is told was chosen.
|
|
88
|
+
label: The row's name.
|
|
89
|
+
note: What it does, in a few words; also the line under the rows while the cursor is on it.
|
|
90
|
+
saves: Whether the settings are taken with it, and so refused when a required one is empty.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
key: str
|
|
94
|
+
label: str
|
|
95
|
+
note: str
|
|
96
|
+
saves: bool = False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _Row(Widget):
|
|
100
|
+
"""What every row of the panel shares: the marker and the label on the left."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, page: ModelSettingsPage, label: str, *, hint: str = "", required: bool = False) -> None:
|
|
103
|
+
"""Hold the page the row is on, the words it is called, and the sentence that explains it."""
|
|
104
|
+
super().__init__()
|
|
105
|
+
self.page = page
|
|
106
|
+
self.label = f"{label} *" if required else label
|
|
107
|
+
self.hint = hint
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def here(self) -> bool:
|
|
111
|
+
"""Return whether the cursor is in this row."""
|
|
112
|
+
return self.target.focused
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def target(self) -> Widget:
|
|
116
|
+
"""Return the widget that takes focus when the cursor moves to this row."""
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def column(self) -> int:
|
|
121
|
+
"""Return the column the row's value starts at."""
|
|
122
|
+
return self.rect.x + MARGIN + MARKER_WIDTH + LABEL_WIDTH
|
|
123
|
+
|
|
124
|
+
def draw_label(self, canvas: Canvas) -> None:
|
|
125
|
+
"""Draw the marker when the cursor is here, then the label."""
|
|
126
|
+
theme = self.theme
|
|
127
|
+
surface = theme.surface_alt
|
|
128
|
+
x, y = self.rect.x + MARGIN, self.rect.y
|
|
129
|
+
if self.here:
|
|
130
|
+
canvas.draw_text(x, y, PROMPT, Style(foreground=theme.accent, background=surface, bold=True))
|
|
131
|
+
canvas.draw_text(
|
|
132
|
+
x + MARKER_WIDTH,
|
|
133
|
+
y,
|
|
134
|
+
truncate(self.label, LABEL_WIDTH - 1),
|
|
135
|
+
Style(foreground=theme.text, background=surface, bold=self.here),
|
|
136
|
+
max_width=LABEL_WIDTH - 1,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class _TextRow(_Row):
|
|
141
|
+
"""A typed row: the editor sits on a band to the right of the label, so it reads as somewhere to type."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, page: ModelSettingsPage, setting: Setting) -> None:
|
|
144
|
+
"""Hold one editor, a bullet-painting one for a secret."""
|
|
145
|
+
super().__init__(page, setting.label, hint=setting.hint, required=setting.required)
|
|
146
|
+
self.setting = setting
|
|
147
|
+
self.area: LineArea = (SecretLine if setting.kind == "secret" else LineArea)(
|
|
148
|
+
prompt="", placeholder=setting.placeholder, max_height=1, on_change=page.edited
|
|
149
|
+
)
|
|
150
|
+
if setting.initial:
|
|
151
|
+
self.area.set_text(setting.initial)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def children(self) -> tuple[Widget, ...]:
|
|
155
|
+
"""Expose the editor."""
|
|
156
|
+
return (self.area,)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def target(self) -> Widget:
|
|
160
|
+
"""Return the editor: the cursor lives in it."""
|
|
161
|
+
return self.area
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def answer(self) -> Answer:
|
|
165
|
+
"""Return what is typed, trimmed."""
|
|
166
|
+
return self.area.text.strip()
|
|
167
|
+
|
|
168
|
+
def layout(self, rect: Rect) -> None:
|
|
169
|
+
"""Put the editor after the label, as wide as the row has left."""
|
|
170
|
+
super().layout(rect)
|
|
171
|
+
if rect.empty:
|
|
172
|
+
self.area.layout(Rect(rect.x, rect.y, 0, 0))
|
|
173
|
+
return
|
|
174
|
+
start = self.column
|
|
175
|
+
self.area.layout(Rect(start, rect.y, max(0, rect.right - MARGIN - start), 1))
|
|
176
|
+
|
|
177
|
+
def render(self, canvas: Canvas) -> None:
|
|
178
|
+
"""Draw the label, the band, and the editor."""
|
|
179
|
+
if self.rect.empty:
|
|
180
|
+
return
|
|
181
|
+
self.draw_label(canvas)
|
|
182
|
+
theme = self.theme
|
|
183
|
+
band = Style(background=theme.surface)
|
|
184
|
+
canvas.fill(self.area.rect.x - 1, self.area.rect.y, self.area.rect.width + 2, 1, band)
|
|
185
|
+
self.area.render(canvas)
|
|
186
|
+
|
|
187
|
+
def handle(self, event: AnyEvent, host: Host) -> bool:
|
|
188
|
+
"""Take a press on the label or the band's edge: the editor takes one inside it for itself."""
|
|
189
|
+
if isinstance(event, MouseEvent) and event.action is MouseAction.DOWN and self.rect.contains(event.x, event.y):
|
|
190
|
+
host.focus(self.area)
|
|
191
|
+
return True
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
def cursor(self) -> Point | None:
|
|
195
|
+
"""Forward the editor's cursor."""
|
|
196
|
+
return self.area.cursor()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class _RailRow(_Row):
|
|
200
|
+
"""A switch or a choice: Left and Right move it, Space turns a switch over."""
|
|
201
|
+
|
|
202
|
+
def __init__(self, page: ModelSettingsPage, setting: Setting) -> None:
|
|
203
|
+
"""Start the knob on the setting's word."""
|
|
204
|
+
super().__init__(page, setting.label, hint=setting.hint)
|
|
205
|
+
self.setting = setting
|
|
206
|
+
self.rail = Rail(setting.words, setting.value)
|
|
207
|
+
self.switch = setting.kind == "switch"
|
|
208
|
+
self._token = f"model-settings:{setting.key}:{id(self)}"
|
|
209
|
+
#: Where each word of a choice was drawn at the last paint, for a click.
|
|
210
|
+
self._spans: list[tuple[int, int]] = []
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def focusable(self) -> bool:
|
|
214
|
+
"""Take the cursor: there is no editor in the row to hold it."""
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def answer(self) -> Answer:
|
|
219
|
+
"""Return whether a switch is on, or the word a choice is on, once the knob arrives."""
|
|
220
|
+
return bool(self.rail.selected) if self.switch else self.rail.words[self.rail.selected]
|
|
221
|
+
|
|
222
|
+
def _now(self) -> float:
|
|
223
|
+
return self.app.clock() if self.app is not None else monotonic()
|
|
224
|
+
|
|
225
|
+
def choose(self, index: int) -> None:
|
|
226
|
+
"""Send the knob to a word; a switch slides there, a choice jumps."""
|
|
227
|
+
instant = not self.switch or (self.app is not None and self.app.reduced_motion)
|
|
228
|
+
if self.rail.choose(index, self._now(), instant=instant) and self.app is not None:
|
|
229
|
+
self.app.scheduler.animate(self._token)
|
|
230
|
+
self.page.edited()
|
|
231
|
+
self.invalidate()
|
|
232
|
+
|
|
233
|
+
def on_unmount(self) -> None:
|
|
234
|
+
"""Release the frame loop, so a panel put away mid-slide stops costing frames."""
|
|
235
|
+
self.rail.moving = False
|
|
236
|
+
if self.app is not None:
|
|
237
|
+
self.app.scheduler.animate(self._token, active=False)
|
|
238
|
+
|
|
239
|
+
def on_tick(self) -> bool:
|
|
240
|
+
"""Hand the frame loop back once the knob has arrived; say whether the picture is still changing."""
|
|
241
|
+
landed = self.rail.arrive(self._now())
|
|
242
|
+
if not self.rail.moving and self.app is not None:
|
|
243
|
+
self.app.scheduler.animate(self._token, active=False)
|
|
244
|
+
return self.rail.moving or landed
|
|
245
|
+
|
|
246
|
+
def handle(self, event: AnyEvent, host: Host) -> bool:
|
|
247
|
+
"""Move with Left and Right (Home and End go to the ends), and turn over or step on with Space."""
|
|
248
|
+
if isinstance(event, MouseEvent):
|
|
249
|
+
return self._press(event, host)
|
|
250
|
+
last = len(self.rail.words) - 1
|
|
251
|
+
match pressed(event):
|
|
252
|
+
case "left":
|
|
253
|
+
self.choose(self.rail.selected - 1)
|
|
254
|
+
case "right":
|
|
255
|
+
self.choose(self.rail.selected + 1)
|
|
256
|
+
case "home":
|
|
257
|
+
self.choose(0)
|
|
258
|
+
case "end":
|
|
259
|
+
self.choose(last)
|
|
260
|
+
case " ":
|
|
261
|
+
self.choose((self.rail.selected + 1) % (last + 1))
|
|
262
|
+
case _:
|
|
263
|
+
return False
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
def _press(self, event: MouseEvent, host: Host) -> bool:
|
|
267
|
+
"""Put the cursor here, and set the switch or the word under the click."""
|
|
268
|
+
if event.action is not MouseAction.DOWN or not self.rect.contains(event.x, event.y):
|
|
269
|
+
return False
|
|
270
|
+
host.focus(self)
|
|
271
|
+
if self.switch:
|
|
272
|
+
if (
|
|
273
|
+
self.column
|
|
274
|
+
<= event.x
|
|
275
|
+
< self.column + SWITCH_WIDTH + WORD_GAP + max(map(display_width, self.rail.words))
|
|
276
|
+
):
|
|
277
|
+
self.choose(1 - self.rail.selected)
|
|
278
|
+
else:
|
|
279
|
+
for index, (start, stop) in enumerate(self._spans):
|
|
280
|
+
if start <= event.x < stop:
|
|
281
|
+
self.choose(index)
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
def render(self, canvas: Canvas) -> None:
|
|
285
|
+
"""Draw the label, then the rail and its word, or the words with the chosen one marked."""
|
|
286
|
+
if self.rect.empty:
|
|
287
|
+
return
|
|
288
|
+
self.draw_label(canvas)
|
|
289
|
+
theme = self.theme
|
|
290
|
+
surface = theme.surface_alt
|
|
291
|
+
y = self.rect.y
|
|
292
|
+
if self.switch:
|
|
293
|
+
position = self.rail.position(self._now())
|
|
294
|
+
on = bool(self.rail.selected)
|
|
295
|
+
draw_switch(canvas, theme, y, self.column, position, blend(theme.muted, surface, 0.55))
|
|
296
|
+
canvas.draw_text(
|
|
297
|
+
self.column + SWITCH_WIDTH + WORD_GAP,
|
|
298
|
+
y,
|
|
299
|
+
self.rail.words[self.rail.selected],
|
|
300
|
+
Style(foreground=theme.accent if on else blend(theme.muted, surface, 0.25), background=surface),
|
|
301
|
+
max_width=max(0, self.rect.right - MARGIN - self.column - SWITCH_WIDTH - WORD_GAP),
|
|
302
|
+
)
|
|
303
|
+
return
|
|
304
|
+
x = self.column
|
|
305
|
+
self._spans = []
|
|
306
|
+
for index, word in enumerate(self.rail.words):
|
|
307
|
+
chosen = index == self.rail.selected
|
|
308
|
+
width = display_width(word) + 2
|
|
309
|
+
self._spans.append((x, x + width))
|
|
310
|
+
style = (
|
|
311
|
+
Style(foreground=theme.accent, background=surface, bold=True)
|
|
312
|
+
if chosen
|
|
313
|
+
else Style(foreground=theme.muted, background=surface)
|
|
314
|
+
)
|
|
315
|
+
canvas.draw_text(x, y, f"{ON if chosen else OFF} {word}", style, max_width=max(0, self.rect.right - x))
|
|
316
|
+
x += width + CHOICE_GAP
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class _ActionRow(_Row):
|
|
320
|
+
"""A thing to do rather than a value: the label, and what it does beside it."""
|
|
321
|
+
|
|
322
|
+
def __init__(self, page: ModelSettingsPage, action: Action) -> None:
|
|
323
|
+
"""Hold the action and the words it is drawn with."""
|
|
324
|
+
super().__init__(page, action.label, hint=action.note)
|
|
325
|
+
self.action = action
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def focusable(self) -> bool:
|
|
329
|
+
"""Take the cursor, so Enter does it."""
|
|
330
|
+
return True
|
|
331
|
+
|
|
332
|
+
def render(self, canvas: Canvas) -> None:
|
|
333
|
+
"""Draw the label and, muted after it, what it does."""
|
|
334
|
+
if self.rect.empty:
|
|
335
|
+
return
|
|
336
|
+
self.draw_label(canvas)
|
|
337
|
+
theme = self.theme
|
|
338
|
+
room = max(0, self.rect.right - MARGIN - self.column)
|
|
339
|
+
canvas.draw_text(
|
|
340
|
+
self.column,
|
|
341
|
+
self.rect.y,
|
|
342
|
+
truncate(self.action.note, room),
|
|
343
|
+
Style(foreground=blend(theme.muted, theme.surface_alt, 0.15), background=theme.surface_alt),
|
|
344
|
+
max_width=room,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
def handle(self, event: AnyEvent, host: Host) -> bool:
|
|
348
|
+
"""Put the cursor here on a press, and do it on a second one."""
|
|
349
|
+
if isinstance(event, MouseEvent) and event.action is MouseAction.DOWN and self.rect.contains(event.x, event.y):
|
|
350
|
+
if self.here:
|
|
351
|
+
self.page.act(self.action, host)
|
|
352
|
+
else:
|
|
353
|
+
host.focus(self)
|
|
354
|
+
return True
|
|
355
|
+
return False
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class ModelSettingsPage(Widget):
|
|
359
|
+
"""Change everything about one model on one page, and save it all at once.
|
|
360
|
+
|
|
361
|
+
Shape::
|
|
362
|
+
|
|
363
|
+
GPT-6.1-sol (current) edited
|
|
364
|
+
● address: write it as https://host/v1 <- why the last save was refused
|
|
365
|
+
▸ Token ••••••••••••
|
|
366
|
+
API ◉ chat ○ responses ○ anthropic
|
|
367
|
+
Address http://tds.com:8787
|
|
368
|
+
Model id * gpt-6.1-sol
|
|
369
|
+
Shown name GPT-6.1-sol
|
|
370
|
+
Images ━━━━━━━━━● accepted
|
|
371
|
+
Web search ●───────── off
|
|
372
|
+
Search tool web_search_20260318
|
|
373
|
+
Temperature the provider's default
|
|
374
|
+
Context window 1m
|
|
375
|
+
Compress at 80
|
|
376
|
+
|
|
377
|
+
Use this model save, then switch to it now
|
|
378
|
+
Remove delete it from the file
|
|
379
|
+
|
|
380
|
+
Your key. The current one is never shown; leave it empty to keep it.
|
|
381
|
+
↑ ↓ row · ← → set · enter save · esc back
|
|
382
|
+
|
|
383
|
+
The names are on the left and the values on the right: a typed row is an editor on a band, a switch is the rail
|
|
384
|
+
``/statusline`` uses, with its knob, and a choice is its words with the chosen one marked. Up, Down and Tab move
|
|
385
|
+
between rows (they are no use inside a one-line editor), Left and Right and Space set a switch or a choice, and
|
|
386
|
+
Enter saves every row at once, as a form does: the answers go to ``on_save``, which answers ``None`` when it took
|
|
387
|
+
them or ``(key, sentence)`` when it did not. The sentence is shown under the title and the cursor goes to the row it
|
|
388
|
+
names, with every row as typed. The two actions at the bottom take the answers along when ``saves`` says so, and the
|
|
389
|
+
page scrolls when the screen is too short for every row.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
title: The model's name.
|
|
393
|
+
settings: The rows, top to bottom.
|
|
394
|
+
actions: The things to do with the model, under the rows.
|
|
395
|
+
on_save: Receives the answers when Enter is pressed on a setting.
|
|
396
|
+
on_action: Receives the action chosen and the answers.
|
|
397
|
+
on_cancel: Called on Escape.
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
KEYS = f"{ARROW_UP} {ARROW_DOWN} row {SEPARATOR} \u2190 \u2192 set {SEPARATOR} enter save {SEPARATOR} esc back"
|
|
401
|
+
|
|
402
|
+
def __init__(
|
|
403
|
+
self,
|
|
404
|
+
title: str,
|
|
405
|
+
settings: Sequence[Setting],
|
|
406
|
+
actions: Sequence[Action] = (),
|
|
407
|
+
*,
|
|
408
|
+
on_save: Callable[[dict[str, Answer]], tuple[str, str] | None],
|
|
409
|
+
on_action: Callable[[Action, dict[str, Answer]], tuple[str, str] | None],
|
|
410
|
+
on_cancel: Callable[[], None],
|
|
411
|
+
) -> None:
|
|
412
|
+
"""Build the rows with the cursor in the first."""
|
|
413
|
+
super().__init__()
|
|
414
|
+
if not settings:
|
|
415
|
+
raise ValueError("A model page needs at least one setting")
|
|
416
|
+
self.title = title
|
|
417
|
+
self.settings = tuple(settings)
|
|
418
|
+
self.on_save = on_save
|
|
419
|
+
self.on_action = on_action
|
|
420
|
+
self.on_cancel = on_cancel
|
|
421
|
+
#: Why the last save was refused; empty until one is, and again as soon as the reader changes a row.
|
|
422
|
+
self.refusal = ""
|
|
423
|
+
#: The first visible slot of the list, so a screen too short for it still shows the row the cursor is in.
|
|
424
|
+
self.top = 0
|
|
425
|
+
self._rows: dict[str, _Row] = {}
|
|
426
|
+
slots: list[_Row | None] = []
|
|
427
|
+
for setting in self.settings:
|
|
428
|
+
row: _Row = _TextRow(self, setting) if setting.kind in ("text", "secret") else _RailRow(self, setting)
|
|
429
|
+
self._rows[setting.key] = row
|
|
430
|
+
slots.append(row)
|
|
431
|
+
if actions:
|
|
432
|
+
slots.append(None) # a blank row between the settings and what can be done with them
|
|
433
|
+
for action in actions:
|
|
434
|
+
row = _ActionRow(self, action)
|
|
435
|
+
self._rows[action.key] = row
|
|
436
|
+
slots.append(row)
|
|
437
|
+
self._slots: list[_Row | None] = slots
|
|
438
|
+
self._order: list[_Row] = [slot for slot in slots if slot is not None]
|
|
439
|
+
self._start = self.values
|
|
440
|
+
|
|
441
|
+
# -- tree ------------------------------------------------------------------------
|
|
442
|
+
@property
|
|
443
|
+
def children(self) -> tuple[Widget, ...]:
|
|
444
|
+
"""Expose every row, whether it is on screen or not."""
|
|
445
|
+
return tuple(self._order)
|
|
446
|
+
|
|
447
|
+
@property
|
|
448
|
+
def focusable(self) -> bool:
|
|
449
|
+
"""Leave the cursor to the rows."""
|
|
450
|
+
return False
|
|
451
|
+
|
|
452
|
+
# -- the answers -----------------------------------------------------------------
|
|
453
|
+
@property
|
|
454
|
+
def values(self) -> dict[str, Answer]:
|
|
455
|
+
"""Return every setting's answer by key."""
|
|
456
|
+
answers: dict[str, Answer] = {}
|
|
457
|
+
for setting in self.settings:
|
|
458
|
+
row = self._rows[setting.key]
|
|
459
|
+
if isinstance(row, (_TextRow, _RailRow)):
|
|
460
|
+
answers[setting.key] = row.answer
|
|
461
|
+
return answers
|
|
462
|
+
|
|
463
|
+
@property
|
|
464
|
+
def changed(self) -> bool:
|
|
465
|
+
"""Return whether any row is anything but how it started."""
|
|
466
|
+
return self.values != self._start
|
|
467
|
+
|
|
468
|
+
def edited(self) -> None:
|
|
469
|
+
"""Forget the refusal once the reader starts correcting it."""
|
|
470
|
+
self.refusal = ""
|
|
471
|
+
|
|
472
|
+
def editor(self, key: str) -> LineArea:
|
|
473
|
+
"""Return the editor of a typed row.
|
|
474
|
+
|
|
475
|
+
Raises:
|
|
476
|
+
KeyError: When the key is not a typed row's.
|
|
477
|
+
"""
|
|
478
|
+
row = self._rows.get(key)
|
|
479
|
+
if not isinstance(row, _TextRow):
|
|
480
|
+
raise KeyError(key)
|
|
481
|
+
return row.area
|
|
482
|
+
|
|
483
|
+
def focus_key(self) -> str | None:
|
|
484
|
+
"""Return the key of the row the cursor is in, if it is in one."""
|
|
485
|
+
return next((key for key, row in self._rows.items() if row.here), None)
|
|
486
|
+
|
|
487
|
+
# -- geometry --------------------------------------------------------------------
|
|
488
|
+
def _view(self) -> int:
|
|
489
|
+
"""Return how many rows of the list the panel has room for."""
|
|
490
|
+
return max(1, self.rect.height - CHROME_ROWS)
|
|
491
|
+
|
|
492
|
+
def _reveal(self) -> None:
|
|
493
|
+
"""Scroll the list the least that shows the row the cursor is in."""
|
|
494
|
+
view = self._view()
|
|
495
|
+
here = next((index for index, row in enumerate(self._slots) if row is not None and row.here), None)
|
|
496
|
+
if here is not None:
|
|
497
|
+
if here < self.top:
|
|
498
|
+
self.top = here
|
|
499
|
+
if here >= self.top + view:
|
|
500
|
+
self.top = here - view + 1
|
|
501
|
+
self.top = max(0, min(self.top, len(self._slots) - view))
|
|
502
|
+
|
|
503
|
+
def _place(self) -> None:
|
|
504
|
+
"""Give the rows on screen a line each, and the others none."""
|
|
505
|
+
for hidden in self._order:
|
|
506
|
+
hidden.layout(Rect(self.rect.x, self.rect.y, 0, 0))
|
|
507
|
+
first = self.rect.y + 2
|
|
508
|
+
for offset, slot in enumerate(self._slots[self.top : self.top + self._view()]):
|
|
509
|
+
if slot is not None:
|
|
510
|
+
slot.layout(Rect(self.rect.x, first + offset, self.rect.width, 1))
|
|
511
|
+
|
|
512
|
+
def layout(self, rect: Rect) -> None:
|
|
513
|
+
"""Place the rows that fit."""
|
|
514
|
+
super().layout(rect)
|
|
515
|
+
self._reveal()
|
|
516
|
+
self._place()
|
|
517
|
+
|
|
518
|
+
# -- painting --------------------------------------------------------------------
|
|
519
|
+
def render(self, canvas: Canvas) -> None:
|
|
520
|
+
"""Fill the panel, then draw the title, the refusal, the rows on screen, the explanation and the keys."""
|
|
521
|
+
if self.rect.empty:
|
|
522
|
+
return
|
|
523
|
+
theme = self.theme
|
|
524
|
+
surface = theme.surface_alt
|
|
525
|
+
canvas.fill(self.rect.x, self.rect.y, self.rect.width, self.rect.height, Style(background=surface))
|
|
526
|
+
self._reveal()
|
|
527
|
+
self._place()
|
|
528
|
+
x = self.rect.x + MARGIN
|
|
529
|
+
room = max(0, self.rect.width - 2 * MARGIN)
|
|
530
|
+
muted = Style(foreground=theme.muted, background=surface)
|
|
531
|
+
canvas.draw_text(
|
|
532
|
+
x, self.rect.y, truncate(self.title, room), Style(foreground=theme.text, background=surface, bold=True)
|
|
533
|
+
)
|
|
534
|
+
if self.changed:
|
|
535
|
+
tag = "edited"
|
|
536
|
+
start = self.rect.right - MARGIN - display_width(tag)
|
|
537
|
+
if start > x + display_width(self.title) + 2:
|
|
538
|
+
canvas.draw_text(start, self.rect.y, tag, Style(foreground=theme.accent, background=surface, bold=True))
|
|
539
|
+
if self.refusal:
|
|
540
|
+
canvas.draw_text(
|
|
541
|
+
x,
|
|
542
|
+
self.rect.y + 1,
|
|
543
|
+
truncate(self.refusal, room),
|
|
544
|
+
Style(foreground=theme.warning, background=surface),
|
|
545
|
+
max_width=room,
|
|
546
|
+
)
|
|
547
|
+
for row in self._order:
|
|
548
|
+
row.render(canvas)
|
|
549
|
+
# A mark at the edge says there is more list than the screen could give it.
|
|
550
|
+
first = self.rect.y + 2
|
|
551
|
+
if self.top > 0:
|
|
552
|
+
canvas.draw_text(self.rect.right - MARGIN, first, "\u25b2", muted)
|
|
553
|
+
if self.top + self._view() < len(self._slots):
|
|
554
|
+
canvas.draw_text(self.rect.right - MARGIN, first + self._view() - 1, "\u25bc", muted)
|
|
555
|
+
canvas.draw_text(x, self.rect.bottom - 2, truncate(self._explanation(), room), muted, max_width=room)
|
|
556
|
+
canvas.draw_text(x, self.rect.bottom - 1, truncate(self.KEYS, room), muted, max_width=room)
|
|
557
|
+
|
|
558
|
+
def _explanation(self) -> str:
|
|
559
|
+
"""Return the sentence about the row the cursor is in, or nothing when it is in none."""
|
|
560
|
+
key = self.focus_key()
|
|
561
|
+
return "" if key is None else self._rows[key].hint
|
|
562
|
+
|
|
563
|
+
def cursor(self) -> Point | None:
|
|
564
|
+
"""Forward the cursor of the editor that has it."""
|
|
565
|
+
for row in self._order:
|
|
566
|
+
if isinstance(row, _TextRow) and row.here:
|
|
567
|
+
return row.cursor()
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
# -- keys ------------------------------------------------------------------------
|
|
571
|
+
def refuse(self, host: Host, key: str, reason: str) -> None:
|
|
572
|
+
"""Say why nothing was saved and put the cursor where it can be fixed."""
|
|
573
|
+
self.refusal = reason
|
|
574
|
+
row = self._rows.get(key)
|
|
575
|
+
if row is not None:
|
|
576
|
+
host.focus(row.target)
|
|
577
|
+
|
|
578
|
+
def _required(self, host: Host) -> bool:
|
|
579
|
+
"""Refuse, and say so, when a required row is empty; return whether the page was refused."""
|
|
580
|
+
values = self.values
|
|
581
|
+
for setting in self.settings:
|
|
582
|
+
if setting.required and not values[setting.key]:
|
|
583
|
+
self.refuse(host, setting.key, f"{setting.label.lower()} is required")
|
|
584
|
+
return True
|
|
585
|
+
return False
|
|
586
|
+
|
|
587
|
+
def save(self, host: Host) -> None:
|
|
588
|
+
"""Save every row, unless a required one is empty or the owner refuses."""
|
|
589
|
+
if self._required(host):
|
|
590
|
+
return
|
|
591
|
+
refused = self.on_save(self.values)
|
|
592
|
+
if refused is not None:
|
|
593
|
+
self.refuse(host, *refused)
|
|
594
|
+
|
|
595
|
+
def act(self, action: Action, host: Host) -> None:
|
|
596
|
+
"""Do one of the actions, with the rows' answers when it takes them."""
|
|
597
|
+
if action.saves and self._required(host):
|
|
598
|
+
return
|
|
599
|
+
refused = self.on_action(action, self.values)
|
|
600
|
+
if refused is not None:
|
|
601
|
+
self.refuse(host, *refused)
|
|
602
|
+
|
|
603
|
+
def _move(self, host: Host, step: int) -> None:
|
|
604
|
+
"""Put the cursor in the next or the previous row, going round at either end."""
|
|
605
|
+
here = next((index for index, row in enumerate(self._order) if row.here), None)
|
|
606
|
+
index = 0 if here is None else (here + step) % len(self._order)
|
|
607
|
+
host.focus(self._order[index].target)
|
|
608
|
+
self._reveal()
|
|
609
|
+
self._place()
|
|
610
|
+
|
|
611
|
+
def capture_event(self, event: AnyEvent, host: Host) -> bool:
|
|
612
|
+
"""Take Escape, Enter and the keys that move between rows before the row can."""
|
|
613
|
+
key = event_key(event)
|
|
614
|
+
if key == "escape":
|
|
615
|
+
self.on_cancel()
|
|
616
|
+
return True
|
|
617
|
+
if key == "enter":
|
|
618
|
+
row = next((row for row in self._order if row.here), None)
|
|
619
|
+
if isinstance(row, _ActionRow):
|
|
620
|
+
self.act(row.action, host)
|
|
621
|
+
else:
|
|
622
|
+
self.save(host)
|
|
623
|
+
return True
|
|
624
|
+
if key in ("tab", "down"):
|
|
625
|
+
self._move(host, 1)
|
|
626
|
+
return True
|
|
627
|
+
if key in ("backtab", "up"):
|
|
628
|
+
self._move(host, -1)
|
|
629
|
+
return True
|
|
630
|
+
return False
|
|
631
|
+
|
|
632
|
+
def handle(self, event: AnyEvent, host: Host) -> bool:
|
|
633
|
+
"""Take the wheel over the title and the gaps, as it is taken over a row."""
|
|
634
|
+
return self.bubble_event(event, host)
|
|
635
|
+
|
|
636
|
+
def bubble_event(self, event: AnyEvent, host: Host) -> bool:
|
|
637
|
+
"""Move the cursor a row with the wheel, which no row has a use for."""
|
|
638
|
+
if isinstance(event, MouseEvent) and self.rect.contains(event.x, event.y):
|
|
639
|
+
if event.action is MouseAction.SCROLL_UP:
|
|
640
|
+
self._move(host, -1)
|
|
641
|
+
return True
|
|
642
|
+
if event.action is MouseAction.SCROLL_DOWN:
|
|
643
|
+
self._move(host, 1)
|
|
644
|
+
return True
|
|
645
|
+
return False
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def model_settings_rows(settings: int, actions: int) -> int:
|
|
649
|
+
"""Return the height of a panel that shows every row: the rows, a blank before the actions, and the chrome."""
|
|
650
|
+
return CHROME_ROWS + settings + (actions + 1 if actions else 0)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
__all__ = ["Action", "Answer", "ModelSettingsPage", "Setting", "model_settings_rows"]
|