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.
Files changed (81) hide show
  1. zettcode/app/__init__.py +63 -0
  2. zettcode/app/agent/__init__.py +47 -0
  3. zettcode/app/agent/agent/__init__.py +84 -0
  4. zettcode/app/agent/agent/commands.py +269 -0
  5. zettcode/app/agent/agent/settings.py +132 -0
  6. zettcode/app/agent/conversation/__init__.py +7 -0
  7. zettcode/app/agent/conversation/changes.py +64 -0
  8. zettcode/app/agent/conversation/effort_gauge.py +126 -0
  9. zettcode/app/agent/conversation/projection.py +265 -0
  10. zettcode/app/agent/conversation/rendering.py +837 -0
  11. zettcode/app/agent/conversation/replay.py +198 -0
  12. zettcode/app/agent/conversation/shimmer.py +256 -0
  13. zettcode/app/agent/conversation/stops.py +57 -0
  14. zettcode/app/agent/conversation/tips.py +86 -0
  15. zettcode/app/agent/conversation/transcript/__init__.py +80 -0
  16. zettcode/app/agent/conversation/transcript/fold.py +57 -0
  17. zettcode/app/agent/conversation/transcript/log.py +182 -0
  18. zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
  19. zettcode/app/agent/conversation/transcript/stream.py +187 -0
  20. zettcode/app/agent/conversation/transcript/turn.py +323 -0
  21. zettcode/app/commandline/__init__.py +47 -0
  22. zettcode/app/commandline/arguments.py +169 -0
  23. zettcode/app/commandline/completion.py +262 -0
  24. zettcode/app/commandline/history.py +305 -0
  25. zettcode/app/commandline/runner.py +365 -0
  26. zettcode/app/errors.py +12 -0
  27. zettcode/app/plugins/__init__.py +110 -0
  28. zettcode/app/plugins/builtins.py +33 -0
  29. zettcode/app/plugins/groups.py +503 -0
  30. zettcode/app/plugins/loader.py +268 -0
  31. zettcode/app/py.typed +0 -0
  32. zettcode/app/ui/__init__.py +63 -0
  33. zettcode/app/ui/app.py +1507 -0
  34. zettcode/app/ui/bars.py +290 -0
  35. zettcode/app/ui/boot.py +131 -0
  36. zettcode/app/ui/clipboard.py +340 -0
  37. zettcode/app/ui/commands.py +323 -0
  38. zettcode/app/ui/config_form.py +343 -0
  39. zettcode/app/ui/demo.py +180 -0
  40. zettcode/app/ui/followups.py +430 -0
  41. zettcode/app/ui/gc_policy.py +147 -0
  42. zettcode/app/ui/keys.py +462 -0
  43. zettcode/app/ui/messaging.py +260 -0
  44. zettcode/app/ui/notices.py +195 -0
  45. zettcode/app/ui/providers.py +626 -0
  46. zettcode/app/ui/sessions.py +422 -0
  47. zettcode/app/ui/settings.py +161 -0
  48. zettcode/app/ui/shell.py +361 -0
  49. zettcode/app/ui/turns.py +115 -0
  50. zettcode/app/ui/updating.py +204 -0
  51. zettcode/app/ui/widgets/__init__.py +161 -0
  52. zettcode/app/ui/widgets/approval_page.py +284 -0
  53. zettcode/app/ui/widgets/ask_page.py +386 -0
  54. zettcode/app/ui/widgets/bash_page.py +90 -0
  55. zettcode/app/ui/widgets/completer.py +100 -0
  56. zettcode/app/ui/widgets/composer.py +529 -0
  57. zettcode/app/ui/widgets/document_page.py +159 -0
  58. zettcode/app/ui/widgets/effort_page.py +294 -0
  59. zettcode/app/ui/widgets/follow_up.py +168 -0
  60. zettcode/app/ui/widgets/form_page.py +380 -0
  61. zettcode/app/ui/widgets/input_page.py +175 -0
  62. zettcode/app/ui/widgets/model_page.py +63 -0
  63. zettcode/app/ui/widgets/model_settings_page.py +653 -0
  64. zettcode/app/ui/widgets/pet.py +247 -0
  65. zettcode/app/ui/widgets/pet_page.py +347 -0
  66. zettcode/app/ui/widgets/plugins_page.py +442 -0
  67. zettcode/app/ui/widgets/root.py +68 -0
  68. zettcode/app/ui/widgets/self_compact_page.py +363 -0
  69. zettcode/app/ui/widgets/send_kind_page.py +193 -0
  70. zettcode/app/ui/widgets/sessions_page.py +511 -0
  71. zettcode/app/ui/widgets/statusline_page.py +427 -0
  72. zettcode/app/ui/widgets/steering.py +126 -0
  73. zettcode/app/ui/widgets/transcript.py +287 -0
  74. zettcode/app/ui/widgets/update_page.py +65 -0
  75. zettcode/app/ui/widgets/welcome.py +24 -0
  76. zettcode/collector.py +54 -0
  77. zettcode/update.py +519 -0
  78. zettcode_app-0.4.2.dist-info/METADATA +26 -0
  79. zettcode_app-0.4.2.dist-info/RECORD +81 -0
  80. zettcode_app-0.4.2.dist-info/WHEEL +4 -0
  81. 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
+ )