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,422 @@
|
|
|
1
|
+
"""Session switching, restoration, and the background title.
|
|
2
|
+
|
|
3
|
+
Switching sessions replaces the visible history, restores the store's title,
|
|
4
|
+
and asks the agent to name a new session once, off the composer's critical path.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from zettcode.app.errors import COMMAND_ERRORS
|
|
16
|
+
from zettcode.collector import collector_paused
|
|
17
|
+
from zettcode.plugin_api.state import Activity
|
|
18
|
+
from zettcode.presenters.presenters import block_for
|
|
19
|
+
from zettcode.sdk.types import StoredSession
|
|
20
|
+
from zettcode.tui import ELLIPSIS, SEPARATOR, Theme
|
|
21
|
+
|
|
22
|
+
from ..agent.conversation.replay import Replayed, replay
|
|
23
|
+
from .shell import ShellState
|
|
24
|
+
from .widgets import LOCK_CAPTIONS, UPDATING_CAPTIONS, WELCOME
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
27
|
+
from zettcode.sdk.types import ModelInfo
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
#: Seconds between the tries to take the lock of a session another process has open: long enough to cost
|
|
31
|
+
#: nothing, short enough that the session is the reader's within a moment of the other process letting go.
|
|
32
|
+
LOCK_POLL_SECONDS = 2.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class LoadedSession:
|
|
37
|
+
"""A stored session and the rows the terminal built from it, ready to install.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
stored: What the engine read: the parsed file, its totals, title and model.
|
|
41
|
+
replayed: The rows the terminal replayed it into, already measured.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
stored: StoredSession
|
|
45
|
+
replayed: Replayed
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SessionMixin(ShellState):
|
|
49
|
+
"""Switch the active session and keep its title current."""
|
|
50
|
+
|
|
51
|
+
#: The task that keeps trying to take the lock of a session another process has open.
|
|
52
|
+
_lock_watch: asyncio.Task[None] | None
|
|
53
|
+
#: Whether the reader has been told the session is read-only here, so that is said once.
|
|
54
|
+
_lock_said: str | None
|
|
55
|
+
|
|
56
|
+
# -- the lock on the session -------------------------------------------------------------------
|
|
57
|
+
def _sync_lock(self) -> None:
|
|
58
|
+
"""Make the composer, the messaging and the watcher agree with who holds the active session's lock.
|
|
59
|
+
|
|
60
|
+
A session another process has open is shown and not written to: the composer shows a lock and takes
|
|
61
|
+
only commands, the session does not register with the broker (so nobody sends it a message it would
|
|
62
|
+
turn into a write), and a task tries again every few seconds. Called wherever the active session may
|
|
63
|
+
have changed: starting, resuming, forking, ``/new``.
|
|
64
|
+
|
|
65
|
+
An upgrade in progress locks it too, and for a reason of its own: the code that would answer is being
|
|
66
|
+
replaced. This is the one place that decides, so the watcher below and the upgrade's own end cannot
|
|
67
|
+
disagree about it.
|
|
68
|
+
"""
|
|
69
|
+
updating = self._updating
|
|
70
|
+
held = self.client.session.locked
|
|
71
|
+
self.composer.lock_captions = UPDATING_CAPTIONS if updating else LOCK_CAPTIONS
|
|
72
|
+
self.composer.locked = held or updating
|
|
73
|
+
# The notice and the watcher are about a session another process holds: an upgrade locks the window for a
|
|
74
|
+
# different reason, and saying "open in another process" then would be false.
|
|
75
|
+
if not held:
|
|
76
|
+
self._lock_said = None
|
|
77
|
+
self._stop_lock_watch()
|
|
78
|
+
else:
|
|
79
|
+
if self._lock_said != self.client.session.id:
|
|
80
|
+
self._lock_said = self.client.session.id
|
|
81
|
+
self.transcript.notice(
|
|
82
|
+
"This session is open in another ZettCode process, so it is read-only here. "
|
|
83
|
+
"It unlocks by itself when that one lets go; /new starts a new session, "
|
|
84
|
+
"/fork carries this conversation into one."
|
|
85
|
+
)
|
|
86
|
+
self._start_lock_watch()
|
|
87
|
+
self._sync_messaging()
|
|
88
|
+
self.app.invalidate()
|
|
89
|
+
|
|
90
|
+
def _start_lock_watch(self) -> None:
|
|
91
|
+
"""Begin trying to take the lock again, when there is a loop to try on."""
|
|
92
|
+
if self._lock_watch is not None and not self._lock_watch.done():
|
|
93
|
+
return
|
|
94
|
+
try:
|
|
95
|
+
self._lock_watch = asyncio.get_running_loop().create_task(self._watch_lock())
|
|
96
|
+
except RuntimeError:
|
|
97
|
+
self._lock_watch = None # the command line builds the shell with no loop; starting it again starts this
|
|
98
|
+
|
|
99
|
+
def _stop_lock_watch(self) -> None:
|
|
100
|
+
"""Stop trying: the session is ours, or another one is open."""
|
|
101
|
+
watch, self._lock_watch = self._lock_watch, None
|
|
102
|
+
if watch is not None and watch is not asyncio.current_task():
|
|
103
|
+
watch.cancel()
|
|
104
|
+
|
|
105
|
+
async def _watch_lock(self) -> None:
|
|
106
|
+
"""Try to take the lock until it is had, then load the session again: it moved while it was not ours."""
|
|
107
|
+
session_id = self.client.session.id
|
|
108
|
+
while self.client.session.locked and self.client.session.id == session_id:
|
|
109
|
+
await asyncio.sleep(LOCK_POLL_SECONDS)
|
|
110
|
+
if self._busy or self._updating or self.client.session.id != session_id:
|
|
111
|
+
# Nothing is claimed while the window is taken: `_lock_taken` would refuse to reload it, and the
|
|
112
|
+
# conversation read before the other process wrote to it would be the one left on screen. The next
|
|
113
|
+
# poll claims it once there is nothing running.
|
|
114
|
+
continue
|
|
115
|
+
if self.client.session.retake():
|
|
116
|
+
await self._lock_taken(session_id)
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
def _retry_lock(self) -> None:
|
|
120
|
+
"""Enter on a locked composer: try for the lock now, and say how it went."""
|
|
121
|
+
if self._busy or self._updating:
|
|
122
|
+
return
|
|
123
|
+
session_id = self.client.session.id
|
|
124
|
+
if self.client.session.retake():
|
|
125
|
+
self._task = asyncio.create_task(self._lock_taken(session_id))
|
|
126
|
+
else:
|
|
127
|
+
self.flash("still open in another ZettCode process", level="error")
|
|
128
|
+
|
|
129
|
+
async def _lock_taken(self, session_id: str) -> None:
|
|
130
|
+
"""The other process let go: read the session again, so what it wrote is what is on screen, and say so.
|
|
131
|
+
|
|
132
|
+
The conversation shown was read when the lock was not ours, and the process that had it has been
|
|
133
|
+
writing to it since; typing into that would continue a conversation that is no longer there.
|
|
134
|
+
"""
|
|
135
|
+
if self._busy or self._updating:
|
|
136
|
+
return
|
|
137
|
+
await self._switch_session(session_id)
|
|
138
|
+
if self.client.session.id == session_id and not self.client.session.locked:
|
|
139
|
+
self.transcript.notice("The other process has let go of this session; it is yours now.")
|
|
140
|
+
self.app.invalidate()
|
|
141
|
+
|
|
142
|
+
async def rename_session(self, title: str) -> str:
|
|
143
|
+
"""Name the active session and show it in the status line.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
title: Name the user typed; the store rejects a blank or over-long
|
|
147
|
+
one, which the command turns into a notice.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The stored title, so the command can confirm it.
|
|
151
|
+
"""
|
|
152
|
+
# No wait for the run slot: a title of the current conversation is a store write, which the SDK allows beside
|
|
153
|
+
# a run (`Session.rename` checks only when the name would switch conversations).
|
|
154
|
+
renamed = await self.client.session.rename(title)
|
|
155
|
+
self._session_title = renamed
|
|
156
|
+
self._sync_messaging()
|
|
157
|
+
self.app.invalidate()
|
|
158
|
+
return renamed
|
|
159
|
+
|
|
160
|
+
def select_session(self, session_id: str) -> None:
|
|
161
|
+
"""Switch to the session picked in the panel, and close the panel.
|
|
162
|
+
|
|
163
|
+
Resuming reads and rebuilds a whole session, which on a long one is a
|
|
164
|
+
noticeable part of a second; it runs behind the interface rather than in
|
|
165
|
+
front of it, as a command does, so the panel closes at once and the status
|
|
166
|
+
line says what is happening while the rows are made.
|
|
167
|
+
"""
|
|
168
|
+
if self._busy:
|
|
169
|
+
return # a request or an earlier resume is already in flight
|
|
170
|
+
# Close the panel first: the toast is its own screen, and closing it
|
|
171
|
+
# instead of the panel would leave the picker on top.
|
|
172
|
+
self.close_page()
|
|
173
|
+
self._task = asyncio.create_task(self._switch_session(session_id))
|
|
174
|
+
|
|
175
|
+
async def _switch_session(self, session_id: str) -> None:
|
|
176
|
+
"""Resume a session as a task, holding the busy flag the way a command does."""
|
|
177
|
+
self._activity = Activity.RUNNING
|
|
178
|
+
self._note = f"resume {ELLIPSIS}"
|
|
179
|
+
self._set_busy(True)
|
|
180
|
+
self.app.invalidate()
|
|
181
|
+
try:
|
|
182
|
+
await self.resume_session(session_id)
|
|
183
|
+
except COMMAND_ERRORS as error:
|
|
184
|
+
self.transcript.error(str(error))
|
|
185
|
+
else:
|
|
186
|
+
self.notify(f"using session {session_id[:8]}", level="success")
|
|
187
|
+
finally:
|
|
188
|
+
self._set_busy(False)
|
|
189
|
+
self._activity = Activity.READY
|
|
190
|
+
self._note = None
|
|
191
|
+
self.app.invalidate()
|
|
192
|
+
self._start_next_command()
|
|
193
|
+
|
|
194
|
+
async def resume_session(self, session_id: str) -> None:
|
|
195
|
+
"""Restore a session without holding the event loop for the work.
|
|
196
|
+
|
|
197
|
+
The reading, the parsing, and the building of rows — and the first
|
|
198
|
+
measuring of those rows at the width the view has now — happen on a worker
|
|
199
|
+
thread; what is left for the loop is the assignment that makes the result
|
|
200
|
+
the visible conversation. While it runs the interface keeps drawing and
|
|
201
|
+
taking keys, which a resume that parsed the file in the handler did not.
|
|
202
|
+
"""
|
|
203
|
+
stored = await asyncio.to_thread(self._load_session, session_id)
|
|
204
|
+
self._install_session(stored)
|
|
205
|
+
|
|
206
|
+
async def fork_session(self) -> str:
|
|
207
|
+
"""Carry this conversation into a new session, behind the interface.
|
|
208
|
+
|
|
209
|
+
Writing the copy is file work over everything the conversation carries, so it
|
|
210
|
+
runs on a worker like a resume does, under the same busy flag: the reader keeps
|
|
211
|
+
their keys, another command is refused, and the status line says what is
|
|
212
|
+
happening while the fork is written. The new session is then installed the way
|
|
213
|
+
a resumed one is, so being in a fork is like being in anything else.
|
|
214
|
+
"""
|
|
215
|
+
# The session being left, named before it is left: the notice says where the
|
|
216
|
+
# reader came from, which is only knowable here.
|
|
217
|
+
if not self._busy:
|
|
218
|
+
# As for a rename: wait for a run the shell did not start, never for one of its own.
|
|
219
|
+
await self._wait_for_client()
|
|
220
|
+
previous_id, previous_title = self.client.session.id, self.client.session.title
|
|
221
|
+
self._activity = Activity.RUNNING
|
|
222
|
+
self._note = f"fork {ELLIPSIS}"
|
|
223
|
+
self._set_busy(True)
|
|
224
|
+
self.app.invalidate()
|
|
225
|
+
try:
|
|
226
|
+
forked = await self.client.session.fork()
|
|
227
|
+
loaded = await asyncio.to_thread(self._load_session, forked.id)
|
|
228
|
+
finally:
|
|
229
|
+
self._set_busy(False)
|
|
230
|
+
self._activity = Activity.READY
|
|
231
|
+
self._note = None
|
|
232
|
+
self.app.invalidate()
|
|
233
|
+
self._install_session(loaded)
|
|
234
|
+
# After the install, never before: installing replaces every entry with the
|
|
235
|
+
# fork's own rows, so a line written first would be wiped by the very thing it
|
|
236
|
+
# is there to describe.
|
|
237
|
+
stored = loaded.stored
|
|
238
|
+
self.transcript.notice(fork_notice(stored.id, stored.title, previous_id, previous_title, self.client.workspace))
|
|
239
|
+
return stored.id
|
|
240
|
+
|
|
241
|
+
def restore_session(self, session_id: str, *, window: Callable[[], tuple[int, Theme]] | None = None) -> None:
|
|
242
|
+
"""Replace the visible history with the chosen persisted branch.
|
|
243
|
+
|
|
244
|
+
The same two steps :meth:`resume_session` takes, run where the caller is:
|
|
245
|
+
the command line calls this on its own worker thread, before the interface
|
|
246
|
+
has a loop to hold up.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
session_id: The session to restore.
|
|
250
|
+
window: Where the rows will be drawn — the width and the palette — when
|
|
251
|
+
this view is not laid out yet, which is the case on the command
|
|
252
|
+
line: the real tree is built behind a frame painted by another
|
|
253
|
+
app, so ``self.view`` has no size to measure at. It is a function,
|
|
254
|
+
called once the file has been read, because the terminal's size and
|
|
255
|
+
the palette it asked for are settled by then and may not have been
|
|
256
|
+
when the call was made.
|
|
257
|
+
"""
|
|
258
|
+
self._install_session(self._load_session(session_id, window=window))
|
|
259
|
+
|
|
260
|
+
def _load_session(self, session_id: str, *, window: Callable[[], tuple[int, Theme]] | None = None) -> LoadedSession:
|
|
261
|
+
"""Read a session and prepare its rows, changing nothing the shell shows.
|
|
262
|
+
|
|
263
|
+
Safe to run on a worker thread: the rows live in a transcript of their own
|
|
264
|
+
until they are installed, so nothing here is visible to the loop. The
|
|
265
|
+
blocks are measured now, at the width and palette they will be drawn in, so
|
|
266
|
+
the loop's first look at them is a cache hit; a window that has since
|
|
267
|
+
changed size simply misses and measures again, as it would have.
|
|
268
|
+
|
|
269
|
+
A view that is not laid out reports a width of one column, and measuring
|
|
270
|
+
every row at one column is the most expensive thing the transcript can be
|
|
271
|
+
asked and is no use to anyone: it is what this did before it was told where
|
|
272
|
+
the rows would go, and the first frame then measured them all again.
|
|
273
|
+
"""
|
|
274
|
+
with collector_paused():
|
|
275
|
+
stored = self.client.sessions.read_blocking(session_id)
|
|
276
|
+
transcript = self.transcript
|
|
277
|
+
replayed = replay(
|
|
278
|
+
stored.records,
|
|
279
|
+
renderers=transcript.renderers,
|
|
280
|
+
presenters=transcript.presenters,
|
|
281
|
+
frame=transcript.frame,
|
|
282
|
+
max_entries=transcript.max_entries,
|
|
283
|
+
)
|
|
284
|
+
rows = replayed.transcript
|
|
285
|
+
width, theme = window() if window is not None else (self.view.line_width, self.view.theme)
|
|
286
|
+
for entry in rows.entries:
|
|
287
|
+
block_for(entry, width, theme, rows.frame, presenters=rows.presenters).count(width)
|
|
288
|
+
return LoadedSession(stored, replayed)
|
|
289
|
+
|
|
290
|
+
def _install_session(self, loaded: LoadedSession) -> None:
|
|
291
|
+
"""Make a loaded session the visible one. Assignment only; runs on the loop."""
|
|
292
|
+
previous = self.model
|
|
293
|
+
self.transcript.replace(loaded.replayed.transcript.entries)
|
|
294
|
+
self.client.sessions.install(loaded.stored)
|
|
295
|
+
if not self.transcript.entries:
|
|
296
|
+
self.transcript.welcome(WELCOME)
|
|
297
|
+
self._announce_restored_model(previous, loaded.stored)
|
|
298
|
+
self.view.follow_tail = True
|
|
299
|
+
self.view.clear_selection()
|
|
300
|
+
self._session_title = loaded.stored.title
|
|
301
|
+
self._sync_lock()
|
|
302
|
+
self._usage = self.client.usage
|
|
303
|
+
self._refresh_tasks()
|
|
304
|
+
# The held follow-ups continued the conversation being left; they are handed back
|
|
305
|
+
# as text, after the new transcript is up so the line that says so is in it.
|
|
306
|
+
self._return_follow_ups_to_draft()
|
|
307
|
+
self.app.request_layout()
|
|
308
|
+
|
|
309
|
+
def _announce_restored_model(self, previous: ModelInfo | None, stored: StoredSession) -> None:
|
|
310
|
+
"""Say so when resuming a session moved the model, or could not.
|
|
311
|
+
|
|
312
|
+
The model is part of what the reader picks up when they come back to a session, and a
|
|
313
|
+
header that quietly shows another name is not a thing to notice. Silence is for when
|
|
314
|
+
nothing changed: the session never recorded a model, or it is the one already in use.
|
|
315
|
+
A machine with no model configured says nothing here at all: there is no model the session
|
|
316
|
+
could have moved to, and the line that explains the setup is the one the shell starts with.
|
|
317
|
+
"""
|
|
318
|
+
current = self.model
|
|
319
|
+
if current is None:
|
|
320
|
+
return
|
|
321
|
+
if previous is None or current != previous:
|
|
322
|
+
self.transcript.announce(f"Model set to {current.name}, the one this session last used.")
|
|
323
|
+
elif stored.model and sum(stored.model in (m.name, m.model) for m in self.client.models) != 1:
|
|
324
|
+
self.transcript.notice(
|
|
325
|
+
f"This session last used {stored.model}, which is not in your configuration; staying on {current.name}."
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def _remeasure_context_later(self) -> None:
|
|
329
|
+
"""Measure the context again, for a figure that cannot be true.
|
|
330
|
+
|
|
331
|
+
Only a billed size at or past the window is replaced this way: a request
|
|
332
|
+
cannot be larger than the window it is measured against, so the
|
|
333
|
+
measurement of the request this process assembled is the better answer.
|
|
334
|
+
A *restored* session is deliberately not measured here: its stored usage
|
|
335
|
+
is the provider's count of a real request, while a list rebuilt from the
|
|
336
|
+
store still holds messages the live request no longer carries — counting
|
|
337
|
+
those measured 1.65 M tokens against a provider's 326 k, which is how the
|
|
338
|
+
share came to sit at a hundred.
|
|
339
|
+
|
|
340
|
+
Nothing happens where no loop can carry it, since the command line builds
|
|
341
|
+
the application on a worker thread that has none; this figure is only ever
|
|
342
|
+
replaced while the interface is up.
|
|
343
|
+
"""
|
|
344
|
+
try:
|
|
345
|
+
loop = asyncio.get_running_loop()
|
|
346
|
+
except RuntimeError:
|
|
347
|
+
return
|
|
348
|
+
if self._measure_task is not None and not self._measure_task.done():
|
|
349
|
+
self._measure_task.cancel()
|
|
350
|
+
self._measure_task = loop.create_task(self._remeasure_context())
|
|
351
|
+
|
|
352
|
+
async def _remeasure_context(self) -> None:
|
|
353
|
+
"""Seed the status line's context figure with the measured request."""
|
|
354
|
+
report = await self.client.session.context()
|
|
355
|
+
if report is None:
|
|
356
|
+
return
|
|
357
|
+
self._usage = replace(self._usage, context_tokens=report.used)
|
|
358
|
+
self.app.request_layout()
|
|
359
|
+
|
|
360
|
+
def _remember_session_title(self) -> None:
|
|
361
|
+
"""Cache the active session's title, which the status line paints per frame."""
|
|
362
|
+
self._session_title = self.client.session.title
|
|
363
|
+
self._sync_messaging()
|
|
364
|
+
|
|
365
|
+
def _title_session_later(self, session_id: str) -> None:
|
|
366
|
+
"""Ask the agent to name a session once, off the composer's critical path."""
|
|
367
|
+
if self._title_task is not None and not self._title_task.done():
|
|
368
|
+
return
|
|
369
|
+
self._title_task = asyncio.create_task(self._name_session(session_id))
|
|
370
|
+
|
|
371
|
+
async def _name_session(self, session_id: str) -> None:
|
|
372
|
+
"""Store the title the agent summarizes and show it in the status line."""
|
|
373
|
+
try:
|
|
374
|
+
if session_id != self.client.session.id:
|
|
375
|
+
return # the reader moved on; naming it from here would switch back to it
|
|
376
|
+
title = await self.client.session.name_it()
|
|
377
|
+
except Exception:
|
|
378
|
+
return # naming a session is a nicety; never report its failure
|
|
379
|
+
if title is None:
|
|
380
|
+
return
|
|
381
|
+
if session_id == self.client.session.id:
|
|
382
|
+
self._session_title = title
|
|
383
|
+
self._sync_messaging()
|
|
384
|
+
self.app.invalidate()
|
|
385
|
+
|
|
386
|
+
async def _stop_title_task(self) -> None:
|
|
387
|
+
"""Stop a title request that is still in flight when the app exits."""
|
|
388
|
+
if self._title_task is None or self._title_task.done():
|
|
389
|
+
return
|
|
390
|
+
self._title_task.cancel()
|
|
391
|
+
await asyncio.gather(self._title_task, return_exceptions=True)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def fork_notice(
|
|
395
|
+
session_id: str,
|
|
396
|
+
title: str | None,
|
|
397
|
+
previous_id: str,
|
|
398
|
+
previous_title: str | None,
|
|
399
|
+
workspace: Path,
|
|
400
|
+
) -> str:
|
|
401
|
+
"""Return what a fork leaves in the conversation: which two sessions, and how to reach either.
|
|
402
|
+
|
|
403
|
+
A fork is the one command that moves the reader without their asking to go
|
|
404
|
+
anywhere, so it says where they are *and* where they were: the session they left
|
|
405
|
+
is where the work that led here lives, and it is one `/resume` away. The command
|
|
406
|
+
line is named too, because it is the form that still works from another directory
|
|
407
|
+
later — the store is keyed by workspace, so an id on its own means nothing in
|
|
408
|
+
another checkout, and its line carries a placeholder rather than one of the two
|
|
409
|
+
ids, since either fits.
|
|
410
|
+
|
|
411
|
+
One command to a line: the transcript wraps at its column budget, and a hard wrap
|
|
412
|
+
in the middle of `--resume` reads as a typo. Each line opens with the part that
|
|
413
|
+
matters, so a path too long for the window wraps where it costs least.
|
|
414
|
+
"""
|
|
415
|
+
here = f'"{title}" {SEPARATOR} ' if title else ""
|
|
416
|
+
there = f' {SEPARATOR} "{previous_title}"' if previous_title else ""
|
|
417
|
+
return (
|
|
418
|
+
f"Forked into a new session: {here}{session_id}\n"
|
|
419
|
+
f"resume this session: /resume {session_id}\n"
|
|
420
|
+
f"back to the previous session: /resume {previous_id}{there}\n"
|
|
421
|
+
f"from a shell: zettcode --resume <session id> --workspace {workspace}"
|
|
422
|
+
)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""The shell's pickers: model, palette, pet, and reasoning effort.
|
|
2
|
+
|
|
3
|
+
A picker page hands its choice back through one of these methods, which applies
|
|
4
|
+
it through the agent and closes the page. Keeping them here leaves the shell
|
|
5
|
+
class the wiring and the turn loop rather than one method per setting.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from zettcode.tui import Theme
|
|
13
|
+
|
|
14
|
+
from .shell import ShellState
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
17
|
+
from zettcode.plugin_api.pets import Pet
|
|
18
|
+
from zettcode.sdk.types import ModelInfo
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SettingsMixin(ShellState):
|
|
22
|
+
"""Apply the setting a picker returned and return to the composer."""
|
|
23
|
+
|
|
24
|
+
def close_page(self) -> None:
|
|
25
|
+
"""Return to the composer, restoring its focus and layout."""
|
|
26
|
+
self.app.pop_screen()
|
|
27
|
+
self.app.request_layout()
|
|
28
|
+
|
|
29
|
+
def request_layout(self) -> None:
|
|
30
|
+
"""Lay the screen out again; the one call a command makes when what it changed alters the rows."""
|
|
31
|
+
self.app.request_layout()
|
|
32
|
+
|
|
33
|
+
def offer(self, command: str) -> None:
|
|
34
|
+
"""Put the command and a space in the draft, which opens the menu of the words it takes."""
|
|
35
|
+
self.composer.set_text(f"{command} ")
|
|
36
|
+
|
|
37
|
+
def quit(self) -> None:
|
|
38
|
+
"""Leave the application."""
|
|
39
|
+
self.app.exit()
|
|
40
|
+
|
|
41
|
+
def change_model(self, name: ModelInfo | str) -> ModelInfo:
|
|
42
|
+
"""Switch models and announce the change in the transcript."""
|
|
43
|
+
previous = self.model
|
|
44
|
+
selected = self.client.use_model(name)
|
|
45
|
+
if previous is None or selected.name != previous.name or selected.model != previous.model:
|
|
46
|
+
self.transcript.model_changed(previous.name if previous is not None else "", selected.name)
|
|
47
|
+
self.app.request_layout()
|
|
48
|
+
return selected
|
|
49
|
+
|
|
50
|
+
def select_model(self, name: ModelInfo) -> None:
|
|
51
|
+
"""Apply the highlighted model and return to the composer."""
|
|
52
|
+
try:
|
|
53
|
+
self.change_model(name)
|
|
54
|
+
except ValueError as error:
|
|
55
|
+
self.transcript.error(str(error))
|
|
56
|
+
self.close_page()
|
|
57
|
+
|
|
58
|
+
def apply_theme(self, name: str) -> Theme:
|
|
59
|
+
"""Switch the palette to the one called ``name`` and repaint.
|
|
60
|
+
|
|
61
|
+
The name is a built-in palette or a file of the reader's (``~/.zettcode/themes/<name>.toml``).
|
|
62
|
+
Widgets read ``theme`` while painting, so nothing has to be rebuilt;
|
|
63
|
+
the invalidate is what makes the new colours land, and the re-layout
|
|
64
|
+
covers the caches a size-only change would have missed.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ValueError: There is no such palette, or its file cannot be read; the message lists the ones there are.
|
|
68
|
+
"""
|
|
69
|
+
theme = self.agent.themes.load(name)
|
|
70
|
+
self._theme_before = None # what is applied is not a preview to be put back
|
|
71
|
+
self.app.theme = theme
|
|
72
|
+
self.app.request_layout()
|
|
73
|
+
return theme
|
|
74
|
+
|
|
75
|
+
def preview_theme(self, name: str | None) -> None:
|
|
76
|
+
"""Show a palette without choosing it, or put back the one that was in force.
|
|
77
|
+
|
|
78
|
+
Called as the highlight moves through the ``/theme`` menu: the whole interface is repainted in the
|
|
79
|
+
highlighted palette so it can be judged on the conversation in front of the reader, and the palette that
|
|
80
|
+
was in force is kept to be restored when the menu goes away without a choice. A palette that cannot be
|
|
81
|
+
loaded is simply not shown; the error belongs to the reader who picks it.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
name: The palette to show, or ``None`` to end the preview and restore.
|
|
85
|
+
"""
|
|
86
|
+
if name is None:
|
|
87
|
+
if self._theme_before is not None:
|
|
88
|
+
self.app.theme, self._theme_before = self._theme_before, None
|
|
89
|
+
self.app.request_layout()
|
|
90
|
+
return
|
|
91
|
+
try:
|
|
92
|
+
theme = self.agent.themes.load(name)
|
|
93
|
+
except ValueError:
|
|
94
|
+
return
|
|
95
|
+
if self._theme_before is None:
|
|
96
|
+
self._theme_before = self.app.theme
|
|
97
|
+
if theme != self.app.theme:
|
|
98
|
+
self.app.theme = theme
|
|
99
|
+
self.app.request_layout()
|
|
100
|
+
|
|
101
|
+
def apply_pet(self, name: str) -> Pet:
|
|
102
|
+
"""Put the pet called ``name`` over the conversation, where the last one stood.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ValueError: There is no such pet, or it cannot be built; the message lists the ones there are.
|
|
106
|
+
"""
|
|
107
|
+
pet = self.agent.pets.load(name)
|
|
108
|
+
self._pet_before = None # what is applied is not a preview to be put back
|
|
109
|
+
self.pet.set_pet(pet)
|
|
110
|
+
self.app.invalidate()
|
|
111
|
+
return pet
|
|
112
|
+
|
|
113
|
+
def preview_pet(self, name: str | None) -> None:
|
|
114
|
+
"""Show a pet without choosing it, or put back the one that was there.
|
|
115
|
+
|
|
116
|
+
Called as the highlight moves through the ``/pet`` menu, so each pet can be judged where it will stand.
|
|
117
|
+
The pet that was there is kept to be restored when the menu goes away without a choice. A pet that cannot
|
|
118
|
+
be built is simply not shown; the error belongs to the reader who picks it.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
name: The pet to show, or ``None`` to end the preview and restore.
|
|
122
|
+
"""
|
|
123
|
+
if name is None:
|
|
124
|
+
if self._pet_before is not None:
|
|
125
|
+
self.pet.set_pet(self._pet_before)
|
|
126
|
+
self._pet_before = None
|
|
127
|
+
self.app.invalidate()
|
|
128
|
+
return
|
|
129
|
+
try:
|
|
130
|
+
pet = self.agent.pets.load(name)
|
|
131
|
+
except ValueError:
|
|
132
|
+
return
|
|
133
|
+
if self._pet_before is None:
|
|
134
|
+
self._pet_before = self.pet.pet
|
|
135
|
+
self.pet.set_pet(pet)
|
|
136
|
+
self.app.invalidate()
|
|
137
|
+
|
|
138
|
+
def change_effort(self, name: str) -> str:
|
|
139
|
+
"""Switch reasoning effort, run the header's gauge, and announce the change in the transcript."""
|
|
140
|
+
previous = self.client.effort
|
|
141
|
+
selected = self.client.use_effort(name)
|
|
142
|
+
if selected != previous:
|
|
143
|
+
self.transcript.effort_changed(previous, selected)
|
|
144
|
+
self.start_effort_animation(previous, selected)
|
|
145
|
+
return selected
|
|
146
|
+
|
|
147
|
+
def change_permission(self, name: str, *, remember: bool = True) -> str:
|
|
148
|
+
"""Set how much of the shell may run unasked and announce the change.
|
|
149
|
+
|
|
150
|
+
Takes effect on the next command, including one in the request that is
|
|
151
|
+
running now, because the policy is read at each shell call rather than
|
|
152
|
+
when the request starts. The level belongs to the session unless
|
|
153
|
+
``remember`` is off, which is for a decision the approval panel offers
|
|
154
|
+
for this run alone.
|
|
155
|
+
"""
|
|
156
|
+
previous = self.client.permission
|
|
157
|
+
selected = self.client.use_permission(name, remember=remember)
|
|
158
|
+
if selected != previous:
|
|
159
|
+
self.transcript.permission_changed(previous, selected)
|
|
160
|
+
self.app.invalidate()
|
|
161
|
+
return selected
|