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,363 @@
|
|
|
1
|
+
"""The bottom panel ``/self-compact`` opens: two rails, one for this session and one for every session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from time import monotonic
|
|
7
|
+
|
|
8
|
+
from zettcode.plugin_api.panels import pressed
|
|
9
|
+
from zettcode.plugin_api.switch import Rail
|
|
10
|
+
from zettcode.tui import SEPARATOR, Canvas, Host, MouseAction, MouseEvent, Theme, Widget
|
|
11
|
+
from zettcode.tui.render import Style, blend, display_width, truncate
|
|
12
|
+
|
|
13
|
+
#: Rows the panel takes: a blank, the title, a blank, the session rail and its labels, a blank, the default
|
|
14
|
+
#: rail and its labels, a blank, what is in effect, the note, and the keys.
|
|
15
|
+
SELF_COMPACT_ROWS = 12
|
|
16
|
+
|
|
17
|
+
#: Columns kept free at the right of a rail so the last label stays inside the panel.
|
|
18
|
+
RAIL_MARGIN = 5
|
|
19
|
+
|
|
20
|
+
#: Columns the row names take, and the least a rail may be left with before the names shrink to make room.
|
|
21
|
+
NAME_WIDTH = 15
|
|
22
|
+
MIN_RAIL = 12
|
|
23
|
+
|
|
24
|
+
#: Characters of a rail: a lit stretch, the groove it runs in, a node passed, a node ahead, and the knob.
|
|
25
|
+
LIT, GROOVE, PASSED, AHEAD, KNOB = "\u2501", "\u2500", "\u25cf", "\u25cb", "\u25c9"
|
|
26
|
+
|
|
27
|
+
#: What this session can be: no choice (it follows the default), off, or on. ``None`` is the first.
|
|
28
|
+
SESSION_CHOICES: tuple[bool | None, ...] = (None, False, True)
|
|
29
|
+
SESSION_WORDS = ("default", "off", "on")
|
|
30
|
+
DEFAULT_WORDS = ("off", "on")
|
|
31
|
+
|
|
32
|
+
#: One line per option, shown under the rails for the one the knob is on.
|
|
33
|
+
SESSION_NOTES = {
|
|
34
|
+
"default": "this session follows the default below, whatever it becomes",
|
|
35
|
+
"off": "the model cannot compact its own context in this session",
|
|
36
|
+
"on": "the model may compact its own context in this session",
|
|
37
|
+
}
|
|
38
|
+
DEFAULT_NOTES = {
|
|
39
|
+
"off": "sessions that never chose start with it off",
|
|
40
|
+
"on": "sessions that never chose start with it on",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SelfCompactPage(Widget):
|
|
45
|
+
"""Choose whether the model may compact its own context, for this session and as the default.
|
|
46
|
+
|
|
47
|
+
Shape::
|
|
48
|
+
|
|
49
|
+
Self-compaction in effect here: on
|
|
50
|
+
|
|
51
|
+
\u25b8 This session \u25cf\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u25c9\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25cb
|
|
52
|
+
default (off) off on
|
|
53
|
+
Every session \u25cf\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u25c9
|
|
54
|
+
off on
|
|
55
|
+
|
|
56
|
+
this session chose it \u00b7 sessions that never chose start with it on
|
|
57
|
+
the model may compact its own context in this session
|
|
58
|
+
|
|
59
|
+
\u2191 \u2193 row \u00b7 \u2190 \u2192 set \u00b7 enter apply \u00b7 esc cancel
|
|
60
|
+
|
|
61
|
+
The top rail is this session's own choice: ``default`` means it has none and follows the rail below,
|
|
62
|
+
``off`` and ``on`` are a choice that stays with the session. The bottom rail is the default for every
|
|
63
|
+
session that never chose, which is what ``[compaction] self`` in the config file says. Moving a knob
|
|
64
|
+
changes nothing: the line under the rails says what would be in effect, and Enter applies both rails
|
|
65
|
+
(the default is written to the config file, the choice is saved with the session). Escape applies
|
|
66
|
+
nothing. The knobs travel the way the effort panel's does, from the clock and not from a frame count.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
choice: This session's own choice, ``None`` when it has none.
|
|
70
|
+
default: The default for every session.
|
|
71
|
+
on_apply: Receives the session's choice and the default when Enter is pressed.
|
|
72
|
+
on_cancel: Called when the reader backs out.
|
|
73
|
+
session_open: Whether this process may change the session. A session another process has open
|
|
74
|
+
keeps what it has, and only the default can be moved.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
HINT = f"\u2191 \u2193 row {SEPARATOR} \u2190 \u2192 set {SEPARATOR} enter apply {SEPARATOR} esc cancel"
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
choice: bool | None,
|
|
82
|
+
default: bool,
|
|
83
|
+
*,
|
|
84
|
+
on_apply: Callable[[bool | None, bool], None],
|
|
85
|
+
on_cancel: Callable[[], None],
|
|
86
|
+
session_open: bool = True,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Start with both knobs where the settings are now."""
|
|
89
|
+
super().__init__()
|
|
90
|
+
self.session_rail = Rail(SESSION_WORDS, SESSION_CHOICES.index(choice))
|
|
91
|
+
self.default_rail = Rail(DEFAULT_WORDS, int(default))
|
|
92
|
+
self.on_apply = on_apply
|
|
93
|
+
self.on_cancel = on_cancel
|
|
94
|
+
self.session_open = session_open
|
|
95
|
+
#: The row the arrows move: 0 for the session, 1 for the default.
|
|
96
|
+
self.row = 0 if session_open else 1
|
|
97
|
+
self._token = f"self-compact-panel:{id(self)}"
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def focusable(self) -> bool:
|
|
101
|
+
"""Take focus when the shell pushes the page, so the keys reach it."""
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def choice(self) -> bool | None:
|
|
106
|
+
"""Return this session's choice once the knob arrives."""
|
|
107
|
+
return SESSION_CHOICES[self.session_rail.selected]
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def default(self) -> bool:
|
|
111
|
+
"""Return the default once the knob arrives."""
|
|
112
|
+
return bool(self.default_rail.selected)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def effective(self) -> bool:
|
|
116
|
+
"""Return what would be in force in this session if the panel were applied now."""
|
|
117
|
+
return self.default if self.choice is None else self.choice
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def changed(self) -> bool:
|
|
121
|
+
"""Return whether either knob is anywhere but where it started."""
|
|
122
|
+
return (
|
|
123
|
+
self.session_rail.selected != self.session_rail.start
|
|
124
|
+
or self.default_rail.selected != self.default_rail.start
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def moving(self) -> bool:
|
|
129
|
+
"""Return whether a knob is still on its way."""
|
|
130
|
+
return self.session_rail.moving or self.default_rail.moving
|
|
131
|
+
|
|
132
|
+
def _now(self) -> float:
|
|
133
|
+
return self.app.clock() if self.app is not None else monotonic()
|
|
134
|
+
|
|
135
|
+
def _active(self) -> Rail:
|
|
136
|
+
return self.session_rail if self.row == 0 else self.default_rail
|
|
137
|
+
|
|
138
|
+
def choose(self, row: int, index: int) -> None:
|
|
139
|
+
"""Send one row's knob to an option, and make that the row the arrows move."""
|
|
140
|
+
if row == 0 and not self.session_open:
|
|
141
|
+
return
|
|
142
|
+
self.row = row
|
|
143
|
+
rail = self.session_rail if row == 0 else self.default_rail
|
|
144
|
+
reduced = self.app is not None and self.app.reduced_motion
|
|
145
|
+
if rail.choose(index, self._now(), instant=reduced) and self.app is not None:
|
|
146
|
+
self.app.scheduler.animate(self._token)
|
|
147
|
+
|
|
148
|
+
def on_unmount(self) -> None:
|
|
149
|
+
"""Release the frame loop, so a panel put away mid-slide stops costing frames."""
|
|
150
|
+
self.session_rail.moving = self.default_rail.moving = False
|
|
151
|
+
if self.app is not None:
|
|
152
|
+
self.app.scheduler.animate(self._token, active=False)
|
|
153
|
+
|
|
154
|
+
def on_tick(self) -> bool:
|
|
155
|
+
"""Hand the frame loop back once both knobs have arrived; say whether the picture is still changing."""
|
|
156
|
+
now = self._now()
|
|
157
|
+
landed = self.session_rail.arrive(now) | self.default_rail.arrive(now)
|
|
158
|
+
if not self.moving and self.app is not None:
|
|
159
|
+
self.app.scheduler.animate(self._token, active=False)
|
|
160
|
+
return self.moving or landed
|
|
161
|
+
|
|
162
|
+
def capture_event(self, event, host: Host) -> bool:
|
|
163
|
+
"""Take Escape before the focused widget can."""
|
|
164
|
+
if pressed(event) == "escape":
|
|
165
|
+
self.on_cancel()
|
|
166
|
+
return True
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
def handle(self, event, host: Host) -> bool:
|
|
170
|
+
"""Move between the rows with Up and Down, along one with Left and Right, and apply with Enter."""
|
|
171
|
+
if isinstance(event, MouseEvent):
|
|
172
|
+
return self._handle_mouse(event)
|
|
173
|
+
rail = self._active()
|
|
174
|
+
match pressed(event):
|
|
175
|
+
case "up" | "backtab":
|
|
176
|
+
self.row = 0 if self.session_open else 1
|
|
177
|
+
case "down" | "tab":
|
|
178
|
+
self.row = 1
|
|
179
|
+
case "left":
|
|
180
|
+
self.choose(self.row, rail.selected - 1)
|
|
181
|
+
case "right":
|
|
182
|
+
self.choose(self.row, rail.selected + 1)
|
|
183
|
+
case "home":
|
|
184
|
+
self.choose(self.row, 0)
|
|
185
|
+
case "end":
|
|
186
|
+
self.choose(self.row, len(rail.words) - 1)
|
|
187
|
+
case "enter":
|
|
188
|
+
self.on_apply(self.choice, self.default)
|
|
189
|
+
case _:
|
|
190
|
+
return False
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
def _handle_mouse(self, event: MouseEvent) -> bool:
|
|
194
|
+
"""Send a knob to the option nearest a click on its rail or its labels."""
|
|
195
|
+
if event.action is not MouseAction.DOWN:
|
|
196
|
+
return False
|
|
197
|
+
top = self.rect.y + 3
|
|
198
|
+
for row, first in ((0, top), (1, top + 3)):
|
|
199
|
+
if first <= event.y <= first + 1:
|
|
200
|
+
rail = self.session_rail if row == 0 else self.default_rail
|
|
201
|
+
left, right = self._rail()
|
|
202
|
+
nearest = min(
|
|
203
|
+
range(len(rail.words)), key=lambda index: abs(self._column(rail, index, left, right) - event.x)
|
|
204
|
+
)
|
|
205
|
+
self.choose(row, nearest)
|
|
206
|
+
return True
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
def _names(self) -> int:
|
|
210
|
+
"""Return the columns the row names take: less on a panel too narrow to leave the rails room."""
|
|
211
|
+
room = max(0, self.rect.width - 4 - RAIL_MARGIN)
|
|
212
|
+
return max(0, min(NAME_WIDTH, room - MIN_RAIL))
|
|
213
|
+
|
|
214
|
+
def _rail(self) -> tuple[int, int]:
|
|
215
|
+
"""Return the first and last column of a rail."""
|
|
216
|
+
left = self.rect.x + 2 + self._names() + 2
|
|
217
|
+
return left, max(left, self.rect.right - RAIL_MARGIN - 1)
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _column(rail: Rail, index: float, left: int, right: int) -> int:
|
|
221
|
+
"""Return the column a (possibly fractional) option sits at."""
|
|
222
|
+
steps = max(1, len(rail.words) - 1)
|
|
223
|
+
return left + round(index * (right - left) / steps)
|
|
224
|
+
|
|
225
|
+
def _colour(self, theme: Theme, rail: Rail, index: float) -> str:
|
|
226
|
+
"""Return the colour of a point on a rail: grey for off, the accent for on, the text for no choice."""
|
|
227
|
+
stops: tuple[str, ...]
|
|
228
|
+
if rail is self.session_rail:
|
|
229
|
+
stops = (theme.text, theme.muted, theme.accent)
|
|
230
|
+
else:
|
|
231
|
+
stops = (theme.muted, theme.accent)
|
|
232
|
+
scaled = min(max(index, 0.0), len(stops) - 1)
|
|
233
|
+
low = min(int(scaled), len(stops) - 2)
|
|
234
|
+
return blend(stops[low], stops[low + 1], scaled - low)
|
|
235
|
+
|
|
236
|
+
def _label(self, rail: Rail, index: int) -> str:
|
|
237
|
+
"""Return the words under an option: ``default`` says what it would be, and the old place says ``now``."""
|
|
238
|
+
word = rail.words[index]
|
|
239
|
+
if rail is self.session_rail and index == 0:
|
|
240
|
+
word = f"default ({'on' if self.default else 'off'})"
|
|
241
|
+
if index == rail.start and rail.selected != rail.start:
|
|
242
|
+
word = f"{word} (now)"
|
|
243
|
+
return word
|
|
244
|
+
|
|
245
|
+
def render(self, canvas: Canvas) -> None:
|
|
246
|
+
"""Fill the panel, then draw the title, the two rails, what is in effect, the note and the keys."""
|
|
247
|
+
if self.rect.empty:
|
|
248
|
+
return
|
|
249
|
+
theme = self.theme
|
|
250
|
+
surface = theme.surface_alt
|
|
251
|
+
canvas.fill(self.rect.x, self.rect.y, self.rect.width, self.rect.height, Style(background=surface))
|
|
252
|
+
x = self.rect.x + 2
|
|
253
|
+
room = max(0, self.rect.width - 4)
|
|
254
|
+
muted = Style(foreground=theme.muted, background=surface)
|
|
255
|
+
bold = Style(foreground=theme.text, background=surface, bold=True)
|
|
256
|
+
canvas.draw_text(x, self.rect.y + 1, "Self-compaction", bold, max_width=room)
|
|
257
|
+
word = "on" if self.effective else "off"
|
|
258
|
+
# What is in force, as long as there is room beside the title; shorter before it would run into it.
|
|
259
|
+
gap = room - display_width("Self-compaction") - 2
|
|
260
|
+
suffix = " (not applied)" if self.changed else ""
|
|
261
|
+
state = next(
|
|
262
|
+
(
|
|
263
|
+
text
|
|
264
|
+
for text in (f"in effect here: {word}{suffix}", f"in effect here: {word}", word)
|
|
265
|
+
if display_width(text) <= gap
|
|
266
|
+
),
|
|
267
|
+
"",
|
|
268
|
+
)
|
|
269
|
+
canvas.draw_text(
|
|
270
|
+
self.rect.right - 2 - display_width(state),
|
|
271
|
+
self.rect.y + 1,
|
|
272
|
+
state,
|
|
273
|
+
Style(foreground=theme.accent if self.effective else theme.muted, background=surface, bold=True),
|
|
274
|
+
max_width=room,
|
|
275
|
+
)
|
|
276
|
+
left, right = self._rail()
|
|
277
|
+
now = self._now()
|
|
278
|
+
names = self._names()
|
|
279
|
+
for row, (title, rail) in enumerate(
|
|
280
|
+
(("This session", self.session_rail), ("Every session", self.default_rail))
|
|
281
|
+
):
|
|
282
|
+
top = self.rect.y + 3 + row * 3
|
|
283
|
+
enabled = row == 1 or self.session_open
|
|
284
|
+
active = row == self.row
|
|
285
|
+
mark = "\u25b8 " if active and enabled else " "
|
|
286
|
+
canvas.draw_text(
|
|
287
|
+
x,
|
|
288
|
+
top,
|
|
289
|
+
truncate(mark + title, names),
|
|
290
|
+
bold if active and enabled else muted,
|
|
291
|
+
max_width=names,
|
|
292
|
+
)
|
|
293
|
+
self._draw_rail(canvas, rail, top, left, right, now, enabled)
|
|
294
|
+
self._draw_labels(canvas, rail, top + 1, left, right, now, enabled)
|
|
295
|
+
summary = self._summary()
|
|
296
|
+
canvas.draw_text(x, self.rect.y + 9, truncate(summary, room), muted, max_width=room)
|
|
297
|
+
canvas.draw_text(x, self.rect.y + 10, truncate(self._note(), room), muted, max_width=room)
|
|
298
|
+
canvas.draw_text(x, self.rect.bottom - 1, truncate(self.HINT, room), muted, max_width=room)
|
|
299
|
+
|
|
300
|
+
def _draw_rail(self, canvas: Canvas, rail: Rail, y: int, left: int, right: int, now: float, enabled: bool) -> None:
|
|
301
|
+
"""Draw one rail: lit up to the knob in the colour of where each cell sits, the nodes, and the knob."""
|
|
302
|
+
theme = self.theme
|
|
303
|
+
surface = theme.surface_alt
|
|
304
|
+
position = rail.position(now)
|
|
305
|
+
knob = self._column(rail, position, left, right)
|
|
306
|
+
groove = blend(theme.muted, surface, 0.55)
|
|
307
|
+
dim = 0.0 if enabled else 0.6
|
|
308
|
+
steps = max(1, len(rail.words) - 1)
|
|
309
|
+
for column in range(left, right + 1):
|
|
310
|
+
if column <= knob:
|
|
311
|
+
colour = blend(self._colour(theme, rail, (column - left) / max(1, right - left) * steps), surface, dim)
|
|
312
|
+
canvas.draw_text(column, y, LIT, Style(foreground=colour, background=surface))
|
|
313
|
+
else:
|
|
314
|
+
canvas.draw_text(column, y, GROOVE, Style(foreground=groove, background=surface))
|
|
315
|
+
for index in range(len(rail.words)):
|
|
316
|
+
column = self._column(rail, index, left, right)
|
|
317
|
+
if column < knob:
|
|
318
|
+
colour = blend(self._colour(theme, rail, index), surface, dim)
|
|
319
|
+
canvas.draw_text(column, y, PASSED, Style(foreground=colour, background=surface, bold=True))
|
|
320
|
+
elif column > knob:
|
|
321
|
+
colour = blend(theme.muted, surface, 0.2 + dim)
|
|
322
|
+
canvas.draw_text(column, y, AHEAD, Style(foreground=colour, background=surface))
|
|
323
|
+
colour = blend(blend(self._colour(theme, rail, position), theme.text, 0.35), surface, dim)
|
|
324
|
+
canvas.draw_text(knob, y, KNOB, Style(foreground=colour, background=surface, bold=True))
|
|
325
|
+
|
|
326
|
+
def _draw_labels(
|
|
327
|
+
self, canvas: Canvas, rail: Rail, y: int, left: int, right: int, now: float, enabled: bool
|
|
328
|
+
) -> None:
|
|
329
|
+
"""Write each option under its node, skipping any that would run into one already written.
|
|
330
|
+
|
|
331
|
+
The option the knob is on is written first and never skipped, then the rest from the left, so on a
|
|
332
|
+
narrow panel what is left is the knob's neighbourhood rather than an arbitrary run.
|
|
333
|
+
"""
|
|
334
|
+
theme = self.theme
|
|
335
|
+
nearest = min(len(rail.words) - 1, max(0, round(rail.position(now))))
|
|
336
|
+
taken: list[tuple[int, int]] = []
|
|
337
|
+
order = [nearest] + [index for index in range(len(rail.words)) if index != nearest]
|
|
338
|
+
for index in order:
|
|
339
|
+
label = self._label(rail, index)
|
|
340
|
+
width = display_width(label)
|
|
341
|
+
start = self._column(rail, index, left, right) - width // 2
|
|
342
|
+
start = max(self.rect.x + 1, min(start, self.rect.right - 1 - width))
|
|
343
|
+
if any(start < stop + 1 and first < start + width + 1 for first, stop in taken):
|
|
344
|
+
continue
|
|
345
|
+
taken.append((start, start + width))
|
|
346
|
+
on = index == nearest and enabled
|
|
347
|
+
style = Style(foreground=theme.text if on else theme.muted, background=theme.surface_alt, bold=on)
|
|
348
|
+
canvas.draw_text(start, y, label, style, max_width=width)
|
|
349
|
+
|
|
350
|
+
def _summary(self) -> str:
|
|
351
|
+
"""Say where what is in force comes from, and what the default is."""
|
|
352
|
+
default = "on" if self.default else "off"
|
|
353
|
+
if self.choice is None:
|
|
354
|
+
return f"following the default, which is {default}"
|
|
355
|
+
return f"this session chose it; the default is {default}"
|
|
356
|
+
|
|
357
|
+
def _note(self) -> str:
|
|
358
|
+
"""Return the note for the option the active knob is on."""
|
|
359
|
+
if not self.session_open and self.row == 1:
|
|
360
|
+
return "this session is open in another process, so only the default can change"
|
|
361
|
+
rail = self._active()
|
|
362
|
+
notes = SESSION_NOTES if self.row == 0 else DEFAULT_NOTES
|
|
363
|
+
return notes[rail.words[rail.selected]]
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""The bottom-panel question a message typed during a request has to answer: steer, or follow up?"""
|
|
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, PROMPT, SEPARATOR, Canvas, Host, MouseAction, MouseEvent, Widget
|
|
11
|
+
from zettcode.tui.render import Style, truncate
|
|
12
|
+
|
|
13
|
+
from .follow_up import one_line
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SendKind(StrEnum):
|
|
17
|
+
"""What a message typed while the agent is working should do.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
FOLLOW_UP: Hold it, and send it as a turn of its own when this request ends.
|
|
21
|
+
STEER: Hand it to the agent now; it is adopted at the next model or tool
|
|
22
|
+
boundary and replaces the rest of the current task.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
FOLLOW_UP = "follow_up"
|
|
26
|
+
STEER = "steer"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class SendOption:
|
|
31
|
+
"""One numbered row of the question.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
kind: The answer this row gives.
|
|
35
|
+
key: Key that picks the row outright.
|
|
36
|
+
label: The name of the kind.
|
|
37
|
+
detail: What it does, in a sentence.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
kind: SendKind
|
|
41
|
+
key: str
|
|
42
|
+
label: str
|
|
43
|
+
detail: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
#: Rows in the order shown. Follow-up comes first and is highlighted first: it is the one
|
|
47
|
+
#: that cannot interrupt anything, so an Enter pressed without reading is the safe answer.
|
|
48
|
+
OPTIONS = (
|
|
49
|
+
SendOption(SendKind.FOLLOW_UP, "f", "Follow up", "send it after this request finishes"),
|
|
50
|
+
SendOption(SendKind.STEER, "s", "Steer", "hand it to the agent now; it replaces the rest of the current task"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
#: Rows the panel takes: a blank, the question, a blank, the message, a blank, the two
|
|
54
|
+
#: options, a blank, and the keys line.
|
|
55
|
+
SEND_KIND_ROWS = 9
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SendKindPage(Widget):
|
|
59
|
+
"""Ask which kind of message a draft typed during a request is.
|
|
60
|
+
|
|
61
|
+
Shape::
|
|
62
|
+
|
|
63
|
+
You sent a message while ZettCode is working. What should it do?
|
|
64
|
+
|
|
65
|
+
\u203a stop editing and explain the diff <- the draft, one line
|
|
66
|
+
|
|
67
|
+
\u25b8 1. Follow up send it after this request finishes (f)
|
|
68
|
+
2. Steer hand it to the agent now; it replaces the rest of the current task (s)
|
|
69
|
+
|
|
70
|
+
enter choose \u00b7 esc keep editing the draft
|
|
71
|
+
|
|
72
|
+
The draft is still in the composer while this is up: Escape closes the panel and
|
|
73
|
+
leaves it there, which is what makes asking safe — a reader who pressed Enter too early
|
|
74
|
+
has lost nothing. The two kinds are named with what each does to the running task,
|
|
75
|
+
because "steer" is a word the program uses and the reader may never have met.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
message: The draft, shown so the question is about something visible.
|
|
79
|
+
on_choice: Receives the picked :class:`SendKind`.
|
|
80
|
+
on_cancel: Called when the reader backs out.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
QUESTION = "You sent a message while ZettCode is working. What should it do?"
|
|
84
|
+
HINT = f"enter choose {SEPARATOR} f follow up {SEPARATOR} s steer {SEPARATOR} esc keep editing the draft"
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
message: str,
|
|
89
|
+
*,
|
|
90
|
+
on_choice: Callable[[SendKind], None],
|
|
91
|
+
on_cancel: Callable[[], None],
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Start with the first row highlighted."""
|
|
94
|
+
super().__init__()
|
|
95
|
+
self.message = message
|
|
96
|
+
self.on_choice = on_choice
|
|
97
|
+
self.on_cancel = on_cancel
|
|
98
|
+
self.options = OPTIONS
|
|
99
|
+
self.selected = 0
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def focusable(self) -> bool:
|
|
103
|
+
"""Take focus when the shell pushes the page, so the keys reach it."""
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def choice(self) -> SendKind:
|
|
108
|
+
"""Return the kind the highlighted row would commit."""
|
|
109
|
+
return self.options[self.selected].kind
|
|
110
|
+
|
|
111
|
+
def move(self, amount: int) -> None:
|
|
112
|
+
"""Move the highlight, wrapping around the ends."""
|
|
113
|
+
self.selected = (self.selected + amount) % len(self.options)
|
|
114
|
+
|
|
115
|
+
def activate(self) -> None:
|
|
116
|
+
"""Hand the highlighted row to the shell."""
|
|
117
|
+
self.on_choice(self.choice)
|
|
118
|
+
|
|
119
|
+
def capture_event(self, event, host: Host) -> bool:
|
|
120
|
+
"""Take Escape and a row's own key before anything else sees them."""
|
|
121
|
+
key = pressed(event)
|
|
122
|
+
if key == "escape":
|
|
123
|
+
self.on_cancel()
|
|
124
|
+
return True
|
|
125
|
+
option = next((item for item in self.options if item.key == key), None)
|
|
126
|
+
if option is None:
|
|
127
|
+
return False
|
|
128
|
+
self.on_choice(option.kind)
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
def handle(self, event, host: Host) -> bool:
|
|
132
|
+
"""Move with the arrows, and commit with Enter, a row number, or a click."""
|
|
133
|
+
if isinstance(event, MouseEvent):
|
|
134
|
+
return self._handle_mouse(event)
|
|
135
|
+
key = pressed(event)
|
|
136
|
+
match key:
|
|
137
|
+
case "up":
|
|
138
|
+
self.move(-1)
|
|
139
|
+
case "down":
|
|
140
|
+
self.move(1)
|
|
141
|
+
case "enter":
|
|
142
|
+
self.activate()
|
|
143
|
+
case digit if (
|
|
144
|
+
digit is not None and digit.isascii() and digit.isdigit() and 1 <= int(digit) <= len(self.options)
|
|
145
|
+
):
|
|
146
|
+
self.selected = int(digit) - 1
|
|
147
|
+
self.activate()
|
|
148
|
+
case _:
|
|
149
|
+
return False
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
def _handle_mouse(self, event: MouseEvent) -> bool:
|
|
153
|
+
"""Commit a clicked row, the way Enter commits the highlighted one."""
|
|
154
|
+
if event.action is not MouseAction.DOWN or not self.rect.contains(event.x, event.y):
|
|
155
|
+
return False
|
|
156
|
+
index = event.y - (self.rect.y + 5)
|
|
157
|
+
if not 0 <= index < len(self.options):
|
|
158
|
+
return False
|
|
159
|
+
self.selected = index
|
|
160
|
+
self.activate()
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
def render(self, canvas: Canvas) -> None:
|
|
164
|
+
"""Fill the panel, then draw the question, the draft, the options, and the keys."""
|
|
165
|
+
if self.rect.empty:
|
|
166
|
+
return
|
|
167
|
+
theme = self.theme
|
|
168
|
+
canvas.fill(self.rect.x, self.rect.y, self.rect.width, self.rect.height, Style(background=theme.surface_alt))
|
|
169
|
+
x = self.rect.x + 2
|
|
170
|
+
room = max(0, self.rect.width - 4)
|
|
171
|
+
canvas.draw_text(
|
|
172
|
+
x, self.rect.y + 1, truncate(self.QUESTION, room), Style(foreground=theme.text, bold=True), max_width=room
|
|
173
|
+
)
|
|
174
|
+
draft = f"{PROMPT} {one_line(self.message)}"
|
|
175
|
+
canvas.draw_text(x, self.rect.y + 3, truncate(draft, room), Style(foreground=theme.accent), max_width=room)
|
|
176
|
+
for index, option in enumerate(self.options):
|
|
177
|
+
self._render_option(canvas, self.rect.y + 5 + index, index, option)
|
|
178
|
+
hint_y = self.rect.y + 5 + len(self.options) + 1
|
|
179
|
+
if hint_y < self.rect.bottom:
|
|
180
|
+
canvas.draw_text(x, hint_y, truncate(self.HINT, room), Style(foreground=theme.muted), max_width=room)
|
|
181
|
+
|
|
182
|
+
def _render_option(self, canvas: Canvas, y: int, index: int, option: SendOption) -> None:
|
|
183
|
+
"""Draw one numbered row, banding the highlighted one."""
|
|
184
|
+
theme = self.theme
|
|
185
|
+
selected = index == self.selected
|
|
186
|
+
if selected:
|
|
187
|
+
style = Style(foreground=theme.text, background=theme.selection, bold=True)
|
|
188
|
+
canvas.fill(self.rect.x + 1, y, max(0, self.rect.width - 2), 1, style)
|
|
189
|
+
else:
|
|
190
|
+
style = Style(foreground=theme.text)
|
|
191
|
+
marker = f"{MARKER} " if selected else " "
|
|
192
|
+
text = f"{index + 1}. {option.label:<10}{option.detail} ({option.key})"
|
|
193
|
+
canvas.draw_text(self.rect.x + 2, y, marker + text, style, max_width=max(0, self.rect.width - 4))
|