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,126 @@
1
+ """The gauge the header plays when the reasoning effort changes: a hairline that fills, and a glow that travels.
2
+
3
+ A frame is a value without a colour: the cells, the word, and how bright the glow is on each. The painter turns
4
+ it into spans with the theme's colours, and the switch to the top of the scale has an animation of its own in
5
+ :mod:`.shimmer`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+
14
+ #: How long the effort gauge plays after a switch. It is a function of time rather
15
+ #: than of a frame count, so it is as smooth as the loop that paints it and its
16
+ #: length is one number to tune: a switch at the top of the scale is worth a moment
17
+ #: of watching, and the gauge holds the corner for this one.
18
+ EFFORT_SECONDS = 3
19
+
20
+ #: Fraction of the second the fill takes to arrive; the rest is the glow leaving.
21
+ EFFORT_FILL_SHARE = 0.8
22
+
23
+ #: Half the width, in cells, of the glow that travels over the gauge and over the
24
+ #: word. Its brightness falls off as a cosine, so it has no edge.
25
+ EFFORT_GLOW_HALF_WIDTH = 3.5
26
+
27
+ #: Cells past either end the glow travels, so it enters and leaves rather than
28
+ #: appearing on the first cell.
29
+ EFFORT_GLOW_RUNWAY = 4
30
+
31
+ #: Fractions of the second the gauge takes to arrive and to leave. Both are short
32
+ #: and the leaving one is longer: a thing that appears should be there at once, and
33
+ #: one that goes should be allowed to go, or the header jumps back to plain text.
34
+ EFFORT_FADE_IN = 0.12
35
+ EFFORT_FADE_OUT = 0.25
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class GaugeCell:
40
+ """One cell of the effort gauge, described without a colour.
41
+
42
+ Attributes:
43
+ char: The glyph: a full rule, a half rule for the leading edge, or the
44
+ faint rail that has not been reached.
45
+ heat: Where on the scale this cell sits, ``0`` at the cheapest level and
46
+ ``1`` at the dearest; the colour ramp reads it.
47
+ glow: How much of the travelling highlight is on this cell, ``0`` to ``1``.
48
+ lit: Whether the fill has reached it; an unlit cell is rail.
49
+ """
50
+
51
+ char: str
52
+ heat: float
53
+ glow: float
54
+ lit: bool
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class EffortFrame:
59
+ """One instant of the effort gauge: its cells and the word beside them.
60
+
61
+ ``presence`` is how much of the gauge is there: it rises from nothing, holds,
62
+ and falls back to nothing, and the painter blends every colour toward the
63
+ resting header by what is missing, so the gauge fades in and out instead of
64
+ appearing and vanishing between one frame and the next.
65
+ """
66
+
67
+ cells: tuple[GaugeCell, ...]
68
+ word: str
69
+ word_heat: float
70
+ word_glow: tuple[float, ...]
71
+ presence: float
72
+
73
+
74
+ def _glow(position: float, centre: float) -> float:
75
+ """Return the highlight's brightness at a position: a cosine, zero past its reach."""
76
+ distance = abs(position - centre)
77
+ if distance >= EFFORT_GLOW_HALF_WIDTH:
78
+ return 0.0
79
+ return 0.5 * (1 + math.cos(math.pi * distance / EFFORT_GLOW_HALF_WIDTH))
80
+
81
+
82
+ def effort_frame(previous: str, current: str, levels: Sequence[str], *, progress: float) -> EffortFrame | None:
83
+ """Return the effort gauge ``progress`` of the way through a switch.
84
+
85
+ The gauge is a hairline with one cell per level. The fill eases out from the
86
+ level being left to the level chosen, so a jump of four rungs travels four
87
+ times as far as a nudge and a step down wipes back. Its colour is a property
88
+ of *where* a cell sits on the scale rather than of what is being switched to:
89
+ the cheap end is the accent, the dear end is warm, and the word beside it takes
90
+ the heat of the level it names — so the position of the fill and the colour of
91
+ the word say the same thing twice, and `ultra` is the one that is red.
92
+
93
+ A soft highlight travels over the gauge and then over the word, a cosine of its
94
+ distance rather than a window with edges, which is what keeps it from looking
95
+ like a progress bar.
96
+
97
+ Args:
98
+ previous: Level the switch came from; an unknown name starts from empty.
99
+ current: Level being reached; an unknown name answers ``None``.
100
+ levels: The configured levels, cheapest first.
101
+ progress: ``0`` at the start through ``1`` at the end; outside that range
102
+ there is nothing to draw and the answer is ``None``, which is the
103
+ caller's cue to release the frame loop.
104
+
105
+ Returns:
106
+ The cells and the word, or ``None`` when there is nothing to animate.
107
+ """
108
+ if not 0.0 <= progress < 1.0 or not levels or current not in levels:
109
+ return None
110
+ count = len(levels)
111
+ target = levels.index(current)
112
+ source = levels.index(previous) if previous in levels else -1
113
+ arrived = 1 - (1 - min(1.0, progress / EFFORT_FILL_SHARE)) ** 3
114
+ reach = (source + 1) + (target - source) * arrived
115
+ span = count + 2 * EFFORT_GLOW_RUNWAY + len(current) + 1
116
+ sweep = -EFFORT_GLOW_RUNWAY + progress * span
117
+ last = max(1, count - 1)
118
+ cells = []
119
+ for index in range(count):
120
+ fill = max(0.0, min(1.0, reach - index))
121
+ lit = fill > 0.45
122
+ char = "\u2501" if fill >= 1.0 else "\u2578" if lit else "\u2500"
123
+ cells.append(GaugeCell(char, index / last, _glow(index, sweep) if lit else 0.0, lit))
124
+ word_glow = tuple(_glow(count + 1 + offset, sweep) for offset in range(len(current)))
125
+ presence = min(1.0, progress / EFFORT_FADE_IN, (1.0 - progress) / EFFORT_FADE_OUT)
126
+ return EffortFrame(tuple(cells), current, target / last, word_glow, max(0.0, presence))
@@ -0,0 +1,265 @@
1
+ """Project agent events into transcript entries.
2
+
3
+ The agent's events are translated once, by :func:`zettcode.sdk.translation.translate`, into the SDK's typed events,
4
+ and this module draws those. A program that drives the agent through the SDK reads the same events, so what the
5
+ terminal shows and what the SDK reports cannot drift apart.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from collections.abc import Callable
12
+
13
+ from zett_agent.dispatcher import AgentEventDispatcher
14
+ from zett_agent.events import AgentEvent
15
+
16
+ from zettcode.engine.dialogue.ask import declined_answer
17
+ from zettcode.messaging.relayed import Relayed
18
+ from zettcode.plugin_api.entries import EntryStatus
19
+ from zettcode.sdk.events import (
20
+ AnyEvent,
21
+ ApprovalRequested,
22
+ CompactionDelta,
23
+ CompactionFinished,
24
+ CompactionStarted,
25
+ Finished,
26
+ ModelStopped,
27
+ Notice,
28
+ QuestionAsked,
29
+ ReasoningDelta,
30
+ ReasoningFinished,
31
+ ReasoningStarted,
32
+ ServerToolFinished,
33
+ ServerToolStarted,
34
+ Steered,
35
+ SteeringFinished,
36
+ SteeringInterrupted,
37
+ TextDelta,
38
+ ToolFinished,
39
+ ToolStarted,
40
+ UsageUpdated,
41
+ )
42
+ from zettcode.sdk.translation import Bridge, translate
43
+ from zettcode.sdk.types import Usage
44
+
45
+ from .stops import stop_text
46
+ from .transcript import Transcript
47
+
48
+
49
+ class _NobodyAnswers(Bridge):
50
+ """The bridge of a projector that was given nobody to answer for: an answer sent through it goes nowhere."""
51
+
52
+ def approve(self, session_id: str, call_id: str, approve: bool, remember: bool) -> None:
53
+ raise RuntimeError("This projector has no agent to carry an approval to")
54
+
55
+ def answer(self, question: object, text: str | None) -> None:
56
+ raise RuntimeError("This projector has no agent to carry an answer to")
57
+
58
+
59
+ logger = logging.getLogger("zettcode.terminal")
60
+
61
+
62
+ class TranscriptProjector(AgentEventDispatcher):
63
+ """Turn AgentClient callbacks into transcript blocks.
64
+
65
+ Every callback returns immediately. The approval callback in particular must
66
+ not await the user: the agent is already suspended waiting for the response
67
+ that the UI will emit later.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ transcript: Transcript,
73
+ *,
74
+ bridge: Bridge | None = None,
75
+ on_approval: Callable[[ApprovalRequested], None] | None = None,
76
+ on_ask: Callable[[QuestionAsked], None] | None = None,
77
+ on_usage: Callable[[Usage, str], None] | None = None,
78
+ on_steering_started: Callable[[str], Relayed | str | None] | None = None,
79
+ on_steering_interrupted: Callable[[str], None] | None = None,
80
+ side_running: Callable[[], bool] | None = None,
81
+ ) -> None:
82
+ """Send projected events to ``transcript``; approvals go to ``on_approval``.
83
+
84
+ Args:
85
+ transcript: Model that receives the projected entries.
86
+ bridge: How an answer to an approval or a question reaches the suspended agent
87
+ (:class:`zettcode.sdk.translation.AgentBridge` over the shell's agent).
88
+ on_approval: Called synchronously for a shell approval request; the
89
+ callback must not await, because the agent is suspended until the
90
+ UI answers it later.
91
+ on_ask: Called synchronously with each question the model asks; the
92
+ callback must not await, for the same reason.
93
+ on_usage: Called synchronously with each cumulative usage update and the session it is for, so
94
+ the shell can refresh the status line without polling the store.
95
+ on_steering_started: Called with the text of a queued steering
96
+ message the agent just adopted, so the shell can drop it from
97
+ the pending queue before the row is echoed. What it returns, when
98
+ it is a :class:`~zettcode.messaging.relayed.Relayed`, is drawn as that session's
99
+ message — what its sender wrote, not the line the model reads; a
100
+ string is drawn as the row's text.
101
+ on_steering_interrupted: Called with the text of a steering message
102
+ a newer one superseded, so the shell can drop it too.
103
+ side_running: Asked whether the run in flight is a side question — a question the model answers without
104
+ the exchange joining the conversation. Such a run is drawn only when the shell began it, so a
105
+ plugin that asks one for itself (a recap) is not also drawn as an answer of its own. ``None``
106
+ treats every run as one the shell began. Two plain flags are enough here because the SDK runs one
107
+ request at a time: a second run is refused while one is in flight, so a plugin's ask cannot
108
+ overlap a turn the shell began and a stale answer cannot be drawn as a turn's.
109
+ """
110
+ self.transcript = transcript
111
+ self.bridge: Bridge = bridge if bridge is not None else _NobodyAnswers()
112
+ self.on_approval = on_approval
113
+ self.on_ask = on_ask
114
+ self.on_usage = on_usage
115
+ self.on_steering_started = on_steering_started
116
+ self.on_steering_interrupted = on_steering_interrupted
117
+ #: What a hosted tool's input has streamed so far, by call. The translation fills it as fragments arrive
118
+ #: and reads the query out of it when the call reports back.
119
+ self._hosted: dict[str, str] = {}
120
+ #: Whether the shell began the run in flight, and so draws it.
121
+ self._begun = False
122
+ #: Whether a run nobody began has been reported as left out, so dropping one is a line and not one per
123
+ #: token; it is cleared when a turn is begun, so it reports the next run that way too.
124
+ self._reported_side = False
125
+ self._side_running = side_running
126
+
127
+ def begin_turn(self, prompt: str, *, side: bool = False, relayed: Relayed | None = None) -> None:
128
+ """Begin drawing one run into the transcript, echoing the prompt that asked for it.
129
+
130
+ The shell begins a turn before every run it shows on screen, which is also how a side question it asked
131
+ is told apart from one a plugin asked for itself: see ``side_running``.
132
+
133
+ Args:
134
+ prompt: What the reader asked, echoed as the turn's first row.
135
+ side: The question is a side one, which the transcript shows as such.
136
+ relayed: The turn answers a message another session sent, which the transcript shows as that.
137
+ """
138
+ # The row first, the flag second: an echo that raises leaves no run to draw, and a flag set before it would
139
+ # stay set, drawing every later side question as an answer of its own.
140
+ if relayed is None:
141
+ self.transcript.begin_turn(prompt, side=side)
142
+ else:
143
+ self.transcript.begin_turn(prompt, side=side, relayed=relayed)
144
+ self._begun = True
145
+ self._reported_side = False
146
+
147
+ def end_turn(self) -> None:
148
+ """Stop drawing, once the run the shell began has ended.
149
+
150
+ Nothing of whatever runs next is drawn until a turn is begun for it, which is what keeps a plugin's own
151
+ side question out of the transcript between turns.
152
+ """
153
+ self._begun = False
154
+
155
+ async def dispatch(self, event: AgentEvent) -> None:
156
+ """Note that the agent spoke, then draw what the event stands for.
157
+
158
+ Every event ends a silence, whichever it is: the waiting row counts how long it has been since the model
159
+ last said anything, and has to be told each time it does.
160
+
161
+ A side question the shell did not begin is left out: its answer would land under the last turn's line as a
162
+ reply nobody asked for, and whoever asked for it — a plugin's recap — draws the part it wants itself. The
163
+ drop is logged, a line rather than one per token, so an answer that is missing from the transcript can be
164
+ explained from the log.
165
+ """
166
+ self.transcript.heard()
167
+ if not self._begun and self._side_running is not None and self._side_running():
168
+ if not self._reported_side:
169
+ self._reported_side = True
170
+ logger.debug("projector.side_not_drawn", extra={"session": event.session_id})
171
+ return
172
+ for translated in translate(event, self.bridge, self._hosted):
173
+ self.project(translated, event)
174
+
175
+ def project(self, event: AnyEvent, source: AgentEvent | None = None) -> None:
176
+ """Draw one SDK event.
177
+
178
+ Args:
179
+ event: The event, as the SDK reports it.
180
+ source: The agent event it came from, for the few details the SDK's event leaves out.
181
+ """
182
+ transcript = self.transcript
183
+ match event:
184
+ case TextDelta(text=text):
185
+ # Closes reasoning before the first answer token lands in the transcript.
186
+ transcript.complete_thinking()
187
+ transcript.append_answer(text)
188
+ case ReasoningStarted():
189
+ transcript.start_thinking()
190
+ case ReasoningDelta(text=text):
191
+ transcript.append_thinking(text)
192
+ case ReasoningFinished():
193
+ transcript.complete_thinking()
194
+ case ToolStarted(call_id=call_id, name=name, arguments=arguments):
195
+ logger.debug("tool.start", extra={"tool": name, "call": call_id})
196
+ transcript.start_tool(call_id, name, arguments)
197
+ case ToolFinished(call_id=call_id, name=name, output=output, ok=ok, skipped=skipped):
198
+ logger.debug("tool.finish", extra={"tool": name, "call": call_id, "ok": ok, "skipped": skipped})
199
+ self._tool_finished(call_id, name, output, ok, skipped)
200
+ case ServerToolStarted(call_id=call_id, name=name, arguments=arguments):
201
+ transcript.start_tool(call_id, name, arguments)
202
+ case ServerToolFinished(call_id=call_id, arguments=arguments, output=output, ok=ok):
203
+ if arguments:
204
+ # The row opened before its input had streamed in; now it knows what was searched for.
205
+ transcript.restate_tool(call_id, arguments)
206
+ status = EntryStatus.COMPLETED if ok else EntryStatus.FAILED
207
+ transcript.complete_tool(call_id, output or "Completed", status=status)
208
+ case CompactionStarted():
209
+ transcript.start_compaction()
210
+ case CompactionDelta(text=text):
211
+ transcript.append_compaction(text)
212
+ case CompactionFinished(applied=applied):
213
+ transcript.complete_compaction()
214
+ transcript.notice(f"Context compaction {'applied' if applied else 'skipped'}")
215
+ case ModelStopped():
216
+ # The event follows the step's completion and is sent only for an abnormal ending, so the reply
217
+ # above it is whatever the model managed to write, and this line is what stands between it and
218
+ # being taken for an answer.
219
+ transcript.complete_thinking()
220
+ stop = source.stop if source is not None else None
221
+ if stop is not None and not stop.ok:
222
+ transcript.error(stop_text(stop))
223
+ case Steered(text=text):
224
+ adopted = self.on_steering_started(text) if self.on_steering_started is not None else None
225
+ if isinstance(adopted, Relayed):
226
+ transcript.begin_turn(adopted.text, relayed=adopted)
227
+ else:
228
+ transcript.begin_turn(adopted or text)
229
+ case SteeringFinished():
230
+ transcript.complete_thinking()
231
+ case SteeringInterrupted(text=text):
232
+ if self.on_steering_interrupted is not None:
233
+ self.on_steering_interrupted(text)
234
+ transcript.complete_thinking()
235
+ transcript.notice("steering superseded by a newer message")
236
+ case Finished():
237
+ transcript.complete_thinking()
238
+ case ApprovalRequested() if self.on_approval is not None:
239
+ self.on_approval(event)
240
+ case QuestionAsked() if self.on_ask is not None:
241
+ self.on_ask(event)
242
+ case UsageUpdated(session_id=session_id, usage=usage) if self.on_usage is not None:
243
+ self.on_usage(usage, session_id)
244
+ case Notice(text=text):
245
+ # News about an MCP server or its configuration. It is a remark in the conversation,
246
+ # not an error: the request goes on without whatever could not be reached.
247
+ transcript.notice(text)
248
+ case _:
249
+ pass
250
+
251
+ def _tool_finished(self, call_id: str, name: str, output: str, ok: bool, skipped: bool) -> None:
252
+ """Attach a tool's ending to its row: skipped, failed, or done.
253
+
254
+ A question the reader cancelled comes back as a result, not a failure: the model reads that it was declined
255
+ and carries on. The row is marked skipped so it does not look like a question that was answered.
256
+ """
257
+ if skipped:
258
+ self.transcript.complete_tool(call_id, output or "Skipped", status=EntryStatus.SKIPPED)
259
+ elif not ok:
260
+ self.transcript.complete_tool(call_id, output or "Tool failed", status=EntryStatus.FAILED)
261
+ else:
262
+ declined = name == "ask_user" and declined_answer(output)
263
+ self.transcript.complete_tool(
264
+ call_id, output or "Completed", status=EntryStatus.SKIPPED if declined else EntryStatus.COMPLETED
265
+ )