Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions features/F45-tui-performance-optimization/REPORT.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions features/F45-tui-performance-optimization/SPEC.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 42 additions & 5 deletions src/aignt_os/cli/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from pathlib import Path
from typing import Any

from textual.app import App, ComposeResult
Expand All @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -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")
Expand Down
14 changes: 14 additions & 0 deletions src/aignt_os/cli/rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand Down
1 change: 1 addition & 0 deletions src/aignt_os/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
35 changes: 35 additions & 0 deletions tests/unit/test_dashboard_log_viewer.py
Original file line number Diff line number Diff line change
@@ -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()
34 changes: 34 additions & 0 deletions tests/unit/test_tui_rendering.py
Original file line number Diff line number Diff line change
@@ -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")
Loading