Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 22 changed files with 3630 additions and 12 deletions.
| @@ -73,7 +73,11 @@ enum class Lucide(val res: Int) { | ||
| 73 | 73 | GitBranch(R.drawable.lucide_git_branch), |
| 74 | 74 | GitCommitHorizontal(R.drawable.lucide_git_commit_horizontal), |
| 75 | 75 | GitFork(R.drawable.lucide_git_fork), |
| 76 | + GitMerge(R.drawable.lucide_git_merge), | |
| 76 | 77 | GitPullRequest(R.drawable.lucide_git_pull_request), |
| 78 | + GitPullRequestArrow(R.drawable.lucide_git_pull_request_arrow), | |
| 79 | + GitPullRequestClosed(R.drawable.lucide_git_pull_request_closed), | |
| 80 | + GitPullRequestDraft(R.drawable.lucide_git_pull_request_draft), | |
| 77 | 81 | Github(R.drawable.lucide_github), |
| 78 | 82 | Globe(R.drawable.lucide_globe), |
| 79 | 83 | Hammer(R.drawable.lucide_hammer), |
| @@ -97,9 +101,11 @@ enum class Lucide(val res: Int) { | ||
| 97 | 101 | Mail(R.drawable.lucide_mail), |
| 98 | 102 | MapPin(R.drawable.lucide_map_pin), |
| 99 | 103 | MemoryStick(R.drawable.lucide_memory_stick), |
| 104 | + MessageCircle(R.drawable.lucide_message_circle), | |
| 100 | 105 | MessageCircleQuestionMark(R.drawable.lucide_message_circle_question_mark), |
| 101 | 106 | MessageSquare(R.drawable.lucide_message_square), |
| 102 | 107 | Mic(R.drawable.lucide_mic), |
| 108 | + Minimize2(R.drawable.lucide_minimize_2), | |
| 103 | 109 | Minus(R.drawable.lucide_minus), |
| 104 | 110 | MonitorSmartphone(R.drawable.lucide_monitor_smartphone), |
| 105 | 111 | Moon(R.drawable.lucide_moon), |
| @@ -137,6 +143,7 @@ enum class Lucide(val res: Int) { | ||
| 137 | 143 | ShieldCheck(R.drawable.lucide_shield_check), |
| 138 | 144 | SlidersHorizontal(R.drawable.lucide_sliders_horizontal), |
| 139 | 145 | Smartphone(R.drawable.lucide_smartphone), |
| 146 | + Space(R.drawable.lucide_space), | |
| 140 | 147 | Sparkles(R.drawable.lucide_sparkles), |
| 141 | 148 | Square(R.drawable.lucide_square), |
| 142 | 149 | SquareDashed(R.drawable.lucide_square_dashed), |
| @@ -0,0 +1,177 @@ | ||
| 1 | + package cc.vectrum.code.ui.chat | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.clickable | |
| 4 | + import androidx.compose.foundation.layout.Arrangement | |
| 5 | + import androidx.compose.foundation.layout.Column | |
| 6 | + import androidx.compose.foundation.layout.Row | |
| 7 | + import androidx.compose.foundation.layout.Spacer | |
| 8 | + import androidx.compose.foundation.layout.defaultMinSize | |
| 9 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 10 | + import androidx.compose.foundation.layout.padding | |
| 11 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 12 | + import androidx.compose.runtime.Composable | |
| 13 | + import androidx.compose.ui.Alignment | |
| 14 | + import androidx.compose.ui.Modifier | |
| 15 | + import androidx.compose.ui.draw.clip | |
| 16 | + import androidx.compose.ui.text.font.FontWeight | |
| 17 | + import androidx.compose.ui.unit.dp | |
| 18 | + import androidx.compose.ui.unit.sp | |
| 19 | + import cc.vectrum.code.net.FileChange | |
| 20 | + import cc.vectrum.code.net.TurnChanges | |
| 21 | + import cc.vectrum.code.theme.Cap | |
| 22 | + import cc.vectrum.code.theme.CapKind | |
| 23 | + import cc.vectrum.code.theme.CapSize | |
| 24 | + import cc.vectrum.code.theme.Mono | |
| 25 | + import cc.vectrum.code.theme.Txt | |
| 26 | + import cc.vectrum.code.theme.palette | |
| 27 | + import cc.vectrum.code.theme.region | |
| 28 | + import cc.vectrum.code.ui.Lucide | |
| 29 | + import cc.vectrum.code.ui.LucideIcon | |
| 30 | + import cc.vectrum.code.ui.diff.Counts | |
| 31 | + import cc.vectrum.code.ui.diff.DiffInk | |
| 32 | + import cc.vectrum.code.ui.diff.fileIcon | |
| 33 | + | |
| 34 | + // What a turn changed on disk, under the turn (src/ui/changes.rs). | |
| 35 | + | |
| 36 | + private class Dir { | |
| 37 | + val children = sortedMapOf<String, Dir>() | |
| 38 | + val files = mutableListOf<FileChange>() | |
| 39 | + | |
| 40 | + fun totals(): Pair<Long, Long> { | |
| 41 | + var added = files.sumOf { it.added ?: 0 } | |
| 42 | + var removed = files.sumOf { it.removed ?: 0 } | |
| 43 | + for (child in children.values) { | |
| 44 | + val (a, r) = child.totals() | |
| 45 | + added += a | |
| 46 | + removed += r | |
| 47 | + } | |
| 48 | + return added to removed | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + /** A row of the changes tree. */ | |
| 53 | + sealed interface ChangeRow { | |
| 54 | + val depth: Int | |
| 55 | + | |
| 56 | + data class Folder(val path: String, val name: String, override val depth: Int, val open: Boolean, val added: Long, val removed: Long) : ChangeRow | |
| 57 | + | |
| 58 | + data class File(val change: FileChange, val name: String, override val depth: Int) : ChangeRow | |
| 59 | + } | |
| 60 | + | |
| 61 | + private fun buildTree(files: List<FileChange>): Dir { | |
| 62 | + val root = Dir() | |
| 63 | + for (file in files) { | |
| 64 | + var dir = root | |
| 65 | + for (part in file.path.split('/').dropLast(1)) dir = dir.children.getOrPut(part) { Dir() } | |
| 66 | + dir.files.add(file) | |
| 67 | + } | |
| 68 | + return root | |
| 69 | + } | |
| 70 | + | |
| 71 | + /** | |
| 72 | + * Lays the tree out as rows, folders first. A folder holding nothing but | |
| 73 | + * one other folder shares its row (`src/ui`). | |
| 74 | + */ | |
| 75 | + private fun flatten(dir: Dir, prefix: String, depth: Int, open: (String) -> Boolean, rows: MutableList<ChangeRow>) { | |
| 76 | + for ((childName, first) in dir.children) { | |
| 77 | + var name = childName | |
| 78 | + var child = first | |
| 79 | + while (child.files.isEmpty() && child.children.size == 1) { | |
| 80 | + val (grandName, grandchild) = child.children.entries.first() | |
| 81 | + name = "$name/$grandName" | |
| 82 | + child = grandchild | |
| 83 | + } | |
| 84 | + val path = if (prefix.isEmpty()) name else "$prefix/$name" | |
| 85 | + val isOpen = open(path) | |
| 86 | + val (added, removed) = child.totals() | |
| 87 | + rows.add(ChangeRow.Folder(path, name, depth, isOpen, added, removed)) | |
| 88 | + if (isOpen) flatten(child, path, depth + 1, open, rows) | |
| 89 | + } | |
| 90 | + for (file in dir.files) rows.add(ChangeRow.File(file, file.path.substringAfterLast('/'), depth)) | |
| 91 | + } | |
| 92 | + | |
| 93 | + /** The changes tree's rows, the folders in `open` unfolded. */ | |
| 94 | + fun changeRows(changes: TurnChanges, open: Set<String>): List<ChangeRow> = | |
| 95 | + mutableListOf<ChangeRow>().also { flatten(buildTree(changes.files), "", 0, { it in open }, it) } | |
| 96 | + | |
| 97 | + /** Every folder path in the tree, as the rows name them. */ | |
| 98 | + fun allDirs(changes: TurnChanges): Set<String> = | |
| 99 | + mutableListOf<ChangeRow>().also { flatten(buildTree(changes.files), "", 0, { true }, it) } | |
| 100 | + .filterIsInstance<ChangeRow.Folder>() | |
| 101 | + .map { it.path } | |
| 102 | + .toSet() | |
| 103 | + | |
| 104 | + /** | |
| 105 | + * The overview under a finished turn: `N changed files +a −r`, a folder tree | |
| 106 | + * with per-file counts, and a way into the diff. `onOpen` gets a file's path, | |
| 107 | + * or `null` for the whole turn. | |
| 108 | + */ | |
| 109 | + @Composable | |
| 110 | + fun ChangesCard( | |
| 111 | + changes: TurnChanges, | |
| 112 | + openDirs: Set<String>, | |
| 113 | + ink: DiffInk, | |
| 114 | + onDirs: (Set<String>) -> Unit, | |
| 115 | + onOpen: (String?) -> Unit, | |
| 116 | + ) { | |
| 117 | + val p = palette | |
| 118 | + val rows = changeRows(changes, openDirs) | |
| 119 | + val anyOpen = rows.any { it is ChangeRow.Folder && it.open } | |
| 120 | + val count = changes.files.size | |
| 121 | + Column(Modifier.fillMaxWidth().padding(top = 16.dp).region(p, RoundedCornerShape(10.dp))) { | |
| 122 | + Row( | |
| 123 | + Modifier.fillMaxWidth().padding(start = 12.dp, end = 6.dp, top = 6.dp, bottom = 6.dp), | |
| 124 | + horizontalArrangement = Arrangement.spacedBy(12.dp), | |
| 125 | + verticalAlignment = Alignment.CenterVertically, | |
| 126 | + ) { | |
| 127 | + Txt( | |
| 128 | + "$count changed file${if (count == 1) "" else "s"}", | |
| 129 | + color = p.ink, | |
| 130 | + size = 12.sp, | |
| 131 | + weight = FontWeight.Medium, | |
| 132 | + maxLines = 1, | |
| 133 | + ) | |
| 134 | + Counts(changes.added, changes.removed, ink) | |
| 135 | + Spacer(Modifier.weight(1f)) | |
| 136 | + if (rows.any { it is ChangeRow.Folder }) { | |
| 137 | + Cap(icon = Lucide.ChevronsUpDown, kind = CapKind.Ghost, size = CapSize.Mini) { | |
| 138 | + onDirs(if (anyOpen) emptySet() else allDirs(changes)) | |
| 139 | + } | |
| 140 | + } | |
| 141 | + Cap(label = "Open diff", icon = Lucide.FileDiff, kind = CapKind.Ghost, size = CapSize.Compact) { onOpen(null) } | |
| 142 | + } | |
| 143 | + Column(Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)) { | |
| 144 | + for (row in rows) { | |
| 145 | + Row( | |
| 146 | + Modifier | |
| 147 | + .fillMaxWidth() | |
| 148 | + .defaultMinSize(minHeight = 34.dp) | |
| 149 | + .clip(RoundedCornerShape(8.dp)) | |
| 150 | + .clickable { | |
| 151 | + when (row) { | |
| 152 | + is ChangeRow.Folder -> onDirs(if (row.open) openDirs - row.path else openDirs + row.path) | |
| 153 | + is ChangeRow.File -> onOpen(row.change.path) | |
| 154 | + } | |
| 155 | + } | |
| 156 | + .padding(start = 8.dp + 14.dp * row.depth, end = 8.dp, top = 6.dp, bottom = 6.dp), | |
| 157 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 158 | + verticalAlignment = Alignment.CenterVertically, | |
| 159 | + ) { | |
| 160 | + when (row) { | |
| 161 | + is ChangeRow.Folder -> { | |
| 162 | + LucideIcon(if (row.open) Lucide.ChevronDown else Lucide.ChevronRight, size = 12.dp, tint = p.ink3) | |
| 163 | + LucideIcon(if (row.open) Lucide.FolderOpen else Lucide.Folder, size = 14.dp, tint = p.ink3) | |
| 164 | + Txt(row.name, Modifier.weight(1f), color = p.ink2, size = 12.5.sp, family = Mono, maxLines = 1) | |
| 165 | + if (!row.open) Counts(row.added, row.removed, ink) | |
| 166 | + } | |
| 167 | + is ChangeRow.File -> { | |
| 168 | + LucideIcon(fileIcon(row.name), size = 14.dp, tint = p.ink3) | |
| 169 | + Txt(row.name, Modifier.weight(1f), color = p.ink, size = 12.5.sp, family = Mono, maxLines = 1) | |
| 170 | + Counts(row.change.added, row.change.removed, ink) | |
| 171 | + } | |
| 172 | + } | |
| 173 | + } | |
| 174 | + } | |
| 175 | + } | |
| 176 | + } | |
| 177 | + } | |
| @@ -0,0 +1,83 @@ | ||
| 1 | + package cc.vectrum.code.ui.chat | |
| 2 | + | |
| 3 | + import android.content.Context | |
| 4 | + import android.text.format.DateFormat | |
| 5 | + import java.time.DayOfWeek | |
| 6 | + import java.time.Instant | |
| 7 | + import java.time.LocalDate | |
| 8 | + import java.time.ZoneId | |
| 9 | + import java.time.ZonedDateTime | |
| 10 | + import java.time.format.DateTimeFormatter | |
| 11 | + import java.time.temporal.ChronoUnit | |
| 12 | + import java.util.Locale | |
| 13 | + | |
| 14 | + // The desktop's clock (src/clock.rs): times as the Time format setting | |
| 15 | + // writes them. | |
| 16 | + | |
| 17 | + /** Whether times read `2:05 PM` rather than `14:05`, by the `time_format` setting. */ | |
| 18 | + fun uses12Hours(context: Context, timeFormat: String): Boolean = when (timeFormat) { | |
| 19 | + "hour12" -> true | |
| 20 | + "hour24" -> false | |
| 21 | + else -> !DateFormat.is24HourFormat(context) | |
| 22 | + } | |
| 23 | + | |
| 24 | + private fun local(millis: Long): ZonedDateTime = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()) | |
| 25 | + | |
| 26 | + /** `14:32`, or `2:32 PM`. */ | |
| 27 | + fun clock(millis: Long, twelve: Boolean): String = | |
| 28 | + local(millis).format(DateTimeFormatter.ofPattern(if (twelve) "h:mm a" else "HH:mm", Locale.US)) | |
| 29 | + | |
| 30 | + /** A message's time: `14:32`, `yesterday at 14:32`, `6/4 14:32`. */ | |
| 31 | + fun dayAware(millis: Long, twelve: Boolean): String { | |
| 32 | + val time = local(millis) | |
| 33 | + val days = ChronoUnit.DAYS.between(LocalDate.now(), time.toLocalDate()) | |
| 34 | + val clock = clock(millis, twelve) | |
| 35 | + return when { | |
| 36 | + days == 0L -> clock | |
| 37 | + days == -1L -> "yesterday at $clock" | |
| 38 | + time.year == LocalDate.now().year -> "${time.monthValue}/${time.dayOfMonth} $clock" | |
| 39 | + else -> "${time.monthValue}/${time.dayOfMonth}/${time.year} $clock" | |
| 40 | + } | |
| 41 | + } | |
| 42 | + | |
| 43 | + /** A moment near now, as briefly as it reads clearly: `14:32`, `tomorrow 09:00`, `Fri 09:00`. */ | |
| 44 | + fun clockWhen(millis: Long, twelve: Boolean): String { | |
| 45 | + val time = local(millis) | |
| 46 | + val days = ChronoUnit.DAYS.between(LocalDate.now(), time.toLocalDate()) | |
| 47 | + val clock = clock(millis, twelve) | |
| 48 | + return when { | |
| 49 | + days == 0L -> clock | |
| 50 | + days == 1L -> "tomorrow $clock" | |
| 51 | + days == -1L -> "yesterday $clock" | |
| 52 | + days in 2..6 -> "${time.format(DateTimeFormatter.ofPattern("EEE", Locale.US))} $clock" | |
| 53 | + time.year == LocalDate.now().year -> "${time.format(DateTimeFormatter.ofPattern("MMM d", Locale.US))}, $clock" | |
| 54 | + else -> "${time.format(DateTimeFormatter.ofPattern("MMM d yyyy", Locale.US))}, $clock" | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + /** A snooze preset: `In 1 hour (15:32)`. */ | |
| 59 | + data class SnoozePreset(val label: String, val time: String, val until: Long) | |
| 60 | + | |
| 61 | + private fun atHour(days: Long, hour: Int): Long = | |
| 62 | + LocalDate.now().plusDays(days).atTime(hour, 0).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() | |
| 63 | + | |
| 64 | + /** T3's snooze presets with their wake times. */ | |
| 65 | + fun snoozePresets(twelve: Boolean, now: Long = System.currentTimeMillis()): List<SnoozePreset> { | |
| 66 | + val hour = 3_600_000L | |
| 67 | + val presets = mutableListOf( | |
| 68 | + SnoozePreset("In 1 hour", clock(now + hour, twelve), now + hour), | |
| 69 | + SnoozePreset("In 3 hours", clock(now + 3 * hour, twelve), now + 3 * hour), | |
| 70 | + ) | |
| 71 | + val evening = atHour(0, 18) | |
| 72 | + if (evening - now > hour) presets.add(SnoozePreset("This evening", clock(evening, twelve), evening)) | |
| 73 | + val tomorrow = atHour(1, 9) | |
| 74 | + presets.add(SnoozePreset("Tomorrow", clock(tomorrow, twelve), tomorrow)) | |
| 75 | + val weekday = LocalDate.now().dayOfWeek | |
| 76 | + val untilMonday = ((DayOfWeek.MONDAY.value - weekday.value + 7) % 7).let { if (it == 0) 7 else it } | |
| 77 | + val nextWeek = atHour(untilMonday.toLong(), 9) | |
| 78 | + if (nextWeek != tomorrow) { | |
| 79 | + val day = local(nextWeek).format(DateTimeFormatter.ofPattern("EEE", Locale.US)) | |
| 80 | + presets.add(SnoozePreset("Next week", "$day ${clock(nextWeek, twelve)}", nextWeek)) | |
| 81 | + } | |
| 82 | + return presets | |
| 83 | + } | |
| @@ -1,19 +1,180 @@ | ||
| 1 | 1 | package cc.vectrum.code.ui.chat |
| 2 | 2 | |
| 3 | + import android.content.Intent | |
| 4 | + import androidx.compose.foundation.clickable | |
| 5 | + import androidx.compose.foundation.layout.Arrangement | |
| 3 | 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.fillMaxWidth | |
| 10 | + import androidx.compose.foundation.layout.height | |
| 4 | 11 | import androidx.compose.foundation.layout.padding |
| 12 | + import androidx.compose.foundation.layout.widthIn | |
| 13 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 5 | 14 | import androidx.compose.runtime.Composable |
| 15 | + import androidx.compose.runtime.derivedStateOf | |
| 16 | + import androidx.compose.runtime.getValue | |
| 17 | + import androidx.compose.runtime.mutableStateOf | |
| 18 | + import androidx.compose.runtime.remember | |
| 19 | + import androidx.compose.runtime.setValue | |
| 20 | + import androidx.compose.ui.Alignment | |
| 6 | 21 | import androidx.compose.ui.Modifier |
| 22 | + import androidx.compose.ui.draw.clip | |
| 23 | + import androidx.compose.ui.platform.LocalConfiguration | |
| 24 | + import androidx.compose.ui.platform.LocalContext | |
| 25 | + import androidx.compose.ui.text.font.FontWeight | |
| 7 | 26 | import androidx.compose.ui.unit.dp |
| 27 | + import androidx.compose.ui.unit.sp | |
| 8 | 28 | import cc.vectrum.code.data.AppState |
| 9 | 29 | import cc.vectrum.code.data.OpenThread |
| 10 | 30 | import cc.vectrum.code.data.ThreadRef |
| 11 | - import cc.vectrum.code.theme.Engraved | |
| 31 | + import cc.vectrum.code.net.PullRequest | |
| 32 | + import cc.vectrum.code.theme.Badge | |
| 33 | + import cc.vectrum.code.theme.Cap | |
| 34 | + import cc.vectrum.code.theme.CapKind | |
| 35 | + import cc.vectrum.code.theme.Led | |
| 36 | + import cc.vectrum.code.theme.Rule | |
| 37 | + import cc.vectrum.code.theme.StatusLed | |
| 38 | + import cc.vectrum.code.theme.Tag | |
| 39 | + import cc.vectrum.code.theme.Txt | |
| 40 | + import cc.vectrum.code.theme.palette | |
| 41 | + import cc.vectrum.code.ui.Lucide | |
| 42 | + import cc.vectrum.code.ui.LucideIcon | |
| 12 | 43 | import cc.vectrum.code.ui.Nav |
| 13 | 44 | import cc.vectrum.code.ui.Screen |
| 45 | + import cc.vectrum.code.ui.Surface | |
| 46 | + import cc.vectrum.code.util.initials | |
| 47 | + import cc.vectrum.code.util.tintFor | |
| 14 | 48 | |
| 15 | - // Placeholder until it's built. | |
| 49 | + /** | |
| 50 | + * The chat header, as the desktop lays it out (src/ui/workspace/header.rs): | |
| 51 | + * the sidebar toggle, the project as a "New thread in" breadcrumb, the | |
| 52 | + * thread's title, which opens the thread menu, and the right panel's toggle | |
| 53 | + * with its light while subagents work. | |
| 54 | + */ | |
| 16 | 55 | @Composable |
| 17 | 56 | fun ChatHeader(app: AppState, nav: Nav, thread: OpenThread) { |
| 18 | - Box(Modifier.padding(16.dp)) { Engraved(thread.summary.title) } | |
| 57 | + val p = palette | |
| 58 | + val context = LocalContext.current | |
| 59 | + // On a tablet the sidebar sits beside the sheet, as Root lays it out. | |
| 60 | + val drawer = LocalConfiguration.current.screenWidthDp < 840 | |
| 61 | + val summary = thread.summary | |
| 62 | + val host = thread.host | |
| 63 | + val projectName = thread.project?.name ?: summary.project.trimEnd('/').substringAfterLast('/') | |
| 64 | + val parent = summary.parent?.let { host.threads[it] } | |
| 65 | + val subagentsWorking by remember(thread) { derivedStateOf { workingSubagents(thread.items) } } | |
| 66 | + var menu by remember { mutableStateOf(false) } | |
| 67 | + | |
| 68 | + Column(Modifier.fillMaxWidth()) { | |
| 69 | + Row( | |
| 70 | + Modifier | |
| 71 | + .fillMaxWidth() | |
| 72 | + .height(52.dp) | |
| 73 | + .padding(start = if (drawer) 8.dp else 20.dp, end = 8.dp), | |
| 74 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 75 | + verticalAlignment = Alignment.CenterVertically, | |
| 76 | + ) { | |
| 77 | + if (drawer) Cap(icon = Lucide.PanelLeftOpen, kind = CapKind.Ghost) { nav.sidebarOpen = true } | |
| 78 | + if (projectName.isNotEmpty()) { | |
| 79 | + // "New thread in {project}". | |
| 80 | + Row( | |
| 81 | + Modifier | |
| 82 | + .clip(RoundedCornerShape(8.dp)) | |
| 83 | + .clickable { nav.go(Screen.NewThread(host.saved.id, summary.project)) } | |
| 84 | + .padding(vertical = 6.dp), | |
| 85 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 86 | + verticalAlignment = Alignment.CenterVertically, | |
| 87 | + ) { | |
| 88 | + Badge(initials(projectName), tintFor(projectName)) | |
| 89 | + // A subagent's thread names its parent instead; the badge still says where it is. | |
| 90 | + if (parent == null) { | |
| 91 | + Txt( | |
| 92 | + projectName, | |
| 93 | + Modifier.widthIn(max = if (drawer) 96.dp else 160.dp), | |
| 94 | + color = p.ink3, | |
| 95 | + size = 14.sp, | |
| 96 | + weight = FontWeight.Medium, | |
| 97 | + maxLines = 1, | |
| 98 | + ) | |
| 99 | + } | |
| 100 | + } | |
| 101 | + Txt("/", color = p.icon, size = 14.sp) | |
| 102 | + } | |
| 103 | + // A subagent's thread leads back to the thread that delegated its task. | |
| 104 | + if (parent != null) { | |
| 105 | + Row( | |
| 106 | + Modifier | |
| 107 | + .clip(RoundedCornerShape(8.dp)) | |
| 108 | + .clickable { nav.openThread(ThreadRef(host.saved.id, parent.id)) } | |
| 109 | + .padding(vertical = 6.dp), | |
| 110 | + horizontalArrangement = Arrangement.spacedBy(6.dp), | |
| 111 | + verticalAlignment = Alignment.CenterVertically, | |
| 112 | + ) { | |
| 113 | + Txt(parent.title, Modifier.widthIn(max = if (drawer) 88.dp else 200.dp), color = p.ink3, size = 14.sp, maxLines = 1) | |
| 114 | + Txt("/", color = p.icon, size = 14.sp) | |
| 115 | + LucideIcon(Lucide.Bot, size = 14.dp, tint = p.ink3) | |
| 116 | + } | |
| 117 | + } | |
| 118 | + // The title opens the thread menu. | |
| 119 | + Box(Modifier.weight(1f)) { | |
| 120 | + Row( | |
| 121 | + Modifier | |
| 122 | + .clip(RoundedCornerShape(8.dp)) | |
| 123 | + .clickable { menu = true } | |
| 124 | + .height(32.dp) | |
| 125 | + .padding(horizontal = 6.dp), | |
| 126 | + horizontalArrangement = Arrangement.spacedBy(4.dp), | |
| 127 | + verticalAlignment = Alignment.CenterVertically, | |
| 128 | + ) { | |
| 129 | + Txt( | |
| 130 | + summary.title.ifBlank { "New thread" }, | |
| 131 | + Modifier.weight(1f, fill = false), | |
| 132 | + color = p.ink, | |
| 133 | + size = 14.sp, | |
| 134 | + weight = FontWeight.Medium, | |
| 135 | + maxLines = 1, | |
| 136 | + ) | |
| 137 | + LucideIcon(Lucide.ChevronDown, size = 14.dp, tint = p.ink3) | |
| 138 | + } | |
| 139 | + } | |
| 140 | + // Which host it runs on, once there's more than one to tell apart. | |
| 141 | + if (app.hosts.size > 1) Tag(host.name, Modifier.widthIn(max = 96.dp)) | |
| 142 | + thread.detail.pullRequest?.let { pr -> | |
| 143 | + PullRequestBadge(pr) { | |
| 144 | + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(pr.url))) } | |
| 145 | + } | |
| 146 | + } | |
| 147 | + Box { | |
| 148 | + Cap(icon = Lucide.PanelRightOpen, kind = CapKind.Ghost, selected = nav.surface != null) { nav.surfacePicker = true } | |
| 149 | + if (subagentsWorking > 0 && nav.surface != Surface.Subagents) { | |
| 150 | + StatusLed(Led.Live, Modifier.align(Alignment.TopEnd).padding(top = 3.dp, end = 3.dp), pulse = true) | |
| 151 | + } | |
| 152 | + } | |
| 153 | + } | |
| 154 | + Rule() | |
| 155 | + } | |
| 156 | + ThreadMenu(app, nav, thread, menu) { menu = false } | |
| 157 | + } | |
| 158 | + | |
| 159 | + /** A pull request's state and number, as the sidebar marks threads with one. */ | |
| 160 | + @Composable | |
| 161 | + fun PullRequestBadge(pr: PullRequest, onClick: () -> Unit) { | |
| 162 | + val p = palette | |
| 163 | + val (icon, ink) = when (pr.state) { | |
| 164 | + "MERGED" -> Lucide.GitMerge to p.blueInk | |
| 165 | + "CLOSED" -> Lucide.GitPullRequestClosed to p.red | |
| 166 | + "DRAFT" -> Lucide.GitPullRequestDraft to p.ink3 | |
| 167 | + else -> Lucide.GitPullRequestArrow to p.greenInk | |
| 168 | + } | |
| 169 | + Row( | |
| 170 | + Modifier | |
| 171 | + .clip(RoundedCornerShape(8.dp)) | |
| 172 | + .clickable(onClick = onClick) | |
| 173 | + .padding(horizontal = 4.dp, vertical = 8.dp), | |
| 174 | + horizontalArrangement = Arrangement.spacedBy(3.dp), | |
| 175 | + verticalAlignment = Alignment.CenterVertically, | |
| 176 | + ) { | |
| 177 | + LucideIcon(icon, size = 12.dp, tint = ink) | |
| 178 | + Txt("#${pr.number}", color = ink, size = 12.sp, maxLines = 1) | |
| 179 | + } | |
| 19 | 180 | } |
| @@ -0,0 +1,422 @@ | ||
| 1 | + package cc.vectrum.code.ui.chat | |
| 2 | + | |
| 3 | + import android.annotation.SuppressLint | |
| 4 | + import android.content.ClipData | |
| 5 | + import android.content.ClipboardManager | |
| 6 | + import android.content.Context | |
| 7 | + import android.util.Base64 | |
| 8 | + import android.webkit.WebView | |
| 9 | + import androidx.compose.foundation.background | |
| 10 | + import androidx.compose.foundation.border | |
| 11 | + import androidx.compose.foundation.horizontalScroll | |
| 12 | + import androidx.compose.foundation.layout.Arrangement | |
| 13 | + import androidx.compose.foundation.layout.Box | |
| 14 | + import androidx.compose.foundation.layout.Column | |
| 15 | + import androidx.compose.foundation.layout.IntrinsicSize | |
| 16 | + import androidx.compose.foundation.layout.Row | |
| 17 | + import androidx.compose.foundation.layout.Spacer | |
| 18 | + import androidx.compose.foundation.layout.aspectRatio | |
| 19 | + import androidx.compose.foundation.layout.fillMaxHeight | |
| 20 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 21 | + import androidx.compose.foundation.layout.height | |
| 22 | + import androidx.compose.foundation.layout.heightIn | |
| 23 | + import androidx.compose.foundation.layout.padding | |
| 24 | + import androidx.compose.foundation.layout.width | |
| 25 | + import androidx.compose.foundation.rememberScrollState | |
| 26 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 27 | + import androidx.compose.foundation.text.BasicText | |
| 28 | + import androidx.compose.foundation.text.selection.SelectionContainer | |
| 29 | + import androidx.compose.runtime.Composable | |
| 30 | + import androidx.compose.runtime.getValue | |
| 31 | + import androidx.compose.runtime.mutableStateOf | |
| 32 | + import androidx.compose.runtime.remember | |
| 33 | + import androidx.compose.runtime.saveable.rememberSaveable | |
| 34 | + import androidx.compose.runtime.setValue | |
| 35 | + import androidx.compose.ui.Alignment | |
| 36 | + import androidx.compose.ui.Modifier | |
| 37 | + import androidx.compose.ui.draw.clip | |
| 38 | + import androidx.compose.ui.draw.drawBehind | |
| 39 | + import androidx.compose.ui.geometry.Offset | |
| 40 | + import androidx.compose.ui.graphics.Color | |
| 41 | + import androidx.compose.ui.layout.Layout | |
| 42 | + import androidx.compose.ui.platform.LocalContext | |
| 43 | + import androidx.compose.ui.text.AnnotatedString | |
| 44 | + import androidx.compose.ui.text.LinkAnnotation | |
| 45 | + import androidx.compose.ui.text.SpanStyle | |
| 46 | + import androidx.compose.ui.text.TextLinkStyles | |
| 47 | + import androidx.compose.ui.text.TextStyle | |
| 48 | + import androidx.compose.ui.text.buildAnnotatedString | |
| 49 | + import androidx.compose.ui.text.font.FontStyle | |
| 50 | + import androidx.compose.ui.text.font.FontWeight | |
| 51 | + import androidx.compose.ui.text.style.TextAlign | |
| 52 | + import androidx.compose.ui.text.style.TextDecoration | |
| 53 | + import androidx.compose.ui.text.withLink | |
| 54 | + import androidx.compose.ui.text.withStyle | |
| 55 | + import androidx.compose.ui.unit.Constraints | |
| 56 | + import androidx.compose.ui.unit.TextUnit | |
| 57 | + import androidx.compose.ui.unit.dp | |
| 58 | + import androidx.compose.ui.unit.em | |
| 59 | + import androidx.compose.ui.unit.sp | |
| 60 | + import androidx.compose.ui.viewinterop.AndroidView | |
| 61 | + import cc.vectrum.code.theme.Cap | |
| 62 | + import cc.vectrum.code.theme.CapKind | |
| 63 | + import cc.vectrum.code.theme.CapSize | |
| 64 | + import cc.vectrum.code.theme.Engraved | |
| 65 | + import cc.vectrum.code.theme.Mono | |
| 66 | + import cc.vectrum.code.theme.Palette | |
| 67 | + import cc.vectrum.code.theme.Rule | |
| 68 | + import cc.vectrum.code.theme.Sans | |
| 69 | + import cc.vectrum.code.theme.palette | |
| 70 | + import cc.vectrum.code.theme.well | |
| 71 | + import cc.vectrum.code.ui.Lucide | |
| 72 | + | |
| 73 | + // Markdown in MetalUI's voice, as the desktop's `markdown_style` sets it: | |
| 74 | + // code sits in pressed wells, inline code on a chip of the well's color. | |
| 75 | + | |
| 76 | + /** Copies text to the clipboard. */ | |
| 77 | + fun copyText(context: Context, text: String) { | |
| 78 | + context.getSystemService(ClipboardManager::class.java)?.setPrimaryClip(ClipData.newPlainText("Vectrum Code", text)) | |
| 79 | + } | |
| 80 | + | |
| 81 | + /** How a Markdown text is set: the body's size, leading and ink. */ | |
| 82 | + private data class MdStyle(val size: TextUnit, val lineHeight: TextUnit, val color: Color, val inlineUi: Boolean) | |
| 83 | + | |
| 84 | + /** | |
| 85 | + * Renders Markdown: paragraphs, headings, emphasis, code, links, lists, quotes, | |
| 86 | + * tables, rules and fenced code blocks with a Copy button. With `inlineUi`, | |
| 87 | + * the blocks the desktop draws as live UI (Mermaid, OpenUI, SVG) say what they | |
| 88 | + * are, and SVG is drawn. | |
| 89 | + */ | |
| 90 | + @Composable | |
| 91 | + fun MarkdownText( | |
| 92 | + text: String, | |
| 93 | + modifier: Modifier = Modifier, | |
| 94 | + size: TextUnit = 14.sp, | |
| 95 | + lineHeight: TextUnit = 22.75.sp, | |
| 96 | + color: Color = palette.ink, | |
| 97 | + inlineUi: Boolean = true, | |
| 98 | + selectable: Boolean = true, | |
| 99 | + ) { | |
| 100 | + val blocks = remember(text) { Markdown.parse(text) } | |
| 101 | + val style = MdStyle(size, lineHeight, color, inlineUi) | |
| 102 | + val body = @Composable { | |
| 103 | + Column(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) { | |
| 104 | + for (block in blocks) MdBlockView(block, style, depth = 0) | |
| 105 | + } | |
| 106 | + } | |
| 107 | + if (selectable) SelectionContainer { body() } else body() | |
| 108 | + } | |
| 109 | + | |
| 110 | + @Composable | |
| 111 | + private fun MdBlockView(block: MdBlock, style: MdStyle, depth: Int) { | |
| 112 | + val p = palette | |
| 113 | + when (block) { | |
| 114 | + is MdBlock.Paragraph -> InlineText(block.text, style) | |
| 115 | + is MdBlock.Heading -> { | |
| 116 | + val (size, leading) = when (block.level) { | |
| 117 | + 1 -> 28.sp to 34.sp | |
| 118 | + 2 -> 21.sp to 28.sp | |
| 119 | + 3 -> 17.5.sp to 24.sp | |
| 120 | + 4 -> 15.75.sp to 22.sp | |
| 121 | + else -> style.size to style.lineHeight | |
| 122 | + } | |
| 123 | + InlineText( | |
| 124 | + block.text, | |
| 125 | + style.copy(size = size, lineHeight = leading), | |
| 126 | + weight = FontWeight.SemiBold, | |
| 127 | + modifier = Modifier.padding(top = if (block.level <= 3) 4.dp else 0.dp), | |
| 128 | + ) | |
| 129 | + } | |
| 130 | + is MdBlock.Code -> CodeBlockView(block, style) | |
| 131 | + is MdBlock.Quote -> Row(Modifier.height(IntrinsicSize.Min)) { | |
| 132 | + Spacer(Modifier.width(3.dp).fillMaxHeight().background(p.ink3.copy(alpha = 0.3f), RoundedCornerShape(2.dp))) | |
| 133 | + Column(Modifier.padding(start = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 134 | + for (inner in block.blocks) MdBlockView(inner, style.copy(color = p.ink2), depth) | |
| 135 | + } | |
| 136 | + } | |
| 137 | + is MdBlock.ListBlock -> ListView(block, style, depth) | |
| 138 | + is MdBlock.Table -> TableView(block, style) | |
| 139 | + MdBlock.Rule -> Rule(Modifier.padding(vertical = 4.dp)) | |
| 140 | + } | |
| 141 | + } | |
| 142 | + | |
| 143 | + @Composable | |
| 144 | + private fun InlineText( | |
| 145 | + inlines: List<MdInline>, | |
| 146 | + style: MdStyle, | |
| 147 | + modifier: Modifier = Modifier, | |
| 148 | + weight: FontWeight = FontWeight.Normal, | |
| 149 | + align: TextAlign = TextAlign.Start, | |
| 150 | + ) { | |
| 151 | + val p = palette | |
| 152 | + val text = remember(inlines, p) { annotate(inlines, p) } | |
| 153 | + BasicText( | |
| 154 | + text, | |
| 155 | + modifier, | |
| 156 | + style = TextStyle( | |
| 157 | + color = style.color, | |
| 158 | + fontSize = style.size, | |
| 159 | + lineHeight = style.lineHeight, | |
| 160 | + fontFamily = Sans, | |
| 161 | + fontWeight = weight, | |
| 162 | + textAlign = align, | |
| 163 | + ), | |
| 164 | + ) | |
| 165 | + } | |
| 166 | + | |
| 167 | + /** Inline Markdown as styled text, links clickable. */ | |
| 168 | + fun annotate(inlines: List<MdInline>, p: Palette): AnnotatedString = buildAnnotatedString { | |
| 169 | + fun add(list: List<MdInline>) { | |
| 170 | + for (inline in list) { | |
| 171 | + when (inline) { | |
| 172 | + is MdInline.Text -> append(inline.text) | |
| 173 | + is MdInline.Code -> withStyle(SpanStyle(fontFamily = Mono, fontSize = 0.88.em, background = p.wellTop)) { | |
| 174 | + // Thin spaces pad the chip. | |
| 175 | + append("\u2009${inline.code}\u2009") | |
| 176 | + } | |
| 177 | + is MdInline.Emph -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { add(inline.children) } | |
| 178 | + is MdInline.Strong -> withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { add(inline.children) } | |
| 179 | + is MdInline.Strike -> withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { add(inline.children) } | |
| 180 | + is MdInline.Link -> withLink( | |
| 181 | + LinkAnnotation.Url( | |
| 182 | + inline.url, | |
| 183 | + TextLinkStyles(SpanStyle(color = p.blueInk, textDecoration = TextDecoration.Underline)), | |
| 184 | + ), | |
| 185 | + ) { add(inline.children) } | |
| 186 | + MdInline.Break -> append('\n') | |
| 187 | + } | |
| 188 | + } | |
| 189 | + } | |
| 190 | + add(inlines) | |
| 191 | + } | |
| 192 | + | |
| 193 | + @Composable | |
| 194 | + private fun ListView(block: MdBlock.ListBlock, style: MdStyle, depth: Int) { | |
| 195 | + val p = palette | |
| 196 | + val ordered = block.start != null | |
| 197 | + val last = (block.start ?: 0) + block.items.size - 1 | |
| 198 | + val markerWidth = if (ordered) (last.toString().length * 8 + 12).dp else 18.dp | |
| 199 | + Column(verticalArrangement = Arrangement.spacedBy(if (block.tight) 4.dp else 10.dp)) { | |
| 200 | + block.items.forEachIndexed { ix, item -> | |
| 201 | + Row { | |
| 202 | + val marker = if (ordered) "${block.start!! + ix}." else when (depth) { | |
| 203 | + 0 -> "•" | |
| 204 | + 1 -> "◦" | |
| 205 | + else -> "▪" | |
| 206 | + } | |
| 207 | + BasicText( | |
| 208 | + marker, | |
| 209 | + Modifier.width(markerWidth), | |
| 210 | + style = TextStyle( | |
| 211 | + color = p.ink3, | |
| 212 | + fontSize = style.size, | |
| 213 | + lineHeight = style.lineHeight, | |
| 214 | + fontFamily = Sans, | |
| 215 | + fontWeight = if (ordered) FontWeight.Medium else FontWeight.Normal, | |
| 216 | + ), | |
| 217 | + ) | |
| 218 | + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(if (block.tight) 4.dp else 10.dp)) { | |
| 219 | + for (inner in item) MdBlockView(inner, style, depth + 1) | |
| 220 | + } | |
| 221 | + } | |
| 222 | + } | |
| 223 | + } | |
| 224 | + } | |
| 225 | + | |
| 226 | + // ---- code ---------------------------------------------------------------------- | |
| 227 | + | |
| 228 | + /** What the desktop draws a fenced block of this language as, if it draws it. */ | |
| 229 | + private fun richLabel(lang: String?): String? = when (lang?.trim()?.lowercase()) { | |
| 230 | + "mermaid" -> "Diagram" | |
| 231 | + "openui", "openui-lang" -> "Interactive" | |
| 232 | + "svg" -> "Image" | |
| 233 | + else -> null | |
| 234 | + } | |
| 235 | + | |
| 236 | + @Composable | |
| 237 | + private fun CodeBlockView(block: MdBlock.Code, style: MdStyle) { | |
| 238 | + val context = LocalContext.current | |
| 239 | + val rich = if (style.inlineUi) richLabel(block.lang) else null | |
| 240 | + if (rich == null) { | |
| 241 | + CodeWell(block.lang, block.code) | |
| 242 | + return | |
| 243 | + } | |
| 244 | + // The desktop's rich blocks: their kind, Show source and Copy over the block. | |
| 245 | + val svg = rich == "Image" && block.closed | |
| 246 | + var source by rememberSaveable(block.code) { mutableStateOf(false) } | |
| 247 | + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { | |
| 248 | + Row( | |
| 249 | + Modifier.fillMaxWidth().height(26.dp).padding(horizontal = 4.dp), | |
| 250 | + verticalAlignment = Alignment.CenterVertically, | |
| 251 | + horizontalArrangement = Arrangement.spacedBy(4.dp), | |
| 252 | + ) { | |
| 253 | + Engraved(rich) | |
| 254 | + Spacer(Modifier.weight(1f)) | |
| 255 | + if (svg) { | |
| 256 | + Cap(icon = if (source) Lucide.Eye else Lucide.Code, kind = CapKind.Ghost, size = CapSize.Mini) { source = !source } | |
| 257 | + } | |
| 258 | + Cap(icon = Lucide.Copy, kind = CapKind.Ghost, size = CapSize.Mini) { copyText(context, block.code) } | |
| 259 | + } | |
| 260 | + if (svg && !source) SvgImage(block.code) { CodeWell(block.lang, block.code, header = false) } | |
| 261 | + else CodeWell(block.lang, block.code, header = false) | |
| 262 | + } | |
| 263 | + } | |
| 264 | + | |
| 265 | + /** Code in a pressed well: the language and Copy over it, the lines scrolling sideways. */ | |
| 266 | + @Composable | |
| 267 | + fun CodeWell(lang: String?, code: String, header: Boolean = true) { | |
| 268 | + val p = palette | |
| 269 | + val context = LocalContext.current | |
| 270 | + Column(Modifier.fillMaxWidth().well(p, RoundedCornerShape(14.dp))) { | |
| 271 | + if (header) { | |
| 272 | + Row( | |
| 273 | + Modifier.fillMaxWidth().height(30.dp).padding(start = 14.dp, end = 4.dp), | |
| 274 | + verticalAlignment = Alignment.CenterVertically, | |
| 275 | + ) { | |
| 276 | + Engraved(lang.orEmpty(), Modifier.weight(1f)) | |
| 277 | + Cap(icon = Lucide.Copy, kind = CapKind.Ghost, size = CapSize.Mini) { copyText(context, code) } | |
| 278 | + } | |
| 279 | + } | |
| 280 | + Box( | |
| 281 | + Modifier | |
| 282 | + .fillMaxWidth() | |
| 283 | + .horizontalScroll(rememberScrollState()) | |
| 284 | + .padding(start = 14.dp, end = 14.dp, top = if (header) 0.dp else 12.dp, bottom = 12.dp), | |
| 285 | + ) { | |
| 286 | + BasicText( | |
| 287 | + code, | |
| 288 | + style = TextStyle(color = p.ink, fontSize = 12.5.sp, lineHeight = 19.sp, fontFamily = Mono), | |
| 289 | + softWrap = false, | |
| 290 | + ) | |
| 291 | + } | |
| 292 | + } | |
| 293 | + } | |
| 294 | + | |
| 295 | + /** An SVG's width over its height, from its `viewBox` or its size. */ | |
| 296 | + fun svgAspect(svg: String): Float? { | |
| 297 | + val tag = Regex("<svg\\b[^>]*>", RegexOption.IGNORE_CASE).find(svg)?.value ?: return null | |
| 298 | + fun attr(name: String) = Regex("\\b$name\\s*=\\s*[\"']([^\"']*)[\"']", RegexOption.IGNORE_CASE).find(tag)?.groupValues?.get(1) | |
| 299 | + val box = attr("viewBox")?.trim()?.split(Regex("[\\s,]+"))?.mapNotNull { it.toFloatOrNull() } | |
| 300 | + if (box != null && box.size == 4 && box[2] > 0 && box[3] > 0) return box[2] / box[3] | |
| 301 | + val width = attr("width")?.removeSuffix("px")?.toFloatOrNull() | |
| 302 | + val height = attr("height")?.removeSuffix("px")?.toFloatOrNull() | |
| 303 | + if (width != null && height != null && width > 0 && height > 0) return width / height | |
| 304 | + return null | |
| 305 | + } | |
| 306 | + | |
| 307 | + /** | |
| 308 | + * Draws an SVG block. Android has no SVG renderer, so a WebView shows it as | |
| 309 | + * an image (which runs none of its scripts); without a size it stays code. | |
| 310 | + */ | |
| 311 | + @SuppressLint("SetJavaScriptEnabled") | |
| 312 | + @Composable | |
| 313 | + private fun SvgImage(svg: String, fallback: @Composable () -> Unit) { | |
| 314 | + val aspect = remember(svg) { svgAspect(svg) } | |
| 315 | + if (aspect == null) { | |
| 316 | + fallback() | |
| 317 | + return | |
| 318 | + } | |
| 319 | + val html = remember(svg) { | |
| 320 | + val data = Base64.encodeToString(svg.toByteArray(), Base64.NO_WRAP) | |
| 321 | + "<html><head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" + | |
| 322 | + "<style>html,body{margin:0;padding:0;background:transparent}" + | |
| 323 | + "img{display:block;width:100%;height:100%;object-fit:contain}</style></head>" + | |
| 324 | + "<body><img src=\"data:image/svg+xml;base64,$data\"></body></html>" | |
| 325 | + } | |
| 326 | + AndroidView( | |
| 327 | + factory = { context -> | |
| 328 | + WebView(context).apply { | |
| 329 | + setBackgroundColor(android.graphics.Color.TRANSPARENT) | |
| 330 | + settings.javaScriptEnabled = false | |
| 331 | + isVerticalScrollBarEnabled = false | |
| 332 | + isHorizontalScrollBarEnabled = false | |
| 333 | + } | |
| 334 | + }, | |
| 335 | + update = { view -> | |
| 336 | + if (view.tag != html) { | |
| 337 | + view.tag = html | |
| 338 | + view.loadDataWithBaseURL(null, html, "text/html", "utf-8", null) | |
| 339 | + } | |
| 340 | + }, | |
| 341 | + modifier = Modifier | |
| 342 | + .fillMaxWidth() | |
| 343 | + .heightIn(max = 480.dp) | |
| 344 | + .aspectRatio(aspect.coerceIn(0.4f, 6f), matchHeightConstraintsFirst = false), | |
| 345 | + ) | |
| 346 | + } | |
| 347 | + | |
| 348 | + // ---- tables -------------------------------------------------------------------- | |
| 349 | + | |
| 350 | + private val TableRadius = 12.dp | |
| 351 | + private val MaxColumn = 260.dp | |
| 352 | + private val MinColumn = 40.dp | |
| 353 | + | |
| 354 | + /** A table in a hairline frame, scrolling sideways when it's wider than the answer. */ | |
| 355 | + @Composable | |
| 356 | + private fun TableView(table: MdBlock.Table, style: MdStyle) { | |
| 357 | + val p = palette | |
| 358 | + val columns = table.header.size | |
| 359 | + val rows = table.rows.size + 1 | |
| 360 | + val shape = RoundedCornerShape(TableRadius) | |
| 361 | + val cellStyle = style.copy(size = 13.sp, lineHeight = 19.sp) | |
| 362 | + Box(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) { | |
| 363 | + Layout( | |
| 364 | + modifier = Modifier.clip(shape).border(1.dp, p.rule, shape), | |
| 365 | + content = { | |
| 366 | + for (row in 0 until rows) { | |
| 367 | + val cells = if (row == 0) table.header else table.rows[row - 1] | |
| 368 | + for (col in 0 until columns) { | |
| 369 | + val lastRow = row == rows - 1 | |
| 370 | + val lastCol = col == columns - 1 | |
| 371 | + Box( | |
| 372 | + Modifier | |
| 373 | + .then(if (row == 0) Modifier.background(p.ink.copy(alpha = 0.04f)) else Modifier) | |
| 374 | + .drawBehind { | |
| 375 | + val stroke = 1.dp.toPx() | |
| 376 | + if (!lastRow) drawRect(p.rule, Offset(0f, size.height - stroke), androidx.compose.ui.geometry.Size(size.width, stroke)) | |
| 377 | + if (!lastCol) drawRect(p.rule, Offset(size.width - stroke, 0f), androidx.compose.ui.geometry.Size(stroke, size.height)) | |
| 378 | + } | |
| 379 | + .padding(horizontal = 10.dp, vertical = 6.dp), | |
| 380 | + ) { | |
| 381 | + val align = when (table.align.getOrElse(col) { 0 }) { | |
| 382 | + 1 -> TextAlign.Center | |
| 383 | + 2 -> TextAlign.End | |
| 384 | + else -> TextAlign.Start | |
| 385 | + } | |
| 386 | + InlineText( | |
| 387 | + cells.getOrElse(col) { emptyList() }, | |
| 388 | + cellStyle, | |
| 389 | + Modifier.fillMaxWidth(), | |
| 390 | + weight = if (row == 0) FontWeight.SemiBold else FontWeight.Normal, | |
| 391 | + align = align, | |
| 392 | + ) | |
| 393 | + } | |
| 394 | + } | |
| 395 | + } | |
| 396 | + }, | |
| 397 | + ) { measurables, _ -> | |
| 398 | + val max = MaxColumn.roundToPx() | |
| 399 | + val min = MinColumn.roundToPx() | |
| 400 | + val widths = IntArray(columns) { col -> | |
| 401 | + (0 until rows).maxOf { row -> measurables[row * columns + col].maxIntrinsicWidth(Constraints.Infinity) }.coerceIn(min, max) | |
| 402 | + } | |
| 403 | + val heights = IntArray(rows) { row -> | |
| 404 | + (0 until columns).maxOf { col -> measurables[row * columns + col].minIntrinsicHeight(widths[col]) } | |
| 405 | + } | |
| 406 | + val placeables = measurables.mapIndexed { ix, measurable -> | |
| 407 | + measurable.measure(Constraints.fixed(widths[ix % columns], heights[ix / columns])) | |
| 408 | + } | |
| 409 | + layout(widths.sum(), heights.sum()) { | |
| 410 | + var y = 0 | |
| 411 | + for (row in 0 until rows) { | |
| 412 | + var x = 0 | |
| 413 | + for (col in 0 until columns) { | |
| 414 | + placeables[row * columns + col].place(x, y) | |
| 415 | + x += widths[col] | |
| 416 | + } | |
| 417 | + y += heights[row] | |
| 418 | + } | |
| 419 | + } | |
| 420 | + } | |
| 421 | + } | |
| 422 | + } | |
| @@ -0,0 +1,566 @@ | ||
| 1 | + package cc.vectrum.code.ui.chat | |
| 2 | + | |
| 3 | + // A small CommonMark-ish parser for agents' answers, with GitHub's tables, | |
| 4 | + // strikethrough and bare links. It's forgiving rather than exact: answers | |
| 5 | + // stream in, so an unclosed fence runs to the end, as the desktop shows it. | |
| 6 | + | |
| 7 | + /** A block of Markdown. */ | |
| 8 | + sealed interface MdBlock { | |
| 9 | + data class Heading(val level: Int, val text: List<MdInline>) : MdBlock | |
| 10 | + | |
| 11 | + data class Paragraph(val text: List<MdInline>) : MdBlock | |
| 12 | + | |
| 13 | + /** A fenced (or indented) code block; `closed` is false while it's still streaming. */ | |
| 14 | + data class Code(val lang: String?, val code: String, val closed: Boolean = true) : MdBlock | |
| 15 | + | |
| 16 | + data class Quote(val blocks: List<MdBlock>) : MdBlock | |
| 17 | + | |
| 18 | + /** A list; `start` is the first number of an ordered one, `null` for bullets. */ | |
| 19 | + data class ListBlock(val start: Int?, val items: List<List<MdBlock>>, val tight: Boolean) : MdBlock | |
| 20 | + | |
| 21 | + /** `align`: -1 left, 0 none, 1 center, 2 right. */ | |
| 22 | + data class Table(val header: List<List<MdInline>>, val align: List<Int>, val rows: List<List<List<MdInline>>>) : MdBlock | |
| 23 | + | |
| 24 | + data object Rule : MdBlock | |
| 25 | + } | |
| 26 | + | |
| 27 | + /** A span of text in a block. */ | |
| 28 | + sealed interface MdInline { | |
| 29 | + data class Text(val text: String) : MdInline | |
| 30 | + | |
| 31 | + data class Code(val code: String) : MdInline | |
| 32 | + | |
| 33 | + data class Emph(val children: List<MdInline>) : MdInline | |
| 34 | + | |
| 35 | + data class Strong(val children: List<MdInline>) : MdInline | |
| 36 | + | |
| 37 | + data class Strike(val children: List<MdInline>) : MdInline | |
| 38 | + | |
| 39 | + data class Link(val url: String, val children: List<MdInline>) : MdInline | |
| 40 | + | |
| 41 | + data object Break : MdInline | |
| 42 | + } | |
| 43 | + | |
| 44 | + object Markdown { | |
| 45 | + fun parse(text: String): List<MdBlock> = parseBlocks(text.replace("\r\n", "\n").replace('\t', ' ').split('\n')) | |
| 46 | + | |
| 47 | + // ---- blocks ------------------------------------------------------------------ | |
| 48 | + | |
| 49 | + private val FENCE = Regex("^ {0,3}(`{3,}|~{3,})\\s*([^`\\s]*)[^`]*$") | |
| 50 | + private val HEADING = Regex("^ {0,3}(#{1,6})(?:[ ]+(.*?))?(?:[ ]+#+)?[ ]*$") | |
| 51 | + private val RULE = Regex("^ {0,3}((?:\\*[ ]*){3,}|(?:-[ ]*){3,}|(?:_[ ]*){3,})$") | |
| 52 | + private val QUOTE = Regex("^ {0,3}> ?(.*)$") | |
| 53 | + private val BULLET = Regex("^( {0,3})([-*+])( +|$)(.*)$") | |
| 54 | + private val ORDERED = Regex("^( {0,3})(\\d{1,9})([.)])( +|$)(.*)$") | |
| 55 | + private val SETEXT = Regex("^ {0,3}(=+|-+)[ ]*$") | |
| 56 | + private val DELIMITER = Regex("^ *\\|? *:?-+:? *(\\| *:?-+:? *)*\\|? *$") | |
| 57 | + | |
| 58 | + private fun blank(line: String) = line.isBlank() | |
| 59 | + | |
| 60 | + private fun indent(line: String) = line.length - line.trimStart(' ').length | |
| 61 | + | |
| 62 | + /** Whether a line starts a block that interrupts a paragraph. */ | |
| 63 | + private fun interrupts(line: String): Boolean = | |
| 64 | + FENCE.matches(line) || HEADING.matches(line) || RULE.matches(line) || QUOTE.matches(line) || | |
| 65 | + BULLET.find(line)?.let { it.groupValues[4].isNotBlank() } == true || | |
| 66 | + ORDERED.find(line)?.let { it.groupValues[5].isNotBlank() && it.groupValues[2] == "1" } == true | |
| 67 | + | |
| 68 | + private fun parseBlocks(lines: List<String>): List<MdBlock> { | |
| 69 | + val blocks = mutableListOf<MdBlock>() | |
| 70 | + var ix = 0 | |
| 71 | + while (ix < lines.size) { | |
| 72 | + val line = lines[ix] | |
| 73 | + if (blank(line)) { | |
| 74 | + ix++ | |
| 75 | + continue | |
| 76 | + } | |
| 77 | + // A fenced code block runs to its closing fence, or to the end while it streams. | |
| 78 | + val fenceMatch = FENCE.matchEntire(line) | |
| 79 | + if (fenceMatch != null) { | |
| 80 | + val fence = fenceMatch.groupValues[1] | |
| 81 | + val lang = fenceMatch.groupValues[2].ifEmpty { null } | |
| 82 | + val pad = indent(line) | |
| 83 | + val code = mutableListOf<String>() | |
| 84 | + var closed = false | |
| 85 | + ix++ | |
| 86 | + while (ix < lines.size) { | |
| 87 | + val next = lines[ix] | |
| 88 | + val trimmed = next.trim() | |
| 89 | + if (indent(next) < 4 && trimmed.length >= fence.length && trimmed.all { it == fence[0] }) { | |
| 90 | + closed = true | |
| 91 | + ix++ | |
| 92 | + break | |
| 93 | + } | |
| 94 | + code.add(next.drop(minOf(pad, indent(next)))) | |
| 95 | + ix++ | |
| 96 | + } | |
| 97 | + blocks.add(MdBlock.Code(lang, code.joinToString("\n"), closed)) | |
| 98 | + continue | |
| 99 | + } | |
| 100 | + val headingMatch = HEADING.matchEntire(line) | |
| 101 | + if (headingMatch != null) { | |
| 102 | + blocks.add(MdBlock.Heading(headingMatch.groupValues[1].length, inlines(headingMatch.groupValues[2].trim()))) | |
| 103 | + ix++ | |
| 104 | + continue | |
| 105 | + } | |
| 106 | + if (RULE.matches(line)) { | |
| 107 | + blocks.add(MdBlock.Rule) | |
| 108 | + ix++ | |
| 109 | + continue | |
| 110 | + } | |
| 111 | + if (QUOTE.matches(line)) { | |
| 112 | + val inner = mutableListOf<String>() | |
| 113 | + while (ix < lines.size) { | |
| 114 | + val quoted = QUOTE.matchEntire(lines[ix]) | |
| 115 | + when { | |
| 116 | + quoted != null -> inner.add(quoted.groupValues[1]) | |
| 117 | + // A lazy continuation of the quote's paragraph. | |
| 118 | + !blank(lines[ix]) && inner.isNotEmpty() && !blank(inner.last()) && !interrupts(lines[ix]) -> inner.add(lines[ix]) | |
| 119 | + else -> break | |
| 120 | + } | |
| 121 | + ix++ | |
| 122 | + } | |
| 123 | + blocks.add(MdBlock.Quote(parseBlocks(inner))) | |
| 124 | + continue | |
| 125 | + } | |
| 126 | + if (listMarker(line) != null) { | |
| 127 | + ix = parseList(lines, ix, blocks) | |
| 128 | + continue | |
| 129 | + } | |
| 130 | + // A table: a row of cells, then its delimiter row. | |
| 131 | + if ('|' in line && ix + 1 < lines.size && DELIMITER.matches(lines[ix + 1]) && '-' in lines[ix + 1]) { | |
| 132 | + val header = cells(line) | |
| 133 | + val align = cells(lines[ix + 1]).map { cell -> | |
| 134 | + val left = cell.startsWith(":") | |
| 135 | + val right = cell.endsWith(":") | |
| 136 | + when { | |
| 137 | + left && right -> 1 | |
| 138 | + right -> 2 | |
| 139 | + left -> -1 | |
| 140 | + else -> 0 | |
| 141 | + } | |
| 142 | + } | |
| 143 | + ix += 2 | |
| 144 | + val rows = mutableListOf<List<List<MdInline>>>() | |
| 145 | + while (ix < lines.size && !blank(lines[ix]) && ('|' in lines[ix])) { | |
| 146 | + val row = cells(lines[ix]) | |
| 147 | + rows.add(List(header.size) { col -> inlines(row.getOrElse(col) { "" }) }) | |
| 148 | + ix++ | |
| 149 | + } | |
| 150 | + blocks.add(MdBlock.Table(header.map { inlines(it) }, List(header.size) { align.getOrElse(it) { 0 } }, rows)) | |
| 151 | + continue | |
| 152 | + } | |
| 153 | + // Indented code. | |
| 154 | + if (indent(line) >= 4) { | |
| 155 | + val code = mutableListOf<String>() | |
| 156 | + while (ix < lines.size && (indent(lines[ix]) >= 4 || blank(lines[ix]))) { | |
| 157 | + code.add(lines[ix].drop(4)) | |
| 158 | + ix++ | |
| 159 | + } | |
| 160 | + while (code.isNotEmpty() && code.last().isBlank()) code.removeAt(code.lastIndex) | |
| 161 | + blocks.add(MdBlock.Code(null, code.joinToString("\n"))) | |
| 162 | + continue | |
| 163 | + } | |
| 164 | + // A paragraph, or a setext heading. | |
| 165 | + val text = mutableListOf(line.trimStart()) | |
| 166 | + ix++ | |
| 167 | + var heading = 0 | |
| 168 | + while (ix < lines.size && !blank(lines[ix])) { | |
| 169 | + val next = lines[ix] | |
| 170 | + val setext = SETEXT.matchEntire(next) | |
| 171 | + if (setext != null) { | |
| 172 | + heading = if (setext.groupValues[1].startsWith("=")) 1 else 2 | |
| 173 | + ix++ | |
| 174 | + break | |
| 175 | + } | |
| 176 | + if (interrupts(next)) break | |
| 177 | + if ('|' in next && ix + 1 < lines.size && DELIMITER.matches(lines[ix + 1]) && '-' in lines[ix + 1]) break | |
| 178 | + text.add(next.trimStart()) | |
| 179 | + ix++ | |
| 180 | + } | |
| 181 | + val content = inlines(text.joinToString("\n").trimEnd()) | |
| 182 | + blocks.add(if (heading > 0) MdBlock.Heading(heading, content) else MdBlock.Paragraph(content)) | |
| 183 | + } | |
| 184 | + return blocks | |
| 185 | + } | |
| 186 | + | |
| 187 | + /** A list item's marker: (indent, marker text, ordered start or null, content column, rest). */ | |
| 188 | + private data class Marker(val indent: Int, val bullet: String, val start: Int?, val column: Int, val rest: String) | |
| 189 | + | |
| 190 | + private fun listMarker(line: String): Marker? { | |
| 191 | + BULLET.matchEntire(line)?.let { m -> | |
| 192 | + if (RULE.matches(line)) return null | |
| 193 | + val pad = m.groupValues[1].length | |
| 194 | + val spaces = m.groupValues[3].length.let { if (it in 1..4) it else 1 } | |
| 195 | + return Marker(pad, m.groupValues[2], null, pad + 1 + spaces, m.groupValues[4]) | |
| 196 | + } | |
| 197 | + ORDERED.matchEntire(line)?.let { m -> | |
| 198 | + val pad = m.groupValues[1].length | |
| 199 | + val spaces = m.groupValues[4].length.let { if (it in 1..4) it else 1 } | |
| 200 | + val number = m.groupValues[2] | |
| 201 | + return Marker(pad, m.groupValues[3], number.toInt(), pad + number.length + 1 + spaces, m.groupValues[5]) | |
| 202 | + } | |
| 203 | + return null | |
| 204 | + } | |
| 205 | + | |
| 206 | + private fun parseList(lines: List<String>, from: Int, blocks: MutableList<MdBlock>): Int { | |
| 207 | + val first = listMarker(lines[from])!! | |
| 208 | + val ordered = first.start != null | |
| 209 | + val items = mutableListOf<List<MdBlock>>() | |
| 210 | + var tight = true | |
| 211 | + var ix = from | |
| 212 | + while (ix < lines.size) { | |
| 213 | + val marker = listMarker(lines[ix]) ?: break | |
| 214 | + if ((marker.start != null) != ordered || marker.bullet != first.bullet || marker.indent > first.indent + 3) break | |
| 215 | + val content = mutableListOf(marker.rest) | |
| 216 | + ix++ | |
| 217 | + var sawBlank = false | |
| 218 | + while (ix < lines.size) { | |
| 219 | + val next = lines[ix] | |
| 220 | + if (blank(next)) { | |
| 221 | + content.add("") | |
| 222 | + sawBlank = true | |
| 223 | + ix++ | |
| 224 | + continue | |
| 225 | + } | |
| 226 | + // Content under the item's text, or a sublist indented less than it (as `1.` items often are). | |
| 227 | + val nested = indent(next) >= marker.column || (indent(next) > marker.indent && listMarker(next) != null) | |
| 228 | + when { | |
| 229 | + nested -> content.add(next.drop(minOf(marker.column, indent(next)))) | |
| 230 | + // A lazy continuation of the item's paragraph. | |
| 231 | + !sawBlank && listMarker(next) == null && !interrupts(next) -> content.add(next.trimStart()) | |
| 232 | + else -> break | |
| 233 | + } | |
| 234 | + sawBlank = false | |
| 235 | + ix++ | |
| 236 | + } | |
| 237 | + // Blank lines between items (rather than after the last) loosen the list. | |
| 238 | + val trailing = content.takeLastWhile { it.isBlank() }.size | |
| 239 | + val body = content.dropLast(trailing) | |
| 240 | + if (trailing > 0 && ix < lines.size && listMarker(lines[ix])?.let { (it.start != null) == ordered } == true) tight = false | |
| 241 | + if (body.dropWhile { it.isNotBlank() }.any { it.isNotBlank() }) tight = false | |
| 242 | + items.add(parseBlocks(body)) | |
| 243 | + } | |
| 244 | + blocks.add(MdBlock.ListBlock(first.start, items, tight)) | |
| 245 | + return ix | |
| 246 | + } | |
| 247 | + | |
| 248 | + /** A table row's cells, split on unescaped pipes outside code. */ | |
| 249 | + private fun cells(line: String): List<String> { | |
| 250 | + var row = line.trim() | |
| 251 | + if (row.startsWith("|")) row = row.substring(1) | |
| 252 | + if (row.endsWith("|") && !row.endsWith("\\|")) row = row.dropLast(1) | |
| 253 | + val cells = mutableListOf<String>() | |
| 254 | + val cell = StringBuilder() | |
| 255 | + var code = false | |
| 256 | + var ix = 0 | |
| 257 | + while (ix < row.length) { | |
| 258 | + val c = row[ix] | |
| 259 | + when { | |
| 260 | + c == '\\' && ix + 1 < row.length && row[ix + 1] == '|' -> { | |
| 261 | + cell.append('|') | |
| 262 | + ix++ | |
| 263 | + } | |
| 264 | + c == '`' -> { | |
| 265 | + code = !code | |
| 266 | + cell.append(c) | |
| 267 | + } | |
| 268 | + c == '|' && !code -> { | |
| 269 | + cells.add(cell.toString().trim()) | |
| 270 | + cell.clear() | |
| 271 | + } | |
| 272 | + else -> cell.append(c) | |
| 273 | + } | |
| 274 | + ix++ | |
| 275 | + } | |
| 276 | + cells.add(cell.toString().trim()) | |
| 277 | + return cells | |
| 278 | + } | |
| 279 | + | |
| 280 | + // ---- inlines ----------------------------------------------------------------- | |
| 281 | + | |
| 282 | + private const val PUNCTUATION = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" | |
| 283 | + | |
| 284 | + fun inlines(text: String): List<MdInline> = InlineParser(text).parse(0, text.length) | |
| 285 | + | |
| 286 | + private class InlineParser(val s: String) { | |
| 287 | + fun parse(from: Int, to: Int): List<MdInline> { | |
| 288 | + val out = mutableListOf<MdInline>() | |
| 289 | + val buffer = StringBuilder() | |
| 290 | + fun flush() { | |
| 291 | + if (buffer.isNotEmpty()) { | |
| 292 | + val last = out.lastOrNull() | |
| 293 | + if (last is MdInline.Text) out[out.lastIndex] = MdInline.Text(last.text + buffer) else out.add(MdInline.Text(buffer.toString())) | |
| 294 | + buffer.clear() | |
| 295 | + } | |
| 296 | + } | |
| 297 | + fun push(inline: MdInline) { | |
| 298 | + flush() | |
| 299 | + out.add(inline) | |
| 300 | + } | |
| 301 | + var ix = from | |
| 302 | + while (ix < to) { | |
| 303 | + val c = s[ix] | |
| 304 | + when { | |
| 305 | + c == '\\' && ix + 1 < to && s[ix + 1] == '\n' -> { | |
| 306 | + push(MdInline.Break) | |
| 307 | + ix += 2 | |
| 308 | + } | |
| 309 | + c == '\\' && ix + 1 < to && s[ix + 1] in PUNCTUATION -> { | |
| 310 | + buffer.append(s[ix + 1]) | |
| 311 | + ix += 2 | |
| 312 | + } | |
| 313 | + c == '`' -> { | |
| 314 | + val run = runLength(ix, to, '`') | |
| 315 | + val close = findRun(ix + run, to, '`', run) | |
| 316 | + if (close < 0) { | |
| 317 | + buffer.append(s, ix, ix + run) | |
| 318 | + ix += run | |
| 319 | + } else { | |
| 320 | + var code = s.substring(ix + run, close).replace('\n', ' ') | |
| 321 | + if (code.length >= 2 && code.startsWith(" ") && code.endsWith(" ") && code.isNotBlank()) { | |
| 322 | + code = code.substring(1, code.length - 1) | |
| 323 | + } | |
| 324 | + push(MdInline.Code(code)) | |
| 325 | + ix = close + run | |
| 326 | + } | |
| 327 | + } | |
| 328 | + c == '\n' -> { | |
| 329 | + // Two trailing spaces make a hard break; otherwise it's a space. | |
| 330 | + val hard = buffer.endsWith(" ") | |
| 331 | + while (buffer.isNotEmpty() && buffer.last() == ' ') buffer.setLength(buffer.length - 1) | |
| 332 | + if (hard) push(MdInline.Break) else buffer.append(' ') | |
| 333 | + ix++ | |
| 334 | + while (ix < to && s[ix] == ' ') ix++ | |
| 335 | + } | |
| 336 | + c == '<' -> { | |
| 337 | + val end = s.indexOf('>', ix) | |
| 338 | + val inner = if (end in (ix + 1) until to) s.substring(ix + 1, end) else null | |
| 339 | + when { | |
| 340 | + inner != null && Regex("(?i)br\\s*/?").matches(inner) -> { | |
| 341 | + push(MdInline.Break) | |
| 342 | + ix = end + 1 | |
| 343 | + } | |
| 344 | + inner != null && Regex("(?i)(https?|mailto|ftp):[^\\s<>]*").matches(inner) -> { | |
| 345 | + push(MdInline.Link(inner, listOf(MdInline.Text(inner)))) | |
| 346 | + ix = end + 1 | |
| 347 | + } | |
| 348 | + else -> { | |
| 349 | + buffer.append(c) | |
| 350 | + ix++ | |
| 351 | + } | |
| 352 | + } | |
| 353 | + } | |
| 354 | + c == '[' || (c == '!' && ix + 1 < to && s[ix + 1] == '[') -> { | |
| 355 | + val link = link(if (c == '!') ix + 1 else ix, to) | |
| 356 | + if (link == null) { | |
| 357 | + buffer.append(c) | |
| 358 | + ix++ | |
| 359 | + } else { | |
| 360 | + val (label, url, end) = link | |
| 361 | + val children = parse(label.first, label.last + 1) | |
| 362 | + push(MdInline.Link(url, children.ifEmpty { listOf(MdInline.Text(url)) })) | |
| 363 | + ix = end | |
| 364 | + } | |
| 365 | + } | |
| 366 | + (c == 'h' || c == 'w') && (ix == from || !s[ix - 1].isLetterOrDigit()) && bareUrl(ix, to) > ix -> { | |
| 367 | + val end = bareUrl(ix, to) | |
| 368 | + val url = s.substring(ix, end) | |
| 369 | + push(MdInline.Link(if (url.startsWith("www.")) "https://$url" else url, listOf(MdInline.Text(url)))) | |
| 370 | + ix = end | |
| 371 | + } | |
| 372 | + c == '*' || c == '_' || c == '~' -> { | |
| 373 | + val run = runLength(ix, to, c) | |
| 374 | + val span = emphasis(ix, to, c, run, from) | |
| 375 | + if (span == null) { | |
| 376 | + buffer.append(s, ix, ix + run) | |
| 377 | + ix += run | |
| 378 | + } else { | |
| 379 | + val (inlines, end) = span | |
| 380 | + for (inline in inlines) if (inline is MdInline.Text) buffer.append(inline.text) else push(inline) | |
| 381 | + ix = end | |
| 382 | + } | |
| 383 | + } | |
| 384 | + else -> { | |
| 385 | + buffer.append(c) | |
| 386 | + ix++ | |
| 387 | + } | |
| 388 | + } | |
| 389 | + } | |
| 390 | + flush() | |
| 391 | + return out | |
| 392 | + } | |
| 393 | + | |
| 394 | + private fun runLength(at: Int, to: Int, c: Char): Int { | |
| 395 | + var end = at | |
| 396 | + while (end < to && s[end] == c) end++ | |
| 397 | + return end - at | |
| 398 | + } | |
| 399 | + | |
| 400 | + /** The next run of exactly `length` `c`s from `from`, or -1. */ | |
| 401 | + private fun findRun(from: Int, to: Int, c: Char, length: Int): Int { | |
| 402 | + var ix = from | |
| 403 | + while (ix < to) { | |
| 404 | + if (s[ix] == c) { | |
| 405 | + val run = runLength(ix, to, c) | |
| 406 | + if (run == length) return ix | |
| 407 | + ix += run | |
| 408 | + } else { | |
| 409 | + ix++ | |
| 410 | + } | |
| 411 | + } | |
| 412 | + return -1 | |
| 413 | + } | |
| 414 | + | |
| 415 | + /** `[label](url "title")`: the label's range, the url, and where it ends. */ | |
| 416 | + private fun link(open: Int, to: Int): Triple<IntRange, String, Int>? { | |
| 417 | + var depth = 0 | |
| 418 | + var ix = open | |
| 419 | + var close = -1 | |
| 420 | + while (ix < to) { | |
| 421 | + when (s[ix]) { | |
| 422 | + '\\' -> ix++ | |
| 423 | + '`' -> { | |
| 424 | + val run = runLength(ix, to, '`') | |
| 425 | + val end = findRun(ix + run, to, '`', run) | |
| 426 | + if (end > 0) ix = end + run - 1 | |
| 427 | + } | |
| 428 | + '[' -> depth++ | |
| 429 | + ']' -> { | |
| 430 | + depth-- | |
| 431 | + if (depth == 0) { | |
| 432 | + close = ix | |
| 433 | + break | |
| 434 | + } | |
| 435 | + } | |
| 436 | + } | |
| 437 | + ix++ | |
| 438 | + } | |
| 439 | + if (close < 0 || close + 1 >= to || s[close + 1] != '(') return null | |
| 440 | + var end = close + 2 | |
| 441 | + var parens = 1 | |
| 442 | + while (end < to) { | |
| 443 | + when (s[end]) { | |
| 444 | + '\\' -> end++ | |
| 445 | + '(' -> parens++ | |
| 446 | + ')' -> { | |
| 447 | + parens-- | |
| 448 | + if (parens == 0) break | |
| 449 | + } | |
| 450 | + '\n' -> if (s.substring(close + 2, end).isNotBlank() && ' ' !in s.substring(close + 2, end).trim()) return null | |
| 451 | + } | |
| 452 | + end++ | |
| 453 | + } | |
| 454 | + if (end >= to) return null | |
| 455 | + var target = s.substring(close + 2, end).trim() | |
| 456 | + // Drop a title: `url "title"`. | |
| 457 | + val space = target.indexOfFirst { it == ' ' || it == '\n' } | |
| 458 | + if (space > 0) target = target.substring(0, space) | |
| 459 | + target = target.removePrefix("<").removeSuffix(">") | |
| 460 | + return Triple((open + 1) until close, target, end + 1) | |
| 461 | + } | |
| 462 | + | |
| 463 | + /** A bare `https://…` or `www.…` link's end, or `at` when there's none. */ | |
| 464 | + fun bareUrl(at: Int, to: Int): Int { | |
| 465 | + val rest = s.substring(at, minOf(to, at + 8)) | |
| 466 | + if (!rest.startsWith("http://") && !rest.startsWith("https://") && !rest.startsWith("www.")) return at | |
| 467 | + var end = at | |
| 468 | + while (end < to && !s[end].isWhitespace() && s[end] != '<') end++ | |
| 469 | + // Trailing punctuation belongs to the sentence, and a closing paren | |
| 470 | + // only to the link when it opened one. | |
| 471 | + while (end > at) { | |
| 472 | + val last = s[end - 1] | |
| 473 | + if (last in ".,:;!?\"'*_~") { | |
| 474 | + end-- | |
| 475 | + } else if (last == ')' && s.substring(at, end).count { it == ')' } > s.substring(at, end).count { it == '(' }) { | |
| 476 | + end-- | |
| 477 | + } else { | |
| 478 | + break | |
| 479 | + } | |
| 480 | + } | |
| 481 | + val url = s.substring(at, end) | |
| 482 | + val minimum = if (url.startsWith("www.")) 5 else url.indexOf("://") + 4 | |
| 483 | + return if (url.length >= minimum) end else at | |
| 484 | + } | |
| 485 | + | |
| 486 | + private fun isPunct(c: Char) = c in PUNCTUATION || Character.getType(c).let { | |
| 487 | + it == Character.CONNECTOR_PUNCTUATION.toInt() || it == Character.DASH_PUNCTUATION.toInt() || | |
| 488 | + it == Character.START_PUNCTUATION.toInt() || it == Character.END_PUNCTUATION.toInt() || | |
| 489 | + it == Character.OTHER_PUNCTUATION.toInt() | |
| 490 | + } | |
| 491 | + | |
| 492 | + /** Whether a delimiter run at `[at, at+run)` can open emphasis. */ | |
| 493 | + private fun canOpen(at: Int, run: Int, to: Int, from: Int, c: Char): Boolean { | |
| 494 | + val next = if (at + run < to) s[at + run] else ' ' | |
| 495 | + val prev = if (at > from) s[at - 1] else ' ' | |
| 496 | + if (next.isWhitespace()) return false | |
| 497 | + val leftFlanking = !isPunct(next) || prev.isWhitespace() || isPunct(prev) | |
| 498 | + if (!leftFlanking) return false | |
| 499 | + // Underscores don't open inside a word. | |
| 500 | + return c != '_' || !prev.isLetterOrDigit() | |
| 501 | + } | |
| 502 | + | |
| 503 | + private fun canClose(at: Int, run: Int, to: Int, c: Char): Boolean { | |
| 504 | + val prev = s[at - 1] | |
| 505 | + val next = if (at + run < to) s[at + run] else ' ' | |
| 506 | + if (prev.isWhitespace()) return false | |
| 507 | + val rightFlanking = !isPunct(prev) || next.isWhitespace() || isPunct(next) | |
| 508 | + if (!rightFlanking) return false | |
| 509 | + return c != '_' || !next.isLetterOrDigit() | |
| 510 | + } | |
| 511 | + | |
| 512 | + /** | |
| 513 | + * Emphasis, strong or strikethrough opening at `at` (extra opening | |
| 514 | + * delimiters stay as text in front), and where it ends. | |
| 515 | + */ | |
| 516 | + private fun emphasis(at: Int, to: Int, c: Char, run: Int, from: Int): Pair<List<MdInline>, Int>? { | |
| 517 | + if (!canOpen(at, run, to, from, c)) return null | |
| 518 | + if (c == '~') { | |
| 519 | + if (run > 2) return null | |
| 520 | + val close = findCloser(at + run, to, c, run, exact = true) ?: return null | |
| 521 | + return listOf(MdInline.Strike(parse(at + run, close))) to close + run | |
| 522 | + } | |
| 523 | + // The widest match first: ***both***, **strong**, *emphasis*; | |
| 524 | + // a closer of exactly that width before a longer one. | |
| 525 | + for (exact in listOf(true, false)) { | |
| 526 | + for (width in minOf(run, 3) downTo 1) { | |
| 527 | + val start = at + run - width | |
| 528 | + val close = findCloser(start + width, to, c, width, exact) ?: continue | |
| 529 | + val inner = parse(start + width, close) | |
| 530 | + val inline = when (width) { | |
| 531 | + 3 -> MdInline.Strong(listOf(MdInline.Emph(inner))) | |
| 532 | + 2 -> MdInline.Strong(inner) | |
| 533 | + else -> MdInline.Emph(inner) | |
| 534 | + } | |
| 535 | + val lead = if (run > width) listOf(MdInline.Text(c.toString().repeat(run - width))) else emptyList() | |
| 536 | + return lead + inline to close + width | |
| 537 | + } | |
| 538 | + } | |
| 539 | + return null | |
| 540 | + } | |
| 541 | + | |
| 542 | + /** A closing run of `width` `c`s (or a longer one, unless `exact`), skipping code spans and escapes. */ | |
| 543 | + private fun findCloser(from: Int, to: Int, c: Char, width: Int, exact: Boolean): Int? { | |
| 544 | + var ix = from | |
| 545 | + while (ix < to) { | |
| 546 | + val ch = s[ix] | |
| 547 | + when { | |
| 548 | + ch == '\\' -> ix += 2 | |
| 549 | + ch == '`' -> { | |
| 550 | + val run = runLength(ix, to, '`') | |
| 551 | + val end = findRun(ix + run, to, '`', run) | |
| 552 | + ix = if (end > 0) end + run else ix + run | |
| 553 | + } | |
| 554 | + ch == c -> { | |
| 555 | + val run = runLength(ix, to, c) | |
| 556 | + val fits = if (exact) run == width else run > width | |
| 557 | + if (fits && ix > from && canClose(ix, run, to, c)) return ix | |
| 558 | + ix += run | |
| 559 | + } | |
| 560 | + else -> ix++ | |
| 561 | + } | |
| 562 | + } | |
| 563 | + return null | |
| 564 | + } | |
| 565 | + } | |
| 566 | + } | |
| @@ -0,0 +1,150 @@ | ||
| 1 | + package cc.vectrum.code.ui.chat | |
| 2 | + | |
| 3 | + import androidx.compose.runtime.Composable | |
| 4 | + import androidx.compose.runtime.LaunchedEffect | |
| 5 | + import androidx.compose.runtime.getValue | |
| 6 | + import androidx.compose.runtime.mutableStateOf | |
| 7 | + import androidx.compose.runtime.remember | |
| 8 | + import androidx.compose.runtime.setValue | |
| 9 | + import androidx.compose.ui.platform.LocalContext | |
| 10 | + import androidx.compose.ui.unit.dp | |
| 11 | + import cc.vectrum.code.data.AppState | |
| 12 | + import cc.vectrum.code.data.OpenThread | |
| 13 | + import cc.vectrum.code.net.ThreadAction | |
| 14 | + import cc.vectrum.code.theme.palette | |
| 15 | + import cc.vectrum.code.ui.BottomSheet | |
| 16 | + import cc.vectrum.code.ui.ConfirmDialog | |
| 17 | + import cc.vectrum.code.ui.Lucide | |
| 18 | + import cc.vectrum.code.ui.LucideIcon | |
| 19 | + import cc.vectrum.code.ui.MenuRow | |
| 20 | + import cc.vectrum.code.ui.MenuSeparator | |
| 21 | + import cc.vectrum.code.ui.Nav | |
| 22 | + import cc.vectrum.code.ui.PromptDialog | |
| 23 | + | |
| 24 | + /** | |
| 25 | + * The thread action menu the chat header's title opens, as the desktop's | |
| 26 | + * (src/ui/workspace/actions.rs), with what a client can ask of the host: | |
| 27 | + * pin, settle, snooze, rename, copy and delete. | |
| 28 | + */ | |
| 29 | + @Composable | |
| 30 | + fun ThreadMenu(app: AppState, nav: Nav, thread: OpenThread, open: Boolean, onDismiss: () -> Unit) { | |
| 31 | + val p = palette | |
| 32 | + val context = LocalContext.current | |
| 33 | + val summary = thread.summary | |
| 34 | + var snoozeOpen by remember { mutableStateOf(false) } | |
| 35 | + var copyOpen by remember { mutableStateOf(false) } | |
| 36 | + var renaming by remember { mutableStateOf(false) } | |
| 37 | + var deleting by remember { mutableStateOf(false) } | |
| 38 | + val twelve = uses12Hours(context, app.prefs.string("time_format")) | |
| 39 | + LaunchedEffect(open) { | |
| 40 | + if (open) { | |
| 41 | + snoozeOpen = false | |
| 42 | + copyOpen = false | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + fun act(action: ThreadAction) { | |
| 47 | + thread.act(action) | |
| 48 | + onDismiss() | |
| 49 | + } | |
| 50 | + | |
| 51 | + fun copy(text: String, notice: String) { | |
| 52 | + copyText(context, text) | |
| 53 | + app.showNotice(notice) | |
| 54 | + onDismiss() | |
| 55 | + } | |
| 56 | + | |
| 57 | + BottomSheet(open, onDismiss = onDismiss, title = summary.title) { | |
| 58 | + if (summary.pinned) { | |
| 59 | + MenuRow("Unpin thread", Lucide.PinOff) { | |
| 60 | + act(ThreadAction.Pin(false)) | |
| 61 | + app.showNotice("Unpinned 1 thread", undo = { thread.act(ThreadAction.Pin(true)) }) | |
| 62 | + } | |
| 63 | + } else { | |
| 64 | + MenuRow("Pin thread", Lucide.Pin) { act(ThreadAction.Pin(true)) } | |
| 65 | + } | |
| 66 | + if (summary.settled) { | |
| 67 | + MenuRow("Un-settle thread", Lucide.CircleCheck) { act(ThreadAction.Unsettle) } | |
| 68 | + } else { | |
| 69 | + MenuRow("Settle thread", Lucide.CircleCheck) { | |
| 70 | + act(ThreadAction.Settle) | |
| 71 | + app.showNotice("Settled 1 thread", undo = { thread.act(ThreadAction.Unsettle) }) | |
| 72 | + } | |
| 73 | + } | |
| 74 | + val snoozed = summary.snoozedUntil?.let { it > System.currentTimeMillis() } == true | |
| 75 | + if (snoozed) { | |
| 76 | + MenuRow("Wake thread", Lucide.Clock) { act(ThreadAction.Snooze(null)) } | |
| 77 | + } else { | |
| 78 | + // Not while it waits on the user, or sets up its worktree. | |
| 79 | + val canSnooze = !summary.attention && !thread.detail.preparing | |
| 80 | + MenuRow( | |
| 81 | + "Snooze", | |
| 82 | + Lucide.Clock, | |
| 83 | + enabled = canSnooze, | |
| 84 | + trailing = { LucideIcon(if (snoozeOpen) Lucide.ChevronDown else Lucide.ChevronRight, size = 14.dp, tint = p.ink3) }, | |
| 85 | + ) { snoozeOpen = !snoozeOpen } | |
| 86 | + if (snoozeOpen && canSnooze) { | |
| 87 | + for (preset in snoozePresets(twelve)) { | |
| 88 | + MenuRow( | |
| 89 | + "${preset.label} (${preset.time})", | |
| 90 | + trailing = null, | |
| 91 | + ) { | |
| 92 | + act(ThreadAction.Snooze(preset.until)) | |
| 93 | + app.showNotice("Snoozed 1 thread", undo = { thread.act(ThreadAction.Snooze(null)) }) | |
| 94 | + } | |
| 95 | + } | |
| 96 | + } | |
| 97 | + } | |
| 98 | + MenuSeparator() | |
| 99 | + MenuRow("Rename thread", Lucide.Pencil) { | |
| 100 | + renaming = true | |
| 101 | + onDismiss() | |
| 102 | + } | |
| 103 | + MenuRow( | |
| 104 | + "Copy", | |
| 105 | + Lucide.Copy, | |
| 106 | + trailing = { LucideIcon(if (copyOpen) Lucide.ChevronDown else Lucide.ChevronRight, size = 14.dp, tint = p.ink3) }, | |
| 107 | + ) { copyOpen = !copyOpen } | |
| 108 | + if (copyOpen) { | |
| 109 | + val cwd = thread.detail.cwd | |
| 110 | + if (cwd.isNotEmpty()) MenuRow("Path", Lucide.Folder) { copy(cwd, "Path copied") } | |
| 111 | + summary.branch?.let { branch -> MenuRow("Branch", Lucide.GitBranch) { copy(branch, "Branch copied") } } | |
| 112 | + thread.detail.pullRequest?.let { pr -> MenuRow("PR link", Lucide.GitPullRequest) { copy(pr.url, "PR link copied") } } | |
| 113 | + MenuRow("Thread ID", Lucide.Hash) { copy(thread.id, "Thread ID copied") } | |
| 114 | + } | |
| 115 | + MenuSeparator() | |
| 116 | + MenuRow("Delete", Lucide.Trash, danger = true) { | |
| 117 | + if (app.prefs.bool("confirm_delete")) { | |
| 118 | + deleting = true | |
| 119 | + onDismiss() | |
| 120 | + } else { | |
| 121 | + act(ThreadAction.Delete) | |
| 122 | + nav.home() | |
| 123 | + } | |
| 124 | + } | |
| 125 | + } | |
| 126 | + | |
| 127 | + if (renaming) { | |
| 128 | + PromptDialog( | |
| 129 | + title = "Rename thread", | |
| 130 | + initial = summary.title, | |
| 131 | + confirm = "Rename", | |
| 132 | + placeholder = "Thread title", | |
| 133 | + onDismiss = { renaming = false }, | |
| 134 | + onConfirm = { title -> thread.act(ThreadAction.Rename(title)) }, | |
| 135 | + ) | |
| 136 | + } | |
| 137 | + if (deleting) { | |
| 138 | + ConfirmDialog( | |
| 139 | + title = "Delete thread \"${summary.title}\"?", | |
| 140 | + message = "This permanently clears conversation history for this thread.", | |
| 141 | + confirm = "Delete", | |
| 142 | + danger = true, | |
| 143 | + onDismiss = { deleting = false }, | |
| 144 | + onConfirm = { | |
| 145 | + thread.act(ThreadAction.Delete) | |
| 146 | + nav.home() | |
| 147 | + }, | |
| 148 | + ) | |
| 149 | + } | |
| 150 | + } | |
| @@ -1,19 +1,480 @@ | ||
| 1 | 1 | package cc.vectrum.code.ui.chat |
| 2 | 2 | |
| 3 | + import androidx.compose.animation.AnimatedVisibility | |
| 4 | + import androidx.compose.animation.fadeIn | |
| 5 | + import androidx.compose.animation.fadeOut | |
| 6 | + import androidx.compose.foundation.background | |
| 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.BoxWithConstraints | |
| 11 | + import androidx.compose.foundation.layout.Column | |
| 12 | + import androidx.compose.foundation.layout.PaddingValues | |
| 13 | + import androidx.compose.foundation.layout.Row | |
| 14 | + import androidx.compose.foundation.layout.Spacer | |
| 15 | + import androidx.compose.foundation.layout.defaultMinSize | |
| 16 | + import androidx.compose.foundation.layout.fillMaxSize | |
| 17 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 18 | + import androidx.compose.foundation.layout.height | |
| 4 | 19 | import androidx.compose.foundation.layout.padding |
| 20 | + import androidx.compose.foundation.layout.widthIn | |
| 21 | + import androidx.compose.foundation.lazy.LazyColumn | |
| 22 | + import androidx.compose.foundation.lazy.LazyListState | |
| 23 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 5 | 24 | import androidx.compose.runtime.Composable |
| 25 | + import androidx.compose.runtime.LaunchedEffect | |
| 26 | + import androidx.compose.runtime.derivedStateOf | |
| 27 | + import androidx.compose.runtime.getValue | |
| 28 | + import androidx.compose.runtime.mutableLongStateOf | |
| 29 | + import androidx.compose.runtime.mutableStateMapOf | |
| 30 | + import androidx.compose.runtime.mutableStateOf | |
| 31 | + import androidx.compose.runtime.remember | |
| 32 | + import androidx.compose.runtime.rememberCoroutineScope | |
| 33 | + import androidx.compose.runtime.setValue | |
| 34 | + import androidx.compose.runtime.snapshotFlow | |
| 35 | + import androidx.compose.runtime.snapshots.SnapshotStateMap | |
| 36 | + import androidx.compose.ui.Alignment | |
| 6 | 37 | import androidx.compose.ui.Modifier |
| 38 | + import androidx.compose.ui.draw.clip | |
| 39 | + import androidx.compose.ui.draw.drawBehind | |
| 40 | + import androidx.compose.ui.geometry.Offset | |
| 41 | + import androidx.compose.ui.geometry.Size | |
| 42 | + import androidx.compose.ui.graphics.Brush | |
| 43 | + import androidx.compose.ui.graphics.Color | |
| 44 | + import androidx.compose.ui.platform.LocalContext | |
| 45 | + import androidx.compose.ui.unit.Dp | |
| 7 | 46 | import androidx.compose.ui.unit.dp |
| 47 | + import androidx.compose.ui.unit.sp | |
| 8 | 48 | import cc.vectrum.code.data.AppState |
| 9 | 49 | import cc.vectrum.code.data.OpenThread |
| 10 | - import cc.vectrum.code.data.ThreadRef | |
| 11 | - import cc.vectrum.code.theme.Engraved | |
| 50 | + import cc.vectrum.code.net.SavedItem | |
| 51 | + import cc.vectrum.code.net.TurnChanges | |
| 52 | + import cc.vectrum.code.theme.Cap | |
| 53 | + import cc.vectrum.code.theme.CapKind | |
| 54 | + import cc.vectrum.code.theme.CapSize | |
| 55 | + import cc.vectrum.code.theme.Led | |
| 56 | + import cc.vectrum.code.theme.StatusLed | |
| 57 | + import cc.vectrum.code.theme.Txt | |
| 58 | + import cc.vectrum.code.theme.palette | |
| 59 | + import cc.vectrum.code.theme.raised | |
| 60 | + import cc.vectrum.code.ui.Lucide | |
| 61 | + import cc.vectrum.code.ui.LucideIcon | |
| 12 | 62 | import cc.vectrum.code.ui.Nav |
| 13 | 63 | import cc.vectrum.code.ui.Screen |
| 64 | + import cc.vectrum.code.ui.diff.DiffInk | |
| 65 | + import cc.vectrum.code.ui.diff.diffInk | |
| 66 | + import cc.vectrum.code.util.formatCost | |
| 67 | + import cc.vectrum.code.util.formatDuration | |
| 68 | + import kotlinx.coroutines.delay | |
| 69 | + import kotlinx.coroutines.launch | |
| 14 | 70 | |
| 15 | - // Placeholder until it's built. | |
| 71 | + // The conversation, laid out like the desktop's (src/ui/chat/timeline.rs): | |
| 72 | + // user messages as bubbles, the agent's answers as Markdown, its tool calls | |
| 73 | + // as compact work-log rows (a run of them under one summary), a finished | |
| 74 | + // turn folded under "Worked for 2m 14s", and what the turn changed on disk. | |
| 75 | + | |
| 76 | + /** The chat column's widest, as the desktop's comfortable chat width. */ | |
| 77 | + private val ContentWidth = 736.dp | |
| 78 | + | |
| 79 | + /** A user message longer than this folds behind "Show full message". */ | |
| 80 | + private const val LONG_MESSAGE_CHARS = 600 | |
| 81 | + private const val LONG_MESSAGE_LINES = 8 | |
| 82 | + | |
| 83 | + /** What the timeline remembers of an open thread: what's unfolded. */ | |
| 84 | + class TimelineState { | |
| 85 | + /** Runs of tool calls unfolded, by their first call. */ | |
| 86 | + val groups = mutableStateMapOf<Int, Boolean>() | |
| 87 | + | |
| 88 | + /** Long user messages shown in full. */ | |
| 89 | + val messages = mutableStateMapOf<Int, Boolean>() | |
| 90 | + | |
| 91 | + /** Tool calls and plans unfolded. */ | |
| 92 | + val tools = mutableStateMapOf<Int, Boolean>() | |
| 93 | + | |
| 94 | + /** Turns flipped from the Fold turns setting, by their end. */ | |
| 95 | + val turns = mutableStateMapOf<Int, Boolean>() | |
| 96 | + | |
| 97 | + /** The folders open in each turn's changes, by the turn's end. */ | |
| 98 | + val dirs = mutableStateMapOf<Int, Set<String>>() | |
| 99 | + | |
| 100 | + /** User messages showing their time and Copy, as the desktop shows them on hover. */ | |
| 101 | + val meta = mutableStateMapOf<Int, Boolean>() | |
| 102 | + } | |
| 103 | + | |
| 104 | + private fun SnapshotStateMap<Int, Boolean>.toggle(key: Int) { | |
| 105 | + if (remove(key) == null) put(key, true) | |
| 106 | + } | |
| 107 | + | |
| 108 | + /** What every row reads, worked out once per frame. */ | |
| 109 | + private class RowContext( | |
| 110 | + val thread: OpenThread, | |
| 111 | + val nav: Nav, | |
| 112 | + val layout: TranscriptLayout, | |
| 113 | + val state: TimelineState, | |
| 114 | + val changes: Map<Int, TurnChanges>, | |
| 115 | + val ink: DiffInk, | |
| 116 | + val fold: Boolean, | |
| 117 | + val showCosts: Boolean, | |
| 118 | + val inlineUi: Boolean, | |
| 119 | + val twelve: Boolean, | |
| 120 | + val working: Boolean, | |
| 121 | + val waiting: Boolean, | |
| 122 | + val pad: Dp, | |
| 123 | + ) | |
| 124 | + | |
| 125 | + /** | |
| 126 | + * The transcript: bottom-anchored, it sticks to the latest message as items | |
| 127 | + * arrive unless the user scrolled up, when "Scroll to end" brings them back. | |
| 128 | + */ | |
| 16 | 129 | @Composable |
| 17 | 130 | fun Timeline(app: AppState, nav: Nav, thread: OpenThread, modifier: Modifier = Modifier) { |
| 18 | - Box(modifier.padding(16.dp)) { Engraved("${thread.items.size} items") } | |
| 131 | + val p = palette | |
| 132 | + val prefs = app.prefs | |
| 133 | + val context = LocalContext.current | |
| 134 | + val state = remember(thread.id) { TimelineState() } | |
| 135 | + val list = remember(thread.id) { LazyListState() } | |
| 136 | + val layout by remember(thread) { derivedStateOf { TranscriptLayout(thread.items.toList()) } } | |
| 137 | + val detail = thread.detail | |
| 138 | + val changes = remember(detail) { detail.changesByTurn() } | |
| 139 | + | |
| 140 | + if (!thread.loaded || thread.items.isEmpty()) { | |
| 141 | + Box(modifier, contentAlignment = Alignment.Center) { | |
| 142 | + Txt(if (thread.loaded) "No messages yet" else "Loading the conversation…", color = p.ink3, size = 13.sp) | |
| 143 | + } | |
| 144 | + return | |
| 145 | + } | |
| 146 | + | |
| 147 | + // Whether the view follows new items: until the user scrolls away from the end. | |
| 148 | + var stick by remember(thread.id) { mutableStateOf(true) } | |
| 149 | + val atEnd by remember(list) { | |
| 150 | + derivedStateOf { list.firstVisibleItemIndex == 0 && list.firstVisibleItemScrollOffset < 48 } | |
| 151 | + } | |
| 152 | + LaunchedEffect(list) { | |
| 153 | + snapshotFlow { atEnd }.collect { if (list.isScrollInProgress) stick = it } | |
| 154 | + } | |
| 155 | + LaunchedEffect(list) { | |
| 156 | + snapshotFlow { list.isScrollInProgress }.collect { scrolling -> if (!scrolling) stick = atEnd } | |
| 157 | + } | |
| 158 | + LaunchedEffect(thread.id) { | |
| 159 | + snapshotFlow { thread.items.size }.collect { if (stick) list.scrollToItem(0) } | |
| 160 | + } | |
| 161 | + val scope = rememberCoroutineScope() | |
| 162 | + | |
| 163 | + BoxWithConstraints(modifier) { | |
| 164 | + val ctx = RowContext( | |
| 165 | + thread = thread, | |
| 166 | + nav = nav, | |
| 167 | + layout = layout, | |
| 168 | + state = state, | |
| 169 | + changes = changes, | |
| 170 | + ink = diffInk(p, prefs.string("diff_colors")), | |
| 171 | + fold = prefs.bool("fold_turns"), | |
| 172 | + showCosts = prefs.bool("show_costs"), | |
| 173 | + inlineUi = prefs.bool("inline_ui"), | |
| 174 | + twelve = uses12Hours(context, prefs.string("time_format")), | |
| 175 | + working = thread.working, | |
| 176 | + waiting = thread.summary.attention || detail.preparing, | |
| 177 | + pad = if (maxWidth < 600.dp) 14.dp else 28.dp, | |
| 178 | + ) | |
| 179 | + val count = layout.items.size | |
| 180 | + LazyColumn( | |
| 181 | + Modifier.fillMaxSize(), | |
| 182 | + state = list, | |
| 183 | + reverseLayout = true, | |
| 184 | + contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp), | |
| 185 | + ) { | |
| 186 | + items(count, key = { count - 1 - it }, contentType = { layout.items[count - 1 - it]::class }) { reversed -> | |
| 187 | + TranscriptRow(count - 1 - reversed, ctx) | |
| 188 | + } | |
| 189 | + } | |
| 190 | + // The fade into the sheet's bottom edge. | |
| 191 | + Spacer( | |
| 192 | + Modifier | |
| 193 | + .align(Alignment.BottomCenter) | |
| 194 | + .fillMaxWidth() | |
| 195 | + .height(24.dp) | |
| 196 | + .background(Brush.verticalGradient(listOf(p.s.copy(alpha = 0f), p.s))), | |
| 197 | + ) | |
| 198 | + AnimatedVisibility( | |
| 199 | + visible = !atEnd, | |
| 200 | + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp), | |
| 201 | + enter = fadeIn(), | |
| 202 | + exit = fadeOut(), | |
| 203 | + ) { | |
| 204 | + Cap(label = "Scroll to end", icon = Lucide.ChevronDown, size = CapSize.Compact) { | |
| 205 | + stick = true | |
| 206 | + scope.launch { list.animateScrollToItem(0) } | |
| 207 | + } | |
| 208 | + } | |
| 209 | + } | |
| 210 | + } | |
| 211 | + | |
| 212 | + /** What a row's content is, which sets its spacing. */ | |
| 213 | + private enum class Kind { User, Answer, Plan, Work, Other, Changes } | |
| 214 | + | |
| 215 | + /** One transcript row, as the desktop's `render_item`. */ | |
| 216 | + @Composable | |
| 217 | + private fun TranscriptRow(ix: Int, ctx: RowContext) { | |
| 218 | + val item = ctx.layout.items[ix] | |
| 219 | + val turn = ctx.layout.turn(ix) | |
| 220 | + // With folding off, turns start unfolded and a tap folds them. | |
| 221 | + val expanded = turn != null && (ctx.state.turns[turn.end] == true) == ctx.fold | |
| 222 | + val summary = turn?.takeIf { it.first == ix } | |
| 223 | + val visible = when { | |
| 224 | + item is SavedItem.User && item.text.startsWith(RESULT_PREFIX) -> false | |
| 225 | + item is SavedItem.TurnEnd -> ctx.changes[ix] != null | |
| 226 | + item is SavedItem.Notice && item.error || item is SavedItem.Question -> true | |
| 227 | + item is SavedItem.Tool && isAsk(item) || item is SavedItem.TaskEvent -> true | |
| 228 | + isHidden(item) -> false | |
| 229 | + turn != null -> expanded || ix == turn.answer | |
| 230 | + else -> true | |
| 231 | + } | |
| 232 | + // The live turn opens under its user message with "Working for 12s". | |
| 233 | + val working = ctx.working && ix == ctx.layout.lastUser && !ctx.waiting | |
| 234 | + if (!visible && summary == null && !working) return | |
| 235 | + | |
| 236 | + val meta = (item as? SavedItem.Assistant)?.let { turn?.takeIf { it.answer == ix }?.at } | |
| 237 | + val kind = when (item) { | |
| 238 | + is SavedItem.User -> Kind.User | |
| 239 | + is SavedItem.Assistant -> if (meta != null) Kind.Answer else Kind.Other | |
| 240 | + is SavedItem.Tool -> when { | |
| 241 | + isAsk(item) -> Kind.Other | |
| 242 | + item.name == "ExitPlanMode" -> Kind.Plan | |
| 243 | + else -> Kind.Work | |
| 244 | + } | |
| 245 | + is SavedItem.TurnEnd -> Kind.Changes | |
| 246 | + else -> Kind.Other | |
| 247 | + } | |
| 248 | + val bottom = when { | |
| 249 | + !visible -> 6.dp | |
| 250 | + kind == Kind.Work -> 0.dp | |
| 251 | + kind == Kind.Other -> 8.dp | |
| 252 | + else -> 16.dp | |
| 253 | + } | |
| 254 | + val top = if (item is SavedItem.User && ix == 0) 16.dp else 0.dp | |
| 255 | + val fold = @Composable { if (summary != null) TurnFold(summary, expanded, ctx) } | |
| 256 | + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { | |
| 257 | + Column( | |
| 258 | + Modifier | |
| 259 | + .widthIn(max = ContentWidth + ctx.pad * 2) | |
| 260 | + .fillMaxWidth() | |
| 261 | + .padding(start = ctx.pad, end = ctx.pad, top = top, bottom = bottom), | |
| 262 | + ) { | |
| 263 | + if (item is SavedItem.TaskEvent) { | |
| 264 | + if (visible) TaskEventRow(item) | |
| 265 | + if (summary != null) Box(Modifier.padding(top = 8.dp)) { fold() } | |
| 266 | + } else { | |
| 267 | + if (summary != null) Box(Modifier.padding(bottom = 6.dp)) { fold() } | |
| 268 | + if (visible) RowContent(ix, item, kind, meta, ctx) | |
| 269 | + } | |
| 270 | + if (working) WorkingRow(ctx.thread.detail.turnElapsedMs) | |
| 271 | + } | |
| 272 | + } | |
| 273 | + } | |
| 274 | + | |
| 275 | + @Composable | |
| 276 | + private fun RowContent(ix: Int, item: SavedItem, kind: Kind, meta: Long?, ctx: RowContext) { | |
| 277 | + val cwd = ctx.thread.detail.cwd | |
| 278 | + when (item) { | |
| 279 | + is SavedItem.User -> UserBubble(ix, item, ctx) | |
| 280 | + is SavedItem.Assistant -> AssistantText(item.text, meta, ctx) | |
| 281 | + is SavedItem.Tool -> when (kind) { | |
| 282 | + Kind.Plan -> PlanCard(item, ctx.state.tools[ix] == true) { ctx.state.tools.toggle(ix) } | |
| 283 | + Kind.Work -> WorkCell(ix, item, cwd, ctx) | |
| 284 | + else -> AnswersRows(item) | |
| 285 | + } | |
| 286 | + is SavedItem.Question -> QuestionRecord(item) | |
| 287 | + is SavedItem.TaskEvent -> TaskEventRow(item) | |
| 288 | + is SavedItem.Notice -> if (item.error) ErrorNotice(item.text) else NoticeDivider(item.text) | |
| 289 | + is SavedItem.TurnEnd -> { | |
| 290 | + val changes = ctx.changes[ix] ?: return | |
| 291 | + ChangesCard( | |
| 292 | + changes, | |
| 293 | + openDirs = ctx.state.dirs[ix] ?: emptySet(), | |
| 294 | + ink = ctx.ink, | |
| 295 | + onDirs = { ctx.state.dirs[ix] = it }, | |
| 296 | + onOpen = { path -> ctx.nav.go(Screen.Diff(ctx.thread.ref, changes.base, changes.head, path)) }, | |
| 297 | + ) | |
| 298 | + } | |
| 299 | + } | |
| 300 | + } | |
| 301 | + | |
| 302 | + /** A tool call: its own row, or in a run of them, the run's summary that unfolds into each. */ | |
| 303 | + @Composable | |
| 304 | + private fun WorkCell(ix: Int, tool: SavedItem.Tool, cwd: String, ctx: RowContext) { | |
| 305 | + val state = ctx.state | |
| 306 | + val members = ctx.layout.group(ix) | |
| 307 | + val entry = @Composable { ToolEntry(tool, cwd, state.tools[ix] == true) { state.tools.toggle(ix) } } | |
| 308 | + if (members.size < 2) { | |
| 309 | + Box(Modifier.padding(bottom = 8.dp)) { entry() } | |
| 310 | + return | |
| 311 | + } | |
| 312 | + val start = members[0] | |
| 313 | + val open = state.groups[start] == true | |
| 314 | + if (ix != start) { | |
| 315 | + if (open) Box(Modifier.padding(bottom = if (members.last() == ix) 8.dp else 0.dp)) { entry() } | |
| 316 | + return | |
| 317 | + } | |
| 318 | + // The live turn's trailing run shows its latest call; a settled one sums them up. | |
| 319 | + val items = ctx.layout.items | |
| 320 | + val live = ctx.working && items.subList(members.last() + 1, items.size).all { isHidden(it) || it is SavedItem.TaskEvent } | |
| 321 | + val tools = members.mapNotNull { items[it] as? SavedItem.Tool } | |
| 322 | + Column(Modifier.padding(bottom = if (open) 0.dp else 8.dp)) { | |
| 323 | + WorkGroupHeader(tools, cwd, live) { state.groups.toggle(start) } | |
| 324 | + if (open) entry() | |
| 325 | + } | |
| 326 | + } | |
| 327 | + | |
| 328 | + /** | |
| 329 | + * A user message: a bubble on the right. A tap shows its time and Copy | |
| 330 | + * under it; a long one folds behind "Show full message". | |
| 331 | + */ | |
| 332 | + @Composable | |
| 333 | + private fun UserBubble(ix: Int, item: SavedItem.User, ctx: RowContext) { | |
| 334 | + val p = palette | |
| 335 | + val context = LocalContext.current | |
| 336 | + val text = item.text | |
| 337 | + val unfolded = ctx.state.messages[ix] == true | |
| 338 | + val long = text.length > LONG_MESSAGE_CHARS || text.lines().size > LONG_MESSAGE_LINES | |
| 339 | + val shown = if (long && !unfolded) { | |
| 340 | + var cut = text.lines().take(LONG_MESSAGE_LINES).joinToString("\n") | |
| 341 | + if (cut.length > LONG_MESSAGE_CHARS) cut = cut.take(LONG_MESSAGE_CHARS) | |
| 342 | + "$cut…" | |
| 343 | + } else { | |
| 344 | + text | |
| 345 | + } | |
| 346 | + val showMeta = ctx.state.meta[ix] == true | |
| 347 | + BoxWithConstraints(Modifier.fillMaxWidth()) { | |
| 348 | + val maxBubble = maxWidth * 0.8f | |
| 349 | + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) { | |
| 350 | + val shape = RoundedCornerShape(18.dp) | |
| 351 | + Column( | |
| 352 | + Modifier | |
| 353 | + .widthIn(max = maxBubble) | |
| 354 | + .raised(p, shape) | |
| 355 | + .clip(shape) | |
| 356 | + .clickable(interactionSource = null, indication = null) { ctx.state.meta.toggle(ix) } | |
| 357 | + .padding(12.dp), | |
| 358 | + ) { | |
| 359 | + if (item.attachments.isNotEmpty()) { | |
| 360 | + Column(Modifier.padding(bottom = if (text.isEmpty()) 0.dp else 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { | |
| 361 | + for (name in item.attachments) { | |
| 362 | + Row(Modifier.padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 363 | + LucideIcon(Lucide.File, size = 16.dp, tint = p.ink3) | |
| 364 | + Txt(name.substringAfterLast('/'), color = p.ink, size = 14.sp, maxLines = 1) | |
| 365 | + } | |
| 366 | + } | |
| 367 | + } | |
| 368 | + } | |
| 369 | + if (text.isNotEmpty()) Txt(shown, color = p.ink, size = 14.sp, lineHeight = 22.sp) | |
| 370 | + if (long) { | |
| 371 | + Box(Modifier.align(Alignment.End).padding(top = 6.dp)) { | |
| 372 | + Cap(label = if (unfolded) "Show less" else "Show full message", kind = CapKind.Ghost, size = CapSize.Mini) { | |
| 373 | + ctx.state.messages.toggle(ix) | |
| 374 | + } | |
| 375 | + } | |
| 376 | + } | |
| 377 | + } | |
| 378 | + if (showMeta) { | |
| 379 | + Row( | |
| 380 | + Modifier.padding(end = 4.dp), | |
| 381 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 382 | + verticalAlignment = Alignment.CenterVertically, | |
| 383 | + ) { | |
| 384 | + if (item.at > 0) Txt(dayAware(item.at, ctx.twelve), color = p.ink3, size = 12.sp) | |
| 385 | + Cap(icon = Lucide.Copy, kind = CapKind.Ghost, size = CapSize.Mini) { copyText(context, text) } | |
| 386 | + } | |
| 387 | + } | |
| 388 | + } | |
| 389 | + } | |
| 390 | + } | |
| 391 | + | |
| 392 | + /** The agent's message; the turn's answer has Copy and its time under it. */ | |
| 393 | + @Composable | |
| 394 | + private fun AssistantText(text: String, meta: Long?, ctx: RowContext) { | |
| 395 | + val p = palette | |
| 396 | + val context = LocalContext.current | |
| 397 | + Column(Modifier.fillMaxWidth()) { | |
| 398 | + Box(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp)) { | |
| 399 | + if (text.isBlank()) { | |
| 400 | + Txt("(empty response)", color = p.ink3, size = 14.sp, lineHeight = 22.75.sp) | |
| 401 | + } else { | |
| 402 | + MarkdownText(text, inlineUi = ctx.inlineUi) | |
| 403 | + } | |
| 404 | + } | |
| 405 | + if (meta != null) { | |
| 406 | + Row( | |
| 407 | + Modifier.padding(top = 6.dp, start = 4.dp, end = 4.dp), | |
| 408 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 409 | + verticalAlignment = Alignment.CenterVertically, | |
| 410 | + ) { | |
| 411 | + Cap(icon = Lucide.Copy, kind = CapKind.Ghost, size = CapSize.Mini) { copyText(context, text) } | |
| 412 | + if (meta > 0) Txt(dayAware(meta, ctx.twelve), color = p.ink3, size = 12.sp) | |
| 413 | + } | |
| 414 | + } | |
| 415 | + } | |
| 416 | + } | |
| 417 | + | |
| 418 | + /** A hairline under a row, as the desktop's `border_b_1`. */ | |
| 419 | + private fun Modifier.underline(color: Color) = drawBehind { | |
| 420 | + val stroke = 1.dp.toPx() | |
| 421 | + drawRect(color, Offset(0f, size.height - stroke), Size(size.width, stroke)) | |
| 422 | + } | |
| 423 | + | |
| 424 | + /** "Worked for 2m 14s", the row a finished turn folds under; its cost on the right. */ | |
| 425 | + @Composable | |
| 426 | + private fun TurnFold(turn: FinishedTurn, expanded: Boolean, ctx: RowContext) { | |
| 427 | + val p = palette | |
| 428 | + val cost = turn.cost?.takeIf { it > 0 && ctx.showCosts } | |
| 429 | + Row( | |
| 430 | + Modifier.fillMaxWidth().underline(p.rule).padding(top = 4.dp, bottom = 8.dp, end = 2.dp), | |
| 431 | + verticalAlignment = Alignment.CenterVertically, | |
| 432 | + ) { | |
| 433 | + Row( | |
| 434 | + Modifier | |
| 435 | + .clip(RoundedCornerShape(8.dp)) | |
| 436 | + .then(if (turn.hasWork) Modifier.clickable { ctx.state.turns.toggle(turn.end) } else Modifier) | |
| 437 | + .defaultMinSize(minHeight = 30.dp) | |
| 438 | + .padding(horizontal = 4.dp), | |
| 439 | + horizontalArrangement = Arrangement.spacedBy(4.dp), | |
| 440 | + verticalAlignment = Alignment.CenterVertically, | |
| 441 | + ) { | |
| 442 | + Txt("Worked for ${formatDuration(turn.durationMs)}", color = p.ink3, size = 14.sp, lineHeight = 22.75.sp, maxLines = 1) | |
| 443 | + if (turn.hasWork) LucideIcon(if (expanded) Lucide.ChevronDown else Lucide.ChevronRight, size = 14.dp, tint = p.ink3) | |
| 444 | + } | |
| 445 | + Spacer(Modifier.weight(1f)) | |
| 446 | + if (cost != null) Txt(formatCost(cost), color = p.ink3, size = 12.sp, maxLines = 1) | |
| 447 | + } | |
| 448 | + } | |
| 449 | + | |
| 450 | + /** "Working for 12s", heading the live turn. It counts on between the host's updates. */ | |
| 451 | + @Composable | |
| 452 | + private fun WorkingRow(elapsedMs: Long?) { | |
| 453 | + val p = palette | |
| 454 | + val since = remember(elapsedMs) { System.currentTimeMillis() } | |
| 455 | + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } | |
| 456 | + LaunchedEffect(Unit) { | |
| 457 | + while (true) { | |
| 458 | + delay(1_000) | |
| 459 | + now = System.currentTimeMillis() | |
| 460 | + } | |
| 461 | + } | |
| 462 | + val label = if (elapsedMs != null) { | |
| 463 | + "Working for ${formatElapsed(((elapsedMs + now - since).coerceAtLeast(0)) / 1_000)}" | |
| 464 | + } else { | |
| 465 | + "Working..." | |
| 466 | + } | |
| 467 | + Row( | |
| 468 | + Modifier | |
| 469 | + .fillMaxWidth() | |
| 470 | + .padding(top = 12.dp, bottom = 8.dp) | |
| 471 | + .underline(p.rule) | |
| 472 | + .defaultMinSize(minHeight = 24.dp) | |
| 473 | + .padding(start = 4.dp, end = 4.dp, bottom = 8.dp), | |
| 474 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 475 | + verticalAlignment = Alignment.CenterVertically, | |
| 476 | + ) { | |
| 477 | + StatusLed(Led.Live, pulse = true) | |
| 478 | + Txt(label, color = p.ink3, size = 14.sp, maxLines = 1) | |
| 479 | + } | |
| 19 | 480 | } |
| @@ -0,0 +1,434 @@ | ||
| 1 | + @file:OptIn(ExperimentalSerializationApi::class) | |
| 2 | + | |
| 3 | + package cc.vectrum.code.ui.chat | |
| 4 | + | |
| 5 | + import androidx.compose.foundation.background | |
| 6 | + import androidx.compose.foundation.clickable | |
| 7 | + import androidx.compose.foundation.layout.Arrangement | |
| 8 | + import androidx.compose.foundation.layout.Box | |
| 9 | + import androidx.compose.foundation.layout.Column | |
| 10 | + import androidx.compose.foundation.layout.Row | |
| 11 | + import androidx.compose.foundation.layout.RowScope | |
| 12 | + import androidx.compose.foundation.layout.Spacer | |
| 13 | + import androidx.compose.foundation.layout.defaultMinSize | |
| 14 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 15 | + import androidx.compose.foundation.layout.height | |
| 16 | + import androidx.compose.foundation.layout.heightIn | |
| 17 | + import androidx.compose.foundation.layout.padding | |
| 18 | + import androidx.compose.foundation.layout.size | |
| 19 | + import androidx.compose.foundation.rememberScrollState | |
| 20 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 21 | + import androidx.compose.foundation.text.BasicText | |
| 22 | + import androidx.compose.foundation.text.selection.SelectionContainer | |
| 23 | + import androidx.compose.foundation.verticalScroll | |
| 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.innerShadow | |
| 29 | + import androidx.compose.ui.graphics.Color | |
| 30 | + import androidx.compose.ui.graphics.shadow.Shadow | |
| 31 | + import androidx.compose.ui.platform.LocalContext | |
| 32 | + import androidx.compose.ui.text.TextStyle | |
| 33 | + import androidx.compose.ui.text.font.FontWeight | |
| 34 | + import androidx.compose.ui.unit.dp | |
| 35 | + import androidx.compose.ui.unit.sp | |
| 36 | + import cc.vectrum.code.net.SavedItem | |
| 37 | + import cc.vectrum.code.theme.Cap | |
| 38 | + import cc.vectrum.code.theme.CapKind | |
| 39 | + import cc.vectrum.code.theme.CapSize | |
| 40 | + import cc.vectrum.code.theme.Engraved | |
| 41 | + import cc.vectrum.code.theme.Led | |
| 42 | + import cc.vectrum.code.theme.Mono | |
| 43 | + import cc.vectrum.code.theme.StatusLed | |
| 44 | + import cc.vectrum.code.theme.Tag | |
| 45 | + import cc.vectrum.code.theme.Txt | |
| 46 | + import cc.vectrum.code.theme.palette | |
| 47 | + import cc.vectrum.code.theme.raised | |
| 48 | + import cc.vectrum.code.theme.well | |
| 49 | + import cc.vectrum.code.ui.Lucide | |
| 50 | + import cc.vectrum.code.ui.LucideIcon | |
| 51 | + import cc.vectrum.code.util.formatDuration | |
| 52 | + import kotlinx.serialization.ExperimentalSerializationApi | |
| 53 | + import kotlinx.serialization.json.Json | |
| 54 | + import kotlinx.serialization.json.JsonElement | |
| 55 | + import kotlinx.serialization.json.JsonNull | |
| 56 | + | |
| 57 | + // Tool calls as T3 Code's compact work-log rows (src/ui/chat/timeline.rs), | |
| 58 | + // and the transcript's other small rows: answers, task events, notices. | |
| 59 | + | |
| 60 | + private val Pretty = Json { | |
| 61 | + prettyPrint = true | |
| 62 | + prettyPrintIndent = " " | |
| 63 | + } | |
| 64 | + | |
| 65 | + /** A work-log row: a 24dp icon slot, the label, and whatever trails it. */ | |
| 66 | + @Composable | |
| 67 | + fun WorkRow( | |
| 68 | + icon: Lucide, | |
| 69 | + iconColor: Color, | |
| 70 | + modifier: Modifier = Modifier, | |
| 71 | + onClick: (() -> Unit)? = null, | |
| 72 | + content: @Composable RowScope.() -> Unit, | |
| 73 | + ) { | |
| 74 | + Row( | |
| 75 | + modifier | |
| 76 | + .fillMaxWidth() | |
| 77 | + .defaultMinSize(minHeight = 32.dp) | |
| 78 | + .clip(RoundedCornerShape(8.dp)) | |
| 79 | + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) | |
| 80 | + .padding(horizontal = 2.dp, vertical = 4.dp), | |
| 81 | + horizontalArrangement = Arrangement.spacedBy(6.dp), | |
| 82 | + verticalAlignment = Alignment.CenterVertically, | |
| 83 | + ) { | |
| 84 | + Box(Modifier.size(24.dp), contentAlignment = Alignment.Center) { LucideIcon(icon, size = 16.dp, tint = iconColor) } | |
| 85 | + content() | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + /** The row's label: one line, ink while it runs. */ | |
| 90 | + @Composable | |
| 91 | + fun RowScope.WorkLabel(text: String, running: Boolean = false) { | |
| 92 | + val p = palette | |
| 93 | + Txt(text, Modifier.weight(1f), color = if (running) p.ink else p.ink2, size = 14.sp, lineHeight = 22.sp, maxLines = 1) | |
| 94 | + } | |
| 95 | + | |
| 96 | + /** A short note at a row's end: `Awaiting approval`, `Finished`. */ | |
| 97 | + @Composable | |
| 98 | + fun RowNote(text: String, color: Color) = Txt(text, color = color, size = 12.sp, maxLines = 1) | |
| 99 | + | |
| 100 | + /** The chevron a row that unfolds ends with. */ | |
| 101 | + @Composable | |
| 102 | + fun RowChevron(expanded: Boolean) { | |
| 103 | + val p = palette | |
| 104 | + Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) { | |
| 105 | + LucideIcon(if (expanded) Lucide.ChevronDown else Lucide.ChevronRight, size = 12.dp, tint = p.ink3.copy(alpha = 0.7f)) | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + /** One tool call's row, unfolding into its input and output. */ | |
| 110 | + @Composable | |
| 111 | + fun ToolEntry(tool: SavedItem.Tool, cwd: String, expanded: Boolean, onToggle: () -> Unit) { | |
| 112 | + val p = palette | |
| 113 | + val presentation = present(tool.name, tool.input, cwd) | |
| 114 | + val pending = tool.permission == "pending" | |
| 115 | + val running = tool.state == "running" && !pending || tool.background == "running" | |
| 116 | + val failed = tool.state == "error" || tool.background == "failed" | |
| 117 | + val decision = when (tool.permission) { | |
| 118 | + "pending" -> "Awaiting approval" to p.amberInk | |
| 119 | + "denied" -> "Declined" to p.red | |
| 120 | + "cancelled" -> "Cancelled" to p.ink3 | |
| 121 | + else -> null | |
| 122 | + } | |
| 123 | + Column(Modifier.fillMaxWidth()) { | |
| 124 | + WorkRow(presentation.icon, if (failed) p.red.copy(alpha = 0.4f) else p.ink3, onClick = onToggle) { | |
| 125 | + WorkLabel(presentation.label, running) | |
| 126 | + if (tool.subagentToolCount > 0) RowNote("${tool.subagentToolCount} tool uses", p.ink3) | |
| 127 | + if (decision != null) RowNote(decision.first, decision.second) | |
| 128 | + TaskControls(tool) | |
| 129 | + if (running) StatusLed(Led.Live, pulse = true) | |
| 130 | + RowChevron(expanded) | |
| 131 | + } | |
| 132 | + if (expanded) { | |
| 133 | + Box(Modifier.padding(start = 28.dp, bottom = 4.dp)) { ToolDetails(tool, cwd) } | |
| 134 | + } | |
| 135 | + } | |
| 136 | + } | |
| 137 | + | |
| 138 | + /** | |
| 139 | + * Where a tool's background task stands. The host doesn't share task ids, | |
| 140 | + * so (as on a desktop client) the buttons to move a command to the | |
| 141 | + * background or stop it there stay on the host. | |
| 142 | + */ | |
| 143 | + @Composable | |
| 144 | + private fun TaskControls(tool: SavedItem.Tool) { | |
| 145 | + val status = tool.background ?: return | |
| 146 | + Engraved( | |
| 147 | + when (taskStatus(status)) { | |
| 148 | + "failed" -> "Failed in background" | |
| 149 | + "stopped" -> "Stopped" | |
| 150 | + else -> "Ran in background" | |
| 151 | + }, | |
| 152 | + ) | |
| 153 | + } | |
| 154 | + | |
| 155 | + /** A tool call's input and output, each in an inset well. */ | |
| 156 | + @Composable | |
| 157 | + fun ToolDetails(tool: SavedItem.Tool, cwd: String) { | |
| 158 | + val p = palette | |
| 159 | + val oldText = field(tool.input, "old_string", "oldText") | |
| 160 | + val newText = field(tool.input, "new_string", "newText") | |
| 161 | + val sections = mutableListOf<Pair<String, String>>() | |
| 162 | + var plan: String? = null | |
| 163 | + val name = tool.name.lowercase() | |
| 164 | + when { | |
| 165 | + name == "bash" -> { | |
| 166 | + field(tool.input, "description")?.let { sections.add("Description" to it) } | |
| 167 | + sections.add("Command" to field(tool.input, "command").orEmpty()) | |
| 168 | + } | |
| 169 | + name == "edit" && (oldText != null || newText != null) -> { | |
| 170 | + sections.add("File" to field(tool.input, "file_path", "path").orEmpty()) | |
| 171 | + sections.add("Replace" to oldText.orEmpty()) | |
| 172 | + sections.add("With" to newText.orEmpty()) | |
| 173 | + } | |
| 174 | + name == "write" -> { | |
| 175 | + sections.add("File" to field(tool.input, "file_path", "path").orEmpty()) | |
| 176 | + sections.add("Content" to field(tool.input, "content").orEmpty()) | |
| 177 | + } | |
| 178 | + name == "exitplanmode" -> plan = field(tool.input, "plan") | |
| 179 | + else -> sections.add("Input" to prettyJson(tool.input)) | |
| 180 | + } | |
| 181 | + tool.result?.let { sections.add((if (tool.state == "error") "Error" else "Output") to truncate(it, MAX_TOOL_OUTPUT)) } | |
| 182 | + val subagents = subagentsOf(tool) | |
| 183 | + Column( | |
| 184 | + Modifier.padding(start = 12.dp, end = 12.dp, top = 2.dp, bottom = 12.dp), | |
| 185 | + verticalArrangement = Arrangement.spacedBy(10.dp), | |
| 186 | + ) { | |
| 187 | + if (subagents.isNotEmpty()) SubagentList(subagents, cwd) | |
| 188 | + if (plan != null) { | |
| 189 | + Box(Modifier.fillMaxWidth().well(p, RoundedCornerShape(12.dp)).padding(horizontal = 16.dp, vertical = 12.dp)) { | |
| 190 | + MarkdownText(plan) | |
| 191 | + } | |
| 192 | + } | |
| 193 | + for ((label, body) in sections) { | |
| 194 | + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { | |
| 195 | + Engraved(label, Modifier.padding(horizontal = 2.dp)) | |
| 196 | + Box( | |
| 197 | + Modifier | |
| 198 | + .fillMaxWidth() | |
| 199 | + .well(p, RoundedCornerShape(12.dp)) | |
| 200 | + .heightIn(max = 280.dp) | |
| 201 | + .verticalScroll(rememberScrollState()) | |
| 202 | + .padding(horizontal = 12.dp, vertical = 9.dp), | |
| 203 | + ) { | |
| 204 | + SelectionContainer { | |
| 205 | + BasicText( | |
| 206 | + body.ifEmpty { "(empty)" }, | |
| 207 | + style = TextStyle(color = p.ink2, fontSize = 11.5.sp, lineHeight = 17.sp, fontFamily = Mono), | |
| 208 | + ) | |
| 209 | + } | |
| 210 | + } | |
| 211 | + } | |
| 212 | + } | |
| 213 | + } | |
| 214 | + } | |
| 215 | + | |
| 216 | + private fun prettyJson(input: JsonElement): String = | |
| 217 | + if (input is JsonNull) "" else runCatching { Pretty.encodeToString(JsonElement.serializer(), input) }.getOrDefault(input.toString()) | |
| 218 | + | |
| 219 | + /** | |
| 220 | + * The subagents a call started, as the subagents panel lists them: a light, | |
| 221 | + * what each was asked, for how long, what it's doing now, and its totals. | |
| 222 | + */ | |
| 223 | + @Composable | |
| 224 | + fun SubagentList(entries: List<SubagentEntry>, cwd: String) { | |
| 225 | + val p = palette | |
| 226 | + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { | |
| 227 | + for (entry in entries) { | |
| 228 | + val running = entry.status == "running" | |
| 229 | + val (led, status) = when (entry.status) { | |
| 230 | + "running" -> Led.Live to null | |
| 231 | + "failed" -> Led.Failed to "Failed" | |
| 232 | + "stopped" -> Led.Off to "Stopped" | |
| 233 | + else -> Led.Off to null | |
| 234 | + } | |
| 235 | + val meta = listOfNotNull( | |
| 236 | + entry.kind?.takeIf { it.isNotEmpty() && it != "general-purpose" }, | |
| 237 | + entry.toolUses.takeIf { it > 0 }?.let { "$it ${if (it == 1) "tool" else "tools"}" }, | |
| 238 | + entry.tokens?.takeIf { it > 0 }?.let(::formatTokens), | |
| 239 | + "background".takeIf { entry.background }, | |
| 240 | + ) | |
| 241 | + Row(Modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 5.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { | |
| 242 | + Box(Modifier.height(18.dp), contentAlignment = Alignment.Center) { StatusLed(led, pulse = running) } | |
| 243 | + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { | |
| 244 | + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 245 | + Txt(entry.label, Modifier.weight(1f), color = if (running) p.ink else p.ink2, size = 13.sp, maxLines = 1) | |
| 246 | + if (status != null) Txt(status, color = if (entry.status == "failed") p.red else p.ink3, size = 11.5.sp) | |
| 247 | + entry.elapsedMs?.let { Txt(formatDuration(it), color = p.ink3, size = 11.sp, family = Mono) } | |
| 248 | + } | |
| 249 | + entry.tool?.let { (name, input) -> | |
| 250 | + val activity = present(name, input, cwd) | |
| 251 | + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 252 | + LucideIcon(activity.icon, size = 13.dp, tint = p.ink3) | |
| 253 | + Txt(activity.label, Modifier.weight(1f), color = p.ink2, size = 12.sp, maxLines = 1) | |
| 254 | + } | |
| 255 | + } | |
| 256 | + if (running && entry.summary != null) Txt(entry.summary, color = p.ink3, size = 12.sp, maxLines = 2) | |
| 257 | + if (meta.isNotEmpty()) Txt(meta.joinToString(" · "), color = p.ink3, size = 10.5.sp, family = Mono) | |
| 258 | + } | |
| 259 | + } | |
| 260 | + } | |
| 261 | + } | |
| 262 | + } | |
| 263 | + | |
| 264 | + /** | |
| 265 | + * A tool call as a work-log row or, heading a run of them, the run's | |
| 266 | + * summary, which unfolds into every call. `live` is a run the working turn | |
| 267 | + * ends with: it shows its latest call. | |
| 268 | + */ | |
| 269 | + @Composable | |
| 270 | + fun WorkGroupHeader(tools: List<SavedItem.Tool>, cwd: String, live: Boolean, onToggle: () -> Unit) { | |
| 271 | + val p = palette | |
| 272 | + val presentations = tools.map { present(it.name, it.input, cwd) } | |
| 273 | + val failed = tools.any { it.state == "error" } | |
| 274 | + val icon: Lucide | |
| 275 | + val label: String | |
| 276 | + val running: Boolean | |
| 277 | + if (live) { | |
| 278 | + val latest = tools.indexOfLast { it.state == "running" }.takeIf { it >= 0 } ?: tools.lastIndex | |
| 279 | + val tool = tools[latest] | |
| 280 | + running = tool.state == "running" | |
| 281 | + val program = program(tool) | |
| 282 | + label = when { | |
| 283 | + tool.name == "Bash" && running && program != null -> "Running $program" | |
| 284 | + tool.name == "Bash" && tool.state == "error" && program != null -> "Failed $program" | |
| 285 | + tool.name == "Bash" && program != null -> "Ran $program" | |
| 286 | + else -> presentations[latest].label | |
| 287 | + } | |
| 288 | + icon = presentations[latest].icon | |
| 289 | + } else { | |
| 290 | + val actions = presentations.map { it.action } | |
| 291 | + icon = dominant(actions).icon | |
| 292 | + label = groupSummary(actions) | |
| 293 | + running = false | |
| 294 | + } | |
| 295 | + WorkRow(icon, if (failed && !running) p.red.copy(alpha = 0.4f) else p.ink3, onClick = onToggle) { | |
| 296 | + WorkLabel(label, running) | |
| 297 | + if (running) StatusLed(Led.Live, pulse = true) | |
| 298 | + } | |
| 299 | + } | |
| 300 | + | |
| 301 | + /** What an AskUserQuestion call asked and what the user answered. */ | |
| 302 | + @Composable | |
| 303 | + fun AnswersRows(tool: SavedItem.Tool) { | |
| 304 | + val p = palette | |
| 305 | + val status = when (tool.permission) { | |
| 306 | + "pending" -> "Waiting for your answer" to p.amberInk | |
| 307 | + "denied" -> "Dismissed" to p.ink3 | |
| 308 | + "cancelled" -> "Cancelled" to p.ink3 | |
| 309 | + else -> null | |
| 310 | + } | |
| 311 | + Column(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { | |
| 312 | + questionsOf(tool.input).forEachIndexed { q, question -> | |
| 313 | + val answer = tool.answers?.getOrNull(q) | |
| 314 | + WorkRow(Lucide.MessageCircle, p.ink3) { | |
| 315 | + Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) { | |
| 316 | + Txt(question, Modifier.weight(1f, fill = false), color = p.ink2, size = 14.sp, maxLines = 1) | |
| 317 | + if (answer != null) Txt(answer, Modifier.weight(1f, fill = false), color = p.ink, size = 14.sp, maxLines = 1) | |
| 318 | + } | |
| 319 | + if (q == 0 && status != null) RowNote(status.first, status.second) | |
| 320 | + } | |
| 321 | + } | |
| 322 | + } | |
| 323 | + } | |
| 324 | + | |
| 325 | + /** A Pi extension's question and its answer, as it stays in the transcript. */ | |
| 326 | + @Composable | |
| 327 | + fun QuestionRecord(question: SavedItem.Question) { | |
| 328 | + val p = palette | |
| 329 | + Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { | |
| 330 | + WorkRow(Lucide.MessageCircle, p.ink3) { | |
| 331 | + Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 332 | + Txt(question.title, Modifier.weight(1f, fill = false), color = p.ink2, size = 14.sp, maxLines = 1) | |
| 333 | + if (question.answer != null) { | |
| 334 | + Txt(question.answer, Modifier.weight(1f, fill = false), color = p.ink, size = 14.sp, maxLines = 1) | |
| 335 | + } else { | |
| 336 | + RowNote("Waiting for your answer", p.amberInk) | |
| 337 | + } | |
| 338 | + } | |
| 339 | + } | |
| 340 | + } | |
| 341 | + } | |
| 342 | + | |
| 343 | + /** A background task's end, which wakes the agent: a row of its own. */ | |
| 344 | + @Composable | |
| 345 | + fun TaskEventRow(event: SavedItem.TaskEvent) { | |
| 346 | + val p = palette | |
| 347 | + val (color, outcome) = when (taskStatus(event.status)) { | |
| 348 | + "completed" -> p.ink3 to "Finished" | |
| 349 | + "running" -> p.ink3 to "Updated" | |
| 350 | + "failed" -> p.red to "Failed" | |
| 351 | + else -> p.ink3 to "Stopped" | |
| 352 | + } | |
| 353 | + Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { | |
| 354 | + WorkRow(Lucide.Zap, color) { | |
| 355 | + WorkLabel(event.summary) | |
| 356 | + RowNote(outcome, color) | |
| 357 | + } | |
| 358 | + } | |
| 359 | + } | |
| 360 | + | |
| 361 | + /** An informational notice, as a divider across the conversation. */ | |
| 362 | + @Composable | |
| 363 | + fun NoticeDivider(text: String) { | |
| 364 | + val p = palette | |
| 365 | + val compacted = text.startsWith("Conversation compacted") | |
| 366 | + Row( | |
| 367 | + Modifier.fillMaxWidth().padding(vertical = 8.dp), | |
| 368 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 369 | + verticalAlignment = Alignment.CenterVertically, | |
| 370 | + ) { | |
| 371 | + Spacer(Modifier.weight(1f).height(1.dp).background(p.rule)) | |
| 372 | + Row( | |
| 373 | + Modifier.weight(4f, fill = false).padding(horizontal = 8.dp), | |
| 374 | + horizontalArrangement = Arrangement.spacedBy(6.dp), | |
| 375 | + verticalAlignment = Alignment.CenterVertically, | |
| 376 | + ) { | |
| 377 | + if (compacted) LucideIcon(Lucide.Minimize2, size = 12.dp, tint = p.ink3) | |
| 378 | + Txt(if (compacted) "Context compacted" else text, color = p.ink3, size = 11.sp, weight = FontWeight.Medium) | |
| 379 | + } | |
| 380 | + Spacer(Modifier.weight(1f).height(1.dp).background(p.rule)) | |
| 381 | + } | |
| 382 | + } | |
| 383 | + | |
| 384 | + /** An error: a tinted box with its message. */ | |
| 385 | + @Composable | |
| 386 | + fun ErrorNotice(text: String) { | |
| 387 | + val p = palette | |
| 388 | + val shape = RoundedCornerShape(8.dp) | |
| 389 | + Row( | |
| 390 | + Modifier | |
| 391 | + .fillMaxWidth() | |
| 392 | + .background(p.red.copy(alpha = 0.05f), shape) | |
| 393 | + .innerShadow(shape, Shadow(radius = 0.dp, color = p.red.copy(alpha = 0.25f), spread = 1.dp)) | |
| 394 | + .padding(horizontal = 10.dp, vertical = 6.dp), | |
| 395 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 396 | + ) { | |
| 397 | + LucideIcon(Lucide.CircleAlert, Modifier.padding(top = 2.dp), size = 14.dp, tint = p.red) | |
| 398 | + SelectionContainer(Modifier.weight(1f)) { | |
| 399 | + Txt(text, color = p.ink, size = 12.sp, lineHeight = 18.sp) | |
| 400 | + } | |
| 401 | + } | |
| 402 | + } | |
| 403 | + | |
| 404 | + /** | |
| 405 | + * Claude's plan as a card: "Plan" and its title, Copy, and the plan itself | |
| 406 | + * (a long one folds behind "Expand plan"). | |
| 407 | + */ | |
| 408 | + @Composable | |
| 409 | + fun PlanCard(tool: SavedItem.Tool, unfolded: Boolean, onToggle: () -> Unit) { | |
| 410 | + val p = palette | |
| 411 | + val context = LocalContext.current | |
| 412 | + val plan = field(tool.input, "plan").orEmpty() | |
| 413 | + val long = plan.length > 900 || plan.lines().size > 20 | |
| 414 | + val shown = if (long && !unfolded) plan.lines().take(10).joinToString("\n") else plan | |
| 415 | + Column( | |
| 416 | + Modifier | |
| 417 | + .fillMaxWidth() | |
| 418 | + .padding(horizontal = 4.dp) | |
| 419 | + .raised(p, RoundedCornerShape(22.dp)) | |
| 420 | + .padding(16.dp), | |
| 421 | + ) { | |
| 422 | + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 423 | + Tag("Plan") | |
| 424 | + Txt(planTitle(tool), Modifier.weight(1f), color = p.ink, size = 14.sp, weight = FontWeight.Medium, maxLines = 1) | |
| 425 | + Cap(icon = Lucide.Copy, kind = CapKind.Ghost, size = CapSize.Mini) { copyText(context, plan) } | |
| 426 | + } | |
| 427 | + MarkdownText(shown, Modifier.padding(top = 16.dp)) | |
| 428 | + if (long) { | |
| 429 | + Box(Modifier.fillMaxWidth().padding(top = 16.dp), contentAlignment = Alignment.Center) { | |
| 430 | + Cap(label = if (unfolded) "Collapse plan" else "Expand plan", size = CapSize.Compact, onClick = onToggle) | |
| 431 | + } | |
| 432 | + } | |
| 433 | + } | |
| 434 | + } | |
| @@ -0,0 +1,429 @@ | ||
| 1 | + package cc.vectrum.code.ui.chat | |
| 2 | + | |
| 3 | + import cc.vectrum.code.net.SavedItem | |
| 4 | + import cc.vectrum.code.net.str | |
| 5 | + import cc.vectrum.code.ui.Lucide | |
| 6 | + import kotlinx.serialization.json.JsonArray | |
| 7 | + import kotlinx.serialization.json.JsonElement | |
| 8 | + import kotlinx.serialization.json.JsonNull | |
| 9 | + import kotlinx.serialization.json.JsonObject | |
| 10 | + import kotlinx.serialization.json.JsonPrimitive | |
| 11 | + import kotlinx.serialization.json.booleanOrNull | |
| 12 | + import kotlinx.serialization.json.jsonPrimitive | |
| 13 | + | |
| 14 | + // What the desktop's timeline (src/ui/chat/timeline.rs) and session | |
| 15 | + // (src/session.rs) work out about a transcript before drawing it: which turn | |
| 16 | + // an item belongs to, which tool calls run together, and how a call reads. | |
| 17 | + | |
| 18 | + /** How the message that hands a subagent's result to its parent starts. */ | |
| 19 | + const val RESULT_PREFIX = "[Vectrum Code] Delegated task" | |
| 20 | + | |
| 21 | + /** Tool output longer than this is cut short in its card. */ | |
| 22 | + const val MAX_TOOL_OUTPUT = 6_000 | |
| 23 | + | |
| 24 | + /** | |
| 25 | + * A finished turn, which the transcript folds into one "Worked for …" row | |
| 26 | + * above its final answer. | |
| 27 | + */ | |
| 28 | + data class FinishedTurn( | |
| 29 | + /** The first item after the user's message; the fold row sits here. */ | |
| 30 | + val first: Int, | |
| 31 | + /** The last assistant message, which stays visible. */ | |
| 32 | + val answer: Int?, | |
| 33 | + /** The turn's `TurnEnd` item. */ | |
| 34 | + val end: Int, | |
| 35 | + val durationMs: Long, | |
| 36 | + val cost: Double?, | |
| 37 | + /** When it ended. */ | |
| 38 | + val at: Long, | |
| 39 | + /** Whether anything besides the answer happened (tools, interim text). */ | |
| 40 | + val hasWork: Boolean, | |
| 41 | + ) | |
| 42 | + | |
| 43 | + /** Whether an item shows as a work-log row. */ | |
| 44 | + fun isWork(item: SavedItem): Boolean = | |
| 45 | + item is SavedItem.Tool && !isAsk(item) && item.name != "ExitPlanMode" && item.name != "TodoWrite" | |
| 46 | + | |
| 47 | + /** | |
| 48 | + * Items the timeline leaves out altogether (the task list lives above the | |
| 49 | + * composer), and which don't break a group of tool calls. | |
| 50 | + */ | |
| 51 | + fun isHidden(item: SavedItem): Boolean = item is SavedItem.Tool && item.name == "TodoWrite" | |
| 52 | + | |
| 53 | + /** An AskUserQuestion call, which shows as its questions and answers. */ | |
| 54 | + fun isAsk(tool: SavedItem.Tool): Boolean = tool.answers != null && questionsOf(tool.input).isNotEmpty() | |
| 55 | + | |
| 56 | + /** An AskUserQuestion call's questions. */ | |
| 57 | + fun questionsOf(input: JsonElement): List<String> = | |
| 58 | + ((input as? JsonObject)?.get("questions") as? JsonArray)?.mapNotNull { it.str("question") } ?: emptyList() | |
| 59 | + | |
| 60 | + /** | |
| 61 | + * The transcript as the timeline lays it out: each item's finished turn, and | |
| 62 | + * the run of tool calls each work row belongs to. Worked out once per change | |
| 63 | + * rather than per row. | |
| 64 | + */ | |
| 65 | + class TranscriptLayout(val items: List<SavedItem>) { | |
| 66 | + private val turns = arrayOfNulls<FinishedTurn>(items.size) | |
| 67 | + private val groups = arrayOfNulls<List<Int>>(items.size) | |
| 68 | + | |
| 69 | + /** The last user message, under which the live turn shows it's working. */ | |
| 70 | + val lastUser: Int = items.indexOfLast { it is SavedItem.User } | |
| 71 | + | |
| 72 | + init { | |
| 73 | + // A turn runs from after a user message (or the previous turn's end) | |
| 74 | + // to the next `TurnEnd`. | |
| 75 | + var first = 0 | |
| 76 | + for (ix in items.indices) { | |
| 77 | + val item = items[ix] | |
| 78 | + if (item is SavedItem.User) { | |
| 79 | + first = ix + 1 | |
| 80 | + continue | |
| 81 | + } | |
| 82 | + if (item is SavedItem.TurnEnd) { | |
| 83 | + val answer = (ix - 1 downTo first).firstOrNull { items[it] is SavedItem.Assistant } | |
| 84 | + val hasWork = (first until ix).any { member -> | |
| 85 | + val other = items[member] | |
| 86 | + member != answer && !(other is SavedItem.Notice && other.error) && other !is SavedItem.TaskEvent | |
| 87 | + } | |
| 88 | + val turn = FinishedTurn(first, answer, ix, item.durationMs, item.cost, item.at, hasWork) | |
| 89 | + for (member in first..ix) turns[member] = turn | |
| 90 | + first = ix + 1 | |
| 91 | + } | |
| 92 | + } | |
| 93 | + // Runs of consecutive tool calls, hidden ones included. | |
| 94 | + var start = 0 | |
| 95 | + while (start < items.size) { | |
| 96 | + if (!isWork(items[start]) && !isHidden(items[start])) { | |
| 97 | + start++ | |
| 98 | + continue | |
| 99 | + } | |
| 100 | + var end = start | |
| 101 | + while (end + 1 < items.size && (isWork(items[end + 1]) || isHidden(items[end + 1]))) end++ | |
| 102 | + val members = (start..end).filter { isWork(items[it]) } | |
| 103 | + for (member in members) groups[member] = members | |
| 104 | + start = end + 1 | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + /** The finished turn item `ix` belongs to, if its turn has ended. */ | |
| 109 | + fun turn(ix: Int): FinishedTurn? = turns.getOrNull(ix) | |
| 110 | + | |
| 111 | + /** The run of consecutive tool calls `ix` belongs to: its work rows' indices. */ | |
| 112 | + fun group(ix: Int): List<Int> = groups.getOrNull(ix) ?: listOf(ix) | |
| 113 | + } | |
| 114 | + | |
| 115 | + // ---- how a tool call reads ----------------------------------------------------- | |
| 116 | + | |
| 117 | + /** What a tool call does, as T3 Code sums up a group of them. */ | |
| 118 | + enum class Action(val icon: Lucide, val priority: Int) { | |
| 119 | + Read(Lucide.Eye, 1), | |
| 120 | + Edit(Lucide.SquarePen, 0), | |
| 121 | + Command(Lucide.Terminal, 0), | |
| 122 | + WebSearch(Lucide.Globe, 1), | |
| 123 | + CodeSearch(Lucide.Search, 1), | |
| 124 | + Other(Lucide.Wrench, 2); | |
| 125 | + | |
| 126 | + /** `Ran 2 commands`, `Read 1 file`. */ | |
| 127 | + fun summary(count: Int): String { | |
| 128 | + fun s(one: String, many: String) = if (count == 1) one else many | |
| 129 | + return when (this) { | |
| 130 | + Read -> "Read $count ${s("file", "files")}" | |
| 131 | + Edit -> "Changed $count ${s("file", "files")}" | |
| 132 | + Command -> "Ran $count ${s("command", "commands")}" | |
| 133 | + WebSearch -> "Searched the web $count ${s("time", "times")}" | |
| 134 | + CodeSearch -> "Searched code $count ${s("time", "times")}" | |
| 135 | + Other -> "Used $count ${s("tool", "tools")}" | |
| 136 | + } | |
| 137 | + } | |
| 138 | + } | |
| 139 | + | |
| 140 | + /** How a tool call reads in the timeline. */ | |
| 141 | + data class Presentation(val icon: Lucide, val label: String, val action: Action) | |
| 142 | + | |
| 143 | + /** Reads the first of several possible argument names: Claude and Pi name them differently. */ | |
| 144 | + fun field(input: JsonElement, vararg keys: String): String? = keys.firstNotNullOfOrNull { key -> | |
| 145 | + ((input as? JsonObject)?.get(key) as? JsonPrimitive)?.takeIf { it.isString }?.content | |
| 146 | + } | |
| 147 | + | |
| 148 | + /** A path relative to the thread's folder. */ | |
| 149 | + fun relativePath(path: String, cwd: String): String { | |
| 150 | + if (cwd.isEmpty()) return path | |
| 151 | + val base = cwd.trimEnd('/') | |
| 152 | + return when { | |
| 153 | + path == base -> path | |
| 154 | + path.startsWith("$base/") -> path.substring(base.length + 1) | |
| 155 | + else -> path | |
| 156 | + } | |
| 157 | + } | |
| 158 | + | |
| 159 | + private fun firstLine(text: String) = text.lineSequence().firstOrNull().orEmpty().take(200) | |
| 160 | + | |
| 161 | + /** The app's own tools, under whatever prefix the agent gives them. */ | |
| 162 | + private fun bare(name: String) = name.removePrefix("mcp__vectrum-code__").removePrefix("mcp__vectrum_code__") | |
| 163 | + | |
| 164 | + private val OWN_TOOLS = setOf("delegate_task", "task_status", "task_cancel", "orchestrator_capabilities") | |
| 165 | + private val DEVICE_TOOLS = setOf("device_list", "device_open", "device_screenshot", "device_close") | |
| 166 | + | |
| 167 | + /** A call to the app's `delegate_task`, whose task runs in a thread of its own. */ | |
| 168 | + fun isDelegation(name: String) = bare(name) == "delegate_task" | |
| 169 | + | |
| 170 | + /** A tool call's icon, label and kind, as the timeline shows them. */ | |
| 171 | + fun present(name: String, input: JsonElement, cwd: String): Presentation { | |
| 172 | + fun f(vararg keys: String) = field(input, *keys) | |
| 173 | + val bare = bare(name) | |
| 174 | + if (bare in OWN_TOOLS) { | |
| 175 | + val label = when (bare) { | |
| 176 | + "delegate_task" -> (f("title") ?: f("task"))?.let(::firstLine) ?: "Subagent" | |
| 177 | + "task_cancel" -> "Stopped a subagent" | |
| 178 | + "orchestrator_capabilities" -> "Looked up agents and models" | |
| 179 | + else -> "Checked on a subagent" | |
| 180 | + } | |
| 181 | + return Presentation(Lucide.Bot, label, Action.Other) | |
| 182 | + } | |
| 183 | + if (bare in DEVICE_TOOLS) { | |
| 184 | + val target = f("deviceId") ?: f("platform")?.let { | |
| 185 | + when (it) { | |
| 186 | + "ios" -> "an iOS Simulator" | |
| 187 | + "android" -> "an Android Emulator" | |
| 188 | + else -> it | |
| 189 | + } | |
| 190 | + } | |
| 191 | + val shutdown = ((input as? JsonObject)?.get("shutdown") as? JsonPrimitive)?.booleanOrNull == true | |
| 192 | + val label = when { | |
| 193 | + bare == "device_list" -> "Listed devices" | |
| 194 | + bare == "device_open" -> target?.let { "Opened $it" } ?: "Opened a device" | |
| 195 | + bare == "device_close" && shutdown -> target?.let { "Powered off $it" } ?: "Powered off devices" | |
| 196 | + bare == "device_close" -> target?.let { "Closed $it" } ?: "Closed devices" | |
| 197 | + else -> "Took a device screenshot" | |
| 198 | + } | |
| 199 | + return Presentation(Lucide.Smartphone, label, Action.Other) | |
| 200 | + } | |
| 201 | + val lower = name.lowercase() | |
| 202 | + val (action, label) = when (lower) { | |
| 203 | + "bash" -> Action.Command to (f("command")?.let(::firstLine) ?: "Ran command") | |
| 204 | + "bashoutput", "killshell" -> Action.Command to name | |
| 205 | + "read", "notebookread" -> Action.Read to | |
| 206 | + (f("file_path", "path", "notebook_path")?.let { "Read ${relativePath(it, cwd)}" } ?: "Read file") | |
| 207 | + "write", "edit", "multiedit", "notebookedit" -> Action.Edit to | |
| 208 | + (f("file_path", "path", "notebook_path")?.let { relativePath(it, cwd) } ?: "Changed a file") | |
| 209 | + "grep", "glob", "find" -> Action.CodeSearch to | |
| 210 | + (f("pattern", "query")?.let { "Searched for ${firstLine(it)}" } ?: "Searched code") | |
| 211 | + "ls" -> Action.CodeSearch to (f("path")?.let { "Listed ${relativePath(it, cwd)}" } ?: "Listed files") | |
| 212 | + "websearch" -> Action.WebSearch to (f("query")?.let { "Web search: $it" } ?: "Web search") | |
| 213 | + "webfetch" -> Action.WebSearch to (f("url") ?: "Fetched a page") | |
| 214 | + "task", "agent" -> Action.Other to (f("description") ?: "Subagent") | |
| 215 | + "subagent" -> { | |
| 216 | + val obj = input as? JsonObject | |
| 217 | + val several = ((obj?.get("tasks") ?: obj?.get("chain")) as? JsonArray)?.size | |
| 218 | + Action.Other to (several?.let { "$it subagents" } ?: f("description", "task")?.let(::firstLine) ?: "Subagent") | |
| 219 | + } | |
| 220 | + "swarm" -> { | |
| 221 | + val swarmAction = f("action").orEmpty() | |
| 222 | + val swarmLabel = f("label", "prompt")?.let(::firstLine) | |
| 223 | + Action.Other to when { | |
| 224 | + swarmAction == "spawn" && swarmLabel != null -> "Spawn agent: $swarmLabel" | |
| 225 | + swarmAction == "spawn" -> "Spawn agent" | |
| 226 | + swarmAction.isEmpty() -> "Swarm" | |
| 227 | + else -> "Swarm: ${swarmAction.replace('_', ' ')}" | |
| 228 | + } | |
| 229 | + } | |
| 230 | + "monitor" -> Action.Other to (f("description", "command")?.let(::firstLine) ?: "Monitor") | |
| 231 | + else -> if (name.startsWith("mcp__")) { | |
| 232 | + val parts = name.removePrefix("mcp__").split("__", limit = 2) | |
| 233 | + Action.Other to "${parts[0]}: ${parts.getOrElse(1) { "" }.replace('_', ' ')}" | |
| 234 | + } else { | |
| 235 | + val summary = toolSummary(name, input, cwd) | |
| 236 | + Action.Other to if (summary.isEmpty()) name else "$name $summary" | |
| 237 | + } | |
| 238 | + } | |
| 239 | + val icon = when { | |
| 240 | + lower in setOf("task", "agent", "subagent", "swarm") -> Lucide.Bot | |
| 241 | + lower == "monitor" -> Lucide.Eye | |
| 242 | + name.startsWith("mcp__") -> Lucide.Hammer | |
| 243 | + else -> action.icon | |
| 244 | + } | |
| 245 | + return Presentation(icon, label, action) | |
| 246 | + } | |
| 247 | + | |
| 248 | + /** A one-line description of what a tool call is doing. */ | |
| 249 | + fun toolSummary(name: String, input: JsonElement, cwd: String): String { | |
| 250 | + fun f(vararg keys: String) = field(input, *keys) | |
| 251 | + fun path(vararg keys: String) = f(*keys)?.let { relativePath(it, cwd) } | |
| 252 | + val summary = when (name.lowercase()) { | |
| 253 | + "bash" -> f("command") | |
| 254 | + "read", "write", "edit", "multiedit" -> path("file_path", "path") | |
| 255 | + "notebookedit" -> path("notebook_path") | |
| 256 | + "glob", "grep", "find" -> f("pattern", "query") | |
| 257 | + "ls" -> path("path") | |
| 258 | + "webfetch" -> f("url") | |
| 259 | + "websearch" -> f("query") | |
| 260 | + "task", "agent" -> f("description") | |
| 261 | + "todowrite" -> ((input as? JsonObject)?.get("todos") as? JsonArray)?.let { "${it.size} items" } | |
| 262 | + "exitplanmode" -> "Plan ready for review" | |
| 263 | + else -> null | |
| 264 | + } ?: when { | |
| 265 | + input is JsonNull -> "" | |
| 266 | + input is JsonObject && input.isEmpty() -> "" | |
| 267 | + else -> input.toString() | |
| 268 | + } | |
| 269 | + val lines = summary.lines() | |
| 270 | + val first = lines.firstOrNull().orEmpty() | |
| 271 | + var line = first.take(160) | |
| 272 | + if (first.length > 160 || lines.size > 1) line += "…" | |
| 273 | + return line | |
| 274 | + } | |
| 275 | + | |
| 276 | + /** The program a command runs, for "Running npm". */ | |
| 277 | + fun program(tool: SavedItem.Tool): String? { | |
| 278 | + val command = field(tool.input, "command") ?: return null | |
| 279 | + return command.split(Regex("\\s+")) | |
| 280 | + .firstOrNull { it.isNotEmpty() && '=' !in it && it !in setOf("sudo", "env", "time", "cd") } | |
| 281 | + ?.substringAfterLast('/') | |
| 282 | + } | |
| 283 | + | |
| 284 | + /** | |
| 285 | + * T3 Code's summary of a group of tool calls: up to two kinds, then the | |
| 286 | + * rest. "Ran 2 commands and read 3 files", "Changed 1 file, ran 4 | |
| 287 | + * commands, and performed 2 other actions". | |
| 288 | + */ | |
| 289 | + fun groupSummary(actions: List<Action>): String { | |
| 290 | + val counts = mutableListOf<Pair<Action, Int>>() | |
| 291 | + for (action in actions) { | |
| 292 | + val known = counts.indexOfFirst { it.first == action } | |
| 293 | + if (known >= 0) counts[known] = action to counts[known].second + 1 else counts.add(action to 1) | |
| 294 | + } | |
| 295 | + val sorted = counts.sortedBy { it.first.priority } | |
| 296 | + val parts = sorted.take(2).map { (action, count) -> action.summary(count) }.toMutableList() | |
| 297 | + val rest = sorted.drop(2).sumOf { it.second } | |
| 298 | + if (rest > 0) parts.add("Performed $rest other ${if (rest == 1) "action" else "actions"}") | |
| 299 | + val joined = parts.mapIndexed { ix, part -> if (ix == 0) part else part.replaceFirstChar { it.lowercase() } } | |
| 300 | + return when (joined.size) { | |
| 301 | + 0 -> "" | |
| 302 | + 1 -> joined[0] | |
| 303 | + 2 -> "${joined[0]} and ${joined[1]}" | |
| 304 | + else -> "${joined.dropLast(1).joinToString(", ")}, and ${joined.last()}" | |
| 305 | + } | |
| 306 | + } | |
| 307 | + | |
| 308 | + /** The kind of call that leads a group's icon: commands and edits first, then the commonest. */ | |
| 309 | + fun dominant(actions: List<Action>): Action = | |
| 310 | + actions.minWithOrNull(compareBy<Action> { it.priority }.thenByDescending { action -> actions.count { it == action } }) | |
| 311 | + ?: Action.Other | |
| 312 | + | |
| 313 | + /** Cuts long tool output short, saying so. */ | |
| 314 | + fun truncate(text: String, max: Int): String = | |
| 315 | + if (text.length <= max) text else text.take(max) + "\n… (truncated)" | |
| 316 | + | |
| 317 | + /** `12s`, `1m 5s`, `1h 2m`, as T3 Code's working timer reads. */ | |
| 318 | + fun formatElapsed(seconds: Long): String = when { | |
| 319 | + seconds < 60 -> "${seconds}s" | |
| 320 | + seconds < 3_600 -> "${seconds / 60}m ${seconds % 60}s" | |
| 321 | + else -> "${seconds / 3_600}h ${seconds % 3_600 / 60}m" | |
| 322 | + } | |
| 323 | + | |
| 324 | + /** A plan's title: its first heading, or its first line. */ | |
| 325 | + fun planTitle(tool: SavedItem.Tool): String { | |
| 326 | + val plan = field(tool.input, "plan").orEmpty() | |
| 327 | + val lines = plan.lines().map { it.trim() } | |
| 328 | + val line = lines.firstOrNull { it.startsWith("#") } ?: lines.firstOrNull { it.isNotEmpty() } ?: "Proposed plan" | |
| 329 | + return line.trimStart('#').trim() | |
| 330 | + } | |
| 331 | + | |
| 332 | + // ---- subagents ----------------------------------------------------------------- | |
| 333 | + | |
| 334 | + /** `running`, `completed`, `failed` or `stopped`, as the CLI says them. */ | |
| 335 | + fun taskStatus(status: String): String = when (status) { | |
| 336 | + "completed" -> "completed" | |
| 337 | + "failed" -> "failed" | |
| 338 | + "running", "pending" -> "running" | |
| 339 | + else -> "stopped" | |
| 340 | + } | |
| 341 | + | |
| 342 | + /** A subagent a tool call started, as the subagents panel lists it. */ | |
| 343 | + data class SubagentEntry( | |
| 344 | + val label: String, | |
| 345 | + val kind: String?, | |
| 346 | + val status: String, | |
| 347 | + val background: Boolean, | |
| 348 | + val toolUses: Int, | |
| 349 | + val tokens: Long?, | |
| 350 | + val elapsedMs: Long?, | |
| 351 | + /** Its latest tool call while it works: `[name, input]`. */ | |
| 352 | + val tool: Pair<String, JsonElement>?, | |
| 353 | + val summary: String?, | |
| 354 | + ) | |
| 355 | + | |
| 356 | + private fun elapsedBetween(started: Long, finished: Long?, running: Boolean, now: Long): Long? { | |
| 357 | + val end = finished ?: if (running) now else return null | |
| 358 | + return if (started > 0) (end - started).coerceAtLeast(0) else null | |
| 359 | + } | |
| 360 | + | |
| 361 | + private fun toolPair(array: JsonArray?): Pair<String, JsonElement>? { | |
| 362 | + val name = array?.getOrNull(0)?.let { runCatching { it.jsonPrimitive.content }.getOrNull() } ?: return null | |
| 363 | + return name to (array.getOrNull(1) ?: JsonNull) | |
| 364 | + } | |
| 365 | + | |
| 366 | + /** The subagents a tool call started: one for Claude's Task, several for Pi's or a swarm's. */ | |
| 367 | + fun subagentsOf(tool: SavedItem.Tool, now: Long = System.currentTimeMillis()): List<SubagentEntry> { | |
| 368 | + val progress = tool.subagent ?: return emptyList() | |
| 369 | + val callRunning = tool.state == "running" | |
| 370 | + val outlives = tool.name == "swarm" || isDelegation(tool.name) | |
| 371 | + if (progress.members.isNotEmpty()) { | |
| 372 | + return progress.members.map { member -> | |
| 373 | + var status = taskStatus(member.status) | |
| 374 | + // A Pi subagent runs inside its call; a swarm agent or a delegated task outlives it. | |
| 375 | + if (status == "running" && !callRunning && !outlives) status = "stopped" | |
| 376 | + val running = status == "running" | |
| 377 | + SubagentEntry( | |
| 378 | + label = member.label, | |
| 379 | + kind = member.kind, | |
| 380 | + status = status, | |
| 381 | + background = false, | |
| 382 | + toolUses = member.toolUses, | |
| 383 | + tokens = member.tokens, | |
| 384 | + elapsedMs = elapsedBetween(member.started, member.finished, running, now), | |
| 385 | + tool = toolPair(member.tool).takeIf { running }, | |
| 386 | + summary = member.summary, | |
| 387 | + ) | |
| 388 | + } | |
| 389 | + } | |
| 390 | + if (tool.name == "swarm" && field(tool.input, "action") != "spawn") return emptyList() | |
| 391 | + val status = when { | |
| 392 | + tool.state == "error" -> "failed" | |
| 393 | + tool.background != null -> taskStatus(tool.background) | |
| 394 | + tool.state == "running" -> "running" | |
| 395 | + else -> "completed" | |
| 396 | + } | |
| 397 | + val running = status == "running" | |
| 398 | + val obj = tool.input as? JsonObject | |
| 399 | + val label = field(tool.input, "description", "label", "title", "task")?.takeIf { it.isNotBlank() } | |
| 400 | + ?: ((obj?.get("tasks") ?: obj?.get("chain")) as? JsonArray)?.let { "${it.size} subagents" } | |
| 401 | + ?: "Subagent" | |
| 402 | + return listOf( | |
| 403 | + SubagentEntry( | |
| 404 | + label = label, | |
| 405 | + kind = field(tool.input, "subagent_type", "agent"), | |
| 406 | + status = status, | |
| 407 | + background = tool.background != null, | |
| 408 | + toolUses = tool.subagentToolCount, | |
| 409 | + tokens = progress.tokens, | |
| 410 | + elapsedMs = elapsedBetween(progress.started, progress.finished, running, now), | |
| 411 | + tool = toolPair(progress.tool).takeIf { running }, | |
| 412 | + summary = progress.summary, | |
| 413 | + ), | |
| 414 | + ) | |
| 415 | + } | |
| 416 | + | |
| 417 | + /** How many of a transcript's subagents are working, for the right panel button's light. */ | |
| 418 | + fun workingSubagents(items: List<SavedItem>): Int = | |
| 419 | + items.sumOf { item -> if (item is SavedItem.Tool) subagentsOf(item).count { it.status == "running" } else 0 } | |
| 420 | + | |
| 421 | + /** `812 tokens`, `23.4k tokens`, `1.2M tokens`. */ | |
| 422 | + fun formatTokens(tokens: Long): String { | |
| 423 | + val (value, unit) = when { | |
| 424 | + tokens < 1_000 -> return "$tokens tokens" | |
| 425 | + tokens < 1_000_000 -> tokens / 1_000.0 to "k" | |
| 426 | + else -> tokens / 1_000_000.0 to "M" | |
| 427 | + } | |
| 428 | + return String.format(java.util.Locale.ROOT, "%.1f", value).removeSuffix(".0") + "$unit tokens" | |
| 429 | + } | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.