zettcode-app 0.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. zettcode/app/__init__.py +63 -0
  2. zettcode/app/agent/__init__.py +47 -0
  3. zettcode/app/agent/agent/__init__.py +84 -0
  4. zettcode/app/agent/agent/commands.py +269 -0
  5. zettcode/app/agent/agent/settings.py +132 -0
  6. zettcode/app/agent/conversation/__init__.py +7 -0
  7. zettcode/app/agent/conversation/changes.py +64 -0
  8. zettcode/app/agent/conversation/effort_gauge.py +126 -0
  9. zettcode/app/agent/conversation/projection.py +265 -0
  10. zettcode/app/agent/conversation/rendering.py +837 -0
  11. zettcode/app/agent/conversation/replay.py +198 -0
  12. zettcode/app/agent/conversation/shimmer.py +256 -0
  13. zettcode/app/agent/conversation/stops.py +57 -0
  14. zettcode/app/agent/conversation/tips.py +86 -0
  15. zettcode/app/agent/conversation/transcript/__init__.py +80 -0
  16. zettcode/app/agent/conversation/transcript/fold.py +57 -0
  17. zettcode/app/agent/conversation/transcript/log.py +182 -0
  18. zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
  19. zettcode/app/agent/conversation/transcript/stream.py +187 -0
  20. zettcode/app/agent/conversation/transcript/turn.py +323 -0
  21. zettcode/app/commandline/__init__.py +47 -0
  22. zettcode/app/commandline/arguments.py +169 -0
  23. zettcode/app/commandline/completion.py +262 -0
  24. zettcode/app/commandline/history.py +305 -0
  25. zettcode/app/commandline/runner.py +365 -0
  26. zettcode/app/errors.py +12 -0
  27. zettcode/app/plugins/__init__.py +110 -0
  28. zettcode/app/plugins/builtins.py +33 -0
  29. zettcode/app/plugins/groups.py +503 -0
  30. zettcode/app/plugins/loader.py +268 -0
  31. zettcode/app/py.typed +0 -0
  32. zettcode/app/ui/__init__.py +63 -0
  33. zettcode/app/ui/app.py +1507 -0
  34. zettcode/app/ui/bars.py +290 -0
  35. zettcode/app/ui/boot.py +131 -0
  36. zettcode/app/ui/clipboard.py +340 -0
  37. zettcode/app/ui/commands.py +323 -0
  38. zettcode/app/ui/config_form.py +343 -0
  39. zettcode/app/ui/demo.py +180 -0
  40. zettcode/app/ui/followups.py +430 -0
  41. zettcode/app/ui/gc_policy.py +147 -0
  42. zettcode/app/ui/keys.py +462 -0
  43. zettcode/app/ui/messaging.py +260 -0
  44. zettcode/app/ui/notices.py +195 -0
  45. zettcode/app/ui/providers.py +626 -0
  46. zettcode/app/ui/sessions.py +422 -0
  47. zettcode/app/ui/settings.py +161 -0
  48. zettcode/app/ui/shell.py +361 -0
  49. zettcode/app/ui/turns.py +115 -0
  50. zettcode/app/ui/updating.py +204 -0
  51. zettcode/app/ui/widgets/__init__.py +161 -0
  52. zettcode/app/ui/widgets/approval_page.py +284 -0
  53. zettcode/app/ui/widgets/ask_page.py +386 -0
  54. zettcode/app/ui/widgets/bash_page.py +90 -0
  55. zettcode/app/ui/widgets/completer.py +100 -0
  56. zettcode/app/ui/widgets/composer.py +529 -0
  57. zettcode/app/ui/widgets/document_page.py +159 -0
  58. zettcode/app/ui/widgets/effort_page.py +294 -0
  59. zettcode/app/ui/widgets/follow_up.py +168 -0
  60. zettcode/app/ui/widgets/form_page.py +380 -0
  61. zettcode/app/ui/widgets/input_page.py +175 -0
  62. zettcode/app/ui/widgets/model_page.py +63 -0
  63. zettcode/app/ui/widgets/model_settings_page.py +653 -0
  64. zettcode/app/ui/widgets/pet.py +247 -0
  65. zettcode/app/ui/widgets/pet_page.py +347 -0
  66. zettcode/app/ui/widgets/plugins_page.py +442 -0
  67. zettcode/app/ui/widgets/root.py +68 -0
  68. zettcode/app/ui/widgets/self_compact_page.py +363 -0
  69. zettcode/app/ui/widgets/send_kind_page.py +193 -0
  70. zettcode/app/ui/widgets/sessions_page.py +511 -0
  71. zettcode/app/ui/widgets/statusline_page.py +427 -0
  72. zettcode/app/ui/widgets/steering.py +126 -0
  73. zettcode/app/ui/widgets/transcript.py +287 -0
  74. zettcode/app/ui/widgets/update_page.py +65 -0
  75. zettcode/app/ui/widgets/welcome.py +24 -0
  76. zettcode/collector.py +54 -0
  77. zettcode/update.py +519 -0
  78. zettcode_app-0.4.2.dist-info/METADATA +26 -0
  79. zettcode_app-0.4.2.dist-info/RECORD +81 -0
  80. zettcode_app-0.4.2.dist-info/WHEEL +4 -0
  81. zettcode_app-0.4.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,260 @@
