From bde89c169bdcc7d34839a8706b142929782c615f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Apr 2026 16:17:39 -0500 Subject: [PATCH] fix(cli): -c picks the most recently used session --- hermes_cli/main.py | 25 +++++- tests/hermes_cli/test_resolve_last_session.py | 61 ++++++++++++++ ui-tui/src/components/appLayout.tsx | 29 ++++--- ui-tui/src/lib/perfPane.tsx | 82 +++++++++++++++++++ 4 files changed, 183 insertions(+), 14 deletions(-) create mode 100644 tests/hermes_cli/test_resolve_last_session.py create mode 100644 ui-tui/src/lib/perfPane.tsx diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 968745704b..40de1f125e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -596,15 +596,32 @@ def _session_browse_picker(sessions: list) -> Optional[str]: def _resolve_last_session(source: str = "cli") -> Optional[str]: - """Look up the most recent session ID for a source.""" + """Look up the most recently *used* session ID for a source. + + Previously this returned the most recently *started* session, which meant + `hermes -c` could skip the session you just closed if a newer one had been + opened earlier in a different window. We now order by last_active + (max message timestamp, falling back to started_at) so -c always resumes + the most recent conversation you actually touched. + """ try: from hermes_state import SessionDB db = SessionDB() - sessions = db.search_sessions(source=source, limit=1) + sessions = db.search_sessions(source=source, limit=20) db.close() - if sessions: - return sessions[0]["id"] + if not sessions: + return None + + def _last_active(s: dict) -> float: + v = s.get("last_active") or s.get("started_at") or 0 + try: + return float(v) + except (TypeError, ValueError): + return 0.0 + + sessions.sort(key=_last_active, reverse=True) + return sessions[0]["id"] except Exception: pass return None diff --git a/tests/hermes_cli/test_resolve_last_session.py b/tests/hermes_cli/test_resolve_last_session.py new file mode 100644 index 0000000000..68abc3df50 --- /dev/null +++ b/tests/hermes_cli/test_resolve_last_session.py @@ -0,0 +1,61 @@ +"""Verify `hermes -c` picks the session the user most recently used.""" + +from __future__ import annotations + +from hermes_cli.main import _resolve_last_session + + +class _FakeDB: + def __init__(self, rows): + self._rows = rows + self.closed = False + + def search_sessions(self, source=None, limit=20, **_kw): + rows = [r for r in self._rows if r.get("source") == source] if source else list(self._rows) + return rows[:limit] + + def close(self): + self.closed = True + + +def test_resolve_last_session_prefers_last_active_over_started_at(monkeypatch): + # `search_sessions` returns in started_at DESC order, but the most recently + # *touched* session may have been started earlier. -c should pick by + # last_active so closing the active session and typing `hermes -c` resumes + # that one, not an older-but-newer-started session from another window. + rows = [ + { + "id": "new_started_old_active", + "source": "cli", + "started_at": 1000.0, + "last_active": 100.0, + }, + { + "id": "old_started_recently_active", + "source": "cli", + "started_at": 500.0, + "last_active": 999.0, + }, + ] + + fake_db = _FakeDB(rows) + monkeypatch.setattr("hermes_state.SessionDB", lambda: fake_db) + + assert _resolve_last_session("cli") == "old_started_recently_active" + assert fake_db.closed + + +def test_resolve_last_session_returns_none_when_empty(monkeypatch): + monkeypatch.setattr("hermes_state.SessionDB", lambda: _FakeDB([])) + assert _resolve_last_session("cli") is None + + +def test_resolve_last_session_falls_back_to_started_at(monkeypatch): + # When last_active is missing entirely (legacy row), fall back to + # started_at so the helper still picks the newest session. + rows = [ + {"id": "older", "source": "cli", "started_at": 10.0}, + {"id": "newer", "source": "cli", "started_at": 20.0}, + ] + monkeypatch.setattr("hermes_state.SessionDB", lambda: _FakeDB(rows)) + assert _resolve_last_session("cli") == "newer" diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 9e716583c9..0c13640765 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -8,6 +8,7 @@ import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStor import { $uiState } from '../app/uiStore.js' import { PLACEHOLDER } from '../content/placeholders.js' import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js' +import { PerfPane } from '../lib/perfPane.js' import { AgentsOverlay } from './agentsOverlay.js' import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' @@ -248,23 +249,31 @@ export const AppLayout = memo(function AppLayout({ {overlay.agents ? ( - + + + ) : ( - + + + )} {!overlay.agents && ( <> - + + + - + + + )} diff --git a/ui-tui/src/lib/perfPane.tsx b/ui-tui/src/lib/perfPane.tsx new file mode 100644 index 0000000000..32b260b721 --- /dev/null +++ b/ui-tui/src/lib/perfPane.tsx @@ -0,0 +1,82 @@ +// Perf instrumentation: wraps React.Profiler around named panes and writes +// commit timings to a log file when HERMES_DEV_PERF is set. Enabled per-run +// via the env var; zero-cost (Profiler is replaced by a Fragment) when off. +// +// Log format: one JSON object per line, for easy `jq` filtering. We only +// log commits that exceed a threshold (default 2ms) so the file doesn't +// fill up with sub-millisecond idle renders. Tune via HERMES_DEV_PERF_MS. +// +// Usage in consumers: +// import { PerfPane } from './perfPane.js' +// ... +// +// Inspect with: +// tail -f ~/.hermes/perf.log | jq -c 'select(.actualMs > 8)' +// jq -s 'group_by(.id) | map({id: .[0].id, count: length, p50: (sort_by(.actualMs) | .[length/2|floor].actualMs), p99: (sort_by(.actualMs) | .[length*0.99|floor].actualMs)})' ~/.hermes/perf.log + +import { appendFileSync, mkdirSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' + +import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react' + +const ENABLED = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_DEV_PERF ?? '').trim()) +const THRESHOLD_MS = Number(process.env.HERMES_DEV_PERF_MS ?? '2') || 2 +const LOG_PATH = process.env.HERMES_DEV_PERF_LOG?.trim() || join(homedir(), '.hermes', 'perf.log') + +let initialized = false + +const ensureLogDir = () => { + if (initialized) { + return + } + + initialized = true + + try { + mkdirSync(dirname(LOG_PATH), { recursive: true }) + } catch { + // Best-effort — if we can't create the dir (readonly fs, /tmp, etc.) + // the appendFileSync calls below will throw silently and we drop the + // sample. Perf logging should never crash the TUI. + } +} + +const onRender: ProfilerOnRenderCallback = (id, phase, actualMs, baseMs, startTime, commitTime) => { + if (actualMs < THRESHOLD_MS) { + return + } + + ensureLogDir() + + const row = { + actualMs: Math.round(actualMs * 100) / 100, + baseMs: Math.round(baseMs * 100) / 100, + commitMs: Math.round(commitTime * 100) / 100, + id, + phase, + startMs: Math.round(startTime * 100) / 100, + ts: Date.now() + } + + try { + appendFileSync(LOG_PATH, `${JSON.stringify(row)}\n`) + } catch { + // Same rationale as ensureLogDir — never crash the UI to log a sample. + } +} + +export function PerfPane({ children, id }: { children: ReactNode; id: string }) { + if (!ENABLED) { + return children + } + + return ( + + {children} + + ) +} + +export const PERF_ENABLED = ENABLED +export const PERF_LOG_PATH = LOG_PATH