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,943 @@
|
|
|
1
|
+
"""Run-control modal dialogs.
|
|
2
|
+
|
|
3
|
+
Results come back through ModalScreen.dismiss(): a dict of options from the
|
|
4
|
+
start modals, True from confirmations, None on cancel/escape. Pause reasons
|
|
5
|
+
and captured command output are arbitrary engine text and are rendered as
|
|
6
|
+
rich Text, never markup.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
from textual.app import ComposeResult
|
|
16
|
+
from textual.binding import Binding
|
|
17
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
18
|
+
from textual.css.query import NoMatches
|
|
19
|
+
from textual.screen import ModalScreen
|
|
20
|
+
from textual.widgets import Button, Checkbox, Input, Label, Select, Static
|
|
21
|
+
|
|
22
|
+
from ... import stories
|
|
23
|
+
from ...model import RunState
|
|
24
|
+
from .. import data, widgets
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _int_or_none(value: str) -> int | None:
|
|
28
|
+
value = value.strip()
|
|
29
|
+
return int(value) if value else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BaseDialog(ModalScreen):
|
|
33
|
+
"""Shared chrome: centered bordered box, escape cancels."""
|
|
34
|
+
|
|
35
|
+
DEFAULT_CSS = """
|
|
36
|
+
BaseDialog {
|
|
37
|
+
align: center middle;
|
|
38
|
+
}
|
|
39
|
+
BaseDialog #dialog {
|
|
40
|
+
width: 64;
|
|
41
|
+
/* the clamp lives on the SHARED rule on purpose: the five subclasses that
|
|
42
|
+
widen the dialog (Decision 86, Escalation 90, DeferredEntry/Validate/
|
|
43
|
+
TextOutput 96, SpecReview 100) override only `width`, never `max-width`,
|
|
44
|
+
so this one declaration covers all ten and a subclass cannot silently
|
|
45
|
+
opt out of it. Without it a fixed column count is laid out wider than a
|
|
46
|
+
narrow terminal and the right-hand side — i.e. the docked button row,
|
|
47
|
+
which is align-horizontal: right — is clipped off-screen (#281). */
|
|
48
|
+
max-width: 100%;
|
|
49
|
+
/* height is intentionally auto (not a definite %). Two-tier by design:
|
|
50
|
+
list-heavy modals (Decision/Escalation/StartRun/...) override #dialog
|
|
51
|
+
with a definite height + a 1fr body; the bounded modals (Confirm/
|
|
52
|
+
StartSweep/StoryCheckpoint) keep this auto so short content stays
|
|
53
|
+
compact and only long bodies grow to the #body max-height cap. Giving
|
|
54
|
+
#dialog a definite % here would balloon a one-line confirm to ~90% of
|
|
55
|
+
the screen — see test_short_confirm_modal_stays_compact. */
|
|
56
|
+
height: auto;
|
|
57
|
+
max-height: 90%;
|
|
58
|
+
padding: 1 2;
|
|
59
|
+
background: $surface;
|
|
60
|
+
border: thick $primary-darken-2;
|
|
61
|
+
}
|
|
62
|
+
BaseDialog .title {
|
|
63
|
+
text-style: bold;
|
|
64
|
+
margin-bottom: 1;
|
|
65
|
+
}
|
|
66
|
+
BaseDialog .buttons {
|
|
67
|
+
height: auto;
|
|
68
|
+
align-horizontal: right;
|
|
69
|
+
margin-top: 1;
|
|
70
|
+
}
|
|
71
|
+
BaseDialog .buttons Button {
|
|
72
|
+
margin-left: 2;
|
|
73
|
+
}
|
|
74
|
+
/* Clamping #dialog is not enough on its own: Textual's Button is
|
|
75
|
+
`width: auto; min-width: 16` and the rule above adds a 2-column margin per
|
|
76
|
+
button, so a three-button row demands 3*16 + 3*2 = 54 columns while a
|
|
77
|
+
dialog clamped to a 50-column terminal only has 50 - 2 (thick border)
|
|
78
|
+
- 4 (padding `1 2`) = 44 columns of content — the row still overflows and
|
|
79
|
+
the right-most button is clipped. Dropping the floor lets each button size
|
|
80
|
+
to its own label instead. Scoped to `-narrow` so terminals 60 columns and
|
|
81
|
+
wider keep today's button sizing byte-for-byte. */
|
|
82
|
+
BaseDialog.-narrow .buttons Button {
|
|
83
|
+
min-width: 4;
|
|
84
|
+
margin-left: 1;
|
|
85
|
+
}
|
|
86
|
+
/* The vertical twin. Before any body content a dialog spends 10 rows of
|
|
87
|
+
chrome: 2 border (1 per side — every Textual border STYLE is exactly one
|
|
88
|
+
cell, so `thick` -> `round` would save nothing), 2 padding, 1 title,
|
|
89
|
+
1 title margin-bottom, 1 .buttons margin-top and 3 for the button row
|
|
90
|
+
(Textual's Button is `border: tall`, so a one-line label is 3 rows).
|
|
91
|
+
`max-height: 90%` then turns those 10 rows into a per-modal terminal-height
|
|
92
|
+
floor of 12-14. Under `-short` the chrome collapses to 4 rows — padding
|
|
93
|
+
0 1, no title margin, no button margin, a 1-row borderless button — and
|
|
94
|
+
max-height goes to 100% so the dialog may use the last row. The `#dialog`
|
|
95
|
+
BORDER IS DELIBERATELY KEPT: it is the only thing separating the dialog
|
|
96
|
+
from the dashboard rendered behind a ModalScreen, and the 2 rows it costs
|
|
97
|
+
are not needed to clear the floor. Nothing here sets a definite `height`
|
|
98
|
+
— that would balloon the bounded tier, see
|
|
99
|
+
test_short_confirm_modal_stays_compact — and nothing here touches `width`,
|
|
100
|
+
which is the `-narrow` axis above. */
|
|
101
|
+
BaseDialog.-short #dialog {
|
|
102
|
+
max-height: 100%;
|
|
103
|
+
padding: 0 1;
|
|
104
|
+
}
|
|
105
|
+
BaseDialog.-short .title {
|
|
106
|
+
margin-bottom: 0;
|
|
107
|
+
}
|
|
108
|
+
BaseDialog.-short .buttons {
|
|
109
|
+
margin-top: 0;
|
|
110
|
+
}
|
|
111
|
+
BaseDialog.-short .buttons Button {
|
|
112
|
+
height: 1;
|
|
113
|
+
border: none;
|
|
114
|
+
}
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
# Textual applies the matching breakpoint class to the Screen itself on
|
|
118
|
+
# resize, and BaseDialog IS a ModalScreen — so `-narrow` lands on the dialog
|
|
119
|
+
# screen and CSS can select `BaseDialog.-narrow ...`. `-narrow` therefore
|
|
120
|
+
# means "the terminal is under 60 columns". 60 is where an un-narrowed
|
|
121
|
+
# three-button row (3 * Textual's `min-width: 16` + 3 * `margin-left: 2`
|
|
122
|
+
# = 54) still fits a clamped dialog's content region (60 - 2 border
|
|
123
|
+
# - 4 padding = 54). Declaring this here scopes it to dialogs: the App and
|
|
124
|
+
# DashboardScreen leave their breakpoints at the default, so the dashboard
|
|
125
|
+
# is unaffected.
|
|
126
|
+
HORIZONTAL_BREAKPOINTS = [(0, "-narrow"), (60, "-wide")]
|
|
127
|
+
|
|
128
|
+
# Same mechanism on the other axis: `-short` means "the terminal is under 20
|
|
129
|
+
# rows". 20 is chosen to sit clear of the tallest measured pre-fix frame
|
|
130
|
+
# floor — the height at which every docked control of a modal is fully
|
|
131
|
+
# on-screen: ConfirmModal 12, StartSweep 13, StoryCheckpoint 13, and 14 for
|
|
132
|
+
# ConfirmModal WITH a warning (the ConfirmResumeModal case, whose
|
|
133
|
+
# double-drive warning gates a destructive confirm and is docked outside
|
|
134
|
+
# #body). The compact layout has to engage before anything clips, not at the
|
|
135
|
+
# moment it does, so the threshold is above 14 rather than on it. It is also
|
|
136
|
+
# below the 24 rows of a default terminal, so an ordinary window still gets
|
|
137
|
+
# the full chrome. Every figure above is a CHROME measurement, taken with
|
|
138
|
+
# short titles: a title, header, warning or path is docked outside the
|
|
139
|
+
# scrolling body, so a long enough one wraps and costs rows this layout
|
|
140
|
+
# cannot reclaim — the body is already at its 1-row minimum. Nothing bounds
|
|
141
|
+
# that caller-supplied text, so a long enough value clips the docked controls
|
|
142
|
+
# at ANY fixed size and those dialogs have no floor to state. That is why
|
|
143
|
+
# docs/tui-guide.md gives its figures as sizes measured to be sufficient for
|
|
144
|
+
# the content it exercised rather than as a minimum — 80x24 included
|
|
145
|
+
# (#628, #629).
|
|
146
|
+
VERTICAL_BREAKPOINTS = [(0, "-short"), (20, "-tall")]
|
|
147
|
+
|
|
148
|
+
BINDINGS = [Binding("escape", "cancel", "cancel")]
|
|
149
|
+
|
|
150
|
+
def action_cancel(self) -> None:
|
|
151
|
+
self.dismiss(None)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class StartRunModal(BaseDialog):
|
|
155
|
+
"""Options for `froid-loop run`.
|
|
156
|
+
|
|
157
|
+
Dual-flow: a source select (prefilled from ``[stories]``) picks sprint mode
|
|
158
|
+
vs. stories mode; the spec-folder input feeds a live schedule preview that
|
|
159
|
+
validates ``stories.yaml`` (parses + rules pass, SPEC.md present) and lists
|
|
160
|
+
the linear schedule with independent spec/done checkpoint markers — the same
|
|
161
|
+
projection `run --dry-run` prints. Returns
|
|
162
|
+
``{source, spec_folder, epic, story, max_stories, dry_run}``."""
|
|
163
|
+
|
|
164
|
+
DEFAULT_CSS = """
|
|
165
|
+
StartRunModal #dialog {
|
|
166
|
+
height: 90%;
|
|
167
|
+
}
|
|
168
|
+
StartRunModal #fields {
|
|
169
|
+
height: 1fr;
|
|
170
|
+
}
|
|
171
|
+
StartRunModal #preview {
|
|
172
|
+
height: auto;
|
|
173
|
+
max-height: 14;
|
|
174
|
+
border: solid $primary-darken-2;
|
|
175
|
+
padding: 0 1;
|
|
176
|
+
margin-top: 1;
|
|
177
|
+
}
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(
|
|
181
|
+
self,
|
|
182
|
+
project: Path,
|
|
183
|
+
*,
|
|
184
|
+
default_source: str = "sprint-status",
|
|
185
|
+
default_spec_folder: str = "",
|
|
186
|
+
):
|
|
187
|
+
super().__init__()
|
|
188
|
+
self._project = project
|
|
189
|
+
self._default_source = default_source
|
|
190
|
+
self._default_spec_folder = default_spec_folder
|
|
191
|
+
|
|
192
|
+
def compose(self) -> ComposeResult:
|
|
193
|
+
with Vertical(id="dialog"):
|
|
194
|
+
yield Label("start run", classes="title")
|
|
195
|
+
# fields scroll; the button row is docked below so it stays clickable
|
|
196
|
+
# in any terminal size (the modal is tall in stories mode).
|
|
197
|
+
with VerticalScroll(id="fields"):
|
|
198
|
+
yield Select(
|
|
199
|
+
[
|
|
200
|
+
("sprint mode — sprint-status.yaml", "sprint-status"),
|
|
201
|
+
("stories mode — folder+id dispatch", "stories"),
|
|
202
|
+
],
|
|
203
|
+
value=self._default_source,
|
|
204
|
+
allow_blank=False,
|
|
205
|
+
id="source",
|
|
206
|
+
)
|
|
207
|
+
yield Input(
|
|
208
|
+
value=self._default_spec_folder,
|
|
209
|
+
placeholder="stories mode: spec folder holding stories.yaml + SPEC.md",
|
|
210
|
+
id="spec-folder",
|
|
211
|
+
)
|
|
212
|
+
yield Input(
|
|
213
|
+
placeholder="epic — blank for all (sprint mode)",
|
|
214
|
+
type="integer",
|
|
215
|
+
valid_empty=True,
|
|
216
|
+
id="epic",
|
|
217
|
+
)
|
|
218
|
+
yield Input(
|
|
219
|
+
placeholder="story — 3-1 / slug / full key (sprint), or story id (stories)",
|
|
220
|
+
id="story",
|
|
221
|
+
)
|
|
222
|
+
yield Input(
|
|
223
|
+
placeholder="max stories — blank for no limit",
|
|
224
|
+
type="integer",
|
|
225
|
+
valid_empty=True,
|
|
226
|
+
id="max-stories",
|
|
227
|
+
)
|
|
228
|
+
yield Checkbox("dry run (print the plan, spawn nothing)", id="dry-run")
|
|
229
|
+
with VerticalScroll(id="preview"):
|
|
230
|
+
yield Static(id="preview-body")
|
|
231
|
+
with Horizontal(classes="buttons"):
|
|
232
|
+
yield Button("start", variant="primary", id="ok")
|
|
233
|
+
yield Button("cancel", id="cancel")
|
|
234
|
+
|
|
235
|
+
def on_mount(self) -> None:
|
|
236
|
+
self._refresh_preview()
|
|
237
|
+
|
|
238
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
239
|
+
self._refresh_preview()
|
|
240
|
+
|
|
241
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
242
|
+
if event.input.id == "spec-folder":
|
|
243
|
+
self._refresh_preview()
|
|
244
|
+
|
|
245
|
+
def _refresh_preview(self) -> None:
|
|
246
|
+
try:
|
|
247
|
+
body = self.query_one("#preview-body", Static)
|
|
248
|
+
source = self.query_one("#source", Select).value
|
|
249
|
+
spec_folder = self.query_one("#spec-folder", Input).value.strip()
|
|
250
|
+
except NoMatches:
|
|
251
|
+
return # a Changed message during mount, before the tree is built
|
|
252
|
+
if source != "stories":
|
|
253
|
+
body.update(Text("sprint mode — walks sprint-status.yaml", style="dim"))
|
|
254
|
+
return
|
|
255
|
+
if not spec_folder:
|
|
256
|
+
body.update(
|
|
257
|
+
Text("stories mode needs a spec folder (stories.yaml + SPEC.md)", style="yellow")
|
|
258
|
+
)
|
|
259
|
+
return
|
|
260
|
+
folder = stories.resolve_spec_folder(self._project, spec_folder)
|
|
261
|
+
try:
|
|
262
|
+
rows = stories.story_rows(folder)
|
|
263
|
+
except stories.StoriesError as e:
|
|
264
|
+
body.update(Text(f"⚠ {e}", style="red"))
|
|
265
|
+
return
|
|
266
|
+
text = Text()
|
|
267
|
+
text.append(f"{len(rows)} stories · linear order", style="bold")
|
|
268
|
+
if not (folder / "SPEC.md").is_file():
|
|
269
|
+
text.append(" ⚠ SPEC.md missing", style="red")
|
|
270
|
+
for r in rows:
|
|
271
|
+
text.append(f"\n {r.position}. {r.id} ({r.label})")
|
|
272
|
+
marks = [
|
|
273
|
+
m for m, on in (("spec", r.spec_checkpoint), ("done", r.done_checkpoint)) if on
|
|
274
|
+
]
|
|
275
|
+
if marks:
|
|
276
|
+
text.append(f" [{'/'.join(marks)}]", style="magenta")
|
|
277
|
+
text.append(f" {r.title}", style="dim")
|
|
278
|
+
body.update(text)
|
|
279
|
+
|
|
280
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
281
|
+
if event.button.id != "ok":
|
|
282
|
+
self.dismiss(None)
|
|
283
|
+
return
|
|
284
|
+
self.dismiss(
|
|
285
|
+
{
|
|
286
|
+
"source": self.query_one("#source", Select).value,
|
|
287
|
+
"spec_folder": self.query_one("#spec-folder", Input).value.strip(),
|
|
288
|
+
"epic": _int_or_none(self.query_one("#epic", Input).value),
|
|
289
|
+
"story": self.query_one("#story", Input).value.strip() or None,
|
|
290
|
+
"max_stories": _int_or_none(self.query_one("#max-stories", Input).value),
|
|
291
|
+
"dry_run": self.query_one("#dry-run", Checkbox).value,
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class StartSweepModal(BaseDialog):
|
|
297
|
+
"""Options for `froid-loop sweep` → {no_prompt, decisions_only,
|
|
298
|
+
max_bundles, dry_run}."""
|
|
299
|
+
|
|
300
|
+
DEFAULT_CSS = """
|
|
301
|
+
StartSweepModal #body {
|
|
302
|
+
height: auto;
|
|
303
|
+
max-height: 70%;
|
|
304
|
+
}
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
def compose(self) -> ComposeResult:
|
|
308
|
+
with Vertical(id="dialog"):
|
|
309
|
+
yield Label("start sweep", classes="title")
|
|
310
|
+
with VerticalScroll(id="body"):
|
|
311
|
+
yield Checkbox("unattended (--no-prompt): skip decisions", id="no-prompt")
|
|
312
|
+
yield Checkbox("decisions only: triage + answer, no bundles", id="decisions-only")
|
|
313
|
+
yield Input(
|
|
314
|
+
placeholder="max bundles — blank for policy default",
|
|
315
|
+
type="integer",
|
|
316
|
+
valid_empty=True,
|
|
317
|
+
id="max-bundles",
|
|
318
|
+
)
|
|
319
|
+
yield Checkbox("dry run (list open entries, spawn nothing)", id="dry-run")
|
|
320
|
+
with Horizontal(classes="buttons"):
|
|
321
|
+
yield Button("start", variant="primary", id="ok")
|
|
322
|
+
yield Button("cancel", id="cancel")
|
|
323
|
+
|
|
324
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
325
|
+
if event.button.id != "ok":
|
|
326
|
+
self.dismiss(None)
|
|
327
|
+
return
|
|
328
|
+
self.dismiss(
|
|
329
|
+
{
|
|
330
|
+
"no_prompt": self.query_one("#no-prompt", Checkbox).value,
|
|
331
|
+
"decisions_only": self.query_one("#decisions-only", Checkbox).value,
|
|
332
|
+
"max_bundles": _int_or_none(self.query_one("#max-bundles", Input).value),
|
|
333
|
+
"dry_run": self.query_one("#dry-run", Checkbox).value,
|
|
334
|
+
}
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class ConfirmModal(BaseDialog):
|
|
339
|
+
"""Generic confirmation → dismiss(True) on confirm, None otherwise."""
|
|
340
|
+
|
|
341
|
+
DEFAULT_CSS = """
|
|
342
|
+
ConfirmModal #body {
|
|
343
|
+
height: auto;
|
|
344
|
+
max-height: 60%;
|
|
345
|
+
}
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
def __init__(
|
|
349
|
+
self,
|
|
350
|
+
title: str,
|
|
351
|
+
body: str | Text,
|
|
352
|
+
*,
|
|
353
|
+
confirm_label: str = "confirm",
|
|
354
|
+
warning: str | None = None,
|
|
355
|
+
):
|
|
356
|
+
super().__init__()
|
|
357
|
+
self._title = title
|
|
358
|
+
self._body = body if isinstance(body, Text) else Text(body)
|
|
359
|
+
self._confirm_label = confirm_label
|
|
360
|
+
self._warning = warning
|
|
361
|
+
|
|
362
|
+
def compose(self) -> ComposeResult:
|
|
363
|
+
with Vertical(id="dialog"):
|
|
364
|
+
yield Label(self._title, classes="title")
|
|
365
|
+
with VerticalScroll(id="body"):
|
|
366
|
+
yield Static(self._body)
|
|
367
|
+
# The warning gates an enabled, destructive confirm, so it is docked
|
|
368
|
+
# outside #body (never scrolled off) — directly above the button row.
|
|
369
|
+
if self._warning:
|
|
370
|
+
yield Static(Text(f"⚠ {self._warning}", style="bold red"), id="warning")
|
|
371
|
+
with Horizontal(classes="buttons"):
|
|
372
|
+
yield Button(self._confirm_label, variant="warning", id="ok")
|
|
373
|
+
yield Button("cancel", id="cancel")
|
|
374
|
+
|
|
375
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
376
|
+
self.dismiss(True if event.button.id == "ok" else None)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class ConfirmResumeModal(ConfirmModal):
|
|
380
|
+
"""Resume confirmation with pause details and a double-drive warning when
|
|
381
|
+
the recorded engine pid may still be live."""
|
|
382
|
+
|
|
383
|
+
def __init__(self, run_id: str, state: RunState, engine_alive: bool):
|
|
384
|
+
body = Text()
|
|
385
|
+
body.append("resume run ")
|
|
386
|
+
body.append(run_id, style="bold")
|
|
387
|
+
body.append("?\n")
|
|
388
|
+
if state.paused:
|
|
389
|
+
body.append(f"paused at {state.paused_stage or '?'}", style="yellow")
|
|
390
|
+
if state.paused_reason:
|
|
391
|
+
body.append(f" — {state.paused_reason}", style="yellow")
|
|
392
|
+
else:
|
|
393
|
+
body.append("run is not paused — it looks interrupted", style="dim")
|
|
394
|
+
warning = (
|
|
395
|
+
"engine.pid may still be live — resuming could double-drive this run"
|
|
396
|
+
if engine_alive
|
|
397
|
+
else None
|
|
398
|
+
)
|
|
399
|
+
super().__init__("resume run", body, confirm_label="resume", warning=warning)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class DeferredEntryModal(BaseDialog):
|
|
403
|
+
"""Full body of one deferred-work entry. The ledger is LLM-written
|
|
404
|
+
markdown, so the body renders as plain Text, never markup."""
|
|
405
|
+
|
|
406
|
+
DEFAULT_CSS = """
|
|
407
|
+
DeferredEntryModal #dialog {
|
|
408
|
+
width: 96;
|
|
409
|
+
height: 80%;
|
|
410
|
+
}
|
|
411
|
+
DeferredEntryModal #entry {
|
|
412
|
+
height: 1fr;
|
|
413
|
+
}
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
def __init__(self, item: data.DeferredItem):
|
|
417
|
+
super().__init__()
|
|
418
|
+
self._item = item
|
|
419
|
+
|
|
420
|
+
def compose(self) -> ComposeResult:
|
|
421
|
+
item = self._item
|
|
422
|
+
title = Text()
|
|
423
|
+
title.append(f"{item.id} — {item.title}", style="bold")
|
|
424
|
+
if item.done:
|
|
425
|
+
title.append(" ✓ done", style="green")
|
|
426
|
+
if item.legacy:
|
|
427
|
+
title.append(" · legacy — converted to DW format on next sweep", style="dim")
|
|
428
|
+
with Vertical(id="dialog"):
|
|
429
|
+
yield Static(title, classes="title")
|
|
430
|
+
with VerticalScroll(id="entry"):
|
|
431
|
+
body = item.body.strip()
|
|
432
|
+
if body:
|
|
433
|
+
yield Static(Text(body))
|
|
434
|
+
else:
|
|
435
|
+
yield Static(Text("(empty entry)", style="dim"))
|
|
436
|
+
with Horizontal(classes="buttons"):
|
|
437
|
+
yield Button("close", variant="primary", id="ok")
|
|
438
|
+
|
|
439
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
440
|
+
self.dismiss(None)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class DecisionModal(BaseDialog):
|
|
444
|
+
"""Answer one deferred-work decision a past sweep left unanswered. Dismisses
|
|
445
|
+
with the chosen sweep.DecisionOption, or None on skip/cancel. Question,
|
|
446
|
+
option labels and details are LLM-written, so they render as plain Text."""
|
|
447
|
+
|
|
448
|
+
DEFAULT_CSS = """
|
|
449
|
+
DecisionModal #dialog {
|
|
450
|
+
width: 86;
|
|
451
|
+
height: 90%;
|
|
452
|
+
}
|
|
453
|
+
DecisionModal #body {
|
|
454
|
+
height: 1fr;
|
|
455
|
+
}
|
|
456
|
+
DecisionModal .context {
|
|
457
|
+
margin-bottom: 1;
|
|
458
|
+
}
|
|
459
|
+
DecisionModal .opt {
|
|
460
|
+
margin-top: 1;
|
|
461
|
+
}
|
|
462
|
+
DecisionModal .opt-detail {
|
|
463
|
+
margin-bottom: 1;
|
|
464
|
+
}
|
|
465
|
+
"""
|
|
466
|
+
|
|
467
|
+
def __init__(self, decision: Any):
|
|
468
|
+
super().__init__()
|
|
469
|
+
self._decision = decision
|
|
470
|
+
|
|
471
|
+
def compose(self) -> ComposeResult:
|
|
472
|
+
d = self._decision
|
|
473
|
+
title = Text()
|
|
474
|
+
title.append(f"{d.id} — answer this decision", style="bold")
|
|
475
|
+
with Vertical(id="dialog"):
|
|
476
|
+
yield Static(title, classes="title")
|
|
477
|
+
with VerticalScroll(id="body"):
|
|
478
|
+
yield Static(Text(d.question))
|
|
479
|
+
if d.context:
|
|
480
|
+
yield Static(Text(d.context, style="dim"), classes="context")
|
|
481
|
+
for opt in d.options:
|
|
482
|
+
head = Text()
|
|
483
|
+
head.append(f"[{opt.key}] ", style="bold")
|
|
484
|
+
head.append(opt.label)
|
|
485
|
+
head.append(f" · {opt.effect}", style="cyan")
|
|
486
|
+
if opt.key == d.recommendation:
|
|
487
|
+
head.append(" (recommended)", style="green")
|
|
488
|
+
yield Static(head, classes="opt")
|
|
489
|
+
detail = opt.intent or opt.resolution
|
|
490
|
+
if detail:
|
|
491
|
+
yield Static(Text(f" {detail}", style="dim"), classes="opt-detail")
|
|
492
|
+
yield Button(f"choose {opt.key}", id=f"opt-{opt.key}")
|
|
493
|
+
with Horizontal(classes="buttons"):
|
|
494
|
+
yield Button("skip", id="cancel")
|
|
495
|
+
|
|
496
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
497
|
+
bid = event.button.id or ""
|
|
498
|
+
if bid.startswith("opt-"):
|
|
499
|
+
self.dismiss(self._decision.option(bid[len("opt-") :]))
|
|
500
|
+
else:
|
|
501
|
+
self.dismiss(None)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
class SpecReviewModal(BaseDialog):
|
|
505
|
+
"""Read-only story-spec viewer with a configurable action row.
|
|
506
|
+
|
|
507
|
+
Shared by the plan-checkpoint viewer (Approve & resume / Request replan) and
|
|
508
|
+
the spec-approval gate viewer (Approve & resume). Dismisses with the
|
|
509
|
+
chosen action verb, or None on close/escape. The spec path is shown
|
|
510
|
+
prominently with a copy-path action; the spec body is LLM-written markdown so
|
|
511
|
+
it renders as plain Text, never markup. The modal owns no logic — the caller
|
|
512
|
+
maps each verb to the exact CLI code path (resume / reset-to-draft + resume)."""
|
|
513
|
+
|
|
514
|
+
DEFAULT_CSS = """
|
|
515
|
+
SpecReviewModal #dialog {
|
|
516
|
+
width: 100;
|
|
517
|
+
height: 85%;
|
|
518
|
+
}
|
|
519
|
+
SpecReviewModal #spec {
|
|
520
|
+
height: 1fr;
|
|
521
|
+
border: solid $primary-darken-2;
|
|
522
|
+
padding: 0 1;
|
|
523
|
+
}
|
|
524
|
+
SpecReviewModal .path {
|
|
525
|
+
color: $text-muted;
|
|
526
|
+
margin-bottom: 1;
|
|
527
|
+
}
|
|
528
|
+
"""
|
|
529
|
+
|
|
530
|
+
def __init__(
|
|
531
|
+
self,
|
|
532
|
+
*,
|
|
533
|
+
title: str,
|
|
534
|
+
subtitle: str | Text,
|
|
535
|
+
spec_path: Path | None,
|
|
536
|
+
spec_text: str,
|
|
537
|
+
actions: list[tuple[str, str, str]],
|
|
538
|
+
unreadable: bool = False,
|
|
539
|
+
):
|
|
540
|
+
super().__init__()
|
|
541
|
+
self._title = title
|
|
542
|
+
self._subtitle = subtitle if isinstance(subtitle, Text) else Text(subtitle)
|
|
543
|
+
self._spec_path = spec_path
|
|
544
|
+
self._spec_text = spec_text
|
|
545
|
+
self._actions = actions
|
|
546
|
+
self._unreadable = unreadable
|
|
547
|
+
|
|
548
|
+
def compose(self) -> ComposeResult:
|
|
549
|
+
with Vertical(id="dialog"):
|
|
550
|
+
yield Label(self._title, classes="title")
|
|
551
|
+
yield Static(self._subtitle)
|
|
552
|
+
path_line = Text()
|
|
553
|
+
if self._spec_path is not None:
|
|
554
|
+
path_line.append(str(self._spec_path))
|
|
555
|
+
else:
|
|
556
|
+
path_line.append("(no spec file resolved)", style="dim")
|
|
557
|
+
yield Static(path_line, classes="path")
|
|
558
|
+
with VerticalScroll(id="spec"):
|
|
559
|
+
body = self._spec_text.strip()
|
|
560
|
+
if self._unreadable:
|
|
561
|
+
# Dimmed like "(empty spec)", never as plain body text: this string
|
|
562
|
+
# is THIS modal's report of a failed read, and rendering it in the
|
|
563
|
+
# style reserved for the spec's own words invites it to be read as
|
|
564
|
+
# spec content that happens to open with that sentence.
|
|
565
|
+
yield Static(Text(body, style="dim"))
|
|
566
|
+
else:
|
|
567
|
+
yield Static(Text(body) if body else Text("(empty spec)", style="dim"))
|
|
568
|
+
with Horizontal(classes="buttons"):
|
|
569
|
+
if self._spec_path is not None:
|
|
570
|
+
yield Button("copy path", id="copy-path")
|
|
571
|
+
for verb, label, variant in self._actions:
|
|
572
|
+
# Every verb this modal offers acts ON the spec — approve resumes the
|
|
573
|
+
# run past the gate, replan rewrites the file. A spec nobody could
|
|
574
|
+
# read is one nobody reviewed, so the actions are refused at the
|
|
575
|
+
# source rather than left to fail (or worse, succeed) downstream.
|
|
576
|
+
yield Button(
|
|
577
|
+
label,
|
|
578
|
+
variant=variant, # type: ignore[arg-type]
|
|
579
|
+
id=f"act-{verb}",
|
|
580
|
+
disabled=self._unreadable,
|
|
581
|
+
)
|
|
582
|
+
yield Button("close", id="cancel")
|
|
583
|
+
|
|
584
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
585
|
+
bid = event.button.id or ""
|
|
586
|
+
if bid == "copy-path" and self._spec_path is not None:
|
|
587
|
+
self.app.copy_to_clipboard(str(self._spec_path))
|
|
588
|
+
self.app.notify("spec path copied to clipboard")
|
|
589
|
+
return
|
|
590
|
+
if bid.startswith("act-"):
|
|
591
|
+
self.dismiss(bid[len("act-") :])
|
|
592
|
+
return
|
|
593
|
+
self.dismiss(None)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
class PauseReasonModal(BaseDialog):
|
|
597
|
+
"""Spec-less gate viewer: a story gate fires before its story is registered
|
|
598
|
+
and an epic boundary has no story at all, so the pause reason IS the payload
|
|
599
|
+
— it names the blocking entries and the remedy. Dismisses with 'resume', or
|
|
600
|
+
None on close/escape. Reasons are arbitrary engine text → plain Text, never
|
|
601
|
+
markup. The modal owns no logic — the caller maps the verb to _do_resume."""
|
|
602
|
+
|
|
603
|
+
DEFAULT_CSS = """
|
|
604
|
+
PauseReasonModal #reason {
|
|
605
|
+
height: auto;
|
|
606
|
+
max-height: 60%;
|
|
607
|
+
}
|
|
608
|
+
"""
|
|
609
|
+
|
|
610
|
+
def __init__(self, *, title: str, subtitle: str | Text, reason: str):
|
|
611
|
+
super().__init__()
|
|
612
|
+
self._title = title
|
|
613
|
+
self._subtitle = subtitle if isinstance(subtitle, Text) else Text(subtitle)
|
|
614
|
+
self._reason = reason
|
|
615
|
+
|
|
616
|
+
def compose(self) -> ComposeResult:
|
|
617
|
+
with Vertical(id="dialog"):
|
|
618
|
+
yield Label(self._title, classes="title")
|
|
619
|
+
yield Static(self._subtitle, id="subtitle")
|
|
620
|
+
with VerticalScroll(id="reason"):
|
|
621
|
+
body = self._reason.strip()
|
|
622
|
+
yield Static(
|
|
623
|
+
Text(body) if body else Text("(no pause reason recorded)", style="dim")
|
|
624
|
+
)
|
|
625
|
+
with Horizontal(classes="buttons"):
|
|
626
|
+
yield Button("Resume", variant="primary", id="act-resume")
|
|
627
|
+
yield Button("close", id="cancel")
|
|
628
|
+
|
|
629
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
630
|
+
bid = event.button.id or ""
|
|
631
|
+
self.dismiss(bid[len("act-") :] if bid.startswith("act-") else None)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
class StoryCheckpointModal(BaseDialog):
|
|
635
|
+
"""done_checkpoint summary card shown after a story commits: id/title, the
|
|
636
|
+
commit subject + short hash, a gate line derived from real task state (the
|
|
637
|
+
verify + review gates the commit cleared, plus the follow-up review-cycle
|
|
638
|
+
count) and token totals. Dismisses with 'continue' (resume the schedule) or
|
|
639
|
+
'stop' (mark the run stopped), None on close/escape."""
|
|
640
|
+
|
|
641
|
+
DEFAULT_CSS = """
|
|
642
|
+
StoryCheckpointModal #body {
|
|
643
|
+
height: auto;
|
|
644
|
+
max-height: 70%;
|
|
645
|
+
}
|
|
646
|
+
"""
|
|
647
|
+
|
|
648
|
+
def __init__(
|
|
649
|
+
self,
|
|
650
|
+
*,
|
|
651
|
+
story_key: str,
|
|
652
|
+
title: str,
|
|
653
|
+
commit: str,
|
|
654
|
+
verify_line: str,
|
|
655
|
+
tokens: str,
|
|
656
|
+
):
|
|
657
|
+
super().__init__()
|
|
658
|
+
self._story_key = story_key
|
|
659
|
+
self._title = title
|
|
660
|
+
self._commit = commit
|
|
661
|
+
self._verify_line = verify_line
|
|
662
|
+
self._tokens = tokens
|
|
663
|
+
|
|
664
|
+
def compose(self) -> ComposeResult:
|
|
665
|
+
head = Text()
|
|
666
|
+
head.append(f"story checkpoint — {self._story_key}", style="bold")
|
|
667
|
+
with Vertical(id="dialog"):
|
|
668
|
+
yield Label(head, classes="title")
|
|
669
|
+
with VerticalScroll(id="body"):
|
|
670
|
+
if self._title:
|
|
671
|
+
yield Static(Text(self._title))
|
|
672
|
+
card = Text()
|
|
673
|
+
card.append("\ncommit ", style="dim")
|
|
674
|
+
card.append(self._commit or "(none)", style="green")
|
|
675
|
+
card.append("\nverify ", style="dim")
|
|
676
|
+
card.append(self._verify_line)
|
|
677
|
+
card.append("\ntokens ", style="dim")
|
|
678
|
+
card.append(self._tokens, style="dim")
|
|
679
|
+
yield Static(card)
|
|
680
|
+
with Horizontal(classes="buttons"):
|
|
681
|
+
yield Button("Continue run", variant="primary", id="act-continue")
|
|
682
|
+
yield Button("Stop run", variant="warning", id="act-stop")
|
|
683
|
+
yield Button("close", id="cancel")
|
|
684
|
+
|
|
685
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
686
|
+
bid = event.button.id or ""
|
|
687
|
+
self.dismiss(bid[len("act-") :] if bid.startswith("act-") else None)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
class EscalationModal(BaseDialog):
|
|
691
|
+
"""Blocked-story escalation view with story context: the story entry's
|
|
692
|
+
title/description (stories mode), the blocking condition parsed from the
|
|
693
|
+
spec's ``## Auto Run Result``, and a sentinel indicator when the matched spec
|
|
694
|
+
is a fixed-slug pre-planning-halt sentinel. Dismisses with 'resolve' (launch
|
|
695
|
+
the interactive resolve agent) or 'rearm' (re-arm + resume — only offered once
|
|
696
|
+
the resolution marker exists), None on close/escape."""
|
|
697
|
+
|
|
698
|
+
DEFAULT_CSS = """
|
|
699
|
+
EscalationModal #dialog {
|
|
700
|
+
width: 90;
|
|
701
|
+
height: 90%;
|
|
702
|
+
}
|
|
703
|
+
EscalationModal #body {
|
|
704
|
+
height: 1fr;
|
|
705
|
+
}
|
|
706
|
+
EscalationModal #blocking {
|
|
707
|
+
height: auto;
|
|
708
|
+
margin-top: 1;
|
|
709
|
+
border: solid $primary-darken-2;
|
|
710
|
+
padding: 0 1;
|
|
711
|
+
}
|
|
712
|
+
"""
|
|
713
|
+
|
|
714
|
+
def __init__(
|
|
715
|
+
self,
|
|
716
|
+
*,
|
|
717
|
+
story_key: str,
|
|
718
|
+
title: str,
|
|
719
|
+
description: str,
|
|
720
|
+
blocking: str,
|
|
721
|
+
sentinel_kind: str,
|
|
722
|
+
resolution_ready: bool,
|
|
723
|
+
engine_live: bool,
|
|
724
|
+
restore_recorded: bool = False,
|
|
725
|
+
unreadable: bool = False,
|
|
726
|
+
):
|
|
727
|
+
super().__init__()
|
|
728
|
+
self._story_key = story_key
|
|
729
|
+
self._title = title
|
|
730
|
+
self._description = description
|
|
731
|
+
self._blocking = blocking
|
|
732
|
+
self._sentinel_kind = sentinel_kind
|
|
733
|
+
self._resolution_ready = resolution_ready
|
|
734
|
+
self._engine_live = engine_live
|
|
735
|
+
self._restore_recorded = restore_recorded
|
|
736
|
+
self._unreadable = unreadable
|
|
737
|
+
|
|
738
|
+
def compose(self) -> ComposeResult:
|
|
739
|
+
head = Text()
|
|
740
|
+
head.append(f"escalation — {self._story_key}", style="bold red")
|
|
741
|
+
with Vertical(id="dialog"):
|
|
742
|
+
yield Label(head, classes="title")
|
|
743
|
+
with VerticalScroll(id="body"):
|
|
744
|
+
if self._title:
|
|
745
|
+
yield Static(Text(self._title, style="bold"))
|
|
746
|
+
if self._description:
|
|
747
|
+
yield Static(Text(self._description, style="dim"))
|
|
748
|
+
if self._sentinel_kind:
|
|
749
|
+
yield Static(
|
|
750
|
+
Text(
|
|
751
|
+
f"⚠ pre-planning-halt sentinel ({self._sentinel_kind}) — "
|
|
752
|
+
"re-arm deletes it (a copy is preserved) for a clean re-dispatch",
|
|
753
|
+
style="yellow",
|
|
754
|
+
)
|
|
755
|
+
)
|
|
756
|
+
with Vertical(id="blocking"):
|
|
757
|
+
body = self._blocking.strip()
|
|
758
|
+
if self._unreadable:
|
|
759
|
+
# The spec could not be READ at the anchored path, so there is
|
|
760
|
+
# no blocking condition to parse and "(no blocking condition
|
|
761
|
+
# recorded)" would be a lie indistinguishable from a readable
|
|
762
|
+
# spec that simply halted without one. `_blocking_condition`
|
|
763
|
+
# reduces the read-failure body to "" like any other non-halt
|
|
764
|
+
# text, so this arm cannot be inferred downstream — it has to
|
|
765
|
+
# be carried in.
|
|
766
|
+
#
|
|
767
|
+
# REPLACES the body rather than prefixing it: `body` is always
|
|
768
|
+
# "" on this arm (the failure sentence carries no halt block),
|
|
769
|
+
# so an `if` that fell through still rendered the very sentence
|
|
770
|
+
# the paragraph above calls a lie, directly under the warning
|
|
771
|
+
# denying it.
|
|
772
|
+
yield Static(
|
|
773
|
+
Text(
|
|
774
|
+
"⚠ the spec could not be read at the anchored path — "
|
|
775
|
+
"the blocking condition is unknown, not absent",
|
|
776
|
+
style="red",
|
|
777
|
+
)
|
|
778
|
+
)
|
|
779
|
+
elif body:
|
|
780
|
+
yield Static(Text(body))
|
|
781
|
+
else:
|
|
782
|
+
yield Static(Text("(no blocking condition recorded)", style="dim"))
|
|
783
|
+
if self._engine_live:
|
|
784
|
+
yield Static(
|
|
785
|
+
Text("engine may still be live — stop it before resolving", style="yellow")
|
|
786
|
+
)
|
|
787
|
+
# The restore-discard branch below gates an enabled Re-arm, so the hint
|
|
788
|
+
# is docked outside #body (never scrolled off) — directly above the buttons.
|
|
789
|
+
hint = Text()
|
|
790
|
+
if self._unreadable:
|
|
791
|
+
# Precedence over both branches below: they explain when Re-arm
|
|
792
|
+
# unlocks, and neither is true while the evidence cannot be read.
|
|
793
|
+
hint.append(
|
|
794
|
+
"re-arm is refused while the spec is unreadable — it flips the "
|
|
795
|
+
"frontmatter, strips the result and re-stamps the baseline on "
|
|
796
|
+
"evidence nobody could read. Resolve stays OPEN: it is the "
|
|
797
|
+
"non-destructive remedy, and a bad anchor is exactly what it "
|
|
798
|
+
"repairs — `froid-loop resolve` does the same from the CLI",
|
|
799
|
+
style="red",
|
|
800
|
+
)
|
|
801
|
+
elif self._restore_recorded:
|
|
802
|
+
# honoring the latch from here would be unsafe (a stale marker is
|
|
803
|
+
# indistinguishable from a fresh one), so Re-arm stays a plain
|
|
804
|
+
# from-scratch re-drive — but never a silent drop of the decision.
|
|
805
|
+
hint.append(
|
|
806
|
+
"⚠ the resolution records a restore patch — Re-arm here re-drives "
|
|
807
|
+
"from scratch and drops it; run `froid-loop resolve` to honor the "
|
|
808
|
+
"restore",
|
|
809
|
+
style="yellow",
|
|
810
|
+
)
|
|
811
|
+
elif self._resolution_ready:
|
|
812
|
+
hint.append("resolution recorded — re-arm & resume when ready", style="green")
|
|
813
|
+
else:
|
|
814
|
+
hint.append(
|
|
815
|
+
"resolve opens an interactive agent to fix the frozen spec; "
|
|
816
|
+
"re-arm unlocks once it records a resolution",
|
|
817
|
+
style="dim",
|
|
818
|
+
)
|
|
819
|
+
yield Static(hint, id="hint")
|
|
820
|
+
with Horizontal(classes="buttons"):
|
|
821
|
+
# NOT gated on `_unreadable`. Resolve opens an interactive agent to
|
|
822
|
+
# repair the frozen spec and writes nothing itself, so it is the one
|
|
823
|
+
# verb an unreadable spec is a REASON to offer — refusing it left the
|
|
824
|
+
# modal with `close` as its only action, on the very failure the
|
|
825
|
+
# resolve agent exists to fix. It also kept the modal out of step with
|
|
826
|
+
# `action_resolve_run` (the `R` binding), which has no readability
|
|
827
|
+
# check, so the refusal was advisory rather than enforced.
|
|
828
|
+
yield Button(
|
|
829
|
+
"Resolve",
|
|
830
|
+
variant="primary",
|
|
831
|
+
id="act-resolve",
|
|
832
|
+
disabled=self._engine_live,
|
|
833
|
+
)
|
|
834
|
+
yield Button(
|
|
835
|
+
"Re-arm & resume",
|
|
836
|
+
variant="warning",
|
|
837
|
+
id="act-rearm",
|
|
838
|
+
disabled=not self._resolution_ready or self._engine_live or self._unreadable,
|
|
839
|
+
)
|
|
840
|
+
yield Button("close", id="cancel")
|
|
841
|
+
|
|
842
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
843
|
+
bid = event.button.id or ""
|
|
844
|
+
self.dismiss(bid[len("act-") :] if bid.startswith("act-") else None)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
class ValidateFindingsModal(BaseDialog):
|
|
848
|
+
"""`validate --json` rendered structurally: verdict, then one row per finding.
|
|
849
|
+
|
|
850
|
+
The text mode this replaces made severity a string prefix and the verdict an
|
|
851
|
+
exit code, and had already flattened away what each check knew. Here the
|
|
852
|
+
verdict is the document's own ``ok`` — which, unlike ``rc``, separates "the
|
|
853
|
+
checks failed" from "the command broke" — severities are styled, and
|
|
854
|
+
``detail`` is renderable: inline for warnings and problems, and one ``d``
|
|
855
|
+
away for everything else.
|
|
856
|
+
|
|
857
|
+
Everything user-controlled (a ``spec_folder``, a check's ``message``) arrives
|
|
858
|
+
as rich ``Text`` built in :mod:`froid_loop.tui.widgets`, never as markup: both
|
|
859
|
+
``Static`` and ``Label`` default to ``markup=True``, so a spec folder named
|
|
860
|
+
``docs/[wip]-epic-3`` interpolated into an f-string title would be a
|
|
861
|
+
``MarkupError``.
|
|
862
|
+
|
|
863
|
+
``__init__`` deliberately does nothing but store the document. The worker
|
|
864
|
+
that builds this screen runs on a **thread**; composing is the app's job, on
|
|
865
|
+
the main thread. ``TextOutputModal`` is the precedent.
|
|
866
|
+
"""
|
|
867
|
+
|
|
868
|
+
DEFAULT_CSS = """
|
|
869
|
+
ValidateFindingsModal #dialog {
|
|
870
|
+
width: 96;
|
|
871
|
+
height: 80%;
|
|
872
|
+
}
|
|
873
|
+
ValidateFindingsModal #findings {
|
|
874
|
+
height: 1fr;
|
|
875
|
+
}
|
|
876
|
+
"""
|
|
877
|
+
|
|
878
|
+
# BaseDialog's escape binding is inherited: Textual collects BINDINGS from
|
|
879
|
+
# the whole MRO, so this list adds to it rather than replacing it.
|
|
880
|
+
BINDINGS = [Binding("d", "toggle_detail", "detail")]
|
|
881
|
+
|
|
882
|
+
def __init__(self, doc: dict):
|
|
883
|
+
super().__init__()
|
|
884
|
+
self._doc = doc
|
|
885
|
+
self._details = False
|
|
886
|
+
|
|
887
|
+
def compose(self) -> ComposeResult:
|
|
888
|
+
with Vertical(id="dialog"):
|
|
889
|
+
yield Static(widgets.validate_header(self._doc), classes="title")
|
|
890
|
+
with VerticalScroll(id="findings"):
|
|
891
|
+
yield Static(widgets.validate_findings(self._doc, details=self._details), id="grid")
|
|
892
|
+
yield Static(Text("d — toggle detail on every finding", style="dim"))
|
|
893
|
+
with Horizontal(classes="buttons"):
|
|
894
|
+
yield Button("close", variant="primary", id="ok")
|
|
895
|
+
|
|
896
|
+
def action_toggle_detail(self) -> None:
|
|
897
|
+
self._details = not self._details
|
|
898
|
+
self.query_one("#grid", Static).update(
|
|
899
|
+
widgets.validate_findings(self._doc, details=self._details)
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
903
|
+
self.dismiss(None)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
class TextOutputModal(BaseDialog):
|
|
907
|
+
"""Scrollable captured command output (dry runs, and the validate degrade).
|
|
908
|
+
|
|
909
|
+
Still the validate path's fallback: when the JSON document is unrenderable,
|
|
910
|
+
the app re-runs validate in text mode and shows it here, byte-for-byte the
|
|
911
|
+
pre-#210 behavior. See ``FroidLoopApp._show_validate``.
|
|
912
|
+
"""
|
|
913
|
+
|
|
914
|
+
DEFAULT_CSS = """
|
|
915
|
+
TextOutputModal #dialog {
|
|
916
|
+
width: 96;
|
|
917
|
+
height: 80%;
|
|
918
|
+
}
|
|
919
|
+
TextOutputModal #output {
|
|
920
|
+
height: 1fr;
|
|
921
|
+
}
|
|
922
|
+
"""
|
|
923
|
+
|
|
924
|
+
def __init__(self, title: str, returncode: int, output: str):
|
|
925
|
+
super().__init__()
|
|
926
|
+
self._title = title
|
|
927
|
+
self._returncode = returncode
|
|
928
|
+
self._output = output
|
|
929
|
+
|
|
930
|
+
def compose(self) -> ComposeResult:
|
|
931
|
+
status = "ok" if self._returncode == 0 else f"exit {self._returncode}"
|
|
932
|
+
with Vertical(id="dialog"):
|
|
933
|
+
yield Label(f"{self._title} — {status}", classes="title")
|
|
934
|
+
with VerticalScroll(id="output"):
|
|
935
|
+
if self._output.strip():
|
|
936
|
+
yield Static(Text.from_ansi(self._output))
|
|
937
|
+
else:
|
|
938
|
+
yield Static(Text("(no output)", style="dim"))
|
|
939
|
+
with Horizontal(classes="buttons"):
|
|
940
|
+
yield Button("close", variant="primary", id="ok")
|
|
941
|
+
|
|
942
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
943
|
+
self.dismiss(None)
|