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,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
|
+
)
|