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,430 @@
1
+ """Messages typed while the agent is working: steer them in, or hold them as follow-ups.
2
+
3
+ A reader who types during a request means one of two things. Either "change what you are
4
+ doing" — a steering message, which the agent adopts at its next model or tool boundary and
5
+ which replaces the rest of the task — or "when you are done, also do this" — a follow-up,
6
+ which waits and goes out as a turn of its own. The shell asks which, because nothing in the
7
+ text says.
8
+
9
+ The two differ in who holds the message. A steering message is handed to the agent the moment
10
+ it is sent and cannot be taken back; a follow-up stays here, so it is the one that can be
11
+ selected, edited, and put in a different order. That is why this module owns the follow-up
12
+ list and the keys that work on it, and why the steering list stays a plain display.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ from dataclasses import dataclass
19
+ from typing import TYPE_CHECKING
20
+
21
+ from zettcode.app.errors import COMMAND_ERRORS
22
+ from zettcode.prompt import PromptPart, carries_image, mention_parts
23
+ from zettcode.tui import Screen
24
+ from zettcode.tui.render import truncate
25
+
26
+ from .shell import MAX_FOLLOW_UPS, MAX_STEERING, PAGE_SCREEN, ShellState
27
+ from .turns import prompt_pieces
28
+ from .widgets import SEND_KIND_ROWS, SendKind, SendKindPage, bottom_panel
29
+ from .widgets.follow_up import one_line
30
+
31
+ if TYPE_CHECKING: # pragma: no cover - annotations only
32
+ from collections.abc import Sequence
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class FollowUp:
37
+ """One message waiting for the request to end.
38
+
39
+ Attributes:
40
+ text: The message as the reader wrote it, pasted text put back; what the row
41
+ shows and what editing it returns to the composer.
42
+ parts: The same message as the ordered parts of a turn, taken when it was sent so
43
+ an ``@`` reference is read the way it was typed.
44
+ """
45
+
46
+ text: str
47
+ parts: tuple[PromptPart, ...]
48
+
49
+
50
+ class FollowUpsMixin(ShellState):
51
+ """Ask what a message sent mid-request is, hold the follow-ups, and send them in turn."""
52
+
53
+ # -- a message typed while busy ---------------------------------------------------
54
+ def _send_while_busy(self, value: str) -> bool:
55
+ """Decide what one plain message typed during a request becomes.
56
+
57
+ When it could be either kind the reader is asked, and the draft stays in the
58
+ composer meanwhile — which is why this answers ``False`` then: the draft is only
59
+ cleared once there is an answer. When only one kind is possible there is nothing to
60
+ ask, and a reader who could not steer is told why only if the reason is not obvious.
61
+
62
+ Returns:
63
+ What :meth:`submit` returns: whether the draft was taken.
64
+ """
65
+ parts = self.composer.parts()
66
+ if self._editing_slot is not None:
67
+ # A message taken out to be edited goes back where it was, as what it was.
68
+ return self._queue_follow_up(value, parts, slot=self._editing_slot)
69
+ blocked = self._steer_blocked()
70
+ if blocked is None:
71
+ self._ask_send_kind(value, parts)
72
+ return False
73
+ if blocked:
74
+ self.transcript.notice(f"queued as a follow-up \u2014 {blocked}")
75
+ return self._queue_follow_up(value, parts)
76
+
77
+ def _steer_blocked(self) -> str | None:
78
+ """Return why the running work cannot be steered, or ``None`` when it can.
79
+
80
+ An empty string means there is nothing to steer — a command is running, not a
81
+ turn, and a ``/btw`` question is one of those — which needs no explaining; any other
82
+ text is shown to the reader.
83
+ """
84
+ if not self._turn_running:
85
+ return ""
86
+ if self._steering_sent >= MAX_STEERING:
87
+ return f"steering limit reached ({MAX_STEERING}, messages from other sessions included)"
88
+ return None
89
+
90
+ def _ask_send_kind(self, value: str, parts: Sequence[PromptPart]) -> None:
91
+ """Put the question on screen; its answer comes back through :meth:`_kind_chosen`."""
92
+ page = SendKindPage(
93
+ value,
94
+ on_choice=lambda kind: self._kind_chosen(kind, value, parts),
95
+ on_cancel=self.close_page,
96
+ )
97
+ self.app.push_screen(Screen(bottom_panel(page, rows=SEND_KIND_ROWS), name=PAGE_SCREEN, modal=True))
98
+ self.app.invalidate()
99
+
100
+ def _kind_chosen(self, kind: SendKind, value: str, parts: Sequence[PromptPart]) -> None:
101
+ """Carry out the answer, then clear the draft it was about.
102
+
103
+ The request may have ended while the question was open, so the state is read again
104
+ rather than trusted: with nothing running there is nothing to steer or to follow, and
105
+ the message is simply sent.
106
+ """
107
+ self.close_page()
108
+ if not self._busy:
109
+ self.submit(value, command=False) # it was asked about, so it is a message
110
+ self.composer.commit(value)
111
+ return
112
+ if kind is SendKind.STEER:
113
+ if self._steer_now(value, parts):
114
+ self.composer.commit(value)
115
+ else:
116
+ self._refuse_busy()
117
+ return
118
+ if self._queue_follow_up(value, parts):
119
+ self.composer.commit(value)
120
+
121
+ def _steer_now(self, value: str, parts: Sequence[PromptPart]) -> bool:
122
+ """Hand one message to the running request as steering, and show it waiting to be adopted.
123
+
124
+ Text goes as it was typed. A message with pictures, or an ``@`` reference that needs explaining, is built the
125
+ way a turn is, and the run reports it by the text *that* has, which is what the queue must match; the SDK
126
+ returns that text.
127
+
128
+ Returns:
129
+ Whether the agent took it; ``False`` leaves everything as it was.
130
+ """
131
+ # A reference that needs explaining (a session) makes the message a built one, as a picture does:
132
+ # the model is told what the @ means whether the turn started with it or it arrived mid-request.
133
+ parts = mention_parts(parts, self.mentions)
134
+ if self._steer_blocked() is not None:
135
+ return False
136
+ try:
137
+ key = self.client.session.steer(prompt_pieces(parts or (value,)), mentions=True)
138
+ except COMMAND_ERRORS:
139
+ return False # e.g. a picture for a model that takes none
140
+ if key is None:
141
+ return False
142
+ self._queue_steering(value, key, parts)
143
+ return True
144
+
145
+ def _steer_follow_up(self) -> bool:
146
+ """Turn the highlighted follow-up into a steering message, without taking it out of the reader's hands first.
147
+
148
+ A follow-up waits for the request to finish; steering changes what the request does *now*,
149
+ so this is for the message that turned out to be urgent. It is the same hand-over the
150
+ question makes, and it is refused for the same reasons, said in words: nothing is running
151
+ (the list is held after a stop, and steering has nothing to steer), a command is running
152
+ rather than a turn, or the steering limit is reached. A refused message stays where it is.
153
+
154
+ Returns:
155
+ Whether it was sent. The key is always consumed; the reason, when there is one, is flashed.
156
+ """
157
+ index = self._follow_up_selected
158
+ if index is None or index >= len(self._follow_ups):
159
+ return False
160
+ if not self._busy:
161
+ self.flash("nothing is running to steer \u2014 it will be sent when you send your next message")
162
+ return False
163
+ blocked = self._steer_blocked()
164
+ if blocked is not None:
165
+ self.flash(blocked or "only a reply can be steered, not a command")
166
+ return False
167
+ item = self._follow_ups[index]
168
+ if not self._steer_now(item.text, item.parts):
169
+ self._refuse_busy()
170
+ return False
171
+ del self._follow_ups[index]
172
+ if self._editing_slot is not None and index < self._editing_slot:
173
+ self._editing_slot -= 1
174
+ remaining = len(self._follow_ups)
175
+ self._follow_up_selected = None if not remaining else min(index, remaining - 1)
176
+ self._sync_follow_ups()
177
+ self.flash(f"steering: {truncate(one_line(item.text), 40)}", level="success")
178
+ return True
179
+
180
+ # -- the follow-up list ------------------------------------------------------------
181
+ def _queue_follow_up(self, value: str, parts: Sequence[PromptPart], *, slot: int | None = None) -> bool:
182
+ """Hold one message until the request ends.
183
+
184
+ Args:
185
+ value: The message, pasted text put back.
186
+ parts: The message as the parts of a turn.
187
+ slot: Where in the list it goes; ``None`` is the end. A message taken out to be
188
+ edited goes back to the slot it left.
189
+
190
+ Returns:
191
+ Whether it was taken; ``False`` leaves the draft where it is.
192
+ """
193
+ if len(self._follow_ups) >= MAX_FOLLOW_UPS:
194
+ self.transcript.error(f"follow-up limit reached ({MAX_FOLLOW_UPS}) \u2014 wait for the queue to drain")
195
+ self.app.invalidate()
196
+ return False
197
+ item = FollowUp(value, tuple(parts))
198
+ if slot is None:
199
+ self._follow_ups.append(item)
200
+ else:
201
+ self._follow_ups.insert(min(slot, len(self._follow_ups)), item)
202
+ self._editing_slot = None
203
+ self._sync_follow_ups()
204
+ return True
205
+
206
+ def _sync_follow_ups(self) -> None:
207
+ """Show the list and the highlight as they are, making room for them if they changed."""
208
+ if not self._follow_ups:
209
+ self._follow_ups_held = False # nothing left to hold
210
+ changed = self.follow_ups.set_messages(
211
+ [item.text for item in self._follow_ups],
212
+ self._follow_up_selected,
213
+ held=not self._busy,
214
+ )
215
+ if changed:
216
+ self.app.request_layout()
217
+ self.app.invalidate()
218
+
219
+ @property
220
+ def _follow_up_selecting(self) -> bool:
221
+ """Return whether a follow-up is highlighted."""
222
+ return self._follow_up_selected is not None
223
+
224
+ def _select_follow_up(self, step: int) -> None:
225
+ """Move the highlight one row, entering the list from the composer's side.
226
+
227
+ From nothing, going up lands on the row nearest the composer and going down on the
228
+ first. Going down from the last row leaves the list — the highlight goes back to the
229
+ composer — and going up from the first stays on it.
230
+ """
231
+ count = len(self._follow_ups)
232
+ if not count:
233
+ return
234
+ current = self._follow_up_selected
235
+ if current is None:
236
+ target: int | None = count - 1 if step < 0 else 0
237
+ else:
238
+ target = current + step
239
+ target = None if target >= count else max(0, target)
240
+ self._follow_up_selected = target
241
+ self._sync_follow_ups()
242
+
243
+ def _deselect_follow_up(self) -> None:
244
+ """Take the highlight off, leaving the list as it is."""
245
+ if self._follow_up_selected is None:
246
+ return
247
+ self._follow_up_selected = None
248
+ self._sync_follow_ups()
249
+
250
+ def _move_follow_up(self, step: int) -> None:
251
+ """Swap the highlighted message with its neighbour, keeping it highlighted."""
252
+ index = self._follow_up_selected
253
+ if index is None:
254
+ return
255
+ target = index + step
256
+ if not 0 <= target < len(self._follow_ups):
257
+ return
258
+ self._follow_ups[index], self._follow_ups[target] = self._follow_ups[target], self._follow_ups[index]
259
+ self._follow_up_selected = target
260
+ self._sync_follow_ups()
261
+
262
+ def _delete_follow_up(self) -> None:
263
+ """Remove the highlighted message from the list; it will not be sent.
264
+
265
+ The highlight stays where the reader is: on the message that moved up into the
266
+ place, or on the one before it when the last row went, and nowhere when the list is
267
+ empty. The row that went is named in the line above the composer, because a key as
268
+ close to the hand as Backspace should say what it just did.
269
+ """
270
+ index = self._follow_up_selected
271
+ if index is None:
272
+ return
273
+ removed = self._follow_ups.pop(index)
274
+ if self._editing_slot is not None and index < self._editing_slot:
275
+ self._editing_slot -= 1
276
+ remaining = len(self._follow_ups)
277
+ self._follow_up_selected = None if not remaining else min(index, remaining - 1)
278
+ self._sync_follow_ups()
279
+ self.flash(f"removed follow-up: {truncate(one_line(removed.text), 40)}", level="success")
280
+
281
+ def _edit_follow_up(self) -> bool:
282
+ """Take the highlighted message out of the list and put it in the composer.
283
+
284
+ Sending it again puts it back in the slot it left, so editing a message in the middle
285
+ of the list does not move it to the end. A draft already in the composer is not
286
+ overwritten: the reader is told to deal with it first.
287
+
288
+ Returns:
289
+ Whether the message was taken out.
290
+ """
291
+ index = self._follow_up_selected
292
+ if index is None:
293
+ return False
294
+ if self.composer.text.strip():
295
+ self.flash("finish or clear the draft (Ctrl-C) before editing a queued message")
296
+ return True
297
+ item = self._follow_ups.pop(index)
298
+ self._follow_up_selected = None
299
+ self._editing_slot = index
300
+ self._sync_follow_ups()
301
+ self.composer.restore(item.parts)
302
+ self.app.request_layout()
303
+ return True
304
+
305
+ def _composer_changed(self) -> None:
306
+ """React to the draft changing: refresh the menu, and let go of what it no longer means.
307
+
308
+ Typing means the reader has moved on from the highlighted message, so the highlight
309
+ goes; and an emptied draft means a message taken out for editing was abandoned.
310
+ """
311
+ # A key was pressed: nothing may pause the screen the reader is typing into.
312
+ self.collector.typed()
313
+ self._refresh_completions()
314
+ if self._follow_up_selected is not None:
315
+ self._deselect_follow_up()
316
+ if self._editing_slot is not None and not self.composer.text:
317
+ self._editing_slot = None
318
+
319
+ # -- when a request ends -----------------------------------------------------------
320
+ def _settle_queues(self, completed: bool, *, turn: bool) -> None:
321
+ """Decide what the waiting messages become now that the request is over.
322
+
323
+ The follow-ups are the reader's own list, kept by this shell, and they are attached
324
+ only to a conversation that *ended well*: a request that was stopped or failed does
325
+ not send them and does not give them up either. They stay where they are, the reader
326
+ types whatever they meant to say instead, and when that reply completes they go out
327
+ behind it. So Escape is only ever "stop this" — it never costs the reader their list.
328
+
329
+ What does not survive is a steering message the agent never read. It was written to
330
+ redirect the request that just ended, nobody is going to read it, and it is text the
331
+ reader typed a moment ago, so it goes back into the draft rather than being dropped.
332
+
333
+ Whether the list is *held* is a state, not a property of the thing that just ended:
334
+ a stop with follow-ups waiting holds it, and only a reply completing releases it. A
335
+ command that finishes in between — a ``/btw`` question, which is not part of the
336
+ conversation at all — leaves it as it was.
337
+
338
+ Args:
339
+ completed: Whether the request ended by answering.
340
+ turn: Whether it was a turn of the conversation, as opposed to a command.
341
+ """
342
+ # Each with the parts it was sent as, so a picture is not lost on the way back.
343
+ unread = [
344
+ (value, (self._steering_meta[index][1] if index < len(self._steering_meta) else ()) or (value,))
345
+ for index, value in enumerate(self._steering)
346
+ ]
347
+ self._clear_steering()
348
+ if completed and turn:
349
+ self._follow_ups_held = False
350
+ if completed:
351
+ # A request that answered normally with one still unread hands it on, ahead of
352
+ # the follow-ups: it was sent first.
353
+ if unread:
354
+ self._follow_ups[:0] = [FollowUp(text, tuple(parts)) for text, parts in unread]
355
+ self._sync_follow_ups()
356
+ else:
357
+ if unread:
358
+ noun = "steering message was" if len(unread) == 1 else "steering messages were"
359
+ self._return_to_draft(
360
+ unread,
361
+ f"{len(unread)} {noun} never read by the agent and {'is' if len(unread) == 1 else 'are'} back in the draft",
362
+ )
363
+ if self._follow_ups:
364
+ self._follow_ups_held = True
365
+ held = len(self._follow_ups)
366
+ noun = "follow-up is" if held == 1 else "follow-ups are"
367
+ self.transcript.notice(f"{held} {noun} held and will be sent after your next reply completes")
368
+ # A `/continue` is a turn run by the command path, so it reaches here with `turn=False` and still has to
369
+ # settle what it was steered with; a command that runs beside a request never calls this at all (`_execute_now`
370
+ # does not), so settling here cannot take a live request's messages.
371
+ self._settle_relayed_steering()
372
+
373
+ def _return_to_draft(self, items: Sequence[tuple[str, Sequence[PromptPart]]], notice: str) -> None:
374
+ """Put messages back in the composer, after whatever is already there, and say so.
375
+
376
+ Args:
377
+ items: Each message as its text and the parts it was sent as.
378
+ notice: The line to leave in the transcript.
379
+ """
380
+ draft = self.composer.text.strip()
381
+ pictures = self.composer.images or any(carries_image(parts) for _, parts in items)
382
+ if pictures:
383
+ # With a picture in play the draft is rebuilt from parts, so each picture is a chip
384
+ # again and keeps its place; the labels are renumbered across the whole draft.
385
+ merged: list[PromptPart] = list(self.composer.parts()) if draft else []
386
+ for index, (_, parts) in enumerate(items):
387
+ if merged or index:
388
+ merged.append("\n\n")
389
+ merged.extend(parts)
390
+ self.composer.restore(merged)
391
+ else:
392
+ self.composer.position = len(self.composer.text)
393
+ self.composer.paste(("\n\n" if draft else "") + "\n\n".join(text for text, _ in items))
394
+ self.transcript.notice(notice)
395
+ self.app.request_layout()
396
+
397
+ def _return_follow_ups_to_draft(self) -> None:
398
+ """Give the held follow-ups back to the reader, because their conversation is over.
399
+
400
+ They continue one conversation, so they cannot go on into another: a new session, a
401
+ resumed one, and a fork all end the list. Handing them back as text keeps what the
402
+ reader wrote; sending them into a conversation they were not written for would not.
403
+ """
404
+ if not self._follow_ups:
405
+ return
406
+ texts = [item.text for item in self._follow_ups]
407
+ items = [(item.text, item.parts) for item in self._follow_ups]
408
+ self._follow_ups.clear()
409
+ self._follow_up_selected = None
410
+ self._editing_slot = None
411
+ self._sync_follow_ups()
412
+ noun = "held follow-up" if len(texts) == 1 else "held follow-ups"
413
+ self._return_to_draft(
414
+ items,
415
+ f"{len(texts)} {noun} from the previous session {'is' if len(texts) == 1 else 'are'} back in the draft",
416
+ )
417
+
418
+ def _start_follow_up(self) -> None:
419
+ """Send the first waiting message as a turn of its own."""
420
+ item = self._follow_ups.pop(0)
421
+ if self._follow_up_selected is not None:
422
+ self._follow_up_selected = None if self._follow_up_selected == 0 else self._follow_up_selected - 1
423
+ if self._editing_slot:
424
+ self._editing_slot -= 1
425
+ self._sync_follow_ups()
426
+ # Busy before the task has run: between here and its first step a keystroke must not
427
+ # be able to start a second turn beside it.
428
+ self._set_busy(True)
429
+ parts = mention_parts(item.parts, self.mentions)
430
+ self._task = asyncio.create_task(self._run_prompt(item.text, parts))
@@ -0,0 +1,147 @@
1
+ """When the cyclic garbage collector may run: while the program is working, never while the reader types.
2
+
3
+ A collection stops everything, and what it costs is the size of what it has to walk. With the model
4
+ SDKs loaded that is a heap of about 380,000 objects, and one full pass over it measured 90 ms
5
+ (115 ms at worst): six dropped frames, landing wherever it happened to fall. Two things take it out of
6
+ the reader's way.
7
+
8
+ * The long-lived heap is frozen once the runtime has warmed (:func:`freeze_long_lived`). Those
9
+ objects — the SDKs, the widget tree — never die, so the collector has no reason to look at them
10
+ again: the same full pass took 0 ms afterwards, and a collection that did fall in a long streaming
11
+ turn went from 57 ms to 9 ms. Freezing only stops the *cyclic* collector from walking them; a frozen
12
+ object is still freed the moment its last reference goes.
13
+ * The collector is switched on only while something is running, and off while the reader types
14
+ (:class:`CollectorPolicy`). Raising the threshold (``cli.GC_YOUNG_THRESHOLD``) makes a collection
15
+ rare; this makes it *impossible* at the one moment a pause is felt. A keystroke that lands on a
16
+ collection is a stutter in the thing the reader is looking at, and nothing has to run then.
17
+
18
+ A restore of a large session already holds the collector off for its burst of long-lived objects
19
+ (:mod:`zettcode.collector`); the policy yields to that rather than undoing it.
20
+
21
+ Nothing is lost by leaving it off. CPython keeps counting allocations while the collector is
22
+ disabled, so the first allocation after it is switched back on collects whatever piled up in
23
+ between: garbage is delayed, never dropped, and what an idle shell and a few keystrokes leave behind
24
+ is small.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import gc
31
+ from collections.abc import Callable
32
+ from time import monotonic
33
+
34
+ from zettcode.collector import held
35
+
36
+ #: Seconds after the last keystroke before the collector may run again, while work is in flight. Long
37
+ #: enough to bridge the gap between two words, short enough that a reader who has stopped to think
38
+ #: does not leave garbage waiting.
39
+ TYPING_GRACE = 0.5
40
+
41
+
42
+ def freeze_long_lived() -> None:
43
+ """Take everything that exists now out of the collector's reach.
44
+
45
+ Called when the runtime has finished loading: from then on the SDKs and the interface are part of
46
+ the permanent heap. Nothing is collected first. A pass costs the very pause being avoided, and
47
+ what it would find is about a hundred unreachable objects from import time, which stay as they are.
48
+ """
49
+ gc.freeze()
50
+
51
+
52
+ class CollectorPolicy:
53
+ """Switch the collector with what the program is doing.
54
+
55
+ Three states, none of them held anywhere but in the collector itself:
56
+
57
+ * **resting** — nothing is running. Off.
58
+ * **working** — a turn, a command, or a restore is in flight. On.
59
+ * **typing** — working, but the reader pressed a key less than ``grace`` seconds ago. Off, until
60
+ they stop.
61
+
62
+ Args:
63
+ grace: Seconds of quiet that end *typing*.
64
+ clock: Time source, for tests.
65
+ """
66
+
67
+ def __init__(self, *, grace: float = TYPING_GRACE, clock: Callable[[], float] = monotonic) -> None:
68
+ self.grace = grace
69
+ self.clock = clock
70
+ self._working = False
71
+ self._quiet_at = float("-inf")
72
+ self._timer: asyncio.TimerHandle | None = None
73
+
74
+ def rest(self) -> None:
75
+ """Nothing is running: the collector goes off."""
76
+ self._working = False
77
+ self._apply()
78
+
79
+ def work_started(self) -> None:
80
+ """Something started running: the collector goes on, unless the reader is mid-word."""
81
+ self._working = True
82
+ self._apply()
83
+ self._watch()
84
+
85
+ def work_ended(self) -> None:
86
+ """The work is over: the collector goes off, and any wait for the reader to stop is dropped."""
87
+ self._working = False
88
+ self._cancel()
89
+ self._apply()
90
+
91
+ def typed(self) -> None:
92
+ """The reader pressed a key: the collector goes off now, and comes back once they pause.
93
+
94
+ Cheap on purpose, because it runs on every keystroke: it only moves a deadline, and arms one
95
+ timer when none is waiting. With nothing running the collector is already off and there is
96
+ nothing to wait for.
97
+ """
98
+ if not self._working:
99
+ return
100
+ self._quiet_at = self.clock() + self.grace
101
+ gc.disable()
102
+ self._watch()
103
+
104
+ def close(self) -> None:
105
+ """The program is leaving: put the collector back on, so nothing after this runs without it."""
106
+ self._working = False
107
+ self._cancel()
108
+ gc.enable()
109
+
110
+ # -- internals ---------------------------------------------------------------------------
111
+ def _apply(self) -> None:
112
+ """Put the collector in the state the facts above call for.
113
+
114
+ Never on while a burst holds it off (:func:`~zettcode.collector.collector_paused`, which
115
+ restores what it found when the last holder lets go: a moment later, the busy state that
116
+ called for the collector to be on has ended and this puts it right).
117
+ """
118
+ if self._working and self.clock() >= self._quiet_at and not held():
119
+ gc.enable()
120
+ else:
121
+ gc.disable()
122
+
123
+ def _watch(self) -> None:
124
+ """Arm the timer that ends *typing*, when work is in flight and the reader is in the middle of it."""
125
+ if not self._working or self._timer is not None or self.clock() >= self._quiet_at:
126
+ return
127
+ try:
128
+ loop = asyncio.get_running_loop()
129
+ except RuntimeError:
130
+ # No loop to wake us: the collector stays off, which is the safe way to be wrong. The
131
+ # next change of state puts it right.
132
+ return
133
+ self._timer = loop.call_later(max(0.0, self._quiet_at - self.clock()), self._wake)
134
+
135
+ def _wake(self) -> None:
136
+ """The timer fired: end *typing* if the reader has really stopped, otherwise wait for the rest."""
137
+ self._timer = None
138
+ if self.clock() < self._quiet_at:
139
+ self._watch()
140
+ return
141
+ self._apply()
142
+
143
+ def _cancel(self) -> None:
144
+ """Drop the pending timer, if any."""
145
+ timer, self._timer = self._timer, None
146
+ if timer is not None:
147
+ timer.cancel()