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,981 @@
1
+ """Small presentation widgets for the dashboard.
2
+
3
+ Rendering builds rich Text objects rather than markup strings: pause reasons,
4
+ defer reasons and journal fields are arbitrary engine output and must never be
5
+ interpreted as markup.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any, Callable
14
+
15
+ from rich.segment import Segment
16
+ from rich.style import Style
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+ from textual import events
20
+ from textual.selection import Selection
21
+ from textual.strip import Strip
22
+ from textual.widget import Widget
23
+ from textual.widgets import DataTable, RichLog, Static, Tree
24
+ from textual.widgets.option_list import Option
25
+ from textual.widgets.tree import TreeNode
26
+
27
+ from .. import policy
28
+ from ..model import (
29
+ PAUSE_EPIC_BOUNDARY,
30
+ PAUSE_ESCALATION,
31
+ PAUSE_PLAN_CHECKPOINT,
32
+ PAUSE_SPEC_APPROVAL,
33
+ PAUSE_STORY_CHECKPOINT,
34
+ PAUSE_STORY_GATE,
35
+ Phase,
36
+ RunState,
37
+ )
38
+ from ..sprintstatus import SprintStatus, Story
39
+ from ..stories import StoryRow
40
+ from . import data
41
+
42
+ STATUS_GLYPHS = {
43
+ data.RUNNING: "▶",
44
+ data.PAUSED: "⏸",
45
+ data.FINISHED: "✔",
46
+ data.STOPPED: "⏹",
47
+ data.CRASHED: "✖",
48
+ data.INTERRUPTED: "✖",
49
+ data.UNKNOWN: "?",
50
+ }
51
+
52
+ STATUS_STYLES = {
53
+ data.RUNNING: "green",
54
+ data.PAUSED: "yellow",
55
+ data.FINISHED: "dim",
56
+ data.STOPPED: "bold yellow",
57
+ data.CRASHED: "bold red",
58
+ data.INTERRUPTED: "bold red",
59
+ data.UNKNOWN: "dim",
60
+ }
61
+
62
+
63
+ def status_cell(status: str) -> Text:
64
+ return Text(STATUS_GLYPHS.get(status, "?"), style=STATUS_STYLES.get(status, ""))
65
+
66
+
67
+ # ------------------------------------------------------------- pause badges
68
+ #
69
+ # Every mid-run pause that awaits a human maps to a visually distinct badge,
70
+ # shown as a short tag in the runs table and a full label in the run header.
71
+ # (short tag, full label, rich style)
72
+ _PAUSE_BADGES: dict[str, tuple[str, str, str]] = {
73
+ PAUSE_PLAN_CHECKPOINT: ("plan", "plan checkpoint", "magenta"),
74
+ PAUSE_STORY_CHECKPOINT: ("story", "story checkpoint", "cyan"),
75
+ PAUSE_SPEC_APPROVAL: ("spec", "spec-approval gate", "yellow"),
76
+ PAUSE_EPIC_BOUNDARY: ("epic", "epic gate", "yellow"),
77
+ PAUSE_STORY_GATE: ("gate", "story gate", "yellow"),
78
+ PAUSE_ESCALATION: ("esc", "escalation", "bold red"),
79
+ }
80
+
81
+
82
+ def pause_tag(stage: str) -> Text:
83
+ """Compact colored tag for a paused run in the runs table ('' when not
84
+ paused / unknown stage renders the raw stage)."""
85
+ if not stage:
86
+ return Text("")
87
+ tag, _label, style = _PAUSE_BADGES.get(stage, (stage, stage, "yellow"))
88
+ return Text(tag, style=style)
89
+
90
+
91
+ def pause_label(stage: str) -> tuple[str, str]:
92
+ """(full label, rich style) for a pause stage, for the run-header badge."""
93
+ _tag, label, style = _PAUSE_BADGES.get(stage, (stage, stage, "yellow"))
94
+ return label, style
95
+
96
+
97
+ def stopping_tag() -> Text:
98
+ """Compact tag for a run with a stop request pending — either mode (#319),
99
+ matching the mode-blind read behind it — shown in the runs-table note cell in
100
+ place of a pause badge. The glyph + style match
101
+ STATUS_GLYPHS/STATUS_STYLES[STOPPED] — the end state either stop lands in."""
102
+ return Text("⏹ stop", style=STATUS_STYLES[data.STOPPED])
103
+
104
+
105
+ def agent_label(name: str, model: str) -> str:
106
+ """Compact ``name·model`` label for a resolved adapter (e.g. "claude·opus"),
107
+ or just the name when no explicit model is recorded (``model == ""`` means the
108
+ session ran the CLI profile's default). Shared by the header's agent line and
109
+ the tasks table's agent cell so the two never drift."""
110
+ return f"{name}·{model}" if model else name
111
+
112
+
113
+ class RunHeader(Static):
114
+ """One-glance summary of the selected run, or the empty-state hint."""
115
+
116
+ def show_empty(self, project: Path) -> None:
117
+ text = Text()
118
+ text.append("no runs found", style="bold")
119
+ text.append(f" ({project})\n", style="dim")
120
+ text.append(
121
+ "start one with `froid-loop run` or `froid-loop sweep`"
122
+ " — or `froid-loop init` if this project is not set up yet",
123
+ style="dim",
124
+ )
125
+ self.update(text)
126
+
127
+ def show_starting(self, run_id: str) -> None:
128
+ text = Text()
129
+ text.append(run_id, style="bold")
130
+ text.append(" ⧗ starting…", style="yellow")
131
+ text.append(
132
+ "\nwaiting for the engine to write state.json"
133
+ " — if nothing appears, attach (a) to its control window",
134
+ style="dim",
135
+ )
136
+ self.update(text)
137
+
138
+ def show_run(
139
+ self,
140
+ run_id: str,
141
+ status: str,
142
+ state: RunState | None,
143
+ decision: tuple[str, str] | None = None,
144
+ stopping: bool = False,
145
+ agent: data.ActiveAgent | None = None,
146
+ ) -> None:
147
+ text = Text()
148
+ text.append(run_id, style="bold")
149
+ if state is not None and state.run_type != "story":
150
+ text.append(f" [{state.run_type}]")
151
+ text.append(" ")
152
+ text.append(
153
+ f"{STATUS_GLYPHS.get(status, '?')} {status}",
154
+ style=STATUS_STYLES.get(status, ""),
155
+ )
156
+ if state is None:
157
+ text.append("\nstate unavailable", style="dim")
158
+ self.update(text)
159
+ return
160
+ text.append(f" started {state.started_at}", style="dim")
161
+ if state.current_epic is not None:
162
+ text.append(f" epic {state.current_epic}", style="dim")
163
+
164
+ counts = {Phase.DONE: 0, Phase.DEFERRED: 0, Phase.ESCALATED: 0, Phase.AWAITING_OPERATOR: 0}
165
+ weight = state.cache_read_weight()
166
+ weighted = raw = 0
167
+ for task in state.tasks.values():
168
+ if task.phase in counts:
169
+ counts[task.phase] += 1
170
+ weighted += task.tokens.weighted_total(weight)
171
+ raw += task.tokens.total
172
+ text.append("\n")
173
+ text.append(f"tasks {len(state.tasks)}", style="dim")
174
+ text.append(f" done {counts[Phase.DONE]}", style="green")
175
+ text.append(f" deferred {counts[Phase.DEFERRED]}", style="yellow")
176
+ style = "red" if counts[Phase.ESCALATED] else "dim"
177
+ text.append(f" escalated {counts[Phase.ESCALATED]}", style=style)
178
+ # Only when non-zero, unlike the three above: the header line is already
179
+ # at the width the narrowest supported pane can hold, and a standing
180
+ # "awaiting 0" would cost that room on every run that never parks a story.
181
+ if counts[Phase.AWAITING_OPERATOR]:
182
+ text.append(f" awaiting {counts[Phase.AWAITING_OPERATOR]}", style="yellow")
183
+ text.append(f" {weighted:,} tokens ({raw:,} raw)", style="dim")
184
+
185
+ # The agent line: who is driving (or, when idle, who is configured to).
186
+ if agent is not None:
187
+ # A session is open: show the live agent, its model and stage role.
188
+ text.append("\nagent ", style="dim")
189
+ text.append(agent.name, style="bold cyan")
190
+ if agent.model:
191
+ text.append(f" · {agent.model}", style="cyan")
192
+ if agent.role:
193
+ text.append(f" · {agent.role}", style="dim")
194
+ else:
195
+ # No session open: show the configured adapters from the run's policy
196
+ # snapshot. Skip the line entirely when the snapshot can't be rebuilt
197
+ # (a pre-#153 run) rather than fabricate an all-defaults "claude".
198
+ rebuilt = policy.adapter_policy_from_snapshot(state.policy_snapshot)
199
+ if rebuilt is not None:
200
+ dev = rebuilt.resolved("dev")
201
+ review = rebuilt.resolved("review")
202
+ dev_label = agent_label(dev.name, dev.model)
203
+ review_label = agent_label(review.name, review.model)
204
+ if dev_label == review_label:
205
+ line = f"\nagents {dev_label}" # dev and review resolve alike
206
+ else:
207
+ line = f"\nagents dev {dev_label} review {review_label}"
208
+ if state.run_type == "sweep":
209
+ triage = rebuilt.resolved("triage")
210
+ line += f" triage {agent_label(triage.name, triage.model)}"
211
+ text.append(line, style="dim")
212
+
213
+ if stopping:
214
+ # A RUNNING or UNKNOWN run with a pending graceful-stop request: it never
215
+ # enters the PAUSED/CRASHED/INTERRUPTED branches below, so this stands on
216
+ # its own.
217
+ text.append(
218
+ "\n⏹ graceful stop pending — will stop after the current item",
219
+ style="bold yellow",
220
+ )
221
+
222
+ if status == data.PAUSED:
223
+ text.append("\n⏸ paused", style="bold yellow")
224
+ if state.paused_stage:
225
+ label, badge_style = pause_label(state.paused_stage)
226
+ text.append(" ")
227
+ text.append(f"[{label}]", style=f"bold {badge_style}")
228
+ if state.paused_reason:
229
+ text.append(f" — {state.paused_reason}", style="yellow")
230
+ # p opens the stage-appropriate review viewer; e resumes; R resolves
231
+ # an escalation (the header only hints the common paths).
232
+ text.append("\n press p to review · e to resume", style="dim")
233
+ elif status == data.CRASHED:
234
+ text.append(
235
+ "\n✖ engine crashed — see crash.txt · press e to resume",
236
+ style="bold red",
237
+ )
238
+ if state.crash_error:
239
+ text.append(f"\n {state.crash_error}", style="red")
240
+ elif status == data.INTERRUPTED:
241
+ text.append(
242
+ "\n✖ engine gone — run was interrupted · press e to resume",
243
+ style="bold red",
244
+ )
245
+ if decision is not None and status not in (
246
+ data.FINISHED,
247
+ data.INTERRUPTED,
248
+ data.CRASHED,
249
+ ):
250
+ dw_id, question = decision
251
+ text.append(f"\n⚑ decision needed: {dw_id}", style="bold yellow")
252
+ if question:
253
+ text.append(f" — {_short(question, 100)}", style="yellow")
254
+ text.append("\n press a to attach and answer", style="bold yellow")
255
+ self.update(text)
256
+
257
+
258
+ # ------------------------------------------------------------ journal lines
259
+
260
+ # kind substrings -> style, first match wins; anything else renders dim
261
+ _JOURNAL_STYLES = (
262
+ ("escalation-resolved", "green"), # positive — must precede the "escalat" -> red rule
263
+ ("escalat", "red"),
264
+ ("failed", "red"),
265
+ ("done", "green"),
266
+ ("complete", "green"),
267
+ ("finished", "green"),
268
+ ("decision", "yellow"),
269
+ ("deferred", "yellow"),
270
+ ("boundary", "yellow"),
271
+ ("truncated", "yellow"),
272
+ ("start", "cyan"),
273
+ ("resume", "cyan"),
274
+ )
275
+
276
+
277
+ # metadata fields not worth a column on every line; log_task/log_pos drive
278
+ # the journal -> log jump, not the human
279
+ _JOURNAL_HIDDEN_FIELDS = ("ts", "kind", "log_task", "log_pos")
280
+
281
+ # Row-grid geometry. The fields column's left edge sits at
282
+ # _JOURNAL_CLOCK_WIDTH + _JOURNAL_COL_PAD + _JOURNAL_KIND_WIDTH + _JOURNAL_COL_PAD;
283
+ # the hanging-indent test derives its indent from the same constants so the two
284
+ # can't silently drift apart.
285
+ _JOURNAL_CLOCK_WIDTH = 8
286
+ _JOURNAL_KIND_WIDTH = 24
287
+ _JOURNAL_COL_PAD = 1 # per-column right pad in the row grid
288
+
289
+
290
+ def journal_line(entry: dict[str, Any]) -> Table:
291
+ kind = str(entry.get("kind", "?"))
292
+ style = next((s for sub, s in _JOURNAL_STYLES if sub in kind), "dim")
293
+ ts = entry.get("ts")
294
+ clock = ""
295
+ if isinstance(ts, (int, float)):
296
+ clock = time.strftime("%H:%M:%S", time.localtime(ts))
297
+ fields = " ".join(
298
+ f"{k}={_short(v)}" for k, v in entry.items() if k not in _JOURNAL_HIDDEN_FIELDS
299
+ )
300
+ # A grid per row so the fields cell folds within its own column (hanging
301
+ # indent) instead of wrapping back under the clock/kind columns. A long kind
302
+ # likewise folds within its own column rather than spilling into the fields.
303
+ grid = Table.grid(padding=(0, _JOURNAL_COL_PAD, 0, 0))
304
+ grid.add_column(width=_JOURNAL_CLOCK_WIDTH)
305
+ grid.add_column(width=_JOURNAL_KIND_WIDTH, overflow="fold")
306
+ grid.add_column(overflow="fold")
307
+ grid.add_row(Text(clock, style="dim"), Text(kind, style=style), Text(fields))
308
+ return grid
309
+
310
+
311
+ class JournalEntryOption(Option):
312
+ """One journal entry as an OptionList row; carries the raw entry so
313
+ selecting it can jump to the entry's position in the pane log."""
314
+
315
+ def __init__(self, entry: dict[str, Any]) -> None:
316
+ super().__init__(journal_line(entry))
317
+ self.entry = entry
318
+
319
+
320
+ def _short(value: Any, limit: int = 60) -> str:
321
+ s = str(value)
322
+ return s if len(s) <= limit else s[: limit - 1] + "…"
323
+
324
+
325
+ # --------------------------------------------------------- validate findings
326
+ #
327
+ # A structural rendering of the `validate --json` document (documents.py), for
328
+ # the TUI's validate modal. The text mode's severities are string prefixes and
329
+ # its verdict is an exit code; here both are fields, and the `detail` each check
330
+ # carried before it flattened itself into a sentence is renderable.
331
+
332
+ # The document schema version this renderer was written against — a HAND-WRITTEN
333
+ # literal, deliberately *not* an import of documents.VALIDATE_SCHEMA_VERSION.
334
+ #
335
+ # This is load-bearing. An import would auto-follow a CLI schema bump, and the
336
+ # fields read below would very likely still resolve against a v2 document, so
337
+ # the result would not be a refusal — it would be a quietly wrong modal in a
338
+ # user's terminal, with the suite green. Pinning the literal makes a bump a
339
+ # deliberate edit *here*, after re-reading this renderer against the new
340
+ # document, and a tripwire test fails the moment the two diverge.
341
+ _RENDERS_VALIDATE_SCHEMA = 1
342
+
343
+ # Finding severities (checks.py: ok/warning/problem). A DIFFERENT vocabulary
344
+ # from _SEVERITY_STYLES further down, which styles deferred-work items
345
+ # (critical/high/medium/low) — separate maps on purpose, no reuse. Both are
346
+ # read through .get() with a fallback: severity arrives from a subprocess
347
+ # document, so an unrecognised string must render neutrally, never KeyError.
348
+ _FINDING_STYLES = {
349
+ "ok": "green",
350
+ "warning": "yellow",
351
+ "problem": "bold red",
352
+ }
353
+
354
+ _FINDING_GLYPHS = {
355
+ "ok": "✓",
356
+ "warning": "!",
357
+ "problem": "✖",
358
+ }
359
+
360
+ # Row-grid geometry, mirroring the journal's. The check column is wide enough
361
+ # for the longest id in checks.VALIDATE_CHECKS ("queue.sprint-status-unknown-keys")
362
+ # so ids do not fold in practice; it folds rather than truncates if one grows.
363
+ # The message column's left edge is the sum of everything before it, and the
364
+ # alignment test derives its indent from these same constants so the two cannot
365
+ # silently drift apart.
366
+ _FINDING_GLYPH_WIDTH = 1
367
+ _FINDING_CHECK_WIDTH = 32
368
+ _FINDING_COL_PAD = 1 # per-column right pad in the row grid
369
+
370
+
371
+ def validate_document(stdout: str) -> dict | None:
372
+ """Parse `validate --json` stdout into a document this renderer can draw.
373
+
374
+ Returns ``None`` — **never raises** — for anything undrawable: unparseable
375
+ stdout, a schema version this renderer was not written against, or a
376
+ document whose shape is not the one the renderer walks. The caller's
377
+ degrade is then a value check rather than an exception, which matters
378
+ because the caller runs on a worker thread where an escaping exception
379
+ takes the app down.
380
+
381
+ The version check is an equality, not a ``>=``: a *newer* document is
382
+ exactly the case that must degrade rather than be half-rendered.
383
+ """
384
+ try:
385
+ doc = json.loads(stdout)
386
+ except (ValueError, TypeError):
387
+ return None
388
+ if not isinstance(doc, dict):
389
+ return None
390
+ if doc.get("schema_version") != _RENDERS_VALIDATE_SCHEMA:
391
+ return None
392
+ if not isinstance(doc.get("findings"), list):
393
+ return None
394
+ if not isinstance(doc.get("counts"), dict):
395
+ return None
396
+ return doc
397
+
398
+
399
+ def _is_scalar(value: Any) -> bool:
400
+ return value is None or isinstance(value, (str, int, float, bool))
401
+
402
+
403
+ def _detail_scalar(value: Any) -> str:
404
+ """One leaf as text, in JSON's spelling — ``true``/``null``, not Python's
405
+ ``True``/``None`` — so a rendered detail reads as the document it came from."""
406
+ if value is None:
407
+ return "null"
408
+ if isinstance(value, bool):
409
+ return "true" if value else "false"
410
+ return str(value)
411
+
412
+
413
+ def _detail_pairs(mapping: dict) -> str:
414
+ return ", ".join(f"{k}={_detail_scalar(v)}" for k, v in mapping.items())
415
+
416
+
417
+ def _json_leaf(value: Any) -> str:
418
+ """A value too deep or too odd for the depth-2 walk, as JSON.
419
+
420
+ Never ``str()``: that prints a Python repr (``{'dev': 'claude'}``) at the
421
+ user, which is the exact tell that a renderer met a shape it did not model.
422
+ """
423
+ try:
424
+ return json.dumps(value, ensure_ascii=False, default=str)
425
+ except (TypeError, ValueError): # unreachable for json.loads output; this renders, never raises
426
+ return str(value)
427
+
428
+
429
+ def _detail_lines(detail: Any) -> list[str]:
430
+ """A finding's ``detail`` as flat text rows: **depth-2, not recursive.**
431
+
432
+ The real shapes, enumerated from every check site (cli.py's validate gates
433
+ and platform preflight, install.py's skill probes): ``detail`` is one dict
434
+ whose values are a scalar (``str``/``int``/``bool``/``None`` — ``mux.backend``'s
435
+ ``version`` is ``str | None``), a list of scalars (``missing_markers``,
436
+ ``unknown_keys``, ``trees``), a dict of scalars (``policy``'s ``adapters`` —
437
+ a nested dict, on the *passing* path, in every successful validate), or a
438
+ list of dicts of scalars (``mux.backends-detected``, six keys per backend).
439
+ Two levels covers all of them.
440
+
441
+ Anything deeper or otherwise unmodelled falls back to :func:`_json_leaf`
442
+ rather than recursing: recursion is more machinery than this data justifies
443
+ and is unbounded for a payload nobody has written yet, whereas the fallback
444
+ is correct for any depth and still legible.
445
+ """
446
+ if not isinstance(detail, dict):
447
+ # detail is `dict | None` by contract; anything else still renders.
448
+ return [] if detail is None else [_json_leaf(detail)]
449
+ lines: list[str] = []
450
+ for key, value in detail.items():
451
+ if _is_scalar(value):
452
+ lines.append(f"{key}: {_detail_scalar(value)}")
453
+ elif isinstance(value, list) and all(_is_scalar(v) for v in value):
454
+ lines.append(f"{key}: {', '.join(_detail_scalar(v) for v in value) or '—'}")
455
+ elif isinstance(value, dict) and all(_is_scalar(v) for v in value.values()):
456
+ lines.append(f"{key}: {_detail_pairs(value) or '—'}")
457
+ elif isinstance(value, list) and all(
458
+ isinstance(v, dict) and all(_is_scalar(x) for x in v.values()) for v in value
459
+ ):
460
+ # list[dict] gets a line per entry, so six-key backend rows stay
461
+ # readable instead of becoming one unreadable joined string.
462
+ lines.append(f"{key}:")
463
+ lines.extend(f" {_detail_pairs(v) or '—'}" for v in value)
464
+ else:
465
+ lines.append(f"{key}: {_json_leaf(value)}")
466
+ return lines
467
+
468
+
469
+ def _finding_rows(finding: Any, *, details: bool) -> list[tuple[Text, Text, Text]]:
470
+ """One finding as its grid rows: the finding itself, then its detail lines.
471
+
472
+ Detail shows inline for ``warning`` and ``problem`` — the findings someone
473
+ opened the modal to act on — and for everything when ``details``. One
474
+ severity rule, and zero matching on ``check`` ids: ids are the contracted
475
+ identity, but keying layout off them would make every new check a renderer
476
+ edit.
477
+ """
478
+ if not isinstance(finding, dict):
479
+ raise TypeError("finding is not a dict")
480
+ severity = finding.get("severity")
481
+ if not isinstance(severity, str):
482
+ severity = ""
483
+ style = _FINDING_STYLES.get(severity, "")
484
+ check = finding.get("check")
485
+ rows = [
486
+ (
487
+ Text(_FINDING_GLYPHS.get(severity, "?"), style=style),
488
+ Text("?" if check is None else str(check), style=style),
489
+ Text(str(finding.get("message", ""))),
490
+ )
491
+ ]
492
+ if details or severity in ("warning", "problem"):
493
+ rows += [
494
+ (Text(""), Text(""), Text(line, style="dim"))
495
+ for line in _detail_lines(finding.get("detail"))
496
+ ]
497
+ return rows
498
+
499
+
500
+ def validate_findings(doc: dict, *, details: bool) -> Table:
501
+ """Every finding as **one** grid: glyph, ``check`` id, message + detail rows.
502
+
503
+ One grid for all findings rather than one per finding, which is what buys
504
+ column alignment *across* findings — the check ids line up into a readable
505
+ column instead of each row sizing itself. (journal_line builds a grid per
506
+ row for the mirror-image reason: there each row is alone and only its own
507
+ columns need to align.)
508
+
509
+ Two separate mechanisms keep it legible, both load-bearing:
510
+
511
+ - **The grid itself** handles multi-line messages. ``message`` may carry
512
+ newlines — the config, sprint-status and stories loaders put a PyYAML
513
+ ``MarkedYAMLError`` straight into ``str(e)`` — and in a flat ``Text`` an
514
+ embedded newline returns to column 0, destroying the alignment of every
515
+ row below it. In a cell it stays inside its column.
516
+ - **``overflow="fold"``** handles the long *unbroken* runs that wrapping
517
+ cannot break: absolute paths, joined marker lists, a ``check`` id longer
518
+ than its column. Folding wraps them; the default would truncate with an
519
+ ellipsis, silently dropping the end of a path someone needs to read.
520
+
521
+ Defensive **per finding**: a finding that is not a dict, or whose fields
522
+ are not what this walks, costs its own placeholder row and nothing more.
523
+ One malformed entry must not blank the modal a reader is using to find out
524
+ what is wrong.
525
+ """
526
+ grid = Table.grid(padding=(0, _FINDING_COL_PAD, 0, 0))
527
+ grid.add_column(width=_FINDING_GLYPH_WIDTH)
528
+ grid.add_column(width=_FINDING_CHECK_WIDTH, overflow="fold")
529
+ grid.add_column(overflow="fold")
530
+ findings = doc.get("findings")
531
+ if not isinstance(findings, list):
532
+ findings = []
533
+ for finding in findings:
534
+ try:
535
+ rows = _finding_rows(finding, details=details)
536
+ except Exception: # a bad finding costs its row, never the modal
537
+ rows = [
538
+ (
539
+ Text("?", style="dim"),
540
+ Text("?", style="dim"),
541
+ Text("(unreadable finding)", style="dim"),
542
+ )
543
+ ]
544
+ for row in rows:
545
+ grid.add_row(*row)
546
+ return grid
547
+
548
+
549
+ def validate_header(doc: dict) -> Text:
550
+ """The verdict line, built from the document's ``ok`` — never from the
551
+ subprocess exit code, which conflates "checks failed" with "the command
552
+ broke". The document is the thing that actually knows which happened.
553
+
554
+ When any problem is present, a dim footer says the gates are chained. That
555
+ puts documents.py's "absence is not a pass" on screen: a policy failure
556
+ leaves the binary, hook and skill gates emitting *nothing at all*, so a
557
+ short findings list after a failure is not a short list of problems. It is
558
+ the one thing this rendering can teach that the text mode cannot.
559
+ """
560
+ ok = doc.get("ok")
561
+ text = Text()
562
+ if ok is True:
563
+ text.append("✓ validate passed", style="green")
564
+ elif ok is False:
565
+ text.append("✖ validate failed", style="bold red")
566
+ else:
567
+ text.append("? validate verdict unknown", style="dim")
568
+
569
+ counts = doc.get("counts")
570
+ if not isinstance(counts, dict):
571
+ counts = {}
572
+ tallies = [
573
+ f"{counts[severity]} {severity}"
574
+ for severity in ("ok", "warning", "problem")
575
+ if isinstance(counts.get(severity), int) and not isinstance(counts.get(severity), bool)
576
+ ]
577
+ if tallies:
578
+ text.append(" " + " · ".join(tallies), style="dim")
579
+
580
+ meta = []
581
+ mode = doc.get("mode")
582
+ if isinstance(mode, str) and mode:
583
+ meta.append(f"mode: {mode}")
584
+ # spec_folder is user-controlled; it goes into a Text, never into markup.
585
+ spec_folder = doc.get("spec_folder")
586
+ if isinstance(spec_folder, str) and spec_folder:
587
+ meta.append(f"spec: {spec_folder}")
588
+ if meta:
589
+ text.append("\n" + " · ".join(meta), style="dim")
590
+
591
+ problems = counts.get("problem")
592
+ if isinstance(problems, int) and not isinstance(problems, bool) and problems > 0:
593
+ text.append(
594
+ "\ngates are chained — checks after a failure may not have run",
595
+ style="dim italic",
596
+ )
597
+ return text
598
+
599
+
600
+ # ------------------------------------------------------------- sprint tree
601
+
602
+ # Story/retro statuses -> glyph + style. Statuses come from an LLM-maintained
603
+ # file, so lookups always .get() with a "?"/dim fallback, never KeyError.
604
+ SPRINT_GLYPHS = {
605
+ "done": "✓",
606
+ "in-progress": "▶",
607
+ "review": "◆",
608
+ "ready-for-dev": "○",
609
+ "awaiting-operator": "⏸",
610
+ "backlog": "·",
611
+ "optional": "·",
612
+ }
613
+
614
+ SPRINT_STYLES = {
615
+ "done": "green",
616
+ "in-progress": "cyan",
617
+ "review": "magenta",
618
+ "ready-for-dev": "cyan",
619
+ # yellow, matching the run header's deferred count: work is finished but the
620
+ # story is not, and something outside the loop has to happen next.
621
+ "awaiting-operator": "yellow",
622
+ "backlog": "dim",
623
+ "optional": "dim",
624
+ }
625
+
626
+
627
+ def sprint_story_label(story: Story) -> Text:
628
+ glyph = SPRINT_GLYPHS.get(story.status, "?")
629
+ style = SPRINT_STYLES.get(story.status, "dim")
630
+ return Text(f"{glyph} {story.num}{story.suffix}-{story.slug}", style=style)
631
+
632
+
633
+ def sprint_retro_label(status: str) -> Text:
634
+ glyph = SPRINT_GLYPHS.get(status, "?")
635
+ style = SPRINT_STYLES.get(status, "dim")
636
+ return Text(f"{glyph} retrospective", style=style)
637
+
638
+
639
+ def sprint_epic_label(num: int, status: str, done: int, total: int) -> Text:
640
+ complete = status == "done" or (total > 0 and done == total)
641
+ text = Text()
642
+ text.append(f"Epic {num}", style="green" if complete else "bold")
643
+ if total:
644
+ text.append(f" · {done}/{total}", style="green" if complete else "dim")
645
+ if complete:
646
+ text.append(" ✓", style="green")
647
+ return text
648
+
649
+
650
+ class SprintTree(Tree[str]):
651
+ """Sprint status as expandable epics with their stories and retro.
652
+
653
+ Refreshed every rescan tick, so updates reconcile in place: existing
654
+ nodes only get set_label(), which keeps expansion state and the cursor.
655
+ Children are rebuilt only when an epic's story set actually changes.
656
+ Node data is the sprint-status key ("epic-2", "2-1-slug", ...)."""
657
+
658
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
659
+ super().__init__(*args, **kwargs)
660
+ self.show_root = False
661
+ self.guide_depth = 2
662
+ self._epic_nodes: dict[int, TreeNode[str]] = {}
663
+ self._epic_child_keys: dict[int, tuple[str, ...]] = {}
664
+ self._placeholder = True
665
+ self.update_sprint(None)
666
+
667
+ def _show_placeholder(self, label: str) -> None:
668
+ self.clear()
669
+ self._epic_nodes.clear()
670
+ self._epic_child_keys.clear()
671
+ self.root.add_leaf(Text(label, style="dim"))
672
+ self._placeholder = True
673
+
674
+ def update_sprint(self, ss: SprintStatus | None) -> None:
675
+ if ss is None:
676
+ self._show_placeholder("sprint status unavailable")
677
+ return
678
+ stories_by_epic: dict[int, list[Story]] = {}
679
+ for story in ss.stories:
680
+ stories_by_epic.setdefault(story.epic, []).append(story)
681
+ epic_nums = sorted(set(ss.epics) | set(stories_by_epic) | set(ss.retros))
682
+ if not epic_nums:
683
+ self._show_placeholder("no sprint data")
684
+ return
685
+ if self._placeholder:
686
+ self.clear()
687
+ self._placeholder = False
688
+ for num in [n for n in self._epic_nodes if n not in epic_nums]:
689
+ self._epic_nodes.pop(num).remove()
690
+ self._epic_child_keys.pop(num, None)
691
+ for num in epic_nums:
692
+ stories = stories_by_epic.get(num, [])
693
+ retro = ss.retros.get(num)
694
+ label = sprint_epic_label(
695
+ num,
696
+ ss.epics.get(num, ""),
697
+ sum(s.status == "done" for s in stories),
698
+ len(stories),
699
+ )
700
+ node = self._epic_nodes.get(num)
701
+ if node is None:
702
+ node = self.root.add(label, data=f"epic-{num}")
703
+ self._epic_nodes[num] = node
704
+ else:
705
+ node.set_label(label)
706
+ child_keys = tuple(s.key for s in stories)
707
+ child_labels = [sprint_story_label(s) for s in stories]
708
+ if retro is not None:
709
+ child_keys += (f"epic-{num}-retrospective",)
710
+ child_labels.append(sprint_retro_label(retro))
711
+ if self._epic_child_keys.get(num) == child_keys:
712
+ for child, child_label in zip(node.children, child_labels):
713
+ child.set_label(child_label)
714
+ else:
715
+ node.remove_children()
716
+ for key, child_label in zip(child_keys, child_labels):
717
+ node.add_leaf(child_label, data=key)
718
+ self._epic_child_keys[num] = child_keys
719
+
720
+
721
+ # ------------------------------------------------------------- stories table
722
+
723
+ # Story on-disk state (stories.state_label) -> glyph + style. The label may be a
724
+ # `sentinel:<kind>` composite, so lookups key on the token before ':'.
725
+ STORY_GLYPHS = {
726
+ "pending": "·",
727
+ "draft": "◦",
728
+ "ready-for-dev": "○",
729
+ "in-progress": "▶",
730
+ "in-review": "◆",
731
+ "done": "✓",
732
+ "awaiting-operator": "⏸",
733
+ "blocked": "✖",
734
+ "ambiguous": "⚠",
735
+ "sentinel": "⚠",
736
+ }
737
+
738
+ STORY_STYLES = {
739
+ "pending": "dim",
740
+ "draft": "dim",
741
+ "ready-for-dev": "cyan",
742
+ "in-progress": "cyan",
743
+ "in-review": "magenta",
744
+ "done": "green",
745
+ "awaiting-operator": "yellow",
746
+ "blocked": "bold red",
747
+ "ambiguous": "bold red",
748
+ "sentinel": "bold red",
749
+ }
750
+
751
+
752
+ def story_state_cell(label: str) -> Text:
753
+ key = label.split(":", 1)[0]
754
+ return Text(f"{STORY_GLYPHS.get(key, '?')} {label}", style=STORY_STYLES.get(key, "dim"))
755
+
756
+
757
+ def story_checkpoint_cell(spec_checkpoint: bool, done_checkpoint: bool) -> Text:
758
+ """Independent spec/done checkpoint markers as one compact cell: `S` (plan
759
+ review before code, magenta), `D` (review after commit, cyan), dim `·` for an
760
+ unset slot so the two stay positionally readable."""
761
+ text = Text()
762
+ text.append("S" if spec_checkpoint else "·", style="magenta" if spec_checkpoint else "dim")
763
+ text.append("D" if done_checkpoint else "·", style="cyan" if done_checkpoint else "dim")
764
+ return text
765
+
766
+
767
+ class StoriesTable(DataTable):
768
+ """The stories-mode board — one row per stories.yaml entry with its live
769
+ on-disk state, replacing the sprint tree when a stories-mode run is selected.
770
+
771
+ Reconciles in place each rescan tick (stable row key = story id): the id set
772
+ is stable within a run, so existing rows only get cell updates, keeping the
773
+ cursor. Rebuilds only when the id set/order actually changes (a between-runs
774
+ Story Breakdown re-derive)."""
775
+
776
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
777
+ super().__init__(*args, **kwargs)
778
+ self.cursor_type = "row"
779
+ self.zebra_stripes = True
780
+ self._row_ids: list[str] | None = None # None: placeholder/empty shown
781
+
782
+ def on_mount(self) -> None:
783
+ self.add_column("state", key="state", width=15)
784
+ self.add_column("id", key="id", width=8)
785
+ self.add_column("✓", key="chk", width=2)
786
+ self.add_column("title", key="title")
787
+
788
+ def _placeholder(self, label: str) -> None:
789
+ self.clear()
790
+ self.add_row(Text(label, style="dim"), "", "", "")
791
+ self._row_ids = None
792
+
793
+ def update_stories(self, rows: list[StoryRow] | None) -> None:
794
+ if rows is None:
795
+ self._placeholder("stories board unavailable")
796
+ return
797
+ if not rows:
798
+ self._placeholder("no stories")
799
+ return
800
+ ids = [r.id for r in rows]
801
+ if self._row_ids != ids:
802
+ self.clear()
803
+ for r in rows:
804
+ self.add_row(
805
+ story_state_cell(r.label),
806
+ r.id,
807
+ story_checkpoint_cell(r.spec_checkpoint, r.done_checkpoint),
808
+ _short(r.title, 48),
809
+ key=r.id,
810
+ )
811
+ self._row_ids = ids
812
+ return
813
+ for r in rows:
814
+ self.update_cell(r.id, "state", story_state_cell(r.label))
815
+ self.update_cell(
816
+ r.id, "chk", story_checkpoint_cell(r.spec_checkpoint, r.done_checkpoint)
817
+ )
818
+ self.update_cell(r.id, "title", _short(r.title, 48))
819
+
820
+
821
+ # ------------------------------------------------------------ deferred work
822
+
823
+ _SEVERITY_STYLES = {
824
+ "critical": "bold red",
825
+ "high": "red",
826
+ "medium": "yellow",
827
+ "low": "dim",
828
+ }
829
+
830
+
831
+ def deferred_line(item: data.DeferredItem) -> Text:
832
+ # single-line; the pane's text-wrap/text-overflow CSS truncates with "…"
833
+ text = Text()
834
+ if item.done:
835
+ text.append(f"{item.id} ✓ {item.title}", style="green")
836
+ else:
837
+ text.append(f"{item.id} ", style="dim")
838
+ text.append(item.title, style=_SEVERITY_STYLES.get(item.severity or "", ""))
839
+ if item.legacy:
840
+ text.append(" ·legacy", style="dim italic")
841
+ return text
842
+
843
+
844
+ class DeferredEntryOption(Option):
845
+ """One deferred-work entry as an OptionList row; carries the item so
846
+ selecting it can show the full entry body. option_id is the DW id when
847
+ unique in the ledger (used to restore the highlight across refreshes),
848
+ None for forgiveness when an LLM wrote duplicate ids."""
849
+
850
+ def __init__(self, item: data.DeferredItem, option_id: str | None = None) -> None:
851
+ super().__init__(deferred_line(item), id=option_id)
852
+ self.item = item
853
+
854
+
855
+ class SelectableRichLog(RichLog):
856
+ """RichLog that supports Textual text selection + ctrl+c copy.
857
+
858
+ Base RichLog caches rendered Strips rather than a single renderable, so the
859
+ default Widget.get_selection returns None and ctrl+c copies nothing. Rebuild
860
+ the plain text from the cached strips (as the builtin Log widget does) so
861
+ click-drag selection and ctrl+c work. wrap=False (the default, kept by the
862
+ dashboard) means one strip per logical row, so document line indices line up
863
+ with selection offsets.
864
+ """
865
+
866
+ def get_selection(self, selection: Selection) -> tuple[str, str] | None:
867
+ text = "\n".join(strip.text for strip in self.lines)
868
+ return selection.extract(text), "\n"
869
+
870
+ def selection_updated(self, selection: Selection | None) -> None:
871
+ self.refresh()
872
+
873
+
874
+ class Splitter(Widget):
875
+ """A draggable divider between two panes, drawn as a thin line so it reads
876
+ like the static border it replaces (not a filled bar). A ``horizontal``
877
+ splitter is a 1-row rule between vertically stacked panes (drag up/down); a
878
+ vertical one is a 1-column ``│`` line between side-by-side panes (drag
879
+ left/right). A horizontal splitter's ``label`` rides the rule as ``─ Sprint
880
+ ──`` so the section title that used to sit on the pane's ``border-top``
881
+ survives its removal.
882
+
883
+ The widget only measures a drag as a signed cell ``delta`` along its axis and
884
+ hands it to ``apply`` — the screen bakes in the sign and which reactive the
885
+ boundary moves. ``bump`` is the keyboard entry point (resize mode), so mouse
886
+ and keyboard drive the exact same code path. ``on_release`` fires once a drag
887
+ ends, for the screen to persist the new geometry.
888
+ """
889
+
890
+ DEFAULT_CSS = """
891
+ Splitter {
892
+ color: $primary-darken-2; /* the line color; matches the old borders */
893
+ background: transparent;
894
+ }
895
+ Splitter.-vertical {
896
+ width: 1;
897
+ height: 1fr;
898
+ }
899
+ Splitter.-horizontal {
900
+ height: 1;
901
+ width: 1fr;
902
+ }
903
+ Splitter:hover, Splitter.-active, Splitter.-dragging {
904
+ color: $accent; /* brighten the line while hovered / grabbed */
905
+ }
906
+ """
907
+
908
+ def __init__(
909
+ self,
910
+ *,
911
+ horizontal: bool,
912
+ apply: Callable[[int], None],
913
+ on_release: Callable[[], None],
914
+ label: str = "",
915
+ id: str | None = None,
916
+ ) -> None:
917
+ super().__init__(id=id)
918
+ self._horizontal = horizontal
919
+ self._apply = apply
920
+ self._on_release = on_release
921
+ self._label = label
922
+ self._last: int | None = None # last drag coordinate; None = not dragging
923
+ self.add_class("-horizontal" if horizontal else "-vertical")
924
+ self.tooltip = "drag to resize (or Ctrl+W)"
925
+
926
+ @property
927
+ def label(self) -> str:
928
+ return self._label
929
+
930
+ def set_label(self, label: str) -> None:
931
+ if label != self._label:
932
+ self._label = label
933
+ self.refresh()
934
+
935
+ def render_line(self, y: int) -> Strip:
936
+ width = self.size.width
937
+ if width <= 0:
938
+ return Strip([])
939
+ base = self.rich_style
940
+ if not self._horizontal:
941
+ return Strip([Segment("│", base)], width)
942
+ if self._label:
943
+ # `─ Label ───…` — a left-set title on a box-drawing rule, echoing the
944
+ # old border-title. The label is bold so it reads as a heading.
945
+ head = f"─ {self._label} "[:width]
946
+ tail = "─" * max(0, width - len(head))
947
+ return Strip([Segment(head, base + Style(bold=True)), Segment(tail, base)], width)
948
+ return Strip([Segment("─" * width, base)], width)
949
+
950
+ def _coord(self, event: events.MouseEvent) -> int:
951
+ return event.screen_y if self._horizontal else event.screen_x
952
+
953
+ def on_mouse_down(self, event: events.MouseDown) -> None:
954
+ self._last = self._coord(event)
955
+ self.capture_mouse()
956
+ self.add_class("-dragging")
957
+ event.stop()
958
+
959
+ def on_mouse_move(self, event: events.MouseMove) -> None:
960
+ if self._last is None:
961
+ return # hover, not a drag
962
+ pos = self._coord(event)
963
+ delta = pos - self._last
964
+ if delta:
965
+ self._last = pos
966
+ self._apply(delta)
967
+ event.stop()
968
+
969
+ def on_mouse_up(self, event: events.MouseUp) -> None:
970
+ if self._last is None:
971
+ return
972
+ self._last = None
973
+ self.release_mouse()
974
+ self.remove_class("-dragging")
975
+ self._on_release()
976
+ event.stop()
977
+
978
+ def bump(self, delta: int) -> None:
979
+ """Keyboard-driven nudge (resize mode). Persistence is handled by the
980
+ screen when the mode exits, so this does not call ``on_release``."""
981
+ self._apply(delta)