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,294 @@
1
+ """The bottom panel ``/effort`` opens: a rail with a node for every level and a knob that slides along it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping, Sequence
6
+ from time import monotonic
7
+
8
+ from zettcode.plugin_api.panels import pressed
9
+ from zettcode.tui import SEPARATOR, Canvas, Host, MouseAction, MouseEvent, Theme, Widget
10
+ from zettcode.tui.render import Style, blend, display_width, truncate
11
+
12
+ #: Rows the panel takes: a blank, the title, a blank, the name over the knob, the rail, the labels, what is
13
+ #: in force, the note, and the keys.
14
+ EFFORT_ROWS = 9
15
+
16
+ #: Seconds the knob takes to travel to the level it was sent to.
17
+ SLIDE_SECONDS = 0.28
18
+
19
+ #: Columns kept free on each side of the rail so the first and last labels stay inside the panel.
20
+ RAIL_MARGIN = 5
21
+
22
+ #: Characters of the rail: a lit stretch, the groove it runs in, a node passed, a node ahead, and the knob.
23
+ LIT, GROOVE, PASSED, AHEAD, KNOB = "\u2501", "\u2500", "\u25cf", "\u25cb", "\u25c9"
24
+
25
+
26
+ def heat_colour(theme: Theme, heat: float) -> str:
27
+ """Return the colour of a point on the effort scale, from cheap to dear.
28
+
29
+ The ramp runs through the theme's own accents \u2014 green, a brighter green, amber, and the error colour
30
+ \u2014 rather than through fixed hues, so it follows ``/theme`` and a light palette gets the darker ends of
31
+ the same story. Position on the scale is the only input: the same level is the same colour whether
32
+ it is being switched to or away from. The header's gauge and this panel read the same ramp.
33
+ """
34
+ stops = (theme.accent, theme.accent_bright, theme.warning, theme.error)
35
+ scaled = min(max(heat, 0.0), 1.0) * (len(stops) - 1)
36
+ index = min(int(scaled), len(stops) - 2)
37
+ return blend(stops[index], stops[index + 1], scaled - index)
38
+
39
+
40
+ class EffortPage(Widget):
41
+ """Choose how much the model reasons by sliding a knob along a rail.
42
+
43
+ Shape::
44
+
45
+ Reasoning effort medium \u2192 high
46
+
47
+ high
48
+ \u25cf\u2501\u2501\u2501\u2501\u2501\u25cf\u2501\u2501\u2501\u2501\u2501\u25cf\u2501\u2501\u2501\u2501\u2501\u25c9\u2500\u2500\u2500\u2500\u2500\u25cb\u2500\u2500\u2500\u2500\u2500\u25cb\u2500\u2500\u2500\u2500\u2500\u25cb
49
+ off minimal low medium high xhigh max ultra
50
+ now
51
+ more thought on hard problems
52
+
53
+ \u2190 \u2192 move \u00b7 enter select \u00b7 esc cancel
54
+
55
+ Left and Right send the knob to the next level and it *travels* there: it eases along the rail, the
56
+ stretch behind it lights in the colour of the scale (green for the cheap end, warm for the dear end), the
57
+ nodes it passes light as it reaches them, and the name over it follows. Nothing is changed by moving
58
+ it: Enter applies the level under the knob and puts the panel away, which is when the header plays its
59
+ own gauge; Escape puts the panel away and leaves the level as it was. Left and Right stop at the ends of the
60
+ scale, which is ordered cheapest first: wrapping from ``ultra`` to ``off`` would be the one move in the
61
+ row that takes a reader across the whole range.
62
+
63
+ The motion is the clock's, not a frame count: the knob is placed from the time since it set off, so a slow
64
+ terminal drops frames instead of slowing the knob down, and under reduced motion it simply jumps.
65
+
66
+ Args:
67
+ levels: The levels, cheapest first.
68
+ current: The level in force when the panel opens.
69
+ notes: One line per level, shown under the rail for the level under the knob.
70
+ on_select: Receives the level Enter applied.
71
+ on_cancel: Called when the reader backs out.
72
+ """
73
+
74
+ HINT = f"\u2190 \u2192 move {SEPARATOR} enter select {SEPARATOR} esc cancel"
75
+
76
+ def __init__(
77
+ self,
78
+ levels: Sequence[str],
79
+ current: str,
80
+ *,
81
+ notes: Mapping[str, str] | None = None,
82
+ on_select: Callable[[str], None],
83
+ on_cancel: Callable[[], None],
84
+ ) -> None:
85
+ """Start with the knob on ``current`` (or the first level when it is not one of ``levels``)."""
86
+ super().__init__()
87
+ self.levels = tuple(levels)
88
+ self.notes = dict(notes or {})
89
+ self.on_select = on_select
90
+ self.on_cancel = on_cancel
91
+ self.start = self.levels.index(current) if current in self.levels else 0
92
+ self.selected = self.start
93
+ self._origin = float(self.start)
94
+ self._set_off = 0.0
95
+ self._token = f"effort-panel:{id(self)}"
96
+ self._moving = False
97
+
98
+ @property
99
+ def focusable(self) -> bool:
100
+ """Take focus when the shell pushes the page, so the keys reach it."""
101
+ return True
102
+
103
+ @property
104
+ def level(self) -> str:
105
+ """Return the level under the knob once it arrives."""
106
+ return self.levels[self.selected] if self.levels else ""
107
+
108
+ @property
109
+ def moving(self) -> bool:
110
+ """Return whether the knob is still on its way."""
111
+ return self._moving
112
+
113
+ def _now(self) -> float:
114
+ return self.app.clock() if self.app is not None else monotonic()
115
+
116
+ def position(self) -> float:
117
+ """Return where the knob is on the scale now, in levels from the first: a fraction while it travels."""
118
+ if not self._moving:
119
+ return float(self.selected)
120
+ progress = (self._now() - self._set_off) / SLIDE_SECONDS
121
+ if progress >= 1.0:
122
+ return float(self.selected)
123
+ eased = 1 - (1 - max(0.0, progress)) ** 3
124
+ return self._origin + (self.selected - self._origin) * eased
125
+
126
+ def choose(self, index: int) -> None:
127
+ """Send the knob to a level by position; the one it is already heading for is left alone."""
128
+ if not self.levels:
129
+ return
130
+ index = max(0, min(len(self.levels) - 1, index))
131
+ if index == self.selected:
132
+ return
133
+ self._origin = self.position()
134
+ self.selected = index
135
+ self._set_off = self._now()
136
+ if self.app is not None and self.app.reduced_motion:
137
+ self._origin = float(index)
138
+ self._moving = False
139
+ return
140
+ self._moving = True
141
+ if self.app is not None:
142
+ self.app.scheduler.animate(self._token)
143
+
144
+ def on_unmount(self) -> None:
145
+ """Release the frame loop, so a panel put away mid-slide stops costing frames."""
146
+ self._moving = False
147
+ if self.app is not None:
148
+ self.app.scheduler.animate(self._token, active=False)
149
+
150
+ def on_tick(self) -> bool:
151
+ """Hand the frame loop back once the knob has arrived; say whether the picture is still changing."""
152
+ if self._moving and self.position() == float(self.selected):
153
+ self._moving = False
154
+ self._origin = float(self.selected)
155
+ if self.app is not None:
156
+ self.app.scheduler.animate(self._token, active=False)
157
+ return True # the last frame, with the knob on its node
158
+ return self._moving
159
+
160
+ def capture_event(self, event, host: Host) -> bool:
161
+ """Take Escape before the focused widget can."""
162
+ if pressed(event) == "escape":
163
+ self.on_cancel()
164
+ return True
165
+ return False
166
+
167
+ def handle(self, event, host: Host) -> bool:
168
+ """Slide with Left and Right (and Home and End), and apply with Enter."""
169
+ if isinstance(event, MouseEvent):
170
+ return self._handle_mouse(event)
171
+ match pressed(event):
172
+ case "left":
173
+ self.choose(self.selected - 1)
174
+ case "right":
175
+ self.choose(self.selected + 1)
176
+ case "home":
177
+ self.choose(0)
178
+ case "end":
179
+ self.choose(len(self.levels) - 1)
180
+ case "enter":
181
+ self.on_select(self.level)
182
+ case _:
183
+ return False
184
+ return True
185
+
186
+ def _handle_mouse(self, event: MouseEvent) -> bool:
187
+ """Send the knob to the level nearest a click on the rail or its labels."""
188
+ top = self.rect.y + 3
189
+ if event.action is not MouseAction.DOWN or not top <= event.y <= top + 3 or not self.levels:
190
+ return False
191
+ left, right = self._rail()
192
+ nearest = min(range(len(self.levels)), key=lambda index: abs(self._column(index, left, right) - event.x))
193
+ self.choose(nearest)
194
+ return True
195
+
196
+ def _rail(self) -> tuple[int, int]:
197
+ """Return the first and last column of the rail."""
198
+ left = self.rect.x + RAIL_MARGIN
199
+ return left, max(left, self.rect.right - RAIL_MARGIN - 1)
200
+
201
+ def _column(self, index: float, left: int, right: int) -> int:
202
+ """Return the column a (possibly fractional) level sits at."""
203
+ steps = max(1, len(self.levels) - 1)
204
+ return left + round(index * (right - left) / steps)
205
+
206
+ def render(self, canvas: Canvas) -> None:
207
+ """Fill the panel, then draw the title, the rail with its knob, the labels, the note and the keys."""
208
+ if self.rect.empty:
209
+ return
210
+ theme = self.theme
211
+ surface = theme.surface_alt
212
+ canvas.fill(self.rect.x, self.rect.y, self.rect.width, self.rect.height, Style(background=surface))
213
+ x = self.rect.x + 2
214
+ room = max(0, self.rect.width - 4)
215
+ muted = Style(foreground=theme.muted, background=surface)
216
+ canvas.draw_text(
217
+ x,
218
+ self.rect.y + 1,
219
+ "Reasoning effort",
220
+ Style(foreground=theme.text, background=surface, bold=True),
221
+ max_width=room,
222
+ )
223
+ if not self.levels:
224
+ return
225
+ last = max(1, len(self.levels) - 1)
226
+ position = self.position()
227
+ nearest = min(len(self.levels) - 1, max(0, round(position)))
228
+ if self.selected != self.start:
229
+ change = f"{self.levels[self.start]} \u2192 {self.level}"
230
+ canvas.draw_text(
231
+ self.rect.right - 2 - display_width(change),
232
+ self.rect.y + 1,
233
+ change,
234
+ Style(foreground=heat_colour(theme, self.selected / last), background=surface),
235
+ max_width=room,
236
+ )
237
+ left, right = self._rail()
238
+ knob = self._column(position, left, right)
239
+ rail_y = self.rect.y + 4
240
+ groove = blend(theme.muted, surface, 0.55)
241
+ # The rail first: lit up to the knob in the colour of where each cell sits on the scale, groove after it.
242
+ for column in range(left, right + 1):
243
+ if column <= knob:
244
+ heat = (column - left) / max(1, right - left)
245
+ canvas.draw_text(column, rail_y, LIT, Style(foreground=heat_colour(theme, heat), background=surface))
246
+ else:
247
+ canvas.draw_text(column, rail_y, GROOVE, Style(foreground=groove, background=surface))
248
+ for index in range(len(self.levels)):
249
+ column = self._column(index, left, right)
250
+ if column < knob:
251
+ style = Style(foreground=heat_colour(theme, index / last), background=surface, bold=True)
252
+ canvas.draw_text(column, rail_y, PASSED, style)
253
+ elif column > knob:
254
+ canvas.draw_text(
255
+ column, rail_y, AHEAD, Style(foreground=blend(theme.muted, surface, 0.2), background=surface)
256
+ )
257
+ knob_colour = heat_colour(theme, position / last)
258
+ canvas.draw_text(
259
+ knob, rail_y, KNOB, Style(foreground=blend(knob_colour, theme.text, 0.35), background=surface, bold=True)
260
+ )
261
+ # The name over the knob follows it, in the colour of the level it is nearest.
262
+ word = self.levels[nearest]
263
+ over = min(max(knob - display_width(word) // 2, x), max(x, self.rect.right - 2 - display_width(word)))
264
+ canvas.draw_text(
265
+ over, rail_y - 1, word, Style(foreground=heat_colour(theme, nearest / last), background=surface, bold=True)
266
+ )
267
+ self._labels(canvas, rail_y + 1, left, right, nearest, muted)
268
+ now_column = self._column(self.start, left, right) - 1
269
+ canvas.draw_text(now_column, rail_y + 2, "now", muted, max_width=max(0, self.rect.right - 1 - now_column))
270
+ note = self.notes.get(self.level, "")
271
+ if note:
272
+ canvas.draw_text(x, rail_y + 3, truncate(note, room), muted, max_width=room)
273
+ canvas.draw_text(x, self.rect.bottom - 1, truncate(self.HINT, room), muted, max_width=room)
274
+
275
+ def _labels(self, canvas: Canvas, y: int, left: int, right: int, nearest: int, muted: Style) -> None:
276
+ """Write each level under its node, skipping the ones that would run into a label already written.
277
+
278
+ The level the knob is on is written first and never skipped, then the rest from the left, so on a
279
+ narrow panel what is left is the knob's neighbourhood rather than an arbitrary run.
280
+ """
281
+ theme = self.theme
282
+ taken: list[tuple[int, int]] = []
283
+ order = [nearest] + [index for index in range(len(self.levels)) if index != nearest]
284
+ for index in order:
285
+ label = self.levels[index]
286
+ width = display_width(label)
287
+ start = self._column(index, left, right) - width // 2
288
+ start = max(self.rect.x + 1, min(start, self.rect.right - 1 - width))
289
+ if any(start < stop + 1 and first < start + width + 1 for first, stop in taken):
290
+ continue
291
+ taken.append((start, start + width))
292
+ on = index == nearest
293
+ style = Style(foreground=theme.text if on else theme.muted, background=theme.surface_alt, bold=on)
294
+ canvas.draw_text(start, y, label, style, max_width=width)
@@ -0,0 +1,168 @@
1
+ """Messages held to be sent once the running request is over.
2
+
3
+ A follow-up is the other kind of message a reader can send while the agent is working.
4
+ A steering message is handed to the agent, which adopts it at its next boundary and
5
+ drops the rest of the task; a follow-up waits here, in the reader's hands, and goes out
6
+ as a turn of its own when the request ends. Because it has not been handed to anyone it
7
+ can still be changed, so this container — unlike the steering one — has a highlight,
8
+ and the row under the list says which keys use it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Sequence
14
+
15
+ from zettcode.plugin_api.presenters import CONTENT_INDENT
16
+ from zettcode.tui import MARKER, OPTION, SEPARATOR, Canvas, Constraints, Size, Style, Widget
17
+ from zettcode.tui.render import ELLIPSIS, display_width, truncate
18
+
19
+ #: Right-aligned badge that says a row is a follow-up.
20
+ FOLLOW_UP_BADGE = "follow-up"
21
+ #: What stands before a waiting message: a bullet, because it is an item in a list.
22
+ BULLET = "\u2022"
23
+ #: What the modifier key is called on this machine's keyboard, from :mod:`zettcode.tui`.
24
+ #: The same physical key sends the same bytes everywhere; only its name differs, and a hint
25
+ #: that says "alt" to someone looking at a key marked "option" is a hint they translate.
26
+ #: The row under the list when nothing is highlighted: what the container is, and the
27
+ #: key that reaches the rest.
28
+ HINT_IDLE = f"sent in order after a reply completes {SEPARATOR} {OPTION}+\u2191/\u2193 to select"
29
+ #: The same row when no request is running. The list is still there — a request that was
30
+ #: stopped leaves it alone — and "after this request" would be about nothing.
31
+ HINT_HELD = f"held until your next reply completes {SEPARATOR} {OPTION}+\u2191/\u2193 to select"
32
+ #: The row under the list while one message is highlighted: everything it can do. The arrows
33
+ #: move the highlight, and with the modifier they move the message.
34
+ HINT_SELECTED = (
35
+ f"\u2191/\u2193 select {SEPARATOR} {OPTION}+\u2191/\u2193 reorder {SEPARATOR} enter edit "
36
+ f"{SEPARATOR} ^S steer {SEPARATOR} del remove {SEPARATOR} esc done"
37
+ )
38
+ #: Left margin, shared with the transcript and the composer prompt.
39
+ INDENT = CONTENT_INDENT
40
+ #: Columns the marker and the space after it take.
41
+ MARKER_COLUMNS = 2
42
+ #: Cells kept between the message and the badge so they never touch.
43
+ BADGE_GAP = 2
44
+
45
+
46
+ def one_line(message: str) -> str:
47
+ """Return the first line of a message, marked when there were more.
48
+
49
+ A row is one line tall, and a message with a line break in it would otherwise draw a
50
+ control character into the middle of the list. The ellipsis says the rest is there.
51
+ """
52
+ lines = message.strip().splitlines()
53
+ head = lines[0].strip() if lines else ""
54
+ return f"{head} {ELLIPSIS}" if len(lines) > 1 else head
55
+
56
+
57
+ class FollowUpQueue(Widget):
58
+ """Show the messages waiting for the request to end, one row each, with a highlight.
59
+
60
+ Shape::
61
+
62
+ \u2022 then run the tests follow-up
63
+ \u25b8 summarize what changed follow-up <- highlighted
64
+ \u2022 and open a pull request follow-up
65
+ \u2191/\u2193 select \u00b7 option+\u2191/\u2193 reorder \u00b7 enter edit \u00b7 ^S steer \u00b7 del remove \u00b7 esc done
66
+ ^ marker, body text right-aligned badge, then the keys
67
+
68
+ The rows are in the order they will be sent, the first at the top. The highlighted
69
+ row is banded and bold, the way a picker marks its choice, and the row under the
70
+ list changes with it: before anything is highlighted it names the one key that
71
+ starts, after it lists the rest. An empty queue asks for no rows, so the body slot
72
+ collapses and the composer keeps its place.
73
+ """
74
+
75
+ def __init__(self) -> None:
76
+ """Start with nothing waiting and nothing highlighted."""
77
+ super().__init__()
78
+ self.messages: tuple[str, ...] = ()
79
+ self.selected: int | None = None
80
+ #: Whether nothing is running, so the list waits for the reader's next reply
81
+ #: rather than for the one in flight.
82
+ self.held = False
83
+
84
+ @property
85
+ def visible(self) -> bool:
86
+ """Return whether any messages are waiting."""
87
+ return bool(self.messages)
88
+
89
+ def set_messages(self, messages: Sequence[str], selected: int | None = None, *, held: bool = False) -> bool:
90
+ """Replace the list and the highlight, and report whether anything changed.
91
+
92
+ Args:
93
+ messages: What is waiting, in the order it will be sent.
94
+ selected: Index of the highlighted row, or ``None``; one past the end is
95
+ treated as none, because the shell asks for it when the list shrinks.
96
+ held: Whether no request is running, which changes what the keys line says
97
+ the list is waiting for.
98
+ """
99
+ updated = tuple(messages)
100
+ chosen = selected if selected is not None and 0 <= selected < len(updated) else None
101
+ if updated == self.messages and chosen == self.selected and held == self.held:
102
+ return False
103
+ self.messages = updated
104
+ self.selected = chosen
105
+ self.held = held
106
+ self.invalidate()
107
+ return True
108
+
109
+ @property
110
+ def hint(self) -> str:
111
+ """Return the keys line for the current state."""
112
+ if self.selected is not None:
113
+ return HINT_SELECTED
114
+ return HINT_HELD if self.held else HINT_IDLE
115
+
116
+ def preferred_height(self) -> int:
117
+ """Return one row per message plus the keys line, or none."""
118
+ return len(self.messages) + 1 if self.messages else 0
119
+
120
+ def measure(self, constraints: Constraints) -> Size:
121
+ """Ask for the widest row, the keys line included."""
122
+ widest = max((display_width(one_line(message)) for message in self.messages), default=0)
123
+ row = INDENT + MARKER_COLUMNS + widest + BADGE_GAP + display_width(FOLLOW_UP_BADGE)
124
+ note = INDENT + max(display_width(HINT_SELECTED), display_width(HINT_HELD))
125
+ return constraints.constrain(Size(max(row, note), self.preferred_height()))
126
+
127
+ def render(self, canvas: Canvas) -> None:
128
+ """Paint the rows, banding the highlighted one, and the keys line under them."""
129
+ if not self.messages or self.rect.empty:
130
+ return
131
+ theme = self.theme
132
+ canvas.fill(self.rect.x, self.rect.y, self.rect.width, self.rect.height, Style(background=theme.surface), " ")
133
+ width = self.rect.width
134
+ badge = display_width(FOLLOW_UP_BADGE)
135
+ left = INDENT + MARKER_COLUMNS
136
+ reserved = left + badge + BADGE_GAP
137
+ # A row too narrow for both drops the badge rather than the message.
138
+ show_badge = width >= reserved + 4
139
+ text_width = max(0, width - (reserved if show_badge else left))
140
+ for row, message in enumerate(self.messages):
141
+ y = self.rect.y + row
142
+ if y >= self.rect.y + self.rect.height:
143
+ return
144
+ chosen = row == self.selected
145
+ if chosen:
146
+ band = Style(foreground=theme.text, background=theme.selection, bold=True)
147
+ canvas.fill(self.rect.x, y, width, 1, band, " ")
148
+ marker, body, badge_style = MARKER, band, Style(foreground=theme.muted, background=theme.selection)
149
+ else:
150
+ marker = BULLET
151
+ body = Style(foreground=theme.text)
152
+ badge_style = Style(foreground=theme.muted)
153
+ canvas.draw_text(
154
+ self.rect.x + INDENT, y, marker, Style(foreground=theme.accent, background=body.background, bold=chosen)
155
+ )
156
+ canvas.draw_text(self.rect.x + left, y, truncate(one_line(message), text_width), body, max_width=text_width)
157
+ if show_badge:
158
+ canvas.draw_text(self.rect.x + width - badge, y, FOLLOW_UP_BADGE, badge_style)
159
+ note_y = self.rect.y + len(self.messages)
160
+ if note_y < self.rect.y + self.rect.height:
161
+ note_width = max(0, width - INDENT)
162
+ canvas.draw_text(
163
+ self.rect.x + INDENT,
164
+ note_y,
165
+ truncate(self.hint, note_width),
166
+ Style(foreground=theme.muted),
167
+ max_width=note_width,
168
+ )