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,380 @@
|
|
|
1
|
+
"""The bottom panel that asks for several lines at once: Tab moves between them and Enter saves them all."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from zettcode.tui import (
|
|
9
|
+
PROMPT,
|
|
10
|
+
SEPARATOR,
|
|
11
|
+
AnyEvent,
|
|
12
|
+
Canvas,
|
|
13
|
+
Host,
|
|
14
|
+
MouseAction,
|
|
15
|
+
MouseEvent,
|
|
16
|
+
Rect,
|
|
17
|
+
RichText,
|
|
18
|
+
Span,
|
|
19
|
+
Style,
|
|
20
|
+
Text,
|
|
21
|
+
TextArea,
|
|
22
|
+
TextLine,
|
|
23
|
+
VBox,
|
|
24
|
+
Widget,
|
|
25
|
+
event_key,
|
|
26
|
+
)
|
|
27
|
+
from zettcode.tui.core.geometry import Constraints, Point
|
|
28
|
+
from zettcode.tui.layout import Slot
|
|
29
|
+
from zettcode.tui.render import display_width
|
|
30
|
+
|
|
31
|
+
#: What stands in for each character of a secret, so the reader sees that something was typed.
|
|
32
|
+
MASK = "\u2022"
|
|
33
|
+
|
|
34
|
+
#: The corners and rules of a field's box.
|
|
35
|
+
BOX_TOP_LEFT, BOX_TOP_RIGHT = "\u256d", "\u256e"
|
|
36
|
+
BOX_BOTTOM_LEFT, BOX_BOTTOM_RIGHT = "\u2570", "\u256f"
|
|
37
|
+
BOX_HORIZONTAL, BOX_VERTICAL = "\u2500", "\u2502"
|
|
38
|
+
|
|
39
|
+
#: Rows a boxed line takes (the top border with its label, the text, the bottom border) and a bare one.
|
|
40
|
+
BOXED_ROWS = 3
|
|
41
|
+
BARE_ROWS = 1
|
|
42
|
+
|
|
43
|
+
#: Rows besides the lines: the title, the reason a save was refused, two for the hint of the line the cursor is in (it
|
|
44
|
+
#: wraps rather than being cut off), and the keys. A form that is given a note has one more.
|
|
45
|
+
FORM_CHROME_ROWS = 5
|
|
46
|
+
|
|
47
|
+
#: The gap between a bare line's label and its editor, and the width of the marker that stands before the label of
|
|
48
|
+
#: the line the cursor is in.
|
|
49
|
+
LABEL_GAP = 2
|
|
50
|
+
MARKER_WIDTH = 2
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class Field:
|
|
55
|
+
"""One line of a form.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
key: What the line is called in the answers handed to the form's owner.
|
|
59
|
+
label: What the reader sees beside the line.
|
|
60
|
+
initial: The text it starts with.
|
|
61
|
+
placeholder: Shown, muted, while the line is empty.
|
|
62
|
+
hint: One sentence shown under the form while the cursor is in this line.
|
|
63
|
+
required: Refuse a save while the line is empty; the label then carries a star.
|
|
64
|
+
secret: Paint the line as bullets, for a token.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
key: str
|
|
68
|
+
label: str
|
|
69
|
+
initial: str = ""
|
|
70
|
+
placeholder: str = ""
|
|
71
|
+
hint: str = ""
|
|
72
|
+
required: bool = False
|
|
73
|
+
secret: bool = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LineArea(TextArea):
|
|
77
|
+
"""A :class:`TextArea` that holds one line: no newline can be typed or pasted into it.
|
|
78
|
+
|
|
79
|
+
A key is not a paragraph. A paste that carries the newline it was copied with (a token selected by triple click
|
|
80
|
+
takes it along) would otherwise leave a second row in a field that has room for one, and the value saved with a
|
|
81
|
+
line break inside it.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
NEWLINE_KEYS: tuple[str, ...] = ()
|
|
85
|
+
|
|
86
|
+
def paste(self, text: str) -> None:
|
|
87
|
+
"""Insert pasted text with its line breaks removed."""
|
|
88
|
+
self._insert(text.replace("\r", "").replace("\n", ""))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SecretLine(LineArea):
|
|
92
|
+
"""A :class:`LineArea` that paints a bullet for every character instead of the character.
|
|
93
|
+
|
|
94
|
+
Only the painting is changed; the text, the cursor and the editing keys are the editor's own, so a paste,
|
|
95
|
+
Backspace and Ctrl-U work as they do anywhere, and what is saved is what was typed.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def spans_for(self, line: str, start: int, body: Style) -> tuple[Span, ...]:
|
|
99
|
+
"""Paint the row as bullets."""
|
|
100
|
+
return (Span(MASK * len(line), body),)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class FieldBox(Widget):
|
|
104
|
+
"""One line of a form: an editor inside a box that carries the line's label.
|
|
105
|
+
|
|
106
|
+
Shape, boxed (three rows), the box bright and the label bold while the cursor is in it::
|
|
107
|
+
|
|
108
|
+
╭─ Model id * ─────────────────────────────╮
|
|
109
|
+
│ deepseek-chat │
|
|
110
|
+
╰──────────────────────────────────────╯
|
|
111
|
+
|
|
112
|
+
Shape, bare (one row), for a terminal too short for boxes: the label, then the editor on a band of its own::
|
|
113
|
+
|
|
114
|
+
› Model id * deepseek-chat
|
|
115
|
+
|
|
116
|
+
A press anywhere on the box, its border included, puts the cursor in the editor.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, form: FormPage, field: Field, area: LineArea) -> None:
|
|
120
|
+
"""Hold one editor for one line of ``form``."""
|
|
121
|
+
super().__init__()
|
|
122
|
+
self.form = form
|
|
123
|
+
self.field = field
|
|
124
|
+
self.area = area
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def children(self) -> tuple[Widget, ...]:
|
|
128
|
+
"""Expose the editor."""
|
|
129
|
+
return (self.area,)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def here(self) -> bool:
|
|
133
|
+
"""Return whether the cursor is in this line."""
|
|
134
|
+
return self.area.focused
|
|
135
|
+
|
|
136
|
+
def layout(self, rect: Rect) -> None:
|
|
137
|
+
"""Put the editor inside the border, or after the label when there is no room for a border."""
|
|
138
|
+
super().layout(rect)
|
|
139
|
+
if self.form.boxed:
|
|
140
|
+
self.area.layout(Rect(rect.x + 2, rect.y + 1, max(0, rect.width - 4), 1))
|
|
141
|
+
else:
|
|
142
|
+
column = self.form.column
|
|
143
|
+
self.area.layout(Rect(rect.x + column, rect.y, max(0, rect.width - column), 1))
|
|
144
|
+
|
|
145
|
+
def render(self, canvas: Canvas) -> None:
|
|
146
|
+
"""Draw the box and its label, or the bare label and the band, then the editor."""
|
|
147
|
+
if self.rect.empty:
|
|
148
|
+
return
|
|
149
|
+
theme = self.theme
|
|
150
|
+
label = self.form.label_of(self.field)
|
|
151
|
+
if self.form.boxed:
|
|
152
|
+
style = Style(foreground=theme.accent_bright if self.here else theme.muted)
|
|
153
|
+
width, x, y = self.rect.width, self.rect.x, self.rect.y
|
|
154
|
+
rule = BOX_HORIZONTAL * max(0, width - 2)
|
|
155
|
+
canvas.draw_text(x, y, f"{BOX_TOP_LEFT}{rule}{BOX_TOP_RIGHT}", style)
|
|
156
|
+
canvas.draw_text(x, y + 1, BOX_VERTICAL, style)
|
|
157
|
+
canvas.draw_text(x + width - 1, y + 1, BOX_VERTICAL, style)
|
|
158
|
+
canvas.draw_text(x, y + 2, f"{BOX_BOTTOM_LEFT}{rule}{BOX_BOTTOM_RIGHT}", style)
|
|
159
|
+
if width > 6:
|
|
160
|
+
words = Style(foreground=theme.accent_bright if self.here else theme.subtle, bold=self.here)
|
|
161
|
+
canvas.draw_text(x + 2, y, f" {label} ", words, max_width=width - 4)
|
|
162
|
+
else:
|
|
163
|
+
marker = f"{PROMPT} " if self.here else " "
|
|
164
|
+
words = Style(foreground=theme.accent_bright if self.here else theme.muted, bold=self.here)
|
|
165
|
+
canvas.draw_text(self.rect.x, self.rect.y, marker + label, words, max_width=self.form.column)
|
|
166
|
+
canvas.fill(self.area.rect.x, self.area.rect.y, self.area.rect.width, 1, Style(background=theme.surface))
|
|
167
|
+
self.area.render(canvas)
|
|
168
|
+
|
|
169
|
+
def handle(self, event: AnyEvent, host: Host) -> bool:
|
|
170
|
+
"""Take a press on the border or the label: the editor takes one inside it for itself."""
|
|
171
|
+
if isinstance(event, MouseEvent) and event.action is MouseAction.DOWN and self.rect.contains(event.x, event.y):
|
|
172
|
+
host.focus(self.area)
|
|
173
|
+
return True
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
def cursor(self) -> Point | None:
|
|
177
|
+
"""Forward the editor's cursor."""
|
|
178
|
+
return self.area.cursor()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class FormPage(VBox):
|
|
182
|
+
"""Ask for several lines on one page, and refuse a save that will not do.
|
|
183
|
+
|
|
184
|
+
Shape::
|
|
185
|
+
|
|
186
|
+
Add a model at DeepSeek
|
|
187
|
+
● address is required <- why the last save was refused
|
|
188
|
+
╭─ Model id * ─────────────────────────────╮
|
|
189
|
+
│ deepseek-chat │
|
|
190
|
+
╰──────────────────────────────────────╯
|
|
191
|
+
╭─ Token ───────────────────────────────╮
|
|
192
|
+
│ │
|
|
193
|
+
╰──────────────────────────────────────╯
|
|
194
|
+
The id the provider calls the model, exactly as its documentation writes it.
|
|
195
|
+
tab next line · enter save · esc back
|
|
196
|
+
|
|
197
|
+
Every line is a box, bright while the cursor is in it, so it reads as something to type into. A terminal too
|
|
198
|
+
short for the boxes (see :func:`form_rows`) gets one row per line instead, the editor on a band of its own.
|
|
199
|
+
|
|
200
|
+
``on_submit`` is given every line's trimmed text by key and answers ``None`` when it took them, or ``(key, sentence)``
|
|
201
|
+
when it did not; the sentence is shown above the lines, the cursor goes to the line it names, and every line stays as
|
|
202
|
+
typed, so a slip costs one correction rather than the whole entry.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
title: What is being asked, in a few words.
|
|
206
|
+
fields: The lines, top to bottom.
|
|
207
|
+
on_submit: Receives the trimmed answers; returns ``None`` to accept or a refusal.
|
|
208
|
+
on_cancel: Called on Escape.
|
|
209
|
+
note: One line under the title that says how to use the form; none by default, because the keys line does.
|
|
210
|
+
cancel_label: What the keys line says Escape does.
|
|
211
|
+
save_label: What the keys line says Enter does.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
def __init__(
|
|
215
|
+
self,
|
|
216
|
+
title: str,
|
|
217
|
+
fields: Sequence[Field],
|
|
218
|
+
*,
|
|
219
|
+
on_submit: Callable[[dict[str, str]], tuple[str, str] | None],
|
|
220
|
+
on_cancel: Callable[[], None],
|
|
221
|
+
note: str = "",
|
|
222
|
+
cancel_label: str = "esc back",
|
|
223
|
+
save_label: str = "enter save",
|
|
224
|
+
) -> None:
|
|
225
|
+
"""Build the form with the cursor in the first line."""
|
|
226
|
+
if not fields:
|
|
227
|
+
raise ValueError("A form needs at least one line")
|
|
228
|
+
self.title = title
|
|
229
|
+
self.fields = tuple(fields)
|
|
230
|
+
self.note = note
|
|
231
|
+
self.on_submit = on_submit
|
|
232
|
+
self.on_cancel = on_cancel
|
|
233
|
+
#: Why the last save was refused; empty until one is, and again as soon as the reader types.
|
|
234
|
+
self.refusal = ""
|
|
235
|
+
#: Whether the lines are drawn in boxes; decided at each layout, by whether the panel is tall enough.
|
|
236
|
+
self.boxed = True
|
|
237
|
+
self.areas: dict[str, LineArea] = {}
|
|
238
|
+
for field in self.fields:
|
|
239
|
+
area = (SecretLine if field.secret else LineArea)(
|
|
240
|
+
prompt="", placeholder=field.placeholder, max_height=1, on_change=self._edited
|
|
241
|
+
)
|
|
242
|
+
if field.initial:
|
|
243
|
+
area.set_text(field.initial)
|
|
244
|
+
self.areas[field.key] = area
|
|
245
|
+
#: Where a bare line's editor starts: after the marker, the longest label, and the gap.
|
|
246
|
+
self.column = max(display_width(self.label_of(field)) for field in self.fields) + MARKER_WIDTH + LABEL_GAP
|
|
247
|
+
self._heading = RichText(self._heading_lines)
|
|
248
|
+
self._hint = RichText(self._hint_line, wrap=True)
|
|
249
|
+
self._keys = Text(f"tab next line {SEPARATOR} {save_label} {SEPARATOR} {cancel_label}", muted=True)
|
|
250
|
+
rows: list[Slot] = [
|
|
251
|
+
Slot(self._heading, size=lambda width: self._heading.measure(Constraints(max_width=width)).height)
|
|
252
|
+
]
|
|
253
|
+
for field in self.fields:
|
|
254
|
+
rows.append(Slot(FieldBox(self, field, self.areas[field.key]), size=lambda width: self.line_rows))
|
|
255
|
+
rows += [Slot(self._hint, size=2), Slot(self._keys, size=1)]
|
|
256
|
+
super().__init__(rows)
|
|
257
|
+
|
|
258
|
+
# -- geometry --------------------------------------------------------------------
|
|
259
|
+
@property
|
|
260
|
+
def line_rows(self) -> int:
|
|
261
|
+
"""Return the rows one line takes now: three in a box, one without."""
|
|
262
|
+
return BOXED_ROWS if self.boxed else BARE_ROWS
|
|
263
|
+
|
|
264
|
+
def layout(self, rect: Rect) -> None:
|
|
265
|
+
"""Choose boxes when the panel is tall enough for all of them, and lay the lines out."""
|
|
266
|
+
self.boxed = rect.height >= form_rows(len(self.fields), note=bool(self.note))
|
|
267
|
+
super().layout(rect)
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def label_of(field: Field) -> str:
|
|
271
|
+
"""Return a line's label, starred when the line is required."""
|
|
272
|
+
return f"{field.label} *" if field.required else field.label
|
|
273
|
+
|
|
274
|
+
# -- painting --------------------------------------------------------------------
|
|
275
|
+
def render(self, canvas: Canvas) -> None:
|
|
276
|
+
"""Fill the panel first: a page over the conversation owes the screen a surface.
|
|
277
|
+
|
|
278
|
+
Without the fill whatever the frame underneath drew in these cells shows through the gaps between the panel's
|
|
279
|
+
own text.
|
|
280
|
+
"""
|
|
281
|
+
canvas.fill(
|
|
282
|
+
self.rect.x, self.rect.y, self.rect.width, self.rect.height, Style(background=self.theme.surface_alt)
|
|
283
|
+
)
|
|
284
|
+
super().render(canvas)
|
|
285
|
+
|
|
286
|
+
def _heading_lines(self) -> tuple[TextLine, ...]:
|
|
287
|
+
"""Return the title, how to use the form if it says, and the reason the last save was refused (a blank row if none)."""
|
|
288
|
+
theme = self.theme
|
|
289
|
+
lines = [TextLine((Span(self.title, Style(foreground=theme.text, bold=True)),))]
|
|
290
|
+
if self.note:
|
|
291
|
+
lines.append(TextLine((Span(self.note, Style(foreground=theme.muted)),)))
|
|
292
|
+
lines.append(TextLine((Span(self.refusal, Style(foreground=theme.warning)),)) if self.refusal else TextLine())
|
|
293
|
+
return tuple(lines)
|
|
294
|
+
|
|
295
|
+
def _hint_line(self) -> TextLine:
|
|
296
|
+
"""Return the hint of the line the cursor is in, or nothing when it is in none."""
|
|
297
|
+
for field in self.fields:
|
|
298
|
+
if self.areas[field.key].focused and field.hint:
|
|
299
|
+
return TextLine((Span(field.hint, Style(foreground=self.theme.muted)),))
|
|
300
|
+
return TextLine()
|
|
301
|
+
|
|
302
|
+
def _edited(self) -> None:
|
|
303
|
+
"""Forget the refusal once the reader starts correcting it."""
|
|
304
|
+
self.refusal = ""
|
|
305
|
+
|
|
306
|
+
# -- the answers -----------------------------------------------------------------
|
|
307
|
+
@property
|
|
308
|
+
def values(self) -> dict[str, str]:
|
|
309
|
+
"""Return every line's trimmed text by key."""
|
|
310
|
+
return {key: area.text.strip() for key, area in self.areas.items()}
|
|
311
|
+
|
|
312
|
+
def focus_key(self) -> str | None:
|
|
313
|
+
"""Return the key of the line the cursor is in, if it is in one."""
|
|
314
|
+
return next((key for key, area in self.areas.items() if area.focused), None)
|
|
315
|
+
|
|
316
|
+
def _refuse(self, host: Host, key: str, reason: str) -> None:
|
|
317
|
+
"""Say why nothing was saved and put the cursor where it can be fixed."""
|
|
318
|
+
self.refusal = reason
|
|
319
|
+
area = self.areas.get(key)
|
|
320
|
+
if area is not None:
|
|
321
|
+
host.focus(area)
|
|
322
|
+
|
|
323
|
+
def _submit(self, host: Host) -> None:
|
|
324
|
+
"""Save, unless a required line is empty or the owner refuses; never clears a line."""
|
|
325
|
+
values = self.values
|
|
326
|
+
for field in self.fields:
|
|
327
|
+
if field.required and not values[field.key]:
|
|
328
|
+
self._refuse(host, field.key, f"{field.label.lower()} is required")
|
|
329
|
+
return
|
|
330
|
+
refused = self.on_submit(values)
|
|
331
|
+
if refused is not None:
|
|
332
|
+
self._refuse(host, *refused)
|
|
333
|
+
|
|
334
|
+
def _move(self, host: Host, step: int) -> None:
|
|
335
|
+
"""Put the cursor in the next or the previous line, going round at either end."""
|
|
336
|
+
keys = [field.key for field in self.fields]
|
|
337
|
+
here = self.focus_key()
|
|
338
|
+
index = (keys.index(here) + step) % len(keys) if here in keys else 0
|
|
339
|
+
host.focus(self.areas[keys[index]])
|
|
340
|
+
|
|
341
|
+
def capture_event(self, event: AnyEvent, host: Host) -> bool:
|
|
342
|
+
"""Take Escape, Enter and the keys that move between lines before the editor can.
|
|
343
|
+
|
|
344
|
+
Up and Down move between lines as Tab and Shift-Tab do: in a one-line editor they have nothing else to do
|
|
345
|
+
(the history of what was typed elsewhere is not wanted here).
|
|
346
|
+
"""
|
|
347
|
+
key = event_key(event)
|
|
348
|
+
if key == "escape":
|
|
349
|
+
self.on_cancel()
|
|
350
|
+
return True
|
|
351
|
+
if key == "enter":
|
|
352
|
+
self._submit(host)
|
|
353
|
+
return True
|
|
354
|
+
if key in ("tab", "down"):
|
|
355
|
+
self._move(host, 1)
|
|
356
|
+
return True
|
|
357
|
+
if key in ("backtab", "up"):
|
|
358
|
+
self._move(host, -1)
|
|
359
|
+
return True
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
@property
|
|
363
|
+
def focusable(self) -> bool:
|
|
364
|
+
"""Leave focus to the lines inside."""
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
def preferred_height(self, width: int) -> int:
|
|
368
|
+
"""Return how many rows the panel needs."""
|
|
369
|
+
return self.measure(Constraints(max_width=width)).height
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def form_rows(fields: int, *, note: bool = False) -> int:
|
|
373
|
+
"""Return the height of a panel whose lines are all in boxes: three rows each, and the rows around them.
|
|
374
|
+
|
|
375
|
+
A panel given fewer rows than this (a short terminal) draws its lines bare, one row each.
|
|
376
|
+
"""
|
|
377
|
+
return FORM_CHROME_ROWS + (1 if note else 0) + BOXED_ROWS * fields
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
__all__ = ["FORM_CHROME_ROWS", "Field", "FieldBox", "FormPage", "LineArea", "SecretLine", "form_rows"]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""The bottom panel that asks for one line of text: an address, a model id, a token."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from zettcode.tui import (
|
|
8
|
+
PROMPT,
|
|
9
|
+
SEPARATOR,
|
|
10
|
+
AnyEvent,
|
|
11
|
+
Canvas,
|
|
12
|
+
Host,
|
|
13
|
+
RichText,
|
|
14
|
+
Span,
|
|
15
|
+
Style,
|
|
16
|
+
Text,
|
|
17
|
+
TextArea,
|
|
18
|
+
TextLine,
|
|
19
|
+
VBox,
|
|
20
|
+
event_key,
|
|
21
|
+
)
|
|
22
|
+
from zettcode.tui.core.geometry import Constraints
|
|
23
|
+
from zettcode.tui.layout import Slot
|
|
24
|
+
|
|
25
|
+
#: What stands in for each character of a secret, so the reader sees that something was typed.
|
|
26
|
+
MASK = "\u2022"
|
|
27
|
+
|
|
28
|
+
#: The rows the panel takes: the title, up to three lines of help, the note, the line itself and the keys.
|
|
29
|
+
INPUT_ROWS = 7
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SecretArea(TextArea):
|
|
33
|
+
"""A :class:`TextArea` that paints a bullet for every character instead of the character.
|
|
34
|
+
|
|
35
|
+
Only the painting is changed. The text, the cursor and the editing keys are the editor's own, so
|
|
36
|
+
a paste, Backspace and Ctrl-U work as they do anywhere, and what is submitted is what was typed.
|
|
37
|
+
A token is ASCII, so a bullet is as wide as the character it hides and the cursor stays under it.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def spans_for(self, line: str, start: int, body: Style) -> tuple[Span, ...]:
|
|
41
|
+
"""Paint the row as bullets."""
|
|
42
|
+
return (Span(MASK * len(line), body),)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class InputPage(VBox):
|
|
46
|
+
"""Ask for one line, say what it is for, and refuse an answer that will not do.
|
|
47
|
+
|
|
48
|
+
Shape::
|
|
49
|
+
|
|
50
|
+
Token for DeepSeek
|
|
51
|
+
The key from platform.deepseek.com; it is written to ~/.zettcode/config.toml.
|
|
52
|
+
● that is not an address <- why the last answer was refused
|
|
53
|
+
› ••••••••••••
|
|
54
|
+
enter confirm · esc cancel
|
|
55
|
+
|
|
56
|
+
``on_submit`` is given the trimmed text and answers ``None`` when it took it, or a sentence
|
|
57
|
+
when it did not; the sentence is shown above the line and the text stays, so a typing slip costs
|
|
58
|
+
one correction rather than the whole entry.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
title: What is being asked, in a few words.
|
|
62
|
+
on_submit: Receives the trimmed answer; returns ``None`` to accept or a reason to refuse.
|
|
63
|
+
on_cancel: Called on Escape.
|
|
64
|
+
help_lines: Up to three short lines saying what a good answer looks like.
|
|
65
|
+
secret: Paint the answer as bullets, for a token.
|
|
66
|
+
initial: Text to start with, for editing something that is already set.
|
|
67
|
+
placeholder: Shown while the line is empty.
|
|
68
|
+
allow_empty: Accept an empty answer, which ``on_submit`` receives as ``""``.
|
|
69
|
+
cancel_label: What the keys line says Escape does: "esc back" when there is a step behind.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
title: str,
|
|
75
|
+
*,
|
|
76
|
+
on_submit: Callable[[str], str | None],
|
|
77
|
+
on_cancel: Callable[[], None],
|
|
78
|
+
help_lines: tuple[str, ...] = (),
|
|
79
|
+
secret: bool = False,
|
|
80
|
+
initial: str = "",
|
|
81
|
+
placeholder: str = "",
|
|
82
|
+
allow_empty: bool = False,
|
|
83
|
+
cancel_label: str = "esc cancel",
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Build the panel with the line focused."""
|
|
86
|
+
self.title = title
|
|
87
|
+
self.help_lines = help_lines[:3]
|
|
88
|
+
self.on_submit = on_submit
|
|
89
|
+
self.on_cancel = on_cancel
|
|
90
|
+
self.allow_empty = allow_empty
|
|
91
|
+
self.note = ""
|
|
92
|
+
area_type = SecretArea if secret else TextArea
|
|
93
|
+
self.answer = area_type(
|
|
94
|
+
prompt=f"{PROMPT} ",
|
|
95
|
+
placeholder=placeholder,
|
|
96
|
+
max_height=3,
|
|
97
|
+
on_change=self._edited,
|
|
98
|
+
)
|
|
99
|
+
if initial:
|
|
100
|
+
self.answer.set_text(initial)
|
|
101
|
+
self._heading = RichText(self._heading_lines)
|
|
102
|
+
self._keys = Text(f"enter confirm {SEPARATOR} {cancel_label}", muted=True)
|
|
103
|
+
super().__init__(
|
|
104
|
+
[
|
|
105
|
+
Slot(self._heading, size=lambda width: self._heading.measure(Constraints(max_width=width)).height),
|
|
106
|
+
Slot(self.answer, size=lambda width: self.answer.preferred_height(width)),
|
|
107
|
+
Slot(self._keys, size=1),
|
|
108
|
+
]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def render(self, canvas: Canvas) -> None:
|
|
112
|
+
"""Fill the panel first: a page over the conversation owes the screen a surface.
|
|
113
|
+
|
|
114
|
+
Without the fill whatever the frame underneath drew in these cells — the status line's
|
|
115
|
+
keys, the end of a word — shows through the gaps between the panel's own text.
|
|
116
|
+
"""
|
|
117
|
+
canvas.fill(
|
|
118
|
+
self.rect.x, self.rect.y, self.rect.width, self.rect.height, Style(background=self.theme.surface_alt)
|
|
119
|
+
)
|
|
120
|
+
super().render(canvas)
|
|
121
|
+
|
|
122
|
+
def _heading_lines(self) -> tuple[TextLine, ...]:
|
|
123
|
+
"""Return the title, the help, and the reason the last answer was refused (a blank row if none)."""
|
|
124
|
+
theme = self.theme
|
|
125
|
+
lines = [TextLine((Span(self.title, Style(foreground=theme.text, bold=True)),))]
|
|
126
|
+
lines += [TextLine((Span(line, Style(foreground=theme.muted)),)) for line in self.help_lines]
|
|
127
|
+
lines.append(TextLine((Span(self.note, Style(foreground=theme.warning)),)) if self.note else TextLine())
|
|
128
|
+
return tuple(lines)
|
|
129
|
+
|
|
130
|
+
def _edited(self) -> None:
|
|
131
|
+
"""Forget the reason once the reader starts correcting it."""
|
|
132
|
+
self.note = ""
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def text(self) -> str:
|
|
136
|
+
"""Return what is typed, trimmed."""
|
|
137
|
+
return self.answer.text.strip()
|
|
138
|
+
|
|
139
|
+
def _submit(self) -> bool:
|
|
140
|
+
"""Hand the answer on; keep it, with the reason, when it is refused. Never clears the line."""
|
|
141
|
+
value = self.text
|
|
142
|
+
if not value and not self.allow_empty:
|
|
143
|
+
self.note = "type something, or Esc to cancel"
|
|
144
|
+
return False
|
|
145
|
+
reason = self.on_submit(value)
|
|
146
|
+
if reason is not None:
|
|
147
|
+
self.note = reason
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
def capture_event(self, event: AnyEvent, host: Host) -> bool:
|
|
151
|
+
"""Take Escape and Enter before the editor can.
|
|
152
|
+
|
|
153
|
+
Enter is taken here rather than left to the editor's own submit because the editor ignores
|
|
154
|
+
an empty line, and an empty answer is sometimes the answer: it clears a setting.
|
|
155
|
+
"""
|
|
156
|
+
key = event_key(event)
|
|
157
|
+
if key == "escape":
|
|
158
|
+
self.on_cancel()
|
|
159
|
+
return True
|
|
160
|
+
if key == "enter":
|
|
161
|
+
self._submit()
|
|
162
|
+
return True
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def focusable(self) -> bool:
|
|
167
|
+
"""Leave focus to the line inside."""
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
def preferred_height(self, width: int) -> int:
|
|
171
|
+
"""Return how many rows the panel needs."""
|
|
172
|
+
return self.measure(Constraints(max_width=width)).height
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
__all__ = ["INPUT_ROWS", "InputPage", "MASK", "SecretArea"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The full-screen model picker opened by ``/model``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from zettcode.sdk.client import Client
|
|
9
|
+
from zettcode.sdk.types import ModelInfo
|
|
10
|
+
from zettcode.tui import SEPARATOR, ListItem, ListPage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ModelPage(ListPage):
|
|
14
|
+
"""The configured models as a selectable :class:`ListPage`.
|
|
15
|
+
|
|
16
|
+
Shape::
|
|
17
|
+
|
|
18
|
+
Select Model
|
|
19
|
+
> 1. GPT-4o (current) gpt-4o \u00b7 chat \u00b7 multimodal
|
|
20
|
+
2. DeepSeek Chat deepseek-chat \u00b7 chat \u00b7 text
|
|
21
|
+
10. Claude Sonnet 4.5 claude-sonnet-4-5 \u00b7 anthropic \u00b7 text
|
|
22
|
+
enter select \u00b7 esc back
|
|
23
|
+
|
|
24
|
+
The page only maps models to rows; the title, background, bounded scrolling,
|
|
25
|
+
and Escape handling come from the base class. Row numbers are right-aligned to
|
|
26
|
+
the widest of them, as the session picker's are, so a name starts in the same
|
|
27
|
+
column on the tenth row as on the first. Each row carries the
|
|
28
|
+
:class:`ModelInfo` itself, because the same model id can be configured
|
|
29
|
+
twice against different endpoints and the chosen entry, not its name, is
|
|
30
|
+
what the shell applies. A row names the API its entry speaks as well,
|
|
31
|
+
which is the one thing about a model a reader cannot infer from its name.
|
|
32
|
+
The command wraps it in a bottom panel, so the conversation stays visible
|
|
33
|
+
while the model is chosen.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self, client: Client, *, on_select: Callable[[ModelInfo], None], on_cancel: Callable[[], None]
|
|
38
|
+
) -> None:
|
|
39
|
+
"""List every configured model, starting on the active one.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
client: Supplies the models and marks which one is active.
|
|
43
|
+
on_select: Receives the chosen entry; the same model id can appear
|
|
44
|
+
twice with different endpoints, so the whole entry is passed.
|
|
45
|
+
on_cancel: Called when the user presses Escape.
|
|
46
|
+
"""
|
|
47
|
+
models = client.models
|
|
48
|
+
# As many columns as the largest number needs, so the names line up.
|
|
49
|
+
digits = len(str(len(models))) if models else 1
|
|
50
|
+
super().__init__(
|
|
51
|
+
[
|
|
52
|
+
ListItem(
|
|
53
|
+
entry,
|
|
54
|
+
f"{index:>{digits}}. {entry.name}{' (current)' if entry.active else ''}",
|
|
55
|
+
f"{entry.model} {SEPARATOR} {entry.api} {SEPARATOR} {'multimodal' if entry.multimodal else 'text'}",
|
|
56
|
+
)
|
|
57
|
+
for index, entry in enumerate(models, start=1)
|
|
58
|
+
],
|
|
59
|
+
title="Select Model",
|
|
60
|
+
on_select=lambda item: on_select(cast(ModelInfo, item.value)),
|
|
61
|
+
on_cancel=on_cancel,
|
|
62
|
+
selected=next((i for i, entry in enumerate(models) if entry.active), 0),
|
|
63
|
+
)
|