zettcode-app 0.4.2__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/app/__init__.py +63 -0
- zettcode/app/agent/__init__.py +47 -0
- zettcode/app/agent/agent/__init__.py +84 -0
- zettcode/app/agent/agent/commands.py +269 -0
- zettcode/app/agent/agent/settings.py +132 -0
- zettcode/app/agent/conversation/__init__.py +7 -0
- zettcode/app/agent/conversation/changes.py +64 -0
- zettcode/app/agent/conversation/effort_gauge.py +126 -0
- zettcode/app/agent/conversation/projection.py +265 -0
- zettcode/app/agent/conversation/rendering.py +837 -0
- zettcode/app/agent/conversation/replay.py +198 -0
- zettcode/app/agent/conversation/shimmer.py +256 -0
- zettcode/app/agent/conversation/stops.py +57 -0
- zettcode/app/agent/conversation/tips.py +86 -0
- zettcode/app/agent/conversation/transcript/__init__.py +80 -0
- zettcode/app/agent/conversation/transcript/fold.py +57 -0
- zettcode/app/agent/conversation/transcript/log.py +182 -0
- zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
- zettcode/app/agent/conversation/transcript/stream.py +187 -0
- zettcode/app/agent/conversation/transcript/turn.py +323 -0
- zettcode/app/commandline/__init__.py +47 -0
- zettcode/app/commandline/arguments.py +169 -0
- zettcode/app/commandline/completion.py +262 -0
- zettcode/app/commandline/history.py +305 -0
- zettcode/app/commandline/runner.py +365 -0
- zettcode/app/errors.py +12 -0
- zettcode/app/plugins/__init__.py +110 -0
- zettcode/app/plugins/builtins.py +33 -0
- zettcode/app/plugins/groups.py +503 -0
- zettcode/app/plugins/loader.py +268 -0
- zettcode/app/py.typed +0 -0
- zettcode/app/ui/__init__.py +63 -0
- zettcode/app/ui/app.py +1507 -0
- zettcode/app/ui/bars.py +290 -0
- zettcode/app/ui/boot.py +131 -0
- zettcode/app/ui/clipboard.py +340 -0
- zettcode/app/ui/commands.py +323 -0
- zettcode/app/ui/config_form.py +343 -0
- zettcode/app/ui/demo.py +180 -0
- zettcode/app/ui/followups.py +430 -0
- zettcode/app/ui/gc_policy.py +147 -0
- zettcode/app/ui/keys.py +462 -0
- zettcode/app/ui/messaging.py +260 -0
- zettcode/app/ui/notices.py +195 -0
- zettcode/app/ui/providers.py +626 -0
- zettcode/app/ui/sessions.py +422 -0
- zettcode/app/ui/settings.py +161 -0
- zettcode/app/ui/shell.py +361 -0
- zettcode/app/ui/turns.py +115 -0
- zettcode/app/ui/updating.py +204 -0
- zettcode/app/ui/widgets/__init__.py +161 -0
- zettcode/app/ui/widgets/approval_page.py +284 -0
- zettcode/app/ui/widgets/ask_page.py +386 -0
- zettcode/app/ui/widgets/bash_page.py +90 -0
- zettcode/app/ui/widgets/completer.py +100 -0
- zettcode/app/ui/widgets/composer.py +529 -0
- zettcode/app/ui/widgets/document_page.py +159 -0
- zettcode/app/ui/widgets/effort_page.py +294 -0
- zettcode/app/ui/widgets/follow_up.py +168 -0
- zettcode/app/ui/widgets/form_page.py +380 -0
- zettcode/app/ui/widgets/input_page.py +175 -0
- zettcode/app/ui/widgets/model_page.py +63 -0
- zettcode/app/ui/widgets/model_settings_page.py +653 -0
- zettcode/app/ui/widgets/pet.py +247 -0
- zettcode/app/ui/widgets/pet_page.py +347 -0
- zettcode/app/ui/widgets/plugins_page.py +442 -0
- zettcode/app/ui/widgets/root.py +68 -0
- zettcode/app/ui/widgets/self_compact_page.py +363 -0
- zettcode/app/ui/widgets/send_kind_page.py +193 -0
- zettcode/app/ui/widgets/sessions_page.py +511 -0
- zettcode/app/ui/widgets/statusline_page.py +427 -0
- zettcode/app/ui/widgets/steering.py +126 -0
- zettcode/app/ui/widgets/transcript.py +287 -0
- zettcode/app/ui/widgets/update_page.py +65 -0
- zettcode/app/ui/widgets/welcome.py +24 -0
- zettcode/collector.py +54 -0
- zettcode/update.py +519 -0
- zettcode_app-0.4.2.dist-info/METADATA +26 -0
- zettcode_app-0.4.2.dist-info/RECORD +81 -0
- zettcode_app-0.4.2.dist-info/WHEEL +4 -0
- zettcode_app-0.4.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Rebuild a transcript from a stored branch, the way ``/resume`` restores one.
|
|
2
|
+
|
|
3
|
+
The walk lives here, apart from the shell, so that how a stored turn, a tool result,
|
|
4
|
+
or the reasoning that came with an answer becomes a row is written down once and is
|
|
5
|
+
the same whoever asks.
|
|
6
|
+
|
|
7
|
+
The transcript keeps only its newest ``max_entries`` entries, so a long branch is
|
|
8
|
+
not replayed from its beginning: the walk starts where the entries that will be kept
|
|
9
|
+
begin, and the rest of the branch contributes only what is recorded beside its
|
|
10
|
+
messages — the usage of each reply — which needs no message decoded at all.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import cast
|
|
18
|
+
|
|
19
|
+
from zett_agent.messages import AnyMessage, AssistantMessage, SystemMessage, ToolMessage, UserMessage
|
|
20
|
+
|
|
21
|
+
from zettcode.config import DEFAULT_TRANSCRIPT_MAX_ENTRIES
|
|
22
|
+
from zettcode.engine.dialogue.ask import declined_answer
|
|
23
|
+
from zettcode.engine.dialogue.side import is_side_line, is_watched_side_line
|
|
24
|
+
from zettcode.engine.extensions.usage import UsageSnapshot, session_usage
|
|
25
|
+
from zettcode.engine.server_tools import ServerToolRun, answer_after_tools, describe_output, runs_in_blocks
|
|
26
|
+
from zettcode.engine.storage import MessageLine, Session
|
|
27
|
+
from zettcode.messaging.relayed import relay_of
|
|
28
|
+
from zettcode.plugin_api.entries import EntryStatus
|
|
29
|
+
from zettcode.presenters.presenters import DEFAULT_PRESENTERS, Presenters
|
|
30
|
+
|
|
31
|
+
from .rendering import DEFAULT_RENDERERS, Renderers
|
|
32
|
+
from .stops import stop_text
|
|
33
|
+
from .transcript import Transcript
|
|
34
|
+
|
|
35
|
+
#: Entries of margin past the ones a window has to hold. Counting what a message will
|
|
36
|
+
#: add is an upper bound — consecutive replies with no row between them share one
|
|
37
|
+
#: answer, and a result whose call is before the window adds nothing — so the window
|
|
38
|
+
#: starts a little earlier than the count says it needs to.
|
|
39
|
+
WINDOW_SLACK = 64
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class Replayed:
|
|
44
|
+
"""What one stored branch replays into.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
transcript: The newest entries of the branch, ready to render or install.
|
|
48
|
+
usage: Totals accumulated from the usage every stored reply recorded, the
|
|
49
|
+
ones before the transcript's window included.
|
|
50
|
+
lines: The whole active branch, oldest first.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
transcript: Transcript
|
|
54
|
+
usage: UsageSnapshot
|
|
55
|
+
lines: Sequence[MessageLine]
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def history(self) -> tuple[AnyMessage, ...]:
|
|
59
|
+
"""Return the messages a request would carry, oldest first.
|
|
60
|
+
|
|
61
|
+
Everything on the branch that is not an instruction or a side question. It is
|
|
62
|
+
worked out when asked for, because it needs every message on the branch
|
|
63
|
+
decoded, which restoring a session into the shell never wants.
|
|
64
|
+
"""
|
|
65
|
+
return tuple(
|
|
66
|
+
line.message[0]
|
|
67
|
+
for line in self.lines
|
|
68
|
+
if not is_side_line(line)
|
|
69
|
+
and line.message[0].include_in_messages
|
|
70
|
+
and not isinstance(line.message[0], SystemMessage)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def entries_added_by(message: AnyMessage) -> int:
|
|
75
|
+
"""Return the most rows replaying one message adds to the transcript."""
|
|
76
|
+
match message:
|
|
77
|
+
case UserMessage():
|
|
78
|
+
return 1
|
|
79
|
+
case AssistantMessage():
|
|
80
|
+
stopped = message.stop is not None and not message.stop.ok
|
|
81
|
+
return (
|
|
82
|
+
bool(message.reasoning)
|
|
83
|
+
+ bool(message.content)
|
|
84
|
+
+ len(message.tool_calls)
|
|
85
|
+
+ len(runs_in_blocks(message.replay_blocks))
|
|
86
|
+
+ stopped
|
|
87
|
+
)
|
|
88
|
+
case _:
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def window_start(lines: Sequence[MessageLine], entries: int) -> int:
|
|
93
|
+
"""Return the index of the line to start replaying from to end with ``entries`` rows.
|
|
94
|
+
|
|
95
|
+
The branch is walked from its newest line, so the only messages looked inside are
|
|
96
|
+
the ones the window will replay anyway; a short branch returns 0 and is replayed
|
|
97
|
+
whole. A side run nobody watched is skipped here as it is skipped below, or the
|
|
98
|
+
count would reach its target on rows the window then drops and the restored
|
|
99
|
+
transcript would hold fewer rows than it was asked for.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
lines: The active branch, oldest first.
|
|
103
|
+
entries: Rows the window has to hold.
|
|
104
|
+
"""
|
|
105
|
+
wanted = entries + WINDOW_SLACK
|
|
106
|
+
seen = 0
|
|
107
|
+
for index in range(len(lines) - 1, -1, -1):
|
|
108
|
+
if is_side_line(lines[index]) and not is_watched_side_line(lines[index]):
|
|
109
|
+
continue
|
|
110
|
+
seen += entries_added_by(lines[index].message[0])
|
|
111
|
+
if seen >= wanted:
|
|
112
|
+
return index
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def replay(
|
|
117
|
+
session: object,
|
|
118
|
+
*,
|
|
119
|
+
renderers: Renderers = DEFAULT_RENDERERS,
|
|
120
|
+
presenters: Presenters = DEFAULT_PRESENTERS,
|
|
121
|
+
frame: int = 0,
|
|
122
|
+
max_entries: int = DEFAULT_TRANSCRIPT_MAX_ENTRIES,
|
|
123
|
+
) -> Replayed:
|
|
124
|
+
"""Rebuild a session's active branch into entries, totals, and messages.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
session: The parsed session (the ``records`` of an SDK ``StoredSession``; typed ``object`` because its type is
|
|
128
|
+
the engine's, which the views do not import).
|
|
129
|
+
renderers: Chain that phrases tool rows.
|
|
130
|
+
presenters: How each row is drawn.
|
|
131
|
+
frame: The animation step the rebuilt rows start at.
|
|
132
|
+
max_entries: How many entries the transcript keeps. Only the lines that
|
|
133
|
+
produce them are decoded and walked; the older ones add their usage to
|
|
134
|
+
the totals and nothing else.
|
|
135
|
+
"""
|
|
136
|
+
lines = cast("Session", session).branch()
|
|
137
|
+
start = window_start(lines, max_entries)
|
|
138
|
+
transcript = Transcript(renderers=renderers, presenters=presenters, max_entries=max_entries)
|
|
139
|
+
transcript.frame = frame
|
|
140
|
+
for line in lines[start:]:
|
|
141
|
+
# A side question the reader watched is shown again — restoring the session restores the transcript they were
|
|
142
|
+
# looking at — while staying out of ``history``, which is what the next request would carry. One nobody watched
|
|
143
|
+
# is left out whole, question and answer: a plugin's recap is asked with nothing on screen, so restoring it
|
|
144
|
+
# would put words in the conversation that were never said there.
|
|
145
|
+
side = is_side_line(line)
|
|
146
|
+
if side and not is_watched_side_line(line):
|
|
147
|
+
continue
|
|
148
|
+
message = line.message[0]
|
|
149
|
+
match message:
|
|
150
|
+
case UserMessage():
|
|
151
|
+
# A message whose ``@`` references expanded carries the text the
|
|
152
|
+
# reader typed as an attribute; the row restores that, while
|
|
153
|
+
# ``history`` keeps the text the model was given.
|
|
154
|
+
original = message.attributes.get("prompt")
|
|
155
|
+
shown = original if isinstance(original, str) and original else message.text
|
|
156
|
+
relayed = relay_of(message.attributes, shown)
|
|
157
|
+
if relayed is not None and not side:
|
|
158
|
+
transcript.relayed_message(relayed)
|
|
159
|
+
else:
|
|
160
|
+
transcript.user_message(shown, side=side)
|
|
161
|
+
case AssistantMessage():
|
|
162
|
+
if message.reasoning:
|
|
163
|
+
transcript.restore_thinking(message.reasoning, line.timing.reasoning_duration_ns)
|
|
164
|
+
runs = runs_in_blocks(message.replay_blocks)
|
|
165
|
+
|
|
166
|
+
def hosted(rows: Sequence[ServerToolRun] = runs) -> None:
|
|
167
|
+
"""Add the rows of the tools the endpoint ran, each closed by its own result."""
|
|
168
|
+
for run in rows:
|
|
169
|
+
transcript.start_tool(run.call_id, run.name, run.arguments)
|
|
170
|
+
transcript.complete_tool(
|
|
171
|
+
run.call_id,
|
|
172
|
+
describe_output(run),
|
|
173
|
+
status=EntryStatus.FAILED if run.error else EntryStatus.COMPLETED,
|
|
174
|
+
wait=False,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# A hosted tool has no message of its own: its call and result are both in the answer's blocks, kept
|
|
178
|
+
# because the provider needs them back. Where the answer's text sits in those blocks is where the row
|
|
179
|
+
# goes relative to it, so a reopened session reads in the order the run did.
|
|
180
|
+
if not answer_after_tools(message.replay_blocks) and message.content:
|
|
181
|
+
transcript.append_answer(message.content)
|
|
182
|
+
hosted()
|
|
183
|
+
if answer_after_tools(message.replay_blocks) and message.content:
|
|
184
|
+
transcript.append_answer(message.content)
|
|
185
|
+
for call in message.tool_calls:
|
|
186
|
+
transcript.start_tool(call.id, call.name, call.arguments)
|
|
187
|
+
if message.stop is not None and not message.stop.ok:
|
|
188
|
+
transcript.error(stop_text(message.stop))
|
|
189
|
+
case ToolMessage():
|
|
190
|
+
status = EntryStatus.COMPLETED if message.success else EntryStatus.FAILED
|
|
191
|
+
if message.name == "ask_user" and declined_answer(message.text):
|
|
192
|
+
# A cancelled question is an answer of its own, not a failure.
|
|
193
|
+
status = EntryStatus.SKIPPED
|
|
194
|
+
transcript.complete_tool(message.tool_call_id, message.text, status=status, wait=False)
|
|
195
|
+
case _:
|
|
196
|
+
continue
|
|
197
|
+
transcript.finish_restored_tools()
|
|
198
|
+
return Replayed(transcript=transcript, usage=session_usage(lines), lines=lines)
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""The show the header plays for a switch to the top of the scale: light crossing the whole row, right to left.
|
|
2
|
+
|
|
3
|
+
Reaching ``max`` or ``ultra`` is the expensive thing a reader can do here, and the header marks it with an
|
|
4
|
+
animation of its own rather than the gauge every other switch gets (:mod:`.effort_gauge`). Light crosses the
|
|
5
|
+
header several times; the first pass ignites the right segment as it goes and the later ones glint over the
|
|
6
|
+
left. A frame is a value without a colour, like the gauge's: the painter chooses the colours from the theme.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
#: The levels at the top of the scale. Reaching one is the expensive thing a reader can do here, and the
|
|
16
|
+
#: header marks it with a show of its own rather than the gauge every other switch gets.
|
|
17
|
+
SHIMMER_LEVELS = frozenset({"max", "ultra"})
|
|
18
|
+
|
|
19
|
+
#: How fast the light crosses the header, in columns a second, pass by pass. It is a speed rather than a time,
|
|
20
|
+
#: because a time would send the light across a wide terminal at twice the pace it crosses a narrow one. The
|
|
21
|
+
#: first pass is the slowest, so there is time to watch it light the corner as it goes, and each later one is a
|
|
22
|
+
#: little quicker; the top of the scale gets one pass more. The pace is chosen against the frame: at 120 frames a
|
|
23
|
+
#: second the slowest pass moves the light about a cell per frame, which is what makes it read as one continuous
|
|
24
|
+
#: crossing rather than a light that sits still and then jumps a column — at half this pace most frames carried
|
|
25
|
+
#: no movement at all, which is the stutter a reader sees as jerky.
|
|
26
|
+
SHIMMER_SPEEDS: dict[str, tuple[float, ...]] = {
|
|
27
|
+
"max": (110.0, 140.0, 170.0, 200.0),
|
|
28
|
+
"ultra": (110.0, 140.0, 170.0, 200.0, 230.0),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#: How far across its trip a pass is when the next one sets off, so the light keeps coming. High enough that a
|
|
32
|
+
#: pass is still crossing when the next is well under way: the row carries light the whole time.
|
|
33
|
+
SHIMMER_OVERLAP = 0.5
|
|
34
|
+
|
|
35
|
+
#: Seconds the corner stays lit after the last pass has gone, before it hands the header back. It is a hold
|
|
36
|
+
#: rather than a fade of nothing: the corner keeps the light the first pass put on it, and the row would look
|
|
37
|
+
#: abandoned if it went straight back to the resting header the moment the last pass left.
|
|
38
|
+
SHIMMER_TAIL = 1.6
|
|
39
|
+
|
|
40
|
+
#: Seconds the row takes to arrive and to leave.
|
|
41
|
+
SHIMMER_FADE_IN_SECONDS = 0.25
|
|
42
|
+
SHIMMER_FADE_OUT_SECONDS = 0.8
|
|
43
|
+
|
|
44
|
+
#: Seconds the slowest pass is allowed to take, however wide the terminal: past this the light speeds up rather
|
|
45
|
+
#: than keep a very wide header waiting.
|
|
46
|
+
SHIMMER_LONGEST_PASS = 2.6
|
|
47
|
+
|
|
48
|
+
#: Half the width, in cells, of the light that crosses the header (its halo), and of the white-hot core inside it.
|
|
49
|
+
#: The halo is what makes the crossing visible at a glance; the core is the hot centre of it. The core is wide
|
|
50
|
+
#: enough to cover a few cells, so the glint is a band of light rather than a single cell that flickers.
|
|
51
|
+
SHIMMER_HALO = 8.0
|
|
52
|
+
SHIMMER_CORE = 4.0
|
|
53
|
+
|
|
54
|
+
#: Cells past either end of the row a pass starts and finishes, so the light enters and leaves.
|
|
55
|
+
SHIMMER_RUNWAY = 8
|
|
56
|
+
|
|
57
|
+
#: Twinkles either side of the word, by level, stepped this many times a second.
|
|
58
|
+
SHIMMER_SPARKS: dict[str, tuple[str, ...]] = {
|
|
59
|
+
"max": ("\u2726", " ", "\u00b7", " "),
|
|
60
|
+
"ultra": ("\u2726", "\u2727", "\u00b7", "\u2727"),
|
|
61
|
+
}
|
|
62
|
+
SHIMMER_SPARK_RATE = 14
|
|
63
|
+
|
|
64
|
+
#: Columns between the last gauge cell and the word's first letter (a gap, a sparkle, a gap), and after the word.
|
|
65
|
+
SHIMMER_BEFORE_WORD = 3
|
|
66
|
+
SHIMMER_AFTER_WORD = 2
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def shimmer_schedule(level: str, reach: int) -> tuple[tuple[float, float], ...]:
|
|
70
|
+
"""Return when each pass of light is on, in seconds from the start of the show: ``(start, end)``.
|
|
71
|
+
|
|
72
|
+
A pass crosses ``reach`` columns and its runway at its level's speed (but takes no longer than
|
|
73
|
+
:data:`SHIMMER_LONGEST_PASS`, scaled by its speed), and the next sets off once this one is
|
|
74
|
+
:data:`SHIMMER_OVERLAP` of the way across.
|
|
75
|
+
"""
|
|
76
|
+
trip = reach + 2 * SHIMMER_RUNWAY
|
|
77
|
+
windows = []
|
|
78
|
+
start = 0.0
|
|
79
|
+
slowest = SHIMMER_SPEEDS[level][0]
|
|
80
|
+
for speed in SHIMMER_SPEEDS[level]:
|
|
81
|
+
duration = min(trip / speed, SHIMMER_LONGEST_PASS * slowest / speed)
|
|
82
|
+
windows.append((start, start + duration))
|
|
83
|
+
start += duration * SHIMMER_OVERLAP
|
|
84
|
+
return tuple(windows)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def shimmer_seconds(level: str, reach: int) -> float:
|
|
88
|
+
"""Return how long the show lasts for a header ``reach`` columns wide: the last pass, then the tail."""
|
|
89
|
+
return shimmer_schedule(level, reach)[-1][1] + SHIMMER_TAIL
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class ShimmerFrame:
|
|
94
|
+
"""One instant of the show a switch to the top of the scale plays in the header, without a colour.
|
|
95
|
+
|
|
96
|
+
The row is ``model \u00b7 gauge \u2726 word \u2726`` and every list here has one entry per column of it, from the
|
|
97
|
+
first letter of the model's name to the last sparkle, so the painter lays them over the spans it draws. The
|
|
98
|
+
width never changes during the show: the sparkles are always there, blank or not, so the right-aligned row
|
|
99
|
+
does not shiver as they twinkle.
|
|
100
|
+
|
|
101
|
+
Attributes:
|
|
102
|
+
lead: Columns before the gauge: the model's name and the separator after it.
|
|
103
|
+
cells: Cells in the gauge.
|
|
104
|
+
lit_cells: How many of them the level reaches; the rest stay rail.
|
|
105
|
+
word: The level being shown.
|
|
106
|
+
glow: How much of the light's halo is on each column, ``0`` to ``1``.
|
|
107
|
+
core: How much of its white-hot core is, ``0`` to ``1``; always at most the glow.
|
|
108
|
+
ignited: Whether the first pass has gone over each column. The light travels right to left, so the
|
|
109
|
+
columns it has left behind are the ones to the right of it.
|
|
110
|
+
glitter: A fast twinkle on each column; nothing at ``max``, and on the lit part of the row at ``ultra``.
|
|
111
|
+
sparks: The sparkle drawn left of the word and the one drawn right of it.
|
|
112
|
+
heat: Where on the scale the level sits, ``0`` to ``1``; the colour ramp reads it.
|
|
113
|
+
presence: How much of the show is there: it rises, holds and falls, and the painter blends every colour
|
|
114
|
+
toward the resting header by what is missing.
|
|
115
|
+
reach: Columns of the header the light crosses, from the right edge to the left one.
|
|
116
|
+
bands: Where each pass of light is now, as its distance from the right edge.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
lead: int
|
|
120
|
+
cells: int
|
|
121
|
+
lit_cells: int
|
|
122
|
+
word: str
|
|
123
|
+
glow: tuple[float, ...]
|
|
124
|
+
core: tuple[float, ...]
|
|
125
|
+
ignited: tuple[bool, ...]
|
|
126
|
+
glitter: tuple[float, ...]
|
|
127
|
+
sparks: tuple[str, str]
|
|
128
|
+
heat: float
|
|
129
|
+
presence: float
|
|
130
|
+
reach: int
|
|
131
|
+
bands: tuple[float, ...]
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def width(self) -> int:
|
|
135
|
+
"""Return the columns the row takes, which does not change from one frame to the next."""
|
|
136
|
+
return len(self.glow)
|
|
137
|
+
|
|
138
|
+
def light(self, distance: float) -> tuple[float, float]:
|
|
139
|
+
"""Return the halo and the core of the light at a column ``distance`` cells from the right edge.
|
|
140
|
+
|
|
141
|
+
The right segment is described by :attr:`glow` and :attr:`core`; this is the same light for any other
|
|
142
|
+
column of the header, which is how the left segment glints as the light goes over it.
|
|
143
|
+
"""
|
|
144
|
+
halo = max((_profile(abs(distance - centre), SHIMMER_HALO) for centre in self.bands), default=0.0)
|
|
145
|
+
core = max((_profile(abs(distance - centre), SHIMMER_CORE) for centre in self.bands), default=0.0)
|
|
146
|
+
return halo, core
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def word_start(self) -> int:
|
|
150
|
+
"""Return the column the word's first letter is in."""
|
|
151
|
+
return self.lead + self.cells + SHIMMER_BEFORE_WORD
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def shimmer_width(lead: int, cells: int, word: str) -> int:
|
|
155
|
+
"""Return the columns of the show's row: the lead, the gauge, the word with its sparkles."""
|
|
156
|
+
return lead + cells + SHIMMER_BEFORE_WORD + len(word) + SHIMMER_AFTER_WORD
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _profile(distance: float, half_width: float) -> float:
|
|
160
|
+
"""Return the brightness of light ``distance`` cells from its centre: a cosine, zero past its reach."""
|
|
161
|
+
if distance >= half_width:
|
|
162
|
+
return 0.0
|
|
163
|
+
return 0.5 * (1 + math.cos(math.pi * distance / half_width))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _centre(window: tuple[float, float], elapsed: float, reach: int) -> float | None:
|
|
167
|
+
"""Return where a pass of light is, as its distance from the right edge, or ``None`` when it is not on.
|
|
168
|
+
|
|
169
|
+
It enters past the right edge and leaves past the left one, ``reach`` columns away. The ease is gentle — half
|
|
170
|
+
a smooth step, half a straight line — so the light leans into the trip and out of it without the
|
|
171
|
+
middle of the trip being much quicker than the pace the pass was given.
|
|
172
|
+
"""
|
|
173
|
+
start, end = window
|
|
174
|
+
if not start <= elapsed <= end:
|
|
175
|
+
return None
|
|
176
|
+
along = (elapsed - start) / (end - start)
|
|
177
|
+
eased = 0.5 * along + 0.5 * along * along * (3 - 2 * along)
|
|
178
|
+
return -SHIMMER_RUNWAY + eased * (reach + 2 * SHIMMER_RUNWAY)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def shimmer_frame(
|
|
182
|
+
current: str,
|
|
183
|
+
levels: Sequence[str],
|
|
184
|
+
*,
|
|
185
|
+
lead: int,
|
|
186
|
+
reach: int,
|
|
187
|
+
progress: float,
|
|
188
|
+
) -> ShimmerFrame | None:
|
|
189
|
+
"""Return the header's show for a switch to the top of the scale, ``progress`` of the way through it.
|
|
190
|
+
|
|
191
|
+
Light crosses the whole header from right to left, several times: the first pass ignites the right segment,
|
|
192
|
+
laying the gauge and the word down in the hot end of the scale's colours as it goes, and the later passes are
|
|
193
|
+
a little quicker, each with a white-hot core inside a wide halo. They keep going over the left segment, so
|
|
194
|
+
the whole row glints, not just the corner that changed. Sparkles twinkle either side of the word once it is
|
|
195
|
+
lit. Everything is a function of ``progress``, the fraction of the show's time that has gone, so a slow
|
|
196
|
+
terminal drops frames instead of stretching the show.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
current: The level reached. One that is not at the top of the scale answers ``None``: the ordinary gauge
|
|
200
|
+
is the animation for it.
|
|
201
|
+
levels: The configured levels, cheapest first.
|
|
202
|
+
lead: Columns of the right segment before the gauge, which the painter knows and this does not.
|
|
203
|
+
reach: Columns of the header, which is how far the light travels and, at a fixed speed, how long the
|
|
204
|
+
show lasts (:func:`shimmer_seconds`). At least as wide as the row itself.
|
|
205
|
+
progress: ``0`` at the start through ``1`` at the end; outside that range the answer is ``None``, which
|
|
206
|
+
is the caller's cue to release the frame loop.
|
|
207
|
+
"""
|
|
208
|
+
if not 0.0 <= progress < 1.0 or current not in SHIMMER_LEVELS or current not in levels:
|
|
209
|
+
return None
|
|
210
|
+
count = len(levels)
|
|
211
|
+
width = shimmer_width(lead, count, current)
|
|
212
|
+
reach = max(reach, width)
|
|
213
|
+
seconds = shimmer_seconds(current, reach)
|
|
214
|
+
elapsed = progress * seconds
|
|
215
|
+
schedule = shimmer_schedule(current, reach)
|
|
216
|
+
target = levels.index(current)
|
|
217
|
+
last = max(1, count - 1)
|
|
218
|
+
bands = tuple(centre for window in schedule if (centre := _centre(window, elapsed, reach)) is not None)
|
|
219
|
+
first = schedule[0]
|
|
220
|
+
# The columns the first pass has gone over are the ones nearer the right edge than it is.
|
|
221
|
+
first_centre = _centre(first, elapsed, reach)
|
|
222
|
+
if first_centre is None:
|
|
223
|
+
first_centre = -SHIMMER_RUNWAY if elapsed < first[0] else reach + SHIMMER_RUNWAY
|
|
224
|
+
steps = int(elapsed * SHIMMER_SPARK_RATE)
|
|
225
|
+
glow, core, ignited, glitter = [], [], [], []
|
|
226
|
+
for column in range(width):
|
|
227
|
+
distance = width - 1 - column
|
|
228
|
+
glow.append(max((_profile(abs(distance - centre), SHIMMER_HALO) for centre in bands), default=0.0))
|
|
229
|
+
core.append(max((_profile(abs(distance - centre), SHIMMER_CORE) for centre in bands), default=0.0))
|
|
230
|
+
ignited.append(distance < first_centre)
|
|
231
|
+
if current == "ultra" and distance < first_centre:
|
|
232
|
+
twinkle = 0.5 + 0.5 * math.sin(elapsed * 31 + column * 2.3)
|
|
233
|
+
glitter.append(max(0.0, twinkle - 0.62) / 0.38)
|
|
234
|
+
else:
|
|
235
|
+
glitter.append(0.0)
|
|
236
|
+
sparks = SHIMMER_SPARKS[current]
|
|
237
|
+
left_column = width - SHIMMER_AFTER_WORD - len(current) - 2
|
|
238
|
+
right_column = width - 1
|
|
239
|
+
left = sparks[steps % len(sparks)] if ignited[left_column] else " "
|
|
240
|
+
right = sparks[(steps + 2) % len(sparks)] if ignited[right_column] else " "
|
|
241
|
+
presence = min(1.0, elapsed / SHIMMER_FADE_IN_SECONDS, (seconds - elapsed) / SHIMMER_FADE_OUT_SECONDS)
|
|
242
|
+
return ShimmerFrame(
|
|
243
|
+
lead,
|
|
244
|
+
count,
|
|
245
|
+
target + 1,
|
|
246
|
+
current,
|
|
247
|
+
tuple(glow),
|
|
248
|
+
tuple(core),
|
|
249
|
+
tuple(ignited),
|
|
250
|
+
tuple(glitter),
|
|
251
|
+
(left, right),
|
|
252
|
+
target / last,
|
|
253
|
+
max(0.0, presence),
|
|
254
|
+
reach,
|
|
255
|
+
bands,
|
|
256
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Say why a model stopped when it did not finish, in a sentence a reader can act on.
|
|
2
|
+
|
|
3
|
+
A model step ends in one of a few ways the library names (:class:`~zett_agent.stop.StopReason`), and only two are
|
|
4
|
+
normal: it finished, or it asked for tools. Every other ending leaves a reply that is missing, cut off, or not what the
|
|
5
|
+
model would have said, and a row that only says how long the request took would let that pass for an answer.
|
|
6
|
+
|
|
7
|
+
The same sentence is used live (an event arrives) and when a stored session is shown again (the message carries the
|
|
8
|
+
stop), so what was seen is what is seen again.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from zett_agent.stop import StopInfo, StopReason
|
|
14
|
+
|
|
15
|
+
#: What to do about each ending the reader can do something about. A refusal has none: the same input is declined again.
|
|
16
|
+
_NEXT = {
|
|
17
|
+
StopReason.MAX_OUTPUT: "/continue asks the model to carry on from there.",
|
|
18
|
+
StopReason.INTERRUPTED: "/continue asks the model to start that reply again.",
|
|
19
|
+
StopReason.PAUSED: "/continue picks the request up where the provider paused it.",
|
|
20
|
+
StopReason.CONTEXT_FULL: "/compact summarizes the conversation to make room, or /new starts afresh.",
|
|
21
|
+
StopReason.REFUSAL: "Rephrase the request, or choose another model with /model.",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#: Why it stopped, in the reader's terms, by reason.
|
|
25
|
+
_WHAT = {
|
|
26
|
+
StopReason.MAX_OUTPUT: "The reply reached the model's output limit and is cut off.",
|
|
27
|
+
StopReason.INTERRUPTED: "The reply was cut off before the model finished it.",
|
|
28
|
+
StopReason.PAUSED: "The provider paused the reply before it was done.",
|
|
29
|
+
StopReason.CONTEXT_FULL: "The context window filled up, so the reply stopped short.",
|
|
30
|
+
StopReason.REFUSAL: "The model declined this request.",
|
|
31
|
+
StopReason.OTHER: "The model stopped for a reason ZettCode does not know, so the reply may be incomplete.",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def stop_text(stop: StopInfo) -> str:
|
|
36
|
+
"""Return the line that says a model step ended abnormally, with what the provider said and what to try.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
stop: How the step ended; one that ended normally has nothing to say and gives an empty string.
|
|
40
|
+
"""
|
|
41
|
+
if stop.ok:
|
|
42
|
+
return ""
|
|
43
|
+
parts = [_WHAT.get(stop.reason, _WHAT[StopReason.OTHER])]
|
|
44
|
+
said = stop.message or stop.category or (stop.raw if stop.raw not in (None, stop.reason.value) else None)
|
|
45
|
+
if said:
|
|
46
|
+
label = f"{stop.category}: " if stop.category and stop.message and stop.category != stop.message else ""
|
|
47
|
+
parts.append(f"The provider said: {label}{said}")
|
|
48
|
+
withheld = stop.detail.get("withheld_tool_calls")
|
|
49
|
+
if isinstance(withheld, (list, tuple)) and withheld:
|
|
50
|
+
names = ", ".join(sorted({str(name) for name in withheld}))
|
|
51
|
+
parts.append(f"It had asked to run {names}; that was not done.")
|
|
52
|
+
if stop.recommended_model:
|
|
53
|
+
parts.append(f"The provider suggests {stop.recommended_model}.")
|
|
54
|
+
advice = _NEXT.get(stop.reason)
|
|
55
|
+
if advice:
|
|
56
|
+
parts.append(advice)
|
|
57
|
+
return " ".join(part if part.endswith((".", "!", "?")) else f"{part}." for part in parts)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Small tips for the reader, shown under the waiting row while a request runs.
|
|
2
|
+
|
|
3
|
+
A request can take a while and the screen has nothing to say meanwhile, so it says something that might be
|
|
4
|
+
useful: a command, a key, a thing the program does that a reader would not find by typing. Each tip is one
|
|
5
|
+
sentence that names what to type, and each is true of this program as it is: ``tests/test_tips.py`` holds
|
|
6
|
+
that every command a tip names exists.
|
|
7
|
+
|
|
8
|
+
The tips are not chosen for the request (they are not about it): they come in an order that is different each
|
|
9
|
+
time the program starts, and each request begins one tip further along, so a reader who sends ten requests sees
|
|
10
|
+
ten different tips before one comes round again.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import random
|
|
16
|
+
from collections.abc import Callable, MutableSequence, Sequence
|
|
17
|
+
|
|
18
|
+
from zettcode.tui import OPTION
|
|
19
|
+
|
|
20
|
+
#: What the reader may be told, in the words they will see.
|
|
21
|
+
TIPS: tuple[str, ...] = (
|
|
22
|
+
"Type while it works: your message can steer this request, or wait and follow it.",
|
|
23
|
+
"Press Shift-Enter (or Alt-Enter) to add a line to your message instead of sending it.",
|
|
24
|
+
"Type @ to mention a file or a skill. A file is read when the message is sent.",
|
|
25
|
+
"Type @ to mention another running ZettCode session; the model can pass your message to it.",
|
|
26
|
+
"/btw <question> is answered on the side and never joins the conversation.",
|
|
27
|
+
"/bash opens a full-screen command line: your shell's history, Tab completion, and coloured output.",
|
|
28
|
+
"In /bash, Tab completes git subcommands and branches, and Ctrl-C stops a running command.",
|
|
29
|
+
"/compact summarizes the conversation now, instead of waiting for the window to fill.",
|
|
30
|
+
"/context shows what is filling the context window, and where this session is stored.",
|
|
31
|
+
"/effort <level> sets how much the model reasons before it answers.",
|
|
32
|
+
"/permission changes how much of the shell may run without asking, even in the middle of a request.",
|
|
33
|
+
"/resume picks up an earlier session; /fork carries this one into a new session.",
|
|
34
|
+
"/title names this session; /export writes it to a standalone HTML file.",
|
|
35
|
+
"/model changes the model for the next request, and /config adds or removes models.",
|
|
36
|
+
"/theme dark or /theme light switches the palette in one step.",
|
|
37
|
+
"Ctrl-R searches what you have sent before; Up recalls the last message.",
|
|
38
|
+
"Ctrl-V attaches an image from the clipboard, for a model that takes images.",
|
|
39
|
+
"Page Up scrolls the conversation, and Esc jumps back to the newest line.",
|
|
40
|
+
f"{OPTION.capitalize()}-\u2191/\u2193 (or Ctrl-\u2191/\u2193) jumps to the previous or next message you sent.",
|
|
41
|
+
"Drag in the conversation to copy text. Hold Shift or Alt to use your terminal's own selection.",
|
|
42
|
+
"Put project instructions in AGENTS.md and they are given to the model with every request.",
|
|
43
|
+
"A SKILL.md in ~/.zettcode/skills teaches ZettCode a job it can repeat.",
|
|
44
|
+
"/usage shows tokens, cache and cost by day, model and session; /resume lists them per session.",
|
|
45
|
+
"/statusline chooses what the header and the status line show, and turns the pet on.",
|
|
46
|
+
"/pet picks a companion that sleeps while idle and works while a request runs; drag it anywhere.",
|
|
47
|
+
"/plugins can turn self-compaction on or off: the model decides when to compact its own context.",
|
|
48
|
+
"zettcode resume <id> opens a stored session; zettcode update installs the newest release.",
|
|
49
|
+
"zettcode doctor checks every configured model from a shell, with no session started.",
|
|
50
|
+
"zettcode --full skips every shell question and the sandbox for that run; /permission can change it later.",
|
|
51
|
+
"Press Esc to stop a request; /continue picks it up again where it left off.",
|
|
52
|
+
"A plugin is a Python package: install it and ZettCode gains its commands, tools, themes or pets.",
|
|
53
|
+
"zettcode plugin install owner/repo adds a plugin; restart ZettCode to load it.",
|
|
54
|
+
"/hub shows the session graph, the running sessions, and every daemon on this machine.",
|
|
55
|
+
"zettcode daemon spawn starts a background agent; /hub lists it and send_message reaches it.",
|
|
56
|
+
"zettcode gateway opens a web page for this machine's sessions and daemons.",
|
|
57
|
+
"/code-review asks for a review of the current change before you hand it over.",
|
|
58
|
+
'Memory is off by default; [plugins] enable = ["memory"] turns it on.',
|
|
59
|
+
"When memory is on, tell the agent what to remember; it keeps personal and project notes current.",
|
|
60
|
+
"These tips can be turned off: tips = false under [transcript] in the configuration.",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TipDeck:
|
|
65
|
+
"""Hands each request its tips, beginning one further along than the request before.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
tips: What to hand out.
|
|
69
|
+
shuffle: Puts them in an order once, when the program starts; ``random.shuffle`` by default, injectable so
|
|
70
|
+
a test can fix the order.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self, tips: Sequence[str] = TIPS, *, shuffle: Callable[[MutableSequence[str]], None] = random.shuffle
|
|
75
|
+
) -> None:
|
|
76
|
+
self._tips = list(tips)
|
|
77
|
+
shuffle(self._tips)
|
|
78
|
+
self._requests = 0
|
|
79
|
+
|
|
80
|
+
def next(self) -> tuple[str, ...]:
|
|
81
|
+
"""Return all the tips in the order this request will show them: the first it shows is the one that follows."""
|
|
82
|
+
if not self._tips:
|
|
83
|
+
return ()
|
|
84
|
+
start = self._requests % len(self._tips)
|
|
85
|
+
self._requests += 1
|
|
86
|
+
return tuple(self._tips[start:] + self._tips[:start])
|