zettcode-presenters 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zettcode/presenters/__init__.py +17 -0
- zettcode/presenters/brand.py +43 -0
- zettcode/presenters/plugin.py +35 -0
- zettcode/presenters/presenters.py +809 -0
- zettcode/presenters/pulse.py +116 -0
- zettcode/presenters/py.typed +0 -0
- zettcode/presenters/text.py +304 -0
- zettcode_presenters-0.4.0.dist-info/METADATA +21 -0
- zettcode_presenters-0.4.0.dist-info/RECORD +11 -0
- zettcode_presenters-0.4.0.dist-info/WHEEL +4 -0
- zettcode_presenters-0.4.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""How the waiting row and the status bar's icon move: the pulse, the sweep, and the blink.
|
|
2
|
+
|
|
3
|
+
Everything here is a function of one monotonic frame counter the application keeps, counted in animation
|
|
4
|
+
steps rather than terminal ticks, so the pace of the sweep and of the running marker does not depend on how
|
|
5
|
+
often the terminal happens to repaint: a period is a number of seconds, and a step is a share of it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from zettcode.tui import RUNNING
|
|
11
|
+
|
|
12
|
+
#: Animation steps per second: the rate the sweep, the running marker and the
|
|
13
|
+
#: timers advance at, matching the frame loop's own ceiling. It used to be ten a
|
|
14
|
+
#: second, which is a step the eye can see — the sweep moved in jumps — while the
|
|
15
|
+
#: loop was repainting a hundred and twenty times a second to show the same
|
|
16
|
+
#: picture twelve times over.
|
|
17
|
+
ANIMATION_FPS = 120
|
|
18
|
+
|
|
19
|
+
#: Wall-clock length of one animation step. ``Transcript.frame`` counts these
|
|
20
|
+
#: steps rather than terminal ticks, so the pace of the sweep and the running
|
|
21
|
+
#: marker does not depend on how often the terminal happens to repaint: a period
|
|
22
|
+
#: is a number of seconds, and a step is a share of it.
|
|
23
|
+
ANIMATION_SECONDS = 1 / ANIMATION_FPS
|
|
24
|
+
|
|
25
|
+
#: The running marker's two states: the sparkle, then a dot of the same width so
|
|
26
|
+
#: the label after it never shifts as the row pulses.
|
|
27
|
+
RUNNING_GLYPHS = RUNNING
|
|
28
|
+
|
|
29
|
+
#: Seconds one pulse of the waiting row takes: it moves for a second, then rests
|
|
30
|
+
#: for a second before it moves again. The marker and the highlight share the
|
|
31
|
+
#: period, so the row reads as one thing breathing instead of two effects on
|
|
32
|
+
#: clocks of their own. The rest is deliberate: a row that never stops reads as
|
|
33
|
+
#: busy-work, and the pause is what makes the sweep look like an event.
|
|
34
|
+
MOVE_SECONDS = 1.0
|
|
35
|
+
REST_SECONDS = 1.0
|
|
36
|
+
|
|
37
|
+
#: Share of a pulse spent moving, and the frames one takes, counted in animation
|
|
38
|
+
#: steps: the pace is a period rather than a speed, so a long wait row is crossed
|
|
39
|
+
#: in the same time as a short one and the pulse does not depend on how often the
|
|
40
|
+
#: terminal happens to repaint. The share is what the demo's pace keys scale, so
|
|
41
|
+
#: changing the period keeps the two phases in proportion.
|
|
42
|
+
MOVE_SHARE = MOVE_SECONDS / (MOVE_SECONDS + REST_SECONDS)
|
|
43
|
+
PULSE_FRAMES = round((MOVE_SECONDS + REST_SECONDS) / ANIMATION_SECONDS)
|
|
44
|
+
|
|
45
|
+
#: Seconds one blink of the status bar's icon takes. The icon is a small thing on
|
|
46
|
+
#: a line of its own, so it turns over on a period of its own — no rest to sit
|
|
47
|
+
#: through, unlike the waiting row it reports on.
|
|
48
|
+
STATUS_BLINK_SECONDS = 1.5
|
|
49
|
+
STATUS_BLINK_FRAMES = round(STATUS_BLINK_SECONDS / ANIMATION_SECONDS)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def move_frames() -> int:
|
|
53
|
+
"""Return the frames of one pulse the waiting row spends moving."""
|
|
54
|
+
return max(1, round(PULSE_FRAMES * MOVE_SHARE))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def moving(frame: int) -> bool:
|
|
58
|
+
"""Say whether the waiting row is in the moving part of its pulse."""
|
|
59
|
+
return frame % PULSE_FRAMES < move_frames()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def sweep_column(frame: int, travel: int) -> int:
|
|
63
|
+
"""Return the column the highlight has reached at one animation frame.
|
|
64
|
+
|
|
65
|
+
One pass takes the moving part of the pulse, so the highlight arrives at the
|
|
66
|
+
end of the label as the row rests and sets off again when it moves.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
frame: Monotonic frame counter shared by the whole application.
|
|
70
|
+
travel: Columns one whole pass takes, ends included —
|
|
71
|
+
:attr:`~zettcode.tui.render.sweep.SweepSpan.travel`.
|
|
72
|
+
"""
|
|
73
|
+
return frame % PULSE_FRAMES * max(1, travel) // move_frames()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
#: Columns of fade on each side of the waiting row's highlight. The presenter draws with it and
|
|
77
|
+
#: :func:`wait_picture` counts with it, so the two cannot disagree about how far a pass travels.
|
|
78
|
+
WAIT_RAMP = 3
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def wait_picture(frame: int, title_width: int) -> tuple[bool, int]:
|
|
82
|
+
"""Return what the waiting row looks like at one frame, as a value.
|
|
83
|
+
|
|
84
|
+
Two frames give equal values exactly when the row is drawn the same way at both: the
|
|
85
|
+
highlight sits on a whole column, and its colours depend on nothing else, so this is
|
|
86
|
+
that column (``0`` while the row rests, which is drawn flat whatever the frame is).
|
|
87
|
+
The loop uses it to tell a frame that changes the screen from one that only repeats it.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
frame: Monotonic frame counter shared by the whole application.
|
|
91
|
+
title_width: Display width of the word the highlight crosses.
|
|
92
|
+
"""
|
|
93
|
+
if not moving(frame):
|
|
94
|
+
return (False, 0)
|
|
95
|
+
return (True, sweep_column(frame, title_width + 2 * WAIT_RAMP))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def blink_state(frame: int) -> bool:
|
|
99
|
+
"""Say which of its two states the status bar's icon is in at one frame."""
|
|
100
|
+
return frame % STATUS_BLINK_FRAMES < STATUS_BLINK_FRAMES // 2
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def activity_glyph(frame: int) -> str:
|
|
104
|
+
"""Return the marker the status bar shows while work is in progress.
|
|
105
|
+
|
|
106
|
+
The marker keeps one column and holds each of its two states instead of
|
|
107
|
+
cycling through different symbols, which is what makes it read as an icon
|
|
108
|
+
rather than as a spinner. It alternates on a period of its own, with no rest
|
|
109
|
+
between blinks, because a small icon can turn over faster than the row it
|
|
110
|
+
reports on; both read the same frame counter, so neither depends on how often
|
|
111
|
+
the terminal repaints.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
frame: Monotonic frame counter shared by the whole application.
|
|
115
|
+
"""
|
|
116
|
+
return RUNNING_GLYPHS[0 if blink_state(frame) else 1]
|
|
File without changes
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""The words and bounds of a transcript row: its margin, its labels, how long output is allowed to be.
|
|
2
|
+
|
|
3
|
+
What a row says and how much of it is kept live here rather than with the conversation model: the left margin,
|
|
4
|
+
the bounding of tool output, the text of an elapsed time, the wording of the waiting row's remarks, and the
|
|
5
|
+
path the header shortens. The model imports these to build entries and the renderer imports them to paint, and
|
|
6
|
+
this module imports neither — which is what keeps the two apart. How a row *moves* is :mod:`.pulse`, and the
|
|
7
|
+
two animations of the effort setting are :mod:`.effort_gauge` and :mod:`.shimmer`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from zettcode.tui import ELLIPSIS
|
|
18
|
+
from zettcode.tui.glyphs import ARROW_DOWN, ARROW_UP, SEPARATOR
|
|
19
|
+
from zettcode.tui.render import count_wrapped_rows, display_width, truncate, wrap_columns
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only; the wording reads figures, it does not need the SDK
|
|
22
|
+
from zettcode.sdk.types import Usage
|
|
23
|
+
|
|
24
|
+
#: Left margin of every transcript row: the width of the composer's ``\u203a ``
|
|
25
|
+
#: prompt, so text lines up under what the user types and never starts left of
|
|
26
|
+
#: the caret in an empty composer.
|
|
27
|
+
|
|
28
|
+
#: Label of the row that shows a reasoning span while it streams.
|
|
29
|
+
|
|
30
|
+
#: Label of the row that shows the summarizer working: the same kind of row, so
|
|
31
|
+
#: it carries the same marker and timer as reasoning does.
|
|
32
|
+
COMPACTING_LABEL = "Compacting"
|
|
33
|
+
|
|
34
|
+
MAX_TOOL_OUTPUT = 64_000
|
|
35
|
+
TOOL_PREVIEW_ROWS = 5
|
|
36
|
+
TOOL_EXPANDED_ROWS = 40
|
|
37
|
+
|
|
38
|
+
#: Label of a row shown while the model is working and has not answered yet.
|
|
39
|
+
#: One request can show it more than once: every tool batch is followed by
|
|
40
|
+
#: another model call, and until that call produces something there is nothing
|
|
41
|
+
#: else on screen to say work is still going on.
|
|
42
|
+
PROCESSING = "Processing"
|
|
43
|
+
|
|
44
|
+
#: What the reader can press to stop the request, shown beside its clock. The
|
|
45
|
+
#: status row builds its own hint from this, so the two cannot drift apart, and
|
|
46
|
+
#: both keys are named because both work: Escape stops a turn while one is in
|
|
47
|
+
#: flight, and a reader should not have to remember which of the two the shell
|
|
48
|
+
#: reserved for it.
|
|
49
|
+
STOP_HINT = "^C/Esc to interrupt"
|
|
50
|
+
|
|
51
|
+
#: What the row of a question the reader cancelled says under its title.
|
|
52
|
+
DECLINED_NOTE = "You declined; the model was told and carries on."
|
|
53
|
+
|
|
54
|
+
# Matches both escape families a tool can smuggle into its output: CSI
|
|
55
|
+
# (``ESC [`` parameters and a final byte) and OSC (``ESC ]`` up to BEL or ST).
|
|
56
|
+
# Stripping them keeps a coloured compiler message from repainting the canvas.
|
|
57
|
+
_ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def terminal_safe(value: str) -> str:
|
|
61
|
+
"""Strip escape sequences and replace characters the canvas cannot paint.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
value: Raw tool output; newlines survive, a tab advances to the next
|
|
65
|
+
four-column stop, and anything else non-printable becomes the
|
|
66
|
+
replacement character so a tool cannot smuggle control codes into
|
|
67
|
+
the canvas.
|
|
68
|
+
|
|
69
|
+
Line endings are normalised first. Output printed with CRLF — a tool that
|
|
70
|
+
shells out to ``curl -i`` or to a Windows program — would otherwise leave a
|
|
71
|
+
replacement glyph at the end of every line, because a carriage return stops
|
|
72
|
+
being a line break the moment it is replaced.
|
|
73
|
+
"""
|
|
74
|
+
cleaned = _ANSI.sub("", value).replace("\r\n", "\n").replace("\r", "\n").expandtabs(4)
|
|
75
|
+
if cleaned.replace("\n", "").isprintable():
|
|
76
|
+
# Nothing to replace, which is the usual output: say so in one pass instead
|
|
77
|
+
# of looking at every character of what may be tens of kilobytes.
|
|
78
|
+
return cleaned
|
|
79
|
+
return "".join(
|
|
80
|
+
character if character in "\n" else character if character.isprintable() else "\ufffd" for character in cleaned
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def bounded_rows(value: str, width: int, limit: int) -> tuple[list[str], int]:
|
|
85
|
+
"""Wrap tool output and return a bounded viewport plus omitted rows.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
value: Raw tool output.
|
|
89
|
+
width: Wrap width in cells.
|
|
90
|
+
limit: Most rows to return; the remainder is reported as omitted.
|
|
91
|
+
"""
|
|
92
|
+
rows: list[str] = []
|
|
93
|
+
hidden = 0
|
|
94
|
+
for source in terminal_safe(value).splitlines() or [""]:
|
|
95
|
+
if len(rows) < limit:
|
|
96
|
+
rows.extend(wrap_columns(source, width))
|
|
97
|
+
else:
|
|
98
|
+
# Past the rows that are shown only the count is wanted, and counting
|
|
99
|
+
# a line's rows does not need the rows: a 900-line test log is wrapped
|
|
100
|
+
# for five of them.
|
|
101
|
+
hidden += count_wrapped_rows(source, width)
|
|
102
|
+
omitted = max(0, len(rows) - limit) + hidden
|
|
103
|
+
return rows[:limit], omitted
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def limit_output(value: str) -> str:
|
|
107
|
+
"""Bound stored tool output while keeping both ends useful.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
value: Raw tool output; anything past ``MAX_TOOL_OUTPUT`` keeps its head
|
|
111
|
+
and tail with a marker in between, so a huge result cannot dominate
|
|
112
|
+
memory while still showing how it started and ended.
|
|
113
|
+
"""
|
|
114
|
+
if len(value) <= MAX_TOOL_OUTPUT:
|
|
115
|
+
return value
|
|
116
|
+
half = MAX_TOOL_OUTPUT // 2
|
|
117
|
+
omitted = len(value) - half * 2
|
|
118
|
+
return f"{value[:half]}\n{ELLIPSIS} {omitted:,} characters omitted {ELLIPSIS}\n{value[-half:]}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def duration_text(seconds: float | None) -> str:
|
|
122
|
+
"""Format an elapsed time for a row header, in the unit a reader wants.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
seconds: Elapsed time, or ``None`` for a row that has already finished
|
|
126
|
+
without a recorded duration.
|
|
127
|
+
|
|
128
|
+
Under a minute the time is shown to the tenth of a second, because that is
|
|
129
|
+
what makes a live row look alive and milliseconds would change the column's
|
|
130
|
+
width on every repaint. Past a minute the tenth is noise — ``134.5 s`` is a
|
|
131
|
+
number a reader has to divide before it means anything — so the row switches
|
|
132
|
+
to minutes and seconds, and to hours and minutes past an hour. That column
|
|
133
|
+
changes width as the units change: it narrows at each boundary — ``1m 0s``
|
|
134
|
+
is a column narrower than ``59.9 s``, ``1h 0m`` two narrower than
|
|
135
|
+
``59m 59s`` — and widens as the count climbs inside one, so a running tool
|
|
136
|
+
row's title is truncated again whenever it does. A whole request's wall time
|
|
137
|
+
is a different question, so it goes through :func:`elapsed_text` instead.
|
|
138
|
+
"""
|
|
139
|
+
if seconds is None:
|
|
140
|
+
return "done"
|
|
141
|
+
total = max(0.0, seconds)
|
|
142
|
+
if round(total, 1) < 60:
|
|
143
|
+
return f"{total:.1f} s"
|
|
144
|
+
minutes, remainder = divmod(round(total), 60)
|
|
145
|
+
if minutes < 60:
|
|
146
|
+
return f"{minutes}m {remainder}s"
|
|
147
|
+
hours, minutes = divmod(minutes, 60)
|
|
148
|
+
return f"{hours}h {minutes}m"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
#: Seconds the model may say nothing before the waiting row says so. A first token after ten seconds is a
|
|
152
|
+
#: slow model and after twenty is something a reader is already wondering about; a reasoning model that
|
|
153
|
+
#: streams nothing for longer is also why this only *says* it and never gives up on the request.
|
|
154
|
+
QUIET_SECONDS = 20.0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
#: Seconds a request has to run before a tip is put under its waiting row: one that is answered at once is not
|
|
158
|
+
#: worth a line, and a tip that flashes by is not read.
|
|
159
|
+
#: Whole seconds, like :data:`TIP_EVERY`: the waiting row's clock counts whole seconds and the picture the loop compares
|
|
160
|
+
#: holds that count, so a tip appears and changes on a second the loop already draws.
|
|
161
|
+
TIP_AFTER = 5.0
|
|
162
|
+
|
|
163
|
+
#: Seconds each tip stays before the next takes its place, long enough to read and not so long that a request of
|
|
164
|
+
#: several minutes shows one.
|
|
165
|
+
TIP_EVERY = 15.0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def tip_text(tips: tuple[str, ...], seconds: float | None) -> str | None:
|
|
169
|
+
"""Return the tip a request that has run for ``seconds`` shows, or ``None`` while it is too soon or there are none."""
|
|
170
|
+
if not tips or seconds is None or seconds < TIP_AFTER:
|
|
171
|
+
return None
|
|
172
|
+
return tips[int((seconds - TIP_AFTER) // TIP_EVERY) % len(tips)]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def quiet_text(seconds: float) -> str | None:
|
|
176
|
+
"""Return what the waiting row says about a silence of ``seconds``, or ``None`` while it is too short to mention."""
|
|
177
|
+
if seconds < QUIET_SECONDS:
|
|
178
|
+
return None
|
|
179
|
+
return f"nothing from the model for {elapsed_text(seconds)}"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def elapsed_text(seconds: float) -> str:
|
|
183
|
+
"""Show every nonzero time unit in a finished request's wall time.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
seconds: Wall time of the request, negative values clamped to zero.
|
|
187
|
+
"""
|
|
188
|
+
total = max(0, round(seconds))
|
|
189
|
+
hours, remainder = divmod(total, 3600)
|
|
190
|
+
minutes, seconds = divmod(remainder, 60)
|
|
191
|
+
if hours:
|
|
192
|
+
return f"{hours}h {minutes}m {seconds}s"
|
|
193
|
+
if minutes:
|
|
194
|
+
return f"{minutes}m {seconds}s"
|
|
195
|
+
return f"{seconds}s"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def clock_text(moment: datetime | None = None) -> str:
|
|
199
|
+
"""Format a wall-clock reading as local ``HH:MM``.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
moment: Time to show; ``None`` reads the clock, and tests inject a value
|
|
203
|
+
so a rendered footer stays deterministic.
|
|
204
|
+
"""
|
|
205
|
+
return (moment or datetime.now()).strftime("%H:%M")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def pack_segments(segments: list[str], room: int) -> list[str]:
|
|
209
|
+
"""Join segments with `` · `` into rows no wider than ``room``, breaking between segments and never inside one.
|
|
210
|
+
|
|
211
|
+
A single segment wider than the room gets a row of its own and is cut to fit, so no row is ever too wide.
|
|
212
|
+
"""
|
|
213
|
+
joint = f" {SEPARATOR} "
|
|
214
|
+
rows: list[str] = []
|
|
215
|
+
current = ""
|
|
216
|
+
for segment in segments:
|
|
217
|
+
if current and display_width(current) + display_width(joint) + display_width(segment) <= room:
|
|
218
|
+
current += joint + segment
|
|
219
|
+
continue
|
|
220
|
+
if current:
|
|
221
|
+
rows.append(current)
|
|
222
|
+
current = segment
|
|
223
|
+
if current:
|
|
224
|
+
rows.append(current)
|
|
225
|
+
return [row if display_width(row) <= room else truncate(row, room, ellipsis=ELLIPSIS) for row in rows]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def tokens_text(usage: Usage) -> str | None:
|
|
229
|
+
"""Say the tokens a request sent and received, or nothing before the model has answered."""
|
|
230
|
+
if not usage.requests:
|
|
231
|
+
return None
|
|
232
|
+
return f"{ARROW_UP}{compact_tokens(usage.input_tokens)} {ARROW_DOWN}{compact_tokens(usage.output_tokens)}"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def cache_text(usage: Usage) -> str | None:
|
|
236
|
+
"""Say the share of the input the provider served from its cache, or nothing when it served none."""
|
|
237
|
+
if usage.requests and usage.cache_read_tokens > 0 and usage.input_tokens > 0:
|
|
238
|
+
return f"{usage.cache_read_tokens / usage.input_tokens * 100:.1f}% cached"
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def calls_text(usage: Usage) -> str | None:
|
|
243
|
+
"""Say how many times the model was asked, when it was asked more than once."""
|
|
244
|
+
return f"{usage.requests} model calls" if usage.requests > 1 else None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def speed_text(usage: Usage) -> str | None:
|
|
248
|
+
"""Say how fast the model generated, when it can be told and is at least one token a second.
|
|
249
|
+
|
|
250
|
+
Reads :attr:`Usage.output_rate` — the provider's whole count over the time from the first token to the end of the
|
|
251
|
+
reply — so this line and the status line a reader sees above it never disagree.
|
|
252
|
+
"""
|
|
253
|
+
rate = usage.output_rate
|
|
254
|
+
if usage.requests and rate is not None and rate >= 1:
|
|
255
|
+
return f"{rate:.1f} tok/s" if rate < 10 else f"{rate:.0f} tok/s"
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def tools_text(tools: int, failed_tools: int = 0) -> str | None:
|
|
260
|
+
"""Say how many tool calls a request made and how many failed; the failures stay in the same segment."""
|
|
261
|
+
if not tools:
|
|
262
|
+
return None
|
|
263
|
+
said = f"{tools} tool call{'' if tools == 1 else 's'}"
|
|
264
|
+
return f"{said}, {failed_tools} failed" if failed_tools else said
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def compact_path(path: Path, *, limit: int | None = 38) -> str:
|
|
268
|
+
"""Shorten a workspace path for the header.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
path: Absolute path to display.
|
|
272
|
+
limit: Most columns to keep; the home directory collapses to ``~``, and
|
|
273
|
+
anything longer keeps a leading ellipsis plus its tail. ``None``
|
|
274
|
+
keeps the whole path and collapses only the home directory, for a
|
|
275
|
+
line that has the room to show it.
|
|
276
|
+
"""
|
|
277
|
+
value = str(path)
|
|
278
|
+
home = str(Path.home())
|
|
279
|
+
if value == home or value.startswith(home + "/"):
|
|
280
|
+
value = "~" + value[len(home) :]
|
|
281
|
+
if limit is None:
|
|
282
|
+
return value
|
|
283
|
+
width = display_width(value)
|
|
284
|
+
if width <= limit:
|
|
285
|
+
return value
|
|
286
|
+
# Count the tail from the end so a wide glyph is dropped whole rather than
|
|
287
|
+
# overhanging the budget, which the ellipsis also has to fit inside.
|
|
288
|
+
budget = limit - 1
|
|
289
|
+
tail = ""
|
|
290
|
+
for character in reversed(value):
|
|
291
|
+
if display_width(character) > budget:
|
|
292
|
+
break
|
|
293
|
+
tail = character + tail
|
|
294
|
+
budget -= display_width(character)
|
|
295
|
+
return ELLIPSIS + tail
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def compact_tokens(count: int) -> str:
|
|
299
|
+
"""Shorten a token count for the status line: ``912``, ``12.3k``, ``1.2M``."""
|
|
300
|
+
if count < 1_000:
|
|
301
|
+
return str(count)
|
|
302
|
+
if count < 999_950:
|
|
303
|
+
return f"{count / 1_000:.1f}k"
|
|
304
|
+
return f"{count / 1_000_000:.1f}M"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: zettcode-presenters
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: The conversation's rows and how ZettCode's terminal draws them
|
|
5
|
+
Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
|
|
6
|
+
Author: Chang-LeHung
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: presenters,transcript,zettcode
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: zettcode-plugin-api
|
|
19
|
+
Requires-Dist: zettcode-tui
|
|
20
|
+
Provides-Extra: types
|
|
21
|
+
Requires-Dist: zettcode-sdk; extra == 'types'
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
zettcode/presenters/__init__.py,sha256=CVzMjwmPMFoBmIyLwvOnrdsfJv9Gtpt4ONm83O_MMAY,1115
|
|
2
|
+
zettcode/presenters/brand.py,sha256=Ebq97gOYCHXBWqTBii-iPmj0b53238WxRbjPtOWnol0,1256
|
|
3
|
+
zettcode/presenters/plugin.py,sha256=2Z7hZwaqi0wWJGwMxej_TpC34BxJV_TZPVbjI2ZLf2I,1529
|
|
4
|
+
zettcode/presenters/presenters.py,sha256=1bB832-GPi5MEtc-GISG3OSKBHnvqrIsgTIKv105XCw,35996
|
|
5
|
+
zettcode/presenters/pulse.py,sha256=62HnCyaw3CEIC1TUaJ8RcuudNpYI-BAkdrWI2Rhe6Qg,5337
|
|
6
|
+
zettcode/presenters/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
zettcode/presenters/text.py,sha256=vr2Vi5j2qxxGUgPITs2EIJFFiYgGAcjlhSDDSc7MRnY,12913
|
|
8
|
+
zettcode_presenters-0.4.0.dist-info/METADATA,sha256=H3H6KBfTp92VGr60o9tun8nMfvA50F5PwgPoI05Ao1o,832
|
|
9
|
+
zettcode_presenters-0.4.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
10
|
+
zettcode_presenters-0.4.0.dist-info/entry_points.txt,sha256=V-yGLKSy_I7OZRZpAdAO4NU2E8TCnMkgslI33U24hJE,77
|
|
11
|
+
zettcode_presenters-0.4.0.dist-info/RECORD,,
|