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,511 @@
|
|
|
1
|
+
"""The bottom-panel list of recent sessions opened by ``/resume``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import NamedTuple
|
|
8
|
+
|
|
9
|
+
from zettcode.presenters.usage_charts import compact
|
|
10
|
+
from zettcode.sdk.types import SessionInfo, SessionStatsInfo
|
|
11
|
+
from zettcode.tui import (
|
|
12
|
+
ELLIPSIS,
|
|
13
|
+
SEPARATOR,
|
|
14
|
+
AnyEvent,
|
|
15
|
+
Canvas,
|
|
16
|
+
Host,
|
|
17
|
+
KeyEvent,
|
|
18
|
+
ListItem,
|
|
19
|
+
ListPage,
|
|
20
|
+
PasteEvent,
|
|
21
|
+
Rect,
|
|
22
|
+
Style,
|
|
23
|
+
TextEvent,
|
|
24
|
+
Theme,
|
|
25
|
+
Widget,
|
|
26
|
+
)
|
|
27
|
+
from zettcode.tui.render import blend, display_width
|
|
28
|
+
from zettcode.tui.render.text import truncate
|
|
29
|
+
from zettcode.tui.widgets.list import Part, draw_parts
|
|
30
|
+
from zettcode.wording import UNTITLED_SESSION
|
|
31
|
+
|
|
32
|
+
#: Columns the marker takes at the left of a row, and the gap between the title and the figures, as the list lays them.
|
|
33
|
+
MARKER = 2
|
|
34
|
+
GAP = 2
|
|
35
|
+
|
|
36
|
+
#: The shortest a title is cut to before a column of figures is given up for it.
|
|
37
|
+
MIN_TITLE = 18
|
|
38
|
+
|
|
39
|
+
#: What a figure reads as when the session has none: a subagent's, or one the totals could not be read for.
|
|
40
|
+
NONE = "\u2014"
|
|
41
|
+
|
|
42
|
+
#: How much of its column's colour a heading takes, over the muted text colour.
|
|
43
|
+
HEADING_SHARE = 0.6
|
|
44
|
+
|
|
45
|
+
#: How much of the cached column's colour a share of nothing keeps; a share of everything keeps all of it.
|
|
46
|
+
CACHE_FLOOR = 0.4
|
|
47
|
+
|
|
48
|
+
#: The width the panel is built for before it has been laid out, so a page painted without a layout is complete.
|
|
49
|
+
DEFAULT_WIDTH = 120
|
|
50
|
+
|
|
51
|
+
#: The corners and rules of the search box, the rounded shape a form's fields are drawn in.
|
|
52
|
+
BOX_TOP_LEFT, BOX_TOP_RIGHT = "\u256d", "\u256e"
|
|
53
|
+
BOX_BOTTOM_LEFT, BOX_BOTTOM_RIGHT = "\u2570", "\u256f"
|
|
54
|
+
BOX_HORIZONTAL, BOX_VERTICAL = "\u2500", "\u2502"
|
|
55
|
+
|
|
56
|
+
#: The label on the box's top border, and the three rows the box spends: that border, the query, its foot.
|
|
57
|
+
SEARCH_LABEL = "Search"
|
|
58
|
+
BOX_ROWS = 3
|
|
59
|
+
|
|
60
|
+
#: Rows the page spends above the first session: the top margin, the title, the box, and the heading.
|
|
61
|
+
HEADER_ROWS = 2 + BOX_ROWS + 1
|
|
62
|
+
|
|
63
|
+
#: What the search box says before anything is typed into it.
|
|
64
|
+
SEARCH_HINT = "search by title or id"
|
|
65
|
+
|
|
66
|
+
#: Where typing lands in the box: "\u2588", a full block — the solid cursor vim leaves on the cell at the end of
|
|
67
|
+
#: a line, rather than a thin bar that is easy to miss in a terminal.
|
|
68
|
+
CARET = "\u2588"
|
|
69
|
+
|
|
70
|
+
#: The keys line while nothing is typed; once a search narrows the list, Escape has a job before it can close.
|
|
71
|
+
KEYS = f"type to search {SEPARATOR} enter open {SEPARATOR} esc back"
|
|
72
|
+
SEARCHING_KEYS = f"enter open {SEPARATOR} esc clear"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def format_ago(moment: datetime, *, now: datetime | None = None) -> str:
|
|
76
|
+
"""Return how long ago ``moment`` was, in the coarsest unit that fits.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
moment: When the session last changed; stored times are UTC.
|
|
80
|
+
now: Current time, injected by tests; defaults to the real clock.
|
|
81
|
+
"""
|
|
82
|
+
seconds = max(0, int(((now or datetime.now(timezone.utc)) - moment).total_seconds()))
|
|
83
|
+
if seconds < 60:
|
|
84
|
+
return "just now"
|
|
85
|
+
if seconds < 3600:
|
|
86
|
+
return f"{seconds // 60}m ago"
|
|
87
|
+
if seconds < 86_400:
|
|
88
|
+
return f"{seconds // 3600}h ago"
|
|
89
|
+
return f"{seconds // 86_400}d ago"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _plain(theme: Theme) -> str:
|
|
93
|
+
"""Return the colour of a column with nothing to say about itself: the readable secondary text."""
|
|
94
|
+
return theme.subtle
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _muted(theme: Theme) -> Style:
|
|
98
|
+
"""Return the style of a quiet line on the panel's own surface: the box's label, and what it says while empty."""
|
|
99
|
+
return Style(foreground=theme.muted, background=theme.surface_alt)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _Column(NamedTuple):
|
|
103
|
+
"""One column of figures: its heading, its cells, which side they line up on, and what colour they are.
|
|
104
|
+
|
|
105
|
+
``hue`` is the colour the column is known by, the one the same figure has on the ``/usage`` page; ``strength``
|
|
106
|
+
is how much of it each cell takes, from ``0`` (the plain secondary colour) to ``1`` (all of it), or ``None``
|
|
107
|
+
when every cell takes all of it. A cell with no figure is always the muted colour.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
heading: str
|
|
111
|
+
cells: tuple[str, ...]
|
|
112
|
+
right: bool
|
|
113
|
+
hue: Callable[[Theme], str] = _plain
|
|
114
|
+
strength: tuple[float, ...] | None = None
|
|
115
|
+
|
|
116
|
+
def colour(self, theme: Theme, index: int) -> str:
|
|
117
|
+
"""Return the colour of one cell."""
|
|
118
|
+
if self.cells[index] == NONE:
|
|
119
|
+
return theme.muted
|
|
120
|
+
amount = 1.0 if self.strength is None else self.strength[index]
|
|
121
|
+
return blend(theme.subtle, self.hue(theme), amount)
|
|
122
|
+
|
|
123
|
+
def head_colour(self, theme: Theme) -> str:
|
|
124
|
+
"""Return the colour of the heading: the column's own, turned down so the figures stay the loudest."""
|
|
125
|
+
return blend(theme.muted, self.hue(theme), HEADING_SHARE)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def width(self) -> int:
|
|
129
|
+
"""Return the columns it takes: the widest of its heading and its cells."""
|
|
130
|
+
return max(display_width(self.heading), *(display_width(cell) for cell in self.cells), 0)
|
|
131
|
+
|
|
132
|
+
def cell(self, index: int) -> str:
|
|
133
|
+
"""Return one cell padded to the column's width."""
|
|
134
|
+
text = self.cells[index]
|
|
135
|
+
pad = " " * (self.width - display_width(text))
|
|
136
|
+
return pad + text if self.right else text + pad
|
|
137
|
+
|
|
138
|
+
def head(self) -> str:
|
|
139
|
+
"""Return the heading padded to the column's width."""
|
|
140
|
+
pad = " " * (self.width - display_width(self.heading))
|
|
141
|
+
return pad + self.heading if self.right else self.heading + pad
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _share(stats: SessionStatsInfo) -> str:
|
|
145
|
+
"""Return the cached share of a session's input as a percent to two places, or a dash before any input."""
|
|
146
|
+
share = stats.cache_share
|
|
147
|
+
return NONE if share is None else f"{share * 100:.2f}%"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class SessionsPage(ListPage):
|
|
151
|
+
"""Recent sessions as a selectable :class:`ListPage`, each with how big it is and what it cost.
|
|
152
|
+
|
|
153
|
+
Shape::
|
|
154
|
+
|
|
155
|
+
Sessions
|
|
156
|
+
\u256d\u2500 Search \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e
|
|
157
|
+
\u2502 search by title or id \u2502
|
|
158
|
+
\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
|
|
159
|
+
Updated Entries Input Output Cached ID
|
|
160
|
+
\u25b8 1. Fix the parser crash 3m ago 41 12.3K 1.2K 87% 1a2b3c4d
|
|
161
|
+
2. New session 17h ago 3 3.9K 2 0% 9f8e7d6c <- still unnamed
|
|
162
|
+
type to search \u00b7 enter open \u00b7 esc back
|
|
163
|
+
|
|
164
|
+
Entries are the message records the session's file holds; input and output are the tokens its model replies
|
|
165
|
+
were charged for, the input counting what came from the provider's cache, and the cached share is that part
|
|
166
|
+
of the input. A fork is not charged for the replies it copied, so it shows only what it spent after the fork
|
|
167
|
+
(see :mod:`zettcode.engine.storage.ledger`). The heading over the figures is the page's subtitle, aligned
|
|
168
|
+
with the column the list itself lays the figures in.
|
|
169
|
+
|
|
170
|
+
Typing narrows the list as it goes: every character goes into the search box, Backspace takes one out, and a
|
|
171
|
+
session stays only while the query appears in its title or its id, case folded, so a short id from this very
|
|
172
|
+
panel finds the session again. The order is the store's, newest first, filtered rather than sorted; an empty
|
|
173
|
+
box is the whole list in that order. The box and the list are one ring for the arrow keys: Up on the first row
|
|
174
|
+
puts the caret in the box, Up in the box wraps to the last row, and Down in the box goes back to the first, so
|
|
175
|
+
the box is reachable without a mouse. Typing fills the box from either stop, but the caret is drawn only in the
|
|
176
|
+
box's own stop — the list keeps the keyboard until the reader asks for the box. Enter opens the highlighted row
|
|
177
|
+
from either stop. Escape gives the list back to the page before it gives the page up: with something in the box
|
|
178
|
+
it clears it, and a second press closes the panel.
|
|
179
|
+
|
|
180
|
+
The rows are made for the width the page is given: when the panel is narrower than the title and every
|
|
181
|
+
column need, the columns on the right are given up one at a time, last first, until the longest title has
|
|
182
|
+
:data:`MIN_TITLE` columns (or all of it, when it is shorter), and only then is the title cut with an
|
|
183
|
+
ellipsis. The ``Updated`` column is never given up. A session without a title reads as ``New session``, the
|
|
184
|
+
same name the status line shows; the short id is what tells two of them apart. Rows commit the session id,
|
|
185
|
+
which the shell turns into ``use_session``.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
sessions: Metadata rows to show, newest first, as returned by the store.
|
|
189
|
+
stats: Each session's totals by session id. A session without an entry shows dashes. ``None`` shows no
|
|
190
|
+
figures at all, only the age and the id: what a store that could not be read gets.
|
|
191
|
+
on_select: Receives the chosen session id.
|
|
192
|
+
on_cancel: Called when the user presses Escape.
|
|
193
|
+
now: Current time for the age column; tests inject it, everything else reads the clock so a long-open
|
|
194
|
+
panel keeps counting.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
sessions: Sequence[SessionInfo],
|
|
200
|
+
*,
|
|
201
|
+
stats: Mapping[str, SessionStatsInfo] | None = None,
|
|
202
|
+
on_select: Callable[[str], None],
|
|
203
|
+
on_cancel: Callable[[], None],
|
|
204
|
+
now: datetime | None = None,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Make the rows for a wide panel; they are made again when the page learns its width."""
|
|
207
|
+
self._sessions = tuple(sessions)
|
|
208
|
+
self._visible = self._sessions
|
|
209
|
+
self._query = ""
|
|
210
|
+
self._stats = None if stats is None else dict(stats)
|
|
211
|
+
self._now = now
|
|
212
|
+
self._built_for = DEFAULT_WIDTH
|
|
213
|
+
self._head: tuple[int, Sequence[_Column]] = (0, ())
|
|
214
|
+
items = self.arrange(DEFAULT_WIDTH)
|
|
215
|
+
super().__init__(
|
|
216
|
+
items,
|
|
217
|
+
title="Sessions",
|
|
218
|
+
footer=KEYS,
|
|
219
|
+
on_select=lambda item: on_select(str(item.value)),
|
|
220
|
+
on_cancel=on_cancel,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def query(self) -> str:
|
|
225
|
+
"""Return what is in the search box."""
|
|
226
|
+
return self._query
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def heading(self) -> str:
|
|
230
|
+
"""Return the heading over the figures as one plain row: the columns' names at the widths they are drawn."""
|
|
231
|
+
indent, shown = self._head
|
|
232
|
+
return " " * indent + (" " * GAP).join(figures.head() for figures in shown)
|
|
233
|
+
|
|
234
|
+
def matching(self, query: str) -> tuple[SessionInfo, ...]:
|
|
235
|
+
"""Return the sessions a query keeps, in the store's order: newest first, never sorted again.
|
|
236
|
+
|
|
237
|
+
A query is read case folded, trimmed, and matched anywhere in the name a row shows or in the session id,
|
|
238
|
+
so ``fix`` finds a title and ``1a2b`` finds the id printed on the row. A blank query keeps everything,
|
|
239
|
+
which is the list the panel opened with.
|
|
240
|
+
"""
|
|
241
|
+
wanted = query.strip().casefold()
|
|
242
|
+
if not wanted:
|
|
243
|
+
return self._sessions
|
|
244
|
+
return tuple(
|
|
245
|
+
session
|
|
246
|
+
for session in self._sessions
|
|
247
|
+
if wanted in (session.title or UNTITLED_SESSION).casefold() or wanted in session.id.casefold()
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def search(self, text: str) -> None:
|
|
251
|
+
"""Add what was typed to the search box and make the rows again."""
|
|
252
|
+
self._set_query(self._query + text)
|
|
253
|
+
|
|
254
|
+
def _set_query(self, query: str) -> None:
|
|
255
|
+
"""Put a query in the box, narrow the list to it, and reset the highlight to the first match.
|
|
256
|
+
|
|
257
|
+
A query of nothing but spaces is no query at all: the list shows everything and Escape closes the panel
|
|
258
|
+
as it does from an empty box, rather than clearing an invisible character first. Spaces inside the box are
|
|
259
|
+
kept as typed — ``fix parser`` has to be typable — and trimmed when the rows are matched.
|
|
260
|
+
"""
|
|
261
|
+
if not query.strip():
|
|
262
|
+
query = ""
|
|
263
|
+
if query == self._query:
|
|
264
|
+
return
|
|
265
|
+
self._query = query
|
|
266
|
+
self._visible = self.matching(query)
|
|
267
|
+
items = self.arrange(self._built_for)
|
|
268
|
+
self.list.set_items(items)
|
|
269
|
+
self.footer = SEARCHING_KEYS if query else KEYS
|
|
270
|
+
self.invalidate()
|
|
271
|
+
|
|
272
|
+
def capture_event(self, event: AnyEvent, host: Host) -> bool:
|
|
273
|
+
"""Take typing as a search before the list can see it, and make the box and the list one up/down cycle.
|
|
274
|
+
|
|
275
|
+
Every character is a search term here, whichever of the two stops has the keyboard. The box is the stop
|
|
276
|
+
above the list: Up on the first row puts the cursor in the box, Up in the box wraps to the last row, and
|
|
277
|
+
Down in the box goes back to the first — so the search is reachable with the arrows and left the same way,
|
|
278
|
+
without a click. Escape still clears the box before it gives the panel up.
|
|
279
|
+
"""
|
|
280
|
+
if isinstance(event, TextEvent) and not (event.alt or event.control or event.meta):
|
|
281
|
+
self.search(event.text)
|
|
282
|
+
return True
|
|
283
|
+
if isinstance(event, PasteEvent) and not (event.alt or event.control or event.meta):
|
|
284
|
+
self.search(" ".join(event.text.split()))
|
|
285
|
+
return True
|
|
286
|
+
if isinstance(event, KeyEvent) and not (event.alt or event.control or event.meta):
|
|
287
|
+
if event.key == "backspace":
|
|
288
|
+
self._set_query(self._query[:-1])
|
|
289
|
+
return True
|
|
290
|
+
if event.key == "escape" and self._query:
|
|
291
|
+
self._set_query("")
|
|
292
|
+
return True
|
|
293
|
+
if self.focused:
|
|
294
|
+
if event.key == "up":
|
|
295
|
+
self._leave_box(host, len(self.list.items) - 1)
|
|
296
|
+
return True
|
|
297
|
+
if event.key == "down":
|
|
298
|
+
self._leave_box(host, 0)
|
|
299
|
+
return True
|
|
300
|
+
if event.key == "enter":
|
|
301
|
+
# With no match there is no row to open, but the key still belongs to the panel: it must not
|
|
302
|
+
# fall through to the shell from a box that said it was taking Enter.
|
|
303
|
+
self.list.activate()
|
|
304
|
+
return True
|
|
305
|
+
elif self.list.focused and self.list.items:
|
|
306
|
+
if event.key == "up" and self.list.selected == 0:
|
|
307
|
+
self._enter_box(host)
|
|
308
|
+
return True
|
|
309
|
+
if event.key == "down" and self.list.selected == len(self.list.items) - 1:
|
|
310
|
+
self._enter_box(host)
|
|
311
|
+
return True
|
|
312
|
+
return super().capture_event(event, host)
|
|
313
|
+
|
|
314
|
+
def _enter_box(self, host: Host | None) -> None:
|
|
315
|
+
"""Put the keyboard in the search box, keeping the row the list had highlighted."""
|
|
316
|
+
if host is None:
|
|
317
|
+
return
|
|
318
|
+
host.focus(self)
|
|
319
|
+
self.invalidate()
|
|
320
|
+
|
|
321
|
+
def _leave_box(self, host: Host | None, index: int) -> None:
|
|
322
|
+
"""Hand the keyboard back to the list on one row, unless there is no host or no row to go to."""
|
|
323
|
+
if host is None or not self.list.items:
|
|
324
|
+
return
|
|
325
|
+
self.list.select(index, notify=False)
|
|
326
|
+
host.focus(self.list)
|
|
327
|
+
self.invalidate()
|
|
328
|
+
|
|
329
|
+
def columns(self) -> list[_Column]:
|
|
330
|
+
"""Return the columns of figures, most wanted first."""
|
|
331
|
+
sessions = self._visible
|
|
332
|
+
ages = tuple(format_ago(session.updated_at, now=self._now) for session in sessions)
|
|
333
|
+
ids = tuple(session.id[:8] for session in sessions)
|
|
334
|
+
age = _Column("Updated", ages, True)
|
|
335
|
+
short = _Column("ID", ids, False, lambda theme: theme.muted)
|
|
336
|
+
stats = self._stats
|
|
337
|
+
if stats is None:
|
|
338
|
+
return [age, short]
|
|
339
|
+
rows = [stats.get(session.id) for session in sessions]
|
|
340
|
+
|
|
341
|
+
def cells(pick: Callable[[SessionStatsInfo], str]) -> tuple[str, ...]:
|
|
342
|
+
return tuple(NONE if row is None else pick(row) for row in rows)
|
|
343
|
+
|
|
344
|
+
shares = tuple(0.0 if row is None else row.cache_share or 0.0 for row in rows)
|
|
345
|
+
# A cache that did little is dimmer than one that did most of the work, so the column can be read down.
|
|
346
|
+
weights = tuple(CACHE_FLOOR + (1 - CACHE_FLOOR) * share for share in shares)
|
|
347
|
+
|
|
348
|
+
return [
|
|
349
|
+
age,
|
|
350
|
+
_Column("Entries", cells(lambda row: compact(row.entries)), True),
|
|
351
|
+
_Column("Input", cells(lambda row: compact(row.input_tokens)), True, lambda theme: theme.accent),
|
|
352
|
+
_Column("Output", cells(lambda row: compact(row.output_tokens)), True, lambda theme: theme.warning),
|
|
353
|
+
_Column("Cached", cells(_share), True, lambda theme: theme.tools.subagent, weights),
|
|
354
|
+
short,
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
def arrange(self, width: int) -> list[ListItem]:
|
|
358
|
+
"""Return the rows for a list ``width`` columns wide and remember the heading that goes over them."""
|
|
359
|
+
sessions = self._visible
|
|
360
|
+
digits = len(str(len(sessions))) if sessions else 1
|
|
361
|
+
titles = [
|
|
362
|
+
f"{index:>{digits}}. {session.title or UNTITLED_SESSION}" for index, session in enumerate(sessions, 1)
|
|
363
|
+
]
|
|
364
|
+
widest = max((display_width(title) for title in titles), default=0)
|
|
365
|
+
columns = self.columns()
|
|
366
|
+
# Give up the columns on the right while the title would be cut below what is readable.
|
|
367
|
+
keep = len(columns)
|
|
368
|
+
while keep > 1 and self._room(width, columns[:keep]) < min(MIN_TITLE, widest):
|
|
369
|
+
keep -= 1
|
|
370
|
+
shown = columns[:keep]
|
|
371
|
+
room = max(4, self._room(width, shown))
|
|
372
|
+
titles = [
|
|
373
|
+
title if display_width(title) <= room else truncate(title, room, ellipsis=ELLIPSIS) for title in titles
|
|
374
|
+
]
|
|
375
|
+
column = max((display_width(title) for title in titles), default=0)
|
|
376
|
+
items = [
|
|
377
|
+
ListItem(
|
|
378
|
+
session.id,
|
|
379
|
+
title,
|
|
380
|
+
(" " * GAP).join(figures.cell(index) for figures in shown),
|
|
381
|
+
parts=self._figures(shown, index),
|
|
382
|
+
)
|
|
383
|
+
for index, (session, title) in enumerate(zip(sessions, titles, strict=True))
|
|
384
|
+
]
|
|
385
|
+
self._head = (MARKER + column + GAP, shown)
|
|
386
|
+
return items
|
|
387
|
+
|
|
388
|
+
@staticmethod
|
|
389
|
+
def _figures(shown: Sequence[_Column], index: int) -> Callable[[Theme], list[Part]]:
|
|
390
|
+
"""Return a row's figures as coloured pieces, each column in its own hue and the gaps between them bare."""
|
|
391
|
+
|
|
392
|
+
def parts(theme: Theme) -> list[Part]:
|
|
393
|
+
pieces: list[Part] = []
|
|
394
|
+
for position, figures in enumerate(shown):
|
|
395
|
+
if position:
|
|
396
|
+
pieces.append((" " * GAP, None))
|
|
397
|
+
pieces.append((figures.cell(index), figures.colour(theme, index)))
|
|
398
|
+
return pieces
|
|
399
|
+
|
|
400
|
+
return parts
|
|
401
|
+
|
|
402
|
+
def _heading_parts(self, theme: Theme) -> list[Part]:
|
|
403
|
+
"""Return the heading over the figures with each heading word in the hue of the column under it."""
|
|
404
|
+
indent, shown = self._head
|
|
405
|
+
pieces: list[Part] = [(" " * indent, None)]
|
|
406
|
+
for position, figures in enumerate(shown):
|
|
407
|
+
if position:
|
|
408
|
+
pieces.append((" " * GAP, None))
|
|
409
|
+
pieces.append((figures.head(), figures.head_colour(theme)))
|
|
410
|
+
return pieces
|
|
411
|
+
|
|
412
|
+
@staticmethod
|
|
413
|
+
def _room(width: int, columns: Sequence[_Column]) -> int:
|
|
414
|
+
"""Return the columns a title may take beside these columns of figures in a list ``width`` wide."""
|
|
415
|
+
figures = sum(figure.width for figure in columns) + GAP * max(0, len(columns) - 1)
|
|
416
|
+
return width - MARKER - GAP - figures
|
|
417
|
+
|
|
418
|
+
def layout(self, rect: Rect) -> None:
|
|
419
|
+
"""Make the rows again when the width has changed since they were made, keeping the highlighted one.
|
|
420
|
+
|
|
421
|
+
The list starts under the title, the boxed search field and the heading, which no longer fits a
|
|
422
|
+
:class:`ListPage`'s three-row frame; the rows below are bounded so the keys keep the bottom row.
|
|
423
|
+
"""
|
|
424
|
+
width = max(0, rect.width - 4)
|
|
425
|
+
if width != self._built_for:
|
|
426
|
+
self._built_for = width
|
|
427
|
+
items = self.arrange(width)
|
|
428
|
+
selected = max(0, self.list.selected)
|
|
429
|
+
self.list.set_items(items)
|
|
430
|
+
self.list.select(selected, notify=False)
|
|
431
|
+
Widget.layout(self, rect)
|
|
432
|
+
rows = max(0, rect.height - HEADER_ROWS - 2)
|
|
433
|
+
self.list.layout(Rect(rect.x + 2, rect.y + HEADER_ROWS, max(0, rect.width - 4), rows))
|
|
434
|
+
|
|
435
|
+
def render(self, canvas: Canvas) -> None:
|
|
436
|
+
"""Fill the page, then draw what fits of the title, box, heading, rows, and keys.
|
|
437
|
+
|
|
438
|
+
The footer keeps the bottom row. On a page too short for the rest, the title, the box, the heading and the
|
|
439
|
+
line that says nothing matched are drawn only while their own rows are above it, so none is painted over
|
|
440
|
+
the keys.
|
|
441
|
+
"""
|
|
442
|
+
if self.rect.empty:
|
|
443
|
+
return
|
|
444
|
+
theme = self.theme
|
|
445
|
+
canvas.fill(self.rect.x, self.rect.y, self.rect.width, self.rect.height, Style(background=theme.surface_alt))
|
|
446
|
+
room = max(0, self.rect.width - 4)
|
|
447
|
+
x = self.rect.x + 2
|
|
448
|
+
heading = Style(foreground=theme.text, background=theme.surface_alt, bold=True)
|
|
449
|
+
hint = Style(foreground=theme.muted, background=theme.surface_alt)
|
|
450
|
+
bottom = self.rect.bottom - 1
|
|
451
|
+
if self.rect.y + 1 < bottom:
|
|
452
|
+
canvas.draw_text(x, self.rect.y + 1, self.title, heading, max_width=room)
|
|
453
|
+
if self.rect.y + 2 + BOX_ROWS - 1 < bottom:
|
|
454
|
+
self._paint_search(canvas, x, self.rect.y + 2, room)
|
|
455
|
+
heading_y = self.rect.y + 2 + BOX_ROWS
|
|
456
|
+
if heading_y < bottom:
|
|
457
|
+
draw_parts(canvas, x, heading_y, self._heading_parts(theme), hint, max_width=room)
|
|
458
|
+
if self.list.items:
|
|
459
|
+
self.list.render(canvas)
|
|
460
|
+
elif self._query and heading_y + 1 < bottom:
|
|
461
|
+
canvas.draw_text(x, heading_y + 1, self._no_match(), hint, max_width=room)
|
|
462
|
+
canvas.draw_text(x, bottom, self.footer, hint, max_width=room)
|
|
463
|
+
|
|
464
|
+
def _paint_search(self, canvas: Canvas, x: int, y: int, room: int) -> None:
|
|
465
|
+
"""Draw the rounded box, its label, and the query — or what the box is for while it is empty.
|
|
466
|
+
|
|
467
|
+
The border and the label take the bright accent while the box is the keyboard stop, which is also when
|
|
468
|
+
the caret is drawn; the box beside a focused list is quiet, since typing still reaches it either way.
|
|
469
|
+
"""
|
|
470
|
+
if room < 2:
|
|
471
|
+
return # narrower than its own two uprights: there is no box to draw
|
|
472
|
+
theme = self.theme
|
|
473
|
+
active = self.focused
|
|
474
|
+
frame = Style(foreground=theme.accent_bright if active else theme.subtle, background=theme.surface_alt)
|
|
475
|
+
rule = BOX_HORIZONTAL * max(0, room - 2)
|
|
476
|
+
canvas.draw_text(x, y, f"{BOX_TOP_LEFT}{rule}{BOX_TOP_RIGHT}", frame, max_width=room)
|
|
477
|
+
canvas.draw_text(x, y + 1, BOX_VERTICAL, frame)
|
|
478
|
+
canvas.draw_text(x + room - 1, y + 1, BOX_VERTICAL, frame)
|
|
479
|
+
canvas.draw_text(x, y + 2, f"{BOX_BOTTOM_LEFT}{rule}{BOX_BOTTOM_RIGHT}", frame, max_width=room)
|
|
480
|
+
if room > 8:
|
|
481
|
+
label = Style(
|
|
482
|
+
foreground=theme.accent_bright if active else theme.muted,
|
|
483
|
+
background=theme.surface_alt,
|
|
484
|
+
bold=active,
|
|
485
|
+
)
|
|
486
|
+
canvas.draw_text(x + 2, y, f" {SEARCH_LABEL} ", label, max_width=room - 4)
|
|
487
|
+
# A cell of padding inside each upright, then the query itself: no prompt mark, the box already says
|
|
488
|
+
# that this is where typing goes.
|
|
489
|
+
field_x = x + 2
|
|
490
|
+
field_room = max(0, room - 4)
|
|
491
|
+
if field_room <= 0:
|
|
492
|
+
return # a box too narrow to hold a query keeps its border and says nothing inside it
|
|
493
|
+
caret = Style(foreground=theme.text, background=theme.surface_alt)
|
|
494
|
+
if self._query:
|
|
495
|
+
shown = truncate(self._query, max(0, field_room - (1 if active else 0)), ellipsis=ELLIPSIS)
|
|
496
|
+
canvas.draw_text(
|
|
497
|
+
field_x, y + 1, shown, Style(foreground=theme.text, background=theme.surface_alt), max_width=field_room
|
|
498
|
+
)
|
|
499
|
+
if active:
|
|
500
|
+
canvas.draw_text(field_x + display_width(shown), y + 1, CARET, caret, max_width=1)
|
|
501
|
+
elif active:
|
|
502
|
+
canvas.draw_text(field_x, y + 1, CARET + " ", caret, max_width=field_room)
|
|
503
|
+
hint = truncate(SEARCH_HINT, max(0, field_room - 2), ellipsis=ELLIPSIS)
|
|
504
|
+
canvas.draw_text(field_x + 2, y + 1, hint, _muted(theme), max_width=max(0, field_room - 2))
|
|
505
|
+
else:
|
|
506
|
+
hint = truncate(SEARCH_HINT, field_room, ellipsis=ELLIPSIS)
|
|
507
|
+
canvas.draw_text(field_x, y + 1, hint, _muted(theme), max_width=field_room)
|
|
508
|
+
|
|
509
|
+
def _no_match(self) -> str:
|
|
510
|
+
"""Return the line a search with no matches leaves in place of the rows."""
|
|
511
|
+
return f"No session matches \u201c{self._query.strip()}\u201d"
|