NousResearch/hermes-agent

Curses provider menu scrolling and UI fixes

Cluster 5755 · hermes cli. Data as of

Nothing open on GitHub. Fix merged in #5974 on 8 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #5974. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #5974. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #5974 fix: CLI/UX batch — ChatConsole errors, curses scroll, skin-aware banner, git state banner

    merged to main

    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

    Show the diff
    • cli.py
      @@ -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 = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
       
      @@ -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"
           )
       
       
      @@ -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")
      @@ -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):
      @@ -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:
      @@ -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
      @@ -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
      @@ -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
      @@ -5,6 +5,7 @@
       
       import json
       import logging
      +import os
       import shutil
       import subprocess
       import threading
      @@ -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
       # =========================================================================
      @@ -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
      @@ -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,
      @@ -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
      @@ -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
      @@ -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
      @@ -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

1 PR closed without merging

From the close comments: merged via another change 1

  • #5790 fix(model-picker): add scrolling viewport to curses provider menu · merged via another change → #5974
Closed issues · 1
  • Closed#5755[Bug]: config models choose More providers, up or down only show cancel option.
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#5974
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
8 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.