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

* fix(cli): route error messages through ChatConsole inside patch_stdout

Cherry-pick of PR #5798 by @icn5381.

Replace self.console.print() with ChatConsole().print() for 11 error/status
messages reachable during the interactive session. Inside patch_stdout,
self.console (plain Rich Console) writes raw ANSI escapes that StdoutProxy
mangles into garbled text. ChatConsole uses prompt_toolkit's native
print_formatted_text which renders correctly.

Same class of bug as #2262 — that fix covered agent output but missed
these error paths in _ensure_runtime_credentials, _init_agent, quick
commands, skill loading, and plan mode.

* fix(model-picker): add scrolling viewport to curses provider menu

Cherry-pick of PR #5790 by @Lempkey. Fixes #5755.

_curses_prompt_choice rendered items starting unconditionally from index 0
with no scroll offset. The 'More providers' submenu has 13 entries. On
terminals shorter than ~16 rows, items past the fold were never drawn.
When UP-arrow wrapped cursor from 0 to the last item (Cancel, index 12),
the highlight rendered off-screen — appearing as if only Cancel existed.

Adds scroll_offset tracking that adjusts each frame to keep the cursor
inside the visible window.

* feat(cli): skin-aware compact banner + git state in startup banner

Combined salvage of PR #5922 by @ASRagab and PR #5877 by @xinbenlv.

Compact banner changes (from #5922):
- Read active skin colors and branding instead of hardcoding gold/NOUS HERMES
- Default skin preserves backward-compatible legacy branding
- Non-default skins use their own agent_name and colors

Git state in banner (from #5877):
- New format_banner_version_label() shows upstream/local git hashes
- Full banner title now includes git state (upstream hash, carried commits)
- Compact banner line2 shows the version label with git state
- Widen compact banner max width from 64 to 88 to fit version info

Both the full Rich banner and compact fallback are now skin-aware
and show git state.
This commit is contained in:
Teknium 2026-04-07 17:59:42 -07:00 committed by GitHub
parent f3c59321af
commit 9692b3c28a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 456 additions and 26 deletions

67
cli.py
View file

@ -63,7 +63,7 @@ from agent.usage_pricing import (
format_duration_compact, format_duration_compact,
format_token_count_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 = ("", "", "", "", "", "", "", "", "", "") _COMMAND_SPINNER_FRAMES = ("", "", "", "", "", "", "", "", "", "")
@ -1036,21 +1036,44 @@ COMPACT_BANNER = """
def _build_compact_banner() -> str: def _build_compact_banner() -> str:
"""Build a compact banner that fits the current terminal width.""" """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: 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 inner = w - 2 # inside the box border
bar = "" * w bar = "" * w
line1 = "⚕ NOUS HERMES - AI Agent Framework" content_width = inner - 2
line2 = "Messenger of the Digital Gods · Nous Research"
# Truncate and pad to fit # Truncate and pad to fit
line1 = line1[:inner - 2].ljust(inner - 2) line1 = line1[:content_width].ljust(content_width)
line2 = line2[:inner - 2].ljust(inner - 2) line2 = version_line[:content_width].ljust(content_width)
return ( return (
f"\n[bold #FFD700]╔{bar}╗[/]\n" f"\n[bold {border_color}]╔{bar}╗[/]\n"
f"[bold #FFD700]║[/] [#FFBF00]{line1}[/] [bold #FFD700]║[/]\n" f"[bold {border_color}]║[/] [{title_color}]{line1}[/] [bold {border_color}]║[/]\n"
f"[bold #FFD700]║[/] [dim #B8860B]{line2}[/] [bold #FFD700]║[/]\n" f"[bold {border_color}]║[/] [dim {dim_color}]{line2}[/] [bold {border_color}]║[/]\n"
f"[bold #FFD700]╚{bar}╝[/]\n" f"[bold {border_color}]╚{bar}╝[/]\n"
) )
@ -2163,7 +2186,7 @@ class HermesCLI:
) )
except Exception as exc: except Exception as exc:
message = format_runtime_provider_error(exc) message = format_runtime_provider_error(exc)
self.console.print(f"[bold red]{message}[/]") ChatConsole().print(f"[bold red]{message}[/]")
return False return False
api_key = runtime.get("api_key") api_key = runtime.get("api_key")
@ -2378,7 +2401,7 @@ class HermesCLI:
self._pending_title = None self._pending_title = None
return True return True
except Exception as e: 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 return False
def show_banner(self): def show_banner(self):
@ -4530,13 +4553,13 @@ class HermesCLI:
if output: if output:
self.console.print(_rich_text_from_ansi(output)) self.console.print(_rich_text_from_ansi(output))
else: else:
self.console.print("[dim]Command returned no output[/]") ChatConsole().print("[dim]Command returned no output[/]")
except subprocess.TimeoutExpired: 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: except Exception as e:
self.console.print(f"[bold red]Quick command error: {e}[/]") ChatConsole().print(f"[bold red]Quick command error: {e}[/]")
else: 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": elif qcmd.get("type") == "alias":
target = qcmd.get("target", "").strip() target = qcmd.get("target", "").strip()
if target: if target:
@ -4545,9 +4568,9 @@ class HermesCLI:
aliased_command = f"{target} {user_args}".strip() aliased_command = f"{target} {user_args}".strip()
return self.process_command(aliased_command) return self.process_command(aliased_command)
else: 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: 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 # Check for plugin-registered slash commands
elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names(): elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names():
from hermes_cli.plugins import get_plugin_command_handler from hermes_cli.plugins import get_plugin_command_handler
@ -4572,7 +4595,7 @@ class HermesCLI:
if hasattr(self, '_pending_input'): if hasattr(self, '_pending_input'):
self._pending_input.put(msg) self._pending_input.put(msg)
else: 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: else:
# Prefix matching: if input uniquely identifies one command, execute it. # Prefix matching: if input uniquely identifies one command, execute it.
# Matches against both built-in COMMANDS and installed skill commands so # Matches against both built-in COMMANDS and installed skill commands so
@ -4633,14 +4656,14 @@ class HermesCLI:
) )
if not msg: 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 return
_cprint(f" 📝 Plan mode queued via skill. Markdown plan target: {plan_path}") _cprint(f" 📝 Plan mode queued via skill. Markdown plan target: {plan_path}")
if hasattr(self, '_pending_input'): if hasattr(self, '_pending_input'):
self._pending_input.put(msg) self._pending_input.put(msg)
else: 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): def _handle_background_command(self, cmd: str):
"""Handle /background <prompt> — run a prompt in a separate background session. """Handle /background <prompt> — run a prompt in a separate background session.

