diff --git a/src/poly/cli_commands/branch.py b/src/poly/cli_commands/branch.py index 89c881e1..28bcf107 100644 --- a/src/poly/cli_commands/branch.py +++ b/src/poly/cli_commands/branch.py @@ -444,8 +444,8 @@ def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: P branch_history_parser.add_argument( "--limit", type=int, - default=10, - help="Number of history entries to show. Defaults to 10.", + default=None, + help="Maximum number of history entries to show. Shows all by default.", ) branch_history_parser.set_defaults(branch_subcommand="history") @@ -1575,10 +1575,10 @@ def branch_history( base_path: str, branch_name: Optional[str] = None, output_json: bool = False, - limit: int = 10, + limit: Optional[int] = None, ) -> None: """Show the history of a branch in the Agent Studio project.""" - from poly.output.console import plain, print_branch_history, warning + from poly.output.console import paged_output, plain, print_branch_history, warning project = load_project(base_path, output_json=output_json) @@ -1607,7 +1607,8 @@ def branch_history( return history = project.get_branch_history(branch_id) - history = history[:limit] + if limit is not None: + history = history[:limit] if output_json: json_print({"branch_name": branch_name, "branch_id": branch_id, "history": history}) @@ -1617,8 +1618,9 @@ def branch_history( plain(f"[muted]No history found for branch '{branch_name}'.[/muted]") return - plain(f"History for branch '{branch_name}':") - print_branch_history(history) + with paged_output(): + plain(f"History for branch '{branch_name}':") + print_branch_history(history) @classmethod def branch_rename( diff --git a/src/poly/cli_commands/conversations.py b/src/poly/cli_commands/conversations.py index 52e8de4d..27d22e44 100644 --- a/src/poly/cli_commands/conversations.py +++ b/src/poly/cli_commands/conversations.py @@ -159,7 +159,7 @@ def conversations_list( offset: Number of conversations to skip. output_json: If True, emit machine-readable JSON. """ - from poly.output.console import info, print_conversations + from poly.output.console import info, paged_output, print_conversations project = load_project(base_path, output_json=output_json) result = AgentStudioInterface.list_conversations( @@ -176,7 +176,8 @@ def conversations_list( if not conversations: info("No conversations found.") return - print_conversations(conversations, url_builder=project.get_conversation_url) + with paged_output(): + print_conversations(conversations, url_builder=project.get_conversation_url) @classmethod def conversations_get( diff --git a/src/poly/cli_commands/deployments.py b/src/poly/cli_commands/deployments.py index 64739e5f..3149e964 100644 --- a/src/poly/cli_commands/deployments.py +++ b/src/poly/cli_commands/deployments.py @@ -65,8 +65,8 @@ def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: P deployment_list_parser.add_argument( "--limit", type=int, - default=10, - help="Number of versions to show. Defaults to 10.", + default=None, + help="Maximum number of versions to show. Shows all by default.", ) deployment_list_parser.add_argument( "--offset", @@ -487,7 +487,7 @@ def deployments_list( cls, base_path: str, environment: Optional[str] = None, - limit: int = 10, + limit: Optional[int] = None, offset: int = 0, version_hash: str = None, output_json: bool = False, @@ -495,21 +495,21 @@ def deployments_list( ) -> None: """List deployment history for the project. - By default shows the 10 most recent deployments for live on projects using - simplified deployments, and for sandbox otherwise. Pass version_hash to start - the listing from a specific version. Use details for full per-deployment metadata. + By default shows all deployments for live on projects using simplified + deployments, and for sandbox otherwise. Pass version_hash to start the listing + from a specific version. Use details for full per-deployment metadata. Args: base_path: Base path for the project. environment: Environment to query — sandbox, pre-release, or live. Defaults to the environment holding the project's deployments. - limit: Maximum number of versions to show. + limit: Maximum number of versions to show. Shows all by default. offset: Number of versions to skip before showing results. version_hash: Start listing from this version hash (overrides offset). output_json: If True, print result as JSON instead of rich text. details: If True, print full metadata for each deployment. """ - from poly.output.console import error, print_deployments + from poly.output.console import error, paged_output, print_deployments project = load_project(base_path, output_json=output_json) if environment is None: @@ -535,7 +535,8 @@ def deployments_list( return offset = version_idx - versions = versions[offset : offset + limit] + end = offset + limit if limit is not None else None + versions = versions[offset:end] if output_json: json_output = { "versions": versions, @@ -543,7 +544,8 @@ def deployments_list( } json_print(json_output) else: - print_deployments(versions, active_deployment_hashes, details=details) + with paged_output(): + print_deployments(versions, active_deployment_hashes, details=details) @classmethod def deployments_show( @@ -1006,7 +1008,7 @@ def ab_test_list( output_json: bool = False, ) -> None: """List A/B tests for the project.""" - from poly.output.console import print_ab_tests + from poly.output.console import paged_output, print_ab_tests project = load_project(base_path, output_json=output_json) ab_tests = project.list_ab_tests(limit=limit) @@ -1014,7 +1016,8 @@ def ab_test_list( json_print({"success": True, "ab_tests": ab_tests}) else: dep_map = cls._fetch_deployment_map(project) if ab_tests else {} - print_ab_tests(ab_tests, deployments=dep_map) + with paged_output(): + print_ab_tests(ab_tests, deployments=dep_map) @classmethod def ab_test_active( diff --git a/src/poly/cli_commands/testing.py b/src/poly/cli_commands/testing.py index 960f180c..7d254ea1 100644 --- a/src/poly/cli_commands/testing.py +++ b/src/poly/cli_commands/testing.py @@ -252,7 +252,7 @@ def testing_list( output_json: bool = False, ) -> None: """List test runs.""" - from poly.output.console import print_test_run_list + from poly.output.console import paged_output, print_test_run_list project = load_project(base_path) result = project.list_test_runs(limit=limit, offset=offset) @@ -261,7 +261,8 @@ def testing_list( json_print({"success": True, "test_runs": result}) return - print_test_run_list(result) + with paged_output(): + print_test_run_list(result) @classmethod def testing_show( diff --git a/src/poly/output/console.py b/src/poly/output/console.py index 892219d4..9a350fa5 100644 --- a/src/poly/output/console.py +++ b/src/poly/output/console.py @@ -9,7 +9,8 @@ import logging import os import sys -from collections.abc import Callable +from collections.abc import Callable, Iterator +from contextlib import contextmanager from datetime import datetime from typing import Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError @@ -17,6 +18,7 @@ from rich import box from rich.console import Console, Group from rich.live import Live +from rich.pager import Pager, SystemPager from rich.panel import Panel from rich.spinner import Spinner from rich.syntax import Syntax @@ -55,6 +57,36 @@ def set_verbose(verbose: bool) -> None: _verbose = verbose +class _OverflowPager(Pager): + """Pager that only engages when the content overflows the screen. + + Rich's default pager sends everything to ``less``, so even a three-row table + would open the pager and demand ``q``. git avoids that with ``less -F`` + (quit-if-one-screen); deciding it here instead means it also holds for a + reader whose ``PAGER`` is ``bat`` or ``more``, or whose ``LESS`` has no + ``F``. Spawning the pager, colour passthrough, ctrl-c and quitting early are + all left to Rich and pydoc, which already handle them. + """ + + def show(self, content: str) -> None: + """Write content straight out if it fits, otherwise hand it to the pager.""" + # Rich has already wrapped to the console width, so newlines are rows. + if content.count("\n") < console.size.height: + console.file.write(content) + else: + SystemPager().show(content) + + +@contextmanager +def paged_output(enabled: bool = True) -> Iterator[None]: + """Pipe output through the system pager when enabled and stdout is a TTY.""" + if enabled and console.is_terminal: + with console.pager(pager=_OverflowPager(), styles=True): + yield + else: + yield + + # ── Helpers ────────────────────────────────────────────────────────── @@ -307,7 +339,7 @@ def print_branch_history(commits: list[dict[str, Any]]) -> None: console.print("[muted]No commits found for this branch.[/muted]") return - table = Table(box=None, show_header=False, header_style="bold", padding=(0, 1)) + table = Table(box=None, show_header=True, header_style="bold", padding=(0, 1)) table.add_column("Merged At", no_wrap=True) table.add_column("Branch", no_wrap=True) table.add_column("Merged By", no_wrap=True) @@ -699,7 +731,7 @@ def print_deployments( if not details: table = Table( box=None, - show_header=False, + show_header=True, header_style="bold", padding=(0, 1), ) diff --git a/src/poly/tests/cli_test.py b/src/poly/tests/cli_test.py index 1e83d4e9..43df43a6 100644 --- a/src/poly/tests/cli_test.py +++ b/src/poly/tests/cli_test.py @@ -1979,17 +1979,27 @@ def test_no_versions_calls_error(self, mock_error): self.assertIn("No versions found", mock_error.call_args[0][0]) @patch("poly.output.console.print_deployments") - def test_default_call_shows_first_ten(self, mock_print_dep): - """Default call (no hash, no json) displays the first 10 versions.""" + def test_default_call_shows_all(self, mock_print_dep): + """Default call (no hash, no json, no limit) displays all versions.""" self.proj.get_deployments.return_value = (self.versions, self.active_hashes) DeploymentsCommand.deployments_list(TEST_DIR) mock_print_dep.assert_called_once() displayed_versions = mock_print_dep.call_args[0][0] - self.assertEqual(len(displayed_versions), 10) + self.assertEqual(len(displayed_versions), 15) self.assertEqual(displayed_versions[0]["name"], "v0") + @patch("poly.output.console.print_deployments") + def test_explicit_limit(self, mock_print_dep): + """--limit caps the number of displayed versions.""" + self.proj.get_deployments.return_value = (self.versions, self.active_hashes) + + DeploymentsCommand.deployments_list(TEST_DIR, limit=5) + + displayed_versions = mock_print_dep.call_args[0][0] + self.assertEqual(len(displayed_versions), 5) + @patch("poly.cli_commands.deployments.json_print") def test_output_json_calls_json_print(self, mock_json_print): """print_deployments with output_json=True calls json_print.""" @@ -2001,7 +2011,7 @@ def test_output_json_calls_json_print(self, mock_json_print): output = mock_json_print.call_args[0][0] self.assertIn("versions", output) self.assertIn("active_deployment_hashes", output) - self.assertEqual(len(output["versions"]), 10) + self.assertEqual(len(output["versions"]), 15) @patch("poly.output.console.print_deployments") def test_hash_sets_offset(self, mock_print_dep): @@ -3632,8 +3642,8 @@ def test_limit_truncates_history(self, mock_plain, mock_print_history): @patch("poly.output.console.print_branch_history") @patch("poly.output.console.plain") - def test_default_limit_is_10(self, mock_plain, mock_print_history): - """Without --limit, history is capped at 10 entries.""" + def test_no_limit_shows_all(self, mock_plain, mock_print_history): + """Without --limit, all history entries are shown.""" self.proj.get_branch_history.return_value = [ {"mergedAt": f"2026-07-{i:02d}"} for i in range(1, 21) ] @@ -3642,7 +3652,7 @@ def test_default_limit_is_10(self, mock_plain, mock_print_history): mock_print_history.assert_called_once() printed = mock_print_history.call_args[0][0] - self.assertEqual(len(printed), 10) + self.assertEqual(len(printed), 20) @patch("poly.cli_commands.branch.json_print") def test_limit_applies_to_json_output(self, mock_json): diff --git a/src/poly/tests/console_test.py b/src/poly/tests/console_test.py index e56c7940..a57e7d16 100644 --- a/src/poly/tests/console_test.py +++ b/src/poly/tests/console_test.py @@ -4,10 +4,15 @@ """ import unittest +from unittest.mock import MagicMock, PropertyMock, patch + +from rich.console import Console, ConsoleDimensions from poly.output.console import ( + _OverflowPager, console, flatten_branch_tree, + paged_output, print_archived_branches, print_branch_history, print_releases_branches, @@ -380,3 +385,99 @@ def test_table_does_not_mark_a_live_parent(self): self.assertIn("Active Release", output) self.assertNotIn("(archived)", output) + + +class OverflowPagerTest(unittest.TestCase): + """Tests for _OverflowPager, which pages only when output overflows.""" + + SCREEN_HEIGHT = 25 + + def setUp(self): + size_patcher = patch.object( + Console, + "size", + new_callable=PropertyMock, + return_value=ConsoleDimensions(80, self.SCREEN_HEIGHT), + ) + size_patcher.start() + self.addCleanup(size_patcher.stop) + + def _show(self, content: str): + """Run the pager over content, returning (written_directly, system_pager).""" + written = [] + with ( + patch.object( + Console, + "file", + new_callable=PropertyMock, + return_value=MagicMock(write=written.append), + ), + patch("poly.output.console.SystemPager") as mock_system_pager, + ): + _OverflowPager().show(content) + return "".join(written), mock_system_pager + + def test_content_shorter_than_screen_is_written_directly(self): + """Output that fits on one screen bypasses the pager entirely.""" + content = "row\n" * (self.SCREEN_HEIGHT - 1) + + written, mock_system_pager = self._show(content) + + self.assertEqual(written, content) + mock_system_pager.assert_not_called() + + def test_content_longer_than_screen_is_handed_to_the_pager(self): + """Output that overflows the screen goes to the pager, not stdout.""" + content = "row\n" * (self.SCREEN_HEIGHT + 10) + + written, mock_system_pager = self._show(content) + + self.assertEqual(written, "") + mock_system_pager.return_value.show.assert_called_once_with(content) + + def test_content_exactly_filling_the_screen_is_not_paged(self): + """The boundary case stays inline rather than opening the pager.""" + content = "row\n" * (self.SCREEN_HEIGHT - 1) + + _, mock_system_pager = self._show(content) + + mock_system_pager.assert_not_called() + + +class PagedOutputTest(unittest.TestCase): + """Tests for the paged_output context manager's TTY and enabled guards.""" + + def test_no_paging_when_stdout_is_not_a_terminal(self): + """Piped and redirected output is never paged, matching git.""" + with ( + patch.object(Console, "is_terminal", new_callable=PropertyMock, return_value=False), + patch.object(console, "pager") as mock_pager, + ): + with paged_output(): + pass + + mock_pager.assert_not_called() + + def test_no_paging_when_explicitly_disabled(self): + """Passing enabled=False opts a caller out even on a terminal.""" + with ( + patch.object(Console, "is_terminal", new_callable=PropertyMock, return_value=True), + patch.object(console, "pager") as mock_pager, + ): + with paged_output(enabled=False): + pass + + mock_pager.assert_not_called() + + def test_paging_uses_the_overflow_pager_with_styles(self): + """On a terminal the custom pager is used, with styles kept for colour.""" + with ( + patch.object(Console, "is_terminal", new_callable=PropertyMock, return_value=True), + patch.object(console, "pager") as mock_pager, + ): + with paged_output(): + pass + + mock_pager.assert_called_once() + self.assertIsInstance(mock_pager.call_args.kwargs["pager"], _OverflowPager) + self.assertTrue(mock_pager.call_args.kwargs["styles"])