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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "slopometry"
version = "2026.4.7"
version = "2026.4.15"
description = "Opinionated code quality metrics for code agents and humans"
readme = "README.md"
requires-python = ">=3.13"
Expand Down
62 changes: 62 additions & 0 deletions src/slopometry/display/console.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,68 @@
"""Singleton Rich Console instance for consistent output across the application."""

import logging
import shutil
import subprocess
import sys

from rich.console import Console
from rich.pager import Pager

logger = logging.getLogger(__name__)


def _show_with_less(content: str) -> None:
"""Page content through less -R, falling back to direct stdout on failure.

Uses -R so less correctly treats ANSI color codes as zero-width.
Without it, less counts escape-code bytes as visual width, which in
narrow terminals makes it miscalculate line positions and show (END)
before all content is reachable.
"""
terminal_height = shutil.get_terminal_size().lines
content_lines = content.count("\n")
if content_lines <= terminal_height - 1:
sys.stdout.write(content)
sys.stdout.flush()
return

try:
proc = subprocess.Popen(
["less", "-R"],
stdin=subprocess.PIPE,
errors="backslashreplace",
)
pipe = proc.stdin
assert pipe is not None
try:
with pipe:
try:
pipe.write(content)
except KeyboardInterrupt:
pass
except OSError:
# Broken pipe: user quit less before all content was written
logger.debug("Broken pipe writing to less (user quit early)")
while True:
try:
proc.wait()
break
except KeyboardInterrupt:
pass
except FileNotFoundError:
logger.debug("less not found, writing directly to stdout")
sys.stdout.write(content)
sys.stdout.flush()


class _LessPager(Pager):
"""Adapter to satisfy Rich's Pager protocol."""

def show(self, content: str) -> None:
_show_with_less(content)


styled_pager = _LessPager()

# Single console instance used throughout the application
# This ensures pager context works correctly across modules
Expand Down
17 changes: 8 additions & 9 deletions src/slopometry/solo/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import click

from slopometry.display.console import console
from slopometry.display.console import console, styled_pager

if TYPE_CHECKING:
from slopometry.core.models import ImpactAssessment, RepoBaseline, SessionStatistics
Expand Down Expand Up @@ -156,7 +156,7 @@ def list_sessions(limit: int, show_all: bool, pager: bool) -> None:

table = create_sessions_table(sessions_data)
if pager:
with console.pager(styles=True):
with console.pager(pager=styled_pager, styles=True):
console.print(table)
else:
console.print(table)
Expand Down Expand Up @@ -209,7 +209,7 @@ def _display() -> None:
console.print(f"\n[dim]Analysis completed in {elapsed:.1f}s[/dim]")

if pager:
with console.pager(styles=True):
with console.pager(pager=styled_pager, styles=True):
_display()
else:
_display()
Expand Down Expand Up @@ -304,7 +304,7 @@ def _display() -> None:
console.print(f"\n[dim]Analysis completed in {elapsed:.1f}s[/dim]")

if pager:
with console.pager(styles=True):
with console.pager(pager=styled_pager, styles=True):
_display()
else:
_display()
Expand Down Expand Up @@ -748,10 +748,9 @@ def save_transcript(session_id: str | None, output_dir: str, yes: bool) -> None:
try:
meta = json.loads(row["metadata"])
model_id = meta.get("model_id")
except (json.JSONDecodeError, ValueError):
pass
except (json.JSONDecodeError, ValueError) as e:
logger.debug(f"Failed to parse MessageUpdated metadata for session {session_id}: {e}")

# Extract opencode_version from Stop event metadata
stop_row = conn.execute(
"""
SELECT metadata FROM hook_events
Expand All @@ -764,8 +763,8 @@ def save_transcript(session_id: str | None, output_dir: str, yes: bool) -> None:
try:
stop_meta = json.loads(stop_row["metadata"])
opencode_version = stop_meta.get("opencode_version")
except (json.JSONDecodeError, ValueError):
pass
except (json.JSONDecodeError, ValueError) as e:
logger.debug(f"Failed to parse Stop event metadata for session {session_id}: {e}")

metadata = SessionMetadata(
session_id=stats.session_id,
Expand Down
4 changes: 2 additions & 2 deletions src/slopometry/summoner/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import click
from click.shell_completion import CompletionItem

from slopometry.display.console import console
from slopometry.display.console import console, styled_pager

# Imports moved inside functions to optimize startup time

Expand Down Expand Up @@ -547,7 +547,7 @@ def current_impact(
summary = CurrentImpactSummary.from_analysis(analysis)
print(summary.model_dump_json(indent=2))
elif pager:
with console.pager(styles=True):
with console.pager(pager=styled_pager, styles=True):
display_current_impact_analysis(
analysis, show_file_details=file_details, behavioral_trends=behavioral_trends
)
Expand Down
Loading