View file

@ -5,6 +5,7 @@ Pure display functions with no HermesCLI state dependency.
import json import json
import logging import logging
import os
import shutil import shutil
import subprocess import subprocess
import threading import threading
@ -189,6 +190,79 @@ def check_for_updates() -> Optional[int]:
return behind 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 # 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") border_color = _skin_color("banner_border", "#CD7F32")
outer_panel = Panel( outer_panel = Panel(
layout_table, 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, border_style=border_color,
padding=(0, 2), padding=(0, 2),
) )

View file

@ -421,10 +421,22 @@ def _curses_prompt_choice(question: str, choices: list, default: int = 0) -> int
curses.init_pair(1, curses.COLOR_GREEN, -1) curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1) curses.init_pair(2, curses.COLOR_YELLOW, -1)
cursor = default cursor = default
scroll_offset = 0
while True: while True:
stdscr.clear() stdscr.clear()
max_y, max_x = stdscr.getmaxyx() 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: try:
stdscr.addnstr( stdscr.addnstr(
0, 0,
@ -436,12 +448,12 @@ def _curses_prompt_choice(question: str, choices: list, default: int = 0) -> int
except curses.error: except curses.error:
pass pass
for i, choice in enumerate(choices): for row, i in enumerate(range(scroll_offset, min(scroll_offset + visible, len(choices)))):
y = i + 2 y = row + 2
if y >= max_y - 1: if y >= max_y - 1:
break break
arrow = "" if i == cursor else " " arrow = "" if i == cursor else " "
line = f" {arrow} {choice}" line = f" {arrow} {choices[i]}"
attr = curses.A_NORMAL attr = curses.A_NORMAL
if i == cursor: if i == cursor:
attr = curses.A_BOLD attr = curses.A_BOLD

View file

@ -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}

View file

@ -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"

View file

@ -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