* fix(chat): snappy in-chat search over visible text only - findMatches now scans USER + ASSISTANT messages only — tool rows and system events were matched before, so navigation jumped to invisible matches and the scan paid for the chat's heaviest strings (tool payloads) - Reasoning text is explicitly out of scope (lives in reasoningText, never highlighted) — documented in the controller contract - Debounce (150ms) coalesces typing into one scan instead of one full scan per keystroke; blanking the query still clears instantly - Tests: controller scope (user/assistant only, tool/system/reasoning excluded, case-insensitive, navigation wrap) + delegate debounce (coalescing, cancelled intermediate queries, instant blank clear) * fix(chat): search navigation lands the match mid-view, not at an edge scrollToSearchMatch top-aligned the matched message with animateScrollToItem(index), pinning it to the viewport edge — tall messages left the highlighted word above/below the visible area. Now top-aligns, then nudges the item down to ~1/3 viewport height (verified top-align -> measure -> adjust pattern, same as scrollToBottom). * fix(chat): search scroll targets the real lazy item and the actual word ROOT CAUSE: search-match indices are MESSAGE-list indices, but the LazyColumn has extra items (reasoning hoists, tool rows, loading-older), so scrollToSearchMatch(index) landed on the WRONG item whenever reasoning or tools preceded the match — the 'does nothing / word under or above the view' bug from the start. - messageIdToLazyIndex(): pure messageId -> LazyColumn item index mapping mirroring FullBleedChatList's emission order (verified in tests) - search-scroll effect moved INTO FullBleedChatList (the only composable that knows the mapping), serialized through the same ChatScrollController - word-level targeting: each SearchMatch now carries its character offset in the message; the scroll places offset/contentLength x item height at ~1/3 viewport, so the highlighted word itself lands in view on tall or multi-occurrence messages - counter/navigation now count OCCURRENCES (word hits), one entry per hit with a parallel searchMatchOffsets list; per-message dedupe removed - tests: lazy-index mapping (reasoning hoist, tools, leading items), SearchMatch offsets, occurrence flattening, regex-literal queries * fix(ci): pass searchMatchOffsets and scrollController to FullBleedChatList in instrumented test The render helper in FullBleedChatListTest still used the pre-offset signature, so compileDebugAndroidTestKotlin failed on the emulator job. Verified locally with compileDebugAndroidTestKotlin + ktlintCheck. * perf(chat): allocation-free indexOf search matcher with capped results - findMatches scans with String.indexOf(ignoreCase=true) on the ORIGINAL content instead of Regex(Pattern.quote()).findAll: queries are always literal, so regex was pure overhead — a compiled Pattern plus one MatchResult allocation per hit. Offsets stay exact (no lowercase transform, which can change string length on some scripts). - returns SearchResult(matches, totalMatches, capped): results are capped at 500 stored entries (MAX_SEARCH_MATCHES) to bound allocations and navigation on degenerate queries, while the counter still shows the exact total (searchMatchTotal/searchMatchCapped in ChatUiState). - 14 controller tests green, delegate behavior unchanged. * perf(chat): O(1) current-match lookup and highlight only matched bubbles Two recomposition hot spots killed in the list renderer: - isCurrentMatchFor() did messages.indexOfFirst per rendered bubble per search update — O(n) per item, O(n^2) per update on the main thread. Now currentMatchMessageId() resolves the current match's message id ONCE per recomposition and items compare ids (O(1)). - every prose bubble re-ran its highlight scan on every search-state change even with zero hits. matchedMessageIds() computes the matched id set once; bubbles outside it get an empty searchQuery and skip the scan entirely. Both helpers are pure, tested in FullBleedTurnsTest (18 tests), and the instrumented test target still compiles. * perf(chat): dedicated ChatSearchState holder — search no longer recomposes the list Search fields lived in ChatUiState, so every debounced scan result changed the whole state object: ChatScreen recomposed, FullBleedChatList's params changed, and the entire list body re-ran (turns loop + item registrations) on the main thread per search update. Search state now lives in a @Stable snapshot-backed ChatSearchState (LazyListState pattern) owned by ChatSearchDelegate: - the list takes ONE stable param; the instance never changes, so the body is skipped on search updates — only the scopes that read a field recompose (search bar, matched bubbles, the scroll effect) - matchedIds and currentMatchId are computed once per scan/navigate in the delegate and stored as state fields — items do O(1) id lookups inside their own lambdas, no body-level derivation - ChatUiState loses the 7 search fields; ChatViewModel exposes viewModel.searchState; delegate tests now assert on the holder 19 search-related unit tests green, androidTest compiles. * test(chat): long-session search sanity (2000 msgs) — exact counts, valid offsets Synthetic 2000-message / ~1KB session verifying the matcher at scale: - frequent word: exact 32_000 total, capped storage, every stored offset points at a real occurrence - unique word resolves to the right message; tool payloads stay excluded - local measurement: full scan ~7ms (regex version allocates per hit) --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Hermes Mobile
Native Android companion app for your Hermes AI agent.
Overview
Hermes Mobile is the native Android client for Hermes Agent. It connects securely to your local Hermes gateway (REST API and WebSocket TUI Gateway) over LAN, giving you pocket control over your AI assistant.
Screenshots
Chat, automation, productivity, and agent configuration — from your phone.
Features
- Real-Time Chat: Message your agent with Room-backed local database history and inline reply notifications.
- System Config: Manage active profiles, installed skills, plugins, toolsets, and LLM model/provider selections.
- Operations: Stream and filter live logs, manage cron jobs, edit environment keys, test webhooks, and monitor processes.
- Gateway Status: Monitor WebSocket connection, MCP servers, messaging channels, and OAuth providers.
- Productivity: View and manage tasks via integrated Kanban boards, track agent milestones, and browse session history.
- Analytics & Billing: Usage analytics dashboard and billing/subscription management.
- Theming: 6 built-in color presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Neon Noir) plus Material You dynamic colors on supported devices.
- Modern UX: Native Material 3 design with pull-to-refresh, scroll-aware TopBar, and customizable bottom navigation.
Quick Start
Prerequisites
- JDK 21+ (required for Kotlin compilation and the Gradle toolchain)
- Android Studio (Ladybug+) or a Nix development environment
Build & Deploy
- Clone the repository:
git clone https://github.com/Hy4ri/hermes-mobile.git cd hermes-mobile - Build the debug APK:
./gradlew assembleDebug - Install on your emulator/device:
adb install app/build/outputs/apk/debug/app-debug.apk
Note: For release builds, ensure keystore environment variables (KEYSTORE_PATH, KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD) are configured, or let the GitHub Actions release workflow handle it on tag push (v*).
Authentication
Once the app is installed, you need to point it at your Hermes gateway. The app auto-detects which auth mode the dashboard is using — just fill in the fields it shows.
1. Start the dashboard
On your host machine, start the dashboard:
hermes dashboard # loopback (127.0.0.1:9119) — no auth needed
hermes dashboard --host 0.0.0.0 # LAN — requires auth
For LAN access, configure credentials in ~/.hermes/config.yaml:
dashboard:
basic_auth:
username: admin # pick your own
password: hermes # pick your own
2. Connect the app
Tap Sign in on the landing screen and enter the dashboard host and port. The app probes the dashboard and reveals the fields you need:
| Auth mode | When | What you fill |
|---|---|---|
| Token only | Dashboard on same machine (loopback) | Token — grab from ~/.hermes/dashboard-token.txt or ~/.hermes/.env (HERMES_DASHBOARD_SESSION_TOKEN). The app can also auto-extract it from the dashboard page |
| Basic auth | Dashboard on LAN with password gate | Username + Password (default admin / hermes). The app logs in, gets a session cookie, and mints a WebSocket ticket automatically |
The app communicates over plain HTTP — it's designed for trusted local networks only. Do not expose your Hermes gateway to untrusted networks.
Connection profiles
Have multiple gateways? Switch between them in Settings → Connection profiles. Each profile stores its own host, port, and token — just tap to swap.
Pairing (admin)
The Pairing screen lets you approve or revoke agents and services that are trying to connect to your gateway, such as Telegram or Discord sessions.
Project Structure
app/src/main/java/com/m57/hermescontrol/
├── data/ # Local (Room, AuthManager), Remote (Retrofit, OkHttp), WS (WebSocket), Models
├── notification/ # Foreground service + inline reply for chat notifications
├── theme/ # Preset-based design system (6 themes), status colors, spacing, typography
├── ui/ # 28 Compose feature screens + common components (HermesScaffold, StateViews)
├── util/ # CronExpressionFormatter, LocaleContextWrapper
└── Navigation*.kt # Navigation3 wiring, keys, screen registry, controller
Tech Stack
- Language: Kotlin 2.4.10 with KSP 2.3.10 compiler plugin
- UI & Layout: Jetpack Compose (BOM 2026.03.01) & Material 3 / Material You
- Navigation: Navigation3 (Compose-first Routing)
- Networking: Retrofit 3.0.0, OkHttp 5.4.0, Kotlinx Serialization 1.11.0
- Database: Room 2.7.1 with SQLCipher 4.17.0 encryption
- Security:
EncryptedSharedPreferences(AES256-GCM), DataStore - Theming: 6 built-in presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Neon Noir) + Material You dynamic colors
- Image Loading: Coil 2.7.0
- Testing: JUnit 5, MockK, Turbine, Espresso, Compose UI testing
- Formatting:
ktlint1.2.1 style rules (checked automatically in CI)
Contributing
Contributions are welcome! Please read CONTRIBUTING.md for our branch workflow, code style guidelines, and PR checklist.
For developer-specific details, code conventions, and project architecture notes, refer to AGENTS.md.
License
Copyright © 2026 M57 (Hy4ri).
This project is licensed under the Apache License, Version 2.0. See the LICENSE file for details.



