diff --git a/.gitignore b/.gitignore index ad097ca..67695ab 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ build/ .next/ coverage/ *.tsbuildinfo +.gradle-user/ # env/secrets .env @@ -25,6 +26,9 @@ uploads/ *.db-wal logs/ +# local agent/audit workspace +.dev/ + # OS/editor .DS_Store .vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7fc7a5e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# Changelog + +All notable Hermes Mobile source changes are recorded here. Entries are added during the same work loop as their implementation; release artifacts are only listed after source commit, checks, and APK upload are verified. + +## Unreleased + +### Added +- Added native Android chat, workspace, settings, local defaults, companion health, and file editing surfaces for the beta client. +- Added `PROJECT_PLAN.md` as the project-controlled milestone plan. + +### Changed +- Updated companion `/api/chat` to call `hermes chat -Q --source tool -q `, sanitize CLI banners/help/control output, truncate large mobile replies, and return structured failures. +- Reconciled `docs/ROADMAP.md` to the current native Android beta state. +- Ignored `.gradle-user/` and `.dev/` alongside existing generated build artifacts. + +### Fixed +- Fixed companion/mobile lint issues from missing Node globals and an unused React settings value. + +### Verification +- `git diff --check` — passed +- `npm run typecheck --workspace @hermes-mobile/companion` — passed +- `npm run lint --workspace @hermes-mobile/companion` — passed +- `npm run build --workspace @hermes-mobile/companion` — passed +- `npm run typecheck` — passed +- `npm run lint` — passed +- `npm run build` — passed +- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android` — blocked before compilation: network socket creation is not permitted while downloading Gradle 8.11.1 + +## Release history + +_No versioned APK releases yet._ + +## Entry format + +```markdown +## [v0.1.0-beta.1] - YYYY-MM-DD + +### Added +- Concise user-visible change. + +### Changed +- Concise refinement or architectural change. + +### Fixed +- Concise defect fix. + +### Verification +- `command` — passed + +### APK +- `hermes-mobile-v0.1.0-beta.1.apk` — Telegram release topic message +``` diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md new file mode 100644 index 0000000..7dec6dd --- /dev/null +++ b/PROJECT_PLAN.md @@ -0,0 +1,113 @@ +# Hermes Mobile — Delivery Plan + +> **Operational contract:** Codex must read this file before each work loop. Work exactly one coherent milestone at a time. Move completed items to the completed section in the same commit that implements them. Add a concise entry to `CHANGELOG.md` in the same commit. Never mark work complete without running its listed verification. + +## Product guardrails + +- Native Android client for upstream Hermes Agent: **Kotlin + Jetpack Compose**. +- Material 3 base, deliberately customized through a reusable design system. +- Thin client: Hermes remains the authority for auth, sessions, streaming, tool semantics, and backend behavior. +- No WebView-first app, no cloned Hermes backend, no embedded credentials. +- Adaptive, accessible, compact-but-readable UI: phone, tablet, light/dark, font scaling, IME/keyboard, long code/output must remain safe. +- Keep DTO/transport, domain/UI models, repositories, view models/state, and composables separated. +- Never commit APKs, build outputs, credentials, or generated local environment files. + +## Release policy + +- Start artifacts at `v0.1.0-beta.1`. +- Patch: compatible fixes/polish. Minor: a coherent feature milestone. Major: stable `v1.0.0+` only. +- A release requires a pushed source commit, passing relevant checks, a versioned APK artifact, and a Telegram release post. + +--- + +## Now — finish the foundation already in progress + +- [ ] **F1 — Compose design system extraction** + - Extract tokens for color, spacing, typography, shape, elevation, motion, and semantic status colors. + - Implement light/dark themes with readable contrast; eliminate default-sample styling. + - Split oversized UI into clear design, feature, and reusable component packages. + - Add Compose previews: small phone, normal phone, tablet width, light, dark. + - Verification: Android compile/build plus visual preview review. + +- [ ] **F2 — Polished fake-data chat proving ground** + - Session rail/list, conversation screen, context controls, keyboard-safe composer, send/cancel/retry behavior. + - Render assistant/user/system/error/streaming messages from UI models. + - Add markdown-friendly surfaces, selectable long text, code blocks with horizontal scroll + copy, compact expandable tool results. + - Cover empty/loading/reconnecting/error states without clipped/overlapping content. + - Verification: focused UI tests where practical; Android debug build. + +## Beta path — upstream-connected MVP + +- [ ] **B1 — Transport/domain contracts** + - Define transport DTOs and a repository interface for server profiles, authentication, sessions, messages, stream events, tool results, and artifacts. + - Map DTOs to stable UI models; fake repository powers previews/tests. + - Keep the existing companion as an optional adapter only; do not change upstream Hermes semantics. + - Verification: unit tests for mapping/state reducers; build/typecheck. + +- [ ] **B2 — Connection and authentication UX** + - Server connection screen with validation, health, auth/session configuration, offline/unauthorized/retry states. + - Store sensitive values using Android-appropriate secure storage; never log secrets. + - Verification: connection-state tests and manual unhappy-path walkthrough. + +- [ ] **B3 — Session list and durable chat state** + - Fetch/list/search/select/create sessions through repository contracts. + - Restore selected session and protect against duplicate sends / lost drafts. + - Verification: fake-repository UI tests; companion/integration checks where available. + +- [ ] **B4 — Streaming, reconnects, and resilient long-running work** + - Stream event reducer: token deltas, tool lifecycle, final/error/cancel, reconnect/backoff. + - Preserve partial output and make recovery obvious and safe. + - Verification: deterministic fake stream tests and real upstream-compatible exercise. + +- [ ] **B5 — Tool, artifact, and output experience** + - Compact tool cards with lifecycle/status, expandable output, copy/share/save affordances. + - Safe rendering for large logs and code; no unbounded nested scroll failures. + - Artifact/file handoff only where upstream capability is actually supported. + - Verification: large-output and rotation/resume checks. + +## Beta hardening and release + +- [ ] **R1 — Adaptive/accessibility quality pass** + - Small phone, normal phone, tablet/two-pane behavior; portrait/landscape. + - Font scaling, TalkBack semantics, contrast, touch targets, reduced motion, IME behavior. + - Verification: emulator/device matrix evidence documented in `docs/ROADMAP.md`. + +- [ ] **R2 — Beta packaging and install guide** + - Version `v0.1.0-beta.1`, reproducible debug/beta APK build, install/run guide, known limitations. + - Publish source commit, APK, release notes, and verification result. + - Verification: clean install on a real device or emulator plus full required checks. + +## V2 backlog + +- [ ] Multiple server profiles and fast switching. +- [ ] Rich file/artifact workflows based on upstream capability. +- [ ] Notifications for task completion. +- [ ] Mature tablet/two-pane session + optional terminal/log pane. +- [ ] Offline cache/search and diagnostic export with privacy controls. + +--- + +## Completed + +_Move finished items here with date, commit, and verification. Keep this section factual; do not claim unverified work._ + +- [x] **F0 — Audit and safely land existing work** — 2026-07-24, commit `da79d48` (`feat: land native mobile foundation`). + - Reviewed existing Android and companion edits; preserved the native Android chat/workspace/settings work and companion chat hardening. + - Confirmed generated build outputs are ignored and added ignores for `.gradle-user/` and `.dev/`. + - Reconciled `docs/ROADMAP.md` to the actual native Android beta state and remaining validation gaps. + - Verification: `git diff --check` passed; `npm run typecheck --workspace @hermes-mobile/companion` passed; `npm run lint --workspace @hermes-mobile/companion` passed; `npm run build --workspace @hermes-mobile/companion` passed; `npm run typecheck` passed; `npm run lint` passed; `npm run build` passed. + - Android debug build attempted with `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android`; blocked before compilation because network socket creation is not permitted while downloading Gradle 8.11.1. + +## Work-loop checklist + +Before coding: +1. Read `PROJECT_PLAN.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, git status, and the prior commit. +2. Choose the single highest-value unchecked item or a narrowly scoped sub-item. +3. Inspect existing implementation before rewriting it. + +Before committing: +1. Run relevant checks; record exact pass/fail scope in the changelog. +2. Update this plan: check the completed item, move it into **Completed**, and leave the next executable item unchecked. +3. Add a concise `CHANGELOG.md` entry under **Unreleased**. +4. Commit using Conventional Commits and push `origin/main`. +5. Do not claim a release without an uploaded, verified versioned APK. diff --git a/apps/companion/src/routes/auth.ts b/apps/companion/src/routes/auth.ts index 3ae96eb..470c69d 100644 --- a/apps/companion/src/routes/auth.ts +++ b/apps/companion/src/routes/auth.ts @@ -1,3 +1,4 @@ +import { Buffer } from 'node:buffer'; import { timingSafeEqual } from 'node:crypto'; import type { FastifyInstance, FastifyRequest } from 'fastify'; import { authValidateResponseSchema } from '@hermes-mobile/shared'; diff --git a/apps/companion/src/routes/chat.ts b/apps/companion/src/routes/chat.ts index 5ec0173..16a85f9 100644 --- a/apps/companion/src/routes/chat.ts +++ b/apps/companion/src/routes/chat.ts @@ -5,33 +5,123 @@ import { toSafeAbsolutePath } from '../config/paths.js'; import { getHermesHealth } from '../system/hermesHealth.js'; import { runProcess } from '../system/processes.js'; +const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[;?0-9]*[ -/]*[@-~]`, 'g'); + export async function registerChatRoutes(app: FastifyInstance, config: CompanionConfig): Promise { app.post('/api/chat', async (request, reply) => { - const body = chatRequestSchema.parse(request.body); - const hermesHealth = await getHermesHealth(); - - if (!hermesHealth.cliAvailable) { - return chatResponseSchema.parse({ - reply: 'Hermes CLI was not found on this machine. Install Hermes or make sure `hermes` is on PATH, then retry.', - exitCode: null, - stderr: undefined, - hermesAvailable: false, - }); + const body = chatRequestSchema.safeParse(request.body); + if (!body.success) { + return reply.code(400).send( + chatResponseSchema.parse({ + reply: 'Chat prompt is required.', + exitCode: null, + stderr: undefined, + hermesAvailable: false, + }), + ); } - const result = await runProcess(hermesHealth.cliPath ?? 'hermes', [], { - cwd: toSafeAbsolutePath(config.workspaceRoot), - timeoutMs: 30000, - input: body.prompt, - }); + try { + const hermesHealth = await getHermesHealth(); - return reply.code(result.exitCode === 0 || result.exitCode === null ? 200 : 502).send( - chatResponseSchema.parse({ - reply: result.stdout.trim() || result.stderr.trim() || 'Hermes finished without output.', - exitCode: result.exitCode, - stderr: result.stderr || undefined, - hermesAvailable: true, - }), - ); + if (!hermesHealth.cliAvailable) { + return reply.send( + chatResponseSchema.parse({ + reply: 'Hermes CLI was not found on this machine. Install Hermes or make sure `hermes` is on PATH, then retry.', + exitCode: null, + stderr: undefined, + hermesAvailable: false, + }), + ); + } + + const result = await runProcess(hermesHealth.cliPath ?? 'hermes', ['chat', '-Q', '--source', 'tool', '-q', body.data.prompt], { + cwd: toSafeAbsolutePath(config.workspaceRoot), + timeoutMs: 120000, + }); + + const cleanStdout = sanitizeHermesOutput(result.stdout); + const cleanStderr = sanitizeHermesOutput(result.stderr); + const openedHelp = looksLikeTopLevelHermesHelp(result.stdout) || looksLikeTopLevelHermesHelp(result.stderr); + const spawnFailed = result.exitCode === null && cleanStderr.length > 0 && cleanStdout.length === 0; + const failed = result.timedOut || openedHelp || spawnFailed || (result.exitCode !== 0 && result.exitCode !== null); + const fallback = openedHelp + ? 'Hermes opened its CLI help instead of running the prompt. Restart the companion server so it uses the updated chat command.' + : failed + ? `Hermes did not finish successfully${result.exitCode === null ? '' : ` (exit ${result.exitCode})`}.${result.timedOut ? ' The request timed out.' : ''}` + : 'Hermes finished without output.'; + const responseText = (openedHelp ? '' : cleanStdout) || (openedHelp ? '' : cleanStderr) || fallback; + + return reply.code(failed ? (result.timedOut ? 504 : 502) : 200).send( + chatResponseSchema.parse({ + reply: truncateForMobile(responseText), + exitCode: result.exitCode, + stderr: cleanStderr ? truncateForMobile(cleanStderr) : undefined, + hermesAvailable: true, + }), + ); + } catch (error) { + const message = error instanceof Error ? error.message : 'Chat request failed.'; + return reply.send( + chatResponseSchema.parse({ + reply: sanitizeHermesOutput(message) || 'Chat request failed.', + exitCode: null, + stderr: undefined, + hermesAvailable: true, + }), + ); + } }); } + +function sanitizeHermesOutput(text: string): string { + return text + .replace(ansiPattern, '') + .replace(/\r/g, '') + .split('\n') + .filter((line) => !isBannerOrHelpLine(line)) + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function isBannerOrHelpLine(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed) return false; + if (/session_id\s*:/i.test(trimmed)) return true; + if (/[\u2500-\u257F]/u.test(trimmed)) return true; + if (/^[+\-_|/\\()[\]{}<>\s*]+$/.test(trimmed) && trimmed.length > 8) return true; + if (/^(usage:|positional arguments:|options:|examples:|for more help on a command:|hermes --help)/i.test(trimmed)) return true; + if (/^\{chat,model,gateway,setup,whatsapp,login,logout,auth,status,cron,webhook,doctor,config,pairing,skills,plugins,honcho,tools,mcp,sessions,insights,claw,version,update,uninstall,acp,profile,completion\}/i.test(trimmed)) return true; + if (/^(chat|model|gateway|setup|whatsapp|login|logout|auth|status|cron|webhook|doctor|config|pairing|skills|plugins|honcho|tools|mcp|sessions|insights|claw|version|update|uninstall|acp|profile|completion)\s{2,}/i.test(trimmed)) return true; + if (/^hermes\s+(chat|setup|gateway|status|doctor|model|config|sessions|update|uninstall)\b/i.test(trimmed)) return true; + if (/^(Hermes|⚕ Hermes)\b.*(Agent|CLI|v?\d)/i.test(trimmed)) return true; + if (/^Hermes Agent - AI assistant with tool-calling capabilities$/i.test(trimmed)) return true; + return false; +} + +function looksLikeTopLevelHermesHelp(text: string): boolean { + const clean = text.replace(ansiPattern, ''); + return /usage:\s+hermes\b/i.test(clean) && /Hermes Agent - AI assistant with tool-calling capabilities/i.test(clean); +} + +function selfCheckSanitizer(): void { + const sample = [ + '\u001B[36m╭─ ⚕ Hermes v1.2.3 ─╮\u001B[0m', + '│ session_id: abc123 │', + '╰────────────────────╯', + '', + 'Final answer line.', + ].join('\n'); + if (sanitizeHermesOutput(sample) !== 'Final answer line.') { + throw new Error('Hermes output sanitizer self-check failed.'); + } +} + +selfCheckSanitizer(); + +function truncateForMobile(text: string): string { + const maxChars = 24000; + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars)}\n\n[Output truncated]`; +} diff --git a/apps/companion/src/routes/files.ts b/apps/companion/src/routes/files.ts index 47caa37..7932d5c 100644 --- a/apps/companion/src/routes/files.ts +++ b/apps/companion/src/routes/files.ts @@ -1,3 +1,4 @@ +import { Buffer } from 'node:buffer'; import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; import { basename, dirname, join } from 'node:path'; import type { FastifyInstance } from 'fastify'; diff --git a/apps/companion/src/system/processes.ts b/apps/companion/src/system/processes.ts index d6764ce..e8dd1ff 100644 --- a/apps/companion/src/system/processes.ts +++ b/apps/companion/src/system/processes.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process'; +import { clearTimeout, setTimeout } from 'node:timers'; export type RunProcessResult = { stdout: string; diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt index fae9853..70d3f7e 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt @@ -7,6 +7,7 @@ import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.animation.AnimatedContent @@ -22,10 +23,9 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -33,45 +33,53 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ChatBubble import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material.icons.filled.WifiOff import androidx.compose.material.icons.outlined.Article import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.outlined.Security import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card -import androidx.compose.material3.darkColorScheme import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.darkColorScheme +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -85,9 +93,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -102,24 +112,54 @@ import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject import java.net.URLEncoder +import java.util.Locale import java.util.concurrent.TimeUnit -enum class Screen(val label: String, val icon: ImageVector, val title: String, val kicker: String) { - Inbox("Inbox", Icons.Filled.ChatBubble, "Inbox", "connected"), - Terminals("Terminals", Icons.Filled.Terminal, "Terminals", "connected"), - Settings("Settings", Icons.Filled.Settings, "Settings", "connected") +enum class Screen(val label: String, val icon: ImageVector, val title: String) { + Inbox("Chat", Icons.Filled.ChatBubble, "Chat"), + Terminals("Workspace", Icons.Filled.Folder, "Workspace"), + Settings("Settings", Icons.Filled.Settings, "Settings") } +enum class TerminalPanel { Home, Machine, Files } +enum class SettingsPanel { Home, Appearance, Defaults, Health, Accounts } + data class FileEntry(val name: String, val path: String, val type: String, val size: Long) data class ChatMessage(val role: String, val content: String) +class ChatConversation(val id: Int) { + val messages = mutableStateListOf() + var draft by mutableStateOf("") + var busy by mutableStateOf(false) + var title by mutableStateOf("New chat") + var subtitle by mutableStateOf("Ready") +} + +data class ChatResult( + val reply: String, + val hermesAvailable: Boolean, + val exitCode: Int?, + val stderr: String +) + +data class AgentDefaults( + val model: String = "Hermes default", + val effort: String = "Normal", + val permissions: String = "Ask before risky commands" +) + +data class AppearancePrefs( + val density: String = "Comfortable", + val completionDetail: String = "Expanded" +) + data class HealthState( val reachable: Boolean = false, val status: String = "Tap refresh to check companion status.", val hermesCli: String = "Unknown / unavailable", val workspace: String = "Unknown", - val uptime: String = "—", - val mode: String = "—" + val uptime: String = "-", + val mode: String = "-" ) class CompanionApi(private val context: Context) { @@ -131,11 +171,41 @@ class CompanionApi(private val context: Context) { var baseUrl: String get() = prefs.getString("baseUrl", "") ?: "" - set(value) { prefs.edit().putString("baseUrl", value.trim()).apply() } + set(value) { + prefs.edit().putString("baseUrl", value.trim()).apply() + } var accessKey: String get() = prefs.getString("accessKey", "") ?: "" - set(value) { prefs.edit().putString("accessKey", value.trim()).apply() } + set(value) { + prefs.edit().putString("accessKey", value.trim()).apply() + } + + var defaults: AgentDefaults + get() = AgentDefaults( + model = prefs.getString("defaultModel", "Hermes default") ?: "Hermes default", + effort = prefs.getString("defaultEffort", "Normal") ?: "Normal", + permissions = prefs.getString("defaultPermissions", "Ask before risky commands") ?: "Ask before risky commands" + ) + set(value) { + prefs.edit() + .putString("defaultModel", value.model.trim().ifBlank { "Hermes default" }) + .putString("defaultEffort", value.effort.trim().ifBlank { "Normal" }) + .putString("defaultPermissions", value.permissions.trim().ifBlank { "Ask before risky commands" }) + .apply() + } + + var appearance: AppearancePrefs + get() = AppearancePrefs( + density = prefs.getString("appearanceDensity", "Comfortable") ?: "Comfortable", + completionDetail = prefs.getString("appearanceCompletionDetail", "Expanded") ?: "Expanded" + ) + set(value) { + prefs.edit() + .putString("appearanceDensity", value.density) + .putString("appearanceCompletionDetail", value.completionDetail) + .apply() + } private fun endpoint(path: String): String = baseUrl.trimEnd('/') + path @@ -144,17 +214,24 @@ class CompanionApi(private val context: Context) { val mediaType = "application/json; charset=utf-8".toMediaType() val builder = Request.Builder() .url(endpoint(path)) - .header("Authorization", "Bearer $accessKey") .header("Accept", "application/json") + if (accessKey.isNotBlank()) builder.header("Authorization", "Bearer $accessKey") val request = when (method) { "POST" -> builder.post((body ?: JSONObject()).toString().toRequestBody(mediaType)).build() "PUT" -> builder.put((body ?: JSONObject()).toString().toRequestBody(mediaType)).build() else -> builder.get().build() } http.newCall(request).execute().use { response -> - val text = response.body?.string().orEmpty() - if (!response.isSuccessful) throw IllegalStateException(text.ifBlank { "HTTP ${response.code}" }) - return if (text.isBlank()) JSONObject() else JSONObject(text) + val text = cleanServerText(response.body?.string().orEmpty()) + val json = runCatching { if (text.isBlank()) JSONObject() else JSONObject(text) }.getOrNull() + if (!response.isSuccessful) { + val message = json?.optString("reply")?.takeIf { it.isNotBlank() } + ?: json?.optString("message")?.takeIf { it.isNotBlank() } + ?: json?.optString("error")?.takeIf { it.isNotBlank() } + ?: text.take(600).ifBlank { "Companion returned HTTP ${response.code}." } + throw IllegalStateException(message) + } + return json ?: throw IllegalStateException("Companion returned a non-JSON response: ${text.take(220).ifBlank { "empty body" }}") } } @@ -165,10 +242,10 @@ class CompanionApi(private val context: Context) { HealthState( reachable = true, status = "Companion is reachable.", - hermesCli = if (checks?.optBoolean("cliAvailable") == true) "Found at ${checks.optString("cliPath")}" else "Unknown / unavailable", + hermesCli = if (checks?.optBoolean("cliAvailable") == true) "Found at ${checks.optString("cliPath")}" else "Hermes CLI was not found on PATH.", workspace = config?.optString("workspaceRoot") ?: "Unknown", uptime = "${json.optDouble("uptimeSeconds", 0.0).toInt()}s", - mode = json.optString("mode", "—") + mode = json.optString("mode", "-") ) } @@ -176,8 +253,23 @@ class CompanionApi(private val context: Context) { request("/api/auth/validate").optString("message", "Connection works.") } - suspend fun chat(prompt: String): String = withContext(Dispatchers.IO) { - request("/api/chat", "POST", JSONObject().put("prompt", prompt)).optString("reply", "") + suspend fun chat(prompt: String): ChatResult = withContext(Dispatchers.IO) { + val json = request("/api/chat", "POST", JSONObject().put("prompt", prompt)) + ChatResult( + reply = json.optString("reply", ""), + hermesAvailable = json.optBoolean("hermesAvailable", false), + exitCode = if (json.isNull("exitCode")) null else json.optInt("exitCode"), + stderr = json.optString("stderr", "") + ) + } + + suspend fun testHermes(): String { + val result = chat("ping") + return if (result.hermesAvailable) { + "Hermes responded${result.exitCode?.let { " with exit code $it" } ?: ""}." + } else { + result.reply.ifBlank { "Hermes CLI is not available on the companion machine." } + } } suspend fun listFiles(path: String): Pair> = withContext(Dispatchers.IO) { @@ -202,7 +294,7 @@ class CompanionApi(private val context: Context) { } suspend fun writeFile(path: String, content: String): String = withContext(Dispatchers.IO) { - val json = request("/api/files/write", "PUT", JSONObject().put("path", path).put("content", content)) + val json = request("/api/files/write", "POST", JSONObject().put("path", path).put("content", content)) "Saved ${json.optLong("bytesWritten")} bytes" } @@ -217,6 +309,11 @@ class CompanionApi(private val context: Context) { } } +fun cleanServerText(text: String): String = + text.replace(Regex("\\u001B\\[[;?0-9]*[ -/]*[@-~]"), "") + .replace("\u0000", "") + .trim() + class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -229,10 +326,17 @@ class MainActivity : ComponentActivity() { fun HermesNativeApp() { val context = LocalContext.current val api = remember { CompanionApi(context.applicationContext) } + val snackbar = remember { SnackbarHostState() } + val scope = remember { CoroutineScope(Dispatchers.Main) } var screen by remember { mutableStateOf(Screen.Inbox) } var online by remember { mutableStateOf(false) } + var resetInboxToken by remember { mutableStateOf(0) } - LaunchedEffect(screen) { + fun notify(message: String) { + scope.launch { snackbar.showSnackbar(message) } + } + + LaunchedEffect(screen, api.baseUrl, api.accessKey) { runCatching { api.health() }.onSuccess { online = true }.onFailure { online = false } } @@ -240,8 +344,8 @@ fun HermesNativeApp() { colorScheme = darkColorScheme( background = AppBackground, surface = RowSurface, - primary = Teal, - onPrimary = Color.Black, + primary = Ink, + onPrimary = AppBackground, onBackground = Ink, onSurface = Ink ) @@ -249,7 +353,14 @@ fun HermesNativeApp() { Scaffold( modifier = Modifier.fillMaxSize(), containerColor = AppBackground, - topBar = { NativeTopBar(screen, online) }, + topBar = { + NativeTopBar(screen, online, onAction = { + if (screen == Screen.Inbox) { + resetInboxToken++ + notify("New chat started.") + } + }) + }, bottomBar = { NativeBottomBar( current = screen, @@ -258,21 +369,25 @@ fun HermesNativeApp() { screen = it } ) - } + }, + snackbarHost = { SnackbarHost(snackbar) } ) { padding -> AnimatedContent( targetState = screen, transitionSpec = { slideFor(initialState.ordinal, targetState.ordinal) }, modifier = Modifier .fillMaxSize() - .padding(padding) - .imePadding(), + .padding(padding), label = "screen-slide" ) { active -> when (active) { - Screen.Inbox -> InboxScreen(api) - Screen.Terminals -> TerminalsScreen(api) - Screen.Settings -> SettingsScreen(api) + Screen.Inbox -> InboxScreen(api, resetInboxToken) + Screen.Terminals -> TerminalsScreen(api, ::notify, onNewChat = { + resetInboxToken++ + screen = Screen.Inbox + notify("New chat started.") + }) + Screen.Settings -> SettingsScreen(api, ::notify) } } } @@ -294,27 +409,35 @@ fun vibrate(context: Context) { @Suppress("DEPRECATION") context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator } if (!vibrator.hasVibrator()) return - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) - else @Suppress("DEPRECATION") vibrator.vibrate(20) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) + } else { + @Suppress("DEPRECATION") vibrator.vibrate(20) + } } } @Composable -fun NativeTopBar(screen: Screen, online: Boolean) { - Column( +fun NativeTopBar(screen: Screen, online: Boolean, onAction: () -> Unit) { + Row( Modifier .fillMaxWidth() .statusBarsPadding() .padding(horizontal = 16.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Text(screen.title, Modifier.weight(1f), fontSize = 24.sp, fontWeight = FontWeight.SemiBold, color = Ink, lineHeight = 28.sp) - if (screen == Screen.Terminals) Text("+", color = Ink, fontSize = 26.sp, fontWeight = FontWeight.Light) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(screen.title, fontSize = 24.sp, fontWeight = FontWeight.SemiBold, color = Ink, lineHeight = 28.sp) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box(Modifier.size(8.dp).clip(CircleShape).background(if (online) Good else Muted)) + Text(if (online) "connected" else "offline", color = if (online) Good else Muted, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + } } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Box(Modifier.size(8.dp).clip(CircleShape).background(if (online) Teal else Muted)) - Text(if (online) "connected" else "offline", color = if (online) Teal else Muted, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + if (screen == Screen.Inbox) { + IconButton(onClick = onAction, modifier = Modifier.size(44.dp)) { + Icon(Icons.Filled.Add, contentDescription = "New chat", tint = Ink) + } } } } @@ -335,7 +458,7 @@ fun NativeBottomBar(current: Screen, onSelect: (Screen) -> Unit) { selected = current == item, onClick = { onSelect(item) }, icon = { Icon(item.icon, contentDescription = item.label) }, - label = { Text(item.label, fontWeight = FontWeight.Black, fontSize = 11.sp) }, + label = { Text(item.label, fontWeight = FontWeight.SemiBold, fontSize = 11.sp) }, colors = NavigationBarItemDefaults.colors( selectedIconColor = Ink, selectedTextColor = Ink, @@ -350,279 +473,756 @@ fun NativeBottomBar(current: Screen, onSelect: (Screen) -> Unit) { } @Composable -fun InboxScreen(api: CompanionApi) { +fun InboxScreen(api: CompanionApi, resetToken: Int) { val scope = remember { CoroutineScope(Dispatchers.Main) } - var prompt by remember { mutableStateOf("") } - var busy by remember { mutableStateOf(false) } - var error by remember { mutableStateOf("") } - val messages = remember { mutableStateListOf() } + val conversations = remember { mutableStateListOf() } + var selectedId by remember { mutableStateOf(null) } + var nextId by remember { mutableStateOf(1) } - ScreenList { - Text("Hello!", color = Ink, fontSize = 32.sp, fontWeight = FontWeight.Normal, modifier = Modifier.padding(top = 18.dp, bottom = 24.dp)) - if (messages.isEmpty()) EmptyLesson() - messages.forEach { Bubble(it) } - if (error.isNotBlank()) ErrorCard(error) - RaisedCard(Modifier.fillMaxWidth()) { - Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - AppTextField(value = prompt, onValue = { prompt = it }, placeholder = "Message Hermes", minLines = 3) - PrimaryButton(text = if (busy) "Working…" else "Send", icon = Icons.Filled.PlayArrow, enabled = !busy && prompt.isNotBlank()) { - val p = prompt.trim() - prompt = "" - messages.add(ChatMessage("You", p)) - busy = true - error = "" - scope.launch { - runCatching { api.chat(p) } - .onSuccess { messages.add(ChatMessage("Hermes", it)) } - .onFailure { error = it.message ?: "Chat failed" } - busy = false - } + fun newConversation(): ChatConversation { + val conversation = ChatConversation(nextId++) + conversations.add(0, conversation) + selectedId = conversation.id + return conversation + } + + LaunchedEffect(resetToken) { + if (resetToken > 0) { + newConversation() + } + } + + fun send(conversation: ChatConversation) { + val p = conversation.draft.trim() + if (p.isBlank() || conversation.busy) return + conversation.draft = "" + conversation.messages.add(ChatMessage("You", p)) + if (conversation.title == "New chat") conversation.title = p.lineSequence().first().take(48) + conversation.subtitle = "Hermes is working..." + conversation.busy = true + scope.launch { + runCatching { api.chat(p) } + .onSuccess { + val suffix = if (it.exitCode != null && it.exitCode != 0) "\n\nExit code: ${it.exitCode}" else "" + val reply = cleanMessage(it.reply.ifBlank { "Hermes finished without output." }) + suffix + conversation.messages.add(ChatMessage("Hermes", reply)) + conversation.subtitle = reply.lineSequence().firstOrNull()?.take(72)?.ifBlank { "Hermes replied." } ?: "Hermes replied." } + .onFailure { + val message = it.message ?: "Chat failed." + conversation.messages.add(ChatMessage("Error", message)) + conversation.subtitle = message.take(72) + } + conversation.busy = false + } + } + + val selected = conversations.firstOrNull { it.id == selectedId } + BackHandler(selected != null) { selectedId = null } + + if (selected == null) { + ConversationList( + conversations = conversations, + onNew = { newConversation() }, + onOpen = { selectedId = it.id } + ) + } else { + ConversationThread( + conversation = selected, + onBack = { selectedId = null }, + onSend = { send(selected) } + ) + } +} + +@Composable +fun ConversationList( + conversations: List, + onNew: () -> Unit, + onOpen: (ChatConversation) -> Unit +) { + ScreenList { + PrimaryButton("New Chat", Icons.Filled.Add, Modifier.fillMaxWidth(), onClick = onNew) + SectionHeader("Conversations", if (conversations.isEmpty()) "No conversations yet." else "${conversations.size} active ${if (conversations.size == 1) "thread" else "threads"}") + conversations.forEach { conversation -> + val title = conversation.title + val status = if (conversation.busy) "Hermes is working..." else conversation.subtitle + ListRow( + icon = Icons.Filled.ChatBubble, + title = title, + subtitle = status, + onClick = { onOpen(conversation) } + ) + } + } +} + +@Composable +fun ConversationThread( + conversation: ChatConversation, + onBack: () -> Unit, + onSend: () -> Unit +) { + Column(Modifier.fillMaxSize()) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) { + Icon(Icons.Filled.ArrowBack, contentDescription = "Conversations", tint = Ink) + } + Column(Modifier.weight(1f)) { + Text(conversation.title, color = Ink, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(if (conversation.busy) "Hermes is working..." else conversation.subtitle, color = Muted, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + if (conversation.messages.isEmpty()) { + item { EmptyState() } + } + items(conversation.messages) { message -> + Bubble(message) + } + if (conversation.busy) { + item { TypingIndicator() } + } + } + InboxComposer( + prompt = conversation.draft, + onPrompt = { conversation.draft = it }, + busy = conversation.busy, + canSend = conversation.draft.isNotBlank(), + onSend = onSend + ) + } +} + +@Composable +fun InboxComposer( + prompt: String, + onPrompt: (String) -> Unit, + busy: Boolean, + canSend: Boolean, + onSend: () -> Unit +) { + Surface(color = AppBackground, tonalElevation = 0.dp) { + Row( + Modifier + .fillMaxWidth() + .imePadding() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = prompt, + onValueChange = onPrompt, + modifier = Modifier.weight(1f), + placeholder = { Text("Message Hermes", color = Muted) }, + minLines = 1, + maxLines = 5, + shape = RoundedCornerShape(24.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Surface, + unfocusedContainerColor = Surface, + focusedIndicatorColor = NeutralBorder, + unfocusedIndicatorColor = NeutralBorder, + cursorColor = Ink + ) + ) + IconButton( + onClick = onSend, + enabled = !busy && canSend, + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .background(if (!busy && canSend) Ink else Surface) + ) { + Icon( + Icons.Filled.Send, + contentDescription = "Send", + tint = if (!busy && canSend) AppBackground else Muted + ) } } } } @Composable -fun FilesScreen(api: CompanionApi) { - val scope = remember { CoroutineScope(Dispatchers.Main) } - var path by remember { mutableStateOf(".") } - var entries by remember { mutableStateOf>(emptyList()) } - var activePath by remember { mutableStateOf("") } - var draft by remember { mutableStateOf("") } - var status by remember { mutableStateOf("") } +fun TerminalsScreen(api: CompanionApi, notify: (String) -> Unit, onNewChat: () -> Unit) { + var panel by remember { mutableStateOf(TerminalPanel.Home) } + BackHandler(panel != TerminalPanel.Home) { panel = TerminalPanel.Home } - fun load(next: String = path) = scope.launch { - runCatching { api.listFiles(next) } - .onSuccess { (p, list) -> path = p; entries = list; activePath = ""; draft = ""; status = "" } - .onFailure { status = it.message ?: "Unable to list files" } - } - LaunchedEffect(Unit) { load(".") } - - ScreenList { - CompactQuest(title = "Project files", label = "Workspace", text = "Browse and edit files through the companion server.", icon = Icons.Filled.Folder) { load() } - RaisedCard(Modifier.fillMaxWidth()) { - Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton(onClick = { load(parentPath(path)) }, enabled = path != ".") { Text("Up", fontWeight = FontWeight.Black) } - Text(path, Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis, color = Muted, fontWeight = FontWeight.Black) - } - entries.forEach { entry -> - FileRow(entry) { - if (entry.type == "directory") load(entry.path) else scope.launch { - runCatching { api.readFile(entry.path) } - .onSuccess { (p, c) -> activePath = p; draft = c } - .onFailure { status = it.message ?: "Unable to read file" } - } - } - } - } - } - if (activePath.isNotBlank()) RaisedCard(Modifier.fillMaxWidth()) { - Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - TinyLabel("Editing") - Text(activePath, color = Ink, fontWeight = FontWeight.Black, maxLines = 2, overflow = TextOverflow.Ellipsis) - AppTextField(draft, { draft = it }, "File content", minLines = 10) - PrimaryButton("Save", Icons.Filled.Save) { - scope.launch { runCatching { api.writeFile(activePath, draft) }.onSuccess { status = it }.onFailure { status = it.message ?: "Save failed" } } - } - } - } - if (status.isNotBlank()) ErrorCard(status) + when (panel) { + TerminalPanel.Home -> TerminalHome(api, notify, onNewChat, onPanel = { panel = it }) + TerminalPanel.Machine -> MachineDetails(api, onBack = { panel = TerminalPanel.Home }) + TerminalPanel.Files -> FilesScreen(api, notify, onBack = { panel = TerminalPanel.Home }) } } @Composable -fun TerminalsScreen(api: CompanionApi) { +fun TerminalHome(api: CompanionApi, notify: (String) -> Unit, onNewChat: () -> Unit, onPanel: (TerminalPanel) -> Unit) { val scope = remember { CoroutineScope(Dispatchers.Main) } var cwd by remember { mutableStateOf(".") } var command by remember { mutableStateOf("pwd && ls") } var output by remember { mutableStateOf("Command output appears here.") } var busy by remember { mutableStateOf(false) } - ScreenList { - SectionLabel("MACHINES") - ListRow(icon = Icons.Filled.Terminal, title = "Hermes", subtitle = "hermes-mobile • online") {} - TextButton(onClick = {}, modifier = Modifier.fillMaxWidth()) { Text("Show archived", color = Muted, fontWeight = FontWeight.SemiBold) } - SectionLabel("NEW CHAT") - ListRow(icon = Icons.Filled.ChatBubble, title = "New chat", subtitle = "Start a session on the paired machine") {} - RaisedCard(Modifier.fillMaxWidth()) { + SectionHeader("Command", "Run a shell command on the companion workspace.") + FlatCard { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { FieldLabel("Working directory") AppTextField(cwd, { cwd = it }, ".") FieldLabel("Command") - AppTextField(command, { command = it }, "pwd && ls", minLines = 4) - PrimaryButton(if (busy) "Running…" else "Run command", Icons.Filled.PlayArrow, enabled = !busy && command.isNotBlank()) { + AppTextField(command, { command = it }, "pwd && ls", minLines = 3) + PrimaryButton(if (busy) "Running..." else "Run command", Icons.Filled.PlayArrow, enabled = !busy && command.isNotBlank()) { busy = true scope.launch { - runCatching { api.runCommand(command, cwd) }.onSuccess { output = it }.onFailure { output = it.message ?: "Command failed" } + runCatching { api.runCommand(command, cwd) } + .onSuccess { + output = it + notify("Command finished.") + } + .onFailure { output = it.message ?: "Command failed" } busy = false } } } } CodeCard(output) + SectionHeader("Workspace", "Browse files or inspect the companion host.") + ListRow(icon = Icons.Filled.Folder, title = "Files", subtitle = "Browse, read, and edit workspace files", onClick = { onPanel(TerminalPanel.Files) }) + ListRow(icon = Icons.Filled.Terminal, title = "Machine", subtitle = "Companion health, workspace, and CLI status") { onPanel(TerminalPanel.Machine) } + SectionHeader("Chat", "Start over in the messenger view.") + ListRow(icon = Icons.Filled.ChatBubble, title = "New Inbox chat", subtitle = "Reset the chat session and start fresh", onClick = onNewChat) } } @Composable -fun StatusScreen(api: CompanionApi) { +fun MachineDetails(api: CompanionApi, onBack: () -> Unit) { val scope = remember { CoroutineScope(Dispatchers.Main) } var health by remember { mutableStateOf(HealthState()) } - fun refresh() = scope.launch { health = runCatching { api.health() }.getOrElse { HealthState(false, it.message ?: "Health check failed") } } + fun refresh() = scope.launch { + health = runCatching { api.health() }.getOrElse { HealthState(false, it.message ?: "Health check failed") } + } + LaunchedEffect(Unit) { refresh() } - ScreenList { - CompactQuest(title = "Companion status", label = "Sessions", text = health.status, icon = Icons.Outlined.Bolt, orbColor = Teal, orbShadow = TealDark) { refresh() } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Metric("Hermes", if (health.hermesCli.startsWith("Found")) "Ready" else "—", Modifier.weight(1f)) - Metric("Uptime", health.uptime, Modifier.weight(1f)) - Metric("Mode", health.mode, Modifier.weight(1f)) - } - RaisedCard(Modifier.fillMaxWidth()) { - Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - FieldLabel("Companion URL") - Text(api.baseUrl.ifBlank { "Not set" }, color = Ink, fontWeight = FontWeight.Black) - FieldLabel("Hermes CLI") - Text(health.hermesCli, color = Ink, fontWeight = FontWeight.Black) - FieldLabel("Workspace") - Text(health.workspace, color = Ink, fontWeight = FontWeight.Black) - } - } + DetailList("Machine", onBack) { + NoticeCard(health.status, danger = !health.reachable) + MetricRow("Hermes", if (health.hermesCli.startsWith("Found")) "Ready" else "Unavailable") + MetricRow("Uptime", health.uptime) + MetricRow("Mode", health.mode) + MetricRow("Workspace", health.workspace) + MetricRow("Hermes CLI", health.hermesCli) + PrimaryButton("Refresh", Icons.Filled.Refresh) { refresh() } } } @Composable -fun SettingsScreen(api: CompanionApi) { +fun FilesScreen(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit) { val scope = remember { CoroutineScope(Dispatchers.Main) } - var baseUrl by remember { mutableStateOf(api.baseUrl) } - var accessKey by remember { mutableStateOf(api.accessKey) } - var status by remember { mutableStateOf("Paste the companion access key, then test the connection.") } + var path by remember { mutableStateOf(".") } + var entries by remember { mutableStateOf>(emptyList()) } + var activePath by remember { mutableStateOf("") } + var draft by remember { mutableStateOf("") } + var status by remember { mutableStateOf("") } + var loading by remember { mutableStateOf(false) } - ScreenList { - SectionLabel("MACHINES") - ListRow(icon = Icons.Filled.Terminal, title = "Hermes", subtitle = "linux • online") {} - SectionLabel("FEATURES") - ListRow(icon = Icons.Filled.Settings, title = "Appearance", subtitle = "Customize how the app looks") {} - ListRow(icon = Icons.Outlined.Bolt, title = "Agent Defaults", subtitle = "Default model, effort, and permissions") {} - SectionLabel("COMPANION") - RaisedCard(Modifier.fillMaxWidth()) { + fun load(next: String = path) = scope.launch { + loading = true + runCatching { api.listFiles(next) } + .onSuccess { (p, list) -> + path = p + entries = list + activePath = "" + draft = "" + status = "" + } + .onFailure { status = it.message ?: "Unable to list files" } + loading = false + } + LaunchedEffect(Unit) { load(".") } + + DetailList("Files", onBack) { + SectionHeader("Workspace files", "Navigate the companion workspace and edit one file at a time.") + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + SecondaryButton("Up", Icons.Filled.ArrowBack, Modifier.weight(0.8f), enabled = path != ".") { load(parentPath(path)) } + PrimaryButton(if (loading) "Loading..." else "Refresh", Icons.Filled.Refresh, Modifier.weight(1f), enabled = !loading) { load() } + } + Text(path, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Muted, fontWeight = FontWeight.SemiBold) + if (status.isNotBlank()) NoticeCard(status, danger = status.contains("failed", true) || status.contains("unable", true)) + if (activePath.isNotBlank()) FlatCard { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - FieldLabel("Companion server URL") - AppTextField(baseUrl, { baseUrl = it }, "", keyboardType = KeyboardType.Uri) - FieldLabel("Access key") - AppTextField(accessKey, { accessKey = it }, "hm_…", password = true) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - SecondaryButton("Save", Icons.Filled.Save, Modifier.weight(1f)) { api.baseUrl = baseUrl; api.accessKey = accessKey; status = "Settings saved." } - PrimaryButton("Test", Icons.Filled.Wifi, Modifier.weight(1f)) { - api.baseUrl = baseUrl; api.accessKey = accessKey; status = "Testing…" - scope.launch { status = runCatching { api.validate() }.getOrElse { it.message ?: "Connection failed" } } + TinyLabel("Editing") + Text(activePath, color = Ink, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis) + AppTextField(draft, { draft = it }, "File content", minLines = 10) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + SecondaryButton("Close", Icons.Filled.Close, Modifier.weight(1f)) { activePath = ""; draft = "" } + PrimaryButton("Save", Icons.Filled.Save, Modifier.weight(1f)) { + scope.launch { + runCatching { api.writeFile(activePath, draft) } + .onSuccess { + status = it + notify(it) + load(path) + } + .onFailure { status = it.message ?: "Save failed" } + } } } } } + SectionHeader("Directory", if (loading) "Loading entries..." else "${entries.size} item${if (entries.size == 1) "" else "s"}") + entries.forEach { entry -> + FileRow(entry) { + if (entry.type == "directory") { + load(entry.path) + } else { + scope.launch { + runCatching { api.readFile(entry.path) } + .onSuccess { (p, c) -> + activePath = p + draft = c + status = "" + } + .onFailure { status = it.message ?: "Unable to read file" } + } + } + } + } + if (entries.isEmpty() && !loading) NoticeCard("No visible files in this workspace path.") + } +} + +@Composable +fun SettingsScreen(api: CompanionApi, notify: (String) -> Unit) { + var panel by remember { mutableStateOf(SettingsPanel.Home) } + BackHandler(panel != SettingsPanel.Home) { panel = SettingsPanel.Home } + + when (panel) { + SettingsPanel.Home -> SettingsHome(api, notify, onPanel = { panel = it }) + SettingsPanel.Appearance -> AppearanceSettings(api, notify, onBack = { panel = SettingsPanel.Home }) + SettingsPanel.Defaults -> AgentDefaultSettings(api, notify, onBack = { panel = SettingsPanel.Home }) + SettingsPanel.Health -> HealthSettings(api, notify, onBack = { panel = SettingsPanel.Home }) + SettingsPanel.Accounts -> ConnectedAccountsSettings(onBack = { panel = SettingsPanel.Home }) + } +} + +@Composable +fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (SettingsPanel) -> Unit) { + val scope = remember { CoroutineScope(Dispatchers.Main) } + var baseUrl by remember { mutableStateOf(api.baseUrl) } + var accessKey by remember { mutableStateOf(api.accessKey) } + var status by remember { mutableStateOf("Server URL is blank until you enter your companion address.") } + + ScreenList { + SectionHeader("Companion", "Connect this device before using Inbox, terminal, or files.") + FlatCard { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + FieldLabel("Companion server URL") + AppTextField(baseUrl, { baseUrl = it }, "", keyboardType = KeyboardType.Uri) + FieldLabel("Access key") + AppTextField(accessKey, { accessKey = it }, "hm_...", password = true) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + SecondaryButton("Save", Icons.Filled.Save, Modifier.weight(1f)) { + api.baseUrl = baseUrl + api.accessKey = accessKey + status = "Settings saved." + notify(status) + } + PrimaryButton("Test", Icons.Filled.Wifi, Modifier.weight(1f)) { + api.baseUrl = baseUrl + api.accessKey = accessKey + status = "Testing..." + scope.launch { + status = runCatching { api.validate() }.getOrElse { it.message ?: "Connection failed" } + notify(status) + } + } + } + } + } + NoticeCard(status, danger = status.contains("failed", true) || status.contains("Unauthorized", true)) + SectionHeader("Machine", "Inspect companion health and credentials.") + ListRow(icon = Icons.Filled.Terminal, title = "Hermes", subtitle = "Health, workspace, and CLI status") { onPanel(SettingsPanel.Health) } + ListRow(icon = Icons.Outlined.Security, title = "Accounts", subtitle = "Use companion environment and Hermes CLI auth") { onPanel(SettingsPanel.Accounts) } + SectionHeader("Preferences", "Local defaults stored on this Android device.") + ListRow(icon = Icons.Filled.Tune, title = "Agent Defaults", subtitle = api.defaults.run { "$model - $effort" }) { onPanel(SettingsPanel.Defaults) } + ListRow(icon = Icons.Filled.Palette, title = "Appearance", subtitle = "Dark native theme settings") { onPanel(SettingsPanel.Appearance) } + } +} + +@Composable +fun AppearanceSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit) { + var density by remember { mutableStateOf(api.appearance.density) } + var completionDetail by remember { mutableStateOf(api.appearance.completionDetail) } + + DetailList("Appearance", onBack) { + MetricRow("Theme", "Dark") + MetricRow("Background", "#212121") + MetricRow("Rows", "#303030") + FieldLabel("Row density") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + ModeChip("Comfortable", density == "Comfortable", Modifier.weight(1f)) { density = "Comfortable" } + ModeChip("Compact", density == "Compact", Modifier.weight(1f)) { density = "Compact" } + } + FieldLabel("Completion detail") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + ModeChip("Expanded", completionDetail == "Expanded", Modifier.weight(1f)) { completionDetail = "Expanded" } + ModeChip("Brief", completionDetail == "Brief", Modifier.weight(1f)) { completionDetail = "Brief" } + } + PrimaryButton("Save appearance", Icons.Filled.Save) { + api.appearance = AppearancePrefs(density, completionDetail) + notify("Appearance settings saved locally.") + } + NoticeCard("These appearance preferences are stored on this Android device. The current native pass keeps the dark palette fixed to match the reference app.") + } +} + +@Composable +fun AgentDefaultSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit) { + var model by remember { mutableStateOf(api.defaults.model) } + var effort by remember { mutableStateOf(api.defaults.effort) } + var permissions by remember { mutableStateOf(api.defaults.permissions) } + + DetailList("Defaults", onBack) { + FieldLabel("Model") + AppTextField(model, { model = it }, "Hermes default") + FieldLabel("Effort") + AppTextField(effort, { effort = it }, "Normal") + FieldLabel("Permissions") + AppTextField(permissions, { permissions = it }, "Ask before risky commands", minLines = 2) + PrimaryButton("Save defaults", Icons.Filled.Save) { + api.defaults = AgentDefaults(model, effort, permissions) + notify("Agent defaults saved locally.") + } + NoticeCard("These defaults are stored on this Android device. The companion API does not consume model, effort, or permission defaults yet.") + } +} + +@Composable +fun HealthSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit) { + val scope = remember { CoroutineScope(Dispatchers.Main) } + var health by remember { mutableStateOf(HealthState()) } + var testStatus by remember { mutableStateOf("") } + fun refresh() = scope.launch { + health = runCatching { api.health() }.getOrElse { HealthState(false, it.message ?: "Health check failed") } + } + LaunchedEffect(Unit) { refresh() } + + DetailList("Hermes", onBack) { + NoticeCard(health.status, danger = !health.reachable) + MetricRow("Companion URL", api.baseUrl.ifBlank { "Not set" }) + MetricRow("Hermes CLI", health.hermesCli) + MetricRow("Workspace", health.workspace) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + SecondaryButton("Refresh", Icons.Filled.Refresh, Modifier.weight(1f)) { refresh() } + PrimaryButton("Test Hermes", Icons.Outlined.Bolt, Modifier.weight(1f)) { + testStatus = "Testing Hermes..." + scope.launch { + testStatus = runCatching { api.testHermes() }.getOrElse { it.message ?: "Hermes test failed" } + notify(testStatus) + } + } + } + if (testStatus.isNotBlank()) NoticeCard(testStatus, danger = testStatus.contains("failed", true) || testStatus.contains("not available", true)) + } +} + +@Composable +fun ConnectedAccountsSettings(onBack: () -> Unit) { + DetailList("Accounts", onBack) { + MetricRow("Hermes CLI", "Managed on companion machine") + MetricRow("API keys", "Read from companion environment") + NoticeCard("The mobile app does not store provider account tokens. Configure Hermes and provider credentials on the companion host, then use Settings > Hermes to test the CLI.") } } fun parentPath(path: String): String = path.split('/').filter { it.isNotBlank() }.dropLast(1).joinToString("/").ifBlank { "." } +fun cleanMessage(text: String): String = + cleanServerText(text).take(12000).ifBlank { "Hermes finished without output." } + @Composable fun ScreenList(content: @Composable ColumnScope.() -> Unit) { LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 8.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(14.dp), content = { item { Column(verticalArrangement = Arrangement.spacedBy(14.dp), content = content) } } ) } @Composable -fun SectionLabel(text: String) { - Text(text, color = Muted, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp, modifier = Modifier.padding(top = 10.dp, start = 2.dp)) +fun DetailList(title: String, onBack: () -> Unit, content: @Composable ColumnScope.() -> Unit) { + ScreenList { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back", tint = Ink) } + Text(title, color = Ink, fontSize = 22.sp, fontWeight = FontWeight.SemiBold) + } + content() + } +} + +@Composable +fun SectionHeader(title: String, detail: String) { + Column(Modifier.padding(top = 8.dp, start = 2.dp, end = 2.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, color = Ink, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + Text(detail, color = Muted, fontSize = 13.sp, lineHeight = 18.sp) + } } @Composable fun ListRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> Unit) { - RaisedCard(Modifier.fillMaxWidth()) { + FlatCard { Row(Modifier.clickable(onClick = onClick).padding(horizontal = 14.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { Box(Modifier.size(32.dp).clip(RoundedCornerShape(8.dp)).background(Surface), contentAlignment = Alignment.Center) { Icon(icon, null, tint = Ink, modifier = Modifier.size(19.dp)) } Column(Modifier.weight(1f)) { Text(title, color = Ink, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(subtitle, color = Muted, fontWeight = FontWeight.Normal, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(subtitle, color = Muted, fontWeight = FontWeight.Normal, fontSize = 13.sp, maxLines = 2, overflow = TextOverflow.Ellipsis) } - Text("›", color = Muted, fontSize = 22.sp, fontWeight = FontWeight.Light) + Icon(Icons.Filled.ChevronRight, null, tint = Muted, modifier = Modifier.size(20.dp)) } } } @Composable -fun RaisedCard(modifier: Modifier = Modifier, color: Color = RowSurface, shadow: Color = Color.Transparent, border: Color = Color.Transparent, content: @Composable () -> Unit) { - Card(modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp), colors = CardDefaults.cardColors(containerColor = color), border = BorderStroke(0.dp, border), elevation = CardDefaults.cardElevation(0.dp)) { content() } +fun FlatCard(modifier: Modifier = Modifier, color: Color = RowSurface, border: Color = Color.Transparent, content: @Composable () -> Unit) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = color), + border = BorderStroke(0.dp, border), + elevation = CardDefaults.cardElevation(0.dp) + ) { content() } } @Composable -fun HeroCard(icon: ImageVector, title: String, label: String, text: String, orbColor: Color = Teal, orbShadow: Color = TealDark) { - RaisedCard(Modifier.fillMaxWidth()) { - Row(Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) { - RaisedIcon(icon, orbColor, orbShadow) - Column(Modifier.weight(1f)) { TinyLabel(label); Text(title, color = Ink, fontWeight = FontWeight.Black, fontSize = 28.sp, lineHeight = 29.sp); Text(text, color = Muted, fontWeight = FontWeight.ExtraBold, lineHeight = 18.sp) } +fun ModeChip(text: String, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { + Surface( + modifier = modifier.height(44.dp).clip(RoundedCornerShape(12.dp)).clickable(onClick = onClick), + color = if (selected) Ink else RowSurface, + contentColor = if (selected) AppBackground else Ink + ) { + Box(contentAlignment = Alignment.Center) { + Text(text, fontWeight = FontWeight.SemiBold, fontSize = 14.sp) } } } @Composable -fun CompactQuest(title: String, label: String, text: String, icon: ImageVector, orbColor: Color = Teal, orbShadow: Color = TealDark, onRefresh: () -> Unit) { - RaisedCard(Modifier.fillMaxWidth()) { - Row(Modifier.padding(14.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { - RaisedIcon(icon, orbColor, orbShadow, size = 58) - Column(Modifier.weight(1f)) { TinyLabel(label); Text(title, color = Ink, fontWeight = FontWeight.Black, fontSize = 24.sp); Text(text, color = Muted, fontWeight = FontWeight.ExtraBold, lineHeight = 17.sp) } - if (onRefresh !== {}) RoundButton(Icons.Filled.Refresh, onRefresh) +fun EmptyState() { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 28.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box(Modifier.size(48.dp).clip(CircleShape).background(Surface), contentAlignment = Alignment.Center) { + Icon(Icons.Filled.ChatBubble, null, tint = Ink) + } + Text("Message Hermes", color = Ink, fontWeight = FontWeight.SemiBold, fontSize = 20.sp) + Text("Ask a question or send a workspace task. Replies will appear here.", color = Muted, lineHeight = 19.sp) + } +} + +@Composable +fun Bubble(msg: ChatMessage) { + val isUser = msg.role == "You" + val isError = msg.role == "Error" + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start + ) { + Card( + modifier = Modifier.fillMaxWidth(0.86f), + shape = RoundedCornerShape( + topStart = 18.dp, + topEnd = 18.dp, + bottomStart = if (isUser) 18.dp else 4.dp, + bottomEnd = if (isUser) 4.dp else 18.dp + ), + colors = CardDefaults.cardColors(containerColor = when { + isError -> DangerPanel + isUser -> UserBubble + else -> Surface + }), + elevation = CardDefaults.cardElevation(0.dp) + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + TinyLabel(if (isError) "Could not send" else msg.role) + SelectionContainer { + Text( + cleanMessage(msg.content), + color = if (isError) Danger else Ink, + fontWeight = FontWeight.Normal, + lineHeight = 19.sp + ) + } + } } } } -@Composable fun RaisedIcon(icon: ImageVector, color: Color, shadow: Color, size: Int = 66) { Box(Modifier.size(size.dp), contentAlignment = Alignment.Center) { Box(Modifier.matchParentSize().padding(top = 5.dp).clip(CircleShape).background(shadow)); Box(Modifier.matchParentSize().padding(bottom = 5.dp).clip(CircleShape).background(color), contentAlignment = Alignment.Center) { Icon(icon, null, tint = Color.White, modifier = Modifier.size((size / 2).dp)) } } } -@Composable fun RaisedCircle(color: Color, shadow: Color, content: String) { Box(Modifier.size(54.dp), contentAlignment = Alignment.Center) { Box(Modifier.matchParentSize().padding(top = 5.dp).clip(RoundedCornerShape(18.dp)).background(shadow)); Box(Modifier.matchParentSize().padding(bottom = 5.dp).clip(RoundedCornerShape(18.dp)).background(color), contentAlignment = Alignment.Center) { Text(content, fontSize = 24.sp, fontWeight = FontWeight.Black, color = Color.White) } } } -@Composable fun StatusDot(online: Boolean) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(if (online) TealPanel else WarningPanel).padding(horizontal = 10.dp, vertical = 7.dp)) { Box(Modifier.size(8.dp).clip(CircleShape).background(if (online) Teal else Orange)); Text(if (online) "LIVE" else "SETUP", color = if (online) Teal else Orange, fontWeight = FontWeight.Black, fontSize = 11.sp) } } -@Composable fun TinyLabel(text: String) { Text(text.uppercase(), color = Muted, fontSize = 11.sp, fontWeight = FontWeight.Black, letterSpacing = 0.7.sp) } -@Composable fun FieldLabel(text: String) { Text(text.uppercase(), color = Muted, fontSize = 12.sp, fontWeight = FontWeight.Black) } -@Composable fun PathPill(icon: ImageVector, text: String, done: Boolean, current: Boolean = false) { Row(Modifier.clip(RoundedCornerShape(99.dp)).background(if (current) Teal else if (done) TealPanel else Surface).padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { Icon(icon, null, tint = if (current) Color.Black else Teal, modifier = Modifier.size(18.dp)); Text(text, color = if (current) Color.Black else Teal, fontWeight = FontWeight.Black, fontSize = 13.sp) } } -@Composable fun EmptyLesson() { RaisedCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(18.dp), horizontalAlignment = Alignment.CenterHorizontally) { Text("\$ happy", color = Teal, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, fontWeight = FontWeight.Black, fontSize = 24.sp); Text("No messages yet", color = Ink, fontWeight = FontWeight.Black, fontSize = 20.sp); Text("Try: check PM2 status or summarize logs.", color = Muted, fontWeight = FontWeight.ExtraBold) } } } -@Composable fun Bubble(msg: ChatMessage) { RaisedCard(Modifier.fillMaxWidth().padding(start = if (msg.role == "You") 28.dp else 0.dp, end = if (msg.role == "You") 0.dp else 28.dp), color = if (msg.role == "You") TealPanel else Surface) { Column(Modifier.padding(12.dp)) { TinyLabel(msg.role); Text(msg.content, color = Ink, fontWeight = FontWeight.ExtraBold, lineHeight = 19.sp) } } } -@Composable fun ErrorCard(text: String) { RaisedCard(Modifier.fillMaxWidth(), color = PaleRed, shadow = RedShadow, border = RedBorder) { Text(text, Modifier.padding(12.dp), color = Red, fontWeight = FontWeight.Black) } } -@Composable fun CodeCard(text: String) { RaisedCard(Modifier.fillMaxWidth()) { Text(text, Modifier.padding(14.dp), color = BlueDark, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, fontSize = 13.sp, lineHeight = 18.sp) } } -@Composable fun FileRow(entry: FileEntry, onClick: () -> Unit) { RaisedCard(Modifier.fillMaxWidth()) { Row(Modifier.clickable(onClick = onClick).padding(10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { Box(Modifier.size(42.dp).clip(RoundedCornerShape(14.dp)).background(if (entry.type == "directory") PaleBlue else PaleGold), contentAlignment = Alignment.Center) { Icon(if (entry.type == "directory") Icons.Filled.Folder else Icons.Outlined.Article, null, tint = if (entry.type == "directory") Blue else GoldDark) }; Text(entry.name, Modifier.weight(1f), color = Ink, fontWeight = FontWeight.Black, maxLines = 1, overflow = TextOverflow.Ellipsis); Text(if (entry.type == "file") "${entry.size} B" else "dir", color = Muted, fontWeight = FontWeight.Black, fontSize = 12.sp) } } } -@Composable fun Metric(label: String, value: String, modifier: Modifier = Modifier) { RaisedCard(modifier) { Column(Modifier.padding(10.dp), horizontalAlignment = Alignment.CenterHorizontally) { Text(value, color = Teal, fontWeight = FontWeight.Black, fontSize = 18.sp, maxLines = 1, overflow = TextOverflow.Ellipsis); Text(label.uppercase(), color = Muted, fontWeight = FontWeight.Black, fontSize = 10.sp) } } } -@Composable fun AppTextField(value: String, onValue: (String) -> Unit, placeholder: String, minLines: Int = 1, keyboardType: KeyboardType = KeyboardType.Text, password: Boolean = false) { OutlinedTextField(value = value, onValueChange = onValue, modifier = Modifier.fillMaxWidth(), placeholder = { Text(placeholder, color = Muted) }, minLines = minLines, keyboardOptions = KeyboardOptions(keyboardType = keyboardType), visualTransformation = if (password) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None, shape = RoundedCornerShape(16.dp), colors = TextFieldDefaults.colors(focusedContainerColor = Surface, unfocusedContainerColor = Surface, focusedIndicatorColor = Teal, unfocusedIndicatorColor = NeutralBorder, cursorColor = Teal)) } -@Composable fun PrimaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { Button(onClick = onClick, enabled = enabled, modifier = modifier.height(54.dp), shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = Teal, contentColor = Color.Black, disabledContainerColor = NeutralBorder), elevation = ButtonDefaults.buttonElevation(defaultElevation = 6.dp, pressedElevation = 1.dp)) { Text(text, fontWeight = FontWeight.Black); Spacer(Modifier.width(6.dp)); Icon(icon, null) } } -@Composable fun SecondaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier, onClick: () -> Unit) { Button(onClick = onClick, modifier = modifier.height(54.dp), shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = Surface, contentColor = Ink), elevation = ButtonDefaults.buttonElevation(defaultElevation = 4.dp, pressedElevation = 1.dp)) { Text(text, fontWeight = FontWeight.Black); Spacer(Modifier.width(6.dp)); Icon(icon, null) } } -@Composable fun RoundButton(icon: ImageVector, onClick: () -> Unit) { Button(onClick = onClick, modifier = Modifier.size(50.dp), shape = RoundedCornerShape(16.dp), contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp), colors = ButtonDefaults.buttonColors(containerColor = Purple, contentColor = Color.White), elevation = ButtonDefaults.buttonElevation(defaultElevation = 5.dp, pressedElevation = 1.dp)) { Icon(icon, null) } } +@Composable +fun TypingIndicator() { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { + Card( + modifier = Modifier.fillMaxWidth(0.68f), + shape = RoundedCornerShape(18.dp, 18.dp, 18.dp, 4.dp), + colors = CardDefaults.cardColors(containerColor = Surface), + elevation = CardDefaults.cardElevation(0.dp) + ) { + Row(Modifier.padding(horizontal = 13.dp, vertical = 11.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(Modifier.size(8.dp).clip(CircleShape).background(Muted)) + Text("Hermes is working...", color = Muted, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + } + } + } +} + +@Composable +fun NoticeCard(text: String, danger: Boolean = false) { + FlatCard(color = if (danger) DangerPanel else Surface) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Icon(if (danger) Icons.Filled.Info else Icons.Filled.CheckCircle, null, tint = if (danger) Danger else Good, modifier = Modifier.size(18.dp)) + Text(text, Modifier.weight(1f), color = if (danger) Danger else Ink, fontWeight = FontWeight.Normal, lineHeight = 18.sp) + } + } +} + +@Composable +fun CodeCard(text: String) { + FlatCard { + Text(text, Modifier.padding(14.dp), color = CodeText, fontFamily = FontFamily.Monospace, fontSize = 13.sp, lineHeight = 18.sp) + } +} + +@Composable +fun FileRow(entry: FileEntry, onClick: () -> Unit) { + FlatCard { + Row(Modifier.clickable(onClick = onClick).padding(10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box(Modifier.size(38.dp).clip(RoundedCornerShape(8.dp)).background(Surface), contentAlignment = Alignment.Center) { + Icon(if (entry.type == "directory") Icons.Filled.Folder else Icons.Outlined.Article, null, tint = Ink, modifier = Modifier.size(20.dp)) + } + Text(entry.name, Modifier.weight(1f), color = Ink, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(if (entry.type == "file") formatBytes(entry.size) else "dir", color = Muted, fontWeight = FontWeight.SemiBold, fontSize = 12.sp) + } + } +} + +fun formatBytes(size: Long): String { + if (size < 1024) return "$size B" + val kb = size / 1024.0 + if (kb < 1024) return String.format(Locale.US, "%.1f KB", kb) + return String.format(Locale.US, "%.1f MB", kb / 1024.0) +} + +@Composable +fun MetricRow(label: String, value: String) { + FlatCard { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text(label, color = Muted, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, modifier = Modifier.width(104.dp)) + Text(value, color = Ink, fontWeight = FontWeight.Normal, fontSize = 14.sp, modifier = Modifier.weight(1f), maxLines = 4, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +fun TinyLabel(text: String) { + Text(text.uppercase(Locale.US), color = Muted, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.sp) +} + +@Composable +fun FieldLabel(text: String) { + Text(text.uppercase(Locale.US), color = Muted, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.sp) +} + +@Composable +fun AppTextField( + value: String, + onValue: (String) -> Unit, + placeholder: String, + minLines: Int = 1, + keyboardType: KeyboardType = KeyboardType.Text, + password: Boolean = false +) { + OutlinedTextField( + value = value, + onValueChange = onValue, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(placeholder, color = Muted) }, + minLines = minLines, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None, + shape = RoundedCornerShape(12.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Surface, + unfocusedContainerColor = Surface, + focusedIndicatorColor = Muted, + unfocusedIndicatorColor = NeutralBorder, + cursorColor = Ink + ) + ) +} + +@Composable +fun PrimaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button( + onClick = onClick, + enabled = enabled, + modifier = modifier.height(52.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = AppBackground, disabledContainerColor = Surface, disabledContentColor = Muted), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp, pressedElevation = 0.dp) + ) { + Text(text, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.width(6.dp)) + Icon(icon, null) + } +} + +@Composable +fun SecondaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) { + Button( + onClick = onClick, + enabled = enabled, + modifier = modifier.height(52.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors(containerColor = Surface, contentColor = Ink, disabledContainerColor = Surface, disabledContentColor = Muted), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp, pressedElevation = 0.dp) + ) { + Text(text, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.width(6.dp)) + Icon(icon, null) + } +} + +fun colorForStatus(status: String): Color = when (status) { + "done" -> Good + "running" -> Muted + "error" -> Danger + else -> Muted +} -val Teal = Color(0xFF28DBC9) -val TealDark = Color(0xFF0F8F84) -val TealPanel = Color(0xFF0E2B2A) -val TealBorder = Color(0xFF176C66) -val Purple = Color(0xFF6701E6) -val PurpleDark = Color(0xFF3B0087) -val PurplePanel = Color(0xFF211133) -val Blue = Color(0xFF3B82F6) -val BlueDark = Color(0xFF8AB7FF) -val PaleBlue = Color(0xFF111B2B) -val GoldDark = Color(0xFFFBBF24) -val PaleGold = Color(0xFF211B10) -val Orange = Color(0xFFFBBF24) -val WarningPanel = Color(0xFF2B2111) -val WarningBorder = Color(0xFF7A5A1B) -val Red = Color(0xFFF87171) -val PaleRed = Color(0xFF311719) -val RedBorder = Color(0xFF7A363A) -val RedShadow = Color(0xFF0A0304) val AppBackground = Color(0xFF212121) val RowSurface = Color(0xFF303030) val Surface = Color(0xFF2B2B2B) +val UserBubble = Color(0xFF263833) val Ink = Color(0xFFF4F4F5) val Muted = Color(0xFF9CA3AF) -val NeutralBorder = Color(0xFF2A3138) -val NeutralShadow = Color(0xFF030506) -val PanelShadow = Color(0xFF040707) +val NeutralBorder = Color(0xFF3A3A3A) +val Good = Color(0xFF68D391) +val Danger = Color(0xFFF87171) +val DangerPanel = Color(0xFF3A2424) +val CodeText = Color(0xFFC7D2FE) diff --git a/apps/mobile/src/screens/SettingsScreen/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen/SettingsScreen.tsx index c704c6e..c5bfdf1 100644 --- a/apps/mobile/src/screens/SettingsScreen/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen/SettingsScreen.tsx @@ -4,7 +4,7 @@ import { useCompanion } from '../../api/CompanionContext.js'; import { CompanionClient, DEFAULT_COMPANION_URL } from '../../api/companionClient.js'; export function SettingsScreen() { - const { client, settings, updateSettings } = useCompanion(); + const { settings, updateSettings } = useCompanion(); const [baseUrl, setBaseUrl] = useState(settings.baseUrl); const [accessKey, setAccessKey] = useState(settings.accessKey); const [status, setStatus] = useState('Run companion setup, paste the access key, then test the connection.'); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e907d65..00a8d46 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,136 +1,34 @@ -# Roadmap +# Hermes Mobile Beta Roadmap -## Phase 0 — Planning Scaffold +## Current Milestone: Native Android Beta Chat and Connection UX -Status: completed. +Status: in progress, close to beta validation. F0 source landing is complete; Android debug build validation remains environment-blocked. -Deliverables: -- Repo created -- Product plan -- Architecture plan -- Design direction -- Install strategy -- Project structure +Done: +- Native Kotlin/Compose Android shell replaces the web-first mobile path for the beta surface. +- Companion settings flow stores companion URL and access key locally, validates bearer auth, and surfaces health errors in-app. +- Chat UI supports conversation list/thread navigation, native composer, busy state, selectable replies, error bubbles, and new-chat reset. +- Companion `/api/chat` invokes Hermes through the CLI with `hermes chat -Q --source tool -q `. +- Companion chat responses sanitize terminal banners/help/control output, truncate large replies for mobile, report CLI availability, and return structured failure responses. +- Workspace utilities remain native: command runner, health view, file browsing, read, and write. +- Local build artifacts, Android transient build output, `.gradle-user/`, and `.dev/` are ignored. +- Companion and workspace TypeScript typecheck, build, and lint pass as of 2026-07-24. -## Phase 1 — Skeleton +Remaining for beta: +- Validate Android debug build in an environment where Gradle can fetch or reuse the Gradle 8.11.1 distribution. On 2026-07-24, `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` failed before compilation because network socket creation is blocked while downloading `https://services.gradle.org/distributions/gradle-8.11.1-all.zip`. +- Exercise a real paired device against a running companion and authenticated Hermes CLI. +- Exercise upstream-compatible transport/auth/session/streaming behavior end to end: bearer auth, health, chat request/response, session continuity expectations, streamed/final output handling, and failure recovery. +- Add or run focused Android UI validation for chat/settings flows on small and large screens. +- Confirm adaptive accessibility: font scaling, TalkBack labels, contrast, keyboard/IME behavior, and reduced-motion tolerance. +- Document beta install/run steps after the native build is verified. -Goal: empty but runnable monorepo. +## Beta Release Criteria -Status: in progress. - -Deliverables: -- pnpm workspace -- `apps/mobile` Vite React app with Capacitor Android target and PWA fallback -- `apps/companion` Fastify server -- shared TypeScript package for event/API schemas -- basic app shell with bottom nav -- `/api/health` -- local dev scripts - -## Phase 2 — Basic Hermes Prompting - -Goal: phone can send text prompt and receive final answer. - -Deliverables: -- pairing/auth MVP -- Settings connect flow -- task creation endpoint -- Hermes CLI/API adapter MVP -- Ask screen sends prompt -- response display -- task history in SQLite - -## Phase 3 — Realtime Activity + Tool Timeline - -Goal: see Hermes work live. - -Deliverables: -- event bus -- WebSocket/SSE stream -- tool timeline UI -- Python worker adapter using AIAgent callbacks if needed -- coarse/fine tool event mapping - -## Phase 4 — Uploads + Voice - -Goal: prompt with files and voice notes. - -Deliverables: -- multipart upload endpoint -- upload tray UI -- image/archive/pdf metadata -- voice recorder UI -- local STT or Hermes STT bridge -- attach files to task prompt - -## Phase 5 — Notifications - -Goal: close phone and get told when done. - -Deliverables: -- Native Android push path or ntfy/Web Push fallback -- PWA service worker fallback -- notification provider abstraction -- notification permission onboarding -- completion/failure/approval notifications - -## Phase 6 — Cron + Approvals - -Goal: mobile control plane. - -Deliverables: -- cron list/run/pause/resume/remove -- cron output viewer -- approval queue UI -- companion approval API -- Hermes approval integration strategy - -## Phase 7 — Installer + Release - -Goal: one-liner install. - -Deliverables: -- production build -- install.sh -- systemd service -- update/uninstall modes -- release artifact -- pairing URL output - -## Phase 8 — Android Polish - -Goal: feels native. - -Deliverables: -- refined Happy-inspired UI -- animations/microinteractions -- Capacitor Android build pipeline -- adaptive icons/splash -- native status/navigation bars -- share target -- haptics -- offline shell polish -- APK release/signing flow - -## Phase 9 — Deep Hermes Integration - -Goal: full Hermes-native experience. - -Deliverables: -- structured event stream from Hermes core or stable Python adapter -- richer session browsing -- generated file detection -- background process control -- subagent visualization -- memory/skills dashboards possibly - -## Open Feature Ideas - -- Quick action tiles: “Check homelab”, “Run DV status”, “Start coding task”. -- Voice-to-voice mode. -- Per-task model/toolset selection. -- “Watch mode” for long-running coding tasks. -- Home Assistant widgets/actions. -- Secure notification actions: approve/deny from notification. -- Android native share target. -- QR pairing from terminal output. +- Native Kotlin/Compose Android app is the shipped beta client. +- No WebView, Capacitor runtime dependency, fake backend, embedded secrets, or new external infrastructure is introduced for beta. +- Companion transport and auth are upstream-compatible and exercised against the real Hermes CLI. +- Chat/session/streaming behavior is exercised with Hermes, including success, CLI unavailable, timeout, non-zero exit, and malformed response cases. +- Settings connection flow is reliable: save, validate, health, Hermes test, offline/unauthorized states, and recovery are clear. +- Adaptive accessibility passes on target Android devices or emulators. +- Required checks pass: `./gradlew :app:assembleDebug` from `apps/mobile/android`, companion typecheck/build, and relevant lint. +- Release notes clearly list remaining known beta limitations. diff --git a/eslint.config.js b/eslint.config.js index 7ec2141..5010bbf 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -28,4 +28,17 @@ export default [ ...tseslint.configs.recommended.rules, }, }, + { + files: ['apps/mobile/src/**/*.{ts,tsx}'], + languageOptions: { + globals: { + document: 'readonly', + fetch: 'readonly', + HTMLFormElement: 'readonly', + localStorage: 'readonly', + RequestInit: 'readonly', + URL: 'readonly', + }, + }, + }, ];