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,1071 @@
1
+ """Live read-only dashboard: run picker, run detail, journal/log/sprint tails.
2
+
3
+ Polling model: a 1s interval kicks an exclusive thread worker that does all
4
+ filesystem I/O through the stat-gated readers in tui.data and produces an
5
+ immutable snapshot; the snapshot is applied to widgets back on the event loop.
6
+ Selecting a run replaces the whole poll context and bumps a generation
7
+ counter, so a stale in-flight snapshot for the previous run is dropped on
8
+ arrival rather than painted over the new one. The run list itself is
9
+ selection-independent and is always applied.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import threading
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from rich.text import Text
21
+ from textual import events, work
22
+ from textual.app import ComposeResult
23
+ from textual.binding import Binding
24
+ from textual.containers import Horizontal, Vertical
25
+ from textual.reactive import reactive
26
+ from textual.screen import Screen
27
+ from textual.widgets import (
28
+ DataTable,
29
+ Footer,
30
+ Header,
31
+ OptionList,
32
+ RichLog,
33
+ TabbedContent,
34
+ TabPane,
35
+ )
36
+ from textual.widgets.option_list import Option, OptionDoesNotExist
37
+
38
+ from ... import policy as policy_mod
39
+ from ... import sprintstatus, stories
40
+ from ...model import RunState, StoryTask
41
+ from ...runs import RUNS_DIR
42
+ from .. import data, launch
43
+ from ..widgets import (
44
+ DeferredEntryOption,
45
+ JournalEntryOption,
46
+ RunHeader,
47
+ SelectableRichLog,
48
+ Splitter,
49
+ SprintTree,
50
+ StoriesTable,
51
+ agent_label,
52
+ pause_tag,
53
+ status_cell,
54
+ stopping_tag,
55
+ )
56
+ from .modals import DeferredEntryModal
57
+
58
+ # Resizable-pane geometry, in terminal cells. The MIN_* floors keep a pane from
59
+ # collapsing to nothing when a boundary is dragged or the terminal shrinks; they
60
+ # mirror the `min-height: 4` backstops still in the CSS.
61
+ _MIN_SIDEBAR = 20
62
+ _MIN_DETAIL = 30
63
+ _MIN_PANE = 4 # a stacked left-column pane (Runs / Deferred)
64
+ _MIN_TASKS = 3
65
+ _MIN_TABS = 5
66
+ # The horizontal splitters cycled by Tab in resize mode, top to bottom.
67
+ _HSPLITS = ("#split-runs", "#split-deferred", "#split-tasks")
68
+ # Resize-mode actions gated off (key passes through to the focused widget) unless
69
+ # the mode is active. Tab/Shift+Tab are intentionally absent: they stay live and
70
+ # delegate to focus movement when not resizing.
71
+ _RESIZE_ACTIONS = frozenset(
72
+ {"resize_up", "resize_down", "resize_left", "resize_right", "resize_done"}
73
+ )
74
+
75
+ # Keep at most this many parsed journal entries per run for active-task
76
+ # tracking; the visible pane is bounded separately per widget.
77
+ _MAX_ENTRIES = 500
78
+
79
+ _MAX_JOURNAL_OPTIONS = 2000 # visible journal rows kept in the OptionList
80
+
81
+ _RESCAN_EVERY = 3 # run-list + sprint rescan cadence, in 1s ticks
82
+
83
+ _LAUNCH_TIMEOUT = 10.0 # seconds before a pending launch is presumed failed
84
+
85
+ _UNAPPLIED: Any = object() # "no snapshot applied yet" for the identity gates
86
+
87
+
88
+ def _transcript_for_task(state: RunState, task_id: str) -> str | None:
89
+ """The agent JSONL transcript recorded for a log task (the per-session
90
+ task id matching the `<task>.log` name), newest session wins. Used to point
91
+ a fullscreen-capture notice at the complete session record."""
92
+ found: str | None = None
93
+ for story in state.tasks.values():
94
+ for rec in story.sessions:
95
+ if rec.task_id == task_id and rec.transcript_path:
96
+ found = rec.transcript_path
97
+ return found
98
+
99
+
100
+ class _PollContext:
101
+ """Mutable state for polling one selected run. Constructed on the UI
102
+ thread (constructors do no I/O), then mutated only inside the poll worker.
103
+
104
+ Forced ticks (journal jumps, run select) reuse self._ctx, so a superseded
105
+ worker can still hold THIS object — and exclusive=True cannot stop a
106
+ running thread, only mark it cancelled. The screen's _poll_lock therefore
107
+ serializes worker bodies so this state (and ctx.log's pyte stream) is never
108
+ mutated by two threads at once."""
109
+
110
+ def __init__(self, run_dir: Path):
111
+ self.run_dir = run_dir
112
+ self.watcher = data.RunWatcher(run_dir)
113
+ self.journal = data.JournalTail(run_dir)
114
+ self.entries: list[dict[str, Any]] = []
115
+ self.log: data.LogView | None = None
116
+ self.log_task: str | None = None
117
+ self.attention_seen = 0
118
+ self.first_poll = True
119
+ self.decision_toasted: str | None = None # dw_id already announced
120
+
121
+
122
+ @dataclass
123
+ class _Snapshot:
124
+ generation: int
125
+ runs: list[data.RunInfo] | None = None # None: no rescan this tick
126
+ project_refreshed: bool = False # sprint + deferred rescanned this tick
127
+ missed_decisions: int = 0 # decisions past sweeps left unanswered
128
+ sprint: sprintstatus.SprintStatus | None = None
129
+ deferred: list[data.DeferredItem] | None = None
130
+ has_run: bool = False
131
+ run_id: str = ""
132
+ status: str = data.UNKNOWN
133
+ stopping: bool = False # selected run has a stop request pending, either mode (RUNNING only)
134
+ agent: data.ActiveAgent | None = None # agent driving the selected run, live only
135
+ state: RunState | None = None
136
+ stories_mode: bool = False # selected run is stories mode (source == "stories")
137
+ stories: list[stories.StoryRow] | None = None # stories board rows, when stories_mode
138
+ new_entries: list[dict[str, Any]] = field(default_factory=list)
139
+ log_task: str | None = None
140
+ log_reset: bool = False
141
+ log_lines: Text | None = None # full re-render; None = unchanged this tick
142
+ log_index: data.LogIndex | None = None # rebuilt alongside log_lines
143
+ log_pinned: bool = False
144
+ log_altscreen: bool = False # the capture entered a fullscreen (alt-screen) TUI
145
+ log_transcript: str | None = None # agent JSONL transcript, when an altscreen log has one
146
+ attention_reset: bool = False
147
+ new_attention: str = ""
148
+ toast_attention: bool = False
149
+ decision: tuple[str, str] | None = None # (dw_id, question) awaiting a human
150
+ toast_decision: bool = False
151
+
152
+
153
+ class DashboardScreen(Screen[None]):
154
+ BINDINGS = [
155
+ Binding("escape", "unpin_log", "follow log", show=False),
156
+ Binding("y", "copy_pane", "copy"),
157
+ Binding("ctrl+w", "resize_mode", "resize"),
158
+ # Priority so they beat the focused table/tree/list in resize mode;
159
+ # check_action() disables them (key passes through) when not resizing.
160
+ Binding("up", "resize_up", show=False, priority=True),
161
+ Binding("down", "resize_down", show=False, priority=True),
162
+ Binding("left", "resize_left", show=False, priority=True),
163
+ Binding("right", "resize_right", show=False, priority=True),
164
+ Binding("enter", "resize_done", show=False, priority=True),
165
+ # Tab stays live: it cycles the active boundary in resize mode and falls
166
+ # back to focus movement otherwise (see action_resize_cycle).
167
+ Binding("tab", "resize_cycle(1)", show=False, priority=True),
168
+ Binding("shift+tab", "resize_cycle(-1)", show=False, priority=True),
169
+ ]
170
+
171
+ # Persisted pane geometry (cells); 0 = unset -> keep the CSS default. Seeded
172
+ # after first layout (and from policy) and mutated by drags / resize mode.
173
+ left_width: reactive[int] = reactive(0, init=False)
174
+ runs_height: reactive[int] = reactive(0, init=False)
175
+ deferred_height: reactive[int] = reactive(0, init=False)
176
+ tasks_height: reactive[int] = reactive(0, init=False)
177
+
178
+ def __init__(self, project: Path):
179
+ super().__init__()
180
+ self.project = project
181
+ # Persisted pane sizes to seed on first layout; a malformed or unreadable
182
+ # policy file degrades to defaults rather than blocking the dashboard. That
183
+ # now covers a file whose BYTES are unreadable too — `policy.load` converts
184
+ # the `UnicodeDecodeError` rather than letting a ValueError past a handler
185
+ # that runs before the app can draw anything.
186
+ try:
187
+ self._tui_policy = policy_mod.load(project / policy_mod.POLICY_FILE).tui
188
+ except (policy_mod.PolicyError, OSError):
189
+ self._tui_policy = policy_mod.TuiPolicy()
190
+ self._seeded = False
191
+ self._left_frozen = False # Runs/Deferred switched to explicit heights
192
+ self._detail_frozen = False # Tasks switched to an explicit height
193
+ self._resize_mode = False
194
+ self._active_hsplit = 0 # index into _HSPLITS for Up/Down in resize mode
195
+ self._saved_subtitle = ""
196
+ self._generation = 0
197
+ self._ctx: _PollContext | None = None
198
+ self._tick_count = 0
199
+ self._run_rows: list[str] = [] # row keys, table order (oldest first)
200
+ self._task_rows: set[str] = set()
201
+ self._pending_run: str | None = None # just-launched run, no state.json yet
202
+ self._pending_deadline = 0.0
203
+ self._decision: tuple[str, str] | None = None
204
+ # identity gates: the stat-gated readers return the same object while
205
+ # the file is unchanged, so `is` detects "nothing to repaint"; the
206
+ # sentinel makes the first snapshot always paint (even a None one)
207
+ self._last_sprint: Any = _UNAPPLIED
208
+ self._last_deferred: Any = _UNAPPLIED
209
+ # serializes the poll worker body: exclusive=True marks superseded
210
+ # thread workers cancelled but cannot stop them, so without this two
211
+ # threads could feed ctx.log's pyte stream at once (crash)
212
+ self._poll_lock = threading.Lock()
213
+ # journal -> log jump state, all owned by the UI thread
214
+ self._log_index: data.LogIndex | None = None
215
+ self._displayed_log_task: str | None = None
216
+ self._pin_task: str | None = None # show this task's log instead of the active one
217
+ self._pending_jump: tuple[str, int] | None = None # (task_id, log_pos)
218
+ self._log_follow_tail = True # stick to newest log lines until a jump pins us
219
+
220
+ def compose(self) -> ComposeResult:
221
+ yield Header()
222
+ with Horizontal():
223
+ with Vertical(id="left"):
224
+ runs = DataTable(id="runs", cursor_type="row")
225
+ runs.border_title = "Runs" # first pane: keeps its own titled border
226
+ yield runs
227
+ # The section titles that used to ride each pane's border-top now
228
+ # ride the splitter bar above that pane (its border-top is dropped
229
+ # in CSS). split-runs' label tracks the sprint/stories toggle.
230
+ yield Splitter(
231
+ horizontal=True,
232
+ label="Sprint",
233
+ apply=self._resize_runs,
234
+ on_release=self._persist_geometry,
235
+ id="split-runs",
236
+ )
237
+ yield SprintTree("sprint", id="sprint-tree")
238
+ yield StoriesTable(id="stories-table")
239
+ yield Splitter(
240
+ horizontal=True,
241
+ label="Deferred Work",
242
+ apply=self._resize_deferred,
243
+ on_release=self._persist_geometry,
244
+ id="split-deferred",
245
+ )
246
+ yield OptionList(id="deferred")
247
+ yield Splitter(
248
+ horizontal=False,
249
+ apply=self._resize_left,
250
+ on_release=self._persist_geometry,
251
+ id="split-main",
252
+ )
253
+ with Vertical(id="detail"):
254
+ yield RunHeader(id="runheader")
255
+ yield DataTable(id="tasks", cursor_type="row")
256
+ yield Splitter(
257
+ horizontal=True,
258
+ apply=self._resize_tasks,
259
+ on_release=self._persist_geometry,
260
+ id="split-tasks",
261
+ )
262
+ with TabbedContent(id="tabs"):
263
+ with TabPane("Journal", id="tab-journal"):
264
+ yield OptionList(id="journal")
265
+ with TabPane("Log", id="tab-log"):
266
+ # headroom over the render's 2000-line history cap so
267
+ # the header row is never silently dropped at capacity
268
+ yield SelectableRichLog(id="log", max_lines=2048, auto_scroll=True)
269
+ with TabPane("Attention", id="tab-attention"):
270
+ yield SelectableRichLog(id="attention", max_lines=500, auto_scroll=True)
271
+ yield Footer()
272
+
273
+ def on_mount(self) -> None:
274
+ runs = self.query_one("#runs", DataTable)
275
+ runs.add_column("st", key="st", width=2)
276
+ runs.add_column("run", key="run")
277
+ runs.add_column("type", key="type")
278
+ runs.add_column("note", key="note", width=6) # pause-kind badge for paused runs
279
+ # the stories board shares the sprint-tree slot; hidden until a stories-mode
280
+ # run is selected (the poll worker toggles both by the run's source).
281
+ self.query_one("#stories-table", StoriesTable).display = False
282
+ tasks = self.query_one("#tasks", DataTable)
283
+ tasks.add_column("story", key="story", width=30)
284
+ tasks.add_column("phase", key="phase", width=16)
285
+ tasks.add_column("agent", key="agent", width=13)
286
+ tasks.add_column("dev", key="dev", width=5)
287
+ tasks.add_column("review", key="review", width=6)
288
+ tasks.add_column("tokens", key="tokens", width=12)
289
+ tasks.add_column("info", key="info")
290
+ tasks.add_column("raw", key="raw", width=13)
291
+ self.query_one("#runheader", RunHeader).show_empty(self.project)
292
+ self.set_interval(1.0, self._tick)
293
+ self._tick()
294
+ # Seed pane geometry once the first layout has real sizes to read.
295
+ self.call_after_refresh(self._seed_geometry)
296
+
297
+ # ------------------------------------------------------------- selection
298
+
299
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
300
+ if event.data_table.id == "runs" and event.row_key is not None:
301
+ self._select_run(str(event.row_key.value))
302
+
303
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
304
+ if event.data_table.id == "runs" and event.row_key is not None:
305
+ self._select_run(str(event.row_key.value))
306
+
307
+ @property
308
+ def selected_run_id(self) -> str | None:
309
+ return self._ctx.run_dir.name if self._ctx else None
310
+
311
+ @property
312
+ def decision_pending(self) -> tuple[str, str] | None:
313
+ """(dw_id, question) the selected run's sweep is blocked on, if any —
314
+ the attach action uses this to target the orchestrator window."""
315
+ return self._decision
316
+
317
+ def _select_run(self, run_id: str) -> None:
318
+ if self._ctx is not None and self._ctx.run_dir.name == run_id:
319
+ return
320
+ self._generation += 1
321
+ self._ctx = _PollContext(self.project / RUNS_DIR / run_id)
322
+ self._decision = None
323
+ self._log_index = None
324
+ self._displayed_log_task = None
325
+ self._pin_task = None
326
+ self._pending_jump = None
327
+ self._log_follow_tail = True
328
+ tasks = self.query_one("#tasks", DataTable)
329
+ tasks.clear()
330
+ self._task_rows.clear()
331
+ self.query_one("#journal", OptionList).clear_options()
332
+ for log_id in ("#log", "#attention"):
333
+ self.query_one(log_id, RichLog).clear()
334
+ self.query_one("#runheader", RunHeader).show_run(run_id, data.UNKNOWN, None)
335
+ self._tick(force_rescan=False)
336
+
337
+ def forget_run(self, run_id: str) -> None:
338
+ """A run dir was just removed (delete/archive): drop the selection when
339
+ it was the gone run and rescan so the table rebuilds and re-selects."""
340
+ if self._ctx is not None and self._ctx.run_dir.name == run_id:
341
+ self._ctx = None
342
+ self._tick(force_rescan=True)
343
+
344
+ def expect_run(self, run_id: str) -> None:
345
+ """A launch just happened: select the run before its dir exists, show
346
+ a 'starting' header until state.json appears, and complain past the
347
+ launch timeout."""
348
+ self._pending_run = run_id
349
+ self._pending_deadline = time.monotonic() + _LAUNCH_TIMEOUT
350
+ self._select_run(run_id)
351
+ self.query_one("#runheader", RunHeader).show_starting(run_id)
352
+
353
+ # ----------------------------------------------------- journal -> log jump
354
+
355
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
356
+ if event.option_list.id == "journal":
357
+ entry = getattr(event.option, "entry", None)
358
+ if entry is not None:
359
+ self._jump_to_log_event(entry)
360
+ elif event.option_list.id == "deferred":
361
+ item = getattr(event.option, "item", None)
362
+ if item is not None:
363
+ self.app.push_screen(DeferredEntryModal(item))
364
+
365
+ def _jump_to_log_event(self, entry: dict[str, Any]) -> None:
366
+ task, pos = entry.get("log_task"), entry.get("log_pos")
367
+ if not task or not isinstance(pos, (int, float)):
368
+ self.notify(
369
+ "no log position recorded for this entry (older run?)",
370
+ severity="warning",
371
+ )
372
+ return
373
+ task, pos = str(task), int(pos)
374
+ self.query_one("#tabs", TabbedContent).active = "tab-log"
375
+ self._log_follow_tail = False # anchor on the jump target, stop chasing the tail
376
+ # _scroll_log_to owns clearing this once the scroll lands; until then
377
+ # every poll tick's _apply re-attempts it, so a flush that outlives the
378
+ # retry chain delays the jump instead of losing it.
379
+ self._pending_jump = (task, pos)
380
+ if task == self._displayed_log_task and self._log_index is not None:
381
+ self._scroll_log_to()
382
+ return
383
+ # another session's log (or this one not rendered yet): pin it and
384
+ # finish the jump once a poll has fed and rendered that file
385
+ if task != self._displayed_log_task:
386
+ self._pin_task = task
387
+ self._tick(force_rescan=False)
388
+
389
+ def _scroll_log_to(self, attempts: int = 20, finalize: bool = False) -> None:
390
+ jump = self._pending_jump
391
+ if jump is None or jump[0] != self._displayed_log_task:
392
+ # jump cancelled or superseded, or the pane re-rendered another
393
+ # task's log while a retry was armed — a stale fire must not scroll
394
+ return
395
+ if self._log_index is None:
396
+ # not rendered yet (or a log reset mid-chain): the pending jump
397
+ # survives and the next ≤1s poll tick re-attempts it
398
+ return
399
+ line = self._log_index.line_for_offset(jump[1])
400
+ if line is None:
401
+ self._pending_jump = None # give up for good: a retry would re-notify
402
+ self.notify("log is empty or not loaded yet", severity="warning")
403
+ return
404
+ log = self.query_one("#log", RichLog)
405
+ target = line + 1 # the '— task.log —' header row above the render
406
+ if log.virtual_size.height <= target:
407
+ # A previously hidden RichLog defers writes until the tab switch
408
+ # gives it a size, and that flush applies its own scroll_end.
409
+ # Wait for the flush (content taller than the target proves it)
410
+ # so our scroll lands after it instead of being stomped. The chain
411
+ # only covers sub-second snappiness: when it exhausts, the pending
412
+ # jump stays set and the next ≤1s poll tick re-attempts it. Each
413
+ # fire re-checks the jump identity so a superseded chain dies, and
414
+ # recomputes the line from the live index so a same-task repaint
415
+ # mid-chain (render_base drift) can't scroll to stale coordinates.
416
+ if attempts > 0:
417
+ self.set_timer(
418
+ 0.05,
419
+ lambda: (
420
+ self._scroll_log_to(attempts - 1) if self._pending_jump is jump else None
421
+ ),
422
+ )
423
+ return
424
+ viewport = max(1, log.scrollable_content_region.height)
425
+ log.scroll_to(y=max(0, target - viewport // 2), animate=False)
426
+ if finalize:
427
+ self._pending_jump = None # landed: release so later renders don't re-jump
428
+ return
429
+ # The open height gate proves the reveal flush *wrote* — not that its
430
+ # scroll drained: RichLog.on_resize replays the deferred writes, whose
431
+ # scroll_end goes through call_after_refresh, so a tail-scroll can
432
+ # still be queued on the log's pump while scroll_to (immediate on a
433
+ # ScrollView) already landed. Releasing here would let that queued
434
+ # scroll stomp the jump with nothing left to re-attempt it. Route the
435
+ # release through the same queue instead: FIFO puts the finalize fire
436
+ # after any queued stomp, and it re-scrolls to the recomputed target
437
+ # before letting go. If its guards bail (log reset mid-window), the
438
+ # jump stays pending for the next ≤1s tick.
439
+ log.call_after_refresh(
440
+ lambda: (self._scroll_log_to(finalize=True) if self._pending_jump is jump else None)
441
+ )
442
+
443
+ def action_unpin_log(self) -> None:
444
+ if self._resize_mode: # Escape leaves resize mode before it unpins the log
445
+ self._exit_resize_mode()
446
+ return
447
+ if self._pin_task is None and self._pending_jump is None and self._log_follow_tail:
448
+ return # nothing to unpin and already following the tail
449
+ self._pin_task = None
450
+ self._pending_jump = None
451
+ self._log_follow_tail = True
452
+ self._tick(force_rescan=False)
453
+
454
+ def action_copy_pane(self) -> None:
455
+ """Copy the whole Log/Attention pane to the clipboard (mouse-free
456
+ alternative to click-drag + ctrl+c). Only the two RichLog tabs hold
457
+ free-form copyable text; the tables/tree are not selectable."""
458
+ active = self.query_one("#tabs", TabbedContent).active
459
+ log_id = {"tab-log": "#log", "tab-attention": "#attention"}.get(active)
460
+ if log_id is None:
461
+ self.notify("switch to the Log or Attention tab to copy", severity="warning")
462
+ return
463
+ pane = self.query_one(log_id, RichLog)
464
+ # No rstrip: keep the joined buffer byte-for-byte identical to what
465
+ # SelectableRichLog.get_selection() returns, so `y` and drag-select+ctrl+c
466
+ # copy the same text.
467
+ text = "\n".join(strip.text for strip in pane.lines)
468
+ if not text:
469
+ self.notify("nothing to copy", severity="warning")
470
+ return
471
+ self.app.copy_to_clipboard(text)
472
+ self.notify(f"copied {log_id.lstrip('#')} pane to clipboard")
473
+
474
+ # ------------------------------------------------------------ pane resizing
475
+ #
476
+ # Model: the pane on one side of a boundary carries an explicit cell size
477
+ # (owned by a reactive below); a designated neighbour stays `1fr` and absorbs
478
+ # slack, including terminal resizes. The sidebar (#left) is already fixed and
479
+ # #detail flexes. The left column freezes Runs/Deferred to explicit heights on
480
+ # first resize (the middle Sprint/Stories slot becomes the sole flex); the
481
+ # detail column freezes Tasks (the Tabs pane already flexes). Watchers clamp
482
+ # against live region sizes and write widget.styles; on_resize re-clamps.
483
+
484
+ def _seed_geometry(self) -> None:
485
+ """Apply persisted sizes once the first layout has real dimensions. With
486
+ nothing persisted the panes keep their CSS proportions untouched, so a
487
+ fresh project looks exactly as before."""
488
+ if self._seeded:
489
+ return
490
+ self._seeded = True
491
+ pol = self._tui_policy
492
+ if pol.left_width > 0:
493
+ self.left_width = pol.left_width
494
+ if pol.runs_height > 0 or pol.deferred_height > 0:
495
+ self._freeze_left()
496
+ if pol.runs_height > 0:
497
+ self.runs_height = pol.runs_height
498
+ if pol.deferred_height > 0:
499
+ self.deferred_height = pol.deferred_height
500
+ if pol.tasks_height > 0:
501
+ self._freeze_detail()
502
+ self.tasks_height = pol.tasks_height
503
+
504
+ def _freeze_left(self) -> None:
505
+ """Switch the left column to explicit Runs/Deferred heights with the
506
+ Sprint/Stories slot as the sole vertical flex. Idempotent."""
507
+ if self._left_frozen:
508
+ return
509
+ self._left_frozen = True
510
+ # Snapshot both current render heights before mutating anything: assigning
511
+ # one reactive fires its watcher, and _apply_left_heights must see both
512
+ # seeds (its 0-guard skips a half-seeded state). outer_size is the
513
+ # border-box height styles.height sets, so freezing is seamless even for
514
+ # #runs' titled border.
515
+ runs_seed = max(_MIN_PANE, self.query_one("#runs").outer_size.height)
516
+ deferred_seed = max(_MIN_PANE, self.query_one("#deferred").outer_size.height)
517
+ for wid in ("#sprint-tree", "#stories-table"):
518
+ self.query_one(wid).styles.height = "1fr"
519
+ if self.runs_height <= 0:
520
+ self.runs_height = runs_seed
521
+ if self.deferred_height <= 0:
522
+ self.deferred_height = deferred_seed
523
+ self._apply_left_heights()
524
+
525
+ def _freeze_detail(self) -> None:
526
+ """Switch Tasks to an explicit height (Tabs already flexes). Idempotent."""
527
+ if self._detail_frozen:
528
+ return
529
+ self._detail_frozen = True
530
+ tasks = self.query_one("#tasks", DataTable)
531
+ if self.tasks_height <= 0:
532
+ self.tasks_height = max(_MIN_TASKS, tasks.outer_size.height)
533
+ # The explicit height governs now; _apply_tasks_height also lifts the CSS
534
+ # `max-height: 35%` cap (setting the inline value to None would NOT — the
535
+ # stylesheet rule wins, silently re-clamping a taller Tasks pane).
536
+ self._apply_tasks_height()
537
+
538
+ # ---- appliers (clamp against live sizes, then write styles) --------------
539
+
540
+ def _apply_left_width(self) -> None:
541
+ if self.left_width <= 0:
542
+ return
543
+ total = self.size.width
544
+ if total <= 0:
545
+ return
546
+ hi = max(_MIN_SIDEBAR, total - _MIN_DETAIL - 1) # -1 for the splitter column
547
+ val = max(_MIN_SIDEBAR, min(self.left_width, hi))
548
+ self.query_one("#left").styles.width = val
549
+ if val != self.left_width:
550
+ self.left_width = val # snap the reactive to the clamped value
551
+
552
+ def _apply_left_heights(self) -> None:
553
+ if not self._left_frozen:
554
+ return
555
+ if self.runs_height <= 0 or self.deferred_height <= 0:
556
+ return # mid-freeze: both seeds not in yet (see _freeze_left)
557
+ total = self.query_one("#left").size.height
558
+ if total <= 0:
559
+ return
560
+ # Reserve rows the two fixed panes cannot use: the two splitter bars, the
561
+ # Runs border-top, and the flex middle's minimum.
562
+ room = max(2 * _MIN_PANE, total - (2 + 1 + _MIN_PANE))
563
+ rh = max(_MIN_PANE, self.runs_height)
564
+ dh = max(_MIN_PANE, self.deferred_height)
565
+ if rh + dh > room: # trim the deferred pane first, then runs
566
+ dh = max(_MIN_PANE, min(dh, room - _MIN_PANE))
567
+ rh = max(_MIN_PANE, min(rh, room - dh))
568
+ self.query_one("#runs").styles.height = rh
569
+ self.query_one("#deferred").styles.height = dh
570
+ if rh != self.runs_height:
571
+ self.runs_height = rh
572
+ if dh != self.deferred_height:
573
+ self.deferred_height = dh
574
+
575
+ def _apply_tasks_height(self) -> None:
576
+ if not self._detail_frozen or self.tasks_height <= 0:
577
+ return
578
+ total = self.query_one("#detail").size.height
579
+ if total <= 0:
580
+ return
581
+ header = self.query_one("#runheader").size.height
582
+ hi = max(_MIN_TASKS, total - header - 1 - _MIN_TABS) # -1 for the splitter
583
+ val = max(_MIN_TASKS, min(self.tasks_height, hi))
584
+ tasks = self.query_one("#tasks")
585
+ tasks.styles.height = val
586
+ # Neutralize the stylesheet's `max-height: 35%` (the unfrozen default): an
587
+ # inline value >= the height is the only way to override it, since None
588
+ # falls back to the CSS rule and would re-clamp Tasks below `val`.
589
+ tasks.styles.max_height = val
590
+ if val != self.tasks_height:
591
+ self.tasks_height = val
592
+
593
+ def watch_left_width(self, _old: int, _new: int) -> None:
594
+ self._apply_left_width()
595
+
596
+ def watch_runs_height(self, _old: int, _new: int) -> None:
597
+ self._apply_left_heights()
598
+
599
+ def watch_deferred_height(self, _old: int, _new: int) -> None:
600
+ self._apply_left_heights()
601
+
602
+ def watch_tasks_height(self, _old: int, _new: int) -> None:
603
+ self._apply_tasks_height()
604
+
605
+ def on_resize(self, event: events.Resize) -> None:
606
+ # Re-clamp explicit sizes so a shrunk terminal can't push a fixed pane off
607
+ # screen; unfrozen dimensions are no-ops.
608
+ self._apply_left_width()
609
+ self._apply_left_heights()
610
+ self._apply_tasks_height()
611
+
612
+ # ---- drag targets (one per boundary; sign lives here) -------------------
613
+
614
+ def _resize_left(self, delta: int) -> None:
615
+ base = self.left_width if self.left_width > 0 else self.query_one("#left").size.width
616
+ self.left_width = base + delta # +delta widens the sidebar
617
+
618
+ def _resize_runs(self, delta: int) -> None:
619
+ self._freeze_left()
620
+ self.runs_height = max(_MIN_PANE, self.runs_height + delta) # down grows Runs
621
+
622
+ def _resize_deferred(self, delta: int) -> None:
623
+ self._freeze_left()
624
+ # The bar sits atop Deferred: moving it down grows Sprint / shrinks Deferred.
625
+ self.deferred_height = max(_MIN_PANE, self.deferred_height - delta)
626
+
627
+ def _resize_tasks(self, delta: int) -> None:
628
+ self._freeze_detail()
629
+ self.tasks_height = max(_MIN_TASKS, self.tasks_height + delta) # down grows Tasks
630
+
631
+ def _persist_geometry(self) -> None:
632
+ """Write the current sizes to policy.toml (drag end / resize-mode exit).
633
+ Best-effort: a write or parse failure degrades to a toast, never a crash."""
634
+ import tomlkit.exceptions
635
+
636
+ from ..settings import PolicyDoc
637
+
638
+ path = self.project / policy_mod.POLICY_FILE
639
+ dirty = self.left_width > 0 or self._left_frozen or self._detail_frozen
640
+ if not dirty and not path.is_file():
641
+ return # nothing customised and no file to update — don't create one
642
+ try:
643
+ # A missing file starts empty, NOT from POLICY_TEMPLATE: a geometry
644
+ # save must not freeze every default setting into a fresh policy.toml.
645
+ doc = PolicyDoc.load(path, template_when_missing=False)
646
+ doc.set("tui", "left_width", self.left_width if self.left_width > 0 else None)
647
+ doc.set("tui", "runs_height", self.runs_height if self._left_frozen else None)
648
+ doc.set("tui", "deferred_height", self.deferred_height if self._left_frozen else None)
649
+ doc.set("tui", "tasks_height", self.tasks_height if self._detail_frozen else None)
650
+ doc.save(path, confine_root=self.project)
651
+ except (OSError, tomlkit.exceptions.TOMLKitError) as e:
652
+ self.notify(f"could not save layout: {e}", severity="warning")
653
+
654
+ def on_unmount(self) -> None:
655
+ # Quitting mid-resize-mode would otherwise drop the new geometry: keyboard
656
+ # bumps persist only on mode exit, and `q` stays live inside the mode.
657
+ if self._resize_mode:
658
+ self._persist_geometry()
659
+
660
+ # ---- keyboard resize mode -----------------------------------------------
661
+
662
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
663
+ # The arrow/Enter resize bindings are priority so they beat the focused
664
+ # table/list while resizing; disable them otherwise so the key reaches the
665
+ # widget (Tab/Shift+Tab stay live — see action_resize_cycle).
666
+ if action in _RESIZE_ACTIONS and not self._resize_mode:
667
+ return False
668
+ return True
669
+
670
+ def action_resize_mode(self) -> None:
671
+ if self._resize_mode:
672
+ self._exit_resize_mode()
673
+ else:
674
+ self._enter_resize_mode()
675
+
676
+ def _enter_resize_mode(self) -> None:
677
+ self._resize_mode = True
678
+ self._active_hsplit = 0
679
+ self.query_one("#split-main", Splitter).add_class("-active")
680
+ self._highlight_active_hsplit()
681
+ self._saved_subtitle = self.app.sub_title
682
+ self.app.sub_title = "[RESIZE] ←/→ width · ↑/↓ height · Tab boundary · Esc done"
683
+
684
+ def _exit_resize_mode(self) -> None:
685
+ if not self._resize_mode:
686
+ return
687
+ self._resize_mode = False
688
+ for split in self.query(Splitter):
689
+ split.remove_class("-active")
690
+ self.app.sub_title = self._saved_subtitle or str(self.project)
691
+ self._persist_geometry()
692
+
693
+ def _highlight_active_hsplit(self) -> None:
694
+ for i, sid in enumerate(_HSPLITS):
695
+ self.query_one(sid, Splitter).set_class(i == self._active_hsplit, "-active")
696
+
697
+ def _active_hsplit_widget(self) -> Splitter:
698
+ return self.query_one(_HSPLITS[self._active_hsplit], Splitter)
699
+
700
+ def action_resize_cycle(self, direction: int) -> None:
701
+ # Tab stays bound always: cycle the active boundary while resizing, else
702
+ # fall back to the normal focus movement Tab would have done.
703
+ if not self._resize_mode:
704
+ if direction > 0:
705
+ self.app.action_focus_next()
706
+ else:
707
+ self.app.action_focus_previous()
708
+ return
709
+ self._active_hsplit = (self._active_hsplit + direction) % len(_HSPLITS)
710
+ self._highlight_active_hsplit()
711
+
712
+ def action_resize_left(self) -> None:
713
+ self.query_one("#split-main", Splitter).bump(-1)
714
+
715
+ def action_resize_right(self) -> None:
716
+ self.query_one("#split-main", Splitter).bump(1)
717
+
718
+ def action_resize_up(self) -> None:
719
+ self._active_hsplit_widget().bump(-1)
720
+
721
+ def action_resize_down(self) -> None:
722
+ self._active_hsplit_widget().bump(1)
723
+
724
+ def action_resize_done(self) -> None:
725
+ self._exit_resize_mode()
726
+
727
+ # --------------------------------------------------------------- polling
728
+
729
+ def _tick(self, force_rescan: bool | None = None) -> None:
730
+ if self._pending_run is not None and time.monotonic() > self._pending_deadline:
731
+ self._pending_run = None
732
+ self.notify(
733
+ "launch may have failed — attach to control session "
734
+ f"{launch.ctl_session(self.project)}",
735
+ severity="error",
736
+ timeout=15,
737
+ )
738
+ if force_rescan is None:
739
+ force_rescan = self._tick_count % _RESCAN_EVERY == 0
740
+ self._tick_count += 1
741
+ # the pin is read here on the UI thread; ctx stays worker-owned. If a
742
+ # worker is still mid-flight _poll bails on the lock; _pin_task and
743
+ # _pending_jump persist on the screen, so the next interval tick (≤1s)
744
+ # re-applies them — no extra rescheduling needed.
745
+ self._poll(self._ctx, self._generation, force_rescan, self._pin_task)
746
+
747
+ @work(thread=True, exclusive=True, group="poll")
748
+ def _poll(
749
+ self, ctx: _PollContext | None, generation: int, rescan: bool, pin: str | None
750
+ ) -> None:
751
+ # A superseded thread worker keeps running until it returns, so guard
752
+ # the whole body: only one poll may touch ctx (and ctx.log's pyte
753
+ # stream) at a time. Skipped ticks are safe — _pin_task/_pending_jump
754
+ # persist on the screen and the next tick reapplies them.
755
+ if not self._poll_lock.acquire(blocking=False):
756
+ return
757
+ try:
758
+ snap = _Snapshot(generation=generation)
759
+ if rescan:
760
+ snap.runs = data.discover_runs(self.project)
761
+ snap.project_refreshed = True
762
+ snap.sprint = data.sprint_overview(self.project)
763
+ snap.deferred = data.deferred_entries(self.project)
764
+ snap.missed_decisions = len(data.pending_missed_decisions(self.project))
765
+ if ctx is not None:
766
+ snap.has_run = True
767
+ snap.run_id = ctx.run_dir.name
768
+ snap.state = ctx.watcher.state()
769
+ snap.status = ctx.watcher.status()
770
+ # A pending stop is the control file's presence in either mode,
771
+ # meaningful while an engine is still around to consume it — RUNNING
772
+ # or UNKNOWN (an unverifiable pid still honors it, matching the CLI's
773
+ # != "dead").
774
+ snap.stopping = (
775
+ snap.status in (data.RUNNING, data.UNKNOWN) and ctx.watcher.stopping()
776
+ )
777
+ if snap.state is not None and snap.state.source == "stories":
778
+ # per-run board: re-derived each tick (only while a stories run
779
+ # is selected) so it tracks the dev sessions writing story specs.
780
+ snap.stories_mode = True
781
+ snap.stories = data.stories_overview(self.project, snap.state.spec_folder)
782
+ snap.new_entries = ctx.journal.read_new()
783
+ ctx.entries.extend(snap.new_entries)
784
+ del ctx.entries[:-_MAX_ENTRIES]
785
+ snap.decision = data.pending_decision(ctx.entries)
786
+ if snap.decision is not None and ctx.decision_toasted != snap.decision[0]:
787
+ snap.toast_decision = True
788
+ ctx.decision_toasted = snap.decision[0] if snap.decision else None
789
+ task = pin or data.active_task_id(ctx.run_dir, ctx.entries)
790
+ # The live agent driving the run, gated like `stopping`: a dead run
791
+ # has no live agent, but UNKNOWN liveness is honored (cf. 3eb5c21).
792
+ if snap.status in (data.RUNNING, data.UNKNOWN):
793
+ snap.agent = data.active_agent(
794
+ ctx.entries, snap.state.policy_snapshot if snap.state else None
795
+ )
796
+ snap.log_pinned = pin is not None
797
+ if task != ctx.log_task:
798
+ ctx.log_task = task
799
+ ctx.log = (
800
+ data.LogView(ctx.run_dir / data.LOGS_DIR / f"{task}.log") if task else None
801
+ )
802
+ snap.log_reset = True
803
+ snap.log_task = task
804
+ if ctx.log is not None and (ctx.log.read_new() or snap.log_reset):
805
+ snap.log_lines = ctx.log.render()
806
+ snap.log_index = ctx.log.index()
807
+ if ctx.log is not None and ctx.log.altscreen_seen:
808
+ snap.log_altscreen = True
809
+ if snap.state is not None and task:
810
+ snap.log_transcript = _transcript_for_task(snap.state, task)
811
+ attention = ctx.watcher.attention()
812
+ if len(attention) < ctx.attention_seen:
813
+ snap.attention_reset = True
814
+ snap.new_attention = attention
815
+ else:
816
+ snap.new_attention = attention[ctx.attention_seen :]
817
+ ctx.attention_seen = len(attention)
818
+ snap.toast_attention = bool(snap.new_attention.strip()) and not ctx.first_poll
819
+ ctx.first_poll = False
820
+ self.app.call_from_thread(self._apply, snap)
821
+ finally:
822
+ self._poll_lock.release()
823
+
824
+ # ------------------------------------------------------------ applying
825
+
826
+ def _apply(self, snap: _Snapshot) -> None:
827
+ # A thread-worker poll delivers its snapshot here via call_from_thread; that
828
+ # callback can land after the screen has been torn down (app shutdown, or
829
+ # another screen switched in), when the widgets below are already gone and
830
+ # query_one would raise NoMatches. is_running flips False on teardown but
831
+ # stays True while merely backgrounded under a pushed screen, so a stale
832
+ # apply is dropped while a live-but-background one still refreshes.
833
+ if not self.is_running:
834
+ return
835
+ if snap.runs is not None:
836
+ self._apply_runs(snap.runs)
837
+ if snap.project_refreshed:
838
+ self._apply_sprint_tree(snap.sprint)
839
+ self._apply_deferred(snap.deferred)
840
+ self._apply_missed_decisions(snap.missed_decisions)
841
+ if not snap.has_run or snap.generation != self._generation:
842
+ return # selection changed mid-poll: per-run parts are stale
843
+
844
+ self._decision = snap.decision
845
+ header = self.query_one("#runheader", RunHeader)
846
+ if snap.run_id == self._pending_run and snap.state is None:
847
+ header.show_starting(snap.run_id) # launched, state.json not yet written
848
+ else:
849
+ if snap.run_id == self._pending_run:
850
+ self._pending_run = None # the engine is up
851
+ header.show_run(
852
+ snap.run_id,
853
+ snap.status,
854
+ snap.state,
855
+ snap.decision,
856
+ stopping=snap.stopping,
857
+ agent=snap.agent,
858
+ )
859
+ self._apply_board(snap)
860
+ if snap.state is not None:
861
+ self._apply_tasks(snap.state, snap.agent)
862
+ if snap.toast_decision and snap.decision is not None:
863
+ self.notify(
864
+ snap.decision[1] or snap.decision[0],
865
+ title=f"decision needed: {snap.decision[0]} — press a to attach",
866
+ severity="warning",
867
+ timeout=30,
868
+ )
869
+
870
+ journal = self.query_one("#journal", OptionList)
871
+ if snap.new_entries:
872
+ at_end = journal.is_vertical_scroll_end
873
+ journal.add_options(JournalEntryOption(e) for e in snap.new_entries)
874
+ for _ in range(max(0, journal.option_count - _MAX_JOURNAL_OPTIONS)):
875
+ journal.remove_option_at_index(0)
876
+ if at_end:
877
+ # follow the tail like the old RichLog did, but leave the
878
+ # highlight alone so a user browsing upward is not yanked down
879
+ journal.scroll_end(animate=False)
880
+
881
+ log = self.query_one("#log", RichLog)
882
+ self._displayed_log_task = snap.log_task
883
+ if snap.log_reset:
884
+ self._log_index = None
885
+ if snap.log_index is not None:
886
+ self._log_index = snap.log_index
887
+ if snap.log_reset or snap.log_lines is not None:
888
+ # Cursor-up repaints rewrite earlier content, so the pane is a full
889
+ # re-render, not an append; RichLog keeps scroll_y across the
890
+ # clear+rewrite, so only an explicit scroll_end moves the view.
891
+ # Follow the tail only when the user means to (no jump has pinned
892
+ # them off it) — inferring it from "currently at the bottom" dragged
893
+ # a jump that happened to land at the tail down as the log grew.
894
+ # (Jump targets rely on wrap=False: one render line == one row.)
895
+ following = self._log_follow_tail and log.is_vertical_scroll_end
896
+ at_end = snap.log_reset or following
897
+ log.clear()
898
+ if snap.log_task:
899
+ suffix = " (pinned — esc to follow)" if snap.log_pinned else ""
900
+ log.write(Text(f"— {snap.log_task}.log —{suffix}", style="dim"), scroll_end=False)
901
+ if snap.log_altscreen:
902
+ # A fullscreen (alt-screen) TUI repaints in place, so the emulated
903
+ # pane collapses to the final frame. Flag it so the partial view is
904
+ # not mistaken for the whole session, and point at the full record.
905
+ note = "⚠ fullscreen (alt-screen) session — this pane shows only the final frame"
906
+ if snap.log_transcript:
907
+ note += f"; full transcript: {snap.log_transcript}"
908
+ log.write(Text(note, style="yellow"), scroll_end=False)
909
+ if snap.log_lines is not None and snap.log_lines.plain:
910
+ log.write(snap.log_lines, scroll_end=at_end)
911
+ if self._pending_jump is not None:
912
+ # _scroll_log_to owns clearing the pending jump when the scroll
913
+ # lands (or the log is provably empty) — until then this block
914
+ # re-attempts on every tick, so a flush that outlives the retry
915
+ # chain delays the jump by ≤1s instead of losing it. Task-match
916
+ # and index guards live inside _scroll_log_to.
917
+ self._scroll_log_to()
918
+
919
+ attention = self.query_one("#attention", RichLog)
920
+ if snap.attention_reset:
921
+ attention.clear()
922
+ if snap.new_attention.strip():
923
+ attention.write(Text(snap.new_attention.rstrip("\n")))
924
+ if snap.toast_attention:
925
+ last = snap.new_attention.strip().splitlines()[-1]
926
+ self.notify(last, title="attention", severity="warning", timeout=10)
927
+
928
+ def _apply_runs(self, runs: list[data.RunInfo]) -> None:
929
+ table = self.query_one("#runs", DataTable)
930
+ self._apply_attention(table, runs)
931
+ ids = [r.run_id for r in runs]
932
+ if not runs:
933
+ if self._run_rows:
934
+ table.clear()
935
+ self._run_rows.clear()
936
+ if self._ctx is None:
937
+ self.query_one("#runheader", RunHeader).show_empty(self.project)
938
+ return
939
+ if any(known not in ids for known in self._run_rows):
940
+ # a run dir disappeared — rare enough to just rebuild
941
+ table.clear()
942
+ self._run_rows.clear()
943
+ first_populate = not self._run_rows
944
+ added: list[str] = []
945
+ for run in runs:
946
+ note = stopping_tag() if run.stopping else pause_tag(run.paused_stage)
947
+ if run.run_id in self._run_rows:
948
+ table.update_cell(run.run_id, "st", status_cell(run.status))
949
+ table.update_cell(run.run_id, "note", note)
950
+ else:
951
+ table.add_row(
952
+ status_cell(run.status),
953
+ run.run_id,
954
+ run.run_type,
955
+ note,
956
+ key=run.run_id,
957
+ )
958
+ self._run_rows.append(run.run_id)
959
+ added.append(run.run_id)
960
+ if first_populate:
961
+ if self.selected_run_id in ids:
962
+ # a pre-selected (just-launched) run beats auto-select-newest
963
+ table.move_cursor(row=ids.index(self.selected_run_id))
964
+ else:
965
+ table.move_cursor(row=len(ids) - 1) # newest; RowHighlighted selects
966
+ self._select_run(ids[-1])
967
+ elif self.selected_run_id in added:
968
+ # the selected run was launched before its dir existed; its row
969
+ # just appeared — bring the cursor to it
970
+ table.move_cursor(row=self._run_rows.index(self.selected_run_id))
971
+
972
+ def _apply_tasks(self, state: RunState, agent: data.ActiveAgent | None = None) -> None:
973
+ table = self.query_one("#tasks", DataTable)
974
+ weight = state.cache_read_weight()
975
+ for key, task in state.tasks.items():
976
+ weighted = task.tokens.weighted_total(weight)
977
+ has_tokens = bool(task.tokens.total)
978
+ # Gate on total (any tokens?), not on `weighted`: with cache_read_weight=0
979
+ # a cache-read-only task has weighted==0 but nonzero raw — show "0", not "-"
980
+ # (which reads as missing data). "-" means the task has no tokens at all.
981
+ tokens = f"{weighted:,}" if has_tokens else "-"
982
+ raw = f"{task.tokens.total:,}" if has_tokens else "-"
983
+ info = task.defer_reason or (task.commit_sha or "")[:12]
984
+ cells = {
985
+ "phase": str(task.phase),
986
+ "agent": self._agent_cell(task, key, agent),
987
+ "dev": f"×{task.attempt}",
988
+ "review": f"×{task.review_cycle}",
989
+ "tokens": tokens,
990
+ "info": info,
991
+ "raw": raw, # must be last — matches add_column order
992
+ }
993
+ if key in self._task_rows:
994
+ for column, value in cells.items():
995
+ table.update_cell(key, column, value)
996
+ else:
997
+ table.add_row(key, *cells.values(), key=key)
998
+ self._task_rows.add(key)
999
+
1000
+ @staticmethod
1001
+ def _agent_cell(task: StoryTask, key: str, agent: data.ActiveAgent | None) -> str:
1002
+ """The agent column for one task row. Prefer the live agent when it is
1003
+ driving THIS task, else the last session that stamped an adapter identity
1004
+ (#153 phase 1), else "-" (no live agent and no stamped history)."""
1005
+ if agent is not None and agent.story_key == key:
1006
+ return agent_label(agent.name, agent.model)
1007
+ for record in reversed(task.sessions):
1008
+ if record.adapter:
1009
+ return agent_label(record.adapter, record.model)
1010
+ return "-"
1011
+
1012
+ def _apply_attention(self, table: DataTable, runs: list[data.RunInfo]) -> None:
1013
+ """Global attention indicator: how many runs are paused awaiting a human.
1014
+ Shown on the runs-table border title, consistent with the per-run pause
1015
+ badge and the ATTENTION-file notify machinery."""
1016
+ waiting = sum(1 for r in runs if r.status == data.PAUSED)
1017
+ table.border_title = f"Runs — ⚑ {waiting} need attention" if waiting else "Runs"
1018
+
1019
+ def _apply_board(self, snap: _Snapshot) -> None:
1020
+ """Toggle the sprint tree vs the stories board by the selected run's mode
1021
+ and refresh whichever is live. The stories board is per-run (keyed by the
1022
+ run's spec folder); the sprint tree is project-level and painted
1023
+ separately on rescan."""
1024
+ sprint_tree = self.query_one("#sprint-tree", SprintTree)
1025
+ stories_table = self.query_one("#stories-table", StoriesTable)
1026
+ sprint_tree.display = not snap.stories_mode
1027
+ stories_table.display = snap.stories_mode
1028
+ # The splitter above this slot carries its section title; set_label already
1029
+ # no-ops when the label is unchanged (this runs every poll tick).
1030
+ self.query_one("#split-runs", Splitter).set_label(
1031
+ "Stories" if snap.stories_mode else "Sprint"
1032
+ )
1033
+ if snap.stories_mode:
1034
+ stories_table.update_stories(snap.stories)
1035
+
1036
+ def _apply_sprint_tree(self, ss: sprintstatus.SprintStatus | None) -> None:
1037
+ if ss is self._last_sprint:
1038
+ return
1039
+ self._last_sprint = ss
1040
+ self.query_one("#sprint-tree", SprintTree).update_sprint(ss)
1041
+
1042
+ def _apply_deferred(self, items: list[data.DeferredItem] | None) -> None:
1043
+ if items is self._last_deferred:
1044
+ return
1045
+ self._last_deferred = items
1046
+ deferred = self.query_one("#deferred", OptionList)
1047
+ highlighted_id: str | None = None
1048
+ if deferred.highlighted is not None:
1049
+ highlighted_id = deferred.get_option_at_index(deferred.highlighted).id
1050
+ deferred.clear_options()
1051
+ if not items:
1052
+ label = "no deferred work" if items is not None else "deferred ledger unavailable"
1053
+ deferred.add_option(Option(Text(label, style="dim"), disabled=True))
1054
+ return
1055
+ seen_ids: set[str] = set()
1056
+ for item in items:
1057
+ key = item.option_key or item.id
1058
+ option_id = key if key not in seen_ids else None
1059
+ seen_ids.add(key)
1060
+ deferred.add_option(DeferredEntryOption(item, option_id))
1061
+ if highlighted_id is not None:
1062
+ try:
1063
+ deferred.highlighted = deferred.get_option_index(highlighted_id)
1064
+ except OptionDoesNotExist:
1065
+ pass
1066
+
1067
+ def _apply_missed_decisions(self, count: int) -> None:
1068
+ deferred = self.query_one("#deferred", OptionList)
1069
+ deferred.border_title = (
1070
+ f"Deferred Work — {count} to answer (d)" if count else "Deferred Work"
1071
+ )