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,161 @@
1
+ """Widgets the ZettCode shell composes, one component per module.
2
+
3
+ These are the application's own pieces — the transcript view, the composer's
4
+ command completion, the model picker, the session list, the approval prompt,
5
+ the root, and the banner — as opposed to ``zettcode.tui``, which holds the
6
+ reusable framework they are built from.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib
12
+ from typing import TYPE_CHECKING
13
+
14
+ #: Where each public name lives; read by :func:`__getattr__` on first use.
15
+ _EXPORTS = {
16
+ "ApprovalChoice": ".approval_page",
17
+ "ApprovalPage": ".approval_page",
18
+ "AskUserPage": ".ask_page",
19
+ "BashPage": ".bash_page",
20
+ "CommandCompleter": ".completer",
21
+ "Composer": ".composer",
22
+ "LOCK_CAPTIONS": ".composer",
23
+ "UPDATING_CAPTIONS": ".composer",
24
+ "DocumentPage": ".document_page",
25
+ "EFFORT_ROWS": ".effort_page",
26
+ "EffortPage": ".effort_page",
27
+ "FollowUpQueue": ".follow_up",
28
+ "FORM_CHROME_ROWS": ".form_page",
29
+ "Field": ".form_page",
30
+ "FormPage": ".form_page",
31
+ "form_rows": ".form_page",
32
+ "model_settings_rows": ".model_settings_page",
33
+ "INPUT_ROWS": ".input_page",
34
+ "InputPage": ".input_page",
35
+ "LATER": ".update_page",
36
+ "ModelPage": ".model_page",
37
+ "ModelSettingsPage": ".model_settings_page",
38
+ "PetView": ".pet",
39
+ "PetPage": ".pet_page",
40
+ "PluginRow": ".plugins_page",
41
+ "PluginsPage": ".plugins_page",
42
+ "SEND_KIND_ROWS": ".send_kind_page",
43
+ "SELF_COMPACT_ROWS": ".self_compact_page",
44
+ "SKIP": ".update_page",
45
+ "SelfCompactPage": ".self_compact_page",
46
+ "SendKind": ".send_kind_page",
47
+ "SendKindPage": ".send_kind_page",
48
+ "SessionsPage": ".sessions_page",
49
+ "SteeringQueue": ".steering",
50
+ "StatuslinePage": ".statusline_page",
51
+ "TranscriptSource": ".transcript",
52
+ "TranscriptView": ".transcript",
53
+ "UPGRADE": ".update_page",
54
+ "UpdatePage": ".update_page",
55
+ "WELCOME": ".welcome",
56
+ "ZettCodeRoot": ".root",
57
+ "bottom_panel": "zettcode.plugin_api.panels",
58
+ "format_ago": ".sessions_page",
59
+ "help_text": ".completer",
60
+ "pet_rows": ".pet_page",
61
+ "statusline_rows": ".statusline_page",
62
+ }
63
+
64
+
65
+ if TYPE_CHECKING: # pragma: no cover - for type checkers, not the runtime
66
+ from zettcode.plugin_api.panels import bottom_panel
67
+
68
+ from .approval_page import ApprovalChoice, ApprovalPage
69
+ from .ask_page import AskUserPage
70
+ from .bash_page import BashPage
71
+ from .completer import CommandCompleter, help_text
72
+ from .composer import LOCK_CAPTIONS, UPDATING_CAPTIONS, Composer
73
+ from .document_page import DocumentPage
74
+ from .effort_page import EFFORT_ROWS, EffortPage
75
+ from .follow_up import FollowUpQueue
76
+ from .form_page import FORM_CHROME_ROWS, Field, FormPage, form_rows
77
+ from .input_page import INPUT_ROWS, InputPage
78
+ from .model_page import ModelPage
79
+ from .model_settings_page import ModelSettingsPage, model_settings_rows
80
+ from .pet import PetView
81
+ from .pet_page import PetPage, pet_rows
82
+ from .plugins_page import PluginRow, PluginsPage
83
+ from .root import ZettCodeRoot
84
+ from .self_compact_page import SELF_COMPACT_ROWS, SelfCompactPage
85
+ from .send_kind_page import SEND_KIND_ROWS, SendKind, SendKindPage
86
+ from .sessions_page import SessionsPage, format_ago
87
+ from .statusline_page import StatuslinePage, statusline_rows
88
+ from .steering import SteeringQueue
89
+ from .transcript import TranscriptSource, TranscriptView
90
+ from .update_page import LATER, SKIP, UPGRADE, UpdatePage
91
+ from .welcome import WELCOME
92
+
93
+
94
+ def __getattr__(name: str) -> object:
95
+ """Import the module a public name lives in, the first time it is asked for.
96
+
97
+ This package is a facade over several subpackages, and a caller that wants
98
+ one name should not pay for the rest of them — the TUI alone holds the
99
+ widget library, the Markdown parser, the diff viewer, and the syntax
100
+ scanners. ``__all__`` is still the public surface.
101
+ """
102
+ try:
103
+ module = _EXPORTS[name]
104
+ except KeyError:
105
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
106
+ return getattr(importlib.import_module(module, __name__), name)
107
+
108
+
109
+ def __dir__() -> list[str]:
110
+ """Return the names this package publishes."""
111
+ return sorted(__all__)
112
+
113
+
114
+ __all__ = [
115
+ "ApprovalChoice",
116
+ "ApprovalPage",
117
+ "AskUserPage",
118
+ "BashPage",
119
+ "CommandCompleter",
120
+ "Composer",
121
+ "DocumentPage",
122
+ "EFFORT_ROWS",
123
+ "EffortPage",
124
+ "FollowUpQueue",
125
+ "FORM_CHROME_ROWS",
126
+ "Field",
127
+ "FormPage",
128
+ "form_rows",
129
+ "INPUT_ROWS",
130
+ "InputPage",
131
+ "LATER",
132
+ "LOCK_CAPTIONS",
133
+ "ModelPage",
134
+ "ModelSettingsPage",
135
+ "PetView",
136
+ "PetPage",
137
+ "PluginRow",
138
+ "PluginsPage",
139
+ "SEND_KIND_ROWS",
140
+ "SELF_COMPACT_ROWS",
141
+ "SKIP",
142
+ "SelfCompactPage",
143
+ "SendKind",
144
+ "SendKindPage",
145
+ "SessionsPage",
146
+ "SteeringQueue",
147
+ "StatuslinePage",
148
+ "TranscriptSource",
149
+ "TranscriptView",
150
+ "UPGRADE",
151
+ "UPDATING_CAPTIONS",
152
+ "UpdatePage",
153
+ "WELCOME",
154
+ "ZettCodeRoot",
155
+ "bottom_panel",
156
+ "format_ago",
157
+ "help_text",
158
+ "model_settings_rows",
159
+ "pet_rows",
160
+ "statusline_rows",
161
+ ]
@@ -0,0 +1,284 @@
1
+ """The bottom-panel prompt that asks whether to run one shell command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+
8
+ from zettcode._compat import StrEnum
9
+ from zettcode.plugin_api.panels import pressed
10
+ from zettcode.tui import MARKER, SEPARATOR, Canvas, Host, MouseAction, MouseEvent, Widget
11
+ from zettcode.tui.render import ELLIPSIS, SHELL, Span, Style, highlight, wrap_spans
12
+
13
+
14
+ class ApprovalChoice(StrEnum):
15
+ """What the user wants done with one pending shell command.
16
+
17
+ The rows are the same ladder ``/permission`` offers, seen from one command: a
18
+ choice either leaves the level alone (``RUN``, ``ALWAYS``, ``ABORT``) or moves
19
+ it up a rung, which is a change to the session (``RUN_SAFE`` to ``safe``,
20
+ ``RUN_AUTO`` to ``full``). Neither ``deny`` nor ``full`` ever shows the prompt,
21
+ so there is no row that goes down.
22
+
23
+ Attributes:
24
+ RUN: Execute this command once; the next one asks again.
25
+ RUN_SAFE: Execute it and stop asking: later commands run in the sandbox.
26
+ RUN_AUTO: Execute it and stop asking for any later command, with full access; the session remembers it.
27
+ ALWAYS: Execute it and remember this exact command for the session.
28
+ ABORT: Do not execute it; the agent sees a normal tool failure.
29
+ """
30
+
31
+ RUN = "run"
32
+ RUN_SAFE = "run_safe"
33
+ RUN_AUTO = "run_auto"
34
+ ALWAYS = "always"
35
+ ABORT = "abort"
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class ApprovalOption:
40
+ """One numbered line of the prompt.
41
+
42
+ Attributes:
43
+ choice: Decision handed to the page's ``on_choice`` callback.
44
+ key: Key that triggers this row without moving the selection first.
45
+ label: Sentence drawn after the number and before the key hint.
46
+ """
47
+
48
+ choice: ApprovalChoice
49
+ key: str
50
+ label: str
51
+
52
+
53
+ #: How a key name is spelled in a hint; Escape is the one that shortens.
54
+ _KEY_HINTS = {"escape": "esc"}
55
+
56
+
57
+ class ApprovalPage(Widget):
58
+ """Review one shell command before it runs.
59
+
60
+ Shape::
61
+
62
+ Would you like to run the following command?
63
+
64
+ Environment: local
65
+
66
+ $ rm -rf build && make dist <- the command, shell-highlighted
67
+ in clients/web <- only when the call names a directory
68
+
69
+ \u25b8 1. Yes, proceed (y) <- selected row is banded
70
+ 2. Yes, and don't ask again for this command (p)
71
+ 3. Yes, and stop asking; run commands sandboxed (s)
72
+ 4. Yes, and stop asking; full access, no sandbox (a)
73
+ 5. No, and say what to do differently (esc)
74
+
75
+ The ``s`` row is offered only while the level is ``ask``: at ``safe`` it would
76
+ change nothing, and a row that does nothing is a row that teaches the wrong
77
+ thing about the ladder.
78
+
79
+ The panel answers the question the agent is blocked on, so it names every
80
+ option and its key instead of relying on the list conventions alone; the
81
+ arrows and Enter still work, and the digits pick a row outright. Escape
82
+ takes the ``Abort`` row.
83
+
84
+ It owns no fill colour: the page clears its rectangle and paints text
85
+ straight onto the terminal's own background, with the surrounding border as
86
+ the only frame. Only the highlighted option bands a row, so the prompt
87
+ never reads as a second surface stacked on the conversation.
88
+
89
+ It paints into whatever rectangle it is given; the shell puts it in a
90
+ bottom panel so the conversation stays visible above the command.
91
+
92
+ Args:
93
+ command: Shell command under review, shown verbatim and highlighted.
94
+ on_choice: Receives the picked :class:`ApprovalChoice`.
95
+ environment: Where the command will run; ZettCode has no sandbox, so
96
+ the default is ``"local"``.
97
+ remember_supported: Whether to offer ``Always allow``; the runtime can
98
+ only honour it when it has approval storage.
99
+ permission: The shell permission level in force, shown beside the
100
+ environment so the prompt says which rung it is on, and used to leave
101
+ out the rung it is already standing on.
102
+ work_dir: The directory the command runs in, when the call names one
103
+ instead of the workspace. It is drawn under the command, because a
104
+ command that runs somewhere else is a different thing to approve.
105
+ """
106
+
107
+ QUESTION = "Would you like to run the following command?"
108
+ #: Rows spent on everything but the command and the options: a leading
109
+ #: blank, the question, the blank, the environment line, the blank after
110
+ #: the command, the blank before the options, and one spare bottom row.
111
+ FIXED_ROWS = 7
112
+
113
+ def __init__(
114
+ self,
115
+ command: str,
116
+ *,
117
+ on_choice: Callable[[ApprovalChoice], None],
118
+ environment: str = "local",
119
+ remember_supported: bool = True,
120
+ permission: str = "ask",
121
+ work_dir: str | None = None,
122
+ ) -> None:
123
+ """Build the numbered options and start the highlight on the first."""
124
+ super().__init__()
125
+ self.command = command
126
+ self.on_choice = on_choice
127
+ self.environment = environment
128
+ self.permission = permission
129
+ self.work_dir = work_dir
130
+ self.options = self._options(remember_supported, permission)
131
+ self.selected = 0
132
+
133
+ @staticmethod
134
+ def _options(remember_supported: bool, permission: str = "ask") -> tuple[ApprovalOption, ...]:
135
+ """Return the rows to show, dropping the ones that would do nothing.
136
+
137
+ A row the runtime cannot honour (``Always`` without storage) or that would
138
+ move the level to where it already is (``safe`` at ``safe``) is left out.
139
+ """
140
+ options = [ApprovalOption(ApprovalChoice.RUN, "y", "Yes, proceed")]
141
+ if remember_supported:
142
+ options.append(ApprovalOption(ApprovalChoice.ALWAYS, "p", "Yes, and don't ask again for this command"))
143
+ if permission == "ask":
144
+ options.append(ApprovalOption(ApprovalChoice.RUN_SAFE, "s", "Yes, and stop asking; run commands sandboxed"))
145
+ options.append(ApprovalOption(ApprovalChoice.RUN_AUTO, "a", "Yes, and stop asking; full access, no sandbox"))
146
+ options.append(ApprovalOption(ApprovalChoice.ABORT, "escape", "No, and say what to do differently"))
147
+ return tuple(options)
148
+
149
+ @property
150
+ def focusable(self) -> bool:
151
+ """Take focus when the shell pushes the page, so the keys reach it."""
152
+ return True
153
+
154
+ @property
155
+ def choice(self) -> ApprovalChoice:
156
+ """Return the decision the highlighted row would commit."""
157
+ return self.options[self.selected].choice
158
+
159
+ def move(self, amount: int) -> None:
160
+ """Move the highlight, wrapping around the ends."""
161
+ self.selected = (self.selected + amount) % len(self.options)
162
+
163
+ def activate(self) -> None:
164
+ """Hand the highlighted row to the shell."""
165
+ self.on_choice(self.choice)
166
+
167
+ def capture_event(self, event, host: Host) -> bool:
168
+ """Commit a keyed row, or abort on Escape, before anything else sees it."""
169
+ key = pressed(event)
170
+ if key is None:
171
+ return False
172
+ if key == "escape":
173
+ self.on_choice(ApprovalChoice.ABORT)
174
+ return True
175
+ option = next((item for item in self.options if item.key == key), None)
176
+ if option is None:
177
+ return False
178
+ self.on_choice(option.choice)
179
+ return True
180
+
181
+ def handle(self, event, host: Host) -> bool:
182
+ """Move with the arrows, and commit with Enter, a row number, or a click."""
183
+ if isinstance(event, MouseEvent):
184
+ return self._handle_mouse(event)
185
+ key = pressed(event)
186
+ if key is None:
187
+ return False
188
+ match key:
189
+ case "up":
190
+ self.move(-1)
191
+ case "down":
192
+ self.move(1)
193
+ case "enter":
194
+ self.activate()
195
+ case digit if digit.isascii() and digit.isdigit() and 1 <= int(digit) <= len(self.options):
196
+ self.selected = int(digit) - 1
197
+ self.activate()
198
+ case _:
199
+ return False
200
+ return True
201
+
202
+ def _handle_mouse(self, event: MouseEvent) -> bool:
203
+ """Commit a clicked option row, the way Enter commits the highlighted one."""
204
+ if event.action is not MouseAction.DOWN or not self.rect.contains(event.x, event.y):
205
+ return False
206
+ top = self.rect.y + 6 + len(self._command_rows(max(0, self.rect.width - 4))) + (1 if self.work_dir else 0)
207
+ index = event.y - top
208
+ if not 0 <= index < len(self.options):
209
+ return False
210
+ self.selected = index
211
+ self.activate()
212
+ return True
213
+
214
+ def render(self, canvas: Canvas) -> None:
215
+ """Fill the panel, then draw the question, the command, and the options."""
216
+ if self.rect.empty:
217
+ return
218
+ theme = self.theme
219
+ # Fill the panel like every other page: it is a screen of its own over
220
+ # the conversation, so leaving the cells unstyled would let the
221
+ # terminal's background through instead of the palette's.
222
+ canvas.fill(
223
+ self.rect.x,
224
+ self.rect.y,
225
+ self.rect.width,
226
+ self.rect.height,
227
+ Style(background=theme.surface_alt),
228
+ )
229
+ x = self.rect.x + 2
230
+ room = max(0, self.rect.width - 4)
231
+ heading = Style(foreground=theme.text, bold=True)
232
+ hint = Style(foreground=theme.muted)
233
+ canvas.draw_text(x, self.rect.y + 1, self.QUESTION, heading, max_width=room)
234
+ canvas.draw_text(
235
+ x,
236
+ self.rect.y + 3,
237
+ f"Environment: {self.environment} {SEPARATOR} Permission: {self.permission}",
238
+ hint,
239
+ max_width=room,
240
+ )
241
+ rows = self._command_rows(room)
242
+ for offset, spans in enumerate(rows):
243
+ canvas.draw_spans(x, self.rect.y + 5 + offset, spans, max_width=room)
244
+ top = self.rect.y + 6 + len(rows)
245
+ if self.work_dir:
246
+ canvas.draw_text(x, top, f"in {self.work_dir}", hint, max_width=room)
247
+ top += 1
248
+ for index, option in enumerate(self.options):
249
+ self._render_option(canvas, top + index, index, option)
250
+
251
+ def _command_rows(self, width: int) -> list[tuple[Span, ...]]:
252
+ """Return the highlighted command, wrapped and cut to the rows that fit.
253
+
254
+ The first row carries a ``$`` prompt and continuation rows are indented,
255
+ the way a shell transcript reads. A command longer than the panel leaves
256
+ a lone ellipsis row rather than looking complete.
257
+ """
258
+ code = self.theme.code
259
+ prompt = Span("$ ", Style(foreground=self.theme.accent, bold=True))
260
+ indent = Span(" ", Style(foreground=code.text))
261
+ rows: list[tuple[Span, ...]] = []
262
+ for line in self.command.splitlines() or [""]:
263
+ for chunk in wrap_spans(highlight(line, SHELL, code), max(1, width - 2)):
264
+ rows.append((prompt if not rows else indent, *chunk))
265
+ budget = max(0, self.rect.height - len(self.options) - self.FIXED_ROWS - (1 if self.work_dir else 0))
266
+ if len(rows) <= budget:
267
+ return rows
268
+ return [*rows[: max(0, budget - 1)], (indent, Span(ELLIPSIS, Style(foreground=self.theme.muted)))]
269
+
270
+ def _render_option(self, canvas: Canvas, y: int, index: int, option: ApprovalOption) -> None:
271
+ """Draw one numbered row, banding the highlighted one."""
272
+ if y >= self.rect.bottom - 1:
273
+ return
274
+ theme = self.theme
275
+ selected = index == self.selected
276
+ if selected:
277
+ style = Style(foreground=theme.text, background=theme.selection, bold=True)
278
+ canvas.fill(self.rect.x + 1, y, max(0, self.rect.width - 2), 1, style)
279
+ else:
280
+ style = Style(foreground=theme.text)
281
+ marker = f"{MARKER} " if selected else " "
282
+ hint = f" ({_KEY_HINTS.get(option.key, option.key)})"
283
+ text = f"{index + 1}. {option.label}{hint}"
284
+ canvas.draw_text(self.rect.x + 2, y, marker + text, style, max_width=max(0, self.rect.width - 4))