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
5eaa0b7iko
.github/workflows/build.yml+6
@@ -5,6 +5,12 @@ on:
5 branches: [master]
5 branches: [master]
6 pull_request:
6 pull_request:
7
7
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+
8jobs:
14jobs:
9 build:
15 build:
10 runs-on: ubuntu-latest
16 runs-on: ubuntu-latest
app/detekt-baseline.xml+3−2
@@ -4,13 +4,14 @@
4 <CurrentIssues>
4 <CurrentIssues>
5 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
5 <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
6 <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
6 <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>
8 <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
8 <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>
9 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
11 <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
10 <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
12 <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
11 <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
13 <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
12 <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
14 <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
13 <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
15 <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
14− <ID>UnusedParameter:MarkdownText.kt$style: TextStyle = MaterialTheme.typography.bodyLarge</ID>
15 </CurrentIssues>
16 </CurrentIssues>
16</SmellBaseline>
17</SmellBaseline>
app/src/main/AndroidManifest.xml+30
@@ -71,6 +71,36 @@
71 android:name=".notification.NotificationActionReceiver"
71 android:name=".notification.NotificationActionReceiver"
72 android:exported="false" />
72 android:exported="false" />
73
73
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+
74 <!-- Serves camera-capture temp files (cache/captures) to the system camera app. -->
104 <!-- Serves camera-capture temp files (cache/captures) to the system camera app. -->
75 <provider
105 <provider
76 android:name="androidx.core.content.FileProvider"
106 android:name="androidx.core.content.FileProvider"
app/src/main/java/soy/iko/opencode/MainActivity.kt+15−1
@@ -37,6 +37,7 @@ class MainActivity : FragmentActivity() {
37
37
38 private var shareIntentHandled = false
38 private var shareIntentHandled = false
39 private var openSessionHandled = false
39 private var openSessionHandled = false
40+ private var newSessionHandled = false
40
41
41 override fun onCreate(savedInstanceState: Bundle?) {
42 override fun onCreate(savedInstanceState: Bundle?) {
42 val splash = installSplashScreen()
43 val splash = installSplashScreen()
@@ -45,6 +46,7 @@ class MainActivity : FragmentActivity() {
45 savedInstanceState?.let {
46 savedInstanceState?.let {
46 shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
47 shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
47 openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
48 openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
49+ newSessionHandled = it.getBoolean(KEY_NEW_SESSION_HANDLED, false)
48 }
50 }
49 val container = (application as OpencodeApp).container
51 val container = (application as OpencodeApp).container
50 handleIntent(intent)
52 handleIntent(intent)
@@ -87,6 +89,7 @@ class MainActivity : FragmentActivity() {
87 setIntent(intent)
89 setIntent(intent)
88 shareIntentHandled = false
90 shareIntentHandled = false
89 openSessionHandled = false
91 openSessionHandled = false
92+ newSessionHandled = false
90 handleIntent(intent)
93 handleIntent(intent)
91 }
94 }
92
95
@@ -109,6 +112,12 @@ class MainActivity : FragmentActivity() {
109 private fun handleIntent(intent: Intent?) {
112 private fun handleIntent(intent: Intent?) {
110 val container = (application as OpencodeApp).container
113 val container = (application as OpencodeApp).container
111 val action = intent?.action
114 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+ }
112 if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) {
121 if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) {
113 val type = intent.type.orEmpty()
122 val type = intent.type.orEmpty()
114 if (action == Intent.ACTION_SEND && type.startsWith("image/")) {
123 if (action == Intent.ACTION_SEND && type.startsWith("image/")) {
@@ -157,20 +166,25 @@ class MainActivity : FragmentActivity() {
157 }
166 }
158
167
159 companion object {
168 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). */
161 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
170 const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
162
171
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+
163 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
175 /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
164 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
176 private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
165
177
166 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
178 private const val KEY_SHARE_HANDLED = "shareIntentHandled"
167 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
179 private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
180+ private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled"
168 }
181 }
169
182
170 override fun onSaveInstanceState(outState: Bundle) {
183 override fun onSaveInstanceState(outState: Bundle) {
171 super.onSaveInstanceState(outState)
184 super.onSaveInstanceState(outState)
172 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
185 outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
173 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled)
186 outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled)
187+ outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled)
174 }
188 }
175
189
176 /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
190 /** 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(
14 val username: String? = null,
14 val username: String? = null,
15 val password: String? = null,
15 val password: String? = null,
16 val lastUsed: Long = 0,
16 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,
17) {
23) {
18 val hasAuth: Boolean get() = !username.isNullOrBlank()
24 val hasAuth: Boolean get() = !username.isNullOrBlank()
19
25
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+59−27
@@ -14,6 +14,7 @@ import io.ktor.client.request.header
14import io.ktor.http.URLBuilder
14import io.ktor.http.URLBuilder
15import io.ktor.http.takeFrom
15import io.ktor.http.takeFrom
16import io.ktor.serialization.kotlinx.json.json
16import io.ktor.serialization.kotlinx.json.json
17+import okhttp3.CertificatePinner
17import java.util.concurrent.TimeUnit
18import java.util.concurrent.TimeUnit
18
19
19/**
20/**
@@ -23,39 +24,53 @@ import java.util.concurrent.TimeUnit
23 */
24 */
24object HttpClientFactory {
25object HttpClientFactory {
25
26
26− fun create(profile: ServerProfile): HttpClient = HttpClient(OkHttp) {
27+ fun create(profile: ServerProfile): HttpClient {
27− expectSuccess = true
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)
28
35
29− engine {
36+ return HttpClient(OkHttp) {
30− config {
37+ expectSuccess = true
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− }
40
38
41− install(ContentNegotiation) {
39+ engine {
42− json(OpencodeJson)
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+ }
43− }
57+ }
44
58
45− install(SSE)
59+ install(ContentNegotiation) {
60+ json(OpencodeJson)
61+ }
46
62
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) {
63+ install(SSE)
52− requestTimeoutMillis = NetworkConfig.restRequestTimeoutMs
53− }
54
64
55− val normalizedUrl = normalizeBaseUrl(profile.baseUrl)
65+ // Request-level timeout for REST calls. The SSE stream overrides this to
56− val isHttps = normalizedUrl.lowercase().startsWith("https://")
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+ }
57
72
58− if (profile.hasAuth) {
73+ if (profile.hasAuth) {
59 if (isHttps) {
74 if (isHttps) {
60 install(Auth) {
75 install(Auth) {
61 basic {
76 basic {
@@ -94,8 +109,25 @@ object HttpClientFactory {
94 header("Authorization", "Basic $credentials")
109 header("Authorization", "Basic $credentials")
95 }
110 }
96 }
111 }
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+ }
97 }
124 }
98
125
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+
99 /** Ensure a scheme and a single trailing slash so relative paths resolve correctly.
131 /** Ensure a scheme and a single trailing slash so relative paths resolve correctly.
100 * Defaults to [https] when no scheme is given so credentials aren't accidentally
132 * Defaults to [https] when no scheme is given so credentials aren't accidentally
101 * sent over cleartext. Users who need plain HTTP must type the scheme explicitly. */
133 * 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(
267 value
267 value
268 }
268 }
269
269
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+
270 open suspend fun agents(): List<Agent> = agentsMutex.withLock {
276 open suspend fun agents(): List<Agent> = agentsMutex.withLock {
271 val now = System.currentTimeMillis()
277 val now = System.currentTimeMillis()
272 val cached = cachedAgents
278 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(
131 val baseUrl: String,
131 val baseUrl: String,
132 val username: String? = null,
132 val username: String? = null,
133 val lastUsed: Long = 0,
133 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,
134 )
138 )
135
139
136 open val profiles: Flow<List<ServerProfile>> = appContext?.dataStore?.data?.map { prefs ->
140 open val profiles: Flow<List<ServerProfile>> = appContext?.dataStore?.data?.map { prefs ->
@@ -164,6 +168,8 @@ open class ProfileStore private constructor(
164 username = username,
168 username = username,
165 password = pw,
169 password = pw,
166 lastUsed = lastUsed,
170 lastUsed = lastUsed,
171+ requireHttps = requireHttps,
172+ certPin = certPin,
167 )
173 )
168 }
174 }
169
175
@@ -210,6 +216,8 @@ open class ProfileStore private constructor(
210 baseUrl = profile.baseUrl,
216 baseUrl = profile.baseUrl,
211 username = profile.username?.takeIf { it.isNotBlank() },
217 username = profile.username?.takeIf { it.isNotBlank() },
212 lastUsed = preservedLastUsed,
218 lastUsed = preservedLastUsed,
219+ requireHttps = profile.requireHttps,
220+ certPin = profile.certPin?.takeIf { it.isNotBlank() },
213 )
221 )
214 val updated = current.filterNot { it.id == profile.id } + stored
222 val updated = current.filterNot { it.id == profile.id } + stored
215 prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
223 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
3import android.content.Context
3import android.content.Context
4import androidx.datastore.preferences.core.booleanPreferencesKey
4import androidx.datastore.preferences.core.booleanPreferencesKey
5import androidx.datastore.preferences.core.edit
5import androidx.datastore.preferences.core.edit
6+import androidx.datastore.preferences.core.floatPreferencesKey
6import androidx.datastore.preferences.core.stringPreferencesKey
7import androidx.datastore.preferences.core.stringPreferencesKey
7import androidx.datastore.preferences.preferencesDataStore
8import androidx.datastore.preferences.preferencesDataStore
8import kotlinx.coroutines.flow.Flow
9import kotlinx.coroutines.flow.Flow
@@ -20,6 +21,16 @@ class SettingsStore(context: Context) {
20 private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
21 private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
21 private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
22 private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
22 private val appLockKey = booleanPreferencesKey("app_lock")
23 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+ }
23
34
24 val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
35 val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
25 runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
36 runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
@@ -44,6 +55,21 @@ class SettingsStore(context: Context) {
44 prefs[appLockKey] ?: false
55 prefs[appLockKey] ?: false
45 }
56 }
46
57
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+
47 suspend fun setThemeMode(mode: ThemeMode) {
73 suspend fun setThemeMode(mode: ThemeMode) {
48 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
74 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
49 }
75 }
@@ -59,4 +85,14 @@ class SettingsStore(context: Context) {
59 suspend fun setAppLock(enabled: Boolean) {
85 suspend fun setAppLock(enabled: Boolean) {
60 appContext.settingsDataStore.edit { it[appLockKey] = enabled }
86 appContext.settingsDataStore.edit { it[appLockKey] = enabled }
61 }
87 }
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+ }
62}
98}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+78
@@ -8,6 +8,7 @@ import android.net.NetworkRequest
8import android.util.Log
8import android.util.Log
9import soy.iko.opencode.data.model.AssistantMessage
9import soy.iko.opencode.data.model.AssistantMessage
10import soy.iko.opencode.data.model.BusEvent
10import soy.iko.opencode.data.model.BusEvent
11+import soy.iko.opencode.data.model.FilePromptPart
11import soy.iko.opencode.data.model.MessagePartUpdated
12import soy.iko.opencode.data.model.MessagePartUpdated
12import soy.iko.opencode.data.model.MessageUpdated
13import soy.iko.opencode.data.model.MessageUpdated
13import soy.iko.opencode.data.model.Permission
14import soy.iko.opencode.data.model.Permission
@@ -19,9 +20,11 @@ import soy.iko.opencode.data.model.ServerProfile
19import soy.iko.opencode.data.model.SessionError
20import soy.iko.opencode.data.model.SessionError
20import soy.iko.opencode.data.model.SessionIdle
21import soy.iko.opencode.data.model.SessionIdle
21import soy.iko.opencode.data.repo.AttachmentDraftStore
22import soy.iko.opencode.data.repo.AttachmentDraftStore
23+import soy.iko.opencode.data.repo.BackupManager
22import soy.iko.opencode.data.repo.DraftStore
24import soy.iko.opencode.data.repo.DraftStore
23import soy.iko.opencode.data.repo.ErrorKind
25import soy.iko.opencode.data.repo.ErrorKind
24import soy.iko.opencode.data.repo.MessageCacheStore
26import soy.iko.opencode.data.repo.MessageCacheStore
27+import soy.iko.opencode.data.repo.OutboxStore
25import soy.iko.opencode.data.repo.SessionPrefsStore
28import soy.iko.opencode.data.repo.SessionPrefsStore
26import soy.iko.opencode.data.repo.ProfileStore
29import soy.iko.opencode.data.repo.ProfileStore
27import soy.iko.opencode.data.repo.SettingsStore
30import soy.iko.opencode.data.repo.SettingsStore
@@ -44,6 +47,7 @@ import kotlinx.coroutines.delay
44import kotlinx.coroutines.flow.MutableStateFlow
47import kotlinx.coroutines.flow.MutableStateFlow
45import kotlinx.coroutines.flow.StateFlow
48import kotlinx.coroutines.flow.StateFlow
46import kotlinx.coroutines.flow.asStateFlow
49import kotlinx.coroutines.flow.asStateFlow
50+import kotlinx.coroutines.flow.combine
47import kotlinx.coroutines.flow.emptyFlow
51import kotlinx.coroutines.flow.emptyFlow
48import kotlinx.coroutines.flow.first
52import kotlinx.coroutines.flow.first
49import kotlinx.coroutines.Job
53import kotlinx.coroutines.Job
@@ -90,6 +94,18 @@ open class AppContainer private constructor(
90 open val attachmentDraftStore: AttachmentDraftStore by lazy { AttachmentDraftStore(appContext!!) }
94 open val attachmentDraftStore: AttachmentDraftStore by lazy { AttachmentDraftStore(appContext!!) }
91 open val sessionPrefsStore: SessionPrefsStore by lazy { SessionPrefsStore(appContext!!) }
95 open val sessionPrefsStore: SessionPrefsStore by lazy { SessionPrefsStore(appContext!!) }
92 open val messageCacheStore: MessageCacheStore by lazy { MessageCacheStore(appContext!!) }
96 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 } }
93
109
94 private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
110 private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
95 open val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
111 open val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
@@ -128,6 +144,15 @@ open class AppContainer private constructor(
128 return if (_pendingSharedMedia.compareAndSet(current, emptyList())) current else emptyList()
144 return if (_pendingSharedMedia.compareAndSet(current, emptyList())) current else emptyList()
129 }
145 }
130
146
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+
131 /**
156 /**
132 * A session id to open from an external trigger (a notification tap or a deep link).
157 * A session id to open from an external trigger (a notification tap or a deep link).
133 * The nav host consumes it once a connection is active.
158 * The nav host consumes it once a connection is active.
@@ -241,10 +266,62 @@ open class AppContainer private constructor(
241 if (!skipInit) {
266 if (!skipInit) {
242 NotificationChannels.create(appContext!!)
267 NotificationChannels.create(appContext!!)
243 observeMessageActivity()
268 observeMessageActivity()
269+ appScope.launch { runCatchingCancellable { outboxStore.load() } }
270+ observeOutbox()
244 appScope.launch { autoConnect() }
271 appScope.launch { autoConnect() }
245 }
272 }
246 }
273 }
247
274
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+
248 /** Release resources held for the process lifetime (network callback, app scope). */
325 /** Release resources held for the process lifetime (network callback, app scope). */
249 open fun shutdown() {
326 open fun shutdown() {
250 // Cancel the app scope first so any coroutine holding the connection mutex
327 // Cancel the app scope first so any coroutine holding the connection mutex
@@ -654,6 +731,7 @@ open class AppContainer private constructor(
654 attachmentDraftStore.remove(id)
731 attachmentDraftStore.remove(id)
655 sessionPrefsStore.forget(id)
732 sessionPrefsStore.forget(id)
656 messageCacheStore.remove(id)
733 messageCacheStore.remove(id)
734+ outboxStore.removeForSession(id)
657 onDeleted()
735 onDeleted()
658 }
736 }
659 .onFailure { onError(it) }
737 .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
7import androidx.compose.animation.slideOutHorizontally
7import androidx.compose.animation.slideOutHorizontally
8import androidx.compose.foundation.layout.BoxWithConstraints
8import androidx.compose.foundation.layout.BoxWithConstraints
9import androidx.compose.runtime.Composable
9import androidx.compose.runtime.Composable
10+import androidx.compose.runtime.CompositionLocalProvider
10import androidx.compose.runtime.LaunchedEffect
11import androidx.compose.runtime.LaunchedEffect
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.getValue
12import androidx.compose.runtime.mutableStateOf
13import androidx.compose.runtime.mutableStateOf
@@ -29,10 +30,14 @@ import soy.iko.opencode.ui.file.FileViewScreen
29import soy.iko.opencode.ui.server.ServerEditScreen
30import soy.iko.opencode.ui.server.ServerEditScreen
30import soy.iko.opencode.ui.server.ServerListScreen
31import soy.iko.opencode.ui.server.ServerListScreen
31import soy.iko.opencode.ui.search.GlobalSearchScreen
32import soy.iko.opencode.ui.search.GlobalSearchScreen
33+import soy.iko.opencode.ui.components.LocalChatTextScale
34+import soy.iko.opencode.ui.components.LocalCodeWrap
32import soy.iko.opencode.ui.session.SessionListScreen
35import soy.iko.opencode.ui.session.SessionListScreen
33import soy.iko.opencode.ui.session.TwoPaneSessionChat
36import soy.iko.opencode.ui.session.TwoPaneSessionChat
34import soy.iko.opencode.ui.settings.DiagnosticsScreen
37import soy.iko.opencode.ui.settings.DiagnosticsScreen
35import soy.iko.opencode.ui.settings.SettingsScreen
38import soy.iko.opencode.ui.settings.SettingsScreen
39+import soy.iko.opencode.ui.usage.UsageScreen
40+import soy.iko.opencode.ui.mcp.McpScreen
36import soy.iko.opencode.util.runCatchingCancellable
41import soy.iko.opencode.util.runCatchingCancellable
37
42
38@Composable
43@Composable
@@ -42,7 +47,12 @@ fun OpencodeApp(container: AppContainer) {
42 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
47 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
43 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
48 val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
44 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
49 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
50+ val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
45 val connection by container.activeConnection.collectAsStateWithLifecycle()
51 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)
46
56
47 // Hold a foreground priority for as long as ANY session is actively running — not just
57 // Hold a foreground priority for as long as ANY session is actively running — not just
48 // while a running chat is on screen — so a run started and then backgrounded (or left
58 // while a running chat is on screen — so a run started and then backgrounded (or left
@@ -80,6 +90,24 @@ fun OpencodeApp(container: AppContainer) {
80 }
90 }
81 }
91 }
82
92
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+ ) {
83 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
111 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
84 // the open conversation side by side instead of a single-pane back stack.
112 // the open conversation side by side instead of a single-pane back stack.
85 BoxWithConstraints {
113 BoxWithConstraints {
@@ -206,6 +234,7 @@ fun OpencodeApp(container: AppContainer) {
206 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
234 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
207 onBack = { navController.popBackStack() },
235 onBack = { navController.popBackStack() },
208 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
236 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
237+ onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
209 )
238 )
210 }
239 }
211
240
@@ -251,6 +280,8 @@ fun OpencodeApp(container: AppContainer) {
251 }
280 }
252 },
281 },
253 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
282 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
283+ onOpenUsage = { navController.navigate(Routes.USAGE) },
284+ onOpenMcp = { navController.navigate(Routes.MCP) },
254 )
285 )
255 }
286 }
256
287
@@ -258,6 +289,14 @@ fun OpencodeApp(container: AppContainer) {
258 DiagnosticsScreen(onBack = { navController.popBackStack() })
289 DiagnosticsScreen(onBack = { navController.popBackStack() })
259 }
290 }
260
291
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+
261 composable(Routes.SEARCH) {
300 composable(Routes.SEARCH) {
262 GlobalSearchScreen(
301 GlobalSearchScreen(
263 container = container,
302 container = container,
@@ -267,4 +306,5 @@ fun OpencodeApp(container: AppContainer) {
267 }
306 }
268 }
307 }
269 }
308 }
309+ }
270}
310}
app/src/main/java/soy/iko/opencode/ui/Routes.kt+2
@@ -6,6 +6,8 @@ object Routes {
6 const val SESSIONS = "sessions"
6 const val SESSIONS = "sessions"
7 const val SETTINGS = "settings"
7 const val SETTINGS = "settings"
8 const val DIAGNOSTICS = "diagnostics"
8 const val DIAGNOSTICS = "diagnostics"
9+ const val USAGE = "usage"
10+ const val MCP = "mcp"
9 const val FILES = "files"
11 const val FILES = "files"
10 const val SEARCH = "search"
12 const val SEARCH = "search"
11
13
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+119
@@ -119,7 +119,9 @@ import soy.iko.opencode.data.network.EventStreamClient
119import soy.iko.opencode.data.network.NetworkConfig
119import soy.iko.opencode.data.network.NetworkConfig
120import soy.iko.opencode.di.AppContainer
120import soy.iko.opencode.di.AppContainer
121import soy.iko.opencode.R
121import soy.iko.opencode.R
122+import soy.iko.opencode.ui.components.CommandPalette
122import soy.iko.opencode.ui.components.ConnectionBanner
123import soy.iko.opencode.ui.components.ConnectionBanner
124+import soy.iko.opencode.ui.components.PaletteAction
123import soy.iko.opencode.ui.components.copyToClipboard
125import soy.iko.opencode.ui.components.copyToClipboard
124import soy.iko.opencode.ui.components.LocalRelativeTimeTick
126import soy.iko.opencode.ui.components.LocalRelativeTimeTick
125import soy.iko.opencode.ui.components.rememberRelativeTimeTick
127import soy.iko.opencode.ui.components.rememberRelativeTimeTick
@@ -135,6 +137,7 @@ fun ChatScreen(
135 sessionId: String,
137 sessionId: String,
136 onBack: () -> Unit,
138 onBack: () -> Unit,
137 onOpenFile: ((String) -> Unit)? = null,
139 onOpenFile: ((String) -> Unit)? = null,
140+ onOpenSession: ((String) -> Unit)? = null,
138) {
141) {
139 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
142 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
140 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
143 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
@@ -219,6 +222,30 @@ fun ChatScreen(
219 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
222 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
220 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
223 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
221 var showShellDialog by rememberSaveable { mutableStateOf(false) }
224 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+ }
222
249
223 // Keep the screen awake while the agent is working in *this* session. The foreground
250 // Keep the screen awake while the agent is working in *this* session. The foreground
224 // service that holds process priority during a run is managed app-wide off
251 // service that holds process priority during a run is managed app-wide off
@@ -337,6 +364,15 @@ fun ChatScreen(
337 }
364 }
338 }
365 }
339
366
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+
340 // Stage images shared into the app (via the system share sheet) as attachments, once.
376 // Stage images shared into the app (via the system share sheet) as attachments, once.
341 LaunchedEffect(Unit) {
377 LaunchedEffect(Unit) {
342 val shared = container.consumePendingSharedMedia()
378 val shared = container.consumePendingSharedMedia()
@@ -354,6 +390,21 @@ fun ChatScreen(
354 BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
390 BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
355
391
356 Scaffold(
392 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+ },
357 topBar = {
408 topBar = {
358 TopAppBar(
409 TopAppBar(
359 title = {
410 title = {
@@ -568,6 +619,8 @@ fun ChatScreen(
568 bottomBar = {
619 bottomBar = {
569 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
620 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
570 val todoPlan by vm.todoPlan.collectAsStateWithLifecycle()
621 val todoPlan by vm.todoPlan.collectAsStateWithLifecycle()
622+ val queuedOffline by vm.outbox.collectAsStateWithLifecycle()
623+ val outboxSending by container.outboxSending.collectAsStateWithLifecycle()
571 Column {
624 Column {
572 // Pinned above the composer so the agent's plan and progress stay visible
625 // Pinned above the composer so the agent's plan and progress stay visible
573 // while scrolled up mid-run; renders nothing when there's no plan.
626 // while scrolled up mid-run; renders nothing when there's no plan.
@@ -575,6 +628,14 @@ fun ChatScreen(
575 AnimatedVisibility(visible = reverted) {
628 AnimatedVisibility(visible = reverted) {
576 RevertBanner(onUndo = { vm.unrevert() })
629 RevertBanner(onUndo = { vm.unrevert() })
577 }
630 }
631+ AnimatedVisibility(visible = queuedOffline.isNotEmpty()) {
632+ OutboxBanner(
633+ count = queuedOffline.size,
634+ sending = outboxSending,
635+ onFlush = { vm.flushQueued() },
636+ onDiscard = { vm.discardAllQueued() },
637+ )
638+ }
578 ChatInputBar(
639 ChatInputBar(
579 value = draft,
640 value = draft,
580 onValueChange = vm::updateDraft,
641 onValueChange = vm::updateDraft,
@@ -834,6 +895,11 @@ fun ChatScreen(
834 onEdit = { text -> vm.editMessage(message.info.id, text) },
895 onEdit = { text -> vm.editMessage(message.info.id, text) },
835 onSpeak = { text -> tts.toggle(message.info.id, text) },
896 onSpeak = { text -> tts.toggle(message.info.id, text) },
836 isSpeaking = message.info.id == speakingMessageId,
897 isSpeaking = message.info.id == speakingMessageId,
898+ onQuote = { text ->
899+ vm.quoteReply(text)
900+ runCatching { inputFocusRequester.requestFocus() }
901+ },
902+ onBranch = { text -> vm.branchFrom(text) },
837 )
903 )
838 }
904 }
839 }
905 }
@@ -996,6 +1062,10 @@ fun ChatScreen(
996 onRespond = { response -> vm.respondPermission(permission, response) },
1062 onRespond = { response -> vm.respondPermission(permission, response) },
997 )
1063 )
998 }
1064 }
1065+
1066+ if (showPalette) {
1067+ CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
1068+ }
999}
1069}
1000
1070
1001@Composable
1071@Composable
@@ -1299,6 +1369,55 @@ private fun RevertBanner(onUndo: () -> Unit) {
1299 }
1369 }
1300}
1370}
1301
1371
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+
1302/** Prompt for a one-off shell command to run in the session's worktree. */
1421/** Prompt for a one-off shell command to run in the session's worktree. */
1303@Composable
1422@Composable
1304private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) {
1423private 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
20import soy.iko.opencode.data.model.toOptions
20import soy.iko.opencode.data.model.toOptions
21import soy.iko.opencode.data.network.EventStreamClient
21import soy.iko.opencode.data.network.EventStreamClient
22import soy.iko.opencode.data.network.NetworkConfig
22import soy.iko.opencode.data.network.NetworkConfig
23+import soy.iko.opencode.data.repo.OutboxMessage
23import soy.iko.opencode.data.repo.SessionRepository
24import soy.iko.opencode.data.repo.SessionRepository
24import soy.iko.opencode.di.AppContainer
25import soy.iko.opencode.di.AppContainer
25import soy.iko.opencode.R
26import soy.iko.opencode.R
@@ -298,6 +299,18 @@ class ChatViewModel(
298 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
299 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
299 val draft: StateFlow<String> = _draft.asStateFlow()
300 val draft: StateFlow<String> = _draft.asStateFlow()
300
301
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+
301 /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
314 /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
302 * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
315 * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
303 * survives process death — the source content Uris wouldn't survive, but the data URLs
316 * survives process death — the source content Uris wouldn't survive, but the data URLs
@@ -321,6 +334,14 @@ class ChatViewModel(
321 )
334 )
322 val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
335 val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
323
336
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+
324 init {
345 init {
325 // DraftStore loads SharedPreferences asynchronously; on a cold start the
346 // DraftStore loads SharedPreferences asynchronously; on a cold start the
326 // synchronous get() above returns "" until the background load completes.
347 // synchronous get() above returns "" until the background load completes.
@@ -634,12 +655,20 @@ class ChatViewModel(
634 * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
655 * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
635 */
656 */
636 private fun send(text: String, includeAttachments: Boolean): Boolean {
657 private fun send(text: String, includeAttachments: Boolean): Boolean {
637− val conn = connection ?: return false
638 val trimmed = text.trim()
658 val trimmed = text.trim()
639 val attachments = if (includeAttachments) _attachments.value else emptyList()
659 val attachments = if (includeAttachments) _attachments.value else emptyList()
640 // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
660 // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
641 // attached is not.
661 // attached is not.
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+ }
642− if ((trimmed.isEmpty() && attachments.isEmpty()) || !_running.compareAndSet(false, true)) return false
671+ if (!_running.compareAndSet(false, true)) return false
643 _failedDraft.value = null
672 _failedDraft.value = null
644 // Clear the staged attachments optimistically so the composer empties immediately;
673 // Clear the staged attachments optimistically so the composer empties immediately;
645 // restore them if the send fails (mirrors the draft handling below).
674 // restore them if the send fails (mirrors the draft handling below).
@@ -700,6 +729,82 @@ class ChatViewModel(
700 return true
729 return true
701 }
730 }
702
731
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+
703 /** Re-send the last draft whose send failed, if any. */
808 /** Re-send the last draft whose send failed, if any. */
704 fun retryFailed() {
809 fun retryFailed() {
705 val draft = _failedDraft.value ?: return
810 val draft = _failedDraft.value ?: return
@@ -844,6 +949,40 @@ class ChatViewModel(
844 _draft.value = text
949 _draft.value = text
845 }
950 }
846
951
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+
847 /** Undo the active revert checkpoint, restoring the hidden messages. */
986 /** Undo the active revert checkpoint, restoring the hidden messages. */
848 fun unrevert() {
987 fun unrevert() {
849 val conn = connection ?: return
988 val conn = connection ?: return
@@ -965,6 +1104,7 @@ class ChatViewModel(
965 container.draftStore.remove(sessionId)
1104 container.draftStore.remove(sessionId)
966 container.attachmentDraftStore.remove(sessionId)
1105 container.attachmentDraftStore.remove(sessionId)
967 container.messageCacheStore.remove(sessionId)
1106 container.messageCacheStore.remove(sessionId)
1107+ container.outboxStore.removeForSession(sessionId)
968 _sessionDeleted.value = true
1108 _sessionDeleted.value = true
969 }
1109 }
970 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1110 .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
10import androidx.compose.material.icons.Icons
10import androidx.compose.material.icons.Icons
11import androidx.compose.material.icons.automirrored.filled.HelpOutline
11import androidx.compose.material.icons.automirrored.filled.HelpOutline
12import androidx.compose.material.icons.automirrored.filled.VolumeUp
12import androidx.compose.material.icons.automirrored.filled.VolumeUp
13+import androidx.compose.material.icons.filled.CallSplit
13import androidx.compose.material.icons.filled.ContentCopy
14import androidx.compose.material.icons.filled.ContentCopy
14import androidx.compose.material.icons.filled.Edit
15import androidx.compose.material.icons.filled.Edit
16+import androidx.compose.material.icons.filled.FormatQuote
17+import androidx.compose.material.icons.filled.MoreVert
15import androidx.compose.material.icons.filled.Restore
18import androidx.compose.material.icons.filled.Restore
16import androidx.compose.material.icons.filled.StopCircle
19import androidx.compose.material.icons.filled.StopCircle
20+import androidx.compose.material3.DropdownMenu
21+import androidx.compose.material3.DropdownMenuItem
17import androidx.compose.material3.Icon
22import androidx.compose.material3.Icon
18import androidx.compose.material3.IconButton
23import androidx.compose.material3.IconButton
19import androidx.compose.material3.MaterialTheme
24import androidx.compose.material3.MaterialTheme
20import androidx.compose.material3.Text
25import androidx.compose.material3.Text
21import androidx.compose.runtime.Composable
26import androidx.compose.runtime.Composable
27+import androidx.compose.runtime.getValue
28+import androidx.compose.runtime.mutableStateOf
22import androidx.compose.runtime.remember
29import androidx.compose.runtime.remember
30+import androidx.compose.runtime.setValue
23import androidx.compose.ui.Alignment
31import androidx.compose.ui.Alignment
24import androidx.compose.ui.Modifier
32import androidx.compose.ui.Modifier
25import androidx.compose.ui.draw.clip
33import androidx.compose.ui.draw.clip
@@ -66,11 +74,53 @@ fun MessageBubble(
66 onEdit: ((String) -> Unit)? = null,
74 onEdit: ((String) -> Unit)? = null,
67 onSpeak: ((String) -> Unit)? = null,
75 onSpeak: ((String) -> Unit)? = null,
68 isSpeaking: Boolean = false,
76 isSpeaking: Boolean = false,
77+ onQuote: ((String) -> Unit)? = null,
78+ onBranch: ((String) -> Unit)? = null,
69) {
79) {
70 when (message.info) {
80 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)
72 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
82 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+ }
74 }
124 }
75}
125}
76
126
@@ -133,6 +183,8 @@ private fun UserBubble(
133 onOpenFile: ((String) -> Unit)? = null,
183 onOpenFile: ((String) -> Unit)? = null,
134 onRevert: (() -> Unit)? = null,
184 onRevert: (() -> Unit)? = null,
135 onEdit: ((String) -> Unit)? = null,
185 onEdit: ((String) -> Unit)? = null,
186+ onQuote: ((String) -> Unit)? = null,
187+ onBranch: ((String) -> Unit)? = null,
136) {
188) {
137 val context = LocalContext.current
189 val context = LocalContext.current
138 val copyLabel = stringResource(R.string.copy)
190 val copyLabel = stringResource(R.string.copy)
@@ -191,6 +243,11 @@ private fun UserBubble(
191 )
243 )
192 }
244 }
193 }
245 }
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+ }
194 }
251 }
195 }
252 }
196 }
253 }
@@ -208,6 +265,7 @@ private fun AssistantBlock(
208 onRevert: (() -> Unit)? = null,
265 onRevert: (() -> Unit)? = null,
209 onSpeak: ((String) -> Unit)? = null,
266 onSpeak: ((String) -> Unit)? = null,
210 isSpeaking: Boolean = false,
267 isSpeaking: Boolean = false,
268+ onQuote: ((String) -> Unit)? = null,
211) {
269) {
212 Column(
270 Column(
213 modifier = modifier.fillMaxWidth(),
271 modifier = modifier.fillMaxWidth(),
@@ -278,6 +336,7 @@ private fun AssistantBlock(
278 isSpeaking = isSpeaking,
336 isSpeaking = isSpeaking,
279 onSpeak = onSpeak,
337 onSpeak = onSpeak,
280 onRevert = onRevert,
338 onRevert = onRevert,
339+ onQuote = onQuote,
281 )
340 )
282 }
341 }
283 } else {
342 } else {
@@ -294,6 +353,7 @@ private fun AssistantActions(
294 isSpeaking: Boolean,
353 isSpeaking: Boolean,
295 onSpeak: ((String) -> Unit)?,
354 onSpeak: ((String) -> Unit)?,
296 onRevert: (() -> Unit)?,
355 onRevert: (() -> Unit)?,
356+ onQuote: ((String) -> Unit)? = null,
297) {
357) {
298 val context = LocalContext.current
358 val context = LocalContext.current
299 val copyLabel = stringResource(R.string.copy)
359 val copyLabel = stringResource(R.string.copy)
@@ -323,6 +383,11 @@ private fun AssistantActions(
323 )
383 )
324 }
384 }
325 }
385 }
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+ }
326 }
391 }
327}
392}
328
393
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
6import android.widget.Toast
6import android.widget.Toast
7import androidx.compose.foundation.background
7import androidx.compose.foundation.background
8import androidx.compose.foundation.horizontalScroll
8import androidx.compose.foundation.horizontalScroll
9+import androidx.compose.foundation.layout.Arrangement
9import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.Box
11+import androidx.compose.foundation.layout.Column
12+import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
15import androidx.compose.foundation.layout.size
@@ -14,11 +17,13 @@ import androidx.compose.foundation.rememberScrollState
14import androidx.compose.foundation.text.selection.SelectionContainer
17import androidx.compose.foundation.text.selection.SelectionContainer
15import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.filled.ContentCopy
19import androidx.compose.material.icons.filled.ContentCopy
20+import androidx.compose.material.icons.automirrored.filled.WrapText
17import androidx.compose.material3.Icon
21import androidx.compose.material3.Icon
18import androidx.compose.material3.IconButton
22import androidx.compose.material3.IconButton
19import androidx.compose.material3.MaterialTheme
23import androidx.compose.material3.MaterialTheme
20import androidx.compose.material3.Surface
24import androidx.compose.material3.Surface
21import androidx.compose.material3.Text
25import androidx.compose.material3.Text
26+import androidx.compose.material3.Typography
22import androidx.compose.runtime.Composable
27import androidx.compose.runtime.Composable
23import androidx.compose.runtime.LaunchedEffect
28import androidx.compose.runtime.LaunchedEffect
24import androidx.compose.runtime.getValue
29import androidx.compose.runtime.getValue
@@ -40,7 +45,6 @@ import com.mikepenz.markdown.compose.components.MarkdownComponentModel
40import com.mikepenz.markdown.compose.components.markdownComponents
45import com.mikepenz.markdown.compose.components.markdownComponents
41import com.mikepenz.markdown.m3.Markdown
46import com.mikepenz.markdown.m3.Markdown
42import org.intellij.markdown.MarkdownElementTypes
47import org.intellij.markdown.MarkdownElementTypes
43−import org.intellij.markdown.ast.ASTNode
44import org.intellij.markdown.ast.getTextInNode
48import org.intellij.markdown.ast.getTextInNode
45import soy.iko.opencode.R
49import soy.iko.opencode.R
46import soy.iko.opencode.data.network.NetworkConfig
50import soy.iko.opencode.data.network.NetworkConfig
@@ -76,10 +80,33 @@ import soy.iko.opencode.data.network.NetworkConfig
76fun MarkdownText(
80fun MarkdownText(
77 markdown: String,
81 markdown: String,
78 modifier: Modifier = Modifier,
82 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,
80 streaming: Boolean = false,
108 streaming: Boolean = false,
81) {
109) {
82− val context = LocalContext.current
83 val components = remember {
110 val components = remember {
84 markdownComponents(
111 markdownComponents(
85 codeFence = { CodeWithCopy(it) },
112 codeFence = { CodeWithCopy(it) },
@@ -138,44 +165,61 @@ fun MarkdownText(
138}
165}
139
166
140/**
167/**
141− * Renders a fenced or indented code block with a small copy button in the top-right corner.
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
142− * Self-contained so it doesn't depend on the library's internal code-block composables.
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.
143 */
173 */
144@Composable
174@Composable
145private fun CodeWithCopy(model: MarkdownComponentModel) {
175private fun CodeWithCopy(model: MarkdownComponentModel) {
146 val context = LocalContext.current
176 val context = LocalContext.current
147 // Key on the content string + the AST node's offset range (stable ints) instead of
177 // Key on the content string + the AST node's offset range (stable ints) instead of
148 // the model itself. MarkdownComponentModel is not @Immutable/@Stable (it holds an
178 // 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.
150 val node = model.node
180 val node = model.node
181+ val isFenced = node.type == MarkdownElementTypes.CODE_FENCE
151− val code = remember(model.content, node.startOffset, node.endOffset) {
182+ val raw = remember(model.content, node.startOffset, node.endOffset) {
152− extractCode(model.content, node)
183+ node.getTextInNode(model.content).toString()
153 }
184 }
185+ val code = remember(raw, isFenced) { extractCodeText(raw, isFenced) }
186+ val language = remember(raw, isFenced) { extractFenceLanguage(raw, isFenced) }
154 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
187 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(
156 modifier = Modifier
193 modifier = Modifier
157 .fillMaxWidth()
194 .fillMaxWidth()
158 .clip(MaterialTheme.shapes.small)
195 .clip(MaterialTheme.shapes.small)
159 .background(MaterialTheme.colorScheme.surfaceVariant),
196 .background(MaterialTheme.colorScheme.surfaceVariant),
160 ) {
197 ) {
161− Text(
198+ Row(
162− text = code,
163− style = codeStyle,
164 modifier = Modifier
199 modifier = Modifier
165− .horizontalScroll(rememberScrollState())
166 .fillMaxWidth()
200 .fillMaxWidth()
167− .padding(start = 12.dp, top = 12.dp, end = 40.dp, bottom = 12.dp),
201+ .padding(start = 12.dp, end = 2.dp),
168− )
202+ verticalAlignment = Alignment.CenterVertically,
169− Surface(
170− color = MaterialTheme.colorScheme.surface,
171− shape = MaterialTheme.shapes.extraSmall,
172− modifier = Modifier
173− .align(Alignment.TopEnd)
174− .padding(4.dp),
175 ) {
203 ) {
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+ }
176 IconButton(
220 IconButton(
177 onClick = { copyToClipboard(context, "code", code) },
221 onClick = { copyToClipboard(context, "code", code) },
178− modifier = Modifier.size(48.dp),
222+ modifier = Modifier.size(36.dp),
179 ) {
223 ) {
180 Icon(
224 Icon(
181 Icons.Filled.ContentCopy,
225 Icons.Filled.ContentCopy,
@@ -185,15 +229,38 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
185 )
229 )
186 }
230 }
187 }
231 }
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+ }
188 }
248 }
189}
249}
190
250
191/**
251/**
192− * Extract the raw code text from a markdown code node, stripping fence markers
252+ * Extract the fence language tag (e.g. "kotlin" from ```kotlin) so the code block can
193− * (```/~~~ and the language tag) so only the code content is copied.
253+ * label itself. Returns null for indented blocks or fences with no info string. Extracted
254+ * as a pure function for testability.
194 */
255 */
195−private fun extractCode(content: String, node: ASTNode): String =
256+internal fun extractFenceLanguage(raw: String, isFenced: Boolean): String? {
196− extractCodeText(node.getTextInNode(content).toString(), node.type == MarkdownElementTypes.CODE_FENCE)
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+}
197
264
198/**
265/**
199 * Strip fence markers (```/~~~ and the language tag) from a raw code node text so
266 * 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
32import androidx.compose.material3.OutlinedButton
32import androidx.compose.material3.OutlinedButton
33import androidx.compose.material3.OutlinedTextField
33import androidx.compose.material3.OutlinedTextField
34import androidx.compose.material3.Scaffold
34import androidx.compose.material3.Scaffold
35+import androidx.compose.material3.Switch
35import androidx.compose.material3.Text
36import androidx.compose.material3.Text
36import androidx.compose.material3.TextButton
37import androidx.compose.material3.TextButton
37import androidx.compose.material3.TopAppBar
38import androidx.compose.material3.TopAppBar
@@ -243,6 +244,7 @@ private fun ServerEditForm(
243 onImeDone = { if (canSave && !state.saving) vm.saveAndConnect(onDone) },
244 onImeDone = { if (canSave && !state.saving) vm.saveAndConnect(onDone) },
244 )
245 )
245 }
246 }
247+ SecurityFields(state = state, onUpdate = vm::update)
246 Row(
248 Row(
247 modifier = Modifier.fillMaxWidth(),
249 modifier = Modifier.fillMaxWidth(),
248 horizontalArrangement = Arrangement.spacedBy(12.dp),
250 horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -362,6 +364,55 @@ private fun AuthFields(
362 }
364 }
363}
365}
364
366
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+
365/**
416/**
366 * Optional LAN discovery: browses for opencode servers advertised via mDNS (`--mdns`) and
417 * Optional LAN discovery: browses for opencode servers advertised via mDNS (`--mdns`) and
367 * offers each as a one-tap fill for the base URL. Collapsed by default so the discovery (and
418 * 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(
25 val baseUrl: String = "",
25 val baseUrl: String = "",
26 val username: String = "",
26 val username: String = "",
27 val password: String = "",
27 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 = "",
28 val loaded: Boolean = false,
32 val loaded: Boolean = false,
29 val saving: Boolean = false,
33 val saving: Boolean = false,
30 val error: String? = null,
34 val error: String? = null,
@@ -42,8 +46,10 @@ data class ServerEditState(
42 * Null until a probe runs, and cleared when the base URL is edited. */
46 * Null until a probe runs, and cleared when the base URL is edited. */
43 val probeReachable: Boolean? = null,
47 val probeReachable: Boolean? = null,
44) {
48) {
45− val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
49+ val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl) && certPinValid
46 val isNew: Boolean get() = id == null
50 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)
47 /** True if any field differs from its loaded value. */
53 /** True if any field differs from its loaded value. */
48 val isDirty: Boolean
54 val isDirty: Boolean
49 get() {
55 get() {
@@ -51,7 +57,9 @@ data class ServerEditState(
51 return label.trim() != init.label ||
57 return label.trim() != init.label ||
52 baseUrl.trim() != init.baseUrl ||
58 baseUrl.trim() != init.baseUrl ||
53 username.trim() != init.username ||
59 username.trim() != init.username ||
54− password.trim() != init.password
60+ password.trim() != init.password ||
61+ requireHttps != init.requireHttps ||
62+ certPin.trim() != init.certPin
55 }
63 }
56}
64}
57
65
@@ -61,8 +69,19 @@ data class InitialProfile(
61 val baseUrl: String,
69 val baseUrl: String,
62 val username: String,
70 val username: String,
63 val password: String,
71 val password: String,
72+ val requireHttps: Boolean = false,
73+ val certPin: String = "",
64)
74)
65
75
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+
66fun isValidUrl(url: String): Boolean = try {
85fun isValidUrl(url: String): Boolean = try {
67 val u = java.net.URI(url.trim())
86 val u = java.net.URI(url.trim())
68 u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
87 u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
@@ -105,6 +124,8 @@ class ServerEditViewModel(
105 baseUrl = existing.baseUrl,
124 baseUrl = existing.baseUrl,
106 username = existing.username.orEmpty(),
125 username = existing.username.orEmpty(),
107 password = existing.password.orEmpty(),
126 password = existing.password.orEmpty(),
127+ requireHttps = existing.requireHttps,
128+ certPin = existing.certPin.orEmpty().trim(),
108 )
129 )
109 _state.value = ServerEditState(
130 _state.value = ServerEditState(
110 id = existing.id,
131 id = existing.id,
@@ -112,6 +133,8 @@ class ServerEditViewModel(
112 baseUrl = existing.baseUrl,
133 baseUrl = existing.baseUrl,
113 username = existing.username.orEmpty(),
134 username = existing.username.orEmpty(),
114 password = existing.password.orEmpty(),
135 password = existing.password.orEmpty(),
136+ requireHttps = existing.requireHttps,
137+ certPin = existing.certPin.orEmpty(),
115 loaded = true,
138 loaded = true,
116 authFieldsVisible = existing.hasAuth,
139 authFieldsVisible = existing.hasAuth,
117 initial = init,
140 initial = init,
@@ -142,6 +165,8 @@ class ServerEditViewModel(
142 baseUrl = source.baseUrl,
165 baseUrl = source.baseUrl,
143 username = source.username.orEmpty(),
166 username = source.username.orEmpty(),
144 password = source.password.orEmpty(),
167 password = source.password.orEmpty(),
168+ requireHttps = source.requireHttps,
169+ certPin = source.certPin.orEmpty(),
145 loaded = true,
170 loaded = true,
146 authFieldsVisible = source.hasAuth,
171 authFieldsVisible = source.hasAuth,
147 // Snapshot the seeded (copy) values so an untouched duplicate isn't
172 // Snapshot the seeded (copy) values so an untouched duplicate isn't
@@ -152,6 +177,8 @@ class ServerEditViewModel(
152 baseUrl = source.baseUrl.trim(),
177 baseUrl = source.baseUrl.trim(),
153 username = source.username.orEmpty().trim(),
178 username = source.username.orEmpty().trim(),
154 password = source.password.orEmpty().trim(),
179 password = source.password.orEmpty().trim(),
180+ requireHttps = source.requireHttps,
181+ certPin = source.certPin.orEmpty().trim(),
155 ),
182 ),
156 )
183 )
157 return@launch
184 return@launch
@@ -272,6 +299,8 @@ class ServerEditViewModel(
272 username = s.username.trim().takeIf { it.isNotBlank() },
299 username = s.username.trim().takeIf { it.isNotBlank() },
273 password = s.password.trim().takeIf { it.isNotEmpty() },
300 password = s.password.trim().takeIf { it.isNotEmpty() },
274 lastUsed = existingLastUsed,
301 lastUsed = existingLastUsed,
302+ requireHttps = s.requireHttps,
303+ certPin = s.certPin.trim().takeIf { it.isNotBlank() },
275 )
304 )
276 container.profileStore.save(saved)
305 container.profileStore.save(saved)
277 if (connectAfter) {
306 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
75import androidx.compose.ui.text.style.TextOverflow
75import androidx.compose.ui.text.style.TextOverflow
76import androidx.compose.ui.unit.dp
76import androidx.compose.ui.unit.dp
77import androidx.compose.ui.res.stringResource
77import androidx.compose.ui.res.stringResource
78+import androidx.compose.ui.platform.LocalContext
78import androidx.lifecycle.compose.collectAsStateWithLifecycle
79import androidx.lifecycle.compose.collectAsStateWithLifecycle
79import androidx.lifecycle.viewmodel.compose.viewModel
80import androidx.lifecycle.viewmodel.compose.viewModel
80import kotlinx.coroutines.coroutineScope
81import kotlinx.coroutines.coroutineScope
@@ -84,7 +85,11 @@ import kotlinx.coroutines.launch
84import soy.iko.opencode.data.model.ServerProfile
85import soy.iko.opencode.data.model.ServerProfile
85import soy.iko.opencode.data.model.Session
86import soy.iko.opencode.data.model.Session
86import soy.iko.opencode.data.network.NetworkConfig
87import soy.iko.opencode.data.network.NetworkConfig
88+import soy.iko.opencode.data.repo.RecentSession
89+import soy.iko.opencode.data.repo.RecentSessionsStore
87import soy.iko.opencode.di.AppContainer
90import soy.iko.opencode.di.AppContainer
91+import soy.iko.opencode.platform.AppShortcuts
92+import soy.iko.opencode.platform.SessionsWidgetProvider
88import soy.iko.opencode.R
93import soy.iko.opencode.R
89import soy.iko.opencode.ui.components.ConnectionBanner
94import soy.iko.opencode.ui.components.ConnectionBanner
90import soy.iko.opencode.ui.components.LocalRelativeTimeTick
95import soy.iko.opencode.ui.components.LocalRelativeTimeTick
@@ -139,6 +144,19 @@ fun SessionListScreen(
139 val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
144 val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
140 val pendingRename = pendingRenameId?.let { id -> state.sessions.firstOrNull { it.id == id } }
145 val pendingRename = pendingRenameId?.let { id -> state.sessions.firstOrNull { it.id == id } }
141
146
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+
142 // Close the open dropdown on back press instead of navigating away.
160 // Close the open dropdown on back press instead of navigating away.
143 BackHandler(enabled = showServerMenu) { showServerMenu = false }
161 BackHandler(enabled = showServerMenu) { showServerMenu = false }
144 BackHandler(enabled = showSortMenu) { showSortMenu = false }
162 BackHandler(enabled = showSortMenu) { showSortMenu = false }
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+1
@@ -130,6 +130,7 @@ fun TwoPaneSessionChat(
130 sessionId = sessionId,
130 sessionId = sessionId,
131 onBack = { selected = null },
131 onBack = { selected = null },
132 onOpenFile = onOpenFile,
132 onOpenFile = onOpenFile,
133+ onOpenSession = { id -> selected = id },
133 )
134 )
134 }
135 }
135 }
136 }
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+238−1
@@ -2,6 +2,8 @@ package soy.iko.opencode.ui.settings
2
2
3import android.content.pm.PackageManager
3import android.content.pm.PackageManager
4import android.os.Build
4import android.os.Build
5+import androidx.activity.compose.rememberLauncherForActivityResult
6+import androidx.activity.result.contract.ActivityResultContracts
5import androidx.compose.foundation.background
7import androidx.compose.foundation.background
6import androidx.compose.foundation.clickable
8import androidx.compose.foundation.clickable
7import androidx.compose.foundation.layout.Arrangement
9import androidx.compose.foundation.layout.Arrangement
@@ -24,6 +26,10 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
24import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
26import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
25import androidx.compose.material.icons.filled.BugReport
27import androidx.compose.material.icons.filled.BugReport
26import androidx.compose.material.icons.filled.Dns
28import 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
27import androidx.compose.material3.ExperimentalMaterial3Api
33import androidx.compose.material3.ExperimentalMaterial3Api
28import androidx.compose.material3.CircularProgressIndicator
34import androidx.compose.material3.CircularProgressIndicator
29import androidx.compose.material3.HorizontalDivider
35import androidx.compose.material3.HorizontalDivider
@@ -32,14 +38,19 @@ import androidx.compose.material3.IconButton
32import androidx.compose.material3.MaterialTheme
38import androidx.compose.material3.MaterialTheme
33import androidx.compose.material3.RadioButton
39import androidx.compose.material3.RadioButton
34import androidx.compose.material3.Scaffold
40import androidx.compose.material3.Scaffold
41+import androidx.compose.material3.Slider
35import androidx.compose.material3.Switch
42import androidx.compose.material3.Switch
36import androidx.compose.material3.Text
43import androidx.compose.material3.Text
37import androidx.compose.material3.TextButton
44import androidx.compose.material3.TextButton
38import androidx.compose.material3.TopAppBar
45import androidx.compose.material3.TopAppBar
39import androidx.compose.runtime.Composable
46import androidx.compose.runtime.Composable
40import androidx.compose.runtime.getValue
47import androidx.compose.runtime.getValue
48+import androidx.compose.runtime.mutableFloatStateOf
49+import androidx.compose.runtime.mutableStateOf
41import androidx.compose.runtime.remember
50import androidx.compose.runtime.remember
42import androidx.compose.runtime.rememberCoroutineScope
51import androidx.compose.runtime.rememberCoroutineScope
52+import androidx.compose.runtime.saveable.rememberSaveable
53+import androidx.compose.runtime.setValue
43import androidx.compose.ui.Alignment
54import androidx.compose.ui.Alignment
44import androidx.compose.ui.Modifier
55import androidx.compose.ui.Modifier
45import androidx.compose.ui.draw.clip
56import androidx.compose.ui.draw.clip
@@ -60,17 +71,27 @@ import kotlinx.coroutines.flow.flowOf
60import kotlinx.coroutines.launch
71import kotlinx.coroutines.launch
61import soy.iko.opencode.data.network.EventStreamClient
72import soy.iko.opencode.data.network.EventStreamClient
62import soy.iko.opencode.data.repo.CrashLogger
73import soy.iko.opencode.data.repo.CrashLogger
74+import soy.iko.opencode.data.repo.SettingsStore
63import soy.iko.opencode.data.repo.ThemeMode
75import soy.iko.opencode.data.repo.ThemeMode
64import soy.iko.opencode.di.AppContainer
76import soy.iko.opencode.di.AppContainer
65import soy.iko.opencode.R
77import soy.iko.opencode.R
66import soy.iko.opencode.ui.canAuthenticateForAppLock
78import soy.iko.opencode.ui.canAuthenticateForAppLock
79+import soy.iko.opencode.ui.components.showToast
67import soy.iko.opencode.ui.theme.LightPaletteSwatches
80import soy.iko.opencode.ui.theme.LightPaletteSwatches
68import soy.iko.opencode.ui.theme.DarkPaletteSwatches
81import soy.iko.opencode.ui.theme.DarkPaletteSwatches
69import soy.iko.opencode.util.runCatchingCancellable
82import soy.iko.opencode.util.runCatchingCancellable
83+import kotlin.math.roundToInt
70
84
71@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class)
85@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class)
72@Composable
86@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+) {
74 val scope = rememberCoroutineScope()
95 val scope = rememberCoroutineScope()
75 // Combine the three settings into a single nullable state so the appearance section
96 // Combine the three settings into a single nullable state so the appearance section
76 // renders only after the persisted values have loaded, avoiding a brief flash of
97 // renders only after the persisted values have loaded, avoiding a brief flash of
@@ -87,6 +108,12 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
87 ) { theme, dyn, enter, lock -> SettingsValues(theme, dyn, enter, lock) as SettingsValues? }
108 ) { theme, dyn, enter, lock -> SettingsValues(theme, dyn, enter, lock) as SettingsValues? }
88 }
109 }
89 val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
110 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)
90 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
117 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
91 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
118 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
92 // SSE connection state so the Settings screen can show a dropped stream (the
119 // SSE connection state so the Settings screen can show a dropped stream (the
@@ -121,6 +148,40 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
121 }.getOrNull() ?: unknownVersion
148 }.getOrNull() ?: unknownVersion
122 }
149 }
123
150
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+
124 Scaffold(
185 Scaffold(
125 topBar = {
186 topBar = {
126 TopAppBar(
187 TopAppBar(
@@ -250,6 +311,41 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
250
311
251 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
312 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
252
313
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+
253 Text(
349 Text(
254 stringResource(R.string.connection),
350 stringResource(R.string.connection),
255 style = MaterialTheme.typography.titleSmall,
351 style = MaterialTheme.typography.titleSmall,
@@ -316,6 +412,70 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
316 Text(stringResource(R.string.manage_servers), modifier = Modifier.weight(1f).padding(start = 8.dp))
412 Text(stringResource(R.string.manage_servers), modifier = Modifier.weight(1f).padding(start = 8.dp))
317 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
413 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
318 }
414 }
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+ )
319
479
320 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
480 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
321
481
@@ -360,6 +520,83 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
360 }
520 }
361}
521}
362
522
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+
363@Composable
600@Composable
364private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
601private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
365 // Preview swatches so the user can see what each mode looks like before selecting,
602 // 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 @@
367 <string name="todo_in_progress">En curso</string>
367 <string name="todo_in_progress">En curso</string>
368 <string name="todo_completed">Completado</string>
368 <string name="todo_completed">Completado</string>
369 <string name="todo_cancelled">Cancelado</string>
369 <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>
370</resources>
473</resources>
app/src/main/res/values/strings.xml+103
@@ -369,4 +369,107 @@
369 <string name="todo_in_progress">In progress</string>
369 <string name="todo_in_progress">In progress</string>
370 <string name="todo_completed">Completed</string>
370 <string name="todo_completed">Completed</string>
371 <string name="todo_cancelled">Cancelled</string>
371 <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>
372</resources>
475</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
29import soy.iko.opencode.data.repo.AttachmentDraftStore
29import soy.iko.opencode.data.repo.AttachmentDraftStore
30import soy.iko.opencode.data.repo.DraftStore
30import soy.iko.opencode.data.repo.DraftStore
31import soy.iko.opencode.data.repo.MessageCacheStore
31import soy.iko.opencode.data.repo.MessageCacheStore
32+import soy.iko.opencode.data.repo.OutboxStore
32import soy.iko.opencode.data.repo.PersistedAttachment
33import soy.iko.opencode.data.repo.PersistedAttachment
33import soy.iko.opencode.data.repo.ProfileStore
34import soy.iko.opencode.data.repo.ProfileStore
34import soy.iko.opencode.data.repo.SessionPrefsStore
35import soy.iko.opencode.data.repo.SessionPrefsStore
@@ -382,6 +383,8 @@ class FakeAppContainer : AppContainer() {
382 val fakeSessionPrefsStore = FakeSessionPrefsStore()
383 val fakeSessionPrefsStore = FakeSessionPrefsStore()
383 val fakeAttachmentDraftStore = FakeAttachmentDraftStore()
384 val fakeAttachmentDraftStore = FakeAttachmentDraftStore()
384 val fakeMessageCacheStore = FakeMessageCacheStore()
385 val fakeMessageCacheStore = FakeMessageCacheStore()
386+ // In-memory outbox (no Context / disk); exercises the same reduce logic the app uses.
387+ val fakeOutboxStore = object : OutboxStore() {}
385 val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
388 val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
386 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
389 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
387
390
@@ -390,6 +393,7 @@ class FakeAppContainer : AppContainer() {
390 override val sessionPrefsStore: SessionPrefsStore = fakeSessionPrefsStore
393 override val sessionPrefsStore: SessionPrefsStore = fakeSessionPrefsStore
391 override val attachmentDraftStore: AttachmentDraftStore = fakeAttachmentDraftStore
394 override val attachmentDraftStore: AttachmentDraftStore = fakeAttachmentDraftStore
392 override val messageCacheStore: MessageCacheStore = fakeMessageCacheStore
395 override val messageCacheStore: MessageCacheStore = fakeMessageCacheStore
396+ override val outboxStore: OutboxStore = fakeOutboxStore
393 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
397 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
394 override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()
398 override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()
395
399
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+}