zettcode-app 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zettcode/app/__init__.py +63 -0
- zettcode/app/agent/__init__.py +47 -0
- zettcode/app/agent/agent/__init__.py +84 -0
- zettcode/app/agent/agent/commands.py +269 -0
- zettcode/app/agent/agent/settings.py +132 -0
- zettcode/app/agent/conversation/__init__.py +7 -0
- zettcode/app/agent/conversation/changes.py +64 -0
- zettcode/app/agent/conversation/effort_gauge.py +126 -0
- zettcode/app/agent/conversation/projection.py +265 -0
- zettcode/app/agent/conversation/rendering.py +837 -0
- zettcode/app/agent/conversation/replay.py +198 -0
- zettcode/app/agent/conversation/shimmer.py +256 -0
- zettcode/app/agent/conversation/stops.py +57 -0
- zettcode/app/agent/conversation/tips.py +86 -0
- zettcode/app/agent/conversation/transcript/__init__.py +80 -0
- zettcode/app/agent/conversation/transcript/fold.py +57 -0
- zettcode/app/agent/conversation/transcript/log.py +182 -0
- zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
- zettcode/app/agent/conversation/transcript/stream.py +187 -0
- zettcode/app/agent/conversation/transcript/turn.py +323 -0
- zettcode/app/commandline/__init__.py +47 -0
- zettcode/app/commandline/arguments.py +169 -0
- zettcode/app/commandline/completion.py +262 -0
- zettcode/app/commandline/history.py +305 -0
- zettcode/app/commandline/runner.py +365 -0
- zettcode/app/errors.py +12 -0
- zettcode/app/plugins/__init__.py +110 -0
- zettcode/app/plugins/builtins.py +33 -0
- zettcode/app/plugins/groups.py +503 -0
- zettcode/app/plugins/loader.py +268 -0
- zettcode/app/py.typed +0 -0
- zettcode/app/ui/__init__.py +63 -0
- zettcode/app/ui/app.py +1507 -0
- zettcode/app/ui/bars.py +290 -0
- zettcode/app/ui/boot.py +131 -0
- zettcode/app/ui/clipboard.py +340 -0
- zettcode/app/ui/commands.py +323 -0
- zettcode/app/ui/config_form.py +343 -0
- zettcode/app/ui/demo.py +180 -0
- zettcode/app/ui/followups.py +430 -0
- zettcode/app/ui/gc_policy.py +147 -0
- zettcode/app/ui/keys.py +462 -0
- zettcode/app/ui/messaging.py +260 -0
- zettcode/app/ui/notices.py +195 -0
- zettcode/app/ui/providers.py +626 -0
- zettcode/app/ui/sessions.py +422 -0
- zettcode/app/ui/settings.py +161 -0
- zettcode/app/ui/shell.py +361 -0
- zettcode/app/ui/turns.py +115 -0
- zettcode/app/ui/updating.py +204 -0
- zettcode/app/ui/widgets/__init__.py +161 -0
- zettcode/app/ui/widgets/approval_page.py +284 -0
- zettcode/app/ui/widgets/ask_page.py +386 -0
- zettcode/app/ui/widgets/bash_page.py +90 -0
- zettcode/app/ui/widgets/completer.py +100 -0
- zettcode/app/ui/widgets/composer.py +529 -0
- zettcode/app/ui/widgets/document_page.py +159 -0
- zettcode/app/ui/widgets/effort_page.py +294 -0
- zettcode/app/ui/widgets/follow_up.py +168 -0
- zettcode/app/ui/widgets/form_page.py +380 -0
- zettcode/app/ui/widgets/input_page.py +175 -0
- zettcode/app/ui/widgets/model_page.py +63 -0
- zettcode/app/ui/widgets/model_settings_page.py +653 -0
- zettcode/app/ui/widgets/pet.py +247 -0
- zettcode/app/ui/widgets/pet_page.py +347 -0
- zettcode/app/ui/widgets/plugins_page.py +442 -0
- zettcode/app/ui/widgets/root.py +68 -0
- zettcode/app/ui/widgets/self_compact_page.py +363 -0
- zettcode/app/ui/widgets/send_kind_page.py +193 -0
- zettcode/app/ui/widgets/sessions_page.py +511 -0
- zettcode/app/ui/widgets/statusline_page.py +427 -0
- zettcode/app/ui/widgets/steering.py +126 -0
- zettcode/app/ui/widgets/transcript.py +287 -0
- zettcode/app/ui/widgets/update_page.py +65 -0
- zettcode/app/ui/widgets/welcome.py +24 -0
- zettcode/collector.py +54 -0
- zettcode/update.py +519 -0
- zettcode_app-0.4.2.dist-info/METADATA +26 -0
- zettcode_app-0.4.2.dist-info/RECORD +81 -0
- zettcode_app-0.4.2.dist-info/WHEEL +4 -0
- zettcode_app-0.4.2.dist-info/entry_points.txt +2 -0
|
@@ -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)
|