zettcode-app 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zettcode/app/__init__.py +63 -0
- zettcode/app/agent/__init__.py +47 -0
- zettcode/app/agent/agent/__init__.py +84 -0
- zettcode/app/agent/agent/commands.py +269 -0
- zettcode/app/agent/agent/settings.py +132 -0
- zettcode/app/agent/conversation/__init__.py +7 -0
- zettcode/app/agent/conversation/changes.py +64 -0
- zettcode/app/agent/conversation/effort_gauge.py +126 -0
- zettcode/app/agent/conversation/projection.py +265 -0
- zettcode/app/agent/conversation/rendering.py +837 -0
- zettcode/app/agent/conversation/replay.py +198 -0
- zettcode/app/agent/conversation/shimmer.py +256 -0
- zettcode/app/agent/conversation/stops.py +57 -0
- zettcode/app/agent/conversation/tips.py +86 -0
- zettcode/app/agent/conversation/transcript/__init__.py +80 -0
- zettcode/app/agent/conversation/transcript/fold.py +57 -0
- zettcode/app/agent/conversation/transcript/log.py +182 -0
- zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
- zettcode/app/agent/conversation/transcript/stream.py +187 -0
- zettcode/app/agent/conversation/transcript/turn.py +323 -0
- zettcode/app/commandline/__init__.py +47 -0
- zettcode/app/commandline/arguments.py +169 -0
- zettcode/app/commandline/completion.py +262 -0
- zettcode/app/commandline/history.py +305 -0
- zettcode/app/commandline/runner.py +365 -0
- zettcode/app/errors.py +12 -0
- zettcode/app/plugins/__init__.py +110 -0
- zettcode/app/plugins/builtins.py +33 -0
- zettcode/app/plugins/groups.py +503 -0
- zettcode/app/plugins/loader.py +268 -0
- zettcode/app/py.typed +0 -0
- zettcode/app/ui/__init__.py +63 -0
- zettcode/app/ui/app.py +1507 -0
- zettcode/app/ui/bars.py +290 -0
- zettcode/app/ui/boot.py +131 -0
- zettcode/app/ui/clipboard.py +340 -0
- zettcode/app/ui/commands.py +323 -0
- zettcode/app/ui/config_form.py +343 -0
- zettcode/app/ui/demo.py +180 -0
- zettcode/app/ui/followups.py +430 -0
- zettcode/app/ui/gc_policy.py +147 -0
- zettcode/app/ui/keys.py +462 -0
- zettcode/app/ui/messaging.py +260 -0
- zettcode/app/ui/notices.py +195 -0
- zettcode/app/ui/providers.py +626 -0
- zettcode/app/ui/sessions.py +422 -0
- zettcode/app/ui/settings.py +161 -0
- zettcode/app/ui/shell.py +361 -0
- zettcode/app/ui/turns.py +115 -0
- zettcode/app/ui/updating.py +204 -0
- zettcode/app/ui/widgets/__init__.py +161 -0
- zettcode/app/ui/widgets/approval_page.py +284 -0
- zettcode/app/ui/widgets/ask_page.py +386 -0
- zettcode/app/ui/widgets/bash_page.py +90 -0
- zettcode/app/ui/widgets/completer.py +100 -0
- zettcode/app/ui/widgets/composer.py +529 -0
- zettcode/app/ui/widgets/document_page.py +159 -0
- zettcode/app/ui/widgets/effort_page.py +294 -0
- zettcode/app/ui/widgets/follow_up.py +168 -0
- zettcode/app/ui/widgets/form_page.py +380 -0
- zettcode/app/ui/widgets/input_page.py +175 -0
- zettcode/app/ui/widgets/model_page.py +63 -0
- zettcode/app/ui/widgets/model_settings_page.py +653 -0
- zettcode/app/ui/widgets/pet.py +247 -0
- zettcode/app/ui/widgets/pet_page.py +347 -0
- zettcode/app/ui/widgets/plugins_page.py +442 -0
- zettcode/app/ui/widgets/root.py +68 -0
- zettcode/app/ui/widgets/self_compact_page.py +363 -0
- zettcode/app/ui/widgets/send_kind_page.py +193 -0
- zettcode/app/ui/widgets/sessions_page.py +511 -0
- zettcode/app/ui/widgets/statusline_page.py +427 -0
- zettcode/app/ui/widgets/steering.py +126 -0
- zettcode/app/ui/widgets/transcript.py +287 -0
- zettcode/app/ui/widgets/update_page.py +65 -0
- zettcode/app/ui/widgets/welcome.py +24 -0
- zettcode/collector.py +54 -0
- zettcode/update.py +519 -0
- zettcode_app-0.4.2.dist-info/METADATA +26 -0
- zettcode_app-0.4.2.dist-info/RECORD +81 -0
- zettcode_app-0.4.2.dist-info/WHEEL +4 -0
- zettcode_app-0.4.2.dist-info/entry_points.txt +2 -0
zettcode/app/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""ZettCode's application layer, split into its interface and its agent glue.
|
|
2
|
+
|
|
3
|
+
``ui`` holds what the user sees and drives: the transcript and the application
|
|
4
|
+
shell. ``agent`` holds the adapter that turns agent events into transcript
|
|
5
|
+
entries. This module is the facade over both, so ``from zettcode.app import
|
|
6
|
+
ZettCodeApp`` keeps working for callers that do not care where it lives.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
#: Where each public name lives; read by :func:`__getattr__` on first use.
|
|
15
|
+
_EXPORTS = {
|
|
16
|
+
"Entry": ".agent",
|
|
17
|
+
"Transcript": ".agent",
|
|
18
|
+
"TranscriptProjector": ".agent",
|
|
19
|
+
"TranscriptSource": ".ui",
|
|
20
|
+
"TranscriptView": ".ui",
|
|
21
|
+
"TerminalAgent": ".agent",
|
|
22
|
+
"ZettCodeApp": ".ui",
|
|
23
|
+
"ZettCodeRoot": ".ui",
|
|
24
|
+
"ZettCodeEngine": ".agent",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING: # pragma: no cover - for type checkers, not the runtime
|
|
29
|
+
from .agent import Entry, TerminalAgent, Transcript, TranscriptProjector, ZettCodeEngine
|
|
30
|
+
from .ui import TranscriptSource, TranscriptView, ZettCodeApp, ZettCodeRoot
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def __getattr__(name: str) -> object:
|
|
34
|
+
"""Import the module a public name lives in, the first time it is asked for.
|
|
35
|
+
|
|
36
|
+
This package is a facade over several subpackages, and a caller that wants
|
|
37
|
+
one name should not pay for the rest of them — the TUI alone holds the
|
|
38
|
+
widget library, the Markdown parser, the diff viewer, and the syntax
|
|
39
|
+
scanners. ``__all__`` is still the public surface.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
module = _EXPORTS[name]
|
|
43
|
+
except KeyError:
|
|
44
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
|
45
|
+
return getattr(importlib.import_module(module, __name__), name)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def __dir__() -> list[str]:
|
|
49
|
+
"""Return the names this package publishes."""
|
|
50
|
+
return sorted(__all__)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"Entry",
|
|
55
|
+
"Transcript",
|
|
56
|
+
"TranscriptProjector",
|
|
57
|
+
"TranscriptSource",
|
|
58
|
+
"TranscriptView",
|
|
59
|
+
"ZettCodeApp",
|
|
60
|
+
"TerminalAgent",
|
|
61
|
+
"ZettCodeRoot",
|
|
62
|
+
"ZettCodeEngine",
|
|
63
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Agent-facing glue: the conversation model and the events that fill it."""
|
|
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
|
+
"Entry": "zettcode.plugin_api.entries",
|
|
11
|
+
"Transcript": ".conversation.transcript",
|
|
12
|
+
"TranscriptProjector": ".conversation.projection",
|
|
13
|
+
"TerminalAgent": ".agent",
|
|
14
|
+
"ZettCodeEngine": "zettcode.engine.core",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING: # pragma: no cover - for type checkers, not the runtime
|
|
19
|
+
from zettcode.engine.core import ZettCodeEngine
|
|
20
|
+
from zettcode.plugin_api.entries import Entry
|
|
21
|
+
|
|
22
|
+
from .agent import TerminalAgent
|
|
23
|
+
from .conversation.projection import TranscriptProjector
|
|
24
|
+
from .conversation.transcript import Transcript
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def __getattr__(name: str) -> object:
|
|
28
|
+
"""Import the module a public name lives in, the first time it is asked for.
|
|
29
|
+
|
|
30
|
+
This package is a facade over several subpackages, and a caller that wants
|
|
31
|
+
one name should not pay for the rest of them — the TUI alone holds the
|
|
32
|
+
widget library, the Markdown parser, the diff viewer, and the syntax
|
|
33
|
+
scanners. ``__all__`` is still the public surface.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
module = _EXPORTS[name]
|
|
37
|
+
except KeyError:
|
|
38
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
|
39
|
+
return getattr(importlib.import_module(module, __name__), name)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def __dir__() -> list[str]:
|
|
43
|
+
"""Return the names this package publishes."""
|
|
44
|
+
return sorted(__all__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = ["Entry", "Transcript", "TranscriptProjector", "TerminalAgent", "ZettCodeEngine"]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""The terminal's agent: the engine's agent, plus the commands and preferences only a terminal has.
|
|
2
|
+
|
|
3
|
+
The engine's agent (:class:`zettcode.engine.agent.ZettCodeAgent`) does the work. This subclass adds what the shell
|
|
4
|
+
asks of it that is not work: the slash commands and what plugins contributed (``commands``), and the theme, the pet
|
|
5
|
+
and the status line the reader can change (``settings``). Its engine is built with the terminal's plugin loader, so
|
|
6
|
+
the commands, palettes, pets, presenters and rows of the plugins are there to be read.
|
|
7
|
+
|
|
8
|
+
This package is also where the terminal is put together from the engine's parts, so it is what names, for the views, the
|
|
9
|
+
engine objects they hold: ``SessionLink`` (the line to the other sessions) and ``StoredSession`` (a session read from
|
|
10
|
+
the store). The views import those names from here and not from the engine.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING, TypeAlias
|
|
16
|
+
|
|
17
|
+
from zettcode.engine.agent import ZettCodeAgent
|
|
18
|
+
from zettcode.engine.agent.sessions import StoredSession
|
|
19
|
+
from zettcode.engine.core import PluginLoader, ZettCodeEngine
|
|
20
|
+
from zettcode.messaging.messenger import SESSION_LINK, Messenger
|
|
21
|
+
from zettcode.plugin_api.commands import CommandAgent
|
|
22
|
+
|
|
23
|
+
from .commands import CommandsMixin
|
|
24
|
+
|
|
25
|
+
#: The line to the other sessions, under the name the views use for it.
|
|
26
|
+
SessionLink: TypeAlias = Messenger
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
29
|
+
from collections.abc import Mapping
|
|
30
|
+
|
|
31
|
+
from zettcode.config import ZettCodeConfig
|
|
32
|
+
from zettcode.sdk.client import Client
|
|
33
|
+
|
|
34
|
+
__all__ = ["SessionLink", "StoredSession", "TerminalAgent"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _terminal_loader() -> PluginLoader:
|
|
38
|
+
"""Return the terminal's plugin loader, imported when an agent is built and not before."""
|
|
39
|
+
from zettcode.app.plugins.loader import terminal_plugins
|
|
40
|
+
|
|
41
|
+
return terminal_plugins
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# The contract is last on purpose: `AgentBase`'s concrete `session_id`/`workspace` must win over `CommandAgent`'s
|
|
45
|
+
# abstract properties, or the class becomes uninstantiable.
|
|
46
|
+
class TerminalAgent(CommandsMixin, ZettCodeAgent, CommandAgent):
|
|
47
|
+
"""The agent the terminal application drives."""
|
|
48
|
+
|
|
49
|
+
_client: Client | None = None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def messenger(self) -> SessionLink | None:
|
|
53
|
+
"""Return the line to the other sessions, or ``None`` when the configuration switches messaging off."""
|
|
54
|
+
bundle = getattr(self.runtime, "plugins", None)
|
|
55
|
+
return None if bundle is None else bundle.find(SESSION_LINK, SessionLink)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def client(self) -> Client:
|
|
59
|
+
"""Return the SDK client over this agent, which is how the shell reaches everything the SDK offers.
|
|
60
|
+
|
|
61
|
+
Made on first use and then kept, so the shell and the commands it runs read the same client. The agent still
|
|
62
|
+
owns closing: the client is a view of it.
|
|
63
|
+
"""
|
|
64
|
+
if self._client is None:
|
|
65
|
+
from zettcode.sdk.client import Client
|
|
66
|
+
|
|
67
|
+
self._client = Client._around(self)
|
|
68
|
+
return self._client
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
async def create(cls, config: ZettCodeConfig, load: PluginLoader | None = None) -> TerminalAgent:
|
|
72
|
+
"""Build the engine with the terminal's plugins and start it."""
|
|
73
|
+
return cls(await ZettCodeEngine.create(config, load or _terminal_loader()))
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def preview(
|
|
77
|
+
cls,
|
|
78
|
+
config: ZettCodeConfig,
|
|
79
|
+
load: PluginLoader | None = None,
|
|
80
|
+
*,
|
|
81
|
+
shared: Mapping[str, object] | None = None,
|
|
82
|
+
) -> TerminalAgent:
|
|
83
|
+
"""Wrap an engine that still has to start, built with the terminal's plugins, so the shell can paint first."""
|
|
84
|
+
return cls(ZettCodeEngine.preview(config, load or _terminal_loader(), shared=shared))
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""What the agent offers the shell to run and to show: its own commands, and what plugins added.
|
|
2
|
+
|
|
3
|
+
The commands report through transcript Markdown; commands that present a widget belong to
|
|
4
|
+
the shell, which owns the widgets. The plugin contributions are listed here because the
|
|
5
|
+
agent is where the shell asks for everything the runtime loaded.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
from zettcode.engine.agent.exports import ExportsMixin
|
|
14
|
+
from zettcode.plugin_api.commands import Command, CommandContext, CommandHost, CommandResult
|
|
15
|
+
from zettcode.plugin_api.host import HOST_LINK, HostLink
|
|
16
|
+
from zettcode.plugin_api.pets import PetCatalog
|
|
17
|
+
from zettcode.plugin_api.plugin import TuiPlugins
|
|
18
|
+
from zettcode.plugin_api.presenters import Presenters
|
|
19
|
+
from zettcode.plugin_api.state import TurnSegment, UiRow
|
|
20
|
+
from zettcode.presenters.statusline import NAMES, unknown
|
|
21
|
+
from zettcode.providers import BUILTIN_PROVIDERS, Provider, merge_providers
|
|
22
|
+
from zettcode.tui import ThemeCatalog
|
|
23
|
+
|
|
24
|
+
from .settings import AppearanceMixin
|
|
25
|
+
|
|
26
|
+
#: The words ``/self-compact`` takes after it, or after ``global``.
|
|
27
|
+
_SELF_COMPACT_WORDS = frozenset({"on", "off", "default"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CommandsMixin(AppearanceMixin, ExportsMixin):
|
|
31
|
+
"""The ``/new``, ``/compact``, and ``/export`` commands, and the plugins' commands, mentions, and rows."""
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def terminal_plugins(self) -> TuiPlugins:
|
|
35
|
+
"""Return what the plugins contributed to the terminal; the engine was built with the terminal's loader."""
|
|
36
|
+
plugins = self.runtime.plugins
|
|
37
|
+
if not isinstance(plugins, TuiPlugins):
|
|
38
|
+
raise TypeError("This agent's engine was not built with the terminal's plugin loader")
|
|
39
|
+
return plugins
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def commands(self) -> tuple[Command, ...]:
|
|
43
|
+
"""Return the conversation commands the agent owns.
|
|
44
|
+
|
|
45
|
+
They report through transcript Markdown; commands that present a widget
|
|
46
|
+
(such as the model picker) belong to the shell, which owns the widgets.
|
|
47
|
+
"""
|
|
48
|
+
return (
|
|
49
|
+
Command("/new", "start a fresh session", "agent", self._command_new),
|
|
50
|
+
Command("/compact", "summarize the context now", "agent", self._command_compact),
|
|
51
|
+
Command("/export", "write this session to an HTML file: /export [path]", "agent", self._command_export),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def attach_host(self, host: CommandHost) -> None:
|
|
55
|
+
"""Tell the plugins where the shell is, so the words their commands offer can follow the session."""
|
|
56
|
+
link = self.runtime.plugins.find(HOST_LINK, HostLink)
|
|
57
|
+
if link is not None:
|
|
58
|
+
link.attach(host)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def plugin_commands(self) -> tuple[Command, ...]:
|
|
62
|
+
"""Return the commands the installed plugins registered, not counting the built-in ones.
|
|
63
|
+
|
|
64
|
+
The shell keeps them after everything built in, so a plugin cannot take over a built-in name;
|
|
65
|
+
plugin-versus-plugin duplicates are rejected when the plugins load.
|
|
66
|
+
"""
|
|
67
|
+
return self.terminal_plugins.installed_commands
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def builtin_plugin_commands(self) -> tuple[Command, ...]:
|
|
71
|
+
"""Return the commands the program's own plugins registered: ``/resume``, ``/model``, ``/usage`` and the rest."""
|
|
72
|
+
return self.terminal_plugins.builtin_commands
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def providers(self) -> tuple[Provider, ...]:
|
|
76
|
+
"""Return the hosts ``/config`` can offer: the built-in ones, then the plugins' own."""
|
|
77
|
+
return merge_providers(BUILTIN_PROVIDERS, self.runtime.plugins.providers)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def themes(self) -> ThemeCatalog:
|
|
81
|
+
"""Return every palette that can be asked for by name: the plugins', then the reader's own files.
|
|
82
|
+
|
|
83
|
+
Built fresh each time it is asked: it lists names and builds nothing, and the reader's directory may have
|
|
84
|
+
gained a file since the last look.
|
|
85
|
+
"""
|
|
86
|
+
return ThemeCatalog(self.terminal_plugins.themes, self.runtime.config.themes_dir)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def pets(self) -> PetCatalog:
|
|
90
|
+
"""Return every pet that can be asked for by name, in the order the plugins registered them.
|
|
91
|
+
|
|
92
|
+
Nothing is bundled here: with no pets plugin the catalog is empty.
|
|
93
|
+
"""
|
|
94
|
+
return PetCatalog(self.terminal_plugins.pets)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def turn_segments(self) -> tuple[TurnSegment, ...]:
|
|
98
|
+
"""Return the items of the line a request ends on, the program's own first, then the plugins'."""
|
|
99
|
+
return self.terminal_plugins.turn_segments
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def pets_switched_off(self) -> bool:
|
|
103
|
+
"""Return whether the reader turned the pets plugin off, which is why a catalog of none is empty."""
|
|
104
|
+
from zettcode.engine.plugins import off_names
|
|
105
|
+
|
|
106
|
+
return "pets" in off_names(self.runtime.config)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def plugin_failures(self) -> tuple[str, ...]:
|
|
110
|
+
"""Return one message per plugin that could not be loaded or activated."""
|
|
111
|
+
return self.terminal_plugins.failures
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def presenters(self) -> Presenters:
|
|
115
|
+
"""Return how this run draws entries: the built-in presenters, and any a plugin outranks them with.
|
|
116
|
+
|
|
117
|
+
Built from the bundle rather than from a module-level default for the same reason the palettes are: the
|
|
118
|
+
program's own rows are registered through the plugin route, so a plugin that draws a kind or a family of
|
|
119
|
+
rows reaches the transcript without a second path only plugins could not take.
|
|
120
|
+
"""
|
|
121
|
+
return Presenters(self.terminal_plugins.presenters)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def plugin_rows(self) -> tuple[UiRow, ...]:
|
|
125
|
+
"""Return the header and status rows plugins asked the shell to draw."""
|
|
126
|
+
return self.terminal_plugins.rows
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def plugin_focus_handlers(self) -> tuple[Callable[[bool], None], ...]:
|
|
130
|
+
"""Return the handlers told when the terminal window gains or loses focus."""
|
|
131
|
+
return self.terminal_plugins.focus_handlers
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def plugin_turn_start_handlers(self) -> tuple[Callable[[], None], ...]:
|
|
135
|
+
"""Return the handlers told when a turn of the conversation starts."""
|
|
136
|
+
return self.terminal_plugins.turn_start_handlers
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def plugin_turn_end_handlers(self) -> tuple[Callable[[bool], None], ...]:
|
|
140
|
+
"""Return the handlers told when a turn of the conversation ends."""
|
|
141
|
+
return self.terminal_plugins.turn_end_handlers
|
|
142
|
+
|
|
143
|
+
async def _command_new(self, context: CommandContext) -> CommandResult:
|
|
144
|
+
"""Start a fresh session."""
|
|
145
|
+
session_id = self.new_session()
|
|
146
|
+
return CommandResult(notification=f"started session {session_id[:8]}")
|
|
147
|
+
|
|
148
|
+
async def _command_export(self, context: CommandContext) -> CommandResult:
|
|
149
|
+
"""Write the session and its context to one HTML file.
|
|
150
|
+
|
|
151
|
+
The path argument is optional; without it the file lands in the
|
|
152
|
+
workspace, named after the session, and the result says where.
|
|
153
|
+
"""
|
|
154
|
+
path = await self.export_session(context.argument.strip() or None)
|
|
155
|
+
return CommandResult(message=f"Exported to `{path}`")
|
|
156
|
+
|
|
157
|
+
def self_compaction_summary(self) -> str:
|
|
158
|
+
"""Say in one line whether self-compaction is on here, where that comes from, and what the default is."""
|
|
159
|
+
state = "on" if self.self_compaction else "off"
|
|
160
|
+
default = "on" if self.self_compaction_default else "off"
|
|
161
|
+
if self.self_compaction_choice is None:
|
|
162
|
+
return f"Self-compaction is {state}: this session follows the default ({default})"
|
|
163
|
+
return f"Self-compaction is {state}: this session chose it; the default is {default}"
|
|
164
|
+
|
|
165
|
+
def self_compact_report(self) -> str:
|
|
166
|
+
"""Say what self-compaction is, whether it is on here, and where that comes from."""
|
|
167
|
+
choice = self.self_compaction_choice
|
|
168
|
+
default = "on" if self.self_compaction_default else "off"
|
|
169
|
+
state = "on" if self.self_compaction else "off"
|
|
170
|
+
source = (
|
|
171
|
+
f"this session chose it; the config default is **{default}**"
|
|
172
|
+
if choice is not None
|
|
173
|
+
else "following the config default (`[compaction] self`)"
|
|
174
|
+
)
|
|
175
|
+
return (
|
|
176
|
+
f"Self-compaction is **{state}** ({source}).\n\n"
|
|
177
|
+
"When on, the model gets a `compact_context` tool and can summarize its own earlier conversation "
|
|
178
|
+
"when it judges the detail is no longer needed. The current turn is always kept. "
|
|
179
|
+
"`/compact` and the automatic threshold work either way.\n\n"
|
|
180
|
+
"`/self-compact on|off` sets this session; `default` hands it back to the config; "
|
|
181
|
+
"`global on|off` changes the default for every session that has not chosen."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def change_self_compaction(self, argument: str) -> str:
|
|
185
|
+
"""Read or change self-compaction from the words ``/self-compact`` takes, and say what now applies.
|
|
186
|
+
|
|
187
|
+
``on``, ``off`` and ``default`` set this session's own choice; ``global on`` and ``global off``
|
|
188
|
+
write the default for every session into the config file. A failed write is never reported as saved.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
ValueError: The words are not ones the command takes, or the change could not be saved.
|
|
192
|
+
"""
|
|
193
|
+
words = argument.lower().split()
|
|
194
|
+
scope = "session"
|
|
195
|
+
if words[:1] == ["global"]:
|
|
196
|
+
scope, words = "global", words[1:]
|
|
197
|
+
usage = ValueError("Usage: /self-compact [on|off|default] or /self-compact global on|off")
|
|
198
|
+
if len(words) > 1 or (scope == "global" and not words) or (words and words[0] not in _SELF_COMPACT_WORDS):
|
|
199
|
+
raise usage
|
|
200
|
+
if scope == "global":
|
|
201
|
+
if words[0] == "default":
|
|
202
|
+
raise usage
|
|
203
|
+
self.remember_self_compaction(words[0] == "on")
|
|
204
|
+
elif words:
|
|
205
|
+
self.use_self_compaction({"on": True, "off": False, "default": None}[words[0]])
|
|
206
|
+
return self.self_compact_report()
|
|
207
|
+
|
|
208
|
+
def statusline_summary(self) -> str:
|
|
209
|
+
"""Say in one line which parts of the header and the status line are hidden."""
|
|
210
|
+
hidden = [
|
|
211
|
+
*(name for name in NAMES if name in self.statusline_hidden),
|
|
212
|
+
*sorted(self.statusline_hidden - set(NAMES)),
|
|
213
|
+
]
|
|
214
|
+
if not hidden:
|
|
215
|
+
return "Status line: every part is shown"
|
|
216
|
+
total = len(NAMES) + len({item.name for item in self.turn_segments} - set(NAMES))
|
|
217
|
+
return f"Status line: {len(hidden)} of {total} parts hidden ({', '.join(hidden)})"
|
|
218
|
+
|
|
219
|
+
def change_statusline(self, argument: str) -> str:
|
|
220
|
+
"""Hide or show parts of the header and status line from the words ``/statusline`` takes.
|
|
221
|
+
|
|
222
|
+
``hide <name>...`` and ``show <name>...`` change the named parts, and ``show all`` brings every one
|
|
223
|
+
back. The change is written to ``[statusline]`` in the config file, and nothing is changed when any
|
|
224
|
+
name is not a part.
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
ValueError: The words are not ones the command takes, or the change could not be saved.
|
|
228
|
+
"""
|
|
229
|
+
words = argument.lower().split()
|
|
230
|
+
usage = ValueError(
|
|
231
|
+
f"Usage: /statusline [hide|show] <part>... or /statusline show all; parts: {', '.join(NAMES)}"
|
|
232
|
+
)
|
|
233
|
+
if len(words) < 2 or words[0] not in {"hide", "show"}:
|
|
234
|
+
raise usage
|
|
235
|
+
visible = words[0] == "show"
|
|
236
|
+
names = list(NAMES) if visible and words[1:] == ["all"] else words[1:]
|
|
237
|
+
if stray := unknown(names):
|
|
238
|
+
raise ValueError(f"No such part: {', '.join(stray)}. Parts: {', '.join(NAMES)}")
|
|
239
|
+
self.remember_statusline({name: visible for name in names})
|
|
240
|
+
return self.statusline_summary()
|
|
241
|
+
|
|
242
|
+
async def _command_compact(self, context: CommandContext) -> CommandResult:
|
|
243
|
+
"""Summarize the conversation now, whatever its size.
|
|
244
|
+
|
|
245
|
+
The row is opened only after the refusal check. That check may read a
|
|
246
|
+
cold session on a worker, so the interface stays live while it runs;
|
|
247
|
+
once the row is up, the shell draws it before the pass starts. What
|
|
248
|
+
follows the first ``await`` is still synchronous token counting and the
|
|
249
|
+
summary request, so a row created only by ``CompactionStarted`` would
|
|
250
|
+
reach the screen after the work it was meant to explain.
|
|
251
|
+
"""
|
|
252
|
+
host = context.host
|
|
253
|
+
if host is not None:
|
|
254
|
+
await self.ensure_compactable()
|
|
255
|
+
host.transcript.start_compaction()
|
|
256
|
+
try:
|
|
257
|
+
if host is not None:
|
|
258
|
+
await host.wait_for_frame()
|
|
259
|
+
await self._compact_pass()
|
|
260
|
+
else:
|
|
261
|
+
await self.compact()
|
|
262
|
+
except asyncio.CancelledError:
|
|
263
|
+
if host is not None:
|
|
264
|
+
host.transcript.complete_compaction("Compacting stopped")
|
|
265
|
+
raise
|
|
266
|
+
finally:
|
|
267
|
+
if host is not None:
|
|
268
|
+
host.transcript.complete_compaction()
|
|
269
|
+
return CommandResult()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""What the reader can change about how the terminal looks: theme, pet, which parts of the status line are shown.
|
|
2
|
+
|
|
3
|
+
These are the terminal's own preferences. They are kept in the same config file as the engine's settings, and are
|
|
4
|
+
written the same way, which is why this reads the file through the engine's helpers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from zettcode.config_edit import (
|
|
14
|
+
ConfigEditError,
|
|
15
|
+
set_pet,
|
|
16
|
+
set_pet_position,
|
|
17
|
+
set_statusline,
|
|
18
|
+
set_theme,
|
|
19
|
+
write_text_atomically,
|
|
20
|
+
)
|
|
21
|
+
from zettcode.engine.agent.settings import SettingsMixin
|
|
22
|
+
from zettcode.presenters.statusline import NAMES
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AppearanceMixin(SettingsMixin):
|
|
26
|
+
"""Read and remember the terminal's theme, pet and status line."""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def reduced_motion(self) -> bool:
|
|
30
|
+
"""Return whether decorative animation is disabled."""
|
|
31
|
+
return self.runtime.config.reduced_motion
|
|
32
|
+
|
|
33
|
+
def remember_theme(self, name: str) -> Path:
|
|
34
|
+
"""Write ``[ui] theme = name`` into the config file, so the next start opens in that palette.
|
|
35
|
+
|
|
36
|
+
Only that line changes. A config file that does not exist is not created for it: a file with nothing but
|
|
37
|
+
``[ui]`` in it would stand where a reader's environment-variable setup is, and say nothing about models.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The file written.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ConfigEditError: When there is no config file, it cannot be read or edited safely, or it cannot be
|
|
44
|
+
written. Nothing is written then.
|
|
45
|
+
"""
|
|
46
|
+
text = set_theme(self._read_config_file(), name, where=self.config_file)
|
|
47
|
+
try:
|
|
48
|
+
write_text_atomically(self.config_file, text)
|
|
49
|
+
except OSError as error:
|
|
50
|
+
raise ConfigEditError(f"{self.config_file} could not be written: {error}") from error
|
|
51
|
+
return self.config_file
|
|
52
|
+
|
|
53
|
+
def remember_pet(self, name: str) -> Path:
|
|
54
|
+
"""Write ``[ui] pet = name`` into the config file, so the next start opens with that pet.
|
|
55
|
+
|
|
56
|
+
Only that line changes, and a config file that does not exist is not created for it. The pet in force
|
|
57
|
+
for this run is the shell's to switch; what this keeps up to date is the setting the shell reads back.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The file written.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ConfigEditError: When there is no config file, it cannot be read or edited safely, or it cannot be
|
|
64
|
+
written. Nothing is written then.
|
|
65
|
+
"""
|
|
66
|
+
text = set_pet(self._read_config_file(), name, where=self.config_file)
|
|
67
|
+
try:
|
|
68
|
+
write_text_atomically(self.config_file, text)
|
|
69
|
+
except OSError as error:
|
|
70
|
+
raise ConfigEditError(f"{self.config_file} could not be written: {error}") from error
|
|
71
|
+
self.runtime.config = replace(self.runtime.config, pet=name)
|
|
72
|
+
return self.config_file
|
|
73
|
+
|
|
74
|
+
def remember_pet_position(self, across: float, down: float) -> Path:
|
|
75
|
+
"""Write ``[ui] pet_position`` into the config file, so the next start puts the pet back where it was left.
|
|
76
|
+
|
|
77
|
+
The file is global to the reader (``~/.zettcode/config.toml``), not to a project: a pet is the reader's
|
|
78
|
+
companion, and it does not move house when they open another folder. Only that line changes, and a
|
|
79
|
+
config file that does not exist is not created for it.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The file written.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
ConfigEditError: When there is no config file, it cannot be read or edited safely, or it cannot be
|
|
86
|
+
written. Nothing is written then.
|
|
87
|
+
"""
|
|
88
|
+
text = set_pet_position(self._read_config_file(), across, down, where=self.config_file)
|
|
89
|
+
try:
|
|
90
|
+
write_text_atomically(self.config_file, text)
|
|
91
|
+
except OSError as error:
|
|
92
|
+
raise ConfigEditError(f"{self.config_file} could not be written: {error}") from error
|
|
93
|
+
position = (round(min(1.0, max(0.0, across)), 3), round(min(1.0, max(0.0, down)), 3))
|
|
94
|
+
self.runtime.config = replace(self.runtime.config, pet_position=position)
|
|
95
|
+
return self.config_file
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def statusline_hidden(self) -> frozenset[str]:
|
|
99
|
+
"""Return the names of the header and status-line parts the reader switched off."""
|
|
100
|
+
return frozenset(self.runtime.config.statusline_hidden)
|
|
101
|
+
|
|
102
|
+
def remember_statusline(self, shown: Mapping[str, bool]) -> Path:
|
|
103
|
+
"""Write which parts of the header and status line are shown into ``[statusline]``.
|
|
104
|
+
|
|
105
|
+
Only the parts that differ from what is in force are written. The file is written first and the
|
|
106
|
+
running configuration changed second, so a write that fails leaves both as they were; the rows
|
|
107
|
+
follow at the next paint. A config file that does not exist is not created for it, as for the theme.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
shown: Part name to whether it is shown. A part left out keeps what it has.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The file written.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
ConfigEditError: When there is no config file, it cannot be read or edited safely, or it cannot
|
|
117
|
+
be written.
|
|
118
|
+
"""
|
|
119
|
+
hidden = set(self.statusline_hidden)
|
|
120
|
+
changes = {name: visible for name, visible in shown.items() if (name not in hidden) != visible}
|
|
121
|
+
if not changes:
|
|
122
|
+
return self.config_file
|
|
123
|
+
text = set_statusline(self._read_config_file(), changes, where=self.config_file)
|
|
124
|
+
try:
|
|
125
|
+
write_text_atomically(self.config_file, text)
|
|
126
|
+
except OSError as error:
|
|
127
|
+
raise ConfigEditError(f"{self.config_file} could not be written: {error}") from error
|
|
128
|
+
hidden = {name for name in hidden if name not in changes} | {name for name, v in changes.items() if not v}
|
|
129
|
+
# The program's parts in their order, then the ones a plugin added (their names are not in NAMES).
|
|
130
|
+
ordered = (*(name for name in NAMES if name in hidden), *sorted(hidden - set(NAMES)))
|
|
131
|
+
self.runtime.config = replace(self.runtime.config, statusline_hidden=ordered)
|
|
132
|
+
return self.config_file
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""The conversation as the screen shows it: entries, how they are drawn, and what fills them.
|
|
2
|
+
|
|
3
|
+
``entries`` is the data, ``transcript`` the ordered list of it, ``presenters`` how one
|
|
4
|
+
kind of entry becomes rows, ``rendering`` how a tool call is phrased, ``rows`` how a
|
|
5
|
+
row is measured and timed, ``projection`` the agent events that fill the list as a
|
|
6
|
+
request runs, and ``replay`` the stored branch that rebuilds it on a resume.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""What a request did to the files, added up: the data behind the summary at the end of a turn."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from pathlib import PurePath
|
|
7
|
+
|
|
8
|
+
from zettcode.plugin_api.entries import DeleteEntry, Entry, EntryStatus, FileChangeEntry, FileSummary
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def shown_path(path: str, workspace: PurePath | None) -> str:
|
|
12
|
+
"""Return a file as the reader knows it: relative to the workspace when it is inside it.
|
|
13
|
+
|
|
14
|
+
The model names the same file as ``app.py`` in one call and as ``/work/app.py`` in the next, and
|
|
15
|
+
the summary must not count them twice. Only the text is looked at, never the disk.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
path: The path as the call named it.
|
|
19
|
+
workspace: The directory the session works in, or ``None`` to leave every path as it is.
|
|
20
|
+
"""
|
|
21
|
+
if workspace is None or not path:
|
|
22
|
+
return path
|
|
23
|
+
candidate = PurePath(path)
|
|
24
|
+
# Rooted, not only absolute: on Windows ``/work/a.py`` has a root and no drive, which is not
|
|
25
|
+
# "absolute" to pathlib, and it still has to be compared with the workspace it is inside.
|
|
26
|
+
if candidate.is_absolute() or candidate.root:
|
|
27
|
+
try:
|
|
28
|
+
return candidate.relative_to(workspace).as_posix()
|
|
29
|
+
except ValueError:
|
|
30
|
+
return path
|
|
31
|
+
return candidate.as_posix()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def changed_files(entries: Iterable[Entry], workspace: PurePath | None = None) -> tuple[FileSummary, ...]:
|
|
35
|
+
"""Return the files a run of entries changed, each once, in the order they were first touched.
|
|
36
|
+
|
|
37
|
+
Only a call that *succeeded* counts: a failed edit changed nothing, and a skipped one never ran.
|
|
38
|
+
A file changed several times is one row with the totals. A file deleted and then written again is
|
|
39
|
+
a file that exists, counted from the write, and one written and then deleted is deleted.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
entries: The entries of one request, in order; anything that is not a file change is ignored.
|
|
43
|
+
workspace: The directory paths are shown relative to.
|
|
44
|
+
"""
|
|
45
|
+
order: list[str] = []
|
|
46
|
+
totals: dict[str, FileSummary] = {}
|
|
47
|
+
for entry in entries:
|
|
48
|
+
if not isinstance(entry, (FileChangeEntry, DeleteEntry)) or entry.status is not EntryStatus.COMPLETED:
|
|
49
|
+
continue
|
|
50
|
+
if not entry.path:
|
|
51
|
+
continue
|
|
52
|
+
path = shown_path(entry.path, workspace)
|
|
53
|
+
if path not in totals:
|
|
54
|
+
order.append(path)
|
|
55
|
+
totals[path] = FileSummary(path)
|
|
56
|
+
current = totals[path]
|
|
57
|
+
if isinstance(entry, DeleteEntry):
|
|
58
|
+
totals[path] = FileSummary(path, deleted=True)
|
|
59
|
+
continue
|
|
60
|
+
base = FileSummary(path) if current.deleted else current
|
|
61
|
+
totals[path] = FileSummary(
|
|
62
|
+
path, base.added + entry.added, base.removed + getattr(entry, "removed", 0), deleted=False
|
|
63
|
+
)
|
|
64
|
+
return tuple(totals[path] for path in order)
|