Vectrum
Sign In

Android companion: project, protocol, connection, MetalUI theme layer and app shell

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 92fb2cacommit33d7a23

Showing 195 changed files with 4506 additions and 0 deletions.

+6−0android/.gitignoreaddedView File
@@ -0,0 +1,6 @@
1+ .gradle/
2+ .kotlin/
3+ build/
4+ local.properties
5+ *.iml
6+ .idea/
+43−0android/app/build.gradle.ktsaddedView File
@@ -0,0 +1,43 @@
1+ plugins {
2+ alias(libs.plugins.android.application)
3+ alias(libs.plugins.kotlin.compose)
4+ alias(libs.plugins.kotlin.serialization)
5+ }
6+
7+ android {
8+ namespace = "cc.vectrum.code"
9+ compileSdk = 37
10+
11+ defaultConfig {
12+ applicationId = "cc.vectrum.code"
13+ minSdk = 29
14+ targetSdk = 37
15+ versionCode = 1
16+ versionName = "0.1.0"
17+ }
18+
19+ buildFeatures { compose = true }
20+
21+ testOptions { unitTests.isReturnDefaultValues = true }
22+
23+ buildTypes {
24+ release {
25+ isMinifyEnabled = true
26+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
27+ // Signed with the debug key until there's an upload key.
28+ signingConfig = signingConfigs.getByName("debug")
29+ }
30+ }
31+ }
32+
33+ dependencies {
34+ implementation(libs.androidx.activity.compose)
35+ implementation(libs.compose.runtime)
36+ implementation(libs.compose.foundation)
37+ implementation(libs.compose.ui)
38+ implementation(libs.compose.ui.text)
39+ implementation(libs.coroutines.android)
40+ implementation(libs.serialization.json)
41+ implementation(libs.okhttp)
42+ testImplementation(libs.junit)
43+ }
+5−0android/app/proguard-rules.proaddedView File
@@ -0,0 +1,5 @@
1+ # kotlinx.serialization keeps its generated serializers through its own rules.
2+ -dontwarn okhttp3.internal.platform.**
3+ -dontwarn org.conscrypt.**
4+ -dontwarn org.bouncycastle.**
5+ -dontwarn org.openjsse.**
+34−0android/app/src/main/AndroidManifest.xmladdedView File
@@ -0,0 +1,34 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
4+ <uses-permission android:name="android.permission.INTERNET" />
5+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
6+
7+ <application
8+ android:name=".VectrumApp"
9+ android:icon="@mipmap/ic_launcher"
10+ android:roundIcon="@mipmap/ic_launcher"
11+ android:label="Vectrum Code"
12+ android:theme="@style/Theme.Vectrum"
13+ android:usesCleartextTraffic="true"
14+ android:supportsRtl="true">
15+
16+ <activity
17+ android:name=".MainActivity"
18+ android:exported="true"
19+ android:windowSoftInputMode="adjustResize"
20+ android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|density|uiMode">
21+ <intent-filter>
22+ <action android:name="android.intent.action.MAIN" />
23+ <category android:name="android.intent.category.LAUNCHER" />
24+ </intent-filter>
25+ <!-- A pairing code as a link: vectrum://192.168.2.10:47823/<key> -->
26+ <intent-filter>
27+ <action android:name="android.intent.action.VIEW" />
28+ <category android:name="android.intent.category.DEFAULT" />
29+ <category android:name="android.intent.category.BROWSABLE" />
30+ <data android:scheme="vectrum" />
31+ </intent-filter>
32+ </activity>
33+ </application>
34+ </manifest>
+68−0android/app/src/main/java/cc/vectrum/code/MainActivity.ktaddedView File
@@ -0,0 +1,68 @@
1+ package cc.vectrum.code
2+
3+ import android.content.Intent
4+ import android.graphics.Color
5+ import android.os.Bundle
6+ import androidx.activity.ComponentActivity
7+ import androidx.activity.SystemBarStyle
8+ import androidx.activity.compose.setContent
9+ import androidx.activity.enableEdgeToEdge
10+ import androidx.compose.foundation.isSystemInDarkTheme
11+ import androidx.compose.runtime.DisposableEffect
12+ import androidx.compose.runtime.LaunchedEffect
13+ import androidx.compose.runtime.remember
14+ import cc.vectrum.code.net.parsePairing
15+ import cc.vectrum.code.net.withPort
16+ import cc.vectrum.code.theme.ProvidePalette
17+ import cc.vectrum.code.theme.Themes
18+ import cc.vectrum.code.ui.Root
19+ import cc.vectrum.code.ui.Nav
20+
21+ class MainActivity : ComponentActivity() {
22+ private val app get() = (application as VectrumApp).state
23+ private val nav = Nav()
24+
25+ override fun onCreate(savedInstanceState: Bundle?) {
26+ super.onCreate(savedInstanceState)
27+ handle(intent)
28+ setContent {
29+ val systemDark = isSystemInDarkTheme()
30+ val prefs = app.prefs
31+ val palette = when (prefs.themeMode) {
32+ "light" -> Themes.find(prefs.lightTheme, false)
33+ "dark" -> Themes.find(prefs.darkTheme, true)
34+ else -> if (systemDark) Themes.find(prefs.darkTheme, true) else Themes.find(prefs.lightTheme, false)
35+ }
36+ val dark = palette.dark
37+ DisposableEffect(dark) {
38+ val style = if (dark) SystemBarStyle.dark(Color.TRANSPARENT)
39+ else SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT)
40+ enableEdgeToEdge(statusBarStyle = style, navigationBarStyle = style)
41+ onDispose {}
42+ }
43+ ProvidePalette(palette) {
44+ Root(app, nav)
45+ }
46+ }
47+ }
48+
49+ override fun onNewIntent(intent: Intent) {
50+ super.onNewIntent(intent)
51+ handle(intent)
52+ }
53+
54+ override fun onResume() {
55+ super.onResume()
56+ app.resume()
57+ }
58+
59+ /** A `vectrum://address/key` link pairs with that host. */
60+ private fun handle(intent: Intent?) {
61+ val data = intent?.data ?: return
62+ if (data.scheme != "vectrum") return
63+ val (address, key) = parsePairing(data.toString())
64+ if (address.isBlank() || key == null) return
65+ app.addHost(withPort(address), key)
66+ nav.home()
67+ }
68+ }
+17−0android/app/src/main/java/cc/vectrum/code/VectrumApp.ktaddedView File
@@ -0,0 +1,17 @@
1+ package cc.vectrum.code
2+
3+ import android.app.Application
4+ import cc.vectrum.code.data.AppState
5+ import cc.vectrum.code.theme.Themes
6+
7+ class VectrumApp : Application() {
8+ lateinit var state: AppState
9+ private set
10+
11+ override fun onCreate() {
12+ super.onCreate()
13+ Themes.load(this)
14+ state = AppState(this)
15+ state.start()
16+ }
17+ }
+410−0android/app/src/main/java/cc/vectrum/code/data/AppState.ktaddedView File
@@ -0,0 +1,410 @@
1+ package cc.vectrum.code.data
2+
3+ import android.content.Context
4+ import android.content.SharedPreferences
5+ import android.os.Build
6+ import androidx.compose.runtime.getValue
7+ import androidx.compose.runtime.mutableStateListOf
8+ import androidx.compose.runtime.mutableStateMapOf
9+ import androidx.compose.runtime.mutableStateOf
10+ import androidx.compose.runtime.setValue
11+ import cc.vectrum.code.net.ClientMessage
12+ import cc.vectrum.code.net.Connection
13+ import cc.vectrum.code.net.ConnectionStatus
14+ import cc.vectrum.code.net.Folder
15+ import cc.vectrum.code.net.ProjectInfo
16+ import cc.vectrum.code.net.RemoteFileDiff
17+ import cc.vectrum.code.net.RemoteInstance
18+ import cc.vectrum.code.net.RemoteModel
19+ import cc.vectrum.code.net.SavedItem
20+ import cc.vectrum.code.net.ServerMessage
21+ import cc.vectrum.code.net.SettingsPatch
22+ import cc.vectrum.code.net.ThreadAction
23+ import cc.vectrum.code.net.ThreadDetail
24+ import cc.vectrum.code.net.ThreadSummary
25+ import cc.vectrum.code.net.WireJson
26+ import kotlinx.coroutines.CompletableDeferred
27+ import kotlinx.serialization.Serializable
28+ import kotlinx.serialization.builtins.ListSerializer
29+ import kotlinx.serialization.json.JsonElement
30+ import kotlinx.serialization.json.JsonObject
31+ import kotlinx.serialization.json.JsonPrimitive
32+ import java.util.UUID
33+
34+ /** A desktop this phone pairs with, as saved: its address and key. */
35+ @Serializable
36+ data class SavedHost(
37+ val id: String = UUID.randomUUID().toString(),
38+ /** The host's name, as it last said; shown until it says again. */
39+ val name: String = "",
40+ val address: String = "",
41+ val key: String = "",
42+ val enabled: Boolean = true,
43+ )
44+
45+ /** A thread anywhere: the host it lives on and its id there. */
46+ data class ThreadRef(val host: String, val thread: String)
47+
48+ /**
49+ * Everything the app knows, as Compose state: the paired hosts, each one's
50+ * projects and threads, and the threads that are open. Messages from hosts
51+ * are applied on the main thread, so the UI reads it all directly.
52+ */
53+ class AppState(context: Context) {
54+ private val store: SharedPreferences = context.getSharedPreferences("vectrum", Context.MODE_PRIVATE)
55+ val prefs = DevicePrefs(store)
56+
57+ val hosts = mutableStateListOf<HostState>()
58+
59+ /** A short message at the bottom of the screen, like the desktop's notices. */
60+ var notice by mutableStateOf<Notice?>(null)
61+ private set
62+
63+ private val deviceName = listOfNotNull(Build.MANUFACTURER?.replaceFirstChar { it.uppercase() }, Build.MODEL)
64+ .distinct()
65+ .joinToString(" ")
66+ .ifBlank { "Android" }
67+
68+ init {
69+ val saved = store.getString("hosts", null)?.let {
70+ runCatching { WireJson.decodeFromString(ListSerializer(SavedHost.serializer()), it) }.getOrNull()
71+ } ?: emptyList()
72+ saved.forEach { hosts.add(HostState(it, this)) }
73+ }
74+
75+ fun start() = hosts.filter { it.saved.enabled }.forEach { it.connect(deviceName) }
76+
77+ fun stop() = hosts.forEach { it.disconnect() }
78+
79+ /** Reconnects whatever dropped, as when the app comes back to the foreground. */
80+ fun resume() = hosts.filter { it.saved.enabled && it.status !is ConnectionStatus.Connected }.forEach { it.retry() }
81+
82+ fun host(id: String): HostState? = hosts.find { it.saved.id == id }
83+
84+ fun addHost(address: String, key: String, name: String = ""): HostState {
85+ val existing = hosts.find { it.saved.address == address }
86+ if (existing != null) {
87+ existing.update(existing.saved.copy(key = key, enabled = true))
88+ existing.retry()
89+ return existing
90+ }
91+ val host = HostState(SavedHost(name = name, address = address, key = key), this)
92+ hosts.add(host)
93+ persistHosts()
94+ host.connect(deviceName)
95+ return host
96+ }
97+
98+ fun removeHost(host: HostState) {
99+ host.disconnect()
100+ hosts.remove(host)
101+ persistHosts()
102+ }
103+
104+ fun setHostEnabled(host: HostState, enabled: Boolean) {
105+ host.update(host.saved.copy(enabled = enabled))
106+ if (enabled) host.connect(deviceName) else host.disconnect()
107+ }
108+
109+ internal fun persistHosts() {
110+ val list = hosts.map { it.saved }
111+ store.edit().putString("hosts", WireJson.encodeToString(ListSerializer(SavedHost.serializer()), list)).apply()
112+ }
113+
114+ fun thread(ref: ThreadRef): ThreadSummary? = host(ref.host)?.threads?.get(ref.thread)
115+
116+ fun showNotice(text: String, error: Boolean = false, undo: (() -> Unit)? = null) {
117+ notice = Notice(text, error, undo, System.currentTimeMillis())
118+ }
119+
120+ fun clearNotice(shown: Notice) {
121+ if (notice == shown) notice = null
122+ }
123+ }
124+
125+ data class Notice(val text: String, val error: Boolean, val undo: (() -> Unit)?, val at: Long)
126+
127+ /** One paired host: its connection and what it shares. */
128+ class HostState(saved: SavedHost, private val app: AppState) {
129+ var saved by mutableStateOf(saved)
130+ private set
131+ var status by mutableStateOf<ConnectionStatus>(ConnectionStatus.Offline("Not connected"))
132+ private set
133+
134+ /** The name the host gives itself, or the address until it does. */
135+ val name: String get() = saved.name.ifBlank { saved.address }
136+ var version by mutableStateOf("")
137+ private set
138+
139+ val projects = mutableStateListOf<ProjectInfo>()
140+ val threads = mutableStateMapOf<String, ThreadSummary>()
141+
142+ /** The host's environment settings and `project_overrides`, raw. */
143+ var settings by mutableStateOf(JsonObject(emptyMap()))
144+ private set
145+ var instances by mutableStateOf<List<RemoteInstance>>(emptyList())
146+ private set
147+ var favorites by mutableStateOf<List<String>>(emptyList())
148+ private set
149+
150+ /** Threads open in the app, with their transcripts. */
151+ val open = mutableStateMapOf<String, OpenThread>()
152+
153+ /** The host's devices, as `DeviceState` last said, for the Device panel. */
154+ var deviceState by mutableStateOf<JsonObject?>(null)
155+ private set
156+
157+ private var connection: Connection? = null
158+ private var nextRequest = 1L
159+ private val subscribed = mutableSetOf<String>()
160+ private val createdCallbacks = mutableMapOf<Long, (String) -> Unit>()
161+ private val folderRequests = mutableMapOf<String, CompletableDeferred<ServerMessage.Folders>>()
162+ private val diffRequests = mutableMapOf<Long, CompletableDeferred<ServerMessage.DiffReply>>()
163+ private val deviceRequests = mutableMapOf<Long, CompletableDeferred<ServerMessage.DeviceReply>>()
164+ private val terminals = mutableMapOf<Long, TerminalListener>()
165+
166+ /** Listeners for device events agents cause: (thread, device or `null` for all, opened). */
167+ val deviceEvents = mutableListOf<(String, JsonObject?, String?) -> Unit>()
168+
169+ val connected: Boolean get() = status is ConnectionStatus.Connected
170+
171+ internal fun update(saved: SavedHost) {
172+ this.saved = saved
173+ app.persistHosts()
174+ }
175+
176+ fun connect(deviceName: String) {
177+ connection?.stop()
178+ val connection = Connection(saved.address, saved.key, deviceName, ::onStatus, ::onMessage)
179+ this.connection = connection
180+ connection.start()
181+ }
182+
183+ fun disconnect() {
184+ connection?.stop()
185+ connection = null
186+ status = ConnectionStatus.Offline("Turned off")
187+ }
188+
189+ fun retry() {
190+ val connection = connection
191+ if (connection == null) {
192+ if (saved.enabled) connect(Build.MODEL ?: "Android")
193+ } else {
194+ connection.reconnect()
195+ }
196+ }
197+
198+ fun send(message: ClientMessage): Boolean = connection?.send(message) ?: false
199+
200+ private fun onStatus(status: ConnectionStatus) {
201+ this.status = status
202+ if (status is ConnectionStatus.Connected) {
203+ version = status.version
204+ if (saved.name != status.host) update(saved.copy(name = status.host))
205+ send(ClientMessage.Catalog)
206+ subscribed.forEach { send(ClientMessage.Subscribe(it)) }
207+ }
208+ if (status !is ConnectionStatus.Connected && status !is ConnectionStatus.Connecting) {
209+ // Whatever waits on a reply won't get one from this connection.
210+ folderRequests.values.forEach { it.complete(ServerMessage.Folders(error = "The host is offline.")) }
211+ folderRequests.clear()
212+ diffRequests.values.forEach { it.complete(ServerMessage.DiffReply(error = "The host is offline.")) }
213+ diffRequests.clear()
214+ deviceRequests.values.forEach { it.complete(ServerMessage.DeviceReply(error = "The host is offline.")) }
215+ deviceRequests.clear()
216+ terminals.values.forEach { it.onExit("The connection dropped.") }
217+ terminals.clear()
218+ }
219+ }
220+
221+ private fun onMessage(message: ServerMessage) {
222+ when (message) {
223+ is ServerMessage.State -> {
224+ projects.clear()
225+ projects.addAll(message.projects)
226+ val ids = message.threads.map { it.id }.toSet()
227+ threads.keys.retainAll(ids)
228+ message.threads.forEach { threads[it.id] = it }
229+ open.values.forEach { thread -> message.threads.find { it.id == thread.id }?.let { thread.summary = it } }
230+ }
231+ is ServerMessage.Thread -> {
232+ threads[message.summary.id] = message.summary
233+ open.getOrPut(message.summary.id) { OpenThread(this, message.summary.id) }.apply(message)
234+ }
235+ is ServerMessage.Created -> createdCallbacks.remove(message.reference)?.invoke(message.thread)
236+ is ServerMessage.Error -> app.showNotice(message.message, error = true)
237+ is ServerMessage.Settings -> settings = message.values
238+ is ServerMessage.Folders -> folderRequests.remove(message.path)?.complete(message)
239+ is ServerMessage.ProjectAdded -> app.showNotice("Added ${message.path.substringAfterLast('/')}")
240+ is ServerMessage.TerminalOutput -> terminals[message.terminal]?.onOutput(
241+ android.util.Base64.decode(message.data, android.util.Base64.DEFAULT),
242+ )
243+ is ServerMessage.TerminalExited -> terminals.remove(message.terminal)?.onExit(message.error)
244+ is ServerMessage.DeviceReply -> deviceRequests.remove(message.request)?.complete(message)
245+ is ServerMessage.DeviceState -> deviceState = message.state
246+ is ServerMessage.DeviceOpened -> deviceEvents.toList().forEach { it(message.thread, message.device, null) }
247+ is ServerMessage.DeviceClosed -> deviceEvents.toList().forEach { it(message.thread, null, message.device ?: "") }
248+ is ServerMessage.Catalog -> {
249+ instances = message.instances
250+ favorites = message.favorites
251+ }
252+ is ServerMessage.DiffReply -> diffRequests.remove(message.request)?.complete(message)
253+ is ServerMessage.Welcome, is ServerMessage.Denied -> {}
254+ }
255+ }
256+
257+ // ---- threads ---------------------------------------------------------------
258+
259+ /** Opens a thread: its transcript comes and stays current until [close]. */
260+ fun open(id: String): OpenThread {
261+ val thread = open.getOrPut(id) { OpenThread(this, id) }
262+ threads[id]?.let { thread.summary = it }
263+ if (subscribed.add(id)) send(ClientMessage.Subscribe(id))
264+ return thread
265+ }
266+
267+ fun close(id: String) {
268+ if (subscribed.remove(id)) send(ClientMessage.Unsubscribe(id))
269+ }
270+
271+ fun act(thread: String, action: ThreadAction) {
272+ if (!send(ClientMessage.Action(thread, action))) app.showNotice("$name is offline.", error = true)
273+ }
274+
275+ /** Starts a thread with its first message; [onCreated] gets its id once the host made it. */
276+ fun create(message: ClientMessage.Create, onCreated: (String) -> Unit) {
277+ val reference = nextRequest++
278+ createdCallbacks[reference] = onCreated
279+ if (!send(message.copy(reference = reference))) {
280+ createdCallbacks.remove(reference)
281+ app.showNotice("$name is offline.", error = true)
282+ }
283+ }
284+
285+ fun updateSettings(patch: SettingsPatch) {
286+ // Shown at once; the host sends the settings back as they ended up.
287+ settings = JsonObject(settings + patch.values)
288+ if (!send(ClientMessage.UpdateSettings(patch))) app.showNotice("$name is offline.", error = true)
289+ }
290+
291+ fun setting(key: String): JsonElement? = settings[key]
292+
293+ fun refreshCatalog() = send(ClientMessage.Catalog)
294+
295+ fun model(instance: String, id: String?): RemoteModel? {
296+ val models = instances.find { it.id == instance }?.models ?: return null
297+ return models.find { it.id == id } ?: models.find { it.resolved == id }
298+ }
299+
300+ // ---- requests --------------------------------------------------------------
301+
302+ suspend fun listFolders(path: String): ServerMessage.Folders {
303+ val reply = CompletableDeferred<ServerMessage.Folders>()
304+ folderRequests[path]?.complete(ServerMessage.Folders(path = path, error = "Asked again."))
305+ folderRequests[path] = reply
306+ if (!send(ClientMessage.ListFolders(path))) {
307+ folderRequests.remove(path)
308+ return ServerMessage.Folders(path = path, error = "$name is offline.")
309+ }
310+ return reply.await()
311+ }
312+
313+ suspend fun diff(thread: String, base: String, head: String, path: String?, ignoreWhitespace: Boolean): ServerMessage.DiffReply {
314+ val request = nextRequest++
315+ val reply = CompletableDeferred<ServerMessage.DiffReply>()
316+ diffRequests[request] = reply
317+ if (!send(ClientMessage.Diff(request, thread, base, head, path, ignoreWhitespace))) {
318+ diffRequests.remove(request)
319+ return ServerMessage.DiffReply(request, error = "$name is offline.")
320+ }
321+ return reply.await()
322+ }
323+
324+ /** Sends a `DeviceCall` (an object tagged by `call`) and waits for its reply. */
325+ suspend fun device(call: JsonObject): ServerMessage.DeviceReply {
326+ val request = nextRequest++
327+ val reply = CompletableDeferred<ServerMessage.DeviceReply>()
328+ deviceRequests[request] = reply
329+ if (!send(ClientMessage.Device(request, call))) {
330+ deviceRequests.remove(request)
331+ return ServerMessage.DeviceReply(request, error = "$name is offline.")
332+ }
333+ return reply.await()
334+ }
335+
336+ fun deviceCall(name: String, vararg fields: Pair<String, JsonElement>): JsonObject =
337+ JsonObject(mapOf("call" to JsonPrimitive(name)) + fields)
338+
339+ // ---- terminals -------------------------------------------------------------
340+
341+ /** Starts a shell on the host in a thread's folder; returns its id, or `null` while offline. */
342+ fun openTerminal(thread: String?, project: String, columns: Int, lines: Int, listener: TerminalListener): Long? {
343+ val terminal = nextRequest++
344+ terminals[terminal] = listener
345+ if (!send(ClientMessage.TerminalOpen(terminal, thread, project, columns, lines))) {
346+ terminals.remove(terminal)
347+ return null
348+ }
349+ return terminal
350+ }
351+
352+ fun terminalInput(terminal: Long, bytes: ByteArray) {
353+ send(ClientMessage.TerminalInput(terminal, android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)))
354+ }
355+
356+ fun resizeTerminal(terminal: Long, columns: Int, lines: Int) = send(ClientMessage.TerminalResize(terminal, columns, lines))
357+
358+ fun closeTerminal(terminal: Long) {
359+ terminals.remove(terminal)
360+ send(ClientMessage.TerminalClose(terminal))
361+ }
362+ }
363+
364+ interface TerminalListener {
365+ fun onOutput(bytes: ByteArray)
366+
367+ fun onExit(error: String?)
368+ }
369+
370+ /** A thread that's open, with its transcript kept current by the host. */
371+ class OpenThread(val host: HostState, val id: String) {
372+ var summary by mutableStateOf(host.threads[id] ?: ThreadSummary(id = id))
373+ internal set
374+ var detail by mutableStateOf(ThreadDetail())
375+ private set
376+ val items = mutableStateListOf<SavedItem>()
377+
378+ /** The host sent the transcript at least once. */
379+ var loaded by mutableStateOf(false)
380+ private set
381+
382+ val ref get() = ThreadRef(host.saved.id, id)
383+
384+ internal fun apply(message: ServerMessage.Thread) {
385+ summary = message.summary
386+ detail = message.detail
387+ val from = message.from.coerceAtMost(items.size)
388+ // Items before `from` are as last sent; the rest are replaced.
389+ if (from < items.size) items.removeRange(from, items.size)
390+ items.addAll(message.items)
391+ if (items.size > message.len) items.removeRange(message.len, items.size)
392+ loaded = true
393+ }
394+
395+ fun act(action: ThreadAction) = host.act(id, action)
396+
397+ val working: Boolean get() = summary.status == "working" || detail.thinking
398+
399+ /** The project this thread runs in, if the host lists it. */
400+ val project: ProjectInfo? get() = host.projects.find { it.path == summary.project }
401+ }
402+
403+ /** The Folders reply's subfolders, for pickers. */
404+ typealias Folders = List<Folder>
405+
406+ /** Patches with one value. */
407+ fun patchOf(key: String, value: JsonElement) = SettingsPatch(values = JsonObject(mapOf(key to value)))
408+
409+ /** One diff, as fetched. */
410+ typealias FileDiffs = List<RemoteFileDiff>
+87−0android/app/src/main/java/cc/vectrum/code/data/DevicePrefs.ktaddedView File
@@ -0,0 +1,87 @@
1+ package cc.vectrum.code.data
2+
3+ import android.content.SharedPreferences
4+ import androidx.compose.runtime.getValue
5+ import androidx.compose.runtime.mutableStateOf
6+ import androidx.compose.runtime.setValue
7+ import cc.vectrum.code.net.WireJson
8+ import kotlinx.serialization.json.JsonElement
9+ import kotlinx.serialization.json.JsonObject
10+ import kotlinx.serialization.json.JsonPrimitive
11+ import kotlinx.serialization.json.booleanOrNull
12+ import kotlinx.serialization.json.jsonObject
13+ import kotlinx.serialization.json.jsonPrimitive
14+
15+ /**
16+ * This phone's own preferences: what the desktop calls device preferences
17+ * (appearance, conversation, confirmations), under the desktop's setting
18+ * keys and with its defaults. Environment settings belong to the host and
19+ * come over the connection instead.
20+ */
21+ class DevicePrefs(private val store: SharedPreferences) {
22+ private var values by mutableStateOf(load())
23+
24+ private fun load(): JsonObject = store.getString("prefs", null)
25+ ?.let { runCatching { WireJson.parseToJsonElement(it).jsonObject }.getOrNull() }
26+ ?: JsonObject(emptyMap())
27+
28+ operator fun get(key: String): JsonElement? = values[key]
29+
30+ fun string(key: String): String = values[key]?.jsonPrimitive?.content ?: DEFAULTS.getValue(key).toString()
31+
32+ fun bool(key: String): Boolean = values[key]?.jsonPrimitive?.booleanOrNull ?: (DEFAULTS[key] as? Boolean ?: false)
33+
34+ fun set(key: String, value: String) = put(key, JsonPrimitive(value))
35+
36+ fun set(key: String, value: Boolean) = put(key, JsonPrimitive(value))
37+
38+ /** Whether a preference differs from its default, for the settings' reset buttons. */
39+ fun changed(key: String): Boolean = key in values && values[key]?.jsonPrimitive?.content != DEFAULTS[key].toString()
40+
41+ fun reset(key: String) {
42+ values = JsonObject(values - key)
43+ save()
44+ }
45+
46+ private fun put(key: String, value: JsonElement) {
47+ values = JsonObject(values + (key to value))
48+ save()
49+ }
50+
51+ private fun save() = store.edit().putString("prefs", values.toString()).apply()
52+
53+ // The ones read everywhere, by name.
54+ /** `system`, `light` or `dark`. */
55+ val themeMode get() = string("theme_mode")
56+ val lightTheme get() = string("light_theme")
57+ val darkTheme get() = string("dark_theme")
58+
59+ companion object {
60+ /** The desktop's defaults (src/settings.rs), as JSON scalars. */
61+ val DEFAULTS: Map<String, Any> = mapOf(
62+ "theme_mode" to "system",
63+ "light_theme" to "bone",
64+ "dark_theme" to "graphite",
65+ "diff_colors" to "red_green",
66+ "time_format" to "system",
67+ "fold_turns" to true,
68+ "show_costs" to true,
69+ "follow_up" to "steer",
70+ "streaming" to "token",
71+ "slash_skills" to true,
72+ "inline_ui" to true,
73+ "hide_whitespace" to false,
74+ "diff_collapsed" to false,
75+ "diff_side_by_side" to false,
76+ "composer_context" to false,
77+ "plan_mode" to false,
78+ "context_meter" to false,
79+ "confirm_unpin" to false,
80+ "confirm_archive" to true,
81+ "confirm_delete" to true,
82+ "thread_notifications" to "notifications",
83+ "working_section" to true,
84+ "show_settled" to true,
85+ )
86+ }
87+ }
+138−0android/app/src/main/java/cc/vectrum/code/net/Connection.ktaddedView File
@@ -0,0 +1,138 @@
1+ package cc.vectrum.code.net
2+
3+ import android.os.Handler
4+ import android.os.Looper
5+ import okhttp3.OkHttpClient
6+ import okhttp3.Request
7+ import okhttp3.Response
8+ import okhttp3.WebSocket
9+ import okhttp3.WebSocketListener
10+ import java.util.concurrent.TimeUnit
11+
12+ /** Where a connection to a host stands. */
13+ sealed interface ConnectionStatus {
14+ data object Connecting : ConnectionStatus
15+
16+ data class Connected(val host: String, val version: String) : ConnectionStatus
17+
18+ /** The host turned us away (a wrong key, another protocol); no retrying. */
19+ data class Denied(val reason: String) : ConnectionStatus
20+
21+ /** Lost or never made; retried after a while. */
22+ data class Offline(val reason: String) : ConnectionStatus
23+ }
24+
25+ /**
26+ * One WebSocket to a host, kept up: it says hello with the key, hands every
27+ * message to [onMessage] on the main thread, and reconnects with a backoff
28+ * when the connection drops.
29+ */
30+ class Connection(
31+ private val address: String,
32+ private val key: String,
33+ private val deviceName: String,
34+ private val onStatus: (ConnectionStatus) -> Unit,
35+ private val onMessage: (ServerMessage) -> Unit,
36+ ) {
37+ private val main = Handler(Looper.getMainLooper())
38+ private var socket: WebSocket? = null
39+ private var welcomed = false
40+ private var stopped = false
41+ private var attempt = 0
42+ private var generation = 0
43+
44+ fun start() {
45+ stopped = false
46+ open()
47+ }
48+
49+ fun stop() {
50+ stopped = true
51+ generation++
52+ main.removeCallbacksAndMessages(null)
53+ socket?.close(1000, "bye")
54+ socket = null
55+ }
56+
57+ /** Drops the connection and dials again at once, as after the network comes back. */
58+ fun reconnect() {
59+ if (stopped) return
60+ generation++
61+ main.removeCallbacksAndMessages(null)
62+ socket?.cancel()
63+ socket = null
64+ attempt = 0
65+ open()
66+ }
67+
68+ /** Sends once welcomed; messages before that are dropped like the desktop drops them. */
69+ fun send(message: ClientMessage): Boolean {
70+ val socket = socket ?: return false
71+ if (!welcomed) return false
72+ return socket.send(encode(message))
73+ }
74+
75+ val connected: Boolean get() = welcomed && socket != null
76+
77+ private fun open() {
78+ val gen = ++generation
79+ welcomed = false
80+ onStatus(ConnectionStatus.Connecting)
81+ val request = Request.Builder().url("ws://${withPort(address)}/").build()
82+ socket = client.newWebSocket(
83+ request,
84+ object : WebSocketListener() {
85+ override fun onOpen(webSocket: WebSocket, response: Response) {
86+ webSocket.send(encode(ClientMessage.Hello(key = key, name = deviceName)))
87+ }
88+
89+ override fun onMessage(webSocket: WebSocket, text: String) {
90+ val message = parseServerMessage(text) ?: return
91+ main.post {
92+ if (gen != generation) return@post
93+ when (message) {
94+ is ServerMessage.Welcome -> {
95+ welcomed = true
96+ attempt = 0
97+ onStatus(ConnectionStatus.Connected(message.host, message.version))
98+ }
99+ is ServerMessage.Denied -> {
100+ stopped = true
101+ onStatus(ConnectionStatus.Denied(message.reason))
102+ }
103+ else -> onMessage(message)
104+ }
105+ }
106+ }
107+
108+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) =
109+ dropped(gen, reason.ifBlank { "The host closed the connection." })
110+
111+ override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) =
112+ dropped(gen, t.message ?: t.javaClass.simpleName)
113+ },
114+ )
115+ }
116+
117+ private fun dropped(gen: Int, reason: String) {
118+ main.post {
119+ if (gen != generation || stopped) return@post
120+ welcomed = false
121+ socket = null
122+ onStatus(ConnectionStatus.Offline(reason))
123+ val delay = RETRY_DELAYS[attempt.coerceAtMost(RETRY_DELAYS.lastIndex)]
124+ attempt++
125+ main.postDelayed({ if (gen == generation && !stopped) open() }, delay)
126+ }
127+ }
128+
129+ companion object {
130+ private val RETRY_DELAYS = longArrayOf(1_000, 2_000, 4_000, 8_000, 15_000)
131+
132+ private val client = OkHttpClient.Builder()
133+ .connectTimeout(8, TimeUnit.SECONDS)
134+ .readTimeout(0, TimeUnit.SECONDS)
135+ .pingInterval(0, TimeUnit.SECONDS)
136+ .build()
137+ }
138+ }
+542−0android/app/src/main/java/cc/vectrum/code/net/Protocol.ktaddedView File
@@ -0,0 +1,542 @@
1+ @file:OptIn(ExperimentalSerializationApi::class)
2+
3+ package cc.vectrum.code.net
4+
5+ import kotlinx.serialization.ExperimentalSerializationApi
6+ import kotlinx.serialization.SerialName
7+ import kotlinx.serialization.Serializable
8+ import kotlinx.serialization.json.Json
9+ import kotlinx.serialization.json.JsonArray
10+ import kotlinx.serialization.json.JsonClassDiscriminator
11+ import kotlinx.serialization.json.JsonElement
12+ import kotlinx.serialization.json.JsonNamingStrategy
13+ import kotlinx.serialization.json.JsonNull
14+ import kotlinx.serialization.json.JsonObject
15+ import kotlinx.serialization.json.decodeFromJsonElement
16+ import kotlinx.serialization.json.jsonObject
17+ import kotlinx.serialization.json.jsonPrimitive
18+ import kotlinx.serialization.json.longOrNull
19+
20+ // The desktop's remote protocol (src/remote/mod.rs): JSON over WebSocket text
21+ // messages, enums tagged by `type`, fields in snake_case. A phone is a client
22+ // like another desktop: it says hello with the host's key, gets the projects
23+ // and threads, subscribes to the threads it opens and acts on them.
24+
25+ /** The port a host listens on unless told otherwise. */
26+ const val DEFAULT_PORT = 47823
27+
28+ /** Bumped by the desktop when the messages change incompatibly. */
29+ const val PROTOCOL = 1
30+
31+ val WireJson = Json {
32+ ignoreUnknownKeys = true
33+ explicitNulls = false
34+ encodeDefaults = true
35+ coerceInputValues = true
36+ namingStrategy = JsonNamingStrategy.SnakeCase
37+ classDiscriminator = "type"
38+ }
39+
40+ /** A thread as the sidebar needs it. Times are milliseconds since the epoch. */
41+ @Serializable
42+ data class ThreadSummary(
43+ val id: String = "",
44+ val project: String = "",
45+ val title: String = "",
46+ val provider: String = "claude",
47+ val instance: String = "",
48+ val model: String? = null,
49+ val permissionMode: String = "default",
50+ val reasoning: String = "default",
51+ val fastMode: Boolean = false,
52+ /** `starting`, `idle`, `working` or `exited`. */
53+ val status: String = "idle",
54+ /** A tool call or question waits for the user. */
55+ val attention: Boolean = false,
56+ val settled: Boolean = false,
57+ val pinned: Boolean = false,
58+ val snoozedUntil: Long? = null,
59+ val created: Long = 0,
60+ val updated: Long = 0,
61+ val lastMessage: Long = 0,
62+ val branch: String? = null,
63+ val totalCost: Double = 0.0,
64+ val hasItems: Boolean = false,
65+ /** For a subagent's thread, the thread that delegated its task. */
66+ val parent: String? = null,
67+ )
68+
69+ @Serializable
70+ data class QueuedMessage(val text: String = "", val attachments: List<String> = emptyList())
71+
72+ @Serializable
73+ data class PullRequest(val number: Long = 0, val url: String = "", val state: String = "OPEN")
74+
75+ @Serializable
76+ data class FileChange(val path: String = "", val added: Long? = null, val removed: Long? = null)
77+
78+ @Serializable
79+ data class TurnChanges(val base: String = "", val head: String = "", val files: List<FileChange> = emptyList()) {
80+ val added get() = files.sumOf { it.added ?: 0 }
81+ val removed get() = files.sumOf { it.removed ?: 0 }
82+ }
83+
84+ /** A command, monitor or subagent the host's agent left running. */
85+ @Serializable
86+ data class RemoteTask(val taskId: String = "", val description: String = "", val elapsedMs: Long = 0)
87+
88+ /** The rest of an open thread's state, sent with its transcript. */
89+ @Serializable
90+ data class ThreadDetail(
91+ val cwd: String = "",
92+ val activeModel: String? = null,
93+ val thinking: Boolean = false,
94+ val preparing: Boolean = false,
95+ val turnElapsedMs: Long? = null,
96+ val contextTokens: Long? = null,
97+ val contextWindow: Long? = null,
98+ val queue: List<QueuedMessage> = emptyList(),
99+ val limitResets: Long? = null,
100+ val resumeAt: Long? = null,
101+ val pendingWorktree: Boolean = false,
102+ val pullRequest: PullRequest? = null,
103+ /** `[turn end index, changes]` pairs. */
104+ val turnChanges: List<JsonArray> = emptyList(),
105+ val backgroundTasks: List<RemoteTask> = emptyList(),
106+ ) {
107+ /** What each finished turn changed, by its `TurnEnd` item's index. */
108+ fun changesByTurn(): Map<Int, TurnChanges> = turnChanges.mapNotNull { pair ->
109+ val ix = pair.getOrNull(0)?.jsonPrimitive?.longOrNull?.toInt() ?: return@mapNotNull null
110+ val changes = pair.getOrNull(1)?.let { WireJson.decodeFromJsonElement<TurnChanges>(it) } ?: return@mapNotNull null
111+ ix to changes
112+ }.toMap()
113+ }
114+
115+ @Serializable
116+ data class ProjectInfo(
117+ val path: String = "",
118+ val name: String = "",
119+ /** `null` outside a git repository. */
120+ val branch: String? = null,
121+ val branches: List<String> = emptyList(),
122+ val git: Boolean = false,
123+ val added: Long = 0,
124+ val removed: Long = 0,
125+ )
126+
127+ /** One agent among several a call started. */
128+ @Serializable
129+ data class SubagentMember(
130+ val id: String = "",
131+ val label: String = "",
132+ val kind: String? = null,
133+ /** `running`, `completed`, `failed` or `stopped`. */
134+ val status: String = "running",
135+ val started: Long = 0,
136+ val finished: Long? = null,
137+ val tokens: Long? = null,
138+ val toolUses: Int = 0,
139+ /** `[tool name, input]`. */
140+ val tool: JsonArray? = null,
141+ val summary: String? = null,
142+ )
143+
144+ @Serializable
145+ data class SubagentProgress(
146+ val started: Long = 0,
147+ val finished: Long? = null,
148+ val tokens: Long? = null,
149+ /** Its latest tool call: `[tool name, input]`. */
150+ val tool: JsonArray? = null,
151+ val summary: String? = null,
152+ val members: List<SubagentMember> = emptyList(),
153+ )
154+
155+ /** One entry of a transcript, as the host saves and sends it. */
156+ @Serializable
157+ sealed class SavedItem {
158+ @Serializable
159+ @SerialName("user")
160+ data class User(val text: String = "", val attachments: List<String> = emptyList(), val at: Long = 0) : SavedItem()
161+
162+ @Serializable
163+ @SerialName("assistant")
164+ data class Assistant(val text: String = "") : SavedItem()
165+
166+ @Serializable
167+ @SerialName("tool")
168+ data class Tool(
169+ val id: String = "",
170+ val name: String = "",
171+ val input: JsonElement = JsonNull,
172+ val result: String? = null,
173+ /** `done`, `running` or `error`. */
174+ val state: String = "done",
175+ /** `pending`, `allowed`, `denied` or `cancelled`, if it asked for permission. */
176+ val permission: String? = null,
177+ val suggestions: List<JsonElement> = emptyList(),
178+ val subagentToolCount: Int = 0,
179+ /** An AskUserQuestion form's answers so far, one per question. */
180+ val answers: List<String?>? = null,
181+ /** `running`, `completed`, `failed` or `stopped`, if it ran in the background. */
182+ val background: String? = null,
183+ val subagent: SubagentProgress? = null,
184+ ) : SavedItem()
185+
186+ @Serializable
187+ @SerialName("notice")
188+ data class Notice(val error: Boolean = false, val text: String = "") : SavedItem()
189+
190+ @Serializable
191+ @SerialName("task_event")
192+ data class TaskEvent(val summary: String = "", val status: String = "") : SavedItem()
193+
194+ @Serializable
195+ @SerialName("question")
196+ data class Question(
197+ val title: String = "",
198+ val message: String? = null,
199+ val options: List<String> = emptyList(),
200+ val confirm: Boolean = false,
201+ val answer: String? = null,
202+ ) : SavedItem()
203+
204+ @Serializable
205+ @SerialName("turn_end")
206+ data class TurnEnd(val durationMs: Long = 0, val cost: Double? = null, val at: Long = 0) : SavedItem()
207+ }
208+
209+ /** A file sent along with a message, base64. */
210+ @Serializable
211+ data class Upload(val name: String, val data: String)
212+
213+ /** What a client asks of a thread on the host. */
214+ @Serializable
215+ @JsonClassDiscriminator("action")
216+ sealed class ThreadAction {
217+ @Serializable @SerialName("send")
218+ data class Send(val text: String, val attachments: List<Upload> = emptyList()) : ThreadAction()
219+
220+ @Serializable @SerialName("queue")
221+ data class Queue(val text: String, val attachments: List<Upload> = emptyList()) : ThreadAction()
222+
223+ @Serializable @SerialName("steer_queued")
224+ data class SteerQueued(val ix: Int) : ThreadAction()
225+
226+ @Serializable @SerialName("remove_queued")
227+ data class RemoveQueued(val ix: Int) : ThreadAction()
228+
229+ @Serializable @SerialName("interrupt")
230+ data object Interrupt : ThreadAction()
231+
232+ /** `decision`: `allow`, `always` or `deny`. */
233+ @Serializable @SerialName("permission")
234+ data class Permission(val ix: Int, val decision: String) : ThreadAction()
235+
236+ @Serializable @SerialName("answer")
237+ data class Answer(val ix: Int, val option: Int) : ThreadAction()
238+
239+ /** Answers an AskUserQuestion form, one answer per question. */
240+ @Serializable @SerialName("ask_submit")
241+ data class AskSubmit(val ix: Int, val answers: List<String>) : ThreadAction()
242+
243+ @Serializable @SerialName("ask_skip")
244+ data class AskSkip(val ix: Int) : ThreadAction()
245+
246+ /** Sends a running command or subagent to the background. */
247+ @Serializable @SerialName("background")
248+ data class Background(val ix: Int) : ThreadAction()
249+
250+ @Serializable @SerialName("stop_task")
251+ data class StopTask(val taskId: String) : ThreadAction()
252+
253+ @Serializable @SerialName("set_model")
254+ data class SetModel(val model: String?) : ThreadAction()
255+
256+ @Serializable @SerialName("set_permission_mode")
257+ data class SetPermissionMode(val mode: String) : ThreadAction()
258+
259+ @Serializable @SerialName("set_reasoning")
260+ data class SetReasoning(val reasoning: String) : ThreadAction()
261+
262+ @Serializable @SerialName("set_fast_mode")
263+ data class SetFastMode(val on: Boolean) : ThreadAction()
264+
265+ @Serializable @SerialName("settle")
266+ data object Settle : ThreadAction()
267+
268+ @Serializable @SerialName("unsettle")
269+ data object Unsettle : ThreadAction()
270+
271+ @Serializable @SerialName("pin")
272+ data class Pin(val pinned: Boolean) : ThreadAction()
273+
274+ @Serializable @SerialName("snooze")
275+ data class Snooze(val until: Long?) : ThreadAction()
276+
277+ @Serializable @SerialName("rename")
278+ data class Rename(val title: String) : ThreadAction()
279+
280+ @Serializable @SerialName("cancel_resume")
281+ data object CancelResume : ThreadAction()
282+
283+ @Serializable @SerialName("schedule_resume")
284+ data class ScheduleResume(val at: Long) : ThreadAction()
285+
286+ @Serializable @SerialName("delete")
287+ data object Delete : ThreadAction()
288+ }
289+
290+ /** What the Add project palette picked. */
291+ @Serializable
292+ @JsonClassDiscriminator("kind")
293+ sealed class ProjectSource {
294+ @Serializable @SerialName("folder")
295+ data class Folder(val path: String) : ProjectSource()
296+
297+ @Serializable @SerialName("new")
298+ data class New(val name: String) : ProjectSource()
299+
300+ @Serializable @SerialName("url")
301+ data class Url(val url: String) : ProjectSource()
302+
303+ /** `forge`: `git_hub`, `git_lab`, `azure_dev_ops`, `bitbucket` or `forgejo`. */
304+ @Serializable @SerialName("forge")
305+ data class Forge(val forge: String, val repo: String) : ProjectSource()
306+ }
307+
308+ /** Changes to the host's environment settings or its projects' overrides. */
309+ @Serializable
310+ data class SettingsPatch(
311+ val values: JsonObject = JsonObject(emptyMap()),
312+ val projects: Map<String, JsonObject> = emptyMap(),
313+ )
314+
315+ @Serializable
316+ sealed class ClientMessage {
317+ @Serializable @SerialName("hello")
318+ data class Hello(val key: String, val name: String, val protocol: Int = PROTOCOL) : ClientMessage()
319+
320+ @Serializable @SerialName("subscribe")
321+ data class Subscribe(val thread: String) : ClientMessage()
322+
323+ @Serializable @SerialName("unsubscribe")
324+ data class Unsubscribe(val thread: String) : ClientMessage()
325+
326+ @Serializable @SerialName("action")
327+ data class Action(val thread: String, val action: ThreadAction) : ClientMessage()
328+
329+ /** Starts a thread in one of the host's projects with its first message. */
330+ @Serializable @SerialName("create")
331+ data class Create(
332+ val reference: Long,
333+ val project: String,
334+ val instance: String,
335+ val provider: String,
336+ val model: String?,
337+ val permissionMode: String,
338+ val reasoning: String,
339+ val fastMode: Boolean,
340+ val worktree: Boolean,
341+ val base: String?,
342+ val text: String,
343+ val attachments: List<Upload> = emptyList(),
344+ ) : ClientMessage()
345+
346+ @Serializable @SerialName("update_settings")
347+ data class UpdateSettings(val patch: SettingsPatch) : ClientMessage()
348+
349+ /** Lists a folder on the host, as typed: `~/Work/`. */
350+ @Serializable @SerialName("list_folders")
351+ data class ListFolders(val path: String) : ClientMessage()
352+
353+ @Serializable @SerialName("add_project")
354+ data class AddProject(val source: ProjectSource) : ClientMessage()
355+
356+ @Serializable @SerialName("terminal_open")
357+ data class TerminalOpen(val terminal: Long, val thread: String?, val project: String, val columns: Int, val lines: Int) : ClientMessage()
358+
359+ /** Typed bytes, base64. */
360+ @Serializable @SerialName("terminal_input")
361+ data class TerminalInput(val terminal: Long, val data: String) : ClientMessage()
362+
363+ @Serializable @SerialName("terminal_resize")
364+ data class TerminalResize(val terminal: Long, val columns: Int, val lines: Int) : ClientMessage()
365+
366+ @Serializable @SerialName("terminal_close")
367+ data class TerminalClose(val terminal: Long) : ClientMessage()
368+
369+ /** Something for the host's devices; `call` is a `DeviceCall` (tagged by `call`). */
370+ @Serializable @SerialName("device")
371+ data class Device(val request: Long, val call: JsonObject) : ClientMessage()
372+
373+ /** The host's agents and their models. Older hosts don't answer. */
374+ @Serializable @SerialName("catalog")
375+ data object Catalog : ClientMessage()
376+
377+ /** The diff between two of a thread's snapshots. Older hosts don't answer. */
378+ @Serializable @SerialName("diff")
379+ data class Diff(
380+ val request: Long,
381+ val thread: String,
382+ val base: String,
383+ val head: String,
384+ val path: String?,
385+ val ignoreWhitespace: Boolean,
386+ ) : ClientMessage()
387+ }
388+
389+ @Serializable
390+ data class Folder(val name: String = "", val git: Boolean = false)
391+
392+ @Serializable
393+ data class RemoteModel(
394+ val id: String = "",
395+ val resolved: String = "",
396+ val name: String = "",
397+ val detail: String = "",
398+ val legacy: Boolean = false,
399+ val efforts: List<String> = emptyList(),
400+ val defaultEffort: String? = null,
401+ val ultracode: Boolean = false,
402+ val fastMode: Boolean = false,
403+ val autoMode: Boolean = false,
404+ val contextWindow: Long? = null,
405+ val hidden: Boolean = false,
406+ )
407+
408+ @Serializable
409+ data class RemoteCommand(
410+ val name: String = "",
411+ val description: String = "",
412+ val argumentHint: String = "",
413+ val skill: Boolean = false,
414+ )
415+
416+ /** One agent instance on the host, say a second Claude account. */
417+ @Serializable
418+ data class RemoteInstance(
419+ val id: String = "",
420+ val provider: String = "claude",
421+ val name: String = "",
422+ val accent: String? = null,
423+ /** `ready`, `checking`, `missing`, `disabled` or `failed`. */
424+ val status: String = "ready",
425+ val account: String? = null,
426+ val plan: String? = null,
427+ val defaultModel: String? = null,
428+ val models: List<RemoteModel> = emptyList(),
429+ val commands: List<RemoteCommand> = emptyList(),
430+ )
431+
432+ @Serializable
433+ data class RemoteDiffLine(
434+ /** `hunk`, `context`, `added` or `removed`. */
435+ val kind: String = "context",
436+ val text: String = "",
437+ val oldLine: Int? = null,
438+ val newLine: Int? = null,
439+ )
440+
441+ @Serializable
442+ data class RemoteFileDiff(val path: String = "", val binary: Boolean = false, val lines: List<RemoteDiffLine> = emptyList())
443+
444+ @Serializable
445+ sealed class ServerMessage {
446+ @Serializable @SerialName("welcome")
447+ data class Welcome(val host: String = "", val version: String = "", val protocol: Int = 0) : ServerMessage()
448+
449+ @Serializable @SerialName("denied")
450+ data class Denied(val reason: String = "") : ServerMessage()
451+
452+ @Serializable @SerialName("state")
453+ data class State(val projects: List<ProjectInfo> = emptyList(), val threads: List<ThreadSummary> = emptyList()) : ServerMessage()
454+
455+ /** A subscribed thread changed: its transcript from item `from` on; `len` items in all. */
456+ @Serializable @SerialName("thread")
457+ data class Thread(
458+ val summary: ThreadSummary = ThreadSummary(),
459+ val detail: ThreadDetail = ThreadDetail(),
460+ val from: Int = 0,
461+ val len: Int = 0,
462+ val items: List<SavedItem> = emptyList(),
463+ ) : ServerMessage()
464+
465+ @Serializable @SerialName("created")
466+ data class Created(val reference: Long = 0, val thread: String = "") : ServerMessage()
467+
468+ @Serializable @SerialName("error")
469+ data class Error(val message: String = "") : ServerMessage()
470+
471+ /** The host's environment settings and project overrides, raw. */
472+ @Serializable @SerialName("settings")
473+ data class Settings(val values: JsonObject = JsonObject(emptyMap())) : ServerMessage()
474+
475+ @Serializable @SerialName("folders")
476+ data class Folders(
477+ val path: String = "",
478+ val folders: List<Folder> = emptyList(),
479+ val truncated: Boolean = false,
480+ val error: String? = null,
481+ ) : ServerMessage()
482+
483+ @Serializable @SerialName("project_added")
484+ data class ProjectAdded(val path: String = "") : ServerMessage()
485+
486+ @Serializable @SerialName("terminal_output")
487+ data class TerminalOutput(val terminal: Long = 0, val data: String = "") : ServerMessage()
488+
489+ @Serializable @SerialName("terminal_exited")
490+ data class TerminalExited(val terminal: Long = 0, val error: String? = null) : ServerMessage()
491+
492+ @Serializable @SerialName("device_reply")
493+ data class DeviceReply(val request: Long = 0, val ok: JsonElement? = null, val error: String? = null) : ServerMessage()
494+
495+ @Serializable @SerialName("device_state")
496+ data class DeviceState(val state: JsonObject = JsonObject(emptyMap())) : ServerMessage()
497+
498+ @Serializable @SerialName("device_opened")
499+ data class DeviceOpened(val thread: String = "", val device: JsonObject = JsonObject(emptyMap())) : ServerMessage()
500+
501+ @Serializable @SerialName("device_closed")
502+ data class DeviceClosed(val thread: String = "", val device: String? = null) : ServerMessage()
503+
504+ @Serializable @SerialName("catalog")
505+ data class Catalog(val instances: List<RemoteInstance> = emptyList(), val favorites: List<String> = emptyList()) : ServerMessage()
506+
507+ @Serializable @SerialName("diff_reply")
508+ data class DiffReply(val request: Long = 0, val files: List<RemoteFileDiff> = emptyList(), val error: String? = null) : ServerMessage()
509+ }
510+
511+ /** A message from the host, or `null` for one this app doesn't know. */
512+ fun parseServerMessage(text: String): ServerMessage? = try {
513+ WireJson.decodeFromString(ServerMessage.serializer(), text)
514+ } catch (_: Exception) {
515+ null
516+ }
517+
518+ fun encode(message: ClientMessage): String = WireJson.encodeToString(ClientMessage.serializer(), message)
519+
520+ /** A pairing code bundles a host's address and key: `192.168.2.10:47823/<key>`. */
521+ fun parsePairing(code: String): Pair<String, String?> {
522+ val trimmed = code.trim().removePrefix("ws://").removePrefix("vectrum://")
523+ val slash = trimmed.indexOf('/')
524+ if (slash < 0) return trimmed to null
525+ val key = trimmed.substring(slash + 1).trim()
526+ return trimmed.substring(0, slash) to key.ifEmpty { null }
527+ }
528+
529+ /** `host` or `host:port`, with the default port filled in. */
530+ fun withPort(address: String): String {
531+ val trimmed = address.trim()
532+ val colon = trimmed.lastIndexOf(':')
533+ val hasPort = colon > 0 && trimmed.substring(colon + 1).toIntOrNull() != null &&
534+ (!trimmed.substring(0, colon).contains(':') || trimmed.substring(0, colon).endsWith("]"))
535+ return if (hasPort) trimmed else "$trimmed:$DEFAULT_PORT"
536+ }
537+
538+ /** A tool input's string field, if it has one. */
539+ fun JsonElement.str(key: String): String? =
540+ (this as? JsonObject)?.get(key)?.let { runCatching { it.jsonPrimitive.content }.getOrNull() }
541+
542+ fun JsonElement.obj(): JsonObject? = runCatching { jsonObject }.getOrNull()
+412−0android/app/src/main/java/cc/vectrum/code/theme/Metal.ktaddedView File
@@ -0,0 +1,412 @@
1+ package cc.vectrum.code.theme
2+
3+ import androidx.compose.animation.core.RepeatMode
4+ import androidx.compose.animation.core.animateFloat
5+ import androidx.compose.animation.core.infiniteRepeatable
6+ import androidx.compose.animation.core.rememberInfiniteTransition
7+ import androidx.compose.animation.core.tween
8+ import androidx.compose.foundation.background
9+ import androidx.compose.foundation.clickable
10+ import androidx.compose.foundation.interaction.MutableInteractionSource
11+ import androidx.compose.foundation.interaction.collectIsPressedAsState
12+ import androidx.compose.foundation.layout.Arrangement
13+ import androidx.compose.foundation.layout.Box
14+ import androidx.compose.foundation.layout.Row
15+ import androidx.compose.foundation.layout.RowScope
16+ import androidx.compose.foundation.layout.Spacer
17+ import androidx.compose.foundation.layout.defaultMinSize
18+ import androidx.compose.foundation.layout.fillMaxWidth
19+ import androidx.compose.foundation.layout.height
20+ import androidx.compose.foundation.layout.padding
21+ import androidx.compose.foundation.layout.size
22+ import androidx.compose.foundation.layout.width
23+ import androidx.compose.foundation.shape.CircleShape
24+ import androidx.compose.foundation.shape.RoundedCornerShape
25+ import androidx.compose.foundation.text.BasicText
26+ import androidx.compose.runtime.Composable
27+ import androidx.compose.runtime.CompositionLocalProvider
28+ import androidx.compose.runtime.getValue
29+ import androidx.compose.runtime.remember
30+ import androidx.compose.runtime.staticCompositionLocalOf
31+ import androidx.compose.ui.Alignment
32+ import androidx.compose.ui.Modifier
33+ import androidx.compose.ui.draw.alpha
34+ import androidx.compose.ui.draw.drawBehind
35+ import androidx.compose.ui.draw.dropShadow
36+ import androidx.compose.ui.draw.innerShadow
37+ import androidx.compose.ui.geometry.Offset
38+ import androidx.compose.ui.graphics.Brush
39+ import androidx.compose.ui.graphics.Color
40+ import androidx.compose.ui.graphics.Shape
41+ import androidx.compose.ui.graphics.shadow.Shadow
42+ import androidx.compose.ui.semantics.Role
43+ import androidx.compose.ui.text.TextStyle
44+ import androidx.compose.ui.text.font.Font
45+ import androidx.compose.ui.text.font.FontFamily
46+ import androidx.compose.ui.text.font.FontWeight
47+ import androidx.compose.ui.text.style.TextOverflow
48+ import androidx.compose.ui.unit.Dp
49+ import androidx.compose.ui.unit.DpOffset
50+ import androidx.compose.ui.unit.TextUnit
51+ import androidx.compose.ui.unit.dp
52+ import androidx.compose.ui.unit.sp
53+ import cc.vectrum.code.R
54+ import cc.vectrum.code.ui.Lucide
55+ import cc.vectrum.code.ui.LucideIcon
56+
57+ // MetalUI, the "soft hardware" design system the desktop app is built on:
58+ // raised caps over inset wells, Geist type and engraved mono labels. The
59+ // sizes here are the desktop's, in dp for its px.
60+
61+ val Sans = FontFamily(
62+ Font(R.font.geist_regular, FontWeight.Normal),
63+ Font(R.font.geist_medium, FontWeight.Medium),
64+ Font(R.font.geist_semibold, FontWeight.SemiBold),
65+ Font(R.font.geist_bold, FontWeight.Bold),
66+ )
67+ val Mono = FontFamily(
68+ Font(R.font.geistmono_regular, FontWeight.Normal),
69+ Font(R.font.geistmono_medium, FontWeight.Medium),
70+ )
71+
72+ /** A dot-matrix face for numeric readouts, MetalUI's "pixel" voice. */
73+ val Pixel = FontFamily(Font(R.font.geistpixel_circle, FontWeight.Normal))
74+
75+ val LocalPalette = staticCompositionLocalOf<Palette> { error("No palette") }
76+
77+ /** The palette of the active theme. */
78+ val palette: Palette
79+ @Composable get() = LocalPalette.current
80+
81+ @Composable
82+ fun ProvidePalette(palette: Palette, content: @Composable () -> Unit) {
83+ CompositionLocalProvider(LocalPalette provides palette, content = content)
84+ }
85+
86+ // ---- surfaces ------------------------------------------------------------------
87+
88+ /**
89+ * A surface in MetalUI's recipe: its drop shadows, the fill, then its inset
90+ * shadows. CSS paints a list's first layer on top, so layers go on in
91+ * reverse.
92+ */
93+ fun Modifier.metal(shape: Shape, fill: Brush?, layers: List<ShadowLayer>): Modifier {
94+ var modifier = this
95+ for (layer in layers.asReversed()) {
96+ if (!layer.inset) modifier = modifier.dropShadow(shape, layer.toShadow())
97+ }
98+ if (fill != null) modifier = modifier.background(fill, shape)
99+ for (layer in layers.asReversed()) {
100+ if (layer.inset) modifier = modifier.innerShadow(shape, layer.toShadow())
101+ }
102+ return modifier
103+ }
104+
105+ fun Modifier.metal(shape: Shape, fill: Color, layers: List<ShadowLayer>): Modifier =
106+ metal(shape, Brush.verticalGradient(listOf(fill, fill)), layers)
107+
108+ /** CSS's blur radius is twice the Gaussian's sigma; Android's blur radius is a bit over 1.7 sigma. */
109+ private fun ShadowLayer.toShadow() = Shadow(
110+ radius = (blur * 0.87f).dp,
111+ color = color,
112+ spread = spread.dp,
113+ offset = DpOffset(x.dp, y.dp),
114+ )
115+
116+ /** A large raised sheet, like vectrum.cc's page plate. */
117+ fun Modifier.plate(p: Palette, shape: Shape = RoundedCornerShape(18.dp)) =
118+ metal(shape, Duo(p.sHi, p.s).brush, p.raiseLite)
119+
120+ /** A small raised object sitting on a surface: cards, bubbles, badges. */
121+ fun Modifier.raised(p: Palette, shape: Shape) = metal(shape, p.rowOn.brush, p.raiseSm)
122+
123+ /** A pressed-in field. */
124+ fun Modifier.well(p: Palette, shape: Shape) = metal(shape, Duo(p.wellTop, p.wellBot).brush, p.well)
125+
126+ /** A gently recessed area of a sheet. */
127+ fun Modifier.region(p: Palette, shape: Shape) = metal(shape, p.region.brush, p.regionSh)
128+
129+ /** A popup menu's body. */
130+ fun Modifier.menu(p: Palette, shape: Shape = RoundedCornerShape(16.dp)) = metal(shape, p.menuBg, p.raise)
131+
132+ // ---- text ----------------------------------------------------------------------
133+
134+ @Composable
135+ fun Txt(
136+ text: String,
137+ modifier: Modifier = Modifier,
138+ color: Color = palette.ink,
139+ size: TextUnit = 13.sp,
140+ weight: FontWeight = FontWeight.Normal,
141+ family: FontFamily = Sans,
142+ lineHeight: TextUnit = TextUnit.Unspecified,
143+ maxLines: Int = Int.MAX_VALUE,
144+ overflow: TextOverflow = TextOverflow.Ellipsis,
145+ style: TextStyle = TextStyle.Default,
146+ ) {
147+ BasicText(
148+ text = text,
149+ modifier = modifier,
150+ style = style.merge(
151+ TextStyle(color = color, fontSize = size, fontWeight = weight, fontFamily = family, lineHeight = lineHeight),
152+ ),
153+ maxLines = maxLines,
154+ overflow = if (maxLines == Int.MAX_VALUE) TextOverflow.Clip else overflow,
155+ )
156+ }
157+
158+ /** Mono, uppercase, engraved: MetalUI's small label voice. */
159+ @Composable
160+ fun Engraved(text: String, modifier: Modifier = Modifier, color: Color = palette.engrave) {
161+ Txt(text.uppercase(), modifier, color = color, size = 9.5.sp, family = Mono, lineHeight = 12.sp, maxLines = 1)
162+ }
163+
164+ /** An engraved tag with a hairline ring, like vectrum.cc's `PUBLIC`. */
165+ @Composable
166+ fun Tag(text: String, modifier: Modifier = Modifier) {
167+ val p = palette
168+ val shape = RoundedCornerShape(5.dp)
169+ Box(
170+ modifier
171+ .height(16.dp)
172+ .innerShadow(shape, Shadow(radius = 0.dp, color = p.tagRing, spread = 0.5.dp))
173+ .padding(horizontal = 6.dp),
174+ contentAlignment = Alignment.Center,
175+ ) { Engraved(text) }
176+ }
177+
178+ /** A small tinted badge with a project's initials, like T3 Code's. */
179+ @Composable
180+ fun Badge(initials: String, tint: Int, modifier: Modifier = Modifier) {
181+ val p = palette
182+ val color = p.tints[Math.floorMod(tint, p.tints.size)]
183+ Box(
184+ modifier
185+ .height(16.dp)
186+ .defaultMinSize(minWidth = 20.dp)
187+ .background(color.copy(alpha = color.alpha * if (p.dark) 0.2f else 0.13f), RoundedCornerShape(5.dp))
188+ .padding(horizontal = 3.dp),
189+ contentAlignment = Alignment.Center,
190+ ) {
191+ Txt(initials, color = color, size = 9.sp, weight = FontWeight.Bold, family = Mono, maxLines = 1)
192+ }
193+ }
194+
195+ /** A keycap: `Ctrl`, `N`, `Esc`. */
196+ @Composable
197+ fun Keycap(text: String, modifier: Modifier = Modifier) {
198+ val p = palette
199+ val shape = RoundedCornerShape(7.dp)
200+ Box(
201+ modifier
202+ .height(18.dp)
203+ .defaultMinSize(minWidth = 19.dp)
204+ .metal(shape, p.kbd.brush, p.raiseSm)
205+ .padding(horizontal = 5.dp),
206+ contentAlignment = Alignment.Center,
207+ ) {
208+ Txt(text, color = p.ink2, size = 10.sp, weight = FontWeight.Medium, family = Mono, maxLines = 1)
209+ }
210+ }
211+
212+ /** A hairline rule with the light lip MetalUI casts below it. */
213+ @Composable
214+ fun Rule(modifier: Modifier = Modifier) {
215+ val p = palette
216+ Spacer(
217+ modifier
218+ .fillMaxWidth()
219+ .height(1.dp)
220+ .drawBehind {
221+ drawRect(p.ruleLip, topLeft = Offset(0f, size.height), size = size)
222+ drawRect(p.rule)
223+ },
224+ )
225+ }
226+
227+ /** A vertical hairline, for separating toolbar groups. */
228+ @Composable
229+ fun VRule(modifier: Modifier = Modifier, height: Dp = 16.dp) {
230+ Spacer(modifier.width(1.dp).height(height).background(palette.rule))
231+ }
232+
233+ // ---- controls ------------------------------------------------------------------
234+
235+ /** A switch: a sunk track with a raised knob, lit green when on. */
236+ @Composable
237+ fun Toggle(on: Boolean, modifier: Modifier = Modifier, enabled: Boolean = true, onChange: (Boolean) -> Unit) {
238+ val p = palette
239+ Row(
240+ modifier
241+ .width(38.dp)
242+ .height(22.dp)
243+ .then(
244+ if (on) Modifier.metal(CircleShape, Duo(p.greenInk, p.greenDeep).brush, p.pressedSh)
245+ else Modifier.well(p, CircleShape),
246+ )
247+ .clickable(enabled = enabled, role = Role.Switch, interactionSource = null, indication = null) { onChange(!on) }
248+ .padding(3.dp)
249+ .alpha(if (enabled) 1f else 0.4f),
250+ horizontalArrangement = if (on) Arrangement.End else Arrangement.Start,
251+ verticalAlignment = Alignment.CenterVertically,
252+ ) {
253+ Box(Modifier.size(16.dp).metal(CircleShape, p.cap.brush, p.raiseSm))
254+ }
255+ }
256+
257+ /** A segmented switch: options as caps in one well, the current one raised. */
258+ @Composable
259+ fun <T> Segmented(options: List<Pair<T, String>>, current: T, modifier: Modifier = Modifier, onPick: (T) -> Unit) {
260+ val p = palette
261+ Row(
262+ modifier.well(p, CircleShape).padding(3.dp),
263+ horizontalArrangement = Arrangement.spacedBy(2.dp),
264+ ) {
265+ for ((value, label) in options) {
266+ val selected = value == current
267+ Box(
268+ Modifier
269+ .height(24.dp)
270+ .then(if (selected) Modifier.metal(CircleShape, p.cap.brush, p.btn) else Modifier)
271+ .clickable(interactionSource = null, indication = null) { onPick(value) }
272+ .padding(horizontal = 12.dp),
273+ contentAlignment = Alignment.Center,
274+ ) {
275+ Txt(label, color = if (selected) p.ink else p.ink3, size = 12.5.sp, weight = FontWeight.Medium, maxLines = 1)
276+ }
277+ }
278+ }
279+ }
280+
281+ enum class Led { Live, Waiting, Failed, Off }
282+
283+ /** A small domed status light. `pulse` breathes it, for work in progress. */
284+ @Composable
285+ fun StatusLed(kind: Led, modifier: Modifier = Modifier, pulse: Boolean = false, size: Dp = 6.dp) {
286+ val p = palette
287+ val (top, bottom, glow) = when (kind) {
288+ Led.Live -> Triple(Color(0xffd9ffe9), Color(0xff2fb673), Color(0x8c7beaae))
289+ Led.Waiting -> Triple(Color(0xfffff1cf), Color(0xffc98a18), Color(0x8cf5bf55))
290+ Led.Failed -> Triple(Color(0xffffd9d2), Color(0xffd5392a), Color(0x8cff7a66))
291+ Led.Off -> Triple(p.ledOff.top, p.ledOff.bottom, null)
292+ }
293+ val alpha = if (pulse) {
294+ val transition = rememberInfiniteTransition(label = "led")
295+ val value by transition.animateFloat(
296+ initialValue = 1f,
297+ targetValue = 0.35f,
298+ animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
299+ label = "pulse",
300+ )
301+ value
302+ } else 1f
303+ val fill = Brush.linearGradient(
304+ 0f to top,
305+ 0.75f to bottom,
306+ start = Offset(0f, 0f),
307+ end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY),
308+ )
309+ val layers = if (glow != null) {
310+ listOf(ShadowLayer(0f, 0f, 0f, 0.5f, false, Color(0x40000000)), ShadowLayer(0f, 0f, 3f, 0f, false, glow))
311+ } else p.ledOffSh
312+ Box(modifier.size(size).alpha(alpha).metal(CircleShape, fill, layers))
313+ }
314+
315+ enum class CapKind {
316+ /** The everyday raised button. */
317+ Standard,
318+
319+ /** The one action that matters: an inverted cap. */
320+ Primary,
321+ Danger,
322+
323+ /** No cap until it's pressed. */
324+ Ghost,
325+ }
326+
327+ enum class CapSize(val height: Dp, val pad: Dp, val font: TextUnit, val glyph: Dp) {
328+ /** 32px, MetalUI's default button. */
329+ Regular(32.dp, 15.dp, 13.sp, 16.dp),
330+
331+ /** 26px, for toolbars. */
332+ Compact(26.dp, 11.dp, 12.sp, 14.dp),
333+
334+ /** 22px, for icon buttons tucked into rows. */
335+ Mini(22.dp, 8.dp, 12.sp, 13.dp),
336+ }
337+
338+ /** A MetalUI button: a pill-shaped cap that sinks into a well when pressed. */
339+ @Composable
340+ fun Cap(
341+ modifier: Modifier = Modifier,
342+ label: String? = null,
343+ icon: Lucide? = null,
344+ kind: CapKind = CapKind.Standard,
345+ size: CapSize = CapSize.Regular,
346+ iconColor: Color? = null,
347+ caret: Boolean = false,
348+ selected: Boolean = false,
349+ enabled: Boolean = true,
350+ stretch: Boolean = false,
351+ leading: (@Composable RowScope.() -> Unit)? = null,
352+ onClick: () -> Unit,
353+ ) {
354+ val p = palette
355+ val interaction = remember { MutableInteractionSource() }
356+ val pressed by interaction.collectIsPressedAsState()
357+ val ghost = kind == CapKind.Ghost
358+ val (fill, pressedFill, shadow, pressedShadow, ink) = when (kind) {
359+ CapKind.Standard, CapKind.Ghost -> CapLook(p.cap, p.pressed, p.btn, p.pressedSh, p.ink)
360+ CapKind.Primary -> CapLook(p.primary, p.primaryPressed, p.primarySh, p.primaryPressedSh, p.primaryInk)
361+ CapKind.Danger -> CapLook(p.danger, p.dangerPressed, p.dangerSh, p.dangerPressedSh, Color.White)
362+ }
363+ val down = enabled && pressed
364+ val surface = when {
365+ selected -> Modifier.metal(CircleShape, p.pressed.brush, p.pressedSh)
366+ down && ghost -> Modifier.background(p.rowHover, CircleShape)
367+ down -> Modifier.metal(CircleShape, pressedFill.brush, pressedShadow)
368+ ghost -> Modifier
369+ else -> Modifier.metal(CircleShape, fill.brush, shadow)
370+ }
371+ val text = when {
372+ selected -> p.ink
373+ ghost && !down -> p.ink2
374+ ghost -> p.ink
375+ else -> ink
376+ }
377+ val iconOnly = label == null && leading == null
378+ Row(
379+ modifier
380+ .height(size.height)
381+ .then(if (iconOnly) Modifier.width(size.height) else Modifier)
382+ .then(surface)
383+ .clickable(enabled = enabled, interactionSource = interaction, indication = null, role = Role.Button, onClick = onClick)
384+ .then(if (iconOnly) Modifier else Modifier.padding(horizontal = size.pad))
385+ .alpha(if (enabled) 1f else 0.4f),
386+ horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
387+ verticalAlignment = Alignment.CenterVertically,
388+ ) {
389+ if (icon != null) LucideIcon(icon, size = size.glyph, tint = iconColor ?: text)
390+ leading?.invoke(this)
391+ if (label != null) {
392+ Txt(
393+ label,
394+ modifier = if (stretch) Modifier.weight(1f) else Modifier,
395+ color = text,
396+ size = size.font,
397+ weight = FontWeight.Medium,
398+ lineHeight = 16.sp,
399+ maxLines = 1,
400+ )
401+ }
402+ if (caret) LucideIcon(Lucide.ChevronDown, size = 12.dp, tint = if (ghost) p.ink3 else ink.copy(alpha = 0.6f))
403+ }
404+ }
405+
406+ private data class CapLook(
407+ val fill: Duo,
408+ val pressedFill: Duo,
409+ val shadow: List<ShadowLayer>,
410+ val pressedShadow: List<ShadowLayer>,
411+ val ink: Color,
412+ )
+230−0android/app/src/main/java/cc/vectrum/code/theme/Palette.ktaddedView File
@@ -0,0 +1,230 @@
1+ package cc.vectrum.code.theme
2+
3+ import android.content.Context
4+ import androidx.compose.runtime.Immutable
5+ import androidx.compose.ui.graphics.Brush
6+ import androidx.compose.ui.graphics.Color
7+ import kotlinx.serialization.json.Json
8+ import kotlinx.serialization.json.JsonArray
9+ import kotlinx.serialization.json.JsonObject
10+ import kotlinx.serialization.json.boolean
11+ import kotlinx.serialization.json.float
12+ import kotlinx.serialization.json.jsonArray
13+ import kotlinx.serialization.json.jsonObject
14+ import kotlinx.serialization.json.jsonPrimitive
15+
16+ /** One layer of a CSS `box-shadow`, as MetalUI writes them. */
17+ @Immutable
18+ data class ShadowLayer(
19+ val x: Float,
20+ val y: Float,
21+ val blur: Float,
22+ val spread: Float,
23+ val inset: Boolean,
24+ val color: Color,
25+ )
26+
27+ /** A top-to-bottom pair of colors, CSS's `linear-gradient(top, bottom)`. */
28+ @Immutable
29+ data class Duo(val top: Color, val bottom: Color) {
30+ val brush: Brush get() = Brush.verticalGradient(listOf(top, bottom))
31+ }
32+
33+ /**
34+ * One MetalUI colorway, field for field the desktop's `metal::Palette`. The
35+ * values aren't derived here: `assets/themes.json` is exported from the
36+ * desktop app (`cargo test export_android_themes -- --ignored`), so every
37+ * theme matches it exactly.
38+ */
39+ @Immutable
40+ class Palette(
41+ val dark: Boolean,
42+ /** The table everything sits on. */
43+ val page: Color,
44+ val sHi: Color,
45+ val s: Color,
46+ val sLo: Color,
47+ val wellTop: Color,
48+ val wellBot: Color,
49+ val ink: Color,
50+ val ink2: Color,
51+ val ink3: Color,
52+ val icon: Color,
53+ /** Engraved mono labels. */
54+ val engrave: Color,
55+ /** The hairline ring around an engraved tag. */
56+ val tagRing: Color,
57+ val rule: Color,
58+ /** The highlight a hairline rule casts below itself. */
59+ val ruleLip: Color,
60+ val rowHover: Color,
61+ val menuBg: Color,
62+ val menuRowHover: Color,
63+ val fieldInk: Color,
64+ val cap: Duo,
65+ val pressed: Duo,
66+ val rowOn: Duo,
67+ val kbd: Duo,
68+ val primary: Duo,
69+ val primaryPressed: Duo,
70+ val primaryInk: Color,
71+ val danger: Duo,
72+ val dangerPressed: Duo,
73+ val region: Duo,
74+ val greenDeep: Color,
75+ /** Green that is legible as text on this colorway's surfaces. */
76+ val greenInk: Color,
77+ val red: Color,
78+ val amber: Color,
79+ val amberInk: Color,
80+ val blueInk: Color,
81+ val ledOff: Duo,
82+ /** MetalUI's accent tints: ember, blush, tide, spark, dusk, iris. */
83+ val tints: List<Color>,
84+ val raise: List<ShadowLayer>,
85+ val raiseLite: List<ShadowLayer>,
86+ val raiseSm: List<ShadowLayer>,
87+ val btn: List<ShadowLayer>,
88+ val pressedSh: List<ShadowLayer>,
89+ val well: List<ShadowLayer>,
90+ val regionSh: List<ShadowLayer>,
91+ val primarySh: List<ShadowLayer>,
92+ val primaryPressedSh: List<ShadowLayer>,
93+ val dangerSh: List<ShadowLayer>,
94+ val dangerPressedSh: List<ShadowLayer>,
95+ val ledOffSh: List<ShadowLayer>,
96+ )
97+
98+ /** A theme on the Appearance page. */
99+ @Immutable
100+ class Theme(val id: String, val name: String, val palette: Palette)
101+
102+ /** Themes from one color scheme, shown as one card with a swatch per variant. */
103+ @Immutable
104+ class Family(val name: String, val themes: List<Theme>)
105+
106+ /** Every theme the desktop app has, loaded once from `assets/themes.json`. */
107+ object Themes {
108+ lateinit var all: List<Theme>
109+ private set
110+ lateinit var families: List<Family>
111+ private set
112+
113+ fun load(context: Context) {
114+ if (::all.isInitialized) return
115+ val text = context.assets.open("themes.json").bufferedReader().use { it.readText() }
116+ val root = Json.parseToJsonElement(text).jsonObject
117+ all = root["themes"]!!.jsonArray.map { element ->
118+ val theme = element.jsonObject
119+ Theme(
120+ id = theme.string("id"),
121+ name = theme.string("name"),
122+ palette = parsePalette(theme["palette"]!!.jsonObject),
123+ )
124+ }
125+ families = root["families"]!!.jsonArray.map { element ->
126+ val family = element.jsonObject
127+ Family(
128+ name = family.string("name"),
129+ themes = family["themes"]!!.jsonArray.mapNotNull { id -> all.find { it.id == id.jsonPrimitive.content } },
130+ )
131+ }
132+ }
133+
134+ /** The palette of the theme with this id, if it's of the right kind, or Graphite or Bone. */
135+ fun find(id: String, dark: Boolean): Palette {
136+ val match = all.find { it.id == id && it.palette.dark == dark }
137+ return match?.palette ?: all.first { it.id == if (dark) "graphite" else "bone" }.palette
138+ }
139+
140+ fun theme(id: String): Theme? = all.find { it.id == id }
141+ }
142+
143+ private fun JsonObject.string(key: String) = this[key]!!.jsonPrimitive.content
144+
145+ /** `#rrggbbaa`, as the export writes every color. */
146+ fun parseHex(text: String): Color {
147+ val digits = text.removePrefix("#")
148+ val expanded = when (digits.length) {
149+ 3, 4 -> digits.map { "$it$it" }.joinToString("")
150+ else -> digits
151+ }
152+ val value = expanded.toLong(16)
153+ return if (expanded.length == 6) {
154+ Color(0xff000000 or value)
155+ } else {
156+ val rgb = value ushr 8
157+ val alpha = value and 0xff
158+ Color((alpha shl 24) or rgb)
159+ }
160+ }
161+
162+ private fun parsePalette(json: JsonObject): Palette {
163+ fun color(key: String) = parseHex(json[key]!!.jsonPrimitive.content)
164+ fun duo(key: String): Duo {
165+ val pair = json[key]!!.jsonArray
166+ return Duo(parseHex(pair[0].jsonPrimitive.content), parseHex(pair[1].jsonPrimitive.content))
167+ }
168+ fun shadows(key: String): List<ShadowLayer> = (json[key] as JsonArray).map { element ->
169+ val layer = element.jsonObject
170+ ShadowLayer(
171+ x = layer["x"]!!.jsonPrimitive.float,
172+ y = layer["y"]!!.jsonPrimitive.float,
173+ blur = layer["blur"]!!.jsonPrimitive.float,
174+ spread = layer["spread"]!!.jsonPrimitive.float,
175+ inset = layer["inset"]!!.jsonPrimitive.boolean,
176+ color = parseHex(layer["color"]!!.jsonPrimitive.content),
177+ )
178+ }
179+ return Palette(
180+ dark = json["dark"]!!.jsonPrimitive.boolean,
181+ page = color("page"),
182+ sHi = color("s_hi"),
183+ s = color("s"),
184+ sLo = color("s_lo"),
185+ wellTop = color("well_top"),
186+ wellBot = color("well_bot"),
187+ ink = color("ink"),
188+ ink2 = color("ink2"),
189+ ink3 = color("ink3"),
190+ icon = color("icon"),
191+ engrave = color("engrave"),
192+ tagRing = color("tag_ring"),
193+ rule = color("rule"),
194+ ruleLip = color("rule_lip"),
195+ rowHover = color("row_hover"),
196+ menuBg = color("menu_bg"),
197+ menuRowHover = color("menu_row_hover"),
198+ fieldInk = color("field_ink"),
199+ cap = duo("cap"),
200+ pressed = duo("pressed"),
201+ rowOn = duo("row_on"),
202+ kbd = duo("kbd"),
203+ primary = duo("primary"),
204+ primaryPressed = duo("primary_pressed"),
205+ primaryInk = color("primary_ink"),
206+ danger = duo("danger"),
207+ dangerPressed = duo("danger_pressed"),
208+ region = duo("region"),
209+ greenDeep = color("green_deep"),
210+ greenInk = color("green_ink"),
211+ red = color("red"),
212+ amber = color("amber"),
213+ amberInk = color("amber_ink"),
214+ blueInk = color("blue_ink"),
215+ ledOff = duo("led_off"),
216+ tints = json["tints"]!!.jsonArray.map { parseHex(it.jsonPrimitive.content) },
217+ raise = shadows("raise"),
218+ raiseLite = shadows("raise_lite"),
219+ raiseSm = shadows("raise_sm"),
220+ btn = shadows("btn"),
221+ pressedSh = shadows("pressed_sh"),
222+ well = shadows("well"),
223+ regionSh = shadows("region_sh"),
224+ primarySh = shadows("primary_sh"),
225+ primaryPressedSh = shadows("primary_pressed_sh"),
226+ dangerSh = shadows("danger_sh"),
227+ dangerPressedSh = shadows("danger_pressed_sh"),
228+ ledOffSh = shadows("led_off_sh"),
229+ )
230+ }
+171−0android/app/src/main/java/cc/vectrum/code/ui/Icons.ktaddedView File
@@ -0,0 +1,171 @@
1+ package cc.vectrum.code.ui
2+
3+ import androidx.compose.foundation.layout.size
4+ import androidx.compose.runtime.Composable
5+ import androidx.compose.ui.Modifier
6+ import androidx.compose.ui.graphics.Color
7+ import androidx.compose.ui.graphics.ColorFilter
8+ import androidx.compose.ui.res.painterResource
9+ import androidx.compose.ui.unit.Dp
10+ import androidx.compose.ui.unit.dp
11+ import androidx.compose.foundation.Image
12+ import cc.vectrum.code.R
13+ import cc.vectrum.code.theme.palette
14+
15+ /** The Lucide icons the app uses, as the desktop draws them. Add one with scripts/lucide.py. */
16+ enum class Lucide(val res: Int) {
17+ AlarmClock(R.drawable.lucide_alarm_clock),
18+ AlarmClockOff(R.drawable.lucide_alarm_clock_off),
19+ Archive(R.drawable.lucide_archive),
20+ ArchiveRestore(R.drawable.lucide_archive_restore),
21+ ArchiveX(R.drawable.lucide_archive_x),
22+ ArrowDown(R.drawable.lucide_arrow_down),
23+ ArrowDownToLine(R.drawable.lucide_arrow_down_to_line),
24+ ArrowLeft(R.drawable.lucide_arrow_left),
25+ ArrowRight(R.drawable.lucide_arrow_right),
26+ ArrowUp(R.drawable.lucide_arrow_up),
27+ Asterisk(R.drawable.lucide_asterisk),
28+ AtSign(R.drawable.lucide_at_sign),
29+ Bell(R.drawable.lucide_bell),
30+ Blocks(R.drawable.lucide_blocks),
31+ BookOpen(R.drawable.lucide_book_open),
32+ Bot(R.drawable.lucide_bot),
33+ Brain(R.drawable.lucide_brain),
34+ Bug(R.drawable.lucide_bug),
35+ Camera(R.drawable.lucide_camera),
36+ ChartNoAxesColumn(R.drawable.lucide_chart_no_axes_column),
37+ Check(R.drawable.lucide_check),
38+ ChevronDown(R.drawable.lucide_chevron_down),
39+ ChevronLeft(R.drawable.lucide_chevron_left),
40+ ChevronRight(R.drawable.lucide_chevron_right),
41+ ChevronUp(R.drawable.lucide_chevron_up),
42+ ChevronsDownUp(R.drawable.lucide_chevrons_down_up),
43+ ChevronsUpDown(R.drawable.lucide_chevrons_up_down),
44+ Circle(R.drawable.lucide_circle),
45+ CircleAlert(R.drawable.lucide_circle_alert),
46+ CircleCheck(R.drawable.lucide_circle_check),
47+ CircleDot(R.drawable.lucide_circle_dot),
48+ CircleStop(R.drawable.lucide_circle_stop),
49+ CircleX(R.drawable.lucide_circle_x),
50+ Clock(R.drawable.lucide_clock),
51+ Code(R.drawable.lucide_code),
52+ Contrast(R.drawable.lucide_contrast),
53+ Copy(R.drawable.lucide_copy),
54+ CornerDownRight(R.drawable.lucide_corner_down_right),
55+ Cpu(R.drawable.lucide_cpu),
56+ DollarSign(R.drawable.lucide_dollar_sign),
57+ Ellipsis(R.drawable.lucide_ellipsis),
58+ EllipsisVertical(R.drawable.lucide_ellipsis_vertical),
59+ ExternalLink(R.drawable.lucide_external_link),
60+ Eye(R.drawable.lucide_eye),
61+ EyeOff(R.drawable.lucide_eye_off),
62+ Feather(R.drawable.lucide_feather),
63+ File(R.drawable.lucide_file),
64+ FileCode(R.drawable.lucide_file_code),
65+ FileDiff(R.drawable.lucide_file_diff),
66+ FilePlus(R.drawable.lucide_file_plus),
67+ FileText(R.drawable.lucide_file_text),
68+ Folder(R.drawable.lucide_folder),
69+ FolderGit2(R.drawable.lucide_folder_git_2),
70+ FolderOpen(R.drawable.lucide_folder_open),
71+ FolderPlus(R.drawable.lucide_folder_plus),
72+ Gauge(R.drawable.lucide_gauge),
73+ GitBranch(R.drawable.lucide_git_branch),
74+ GitCommitHorizontal(R.drawable.lucide_git_commit_horizontal),
75+ GitFork(R.drawable.lucide_git_fork),
76+ GitPullRequest(R.drawable.lucide_git_pull_request),
77+ Github(R.drawable.lucide_github),
78+ Globe(R.drawable.lucide_globe),
79+ Hammer(R.drawable.lucide_hammer),
80+ HardDrive(R.drawable.lucide_hard_drive),
81+ Hash(R.drawable.lucide_hash),
82+ House(R.drawable.lucide_house),
83+ Image(R.drawable.lucide_image),
84+ Inbox(R.drawable.lucide_inbox),
85+ Info(R.drawable.lucide_info),
86+ Keyboard(R.drawable.lucide_keyboard),
87+ Laptop(R.drawable.lucide_laptop),
88+ Layers(R.drawable.lucide_layers),
89+ Link2(R.drawable.lucide_link_2),
90+ List(R.drawable.lucide_list),
91+ ListChecks(R.drawable.lucide_list_checks),
92+ ListOrdered(R.drawable.lucide_list_ordered),
93+ ListTodo(R.drawable.lucide_list_todo),
94+ LoaderCircle(R.drawable.lucide_loader_circle),
95+ Lock(R.drawable.lucide_lock),
96+ LockOpen(R.drawable.lucide_lock_open),
97+ Mail(R.drawable.lucide_mail),
98+ MapPin(R.drawable.lucide_map_pin),
99+ MemoryStick(R.drawable.lucide_memory_stick),
100+ MessageCircleQuestionMark(R.drawable.lucide_message_circle_question_mark),
101+ MessageSquare(R.drawable.lucide_message_square),
102+ Mic(R.drawable.lucide_mic),
103+ Minus(R.drawable.lucide_minus),
104+ MonitorSmartphone(R.drawable.lucide_monitor_smartphone),
105+ Moon(R.drawable.lucide_moon),
106+ MoonStar(R.drawable.lucide_moon_star),
107+ MoveLeft(R.drawable.lucide_move_left),
108+ MoveRight(R.drawable.lucide_move_right),
109+ Navigation(R.drawable.lucide_navigation),
110+ Network(R.drawable.lucide_network),
111+ Palette(R.drawable.lucide_palette),
112+ PanelLeftClose(R.drawable.lucide_panel_left_close),
113+ PanelLeftOpen(R.drawable.lucide_panel_left_open),
114+ PanelRightClose(R.drawable.lucide_panel_right_close),
115+ PanelRightOpen(R.drawable.lucide_panel_right_open),
116+ Paperclip(R.drawable.lucide_paperclip),
117+ Pencil(R.drawable.lucide_pencil),
118+ Pi(R.drawable.lucide_pi),
119+ Pin(R.drawable.lucide_pin),
120+ PinOff(R.drawable.lucide_pin_off),
121+ Play(R.drawable.lucide_play),
122+ Plug(R.drawable.lucide_plug),
123+ PlugZap(R.drawable.lucide_plug_zap),
124+ Plus(R.drawable.lucide_plus),
125+ Power(R.drawable.lucide_power),
126+ QrCode(R.drawable.lucide_qr_code),
127+ RefreshCw(R.drawable.lucide_refresh_cw),
128+ RotateCcw(R.drawable.lucide_rotate_ccw),
129+ RotateCw(R.drawable.lucide_rotate_cw),
130+ ScanLine(R.drawable.lucide_scan_line),
131+ Search(R.drawable.lucide_search),
132+ SendHorizontal(R.drawable.lucide_send_horizontal),
133+ Server(R.drawable.lucide_server),
134+ Settings(R.drawable.lucide_settings),
135+ Settings2(R.drawable.lucide_settings_2),
136+ Shield(R.drawable.lucide_shield),
137+ ShieldCheck(R.drawable.lucide_shield_check),
138+ SlidersHorizontal(R.drawable.lucide_sliders_horizontal),
139+ Smartphone(R.drawable.lucide_smartphone),
140+ Sparkles(R.drawable.lucide_sparkles),
141+ Square(R.drawable.lucide_square),
142+ SquareDashed(R.drawable.lucide_square_dashed),
143+ SquarePen(R.drawable.lucide_square_pen),
144+ SquareStop(R.drawable.lucide_square_stop),
145+ SquareTerminal(R.drawable.lucide_square_terminal),
146+ Sun(R.drawable.lucide_sun),
147+ SunMoon(R.drawable.lucide_sun_moon),
148+ Terminal(R.drawable.lucide_terminal),
149+ Trash(R.drawable.lucide_trash),
150+ TriangleAlert(R.drawable.lucide_triangle_alert),
151+ Type(R.drawable.lucide_type),
152+ Undo2(R.drawable.lucide_undo_2),
153+ Users(R.drawable.lucide_users),
154+ WandSparkles(R.drawable.lucide_wand_sparkles),
155+ Wifi(R.drawable.lucide_wifi),
156+ WifiOff(R.drawable.lucide_wifi_off),
157+ Workflow(R.drawable.lucide_workflow),
158+ Wrench(R.drawable.lucide_wrench),
159+ X(R.drawable.lucide_x),
160+ Zap(R.drawable.lucide_zap);
161+ }
162+
163+ @Composable
164+ fun LucideIcon(icon: Lucide, modifier: Modifier = Modifier, size: Dp = 16.dp, tint: Color = palette.icon) {
165+ Image(
166+ painter = painterResource(icon.res),
167+ contentDescription = null,
168+ modifier = modifier.size(size),
169+ colorFilter = ColorFilter.tint(tint),
170+ )
171+ }
+84−0android/app/src/main/java/cc/vectrum/code/ui/Nav.ktaddedView File
@@ -0,0 +1,84 @@
1+ package cc.vectrum.code.ui
2+
3+ import androidx.compose.runtime.getValue
4+ import androidx.compose.runtime.mutableStateListOf
5+ import androidx.compose.runtime.mutableStateOf
6+ import androidx.compose.runtime.setValue
7+ import cc.vectrum.code.data.ThreadRef
8+
9+ /** What the sheet shows. */
10+ sealed interface Screen {
11+ /** Nothing open: the start screen of a new thread, or pairing while there's no host. */
12+ data object Home : Screen
13+
14+ data class Thread(val ref: ThreadRef) : Screen
15+
16+ /** A new thread's start screen, on a host and in a project if picked. */
17+ data class NewThread(val host: String? = null, val project: String? = null) : Screen
18+
19+ /** A settings page: `general`, `appearance`, `connections`, `providers`, `archive`, `about`... */
20+ data class Settings(val page: String = "general") : Screen
21+
22+ /** Pairing with a desktop: a pairing code typed or scanned. */
23+ data object Pair : Screen
24+
25+ /** A turn's diff, or one file of it. */
26+ data class Diff(val ref: ThreadRef, val base: String, val head: String, val path: String? = null) : Screen
27+ }
28+
29+ /** The surfaces the right panel can show next to a thread, as the desktop's "Open a surface". */
30+ enum class Surface { Subagents, Terminal, Device }
31+
32+ /** Where the app is: the sheet's screen, a back stack, the sidebar drawer and the right panel. */
33+ class Nav {
34+ var screen by mutableStateOf<Screen>(Screen.Home)
35+ private set
36+ private val stack = mutableStateListOf<Screen>()
37+
38+ /** The sidebar, as a drawer over the sheet on a phone. */
39+ var sidebarOpen by mutableStateOf(false)
40+
41+ /** The right panel's surface, shown as a sheet over the thread on a phone. */
42+ var surface by mutableStateOf<Surface?>(null)
43+
44+ /** The surface picker ("Open a surface") is up. */
45+ var surfacePicker by mutableStateOf(false)
46+
47+ /** The command palette is up. */
48+ var palette by mutableStateOf(false)
49+
50+ val canGoBack: Boolean get() = stack.isNotEmpty() || sidebarOpen || surface != null || surfacePicker || palette
51+
52+ fun go(screen: Screen) {
53+ sidebarOpen = false
54+ surface = null
55+ surfacePicker = false
56+ palette = false
57+ if (screen == this.screen) return
58+ // Threads replace each other rather than piling up, like the desktop's sidebar.
59+ if (!(this.screen is Screen.Thread && screen is Screen.Thread)) stack.add(this.screen)
60+ this.screen = screen
61+ }
62+
63+ fun openThread(ref: ThreadRef) = go(Screen.Thread(ref))
64+
65+ fun home() {
66+ stack.clear()
67+ sidebarOpen = false
68+ surface = null
69+ screen = Screen.Home
70+ }
71+
72+ /** Goes back one step; `false` when there's nowhere to go. */
73+ fun back(): Boolean {
74+ when {
75+ palette -> palette = false
76+ surfacePicker -> surfacePicker = false
77+ surface != null -> surface = null
78+ sidebarOpen -> sidebarOpen = false
79+ stack.isNotEmpty() -> screen = stack.removeAt(stack.lastIndex)
80+ else -> return false
81+ }
82+ return true
83+ }
84+ }
+192−0android/app/src/main/java/cc/vectrum/code/ui/Root.ktaddedView File
@@ -0,0 +1,192 @@
1+ package cc.vectrum.code.ui
2+
3+ import androidx.activity.compose.BackHandler
4+ import androidx.compose.animation.AnimatedVisibility
5+ import androidx.compose.animation.core.animateDpAsState
6+ import androidx.compose.animation.core.animateFloatAsState
7+ import androidx.compose.animation.fadeIn
8+ import androidx.compose.animation.fadeOut
9+ import androidx.compose.animation.slideInVertically
10+ import androidx.compose.animation.slideOutVertically
11+ import androidx.compose.foundation.background
12+ import androidx.compose.foundation.clickable
13+ import androidx.compose.foundation.gestures.detectHorizontalDragGestures
14+ import androidx.compose.foundation.layout.Arrangement
15+ import androidx.compose.foundation.layout.Box
16+ import androidx.compose.foundation.layout.BoxWithConstraints
17+ import androidx.compose.foundation.layout.Row
18+ import androidx.compose.foundation.layout.WindowInsets
19+ import androidx.compose.foundation.layout.fillMaxHeight
20+ import androidx.compose.foundation.layout.fillMaxSize
21+ import androidx.compose.foundation.layout.fillMaxWidth
22+ import androidx.compose.foundation.layout.navigationBarsPadding
23+ import androidx.compose.foundation.layout.offset
24+ import androidx.compose.foundation.layout.padding
25+ import androidx.compose.foundation.layout.safeDrawing
26+ import androidx.compose.foundation.layout.width
27+ import androidx.compose.foundation.layout.windowInsetsPadding
28+ import androidx.compose.foundation.shape.RoundedCornerShape
29+ import androidx.compose.runtime.Composable
30+ import androidx.compose.runtime.LaunchedEffect
31+ import androidx.compose.runtime.getValue
32+ import androidx.compose.ui.Alignment
33+ import androidx.compose.ui.Modifier
34+ import androidx.compose.ui.draw.alpha
35+ import androidx.compose.ui.draw.clip
36+ import androidx.compose.ui.graphics.Color
37+ import androidx.compose.ui.input.pointer.pointerInput
38+ import androidx.compose.ui.unit.dp
39+ import androidx.compose.ui.unit.sp
40+ import cc.vectrum.code.data.AppState
41+ import cc.vectrum.code.theme.Cap
42+ import cc.vectrum.code.theme.CapKind
43+ import cc.vectrum.code.theme.CapSize
44+ import cc.vectrum.code.theme.Txt
45+ import cc.vectrum.code.theme.menu
46+ import cc.vectrum.code.theme.palette
47+ import cc.vectrum.code.theme.plate
48+ import cc.vectrum.code.ui.chat.ThreadScreen
49+ import cc.vectrum.code.ui.composer.NewThreadScreen
50+ import cc.vectrum.code.ui.diff.DiffScreen
51+ import cc.vectrum.code.ui.pair.PairScreen
52+ import cc.vectrum.code.ui.panels.CommandPalette
53+ import cc.vectrum.code.ui.panels.RightPanel
54+ import cc.vectrum.code.ui.settings.SettingsScreen
55+ import cc.vectrum.code.ui.sidebar.Sidebar
56+ import kotlinx.coroutines.delay
57+
58+ /** The desktop's sheet inset and corner radius. */
59+ val SheetInset = 8.dp
60+ val SheetRadius = 24.dp
61+ val SidebarWidth = 280.dp
62+
63+ /**
64+ * The window, as the desktop lays it out: the sidebar on the page, and the
65+ * conversation on a raised sheet beside it. On a phone the sidebar is a
66+ * drawer that slides over the sheet; on a tablet it sits beside it.
67+ */
68+ @Composable
69+ fun Root(app: AppState, nav: Nav) {
70+ val p = palette
71+ BackHandler(enabled = nav.canGoBack) { nav.back() }
72+ BoxWithConstraints(
73+ Modifier
74+ .fillMaxSize()
75+ .background(p.page)
76+ .windowInsetsPadding(WindowInsets.safeDrawing),
77+ ) {
78+ val wide = maxWidth >= 840.dp
79+ val drawerWidth = minOf(SidebarWidth + 24.dp, maxWidth - 48.dp)
80+ if (wide) {
81+ Row(Modifier.fillMaxSize()) {
82+ Sidebar(app, nav, Modifier.width(SidebarWidth).fillMaxHeight())
83+ Sheet(app, nav, Modifier.weight(1f).padding(top = SheetInset, end = SheetInset, bottom = SheetInset))
84+ }
85+ } else {
86+ val shift by animateDpAsState(if (nav.sidebarOpen) drawerWidth else 0.dp, label = "drawer")
87+ val scrim by animateFloatAsState(if (nav.sidebarOpen) 1f else 0f, label = "scrim")
88+ Box(
89+ Modifier
90+ .fillMaxSize()
91+ .pointerInput(Unit) {
92+ // A swipe from the left edge opens the sidebar, as on most phone apps.
93+ var start = 0f
94+ var total = 0f
95+ detectHorizontalDragGestures(
96+ onDragStart = { start = it.x; total = 0f },
97+ onHorizontalDrag = { _, amount -> total += amount },
98+ onDragEnd = {
99+ if (start < 32.dp.toPx() && total > 60.dp.toPx()) nav.sidebarOpen = true
100+ if (nav.sidebarOpen && total < -60.dp.toPx()) nav.sidebarOpen = false
101+ },
102+ )
103+ },
104+ ) {
105+ Sidebar(app, nav, Modifier.width(drawerWidth).fillMaxHeight().offset(x = shift - drawerWidth))
106+ Box(Modifier.fillMaxSize().offset(x = shift)) {
107+ Sheet(app, nav, Modifier.fillMaxSize().padding(SheetInset))
108+ if (scrim > 0f) {
109+ Box(
110+ Modifier
111+ .fillMaxSize()
112+ .alpha(scrim)
113+ .background(Color.Black.copy(alpha = if (p.dark) 0.3f else 0.08f))
114+ .clickable(interactionSource = null, indication = null) { nav.sidebarOpen = false },
115+ )
116+ }
117+ }
118+ }
119+ }
120+ if (nav.palette) CommandPalette(app, nav)
121+ NoticeBar(app, Modifier.align(Alignment.BottomCenter))
122+ }
123+ }
124+
125+ /** The raised sheet and what's on it. */
126+ @Composable
127+ private fun Sheet(app: AppState, nav: Nav, modifier: Modifier) {
128+ val p = palette
129+ val shape = RoundedCornerShape(SheetRadius)
130+ Box(modifier.plate(p, shape).clip(shape)) {
131+ when (val screen = nav.screen) {
132+ Screen.Home -> if (app.hosts.isEmpty()) PairScreen(app, nav) else NewThreadScreen(app, nav, Screen.NewThread())
133+ is Screen.Thread -> {
134+ val host = app.host(screen.ref.host)
135+ if (host == null) {
136+ LaunchedEffect(screen) { nav.home() }
137+ } else {
138+ ThreadScreen(app, nav, host, screen.ref.thread)
139+ }
140+ }
141+ is Screen.NewThread -> NewThreadScreen(app, nav, screen)
142+ is Screen.Settings -> SettingsScreen(app, nav, screen.page)
143+ Screen.Pair -> PairScreen(app, nav)
144+ is Screen.Diff -> DiffScreen(app, nav, screen)
145+ }
146+ if (nav.surface != null || nav.surfacePicker) {
147+ val screen = nav.screen
148+ if (screen is Screen.Thread) RightPanel(app, nav, screen.ref)
149+ }
150+ }
151+ }
152+
153+ /** The desktop's notices: "Settled 1 thread, Ctrl+Z to undo", errors from the host. */
154+ @Composable
155+ private fun NoticeBar(app: AppState, modifier: Modifier) {
156+ val p = palette
157+ val notice = app.notice
158+ LaunchedEffect(notice) {
159+ if (notice != null) {
160+ delay(if (notice.undo != null) 5_000 else 4_000)
161+ app.clearNotice(notice)
162+ }
163+ }
164+ AnimatedVisibility(
165+ visible = notice != null,
166+ modifier = modifier.navigationBarsPadding().padding(bottom = 96.dp, start = 16.dp, end = 16.dp),
167+ enter = fadeIn() + slideInVertically { it / 2 },
168+ exit = fadeOut() + slideOutVertically { it / 2 },
169+ ) {
170+ val shown = notice ?: return@AnimatedVisibility
171+ Row(
172+ Modifier.menu(p, RoundedCornerShape(14.dp)).padding(start = 14.dp, end = 6.dp, top = 6.dp, bottom = 6.dp),
173+ horizontalArrangement = Arrangement.spacedBy(10.dp),
174+ verticalAlignment = Alignment.CenterVertically,
175+ ) {
176+ Txt(
177+ shown.text,
178+ Modifier.weight(1f, fill = false).padding(vertical = 6.dp),
179+ color = if (shown.error) p.red else p.ink,
180+ size = 13.sp,
181+ maxLines = 3,
182+ )
183+ val undo = shown.undo
184+ if (undo != null) {
185+ Cap(label = "Undo", size = CapSize.Compact, kind = CapKind.Standard) {
186+ undo()
187+ app.clearNotice(shown)
188+ }
189+ }
190+ }
191+ }
192+ }
+19−0android/app/src/main/java/cc/vectrum/code/ui/chat/Header.ktaddedView File
@@ -0,0 +1,19 @@
1+ package cc.vectrum.code.ui.chat
2+
3+ import androidx.compose.foundation.layout.Box
4+ import androidx.compose.foundation.layout.padding
5+ import androidx.compose.runtime.Composable
6+ import androidx.compose.ui.Modifier
7+ import androidx.compose.ui.unit.dp
8+ import cc.vectrum.code.data.AppState
9+ import cc.vectrum.code.data.OpenThread
10+ import cc.vectrum.code.data.ThreadRef
11+ import cc.vectrum.code.theme.Engraved
12+ import cc.vectrum.code.ui.Nav
13+ import cc.vectrum.code.ui.Screen
14+
15+ // Placeholder until it's built.
16+ @Composable
17+ fun ChatHeader(app: AppState, nav: Nav, thread: OpenThread) {
18+ Box(Modifier.padding(16.dp)) { Engraved(thread.summary.title) }
19+ }
+34−0android/app/src/main/java/cc/vectrum/code/ui/chat/ThreadScreen.ktaddedView File
@@ -0,0 +1,34 @@
1+ package cc.vectrum.code.ui.chat
2+
3+ import androidx.compose.foundation.layout.Box
4+ import androidx.compose.foundation.layout.Column
5+ import androidx.compose.foundation.layout.fillMaxSize
6+ import androidx.compose.foundation.layout.fillMaxWidth
7+ import androidx.compose.runtime.Composable
8+ import androidx.compose.runtime.DisposableEffect
9+ import androidx.compose.ui.Modifier
10+ import cc.vectrum.code.data.AppState
11+ import cc.vectrum.code.data.HostState
12+ import cc.vectrum.code.ui.Nav
13+ import cc.vectrum.code.ui.composer.Composer
14+ import cc.vectrum.code.ui.composer.Dock
15+
16+ /**
17+ * An open thread on the sheet: the chat header, the transcript, what the
18+ * agent waits on docked over the composer, and the composer.
19+ */
20+ @Composable
21+ fun ThreadScreen(app: AppState, nav: Nav, host: HostState, id: String) {
22+ val thread = host.open(id)
23+ DisposableEffect(host, id) {
24+ onDispose { host.close(id) }
25+ }
26+ Column(Modifier.fillMaxSize()) {
27+ ChatHeader(app, nav, thread)
28+ Box(Modifier.weight(1f).fillMaxWidth()) {
29+ Timeline(app, nav, thread, Modifier.fillMaxSize())
30+ }
31+ Dock(app, nav, thread)
32+ Composer(app, nav, thread)
33+ }
34+ }
+19−0android/app/src/main/java/cc/vectrum/code/ui/chat/Timeline.ktaddedView File
@@ -0,0 +1,19 @@
1+ package cc.vectrum.code.ui.chat
2+
3+ import androidx.compose.foundation.layout.Box
4+ import androidx.compose.foundation.layout.padding
5+ import androidx.compose.runtime.Composable
6+ import androidx.compose.ui.Modifier
7+ import androidx.compose.ui.unit.dp
8+ import cc.vectrum.code.data.AppState
9+ import cc.vectrum.code.data.OpenThread
10+ import cc.vectrum.code.data.ThreadRef
11+ import cc.vectrum.code.theme.Engraved
12+ import cc.vectrum.code.ui.Nav
13+ import cc.vectrum.code.ui.Screen
14+
15+ // Placeholder until it's built.
16+ @Composable
17+ fun Timeline(app: AppState, nav: Nav, thread: OpenThread, modifier: Modifier = Modifier) {
18+ Box(modifier.padding(16.dp)) { Engraved("${thread.items.size} items") }
19+ }
+19−0android/app/src/main/java/cc/vectrum/code/ui/composer/Composer.ktaddedView File
@@ -0,0 +1,19 @@
1+ package cc.vectrum.code.ui.composer
2+
3+ import androidx.compose.foundation.layout.Box
4+ import androidx.compose.foundation.layout.padding
5+ import androidx.compose.runtime.Composable
6+ import androidx.compose.ui.Modifier
7+ import androidx.compose.ui.unit.dp
8+ import cc.vectrum.code.data.AppState
9+ import cc.vectrum.code.data.OpenThread
10+ import cc.vectrum.code.data.ThreadRef
11+ import cc.vectrum.code.theme.Engraved
12+ import cc.vectrum.code.ui.Nav
13+ import cc.vectrum.code.ui.Screen
14+
15+ // Placeholder until it's built.
16+ @Composable
17+ fun Composer(app: AppState, nav: Nav, thread: OpenThread) {
18+ Box(Modifier.padding(16.dp)) { Engraved("Composer") }
19+ }
+17−0android/app/src/main/java/cc/vectrum/code/ui/composer/Dock.ktaddedView File
@@ -0,0 +1,17 @@
1+ package cc.vectrum.code.ui.composer
2+
3+ import androidx.compose.foundation.layout.Box
4+ import androidx.compose.foundation.layout.padding
5+ import androidx.compose.runtime.Composable
6+ import androidx.compose.ui.Modifier
7+ import androidx.compose.ui.unit.dp
8+ import cc.vectrum.code.data.AppState
9+ import cc.vectrum.code.data.OpenThread
10+ import cc.vectrum.code.data.ThreadRef
11+ import cc.vectrum.code.theme.Engraved
12+ import cc.vectrum.code.ui.Nav
13+ import cc.vectrum.code.ui.Screen
14+
15+ // Placeholder until it's built.
16+ @Composable
17+ fun Dock(app: AppState, nav: Nav, thread: OpenThread) {}
+19−0android/app/src/main/java/cc/vectrum/code/ui/composer/NewThreadScreen.ktaddedView File
@@ -0,0 +1,19 @@
1+ package cc.vectrum.code.ui.composer
2+
3+ import androidx.compose.foundation.layout.Box
4+ import androidx.compose.foundation.layout.padding
5+ import androidx.compose.runtime.Composable
6+ import androidx.compose.ui.Modifier
7+ import androidx.compose.ui.unit.dp
8+ import cc.vectrum.code.data.AppState
9+ import cc.vectrum.code.data.OpenThread
10+ import cc.vectrum.code.data.ThreadRef
11+ import cc.vectrum.code.theme.Engraved
12+ import cc.vectrum.code.ui.Nav
13+ import cc.vectrum.code.ui.Screen
14+
15+ // Placeholder until it's built.
16+ @Composable
17+ fun NewThreadScreen(app: AppState, nav: Nav, screen: Screen.NewThread) {
18+ Box(Modifier.padding(16.dp)) { Engraved("New thread") }
19+ }
+19−0android/app/src/main/java/cc/vectrum/code/ui/diff/DiffScreen.ktaddedView File
@@ -0,0 +1,19 @@
1+ package cc.vectrum.code.ui.diff
2+
3+ import androidx.compose.foundation.layout.Box
4+ import androidx.compose.foundation.layout.padding
5+ import androidx.compose.runtime.Composable
6+ import androidx.compose.ui.Modifier
7+ import androidx.compose.ui.unit.dp
8+ import cc.vectrum.code.data.AppState
9+ import cc.vectrum.code.data.OpenThread
10+ import cc.vectrum.code.data.ThreadRef
11+ import cc.vectrum.code.theme.Engraved
12+ import cc.vectrum.code.ui.Nav
13+ import cc.vectrum.code.ui.Screen
14+
15+ // Placeholder until it's built.
16+ @Composable
17+ fun DiffScreen(app: AppState, nav: Nav, screen: Screen.Diff) {
18+ Box(Modifier.padding(16.dp)) { Engraved("Diff") }
19+ }
+19−0android/app/src/main/java/cc/vectrum/code/ui/pair/PairScreen.ktaddedView File
@@ -0,0 +1,19 @@
1+ package cc.vectrum.code.ui.pair
2+
3+ import androidx.compose.foundation.layout.Box
4+ import androidx.compose.foundation.layout.padding
5+ import androidx.compose.runtime.Composable
6+ import androidx.compose.ui.Modifier
7+ import androidx.compose.ui.unit.dp
8+ import cc.vectrum.code.data.AppState
9+ import cc.vectrum.code.data.OpenThread
10+ import cc.vectrum.code.data.ThreadRef
11+ import cc.vectrum.code.theme.Engraved
12+ import cc.vectrum.code.ui.Nav
13+ import cc.vectrum.code.ui.Screen
14+
15+ // Placeholder until it's built.
16+ @Composable
17+ fun PairScreen(app: AppState, nav: Nav) {
18+ Box(Modifier.padding(16.dp)) { Engraved("Pair") }
19+ }
+17−0android/app/src/main/java/cc/vectrum/code/ui/panels/CommandPalette.ktaddedView File
@@ -0,0 +1,17 @@
1+ package cc.vectrum.code.ui.panels
2+
3+ import androidx.compose.foundation.layout.Box
4+ import androidx.compose.foundation.layout.padding
5+ import androidx.compose.runtime.Composable
6+ import androidx.compose.ui.Modifier
7+ import androidx.compose.ui.unit.dp
8+ import cc.vectrum.code.data.AppState
9+ import cc.vectrum.code.data.OpenThread
10+ import cc.vectrum.code.data.ThreadRef
11+ import cc.vectrum.code.theme.Engraved
12+ import cc.vectrum.code.ui.Nav
13+ import cc.vectrum.code.ui.Screen
14+
15+ // Placeholder until it's built.
16+ @Composable
17+ fun CommandPalette(app: AppState, nav: Nav) {}
+17−0android/app/src/main/java/cc/vectrum/code/ui/panels/RightPanel.ktaddedView File
@@ -0,0 +1,17 @@
1+ package cc.vectrum.code.ui.panels
2+
3+ import androidx.compose.foundation.layout.Box
4+ import androidx.compose.foundation.layout.padding
5+ import androidx.compose.runtime.Composable
6+ import androidx.compose.ui.Modifier
7+ import androidx.compose.ui.unit.dp
8+ import cc.vectrum.code.data.AppState
9+ import cc.vectrum.code.data.OpenThread
10+ import cc.vectrum.code.data.ThreadRef
11+ import cc.vectrum.code.theme.Engraved
12+ import cc.vectrum.code.ui.Nav
13+ import cc.vectrum.code.ui.Screen
14+
15+ // Placeholder until it's built.
16+ @Composable
17+ fun RightPanel(app: AppState, nav: Nav, ref: ThreadRef) {}
+19−0android/app/src/main/java/cc/vectrum/code/ui/settings/SettingsScreen.ktaddedView File
@@ -0,0 +1,19 @@
1+ package cc.vectrum.code.ui.settings
2+
3+ import androidx.compose.foundation.layout.Box
4+ import androidx.compose.foundation.layout.padding
5+ import androidx.compose.runtime.Composable
6+ import androidx.compose.ui.Modifier
7+ import androidx.compose.ui.unit.dp
8+ import cc.vectrum.code.data.AppState
9+ import cc.vectrum.code.data.OpenThread
10+ import cc.vectrum.code.data.ThreadRef
11+ import cc.vectrum.code.theme.Engraved
12+ import cc.vectrum.code.ui.Nav
13+ import cc.vectrum.code.ui.Screen
14+
15+ // Placeholder until it's built.
16+ @Composable
17+ fun SettingsScreen(app: AppState, nav: Nav, page: String) {
18+ Box(Modifier.padding(16.dp)) { Engraved("Settings") }
19+ }
+19−0android/app/src/main/java/cc/vectrum/code/ui/sidebar/Sidebar.ktaddedView File
@@ -0,0 +1,19 @@
1+ package cc.vectrum.code.ui.sidebar
2+
3+ import androidx.compose.foundation.layout.Box
4+ import androidx.compose.foundation.layout.padding
5+ import androidx.compose.runtime.Composable
6+ import androidx.compose.ui.Modifier
7+ import androidx.compose.ui.unit.dp
8+ import cc.vectrum.code.data.AppState
9+ import cc.vectrum.code.data.OpenThread
10+ import cc.vectrum.code.data.ThreadRef
11+ import cc.vectrum.code.theme.Engraved
12+ import cc.vectrum.code.ui.Nav
13+ import cc.vectrum.code.ui.Screen
14+
15+ // Placeholder until it's built.
16+ @Composable
17+ fun Sidebar(app: AppState, nav: Nav, modifier: Modifier = Modifier) {
18+ Box(modifier.padding(16.dp)) { Engraved("Sidebar") }
19+ }
+48−0android/app/src/main/java/cc/vectrum/code/util/Names.ktaddedView File
@@ -0,0 +1,48 @@
1+ package cc.vectrum.code.util
2+
3+ /** A project's badge letters, as the desktop picks them: `vectrum.git` → `VG`, `codey` → `CO`. */
4+ fun initials(name: String): String {
5+ val words = name.split(Regex("[^\\p{L}\\p{N}]+")).filter { it.isNotEmpty() }
6+ val letters = when {
7+ words.size >= 2 && words[1].first().isLetter() -> "${words[0].first()}${words[1].first()}"
8+ words.isNotEmpty() -> words[0].take(2)
9+ else -> "?"
10+ }
11+ return letters.uppercase()
12+ }
13+
14+ /**
15+ * The tint a project's badge takes. The desktop hashes the name with Rust's
16+ * `DefaultHasher` (SipHash-1-3 with zero keys, `str` hashed as its bytes and a
17+ * 0xff) and takes it modulo the six tints, so this does the same.
18+ */
19+ fun tintFor(name: String): Int = java.lang.Long.remainderUnsigned(sipHash13(name.toByteArray() + 0xff.toByte()), 6).toInt()
20+
21+ private fun sipHash13(data: ByteArray): Long {
22+ var v0 = 0x736f6d6570736575L
23+ var v1 = 0x646f72616e646f6dL
24+ var v2 = 0x6c7967656e657261L
25+ var v3 = 0x7465646279746573L
26+ fun round() {
27+ v0 += v1; v1 = java.lang.Long.rotateLeft(v1, 13); v1 = v1 xor v0; v0 = java.lang.Long.rotateLeft(v0, 32)
28+ v2 += v3; v3 = java.lang.Long.rotateLeft(v3, 16); v3 = v3 xor v2
29+ v0 += v3; v3 = java.lang.Long.rotateLeft(v3, 21); v3 = v3 xor v0
30+ v2 += v1; v1 = java.lang.Long.rotateLeft(v1, 17); v1 = v1 xor v2; v2 = java.lang.Long.rotateLeft(v2, 32)
31+ }
32+ val whole = data.size / 8 * 8
33+ for (offset in 0 until whole step 8) {
34+ var m = 0L
35+ for (ix in 7 downTo 0) m = (m shl 8) or (data[offset + ix].toLong() and 0xff)
36+ v3 = v3 xor m
37+ round()
38+ v0 = v0 xor m
39+ }
40+ var last = (data.size.toLong() and 0xff) shl 56
41+ for (ix in data.size - 1 downTo whole) last = last or ((data[ix].toLong() and 0xff) shl (8 * (ix - whole)))
42+ v3 = v3 xor last
43+ round()
44+ v0 = v0 xor last
45+ v2 = v2 xor 0xff
46+ repeat(3) { round() }
47+ return v0 xor v1 xor v2 xor v3
48+ }
+9−0android/app/src/main/res/drawable/ic_launcher_foreground.xmladdedView File
@@ -0,0 +1,9 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="108dp" android:height="108dp"
4+ android:viewportWidth="108" android:viewportHeight="108">
5+ <group android:translateX="23.6" android:translateY="23.6" android:scaleX="1.9" android:scaleY="1.9">
6+ <path android:fillColor="#FFDCDCDC" android:fillType="evenOdd"
7+ android:pathData="M2 13a8.5 8.5 0 1 0 17 0a8.5 8.5 0 1 0-17 0ZM11.54 7.69Q10 4 14 4H26Q30 4 28.46 7.69L21.54 24.31Q20 28 18.46 24.31Z" />
8+ </group>
9+ </vector>
+11−0android/app/src/main/res/drawable/lucide_alarm_clock.xmladdedView File
@@ -0,0 +1,11 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M4,13a8,8 0 1 0 16,0a8,8 0 1 0 -16,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M12 9v4l2 2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="M5 3 2 6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ <path android:pathData="m22 6-3-3" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
9+ <path android:pathData="M6.38 18.7 4 21" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
10+ <path android:pathData="M17.64 18.67 20 21" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
11+ </vector>
+11−0android/app/src/main/res/drawable/lucide_alarm_clock_off.xmladdedView File
@@ -0,0 +1,11 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M6.87 6.87a8 8 0 1 0 11.26 11.26" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M19.9 14.25a8 8 0 0 0-9.15-9.15" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="m22 6-3-3" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ <path android:pathData="M6.26 18.67 4 21" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
9+ <path android:pathData="m2 2 20 20" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
10+ <path android:pathData="M4 4 2 6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
11+ </vector>
+8−0android/app/src/main/res/drawable/lucide_archive.xmladdedView File
@@ -0,0 +1,8 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M3,3h18a1,1 0 0 1 1,1v3a1,1 0 0 1 -1,1h-18a1,1 0 0 1 -1,-1v-3a1,1 0 0 1 1,-1Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="M10 12h4" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ </vector>
+10−0android/app/src/main/res/drawable/lucide_archive_restore.xmladdedView File
@@ -0,0 +1,10 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M3,3h18a1,1 0 0 1 1,1v3a1,1 0 0 1 -1,1h-18a1,1 0 0 1 -1,-1v-3a1,1 0 0 1 1,-1Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M4 8v11a2 2 0 0 0 2 2h2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="M20 8v11a2 2 0 0 1-2 2h-2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ <path android:pathData="m9 15 3-3 3 3" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
9+ <path android:pathData="M12 12v9" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
10+ </vector>
+9−0android/app/src/main/res/drawable/lucide_archive_x.xmladdedView File
@@ -0,0 +1,9 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M3,3h18a1,1 0 0 1 1,1v3a1,1 0 0 1 -1,1h-18a1,1 0 0 1 -1,-1v-3a1,1 0 0 1 1,-1Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="m9.5 17 5-5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ <path android:pathData="m9.5 12 5 5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
9+ </vector>
+7−0android/app/src/main/res/drawable/lucide_arrow_down.xmladdedView File
@@ -0,0 +1,7 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M12 5v14" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="m19 12-7 7-7-7" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ </vector>
+8−0android/app/src/main/res/drawable/lucide_arrow_down_to_line.xmladdedView File
@@ -0,0 +1,8 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M12 17V3" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="m6 11 6 6 6-6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="M19 21H5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ </vector>
+7−0android/app/src/main/res/drawable/lucide_arrow_left.xmladdedView File
@@ -0,0 +1,7 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="m12 19-7-7 7-7" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M19 12H5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ </vector>
+7−0android/app/src/main/res/drawable/lucide_arrow_right.xmladdedView File
@@ -0,0 +1,7 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M5 12h14" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="m12 5 7 7-7 7" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ </vector>
+7−0android/app/src/main/res/drawable/lucide_arrow_up.xmladdedView File
@@ -0,0 +1,7 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="m5 12 7-7 7 7" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M12 19V5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ </vector>
+8−0android/app/src/main/res/drawable/lucide_asterisk.xmladdedView File
@@ -0,0 +1,8 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M12 5v14" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="m18.065 8.496-12.125 7" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="m5.94 8.504 12.125 7" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ </vector>
+7−0android/app/src/main/res/drawable/lucide_at_sign.xmladdedView File
@@ -0,0 +1,7 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M8,12a4,4 0 1 0 8,0a4,4 0 1 0 -8,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ </vector>
+7−0android/app/src/main/res/drawable/lucide_bell.xmladdedView File
@@ -0,0 +1,7 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M10.268 21a2 2 0 0 0 3.464 0" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ </vector>
+7−0android/app/src/main/res/drawable/lucide_blocks.xmladdedView File
@@ -0,0 +1,7 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M10 22V7a1 1 0 0 0-1-1H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5a1 1 0 0 0-1-1H2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M15,2h6a1,1 0 0 1 1,1v6a1,1 0 0 1 -1,1h-6a1,1 0 0 1 -1,-1v-6a1,1 0 0 1 1,-1Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ </vector>
+7−0android/app/src/main/res/drawable/lucide_book_open.xmladdedView File
@@ -0,0 +1,7 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M12 5v16" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ </vector>
+11−0android/app/src/main/res/drawable/lucide_bot.xmladdedView File
@@ -0,0 +1,11 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M12 8V4H8" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M6,8h12a2,2 0 0 1 2,2v8a2,2 0 0 1 -2,2h-12a2,2 0 0 1 -2,-2v-8a2,2 0 0 1 2,-2Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="M2 14h2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ <path android:pathData="M20 14h2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
9+ <path android:pathData="M15 13v2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
10+ <path android:pathData="M9 13v2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
11+ </vector>
+13−0android/app/src/main/res/drawable/lucide_brain.xmladdedView File
@@ -0,0 +1,13 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M12 18V5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ <path android:pathData="M17.997 5.125a4 4 0 0 1 2.526 5.77" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
9+ <path android:pathData="M18 18a4 4 0 0 0 2-7.464" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
10+ <path android:pathData="M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
11+ <path android:pathData="M6 18a4 4 0 0 1-2-7.464" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
12+ <path android:pathData="M6.003 5.125a4 4 0 0 0-2.526 5.77" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
13+ </vector>
+16−0android/app/src/main/res/drawable/lucide_bug.xmladdedView File
@@ -0,0 +1,16 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M12 20v-9" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="M14.12 3.88 16 2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ <path android:pathData="M21 21a4 4 0 0 0-3.81-4" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
9+ <path android:pathData="M21 5a4 4 0 0 1-3.55 3.97" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
10+ <path android:pathData="M22 13h-4" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
11+ <path android:pathData="M3 21a4 4 0 0 1 3.81-4" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
12+ <path android:pathData="M3 5a4 4 0 0 0 3.55 3.97" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
13+ <path android:pathData="M6 13H2" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
14+ <path android:pathData="m8 2 1.88 1.88" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
15+ <path android:pathData="M9 7.13V6a3 3 0 1 1 6 0v1.13" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
16+ </vector>
+7−0android/app/src/main/res/drawable/lucide_camera.xmladdedView File
@@ -0,0 +1,7 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M9,13a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ </vector>
+8−0android/app/src/main/res/drawable/lucide_chart_no_axes_column.xmladdedView File
@@ -0,0 +1,8 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M5 21v-6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ <path android:pathData="M12 21V3" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
7+ <path android:pathData="M19 21V9" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
8+ </vector>
+6−0android/app/src/main/res/drawable/lucide_check.xmladdedView File
@@ -0,0 +1,6 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="M20 6 9 17l-5-5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ </vector>
+6−0android/app/src/main/res/drawable/lucide_chevron_down.xmladdedView File
@@ -0,0 +1,6 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="m6 9 6 6 6-6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ </vector>
+6−0android/app/src/main/res/drawable/lucide_chevron_left.xmladdedView File
@@ -0,0 +1,6 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="m15 18-6-6 6-6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ </vector>
+6−0android/app/src/main/res/drawable/lucide_chevron_right.xmladdedView File
@@ -0,0 +1,6 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="m9 18 6-6-6-6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ </vector>
+6−0android/app/src/main/res/drawable/lucide_chevron_up.xmladdedView File
@@ -0,0 +1,6 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3+ android:width="24dp" android:height="24dp"
4+ android:viewportWidth="24" android:viewportHeight="24">
5+ <path android:pathData="m18 15-6-6-6 6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
6+ </vector>
+7−0android/app/src/main/res/drawable/lucide_chevrons_down_up.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_chevrons_up_down.xmladdedView File

