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,268 @@
1
+ """Load the plugins for the terminal: the engine's loader, plus the commands, palettes, pets, presenters and rows.
2
+
3
+ Everything the engine's :class:`~zettcode.engine.plugins.loader.Loader` checks and merges, this one does too, and in
4
+ addition it keeps command names, theme names and pet names unique and merges the header and status rows, so a plugin
5
+ that collides on any of them is reported.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ from collections import Counter
12
+ from collections.abc import Callable
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from zettcode.engine.plugins.commands import PlainCommandContext, TextCommandResult, TurnCommandResult
16
+ from zettcode.engine.plugins.context import PluginContext
17
+ from zettcode.engine.plugins.loader import Loader, run
18
+ from zettcode.plugin_api.commands import Command, CommandResult
19
+ from zettcode.plugin_api.host import HOST_LINK, HostLink
20
+ from zettcode.plugin_api.plugin import TuiPlugin, TuiPlugins
21
+ from zettcode.plugin_api.state import TurnSegment
22
+ from zettcode.sdk.builtins import require_agent_parts
23
+ from zettcode.tui import RESERVED_THEMES
24
+
25
+ from .groups import TuiGroups, UiSlots
26
+
27
+ if TYPE_CHECKING: # pragma: no cover - annotations only
28
+ from collections.abc import Iterable, Mapping, Sequence
29
+ from importlib.metadata import EntryPoint
30
+
31
+ from zettcode.config import ZettCodeConfig
32
+ from zettcode.engine.plugins.commands import PlainCommand
33
+ from zettcode.engine.plugins.loader import OfferedPlugin
34
+ from zettcode.engine.plugins.plugin import Plugin
35
+ from zettcode.plugin_api.commands import Command
36
+ from zettcode.plugin_api.pets import PetSource
37
+ from zettcode.plugin_api.presenters import Presenter
38
+ from zettcode.tui import ThemeSource
39
+
40
+
41
+ def load_terminal_plugins(
42
+ config: ZettCodeConfig,
43
+ *,
44
+ entry_points: Iterable[EntryPoint] | None = None,
45
+ builtins: Sequence[OfferedPlugin] = (),
46
+ shared: Mapping[str, object] | None = None,
47
+ required: Sequence[str] = (),
48
+ agent: bool = True,
49
+ ) -> TuiPlugins:
50
+ """Load the builtin plugins and every enabled distribution into one bundle, for the terminal.
51
+
52
+ The arguments are those of :func:`zettcode.engine.plugins.loader.load_plugins`; the builtins default to the
53
+ terminal's own, and the agent's come from discovery like the other parts. ``agent=False`` leaves them out, for a
54
+ picture or a test that draws the shell with no runtime behind it.
55
+ """
56
+ bundle = run(
57
+ TerminalLoader(config, shared),
58
+ config,
59
+ builtins=builtins,
60
+ entry_points=entry_points,
61
+ required=required,
62
+ terminal=True,
63
+ agent=agent,
64
+ )
65
+ assert isinstance(bundle, TuiPlugins)
66
+ return bundle
67
+
68
+
69
+ #: The plugins the terminal cannot run without: the rows it paints, the presenters that draw the conversation and the
70
+ #: commands that are the way back to a working setup (`/help`, `/plugins`, `/model`).
71
+ #: ``[plugins] disable`` does not reach them, and the terminal does not start when one is missing.
72
+ REQUIRED_PLUGINS: tuple[str, ...] = ("shell", "turn-line", "presenters", "commands")
73
+
74
+
75
+ def terminal_plugins(config: ZettCodeConfig, shared: Mapping[str, object]) -> TuiPlugins:
76
+ """Load the plugins the terminal's way, in the shape :meth:`ZettCodeEngine.preview` takes as its loader."""
77
+ return require_agent_parts(load_terminal_plugins(config, shared=shared, required=REQUIRED_PLUGINS))
78
+
79
+
80
+ def _tui(context: PluginContext) -> TuiGroups:
81
+ """Return the terminal group of ``context``, which this loader always attaches."""
82
+ if context.tui is None or not isinstance(context.tui, TuiGroups):
83
+ raise TypeError(f"{type(context).__name__} has no terminal group")
84
+ return context.tui
85
+
86
+
87
+ def terminal_context(
88
+ config: ZettCodeConfig, services: Mapping[str, object] | None = None, *, name: str = ""
89
+ ) -> PluginContext:
90
+ """Build one context with the terminal group attached, the way this loader does for a plugin."""
91
+ context = PluginContext(config, services, name=name)
92
+ context.attach_tui(TuiGroups(context))
93
+ return context
94
+
95
+
96
+ def plain_commands(context: PluginContext) -> list[Command]:
97
+ """Turn the plugin's plain commands into the shell's, keeping the handler's contract.
98
+
99
+ The handler returns a :class:`~zettcode.engine.plugins.commands.TextCommandResult` or a
100
+ :class:`~zettcode.engine.plugins.commands.TurnCommandResult` — words, a document, or a request to start — so there
101
+ is nothing a shell could show that a process without one could not; the adapter maps each onto what the shell does
102
+ with it.
103
+ """
104
+ adapted: list[Command] = []
105
+ for plain in context.commands.commands:
106
+ adapted.append(
107
+ Command(
108
+ name=plain.name,
109
+ description=plain.summary,
110
+ type="plugin",
111
+ handler=_plain_handler(plain),
112
+ when_busy=plain.when_busy, # type: ignore[arg-type] # the shell validates the word
113
+ subcommands=plain.subcommands, # type: ignore[arg-type]
114
+ )
115
+ )
116
+ return adapted
117
+
118
+
119
+ def _plain_handler(plain: PlainCommand):
120
+ """Wrap one plain handler as the shell's, one result type at a time."""
121
+
122
+ async def run(context):
123
+ host = context.host
124
+ level = host.client.permission if host is not None else "ask"
125
+ result = plain.handler(PlainCommandContext(argument=context.argument, level=level))
126
+ if inspect.isawaitable(result):
127
+ result = await result
128
+ if isinstance(result, TurnCommandResult):
129
+ # A preset for a turn: the shell starts it, and the conversation keeps the short form the command was
130
+ # typed as.
131
+ await context.require_host().prompt(result.prompt, shown=result.shown or None, cache=result.cache or None)
132
+ return CommandResult(notification=result.notification)
133
+ if isinstance(result, TextCommandResult):
134
+ # A document is a page of its own, so the shell opens one instead of stacking it into the conversation;
135
+ # the message beside it is unchanged, so a command that gives both gets both. The widget library is
136
+ # imported only where there is a document: a command that answers with words alone never pays for it.
137
+ widget = None
138
+ if result.markdown:
139
+ from zettcode.app.ui.widgets import DocumentPage
140
+
141
+ # Titled by the command as it was typed: the summary is already in the menu, and this page covers the
142
+ # conversation, so it has to say which command opened it.
143
+ widget = DocumentPage(result.markdown, title=plain.name)
144
+ return CommandResult(message=result.message, notification=result.notification, widget=widget)
145
+ raise TypeError(
146
+ f"a plain command returns a TextCommandResult or a TurnCommandResult, not {type(result).__name__}"
147
+ )
148
+
149
+ return run
150
+
151
+
152
+ class TerminalLoader(Loader):
153
+ """The engine's loader with the terminal's registries."""
154
+
155
+ def __init__(self, config: ZettCodeConfig, shared: Mapping[str, object] | None = None) -> None:
156
+ super().__init__(config, shared)
157
+ self.services.setdefault(HOST_LINK, HostLink())
158
+ self.slots = UiSlots()
159
+ self.turn_segments: dict[str, TurnSegment] = {}
160
+ self.commands: list[Command] = []
161
+ #: The commands the program's own plugins (built in, or its parts) registered; the bundle puts them first.
162
+ self.program_commands: list[Command] = []
163
+ self.themes: list[ThemeSource] = []
164
+ self.pets: list[PetSource] = []
165
+ self.presenters: list[Presenter[Any]] = []
166
+ #: Told when the terminal window gains or loses focus, and when a turn of the conversation ends.
167
+ self.focus_handlers: list[Callable[[bool], None]] = []
168
+ self.turn_start_handlers: list[Callable[[], None]] = []
169
+ self.turn_end_handlers: list[Callable[[bool], None]] = []
170
+ self._claimed: set[str] = set()
171
+ self._theme_names: set[str] = set(RESERVED_THEMES)
172
+ self._pet_names: set[str] = set()
173
+
174
+ def new_context(self, name: str = "") -> PluginContext:
175
+ # Seed the registry with what is already loaded, so a segment that
176
+ # reuses a name — or asks to override a side — acts on the real rows
177
+ # instead of on an empty copy. A failure leaves it untouched.
178
+ context = terminal_context(self.config, self.services, name=name)
179
+ tui = _tui(context)
180
+ tui.slots.take_from(self.slots)
181
+ for segment in self.turn_segments.values():
182
+ tui.register_turn_segment(segment.name, segment.summary, segment.builder)
183
+ return context
184
+
185
+ def activated(self, plugin: Plugin[Any], context: PluginContext) -> None:
186
+ if isinstance(plugin, TuiPlugin):
187
+ terminal = _tui(context)
188
+ for region, side, slot_name, builder in plugin.ui_slots():
189
+ terminal.slots.add(region, side, slot_name, builder)
190
+
191
+ def clash(self, context: PluginContext) -> str | None:
192
+ terminal = _tui(context)
193
+ # Command lookup takes the first match, so a second plugin claiming the
194
+ # same name would be silently unreachable. Reject the plugin instead.
195
+ claimed = [command.name for command in terminal.registered_commands]
196
+ claimed += [plain.name for plain in context.commands.commands]
197
+ # Both registries of this plugin, and everything loaded before it, are one namespace: the shell keeps the
198
+ # first match, so a second spelling of a name would be unreachable rather than refused.
199
+ command_clash = next(
200
+ (name for name, count in Counter(claimed).items() if count > 1 or name in self._claimed), None
201
+ )
202
+ if command_clash is not None:
203
+ return f"command {command_clash} is already registered by another plugin"
204
+ found = super().clash(context)
205
+ if found is not None:
206
+ return found
207
+ # `/theme <name>` has to mean one palette, so a name an earlier plugin (the built-in one first) or the
208
+ # program itself already has is not shared out a second time.
209
+ theme_clash = next((source.name for source in terminal.themes if source.name in self._theme_names), None)
210
+ if theme_clash is not None:
211
+ return f"theme {theme_clash} is already registered"
212
+ # `/pet <name>` has to mean one pet, so a name an earlier plugin (the built-in one first) already has is not
213
+ # shared out a second time.
214
+ pet_clash = next((source.name for source in terminal.pets if source.name in self._pet_names), None)
215
+ if pet_clash is not None:
216
+ return f"pet {pet_clash} is already registered"
217
+ return None
218
+
219
+ def merge(self, context: PluginContext) -> None:
220
+ terminal = _tui(context)
221
+ super().merge(context)
222
+ # A plain command is one the shell can run too: text and a toast need no page, so the plugin's
223
+ # ``ctx.commands`` become ordinary commands here, beside the ones registered on ``ctx.tui``.
224
+ plain = plain_commands(context)
225
+ self._claimed.update(command.name for command in plain)
226
+ before = len(self.commands)
227
+ self.commands.extend(plain)
228
+ self._theme_names.update(source.name for source in terminal.themes)
229
+ self.themes.extend(terminal.themes)
230
+ self._pet_names.update(source.name for source in terminal.pets)
231
+ self.pets.extend(terminal.pets)
232
+ self.presenters.extend(terminal.presenters)
233
+ self.focus_handlers.extend(terminal.focus_handlers)
234
+ self.turn_start_handlers.extend(terminal.turn_start_handlers)
235
+ self.turn_end_handlers.extend(terminal.turn_end_handlers)
236
+ self._claimed.update(command.name for command in terminal.registered_commands)
237
+ self.commands.extend(terminal.registered_commands)
238
+ if self.program:
239
+ self.program_commands.extend(self.commands[before:])
240
+ self.slots = terminal.slots
241
+ self.turn_segments = {segment.name: segment for segment in terminal.turn_segments}
242
+
243
+ def bundle(self) -> TuiPlugins:
244
+ plain = super().bundle()
245
+ program = tuple(self.program_commands)
246
+ own = {id(command) for command in program}
247
+ return TuiPlugins(
248
+ host=plain.host,
249
+ mentions=plain.mentions,
250
+ providers=plain.providers,
251
+ contributed=plain.contributed,
252
+ services=plain.services,
253
+ plain_commands=plain.plain_commands,
254
+ failed=plain.failed,
255
+ builtins=plain.builtins,
256
+ triggers=plain.triggers,
257
+ taken=plain.taken,
258
+ commands=(*program, *(command for command in self.commands if id(command) not in own)),
259
+ builtin_commands=tuple(program),
260
+ themes=tuple(self.themes),
261
+ pets=tuple(self.pets),
262
+ presenters=tuple(self.presenters),
263
+ rows=self.slots.rows(),
264
+ turn_segments=tuple(self.turn_segments.values()),
265
+ focus_handlers=tuple(self.focus_handlers),
266
+ turn_start_handlers=tuple(self.turn_start_handlers),
267
+ turn_end_handlers=tuple(self.turn_end_handlers),
268
+ )
zettcode/app/py.typed ADDED
File without changes
@@ -0,0 +1,63 @@
1
+ """The ZettCode interface: the application shell and the widgets it composes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from typing import TYPE_CHECKING
7
+
8
+ #: Where each public name lives; read by :func:`__getattr__` on first use.
9
+ _EXPORTS = {
10
+ "CommandCompleter": ".widgets",
11
+ "ModelPage": ".widgets",
12
+ "TranscriptSource": ".widgets",
13
+ "TranscriptView": ".widgets",
14
+ "WELCOME": ".widgets",
15
+ "ZettCodeApp": ".app",
16
+ "ZettCodeRoot": ".widgets",
17
+ "help_text": ".widgets",
18
+ }
19
+
20
+
21
+ if TYPE_CHECKING: # pragma: no cover - for type checkers, not the runtime
22
+ from .app import ZettCodeApp
23
+ from .widgets import (
24
+ WELCOME,
25
+ CommandCompleter,
26
+ ModelPage,
27
+ TranscriptSource,
28
+ TranscriptView,
29
+ ZettCodeRoot,
30
+ help_text,
31
+ )
32
+
33
+
34
+ def __getattr__(name: str) -> object:
35
+ """Import the module a public name lives in, the first time it is asked for.
36
+
37
+ This package is a facade over several subpackages, and a caller that wants
38
+ one name should not pay for the rest of them — the TUI alone holds the
39
+ widget library, the Markdown parser, the diff viewer, and the syntax
40
+ scanners. ``__all__`` is still the public surface.
41
+ """
42
+ try:
43
+ module = _EXPORTS[name]
44
+ except KeyError:
45
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
46
+ return getattr(importlib.import_module(module, __name__), name)
47
+
48
+
49
+ def __dir__() -> list[str]:
50
+ """Return the names this package publishes."""
51
+ return sorted(__all__)
52
+
53
+
54
+ __all__ = [
55
+ "CommandCompleter",
56
+ "ModelPage",
57
+ "TranscriptSource",
58
+ "TranscriptView",
59
+ "WELCOME",
60
+ "ZettCodeApp",
61
+ "ZettCodeRoot",
62
+ "help_text",
63
+ ]