Showing 20 changed files with 2590 additions and 13 deletions.
| @@ -69,11 +69,16 @@ enum class Lucide(val res: Int) { | ||
| 69 | 69 | FolderGit2(R.drawable.lucide_folder_git_2), |
| 70 | 70 | FolderOpen(R.drawable.lucide_folder_open), |
| 71 | 71 | FolderPlus(R.drawable.lucide_folder_plus), |
| 72 | + FolderTree(R.drawable.lucide_folder_tree), | |
| 72 | 73 | Gauge(R.drawable.lucide_gauge), |
| 73 | 74 | GitBranch(R.drawable.lucide_git_branch), |
| 74 | 75 | GitCommitHorizontal(R.drawable.lucide_git_commit_horizontal), |
| 75 | 76 | GitFork(R.drawable.lucide_git_fork), |
| 77 | + GitMerge(R.drawable.lucide_git_merge), | |
| 76 | 78 | GitPullRequest(R.drawable.lucide_git_pull_request), |
| 79 | + GitPullRequestArrow(R.drawable.lucide_git_pull_request_arrow), | |
| 80 | + GitPullRequestClosed(R.drawable.lucide_git_pull_request_closed), | |
| 81 | + GitPullRequestDraft(R.drawable.lucide_git_pull_request_draft), | |
| 77 | 82 | Github(R.drawable.lucide_github), |
| 78 | 83 | Globe(R.drawable.lucide_globe), |
| 79 | 84 | Hammer(R.drawable.lucide_hammer), |
| @@ -86,6 +91,7 @@ enum class Lucide(val res: Int) { | ||
| 86 | 91 | Keyboard(R.drawable.lucide_keyboard), |
| 87 | 92 | Laptop(R.drawable.lucide_laptop), |
| 88 | 93 | Layers(R.drawable.lucide_layers), |
| 94 | + Link(R.drawable.lucide_link), | |
| 89 | 95 | Link2(R.drawable.lucide_link_2), |
| 90 | 96 | List(R.drawable.lucide_list), |
| 91 | 97 | ListChecks(R.drawable.lucide_list_checks), |
| @@ -95,12 +101,15 @@ enum class Lucide(val res: Int) { | ||
| 95 | 101 | Lock(R.drawable.lucide_lock), |
| 96 | 102 | LockOpen(R.drawable.lucide_lock_open), |
| 97 | 103 | Mail(R.drawable.lucide_mail), |
| 104 | + MailOpen(R.drawable.lucide_mail_open), | |
| 98 | 105 | MapPin(R.drawable.lucide_map_pin), |
| 99 | 106 | MemoryStick(R.drawable.lucide_memory_stick), |
| 100 | 107 | MessageCircleQuestionMark(R.drawable.lucide_message_circle_question_mark), |
| 101 | 108 | MessageSquare(R.drawable.lucide_message_square), |
| 109 | + MessageSquarePlus(R.drawable.lucide_message_square_plus), | |
| 102 | 110 | Mic(R.drawable.lucide_mic), |
| 103 | 111 | Minus(R.drawable.lucide_minus), |
| 112 | + Monitor(R.drawable.lucide_monitor), | |
| 104 | 113 | MonitorSmartphone(R.drawable.lucide_monitor_smartphone), |
| 105 | 114 | Moon(R.drawable.lucide_moon), |
| 106 | 115 | MoonStar(R.drawable.lucide_moon_star), |
| @@ -1,17 +1,562 @@ | ||
| 1 | 1 | package cc.vectrum.code.ui.panels |
| 2 | 2 | |
| 3 | + import android.content.Context | |
| 4 | + import androidx.activity.compose.BackHandler | |
| 5 | + import androidx.compose.foundation.background | |
| 6 | + import androidx.compose.foundation.clickable | |
| 7 | + import androidx.compose.foundation.interaction.MutableInteractionSource | |
| 8 | + import androidx.compose.foundation.interaction.collectIsPressedAsState | |
| 9 | + import androidx.compose.foundation.layout.Arrangement | |
| 3 | 10 | import androidx.compose.foundation.layout.Box |
| 11 | + import androidx.compose.foundation.layout.Column | |
| 12 | + import androidx.compose.foundation.layout.Row | |
| 13 | + import androidx.compose.foundation.layout.defaultMinSize | |
| 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.heightIn | |
| 4 | 18 | import androidx.compose.foundation.layout.padding |
| 19 | + import androidx.compose.foundation.layout.width | |
| 20 | + import androidx.compose.foundation.layout.widthIn | |
| 21 | + import androidx.compose.foundation.rememberScrollState | |
| 22 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 23 | + import androidx.compose.foundation.text.BasicTextField | |
| 24 | + import androidx.compose.foundation.text.KeyboardActions | |
| 25 | + import androidx.compose.foundation.text.KeyboardOptions | |
| 26 | + import androidx.compose.foundation.verticalScroll | |
| 5 | 27 | import androidx.compose.runtime.Composable |
| 28 | + import androidx.compose.runtime.LaunchedEffect | |
| 29 | + import androidx.compose.runtime.getValue | |
| 30 | + import androidx.compose.runtime.mutableStateListOf | |
| 31 | + import androidx.compose.runtime.mutableStateOf | |
| 32 | + import androidx.compose.runtime.remember | |
| 33 | + import androidx.compose.runtime.setValue | |
| 34 | + import androidx.compose.ui.Alignment | |
| 6 | 35 | import androidx.compose.ui.Modifier |
| 36 | + import androidx.compose.ui.focus.FocusRequester | |
| 37 | + import androidx.compose.ui.focus.focusRequester | |
| 38 | + import androidx.compose.ui.graphics.Color | |
| 39 | + import androidx.compose.ui.graphics.SolidColor | |
| 40 | + import androidx.compose.ui.platform.LocalContext | |
| 41 | + import androidx.compose.ui.text.SpanStyle | |
| 42 | + import androidx.compose.ui.text.TextStyle | |
| 43 | + import androidx.compose.ui.text.buildAnnotatedString | |
| 44 | + import androidx.compose.ui.text.font.FontWeight | |
| 45 | + import androidx.compose.ui.text.input.ImeAction | |
| 46 | + import androidx.compose.ui.text.style.TextOverflow | |
| 47 | + import androidx.compose.ui.text.withStyle | |
| 7 | 48 | import androidx.compose.ui.unit.dp |
| 49 | + import androidx.compose.ui.unit.sp | |
| 50 | + import androidx.compose.foundation.text.BasicText | |
| 8 | 51 | 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.data.HostState | |
| 53 | + import cc.vectrum.code.theme.Badge | |
| 11 | 54 | import cc.vectrum.code.theme.Engraved |
| 55 | + import cc.vectrum.code.theme.Rule | |
| 56 | + import cc.vectrum.code.theme.Sans | |
| 57 | + import cc.vectrum.code.theme.Tag | |
| 58 | + import cc.vectrum.code.theme.Themes | |
| 59 | + import cc.vectrum.code.theme.Txt | |
| 60 | + import cc.vectrum.code.theme.menu | |
| 61 | + import cc.vectrum.code.theme.palette | |
| 62 | + import cc.vectrum.code.ui.Lucide | |
| 63 | + import cc.vectrum.code.ui.LucideIcon | |
| 12 | 64 | import cc.vectrum.code.ui.Nav |
| 13 | 65 | import cc.vectrum.code.ui.Screen |
| 66 | + import cc.vectrum.code.ui.pair.AddProjectSheet | |
| 67 | + import cc.vectrum.code.ui.sidebar.Entry | |
| 68 | + import cc.vectrum.code.ui.sidebar.allEntries | |
| 69 | + import cc.vectrum.code.ui.sidebar.copy | |
| 70 | + import cc.vectrum.code.ui.sidebar.displayPath | |
| 71 | + import cc.vectrum.code.ui.sidebar.projectName | |
| 72 | + import cc.vectrum.code.ui.sidebar.relativeTimeLong | |
| 73 | + import cc.vectrum.code.util.initials | |
| 74 | + import cc.vectrum.code.util.tintFor | |
| 75 | + | |
| 76 | + // The command palette (src/ui/workspace/palette.rs), as T3 Code has it: | |
| 77 | + // Actions and Recent Threads at rest, plus Projects, Settings and Threads | |
| 78 | + // while searching, with submenus for starting a thread in a project, the | |
| 79 | + // theme and the appearance. A leading ">" searches actions only. What runs | |
| 80 | + // on the desktop alone (usage, restarting an agent, scratch threads) is | |
| 81 | + // left out. | |
| 82 | + | |
| 83 | + private const val RECENT_THREADS = 12 | |
| 84 | + | |
| 85 | + /** Where the palette is: its root, or one of its submenus. */ | |
| 86 | + private enum class PaletteView(val placeholder: String) { | |
| 87 | + Root("Search commands, projects, and threads..."), | |
| 88 | + NewThreadIn("Search..."), | |
| 89 | + Themes("Search..."), | |
| 90 | + Appearance("Search..."), | |
| 91 | + } | |
| 92 | + | |
| 93 | + /** What choosing a row does. */ | |
| 94 | + private sealed interface PaletteAction { | |
| 95 | + /** Runs and closes the palette, unless it hands over to something that replaces it. */ | |
| 96 | + class Run(val close: Boolean = true, val run: () -> Unit) : PaletteAction | |
| 97 | + | |
| 98 | + class Submenu(val view: PaletteView) : PaletteAction | |
| 99 | + } | |
| 100 | + | |
| 101 | + /** What leads a row: an icon, or a project's initials in its tint. */ | |
| 102 | + private sealed interface Glyph { | |
| 103 | + class Icon(val icon: Lucide) : Glyph | |
| 104 | + | |
| 105 | + class ProjectBadge(val name: String) : Glyph | |
| 106 | + } | |
| 107 | + | |
| 108 | + /** One row of the palette. */ | |
| 109 | + private class PaletteItem( | |
| 110 | + val group: String, | |
| 111 | + val title: String, | |
| 112 | + val glyph: Glyph, | |
| 113 | + val action: PaletteAction, | |
| 114 | + /** Set in bold after the title: "New thread in **project**". */ | |
| 115 | + val emphasis: String? = null, | |
| 116 | + val description: String? = null, | |
| 117 | + /** Shown before the description: the server of `XPS · ~/Work/app`. */ | |
| 118 | + val descriptionIcon: Lucide? = null, | |
| 119 | + val timestamp: String? = null, | |
| 120 | + /** An engraved tag: `Current` on the theme in use. */ | |
| 121 | + val tag: String? = null, | |
| 122 | + extraTerms: List<String> = emptyList(), | |
| 123 | + ) { | |
| 124 | + /** What a search matches, most important first. */ | |
| 125 | + val terms: List<String> = listOfNotNull(title) + listOfNotNull(description) + extraTerms | |
| 126 | + | |
| 127 | + /** Every word must appear in a term; an exact match beats a prefix beats a substring, and earlier terms win. */ | |
| 128 | + fun score(words: List<String>): Int? { | |
| 129 | + val lower = terms.map { it.lowercase() } | |
| 130 | + var total = 0 | |
| 131 | + for (word in words) { | |
| 132 | + val best = lower.mapIndexedNotNull { ix, term -> | |
| 133 | + val rank = when { | |
| 134 | + term == word -> 0 | |
| 135 | + term.startsWith(word) -> 1 | |
| 136 | + term.contains(word) -> 2 | |
| 137 | + else -> return@mapIndexedNotNull null | |
| 138 | + } | |
| 139 | + rank * 10 + minOf(ix, 9) | |
| 140 | + }.minOrNull() ?: return null | |
| 141 | + total += best | |
| 142 | + } | |
| 143 | + return total | |
| 144 | + } | |
| 145 | + } | |
| 146 | + | |
| 147 | + /** Settings the palette finds by name, with the page they're on. Desktop-only ones are left out. */ | |
| 148 | + private val SETTINGS = listOf( | |
| 149 | + "Model" to "general", | |
| 150 | + "Permissions" to "general", | |
| 151 | + "Workspace" to "general", | |
| 152 | + "Text generation model" to "general", | |
| 153 | + "Start from origin" to "general", | |
| 154 | + "Auto-resume limited threads" to "general", | |
| 155 | + "Snooze limited threads" to "general", | |
| 156 | + "Working section (beta)" to "general", | |
| 157 | + "Auto-settle merged threads" to "general", | |
| 158 | + "Auto-settle inactive threads" to "general", | |
| 159 | + "Unpin confirmation" to "general", | |
| 160 | + "Delete confirmation" to "general", | |
| 161 | + "Thread notifications" to "behavior", | |
| 162 | + "Time format" to "behavior", | |
| 163 | + "Response streaming" to "behavior", | |
| 164 | + "Fold finished turns" to "behavior", | |
| 165 | + "Show costs" to "behavior", | |
| 166 | + "Hide whitespace changes" to "behavior", | |
| 167 | + "Diff layout" to "behavior", | |
| 168 | + "Follow-up behavior" to "behavior", | |
| 169 | + "Thread cards" to "appearance", | |
| 170 | + "Theme" to "appearance", | |
| 171 | + "Providers" to "providers", | |
| 172 | + "Connections" to "connections", | |
| 173 | + "Archived threads" to "archive", | |
| 174 | + "About" to "about", | |
| 175 | + ) | |
| 176 | + | |
| 177 | + private fun pageLabel(page: String) = page.replaceFirstChar { it.uppercase() } | |
| 178 | + | |
| 179 | + /** The command palette over a scrim, shown while `nav.palette` is set. */ | |
| 180 | + @Composable | |
| 181 | + fun CommandPalette(app: AppState, nav: Nav) { | |
| 182 | + val p = palette | |
| 183 | + val context = LocalContext.current | |
| 184 | + val stack = remember { mutableStateListOf(PaletteView.Root) } | |
| 185 | + var query by remember { mutableStateOf("") } | |
| 186 | + var addingProject by remember { mutableStateOf<HostState?>(null) } | |
| 187 | + var adding by remember { mutableStateOf(false) } | |
| 188 | + val view = stack.last() | |
| 189 | + val focus = remember { FocusRequester() } | |
| 190 | + | |
| 191 | + if (adding) { | |
| 192 | + // Add project replaces the palette, and closes it when it's done. | |
| 193 | + AddProjectSheet(app, addingProject, true) { nav.palette = false } | |
| 194 | + return | |
| 195 | + } | |
| 196 | + | |
| 197 | + BackHandler(enabled = stack.size > 1) { | |
| 198 | + stack.removeAt(stack.lastIndex) | |
| 199 | + query = "" | |
| 200 | + } | |
| 201 | + | |
| 202 | + fun execute(item: PaletteItem) { | |
| 203 | + when (val action = item.action) { | |
| 204 | + is PaletteAction.Submenu -> { | |
| 205 | + stack.add(action.view) | |
| 206 | + query = "" | |
| 207 | + } | |
| 208 | + is PaletteAction.Run -> { | |
| 209 | + if (action.close) nav.palette = false | |
| 210 | + action.run() | |
| 211 | + } | |
| 212 | + } | |
| 213 | + } | |
| 214 | + | |
| 215 | + val dark = p.dark | |
| 216 | + val items = paletteItems(app, nav, context, view, query.isNotBlank(), dark, onAddProject = { host -> | |
| 217 | + addingProject = host | |
| 218 | + adding = true | |
| 219 | + }) | |
| 220 | + val actionsOnly = view == PaletteView.Root && query.trimStart().startsWith(">") | |
| 221 | + val results = results(items, query, actionsOnly) | |
| 222 | + | |
| 223 | + Box( | |
| 224 | + Modifier | |
| 225 | + .fillMaxSize() | |
| 226 | + .background(Color.Black.copy(alpha = if (p.dark) 0.4f else 0.16f)) | |
| 227 | + .clickable(interactionSource = null, indication = null) { nav.palette = false }, | |
| 228 | + contentAlignment = Alignment.TopCenter, | |
| 229 | + ) { | |
| 230 | + Column( | |
| 231 | + Modifier | |
| 232 | + .padding(horizontal = 8.dp, vertical = 16.dp) | |
| 233 | + .widthIn(max = 600.dp) | |
| 234 | + .fillMaxWidth() | |
| 235 | + .menu(p, RoundedCornerShape(18.dp)) | |
| 236 | + .clickable(interactionSource = null, indication = null) {}, | |
| 237 | + ) { | |
| 238 | + Row( | |
| 239 | + Modifier.fillMaxWidth().height(48.dp).padding(horizontal = 14.dp), | |
| 240 | + horizontalArrangement = Arrangement.spacedBy(10.dp), | |
| 241 | + verticalAlignment = Alignment.CenterVertically, | |
| 242 | + ) { | |
| 243 | + if (stack.size > 1) { | |
| 244 | + Box( | |
| 245 | + Modifier.clickable(interactionSource = null, indication = null) { | |
| 246 | + stack.removeAt(stack.lastIndex) | |
| 247 | + query = "" | |
| 248 | + }, | |
| 249 | + ) { LucideIcon(Lucide.ArrowLeft, size = 16.dp, tint = p.ink2) } | |
| 250 | + } else { | |
| 251 | + LucideIcon(Lucide.Search, size = 16.dp, tint = p.fieldInk) | |
| 252 | + } | |
| 253 | + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { | |
| 254 | + if (query.isEmpty()) Txt(view.placeholder, color = p.fieldInk, size = 14.sp, maxLines = 1) | |
| 255 | + BasicTextField( | |
| 256 | + value = query, | |
| 257 | + onValueChange = { query = it }, | |
| 258 | + singleLine = true, | |
| 259 | + textStyle = TextStyle(color = p.ink, fontSize = 14.sp, fontFamily = Sans), | |
| 260 | + cursorBrush = SolidColor(p.ink), | |
| 261 | + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go), | |
| 262 | + keyboardActions = KeyboardActions(onGo = { results.firstOrNull()?.let { execute(it) } }), | |
| 263 | + modifier = Modifier.fillMaxWidth().focusRequester(focus), | |
| 264 | + ) | |
| 265 | + } | |
| 266 | + } | |
| 267 | + Rule() | |
| 268 | + Column( | |
| 269 | + Modifier | |
| 270 | + .heightIn(max = 448.dp) | |
| 271 | + .verticalScroll(rememberScrollState()) | |
| 272 | + .padding(6.dp), | |
| 273 | + ) { | |
| 274 | + if (results.isEmpty()) { | |
| 275 | + val message = when { | |
| 276 | + actionsOnly -> "No matching actions." | |
| 277 | + view == PaletteView.Root -> "No matching commands, projects, or threads." | |
| 278 | + else -> "No matches." | |
| 279 | + } | |
| 280 | + Box(Modifier.fillMaxWidth().padding(vertical = 40.dp), contentAlignment = Alignment.Center) { | |
| 281 | + Txt(message, color = p.ink3, size = 13.sp) | |
| 282 | + } | |
| 283 | + } | |
| 284 | + var lastGroup: String? = null | |
| 285 | + results.forEachIndexed { ix, item -> | |
| 286 | + if (item.group != lastGroup) { | |
| 287 | + lastGroup = item.group | |
| 288 | + Engraved(item.group, Modifier.padding(start = 10.dp, end = 10.dp, top = if (ix == 0) 4.dp else 10.dp, bottom = 4.dp)) | |
| 289 | + } | |
| 290 | + PaletteRow(item, highlighted = ix == 0 && query.isNotBlank()) { execute(item) } | |
| 291 | + } | |
| 292 | + } | |
| 293 | + } | |
| 294 | + } | |
| 295 | + LaunchedEffect(view) { focus.requestFocus() } | |
| 296 | + } | |
| 297 | + | |
| 298 | + /** The rows that match the query, in their groups, best first. */ | |
| 299 | + private fun results(items: List<PaletteItem>, raw: String, actionsOnly: Boolean): List<PaletteItem> { | |
| 300 | + val query = raw.trimStart().removePrefix(">").trim().lowercase() | |
| 301 | + val filtered = items.filter { !actionsOnly || it.group == "Actions" } | |
| 302 | + val words = query.split(Regex("\\s+")).filter { it.isNotEmpty() } | |
| 303 | + if (words.isEmpty()) return filtered | |
| 304 | + val groups = filtered.map { it.group }.distinct() | |
| 305 | + return filtered.mapIndexedNotNull { order, item -> | |
| 306 | + val score = item.score(words) ?: return@mapIndexedNotNull null | |
| 307 | + // Thread titles tie, keeping the most recent first. | |
| 308 | + Triple(groups.indexOf(item.group), if (item.group == "Threads") 0 else score, order) to item | |
| 309 | + }.sortedWith(compareBy({ it.first.first }, { it.first.second }, { it.first.third })).map { it.second } | |
| 310 | + } | |
| 311 | + | |
| 312 | + /** The project a new thread starts in: the open thread's, or the first. */ | |
| 313 | + private fun contextualProject(app: AppState, nav: Nav): Pair<HostState, String>? { | |
| 314 | + val screen = nav.screen | |
| 315 | + if (screen is Screen.Thread) { | |
| 316 | + val host = app.host(screen.ref.host) | |
| 317 | + val summary = host?.threads?.get(screen.ref.thread) | |
| 318 | + if (host != null && summary != null) return host to summary.project | |
| 319 | + } | |
| 320 | + if (screen is Screen.NewThread && screen.host != null && screen.project != null) { | |
| 321 | + app.host(screen.host)?.let { return it to screen.project } | |
| 322 | + } | |
| 323 | + val host = app.hosts.firstOrNull { it.projects.isNotEmpty() } ?: return null | |
| 324 | + return host to host.projects.first().path | |
| 325 | + } | |
| 326 | + | |
| 327 | + /** A project's row, which starts a thread in it. */ | |
| 328 | + private fun projectItem(app: AppState, nav: Nav, host: HostState, path: String, group: String = "Projects"): PaletteItem { | |
| 329 | + val name = projectName(host, path) | |
| 330 | + return PaletteItem( | |
| 331 | + group = group, | |
| 332 | + title = name, | |
| 333 | + glyph = Glyph.ProjectBadge(name), | |
| 334 | + description = "${host.name} · ${displayPath(path)}", | |
| 335 | + descriptionIcon = Lucide.Server, | |
| 336 | + action = PaletteAction.Run { nav.go(Screen.NewThread(host.saved.id, path)) }, | |
| 337 | + ) | |
| 338 | + } | |
| 339 | + | |
| 340 | + private fun paletteItems( | |
| 341 | + app: AppState, | |
| 342 | + nav: Nav, | |
| 343 | + context: Context, | |
| 344 | + view: PaletteView, | |
| 345 | + searching: Boolean, | |
| 346 | + showingDark: Boolean, | |
| 347 | + onAddProject: (HostState?) -> Unit, | |
| 348 | + ): List<PaletteItem> = when (view) { | |
| 349 | + PaletteView.Root -> buildList { | |
| 350 | + addAll(actions(app, nav, context, onAddProject)) | |
| 351 | + if (searching) { | |
| 352 | + for (host in app.hosts) for (project in host.projects) add(projectItem(app, nav, host, project.path)) | |
| 353 | + for ((title, page) in SETTINGS) { | |
| 354 | + add( | |
| 355 | + PaletteItem( | |
| 356 | + "Settings", | |
| 357 | + title, | |
| 358 | + Glyph.Icon(Lucide.Settings), | |
| 359 | + description = "Settings · ${pageLabel(page)}", | |
| 360 | + action = PaletteAction.Run { nav.go(Screen.Settings(page)) }, | |
| 361 | + ), | |
| 362 | + ) | |
| 363 | + } | |
| 364 | + addAll(threads(app, nav, Int.MAX_VALUE, "Threads")) | |
| 365 | + } else { | |
| 366 | + addAll(threads(app, nav, RECENT_THREADS, "Recent Threads")) | |
| 367 | + } | |
| 368 | + } | |
| 369 | + PaletteView.NewThreadIn -> { | |
| 370 | + val current = contextualProject(app, nav) | |
| 371 | + val all = app.hosts.flatMap { host -> host.projects.map { host to it.path } } | |
| 372 | + val ordered = listOfNotNull(current?.takeIf { it in all }) + all.filter { it != current } | |
| 373 | + ordered.map { (host, path) -> projectItem(app, nav, host, path) } | |
| 374 | + } | |
| 375 | + PaletteView.Themes -> { | |
| 376 | + val prefs = app.prefs | |
| 377 | + val mode = prefs.themeMode | |
| 378 | + Themes.all.map { theme -> | |
| 379 | + val dark = theme.palette.dark | |
| 380 | + val current = (dark && theme.id == prefs.darkTheme && (showingDark || mode == "dark")) || | |
| 381 | + (!dark && theme.id == prefs.lightTheme && (!showingDark || mode == "light")) | |
| 382 | + PaletteItem( | |
| 383 | + "Themes", | |
| 384 | + theme.name, | |
| 385 | + Glyph.Icon(Lucide.Palette), | |
| 386 | + description = if (dark) "Dark" else "Light", | |
| 387 | + tag = if (current) "Current" else null, | |
| 388 | + action = PaletteAction.Run { | |
| 389 | + // The theme becomes the light or dark one, and its mode unless the mode follows the system. | |
| 390 | + prefs.set(if (dark) "dark_theme" else "light_theme", theme.id) | |
| 391 | + if (prefs.themeMode != "system") prefs.set("theme_mode", if (dark) "dark" else "light") | |
| 392 | + }, | |
| 393 | + ) | |
| 394 | + } | |
| 395 | + } | |
| 396 | + PaletteView.Appearance -> listOf("system" to "System", "light" to "Light", "dark" to "Dark").map { (mode, label) -> | |
| 397 | + PaletteItem( | |
| 398 | + "Appearance", | |
| 399 | + label, | |
| 400 | + Glyph.Icon(Lucide.Monitor), | |
| 401 | + tag = if (app.prefs.themeMode == mode) "Current" else null, | |
| 402 | + action = PaletteAction.Run { app.prefs.set("theme_mode", mode) }, | |
| 403 | + ) | |
| 404 | + } | |
| 405 | + } | |
| 406 | + | |
| 407 | + private fun actions(app: AppState, nav: Nav, context: Context, onAddProject: (HostState?) -> Unit): List<PaletteItem> = buildList { | |
| 408 | + val contextual = contextualProject(app, nav) | |
| 409 | + if (contextual != null) { | |
| 410 | + val (host, path) = contextual | |
| 411 | + val name = projectName(host, path) | |
| 412 | + add( | |
| 413 | + PaletteItem( | |
| 414 | + "Actions", | |
| 415 | + "New thread in ", | |
| 416 | + Glyph.Icon(Lucide.SquarePen), | |
| 417 | + emphasis = name, | |
| 418 | + extraTerms = listOf("New thread in $name", "new chat"), | |
| 419 | + action = PaletteAction.Run { nav.go(Screen.NewThread(host.saved.id, path)) }, | |
| 420 | + ), | |
| 421 | + ) | |
| 422 | + add( | |
| 423 | + PaletteItem( | |
| 424 | + "Actions", | |
| 425 | + "New thread in...", | |
| 426 | + Glyph.Icon(Lucide.SquarePen), | |
| 427 | + extraTerms = listOf("project", "new chat"), | |
| 428 | + action = PaletteAction.Submenu(PaletteView.NewThreadIn), | |
| 429 | + ), | |
| 430 | + ) | |
| 431 | + } | |
| 432 | + val screen = nav.screen | |
| 433 | + if (screen is Screen.Thread) { | |
| 434 | + val host = app.host(screen.ref.host) | |
| 435 | + val summary = host?.threads?.get(screen.ref.thread) | |
| 436 | + if (host != null && summary != null) { | |
| 437 | + val pr = host.open[summary.id]?.detail?.pullRequest | |
| 438 | + val (title, value) = if (pr != null) "Copy PR link" to pr.url else "Copy thread ID" to summary.id | |
| 439 | + add( | |
| 440 | + PaletteItem( | |
| 441 | + "Actions", | |
| 442 | + title, | |
| 443 | + Glyph.Icon(Lucide.Link), | |
| 444 | + description = value, | |
| 445 | + action = PaletteAction.Run { | |
| 446 | + copy(context, app, value, if (pr != null) "PR link copied" else "Thread ID copied") | |
| 447 | + }, | |
| 448 | + ), | |
| 449 | + ) | |
| 450 | + } | |
| 451 | + } | |
| 452 | + if (app.hosts.any { it.connected }) { | |
| 453 | + add( | |
| 454 | + PaletteItem( | |
| 455 | + "Actions", | |
| 456 | + "Add project", | |
| 457 | + Glyph.Icon(Lucide.FolderPlus), | |
| 458 | + extraTerms = listOf("open folder", "local folder", "clone", "new project"), | |
| 459 | + action = PaletteAction.Run(close = false) { | |
| 460 | + val host = contextual?.first ?: app.hosts.singleOrNull() | |
| 461 | + onAddProject(host) | |
| 462 | + }, | |
| 463 | + ), | |
| 464 | + ) | |
| 465 | + } | |
| 466 | + add( | |
| 467 | + PaletteItem( | |
| 468 | + "Actions", | |
| 469 | + "Change theme", | |
| 470 | + Glyph.Icon(Lucide.Palette), | |
| 471 | + extraTerms = listOf("color scheme", "colors"), | |
| 472 | + action = PaletteAction.Submenu(PaletteView.Themes), | |
| 473 | + ), | |
| 474 | + ) | |
| 475 | + add( | |
| 476 | + PaletteItem( | |
| 477 | + "Actions", | |
| 478 | + "Change appearance", | |
| 479 | + Glyph.Icon(Lucide.Monitor), | |
| 480 | + extraTerms = listOf("light", "dark", "system", "mode"), | |
| 481 | + action = PaletteAction.Submenu(PaletteView.Appearance), | |
| 482 | + ), | |
| 483 | + ) | |
| 484 | + add( | |
| 485 | + PaletteItem( | |
| 486 | + "Actions", | |
| 487 | + "Open settings", | |
| 488 | + Glyph.Icon(Lucide.Settings), | |
| 489 | + extraTerms = listOf("preferences"), | |
| 490 | + action = PaletteAction.Run { nav.go(Screen.Settings()) }, | |
| 491 | + ), | |
| 492 | + ) | |
| 493 | + } | |
| 494 | + | |
| 495 | + /** Threads, most recently updated first: the recent ones at rest, all while searching. */ | |
| 496 | + private fun threads(app: AppState, nav: Nav, limit: Int, group: String): List<PaletteItem> { | |
| 497 | + val active = (nav.screen as? Screen.Thread)?.ref | |
| 498 | + return allEntries(app) | |
| 499 | + .sortedByDescending { it.summary.updated } | |
| 500 | + .take(limit) | |
| 501 | + .map { entry: Entry -> | |
| 502 | + val description = buildString { | |
| 503 | + append(entry.projectName) | |
| 504 | + entry.branch?.let { append(" · #$it") } | |
| 505 | + if (entry.ref == active) append(" · Current thread") | |
| 506 | + } | |
| 507 | + PaletteItem( | |
| 508 | + group, | |
| 509 | + entry.summary.title, | |
| 510 | + Glyph.Icon(Lucide.MessageSquare), | |
| 511 | + description = description, | |
| 512 | + timestamp = relativeTimeLong(entry.summary.updated), | |
| 513 | + action = PaletteAction.Run { nav.openThread(entry.ref) }, | |
| 514 | + ) | |
| 515 | + } | |
| 516 | + } | |
| 14 | 517 | |
| 15 | - // Placeholder until it's built. | |
| 16 | 518 | @Composable |
| 17 | - fun CommandPalette(app: AppState, nav: Nav) {} | |
| 519 | + private fun PaletteRow(item: PaletteItem, highlighted: Boolean, onClick: () -> Unit) { | |
| 520 | + val p = palette | |
| 521 | + val interaction = remember { MutableInteractionSource() } | |
| 522 | + val pressed by interaction.collectIsPressedAsState() | |
| 523 | + val shape = RoundedCornerShape(10.dp) | |
| 524 | + Row( | |
| 525 | + Modifier | |
| 526 | + .fillMaxWidth() | |
| 527 | + .defaultMinSize(minHeight = 40.dp) | |
| 528 | + .then(if (pressed || highlighted) Modifier.background(p.menuRowHover, shape) else Modifier) | |
| 529 | + .clickable(interactionSource = interaction, indication = null, onClick = onClick) | |
| 530 | + .padding(horizontal = 10.dp, vertical = 6.dp), | |
| 531 | + horizontalArrangement = Arrangement.spacedBy(10.dp), | |
| 532 | + verticalAlignment = Alignment.CenterVertically, | |
| 533 | + ) { | |
| 534 | + Box(Modifier.width(20.dp), contentAlignment = Alignment.Center) { | |
| 535 | + when (val glyph = item.glyph) { | |
| 536 | + is Glyph.Icon -> LucideIcon(glyph.icon, size = 16.dp, tint = p.icon) | |
| 537 | + is Glyph.ProjectBadge -> Badge(initials(glyph.name), tintFor(glyph.name)) | |
| 538 | + } | |
| 539 | + } | |
| 540 | + Column(Modifier.weight(1f)) { | |
| 541 | + BasicText( | |
| 542 | + buildAnnotatedString { | |
| 543 | + append(item.title) | |
| 544 | + item.emphasis?.let { withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { append(it) } } | |
| 545 | + }, | |
| 546 | + style = TextStyle(color = p.ink, fontSize = 13.5.sp, fontFamily = Sans), | |
| 547 | + maxLines = 1, | |
| 548 | + overflow = TextOverflow.Ellipsis, | |
| 549 | + ) | |
| 550 | + val description = item.description | |
| 551 | + if (description != null) { | |
| 552 | + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 553 | + item.descriptionIcon?.let { LucideIcon(it, size = 12.dp, tint = p.ink3) } | |
| 554 | + Txt(description, color = p.ink3, size = 12.sp, maxLines = 1) | |
| 555 | + } | |
| 556 | + } | |
| 557 | + } | |
| 558 | + item.tag?.let { Tag(it) } | |
| 559 | + item.timestamp?.let { Txt(it, color = p.ink3, size = 12.sp, maxLines = 1) } | |
| 560 | + if (item.action is PaletteAction.Submenu) LucideIcon(Lucide.ChevronRight, size = 16.dp, tint = p.ink3) | |
| 561 | + } | |
| 562 | + } | |
| @@ -1,17 +1,156 @@ | ||
| 1 | 1 | package cc.vectrum.code.ui.panels |
| 2 | 2 | |
| 3 | + import androidx.compose.animation.AnimatedVisibility | |
| 4 | + import androidx.compose.animation.core.MutableTransitionState | |
| 5 | + import androidx.compose.animation.fadeIn | |
| 6 | + import androidx.compose.animation.slideInHorizontally | |
| 7 | + import androidx.compose.foundation.clickable | |
| 8 | + import androidx.compose.foundation.layout.Arrangement | |
| 3 | 9 | import androidx.compose.foundation.layout.Box |
| 10 | + import androidx.compose.foundation.layout.Column | |
| 11 | + import androidx.compose.foundation.layout.Row | |
| 12 | + import androidx.compose.foundation.layout.fillMaxSize | |
| 13 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 14 | + import androidx.compose.foundation.layout.height | |
| 4 | 15 | import androidx.compose.foundation.layout.padding |
| 16 | + import androidx.compose.foundation.layout.widthIn | |
| 17 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 5 | 18 | import androidx.compose.runtime.Composable |
| 19 | + import androidx.compose.runtime.remember | |
| 20 | + import androidx.compose.ui.Alignment | |
| 6 | 21 | import androidx.compose.ui.Modifier |
| 22 | + import androidx.compose.ui.draw.clip | |
| 23 | + import androidx.compose.ui.text.font.FontWeight | |
| 24 | + import androidx.compose.ui.text.style.TextAlign | |
| 7 | 25 | import androidx.compose.ui.unit.dp |
| 26 | + import androidx.compose.ui.unit.sp | |
| 27 | + import androidx.compose.ui.text.TextStyle | |
| 8 | 28 | import cc.vectrum.code.data.AppState |
| 9 | - import cc.vectrum.code.data.OpenThread | |
| 10 | 29 | import cc.vectrum.code.data.ThreadRef |
| 11 | - import cc.vectrum.code.theme.Engraved | |
| 30 | + import cc.vectrum.code.theme.Cap | |
| 31 | + import cc.vectrum.code.theme.CapKind | |
| 32 | + import cc.vectrum.code.theme.CapSize | |
| 33 | + import cc.vectrum.code.theme.Tag | |
| 34 | + import cc.vectrum.code.theme.Txt | |
| 35 | + import cc.vectrum.code.theme.palette | |
| 36 | + import cc.vectrum.code.theme.plate | |
| 37 | + import cc.vectrum.code.theme.raised | |
| 38 | + import cc.vectrum.code.ui.Lucide | |
| 39 | + import cc.vectrum.code.ui.LucideIcon | |
| 12 | 40 | import cc.vectrum.code.ui.Nav |
| 13 | - import cc.vectrum.code.ui.Screen | |
| 41 | + import cc.vectrum.code.ui.SheetRadius | |
| 42 | + import cc.vectrum.code.ui.Surface | |
| 43 | + import cc.vectrum.code.ui.device.DevicePanel | |
| 44 | + import cc.vectrum.code.ui.sidebar.pressTint | |
| 45 | + import cc.vectrum.code.ui.terminal.TerminalPanel | |
| 14 | 46 | |
| 15 | - // Placeholder until it's built. | |
| 47 | + // The right panel (src/ui/workspace/surfaces.rs): one surface at a time | |
| 48 | + // next to a thread, opened from "Open a surface". On a phone it slides | |
| 49 | + // over the sheet, full height, with a header to close it. There's no | |
| 50 | + // Browser: the protocol can't stream it. | |
| 51 | + | |
| 52 | + /** The surfaces the picker lists, each with its icon and name. */ | |
| 53 | + private val SURFACES = listOf( | |
| 54 | + Triple(Surface.Subagents, Lucide.Bot, "Subagents"), | |
| 55 | + Triple(Surface.Device, Lucide.Smartphone, "Device"), | |
| 56 | + Triple(Surface.Terminal, Lucide.SquareTerminal, "Terminal"), | |
| 57 | + ) | |
| 58 | + | |
| 59 | + /** The right panel over a thread: "Open a surface", or the surface picked. */ | |
| 60 | + @Composable | |
| 61 | + fun RightPanel(app: AppState, nav: Nav, ref: ThreadRef) { | |
| 62 | + val p = palette | |
| 63 | + val shown = remember { MutableTransitionState(false) } | |
| 64 | + shown.targetState = true | |
| 65 | + val shape = RoundedCornerShape(SheetRadius) | |
| 66 | + AnimatedVisibility( | |
| 67 | + visibleState = shown, | |
| 68 | + enter = slideInHorizontally { it / 3 } + fadeIn(), | |
| 69 | + ) { | |
| 70 | + Box( | |
| 71 | + Modifier | |
| 72 | + .fillMaxSize() | |
| 73 | + .plate(p, shape) | |
| 74 | + .clip(shape) | |
| 75 | + // The thread underneath doesn't take touches meant for the panel. | |
| 76 | + .clickable(interactionSource = null, indication = null) {}, | |
| 77 | + ) { | |
| 78 | + val surface = nav.surface | |
| 79 | + when { | |
| 80 | + nav.surfacePicker || surface == null -> SurfacePicker(nav) | |
| 81 | + surface == Surface.Subagents -> SubagentsPanel(app, nav, ref) | |
| 82 | + else -> Column(Modifier.fillMaxSize()) { | |
| 83 | + val (icon, title) = if (surface == Surface.Terminal) Lucide.SquareTerminal to "Terminal" else Lucide.Smartphone to "Device" | |
| 84 | + PanelHeader(icon, title) { nav.surface = null } | |
| 85 | + Box(Modifier.weight(1f).fillMaxWidth()) { | |
| 86 | + if (surface == Surface.Terminal) TerminalPanel(app, nav, ref) else DevicePanel(app, nav, ref) | |
| 87 | + } | |
| 88 | + } | |
| 89 | + } | |
| 90 | + } | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + /** A panel's header on a phone: its icon and title, a tag, and the close button. */ | |
| 95 | + @Composable | |
| 96 | + fun PanelHeader(icon: Lucide, title: String, tag: String? = null, onClose: () -> Unit) { | |
| 97 | + val p = palette | |
| 98 | + Row( | |
| 99 | + Modifier.fillMaxWidth().height(48.dp).padding(start = 16.dp, end = 10.dp), | |
| 100 | + horizontalArrangement = Arrangement.spacedBy(10.dp), | |
| 101 | + verticalAlignment = Alignment.CenterVertically, | |
| 102 | + ) { | |
| 103 | + LucideIcon(icon, size = 16.dp, tint = p.icon) | |
| 104 | + Txt(title, Modifier.weight(1f), size = 13.5.sp, weight = FontWeight.Medium, maxLines = 1) | |
| 105 | + if (tag != null) Tag(tag) | |
| 106 | + Cap(icon = Lucide.X, kind = CapKind.Ghost, size = CapSize.Compact, onClick = onClose) | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | + /** "Open a surface": the surfaces to pick from, on a raised card. */ | |
| 16 | 111 | @Composable |
| 17 | - fun RightPanel(app: AppState, nav: Nav, ref: ThreadRef) {} | |
| 112 | + private fun SurfacePicker(nav: Nav) { | |
| 113 | + val p = palette | |
| 114 | + Column(Modifier.fillMaxSize()) { | |
| 115 | + Row(Modifier.fillMaxWidth().height(48.dp).padding(end = 10.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 116 | + Box(Modifier.weight(1f)) | |
| 117 | + Cap(icon = Lucide.X, kind = CapKind.Ghost, size = CapSize.Compact) { nav.surfacePicker = false } | |
| 118 | + } | |
| 119 | + Box(Modifier.weight(1f).fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { | |
| 120 | + Column( | |
| 121 | + Modifier | |
| 122 | + .widthIn(max = 360.dp) | |
| 123 | + .fillMaxWidth() | |
| 124 | + .raised(p, RoundedCornerShape(18.dp)) | |
| 125 | + .padding(horizontal = 16.dp, vertical = 20.dp), | |
| 126 | + ) { | |
| 127 | + Txt( | |
| 128 | + "Open a surface", | |
| 129 | + Modifier.fillMaxWidth().padding(bottom = 14.dp), | |
| 130 | + size = 14.sp, | |
| 131 | + weight = FontWeight.Medium, | |
| 132 | + style = TextStyle(textAlign = TextAlign.Center), | |
| 133 | + ) | |
| 134 | + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { | |
| 135 | + for ((surface, icon, name) in SURFACES) { | |
| 136 | + Row( | |
| 137 | + Modifier | |
| 138 | + .fillMaxWidth() | |
| 139 | + .height(40.dp) | |
| 140 | + .pressTint(8.dp) { | |
| 141 | + nav.surface = surface | |
| 142 | + nav.surfacePicker = false | |
| 143 | + } | |
| 144 | + .padding(horizontal = 10.dp), | |
| 145 | + horizontalArrangement = Arrangement.spacedBy(10.dp), | |
| 146 | + verticalAlignment = Alignment.CenterVertically, | |
| 147 | + ) { | |
| 148 | + LucideIcon(icon, size = 16.dp, tint = p.ink2) | |
| 149 | + Txt(name, size = 13.5.sp, maxLines = 1) | |
| 150 | + } | |
| 151 | + } | |
| 152 | + } | |
| 153 | + } | |
| 154 | + } | |
| 155 | + } | |
| 156 | + } | |
| @@ -0,0 +1,344 @@ | ||
| 1 | + package cc.vectrum.code.ui.panels | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Arrangement | |
| 4 | + import androidx.compose.foundation.layout.Box | |
| 5 | + import androidx.compose.foundation.layout.Column | |
| 6 | + import androidx.compose.foundation.layout.Row | |
| 7 | + import androidx.compose.foundation.layout.fillMaxSize | |
| 8 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 9 | + import androidx.compose.foundation.layout.height | |
| 10 | + import androidx.compose.foundation.layout.padding | |
| 11 | + import androidx.compose.foundation.rememberScrollState | |
| 12 | + import androidx.compose.foundation.verticalScroll | |
| 13 | + import androidx.compose.runtime.Composable | |
| 14 | + import androidx.compose.runtime.getValue | |
| 15 | + import androidx.compose.ui.Alignment | |
| 16 | + import androidx.compose.ui.Modifier | |
| 17 | + import androidx.compose.ui.unit.dp | |
| 18 | + import androidx.compose.ui.unit.sp | |
| 19 | + import cc.vectrum.code.data.AppState | |
| 20 | + import cc.vectrum.code.data.HostState | |
| 21 | + import cc.vectrum.code.data.ThreadRef | |
| 22 | + import cc.vectrum.code.net.SavedItem | |
| 23 | + import cc.vectrum.code.net.ThreadAction | |
| 24 | + import cc.vectrum.code.net.str | |
| 25 | + import cc.vectrum.code.theme.Cap | |
| 26 | + import cc.vectrum.code.theme.CapKind | |
| 27 | + import cc.vectrum.code.theme.CapSize | |
| 28 | + import cc.vectrum.code.theme.Engraved | |
| 29 | + import cc.vectrum.code.theme.Led | |
| 30 | + import cc.vectrum.code.theme.Mono | |
| 31 | + import cc.vectrum.code.theme.Rule | |
| 32 | + import cc.vectrum.code.theme.StatusLed | |
| 33 | + import cc.vectrum.code.theme.Txt | |
| 34 | + import cc.vectrum.code.theme.palette | |
| 35 | + import cc.vectrum.code.ui.Lucide | |
| 36 | + import cc.vectrum.code.ui.LucideIcon | |
| 37 | + import cc.vectrum.code.ui.Nav | |
| 38 | + import cc.vectrum.code.ui.sidebar.pressTint | |
| 39 | + import cc.vectrum.code.ui.sidebar.rememberNow | |
| 40 | + import cc.vectrum.code.util.formatDuration | |
| 41 | + import kotlinx.serialization.json.JsonArray | |
| 42 | + import kotlinx.serialization.json.JsonElement | |
| 43 | + import kotlinx.serialization.json.JsonNull | |
| 44 | + import kotlinx.serialization.json.JsonObject | |
| 45 | + import kotlinx.serialization.json.jsonPrimitive | |
| 46 | + | |
| 47 | + // The subagents panel (src/ui/workspace/subagents.rs): every subagent the | |
| 48 | + // thread's agent started, the working ones first with what each is doing | |
| 49 | + // right now (its latest tool call, its progress summary, how long it's run, | |
| 50 | + // its tool uses and tokens), then the finished ones. A task delegated to | |
| 51 | + // the app runs in a thread of its own, which a tap opens. | |
| 52 | + | |
| 53 | + private enum class TaskStatus { Running, Completed, Failed, Stopped } | |
| 54 | + | |
| 55 | + private fun taskStatus(status: String) = when (status) { | |
| 56 | + "completed" -> TaskStatus.Completed | |
| 57 | + "failed" -> TaskStatus.Failed | |
| 58 | + "running", "pending" -> TaskStatus.Running | |
| 59 | + else -> TaskStatus.Stopped | |
| 60 | + } | |
| 61 | + | |
| 62 | + /** How the Stop button stops a subagent, where the host can. */ | |
| 63 | + private sealed interface Stop { | |
| 64 | + /** Claude's background-task control, by task id. */ | |
| 65 | + class Task(val taskId: String) : Stop | |
| 66 | + | |
| 67 | + /** Pi runs its subagents inside one tool call: stopping one stops the turn. */ | |
| 68 | + data object Turn : Stop | |
| 69 | + | |
| 70 | + /** A task delegated to the app: its thread, by id. */ | |
| 71 | + class Thread(val id: String) : Stop | |
| 72 | + } | |
| 73 | + | |
| 74 | + /** A subagent as the panel lists it. */ | |
| 75 | + private class Subagent( | |
| 76 | + val id: String, | |
| 77 | + val description: String, | |
| 78 | + /** The kind of agent, e.g. `Explore`. */ | |
| 79 | + val kind: String?, | |
| 80 | + val status: TaskStatus, | |
| 81 | + val background: Boolean, | |
| 82 | + val toolUses: Int, | |
| 83 | + val tokens: Long?, | |
| 84 | + val started: Long, | |
| 85 | + val finished: Long?, | |
| 86 | + /** Its latest tool call while it works: `[tool name, input]`. */ | |
| 87 | + val tool: JsonArray?, | |
| 88 | + val summary: String?, | |
| 89 | + /** The thread a task delegated to the app runs in, by id. */ | |
| 90 | + val thread: String?, | |
| 91 | + val stop: Stop?, | |
| 92 | + ) | |
| 93 | + | |
| 94 | + /** The app's own tools, under whatever prefix the agent gives them. */ | |
| 95 | + private fun bare(name: String) = name.removePrefix("mcp__vectrum-code__").removePrefix("mcp__vectrum_code__") | |
| 96 | + | |
| 97 | + private fun field(input: JsonElement, vararg keys: String): String? = keys.firstNotNullOfOrNull { input.str(it) } | |
| 98 | + | |
| 99 | + /** A thread's subagents, in the order they started. */ | |
| 100 | + private fun subagentsOf(items: List<SavedItem>, tasks: List<cc.vectrum.code.net.RemoteTask>): List<Subagent> { | |
| 101 | + val subagents = mutableListOf<Subagent>() | |
| 102 | + items.forEachIndexed { ix, item -> | |
| 103 | + val tool = item as? SavedItem.Tool ?: return@forEachIndexed | |
| 104 | + val progress = tool.subagent ?: return@forEachIndexed | |
| 105 | + val callRunning = tool.state == "running" | |
| 106 | + val delegation = bare(tool.name) == "delegate_task" | |
| 107 | + val swarm = tool.name == "swarm" | |
| 108 | + if (progress.members.isNotEmpty()) { | |
| 109 | + progress.members.forEachIndexed { member, entry -> | |
| 110 | + var status = taskStatus(entry.status) | |
| 111 | + // A Pi subagent runs inside its call; a swarm agent or a delegated task outlives it. | |
| 112 | + if (status == TaskStatus.Running && !callRunning && !(swarm || delegation)) status = TaskStatus.Stopped | |
| 113 | + val running = status == TaskStatus.Running | |
| 114 | + subagents.add( | |
| 115 | + Subagent( | |
| 116 | + id = "$ix/$member", | |
| 117 | + description = entry.label, | |
| 118 | + kind = entry.kind, | |
| 119 | + status = status, | |
| 120 | + background = false, | |
| 121 | + toolUses = entry.toolUses, | |
| 122 | + tokens = entry.tokens, | |
| 123 | + started = entry.started, | |
| 124 | + finished = entry.finished, | |
| 125 | + tool = entry.tool.takeIf { running }, | |
| 126 | + summary = entry.summary, | |
| 127 | + thread = entry.id.takeIf { delegation }, | |
| 128 | + // A swarm agent stops from the host's own jcode, which a client can't reach. | |
| 129 | + stop = when { | |
| 130 | + !running -> null | |
| 131 | + delegation -> Stop.Thread(entry.id) | |
| 132 | + swarm -> null | |
| 133 | + else -> Stop.Turn | |
| 134 | + }, | |
| 135 | + ), | |
| 136 | + ) | |
| 137 | + } | |
| 138 | + return@forEachIndexed | |
| 139 | + } | |
| 140 | + // Only a swarm call that spawns an agent starts one. | |
| 141 | + if (swarm && tool.input.str("action") != "spawn") return@forEachIndexed | |
| 142 | + val background = tool.background | |
| 143 | + val status = when { | |
| 144 | + background != null && (tool.state == "running" || tool.state == "done") -> taskStatus(background) | |
| 145 | + tool.state == "error" -> TaskStatus.Failed | |
| 146 | + tool.state == "running" -> TaskStatus.Running | |
| 147 | + else -> TaskStatus.Completed | |
| 148 | + } | |
| 149 | + val running = status == TaskStatus.Running | |
| 150 | + val several = (tool.input as? JsonObject)?.let { it["tasks"] ?: it["chain"] } as? JsonArray | |
| 151 | + val label = field(tool.input, "description", "label", "title", "task")?.takeIf { it.isNotBlank() } | |
| 152 | + ?: several?.let { "${it.size} subagents" } | |
| 153 | + ?: "Subagent" | |
| 154 | + // The host doesn't send the call's task id; its background task, by the same description, has it. | |
| 155 | + val task = tasks.singleOrNull { it.description == label }?.taskId | |
| 156 | + subagents.add( | |
| 157 | + Subagent( | |
| 158 | + id = "$ix", | |
| 159 | + description = label, | |
| 160 | + kind = field(tool.input, "subagent_type", "agent"), | |
| 161 | + status = status, | |
| 162 | + background = background != null, | |
| 163 | + toolUses = tool.subagentToolCount, | |
| 164 | + tokens = progress.tokens, | |
| 165 | + started = progress.started, | |
| 166 | + finished = progress.finished, | |
| 167 | + tool = progress.tool.takeIf { running }, | |
| 168 | + summary = progress.summary, | |
| 169 | + thread = null, | |
| 170 | + stop = task?.takeIf { running }?.let { Stop.Task(it) }, | |
| 171 | + ), | |
| 172 | + ) | |
| 173 | + } | |
| 174 | + return subagents | |
| 175 | + } | |
| 176 | + | |
| 177 | + /** How long it ran, or has run while it still does. */ | |
| 178 | + private fun elapsed(started: Long, finished: Long?, running: Boolean, now: Long): Long? { | |
| 179 | + val end = finished ?: if (running) now else return null | |
| 180 | + return if (started > 0) (end - started).coerceAtLeast(0) else null | |
| 181 | + } | |
| 182 | + | |
| 183 | + /** `812 tokens`, `23.4k tokens`, `1.2M tokens`. */ | |
| 184 | + fun formatTokens(tokens: Long): String = when { | |
| 185 | + tokens < 1_000 -> "$tokens tokens" | |
| 186 | + tokens < 1_000_000 -> String.format(java.util.Locale.ROOT, "%.1fk tokens", tokens / 1_000.0).replace(".0k", "k") | |
| 187 | + else -> String.format(java.util.Locale.ROOT, "%.1fM tokens", tokens / 1_000_000.0).replace(".0M", "M") | |
| 188 | + } | |
| 189 | + | |
| 190 | + /** The subagents panel for a thread. */ | |
| 191 | + @Composable | |
| 192 | + fun SubagentsPanel(app: AppState, nav: Nav, ref: ThreadRef) { | |
| 193 | + val p = palette | |
| 194 | + val host = app.host(ref.host) | |
| 195 | + val thread = host?.open?.get(ref.thread) | |
| 196 | + val all = if (thread != null) subagentsOf(thread.items, thread.detail.backgroundTasks) else emptyList() | |
| 197 | + val working = all.filter { it.status == TaskStatus.Running } | |
| 198 | + val finished = all.filter { it.status != TaskStatus.Running }.asReversed() | |
| 199 | + val now by rememberNow(1_000) | |
| 200 | + val cwd = thread?.detail?.cwd.orEmpty() | |
| 201 | + Column(Modifier.fillMaxSize()) { | |
| 202 | + PanelHeader(Lucide.Bot, "Subagents", tag = if (working.isNotEmpty()) "${working.size} working" else null) { | |
| 203 | + nav.surface = null | |
| 204 | + } | |
| 205 | + Box(Modifier.padding(horizontal = 14.dp, vertical = 4.dp)) { Rule() } | |
| 206 | + Column( | |
| 207 | + Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 6.dp, vertical = 4.dp), | |
| 208 | + verticalArrangement = Arrangement.spacedBy(2.dp), | |
| 209 | + ) { | |
| 210 | + if (working.isEmpty() && finished.isEmpty()) { | |
| 211 | + Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { | |
| 212 | + Txt("No subagents in this thread", color = p.ink2, size = 13.sp) | |
| 213 | + Txt( | |
| 214 | + "When the agent hands work to a subagent, it shows up here with what it's doing.", | |
| 215 | + color = p.ink3, | |
| 216 | + size = 12.sp, | |
| 217 | + lineHeight = 17.sp, | |
| 218 | + ) | |
| 219 | + } | |
| 220 | + } | |
| 221 | + if (working.isNotEmpty()) { | |
| 222 | + Engraved("Working", Modifier.padding(start = 10.dp, end = 10.dp, top = 8.dp, bottom = 4.dp)) | |
| 223 | + for (subagent in working) SubagentRow(nav, host!!, ref, subagent, cwd, now) | |
| 224 | + } | |
| 225 | + if (finished.isNotEmpty()) { | |
| 226 | + Engraved("Finished · ${finished.size}", Modifier.padding(start = 10.dp, end = 10.dp, top = 8.dp, bottom = 4.dp)) | |
| 227 | + for (subagent in finished) SubagentRow(nav, host!!, ref, subagent, cwd, now) | |
| 228 | + } | |
| 229 | + } | |
| 230 | + } | |
| 231 | + } | |
| 232 | + | |
| 233 | + /** | |
| 234 | + * One subagent: its light, what it was asked to do and for how long, its | |
| 235 | + * kind and totals, and while it works, what it's doing now. | |
| 236 | + */ | |
| 237 | + @Composable | |
| 238 | + private fun SubagentRow(nav: Nav, host: HostState, ref: ThreadRef, subagent: Subagent, cwd: String, now: Long) { | |
| 239 | + val p = palette | |
| 240 | + val running = subagent.status == TaskStatus.Running | |
| 241 | + val led = when (subagent.status) { | |
| 242 | + TaskStatus.Running -> Led.Live | |
| 243 | + TaskStatus.Failed -> Led.Failed | |
| 244 | + else -> Led.Off | |
| 245 | + } | |
| 246 | + // A delegated task's agent can stop to ask the user something. | |
| 247 | + val child = subagent.thread?.let { host.threads[it] } | |
| 248 | + val waiting = running && child?.attention == true | |
| 249 | + val status = when { | |
| 250 | + waiting -> "Waiting for you" | |
| 251 | + subagent.status == TaskStatus.Failed -> "Failed" | |
| 252 | + subagent.status == TaskStatus.Stopped -> "Stopped" | |
| 253 | + else -> null | |
| 254 | + } | |
| 255 | + val meta = listOfNotNull( | |
| 256 | + subagent.kind?.takeIf { it.isNotEmpty() && it != "general-purpose" }, | |
| 257 | + subagent.toolUses.takeIf { it > 0 }?.let { "$it ${if (it == 1) "tool" else "tools"}" }, | |
| 258 | + subagent.tokens?.takeIf { it > 0 }?.let { formatTokens(it) }, | |
| 259 | + "background".takeIf { subagent.background }, | |
| 260 | + ) | |
| 261 | + val elapsed = elapsed(subagent.started, subagent.finished, running, now) | |
| 262 | + // A tap opens the thread a delegated task runs in. | |
| 263 | + val opens = child != null && child.parent == ref.thread | |
| 264 | + Row( | |
| 265 | + Modifier | |
| 266 | + .fillMaxWidth() | |
| 267 | + .then(if (opens) Modifier.pressTint(12.dp) { nav.openThread(ThreadRef(ref.host, child.id)) } else Modifier) | |
| 268 | + .padding(horizontal = 10.dp, vertical = 7.dp), | |
| 269 | + horizontalArrangement = Arrangement.spacedBy(10.dp), | |
| 270 | + ) { | |
| 271 | + Box(Modifier.height(18.dp), contentAlignment = Alignment.Center) { StatusLed(led, pulse = running) } | |
| 272 | + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { | |
| 273 | + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 274 | + Txt(subagent.description, Modifier.weight(1f), color = if (running) p.ink else p.ink2, size = 13.sp, maxLines = 1) | |
| 275 | + if (status != null) { | |
| 276 | + Txt(status, color = if (subagent.status == TaskStatus.Failed) p.red else p.ink3, size = 11.5.sp, maxLines = 1) | |
| 277 | + } | |
| 278 | + if (elapsed != null) Txt(formatDuration(elapsed), color = p.ink3, size = 11.sp, family = Mono, maxLines = 1) | |
| 279 | + subagent.stop?.let { stop -> | |
| 280 | + Cap(icon = Lucide.Square, kind = CapKind.Ghost, size = CapSize.Mini) { | |
| 281 | + when (stop) { | |
| 282 | + is Stop.Task -> host.act(ref.thread, ThreadAction.StopTask(stop.taskId)) | |
| 283 | + Stop.Turn -> host.act(ref.thread, ThreadAction.Interrupt) | |
| 284 | + is Stop.Thread -> host.act(stop.id, ThreadAction.Interrupt) | |
| 285 | + } | |
| 286 | + } | |
| 287 | + } | |
| 288 | + } | |
| 289 | + subagent.tool?.let { call -> | |
| 290 | + val name = runCatching { call[0].jsonPrimitive.content }.getOrNull() | |
| 291 | + if (name != null) { | |
| 292 | + val (icon, label) = describeCall(name, call.getOrNull(1) ?: JsonNull, cwd) | |
| 293 | + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 294 | + LucideIcon(icon, size = 13.dp, tint = p.ink3) | |
| 295 | + Txt(label, Modifier.weight(1f), color = p.ink2, size = 12.sp, maxLines = 1) | |
| 296 | + } | |
| 297 | + } | |
| 298 | + } | |
| 299 | + if (running) subagent.summary?.let { Txt(it, color = p.ink3, size = 12.sp, maxLines = 2, lineHeight = 16.sp) } | |
| 300 | + if (meta.isNotEmpty()) Txt(meta.joinToString(" · "), color = p.ink3, size = 10.5.sp, family = Mono, maxLines = 1) | |
| 301 | + } | |
| 302 | + } | |
| 303 | + } | |
| 304 | + | |
| 305 | + /** A path relative to the thread's folder. */ | |
| 306 | + private fun relativePath(path: String, cwd: String): String = | |
| 307 | + if (cwd.isNotEmpty() && path.startsWith("$cwd/")) path.removePrefix("$cwd/") else path | |
| 308 | + | |
| 309 | + /** A tool call's icon and label as the timeline shows them, for a call known only by its name and input. */ | |
| 310 | + private fun describeCall(name: String, input: JsonElement, cwd: String): Pair<Lucide, String> { | |
| 311 | + fun firstLine(text: String) = text.lineSequence().firstOrNull().orEmpty().take(200) | |
| 312 | + when (bare(name)) { | |
| 313 | + "delegate_task" -> return Lucide.Bot to (field(input, "title") ?: field(input, "task"))?.let(::firstLine).orEmpty().ifEmpty { "Subagent" } | |
| 314 | + "task_cancel" -> return Lucide.Bot to "Stopped a subagent" | |
| 315 | + "orchestrator_capabilities" -> return Lucide.Bot to "Looked up agents and models" | |
| 316 | + "task_status" -> return Lucide.Bot to "Checked on a subagent" | |
| 317 | + "device_list" -> return Lucide.Smartphone to "Listed devices" | |
| 318 | + "device_open" -> return Lucide.Smartphone to "Opened a device" | |
| 319 | + "device_close" -> return Lucide.Smartphone to "Closed devices" | |
| 320 | + "device_screenshot" -> return Lucide.Smartphone to "Took a device screenshot" | |
| 321 | + } | |
| 322 | + val path = { keys: Array<String> -> field(input, *keys)?.let { relativePath(it, cwd) } } | |
| 323 | + return when (name.lowercase()) { | |
| 324 | + "bash" -> Lucide.Terminal to (field(input, "command")?.let(::firstLine) ?: "Ran command") | |
| 325 | + "bashoutput", "killshell" -> Lucide.Terminal to name | |
| 326 | + "read", "notebookread" -> Lucide.FileText to (path(arrayOf("file_path", "path", "notebook_path"))?.let { "Read $it" } ?: "Read file") | |
| 327 | + "write", "edit", "multiedit", "notebookedit" -> | |
| 328 | + Lucide.SquarePen to (path(arrayOf("file_path", "path", "notebook_path")) ?: "Changed a file") | |
| 329 | + "grep", "glob", "find" -> Lucide.Search to (field(input, "pattern", "query")?.let { "Searched for ${firstLine(it)}" } ?: "Searched code") | |
| 330 | + "ls" -> Lucide.Search to (path(arrayOf("path"))?.let { "Listed $it" } ?: "Listed files") | |
| 331 | + "websearch" -> Lucide.Globe to (field(input, "query")?.let { "Web search: $it" } ?: "Web search") | |
| 332 | + "webfetch" -> Lucide.Globe to (field(input, "url") ?: "Fetched a page") | |
| 333 | + "task", "agent" -> Lucide.Bot to (field(input, "description") ?: "Subagent") | |
| 334 | + "subagent" -> Lucide.Bot to (field(input, "description", "task")?.let(::firstLine) ?: "Subagent") | |
| 335 | + "swarm" -> Lucide.Bot to "Swarm" | |
| 336 | + "monitor" -> Lucide.Eye to (field(input, "description", "command")?.let(::firstLine) ?: "Monitor") | |
| 337 | + else -> if (name.startsWith("mcp__")) { | |
| 338 | + val parts = name.removePrefix("mcp__").split("__", limit = 2) | |
| 339 | + Lucide.Hammer to "${parts[0]}: ${parts.getOrElse(1) { "" }.replace('_', ' ')}" | |
| 340 | + } else { | |
| 341 | + Lucide.Wrench to name | |
| 342 | + } | |
| 343 | + } | |
| 344 | + } | |
| @@ -0,0 +1,9 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="M3 5a2 2 0 0 0 2 2h3" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + <path android:pathData="M3 3v13a2 2 0 0 0 2 2h3" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 9 | + </vector> | |
| @@ -0,0 +1,8 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M15,18a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M3,6a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="M6 21V9a9 9 0 0 0 9 9" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + </vector> | |
| @@ -0,0 +1,10 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M2,6a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M5 9v12" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="M16,18a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + <path android:pathData="m15 9-3-3 3-3" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 9 | + <path android:pathData="M12 6h5a2 2 0 0 1 2 2v7" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 10 | + </vector> | |
| @@ -0,0 +1,11 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="m15.5 3.5 5 5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="m15.5 8.5 5-5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="M18 11.62V15" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + <path android:pathData="M6 9v12" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 9 | + <path android:pathData="M15,18a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 10 | + <path android:pathData="M3,6a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 11 | + </vector> | |
| @@ -0,0 +1,10 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M15,18a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M3,6a3,3 0 1 0 6,0a3,3 0 1 0 -6,0Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="M18 6V5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + <path android:pathData="M18 11v-1" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 9 | + <path android:pathData="M6,9 L6,21" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 10 | + </vector> | |
| @@ -0,0 +1,7 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + </vector> | |
| @@ -0,0 +1,7 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0l8 6Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + </vector> | |
| @@ -0,0 +1,8 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M12 8v6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="M9 11h6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + </vector> | |
| @@ -0,0 +1,8 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="M4,3h16a2,2 0 0 1 2,2v10a2,2 0 0 1 -2,2h-16a2,2 0 0 1 -2,-2v-10a2,2 0 0 1 2,-2Z" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="M8,21 L16,21" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="M12,17 L12,21" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + </vector> | |
| @@ -6,3 +6,4 @@ circle-alert triangle-alert info loader-circle play rotate-ccw rotate-cw refresh | ||
| 6 | 6 | clock zap sparkles message-circle-question-mark corner-down-right users workflow hard-drive network external-link house undo-2 power camera wrench plus minus hash at-sign dollar-sign |
| 7 | 7 | wand-sparkles feather code list send-horizontal circle circle-dot square-dashed bell git-fork book-open bug hammer mic blocks arrow-down-to-line square-stop circle-stop |
| 8 | 8 | moon-star type contrast map-pin navigation shield move-left move-right inbox mail message-square list-checks chart-no-axes-column plug-zap cpu memory-stick |
| 9 | + folder-tree git-merge git-pull-request-arrow git-pull-request-closed git-pull-request-draft link mail-open message-square-plus monitor | |