Large diffs are not rendered by default.

+6−0android/app/src/main/res/drawable/lucide_circle.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_circle_alert.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_circle_check.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_circle_dot.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_circle_stop.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_circle_x.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_clock.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_code.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_contrast.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_copy.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_corner_down_right.xmladdedView File

Large diffs are not rendered by default.

+19−0android/app/src/main/res/drawable/lucide_cpu.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_dollar_sign.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_ellipsis.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_ellipsis_vertical.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_external_link.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_eye.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/drawable/lucide_eye_off.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_feather.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_file.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/drawable/lucide_file_code.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/drawable/lucide_file_diff.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/drawable/lucide_file_plus.xmladdedView File

Large diffs are not rendered by default.

+10−0android/app/src/main/res/drawable/lucide_file_text.xmladdedView File

Large diffs are not rendered by default.

+6−0android/app/src/main/res/drawable/lucide_folder.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/drawable/lucide_folder_git_2.xmladdedView File

Large diffs are not rendered by default.

+6−0android/app/src/main/res/drawable/lucide_folder_open.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_folder_plus.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_gauge.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_git_branch.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_git_commit_horizontal.xmladdedView File

Large diffs are not rendered by default.

+10−0android/app/src/main/res/drawable/lucide_git_fork.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/drawable/lucide_git_pull_request.xmladdedView File

