zettcode-presenters 0.4.1__tar.gz → 0.4.2__tar.gz

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.
@@ -35,3 +35,8 @@ docs/internal/palettes.html
35
35
 
36
36
  # The release order is generated from the pyproject.toml files (`make order`); it is not kept in git.
37
37
  packages/order.txt
38
+
39
+ # The desktop and phone apps build the gateway's page into their own folders, and electron-builder writes its
40
+ # installers under release/.
41
+ clients/desktop/renderer/
42
+ clients/desktop/release/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: zettcode-presenters
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: The conversation's rows and how ZettCode's terminal draws them
5
5
  Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
6
6
  Author: Chang-LeHung
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.14
15
15
  Classifier: Programming Language :: Python :: 3.15
16
16
  Classifier: Typing :: Typed
17
17
  Requires-Python: >=3.10
18
+ Requires-Dist: zettcode-engine
18
19
  Requires-Dist: zettcode-plugin-api
19
20
  Requires-Dist: zettcode-tui
20
21
  Provides-Extra: types
@@ -19,6 +19,8 @@ classifiers = [
19
19
  dependencies = [
20
20
  # Written against the plugin contract, which is where the row model and the presenter base class are defined.
21
21
  "zettcode-plugin-api",
22
+ # The `[statusline]` vocabulary (`statusline_parts`) and the TOML reader the boot labels use.
23
+ "zettcode-engine",
22
24
  "zettcode-tui",
23
25
  ]
24
26
 
@@ -26,9 +28,11 @@ dependencies = [
26
28
  # The wording reads `Usage` figures the SDK defines; it is imported for the annotation only.
27
29
  types = ["zettcode-sdk"]
28
30
 
29
- # A part of the program, not a plugin: the `zettcode.plugins` group is what the plugin release line looks for.
30
- [project.entry-points."zettcode.terminal"]
31
+ # A part of the program, not a plugin: the `[tool.zettcode] plugin = true` marker is what the plugin release line looks for.
32
+ [project.entry-points."zettcode.plugin"]
31
33
  presenters = "zettcode.presenters.plugin:PresentersPlugin"
34
+ shell = "zettcode.presenters.rows:ShellRows"
35
+ turn-line = "zettcode.presenters.rows:TurnLine"
32
36
 
33
37
  [project.urls]
34
38
  Repository = "https://github.com/Chang-LeHung/zettcode"
@@ -0,0 +1,17 @@
1
+ """The conversation's rows and how the terminal draws them.
2
+
3
+ `entries` is the data — one class per kind of row a transcript can hold — and `presenters` is how one kind becomes
4
+ lines: a presenter per kind, a presenter per tool name, and the generic `ToolPresenter` that catches any tool no
5
+ name claimed. `brand` is the pixel art the welcome row draws; `labels`, `statusline` and `rows` are the shell's
6
+ header, status and turn-line, published as the `shell`/`turn-line` entry points.
7
+
8
+ The terminal's transcript, the projector that fills it and the replay that rebuilds it stay in `zettcode.app`: this
9
+ distribution is the part of them that a plugin replaces — a front end registers its own presenter through
10
+ `ctx.tui.register_presenter`, and `zettcode-plugin-api` re-exports `Entry` and `Presenter` so it can.
11
+
12
+ The plugin that registers the presenters is `zettcode.presenters.plugin`, published as the `presenters` entry point.
13
+ `shell` and `turn-line` are entry points of this distribution too; all are discovered, and the terminal names the
14
+ ones it cannot run without in `REQUIRED_PLUGINS`.
15
+ """
16
+
17
+ from __future__ import annotations
@@ -0,0 +1,244 @@
1
+ """The text of the built-in header and status rows.
2
+
3
+ One implementation, two callers: the plugin that draws those rows for the
4
+ running shell, and the frame the command line paints before the application
5
+ exists. Sharing the text is what lets the boot frame put the same characters on
6
+ screen, so the swap fills rows in instead of rewriting them.
7
+
8
+ Nothing heavy may be imported here — the boot frame's whole purpose is to draw
9
+ before the application's imports happen.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from zettcode.tui import ARROW_DOWN, ARROW_UP, ELLIPSIS, HEADER, STATUS
17
+ from zettcode.tui.render import display_width
18
+
19
+ from .statusline import TURN_PARTS, Row
20
+ from .text import STOP_HINT
21
+
22
+ #: The keys worth remembering, shown at the right of the status row.
23
+ KEY_HINTS = f"{STOP_HINT} ^D exit"
24
+
25
+ #: The reasoning effort a fresh launch shows until ``/effort`` changes it: the
26
+ #: shell's own default, spelled the way the header spells it. The test that
27
+ #: compares the two frames cell by cell fails if the two ever drift apart.
28
+ DEFAULT_EFFORT = "medium"
29
+
30
+
31
+ def compact_path(path: Path, *, limit: int | None = 38) -> str:
32
+ """Shorten a workspace path for the header.
33
+
34
+ Args:
35
+ path: Absolute path to display.
36
+ limit: Most columns to keep; the home directory collapses to ``~``, and
37
+ anything longer keeps a leading ellipsis plus its tail. ``None``
38
+ keeps the whole path and collapses only the home directory, for a
39
+ line that has the room to show it.
40
+ """
41
+ value = str(path)
42
+ home = str(Path.home())
43
+ if value == home or value.startswith(home + "/"):
44
+ value = "~" + value[len(home) :]
45
+ if limit is None:
46
+ return value
47
+ width = display_width(value)
48
+ if width <= limit:
49
+ return value
50
+ # Count the tail from the end so a wide glyph is dropped whole rather than
51
+ # overhanging the budget, which the ellipsis also has to fit inside.
52
+ budget = limit - 1
53
+ tail = ""
54
+ for character in reversed(value):
55
+ if display_width(character) > budget:
56
+ break
57
+ tail = character + tail
58
+ budget -= display_width(character)
59
+ return f"{ELLIPSIS}{tail}"
60
+
61
+
62
+ #: The most columns of a session's title the header keeps. A title can be a hundred and twenty characters, and the
63
+ #: header is cut from its right end when the row is full: unbounded, a long title would push the session's name
64
+ #: and the workspace off the screen.
65
+ TITLE_COLUMNS = 36
66
+
67
+
68
+ def header_left(workspace: Path, name: str = "", handle: str = "") -> Row:
69
+ """Label the app, the session, and the workspace it runs in.
70
+
71
+ The session comes before the workspace because a row that is too narrow loses its right end first, and which
72
+ session this is matters more than a directory that does not change while it is open.
73
+
74
+ Args:
75
+ workspace: Directory the session runs in.
76
+ name: The session's title, or its placeholder before it has one. Left out, the row names no session, which
77
+ is how a frame drawn before there is one looks.
78
+ handle: The name other sessions address this one by (without the ``@``); empty when messaging is off or the
79
+ session is not registered yet.
80
+ """
81
+ row = Row(lead=" ")
82
+ row.add("app", "The mark and name of the program at the start of the header", f"{HEADER} zettcode").gap()
83
+ row.add("session", "The session's title; New session until it has one", _fit(name, TITLE_COLUMNS) if name else "")
84
+ row.add(
85
+ "handle", "The @name other sessions reach this one by, when messaging is on", f"@{handle}" if handle else ""
86
+ )
87
+ row.add("workspace", "The directory the session works in", compact_path(workspace))
88
+ return row
89
+
90
+
91
+ def _fit(text: str, columns: int) -> str:
92
+ """Return ``text`` cut to ``columns`` with an ellipsis when it is longer; a wide glyph is dropped whole."""
93
+ if display_width(text) <= columns:
94
+ return text
95
+ kept = ""
96
+ for character in text:
97
+ if display_width(kept) + display_width(character) > columns - 1:
98
+ break
99
+ kept += character
100
+ return kept.rstrip() + ELLIPSIS
101
+
102
+
103
+ #: What the header's model segment says before any model is configured. The row is where a reader looks to see
104
+ #: what will answer them, and an empty segment reads as a rendering accident rather than as a machine to set up.
105
+ NO_MODEL = "no model"
106
+
107
+
108
+ def header_right(model: str, effort: str) -> Row:
109
+ """Show the model and the reasoning effort the next request will use.
110
+
111
+ An empty ``model`` — a machine with no ``[[models]]`` entry yet — is named rather than left blank, and the
112
+ effort goes with it: a level applies to a model, and ``no model · medium`` would name one this machine does
113
+ not have. ``/config`` is what fills the row in.
114
+ """
115
+ row = Row(trail=" ")
116
+ row.add("model", "The model the next request goes to; no model configured yet", model or NO_MODEL)
117
+ row.add("effort", "How much the model reasons on the next request", effort if model else "")
118
+ return row
119
+
120
+
121
+ def model_name(config_file: Path) -> str | None:
122
+ """Return the model the header will name, read from the config file alone.
123
+
124
+ The frame the command line paints is drawn before the settings module is
125
+ imported, and the name it will show is in that file: reading the one value
126
+ costs a file read, where importing the settings to ask for it costs their
127
+ whole import graph. The rule is the settings' own — the first ``[[models]]``
128
+ entry's ``display_model``, falling back to its id — but only that rule, so
129
+ anything unreadable answers ``None`` and leaves the header's right side for
130
+ the shell to fill in when it arrives.
131
+
132
+ Args:
133
+ config_file: The file the settings will be read from, which is
134
+ ``$ZETTCODE_CONFIG`` or the conventional path.
135
+ """
136
+ from zettcode._compat import tomllib
137
+
138
+ if not config_file.is_file():
139
+ # Not a file is not a file to open: a FIFO here would block the very frame this answers for, and a
140
+ # directory would raise where the caller only wants the name.
141
+ return None
142
+ try:
143
+ settings = tomllib.loads(config_file.read_text(encoding="utf-8"))
144
+ except (OSError, ValueError):
145
+ return None
146
+ models = settings.get("models")
147
+ if not isinstance(models, list) or not models or not isinstance(models[0], dict):
148
+ return None
149
+ for key in ("display_model", "model"):
150
+ value = models[0].get(key)
151
+ if isinstance(value, str) and value.strip():
152
+ return value
153
+ return None
154
+
155
+
156
+ def hidden_parts(config_file: Path) -> tuple[str, ...]:
157
+ """Return the header and status-line parts the config file switches off, read from the file alone.
158
+
159
+ Like :func:`model_name` this is for the frame drawn before the settings are imported, so it reads the one
160
+ table and trusts nothing: anything unreadable, a name that is no part, or a value that is not ``false``
161
+ answers "nothing hidden" for that entry, and the application reports the mistake when it arrives.
162
+ """
163
+ from zettcode._compat import tomllib
164
+
165
+ from .statusline import NAMES
166
+
167
+ if not config_file.is_file():
168
+ return ()
169
+ try:
170
+ table = tomllib.loads(config_file.read_text(encoding="utf-8")).get("statusline")
171
+ except (OSError, ValueError):
172
+ return ()
173
+ if not isinstance(table, dict):
174
+ return ()
175
+ return tuple(name for name in NAMES if table.get(name) is False)
176
+
177
+
178
+ def status_left(
179
+ label: str,
180
+ *,
181
+ icon: str = STATUS,
182
+ permission: str = "ask",
183
+ self_compact: bool = False,
184
+ tokens: str = "",
185
+ cache: str = "",
186
+ speed: str = "",
187
+ ctx: str = "",
188
+ ) -> Row:
189
+ """Show what is happening, the mode it runs in, and what the session has cost so far.
190
+
191
+ The session is named in the header. Before the first reply the counters are empty and take no room.
192
+
193
+ Args:
194
+ label: The status word, or the one-off note that stands in for it.
195
+ icon: The activity glyph; the still dot unless a request is running.
196
+ permission: The shell permission; ``ask`` is the default and is not shown.
197
+ self_compact: Whether the model may compact its own context in this session.
198
+ tokens: Tokens sent and received, as the figures read.
199
+ cache: The cache share, as it reads.
200
+ speed: The generation speed, as it reads.
201
+ ctx: How full the context window is, as it reads.
202
+ """
203
+ row = Row(lead=" ")
204
+ row.add("state", "The activity icon and what is happening: ready, thinking, a tool running", f"{icon} {label}")
205
+ row.add(
206
+ "mode",
207
+ "How much of the shell may run unasked; shown unless it is ask",
208
+ "" if permission == "ask" else permission,
209
+ )
210
+ row.add(
211
+ "self-compact",
212
+ "Marks a session where the model may compact its own context",
213
+ "self-compact" if self_compact else "",
214
+ )
215
+ row.gap()
216
+ row.add("tokens", "Tokens sent and received in this session", tokens)
217
+ row.add("cache", "The share of input the provider served from its cache", cache)
218
+ row.add("speed", "How fast the model generates, in tokens per second", speed)
219
+ row.add("ctx", "How much of the model's context window the last request filled", ctx)
220
+ return row
221
+
222
+
223
+ def turn_line() -> Row:
224
+ """Show the line a request ends on as the panel previews it: the program's own items on a plausible request."""
225
+ samples = {
226
+ "turn-time": "Processed for 12s",
227
+ "turn-clock": "22:53",
228
+ "turn-tokens": f"{ARROW_UP}12.3k {ARROW_DOWN}1.2k",
229
+ "turn-cache": "87.0% cached",
230
+ "turn-calls": "3 model calls",
231
+ "turn-speed": "62 tok/s",
232
+ "turn-tools": "5 tool calls, 1 failed",
233
+ }
234
+ row = Row(lead=" ")
235
+ for name, summary in TURN_PARTS:
236
+ row.add(name, summary, samples[name])
237
+ return row
238
+
239
+
240
+ def status_right() -> Row:
241
+ """List the keys worth remembering."""
242
+ row = Row(lead=" ", trail=" ")
243
+ row.add("keys", "The keys worth remembering: stop and exit", KEY_HINTS)
244
+ return row
@@ -1,6 +1,6 @@
1
1
  """The plugin that offers the program's own row drawing to the terminal.
2
2
 
3
- It is an ordinary plugin, found through the ``zettcode.plugins`` entry point like any other, and the terminal lists it
3
+ It is an ordinary plugin, found through the ``zettcode.plugin`` entry point like any other, and the terminal lists it
4
4
  as required: the program does not start without it, and ``[plugins] disable`` does not reach it. It needs nothing
5
5
  else, and the plugins that draw more kinds of row depend on it (``deps = ("presenters",)``) so theirs register after.
6
6
  """
@@ -1,10 +1,10 @@
1
1
  """Present entries as styled lines, without mounting a widget per entry.
2
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.
3
+ An entry is data; a *presenter* is what turns a row into lines. The built-in presenters are one per
4
+ :class:`~.entries.EntryKind` and one per tool name, held in a :class:`Presenters` chain: each presenter's
5
+ ``handle`` returns itself to claim a row and passes it to the next presenter otherwise, and ``ToolPresenter`` is the
6
+ fallback for any tool no named presenter took. Adding a kind of row is three things: a member of the enum, an entry
7
+ class, and a presenter that claims it.
8
8
 
9
9
  A presenter also says what its rows depend on (:meth:`Presenter.look`) and whether
10
10
  it draws its own marker in the gutter, because both are facts about the same code
@@ -15,10 +15,11 @@ transcript view then stacks the cached rows into one virtualized line source.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
- from typing import Any, TypeVar
18
+ from typing import Any, ClassVar, TypeVar
19
19
 
20
20
  from zettcode.plugin_api.entries import (
21
21
  ChangesEntry,
22
+ DeleteEntry,
22
23
  EditEntry,
23
24
  Entry,
24
25
  EntryKind,
@@ -36,7 +37,14 @@ from zettcode.plugin_api.entries import (
36
37
  WaitEntry,
37
38
  WriteEntry,
38
39
  )
39
- from zettcode.plugin_api.presenters import CONTENT_INDENT, Indented, LeadingGap, Presenter, Presenters
40
+ from zettcode.plugin_api.presenters import (
41
+ CONTENT_INDENT,
42
+ Indented,
43
+ LeadingGap,
44
+ NextPresenter,
45
+ Presenter,
46
+ Presenters,
47
+ )
40
48
  from zettcode.tui import (
41
49
  DONE,
42
50
  ELLIPSIS,
@@ -156,7 +164,9 @@ def _unindented(spans: list[Span]) -> list[Span]:
156
164
  class WelcomePresenter(Presenter[TextEntry]):
157
165
  """Draw the shared brand pixels unchanged, with themed title and hint text."""
158
166
 
159
- kind = EntryKind.WELCOME
167
+ def handle(self, entry: Entry, next: NextPresenter) -> Presenter[Any]:
168
+ """Claim the welcome banner, and nothing else."""
169
+ return self if entry.kind is EntryKind.WELCOME else next(entry)
160
170
 
161
171
  def lines(self, entry: TextEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
162
172
  """Keep labels beside the mark, stacking them when the window is narrow."""
@@ -193,7 +203,9 @@ class NoticePresenter(Presenter[TextEntry]):
193
203
  failure is what the palette's error colour is for.
194
204
  """
195
205
 
196
- kind = EntryKind.NOTICE
206
+ def handle(self, entry: Entry, next: NextPresenter) -> Presenter[Any]:
207
+ """Claim a notice, and nothing else."""
208
+ return self if entry.kind is EntryKind.NOTICE else next(entry)
197
209
 
198
210
  def lines(self, entry: TextEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
199
211
  """Leave a blank separator and let the transcript gutter add the inset."""
@@ -204,7 +216,9 @@ class NoticePresenter(Presenter[TextEntry]):
204
216
  class AnnouncementPresenter(Presenter[TextEntry]):
205
217
  """Center an announcement — a model or effort change — between rules."""
206
218
 
207
- kind = EntryKind.ANNOUNCEMENT
219
+ def handle(self, entry: Entry, next: NextPresenter) -> Presenter[Any]:
220
+ """Claim an announcement, and nothing else."""
221
+ return self if entry.kind is EntryKind.ANNOUNCEMENT else next(entry)
208
222
 
209
223
  def lines(self, entry: TextEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
210
224
  """Center the label inside the padded content box.
@@ -238,9 +252,12 @@ class AnnouncementPresenter(Presenter[TextEntry]):
238
252
  class UserPresenter(Presenter[TextEntry]):
239
253
  """Paint a full-width message surface with its arrow flush left."""
240
254
 
241
- kind = EntryKind.USER
242
255
  gutter = True
243
256
 
257
+ def handle(self, entry: Entry, next: NextPresenter) -> Presenter[Any]:
258
+ """Claim the reader's message, and nothing else."""
259
+ return self if entry.kind is EntryKind.USER else next(entry)
260
+
244
261
  def lines(self, entry: TextEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
245
262
  """Wrap the message and fill each row to the edge of the surface.
246
263
 
@@ -297,9 +314,12 @@ class RelayPresenter(Presenter[RelayEntry]):
297
314
  and the accent for this reader's own message to it.
298
315
  """
299
316
 
300
- kind = EntryKind.RELAY
301
317
  gutter = True
302
318
 
319
+ def handle(self, entry: Entry, next: NextPresenter) -> Presenter[Any]:
320
+ """Claim a message between sessions, and nothing else."""
321
+ return self if entry.kind is EntryKind.RELAY else next(entry)
322
+
303
323
  def lines(self, entry: RelayEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
304
324
  """Return the header and the wrapped message on the surface, every row filled to the edge."""
305
325
  if entry.outgoing:
@@ -355,9 +375,12 @@ class WaitPresenter(Presenter[WaitEntry]):
355
375
  request that was stopped still says how long it ran and when it stopped.
356
376
  """
357
377
 
358
- kind = EntryKind.WAIT
359
378
  gutter = True
360
379
 
380
+ def handle(self, entry: Entry, next: NextPresenter) -> Presenter[Any]:
381
+ """Claim the wait row, and nothing else."""
382
+ return self if entry.kind is EntryKind.WAIT else next(entry)
383
+
361
384
  def look(self, entry: WaitEntry, frame: int) -> object:
362
385
  """Add the animation step while the row runs; a settled row is a line.
363
386
 
@@ -416,9 +439,12 @@ class WaitPresenter(Presenter[WaitEntry]):
416
439
  class ThinkingPresenter(Presenter[ThinkingEntry]):
417
440
  """Draw reasoning in the "model" hue, apart from the tool rows' green."""
418
441
 
419
- kind = EntryKind.THINKING
420
442
  gutter = True
421
443
 
444
+ def handle(self, entry: Entry, next: NextPresenter) -> Presenter[Any]:
445
+ """Claim a reasoning row, and nothing else."""
446
+ return self if entry.kind is EntryKind.THINKING else next(entry)
447
+
422
448
  def lines(self, entry: ThinkingEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
423
449
  """Show the heading, plus the indented body when expanded."""
424
450
  header = Style(foreground=theme.accent_bright)
@@ -456,9 +482,12 @@ class ToolPresenter(Presenter[T]):
456
482
  them and cannot drift apart.
457
483
  """
458
484
 
459
- kind = EntryKind.TOOL
460
485
  gutter = True
461
486
 
487
+ def handle(self, entry: Entry, next: NextPresenter) -> Presenter[Any]:
488
+ """Claim any tool row that no more specific presenter took first."""
489
+ return self if entry.kind is EntryKind.TOOL else next(entry)
490
+
462
491
  def lines(self, entry: T, width: int, theme: Theme, frame: int) -> list[TextLine]:
463
492
  """Show the call, elapsed time, and whatever body the tool's entry has."""
464
493
  running = entry.running
@@ -529,7 +558,27 @@ class ToolPresenter(Presenter[T]):
529
558
  return lines
530
559
 
531
560
 
532
- class SessionMessagePresenter(ToolPresenter[MessageEntry]):
561
+ class NamedToolPresenter(ToolPresenter[T]):
562
+ """A tool presenter that claims the tools it names, and leaves every other tool to the next presenter."""
563
+
564
+ names: ClassVar[frozenset[str]] = frozenset()
565
+ requires: ClassVar[type[ToolEntry] | None] = None
566
+
567
+ def handle(self, entry: Entry, next: NextPresenter) -> Presenter[Any]:
568
+ """Claim a tool row by the tool's own name; any other row goes on.
569
+
570
+ A presenter that reads fields only its family's entry carries names that class in :attr:`requires`, so a
571
+ plain entry for the same tool — one a handler opened without its family class — is left to the fallback
572
+ instead of failing on a field it does not have.
573
+ """
574
+ if not isinstance(entry, ToolEntry) or entry.tool not in self.names:
575
+ return next(entry)
576
+ if self.requires is not None and not isinstance(entry, self.requires):
577
+ return next(entry)
578
+ return self
579
+
580
+
581
+ class SessionMessagePresenter(NamedToolPresenter[MessageEntry]):
533
582
  """A message the agent sent to another session: what it said, then how it ended.
534
583
 
535
584
  Folded, the row shows the first lines of the message and the one-line outcome; expanded, every
@@ -538,7 +587,8 @@ class SessionMessagePresenter(ToolPresenter[MessageEntry]):
538
587
  result — muted when it went, the error colour when it did not.
539
588
  """
540
589
 
541
- entry_type = MessageEntry
590
+ names = frozenset({"send_message"})
591
+ requires = MessageEntry
542
592
 
543
593
  def body_lines(self, entry: MessageEntry, width: int, theme: Theme, muted: Style) -> list[TextLine]:
544
594
  """Return the message, bounded as any tool output is, and the outcome under it."""
@@ -567,10 +617,11 @@ class SessionMessagePresenter(ToolPresenter[MessageEntry]):
567
617
  return lines
568
618
 
569
619
 
570
- class ReadPresenter(ToolPresenter[ReadEntry]):
620
+ class ReadPresenter(NamedToolPresenter[ReadEntry]):
571
621
  """A file that was read: the body is highlighted as the language the file is in, and the row says how much."""
572
622
 
573
- entry_type = ReadEntry
623
+ names = frozenset({"read_file", "read"})
624
+ requires = ReadEntry
574
625
 
575
626
  def after_title(self, entry: ReadEntry, theme: Theme) -> list[Span]:
576
627
  """Say how many lines came back, beside the title rather than inside it.
@@ -598,7 +649,7 @@ def _change_counts(added: int, removed: int, theme: Theme) -> list[Span]:
598
649
  return spans
599
650
 
600
651
 
601
- class FileChangePresenter(ToolPresenter[F]):
652
+ class FileChangePresenter(NamedToolPresenter[F]):
602
653
  """A tool that changed a file: the lines it changed beside the name, and the change under it.
603
654
 
604
655
  What a write and an edit share. They differ only in whether anything is taken away
@@ -655,19 +706,81 @@ class FileChangePresenter(ToolPresenter[F]):
655
706
  class WritePresenter(FileChangePresenter[WriteEntry]):
656
707
  """A file that was written whole: every line of it an addition."""
657
708
 
658
- entry_type = WriteEntry
709
+ names = frozenset({"write_file"})
710
+ requires = WriteEntry
659
711
 
660
712
 
661
713
  class EditPresenter(FileChangePresenter[EditEntry]):
662
714
  """A file that was edited: the lines added and removed, and the change."""
663
715
 
664
- entry_type = EditEntry
716
+ names = frozenset({"edit", "replace_in_file"})
717
+ requires = EditEntry
665
718
 
666
719
  def removed(self, entry: EditEntry) -> int:
667
720
  """Return the lines the edits remove."""
668
721
  return entry.removed
669
722
 
670
723
 
724
+ class DeletePresenter(NamedToolPresenter[DeleteEntry]):
725
+ """A file that was deleted: the tool row names the file, and the deletion is the result."""
726
+
727
+ names = frozenset({"delete_file"})
728
+
729
+
730
+ class ShellPresenter(NamedToolPresenter[ToolEntry]):
731
+ """A shell command: the command is the title, and its output is the body."""
732
+
733
+ names = frozenset({"bash"})
734
+
735
+
736
+ class SearchPresenter(NamedToolPresenter[ToolEntry]):
737
+ """A listing or a content search: the pattern and where it looked are the title."""
738
+
739
+ names = frozenset({"glob", "grep"})
740
+
741
+
742
+ class WebSearchPresenter(NamedToolPresenter[ToolEntry]):
743
+ """A web search: the endpoint ran it, so the query is the whole of the row's title."""
744
+
745
+ names = frozenset({"web_search"})
746
+
747
+
748
+ class AskUserPresenter(NamedToolPresenter[ToolEntry]):
749
+ """A question put to the reader: the body is what they did, not the shell's payload."""
750
+
751
+ names = frozenset({"ask_user"})
752
+
753
+
754
+ class TodoPresenter(NamedToolPresenter[ToolEntry]):
755
+ """A plan update: the number of steps is the story."""
756
+
757
+ names = frozenset({"todo_write"})
758
+
759
+
760
+ class SkillPresenter(NamedToolPresenter[ToolEntry]):
761
+ """A skill whose instructions were read."""
762
+
763
+ names = frozenset({"read_skill"})
764
+
765
+
766
+ class TaskPresenter(NamedToolPresenter[ToolEntry]):
767
+ """Work handed to a subagent: the profile names the row, and the report is the body."""
768
+
769
+ names = frozenset({"task"})
770
+
771
+
772
+ class HistoryPresenter(NamedToolPresenter[ToolEntry]):
773
+ """A read of what was said before, with a session or in a group."""
774
+
775
+ names = frozenset({"message_history"})
776
+
777
+
778
+ class GroupPresenter(NamedToolPresenter[ToolEntry]):
779
+ """A change to a group chat: what was done, to which group, and with whom."""
780
+
781
+ names = frozenset({"manage_group"})
782
+
783
+
671
784
  #: Files listed under "Changed N files" before the rest are counted. A request that rewrote forty
672
785
  #: files has a summary that is a summary, not the list of them.
673
786
  MAX_CHANGED_ROWS = 8
@@ -688,7 +801,9 @@ class ChangesPresenter(Presenter[ChangesEntry]):
688
801
  The same colours as the tool rows above it, so a count means the same thing in both places.
689
802
  """
690
803
 
691
- kind = EntryKind.CHANGES
804
+ def handle(self, entry: Entry, next: NextPresenter) -> Presenter[Any]:
805
+ """Claim the changed-files summary, and nothing else."""
806
+ return self if entry.kind is EntryKind.CHANGES else next(entry)
692
807
 
693
808
  def lines(self, entry: ChangesEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
694
809
  """Return the summary under a blank separator, each row cut at the width."""
@@ -728,6 +843,10 @@ class MarkdownPresenter(Presenter[MarkdownEntry]):
728
843
  look-keyed cache: the source is the cache.
729
844
  """
730
845
 
846
+ def handle(self, entry: Entry, next: NextPresenter) -> Presenter[Any]:
847
+ """Claim a Markdown row that a more specific answer or message presenter did not take first."""
848
+ return self if isinstance(entry, MarkdownEntry) else next(entry)
849
+
731
850
  def lines(self, entry: MarkdownEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
732
851
  """Return every row of the document at one width, for a caller that wants them all."""
733
852
  entry.markdown.theme = theme
@@ -745,13 +864,17 @@ class MarkdownPresenter(Presenter[MarkdownEntry]):
745
864
  class AnswerPresenter(MarkdownPresenter):
746
865
  """Present the model's reply as it streams in."""
747
866
 
748
- kind = EntryKind.ANSWER
867
+ def handle(self, entry: Entry, next: NextPresenter) -> Presenter[Any]:
868
+ """Claim the model's reply, and nothing else."""
869
+ return self if entry.kind is EntryKind.ANSWER else next(entry)
749
870
 
750
871
 
751
872
  class MessagePresenter(MarkdownPresenter):
752
873
  """Present Markdown that is not a reply, such as a command's output."""
753
874
 
754
- kind = EntryKind.MESSAGE
875
+ def handle(self, entry: Entry, next: NextPresenter) -> Presenter[Any]:
876
+ """Claim Markdown that is not a reply, and nothing else."""
877
+ return self if entry.kind is EntryKind.MESSAGE else next(entry)
755
878
 
756
879
 
757
880
  BUILTIN_PRESENTERS: tuple[Presenter[Any], ...] = (
@@ -770,6 +893,16 @@ BUILTIN_PRESENTERS: tuple[Presenter[Any], ...] = (
770
893
  WritePresenter(),
771
894
  EditPresenter(),
772
895
  SessionMessagePresenter(),
896
+ DeletePresenter(),
897
+ ShellPresenter(),
898
+ SearchPresenter(),
899
+ WebSearchPresenter(),
900
+ AskUserPresenter(),
901
+ TodoPresenter(),
902
+ SkillPresenter(),
903
+ TaskPresenter(),
904
+ HistoryPresenter(),
905
+ GroupPresenter(),
773
906
  )
774
907
 
775
908
  #: How a transcript draws entries when nothing has registered a presenter: the built-in ones, at the