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,287 @@
|
|
|
1
|
+
"""The transcript view: a virtualized window over the conversation model.
|
|
2
|
+
|
|
3
|
+
The model itself lives in ``app.agent.transcript``, because that is what the
|
|
4
|
+
agent projector writes into; this module only turns it into lines and mouse
|
|
5
|
+
handling, so the dependency arrow points one way (ui reads agent).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from bisect import bisect_right
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
from zettcode.app.agent.conversation.transcript import Transcript
|
|
14
|
+
from zettcode.plugin_api.entries import Entry, EntryKind
|
|
15
|
+
from zettcode.plugin_api.presenters import Presenters
|
|
16
|
+
from zettcode.presenters.presenters import block_for
|
|
17
|
+
from zettcode.tui import (
|
|
18
|
+
ARROW_DOWN,
|
|
19
|
+
SEPARATOR,
|
|
20
|
+
Canvas,
|
|
21
|
+
Host,
|
|
22
|
+
LineSource,
|
|
23
|
+
MouseAction,
|
|
24
|
+
MouseEvent,
|
|
25
|
+
Rect,
|
|
26
|
+
ScrollView,
|
|
27
|
+
Span,
|
|
28
|
+
Style,
|
|
29
|
+
TextLine,
|
|
30
|
+
Theme,
|
|
31
|
+
)
|
|
32
|
+
from zettcode.tui.render import display_width
|
|
33
|
+
|
|
34
|
+
#: Badge shown once the reader scrolls away from the newest line. It names the
|
|
35
|
+
#: shortcut so the mouse and the keyboard offer the same way back.
|
|
36
|
+
BADGE_ARROW = ARROW_DOWN
|
|
37
|
+
BADGE_LABEL = f" back to bottom {SEPARATOR} Esc"
|
|
38
|
+
BACK_TO_BOTTOM = BADGE_ARROW + BADGE_LABEL
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TranscriptSource(LineSource):
|
|
42
|
+
"""LineSource over the transcript: every entry's block, stacked, rendered at most once.
|
|
43
|
+
|
|
44
|
+
A *block* is one entry presented as rows (``presenters.block_for``); this stacks
|
|
45
|
+
them into the single line source the scrolling view reads, remembering where each
|
|
46
|
+
one starts so a line index finds its entry without walking the list.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, transcript: Transcript, *, theme: Theme, frame: Callable[[], int] = lambda: 0) -> None:
|
|
50
|
+
"""Serve ``transcript`` as lines, caching blocks per width and version.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
transcript: Model to read; its ``version`` invalidates this cache.
|
|
54
|
+
theme: Palette handed to each entry's presenter.
|
|
55
|
+
frame: Pulled lazily so the animation frame is only read for the
|
|
56
|
+
rows that are actually rendered.
|
|
57
|
+
"""
|
|
58
|
+
self.transcript = transcript
|
|
59
|
+
self.theme = theme
|
|
60
|
+
self.frame = frame
|
|
61
|
+
self._width = 0
|
|
62
|
+
self._version = -1
|
|
63
|
+
self._theme: Theme | None = None
|
|
64
|
+
self._presenters: Presenters | None = None
|
|
65
|
+
self._starts: list[int] = []
|
|
66
|
+
self._blocks: list[LineSource] = []
|
|
67
|
+
self._count = 0
|
|
68
|
+
self._entry_count = 0
|
|
69
|
+
|
|
70
|
+
def count(self, width: int) -> int:
|
|
71
|
+
"""Return how many terminal lines the transcript occupies at ``width``."""
|
|
72
|
+
self._sync(width)
|
|
73
|
+
return self._count
|
|
74
|
+
|
|
75
|
+
def line(self, index: int, width: int) -> TextLine:
|
|
76
|
+
"""Return one rendered line, resolving its owning entry block first."""
|
|
77
|
+
self._sync(width)
|
|
78
|
+
if not self._blocks:
|
|
79
|
+
return TextLine()
|
|
80
|
+
position = self._owner(index)
|
|
81
|
+
return self._blocks[position].line(index - self._starts[position], width)
|
|
82
|
+
|
|
83
|
+
def entry_at(self, index: int, width: int) -> Entry | None:
|
|
84
|
+
"""Return the entry that owns a rendered line, for hit testing."""
|
|
85
|
+
self._sync(width)
|
|
86
|
+
if not self._blocks:
|
|
87
|
+
return None
|
|
88
|
+
return self.transcript.entries[self._owner(index)]
|
|
89
|
+
|
|
90
|
+
def user_starts(self, width: int) -> list[int]:
|
|
91
|
+
"""Return the line each message the reader sent begins on, in order.
|
|
92
|
+
|
|
93
|
+
A side question is not one of them: it is answered apart from the conversation and is not something the
|
|
94
|
+
reader would be looking for when they go back through what they asked.
|
|
95
|
+
"""
|
|
96
|
+
self._sync(width)
|
|
97
|
+
return [
|
|
98
|
+
self._starts[position]
|
|
99
|
+
for position, entry in enumerate(self.transcript.entries[: len(self._starts)])
|
|
100
|
+
if entry.kind is EntryKind.USER and not getattr(entry, "side", False)
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
def _owner(self, index: int) -> int:
|
|
104
|
+
"""Return the position of the block that owns a line.
|
|
105
|
+
|
|
106
|
+
``_starts`` holds the first line of every block, so the last start at or
|
|
107
|
+
before ``index`` names it.
|
|
108
|
+
"""
|
|
109
|
+
return min(max(0, bisect_right(self._starts, index) - 1), len(self._blocks) - 1)
|
|
110
|
+
|
|
111
|
+
def _sync(self, width: int) -> None:
|
|
112
|
+
"""Rebuild the block boundaries when the width or the transcript changed.
|
|
113
|
+
|
|
114
|
+
Only the entries the model marked dirty are re-measured. Streaming one
|
|
115
|
+
answer dirties a single index, so a token lands in O(1) work instead of
|
|
116
|
+
re-walking every entry ever written; a width, theme, or presenter change
|
|
117
|
+
still rebuilds the whole list because it invalidates every block.
|
|
118
|
+
"""
|
|
119
|
+
if (
|
|
120
|
+
width == self._width
|
|
121
|
+
and self.transcript.version == self._version
|
|
122
|
+
and self._theme == self.theme
|
|
123
|
+
and self._presenters is self.transcript.presenters
|
|
124
|
+
):
|
|
125
|
+
return
|
|
126
|
+
entries = self.transcript.entries
|
|
127
|
+
rebuild_all = (
|
|
128
|
+
width != self._width or self._theme != self.theme or self._presenters is not self.transcript.presenters
|
|
129
|
+
)
|
|
130
|
+
dirty = None if rebuild_all else self.transcript.take_dirty()
|
|
131
|
+
if dirty is None:
|
|
132
|
+
start = 0
|
|
133
|
+
else:
|
|
134
|
+
start = min(dirty, self._entry_count, len(entries))
|
|
135
|
+
self._width = width
|
|
136
|
+
self._version = self.transcript.version
|
|
137
|
+
self._theme = self.theme
|
|
138
|
+
self._presenters = self.transcript.presenters
|
|
139
|
+
frame = self.frame()
|
|
140
|
+
if start == 0:
|
|
141
|
+
self._starts = []
|
|
142
|
+
self._blocks = []
|
|
143
|
+
total = 0
|
|
144
|
+
else:
|
|
145
|
+
total = self._starts[start] if start < len(self._starts) else self._count
|
|
146
|
+
del self._starts[start:]
|
|
147
|
+
del self._blocks[start:]
|
|
148
|
+
for entry in entries[start:]:
|
|
149
|
+
self._starts.append(total)
|
|
150
|
+
block = block_for(entry, width, self.theme, frame, presenters=self.transcript.presenters)
|
|
151
|
+
self._blocks.append(block)
|
|
152
|
+
total += block.count(width)
|
|
153
|
+
self._count = total
|
|
154
|
+
self._entry_count = len(entries)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class TranscriptView(ScrollView):
|
|
158
|
+
"""Scrollable transcript that toggles entries when they are clicked.
|
|
159
|
+
|
|
160
|
+
Shape, one blank row between entries::
|
|
161
|
+
|
|
162
|
+
> inspect the project <- user prompt: U+276F flush left,
|
|
163
|
+
text under the composer caret
|
|
164
|
+
v Thinking 1.2 s <- reasoning row; v collapsed,
|
|
165
|
+
checking the renderer U+25BE expanded, animated
|
|
166
|
+
while running, stamped at end
|
|
167
|
+
x Read app.py 1.2 s v <- tool row: glyph, what the call
|
|
168
|
+
| import os did, duration, and U+25B8 when
|
|
169
|
+
... its bounded output can open
|
|
170
|
+
Fixed parse. <- answer: parsed as Markdown
|
|
171
|
+
|
|
172
|
+
Every row starts at the composer's caret column — the width of its ``\u203a ``
|
|
173
|
+
prompt — so text never sits left of where the user types. A user prompt is
|
|
174
|
+
the one exception: its own ``\u203a `` draws at column zero, flush with the
|
|
175
|
+
edge like the composer's, and its text lands on that same shared margin.
|
|
176
|
+
|
|
177
|
+
The last two rows are the same entry: a tool row collapses its output to
|
|
178
|
+
`TOOL_PREVIEW_ROWS` unless it is expanded. A click toggles the entry under
|
|
179
|
+
the pointer; everything else, the wheel included, is ScrollView's.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def __init__(self, transcript: Transcript, *, theme: Theme, wheel_speed: float = 1.0) -> None:
|
|
183
|
+
"""Scroll the transcript and keep its line source in sync with the theme.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
transcript: Model rendered by this view.
|
|
187
|
+
theme: Initial palette; ``render`` refreshes it from the live theme.
|
|
188
|
+
wheel_speed: Rows one wheel event scrolls (``transcript.mouse_scroll_speed``).
|
|
189
|
+
"""
|
|
190
|
+
self.transcript = transcript
|
|
191
|
+
self.transcript_source = TranscriptSource(transcript, theme=theme, frame=lambda: transcript.frame)
|
|
192
|
+
super().__init__(self.transcript_source, follow_tail=True, selectable=True, wheel_speed=wheel_speed)
|
|
193
|
+
|
|
194
|
+
def render(self, canvas: Canvas) -> None:
|
|
195
|
+
"""Refresh the source theme, then float the return badge over the rows."""
|
|
196
|
+
self.transcript_source.theme = self.theme
|
|
197
|
+
super().render(canvas)
|
|
198
|
+
self._render_badge(canvas)
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def scrolled_up(self) -> bool:
|
|
202
|
+
"""Return whether the newest lines are out of view."""
|
|
203
|
+
return not self.follow_tail
|
|
204
|
+
|
|
205
|
+
def jump_to_message(self, direction: int) -> bool:
|
|
206
|
+
"""Scroll so a message the reader sent is the first row: the one above the view, or the one below it.
|
|
207
|
+
|
|
208
|
+
Going back, the target is the nearest message that begins above the first visible row, so pressing again
|
|
209
|
+
walks back one message at a time; with none above, the view goes to the top of the conversation. Going
|
|
210
|
+
forward, it is the nearest one that begins below the first row, and with none left the view goes back to
|
|
211
|
+
the newest line and follows it again.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
direction: ``-1`` for the previous message, ``1`` for the next.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Whether the view moved, which is false only when there is nothing to move to.
|
|
218
|
+
"""
|
|
219
|
+
starts = self.transcript_source.user_starts(self.line_width)
|
|
220
|
+
if direction < 0:
|
|
221
|
+
above = [start for start in starts if start < self.top]
|
|
222
|
+
if not above:
|
|
223
|
+
if self.top == 0:
|
|
224
|
+
return False
|
|
225
|
+
self.scroll_to(0)
|
|
226
|
+
return True
|
|
227
|
+
self.scroll_to(above[-1])
|
|
228
|
+
return True
|
|
229
|
+
below = [start for start in starts if start > self.top]
|
|
230
|
+
if below:
|
|
231
|
+
self.scroll_to(below[0])
|
|
232
|
+
return True
|
|
233
|
+
if self.follow_tail:
|
|
234
|
+
return False
|
|
235
|
+
self.scroll_end()
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
def badge_rect(self) -> Rect | None:
|
|
239
|
+
"""Return the cell rectangle of the return badge, or None while at the tail.
|
|
240
|
+
|
|
241
|
+
The badge is anchored to the bottom-right corner with the same two-cell
|
|
242
|
+
margin the header, the status bar, and the composer keep, so it lines up
|
|
243
|
+
with the rest of the frame instead of the edge.
|
|
244
|
+
"""
|
|
245
|
+
if not self.scrolled_up or self.rect.empty:
|
|
246
|
+
return None
|
|
247
|
+
width = display_width(BACK_TO_BOTTOM) + 2
|
|
248
|
+
if width > self.rect.width:
|
|
249
|
+
return None
|
|
250
|
+
x = max(self.rect.x, self.rect.x + self.rect.width - width - 2)
|
|
251
|
+
return Rect(x, self.rect.bottom - 1, width, 1)
|
|
252
|
+
|
|
253
|
+
def _render_badge(self, canvas: Canvas) -> None:
|
|
254
|
+
"""Paint the return badge in the composer's raised-surface colours."""
|
|
255
|
+
rect = self.badge_rect()
|
|
256
|
+
if rect is None:
|
|
257
|
+
return
|
|
258
|
+
style = Style(foreground=self.theme.text, background=self.theme.surface_alt)
|
|
259
|
+
arrow = Style(foreground=self.theme.accent_bright, background=self.theme.surface_alt, bold=True)
|
|
260
|
+
canvas.draw_spans(
|
|
261
|
+
rect.x,
|
|
262
|
+
rect.y,
|
|
263
|
+
(
|
|
264
|
+
Span(" ", style),
|
|
265
|
+
Span(BADGE_ARROW, arrow),
|
|
266
|
+
Span(BADGE_LABEL, style),
|
|
267
|
+
Span(" ", style),
|
|
268
|
+
),
|
|
269
|
+
max_width=rect.width,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def handle(self, event, host: Host) -> bool:
|
|
273
|
+
"""Run the badge, else toggle the clicked entry, else leave it to ScrollView."""
|
|
274
|
+
if isinstance(event, MouseEvent) and event.action is MouseAction.DOWN:
|
|
275
|
+
badge = self.badge_rect()
|
|
276
|
+
if badge is not None and badge.contains(event.x, event.y):
|
|
277
|
+
self.scroll_end()
|
|
278
|
+
host.invalidate()
|
|
279
|
+
return True
|
|
280
|
+
if isinstance(event, MouseEvent) and event.action is MouseAction.DOWN and self.rect.contains(event.x, event.y):
|
|
281
|
+
index = self.top + event.y - self.rect.y
|
|
282
|
+
entry = self.transcript_source.entry_at(index, self.line_width)
|
|
283
|
+
if entry is not None and self.transcript.toggle(entry.id):
|
|
284
|
+
host.invalidate()
|
|
285
|
+
return True
|
|
286
|
+
# Everything else, the wheel included, belongs to ScrollView.
|
|
287
|
+
return super().handle(event, host)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The bottom panel that offers a newer release."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
|
|
7
|
+
from zettcode.tui import SEPARATOR, ListItem, ListPage
|
|
8
|
+
from zettcode.update import SIMPLE_INDEX, display_command
|
|
9
|
+
|
|
10
|
+
#: Row values the page hands back through :meth:`UpdatePage.chosen`.
|
|
11
|
+
UPGRADE = "upgrade"
|
|
12
|
+
SKIP = "skip"
|
|
13
|
+
LATER = "later"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UpdatePage(ListPage):
|
|
17
|
+
"""The available version, and the three things a reader can do about it.
|
|
18
|
+
|
|
19
|
+
Shape::
|
|
20
|
+
|
|
21
|
+
ZettCode 0.1.3 is available, you have 0.1.2
|
|
22
|
+
> Upgrade now uv tool upgrade zettcode
|
|
23
|
+
Skip this version not asked about 0.1.3 again
|
|
24
|
+
Not now asked again next time
|
|
25
|
+
enter select \u00b7 esc not now
|
|
26
|
+
|
|
27
|
+
The command is part of the row, not a footnote: an upgrade installs into
|
|
28
|
+
the environment this process was started from, and seeing which one is
|
|
29
|
+
about to run is what makes that safe to accept. The two ways of saying no
|
|
30
|
+
are kept apart on purpose — one is about this version, the other about this
|
|
31
|
+
moment — and Escape takes the gentler one. The page only reports the choice;
|
|
32
|
+
the shell acts on it.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
latest: str,
|
|
38
|
+
current: str,
|
|
39
|
+
command: Sequence[str],
|
|
40
|
+
*,
|
|
41
|
+
on_choice: Callable[[str], None],
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Build the panel for one offered version.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
latest: Version the index reported, shown in the title.
|
|
47
|
+
current: Version running now, shown beside it.
|
|
48
|
+
command: Argv the upgrade would run, shown under its row.
|
|
49
|
+
on_choice: Receives :data:`UPGRADE`, :data:`SKIP` or :data:`LATER`;
|
|
50
|
+
Escape asks again next time.
|
|
51
|
+
"""
|
|
52
|
+
super().__init__(
|
|
53
|
+
[
|
|
54
|
+
ListItem(UPGRADE, "Upgrade now", display_command(command)),
|
|
55
|
+
ListItem(SKIP, "Skip this version", f"not asked about {latest} again"),
|
|
56
|
+
ListItem(LATER, "Not now", "asked again next time"),
|
|
57
|
+
],
|
|
58
|
+
title=f"ZettCode {latest} is available, you have {current}",
|
|
59
|
+
# The row is too narrow for the URL the upgrade installs from, and that URL is the point of the change
|
|
60
|
+
# this panel announces: it goes where the width is, under the title, and the row keeps the command.
|
|
61
|
+
subtitle=f"installs from PyPI ({SIMPLE_INDEX})",
|
|
62
|
+
footer=f"enter select {SEPARATOR} esc not now",
|
|
63
|
+
on_select=lambda item: on_choice(str(item.value)),
|
|
64
|
+
on_cancel=lambda: on_choice(LATER),
|
|
65
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""The banner the shell writes into the transcript on startup.
|
|
2
|
+
|
|
3
|
+
It is data, not a widget: ``WelcomePresenter`` combines the shared pixel mark
|
|
4
|
+
with themed labels when it renders the ``welcome`` entry.
|
|
5
|
+
|
|
6
|
+
The compact mark retains the website's solid face, square eyes, side ears, and
|
|
7
|
+
warm heart. Its labels form one block next to it, instead of leaving the hint
|
|
8
|
+
detached underneath. Half blocks keep the pixels square in tall terminal cells.
|
|
9
|
+
The version follows the name on the title line, two spaces after it, which is
|
|
10
|
+
where the presenter looks for it to draw it quietly.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from zettcode.presenters.brand import LOGO_LINES
|
|
14
|
+
from zettcode.version import __version__
|
|
15
|
+
|
|
16
|
+
WELCOME = "\n".join(
|
|
17
|
+
f" {line.text}"
|
|
18
|
+
+ {
|
|
19
|
+
1: f" ZettCode v{__version__}",
|
|
20
|
+
2: " A focused coding agent",
|
|
21
|
+
4: " Type a task, or /help for commands.",
|
|
22
|
+
}.get(index, "")
|
|
23
|
+
for index, line in enumerate(LOGO_LINES)
|
|
24
|
+
)
|
zettcode/collector.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Holding the cyclic garbage collector off while a burst of long-lived objects is built.
|
|
2
|
+
|
|
3
|
+
Reading a session creates hundreds of thousands of objects that will live as long as
|
|
4
|
+
the session does and that nothing will ever collect: parsed records, their envelopes,
|
|
5
|
+
the rows built from them. The collector cannot know that. Each time the allocation
|
|
6
|
+
count crosses its threshold it walks what was allocated since the last pass, and on a
|
|
7
|
+
burst this size it does so over and over — about a tenth of the whole resume, in a
|
|
8
|
+
measurement with the collector switched off for the same work.
|
|
9
|
+
|
|
10
|
+
Pausing it for the length of the burst costs nothing that matters here: none of those
|
|
11
|
+
objects is cyclic garbage, and the pause is a few hundred milliseconds. It is a count,
|
|
12
|
+
not a flag, because the command line's build and an in-app resume can overlap, and the
|
|
13
|
+
collector must come back on only when the last holder lets go — and only if it was on
|
|
14
|
+
to begin with.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import gc
|
|
20
|
+
import threading
|
|
21
|
+
from collections.abc import Generator
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
|
|
24
|
+
_lock = threading.Lock()
|
|
25
|
+
_holders = 0
|
|
26
|
+
_was_enabled = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def held() -> bool:
|
|
30
|
+
"""Return whether a burst is holding the collector off right now.
|
|
31
|
+
|
|
32
|
+
The shell switches the collector with what the reader is doing (:mod:`zettcode.app.ui.gc_policy`),
|
|
33
|
+
and must not switch it back on in the middle of a burst that is being built with it off: that
|
|
34
|
+
would hand the burst the very passes the pause exists to skip.
|
|
35
|
+
"""
|
|
36
|
+
return _holders > 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@contextmanager
|
|
40
|
+
def collector_paused() -> Generator[None, None, None]:
|
|
41
|
+
"""Keep the cyclic collector off for the body, and restore it as it was afterwards."""
|
|
42
|
+
global _holders, _was_enabled
|
|
43
|
+
with _lock:
|
|
44
|
+
if _holders == 0:
|
|
45
|
+
_was_enabled = gc.isenabled()
|
|
46
|
+
gc.disable()
|
|
47
|
+
_holders += 1
|
|
48
|
+
try:
|
|
49
|
+
yield
|
|
50
|
+
finally:
|
|
51
|
+
with _lock:
|
|
52
|
+
_holders -= 1
|
|
53
|
+
if _holders == 0 and _was_enabled:
|
|
54
|
+
gc.enable()
|