Large diffs are not rendered by default.

+6−0android/app/src/main/res/drawable/lucide_github.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_globe.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_hammer.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/drawable/lucide_hard_drive.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/drawable/lucide_hash.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_house.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_image.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_inbox.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_info.xmladdedView File

Large diffs are not rendered by default.

+14−0android/app/src/main/res/drawable/lucide_keyboard.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_laptop.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_layers.xmladdedView File

Large diffs are not rendered by default.

+11−0android/app/src/main/res/drawable/lucide_list.xmladdedView File

Large diffs are not rendered by default.

+10−0android/app/src/main/res/drawable/lucide_list_checks.xmladdedView File

Large diffs are not rendered by default.

+11−0android/app/src/main/res/drawable/lucide_list_ordered.xmladdedView File

Large diffs are not rendered by default.

+10−0android/app/src/main/res/drawable/lucide_list_todo.xmladdedView File

Large diffs are not rendered by default.

+6−0android/app/src/main/res/drawable/lucide_loader_circle.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_lock.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_lock_open.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_mail.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_map_pin.xmladdedView File

Large diffs are not rendered by default.

+16−0android/app/src/main/res/drawable/lucide_memory_stick.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_message_circle_question_mark.xmladdedView File

Large diffs are not rendered by default.

+6−0android/app/src/main/res/drawable/lucide_message_square.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_mic.xmladdedView File

