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