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,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