Large diffs are not rendered by default.

+6−0android/app/src/main/res/drawable/lucide_minus.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/drawable/lucide_monitor_smartphone.xmladdedView File

Large diffs are not rendered by default.

+6−0android/app/src/main/res/drawable/lucide_moon.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_moon_star.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_move_left.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_move_right.xmladdedView File

Large diffs are not rendered by default.

+6−0android/app/src/main/res/drawable/lucide_navigation.xmladdedView File

Large diffs are not rendered by default.

+10−0android/app/src/main/res/drawable/lucide_network.xmladdedView File

Large diffs are not rendered by default.

+10−0android/app/src/main/res/drawable/lucide_palette.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_panel_left_close.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_panel_left_open.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_panel_right_close.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_panel_right_open.xmladdedView File

Large diffs are not rendered by default.

+6−0android/app/src/main/res/drawable/lucide_paperclip.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_pencil.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_pi.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_pin.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/drawable/lucide_pin_off.xmladdedView File

Large diffs are not rendered by default.

+6−0android/app/src/main/res/drawable/lucide_play.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/drawable/lucide_plug.xmladdedView File

Large diffs are not rendered by default.

+10−0android/app/src/main/res/drawable/lucide_plug_zap.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_plus.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_power.xmladdedView File

Large diffs are not rendered by default.

