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

Add `hermes sessions browse` — a curses-based interactive session picker
with live type-to-search filtering, arrow key navigation, and seamless
session resume via Enter.

Features:
- Arrow keys to navigate, Enter to select and resume, Esc/q to quit
- Type characters to live-filter sessions by title, preview, source, or ID
- Backspace to edit filter, first Esc clears filter, second Esc exits
- Adaptive column layout (title/preview, last active, source, ID)
- Scrolling support for long session lists
- --source flag to filter by platform (cli, telegram, discord, etc.)
- --limit flag to control how many sessions to load (default: 50)
- Windows fallback: numbered list with input prompt
- After selection, seamlessly execs into `hermes --resume <id>`

Design decisions:
- Separate subcommand (not a flag on -c) — preserves `hermes -c` as-is
  for instant most-recent-session resume
- Uses curses (not simple_term_menu) per Known Pitfalls to avoid the
  arrow-key ghost-duplication rendering bug in tmux/iTerm
- Follows existing curses pattern from hermes_cli/tools_config.py

Also fixes: removed redundant `import os` inside cmd_sessions stats
block that shadowed the module-level import (would cause UnboundLocalError
if browse action was taken in the same function).

Tests: 33 new tests covering curses picker, fallback mode, filtering,
navigation, edge cases, and argument parser registration.
This commit is contained in:
teknium1
2026-03-08 17:42:50 -07:00
parent c0520223fd
commit ecac6321c4
2 changed files with 852 additions and 1 deletions

View File

@@ -21,6 +21,7 @@ Usage:
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 @@ Examples:
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 @@ For more help on a command:
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 @@ For more help on a command:
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 @@ For more help on a command:
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)