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,56 @@
1
+ """The plugin that offers the slash commands that act on the application.
2
+
3
+ One plugin, not one per command group, so the order ``/help`` lists them in is the order they are registered here and
4
+ does not depend on how the loader happens to sort entry-point names. It is found through the ``zettcode.plugin``
5
+ entry point and is one the terminal requires: with no ``/help``, no ``/plugins`` and no ``/model`` there would be no
6
+ way back to a working setup from inside the program.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from zettcode.plugin_api.plugin import TuiPlugin
14
+
15
+ from .handlers import (
16
+ BashCommand,
17
+ CodeReview,
18
+ HelpCommand,
19
+ ModelCommands,
20
+ PluginCommand,
21
+ SessionCommands,
22
+ ShellControl,
23
+ SideQuestion,
24
+ )
25
+ from .hub import HubCommand
26
+
27
+ if TYPE_CHECKING: # pragma: no cover - annotations only
28
+ from zettcode.plugin_api import PluginContext
29
+
30
+
31
+ class CommandsPlugin(TuiPlugin):
32
+ """``/help``, ``/plugins``, ``/resume``, ``/model``, ``/btw``, ``/review``, ``/bash``, ``/clear``…"""
33
+
34
+ name = "commands"
35
+ description = "The slash commands that act on the application"
36
+
37
+ def __init__(self) -> None:
38
+ #: The command groups, in the order ``/help`` lists them.
39
+ self.groups: tuple[TuiPlugin, ...] = (
40
+ HelpCommand(),
41
+ PluginCommand(),
42
+ SessionCommands(),
43
+ ModelCommands(),
44
+ SideQuestion(),
45
+ CodeReview(),
46
+ BashCommand(),
47
+ ShellControl(),
48
+ HubCommand(),
49
+ )
50
+
51
+ def activate(self, ctx: PluginContext) -> None:
52
+ """Register every group's commands, in order, on this plugin's own context."""
53
+ if ctx.tui is None:
54
+ return
55
+ for group in self.groups:
56
+ group.activate(ctx)
File without changes
@@ -0,0 +1,46 @@
1
+ """``/usage``: what this workspace's sessions have spent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from zettcode.app.plugins.groups import require_tui
8
+ from zettcode.plugin_api import USAGE_LEDGER
9
+ from zettcode.plugin_api.commands import CommandContext, CommandResult
10
+ from zettcode.plugin_api.plugin import TuiPlugin
11
+ from zettcode.presenters import labels
12
+ from zettcode.sdk.translation import ledger_from
13
+
14
+ from .usage_page import UsagePage
15
+
16
+ if TYPE_CHECKING: # pragma: no cover - annotations only
17
+ from zettcode.plugin_api import PluginContext
18
+
19
+
20
+ class UsageCommand(TuiPlugin):
21
+ """``/usage``: what this workspace's sessions have spent, by model, session and day.
22
+
23
+ The numbers are read back from the stored conversations, so the page covers every
24
+ session of the workspace, not only the one open, and an hour from now it will still
25
+ agree with the files. It only reads, so it may run beside a request.
26
+ """
27
+
28
+ name = "usage"
29
+ description = "Token use by model, by session and by day"
30
+
31
+ def activate(self, ctx: PluginContext) -> None:
32
+ """Register ``/usage``; the store keeps one reader, so a second look re-reads only what changed."""
33
+ config = ctx.config
34
+ tui = require_tui(ctx)
35
+
36
+ async def show(context: CommandContext) -> CommandResult:
37
+ """Open the usage page, or say that nothing has been spent yet."""
38
+ service = ctx.agent.usage_ledger_service()
39
+ if service is None:
40
+ raise LookupError(f"No plugin provides the {USAGE_LEDGER!r} service")
41
+ usage = ledger_from(await service.ledger())
42
+ if usage.total.requests == 0:
43
+ return CommandResult(message="No model replies are recorded in this workspace yet.")
44
+ return CommandResult(widget=UsagePage(usage, workspace=labels.compact_path(config.workspace, limit=None)))
45
+
46
+ tui.register_command("usage", "Token use by model, by session and by day", show, when_busy="now")
@@ -0,0 +1,290 @@
1
+ """The page ``/usage`` opens: where the tokens went, by model, by session, and by day."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import colorsys
6
+ from collections.abc import Callable
7
+ from datetime import date, timedelta
8
+
9
+ from zettcode.presenters import usage_charts as charts
10
+ from zettcode.sdk.types import WorkspaceUsage
11
+ from zettcode.tui import SEPARATOR, KeyEvent, MouseAction, MouseEvent, Style, Theme, Widget
12
+ from zettcode.tui.render import blend, parse_hex
13
+
14
+ #: The tabs, in the order Tab walks through them.
15
+ TABS = ("Models", "Sessions", "Calendar")
16
+
17
+ #: Rows above the body: a blank, the title, the tabs, and the totals.
18
+ HEAD_ROWS = 4
19
+
20
+ #: How far a mouse-wheel notch moves the body.
21
+ WHEEL_ROWS = 3
22
+
23
+ #: Shade of each calendar level, as how far it sits from the page colour towards the busiest day's colour.
24
+ #: The first level starts well clear of the empty cell, as GitHub's does: an active day must not read as
25
+ #: a quiet one, on a dark page least of all, where a faint blend vanishes into the background. The steps
26
+ #: still grow towards the top, so the busiest days pull away from the ones below them. They are wide at the
27
+ #: bottom (a pale green was hard to tell from the level above it) and narrow at the top.
28
+ HEAT = (0.0, 0.45, 0.65, 0.83, 1.0)
29
+
30
+ #: How far the calendar's colour sits between the palette's warning yellow (0) and its orange, the write colour (1):
31
+ #: an orange-gold that no other part of the page uses for data, and one every palette defines.
32
+ HEAT_ORANGE = 0.5
33
+
34
+ #: How the busiest day deepens the accent: more saturation and less lightness, so the top of
35
+ #: the ramp reads as a deeper colour than the accent the rest of the page draws.
36
+ DEEPER_SATURATION = 1.25
37
+ DEEPER_LIGHTNESS = 0.88
38
+
39
+
40
+ def deepened_accent(colour: str) -> str:
41
+ """Return ``colour`` deeper and more saturated: the shade of the busiest calendar day.
42
+
43
+ The ramp runs from the page towards this shade rather than towards the colour itself, so
44
+ the four levels spread over a wider range and the top one is deeper than anything else on
45
+ the page. A colour that cannot be parsed is returned unchanged, as :func:`blend` does.
46
+ """
47
+ rgb = parse_hex(colour)
48
+ if rgb is None:
49
+ return colour
50
+ hue, lightness, saturation = colorsys.rgb_to_hls(*(channel / 255 for channel in rgb))
51
+ deepened = colorsys.hls_to_rgb(hue, lightness * DEEPER_LIGHTNESS, min(1.0, saturation * DEEPER_SATURATION))
52
+ return "#" + "".join(f"{round(channel * 255):02x}" for channel in deepened)
53
+
54
+
55
+ def heat_colour(theme: Theme) -> str:
56
+ """Return the colour of the busiest calendar day in a palette: a deepened orange-gold.
57
+
58
+ The calendar used to be shades of the accent, and the accent is a green made for text on a dark page; its four
59
+ levels sat close together and close to the empty cell. An orange-gold is told apart from the grey of an empty
60
+ day and from its own neighbours, and it takes nothing from the green the rest of the page draws.
61
+ """
62
+ return deepened_accent(blend(theme.warning, theme.tools.write, HEAT_ORANGE))
63
+
64
+
65
+ class UsagePage(Widget):
66
+ """A full-screen page with three views of one workspace's usage.
67
+
68
+ Shape::
69
+
70
+ Usage ~/projects/api <- title and workspace
71
+ Models Sessions Calendar <- tabs; the open one is filled
72
+ All models 1.2B tokens in ... <- totals, on every tab
73
+ <body> <- scrolls
74
+ tab switch · ↑↓ scroll · esc back <- footer
75
+
76
+ **Models** draws two bars for each model, **Sessions** one row for each session with its
77
+ input and output, and **Calendar** a grid of weeks like the one GitHub draws for
78
+ contributions: a column is a week, a square a day, and the shade is the day's tokens.
79
+ On the calendar the arrow keys choose a day and the line under the grid says what it
80
+ spent.
81
+
82
+ The page is the whole screen, and paints on every frame from the lines the chart module
83
+ describes, so a resize costs nothing but a repaint.
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ ledger: WorkspaceUsage,
89
+ *,
90
+ workspace: str = "",
91
+ on_cancel: Callable[[], None] | None = None,
92
+ today: date | None = None,
93
+ tab: str = TABS[0],
94
+ ) -> None:
95
+ """Hold the ledger to draw and start on one tab.
96
+
97
+ Args:
98
+ ledger: What the workspace has spent.
99
+ workspace: Shown beside the title, so the page says whose usage it is.
100
+ on_cancel: Called on Escape instead of the page popping itself, for an owner that
101
+ keeps its own screen stack; most callers leave it out.
102
+ today: The last day the calendar draws; the real date when omitted.
103
+ tab: Which tab opens first.
104
+ """
105
+ super().__init__()
106
+ self.ledger = ledger
107
+ self.workspace = workspace
108
+ self.on_cancel = on_cancel
109
+ self.today = today or date.today()
110
+ self.tab = TABS.index(tab) if tab in TABS else 0
111
+ self.selected = self.today
112
+ self.offset = 0
113
+ self._body_rows = 0
114
+
115
+ @property
116
+ def focusable(self) -> bool:
117
+ """Take the keyboard when the page opens, so its keys work without a click."""
118
+ return True
119
+
120
+ @property
121
+ def tab_name(self) -> str:
122
+ """Return the name of the open tab."""
123
+ return TABS[self.tab]
124
+
125
+ # -- content ------------------------------------------------------------
126
+ def lines(self, width: int) -> tuple[list[charts.Line], list[charts.Line]]:
127
+ """Return the open tab's fixed heading lines and its scrolling body."""
128
+ if self.tab_name == "Models":
129
+ return charts.models_tab(self.ledger, width)
130
+ if self.tab_name == "Sessions":
131
+ return charts.sessions_tab(self.ledger, width)
132
+ head, body, _, _ = charts.calendar_tab(self.ledger, width, self.today, self.selected)
133
+ return head, body
134
+
135
+ # -- input --------------------------------------------------------------
136
+ def close(self) -> None:
137
+ """Leave the page: through the owner's callback when it gave one, else by popping itself."""
138
+ if self.on_cancel is not None:
139
+ self.on_cancel()
140
+ elif self.app is not None and self.app.screens.top.widget is self:
141
+ self.app.pop_screen()
142
+ self.app.request_layout()
143
+
144
+ def capture_event(self, event, host) -> bool:
145
+ """Close on Escape before anything else can take the key."""
146
+ if isinstance(event, KeyEvent) and event.key == "escape":
147
+ self.close()
148
+ return True
149
+ return False
150
+
151
+ def handle(self, event, host) -> bool:
152
+ """Switch tabs, scroll the body, or move the day chosen on the calendar."""
153
+ if isinstance(event, MouseEvent):
154
+ if event.action is MouseAction.SCROLL_UP:
155
+ self._scroll(-WHEEL_ROWS)
156
+ elif event.action is MouseAction.SCROLL_DOWN:
157
+ self._scroll(WHEEL_ROWS)
158
+ else:
159
+ return False
160
+ host.invalidate()
161
+ return True
162
+ key = self._key_of(event)
163
+ if key is None:
164
+ return False
165
+ if key in ("tab", "backtab"):
166
+ self._open((self.tab + (1 if key == "tab" else -1)) % len(TABS))
167
+ elif key in ("1", "2", "3"):
168
+ self._open(int(key) - 1)
169
+ elif self.tab_name == "Calendar" and key in ("left", "right", "up", "down", "home", "end"):
170
+ self._move_day(key)
171
+ elif key in ("left", "right"):
172
+ self._open((self.tab + (1 if key == "right" else -1)) % len(TABS))
173
+ elif key in ("up", "k"):
174
+ self._scroll(-1)
175
+ elif key in ("down", "j"):
176
+ self._scroll(1)
177
+ elif key == "page_up":
178
+ self._scroll(-max(1, self._body_rows - 1))
179
+ elif key == "page_down":
180
+ self._scroll(max(1, self._body_rows - 1))
181
+ elif key == "home":
182
+ self.offset = 0
183
+ elif key == "end":
184
+ self._scroll(10**9)
185
+ elif key == "q":
186
+ self.close()
187
+ else:
188
+ return False
189
+ host.invalidate()
190
+ return True
191
+
192
+ @staticmethod
193
+ def _key_of(event: object) -> str | None:
194
+ """Return the key an event names, whether it came as a key or as typed text."""
195
+ if isinstance(event, KeyEvent):
196
+ return None if event.alt or event.control else event.key
197
+ text = getattr(event, "text", None)
198
+ return text if isinstance(text, str) and len(text) == 1 else None
199
+
200
+ def _open(self, tab: int) -> None:
201
+ """Show another tab from its top."""
202
+ self.tab = tab
203
+ self.offset = 0
204
+
205
+ def _scroll(self, rows: int) -> None:
206
+ """Move the body, never past its first row or its last."""
207
+ _, body = self.lines(max(1, self.rect.width - 4))
208
+ furthest = max(0, len(body) - self._body_rows)
209
+ self.offset = min(max(0, self.offset + rows), furthest)
210
+
211
+ def _move_day(self, key: str) -> None:
212
+ """Move the chosen calendar day: a day for up and down, a week for left and right."""
213
+ step = {"left": -7, "right": 7, "up": -1, "down": 1}.get(key)
214
+ earliest = charts.first_day(self.today, charts.weeks_that_fit(max(1, self.rect.width - 4)))
215
+ if key == "home":
216
+ self.selected = earliest
217
+ elif key == "end":
218
+ self.selected = self.today
219
+ elif step is not None:
220
+ self.selected = min(self.today, max(earliest, self.selected + timedelta(days=step)))
221
+
222
+ # -- painting -----------------------------------------------------------
223
+ def style_of(self, role: str) -> Style:
224
+ """Return how a role is painted in the active theme."""
225
+ theme = self.theme
226
+ page = theme.surface_alt
227
+ if role.startswith("heat"):
228
+ chosen = role.endswith("*")
229
+ level = int(role.removeprefix("heat").removesuffix("*"))
230
+ if level == 0:
231
+ colour = blend(page, theme.text, 0.14)
232
+ else:
233
+ colour = blend(page, heat_colour(theme), HEAT[level])
234
+ # The day the arrow keys are on sits on a neutral lift of the page: the ramp's own colours are the data,
235
+ # and a tinted ground would pull the cell towards one of its neighbours.
236
+ ground = blend(page, theme.text, 0.2) if chosen else page
237
+ return Style(foreground=colour, background=ground, bold=chosen)
238
+ colours = {
239
+ "text": theme.text,
240
+ "heading": theme.text,
241
+ "muted": theme.muted,
242
+ "input": theme.accent,
243
+ "output": theme.warning,
244
+ "cached": theme.tools.subagent,
245
+ "track": blend(page, theme.text, 0.18),
246
+ }
247
+ return Style(foreground=colours.get(role, theme.text), background=page, bold=role == "heading")
248
+
249
+ def render(self, canvas) -> None:
250
+ """Fill the page, then draw the title, the tabs, the totals, the body, and the footer."""
251
+ if self.rect.empty:
252
+ return
253
+ theme = self.theme
254
+ rect = self.rect
255
+ page = Style(background=theme.surface_alt)
256
+ canvas.fill(rect.x, rect.y, rect.width, rect.height, page)
257
+ room = max(0, rect.width - 4)
258
+ canvas.draw_text(rect.x + 2, rect.y + 1, "Usage", self.style_of("heading"), max_width=room)
259
+ canvas.draw_text(rect.x + 9, rect.y + 1, self.workspace, self.style_of("muted"), max_width=max(0, room - 7))
260
+ column = rect.x + 2
261
+ for index, name in enumerate(TABS):
262
+ label = f" {name} "
263
+ style = (
264
+ Style(foreground=theme.surface_alt, background=theme.accent, bold=True)
265
+ if index == self.tab
266
+ else self.style_of("muted")
267
+ )
268
+ column += canvas.draw_text(column, rect.y + 2, label, style) + 1
269
+ head, body = self.lines(room)
270
+ for row, line in enumerate(head):
271
+ self._draw(canvas, rect.x + 2, rect.y + 3 + row, line, room)
272
+ top = rect.y + 3 + len(head) + 1
273
+ self._body_rows = max(0, rect.bottom - 2 - top)
274
+ self.offset = min(self.offset, max(0, len(body) - self._body_rows))
275
+ for row, line in enumerate(body[self.offset : self.offset + self._body_rows]):
276
+ self._draw(canvas, rect.x + 2, top + row, line, room)
277
+ if self.tab_name == "Calendar":
278
+ keys = "arrows choose a day"
279
+ else:
280
+ keys = "\u2191\u2193 scroll" if len(body) > self._body_rows else ""
281
+ hint = f" {SEPARATOR} ".join(part for part in ("tab switch", keys, "esc back") if part)
282
+ canvas.draw_text(rect.x + 2, rect.bottom - 1, hint, self.style_of("muted"), max_width=room)
283
+
284
+ def _draw(self, canvas, x: int, y: int, line: charts.Line, room: int) -> None:
285
+ """Draw one line of roles from ``x``, stopping at the edge of the page."""
286
+ used = 0
287
+ for text, role in line:
288
+ if used >= room:
289
+ break
290
+ used += canvas.draw_text(x + used, y, text, self.style_of(role), max_width=room - used)
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.5
2
+ Name: zettcode-commands
3
+ Version: 0.4.2
4
+ Summary: The slash commands that act on ZettCode's terminal application, offered to it as a plugin
5
+ Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
6
+ Author: Chang-LeHung
7
+ License-Expression: MIT
8
+ Keywords: commands,terminal,zettcode
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: 3.15
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: zettcode-app
19
+ Requires-Dist: zettcode-engine
20
+ Requires-Dist: zettcode-plugin-api
21
+ Requires-Dist: zettcode-presenters
22
+ Requires-Dist: zettcode-sdk
23
+ Requires-Dist: zettcode-tui
@@ -0,0 +1,15 @@
1
+ zettcode/commands/__init__.py,sha256=8s-idpD7K8ar5mfvXqYXPGd2012ZKbTabVM7v-jQXik,113
2
+ zettcode/commands/context.py,sha256=TP7uue6cmbXbTFd29gL4y83-eKYWX7Vsv78_aXmkxsc,4024
3
+ zettcode/commands/context_charts.py,sha256=n6NjNOXIkuSfN-8t_qQkfyiKQ1LE-EfmGy1HuhcqVuw,13127
4
+ zettcode/commands/context_page.py,sha256=zBWpZSbMetgj1rJvMmG3D3XQu-3eqgzXupt15QEyShg,19306
5
+ zettcode/commands/handlers.py,sha256=1PTpkQCr3NrEwqtgrbELcNmDuR5bY2ctYfH7x7ssICY,31737
6
+ zettcode/commands/hub.py,sha256=nDOYwgeih9FCbq7QI2BmjP3dt6n4HMUVP6PXwRymLEs,52567
7
+ zettcode/commands/hub_graph.py,sha256=QQ9D5X12WaEJWQc7iU_deer8Pr9MGgerJsgR7a5oLGY,30889
8
+ zettcode/commands/plugin.py,sha256=MsN7v_xsfQZMvYZWCbVQKjxkj9QouD2lRi8s8OQcL7g,1801
9
+ zettcode/commands/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ zettcode/commands/usage.py,sha256=NTSPUDc_-JBayqQiirOlC7Zvq3DhPgXgOXopfPazdNc,2004
11
+ zettcode/commands/usage_page.py,sha256=BbJepFlJhlR6PhlYS3Af5cGsoAu8EhJrwbnuIuHZefs,13020
12
+ zettcode_commands-0.4.2.dist-info/METADATA,sha256=Fmx1za6dA0-5prHTDG88ejBoXZe2giNGBVrekHL5vVY,907
13
+ zettcode_commands-0.4.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
14
+ zettcode_commands-0.4.2.dist-info/entry_points.txt,sha256=sNgTapffS02eE0nLmeKamISYGIy5GmD5rb1QBspKiNo,168
15
+ zettcode_commands-0.4.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [zettcode.plugin]
2
+ commands = zettcode.commands.plugin:CommandsPlugin
3
+ context = zettcode.commands.context:ContextPagePlugin
4
+ usage = zettcode.commands.usage:UsageCommand