diff --git a/cli.py b/cli.py index 37aa8a7c8..3d747f41b 100644 --- a/cli.py +++ b/cli.py @@ -1845,15 +1845,51 @@ class HermesCLI: width += ch_width return "".join(out).rstrip() + ellipsis + @staticmethod + def _get_tui_terminal_width(default: tuple[int, int] = (80, 24)) -> int: + """Return the live prompt_toolkit width, falling back to ``shutil``. + + The TUI layout can be narrower than ``shutil.get_terminal_size()`` reports, + especially on Termux/mobile shells, so prefer prompt_toolkit's width whenever + an app is active. + """ + try: + from prompt_toolkit.application import get_app + return get_app().output.get_size().columns + except Exception: + return shutil.get_terminal_size(default).columns + + def _use_minimal_tui_chrome(self, width: Optional[int] = None) -> bool: + """Hide low-value chrome on narrow/mobile terminals to preserve rows.""" + if width is None: + width = self._get_tui_terminal_width() + return width < 64 + + def _tui_input_rule_height(self, position: str, width: Optional[int] = None) -> int: + """Return the visible height for the top/bottom input separator rules.""" + if position not in {"top", "bottom"}: + raise ValueError(f"Unknown input rule position: {position}") + if position == "top": + return 1 + return 0 if self._use_minimal_tui_chrome(width=width) else 1 + + def _agent_spacer_height(self, width: Optional[int] = None) -> int: + """Return the spacer height shown above the status bar while the agent runs.""" + if not getattr(self, "_agent_running", False): + return 0 + return 0 if self._use_minimal_tui_chrome(width=width) else 1 + + def _spinner_widget_height(self, width: Optional[int] = None) -> int: + """Return the visible height for the spinner/status text line above the status bar.""" + if not getattr(self, "_spinner_text", ""): + return 0 + return 0 if self._use_minimal_tui_chrome(width=width) else 1 + def _build_status_bar_text(self, width: Optional[int] = None) -> str: try: snapshot = self._get_status_bar_snapshot() if width is None: - try: - from prompt_toolkit.application import get_app - width = get_app().output.get_size().columns - except Exception: - width = shutil.get_terminal_size((80, 24)).columns + width = self._get_tui_terminal_width() percent = snapshot["context_percent"] percent_label = f"{percent}%" if percent is not None else "--" duration_label = snapshot["duration"] @@ -1889,11 +1925,7 @@ class HermesCLI: # values (especially on SSH) that differ from what prompt_toolkit # actually renders, causing the fragments to overflow to a second # line and produce duplicated status bar rows over long sessions. - try: - from prompt_toolkit.application import get_app - width = get_app().output.get_size().columns - except Exception: - width = shutil.get_terminal_size((80, 24)).columns + width = self._get_tui_terminal_width() duration_label = snapshot["duration"] if width < 52: @@ -8028,9 +8060,9 @@ class HermesCLI: def get_hint_height(): if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._approval_state or cli_ref._clarify_state or cli_ref._command_running: return 1 - # Keep a 1-line spacer while agent runs so output doesn't push - # right up against the top rule of the input area - return 1 if cli_ref._agent_running else 0 + # Keep a spacer while the agent runs on roomy terminals, but reclaim + # the row on narrow/mobile screens where every line matters. + return cli_ref._agent_spacer_height() def get_spinner_text(): txt = cli_ref._spinner_text @@ -8039,7 +8071,7 @@ class HermesCLI: return [('class:hint', f' {txt}')] def get_spinner_height(): - return 1 if cli_ref._spinner_text else 0 + return cli_ref._spinner_widget_height() spinner_widget = Window( content=FormattedTextControl(get_spinner_text), @@ -8230,18 +8262,17 @@ class HermesCLI: filter=Condition(lambda: cli_ref._approval_state is not None), ) - # Horizontal rules above and below the input (bronze, 1 line each). - # The bottom rule moves down as the TextArea grows with newlines. - # Using char='─' instead of hardcoded repetition so the rule - # always spans the full terminal width on any screen size. + # Horizontal rules above and below the input. + # On narrow/mobile terminals we keep the top separator for structure but + # hide the bottom one to recover a full row for conversation content. input_rule_top = Window( char='─', - height=1, + height=lambda: cli_ref._tui_input_rule_height("top"), style='class:input-rule', ) input_rule_bot = Window( char='─', - height=1, + height=lambda: cli_ref._tui_input_rule_height("bottom"), style='class:input-rule', ) diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index a884c4218..cb794465b 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -206,6 +206,37 @@ class TestCLIStatusBar: assert "⚕" in text assert "claude-sonnet-4-20250514" in text + def test_minimal_tui_chrome_threshold(self): + cli_obj = _make_cli() + + assert cli_obj._use_minimal_tui_chrome(width=63) is True + assert cli_obj._use_minimal_tui_chrome(width=64) is False + + def test_bottom_input_rule_hides_on_narrow_terminals(self): + cli_obj = _make_cli() + + assert cli_obj._tui_input_rule_height("top", width=50) == 1 + assert cli_obj._tui_input_rule_height("bottom", width=50) == 0 + assert cli_obj._tui_input_rule_height("bottom", width=90) == 1 + + def test_agent_spacer_reclaimed_on_narrow_terminals(self): + cli_obj = _make_cli() + cli_obj._agent_running = True + + assert cli_obj._agent_spacer_height(width=50) == 0 + assert cli_obj._agent_spacer_height(width=90) == 1 + cli_obj._agent_running = False + assert cli_obj._agent_spacer_height(width=90) == 0 + + def test_spinner_line_hidden_on_narrow_terminals(self): + cli_obj = _make_cli() + cli_obj._spinner_text = "thinking" + + assert cli_obj._spinner_widget_height(width=50) == 0 + assert cli_obj._spinner_widget_height(width=90) == 1 + cli_obj._spinner_text = "" + assert cli_obj._spinner_widget_height(width=90) == 0 + class TestCLIUsageReport: def test_show_usage_includes_estimated_cost(self, capsys): diff --git a/uv.lock b/uv.lock index 8bad8b385..7691ea984 100644 --- a/uv.lock +++ b/uv.lock @@ -1772,6 +1772,15 @@ slack = [ sms = [ { name = "aiohttp" }, ] +termux = [ + { name = "agent-client-protocol" }, + { name = "croniter" }, + { name = "honcho-ai" }, + { name = "mcp" }, + { name = "ptyprocess", marker = "sys_platform != 'win32'" }, + { name = "pywinpty", marker = "sys_platform == 'win32'" }, + { name = "simple-term-menu" }, +] tts-premium = [ { name = "elevenlabs" }, ] @@ -1806,19 +1815,25 @@ requires-dist = [ { name = "fire", specifier = ">=0.7.1,<1" }, { name = "firecrawl-py", specifier = ">=4.16.0,<5" }, { name = "hermes-agent", extras = ["acp"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["acp"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["cli"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["cli"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["cron"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["cron"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["daytona"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["dev"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["feishu"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["homeassistant"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["honcho"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["honcho"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["mcp"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["mcp"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["messaging"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["mistral"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["modal"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["pty"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["pty"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["slack"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["sms"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["tts-premium"], marker = "extra == 'all'" }, @@ -1861,7 +1876,7 @@ requires-dist = [ { name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" }, { name = "yc-bench", marker = "python_full_version >= '3.12' and extra == 'yc-bench'", git = "https://github.com/collinear-ai/yc-bench.git" }, ] -provides-extras = ["modal", "daytona", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "acp", "mistral", "dingtalk", "feishu", "rl", "yc-bench", "all"] +provides-extras = ["modal", "daytona", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "acp", "mistral", "termux", "dingtalk", "feishu", "rl", "yc-bench", "all"] [[package]] name = "hf-transfer"