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,363 @@
1
+ """The bottom panel ``/self-compact`` opens: two rails, one for this session and one for every session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from time import monotonic
7
+
8
+ from zettcode.plugin_api.panels import pressed
9
+ from zettcode.plugin_api.switch import Rail
10
+ from zettcode.tui import SEPARATOR, Canvas, Host, MouseAction, MouseEvent, Theme, Widget
11
+ from zettcode.tui.render import Style, blend, display_width, truncate
12
+
13
+ #: Rows the panel takes: a blank, the title, a blank, the session rail and its labels, a blank, the default
14
+ #: rail and its labels, a blank, what is in effect, the note, and the keys.
15
+ SELF_COMPACT_ROWS = 12
16
+
17
+ #: Columns kept free at the right of a rail so the last label stays inside the panel.
18
+ RAIL_MARGIN = 5
19
+
20
+ #: Columns the row names take, and the least a rail may be left with before the names shrink to make room.
21
+ NAME_WIDTH = 15
22
+ MIN_RAIL = 12
23
+
24
+ #: Characters of a rail: a lit stretch, the groove it runs in, a node passed, a node ahead, and the knob.
25
+ LIT, GROOVE, PASSED, AHEAD, KNOB = "\u2501", "\u2500", "\u25cf", "\u25cb", "\u25c9"
26
+
27
+ #: What this session can be: no choice (it follows the default), off, or on. ``None`` is the first.
28
+ SESSION_CHOICES: tuple[bool | None, ...] = (None, False, True)
29
+ SESSION_WORDS = ("default", "off", "on")
30
+ DEFAULT_WORDS = ("off", "on")
31
+
32
+ #: One line per option, shown under the rails for the one the knob is on.
33
+ SESSION_NOTES = {
34
+ "default": "this session follows the default below, whatever it becomes",
35
+ "off": "the model cannot compact its own context in this session",
36
+ "on": "the model may compact its own context in this session",
37
+ }
38
+ DEFAULT_NOTES = {
39
+ "off": "sessions that never chose start with it off",
40
+ "on": "sessions that never chose start with it on",
41
+ }
42
+
43
+
44
+ class SelfCompactPage(Widget):
45
+ """Choose whether the model may compact its own context, for this session and as the default.
46
+
47
+ Shape::
48
+
49
+ Self-compaction in effect here: on
50
+
51
+ \u25b8 This session \u25cf\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u25c9\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25cb
52
+ default (off) off on
53
+ Every session \u25cf\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u25c9
54
+ off on
55
+
56
+ this session chose it \u00b7 sessions that never chose start with it on
57
+ the model may compact its own context in this session
58
+
59
+ \u2191 \u2193 row \u00b7 \u2190 \u2192 set \u00b7 enter apply \u00b7 esc cancel
60
+
61
+ The top rail is this session's own choice: ``default`` means it has none and follows the rail below,
62
+ ``off`` and ``on`` are a choice that stays with the session. The bottom rail is the default for every
63
+ session that never chose, which is what ``[compaction] self`` in the config file says. Moving a knob
64
+ changes nothing: the line under the rails says what would be in effect, and Enter applies both rails
65
+ (the default is written to the config file, the choice is saved with the session). Escape applies
66
+ nothing. The knobs travel the way the effort panel's does, from the clock and not from a frame count.
67
+
68
+ Args:
69
+ choice: This session's own choice, ``None`` when it has none.
70
+ default: The default for every session.
71
+ on_apply: Receives the session's choice and the default when Enter is pressed.
72
+ on_cancel: Called when the reader backs out.
73
+ session_open: Whether this process may change the session. A session another process has open
74
+ keeps what it has, and only the default can be moved.
75
+ """
76
+
77
+ HINT = f"\u2191 \u2193 row {SEPARATOR} \u2190 \u2192 set {SEPARATOR} enter apply {SEPARATOR} esc cancel"
78
+
79
+ def __init__(
80
+ self,
81
+ choice: bool | None,
82
+ default: bool,
83
+ *,
84
+ on_apply: Callable[[bool | None, bool], None],
85
+ on_cancel: Callable[[], None],
86
+ session_open: bool = True,
87
+ ) -> None:
88
+ """Start with both knobs where the settings are now."""
89
+ super().__init__()
90
+ self.session_rail = Rail(SESSION_WORDS, SESSION_CHOICES.index(choice))
91
+ self.default_rail = Rail(DEFAULT_WORDS, int(default))
92
+ self.on_apply = on_apply
93
+ self.on_cancel = on_cancel
94
+ self.session_open = session_open
95
+ #: The row the arrows move: 0 for the session, 1 for the default.
96
+ self.row = 0 if session_open else 1
97
+ self._token = f"self-compact-panel:{id(self)}"
98
+
99
+ @property
100
+ def focusable(self) -> bool:
101
+ """Take focus when the shell pushes the page, so the keys reach it."""
102
+ return True
103
+
104
+ @property
105
+ def choice(self) -> bool | None:
106
+ """Return this session's choice once the knob arrives."""
107
+ return SESSION_CHOICES[self.session_rail.selected]
108
+
109
+ @property
110
+ def default(self) -> bool:
111
+ """Return the default once the knob arrives."""
112
+ return bool(self.default_rail.selected)
113
+
114
+ @property
115
+ def effective(self) -> bool:
116
+ """Return what would be in force in this session if the panel were applied now."""
117
+ return self.default if self.choice is None else self.choice
118
+
119
+ @property
120
+ def changed(self) -> bool:
121
+ """Return whether either knob is anywhere but where it started."""
122
+ return (
123
+ self.session_rail.selected != self.session_rail.start
124
+ or self.default_rail.selected != self.default_rail.start
125
+ )
126
+
127
+ @property
128
+ def moving(self) -> bool:
129
+ """Return whether a knob is still on its way."""
130
+ return self.session_rail.moving or self.default_rail.moving
131
+
132
+ def _now(self) -> float:
133
+ return self.app.clock() if self.app is not None else monotonic()
134
+
135
+ def _active(self) -> Rail:
136
+ return self.session_rail if self.row == 0 else self.default_rail
137
+
138
+ def choose(self, row: int, index: int) -> None:
139
+ """Send one row's knob to an option, and make that the row the arrows move."""
140
+ if row == 0 and not self.session_open:
141
+ return
142
+ self.row = row
143
+ rail = self.session_rail if row == 0 else self.default_rail
144
+ reduced = self.app is not None and self.app.reduced_motion
145
+ if rail.choose(index, self._now(), instant=reduced) and self.app is not None:
146
+ self.app.scheduler.animate(self._token)
147
+
148
+ def on_unmount(self) -> None:
149
+ """Release the frame loop, so a panel put away mid-slide stops costing frames."""
150
+ self.session_rail.moving = self.default_rail.moving = False
151
+ if self.app is not None:
152
+ self.app.scheduler.animate(self._token, active=False)
153
+
154
+ def on_tick(self) -> bool:
155
+ """Hand the frame loop back once both knobs have arrived; say whether the picture is still changing."""
156
+ now = self._now()
157
+ landed = self.session_rail.arrive(now) | self.default_rail.arrive(now)
158
+ if not self.moving and self.app is not None:
159
+ self.app.scheduler.animate(self._token, active=False)
160
+ return self.moving or landed
161
+
162
+ def capture_event(self, event, host: Host) -> bool:
163
+ """Take Escape before the focused widget can."""
164
+ if pressed(event) == "escape":
165
+ self.on_cancel()
166
+ return True
167
+ return False
168
+
169
+ def handle(self, event, host: Host) -> bool:
170
+ """Move between the rows with Up and Down, along one with Left and Right, and apply with Enter."""
171
+ if isinstance(event, MouseEvent):
172
+ return self._handle_mouse(event)
173
+ rail = self._active()
174
+ match pressed(event):
175
+ case "up" | "backtab":
176
+ self.row = 0 if self.session_open else 1
177
+ case "down" | "tab":
178
+ self.row = 1
179
+ case "left":
180
+ self.choose(self.row, rail.selected - 1)
181
+ case "right":
182
+ self.choose(self.row, rail.selected + 1)
183
+ case "home":
184
+ self.choose(self.row, 0)
185
+ case "end":
186
+ self.choose(self.row, len(rail.words) - 1)
187
+ case "enter":
188
+ self.on_apply(self.choice, self.default)
189
+ case _:
190
+ return False
191
+ return True
192
+
193
+ def _handle_mouse(self, event: MouseEvent) -> bool:
194
+ """Send a knob to the option nearest a click on its rail or its labels."""
195
+ if event.action is not MouseAction.DOWN:
196
+ return False
197
+ top = self.rect.y + 3
198
+ for row, first in ((0, top), (1, top + 3)):
199
+ if first <= event.y <= first + 1:
200
+ rail = self.session_rail if row == 0 else self.default_rail
201
+ left, right = self._rail()
202
+ nearest = min(
203
+ range(len(rail.words)), key=lambda index: abs(self._column(rail, index, left, right) - event.x)
204
+ )
205
+ self.choose(row, nearest)
206
+ return True
207
+ return False
208
+
209
+ def _names(self) -> int:
210
+ """Return the columns the row names take: less on a panel too narrow to leave the rails room."""
211
+ room = max(0, self.rect.width - 4 - RAIL_MARGIN)
212
+ return max(0, min(NAME_WIDTH, room - MIN_RAIL))
213
+
214
+ def _rail(self) -> tuple[int, int]:
215
+ """Return the first and last column of a rail."""
216
+ left = self.rect.x + 2 + self._names() + 2
217
+ return left, max(left, self.rect.right - RAIL_MARGIN - 1)
218
+
219
+ @staticmethod
220
+ def _column(rail: Rail, index: float, left: int, right: int) -> int:
221
+ """Return the column a (possibly fractional) option sits at."""
222
+ steps = max(1, len(rail.words) - 1)
223
+ return left + round(index * (right - left) / steps)
224
+
225
+ def _colour(self, theme: Theme, rail: Rail, index: float) -> str:
226
+ """Return the colour of a point on a rail: grey for off, the accent for on, the text for no choice."""
227
+ stops: tuple[str, ...]
228
+ if rail is self.session_rail:
229
+ stops = (theme.text, theme.muted, theme.accent)
230
+ else:
231
+ stops = (theme.muted, theme.accent)
232
+ scaled = min(max(index, 0.0), len(stops) - 1)
233
+ low = min(int(scaled), len(stops) - 2)
234
+ return blend(stops[low], stops[low + 1], scaled - low)
235
+
236
+ def _label(self, rail: Rail, index: int) -> str:
237
+ """Return the words under an option: ``default`` says what it would be, and the old place says ``now``."""
238
+ word = rail.words[index]
239
+ if rail is self.session_rail and index == 0:
240
+ word = f"default ({'on' if self.default else 'off'})"
241
+ if index == rail.start and rail.selected != rail.start:
242
+ word = f"{word} (now)"
243
+ return word
244
+
245
+ def render(self, canvas: Canvas) -> None:
246
+ """Fill the panel, then draw the title, the two rails, what is in effect, the note and the keys."""
247
+ if self.rect.empty:
248
+ return
249
+ theme = self.theme
250
+ surface = theme.surface_alt
251
+ canvas.fill(self.rect.x, self.rect.y, self.rect.width, self.rect.height, Style(background=surface))
252
+ x = self.rect.x + 2
253
+ room = max(0, self.rect.width - 4)
254
+ muted = Style(foreground=theme.muted, background=surface)
255
+ bold = Style(foreground=theme.text, background=surface, bold=True)
256
+ canvas.draw_text(x, self.rect.y + 1, "Self-compaction", bold, max_width=room)
257
+ word = "on" if self.effective else "off"
258
+ # What is in force, as long as there is room beside the title; shorter before it would run into it.
259
+ gap = room - display_width("Self-compaction") - 2
260
+ suffix = " (not applied)" if self.changed else ""
261
+ state = next(
262
+ (
263
+ text
264
+ for text in (f"in effect here: {word}{suffix}", f"in effect here: {word}", word)
265
+ if display_width(text) <= gap
266
+ ),
267
+ "",
268
+ )
269
+ canvas.draw_text(
270
+ self.rect.right - 2 - display_width(state),
271
+ self.rect.y + 1,
272
+ state,
273
+ Style(foreground=theme.accent if self.effective else theme.muted, background=surface, bold=True),
274
+ max_width=room,
275
+ )
276
+ left, right = self._rail()
277
+ now = self._now()
278
+ names = self._names()
279
+ for row, (title, rail) in enumerate(
280
+ (("This session", self.session_rail), ("Every session", self.default_rail))
281
+ ):
282
+ top = self.rect.y + 3 + row * 3
283
+ enabled = row == 1 or self.session_open
284
+ active = row == self.row
285
+ mark = "\u25b8 " if active and enabled else " "
286
+ canvas.draw_text(
287
+ x,
288
+ top,
289
+ truncate(mark + title, names),
290
+ bold if active and enabled else muted,
291
+ max_width=names,
292
+ )
293
+ self._draw_rail(canvas, rail, top, left, right, now, enabled)
294
+ self._draw_labels(canvas, rail, top + 1, left, right, now, enabled)
295
+ summary = self._summary()
296
+ canvas.draw_text(x, self.rect.y + 9, truncate(summary, room), muted, max_width=room)
297
+ canvas.draw_text(x, self.rect.y + 10, truncate(self._note(), room), muted, max_width=room)
298
+ canvas.draw_text(x, self.rect.bottom - 1, truncate(self.HINT, room), muted, max_width=room)
299
+
300
+ def _draw_rail(self, canvas: Canvas, rail: Rail, y: int, left: int, right: int, now: float, enabled: bool) -> None:
301
+ """Draw one rail: lit up to the knob in the colour of where each cell sits, the nodes, and the knob."""
302
+ theme = self.theme
303
+ surface = theme.surface_alt
304
+ position = rail.position(now)
305
+ knob = self._column(rail, position, left, right)
306
+ groove = blend(theme.muted, surface, 0.55)
307
+ dim = 0.0 if enabled else 0.6
308
+ steps = max(1, len(rail.words) - 1)
309
+ for column in range(left, right + 1):
310
+ if column <= knob:
311
+ colour = blend(self._colour(theme, rail, (column - left) / max(1, right - left) * steps), surface, dim)
312
+ canvas.draw_text(column, y, LIT, Style(foreground=colour, background=surface))
313
+ else:
314
+ canvas.draw_text(column, y, GROOVE, Style(foreground=groove, background=surface))
315
+ for index in range(len(rail.words)):
316
+ column = self._column(rail, index, left, right)
317
+ if column < knob:
318
+ colour = blend(self._colour(theme, rail, index), surface, dim)
319
+ canvas.draw_text(column, y, PASSED, Style(foreground=colour, background=surface, bold=True))
320
+ elif column > knob:
321
+ colour = blend(theme.muted, surface, 0.2 + dim)
322
+ canvas.draw_text(column, y, AHEAD, Style(foreground=colour, background=surface))
323
+ colour = blend(blend(self._colour(theme, rail, position), theme.text, 0.35), surface, dim)
324
+ canvas.draw_text(knob, y, KNOB, Style(foreground=colour, background=surface, bold=True))
325
+
326
+ def _draw_labels(
327
+ self, canvas: Canvas, rail: Rail, y: int, left: int, right: int, now: float, enabled: bool
328
+ ) -> None:
329
+ """Write each option under its node, skipping any that would run into one already written.
330
+
331
+ The option the knob is on is written first and never skipped, then the rest from the left, so on a
332
+ narrow panel what is left is the knob's neighbourhood rather than an arbitrary run.
333
+ """
334
+ theme = self.theme
335
+ nearest = min(len(rail.words) - 1, max(0, round(rail.position(now))))
336
+ taken: list[tuple[int, int]] = []
337
+ order = [nearest] + [index for index in range(len(rail.words)) if index != nearest]
338
+ for index in order:
339
+ label = self._label(rail, index)
340
+ width = display_width(label)
341
+ start = self._column(rail, index, left, right) - width // 2
342
+ start = max(self.rect.x + 1, min(start, self.rect.right - 1 - width))
343
+ if any(start < stop + 1 and first < start + width + 1 for first, stop in taken):
344
+ continue
345
+ taken.append((start, start + width))
346
+ on = index == nearest and enabled
347
+ style = Style(foreground=theme.text if on else theme.muted, background=theme.surface_alt, bold=on)
348
+ canvas.draw_text(start, y, label, style, max_width=width)
349
+
350
+ def _summary(self) -> str:
351
+ """Say where what is in force comes from, and what the default is."""
352
+ default = "on" if self.default else "off"
353
+ if self.choice is None:
354
+ return f"following the default, which is {default}"
355
+ return f"this session chose it; the default is {default}"
356
+
357
+ def _note(self) -> str:
358
+ """Return the note for the option the active knob is on."""
359
+ if not self.session_open and self.row == 1:
360
+ return "this session is open in another process, so only the default can change"
361
+ rail = self._active()
362
+ notes = SESSION_NOTES if self.row == 0 else DEFAULT_NOTES
363
+ return notes[rail.words[rail.selected]]
@@ -0,0 +1,193 @@
1
+ """The bottom-panel question a message typed during a request has to answer: steer, or follow up?"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+
8
+ from zettcode._compat import StrEnum
9
+ from zettcode.plugin_api.panels import pressed
10
+ from zettcode.tui import MARKER, PROMPT, SEPARATOR, Canvas, Host, MouseAction, MouseEvent, Widget
11
+ from zettcode.tui.render import Style, truncate
12
+
13
+ from .follow_up import one_line
14
+
15
+
16
+ class SendKind(StrEnum):
17
+ """What a message typed while the agent is working should do.
18
+
19
+ Attributes:
20
+ FOLLOW_UP: Hold it, and send it as a turn of its own when this request ends.
21
+ STEER: Hand it to the agent now; it is adopted at the next model or tool
22
+ boundary and replaces the rest of the current task.
23
+ """
24
+
25
+ FOLLOW_UP = "follow_up"
26
+ STEER = "steer"
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class SendOption:
31
+ """One numbered row of the question.
32
+
33
+ Attributes:
34
+ kind: The answer this row gives.
35
+ key: Key that picks the row outright.
36
+ label: The name of the kind.
37
+ detail: What it does, in a sentence.
38
+ """
39
+
40
+ kind: SendKind
41
+ key: str
42
+ label: str
43
+ detail: str
44
+
45
+
46
+ #: Rows in the order shown. Follow-up comes first and is highlighted first: it is the one
47
+ #: that cannot interrupt anything, so an Enter pressed without reading is the safe answer.
48
+ OPTIONS = (
49
+ SendOption(SendKind.FOLLOW_UP, "f", "Follow up", "send it after this request finishes"),
50
+ SendOption(SendKind.STEER, "s", "Steer", "hand it to the agent now; it replaces the rest of the current task"),
51
+ )
52
+
53
+ #: Rows the panel takes: a blank, the question, a blank, the message, a blank, the two
54
+ #: options, a blank, and the keys line.
55
+ SEND_KIND_ROWS = 9
56
+
57
+
58
+ class SendKindPage(Widget):
59
+ """Ask which kind of message a draft typed during a request is.
60
+
61
+ Shape::
62
+
63
+ You sent a message while ZettCode is working. What should it do?
64
+
65
+ \u203a stop editing and explain the diff <- the draft, one line
66
+
67
+ \u25b8 1. Follow up send it after this request finishes (f)
68
+ 2. Steer hand it to the agent now; it replaces the rest of the current task (s)
69
+
70
+ enter choose \u00b7 esc keep editing the draft
71
+
72
+ The draft is still in the composer while this is up: Escape closes the panel and
73
+ leaves it there, which is what makes asking safe — a reader who pressed Enter too early
74
+ has lost nothing. The two kinds are named with what each does to the running task,
75
+ because "steer" is a word the program uses and the reader may never have met.
76
+
77
+ Args:
78
+ message: The draft, shown so the question is about something visible.
79
+ on_choice: Receives the picked :class:`SendKind`.
80
+ on_cancel: Called when the reader backs out.
81
+ """
82
+
83
+ QUESTION = "You sent a message while ZettCode is working. What should it do?"
84
+ HINT = f"enter choose {SEPARATOR} f follow up {SEPARATOR} s steer {SEPARATOR} esc keep editing the draft"
85
+
86
+ def __init__(
87
+ self,
88
+ message: str,
89
+ *,
90
+ on_choice: Callable[[SendKind], None],
91
+ on_cancel: Callable[[], None],
92
+ ) -> None:
93
+ """Start with the first row highlighted."""
94
+ super().__init__()
95
+ self.message = message
96
+ self.on_choice = on_choice
97
+ self.on_cancel = on_cancel
98
+ self.options = OPTIONS
99
+ self.selected = 0
100
+
101
+ @property
102
+ def focusable(self) -> bool:
103
+ """Take focus when the shell pushes the page, so the keys reach it."""
104
+ return True
105
+
106
+ @property
107
+ def choice(self) -> SendKind:
108
+ """Return the kind the highlighted row would commit."""
109
+ return self.options[self.selected].kind
110
+
111
+ def move(self, amount: int) -> None:
112
+ """Move the highlight, wrapping around the ends."""
113
+ self.selected = (self.selected + amount) % len(self.options)
114
+
115
+ def activate(self) -> None:
116
+ """Hand the highlighted row to the shell."""
117
+ self.on_choice(self.choice)
118
+
119
+ def capture_event(self, event, host: Host) -> bool:
120
+ """Take Escape and a row's own key before anything else sees them."""
121
+ key = pressed(event)
122
+ if key == "escape":
123
+ self.on_cancel()
124
+ return True
125
+ option = next((item for item in self.options if item.key == key), None)
126
+ if option is None:
127
+ return False
128
+ self.on_choice(option.kind)
129
+ return True
130
+
131
+ def handle(self, event, host: Host) -> bool:
132
+ """Move with the arrows, and commit with Enter, a row number, or a click."""
133
+ if isinstance(event, MouseEvent):
134
+ return self._handle_mouse(event)
135
+ key = pressed(event)
136
+ match key:
137
+ case "up":
138
+ self.move(-1)
139
+ case "down":
140
+ self.move(1)
141
+ case "enter":
142
+ self.activate()
143
+ case digit if (
144
+ digit is not None and digit.isascii() and digit.isdigit() and 1 <= int(digit) <= len(self.options)
145
+ ):
146
+ self.selected = int(digit) - 1
147
+ self.activate()
148
+ case _:
149
+ return False
150
+ return True
151
+
152
+ def _handle_mouse(self, event: MouseEvent) -> bool:
153
+ """Commit a clicked row, the way Enter commits the highlighted one."""
154
+ if event.action is not MouseAction.DOWN or not self.rect.contains(event.x, event.y):
155
+ return False
156
+ index = event.y - (self.rect.y + 5)
157
+ if not 0 <= index < len(self.options):
158
+ return False
159
+ self.selected = index
160
+ self.activate()
161
+ return True
162
+
163
+ def render(self, canvas: Canvas) -> None:
164
+ """Fill the panel, then draw the question, the draft, the options, and the keys."""
165
+ if self.rect.empty:
166
+ return
167
+ theme = self.theme
168
+ canvas.fill(self.rect.x, self.rect.y, self.rect.width, self.rect.height, Style(background=theme.surface_alt))
169
+ x = self.rect.x + 2
170
+ room = max(0, self.rect.width - 4)
171
+ canvas.draw_text(
172
+ x, self.rect.y + 1, truncate(self.QUESTION, room), Style(foreground=theme.text, bold=True), max_width=room
173
+ )
174
+ draft = f"{PROMPT} {one_line(self.message)}"
175
+ canvas.draw_text(x, self.rect.y + 3, truncate(draft, room), Style(foreground=theme.accent), max_width=room)
176
+ for index, option in enumerate(self.options):
177
+ self._render_option(canvas, self.rect.y + 5 + index, index, option)
178
+ hint_y = self.rect.y + 5 + len(self.options) + 1
179
+ if hint_y < self.rect.bottom:
180
+ canvas.draw_text(x, hint_y, truncate(self.HINT, room), Style(foreground=theme.muted), max_width=room)
181
+
182
+ def _render_option(self, canvas: Canvas, y: int, index: int, option: SendOption) -> None:
183
+ """Draw one numbered row, banding the highlighted one."""
184
+ theme = self.theme
185
+ selected = index == self.selected
186
+ if selected:
187
+ style = Style(foreground=theme.text, background=theme.selection, bold=True)
188
+ canvas.fill(self.rect.x + 1, y, max(0, self.rect.width - 2), 1, style)
189
+ else:
190
+ style = Style(foreground=theme.text)
191
+ marker = f"{MARKER} " if selected else " "
192
+ text = f"{index + 1}. {option.label:<10}{option.detail} ({option.key})"
193
+ canvas.draw_text(self.rect.x + 2, y, marker + text, style, max_width=max(0, self.rect.width - 4))