+17−0android/app/src/main/res/drawable/lucide_qr_code.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/drawable/lucide_refresh_cw.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_rotate_ccw.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_rotate_cw.xmladdedView File

Large diffs are not rendered by default.

+10−0android/app/src/main/res/drawable/lucide_scan_line.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_search.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_send_horizontal.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/drawable/lucide_server.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_settings.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/drawable/lucide_settings_2.xmladdedView File

Large diffs are not rendered by default.

+6−0android/app/src/main/res/drawable/lucide_shield.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_shield_check.xmladdedView File

Large diffs are not rendered by default.

+14−0android/app/src/main/res/drawable/lucide_sliders_horizontal.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_smartphone.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/drawable/lucide_sparkles.xmladdedView File

Large diffs are not rendered by default.

+6−0android/app/src/main/res/drawable/lucide_square.xmladdedView File

Large diffs are not rendered by default.

+17−0android/app/src/main/res/drawable/lucide_square_dashed.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_square_pen.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_square_stop.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_square_terminal.xmladdedView File

Large diffs are not rendered by default.

+14−0android/app/src/main/res/drawable/lucide_sun.xmladdedView File

Large diffs are not rendered by default.

+10−0android/app/src/main/res/drawable/lucide_sun_moon.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_terminal.xmladdedView File