1
+ """Messages between sessions: receiving one as a turn, and what the broker knows about this one.
2
+
3
+ Another ZettCode session reaches this one through the broker (:mod:`zettcode.broker`), and this
4
+ one reaches it through the model. The shell's receiving part is small, because a message is meant to
5
+ behave like one the reader typed:
6
+
7
+ * **Idle** — it starts a turn at once, drawn as a message from that session and not as the reader's
8
+ own, and everything after is an ordinary turn: the wait row, the answer, the summary of what
9
+ changed, the title, the stored session.
10
+ * **Busy** — it follows its ``mode``. A steer message (the default) is handed to the request that is running, so
11
+ it changes course now; one that cannot be steered — a command is running, the steering limit is reached, or the
12
+ sender asked for a follow-up — waits and starts when the request in flight ends, after what the reader queued.
13
+ A message from a reader's one-to-one chat window always waits, because the window is answered by the turn that
14
+ message starts.
15
+
16
+ The shell sends nothing itself. ``@name`` in a draft is resolved like any other reference
17
+ (:mod:`zettcode.mentions`): the model reads which session it means and how to reach it, and calls
18
+ ``send_message`` itself when the request calls for one.
19
+
20
+ The session also tells the broker what it is — its id, title, and whether it is busy — whenever one
21
+ of them changes, so the others see a current list. None of this can fail the shell: with no broker the
22
+ shell is exactly what it was.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ from typing import TYPE_CHECKING
29
+
30
+ from zettcode.broker.client import Receipt
31
+ from zettcode.messaging.relay import PREVIEW, Relay
32
+ from zettcode.messaging.relayed import Relayed
33
+ from zettcode.plugin_api import Inbox, Waiting
34
+
35
+ from .shell import ShellState
36
+
37
+ if TYPE_CHECKING: # pragma: no cover - annotations only
38
+ from ..agent.agent import SessionLink
39
+
40
+ #: Characters of a waiting message that the notice repeats, so the reader can tell which one it is.
41
+ _PREVIEW = PREVIEW
42
+
43
+
44
+ class MessagingMixin(ShellState):
45
+ """Hold the line to the other sessions and show what arrives, leaving the queue and the receipts to the relay."""
46
+
47
+ #: The line to the broker, or ``None`` when sessions are switched off.
48
+ messenger: SessionLink | None
49
+ #: What arrives from another session and what this one does about it: the queue, the receipts and the cap are the
50
+ #: messaging package's; this shell supplies the three answers and the way to draw a waiting message.
51
+ _relay: Relay
52
+ #: What the last turn that ran to its end answered, or ``None`` when it did not end that way.
53
+ _turn_answer: str | None
54
+ #: Whether the connection has been started; before that there is no loop to tell it anything on.
55
+ _messaging_live: bool
56
+ #: The session id the broker was last told about, to notice a switch.
57
+ _messaging_session: str | None
58
+ #: The connection's start, which returns at once but is still a task to be awaited on the way out.
59
+ _messaging_start: asyncio.Future[None] | None
60
+ #: Whether the reader has been told the broker is out of reach, so that being back is worth saying.
61
+ _messaging_trouble: bool
62
+
63
+ # -- the life of the connection ---------------------------------------------------------------
64
+ def _start_messaging(self) -> None:
65
+ """Take over the messenger's callbacks and begin connecting. Returns at once."""
66
+ messenger = self.messenger
67
+ self._start_inbox()
68
+ if messenger is None:
69
+ return
70
+ messenger.receiver = self._relay_received
71
+ # A reader's chat window (the web gateway) may send text to run as this window's own prompt.
72
+ messenger.direct = True
73
+ messenger.on_change = self._peers_changed
74
+ messenger.on_conflict = self._messaging_conflict
75
+ messenger.on_problem = self._messaging_problem
76
+ self._messaging_live = True
77
+ self._sync_messaging()
78
+ self._messaging_start = asyncio.ensure_future(messenger.start())
79
+
80
+ async def _stop_messaging(self) -> None:
81
+ """Leave the broker. Nothing here may delay leaving."""
82
+ messenger = self.messenger
83
+ self._messaging_live = False
84
+ start = self._messaging_start
85
+ if start is not None:
86
+ await asyncio.gather(start, return_exceptions=True)
87
+ if messenger is not None:
88
+ await messenger.close()
89
+
90
+ def _sync_messaging(self) -> None:
91
+ """Tell the broker what this session is now: its id, its title, whether it is busy.
92
+
93
+ Cheap when nothing changed, so it is called from every place something might have. Before the
94
+ connection is started it does nothing, because the first call after starting says it all.
95
+ """
96
+ messenger = self.messenger
97
+ if messenger is None or not self._messaging_live:
98
+ return
99
+ session = self.client.session.id
100
+ if self.client.session.locked:
101
+ # Not this session's to answer for: a message sent to it would be a turn, which is a write.
102
+ messenger.retire()
103
+ self._messaging_session = session
104
+ return
105
+ if self._messaging_session not in (None, session) and self._relay.waiting:
106
+ count = len(self._relay.waiting)
107
+ self.transcript.notice(
108
+ f"{count} message{'s' if count != 1 else ''} from other sessions "
109
+ f"{'were' if count != 1 else 'was'} waiting and will arrive in this session"
110
+ )
111
+ self._messaging_session = session
112
+ messenger.describe(session, self._session_title, busy=self._busy)
113
+
114
+ def _peers_changed(self) -> None:
115
+ """Repaint: the completion menu and the styling of an ``@name`` read the current list."""
116
+ self.app.invalidate()
117
+
118
+ def _messaging_conflict(self, message: str | None) -> None:
119
+ """Say once that this session cannot be reached, and why."""
120
+ if message:
121
+ self.transcript.notice(f"messages: {message}")
122
+ self.app.invalidate()
123
+
124
+ def _messaging_problem(self, message: str | None) -> None:
125
+ """Say that the broker cannot be reached, once it has been a while, and again when it can."""
126
+ if message:
127
+ self._messaging_trouble = True
128
+ self.transcript.notice(f"messages: {message}; trying again")
129
+ elif self._messaging_trouble:
130
+ self._messaging_trouble = False
131
+ self.transcript.notice("messages: connected to the session broker again")
132
+ self.app.invalidate()
133
+
134
+ # -- receiving --------------------------------------------------------------------------------
135
+ def _relay_received(self, relayed: Relayed) -> Receipt:
136
+ """Take a message another session sent: start a turn, steer the running one, or hold it until this one is free.
137
+
138
+ Called by the connection, on the loop, with the sender waiting for the answer; what it returns
139
+ is what the sender's agent is told. It must not block.
140
+ """
141
+ if not self._messaging_live:
142
+ return Receipt(False, "this session is closing")
143
+ if self._updating:
144
+ return Receipt(False, "this window is installing an update; try again when it is done")
145
+ if self.client.session.locked:
146
+ return Receipt(False, "this session is open in another ZettCode process, so it is read-only here")
147
+ return self._relay.receive(relayed)
148
+
149
+ def _relayed_waits(self, relayed: Relayed) -> None:
150
+ """Draw one message that now waits for the request in flight to end."""
151
+ preview = relayed.text.replace("\n", " ")
152
+ if len(preview) > _PREVIEW:
153
+ preview = preview[: _PREVIEW - 1] + "\u2026"
154
+ self.transcript.notice(f"message from {relayed.sender} waits for this request to end: {preview}")
155
+ self.app.invalidate()
156
+
157
+ def _steer_relayed(self, relayed: Relayed) -> str | None:
158
+ """Hand a message to the request in flight, if it is a turn that can take one.
159
+
160
+ A direct chat window's message is never steered: the window is answered by the turn that message starts,
161
+ and a turn somebody else started cannot answer it, so it waits like a follow-up. An accepted steer counts
162
+ against the same limit the reader's own steering does — the run has one inbox, whoever filled it.
163
+
164
+ Returns:
165
+ The text the run will report the message by, or ``None`` when it was not handed over.
166
+ """
167
+ if relayed.direct or self._steer_blocked() is not None:
168
+ return None
169
+ key = self.client.session._steer_message(relayed.message())
170
+ if key is None:
171
+ return None
172
+ self._steering_sent += 1
173
+ return key
174
+
175
+ def _adopted_relayed(self, text: str) -> Relayed | None:
176
+ """Take the message the running request just adopted, if it was relayed, so its row can be drawn as one."""
177
+ return self._relay.adopted(text)
178
+
179
+ def _settle_relayed_steering(self) -> None:
180
+ """After a request: messages it never adopted wait for the next turn rather than being lost.
181
+
182
+ The relay keeps them, so this only lets it say so: a message that was interrupted after the run adopted it
183
+ left when the run reported the adoption, and nothing the model has already read is ever run again.
184
+ """
185
+ self._relay.settle()
186
+
187
+ def _start_inbox(self) -> None:
188
+ """Give the runtime's inbox this shell's own way of starting a turn.
189
+
190
+ One queue, one starter: an event from outside — a trigger, a plugin asking for itself, a program driving this
191
+ client — and a message another session sent both wait in the runtime's inbox, and this is where they become
192
+ turns. A message from another session keeps its richer path once it is started (the row in the transcript and
193
+ the recovery of a steer the request never adopted), because the inbox hands the ``Relayed`` back in the item's
194
+ payload and :meth:`_start_waiting` passes it to :meth:`_start_relayed`.
195
+ """
196
+ inbox = self._runtime_inbox()
197
+ if inbox is not None:
198
+ inbox.start_with(self._start_waiting)
199
+
200
+ def _start_waiting(self, item: Waiting) -> bool:
201
+ """Start one turn from a message the inbox held, and say whether it is under way.
202
+
203
+ A message another session sent comes back with the ``Relayed`` in its payload, and runs the way it always has:
204
+ drawn as that session's, answering a window when it is one. Anything else is a turn of the reader's own.
205
+ """
206
+ if self._busy:
207
+ return False
208
+ if isinstance(item.payload, Relayed):
209
+ self._start_relayed(item.payload)
210
+ return True
211
+ message = item.message
212
+ text = message if isinstance(message, str) else message.text
213
+ if not text.strip():
214
+ return False
215
+ self._set_busy(True)
216
+ self._task = asyncio.create_task(self._run_prompt(text, (text,)))
217
+ return True
218
+
219
+ def _runtime_inbox(self) -> Inbox | None:
220
+ """Return the runtime's inbox, or ``None`` when the messaging part did not load."""
221
+ inbox = getattr(self.client, "inbox", None)
222
+ return inbox if isinstance(inbox, Inbox) else None
223
+
224
+ def _start_relayed(self, relayed: Relayed) -> None:
225
+ """Run one received message as a turn of its own."""
226
+ # Busy before the task has run, exactly as for a follow-up: between here and its first step a
227
+ # second message, or a keystroke, must not be able to start a turn beside this one.
228
+ self._set_busy(True)
229
+ if relayed.direct:
230
+ self._task = asyncio.create_task(self._run_direct(relayed))
231
+ else:
232
+ self._task = asyncio.create_task(self._run_prompt(relayed.text, (relayed.text,), relayed=relayed))
233
+
234
+ async def _run_direct(self, relayed: Relayed) -> None:
235
+ """Run a chat window's text as this session's own prompt, then send the final answer back to the window."""
236
+ from zettcode.sdk.types import DIRECT_EMPTY, DIRECT_UNFINISHED, IncomingMessage
237
+
238
+ self._turn_answer = None
239
+ await self._run_prompt(relayed.text, (relayed.text,))
240
+ # This turn's own answer, not the last one in the conversation: a turn that failed or was stopped has none, and
241
+ # the window is told so rather than handed the previous turn's words as this one's.
242
+ answer = self._turn_answer
243
+ message = IncomingMessage(
244
+ sender_id=relayed.sender_id, sender=relayed.sender, text=relayed.text, via=relayed.via, hops=relayed.hops
245
+ )
246
+ if answer is None:
247
+ await self.client.send_back(message, DIRECT_UNFINISHED)
248
+ else:
249
+ await self.client.send_back(message, answer if answer.strip() else DIRECT_EMPTY)
250
+
251
+ def _start_next_relayed(self) -> bool:
252
+ """Start the oldest waiting messages, if there are any and nothing is running.
253
+
254
+ The ones that waited start as a single turn (see :func:`~zettcode.messaging.relayed.batch_size`): a window that was busy
255
+ answers them together instead of one turn each, and a chat window's message is always a turn of its own. The
256
+ queue is the relay's; this only refuses while the window is installing an update.
257
+ """
258
+ if self._updating:
259
+ return False
260
+ return self._relay.start_next()
@@ -0,0 +1,195 @@
1
+ """The shell's notices: approvals, toasts, and the usage mirror.
2
+
3
+ These are the small things shown beside the conversation — a suspended shell
4
+ command waiting for a decision, a one-line confirmation, the token counters the
5
+ status line reads. They live together because they write to the same places: a
6
+ screen, the status line, and the transcript.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import replace
12
+ from typing import TYPE_CHECKING
13
+
14
+ from zettcode.plugin_api.commands import CommandUi
15
+ from zettcode.sdk.types import Usage
16
+ from zettcode.tui import Anchor, Overlay, OverlaySlot, Screen, Toast
17
+
18
+ from .shell import APPROVAL_ROWS, ASK_SCREEN, PAGE_SCREEN, ShellState
19
+ from .widgets import ApprovalChoice, ApprovalPage, AskUserPage, bottom_panel
20
+
21
+ if TYPE_CHECKING: # pragma: no cover - annotations only
22
+ from zettcode.sdk.events import ApprovalRequested, QuestionAsked
23
+
24
+
25
+ class NoticesMixin(CommandUi, ShellState):
26
+ """Show approvals, toasts, and the usage counters."""
27
+
28
+ def _usage_updated(self, snapshot: Usage, session_id: str) -> None:
29
+ """Mirror the usage extension's cumulative counters into the status line.
30
+
31
+ Only this session's: a subagent works in a session of its own, and its
32
+ totals describe a conversation this shell never sent. Painting them here
33
+ would show another context's size as this one's.
34
+ """
35
+ if session_id != self.client.session.id:
36
+ return
37
+ model = self.model
38
+ if model is not None and snapshot.context_tokens >= model.context_window:
39
+ # A request cannot carry more than the window it is measured against,
40
+ # so a figure that does is not this request's size: it belongs to
41
+ # another call or to a provider counting to a rule of its own, and
42
+ # painting it would pin the share at a hundred while the context has
43
+ # room to spare. The counters still move — they are cumulative and
44
+ # honest — but the size waits for a measurement to replace it, which
45
+ # is what the reader's next answer brings. The previous reading stands
46
+ # in the meantime rather than a number that cannot be true.
47
+ snapshot = replace(snapshot, context_tokens=self._usage.context_tokens)
48
+ self._remeasure_context_later()
49
+ self._usage = snapshot
50
+ self.app.invalidate()
51
+
52
+ def _approval_requested(self, event: ApprovalRequested) -> None:
53
+ """Show the approval panel for one pending shell command.
54
+
55
+ The panel is an :class:`ApprovalPage` in a bottom strip: the question,
56
+ the command with its shell highlighting, and the numbered choices, so
57
+ the transcript above stays readable while the command is reviewed.
58
+ """
59
+ page = ApprovalPage(
60
+ event.command,
61
+ on_choice=lambda choice: self._respond(event, choice),
62
+ remember_supported=event.remember_supported,
63
+ permission=self.client.permission,
64
+ work_dir=event.work_dir,
65
+ )
66
+ panel = bottom_panel(page, rows=APPROVAL_ROWS)
67
+ self.app.push_screen(Screen(panel, name=PAGE_SCREEN, modal=True))
68
+
69
+ def _respond(self, event: ApprovalRequested, choice: ApprovalChoice) -> None:
70
+ """Dismiss the panel and answer the suspended agent run.
71
+
72
+ Args:
73
+ event: The request the decision answers.
74
+ choice: What the user picked; ``safe`` and ``auto`` also move the
75
+ permission level up a rung, which is the same change ``/permission``
76
+ makes — including that the session remembers it.
77
+ """
78
+ self.close_page()
79
+ try:
80
+ if choice is ApprovalChoice.ABORT:
81
+ event.deny()
82
+ else:
83
+ event.approve(remember=choice is ApprovalChoice.ALWAYS)
84
+ except Exception as error:
85
+ self.transcript.error(f"approval failed: {error}")
86
+ else:
87
+ if choice is ApprovalChoice.RUN_SAFE:
88
+ self.change_permission("safe")
89
+ elif choice is ApprovalChoice.RUN_AUTO:
90
+ self.change_permission("full")
91
+ self.app.invalidate()
92
+
93
+ # -- questions from the model -------------------------------------------
94
+ def _ask_requested(self, question: QuestionAsked) -> None:
95
+ """Show the panel for one question the model is waiting on.
96
+
97
+ The run is suspended until the panel answers, so this is a modal layer
98
+ like the approval prompt: the question, its options, and the answer line
99
+ stay on screen while the conversation above them is read. One response
100
+ may carry several questions; they queue behind the one on screen and are
101
+ asked in turn, because each suspends the same run.
102
+ """
103
+ if not self._asks:
104
+ self._ask_total = 0
105
+ self._asks.append(question)
106
+ self._ask_total += 1
107
+ if len(self._asks) == 1:
108
+ self._show_ask()
109
+ else:
110
+ self.transcript.notice(f"the model asked another question; {len(self._asks) - 1} waiting behind this one")
111
+
112
+ def _show_ask(self) -> None:
113
+ """Put the question at the head of the queue on screen."""
114
+ question = self._asks[0]
115
+ page = AskUserPage(
116
+ question,
117
+ on_answer=lambda answer: self._answer_ask(question, answer),
118
+ on_cancel=lambda: self._cancel_ask(question),
119
+ position=lambda: (self._ask_total - len(self._asks) + 1, self._ask_total),
120
+ )
121
+ panel = bottom_panel(page, rows=min(page.preferred_height(self.app.width), max(5, self.app.height - 6)))
122
+ self.app.push_screen(Screen(panel, name=ASK_SCREEN, modal=True))
123
+ self.app.invalidate()
124
+
125
+ def _answer_ask(self, question: QuestionAsked, answer: str) -> None:
126
+ """Send the reader's answer to the suspended tool call, then ask the next."""
127
+ self._forget_ask(question)
128
+ self.close_page()
129
+ try:
130
+ question.answer(answer)
131
+ except Exception as error:
132
+ self.transcript.error(f"answer failed: {error}")
133
+ self._next_ask()
134
+
135
+ def _cancel_ask(self, question: QuestionAsked) -> None:
136
+ """Decline the question, closing the panel that asked it."""
137
+ self._forget_ask(question)
138
+ self.close_page()
139
+ try:
140
+ question.decline()
141
+ except Exception as error:
142
+ self.transcript.error(f"cancel failed: {error}")
143
+ else:
144
+ self.transcript.notice("question declined; the model was told")
145
+ self._next_ask()
146
+
147
+ def _forget_ask(self, question: QuestionAsked) -> None:
148
+ """Drop one question from the pending list, if it is still in it."""
149
+ if question in self._asks:
150
+ self._asks.remove(question)
151
+
152
+ def _next_ask(self) -> None:
153
+ """Show the question that was waiting behind the one just answered."""
154
+ if self._asks:
155
+ self._show_ask()
156
+ else:
157
+ self._ask_total = 0
158
+
159
+ def _drop_ask(self) -> None:
160
+ """Close the question panels a finished run left behind, with no answer.
161
+
162
+ Nothing is emitted: the request that asked is already over, and the
163
+ runtime has dropped its pending route, so an answer would go nowhere.
164
+ """
165
+ self._asks.clear()
166
+ self._ask_total = 0
167
+ while self.app.screens.top.name == ASK_SCREEN:
168
+ self.app.pop_screen()
169
+
170
+ def notify(self, message: str, *, level: str = "info") -> None:
171
+ """Show a toast, replacing any toast that is still on screen.
172
+
173
+ Args:
174
+ message: Text shown in the toast.
175
+ level: Colour key understood by ``Toast``.
176
+ """
177
+ while len(self.app.screens) > 1 and self.app.screens.top.name == "toast":
178
+ self.app.pop_screen()
179
+ toast = Toast(message, level=level, duration=2)
180
+ overlay = Overlay([OverlaySlot(toast, Anchor(horizontal="end", vertical="end", offset_x=-1, offset_y=-1))])
181
+ # A paint-only layer: the toast shows over the shell but must not take
182
+ # input, or scrolling the transcript under it would stop working.
183
+ self.app.push_screen(Screen(overlay, name="toast", interactive=False))
184
+
185
+ def markdown(self, text: str) -> None:
186
+ """Append Markdown to the transcript, parsed like an answer."""
187
+ self.transcript.markdown(text)
188
+
189
+ def notice(self, text: str) -> None:
190
+ """Append a muted one-line remark."""
191
+ self.transcript.notice(text)
192
+
193
+ def error(self, text: str) -> None:
194
+ """Append a one-line failure, painted as an error."""
195
+ self.transcript.error(text)