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,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()