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,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)
|