aboutsummaryrefslogtreecommitdiff
path: root/app/ui/taskview.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-28 03:16:14 -0400
committerhistoria <historiavg@proton.me>2026-08-28 03:16:14 -0400
commitd487996281f71ccd3034dd708fa099057e528b42 (patch)
tree989d9f40f5c2dbb03e1cf570f2c463f43cf5ac76 /app/ui/taskview.py
parent5c3db8f500ff206f3a675d8f4184cb0d61f94804 (diff)
downloadtts-audiobook-generator-d487996281f71ccd3034dd708fa099057e528b42.tar.gz
feat: unified logging with logging_kit.py
Diffstat (limited to 'app/ui/taskview.py')
-rw-r--r--app/ui/taskview.py124
1 files changed, 102 insertions, 22 deletions
diff --git a/app/ui/taskview.py b/app/ui/taskview.py
index d7aed96..3831ceb 100644
--- a/app/ui/taskview.py
+++ b/app/ui/taskview.py
@@ -7,8 +7,11 @@ dumped the user into plain console output. This widget keeps them inside the
hub's curses session: a worker thread runs an ordered list of ``TaskStep``s
while the main thread redraws a DOS-style frame showing each step's state
(pending / running with a spinner and elapsed clock / [OK] / [FAIL]), an
-optional progress bar for the current step, and a dim scrolling log tail of
-the step's output.
+optional progress bar for the current step, and a dim log tail of the step's
+output filling the remaining screen height. Every line the view shows is also
+mirrored to the ``tui_YYYYMMDD.log`` day stream under app/logs (see
+``_ConsoleLog``), so console output survives the curses session even when the
+step itself keeps no log.
Steps stream their output by calling ``emit(line)`` (or simply printing to
stdout/stderr, which the view captures). The view turns output into progress
@@ -43,17 +46,22 @@ import sys
import threading
import time
from dataclasses import dataclass
+from datetime import datetime
from queue import Empty, Queue
from typing import Callable, List, Optional, Tuple
+import logging_kit
+
from ui import tui
from ui.viewkit import (TERMINAL_PHASES as _TERMINAL,
- DRAW_TIMEOUT_MS as _DRAW_TIMEOUT_MS,
- ScreenView, _box, _fit, _format_elapsed, _sep,
- _text)
+ DRAW_TIMEOUT_MS as _DRAW_TIMEOUT_MS,
+ ScreenView, _box, _fit, _format_elapsed, _sep,
+ _text)
-# How many recent output lines the log tail keeps.
-_LOG_TAIL = 10
+# How many recent output lines the tail keeps in memory. The on-screen tail
+# draws as many as fit (see render); the full run is also mirrored to the
+# ``tui_`` day stream under app/logs (see _ConsoleLog).
+_LOG_KEEP = 1000
# Progress-line matchers, in order of precedence.
_PROGRESS_BYTES = re.compile(r"AUDIOCPP_PROGRESS downloaded=(\d+) total=(\d+)")
@@ -143,6 +151,43 @@ def _silence_cue_text(silent: float) -> str:
return f"(no output {int(silent // 60)}m)"
+class _ConsoleLog:
+ """Mirrors a task view's console output into the ``tui_`` day stream.
+
+ Every line the view shows (minus machine-readable progress lines) is
+ appended to ``app/logs/tui_YYYYMMDD.log``, with a separator header per
+ run and step start/finish markers, so no in-TUI console output is lost.
+ The file is opened lazily on the first line — a run with no output
+ creates nothing — and every write is best-effort: an unwritable
+ app/logs simply disables the mirror. One instance per view run; both
+ lanes of a LanesView share theirs (ingestion runs on the main thread).
+ """
+
+ def __init__(self, title: str):
+ self._title = title
+ self._handle = None
+ self._started = False
+
+ def line(self, text: str) -> None:
+ """Append TEXT (and, once, the run's separator header)."""
+ if not self._started:
+ self._started = True
+ self._handle = logging_kit.day_stream("tui")
+ logging_kit.write_line(self._handle, "")
+ logging_kit.write_line(
+ self._handle, f"=== {self._title} — "
+ f"{datetime.now():%Y-%m-%d %H:%M:%S} ===")
+ logging_kit.write_line(self._handle, text)
+
+ def close(self) -> None:
+ if self._handle is not None:
+ try:
+ self._handle.close()
+ except OSError:
+ pass
+ self._handle = None
+
+
def _lane_step_mark(current: Optional[int],
results: List[Optional[int]],
cancelled_step: Optional[int],
@@ -238,6 +283,8 @@ class TaskView(ScreenView):
self.finished_at: Optional[float] = None
self.cancelled = False
self.cancelling = False
+ # -- console mirror (app/logs/tui_YYYYMMDD.log) ----------------
+ self._console_log = _ConsoleLog(title)
# -- threads ---------------------------------------------------
self._queue: Queue = Queue()
self._cancel = threading.Event()
@@ -295,27 +342,37 @@ class TaskView(ScreenView):
self.last_line_at = self._now()
self._progress = None
self._progress_kind = ""
+ self._console_log.line(f"--- {event.get('title') or ''} ---")
elif kind == "line":
text = event.get("text") or ""
self._ingest_line(text)
elif kind == "step_done":
index = event["index"]
- self.results[index] = event.get("rc") or 0
+ rc = event.get("rc") or 0
+ self.results[index] = rc
self.current = None
self.last_line_at = None
self._progress = None
self._progress_kind = ""
+ self._console_log.line(
+ f"[{'OK' if rc == 0 else 'FAIL'}] "
+ f"{self.steps[index].title} (exit {rc})")
elif kind == "step_cancelled":
- self.cancelled_step = event["index"]
+ index = event["index"]
+ self.cancelled_step = index
self.current = None
self.last_line_at = None
self._progress = None
self._progress_kind = ""
+ self._console_log.line(
+ f"[x] {self.steps[index].title} (cancelled)")
elif kind == "finish":
self.phase = event.get("phase") or "done"
self.cancelled = self.phase == "cancelled"
self.finished_at = self._now()
self.current = None
+ self._console_log.line(
+ f"=== {self.phase} (exit {event.get('rc') or 0}) ===")
def _ingest_line(self, text: str) -> None:
"""Fold one output line into the log tail and progress bar."""
@@ -333,8 +390,9 @@ class TaskView(ScreenView):
# Percent/count lines stay in the log (the tail already
# collapses rapid \r updates to the last full line).
self.log_tail.append(line)
- if len(self.log_tail) > _LOG_TAIL:
- del self.log_tail[: len(self.log_tail) - _LOG_TAIL]
+ if len(self.log_tail) > _LOG_KEEP:
+ del self.log_tail[: len(self.log_tail) - _LOG_KEEP]
+ self._console_log.line(line)
# ScreenView hooks ------------------------------------------------
@@ -358,6 +416,10 @@ class TaskView(ScreenView):
return 1
return next((rc for rc in self.results if rc), 0)
+ def _on_stop(self) -> None:
+ self._console_log.close()
+ super()._on_stop()
+
def _prompt_cancel(self) -> bool:
"""Esc/q: confirm cancel, then wait for the worker to wind down."""
self._blocking()
@@ -439,12 +501,14 @@ class TaskView(ScreenView):
y += 1
# -- log tail --------------------------------------------------
- for line in self.log_tail[-_LOG_TAIL:]:
- _text(scr, theme, y, inner_x, _fit(line, width - inner_x - 2),
- theme["dim"])
- y += 1
- if y >= height - 3:
- break
+ # Every recent line that fits between here and the footer; the
+ # full run lives in the tui_ day stream (see _ConsoleLog).
+ room = (height - 3) - y
+ if room > 0:
+ for line in self.log_tail[-room:]:
+ _text(scr, theme, y, inner_x, _fit(line, width - inner_x - 2),
+ theme["dim"])
+ y += 1
# -- footer ----------------------------------------------------
suffix = "" if not self.wait_on_finish else " — press any key to return"
@@ -659,6 +723,7 @@ class LanesView(_GetchModes):
self.theme = tui._ensure_theme(curses)
self._clock = clock
self._lanes = [_LaneState(lane.title, lane.steps) for lane in lanes]
+ self._console_log = _ConsoleLog(title) # shared by both lanes
self.phase = "running" # running | done | error | cancelled
self.cancelled = False
self.cancelling = False
@@ -704,23 +769,35 @@ class LanesView(_GetchModes):
lane.last_line_at = self._now()
lane.progress = None
lane.progress_kind = ""
+ self._console_log.line(
+ f"--- [{lane.title}] {event.get('title') or ''} ---")
elif kind == "line":
self._ingest_lane_line(lane, event.get("text") or "")
elif kind == "step_done":
- lane.results[event["index"]] = event.get("rc") or 0
+ index = event["index"]
+ rc = event.get("rc") or 0
+ lane.results[index] = rc
lane.current = None
lane.last_line_at = None
lane.progress = None
lane.progress_kind = ""
+ self._console_log.line(
+ f"[{'OK' if rc == 0 else 'FAIL'}] [{lane.title}] "
+ f"{lane.steps[index].title} (exit {rc})")
elif kind == "step_cancelled":
- lane.cancelled_step = event["index"]
+ index = event["index"]
+ lane.cancelled_step = index
lane.current = None
lane.last_line_at = None
lane.progress = None
lane.progress_kind = ""
+ self._console_log.line(
+ f"[x] [{lane.title}] {lane.steps[index].title} (cancelled)")
elif kind == "lane_finish":
lane.rc = event.get("rc") or 0
lane.finished = True
+ self._console_log.line(
+ f"=== [{lane.title}] finished (exit {lane.rc}) ===")
def _ingest_lane_line(self, lane: _LaneState, text: str) -> None:
"""Fold one output line into LANE's log tail and progress bar."""
@@ -736,8 +813,9 @@ class LanesView(_GetchModes):
if kind == "bytes":
return
lane.log_tail.append(line)
- if len(lane.log_tail) > _LOG_TAIL:
- del lane.log_tail[: len(lane.log_tail) - _LOG_TAIL]
+ if len(lane.log_tail) > _LOG_KEEP:
+ del lane.log_tail[: len(lane.log_tail) - _LOG_KEEP]
+ self._console_log.line(line)
def _drain(self) -> None:
for lane in self._lanes:
@@ -762,6 +840,7 @@ class LanesView(_GetchModes):
self.phase = "error"
break
self.finished_at = self._now()
+ self._console_log.line(f"=== {self.phase} ===")
def _now(self) -> float:
return self._clock()
@@ -814,6 +893,7 @@ class LanesView(_GetchModes):
# must not make later hub dialogs dismiss themselves.
self._blocking()
finally:
+ self._console_log.close()
sys.stdout, sys.stderr = saved_out, saved_err
def _get_key(self) -> Optional[int]:
@@ -935,7 +1015,7 @@ class LanesView(_GetchModes):
theme["accent"])
row += 1
- for line in lane.log_tail[-_LOG_TAIL:]:
+ for line in lane.log_tail:
if row >= y + h - 1:
break
_text(scr, theme, row, x + 1, _fit(line, w - 3), theme["dim"])