zettcode-engine 0.3.4__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 (90) hide show
  1. zettcode/_compat.py +35 -0
  2. zettcode/budget.py +24 -0
  3. zettcode/config.py +1088 -0
  4. zettcode/config_edit.py +457 -0
  5. zettcode/engine/__init__.py +39 -0
  6. zettcode/engine/agent/__init__.py +81 -0
  7. zettcode/engine/agent/base.py +60 -0
  8. zettcode/engine/agent/context.py +166 -0
  9. zettcode/engine/agent/exports.py +73 -0
  10. zettcode/engine/agent/interaction.py +54 -0
  11. zettcode/engine/agent/sessions.py +218 -0
  12. zettcode/engine/agent/settings.py +269 -0
  13. zettcode/engine/agent/turns.py +308 -0
  14. zettcode/engine/core.py +819 -0
  15. zettcode/engine/dialogue/__init__.py +6 -0
  16. zettcode/engine/dialogue/ask.py +113 -0
  17. zettcode/engine/dialogue/side.py +197 -0
  18. zettcode/engine/dialogue/title.py +80 -0
  19. zettcode/engine/export/__init__.py +421 -0
  20. zettcode/engine/export/trace/script.js +18 -0
  21. zettcode/engine/export/trace/style.css +136 -0
  22. zettcode/engine/export/trace/template.html +31 -0
  23. zettcode/engine/extensions/__init__.py +8 -0
  24. zettcode/engine/extensions/capabilities.py +68 -0
  25. zettcode/engine/extensions/compaction.py +259 -0
  26. zettcode/engine/extensions/context.py +521 -0
  27. zettcode/engine/extensions/files.py +101 -0
  28. zettcode/engine/extensions/guidance.py +17 -0
  29. zettcode/engine/extensions/harness.py +208 -0
  30. zettcode/engine/extensions/harness_switch.py +18 -0
  31. zettcode/engine/extensions/mcp_notice.py +49 -0
  32. zettcode/engine/extensions/relay.py +93 -0
  33. zettcode/engine/extensions/subagents.py +391 -0
  34. zettcode/engine/extensions/tool_output.py +153 -0
  35. zettcode/engine/extensions/truncation.py +156 -0
  36. zettcode/engine/extensions/usage.py +304 -0
  37. zettcode/engine/harness_paths.py +74 -0
  38. zettcode/engine/permission/__init__.py +197 -0
  39. zettcode/engine/permission/approval.py +66 -0
  40. zettcode/engine/permission/readonly.py +435 -0
  41. zettcode/engine/permission/sandbox.py +140 -0
  42. zettcode/engine/pipeline.py +191 -0
  43. zettcode/engine/plugins/__init__.py +72 -0
  44. zettcode/engine/plugins/commands.py +152 -0
  45. zettcode/engine/plugins/context.py +251 -0
  46. zettcode/engine/plugins/extension.py +183 -0
  47. zettcode/engine/plugins/groups.py +74 -0
  48. zettcode/engine/plugins/loader.py +367 -0
  49. zettcode/engine/plugins/mixins.py +156 -0
  50. zettcode/engine/plugins/plugin.py +190 -0
  51. zettcode/engine/plugins/services.py +68 -0
  52. zettcode/engine/plugins/settings.py +117 -0
  53. zettcode/engine/plugins/state.py +64 -0
  54. zettcode/engine/plugins/switches.py +553 -0
  55. zettcode/engine/py.typed +0 -0
  56. zettcode/engine/server_tools.py +179 -0
  57. zettcode/engine/storage/__init__.py +51 -0
  58. zettcode/engine/storage/layout.py +161 -0
  59. zettcode/engine/storage/ledger.py +659 -0
  60. zettcode/engine/storage/locks.py +182 -0
  61. zettcode/engine/storage/metadata.py +278 -0
  62. zettcode/engine/storage/records.py +373 -0
  63. zettcode/engine/storage/store.py +1056 -0
  64. zettcode/engine/tools/__init__.py +7 -0
  65. zettcode/engine/tools/boundary.py +56 -0
  66. zettcode/engine/tools/files.py +262 -0
  67. zettcode/engine/tools/images.py +32 -0
  68. zettcode/engine/tools/output.py +81 -0
  69. zettcode/engine/tools/shell.py +181 -0
  70. zettcode/engine/tools/workspace.py +271 -0
  71. zettcode/error_text.py +217 -0
  72. zettcode/filelock.py +125 -0
  73. zettcode/logging_setup.py +196 -0
  74. zettcode/mention.py +124 -0
  75. zettcode/modes.py +18 -0
  76. zettcode/paths.py +113 -0
  77. zettcode/plugin_tui.py +133 -0
  78. zettcode/processes.py +126 -0
  79. zettcode/prompt.py +94 -0
  80. zettcode/providers.py +215 -0
  81. zettcode/py.typed +0 -0
  82. zettcode/registry.py +63 -0
  83. zettcode/relay.py +267 -0
  84. zettcode/statusline_parts.py +84 -0
  85. zettcode/version.py +11 -0
  86. zettcode/wire.py +30 -0
  87. zettcode/wording.py +11 -0
  88. zettcode_engine-0.3.4.dist-info/METADATA +23 -0
  89. zettcode_engine-0.3.4.dist-info/RECORD +90 -0
  90. zettcode_engine-0.3.4.dist-info/WHEEL +4 -0
