# NousResearch/hermes-agent · Curses provider menu scrolling and UI fixes

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #5974. Verify it addresses your case.
- **Recommended:** read PR #5974 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-5974`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #5755: \[Bug\]: config models choose More providers, up or down only show cancel option.

## Prior & competing PRs

- **#5974: fix: CLI/UX batch — ChatConsole errors, curses scroll, skin-aware banner, git state banner**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `cli.py`

    ```diff
    @@ -63,7 +63,7 @@
         format_duration_compact,
         format_token_count_compact,
     )
    -from hermes_cli.banner import _format_context_length
    +from hermes_cli.banner import _format_context_length, format_banner_version_label
     
     _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
     
    ```

    ```diff
    @@ -1036,21 +1036,44 @@ def print(self, *args, **kwargs):
     
     def _build_compact_banner() -> str:
         """Build a compact banner that fits the current terminal width."""
    -    w = min(shutil.get_terminal_size().columns - 2, 64)
    +    try:
    +        from hermes_cli.skin_engine import get_active_skin
    +        _skin = get_active_skin()
    +    except Exception:
    +        _skin = None
    +
    +    skin_name = getattr(_skin, "name", "default") if _skin else "default"
    +    border_color = _skin.get_color("banner_border", "#FFD700") if _skin else "#FFD700"
    +    title_color = _skin.get_color("banner_title", "#FFBF00") if _skin else "#FFBF00"
    +    dim_color = _skin.get_color("banner_dim", "#B8860B") if _skin else "#B8860B"
    +
    +    if skin_name == "default":
    +        line1 = "⚕ NOUS HERMES - AI Agent Framework"
    +        tiny_line = "⚕ NOUS HERMES"
    +    else:
    +        agent_name = _skin.get_branding("agent_name", "Hermes Agent") if _skin else "Hermes Agent"
    +        line1 = f"{agent_name} - AI Agent Framework"
    +        tiny_line = agent_name
    +
    +    version_line = format_banner_version_label()
    +
    +    w = min(shutil.get_terminal_size().columns - 2, 88)
         if w < 30:
    -        return "\n[#FFBF00]⚕ NOUS HERMES[/] [dim #B8860B]- Nous Research[/]\n"
    +        return f"\n[{title_color}]{tiny_line}[/] [dim {dim_color}]- Nous Research[/]\n"
    +
         inner = w - 2  # inside the box border
         bar = "═" * w
    -    line1 = "⚕ NOUS HERMES - AI Agent Framework"
    -    line2 = "Messenger of the Digital Gods  ·  Nous Research"
    +    content_width = inner - 2
    +
         # Truncate and pad to fit
    -    line1 = line1[:inner - 2].ljust(inner - 2)
    -    line2 = line2[:inner - 2].ljust(inner - 2)
    +    line1 = line1[:content_width].ljust(content_width)
    +    line2 = version_line[:content_width].ljust(content_width)
    +
         return (
    -        f"\n[bold #FFD700]╔{bar}╗[/]\n"
    -        f"[bold #FFD700]║[/] [#FFBF00]{line1}[/] [bold #FFD700]║[/]\n"
    -        f"[bold #FFD700]║[/] [dim #B8860B]{line2}[/] [bold #FFD700]║[/]\n"
    -        f"[bold #FFD700]╚{bar}╝[/]\n"
    +        f"\n[bold {border_color}]╔{bar}╗[/]\n"
    +        f"[bold {border_color}]║[/] [{title_color}]{line1}[/] [bold {border_color}]║[/]\n"
    +        f"[bold {border_color}]║[/] [dim {dim_color}]{line2}[/] [bold {border_color}]║[/]\n"
    +        f"[bold {border_color}]╚{bar}╝[/]\n"
         )
     
     
    ```

    ```diff
    @@ -2163,7 +2186,7 @@ def _ensure_runtime_credentials(self) -> bool:
                 )
             except Exception as exc:
                 message = format_runtime_provider_error(exc)
    -            self.console.print(f"[bold red]{message}[/]")
    +            ChatConsole().print(f"[bold red]{message}[/]")
                 return False
     
             api_key = runtime.get("api_key")
    ```

    ```diff
    @@ -2378,7 +2401,7 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No
                         self._pending_title = None
                 return True
             except Exception as e:
    -            self.console.print(f"[bold red]Failed to initialize agent: {e}[/]")
    +            ChatConsole().print(f"[bold red]Failed to initialize agent: {e}[/]")
                 return False
         
         def show_banner(self):
    ```

    ```diff
    @@ -4530,13 +4553,13 @@ def process_command(self, command: str) -> bool:
                                 if output:
                                     self.console.print(_rich_text_from_ansi(output))
                                 else:
    -                                self.console.print("[dim]Command returned no output[/]")
    +                                ChatConsole().print("[dim]Command returned no output[/]")
                             except subprocess.TimeoutExpired:
    -                            self.console.print("[bold red]Quick command timed out (30s)[/]")
    +                            ChatConsole().print("[bold red]Quick command timed out (30s)[/]")
                             except Exception as e:
    -                            self.console.print(f"[bold red]Quick command error: {e}[/]")
    +                            ChatConsole().print(f"[bold red]Quick command error: {e}[/]")
                         else:
    -                        self.console.print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]")
    +                        ChatConsole().print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]")
                     elif qcmd.get("type") == "alias":
                         target = qcmd.get("target", "").strip()
                         if target:
    ```

    ```diff
    @@ -4545,9 +4568,9 @@ def process_command(self, command: str) -> bool:
                             aliased_command = f"{target} {user_args}".strip()
                             return self.process_command(aliased_command)
                         else:
    -                        self.console.print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
    +                        ChatConsole().print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
                     else:
    -                    self.console.print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
    +                    ChatConsole().print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
                 # Check for plugin-registered slash commands
                 elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names():
                     from hermes_cli.plugins import get_plugin_command_handler
    ```

    ```diff
    @@ -4572,7 +4595,7 @@ def process_command(self, command: str) -> bool:
                         if hasattr(self, '_pending_input'):
                             self._pending_input.put(msg)
                     else:
    -                    self.console.print(f"[bold red]Failed to load skill for {base_cmd}[/]")
    +                    ChatConsole().print(f"[bold red]Failed to load skill for {base_cmd}[/]")
                 else:
                     # Prefix matching: if input uniquely identifies one command, execute it.
                     # Matches against both built-in COMMANDS and installed skill commands so
    ```

    ```diff
    @@ -4633,14 +4656,14 @@ def _handle_plan_command(self, cmd: str):
             )
     
             if not msg:
    -            self.console.print("[bold red]Failed to load the bundled /plan skill[/]")
    +            ChatConsole().print("[bold red]Failed to load the bundled /plan skill[/]")
                 return
     
             _cprint(f"  📝 Plan mode queued via skill. Markdown plan target: {plan_path}")
             if hasattr(self, '_pending_input'):
                 self._pending_input.put(msg)
             else:
    -            self.console.print("[bold red]Plan mode unavailable: input queue not initialized[/]")
    +            ChatConsole().print("[bold red]Plan mode unavailable: input queue not initialized[/]")
         
         def _handle_background_command(self, cmd: str):
             """Handle /background <prompt> — run a prompt in a separate background session.
    ```
  - `hermes_cli/banner.py`

    ```diff
    @@ -5,6 +5,7 @@
     
     import json
     import logging
    +import os
     import shutil
     import subprocess
     import threading
    ```

    ```diff
    @@ -189,6 +190,79 @@ def check_for_updates() -> Optional[int]:
         return behind
     
     
    +def _resolve_repo_dir() -> Optional[Path]:
    +    """Return the active Hermes git checkout, or None if this isn't a git install."""
    +    hermes_home = get_hermes_home()
    +    repo_dir = hermes_home / "hermes-agent"
    +    if not (repo_dir / ".git").exists():
    +        repo_dir = Path(__file__).parent.parent.resolve()
    +    return repo_dir if (repo_dir / ".git").exists() else None
    +
    +
    +def _git_short_hash(repo_dir: Path, rev: str) -> Optional[str]:
    +    """Resolve a git revision to an 8-character short hash."""
    +    try:
    +        result = subprocess.run(
    +            ["git", "rev-parse", "--short=8", rev],
    +            capture_output=True,
    +            text=True,
    +            timeout=5,
    +            cwd=str(repo_dir),
    +        )
    +    except Exception:
    +        return None
    +    if result.returncode != 0:
    +        return None
    +    value = (result.stdout or "").strip()
    +    return value or None
    +
    +
    +def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]:
    +    """Return upstream/local git hashes for the startup banner."""
    +    repo_dir = repo_dir or _resolve_repo_dir()
    +    if repo_dir is None:
    +        return None
    +
    +    upstream = _git_short_hash(repo_dir, "origin/main")
    +    local = _git_short_hash(repo_dir, "HEAD")
    +    if not upstream or not local:
    +        return None
    +
    +    ahead = 0
    +    try:
    +        result = subprocess.run(
    +            ["git", "rev-list", "--count", "origin/main..HEAD"],
    +            capture_output=True,
    +            text=True,
    +            timeout=5,
    +            cwd=str(repo_dir),
    +        )
    +        if result.returncode == 0:
    +            ahead = int((result.stdout or "0").strip() or "0")
    +    except Exception:
    +        ahead = 0
    +
    +    return {"upstream": upstream, "local": local, "ahead": max(ahead, 0)}
    +
    +
    +def format_banner_version_label() -> str:
    +    """Return the version label shown in the startup banner title."""
    +    base = f"Hermes Agent v{VERSION} ({RELEASE_DATE})"
    +    state = get_git_banner_state()
    +    if not state:
    +        return base
    +
    +    upstream = state["upstream"]
    +    local = state["local"]
    +    ahead = int(state.get("ahead") or 0)
    +
    +    if ahead <= 0 or upstream == local:
    +        return f"{base} · upstream {upstream}"
    +
    +    carried_word = "commit" if ahead == 1 else "commits"
    +    return f"{base} · upstream {upstream} · local {local} (+{ahead} carried {carried_word})"
    +
    +
     # =========================================================================
     # Non-blocking update check
     # =========================================================================
    ```

    ```diff
    @@ -448,7 +522,7 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
         border_color = _skin_color("banner_border", "#CD7F32")
         outer_panel = Panel(
             layout_table,
    -        title=f"[bold {title_color}]{agent_name} v{VERSION} ({RELEASE_DATE})[/]",
    +        title=f"[bold {title_color}]{format_banner_version_label()}[/]",
             border_style=border_color,
             padding=(0, 2),
         )
    ```
  - `hermes_cli/setup.py`

    ```diff
    @@ -421,10 +421,22 @@ def _curses_menu(stdscr):
                     curses.init_pair(1, curses.COLOR_GREEN, -1)
                     curses.init_pair(2, curses.COLOR_YELLOW, -1)
                 cursor = default
    +            scroll_offset = 0
     
                 while True:
                     stdscr.clear()
                     max_y, max_x = stdscr.getmaxyx()
    +
    +                # Rows available for list items: rows 2..(max_y-2) inclusive.
    +                visible = max(1, max_y - 3)
    +
    +                # Scroll the viewport so the cursor is always visible.
    +                if cursor < scroll_offset:
    +                    scroll_offset = cursor
    +                elif cursor >= scroll_offset + visible:
    +                    scroll_offset = cursor - visible + 1
    +                scroll_offset = max(0, min(scroll_offset, max(0, len(choices) - visible)))
    +
                     try:
                         stdscr.addnstr(
                             0,
    ```

    ```diff
    @@ -436,12 +448,12 @@ def _curses_menu(stdscr):
                     except curses.error:
                         pass
     
    -                for i, choice in enumerate(choices):
    -                    y = i + 2
    +                for row, i in enumerate(range(scroll_offset, min(scroll_offset + visible, len(choices)))):
    +                    y = row + 2
                         if y >= max_y - 1:
                             break
                         arrow = "→" if i == cursor else " "
    -                    line = f" {arrow}  {choice}"
    +                    line = f" {arrow}  {choices[i]}"
                         attr = curses.A_NORMAL
                         if i == cursor:
                             attr = curses.A_BOLD
    ```
  - `tests/hermes_cli/test_banner_git_state.py`

    ```diff
    @@ -0,0 +1,63 @@
    +from unittest.mock import MagicMock, patch
    +
    +
    +def test_format_banner_version_label_without_git_state():
    +    from hermes_cli import banner
    +
    +    with patch.object(banner, "get_git_banner_state", return_value=None):
    +        value = banner.format_banner_version_label()
    +
    +    assert value == f"Hermes Agent v{banner.VERSION} ({banner.RELEASE_DATE})"
    +
    +
    +def test_format_banner_version_label_on_upstream_main():
    +    from hermes_cli import banner
    +
    +    with patch.object(
    +        banner,
    +        "get_git_banner_state",
    +        return_value={"upstream": "b2f477a3", "local": "b2f477a3", "ahead": 0},
    +    ):
    +        value = banner.format_banner_version_label()
    +
    +    assert value.endswith("· upstream b2f477a3")
    +    assert "local" not in value
    +
    +
    +def test_format_banner_version_label_with_carried_commits():
    +    from hermes_cli import banner
    +
    +    with patch.object(
    +        banner,
    +        "get_git_banner_state",
    +        return_value={"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3},
    +    ):
    +        value = banner.format_banner_version_label()
    +
    +    assert "upstream b2f477a3" in value
    +    assert "local af8aad31" in value
    +    assert "+3 carried commits" in value
    +
    +
    +def test_get_git_banner_state_reads_origin_and_head(tmp_path):
    +    from hermes_cli import banner
    +
    +    repo_dir = tmp_path / "repo"
    +    (repo_dir / ".git").mkdir(parents=True)
    +
    +    results = {
    +        ("git", "rev-parse", "--short=8", "origin/main"): MagicMock(returncode=0, stdout="b2f477a3\n"),
    +        ("git", "rev-parse", "--short=8", "HEAD"): MagicMock(returncode=0, stdout="af8aad31\n"),
    +        ("git", "rev-list", "--count", "origin/main..HEAD"): MagicMock(returncode=0, stdout="3\n"),
    +    }
    +
    +    def fake_run(cmd, **kwargs):
    +        key = tuple(cmd)
    +        if key not in results:
    +            raise AssertionError(f"unexpected command: {cmd}")
    +        return results[key]
    +
    +    with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run):
    +        state = banner.get_git_banner_state(repo_dir)
    +
    +    assert state == {"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3}
    ```
  - `tests/test_cli_skin_integration.py`

    ```diff
    @@ -0,0 +1,140 @@
    +from types import SimpleNamespace
    +from unittest.mock import MagicMock, patch
    +
    +from cli import HermesCLI, _build_compact_banner, _rich_text_from_ansi
    +from hermes_cli.skin_engine import get_active_skin, set_active_skin
    +
    +
    +def _make_cli_stub():
    +    cli = HermesCLI.__new__(HermesCLI)
    +    cli._sudo_state = None
    +    cli._secret_state = None
    +    cli._approval_state = None
    +    cli._clarify_state = None
    +    cli._clarify_freetext = False
    +    cli._command_running = False
    +    cli._agent_running = False
    +    cli._voice_recording = False
    +    cli._voice_processing = False
    +    cli._voice_mode = False
    +    cli._command_spinner_frame = lambda: "⟳"
    +    cli._tui_style_base = {
    +        "prompt": "#fff",
    +        "input-area": "#fff",
    +        "input-rule": "#aaa",
    +        "prompt-working": "#888 italic",
    +    }
    +    cli._app = SimpleNamespace(style=None)
    +    cli._invalidate = MagicMock()
    +    return cli
    +
    +
    +class TestCliSkinPromptIntegration:
    +    def test_default_prompt_fragments_use_default_symbol(self):
    +        cli = _make_cli_stub()
    +
    +        set_active_skin("default")
    +        assert cli._get_tui_prompt_fragments() == [("class:prompt", "❯ ")]
    +
    +    def test_ares_prompt_fragments_use_skin_symbol(self):
    +        cli = _make_cli_stub()
    +
    +        set_active_skin("ares")
    +        assert cli._get_tui_prompt_fragments() == [("class:prompt", "⚔ ❯ ")]
    +
    +    def test_secret_prompt_fragments_preserve_secret_state(self):
    +        cli = _make_cli_stub()
    +        cli._secret_state = {"response_queue": object()}
    +
    +        set_active_skin("ares")
    +        assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ❯ ")]
    +
    +    def test_icon_only_skin_symbol_still_visible_in_special_states(self):
    +        cli = _make_cli_stub()
    +        cli._secret_state = {"response_queue": object()}
    +
    +        with patch("hermes_cli.skin_engine.get_active_prompt_symbol", return_value="⚔ "):
    +            assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ⚔ ")]
    +
    +    def test_build_tui_style_dict_uses_skin_overrides(self):
    +        cli = _make_cli_stub()
    +
    +        set_active_skin("ares")
    +        skin = get_active_skin()
    +        style_dict = cli._build_tui_style_dict()
    +
    +        assert style_dict["prompt"] == skin.get_color("prompt")
    +        assert style_dict["input-rule"] == skin.get_color("input_rule")
    +        assert style_dict["prompt-working"] == f"{skin.get_color('banner_dim')} italic"
    +        assert style_dict["approval-title"] == f"{skin.get_color('ui_warn')} bold"
    +
    +    def test_apply_tui_skin_style_updates_running_app(self):
    +        cli = _make_cli_stub()
    +
    +        set_active_skin("ares")
    +        assert cli._apply_tui_skin_style() is True
    +        assert cli._app.style is not None
    +        cli._invalidate.assert_called_once_with(min_interval=0.0)
    +
    +    def test_handle_skin_command_refreshes_live_tui(self, capsys):
    +        cli = _make_cli_stub()
    +
    +        with patch("cli.save_config_value", return_value=True):
    +            cli._handle_skin_command("/skin ares")
    +
    +        output = capsys.readouterr().out
    +        assert "Skin set to: ares (saved)" in output
    +        assert "Prompt + TUI colors updated." in output
    +        assert cli._app.style is not None
    +
    +
    +class TestCompactBannerSkinIntegration:
    +    def test_default_compact_banner_keeps_legacy_nous_hermes_branding(self):
    +        set_active_skin("default")
    +
    +        with patch("cli.shutil.get_terminal_size", return_value=SimpleNamespace(columns=90)), \
    +             patch("cli.format_banner_version_label", return_value="Hermes Agent v0.1.0 (test)"):
    +            banner = _build_compact_banner()
    +
    +        assert "NOUS HERMES" in banner
    +
    +    def test_poseidon_compact_banner_uses_skin_branding_instead_of_nous_hermes(self):
    +        set_active_skin("poseidon")
    +
    +        with patch("cli.shutil.get_terminal_size", return_value=SimpleNamespace(columns=90)), \
    +             patch("cli.format_banner_version_label", return_value="Hermes Agent v0.1.0 (test)"):
    +            banner = _build_compact_banner()
    +
    +        assert "Poseidon Agent" in banner
    +        assert "NOUS HERMES" not in banner
    +
    +    def test_poseidon_compact_banner_uses_skin_colors(self):
    +        set_active_skin("poseidon")
    +        skin = get_active_skin()
    +
    +        with patch("cli.shutil.get_terminal_size", return_value=SimpleNamespace(columns=90)), \
    +             patch("cli.format_banner_version_label", return_value="Hermes Agent v0.1.0 (test)"):
    +            banner = _build_compact_banner()
    +
    +        assert skin.get_color("banner_border") in banner
    +        assert skin.get_color("banner_title") in banner
    +        assert skin.get_color("banner_dim") in banner
    +
    +    def test_compact_banner_shows_version_label(self):
    +        set_active_skin("default")
    +
    +        with patch("cli.shutil.get_terminal_size", return_value=SimpleNamespace(columns=90)), \
    +             patch("cli.format_banner_version_label", return_value="Hermes Agent v1.0 (test) · upstream abc12345"):
    +            banner = _build_compact_banner()
    +
    +        assert "upstream abc12345" in banner
    +
    +
    +class TestAnsiRichTextHelper:
    +    def test_preserves_literal_brackets(self):
    +        text = _rich_text_from_ansi("[notatag] literal")
    +        assert text.plain == "[notatag] literal"
    +
    +    def test_strips_ansi_but_keeps_plain_text(self):
    +        text = _rich_text_from_ansi("\x1b[31mred\x1b[0m")
    +        assert text.plain == "red"
    ```
  - `tests/test_model_picker_scroll.py`

    ```diff
    @@ -0,0 +1,118 @@
    +"""Tests for the scrolling viewport logic in _curses_prompt_choice (issue #5755).
    +
    +The "More providers" submenu has 13 entries (11 extended + custom + cancel).
    +Before the fix, _curses_prompt_choice rendered items starting unconditionally
    +from index 0 with no scroll offset.  On terminals shorter than ~16 rows, items
    +near the bottom were never drawn.  When the cursor wrapped from 0 to the last
    +item (Cancel) via UP-arrow, the highlight rendered off-screen, leaving the menu
    +looking like only "Cancel" existed.
    +
    +The fix adds a scroll_offset that tracks the cursor so the highlighted item
    +is always within the visible window.  These tests exercise that logic in
    +isolation without requiring a real TTY.
    +"""
    +
    +import sys
    +import os
    +import pytest
    +
    +sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
    +
    +
    +# ---------------------------------------------------------------------------
    +# Pure scroll-offset logic extracted from _curses_menu for unit testing
    +# ---------------------------------------------------------------------------
    +
    +def _compute_scroll_offset(cursor: int, scroll_offset: int, visible: int, n_choices: int) -> int:
    +    """Mirror of the scroll adjustment block inside _curses_menu."""
    +    if cursor < scroll_offset:
    +        scroll_offset = cursor
    +    elif cursor >= scroll_offset + visible:
    +        scroll_offset = cursor - visible + 1
    +    scroll_offset = max(0, min(scroll_offset, max(0, n_choices - visible)))
    +    return scroll_offset
    +
    +
    +def _visible_indices(cursor: int, scroll_offset: int, visible: int, n_choices: int):
    +    """Return the list indices that would be rendered for the given state."""
    +    scroll_offset = _compute_scroll_offset(cursor, scroll_offset, visible, n_choices)
    +    return list(range(scroll_offset, min(scroll_offset + visible, n_choices)))
    +
    +
    +# ---------------------------------------------------------------------------
    +# Tests: scroll offset calculation
    +# ---------------------------------------------------------------------------
    +
    +class TestScrollOffsetLogic:
    +    N = 13  # typical extended-providers list length
    +
    +    def test_cursor_at_zero_no_scroll(self):
    +        """Start position: offset stays 0, first items visible."""
    +        assert _compute_scroll_offset(0, 0, 8, self.N) == 0
    +
    +    def test_cursor_within_window_unchanged(self):
    +        """Cursor inside the current window: offset unchanged."""
    +        assert _compute_scroll_offset(5, 0, 8, self.N) == 0
    +
    +    def test_cursor_at_last_item_scrolls_down(self):
    +        """Cursor on Cancel (index 12) with 8-row window: offset = 12 - 8 + 1 = 5."""
    +        offset = _compute_scroll_offset(12, 0, 8, self.N)
    +        assert offset == 5
    +        assert 12 in _visible_indices(12, 0, 8, self.N)
    +
    +    def test_cursor_wraps_to_cancel_via_up(self):
    +        """UP from index 0 wraps to last item; last item must be visible."""
    +        wrapped_cursor = (0 - 1) % self.N  # == 12
    +        indices = _visible_indices(wrapped_cursor, 0, 8, self.N)
    +        assert wrapped_cursor in indices
    +
    +    def test_cursor_above_window_scrolls_up(self):
    +        """Cursor above current window: offset tracks cursor."""
    +        # window currently shows [5..12], cursor moves to 3
    +        offset = _compute_scroll_offset(3, 5, 8, self.N)
    +        assert offset == 3
    +        assert 3 in _visible_indices(3, 5, 8, self.N)
    +
    +    def test_visible_window_never_exceeds_list(self):
    +        """Offset is clamped so the window never starts past the list end."""
    +        offset = _compute_scroll_offset(12, 0, 20, self.N)  # window larger than list
    +        assert offset == 0
    +
    +    def test_single_item_list(self):
    +        """Edge case: one choice, cursor 0."""
    +        assert _compute_scroll_offset(0, 0, 8, 1) == 0
    +
    +    def test_list_fits_in_window_no_scroll_needed(self):
    +        """If all choices fit in the visible window, offset is always 0."""
    +        for cursor in range(self.N):
    +            offset = _compute_scroll_offset(cursor, 0, 20, self.N)
    +            assert offset == 0, f"cursor={cursor} should not scroll when window > list"
    +
    +    def test_cursor_always_in_visible_range(self):
    +        """Invariant: cursor is always within the rendered window after adjustment."""
    +        visible = 5
    +        for cursor in range(self.N):
    +            indices = _visible_indices(cursor, 0, visible, self.N)
    +            assert cursor in indices, f"cursor={cursor} not in visible={indices}"
    +
    +    def test_full_navigation_down_cursor_always_visible(self):
    +        """Simulate pressing DOWN through all items; cursor always in view."""
    +        visible = 6
    +        scroll_offset = 0
    +        cursor = 0
    +        for _ in range(self.N + 2):  # wrap around twice
    +            scroll_offset = _compute_scroll_offset(cursor, scroll_offset, visible, self.N)
    +            rendered = list(range(scroll_offset, min(scroll_offset + visible, self.N)))
    +            assert cursor in rendered, f"cursor={cursor} not in rendered={rendered}"
    +            cursor = (cursor + 1) % self.N
    +
    +    def test_full_navigation_up_cursor_always_visible(self):
    +        """Simulate pressing UP through all items; cursor always in view."""
    +        visible = 6
    +        scroll_offset = 0
    +        cursor = 0
    +        for _ in range(self.N + 2):
    +            scroll_offset = _compute_scroll_offset(cursor, scroll_offset, visible, self.N)
    +            rendered = list(range(scroll_offset, min(scroll_offset + visible, self.N)))
    +            assert cursor in rendered, f"cursor={cursor} not in rendered={rendered}"
    +            cursor = (cursor - 1) % self.N
    ```
- **#5790: fix(model-picker): add scrolling viewport to curses provider menu**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#5974`

## Fix status

- Merged to main in PR #5974 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-08`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `hermes_cli`
