zettcode-presenters 0.4.0__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.
- {zettcode_presenters-0.4.0 → zettcode_presenters-0.4.2}/.gitignore +5 -0
- {zettcode_presenters-0.4.0 → zettcode_presenters-0.4.2}/PKG-INFO +2 -1
- {zettcode_presenters-0.4.0 → zettcode_presenters-0.4.2}/pyproject.toml +6 -2
- zettcode_presenters-0.4.2/src/zettcode/presenters/__init__.py +17 -0
- zettcode_presenters-0.4.2/src/zettcode/presenters/labels.py +244 -0
- {zettcode_presenters-0.4.0 → zettcode_presenters-0.4.2}/src/zettcode/presenters/plugin.py +1 -1
- {zettcode_presenters-0.4.0 → zettcode_presenters-0.4.2}/src/zettcode/presenters/presenters.py +158 -25
- zettcode_presenters-0.4.2/src/zettcode/presenters/rows.py +84 -0
- zettcode_presenters-0.4.2/src/zettcode/presenters/statusline.py +91 -0
- zettcode_presenters-0.4.2/src/zettcode/presenters/usage_charts.py +480 -0
- zettcode_presenters-0.4.0/src/zettcode/presenters/__init__.py +0 -17
- {zettcode_presenters-0.4.0 → zettcode_presenters-0.4.2}/src/zettcode/presenters/brand.py +0 -0
- {zettcode_presenters-0.4.0 → zettcode_presenters-0.4.2}/src/zettcode/presenters/pulse.py +0 -0
- {zettcode_presenters-0.4.0 → zettcode_presenters-0.4.2}/src/zettcode/presenters/py.typed +0 -0
- {zettcode_presenters-0.4.0 → zettcode_presenters-0.4.2}/src/zettcode/presenters/text.py +0 -0
|
@@ -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.
|
|
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
|
|
30
|
-
[project.entry-points."zettcode.
|
|
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.
|
|
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
|
"""
|
{zettcode_presenters-0.4.0 → zettcode_presenters-0.4.2}/src/zettcode/presenters/presenters.py
RENAMED
|
@@ -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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
is three things: a member of the enum, an entry
|
|
7
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|