diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a01e244 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# AGENTS.md + +## Hard rules + +- `hermes-agent/` is a **read-only research reference** (git-ignored). Never commit, push, or modify it — a pre-commit hook (`scripts/guard_hermes_agent.sh --staged`) fails any commit that stages it. Never modify hermes core; we only install our plugin into the live hermes home. +- The plugin is installed by symlink: `~/.hermes/plugins/android` → `/gateway-plugin` (already set up on this machine). + +## Layout + +- `gateway-plugin/` — Python hermes platform plugin (`android`). No build step, zero new deps (stdlib + hermes-provided `websockets`/`httpx`). `protocol.py` is the frame source of truth, mirrored in `app/shared/.../protocol/Protocol.kt` and `docs/protocol/frames.schema.json`. +- `app/` — one Compose Multiplatform Gradle project: `:shared` (KMP, most of the code; `jvmMain` is shared by the android and desktop targets since both are JVM-based), `:androidApp` (thin shell, package `dev.iris.app`), `:desktopApp` (thin shell). +- `docs/` — numbered reference library; read `docs/00-overview.md` first. Locked decisions: `docs/16-open-questions.md`. + +## Commands + +- `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`). +- Gateway: `hermes gateway setup` (one-time; generates `ANDROID_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`. +- Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below). +- Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb). +- Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite). +- Kotlin tests: `cd app && ./gradlew :shared:testDebugUnitTest` / `:shared:desktopTest` (no test sources exist yet). +- WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`. +- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`. + +## Environment / pairing quirks + +- Pairing token: `ANDROID_TOKEN` in `~/.hermes/.env`. The app has **no QR scanner** — pairing is manual URL + token entry. +- WS default bind is `127.0.0.1`; for a phone on the LAN set `ANDROID_WS_HOST` to the gateway's LAN IP. +- `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds. +- `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy. +- JDK 17; no system Gradle — always the wrapper (`./gradlew`). +- Desktop jpackage on Linux/JDK 17 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works. + +## Testing quirks + +- `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `ANDROID_PLUGIN_DIR`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`. +- e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design. +- ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`. +- Gateway logs: `~/.hermes/logs/gateway.log` or `hermes logs --follow`. \ No newline at end of file diff --git a/Screenshot_20260819_083446~2.jpg b/Screenshot_20260819_083446~2.jpg deleted file mode 100644 index ad3bf73..0000000 Binary files a/Screenshot_20260819_083446~2.jpg and /dev/null differ diff --git a/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt b/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt index 233f1e9..a390cb2 100644 --- a/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt +++ b/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt @@ -97,6 +97,14 @@ class AndroidSecureStore(context: Context) : SecureStore { get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty() set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply() + override var threadsEnabled: Boolean + get() = prefs.getBoolean(KEY_THREADS_ENABLED, false) + set(value) = prefs.edit().putBoolean(KEY_THREADS_ENABLED, value).apply() + + override var toolDetail: String + get() = prefs.getString(KEY_TOOL_DETAIL, "truncated").orEmpty().ifBlank { "truncated" } + set(value) = prefs.edit().putString(KEY_TOOL_DETAIL, value).apply() + override fun savePairing(url: String, token: String) { serverUrl = url this.token = token @@ -116,5 +124,7 @@ class AndroidSecureStore(context: Context) : SecureStore { const val KEY_FCM_TOKEN = "fcm_token" const val KEY_NTFY_TOPIC = "ntfy_topic" const val KEY_NTFY_SERVER = "ntfy_server" + const val KEY_THREADS_ENABLED = "threads_enabled" + const val KEY_TOOL_DETAIL = "tool_detail" } } \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt b/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt index 2dacd80..e85d363 100644 --- a/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt +++ b/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt @@ -30,6 +30,12 @@ interface SecureStore { /** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */ var ntfyServer: String + /** UI setting: show threads (topics) in the chat view. */ + var threadsEnabled: Boolean + + /** UI setting: tool verbosity — "everything" / "truncated" / "nothing". */ + var toolDetail: String + fun savePairing(url: String, token: String) fun clear() } \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt index eb11656..ad88a3a 100644 --- a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt +++ b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt @@ -80,8 +80,9 @@ class IrisController( private val _typing = MutableStateFlow(false) val typing: StateFlow = _typing.asStateFlow() - /** Tool-card verbosity (M2). NOTHING suppresses tool cards entirely. */ - private val _toolDetail = MutableStateFlow(ToolDetail.TRUNCATED) + /** Tool-card verbosity (M2). NOTHING suppresses tool cards entirely. + * Persisted (Settings → "Tool detail"). */ + private val _toolDetail = MutableStateFlow(ToolDetail.fromName(store.toolDetail)) val toolDetail: StateFlow = _toolDetail.asStateFlow() fun cycleToolDetail() { @@ -90,6 +91,13 @@ class IrisController( ToolDetail.TRUNCATED -> ToolDetail.NOTHING ToolDetail.NOTHING -> ToolDetail.EVERYTHING } + store.toolDetail = _toolDetail.value.name.lowercase() + } + + fun setToolDetail(detail: ToolDetail) { + if (detail == _toolDetail.value) return + _toolDetail.value = detail + store.toolDetail = detail.name.lowercase() } /** Home channel id (from hello.ack; default until then). */ @@ -101,11 +109,13 @@ class IrisController( val gatewayStatus: StateFlow = _gatewayStatus.asStateFlow() // ── M3: threads toggle (per-app for now; per-channel lands later) ───── - private val _threadsEnabled = MutableStateFlow(false) + // Persisted (Settings → "Threads"). + private val _threadsEnabled = MutableStateFlow(store.threadsEnabled) val threadsEnabled: StateFlow = _threadsEnabled.asStateFlow() fun toggleThreads() { _threadsEnabled.value = !_threadsEnabled.value + store.threadsEnabled = _threadsEnabled.value } // ── M3: search state ────────────────────────────────────────────────── @@ -487,4 +497,11 @@ enum class ToolDetail { EVERYTHING, // name + full args (collapsible) + output preview TRUNCATED, // one-liner "emoji name: preview", collapsible (default) NOTHING, // suppress tool cards entirely + ; + + companion object { + /** Parse a persisted value; unknown/blank falls back to the default. */ + fun fromName(name: String): ToolDetail = + entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: TRUNCATED + } } \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index af79d1e..c1eb0bc 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -49,6 +50,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -70,6 +72,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -107,7 +110,7 @@ import iris.util.localDayKey import kotlinx.coroutines.launch /** - * Chat screen (M3): channel drawer + thread toggle + topic switcher + search, + * Chat screen (M3): channel drawer + topic switcher + search + settings, * on top of the M2 message list / composer. Messages are shown for the current * lane (a channel's flat lane, or a specific thread). */ @@ -132,6 +135,7 @@ fun ChatScreen(controller: IrisController) { var showNewChannel by remember { mutableStateOf(false) } var showNewThread by remember { mutableStateOf(false) } var showPicker by remember { mutableStateOf(false) } + var showSettings by remember { mutableStateOf(false) } val attachments by controller.attachments.collectAsState() val banners by controller.banners.collectAsState() val drawerState = rememberDrawerState(DrawerValue.Closed) @@ -174,7 +178,7 @@ fun ChatScreen(controller: IrisController) { if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false val ctrlOrMeta = e.isCtrlPressed || e.isMetaPressed val overlayOpen = - showSearch || showPicker || showPalette || showNewChannel || showNewThread + showSearch || showPicker || showPalette || showNewChannel || showNewThread || showSettings when { ctrlOrMeta && e.key == Key.N -> { showNewChannel = true; true } ctrlOrMeta && e.key == Key.T -> { showNewThread = true; true } @@ -186,6 +190,7 @@ fun ChatScreen(controller: IrisController) { showPalette = false showNewChannel = false showNewThread = false + showSettings = false true } e.key == Key.Slash && !composerFocused && !overlayOpen -> { @@ -231,12 +236,6 @@ fun ChatScreen(controller: IrisController) { HeaderIconButton(onClick = { showSearch = true }) { Text("🔍", fontSize = 16.sp) } - HeaderIconButton(onClick = { controller.toggleThreads() }) { - Text(if (threadsEnabled) "🧵✓" else "🧵", fontSize = 16.sp) - } - TextButton(onClick = { controller.cycleToolDetail() }) { - Text("tools: ${toolDetail.label}", fontSize = 11.sp) - } if (isDesktop) { IconButton(onClick = { showInspector = !showInspector }) { Box( @@ -290,12 +289,6 @@ fun ChatScreen(controller: IrisController) { HeaderIconButton(onClick = { showSearch = true }) { Text("🔍", fontSize = 16.sp) } - HeaderIconButton(onClick = { controller.toggleThreads() }) { - Text(if (threadsEnabled) "🧵✓" else "🧵", fontSize = 16.sp) - } - TextButton(onClick = { controller.cycleToolDetail() }) { - Text("tools: ${toolDetail.label}", fontSize = 11.sp) - } if (isDesktop) { IconButton(onClick = { showInspector = !showInspector }) { Box( @@ -438,6 +431,8 @@ fun ChatScreen(controller: IrisController) { // Composer (M7: rounded pill + accent circular send button) val canSend = input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null } + val layoutDensity = LocalDensity.current.density + var textHeightPx by remember { mutableFloatStateOf(0f) } Row( modifier = Modifier .fillMaxWidth() @@ -447,34 +442,44 @@ fun ChatScreen(controller: IrisController) { .padding(horizontal = 6.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - IconButton(onClick = { showPicker = true }) { - Text("📎", fontSize = 18.sp) - } - Box( + Row( modifier = Modifier .weight(1f) - .heightIn(min = 40.dp, max = 160.dp) .padding(horizontal = 6.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, ) { - if (input.isEmpty()) { - Text( - "Message", - color = IrisColors.textDim, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.align(Alignment.TopStart), + Box(modifier = Modifier.weight(1f)) { + BasicTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = if (textHeightPx > 0f) (textHeightPx / layoutDensity).dp else Dp.Unspecified) + .focusRequester(composerFocusRequester) + .onFocusChanged { composerFocused = it.isFocused }, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = IrisColors.onSurface), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { doSend() }), + onTextLayout = { result -> + if (result.lineCount > 0) { + val totalHeight = result.getLineBottom(result.lineCount - 1) + val lineHeight = totalHeight / result.lineCount + textHeightPx = minOf(totalHeight, lineHeight * 5f) + } + }, ) + if (input.isEmpty()) { + Text( + "Message", + color = IrisColors.textDim, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.align(Alignment.TopStart), + ) + } + } + IconButton(onClick = { showPicker = true }) { + Text("📎", fontSize = 18.sp) } - BasicTextField( - value = input, - onValueChange = { input = it }, - modifier = Modifier - .fillMaxSize() - .focusRequester(composerFocusRequester) - .onFocusChanged { composerFocused = it.isFocused }, - textStyle = MaterialTheme.typography.bodyLarge.copy(color = IrisColors.onSurface), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { doSend() }), - ) } Box( modifier = Modifier @@ -537,6 +542,7 @@ fun ChatScreen(controller: IrisController) { onNewChannel = { showNewChannel = true }, onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) }, onDelete = { ch -> controller.deleteChannel(ch.chatId) }, + onOpenSettings = { showSettings = true }, ) Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(IrisColors.divider)) Box(modifier = Modifier.weight(1f).fillMaxHeight()) { @@ -550,8 +556,6 @@ fun ChatScreen(controller: IrisController) { currentChannel = currentChannel, threads = threads, items = items, - toolDetail = toolDetail, - threadsEnabled = threadsEnabled, ) } } @@ -574,6 +578,10 @@ fun ChatScreen(controller: IrisController) { }, onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) }, onDelete = { ch -> controller.deleteChannel(ch.chatId) }, + onOpenSettings = { + showSettings = true + drawerScope.launch { drawerState.close() } + }, ) } }, @@ -612,6 +620,11 @@ fun ChatScreen(controller: IrisController) { ) } + // Settings overlay (full screen; covers the drawer) + if (showSettings) { + SettingsScreen(controller = controller, onBack = { showSettings = false }) + } + // New channel dialog if (showNewChannel) { NameDialog( @@ -741,7 +754,7 @@ private fun TitlePill(channelName: String, modifier: Modifier = Modifier) { /** M7: compact 36dp header icon button (smaller than the 48dp default). */ @Composable -private fun HeaderIconButton(onClick: () -> Unit, content: @Composable () -> Unit) { +internal fun HeaderIconButton(onClick: () -> Unit, content: @Composable () -> Unit) { Box( modifier = Modifier .size(36.dp) @@ -804,7 +817,7 @@ private fun LetterAvatar(name: String, size: Dp, color: Color) { } } -/** Left drawer: channel list + create / set-default / delete. */ +/** Left drawer: channel list + create / set-default / delete / settings. */ @Composable private fun ChannelDrawer( channels: List, @@ -813,6 +826,7 @@ private fun ChannelDrawer( onNewChannel: () -> Unit, onSetDefault: (ChannelInfo) -> Unit, onDelete: (ChannelInfo) -> Unit, + onOpenSettings: () -> Unit, ) { Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { Text( @@ -858,6 +872,10 @@ private fun ChannelDrawer( Button(onClick = onNewChannel, modifier = Modifier.fillMaxWidth()) { Text("+ New channel") } + Spacer(modifier = Modifier.height(4.dp)) + OutlinedButton(onClick = onOpenSettings, modifier = Modifier.fillMaxWidth()) { + Text("⚙ Settings") + } } } @@ -876,6 +894,7 @@ private fun ChannelRail( onNewChannel: () -> Unit, onSetDefault: (ChannelInfo) -> Unit, onDelete: (ChannelInfo) -> Unit, + onOpenSettings: () -> Unit, ) { Column( modifier = Modifier @@ -965,10 +984,14 @@ private fun ChannelRail( Button(onClick = onNewChannel, modifier = Modifier.fillMaxWidth()) { Text("+ New channel") } + Spacer(modifier = Modifier.height(4.dp)) + OutlinedButton(onClick = onOpenSettings, modifier = Modifier.fillMaxWidth()) { + Text("⚙ Settings") + } } } -/** M6: optional third pane — session info, channel info, last reply, settings. */ +/** M6: optional third pane — session info, channel info, last reply. */ @Composable private fun InspectorPane( controller: IrisController, @@ -976,8 +999,6 @@ private fun InspectorPane( currentChannel: ChannelInfo?, threads: List, items: List, - toolDetail: ToolDetail, - threadsEnabled: Boolean, ) { val lastAssistant = items.lastOrNull { it is MessageItem && it.role == ROLE_ASSISTANT && !it.streaming @@ -1007,16 +1028,6 @@ private fun InspectorPane( Text("Last reply", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp)) InfoRow("Model", lastAssistant?.model ?: "—") InfoRow("Tokens", lastAssistant?.tokens?.toString() ?: "—") - Spacer(modifier = Modifier.height(8.dp)) - Text("Settings", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp)) - Row { - TextButton(onClick = { controller.cycleToolDetail() }) { - Text("tools: ${toolDetail.label}") - } - TextButton(onClick = { controller.toggleThreads() }) { - Text(if (threadsEnabled) "threads: on" else "threads: off") - } - } } } @@ -1104,7 +1115,7 @@ private fun TopicSwitcher( } @Composable -private fun TopicChip(label: String, selected: Boolean, onClick: () -> Unit) { +internal fun TopicChip(label: String, selected: Boolean, onClick: () -> Unit) { Box( modifier = Modifier .clip(RoundedCornerShape(12.dp)) @@ -1683,7 +1694,7 @@ private fun ToolCard(tool: ToolItem, detail: ToolDetail) { } } -private val ToolDetail.label: String +internal val ToolDetail.label: String get() = when (this) { ToolDetail.EVERYTHING -> "everything" ToolDetail.TRUNCATED -> "truncated" diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/SettingsScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..abdfdfb --- /dev/null +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/SettingsScreen.kt @@ -0,0 +1,149 @@ +package iris.ui.screens + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import iris.state.IrisController +import iris.state.ToolDetail +import iris.ui.theme.IrisColors + +/** + * Settings screen (post-M7): app-level preferences, opened from the channel + * drawer / rail. More settings land here over time. + */ +@Composable +fun SettingsScreen(controller: IrisController, onBack: () -> Unit) { + val threadsEnabled by controller.threadsEnabled.collectAsState() + val toolDetail by controller.toolDetail.collectAsState() + + Box(modifier = Modifier.fillMaxSize().background(IrisColors.background)) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 12.dp), + ) { + HeaderIconButton(onClick = onBack) { + BackArrow() + } + Spacer(modifier = Modifier.width(8.dp)) + Text("Settings", style = MaterialTheme.typography.titleMedium) + } + + Text( + "Chat", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(bottom = 4.dp), + ) + SettingsCard { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.weight(1f)) { + Text("🧵 Threads", fontSize = 14.sp) + Text( + "Show topics in the chat view", + fontSize = 12.sp, + color = IrisColors.textDim, + ) + } + Switch( + checked = threadsEnabled, + onCheckedChange = { controller.toggleThreads() }, + ) + } + } + + Text( + "Tools", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), + ) + SettingsCard { + Text("🔧 Tool detail", fontSize = 14.sp) + Text( + "How much tool activity to show", + fontSize = 12.sp, + color = IrisColors.textDim, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + ToolDetail.entries.forEach { d -> + TopicChip( + label = d.label.replaceFirstChar { it.uppercaseChar() }, + selected = toolDetail == d, + onClick = { controller.setToolDetail(d) }, + ) + } + } + } + } + } +} + +/** Material-style back arrow (vector, matches the app's icon weight). */ +@Composable +private fun BackArrow(size: Dp = 22.dp, color: Color = IrisColors.onSurface) { + Canvas(modifier = Modifier.size(size)) { + val s = this.size.width / 24f + val path = Path().apply { + moveTo(20f * s, 11f * s) + lineTo(7.83f * s, 11f * s) + lineTo(13.42f * s, 5.41f * s) + lineTo(12f * s, 4f * s) + lineTo(4f * s, 12f * s) + lineTo(12f * s, 20f * s) + lineTo(13.41f * s, 18.59f * s) + lineTo(7.83f * s, 13f * s) + lineTo(20f * s, 13f * s) + close() + } + drawPath(path, color) + } +} + +/** Settings row container (panel card). */ +@Composable +private fun SettingsCard(content: @Composable () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(IrisColors.panel) + .padding(12.dp), + ) { + content() + } +} \ No newline at end of file diff --git a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt index 47fe06b..11eb9c5 100644 --- a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt +++ b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt @@ -34,6 +34,8 @@ class DesktopSecureStore : SecureStore { val fcmToken: String = "", val ntfyTopic: String = "", val ntfyServer: String = "", + val threadsEnabled: Boolean = false, + val toolDetail: String = "truncated", ) init { @@ -133,6 +135,20 @@ class DesktopSecureStore : SecureStore { save(d.copy(ntfyServer = value)) } + override var threadsEnabled: Boolean + get() = load().threadsEnabled + set(value) { + val d = load() + save(d.copy(threadsEnabled = value)) + } + + override var toolDetail: String + get() = load().toolDetail.ifBlank { "truncated" } + set(value) { + val d = load() + save(d.copy(toolDetail = value)) + } + override fun savePairing(url: String, token: String) { val d = load() save(d.copy(serverUrl = url.trim()))