From adcb4d554ecdc4a81663590f0c2dc4cdb0c22bff Mon Sep 17 00:00:00 2001 From: Andre Manoel Date: Mon, 6 Jul 2026 21:13:59 -0300 Subject: [PATCH 1/3] fix terminal UI edge cases Signed-off-by: Andre Manoel --- .../src/data_designer/config/run_config.py | 4 +- .../tests/config/test_run_config.py | 4 +- packages/data-designer-engine/pyproject.toml | 1 + .../progress/terminal/throughput_panel.py | 39 ++++++++++---- .../terminal/test_throughput_panel.py | 52 +++++++++++++++++++ uv.lock | 2 + 6 files changed, 88 insertions(+), 14 deletions(-) diff --git a/packages/data-designer-config/src/data_designer/config/run_config.py b/packages/data-designer-config/src/data_designer/config/run_config.py index c56d000dd..4d321e112 100644 --- a/packages/data-designer-config/src/data_designer/config/run_config.py +++ b/packages/data-designer-config/src/data_designer/config/run_config.py @@ -149,7 +149,7 @@ class RunConfig(ConfigBase): async_trace: If True, collect per-task tracing data. Default is False. display_tui: If True, display the terminal throughput TUI instead of periodic log lines during generation. Requires a TTY; falls back to log lines in - non-TTY environments. Default is True. + non-TTY environments. Default is False. progress_interval: How often (in seconds) the async progress reporter emits a consolidated log block. Must be > 0. Default is 5.0. preserve_dropped_columns: If True, write columns removed by drop processors to @@ -185,7 +185,7 @@ class RunConfig(ConfigBase): max_conversation_restarts: int = Field(default=5, ge=0) max_conversation_correction_steps: int = Field(default=0, ge=0) async_trace: bool = False - display_tui: bool = True + display_tui: bool = False progress_interval: float = Field(default=5.0, gt=0.0) preserve_dropped_columns: bool = Field( default=True, diff --git a/packages/data-designer-config/tests/config/test_run_config.py b/packages/data-designer-config/tests/config/test_run_config.py index e52705d5f..d10d9a1ba 100644 --- a/packages/data-designer-config/tests/config/test_run_config.py +++ b/packages/data-designer-config/tests/config/test_run_config.py @@ -24,8 +24,8 @@ def test_run_config_accepts_native_renderer() -> None: assert JinjaRenderingEngine(run_config.jinja_rendering_engine) == JinjaRenderingEngine.NATIVE -def test_run_config_defaults_to_display_tui_enabled() -> None: - assert RunConfig().display_tui is True +def test_run_config_defaults_to_display_tui_disabled() -> None: + assert RunConfig().display_tui is False def test_run_config_accepts_display_tui() -> None: diff --git a/packages/data-designer-engine/pyproject.toml b/packages/data-designer-engine/pyproject.toml index 380d012ba..33416f2fd 100644 --- a/packages/data-designer-engine/pyproject.toml +++ b/packages/data-designer-engine/pyproject.toml @@ -62,6 +62,7 @@ dependencies = [ "sqlfluff>=4.1.0,<5", "starlette>=1.2.0,<2", # 1.2.0 fixes security advisory pulled in by mcp "tiktoken>=0.8.0,<1", + "wcwidth>=0.2.13,<1", ] [tool.hatch.build.targets.wheel] diff --git a/packages/data-designer-engine/src/data_designer/engine/progress/terminal/throughput_panel.py b/packages/data-designer-engine/src/data_designer/engine/progress/terminal/throughput_panel.py index 555507a25..09092455e 100644 --- a/packages/data-designer-engine/src/data_designer/engine/progress/terminal/throughput_panel.py +++ b/packages/data-designer-engine/src/data_designer/engine/progress/terminal/throughput_panel.py @@ -13,6 +13,7 @@ from typing import Sequence, TextIO import asciichartpy +from wcwidth import wcswidth _ANSI_RE = re.compile(r"\033\[[0-9;?]*[a-zA-Z]") _CONTROL_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]") @@ -67,13 +68,23 @@ def _visible_len(text: str) -> int: - return len(_ANSI_RE.sub("", text)) + visible = _ANSI_RE.sub("", text) + width = wcswidth(visible) + return width if width >= 0 else len(visible) + + +def _truncate_to_width(text: str, width: int) -> str: + for index in range(1, len(text) + 1): + prefix_width = wcswidth(text[:index]) + if prefix_width < 0 or prefix_width > width: + return text[: index - 1] + return text def _fit_ansi(text: str, width: int) -> str: visible = _visible_len(text) if visible > width: - return _ANSI_RE.sub("", text)[:width] + _RESET + return _truncate_to_width(_ANSI_RE.sub("", text), width) + _RESET return text + (" " * (width - visible)) @@ -87,7 +98,8 @@ def sanitize_terminal_text(label: str) -> str: def _fit_plain(text: str, width: int) -> str: clean = sanitize_terminal_text(text) - return clean[:width].ljust(width) + fitted = _truncate_to_width(clean, width) + return fitted + (" " * (width - _visible_len(fitted))) def _average(values: Sequence[float]) -> float: @@ -294,6 +306,7 @@ def __init__(self, stream: TextIO | None = None, *, panel_height: int = _DEFAULT self._feedback_markers: list[_FeedbackMarker] = [] self._lock = Lock() self._drawn_lines = 0 + self._drawn_terminal_size: tuple[int, int] | None = None self._active = False self._owns_stream = False self._wrapped_handlers: list[tuple[logging.StreamHandler, object]] = [] @@ -344,6 +357,7 @@ def __exit__(self, *args: object) -> None: self._unwrap_handlers() self._active = False self._drawn_lines = 0 + self._drawn_terminal_size = None finally: self._release_stream() @@ -489,12 +503,16 @@ def _unwrap_handlers(self) -> None: def _clear_bars(self) -> None: """Clear drawn panel lines from the terminal. Caller must hold the lock.""" + terminal_size = shutil.get_terminal_size() if self._drawn_lines > 0: - clear_count = min(self._drawn_lines, max(0, shutil.get_terminal_size().lines - 1)) + clear_count = min(self._drawn_lines, max(0, terminal_size.lines - 1)) for _ in range(clear_count): self._write("\033[A\033[2K") self._write("\r\033[2K") self._drawn_lines = 0 + if self._drawn_terminal_size is not None and self._drawn_terminal_size != tuple(terminal_size): + self._write("\033[J") + self._drawn_terminal_size = None def _redraw_if_due(self, now: float, *, force: bool = False) -> None: if force or self._drawn_lines == 0 or now - self._last_redraw_time >= _MIN_REDRAW_INTERVAL_SECONDS: @@ -504,7 +522,7 @@ def _redraw(self, *, force: bool = False, now: float | None = None) -> None: """Redraw the chart panel. Caller must hold the lock.""" terminal_size = shutil.get_terminal_size() if terminal_size.columns < _MIN_TERMINAL_WIDTH or terminal_size.lines < _MIN_TERMINAL_HEIGHT: - self._drawn_lines = 0 + self._clear_bars() self._write("\033[?25h") self._unwrap_handlers() self._active = False @@ -521,6 +539,7 @@ def _redraw(self, *, force: bool = False, now: float | None = None) -> None: for line in lines: self._write(line + "\n") self._drawn_lines = len(lines) + self._drawn_terminal_size = tuple(terminal_size) self._last_redraw_time = time.perf_counter() if now is None else now def _format_panel(self) -> list[str]: @@ -736,7 +755,7 @@ def _column_table_widths( fixed_width_without_label_or_progress = ( 2 + (separator_count * _LEGEND_COLUMN_GAP) + done_width + (rate_width * 2) + status_width ) - content_label_width = max(len("column"), *(len(sanitize_terminal_text(bar.label)) for bar in bars)) + content_label_width = max(_visible_len("column"), *(_visible_len(bar.label) for bar in bars)) desired_label_width = max(_MIN_LEGEND_LABEL_WIDTH, content_label_width) available_width = inner_width - fixed_width_without_label_or_progress @@ -826,13 +845,13 @@ def _model_table_widths( available_text_width = inner_width - fixed_width_without_text desired_alias_width = max( _MIN_MODEL_ALIAS_WIDTH, - len("model alias"), - *(len(state.model_alias) for state in model_usage), + _visible_len("model alias"), + *(_visible_len(state.model_alias) for state in model_usage), ) desired_model_width = max( _MIN_MODEL_NAME_WIDTH, - len("model name"), - *(len(state.model_name) for state in model_usage), + _visible_len("model name"), + *(_visible_len(state.model_name) for state in model_usage), ) if available_text_width >= desired_alias_width + desired_model_width: diff --git a/packages/data-designer-engine/tests/engine/progress/terminal/test_throughput_panel.py b/packages/data-designer-engine/tests/engine/progress/terminal/test_throughput_panel.py index 374240d0b..343a119fc 100644 --- a/packages/data-designer-engine/tests/engine/progress/terminal/test_throughput_panel.py +++ b/packages/data-designer-engine/tests/engine/progress/terminal/test_throughput_panel.py @@ -13,6 +13,7 @@ from unittest.mock import patch import pytest +from wcwidth import wcswidth from data_designer.engine.models.usage_events import TokenUsageEvent, emit_token_usage_event from data_designer.engine.observability import ( @@ -36,6 +37,7 @@ CURSOR_UP_CLEAR = "\033[A\033[2K" HIDE_CURSOR = "\033[?25l" SHOW_CURSOR = "\033[?25h" +ERASE_TO_END = "\033[J" _ALL_ANSI_RE = re.compile(r"\033\[[0-9;?]*[a-zA-Z]") @@ -140,11 +142,16 @@ def test_only_one_panel_owns_a_stream(tty_stream: FakeTTY) -> None: def test_active_panel_falls_back_when_terminal_shrinks(tty_stream: FakeTTY) -> None: with TerminalThroughputPanel(stream=tty_stream) as bar: bar.add_bar("a", "column 'a'", 100) + snapshot = tty_stream.getvalue() with patch.object(shutil, "get_terminal_size", return_value=os.terminal_size((20, 5))): bar.update("a", completed=1, success=1, force=True) assert bar.is_active is False assert bar.drawn_lines == 0 + resize_output = tty_stream.getvalue()[len(snapshot) :] + assert ERASE_TO_END in resize_output + assert resize_output.index(ERASE_TO_END) < resize_output.index(SHOW_CURSOR) + assert tty_stream.getvalue().count(HIDE_CURSOR) == 1 assert tty_stream.getvalue().count(SHOW_CURSOR) == 1 @@ -400,6 +407,51 @@ def test_narrow_terminal_keeps_panel_within_width(tty_stream: FakeTTY) -> None: assert len(line) <= 35 +@pytest.mark.parametrize( + ("terminal_width", "label", "model_alias", "model_name"), + [ + (30, "εˆ—πŸš€" * 20, "ζ¨‘εž‹" * 20, "提供者/ζ¨‘εž‹" * 20), + (36, "e\u0301" * 20, "πŸ‘©πŸ½β€πŸ’»" * 20, "ζ¨‘εž‹/e\u0301" * 20), + (50, "εˆ—πŸš€" * 20, "ζ¨‘εž‹" * 20, "提供者/ζ¨‘εž‹" * 20), + (80, "e\u0301" * 20, "πŸ‘©πŸ½β€πŸ’»" * 20, "ζ¨‘εž‹/e\u0301" * 20), + ], +) +def test_wide_unicode_labels_stay_within_terminal_width( + tty_stream: FakeTTY, + terminal_width: int, + label: str, + model_alias: str, + model_name: str, +) -> None: + with patch.object(shutil, "get_terminal_size", return_value=os.terminal_size((terminal_width, 24))): + with TerminalThroughputPanel(stream=tty_stream) as bar: + bar.add_bar("a", label, 100) + bar.record_model_usage( + model_alias=model_alias, + model_name=model_name, + input_tokens=100, + output_tokens=25, + force=True, + ) + + assert all(wcswidth(line) <= terminal_width - 1 for line in _last_panel_lines(tty_stream.getvalue())) + + +def test_active_panel_clears_visible_artifacts_when_terminal_resizes(tty_stream: FakeTTY) -> None: + terminal_size = os.terminal_size((80, 24)) + + with patch.object(shutil, "get_terminal_size", side_effect=lambda: terminal_size): + with TerminalThroughputPanel(stream=tty_stream) as bar: + bar.add_bar("a", "column 'a'", 100) + snapshot = tty_stream.getvalue() + terminal_size = os.terminal_size((50, 12)) + + bar.update("a", completed=1, success=1, force=True) + + assert bar.is_active is True + assert ERASE_TO_END in tty_stream.getvalue()[len(snapshot) :] + + def test_update_many_single_redraw(tty_stream: FakeTTY) -> None: with TerminalThroughputPanel(stream=tty_stream) as bar: bar.add_bar("a", "col_a", 100) diff --git a/uv.lock b/uv.lock index a6d03b754..ec1222856 100644 --- a/uv.lock +++ b/uv.lock @@ -878,6 +878,7 @@ dependencies = [ { name = "sqlfluff" }, { name = "starlette" }, { name = "tiktoken" }, + { name = "wcwidth" }, ] [package.metadata] @@ -911,6 +912,7 @@ requires-dist = [ { name = "sqlfluff", specifier = ">=4.1.0,<5" }, { name = "starlette", specifier = ">=1.2.0,<2" }, { name = "tiktoken", specifier = ">=0.8.0,<1" }, + { name = "wcwidth", specifier = ">=0.2.13,<1" }, ] [[package]] From 8c8da754da5ffdc606517bb1790ccc7559e2c567 Mon Sep 17 00:00:00 2001 From: Andre Manoel Date: Mon, 6 Jul 2026 21:19:07 -0300 Subject: [PATCH 2/3] document Unicode terminal width cases Signed-off-by: Andre Manoel --- .../tests/engine/progress/terminal/test_throughput_panel.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/data-designer-engine/tests/engine/progress/terminal/test_throughput_panel.py b/packages/data-designer-engine/tests/engine/progress/terminal/test_throughput_panel.py index 343a119fc..010638ec7 100644 --- a/packages/data-designer-engine/tests/engine/progress/terminal/test_throughput_panel.py +++ b/packages/data-designer-engine/tests/engine/progress/terminal/test_throughput_panel.py @@ -410,6 +410,8 @@ def test_narrow_terminal_keeps_panel_within_width(tty_stream: FakeTTY) -> None: @pytest.mark.parametrize( ("terminal_width", "label", "model_alias", "model_name"), [ + # Exercise double-width CJK/emoji, combining marks, and ZWJ sequences whose + # code-point counts differ from their displayed terminal cell widths. (30, "εˆ—πŸš€" * 20, "ζ¨‘εž‹" * 20, "提供者/ζ¨‘εž‹" * 20), (36, "e\u0301" * 20, "πŸ‘©πŸ½β€πŸ’»" * 20, "ζ¨‘εž‹/e\u0301" * 20), (50, "εˆ—πŸš€" * 20, "ζ¨‘εž‹" * 20, "提供者/ζ¨‘εž‹" * 20), From bf3ad2e99256e69c4a4b43aedb43697cab2068aa Mon Sep 17 00:00:00 2001 From: Andre Manoel Date: Tue, 7 Jul 2026 15:15:22 -0300 Subject: [PATCH 3/3] pad truncated terminal lines Signed-off-by: Andre Manoel --- .../data_designer/engine/progress/terminal/throughput_panel.py | 3 ++- .../tests/engine/progress/terminal/test_throughput_panel.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/data-designer-engine/src/data_designer/engine/progress/terminal/throughput_panel.py b/packages/data-designer-engine/src/data_designer/engine/progress/terminal/throughput_panel.py index 09092455e..b51fea5f4 100644 --- a/packages/data-designer-engine/src/data_designer/engine/progress/terminal/throughput_panel.py +++ b/packages/data-designer-engine/src/data_designer/engine/progress/terminal/throughput_panel.py @@ -84,7 +84,8 @@ def _truncate_to_width(text: str, width: int) -> str: def _fit_ansi(text: str, width: int) -> str: visible = _visible_len(text) if visible > width: - return _truncate_to_width(_ANSI_RE.sub("", text), width) + _RESET + fitted = _truncate_to_width(_ANSI_RE.sub("", text), width) + return fitted + (" " * (width - _visible_len(fitted))) + _RESET return text + (" " * (width - visible)) diff --git a/packages/data-designer-engine/tests/engine/progress/terminal/test_throughput_panel.py b/packages/data-designer-engine/tests/engine/progress/terminal/test_throughput_panel.py index 010638ec7..e03c490e9 100644 --- a/packages/data-designer-engine/tests/engine/progress/terminal/test_throughput_panel.py +++ b/packages/data-designer-engine/tests/engine/progress/terminal/test_throughput_panel.py @@ -436,7 +436,7 @@ def test_wide_unicode_labels_stay_within_terminal_width( force=True, ) - assert all(wcswidth(line) <= terminal_width - 1 for line in _last_panel_lines(tty_stream.getvalue())) + assert all(wcswidth(line) == terminal_width - 1 for line in _last_panel_lines(tty_stream.getvalue())) def test_active_panel_clears_visible_artifacts_when_terminal_resizes(tty_stream: FakeTTY) -> None: