diff options
Diffstat (limited to 'app/ui')
| -rw-r--r-- | app/ui/taskview.py | 54 |
1 files changed, 50 insertions, 4 deletions
diff --git a/app/ui/taskview.py b/app/ui/taskview.py index c5c2ff7..d7aed96 100644 --- a/app/ui/taskview.py +++ b/app/ui/taskview.py @@ -63,6 +63,11 @@ _PROGRESS_COUNT = re.compile(r"\[(\d+)/(\d+)\]") # A spinner frame set for the running step marker. _SPINNER = ("|", "/", "-", "\\") +# How long a running step may stay silent (no output lines) before the +# view starts saying so next to its elapsed clock — an early "this looks +# wedged" cue for the no-output watchdog that eventually kills the step. +_SILENCE_CUE_SECS = 60 + @dataclass class TaskStep: @@ -117,6 +122,27 @@ def _progress_match(text: str) -> Optional[Tuple[float, float, str]]: return None +def _silent_secs(last_line_at: Optional[float], now: float + ) -> Optional[float]: + """Seconds the running step has been silent, or None (no cue). + + None when nothing is tracked (no output yet is not tracked here — the + caller decides), or when the silence is still below the cue threshold. + Module-level for testability. + """ + if last_line_at is None: + return None + silent = now - last_line_at + if silent < _SILENCE_CUE_SECS: + return None + return silent + + +def _silence_cue_text(silent: float) -> str: + """The dim text shown next to the elapsed clock for SILENT seconds.""" + return f"(no output {int(silent // 60)}m)" + + def _lane_step_mark(current: Optional[int], results: List[Optional[int]], cancelled_step: Optional[int], @@ -208,6 +234,7 @@ class TaskView(ScreenView): self._progress: Optional[Tuple[float, float]] = None # (done, total) self._progress_kind = "" # "bytes" | "percent" | "count" | "" self.step_started: List[Optional[float]] = [None] * len(steps) + self.last_line_at: Optional[float] = None # silence cue (see _SILENCE_CUE_SECS) self.finished_at: Optional[float] = None self.cancelled = False self.cancelling = False @@ -265,6 +292,7 @@ class TaskView(ScreenView): if kind == "step_start": self.current = event["index"] self.step_started[self.current] = self._now() + self.last_line_at = self._now() self._progress = None self._progress_kind = "" elif kind == "line": @@ -274,11 +302,13 @@ class TaskView(ScreenView): index = event["index"] self.results[index] = event.get("rc") or 0 self.current = None + self.last_line_at = None self._progress = None self._progress_kind = "" elif kind == "step_cancelled": self.cancelled_step = event["index"] self.current = None + self.last_line_at = None self._progress = None self._progress_kind = "" elif kind == "finish": @@ -290,6 +320,7 @@ class TaskView(ScreenView): def _ingest_line(self, text: str) -> None: """Fold one output line into the log tail and progress bar.""" line = text.rstrip("\r\n") + self.last_line_at = self._now() if not line: return match = _progress_match(line) @@ -371,9 +402,14 @@ class TaskView(ScreenView): _text(scr, theme, y, inner_x + 5, label, theme["body"]) if index == self.current and self.phase not in _TERMINAL: started = self.step_started[index] or self._now() + elapsed_text = f" {_format_elapsed(self._now() - started)}" _text(scr, theme, y, inner_x + 5 + len(label) + 1, - f" {_format_elapsed(self._now() - started)}", - theme["dim"]) + elapsed_text, theme["dim"]) + silent = _silent_secs(self.last_line_at, self._now()) + if silent is not None: + _text(scr, theme, y, + inner_x + 5 + len(label) + 1 + len(elapsed_text), + f" {_silence_cue_text(silent)}", theme["dim"]) y += 1 y += 1 @@ -581,6 +617,7 @@ class _LaneState: self.progress: Optional[Tuple[float, float]] = None self.progress_kind = "" self.step_started: List[Optional[float]] = [None] * len(self.steps) + self.last_line_at: Optional[float] = None # silence cue self.cancelled_step: Optional[int] = None self.rc = 0 self.finished = False @@ -664,6 +701,7 @@ class LanesView(_GetchModes): if kind == "step_start": lane.current = event["index"] lane.step_started[lane.current] = self._now() + lane.last_line_at = self._now() lane.progress = None lane.progress_kind = "" elif kind == "line": @@ -671,11 +709,13 @@ class LanesView(_GetchModes): elif kind == "step_done": lane.results[event["index"]] = event.get("rc") or 0 lane.current = None + lane.last_line_at = None lane.progress = None lane.progress_kind = "" elif kind == "step_cancelled": lane.cancelled_step = event["index"] lane.current = None + lane.last_line_at = None lane.progress = None lane.progress_kind = "" elif kind == "lane_finish": @@ -685,6 +725,7 @@ class LanesView(_GetchModes): def _ingest_lane_line(self, lane: _LaneState, text: str) -> None: """Fold one output line into LANE's log tail and progress bar.""" line = text.rstrip("\r\n") + lane.last_line_at = self._now() if not line: return match = _progress_match(line) @@ -862,9 +903,14 @@ class LanesView(_GetchModes): _text(scr, theme, row, x + 6, label, theme["body"]) if index == lane.current and not terminal: started = lane.step_started[index] or self._now() + elapsed_text = f" {_format_elapsed(self._now() - started)}" _text(scr, theme, row, x + 6 + len(label) + 1, - f" {_format_elapsed(self._now() - started)}", - theme["dim"]) + elapsed_text, theme["dim"]) + silent = _silent_secs(lane.last_line_at, self._now()) + if silent is not None: + _text(scr, theme, row, + x + 6 + len(label) + 1 + len(elapsed_text), + f" {_silence_cue_text(silent)}", theme["dim"]) row += 1 row += 1 |
