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
zettcode/app/ui/shell.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""The state the shell's parts share.
|
|
2
|
+
|
|
3
|
+
:class:`~zettcode.app.ui.app.ZettCodeApp` is assembled from mixins so the class
|
|
4
|
+
itself stays the wiring and the turn loop; each mixin owns one concern — the
|
|
5
|
+
two rows, the keys, the sessions, the notices — and every one of them reads
|
|
6
|
+
the same attributes. This module is that shared surface: annotations plus the
|
|
7
|
+
signatures a part may call on another, never behaviour, so a mixin type-checks
|
|
8
|
+
on its own and the runtime class stays the only implementation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
#: The animation token the effort gauge registers: one token, held for its second
|
|
17
|
+
#: and released when the gauge is over, so the frame loop goes back to sleep.
|
|
18
|
+
EFFORT_TOKEN = "effort"
|
|
19
|
+
|
|
20
|
+
#: How long a flash stays in the row above the composer: long enough to read a
|
|
21
|
+
#: count of characters, short enough that the next thing typed is not typed into
|
|
22
|
+
#: something the reader is still reading.
|
|
23
|
+
FLASH_SECONDS = 1.5
|
|
24
|
+
|
|
25
|
+
#: What the conversation says when a request ends having produced nothing: the model was asked and sent back an
|
|
26
|
+
#: empty reply, which the stream reports as a request that completed.
|
|
27
|
+
EMPTY_REPLY = "The model sent back an empty reply. Try again, or check the model and address with /config."
|
|
28
|
+
|
|
29
|
+
#: What a machine with no ``[[models]]`` entry says for itself. The shell starts anyway — a reader can look through
|
|
30
|
+
#: sessions, change the theme and set a model up — so the line that opens the conversation names the one command
|
|
31
|
+
#: that turns it into a working agent, rather than a start that exits with a message about a file.
|
|
32
|
+
NO_MODEL_START = (
|
|
33
|
+
"no model is configured yet \u2014 /config adds one (a provider, a model id, and a token); "
|
|
34
|
+
"nothing else here waits for it"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
#: What a prompt typed on such a machine is answered with. It is not an error in the program but in the setup, so
|
|
38
|
+
#: the sentence says how to finish the setup, and the draft is kept so nothing has to be typed again.
|
|
39
|
+
NO_MODEL_ASKED = "no model is configured yet \u2014 /config adds one, then ask again; your words are still in the box"
|
|
40
|
+
|
|
41
|
+
#: What ``/btw`` says instead, and the difference is the box: a command is submitted and the composer cleared
|
|
42
|
+
#: before its handler runs, so the prompt's "your words are still in the box" would be false there.
|
|
43
|
+
NO_MODEL_QUESTION = "no model is configured yet \u2014 /config adds one; /btw asks it once there is one"
|
|
44
|
+
|
|
45
|
+
#: Screen name used for a page a command presented; the shell checks it to know
|
|
46
|
+
#: that Ctrl-C and Ctrl-D belong to the page rather than the composer.
|
|
47
|
+
PAGE_SCREEN = "page"
|
|
48
|
+
|
|
49
|
+
#: Screen name of a question panel. It has its own name because the questions
|
|
50
|
+
#: stack: the model may ask several in one response, and each has to be answered
|
|
51
|
+
#: before the run continues, so the shell needs to tell them from other pages.
|
|
52
|
+
ASK_SCREEN = "ask"
|
|
53
|
+
|
|
54
|
+
#: Rows the approval panel takes from the bottom of the screen: the question,
|
|
55
|
+
#: up to three lines of command, and the numbered choices.
|
|
56
|
+
APPROVAL_ROWS = 16
|
|
57
|
+
|
|
58
|
+
#: Rows the "a newer release is out" panel takes: the title, the line saying which index it installs from, the
|
|
59
|
+
#: three rows, and the hint under them.
|
|
60
|
+
UPDATE_ROWS = 9
|
|
61
|
+
|
|
62
|
+
#: Steering messages one run accepts; the shell caps what a reader can queue
|
|
63
|
+
#: for a single request, and the count resets when a new run starts.
|
|
64
|
+
MAX_STEERING = 8
|
|
65
|
+
|
|
66
|
+
#: Follow-up messages that may wait at once. They are held by the shell and each takes a row
|
|
67
|
+
#: above the composer, so the list is bounded the way the steering one is.
|
|
68
|
+
MAX_FOLLOW_UPS = 8
|
|
69
|
+
|
|
70
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only, so the imports stay lazy
|
|
71
|
+
import asyncio
|
|
72
|
+
from collections.abc import Sequence
|
|
73
|
+
|
|
74
|
+
from zettcode.mentions import MentionRegistry
|
|
75
|
+
from zettcode.messaging.relayed import Relayed
|
|
76
|
+
from zettcode.plugin_api.commands import Command
|
|
77
|
+
from zettcode.plugin_api.pets import Pet
|
|
78
|
+
from zettcode.plugin_api.state import Activity, UiRow
|
|
79
|
+
from zettcode.prompt import PromptPart
|
|
80
|
+
from zettcode.sdk.client import Client
|
|
81
|
+
from zettcode.sdk.events import QuestionAsked
|
|
82
|
+
from zettcode.sdk.types import ModelInfo, Usage
|
|
83
|
+
from zettcode.tui import CompletionPopup, StatusBar, Theme, TuiApp
|
|
84
|
+
from zettcode.update import UpdateState
|
|
85
|
+
|
|
86
|
+
from ..agent.agent import SessionLink, TerminalAgent
|
|
87
|
+
from ..agent.conversation.effort_gauge import EffortFrame
|
|
88
|
+
from ..agent.conversation.projection import TranscriptProjector
|
|
89
|
+
from ..agent.conversation.shimmer import ShimmerFrame
|
|
90
|
+
from ..agent.conversation.transcript import Transcript
|
|
91
|
+
from .followups import FollowUp
|
|
92
|
+
from .gc_policy import CollectorPolicy
|
|
93
|
+
from .widgets import Composer, FollowUpQueue, PetView, SteeringQueue, TranscriptView, ZettCodeRoot
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ShellState:
|
|
97
|
+
"""What one part of the shell may read from the others.
|
|
98
|
+
|
|
99
|
+
The attributes are set by :meth:`ZettCodeApp.__init__` and the methods are
|
|
100
|
+
implemented by one of the mixins; a mixin inherits this class only to be
|
|
101
|
+
type-checked against the surface it shares with the rest. The one method
|
|
102
|
+
implemented here is the wait every part of the shell makes before it asks the
|
|
103
|
+
client for a run.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
#: The application agent: the only channel to the conversation. The shell sets it once; a mixin reads it.
|
|
107
|
+
@property
|
|
108
|
+
def agent(self) -> TerminalAgent:
|
|
109
|
+
raise NotImplementedError("the shell sets its agent; a mixin only reads it")
|
|
110
|
+
|
|
111
|
+
#: The SDK client over that agent: what the shell asks of the agent goes through it as it moves off the agent.
|
|
112
|
+
@property
|
|
113
|
+
def client(self) -> Client:
|
|
114
|
+
raise NotImplementedError("the shell sets its client; a mixin only reads it")
|
|
115
|
+
|
|
116
|
+
#: The mounted widget tree and the host services it offers.
|
|
117
|
+
app: TuiApp
|
|
118
|
+
|
|
119
|
+
#: The conversation model every view reads.
|
|
120
|
+
@property
|
|
121
|
+
def transcript(self) -> Transcript:
|
|
122
|
+
raise NotImplementedError("the shell sets its transcript; a mixin only reads it")
|
|
123
|
+
|
|
124
|
+
#: The scrollable transcript, and the composer with its completion menu.
|
|
125
|
+
view: TranscriptView
|
|
126
|
+
composer: Composer
|
|
127
|
+
completions: CompletionPopup
|
|
128
|
+
#: Messages queued to steer the running turn, and the ones held to follow it.
|
|
129
|
+
steering: SteeringQueue
|
|
130
|
+
follow_ups: FollowUpQueue
|
|
131
|
+
#: The two rows the plugin segments fill.
|
|
132
|
+
header: StatusBar
|
|
133
|
+
status: StatusBar
|
|
134
|
+
#: The event projector that fills the transcript, and the root widget.
|
|
135
|
+
projector: TranscriptProjector
|
|
136
|
+
root: ZettCodeRoot
|
|
137
|
+
#: The line to the other sessions, or ``None`` when sessions are switched off.
|
|
138
|
+
messenger: SessionLink | None
|
|
139
|
+
#: The merged slash commands and the ``@`` resource registry.
|
|
140
|
+
commands: tuple[Command, ...]
|
|
141
|
+
mentions: MentionRegistry
|
|
142
|
+
|
|
143
|
+
async def _wait_for_client(self) -> None:
|
|
144
|
+
"""Wait for a run the shell did not start to give the client back.
|
|
145
|
+
|
|
146
|
+
A plugin that asks the model for itself — a recap, a notification — takes the client's one run slot and the
|
|
147
|
+
shell's busy flag knows nothing about it. A turn, a side question, a continue or a fork that started in that
|
|
148
|
+
window was refused with "a run is already in progress on this client", which a reader saw most often when
|
|
149
|
+
another session's message arrived while a recap was being written. The reader asked for the work either way, so
|
|
150
|
+
it waits for the slot; the ask it waits for is one model call, and Ctrl-C stops the wait with everything else.
|
|
151
|
+
|
|
152
|
+
Two things it is not for. A rename: `Session.rename` checks the slot only when the name would switch
|
|
153
|
+
conversations, so a title beside a run needs nothing from here. And a request of the shell's own: a command
|
|
154
|
+
taken while one runs would hang.
|
|
155
|
+
"""
|
|
156
|
+
while self.client.busy:
|
|
157
|
+
await asyncio.sleep(0.05)
|
|
158
|
+
|
|
159
|
+
#: The palette in force before the ``/theme`` menu started showing others, or ``None`` when nothing is previewed.
|
|
160
|
+
_theme_before: Theme | None
|
|
161
|
+
#: The pet in front of the one the ``/pet`` menu is previewing, or ``None`` when nothing is previewed.
|
|
162
|
+
_pet_before: Pet | None
|
|
163
|
+
#: The pet over the conversation.
|
|
164
|
+
pet: PetView
|
|
165
|
+
#: The command whose menu is showing its words as it is walked (see ``Command.preview``).
|
|
166
|
+
_previewing: Command | None
|
|
167
|
+
#: The plugin rows, in paint order.
|
|
168
|
+
_plugin_rows: tuple[UiRow, ...]
|
|
169
|
+
#: What the status line shows: the shell's state and a one-off note.
|
|
170
|
+
_activity: Activity
|
|
171
|
+
_note: str | None
|
|
172
|
+
#: The active session's title, if the store has one.
|
|
173
|
+
_session_title: str | None
|
|
174
|
+
#: Cumulative token counters for the active session.
|
|
175
|
+
_usage: Usage
|
|
176
|
+
#: Whether a turn or a command is in flight.
|
|
177
|
+
_busy: bool
|
|
178
|
+
#: When the garbage collector may run: while work is in flight, and not while the reader types.
|
|
179
|
+
collector: CollectorPolicy
|
|
180
|
+
#: Steering messages queued for this run, and how many were queued in all.
|
|
181
|
+
_steering: list[str]
|
|
182
|
+
#: What each queued steering message was made of, in step with :attr:`_steering`: the text the
|
|
183
|
+
#: agent will report it by when it adopts it, and the parts it was sent as.
|
|
184
|
+
_steering_meta: list[tuple[str, tuple[PromptPart, ...]]]
|
|
185
|
+
_steering_sent: int
|
|
186
|
+
#: The follow-ups waiting in send order, the highlighted one, and the slot a message
|
|
187
|
+
#: taken out for editing will return to.
|
|
188
|
+
_follow_ups: list[FollowUp]
|
|
189
|
+
_follow_up_selected: int | None
|
|
190
|
+
_editing_slot: int | None
|
|
191
|
+
#: Whether the list is held: a request was stopped or failed with follow-ups waiting, and
|
|
192
|
+
#: no reply has completed since.
|
|
193
|
+
_follow_ups_held: bool
|
|
194
|
+
#: Whether the work in flight is a turn, which can be steered, rather than a command.
|
|
195
|
+
_turn_running: bool
|
|
196
|
+
#: Whether a command handed over work that is still being awaited (`CommandResult.run`).
|
|
197
|
+
_command_working: bool
|
|
198
|
+
#: The in-flight request that names a session, if any.
|
|
199
|
+
_title_task: asyncio.Task[None] | None
|
|
200
|
+
#: The in-flight re-measure of a context figure, if any.
|
|
201
|
+
_measure_task: asyncio.Task[None] | None
|
|
202
|
+
#: The in-flight release check or upgrade, if any.
|
|
203
|
+
_update_task: asyncio.Task[None] | None
|
|
204
|
+
#: Whether an upgrade is installing now: the composer takes nothing until it is done, since the code it would
|
|
205
|
+
#: run is being replaced under it.
|
|
206
|
+
_updating: bool
|
|
207
|
+
#: A short-lived message for the row above the composer: its text and the
|
|
208
|
+
#: moment it stops being true.
|
|
209
|
+
_flash: tuple[str, float, str] | None
|
|
210
|
+
#: The effort switch being animated: the level it came from, the level it is
|
|
211
|
+
#: reaching, and the moment it started.
|
|
212
|
+
_effort_animation: tuple[str, str, float] | None
|
|
213
|
+
#: The question the model is waiting on, while its panel is up.
|
|
214
|
+
_asks: list[QuestionAsked]
|
|
215
|
+
#: The turn or command currently in flight, if any.
|
|
216
|
+
_task: asyncio.Task[None] | None
|
|
217
|
+
|
|
218
|
+
def close_page(self) -> None:
|
|
219
|
+
"""Close the page a command presented."""
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def model(self) -> ModelInfo | None:
|
|
223
|
+
"""Return the model the next request would use, or ``None`` on a machine with none configured yet.
|
|
224
|
+
|
|
225
|
+
The shell paints with ``None`` — the header says ``no model``, the counters have no window to measure
|
|
226
|
+
against — and only a request insists on one. :attr:`client.model` is that insisting reader.
|
|
227
|
+
"""
|
|
228
|
+
return self.client.active_model
|
|
229
|
+
|
|
230
|
+
def change_model(self, name: ModelInfo | str) -> ModelInfo:
|
|
231
|
+
"""Switch models and announce the change in the transcript."""
|
|
232
|
+
raise NotImplementedError
|
|
233
|
+
|
|
234
|
+
def _warm_runtime(self) -> None:
|
|
235
|
+
"""Start the runtime's provider and imports behind the frame, once there is a model to build one from."""
|
|
236
|
+
raise NotImplementedError
|
|
237
|
+
|
|
238
|
+
async def resume_session(self, session_id: str) -> None:
|
|
239
|
+
"""Restore a stored session behind the interface rather than in front of it."""
|
|
240
|
+
|
|
241
|
+
def _set_busy(self, busy: bool) -> None:
|
|
242
|
+
"""Track in-flight work and keep the frame loop alive while it runs."""
|
|
243
|
+
|
|
244
|
+
def _start_next_command(self) -> None:
|
|
245
|
+
"""Run the command that was typed while the finished request was busy."""
|
|
246
|
+
|
|
247
|
+
def _return_follow_ups_to_draft(self) -> None:
|
|
248
|
+
"""Hand the held follow-ups back as text, because their conversation is over."""
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def _follow_up_selecting(self) -> bool:
|
|
252
|
+
"""Return whether a follow-up is highlighted."""
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
def _select_follow_up(self, step: int) -> None:
|
|
256
|
+
"""Move the follow-up highlight."""
|
|
257
|
+
|
|
258
|
+
def _deselect_follow_up(self) -> None:
|
|
259
|
+
"""Take the follow-up highlight off."""
|
|
260
|
+
|
|
261
|
+
def _move_follow_up(self, step: int) -> None:
|
|
262
|
+
"""Swap the highlighted follow-up with its neighbour."""
|
|
263
|
+
|
|
264
|
+
def _edit_follow_up(self) -> bool:
|
|
265
|
+
"""Take the highlighted follow-up out and into the composer."""
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
def _delete_follow_up(self) -> None:
|
|
269
|
+
"""Remove the highlighted follow-up from the list."""
|
|
270
|
+
|
|
271
|
+
def _steer_follow_up(self) -> bool:
|
|
272
|
+
"""Send the highlighted follow-up to the running request as steering."""
|
|
273
|
+
raise NotImplementedError
|
|
274
|
+
|
|
275
|
+
def _queue_steering(self, value: str, key: str | None = None, parts: Sequence[PromptPart] = ()) -> None:
|
|
276
|
+
"""Show one message waiting for the agent to adopt it."""
|
|
277
|
+
|
|
278
|
+
def _clear_steering(self) -> None:
|
|
279
|
+
"""Drop the steering list."""
|
|
280
|
+
|
|
281
|
+
def _refuse_busy(self) -> None:
|
|
282
|
+
"""Tell the reader a draft cannot be taken while a request runs."""
|
|
283
|
+
|
|
284
|
+
def _refresh_completions(self) -> None:
|
|
285
|
+
"""Mirror the composer's slash-command candidates into the menu."""
|
|
286
|
+
|
|
287
|
+
async def _run_prompt(
|
|
288
|
+
self,
|
|
289
|
+
prompt: str,
|
|
290
|
+
parts: Sequence[PromptPart] = (),
|
|
291
|
+
*,
|
|
292
|
+
instructions: str | None = None,
|
|
293
|
+
relayed: Relayed | None = None,
|
|
294
|
+
advance: bool = True,
|
|
295
|
+
) -> None:
|
|
296
|
+
"""Stream one agent turn."""
|
|
297
|
+
|
|
298
|
+
def _sync_messaging(self) -> None:
|
|
299
|
+
"""Tell the other sessions what this one is now."""
|
|
300
|
+
|
|
301
|
+
def _sync_lock(self) -> None:
|
|
302
|
+
"""Make the composer and the messaging agree with who holds the active session's lock."""
|
|
303
|
+
|
|
304
|
+
def _start_next_relayed(self) -> bool:
|
|
305
|
+
"""Start the oldest message another session sent, if one waits and nothing runs."""
|
|
306
|
+
return False
|
|
307
|
+
|
|
308
|
+
def _steer_blocked(self) -> str | None:
|
|
309
|
+
"""Return why the work in flight cannot be steered, or ``None`` when it can."""
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
def _settle_relayed_steering(self) -> None:
|
|
313
|
+
"""Hand messages a finished request never adopted to the inbox, so they still get a turn."""
|
|
314
|
+
|
|
315
|
+
async def run_continue(self) -> None:
|
|
316
|
+
"""Pick up the interrupted request where it stopped."""
|
|
317
|
+
|
|
318
|
+
def submit(self, value: str, *, command: bool | None = None) -> bool | None:
|
|
319
|
+
"""Start a turn, steer the running one, or run a slash command (``command``: whether the draft is one)."""
|
|
320
|
+
|
|
321
|
+
async def prompt(self, text: str, *, shown: str | None = None, cache: str | None = None) -> bool:
|
|
322
|
+
"""Run ``text`` as one turn, as if the reader had typed it; whether one was started and has ended."""
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
def stop_working_command(self) -> bool:
|
|
326
|
+
"""Stop a command that handed over work, as Escape does; whether one was working."""
|
|
327
|
+
return False
|
|
328
|
+
|
|
329
|
+
def flash(self, message: str, *, seconds: float = FLASH_SECONDS, level: str = "info") -> None:
|
|
330
|
+
"""Say something in the row above the composer, for a few seconds."""
|
|
331
|
+
|
|
332
|
+
def change_permission(self, name: str, *, remember: bool = True) -> str:
|
|
333
|
+
"""Set how much of the shell may run unasked, and announce the change."""
|
|
334
|
+
return name
|
|
335
|
+
|
|
336
|
+
def start_effort_animation(self, previous: str, current: str) -> None:
|
|
337
|
+
"""Animate the effort slot of the header for a second."""
|
|
338
|
+
|
|
339
|
+
def _effort_gauge(self) -> EffortFrame | ShimmerFrame | None:
|
|
340
|
+
"""Return the effort gauge for this paint, or None when the switch is over."""
|
|
341
|
+
|
|
342
|
+
def settle_effort_animation(self) -> None:
|
|
343
|
+
"""Repaint while an effort switch animates, and release its token at the end."""
|
|
344
|
+
|
|
345
|
+
def notify(self, message: str, *, level: str = "info") -> None:
|
|
346
|
+
"""Show a one-line toast."""
|
|
347
|
+
|
|
348
|
+
def _remeasure_context_later(self) -> None:
|
|
349
|
+
"""Measure the context again, when a figure needs replacing."""
|
|
350
|
+
|
|
351
|
+
def _cancel_ask(self, question: QuestionAsked) -> None:
|
|
352
|
+
"""Decline one question the model is waiting on, and close its panel."""
|
|
353
|
+
|
|
354
|
+
def start_update_check(self) -> asyncio.Task[None] | None:
|
|
355
|
+
"""Ask the index about newer releases in the background."""
|
|
356
|
+
|
|
357
|
+
def offer_update(self) -> UpdateState | None:
|
|
358
|
+
"""Show the panel a stored newer version asks for, if there is one."""
|
|
359
|
+
|
|
360
|
+
def _refresh_tasks(self) -> None:
|
|
361
|
+
"""Mirror the agent's plan into the panel above the composer."""
|
zettcode/app/ui/turns.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""What the shell hands the SDK to run a turn, and how it words a turn that failed.
|
|
2
|
+
|
|
3
|
+
The composer's draft is a list of parts (text, pictures, and ``@`` references as chips); the SDK takes text and
|
|
4
|
+
pictures and resolves the references itself, with the registry the composer completed them from. A failed run reaches
|
|
5
|
+
the shell as an SDK error whose message is already worded, so the only thing left to add is whether trying again may
|
|
6
|
+
help.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Collection, Sequence
|
|
12
|
+
|
|
13
|
+
from zettcode.error_text import CONTINUE_HINT, failure_text
|
|
14
|
+
from zettcode.plugin_api.state import TurnContext, TurnSegment
|
|
15
|
+
from zettcode.prompt import MentionPart, PromptPart
|
|
16
|
+
from zettcode.sdk.errors import RunError, SessionBusyError
|
|
17
|
+
from zettcode.sdk.events import AnyEvent, ToolFinished, ToolStarted
|
|
18
|
+
from zettcode.sdk.types import Image, TaskInfo, Usage
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"RUN_IN_PROGRESS",
|
|
22
|
+
"TurnTally",
|
|
23
|
+
"failure_line",
|
|
24
|
+
"plan_rows",
|
|
25
|
+
"prompt_pieces",
|
|
26
|
+
"turn_parts",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TurnTally:
|
|
31
|
+
"""What one request spent, kept while it runs so the line it ends on can say.
|
|
32
|
+
|
|
33
|
+
The tokens and model calls are the session's usage after the request less its usage before, so they are right
|
|
34
|
+
for a request that failed or was stopped as well (the calls that finished are counted). The tool calls are counted
|
|
35
|
+
from the events, because the session keeps no per-request tally of them. It holds numbers; the items of the
|
|
36
|
+
line (``turn_parts``) say how they read.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, before: Usage) -> None:
|
|
40
|
+
"""Start counting from the session's usage as it is now."""
|
|
41
|
+
self._before = before
|
|
42
|
+
self.tools = 0
|
|
43
|
+
self.failed = 0
|
|
44
|
+
|
|
45
|
+
def see(self, event: AnyEvent) -> None:
|
|
46
|
+
"""Count a tool the model started, and one that failed (a question the reader cancelled is skipped, not failed)."""
|
|
47
|
+
if isinstance(event, ToolStarted):
|
|
48
|
+
self.tools += 1
|
|
49
|
+
elif isinstance(event, ToolFinished) and not event.ok and not event.skipped:
|
|
50
|
+
self.failed += 1
|
|
51
|
+
|
|
52
|
+
def context(self, now: Usage, *, label: str, seconds: float, clock: str) -> TurnContext:
|
|
53
|
+
"""Return what the request came to, given the session's usage now, how it ended, how long it took and when."""
|
|
54
|
+
return TurnContext(label, seconds, clock, now.since(self._before), self.tools, self.failed)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def turn_parts(segments: Sequence[TurnSegment], context: TurnContext, hidden: Collection[str]) -> tuple[str, ...]:
|
|
58
|
+
"""Return the items of the line a request ends on: each shown segment's text, in order.
|
|
59
|
+
|
|
60
|
+
A segment the reader switched off is not asked. One that has nothing to say, or that raises, is left out: a
|
|
61
|
+
plugin's mistake costs its own item and never the line.
|
|
62
|
+
"""
|
|
63
|
+
parts: list[str] = []
|
|
64
|
+
for segment in segments:
|
|
65
|
+
if segment.name in hidden:
|
|
66
|
+
continue
|
|
67
|
+
try:
|
|
68
|
+
text = segment.builder(context)
|
|
69
|
+
except Exception: # noqa: BLE001 - a plugin's item must not take the line down with it
|
|
70
|
+
continue
|
|
71
|
+
if text:
|
|
72
|
+
parts.append(str(text))
|
|
73
|
+
return tuple(parts)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def prompt_pieces(parts: Sequence[PromptPart]) -> list[str | Image]:
|
|
77
|
+
"""Return a draft's parts as the text and pictures the SDK takes, in the order they were written.
|
|
78
|
+
|
|
79
|
+
A reference is its token again: the SDK splits the text at the tokens, as the composer did to make the chips.
|
|
80
|
+
"""
|
|
81
|
+
pieces: list[str | Image] = []
|
|
82
|
+
for part in parts:
|
|
83
|
+
if isinstance(part, str):
|
|
84
|
+
pieces.append(part)
|
|
85
|
+
elif isinstance(part, MentionPart):
|
|
86
|
+
pieces.append(part.token)
|
|
87
|
+
else:
|
|
88
|
+
data, media_type = part
|
|
89
|
+
pieces.append(Image(data, media_type))
|
|
90
|
+
return pieces
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
#: What a run the client refused because another one holds it is said as, in a terminal. The SDK's own sentence tells
|
|
94
|
+
#: a program to leave an ``async with`` block or await ``aclose()``, which is a remedy a reader cannot use; the one
|
|
95
|
+
#: thing that helps is that it will be free in a moment.
|
|
96
|
+
RUN_IN_PROGRESS = "another run is finishing; try again in a moment"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def failure_line(error: BaseException) -> str:
|
|
100
|
+
"""Return the line a failed turn is shown as: what failed, and ``/continue`` when trying again may work.
|
|
101
|
+
|
|
102
|
+
An SDK run error already carries the sentence, and says whether the failure was the provider's or the network's;
|
|
103
|
+
anything else is worded here, as it always was.
|
|
104
|
+
"""
|
|
105
|
+
if isinstance(error, SessionBusyError):
|
|
106
|
+
return f"error: {RUN_IN_PROGRESS}"
|
|
107
|
+
if isinstance(error, RunError):
|
|
108
|
+
text = f"error: {error}"
|
|
109
|
+
return f"{text.rstrip('.')}. {CONTINUE_HINT}" if error.retryable else text
|
|
110
|
+
return failure_text(error)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def plan_rows(tasks: Sequence[TaskInfo]) -> tuple[tuple[str, str], ...]:
|
|
114
|
+
"""Return the model's plan as the (status, content) rows the task panel and the status line draw."""
|
|
115
|
+
return tuple((task.status, task.content) for task in tasks)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""The background release check, and the panel that offers what it found.
|
|
2
|
+
|
|
3
|
+
The check is deliberately not part of the start: it is a task started beside the
|
|
4
|
+
first frame, and the panel is drawn from the *file* the last check left, so a
|
|
5
|
+
start never waits on the network to decide what to paint.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from zettcode.tui import Screen
|
|
15
|
+
from zettcode.update import (
|
|
16
|
+
UpdateState,
|
|
17
|
+
check_for_update,
|
|
18
|
+
display_command,
|
|
19
|
+
fetch_latest,
|
|
20
|
+
in_background,
|
|
21
|
+
installed_version,
|
|
22
|
+
is_newer,
|
|
23
|
+
read_state,
|
|
24
|
+
record_install,
|
|
25
|
+
run_upgrade,
|
|
26
|
+
running_from,
|
|
27
|
+
upgrade_command,
|
|
28
|
+
write_state,
|
|
29
|
+
)
|
|
30
|
+
from zettcode.version import __version__
|
|
31
|
+
|
|
32
|
+
from .shell import PAGE_SCREEN, UPDATE_ROWS, ShellState
|
|
33
|
+
from .widgets import LATER, UPGRADE, UpdatePage, bottom_panel
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UpdateMixin(ShellState):
|
|
37
|
+
"""Offer a newer release, once, until the reader skips it or installs it."""
|
|
38
|
+
|
|
39
|
+
def start_update_check(self) -> asyncio.Task[None] | None:
|
|
40
|
+
"""Ask PyPI in the background, unless the configuration turns it off."""
|
|
41
|
+
if not self.agent.runtime.config.update_enabled:
|
|
42
|
+
return None
|
|
43
|
+
self._update_task = asyncio.ensure_future(self._check_for_update())
|
|
44
|
+
return self._update_task
|
|
45
|
+
|
|
46
|
+
async def _check_for_update(self) -> None:
|
|
47
|
+
"""Refresh the stored state; a failure means "no news", never an error."""
|
|
48
|
+
await check_for_update(self.agent.runtime.config.update_file)
|
|
49
|
+
|
|
50
|
+
def offer_update(self) -> UpdateState | None:
|
|
51
|
+
"""Show the panel a stored newer version asks for, if there is one."""
|
|
52
|
+
if not self.agent.runtime.config.update_enabled:
|
|
53
|
+
return None
|
|
54
|
+
path = self.agent.runtime.config.update_file
|
|
55
|
+
state = read_state(path)
|
|
56
|
+
shadow = state.shadowed_by(__version__)
|
|
57
|
+
if shadow is not None:
|
|
58
|
+
self._explain_shadow(state, shadow, path)
|
|
59
|
+
return None
|
|
60
|
+
latest = state.offer(__version__)
|
|
61
|
+
if latest is None:
|
|
62
|
+
return None
|
|
63
|
+
command = upgrade_command()
|
|
64
|
+
page = UpdatePage(latest, __version__, command, on_choice=lambda choice: self._answer_update(choice, latest))
|
|
65
|
+
self.app.push_screen(Screen(bottom_panel(page, rows=UPDATE_ROWS), name=PAGE_SCREEN, modal=True))
|
|
66
|
+
self.app.request_layout()
|
|
67
|
+
return state
|
|
68
|
+
|
|
69
|
+
def _explain_shadow(self, state: UpdateState, installed: str, path: Path) -> None:
|
|
70
|
+
"""Say, once, that an upgrade worked but is not what is running, instead of offering it again.
|
|
71
|
+
|
|
72
|
+
Offering the upgrade a second time would change nothing — it is installed — and would look
|
|
73
|
+
exactly like an upgrade that never happened. What is useful is where the running code is
|
|
74
|
+
coming from. The explanation is recorded as given, so the next start is quiet about it.
|
|
75
|
+
"""
|
|
76
|
+
if state.noted == installed:
|
|
77
|
+
return
|
|
78
|
+
self.transcript.notice(
|
|
79
|
+
f"ZettCode {installed} is installed, but this session runs {__version__} from {running_from()}: "
|
|
80
|
+
"something else is supplying the code (an editable checkout, PYTHONPATH?), so upgrading again "
|
|
81
|
+
"would not change it"
|
|
82
|
+
)
|
|
83
|
+
try:
|
|
84
|
+
write_state(replace(state, noted=installed), path)
|
|
85
|
+
except OSError:
|
|
86
|
+
pass # said again next start, which is the right failure for a note
|
|
87
|
+
|
|
88
|
+
def _answer_update(self, choice: str, latest: str) -> None:
|
|
89
|
+
"""Act on the panel: upgrade, forget this version, or ask again later."""
|
|
90
|
+
self.close_page()
|
|
91
|
+
if choice == UPGRADE:
|
|
92
|
+
self._update_task = asyncio.ensure_future(self._run_upgrade(latest))
|
|
93
|
+
return
|
|
94
|
+
if choice == LATER:
|
|
95
|
+
# Nothing is written: the next start reads the same file and asks
|
|
96
|
+
# again, which is the whole difference between this and skipping.
|
|
97
|
+
return
|
|
98
|
+
path = self.agent.runtime.config.update_file
|
|
99
|
+
write_state(replace(read_state(path), skipped=latest), path)
|
|
100
|
+
self.transcript.notice(f"skipping ZettCode {latest}: the next release will be offered")
|
|
101
|
+
|
|
102
|
+
async def _run_upgrade(self, latest: str) -> None:
|
|
103
|
+
"""Install the newer release in the background, check that it is there, and report which.
|
|
104
|
+
|
|
105
|
+
A package manager exiting 0 is not an upgrade: ``uv tool upgrade`` answers "Nothing to
|
|
106
|
+
upgrade" with 0, and a pip whose index has not listed the release yet answers "already
|
|
107
|
+
satisfied" with 0. So the version is read back from the interpreter that was upgraded, and
|
|
108
|
+
only that is believed: it is what the notice says, and it is what is written to the file the
|
|
109
|
+
next start reads. An upgrade that left the old version is reported as that, with the way to
|
|
110
|
+
try again, and writes nothing, so it is offered again.
|
|
111
|
+
|
|
112
|
+
The running process keeps the code it started with, so the notice says what the reader has
|
|
113
|
+
to do about it — restart — rather than implying the window is already the new version.
|
|
114
|
+
|
|
115
|
+
The window takes no input while this runs: the installation is being replaced under the process that would be
|
|
116
|
+
answering, so the lock is taken before anything else — including the index read below, which can take the
|
|
117
|
+
request timeout — and `submit`, a message from another session and `^D` all find it.
|
|
118
|
+
|
|
119
|
+
The version the panel offered is read from the index again first: the file it came from is written by a check
|
|
120
|
+
that may be hours old, and what an install is measured against should be what PyPI says now, not what a
|
|
121
|
+
mirror wrote earlier. An index that now names nothing newer than this process means there is nothing to
|
|
122
|
+
install, and the running version is never replaced by one that is not newer.
|
|
123
|
+
"""
|
|
124
|
+
command = upgrade_command()
|
|
125
|
+
self._begin_update()
|
|
126
|
+
try:
|
|
127
|
+
offered = latest
|
|
128
|
+
latest = await self._fresh(offered)
|
|
129
|
+
if not is_newer(latest, __version__):
|
|
130
|
+
self.transcript.notice(
|
|
131
|
+
f"PyPI names {latest}, which is not newer than the {__version__} running here: nothing installed"
|
|
132
|
+
)
|
|
133
|
+
return
|
|
134
|
+
if latest != offered:
|
|
135
|
+
self.transcript.notice(f"PyPI now names {latest}, not the {offered} the panel showed")
|
|
136
|
+
await self._install(command, latest)
|
|
137
|
+
finally:
|
|
138
|
+
self._end_update()
|
|
139
|
+
|
|
140
|
+
def _begin_update(self) -> None:
|
|
141
|
+
"""Take the window for the install: the flag, the composer's lock and the line that explains it."""
|
|
142
|
+
self._updating = True
|
|
143
|
+
self._sync_lock()
|
|
144
|
+
self.transcript.notice("updating ZettCode \u2014 this window takes no input until it is done")
|
|
145
|
+
self.app.invalidate()
|
|
146
|
+
|
|
147
|
+
def _end_update(self) -> None:
|
|
148
|
+
"""Give the window back, whatever became of the install."""
|
|
149
|
+
self._updating = False
|
|
150
|
+
self._sync_lock()
|
|
151
|
+
|
|
152
|
+
async def _fresh(self, stored: str) -> str:
|
|
153
|
+
"""Return the newest version the index names now, or ``stored`` when it cannot be asked.
|
|
154
|
+
|
|
155
|
+
What is in the update file is a note from the last check, and the check may have been made in another
|
|
156
|
+
process, on another day, against an answer that has since changed. The index is asked here, at the moment the
|
|
157
|
+
reader chose to install, and the stored version is only the fallback for an offline machine.
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
return await in_background(fetch_latest)
|
|
161
|
+
except Exception: # noqa: BLE001 - offline, a proxy, an odd answer: install what we thought, then measure
|
|
162
|
+
return stored
|
|
163
|
+
|
|
164
|
+
async def _install(self, command: tuple[str, ...], latest: str) -> None:
|
|
165
|
+
"""Run the package manager and report what it left behind."""
|
|
166
|
+
ok, output = await run_upgrade(command)
|
|
167
|
+
if not ok:
|
|
168
|
+
reason = output.splitlines()[-1] if output else "no output"
|
|
169
|
+
self.transcript.error(f"upgrade failed: {reason}")
|
|
170
|
+
self.transcript.notice(f"run it yourself: {display_command(command)}")
|
|
171
|
+
return
|
|
172
|
+
installed = await installed_version()
|
|
173
|
+
if installed is None:
|
|
174
|
+
self.transcript.notice(
|
|
175
|
+
f"the upgrade ran, but the installed version could not be read back — restart and check "
|
|
176
|
+
f"with `zettcode --version`; if it is not {latest}: {display_command(command)}"
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
if is_newer(latest, installed):
|
|
180
|
+
self.transcript.error(f"the upgrade ran, but ZettCode is still {installed}, not {latest}")
|
|
181
|
+
self.transcript.notice(
|
|
182
|
+
f"the release may be yanked, or the installer may have been pinned to {installed}: "
|
|
183
|
+
f"try `uv tool install zettcode@latest`, or {display_command(command)}"
|
|
184
|
+
)
|
|
185
|
+
return
|
|
186
|
+
try:
|
|
187
|
+
record_install(self.agent.runtime.config.update_file, installed)
|
|
188
|
+
except OSError:
|
|
189
|
+
pass # the install happened; failing to write it down must not make it look as if it had not
|
|
190
|
+
self.transcript.notice(f"ZettCode {installed} installed — restart to use it")
|
|
191
|
+
self.notify("upgrade finished", level="success")
|
|
192
|
+
|
|
193
|
+
async def _stop_update_task(self) -> None:
|
|
194
|
+
"""Drop a check or upgrade still in flight when the app exits.
|
|
195
|
+
|
|
196
|
+
Nobody is left to read the answer, and the request behind it may be
|
|
197
|
+
waiting on a slow index: cancelling the await is what keeps a quit from
|
|
198
|
+
holding the terminal for as long as that takes.
|
|
199
|
+
"""
|
|
200
|
+
task, self._update_task = self._update_task, None
|
|
201
|
+
if task is None or task.done():
|
|
202
|
+
return
|
|
203
|
+
task.cancel()
|
|
204
|
+
await asyncio.gather(task, return_exceptions=True)
|