From fdf2f0b10a252249b755b827fd31538cf889f9e3 Mon Sep 17 00:00:00 2001 From: shuki Date: Fri, 6 Mar 2026 21:47:49 +0200 Subject: [PATCH] Show job log inline below running tasks table View Log now displays the log in a RichLog panel below the buttons instead of opening a modal screen. The log tails the file in real-time with a 0.3s poll interval while the job is running. Co-Authored-By: Claude Opus 4.6 --- tui/gniza.tcss | 3 +- tui/screens/running_tasks.py | 86 +++++++++++++++++++++++++++++------- 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/tui/gniza.tcss b/tui/gniza.tcss index 3839cfc..74e9ad7 100644 --- a/tui/gniza.tcss +++ b/tui/gniza.tcss @@ -278,7 +278,8 @@ SelectionList { } /* Log viewer */ -#log-viewer { +#log-viewer, +#rt-log-viewer { height: 1fr; min-height: 8; border: round $accent; diff --git a/tui/screens/running_tasks.py b/tui/screens/running_tasks.py index 859a7fa..07bf705 100644 --- a/tui/screens/running_tasks.py +++ b/tui/screens/running_tasks.py @@ -1,12 +1,14 @@ from datetime import datetime +from pathlib import Path from textual.app import ComposeResult from textual.screen import Screen -from textual.widgets import Header, Footer, Static, Button, DataTable +from textual.widgets import Header, Footer, Static, Button, DataTable, RichLog from textual.containers import Vertical, Horizontal +from textual.timer import Timer from tui.jobs import job_manager -from tui.widgets import ConfirmDialog, OperationLog +from tui.widgets import ConfirmDialog class RunningTasksScreen(Screen): @@ -23,6 +25,7 @@ class RunningTasksScreen(Screen): yield Button("Kill Job", variant="error", id="btn-rt-kill") yield Button("Clear Finished", variant="warning", id="btn-rt-clear") yield Button("Back", id="btn-rt-back") + yield RichLog(id="rt-log-viewer", wrap=True, highlight=True) yield Footer() def on_mount(self) -> None: @@ -31,6 +34,9 @@ class RunningTasksScreen(Screen): table.cursor_type = "row" self._refresh_table() self._timer = self.set_interval(1, self._refresh_table) + self._log_timer: Timer | None = None + self._viewing_job_id: str | None = None + self._log_file_pos: int = 0 def _format_duration(self, job) -> str: end = job.finished_at or datetime.now() @@ -47,7 +53,6 @@ class RunningTasksScreen(Screen): def _refresh_table(self) -> None: job_manager.check_reconnected() table = self.query_one("#rt-table", DataTable) - # Preserve cursor position old_row = table.cursor_coordinate.row if table.row_count > 0 else 0 table.clear() for job in job_manager.list_jobs(): @@ -73,20 +78,67 @@ class RunningTasksScreen(Screen): elif event.button.id == "btn-rt-kill": self._kill_selected() elif event.button.id == "btn-rt-view": - table = self.query_one("#rt-table", DataTable) - if table.row_count == 0: - self.notify("No jobs to view", severity="warning") - return - row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate) - job_id = str(row_key) - job = job_manager.get_job(job_id) - if job: - log_screen = OperationLog( - title=job.label, - show_spinner=job.status == "running", - job_id=job.id, - ) - self.app.push_screen(log_screen) + self._view_selected_log() + + def _view_selected_log(self) -> None: + table = self.query_one("#rt-table", DataTable) + if table.row_count == 0: + self.notify("No jobs to view", severity="warning") + return + row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate) + job_id = str(row_key) + job = job_manager.get_job(job_id) + if not job: + self.notify("Job not found", severity="warning") + return + log_viewer = self.query_one("#rt-log-viewer", RichLog) + log_viewer.clear() + self._viewing_job_id = job_id + self._log_file_pos = 0 + # Load existing content from log file + if job._log_file and Path(job._log_file).is_file(): + try: + content = Path(job._log_file).read_text() + self._log_file_pos = len(content.encode()) + for line in content.splitlines(): + log_viewer.write(line) + except OSError: + pass + elif job.output: + for line in job.output: + log_viewer.write(line) + # Start polling for new content if job is running + if self._log_timer: + self._log_timer.stop() + if job.status == "running": + self._log_timer = self.set_interval(0.3, self._poll_log) + + def _poll_log(self) -> None: + if not self._viewing_job_id: + return + job = job_manager.get_job(self._viewing_job_id) + if not job: + if self._log_timer: + self._log_timer.stop() + return + try: + log_viewer = self.query_one("#rt-log-viewer", RichLog) + except Exception: + return + if job._log_file and Path(job._log_file).is_file(): + try: + with open(job._log_file, "r") as f: + f.seek(self._log_file_pos) + new_data = f.read() + if new_data: + self._log_file_pos += len(new_data.encode()) + for line in new_data.splitlines(): + log_viewer.write(line) + except OSError: + pass + if job.status != "running": + if self._log_timer: + self._log_timer.stop() def _kill_selected(self) -> None: table = self.query_one("#rt-table", DataTable)