From 69d5b445ab36e1cdf8b27402d1dd4d1b179fd3d7 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Fri, 13 Mar 2026 15:33:27 -0300 Subject: [PATCH] feat(cli): draft F45 tui log buffering --- .../REPORT.md | 22 +++++++++ .../F45-tui-performance-optimization/SPEC.md | 28 +++++++++++ src/aignt_os/cli/dashboard.py | 47 +++++++++++++++++-- src/aignt_os/cli/rendering.py | 14 ++++++ src/aignt_os/config.py | 1 + tests/unit/test_dashboard_log_viewer.py | 35 ++++++++++++++ tests/unit/test_tui_rendering.py | 34 ++++++++++++++ 7 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 features/F45-tui-performance-optimization/REPORT.md create mode 100644 features/F45-tui-performance-optimization/SPEC.md create mode 100644 tests/unit/test_dashboard_log_viewer.py create mode 100644 tests/unit/test_tui_rendering.py diff --git a/features/F45-tui-performance-optimization/REPORT.md b/features/F45-tui-performance-optimization/REPORT.md new file mode 100644 index 0000000..0a25a32 --- /dev/null +++ b/features/F45-tui-performance-optimization/REPORT.md @@ -0,0 +1,22 @@ +# Execution Report - F45 TUI Performance Optimization + +## Summary + +Added bounded log rendering for the dashboard log viewer so large persisted outputs do not overwhelm the TUI. + +## Changes + +- Added `tui_log_buffer_lines` to `AppSettings` with a default of `1000`. +- Added `truncate_logs()` in `src/aignt_os/cli/rendering.py` to keep only the newest lines and prepend a truncation marker. +- Updated `LogViewer` in `src/aignt_os/cli/dashboard.py` to optionally watch a log file, reload it when the file size changes, and render the truncated content. +- Updated `RunDashboard.action_show_logs()` to pass the log file path into the viewer and truncate the initial payload before opening the modal. +- Added unit coverage for truncation behavior and log viewer refresh handling. + +## Validation + +- `python -m pytest tests/unit/test_dashboard_log_viewer.py tests/unit/test_tui_rendering.py -q` +- `./scripts/commit-check.sh --no-sync --skip-branch-validation --skip-docker --skip-security` + +## Residual Risk + +The viewer currently uses file size changes as the refresh heuristic. This is sufficient for the MVP branch stack, but a future feature could switch to content hashing if operators ever need same-size rewrites to trigger refreshes. diff --git a/features/F45-tui-performance-optimization/SPEC.md b/features/F45-tui-performance-optimization/SPEC.md new file mode 100644 index 0000000..432a86c --- /dev/null +++ b/features/F45-tui-performance-optimization/SPEC.md @@ -0,0 +1,28 @@ +--- +id: F45-tui-performance-optimization +type: feature +summary: "Optimize TUI log rendering for large outputs" +inputs: + - Configurable TUI log buffer limit in `AppSettings` +outputs: + - Truncated log rendering in the dashboard log viewer + - Responsive log modal even for large persisted outputs +acceptance_criteria: + - "Configuration `tui_log_buffer_lines` exists with default `1000`" + - "The dashboard log viewer truncates old lines and keeps the newest lines visible" + - "A truncation marker indicates how many lines were omitted" + - "The log viewer can refresh from disk without reloading unchanged files" + - "Unit tests validate truncation and refresh behavior" +non_goals: + - Implement virtualized infinite scrolling + - Change persisted log storage format + - Add distributed log streaming +--- + +# Context + +The AIgnt OS dashboard can open persisted step logs inside a TUI modal. Large logs can degrade responsiveness and force the UI to repaint far more text than the operator needs. + +# Objective + +Add a bounded log buffer for the dashboard viewer so the TUI remains responsive while still showing the latest output that matters to an operator. diff --git a/src/aignt_os/cli/dashboard.py b/src/aignt_os/cli/dashboard.py index ede2c1a..850970c 100644 --- a/src/aignt_os/cli/dashboard.py +++ b/src/aignt_os/cli/dashboard.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import Any from textual.app import App, ComposeResult @@ -18,6 +19,7 @@ TabPane, ) +from aignt_os.cli.rendering import truncate_logs from aignt_os.config import AppSettings from aignt_os.persistence import ArtifactStore, RunRecord, RunRepository, RunStepRecord @@ -59,10 +61,12 @@ class LogViewer(ModalScreen[None]): BINDINGS = [("escape", "app.pop_screen", "Close")] - def __init__(self, title: str, content: str) -> None: + def __init__(self, title: str, content: str, path: str | None = None) -> None: super().__init__() self.dialog_title = title self.log_content = content + self.log_path = path + self._last_size: int | None = None def compose(self) -> ComposeResult: yield Vertical( @@ -75,6 +79,32 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: log_widget = self.query_one("#log_content", RichLog) log_widget.write(self.log_content) + if self.log_path: + self.set_interval(1.0, self.refresh_log) + + def refresh_log(self) -> None: + if not self.log_path: + return + + try: + current_path = Path(self.log_path) + if not current_path.exists(): + return + + current_size = current_path.stat().st_size + if current_size == self._last_size: + return + + settings = AppSettings() + new_content = current_path.read_text(encoding="utf-8", errors="replace") + truncated = truncate_logs(new_content, settings.tui_log_buffer_lines) + + log_widget = self.query_one("#log_content", RichLog) + log_widget.clear() + log_widget.write(truncated) + self._last_size = current_size + except Exception: + pass class RunHeader(Static): @@ -458,6 +488,7 @@ def action_show_logs(self) -> None: if self.step_detail.step: step = self.step_detail.step log_content = "No logs available." + log_path: str | None = None paths_to_check = [] if step.clean_output_path: @@ -467,17 +498,23 @@ def action_show_logs(self) -> None: for path_str in paths_to_check: try: - from pathlib import Path - p = Path(path_str) if p.exists(): - log_content = p.read_text(encoding="utf-8", errors="replace") + full_content = p.read_text(encoding="utf-8", errors="replace") + log_content = truncate_logs( + full_content, self.settings.tui_log_buffer_lines + ) + log_path = path_str break except Exception as e: log_content = f"Error reading log file: {e}" self.push_screen( - LogViewer(f"Logs: Step {step.step_id} ({step.tool_name})", log_content) + LogViewer( + f"Logs: Step {step.step_id} ({step.tool_name})", + log_content, + path=log_path, + ) ) else: self.notify("Select a step first.", severity="warning") diff --git a/src/aignt_os/cli/rendering.py b/src/aignt_os/cli/rendering.py index 50a6b5e..3d9ec94 100644 --- a/src/aignt_os/cli/rendering.py +++ b/src/aignt_os/cli/rendering.py @@ -21,6 +21,20 @@ class RunArtifactPreview: truncated: bool +def truncate_logs(text: str | None, max_lines: int) -> str: + if not text: + return "" + + lines = text.splitlines(keepends=True) + if len(lines) <= max_lines: + return text + + truncated_count = len(lines) - max_lines + kept_lines = lines[-max_lines:] + marker = f"\n... {truncated_count} lines truncated ...\n" + return marker + "".join(kept_lines) + + def render_runtime_status( state: RuntimeState, *, diff --git a/src/aignt_os/config.py b/src/aignt_os/config.py index 96666e3..773a47a 100644 --- a/src/aignt_os/config.py +++ b/src/aignt_os/config.py @@ -30,6 +30,7 @@ class AppSettings(BaseSettings): execution_timeout_seconds: float = Field(default=300.0, gt=0) max_retries: int = Field(default=3, ge=0) + tui_log_buffer_lines: int = Field(default=1000, gt=0) @property def runtime_state_dir_resolved(self) -> Path: diff --git a/tests/unit/test_dashboard_log_viewer.py b/tests/unit/test_dashboard_log_viewer.py new file mode 100644 index 0000000..2f52c57 --- /dev/null +++ b/tests/unit/test_dashboard_log_viewer.py @@ -0,0 +1,35 @@ +from unittest.mock import MagicMock + +from aignt_os.cli.dashboard import LogViewer + + +def test_log_viewer_refreshes_file_when_size_changes(tmp_path, monkeypatch): + log_file = tmp_path / "viewer.log" + log_file.write_text("line1\nline2\nline3\n", encoding="utf-8") + monkeypatch.setenv("AIGNT_OS_TUI_LOG_BUFFER_LINES", "2") + + viewer = LogViewer("Test Log", "Initial", path=str(log_file)) + log_widget = MagicMock() + viewer.query_one = MagicMock(return_value=log_widget) + + viewer.refresh_log() + + log_widget.clear.assert_called_once() + log_widget.write.assert_called_once_with("\n... 1 lines truncated ...\nline2\nline3\n") + + log_widget.reset_mock() + viewer.refresh_log() + + log_widget.clear.assert_not_called() + log_widget.write.assert_not_called() + + +def test_log_viewer_refresh_ignores_missing_file(): + viewer = LogViewer("Test Log", "Initial", path="/tmp/does-not-exist.log") + log_widget = MagicMock() + viewer.query_one = MagicMock(return_value=log_widget) + + viewer.refresh_log() + + log_widget.clear.assert_not_called() + log_widget.write.assert_not_called() diff --git a/tests/unit/test_tui_rendering.py b/tests/unit/test_tui_rendering.py new file mode 100644 index 0000000..38ac271 --- /dev/null +++ b/tests/unit/test_tui_rendering.py @@ -0,0 +1,34 @@ +from aignt_os.cli.rendering import truncate_logs + + +def test_truncate_logs_empty(): + assert truncate_logs("", 10) == "" + assert truncate_logs(None, 10) == "" + + +def test_truncate_logs_below_limit(): + text = "line1\nline2\nline3" + assert truncate_logs(text, 10) == text + + +def test_truncate_logs_at_limit(): + text = "line1\nline2\nline3" + assert truncate_logs(text, 3) == text + + +def test_truncate_logs_exceeds_limit(): + text = "line1\nline2\nline3\nline4\nline5" + truncated = truncate_logs(text, 2) + + assert "truncated" in truncated + assert "3 lines truncated" in truncated + assert truncated.endswith("line4\nline5") + assert "line1" not in truncated + + +def test_truncate_logs_single_line_limit(): + text = "line1\nline2\nline3" + truncated = truncate_logs(text, 1) + + assert "truncated" in truncated + assert truncated.endswith("line3")