zettcode-presenters 0.4.0__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/presenters/__init__.py +17 -0
- zettcode/presenters/brand.py +43 -0
- zettcode/presenters/plugin.py +35 -0
- zettcode/presenters/presenters.py +809 -0
- zettcode/presenters/pulse.py +116 -0
- zettcode/presenters/py.typed +0 -0
- zettcode/presenters/text.py +304 -0
- zettcode_presenters-0.4.0.dist-info/METADATA +21 -0
- zettcode_presenters-0.4.0.dist-info/RECORD +11 -0
- zettcode_presenters-0.4.0.dist-info/WHEEL +4 -0
- zettcode_presenters-0.4.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
"""Present entries as styled lines, without mounting a widget per entry.
|
|
2
|
+
|
|
3
|
+
An entry is data; a *presenter* is what turns one kind of entry into rows. There is
|
|
4
|
+
one presenter per :class:`~.entries.EntryKind`, held in a :class:`Presenters`
|
|
5
|
+
registry that looks the right one up by the entry's kind, so adding a kind of row
|
|
6
|
+
is three things: a member of the enum, an entry class, and a presenter that names
|
|
7
|
+
the kind it presents.
|
|
8
|
+
|
|
9
|
+
A presenter also says what its rows depend on (:meth:`Presenter.look`) and whether
|
|
10
|
+
it draws its own marker in the gutter, because both are facts about the same code
|
|
11
|
+
that reads the entry. The registry caches what a presenter produced on the entry,
|
|
12
|
+
keyed by that look, so a row that has not changed is not rendered again; the
|
|
13
|
+
transcript view then stacks the cached rows into one virtualized line source.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, TypeVar
|
|
19
|
+
|
|
20
|
+
from zettcode.plugin_api.entries import (
|
|
21
|
+
ChangesEntry,
|
|
22
|
+
EditEntry,
|
|
23
|
+
Entry,
|
|
24
|
+
EntryKind,
|
|
25
|
+
EntryStatus,
|
|
26
|
+
FileChangeEntry,
|
|
27
|
+
FileSummary,
|
|
28
|
+
MarkdownEntry,
|
|
29
|
+
MessageEntry,
|
|
30
|
+
NoticeLevel,
|
|
31
|
+
ReadEntry,
|
|
32
|
+
RelayEntry,
|
|
33
|
+
TextEntry,
|
|
34
|
+
ThinkingEntry,
|
|
35
|
+
ToolEntry,
|
|
36
|
+
WaitEntry,
|
|
37
|
+
WriteEntry,
|
|
38
|
+
)
|
|
39
|
+
from zettcode.plugin_api.presenters import CONTENT_INDENT, Indented, LeadingGap, Presenter, Presenters
|
|
40
|
+
from zettcode.tui import (
|
|
41
|
+
DONE,
|
|
42
|
+
ELLIPSIS,
|
|
43
|
+
EXPANDED,
|
|
44
|
+
FAILED,
|
|
45
|
+
MARKER,
|
|
46
|
+
PROMPT,
|
|
47
|
+
SEPARATOR,
|
|
48
|
+
SKIPPED,
|
|
49
|
+
STATUS,
|
|
50
|
+
LineSource,
|
|
51
|
+
Span,
|
|
52
|
+
Style,
|
|
53
|
+
TextLine,
|
|
54
|
+
Theme,
|
|
55
|
+
)
|
|
56
|
+
from zettcode.tui.render import (
|
|
57
|
+
SweepSpan,
|
|
58
|
+
blend,
|
|
59
|
+
display_width,
|
|
60
|
+
highlight,
|
|
61
|
+
inset_line,
|
|
62
|
+
layout_rich_lines,
|
|
63
|
+
sweep_spans,
|
|
64
|
+
truncate,
|
|
65
|
+
)
|
|
66
|
+
from zettcode.tui.widgets.markdown import MarkdownSource
|
|
67
|
+
|
|
68
|
+
from .brand import LOGO_LINES
|
|
69
|
+
from .pulse import RUNNING_GLYPHS, WAIT_RAMP, moving, sweep_column
|
|
70
|
+
from .text import (
|
|
71
|
+
COMPACTING_LABEL,
|
|
72
|
+
STOP_HINT,
|
|
73
|
+
TOOL_EXPANDED_ROWS,
|
|
74
|
+
TOOL_PREVIEW_ROWS,
|
|
75
|
+
bounded_rows,
|
|
76
|
+
duration_text,
|
|
77
|
+
elapsed_text,
|
|
78
|
+
pack_segments,
|
|
79
|
+
quiet_text,
|
|
80
|
+
terminal_safe,
|
|
81
|
+
tip_text,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
#: Columns a tip's text is inset by: the same two the tools' results use for their hook.
|
|
85
|
+
TIP_INDENT = 2
|
|
86
|
+
|
|
87
|
+
#: The tool entry a tool presenter accepts.
|
|
88
|
+
T = TypeVar("T", bound=ToolEntry)
|
|
89
|
+
|
|
90
|
+
#: The file-changing tool entry a file-change presenter accepts.
|
|
91
|
+
F = TypeVar("F", bound=FileChangeEntry)
|
|
92
|
+
|
|
93
|
+
#: Palette role that paints a tool, by the action it performs. The built-in
|
|
94
|
+
#: palettes give every role the same violet; a theme file can separate them. A
|
|
95
|
+
#: tool this table has never seen falls back to the generic accent.
|
|
96
|
+
TOOL_ROLES = {
|
|
97
|
+
"read_file": "read",
|
|
98
|
+
"read": "read",
|
|
99
|
+
"view_image": "image",
|
|
100
|
+
"glob": "search",
|
|
101
|
+
"grep": "search",
|
|
102
|
+
"web_search": "web-search",
|
|
103
|
+
"ask_user": "ask-user",
|
|
104
|
+
"write_file": "write",
|
|
105
|
+
"edit": "write",
|
|
106
|
+
"replace_in_file": "write", # what a session stored before the rename called it
|
|
107
|
+
"delete_file": "delete",
|
|
108
|
+
"bash": "shell",
|
|
109
|
+
"todo_write": "plan",
|
|
110
|
+
"read_skill": "read",
|
|
111
|
+
"task": "subagent",
|
|
112
|
+
"send_message": "message",
|
|
113
|
+
"list_sessions": "message",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def tool_color(tool: str, theme: Theme) -> str:
|
|
118
|
+
"""Return the colour one tool paints its rows with.
|
|
119
|
+
|
|
120
|
+
A role spelled with a hyphen (``web-search``) names the theme field it stands for (``web_search``).
|
|
121
|
+
"""
|
|
122
|
+
role = TOOL_ROLES.get(tool, "").replace("-", "_")
|
|
123
|
+
return getattr(theme.tools, role, theme.accent)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _welcome_label(label: str, theme: Theme, *, titled: bool) -> list[Span]:
|
|
127
|
+
"""Style one label of the banner: the title in bold, everything after it in the quiet colour.
|
|
128
|
+
|
|
129
|
+
The title line is the name and, two spaces after it, the version. The name is the one bold thing; the
|
|
130
|
+
version is the same line's quiet part, so it reads as a fact about the name rather than as part of it.
|
|
131
|
+
"""
|
|
132
|
+
quiet = Style(foreground=theme.subtle)
|
|
133
|
+
if titled:
|
|
134
|
+
return [Span(label, quiet)]
|
|
135
|
+
name, gap, rest = _after_indent(label)
|
|
136
|
+
spans = [Span(name, Style(foreground=theme.text, bold=True))]
|
|
137
|
+
if rest:
|
|
138
|
+
spans.append(Span(gap + rest, quiet))
|
|
139
|
+
return spans
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _after_indent(label: str) -> tuple[str, str, str]:
|
|
143
|
+
"""Split an indented title into its name (indent included), the gap, and what follows the gap."""
|
|
144
|
+
body = label.lstrip()
|
|
145
|
+
indent = label[: len(label) - len(body)]
|
|
146
|
+
name, gap, rest = body.partition(" ")
|
|
147
|
+
return indent + name, gap, rest.strip()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _unindented(spans: list[Span]) -> list[Span]:
|
|
151
|
+
"""Return the spans of a label without the indent it has beside the mark, for stacking under it."""
|
|
152
|
+
first = spans[0]
|
|
153
|
+
return [Span(first.text.lstrip(), first.style), *spans[1:]]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class WelcomePresenter(Presenter[TextEntry]):
|
|
157
|
+
"""Draw the shared brand pixels unchanged, with themed title and hint text."""
|
|
158
|
+
|
|
159
|
+
kind = EntryKind.WELCOME
|
|
160
|
+
|
|
161
|
+
def lines(self, entry: TextEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
|
|
162
|
+
"""Keep labels beside the mark, stacking them when the window is narrow."""
|
|
163
|
+
if width <= 0:
|
|
164
|
+
return []
|
|
165
|
+
lines: list[TextLine] = []
|
|
166
|
+
marks: list[TextLine] = []
|
|
167
|
+
labels: list[TextLine] = []
|
|
168
|
+
titled = False
|
|
169
|
+
for index, line in enumerate(entry.text.split("\n")):
|
|
170
|
+
mark = LOGO_LINES[index] if index < len(LOGO_LINES) else None
|
|
171
|
+
if mark is not None and line.startswith(f" {mark.text}"):
|
|
172
|
+
spans = [Span(" "), *mark.spans]
|
|
173
|
+
marks.append(TextLine(tuple(spans)))
|
|
174
|
+
label = line[2 + len(mark.text) :]
|
|
175
|
+
if label:
|
|
176
|
+
shown = _welcome_label(label, theme, titled=titled)
|
|
177
|
+
spans.extend(shown)
|
|
178
|
+
labels.append(TextLine(tuple(_unindented(shown))))
|
|
179
|
+
titled = True
|
|
180
|
+
lines.append(TextLine(tuple(spans)))
|
|
181
|
+
else:
|
|
182
|
+
lines.append(TextLine((Span(line, Style(foreground=theme.subtle)),)))
|
|
183
|
+
if marks and any(line.width > width for line in lines):
|
|
184
|
+
stacked = [*marks, TextLine(), *(inset_line(label, 2) for label in labels)]
|
|
185
|
+
return list(layout_rich_lines(stacked, width, wrap=False))
|
|
186
|
+
return lines
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class NoticePresenter(Presenter[TextEntry]):
|
|
190
|
+
"""Draw notices aligned with the answer above them.
|
|
191
|
+
|
|
192
|
+
A notice is a remark; one a caller marked as an error is a failure, and
|
|
193
|
+
failure is what the palette's error colour is for.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
kind = EntryKind.NOTICE
|
|
197
|
+
|
|
198
|
+
def lines(self, entry: TextEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
|
|
199
|
+
"""Leave a blank separator and let the transcript gutter add the inset."""
|
|
200
|
+
colour = theme.error if entry.level == NoticeLevel.ERROR else theme.muted
|
|
201
|
+
return [TextLine(), *self.text_lines(entry.text, width, Style(foreground=colour), words=True)]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class AnnouncementPresenter(Presenter[TextEntry]):
|
|
205
|
+
"""Center an announcement — a model or effort change — between rules."""
|
|
206
|
+
|
|
207
|
+
kind = EntryKind.ANNOUNCEMENT
|
|
208
|
+
|
|
209
|
+
def lines(self, entry: TextEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
|
|
210
|
+
"""Center the label inside the padded content box.
|
|
211
|
+
|
|
212
|
+
The transcript gutter already insets the row on the left, so the rule
|
|
213
|
+
keeps the same two cells free on the right; the header, the status bar,
|
|
214
|
+
and the composer all pad both edges by that amount.
|
|
215
|
+
"""
|
|
216
|
+
margin = min(CONTENT_INDENT, max(0, width - 1))
|
|
217
|
+
available = max(1, width - margin)
|
|
218
|
+
if available <= 2:
|
|
219
|
+
return [TextLine(), TextLine((Span("─" * available, Style(foreground=theme.border)),)), TextLine()]
|
|
220
|
+
heading = TextLine(
|
|
221
|
+
(Span("◇ ", Style(foreground=theme.accent)), Span(entry.text, Style(foreground=theme.subtle)))
|
|
222
|
+
)
|
|
223
|
+
heading = heading.layout(available - 2, wrap=False)[0]
|
|
224
|
+
remaining = max(0, available - heading.width - 2)
|
|
225
|
+
left = remaining // 2
|
|
226
|
+
right = remaining - left
|
|
227
|
+
rule = Style(foreground=theme.border)
|
|
228
|
+
line = TextLine(
|
|
229
|
+
(
|
|
230
|
+
Span("─" * left + " ", rule),
|
|
231
|
+
*heading.spans,
|
|
232
|
+
Span(" " + "─" * right, rule),
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
return [TextLine(), line, TextLine()]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class UserPresenter(Presenter[TextEntry]):
|
|
239
|
+
"""Paint a full-width message surface with its arrow flush left."""
|
|
240
|
+
|
|
241
|
+
kind = EntryKind.USER
|
|
242
|
+
gutter = True
|
|
243
|
+
|
|
244
|
+
def lines(self, entry: TextEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
|
|
245
|
+
"""Wrap the message and fill each row to the edge of the surface.
|
|
246
|
+
|
|
247
|
+
The arrow sits flush left, in the column the composer's prompt is drawn in,
|
|
248
|
+
so the message the reader sent lines up under the draft they are typing —
|
|
249
|
+
arrow over arrow, text over text.
|
|
250
|
+
"""
|
|
251
|
+
style = Style(foreground=theme.text, background=theme.surface_alt)
|
|
252
|
+
marker = PROMPT
|
|
253
|
+
if entry.side:
|
|
254
|
+
# A side question is answered on screen and nowhere else: it keeps a
|
|
255
|
+
# surface of its own, so the row reads as a note beside the
|
|
256
|
+
# conversation rather than as one of its turns.
|
|
257
|
+
style = Style(foreground=theme.subtle, background=theme.surface_side)
|
|
258
|
+
marker = f"btw {PROMPT}"
|
|
259
|
+
# Room kept free on the right, so no row runs into the edge of the surface.
|
|
260
|
+
margin = min(CONTENT_INDENT, max(0, width - 1))
|
|
261
|
+
prompt = self.text_lines(entry.text, max(1, width - margin), style)
|
|
262
|
+
background = TextLine((Span(" " * width, style),))
|
|
263
|
+
lines = [TextLine(), background]
|
|
264
|
+
pad = " " * display_width(marker)
|
|
265
|
+
for index, row in enumerate(prompt):
|
|
266
|
+
prefix = f"{marker} " if index == 0 else f"{pad} "
|
|
267
|
+
body = truncate(f"{prefix}{row.text}", width)
|
|
268
|
+
padding = " " * max(0, width - display_width(body))
|
|
269
|
+
lines.append(TextLine((Span(body + padding, style),)))
|
|
270
|
+
lines.append(background)
|
|
271
|
+
return lines
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def relay_detail(theme: Theme) -> str:
|
|
275
|
+
"""Return the colour of the quiet words on a message between sessions (``delivered``, ``queued``).
|
|
276
|
+
|
|
277
|
+
The surface is a little lighter than the reader's own, and the usual quiet text would be too close to
|
|
278
|
+
it, so the words are taken halfway to the body colour: still quieter than the message, and readable.
|
|
279
|
+
"""
|
|
280
|
+
return blend(theme.subtle, theme.text, 0.5)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class RelayPresenter(Presenter[RelayEntry]):
|
|
284
|
+
"""Draw a message between two sessions the way the reader's own message is drawn, on a surface of its own.
|
|
285
|
+
|
|
286
|
+
Same arrow, same text column, same filled rows as what the reader types — it is part of the
|
|
287
|
+
conversation — but on a different background, with a line above the text that says whose it is::
|
|
288
|
+
|
|
289
|
+
← agent of session fix-the-parser
|
|
290
|
+
› The tokenizer change is merged; can you rerun the parser tests?
|
|
291
|
+
|
|
292
|
+
→ to session fix-the-parser · queued
|
|
293
|
+
› yes, on it
|
|
294
|
+
|
|
295
|
+
The header is the one place colour says who spoke: the message colour (mauve) for another session's
|
|
296
|
+
*agent* (another agent, as a delegated child is), the warning yellow for another session's reader,
|
|
297
|
+
and the accent for this reader's own message to it.
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
kind = EntryKind.RELAY
|
|
301
|
+
gutter = True
|
|
302
|
+
|
|
303
|
+
def lines(self, entry: RelayEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
|
|
304
|
+
"""Return the header and the wrapped message on the surface, every row filled to the edge."""
|
|
305
|
+
if entry.outgoing:
|
|
306
|
+
tone = theme.accent
|
|
307
|
+
elif entry.external:
|
|
308
|
+
tone = theme.warning
|
|
309
|
+
elif entry.by_agent:
|
|
310
|
+
tone = theme.tools.message
|
|
311
|
+
else:
|
|
312
|
+
tone = theme.warning
|
|
313
|
+
style = Style(foreground=theme.text, background=theme.surface_peer)
|
|
314
|
+
margin = min(CONTENT_INDENT, max(0, width - 1))
|
|
315
|
+
pad = " " * display_width(PROMPT)
|
|
316
|
+
peer = terminal_safe(entry.peer).replace("\n", " ")
|
|
317
|
+
if entry.outgoing:
|
|
318
|
+
label = f"\u2192 to session {peer}"
|
|
319
|
+
detail = f" {SEPARATOR} {entry.note}" if entry.note else ""
|
|
320
|
+
else:
|
|
321
|
+
label = (
|
|
322
|
+
f"\u2190 external source {peer}"
|
|
323
|
+
if entry.external
|
|
324
|
+
else f"\u2190 {'agent' if entry.by_agent else 'user'} of session {peer}"
|
|
325
|
+
)
|
|
326
|
+
detail = ""
|
|
327
|
+
if entry.group:
|
|
328
|
+
# A group message is read by every member: say so in the header, where a one-to-one message has nothing.
|
|
329
|
+
label += f" in #{terminal_safe(entry.group)}"
|
|
330
|
+
detail = f" {SEPARATOR} to the whole group"
|
|
331
|
+
|
|
332
|
+
def row(*spans: Span) -> TextLine:
|
|
333
|
+
used = sum(display_width(span.text) for span in spans)
|
|
334
|
+
return TextLine((*spans, Span(" " * max(0, width - used), style)))
|
|
335
|
+
|
|
336
|
+
head = truncate(f"{pad} {label}", width)
|
|
337
|
+
tail = truncate(detail, max(0, width - display_width(head)))
|
|
338
|
+
header = row(
|
|
339
|
+
Span(head, Style(foreground=tone, background=theme.surface_peer)),
|
|
340
|
+
Span(tail, Style(foreground=relay_detail(theme), background=theme.surface_peer)),
|
|
341
|
+
)
|
|
342
|
+
blank = row()
|
|
343
|
+
lines = [TextLine(), blank, header]
|
|
344
|
+
for index, text in enumerate(self.text_lines(terminal_safe(entry.text), max(1, width - margin), style)):
|
|
345
|
+
prefix = f"{PROMPT} " if index == 0 else f"{pad} "
|
|
346
|
+
lines.append(row(Span(truncate(f"{prefix}{text.text}", width), style)))
|
|
347
|
+
lines.append(blank)
|
|
348
|
+
return lines
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class WaitPresenter(Presenter[WaitEntry]):
|
|
352
|
+
"""Draw the pinned wait row: a sweep while it runs, a line once it has ended.
|
|
353
|
+
|
|
354
|
+
It is the one row that animates, and it is settled rather than removed: a
|
|
355
|
+
request that was stopped still says how long it ran and when it stopped.
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
kind = EntryKind.WAIT
|
|
359
|
+
gutter = True
|
|
360
|
+
|
|
361
|
+
def look(self, entry: WaitEntry, frame: int) -> object:
|
|
362
|
+
"""Add the animation step while the row runs; a settled row is a line.
|
|
363
|
+
|
|
364
|
+
This is the one presenter that reads the frame, which is the whole of what
|
|
365
|
+
makes the wait row the one row that moves: every other row's look is its
|
|
366
|
+
fields, and an animation step leaves it as it was.
|
|
367
|
+
"""
|
|
368
|
+
return (super().look(entry, frame), frame if entry.running else 0)
|
|
369
|
+
|
|
370
|
+
def lines(self, entry: WaitEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
|
|
371
|
+
"""Pulse the word below a blank separator, or show the line it settled into.
|
|
372
|
+
|
|
373
|
+
The clock and the key that stops the request stand beside the word in
|
|
374
|
+
parentheses, in the same quiet ink a notice uses, so they read as one
|
|
375
|
+
aside rather than as part of the row's business — and the highlight crosses
|
|
376
|
+
the word alone, never the aside. The clock counts whole seconds: tenths of
|
|
377
|
+
a second are more detail than a reader waiting on a request wants.
|
|
378
|
+
"""
|
|
379
|
+
if not entry.running:
|
|
380
|
+
settled = entry.text or f"Processed for {elapsed_text(entry.duration or 0.0)}"
|
|
381
|
+
ink = Style(foreground=theme.muted)
|
|
382
|
+
room = max(1, width - CONTENT_INDENT)
|
|
383
|
+
packed = pack_segments(list(entry.parts) or settled.split(f" {SEPARATOR} "), room)
|
|
384
|
+
return [
|
|
385
|
+
TextLine(),
|
|
386
|
+
*(
|
|
387
|
+
layout_rich_lines((TextLine((Span(" " * CONTENT_INDENT + row, ink),)),), width, wrap=False)[0]
|
|
388
|
+
for row in packed
|
|
389
|
+
),
|
|
390
|
+
]
|
|
391
|
+
spans = list(_running_label(entry.title, theme, frame))
|
|
392
|
+
if entry.duration is not None:
|
|
393
|
+
quiet = quiet_text(entry.quiet)
|
|
394
|
+
said = f"{elapsed_text(entry.duration)} {SEPARATOR} "
|
|
395
|
+
if quiet is not None:
|
|
396
|
+
# In the warning ink, apart from the quiet aside around it: this is the one thing in the row
|
|
397
|
+
# that says something may be wrong, and a reader who has been waiting is looking for it.
|
|
398
|
+
spans.append(Span(f" ({said}", Style(foreground=theme.muted)))
|
|
399
|
+
spans.append(Span(quiet, Style(foreground=theme.warning)))
|
|
400
|
+
spans.append(Span(f" {SEPARATOR} {STOP_HINT})", Style(foreground=theme.muted)))
|
|
401
|
+
else:
|
|
402
|
+
spans.append(Span(f" ({said}{STOP_HINT})", Style(foreground=theme.muted)))
|
|
403
|
+
rows = [TextLine(), layout_rich_lines((TextLine(tuple(spans)),), width, wrap=False)[0]]
|
|
404
|
+
tip = tip_text(entry.tips, entry.duration)
|
|
405
|
+
if tip is not None:
|
|
406
|
+
# Under the word, in the quiet ink and hung from it the way a tool's result is: it is not about the
|
|
407
|
+
# request, so it must not read as part of it.
|
|
408
|
+
gutter, ink = Style(foreground=theme.muted), Style(foreground=theme.muted)
|
|
409
|
+
text = self.text_lines(f"Tip: {tip}", max(1, width - TIP_INDENT * 2), ink, words=True)
|
|
410
|
+
for index, row in enumerate(text):
|
|
411
|
+
lead = f"{' ' * CONTENT_INDENT}\u2514 " if index == 0 else " " * (CONTENT_INDENT * 2)
|
|
412
|
+
rows.append(TextLine((Span(lead, gutter), *row.spans)))
|
|
413
|
+
return rows
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class ThinkingPresenter(Presenter[ThinkingEntry]):
|
|
417
|
+
"""Draw reasoning in the "model" hue, apart from the tool rows' green."""
|
|
418
|
+
|
|
419
|
+
kind = EntryKind.THINKING
|
|
420
|
+
gutter = True
|
|
421
|
+
|
|
422
|
+
def lines(self, entry: ThinkingEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
|
|
423
|
+
"""Show the heading, plus the indented body when expanded."""
|
|
424
|
+
header = Style(foreground=theme.accent_bright)
|
|
425
|
+
if entry.running:
|
|
426
|
+
timing = duration_text(entry.duration) if entry.duration is not None else "working"
|
|
427
|
+
# The marker is painted flat, like a settled row's: the waiting row
|
|
428
|
+
# is the one thing that sweeps, so a run with reasoning and tools in
|
|
429
|
+
# flight keeps one moving row instead of one per row on screen.
|
|
430
|
+
heading = (Span(f"{RUNNING_GLYPHS[0]} ", header), Span(f"{entry.title} {timing}", header))
|
|
431
|
+
else:
|
|
432
|
+
# A finished row whose duration was never recorded says nothing about
|
|
433
|
+
# time; calling it "working" would claim a run that has already ended.
|
|
434
|
+
timing = f" {duration_text(entry.duration)}" if entry.duration is not None else ""
|
|
435
|
+
marker = EXPANDED if entry.expanded else MARKER
|
|
436
|
+
heading = (Span(f"{marker} ", header), Span(f"{entry.title}{timing}", header))
|
|
437
|
+
lines = [TextLine(), layout_rich_lines((TextLine(heading),), width, wrap=False)[0]]
|
|
438
|
+
if entry.expanded:
|
|
439
|
+
waiting = "summary" if entry.title == COMPACTING_LABEL else "reasoning"
|
|
440
|
+
source = "\n".join(entry.text.splitlines()) if entry.text else f"Waiting for {waiting}{ELLIPSIS}"
|
|
441
|
+
lines.extend(self.text_lines(source, width, Style(foreground=theme.subtle), indent=CONTENT_INDENT))
|
|
442
|
+
return lines
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class ToolPresenter(Presenter[T]):
|
|
446
|
+
"""Draw a tool heading and a bounded preview of its result.
|
|
447
|
+
|
|
448
|
+
A row is painted in the hue of the tool that ran, so a transcript reads by
|
|
449
|
+
what was done: reading, searching, editing, running. Tools that do the same
|
|
450
|
+
thing share a hue, and a failed row is red whatever the tool was.
|
|
451
|
+
|
|
452
|
+
This is the row every tool has. A tool whose entry carries more has a presenter that
|
|
453
|
+
extends this one in the two places a row can differ — what is written after the title
|
|
454
|
+
(:meth:`after_title`) and what is written under it (:meth:`body_lines`) — and nothing
|
|
455
|
+
else, so the symbol, the timer, the fold and the preview limits are the same for all of
|
|
456
|
+
them and cannot drift apart.
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
kind = EntryKind.TOOL
|
|
460
|
+
gutter = True
|
|
461
|
+
|
|
462
|
+
def lines(self, entry: T, width: int, theme: Theme, frame: int) -> list[TextLine]:
|
|
463
|
+
"""Show the call, elapsed time, and whatever body the tool's entry has."""
|
|
464
|
+
running = entry.running
|
|
465
|
+
symbol = (
|
|
466
|
+
RUNNING_GLYPHS[0]
|
|
467
|
+
if running
|
|
468
|
+
else {
|
|
469
|
+
EntryStatus.COMPLETED: DONE,
|
|
470
|
+
EntryStatus.FAILED: FAILED,
|
|
471
|
+
EntryStatus.SKIPPED: SKIPPED,
|
|
472
|
+
}.get(entry.status, STATUS)
|
|
473
|
+
)
|
|
474
|
+
hue = tool_color(entry.tool, theme)
|
|
475
|
+
# A failure is the error colour, and a row that did not happen at all — a skipped tool, a cancelled
|
|
476
|
+
# question — is a caution rather than a result, so it is amber and not the tool's own hue.
|
|
477
|
+
colour = {
|
|
478
|
+
EntryStatus.FAILED: theme.error,
|
|
479
|
+
EntryStatus.SKIPPED: theme.warning,
|
|
480
|
+
}.get(entry.status, hue)
|
|
481
|
+
marker = Style(foreground=colour)
|
|
482
|
+
label = Style(foreground=colour)
|
|
483
|
+
muted = Style(foreground=theme.muted)
|
|
484
|
+
timing = f" {duration_text(entry.duration)}" if entry.duration is not None else ""
|
|
485
|
+
disclosure = "" if not entry.foldable else (f" {EXPANDED}" if entry.expanded else f" {MARKER}")
|
|
486
|
+
room = max(1, width - display_width(f"{symbol} {timing}{disclosure}"))
|
|
487
|
+
title = truncate(entry.title, room)
|
|
488
|
+
# A running title is painted flat, exactly as a settled one is: the
|
|
489
|
+
# waiting row owns the motion, and a row that does not move is a row an
|
|
490
|
+
# animation step never has to rebuild.
|
|
491
|
+
heading: list[Span] = [Span(f"{symbol} ", marker), Span(title, label), *self.after_title(entry, theme)]
|
|
492
|
+
if timing:
|
|
493
|
+
heading.append(Span(timing, muted))
|
|
494
|
+
if disclosure:
|
|
495
|
+
heading.append(Span(disclosure, muted))
|
|
496
|
+
lines = [TextLine(), TextLine(tuple(heading))]
|
|
497
|
+
lines.extend(self.body_lines(entry, width, theme, muted))
|
|
498
|
+
return [layout_rich_lines((line,), width, wrap=False)[0] if line.spans else line for line in lines]
|
|
499
|
+
|
|
500
|
+
def after_title(self, entry: T, theme: Theme) -> list[Span]:
|
|
501
|
+
"""Return what follows the title on the heading row; a plain tool has nothing to add."""
|
|
502
|
+
return []
|
|
503
|
+
|
|
504
|
+
def body_lines(self, entry: T, width: int, theme: Theme, muted: Style) -> list[TextLine]:
|
|
505
|
+
"""Return the rows under the heading: the tool's output, bounded, or that it is running."""
|
|
506
|
+
return self.output_lines(entry, width, theme, muted, language=None)
|
|
507
|
+
|
|
508
|
+
@staticmethod
|
|
509
|
+
def output_lines(
|
|
510
|
+
entry: ToolEntry, width: int, theme: Theme, muted: Style, *, language: str | None
|
|
511
|
+
) -> list[TextLine]:
|
|
512
|
+
"""Return the bounded preview of a tool's output, highlighted as ``language`` when it has one."""
|
|
513
|
+
output = f"Running{ELLIPSIS}" if entry.running else entry.text
|
|
514
|
+
if not output:
|
|
515
|
+
return []
|
|
516
|
+
limit = TOOL_EXPANDED_ROWS if entry.expanded else TOOL_PREVIEW_ROWS
|
|
517
|
+
rows, omitted = bounded_rows(output, max(1, width - CONTENT_INDENT * 2), limit)
|
|
518
|
+
detail = Style(foreground=theme.subtle)
|
|
519
|
+
gutter = Style(foreground=theme.muted)
|
|
520
|
+
lines: list[TextLine] = []
|
|
521
|
+
for index, row in enumerate(rows):
|
|
522
|
+
lead = f"{' ' * CONTENT_INDENT}\u2514 " if index == 0 else " " * (CONTENT_INDENT * 2)
|
|
523
|
+
if language is None:
|
|
524
|
+
lines.append(TextLine((Span(lead, gutter), Span(row, detail))))
|
|
525
|
+
else:
|
|
526
|
+
lines.append(TextLine((Span(lead, gutter), *highlight(row, language, theme.code))))
|
|
527
|
+
if omitted:
|
|
528
|
+
lines.append(TextLine((Span(f"{' ' * (CONTENT_INDENT * 2)}{ELLIPSIS} {omitted} more rows", muted),)))
|
|
529
|
+
return lines
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
class SessionMessagePresenter(ToolPresenter[MessageEntry]):
|
|
533
|
+
"""A message the agent sent to another session: what it said, then how it ended.
|
|
534
|
+
|
|
535
|
+
Folded, the row shows the first lines of the message and the one-line outcome; expanded, every
|
|
536
|
+
line of the message. The message comes first because it is the substance of the call; the outcome
|
|
537
|
+
(``Delivered to work …``, or why it was not) is a line of its own underneath, in the colour of the
|
|
538
|
+
result — muted when it went, the error colour when it did not.
|
|
539
|
+
"""
|
|
540
|
+
|
|
541
|
+
entry_type = MessageEntry
|
|
542
|
+
|
|
543
|
+
def body_lines(self, entry: MessageEntry, width: int, theme: Theme, muted: Style) -> list[TextLine]:
|
|
544
|
+
"""Return the message, bounded as any tool output is, and the outcome under it."""
|
|
545
|
+
if entry.running:
|
|
546
|
+
return []
|
|
547
|
+
inner = max(1, width - CONTENT_INDENT * 2)
|
|
548
|
+
limit = TOOL_EXPANDED_ROWS if entry.expanded else TOOL_PREVIEW_ROWS
|
|
549
|
+
detail = Style(foreground=theme.subtle)
|
|
550
|
+
gutter = Style(foreground=theme.muted)
|
|
551
|
+
lines: list[TextLine] = []
|
|
552
|
+
rows, omitted = bounded_rows(entry.message, inner, limit) if entry.message else ([], 0)
|
|
553
|
+
for index, row in enumerate(rows):
|
|
554
|
+
lead = f"{' ' * CONTENT_INDENT}\u2514 " if index == 0 else " " * (CONTENT_INDENT * 2)
|
|
555
|
+
lines.append(TextLine((Span(lead, gutter), Span(row, detail))))
|
|
556
|
+
if omitted:
|
|
557
|
+
lines.append(TextLine((Span(f"{' ' * (CONTENT_INDENT * 2)}{ELLIPSIS} {omitted} more rows", muted),)))
|
|
558
|
+
if entry.text:
|
|
559
|
+
ended = Style(foreground=theme.error if entry.status is EntryStatus.FAILED else theme.muted)
|
|
560
|
+
# An arrow, so the answer is not read as the last line of the message above it.
|
|
561
|
+
outcome, more = bounded_rows(entry.text, max(1, inner - 2), 3 if entry.expanded else 1)
|
|
562
|
+
for index, row in enumerate(outcome):
|
|
563
|
+
lead = f"{' ' * CONTENT_INDENT}\u2192 " if index == 0 else " " * (CONTENT_INDENT * 2)
|
|
564
|
+
lines.append(TextLine((Span(f"{lead}{row}", ended),)))
|
|
565
|
+
if more and not entry.expanded:
|
|
566
|
+
lines[-1] = TextLine((Span(f"{lines[-1].text} {ELLIPSIS}", ended),))
|
|
567
|
+
return lines
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
class ReadPresenter(ToolPresenter[ReadEntry]):
|
|
571
|
+
"""A file that was read: the body is highlighted as the language the file is in, and the row says how much."""
|
|
572
|
+
|
|
573
|
+
entry_type = ReadEntry
|
|
574
|
+
|
|
575
|
+
def after_title(self, entry: ReadEntry, theme: Theme) -> list[Span]:
|
|
576
|
+
"""Say how many lines came back, beside the title rather than inside it.
|
|
577
|
+
|
|
578
|
+
A field of the row survives what a title does not: `restate_tool` rewrites the title from the arguments, and
|
|
579
|
+
the count is drawn from the entry, the way an edit's ``+N -N`` is. A row too narrow for the title and the
|
|
580
|
+
count together cuts the count, as it cuts any tail — an edit's counts go the same way.
|
|
581
|
+
"""
|
|
582
|
+
if entry.lines is None:
|
|
583
|
+
return []
|
|
584
|
+
return [Span(f" \u00b7 {entry.lines} line" + ("" if entry.lines == 1 else "s"), Style(foreground=theme.subtle))]
|
|
585
|
+
|
|
586
|
+
def body_lines(self, entry: ReadEntry, width: int, theme: Theme, muted: Style) -> list[TextLine]:
|
|
587
|
+
"""Show the output as source code when the file is."""
|
|
588
|
+
return self.output_lines(entry, width, theme, muted, language=entry.language)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _change_counts(added: int, removed: int, theme: Theme) -> list[Span]:
|
|
592
|
+
"""Return ``+N`` and ``-M`` in the colours an addition and a deletion get; a zero is left out."""
|
|
593
|
+
spans: list[Span] = []
|
|
594
|
+
if added:
|
|
595
|
+
spans.append(Span(f" +{added}", Style(foreground=theme.addition)))
|
|
596
|
+
if removed:
|
|
597
|
+
spans.append(Span(f" -{removed}", Style(foreground=theme.deletion)))
|
|
598
|
+
return spans
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
class FileChangePresenter(ToolPresenter[F]):
|
|
602
|
+
"""A tool that changed a file: the lines it changed beside the name, and the change under it.
|
|
603
|
+
|
|
604
|
+
What a write and an edit share. They differ only in whether anything is taken away
|
|
605
|
+
(:meth:`removed`), so the diff, the colours, the cut at the width and the preview limits are
|
|
606
|
+
written once.
|
|
607
|
+
"""
|
|
608
|
+
|
|
609
|
+
def removed(self, entry: F) -> int:
|
|
610
|
+
"""Return the lines the change removes; a change that cannot remove any says none."""
|
|
611
|
+
return 0
|
|
612
|
+
|
|
613
|
+
def after_title(self, entry: F, theme: Theme) -> list[Span]:
|
|
614
|
+
"""Show how many lines the change adds and removes."""
|
|
615
|
+
return _change_counts(entry.added, self.removed(entry), theme)
|
|
616
|
+
|
|
617
|
+
def body_lines(self, entry: F, width: int, theme: Theme, muted: Style) -> list[TextLine]:
|
|
618
|
+
"""Show the change once the call has succeeded; before that, and if it failed, the usual body."""
|
|
619
|
+
if not entry.shows_diff:
|
|
620
|
+
return super().body_lines(entry, width, theme, muted)
|
|
621
|
+
return self._diff_lines(entry, width, theme, muted)
|
|
622
|
+
|
|
623
|
+
@staticmethod
|
|
624
|
+
def _diff_lines(entry: FileChangeEntry, width: int, theme: Theme, muted: Style) -> list[TextLine]:
|
|
625
|
+
"""Draw the change an edit made: additions and deletions in the colours the counts wear.
|
|
626
|
+
|
|
627
|
+
A row is cut at the width rather than wrapped, so one long line cannot push the rest of
|
|
628
|
+
the diff off the screen; the break between two edits is drawn quiet, and what is past the
|
|
629
|
+
preview is counted the way a long output's is.
|
|
630
|
+
"""
|
|
631
|
+
limit = TOOL_EXPANDED_ROWS if entry.expanded else TOOL_PREVIEW_ROWS
|
|
632
|
+
room = max(1, width - CONTENT_INDENT * 2 - 2)
|
|
633
|
+
styles = {
|
|
634
|
+
"+": Style(foreground=theme.addition, background=theme.addition_fill),
|
|
635
|
+
"-": Style(foreground=theme.deletion, background=theme.deletion_fill),
|
|
636
|
+
" ": Style(foreground=theme.subtle),
|
|
637
|
+
"@": muted,
|
|
638
|
+
}
|
|
639
|
+
lines: list[TextLine] = []
|
|
640
|
+
for index, (sign, text) in enumerate(entry.diff[:limit]):
|
|
641
|
+
lead = f"{' ' * CONTENT_INDENT}\u2514 " if index == 0 else " " * (CONTENT_INDENT * 2)
|
|
642
|
+
style = styles.get(sign, styles[" "])
|
|
643
|
+
shown = text if sign == "@" else f"{sign} {terminal_safe(text)}"
|
|
644
|
+
cut = truncate(shown, room)
|
|
645
|
+
if style.background is not None:
|
|
646
|
+
# A band runs the width of the row, so a short line is as much a line as a long one.
|
|
647
|
+
cut += " " * max(0, room - display_width(cut))
|
|
648
|
+
lines.append(TextLine((Span(lead, muted), Span(cut, style))))
|
|
649
|
+
omitted = len(entry.diff) - limit
|
|
650
|
+
if omitted > 0:
|
|
651
|
+
lines.append(TextLine((Span(f"{' ' * (CONTENT_INDENT * 2)}{ELLIPSIS} {omitted} more rows", muted),)))
|
|
652
|
+
return lines
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
class WritePresenter(FileChangePresenter[WriteEntry]):
|
|
656
|
+
"""A file that was written whole: every line of it an addition."""
|
|
657
|
+
|
|
658
|
+
entry_type = WriteEntry
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
class EditPresenter(FileChangePresenter[EditEntry]):
|
|
662
|
+
"""A file that was edited: the lines added and removed, and the change."""
|
|
663
|
+
|
|
664
|
+
entry_type = EditEntry
|
|
665
|
+
|
|
666
|
+
def removed(self, entry: EditEntry) -> int:
|
|
667
|
+
"""Return the lines the edits remove."""
|
|
668
|
+
return entry.removed
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
#: Files listed under "Changed N files" before the rest are counted. A request that rewrote forty
|
|
672
|
+
#: files has a summary that is a summary, not the list of them.
|
|
673
|
+
MAX_CHANGED_ROWS = 8
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
class ChangesPresenter(Presenter[ChangesEntry]):
|
|
677
|
+
"""Say which files a request changed: one line for one file, a short list for several.
|
|
678
|
+
|
|
679
|
+
::
|
|
680
|
+
|
|
681
|
+
Changed src/app.py +4 -1
|
|
682
|
+
|
|
683
|
+
Changed 3 files
|
|
684
|
+
src/app.py +4 -1
|
|
685
|
+
src/new.py +40
|
|
686
|
+
old.py deleted
|
|
687
|
+
|
|
688
|
+
The same colours as the tool rows above it, so a count means the same thing in both places.
|
|
689
|
+
"""
|
|
690
|
+
|
|
691
|
+
kind = EntryKind.CHANGES
|
|
692
|
+
|
|
693
|
+
def lines(self, entry: ChangesEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
|
|
694
|
+
"""Return the summary under a blank separator, each row cut at the width."""
|
|
695
|
+
muted = Style(foreground=theme.muted)
|
|
696
|
+
room = max(1, width)
|
|
697
|
+
files = entry.files
|
|
698
|
+
if len(files) == 1:
|
|
699
|
+
return [TextLine(), self._row("Changed ", files[0], theme, muted, room)]
|
|
700
|
+
shown = files[:MAX_CHANGED_ROWS]
|
|
701
|
+
rows = [TextLine(), TextLine((Span(f"Changed {len(files)} files", muted),))]
|
|
702
|
+
rows.extend(self._row(" ", item, theme, muted, room) for item in shown)
|
|
703
|
+
if len(files) > len(shown):
|
|
704
|
+
rows.append(TextLine((Span(f" {ELLIPSIS} and {len(files) - len(shown)} more", muted),)))
|
|
705
|
+
return rows
|
|
706
|
+
|
|
707
|
+
@staticmethod
|
|
708
|
+
def _row(lead: str, item: FileSummary, theme: Theme, muted: Style, room: int) -> TextLine:
|
|
709
|
+
"""One file: its name, then what happened to it."""
|
|
710
|
+
counts = (
|
|
711
|
+
[Span(" deleted", Style(foreground=theme.deletion))]
|
|
712
|
+
if item.deleted
|
|
713
|
+
else _change_counts(item.added, item.removed, theme)
|
|
714
|
+
)
|
|
715
|
+
used = display_width(lead) + sum(display_width(span.text) for span in counts)
|
|
716
|
+
name = truncate(item.path, max(1, room - used))
|
|
717
|
+
return TextLine((Span(lead + name, muted), *counts))
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
class MarkdownPresenter(Presenter[MarkdownEntry]):
|
|
721
|
+
"""Present a Markdown row by serving its parsed document rather than rendering rows.
|
|
722
|
+
|
|
723
|
+
The reply streams in, so rendering it again per token would make a long answer
|
|
724
|
+
cost more with every word. The entry's ``Markdown`` parses incrementally and
|
|
725
|
+
serves its lines on demand; this presenter wraps that once, behind the blank
|
|
726
|
+
row that separates it from the row above, and afterwards only keeps the
|
|
727
|
+
palette current. That is why it overrides :meth:`block` and does not use the
|
|
728
|
+
look-keyed cache: the source is the cache.
|
|
729
|
+
"""
|
|
730
|
+
|
|
731
|
+
def lines(self, entry: MarkdownEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
|
|
732
|
+
"""Return every row of the document at one width, for a caller that wants them all."""
|
|
733
|
+
entry.markdown.theme = theme
|
|
734
|
+
source = LeadingGap(MarkdownSource(entry.markdown))
|
|
735
|
+
return [source.line(index, width) for index in range(source.count(width))]
|
|
736
|
+
|
|
737
|
+
def block(self, entry: MarkdownEntry, width: int, theme: Theme, frame: int, owner: Presenters) -> LineSource:
|
|
738
|
+
"""Keep streamed Markdown virtualized instead of re-rendering it per frame."""
|
|
739
|
+
entry.markdown.theme = theme
|
|
740
|
+
if entry.block is None:
|
|
741
|
+
entry.block = Indented(LeadingGap(MarkdownSource(entry.markdown)))
|
|
742
|
+
return entry.block
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
class AnswerPresenter(MarkdownPresenter):
|
|
746
|
+
"""Present the model's reply as it streams in."""
|
|
747
|
+
|
|
748
|
+
kind = EntryKind.ANSWER
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
class MessagePresenter(MarkdownPresenter):
|
|
752
|
+
"""Present Markdown that is not a reply, such as a command's output."""
|
|
753
|
+
|
|
754
|
+
kind = EntryKind.MESSAGE
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
BUILTIN_PRESENTERS: tuple[Presenter[Any], ...] = (
|
|
758
|
+
WelcomePresenter(),
|
|
759
|
+
UserPresenter(),
|
|
760
|
+
NoticePresenter(),
|
|
761
|
+
AnnouncementPresenter(),
|
|
762
|
+
AnswerPresenter(),
|
|
763
|
+
MessagePresenter(),
|
|
764
|
+
WaitPresenter(),
|
|
765
|
+
ThinkingPresenter(),
|
|
766
|
+
ToolPresenter(),
|
|
767
|
+
ChangesPresenter(),
|
|
768
|
+
RelayPresenter(),
|
|
769
|
+
ReadPresenter(),
|
|
770
|
+
WritePresenter(),
|
|
771
|
+
EditPresenter(),
|
|
772
|
+
SessionMessagePresenter(),
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
#: How a transcript draws entries when nothing has registered a presenter: the built-in ones, at the
|
|
776
|
+
#: lowest priority, which is also what the built-in plugin registers them with for a real run.
|
|
777
|
+
DEFAULT_PRESENTERS = Presenters(BUILTIN_PRESENTERS)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def render_entry(
|
|
781
|
+
entry: Entry, width: int, theme: Theme, frame: int, *, presenters: Presenters = DEFAULT_PRESENTERS
|
|
782
|
+
) -> list[TextLine]:
|
|
783
|
+
"""Render one entry's rows now, through the presenter for its kind."""
|
|
784
|
+
return presenters.lines(entry, width, theme, frame)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def block_for(
|
|
788
|
+
entry: Entry, width: int, theme: Theme, frame: int, *, presenters: Presenters = DEFAULT_PRESENTERS
|
|
789
|
+
) -> LineSource:
|
|
790
|
+
"""Return an entry's rows as a line source, rendering again only when its look changed."""
|
|
791
|
+
return presenters.block(entry, width, theme, frame)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _running_label(text: str, theme: Theme, frame: int) -> tuple[Span, ...]:
|
|
795
|
+
"""Pulse the waiting row: one frame count drives the marker and the highlight.
|
|
796
|
+
|
|
797
|
+
The row moves for a second — the marker lit, the highlight crossing
|
|
798
|
+
the label — and then rests for a second, in which neither moves: the marker is
|
|
799
|
+
the quiet ink's dot so the row keeps its shape without blinking, and the label
|
|
800
|
+
is painted flat. Both halves read the same frame counter, so neither depends
|
|
801
|
+
on how often the terminal repaints, and the marker is in step with the status
|
|
802
|
+
bar's icon. Only the waiting row is drawn with this, which is what makes it
|
|
803
|
+
the one thing that moves.
|
|
804
|
+
"""
|
|
805
|
+
resting = Style(foreground=theme.accent_bright)
|
|
806
|
+
if not moving(frame):
|
|
807
|
+
return (Span(f"{RUNNING_GLYPHS[1]} ", Style(foreground=theme.muted)), Span(text, resting))
|
|
808
|
+
label = SweepSpan(text, resting, peak=Style(foreground=theme.text), ramp=WAIT_RAMP)
|
|
809
|
+
return (Span(f"{RUNNING_GLYPHS[0]} ", resting), *sweep_spans(label, sweep_column(frame, label.travel)))
|