@@ -0,0 +1,196 @@
1
+ """Process-wide setup for the standard-library loggers.
2
+
3
+ Every component uses ordinary ``logging`` calls::
4
+
5
+ logger = logging.getLogger("zettcode.terminal")
6
+ logger.info("turn.start", extra={"session": session})
7
+ logger.exception("turn.failed")
8
+
9
+ ``setup`` adds one rotating JSON-lines handler under ``~/.zettcode/log``. The
10
+ terminal's stderr capture stays in ``tui.log``; structured events go to
11
+ ``terminal.jsonl`` so rotation never renames the file fd 2 writes to.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import os
19
+ from datetime import datetime, timezone
20
+ from logging.handlers import RotatingFileHandler
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ DEFAULT_MAX_BYTES = 8 * 1024 * 1024
25
+ DEFAULT_BACKUPS = 3
26
+ MAX_MESSAGE = 2_000
27
+ MAX_TRACEBACK = 20_000
28
+ MAX_RECORD_CHARS = 64_000
29
+ DEFAULT_DIR = Path.home() / ".zettcode" / "log"
30
+
31
+ _LEVELS = {"debug": logging.DEBUG, "info": logging.INFO, "warning": logging.WARNING, "error": logging.ERROR}
32
+ _RESERVED = frozenset(
33
+ {
34
+ "name",
35
+ "msg",
36
+ "args",
37
+ "levelname",
38
+ "levelno",
39
+ "pathname",
40
+ "filename",
41
+ "module",
42
+ "exc_info",
43
+ "exc_text",
44
+ "stack_info",
45
+ "lineno",
46
+ "funcName",
47
+ "created",
48
+ "msecs",
49
+ "relativeCreated",
50
+ "thread",
51
+ "threadName",
52
+ "processName",
53
+ "process",
54
+ "message",
55
+ "asctime",
56
+ }
57
+ )
58
+
59
+
60
+ def _env_positive(name: str, default: int) -> int:
61
+ try:
62
+ value = int(os.getenv(name, ""))
63
+ except ValueError:
64
+ return default
65
+ return value if value > 0 else default
66
+
67
+
68
+ def _env_non_negative(name: str, default: int) -> int:
69
+ try:
70
+ value = int(os.getenv(name, ""))
71
+ except ValueError:
72
+ return default
73
+ return value if value >= 0 else default
74
+
75
+
76
+ def level() -> int:
77
+ """Return the configured threshold; an unknown value falls back to info."""
78
+ return _LEVELS.get(os.getenv("ZETTCODE_LOG_LEVEL", "info").strip().lower(), logging.INFO)
79
+
80
+
81
+ def log_path(component: str) -> Path:
82
+ """Return the JSONL file for one component."""
83
+ return DEFAULT_DIR / f"{component}.jsonl"
84
+
85
+
86
+ class _OwnerRotatingHandler(RotatingFileHandler):
87
+ """Rotating handler that creates its file owner-only, not with the umask."""
88
+
89
+ def _open(self) -> Any:
90
+ stream = super()._open()
91
+ if os.name != "nt":
92
+ try:
93
+ os.chmod(self.baseFilename, 0o600)
94
+ except OSError:
95
+ pass
96
+ return stream
97
+
98
+
99
+ class JsonFormatter(logging.Formatter):
100
+ """One JSON object per record, including the caller's ``extra`` fields."""
101
+
102
+ def format(self, record: logging.LogRecord) -> str:
103
+ payload: dict[str, Any] = {
104
+ "time": datetime.fromtimestamp(record.created, timezone.utc).isoformat(timespec="milliseconds"),
105
+ "level": record.levelname.lower(),
106
+ "logger": record.name,
107
+ "event": record.getMessage()[:MAX_MESSAGE],
108
+ "pid": record.process,
109
+ }
110
+ for key, value in record.__dict__.items():
111
+ if key not in _RESERVED and not key.startswith("_"):
112
+ payload[key] = value
113
+ if record.exc_info is not None:
114
+ formatted = self.formatException(record.exc_info)
115
+ if len(formatted) > MAX_TRACEBACK:
116
+ marker = "\n<traceback truncated>\n"
117
+ keep = MAX_TRACEBACK - len(marker)
118
+ head, tail = keep // 2, keep - keep // 2
119
+ formatted = formatted[:head] + marker + formatted[-tail:]
120
+ payload["traceback"] = formatted
121
+ line = json.dumps(payload, ensure_ascii=False, default=str)
122
+ if len(line) > MAX_RECORD_CHARS:
123
+ line = json.dumps(
124
+ {
125
+ "time": payload["time"],
126
+ "level": payload["level"],
127
+ "logger": payload["logger"],
128
+ "event": payload["event"],
129
+ "pid": payload["pid"],
130
+ "truncated": True,
131
+ "original_chars": len(line),
132
+ },
133
+ ensure_ascii=False,
134
+ )
135
+ return line
136
+
137
+
138
+ def ensure_log_parent(path: Path) -> None:
139
+ """Create the folder a log file lives in, owner-only when this call made it."""
140
+ parent = path.parent
141
+ created = not parent.exists()
142
+ parent.mkdir(parents=True, exist_ok=True, mode=0o700)
143
+ if created and os.name != "nt":
144
+ try:
145
+ os.chmod(parent, 0o700)
146
+ except OSError:
147
+ pass
148
+
149
+
150
+ def setup(component: str, *, path: Path | None = None) -> logging.Logger:
151
+ """Configure and return ``zettcode.<component>``; safe to call repeatedly."""
152
+ logger = logging.getLogger(f"zettcode.{component}")
153
+ logging.raiseExceptions = False
154
+ logger.setLevel(level())
155
+ logger.propagate = False
156
+ logger.handlers.clear()
157
+ target = path or log_path(component)
158
+ try:
159
+ ensure_log_parent(target)
160
+ handler: logging.Handler = _OwnerRotatingHandler(
161
+ target,
162
+ maxBytes=_env_positive("ZETTCODE_LOG_MAX_BYTES", DEFAULT_MAX_BYTES),
163
+ backupCount=_env_non_negative("ZETTCODE_LOG_BACKUPS", DEFAULT_BACKUPS),
164
+ encoding="utf-8",
165
+ delay=True,
166
+ )
167
+ except OSError:
168
+ handler = logging.NullHandler()
169
+ handler.setLevel(logging.DEBUG)
170
+ handler.setFormatter(JsonFormatter())
171
+ logger.addHandler(handler)
172
+ if not isinstance(handler, logging.NullHandler) and os.name != "nt":
173
+ try:
174
+ os.chmod(target, 0o600)
175
+ except OSError:
176
+ pass
177
+ return logger
178
+
179
+
180
+ def rotate_text(path: Path, *, max_bytes: int | None = None, backups: int | None = None) -> None:
181
+ """Rotate a text log at startup, before a raw stderr fd opens it."""
182
+ limit = max_bytes if max_bytes is not None else _env_positive("ZETTCODE_LOG_MAX_BYTES", DEFAULT_MAX_BYTES)
183
+ count = backups if backups is not None else _env_non_negative("ZETTCODE_LOG_BACKUPS", DEFAULT_BACKUPS)
184
+ try:
185
+ if not path.is_file() or path.stat().st_size <= limit:
186
+ return
187
+ if count <= 0:
188
+ path.unlink(missing_ok=True)
189
+ return
190
+ for index in range(count - 1, 0, -1):
191
+ source = path.with_name(f"{path.name}.{index}")
192
+ if source.exists():
193
+ source.replace(path.with_name(f"{path.name}.{index + 1}"))
194
+ path.replace(path.with_name(f"{path.name}.1"))
195
+ except OSError:
196
+ pass
zettcode/mention.py ADDED
@@ -0,0 +1,124 @@
1
+ """What an ``@`` reference is, and the shape a provider of them has.
2
+
3
+ The runtime resolves references against providers a plugin hands it (:class:`zettcode.engine.plugins.PluginContext`),
4
+ so the type is the engine's: a plugin that offers resources implements this, and the engine reads it without knowing
5
+ whether the resources are files, skills or other sessions. The implementations and the registry over them are
6
+ :mod:`zettcode.mentions`, which ships with the SDK.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from abc import ABC, abstractmethod
13
+ from collections.abc import Sequence
14
+ from dataclasses import dataclass
15
+
16
+ from zettcode.registry import Provider, Registry
17
+
18
+ #: One mention token: an ``@`` at a word boundary, then a name. A name is what a
19
+ #: skill is called (lowercase words) or where a file lives (``src/app.py``), so it
20
+ #: holds path punctuation as well; it starts with a letter, a digit, or an
21
+ #: underscore, which is what keeps ``@/`` and a trailing ``@`` out. The lookbehind
22
+ #: keeps an email address or a second ``@`` from matching.
23
+ MENTION = re.compile(r"(?<![\w@])@([A-Za-z0-9_][A-Za-z0-9_./-]*)")
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class Mention:
28
+ """One resource a draft can reference.
29
+
30
+ Attributes:
31
+ token: Text as typed, e.g. ``@code-review``.
32
+ kind: Provider that owns the resource, e.g. ``"skill"``.
33
+ name: Resource name without the ``@``.
34
+ description: One line shown by the completion menu.
35
+ """
36
+
37
+ token: str
38
+ kind: str
39
+ name: str
40
+ description: str
41
+
42
+
43
+ class MentionProvider(Provider[Mention], ABC):
44
+ """One kind of ``@`` resource: what it offers and what it injects."""
45
+
46
+ #: Short name shown in the completion menu's type column, e.g. ``"skill"``.
47
+ kind: str
48
+
49
+ @abstractmethod
50
+ def candidates(self, query: str) -> Sequence[Mention]:
51
+ """Return the resources whose name matches ``query`` (without the ``@``)."""
52
+
53
+ @property
54
+ def items(self) -> Sequence[Mention]:
55
+ """Return every resource this provider offers."""
56
+ return self.candidates("")
57
+
58
+ @abstractmethod
59
+ def expand(self, name: str) -> str | None:
60
+ """Return the text one resource injects, or ``None`` when it is unknown."""
61
+
62
+ def rename(self, name: str) -> str | None:
63
+ """Return the words the model reads in place of ``@name``, or ``None`` to leave the token as typed.
64
+
65
+ Most references are meaningful to the model as they are (a path, a skill's name). One that is not —
66
+ the name of another session — is replaced, so the model is not left holding a word it must guess at.
67
+ """
68
+ return None
69
+
70
+
71
+ class MentionRegistry(Registry[Mention]):
72
+ """The providers a draft is resolved against, in registration order."""
73
+
74
+ def __init__(self, providers: Sequence[MentionProvider] = ()) -> None:
75
+ """Keep the providers; the first to claim a name wins a duplicate."""
76
+ super().__init__(providers)
77
+
78
+ def candidates(self, query: str) -> tuple[Mention, ...]:
79
+ """Return every resource whose name matches ``query`` (which has no ``@``).
80
+
81
+ Each provider is asked for the query rather than handed every item it has:
82
+ a draft is completed on every keystroke, and the file provider holds
83
+ thousands of names it would otherwise build a ``Mention`` for each time.
84
+ An earlier provider still wins a duplicate name.
85
+ """
86
+ seen: set[str] = set()
87
+ merged: list[Mention] = []
88
+ for provider in self.mention_providers:
89
+ for mention in provider.candidates(query):
90
+ if mention.name in seen:
91
+ continue
92
+ seen.add(mention.name)
93
+ merged.append(mention)
94
+ return tuple(merged)
95
+
96
+ @property
97
+ def mention_providers(self) -> tuple[MentionProvider, ...]:
98
+ """Return the providers as the ``@``-specific type that can expand tokens."""
99
+ return tuple(provider for provider in self.providers if isinstance(provider, MentionProvider))
100
+
101
+ def expand_token(self, token: str) -> str | None:
102
+ """Resolve one typed ``@`` token into the text it contributes, if any.
103
+
104
+ The shell gets the tokens from the composer's parts rather than scanning
105
+ the draft, so this is handed ``"@name"`` exactly as it was written.
106
+ """
107
+ name = token[1:] if token.startswith("@") else token
108
+ for provider in self.mention_providers:
109
+ block = provider.expand(name)
110
+ if block is not None:
111
+ return block
112
+ return None
113
+
114
+ def rename_token(self, token: str) -> str | None:
115
+ """Return the words the model reads in place of one typed token, from the provider that owns it.
116
+
117
+ The owner is the first provider that knows the name, which is the one :meth:`expand_token` asks:
118
+ a session and a file with one name are the session.
119
+ """
120
+ name = token[1:] if token.startswith("@") else token
121
+ for provider in self.mention_providers:
122
+ if provider.expand(name) is not None:
123
+ return provider.rename(name)
124
+ return None
zettcode/modes.py ADDED
@@ -0,0 +1,18 @@
1
+ """How a message asks to be handled by a recipient that is busy with a request.
2
+
3
+ A leaf with no imports, so the broker, the SDK, the server and the shell can name the two modes without one of them
4
+ depending on another. The broker's protocol re-exports these names, because that is where they are checked; an
5
+ unknown value is refused there, and a missing one means :data:`MODE_STEER`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ #: Hand the message to the request that is running, so it changes course.
11
+ MODE_STEER = "steer"
12
+
13
+ #: Wait until the request that is running ends.
14
+ MODE_FOLLOWUP = "followup"
15
+
16
+ MODES = (MODE_STEER, MODE_FOLLOWUP)
17
+
18
+ __all__ = ["MODE_FOLLOWUP", "MODE_STEER", "MODES"]
zettcode/paths.py ADDED
@@ -0,0 +1,113 @@
1
+ """Where ZettCode keeps its own files.
2
+
3
+ Constants only, and deliberately importable without the settings module: the
4
+ shell takes the terminal and points its diagnostics at the log before it has
5
+ read the config, so the paths cannot live behind that import.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shlex
12
+ from pathlib import Path
13
+
14
+ #: The conventional config file, read when the user has created one.
15
+ CONFIG_FILE = Path.home() / ".zettcode" / "config.toml"
16
+
17
+ #: Session storage is an application default, not a model setting.
18
+ DEFAULT_STORE = Path.home() / ".zettcode" / "sessions"
19
+
20
+ #: Plugins the reader switched off with ``/plugins disable``; read at the next start. Kept beside the store so the
21
+ #: reader's config file stays theirs, and a name here is an entry-point name, the same one ``[plugins] disable`` takes.
22
+ DEFAULT_PLUGINS_FILE = DEFAULT_STORE.parent / "plugins.json"
23
+
24
+ #: A plugin that is a single ``.py`` file lives here instead of being installed into site-packages: one file, one
25
+ #: module, loaded at the next start. ``$ZETTCODE_PLUGINS_DIR`` moves it, the way ``$ZETTCODE_CONFIG`` moves the config.
26
+ DEFAULT_PLUGINS_DIR = DEFAULT_STORE.parent / "plugins"
27
+ PLUGINS_DIR_VARIABLE = "ZETTCODE_PLUGINS_DIR"
28
+
29
+
30
+ def plugins_dir() -> Path:
31
+ """Return the folder single-file plugins live in: ``$ZETTCODE_PLUGINS_DIR`` or ``~/.zettcode/plugins``."""
32
+ override = os.getenv(PLUGINS_DIR_VARIABLE)
33
+ return Path(override).expanduser() if override else DEFAULT_PLUGINS_DIR
34
+
35
+
36
+ #: MCP servers are ZettCode's own file, so they are configured independently of
37
+ #: whatever other zett tools read.
38
+ DEFAULT_MCP_CONFIG = Path.home() / ".zettcode" / "mcp.json"
39
+
40
+ #: Where the just-in-time harness keeps the tools a model wrote, one folder per session. Beside the sessions, not in
41
+ #: them: it is the model's own code, and clearing or moving the session store should not take it along.
42
+ DEFAULT_HARNESS_DIR = Path.home() / ".zettcode" / "harness"
43
+
44
+ #: What the background release check leaves behind: the newest version it saw
45
+ #: and the version the reader asked not to hear about again.
46
+ DEFAULT_UPDATE_FILE = Path.home() / ".zettcode" / "update.json"
47
+
48
+ #: Where the reader's own palettes live: one ``<name>.toml`` each, picked by name with ``/theme <name>``.
49
+ DEFAULT_THEMES_DIR = Path.home() / ".zettcode" / "themes"
50
+
51
+ #: The one palette file that is laid over whatever theme is in force.
52
+ DEFAULT_THEME_FILE = Path.home() / ".zettcode" / "theme.toml"
53
+
54
+ #: Where tiktoken keeps the vocabulary it downloads once. Deliberately not the
55
+ #: OS temp directory, which is cleaned whenever the system feels like it: a purge
56
+ #: turns the next token count into a 1.7 MB download, and `/context` waits behind
57
+ #: it. Under this program's own directory the fetch happens once.
58
+ DEFAULT_TOKENIZER_CACHE = Path.home() / ".zettcode" / "cache" / "tiktoken"
59
+
60
+ #: Anything written to stderr while the TUI owns the screen is kept here: a
61
+ #: child process — an MCP server announcing itself, say — cannot know that the
62
+ #: frame is the interface, and the renderer only repaints what it changed.
63
+ DEFAULT_LOG = Path.home() / ".zettcode" / "log" / "tui.log"
64
+
65
+ #: The commands typed in the ``/bash`` panel, one per line, so they are there next time.
66
+ DEFAULT_COMMAND_HISTORY = Path.home() / ".zettcode" / "command_history"
67
+
68
+
69
+ def config_path(path: str | Path | None = None) -> Path:
70
+ """Return the config file to read: an explicit path, ``$ZETTCODE_CONFIG``, or the default.
71
+
72
+ Here rather than beside the settings it loads because the frame drawn before
73
+ those settings are read needs to name the same file without importing them.
74
+ """
75
+ if path is not None:
76
+ return Path(path).expanduser()
77
+ override = os.getenv("ZETTCODE_CONFIG")
78
+ return Path(override).expanduser() if override else CONFIG_FILE
79
+
80
+
81
+ def check_config_path(path: str | Path | None = None) -> Path:
82
+ """Return the config path, refusing one that exists and is not a file.
83
+
84
+ A path that exists and is not a file — a directory from ``ZETTCODE_CONFIG=.``, a FIFO — is a configuration
85
+ nobody can read, and it is not the fresh machine either: the terminal would start as if nothing were set up
86
+ and then fail every setting on that path. So is a link whose target is gone, which is not a file for the same
87
+ reason and would be replaced by the write rather than followed. A path with nothing at it is the fresh machine
88
+ and is answered with the path as it is.
89
+
90
+ Here, beside :func:`config_path`, because the command line asks this before it takes the terminal and before
91
+ the settings module may be imported at all.
92
+
93
+ Args:
94
+ path: The path to check; ``$ZETTCODE_CONFIG`` or ``~/.zettcode/config.toml`` by default.
95
+
96
+ Raises:
97
+ ValueError: The path exists (or is a link) and is not a file.
98
+ """
99
+ source = config_path(path)
100
+ if not source.is_file() and (source.exists() or source.is_symlink()):
101
+ raise ValueError(f"{source} is not a file, so there is nothing to read there")
102
+ return source
103
+
104
+
105
+ def resume_command(session_id: str, workspace: Path) -> str:
106
+ """Return the command that reopens one stored session from anywhere.
107
+
108
+ It is written down once because two places say it: the line printed on the way
109
+ out, and the line a fork leaves in the conversation. The workspace is part of it
110
+ because the store is keyed by workspace — the same id means nothing in another
111
+ checkout — and it is quoted, because a path may hold a space.
112
+ """
113
+ return f"zettcode resume {shlex.quote(session_id)} --workspace {shlex.quote(str(workspace))}"
zettcode/plugin_tui.py ADDED
@@ -0,0 +1,133 @@
1
+ """The interface of ``ctx.tui``: what a plugin may register when the process has a screen.
2
+
3
+ The terminal's group implements this; the context is typed with it so a plugin author sees the surface from the type
4
+ checker, and the guard ``if ctx.tui is None`` narrows it. It lives here, in a leaf module both layers may import, so the
5
+ engine can name it without importing the terminal — and the terminal can implement it without the engine importing the
6
+ terminal's own types.
7
+
8
+ The parameter types are deliberately loose: a command handler, a theme, a pet, a presenter and a row builder are the
9
+ terminal's own classes, which this module does not import. The docstrings say what each argument is, and
10
+ :class:`zettcode.app.plugins.groups.TuiGroups` is the implementation that takes the real ones.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from abc import ABC, abstractmethod
16
+ from typing import Any
17
+
18
+ __all__ = ["TuiGroup"]
19
+
20
+
21
+ class TuiGroup(ABC):
22
+ """What only the terminal can hold, as ``ctx.tui``.
23
+
24
+ A process without a screen has ``ctx.tui is None``; a plugin that only adds these starts its ``activate`` with
25
+ ``if ctx.tui is None: return``.
26
+ """
27
+
28
+ @property
29
+ @abstractmethod
30
+ def registered_commands(self) -> Any:
31
+ """Return the commands registered so far, in registration order."""
32
+
33
+ @property
34
+ @abstractmethod
35
+ def themes(self) -> Any:
36
+ """Return the palettes registered so far, in registration order."""
37
+
38
+ @property
39
+ @abstractmethod
40
+ def pets(self) -> Any:
41
+ """Return the pets registered so far, in registration order."""
42
+
43
+ @property
44
+ @abstractmethod
45
+ def presenters(self) -> Any:
46
+ """Return the presenters registered so far, in registration order."""
47
+
48
+ @property
49
+ @abstractmethod
50
+ def turn_segments(self) -> Any:
51
+ """Return the items of the line a request ends on, in the order they stand."""
52
+
53
+ @abstractmethod
54
+ def register_command(
55
+ self,
56
+ name: str,
57
+ description: str,
58
+ handler: Any,
59
+ *,
60
+ when_busy: str = "refuse",
61
+ subcommands: Any = (),
62
+ ) -> Any:
63
+ """Add a command that may open a page, ask the shell to act, or wait for a request to end.
64
+
65
+ ``handler`` is called with a :class:`~zettcode.app.commands.CommandContext` and returns a
66
+ :class:`~zettcode.app.commands.CommandResult`. A command whose whole answer is text belongs on
67
+ ``ctx.commands`` instead, where a process without a screen can run it too.
68
+ """
69
+
70
+ @abstractmethod
71
+ def register_command_provider(self, provider: Any) -> Any:
72
+ """Add commands that are worked out when the menu is built, from the current state."""
73
+
74
+ @abstractmethod
75
+ def register_theme(self, name: str, theme: Any, *, description: Any = "") -> Any:
76
+ """Offer a palette under its name; ``/theme`` lists, previews and switches to it."""
77
+
78
+ @abstractmethod
79
+ def register_pet(self, name: str, pet: Any, *, description: Any = "") -> Any:
80
+ """Offer a companion under its name; ``/pet`` lists, previews and switches to it."""
81
+
82
+ @abstractmethod
83
+ def register_presenter(self, presenter: Any) -> Any:
84
+ """Draw a kind of entry, or one family inside a kind, instead of the way it is drawn now."""
85
+
86
+ @abstractmethod
87
+ def register_header_left(self, builder: Any, *, name: str = "", override: bool = False) -> tuple[Any, bool]:
88
+ """Draw a segment on the header's left side."""
89
+
90
+ @abstractmethod
91
+ def register_header_right(self, builder: Any, *, name: str = "", override: bool = False) -> tuple[Any, bool]:
92
+ """Draw a segment on the header's right side."""
93
+
94
+ @abstractmethod
95
+ def register_status_left(self, builder: Any, *, name: str = "", override: bool = False) -> tuple[Any, bool]:
96
+ """Draw a segment on the status line's left side."""
97
+
98
+ @abstractmethod
99
+ def register_status_right(self, builder: Any, *, name: str = "", override: bool = False) -> tuple[Any, bool]:
100
+ """Draw a segment on the status line's right side."""
101
+
102
+ @abstractmethod
103
+ def register_turn_segment(self, name: str, summary: str, builder: Any) -> Any:
104
+ """Add one item to the line a request ends on."""
105
+
106
+ @abstractmethod
107
+ def register_focus_handler(self, handler: Any) -> Any:
108
+ """Be told when the terminal window gains or loses focus: ``handler(focused: bool)``.
109
+
110
+ The terminal asks for focus reports (DECSET 1004) when it starts, so a plugin can tell a reader who stepped
111
+ away from one who is watching — which is what a recap, a notification or a pause waits for. A terminal that
112
+ does not report focus never calls it, and nothing else changes.
113
+ """
114
+
115
+ @abstractmethod
116
+ def register_turn_start_handler(self, handler: Any) -> Any:
117
+ """Be told when a turn of the conversation starts: ``handler()``, before the request is sent.
118
+
119
+ The counterpart of :meth:`register_turn_end_handler`, and what makes an idle wait possible: work that waits
120
+ for the reader to stop asking cancels on the start and is armed by the end. A side question and a resumed
121
+ request are not turns; a turn a command starts with ``host.prompt`` is.
122
+ """
123
+
124
+ @abstractmethod
125
+ def register_turn_end_handler(self, handler: Any) -> Any:
126
+ """Be told when a turn of the conversation ends: ``handler(completed: bool)``, after the row it ended on.
127
+
128
+ ``completed`` is false for a turn that was stopped or that failed; both are ends, and a plugin that only
129
+ cares about answered requests tests it. A request the reader sent is a turn, and so is one a command started
130
+ with ``host.prompt``; running a command is not itself a turn, and neither is a side question or a resume. It is
131
+ called from inside the run that just ended, before the shell is free again, and nothing guards two turns at
132
+ once: a handler that starts work schedules it instead of running a turn from there.
133
+ """