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,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"