froid-loop 0.11.1__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.
- froid_loop/__init__.py +11 -0
- froid_loop/__main__.py +12 -0
- froid_loop/adapters/__init__.py +3 -0
- froid_loop/adapters/base.py +254 -0
- froid_loop/adapters/entrypoints.py +63 -0
- froid_loop/adapters/env_fault.py +290 -0
- froid_loop/adapters/generic.py +2013 -0
- froid_loop/adapters/mock.py +49 -0
- froid_loop/adapters/multiplexer.py +914 -0
- froid_loop/adapters/opencode_http.py +1687 -0
- froid_loop/adapters/profile.py +650 -0
- froid_loop/adapters/psmux_backend.py +1428 -0
- froid_loop/adapters/registry.py +322 -0
- froid_loop/adapters/tmux_backend.py +35 -0
- froid_loop/adapters/tmux_base.py +630 -0
- froid_loop/checks.py +187 -0
- froid_loop/cli.py +5041 -0
- froid_loop/data/__init__.py +0 -0
- froid_loop/data/froid_loop_hook.py +228 -0
- froid_loop/data/froid_loop_probe_hook.py +88 -0
- froid_loop/data/plugins/example/plugin.toml +21 -0
- froid_loop/data/plugins/tea/plugin.toml +184 -0
- froid_loop/data/plugins/tea/tea_plugin.py +258 -0
- froid_loop/data/plugins/unity/plugin.toml +140 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
- froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
- froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
- froid_loop/data/plugins/unity/unity_facts.md +17 -0
- froid_loop/data/plugins/unity/unity_plugin.py +415 -0
- froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
- froid_loop/data/plugins/unity/unity_ready.py +230 -0
- froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
- froid_loop/data/plugins/unity/unity_setup.py +551 -0
- froid_loop/data/plugins/unity/unity_teardown.py +362 -0
- froid_loop/data/profiles/antigravity.toml +52 -0
- froid_loop/data/profiles/claude.toml +85 -0
- froid_loop/data/profiles/codex.toml +22 -0
- froid_loop/data/profiles/copilot.toml +52 -0
- froid_loop/data/profiles/gemini.toml +26 -0
- froid_loop/data/profiles/opencode.toml +54 -0
- froid_loop/data/settings/core.toml +458 -0
- froid_loop/data/skills/README.md +93 -0
- froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
- froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
- froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
- froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
- froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
- froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
- froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
- froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
- froid_loop/decisions.py +202 -0
- froid_loop/deferredwork.py +2282 -0
- froid_loop/devcontract.py +892 -0
- froid_loop/diagnostics.py +1104 -0
- froid_loop/documents.py +532 -0
- froid_loop/engine.py +7732 -0
- froid_loop/envvars.py +111 -0
- froid_loop/escalation.py +225 -0
- froid_loop/events.py +266 -0
- froid_loop/fences.py +103 -0
- froid_loop/froidconfig.py +226 -0
- froid_loop/frontmatter.py +526 -0
- froid_loop/gates.py +133 -0
- froid_loop/install.py +2936 -0
- froid_loop/journal.py +178 -0
- froid_loop/machine.py +148 -0
- froid_loop/model.py +898 -0
- froid_loop/operatoractions.py +474 -0
- froid_loop/platform_util.py +1490 -0
- froid_loop/plugins/__init__.py +64 -0
- froid_loop/plugins/bus.py +259 -0
- froid_loop/plugins/context.py +319 -0
- froid_loop/plugins/loader.py +145 -0
- froid_loop/plugins/manifest.py +279 -0
- froid_loop/plugins/model.py +296 -0
- froid_loop/plugins/registry.py +245 -0
- froid_loop/plugins/trust.py +75 -0
- froid_loop/policy.py +1569 -0
- froid_loop/probe.py +1044 -0
- froid_loop/process_host.py +408 -0
- froid_loop/recovery_flow.py +1561 -0
- froid_loop/resolve.py +283 -0
- froid_loop/runs.py +4715 -0
- froid_loop/runsetup.py +1293 -0
- froid_loop/sanitize.py +593 -0
- froid_loop/settings_schema.py +276 -0
- froid_loop/signals.py +160 -0
- froid_loop/sprintstatus.py +609 -0
- froid_loop/statemachine.py +57 -0
- froid_loop/stories.py +615 -0
- froid_loop/stories_engine.py +796 -0
- froid_loop/sweep.py +1892 -0
- froid_loop/tokens.py +196 -0
- froid_loop/tui/__init__.py +11 -0
- froid_loop/tui/app.py +1584 -0
- froid_loop/tui/data.py +840 -0
- froid_loop/tui/launch.py +1003 -0
- froid_loop/tui/screens/__init__.py +1 -0
- froid_loop/tui/screens/dashboard.py +1071 -0
- froid_loop/tui/screens/modals.py +943 -0
- froid_loop/tui/screens/settings_screen.py +477 -0
- froid_loop/tui/settings.py +135 -0
- froid_loop/tui/widgets.py +981 -0
- froid_loop/verify.py +4545 -0
- froid_loop/workspace.py +320 -0
- froid_loop/worktree_flow.py +2301 -0
- froid_loop-0.11.1.dist-info/METADATA +728 -0
- froid_loop-0.11.1.dist-info/RECORD +116 -0
- froid_loop-0.11.1.dist-info/WHEEL +4 -0
- froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
- froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Settings editor for .froid-loop/policy.toml.
|
|
2
|
+
|
|
3
|
+
The form is populated from the raw tomlkit document, so unset keys show
|
|
4
|
+
default placeholders instead of baked-in values, and saving only touches keys
|
|
5
|
+
the user actually changed — tomlkit keeps everything else, comments included,
|
|
6
|
+
byte-identical. Validation is policy.loads() itself via PolicyDoc.validate(),
|
|
7
|
+
plus — on save — each enabled in-process plugin's coupling check (e.g. unity
|
|
8
|
+
editor_mode↔scm.isolation), the same check the engine runs at startup; errors
|
|
9
|
+
block the save and land in the strip above the buttons. A dedicated Plugins
|
|
10
|
+
section is a roster of discovered plugins with enable toggles (their name in
|
|
11
|
+
[plugins] enabled); a plugin's settings appear as a sub-collapsible only once it
|
|
12
|
+
is enabled. Cleared fields
|
|
13
|
+
delete their keys (unset = default / inherit); the adapter extra_args fields
|
|
14
|
+
distinguish "use profile defaults" (override off → key absent) from "replace
|
|
15
|
+
with this list" (override on, possibly empty — None ≠ []).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import shlex
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from rich.text import Text
|
|
24
|
+
from textual import events
|
|
25
|
+
from textual.app import ComposeResult
|
|
26
|
+
from textual.binding import Binding
|
|
27
|
+
from textual.containers import Horizontal, VerticalScroll
|
|
28
|
+
from textual.screen import Screen
|
|
29
|
+
from textual.validation import Number
|
|
30
|
+
from textual.widget import Widget
|
|
31
|
+
from textual.widgets import (
|
|
32
|
+
Button,
|
|
33
|
+
Collapsible,
|
|
34
|
+
Footer,
|
|
35
|
+
Header,
|
|
36
|
+
Input,
|
|
37
|
+
Label,
|
|
38
|
+
Select,
|
|
39
|
+
Static,
|
|
40
|
+
Switch,
|
|
41
|
+
TextArea,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
from ... import policy as policy_mod
|
|
45
|
+
from ...policy import POLICY_FILE
|
|
46
|
+
from ...settings_schema import PluginInfo, SettingSpec, build_registry
|
|
47
|
+
from ..settings import PolicyDoc
|
|
48
|
+
|
|
49
|
+
# collect() sentinel for a field whose widget holds an unusable value
|
|
50
|
+
_INVALID = object()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SettingsScreen(Screen[None]):
|
|
54
|
+
DEFAULT_CSS = """
|
|
55
|
+
SettingsScreen #form {
|
|
56
|
+
padding: 0 2;
|
|
57
|
+
}
|
|
58
|
+
SettingsScreen #note {
|
|
59
|
+
padding: 1 1 0 1;
|
|
60
|
+
color: $text-muted;
|
|
61
|
+
}
|
|
62
|
+
SettingsScreen .field {
|
|
63
|
+
height: auto;
|
|
64
|
+
}
|
|
65
|
+
SettingsScreen .fieldname {
|
|
66
|
+
width: 30;
|
|
67
|
+
padding: 1 1;
|
|
68
|
+
color: $text-muted;
|
|
69
|
+
}
|
|
70
|
+
SettingsScreen .fielddesc {
|
|
71
|
+
padding: 0 1 1 32;
|
|
72
|
+
color: $text-muted;
|
|
73
|
+
text-style: italic;
|
|
74
|
+
}
|
|
75
|
+
SettingsScreen .field Input, SettingsScreen .field Select {
|
|
76
|
+
width: 1fr;
|
|
77
|
+
}
|
|
78
|
+
SettingsScreen .field Switch {
|
|
79
|
+
margin: 0 1;
|
|
80
|
+
}
|
|
81
|
+
SettingsScreen .field TextArea {
|
|
82
|
+
width: 1fr;
|
|
83
|
+
height: 5;
|
|
84
|
+
}
|
|
85
|
+
SettingsScreen .field TextArea.-editing {
|
|
86
|
+
border: tall $accent;
|
|
87
|
+
}
|
|
88
|
+
SettingsScreen .plugin-row {
|
|
89
|
+
height: auto;
|
|
90
|
+
}
|
|
91
|
+
SettingsScreen .plugin-row Switch {
|
|
92
|
+
margin: 0 1;
|
|
93
|
+
}
|
|
94
|
+
SettingsScreen .plugin-always {
|
|
95
|
+
width: 8;
|
|
96
|
+
padding: 1 1;
|
|
97
|
+
color: $text-muted;
|
|
98
|
+
}
|
|
99
|
+
SettingsScreen .plugin-name {
|
|
100
|
+
padding: 1 1;
|
|
101
|
+
}
|
|
102
|
+
SettingsScreen .plugin-divider {
|
|
103
|
+
padding: 1 1 0 1;
|
|
104
|
+
color: $text-muted;
|
|
105
|
+
text-style: italic;
|
|
106
|
+
}
|
|
107
|
+
SettingsScreen .plugin-cfg {
|
|
108
|
+
margin-left: 2;
|
|
109
|
+
}
|
|
110
|
+
SettingsScreen #errors {
|
|
111
|
+
display: none;
|
|
112
|
+
margin-top: 1;
|
|
113
|
+
padding: 0 1;
|
|
114
|
+
background: $error 20%;
|
|
115
|
+
}
|
|
116
|
+
SettingsScreen #errors.-visible {
|
|
117
|
+
display: block;
|
|
118
|
+
}
|
|
119
|
+
SettingsScreen .buttons {
|
|
120
|
+
height: auto;
|
|
121
|
+
align-horizontal: right;
|
|
122
|
+
padding: 1;
|
|
123
|
+
}
|
|
124
|
+
SettingsScreen .buttons Button {
|
|
125
|
+
margin-left: 2;
|
|
126
|
+
}
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
BINDINGS = [
|
|
130
|
+
Binding("escape", "back", "back"),
|
|
131
|
+
Binding("ctrl+s", "save", "save"),
|
|
132
|
+
Binding("ctrl+e", "toggle_all", "expand/collapse all"),
|
|
133
|
+
Binding("up", "nav_prev", "prev", show=False, priority=True),
|
|
134
|
+
Binding("down", "nav_next", "next", show=False, priority=True),
|
|
135
|
+
Binding("enter", "edit_field", "edit", show=False, priority=True),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
def __init__(self, project: Path, doc: PolicyDoc):
|
|
139
|
+
super().__init__()
|
|
140
|
+
self._project = project
|
|
141
|
+
self._path = project / POLICY_FILE
|
|
142
|
+
self._doc = doc
|
|
143
|
+
# The field/section list is generated from the TOML schema registry, not
|
|
144
|
+
# hardcoded: core sections plus a PluginInfo for every discovered plugin
|
|
145
|
+
# (its enable state, whether it is trust-gated, and its settings fields).
|
|
146
|
+
# Enable state comes from the policy under edit; an invalid doc still
|
|
147
|
+
# renders the core schema (on_mount then surfaces the validation error).
|
|
148
|
+
try:
|
|
149
|
+
pol = policy_mod.loads(doc.dumps())
|
|
150
|
+
except policy_mod.PolicyError:
|
|
151
|
+
pol = policy_mod.Policy()
|
|
152
|
+
self._registry = build_registry(project, pol)
|
|
153
|
+
# The TextArea currently in cursor-edit mode (None = navigation mode).
|
|
154
|
+
self._editing: Widget | None = None
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------- compose
|
|
157
|
+
|
|
158
|
+
def compose(self) -> ComposeResult:
|
|
159
|
+
yield Header()
|
|
160
|
+
with VerticalScroll(id="form"):
|
|
161
|
+
yield Static(
|
|
162
|
+
Text(
|
|
163
|
+
f"{self._path}\nrunning engines snapshot policy at start — "
|
|
164
|
+
"changes apply to new runs and resumes",
|
|
165
|
+
style="dim",
|
|
166
|
+
),
|
|
167
|
+
id="note",
|
|
168
|
+
)
|
|
169
|
+
for section in self._registry.sections:
|
|
170
|
+
with Collapsible(title=section.title, collapsed=True):
|
|
171
|
+
for spec in section.fields:
|
|
172
|
+
yield from self._compose_field(spec)
|
|
173
|
+
if self._registry.plugins:
|
|
174
|
+
with Collapsible(title="Plugins", collapsed=True, id="plugins"):
|
|
175
|
+
yield from self._compose_plugins()
|
|
176
|
+
yield Static(id="errors")
|
|
177
|
+
with Horizontal(classes="buttons"):
|
|
178
|
+
yield Button("save", variant="primary", id="save")
|
|
179
|
+
yield Button("cancel", id="cancel")
|
|
180
|
+
yield Footer()
|
|
181
|
+
|
|
182
|
+
def _compose_field(self, spec: SettingSpec) -> ComposeResult:
|
|
183
|
+
raw = self._doc.get(spec.section, spec.key)
|
|
184
|
+
with Horizontal(classes="field"):
|
|
185
|
+
yield Label(spec.label or spec.key, classes="fieldname")
|
|
186
|
+
if spec.kind == "select":
|
|
187
|
+
yield Select(
|
|
188
|
+
[(o, o) for o in spec.options],
|
|
189
|
+
prompt=f"default: {spec.default or 'auto'}",
|
|
190
|
+
value=str(raw) if raw in spec.options else Select.NULL,
|
|
191
|
+
id=spec.widget_id,
|
|
192
|
+
)
|
|
193
|
+
elif spec.kind in ("int", "float"):
|
|
194
|
+
yield Input(
|
|
195
|
+
value=None if raw is None else str(raw),
|
|
196
|
+
placeholder=spec.placeholder or f"default: {spec.default}",
|
|
197
|
+
type="integer" if spec.kind == "int" else "number",
|
|
198
|
+
validators=[Number(minimum=spec.minimum, maximum=spec.maximum)],
|
|
199
|
+
valid_empty=True,
|
|
200
|
+
id=spec.widget_id,
|
|
201
|
+
)
|
|
202
|
+
elif spec.kind == "str":
|
|
203
|
+
yield Input(
|
|
204
|
+
value=None if raw is None else str(raw),
|
|
205
|
+
placeholder=spec.placeholder,
|
|
206
|
+
id=spec.widget_id,
|
|
207
|
+
)
|
|
208
|
+
elif spec.kind == "switch":
|
|
209
|
+
yield Switch(
|
|
210
|
+
value=bool(spec.default) if raw is None else bool(raw),
|
|
211
|
+
id=spec.widget_id,
|
|
212
|
+
)
|
|
213
|
+
elif spec.kind == "lines":
|
|
214
|
+
yield TextArea(
|
|
215
|
+
"\n".join(str(c) for c in raw) if raw else "",
|
|
216
|
+
soft_wrap=False,
|
|
217
|
+
id=spec.widget_id,
|
|
218
|
+
)
|
|
219
|
+
elif spec.kind == "args":
|
|
220
|
+
override = raw is not None
|
|
221
|
+
yield Switch(value=override, id=f"{spec.widget_id}-override", classes="argswitch")
|
|
222
|
+
yield Input(
|
|
223
|
+
value=shlex.join(str(a) for a in raw) if override else None,
|
|
224
|
+
placeholder="profile default flags — toggle to override",
|
|
225
|
+
disabled=not override,
|
|
226
|
+
id=spec.widget_id,
|
|
227
|
+
)
|
|
228
|
+
if spec.description:
|
|
229
|
+
yield Static(spec.description, classes="fielddesc")
|
|
230
|
+
|
|
231
|
+
def _compose_plugins(self) -> ComposeResult:
|
|
232
|
+
"""The Plugins section: a scannable roster of every discovered plugin with
|
|
233
|
+
an enable toggle (trust-gated) or an always-on note (data-only), then —
|
|
234
|
+
below the roster — a settings sub-collapsible per plugin that contributes
|
|
235
|
+
settings. A plugin's settings are revealed only while it is active (data-
|
|
236
|
+
only always; trust-gated once enabled); inactive ones are hidden by
|
|
237
|
+
on_mount and toggled live by on_switch_changed, so the roster stays clean."""
|
|
238
|
+
for info in self._registry.plugins:
|
|
239
|
+
with Horizontal(classes="plugin-row"):
|
|
240
|
+
if info.trust_needed:
|
|
241
|
+
yield Switch(
|
|
242
|
+
value=info.enabled,
|
|
243
|
+
id=f"plugin-enabled-{info.name}",
|
|
244
|
+
classes="plugin-enable",
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
yield Static("on", classes="plugin-always")
|
|
248
|
+
label = f"{info.name} — {info.description}" if info.description else info.name
|
|
249
|
+
yield Static(label, classes="plugin-name")
|
|
250
|
+
configurable = [p for p in self._registry.plugins if p.fields]
|
|
251
|
+
if configurable:
|
|
252
|
+
yield Static("configure", classes="plugin-divider")
|
|
253
|
+
for info in configurable:
|
|
254
|
+
title = f"{info.name} — {info.description}" if info.description else info.name
|
|
255
|
+
with Collapsible(
|
|
256
|
+
title=title, collapsed=True, id=f"plugin-cfg-{info.name}", classes="plugin-cfg"
|
|
257
|
+
):
|
|
258
|
+
for spec in info.fields:
|
|
259
|
+
yield from self._compose_field(spec)
|
|
260
|
+
|
|
261
|
+
def _plugin_active(self, info: PluginInfo) -> bool:
|
|
262
|
+
"""A data-only plugin is always active; a trust-gated one is active only
|
|
263
|
+
while its enable toggle is on (its module is what trust gates)."""
|
|
264
|
+
if not info.trust_needed:
|
|
265
|
+
return True
|
|
266
|
+
return self.query_one(f"#plugin-enabled-{info.name}", Switch).value
|
|
267
|
+
|
|
268
|
+
def on_mount(self) -> None:
|
|
269
|
+
# Hide the settings of every plugin that isn't active yet, so a fresh open
|
|
270
|
+
# shows only the roster plus the active plugins' settings.
|
|
271
|
+
for info in self._registry.plugins:
|
|
272
|
+
if info.fields and not self._plugin_active(info):
|
|
273
|
+
self.query_one(f"#plugin-cfg-{info.name}", Collapsible).display = False
|
|
274
|
+
self._show_errors(self._doc.validate(self._registry.plugin_schemas))
|
|
275
|
+
|
|
276
|
+
# ------------------------------------------------------------ keyboard
|
|
277
|
+
|
|
278
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
279
|
+
# While editing a TextArea, let its own up/down move the cursor instead
|
|
280
|
+
# of moving focus, and let escape exit edit mode rather than the screen.
|
|
281
|
+
if action in ("nav_next", "nav_prev"):
|
|
282
|
+
if self._editing is not None and self.focused is self._editing:
|
|
283
|
+
return False # TextArea cursor owns up/down in edit mode
|
|
284
|
+
if any(select.expanded for select in self.query(Select)):
|
|
285
|
+
return False # an open dropdown owns up/down to pick an option
|
|
286
|
+
return True
|
|
287
|
+
if action == "edit_field":
|
|
288
|
+
# Only intercept Enter to start cursor-editing a TextArea; for every
|
|
289
|
+
# other widget Enter keeps its native meaning (open Select, toggle
|
|
290
|
+
# Switch, submit Input) and for a TextArea already editing it inserts
|
|
291
|
+
# a newline.
|
|
292
|
+
return isinstance(self.focused, TextArea) and self._editing is None
|
|
293
|
+
if action == "back":
|
|
294
|
+
return self._editing is None
|
|
295
|
+
return True
|
|
296
|
+
|
|
297
|
+
def action_nav_next(self) -> None:
|
|
298
|
+
self._exit_edit()
|
|
299
|
+
self.focus_next()
|
|
300
|
+
|
|
301
|
+
def action_nav_prev(self) -> None:
|
|
302
|
+
self._exit_edit()
|
|
303
|
+
self.focus_previous()
|
|
304
|
+
|
|
305
|
+
def action_edit_field(self) -> None:
|
|
306
|
+
area = self.focused
|
|
307
|
+
if isinstance(area, TextArea):
|
|
308
|
+
self._editing = area
|
|
309
|
+
area.add_class("-editing")
|
|
310
|
+
|
|
311
|
+
def action_toggle_all(self) -> None:
|
|
312
|
+
self._exit_edit()
|
|
313
|
+
sections = list(self.query(Collapsible))
|
|
314
|
+
target = any(not c.collapsed for c in sections) # something open -> collapse all
|
|
315
|
+
for c in sections:
|
|
316
|
+
c.collapsed = target
|
|
317
|
+
|
|
318
|
+
def on_key(self, event: events.Key) -> None:
|
|
319
|
+
if event.key == "escape" and self._editing is not None:
|
|
320
|
+
self._exit_edit()
|
|
321
|
+
event.stop()
|
|
322
|
+
|
|
323
|
+
def _exit_edit(self) -> None:
|
|
324
|
+
if self._editing is not None:
|
|
325
|
+
self._editing.remove_class("-editing")
|
|
326
|
+
self._editing = None
|
|
327
|
+
|
|
328
|
+
# -------------------------------------------------------------- events
|
|
329
|
+
|
|
330
|
+
def on_switch_changed(self, event: Switch.Changed) -> None:
|
|
331
|
+
if event.switch.has_class("argswitch") and event.switch.id:
|
|
332
|
+
target = event.switch.id.removesuffix("-override")
|
|
333
|
+
self.query_one(f"#{target}", Input).disabled = not event.value
|
|
334
|
+
elif event.switch.has_class("plugin-enable") and event.switch.id:
|
|
335
|
+
# Reveal/hide the plugin's settings live (a plugin with no settings has
|
|
336
|
+
# no config collapsible). Expand on reveal so they're immediately seen.
|
|
337
|
+
name = event.switch.id.removeprefix("plugin-enabled-")
|
|
338
|
+
if self.query(f"#plugin-cfg-{name}"):
|
|
339
|
+
cfg = self.query_one(f"#plugin-cfg-{name}", Collapsible)
|
|
340
|
+
cfg.display = event.value
|
|
341
|
+
if event.value:
|
|
342
|
+
cfg.collapsed = False
|
|
343
|
+
|
|
344
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
345
|
+
if event.button.id == "save":
|
|
346
|
+
self.action_save()
|
|
347
|
+
else:
|
|
348
|
+
self.action_back()
|
|
349
|
+
|
|
350
|
+
def action_back(self) -> None:
|
|
351
|
+
self.app.pop_screen()
|
|
352
|
+
|
|
353
|
+
# ---------------------------------------------------------------- save
|
|
354
|
+
|
|
355
|
+
def _collect(self, spec: SettingSpec, problems: list[str]) -> object:
|
|
356
|
+
"""Widget state -> raw policy value; None means unset (delete key)."""
|
|
357
|
+
ident = f"{spec.section}.{spec.key}"
|
|
358
|
+
if spec.kind == "select":
|
|
359
|
+
select = self.query_one(f"#{spec.widget_id}", Select)
|
|
360
|
+
return None if select.is_blank() else str(select.value)
|
|
361
|
+
if spec.kind in ("int", "float"):
|
|
362
|
+
box = self.query_one(f"#{spec.widget_id}", Input)
|
|
363
|
+
value = box.value.strip()
|
|
364
|
+
if not value:
|
|
365
|
+
return None
|
|
366
|
+
result = box.validate(value)
|
|
367
|
+
if result is not None and not result.is_valid:
|
|
368
|
+
problems.append(f"{ident}: {'; '.join(result.failure_descriptions)}")
|
|
369
|
+
return _INVALID
|
|
370
|
+
try:
|
|
371
|
+
return int(value) if spec.kind == "int" else float(value)
|
|
372
|
+
except ValueError:
|
|
373
|
+
problems.append(f"{ident}: not a valid number")
|
|
374
|
+
return _INVALID
|
|
375
|
+
if spec.kind == "str":
|
|
376
|
+
return self.query_one(f"#{spec.widget_id}", Input).value.strip() or None
|
|
377
|
+
if spec.kind == "switch":
|
|
378
|
+
return self.query_one(f"#{spec.widget_id}", Switch).value
|
|
379
|
+
if spec.kind == "lines":
|
|
380
|
+
text = self.query_one(f"#{spec.widget_id}", TextArea).text
|
|
381
|
+
return [line.strip() for line in text.splitlines() if line.strip()]
|
|
382
|
+
if spec.kind == "args":
|
|
383
|
+
if not self.query_one(f"#{spec.widget_id}-override", Switch).value:
|
|
384
|
+
return None
|
|
385
|
+
try:
|
|
386
|
+
return shlex.split(self.query_one(f"#{spec.widget_id}", Input).value)
|
|
387
|
+
except ValueError as e:
|
|
388
|
+
problems.append(f"{ident}: {e}")
|
|
389
|
+
return _INVALID
|
|
390
|
+
raise AssertionError(spec.kind)
|
|
391
|
+
|
|
392
|
+
@staticmethod
|
|
393
|
+
def _changed(spec: SettingSpec, current: object, desired: object) -> bool:
|
|
394
|
+
if desired is None:
|
|
395
|
+
# adapter.model = "" means "CLI default" just like an absent key;
|
|
396
|
+
# don't rewrite the template line for an untouched empty input
|
|
397
|
+
return current is not None and not (spec.kind == "str" and current == "")
|
|
398
|
+
if current is None:
|
|
399
|
+
# an unset key already behaves like the default — don't bake it in
|
|
400
|
+
if spec.kind == "switch" and desired == spec.default:
|
|
401
|
+
return False
|
|
402
|
+
if spec.kind == "lines" and desired == []:
|
|
403
|
+
return False
|
|
404
|
+
return True
|
|
405
|
+
if isinstance(desired, list):
|
|
406
|
+
# `current` pairs with a list `desired` here; its static type is the
|
|
407
|
+
# heterogeneous `object` setting supertype, but at runtime it is a list.
|
|
408
|
+
return [
|
|
409
|
+
str(item) for item in current # pyright: ignore[reportGeneralTypeIssues]
|
|
410
|
+
] != desired
|
|
411
|
+
return current != desired
|
|
412
|
+
|
|
413
|
+
def _save_enabled(self) -> None:
|
|
414
|
+
"""Reconcile [plugins] enabled with the enable toggles. Only trust-gated
|
|
415
|
+
plugins carry a toggle; any other name already in the list (a data-only
|
|
416
|
+
plugin, or a plugin this checkout can't discover) is preserved so the
|
|
417
|
+
save never silently drops trust the UI doesn't manage. An absent key
|
|
418
|
+
stays absent when nothing is enabled (unset = nothing trusted)."""
|
|
419
|
+
toggles = {p.name for p in self._registry.plugins if p.trust_needed}
|
|
420
|
+
on = [
|
|
421
|
+
p.name
|
|
422
|
+
for p in self._registry.plugins
|
|
423
|
+
if p.trust_needed and self.query_one(f"#plugin-enabled-{p.name}", Switch).value
|
|
424
|
+
]
|
|
425
|
+
current = self._doc.get("plugins", "enabled")
|
|
426
|
+
current_names = None if current is None else [str(x) for x in current]
|
|
427
|
+
preserved = [n for n in (current_names or []) if n not in toggles]
|
|
428
|
+
desired = preserved + on
|
|
429
|
+
if current_names is None:
|
|
430
|
+
if desired:
|
|
431
|
+
self._doc.set("plugins", "enabled", desired)
|
|
432
|
+
elif current_names != desired:
|
|
433
|
+
self._doc.set("plugins", "enabled", desired)
|
|
434
|
+
|
|
435
|
+
def _save_specs(self) -> list[SettingSpec]:
|
|
436
|
+
"""Core fields plus only the *active* plugins' fields — a disabled plugin's
|
|
437
|
+
settings are never collected, so its defaults are not baked in and any
|
|
438
|
+
existing [plugins.<name>] table is left untouched (preserved as data)."""
|
|
439
|
+
specs = [f for s in self._registry.sections for f in s.fields]
|
|
440
|
+
for info in self._registry.plugins:
|
|
441
|
+
if self._plugin_active(info):
|
|
442
|
+
specs.extend(info.fields)
|
|
443
|
+
return specs
|
|
444
|
+
|
|
445
|
+
def action_save(self) -> None:
|
|
446
|
+
problems: list[str] = []
|
|
447
|
+
desired = {spec: self._collect(spec, problems) for spec in self._save_specs()}
|
|
448
|
+
if problems:
|
|
449
|
+
self._show_errors("\n".join(problems))
|
|
450
|
+
return
|
|
451
|
+
for spec, value in desired.items():
|
|
452
|
+
if self._changed(spec, self._doc.get(spec.section, spec.key), value):
|
|
453
|
+
self._doc.set(spec.section, spec.key, value)
|
|
454
|
+
self._save_enabled()
|
|
455
|
+
# Pass the project so the round-trip also runs every enabled in-process
|
|
456
|
+
# plugin's coupling check (e.g. unity editor_mode↔scm.isolation), blocking
|
|
457
|
+
# an incompatible save the same way the engine fails fast at startup.
|
|
458
|
+
error = self._doc.validate(self._registry.plugin_schemas, project=self._project)
|
|
459
|
+
if error:
|
|
460
|
+
self._show_errors(error)
|
|
461
|
+
return
|
|
462
|
+
try:
|
|
463
|
+
self._doc.save(self._path, confine_root=self._project)
|
|
464
|
+
except OSError as e:
|
|
465
|
+
self._show_errors(f"save failed: {e}")
|
|
466
|
+
return
|
|
467
|
+
self.app.notify(f"policy saved: {self._path}")
|
|
468
|
+
self.app.pop_screen()
|
|
469
|
+
|
|
470
|
+
def _show_errors(self, message: str | None) -> None:
|
|
471
|
+
strip = self.query_one("#errors", Static)
|
|
472
|
+
if message:
|
|
473
|
+
strip.update(Text(f"✗ {message}"))
|
|
474
|
+
strip.add_class("-visible")
|
|
475
|
+
else:
|
|
476
|
+
strip.update("")
|
|
477
|
+
strip.remove_class("-visible")
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""tomlkit-backed editing model for .froid-loop/policy.toml.
|
|
2
|
+
|
|
3
|
+
Zero textual imports: this is the testable core of the settings editor. The
|
|
4
|
+
form never re-implements policy rules — validate() round-trips the document
|
|
5
|
+
through policy.loads(), so policy.py stays the single source of truth.
|
|
6
|
+
tomlkit preserves comments and key order; a missing file starts from
|
|
7
|
+
POLICY_TEMPLATE so the first save carries the full inline documentation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import tomlkit
|
|
16
|
+
|
|
17
|
+
from .. import policy as policy_mod
|
|
18
|
+
from ..platform_util import atomic_write_text_confined
|
|
19
|
+
|
|
20
|
+
# STAGES now lives in policy.py (#679) so the settings schema can reach it
|
|
21
|
+
# without importing this tomlkit-backed module. Re-exported here because
|
|
22
|
+
# `settings.STAGES` is this module's historical name for it; the pin is
|
|
23
|
+
# load-bearing — without it ruff F401 autofix deletes the re-export.
|
|
24
|
+
from ..policy import STAGES # noqa: F401 — re-export
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PolicyDoc:
|
|
28
|
+
"""A policy.toml document under edit. Sections may be dotted
|
|
29
|
+
("adapter.dev") to address the per-stage override tables."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, doc: tomlkit.TOMLDocument):
|
|
32
|
+
self._doc = doc
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def load(cls, path: Path, *, template_when_missing: bool = True) -> PolicyDoc:
|
|
36
|
+
"""``template_when_missing=False`` starts a missing file from an empty
|
|
37
|
+
document instead of POLICY_TEMPLATE — for callers that write a single
|
|
38
|
+
section (e.g. dashboard geometry) and must not materialise every
|
|
39
|
+
default setting into a fresh policy.toml."""
|
|
40
|
+
if path.is_file():
|
|
41
|
+
text = path.read_text(encoding="utf-8")
|
|
42
|
+
else:
|
|
43
|
+
text = policy_mod.POLICY_TEMPLATE if template_when_missing else ""
|
|
44
|
+
return cls(tomlkit.parse(text))
|
|
45
|
+
|
|
46
|
+
def _table(self, section: str, create: bool) -> Any | None:
|
|
47
|
+
node: Any = self._doc
|
|
48
|
+
for part in section.split("."):
|
|
49
|
+
if part not in node:
|
|
50
|
+
if not create:
|
|
51
|
+
return None
|
|
52
|
+
node[part] = tomlkit.table()
|
|
53
|
+
node = node[part]
|
|
54
|
+
return node
|
|
55
|
+
|
|
56
|
+
def get(self, section: str, key: str) -> Any | None:
|
|
57
|
+
"""Raw value from the document, or None when the key is unset."""
|
|
58
|
+
table = self._table(section, create=False)
|
|
59
|
+
if table is None or key not in table:
|
|
60
|
+
return None
|
|
61
|
+
return table[key]
|
|
62
|
+
|
|
63
|
+
def set(self, section: str, key: str, value: Any | None) -> None:
|
|
64
|
+
"""Set a key; None deletes it. A per-stage adapter table emptied by a
|
|
65
|
+
delete is dropped entirely, restoring 'unset = inherit'."""
|
|
66
|
+
if value is None:
|
|
67
|
+
table = self._table(section, create=False)
|
|
68
|
+
if table is not None and key in table:
|
|
69
|
+
del table[key]
|
|
70
|
+
parent, _, stage = section.partition(".")
|
|
71
|
+
if stage and table is not None and len(table) == 0:
|
|
72
|
+
del self._doc[parent][stage]
|
|
73
|
+
return
|
|
74
|
+
# _table(create=True) never returns None (it creates missing tables), but
|
|
75
|
+
# its return type is Any | None.
|
|
76
|
+
self._table(section, create=True)[key] = value # pyright: ignore[reportOptionalSubscript]
|
|
77
|
+
|
|
78
|
+
def validate(
|
|
79
|
+
self,
|
|
80
|
+
plugin_schemas: dict[str, Any] | None = None,
|
|
81
|
+
project: Path | None = None,
|
|
82
|
+
) -> str | None:
|
|
83
|
+
"""Authoritative validation via policy.loads(); None when valid.
|
|
84
|
+
|
|
85
|
+
``plugin_schemas`` (plugin name -> setting specs) lets the round-trip
|
|
86
|
+
also type-check any [plugins.<name>] tables the screen rendered. When
|
|
87
|
+
``project`` is given, every enabled in-process plugin additionally
|
|
88
|
+
self-validates against the parsed policy (Plugin.validate) — the same
|
|
89
|
+
coupling check the engine runs at startup (e.g. the Unity plugin's
|
|
90
|
+
editor_mode↔scm.isolation rule) — so an incompatible combination is
|
|
91
|
+
caught at save time rather than mid-run. Building the registry imports
|
|
92
|
+
only the plugins already trusted in [plugins] enabled."""
|
|
93
|
+
try:
|
|
94
|
+
pol = policy_mod.loads(self.dumps(), plugin_schemas=plugin_schemas)
|
|
95
|
+
except policy_mod.PolicyError as e:
|
|
96
|
+
return str(e)
|
|
97
|
+
if project is not None:
|
|
98
|
+
from ..plugins.model import PluginError
|
|
99
|
+
from ..plugins.registry import PluginRegistry
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
PluginRegistry.build(project, pol).validate(pol)
|
|
103
|
+
except (policy_mod.PolicyError, PluginError) as e:
|
|
104
|
+
return str(e)
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
def dumps(self) -> str:
|
|
108
|
+
return tomlkit.dumps(self._doc)
|
|
109
|
+
|
|
110
|
+
def save(self, path: Path, *, confine_root: Path) -> None:
|
|
111
|
+
"""#363: via the helper, which removes its temp on any raise. The
|
|
112
|
+
hand-rolled temp was the fixed name `.froid-loop/policy.toml.tmp` —
|
|
113
|
+
gitignored by nothing, and byte-identical to the one
|
|
114
|
+
`policy.write_mux_backend` built, so the settings editor and the mux
|
|
115
|
+
writer raced on one name. Raises rather than degrades: the screen catches
|
|
116
|
+
OSError to show "save failed", which requires the raise to reach it —
|
|
117
|
+
and `UnconfinedWriteError` is an OSError, so a refusal arrives there too.
|
|
118
|
+
|
|
119
|
+
`confine_root` is a REQUIRED keyword rather than a defaulted one, and
|
|
120
|
+
this document does not remember one: a `PolicyDoc` is parsed text with no
|
|
121
|
+
path of its own — `load` and `save` are each handed one per call — so
|
|
122
|
+
there is nothing here to derive a root from. Requiring it makes a caller
|
|
123
|
+
that has not decided what tree this policy file belongs to a type error
|
|
124
|
+
rather than an unconfined write (#593). `runsetup` documents
|
|
125
|
+
`.froid-loop/policy.toml` as a path a driven session may write, so the
|
|
126
|
+
directories above it are exactly the ones worth anchoring.
|
|
127
|
+
|
|
128
|
+
`require_writable_target=True` (#597): policy.toml is hand-edited config,
|
|
129
|
+
and an operator who marks it read-only gets the `PermissionError` a bare
|
|
130
|
+
`Path.write_text` raised before this write went atomic — surfaced by the
|
|
131
|
+
same "save failed" the screen already shows."""
|
|
132
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
atomic_write_text_confined(
|
|
134
|
+
path, self.dumps(), confine_root=confine_root, require_writable_target=True
|
|
135
|
+
)
|