Vectrum
Sign In

Merge branch 'worktree-agent-a1cf765fd8d297bda' into android-companion

# Conflicts:
#	android/scripts/icons.txt
JjohannSocommitted 2 parents 45bf959 35018b8commited9c24f

Showing 18 changed files with 3587 additions and 12 deletions.

Merge commit — showing changes against the first parent.

+3−0android/app/src/main/java/cc/vectrum/code/ui/Icons.ktView File
@@ -106,10 +106,12 @@ enum class Lucide(val res: Int) {
106106 MailOpen(R.drawable.lucide_mail_open),
107107 MapPin(R.drawable.lucide_map_pin),
108108 MemoryStick(R.drawable.lucide_memory_stick),
109+ MessageCircle(R.drawable.lucide_message_circle),
109110 MessageCircleQuestionMark(R.drawable.lucide_message_circle_question_mark),
110111 MessageSquare(R.drawable.lucide_message_square),
111112 MessageSquarePlus(R.drawable.lucide_message_square_plus),
112113 Mic(R.drawable.lucide_mic),
114+ Minimize2(R.drawable.lucide_minimize_2),
113115 Minus(R.drawable.lucide_minus),
114116 Monitor(R.drawable.lucide_monitor),
115117 MonitorSmartphone(R.drawable.lucide_monitor_smartphone),
@@ -150,6 +152,7 @@ enum class Lucide(val res: Int) {
150152 ShieldCheck(R.drawable.lucide_shield_check),
151153 SlidersHorizontal(R.drawable.lucide_sliders_horizontal),
152154 Smartphone(R.drawable.lucide_smartphone),
155+ Space(R.drawable.lucide_space),
153156 Sparkles(R.drawable.lucide_sparkles),
154157 Square(R.drawable.lucide_square),
155158 SquareDashed(R.drawable.lucide_square_dashed),
+177−0android/app/src/main/java/cc/vectrum/code/ui/chat/Changes.ktaddedView File
@@ -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+ }
+83−0android/app/src/main/java/cc/vectrum/code/ui/chat/Clock.ktaddedView File
@@ -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+ }
+164−3android/app/src/main/java/cc/vectrum/code/ui/chat/Header.ktView File
@@ -1,19 +1,180 @@
11 package cc.vectrum.code.ui.chat
22
3+ import android.content.Intent
4+ import androidx.compose.foundation.clickable
5+ import androidx.compose.foundation.layout.Arrangement
36 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
411 import androidx.compose.foundation.layout.padding
12+ import androidx.compose.foundation.layout.widthIn
13+ import androidx.compose.foundation.shape.RoundedCornerShape
514 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
621 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
726 import androidx.compose.ui.unit.dp
27+ import androidx.compose.ui.unit.sp
828 import cc.vectrum.code.data.AppState
929 import cc.vectrum.code.data.OpenThread
1030 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
1243 import cc.vectrum.code.ui.Nav
1344 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
1448
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+ */
1655 @Composable
1756 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+ }
19180 }
+422−0android/app/src/main/java/cc/vectrum/code/ui/chat/Markdown.ktaddedView File
@@ -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+ }
+566−0android/app/src/main/java/cc/vectrum/code/ui/chat/MarkdownParser.ktaddedView File
@@ -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+ }
+150−0android/app/src/main/java/cc/vectrum/code/ui/chat/ThreadMenu.ktaddedView File
@@ -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+ }
+465−4android/app/src/main/java/cc/vectrum/code/ui/chat/Timeline.ktView File
@@ -1,19 +1,480 @@
11 package cc.vectrum.code.ui.chat
22
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
39 import androidx.compose.foundation.layout.Box
10+ import androidx.compose.foundation.layout.BoxWithConstraints
11+ import androidx.compose.foundation.layout.Column
12+ import androidx.compose.foundation.layout.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
419 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
524 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
637 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
746 import androidx.compose.ui.unit.dp
47+ import androidx.compose.ui.unit.sp
848 import cc.vectrum.code.data.AppState
949 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
1262 import cc.vectrum.code.ui.Nav
1363 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
1470
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+ */
16129 @Composable
17130 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+ }
19480 }
+434−0android/app/src/main/java/cc/vectrum/code/ui/chat/ToolCard.ktaddedView File
@@ -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+ }
+429−0android/app/src/main/java/cc/vectrum/code/ui/chat/Transcript.ktaddedView File
@@ -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+ }
+93−0android/app/src/main/java/cc/vectrum/code/ui/diff/DiffParts.ktaddedView File

Large diffs are not rendered by default.

+354−5android/app/src/main/java/cc/vectrum/code/ui/diff/DiffScreen.ktView File

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

+106−0android/app/src/test/java/cc/vectrum/code/ui/chat/MarkdownTest.ktaddedView File

Large diffs are not rendered by default.

+119−0android/app/src/test/java/cc/vectrum/code/ui/chat/TranscriptTest.ktaddedView File

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.