Vectrum
Sign In

Android: pairing, settings and adding projects

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

Showing 32 changed files with 3784 additions and 16 deletions.

+1−0android/app/build.gradle.ktsView File
@@ -39,5 +39,6 @@ dependencies {
3939 implementation(libs.coroutines.android)
4040 implementation(libs.serialization.json)
4141 implementation(libs.okhttp)
42+ implementation(libs.code.scanner)
4243 testImplementation(libs.junit)
4344 }
+5−0android/app/src/main/AndroidManifest.xmlView File
@@ -13,6 +13,11 @@
1313 android:usesCleartextTraffic="true"
1414 android:supportsRtl="true">
1515
16+ <!-- Fetches Google's QR code scanner with the app, for pairing. -->
17+ <meta-data
18+ android:name="com.google.mlkit.vision.DEPENDENCIES"
19+ android:value="barcode_ui" />
20+
1621 <activity
1722 android:name=".MainActivity"
1823 android:exported="true"
+7−6android/app/src/main/java/cc/vectrum/code/data/DevicePrefs.ktView File
@@ -66,21 +66,22 @@ class DevicePrefs(private val store: SharedPreferences) {
6666 "time_format" to "system",
6767 "fold_turns" to true,
6868 "show_costs" to true,
69- "follow_up" to "steer",
70- "streaming" to "token",
69+ "follow_up" to "queue",
70+ "streaming" to "paragraphs",
7171 "slash_skills" to true,
7272 "inline_ui" to true,
73- "hide_whitespace" to false,
74- "diff_collapsed" to false,
73+ "hide_whitespace" to true,
74+ "diff_collapsed" to true,
7575 "diff_side_by_side" to false,
7676 "composer_context" to false,
7777 "plan_mode" to false,
7878 "context_meter" to false,
7979 "confirm_unpin" to false,
80- "confirm_archive" to true,
80+ "confirm_archive" to false,
81+ "confirm_settle" to false,
8182 "confirm_delete" to true,
8283 "thread_notifications" to "notifications",
83- "working_section" to true,
84+ "working_section" to false,
8485 "show_settled" to true,
8586 )
8687 }
+4−0android/app/src/main/java/cc/vectrum/code/ui/Icons.ktView File
@@ -69,6 +69,7 @@ enum class Lucide(val res: Int) {
6969 FolderGit2(R.drawable.lucide_folder_git_2),
7070 FolderOpen(R.drawable.lucide_folder_open),
7171 FolderPlus(R.drawable.lucide_folder_plus),
72+ FolderUp(R.drawable.lucide_folder_up),
7273 Gauge(R.drawable.lucide_gauge),
7374 GitBranch(R.drawable.lucide_git_branch),
7475 GitCommitHorizontal(R.drawable.lucide_git_commit_horizontal),
@@ -86,6 +87,7 @@ enum class Lucide(val res: Int) {
8687 Keyboard(R.drawable.lucide_keyboard),
8788 Laptop(R.drawable.lucide_laptop),
8889 Layers(R.drawable.lucide_layers),
90+ Link(R.drawable.lucide_link),
8991 Link2(R.drawable.lucide_link_2),
9092 List(R.drawable.lucide_list),
9193 ListChecks(R.drawable.lucide_list_checks),
@@ -114,7 +116,9 @@ enum class Lucide(val res: Int) {
114116 PanelRightClose(R.drawable.lucide_panel_right_close),
115117 PanelRightOpen(R.drawable.lucide_panel_right_open),
116118 Paperclip(R.drawable.lucide_paperclip),
119+ PenLine(R.drawable.lucide_pen_line),
117120 Pencil(R.drawable.lucide_pencil),
121+ PencilRuler(R.drawable.lucide_pencil_ruler),
118122 Pi(R.drawable.lucide_pi),
119123 Pin(R.drawable.lucide_pin),
120124 PinOff(R.drawable.lucide_pin_off),
+5−0android/app/src/main/java/cc/vectrum/code/ui/Nav.ktView File
@@ -60,6 +60,11 @@ class Nav {
6060 this.screen = screen
6161 }
6262
63+ /** Shows another screen in place of this one, as settings pages switch. */
64+ fun replace(screen: Screen) {
65+ this.screen = screen
66+ }
67+
6368 fun openThread(ref: ThreadRef) = go(Screen.Thread(ref))
6469
6570 fun home() {
+311−2android/app/src/main/java/cc/vectrum/code/ui/pair/AddProject.ktView File
@@ -1,9 +1,318 @@
11 package cc.vectrum.code.ui.pair
22
3+ import androidx.activity.compose.BackHandler
4+ import androidx.compose.foundation.Image
5+ import androidx.compose.foundation.layout.Arrangement
6+ import androidx.compose.foundation.layout.Box
7+ import androidx.compose.foundation.layout.Column
8+ import androidx.compose.foundation.layout.Row
9+ import androidx.compose.foundation.layout.defaultMinSize
10+ import androidx.compose.foundation.layout.fillMaxWidth
11+ import androidx.compose.foundation.layout.padding
12+ import androidx.compose.foundation.layout.size
313 import androidx.compose.runtime.Composable
14+ import androidx.compose.runtime.LaunchedEffect
15+ import androidx.compose.runtime.getValue
16+ import androidx.compose.runtime.mutableStateOf
17+ import androidx.compose.runtime.remember
18+ import androidx.compose.runtime.setValue
19+ import androidx.compose.ui.Alignment
20+ import androidx.compose.ui.Modifier
21+ import androidx.compose.ui.graphics.Color
22+ import androidx.compose.ui.graphics.ColorFilter
23+ import androidx.compose.ui.res.painterResource
24+ import androidx.compose.ui.text.TextRange
25+ import androidx.compose.ui.text.font.FontWeight
26+ import androidx.compose.ui.text.input.TextFieldValue
27+ import androidx.compose.ui.unit.dp
28+ import androidx.compose.ui.unit.sp
29+ import cc.vectrum.code.R
430 import cc.vectrum.code.data.AppState
531 import cc.vectrum.code.data.HostState
32+ import cc.vectrum.code.net.ClientMessage
33+ import cc.vectrum.code.net.ConnectionStatus
34+ import cc.vectrum.code.net.Folder
35+ import cc.vectrum.code.net.ProjectSource
36+ import cc.vectrum.code.theme.Cap
37+ import cc.vectrum.code.theme.CapKind
38+ import cc.vectrum.code.theme.CapSize
39+ import cc.vectrum.code.theme.Tag
40+ import cc.vectrum.code.theme.Txt
41+ import cc.vectrum.code.theme.palette
42+ import cc.vectrum.code.ui.BottomSheet
43+ import cc.vectrum.code.ui.Field
44+ import cc.vectrum.code.ui.Lucide
45+ import cc.vectrum.code.ui.LucideIcon
46+ import cc.vectrum.code.ui.MenuItem
47+ import cc.vectrum.code.ui.MenuLabel
48+ import cc.vectrum.code.ui.MenuRow
49+ import cc.vectrum.code.ui.MenuSeparator
50+
51+ // The Add project flow of the desktop's command palette (src/ui/workspace/
52+ // palette.rs) as a sheet: the environment, then where the project comes
53+ // from — a folder there, browsed from a path field (src/ui/folder_browser.rs),
54+ // a new repository, or a clone — and the host adds it.
55+
56+ /** A forge to clone from, with the desktop's serde spelling and its logo. */
57+ private class Forge(
58+ val key: String,
59+ val label: String,
60+ val placeholder: String,
61+ val description: String,
62+ val logo: Int?,
63+ val color: Color?,
64+ )
65+
66+ private val Forges = listOf(
67+ Forge("git_hub", "GitHub repository", "owner/repo", "Clone a GitHub owner/repo", null, null),
68+ Forge("git_lab", "GitLab repository", "owner/repo", "Clone a GitLab group/repo", R.drawable.brand_gitlab, Color(0xfffc6d26)),
69+ Forge(
70+ "azure_dev_ops", "Azure DevOps repository", "organization/project/repo",
71+ "Clone Azure DevOps project/repository", R.drawable.brand_azure_devops, Color(0xff2f8fe8),
72+ ),
73+ Forge("bitbucket", "Bitbucket repository", "workspace/repo", "Clone Bitbucket workspace/repository", R.drawable.brand_bitbucket, Color(0xff2684ff)),
74+ Forge("forgejo", "Forgejo / Gitea repository", "owner/repo", "Clone Forgejo / Gitea owner/repo", R.drawable.brand_forgejo, Color(0xfffb923c)),
75+ )
76+
77+ private sealed interface Step {
78+ data object Hosts : Step
79+
80+ data object Sources : Step
81+
82+ data object Folders : Step
83+
84+ data object NewProject : Step
85+
86+ data class Clone(val forge: Forge?) : Step
87+ }
88+
89+ /** The most folders a listing shows at once; typing narrows them down. */
90+ private const val MaxRows = 300
91+
92+ /** Adds a project on a host: a folder there, a new one, or a clone. [host] skips picking one. */
93+ @Composable
94+ fun AddProjectSheet(app: AppState, host: HostState?, open: Boolean, onDismiss: () -> Unit) {
95+ val p = palette
96+ val first = if (host == null && app.hosts.size > 1) Step.Hosts else Step.Sources
97+ var stack by remember(open) { mutableStateOf(listOf(first)) }
98+ var picked by remember(open) { mutableStateOf(host ?: app.hosts.singleOrNull()) }
99+ val step = stack.last()
100+ val push = { next: Step -> stack = stack + next }
101+ val pop = { stack = stack.dropLast(1) }
102+
103+ val add = { source: ProjectSource ->
104+ val target = picked
105+ if (target != null) {
106+ val clone = when (source) {
107+ is ProjectSource.Url -> source.url
108+ is ProjectSource.Forge -> source.repo
109+ is ProjectSource.Folder, is ProjectSource.New -> null
110+ }
111+ if (clone != null) app.showNotice("Cloning ${repoName(clone) ?: "the repository"} on ${target.name}…")
112+ if (!target.send(ClientMessage.AddProject(source))) app.showNotice("${target.name} is offline.", error = true)
113+ }
114+ onDismiss()
115+ }
116+
117+ BottomSheet(open, onDismiss) {
118+ if (open) BackHandler(enabled = stack.size > 1) { pop() }
119+ val title = when (step) {
120+ Step.Hosts -> "Environments"
121+ Step.Sources -> picked?.let { "Add project on ${it.name}" } ?: "Add project"
122+ Step.Folders -> "Directories"
123+ Step.NewProject -> "New project"
124+ is Step.Clone -> "Clone"
125+ }
126+ Row(
127+ Modifier.fillMaxWidth().defaultMinSize(minHeight = 32.dp),
128+ horizontalArrangement = Arrangement.spacedBy(4.dp),
129+ verticalAlignment = Alignment.CenterVertically,
130+ ) {
131+ if (stack.size > 1) Cap(icon = Lucide.ChevronLeft, kind = CapKind.Ghost, size = CapSize.Compact, onClick = pop)
132+ MenuLabel(title)
133+ }
134+ val target = picked
135+ when {
136+ app.hosts.isEmpty() || (target == null && step != Step.Hosts) -> {
137+ Txt(
138+ "Pair with a computer under Settings → Connections to add its projects here.",
139+ Modifier.padding(horizontal = 8.dp, vertical = 12.dp),
140+ color = p.ink3,
141+ size = 13.sp,
142+ lineHeight = 19.sp,
143+ )
144+ }
145+ step == Step.Hosts -> HostList(app) {
146+ picked = it
147+ push(Step.Sources)
148+ }
149+ target == null -> {}
150+ step == Step.Sources -> Sources(target, push)
151+ step == Step.Folders -> FolderBrowser(target) { add(ProjectSource.Folder(it)) }
152+ step == Step.NewProject -> NewProject(target) { add(ProjectSource.New(it)) }
153+ step is Step.Clone -> CloneRepo(target, step.forge) { input ->
154+ add(if (step.forge == null) ProjectSource.Url(input) else ProjectSource.Forge(step.forge.key, input))
155+ }
156+ }
157+ }
158+ }
159+
160+ @Composable
161+ private fun HostList(app: AppState, onPick: (HostState) -> Unit) {
162+ for (host in app.hosts.toList()) {
163+ val detail = when (host.status) {
164+ is ConnectionStatus.Connected -> host.saved.address
165+ ConnectionStatus.Connecting -> "Connecting…"
166+ else -> "Not connected"
167+ }
168+ MenuRow(host.name, icon = Lucide.Laptop, detail = detail, enabled = host.connected) { onPick(host) }
169+ }
170+ }
171+
172+ @Composable
173+ private fun Sources(host: HostState, push: (Step) -> Unit) {
174+ val p = palette
175+ MenuRow("New project", icon = Lucide.FolderPlus, detail = "Start a new Git repository from a name") { push(Step.NewProject) }
176+ MenuRow("Local folder", icon = Lucide.FolderOpen, detail = "Browse a folder on ${host.name}") { push(Step.Folders) }
177+ MenuRow("Git URL", icon = Lucide.Link, detail = "Clone from a remote URL") { push(Step.Clone(null)) }
178+ MenuSeparator()
179+ for (forge in Forges) {
180+ MenuItem(onClick = { push(Step.Clone(forge)) }) {
181+ ForgeLogo(forge)
182+ Column(Modifier.weight(1f)) {
183+ Txt(forge.label, color = p.ink, size = 13.5.sp, maxLines = 1)
184+ Txt(forge.description, color = p.ink3.copy(alpha = 0.8f), size = 12.sp, maxLines = 2)
185+ }
186+ }
187+ }
188+ }
189+
190+ @Composable
191+ private fun ForgeLogo(forge: Forge?, size: androidx.compose.ui.unit.Dp = 15.dp) {
192+ val p = palette
193+ when {
194+ forge == null -> LucideIcon(Lucide.Link, size = size, tint = p.icon)
195+ forge.logo == null -> LucideIcon(Lucide.Github, size = size, tint = p.icon)
196+ else -> Image(
197+ painterResource(forge.logo),
198+ contentDescription = null,
199+ modifier = Modifier.size(size),
200+ colorFilter = ColorFilter.tint(forge.color ?: p.icon),
201+ )
202+ }
203+ }
204+
205+ /** A listing the host sent, or why there's none. */
206+ private sealed interface Listing {
207+ data object Loading : Listing
208+
209+ data class Ready(val folders: List<Folder>, val truncated: Boolean) : Listing
210+
211+ data class Failed(val error: String) : Listing
212+ }
213+
214+ @Composable
215+ private fun FolderBrowser(host: HostState, onAdd: (String) -> Unit) {
216+ val p = palette
217+ var field by remember { mutableStateOf(TextFieldValue("~/", TextRange(2))) }
218+ val (dir, filter) = splitField(field.text)
219+ var listing by remember { mutableStateOf<Listing>(Listing.Loading) }
220+ LaunchedEffect(dir) {
221+ listing = Listing.Loading
222+ val reply = host.listFolders(dir)
223+ listing = if (reply.error != null) Listing.Failed(reply.error) else Listing.Ready(reply.folders, reply.truncated)
224+ }
225+ val folders = (listing as? Listing.Ready)?.folders ?: emptyList()
226+ val matches = filterNames(folders.map { it.name }, filter).map { folders[it] }
227+ val namesFolder = listing is Listing.Ready && (filter.isEmpty() || folders.any { it.name == filter })
228+ val go = { text: String -> field = TextFieldValue(text, TextRange(text.length)) }
229+
230+ Row(
231+ Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
232+ horizontalArrangement = Arrangement.spacedBy(6.dp),
233+ verticalAlignment = Alignment.CenterVertically,
234+ ) {
235+ Box(Modifier.weight(1f)) { Field(field, { field = it }, placeholder = "~/", leading = Lucide.Folder, mono = true) }
236+ val parent = parentField(dir)
237+ Cap(icon = Lucide.FolderUp, kind = CapKind.Ghost, size = CapSize.Regular, enabled = parent != null) { parent?.let(go) }
238+ }
239+ val message = when (val state = listing) {
240+ Listing.Loading -> "Loading…"
241+ is Listing.Failed -> state.error
242+ is Listing.Ready -> when {
243+ state.truncated -> "Showing the first ${state.folders.size} folders. Type to narrow them down."
244+ state.folders.isEmpty() -> "No folders here."
245+ matches.isEmpty() -> "No folders match."
246+ matches.size > MaxRows -> "Showing the first $MaxRows folders. Type to narrow them down."
247+ else -> null
248+ }
249+ }
250+ if (message != null) {
251+ Txt(message, Modifier.padding(horizontal = 12.dp, vertical = 8.dp), color = p.ink3, size = 12.5.sp, lineHeight = 18.sp)
252+ }
253+ for (folder in matches.take(MaxRows)) {
254+ MenuItem(onClick = { go("$dir${folder.name}/") }) {
255+ LucideIcon(Lucide.Folder, size = 15.dp, tint = p.icon)
256+ Txt(folder.name, Modifier.weight(1f), color = p.ink, size = 13.5.sp, maxLines = 1)
257+ if (folder.git) Tag("git")
258+ }
259+ }
260+ val path = field.text.trim()
261+ val name = path.trimEnd('/').substringAfterLast('/').ifEmpty { path }
262+ Box(Modifier.fillMaxWidth().padding(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 4.dp)) {
263+ Cap(
264+ Modifier.fillMaxWidth(),
265+ label = if (namesFolder && path.isNotEmpty()) "Add $name" else "Add",
266+ icon = Lucide.FolderPlus,
267+ kind = CapKind.Primary,
268+ enabled = namesFolder && path.isNotEmpty(),
269+ ) { onAdd(path) }
270+ }
271+ }
272+
273+ @Composable
274+ private fun NewProject(host: HostState, onCreate: (String) -> Unit) {
275+ var name by remember { mutableStateOf(TextFieldValue("")) }
276+ val trimmed = name.text.trim()
277+ val problem = checkName(trimmed)
278+ val base = "the base folder on ${host.name}"
279+ Box(Modifier.padding(horizontal = 4.dp, vertical = 2.dp)) {
280+ Field(name, { name = it }, placeholder = "Project name", leading = Lucide.FolderPlus)
281+ }
282+ MenuRow(
283+ problem ?: "Create $trimmed",
284+ icon = Lucide.FolderPlus,
285+ detail = if (problem != null) "It goes in $base" else "In $base · a new Git repository",
286+ enabled = problem == null,
287+ ) { onCreate(trimmed) }
288+ }
6289
7- /** Adds a project on a host: a folder there, a new one, or a clone. Placeholder until it's built. */
8290 @Composable
9- fun AddProjectSheet(app: AppState, host: HostState?, open: Boolean, onDismiss: () -> Unit) {}
291+ private fun CloneRepo(host: HostState, forge: Forge?, onClone: (String) -> Unit) {
292+ val p = palette
293+ var input by remember { mutableStateOf(TextFieldValue("")) }
294+ val text = input.text.trim()
295+ val name = repoName(text)
296+ val base = "the base folder on ${host.name}"
297+ Box(Modifier.padding(horizontal = 4.dp, vertical = 2.dp)) {
298+ Field(
299+ input,
300+ { input = it },
301+ placeholder = forge?.placeholder ?: "https://github.com/owner/repo.git",
302+ leading = if (forge == null) Lucide.Link else null,
303+ mono = true,
304+ )
305+ }
306+ val (title, detail) = when {
307+ text.isEmpty() -> (if (forge != null) "Type ${forge.placeholder}" else "Paste a Git URL") to "It clones into $base"
308+ name == null -> "That doesn't look like a repository." to null
309+ else -> "Clone $name" to "Into $base"
310+ }
311+ MenuItem(enabled = text.isNotEmpty() && name != null, onClick = { onClone(text) }) {
312+ ForgeLogo(forge)
313+ Column(Modifier.weight(1f)) {
314+ Txt(title, color = p.ink, size = 13.5.sp, weight = if (name != null) FontWeight.Medium else FontWeight.Normal, maxLines = 2)
315+ if (detail != null) Txt(detail, color = p.ink3.copy(alpha = 0.8f), size = 12.sp, maxLines = 2)
316+ }
317+ }
318+ }
+58−0android/app/src/main/java/cc/vectrum/code/ui/pair/Folders.ktaddedView File
@@ -0,0 +1,58 @@
1+ package cc.vectrum.code.ui.pair
2+
3+ // What the Add project browser does with its path field, as the desktop's
4+ // (src/ui/folder_browser.rs, src/projects.rs): the field names a folder to
5+ // list and filters it, like a shell.
6+
7+ /**
8+ * Splits the field into the folder to list and the partial name after it:
9+ * `~/Work/vec` is `~/Work/` filtered by `vec`. A bare `~` is the home folder.
10+ */
11+ fun splitField(text: String): Pair<String, String> {
12+ if (text == "~") return "~/" to ""
13+ val ix = text.lastIndexOf('/')
14+ return if (ix < 0) "" to text else text.substring(0, ix + 1) to text.substring(ix + 1)
15+ }
16+
17+ /**
18+ * The indexes of the names matching [filter], case-insensitively: those
19+ * starting with it first, then those containing it. Dot-folders only show
20+ * when the filter starts with a dot.
21+ */
22+ fun filterNames(names: List<String>, filter: String): List<Int> {
23+ val needle = filter.lowercase()
24+ val starts = mutableListOf<Int>()
25+ val contains = mutableListOf<Int>()
26+ names.forEachIndexed { ix, name ->
27+ if (name.startsWith('.') && !filter.startsWith('.')) return@forEachIndexed
28+ val lower = name.lowercase()
29+ if (lower.startsWith(needle)) starts += ix else if (lower.contains(needle)) contains += ix
30+ }
31+ return starts + contains
32+ }
33+
34+ /** The folder above the one the field lists, or `null` at `~/` and `/`. */
35+ fun parentField(dir: String): String? {
36+ val trimmed = dir.trimEnd('/')
37+ if (trimmed.isEmpty() || trimmed == "~") return null
38+ val ix = trimmed.lastIndexOf('/')
39+ return if (ix < 0) null else trimmed.substring(0, ix + 1)
40+ }
41+
42+ /** The folder a repository clones into: the URL's last part without `.git`. */
43+ fun repoName(url: String): String? {
44+ val trimmed = url.trim().trimEnd('/')
45+ val last = trimmed.split('/', ':').last()
46+ val name = last.removeSuffix(".git")
47+ return name.takeIf { it.isNotEmpty() && it != "." && it != ".." }
48+ }
49+
50+ /** Why a new project's name won't do, if it won't. */
51+ fun checkName(name: String): String? {
52+ val trimmed = name.trim()
53+ return when {
54+ trimmed.isEmpty() -> "Type a name for the project."
55+ trimmed == "." || trimmed == ".." || '/' in trimmed || '\\' in trimmed -> "A project name can't contain slashes."
56+ else -> null
57+ }
58+ }
+254−4android/app/src/main/java/cc/vectrum/code/ui/pair/PairScreen.ktView File
@@ -1,19 +1,269 @@
11 package cc.vectrum.code.ui.pair
22
3+ import androidx.compose.foundation.Image
4+ import androidx.compose.foundation.layout.Arrangement
35 import androidx.compose.foundation.layout.Box
6+ import androidx.compose.foundation.layout.Column
7+ import androidx.compose.foundation.layout.Row
8+ import androidx.compose.foundation.layout.Spacer
9+ import androidx.compose.foundation.layout.fillMaxSize
10+ import androidx.compose.foundation.layout.fillMaxWidth
11+ import androidx.compose.foundation.layout.height
12+ import androidx.compose.foundation.layout.imePadding
413 import androidx.compose.foundation.layout.padding
14+ import androidx.compose.foundation.layout.size
15+ import androidx.compose.foundation.layout.widthIn
16+ import androidx.compose.foundation.rememberScrollState
17+ import androidx.compose.foundation.shape.RoundedCornerShape
18+ import androidx.compose.foundation.verticalScroll
519 import androidx.compose.runtime.Composable
20+ import androidx.compose.runtime.DisposableEffect
21+ import androidx.compose.runtime.LaunchedEffect
22+ import androidx.compose.runtime.getValue
23+ import androidx.compose.runtime.mutableStateOf
24+ import androidx.compose.runtime.remember
25+ import androidx.compose.runtime.setValue
26+ import androidx.compose.ui.Alignment
627 import androidx.compose.ui.Modifier
28+ import androidx.compose.ui.graphics.ColorFilter
29+ import androidx.compose.ui.platform.LocalConfiguration
30+ import androidx.compose.ui.platform.LocalContext
31+ import androidx.compose.ui.res.painterResource
32+ import androidx.compose.ui.text.font.FontWeight
33+ import androidx.compose.ui.text.input.TextFieldValue
34+ import androidx.compose.ui.text.style.TextAlign
35+ import androidx.compose.ui.text.TextStyle
736 import androidx.compose.ui.unit.dp
37+ import androidx.compose.ui.unit.sp
38+ import cc.vectrum.code.R
839 import cc.vectrum.code.data.AppState
9- import cc.vectrum.code.data.OpenThread
10- import cc.vectrum.code.data.ThreadRef
40+ import cc.vectrum.code.data.HostState
41+ import cc.vectrum.code.net.ConnectionStatus
42+ import cc.vectrum.code.net.parsePairing
43+ import cc.vectrum.code.net.withPort
44+ import cc.vectrum.code.theme.Cap
45+ import cc.vectrum.code.theme.CapKind
46+ import cc.vectrum.code.theme.CapSize
1147 import cc.vectrum.code.theme.Engraved
48+ import cc.vectrum.code.theme.Keycap
49+ import cc.vectrum.code.theme.Led
50+ import cc.vectrum.code.theme.Rule
51+ import cc.vectrum.code.theme.StatusLed
52+ import cc.vectrum.code.theme.Txt
53+ import cc.vectrum.code.theme.palette
54+ import cc.vectrum.code.theme.raised
55+ import cc.vectrum.code.ui.Field
56+ import cc.vectrum.code.ui.Lucide
1257 import cc.vectrum.code.ui.Nav
1358 import cc.vectrum.code.ui.Screen
59+ import kotlinx.coroutines.delay
60+
61+ // Pairing with a computer, the phone's welcome: the desktop shows a pairing
62+ // code (`address:port/key`) and its QR code under Settings → Connections
63+ // once Allow remote connections is on. Typed, pasted or scanned, it's saved
64+ // as a host here and the connection's status shows until it's up.
65+
66+ /** The host being paired, kept while the sheet moves from Home to the Pair screen. */
67+ private object Pairing {
68+ var host by mutableStateOf<String?>(null)
69+ }
1470
15- // Placeholder until it's built.
1671 @Composable
1772 fun PairScreen(app: AppState, nav: Nav) {
18- Box(Modifier.padding(16.dp)) { Engraved("Pair") }
73+ val p = palette
74+ val context = LocalContext.current
75+ var code by remember { mutableStateOf(TextFieldValue("")) }
76+ var key by remember { mutableStateOf(TextFieldValue("")) }
77+ var error by remember { mutableStateOf<String?>(null) }
78+ val host = Pairing.host?.let { app.host(it) }
79+ val (address, codeKey) = parsePairing(code.text)
80+ val needsKey = address.isNotBlank() && codeKey == null
81+ val onPairScreen = nav.screen == Screen.Pair
82+
83+ // The pairing is forgotten once this screen is left for another.
84+ DisposableEffect(Unit) { onDispose { if (nav.screen != Screen.Pair) Pairing.host = null } }
85+
86+ fun connect(text: String = code.text, typedKey: String = key.text) {
87+ val (addr, fromCode) = parsePairing(text)
88+ val secret = typedKey.trim().ifEmpty { null } ?: fromCode
89+ if (addr.isBlank() || secret == null) {
90+ error = "Enter the other device's pairing code, or its address and key."
91+ return
92+ }
93+ error = null
94+ // On Home the screen goes as soon as there's a host; the status shows on the Pair screen.
95+ if (!onPairScreen) nav.go(Screen.Pair)
96+ Pairing.host = app.addHost(withPort(addr), secret).saved.id
97+ }
98+
99+ LaunchedEffect(host?.status) {
100+ if (host?.status is ConnectionStatus.Connected) {
101+ delay(700)
102+ Pairing.host = null
103+ // Back to the settings it was opened from, or to a new thread.
104+ if (app.hosts.size > 1 && onPairScreen) {
105+ if (!nav.back()) nav.home()
106+ } else {
107+ nav.home()
108+ }
109+ }
110+ }
111+
112+ Column(Modifier.fillMaxSize()) {
113+ TopBar(nav, onPairScreen)
114+ Box(
115+ Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).imePadding(),
116+ contentAlignment = Alignment.TopCenter,
117+ ) {
118+ Column(
119+ Modifier.widthIn(max = 440.dp).fillMaxWidth().padding(start = 20.dp, end = 20.dp, top = 16.dp, bottom = 40.dp),
120+ horizontalAlignment = Alignment.CenterHorizontally,
121+ verticalArrangement = Arrangement.spacedBy(14.dp),
122+ ) {
123+ Image(
124+ painterResource(R.drawable.vectrum_logo),
125+ contentDescription = null,
126+ modifier = Modifier.size(32.dp),
127+ colorFilter = ColorFilter.tint(p.ink),
128+ )
129+ Engraved("Pair with a computer")
130+ Txt(
131+ "Work on your computer's threads from here.",
132+ color = p.ink,
133+ size = 22.sp,
134+ weight = FontWeight.SemiBold,
135+ lineHeight = 28.sp,
136+ style = TextStyle(textAlign = TextAlign.Center),
137+ )
138+ Txt(
139+ "This phone opens the projects and threads of Vectrum Code on your computer, with its agents and files.",
140+ color = p.ink2,
141+ size = 13.5.sp,
142+ lineHeight = 20.sp,
143+ style = TextStyle(textAlign = TextAlign.Center),
144+ )
145+ Spacer(Modifier.height(4.dp))
146+ Steps()
147+ Spacer(Modifier.height(4.dp))
148+ Field(
149+ code,
150+ {
151+ code = it
152+ error = null
153+ },
154+ placeholder = "Pairing code, or 192.168.2.10",
155+ leading = Lucide.Link2,
156+ mono = true,
157+ )
158+ if (needsKey) {
159+ Field(key, { key = it }, placeholder = "Key", leading = Lucide.Lock, mono = true)
160+ Txt(
161+ "The key is under Settings → Connections on the computer, next to the pairing code.",
162+ Modifier.fillMaxWidth().padding(horizontal = 4.dp),
163+ color = p.ink3,
164+ size = 12.sp,
165+ lineHeight = 17.sp,
166+ )
167+ }
168+ if (error != null) {
169+ Txt(error!!, Modifier.fillMaxWidth().padding(horizontal = 4.dp), color = p.red, size = 12.5.sp, lineHeight = 18.sp)
170+ }
171+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
172+ Cap(Modifier.weight(1f), label = "Scan QR code", icon = Lucide.ScanLine) {
173+ scanQrCode(
174+ context,
175+ onCode = { text ->
176+ code = TextFieldValue(text)
177+ error = null
178+ if (parsePairing(text).second != null) connect(text, "")
179+ },
180+ onError = { error = it },
181+ )
182+ }
183+ Cap(
184+ Modifier.weight(1f),
185+ label = "Connect",
186+ icon = Lucide.Plug,
187+ kind = CapKind.Primary,
188+ enabled = address.isNotBlank() && (!needsKey || key.text.isNotBlank()),
189+ ) { connect() }
190+ }
191+ if (host != null) PairStatus(host)
192+ Txt(
193+ "Connections aren't encrypted. Pair on networks you trust, or over a VPN such as Tailscale or WireGuard.",
194+ Modifier.padding(top = 6.dp),
195+ color = p.ink3,
196+ size = 12.sp,
197+ lineHeight = 18.sp,
198+ style = TextStyle(textAlign = TextAlign.Center),
199+ )
200+ }
201+ }
202+ }
203+ }
204+
205+ /** Back to where the Pair screen was opened from, or the sidebar on Home. */
206+ @Composable
207+ private fun TopBar(nav: Nav, onPairScreen: Boolean) {
208+ val drawer = LocalConfiguration.current.screenWidthDp < 840
209+ Row(
210+ Modifier.fillMaxWidth().height(52.dp).padding(horizontal = 8.dp),
211+ verticalAlignment = Alignment.CenterVertically,
212+ ) {
213+ when {
214+ onPairScreen -> Cap(icon = Lucide.ArrowLeft, kind = CapKind.Ghost, size = CapSize.Compact) { if (!nav.back()) nav.home() }
215+ drawer -> Cap(icon = Lucide.PanelLeftOpen, kind = CapKind.Ghost, size = CapSize.Compact) { nav.sidebarOpen = true }
216+ }
217+ }
218+ }
219+
220+ /** What to turn on on the computer, step by step. */
221+ @Composable
222+ private fun Steps() {
223+ val p = palette
224+ val steps = listOf(
225+ "On your computer, open Vectrum Code and go to Settings → Connections.",
226+ "Turn on Allow remote connections.",
227+ "Scan the QR code it shows, or copy its pairing code and paste it below.",
228+ )
229+ Column(Modifier.fillMaxWidth().raised(p, RoundedCornerShape(18.dp))) {
230+ steps.forEachIndexed { ix, step ->
231+ Row(
232+ Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
233+ horizontalArrangement = Arrangement.spacedBy(12.dp),
234+ verticalAlignment = Alignment.CenterVertically,
235+ ) {
236+ Keycap("${ix + 1}")
237+ Txt(step, Modifier.weight(1f), color = p.ink, size = 13.5.sp, lineHeight = 19.sp)
238+ }
239+ if (ix + 1 < steps.size) Box(Modifier.padding(horizontal = 16.dp)) { Rule() }
240+ }
241+ }
242+ }
243+
244+ /** The new host's connection, as it stands. */
245+ @Composable
246+ private fun PairStatus(host: HostState) {
247+ val p = palette
248+ val (led, title, detail) = when (val status = host.status) {
249+ ConnectionStatus.Connecting -> Triple(Led.Waiting, "Connecting…", withPort(host.saved.address))
250+ is ConnectionStatus.Connected -> Triple(Led.Live, "Connected to ${status.host}", "Opening its threads…")
251+ is ConnectionStatus.Denied -> Triple(Led.Failed, status.reason, "Check the key, or copy the pairing code again.")
252+ is ConnectionStatus.Offline -> Triple(
253+ Led.Failed,
254+ "Retrying · ${status.reason}",
255+ "Make sure Allow remote connections is on, and that both devices are on the same network.",
256+ )
257+ }
258+ Row(
259+ Modifier.fillMaxWidth().raised(p, RoundedCornerShape(14.dp)).padding(horizontal = 14.dp, vertical = 12.dp),
260+ horizontalArrangement = Arrangement.spacedBy(10.dp),
261+ verticalAlignment = Alignment.CenterVertically,
262+ ) {
263+ StatusLed(led, pulse = host.status == ConnectionStatus.Connecting)
264+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
265+ Txt(title, color = p.ink, size = 13.5.sp, weight = FontWeight.Medium, maxLines = 2)
266+ Txt(detail, color = p.ink3, size = 12.sp, lineHeight = 17.sp)
267+ }
268+ }
19269 }
+35−0android/app/src/main/java/cc/vectrum/code/ui/pair/QrScanner.ktaddedView File
@@ -0,0 +1,35 @@
1+ package cc.vectrum.code.ui.pair
2+
3+ import android.content.Context
4+ import com.google.mlkit.vision.barcode.common.Barcode
5+ import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
6+ import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
7+
8+ /**
9+ * Scans a QR code with Google's code scanner, which brings its own camera
10+ * screen, so the app needs no camera permission. [onCode] gets what the code
11+ * says; [onError] why there's no scanner, as on phones without Google Play
12+ * services. A cancelled scan calls neither.
13+ */
14+ fun scanQrCode(context: Context, onCode: (String) -> Unit, onError: (String) -> Unit) {
15+ val unavailable = "The QR code scanner isn't available on this phone. Paste the pairing code instead."
16+ try {
17+ val options = GmsBarcodeScannerOptions.Builder()
18+ .setBarcodeFormats(Barcode.FORMAT_QR_CODE)
19+ .enableAutoZoom()
20+ .build()
21+ GmsBarcodeScanning.getClient(context, options)
22+ .startScan()
23+ .addOnSuccessListener { barcode ->
24+ val text = barcode.rawValue
25+ if (text.isNullOrBlank()) onError("That QR code is empty.") else onCode(text)
26+ }
27+ .addOnFailureListener { error ->
28+ // The scanner module downloads on first use; until then it isn't there.
29+ val message = error.message.orEmpty()
30+ onError(if (message.contains("download", ignoreCase = true)) "The QR code scanner is still downloading. Try again in a moment." else unavailable)
31+ }
32+ } catch (_: Throwable) {
33+ onError(unavailable)
34+ }
35+ }
+84−0android/app/src/main/java/cc/vectrum/code/ui/settings/AboutPage.ktaddedView File
@@ -0,0 +1,84 @@
1+ package cc.vectrum.code.ui.settings
2+
3+ import android.content.Context
4+ import androidx.compose.runtime.Composable
5+ import androidx.compose.ui.platform.LocalContext
6+ import androidx.compose.ui.platform.LocalUriHandler
7+ import androidx.compose.ui.unit.dp
8+ import cc.vectrum.code.data.AppState
9+ import cc.vectrum.code.theme.Cap
10+ import cc.vectrum.code.theme.CapKind
11+ import cc.vectrum.code.theme.CapSize
12+ import cc.vectrum.code.theme.StatusLed
13+ import cc.vectrum.code.theme.palette
14+ import cc.vectrum.code.ui.Lucide
15+ import cc.vectrum.code.ui.LucideIcon
16+
17+ // About: the desktop keeps it at the end of General, with the open source
18+ // licenses a page further; a phone gives it a page: this app's version, each
19+ // environment's, and the notices for what the app is built from.
20+
21+ /** This app's version name, as the package says it. */
22+ fun appVersion(context: Context): String =
23+ runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }.getOrNull() ?: "?"
24+
25+ /** A notice on the page: what it is, its license, what it is to the app and where it lives. */
26+ private class Notice(val name: String, val license: String, val bundle: String, val source: String)
27+
28+ private val Notices = listOf(
29+ Notice("Geist, Geist Mono and Geist Pixel", "SIL Open Font License 1.1", "Fonts", "https://github.com/vercel/geist-font"),
30+ Notice("Lucide", "ISC", "Icons", "https://github.com/lucide-icons/lucide"),
31+ Notice("Jetpack Compose and AndroidX", "Apache-2.0", "Libraries", "https://developer.android.com/jetpack/androidx"),
32+ Notice("Kotlin, kotlinx.coroutines and kotlinx.serialization", "Apache-2.0", "Libraries", "https://github.com/Kotlin"),
33+ Notice("OkHttp", "Apache-2.0", "Library", "https://github.com/square/okhttp"),
34+ Notice("Google code scanner", "Google Play services terms", "Library", "https://developers.google.com/ml-kit/vision/barcode-scanning/code-scanner"),
35+ )
36+
37+ @Composable
38+ fun AboutPage(app: AppState) {
39+ val p = palette
40+ val context = LocalContext.current
41+ val uri = LocalUriHandler.current
42+ Section("About") {
43+ row {
44+ SettingRow(
45+ "Version",
46+ id = "version",
47+ icon = { LucideIcon(Lucide.Smartphone, size = 14.dp, tint = p.ink2) },
48+ titleSuffix = { Code(appVersion(context)) },
49+ description = "Current version of Vectrum Code for Android.",
50+ )
51+ }
52+ }
53+ if (app.hosts.isNotEmpty()) {
54+ Section("Environments") {
55+ for (host in app.hosts.toList()) row {
56+ val (led, text) = hostStatus(host)
57+ SettingRow(
58+ host.name,
59+ icon = { LucideIcon(Lucide.Server, size = 14.dp, tint = p.ink2) },
60+ titleSuffix = { if (host.version.isNotBlank()) Code(host.version) },
61+ description = if (host.version.isBlank()) "$text · Its version shows once it connects." else text,
62+ inline = true,
63+ ) { StatusLed(led) }
64+ }
65+ }
66+ }
67+ Section(
68+ "Open source licenses",
69+ id = "open-source-licenses",
70+ description = "Notices for the fonts, icons and libraries Vectrum Code for Android is built from. Geist is licensed under the SIL Open Font License 1.1.",
71+ ) {
72+ for (notice in Notices) row {
73+ SettingRow(
74+ notice.name,
75+ description = "${notice.license} · ${notice.bundle}",
76+ inline = true,
77+ ) {
78+ Cap(icon = Lucide.ExternalLink, kind = CapKind.Ghost, size = CapSize.Compact) {
79+ runCatching { uri.openUri(notice.source) }
80+ }
81+ }
82+ }
83+ }
84+ }
+319−0android/app/src/main/java/cc/vectrum/code/ui/settings/AppearancePage.ktaddedView File
@@ -0,0 +1,319 @@
1+ package cc.vectrum.code.ui.settings
2+
3+ import androidx.compose.foundation.background
4+ import androidx.compose.foundation.border
5+ import androidx.compose.foundation.clickable
6+ import androidx.compose.foundation.layout.Arrangement
7+ import androidx.compose.foundation.layout.Box
8+ import androidx.compose.foundation.layout.BoxWithConstraints
9+ import androidx.compose.foundation.layout.Column
10+ import androidx.compose.foundation.layout.Row
11+ import androidx.compose.foundation.layout.Spacer
12+ import androidx.compose.foundation.layout.defaultMinSize
13+ import androidx.compose.foundation.layout.fillMaxHeight
14+ import androidx.compose.foundation.layout.fillMaxSize
15+ import androidx.compose.foundation.layout.fillMaxWidth
16+ import androidx.compose.foundation.layout.height
17+ import androidx.compose.foundation.layout.offset
18+ import androidx.compose.foundation.layout.padding
19+ import androidx.compose.foundation.layout.requiredSize
20+ import androidx.compose.foundation.layout.size
21+ import androidx.compose.foundation.layout.width
22+ import androidx.compose.foundation.shape.CircleShape
23+ import androidx.compose.foundation.shape.RoundedCornerShape
24+ import androidx.compose.runtime.Composable
25+ import androidx.compose.ui.Alignment
26+ import androidx.compose.ui.Modifier
27+ import androidx.compose.ui.draw.clip
28+ import androidx.compose.ui.draw.dropShadow
29+ import androidx.compose.ui.graphics.Color
30+ import androidx.compose.ui.graphics.shadow.Shadow
31+ import androidx.compose.ui.text.font.FontWeight
32+ import androidx.compose.ui.unit.Dp
33+ import androidx.compose.ui.unit.dp
34+ import androidx.compose.ui.unit.sp
35+ import cc.vectrum.code.data.DevicePrefs
36+ import cc.vectrum.code.theme.Duo
37+ import cc.vectrum.code.theme.Palette
38+ import cc.vectrum.code.theme.Tag
39+ import cc.vectrum.code.theme.Theme
40+ import cc.vectrum.code.theme.Themes
41+ import cc.vectrum.code.theme.Txt
42+ import cc.vectrum.code.theme.metal
43+ import cc.vectrum.code.theme.palette
44+ import cc.vectrum.code.theme.raised
45+ import cc.vectrum.code.ui.Lucide
46+ import cc.vectrum.code.ui.LucideIcon
47+
48+ // The Appearance page (src/ui/settings/appearance.rs): Color scheme tiles
49+ // (System, Light, Dark), a card per theme family where each swatch makes
50+ // that theme the light or the dark one, then the Interface rows. Changes
51+ // apply at once: the app reads its palette from these preferences.
52+
53+ @Composable
54+ fun AppearancePage(prefs: DevicePrefs) {
55+ val p = palette
56+ Section("Colors & themes", id = "appearance", hideTitle = true) {
57+ row {
58+ Column(verticalArrangement = Arrangement.spacedBy(24.dp)) {
59+ Column(Modifier.targetable("color-scheme", 18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
60+ PageLabel("Color scheme")
61+ SchemeTiles(prefs)
62+ }
63+ Column(Modifier.targetable("theme", 18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
64+ Box(Modifier.defaultMinSize(minHeight = 32.dp), contentAlignment = Alignment.CenterStart) { PageLabel("Themes") }
65+ ThemeCards(prefs)
66+ }
67+ }
68+ }
69+ }
70+
71+ Section("Interface", id = "appearance-interface") {
72+ row {
73+ val colors = prefs.string("diff_colors")
74+ val (removed, added) = if (colors == "blue_orange") p.tints[0] to p.blueInk else p.red to p.greenInk
75+ SettingRow(
76+ "Diff colors",
77+ id = "diff-color-scheme",
78+ description = "Choose colors for additions and deletions, including change counts.",
79+ onReset = if (prefs.changed("diff_colors")) ({ prefs.reset("diff_colors") }) else null,
80+ ) {
81+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
82+ Row(horizontalArrangement = Arrangement.spacedBy(3.dp)) {
83+ Box(Modifier.size(8.dp).background(removed, CircleShape))
84+ Box(Modifier.size(8.dp).background(added, CircleShape))
85+ }
86+ Choice(
87+ colors,
88+ listOf("red_green" to "Red & green (default)", "blue_orange" to "Blue & orange"),
89+ label = if (colors == "blue_orange") "Blue & orange" else "Red & green",
90+ ) { prefs.set("diff_colors", it) }
91+ }
92+ }
93+ }
94+ row {
95+ PrefSwitch(
96+ prefs, "composer_context", "Composer context",
97+ "Keep branch and worktree controls below the composer after a thread starts.",
98+ id = "composer-context",
99+ )
100+ }
101+ }
102+ }
103+
104+ /** A label over a group on the page. */
105+ @Composable
106+ private fun PageLabel(text: String) {
107+ Txt(text, Modifier.padding(horizontal = 16.dp), color = palette.ink2, size = 14.sp)
108+ }
109+
110+ /** System, Light and Dark, each a miniature of the app in that mode. */
111+ @Composable
112+ private fun SchemeTiles(prefs: DevicePrefs) {
113+ val p = palette
114+ val mode = prefs.themeMode
115+ val light = Themes.find(prefs.lightTheme, false)
116+ val dark = Themes.find(prefs.darkTheme, true)
117+ BoxWithConstraints {
118+ val previewHeight = if (maxWidth < 480.dp) 96.dp else 140.dp
119+ Row(horizontalArrangement = Arrangement.spacedBy(if (maxWidth < 480.dp) 6.dp else 12.dp)) {
120+ for ((value, name) in listOf("system" to "System", "light" to "Light", "dark" to "Dark")) {
121+ val selected = value == mode
122+ val shape = RoundedCornerShape(18.dp)
123+ Column(
124+ Modifier
125+ .weight(1f)
126+ .then(if (selected) Modifier.raised(p, shape) else Modifier)
127+ .clip(shape)
128+ .clickable { prefs.set("theme_mode", value) }
129+ .padding(8.dp),
130+ verticalArrangement = Arrangement.spacedBy(8.dp),
131+ ) {
132+ val previewShape = RoundedCornerShape(14.dp)
133+ Box(
134+ Modifier
135+ .fillMaxWidth()
136+ .height(previewHeight)
137+ .dropShadow(previewShape, Shadow(radius = 0.dp, color = if (selected) p.greenDeep else p.rule, spread = if (selected) 2.dp else 1.dp)),
138+ ) {
139+ when (value) {
140+ "light" -> ThemePreview(light)
141+ "dark" -> ThemePreview(dark)
142+ // Light on the left, dark on the right.
143+ else -> Row(Modifier.fillMaxSize().clip(previewShape)) {
144+ Box(Modifier.weight(1f).fillMaxHeight()) { ThemePreview(light, RoundedCornerShape(topStart = 14.dp, bottomStart = 14.dp)) }
145+ Box(Modifier.weight(1f).fillMaxHeight()) { ThemePreview(dark, RoundedCornerShape(topEnd = 14.dp, bottomEnd = 14.dp)) }
146+ }
147+ }
148+ }
149+ Row(
150+ Modifier.padding(horizontal = 4.dp),
151+ horizontalArrangement = Arrangement.spacedBy(8.dp),
152+ verticalAlignment = Alignment.CenterVertically,
153+ ) {
154+ Radio(selected)
155+ Txt(name, color = p.ink, size = 12.5.sp, weight = FontWeight.Medium, maxLines = 1)
156+ }
157+ }
158+ }
159+ }
160+ }
161+ }
162+
163+ /** A card per theme family: a swatch per variant, the family's name, and which modes it currently owns. */
164+ @Composable
165+ private fun ThemeCards(prefs: DevicePrefs) {
166+ val lightId = prefs.lightTheme
167+ val darkId = prefs.darkTheme
168+ BoxWithConstraints {
169+ val columns = if (maxWidth >= 600.dp) 3 else 2
170+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
171+ for (chunk in Themes.families.chunked(columns)) {
172+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
173+ for (family in chunk) {
174+ Box(Modifier.weight(1f)) { FamilyCard(family.name, family.themes, lightId, darkId, prefs) }
175+ }
176+ repeat(columns - chunk.size) { Spacer(Modifier.weight(1f)) }
177+ }
178+ }
179+ }
180+ }
181+ }
182+
183+ @Composable
184+ private fun FamilyCard(name: String, members: List<Theme>, lightId: String, darkId: String, prefs: DevicePrefs) {
185+ val p = palette
186+ val ownsLight = members.any { !it.palette.dark && it.id == lightId }
187+ val ownsDark = members.any { it.palette.dark && it.id == darkId }
188+ val firstLight = members.firstOrNull { !it.palette.dark }
189+ val firstDark = members.firstOrNull { it.palette.dark }
190+ Column(Modifier.fillMaxWidth().raised(p, RoundedCornerShape(18.dp))) {
191+ BoxWithConstraints(Modifier.fillMaxWidth().height(84.dp), contentAlignment = Alignment.Center) {
192+ val count = members.size.coerceAtLeast(1)
193+ val gap = 10.dp
194+ val size = ((maxWidth - 16.dp - gap * (count - 1)) / count).coerceIn(28.dp, 52.dp)
195+ Row(horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
196+ for (theme in members) {
197+ val chosen = theme.id == if (theme.palette.dark) darkId else lightId
198+ Swatch(theme, chosen, size) { pick(prefs, theme) }
199+ }
200+ }
201+ }
202+ Row(
203+ Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp, top = 4.dp),
204+ horizontalArrangement = Arrangement.spacedBy(6.dp),
205+ verticalAlignment = Alignment.CenterVertically,
206+ ) {
207+ // The family's name uses it for both modes, without switching between them.
208+ Txt(
209+ name,
210+ Modifier
211+ .weight(1f)
212+ .clickable(interactionSource = null, indication = null) {
213+ listOfNotNull(firstLight, firstDark).forEach { assign(prefs, it) }
214+ },
215+ color = p.ink,
216+ size = 13.5.sp,
217+ weight = FontWeight.Medium,
218+ maxLines = 1,
219+ )
220+ if (ownsLight) Tag("Light")
221+ if (ownsDark) Tag("Dark")
222+ }
223+ }
224+ }
225+
226+ /** Makes [theme] the light or dark theme, switching to its mode unless the mode follows the system. */
227+ private fun pick(prefs: DevicePrefs, theme: Theme) {
228+ assign(prefs, theme)
229+ if (prefs.themeMode != "system") prefs.set("theme_mode", if (theme.palette.dark) "dark" else "light")
230+ }
231+
232+ /** Makes [theme] the light or dark theme without touching the mode. */
233+ private fun assign(prefs: DevicePrefs, theme: Theme) {
234+ prefs.set(if (theme.palette.dark) "dark_theme" else "light_theme", theme.id)
235+ }
236+
237+ /** One theme as a round swatch: its page and sheet, a cap and its accent. Ringed when it's the theme of its mode. */
238+ @Composable
239+ private fun Swatch(theme: Theme, chosen: Boolean, size: Dp, onClick: () -> Unit) {
240+ val p = palette
241+ val c = theme.palette
242+ val scale = size / 52.dp
243+ Box(Modifier.size(size).clickable(interactionSource = null, indication = null, onClick = onClick)) {
244+ if (chosen) {
245+ Box(Modifier.align(Alignment.Center).requiredSize(size + 8.dp * scale).border(2.dp, p.greenDeep, CircleShape))
246+ }
247+ Box(
248+ Modifier.fillMaxSize().metal(CircleShape, Duo(c.sHi, c.page).brush, c.raiseSm),
249+ contentAlignment = Alignment.Center,
250+ ) {
251+ Box(Modifier.size(22.dp * scale).metal(CircleShape, c.cap.brush, c.btn), contentAlignment = Alignment.Center) {
252+ Box(Modifier.size(7.dp * scale).background(c.blueInk, CircleShape))
253+ }
254+ }
255+ Box(
256+ Modifier
257+ .align(Alignment.BottomEnd)
258+ .offset(2.dp, 2.dp)
259+ .size(18.dp)
260+ .metal(CircleShape, p.cap.brush, p.raiseSm),
261+ contentAlignment = Alignment.Center,
262+ ) {
263+ LucideIcon(if (c.dark) Lucide.Moon else Lucide.Sun, size = 10.dp, tint = p.ink2)
264+ }
265+ }
266+ }
267+
268+ /**
269+ * A miniature of the app in a theme (themes::preview): the sheet on the
270+ * table, a thread list with one thread picked, a code well and a composer.
271+ */
272+ @Composable
273+ fun ThemePreview(c: Palette, shape: RoundedCornerShape = RoundedCornerShape(14.dp)) {
274+ @Composable
275+ fun line(width: Dp, color: Color) = Box(Modifier.width(width).height(4.dp).background(color, CircleShape))
276+ Row(
277+ Modifier.fillMaxSize().clip(shape).background(c.page).padding(8.dp),
278+ horizontalArrangement = Arrangement.spacedBy(8.dp),
279+ ) {
280+ Column(Modifier.width(30.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
281+ Box(Modifier.metal(RoundedCornerShape(4.dp), c.rowOn.brush, c.raiseSm).padding(3.dp)) { line(20.dp, c.ink2) }
282+ Box(Modifier.padding(horizontal = 3.dp)) { line(16.dp, c.ink3.copy(alpha = c.ink3.alpha * 0.7f)) }
283+ Box(Modifier.padding(horizontal = 3.dp)) { line(20.dp, c.ink3.copy(alpha = c.ink3.alpha * 0.7f)) }
284+ }
285+ Column(
286+ Modifier
287+ .weight(1f)
288+ .fillMaxHeight()
289+ .metal(RoundedCornerShape(10.dp), Duo(c.sHi, c.s).brush, c.raiseSm)
290+ .clip(RoundedCornerShape(10.dp))
291+ .padding(8.dp),
292+ verticalArrangement = Arrangement.spacedBy(5.dp),
293+ ) {
294+ line(40.dp, c.ink.copy(alpha = c.ink.alpha * 0.8f))
295+ line(56.dp, c.ink3.copy(alpha = c.ink3.alpha * 0.7f))
296+ Column(
297+ Modifier.metal(RoundedCornerShape(5.dp), Duo(c.wellTop, c.wellBot).brush, c.well).padding(4.dp),
298+ verticalArrangement = Arrangement.spacedBy(3.dp),
299+ ) {
300+ Row(horizontalArrangement = Arrangement.spacedBy(3.dp)) {
301+ line(10.dp, c.blueInk)
302+ line(18.dp, c.greenInk)
303+ }
304+ Row(horizontalArrangement = Arrangement.spacedBy(3.dp)) {
305+ line(14.dp, c.tints[3])
306+ line(10.dp, c.amberInk)
307+ }
308+ }
309+ Spacer(Modifier.weight(1f))
310+ Row(
311+ Modifier.fillMaxWidth().height(16.dp).metal(RoundedCornerShape(6.dp), c.rowOn.brush, c.raiseSm).padding(horizontal = 4.dp),
312+ verticalAlignment = Alignment.CenterVertically,
313+ ) {
314+ Spacer(Modifier.weight(1f))
315+ Box(Modifier.size(8.dp).metal(CircleShape, c.primary.brush, emptyList()))
316+ }
317+ }
318+ }
319+ }
+130−0android/app/src/main/java/cc/vectrum/code/ui/settings/ArchivePage.ktaddedView File
@@ -0,0 +1,130 @@
1+ package cc.vectrum.code.ui.settings
2+
3+ import androidx.compose.foundation.ExperimentalFoundationApi
4+ import androidx.compose.foundation.combinedClickable
5+ import androidx.compose.foundation.layout.Box
6+ import androidx.compose.runtime.Composable
7+ import androidx.compose.runtime.getValue
8+ import androidx.compose.runtime.mutableStateOf
9+ import androidx.compose.runtime.remember
10+ import androidx.compose.runtime.setValue
11+ import androidx.compose.ui.Modifier
12+ import androidx.compose.ui.unit.dp
13+ import cc.vectrum.code.data.AppState
14+ import cc.vectrum.code.data.HostState
15+ import cc.vectrum.code.net.ThreadAction
16+ import cc.vectrum.code.net.ThreadSummary
17+ import cc.vectrum.code.theme.Badge
18+ import cc.vectrum.code.theme.Cap
19+ import cc.vectrum.code.theme.CapSize
20+ import cc.vectrum.code.theme.palette
21+ import cc.vectrum.code.ui.ConfirmDialog
22+ import cc.vectrum.code.ui.Lucide
23+ import cc.vectrum.code.ui.LucideIcon
24+ import cc.vectrum.code.ui.MenuRow
25+ import cc.vectrum.code.ui.PopupMenu
26+ import cc.vectrum.code.util.initials
27+ import cc.vectrum.code.util.tintFor
28+
29+ // The Archive page (src/ui/settings/archive.rs): settled threads by project,
30+ // newest first, each with when it was archived and created, an Unarchive
31+ // button, and Unarchive and Delete on a long press.
32+
33+ /** A settled thread and the host it's on. */
34+ private class Archived(val host: HostState, val thread: ThreadSummary, val project: String)
35+
36+ /** Settled threads across the hosts, newest first; subagents' threads go with their parents. */
37+ fun settledCount(app: AppState): Int = app.hosts.sumOf { host -> host.threads.values.count { it.settled && it.parent == null } }
38+
39+ @Composable
40+ fun ArchivePage(app: AppState) {
41+ val p = palette
42+ val threads = app.hosts.flatMap { host ->
43+ host.threads.values.filter { it.settled && it.parent == null }.map { thread ->
44+ val name = host.projects.find { it.path == thread.project }?.name ?: thread.project.trimEnd('/').substringAfterLast('/')
45+ Archived(host, thread, name)
46+ }
47+ }.sortedByDescending { it.thread.updated }
48+ if (threads.isEmpty()) {
49+ Section("Archived threads", id = "archive") {
50+ row {
51+ SettingRow(
52+ "No archived threads",
53+ icon = { LucideIcon(Lucide.Archive, size = 14.dp, tint = p.ink3) },
54+ description = "Archived threads will appear here.",
55+ )
56+ }
57+ }
58+ return
59+ }
60+ // One section per project, in the order their newest thread was archived.
61+ val several = app.hosts.size > 1
62+ val groups = threads.groupBy { it.host.saved.id to it.thread.project }
63+ for ((ix, group) in groups.values.withIndex()) {
64+ val name = group.first().project
65+ Section(
66+ if (several) "$name · ${group.first().host.name}" else name,
67+ id = if (ix == 0) "archive" else null,
68+ icon = { Badge(initials(name), tintFor(name)) },
69+ ) {
70+ for (item in group) row(item.thread.id) { ArchivedRow(app, item) }
71+ }
72+ }
73+ }
74+
75+ @OptIn(ExperimentalFoundationApi::class)
76+ @Composable
77+ private fun ArchivedRow(app: AppState, item: Archived) {
78+ var menu by remember { mutableStateOf(false) }
79+ var confirming by remember { mutableStateOf(false) }
80+ val thread = item.thread
81+ val unarchive: () -> Unit = { item.host.act(thread.id, ThreadAction.Unsettle) }
82+ val delete: () -> Unit = {
83+ if (app.prefs.bool("confirm_delete")) {
84+ confirming = true
85+ } else {
86+ item.host.act(thread.id, ThreadAction.Delete)
87+ }
88+ }
89+ Box(Modifier.combinedClickable(interactionSource = null, indication = null, onLongClick = { menu = true }, onClick = {})) {
90+ SettingRow(
91+ thread.title.ifBlank { "New thread" },
92+ icon = { ProviderIcon(thread.provider, size = 12.dp) },
93+ description = "Archived ${ago(thread.updated)} · Created ${ago(thread.created)}",
94+ inline = true,
95+ ) {
96+ Cap(label = "Unarchive", icon = Lucide.ArchiveX, size = CapSize.Compact, onClick = unarchive)
97+ }
98+ PopupMenu(open = menu, onDismiss = { menu = false }) {
99+ MenuRow("Unarchive", icon = Lucide.ArchiveX) {
100+ menu = false
101+ unarchive()
102+ }
103+ MenuRow("Delete", icon = Lucide.Trash, danger = true) {
104+ menu = false
105+ delete()
106+ }
107+ }
108+ }
109+ if (confirming) {
110+ ConfirmDialog(
111+ title = "Delete thread?",
112+ message = "This deletes the thread and its chat history.",
113+ confirm = "Delete",
114+ danger = true,
115+ onDismiss = { confirming = false },
116+ onConfirm = { item.host.act(thread.id, ThreadAction.Delete) },
117+ )
118+ }
119+ }
120+
121+ /** `2m ago`, `just now`, as the desktop's settings say it. */
122+ fun ago(millis: Long, now: Long = System.currentTimeMillis()): String {
123+ val secs = ((now - millis) / 1_000).coerceAtLeast(0)
124+ return when {
125+ secs < 60 -> "just now"
126+ secs < 3_600 -> "${secs / 60}m ago"
127+ secs < 86_400 -> "${secs / 3_600}h ago"
128+ else -> "${secs / 86_400}d ago"
129+ }
130+ }
+106−0android/app/src/main/java/cc/vectrum/code/ui/settings/ConnectionsPage.ktaddedView File
@@ -0,0 +1,106 @@
1+ package cc.vectrum.code.ui.settings
2+
3+ import androidx.compose.foundation.layout.Arrangement
4+ import androidx.compose.foundation.layout.Row
5+ import androidx.compose.runtime.Composable
6+ import androidx.compose.runtime.getValue
7+ import androidx.compose.runtime.mutableStateOf
8+ import androidx.compose.runtime.remember
9+ import androidx.compose.runtime.setValue
10+ import androidx.compose.ui.Alignment
11+ import androidx.compose.ui.unit.dp
12+ import cc.vectrum.code.data.AppState
13+ import cc.vectrum.code.data.HostState
14+ import cc.vectrum.code.net.ConnectionStatus
15+ import cc.vectrum.code.net.withPort
16+ import cc.vectrum.code.theme.Cap
17+ import cc.vectrum.code.theme.CapKind
18+ import cc.vectrum.code.theme.CapSize
19+ import cc.vectrum.code.theme.Led
20+ import cc.vectrum.code.theme.StatusLed
21+ import cc.vectrum.code.theme.Toggle
22+ import cc.vectrum.code.theme.palette
23+ import cc.vectrum.code.ui.ConfirmDialog
24+ import cc.vectrum.code.ui.Lucide
25+ import cc.vectrum.code.ui.LucideIcon
26+ import cc.vectrum.code.ui.Nav
27+ import cc.vectrum.code.ui.Screen
28+
29+ // The Connections page (src/ui/settings/connections.rs): this phone, which
30+ // only connects to others, and the environments it's paired with, with "Add
31+ // environment" for pairing another.
32+
33+ /** How a host's connection stands, as the desktop's environment rows say it. */
34+ fun hostStatus(host: HostState): Pair<Led, String> {
35+ if (!host.saved.enabled) return Led.Off to "Off"
36+ return when (val status = host.status) {
37+ is ConnectionStatus.Connected -> Led.Live to "Connected to ${status.host}"
38+ ConnectionStatus.Connecting -> Led.Waiting to "Connecting…"
39+ is ConnectionStatus.Offline -> Led.Failed to "Retrying · ${status.reason}"
40+ is ConnectionStatus.Denied -> Led.Failed to status.reason
41+ }
42+ }
43+
44+ @Composable
45+ fun ConnectionsPage(app: AppState, nav: Nav) {
46+ val p = palette
47+ Section(
48+ "This device",
49+ id = "connections-device",
50+ icon = { LucideIcon(Lucide.Smartphone, size = 14.dp, tint = p.ink2) },
51+ description = "Connections aren't encrypted. Use them on networks you trust, or over a VPN such as Tailscale or WireGuard.",
52+ ) {
53+ row {
54+ SettingRow(
55+ "Remote client",
56+ description = "This phone opens the projects and threads of the environments below and works in them, with their agents and files. Other devices can't connect to it; to share a computer, turn on Allow remote connections there.",
57+ )
58+ }
59+ }
60+
61+ Section(
62+ "Environments",
63+ id = "remote-environments",
64+ headerAction = {
65+ Cap(label = "Add environment", icon = Lucide.Plus, size = CapSize.Compact) { nav.go(Screen.Pair) }
66+ },
67+ ) {
68+ if (app.hosts.isEmpty()) row { EmptyRow("No saved remote environments") }
69+ for (host in app.hosts.toList()) row(host.saved.id) { HostRow(app, host) }
70+ }
71+ }
72+
73+ @Composable
74+ private fun HostRow(app: AppState, host: HostState) {
75+ val (led, text) = hostStatus(host)
76+ var removing by remember { mutableStateOf(false) }
77+ SettingRow(
78+ host.name,
79+ description = "${withPort(host.saved.address)} · $text",
80+ inline = true,
81+ titleSuffix = {
82+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
83+ StatusLed(led, pulse = host.status == ConnectionStatus.Connecting && host.saved.enabled)
84+ if (host.version.isNotBlank()) Code("v${host.version}")
85+ }
86+ },
87+ ) {
88+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
89+ if (host.saved.enabled && !host.connected) {
90+ Cap(icon = Lucide.RefreshCw, kind = CapKind.Ghost, size = CapSize.Compact) { host.retry() }
91+ }
92+ Toggle(host.saved.enabled) { app.setHostEnabled(host, it) }
93+ Cap(icon = Lucide.Trash, kind = CapKind.Ghost, size = CapSize.Compact) { removing = true }
94+ }
95+ }
96+ if (removing) {
97+ ConfirmDialog(
98+ title = "Remove ${host.name}?",
99+ message = "This phone forgets its address and key. To connect again, pair with it again.",
100+ confirm = "Remove",
101+ danger = true,
102+ onDismiss = { removing = false },
103+ onConfirm = { app.removeHost(host) },
104+ )
105+ }
106+ }
+336−0android/app/src/main/java/cc/vectrum/code/ui/settings/GeneralPage.ktaddedView File
@@ -0,0 +1,336 @@
1+ package cc.vectrum.code.ui.settings
2+
3+ import androidx.compose.foundation.layout.Arrangement
4+ import androidx.compose.foundation.layout.ExperimentalLayoutApi
5+ import androidx.compose.foundation.layout.FlowRow
6+ import androidx.compose.runtime.Composable
7+ import androidx.compose.runtime.LaunchedEffect
8+ import androidx.compose.runtime.getValue
9+ import androidx.compose.runtime.mutableStateOf
10+ import androidx.compose.runtime.saveable.rememberSaveable
11+ import androidx.compose.runtime.setValue
12+ import androidx.compose.ui.unit.dp
13+ import cc.vectrum.code.data.AppState
14+ import cc.vectrum.code.data.DevicePrefs
15+ import cc.vectrum.code.ui.Lucide
16+ import kotlinx.serialization.json.JsonNull
17+ import kotlinx.serialization.json.JsonPrimitive
18+
19+ // The General page, row for row after the desktop's (src/ui/settings/
20+ // general.rs): New threads, Organization, Behavior, Projects & threads,
21+ // Confirmations, Text generation and the folded Legacy features. The rows a
22+ // phone has no use for (window, keyboard and notification settings) are left
23+ // out; About has a page of its own.
24+
25+ private val Workspaces = listOf("local" to "Current checkout", "worktree" to "New worktree")
26+ private val Submodules = listOf("recursive" to "Recursive", "top_level" to "Top level only", "skip" to "Skip")
27+ private val TimeFormats = listOf("system" to "System default", "hour12" to "12-hour", "hour24" to "24-hour")
28+ private val Streaming = listOf(
29+ "turn" to "Wait for the full response",
30+ "paragraphs" to "Show finished paragraphs",
31+ "tokens" to "Stream every token",
32+ )
33+ private val DiffStates = listOf(false to "Expanded", true to "Collapsed")
34+ private val DiffLayouts = listOf(false to "Stacked", true to "Split")
35+ private val FollowUps = listOf("queue" to "Queue", "steer" to "Steer")
36+
37+ /** The desktop's permission modes, as the CLI spells them, with T3 Code's descriptions. */
38+ private val Permissions: List<RichOption<String?>> = listOf(
39+ RichOption(null, null, "Automatic", "Start with the permissions of the thread you have open."),
40+ RichOption("default", Lucide.Lock, "Supervised", "Ask before commands and file changes."),
41+ RichOption("acceptEdits", Lucide.PenLine, "Auto-accept edits", "Auto-approve edits, ask before other actions."),
42+ RichOption("auto", Lucide.Sparkles, "Auto", "Supported providers approve routine actions; others still ask."),
43+ RichOption("plan", Lucide.PencilRuler, "Plan", "Plan first, without editing files or running commands."),
44+ RichOption("bypassPermissions", Lucide.LockOpen, "Full access", "Allow commands and edits without prompts."),
45+ )
46+
47+ /** This phone's preferences "Restore device defaults" resets, by their labels, as the desktop lists them. */
48+ val DeviceDefaults: List<Pair<String, List<String>>> = listOf(
49+ "Theme" to listOf("theme_mode", "light_theme", "dark_theme"),
50+ "Diff colors" to listOf("diff_colors"),
51+ "Time format" to listOf("time_format"),
52+ "Working section" to listOf("working_section"),
53+ "Composer context" to listOf("composer_context"),
54+ "Default diff file state" to listOf("diff_collapsed"),
55+ "Diff whitespace changes" to listOf("hide_whitespace"),
56+ "Diff layout" to listOf("diff_side_by_side"),
57+ "Show skills in slash menu" to listOf("slash_skills"),
58+ "Inline diagrams and UI" to listOf("inline_ui"),
59+ "Follow-up behavior" to listOf("follow_up"),
60+ "Context window indicator" to listOf("context_meter"),
61+ "Plan mode" to listOf("plan_mode"),
62+ "Response streaming" to listOf("streaming"),
63+ "Fold finished turns" to listOf("fold_turns"),
64+ "Show costs" to listOf("show_costs"),
65+ "Unpin confirmation" to listOf("confirm_unpin"),
66+ "Archive confirmation" to listOf("confirm_settle"),
67+ "Delete confirmation" to listOf("confirm_delete"),
68+ )
69+
70+ /** The labels of this phone's preferences that differ from the defaults. */
71+ fun differingDefaults(prefs: DevicePrefs): List<String> =
72+ DeviceDefaults.filter { (_, keys) -> keys.any { prefs.changed(it) } }.map { it.first }
73+
74+ fun restoreDefaults(prefs: DevicePrefs) = DeviceDefaults.flatMap { it.second }.forEach { prefs.reset(it) }
75+
76+ @OptIn(ExperimentalLayoutApi::class)
77+ @Composable
78+ fun GeneralPage(app: AppState, shown: Shown) {
79+ val prefs = app.prefs
80+ val instances = shown.host?.instances ?: emptyList()
81+
82+ Section("New threads", id = "project-defaults") {
83+ row {
84+ val model = shown.string("default_model")
85+ SettingRow(
86+ "Model",
87+ id = "default-model",
88+ description = "Default model for new threads. Projects can override it.",
89+ status = if (model == null) "Automatic" else null,
90+ scope = shown.row("default_model", "default_reasoning"),
91+ onReset = shown.reset("default_model", "default_reasoning"),
92+ ) {
93+ FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
94+ ModelControl(model, "Automatic", oneOff = false, instances = instances) {
95+ shown.set("default_model", it?.let(::JsonPrimitive) ?: JsonNull)
96+ }
97+ ReasoningControl(shown.string("default_reasoning"), "Automatic") {
98+ shown.set("default_reasoning", it?.let(::JsonPrimitive) ?: JsonNull)
99+ }
100+ }
101+ }
102+ }
103+ row {
104+ SettingRow(
105+ "Permissions",
106+ id = "default-permissions",
107+ description = "Default permissions for new threads. Pi and jcode threads always run with full access.",
108+ scope = shown.row("default_access"),
109+ onReset = shown.reset("default_access"),
110+ ) {
111+ RichChoice(shown.string("default_access"), Permissions) {
112+ shown.set("default_access", it?.let(::JsonPrimitive) ?: JsonNull)
113+ }
114+ }
115+ }
116+ row {
117+ EnvChoice(
118+ shown, "workspace", "Workspace",
119+ "Where new threads start. The start screen can switch it for each thread.",
120+ Workspaces, id = "new-threads",
121+ )
122+ }
123+ row {
124+ EnvChoice(shown, "submodules", "Submodules", "How new worktrees populate git submodules.", Submodules, id = "worktree-submodules")
125+ }
126+ }
127+
128+ Section("Organization") {
129+ row {
130+ EnvSwitch(
131+ shown, "auto_resume_limited", "Auto-resume limited threads",
132+ "Resume usage-limit stops at the reported reset time. Each thread can cancel its scheduled continuation.",
133+ id = "auto-resume-limited-threads",
134+ )
135+ }
136+ row {
137+ EnvSwitch(
138+ shown, "snooze_limited", "Snooze limited threads",
139+ "Snooze usage-limit stops until the reported reset time. Combine with auto-resume to continue when they wake.",
140+ id = "snooze-limited-threads",
141+ )
142+ }
143+ row {
144+ PrefSwitch(
145+ prefs, "working_section", "Working section (beta)",
146+ "Fold working and monitoring threads into a Working section. They return to the top of the inbox when they need you.",
147+ id = "working-shelf",
148+ )
149+ }
150+ row {
151+ EnvSwitch(
152+ shown, "auto_settle_merged", "Auto-settle merged threads",
153+ "Settle a thread when its pull request merges. Closed pull requests still settle automatically.",
154+ id = "auto-settle-merged-threads",
155+ )
156+ }
157+ row {
158+ SettingRow(
159+ "Auto-settle inactive threads",
160+ id = "auto-settle-inactive-threads",
161+ description = "Sidebar threads with no activity for this long settle automatically.",
162+ inline = true,
163+ scope = shown.row("auto_settle_inactive"),
164+ onReset = shown.reset("auto_settle_inactive", "inactive_days"),
165+ ) {
166+ Switch(shown.bool("auto_settle_inactive")) { on ->
167+ if (on) {
168+ shown.set(mapOf("auto_settle_inactive" to JsonPrimitive(true), "inactive_days" to JsonPrimitive(3)))
169+ } else {
170+ shown.set("auto_settle_inactive", JsonPrimitive(false))
171+ }
172+ }
173+ }
174+ }
175+ if (shown.bool("auto_settle_inactive")) {
176+ row {
177+ SettingRow(
178+ "Days of inactivity before auto-settle",
179+ id = "days-before-auto-settle",
180+ description = "Any new activity un-settles a thread automatically.",
181+ scope = shown.row("inactive_days"),
182+ ) {
183+ Stepper(shown.int("inactive_days") ?: 3, 1, 1, 90) { shown.set("inactive_days", JsonPrimitive(it)) }
184+ }
185+ }
186+ }
187+ }
188+
189+ Section("Behavior") {
190+ row {
191+ PrefChoice(prefs, "time_format", "Time format", "System default follows your phone's clock preference.", TimeFormats, id = "time-format")
192+ }
193+ row {
194+ val description = when (prefs.string("streaming")) {
195+ "turn" -> "Text appears once the agent finishes its turn."
196+ "tokens" -> "Text appears token by token as the agent writes it."
197+ else -> "Each paragraph or code block appears as soon as it is complete."
198+ }
199+ PrefChoice(prefs, "streaming", "Response streaming", description, Streaming, id = "response-streaming")
200+ }
201+ row {
202+ PrefSwitch(
203+ prefs, "fold_turns", "Fold finished turns",
204+ "Tuck a finished turn's tool calls and interim messages into one “Worked for” row.",
205+ id = "fold-turns",
206+ )
207+ }
208+ row {
209+ PrefSwitch(prefs, "show_costs", "Show costs", "What each turn and thread cost, in the transcript and the project panel.", id = "show-costs")
210+ }
211+ row {
212+ PrefSwitch(
213+ prefs, "hide_whitespace", "Hide whitespace changes",
214+ "Set whether the diff panel ignores whitespace-only edits by default.",
215+ id = "hide-whitespace-changes",
216+ )
217+ }
218+ row {
219+ PrefBoolChoice(
220+ prefs, "diff_collapsed", "Default diff file state",
221+ "Start with files expanded or collapsed when opening diffs.", DiffStates, id = "default-diff-file-state",
222+ )
223+ }
224+ row {
225+ PrefBoolChoice(
226+ prefs, "diff_side_by_side", "Diff layout",
227+ "Show diffs stacked or side by side. The toggle in the diff toolbar changes this too.", DiffLayouts, id = "diff-layout",
228+ )
229+ }
230+ row {
231+ PrefSwitch(
232+ prefs, "slash_skills", "Show skills in slash menu",
233+ "Also include skills in the / command menu. Skills always appear when you type $.",
234+ id = "skills-in-slash-menu",
235+ )
236+ }
237+ row {
238+ PrefSwitch(
239+ prefs, "inline_ui", "Inline diagrams and UI",
240+ "Draw the diagrams, charts and small interactive UIs agents write in their answers.",
241+ id = "inline-ui",
242+ )
243+ }
244+ row {
245+ PrefChoice(
246+ prefs, "follow_up", "Follow-up behavior",
247+ "Queue follow-ups while the agent runs or steer the current run.", FollowUps, id = "follow-up-behavior",
248+ )
249+ }
250+ row {
251+ EnvSwitch(
252+ shown, "update_checks", "Provider update checks",
253+ "Check installed provider CLIs for newer available versions.",
254+ id = "provider-update-checks",
255+ )
256+ }
257+ row {
258+ EnvSwitch(
259+ shown, "continue_after_restart", "Continue threads after restarts",
260+ "Automatically resume interrupted threads after an update, crash, or machine restart.",
261+ id = "continue-threads-after-server-update",
262+ )
263+ }
264+ }
265+
266+ Section("Projects & threads") {
267+ row {
268+ EnvSwitch(
269+ shown, "start_from_origin", "Start from origin",
270+ "Creates the worktree from the latest matching branch on origin instead of your local branch.",
271+ id = "start-from-origin",
272+ )
273+ }
274+ }
275+
276+ Section("Confirmations") {
277+ row {
278+ PrefSwitch(prefs, "confirm_unpin", "Unpin confirmation", "Ask before unpinning a thread from the pinned section.", id = "unpin-confirmation")
279+ }
280+ row {
281+ PrefSwitch(
282+ prefs, "confirm_settle", "Archive confirmation",
283+ "Require a second tap on the inline archive action before a thread is archived.",
284+ id = "archive-confirmation",
285+ )
286+ }
287+ row {
288+ PrefSwitch(prefs, "confirm_delete", "Delete confirmation", "Ask before deleting a thread and its chat history.", id = "delete-confirmation")
289+ }
290+ }
291+
292+ Section("Text generation") {
293+ row {
294+ val model = shown.string("text_model")?.takeIf { it.isNotEmpty() }
295+ SettingRow(
296+ "Text generation model",
297+ id = "text-generation-model",
298+ description = "Used for thread titles and worktree branch names. Off keeps the first line of the first message.",
299+ scope = shown.row("text_model", "text_reasoning"),
300+ onReset = shown.reset("text_model", "text_reasoning"),
301+ ) {
302+ FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
303+ ModelControl(model, "Off", oneOff = true, instances = instances) {
304+ shown.set("text_model", JsonPrimitive(it ?: ""))
305+ }
306+ if (model != null) {
307+ ReasoningControl(shown.string("text_reasoning"), "Default") {
308+ shown.set("text_reasoning", it?.let(::JsonPrimitive) ?: JsonNull)
309+ }
310+ }
311+ }
312+ }
313+ }
314+ }
315+
316+ var legacy by rememberSaveable { mutableStateOf(false) }
317+ // A search jump into the fold opens it.
318+ val target = LocalTarget.current
319+ LaunchedEffect(target) { if (target?.id?.startsWith("legacy-") == true) legacy = true }
320+ FoldedSection("Legacy features", open = legacy, onToggle = { legacy = !legacy }) {
321+ row {
322+ PrefSwitch(
323+ prefs, "plan_mode", "Plan mode (legacy)",
324+ "Restore Build/Plan and /plan in the composer. Off uses build mode.",
325+ id = "legacy-plan-mode",
326+ )
327+ }
328+ row {
329+ PrefSwitch(
330+ prefs, "context_meter", "Context window indicator (legacy)",
331+ "Shows context window usage as a circular indicator in the composer.",
332+ id = "legacy-context-window-indicator",
333+ )
334+ }
335+ }
336+ }
+212−0android/app/src/main/java/cc/vectrum/code/ui/settings/ProvidersPage.ktaddedView File
@@ -0,0 +1,212 @@
1+ package cc.vectrum.code.ui.settings
2+
3+ import androidx.compose.foundation.background
4+ import androidx.compose.foundation.clickable
5+ import androidx.compose.foundation.layout.Arrangement
6+ import androidx.compose.foundation.layout.Box
7+ import androidx.compose.foundation.layout.Column
8+ import androidx.compose.foundation.layout.Row
9+ import androidx.compose.foundation.layout.defaultMinSize
10+ import androidx.compose.foundation.layout.fillMaxWidth
11+ import androidx.compose.foundation.layout.height
12+ import androidx.compose.foundation.layout.offset
13+ import androidx.compose.foundation.layout.padding
14+ import androidx.compose.foundation.layout.size
15+ import androidx.compose.foundation.layout.widthIn
16+ import androidx.compose.foundation.shape.CircleShape
17+ import androidx.compose.runtime.Composable
18+ import androidx.compose.runtime.getValue
19+ import androidx.compose.runtime.mutableStateOf
20+ import androidx.compose.runtime.saveable.rememberSaveable
21+ import androidx.compose.runtime.setValue
22+ import androidx.compose.ui.Alignment
23+ import androidx.compose.ui.Modifier
24+ import androidx.compose.ui.draw.alpha
25+ import androidx.compose.ui.draw.dropShadow
26+ import androidx.compose.ui.draw.rotate
27+ import androidx.compose.ui.graphics.shadow.Shadow
28+ import androidx.compose.ui.text.font.FontWeight
29+ import androidx.compose.ui.unit.dp
30+ import androidx.compose.ui.unit.sp
31+ import cc.vectrum.code.data.AppState
32+ import cc.vectrum.code.data.HostState
33+ import cc.vectrum.code.net.RemoteInstance
34+ import cc.vectrum.code.net.RemoteModel
35+ import cc.vectrum.code.theme.Cap
36+ import cc.vectrum.code.theme.CapKind
37+ import cc.vectrum.code.theme.CapSize
38+ import cc.vectrum.code.theme.Led
39+ import cc.vectrum.code.theme.StatusLed
40+ import cc.vectrum.code.theme.Tag
41+ import cc.vectrum.code.theme.Txt
42+ import cc.vectrum.code.theme.parseHex
43+ import cc.vectrum.code.theme.palette
44+ import cc.vectrum.code.ui.Lucide
45+ import cc.vectrum.code.ui.LucideIcon
46+
47+ // The Providers page (src/ui/settings/providers.rs), read-only: each host's
48+ // agent instances with their status, account, plan and models. Editing
49+ // them isn't on the remote protocol, so it's done on the host.
50+
51+ /** The desktop's status headline for an instance, and its light. */
52+ private fun headline(instance: RemoteInstance): Pair<Led, String> = when (instance.status) {
53+ "disabled" -> Led.Off to "Disabled"
54+ "checking" -> Led.Waiting to "Checking provider status"
55+ "missing" -> Led.Failed to "Not found · CLI not detected on PATH."
56+ "failed" -> Led.Failed to "Unavailable"
57+ else -> {
58+ val first = when {
59+ instance.provider == "claude" && instance.account != null -> "Authenticated"
60+ instance.provider == "claude" -> "Not authenticated"
61+ instance.models.isEmpty() -> "Needs attention · No models configured"
62+ else -> "Available"
63+ }
64+ val led = if (first.startsWith("Not") || first.startsWith("Needs")) Led.Waiting else Led.Live
65+ led to listOfNotNull(first, instance.plan).joinToString(" · ")
66+ }
67+ }
68+
69+ @Composable
70+ fun ProvidersPage(app: AppState) {
71+ val hosts = app.hosts.toList()
72+ if (hosts.isEmpty()) {
73+ Section("Providers", id = "providers") { row { EmptyRow("Pair with a desktop under Connections to see its providers.") } }
74+ return
75+ }
76+ for ((ix, host) in hosts.withIndex()) {
77+ HostProviders(host, single = hosts.size == 1, id = if (ix == 0) "providers" else null)
78+ }
79+ Note("Providers are set up on each environment. Add, sign in to or change them in Vectrum Code there.")
80+ }
81+
82+ @Composable
83+ private fun HostProviders(host: HostState, single: Boolean, id: String?) {
84+ val p = palette
85+ Section(
86+ if (single) "Providers" else host.name,
87+ id = id,
88+ icon = if (single) null else ({ LucideIcon(Lucide.Server, size = 14.dp, tint = p.ink2) }),
89+ headerAction = {
90+ Cap(
91+ label = "Check now",
92+ icon = Lucide.RefreshCw,
93+ kind = CapKind.Ghost,
94+ size = CapSize.Compact,
95+ enabled = host.connected,
96+ ) { host.refreshCatalog() }
97+ },
98+ ) {
99+ val instances = host.instances
100+ if (instances.isEmpty()) {
101+ row { EmptyRow(if (host.connected) "No providers." else "${host.name} isn't connected.") }
102+ }
103+ for (instance in instances) row(instance.id) { InstanceRow(instance) }
104+ }
105+ }
106+
107+ @Composable
108+ private fun InstanceRow(instance: RemoteInstance) {
109+ val p = palette
110+ var open by rememberSaveable(instance.id) { mutableStateOf(false) }
111+ val (led, text) = headline(instance)
112+ Column(Modifier.alpha(if (instance.status == "disabled") 0.6f else 1f)) {
113+ Row(
114+ Modifier
115+ .fillMaxWidth()
116+ .defaultMinSize(minHeight = 72.dp)
117+ .clickable(interactionSource = null, indication = null) { open = !open }
118+ .padding(horizontal = 16.dp, vertical = 12.dp),
119+ horizontalArrangement = Arrangement.spacedBy(12.dp),
120+ verticalAlignment = Alignment.CenterVertically,
121+ ) {
122+ InstanceIcon(instance)
123+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
124+ Txt(instance.name.ifBlank { instance.id }, color = p.ink, size = 14.sp, weight = FontWeight.Medium, maxLines = 1)
125+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
126+ StatusLed(led, pulse = instance.status == "checking")
127+ Txt(text, color = p.ink3, size = 12.sp, maxLines = 2)
128+ }
129+ }
130+ LucideIcon(Lucide.ChevronRight, Modifier.rotate(if (open) 90f else 0f), size = 16.dp, tint = p.ink3)
131+ }
132+ if (open) InstanceDetail(instance)
133+ }
134+ }
135+
136+ /** An instance's icon, with a dot of its accent color. */
137+ @Composable
138+ private fun InstanceIcon(instance: RemoteInstance) {
139+ val p = palette
140+ val accent = instance.accent?.trim()?.removePrefix("#")
141+ ?.takeIf { (it.length == 3 || it.length == 6) && it.all { c -> c.isDigit() || c.lowercaseChar() in 'a'..'f' } }
142+ ?.let { parseHex("#$it") }
143+ Box {
144+ ProviderIcon(instance.provider, size = 20.dp)
145+ if (accent != null) {
146+ Box(
147+ Modifier
148+ .align(Alignment.BottomEnd)
149+ .offset(3.dp, 3.dp)
150+ .size(8.dp)
151+ .dropShadow(CircleShape, Shadow(radius = 0.dp, color = p.sHi, spread = 1.5.dp))
152+ .background(accent, CircleShape),
153+ )
154+ }
155+ }
156+ }
157+
158+ @Composable
159+ private fun InstanceDetail(instance: RemoteInstance) {
160+ val p = palette
161+ Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
162+ val account = when {
163+ instance.status == "disabled" -> "Turn the provider on to check its account."
164+ instance.provider == "claude" && instance.account != null -> "Signed in as ${instance.account}"
165+ instance.provider == "claude" -> "Not signed in. Run claude in a terminal and log in, then refresh."
166+ instance.provider == "pi" -> "Pi signs in to model providers itself: run pi in a terminal and use /login."
167+ else -> null
168+ }
169+ if (account != null) Txt(account, Modifier.padding(bottom = 6.dp), color = p.ink3, size = 12.sp, lineHeight = 17.sp)
170+ val shown = instance.models.filter { !it.hidden }
171+ val hidden = instance.models.filter { it.hidden }
172+ if (instance.models.isEmpty()) {
173+ Txt(
174+ when (instance.status) {
175+ "checking" -> "Asking for its models…"
176+ "disabled" -> "Turn the provider on to list its models."
177+ else -> "No models reported."
178+ },
179+ color = p.ink3,
180+ size = 12.5.sp,
181+ )
182+ }
183+ ModelGroup("Models", shown, instance.defaultModel)
184+ ModelGroup("Hidden from picker", hidden, instance.defaultModel)
185+ }
186+ }
187+
188+ @Composable
189+ private fun ModelGroup(title: String, models: List<RemoteModel>, default: String?) {
190+ if (models.isEmpty()) return
191+ val p = palette
192+ Txt(title, Modifier.padding(top = 8.dp, bottom = 4.dp), color = p.ink3, size = 11.5.sp, weight = FontWeight.Medium)
193+ for (model in models) {
194+ val capabilities = listOfNotNull(
195+ "Fast mode".takeIf { model.fastMode },
196+ "Thinking".takeIf { model.efforts.isNotEmpty() },
197+ "Legacy".takeIf { model.legacy },
198+ )
199+ Row(
200+ Modifier.fillMaxWidth().height(32.dp),
201+ horizontalArrangement = Arrangement.spacedBy(8.dp),
202+ verticalAlignment = Alignment.CenterVertically,
203+ ) {
204+ Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
205+ Txt(model.name, Modifier.weight(1f, fill = false), color = if (model.hidden) p.ink3 else p.ink, size = 13.sp, maxLines = 1)
206+ if (model.name != model.id) Code(model.id, Modifier.widthIn(max = 140.dp))
207+ if (model.id == default) Tag("Default")
208+ }
209+ if (capabilities.isNotEmpty()) Txt(capabilities.joinToString(" · "), color = p.ink3, size = 11.5.sp, maxLines = 1)
210+ }
211+ }
212+ }
+97−0android/app/src/main/java/cc/vectrum/code/ui/settings/Rows.ktaddedView File
@@ -0,0 +1,97 @@
1+ package cc.vectrum.code.ui.settings
2+
3+ import androidx.compose.runtime.Composable
4+ import cc.vectrum.code.data.DevicePrefs
5+ import kotlinx.serialization.json.JsonPrimitive
6+
7+ // Rows for one setting each: a preference of this phone (DevicePrefs, under
8+ // the desktop's keys), or an environment setting of the hosts in scope.
9+
10+ /** A switch for one of this phone's preferences. */
11+ @Composable
12+ fun PrefSwitch(prefs: DevicePrefs, key: String, title: String, description: String, id: String? = null) {
13+ SettingRow(
14+ title,
15+ id = id,
16+ description = description,
17+ inline = true,
18+ onReset = if (prefs.changed(key)) ({ prefs.reset(key) }) else null,
19+ ) { Switch(prefs.bool(key)) { prefs.set(key, it) } }
20+ }
21+
22+ /** A choice of one of this phone's preferences, by the desktop's spellings of its values. */
23+ @Composable
24+ fun PrefChoice(
25+ prefs: DevicePrefs,
26+ key: String,
27+ title: String,
28+ description: String,
29+ options: List<Pair<String, String>>,
30+ id: String? = null,
31+ ) {
32+ SettingRow(
33+ title,
34+ id = id,
35+ description = description,
36+ onReset = if (prefs.changed(key)) ({ prefs.reset(key) }) else null,
37+ ) { Choice(prefs.string(key), options) { prefs.set(key, it) } }
38+ }
39+
40+ /** A choice between two labels for one of this phone's on/off preferences. */
41+ @Composable
42+ fun PrefBoolChoice(
43+ prefs: DevicePrefs,
44+ key: String,
45+ title: String,
46+ description: String,
47+ options: List<Pair<Boolean, String>>,
48+ id: String? = null,
49+ ) {
50+ SettingRow(
51+ title,
52+ id = id,
53+ description = description,
54+ onReset = if (prefs.changed(key)) ({ prefs.reset(key) }) else null,
55+ ) { Choice(prefs.bool(key), options) { prefs.set(key, it) } }
56+ }
57+
58+ /** A switch for an environment setting. */
59+ @Composable
60+ fun EnvSwitch(
61+ shown: Shown,
62+ key: String,
63+ title: String,
64+ description: String,
65+ id: String? = null,
66+ onChange: ((Boolean) -> Unit)? = null,
67+ ) {
68+ SettingRow(
69+ title,
70+ id = id,
71+ description = description,
72+ inline = true,
73+ scope = shown.row(key),
74+ onReset = shown.reset(key),
75+ ) { Switch(shown.bool(key)) { on -> onChange?.invoke(on) ?: shown.set(key, JsonPrimitive(on)) } }
76+ }
77+
78+ /** A choice of an environment setting, by the desktop's spellings of its values. */
79+ @Composable
80+ fun EnvChoice(
81+ shown: Shown,
82+ key: String,
83+ title: String,
84+ description: String,
85+ options: List<Pair<String, String>>,
86+ id: String? = null,
87+ status: String? = null,
88+ ) {
89+ SettingRow(
90+ title,
91+ id = id,
92+ description = description,
93+ status = status,
94+ scope = shown.row(key),
95+ onReset = shown.reset(key),
96+ ) { Choice(shown.string(key) ?: "", options) { shown.set(key, JsonPrimitive(it)) } }
97+ }
+318−0android/app/src/main/java/cc/vectrum/code/ui/settings/Scope.ktaddedView File
@@ -0,0 +1,318 @@
1+ package cc.vectrum.code.ui.settings
2+
3+ import androidx.compose.foundation.layout.Arrangement
4+ import androidx.compose.foundation.layout.ExperimentalLayoutApi
5+ import androidx.compose.foundation.layout.FlowRow
6+ import androidx.compose.foundation.layout.Row
7+ import androidx.compose.foundation.layout.padding
8+ import androidx.compose.runtime.Composable
9+ import androidx.compose.runtime.getValue
10+ import androidx.compose.runtime.mutableStateOf
11+ import androidx.compose.runtime.remember
12+ import androidx.compose.runtime.setValue
13+ import androidx.compose.ui.Alignment
14+ import androidx.compose.ui.Modifier
15+ import androidx.compose.ui.unit.dp
16+ import androidx.compose.ui.unit.sp
17+ import cc.vectrum.code.data.AppState
18+ import cc.vectrum.code.data.HostState
19+ import cc.vectrum.code.net.SettingsPatch
20+ import cc.vectrum.code.theme.Badge
21+ import cc.vectrum.code.theme.CapKind
22+ import cc.vectrum.code.theme.Txt
23+ import cc.vectrum.code.theme.palette
24+ import cc.vectrum.code.ui.Lucide
25+ import cc.vectrum.code.ui.LucideIcon
26+ import cc.vectrum.code.ui.MenuItem
27+ import cc.vectrum.code.ui.MenuRow
28+ import cc.vectrum.code.ui.MenuSeparator
29+ import cc.vectrum.code.util.initials
30+ import cc.vectrum.code.util.tintFor
31+ import kotlinx.serialization.json.JsonElement
32+ import kotlinx.serialization.json.JsonNull
33+ import kotlinx.serialization.json.JsonObject
34+ import kotlinx.serialization.json.JsonPrimitive
35+ import kotlinx.serialization.json.booleanOrNull
36+ import kotlinx.serialization.json.intOrNull
37+ import kotlinx.serialization.json.jsonPrimitive
38+
39+ // The desktop's settings scope (src/ui/settings/scope.rs): "Applying
40+ // settings for <project> across <environment>" at the top of the pages with
41+ // environment settings. Those live on the hosts; the phone edits one host or
42+ // all of them at once, or a project's overrides there.
43+
44+ /** The settings a host keeps for itself, with the desktop's defaults (src/settings.rs). */
45+ val EnvironmentDefaults: Map<String, JsonElement> = mapOf(
46+ "default_model" to JsonNull,
47+ "default_reasoning" to JsonNull,
48+ "default_access" to JsonNull,
49+ "workspace" to JsonPrimitive("local"),
50+ "submodules" to JsonPrimitive("recursive"),
51+ "start_from_origin" to JsonPrimitive(true),
52+ "branch_naming" to JsonPrimitive("static"),
53+ "branch_prefix" to JsonPrimitive("vectrum"),
54+ "branch_instructions" to JsonPrimitive(""),
55+ "writing_style" to JsonPrimitive("repo_conventions"),
56+ "writing_instructions" to JsonPrimitive(""),
57+ "follow_pr_templates" to JsonPrimitive(true),
58+ "worktrees_dir" to JsonPrimitive(""),
59+ "cleanup_deleted_worktrees" to JsonPrimitive(false),
60+ "cleanup_inactive_worktrees" to JsonNull,
61+ "cleanup_merged_worktrees" to JsonPrimitive(false),
62+ "cleanup_unchanged_worktrees" to JsonPrimitive(false),
63+ "text_model" to JsonPrimitive("claude:haiku"),
64+ "text_reasoning" to JsonPrimitive("low"),
65+ "auto_resume_limited" to JsonPrimitive(false),
66+ "snooze_limited" to JsonPrimitive(false),
67+ "auto_settle_merged" to JsonPrimitive(true),
68+ "auto_settle_inactive" to JsonPrimitive(false),
69+ "inactive_days" to JsonPrimitive(3),
70+ "continue_after_restart" to JsonPrimitive(false),
71+ "update_checks" to JsonPrimitive(true),
72+ "agent_browser" to JsonPrimitive(true),
73+ )
74+
75+ /** The environment settings a project can override. */
76+ val ProjectKeys = setOf(
77+ "default_model", "default_reasoning", "default_access", "workspace", "submodules", "start_from_origin",
78+ "branch_naming", "branch_prefix", "branch_instructions", "writing_style", "writing_instructions",
79+ "follow_pr_templates", "cleanup_inactive_worktrees", "cleanup_merged_worktrees", "cleanup_unchanged_worktrees",
80+ "text_model", "text_reasoning", "auto_settle_merged", "auto_settle_inactive", "inactive_days", "agent_browser",
81+ )
82+
83+ /** Which hosts and project the environment settings edit; kept as pages change. */
84+ class SettingsScope {
85+ /** One host by id, or `null` for all of them. */
86+ var host by mutableStateOf<String?>(null)
87+
88+ /** A project by name: the same project on every host. */
89+ var project by mutableStateOf<String?>(null)
90+ }
91+
92+ /** One place a change is written: a host, and the project's path there when a project is picked. */
93+ class WriteTarget(val host: HostState, val project: String?) {
94+ /** The host's settings as this target sees them: defaults, the host's values, the project's overrides. */
95+ val values: Map<String, JsonElement> by lazy {
96+ val raw = host.settings
97+ val overrides = project?.let { (raw["project_overrides"] as? JsonObject)?.get(it) as? JsonObject }
98+ EnvironmentDefaults + raw.filterKeys { it in EnvironmentDefaults } + (overrides ?: emptyMap())
99+ }
100+
101+ val overrides: Set<String>
102+ get() = project?.let { ((host.settings["project_overrides"] as? JsonObject)?.get(it) as? JsonObject)?.keys } ?: emptySet()
103+ }
104+
105+ /** What the scope shows: the values the controls display, where the hosts disagree, what's locked. */
106+ class Shown(
107+ val app: AppState,
108+ val targets: List<WriteTarget>,
109+ /** Why environment settings can't change right now. */
110+ val locked: String?,
111+ val project: String?,
112+ ) {
113+ private val first: Map<String, JsonElement> =
114+ targets.firstOrNull()?.values
115+ ?: (EnvironmentDefaults + (app.hosts.firstOrNull { it.settings.isNotEmpty() }?.settings ?: emptyMap()).filterKeys { it in EnvironmentDefaults })
116+
117+ fun value(key: String): JsonElement = first[key] ?: EnvironmentDefaults[key] ?: JsonNull
118+
119+ fun string(key: String): String? = value(key).let { if (it is JsonNull) null else it.jsonPrimitive.content }
120+
121+ fun bool(key: String): Boolean = (value(key) as? JsonPrimitive)?.booleanOrNull ?: false
122+
123+ fun int(key: String): Int? = (value(key) as? JsonPrimitive)?.intOrNull
124+
125+ /** Whether any of [keys] differs from the desktop's default. */
126+ fun changed(vararg keys: String): Boolean = keys.any { value(it) != EnvironmentDefaults[it] }
127+
128+ /** The instances of the first host shown, for the model pickers. */
129+ val host: HostState? get() = targets.firstOrNull()?.host ?: app.hosts.firstOrNull { it.connected }
130+
131+ /** How a row showing [keys] appears. */
132+ fun row(vararg keys: String): Scoped {
133+ val overridable = keys.all { it in ProjectKeys }
134+ val lock = locked ?: if (project != null && !overridable) "Applies to the whole environment. Pick All projects to change it." else null
135+ val mixed = keys.flatMap { key ->
136+ val values = targets.map { it.values[key] ?: JsonNull }
137+ if (values.distinct().size > 1) targets.zip(values).map { (target, value) -> target.host.name to describe(value) } else emptyList()
138+ }
139+ return Scoped(
140+ locked = lock,
141+ mixed = mixed.ifEmpty { null },
142+ project = project != null && overridable,
143+ overridden = keys.any { key -> targets.any { key in it.overrides } },
144+ )
145+ }
146+
147+ /** Writes [values] to every target: the host's settings, or the project's overrides there. */
148+ fun set(values: Map<String, JsonElement>) {
149+ if (locked != null) return
150+ for (target in targets) {
151+ val patch = when (val path = target.project) {
152+ null -> SettingsPatch(values = JsonObject(values))
153+ else -> SettingsPatch(projects = mapOf(path to JsonObject(values.filterKeys { it in ProjectKeys })))
154+ }
155+ target.host.updateSettings(patch)
156+ }
157+ }
158+
159+ fun set(key: String, value: JsonElement) = set(mapOf(key to value))
160+
161+ /** Lets the picked project follow its hosts again for [keys]. */
162+ fun inherit(vararg keys: String) {
163+ for (target in targets) {
164+ val path = target.project ?: continue
165+ target.host.updateSettings(SettingsPatch(projects = mapOf(path to JsonObject(keys.associateWith { JsonNull }))))
166+ }
167+ }
168+
169+ /** The reset a row showing [keys] gets: back to the defaults, or to the hosts' value for a project. */
170+ fun reset(vararg keys: String): (() -> Unit)? {
171+ val row = row(*keys)
172+ return when {
173+ row.locked != null -> null
174+ row.project -> if (row.overridden) ({ inherit(*keys) }) else null
175+ changed(*keys) -> ({ set(keys.associateWith { EnvironmentDefaults[it] ?: JsonNull }) })
176+ else -> null
177+ }
178+ }
179+ }
180+
181+ /** A setting's value in a few words, for saying how the hosts differ. */
182+ private fun describe(value: JsonElement): String = when {
183+ value is JsonNull -> "Automatic"
184+ value is JsonPrimitive && value.booleanOrNull == true -> "On"
185+ value is JsonPrimitive && value.booleanOrNull == false -> "Off"
186+ value is JsonPrimitive && value.isString && value.content.isEmpty() -> "Empty"
187+ value is JsonPrimitive && value.isString -> value.content.replace('_', ' ').replaceFirstChar { it.uppercase() }
188+ else -> value.toString()
189+ }
190+
191+ /** Works out what [scope] shows, afresh on each composition. */
192+ fun shown(app: AppState, scope: SettingsScope): Shown {
193+ val project = scope.project
194+ val pick = scope.host
195+ val selected: List<HostState>
196+ var locked: String? = null
197+ if (pick == null) {
198+ selected = app.hosts.filter { it.connected && it.settings.isNotEmpty() }
199+ } else {
200+ val host = app.host(pick)
201+ selected = when {
202+ host == null -> {
203+ locked = "That environment is no longer connected."
204+ emptyList()
205+ }
206+ !host.connected -> {
207+ locked = "${host.name} is offline; its settings stay as they are."
208+ emptyList()
209+ }
210+ host.settings.isEmpty() -> {
211+ locked = "${host.name} doesn't share its settings yet. Update the app there to edit them here."
212+ emptyList()
213+ }
214+ else -> listOf(host)
215+ }
216+ }
217+ val targets = selected.flatMap { host ->
218+ if (project == null) listOf(WriteTarget(host, null))
219+ else host.projects.filter { it.name == project }.map { WriteTarget(host, it.path) }
220+ }
221+ if (targets.isEmpty() && locked == null) {
222+ locked = when {
223+ app.hosts.isEmpty() -> "Pair with a desktop under Connections to edit its settings here."
224+ project != null -> "$project isn't a project on the selected environments."
225+ else -> "No connected environment shares its settings."
226+ }
227+ }
228+ return Shown(app, targets, locked, project)
229+ }
230+
231+ /** "Applying settings for <project> across <environment>". */
232+ @OptIn(ExperimentalLayoutApi::class)
233+ @Composable
234+ fun ScopeSentence(app: AppState, scope: SettingsScope) {
235+ val p = palette
236+ val hosts = app.hosts.toList()
237+ val projects = hosts.flatMap { host -> host.projects.map { it.name } }.distinct()
238+ val single = hosts.size < 2
239+ val picked = scope.host?.let { id -> hosts.find { it.saved.id == id } }
240+ val projectLabel = when (val name = scope.project) {
241+ null -> "All projects"
242+ in projects -> name
243+ else -> "Unavailable project"
244+ }
245+ val environmentLabel = when {
246+ scope.host != null -> picked?.name ?: "Unavailable environment"
247+ single -> hosts.firstOrNull()?.name ?: "No environment"
248+ else -> "All environments"
249+ }
250+ val connective = if (scope.host != null || single) "on" else "across"
251+ var projectOpen by remember { mutableStateOf(false) }
252+ var hostOpen by remember { mutableStateOf(false) }
253+ FlowRow(
254+ Modifier.padding(horizontal = 16.dp),
255+ horizontalArrangement = Arrangement.spacedBy(6.dp),
256+ verticalArrangement = Arrangement.spacedBy(4.dp),
257+ itemVerticalAlignment = Alignment.CenterVertically,
258+ ) {
259+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
260+ Txt("Applying settings for", color = p.ink3, size = 14.sp, maxLines = 1)
261+ val badge = scope.project?.takeIf { it in projects }
262+ Dropdown(
263+ projectOpen,
264+ { projectOpen = it },
265+ label = projectLabel,
266+ kind = CapKind.Ghost,
267+ minWidth = 220.dp,
268+ leading = if (badge != null) { { Badge(initials(badge), tintFor(badge)) } } else null,
269+ ) {
270+ MenuRow("All projects", selected = scope.project == null) {
271+ projectOpen = false
272+ scope.project = null
273+ }
274+ if (projects.isNotEmpty()) MenuSeparator()
275+ for (name in projects) {
276+ MenuItem(selected = scope.project == name, onClick = {
277+ projectOpen = false
278+ scope.project = name
279+ }) {
280+ Badge(initials(name), tintFor(name))
281+ Txt(name, color = p.ink, size = 13.sp, maxLines = 1)
282+ }
283+ }
284+ }
285+ }
286+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
287+ Txt(connective, color = p.ink3, size = 14.sp, maxLines = 1)
288+ Dropdown(
289+ hostOpen,
290+ { hostOpen = it },
291+ label = environmentLabel,
292+ icon = if (scope.host != null || single) Lucide.Server else null,
293+ iconColor = p.ink2,
294+ kind = CapKind.Ghost,
295+ minWidth = 240.dp,
296+ ) {
297+ if (hosts.size > 1) {
298+ MenuRow("All environments", icon = Lucide.Layers, selected = scope.host == null) {
299+ hostOpen = false
300+ scope.host = null
301+ }
302+ MenuSeparator()
303+ }
304+ for (host in hosts) {
305+ val checked = scope.host == host.saved.id || (hosts.size == 1 && scope.host == null)
306+ MenuItem(selected = checked, onClick = {
307+ hostOpen = false
308+ scope.host = host.saved.id
309+ }) {
310+ LucideIcon(Lucide.Server, size = 13.dp, tint = p.ink2)
311+ Txt(host.name, Modifier.weight(1f), color = p.ink, size = 13.sp, maxLines = 1)
312+ if (!host.connected) Txt("Offline", color = p.ink3, size = 11.5.sp)
313+ }
314+ }
315+ }
316+ }
317+ }
318+ }
+121−0android/app/src/main/java/cc/vectrum/code/ui/settings/Search.ktaddedView File
@@ -0,0 +1,121 @@
1+ package cc.vectrum.code.ui.settings
2+
3+ import cc.vectrum.code.ui.Lucide
4+
5+ // The settings search (src/ui/settings/search.rs): every searchable row, its
6+ // page and the words people may remember it by, ranked like T3 Code's.
7+
8+ /** The settings pages a phone has, in the desktop's order. */
9+ enum class SettingsPage(val key: String, val label: String, val icon: Lucide) {
10+ General("general", "General", Lucide.Settings2),
11+ Appearance("appearance", "Appearance", Lucide.Palette),
12+ Providers("providers", "Providers", Lucide.Bot),
13+ SourceControl("source-control", "Source Control", Lucide.GitBranch),
14+ Storage("storage", "Storage", Lucide.HardDrive),
15+ Connections("connections", "Connections", Lucide.Link2),
16+ Archive("archive", "Archive", Lucide.Archive),
17+ About("about", "About", Lucide.Info),
18+ ;
19+
20+ /** Whether the page edits environment settings, and so shows the scope sentence. */
21+ val scoped: Boolean get() = this == General || this == SourceControl || this == Storage
22+
23+ companion object {
24+ /** The page a `Screen.Settings` names: `general`, `source-control` or `Source Control`. */
25+ fun find(name: String): SettingsPage? {
26+ val wanted = name.trim().lowercase().replace('_', '-').replace(' ', '-')
27+ return entries.find { it.key == wanted || it.name.lowercase() == wanted.replace("-", "") }
28+ }
29+ }
30+ }
31+
32+ /** One search result: a row (or section) the search can jump to. */
33+ data class SearchItem(val id: String, val title: String, val page: SettingsPage, val terms: String)
34+
35+ private fun item(id: String, title: String, page: SettingsPage, terms: String) = SearchItem(id, title, page, terms)
36+
37+ /** Searchable settings, in result order. */
38+ val SearchItems: List<SearchItem> = listOf(
39+ item("storage-worktrees-location", "Worktree location", SettingsPage.Storage, "worktree location folder directory path drive external disk"),
40+ item("storage-worktrees-deleted", "Delete worktrees with deleted threads", SettingsPage.Storage, "worktree cleanup remove delete thread automatic"),
41+ item("storage-worktrees-inactive", "Delete inactive worktrees", SettingsPage.Storage, "worktree cleanup remove inactive days retention automatic"),
42+ item("storage-worktrees-merged", "Delete merged worktrees", SettingsPage.Storage, "worktree cleanup remove merged pull request automatic"),
43+ item("storage-worktrees-unchanged", "Delete unchanged worktrees", SettingsPage.Storage, "worktree cleanup remove unchanged commits default branch automatic"),
44+ item("default-model", "Default model", SettingsPage.General, "new thread project provider reasoning effort"),
45+ item("default-permissions", "Permissions", SettingsPage.General, "new thread default runtime mode supervised approvals auto accept edits full access plan"),
46+ item("color-scheme", "Color scheme", SettingsPage.Appearance, "appearance light dark system mode"),
47+ item("theme", "Themes", SettingsPage.Appearance, "appearance colors palette bone graphite tokyo night catppuccin github nord gruvbox rose pine solarized dracula one"),
48+ item("diff-color-scheme", "Diff colors", SettingsPage.Appearance, "red green blue orange additions deletions changes counts palette colorblind"),
49+ item("composer-context", "Composer context", SettingsPage.Appearance, "branch worktree controls strip below composer"),
50+ item("snooze-limited-threads", "Snooze limited threads", SettingsPage.General, "usage quota rate limit reset wake recover continue"),
51+ item("auto-resume-limited-threads", "Auto-resume limited threads", SettingsPage.General, "usage quota rate limit reset recover continue"),
52+ item("working-shelf", "Working section (beta)", SettingsPage.General, "hide fold running monitoring threads inbox sidebar shelf"),
53+ item("auto-settle-inactive-threads", "Auto-settle inactive threads", SettingsPage.General, "sidebar inactivity days no activity automatically"),
54+ item("auto-settle-merged-threads", "Auto-settle merged threads", SettingsPage.General, "pull request merge closed automatically sidebar"),
55+ item("days-before-auto-settle", "Days of inactivity before auto-settle", SettingsPage.General, "thread timeout activity sidebar"),
56+ item("time-format", "Time format", SettingsPage.General, "timestamp clock locale system os 12 hour 24 hour"),
57+ item("response-streaming", "Response streaming", SettingsPage.General, "output token paragraph buffered wait turn"),
58+ item("fold-turns", "Fold finished turns", SettingsPage.General, "worked for tool calls collapse transcript"),
59+ item("show-costs", "Show costs", SettingsPage.General, "price dollars spend turn thread"),
60+ item("hide-whitespace-changes", "Hide whitespace changes", SettingsPage.General, "diff ignore spaces edits default"),
61+ item("default-diff-file-state", "Default diff file state", SettingsPage.General, "collapsed expanded collapse expand files"),
62+ item("diff-layout", "Diff layout", SettingsPage.General, "stacked split side by side unified inline view"),
63+ item("skills-in-slash-menu", "Show skills in slash menu", SettingsPage.General, "command menu dollar $ slash /"),
64+ item("inline-ui", "Inline diagrams and UI", SettingsPage.General, "mermaid openui charts tables forms svg html preview artifacts render answers"),
65+ item("follow-up-behavior", "Follow-up behavior", SettingsPage.General, "queue steer running turn send default behavior composer"),
66+ item("provider-update-checks", "Provider update checks", SettingsPage.General, "installed cli versions newer available claude pi jcode"),
67+ item("continue-threads-after-server-update", "Continue threads after restarts", SettingsPage.General, "resume running active interrupted work restart reboot machine crash update automatically"),
68+ item("new-threads", "Workspace", SettingsPage.General, "new threads default workspace mode local worktree checkout"),
69+ item("worktree-submodules", "Submodules", SettingsPage.General, "git submodule init recursive top-level none worktree"),
70+ item("start-from-origin", "Start from origin", SettingsPage.General, "new worktrees latest matching remote branch local"),
71+ item("unpin-confirmation", "Unpin confirmation", SettingsPage.General, "ask before thread pinned section"),
72+ item("archive-confirmation", "Archive confirmation", SettingsPage.General, "ask before thread second click inline action settle"),
73+ item("delete-confirmation", "Delete confirmation", SettingsPage.General, "ask before thread chat history"),
74+ item("text-generation-model", "Text generation model", SettingsPage.General, "generated thread titles default provider haiku"),
75+ item("legacy-plan-mode", "Plan mode (legacy)", SettingsPage.General, "build plan composer old shift tab"),
76+ item("legacy-context-window-indicator", "Context window indicator (legacy)", SettingsPage.General, "composer meter usage tokens circle old"),
77+ item("providers", "Providers", SettingsPage.Providers, "agents cli claude pi jcode sign in install subscription instances authentication models"),
78+ item("worktree-branch-naming", "Worktree branch naming", SettingsPage.SourceControl, "static semantic prefix custom prompt instructions feat fix refactor chore"),
79+ item("source-control-writing-style", "Source control writing style", SettingsPage.SourceControl, "commit message pull request repository conventions conventional commits custom instructions"),
80+ item("follow-change-request-templates", "Follow change request templates", SettingsPage.SourceControl, "pull request template description markdown"),
81+ item("branch-prefix", "Branch prefix", SettingsPage.SourceControl, "worktree branch name prefix vectrum"),
82+ item("branch-naming-instructions", "Branch naming instructions", SettingsPage.SourceControl, "worktree branch custom prompt model"),
83+ item("connections-device", "This device", SettingsPage.Connections, "connections phone client remote access"),
84+ item("remote-environments", "Environments", SettingsPage.Connections, "add pair backend host code devices remote connect qr scan"),
85+ item("archive", "Archived threads", SettingsPage.Archive, "restore reopen settled deleted history projects unarchive"),
86+ item("version", "Version", SettingsPage.About, "about release update vectrum code"),
87+ item("open-source-licenses", "Open source licenses", SettingsPage.About, "notices dependencies fonts geist ofl mit apache"),
88+ )
89+
90+ /** Lowercases and keeps letters, digits and spaces: T3 Code's `normalizeSearchText`. */
91+ private fun normalize(text: String): String =
92+ text.map { if (it.isLetterOrDigit() || it == '$' || it == '/' || it == '+') it.lowercaseChar() else ' ' }
93+ .joinToString("")
94+ .split(' ')
95+ .filter { it.isNotEmpty() }
96+ .joinToString(" ")
97+
98+ /**
99+ * The items matching [query], best first: every word must appear in the
100+ * title, the page name or the terms; exact and leading title matches rank
101+ * highest.
102+ */
103+ fun searchSettings(query: String, items: List<SearchItem> = SearchItems): List<SearchItem> {
104+ val wanted = normalize(query)
105+ if (wanted.isEmpty()) return emptyList()
106+ val tokens = wanted.split(' ')
107+ return items.withIndex().mapNotNull { (index, item) ->
108+ val title = normalize(item.title)
109+ val fields = listOf(title, normalize(item.page.label), normalize(item.terms))
110+ if (!tokens.all { token -> fields.any { it.contains(token) } }) return@mapNotNull null
111+ val rank = when {
112+ title == wanted -> 5
113+ title.startsWith(wanted) -> 4
114+ title.contains(wanted) -> 3
115+ tokens.all { title.contains(it) } -> 2
116+ fields.any { it.contains(wanted) } -> 1
117+ else -> 0
118+ }
119+ Triple(index, rank, item)
120+ }.sortedWith(compareByDescending<Triple<Int, Int, SearchItem>> { it.second }.thenBy { it.first }).map { it.third }
121+ }
+350−4android/app/src/main/java/cc/vectrum/code/ui/settings/SettingsScreen.ktView File
@@ -1,19 +1,365 @@
11 package cc.vectrum.code.ui.settings
22
3+ import androidx.activity.compose.BackHandler
4+ import androidx.compose.foundation.Image
5+ import androidx.compose.foundation.ScrollState
6+ import androidx.compose.foundation.background
7+ import androidx.compose.foundation.clickable
8+ import androidx.compose.foundation.layout.Arrangement
39 import androidx.compose.foundation.layout.Box
10+ import androidx.compose.foundation.layout.BoxWithConstraints
11+ import androidx.compose.foundation.layout.Column
12+ import androidx.compose.foundation.layout.Row
13+ import androidx.compose.foundation.layout.Spacer
14+ import androidx.compose.foundation.layout.defaultMinSize
15+ import androidx.compose.foundation.layout.fillMaxHeight
16+ import androidx.compose.foundation.layout.fillMaxSize
17+ import androidx.compose.foundation.layout.fillMaxWidth
18+ import androidx.compose.foundation.layout.height
419 import androidx.compose.foundation.layout.padding
20+ import androidx.compose.foundation.layout.size
21+ import androidx.compose.foundation.layout.width
22+ import androidx.compose.foundation.layout.widthIn
23+ import androidx.compose.foundation.rememberScrollState
24+ import androidx.compose.foundation.shape.RoundedCornerShape
25+ import androidx.compose.foundation.text.BasicTextField
26+ import androidx.compose.foundation.text.KeyboardOptions
27+ import androidx.compose.foundation.verticalScroll
528 import androidx.compose.runtime.Composable
29+ import androidx.compose.runtime.CompositionLocalProvider
30+ import androidx.compose.runtime.LaunchedEffect
31+ import androidx.compose.runtime.getValue
32+ import androidx.compose.runtime.mutableIntStateOf
33+ import androidx.compose.runtime.mutableStateOf
34+ import androidx.compose.runtime.remember
35+ import androidx.compose.runtime.setValue
36+ import androidx.compose.ui.Alignment
637 import androidx.compose.ui.Modifier
38+ import androidx.compose.ui.draw.clip
39+ import androidx.compose.ui.graphics.ColorFilter
40+ import androidx.compose.ui.graphics.SolidColor
41+ import androidx.compose.ui.platform.LocalConfiguration
42+ import androidx.compose.ui.platform.LocalContext
43+ import androidx.compose.ui.res.painterResource
44+ import androidx.compose.ui.text.TextStyle
45+ import androidx.compose.ui.text.font.FontWeight
46+ import androidx.compose.ui.text.input.ImeAction
47+ import androidx.compose.ui.text.input.TextFieldValue
748 import androidx.compose.ui.unit.dp
49+ import androidx.compose.ui.unit.sp
50+ import cc.vectrum.code.R
851 import cc.vectrum.code.data.AppState
9- import cc.vectrum.code.data.OpenThread
10- import cc.vectrum.code.data.ThreadRef
52+ import cc.vectrum.code.theme.Cap
53+ import cc.vectrum.code.theme.CapKind
54+ import cc.vectrum.code.theme.CapSize
1155 import cc.vectrum.code.theme.Engraved
56+ import cc.vectrum.code.theme.Mono
57+ import cc.vectrum.code.theme.Rule
58+ import cc.vectrum.code.theme.Sans
59+ import cc.vectrum.code.theme.Tag
60+ import cc.vectrum.code.theme.Txt
61+ import cc.vectrum.code.theme.VRule
62+ import cc.vectrum.code.theme.palette
63+ import cc.vectrum.code.theme.raised
64+ import cc.vectrum.code.theme.well
65+ import cc.vectrum.code.ui.ConfirmDialog
66+ import cc.vectrum.code.ui.Lucide
67+ import cc.vectrum.code.ui.LucideIcon
1268 import cc.vectrum.code.ui.Nav
1369 import cc.vectrum.code.ui.Screen
70+ import kotlinx.coroutines.delay
71+
72+ // The settings view (src/ui/settings/mod.rs), laid out like T3 Code's: a
73+ // search field and the pages, then the open page under a "Settings / Page"
74+ // breadcrumb. On a phone the page list comes first and each page fills the
75+ // sheet; on a tablet they sit side by side, as on the desktop. Every change
76+ // is saved at once: this phone's preferences here, environment settings on
77+ // the hosts.
78+
79+ /** The widest a settings page grows (T3 Code's "readable" width). */
80+ private val ReadableWidth = 896.dp
81+
82+ /** Providers and Connections. */
83+ private val WideWidth = 1024.dp
84+
85+ /** The page list's width beside a page, on a tablet. */
86+ private val NavWidth = 260.dp
1487
15- // Placeholder until it's built.
1688 @Composable
1789 fun SettingsScreen(app: AppState, nav: Nav, page: String) {
18- Box(Modifier.padding(16.dp)) { Engraved("Settings") }
90+ val scope = remember { SettingsScope() }
91+ var query by remember { mutableStateOf(TextFieldValue("")) }
92+ var target by remember { mutableStateOf<Target?>(null) }
93+ var jumps by remember { mutableIntStateOf(0) }
94+ val current = SettingsPage.find(page)
95+
96+ // Let the pulse play out, then forget the target so it doesn't pulse again.
97+ LaunchedEffect(target) {
98+ if (target != null) {
99+ delay(1600)
100+ target = null
101+ }
102+ }
103+
104+ BoxWithConstraints(Modifier.fillMaxSize()) {
105+ val wide = maxWidth >= 720.dp
106+ val open = { next: SettingsPage? ->
107+ target = null
108+ nav.replace(Screen.Settings(next?.key ?: ""))
109+ }
110+ val jump = { item: SearchItem ->
111+ query = TextFieldValue("")
112+ jumps += 1
113+ target = Target(item.id, jumps)
114+ nav.replace(Screen.Settings(item.page.key))
115+ }
116+ // Back clears a search first, then leaves a page for the list.
117+ BackHandler(enabled = !nav.sidebarOpen && !nav.palette && (query.text.isNotEmpty() || (!wide && current != null))) {
118+ if (query.text.isNotEmpty()) query = TextFieldValue("") else open(null)
119+ }
120+ val list = @Composable { modifier: Modifier ->
121+ PageList(
122+ app, nav, if (wide) current ?: SettingsPage.General else null,
123+ query, { query = it }, open, jump, modifier,
124+ )
125+ }
126+ if (wide) {
127+ Row(Modifier.fillMaxSize()) {
128+ list(Modifier.width(NavWidth).fillMaxHeight())
129+ Spacer(Modifier.width(1.dp).fillMaxHeight().background(palette.rule))
130+ PageView(app, nav, current ?: SettingsPage.General, scope, target, back = null, open = open, modifier = Modifier.weight(1f))
131+ }
132+ } else if (current == null) {
133+ list(Modifier.fillMaxSize())
134+ } else {
135+ PageView(app, nav, current, scope, target, back = { open(null) }, open = open, modifier = Modifier.fillMaxSize())
136+ }
137+ }
138+ }
139+
140+ /** The pages and the search over them, with the way back to the threads. */
141+ @Composable
142+ private fun PageList(
143+ app: AppState,
144+ nav: Nav,
145+ selected: SettingsPage?,
146+ query: TextFieldValue,
147+ onQuery: (TextFieldValue) -> Unit,
148+ open: (SettingsPage?) -> Unit,
149+ jump: (SearchItem) -> Unit,
150+ modifier: Modifier,
151+ ) {
152+ val p = palette
153+ val context = LocalContext.current
154+ val drawer = LocalConfiguration.current.screenWidthDp < 840
155+ val searching = query.text.isNotBlank()
156+ val results = searchSettings(query.text)
157+ Column(modifier) {
158+ Row(
159+ Modifier.fillMaxWidth().height(52.dp).padding(start = if (drawer) 8.dp else 16.dp, end = 16.dp),
160+ horizontalArrangement = Arrangement.spacedBy(8.dp),
161+ verticalAlignment = Alignment.CenterVertically,
162+ ) {
163+ if (drawer) Cap(icon = Lucide.PanelLeftOpen, kind = CapKind.Ghost, size = CapSize.Compact) { nav.sidebarOpen = true }
164+ Image(
165+ painterResource(R.drawable.vectrum_logo),
166+ contentDescription = null,
167+ modifier = Modifier.size(18.dp),
168+ colorFilter = ColorFilter.tint(p.ink),
169+ )
170+ Txt("Vectrum", color = p.ink, size = 15.sp, weight = FontWeight.SemiBold, maxLines = 1)
171+ Tag("Settings")
172+ }
173+ Column(
174+ Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
175+ verticalArrangement = Arrangement.spacedBy(3.dp),
176+ ) {
177+ Box(Modifier.padding(bottom = 6.dp)) { SearchField(query, onQuery) }
178+ when {
179+ searching && results.isEmpty() -> Box(Modifier.fillMaxWidth().padding(vertical = 24.dp), contentAlignment = Alignment.Center) {
180+ Txt("No settings found", color = p.ink3, size = 12.sp)
181+ }
182+ searching -> results.forEach { item -> ResultRow(item) { jump(item) } }
183+ else -> SettingsPage.entries.forEach { page ->
184+ val count = when (page) {
185+ SettingsPage.Archive -> settledCount(app).takeIf { it > 0 }?.toString()
186+ else -> null
187+ }
188+ PageRow(page, page == selected, count) { open(page) }
189+ }
190+ }
191+ }
192+ Row(
193+ Modifier.fillMaxWidth().height(48.dp).padding(horizontal = 12.dp),
194+ horizontalArrangement = Arrangement.spacedBy(8.dp),
195+ verticalAlignment = Alignment.CenterVertically,
196+ ) {
197+ Cap(label = "Back", icon = Lucide.ArrowLeft, kind = CapKind.Ghost, size = CapSize.Compact) {
198+ if (!nav.back()) nav.home()
199+ }
200+ Spacer(Modifier.weight(1f))
201+ Engraved("v${appVersion(context)}")
202+ }
203+ }
204+ }
205+
206+ @Composable
207+ private fun SearchField(query: TextFieldValue, onQuery: (TextFieldValue) -> Unit) {
208+ val p = palette
209+ Row(
210+ Modifier.fillMaxWidth().height(36.dp).well(p, RoundedCornerShape(12.dp)).padding(start = 10.dp, end = 4.dp),
211+ horizontalArrangement = Arrangement.spacedBy(8.dp),
212+ verticalAlignment = Alignment.CenterVertically,
213+ ) {
214+ LucideIcon(Lucide.Search, size = 15.dp, tint = p.ink3)
215+ Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
216+ if (query.text.isEmpty()) Txt("Search", color = p.fieldInk, size = 13.5.sp, maxLines = 1)
217+ BasicTextField(
218+ value = query,
219+ onValueChange = onQuery,
220+ singleLine = true,
221+ textStyle = TextStyle(color = p.ink, fontSize = 13.5.sp, fontFamily = Sans),
222+ cursorBrush = SolidColor(p.ink),
223+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
224+ modifier = Modifier.fillMaxWidth(),
225+ )
226+ }
227+ if (query.text.isNotEmpty()) {
228+ Cap(icon = Lucide.X, kind = CapKind.Ghost, size = CapSize.Mini) { onQuery(TextFieldValue("")) }
229+ }
230+ }
231+ }
232+
233+ @Composable
234+ private fun PageRow(page: SettingsPage, selected: Boolean, count: String?, onClick: () -> Unit) {
235+ val p = palette
236+ val shape = RoundedCornerShape(12.dp)
237+ Row(
238+ Modifier
239+ .fillMaxWidth()
240+ .height(40.dp)
241+ .then(if (selected) Modifier.raised(p, shape) else Modifier)
242+ .clip(shape)
243+ .clickable(onClick = onClick)
244+ .padding(horizontal = 12.dp),
245+ horizontalArrangement = Arrangement.spacedBy(10.dp),
246+ verticalAlignment = Alignment.CenterVertically,
247+ ) {
248+ LucideIcon(page.icon, size = 15.dp, tint = if (selected) p.ink else p.ink2)
249+ Txt(page.label, Modifier.weight(1f), color = if (selected) p.ink else p.ink2, size = 13.5.sp, weight = FontWeight.Medium, maxLines = 1)
250+ if (count != null) Txt(count, color = p.ink3, size = 10.5.sp, family = Mono)
251+ else if (!selected) LucideIcon(Lucide.ChevronRight, size = 14.dp, tint = p.ink3.copy(alpha = 0.6f))
252+ }
253+ }
254+
255+ @Composable
256+ private fun ResultRow(item: SearchItem, onClick: () -> Unit) {
257+ val p = palette
258+ val shape = RoundedCornerShape(12.dp)
259+ Row(
260+ Modifier
261+ .fillMaxWidth()
262+ .defaultMinSize(minHeight = 44.dp)
263+ .clip(shape)
264+ .clickable(onClick = onClick)
265+ .padding(horizontal = 12.dp, vertical = 6.dp),
266+ horizontalArrangement = Arrangement.spacedBy(10.dp),
267+ ) {
268+ LucideIcon(item.page.icon, Modifier.padding(top = 2.dp), size = 14.dp, tint = p.ink3.copy(alpha = 0.8f))
269+ Column {
270+ Txt(item.title, color = p.ink, size = 13.5.sp, weight = FontWeight.Medium, maxLines = 1)
271+ Txt(item.page.label, color = p.ink3, size = 11.sp, maxLines = 1)
272+ }
273+ }
274+ }
275+
276+ /** One page under its "Settings / Page" breadcrumb. [back] leaves it for the page list, on a phone. */
277+ @Composable
278+ private fun PageView(
279+ app: AppState,
280+ nav: Nav,
281+ page: SettingsPage,
282+ scope: SettingsScope,
283+ target: Target?,
284+ back: (() -> Unit)?,
285+ open: (SettingsPage?) -> Unit,
286+ modifier: Modifier,
287+ ) {
288+ val p = palette
289+ val prefs = app.prefs
290+ var restoring by remember { mutableStateOf(false) }
291+ BoxWithConstraints(modifier) {
292+ val narrow = maxWidth < 480.dp
293+ Column(Modifier.fillMaxSize()) {
294+ Row(
295+ Modifier.fillMaxWidth().height(52.dp).padding(start = if (back != null) 8.dp else 20.dp, end = 12.dp),
296+ horizontalArrangement = Arrangement.spacedBy(8.dp),
297+ verticalAlignment = Alignment.CenterVertically,
298+ ) {
299+ if (back != null) Cap(icon = Lucide.ArrowLeft, kind = CapKind.Ghost, size = CapSize.Compact, onClick = back)
300+ Txt(
301+ "Settings",
302+ Modifier.clickable(interactionSource = null, indication = null) {
303+ if (back != null) back() else open(SettingsPage.General)
304+ },
305+ color = p.ink3,
306+ size = 14.sp,
307+ weight = FontWeight.Medium,
308+ maxLines = 1,
309+ )
310+ Txt("/", color = p.ink3, size = 14.sp)
311+ Txt(page.label, Modifier.weight(1f), color = p.ink, size = 14.sp, weight = FontWeight.SemiBold, maxLines = 1)
312+ if (page == SettingsPage.General) {
313+ val changed = differingDefaults(prefs)
314+ Cap(
315+ label = if (narrow) null else "Restore device defaults",
316+ icon = Lucide.RotateCcw,
317+ kind = CapKind.Ghost,
318+ size = CapSize.Compact,
319+ enabled = changed.isNotEmpty(),
320+ ) { restoring = true }
321+ if (restoring) {
322+ ConfirmDialog(
323+ title = "Restore default settings?",
324+ message = "This will reset: ${changed.joinToString(", ")}.",
325+ confirm = "Restore defaults",
326+ danger = true,
327+ onDismiss = { restoring = false },
328+ onConfirm = { restoreDefaults(prefs) },
329+ )
330+ }
331+ }
332+ }
333+ Rule()
334+ val scroll = remember(page) { ScrollState(0) }
335+ Box(
336+ Modifier.weight(1f).fillMaxWidth().verticalScroll(scroll),
337+ contentAlignment = Alignment.TopCenter,
338+ ) {
339+ val width = if (page == SettingsPage.Providers || page == SettingsPage.Connections) WideWidth else ReadableWidth
340+ Column(
341+ Modifier
342+ .widthIn(max = width)
343+ .fillMaxWidth()
344+ .padding(start = if (narrow) 12.dp else 24.dp, end = if (narrow) 12.dp else 24.dp, top = 24.dp, bottom = 48.dp),
345+ verticalArrangement = Arrangement.spacedBy(32.dp),
346+ ) {
347+ CompositionLocalProvider(LocalTarget provides target) {
348+ val shown = shown(app, scope)
349+ if (page.scoped) ScopeSentence(app, scope)
350+ when (page) {
351+ SettingsPage.General -> GeneralPage(app, shown)
352+ SettingsPage.Appearance -> AppearancePage(prefs)
353+ SettingsPage.Providers -> ProvidersPage(app)
354+ SettingsPage.SourceControl -> SourceControlPage(shown)
355+ SettingsPage.Storage -> StoragePage(shown)
356+ SettingsPage.Connections -> ConnectionsPage(app, nav)
357+ SettingsPage.Archive -> ArchivePage(app)
358+ SettingsPage.About -> AboutPage(app)
359+ }
360+ }
361+ }
362+ }
363+ }
364+ }
19365 }
+167−0android/app/src/main/java/cc/vectrum/code/ui/settings/SourceControlPage.ktaddedView File

Large diffs are not rendered by default.

+724−0android/app/src/main/java/cc/vectrum/code/ui/settings/Widgets.ktaddedView File

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

+72−0android/app/src/test/java/cc/vectrum/code/ui/SettingsAndPairingTest.ktaddedView File

Large diffs are not rendered by default.

+2−0android/gradle/libs.versions.tomlView File

Large diffs are not rendered by default.

+1−0android/scripts/icons.txtView File

Large diffs are not rendered by default.