diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index c9975ac..6569fd1 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -218,6 +218,105 @@ def build_config_payload(ctx: "Context", args: str = "") -> dict[str, Any]: return {"ok": False, "message": "Usage: /config [show|list|keys|sources|get|set|unset|llm|secret] ..."} +_CONFIG_LIST_COMPACT_WIDTH = 92 +_CONFIG_LIST_FULL_WIDTH = 118 + + +def _display_width(console: object, default: int = 100) -> int: + """Return the active Rich console width with a safe fallback for tests.""" + width = getattr(console, "width", None) + if isinstance(width, int) and width > 0: + return width + raw = getattr(console, "raw", None) + raw_width = getattr(raw, "width", None) + if isinstance(raw_width, int) and raw_width > 0: + return raw_width + return default + + +def _shorten(text: object, limit: int) -> str: + """Shorten a cell value before Rich allocates table columns.""" + value = str(text) if text is not None else "" + if limit <= 0: + return "" + if len(value) <= limit: + return value + return value[: max(1, limit - 1)] + "…" + + +def _config_scope_text(item: dict[str, Any], *, limit: int) -> str: + scopes = ",".join(str(scope) for scope in item.get("scopes") or []) + return _shorten(scopes, limit) + + +def _config_reload_text(item: dict[str, Any]) -> str: + value = item.get("hot_reload") + if isinstance(value, bool): + return "yes" if value else "no" + return str(value) if value is not None and str(value) else "no" + + +def _config_meta_text(item: dict[str, Any], *, scope_limit: int) -> str: + parts = [str(item.get("type") or "?")] + scope = _config_scope_text(item, limit=scope_limit) + if scope: + parts.append(scope) + parts.append(f"reload:{_config_reload_text(item)}") + return " · ".join(parts) + + +def _build_config_list_table(console: object, fields: list[dict[str, Any]]) -> object: + """Build a width-aware config list table for TUI rendering.""" + from rich.table import Table + + width = _display_width(console) + table = Table(title="Writable config fields", show_lines=False, expand=True) + if width < _CONFIG_LIST_COMPACT_WIDTH: + table.add_column("Key", no_wrap=True, overflow="ellipsis", max_width=32, ratio=2) + table.add_column("Value", overflow="fold", max_width=24, ratio=1) + table.add_column("Meta", overflow="fold", max_width=24, ratio=1) + for item in fields: + table.add_row( + _shorten(item.get("key"), 36), + _shorten(item.get("value"), 32), + _config_meta_text(item, scope_limit=14), + ) + return table + + if width < _CONFIG_LIST_FULL_WIDTH: + table.add_column("Key", no_wrap=True, overflow="ellipsis", max_width=34, ratio=2) + table.add_column("Value", overflow="fold", max_width=30, ratio=1) + table.add_column("Type", no_wrap=True, max_width=8) + table.add_column("Scope", no_wrap=True, overflow="ellipsis", max_width=18) + table.add_column("Reload", no_wrap=True, max_width=6) + for item in fields: + table.add_row( + _shorten(item.get("key"), 38), + _shorten(item.get("value"), 40), + str(item.get("type") or ""), + _config_scope_text(item, limit=18), + _config_reload_text(item), + ) + return table + + table.add_column("Key", no_wrap=True, overflow="ellipsis", max_width=36, ratio=2) + table.add_column("Value", overflow="fold", max_width=36, ratio=1) + table.add_column("Type", no_wrap=True, max_width=10) + table.add_column("Scope", no_wrap=True, overflow="ellipsis", max_width=22) + table.add_column("Reload", no_wrap=True, max_width=6) + table.add_column("Description", overflow="fold", ratio=2) + for item in fields: + table.add_row( + _shorten(item.get("key"), 42), + _shorten(item.get("value"), 48), + str(item.get("type") or ""), + _config_scope_text(item, limit=22), + _config_reload_text(item), + str(item.get("description") or ""), + ) + return table + + def render_config_payload(console: "LeapConsole", payload: dict[str, Any]) -> None: """Render config command output.""" if not payload.get("ok", True): @@ -239,25 +338,7 @@ def render_config_payload(console: "LeapConsole", payload: dict[str, Any]) -> No if not fields: console.system("No writable config fields found.") return - from rich.table import Table - - table = Table(title="Writable config fields", show_lines=False) - table.add_column("Key", no_wrap=True) - table.add_column("Value", no_wrap=True) - table.add_column("Type", no_wrap=True) - table.add_column("Scope", no_wrap=True) - table.add_column("Reload", no_wrap=True) - table.add_column("Description") - for item in fields: - table.add_row( - str(item.get("key") or ""), - str(item.get("value") or ""), - str(item.get("type") or ""), - ",".join(str(scope) for scope in item.get("scopes") or []), - str(item.get("hot_reload") or ""), - str(item.get("description") or ""), - ) - console.print(table) + console.print(_build_config_list_table(console, fields)) console.system("Use /config show , /config get , /config set , or /config keys for compact output.") return if mode == "show_detail": diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index 3743a77..9f1926e 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -94,6 +94,50 @@ def warning(self, message: str) -> None: await ctx.cleanup() +def test_config_list_table_adapts_to_terminal_width() -> None: + from leapflow.cli.commands.slash_handlers import _build_config_list_table + + class Console: + def __init__(self, width: int) -> None: + self.width = width + + fields = [ + { + "key": "memory.working_max_tokens", + "value": 0, + "type": "int", + "scopes": ["profile", "workspace"], + "hot_reload": "partial", + "description": "Runtime context budget for working memory before compaction.", + }, + { + "key": "visual.track_enabled", + "value": False, + "type": "bool", + "scopes": ["profile"], + "hot_reload": False, + "description": "Enable visual perception.", + }, + ] + + compact = _build_config_list_table(Console(72), fields) + assert [column.header for column in compact.columns] == ["Key", "Value", "Meta"] + assert "Description" not in [column.header for column in compact.columns] + assert "reload:partial" in compact.columns[2]._cells[0] + assert compact.columns[1]._cells[0] == "0" + assert compact.columns[1]._cells[1] == "False" + + medium = _build_config_list_table(Console(100), fields) + assert [column.header for column in medium.columns] == ["Key", "Value", "Type", "Scope", "Reload"] + assert medium.columns[4]._cells[0] == "partial" + assert medium.columns[4]._cells[1] == "no" + + full = _build_config_list_table(Console(132), fields) + assert [column.header for column in full.columns] == ["Key", "Value", "Type", "Scope", "Reload", "Description"] + assert len(full.columns[1]._cells[0]) <= 48 + assert full.columns[4]._cells[0] == "partial" + + @pytest.mark.asyncio async def test_model_slash_is_config_shortcut(tmp_path) -> None: from leapflow.cli.context import Context