git.iko.soy/opencode-android
on master help sign in
Implement 11 major features: offline outbox, usage dashboard, message actions, code UI, chat typography, keyboard shortcuts, widget/tile/shortcuts, MCP viewer, backup/restore, cert pinning, CI improvements
commit5eaa0b797d33b621e7ecaa98021461aac42c14b4 authoriko <mail@iko.soy> dateThu 2 Jul 2026, 10:16 (3 months ago) parentaeac232
49 files changed+2847−63
.github/workflows/build.yml+6 app/detekt-baseline.xml+3−2 app/src/main/AndroidManifest.xml+30 app/src/main/java/soy/iko/opencode/MainActivity.kt+15−1 app/src/main/java/soy/iko/opencode/data/model/ServerProfile.kt+6 app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+59−27 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+6 app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+115 app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+111 app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+8 app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.kt+43 app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+36 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+78 app/src/main/java/soy/iko/opencode/platform/AppShortcuts.kt+58 app/src/main/java/soy/iko/opencode/platform/NewSessionTileService.kt+33 app/src/main/java/soy/iko/opencode/platform/SessionsWidgetProvider.kt+83 app/src/main/java/soy/iko/opencode/platform/SessionsWidgetService.kt+47 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+40 app/src/main/java/soy/iko/opencode/ui/Routes.kt+2 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+119 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+142−2 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+67−2 app/src/main/java/soy/iko/opencode/ui/components/ChatTextPrefs.kt+51 app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.kt+102 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+93−26 app/src/main/java/soy/iko/opencode/ui/components/PaletteAction.kt+11 app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+140 app/src/main/java/soy/iko/opencode/ui/mcp/McpServerInfo.kt+45 app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+44 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+51 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+31−2 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+18 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+1 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+238−1 app/src/main/java/soy/iko/opencode/ui/usage/UsageAggregation.kt+106 app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+224 app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+70 app/src/main/res/drawable/widget_background.xml+6 app/src/main/res/layout/widget_session_item.xml+21 app/src/main/res/layout/widget_sessions.xml+64 app/src/main/res/values-es/strings.xml+103 app/src/main/res/values/strings.xml+103 app/src/main/res/xml/sessions_widget_info.xml+10 app/src/test/java/soy/iko/opencode/data/repo/OutboxStoreTest.kt+44 app/src/test/java/soy/iko/opencode/ui/components/ExtractFenceLanguageTest.kt+28 app/src/test/java/soy/iko/opencode/ui/mcp/McpParserTest.kt+52 app/src/test/java/soy/iko/opencode/ui/server/CertPinValidationTest.kt+32 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+4 app/src/test/java/soy/iko/opencode/ui/usage/UsageAggregationTest.kt+48
.github/workflows/build.yml+6
@@ -5,6 +5,12 @@ on:
55 branches: [master]
66 pull_request:
77
8+# Cancel an in-progress run when a newer commit is pushed to the same ref, so CI minutes
9+# aren't spent finishing a build that's already superseded.
10+concurrency:
11+ group: ${{ github.workflow }}-${{ github.ref }}
12+ cancel-in-progress: true
13+
814jobs:
915 build:
1016 runs-on: ubuntu-latest
app/detekt-baseline.xml+3−2
@@ -4,13 +4,14 @@
44 <CurrentIssues>
55 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
66 <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
7− <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, )</ID>
7+ <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, onOpenSession: ((String) -&gt; Unit)? = null, )</ID>
88 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
9+ <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
10+ <ID>CyclomaticComplexMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class) @Composable fun SettingsScreen( container: AppContainer, onBack: () -&gt; Unit, onManageServers: () -&gt; Unit, onOpenDiagnostics: () -&gt; Unit, onOpenUsage: () -&gt; Unit = {}, onOpenMcp: () -&gt; Unit = {}, )</ID>
911 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
1012 <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
1113 <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
1214 <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
1315 <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
14− <ID>UnusedParameter:MarkdownText.kt$style: TextStyle = MaterialTheme.typography.bodyLarge</ID>
1516 </CurrentIssues>
1617</SmellBaseline>
app/src/main/AndroidManifest.xml+30
@@ -71,6 +71,36 @@
7171 android:name=".notification.NotificationActionReceiver"
7272 android:exported="false" />
7373
74+ <!-- Home-screen widget listing recent sessions. -->
75+ <receiver
76+ android:name=".platform.SessionsWidgetProvider"
77+ android:exported="true">
78+ <intent-filter>
79+ <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
80+ </intent-filter>
81+ <meta-data
82+ android:name="android.appwidget.provider"
83+ android:resource="@xml/sessions_widget_info" />
84+ </receiver>
85+
86+ <!-- Serves the widget's session list rows. Bound by the system with BIND_REMOTEVIEWS. -->
87+ <service
88+ android:name=".platform.SessionsWidgetService"
89+ android:permission="android.permission.BIND_REMOTEVIEWS"
90+ android:exported="false" />
91+
92+ <!-- Quick-settings tile: start a new session. Bound by the system. -->
93+ <service
94+ android:name=".platform.NewSessionTileService"
95+ android:icon="@drawable/ic_launcher_foreground"
96+ android:label="@string/tile_new_session"
97+ android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
98+ android:exported="true">
99+ <intent-filter>
100+ <action android:name="android.service.quicksettings.action.QS_TILE" />
101+ </intent-filter>
102+ </service>
103+
74104 <!-- Serves camera-capture temp files (cache/captures) to the system camera app. -->
75105 <provider
76106 android:name="androidx.core.content.FileProvider"
app/src/main/java/soy/iko/opencode/MainActivity.kt+15−1
@@ -37,6 +37,7 @@ class MainActivity : FragmentActivity() {
3737
3838 private var shareIntentHandled = false
3939 private var openSessionHandled = false
40+ private var newSessionHandled = false
4041
4142 override fun onCreate(savedInstanceState: Bundle?) {
4243 val splash = installSplashScreen()
@@ -45,6 +46,7 @@ class MainActivity : FragmentActivity() {
4546 savedInstanceState?.let {
4647 shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
4748 openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
49+ newSessionHandled = it.getBoolean(KEY_NEW_SESSION_HANDLED, false)
4850 }
4951 val container = (application as OpencodeApp).container
5052 handleIntent(intent)
@@ -87,6 +89,7 @@ class MainActivity : FragmentActivity() {
8789 setIntent(intent)
8890 shareIntentHandled = false
8991 openSessionHandled = false
92+ newSessionHandled = false
9093 handleIntent(intent)
9194 }
9295
@@ -109,6 +112,12 @@ class MainActivity : FragmentActivity() {
109112 private fun handleIntent(intent: Intent?) {
110113 val container = (application as OpencodeApp).container
111114 val action = intent?.action
115+ // "New session" from a launcher shortcut or the quick-settings tile. Guarded so a
116+ // process-death restore (which re-delivers the intent) doesn't spawn a second session.
117+ if (action == ACTION_NEW_SESSION && !newSessionHandled) {
118+ container.requestNewSession()
119+ newSessionHandled = true
120+ }
112121 if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) {
113122 val type = intent.type.orEmpty()
114123 if (action == Intent.ACTION_SEND && type.startsWith("image/")) {
@@ -157,20 +166,25 @@ class MainActivity : FragmentActivity() {
157166 }
158167
159168 companion object {
160− /** Intent extra carrying a session id to open (notifications / deep links). */
169+ /** Intent extra carrying a session id to open (notifications / deep links / widget). */
161170 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
162171
172+ /** Action for the "New session" launcher shortcut and quick-settings tile. */
173+ const val ACTION_NEW_SESSION = "soy.iko.opencode.action.NEW_SESSION"
174+
163175 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
164176 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
165177
166178 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
167179 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
180+ private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled"
168181 }
169182
170183 override fun onSaveInstanceState(outState: Bundle) {
171184 super.onSaveInstanceState(outState)
172185 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
173186 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled)
187+ outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled)
174188 }
175189
176190 /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
app/src/main/java/soy/iko/opencode/data/model/ServerProfile.kt+6
@@ -14,6 +14,12 @@ data class ServerProfile(
1414 val username: String? = null,
1515 val password: String? = null,
1616 val lastUsed: Long = 0,
17+ // When true, the client upgrades a cleartext http:// URL to https:// so credentials and
18+ // traffic are never sent over the wire in the clear for this server.
19+ val requireHttps: Boolean = false,
20+ // Optional OkHttp certificate pin(s) ("sha256/<base64>", whitespace/comma-separated for
21+ // multiple) applied to this server's TLS connection for defense against a rogue CA.
22+ val certPin: String? = null,
1723) {
1824 val hasAuth: Boolean get() = !username.isNullOrBlank()
1925
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+59−27
@@ -14,6 +14,7 @@ import io.ktor.client.request.header
1414import io.ktor.http.URLBuilder
1515import io.ktor.http.takeFrom
1616import io.ktor.serialization.kotlinx.json.json
17+import okhttp3.CertificatePinner
1718import java.util.concurrent.TimeUnit
1819
1920/**
@@ -23,39 +24,53 @@ import java.util.concurrent.TimeUnit
2324 */
2425object HttpClientFactory {
2526
26− fun create(profile: ServerProfile): HttpClient = HttpClient(OkHttp) {
27− expectSuccess = true
27+ fun create(profile: ServerProfile): HttpClient {
28+ // Resolve per-profile TLS options up front so they're available inside the engine
29+ // config: optionally upgrade a cleartext http:// URL to https:// ("Require HTTPS"),
30+ // and pin the server certificate(s) for HTTPS connections.
31+ val normalizedUrl = effectiveBaseUrl(profile)
32+ val isHttps = normalizedUrl.lowercase().startsWith("https://")
33+ val pinHost = runCatching { java.net.URI(normalizedUrl).host }.getOrNull()
34+ val pins = parsePins(profile.certPin)
2835
29− engine {
30− config {
31− // A normal read timeout for REST calls. The SSE stream overrides this
32− // to infinite inside the sse {} request block (see EventStreamClient),
33− // so the long-lived event connection is never killed mid-stream.
34− readTimeout(NetworkConfig.readTimeoutSeconds, TimeUnit.SECONDS)
35− connectTimeout(NetworkConfig.connectTimeoutSeconds, TimeUnit.SECONDS)
36− retryOnConnectionFailure(true)
37− pingInterval(NetworkConfig.pingIntervalSeconds, TimeUnit.SECONDS)
38− }
39− }
36+ return HttpClient(OkHttp) {
37+ expectSuccess = true
4038
41− install(ContentNegotiation) {
42− json(OpencodeJson)
43− }
39+ engine {
40+ config {
41+ // A normal read timeout for REST calls. The SSE stream overrides this
42+ // to infinite inside the sse {} request block (see EventStreamClient),
43+ // so the long-lived event connection is never killed mid-stream.
44+ readTimeout(NetworkConfig.readTimeoutSeconds, TimeUnit.SECONDS)
45+ connectTimeout(NetworkConfig.connectTimeoutSeconds, TimeUnit.SECONDS)
46+ retryOnConnectionFailure(true)
47+ pingInterval(NetworkConfig.pingIntervalSeconds, TimeUnit.SECONDS)
48+ // Pin the server certificate(s) when configured and connecting over TLS.
49+ // A malformed pin throws here (surfaced as a connection failure), which is
50+ // the right fail-closed behavior for a security control the user opted into.
51+ if (pins.isNotEmpty() && pinHost != null && isHttps) {
52+ certificatePinner(
53+ CertificatePinner.Builder().apply { pins.forEach { add(pinHost, it) } }.build(),
54+ )
55+ }
56+ }
57+ }
4458
45− install(SSE)
59+ install(ContentNegotiation) {
60+ json(OpencodeJson)
61+ }
4662
47− // Request-level timeout for REST calls. The SSE stream overrides this to
48− // infinite inside the sse {} request block (see EventStreamClient) so the
49− // long-lived /event connection isn't killed after 60s. The engine's connect
50− // timeout and the SSE idle-timeout watchdog handle stuck SSE connections.
51− install(HttpTimeout) {
52− requestTimeoutMillis = NetworkConfig.restRequestTimeoutMs
53− }
63+ install(SSE)
5464
55− val normalizedUrl = normalizeBaseUrl(profile.baseUrl)
56− val isHttps = normalizedUrl.lowercase().startsWith("https://")
65+ // Request-level timeout for REST calls. The SSE stream overrides this to
66+ // infinite inside the sse {} request block (see EventStreamClient) so the
67+ // long-lived /event connection isn't killed after 60s. The engine's connect
68+ // timeout and the SSE idle-timeout watchdog handle stuck SSE connections.
69+ install(HttpTimeout) {
70+ requestTimeoutMillis = NetworkConfig.restRequestTimeoutMs
71+ }
5772
58− if (profile.hasAuth) {
73+ if (profile.hasAuth) {
5974 if (isHttps) {
6075 install(Auth) {
6176 basic {
@@ -94,8 +109,25 @@ object HttpClientFactory {
94109 header("Authorization", "Basic $credentials")
95110 }
96111 }
112+ }
113+ }
114+
115+ /** The base URL after applying [ServerProfile.requireHttps]: a cleartext http:// URL is
116+ * upgraded to https:// so the "Require HTTPS" choice is enforced at the transport layer. */
117+ private fun effectiveBaseUrl(profile: ServerProfile): String {
118+ val normalized = normalizeBaseUrl(profile.baseUrl)
119+ return if (profile.requireHttps && normalized.lowercase().startsWith("http://")) {
120+ "https://" + normalized.substring("http://".length)
121+ } else {
122+ normalized
123+ }
97124 }
98125
126+ /** Split a certificate-pin field into individual OkHttp pins. Accepts whitespace- or
127+ * comma-separated "sha256/<base64>" entries; blank entries are dropped. */
128+ private fun parsePins(raw: String?): List<String> =
129+ raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
130+
99131 /** Ensure a scheme and a single trailing slash so relative paths resolve correctly.
100132 * Defaults to [https] when no scheme is given so credentials aren't accidentally
101133 * sent over cleartext. Users who need plain HTTP must type the scheme explicitly. */
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+6
@@ -267,6 +267,12 @@ open class OpencodeApiClient private constructor(
267267 value
268268 }
269269
270+ /** The server's resolved configuration as a raw JSON object. Parsed loosely by callers
271+ * (e.g. the MCP viewer) so a schema addition on the server never breaks decoding. */
272+ open suspend fun config(): kotlinx.serialization.json.JsonObject = withRetry {
273+ client!!.get("config").body()
274+ }
275+
270276 open suspend fun agents(): List<Agent> = agentsMutex.withLock {
271277 val now = System.currentTimeMillis()
272278 val cached = cachedAgents
app/src/main/java/soy/iko/opencode/data/repo/BackupManager.ktnew file+115
@@ -0,0 +1,115 @@
1+package soy.iko.opencode.data.repo
2+
3+import kotlinx.coroutines.flow.first
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.decodeFromString
6+import kotlinx.serialization.encodeToString
7+import kotlinx.serialization.json.Json
8+import soy.iko.opencode.data.model.ServerProfile
9+
10+/** A server entry in a backup. [password] is present only when the user opted to include it. */
11+@Serializable
12+data class BackupServer(
13+ val id: String,
14+ val label: String,
15+ val baseUrl: String,
16+ val username: String? = null,
17+ val password: String? = null,
18+ val requireHttps: Boolean = false,
19+ val certPin: String? = null,
20+)
21+
22+/** App preferences in a backup. */
23+@Serializable
24+data class BackupSettings(
25+ val themeMode: String,
26+ val dynamicColor: Boolean,
27+ val sendOnEnter: Boolean,
28+ val appLock: Boolean,
29+ val chatTextScale: Float,
30+ val codeWrap: Boolean,
31+)
32+
33+/** The full backup document. [version] guards against future format changes. */
34+@Serializable
35+data class BackupData(
36+ val version: Int = 1,
37+ val servers: List<BackupServer> = emptyList(),
38+ val settings: BackupSettings? = null,
39+ val pinned: List<String> = emptyList(),
40+ val archived: List<String> = emptyList(),
41+)
42+
43+/**
44+ * Exports/imports the app's servers, settings, and per-session flags as a single JSON
45+ * document (via the Storage Access Framework in the UI). Passwords are only written when the
46+ * user explicitly opts in, since the exported file is plaintext and leaves the app's encrypted
47+ * store.
48+ */
49+class BackupManager(
50+ private val profileStore: ProfileStore,
51+ private val settingsStore: SettingsStore,
52+ private val sessionPrefsStore: SessionPrefsStore,
53+) {
54+ private val json = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true }
55+
56+ /** Build the backup JSON. Reads the current profiles/settings/flags. */
57+ suspend fun export(includePasswords: Boolean): String {
58+ val servers = profileStore.profiles.first().map {
59+ BackupServer(
60+ id = it.id,
61+ label = it.label,
62+ baseUrl = it.baseUrl,
63+ username = it.username,
64+ password = if (includePasswords) it.password else null,
65+ requireHttps = it.requireHttps,
66+ certPin = it.certPin,
67+ )
68+ }
69+ val settings = BackupSettings(
70+ themeMode = settingsStore.themeMode.first().name,
71+ dynamicColor = settingsStore.dynamicColor.first(),
72+ sendOnEnter = settingsStore.sendOnEnter.first(),
73+ appLock = settingsStore.appLock.first(),
74+ chatTextScale = settingsStore.chatTextScale.first(),
75+ codeWrap = settingsStore.codeWrap.first(),
76+ )
77+ val data = BackupData(
78+ servers = servers,
79+ settings = settings,
80+ pinned = sessionPrefsStore.pinned.first().toList(),
81+ archived = sessionPrefsStore.archived.first().toList(),
82+ )
83+ return json.encodeToString(data)
84+ }
85+
86+ /** Apply a backup document. Throws on malformed JSON so the caller can report it. Servers
87+ * are upserted (existing ids overwritten); settings and flags are restored best-effort. */
88+ suspend fun import(text: String) {
89+ val data = json.decodeFromString<BackupData>(text)
90+ for (s in data.servers) {
91+ profileStore.save(
92+ ServerProfile(
93+ id = s.id,
94+ label = s.label,
95+ baseUrl = s.baseUrl,
96+ username = s.username?.takeIf { it.isNotBlank() },
97+ password = s.password?.takeIf { it.isNotEmpty() },
98+ lastUsed = 0,
99+ requireHttps = s.requireHttps,
100+ certPin = s.certPin?.takeIf { it.isNotBlank() },
101+ ),
102+ )
103+ }
104+ data.settings?.let { settings ->
105+ runCatching { settingsStore.setThemeMode(ThemeMode.valueOf(settings.themeMode)) }
106+ settingsStore.setDynamicColor(settings.dynamicColor)
107+ settingsStore.setSendOnEnter(settings.sendOnEnter)
108+ settingsStore.setAppLock(settings.appLock)
109+ settingsStore.setChatTextScale(settings.chatTextScale)
110+ settingsStore.setCodeWrap(settings.codeWrap)
111+ }
112+ data.pinned.forEach { sessionPrefsStore.setPinned(it, true) }
113+ data.archived.forEach { sessionPrefsStore.setArchived(it, true) }
114+ }
115+}
app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.ktnew file+111
@@ -0,0 +1,111 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import android.util.Log
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.flow.MutableStateFlow
7+import kotlinx.coroutines.flow.StateFlow
8+import kotlinx.coroutines.flow.asStateFlow
9+import kotlinx.coroutines.sync.Mutex
10+import kotlinx.coroutines.sync.withLock
11+import kotlinx.coroutines.withContext
12+import kotlinx.serialization.Serializable
13+import kotlinx.serialization.decodeFromString
14+import kotlinx.serialization.encodeToString
15+import kotlinx.serialization.json.Json
16+import soy.iko.opencode.data.model.ModelRef
17+import soy.iko.opencode.util.runCatchingCancellable
18+import java.io.File
19+
20+/**
21+ * One prompt composed while offline (or with no active connection), queued to send when
22+ * connectivity returns. Tagged with the [profileId] it was composed for so a reconnect to a
23+ * *different* server doesn't misfire it against a session id that only exists on the
24+ * original server. [attachments] carry their self-contained base64 data URLs, so a queued
25+ * image survives a process restart just like a staged attachment.
26+ */
27+@Serializable
28+data class OutboxMessage(
29+ val id: String,
30+ val profileId: String,
31+ val sessionId: String,
32+ val text: String,
33+ val attachments: List<PersistedAttachment> = emptyList(),
34+ val model: ModelRef? = null,
35+ val agent: String? = null,
36+ val createdAt: Long,
37+)
38+
39+/**
40+ * A persisted send-queue: prompts the user composed while disconnected, flushed on
41+ * reconnect (see [soy.iko.opencode.di.AppContainer.observeOutbox]). Backed by one JSON file
42+ * under filesDir (not SharedPreferences) because a queued attachment's base64 payload can be
43+ * several megabytes. The in-memory [messages] StateFlow is the source of truth the UI and the
44+ * flusher observe; disk is written through under a mutex on every mutation.
45+ */
46+open class OutboxStore private constructor(
47+ private val appContext: Context?,
48+ @Suppress("unused") private val testMode: Boolean,
49+) {
50+ constructor(context: Context) : this(context.applicationContext, false)
51+ protected constructor() : this(null, true)
52+
53+ private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
54+ private val ioMutex = Mutex()
55+
56+ private val _messages = MutableStateFlow<List<OutboxMessage>>(emptyList())
57+ /** All queued messages across sessions/servers, in enqueue order. */
58+ open val messages: StateFlow<List<OutboxMessage>> = _messages.asStateFlow()
59+
60+ private val file: File? by lazy {
61+ appContext?.let { File(it.filesDir, "outbox.json") }
62+ }
63+
64+ /** Read the persisted queue into [messages]. Call once at startup. Idempotent. */
65+ open suspend fun load() {
66+ val f = file?.takeIf { it.exists() } ?: return
67+ val loaded = withContext(Dispatchers.IO) {
68+ runCatchingCancellable { json.decodeFromString<List<OutboxMessage>>(f.readText()) }
69+ .getOrDefault(emptyList())
70+ }
71+ // Don't clobber items enqueued while the load was in flight: merge by id, keeping
72+ // the in-memory version on conflict (it's newer).
73+ ioMutex.withLock {
74+ if (_messages.value.isEmpty()) {
75+ _messages.value = loaded.sortedBy { it.createdAt }
76+ } else {
77+ val known = _messages.value.mapTo(mutableSetOf()) { it.id }
78+ _messages.value = (loaded.filter { it.id !in known } + _messages.value).sortedBy { it.createdAt }
79+ }
80+ }
81+ }
82+
83+ open suspend fun enqueue(message: OutboxMessage) = mutate { current ->
84+ current.filterNot { it.id == message.id } + message
85+ }
86+
87+ open suspend fun remove(id: String) = mutate { current -> current.filterNot { it.id == id } }
88+
89+ /** Remove every queued message for [sessionId] (e.g. the user discarded them, or the
90+ * session was deleted). */
91+ open suspend fun removeForSession(sessionId: String) =
92+ mutate { current -> current.filterNot { it.sessionId == sessionId } }
93+
94+ private suspend fun mutate(transform: (List<OutboxMessage>) -> List<OutboxMessage>) {
95+ val snapshot = ioMutex.withLock {
96+ val updated = transform(_messages.value).sortedBy { it.createdAt }
97+ _messages.value = updated
98+ updated
99+ }
100+ persist(snapshot)
101+ }
102+
103+ private suspend fun persist(list: List<OutboxMessage>) {
104+ val f = file ?: return
105+ withContext(Dispatchers.IO) {
106+ runCatchingCancellable {
107+ if (list.isEmpty()) f.delete() else f.writeText(json.encodeToString(list))
108+ }.onFailure { Log.w("OutboxStore", "Failed to persist outbox", it) }
109+ }
110+ }
111+}
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+8
@@ -131,6 +131,10 @@ open class ProfileStore private constructor(
131131 val baseUrl: String,
132132 val username: String? = null,
133133 val lastUsed: Long = 0,
134+ // Non-secret TLS options; default to prior behavior for profiles saved before these
135+ // fields existed (deserialization uses these defaults for missing keys).
136+ val requireHttps: Boolean = false,
137+ val certPin: String? = null,
134138 )
135139
136140 open val profiles: Flow<List<ServerProfile>> = appContext?.dataStore?.data?.map { prefs ->
@@ -164,6 +168,8 @@ open class ProfileStore private constructor(
164168 username = username,
165169 password = pw,
166170 lastUsed = lastUsed,
171+ requireHttps = requireHttps,
172+ certPin = certPin,
167173 )
168174 }
169175
@@ -210,6 +216,8 @@ open class ProfileStore private constructor(
210216 baseUrl = profile.baseUrl,
211217 username = profile.username?.takeIf { it.isNotBlank() },
212218 lastUsed = preservedLastUsed,
219+ requireHttps = profile.requireHttps,
220+ certPin = profile.certPin?.takeIf { it.isNotBlank() },
213221 )
214222 val updated = current.filterNot { it.id == profile.id } + stored
215223 prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.ktnew file+43
@@ -0,0 +1,43 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import kotlinx.coroutines.Dispatchers
5+import kotlinx.coroutines.withContext
6+import kotlinx.serialization.Serializable
7+import kotlinx.serialization.decodeFromString
8+import kotlinx.serialization.encodeToString
9+import kotlinx.serialization.json.Json
10+import java.io.File
11+
12+/** A minimal session summary persisted for the home-screen widget and launcher shortcuts. */
13+@Serializable
14+data class RecentSession(val id: String, val title: String)
15+
16+/**
17+ * Persists a short list of recent sessions to a small JSON file under filesDir so app-external
18+ * surfaces (the home-screen widget's [android.widget.RemoteViewsService] and the "Resume last"
19+ * launcher shortcut) can read them without a live connection or in-memory state. Both the app
20+ * and the widget's RemoteViews factory run in the same process, so a plain file is sufficient.
21+ */
22+object RecentSessionsStore {
23+
24+ private const val FILE = "recent_sessions.json"
25+ const val MAX = 8
26+
27+ private val json = Json { ignoreUnknownKeys = true }
28+
29+ private fun file(context: Context) = File(context.applicationContext.filesDir, FILE)
30+
31+ /** Synchronous read — safe to call from the widget factory (bounded, tiny file). */
32+ fun read(context: Context): List<RecentSession> {
33+ val f = file(context).takeIf { it.exists() } ?: return emptyList()
34+ return runCatching { json.decodeFromString<List<RecentSession>>(f.readText()) }
35+ .getOrDefault(emptyList())
36+ }
37+
38+ /** Persist [sessions] (capped to [MAX]); a no-op empty list writes an empty file. */
39+ suspend fun write(context: Context, sessions: List<RecentSession>) = withContext(Dispatchers.IO) {
40+ runCatching { file(context).writeText(json.encodeToString(sessions.take(MAX))) }
41+ Unit
42+ }
43+}
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+36
@@ -3,6 +3,7 @@ package soy.iko.opencode.data.repo
33import android.content.Context
44import androidx.datastore.preferences.core.booleanPreferencesKey
55import androidx.datastore.preferences.core.edit
6+import androidx.datastore.preferences.core.floatPreferencesKey
67import androidx.datastore.preferences.core.stringPreferencesKey
78import androidx.datastore.preferences.preferencesDataStore
89import kotlinx.coroutines.flow.Flow
@@ -20,6 +21,16 @@ class SettingsStore(context: Context) {
2021 private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
2122 private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
2223 private val appLockKey = booleanPreferencesKey("app_lock")
24+ private val chatTextScaleKey = floatPreferencesKey("chat_text_scale")
25+ private val codeWrapKey = booleanPreferencesKey("code_wrap")
26+
27+ companion object {
28+ /** Bounds for the chat text-size multiplier so the UI can't be scaled into
29+ * unreadability or an unusable micro-font. 1.0 is the design default. */
30+ const val MIN_CHAT_TEXT_SCALE = 0.8f
31+ const val MAX_CHAT_TEXT_SCALE = 1.6f
32+ const val DEFAULT_CHAT_TEXT_SCALE = 1.0f
33+ }
2334
2435 val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
2536 runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
@@ -44,6 +55,21 @@ class SettingsStore(context: Context) {
4455 prefs[appLockKey] ?: false
4556 }
4657
58+ /** Multiplier applied to chat message (markdown) and code text so users can size the
59+ * conversation for readability independently of the system font scale. Clamped to
60+ * [[MIN_CHAT_TEXT_SCALE], [MAX_CHAT_TEXT_SCALE]] on read so a corrupt/out-of-range
61+ * stored value can never produce an unreadable UI. Defaults to 1.0 (design size). */
62+ val chatTextScale: Flow<Float> = appContext.settingsDataStore.data.map { prefs ->
63+ (prefs[chatTextScaleKey] ?: DEFAULT_CHAT_TEXT_SCALE)
64+ .coerceIn(MIN_CHAT_TEXT_SCALE, MAX_CHAT_TEXT_SCALE)
65+ }
66+
67+ /** When true, fenced/indented code blocks soft-wrap long lines instead of scrolling
68+ * horizontally. Defaults to false (horizontal scroll) to preserve code formatting. */
69+ val codeWrap: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
70+ prefs[codeWrapKey] ?: false
71+ }
72+
4773 suspend fun setThemeMode(mode: ThemeMode) {
4874 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
4975 }
@@ -59,4 +85,14 @@ class SettingsStore(context: Context) {
5985 suspend fun setAppLock(enabled: Boolean) {
6086 appContext.settingsDataStore.edit { it[appLockKey] = enabled }
6187 }
88+
89+ suspend fun setChatTextScale(scale: Float) {
90+ appContext.settingsDataStore.edit {
91+ it[chatTextScaleKey] = scale.coerceIn(MIN_CHAT_TEXT_SCALE, MAX_CHAT_TEXT_SCALE)
92+ }
93+ }
94+
95+ suspend fun setCodeWrap(enabled: Boolean) {
96+ appContext.settingsDataStore.edit { it[codeWrapKey] = enabled }
97+ }
6298}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+78
@@ -8,6 +8,7 @@ import android.net.NetworkRequest
88import android.util.Log
99import soy.iko.opencode.data.model.AssistantMessage
1010import soy.iko.opencode.data.model.BusEvent
11+import soy.iko.opencode.data.model.FilePromptPart
1112import soy.iko.opencode.data.model.MessagePartUpdated
1213import soy.iko.opencode.data.model.MessageUpdated
1314import soy.iko.opencode.data.model.Permission
@@ -19,9 +20,11 @@ import soy.iko.opencode.data.model.ServerProfile
1920import soy.iko.opencode.data.model.SessionError
2021import soy.iko.opencode.data.model.SessionIdle
2122import soy.iko.opencode.data.repo.AttachmentDraftStore
23+import soy.iko.opencode.data.repo.BackupManager
2224import soy.iko.opencode.data.repo.DraftStore
2325import soy.iko.opencode.data.repo.ErrorKind
2426import soy.iko.opencode.data.repo.MessageCacheStore
27+import soy.iko.opencode.data.repo.OutboxStore
2528import soy.iko.opencode.data.repo.SessionPrefsStore
2629import soy.iko.opencode.data.repo.ProfileStore
2730import soy.iko.opencode.data.repo.SettingsStore
@@ -44,6 +47,7 @@ import kotlinx.coroutines.delay
4447import kotlinx.coroutines.flow.MutableStateFlow
4548import kotlinx.coroutines.flow.StateFlow
4649import kotlinx.coroutines.flow.asStateFlow
50+import kotlinx.coroutines.flow.combine
4751import kotlinx.coroutines.flow.emptyFlow
4852import kotlinx.coroutines.flow.first
4953import kotlinx.coroutines.Job
@@ -90,6 +94,18 @@ open class AppContainer private constructor(
9094 open val attachmentDraftStore: AttachmentDraftStore by lazy { AttachmentDraftStore(appContext!!) }
9195 open val sessionPrefsStore: SessionPrefsStore by lazy { SessionPrefsStore(appContext!!) }
9296 open val messageCacheStore: MessageCacheStore by lazy { MessageCacheStore(appContext!!) }
97+ open val outboxStore: OutboxStore by lazy { OutboxStore(appContext!!) }
98+ open val backupManager: BackupManager by lazy { BackupManager(profileStore, settingsStore, sessionPrefsStore) }
99+
100+ /** True while the outbox is actively flushing queued messages to the server, so the UI
101+ * can show a "sending queued messages…" indicator. */
102+ private val _outboxSending = MutableStateFlow(false)
103+ open val outboxSending: StateFlow<Boolean> = _outboxSending.asStateFlow()
104+
105+ /** Bumped to nudge an immediate outbox flush (after enqueue, or a manual "Send now"),
106+ * independent of connection/online changes. */
107+ private val _outboxFlushTrigger = MutableStateFlow(0)
108+ open fun flushOutbox() { _outboxFlushTrigger.update { it + 1 } }
93109
94110 private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
95111 open val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
@@ -128,6 +144,15 @@ open class AppContainer private constructor(
128144 return if (_pendingSharedMedia.compareAndSet(current, emptyList())) current else emptyList()
129145 }
130146
147+ /**
148+ * Set from an external "New session" trigger (launcher shortcut / QS tile). The nav host
149+ * consumes it once connected, creating a fresh session and opening it.
150+ */
151+ private val _pendingNewSession = MutableStateFlow(false)
152+ open val pendingNewSession: StateFlow<Boolean> = _pendingNewSession.asStateFlow()
153+ open fun requestNewSession() { _pendingNewSession.value = true }
154+ open fun consumePendingNewSession(): Boolean = _pendingNewSession.compareAndSet(true, false)
155+
131156 /**
132157 * A session id to open from an external trigger (a notification tap or a deep link).
133158 * The nav host consumes it once a connection is active.
@@ -241,10 +266,62 @@ open class AppContainer private constructor(
241266 if (!skipInit) {
242267 NotificationChannels.create(appContext!!)
243268 observeMessageActivity()
269+ appScope.launch { runCatchingCancellable { outboxStore.load() } }
270+ observeOutbox()
244271 appScope.launch { autoConnect() }
245272 }
246273 }
247274
275+ /**
276+ * Flush the offline outbox whenever there's an active connection and the device is
277+ * online (and on any manual [flushOutbox] nudge). Queued messages are only sent to the
278+ * server they were composed for (matched by profile id), so a reconnect to a different
279+ * server never misfires them against a session id that doesn't exist there. Uses a plain
280+ * `collect` (not collectLatest) so an in-flight send is never cancelled mid-flight — a
281+ * cancelled+retried POST could double-send since each attempt carries a fresh idempotency
282+ * key.
283+ */
284+ private fun observeOutbox() {
285+ appScope.launch {
286+ combine(activeConnection, isOnline, _outboxFlushTrigger) { conn, online, _ -> conn to online }
287+ .collect { (conn, online) ->
288+ if (conn == null || !online) return@collect
289+ flushOutboxNow(conn)
290+ }
291+ }
292+ }
293+
294+ private suspend fun flushOutboxNow(conn: OpencodeConnection) {
295+ val pending = outboxStore.messages.value
296+ .filter { it.profileId == conn.profile.id }
297+ .sortedBy { it.createdAt }
298+ if (pending.isEmpty()) return
299+ _outboxSending.value = true
300+ try {
301+ for (msg in pending) {
302+ val sent = runCatchingCancellable {
303+ conn.repository.sendPrompt(
304+ msg.sessionId,
305+ msg.text,
306+ attachments = msg.attachments.map {
307+ FilePromptPart(mime = it.mime, url = it.url, filename = it.filename)
308+ },
309+ model = msg.model,
310+ agent = msg.agent,
311+ )
312+ }.onFailure {
313+ Log.w("AppContainer", "Outbox flush failed for ${msg.sessionId}: ${safeExceptionSummary(it)}")
314+ }.isSuccess
315+ // Stop on the first failure — the connection likely dropped again; the
316+ // remaining messages stay queued and retry on the next trigger, preserving
317+ // send order.
318+ if (sent) outboxStore.remove(msg.id) else break
319+ }
320+ } finally {
321+ _outboxSending.value = false
322+ }
323+ }
324+
248325 /** Release resources held for the process lifetime (network callback, app scope). */
249326 open fun shutdown() {
250327 // Cancel the app scope first so any coroutine holding the connection mutex
@@ -654,6 +731,7 @@ open class AppContainer private constructor(
654731 attachmentDraftStore.remove(id)
655732 sessionPrefsStore.forget(id)
656733 messageCacheStore.remove(id)
734+ outboxStore.removeForSession(id)
657735 onDeleted()
658736 }
659737 .onFailure { onError(it) }
app/src/main/java/soy/iko/opencode/platform/AppShortcuts.ktnew file+58
@@ -0,0 +1,58 @@
1+package soy.iko.opencode.platform
2+
3+import android.content.Context
4+import android.content.Intent
5+import android.net.Uri
6+import androidx.core.content.pm.ShortcutInfoCompat
7+import androidx.core.content.pm.ShortcutManagerCompat
8+import androidx.core.graphics.drawable.IconCompat
9+import soy.iko.opencode.MainActivity
10+import soy.iko.opencode.R
11+import soy.iko.opencode.data.repo.RecentSession
12+
13+/**
14+ * Maintains the app's dynamic launcher shortcuts (long-press the icon): "New session" and
15+ * "Resume last". Rebuilt whenever the recent-session list changes so "Resume last" points at
16+ * the most recent conversation via the existing `opencode://session/{id}` deep link.
17+ */
18+object AppShortcuts {
19+
20+ private const val ID_NEW = "new_session"
21+ private const val ID_RESUME = "resume_last"
22+ private val idRegex = Regex("[^A-Za-z0-9_-]")
23+
24+ fun update(context: Context, lastSession: RecentSession?) {
25+ val ctx = context.applicationContext
26+ val shortcuts = mutableListOf<ShortcutInfoCompat>()
27+
28+ shortcuts += ShortcutInfoCompat.Builder(ctx, ID_NEW)
29+ .setShortLabel(ctx.getString(R.string.shortcut_new_short))
30+ .setLongLabel(ctx.getString(R.string.shortcut_new_long))
31+ .setIcon(IconCompat.createWithResource(ctx, R.drawable.ic_launcher_foreground))
32+ .setIntent(
33+ Intent(ctx, MainActivity::class.java).apply { action = MainActivity.ACTION_NEW_SESSION },
34+ )
35+ .build()
36+
37+ if (lastSession != null) {
38+ val safeId = idRegex.replace(lastSession.id, "")
39+ if (safeId.isNotEmpty()) {
40+ shortcuts += ShortcutInfoCompat.Builder(ctx, ID_RESUME)
41+ .setShortLabel(ctx.getString(R.string.shortcut_resume_short))
42+ .setLongLabel(
43+ lastSession.title.ifBlank { ctx.getString(R.string.shortcut_resume_long) }.take(40),
44+ )
45+ .setIcon(IconCompat.createWithResource(ctx, R.drawable.ic_launcher_foreground))
46+ .setIntent(
47+ Intent(ctx, MainActivity::class.java).apply {
48+ action = Intent.ACTION_VIEW
49+ data = Uri.parse("opencode://session/$safeId")
50+ },
51+ )
52+ .build()
53+ }
54+ }
55+
56+ runCatching { ShortcutManagerCompat.setDynamicShortcuts(ctx, shortcuts) }
57+ }
58+}
app/src/main/java/soy/iko/opencode/platform/NewSessionTileService.ktnew file+33
@@ -0,0 +1,33 @@
1+package soy.iko.opencode.platform
2+
3+import android.app.PendingIntent
4+import android.content.Intent
5+import android.os.Build
6+import android.service.quicksettings.TileService
7+import soy.iko.opencode.MainActivity
8+
9+/**
10+ * Quick-settings tile that starts a new opencode session. Tapping launches [MainActivity]
11+ * with [MainActivity.ACTION_NEW_SESSION]; the app creates and opens a fresh session once
12+ * connected. On Android 14+ [startActivityAndCollapse] requires a PendingIntent.
13+ */
14+class NewSessionTileService : TileService() {
15+
16+ override fun onClick() {
17+ super.onClick()
18+ val intent = Intent(this, MainActivity::class.java).apply {
19+ action = MainActivity.ACTION_NEW_SESSION
20+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
21+ }
22+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
23+ val pending = PendingIntent.getActivity(
24+ this, 0, intent,
25+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
26+ )
27+ startActivityAndCollapse(pending)
28+ } else {
29+ @Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
30+ startActivityAndCollapse(intent)
31+ }
32+ }
33+}
app/src/main/java/soy/iko/opencode/platform/SessionsWidgetProvider.ktnew file+83
@@ -0,0 +1,83 @@
1+package soy.iko.opencode.platform
2+
3+import android.app.PendingIntent
4+import android.appwidget.AppWidgetManager
5+import android.appwidget.AppWidgetProvider
6+import android.content.ComponentName
7+import android.content.Context
8+import android.content.Intent
9+import android.net.Uri
10+import android.os.Build
11+import android.widget.RemoteViews
12+import soy.iko.opencode.MainActivity
13+import soy.iko.opencode.R
14+
15+/**
16+ * Home-screen widget listing recent opencode sessions (fed by
17+ * [soy.iko.opencode.data.repo.RecentSessionsStore]) with a "New session" action. Each row deep-links
18+ * into its session; the header opens the app. The list is backed by [SessionsWidgetService].
19+ */
20+class SessionsWidgetProvider : AppWidgetProvider() {
21+
22+ override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
23+ for (id in appWidgetIds) render(context, appWidgetManager, id)
24+ }
25+
26+ companion object {
27+ /** Re-render all widgets and tell them to reload their list data (call after the
28+ * recent-session list changes). No-op when no widget is placed. */
29+ fun refresh(context: Context) {
30+ val manager = AppWidgetManager.getInstance(context) ?: return
31+ val ids = runCatching {
32+ manager.getAppWidgetIds(ComponentName(context, SessionsWidgetProvider::class.java))
33+ }.getOrNull() ?: return
34+ if (ids.isEmpty()) return
35+ for (id in ids) render(context, manager, id)
36+ manager.notifyAppWidgetViewDataChanged(ids, R.id.widget_list)
37+ }
38+
39+ private fun render(context: Context, manager: AppWidgetManager, widgetId: Int) {
40+ val views = RemoteViews(context.packageName, R.layout.widget_sessions)
41+
42+ // Header: title opens the app; the "+" starts a new session.
43+ views.setOnClickPendingIntent(
44+ R.id.widget_title,
45+ activityPending(context, requestCode = 2, Intent(context, MainActivity::class.java)),
46+ )
47+ views.setOnClickPendingIntent(
48+ R.id.widget_new,
49+ activityPending(
50+ context, requestCode = 1,
51+ Intent(context, MainActivity::class.java).apply { action = MainActivity.ACTION_NEW_SESSION },
52+ ),
53+ )
54+
55+ // Bind the list to the RemoteViewsService. A unique data Uri per widget id keeps
56+ // the framework from reusing one adapter across widgets.
57+ val serviceIntent = Intent(context, SessionsWidgetService::class.java).apply {
58+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
59+ data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
60+ }
61+ views.setRemoteAdapter(R.id.widget_list, serviceIntent)
62+ views.setEmptyView(R.id.widget_list, R.id.widget_empty)
63+
64+ // Template intent filled in per row (fill-in carries EXTRA_SESSION_ID). Must be
65+ // mutable on Android 12+ so the row's fill-in can be merged.
66+ val templateIntent = Intent(context, MainActivity::class.java).apply { action = Intent.ACTION_VIEW }
67+ val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
68+ val templatePending = PendingIntent.getActivity(
69+ context, 3, templateIntent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,
70+ )
71+ views.setPendingIntentTemplate(R.id.widget_list, templatePending)
72+
73+ manager.updateAppWidget(widgetId, views)
74+ manager.notifyAppWidgetViewDataChanged(widgetId, R.id.widget_list)
75+ }
76+
77+ private fun activityPending(context: Context, requestCode: Int, intent: Intent): PendingIntent =
78+ PendingIntent.getActivity(
79+ context, requestCode, intent,
80+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
81+ )
82+ }
83+}
app/src/main/java/soy/iko/opencode/platform/SessionsWidgetService.ktnew file+47
@@ -0,0 +1,47 @@
1+package soy.iko.opencode.platform
2+
3+import android.content.Context
4+import android.content.Intent
5+import android.widget.RemoteViews
6+import android.widget.RemoteViewsService
7+import soy.iko.opencode.MainActivity
8+import soy.iko.opencode.R
9+import soy.iko.opencode.data.repo.RecentSession
10+import soy.iko.opencode.data.repo.RecentSessionsStore
11+
12+/** Serves the list rows for [SessionsWidgetProvider] from [RecentSessionsStore]. */
13+class SessionsWidgetService : RemoteViewsService() {
14+ override fun onGetViewFactory(intent: Intent): RemoteViewsFactory =
15+ SessionsWidgetFactory(applicationContext)
16+}
17+
18+private class SessionsWidgetFactory(private val context: Context) : RemoteViewsService.RemoteViewsFactory {
19+
20+ private var items: List<RecentSession> = emptyList()
21+
22+ override fun onCreate() { items = RecentSessionsStore.read(context) }
23+
24+ // Re-read from disk whenever the app signals the data changed (notifyAppWidgetViewDataChanged).
25+ override fun onDataSetChanged() { items = RecentSessionsStore.read(context) }
26+
27+ override fun onDestroy() { items = emptyList() }
28+
29+ override fun getCount(): Int = items.size
30+
31+ override fun getViewAt(position: Int): RemoteViews {
32+ val row = RemoteViews(context.packageName, R.layout.widget_session_item)
33+ val session = items.getOrNull(position) ?: return row
34+ row.setTextViewText(R.id.widget_item_title, session.title.ifBlank { context.getString(R.string.session) })
35+ // Fill-in intent merged into the list's template: carries the session id the app opens.
36+ row.setOnClickFillInIntent(
37+ R.id.widget_item_root,
38+ Intent().putExtra(MainActivity.EXTRA_SESSION_ID, session.id),
39+ )
40+ return row
41+ }
42+
43+ override fun getLoadingView(): RemoteViews? = null
44+ override fun getViewTypeCount(): Int = 1
45+ override fun getItemId(position: Int): Long = position.toLong()
46+ override fun hasStableIds(): Boolean = true
47+}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+40
@@ -7,6 +7,7 @@ import androidx.compose.animation.slideInHorizontally
77import androidx.compose.animation.slideOutHorizontally
88import androidx.compose.foundation.layout.BoxWithConstraints
99import androidx.compose.runtime.Composable
10+import androidx.compose.runtime.CompositionLocalProvider
1011import androidx.compose.runtime.LaunchedEffect
1112import androidx.compose.runtime.getValue
1213import androidx.compose.runtime.mutableStateOf
@@ -29,10 +30,14 @@ import soy.iko.opencode.ui.file.FileViewScreen
2930import soy.iko.opencode.ui.server.ServerEditScreen
3031import soy.iko.opencode.ui.server.ServerListScreen
3132import soy.iko.opencode.ui.search.GlobalSearchScreen
33+import soy.iko.opencode.ui.components.LocalChatTextScale
34+import soy.iko.opencode.ui.components.LocalCodeWrap
3235import soy.iko.opencode.ui.session.SessionListScreen
3336import soy.iko.opencode.ui.session.TwoPaneSessionChat
3437import soy.iko.opencode.ui.settings.DiagnosticsScreen
3538import soy.iko.opencode.ui.settings.SettingsScreen
39+import soy.iko.opencode.ui.usage.UsageScreen
40+import soy.iko.opencode.ui.mcp.McpScreen
3641import soy.iko.opencode.util.runCatchingCancellable
3742
3843@Composable
@@ -42,7 +47,12 @@ fun OpencodeApp(container: AppContainer) {
4247 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
4348 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
4449 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
50+ val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
4551 val connection by container.activeConnection.collectAsStateWithLifecycle()
52+ // Chat presentation preferences, provided to the whole nav graph so the markdown/code
53+ // renderers honor the user's text-size and code-wrap choices everywhere.
54+ val chatTextScale by container.settingsStore.chatTextScale.collectAsStateWithLifecycle(initialValue = 1f)
55+ val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
4656
4757 // Hold a foreground priority for as long as ANY session is actively running — not just
4858 // while a running chat is on screen — so a run started and then backgrounded (or left
@@ -80,6 +90,24 @@ fun OpencodeApp(container: AppContainer) {
8090 }
8191 }
8292
93+ // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
94+ // session and open it. Consumed once (compareAndSet) so a recomposition can't re-create.
95+ LaunchedEffect(pendingNewSession, connection) {
96+ if (!pendingNewSession) return@LaunchedEffect
97+ val conn = connection ?: return@LaunchedEffect
98+ if (!container.consumePendingNewSession()) return@LaunchedEffect
99+ val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
100+ ?: return@LaunchedEffect
101+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
102+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
103+ }
104+ navController.navigate(Routes.chat(session.id)) { launchSingleTop = true }
105+ }
106+
107+ CompositionLocalProvider(
108+ LocalChatTextScale provides chatTextScale,
109+ LocalCodeWrap provides codeWrap,
110+ ) {
83111 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
84112 // the open conversation side by side instead of a single-pane back stack.
85113 BoxWithConstraints {
@@ -206,6 +234,7 @@ fun OpencodeApp(container: AppContainer) {
206234 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
207235 onBack = { navController.popBackStack() },
208236 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
237+ onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
209238 )
210239 }
211240
@@ -251,6 +280,8 @@ fun OpencodeApp(container: AppContainer) {
251280 }
252281 },
253282 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
283+ onOpenUsage = { navController.navigate(Routes.USAGE) },
284+ onOpenMcp = { navController.navigate(Routes.MCP) },
254285 )
255286 }
256287
@@ -258,6 +289,14 @@ fun OpencodeApp(container: AppContainer) {
258289 DiagnosticsScreen(onBack = { navController.popBackStack() })
259290 }
260291
292+ composable(Routes.USAGE) {
293+ UsageScreen(container = container, onBack = { navController.popBackStack() })
294+ }
295+
296+ composable(Routes.MCP) {
297+ McpScreen(container = container, onBack = { navController.popBackStack() })
298+ }
299+
261300 composable(Routes.SEARCH) {
262301 GlobalSearchScreen(
263302 container = container,
@@ -267,4 +306,5 @@ fun OpencodeApp(container: AppContainer) {
267306 }
268307 }
269308 }
309+ }
270310}
app/src/main/java/soy/iko/opencode/ui/Routes.kt+2
@@ -6,6 +6,8 @@ object Routes {
66 const val SESSIONS = "sessions"
77 const val SETTINGS = "settings"
88 const val DIAGNOSTICS = "diagnostics"
9+ const val USAGE = "usage"
10+ const val MCP = "mcp"
911 const val FILES = "files"
1012 const val SEARCH = "search"
1113
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+119
@@ -119,7 +119,9 @@ import soy.iko.opencode.data.network.EventStreamClient
119119import soy.iko.opencode.data.network.NetworkConfig
120120import soy.iko.opencode.di.AppContainer
121121import soy.iko.opencode.R
122+import soy.iko.opencode.ui.components.CommandPalette
122123import soy.iko.opencode.ui.components.ConnectionBanner
124+import soy.iko.opencode.ui.components.PaletteAction
123125import soy.iko.opencode.ui.components.copyToClipboard
124126import soy.iko.opencode.ui.components.LocalRelativeTimeTick
125127import soy.iko.opencode.ui.components.rememberRelativeTimeTick
@@ -135,6 +137,7 @@ fun ChatScreen(
135137 sessionId: String,
136138 onBack: () -> Unit,
137139 onOpenFile: ((String) -> Unit)? = null,
140+ onOpenSession: ((String) -> Unit)? = null,
138141) {
139142 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
140143 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
@@ -219,6 +222,30 @@ fun ChatScreen(
219222 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
220223 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
221224 var showShellDialog by rememberSaveable { mutableStateOf(false) }
225+ var showPalette by rememberSaveable { mutableStateOf(false) }
226+
227+ // Command-palette entries (keyboard-first: Ctrl+K). Labels resolved here so the remembered
228+ // action list doesn't need a Composable context. Actions map to the same handlers the
229+ // overflow menu drives, so the palette is a keyboard shortcut to them, not a parallel path.
230+ val paletteModelLabel = stringResource(R.string.palette_switch_model)
231+ val paletteAgentLabel = stringResource(R.string.palette_choose_agent)
232+ val paletteCommandLabel = stringResource(R.string.palette_run_command)
233+ val paletteSummarizeLabel = stringResource(R.string.palette_summarize)
234+ val paletteRenameLabel = stringResource(R.string.rename_session_chat)
235+ val paletteShellLabel = stringResource(R.string.run_shell_command)
236+ val paletteActions = remember(
237+ paletteModelLabel, paletteAgentLabel, paletteCommandLabel,
238+ paletteSummarizeLabel, paletteRenameLabel, paletteShellLabel, hasMessages, running,
239+ ) {
240+ buildList {
241+ add(PaletteAction("model", paletteModelLabel) { showModelPicker = true })
242+ add(PaletteAction("agent", paletteAgentLabel) { showAgentPicker = true })
243+ add(PaletteAction("command", paletteCommandLabel) { showCommandPicker = true })
244+ if (hasMessages && !running) add(PaletteAction("summarize", paletteSummarizeLabel) { vm.summarize() })
245+ if (!running) add(PaletteAction("shell", paletteShellLabel) { showShellDialog = true })
246+ add(PaletteAction("rename", paletteRenameLabel) { showRenameDialog = true })
247+ }
248+ }
222249
223250 // Keep the screen awake while the agent is working in *this* session. The foreground
224251 // service that holds process priority during a run is managed app-wide off
@@ -337,6 +364,15 @@ fun ChatScreen(
337364 }
338365 }
339366
367+ // Navigate to a session freshly branched from a message (branchFrom), once it's created.
368+ val branchedMsg = stringResource(R.string.branch_created)
369+ LaunchedEffect(onOpenSession) {
370+ vm.branchEvents.collect { newId ->
371+ showToast(shareContext, branchedMsg)
372+ onOpenSession?.invoke(newId)
373+ }
374+ }
375+
340376 // Stage images shared into the app (via the system share sheet) as attachments, once.
341377 LaunchedEffect(Unit) {
342378 val shared = container.consumePendingSharedMedia()
@@ -354,6 +390,21 @@ fun ChatScreen(
354390 BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
355391
356392 Scaffold(
393+ // Hardware-keyboard shortcuts (tablets / DeX / Chromebooks): Ctrl+K opens the command
394+ // palette; Escape closes it, or stops a run. onPreviewKeyEvent on the Scaffold root sees
395+ // events before the focused text field, but only Ctrl+K / Escape are consumed so normal
396+ // typing (and the composer's own Enter-to-send handling) is untouched.
397+ modifier = Modifier.onPreviewKeyEvent { ev ->
398+ if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
399+ when {
400+ ev.isCtrlPressed && ev.key == Key.K -> { showPalette = true; true }
401+ ev.key == Key.Escape && showPalette -> { showPalette = false; true }
402+ ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> {
403+ showStopConfirm = true; true
404+ }
405+ else -> false
406+ }
407+ },
357408 topBar = {
358409 TopAppBar(
359410 title = {
@@ -568,6 +619,8 @@ fun ChatScreen(
568619 bottomBar = {
569620 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
570621 val todoPlan by vm.todoPlan.collectAsStateWithLifecycle()
622+ val queuedOffline by vm.outbox.collectAsStateWithLifecycle()
623+ val outboxSending by container.outboxSending.collectAsStateWithLifecycle()
571624 Column {
572625 // Pinned above the composer so the agent's plan and progress stay visible
573626 // while scrolled up mid-run; renders nothing when there's no plan.
@@ -575,6 +628,14 @@ fun ChatScreen(
575628 AnimatedVisibility(visible = reverted) {
576629 RevertBanner(onUndo = { vm.unrevert() })
577630 }
631+ AnimatedVisibility(visible = queuedOffline.isNotEmpty()) {
632+ OutboxBanner(
633+ count = queuedOffline.size,
634+ sending = outboxSending,
635+ onFlush = { vm.flushQueued() },
636+ onDiscard = { vm.discardAllQueued() },
637+ )
638+ }
578639 ChatInputBar(
579640 value = draft,
580641 onValueChange = vm::updateDraft,
@@ -834,6 +895,11 @@ fun ChatScreen(
834895 onEdit = { text -> vm.editMessage(message.info.id, text) },
835896 onSpeak = { text -> tts.toggle(message.info.id, text) },
836897 isSpeaking = message.info.id == speakingMessageId,
898+ onQuote = { text ->
899+ vm.quoteReply(text)
900+ runCatching { inputFocusRequester.requestFocus() }
901+ },
902+ onBranch = { text -> vm.branchFrom(text) },
837903 )
838904 }
839905 }
@@ -996,6 +1062,10 @@ fun ChatScreen(
9961062 onRespond = { response -> vm.respondPermission(permission, response) },
9971063 )
9981064 }
1065+
1066+ if (showPalette) {
1067+ CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
1068+ }
9991069}
10001070
10011071@Composable
@@ -1299,6 +1369,55 @@ private fun RevertBanner(onUndo: () -> Unit) {
12991369 }
13001370}
13011371
1372+/** A banner shown while messages composed offline are queued in the outbox, with a
1373+ * "Send now" nudge and a discard. Shows a spinner while the queue is flushing. */
1374+@Composable
1375+private fun OutboxBanner(
1376+ count: Int,
1377+ sending: Boolean,
1378+ onFlush: () -> Unit,
1379+ onDiscard: () -> Unit,
1380+) {
1381+ Surface(
1382+ color = MaterialTheme.colorScheme.secondaryContainer,
1383+ tonalElevation = 2.dp,
1384+ modifier = Modifier.fillMaxWidth(),
1385+ ) {
1386+ Row(
1387+ modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
1388+ verticalAlignment = Alignment.CenterVertically,
1389+ horizontalArrangement = Arrangement.SpaceBetween,
1390+ ) {
1391+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
1392+ if (sending) {
1393+ CircularProgressIndicator(
1394+ Modifier.size(16.dp),
1395+ strokeWidth = 2.dp,
1396+ color = MaterialTheme.colorScheme.onSecondaryContainer,
1397+ )
1398+ Spacer(Modifier.size(8.dp))
1399+ }
1400+ Text(
1401+ if (sending) {
1402+ stringResource(R.string.outbox_sending)
1403+ } else {
1404+ val res = if (count == 1) R.string.outbox_pending_one else R.string.outbox_pending_many
1405+ stringResource(res, count)
1406+ },
1407+ style = MaterialTheme.typography.bodySmall,
1408+ color = MaterialTheme.colorScheme.onSecondaryContainer,
1409+ )
1410+ }
1411+ if (!sending) {
1412+ TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
1413+ TextButton(onClick = onDiscard) {
1414+ Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
1415+ }
1416+ }
1417+ }
1418+ }
1419+}
1420+
13021421/** Prompt for a one-off shell command to run in the session's worktree. */
13031422@Composable
13041423private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) {
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+142−2
@@ -20,6 +20,7 @@ import soy.iko.opencode.data.model.defaultOption
2020import soy.iko.opencode.data.model.toOptions
2121import soy.iko.opencode.data.network.EventStreamClient
2222import soy.iko.opencode.data.network.NetworkConfig
23+import soy.iko.opencode.data.repo.OutboxMessage
2324import soy.iko.opencode.data.repo.SessionRepository
2425import soy.iko.opencode.di.AppContainer
2526import soy.iko.opencode.R
@@ -298,6 +299,18 @@ class ChatViewModel(
298299 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
299300 val draft: StateFlow<String> = _draft.asStateFlow()
300301
302+ /** Messages composed for this session while offline/disconnected, queued in the outbox and
303+ * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
304+ * chip with a discard/send-now affordance. */
305+ val outbox: StateFlow<List<OutboxMessage>> =
306+ container.outboxStore.messages
307+ .map { list -> list.filter { it.sessionId == sessionId } }
308+ .stateIn(
309+ scope = viewModelScope,
310+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
311+ initialValue = emptyList(),
312+ )
313+
301314 /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
302315 * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
303316 * survives process death — the source content Uris wouldn't survive, but the data URLs
@@ -321,6 +334,14 @@ class ChatViewModel(
321334 )
322335 val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
323336
337+ /** One-shot events carrying the id of a session freshly branched from a message, so the
338+ * UI can navigate to it. */
339+ private val _branchEvents = Channel<String>(
340+ capacity = NetworkConfig.snackbarEventBufferCapacity,
341+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
342+ )
343+ val branchEvents: Flow<String> = _branchEvents.receiveAsFlow()
344+
324345 init {
325346 // DraftStore loads SharedPreferences asynchronously; on a cold start the
326347 // synchronous get() above returns "" until the background load completes.
@@ -634,12 +655,20 @@ class ChatViewModel(
634655 * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
635656 */
636657 private fun send(text: String, includeAttachments: Boolean): Boolean {
637− val conn = connection ?: return false
638658 val trimmed = text.trim()
639659 val attachments = if (includeAttachments) _attachments.value else emptyList()
640660 // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
641661 // attached is not.
642− if ((trimmed.isEmpty() && attachments.isEmpty()) || !_running.compareAndSet(false, true)) return false
662+ if (trimmed.isEmpty() && attachments.isEmpty()) return false
663+ // Offline, or no active connection: queue the prompt to the outbox (flushed on
664+ // reconnect) instead of failing the send. Device-offline is caught even when a stale
665+ // connection object lingers, so a phone that lost signal mid-conversation still
666+ // captures the message rather than bouncing it off a dead socket.
667+ val conn = connection
668+ if (conn == null || !container.isOnline.value) {
669+ return enqueueOffline(trimmed, attachments)
670+ }
671+ if (!_running.compareAndSet(false, true)) return false
643672 _failedDraft.value = null
644673 // Clear the staged attachments optimistically so the composer empties immediately;
645674 // restore them if the send fails (mirrors the draft handling below).
@@ -700,6 +729,82 @@ class ChatViewModel(
700729 return true
701730 }
702731
732+ /**
733+ * Queue [trimmed] (+ [attachments]) to the offline outbox, clearing the composer
734+ * optimistically like a real send. AppContainer flushes the outbox automatically on
735+ * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
736+ * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
737+ * are restored so nothing is lost.
738+ */
739+ private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>): Boolean {
740+ _failedDraft.value = null
741+ if (attachments.isNotEmpty()) _attachments.value = emptyList()
742+ val clearDraft = _draft.value.trim() == trimmed
743+ if (clearDraft) {
744+ suppressDraftPersist.set(true)
745+ _draft.value = ""
746+ }
747+ viewModelScope.launch {
748+ val profileId = connection?.profile?.id
749+ ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
750+ if (profileId == null) {
751+ // No server to attribute the message to — restore the composer and report it
752+ // rather than silently dropping the prompt.
753+ suppressDraftPersist.set(false)
754+ if (_draft.value.isBlank()) updateDraft(trimmed)
755+ if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
756+ persistAttachments()
757+ _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
758+ return@launch
759+ }
760+ runCatchingCancellable {
761+ container.outboxStore.enqueue(
762+ OutboxMessage(
763+ id = java.util.UUID.randomUUID().toString(),
764+ profileId = profileId,
765+ sessionId = sessionId,
766+ text = trimmed,
767+ attachments = attachments.map { it.toPersisted() },
768+ model = _selectedModel.value?.ref,
769+ agent = _selectedAgent.value,
770+ createdAt = System.currentTimeMillis(),
771+ ),
772+ )
773+ }.onSuccess {
774+ suppressDraftPersist.set(false)
775+ // The staged attachments now live in the outbox item; clear their draft copy.
776+ persistAttachments()
777+ if (_draft.value.isBlank()) {
778+ lastPersistedDraft = ""
779+ container.draftStore.set(sessionId, "")
780+ }
781+ // Nudge a flush in case we're actually online with a live connection.
782+ container.flushOutbox()
783+ _errorEvents.trySend(ChatError(container.string(R.string.outbox_queued)))
784+ }.onFailure {
785+ suppressDraftPersist.set(false)
786+ if (_draft.value.isBlank()) updateDraft(trimmed)
787+ if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
788+ persistAttachments()
789+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
790+ }
791+ }
792+ return true
793+ }
794+
795+ /** Discard a single queued (offline) message. */
796+ fun discardQueued(id: String) {
797+ viewModelScope.launch { runCatchingCancellable { container.outboxStore.remove(id) } }
798+ }
799+
800+ /** Discard every queued (offline) message for this session. */
801+ fun discardAllQueued() {
802+ viewModelScope.launch { runCatchingCancellable { container.outboxStore.removeForSession(sessionId) } }
803+ }
804+
805+ /** Attempt to flush the outbox now (e.g. the user tapped "Send now"). */
806+ fun flushQueued() { container.flushOutbox() }
807+
703808 /** Re-send the last draft whose send failed, if any. */
704809 fun retryFailed() {
705810 val draft = _failedDraft.value ?: return
@@ -844,6 +949,40 @@ class ChatViewModel(
844949 _draft.value = text
845950 }
846951
952+ /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
953+ * specific message inline. Appends to any existing draft (with a blank line) rather than
954+ * overwriting, so quoting doesn't discard text already being typed. */
955+ fun quoteReply(text: String) {
956+ val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
957+ if (quoted.isBlank()) return
958+ val current = _draft.value
959+ _draft.value = if (current.isBlank()) "$quoted\n\n" else "$current\n\n$quoted\n\n"
960+ }
961+
962+ /** Fork the conversation by creating a brand-new session seeded with [text] as its first
963+ * prompt (using the currently-selected model/agent). Emits the new session id via
964+ * [branchEvents] so the UI can navigate to it. There's no server-side "branch" endpoint,
965+ * so this starts a fresh session from the chosen prompt rather than copying history. */
966+ fun branchFrom(text: String) {
967+ val conn = connection ?: return
968+ val prompt = text.trim()
969+ if (prompt.isEmpty()) return
970+ viewModelScope.launch {
971+ runCatchingCancellable {
972+ val session = conn.repository.createSession(title = null)
973+ conn.repository.sendPrompt(
974+ session.id,
975+ prompt,
976+ model = _selectedModel.value?.ref,
977+ agent = _selectedAgent.value,
978+ )
979+ session.id
980+ }
981+ .onSuccess { newId -> _branchEvents.trySend(newId) }
982+ .onFailure { _errorEvents.trySend(ChatError(container.string(R.string.branch_failed))) }
983+ }
984+ }
985+
847986 /** Undo the active revert checkpoint, restoring the hidden messages. */
848987 fun unrevert() {
849988 val conn = connection ?: return
@@ -965,6 +1104,7 @@ class ChatViewModel(
9651104 container.draftStore.remove(sessionId)
9661105 container.attachmentDraftStore.remove(sessionId)
9671106 container.messageCacheStore.remove(sessionId)
1107+ container.outboxStore.removeForSession(sessionId)
9681108 _sessionDeleted.value = true
9691109 }
9701110 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+67−2
@@ -10,16 +10,24 @@ import androidx.compose.foundation.layout.size
1010import androidx.compose.material.icons.Icons
1111import androidx.compose.material.icons.automirrored.filled.HelpOutline
1212import androidx.compose.material.icons.automirrored.filled.VolumeUp
13+import androidx.compose.material.icons.filled.CallSplit
1314import androidx.compose.material.icons.filled.ContentCopy
1415import androidx.compose.material.icons.filled.Edit
16+import androidx.compose.material.icons.filled.FormatQuote
17+import androidx.compose.material.icons.filled.MoreVert
1518import androidx.compose.material.icons.filled.Restore
1619import androidx.compose.material.icons.filled.StopCircle
20+import androidx.compose.material3.DropdownMenu
21+import androidx.compose.material3.DropdownMenuItem
1722import androidx.compose.material3.Icon
1823import androidx.compose.material3.IconButton
1924import androidx.compose.material3.MaterialTheme
2025import androidx.compose.material3.Text
2126import androidx.compose.runtime.Composable
27+import androidx.compose.runtime.getValue
28+import androidx.compose.runtime.mutableStateOf
2229import androidx.compose.runtime.remember
30+import androidx.compose.runtime.setValue
2331import androidx.compose.ui.Alignment
2432import androidx.compose.ui.Modifier
2533import androidx.compose.ui.draw.clip
@@ -66,11 +74,53 @@ fun MessageBubble(
6674 onEdit: ((String) -> Unit)? = null,
6775 onSpeak: ((String) -> Unit)? = null,
6876 isSpeaking: Boolean = false,
77+ onQuote: ((String) -> Unit)? = null,
78+ onBranch: ((String) -> Unit)? = null,
6979) {
7080 when (message.info) {
71− is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit)
81+ is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch)
7282 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
73− else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking)
83+ else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, onQuote)
84+ }
85+}
86+
87+/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
88+ * Shown when at least one action is available and there's text to act on. Keeps the
89+ * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
90+@Composable
91+private fun MessageOverflow(
92+ text: String,
93+ onQuote: ((String) -> Unit)?,
94+ onBranch: ((String) -> Unit)?,
95+) {
96+ if (onQuote == null && onBranch == null) return
97+ var expanded by remember { mutableStateOf(false) }
98+ val moreLabel = stringResource(R.string.message_actions)
99+ androidx.compose.foundation.layout.Box {
100+ IconButton(onClick = { expanded = true }) {
101+ Icon(
102+ Icons.Filled.MoreVert,
103+ contentDescription = moreLabel,
104+ modifier = Modifier.size(18.dp),
105+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
106+ )
107+ }
108+ DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
109+ onQuote?.let { quote ->
110+ DropdownMenuItem(
111+ leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
112+ text = { Text(stringResource(R.string.quote_reply)) },
113+ onClick = { expanded = false; quote(text) },
114+ )
115+ }
116+ onBranch?.let { branch ->
117+ DropdownMenuItem(
118+ leadingIcon = { Icon(Icons.Filled.CallSplit, contentDescription = null) },
119+ text = { Text(stringResource(R.string.branch_session)) },
120+ onClick = { expanded = false; branch(text) },
121+ )
122+ }
123+ }
74124 }
75125}
76126
@@ -133,6 +183,8 @@ private fun UserBubble(
133183 onOpenFile: ((String) -> Unit)? = null,
134184 onRevert: (() -> Unit)? = null,
135185 onEdit: ((String) -> Unit)? = null,
186+ onQuote: ((String) -> Unit)? = null,
187+ onBranch: ((String) -> Unit)? = null,
136188) {
137189 val context = LocalContext.current
138190 val copyLabel = stringResource(R.string.copy)
@@ -191,6 +243,11 @@ private fun UserBubble(
191243 )
192244 }
193245 }
246+ // Quote-reply / branch-a-new-session, tucked behind an overflow so the
247+ // inline row stays compact. Only when there's prompt text to act on.
248+ if (textToCopy != null) {
249+ MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = onBranch)
250+ }
194251 }
195252 }
196253 }
@@ -208,6 +265,7 @@ private fun AssistantBlock(
208265 onRevert: (() -> Unit)? = null,
209266 onSpeak: ((String) -> Unit)? = null,
210267 isSpeaking: Boolean = false,
268+ onQuote: ((String) -> Unit)? = null,
211269) {
212270 Column(
213271 modifier = modifier.fillMaxWidth(),
@@ -278,6 +336,7 @@ private fun AssistantBlock(
278336 isSpeaking = isSpeaking,
279337 onSpeak = onSpeak,
280338 onRevert = onRevert,
339+ onQuote = onQuote,
281340 )
282341 }
283342 } else {
@@ -294,6 +353,7 @@ private fun AssistantActions(
294353 isSpeaking: Boolean,
295354 onSpeak: ((String) -> Unit)?,
296355 onRevert: (() -> Unit)?,
356+ onQuote: ((String) -> Unit)? = null,
297357) {
298358 val context = LocalContext.current
299359 val copyLabel = stringResource(R.string.copy)
@@ -323,6 +383,11 @@ private fun AssistantActions(
323383 )
324384 }
325385 }
386+ // Assistant replies can be quoted into the composer (branching is offered only on
387+ // user prompts, where seeding a new session with the prompt makes sense).
388+ if (textToCopy != null && onQuote != null) {
389+ MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = null)
390+ }
326391 }
327392}
328393
app/src/main/java/soy/iko/opencode/ui/components/ChatTextPrefs.ktnew file+51
@@ -0,0 +1,51 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.runtime.compositionLocalOf
4+import androidx.compose.material3.Typography
5+
6+/**
7+ * Chat presentation preferences, threaded through the composition so the markdown and
8+ * code renderers can honor them without every call site plumbing them explicitly.
9+ *
10+ * - [LocalChatTextScale]: a multiplier applied to chat/message/code font sizes.
11+ * - [LocalCodeWrap]: whether code blocks soft-wrap long lines (vs. horizontal scroll).
12+ *
13+ * Provided once near the app root from the persisted [soy.iko.opencode.data.repo.SettingsStore]
14+ * values. Defaults keep the design behavior when no provider is present (e.g. previews/tests).
15+ */
16+val LocalChatTextScale = compositionLocalOf { 1f }
17+val LocalCodeWrap = compositionLocalOf { false }
18+
19+/**
20+ * Return a copy of this [Typography] with every text role's font size and line height
21+ * multiplied by [scale]. Used to scale a whole markdown subtree (headings, lists, code,
22+ * body) from a single user preference. Returns the receiver unchanged when [scale] is 1.
23+ */
24+fun Typography.scaledBy(scale: Float): Typography {
25+ if (scale == 1f) return this
26+ fun androidx.compose.ui.text.TextStyle.scaled() = copy(
27+ fontSize = if (fontSize.isSp) fontSize * scale else fontSize,
28+ lineHeight = if (lineHeight.isSp) lineHeight * scale else lineHeight,
29+ )
30+ return copy(
31+ displayLarge = displayLarge.scaled(),
32+ displayMedium = displayMedium.scaled(),
33+ displaySmall = displaySmall.scaled(),
34+ headlineLarge = headlineLarge.scaled(),
35+ headlineMedium = headlineMedium.scaled(),
36+ headlineSmall = headlineSmall.scaled(),
37+ titleLarge = titleLarge.scaled(),
38+ titleMedium = titleMedium.scaled(),
39+ titleSmall = titleSmall.scaled(),
40+ bodyLarge = bodyLarge.scaled(),
41+ bodyMedium = bodyMedium.scaled(),
42+ bodySmall = bodySmall.scaled(),
43+ labelLarge = labelLarge.scaled(),
44+ labelMedium = labelMedium.scaled(),
45+ labelSmall = labelSmall.scaled(),
46+ )
47+}
48+
49+/** True when this [androidx.compose.ui.unit.TextUnit] is expressed in sp (safe to scale). */
50+private val androidx.compose.ui.unit.TextUnit.isSp: Boolean
51+ get() = type == androidx.compose.ui.unit.TextUnitType.Sp
app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.ktnew file+102
@@ -0,0 +1,102 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.heightIn
7+import androidx.compose.foundation.layout.padding
8+import androidx.compose.foundation.lazy.LazyColumn
9+import androidx.compose.foundation.lazy.items
10+import androidx.compose.material3.MaterialTheme
11+import androidx.compose.material3.OutlinedTextField
12+import androidx.compose.material3.Surface
13+import androidx.compose.material3.Text
14+import androidx.compose.runtime.Composable
15+import androidx.compose.runtime.LaunchedEffect
16+import androidx.compose.runtime.getValue
17+import androidx.compose.runtime.mutableStateOf
18+import androidx.compose.runtime.remember
19+import androidx.compose.runtime.setValue
20+import androidx.compose.ui.Modifier
21+import androidx.compose.ui.focus.focusRequester
22+import androidx.compose.ui.input.key.Key
23+import androidx.compose.ui.input.key.KeyEventType
24+import androidx.compose.ui.input.key.key
25+import androidx.compose.ui.input.key.onPreviewKeyEvent
26+import androidx.compose.ui.input.key.type
27+import androidx.compose.ui.res.stringResource
28+import androidx.compose.ui.unit.dp
29+import androidx.compose.ui.window.Dialog
30+import soy.iko.opencode.R
31+
32+/**
33+ * A keyboard-first command palette: a search field over a list of [actions], filtered as the
34+ * user types. Enter runs the first match, tap runs any entry; the field auto-focuses so a
35+ * hardware-keyboard user can type immediately after opening it. Dismissed via back/scrim, or
36+ * Escape (handled by the opener). Selecting an action dismisses first, then runs it, so a
37+ * follow-on dialog isn't immediately covered by the palette.
38+ */
39+@Composable
40+fun CommandPalette(
41+ actions: List<PaletteAction>,
42+ onDismiss: () -> Unit,
43+) {
44+ var query by remember { mutableStateOf("") }
45+ val filtered = remember(query, actions) {
46+ if (query.isBlank()) actions else actions.filter { it.label.contains(query, ignoreCase = true) }
47+ }
48+ val focus = remember { androidx.compose.ui.focus.FocusRequester() }
49+ fun run(action: PaletteAction) {
50+ onDismiss()
51+ action.onSelect()
52+ }
53+ Dialog(onDismissRequest = onDismiss) {
54+ Surface(
55+ shape = MaterialTheme.shapes.large,
56+ tonalElevation = 6.dp,
57+ color = MaterialTheme.colorScheme.surface,
58+ ) {
59+ Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
60+ OutlinedTextField(
61+ value = query,
62+ onValueChange = { query = it },
63+ placeholder = { Text(stringResource(R.string.palette_hint)) },
64+ singleLine = true,
65+ modifier = Modifier
66+ .fillMaxWidth()
67+ .focusRequester(focus)
68+ .onPreviewKeyEvent { ev ->
69+ if (ev.type == KeyEventType.KeyDown && ev.key == Key.Enter) {
70+ filtered.firstOrNull()?.let { run(it) }
71+ true
72+ } else {
73+ false
74+ }
75+ },
76+ )
77+ LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
78+ if (filtered.isEmpty()) {
79+ Text(
80+ stringResource(R.string.palette_no_results),
81+ style = MaterialTheme.typography.bodyMedium,
82+ color = MaterialTheme.colorScheme.onSurfaceVariant,
83+ modifier = Modifier.padding(16.dp),
84+ )
85+ } else {
86+ LazyColumn(modifier = Modifier.heightIn(max = 360.dp)) {
87+ items(filtered, key = { it.id }) { action ->
88+ Text(
89+ action.label,
90+ style = MaterialTheme.typography.bodyLarge,
91+ modifier = Modifier
92+ .fillMaxWidth()
93+ .clickable { run(action) }
94+ .padding(horizontal = 12.dp, vertical = 14.dp),
95+ )
96+ }
97+ }
98+ }
99+ }
100+ }
101+ }
102+}
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+93−26
@@ -6,7 +6,10 @@ import android.content.Context
66import android.widget.Toast
77import androidx.compose.foundation.background
88import androidx.compose.foundation.horizontalScroll
9+import androidx.compose.foundation.layout.Arrangement
910import androidx.compose.foundation.layout.Box
11+import androidx.compose.foundation.layout.Column
12+import androidx.compose.foundation.layout.Row
1013import androidx.compose.foundation.layout.fillMaxWidth
1114import androidx.compose.foundation.layout.padding
1215import androidx.compose.foundation.layout.size
@@ -14,11 +17,13 @@ import androidx.compose.foundation.rememberScrollState
1417import androidx.compose.foundation.text.selection.SelectionContainer
1518import androidx.compose.material.icons.Icons
1619import androidx.compose.material.icons.filled.ContentCopy
20+import androidx.compose.material.icons.automirrored.filled.WrapText
1721import androidx.compose.material3.Icon
1822import androidx.compose.material3.IconButton
1923import androidx.compose.material3.MaterialTheme
2024import androidx.compose.material3.Surface
2125import androidx.compose.material3.Text
26+import androidx.compose.material3.Typography
2227import androidx.compose.runtime.Composable
2328import androidx.compose.runtime.LaunchedEffect
2429import androidx.compose.runtime.getValue
@@ -40,7 +45,6 @@ import com.mikepenz.markdown.compose.components.MarkdownComponentModel
4045import com.mikepenz.markdown.compose.components.markdownComponents
4146import com.mikepenz.markdown.m3.Markdown
4247import org.intellij.markdown.MarkdownElementTypes
43−import org.intellij.markdown.ast.ASTNode
4448import org.intellij.markdown.ast.getTextInNode
4549import soy.iko.opencode.R
4650import soy.iko.opencode.data.network.NetworkConfig
@@ -76,10 +80,33 @@ import soy.iko.opencode.data.network.NetworkConfig
7680fun MarkdownText(
7781 markdown: String,
7882 modifier: Modifier = Modifier,
79− style: TextStyle = MaterialTheme.typography.bodyLarge,
83+ @Suppress("UNUSED_PARAMETER") style: TextStyle = MaterialTheme.typography.bodyLarge,
84+ streaming: Boolean = false,
85+) {
86+ val scale = LocalChatTextScale.current
87+ if (scale == 1f) {
88+ MarkdownBody(markdown, modifier, streaming)
89+ } else {
90+ // Scale the entire markdown subtree (headings, lists, code, body) from the user's
91+ // chat text-size preference by nesting a MaterialTheme with a scaled typography:
92+ // the renderer derives its typography from MaterialTheme, so this reaches every
93+ // element without plumbing a scale through the library. colorScheme/shapes default
94+ // to the current theme's, so only sizes change. Memoized so a recomposition that
95+ // doesn't change the scale doesn't rebuild the 15-role Typography.
96+ val base = MaterialTheme.typography
97+ val scaled = remember(scale, base) { base.scaledBy(scale) }
98+ MaterialTheme(typography = scaled) {
99+ MarkdownBody(markdown, modifier, streaming)
100+ }
101+ }
102+}
103+
104+@Composable
105+private fun MarkdownBody(
106+ markdown: String,
107+ modifier: Modifier = Modifier,
80108 streaming: Boolean = false,
81109) {
82− val context = LocalContext.current
83110 val components = remember {
84111 markdownComponents(
85112 codeFence = { CodeWithCopy(it) },
@@ -138,44 +165,61 @@ fun MarkdownText(
138165}
139166
140167/**
141− * Renders a fenced or indented code block with a small copy button in the top-right corner.
142− * Self-contained so it doesn't depend on the library's internal code-block composables.
168+ * Renders a fenced or indented code block with a header toolbar: the fence's language
169+ * label (when present), a wrap toggle (soft-wrap long lines vs. horizontal scroll), and a
170+ * copy button. Self-contained so it doesn't depend on the library's internal code-block
171+ * composables. The wrap toggle starts from the user's [LocalCodeWrap] preference but can
172+ * be flipped per-block.
143173 */
144174@Composable
145175private fun CodeWithCopy(model: MarkdownComponentModel) {
146176 val context = LocalContext.current
147177 // Key on the content string + the AST node's offset range (stable ints) instead of
148178 // the model itself. MarkdownComponentModel is not @Immutable/@Stable (it holds an
149− // ASTNode), so remembering on it re-executes extractCode on every recomposition.
179+ // ASTNode), so remembering on it re-executes extract* on every recomposition.
150180 val node = model.node
151− val code = remember(model.content, node.startOffset, node.endOffset) {
152− extractCode(model.content, node)
181+ val isFenced = node.type == MarkdownElementTypes.CODE_FENCE
182+ val raw = remember(model.content, node.startOffset, node.endOffset) {
183+ node.getTextInNode(model.content).toString()
153184 }
185+ val code = remember(raw, isFenced) { extractCodeText(raw, isFenced) }
186+ val language = remember(raw, isFenced) { extractFenceLanguage(raw, isFenced) }
154187 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
155− Box(
188+ // Default per-block wrap from the global preference; re-seed if the preference changes
189+ // while this block is composed (rememberSaveable would over-persist across blocks).
190+ val defaultWrap = LocalCodeWrap.current
191+ var wrap by remember(defaultWrap) { mutableStateOf(defaultWrap) }
192+ Column(
156193 modifier = Modifier
157194 .fillMaxWidth()
158195 .clip(MaterialTheme.shapes.small)
159196 .background(MaterialTheme.colorScheme.surfaceVariant),
160197 ) {
161− Text(
162− text = code,
163− style = codeStyle,
198+ Row(
164199 modifier = Modifier
165− .horizontalScroll(rememberScrollState())
166200 .fillMaxWidth()
167− .padding(start = 12.dp, top = 12.dp, end = 40.dp, bottom = 12.dp),
168− )
169− Surface(
170− color = MaterialTheme.colorScheme.surface,
171− shape = MaterialTheme.shapes.extraSmall,
172− modifier = Modifier
173− .align(Alignment.TopEnd)
174− .padding(4.dp),
201+ .padding(start = 12.dp, end = 2.dp),
202+ verticalAlignment = Alignment.CenterVertically,
175203 ) {
204+ Text(
205+ language ?: context.getString(R.string.code),
206+ style = MaterialTheme.typography.labelSmall,
207+ color = MaterialTheme.colorScheme.onSurfaceVariant,
208+ modifier = Modifier.weight(1f),
209+ )
210+ val wrapLabel = context.getString(if (wrap) R.string.code_no_wrap else R.string.code_wrap)
211+ IconButton(onClick = { wrap = !wrap }, modifier = Modifier.size(36.dp)) {
212+ Icon(
213+ Icons.AutoMirrored.Filled.WrapText,
214+ contentDescription = wrapLabel,
215+ modifier = Modifier.size(16.dp),
216+ tint = if (wrap) MaterialTheme.colorScheme.primary
217+ else MaterialTheme.colorScheme.onSurfaceVariant,
218+ )
219+ }
176220 IconButton(
177221 onClick = { copyToClipboard(context, "code", code) },
178− modifier = Modifier.size(48.dp),
222+ modifier = Modifier.size(36.dp),
179223 ) {
180224 Icon(
181225 Icons.Filled.ContentCopy,
@@ -185,15 +229,38 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
185229 )
186230 }
187231 }
232+ // Wrap → let the Text wrap naturally (no horizontal scroll). No-wrap → horizontal
233+ // scroll so long lines keep their formatting. Selectable so a portion can be copied.
234+ SelectionContainer {
235+ Text(
236+ text = code,
237+ style = codeStyle,
238+ modifier = if (wrap) {
239+ Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
240+ } else {
241+ Modifier
242+ .horizontalScroll(rememberScrollState())
243+ .fillMaxWidth()
244+ .padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
245+ },
246+ )
247+ }
188248 }
189249}
190250
191251/**
192− * Extract the raw code text from a markdown code node, stripping fence markers
193− * (```/~~~ and the language tag) so only the code content is copied.
252+ * Extract the fence language tag (e.g. "kotlin" from ```kotlin) so the code block can
253+ * label itself. Returns null for indented blocks or fences with no info string. Extracted
254+ * as a pure function for testability.
194255 */
195−private fun extractCode(content: String, node: ASTNode): String =
196− extractCodeText(node.getTextInNode(content).toString(), node.type == MarkdownElementTypes.CODE_FENCE)
256+internal fun extractFenceLanguage(raw: String, isFenced: Boolean): String? {
257+ if (!isFenced) return null
258+ val first = raw.lineSequence().firstOrNull()?.trim() ?: return null
259+ val info = first.removePrefix("```").removePrefix("~~~").trim()
260+ // A fence info string can carry more than the language (e.g. "```ts title=foo"); the
261+ // first whitespace-delimited token is the language.
262+ return info.substringBefore(' ').takeIf { it.isNotBlank() }
263+}
197264
198265/**
199266 * Strip fence markers (```/~~~ and the language tag) from a raw code node text so
app/src/main/java/soy/iko/opencode/ui/components/PaletteAction.ktnew file+11
@@ -0,0 +1,11 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.runtime.Immutable
4+
5+/** One entry in the [CommandPalette]: a label the user can search for and an action to run. */
6+@Immutable
7+data class PaletteAction(
8+ val id: String,
9+ val label: String,
10+ val onSelect: () -> Unit,
11+)
app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.ktnew file+140
@@ -0,0 +1,140 @@
1+package soy.iko.opencode.ui.mcp
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.BoxScope
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.PaddingValues
8+import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.Spacer
10+import androidx.compose.foundation.layout.fillMaxSize
11+import androidx.compose.foundation.layout.fillMaxWidth
12+import androidx.compose.foundation.layout.padding
13+import androidx.compose.foundation.layout.size
14+import androidx.compose.foundation.lazy.LazyColumn
15+import androidx.compose.foundation.lazy.items
16+import androidx.compose.material.icons.Icons
17+import androidx.compose.material.icons.automirrored.filled.ArrowBack
18+import androidx.compose.material.icons.filled.CloudQueue
19+import androidx.compose.material.icons.filled.Computer
20+import androidx.compose.material.icons.filled.Refresh
21+import androidx.compose.material3.Button
22+import androidx.compose.material3.Card
23+import androidx.compose.material3.CircularProgressIndicator
24+import androidx.compose.material3.ExperimentalMaterial3Api
25+import androidx.compose.material3.Icon
26+import androidx.compose.material3.IconButton
27+import androidx.compose.material3.MaterialTheme
28+import androidx.compose.material3.Scaffold
29+import androidx.compose.material3.Text
30+import androidx.compose.material3.TopAppBar
31+import androidx.compose.runtime.Composable
32+import androidx.compose.runtime.getValue
33+import androidx.compose.ui.Alignment
34+import androidx.compose.ui.Modifier
35+import androidx.compose.ui.res.stringResource
36+import androidx.compose.ui.text.style.TextOverflow
37+import androidx.compose.ui.unit.dp
38+import androidx.lifecycle.compose.collectAsStateWithLifecycle
39+import androidx.lifecycle.viewmodel.compose.viewModel
40+import soy.iko.opencode.R
41+import soy.iko.opencode.di.AppContainer
42+import soy.iko.opencode.ui.vmFactory
43+
44+@OptIn(ExperimentalMaterial3Api::class)
45+@Composable
46+fun McpScreen(container: AppContainer, onBack: () -> Unit) {
47+ val vm: McpViewModel = viewModel(factory = vmFactory { McpViewModel(container) })
48+ val state by vm.state.collectAsStateWithLifecycle()
49+
50+ Scaffold(
51+ topBar = {
52+ TopAppBar(
53+ title = { Text(stringResource(R.string.mcp_servers)) },
54+ navigationIcon = {
55+ IconButton(onClick = onBack) {
56+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
57+ }
58+ },
59+ actions = {
60+ IconButton(onClick = { vm.load() }) {
61+ Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
62+ }
63+ },
64+ )
65+ },
66+ ) { padding ->
67+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
68+ when (val s = state) {
69+ is McpViewModel.State.Loading ->
70+ CircularProgressIndicator(Modifier.align(Alignment.Center))
71+ is McpViewModel.State.Disconnected -> CenteredText(stringResource(R.string.not_connected))
72+ is McpViewModel.State.Error -> Column(
73+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
74+ horizontalAlignment = Alignment.CenterHorizontally,
75+ ) {
76+ Text(stringResource(R.string.mcp_failed), color = MaterialTheme.colorScheme.error)
77+ Spacer(Modifier.size(12.dp))
78+ Button(onClick = { vm.load() }) { Text(stringResource(R.string.retry)) }
79+ }
80+ is McpViewModel.State.Ready ->
81+ if (s.servers.isEmpty()) {
82+ CenteredText(stringResource(R.string.mcp_empty))
83+ } else {
84+ LazyColumn(
85+ modifier = Modifier.fillMaxSize(),
86+ contentPadding = PaddingValues(16.dp),
87+ verticalArrangement = Arrangement.spacedBy(12.dp),
88+ ) {
89+ items(s.servers, key = { it.name }) { McpServerCard(it) }
90+ }
91+ }
92+ }
93+ }
94+ }
95+}
96+
97+@Composable
98+private fun BoxScope.CenteredText(text: String) {
99+ Text(
100+ text,
101+ color = MaterialTheme.colorScheme.onSurfaceVariant,
102+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
103+ )
104+}
105+
106+@Composable
107+private fun McpServerCard(server: McpServerInfo) {
108+ Card(modifier = Modifier.fillMaxWidth()) {
109+ Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
110+ val remote = server.type.equals("remote", ignoreCase = true)
111+ Icon(
112+ if (remote) Icons.Filled.CloudQueue else Icons.Filled.Computer,
113+ contentDescription = null,
114+ tint = if (server.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
115+ )
116+ Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
117+ Text(server.name, style = MaterialTheme.typography.titleSmall)
118+ server.target?.let {
119+ Text(
120+ it,
121+ style = MaterialTheme.typography.bodySmall,
122+ color = MaterialTheme.colorScheme.onSurfaceVariant,
123+ maxLines = 2,
124+ overflow = TextOverflow.Ellipsis,
125+ )
126+ }
127+ }
128+ val stateLabel = when {
129+ !server.enabled -> stringResource(R.string.mcp_disabled)
130+ server.type != null -> server.type
131+ else -> stringResource(R.string.mcp_connected)
132+ }
133+ Text(
134+ stateLabel,
135+ style = MaterialTheme.typography.labelMedium,
136+ color = if (server.enabled) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.error,
137+ )
138+ }
139+ }
140+}
app/src/main/java/soy/iko/opencode/ui/mcp/McpServerInfo.ktnew file+45
@@ -0,0 +1,45 @@
1+package soy.iko.opencode.ui.mcp
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.json.JsonArray
5+import kotlinx.serialization.json.JsonObject
6+import kotlinx.serialization.json.JsonPrimitive
7+import kotlinx.serialization.json.booleanOrNull
8+import kotlinx.serialization.json.contentOrNull
9+
10+/**
11+ * A configured MCP (Model Context Protocol) server, as read from the server's `config.mcp`
12+ * map. [type] is "local" (spawns a command) or "remote" (connects to a URL); [target] is the
13+ * command line or URL, whichever applies. Best-effort — fields the server omits are null.
14+ */
15+@Immutable
16+data class McpServerInfo(
17+ val name: String,
18+ val type: String?,
19+ val enabled: Boolean,
20+ val target: String?,
21+)
22+
23+/**
24+ * Extract the configured MCP servers from a resolved opencode `config` object. Tolerant of
25+ * shape drift: a non-object entry, or missing fields, degrade gracefully rather than throwing,
26+ * so a server-side config addition never breaks the viewer. Pure for testability.
27+ */
28+fun parseMcpServers(config: JsonObject): List<McpServerInfo> {
29+ val mcp = config["mcp"] as? JsonObject ?: return emptyList()
30+ return mcp.entries.map { (name, value) ->
31+ val obj = value as? JsonObject
32+ if (obj == null) {
33+ McpServerInfo(name = name, type = null, enabled = true, target = null)
34+ } else {
35+ val type = (obj["type"] as? JsonPrimitive)?.contentOrNull
36+ val enabled = (obj["enabled"] as? JsonPrimitive)?.booleanOrNull ?: true
37+ val url = (obj["url"] as? JsonPrimitive)?.contentOrNull
38+ val command = (obj["command"] as? JsonArray)
39+ ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
40+ ?.joinToString(" ")
41+ ?.takeIf { it.isNotBlank() }
42+ McpServerInfo(name = name, type = type, enabled = enabled, target = url ?: command)
43+ }
44+ }.sortedBy { it.name.lowercase() }
45+}
app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.ktnew file+44
@@ -0,0 +1,44 @@
1+package soy.iko.opencode.ui.mcp
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.flow.MutableStateFlow
7+import kotlinx.coroutines.flow.StateFlow
8+import kotlinx.coroutines.flow.asStateFlow
9+import kotlinx.coroutines.launch
10+import kotlinx.coroutines.withContext
11+import soy.iko.opencode.di.AppContainer
12+import soy.iko.opencode.util.runCatchingCancellable
13+
14+/** Loads the configured MCP servers from the active connection's `config` (read-only view). */
15+class McpViewModel(private val container: AppContainer) : ViewModel() {
16+
17+ sealed interface State {
18+ data object Loading : State
19+ data object Disconnected : State
20+ data object Error : State
21+ data class Ready(val servers: List<McpServerInfo>) : State
22+ }
23+
24+ private val _state = MutableStateFlow<State>(State.Loading)
25+ val state: StateFlow<State> = _state.asStateFlow()
26+
27+ init { load() }
28+
29+ fun load() {
30+ val conn = container.activeConnection.value ?: run {
31+ _state.value = State.Disconnected
32+ return
33+ }
34+ _state.value = State.Loading
35+ viewModelScope.launch {
36+ runCatchingCancellable {
37+ val config = conn.api.config()
38+ withContext(Dispatchers.Default) { parseMcpServers(config) }
39+ }
40+ .onSuccess { _state.value = State.Ready(it) }
41+ .onFailure { _state.value = State.Error }
42+ }
43+ }
44+}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+51
@@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme
3232import androidx.compose.material3.OutlinedButton
3333import androidx.compose.material3.OutlinedTextField
3434import androidx.compose.material3.Scaffold
35+import androidx.compose.material3.Switch
3536import androidx.compose.material3.Text
3637import androidx.compose.material3.TextButton
3738import androidx.compose.material3.TopAppBar
@@ -243,6 +244,7 @@ private fun ServerEditForm(
243244 onImeDone = { if (canSave && !state.saving) vm.saveAndConnect(onDone) },
244245 )
245246 }
247+ SecurityFields(state = state, onUpdate = vm::update)
246248 Row(
247249 modifier = Modifier.fillMaxWidth(),
248250 horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -362,6 +364,55 @@ private fun AuthFields(
362364 }
363365}
364366
367+/** Optional TLS hardening for this server: force HTTPS (upgrade cleartext) and pin the
368+ * server certificate. Both are off by default so the common localhost/HTTP setup is
369+ * unaffected. */
370+@Composable
371+private fun SecurityFields(
372+ state: ServerEditState,
373+ onUpdate: (((ServerEditState) -> ServerEditState)) -> Unit,
374+) {
375+ Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
376+ Text(
377+ stringResource(R.string.server_security),
378+ style = MaterialTheme.typography.titleSmall,
379+ color = MaterialTheme.colorScheme.primary,
380+ )
381+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
382+ Column(modifier = Modifier.weight(1f)) {
383+ Text(stringResource(R.string.require_https), style = MaterialTheme.typography.bodyLarge)
384+ Text(
385+ stringResource(R.string.require_https_desc),
386+ style = MaterialTheme.typography.bodySmall,
387+ color = MaterialTheme.colorScheme.onSurfaceVariant,
388+ )
389+ }
390+ Switch(
391+ checked = state.requireHttps,
392+ onCheckedChange = { v -> onUpdate { it.copy(requireHttps = v) } },
393+ )
394+ }
395+ OutlinedTextField(
396+ value = state.certPin,
397+ onValueChange = { v -> onUpdate { it.copy(certPin = v) } },
398+ label = { Text(stringResource(R.string.cert_pin)) },
399+ placeholder = { Text(stringResource(R.string.cert_pin_hint)) },
400+ singleLine = true,
401+ isError = !state.certPinValid,
402+ supportingText = {
403+ Text(
404+ stringResource(if (state.certPinValid) R.string.cert_pin_desc else R.string.cert_pin_invalid),
405+ style = MaterialTheme.typography.bodySmall,
406+ color = if (state.certPinValid) MaterialTheme.colorScheme.onSurfaceVariant
407+ else MaterialTheme.colorScheme.error,
408+ )
409+ },
410+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
411+ modifier = Modifier.fillMaxWidth().testTag("server_cert_pin"),
412+ )
413+ }
414+}
415+
365416/**
366417 * Optional LAN discovery: browses for opencode servers advertised via mDNS (`--mdns`) and
367418 * offers each as a one-tap fill for the base URL. Collapsed by default so the discovery (and
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+31−2
@@ -25,6 +25,10 @@ data class ServerEditState(
2525 val baseUrl: String = "",
2626 val username: String = "",
2727 val password: String = "",
28+ /** Upgrade cleartext http:// to https:// for this server. */
29+ val requireHttps: Boolean = false,
30+ /** Optional OkHttp certificate pin(s), "sha256/<base64>" (whitespace/comma separated). */
31+ val certPin: String = "",
2832 val loaded: Boolean = false,
2933 val saving: Boolean = false,
3034 val error: String? = null,
@@ -42,8 +46,10 @@ data class ServerEditState(
4246 * Null until a probe runs, and cleared when the base URL is edited. */
4347 val probeReachable: Boolean? = null,
4448) {
45− val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
49+ val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl) && certPinValid
4650 val isNew: Boolean get() = id == null
51+ /** A blank pin is fine (feature off); otherwise every entry must be a valid OkHttp pin. */
52+ val certPinValid: Boolean get() = isValidCertPin(certPin)
4753 /** True if any field differs from its loaded value. */
4854 val isDirty: Boolean
4955 get() {
@@ -51,7 +57,9 @@ data class ServerEditState(
5157 return label.trim() != init.label ||
5258 baseUrl.trim() != init.baseUrl ||
5359 username.trim() != init.username ||
54− password.trim() != init.password
60+ password.trim() != init.password ||
61+ requireHttps != init.requireHttps ||
62+ certPin.trim() != init.certPin
5563 }
5664}
5765
@@ -61,8 +69,19 @@ data class InitialProfile(
6169 val baseUrl: String,
6270 val username: String,
6371 val password: String,
72+ val requireHttps: Boolean = false,
73+ val certPin: String = "",
6474)
6575
76+/** True when the certificate-pin field is empty (feature off) or every whitespace/comma
77+ * separated entry is a well-formed OkHttp "sha256/<base64>" pin. */
78+fun isValidCertPin(raw: String): Boolean {
79+ val entries = raw.split(Regex("[\\s,]+")).map { it.trim() }.filter { it.isNotEmpty() }
80+ if (entries.isEmpty()) return true
81+ val pinRegex = Regex("sha256/[A-Za-z0-9+/=]+")
82+ return entries.all { it.matches(pinRegex) }
83+}
84+
6685fun isValidUrl(url: String): Boolean = try {
6786 val u = java.net.URI(url.trim())
6887 u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
@@ -105,6 +124,8 @@ class ServerEditViewModel(
105124 baseUrl = existing.baseUrl,
106125 username = existing.username.orEmpty(),
107126 password = existing.password.orEmpty(),
127+ requireHttps = existing.requireHttps,
128+ certPin = existing.certPin.orEmpty().trim(),
108129 )
109130 _state.value = ServerEditState(
110131 id = existing.id,
@@ -112,6 +133,8 @@ class ServerEditViewModel(
112133 baseUrl = existing.baseUrl,
113134 username = existing.username.orEmpty(),
114135 password = existing.password.orEmpty(),
136+ requireHttps = existing.requireHttps,
137+ certPin = existing.certPin.orEmpty(),
115138 loaded = true,
116139 authFieldsVisible = existing.hasAuth,
117140 initial = init,
@@ -142,6 +165,8 @@ class ServerEditViewModel(
142165 baseUrl = source.baseUrl,
143166 username = source.username.orEmpty(),
144167 password = source.password.orEmpty(),
168+ requireHttps = source.requireHttps,
169+ certPin = source.certPin.orEmpty(),
145170 loaded = true,
146171 authFieldsVisible = source.hasAuth,
147172 // Snapshot the seeded (copy) values so an untouched duplicate isn't
@@ -152,6 +177,8 @@ class ServerEditViewModel(
152177 baseUrl = source.baseUrl.trim(),
153178 username = source.username.orEmpty().trim(),
154179 password = source.password.orEmpty().trim(),
180+ requireHttps = source.requireHttps,
181+ certPin = source.certPin.orEmpty().trim(),
155182 ),
156183 )
157184 return@launch
@@ -272,6 +299,8 @@ class ServerEditViewModel(
272299 username = s.username.trim().takeIf { it.isNotBlank() },
273300 password = s.password.trim().takeIf { it.isNotEmpty() },
274301 lastUsed = existingLastUsed,
302+ requireHttps = s.requireHttps,
303+ certPin = s.certPin.trim().takeIf { it.isNotBlank() },
275304 )
276305 container.profileStore.save(saved)
277306 if (connectAfter) {
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+18
@@ -75,6 +75,7 @@ import androidx.compose.ui.text.input.ImeAction
7575import androidx.compose.ui.text.style.TextOverflow
7676import androidx.compose.ui.unit.dp
7777import androidx.compose.ui.res.stringResource
78+import androidx.compose.ui.platform.LocalContext
7879import androidx.lifecycle.compose.collectAsStateWithLifecycle
7980import androidx.lifecycle.viewmodel.compose.viewModel
8081import kotlinx.coroutines.coroutineScope
@@ -84,7 +85,11 @@ import kotlinx.coroutines.launch
8485import soy.iko.opencode.data.model.ServerProfile
8586import soy.iko.opencode.data.model.Session
8687import soy.iko.opencode.data.network.NetworkConfig
88+import soy.iko.opencode.data.repo.RecentSession
89+import soy.iko.opencode.data.repo.RecentSessionsStore
8790import soy.iko.opencode.di.AppContainer
91+import soy.iko.opencode.platform.AppShortcuts
92+import soy.iko.opencode.platform.SessionsWidgetProvider
8893import soy.iko.opencode.R
8994import soy.iko.opencode.ui.components.ConnectionBanner
9095import soy.iko.opencode.ui.components.LocalRelativeTimeTick
@@ -139,6 +144,19 @@ fun SessionListScreen(
139144 val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
140145 val pendingRename = pendingRenameId?.let { id -> state.sessions.firstOrNull { it.id == id } }
141146
147+ // Feed the home-screen widget, launcher shortcuts, and "Resume last" from the loaded
148+ // session list — the best source of session titles. Cheap, idempotent, and off the main
149+ // thread (the store write dispatches to IO); the widget refresh no-ops when none is placed.
150+ val platformContext = LocalContext.current.applicationContext
151+ LaunchedEffect(state.sessions) {
152+ val sessions = state.sessions
153+ if (sessions.isEmpty()) return@LaunchedEffect
154+ val recents = sessions.take(RecentSessionsStore.MAX).map { RecentSession(it.id, it.displayTitle) }
155+ RecentSessionsStore.write(platformContext, recents)
156+ AppShortcuts.update(platformContext, recents.firstOrNull())
157+ SessionsWidgetProvider.refresh(platformContext)
158+ }
159+
142160 // Close the open dropdown on back press instead of navigating away.
143161 BackHandler(enabled = showServerMenu) { showServerMenu = false }
144162 BackHandler(enabled = showSortMenu) { showSortMenu = false }
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+1
@@ -130,6 +130,7 @@ fun TwoPaneSessionChat(
130130 sessionId = sessionId,
131131 onBack = { selected = null },
132132 onOpenFile = onOpenFile,
133+ onOpenSession = { id -> selected = id },
133134 )
134135 }
135136 }
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+238−1
@@ -2,6 +2,8 @@ package soy.iko.opencode.ui.settings
22
33import android.content.pm.PackageManager
44import android.os.Build
5+import androidx.activity.compose.rememberLauncherForActivityResult
6+import androidx.activity.result.contract.ActivityResultContracts
57import androidx.compose.foundation.background
68import androidx.compose.foundation.clickable
79import androidx.compose.foundation.layout.Arrangement
@@ -24,6 +26,10 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
2426import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
2527import androidx.compose.material.icons.filled.BugReport
2628import androidx.compose.material.icons.filled.Dns
29+import androidx.compose.material.icons.filled.Download
30+import androidx.compose.material.icons.filled.Hub
31+import androidx.compose.material.icons.filled.QueryStats
32+import androidx.compose.material.icons.filled.Upload
2733import androidx.compose.material3.ExperimentalMaterial3Api
2834import androidx.compose.material3.CircularProgressIndicator
2935import androidx.compose.material3.HorizontalDivider
@@ -32,14 +38,19 @@ import androidx.compose.material3.IconButton
3238import androidx.compose.material3.MaterialTheme
3339import androidx.compose.material3.RadioButton
3440import androidx.compose.material3.Scaffold
41+import androidx.compose.material3.Slider
3542import androidx.compose.material3.Switch
3643import androidx.compose.material3.Text
3744import androidx.compose.material3.TextButton
3845import androidx.compose.material3.TopAppBar
3946import androidx.compose.runtime.Composable
4047import androidx.compose.runtime.getValue
48+import androidx.compose.runtime.mutableFloatStateOf
49+import androidx.compose.runtime.mutableStateOf
4150import androidx.compose.runtime.remember
4251import androidx.compose.runtime.rememberCoroutineScope
52+import androidx.compose.runtime.saveable.rememberSaveable
53+import androidx.compose.runtime.setValue
4354import androidx.compose.ui.Alignment
4455import androidx.compose.ui.Modifier
4556import androidx.compose.ui.draw.clip
@@ -60,17 +71,27 @@ import kotlinx.coroutines.flow.flowOf
6071import kotlinx.coroutines.launch
6172import soy.iko.opencode.data.network.EventStreamClient
6273import soy.iko.opencode.data.repo.CrashLogger
74+import soy.iko.opencode.data.repo.SettingsStore
6375import soy.iko.opencode.data.repo.ThemeMode
6476import soy.iko.opencode.di.AppContainer
6577import soy.iko.opencode.R
6678import soy.iko.opencode.ui.canAuthenticateForAppLock
79+import soy.iko.opencode.ui.components.showToast
6780import soy.iko.opencode.ui.theme.LightPaletteSwatches
6881import soy.iko.opencode.ui.theme.DarkPaletteSwatches
6982import soy.iko.opencode.util.runCatchingCancellable
83+import kotlin.math.roundToInt
7084
7185@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class)
7286@Composable
73−fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers: () -> Unit, onOpenDiagnostics: () -> Unit) {
87+fun SettingsScreen(
88+ container: AppContainer,
89+ onBack: () -> Unit,
90+ onManageServers: () -> Unit,
91+ onOpenDiagnostics: () -> Unit,
92+ onOpenUsage: () -> Unit = {},
93+ onOpenMcp: () -> Unit = {},
94+) {
7495 val scope = rememberCoroutineScope()
7596 // Combine the three settings into a single nullable state so the appearance section
7697 // renders only after the persisted values have loaded, avoiding a brief flash of
@@ -87,6 +108,12 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
87108 ) { theme, dyn, enter, lock -> SettingsValues(theme, dyn, enter, lock) as SettingsValues? }
88109 }
89110 val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
111+ // Text/display prefs are collected separately from [settingsFlow] (kotlinx combine
112+ // tops out at 5 typed flows) and render independently — they don't need the same
113+ // load-gate as the appearance block.
114+ val chatTextScale by container.settingsStore.chatTextScale
115+ .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
116+ val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
90117 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
91118 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
92119 // SSE connection state so the Settings screen can show a dropped stream (the
@@ -121,6 +148,40 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
121148 }.getOrNull() ?: unknownVersion
122149 }
123150
151+ // Backup/restore via the Storage Access Framework. Export writes the JSON the user names;
152+ // import reads a chosen file and applies it. Feedback is a toast (this screen has no snackbar).
153+ var includePasswords by rememberSaveable { mutableStateOf(false) }
154+ val exportedMsg = stringResource(R.string.backup_exported)
155+ val exportFailedMsg = stringResource(R.string.backup_export_failed)
156+ val importedMsg = stringResource(R.string.backup_imported)
157+ val importFailedMsg = stringResource(R.string.backup_import_failed)
158+ val exportLauncher = rememberLauncherForActivityResult(
159+ ActivityResultContracts.CreateDocument("application/json"),
160+ ) { uri ->
161+ if (uri == null) return@rememberLauncherForActivityResult
162+ scope.launch {
163+ val ok = runCatchingCancellable {
164+ val text = container.backupManager.export(includePasswords)
165+ context.contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) }
166+ ?: error("no output stream")
167+ }.isSuccess
168+ showToast(context, if (ok) exportedMsg else exportFailedMsg)
169+ }
170+ }
171+ val importLauncher = rememberLauncherForActivityResult(
172+ ActivityResultContracts.OpenDocument(),
173+ ) { uri ->
174+ if (uri == null) return@rememberLauncherForActivityResult
175+ scope.launch {
176+ val ok = runCatchingCancellable {
177+ val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
178+ ?: error("no input stream")
179+ container.backupManager.import(text)
180+ }.isSuccess
181+ showToast(context, if (ok) importedMsg else importFailedMsg)
182+ }
183+ }
184+
124185 Scaffold(
125186 topBar = {
126187 TopAppBar(
@@ -250,6 +311,41 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
250311
251312 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
252313
314+ Text(
315+ stringResource(R.string.text_display),
316+ style = MaterialTheme.typography.titleSmall,
317+ color = MaterialTheme.colorScheme.primary,
318+ modifier = Modifier.semantics { heading() },
319+ )
320+ ChatTextSizeControl(
321+ scale = chatTextScale,
322+ onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
323+ )
324+ Spacer(Modifier.size(4.dp))
325+ Row(
326+ modifier = Modifier
327+ .fillMaxWidth()
328+ .toggleable(
329+ value = codeWrap,
330+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
331+ role = Role.Switch,
332+ )
333+ .padding(vertical = 8.dp),
334+ verticalAlignment = Alignment.CenterVertically,
335+ ) {
336+ Column(modifier = Modifier.weight(1f)) {
337+ Text(stringResource(R.string.wrap_code_blocks), style = MaterialTheme.typography.bodyLarge)
338+ Text(
339+ stringResource(R.string.wrap_code_blocks_desc),
340+ style = MaterialTheme.typography.bodySmall,
341+ color = MaterialTheme.colorScheme.onSurfaceVariant,
342+ )
343+ }
344+ Switch(checked = codeWrap, onCheckedChange = null)
345+ }
346+
347+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
348+
253349 Text(
254350 stringResource(R.string.connection),
255351 style = MaterialTheme.typography.titleSmall,
@@ -316,6 +412,70 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
316412 Text(stringResource(R.string.manage_servers), modifier = Modifier.weight(1f).padding(start = 8.dp))
317413 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
318414 }
415+ NavRow(
416+ icon = Icons.Filled.QueryStats,
417+ label = stringResource(R.string.usage_title),
418+ enabled = activeProfile != null,
419+ onClick = onOpenUsage,
420+ )
421+ NavRow(
422+ icon = Icons.Filled.Hub,
423+ label = stringResource(R.string.mcp_servers),
424+ enabled = activeProfile != null,
425+ onClick = onOpenMcp,
426+ )
427+
428+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
429+
430+ Text(
431+ stringResource(R.string.backup_restore),
432+ style = MaterialTheme.typography.titleSmall,
433+ color = MaterialTheme.colorScheme.primary,
434+ modifier = Modifier.semantics { heading() },
435+ )
436+ Text(
437+ stringResource(R.string.backup_desc),
438+ style = MaterialTheme.typography.bodySmall,
439+ color = MaterialTheme.colorScheme.onSurfaceVariant,
440+ modifier = Modifier.padding(top = 4.dp),
441+ )
442+ Row(
443+ modifier = Modifier
444+ .fillMaxWidth()
445+ .toggleable(
446+ value = includePasswords,
447+ onValueChange = { includePasswords = it },
448+ role = Role.Switch,
449+ )
450+ .padding(vertical = 8.dp),
451+ verticalAlignment = Alignment.CenterVertically,
452+ ) {
453+ Column(modifier = Modifier.weight(1f)) {
454+ Text(stringResource(R.string.backup_include_passwords), style = MaterialTheme.typography.bodyLarge)
455+ Text(
456+ stringResource(R.string.backup_include_passwords_desc),
457+ style = MaterialTheme.typography.bodySmall,
458+ color = MaterialTheme.colorScheme.onSurfaceVariant,
459+ )
460+ }
461+ Switch(checked = includePasswords, onCheckedChange = null)
462+ }
463+ NavRow(
464+ icon = Icons.Filled.Upload,
465+ label = stringResource(R.string.export_backup),
466+ enabled = true,
467+ onClick = { runCatching { exportLauncher.launch("opencode-backup.json") } },
468+ )
469+ NavRow(
470+ icon = Icons.Filled.Download,
471+ label = stringResource(R.string.import_backup),
472+ enabled = true,
473+ onClick = {
474+ runCatching {
475+ importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
476+ }
477+ },
478+ )
319479
320480 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
321481
@@ -360,6 +520,83 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
360520 }
361521}
362522
523+/** A clickable settings row: leading icon, label, trailing chevron, with a disabled state. */
524+@Composable
525+private fun NavRow(
526+ icon: androidx.compose.ui.graphics.vector.ImageVector,
527+ label: String,
528+ enabled: Boolean,
529+ onClick: () -> Unit,
530+) {
531+ Row(
532+ modifier = Modifier
533+ .fillMaxWidth()
534+ .defaultMinSize(minHeight = 48.dp)
535+ .clickable(enabled = enabled, role = Role.Button) { onClick() }
536+ .padding(vertical = 8.dp),
537+ verticalAlignment = Alignment.CenterVertically,
538+ ) {
539+ Icon(
540+ icon,
541+ contentDescription = null,
542+ tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
543+ )
544+ Text(
545+ label,
546+ modifier = Modifier.weight(1f).padding(start = 8.dp),
547+ color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
548+ )
549+ Icon(
550+ Icons.AutoMirrored.Filled.KeyboardArrowRight,
551+ contentDescription = null,
552+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
553+ )
554+ }
555+}
556+
557+/** Chat text-size slider with a live sample line, a percentage readout, and a Reset.
558+ * Drives [SettingsStore.chatTextScale]; persists only when the drag settles so a DataStore
559+ * write doesn't fire on every pixel of movement. */
560+@Composable
561+private fun ChatTextSizeControl(scale: Float, onScaleChange: (Float) -> Unit) {
562+ // Local slider state so dragging stays smooth; re-seed when the persisted value changes
563+ // (e.g. Reset here, or an external write) so the control stays in sync.
564+ var sliderValue by remember(scale) { mutableFloatStateOf(scale) }
565+ val body = MaterialTheme.typography.bodyLarge
566+ Text(
567+ stringResource(R.string.text_size_sample),
568+ style = body.copy(fontSize = body.fontSize * sliderValue, lineHeight = body.lineHeight * sliderValue),
569+ modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
570+ )
571+ Text(
572+ stringResource(R.string.chat_text_size_desc),
573+ style = MaterialTheme.typography.bodySmall,
574+ color = MaterialTheme.colorScheme.onSurfaceVariant,
575+ )
576+ Row(verticalAlignment = Alignment.CenterVertically) {
577+ Slider(
578+ value = sliderValue,
579+ onValueChange = { sliderValue = it },
580+ onValueChangeFinished = { onScaleChange(sliderValue) },
581+ valueRange = SettingsStore.MIN_CHAT_TEXT_SCALE..SettingsStore.MAX_CHAT_TEXT_SCALE,
582+ modifier = Modifier.weight(1f),
583+ )
584+ Text(
585+ stringResource(R.string.text_scale_value, (sliderValue * 100).roundToInt()),
586+ style = MaterialTheme.typography.labelLarge,
587+ textAlign = TextAlign.End,
588+ modifier = Modifier.widthIn(min = 44.dp).padding(start = 8.dp),
589+ )
590+ }
591+ TextButton(
592+ onClick = {
593+ sliderValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE
594+ onScaleChange(SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
595+ },
596+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
597+ ) { Text(stringResource(R.string.reset)) }
598+}
599+
363600@Composable
364601private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
365602 // Preview swatches so the user can see what each mode looks like before selecting,
app/src/main/java/soy/iko/opencode/ui/usage/UsageAggregation.ktnew file+106
@@ -0,0 +1,106 @@
1+package soy.iko.opencode.ui.usage
2+
3+import androidx.compose.runtime.Immutable
4+import soy.iko.opencode.data.model.AssistantMessage
5+import soy.iko.opencode.data.model.MessageWithParts
6+import soy.iko.opencode.data.model.Tokens
7+
8+/** Aggregated usage for one model across the scanned sessions. */
9+@Immutable
10+data class ModelUsage(
11+ val model: String,
12+ val messages: Int,
13+ val cost: Double,
14+ val tokens: Tokens,
15+)
16+
17+/** Aggregated usage for one session. */
18+@Immutable
19+data class SessionUsage(
20+ val sessionId: String,
21+ val title: String,
22+ val messages: Int,
23+ val cost: Double,
24+ val tokens: Tokens,
25+)
26+
27+/** A full usage report: overall totals plus per-model and per-session breakdowns. */
28+@Immutable
29+data class UsageReport(
30+ val totalCost: Double = 0.0,
31+ val totalTokens: Tokens = Tokens(),
32+ val messageCount: Int = 0,
33+ val sessionCount: Int = 0,
34+ val byModel: List<ModelUsage> = emptyList(),
35+ val bySession: List<SessionUsage> = emptyList(),
36+) {
37+ val isEmpty: Boolean get() = messageCount == 0
38+}
39+
40+/** Sum of two token counters (including the cache read/write sub-counters). */
41+internal operator fun Tokens.plus(other: Tokens): Tokens = Tokens(
42+ input = input + other.input,
43+ output = output + other.output,
44+ reasoning = reasoning + other.reasoning,
45+ cache = Tokens.Cache(read = cache.read + other.cache.read, write = cache.write + other.cache.write),
46+)
47+
48+/** Total tokens across all sub-counters, for a single "N tokens" headline. */
49+internal val Tokens.total: Long get() = input + output + reasoning + cache.read + cache.write
50+
51+private class MutableAgg {
52+ var messages = 0
53+ var cost = 0.0
54+ var tokens = Tokens()
55+}
56+
57+/**
58+ * Aggregate cost/token usage across sessions. Each entry is (sessionId, title, messages).
59+ * Only [AssistantMessage]s that reported cost or tokens contribute — user prompts and
60+ * zero-usage messages are ignored so the counts reflect real model spend. Pure and
61+ * synchronous so it's unit-testable and can run off the main thread.
62+ */
63+fun aggregateUsage(sessions: List<Triple<String, String, List<MessageWithParts>>>): UsageReport {
64+ var totalCost = 0.0
65+ var totalTokens = Tokens()
66+ var messageCount = 0
67+ val byModel = LinkedHashMap<String, MutableAgg>()
68+ val bySession = ArrayList<SessionUsage>()
69+
70+ for ((id, title, messages) in sessions) {
71+ var sessionCost = 0.0
72+ var sessionTokens = Tokens()
73+ var sessionMessages = 0
74+ for (m in messages) {
75+ val info = m.info as? AssistantMessage ?: continue
76+ val cost = info.cost ?: 0.0
77+ val tokens = info.tokens ?: Tokens()
78+ if (cost == 0.0 && tokens.total == 0L) continue
79+ messageCount++
80+ sessionMessages++
81+ totalCost += cost
82+ totalTokens += tokens
83+ sessionCost += cost
84+ sessionTokens += tokens
85+ val key = info.modelID?.takeIf { it.isNotBlank() } ?: "unknown"
86+ val agg = byModel.getOrPut(key) { MutableAgg() }
87+ agg.messages++
88+ agg.cost += cost
89+ agg.tokens += tokens
90+ }
91+ if (sessionMessages > 0) {
92+ bySession.add(SessionUsage(id, title, sessionMessages, sessionCost, sessionTokens))
93+ }
94+ }
95+
96+ return UsageReport(
97+ totalCost = totalCost,
98+ totalTokens = totalTokens,
99+ messageCount = messageCount,
100+ sessionCount = bySession.size,
101+ byModel = byModel.entries
102+ .map { ModelUsage(it.key, it.value.messages, it.value.cost, it.value.tokens) }
103+ .sortedByDescending { it.cost },
104+ bySession = bySession.sortedByDescending { it.cost },
105+ )
106+}
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.ktnew file+224
@@ -0,0 +1,224 @@
1+package soy.iko.opencode.ui.usage
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.PaddingValues
7+import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.Spacer
9+import androidx.compose.foundation.layout.fillMaxSize
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
13+import androidx.compose.foundation.lazy.LazyColumn
14+import androidx.compose.foundation.lazy.items
15+import androidx.compose.material.icons.Icons
16+import androidx.compose.material.icons.automirrored.filled.ArrowBack
17+import androidx.compose.material.icons.filled.Refresh
18+import androidx.compose.material3.Button
19+import androidx.compose.material3.Card
20+import androidx.compose.material3.CircularProgressIndicator
21+import androidx.compose.material3.ExperimentalMaterial3Api
22+import androidx.compose.material3.Icon
23+import androidx.compose.material3.IconButton
24+import androidx.compose.material3.MaterialTheme
25+import androidx.compose.material3.Scaffold
26+import androidx.compose.material3.Text
27+import androidx.compose.material3.TopAppBar
28+import androidx.compose.runtime.Composable
29+import androidx.compose.runtime.getValue
30+import androidx.compose.ui.Alignment
31+import androidx.compose.ui.Modifier
32+import androidx.compose.ui.res.stringResource
33+import androidx.compose.ui.semantics.heading
34+import androidx.compose.ui.semantics.semantics
35+import androidx.compose.ui.text.style.TextOverflow
36+import androidx.compose.ui.unit.dp
37+import androidx.lifecycle.compose.collectAsStateWithLifecycle
38+import androidx.lifecycle.viewmodel.compose.viewModel
39+import soy.iko.opencode.R
40+import soy.iko.opencode.data.model.Tokens
41+import soy.iko.opencode.di.AppContainer
42+import soy.iko.opencode.ui.vmFactory
43+import java.text.NumberFormat
44+import java.util.Locale
45+
46+@OptIn(ExperimentalMaterial3Api::class)
47+@Composable
48+fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
49+ val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
50+ val state by vm.state.collectAsStateWithLifecycle()
51+
52+ Scaffold(
53+ topBar = {
54+ TopAppBar(
55+ title = { Text(stringResource(R.string.usage_title)) },
56+ navigationIcon = {
57+ IconButton(onClick = onBack) {
58+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
59+ }
60+ },
61+ actions = {
62+ IconButton(onClick = { vm.load() }) {
63+ Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
64+ }
65+ },
66+ )
67+ },
68+ ) { padding ->
69+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
70+ when (val s = state) {
71+ is UsageViewModel.State.Loading -> Centered {
72+ CircularProgressIndicator()
73+ Spacer(Modifier.size(12.dp))
74+ Text(stringResource(R.string.usage_loading), color = MaterialTheme.colorScheme.onSurfaceVariant)
75+ }
76+ is UsageViewModel.State.Disconnected -> Centered {
77+ Text(stringResource(R.string.not_connected), color = MaterialTheme.colorScheme.onSurfaceVariant)
78+ }
79+ is UsageViewModel.State.Error -> Centered {
80+ Text(stringResource(R.string.usage_failed), color = MaterialTheme.colorScheme.error)
81+ Spacer(Modifier.size(12.dp))
82+ Button(onClick = { vm.load() }) { Text(stringResource(R.string.retry)) }
83+ }
84+ is UsageViewModel.State.Ready ->
85+ if (s.report.isEmpty) {
86+ Centered {
87+ Text(stringResource(R.string.usage_empty), color = MaterialTheme.colorScheme.onSurfaceVariant)
88+ }
89+ } else {
90+ UsageContent(s.report)
91+ }
92+ }
93+ }
94+ }
95+}
96+
97+@Composable
98+private fun Centered(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {
99+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
100+ Column(horizontalAlignment = Alignment.CenterHorizontally, content = content)
101+ }
102+}
103+
104+@Composable
105+private fun UsageContent(report: UsageReport) {
106+ LazyColumn(
107+ modifier = Modifier.fillMaxSize(),
108+ contentPadding = PaddingValues(16.dp),
109+ verticalArrangement = Arrangement.spacedBy(12.dp),
110+ ) {
111+ item { TotalsCard(report) }
112+ item { TokenBreakdownCard(report.totalTokens) }
113+ if (report.byModel.isNotEmpty()) {
114+ item { SectionHeader(stringResource(R.string.usage_by_model)) }
115+ items(report.byModel, key = { it.model }) { m ->
116+ UsageRow(title = m.model, cost = m.cost, tokens = m.tokens, messages = m.messages)
117+ }
118+ }
119+ if (report.bySession.isNotEmpty()) {
120+ item { SectionHeader(stringResource(R.string.usage_all_sessions)) }
121+ items(report.bySession, key = { it.sessionId }) { s ->
122+ UsageRow(title = s.title, cost = s.cost, tokens = s.tokens, messages = s.messages)
123+ }
124+ }
125+ }
126+}
127+
128+@Composable
129+private fun TotalsCard(report: UsageReport) {
130+ Card(modifier = Modifier.fillMaxWidth()) {
131+ Column(Modifier.padding(16.dp)) {
132+ Text(
133+ stringResource(R.string.usage_total_cost),
134+ style = MaterialTheme.typography.labelMedium,
135+ color = MaterialTheme.colorScheme.onSurfaceVariant,
136+ )
137+ Text(
138+ formatCost(report.totalCost),
139+ style = MaterialTheme.typography.headlineMedium,
140+ color = MaterialTheme.colorScheme.primary,
141+ )
142+ Spacer(Modifier.size(8.dp))
143+ Text(
144+ stringResource(R.string.usage_messages_count, report.messageCount),
145+ style = MaterialTheme.typography.bodySmall,
146+ color = MaterialTheme.colorScheme.onSurfaceVariant,
147+ )
148+ Text(
149+ stringResource(R.string.usage_total_tokens) + ": " + formatInt(report.totalTokens.total),
150+ style = MaterialTheme.typography.bodySmall,
151+ color = MaterialTheme.colorScheme.onSurfaceVariant,
152+ )
153+ }
154+ }
155+}
156+
157+@Composable
158+private fun TokenBreakdownCard(tokens: Tokens) {
159+ Card(modifier = Modifier.fillMaxWidth()) {
160+ Column(Modifier.padding(16.dp)) {
161+ StatRow(stringResource(R.string.usage_input), formatInt(tokens.input))
162+ StatRow(stringResource(R.string.usage_output), formatInt(tokens.output))
163+ if (tokens.reasoning > 0) StatRow(stringResource(R.string.usage_reasoning), formatInt(tokens.reasoning))
164+ if (tokens.cache.read > 0 || tokens.cache.write > 0) {
165+ StatRow(stringResource(R.string.usage_cache), formatInt(tokens.cache.read + tokens.cache.write))
166+ }
167+ }
168+ }
169+}
170+
171+@Composable
172+private fun StatRow(label: String, value: String) {
173+ Row(
174+ modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
175+ horizontalArrangement = Arrangement.SpaceBetween,
176+ ) {
177+ Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
178+ Text(value, style = MaterialTheme.typography.bodyMedium)
179+ }
180+}
181+
182+@Composable
183+private fun SectionHeader(text: String) {
184+ Text(
185+ text,
186+ style = MaterialTheme.typography.titleSmall,
187+ color = MaterialTheme.colorScheme.primary,
188+ modifier = Modifier.padding(top = 8.dp).semantics { heading() },
189+ )
190+}
191+
192+@Composable
193+private fun UsageRow(title: String, cost: Double, tokens: Tokens, messages: Int) {
194+ Row(
195+ modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
196+ verticalAlignment = Alignment.CenterVertically,
197+ ) {
198+ Column(modifier = Modifier.weight(1f)) {
199+ Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
200+ Text(
201+ stringResource(R.string.usage_messages_count, messages) + " · " + formatInt(tokens.total),
202+ style = MaterialTheme.typography.labelSmall,
203+ color = MaterialTheme.colorScheme.onSurfaceVariant,
204+ )
205+ }
206+ Text(
207+ formatCost(cost),
208+ style = MaterialTheme.typography.bodyMedium,
209+ color = MaterialTheme.colorScheme.primary,
210+ modifier = Modifier.padding(start = 12.dp),
211+ )
212+ }
213+}
214+
215+// Locale.US so amounts/counts format identically regardless of device locale (stable digits
216+// and a dot decimal for a dollar figure). Sub-cent totals get more precision so a tiny run
217+// doesn't collapse to "$0.00".
218+private fun formatCost(cost: Double): String =
219+ if (cost in 0.0..0.01) String.format(Locale.US, "$%.4f", cost)
220+ else String.format(Locale.US, "$%.2f", cost)
221+
222+private val integerFormat: NumberFormat = NumberFormat.getIntegerInstance(Locale.US)
223+
224+private fun formatInt(n: Long): String = integerFormat.format(n)
app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.ktnew file+70
@@ -0,0 +1,70 @@
1+package soy.iko.opencode.ui.usage
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.async
7+import kotlinx.coroutines.awaitAll
8+import kotlinx.coroutines.flow.MutableStateFlow
9+import kotlinx.coroutines.flow.StateFlow
10+import kotlinx.coroutines.flow.asStateFlow
11+import kotlinx.coroutines.launch
12+import kotlinx.coroutines.sync.Semaphore
13+import kotlinx.coroutines.sync.withPermit
14+import kotlinx.coroutines.withContext
15+import soy.iko.opencode.di.AppContainer
16+import soy.iko.opencode.util.runCatchingCancellable
17+
18+/**
19+ * Computes a cross-session [UsageReport] by listing the server's sessions and fetching each
20+ * one's messages (bounded concurrency), then aggregating cost/tokens off the main thread.
21+ * There's no server-side usage endpoint, so this reconstructs totals from the per-message
22+ * cost/token fields the API already returns.
23+ */
24+class UsageViewModel(private val container: AppContainer) : ViewModel() {
25+
26+ sealed interface State {
27+ data object Loading : State
28+ data object Disconnected : State
29+ data object Error : State
30+ data class Ready(val report: UsageReport) : State
31+ }
32+
33+ private val _state = MutableStateFlow<State>(State.Loading)
34+ val state: StateFlow<State> = _state.asStateFlow()
35+
36+ init { load() }
37+
38+ fun load() {
39+ val conn = container.activeConnection.value ?: run {
40+ _state.value = State.Disconnected
41+ return
42+ }
43+ _state.value = State.Loading
44+ viewModelScope.launch {
45+ runCatchingCancellable {
46+ val sessions = conn.repository.listSessions()
47+ // Cap concurrent message fetches so a large history doesn't open dozens of
48+ // sockets at once; a per-session fetch failure yields an empty list rather
49+ // than aborting the whole report.
50+ val limiter = Semaphore(MAX_CONCURRENT_FETCHES)
51+ val perSession = sessions.map { session ->
52+ async {
53+ limiter.withPermit {
54+ val messages = runCatchingCancellable { conn.api.listMessages(session.id) }
55+ .getOrDefault(emptyList())
56+ Triple(session.id, session.displayTitle, messages)
57+ }
58+ }
59+ }.awaitAll()
60+ withContext(Dispatchers.Default) { aggregateUsage(perSession) }
61+ }
62+ .onSuccess { _state.value = State.Ready(it) }
63+ .onFailure { _state.value = State.Error }
64+ }
65+ }
66+
67+ private companion object {
68+ const val MAX_CONCURRENT_FETCHES = 6
69+ }
70+}
app/src/main/res/drawable/widget_background.xmlnew file+6
@@ -0,0 +1,6 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<!-- Rounded, slightly translucent Tokyo Night surface for the sessions widget. -->
3+<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
4+ <solid android:color="#EE1F2335" />
5+ <corners android:radius="16dp" />
6+</shape>
app/src/main/res/layout/widget_session_item.xmlnew file+21
@@ -0,0 +1,21 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:id="@+id/widget_item_root"
4+ android:layout_width="match_parent"
5+ android:layout_height="wrap_content"
6+ android:orientation="horizontal"
7+ android:gravity="center_vertical"
8+ android:paddingStart="10dp"
9+ android:paddingEnd="10dp"
10+ android:paddingTop="12dp"
11+ android:paddingBottom="12dp">
12+
13+ <TextView
14+ android:id="@+id/widget_item_title"
15+ android:layout_width="match_parent"
16+ android:layout_height="wrap_content"
17+ android:maxLines="1"
18+ android:ellipsize="end"
19+ android:textColor="#C0CAF5"
20+ android:textSize="14sp" />
21+</LinearLayout>
app/src/main/res/layout/widget_sessions.xmlnew file+64
@@ -0,0 +1,64 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:layout_width="match_parent"
4+ android:layout_height="match_parent"
5+ android:orientation="vertical"
6+ android:background="@drawable/widget_background"
7+ android:padding="8dp">
8+
9+ <LinearLayout
10+ android:layout_width="match_parent"
11+ android:layout_height="wrap_content"
12+ android:orientation="horizontal"
13+ android:gravity="center_vertical"
14+ android:paddingStart="8dp"
15+ android:paddingEnd="4dp"
16+ android:paddingBottom="4dp">
17+
18+ <TextView
19+ android:id="@+id/widget_title"
20+ android:layout_width="0dp"
21+ android:layout_height="wrap_content"
22+ android:layout_weight="1"
23+ android:text="@string/widget_title"
24+ android:textColor="#C0CAF5"
25+ android:textSize="15sp"
26+ android:textStyle="bold" />
27+
28+ <TextView
29+ android:id="@+id/widget_new"
30+ android:layout_width="wrap_content"
31+ android:layout_height="wrap_content"
32+ android:text="@string/widget_new_session"
33+ android:textColor="#7AA2F7"
34+ android:textSize="13sp"
35+ android:textStyle="bold"
36+ android:paddingStart="12dp"
37+ android:paddingEnd="8dp"
38+ android:paddingTop="6dp"
39+ android:paddingBottom="6dp" />
40+ </LinearLayout>
41+
42+ <FrameLayout
43+ android:layout_width="match_parent"
44+ android:layout_height="0dp"
45+ android:layout_weight="1">
46+
47+ <ListView
48+ android:id="@+id/widget_list"
49+ android:layout_width="match_parent"
50+ android:layout_height="match_parent"
51+ android:divider="#33565F89"
52+ android:dividerHeight="1dp" />
53+
54+ <TextView
55+ android:id="@+id/widget_empty"
56+ android:layout_width="match_parent"
57+ android:layout_height="match_parent"
58+ android:gravity="center"
59+ android:text="@string/widget_empty"
60+ android:textColor="#9AA5CE"
61+ android:textSize="13sp"
62+ android:visibility="gone" />
63+ </FrameLayout>
64+</LinearLayout>
app/src/main/res/values-es/strings.xml+103
@@ -367,4 +367,107 @@
367367 <string name="todo_in_progress">En curso</string>
368368 <string name="todo_completed">Completado</string>
369369 <string name="todo_cancelled">Cancelado</string>
370+
371+ <!-- Code blocks -->
372+ <string name="code">Código</string>
373+ <string name="code_wrap">Ajustar líneas</string>
374+ <string name="code_no_wrap">No ajustar líneas</string>
375+
376+ <!-- Text &amp; display settings -->
377+ <string name="text_display">Texto y visualización</string>
378+ <string name="chat_text_size">Tamaño del texto del chat</string>
379+ <string name="chat_text_size_desc">Escala el texto de mensajes y código</string>
380+ <string name="wrap_code_blocks">Ajustar bloques de código</string>
381+ <string name="wrap_code_blocks_desc">Ajusta las líneas largas en vez de desplazarlas</string>
382+ <string name="reset">Restablecer</string>
383+ <string name="text_size_sample">El veloz murciélago hindú comía feliz cardillo.</string>
384+ <string name="text_scale_value">%1$d%%</string>
385+
386+ <!-- Message actions -->
387+ <string name="message_actions">Acciones del mensaje</string>
388+ <string name="quote_reply">Responder citando</string>
389+ <string name="branch_session">Ramificar en sesión nueva</string>
390+ <string name="branch_created">Ramificado en una sesión nueva</string>
391+ <string name="branch_failed">No se pudo ramificar la sesión</string>
392+
393+ <!-- Offline outbox -->
394+ <string name="outbox_queued">Sin conexión: mensaje en cola, se enviará al reconectar</string>
395+ <string name="outbox_pending_one">1 mensaje en cola</string>
396+ <string name="outbox_pending_many">%1$d mensajes en cola</string>
397+ <string name="outbox_sending">Enviando mensajes en cola…</string>
398+ <string name="outbox_discard">Descartar</string>
399+ <string name="outbox_discard_all">Descartar la cola</string>
400+ <string name="outbox_flush">Enviar ahora</string>
401+
402+ <!-- Usage &amp; cost dashboard -->
403+ <string name="usage">Uso</string>
404+ <string name="usage_title">Uso y coste</string>
405+ <string name="usage_total_cost">Coste total</string>
406+ <string name="usage_total_tokens">Tokens totales</string>
407+ <string name="usage_input">Entrada</string>
408+ <string name="usage_output">Salida</string>
409+ <string name="usage_reasoning">Razonamiento</string>
410+ <string name="usage_cache">Caché</string>
411+ <string name="usage_by_model">Por modelo</string>
412+ <string name="usage_this_session">Esta sesión</string>
413+ <string name="usage_all_sessions">Todas las sesiones</string>
414+ <string name="usage_empty">Aún no hay uso registrado</string>
415+ <string name="usage_messages_count">%1$d mensajes del asistente</string>
416+ <string name="usage_loading">Calculando uso…</string>
417+ <string name="usage_failed">No se pudo cargar el uso</string>
418+
419+ <!-- Command palette / keyboard -->
420+ <string name="command_palette">Paleta de comandos</string>
421+ <string name="palette_hint">Buscar acciones…</string>
422+ <string name="palette_new_session">Nueva sesión</string>
423+ <string name="palette_switch_model">Cambiar modelo</string>
424+ <string name="palette_choose_agent">Elegir agente</string>
425+ <string name="palette_run_command">Ejecutar comando</string>
426+ <string name="palette_browse_files">Explorar archivos</string>
427+ <string name="palette_search">Buscar conversaciones</string>
428+ <string name="palette_settings">Ajustes</string>
429+ <string name="palette_summarize">Resumir conversación</string>
430+ <string name="palette_no_results">No hay acciones coincidentes</string>
431+
432+ <!-- Widget / tile / shortcuts -->
433+ <string name="widget_description">Sesiones recientes de opencode</string>
434+ <string name="widget_title">opencode</string>
435+ <string name="widget_new_session">Nueva sesión</string>
436+ <string name="widget_empty">No hay sesiones recientes</string>
437+ <string name="widget_not_connected">Abre opencode para conectar</string>
438+ <string name="shortcut_new_short">Nueva</string>
439+ <string name="shortcut_new_long">Nueva sesión</string>
440+ <string name="shortcut_resume_short">Reanudar</string>
441+ <string name="shortcut_resume_long">Reanudar última sesión</string>
442+ <string name="tile_new_session">Nueva sesión de opencode</string>
443+
444+ <!-- MCP servers -->
445+ <string name="mcp_servers">Servidores MCP</string>
446+ <string name="mcp_empty">No hay servidores MCP configurados</string>
447+ <string name="mcp_failed">No se pudieron cargar los servidores MCP</string>
448+ <string name="mcp_connected">Conectado</string>
449+ <string name="mcp_failed_state">Falló</string>
450+ <string name="mcp_disabled">Desactivado</string>
451+ <string name="mcp_tools_count">%1$d herramientas</string>
452+
453+ <!-- Backup &amp; restore -->
454+ <string name="backup_restore">Copia de seguridad y restauración</string>
455+ <string name="export_backup">Exportar copia</string>
456+ <string name="import_backup">Importar copia</string>
457+ <string name="backup_desc">Guarda o restaura servidores y ajustes</string>
458+ <string name="backup_exported">Copia exportada</string>
459+ <string name="backup_imported">Copia importada: reinicia para aplicar</string>
460+ <string name="backup_export_failed">No se pudo exportar la copia</string>
461+ <string name="backup_import_failed">No se pudo importar la copia</string>
462+ <string name="backup_include_passwords">Incluir contraseñas del servidor</string>
463+ <string name="backup_include_passwords_desc">Las contraseñas se escriben en texto plano en el archivo</string>
464+
465+ <!-- Per-server security -->
466+ <string name="server_security">Seguridad</string>
467+ <string name="require_https">Requerir HTTPS</string>
468+ <string name="require_https_desc">Rechazar conexiones por HTTP sin cifrar</string>
469+ <string name="cert_pin">Fijación de certificado</string>
470+ <string name="cert_pin_hint">sha256/AAAA… (opcional)</string>
471+ <string name="cert_pin_desc">Fija el certificado TLS del servidor (SHA-256)</string>
472+ <string name="cert_pin_invalid">El pin debe ser sha256/&lt;base64&gt;</string>
370473</resources>
app/src/main/res/values/strings.xml+103
@@ -369,4 +369,107 @@
369369 <string name="todo_in_progress">In progress</string>
370370 <string name="todo_completed">Completed</string>
371371 <string name="todo_cancelled">Cancelled</string>
372+
373+ <!-- Code blocks -->
374+ <string name="code">Code</string>
375+ <string name="code_wrap">Wrap lines</string>
376+ <string name="code_no_wrap">Don\'t wrap lines</string>
377+
378+ <!-- Text & display settings -->
379+ <string name="text_display">Text &amp; display</string>
380+ <string name="chat_text_size">Chat text size</string>
381+ <string name="chat_text_size_desc">Scale message and code text</string>
382+ <string name="wrap_code_blocks">Wrap code blocks</string>
383+ <string name="wrap_code_blocks_desc">Soft-wrap long lines instead of scrolling</string>
384+ <string name="reset">Reset</string>
385+ <string name="text_size_sample">The quick brown fox jumps over the lazy dog.</string>
386+ <string name="text_scale_value">%1$d%%</string>
387+
388+ <!-- Message actions -->
389+ <string name="message_actions">Message actions</string>
390+ <string name="quote_reply">Quote reply</string>
391+ <string name="branch_session">Branch new session</string>
392+ <string name="branch_created">Branched to a new session</string>
393+ <string name="branch_failed">Couldn\'t branch session</string>
394+
395+ <!-- Offline outbox -->
396+ <string name="outbox_queued">Offline — message queued, will send when reconnected</string>
397+ <string name="outbox_pending_one">1 message queued</string>
398+ <string name="outbox_pending_many">%1$d messages queued</string>
399+ <string name="outbox_sending">Sending queued messages…</string>
400+ <string name="outbox_discard">Discard</string>
401+ <string name="outbox_discard_all">Discard queued</string>
402+ <string name="outbox_flush">Send now</string>
403+
404+ <!-- Usage &amp; cost dashboard -->
405+ <string name="usage">Usage</string>
406+ <string name="usage_title">Usage &amp; cost</string>
407+ <string name="usage_total_cost">Total cost</string>
408+ <string name="usage_total_tokens">Total tokens</string>
409+ <string name="usage_input">Input</string>
410+ <string name="usage_output">Output</string>
411+ <string name="usage_reasoning">Reasoning</string>
412+ <string name="usage_cache">Cache</string>
413+ <string name="usage_by_model">By model</string>
414+ <string name="usage_this_session">This session</string>
415+ <string name="usage_all_sessions">All sessions</string>
416+ <string name="usage_empty">No usage recorded yet</string>
417+ <string name="usage_messages_count">%1$d assistant messages</string>
418+ <string name="usage_loading">Calculating usage…</string>
419+ <string name="usage_failed">Couldn\'t load usage</string>
420+
421+ <!-- Command palette / keyboard -->
422+ <string name="command_palette">Command palette</string>
423+ <string name="palette_hint">Search actions…</string>
424+ <string name="palette_new_session">New session</string>
425+ <string name="palette_switch_model">Switch model</string>
426+ <string name="palette_choose_agent">Choose agent</string>
427+ <string name="palette_run_command">Run slash command</string>
428+ <string name="palette_browse_files">Browse files</string>
429+ <string name="palette_search">Search conversations</string>
430+ <string name="palette_settings">Settings</string>
431+ <string name="palette_summarize">Summarize conversation</string>
432+ <string name="palette_no_results">No matching actions</string>
433+
434+ <!-- Widget / tile / shortcuts -->
435+ <string name="widget_description">Recent opencode sessions</string>
436+ <string name="widget_title">opencode</string>
437+ <string name="widget_new_session">New session</string>
438+ <string name="widget_empty">No recent sessions</string>
439+ <string name="widget_not_connected">Open opencode to connect</string>
440+ <string name="shortcut_new_short">New</string>
441+ <string name="shortcut_new_long">New session</string>
442+ <string name="shortcut_resume_short">Resume</string>
443+ <string name="shortcut_resume_long">Resume last session</string>
444+ <string name="tile_new_session">New opencode session</string>
445+
446+ <!-- MCP servers -->
447+ <string name="mcp_servers">MCP servers</string>
448+ <string name="mcp_empty">No MCP servers configured</string>
449+ <string name="mcp_failed">Couldn\'t load MCP servers</string>
450+ <string name="mcp_connected">Connected</string>
451+ <string name="mcp_failed_state">Failed</string>
452+ <string name="mcp_disabled">Disabled</string>
453+ <string name="mcp_tools_count">%1$d tools</string>
454+
455+ <!-- Backup &amp; restore -->
456+ <string name="backup_restore">Backup &amp; restore</string>
457+ <string name="export_backup">Export backup</string>
458+ <string name="import_backup">Import backup</string>
459+ <string name="backup_desc">Save or restore servers and settings</string>
460+ <string name="backup_exported">Backup exported</string>
461+ <string name="backup_imported">Backup imported — restart to apply</string>
462+ <string name="backup_export_failed">Couldn\'t export backup</string>
463+ <string name="backup_import_failed">Couldn\'t import backup</string>
464+ <string name="backup_include_passwords">Include server passwords</string>
465+ <string name="backup_include_passwords_desc">Passwords are written in plain text inside the file</string>
466+
467+ <!-- Per-server security -->
468+ <string name="server_security">Security</string>
469+ <string name="require_https">Require HTTPS</string>
470+ <string name="require_https_desc">Refuse to connect over cleartext HTTP</string>
471+ <string name="cert_pin">Certificate pin</string>
472+ <string name="cert_pin_hint">sha256/AAAA… (optional)</string>
473+ <string name="cert_pin_desc">Pin the server\'s TLS certificate (SHA-256)</string>
474+ <string name="cert_pin_invalid">Pin must look like sha256/&lt;base64&gt;</string>
372475</resources>
app/src/main/res/xml/sessions_widget_info.xmlnew file+10
@@ -0,0 +1,10 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:minWidth="180dp"
4+ android:minHeight="110dp"
5+ android:updatePeriodMillis="0"
6+ android:initialLayout="@layout/widget_sessions"
7+ android:previewImage="@mipmap/ic_launcher"
8+ android:resizeMode="horizontal|vertical"
9+ android:widgetCategory="home_screen"
10+ android:description="@string/widget_description" />
app/src/test/java/soy/iko/opencode/data/repo/OutboxStoreTest.ktnew file+44
@@ -0,0 +1,44 @@
1+package soy.iko.opencode.data.repo
2+
3+import kotlinx.coroutines.runBlocking
4+import org.junit.Assert.assertEquals
5+import org.junit.Test
6+
7+class OutboxStoreTest {
8+
9+ /** The protected no-arg constructor runs with no Context, so disk ops are skipped and the
10+ * in-memory queue can be exercised directly. */
11+ private class TestOutbox : OutboxStore()
12+
13+ private fun msg(id: String, sessionId: String, text: String, createdAt: Long) =
14+ OutboxMessage(id = id, profileId = "p", sessionId = sessionId, text = text, createdAt = createdAt)
15+
16+ @Test
17+ fun enqueueOrdersByCreatedAtAndDedupesById() = runBlocking {
18+ val store = TestOutbox()
19+ store.enqueue(msg("b", "s1", "B", createdAt = 2))
20+ store.enqueue(msg("a", "s1", "A", createdAt = 1))
21+ // Same id replaces the prior entry rather than adding a duplicate.
22+ store.enqueue(msg("a", "s1", "A2", createdAt = 1))
23+ assertEquals(listOf("a", "b"), store.messages.value.map { it.id })
24+ assertEquals("A2", store.messages.value.first { it.id == "a" }.text)
25+ }
26+
27+ @Test
28+ fun removeForSessionDropsOnlyThatSession() = runBlocking {
29+ val store = TestOutbox()
30+ store.enqueue(msg("1", "s1", "x", createdAt = 1))
31+ store.enqueue(msg("2", "s2", "y", createdAt = 2))
32+ store.removeForSession("s1")
33+ assertEquals(listOf("2"), store.messages.value.map { it.id })
34+ }
35+
36+ @Test
37+ fun removeDropsSingleMessage() = runBlocking {
38+ val store = TestOutbox()
39+ store.enqueue(msg("1", "s1", "x", createdAt = 1))
40+ store.enqueue(msg("2", "s1", "y", createdAt = 2))
41+ store.remove("1")
42+ assertEquals(listOf("2"), store.messages.value.map { it.id })
43+ }
44+}
app/src/test/java/soy/iko/opencode/ui/components/ExtractFenceLanguageTest.ktnew file+28
@@ -0,0 +1,28 @@
1+package soy.iko.opencode.ui.components
2+
3+import org.junit.Assert.assertEquals
4+import org.junit.Assert.assertNull
5+import org.junit.Test
6+
7+class ExtractFenceLanguageTest {
8+
9+ @Test
10+ fun extractsLanguageTag() {
11+ assertEquals("kotlin", extractFenceLanguage("```kotlin\nval x = 1\n```", isFenced = true))
12+ }
13+
14+ @Test
15+ fun takesFirstTokenOfInfoString() {
16+ assertEquals("ts", extractFenceLanguage("```ts title=foo.ts\ncode\n```", isFenced = true))
17+ }
18+
19+ @Test
20+ fun nullForIndentedBlock() {
21+ assertNull(extractFenceLanguage(" indented code", isFenced = false))
22+ }
23+
24+ @Test
25+ fun nullForBareFence() {
26+ assertNull(extractFenceLanguage("```\nplain\n```", isFenced = true))
27+ }
28+}
app/src/test/java/soy/iko/opencode/ui/mcp/McpParserTest.ktnew file+52
@@ -0,0 +1,52 @@
1+package soy.iko.opencode.ui.mcp
2+
3+import kotlinx.serialization.json.add
4+import kotlinx.serialization.json.buildJsonObject
5+import kotlinx.serialization.json.put
6+import kotlinx.serialization.json.putJsonArray
7+import kotlinx.serialization.json.putJsonObject
8+import org.junit.Assert.assertEquals
9+import org.junit.Assert.assertFalse
10+import org.junit.Assert.assertTrue
11+import org.junit.Test
12+
13+class McpParserTest {
14+
15+ @Test
16+ fun parsesLocalAndRemoteServers() {
17+ val config = buildJsonObject {
18+ putJsonObject("mcp") {
19+ putJsonObject("local-one") {
20+ put("type", "local")
21+ put("enabled", true)
22+ putJsonArray("command") { add("node"); add("server.js") }
23+ }
24+ putJsonObject("remote-one") {
25+ put("type", "remote")
26+ put("url", "https://example.com/mcp")
27+ put("enabled", false)
28+ }
29+ }
30+ }
31+ val servers = parseMcpServers(config)
32+ assertEquals(2, servers.size)
33+ val local = servers.first { it.name == "local-one" }
34+ assertEquals("local", local.type)
35+ assertTrue(local.enabled)
36+ assertEquals("node server.js", local.target)
37+ val remote = servers.first { it.name == "remote-one" }
38+ assertFalse(remote.enabled)
39+ assertEquals("https://example.com/mcp", remote.target)
40+ }
41+
42+ @Test
43+ fun defaultsEnabledWhenOmitted() {
44+ val config = buildJsonObject { putJsonObject("mcp") { putJsonObject("x") { put("type", "local") } } }
45+ assertTrue(parseMcpServers(config).single().enabled)
46+ }
47+
48+ @Test
49+ fun emptyWhenNoMcpKey() {
50+ assertTrue(parseMcpServers(buildJsonObject { }).isEmpty())
51+ }
52+}
app/src/test/java/soy/iko/opencode/ui/server/CertPinValidationTest.ktnew file+32
@@ -0,0 +1,32 @@
1+package soy.iko.opencode.ui.server
2+
3+import org.junit.Assert.assertFalse
4+import org.junit.Assert.assertTrue
5+import org.junit.Test
6+
7+class CertPinValidationTest {
8+
9+ @Test
10+ fun blankIsValid() {
11+ assertTrue(isValidCertPin(""))
12+ assertTrue(isValidCertPin(" "))
13+ }
14+
15+ @Test
16+ fun wellFormedSinglePin() {
17+ assertTrue(isValidCertPin("sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="))
18+ }
19+
20+ @Test
21+ fun whitespaceOrCommaSeparatedPins() {
22+ assertTrue(isValidCertPin("sha256/AAAA= sha256/BBBB="))
23+ assertTrue(isValidCertPin("sha256/AAAA=,sha256/BBBB="))
24+ }
25+
26+ @Test
27+ fun rejectsMalformed() {
28+ assertFalse(isValidCertPin("not-a-pin"))
29+ assertFalse(isValidCertPin("md5/AAAA="))
30+ assertFalse(isValidCertPin("sha256/"))
31+ }
32+}
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+4
@@ -29,6 +29,7 @@ import soy.iko.opencode.data.network.OpencodeApiClient
2929import soy.iko.opencode.data.repo.AttachmentDraftStore
3030import soy.iko.opencode.data.repo.DraftStore
3131import soy.iko.opencode.data.repo.MessageCacheStore
32+import soy.iko.opencode.data.repo.OutboxStore
3233import soy.iko.opencode.data.repo.PersistedAttachment
3334import soy.iko.opencode.data.repo.ProfileStore
3435import soy.iko.opencode.data.repo.SessionPrefsStore
@@ -382,6 +383,8 @@ class FakeAppContainer : AppContainer() {
382383 val fakeSessionPrefsStore = FakeSessionPrefsStore()
383384 val fakeAttachmentDraftStore = FakeAttachmentDraftStore()
384385 val fakeMessageCacheStore = FakeMessageCacheStore()
386+ // In-memory outbox (no Context / disk); exercises the same reduce logic the app uses.
387+ val fakeOutboxStore = object : OutboxStore() {}
385388 val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
386389 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
387390
@@ -390,6 +393,7 @@ class FakeAppContainer : AppContainer() {
390393 override val sessionPrefsStore: SessionPrefsStore = fakeSessionPrefsStore
391394 override val attachmentDraftStore: AttachmentDraftStore = fakeAttachmentDraftStore
392395 override val messageCacheStore: MessageCacheStore = fakeMessageCacheStore
396+ override val outboxStore: OutboxStore = fakeOutboxStore
393397 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
394398 override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()
395399
app/src/test/java/soy/iko/opencode/ui/usage/UsageAggregationTest.ktnew file+48
@@ -0,0 +1,48 @@
1+package soy.iko.opencode.ui.usage
2+
3+import org.junit.Assert.assertEquals
4+import org.junit.Assert.assertTrue
5+import org.junit.Test
6+import soy.iko.opencode.data.model.AssistantMessage
7+import soy.iko.opencode.data.model.MessageWithParts
8+import soy.iko.opencode.data.model.TimeInfo
9+import soy.iko.opencode.data.model.Tokens
10+import soy.iko.opencode.data.model.UserMessage
11+
12+class UsageAggregationTest {
13+
14+ private fun assistant(model: String, cost: Double, input: Long, output: Long) =
15+ MessageWithParts(
16+ info = AssistantMessage(
17+ id = "m",
18+ sessionID = "s",
19+ modelID = model,
20+ cost = cost,
21+ tokens = Tokens(input = input, output = output),
22+ time = TimeInfo(completed = 1L),
23+ ),
24+ )
25+
26+ @Test
27+ fun aggregatesTotalsAndByModel() {
28+ val sessions = listOf(
29+ Triple("s1", "One", listOf(assistant("gpt", 0.10, 100, 50), assistant("gpt", 0.20, 200, 100))),
30+ Triple("s2", "Two", listOf(assistant("claude", 0.05, 10, 5), MessageWithParts(UserMessage("u", "s2")))),
31+ )
32+ val report = aggregateUsage(sessions)
33+ assertEquals(0.35, report.totalCost, 1e-9)
34+ assertEquals(3, report.messageCount)
35+ assertEquals(2, report.sessionCount)
36+ assertEquals(2, report.byModel.size)
37+ // Highest-cost model floats to the top.
38+ assertEquals("gpt", report.byModel.first().model)
39+ assertEquals(0.30, report.byModel.first().cost, 1e-9)
40+ assertEquals(450L, report.byModel.first().tokens.total)
41+ }
42+
43+ @Test
44+ fun ignoresZeroUsageMessages() {
45+ val sessions = listOf(Triple("s", "t", listOf(assistant("m", 0.0, 0, 0))))
46+ assertTrue(aggregateUsage(sessions).isEmpty)
47+ }
48+}