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.
Files changed (81) hide show
  1. zettcode/app/__init__.py +63 -0
  2. zettcode/app/agent/__init__.py +47 -0
  3. zettcode/app/agent/agent/__init__.py +84 -0
  4. zettcode/app/agent/agent/commands.py +269 -0
  5. zettcode/app/agent/agent/settings.py +132 -0
  6. zettcode/app/agent/conversation/__init__.py +7 -0
  7. zettcode/app/agent/conversation/changes.py +64 -0
  8. zettcode/app/agent/conversation/effort_gauge.py +126 -0
  9. zettcode/app/agent/conversation/projection.py +265 -0
  10. zettcode/app/agent/conversation/rendering.py +837 -0
  11. zettcode/app/agent/conversation/replay.py +198 -0
  12. zettcode/app/agent/conversation/shimmer.py +256 -0
  13. zettcode/app/agent/conversation/stops.py +57 -0
  14. zettcode/app/agent/conversation/tips.py +86 -0
  15. zettcode/app/agent/conversation/transcript/__init__.py +80 -0
  16. zettcode/app/agent/conversation/transcript/fold.py +57 -0
  17. zettcode/app/agent/conversation/transcript/log.py +182 -0
  18. zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
  19. zettcode/app/agent/conversation/transcript/stream.py +187 -0
  20. zettcode/app/agent/conversation/transcript/turn.py +323 -0
  21. zettcode/app/commandline/__init__.py +47 -0
  22. zettcode/app/commandline/arguments.py +169 -0
  23. zettcode/app/commandline/completion.py +262 -0
  24. zettcode/app/commandline/history.py +305 -0
  25. zettcode/app/commandline/runner.py +365 -0
  26. zettcode/app/errors.py +12 -0
  27. zettcode/app/plugins/__init__.py +110 -0
  28. zettcode/app/plugins/builtins.py +33 -0
  29. zettcode/app/plugins/groups.py +503 -0
  30. zettcode/app/plugins/loader.py +268 -0
  31. zettcode/app/py.typed +0 -0
  32. zettcode/app/ui/__init__.py +63 -0
  33. zettcode/app/ui/app.py +1507 -0
  34. zettcode/app/ui/bars.py +290 -0
  35. zettcode/app/ui/boot.py +131 -0
  36. zettcode/app/ui/clipboard.py +340 -0
  37. zettcode/app/ui/commands.py +323 -0
  38. zettcode/app/ui/config_form.py +343 -0
  39. zettcode/app/ui/demo.py +180 -0
  40. zettcode/app/ui/followups.py +430 -0
  41. zettcode/app/ui/gc_policy.py +147 -0
  42. zettcode/app/ui/keys.py +462 -0
  43. zettcode/app/ui/messaging.py +260 -0
  44. zettcode/app/ui/notices.py +195 -0
  45. zettcode/app/ui/providers.py +626 -0
  46. zettcode/app/ui/sessions.py +422 -0
  47. zettcode/app/ui/settings.py +161 -0
  48. zettcode/app/ui/shell.py +361 -0
  49. zettcode/app/ui/turns.py +115 -0
  50. zettcode/app/ui/updating.py +204 -0
  51. zettcode/app/ui/widgets/__init__.py +161 -0
  52. zettcode/app/ui/widgets/approval_page.py +284 -0
  53. zettcode/app/ui/widgets/ask_page.py +386 -0
  54. zettcode/app/ui/widgets/bash_page.py +90 -0
  55. zettcode/app/ui/widgets/completer.py +100 -0
  56. zettcode/app/ui/widgets/composer.py +529 -0
  57. zettcode/app/ui/widgets/document_page.py +159 -0
  58. zettcode/app/ui/widgets/effort_page.py +294 -0
  59. zettcode/app/ui/widgets/follow_up.py +168 -0
  60. zettcode/app/ui/widgets/form_page.py +380 -0
  61. zettcode/app/ui/widgets/input_page.py +175 -0
  62. zettcode/app/ui/widgets/model_page.py +63 -0
  63. zettcode/app/ui/widgets/model_settings_page.py +653 -0
  64. zettcode/app/ui/widgets/pet.py +247 -0
  65. zettcode/app/ui/widgets/pet_page.py +347 -0
  66. zettcode/app/ui/widgets/plugins_page.py +442 -0
  67. zettcode/app/ui/widgets/root.py +68 -0
  68. zettcode/app/ui/widgets/self_compact_page.py +363 -0
  69. zettcode/app/ui/widgets/send_kind_page.py +193 -0
  70. zettcode/app/ui/widgets/sessions_page.py +511 -0
  71. zettcode/app/ui/widgets/statusline_page.py +427 -0
  72. zettcode/app/ui/widgets/steering.py +126 -0
  73. zettcode/app/ui/widgets/transcript.py +287 -0
  74. zettcode/app/ui/widgets/update_page.py +65 -0
  75. zettcode/app/ui/widgets/welcome.py +24 -0
  76. zettcode/collector.py +54 -0
  77. zettcode/update.py +519 -0
  78. zettcode_app-0.4.2.dist-info/METADATA +26 -0
  79. zettcode_app-0.4.2.dist-info/RECORD +81 -0
  80. zettcode_app-0.4.2.dist-info/WHEEL +4 -0
  81. 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])