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.
Files changed (116) hide show
  1. froid_loop/__init__.py +11 -0
  2. froid_loop/__main__.py +12 -0
  3. froid_loop/adapters/__init__.py +3 -0
  4. froid_loop/adapters/base.py +254 -0
  5. froid_loop/adapters/entrypoints.py +63 -0
  6. froid_loop/adapters/env_fault.py +290 -0
  7. froid_loop/adapters/generic.py +2013 -0
  8. froid_loop/adapters/mock.py +49 -0
  9. froid_loop/adapters/multiplexer.py +914 -0
  10. froid_loop/adapters/opencode_http.py +1687 -0
  11. froid_loop/adapters/profile.py +650 -0
  12. froid_loop/adapters/psmux_backend.py +1428 -0
  13. froid_loop/adapters/registry.py +322 -0
  14. froid_loop/adapters/tmux_backend.py +35 -0
  15. froid_loop/adapters/tmux_base.py +630 -0
  16. froid_loop/checks.py +187 -0
  17. froid_loop/cli.py +5041 -0
  18. froid_loop/data/__init__.py +0 -0
  19. froid_loop/data/froid_loop_hook.py +228 -0
  20. froid_loop/data/froid_loop_probe_hook.py +88 -0
  21. froid_loop/data/plugins/example/plugin.toml +21 -0
  22. froid_loop/data/plugins/tea/plugin.toml +184 -0
  23. froid_loop/data/plugins/tea/tea_plugin.py +258 -0
  24. froid_loop/data/plugins/unity/plugin.toml +140 -0
  25. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
  26. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
  27. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
  28. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
  29. froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
  30. froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
  31. froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
  32. froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
  33. froid_loop/data/plugins/unity/unity_facts.md +17 -0
  34. froid_loop/data/plugins/unity/unity_plugin.py +415 -0
  35. froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
  36. froid_loop/data/plugins/unity/unity_ready.py +230 -0
  37. froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
  38. froid_loop/data/plugins/unity/unity_setup.py +551 -0
  39. froid_loop/data/plugins/unity/unity_teardown.py +362 -0
  40. froid_loop/data/profiles/antigravity.toml +52 -0
  41. froid_loop/data/profiles/claude.toml +85 -0
  42. froid_loop/data/profiles/codex.toml +22 -0
  43. froid_loop/data/profiles/copilot.toml +52 -0
  44. froid_loop/data/profiles/gemini.toml +26 -0
  45. froid_loop/data/profiles/opencode.toml +54 -0
  46. froid_loop/data/settings/core.toml +458 -0
  47. froid_loop/data/skills/README.md +93 -0
  48. froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
  49. froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
  50. froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
  51. froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
  52. froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
  53. froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
  54. froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
  55. froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
  56. froid_loop/decisions.py +202 -0
  57. froid_loop/deferredwork.py +2282 -0
  58. froid_loop/devcontract.py +892 -0
  59. froid_loop/diagnostics.py +1104 -0
  60. froid_loop/documents.py +532 -0
  61. froid_loop/engine.py +7732 -0
  62. froid_loop/envvars.py +111 -0
  63. froid_loop/escalation.py +225 -0
  64. froid_loop/events.py +266 -0
  65. froid_loop/fences.py +103 -0
  66. froid_loop/froidconfig.py +226 -0
  67. froid_loop/frontmatter.py +526 -0
  68. froid_loop/gates.py +133 -0
  69. froid_loop/install.py +2936 -0
  70. froid_loop/journal.py +178 -0
  71. froid_loop/machine.py +148 -0
  72. froid_loop/model.py +898 -0
  73. froid_loop/operatoractions.py +474 -0
  74. froid_loop/platform_util.py +1490 -0
  75. froid_loop/plugins/__init__.py +64 -0
  76. froid_loop/plugins/bus.py +259 -0
  77. froid_loop/plugins/context.py +319 -0
  78. froid_loop/plugins/loader.py +145 -0
  79. froid_loop/plugins/manifest.py +279 -0
  80. froid_loop/plugins/model.py +296 -0
  81. froid_loop/plugins/registry.py +245 -0
  82. froid_loop/plugins/trust.py +75 -0
  83. froid_loop/policy.py +1569 -0
  84. froid_loop/probe.py +1044 -0
  85. froid_loop/process_host.py +408 -0
  86. froid_loop/recovery_flow.py +1561 -0
  87. froid_loop/resolve.py +283 -0
  88. froid_loop/runs.py +4715 -0
  89. froid_loop/runsetup.py +1293 -0
  90. froid_loop/sanitize.py +593 -0
  91. froid_loop/settings_schema.py +276 -0
  92. froid_loop/signals.py +160 -0
  93. froid_loop/sprintstatus.py +609 -0
  94. froid_loop/statemachine.py +57 -0
  95. froid_loop/stories.py +615 -0
  96. froid_loop/stories_engine.py +796 -0
  97. froid_loop/sweep.py +1892 -0
  98. froid_loop/tokens.py +196 -0
  99. froid_loop/tui/__init__.py +11 -0
  100. froid_loop/tui/app.py +1584 -0
  101. froid_loop/tui/data.py +840 -0
  102. froid_loop/tui/launch.py +1003 -0
  103. froid_loop/tui/screens/__init__.py +1 -0
  104. froid_loop/tui/screens/dashboard.py +1071 -0
  105. froid_loop/tui/screens/modals.py +943 -0
  106. froid_loop/tui/screens/settings_screen.py +477 -0
  107. froid_loop/tui/settings.py +135 -0
  108. froid_loop/tui/widgets.py +981 -0
  109. froid_loop/verify.py +4545 -0
  110. froid_loop/workspace.py +320 -0
  111. froid_loop/worktree_flow.py +2301 -0
  112. froid_loop-0.11.1.dist-info/METADATA +728 -0
  113. froid_loop-0.11.1.dist-info/RECORD +116 -0
  114. froid_loop-0.11.1.dist-info/WHEEL +4 -0
  115. froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
  116. 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)