zettcode-commands 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.
@@ -0,0 +1,418 @@
1
+ """The page ``/context`` opens: how full the window is, and where the tokens in it went."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+ from time import monotonic
9
+
10
+ from zettcode.app.ui.widgets.effort_page import heat_colour
11
+ from zettcode.presenters.labels import compact_path
12
+ from zettcode.sdk.types import ContextReport
13
+ from zettcode.tui import SEPARATOR, KeyEvent, MouseAction, MouseEvent, Style, Widget
14
+ from zettcode.tui.render import blend, display_width
15
+
16
+ from . import context_charts as charts
17
+
18
+ #: Rows above the body: a blank, the title, and the file the session is stored in.
19
+ HEAD_ROWS = 3
20
+
21
+ #: How far a mouse-wheel notch moves the body.
22
+ WHEEL_ROWS = 3
23
+
24
+ #: How far ← and → move the share the window is compacted at: five percent a press, to a multiple of five.
25
+ PERCENT_STEP = 5
26
+ PERCENT_LEAST = 1
27
+
28
+ #: Seconds the knob takes to travel to the share it was sent to; the same as the other panels' knobs.
29
+ SLIDE_SECONDS = 0.42
30
+
31
+ #: The columns the label before the rail takes, the most the rail is drawn across, and the least.
32
+ LABEL_WIDTH = 13
33
+ RAIL_MAX = 72
34
+ RAIL_MIN = 10
35
+
36
+ #: Columns kept beside the number for what the share was, when the rail has room to spare for it.
37
+ NOTE_WIDTH = 10
38
+
39
+ #: Characters of the rail: the lit stretch, the groove ahead, the knob, and the mark where it began.
40
+ LIT, GROOVE, KNOB, ORIGIN = "\u2501", "\u2500", "\u25cf", "\u2506"
41
+
42
+
43
+ class ContextPage(Widget):
44
+ """A full-screen page that shows what the next request is made of.
45
+
46
+ Shape::
47
+
48
+ Context Opus 5 <- title and model
49
+ ~/.zettcode/sessions/…/conversation.jsonl <- where this session is stored
50
+ Window 17,926 / 200,000 tokens 9.0% full
51
+ ██▌░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
52
+ ▲ compacts at 160,000 tokens (80.0%)
53
+ Used 17,926 9.0%
54
+ Until compaction 142,074 71.0%
55
+ Free in window 182,074 91.0%
56
+
57
+ Where it goes how the 17,926 tokens divide between sources
58
+ ██████████████████████████▓▓▓▓▓▒▒▒░ <- the sources, side by side
59
+ Source Tokens Share Items
60
+ ■ System prompt 2,700 15.1% 1
61
+ …
62
+ esc back
63
+
64
+ The gauge answers how full the window is and warms as the request nears the point where it
65
+ is compacted, so a reader sees the trouble coming rather than the number. The bar and the rows
66
+ under it answer a different question, where the tokens went, and divide the request rather than
67
+ the window: a source is a share of the request and not of the model. The two ratios are never
68
+ drawn on one scale. The total is the provider's whenever it reported one; the local count only
69
+ supplies the proportions, so the rows add up to it exactly.
70
+
71
+ The page paints on every frame from the lines the chart module describes, so a resize costs
72
+ nothing but a repaint, and a terminal too short for all of it scrolls.
73
+
74
+ When it is given the share the window is compacted at, a row above the keys shows it as a rail with
75
+ a knob, and ← → slide the knob by five percent (a click on the rail sends it there):
76
+ the rail runs through the theme's colours from calm to hot, the knob is a single dot that slides along it,
77
+ and the mark under the gauge glides with it, from the clock and not from a frame count. Nothing is
78
+ written until Enter, and Escape leaves the model as it was.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ report: ContextReport,
84
+ *,
85
+ path: Path | None = None,
86
+ model: str = "",
87
+ trigger: int | None = None,
88
+ compact_percent: float | None = None,
89
+ on_apply: Callable[[float], None] | None = None,
90
+ on_cancel: Callable[[], None] | None = None,
91
+ ) -> None:
92
+ """Hold the report to draw.
93
+
94
+ Args:
95
+ report: Breakdown to show, as produced by the context extension.
96
+ path: File this session is stored in. The home directory collapses to ``~``; a line
97
+ too long for the page is cut at the edge, so a narrow terminal loses the end of
98
+ the path and not which file it named.
99
+ model: The model the window belongs to, shown beside the title.
100
+ trigger: Tokens at which a request is compacted; the gauge marks it. ``None`` for a
101
+ model that is never compacted.
102
+ compact_percent: The share of the window at which a request is compacted, to be moved with ← →.
103
+ ``None`` leaves the page a read-only report, and the mark is drawn at ``trigger``.
104
+ on_apply: Receives the share when Enter is pressed on one that was moved; it owns closing the page.
105
+ on_cancel: Called on Escape instead of the page popping itself, for an owner that
106
+ keeps its own screen stack.
107
+ """
108
+ super().__init__()
109
+ self.report = report
110
+ self.path = compact_path(path, limit=None) if path is not None else ""
111
+ self.model = model
112
+ self.trigger = trigger
113
+ self.start_percent = compact_percent
114
+ self.percent = compact_percent
115
+ #: Where the knob set off from, when it set off, and whether it is still on its way.
116
+ self._origin = compact_percent
117
+ self._set_off = 0.0
118
+ self._moving = False
119
+ self._token = f"context-panel:{id(self)}"
120
+ #: Where the rail was drawn last (left column, row, width), for a click to land on.
121
+ self._rail: tuple[int, int, int] | None = None
122
+ self.on_apply = on_apply
123
+ self.on_cancel = on_cancel
124
+ self.offset = 0
125
+ self._body_rows = 0
126
+
127
+ @property
128
+ def focusable(self) -> bool:
129
+ """Take the keyboard when the page opens, so its keys work without a click."""
130
+ return True
131
+
132
+ @property
133
+ def title(self) -> str:
134
+ """Return the heading: the page's name and how much of the window the request fills."""
135
+ return f"Context {self.report.used:,} / {self.report.window:,} tokens"
136
+
137
+ def lines(self, width: int) -> list[charts.Line]:
138
+ """Return the body at ``width`` columns."""
139
+ return charts.context_lines(self.report, self._trigger(), width)
140
+
141
+ def _trigger(self) -> int | None:
142
+ """Return the tokens a request is compacted at: where the knob is now, else the point given."""
143
+ if self.percent is None or not self.report.window:
144
+ return self.trigger
145
+ return max(1, round(self.report.window * self.knob_at(self._now()) / 100))
146
+
147
+ def _now(self) -> float:
148
+ return self.app.clock() if self.app is not None else monotonic()
149
+
150
+ def knob_at(self, now: float) -> float:
151
+ """Return the share the knob is at, which is between where it set off and where it is going while it travels."""
152
+ assert self.percent is not None
153
+ if not self._moving or self._origin is None:
154
+ return self.percent
155
+ progress = (now - self._set_off) / SLIDE_SECONDS
156
+ if progress >= 1.0:
157
+ return self.percent
158
+ eased = 1 - (1 - max(0.0, progress)) ** 3
159
+ return self._origin + (self.percent - self._origin) * eased
160
+
161
+ def _slide_to(self, share: float) -> None:
162
+ """Send the knob to a share, from wherever it is now; with reduced motion it is simply there."""
163
+ assert self.percent is not None
164
+ share = float(min(100, max(PERCENT_LEAST, share)))
165
+ if share == self.percent:
166
+ return
167
+ now = self._now()
168
+ self._origin = self.knob_at(now)
169
+ self.percent = share
170
+ self._set_off = now
171
+ if self.app is not None and self.app.reduced_motion:
172
+ self._moving = False
173
+ self._origin = share
174
+ return
175
+ self._moving = True
176
+ if self.app is not None:
177
+ self.app.scheduler.animate(self._token)
178
+
179
+ def on_tick(self) -> bool:
180
+ """Hand the frame loop back once the knob has arrived; say whether the picture is still changing."""
181
+ if not self._moving:
182
+ return False
183
+ if self._now() - self._set_off >= SLIDE_SECONDS:
184
+ self._moving = False
185
+ self._origin = self.percent
186
+ if self.app is not None:
187
+ self.app.scheduler.animate(self._token, active=False)
188
+ return True
189
+
190
+ def on_unmount(self) -> None:
191
+ """Release the frame loop, so a page put away mid-slide stops costing frames."""
192
+ self._moving = False
193
+ if self.app is not None:
194
+ self.app.scheduler.animate(self._token, active=False)
195
+
196
+ @property
197
+ def adjustable(self) -> bool:
198
+ """Return whether the share can be moved here."""
199
+ return self.percent is not None and self.on_apply is not None
200
+
201
+ @property
202
+ def changed(self) -> bool:
203
+ """Return whether the share is anywhere but where it started."""
204
+ return self.percent != self.start_percent
205
+
206
+ def _move_percent(self, direction: int) -> None:
207
+ """Move the share to the next multiple of five in ``direction``, and never off 1 to 100."""
208
+ assert self.percent is not None
209
+ if direction > 0:
210
+ moved = math.floor(self.percent / PERCENT_STEP + 1) * PERCENT_STEP
211
+ else:
212
+ moved = (math.ceil(self.percent / PERCENT_STEP) - 1) * PERCENT_STEP
213
+ self._slide_to(moved)
214
+
215
+ # -- input --------------------------------------------------------------
216
+ def close(self) -> None:
217
+ """Leave the page: through the owner's callback when it gave one, else by popping itself."""
218
+ if self.on_cancel is not None:
219
+ self.on_cancel()
220
+ elif self.app is not None and self.app.screens.top.widget is self:
221
+ self.app.pop_screen()
222
+ self.app.request_layout()
223
+
224
+ def capture_event(self, event, host) -> bool:
225
+ """Close on Escape before anything else can take the key."""
226
+ if isinstance(event, KeyEvent) and event.key == "escape":
227
+ self.close()
228
+ return True
229
+ return False
230
+
231
+ def handle(self, event, host) -> bool:
232
+ """Scroll the body, or close on ``q``."""
233
+ if isinstance(event, MouseEvent):
234
+ if event.action is MouseAction.SCROLL_UP:
235
+ self._scroll(-WHEEL_ROWS)
236
+ elif event.action is MouseAction.SCROLL_DOWN:
237
+ self._scroll(WHEEL_ROWS)
238
+ elif event.action is MouseAction.DOWN and self._rail is not None and self.adjustable:
239
+ left, row, width = self._rail
240
+ if event.y != row or not left <= event.x < left + width:
241
+ return False
242
+ self._slide_to(round((event.x - left) / max(1, width - 1) * 100))
243
+ else:
244
+ return False
245
+ host.invalidate()
246
+ return True
247
+ key = self._key_of(event)
248
+ if self.adjustable and key in ("left", "right", "enter"):
249
+ if key == "enter":
250
+ if self.changed:
251
+ assert self.percent is not None and self.on_apply is not None
252
+ self.on_apply(self.percent)
253
+ else:
254
+ self._move_percent(-1 if key == "left" else 1)
255
+ host.invalidate()
256
+ return True
257
+ if key in ("up", "k"):
258
+ self._scroll(-1)
259
+ elif key in ("down", "j"):
260
+ self._scroll(1)
261
+ elif key == "page_up":
262
+ self._scroll(-max(1, self._body_rows - 1))
263
+ elif key == "page_down":
264
+ self._scroll(max(1, self._body_rows - 1))
265
+ elif key == "home":
266
+ self.offset = 0
267
+ elif key == "end":
268
+ self._scroll(10**9)
269
+ elif key == "q":
270
+ self.close()
271
+ else:
272
+ return False
273
+ host.invalidate()
274
+ return True
275
+
276
+ @staticmethod
277
+ def _key_of(event: object) -> str | None:
278
+ """Return the key an event names, whether it came as a key or as typed text."""
279
+ if isinstance(event, KeyEvent):
280
+ return None if event.alt or event.control else event.key
281
+ text = getattr(event, "text", None)
282
+ return text if isinstance(text, str) and len(text) == 1 else None
283
+
284
+ def _scroll(self, rows: int) -> None:
285
+ """Move the body, never past its first row or its last."""
286
+ furthest = max(0, len(self.lines(max(1, self.rect.width - 4))) - self._body_rows)
287
+ self.offset = min(max(0, self.offset + rows), furthest)
288
+
289
+ # -- painting -----------------------------------------------------------
290
+ def style_of(self, role: str) -> Style:
291
+ """Return how a role is painted in the active theme.
292
+
293
+ Each source has a colour of its own, taken from the hues the theme already gives the tool
294
+ families, so every palette has seven that are told apart without this page asking for any.
295
+ """
296
+ theme = self.theme
297
+ page = theme.surface_alt
298
+ tools = theme.tools
299
+ colours = {
300
+ "text": theme.text,
301
+ "heading": theme.text,
302
+ "muted": theme.muted,
303
+ "track": blend(page, theme.text, 0.18),
304
+ "gauge_calm": theme.accent,
305
+ "gauge_warm": theme.warning,
306
+ "gauge_hot": theme.error,
307
+ "source0": tools.read,
308
+ "source1": tools.subagent,
309
+ "source2": tools.message,
310
+ "source3": theme.accent,
311
+ "source4": tools.write,
312
+ "source5": theme.warning,
313
+ "source6": theme.subtle,
314
+ }
315
+ return Style(foreground=colours.get(role, theme.text), background=page, bold=role == "heading")
316
+
317
+ def render(self, canvas) -> None:
318
+ """Fill the page, then draw the title, the file, the body, and the footer."""
319
+ if self.rect.empty:
320
+ return
321
+ theme = self.theme
322
+ rect = self.rect
323
+ canvas.fill(rect.x, rect.y, rect.width, rect.height, Style(background=theme.surface_alt))
324
+ room = max(0, rect.width - 4)
325
+ used = canvas.draw_text(rect.x + 2, rect.y + 1, "Context", self.style_of("heading"), max_width=room)
326
+ if self.model:
327
+ canvas.draw_text(
328
+ rect.x + 2 + used + 2, rect.y + 1, self.model, self.style_of("muted"), max_width=max(0, room - used - 2)
329
+ )
330
+ if self.path:
331
+ canvas.draw_text(rect.x + 2, rect.y + 2, self.path, self.style_of("muted"), max_width=room)
332
+ body = self.lines(room)
333
+ top = rect.y + HEAD_ROWS + 1
334
+ control = self.adjustable
335
+ self._body_rows = max(0, rect.bottom - 2 - top - (1 if control else 0))
336
+ self.offset = min(self.offset, max(0, len(body) - self._body_rows))
337
+ for row, line in enumerate(body[self.offset : self.offset + self._body_rows]):
338
+ self._draw(canvas, rect.x + 2, top + row, line, room)
339
+ keys = "\u2191\u2193 scroll" if len(body) > self._body_rows else ""
340
+ if control:
341
+ self._draw_control(canvas, rect.x + 2, rect.bottom - 2, room)
342
+ keys = f" {SEPARATOR} ".join(part for part in (keys, "\u2190 \u2192 compress at", "enter apply") if part)
343
+ hint = f" {SEPARATOR} ".join(part for part in (keys, "esc back") if part)
344
+ canvas.draw_text(rect.x + 2, rect.bottom - 1, hint, self.style_of("muted"), max_width=room)
345
+
346
+ def _draw_control(self, canvas, x: int, y: int, room: int) -> None:
347
+ """Draw the share the window is compacted at as a rail with a knob, its value, and what it was until Enter.
348
+
349
+ The rail is the theme's own ramp, calm to hot, drawn lit up to the knob and dimmed beyond it, so
350
+ where the knob stands is told by colour as well as place. The knob is one dot on one cell, and nothing
351
+ follows it: the rail is as wide as the page allows, so five percent is several cells of travel.
352
+ """
353
+ assert self.percent is not None and self.start_percent is not None
354
+ theme = self.theme
355
+ surface = theme.surface_alt
356
+ now = self._now()
357
+ shown = self.knob_at(now)
358
+ # By the clock, not by the flag: the frame after the knob lands is drawn before the tick that clears it.
359
+ travelling = self._moving and now - self._set_off < SLIDE_SECONDS
360
+ canvas.draw_text(x, y, "Compress at", self.style_of("heading"), max_width=room)
361
+ width = min(RAIL_MAX, room - LABEL_WIDTH - 6 - NOTE_WIDTH)
362
+ if width < RAIL_MIN:
363
+ width = min(RAIL_MAX, room - LABEL_WIDTH - 6)
364
+ value = self._shown(self.percent) if not travelling else f"{round(shown)}"
365
+ hue = heat_colour(theme, shown / 100)
366
+ if width < RAIL_MIN:
367
+ self._rail = None
368
+ canvas.draw_text(
369
+ x + LABEL_WIDTH,
370
+ y,
371
+ f"{value}%",
372
+ Style(foreground=hue, background=surface, bold=True),
373
+ max_width=max(0, room - LABEL_WIDTH),
374
+ )
375
+ return
376
+ left = x + LABEL_WIDTH
377
+ self._rail = (left, y, width)
378
+ last = width - 1
379
+ # One knob on one cell, and nothing trailing it: the rail is wide enough that five percent is several cells,
380
+ # and the colour of the knob and of the number follows the position exactly, so the glide reads as one move.
381
+ knob = round(shown / 100 * last)
382
+ glow = blend(hue, theme.text, 0.35)
383
+ for cell in range(width):
384
+ base = heat_colour(theme, cell / last)
385
+ if cell == knob:
386
+ canvas.draw_text(left + cell, y, KNOB, Style(foreground=glow, background=surface, bold=True))
387
+ continue
388
+ colour, glyph = (base, LIT) if cell < knob else (blend(base, surface, 0.5), GROOVE)
389
+ canvas.draw_text(left + cell, y, glyph, Style(foreground=colour, background=surface))
390
+ began = round(self.start_percent / 100 * last)
391
+ if self.changed and began != knob:
392
+ canvas.draw_text(left + began, y, ORIGIN, Style(foreground=theme.muted, background=surface, bold=True))
393
+ after = left + width + 1
394
+ number = f"{value}%"
395
+ canvas.draw_text(after, y, number, Style(foreground=hue, background=surface, bold=True), max_width=5)
396
+ beyond = after + max(4, display_width(number)) + 2
397
+ note = f"was {self._shown(self.start_percent)}%" if self.changed else ""
398
+ if note and x + room - beyond >= display_width(note):
399
+ canvas.draw_text(
400
+ beyond,
401
+ y,
402
+ note,
403
+ Style(foreground=theme.accent if self.changed else theme.muted, background=surface),
404
+ max_width=x + room - beyond,
405
+ )
406
+
407
+ @staticmethod
408
+ def _shown(percent: float) -> str:
409
+ """Return a share without a pointless decimal: ``80`` for 80.0, ``82.5`` as it is."""
410
+ return f"{percent:g}"
411
+
412
+ def _draw(self, canvas, x: int, y: int, line: charts.Line, room: int) -> None:
413
+ """Draw one line of roles from ``x``, stopping at the edge of the page."""
414
+ used = 0
415
+ for text, role in line:
416
+ if used >= room:
417
+ break
418
+ used += canvas.draw_text(x + used, y, text, self.style_of(role), max_width=room - used)