zettcode-app 0.4.2__py3-none-any.whl

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