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.
@@ -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)))