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