Large diffs are not rendered by default.

+10−0android/app/src/main/res/drawable/lucide_trash.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_triangle_alert.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_type.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_undo_2.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/drawable/lucide_users.xmladdedView File

Large diffs are not rendered by default.

+13−0android/app/src/main/res/drawable/lucide_wand_sparkles.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/drawable/lucide_wifi.xmladdedView File

Large diffs are not rendered by default.

+12−0android/app/src/main/res/drawable/lucide_wifi_off.xmladdedView File

Large diffs are not rendered by default.

+8−0android/app/src/main/res/drawable/lucide_workflow.xmladdedView File

Large diffs are not rendered by default.

+6−0android/app/src/main/res/drawable/lucide_wrench.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/lucide_x.xmladdedView File

Large diffs are not rendered by default.

+6−0android/app/src/main/res/drawable/lucide_zap.xmladdedView File

Large diffs are not rendered by default.

+7−0android/app/src/main/res/drawable/vectrum_logo.xmladdedView File

Large diffs are not rendered by default.

+0−0android/app/src/main/res/font/geist_bold.ttfaddedView File

Binary file not shown.

+0−0android/app/src/main/res/font/geist_medium.ttfaddedView File

Binary file not shown.

+0−0android/app/src/main/res/font/geist_regular.ttfaddedView File

