From f94d78a42feba92083e9bae473d71453a55ad6e1 Mon Sep 17 00:00:00 2001 From: Gabe Goodhart Date: Mon, 15 Dec 2025 15:20:25 -0700 Subject: [PATCH 1/4] feat: Add logic to truncate long cells in tables https://github.com/contextforge-org/contextforge-cli/issues/6 Branch: ConciseTables-6 Signed-off-by: Gabe Goodhart --- cforge/common.py | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/cforge/common.py b/cforge/common.py index 855d6ba..ab4fd8c 100644 --- a/cforge/common.py +++ b/cforge/common.py @@ -16,7 +16,9 @@ # Third-Party from pydantic import BaseModel from pydantic_core import PydanticUndefined -from rich.console import Console +from rich.console import Console, ConsoleOptions, RenderResult, RenderableType +from rich.segment import Segment +from rich.measure import Measurement from rich.table import Table from rich.panel import Panel from rich.syntax import Syntax @@ -212,6 +214,37 @@ def make_authenticated_request( # ------------------------------------------------------------------------------ +class LineLimit: + """A renderable that limits the number of lines after rich's wrapping.""" + + def __init__(self, renderable: RenderableType, max_lines: int): + """Implement with the wrapped renderable and the max lines to render""" + self.renderable = renderable + self.max_lines = max_lines + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + """Hook the actual rendering to perform the per-line truncation""" + + # Let rich render the content with proper wrapping + lines = console.render_lines(self.renderable, options, pad=False) + + # Limit to max_lines + for i, line in enumerate(lines): + if i >= self.max_lines: + # Optionally add an ellipsis indicator + yield Segment("...") + break + yield from line + yield Segment.line() + + def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement: + """Hook the measurement of this entry to pass through to the wrapped + renderable + """ + + return Measurement.get(console, options, self.renderable) + + def print_json(data: Any, title: Optional[str] = None) -> None: """Pretty print JSON data with Rich. @@ -250,7 +283,7 @@ def print_table( table.add_column(col_name_map.get(column, column), style="cyan") for item in data: - row = [str(item.get(col, "")) for col in columns] + row = [LineLimit(str(item.get(col, "")), max_lines=4) for col in columns] table.add_row(*row) console.print(table) From 50273040c6e29a16357fd488c0bfa988aa2b3ede Mon Sep 17 00:00:00 2001 From: Gabe Goodhart Date: Mon, 15 Dec 2025 16:09:52 -0700 Subject: [PATCH 2/4] test: Full test cov for new truncation logic Co-authored with Bob https://github.com/contextforge-org/contextforge-cli/issues/6 Branch: ConciseTables-6 Signed-off-by: Gabe Goodhart --- tests/test_common.py | 216 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/tests/test_common.py b/tests/test_common.py index cf3aba1..ca30141 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -27,6 +27,7 @@ _INT_SENTINEL_DEFAULT, AuthenticationError, CLIError, + LineLimit, get_app, get_auth_token, get_console, @@ -134,6 +135,195 @@ def test_authentication_error(self) -> None: assert isinstance(error, CLIError) +class TestLineLimit: + """Tests for LineLimit class that truncates rendered content.""" + + def test_line_limit_basic_truncation(self) -> None: + """Test that LineLimit truncates content to max_lines.""" + from rich.text import Text + from rich.console import Console + + console = Console() + # Create text with 5 lines + text = Text("Line 1\nLine 2\nLine 3\nLine 4\nLine 5") + limited = LineLimit(text, max_lines=3) + + # Render to string and verify truncation + with console.capture() as capture: + console.print(limited) + + output = capture.get() + # Should contain first 3 lines + assert "Line 1" in output + assert "Line 2" in output + assert "Line 3" in output + # Should NOT contain lines 4 and 5 + assert "Line 4" not in output + assert "Line 5" not in output + # Should have ellipsis + assert "..." in output + + def test_line_limit_no_truncation_needed(self) -> None: + """Test that LineLimit doesn't truncate when content is within limit.""" + from rich.text import Text + from rich.console import Console + + console = Console() + # Create text with 2 lines, limit to 5 + text = Text("Line 1\nLine 2") + limited = LineLimit(text, max_lines=5) + + with console.capture() as capture: + console.print(limited) + + output = capture.get() + # Should contain both lines + assert "Line 1" in output + assert "Line 2" in output + # Should NOT have ellipsis since no truncation + assert "..." not in output + + def test_line_limit_exact_match(self) -> None: + """Test LineLimit when content exactly matches max_lines.""" + from rich.text import Text + from rich.console import Console + + console = Console() + # Create text with exactly 3 lines + text = Text("Line 1\nLine 2\nLine 3") + limited = LineLimit(text, max_lines=3) + + with console.capture() as capture: + console.print(limited) + + output = capture.get() + # Should contain all 3 lines + assert "Line 1" in output + assert "Line 2" in output + assert "Line 3" in output + # Should NOT have ellipsis since content fits exactly + assert "..." not in output + + def test_line_limit_zero_lines(self) -> None: + """Test LineLimit with max_lines=0 shows only ellipsis.""" + from rich.text import Text + from rich.console import Console + + console = Console() + text = Text("Line 1\nLine 2") + limited = LineLimit(text, max_lines=0) + + with console.capture() as capture: + console.print(limited) + + output = capture.get() + # Should only show ellipsis, no content + assert "..." in output + assert "Line 1" not in output + assert "Line 2" not in output + + def test_line_limit_one_line(self) -> None: + """Test LineLimit with max_lines=1.""" + from rich.text import Text + from rich.console import Console + + console = Console() + text = Text("Line 1\nLine 2\nLine 3") + limited = LineLimit(text, max_lines=1) + + with console.capture() as capture: + console.print(limited) + + output = capture.get() + # Should show only first line and ellipsis + assert "Line 1" in output + assert "..." in output + assert "Line 2" not in output + assert "Line 3" not in output + + def test_line_limit_with_long_single_line(self) -> None: + """Test LineLimit with a single long line that wraps.""" + from rich.text import Text + from rich.console import Console + + console = Console(width=80) # Set fixed width for predictable wrapping + # Create a very long line that will wrap + long_text = "A" * 200 + text = Text(long_text) + limited = LineLimit(text, max_lines=2) + + with console.capture() as capture: + console.print(limited) + + output = capture.get() + # Should contain some A's but be truncated + assert "A" in output + # Should have ellipsis since it wraps to more than 2 lines + assert "..." in output + + def test_line_limit_measurement_passthrough(self) -> None: + """Test that LineLimit passes through measurement to wrapped renderable.""" + from rich.text import Text + from rich.console import Console + + console = Console() + text = Text("Test content") + limited = LineLimit(text, max_lines=3) + + # Get measurement using console's options + measurement = console.measure(limited) + + # Should return a valid Measurement + assert measurement is not None + assert hasattr(measurement, "minimum") + assert hasattr(measurement, "maximum") + + def test_line_limit_with_empty_content(self) -> None: + """Test LineLimit with empty content.""" + from rich.text import Text + from rich.console import Console + + console = Console() + text = Text("") + limited = LineLimit(text, max_lines=3) + + with console.capture() as capture: + console.print(limited) + + output = capture.get() + # Empty content should produce minimal output + # Should not have ellipsis since there's nothing to truncate + assert "..." not in output + + def test_line_limit_preserves_styling(self) -> None: + """Test that LineLimit preserves rich styling in truncated content.""" + from rich.text import Text + from rich.console import Console + + console = Console() + # Create styled text + text = Text() + text.append("Line 1\n", style="bold red") + text.append("Line 2\n", style="italic blue") + text.append("Line 3\n", style="underline green") + text.append("Line 4", style="bold yellow") + + limited = LineLimit(text, max_lines=2) + + with console.capture() as capture: + console.print(limited) + + output = capture.get() + # Should contain first 2 lines + assert "Line 1" in output + assert "Line 2" in output + # Should NOT contain lines 3 and 4 + assert "Line 3" not in output + assert "Line 4" not in output + # Should have ellipsis + assert "..." in output + + class TestMakeAuthenticatedRequest: """Tests for make_authenticated_request function using a server mock.""" @@ -285,6 +475,32 @@ def test_print_table_missing_columns(self, mock_console) -> None: print_table(test_data, "Test Table", columns) mock_console.print.assert_called_once() + def test_print_table_wraps_all_cells_with_line_limit(self) -> None: + """Test that print_table wraps all cell values with LineLimit for truncation.""" + from unittest.mock import patch + + # Create test data with various types + test_data = [ + {"id": 1, "name": "Item 1", "description": "Short text"}, + {"id": 2, "name": "Item 2", "description": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"}, + ] + columns = ["id", "name", "description"] + + # Mock Table.add_row to capture what's passed to it + with patch.object(Table, "add_row") as mock_add_row: + print_table(test_data, "Test Table", columns) + + # Verify add_row was called for each data row + assert mock_add_row.call_count == 2 + + # Check that all arguments to add_row are LineLimit instances + for call in mock_add_row.call_args_list: + args = call[0] # Get positional arguments + for arg in args: + assert isinstance(arg, LineLimit), f"Expected LineLimit but got {type(arg)}" + # Verify max_lines is set to 4 + assert arg.max_lines == 4 + class TestPromptForSchema: """Tests for prompt_for_schema function.""" From 4c01856ea9c9612fd0291e306edcce74ef9dd29a Mon Sep 17 00:00:00 2001 From: Gabe Goodhart Date: Mon, 15 Dec 2025 16:15:33 -0700 Subject: [PATCH 3/4] feat: Make max table lines configurable with config https://github.com/contextforge-org/contextforge-cli/issues/6 Branch: ConciseTables-6 Signed-off-by: Gabe Goodhart --- cforge/common.py | 5 ++++- cforge/config.py | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/cforge/common.py b/cforge/common.py index ab4fd8c..4476a59 100644 --- a/cforge/common.py +++ b/cforge/common.py @@ -278,12 +278,15 @@ def print_table( console = get_console() table = Table(title=title, show_header=True, header_style="bold magenta") col_name_map = col_name_map or {} + max_lines = get_settings().table_max_lines for column in columns: table.add_column(col_name_map.get(column, column), style="cyan") for item in data: - row = [LineLimit(str(item.get(col, "")), max_lines=4) for col in columns] + row = [str(item.get(col, "")) for col in columns] + if max_lines > 0: + row = [LineLimit(cell, max_lines=max_lines) for cell in row] table.add_row(*row) console.print(table) diff --git a/cforge/config.py b/cforge/config.py index 884c865..921fc7d 100644 --- a/cforge/config.py +++ b/cforge/config.py @@ -59,6 +59,9 @@ def _set_database_url_default(self) -> Self: mcpgateway_bearer_token: Optional[str] = None + # Max number of lines for printed tables (<1 => infinite) + table_max_lines: int = 4 + @lru_cache def get_settings() -> CLISettings: From acf7e6a8d3cfc312692760af45cc6cd1ecc1066d Mon Sep 17 00:00:00 2001 From: Gabe Goodhart Date: Mon, 15 Dec 2025 16:21:43 -0700 Subject: [PATCH 4/4] test: Test configurable table cell height https://github.com/contextforge-org/contextforge-cli/issues/6 Branch: ConciseTables-6 Signed-off-by: Gabe Goodhart --- tests/test_common.py | 55 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/test_common.py b/tests/test_common.py index ca30141..c4a28a6 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -501,6 +501,61 @@ def test_print_table_wraps_all_cells_with_line_limit(self) -> None: # Verify max_lines is set to 4 assert arg.max_lines == 4 + def test_print_table_with_custom_max_lines(self, mock_settings) -> None: + """Test that print_table respects custom table_max_lines configuration.""" + from unittest.mock import patch + + # Configure mock_settings with custom max_lines value + mock_settings.table_max_lines = 2 + + test_data = [ + {"id": 1, "name": "Item 1", "description": "Short text"}, + {"id": 2, "name": "Item 2", "description": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"}, + ] + columns = ["id", "name", "description"] + + # Mock Table.add_row to capture what's passed to it + with patch.object(Table, "add_row") as mock_add_row: + print_table(test_data, "Test Table", columns) + + # Verify add_row was called for each data row + assert mock_add_row.call_count == 2 + + # Check that all arguments to add_row are LineLimit instances with custom max_lines + for call in mock_add_row.call_args_list: + args = call[0] # Get positional arguments + for arg in args: + assert isinstance(arg, LineLimit), f"Expected LineLimit but got {type(arg)}" + # Verify max_lines is set to custom value of 2 + assert arg.max_lines == 2 + + def test_print_table_with_disabled_line_limit(self, mock_settings) -> None: + """Test that print_table skips LineLimit wrapping when table_max_lines is 0 or negative.""" + from unittest.mock import patch + + # Configure mock_settings with disabled max_lines value (0) + mock_settings.table_max_lines = 0 + + test_data = [ + {"id": 1, "name": "Item 1", "description": "Short text"}, + {"id": 2, "name": "Item 2", "description": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"}, + ] + columns = ["id", "name", "description"] + + # Mock Table.add_row to capture what's passed to it + with patch.object(Table, "add_row") as mock_add_row: + print_table(test_data, "Test Table", columns) + + # Verify add_row was called for each data row + assert mock_add_row.call_count == 2 + + # Check that arguments to add_row are plain strings, NOT LineLimit instances + for call in mock_add_row.call_args_list: + args = call[0] # Get positional arguments + for arg in args: + assert isinstance(arg, str), f"Expected str but got {type(arg)}" + assert not isinstance(arg, LineLimit), "Should not wrap with LineLimit when disabled" + class TestPromptForSchema: """Tests for prompt_for_schema function."""