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/app.py
ADDED
|
@@ -0,0 +1,1507 @@
|
|
|
1
|
+
"""The ZettCode application shell: the controller behind the terminal UI.
|
|
2
|
+
|
|
3
|
+
It owns the agent, the widget tree, the keymap, and the slash commands, and it
|
|
4
|
+
is the only place that decides what a command result looks like on screen. The
|
|
5
|
+
widgets themselves live in :mod:`zettcode.app.ui.widgets`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from contextlib import suppress
|
|
14
|
+
from time import monotonic
|
|
15
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
16
|
+
|
|
17
|
+
from zettcode import logging_setup
|
|
18
|
+
from zettcode.config_edit import ConfigEditError
|
|
19
|
+
from zettcode.paths import DEFAULT_LOG
|
|
20
|
+
from zettcode.plugin_api.state import (
|
|
21
|
+
Activity,
|
|
22
|
+
TurnContext,
|
|
23
|
+
UiRow,
|
|
24
|
+
)
|
|
25
|
+
from zettcode.tui import (
|
|
26
|
+
DARK,
|
|
27
|
+
DONE,
|
|
28
|
+
ELLIPSIS,
|
|
29
|
+
PROMPT,
|
|
30
|
+
SEPARATOR,
|
|
31
|
+
CompletionPopup,
|
|
32
|
+
FocusEvent,
|
|
33
|
+
Rect,
|
|
34
|
+
RichText,
|
|
35
|
+
Screen,
|
|
36
|
+
Span,
|
|
37
|
+
StatusBar,
|
|
38
|
+
Style,
|
|
39
|
+
TaskPanel,
|
|
40
|
+
TextArea,
|
|
41
|
+
TextLine,
|
|
42
|
+
Theme,
|
|
43
|
+
TuiApp,
|
|
44
|
+
VBox,
|
|
45
|
+
walk,
|
|
46
|
+
)
|
|
47
|
+
from zettcode.tui.clipboard import copy_route
|
|
48
|
+
from zettcode.tui.layout import Slot
|
|
49
|
+
from zettcode.tui.render import display_width
|
|
50
|
+
from zettcode.tui.widgets import Rule
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
53
|
+
from zettcode.sdk.client import Client
|
|
54
|
+
from zettcode.app.errors import COMMAND_ERRORS
|
|
55
|
+
from zettcode.error_text import describe_error
|
|
56
|
+
from zettcode.mentions import MentionRegistry, SessionMentions
|
|
57
|
+
from zettcode.messaging.relay import Relay
|
|
58
|
+
from zettcode.messaging.relayed import Relayed
|
|
59
|
+
from zettcode.plugin_api.commands import (
|
|
60
|
+
Command,
|
|
61
|
+
CommandContext,
|
|
62
|
+
CommandHost,
|
|
63
|
+
CommandList,
|
|
64
|
+
CommandResult,
|
|
65
|
+
)
|
|
66
|
+
from zettcode.plugin_api.pets import DEFAULT_PET_NAME, IDLE, Pet
|
|
67
|
+
from zettcode.presenters.text import clock_text, elapsed_text
|
|
68
|
+
from zettcode.prompt import PromptPart, carries_image, mention_parts
|
|
69
|
+
from zettcode.registry import Registry
|
|
70
|
+
from zettcode.sdk.errors import SessionBusyError
|
|
71
|
+
from zettcode.sdk.events import QuestionAsked
|
|
72
|
+
from zettcode.sdk.relay import incoming_of
|
|
73
|
+
from zettcode.sdk.types import CacheMode, Usage
|
|
74
|
+
|
|
75
|
+
from ..agent.agent import SessionLink, TerminalAgent
|
|
76
|
+
from ..agent.conversation.effort_gauge import EFFORT_SECONDS, EffortFrame, effort_frame
|
|
77
|
+
from ..agent.conversation.projection import TranscriptProjector
|
|
78
|
+
from ..agent.conversation.shimmer import (
|
|
79
|
+
SHIMMER_LEVELS,
|
|
80
|
+
ShimmerFrame,
|
|
81
|
+
shimmer_frame,
|
|
82
|
+
shimmer_seconds,
|
|
83
|
+
shimmer_width,
|
|
84
|
+
)
|
|
85
|
+
from ..agent.conversation.tips import TipDeck
|
|
86
|
+
from ..agent.conversation.transcript import Transcript
|
|
87
|
+
from .bars import BarsMixin
|
|
88
|
+
from .commands import ShellCommands
|
|
89
|
+
from .followups import FollowUp, FollowUpsMixin
|
|
90
|
+
from .gc_policy import CollectorPolicy, freeze_long_lived
|
|
91
|
+
from .keys import KeysMixin
|
|
92
|
+
from .messaging import MessagingMixin
|
|
93
|
+
from .notices import NoticesMixin
|
|
94
|
+
from .providers import ConfigMixin
|
|
95
|
+
from .sessions import SessionMixin
|
|
96
|
+
from .settings import SettingsMixin
|
|
97
|
+
from .shell import (
|
|
98
|
+
EFFORT_TOKEN,
|
|
99
|
+
EMPTY_REPLY,
|
|
100
|
+
FLASH_SECONDS,
|
|
101
|
+
NO_MODEL_ASKED,
|
|
102
|
+
NO_MODEL_QUESTION,
|
|
103
|
+
NO_MODEL_START,
|
|
104
|
+
PAGE_SCREEN,
|
|
105
|
+
)
|
|
106
|
+
from .turns import RUN_IN_PROGRESS, TurnTally, failure_line, plan_rows, prompt_pieces, turn_parts
|
|
107
|
+
from .updating import UpdateMixin
|
|
108
|
+
from .widgets import (
|
|
109
|
+
WELCOME,
|
|
110
|
+
CommandCompleter,
|
|
111
|
+
Composer,
|
|
112
|
+
FollowUpQueue,
|
|
113
|
+
PetView,
|
|
114
|
+
SteeringQueue,
|
|
115
|
+
TranscriptView,
|
|
116
|
+
ZettCodeRoot,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
logger = logging.getLogger("zettcode.terminal")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# The contract is last on purpose: the mixins above implement its methods and must be found first.
|
|
123
|
+
class ZettCodeApp(
|
|
124
|
+
BarsMixin,
|
|
125
|
+
KeysMixin,
|
|
126
|
+
FollowUpsMixin,
|
|
127
|
+
MessagingMixin,
|
|
128
|
+
NoticesMixin,
|
|
129
|
+
SessionMixin,
|
|
130
|
+
SettingsMixin,
|
|
131
|
+
ConfigMixin,
|
|
132
|
+
UpdateMixin,
|
|
133
|
+
CommandHost,
|
|
134
|
+
):
|
|
135
|
+
"""Own the application agent, widget tree, keymap, and slash commands."""
|
|
136
|
+
|
|
137
|
+
#: The concrete types, narrower than the ``CommandHost`` contract: a view of this shell uses the terminal's
|
|
138
|
+
#: agent and transcript, not the few members a plugin's command is allowed to see.
|
|
139
|
+
_agent: TerminalAgent
|
|
140
|
+
_client: Client
|
|
141
|
+
_transcript: Transcript
|
|
142
|
+
_commands: tuple[Command, ...]
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def agent(self) -> TerminalAgent:
|
|
146
|
+
"""Return the application agent this shell owns."""
|
|
147
|
+
return self._agent
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def client(self) -> Client:
|
|
151
|
+
"""Return the SDK client over that agent."""
|
|
152
|
+
return self._client
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def transcript(self) -> Transcript:
|
|
156
|
+
"""Return the conversation as drawn."""
|
|
157
|
+
return self._transcript
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def commands(self) -> tuple[Command, ...]:
|
|
161
|
+
"""Return every command the shell knows."""
|
|
162
|
+
return self._commands
|
|
163
|
+
|
|
164
|
+
@commands.setter
|
|
165
|
+
def commands(self, commands: tuple[Command, ...]) -> None:
|
|
166
|
+
"""Replace the command list, as a test that adds one does."""
|
|
167
|
+
self._commands = commands
|
|
168
|
+
|
|
169
|
+
def __init__(self, agent: TerminalAgent, *, theme: Theme = DARK, auto_theme: bool = True) -> None:
|
|
170
|
+
"""Wire the agent into the transcript, composer, panel, and keymap.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
agent: Application agent that owns turns, sessions, and models.
|
|
174
|
+
theme: Initial palette; ``/theme`` replaces it at runtime.
|
|
175
|
+
auto_theme: Let the terminal's own background choose between the
|
|
176
|
+
light and dark palettes at startup; a configured theme file
|
|
177
|
+
passes ``False``, because then the palette was a choice.
|
|
178
|
+
"""
|
|
179
|
+
self._agent = agent
|
|
180
|
+
self._client = agent.client
|
|
181
|
+
self._plugin_rows: tuple[UiRow, ...] = agent.plugin_rows
|
|
182
|
+
# The program's own plugins come first, then the shell's commands, then the agent's, and the installed
|
|
183
|
+
# plugins last: the first to claim a name owns it, so an installed plugin can add a command and can never
|
|
184
|
+
# shadow one the program has.
|
|
185
|
+
self._commands = Registry(
|
|
186
|
+
[
|
|
187
|
+
CommandList(agent.builtin_plugin_commands),
|
|
188
|
+
ShellCommands(self),
|
|
189
|
+
CommandList(agent.commands),
|
|
190
|
+
CommandList(agent.plugin_commands),
|
|
191
|
+
]
|
|
192
|
+
).items()
|
|
193
|
+
# The plugins' commands reach the shell through the context they are run with, and the words their menus
|
|
194
|
+
# offer through this link; the shell is built enough by now that either may follow it.
|
|
195
|
+
agent.attach_host(self)
|
|
196
|
+
#: The line to the other sessions, when the configuration has one; a session name it offers for ``@``
|
|
197
|
+
#: comes first, so it wins over a file or plugin resource of the same name.
|
|
198
|
+
self.messenger: SessionLink | None = agent.messenger
|
|
199
|
+
self.mentions = MentionRegistry(
|
|
200
|
+
agent.plugin_mentions
|
|
201
|
+
if self.messenger is None
|
|
202
|
+
else (SessionMentions(self.messenger.peers), *agent.plugin_mentions)
|
|
203
|
+
)
|
|
204
|
+
self._transcript = Transcript(
|
|
205
|
+
presenters=agent.presenters, max_entries=agent.runtime.config.transcript_max_entries
|
|
206
|
+
)
|
|
207
|
+
if agent.runtime.config.tips:
|
|
208
|
+
self._transcript.tip_deck = TipDeck()
|
|
209
|
+
self._transcript.welcome(WELCOME)
|
|
210
|
+
if agent.runtime.active_model is None:
|
|
211
|
+
# The first thing the conversation says on a machine that has never been set up, under the banner and
|
|
212
|
+
# above anything the reader does next: the shell runs, and this is the one command it is waiting for.
|
|
213
|
+
self.transcript.notice(NO_MODEL_START)
|
|
214
|
+
for failure in agent.plugin_failures:
|
|
215
|
+
self.transcript.error(f"plugin: {failure}")
|
|
216
|
+
self.projector = TranscriptProjector(
|
|
217
|
+
self.transcript,
|
|
218
|
+
on_usage=self._usage_updated,
|
|
219
|
+
on_steering_started=self._steering_started,
|
|
220
|
+
on_steering_interrupted=self._steering_interrupted,
|
|
221
|
+
side_running=self._side_question_running,
|
|
222
|
+
)
|
|
223
|
+
self.agent.set_event_dispatcher(self.projector)
|
|
224
|
+
self.view = TranscriptView(self.transcript, theme=theme, wheel_speed=agent.runtime.config.mouse_scroll_speed)
|
|
225
|
+
self.composer = Composer(
|
|
226
|
+
prompt=f"{PROMPT} ",
|
|
227
|
+
placeholder="Ask ZettCode to do anything",
|
|
228
|
+
completer=CommandCompleter(self.commands, self.mentions),
|
|
229
|
+
max_height=8,
|
|
230
|
+
on_submit=self.submit,
|
|
231
|
+
on_change=self._composer_changed,
|
|
232
|
+
surface=True,
|
|
233
|
+
)
|
|
234
|
+
# What is painted and taken whole as a command or a reference is only what exists.
|
|
235
|
+
self.composer.is_command = lambda name: self._lookup(name)[0] is not None
|
|
236
|
+
self.composer.is_mention = lambda token: self.mentions.expand_token(token) is not None
|
|
237
|
+
self.composer.on_locked_enter = self._retry_lock
|
|
238
|
+
#: The row between the conversation and the composer: blank until something
|
|
239
|
+
#: the reader just did needs saying, then a centred line that expires.
|
|
240
|
+
self.flash_line = RichText(self._flash_line, align="center", wrap=False)
|
|
241
|
+
self.completions = CompletionPopup(max_height=6)
|
|
242
|
+
self.steering = SteeringQueue()
|
|
243
|
+
self.follow_ups = FollowUpQueue()
|
|
244
|
+
self.header = StatusBar(self._header_left, self._header_right)
|
|
245
|
+
self.status = StatusBar(self._status_left, self._status_right)
|
|
246
|
+
self.panel = TaskPanel()
|
|
247
|
+
#: The companion over the conversation and the composer, which the reader can drag, choose with ``/pet`` and
|
|
248
|
+
#: switch off in ``/statusline``.
|
|
249
|
+
self._pet_before: Pet | None = None
|
|
250
|
+
#: Whether the pet's place failed to be saved and the reader was told, so it is said once.
|
|
251
|
+
self._pet_unsaved = False
|
|
252
|
+
self.pet = PetView(
|
|
253
|
+
self._starting_pet(),
|
|
254
|
+
area=self._pet_area,
|
|
255
|
+
shown=lambda: "pet" not in self.agent.statusline_hidden,
|
|
256
|
+
busy=lambda: self._busy,
|
|
257
|
+
forward=self.view,
|
|
258
|
+
position=self.agent.runtime.config.pet_position,
|
|
259
|
+
on_moved=self._remember_pet_position,
|
|
260
|
+
)
|
|
261
|
+
body = VBox(
|
|
262
|
+
[
|
|
263
|
+
Slot(self.header, size=1),
|
|
264
|
+
Slot(Rule(), size=1),
|
|
265
|
+
Slot(self.view, flex=1),
|
|
266
|
+
Slot(self.panel, size=lambda width: self.panel.preferred_height()),
|
|
267
|
+
Slot(self.flash_line, size=1),
|
|
268
|
+
Slot(self.completions, size=lambda available: self.completions.visible_height),
|
|
269
|
+
Slot(self.steering, size=lambda width: self.steering.preferred_height()),
|
|
270
|
+
Slot(self.follow_ups, size=lambda width: self.follow_ups.preferred_height()),
|
|
271
|
+
Slot(self.composer, size=lambda width: self.composer.preferred_height(width)),
|
|
272
|
+
Slot(self.status, size=1),
|
|
273
|
+
]
|
|
274
|
+
)
|
|
275
|
+
self.root = ZettCodeRoot(self, body, self.pet)
|
|
276
|
+
# The listener is installed here for a shell that runs its own app, and
|
|
277
|
+
# again by :meth:`adopt` for one that hands its tree to the boot frame's:
|
|
278
|
+
# the copy is handled by whichever app ends up running. Installing it
|
|
279
|
+
# twice is a no-op, because a handler is not chained onto itself.
|
|
280
|
+
self.app = TuiApp(
|
|
281
|
+
self.root,
|
|
282
|
+
theme=theme,
|
|
283
|
+
reduced_motion=agent.reduced_motion,
|
|
284
|
+
auto_theme=auto_theme,
|
|
285
|
+
on_copy=self._copied,
|
|
286
|
+
)
|
|
287
|
+
# The terminal tab says what this session is about, like any editor tab.
|
|
288
|
+
self.app.title = self._terminal_title
|
|
289
|
+
# Focus reports are about the window, not the widget under the pointer: watch them here, before routing.
|
|
290
|
+
self.app.add_listener(self._window_event)
|
|
291
|
+
#: The plugins whose hook failed once already, by ``id``: told once, not on every event.
|
|
292
|
+
self._failed_hooks: set[int] = set()
|
|
293
|
+
#: Whether the task in flight is a command rather than a turn, and the draft typed while it worked.
|
|
294
|
+
self._command_working = False
|
|
295
|
+
self._next_draft: tuple[str, bool] | None = None
|
|
296
|
+
self._task: asyncio.Task[None] | None = None
|
|
297
|
+
self._title_task: asyncio.Task[None] | None = None
|
|
298
|
+
#: A short-lived message for the row above the composer: the text and the
|
|
299
|
+
#: moment it stops being true. It lives here rather than in the status line
|
|
300
|
+
#: because the status line is a standing report of what the shell is doing,
|
|
301
|
+
#: while this answers something the reader just did.
|
|
302
|
+
self._flash: tuple[str, float, str] | None = None
|
|
303
|
+
self._effort_animation: tuple[str, str, float] | None = None
|
|
304
|
+
#: The context re-measure a restore started, replaced by the next restore.
|
|
305
|
+
self._measure_task: asyncio.Task[None] | None = None
|
|
306
|
+
#: The release check or upgrade in flight. Set here, not only where one is
|
|
307
|
+
#: started: the shutdown drops it whatever the configuration, and a shell
|
|
308
|
+
#: that never started one — because the update check is off, or because it
|
|
309
|
+
#: is leaving before the background work began — has nothing else to drop.
|
|
310
|
+
self._update_task: asyncio.Task[None] | None = None
|
|
311
|
+
self._updating = False
|
|
312
|
+
self._theme_before = None
|
|
313
|
+
self._previewing = None
|
|
314
|
+
#: The runtime warm-up this shell started, released on the way out.
|
|
315
|
+
self._warm: asyncio.Task[object] | None = None
|
|
316
|
+
#: Whether :meth:`start_background` ran: the warm-up belongs to a shell with a first frame to warm behind.
|
|
317
|
+
self._background_started = False
|
|
318
|
+
self._busy = False
|
|
319
|
+
#: When the garbage collector may run: while work is in flight, and not while the reader types.
|
|
320
|
+
self.collector = CollectorPolicy()
|
|
321
|
+
self._steering: list[str] = []
|
|
322
|
+
self._steering_meta: list[tuple[str, tuple[PromptPart, ...]]] = []
|
|
323
|
+
self._steering_sent = 0
|
|
324
|
+
self._follow_ups: list[FollowUp] = []
|
|
325
|
+
self._follow_up_selected: int | None = None
|
|
326
|
+
self._editing_slot: int | None = None
|
|
327
|
+
self._follow_ups_held = False
|
|
328
|
+
self._turn_running = False
|
|
329
|
+
# What arrives from another session: the queue is the runtime's inbox, and the two answers it needs from this
|
|
330
|
+
# shell are whether a request is running and how to hand a message to it.
|
|
331
|
+
self._relay = Relay(
|
|
332
|
+
busy=lambda: self._busy,
|
|
333
|
+
steer=self._steer_relayed,
|
|
334
|
+
inbox=self._runtime_inbox,
|
|
335
|
+
start=self._start_relayed,
|
|
336
|
+
on_waiting=self._relayed_waits,
|
|
337
|
+
)
|
|
338
|
+
self._turn_answer: str | None = None
|
|
339
|
+
self._lock_watch: asyncio.Task[None] | None = None
|
|
340
|
+
self._lock_said: str | None = None
|
|
341
|
+
self._messaging_live = False
|
|
342
|
+
self._messaging_trouble = False
|
|
343
|
+
self._messaging_session: str | None = None
|
|
344
|
+
self._messaging_start: asyncio.Future[None] | None = None
|
|
345
|
+
self._asks: list[QuestionAsked] = []
|
|
346
|
+
self._ask_total = 0
|
|
347
|
+
#: Drafts typed while a request was in flight, in arrival order: only
|
|
348
|
+
#: commands that declare ``when_busy="queue"`` land here.
|
|
349
|
+
self._pending: list[str] = []
|
|
350
|
+
#: Commands running beside a request (``when_busy="now"``), held so they are not collected
|
|
351
|
+
#: while they run and can be cancelled when the shell leaves.
|
|
352
|
+
self._now_tasks: set[asyncio.Task[None]] = set()
|
|
353
|
+
self._activity = Activity.READY
|
|
354
|
+
self._note: str | None = None
|
|
355
|
+
self._session_title: str | None = None
|
|
356
|
+
self._usage = Usage()
|
|
357
|
+
self._install_keymap()
|
|
358
|
+
|
|
359
|
+
@property
|
|
360
|
+
def busy(self) -> bool:
|
|
361
|
+
"""Return whether a turn or command is in flight."""
|
|
362
|
+
return self._busy
|
|
363
|
+
|
|
364
|
+
def _pet_area(self) -> Rect:
|
|
365
|
+
"""Return where the pet may stand: from the top of the conversation to the bottom of the composer.
|
|
366
|
+
|
|
367
|
+
That takes in the rows between them (the flash line, the menu, the queued messages) and the composer itself,
|
|
368
|
+
so the pet can sit on the box the reader types in, in the empty space to the right of the draft. It stops at
|
|
369
|
+
the bars: the header and the status line are not its ground. A panel that is open covers it, as it covers
|
|
370
|
+
everything under it, because the panel is a screen above this one.
|
|
371
|
+
"""
|
|
372
|
+
view, composer = self.view.rect, self.composer.rect
|
|
373
|
+
if composer.empty or composer.bottom <= view.y:
|
|
374
|
+
return view
|
|
375
|
+
return Rect(view.x, view.y, view.width, max(view.height, composer.bottom - view.y))
|
|
376
|
+
|
|
377
|
+
def _remember_pet_position(self, across: float, down: float) -> None:
|
|
378
|
+
"""Keep where the pet was left in the config file, so the next start puts it back.
|
|
379
|
+
|
|
380
|
+
A pet is not worth an error, so a failure is a notice and not an error row, and it is said once a session: a
|
|
381
|
+
reader whose configuration is the environment has no file to write it to, and a file that cannot be edited
|
|
382
|
+
safely is theirs to fix by hand. Said nothing, they would only find the pet at its usual place the next
|
|
383
|
+
time and not know why.
|
|
384
|
+
"""
|
|
385
|
+
try:
|
|
386
|
+
self.agent.remember_pet_position(across, down)
|
|
387
|
+
except ConfigEditError as error:
|
|
388
|
+
if not self._pet_unsaved:
|
|
389
|
+
self._pet_unsaved = True
|
|
390
|
+
self.transcript.notice(f"The pet's place was not saved: {error}")
|
|
391
|
+
self.app.invalidate()
|
|
392
|
+
|
|
393
|
+
def _starting_pet(self) -> Pet:
|
|
394
|
+
"""Return the pet the configuration names, or the default one when it names none that exists.
|
|
395
|
+
|
|
396
|
+
A name nobody offers is the reader's typo, or a plugin that was uninstalled; it is said in the conversation
|
|
397
|
+
rather than ending the start, because a pet is not worth a refusal to run.
|
|
398
|
+
"""
|
|
399
|
+
catalog = self.agent.pets
|
|
400
|
+
wanted = self.agent.runtime.config.pet
|
|
401
|
+
if wanted:
|
|
402
|
+
try:
|
|
403
|
+
return catalog.load(wanted)
|
|
404
|
+
except ValueError as error:
|
|
405
|
+
self.transcript.error(f"pet: {error}")
|
|
406
|
+
try:
|
|
407
|
+
return catalog.load(DEFAULT_PET_NAME)
|
|
408
|
+
except ValueError:
|
|
409
|
+
# No plugin offers the default (the pets plugin is off or not installed): a pet of one empty cell, so the
|
|
410
|
+
# companion is simply not there rather than the shell refusing to start.
|
|
411
|
+
return Pet("none", {}, {IDLE: ((".", "."),)})
|
|
412
|
+
|
|
413
|
+
@property
|
|
414
|
+
def task(self) -> asyncio.Task[None] | None:
|
|
415
|
+
"""Return the command or turn currently in flight, if any."""
|
|
416
|
+
return self._task
|
|
417
|
+
|
|
418
|
+
async def run(self) -> None:
|
|
419
|
+
"""Own the terminal until the application exits.
|
|
420
|
+
|
|
421
|
+
The runtime is started in the background as the first frame appears:
|
|
422
|
+
its provider SDK costs a few hundred milliseconds, nothing on screen
|
|
423
|
+
waits for it, and the first turn awaits the same task. The release
|
|
424
|
+
check is a background task for the same reason, and the panel it may
|
|
425
|
+
deserve is drawn from the file the *last* check left, so no start waits
|
|
426
|
+
on the network.
|
|
427
|
+
"""
|
|
428
|
+
from zettcode.tui import Terminal, TerminalRunner
|
|
429
|
+
|
|
430
|
+
logging_setup.setup("terminal")
|
|
431
|
+
logging_setup.rotate_text(DEFAULT_LOG)
|
|
432
|
+
self.start_background()
|
|
433
|
+
try:
|
|
434
|
+
await TerminalRunner(self.app, terminal=Terminal(diagnostics=DEFAULT_LOG)).run()
|
|
435
|
+
finally:
|
|
436
|
+
await self.stop_background()
|
|
437
|
+
|
|
438
|
+
def _copied(self, text: str) -> None:
|
|
439
|
+
"""Say how much was copied, wherever the copy came from.
|
|
440
|
+
|
|
441
|
+
Every copy passes through the host: a drag that selects and releases, a
|
|
442
|
+
drag over a panel's painted cells, and Ctrl-C all end in ``copy``.
|
|
443
|
+
Announcing it from there is what keeps them from drifting — the first
|
|
444
|
+
version of this spoke only on the key, so a reader who dragged, which is
|
|
445
|
+
the way the guide teaches, saw nothing at all.
|
|
446
|
+
|
|
447
|
+
Over ``ssh`` it says "sent", and to the terminal's clipboard, rather than
|
|
448
|
+
"copied": the text was handed to the terminal, which puts it on the clipboard
|
|
449
|
+
of the machine the reader is sitting at — and a terminal may refuse, which the
|
|
450
|
+
program cannot see. "Copied to clipboard" would be a claim about a machine this
|
|
451
|
+
program is not on and a result it cannot check; "sent" is only what it did.
|
|
452
|
+
"""
|
|
453
|
+
n = len(text)
|
|
454
|
+
if copy_route() == "terminal":
|
|
455
|
+
self.flash(f"sent {n} chars to your terminal's clipboard", level="success")
|
|
456
|
+
else:
|
|
457
|
+
self.flash(f"copied {n} chars to clipboard", level="success")
|
|
458
|
+
|
|
459
|
+
def start_effort_animation(self, previous: str, current: str) -> None:
|
|
460
|
+
"""Animate the effort slot for a second, when the moment is right for it.
|
|
461
|
+
|
|
462
|
+
Skipped while a request runs, because the waiting row already owns the
|
|
463
|
+
motion — one row owns it in this shell — and skipped under reduced motion,
|
|
464
|
+
which asks for none at all. That flag is read from the running app rather
|
|
465
|
+
than from the config it came from, so this reads the same snapshot every
|
|
466
|
+
other animation in the tree reads; `adopt` is what keeps them in step. The
|
|
467
|
+
transcript's own row reports the change either way, so nothing is lost when
|
|
468
|
+
the gauge does not appear.
|
|
469
|
+
"""
|
|
470
|
+
if self.app.reduced_motion or self._busy:
|
|
471
|
+
self._effort_animation = None
|
|
472
|
+
return
|
|
473
|
+
self._effort_animation = (previous, current, monotonic())
|
|
474
|
+
self.app.scheduler.animate(EFFORT_TOKEN, active=True)
|
|
475
|
+
self.app.request_layout()
|
|
476
|
+
|
|
477
|
+
def _effort_gauge(self) -> EffortFrame | ShimmerFrame | None:
|
|
478
|
+
"""Return the gauge for this paint, or None when the switch is over.
|
|
479
|
+
|
|
480
|
+
The clock and the state are read here, in the module that set them: an
|
|
481
|
+
animation whose start and whose frame come from two import sites is one
|
|
482
|
+
clock in a running shell and two in a test. Progress is a fraction of
|
|
483
|
+
elapsed time, not a frame count, so the motion is as smooth as the loop
|
|
484
|
+
that paints it and a slow terminal drops frames instead of slowing down.
|
|
485
|
+
A switch to the top of the scale is a different animation, and a longer one:
|
|
486
|
+
light crossing the whole row from right to left, which needs to know how wide
|
|
487
|
+
the row is, so it is told where the gauge starts.
|
|
488
|
+
"""
|
|
489
|
+
animation = self._effort_animation
|
|
490
|
+
model = self.model
|
|
491
|
+
if animation is None or model is None:
|
|
492
|
+
return None
|
|
493
|
+
previous, current, started = animation
|
|
494
|
+
if current in SHIMMER_LEVELS:
|
|
495
|
+
lead = display_width(model.name) + len(f" {SEPARATOR} ")
|
|
496
|
+
reach = max(self.app.width, shimmer_width(lead, len(self.client.efforts), current))
|
|
497
|
+
progress = (monotonic() - started) / shimmer_seconds(current, reach)
|
|
498
|
+
return shimmer_frame(current, self.client.efforts, lead=lead, reach=reach, progress=progress)
|
|
499
|
+
progress = (monotonic() - started) / EFFORT_SECONDS
|
|
500
|
+
return effort_frame(previous, current, self.client.efforts, progress=progress)
|
|
501
|
+
|
|
502
|
+
def settle_effort_animation(self) -> None:
|
|
503
|
+
"""Repaint while the switch animates, and release the token when it ends."""
|
|
504
|
+
if self._effort_animation is None:
|
|
505
|
+
return
|
|
506
|
+
if self._effort_gauge() is None:
|
|
507
|
+
# Past the last frame: the header goes back to the segments a plugin
|
|
508
|
+
# registered, and the token goes with the animation.
|
|
509
|
+
self._effort_animation = None
|
|
510
|
+
self.app.scheduler.animate(EFFORT_TOKEN, active=False)
|
|
511
|
+
self.app.request_layout()
|
|
512
|
+
return
|
|
513
|
+
self.app.invalidate()
|
|
514
|
+
|
|
515
|
+
def flash(self, message: str, *, seconds: float = FLASH_SECONDS, level: str = "info") -> None:
|
|
516
|
+
"""Say something in the row above the composer, for a few seconds.
|
|
517
|
+
|
|
518
|
+
Args:
|
|
519
|
+
message: One line, drawn centred; the row is blank again when it ends.
|
|
520
|
+
seconds: How long it stays.
|
|
521
|
+
level: ``"info"`` is plain accent text, the way a hint reads. ``"success"`` is a
|
|
522
|
+
pill — the text on a raised fill with a check in front — for the one kind of
|
|
523
|
+
message that answers something the reader just did and should be seen to
|
|
524
|
+
have worked.
|
|
525
|
+
"""
|
|
526
|
+
self._flash = (message, monotonic() + seconds, level)
|
|
527
|
+
self.app.request_layout()
|
|
528
|
+
|
|
529
|
+
def _flash_line(self) -> TextLine:
|
|
530
|
+
"""Return the flash as a styled line, which is what that row paints.
|
|
531
|
+
|
|
532
|
+
A plain flash is the accent rather than the muted grey the hints use, and
|
|
533
|
+
nothing more: bold would make the row shout for two seconds. A success is the
|
|
534
|
+
one that has to be *seen* — a copy leaves nothing else on screen to show it
|
|
535
|
+
happened — so it is a pill: the text on the theme's emphasis fill, with a check
|
|
536
|
+
in front. The row's width is the text's, so the fill covers the words and the
|
|
537
|
+
space around them and stops there, which is what makes it a pill rather than a
|
|
538
|
+
stripe across the screen.
|
|
539
|
+
"""
|
|
540
|
+
message = self._flash_text()
|
|
541
|
+
if not message:
|
|
542
|
+
return TextLine(())
|
|
543
|
+
theme = self.app.theme
|
|
544
|
+
if self._flash is not None and self._flash[2] == "success":
|
|
545
|
+
fill = theme.selection
|
|
546
|
+
return TextLine(
|
|
547
|
+
(
|
|
548
|
+
Span(f" {DONE} ", Style(foreground=theme.accent_bright, background=fill, bold=True)),
|
|
549
|
+
Span(f"{message} ", Style(foreground=theme.text, background=fill)),
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
return TextLine((Span(message, Style(foreground=theme.accent)),))
|
|
553
|
+
|
|
554
|
+
def _flash_text(self) -> str:
|
|
555
|
+
"""Return the flash while it lasts, which is what that row paints."""
|
|
556
|
+
if self._flash is None:
|
|
557
|
+
return ""
|
|
558
|
+
message, expires, _ = self._flash
|
|
559
|
+
if monotonic() >= expires:
|
|
560
|
+
self._flash = None
|
|
561
|
+
return ""
|
|
562
|
+
return message
|
|
563
|
+
|
|
564
|
+
def settle_flash(self) -> None:
|
|
565
|
+
"""Ask for a repaint while a flash is alive, so it can expire on its own."""
|
|
566
|
+
if self._flash is not None and self._flash_text():
|
|
567
|
+
self.app.invalidate()
|
|
568
|
+
|
|
569
|
+
def start_background(self) -> None:
|
|
570
|
+
"""Begin the work that belongs behind the first frame.
|
|
571
|
+
|
|
572
|
+
The runtime warms — its provider SDK costs a few hundred milliseconds,
|
|
573
|
+
and it starts loading its token counter with it — and the release check
|
|
574
|
+
runs, nothing on screen waits for either, and the first turn awaits the
|
|
575
|
+
same task. Called once the interface is up, whether :meth:`run` put it
|
|
576
|
+
there or the command line painted a frame before this application
|
|
577
|
+
existed.
|
|
578
|
+
"""
|
|
579
|
+
self.collector.rest()
|
|
580
|
+
self._background_started = True
|
|
581
|
+
self._warm_runtime()
|
|
582
|
+
self.start_update_check()
|
|
583
|
+
self.offer_update()
|
|
584
|
+
if self.client.session.locked:
|
|
585
|
+
self._start_lock_watch()
|
|
586
|
+
self._start_messaging()
|
|
587
|
+
|
|
588
|
+
def _warm_runtime(self) -> None:
|
|
589
|
+
"""Start the runtime behind the first frame: its provider, its client, its token counter.
|
|
590
|
+
|
|
591
|
+
A machine with no model configured has nothing to build — ``start`` builds a provider from the model in
|
|
592
|
+
use — so the warm-up waits until ``/config`` adds the first one and calls this again. Starting it anyway
|
|
593
|
+
would leave a failed task that the first turn would find, and a build that failed does not build again.
|
|
594
|
+
A shell whose background work never began (a tree driven directly, in a test) has no first frame to warm
|
|
595
|
+
behind, so it waits for its first turn like any other caller.
|
|
596
|
+
"""
|
|
597
|
+
if not self._background_started or self._warm is not None or self.model is None:
|
|
598
|
+
return
|
|
599
|
+
self._warm = asyncio.ensure_future(self.agent.runtime.start())
|
|
600
|
+
# Once the SDKs are loaded and the client is built, that heap is permanent: the collector has
|
|
601
|
+
# no reason to walk it again. A warm-up that failed freezes what it did load, which is as true.
|
|
602
|
+
self._warm.add_done_callback(lambda _: freeze_long_lived())
|
|
603
|
+
|
|
604
|
+
async def stop_background(self) -> None:
|
|
605
|
+
"""Release what :meth:`start_background` began, and end a request still in flight."""
|
|
606
|
+
await self._stop_messaging()
|
|
607
|
+
self._stop_lock_watch()
|
|
608
|
+
await self._stop_title_task()
|
|
609
|
+
await self._stop_update_task()
|
|
610
|
+
self.collector.close()
|
|
611
|
+
self._effort_animation = None
|
|
612
|
+
self.app.scheduler.animate(EFFORT_TOKEN, active=False)
|
|
613
|
+
measure, self._measure_task = self._measure_task, None
|
|
614
|
+
if measure is not None:
|
|
615
|
+
# A re-measure still in flight is a wait for the counter, which S8
|
|
616
|
+
# says nothing on screen may pay for and nothing on the way out may
|
|
617
|
+
# hold on to.
|
|
618
|
+
measure.cancel()
|
|
619
|
+
await asyncio.gather(measure, return_exceptions=True)
|
|
620
|
+
await self._stop_turn()
|
|
621
|
+
warm, self._warm = self._warm, None
|
|
622
|
+
if warm is not None:
|
|
623
|
+
# A warm-up still in flight is not worth waiting for: it is a launch
|
|
624
|
+
# that ends before its first turn, and the imports it is running
|
|
625
|
+
# belong to that turn. Cancelling reaches the runtime's own shutdown,
|
|
626
|
+
# which asks the import thread to give up at its next module.
|
|
627
|
+
warm = asyncio.ensure_future(warm)
|
|
628
|
+
warm.cancel()
|
|
629
|
+
# A start that failed is the first turn's error to report, not a
|
|
630
|
+
# reason to keep the process alive or to raise on the way out.
|
|
631
|
+
await asyncio.gather(warm, return_exceptions=True)
|
|
632
|
+
|
|
633
|
+
async def _stop_turn(self) -> None:
|
|
634
|
+
"""End the turn in flight, if there is one, without waiting for the answer.
|
|
635
|
+
|
|
636
|
+
The shell exits when the reader says so, whether or not a request is still
|
|
637
|
+
streaming: that turn was for someone who has already left, and the session
|
|
638
|
+
keeps everything it had already written. Letting it run on is what makes
|
|
639
|
+
leaving feel like it did not work.
|
|
640
|
+
"""
|
|
641
|
+
turn, self._task = self._task, None
|
|
642
|
+
if turn is not None and not turn.done():
|
|
643
|
+
turn.cancel()
|
|
644
|
+
if turn is not None:
|
|
645
|
+
await asyncio.gather(turn, return_exceptions=True)
|
|
646
|
+
beside = list(self._now_tasks)
|
|
647
|
+
for task in beside:
|
|
648
|
+
task.cancel()
|
|
649
|
+
await asyncio.gather(*beside, return_exceptions=True)
|
|
650
|
+
|
|
651
|
+
def adopt(self, tui: TuiApp) -> None:
|
|
652
|
+
"""Move this application's interface into a shell that is already running.
|
|
653
|
+
|
|
654
|
+
The command line paints the composer through a ``TuiApp`` it owns, then
|
|
655
|
+
hands that same app here: the tree, the keymap, the command registry,
|
|
656
|
+
and the title move over, so the widgets built here drive the terminal
|
|
657
|
+
that is already taken. The palette is left alone when this application
|
|
658
|
+
lets the terminal choose it, because the runner has already adopted the
|
|
659
|
+
one the terminal asked for.
|
|
660
|
+
|
|
661
|
+
What the reader typed into the placeholder's composer comes with it. The box on
|
|
662
|
+
screen is the placeholder's until the swap, a keystroke lands wherever the box is,
|
|
663
|
+
and the application takes a few hundred milliseconds to build — seven hundred to
|
|
664
|
+
twelve hundred on a first run after an install — so a reader who starts typing the
|
|
665
|
+
moment the frame appears would otherwise watch their words vanish when it is replaced.
|
|
666
|
+
The swap happens on the event loop, so no key can arrive between reading the draft and
|
|
667
|
+
writing it.
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
tui: Running app whose placeholder this application replaces.
|
|
671
|
+
"""
|
|
672
|
+
placeholder = self.app
|
|
673
|
+
typed = self._typed_into(tui)
|
|
674
|
+
self.app = tui
|
|
675
|
+
tui.commands = placeholder.commands
|
|
676
|
+
tui.keymap = placeholder.keymap
|
|
677
|
+
# The copy listener travels too: this application installs one to say what
|
|
678
|
+
# was copied, the placeholder carries none, and a copy is handled by the
|
|
679
|
+
# app that ends up running rather than by the one that built the tree.
|
|
680
|
+
tui.set_copy_handler(self._copied)
|
|
681
|
+
tui.title = self._terminal_title
|
|
682
|
+
tui.reduced_motion = placeholder.reduced_motion
|
|
683
|
+
tui.auto_theme = placeholder.auto_theme
|
|
684
|
+
if not placeholder.auto_theme:
|
|
685
|
+
tui.theme = placeholder.theme
|
|
686
|
+
tui.set_root(self.root)
|
|
687
|
+
if typed:
|
|
688
|
+
self.composer.set_text(typed)
|
|
689
|
+
|
|
690
|
+
@staticmethod
|
|
691
|
+
def _typed_into(tui: TuiApp) -> str:
|
|
692
|
+
"""Return the draft in the composer the placeholder tree has, or nothing if it has none."""
|
|
693
|
+
for widget in walk(tui.root):
|
|
694
|
+
if isinstance(widget, TextArea):
|
|
695
|
+
return widget.text
|
|
696
|
+
return ""
|
|
697
|
+
|
|
698
|
+
async def dry_run(self, *, width: int = 120, height: int = 40) -> None:
|
|
699
|
+
"""Run the whole start, paint one frame, and return.
|
|
700
|
+
|
|
701
|
+
Profiling the shell means paying for everything the first frame pays
|
|
702
|
+
for — the widget tree, the keymap, the runtime warming in the
|
|
703
|
+
background, and one paint — without a terminal to sit in. The frame
|
|
704
|
+
goes to a canvas that is thrown away; nothing reaches the screen.
|
|
705
|
+
|
|
706
|
+
Args:
|
|
707
|
+
width: Columns to lay the discarded frame out at.
|
|
708
|
+
height: Rows to lay the discarded frame out at.
|
|
709
|
+
"""
|
|
710
|
+
# A machine with no model has nothing to warm, and `start` refuses without one: the dry run is about the
|
|
711
|
+
# startup — the tree, the keymap, one paint — so it paints that and leaves the provider alone.
|
|
712
|
+
warm = self.agent.runtime.start() if self.model is not None else None
|
|
713
|
+
self.app.resize(width, height)
|
|
714
|
+
self.app.mount()
|
|
715
|
+
self.app.render()
|
|
716
|
+
# A warm-up that failed is the first turn's to report, exactly as it is
|
|
717
|
+
# when the shell really runs.
|
|
718
|
+
if warm is not None:
|
|
719
|
+
await asyncio.gather(warm, return_exceptions=True)
|
|
720
|
+
|
|
721
|
+
# -- commands -----------------------------------------------------------
|
|
722
|
+
@staticmethod
|
|
723
|
+
def _refused_line(error: Exception) -> str:
|
|
724
|
+
"""Return what a refused command is shown as, in a reader's words.
|
|
725
|
+
|
|
726
|
+
A run already in progress is the SDK's own refusal; the rest are the command's own sentences, which are written
|
|
727
|
+
for a reader already.
|
|
728
|
+
"""
|
|
729
|
+
return RUN_IN_PROGRESS if isinstance(error, SessionBusyError) else str(error)
|
|
730
|
+
|
|
731
|
+
async def run_side_question(self, question: str) -> None:
|
|
732
|
+
"""Stream one ``/btw`` question, marking it in the transcript as one.
|
|
733
|
+
|
|
734
|
+
The run needs the whole conversation to be useful, so it goes through
|
|
735
|
+
the same session; it is the store that keeps the exchange out of the
|
|
736
|
+
context afterwards (see :mod:`zettcode.engine.dialogue.side`). The side
|
|
737
|
+
mechanics are the extension's, started and ended around this stream;
|
|
738
|
+
what the shell adds is the marker on the turn, so the rows read as a
|
|
739
|
+
question of the reader's rather than as part of the conversation.
|
|
740
|
+
|
|
741
|
+
This is a run, not a query: the handler that owns ``/btw`` awaits it, so
|
|
742
|
+
the busy flag, the request slot, and Ctrl-C are the command path's — a
|
|
743
|
+
second ``/btw`` waits its turn like any queued command.
|
|
744
|
+
"""
|
|
745
|
+
question = question.strip()
|
|
746
|
+
if not question:
|
|
747
|
+
self.transcript.notice("ask something: /btw <question>")
|
|
748
|
+
return
|
|
749
|
+
if self.model is None:
|
|
750
|
+
# Nothing to ask, and no turn is opened for it: one sentence, where /compact gives it, instead of a turn
|
|
751
|
+
# that the stream then fails and a "btw failed" line under it. Its own sentence, not the prompt's: a
|
|
752
|
+
# command clears the composer before its handler runs, so "your words are still in the box" would lie.
|
|
753
|
+
self.transcript.error(NO_MODEL_QUESTION)
|
|
754
|
+
self.app.invalidate()
|
|
755
|
+
return
|
|
756
|
+
self.projector.begin_turn(question, side=True)
|
|
757
|
+
session = self.client.session.id
|
|
758
|
+
logger.info("side.start", extra={"session": session})
|
|
759
|
+
started = monotonic()
|
|
760
|
+
self.app.invalidate()
|
|
761
|
+
outcome = ""
|
|
762
|
+
try:
|
|
763
|
+
await self._wait_for_client()
|
|
764
|
+
async with self.client.session.ask_stream(question) as run:
|
|
765
|
+
async for _event in run:
|
|
766
|
+
self.app.invalidate()
|
|
767
|
+
except asyncio.CancelledError:
|
|
768
|
+
logger.info("side.stopped", extra={"session": session})
|
|
769
|
+
self.transcript.complete_thinking()
|
|
770
|
+
took = elapsed_text(monotonic() - started)
|
|
771
|
+
outcome = f"btw stopped after {took} {SEPARATOR} {clock_text()}"
|
|
772
|
+
raise
|
|
773
|
+
except Exception as error:
|
|
774
|
+
logger.exception("side.failed", extra={"session": session})
|
|
775
|
+
self.transcript.complete_thinking()
|
|
776
|
+
self.transcript.error(f"error: {describe_error(error)}")
|
|
777
|
+
took = elapsed_text(monotonic() - started)
|
|
778
|
+
outcome = f"btw failed after {took} {SEPARATOR} {clock_text()}"
|
|
779
|
+
else:
|
|
780
|
+
took = elapsed_text(monotonic() - started)
|
|
781
|
+
outcome = f"btw answered for {took} {SEPARATOR} {clock_text()}"
|
|
782
|
+
finally:
|
|
783
|
+
# The same order as a turn: close what the run left open, then write
|
|
784
|
+
# the line it ended on. A ``BaseException`` that is not caught above
|
|
785
|
+
# leaves the outcome unset, and the running rows are still closed.
|
|
786
|
+
self.projector.end_turn()
|
|
787
|
+
self._close_run()
|
|
788
|
+
if outcome:
|
|
789
|
+
self._settle_wait(outcome)
|
|
790
|
+
|
|
791
|
+
def _side_question_running(self) -> bool:
|
|
792
|
+
"""Return whether the run in flight is a side question, which the transcript draws only when this shell
|
|
793
|
+
asked it.
|
|
794
|
+
|
|
795
|
+
The projector's ``side_running``; the engine knows the fact and this is where the shell answers with it.
|
|
796
|
+
``/btw`` is a side question the reader asked, so it is begun as a turn and drawn. A plugin can ask the
|
|
797
|
+
session the same kind of question for its own purposes — the recap plugin does, and shows one quote of its
|
|
798
|
+
own — and that answer must not also land in the transcript as a reply: nothing on screen asked for it, and
|
|
799
|
+
the next turn's words would go on writing into it.
|
|
800
|
+
"""
|
|
801
|
+
return self.agent.runtime.sides.pending
|
|
802
|
+
|
|
803
|
+
async def prompt(self, text: str, *, shown: str | None = None, cache: str | None = None) -> bool:
|
|
804
|
+
"""Run ``text`` as one turn of this conversation, as if the reader had typed it.
|
|
805
|
+
|
|
806
|
+
The draft path is taken whole — the echo, ``@`` references, the transcript, the session, the row the
|
|
807
|
+
request ends on — rather than a run of its own, so a command that asks the model something produces a turn
|
|
808
|
+
a reader could have produced. What the command decides is the text; everything after that is the ordinary
|
|
809
|
+
one.
|
|
810
|
+
|
|
811
|
+
With ``shown``, the reader sees one thing and the model reads another: the shown text is what the turn
|
|
812
|
+
draws in the transcript, and the text is the instructions the model reads after it. Both are in the message
|
|
813
|
+
the session keeps — the instructions are not dropped, they are only kept out of what is displayed — so a
|
|
814
|
+
later turn, and a restored session, still have them in front of the model.
|
|
815
|
+
|
|
816
|
+
The turn is awaited, so the request slot stays the command's until the answer is in: a second draft cannot
|
|
817
|
+
start behind it, and the command reports what it did once it is done.
|
|
818
|
+
|
|
819
|
+
``cache`` is the turn's cache mode (``"read"``, ``"read_write"`` or ``"off"``); a command whose answer nobody
|
|
820
|
+
follows passes ``"read"`` so the request does not extend the provider's cache.
|
|
821
|
+
"""
|
|
822
|
+
if not text.strip():
|
|
823
|
+
return False
|
|
824
|
+
if self._updating:
|
|
825
|
+
self.flash("updating ZettCode \u2014 this window takes no input until it is done", level="info")
|
|
826
|
+
return False
|
|
827
|
+
if self.client.session.locked:
|
|
828
|
+
self.flash("this session is open in another ZettCode process; it is read-only here", level="error")
|
|
829
|
+
return False
|
|
830
|
+
display = (shown if shown is not None else text).strip() or text.strip()
|
|
831
|
+
if display == text.strip():
|
|
832
|
+
# No `@` pass here: the SDK resolves the references itself (it is asked for them below), and doing
|
|
833
|
+
# both is how the same instructions once reached the model twice.
|
|
834
|
+
await self._run_prompt(text, (), advance=False, cache=cache)
|
|
835
|
+
else:
|
|
836
|
+
# The brief is not the reader's message: it rides after the shown text, which is what the transcript
|
|
837
|
+
# draws. The SDK keeps both in the message and shows the reader the first.
|
|
838
|
+
await self._run_prompt(display, (), instructions=text, advance=False, cache=cache)
|
|
839
|
+
return True
|
|
840
|
+
|
|
841
|
+
async def wait_for_frame(self) -> None:
|
|
842
|
+
"""Let the shell draw the row a command has just put up.
|
|
843
|
+
|
|
844
|
+
A command such as ``/compact`` adds its working row and then calls into
|
|
845
|
+
code that does a long synchronous stretch before its first ``await``.
|
|
846
|
+
Waiting for the frame here is what turns that row from something in the
|
|
847
|
+
model into something on screen before the stretch starts.
|
|
848
|
+
"""
|
|
849
|
+
await self.app.wait_for_frame()
|
|
850
|
+
|
|
851
|
+
async def run_continue(self) -> None:
|
|
852
|
+
"""Pick up the request that was interrupted, where it stopped, as a turn of the conversation.
|
|
853
|
+
|
|
854
|
+
A request can end without finishing: the connection dropped, the reader pressed Esc, the process was killed,
|
|
855
|
+
the model hit its output limit. The history says which, and the library knows what each calls for (see
|
|
856
|
+
:class:`zett_agent.resume.ResumeKind`), so this asks it, tells the reader what it is about to do, and runs
|
|
857
|
+
it. A tool is never run again on its own: the model is told which ones were cut short and decides.
|
|
858
|
+
|
|
859
|
+
Like ``/btw`` this is run by the command that owns it, so the busy flag and the request slot are the command
|
|
860
|
+
path's. Unlike ``/btw`` it is part of the conversation, so it may be steered and what it ends on is a reply:
|
|
861
|
+
the follow-ups a stop held go out behind it.
|
|
862
|
+
"""
|
|
863
|
+
opened = self.transcript.wait_for_model()
|
|
864
|
+
session = self.client.session.id
|
|
865
|
+
logger.info("continue.start", extra={"session": session})
|
|
866
|
+
try:
|
|
867
|
+
await self.wait_for_frame()
|
|
868
|
+
info = await self.client.session.resumable()
|
|
869
|
+
if info is None:
|
|
870
|
+
message = await self.agent.resume_blocker() or "There is nothing to continue."
|
|
871
|
+
if opened:
|
|
872
|
+
self.transcript.settle_wait(message)
|
|
873
|
+
else:
|
|
874
|
+
self.transcript.notice(message)
|
|
875
|
+
logger.info("continue.nothing", extra={"session": session})
|
|
876
|
+
return
|
|
877
|
+
except BaseException as error:
|
|
878
|
+
if isinstance(error, asyncio.CancelledError):
|
|
879
|
+
logger.info("continue.stopped", extra={"session": session})
|
|
880
|
+
else:
|
|
881
|
+
logger.exception("continue.preflight_failed", extra={"session": session})
|
|
882
|
+
if opened:
|
|
883
|
+
stopped = isinstance(error, asyncio.CancelledError)
|
|
884
|
+
self.transcript.settle_wait("Continue stopped" if stopped else "Continue failed")
|
|
885
|
+
raise
|
|
886
|
+
self.transcript.notice(info.description)
|
|
887
|
+
started = monotonic()
|
|
888
|
+
completed = False
|
|
889
|
+
tally = TurnTally(self.client.usage)
|
|
890
|
+
self._turn_running = True
|
|
891
|
+
self.app.invalidate()
|
|
892
|
+
try:
|
|
893
|
+
self._refresh_tasks()
|
|
894
|
+
await self._wait_for_client()
|
|
895
|
+
async with self.client.session.resume_stream(**self._answers()) as run:
|
|
896
|
+
async for event in run:
|
|
897
|
+
tally.see(event)
|
|
898
|
+
self._refresh_tasks()
|
|
899
|
+
self.app.invalidate()
|
|
900
|
+
except asyncio.CancelledError:
|
|
901
|
+
logger.info("continue.stopped", extra={"session": session})
|
|
902
|
+
self.transcript.complete_thinking()
|
|
903
|
+
self.transcript.notice("stopped")
|
|
904
|
+
raise
|
|
905
|
+
except Exception as error:
|
|
906
|
+
logger.exception("continue.failed", extra={"session": session})
|
|
907
|
+
self.transcript.complete_thinking()
|
|
908
|
+
self.transcript.error(failure_line(error))
|
|
909
|
+
else:
|
|
910
|
+
completed = True
|
|
911
|
+
self._follow_ups_held = False
|
|
912
|
+
if self.transcript.turn_said_nothing():
|
|
913
|
+
self.transcript.notice(EMPTY_REPLY)
|
|
914
|
+
self._title_session_later(self.client.session.id)
|
|
915
|
+
finally:
|
|
916
|
+
self._close_run()
|
|
917
|
+
if completed:
|
|
918
|
+
self.transcript.summarize_changes(self.client.workspace)
|
|
919
|
+
seconds = monotonic() - started
|
|
920
|
+
logger.info(
|
|
921
|
+
"continue.end",
|
|
922
|
+
extra={"session": session, "seconds": round(seconds, 3), "completed": completed},
|
|
923
|
+
)
|
|
924
|
+
self._settle_wait(
|
|
925
|
+
f"Continued for {elapsed_text(seconds)} {SEPARATOR} {clock_text()}",
|
|
926
|
+
tally.context(self.client.usage, label="Continued", seconds=seconds, clock=clock_text()),
|
|
927
|
+
)
|
|
928
|
+
self._drop_ask()
|
|
929
|
+
self._turn_running = False
|
|
930
|
+
self._refresh_tasks()
|
|
931
|
+
self.app.invalidate()
|
|
932
|
+
|
|
933
|
+
def _lookup(self, value: str) -> tuple[Command | None, str]:
|
|
934
|
+
"""Split a typed line into the command it names and its argument.
|
|
935
|
+
|
|
936
|
+
The one place a draft is cut apart, so the composer, the router, and the
|
|
937
|
+
queue all agree on what ``/name argument`` means. An unknown name answers
|
|
938
|
+
``None`` and the whole tail as the argument.
|
|
939
|
+
"""
|
|
940
|
+
name, argument = (value.split(None, 1) + [""])[:2] if value.strip() else ("", "")
|
|
941
|
+
return next((item for item in self.commands if item.name == name), None), argument.strip()
|
|
942
|
+
|
|
943
|
+
def _is_command(self, value: str) -> bool:
|
|
944
|
+
"""Return whether a draft is a command: it opens with the name of one the shell really has.
|
|
945
|
+
|
|
946
|
+
``/usr/bin`` or ``/tmp is full`` or a typo is not one. It is text, and goes to the model like any other.
|
|
947
|
+
This is the same test the composer paints a command by, so what is drawn as a command is what runs as one.
|
|
948
|
+
"""
|
|
949
|
+
return value.startswith("/") and self._lookup(value)[0] is not None
|
|
950
|
+
|
|
951
|
+
def stop_working_command(self) -> bool:
|
|
952
|
+
"""Stop a command that handed over work, as Escape does; report whether one was working.
|
|
953
|
+
|
|
954
|
+
Enter on an empty draft means this: the reader has nothing to say and is not waiting behind a summary. The
|
|
955
|
+
row settles ``stopped``, and what was queued behaves as it does after Escape.
|
|
956
|
+
"""
|
|
957
|
+
if not (self._busy and self._command_working):
|
|
958
|
+
return False
|
|
959
|
+
if self._task is not None and not self._task.done():
|
|
960
|
+
self._task.cancel()
|
|
961
|
+
return True
|
|
962
|
+
|
|
963
|
+
def _run_command_now(self, command: Command, argument: str) -> None:
|
|
964
|
+
"""Run a command beside the request in flight, without taking the busy state.
|
|
965
|
+
|
|
966
|
+
``_run_command`` owns everything about a run being in progress: it sets the busy flag,
|
|
967
|
+
the activity and the task, and when it ends it clears them and settles the waiting
|
|
968
|
+
messages. Done for a command typed during a request, that would end the request's
|
|
969
|
+
busy state under it and hand the unread steering back as if the request were over. So
|
|
970
|
+
this does none of that. The handler runs on a task of its own, its result is shown the
|
|
971
|
+
way any command's is, and Ctrl-C and Escape still mean the request.
|
|
972
|
+
|
|
973
|
+
A failure is reported in the conversation: a command that did nothing and said nothing is
|
|
974
|
+
worse than one that failed.
|
|
975
|
+
|
|
976
|
+
Args:
|
|
977
|
+
command: The command, whose ``when_busy`` is ``"now"``.
|
|
978
|
+
argument: What followed its name.
|
|
979
|
+
"""
|
|
980
|
+
task = asyncio.create_task(self._execute_now(command, argument))
|
|
981
|
+
self._now_tasks.add(task)
|
|
982
|
+
task.add_done_callback(self._now_tasks.discard)
|
|
983
|
+
|
|
984
|
+
async def _execute_now(self, command: Command, argument: str) -> None:
|
|
985
|
+
"""Await one handler and show what it returned, or why it could not."""
|
|
986
|
+
try:
|
|
987
|
+
result = await command.handler(CommandContext(argument=argument, ui=self, host=self))
|
|
988
|
+
except COMMAND_ERRORS as error:
|
|
989
|
+
logger.warning("command.refused", extra={"command": command.name, "reason": str(error)})
|
|
990
|
+
self.transcript.error(self._refused_line(error))
|
|
991
|
+
except Exception as error: # noqa: BLE001 - a command's bug is reported, never swallowed
|
|
992
|
+
logger.exception("command.failed", extra={"command": command.name})
|
|
993
|
+
self.transcript.error(f"{command.name}: {describe_error(error)}")
|
|
994
|
+
else:
|
|
995
|
+
self._apply_result(result)
|
|
996
|
+
self.app.invalidate()
|
|
997
|
+
|
|
998
|
+
def _queue_command(self, value: str) -> None:
|
|
999
|
+
"""Hold one typed command until the request in flight is done."""
|
|
1000
|
+
self._pending.append(value)
|
|
1001
|
+
self.transcript.notice(f"queued for after this request ({len(self._pending)} waiting)")
|
|
1002
|
+
self.app.invalidate()
|
|
1003
|
+
|
|
1004
|
+
def _start_next_command(self) -> None:
|
|
1005
|
+
"""Run what was typed while the finished request was busy: a command, then follow-ups.
|
|
1006
|
+
|
|
1007
|
+
A queued command goes first. It was typed as a command, and a command such as
|
|
1008
|
+
``/btw`` does not join the conversation, so it cannot put a follow-up's answer out of
|
|
1009
|
+
order. Follow-ups then go out one at a time: each is a turn of its own, and the next
|
|
1010
|
+
waits for that turn to end. They go out only while the list is not held: after a
|
|
1011
|
+
request that was stopped or failed it is, and it stays so until a reply completes.
|
|
1012
|
+
|
|
1013
|
+
The queues go in that order, and the relay's batch is the third: a message another session sent waits behind
|
|
1014
|
+
what the reader queued. The inbox is last because its own wake-up lands while this shell is still marked busy
|
|
1015
|
+
— the runtime counts a request as ended before the shell does — so it is asked again here, where nothing runs
|
|
1016
|
+
and :meth:`~zettcode.messaging.inbox.Inbox.start_with` drains what waited.
|
|
1017
|
+
"""
|
|
1018
|
+
if self._busy:
|
|
1019
|
+
return
|
|
1020
|
+
if self._pending:
|
|
1021
|
+
self._task = asyncio.create_task(self._run_command(self._pending.pop(0)))
|
|
1022
|
+
elif self._follow_ups and not self._follow_ups_held:
|
|
1023
|
+
self._start_follow_up()
|
|
1024
|
+
else:
|
|
1025
|
+
self._start_next_relayed()
|
|
1026
|
+
if not self._busy:
|
|
1027
|
+
self._start_inbox()
|
|
1028
|
+
|
|
1029
|
+
def submit(self, value: str, *, command: bool | None = None) -> bool | None:
|
|
1030
|
+
"""Start a turn or a slash command, or take a message typed while one runs.
|
|
1031
|
+
|
|
1032
|
+
Plain text typed during a request is not refused. The reader is asked
|
|
1033
|
+
whether it should steer — handed to the agent now, adopted at its next
|
|
1034
|
+
model or tool boundary — or follow up, held and sent as a turn of its own
|
|
1035
|
+
when the request ends; either way a list above the composer shows it until
|
|
1036
|
+
then. A slash command is refused while one runs, because there is no
|
|
1037
|
+
running request to steer — unless the command itself says otherwise with
|
|
1038
|
+
``when_busy``: ``"queue"`` holds the draft and runs it the moment the request
|
|
1039
|
+
in flight ends, and ``"now"`` runs it at once, beside the request.
|
|
1040
|
+
|
|
1041
|
+
Args:
|
|
1042
|
+
value: Draft text from the composer.
|
|
1043
|
+
command: Whether the draft is a slash command. Only a command is looked up and run as one; any
|
|
1044
|
+
other text, whatever it starts with, is a message and is never checked against the commands.
|
|
1045
|
+
Left out, it is worked out from the draft (see :meth:`_is_command`).
|
|
1046
|
+
|
|
1047
|
+
Returns:
|
|
1048
|
+
False when the draft was refused; returning a true value also
|
|
1049
|
+
clears the composer.
|
|
1050
|
+
"""
|
|
1051
|
+
if command is None:
|
|
1052
|
+
command = self._is_command(value)
|
|
1053
|
+
if self._updating:
|
|
1054
|
+
# The installation is being replaced under this process: nothing typed now can be run against it, and a
|
|
1055
|
+
# half-installed package is not a thing to hand a prompt to.
|
|
1056
|
+
self.flash("updating ZettCode \u2014 this window takes no input until it is done", level="info")
|
|
1057
|
+
return False
|
|
1058
|
+
if self.client.session.locked and not command:
|
|
1059
|
+
# Only commands are taken, which is how a reader leaves: the composer does not let anything else be
|
|
1060
|
+
# typed, and this is for what arrives some other way (a draft that was there already, a recall).
|
|
1061
|
+
self.flash("this session is open in another ZettCode process; it is read-only here", level="error")
|
|
1062
|
+
return False
|
|
1063
|
+
if self._busy and self._command_working:
|
|
1064
|
+
# A command is working, not a request: the reader is not steering anything, and waiting for a summary
|
|
1065
|
+
# they no longer want is worse than stopping it. The draft is taken once the command has stopped.
|
|
1066
|
+
self._next_draft = (value, bool(command))
|
|
1067
|
+
if self._task is not None and not self._task.done():
|
|
1068
|
+
self._task.cancel()
|
|
1069
|
+
return True
|
|
1070
|
+
if self._busy:
|
|
1071
|
+
if command:
|
|
1072
|
+
# Which commands are taken mid-request is the command's own
|
|
1073
|
+
# decision, not a name the shell knows: a queued one runs the
|
|
1074
|
+
# moment the request in flight ends.
|
|
1075
|
+
chosen, argument = self._lookup(value)
|
|
1076
|
+
if chosen is None or chosen.when_busy == "refuse":
|
|
1077
|
+
self._refuse_busy()
|
|
1078
|
+
return False
|
|
1079
|
+
if chosen.when_busy == "now":
|
|
1080
|
+
self._run_command_now(chosen, argument)
|
|
1081
|
+
return True
|
|
1082
|
+
self._queue_command(value)
|
|
1083
|
+
return True
|
|
1084
|
+
if not value.strip():
|
|
1085
|
+
self._refuse_busy()
|
|
1086
|
+
return False
|
|
1087
|
+
# Plain text during a request is one of two things, and the text does not say
|
|
1088
|
+
# which: see :mod:`zettcode.app.ui.followups`.
|
|
1089
|
+
return self._send_while_busy(value)
|
|
1090
|
+
if command:
|
|
1091
|
+
self._task = asyncio.create_task(self._run_command(value))
|
|
1092
|
+
else:
|
|
1093
|
+
# A machine with no model runs nothing that is asked of it, and says what to set up instead of taking
|
|
1094
|
+
# the draft away: the words stay in the box, so setting the model up is all that is left to do. This is
|
|
1095
|
+
# checked before the parts are taken, because nothing downstream of here has a model to ask about.
|
|
1096
|
+
if self.model is None:
|
|
1097
|
+
self.transcript.error(NO_MODEL_ASKED)
|
|
1098
|
+
self.app.invalidate()
|
|
1099
|
+
return False
|
|
1100
|
+
# The parts are taken before the composer is cleared, and keep the
|
|
1101
|
+
# order the chips sit in, so text and pictures stay interleaved.
|
|
1102
|
+
parts = mention_parts(self.composer.parts(), self.mentions)
|
|
1103
|
+
if carries_image(parts) and not self.model.multimodal:
|
|
1104
|
+
# A pasted base64 image reaches the composer without the shell
|
|
1105
|
+
# having seen the clipboard, so the model's own limits are
|
|
1106
|
+
# checked again here rather than only when Ctrl-V runs.
|
|
1107
|
+
self.transcript.error(f"{self.model.name} does not take images")
|
|
1108
|
+
self.app.invalidate()
|
|
1109
|
+
return False
|
|
1110
|
+
self._task = asyncio.create_task(self._run_prompt(value, parts, instructions=None))
|
|
1111
|
+
return True
|
|
1112
|
+
|
|
1113
|
+
def _queue_steering(self, value: str, key: str | None = None, parts: Sequence[PromptPart] = ()) -> None:
|
|
1114
|
+
"""Show one message waiting for the agent to adopt it.
|
|
1115
|
+
|
|
1116
|
+
Args:
|
|
1117
|
+
value: The message as the list shows it.
|
|
1118
|
+
key: What the agent will call it when it adopts it. For text that is the text; for a
|
|
1119
|
+
message with pictures it is not, because the pictures split the text into runs
|
|
1120
|
+
that the agent's own ``text`` joins with line breaks, and matching on the typed
|
|
1121
|
+
text would leave the row in the list for good.
|
|
1122
|
+
parts: The message as the parts of a turn, kept so that a message the agent never
|
|
1123
|
+
read can be handed back with its pictures.
|
|
1124
|
+
"""
|
|
1125
|
+
self._steering.append(value)
|
|
1126
|
+
self._steering_meta.append((value if key is None else key, tuple(parts)))
|
|
1127
|
+
self._steering_sent += 1
|
|
1128
|
+
self.steering.set_messages(self._steering)
|
|
1129
|
+
self.app.request_layout()
|
|
1130
|
+
self.app.invalidate()
|
|
1131
|
+
|
|
1132
|
+
def _refuse_busy(self) -> None:
|
|
1133
|
+
"""Tell the reader a draft cannot be taken while a request runs."""
|
|
1134
|
+
self.transcript.notice("busy \u2014 Ctrl-C stops the current request")
|
|
1135
|
+
self.app.invalidate()
|
|
1136
|
+
|
|
1137
|
+
def _close_run(self) -> None:
|
|
1138
|
+
"""Close any row the request left open when it ended.
|
|
1139
|
+
|
|
1140
|
+
A tool call whose result never arrives — the reader stopped the request,
|
|
1141
|
+
or it failed — would otherwise keep saying ``Running…`` for the rest of
|
|
1142
|
+
the session, with a timer an animation step keeps bringing up.
|
|
1143
|
+
"""
|
|
1144
|
+
self.transcript.finish_running_rows("Result unavailable (request ended)")
|
|
1145
|
+
|
|
1146
|
+
def _settle_wait(self, text: str, turn: TurnContext | None = None) -> None:
|
|
1147
|
+
"""Leave the line a request ended on in the pinned row, or as a notice.
|
|
1148
|
+
|
|
1149
|
+
The wait row is the last line of the transcript while the request runs,
|
|
1150
|
+
so it is where a reader looks for the outcome, and it is settled whether
|
|
1151
|
+
the run answered, failed, or was stopped. A run that never opened one —
|
|
1152
|
+
a command working on its own — still gets the line, as a notice.
|
|
1153
|
+
|
|
1154
|
+
Args:
|
|
1155
|
+
text: The line to leave behind, such as ``Processed for 12s · 22:53``.
|
|
1156
|
+
turn: What the request came to. The line is then made of the items the plugins registered for it
|
|
1157
|
+
(the program's own, and any a plugin added), less the ones switched off in ``/statusline``;
|
|
1158
|
+
``text`` stands when no item is left to say anything.
|
|
1159
|
+
"""
|
|
1160
|
+
parts = turn_parts(self.agent.turn_segments, turn, self.agent.statusline_hidden) if turn else ()
|
|
1161
|
+
if not self.transcript.settle_wait(text, parts):
|
|
1162
|
+
self.transcript.notice(f" {SEPARATOR} ".join(parts) or text)
|
|
1163
|
+
|
|
1164
|
+
def _clear_steering(self) -> None:
|
|
1165
|
+
"""Drop the queue, for instance when the turn it belonged to ended."""
|
|
1166
|
+
self._steering_sent = 0
|
|
1167
|
+
if not self._steering:
|
|
1168
|
+
return
|
|
1169
|
+
self._steering.clear()
|
|
1170
|
+
self._steering_meta.clear()
|
|
1171
|
+
self.steering.set_messages(())
|
|
1172
|
+
self.app.request_layout()
|
|
1173
|
+
|
|
1174
|
+
def _steering_index(self, text: str) -> int | None:
|
|
1175
|
+
"""Return the place in the queue of the message the agent reports by ``text``, if it is there."""
|
|
1176
|
+
for index, value in enumerate(self._steering):
|
|
1177
|
+
key = self._steering_meta[index][0] if index < len(self._steering_meta) else value
|
|
1178
|
+
if key == text:
|
|
1179
|
+
return index
|
|
1180
|
+
return None
|
|
1181
|
+
|
|
1182
|
+
def _drop_steering(self, text: str) -> None:
|
|
1183
|
+
"""Take one message out of the queue, wherever the agent's name for it puts it."""
|
|
1184
|
+
index = self._steering_index(text)
|
|
1185
|
+
if index is not None:
|
|
1186
|
+
del self._steering[index]
|
|
1187
|
+
if index < len(self._steering_meta):
|
|
1188
|
+
del self._steering_meta[index]
|
|
1189
|
+
self.steering.set_messages(self._steering)
|
|
1190
|
+
self.app.request_layout()
|
|
1191
|
+
self.app.invalidate()
|
|
1192
|
+
|
|
1193
|
+
def _steering_started(self, text: str) -> Relayed | None:
|
|
1194
|
+
"""Drop a message the agent just adopted; the projector echoes its row.
|
|
1195
|
+
|
|
1196
|
+
Returns:
|
|
1197
|
+
The message, when it came from another session, so its row can be drawn as that session's message
|
|
1198
|
+
rather than as the reader's own; ``None`` for the reader's own message, which is drawn as typed.
|
|
1199
|
+
"""
|
|
1200
|
+
self._drop_steering(text)
|
|
1201
|
+
return self._adopted_relayed(text)
|
|
1202
|
+
|
|
1203
|
+
def _steering_interrupted(self, text: str) -> None:
|
|
1204
|
+
"""Drop a queued message a newer steering message superseded.
|
|
1205
|
+
|
|
1206
|
+
A message the run had adopted has already left the queues, and one it never adopted is recovered when the
|
|
1207
|
+
run ends; nothing here goes back to the inbox, because an interrupted message was read.
|
|
1208
|
+
"""
|
|
1209
|
+
self._drop_steering(text)
|
|
1210
|
+
|
|
1211
|
+
def _answers(self) -> dict[str, Any]:
|
|
1212
|
+
"""Return what a run is started with so that the shell's panels answer for it.
|
|
1213
|
+
|
|
1214
|
+
The panels open from the handlers and the reader chooses later, from the panel, so the requests are left open
|
|
1215
|
+
past their event (``defer_answers``) and answered with the event's own ``approve``, ``deny``, ``answer`` and
|
|
1216
|
+
``decline``. The projector draws the transcript; it does not ask.
|
|
1217
|
+
"""
|
|
1218
|
+
return {
|
|
1219
|
+
"on_approval": self._approval_requested,
|
|
1220
|
+
"on_question": self._ask_requested,
|
|
1221
|
+
"defer_answers": True,
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
async def _run_prompt(
|
|
1225
|
+
self,
|
|
1226
|
+
prompt: str,
|
|
1227
|
+
parts: Sequence[PromptPart] = (),
|
|
1228
|
+
*,
|
|
1229
|
+
instructions: str | None = None,
|
|
1230
|
+
relayed: Relayed | None = None,
|
|
1231
|
+
advance: bool = True,
|
|
1232
|
+
cache: str | None = None,
|
|
1233
|
+
) -> None:
|
|
1234
|
+
"""Stream one agent turn, keeping the task panel and transcript current.
|
|
1235
|
+
|
|
1236
|
+
Args:
|
|
1237
|
+
prompt: What the user typed, image chips included, as it is echoed
|
|
1238
|
+
into the transcript.
|
|
1239
|
+
parts: The same turn as ordered text and image parts; an empty
|
|
1240
|
+
sequence falls back to the prompt alone.
|
|
1241
|
+
instructions: What the model reads after the prompt but the reader does not: the SDK resolves the
|
|
1242
|
+
draft's ``@`` references itself, so this carries only what a command adds (see
|
|
1243
|
+
:meth:`CommandHost.prompt`). The stored message keeps ``prompt``.
|
|
1244
|
+
relayed: The turn answers a message another session sent, which the transcript draws as
|
|
1245
|
+
that, the model is told where it came from, and the session stores as that.
|
|
1246
|
+
cache: How the turn may use the provider's prompt cache; see :meth:`CommandHost.prompt`.
|
|
1247
|
+
"""
|
|
1248
|
+
if relayed is None:
|
|
1249
|
+
self.projector.begin_turn(prompt)
|
|
1250
|
+
else:
|
|
1251
|
+
self.projector.begin_turn(prompt, relayed=relayed)
|
|
1252
|
+
# Before the request goes out: a plugin that waits for the reader to stop asking cancels that wait here.
|
|
1253
|
+
self._tell_plugins(self.agent.plugin_turn_start_handlers)
|
|
1254
|
+
if self.messenger is not None:
|
|
1255
|
+
# What this turn answers decides how many hops an answer sent from it carries.
|
|
1256
|
+
self.messenger.answering = relayed
|
|
1257
|
+
session = self.client.session.id
|
|
1258
|
+
logger.info(
|
|
1259
|
+
"turn.start",
|
|
1260
|
+
extra={
|
|
1261
|
+
"session": session,
|
|
1262
|
+
"relayed": relayed is not None,
|
|
1263
|
+
"model": self.model.name if self.model is not None else None,
|
|
1264
|
+
},
|
|
1265
|
+
)
|
|
1266
|
+
started = monotonic()
|
|
1267
|
+
completed = False
|
|
1268
|
+
tally = TurnTally(self.client.usage)
|
|
1269
|
+
self._turn_running = True
|
|
1270
|
+
self._set_busy(True)
|
|
1271
|
+
self._activity = Activity.RUNNING
|
|
1272
|
+
self._note = None
|
|
1273
|
+
self.app.invalidate()
|
|
1274
|
+
try:
|
|
1275
|
+
self._refresh_tasks()
|
|
1276
|
+
await self._wait_for_client()
|
|
1277
|
+
# A turn is a message the reader wrote (text, pictures, and ``@`` references the SDK resolves with the same
|
|
1278
|
+
# registry the composer completes from), or one another session sent.
|
|
1279
|
+
if relayed is not None:
|
|
1280
|
+
run = self.client.session.relay(incoming_of(relayed), **self._answers())
|
|
1281
|
+
else:
|
|
1282
|
+
run = self.client.session.prompt(
|
|
1283
|
+
prompt_pieces(parts or (prompt,)),
|
|
1284
|
+
mentions=True,
|
|
1285
|
+
instructions=instructions,
|
|
1286
|
+
# The word is checked by the engine, which raises ValueError for one it does not know; a command
|
|
1287
|
+
# gets that as its error line rather than a turn that quietly caches the wrong way.
|
|
1288
|
+
cache=cast("CacheMode | None", cache),
|
|
1289
|
+
**self._answers(),
|
|
1290
|
+
)
|
|
1291
|
+
self._turn_answer = None
|
|
1292
|
+
async with run:
|
|
1293
|
+
async for event in run:
|
|
1294
|
+
tally.see(event)
|
|
1295
|
+
self._refresh_tasks()
|
|
1296
|
+
self.app.invalidate()
|
|
1297
|
+
self._turn_answer = run.result.text if run.result is not None else None
|
|
1298
|
+
except asyncio.CancelledError:
|
|
1299
|
+
self.transcript.complete_thinking()
|
|
1300
|
+
self.transcript.notice("stopped")
|
|
1301
|
+
except Exception as error:
|
|
1302
|
+
logger.exception("turn.failed", extra={"session": session})
|
|
1303
|
+
self.transcript.complete_thinking()
|
|
1304
|
+
self.transcript.error(failure_line(error))
|
|
1305
|
+
else:
|
|
1306
|
+
completed = True
|
|
1307
|
+
if self.transcript.turn_said_nothing():
|
|
1308
|
+
self.transcript.notice(EMPTY_REPLY)
|
|
1309
|
+
self._title_session_later(self.client.session.id)
|
|
1310
|
+
finally:
|
|
1311
|
+
seconds = monotonic() - started
|
|
1312
|
+
logger.info(
|
|
1313
|
+
"turn.end",
|
|
1314
|
+
extra={"session": session, "seconds": round(seconds, 3), "completed": completed},
|
|
1315
|
+
)
|
|
1316
|
+
took = elapsed_text(seconds)
|
|
1317
|
+
if self.messenger is not None:
|
|
1318
|
+
self.messenger.answering = None
|
|
1319
|
+
# Before the pinned row is settled, not after: what happened to the waiting
|
|
1320
|
+
# messages is part of how the request ended, and the line that says how long it
|
|
1321
|
+
# ran stays the last thing in the transcript, where a reader looks for it.
|
|
1322
|
+
self._settle_queues(completed, turn=True)
|
|
1323
|
+
self.projector.end_turn()
|
|
1324
|
+
self._close_run()
|
|
1325
|
+
# Which files this request changed, said once and only when there are any, above the
|
|
1326
|
+
# line that says how long it took.
|
|
1327
|
+
self.transcript.summarize_changes(self.client.workspace)
|
|
1328
|
+
self._settle_wait(
|
|
1329
|
+
f"Processed for {took} {SEPARATOR} {clock_text()}",
|
|
1330
|
+
tally.context(self.client.usage, label="Processed", seconds=seconds, clock=clock_text()),
|
|
1331
|
+
)
|
|
1332
|
+
self._drop_ask()
|
|
1333
|
+
self._turn_running = False
|
|
1334
|
+
self._set_busy(False)
|
|
1335
|
+
self._activity = Activity.READY
|
|
1336
|
+
self._note = None
|
|
1337
|
+
self._refresh_tasks()
|
|
1338
|
+
self.app.invalidate()
|
|
1339
|
+
# After the row the turn ended on is settled, so a plugin that reads the session or asks the model sees
|
|
1340
|
+
# the state the reader does.
|
|
1341
|
+
self._tell_plugins(self.agent.plugin_turn_end_handlers, completed)
|
|
1342
|
+
if advance:
|
|
1343
|
+
# A turn a command asked for leaves this to the command: it is still running, and what was queued
|
|
1344
|
+
# waits for it to end, not for the turn inside it.
|
|
1345
|
+
self._start_next_command()
|
|
1346
|
+
|
|
1347
|
+
def _window_event(self, event: object) -> None:
|
|
1348
|
+
"""Tell the plugins when the window gains or loses focus.
|
|
1349
|
+
|
|
1350
|
+
A focus report has no widget to route to, and a plugin that waits for the reader to step away (a recap, a
|
|
1351
|
+
notification) needs it before the next key arrives.
|
|
1352
|
+
"""
|
|
1353
|
+
if isinstance(event, FocusEvent):
|
|
1354
|
+
self._tell_plugins(self.agent.plugin_focus_handlers, event.focused)
|
|
1355
|
+
|
|
1356
|
+
def _tell_plugins(self, handlers: Sequence[Callable[..., None]], *args: object) -> None:
|
|
1357
|
+
"""Call the plugin hooks of one kind, telling a handler that raises once and carrying on."""
|
|
1358
|
+
for handler in handlers:
|
|
1359
|
+
try:
|
|
1360
|
+
handler(*args)
|
|
1361
|
+
except Exception as error: # a plugin's hook must not end the session
|
|
1362
|
+
logger.exception("plugin.hook", extra={"session": self.client.session.id})
|
|
1363
|
+
with suppress(Exception): # the bookkeeping must not be what escapes
|
|
1364
|
+
if id(handler) not in self._failed_hooks:
|
|
1365
|
+
self._failed_hooks.add(id(handler))
|
|
1366
|
+
self.transcript.error(f"a plugin hook failed: {error}")
|
|
1367
|
+
|
|
1368
|
+
def _set_busy(self, busy: bool) -> None:
|
|
1369
|
+
"""Track in-flight work and keep the frame loop alive while it runs.
|
|
1370
|
+
|
|
1371
|
+
Rows that are still waiting animate from the frame counter: the sweep
|
|
1372
|
+
travels through their label and the status dot blinks. Frames only
|
|
1373
|
+
arrive while something asks for them, and work can be started by a plain
|
|
1374
|
+
command — `/compact` summarizes for seconds before its row is done — so
|
|
1375
|
+
the flag that says "busy" is what holds the animation token.
|
|
1376
|
+
"""
|
|
1377
|
+
self._busy = busy
|
|
1378
|
+
self._sync_messaging()
|
|
1379
|
+
# The collector runs while there is work and is off while there is none, so an idle shell
|
|
1380
|
+
# and the reader's typing are never interrupted by it.
|
|
1381
|
+
if busy:
|
|
1382
|
+
self.collector.work_started()
|
|
1383
|
+
else:
|
|
1384
|
+
self.collector.work_ended()
|
|
1385
|
+
self.app.scheduler.animate("activity", active=busy)
|
|
1386
|
+
if self._follow_ups:
|
|
1387
|
+
# The list's keys line says what it is waiting for, which is a different thing
|
|
1388
|
+
# while a request runs and while none does.
|
|
1389
|
+
self._sync_follow_ups()
|
|
1390
|
+
|
|
1391
|
+
def _refresh_tasks(self) -> None:
|
|
1392
|
+
"""Mirror the agent's current plan into the panel above the composer."""
|
|
1393
|
+
if self.panel.set_tasks(plan_rows(self.client.session.tasks())):
|
|
1394
|
+
self.app.request_layout()
|
|
1395
|
+
|
|
1396
|
+
async def _run_command(self, value: str) -> None:
|
|
1397
|
+
"""Execute the matching command's handler and present its result."""
|
|
1398
|
+
command, argument = self._lookup(value)
|
|
1399
|
+
# An unknown name still needs one for the status note and the error, and
|
|
1400
|
+
# there is no command to borrow it from.
|
|
1401
|
+
name = command.name if command is not None else value.partition(" ")[0]
|
|
1402
|
+
self._activity = Activity.RUNNING
|
|
1403
|
+
self._note = f"{name} {ELLIPSIS}"
|
|
1404
|
+
# A command may take a while — `/compact` summarizes the conversation —
|
|
1405
|
+
# so it holds the same busy flag a turn does: the status glyph spins,
|
|
1406
|
+
# another submit is refused, and Ctrl-C cancels what is running.
|
|
1407
|
+
self._set_busy(True)
|
|
1408
|
+
self.app.invalidate()
|
|
1409
|
+
completed = False
|
|
1410
|
+
try:
|
|
1411
|
+
previous_session = self.client.session.id
|
|
1412
|
+
if command is None:
|
|
1413
|
+
self.transcript.error(f"Unknown command: {name}. Try /help.")
|
|
1414
|
+
completed = True
|
|
1415
|
+
else:
|
|
1416
|
+
try:
|
|
1417
|
+
result = await command.handler(CommandContext(argument=argument, ui=self, host=self))
|
|
1418
|
+
result = await self._finish_command(result)
|
|
1419
|
+
if name == "/new" and self.client.session.id != previous_session:
|
|
1420
|
+
self.transcript.clear()
|
|
1421
|
+
self.transcript.welcome(WELCOME)
|
|
1422
|
+
self.view.scroll_end()
|
|
1423
|
+
self.view.clear_selection()
|
|
1424
|
+
self._remember_session_title()
|
|
1425
|
+
self._sync_lock()
|
|
1426
|
+
self._usage = self.client.usage
|
|
1427
|
+
self._refresh_tasks()
|
|
1428
|
+
self._return_follow_ups_to_draft()
|
|
1429
|
+
self.app.request_layout()
|
|
1430
|
+
except COMMAND_ERRORS as error:
|
|
1431
|
+
logger.warning("command.refused", extra={"command": name, "reason": str(error)})
|
|
1432
|
+
self.transcript.error(self._refused_line(error))
|
|
1433
|
+
completed = True
|
|
1434
|
+
except Exception as error: # noqa: BLE001 - a command's bug must be reported and logged
|
|
1435
|
+
logger.exception("command.failed", extra={"command": name})
|
|
1436
|
+
self.transcript.error(f"{name}: {describe_error(error)}")
|
|
1437
|
+
else:
|
|
1438
|
+
self._apply_result(result)
|
|
1439
|
+
completed = True
|
|
1440
|
+
finally:
|
|
1441
|
+
# A command stopped part-way is stopped like a turn is: what was queued behind
|
|
1442
|
+
# it was written for something that did not finish, and goes back to the draft.
|
|
1443
|
+
self._settle_queues(completed, turn=False)
|
|
1444
|
+
self._set_busy(False)
|
|
1445
|
+
self._activity = Activity.READY
|
|
1446
|
+
self._note = None
|
|
1447
|
+
self._command_working = False
|
|
1448
|
+
self.app.invalidate()
|
|
1449
|
+
# A draft typed while this command worked is what the reader meant instead; it starts before anything
|
|
1450
|
+
# that was queued behind the command.
|
|
1451
|
+
if self._next_draft is not None:
|
|
1452
|
+
value, was_command = self._next_draft
|
|
1453
|
+
self._next_draft = None
|
|
1454
|
+
self.submit(value, command=was_command)
|
|
1455
|
+
else:
|
|
1456
|
+
# A queued command waited for whatever was running; with the busy
|
|
1457
|
+
# flag down again, this is where it starts.
|
|
1458
|
+
self._start_next_command()
|
|
1459
|
+
|
|
1460
|
+
async def _finish_command(self, result: CommandResult) -> CommandResult:
|
|
1461
|
+
"""Await the rest of a command that returned work to do, showing what it is doing meanwhile.
|
|
1462
|
+
|
|
1463
|
+
A command that takes seconds — a recap asks the model — returns ``run`` instead of awaiting it, so the
|
|
1464
|
+
pinned row says what is happening and the reader can stop it with Ctrl-C or Escape. A failure settles the
|
|
1465
|
+
row as an error line and is reported like any other command's; a stop settles it with ``stopped``.
|
|
1466
|
+
"""
|
|
1467
|
+
if result.run is None:
|
|
1468
|
+
return result
|
|
1469
|
+
intro = result.intro or "Working"
|
|
1470
|
+
opened = self.transcript.wait_for_model(intro, tips=False)
|
|
1471
|
+
# A command that has handed over work is the one case where the composer is not the reader's to lose: a
|
|
1472
|
+
# finished command of milliseconds is not worth cancelling, a recap of twenty seconds is.
|
|
1473
|
+
self._command_working = True
|
|
1474
|
+
self.app.invalidate()
|
|
1475
|
+
try:
|
|
1476
|
+
done = await result.run
|
|
1477
|
+
except asyncio.CancelledError:
|
|
1478
|
+
if opened:
|
|
1479
|
+
self.transcript.settle_wait(f"{intro} {SEPARATOR} stopped")
|
|
1480
|
+
raise
|
|
1481
|
+
except Exception:
|
|
1482
|
+
# The row is what the reader is watching; a failure that leaves it spinning is worse than the failure.
|
|
1483
|
+
if opened:
|
|
1484
|
+
self.transcript.settle_wait(f"{intro} {SEPARATOR} failed")
|
|
1485
|
+
raise
|
|
1486
|
+
else:
|
|
1487
|
+
if opened:
|
|
1488
|
+
self.transcript.settle_wait(f"{intro} {SEPARATOR} done")
|
|
1489
|
+
return done
|
|
1490
|
+
finally:
|
|
1491
|
+
self._command_working = False
|
|
1492
|
+
|
|
1493
|
+
def _apply_result(self, result: CommandResult) -> None:
|
|
1494
|
+
"""Show what a command returned: Markdown, a toast, a widget, a re-layout.
|
|
1495
|
+
|
|
1496
|
+
The order mirrors how the screen stack paints: transcript content and
|
|
1497
|
+
the toast go up first, then the widget is stacked on top of them, and
|
|
1498
|
+
the re-layout happens last so it measures what was actually added.
|
|
1499
|
+
"""
|
|
1500
|
+
if result.message:
|
|
1501
|
+
self.transcript.markdown(result.message)
|
|
1502
|
+
if result.notification:
|
|
1503
|
+
self.notify(result.notification, level="success")
|
|
1504
|
+
if result.widget is not None:
|
|
1505
|
+
self.app.push_screen(Screen(result.widget, name=PAGE_SCREEN, modal=True))
|
|
1506
|
+
if result.relayout:
|
|
1507
|
+
self.app.request_layout()
|