Binary file not shown.

+0−0android/app/src/main/res/font/geist_semibold.ttfaddedView File

Binary file not shown.

+0−0android/app/src/main/res/font/geistmono_medium.ttfaddedView File

Binary file not shown.

+0−0android/app/src/main/res/font/geistmono_regular.ttfaddedView File

Binary file not shown.

+0−0android/app/src/main/res/font/geistpixel_circle.ttfaddedView File

Binary file not shown.

+6−0android/app/src/main/res/mipmap-anydpi/ic_launcher.xmladdedView File

Large diffs are not rendered by default.

+5−0android/app/src/main/res/values/colors.xmladdedView File

Large diffs are not rendered by default.

+9−0android/app/src/main/res/values/themes.xmladdedView File

Large diffs are not rendered by default.

+24−0android/app/src/test/java/cc/vectrum/code/util/NamesTest.ktaddedView File

Large diffs are not rendered by default.

+5−0android/build.gradle.ktsaddedView File

Large diffs are not rendered by default.

+8−0android/gradle.propertiesaddedView File

Large diffs are not rendered by default.

+25−0android/gradle/libs.versions.tomladdedView File

Large diffs are not rendered by default.

+0−0android/gradle/wrapper/gradle-wrapper.jaraddedView File

Binary file not shown.

+9−0android/gradle/wrapper/gradle-wrapper.propertiesaddedView File

Large diffs are not rendered by default.

+248−0android/gradlewaddedView File

Large diffs are not rendered by default.

+82−0android/gradlew.bataddedView File

Large diffs are not rendered by default.

+8−0android/scripts/icons.txtaddedView File

Large diffs are not rendered by default.

+111−0android/scripts/lucide.pyaddedView File

Large diffs are not rendered by default.

+30−0android/settings.gradle.ktsaddedView File

Large diffs are not rendered by default.