Interactive session picker and resume navigation features

NousResearch/hermes-agent · cluster 718 · hermes cli

Data as of

3 open items. Fix merged in #733. Check it covers your case.

The short answer

2open issues
1open PR
10items grouped
Known?
Yes. 10 related items grouped here, 3 issues already closed.
Fix event
A fix was merged to main in PR #733. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #733. That is separate from the fix event.
Still open
2 issues and 1 PR.

These issues and pull requests propose and implement interactive session browsing, search filtering, and numeric/positional selection for resuming sessions via /resume and related CLI commands. Several also touch on history viewing improvements such as a full pager and Ctrl+P inspection.

Open issues2

  • Issue#5509feat: Ctrl+P context-aware inspect + /history full pager
  • Issue#41099[Feature]: Search through sessions easily with a session menu.

Open PRs1

  • PR#8498feat: Ctrl+P history pager + /history full (newest first, full text, +G scroll)

Fixes on record

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

  • #733 feat: interactive session browser with search filtering (#718)

    merged to main

    files: hermes_cli/main.py, tests/hermes_cli/test_session_browse.py

    Show the diff
    • hermes_cli/main.py
      @@ -21,6 +21,7 @@
           hermes version             # Show version
           hermes update              # Update to latest version
           hermes uninstall           # Uninstall Hermes Agent
      +    hermes sessions browse     # Interactive session picker with search
       """
       
       import argparse
      @@ -106,6 +107,279 @@ def _has_any_provider_configured() -> bool:
           return False
       
       
      +def _session_browse_picker(sessions: list) -> Optional[str]:
      +    """Interactive curses-based session browser with live search filtering.
      +
      +    Returns the selected session ID, or None if cancelled.
      +    Uses curses (not simple_term_menu) to avoid the ghost-duplication rendering
      +    bug in tmux/iTerm when arrow keys are used.
      +    """
      +    if not sessions:
      +        print("No sessions found.")
      +        return None
      +
      +    # Try curses-based picker first
      +    try:
      +        import curses
      +        import time as _time
      +        from datetime import datetime
      +
      +        result_holder = [None]
      +
      +        def _relative_time(ts):
      +            if not ts:
      +                return "?"
      +            delta = _time.time() - ts
      +            if delta < 60:
      +                return "just now"
      +            elif delta < 3600:
      +                return f"{int(delta / 60)}m ago"
      +            elif delta < 86400:
      +                return f"{int(delta / 3600)}h ago"
      +            elif delta < 172800:
      +                return "yesterday"
      +            elif delta < 604800:
      +                return f"{int(delta / 86400)}d ago"
      +            else:
      +                return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
      +
      +        def _format_row(s, max_x):
      +            """Format a session row for display."""
      +            title = (s.get("title") or "").strip()
      +            preview = (s.get("preview") or "").strip()
      +            source = s.get("source", "")[:6]
      +            last_active = _relative_time(s.get("last_active"))
      +            sid = s["id"][:18]
      +
      +            # Adaptive column widths based on terminal width
      +            # Layout: [arrow 3] [title/preview flexible] [active 12] [src 6] [id 18]
      +            fixed_cols = 3 + 12 + 6 + 18 + 6  # arrow + active + src + id + padding
      +            name_width = max(20, max_x - fixed_cols)
      +
      +            if title:
      +                name = title[:name_width]
      +            elif preview:
      +                name = preview[:name_width]
      +            else:
      +                name = sid
      +
      +            return f"{name:<{name_width}}  {last_active:<10}  {source:<5} {sid}"
      +
      +        def _match(s, query):
      +            """Check if a session matches the search query (case-insensitive)."""
      +            q = query.lower()
      +            return (
      +                q in (s.get("title") or "").lower()
      +                or q in (s.get("preview") or "").lower()
      +                or q in s.get("id", "").lower()
      +                or q in (s.get("source") or "").lower()
      +            )
      +
      +        def _curses_browse(stdscr):
      +            curses.curs_set(0)
      +            if curses.has_colors():
      +                curses.start_color()
      +                curses.use_default_colors()
      +                curses.init_pair(1, curses.COLOR_GREEN, -1)   # selected
      +                curses.init_pair(2, curses.COLOR_YELLOW, -1)  # header
      +                curses.init_pair(3, curses.COLOR_CYAN, -1)    # search
      +                curses.init_pair(4, 8, -1)                    # dim
      +
      +            cursor = 0
      +            scroll_offset = 0
      +            search_text = ""
      +            filtered = list(sessions)
      +
      +            while True:
      +                stdscr.clear()
      +                max_y, max_x = stdscr.getmaxyx()
      +                if max_y < 5 or max_x < 40:
      +                    # Terminal too small
      +                    try:
      +                        stdscr.addstr(0, 0, "Terminal too small")
      +                    except curses.error:
      +                        pass
      +                    stdscr.refresh()
      +                    stdscr.getch()
      +                    return
      +
      +                # Header line
      +                if search_text:
      +                    header = f"  Browse sessions — filter: {search_text}█"
      +                    header_attr = curses.A_BOLD
      +                    if curses.has_colors():
      +                        header_attr |= curses.color_pair(3)
      +                else:
      +                    header = "  Browse sessions — ↑↓ navigate  Enter select  Type to filter  Esc quit"
      +                    header_attr = curses.A_BOLD
      +                    if curses.has_colors():
      +                        header_attr |= curses.color_pair(2)
      +                try:
      +                    stdscr.addnstr(0, 0, header, max_x - 1, header_attr)
      +                except curses.error:
      +                    pass
      +
      +                # Column header line
      +                fixed_cols = 3 + 12 + 6 + 18 + 6
      +                name_width = max(20, max_x - fixed_cols)
      +                col_header = f"   {'Title / Preview':<{name_width}}  {'Active':<10}  {'Src':<5} {'ID'}"
      +                try:
      +                    dim_attr = curses.color_pair(4) if curses.has_colors() else curses.A_DIM
      +                    stdscr.addnstr(1, 0, col_header, max_x - 1, dim_attr)
      +                except curses.error:
      +                    pass
      +
      +                # Compute visible area
      +                visible_rows = max_y - 4  # header + col header + blank + footer
      +                if visible_rows < 1:
      +                    visible_rows = 1
      +
      +                # Clamp cursor and scroll
      +                if not filtered:
      +                    try:
      +                        msg = "  No sessions match the filter."
      +                        stdscr.addnstr(3, 0, msg, max_x - 1, curses.A_DIM)
      +                    except curses.error:
      +                        pass
      +                else:
      +                    if cursor >= len(filtered):
      +                        cursor = len(filtered) - 1
      +                    if cursor < 0:
      +                        cursor = 0
      +                    if cursor < scroll_offset:
      +                        scroll_offset = cursor
      +                    elif cursor >= scroll_offset + visible_rows:
      +                        scroll_offset = cursor - visible_rows + 1
      +
      +                    for draw_i, i in enumerate(range(
      +                        scroll_offset,
      +                        min(len(filtered), scroll_offset + visible_rows)
      +                    )):
      +                        y = draw_i + 3
      +                        if y >= max_y - 1:
      +                            break
      +                        s = filtered[i]
      +                        arrow = " → " if i == cursor else "   "
      +                        row = arrow + _format_row(s, max_x - 3)
      +                        attr = curses.A_NORMAL
      +                        if i == cursor:
      +                            attr = curses.A_BOLD
      +                            if curses.has_colors():
      +                                attr |= curses.color_pair(1)
      +                        try:
      +                            stdscr.addnstr(y, 0, row, max_x - 1, attr)
      +                        except curses.error:
      +                            pass
      +
      +                # Footer
      +                footer_y = max_y - 1
      +                if filtered:
      +                    footer = f"  {cursor + 1}/{len(filtered)} sessions"
      +                    if len(filtered) < len(sessions):
      +                        footer += f" (filtered from {len(sessions)})"
      +                else:
      +                    footer = f"  0/{len(sessions)} sessions"
      +                try:
      +                    stdscr.addnstr(footer_y, 0, footer, max_x - 1,
      +                                   curses.color_pair(4) if curses.has_colors() else curses.A_DIM)
      +                except curses.error:
      +                    pass
      +
      +                stdscr.refresh()
      +                key = stdscr.getch()
      +
      +                if key in (curses.KEY_UP, ):
      +                    if filtered:
      +                        cursor = (cursor - 1) % len(filtered)
      +                elif key in (curses.KEY_DOWN, ):
      +                    if filtered:
      +                        cursor = (cursor + 1) % len(filtered)
      +                elif key in (curses.KEY_ENTER, 10, 13):
      +                    if filtered:
      +                        result_holder[0] = filtered[cursor]["id"]
      +                    return
      +                elif key == 27:  # Esc
      +                    if search_text:
      +                        # First Esc clears the search
      +                        search_text = ""
      +                        filtered = list(sessions)
      +                        cursor = 0
      +                        scroll_offset = 0
      +                    else:
      +                        # Second Esc exits
      +                        return
      +                elif key in (curses.KEY_BACKSPACE, 127, 8):
      +                    if search_text:
      +                        search_text = search_text[:-1]
      +                        if search_text:
      +                            filtered = [s for s in sessions if _match(s, search_text)]
      +                        else:
      +                            filtered = list(sessions)
      +                        cursor = 0
      +                        scroll_offset = 0
      +                elif key == ord('q') and not search_text:
      +                    return
      +                elif 32 <= key <= 126:
      +                    # Printable character → add to search filter
      +                    search_text += chr(key)
      +                    filtered = [s for s in sessions if _match(s, search_text)]
      +                    cursor = 0
      +                    scroll_offset = 0
      +
      +        curses.wrapper(_curses_browse)
      +        return result_holder[0]
      +
      +    except Exception:
      +        pass
      +
      +    # Fallback: numbered list (Windows without curses, etc.)
      +    import time as _time
      +    from datetime import datetime
      +
      +    def _relative_time_fb(ts):
      +        if not ts:
      +            return "?"
      +        delta = _time.time() - ts
      +        if delta < 60:
      +            return "just now"
      +        elif delta < 3600:
      +            return f"{int(delta / 60)}m ago"
      +        elif delta < 86400:
      +            return f"{int(delta / 3600)}h ago"
      +        elif delta < 172800:
      +            return "yesterday"
      +        elif delta < 604800:
      +            return f"{int(delta / 86400)}d ago"
      +        else:
      +            return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
      +
      +    print("\n  Browse sessions  (enter number to resume, q to cancel)\n")
      +    for i, s in enumerate(sessions):
      +        title = (s.get("title") or "").strip()
      +        preview = (s.get("preview") or "").strip()
      +        label = title or preview or s["id"]
      +        if len(label) > 50:
      +            label = label[:47] + "..."
      +        last_active = _relative_time_fb(s.get("last_active"))
      +        src = s.get("source", "")[:6]
      +        print(f"  {i + 1:>3}. {label:<50}  {last_active:<10}  {src}")
      +
      +    while True:
      +        try:
      +            val = input(f"\n  Select [1-{len(sessions)}]: ").strip()
      +            if not val or val.lower() in ("q", "quit", "exit"):
      +                return None
      +            idx = int(val) - 1
      +            if 0 <= idx < len(sessions):
      +                return sessions[idx]["id"]
      +            print(f"  Invalid selection. Enter 1-{len(sessions)} or q to cancel.")
      +        except ValueError:
      +            print(f"  Invalid input. Enter a number or q to cancel.")
      +        except (KeyboardInterrupt, EOFError):
      +            print()
      +            return None
      +
      +
       def _resolve_last_cli_session() -> Optional[str]:
           """Look up the most recent CLI session ID from SQLite. Returns None if unavailable."""
           try:
      @@ -1269,6 +1543,7 @@ def main():
           hermes -w                     Start in isolated git worktree
           hermes gateway install        Install as system service
           hermes sessions list          List past sessions
      +    hermes sessions browse        Interactive session picker
           hermes sessions rename ID T   Rename/title a session
           hermes update                 Update to latest version
       
      @@ -1753,6 +2028,13 @@ def cmd_tools(args):
           sessions_rename.add_argument("session_id", help="Session ID to rename")
           sessions_rename.add_argument("title", nargs="+", help="New title for the session")
       
      +    sessions_browse = sessions_subparsers.add_parser(
      +        "browse",
      +        help="Interactive session picker — browse, search, and resume sessions",
      +    )
      +    sessions_browse.add_argument("--source", help="Filter by source (cli, telegram, discord, etc.)")
      +    sessions_browse.add_argument("--limit", type=int, default=50, help="Max sessions to load (default: 50)")
      +
           def cmd_sessions(args):
               import json as _json
               try:
      @@ -1859,6 +2141,34 @@ def _relative_time(ts):
                   except ValueError as e:
                       print(f"Error: {e}")
       
      +        elif action == "browse":
      +            limit = getattr(args, "limit", 50) or 50
      +            source = getattr(args, "source", None)
      +            sessions = db.list_sessions_rich(source=source, limit=limit)
      +            db.close()
      +            if not sessions:
      +                print("No sessions found.")
      +                return
      +
      +            selected_id = _session_browse_picker(sessions)
      +            if not selected_id:
      +                print("Cancelled.")
      +                return
      +
      +            # Launch hermes --resume <id> by replacing the current process
      +            print(f"Resuming session: {selected_id}")
      +            import shutil
      +            hermes_bin = shutil.which("hermes")
      +            if hermes_bin:
      +                os.execvp(hermes_bin, ["hermes", "--resume", selected_id])
      +            else:
      +                # Fallback: re-invoke via python -m
      +                os.execvp(
      +                    sys.executable,
      +                    [sys.executable, "-m", "hermes_cli.main", "--resume", selected_id],
      +                )
      +            return  # won't reach here after execvp
      +
               elif action == "stats":
                   total = db.session_count()
                   msgs = db.message_count()
      @@ -1868,7 +2178,6 @@ def _relative_time(ts):
                       c = db.session_count(source=src)
                       if c > 0:
                           print(f"  {src}: {c} sessions")
      -            import os
                   db_path = db.db_path
                   if db_path.exists():
                       size_mb = os.path.getsize(db_path) / (1024 * 1024)
    • tests/hermes_cli/test_session_browse.py
      @@ -0,0 +1,542 @@
      +"""Tests for the interactive session browser (`hermes sessions browse`).
      +
      +Covers:
      +- _session_browse_picker logic (curses mocked, fallback tested)
      +- cmd_sessions 'browse' action integration
      +- Argument parser registration
      +"""
      +
      +import os
      +import time
      +from unittest.mock import MagicMock, patch, call
      +
      +import pytest
      +
      +from hermes_cli.main import _session_browse_picker
      +
      +
      +# ─── Sample session data ──────────────────────────────────────────────────────
      +
      +def _make_sessions(n=5):
      +    """Generate a list of fake rich-session dicts."""
      +    now = time.time()
      +    sessions = []
      +    for i in range(n):
      +        sessions.append({
      +            "id": f"20260308_{i:06d}_abcdef",
      +            "source": "cli" if i % 2 == 0 else "telegram",
      +            "model": "test/model",
      +            "title": f"Session {i}" if i % 3 != 0 else None,
      +            "preview": f"Hello from session {i}",
      +            "last_active": now - i * 3600,
      +            "started_at": now - i * 3600 - 60,
      +            "message_count": (i + 1) * 5,
      +        })
      +    return sessions
      +
      +
      +SAMPLE_SESSIONS = _make_sessions(5)
      +
      +
      +# ─── _session_browse_picker ──────────────────────────────────────────────────
      +
      +class TestSessionBrowsePicker:
      +    """Tests for the _session_browse_picker function."""
      +
      +    def test_empty_sessions_returns_none(self, capsys):
      +        result = _session_browse_picker([])
      +        assert result is None
      +        assert "No sessions found" in capsys.readouterr().out
      +
      +    def test_returns_none_when_no_sessions(self, capsys):
      +        result = _session_browse_picker([])
      +        assert result is None
      +
      +    def test_fallback_mode_valid_selection(self):
      +        """When curses is unavailable, fallback numbered list should work."""
      +        sessions = _make_sessions(3)
      +
      +        # Mock curses import to fail, forcing fallback
      +        import builtins
      +        original_import = builtins.__import__
      +
      +        def mock_import(name, *args, **kwargs):
      +            if name == "curses":
      +                raise ImportError("no curses")
      +            return original_import(name, *args, **kwargs)
      +
      +        with patch.object(builtins, "__import__", side_effect=mock_import):
      +            with patch("builtins.input", return_value="2"):
      +                result = _session_browse_picker(sessions)
      +
      +        assert result == sessions[1]["id"]
      +
      +    def test_fallback_mode_cancel_q(self):
      +        """Entering 'q' in fallback mode cancels."""
      +        sessions = _make_sessions(3)
      +
      +        import builtins
      +        original_import = builtins.__import__
      +
      +        def mock_import(name, *args, **kwargs):
      +            if name == "curses":
      +                raise ImportError("no curses")
      +            return original_import(name, *args, **kwargs)
      +
      +        with patch.object(builtins, "__import__", side_effect=mock_import):
      +            with patch("builtins.input", return_value="q"):
      +                result = _session_browse_picker(sessions)
      +
      +        assert result is None
      +
      +    def test_fallback_mode_cancel_empty(self):
      +        """Entering empty string in fallback mode cancels."""
      +        sessions = _make_sessions(3)
      +
      +        import builtins
      +        original_import = builtins.__import__
      +
      +        def mock_import(name, *args, **kwargs):
      +            if name == "curses":
      +                raise ImportError("no curses")
      +            return original_import(name, *args, **kwargs)
      +
      +        with patch.object(builtins, "__import__", side_effect=mock_import):
      +            with patch("builtins.input", return_value=""):
      +                result = _session_browse_picker(sessions)
      +
      +        assert result is None
      +
      +    def test_fallback_mode_invalid_then_valid(self):
      +        """Invalid selection followed by valid one works."""
      +        sessions = _make_sessions(3)
      +
      +        import builtins
      +        original_import = builtins.__import__
      +
      +        def mock_import(name, *args, **kwargs):
      +            if name == "curses":
      +                raise ImportError("no curses")
      +            return original_import(name, *args, **kwargs)
      +
      +        with patch.object(builtins, "__import__", side_effect=mock_import):
      +            with patch("builtins.input", side_effect=["99", "1"]):
      +                result = _session_browse_picker(sessions)
      +
      +        assert result == sessions[0]["id"]
      +
      +    def test_fallback_mode_keyboard_interrupt(self):
      +        """KeyboardInterrupt in fallback mode returns None."""
      +        sessions = _make_sessions(3)
      +
      +        import builtins
      +        original_import = builtins.__import__
      +
      +        def mock_import(name, *args, **kwargs):
      +            if name == "curses":
      +                raise ImportError("no curses")
      +            return original_import(name, *args, **kwargs)
      +
      +        with patch.object(builtins, "__import__", side_effect=mock_import):
      +            with patch("builtins.input", side_effect=KeyboardInterrupt):
      +                result = _session_browse_picker(sessions)
      +
      +        assert result is None
      +
      +    def test_fallback_displays_all_sessions(self, capsys):
      +        """Fallback mode should display all session entries."""
      +        sessions = _make_sessions(4)
      +
      +        import builtins
      +        original_import = builtins.__import__
      +
      +        def mock_import(name, *args, **kwargs):
      +            if name == "curses":
      +                raise ImportError("no curses")
      +            return original_import(name, *args, **kwargs)
      +
      +        with patch.object(builtins, "__import__", side_effect=mock_import):
      +            with patch("builtins.input", return_value="q"):
      +                _session_browse_picker(sessions)
      +
      +        output = capsys.readouterr().out
      +        # All 4 entries should be shown
      +        assert "1." in output
      +        assert "2." in output
      +        assert "3." in output
      +        assert "4." in output
      +
      +    def test_fallback_shows_title_over_preview(self, capsys):
      +        """When a session has a title, show it instead of the preview."""
      +        sessions = [{
      +            "id": "test_001",
      +            "source": "cli",
      +            "title": "My Cool Project",
      +            "preview": "some preview text",
      +            "last_active": time.time(),
      +        }]
      +
      +        import builtins
      +        original_import = builtins.__import__
      +
      +        def mock_import(name, *args, **kwargs):
      +            if name == "curses":
      +                raise ImportError("no curses")
      +            return original_import(name, *args, **kwargs)
      +
      +        with patch.object(builtins, "__import__", side_effect=mock_import):
      +            with patch("builtins.input", return_value="q"):
      +                _session_browse_picker(sessions)
      +
      +        output = capsys.readouterr().out
      +        assert "My Cool Project" in output
      +
      +    def test_fallback_shows_preview_when_no_title(self, capsys):
      +        """When no title, show preview."""
      +        sessions = [{
      +            "id": "test_002",
      +            "source": "cli",
      +            "title": None,
      +            "preview": "Hello world test message",
      +            "last_active": time.time(),
      +        }]
      +
      +        import builtins
      +        original_import = builtins.__import__
      +
      +        def mock_import(name, *args, **kwargs):
      +            if name == "curses":
      +                raise ImportError("no curses")
      +            return original_import(name, *args, **kwargs)
      +
      +        with patch.object(builtins, "__import__", side_effect=mock_import):
      +            with patch("builtins.input", return_value="q"):
      +                _session_browse_picker(sessions)
      +
      +        output = capsys.readouterr().out
      +        assert "Hello world test message" in output
      +
      +    def test_fallback_shows_id_when_no_title_or_preview(self, capsys):
      +        """When neither title nor preview, show session ID."""
      +        sessions = [{
      +            "id": "test_003_fallback",
      +            "source": "cli",
      +            "title": None,
      +            "preview": "",
      +            "last_active": time.time(),
      +        }]
      +
      +        import builtins
      +        original_import = builtins.__import__
      +
      +        def mock_import(name, *args, **kwargs):
      +            if name == "curses":
      +                raise ImportError("no curses")
      +            return original_import(name, *args, **kwargs)
      +
      +        with patch.object(builtins, "__import__", side_effect=mock_import):
      +            with patch("builtins.input", return_value="q"):
      +                _session_browse_picker(sessions)
      +
      +        output = capsys.readouterr().out
      +        assert "test_003_fallback" in output
      +
      +
      +# ─── Curses-based picker (mocked curses) ────────────────────────────────────
      +
      +class TestCursesBrowse:
      +    """Tests for the curses-based interactive picker via simulated key sequences."""
      +
      +    def _run_with_keys(self, sessions, key_sequence):
      +        """Simulate running the curses picker with a given key sequence."""
      +        import curses
      +
      +        # Build a mock stdscr that returns keys from the sequence
      +        mock_stdscr = MagicMock()
      +        mock_stdscr.getmaxyx.return_value = (30, 120)
      +        mock_stdscr.getch.side_effect = key_sequence
      +
      +        # Capture what curses.wrapper receives and call it with our mock
      +        with patch("curses.wrapper") as mock_wrapper:
      +            # When wrapper is called, invoke the function with our mock stdscr
      +            def run_inner(func):
      +                try:
      +                    func(mock_stdscr)
      +                except StopIteration:
      +                    pass  # key sequence exhausted
      +
      +            mock_wrapper.side_effect = run_inner
      +            with patch("curses.curs_set"):
      +                with patch("curses.has_colors", return_value=False):
      +                    return _session_browse_picker(sessions)
      +
      +    def test_enter_selects_first_session(self):
      +        sessions = _make_sessions(3)
      +        result = self._run_with_keys(sessions, [10])  # Enter key
      +        assert result == sessions[0]["id"]
      +
      +    def test_down_then_enter_selects_second(self):
      +        import curses
      +        sessions = _make_sessions(3)
      +        result = self._run_with_keys(sessions, [curses.KEY_DOWN, 10])
      +        assert result == sessions[1]["id"]
      +
      +    def test_down_down_enter_selects_third(self):
      +        import curses
      +        sessions = _make_sessions(5)
      +        result = self._run_with_keys(sessions, [curses.KEY_DOWN, curses.KEY_DOWN, 10])
      +        assert result == sessions[2]["id"]
      +
      +    def test_up_wraps_to_last(self):
      +        import curses
      +        sessions = _make_sessions(3)
      +        result = self._run_with_keys(sessions, [curses.KEY_UP, 10])
      +        assert result == sessions[2]["id"]
      +
      +    def test_escape_cancels(self):
      +        sessions = _make_sessions(3)
      +        result = self._run_with_keys(sessions, [27])  # Esc
      +        assert result is None
      +
      +    def test_q_cancels(self):
      +        sessions = _make_sessions(3)
      +        result = self._run_with_keys(sessions, [ord('q')])
      +        assert result is None
      +
      +    def test_type_to_filter_then_enter(self):
      +        """Typing characters filters the list, Enter selects from filtered."""
      +        import curses
      +        sessions = [
      +            {"id": "s1", "source": "cli", "title": "Alpha project", "preview": "", "last_active": time.time()},
      +            {"id": "s2", "source": "cli", "title": "Beta project", "preview": "", "last_active": time.time()},
      +            {"id": "s3", "source": "cli", "title": "Gamma project", "preview": "", "last_active": time.time()},
      +        ]
      +        # Type "Beta" then Enter — should select s2
      +        keys = [ord(c) for c in "Beta"] + [10]
      +        result = self._run_with_keys(sessions, keys)
      +        assert result == "s2"
      +
      +    def test_filter_no_match_enter_does_nothing(self):
      +        """When filter produces no results, Enter shouldn't select."""
      +        sessions = _make_sessions(3)
      +        keys = [ord(c) for c in "zzzznonexistent"] + [10]
      +        result = self._run_with_keys(sessions, keys)
      +        assert result is None
      +
      +    def test_backspace_removes_filter_char(self):
      +        """Backspace removes the last character from the filter."""
      +        import curses
      +        sessions = [
      +            {"id": "s1", "source": "cli", "title": "Alpha", "preview": "", "last_active": time.time()},
      +            {"id": "s2", "source": "cli", "title": "Beta", "preview": "", "last_active": time.time()},
      +        ]
      +        # Type "Bet", backspace, backspace, backspace (clears filter), then Enter (selects first)
      +        keys = [ord('B'), ord('e'), ord('t'), 127, 127, 127, 10]
      +        result = self._run_with_keys(sessions, keys)
      +        assert result == "s1"
      +
      +    def test_escape_clears_filter_first(self):
      +        """First Esc clears the search text, second Esc exits."""
      +        import curses
      +        sessions = _make_sessions(3)
      +        # Type "ab" then Esc (clears filter) then Enter (selects first)
      +        keys = [ord('a'), ord('b'), 27, 10]
      +        result = self._run_with_keys(sessions, keys)
      +        assert result == sessions[0]["id"]
      +
      +    def test_filter_matches_preview(self):
      +        """Typing should match against session preview text."""
      +        sessions = [
      +            {"id": "s1", "source": "cli", "title": None, "preview": "Set up Minecraft server", "last_active": time.time()},
      +            {"id": "s2", "source": "cli", "title": None, "preview": "Review PR 438", "last_active": time.time()},
      +        ]
      +        keys = [ord(c) for c in "Mine"] + [10]
      +        result = self._run_with_keys(sessions, keys)
      +        assert result == "s1"
      +
      +    def test_filter_matches_source(self):
      +        """Typing a source name should filter by source."""
      +        sessions = [
      +            {"id": "s1", "source": "telegram", "title": "TG session", "preview": "", "last_active": time.time()},
      +            {"id": "s2", "source": "cli", "title": "CLI session", "preview": "", "last_active": time.time()},
      +        ]
      +        keys = [ord(c) for c in "telegram"] + [10]
      +        result = self._run_with_keys(sessions, keys)
      +        assert result == "s1"
      +
      +    def test_q_quits_when_no_filter_active(self):
      +        """When no search text is active, 'q' should quit (not filter)."""
      +        sessions = _make_sessions(3)
      +        result = self._run_with_keys(sessions, [ord('q')])
      +        assert result is None
      +
      +    def test_q_types_into_filter_when_filter_active(self):
      +        """When search text is already active, 'q' should add to filter, not quit."""
      +        sessions = [
      +            {"id": "s1", "source": "cli", "title": "the sequel", "preview": "", "last_active": time.time()},
      +            {"id": "s2", "source": "cli", "title": "other thing", "preview": "", "last_active": time.time()},
      +        ]
      +        # Type "se" first (activates filter, matches "the sequel")
      +        # Then type "q" — should add 'q' to filter (filter="seq"), NOT quit
      +        # "seq" still matches "the sequel" → Enter selects it
      +        keys = [ord('s'), ord('e'), ord('q'), 10]
      +        result = self._run_with_keys(sessions, keys)
      +        assert result == "s1"  # "the sequel" matches "seq"
      +
      +
      +# ─── Argument parser registration ──────────────────────────────────────────
      +
      +class TestSessionBrowseArgparse:
      +    """Verify the 'browse' subcommand is properly registered."""
      +
      +    def test_browse_subcommand_exists(self):
      +        """hermes sessions browse should be parseable."""
      +        from hermes_cli.main import main as _main_entry
      +
      +        # We can't run main(), but we can import and test the parser setup
      +        # by checking that argparse doesn't error on "sessions browse"
      +        import argparse
      +        # Re-create the parser portion
      +        # Instead, let's just verify the import works and the function exists
      +        from hermes_cli.main import _session_browse_picker
      +        assert callable(_session_browse_picker)
      +
      +    def test_browse_default_limit_is_50(self):
      +        """The default --limit for browse should be 50."""
      +        # This test verifies at the argparse level
      +        # We test by running the parse on "sessions browse" args
      +        # Since we can't easily extract the subparser, verify via the
      +        # _session_browse_picker accepting large lists
      +        sessions = _make_sessions(50)
      +        assert len(sessions) == 50
      +
      +
      +# ─── Integration: cmd_sessions browse action ────────────────────────────────
      +
      +class TestCmdSessionsBrowse:
      +    """Integration tests for the 'browse' action in cmd_sessions."""
      +
      +    def test_browse_no_sessions_prints_message(self, capsys):
      +        """When no sessions exist, _session_browse_picker returns None and prints message."""
      +        result = _session_browse_picker([])
      +        assert result is None
      +        output = capsys.readouterr().out
      +        assert "No sessions found" in output
      +
      +    def test_browse_with_source_filter(self):
      +        """The --source flag should be passed to list_sessions_rich."""
      +        sessions = [
      +            {"id": "s1", "source": "cli", "title": "CLI only", "preview": "", "last_active": time.time()},
      +        ]
      +
      +        import builtins
      +        original_import = builtins.__import__
      +
      +        def mock_import(name, *args, **kwargs):
      +            if name == "curses":
      +                raise ImportError("no curses")
      +            return original_import(name, *args, **kwargs)
      +
      +        with patch.object(builtins, "__import__", side_effect=mock_import):
      +            with patch("builtins.input", return_value="1"):
      +                result = _session_browse_picker(sessions)
      +
      +        assert result == "s1"
      +
      +
      +# ─── Edge cases ──────────────────────────────────────────────────────────────
      +
      +class TestEdgeCases:
      +    """Edge case handling for the session browser."""
      +
      +    def test_sessions_with_missing_fields(self):
      +        """Sessions with missing optional fields should not crash."""
      +        sessions = [
      +            {"id": "minimal_001", "source": "cli"},  # No title, preview, last_active
      +        ]
      +
      +        import builtins
      +        original_import = builtins.__import__
      +
      +        def mock_import(name, *args, **kwargs):
      +            if name == "curses":
      +                raise ImportError("no curses")
      +            return original_import(name, *args, **kwargs)
      +
      +        with patch.object(builtins, "__import__", side_effect=mock_import):
      +            with patch("builtins.input", return_value="1"):
      +                result = _session_browse_picker(sessions)
      +
      +        assert result == "minimal_001"
      +
      +    def test_single_session(self):
      +        """A single session in the list should work fine."""
      +        sessions = [
      +            {"id": "only_one", "source": "cli", "title": "Solo", "preview": "", "last_active": time.time()},
      +        ]
      +
      +        import builtins
      +        original_import = builtins.__import__
      +
      +        def mock_import(name, *args, **kwargs):
      +            if name == "curses":
      +                raise ImportError("no curses")
      +            return original_import(name, *args, **kwargs)
      +
      +        with patch.object(builtins, "__import__", side_effect=mock_import):
      +            with patch("builtins.input", return_value="1"):
      +                result = _session_browse_picker(sessions)
      +
      +        assert result == "only_one"
      +
      +    def test_long_title_truncated_in_fallback(self, capsys):
      +        """Very long titles should be truncated in fallback mode."""
      +        sessions = [{
      +            "id": "long_title_001",
      +            "source": "cli",
      +            "title": "A" * 100,
      +            "preview": "",
      +            "last_active": time.time(),
      +        }]
      +
      +        import builtins
      +        original_import = builtins.__import__
      +
      +        def mock_import(name, *args, **kwargs):
      +            if name == "curses":
      +                raise ImportError("no curses")
      +            return original_import(name, *args, **kwargs)
      +
      +        with patch.object(builtins, "__import__", side_effect=mock_import):
      +            with patch("builtins.input", return_value="q"):
      +                _session_browse_picker(sessions)
      +
      +        output = capsys.readouterr().out
      +        # Title should be truncated to 50 chars with "..."
      +        assert "..." in output
      +
      +    def test_relative_time_formatting(self, capsys):
      +        """Verify various time deltas format correctly."""
      +        now = time.time()
      +        sessions = [
      +            {"id": "recent", "source": "cli", "title": None, "preview": "just now test", "last_active": now},
      +            {"id": "hour_ago", "source": "cli", "title": None, "preview": "hour ago test", "last_active": now - 7200},
      +            {"id": "days_ago", "source": "cli", "title": None, "preview": "days ago test", "last_active": now - 259200},
      +        ]
      +
      +        import builtins
      +        original_import = builtins.__import__
      +
      +        def mock_import(name, *args, **kwargs):
      +            if name == "curses":
      +                raise ImportError("no curses")
      +            return original_import(name, *args, **kwargs)
      +
      +        with patch.object(builtins, "__import__", side_effect=mock_import):
      +            with patch("builtins.input", return_value="q"):
      +                _session_browse_picker(sessions)
      +
      +        output = capsys.readouterr().out
      +        assert "just now" in output
      +        assert "2h ago" in output
      +        assert "3d ago" in output
  • #31709 feat(cli,gateway): /resume accepts position numbers

    merged to main

    files: cli.py, gateway/run.py, locales/af.yaml, locales/de.yaml, locales/en.yaml, locales/es.yaml, locales/fr.yaml, locales/ga.yaml, locales/hu.yaml, locales/it.yaml, locales/ja.yaml, locales/ko.yaml, locales/pt.yaml, locales/ru.yaml, locales/tr.yaml, locales/uk.yaml, locales/zh-hant.yaml, locales/zh.yaml, scripts/release.py, tests/cli/test_cli_init.py (+2 more)

    Show the diff
    • cli.py
      @@ -6172,15 +6172,16 @@ def _show_recent_sessions(self, *, reason: str = "history", limit: int = 10) ->
               else:
                   print("  Recent sessions:")
               print()
      -        print(f"  {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
      -        print(f"  {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}")
      -        for session in sessions:
      +        print(f"  {'#':<3} {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
      +        print(f"  {'─' * 3} {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}")
      +        for idx, session in enumerate(sessions, start=1):
                   title = session.get("title") or "—"
                   preview = (session.get("preview") or "")[:38]
                   last_active = _relative_time(session.get("last_active"))
      -            print(f"  {title:<32} {preview:<40} {last_active:<13} {session['id']}")
      +            print(f"  {idx:<3} {title:<32} {preview:<40} {last_active:<13} {session['id']}")
               print()
      -        print("  Use /resume <session id or title> to continue where you left off.")
      +        print("  Use /resume <number>, /resume <session id>, or /resume <session title> to continue.")
      +        print("  Example: /resume 2")
               print()
               return True
       
      @@ -6525,7 +6526,7 @@ def _handle_resume_command(self, cmd_original: str) -> None:
               target = parts[1].strip() if len(parts) > 1 else ""
       
               if not target:
      -            _cprint("  Usage: /resume <session_id_or_title>")
      +            _cprint("  Usage: /resume <number|session_id_or_title>")
                   if self._show_recent_sessions(reason="resume"):
                       return
                   _cprint("  Tip:   Use /history or `hermes sessions list` to find sessions.")
      @@ -6536,10 +6537,20 @@ def _handle_resume_command(self, cmd_original: str) -> None:
                   _cprint(f"  {format_session_db_unavailable()}")
                   return
       
      -        # Resolve title or ID
      -        from hermes_cli.main import _resolve_session_by_name_or_id
      -        resolved = _resolve_session_by_name_or_id(target)
      -        target_id = resolved or target
      +        # Resolve numbered selection, title, or ID
      +        if target.isdigit():
      +            sessions = self._list_recent_sessions(limit=10)
      +            index = int(target)
      +            if index < 1 or index > len(sessions):
      +                _cprint(f"  Resume index {index} is out of range.")
      +                _cprint("  Use /resume with no arguments to see available sessions.")
      +                return
      +            selected = sessions[index - 1]
      +            target_id = selected["id"]
      +        else:
      +            from hermes_cli.main import _resolve_session_by_name_or_id
      +            resolved = _resolve_session_by_name_or_id(target)
      +            target_id = resolved or target
       
               session_meta = self._session_db.get_session(target_id)
               if not session_meta:
    • gateway/run.py
      @@ -12741,7 +12741,7 @@ async def _handle_title_command(self, event: MessageEvent) -> str:
                       return t("gateway.title.current_no_title", session_id=session_id)
       
           async def _handle_resume_command(self, event: MessageEvent) -> str:
      -        """Handle /resume command — switch to a previously-named session."""
      +        """Handle /resume command — list or switch to a previous session."""
               if not self._session_db:
                   from hermes_state import format_session_db_unavailable
                   return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
      @@ -12750,30 +12750,44 @@ async def _handle_resume_command(self, event: MessageEvent) -> str:
               session_key = self._session_key_for_source(source)
               name = event.get_command_args().strip()
       
      +        def _list_titled_sessions() -> list[dict]:
      +            user_source = source.platform.value if source.platform else None
      +            sessions = self._session_db.list_sessions_rich(source=user_source, limit=10)
      +            return [s for s in sessions if s.get("title")][:10]
      +
               if not name:
                   # List recent titled sessions for this user/platform
                   try:
      -                user_source = source.platform.value if source.platform else None
      -                sessions = self._session_db.list_sessions_rich(
      -                    source=user_source, limit=10
      -                )
      -                titled = [s for s in sessions if s.get("title")]
      +                titled = _list_titled_sessions()
                       if not titled:
                           return t("gateway.resume.no_named_sessions")
                       lines = [t("gateway.resume.list_header")]
      -                for s in titled[:10]:
      +                for idx, s in enumerate(titled[:10], start=1):
                           title = s["title"]
                           preview = s.get("preview", "")[:40]
                           preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else ""
      -                    lines.append(t("gateway.resume.list_item", title=title, preview_part=preview_part))
      -                lines.append(t("gateway.resume.list_footer"))
      +                    lines.append(t("gateway.resume.list_item_numbered", index=idx, title=title, preview_part=preview_part))
      +                lines.append(t("gateway.resume.list_footer_numbered"))
                       return "\n".join(lines)
                   except Exception as e:
                       logger.debug("Failed to list titled sessions: %s", e)
                       return t("gateway.resume.list_failed", error=e)
       
      -        # Resolve the name to a session ID.
      -        target_id = self._session_db.resolve_session_by_title(name)
      +        # Resolve a numbered choice or a title to a session ID.
      +        if name.isdigit():
      +            try:
      +                titled = _list_titled_sessions()
      +            except Exception as e:
      +                logger.debug("Failed to list titled sessions for numeric resume: %s", e)
      +                return t("gateway.resume.list_failed", error=e)
      +            index = int(name)
      +            if index < 1 or index > len(titled):
      +                return t("gateway.resume.out_of_range", index=index)
      +            target = titled[index - 1]
      +            target_id = target.get("id")
      +            name = target.get("title") or name
      +        else:
      +            target_id = self._session_db.resolve_session_by_title(name)
               if not target_id:
                   return t("gateway.resume.not_found", name=name)
               # Compression creates child continuations that hold the live transcript.
    • locales/af.yaml
      @@ -222,9 +222,12 @@ gateway:
           no_named_sessions:     "Geen benoemde sessies gevind nie.\nGebruik `/title My Sessie` om jou huidige sessie 'n naam te gee, en dan `/resume My Sessie` om later daarheen terug te keer."
           list_header:           "📋 **Benoemde Sessies**\n"
           list_item:             "• **{title}**{preview_part}"
      +    list_item_numbered:    "{index}. **{title}**{preview_part}"
           list_preview_suffix:   " — _{preview}_"
           list_footer:           "\nGebruik: `/resume <session name>`"
      +    list_footer_numbered:  "\nGebruik: `/resume <sessienaam>` of `/resume <nommer>` (bv. `/resume 1` vir die mees onlangse)"
           list_failed:           "Kon nie sessies lys nie: {error}"
      +    out_of_range:          "Hervat-indeks {index} is buite bereik.\nGebruik `/resume` sonder argumente om beskikbare sessies te sien."
           not_found:             "Geen sessie gevind wat by '**{name}**' pas nie.\nGebruik `/resume` sonder argumente om beskikbare sessies te sien."
           already_on:            "📌 Reeds op sessie **{name}**."
           switch_failed:         "Kon nie sessie verander nie."
    • locales/de.yaml
      @@ -222,9 +222,12 @@ gateway:
           no_named_sessions:     "Keine benannten Sitzungen gefunden.\nVerwenden Sie `/title Meine Sitzung`, um die aktuelle Sitzung zu benennen, dann `/resume Meine Sitzung`, um später dorthin zurückzukehren."
           list_header:           "📋 **Benannte Sitzungen**\n"
           list_item:             "• **{title}**{preview_part}"
      +    list_item_numbered:    "{index}. **{title}**{preview_part}"
           list_preview_suffix:   " — _{preview}_"
           list_footer:           "\nVerwendung: `/resume <Sitzungsname>`"
      +    list_footer_numbered:  "\nVerwendung: `/resume <Sitzungsname>` oder `/resume <Nummer>` (z. B. `/resume 1` für die zuletzt verwendete)"
           list_failed:           "Sitzungen konnten nicht aufgelistet werden: {error}"
      +    out_of_range:          "Wiederaufnahme-Index {index} liegt außerhalb des gültigen Bereichs.\nVerwenden Sie `/resume` ohne Argumente, um verfügbare Sitzungen anzuzeigen."
           not_found:             "Keine Sitzung passend zu '**{name}**' gefunden.\nVerwenden Sie `/resume` ohne Argumente, um verfügbare Sitzungen zu sehen."
           already_on:            "📌 Bereits in Sitzung **{name}**."
           switch_failed:         "Sitzungswechsel fehlgeschlagen."
    • locales/en.yaml
      @@ -237,9 +237,12 @@ gateway:
           no_named_sessions:     "No named sessions found.\nUse `/title My Session` to name your current session, then `/resume My Session` to return to it later."
           list_header:           "📋 **Named Sessions**\n"
           list_item:             "• **{title}**{preview_part}"
      +    list_item_numbered:    "{index}. **{title}**{preview_part}"
           list_preview_suffix:   " — _{preview}_"
           list_footer:           "\nUsage: `/resume <session name>`"
      +    list_footer_numbered:  "\nUsage: `/resume <session name>` or `/resume <number>` (e.g. `/resume 1` for the most recent)"
           list_failed:           "Could not list sessions: {error}"
      +    out_of_range:          "Resume index {index} is out of range.\nUse `/resume` with no arguments to see available sessions."
           not_found:             "No session found matching '**{name}**'.\nUse `/resume` with no arguments to see available sessions."
           already_on:            "📌 Already on session **{name}**."
           switch_failed:         "Failed to switch session."
    • locales/es.yaml
      @@ -222,9 +222,12 @@ gateway:
           no_named_sessions:     "No se encontraron sesiones con nombre.\nUsa `/title Mi sesión` para nombrar la sesión actual y luego `/resume Mi sesión` para volver a ella."
           list_header:           "📋 **Sesiones con nombre**\n"
           list_item:             "• **{title}**{preview_part}"
      +    list_item_numbered:    "{index}. **{title}**{preview_part}"
           list_preview_suffix:   " — _{preview}_"
           list_footer:           "\nUso: `/resume <nombre de sesión>`"
      +    list_footer_numbered:  "\nUso: `/resume <nombre de sesión>` o `/resume <número>` (p. ej. `/resume 1` para la más reciente)"
           list_failed:           "No se pudieron listar las sesiones: {error}"
      +    out_of_range:          "El índice de reanudación {index} está fuera de rango.\nUsa `/resume` sin argumentos para ver las sesiones disponibles."
           not_found:             "No se encontró ninguna sesión que coincida con '**{name}**'.\nUsa `/resume` sin argumentos para ver las sesiones disponibles."
           already_on:            "📌 Ya estás en la sesión **{name}**."
           switch_failed:         "No se pudo cambiar de sesión."
    • locales/fr.yaml
      @@ -222,9 +222,12 @@ gateway:
           no_named_sessions:     "Aucune session nommée trouvée.\nUtilisez `/title Ma session` pour nommer la session actuelle, puis `/resume Ma session` pour y revenir plus tard."
           list_header:           "📋 **Sessions nommées**\n"
           list_item:             "• **{title}**{preview_part}"
      +    list_item_numbered:    "{index}. **{title}**{preview_part}"
           list_preview_suffix:   " — _{preview}_"
           list_footer:           "\nUsage : `/resume <nom de session>`"
      +    list_footer_numbered:  "\nUtilisation : `/resume <nom de session>` ou `/resume <numéro>` (par exemple `/resume 1` pour la plus récente)"
           list_failed:           "Impossible de lister les sessions : {error}"
      +    out_of_range:          "L'index de reprise {index} est hors limites.\nUtilisez `/resume` sans arguments pour voir les sessions disponibles."
           not_found:             "Aucune session correspondant à '**{name}**' trouvée.\nUtilisez `/resume` sans argument pour voir les sessions disponibles."
           already_on:            "📌 Déjà sur la session **{name}**."
           switch_failed:         "Échec du changement de session."
    • locales/ga.yaml
      @@ -226,9 +226,12 @@ gateway:
           no_named_sessions:     "Níor aimsíodh aon seisiún ainmnithe.\nÚsáid `/title M'Ainm Seisiúin` chun do sheisiún reatha a ainmniú, ansin `/resume M'Ainm Seisiúin` chun filleadh air níos déanaí."
           list_header:           "📋 **Seisiúin Ainmnithe**\n"
           list_item:             "• **{title}**{preview_part}"
      +    list_item_numbered:    "{index}. **{title}**{preview_part}"
           list_preview_suffix:   " — _{preview}_"
           list_footer:           "\nÚsáid: `/resume <session name>`"
      +    list_footer_numbered:  "\nÚsáid: `/resume <ainm seisiúin>` nó `/resume <uimhir>` (m.sh. `/resume 1` don cheann is déanaí)"
           list_failed:           "Níorbh fhéidir seisiúin a liostáil: {error}"
      +    out_of_range:          "Tá an t-innéacs atosaithe {index} as raon.\nÚsáid `/resume` gan argóintí chun na seisiúin atá ar fáil a fheiceáil."
           not_found:             "Níor aimsíodh aon seisiún ag teacht le '**{name}**'.\nÚsáid `/resume` gan argóintí chun seisiúin atá ar fáil a fheiceáil."
           already_on:            "📌 Cheana ar an seisiún **{name}**."
           switch_failed:         "Theip ar athrú seisiúin."
    • locales/hu.yaml
      @@ -222,9 +222,12 @@ gateway:
           no_named_sessions:     "Nem található elnevezett munkamenet.\nHasználd a `/title Saját munkamenet` parancsot a jelenlegi munkamenet elnevezéséhez, majd a `/resume Saját munkamenet` paranccsal térhetsz vissza hozzá."
           list_header:           "📋 **Elnevezett munkamenetek**\n"
           list_item:             "• **{title}**{preview_part}"
      +    list_item_numbered:    "{index}. **{title}**{preview_part}"
           list_preview_suffix:   " — _{preview}_"
           list_footer:           "\nHasználat: `/resume <munkamenet neve>`"
      +    list_footer_numbered:  "\nHasználat: `/resume <munkamenet neve>` vagy `/resume <szám>` (pl. `/resume 1` a legutóbbihoz)"
           list_failed:           "Nem sikerült listázni a munkameneteket: {error}"
      +    out_of_range:          "A folytatási index ({index}) tartományon kívül esik.\nA `/resume` argumentumok nélküli használata megjeleníti az elérhető munkameneteket."
           not_found:             "Nem található '**{name}**' nevű munkamenet.\nArgumentumok nélkül használd a `/resume` parancsot az elérhető munkamenetek megtekintéséhez."
           already_on:            "📌 Már a **{name}** munkamenetben vagy."
           switch_failed:         "Nem sikerült munkamenetet váltani."
    • locales/it.yaml
      @@ -222,9 +222,12 @@ gateway:
           no_named_sessions:     "Nessuna sessione con nome trovata.\nUsa `/title My Session` per dare un nome alla sessione attuale, poi `/resume My Session` per tornare a essa in seguito."
           list_header:           "📋 **Sessioni con nome**\n"
           list_item:             "• **{title}**{preview_part}"
      +    list_item_numbered:    "{index}. **{title}**{preview_part}"
           list_preview_suffix:   " — _{preview}_"
           list_footer:           "\nUso: `/resume <session name>`"
      +    list_footer_numbered:  "\nUso: `/resume <nome sessione>` o `/resume <numero>` (es. `/resume 1` per la più recente)"
           list_failed:           "Impossibile elencare le sessioni: {error}"
      +    out_of_range:          "L'indice di ripresa {index} è fuori intervallo.\nUsa `/resume` senza argomenti per vedere le sessioni disponibili."
           not_found:             "Nessuna sessione trovata corrispondente a '**{name}**'.\nUsa `/resume` senza argomenti per vedere le sessioni disponibili."
           already_on:            "📌 Già nella sessione **{name}**."
           switch_failed:         "Cambio di sessione non riuscito."
    • locales/ja.yaml
      @@ -222,9 +222,12 @@ gateway:
           no_named_sessions:     "名前付きセッションが見つかりません。\n`/title セッション名` で現在のセッションに名前を付けると、後で `/resume セッション名` で戻れます。"
           list_header:           "📋 **名前付きセッション**\n"
           list_item:             "• **{title}**{preview_part}"
      +    list_item_numbered:    "{index}. **{title}**{preview_part}"
           list_preview_suffix:   " — _{preview}_"
           list_footer:           "\n使い方: `/resume <セッション名>`"
      +    list_footer_numbered:  "\n使い方: `/resume <セッション名>` または `/resume <番号>`(例: 最新のセッションには `/resume 1`)"
           list_failed:           "セッションを一覧表示できませんでした: {error}"
      +    out_of_range:          "再開インデックス {index} は範囲外です。\n引数なしで `/resume` を実行すると、利用可能なセッションが表示されます。"
           not_found:             "'**{name}**' に一致するセッションが見つかりません。\n引数なしで `/resume` を実行すると利用可能なセッションを表示します。"
           already_on:            "📌 既にセッション **{name}** にいます。"
           switch_failed:         "セッションの切り替えに失敗しました。"
    • locales/ko.yaml
      @@ -222,9 +222,12 @@ gateway:
           no_named_sessions:     "이름이 지정된 세션이 없습니다.\n현재 세션에 이름을 지정하려면 `/title 내 세션`을 사용하고, 나중에 `/resume 내 세션`으로 돌아오세요."
           list_header:           "📋 **이름이 지정된 세션**\n"
           list_item:             "• **{title}**{preview_part}"
      +    list_item_numbered:    "{index}. **{title}**{preview_part}"
           list_preview_suffix:   " — _{preview}_"
           list_footer:           "\n사용법: `/resume <session name>`"
      +    list_footer_numbered:  "\n사용법: `/resume <세션 이름>` 또는 `/resume <번호>` (예: 가장 최근 세션은 `/resume 1`)"
           list_failed:           "세션 목록을 가져올 수 없습니다: {error}"
      +    out_of_range:          "재개 인덱스 {index}이(가) 범위를 벗어났습니다.\n인자 없이 `/resume`을 실행하면 사용 가능한 세션이 표시됩니다."
           not_found:             "'**{name}**'와 일치하는 세션이 없습니다.\n사용 가능한 세션을 보려면 인수 없이 `/resume`을 사용하세요."
           already_on:            "📌 이미 **{name}** 세션에 있습니다."
           switch_failed:         "세션 전환에 실패했습니다."
    • locales/pt.yaml
      @@ -222,9 +222,12 @@ gateway:
           no_named_sessions:     "Não foram encontradas sessões com nome.\nUsa `/title A minha sessão` para nomear a sessão atual e depois `/resume A minha sessão` para voltar a ela."
           list_header:           "📋 **Sessões com nome**\n"
           list_item:             "• **{title}**{preview_part}"
      +    list_item_numbered:    "{index}. **{title}**{preview_part}"
           list_preview_suffix:   " — _{preview}_"
           list_footer:           "\nUso: `/resume <nome da sessão>`"
      +    list_footer_numbered:  "\nUso: `/resume <nome da sessão>` ou `/resume <número>` (ex.: `/resume 1` para a mais recente)"
           list_failed:           "Não foi possível listar as sessões: {error}"
      +    out_of_range:          "O índice de retomada {index} está fora do intervalo.\nUse `/resume` sem argumentos para ver as sessões disponíveis."
           not_found:             "Não foi encontrada nenhuma sessão correspondente a '**{name}**'.\nUsa `/resume` sem argumentos para ver as sessões disponíveis."
           already_on:            "📌 Já estás na sessão **{name}**."
           switch_failed:         "Falha ao mudar de sessão."
    • locales/ru.yaml
      @@ -222,9 +222,12 @@ gateway:
           no_named_sessions:     "Именованных сеансов не найдено.\nИспользуйте `/title Мой сеанс`, чтобы назвать текущий сеанс, затем `/resume Мой сеанс`, чтобы вернуться к нему позже."
           list_header:           "📋 **Именованные сеансы**\n"
           list_item:             "• **{title}**{preview_part}"
      +    list_item_numbered:    "{index}. **{title}**{preview_part}"
           list_preview_suffix:   " — _{preview}_"
           list_footer:           "\nИспользование: `/resume <название сеанса>`"
      +    list_footer_numbered:  "\nИспользование: `/resume <имя сеанса>` или `/resume <номер>` (например, `/resume 1` для самого недавнего)"
           list_failed:           "Не удалось получить список сеансов: {error}"
      +    out_of_range:          "Индекс возобновления {index} вне диапазона.\nИспользуйте `/resume` без аргументов, чтобы увидеть доступные сеансы."
           not_found:             "Сеанс, соответствующий '**{name}**', не найден.\nИспользуйте `/resume` без аргументов, чтобы увидеть доступные сеансы."
           already_on:            "📌 Уже в сеансе **{name}**."
           switch_failed:         "Не удалось переключить сеанс."
    • locales/tr.yaml
      @@ -222,9 +222,12 @@ gateway:
           no_named_sessions:     "Adlandırılmış oturum bulunamadı.\nMevcut oturumu adlandırmak için `/title Oturumum`, daha sonra geri dönmek için `/resume Oturumum` kullanın."
           list_header:           "📋 **Adlandırılmış Oturumlar**\n"
           list_item:             "• **{title}**{preview_part}"
      +    list_item_numbered:    "{index}. **{title}**{preview_part}"
           list_preview_suffix:   " — _{preview}_"
           list_footer:           "\nKullanım: `/resume <oturum adı>`"
      +    list_footer_numbered:  "\nKullanım: `/resume <oturum adı>` veya `/resume <numara>` (örn. en yenisi için `/resume 1`)"
           list_failed:           "Oturumlar listelenemedi: {error}"
      +    out_of_range:          "Devam endeksi {index} aralık dışında.\nKullanılabilir oturumları görmek için `/resume` komutunu argümansız çalıştırın."
           not_found:             "'**{name}**' ile eşleşen oturum bulunamadı.\nKullanılabilir oturumları görmek için argümansız `/resume` kullanın."
           already_on:            "📌 Zaten **{name}** oturumundasınız."
           switch_failed:         "Oturum değiştirilemedi."
    • locales/uk.yaml
      @@ -222,9 +222,12 @@ gateway:
           no_named_sessions:     "Іменованих сеансів не знайдено.\nВикористайте `/title Мій сеанс`, щоб назвати поточний сеанс, потім `/resume Мій сеанс`, щоб повернутися до нього."
           list_header:           "📋 **Іменовані сеанси**\n"
           list_item:             "• **{title}**{preview_part}"
      +    list_item_numbered:    "{index}. **{title}**{preview_part}"
           list_preview_suffix:   " — _{preview}_"
           list_footer:           "\nВикористання: `/resume <назва сеансу>`"
      +    list_footer_numbered:  "\nВикористання: `/resume <назва сесії>` або `/resume <номер>` (наприклад, `/resume 1` для найновішої)"
           list_failed:           "Не вдалося отримати список сеансів: {error}"
      +    out_of_range:          "Індекс відновлення {index} поза межами діапазону.\nВикористовуйте `/resume` без аргументів, щоб переглянути доступні сесії."
           not_found:             "Сеанс, що відповідає '**{name}**', не знайдено.\nВикористайте `/resume` без аргументів, щоб побачити доступні сеанси."
           already_on:            "📌 Уже в сеансі **{name}**."
           switch_failed:         "Не вдалося переключити сеанс."
    • locales/zh-hant.yaml
      @@ -222,9 +222,12 @@ gateway:
           no_named_sessions:     "找不到已命名的工作階段。\n使用 `/title 我的工作階段` 為目前工作階段命名,然後使用 `/resume 我的工作階段` 返回。"
           list_header:           "📋 **已命名工作階段**\n"
           list_item:             "• **{title}**{preview_part}"
      +    list_item_numbered:    "{index}. **{title}**{preview_part}"
           list_preview_suffix:   " — _{preview}_"
           list_footer:           "\n用法:`/resume <工作階段名稱>`"
      +    list_footer_numbered:  "\n用法:`/resume <會話名稱>` 或 `/resume <編號>`(例如,`/resume 1` 表示最近的會話)"
           list_failed:           "無法列出工作階段:{error}"
      +    out_of_range:          "恢復索引 {index} 超出範圍。\n請使用不帶參數的 `/resume` 查看可用會話。"
           not_found:             "找不到符合 '**{name}**' 的工作階段。\n使用不帶參數的 `/resume` 檢視可用的工作階段。"
           already_on:            "📌 已在工作階段 **{name}** 上。"
           switch_failed:         "切換工作階段失敗。"
    • locales/zh.yaml
      @@ -222,9 +222,12 @@ gateway:
           no_named_sessions:     "未找到已命名的会话。\n使用 `/title 我的会话` 为当前会话命名,然后用 `/resume 我的会话` 返回。"
           list_header:           "📋 **已命名会话**\n"
           list_item:             "• **{title}**{preview_part}"
      +    list_item_numbered:    "{index}. **{title}**{preview_part}"
           list_preview_suffix:   " — _{preview}_"
           list_footer:           "\n用法:`/resume <会话名称>`"
      +    list_footer_numbered:  "\n用法:`/resume <会话名称>` 或 `/resume <编号>`(例如,`/resume 1` 表示最近的会话)"
           list_failed:           "无法列出会话:{error}"
      +    out_of_range:          "恢复索引 {index} 超出范围。\n请使用不带参数的 `/resume` 查看可用会话。"
           not_found:             "未找到匹配 '**{name}**' 的会话。\n使用不带参数的 `/resume` 查看可用会话。"
           already_on:            "📌 已在会话 **{name}** 上。"
           switch_failed:         "切换会话失败。"
    • scripts/release.py
      @@ -1283,6 +1283,7 @@
           "[redacted:email]": "egilewski",  # PR #30432 (MEDIA path traversal fix, GHSA-jmf9-9729-7pp8)
           "[redacted:email]": "McClean-Edison",  # PR #29817 (register_auxiliary_task plugin API)
           "[redacted:email]": "SamuelZ12",  # PR #7480 (show recap after in-session resume)
      +    "[redacted:email]": "daizhonggeng",  # PR #9020 (numbered /resume selection)
       }
       
       
    • tests/cli/test_cli_init.py
      @@ -331,7 +331,8 @@ def test_resume_without_target_lists_recent_sessions(self, capsys):
       
               assert "Recent sessions" in output
               assert "Checking Running Hermes Agent" in output
      -        assert "Use /resume <session id or title> to continue" in output
      +        assert "Use /resume" in output
      +        assert "session title" in output
       
           def test_resume_updates_hermes_session_id_env_and_context(self, tmp_path):
               from gateway.session_context import _UNSET, _VAR_MAP, get_session_env
    • tests/cli/test_cli_resume_command.py
      @@ -0,0 +1,77 @@
      +from unittest.mock import MagicMock, patch
      +
      +from cli import HermesCLI
      +
      +
      +def _make_cli():
      +    cli_obj = HermesCLI.__new__(HermesCLI)
      +    cli_obj.session_id = "current_session"
      +    cli_obj._resumed = False
      +    cli_obj._pending_title = None
      +    cli_obj.conversation_history = []
      +    cli_obj.agent = None
      +    cli_obj._session_db = MagicMock()
      +    # _handle_resume_command now triggers _display_resumed_history (#31695),
      +    # which reads self.resume_display. "minimal" short-circuits the recap so
      +    # the test only exercises session-switch behavior.
      +    cli_obj.resume_display = "minimal"
      +    return cli_obj
      +
      +
      +class TestCliResumeCommand:
      +    def test_show_recent_sessions_includes_indexes_and_resume_hint(self, capsys):
      +        cli_obj = _make_cli()
      +        cli_obj._list_recent_sessions = MagicMock(return_value=[
      +            {"id": "sess_002", "title": "Coding", "preview": "build feature", "last_active": None},
      +            {"id": "sess_001", "title": "Research", "preview": "read docs", "last_active": None},
      +        ])
      +
      +        shown = cli_obj._show_recent_sessions(reason="resume")
      +        output = capsys.readouterr().out
      +
      +        assert shown is True
      +        assert "1" in output
      +        assert "2" in output
      +        assert "Coding" in output
      +        assert "Research" in output
      +        assert "/resume 2" in output
      +        assert "/resume <session title>" in output
      +
      +    def test_handle_resume_by_index_switches_to_numbered_session(self):
      +        cli_obj = _make_cli()
      +        cli_obj._list_recent_sessions = MagicMock(return_value=[
      +            {"id": "sess_002", "title": "Coding"},
      +            {"id": "sess_001", "title": "Research"},
      +        ])
      +        cli_obj._session_db.get_session.return_value = {"id": "sess_001", "title": "Research"}
      +        cli_obj._session_db.get_messages_as_conversation.return_value = [
      +            {"role": "user", "content": "hello"},
      +            {"role": "assistant", "content": "hi"},
      +        ]
      +        # resolve_resume_session_id passes the id through when no compression chain.
      +        cli_obj._session_db.resolve_resume_session_id.return_value = "sess_001"
      +
      +        with (
      +            patch("hermes_cli.main._resolve_session_by_name_or_id", return_value=None),
      +            patch("cli._cprint") as mock_cprint,
      +        ):
      +            cli_obj._handle_resume_command("/resume 2")
      +
      +        printed = " ".join(str(call) for call in mock_cprint.call_args_list)
      +        assert cli_obj.session_id == "sess_001"
      +        assert "Resumed session sess_001" in printed
      +        assert "Research" in printed
      +
      +    def test_handle_resume_by_index_out_of_range(self):
      +        cli_obj = _make_cli()
      +        cli_obj._list_recent_sessions = MagicMock(return_value=[
      +            {"id": "sess_002", "title": "Coding"},
      +        ])
      +
      +        with patch("cli._cprint") as mock_cprint:
      +            cli_obj._handle_resume_command("/resume 9")
      +
      +        printed = " ".join(str(call) for call in mock_cprint.call_args_list)
      +        assert "out of range" in printed.lower()
      +        assert "/resume" in printed
      +        assert cli_obj.session_id == "current_session"
    • tests/gateway/test_resume_command.py
      @@ -88,6 +88,9 @@ async def test_list_named_sessions_when_no_arg(self, tmp_path):
               assert "Research" in result
               assert "Coding" in result
               assert "Named Sessions" in result
      +        assert "1." in result
      +        assert "2." in result
      +        assert "/resume 1" in result
               db.close()
       
           @pytest.mark.asyncio
      @@ -104,6 +107,47 @@ async def test_list_shows_usage_when_no_titled(self, tmp_path):
               assert "/title" in result
               db.close()
       
      +    @pytest.mark.asyncio
      +    async def test_resume_by_index(self, tmp_path):
      +        """Numeric argument resumes the indexed titled session from the list."""
      +        from hermes_state import SessionDB
      +        db = SessionDB(db_path=tmp_path / "state.db")
      +        db.create_session("sess_001", "telegram")
      +        db.create_session("sess_002", "telegram")
      +        db.set_session_title("sess_001", "Research")
      +        db.set_session_title("sess_002", "Coding")
      +        db.create_session("current_session_001", "telegram")
      +
      +        event = _make_event(text="/resume 2")
      +        runner = _make_runner(session_db=db, current_session_id="current_session_001",
      +                              event=event)
      +        result = await runner._handle_resume_command(event)
      +
      +        assert "Resumed" in result
      +        runner.session_store.switch_session.assert_called_once()
      +        call_args = runner.session_store.switch_session.call_args
      +        assert call_args[0][1] == "sess_001"
      +        db.close()
      +
      +    @pytest.mark.asyncio
      +    async def test_resume_index_out_of_range(self, tmp_path):
      +        """Out-of-range numeric arguments show a helpful error."""
      +        from hermes_state import SessionDB
      +        db = SessionDB(db_path=tmp_path / "state.db")
      +        db.create_session("sess_001", "telegram")
      +        db.set_session_title("sess_001", "Research")
      +        db.create_session("current_session_001", "telegram")
      +
      +        event = _make_event(text="/resume 9")
      +        runner = _make_runner(session_db=db, current_session_id="current_session_001",
      +                              event=event)
      +        result = await runner._handle_resume_command(event)
      +
      +        assert "out of range" in result.lower()
      +        assert "/resume" in result
      +        runner.session_store.switch_session.assert_not_called()
      +        db.close()
      +
           @pytest.mark.asyncio
           async def test_resume_by_name(self, tmp_path):
               """Resolves a title and switches to that session."""

2 PRs closed without merging

From the close comments: duplicate 1 · merged via another change 1

  • #4990 feat(cli): add /resume interactive picker and numeric selection (#4988) · merged via another change → #31709
  • #8493 feat: interactive session picker for /resume and hermes resume · duplicate → #7880
Closed issues · 3
  • Closed#718Feature: Interactive Session Picker for hermes --continue — Browsable Session List with Search
  • Closed#4988[Feature]: /resume with no arguments should show an interactive session picker
  • Closed#12406[Feature]: Hermes Session Selector - Professional Session Resume Interface
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.15: 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
#733, #31709
Closed issues
3
Attempts
2 PRs closed without merging
Fix link checked
24 May 2026

For your agent

The same facts, typed.