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,427 @@
|
|
|
1
|
+
"""The bottom panel ``/statusline`` opens: every named part of the header and status line, each with a switch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Collection
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from time import monotonic
|
|
8
|
+
|
|
9
|
+
from zettcode.plugin_api.panels import pressed
|
|
10
|
+
from zettcode.plugin_api.switch import SWITCH_WIDTH, Rail, draw_switch
|
|
11
|
+
from zettcode.presenters import labels
|
|
12
|
+
from zettcode.presenters.statusline import GAP, PET, Part, Row
|
|
13
|
+
from zettcode.tui import ARROW_DOWN, ARROW_UP, SEPARATOR, Canvas, Host, MouseAction, MouseEvent, Widget
|
|
14
|
+
from zettcode.tui.render import Style, blend, display_width, truncate
|
|
15
|
+
|
|
16
|
+
#: Rows the panel needs besides the list: a blank, the title, a blank, the two preview rows, a blank above the
|
|
17
|
+
#: list, then below it a blank, the selected part's description and the keys.
|
|
18
|
+
CHROME = 9
|
|
19
|
+
|
|
20
|
+
#: The first row of the list, counted from the top of the panel.
|
|
21
|
+
LIST_TOP = 6
|
|
22
|
+
|
|
23
|
+
#: The least rows the list may be left with on a short screen, and the rows of the screen the panel leaves to
|
|
24
|
+
#: the conversation above it.
|
|
25
|
+
MIN_LIST = 4
|
|
26
|
+
LEFT_FOR_CONVERSATION = 8
|
|
27
|
+
|
|
28
|
+
#: The heading of the line a request ends on, and of the plugins' items beside the program's own.
|
|
29
|
+
TURN_TITLE = "Request line"
|
|
30
|
+
|
|
31
|
+
#: The rows of the screen as the panel shows them, each as its left and right side. They are built by the
|
|
32
|
+
#: functions that build the real rows, on a plausible session rather than the reader's own, so the names and
|
|
33
|
+
#: descriptions listed here are the ones the rows declare and cannot drift from them.
|
|
34
|
+
SAMPLE: dict[str, tuple[Row, Row]] = {
|
|
35
|
+
"Header": (
|
|
36
|
+
labels.header_left(Path("~/projects/api"), "Fix the parser", "fix-the-parser"),
|
|
37
|
+
labels.header_right("gpt-5", "medium"),
|
|
38
|
+
),
|
|
39
|
+
"Status line": (
|
|
40
|
+
labels.status_left(
|
|
41
|
+
"ready",
|
|
42
|
+
permission="safe",
|
|
43
|
+
self_compact=True,
|
|
44
|
+
tokens=f"{ARROW_UP}12.3k {ARROW_DOWN}1.2k",
|
|
45
|
+
cache="77.3% cached",
|
|
46
|
+
speed="48 tok/s",
|
|
47
|
+
ctx="ctx 34.0%",
|
|
48
|
+
),
|
|
49
|
+
labels.status_right(),
|
|
50
|
+
),
|
|
51
|
+
TURN_TITLE: (labels.turn_line(), Row()),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#: Every part, in the order the rows show them, and the title of the row each is on. The pet is on no row of
|
|
55
|
+
#: the screen; it stands under a heading of its own, last.
|
|
56
|
+
PARTS: tuple[Part, ...] = (*(part for sides in SAMPLE.values() for side in sides for part in side.parts()), PET)
|
|
57
|
+
ROW_OF: dict[str, str] = {
|
|
58
|
+
**{part.name: title for title, sides in SAMPLE.items() for side in sides for part in side.parts()},
|
|
59
|
+
PET.name: "Companion",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#: One row for each part, and one heading for each group of them.
|
|
63
|
+
LIST_ROWS = len(PARTS) + len(SAMPLE) + 1
|
|
64
|
+
|
|
65
|
+
#: Columns the part names take, the switch, and the word beside it.
|
|
66
|
+
NAME_WIDTH = 14
|
|
67
|
+
WORD_WIDTH = 7
|
|
68
|
+
|
|
69
|
+
#: The least columns a description is worth drawing in; narrower and the line under the list says it alone.
|
|
70
|
+
MIN_DESCRIPTION = 18
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def statusline_rows(height: int) -> int:
|
|
74
|
+
"""Return the rows to ask for: the whole list when the screen has room for it, else what it can spare."""
|
|
75
|
+
return max(CHROME + MIN_LIST, min(CHROME + LIST_ROWS, height - LEFT_FOR_CONVERSATION))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class StatuslinePage(Widget):
|
|
79
|
+
"""Choose which named parts of the header and the status line are shown.
|
|
80
|
+
|
|
81
|
+
Shape::
|
|
82
|
+
|
|
83
|
+
Status line 12 of 14 shown
|
|
84
|
+
|
|
85
|
+
◈ zettcode Fix the parser · ~/projects/api gpt-5 · medium
|
|
86
|
+
● ready · safe ↑12.3k ↓1.2k · 77.3% cached · ctx 34.0% ^C stop ^D exit
|
|
87
|
+
|
|
88
|
+
Header ──────────────────────────────────────────────────────────────
|
|
89
|
+
app ━━━━● shown The mark and name of the program
|
|
90
|
+
▸ session ━━━━● shown The session's title; New session until it has one
|
|
91
|
+
handle ●──── hidden The @name other sessions reach this one by
|
|
92
|
+
…
|
|
93
|
+
session: The session's title; New session until it has one
|
|
94
|
+
↑ ↓ part · ← → hide or show · enter apply · esc cancel
|
|
95
|
+
|
|
96
|
+
Up and Down move between the parts, round from the last to the first and back, Left and Right (or Space) hide and show the one the marker is on, and
|
|
97
|
+
the two rows above the list are what the header and the status line would look like with the choices as
|
|
98
|
+
they stand, the marked part picked out. Nothing changes in the shell until Enter: Escape applies nothing.
|
|
99
|
+
The switches travel the way the other panels' knobs do, from the clock and not from a frame count, and the
|
|
100
|
+
list scrolls when the screen is too short for it.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
hidden: The names of the parts that are hidden now; every other part is shown.
|
|
104
|
+
extra: Items of the line a request ends on that plugins added; they are listed with the program's own.
|
|
105
|
+
on_apply: Receives every part's name and whether it is shown when Enter is pressed.
|
|
106
|
+
on_cancel: Called when the reader backs out.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
HINT = f"{ARROW_UP} {ARROW_DOWN} part {SEPARATOR} \u2190 \u2192 hide or show {SEPARATOR} enter apply {SEPARATOR} esc cancel"
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
hidden: Collection[str],
|
|
114
|
+
extra: Collection[Part] = (),
|
|
115
|
+
*,
|
|
116
|
+
on_apply: Callable[[dict[str, bool]], None],
|
|
117
|
+
on_cancel: Callable[[], None],
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Start with every switch where the settings are now, and the marker on the first part."""
|
|
120
|
+
super().__init__()
|
|
121
|
+
self.parts: tuple[Part, ...] = (*PARTS[:-1], *extra, PET)
|
|
122
|
+
self.row_of = {**ROW_OF, **{part.name: TURN_TITLE for part in extra}}
|
|
123
|
+
self.start = {part.name: part.name not in hidden for part in self.parts}
|
|
124
|
+
self.switches = {name: Rail(("hidden", "shown"), int(shown)) for name, shown in self.start.items()}
|
|
125
|
+
self.on_apply = on_apply
|
|
126
|
+
self.on_cancel = on_cancel
|
|
127
|
+
#: The part the marker is on, as an index into :data:`PARTS`.
|
|
128
|
+
self.mark = 0
|
|
129
|
+
#: The first visible row of the list, counted in list rows (headings included).
|
|
130
|
+
self.top = 0
|
|
131
|
+
self._token = f"statusline-panel:{id(self)}"
|
|
132
|
+
self._lines: list[tuple[str, int]] = []
|
|
133
|
+
previous = None
|
|
134
|
+
for index, part in enumerate(self.parts):
|
|
135
|
+
if self.row_of[part.name] != previous:
|
|
136
|
+
self._lines.append(("heading", index))
|
|
137
|
+
previous = self.row_of[part.name]
|
|
138
|
+
self._lines.append(("part", index))
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def focusable(self) -> bool:
|
|
142
|
+
"""Take focus when the shell pushes the page, so the keys reach it."""
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def shown(self) -> dict[str, bool]:
|
|
147
|
+
"""Return every part's name and whether it is shown once the switches arrive."""
|
|
148
|
+
return {name: bool(rail.selected) for name, rail in self.switches.items()}
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def changed(self) -> bool:
|
|
152
|
+
"""Return whether any switch is anywhere but where it started."""
|
|
153
|
+
return self.shown != self.start
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def moving(self) -> bool:
|
|
157
|
+
"""Return whether a switch is still on its way."""
|
|
158
|
+
return any(rail.moving for rail in self.switches.values())
|
|
159
|
+
|
|
160
|
+
def _now(self) -> float:
|
|
161
|
+
return self.app.clock() if self.app is not None else monotonic()
|
|
162
|
+
|
|
163
|
+
def set(self, name: str, shown: bool) -> None:
|
|
164
|
+
"""Send one part's switch to shown or hidden, and set the marker on it."""
|
|
165
|
+
self.mark = next(index for index, part in enumerate(self.parts) if part.name == name)
|
|
166
|
+
reduced = self.app is not None and self.app.reduced_motion
|
|
167
|
+
if self.switches[name].choose(int(shown), self._now(), instant=reduced) and self.app is not None:
|
|
168
|
+
self.app.scheduler.animate(self._token)
|
|
169
|
+
|
|
170
|
+
def on_unmount(self) -> None:
|
|
171
|
+
"""Release the frame loop, so a panel put away mid-slide stops costing frames."""
|
|
172
|
+
for rail in self.switches.values():
|
|
173
|
+
rail.moving = False
|
|
174
|
+
if self.app is not None:
|
|
175
|
+
self.app.scheduler.animate(self._token, active=False)
|
|
176
|
+
|
|
177
|
+
def on_tick(self) -> bool:
|
|
178
|
+
"""Hand the frame loop back once every switch has arrived; say whether the picture is still changing."""
|
|
179
|
+
now = self._now()
|
|
180
|
+
landed = False
|
|
181
|
+
for rail in self.switches.values():
|
|
182
|
+
landed |= rail.arrive(now)
|
|
183
|
+
if not self.moving and self.app is not None:
|
|
184
|
+
self.app.scheduler.animate(self._token, active=False)
|
|
185
|
+
return self.moving or landed
|
|
186
|
+
|
|
187
|
+
def capture_event(self, event, host: Host) -> bool:
|
|
188
|
+
"""Take Escape before the focused widget can."""
|
|
189
|
+
if pressed(event) == "escape":
|
|
190
|
+
self.on_cancel()
|
|
191
|
+
return True
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
def handle(self, event, host: Host) -> bool:
|
|
195
|
+
"""Move the marker with Up and Down, hide and show with Left and Right, and apply with Enter."""
|
|
196
|
+
if isinstance(event, MouseEvent):
|
|
197
|
+
return self._handle_mouse(event)
|
|
198
|
+
name = self.parts[self.mark].name
|
|
199
|
+
match pressed(event):
|
|
200
|
+
case "up" | "backtab":
|
|
201
|
+
self.mark = (self.mark - 1) % len(self.parts)
|
|
202
|
+
case "down" | "tab":
|
|
203
|
+
self.mark = (self.mark + 1) % len(self.parts)
|
|
204
|
+
case "pageup":
|
|
205
|
+
self.mark = max(0, self.mark - max(1, self._view() - 1))
|
|
206
|
+
case "pagedown":
|
|
207
|
+
self.mark = min(len(self.parts) - 1, self.mark + max(1, self._view() - 1))
|
|
208
|
+
case "home":
|
|
209
|
+
self.mark = 0
|
|
210
|
+
case "end":
|
|
211
|
+
self.mark = len(self.parts) - 1
|
|
212
|
+
case "left":
|
|
213
|
+
self.set(name, False)
|
|
214
|
+
case "right":
|
|
215
|
+
self.set(name, True)
|
|
216
|
+
case " ":
|
|
217
|
+
self.set(name, not self.switches[name].selected)
|
|
218
|
+
case "enter":
|
|
219
|
+
self.on_apply(self.shown)
|
|
220
|
+
case _:
|
|
221
|
+
return False
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
def _handle_mouse(self, event: MouseEvent) -> bool:
|
|
225
|
+
"""Mark the part under a click, and flip its switch when the click is on the switch or its word."""
|
|
226
|
+
if event.action is MouseAction.SCROLL_UP:
|
|
227
|
+
self.mark = max(0, self.mark - 1)
|
|
228
|
+
return True
|
|
229
|
+
if event.action is MouseAction.SCROLL_DOWN:
|
|
230
|
+
self.mark = min(len(self.parts) - 1, self.mark + 1)
|
|
231
|
+
return True
|
|
232
|
+
if event.action is not MouseAction.DOWN:
|
|
233
|
+
return False
|
|
234
|
+
offset = event.y - (self.rect.y + LIST_TOP)
|
|
235
|
+
index = self.top + offset
|
|
236
|
+
if not 0 <= offset < self._view() or index >= len(self._lines):
|
|
237
|
+
return False
|
|
238
|
+
kind, part = self._lines[index]
|
|
239
|
+
if kind != "part":
|
|
240
|
+
return False
|
|
241
|
+
name = self.parts[part].name
|
|
242
|
+
start = self._switch_column()
|
|
243
|
+
if start <= event.x < start + SWITCH_WIDTH + 1 + WORD_WIDTH:
|
|
244
|
+
self.set(name, not self.switches[name].selected)
|
|
245
|
+
else:
|
|
246
|
+
self.mark = part
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
def _view(self) -> int:
|
|
250
|
+
"""Return how many rows of the list the panel has room for."""
|
|
251
|
+
return max(1, self.rect.height - CHROME)
|
|
252
|
+
|
|
253
|
+
def _switch_column(self) -> int:
|
|
254
|
+
"""Return the column the first switch cell is in."""
|
|
255
|
+
return self.rect.x + 2 + 2 + NAME_WIDTH
|
|
256
|
+
|
|
257
|
+
def _reveal(self) -> None:
|
|
258
|
+
"""Scroll the list the least that shows the marked part, and its heading when it is the first of its row."""
|
|
259
|
+
view = self._view()
|
|
260
|
+
row = self._lines.index(("part", self.mark))
|
|
261
|
+
first = row - 1 if self._lines[row - 1][0] == "heading" else row
|
|
262
|
+
if first < self.top:
|
|
263
|
+
self.top = first
|
|
264
|
+
if row >= self.top + view:
|
|
265
|
+
self.top = row - view + 1
|
|
266
|
+
self.top = max(0, min(self.top, len(self._lines) - view))
|
|
267
|
+
|
|
268
|
+
def render(self, canvas: Canvas) -> None:
|
|
269
|
+
"""Fill the panel, then draw the title, the preview, the list, the description and the keys."""
|
|
270
|
+
if self.rect.empty:
|
|
271
|
+
return
|
|
272
|
+
theme = self.theme
|
|
273
|
+
surface = theme.surface_alt
|
|
274
|
+
canvas.fill(self.rect.x, self.rect.y, self.rect.width, self.rect.height, Style(background=surface))
|
|
275
|
+
x = self.rect.x + 2
|
|
276
|
+
room = max(0, self.rect.width - 4)
|
|
277
|
+
muted = Style(foreground=theme.muted, background=surface)
|
|
278
|
+
bold = Style(foreground=theme.text, background=surface, bold=True)
|
|
279
|
+
canvas.draw_text(x, self.rect.y + 1, "Status line", bold, max_width=room)
|
|
280
|
+
count = sum(self.shown.values())
|
|
281
|
+
gap = room - display_width("Status line") - 2
|
|
282
|
+
suffix = " (not applied)" if self.changed else ""
|
|
283
|
+
state = next(
|
|
284
|
+
(
|
|
285
|
+
text
|
|
286
|
+
for text in (
|
|
287
|
+
f"{count} of {len(self.parts)} shown{suffix}",
|
|
288
|
+
f"{count} of {len(self.parts)} shown",
|
|
289
|
+
f"{count}/{len(self.parts)}",
|
|
290
|
+
)
|
|
291
|
+
if display_width(text) <= gap
|
|
292
|
+
),
|
|
293
|
+
"",
|
|
294
|
+
)
|
|
295
|
+
canvas.draw_text(
|
|
296
|
+
self.rect.right - 2 - display_width(state),
|
|
297
|
+
self.rect.y + 1,
|
|
298
|
+
state,
|
|
299
|
+
Style(foreground=theme.accent if self.changed else theme.muted, background=surface, bold=self.changed),
|
|
300
|
+
max_width=room,
|
|
301
|
+
)
|
|
302
|
+
for offset, title in enumerate(SAMPLE):
|
|
303
|
+
self._draw_preview(canvas, SAMPLE[title], self.rect.y + 3 + offset)
|
|
304
|
+
self._reveal()
|
|
305
|
+
self._draw_list(canvas)
|
|
306
|
+
part = self.parts[self.mark]
|
|
307
|
+
description = (
|
|
308
|
+
f"{part.name}: {part.summary}" if self.shown[part.name] else f"{part.name} is hidden: {part.summary}"
|
|
309
|
+
)
|
|
310
|
+
head = f"{part.name}:" if self.shown[part.name] else f"{part.name} is hidden:"
|
|
311
|
+
canvas.draw_text(x, self.rect.bottom - 2, truncate(description, room), muted, max_width=room)
|
|
312
|
+
canvas.draw_text(x, self.rect.bottom - 2, head, bold, max_width=min(room, display_width(head)))
|
|
313
|
+
canvas.draw_text(x, self.rect.bottom - 1, truncate(self.HINT, room), muted, max_width=room)
|
|
314
|
+
|
|
315
|
+
def _draw_preview(self, canvas: Canvas, sides: tuple[Row, Row], y: int) -> None:
|
|
316
|
+
"""Draw one row of the screen as it would read with the switches as they stand, in a strip of its own."""
|
|
317
|
+
theme = self.theme
|
|
318
|
+
strip = blend(theme.surface_alt, theme.muted, 0.14)
|
|
319
|
+
left = self.rect.x
|
|
320
|
+
width = self.rect.width
|
|
321
|
+
canvas.fill(left, y, width, 1, Style(background=strip))
|
|
322
|
+
hidden = {name for name, shown in self.shown.items() if not shown}
|
|
323
|
+
runs = [self._runs(side, hidden) for side in sides]
|
|
324
|
+
lengths = [sum(display_width(text) for text, _ in side) for side in runs]
|
|
325
|
+
if not any(lengths):
|
|
326
|
+
canvas.draw_text(
|
|
327
|
+
left + 2, y, "nothing is shown here", Style(foreground=theme.muted, background=strip, italic=True)
|
|
328
|
+
)
|
|
329
|
+
return
|
|
330
|
+
# The right side keeps its width and the left gives way, as the real bar does.
|
|
331
|
+
right_at = max(left, left + width - lengths[1])
|
|
332
|
+
room = max(0, right_at - left - (display_width(GAP) if lengths[1] else 0))
|
|
333
|
+
self._draw_runs(canvas, runs[0], left, y, min(room, width), strip)
|
|
334
|
+
self._draw_runs(canvas, runs[1], right_at, y, lengths[1], strip)
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def _runs(side: Row, hidden: Collection[str]) -> list[tuple[str, str | None]]:
|
|
338
|
+
"""Return a side as the runs of text it is written in, each with the part it belongs to (``None`` for glue)."""
|
|
339
|
+
runs: list[tuple[str, str | None]] = []
|
|
340
|
+
for glue, part in side.shown(hidden):
|
|
341
|
+
runs.extend([(glue, None), (part.text, part.name)])
|
|
342
|
+
return [*runs, (side.trail, None)] if runs and side.trail else runs
|
|
343
|
+
|
|
344
|
+
def _draw_runs(
|
|
345
|
+
self, canvas: Canvas, runs: list[tuple[str, str | None]], x: int, y: int, room: int, strip: str
|
|
346
|
+
) -> None:
|
|
347
|
+
"""Draw a side's runs one after the other, the marked part picked out, and stop when the room is used."""
|
|
348
|
+
theme = self.theme
|
|
349
|
+
marked = self.parts[self.mark].name
|
|
350
|
+
column = x
|
|
351
|
+
for piece, name in runs:
|
|
352
|
+
left = room - (column - x)
|
|
353
|
+
if left <= 0:
|
|
354
|
+
break
|
|
355
|
+
style = (
|
|
356
|
+
Style(foreground=theme.text, background=strip, bold=True)
|
|
357
|
+
if name == marked
|
|
358
|
+
else Style(foreground=theme.muted, background=strip)
|
|
359
|
+
)
|
|
360
|
+
text = truncate(piece, left) if display_width(piece) > left else piece
|
|
361
|
+
canvas.draw_text(column, y, text, style, max_width=left)
|
|
362
|
+
column += display_width(text)
|
|
363
|
+
|
|
364
|
+
def _draw_list(self, canvas: Canvas) -> None:
|
|
365
|
+
"""Draw the visible rows of the list: a heading for each screen row, then its parts with their switches."""
|
|
366
|
+
theme = self.theme
|
|
367
|
+
surface = theme.surface_alt
|
|
368
|
+
x = self.rect.x + 2
|
|
369
|
+
right = self.rect.right - 2
|
|
370
|
+
view = self._view()
|
|
371
|
+
groove = blend(theme.muted, surface, 0.55)
|
|
372
|
+
now = self._now()
|
|
373
|
+
for offset in range(view):
|
|
374
|
+
index = self.top + offset
|
|
375
|
+
if index >= len(self._lines):
|
|
376
|
+
break
|
|
377
|
+
y = self.rect.y + LIST_TOP + offset
|
|
378
|
+
kind, at = self._lines[index]
|
|
379
|
+
if kind == "heading":
|
|
380
|
+
title = self.row_of[self.parts[at].name]
|
|
381
|
+
canvas.draw_text(x, y, title, Style(foreground=theme.muted, background=surface, bold=True))
|
|
382
|
+
start = x + display_width(title) + 1
|
|
383
|
+
if start < right:
|
|
384
|
+
canvas.draw_text(start, y, "\u2500" * (right - start), Style(foreground=groove, background=surface))
|
|
385
|
+
continue
|
|
386
|
+
part = self.parts[at]
|
|
387
|
+
rail = self.switches[part.name]
|
|
388
|
+
on = rail.position(now)
|
|
389
|
+
marked = at == self.mark
|
|
390
|
+
lit = blend(theme.muted, theme.text, 0.55) if on < 0.5 else theme.text
|
|
391
|
+
name_style = Style(foreground=lit if on >= 0.5 else blend(theme.muted, surface, 0.25), background=surface)
|
|
392
|
+
if marked:
|
|
393
|
+
name_style = Style(foreground=theme.text, background=surface, bold=True)
|
|
394
|
+
canvas.draw_text(x, y, "\u25b8", Style(foreground=theme.accent, background=surface, bold=True))
|
|
395
|
+
canvas.draw_text(x + 2, y, truncate(part.name, NAME_WIDTH), name_style, max_width=NAME_WIDTH)
|
|
396
|
+
switch = self._switch_column()
|
|
397
|
+
self._draw_switch(canvas, y, switch, on, groove)
|
|
398
|
+
word = "shown" if rail.selected else "hidden"
|
|
399
|
+
canvas.draw_text(
|
|
400
|
+
switch + SWITCH_WIDTH + 1,
|
|
401
|
+
y,
|
|
402
|
+
word,
|
|
403
|
+
Style(
|
|
404
|
+
foreground=theme.accent if rail.selected else blend(theme.muted, surface, 0.25),
|
|
405
|
+
background=surface,
|
|
406
|
+
),
|
|
407
|
+
)
|
|
408
|
+
start = switch + SWITCH_WIDTH + 1 + WORD_WIDTH + 1
|
|
409
|
+
room = right - 2 - start
|
|
410
|
+
if room >= MIN_DESCRIPTION:
|
|
411
|
+
canvas.draw_text(
|
|
412
|
+
start,
|
|
413
|
+
y,
|
|
414
|
+
truncate(part.summary, room),
|
|
415
|
+
Style(foreground=blend(theme.muted, surface, 0.15), background=surface),
|
|
416
|
+
max_width=room,
|
|
417
|
+
)
|
|
418
|
+
# A mark at the edge says there is more list than the screen could give it.
|
|
419
|
+
edge = Style(foreground=theme.muted, background=surface)
|
|
420
|
+
if self.top > 0:
|
|
421
|
+
canvas.draw_text(self.rect.right - 2, self.rect.y + LIST_TOP, "\u25b2", edge)
|
|
422
|
+
if self.top + view < len(self._lines):
|
|
423
|
+
canvas.draw_text(self.rect.right - 2, self.rect.y + LIST_TOP + view - 1, "\u25bc", edge)
|
|
424
|
+
|
|
425
|
+
def _draw_switch(self, canvas: Canvas, y: int, x: int, position: float, groove: str) -> None:
|
|
426
|
+
"""Draw one part's switch; see :func:`draw_switch`."""
|
|
427
|
+
draw_switch(canvas, self.theme, y, x, position, groove)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Messages queued to steer the request that is already running.
|
|
2
|
+
|
|
3
|
+
Steering is urgent input: the agent adopts it at the next model or tool
|
|
4
|
+
boundary and drops the rest of the current task, so a reader needs to see that
|
|
5
|
+
their message was taken rather than ignored. The queue empties as the agent
|
|
6
|
+
adopts each message, ending with the widget contributing no rows at all.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
from zettcode.plugin_api.presenters import CONTENT_INDENT
|
|
14
|
+
from zettcode.tui import STEERING, Canvas, Constraints, Size, Style, Widget
|
|
15
|
+
from zettcode.tui.render import display_width, truncate
|
|
16
|
+
|
|
17
|
+
from .follow_up import one_line
|
|
18
|
+
|
|
19
|
+
#: Right-aligned badge that says a row is a steering message.
|
|
20
|
+
STEER_BADGE = "steer"
|
|
21
|
+
#: Shown under the rows: when the agent will actually adopt the message.
|
|
22
|
+
STEER_WHEN = "sent when the running tool call or assistant message ends"
|
|
23
|
+
#: Left margin, shared with the transcript and the composer prompt, so the
|
|
24
|
+
#: markers line up in one column.
|
|
25
|
+
INDENT = CONTENT_INDENT
|
|
26
|
+
#: Columns the marker and the space after it take.
|
|
27
|
+
MARKER_COLUMNS = 2
|
|
28
|
+
#: Cells kept between the message and the badge so they never touch.
|
|
29
|
+
BADGE_GAP = 2
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SteeringQueue(Widget):
|
|
33
|
+
"""Show the messages waiting to steer the running request, one row each.
|
|
34
|
+
|
|
35
|
+
Shape::
|
|
36
|
+
|
|
37
|
+
\u21b3 stop editing and explain steer
|
|
38
|
+
\u21b3 then run the tests steer
|
|
39
|
+
sent when the running tool call or assistant message ends
|
|
40
|
+
^ U+21B3 marker, body text right-aligned badge, then the timing
|
|
41
|
+
|
|
42
|
+
Rows sit on their own raised fill, one step quieter than the composer band
|
|
43
|
+
below, so the queue reads as pending rather than as part of the input. An
|
|
44
|
+
empty queue asks for no rows, so the body slot collapses and the composer
|
|
45
|
+
keeps its place when nothing is pending.
|
|
46
|
+
|
|
47
|
+
A row is one line tall, so a message that is longer than that is cut and says so with an
|
|
48
|
+
ellipsis — whether it ran past the end of the line or had more lines after the first, the
|
|
49
|
+
same way the follow-up list does it. Without that a pasted block shows as its first line
|
|
50
|
+
alone, and nothing says there was more.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
"""Start with nothing queued."""
|
|
55
|
+
super().__init__()
|
|
56
|
+
self.messages: tuple[str, ...] = ()
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def visible(self) -> bool:
|
|
60
|
+
"""Return whether any messages are waiting."""
|
|
61
|
+
return bool(self.messages)
|
|
62
|
+
|
|
63
|
+
def set_messages(self, messages: Sequence[str]) -> bool:
|
|
64
|
+
"""Replace the queue and report whether it actually changed."""
|
|
65
|
+
updated = tuple(messages)
|
|
66
|
+
if updated == self.messages:
|
|
67
|
+
return False
|
|
68
|
+
self.messages = updated
|
|
69
|
+
self.invalidate()
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
def preferred_height(self) -> int:
|
|
73
|
+
"""Return one row per queued message plus the timing note, or none."""
|
|
74
|
+
return len(self.messages) + 1 if self.messages else 0
|
|
75
|
+
|
|
76
|
+
def measure(self, constraints: Constraints) -> Size:
|
|
77
|
+
"""Ask for the widest row, the timing note included."""
|
|
78
|
+
widest = max((display_width(one_line(message)) for message in self.messages), default=0)
|
|
79
|
+
row = INDENT + MARKER_COLUMNS + widest + BADGE_GAP + display_width(STEER_BADGE)
|
|
80
|
+
note = INDENT + display_width(STEER_WHEN)
|
|
81
|
+
return constraints.constrain(Size(max(row, note), self.preferred_height()))
|
|
82
|
+
|
|
83
|
+
def render(self, canvas: Canvas) -> None:
|
|
84
|
+
"""Paint the queued rows and the timing note over the input surface."""
|
|
85
|
+
if not self.messages or self.rect.empty:
|
|
86
|
+
return
|
|
87
|
+
theme = self.theme
|
|
88
|
+
canvas.fill(
|
|
89
|
+
self.rect.x,
|
|
90
|
+
self.rect.y,
|
|
91
|
+
self.rect.width,
|
|
92
|
+
self.rect.height,
|
|
93
|
+
Style(background=theme.surface),
|
|
94
|
+
" ",
|
|
95
|
+
)
|
|
96
|
+
width = self.rect.width
|
|
97
|
+
badge = display_width(STEER_BADGE)
|
|
98
|
+
left = INDENT + MARKER_COLUMNS
|
|
99
|
+
reserved = left + badge + BADGE_GAP
|
|
100
|
+
# A row too narrow for both drops the badge rather than the message.
|
|
101
|
+
show_badge = width >= reserved + 4
|
|
102
|
+
text_width = max(0, width - (reserved if show_badge else left))
|
|
103
|
+
for row, message in enumerate(self.messages):
|
|
104
|
+
y = self.rect.y + row
|
|
105
|
+
if y >= self.rect.y + self.rect.height:
|
|
106
|
+
return
|
|
107
|
+
canvas.draw_text(self.rect.x + INDENT, y, STEERING, Style(foreground=theme.accent))
|
|
108
|
+
canvas.draw_text(
|
|
109
|
+
self.rect.x + left,
|
|
110
|
+
y,
|
|
111
|
+
truncate(one_line(message), text_width),
|
|
112
|
+
Style(foreground=theme.text),
|
|
113
|
+
max_width=text_width,
|
|
114
|
+
)
|
|
115
|
+
if show_badge:
|
|
116
|
+
canvas.draw_text(self.rect.x + width - badge, y, STEER_BADGE, Style(foreground=theme.muted))
|
|
117
|
+
note_y = self.rect.y + len(self.messages)
|
|
118
|
+
if note_y < self.rect.y + self.rect.height:
|
|
119
|
+
note_width = max(0, width - INDENT)
|
|
120
|
+
canvas.draw_text(
|
|
121
|
+
self.rect.x + INDENT,
|
|
122
|
+
note_y,
|
|
123
|
+
truncate(STEER_WHEN, note_width),
|
|
124
|
+
Style(foreground=theme.muted),
|
|
125
|
+
max_width=note_width,
|
|
126
|
+
)
|