{"repo":"NousResearch/hermes-agent","public_id":"5755","display_label":"Curses provider menu scrolling and UI fixes","summary":"These changes address a bug where the curses-based provider/model picker menu would scroll improperly, showing only a cancel option. Related CLI and console UI corrections are also included.","subsystem":"hermes_cli","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":5974,"merged_at":"2026-04-08T00:59:42+00:00"},"resolution":{"state":"resolved","by":"pr-5974"},"freshness":{"state":"stale","last_verified_at":"2026-04-08T00:59:42+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":5755,"pr_number":5974,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-08T00:59:42+00:00"}],"members":[{"kind":"issue","number":5755,"title":"[Bug]: config models choose More providers, up or down only show cancel option.","url":null,"state":"closed"},{"kind":"pr","number":5790,"title":"fix(model-picker): add scrolling viewport to curses provider menu","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5974","number":5974}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":5974,"title":"fix: CLI/UX batch — ChatConsole errors, curses scroll, skin-aware banner, git state banner","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","hermes_cli/banner.py","hermes_cli/setup.py","tests/hermes_cli/test_banner_git_state.py","tests/test_cli_skin_integration.py","tests/test_model_picker_scroll.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -63,7 +63,7 @@\n     format_duration_compact,\n     format_token_count_compact,\n )\n-from hermes_cli.banner import _format_context_length\n+from hermes_cli.banner import _format_context_length, format_banner_version_label\n \n _COMMAND_SPINNER_FRAMES = (\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\")\n ","@@ -1036,21 +1036,44 @@ def print(self, *args, **kwargs):\n \n def _build_compact_banner() -> str:\n     \"\"\"Build a compact banner that fits the current terminal width.\"\"\"\n-    w = min(shutil.get_terminal_size().columns - 2, 64)\n+    try:\n+        from hermes_cli.skin_engine import get_active_skin\n+        _skin = get_active_skin()\n+    except Exception:\n+        _skin = None\n+\n+    skin_name = getattr(_skin, \"name\", \"default\") if _skin else \"default\"\n+    border_color = _skin.get_color(\"banner_border\", \"#FFD700\") if _skin else \"#FFD700\"\n+    title_color = _skin.get_color(\"banner_title\", \"#FFBF00\") if _skin else \"#FFBF00\"\n+    dim_color = _skin.get_color(\"banner_dim\", \"#B8860B\") if _skin else \"#B8860B\"\n+\n+    if skin_name == \"default\":\n+        line1 = \"⚕ NOUS HERMES - AI Agent Framework\"\n+        tiny_line = \"⚕ NOUS HERMES\"\n+    else:\n+        agent_name = _skin.get_branding(\"agent_name\", \"Hermes Agent\") if _skin else \"Hermes Agent\"\n+        line1 = f\"{agent_name} - AI Agent Framework\"\n+        tiny_line = agent_name\n+\n+    version_line = format_banner_version_label()\n+\n+    w = min(shutil.get_terminal_size().columns - 2, 88)\n     if w < 30:\n-        return \"\\n[#FFBF00]⚕ NOUS HERMES[/] [dim #B8860B]- Nous Research[/]\\n\"\n+        return f\"\\n[{title_color}]{tiny_line}[/] [dim {dim_color}]- Nous Research[/]\\n\"\n+\n     inner = w - 2  # inside the box border\n     bar = \"═\" * w\n-    line1 = \"⚕ NOUS HERMES - AI Agent Framework\"\n-    line2 = \"Messenger of the Digital Gods  ·  Nous Research\"\n+    content_width = inner - 2\n+\n     # Truncate and pad to fit\n-    line1 = line1[:inner - 2].ljust(inner - 2)\n-    line2 = line2[:inner - 2].ljust(inner - 2)\n+    line1 = line1[:content_width].ljust(content_width)\n+    line2 = version_line[:content_width].ljust(content_width)\n+\n     return (\n-        f\"\\n[bold #FFD700]╔{bar}╗[/]\\n\"\n-        f\"[bold #FFD700]║[/] [#FFBF00]{line1}[/] [bold #FFD700]║[/]\\n\"\n-        f\"[bold #FFD700]║[/] [dim #B8860B]{line2}[/] [bold #FFD700]║[/]\\n\"\n-        f\"[bold #FFD700]╚{bar}╝[/]\\n\"\n+        f\"\\n[bold {border_color}]╔{bar}╗[/]\\n\"\n+        f\"[bold {border_color}]║[/] [{title_color}]{line1}[/] [bold {border_color}]║[/]\\n\"\n+        f\"[bold {border_color}]║[/] [dim {dim_color}]{line2}[/] [bold {border_color}]║[/]\\n\"\n+        f\"[bold {border_color}]╚{bar}╝[/]\\n\"\n     )\n \n ","@@ -2163,7 +2186,7 @@ def _ensure_runtime_credentials(self) -> bool:\n             )\n         except Exception as exc:\n             message = format_runtime_provider_error(exc)\n-            self.console.print(f\"[bold red]{message}[/]\")\n+            ChatConsole().print(f\"[bold red]{message}[/]\")\n             return False\n \n         api_key = runtime.get(\"api_key\")","@@ -2378,7 +2401,7 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No\n                     self._pending_title = None\n             return True\n         except Exception as e:\n-            self.console.print(f\"[bold red]Failed to initialize agent: {e}[/]\")\n+            ChatConsole().print(f\"[bold red]Failed to initialize agent: {e}[/]\")\n             return False\n     \n     def show_banner(self):","@@ -4530,13 +4553,13 @@ def process_command(self, command: str) -> bool:\n                             if output:\n                                 self.console.print(_rich_text_from_ansi(output))\n                             else:\n-                                self.console.print(\"[dim]Command returned no output[/]\")\n+                                ChatConsole().print(\"[dim]Command returned no output[/]\")\n                         except subprocess.TimeoutExpired:\n-                            self.console.print(\"[bold red]Quick command timed out (30s)[/]\")\n+                            ChatConsole().print(\"[bold red]Quick command timed out (30s)[/]\")\n                         except Exception as e:\n-                            self.console.print(f\"[bold red]Quick command error: {e}[/]\")\n+                            ChatConsole().print(f\"[bold red]Quick command error: {e}[/]\")\n                     else:\n-                        self.console.print(f\"[bold red]Quick command '{base_cmd}' has no command defined[/]\")\n+                        ChatConsole().print(f\"[bold red]Quick command '{base_cmd}' has no command defined[/]\")\n                 elif qcmd.get(\"type\") == \"alias\":\n                     target = qcmd.get(\"target\", \"\").strip()\n                     if target:","@@ -4545,9 +4568,9 @@ def process_command(self, command: str) -> bool:\n                         aliased_command = f\"{target} {user_args}\".strip()\n                         return self.process_command(aliased_command)\n                     else:\n-                        self.console.print(f\"[bold red]Quick command '{base_cmd}' has no target defined[/]\")\n+                        ChatConsole().print(f\"[bold red]Quick command '{base_cmd}' has no target defined[/]\")\n                 else:\n-                    self.console.print(f\"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]\")\n+                    ChatConsole().print(f\"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]\")\n             # Check for plugin-registered slash commands\n             elif base_cmd.lstrip(\"/\") in _get_plugin_cmd_handler_names():\n                 from hermes_cli.plugins import get_plugin_command_handler","@@ -4572,7 +4595,7 @@ def process_command(self, command: str) -> bool:\n                     if hasattr(self, '_pending_input'):\n                         self._pending_input.put(msg)\n                 else:\n-                    self.console.print(f\"[bold red]Failed to load skill for {base_cmd}[/]\")\n+                    ChatConsole().print(f\"[bold red]Failed to load skill for {base_cmd}[/]\")\n             else:\n                 # Prefix matching: if input uniquely identifies one command, execute it.\n                 # Matches against both built-in COMMANDS and installed skill commands so","@@ -4633,14 +4656,14 @@ def _handle_plan_command(self, cmd: str):\n         )\n \n         if not msg:\n-            self.console.print(\"[bold red]Failed to load the bundled /plan skill[/]\")\n+            ChatConsole().print(\"[bold red]Failed to load the bundled /plan skill[/]\")\n             return\n \n         _cprint(f\"  📝 Plan mode queued via skill. Markdown plan target: {plan_path}\")\n         if hasattr(self, '_pending_input'):\n             self._pending_input.put(msg)\n         else:\n-            self.console.print(\"[bold red]Plan mode unavailable: input queue not initialized[/]\")\n+            ChatConsole().print(\"[bold red]Plan mode unavailable: input queue not initialized[/]\")\n     \n     def _handle_background_command(self, cmd: str):\n         \"\"\"Handle /background <prompt> — run a prompt in a separate background session."]},{"file":"hermes_cli/banner.py","hunks":["@@ -5,6 +5,7 @@\n \n import json\n import logging\n+import os\n import shutil\n import subprocess\n import threading","@@ -189,6 +190,79 @@ def check_for_updates() -> Optional[int]:\n     return behind\n \n \n+def _resolve_repo_dir() -> Optional[Path]:\n+    \"\"\"Return the active Hermes git checkout, or None if this isn't a git install.\"\"\"\n+    hermes_home = get_hermes_home()\n+    repo_dir = hermes_home / \"hermes-agent\"\n+    if not (repo_dir / \".git\").exists():\n+        repo_dir = Path(__file__).parent.parent.resolve()\n+    return repo_dir if (repo_dir / \".git\").exists() else None\n+\n+\n+def _git_short_hash(repo_dir: Path, rev: str) -> Optional[str]:\n+    \"\"\"Resolve a git revision to an 8-character short hash.\"\"\"\n+    try:\n+        result = subprocess.run(\n+            [\"git\", \"rev-parse\", \"--short=8\", rev],\n+            capture_output=True,\n+            text=True,\n+            timeout=5,\n+            cwd=str(repo_dir),\n+        )\n+    except Exception:\n+        return None\n+    if result.returncode != 0:\n+        return None\n+    value = (result.stdout or \"\").strip()\n+    return value or None\n+\n+\n+def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]:\n+    \"\"\"Return upstream/local git hashes for the startup banner.\"\"\"\n+    repo_dir = repo_dir or _resolve_repo_dir()\n+    if repo_dir is None:\n+        return None\n+\n+    upstream = _git_short_hash(repo_dir, \"origin/main\")\n+    local = _git_short_hash(repo_dir, \"HEAD\")\n+    if not upstream or not local:\n+        return None\n+\n+    ahead = 0\n+    try:\n+        result = subprocess.run(\n+            [\"git\", \"rev-list\", \"--count\", \"origin/main..HEAD\"],\n+            capture_output=True,\n+            text=True,\n+            timeout=5,\n+            cwd=str(repo_dir),\n+        )\n+        if result.returncode == 0:\n+            ahead = int((result.stdout or \"0\").strip() or \"0\")\n+    except Exception:\n+        ahead = 0\n+\n+    return {\"upstream\": upstream, \"local\": local, \"ahead\": max(ahead, 0)}\n+\n+\n+def format_banner_version_label() -> str:\n+    \"\"\"Return the version label shown in the startup banner title.\"\"\"\n+    base = f\"Hermes Agent v{VERSION} ({RELEASE_DATE})\"\n+    state = get_git_banner_state()\n+    if not state:\n+        return base\n+\n+    upstream = state[\"upstream\"]\n+    local = state[\"local\"]\n+    ahead = int(state.get(\"ahead\") or 0)\n+\n+    if ahead <= 0 or upstream == local:\n+        return f\"{base} · upstream {upstream}\"\n+\n+    carried_word = \"commit\" if ahead == 1 else \"commits\"\n+    return f\"{base} · upstream {upstream} · local {local} (+{ahead} carried {carried_word})\"\n+\n+\n # =========================================================================\n # Non-blocking update check\n # =========================================================================","@@ -448,7 +522,7 @@ def build_welcome_banner(console: Console, model: str, cwd: str,\n     border_color = _skin_color(\"banner_border\", \"#CD7F32\")\n     outer_panel = Panel(\n         layout_table,\n-        title=f\"[bold {title_color}]{agent_name} v{VERSION} ({RELEASE_DATE})[/]\",\n+        title=f\"[bold {title_color}]{format_banner_version_label()}[/]\",\n         border_style=border_color,\n         padding=(0, 2),\n     )"]},{"file":"hermes_cli/setup.py","hunks":["@@ -421,10 +421,22 @@ def _curses_menu(stdscr):\n                 curses.init_pair(1, curses.COLOR_GREEN, -1)\n                 curses.init_pair(2, curses.COLOR_YELLOW, -1)\n             cursor = default\n+            scroll_offset = 0\n \n             while True:\n                 stdscr.clear()\n                 max_y, max_x = stdscr.getmaxyx()\n+\n+                # Rows available for list items: rows 2..(max_y-2) inclusive.\n+                visible = max(1, max_y - 3)\n+\n+                # Scroll the viewport so the cursor is always visible.\n+                if cursor < scroll_offset:\n+                    scroll_offset = cursor\n+                elif cursor >= scroll_offset + visible:\n+                    scroll_offset = cursor - visible + 1\n+                scroll_offset = max(0, min(scroll_offset, max(0, len(choices) - visible)))\n+\n                 try:\n                     stdscr.addnstr(\n                         0,","@@ -436,12 +448,12 @@ def _curses_menu(stdscr):\n                 except curses.error:\n                     pass\n \n-                for i, choice in enumerate(choices):\n-                    y = i + 2\n+                for row, i in enumerate(range(scroll_offset, min(scroll_offset + visible, len(choices)))):\n+                    y = row + 2\n                     if y >= max_y - 1:\n                         break\n                     arrow = \"→\" if i == cursor else \" \"\n-                    line = f\" {arrow}  {choice}\"\n+                    line = f\" {arrow}  {choices[i]}\"\n                     attr = curses.A_NORMAL\n                     if i == cursor:\n                         attr = curses.A_BOLD"]},{"file":"tests/hermes_cli/test_banner_git_state.py","hunks":["@@ -0,0 +1,63 @@\n+from unittest.mock import MagicMock, patch\n+\n+\n+def test_format_banner_version_label_without_git_state():\n+    from hermes_cli import banner\n+\n+    with patch.object(banner, \"get_git_banner_state\", return_value=None):\n+        value = banner.format_banner_version_label()\n+\n+    assert value == f\"Hermes Agent v{banner.VERSION} ({banner.RELEASE_DATE})\"\n+\n+\n+def test_format_banner_version_label_on_upstream_main():\n+    from hermes_cli import banner\n+\n+    with patch.object(\n+        banner,\n+        \"get_git_banner_state\",\n+        return_value={\"upstream\": \"b2f477a3\", \"local\": \"b2f477a3\", \"ahead\": 0},\n+    ):\n+        value = banner.format_banner_version_label()\n+\n+    assert value.endswith(\"· upstream b2f477a3\")\n+    assert \"local\" not in value\n+\n+\n+def test_format_banner_version_label_with_carried_commits():\n+    from hermes_cli import banner\n+\n+    with patch.object(\n+        banner,\n+        \"get_git_banner_state\",\n+        return_value={\"upstream\": \"b2f477a3\", \"local\": \"af8aad31\", \"ahead\": 3},\n+    ):\n+        value = banner.format_banner_version_label()\n+\n+    assert \"upstream b2f477a3\" in value\n+    assert \"local af8aad31\" in value\n+    assert \"+3 carried commits\" in value\n+\n+\n+def test_get_git_banner_state_reads_origin_and_head(tmp_path):\n+    from hermes_cli import banner\n+\n+    repo_dir = tmp_path / \"repo\"\n+    (repo_dir / \".git\").mkdir(parents=True)\n+\n+    results = {\n+        (\"git\", \"rev-parse\", \"--short=8\", \"origin/main\"): MagicMock(returncode=0, stdout=\"b2f477a3\\n\"),\n+        (\"git\", \"rev-parse\", \"--short=8\", \"HEAD\"): MagicMock(returncode=0, stdout=\"af8aad31\\n\"),\n+        (\"git\", \"rev-list\", \"--count\", \"origin/main..HEAD\"): MagicMock(returncode=0, stdout=\"3\\n\"),\n+    }\n+\n+    def fake_run(cmd, **kwargs):\n+        key = tuple(cmd)\n+        if key not in results:\n+            raise AssertionError(f\"unexpected command: {cmd}\")\n+        return results[key]\n+\n+    with patch(\"hermes_cli.banner.subprocess.run\", side_effect=fake_run):\n+        state = banner.get_git_banner_state(repo_dir)\n+\n+    assert state == {\"upstream\": \"b2f477a3\", \"local\": \"af8aad31\", \"ahead\": 3}"]},{"file":"tests/test_cli_skin_integration.py","hunks":["@@ -0,0 +1,140 @@\n+from types import SimpleNamespace\n+from unittest.mock import MagicMock, patch\n+\n+from cli import HermesCLI, _build_compact_banner, _rich_text_from_ansi\n+from hermes_cli.skin_engine import get_active_skin, set_active_skin\n+\n+\n+def _make_cli_stub():\n+    cli = HermesCLI.__new__(HermesCLI)\n+    cli._sudo_state = None\n+    cli._secret_state = None\n+    cli._approval_state = None\n+    cli._clarify_state = None\n+    cli._clarify_freetext = False\n+    cli._command_running = False\n+    cli._agent_running = False\n+    cli._voice_recording = False\n+    cli._voice_processing = False\n+    cli._voice_mode = False\n+    cli._command_spinner_frame = lambda: \"⟳\"\n+    cli._tui_style_base = {\n+        \"prompt\": \"#fff\",\n+        \"input-area\": \"#fff\",\n+        \"input-rule\": \"#aaa\",\n+        \"prompt-working\": \"#888 italic\",\n+    }\n+    cli._app = SimpleNamespace(style=None)\n+    cli._invalidate = MagicMock()\n+    return cli\n+\n+\n+class TestCliSkinPromptIntegration:\n+    def test_default_prompt_fragments_use_default_symbol(self):\n+        cli = _make_cli_stub()\n+\n+        set_active_skin(\"default\")\n+        assert cli._get_tui_prompt_fragments() == [(\"class:prompt\", \"❯ \")]\n+\n+    def test_ares_prompt_fragments_use_skin_symbol(self):\n+        cli = _make_cli_stub()\n+\n+        set_active_skin(\"ares\")\n+        assert cli._get_tui_prompt_fragments() == [(\"class:prompt\", \"⚔ ❯ \")]\n+\n+    def test_secret_prompt_fragments_preserve_secret_state(self):\n+        cli = _make_cli_stub()\n+        cli._secret_state = {\"response_queue\": object()}\n+\n+        set_active_skin(\"ares\")\n+        assert cli._get_tui_prompt_fragments() == [(\"class:sudo-prompt\", \"🔑 ❯ \")]\n+\n+    def test_icon_only_skin_symbol_still_visible_in_special_states(self):\n+        cli = _make_cli_stub()\n+        cli._secret_state = {\"response_queue\": object()}\n+\n+        with patch(\"hermes_cli.skin_engine.get_active_prompt_symbol\", return_value=\"⚔ \"):\n+            assert cli._get_tui_prompt_fragments() == [(\"class:sudo-prompt\", \"🔑 ⚔ \")]\n+\n+    def test_build_tui_style_dict_uses_skin_overrides(self):\n+        cli = _make_cli_stub()\n+\n+        set_active_skin(\"ares\")\n+        skin = get_active_skin()\n+        style_dict = cli._build_tui_style_dict()\n+\n+        assert style_dict[\"prompt\"] == skin.get_color(\"prompt\")\n+        assert style_dict[\"input-rule\"] == skin.get_color(\"input_rule\")\n+        assert style_dict[\"prompt-working\"] == f\"{skin.get_color('banner_dim')} italic\"\n+        assert style_dict[\"approval-title\"] == f\"{skin.get_color('ui_warn')} bold\"\n+\n+    def test_apply_tui_skin_style_updates_running_app(self):\n+        cli = _make_cli_stub()\n+\n+        set_active_skin(\"ares\")\n+        assert cli._apply_tui_skin_style() is True\n+        assert cli._app.style is not None\n+        cli._invalidate.assert_called_once_with(min_interval=0.0)\n+\n+    def test_handle_skin_command_refreshes_live_tui(self, capsys):\n+        cli = _make_cli_stub()\n+\n+        with patch(\"cli.save_config_value\", return_value=True):\n+            cli._handle_skin_command(\"/skin ares\")\n+\n+        output = capsys.readouterr().out\n+        assert \"Skin set to: ares (saved)\" in output\n+        assert \"Prompt + TUI colors updated.\" in output\n+        assert cli._app.style is not None\n+\n+\n+class TestCompactBannerSkinIntegration:\n+    def test_default_compact_banner_keeps_legacy_nous_hermes_branding(self):\n+        set_active_skin(\"default\")\n+\n+        with patch(\"cli.shutil.get_terminal_size\", return_value=SimpleNamespace(columns=90)), \\\n+             patch(\"cli.format_banner_version_label\", return_value=\"Hermes Agent v0.1.0 (test)\"):\n+            banner = _build_compact_banner()\n+\n+        assert \"NOUS HERMES\" in banner\n+\n+    def test_poseidon_compact_banner_uses_skin_branding_instead_of_nous_hermes(self):\n+        set_active_skin(\"poseidon\")\n+\n+        with patch(\"cli.shutil.get_terminal_size\", return_value=SimpleNamespace(columns=90)), \\\n+             patch(\"cli.format_banner_version_label\", return_value=\"Hermes Agent v0.1.0 (test)\"):\n+            banner = _build_compact_banner()\n+\n+        assert \"Poseidon Agent\" in banner\n+        assert \"NOUS HERMES\" not in banner\n+\n+    def test_poseidon_compact_banner_uses_skin_colors(self):\n+        set_active_skin(\"poseidon\")\n+        skin = get_active_skin()\n+\n+        with patch(\"cli.shutil.get_terminal_size\", return_value=SimpleNamespace(columns=90)), \\\n+             patch(\"cli.format_banner_version_label\", return_value=\"Hermes Agent v0.1.0 (test)\"):\n+            banner = _build_compact_banner()\n+\n+        assert skin.get_color(\"banner_border\") in banner\n+        assert skin.get_color(\"banner_title\") in banner\n+        assert skin.get_color(\"banner_dim\") in banner\n+\n+    def test_compact_banner_shows_version_label(self):\n+        set_active_skin(\"default\")\n+\n+        with patch(\"cli.shutil.get_terminal_size\", return_value=SimpleNamespace(columns=90)), \\\n+             patch(\"cli.format_banner_version_label\", return_value=\"Hermes Agent v1.0 (test) · upstream abc12345\"):\n+            banner = _build_compact_banner()\n+\n+        assert \"upstream abc12345\" in banner\n+\n+\n+class TestAnsiRichTextHelper:\n+    def test_preserves_literal_brackets(self):\n+        text = _rich_text_from_ansi(\"[notatag] literal\")\n+        assert text.plain == \"[notatag] literal\"\n+\n+    def test_strips_ansi_but_keeps_plain_text(self):\n+        text = _rich_text_from_ansi(\"\\x1b[31mred\\x1b[0m\")\n+        assert text.plain == \"red\""]},{"file":"tests/test_model_picker_scroll.py","hunks":["@@ -0,0 +1,118 @@\n+\"\"\"Tests for the scrolling viewport logic in _curses_prompt_choice (issue #5755).\n+\n+The \"More providers\" submenu has 13 entries (11 extended + custom + cancel).\n+Before the fix, _curses_prompt_choice rendered items starting unconditionally\n+from index 0 with no scroll offset.  On terminals shorter than ~16 rows, items\n+near the bottom were never drawn.  When the cursor wrapped from 0 to the last\n+item (Cancel) via UP-arrow, the highlight rendered off-screen, leaving the menu\n+looking like only \"Cancel\" existed.\n+\n+The fix adds a scroll_offset that tracks the cursor so the highlighted item\n+is always within the visible window.  These tests exercise that logic in\n+isolation without requiring a real TTY.\n+\"\"\"\n+\n+import sys\n+import os\n+import pytest\n+\n+sys.path.insert(0, os.path.join(os.path.dirname(__file__), \"..\"))\n+\n+\n+# ---------------------------------------------------------------------------\n+# Pure scroll-offset logic extracted from _curses_menu for unit testing\n+# ---------------------------------------------------------------------------\n+\n+def _compute_scroll_offset(cursor: int, scroll_offset: int, visible: int, n_choices: int) -> int:\n+    \"\"\"Mirror of the scroll adjustment block inside _curses_menu.\"\"\"\n+    if cursor < scroll_offset:\n+        scroll_offset = cursor\n+    elif cursor >= scroll_offset + visible:\n+        scroll_offset = cursor - visible + 1\n+    scroll_offset = max(0, min(scroll_offset, max(0, n_choices - visible)))\n+    return scroll_offset\n+\n+\n+def _visible_indices(cursor: int, scroll_offset: int, visible: int, n_choices: int):\n+    \"\"\"Return the list indices that would be rendered for the given state.\"\"\"\n+    scroll_offset = _compute_scroll_offset(cursor, scroll_offset, visible, n_choices)\n+    return list(range(scroll_offset, min(scroll_offset + visible, n_choices)))\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests: scroll offset calculation\n+# ---------------------------------------------------------------------------\n+\n+class TestScrollOffsetLogic:\n+    N = 13  # typical extended-providers list length\n+\n+    def test_cursor_at_zero_no_scroll(self):\n+        \"\"\"Start position: offset stays 0, first items visible.\"\"\"\n+        assert _compute_scroll_offset(0, 0, 8, self.N) == 0\n+\n+    def test_cursor_within_window_unchanged(self):\n+        \"\"\"Cursor inside the current window: offset unchanged.\"\"\"\n+        assert _compute_scroll_offset(5, 0, 8, self.N) == 0\n+\n+    def test_cursor_at_last_item_scrolls_down(self):\n+        \"\"\"Cursor on Cancel (index 12) with 8-row window: offset = 12 - 8 + 1 = 5.\"\"\"\n+        offset = _compute_scroll_offset(12, 0, 8, self.N)\n+        assert offset == 5\n+        assert 12 in _visible_indices(12, 0, 8, self.N)\n+\n+    def test_cursor_wraps_to_cancel_via_up(self):\n+        \"\"\"UP from index 0 wraps to last item; last item must be visible.\"\"\"\n+        wrapped_cursor = (0 - 1) % self.N  # == 12\n+        indices = _visible_indices(wrapped_cursor, 0, 8, self.N)\n+        assert wrapped_cursor in indices\n+\n+    def test_cursor_above_window_scrolls_up(self):\n+        \"\"\"Cursor above current window: offset tracks cursor.\"\"\"\n+        # window currently shows [5..12], cursor moves to 3\n+        offset = _compute_scroll_offset(3, 5, 8, self.N)\n+        assert offset == 3\n+        assert 3 in _visible_indices(3, 5, 8, self.N)\n+\n+    def test_visible_window_never_exceeds_list(self):\n+        \"\"\"Offset is clamped so the window never starts past the list end.\"\"\"\n+        offset = _compute_scroll_offset(12, 0, 20, self.N)  # window larger than list\n+        assert offset == 0\n+\n+    def test_single_item_list(self):\n+        \"\"\"Edge case: one choice, cursor 0.\"\"\"\n+        assert _compute_scroll_offset(0, 0, 8, 1) == 0\n+\n+    def test_list_fits_in_window_no_scroll_needed(self):\n+        \"\"\"If all choices fit in the visible window, offset is always 0.\"\"\"\n+        for cursor in range(self.N):\n+            offset = _compute_scroll_offset(cursor, 0, 20, self.N)\n+            assert offset == 0, f\"cursor={cursor} should not scroll when window > list\"\n+\n+    def test_cursor_always_in_visible_range(self):\n+        \"\"\"Invariant: cursor is always within the rendered window after adjustment.\"\"\"\n+        visible = 5\n+        for cursor in range(self.N):\n+            indices = _visible_indices(cursor, 0, visible, self.N)\n+            assert cursor in indices, f\"cursor={cursor} not in visible={indices}\"\n+\n+    def test_full_navigation_down_cursor_always_visible(self):\n+        \"\"\"Simulate pressing DOWN through all items; cursor always in view.\"\"\"\n+        visible = 6\n+        scroll_offset = 0\n+        cursor = 0\n+        for _ in range(self.N + 2):  # wrap around twice\n+            scroll_offset = _compute_scroll_offset(cursor, scroll_offset, visible, self.N)\n+            rendered = list(range(scroll_offset, min(scroll_offset + visible, self.N)))\n+            assert cursor in rendered, f\"cursor={cursor} not in rendered={rendered}\"\n+            cursor = (cursor + 1) % self.N\n+\n+    def test_full_navigation_up_cursor_always_visible(self):\n+        \"\"\"Simulate pressing UP through all items; cursor always in view.\"\"\"\n+        visible = 6\n+        scroll_offset = 0\n+        cursor = 0\n+        for _ in range(self.N + 2):\n+            scroll_offset = _compute_scroll_offset(cursor, scroll_offset, visible, self.N)\n+            rendered = list(range(scroll_offset, min(scroll_offset + visible, self.N)))\n+            assert cursor in rendered, f\"cursor={cursor} not in rendered={rendered}\"\n+            cursor = (cursor - 1) % self.N\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}