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,365 @@
1
+ """Run one command at a time in the reader's shell and stream what it prints.
2
+
3
+ A command is run by the reader's own shell (``$SHELL -c``, PowerShell on Windows), in the directory the panel is in,
4
+ with no input — a program that asks a question gets the end of its input, and says so, instead of waiting for a
5
+ key nobody can press. Each command is a process of its own; the environment does not carry from one to the next,
6
+ except where the directory does: a bare ``cd`` is the panel's to do, because a ``cd`` in a process that is about to
7
+ exit changes nothing.
8
+
9
+ :class:`ShellLauncher` is how a shell is asked to run a string, and there is one per family: the strategy that
10
+ differs between a Unix shell, PowerShell and ``cmd`` is the arguments, and nothing else here knows which is in use.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import codecs
17
+ import contextlib
18
+ import os
19
+ import shlex
20
+ import shutil
21
+ import signal
22
+ import subprocess
23
+ import sys
24
+ import time
25
+ from abc import ABC, abstractmethod
26
+ from collections.abc import Callable, Mapping
27
+ from dataclasses import dataclass
28
+ from pathlib import Path, PureWindowsPath
29
+
30
+ from zettcode.tui import SEPARATOR
31
+
32
+ #: Seconds a command is given to end after Ctrl-C before it is killed.
33
+ KILL_AFTER = 3.0
34
+
35
+ #: Bytes read from the command at once, and the pause after a read that filled it. A program that prints without
36
+ #: end is slowed by a full pipe rather than by the interface: the panel keeps answering keys.
37
+ CHUNK = 16384
38
+ PAUSE = 0.01
39
+
40
+ #: Shells that take ``-c <command>`` and are worth running the reader's commands with.
41
+ POSIX_SHELLS = frozenset({"bash", "zsh", "fish", "sh", "dash", "ksh", "ash"})
42
+
43
+
44
+ class ShellLauncher(ABC):
45
+ """How one family of shells is asked to run a string.
46
+
47
+ Attributes:
48
+ name: What the shell is called, shown in the panel.
49
+ prompt: The glyph before the line the commands are typed on.
50
+ """
51
+
52
+ name = ""
53
+ prompt = "$"
54
+
55
+ @abstractmethod
56
+ def argv(self, command: str) -> list[str]:
57
+ """Return the program and arguments that run ``command``."""
58
+
59
+ def environment(self, base: Mapping[str, str]) -> dict[str, str]:
60
+ """Return the environment to run in: the reader's, plus what makes programs print the way a terminal shows.
61
+
62
+ Programs colour their output only when it goes to a terminal, and a pipe is not one, so they are told to:
63
+ ``FORCE_COLOR`` and ``CLICOLOR_FORCE`` for the many that read them, ``CLICOLOR`` for BSD and macOS ``ls``,
64
+ ``PY_COLORS`` for pytest and ``CARGO_TERM_COLOR`` for cargo. git reads none of those, so it is given
65
+ ``color.ui=always`` through ``GIT_CONFIG_*``, which takes effect for the one process and leaves the reader's
66
+ gitconfig alone (and, being ahead of it in git's order, still loses to a ``color.status = false`` of theirs).
67
+ All of it is skipped when ``NO_COLOR`` is set, and a variable the reader already set keeps their value.
68
+ Pager defaults assume a person at a terminal; the panel is a screen, but not one a pager can be driven from.
69
+ """
70
+ env = dict(base)
71
+ env.update({"TERM": "xterm-256color", "PYTHONUNBUFFERED": "1"})
72
+ env.setdefault("PAGER", "cat")
73
+ env.setdefault("GIT_PAGER", "cat")
74
+ if "NO_COLOR" in env:
75
+ return env
76
+ for name, value in (
77
+ ("FORCE_COLOR", "1"),
78
+ ("CLICOLOR", "1"),
79
+ ("CLICOLOR_FORCE", "1"),
80
+ ("PY_COLORS", "1"),
81
+ ("CARGO_TERM_COLOR", "always"),
82
+ ):
83
+ env.setdefault(name, value)
84
+ # git's own settings can already be coming in this way; this one goes after them.
85
+ raw = env.get("GIT_CONFIG_COUNT", "0")
86
+ count = int(raw) if raw.isdigit() else 0
87
+ env[f"GIT_CONFIG_KEY_{count}"] = "color.ui"
88
+ env[f"GIT_CONFIG_VALUE_{count}"] = "always"
89
+ env["GIT_CONFIG_COUNT"] = str(count + 1)
90
+ return env
91
+
92
+
93
+ class PosixLauncher(ShellLauncher):
94
+ """bash, zsh, fish, sh and the like: ``<shell> -c <command>``.
95
+
96
+ fish is started without its configuration. ``fish -c`` reads ``config.fish`` every time, and a configuration with
97
+ a version manager or a prompt in it takes half a second to a second — for every command, measured at 0.6-0.9 s
98
+ against 4 ms without. bash, zsh and sh do not read their rc files for ``-c``, so they are not slow and are not
99
+ changed. What this costs is a fish user's own aliases and functions; the environment, ``PATH`` included, is
100
+ inherited from the shell that started the program and is not affected.
101
+ """
102
+
103
+ def __init__(self, shell: str) -> None:
104
+ self.shell = shell
105
+ self.name = Path(shell).name
106
+
107
+ def argv(self, command: str) -> list[str]:
108
+ """Return ``[shell, "-c", command]``, with fish's ``--no-config`` before it."""
109
+ if self.name == "fish":
110
+ return [self.shell, "--no-config", "-c", command]
111
+ return [self.shell, "-c", command]
112
+
113
+
114
+ class PowerShellLauncher(ShellLauncher):
115
+ """Windows PowerShell and PowerShell 7: output as UTF-8, no profile, no prompts."""
116
+
117
+ prompt = ">"
118
+
119
+ def __init__(self, executable: str) -> None:
120
+ self.executable = executable
121
+ self.name = PureWindowsPath(executable).stem
122
+
123
+ def argv(self, command: str) -> list[str]:
124
+ """Return the arguments that run ``command`` with its output encoded as UTF-8."""
125
+ script = "[Console]::OutputEncoding=[Text.Encoding]::UTF8;$OutputEncoding=[Console]::OutputEncoding;" + command
126
+ return [self.executable, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script]
127
+
128
+
129
+ class CommandPromptLauncher(ShellLauncher):
130
+ """``cmd.exe``, for a machine with no PowerShell."""
131
+
132
+ name = "cmd"
133
+ prompt = ">"
134
+
135
+ def __init__(self, executable: str = "cmd.exe") -> None:
136
+ self.executable = executable
137
+
138
+ def argv(self, command: str) -> list[str]:
139
+ """Return ``cmd /d /c <command>``."""
140
+ return [self.executable, "/d", "/c", command]
141
+
142
+
143
+ def detect_launcher(env: Mapping[str, str] | None = None, platform: str = sys.platform) -> ShellLauncher:
144
+ """Return the launcher for the shell this reader has.
145
+
146
+ On Unix that is the shell in ``$SHELL`` when it is one that takes ``-c``, so a command is read the way the
147
+ reader writes it (a fish user's ``set -x`` is not a syntax error), otherwise bash, otherwise ``/bin/sh``. On
148
+ Windows it is PowerShell 7, Windows PowerShell, or ``cmd``, whichever is found first.
149
+ """
150
+ env = os.environ if env is None else env
151
+ if platform == "win32":
152
+ for candidate in ("pwsh", "powershell"):
153
+ found = shutil.which(candidate)
154
+ if found:
155
+ return PowerShellLauncher(found)
156
+ return CommandPromptLauncher(env.get("COMSPEC", "cmd.exe"))
157
+ shell = env.get("SHELL", "")
158
+ if shell and Path(shell).name in POSIX_SHELLS and os.access(shell, os.X_OK):
159
+ return PosixLauncher(shell)
160
+ return PosixLauncher(shutil.which("bash") or "/bin/sh")
161
+
162
+
163
+ @dataclass(frozen=True, slots=True)
164
+ class RunResult:
165
+ """How a command ended.
166
+
167
+ Attributes:
168
+ code: The exit status, or ``None`` when the command could not be started.
169
+ seconds: How long it ran.
170
+ stopped: It ended because the reader stopped it.
171
+ """
172
+
173
+ code: int | None
174
+ seconds: float
175
+ stopped: bool = False
176
+
177
+ @property
178
+ def text(self) -> str:
179
+ """Return the outcome in a few words, for the panel's heading."""
180
+ if self.code is None:
181
+ return "not started"
182
+ how = "stopped" if self.stopped else f"exit {self.code}"
183
+ return f"{how} {SEPARATOR} {self.seconds:.1f}s"
184
+
185
+
186
+ class CommandRunner:
187
+ """Run commands one after another in a directory, and tell the panel what each prints.
188
+
189
+ Args:
190
+ cwd: The directory commands run in; ``cd`` changes it.
191
+ launcher: How the shell is asked to run a string; the reader's own shell by default.
192
+ env: The environment commands inherit; the real one by default.
193
+ home: What ``cd`` with no argument goes to, and ``~`` stands for.
194
+ kill_after: Seconds a command has to end after being stopped before it is killed.
195
+ """
196
+
197
+ def __init__(
198
+ self,
199
+ cwd: Path,
200
+ launcher: ShellLauncher | None = None,
201
+ *,
202
+ env: Mapping[str, str] | None = None,
203
+ home: Path | None = None,
204
+ kill_after: float = KILL_AFTER,
205
+ ) -> None:
206
+ self.cwd = cwd
207
+ self.previous = cwd
208
+ self.env = os.environ if env is None else env
209
+ self.launcher = launcher or detect_launcher(self.env)
210
+ self.home = Path.home() if home is None else home
211
+ self.kill_after = kill_after
212
+ self._process: asyncio.subprocess.Process | None = None
213
+ self._stops = 0
214
+ self._starting = False
215
+ self._early_stop = False
216
+ self._killer: asyncio.TimerHandle | None = None
217
+
218
+ @property
219
+ def running(self) -> bool:
220
+ """Return whether a command is running now."""
221
+ return self._process is not None and self._process.returncode is None
222
+
223
+ # -- cd -----------------------------------------------------------------
224
+ def change_directory(self, argument: str) -> str | None:
225
+ """Move to a directory as ``cd`` does; return the complaint, or ``None`` when it worked."""
226
+ try:
227
+ words = shlex.split(argument, posix=os.name != "nt")
228
+ except ValueError as error:
229
+ return f"cd: {error}"
230
+ if os.name == "nt":
231
+ # Outside POSIX mode shlex keeps the quotes round a word; a shell takes them off.
232
+ words = [
233
+ word[1:-1] if len(word) >= 2 and word[0] == word[-1] and word[0] in "\"'" else word for word in words
234
+ ]
235
+ if words[:1] == ["/d"]:
236
+ words = words[1:]
237
+ if len(words) > 1:
238
+ return "cd: too many arguments"
239
+ target = words[0] if words else "~"
240
+ if target == "-":
241
+ destination = self.previous
242
+ else:
243
+ if target == "~" or target.startswith(("~/", "~\\")):
244
+ target = str(self.home) + target[1:]
245
+ destination = Path(target)
246
+ if not destination.is_absolute():
247
+ destination = self.cwd / destination
248
+ try:
249
+ destination = destination.resolve()
250
+ except OSError as error:
251
+ return f"cd: {target}: {error.strerror or error}"
252
+ if not destination.is_dir():
253
+ return f"cd: no such directory: {target}"
254
+ self.previous, self.cwd = self.cwd, destination
255
+ return None
256
+
257
+ # -- running ------------------------------------------------------------
258
+ async def run(self, command: str, write: Callable[[str], None]) -> RunResult:
259
+ """Run ``command`` to its end, calling ``write`` with what it prints as it prints it."""
260
+ started = time.monotonic()
261
+ stripped = command.strip()
262
+ if stripped == "cd" or stripped.startswith(("cd ", "cd\t")):
263
+ error = self.change_directory(stripped[2:])
264
+ if error is not None:
265
+ write(error + "\n")
266
+ return RunResult(1 if error else 0, time.monotonic() - started)
267
+ self._stops = 0
268
+ self._starting, self._early_stop = True, False
269
+ try:
270
+ process = self._process = await asyncio.create_subprocess_exec(
271
+ *self.launcher.argv(command),
272
+ stdin=asyncio.subprocess.DEVNULL,
273
+ stdout=asyncio.subprocess.PIPE,
274
+ stderr=asyncio.subprocess.STDOUT,
275
+ cwd=self.cwd,
276
+ env=self.launcher.environment(self.env),
277
+ creationflags=self._flags(),
278
+ start_new_session=os.name != "nt",
279
+ )
280
+ except OSError as error:
281
+ self._starting = False
282
+ write(f"{self.launcher.name}: {error.strerror or error}\n")
283
+ return RunResult(None, time.monotonic() - started)
284
+ self._starting = False
285
+ if self._early_stop:
286
+ # Stopped in the moment between Enter and the process existing: it is interrupted as soon as it does.
287
+ self.stop()
288
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
289
+ assert process.stdout is not None
290
+ try:
291
+ while True:
292
+ data = await process.stdout.read(CHUNK)
293
+ if not data:
294
+ break
295
+ write(decoder.decode(data))
296
+ await asyncio.sleep(PAUSE if len(data) >= CHUNK // 2 else 0)
297
+ tail = decoder.decode(b"", final=True)
298
+ if tail:
299
+ write(tail)
300
+ code = await process.wait()
301
+ finally:
302
+ await self._reap(process)
303
+ if self._killer is not None:
304
+ self._killer.cancel()
305
+ self._killer = None
306
+ return RunResult(code, time.monotonic() - started, stopped=self._stops > 0)
307
+
308
+ @staticmethod
309
+ def _flags() -> int:
310
+ """Return the Windows creation flags that put the command in a process group of its own (zero elsewhere).
311
+
312
+ Elsewhere ``start_new_session`` does it; either way Ctrl-C can reach the command and all it started.
313
+ """
314
+ return getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0
315
+
316
+ # -- stopping -----------------------------------------------------------
317
+ def stop(self) -> bool:
318
+ """Interrupt the running command; a second call, or a command that ignores the first, kills it.
319
+
320
+ Returns:
321
+ Whether there was a command to stop.
322
+ """
323
+ process = self._process
324
+ if process is None and self._starting:
325
+ self._early_stop = True
326
+ return True
327
+ if process is None or process.returncode is not None:
328
+ return False
329
+ self._stops += 1
330
+ if self._stops == 1:
331
+ self._send(process, interrupt=True)
332
+ with contextlib.suppress(RuntimeError):
333
+ self._killer = asyncio.get_running_loop().call_later(self.kill_after, self._kill, process)
334
+ else:
335
+ self._kill(process)
336
+ return True
337
+
338
+ def _kill(self, process: asyncio.subprocess.Process) -> None:
339
+ if process.returncode is None:
340
+ self._send(process, interrupt=False)
341
+
342
+ @staticmethod
343
+ def _send(process: asyncio.subprocess.Process, *, interrupt: bool) -> None:
344
+ """Signal the command's whole process group: SIGINT to ask, SIGKILL to insist."""
345
+ try:
346
+ if os.name == "nt":
347
+ if interrupt:
348
+ process.send_signal(signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined,unused-ignore]
349
+ else:
350
+ subprocess.run(
351
+ ["taskkill", "/T", "/F", "/PID", str(process.pid)], capture_output=True, check=False, timeout=10
352
+ )
353
+ else:
354
+ os.killpg(process.pid, signal.SIGINT if interrupt else signal.SIGKILL)
355
+ except (OSError, subprocess.SubprocessError):
356
+ with contextlib.suppress(OSError):
357
+ process.kill()
358
+
359
+ async def _reap(self, process: asyncio.subprocess.Process) -> None:
360
+ """Make sure the command is gone when :meth:`run` leaves, however it left (the panel closing, say)."""
361
+ if process.returncode is None:
362
+ self._send(process, interrupt=False)
363
+ with contextlib.suppress(Exception):
364
+ await asyncio.wait_for(process.wait(), 5)
365
+ self._process = None
zettcode/app/errors.py ADDED
@@ -0,0 +1,12 @@
1
+ """What the shell catches from a command, and tells the reader in words."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from zettcode.sdk.errors import ZettCodeError
6
+
7
+ __all__ = ["COMMAND_ERRORS"]
8
+
9
+ #: What a command may fail with and have the reader told in words: a bad argument (``ValueError``), or the SDK saying
10
+ #: the session is locked, busy or missing. Anything else is a bug and is reported as one. This is the shell's catch
11
+ #: clause and not part of what a plugin imports: a command raises, and the shell decides what a reader is told.
12
+ COMMAND_ERRORS = (ValueError, ZettCodeError)
@@ -0,0 +1,110 @@
1
+ """The terminal's plugins: the row hooks, the groups a shell plugin registers into, and the bundle it produces.
2
+
3
+ A plugin subclasses :class:`~zettcode.engine.plugins.Plugin` (or :class:`TuiPlugin` to override a row hook) and
4
+ registers through ``ctx``::
5
+
6
+ from zettcode.app.plugins import TuiPlugin
7
+ from zettcode.engine.plugins import PluginContext
8
+
9
+ class Greeter(TuiPlugin):
10
+ name = "greeter"
11
+
12
+ def activate(self, ctx: PluginContext) -> None:
13
+ ctx.tui.register_command("greet", "say hello", self.greet)
14
+
15
+ :class:`TuiPlugin` is the engine's base class with :class:`PluginUiMixin`; the shell's commands, palettes, pets,
16
+ presenters and rows live in :class:`~zettcode.app.plugins.groups.TuiGroups`, which is ``ctx.tui`` — ``None`` in a
17
+ process without a terminal, which is why a shell-only plugin starts ``activate`` with ``if ctx.tui is None: return``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import importlib
23
+ from typing import TYPE_CHECKING
24
+
25
+ #: Where each public name lives; read by :func:`__getattr__` on first use.
26
+ _EXPORTS = {
27
+ "Activity": "zettcode.plugin_api.state",
28
+ "ActivityState": "zettcode.plugin_api.state",
29
+ "DisplayState": "zettcode.plugin_api.state",
30
+ "ModelState": "zettcode.plugin_api.state",
31
+ "SessionState": "zettcode.plugin_api.state",
32
+ "ShellContext": "zettcode.plugin_api.state",
33
+ "UiBuilder": "zettcode.plugin_api.state",
34
+ "UiRegion": "zettcode.plugin_api.state",
35
+ "UiRow": "zettcode.plugin_api.state",
36
+ "UiSegment": "zettcode.plugin_api.state",
37
+ "UiSide": "zettcode.plugin_api.state",
38
+ "PluginUiMixin": "zettcode.plugin_api.mixins",
39
+ "PluginContext": "zettcode.engine.plugins.context",
40
+ "UiSlots": ".groups",
41
+ "TuiGroups": ".groups",
42
+ "TuiPlugin": "zettcode.plugin_api.plugin",
43
+ "TuiPlugins": "zettcode.plugin_api.plugin",
44
+ "TerminalLoader": ".loader",
45
+ "load_terminal_plugins": ".loader",
46
+ "HOST_LINK": "zettcode.plugin_api.host",
47
+ "HostLink": "zettcode.plugin_api.host",
48
+ }
49
+
50
+
51
+ if TYPE_CHECKING: # pragma: no cover - for type checkers, not the runtime
52
+ from zettcode.engine.plugins.context import PluginContext
53
+ from zettcode.plugin_api.host import HOST_LINK, HostLink
54
+ from zettcode.plugin_api.mixins import PluginUiMixin
55
+ from zettcode.plugin_api.plugin import TuiPlugin, TuiPlugins
56
+ from zettcode.plugin_api.state import (
57
+ Activity,
58
+ ActivityState,
59
+ DisplayState,
60
+ ModelState,
61
+ SessionState,
62
+ ShellContext,
63
+ UiBuilder,
64
+ UiRegion,
65
+ UiRow,
66
+ UiSegment,
67
+ UiSide,
68
+ )
69
+
70
+ from .groups import UiSlots
71
+ from .loader import TerminalLoader, load_terminal_plugins
72
+
73
+
74
+ def __getattr__(name: str) -> object:
75
+ """Import the module a public name lives in, the first time it is asked for."""
76
+ try:
77
+ module = _EXPORTS[name]
78
+ except KeyError:
79
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
80
+ return getattr(importlib.import_module(module, __name__), name)
81
+
82
+
83
+ def __dir__() -> list[str]:
84
+ """Return the names this package publishes."""
85
+ return sorted(__all__)
86
+
87
+
88
+ __all__ = [
89
+ "HOST_LINK",
90
+ "Activity",
91
+ "ActivityState",
92
+ "DisplayState",
93
+ "HostLink",
94
+ "ModelState",
95
+ "PluginUiMixin",
96
+ "TuiGroups",
97
+ "SessionState",
98
+ "ShellContext",
99
+ "PluginContext",
100
+ "TerminalLoader",
101
+ "TuiPlugin",
102
+ "TuiPlugins",
103
+ "UiBuilder",
104
+ "UiRegion",
105
+ "UiRow",
106
+ "UiSegment",
107
+ "UiSide",
108
+ "UiSlots",
109
+ "load_terminal_plugins",
110
+ ]
@@ -0,0 +1,33 @@
1
+ """The palettes ZettCode ships with, written as an ordinary plugin.
2
+
3
+ The default header, status and turn-line rows moved to ``zettcode-presenters`` (``rows.py``), so this module holds
4
+ only the bundled palettes; the app has no built-in plugins, and the loader finds every terminal part through its
5
+ entry point.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from zettcode.engine.plugins.context import PluginContext
11
+ from zettcode.plugin_api.plugin import TuiPlugin
12
+ from zettcode.tui import bundled_themes
13
+
14
+ from .groups import require_tui
15
+
16
+
17
+ class BuiltinThemes(TuiPlugin):
18
+ """The palettes that ship with the program, offered the way any plugin offers one.
19
+
20
+ They are TOML files beside the theme loader, so adding a built-in palette is adding a file and a name to
21
+ ``BUNDLED``; nothing here knows what a palette looks like. They are registered first, so they are listed first
22
+ in the ``/theme`` menu, and a plugin cannot register a name they hold.
23
+ """
24
+
25
+ name = "themes"
26
+
27
+ def activate(self, ctx: PluginContext) -> None:
28
+ """Offer each palette file by name. Nothing is read until one is asked for."""
29
+ if ctx.tui is None:
30
+ return # loaded by a process with no screen (the server): there is nothing to offer a palette to
31
+ tui = require_tui(ctx)
32
+ for source in bundled_themes():
33
+ tui.register_theme(source.name, source.load, description=source.description)