Vectrum
Sign In

Android: composer, pickers, dock and the new thread start screen

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

Showing 17 changed files with 3304 additions and 13 deletions.

+5−0android/app/src/main/java/cc/vectrum/code/ui/Icons.ktView File
@@ -52,6 +52,7 @@ enum class Lucide(val res: Int) {
5252 Contrast(R.drawable.lucide_contrast),
5353 Copy(R.drawable.lucide_copy),
5454 CornerDownRight(R.drawable.lucide_corner_down_right),
55+ CornerUpRight(R.drawable.lucide_corner_up_right),
5556 Cpu(R.drawable.lucide_cpu),
5657 DollarSign(R.drawable.lucide_dollar_sign),
5758 Ellipsis(R.drawable.lucide_ellipsis),
@@ -90,6 +91,7 @@ enum class Lucide(val res: Int) {
9091 List(R.drawable.lucide_list),
9192 ListChecks(R.drawable.lucide_list_checks),
9293 ListOrdered(R.drawable.lucide_list_ordered),
94+ ListPlus(R.drawable.lucide_list_plus),
9395 ListTodo(R.drawable.lucide_list_todo),
9496 LoaderCircle(R.drawable.lucide_loader_circle),
9597 Lock(R.drawable.lucide_lock),
@@ -114,7 +116,9 @@ enum class Lucide(val res: Int) {
114116 PanelRightClose(R.drawable.lucide_panel_right_close),
115117 PanelRightOpen(R.drawable.lucide_panel_right_open),
116118 Paperclip(R.drawable.lucide_paperclip),
119+ PenLine(R.drawable.lucide_pen_line),
117120 Pencil(R.drawable.lucide_pencil),
121+ PencilRuler(R.drawable.lucide_pencil_ruler),
118122 Pi(R.drawable.lucide_pi),
119123 Pin(R.drawable.lucide_pin),
120124 PinOff(R.drawable.lucide_pin_off),
@@ -143,6 +147,7 @@ enum class Lucide(val res: Int) {
143147 SquarePen(R.drawable.lucide_square_pen),
144148 SquareStop(R.drawable.lucide_square_stop),
145149 SquareTerminal(R.drawable.lucide_square_terminal),
150+ StarFill(R.drawable.lucide_star_fill),
146151 Sun(R.drawable.lucide_sun),
147152 SunMoon(R.drawable.lucide_sun_moon),
148153 Terminal(R.drawable.lucide_terminal),
+150−0android/app/src/main/java/cc/vectrum/code/ui/composer/Attachments.ktaddedView File
@@ -0,0 +1,150 @@
1+ package cc.vectrum.code.ui.composer
2+
3+ import android.content.Context
4+ import android.graphics.BitmapFactory
5+ import android.net.Uri
6+ import android.provider.OpenableColumns
7+ import android.util.Base64
8+ import android.webkit.MimeTypeMap
9+ import androidx.activity.compose.rememberLauncherForActivityResult
10+ import androidx.activity.result.contract.ActivityResultContracts
11+ import androidx.compose.foundation.Image
12+ import androidx.compose.foundation.horizontalScroll
13+ import androidx.compose.foundation.layout.Arrangement
14+ import androidx.compose.foundation.layout.Box
15+ import androidx.compose.foundation.layout.Column
16+ import androidx.compose.foundation.layout.Row
17+ import androidx.compose.foundation.layout.fillMaxSize
18+ import androidx.compose.foundation.layout.fillMaxWidth
19+ import androidx.compose.foundation.layout.padding
20+ import androidx.compose.foundation.layout.size
21+ import androidx.compose.foundation.rememberScrollState
22+ import androidx.compose.foundation.shape.RoundedCornerShape
23+ import androidx.compose.runtime.Composable
24+ import androidx.compose.runtime.rememberCoroutineScope
25+ import androidx.compose.ui.Alignment
26+ import androidx.compose.ui.Modifier
27+ import androidx.compose.ui.draw.clip
28+ import androidx.compose.ui.graphics.asImageBitmap
29+ import androidx.compose.ui.layout.ContentScale
30+ import androidx.compose.ui.platform.LocalContext
31+ import androidx.compose.ui.unit.dp
32+ import androidx.compose.ui.unit.sp
33+ import cc.vectrum.code.data.AppState
34+ import cc.vectrum.code.theme.Cap
35+ import cc.vectrum.code.theme.CapKind
36+ import cc.vectrum.code.theme.CapSize
37+ import cc.vectrum.code.theme.Txt
38+ import cc.vectrum.code.theme.palette
39+ import cc.vectrum.code.theme.well
40+ import cc.vectrum.code.ui.Lucide
41+ import cc.vectrum.code.ui.LucideIcon
42+ import kotlinx.coroutines.Dispatchers
43+ import kotlinx.coroutines.launch
44+ import kotlinx.coroutines.withContext
45+
46+ /** The largest file a message carries; the connection takes messages up to 16 MB, base64 and all. */
47+ private const val MaxUpload = 10L * 1024 * 1024
48+
49+ /** Opens the system picker for photos and files, and attaches what's picked. */
50+ @Composable
51+ fun rememberAttach(app: AppState, state: ComposerState): () -> Unit {
52+ val context = LocalContext.current
53+ val scope = rememberCoroutineScope()
54+ val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
55+ scope.launch {
56+ for (uri in uris) {
57+ val result = withContext(Dispatchers.IO) { runCatching { readAttachment(context, uri) } }
58+ result.fold(
59+ onSuccess = { state.attachments.add(it) },
60+ onFailure = { app.showNotice(it.message ?: "Couldn't read that file.", error = true) },
61+ )
62+ }
63+ }
64+ }
65+ return { launcher.launch("*/*") }
66+ }
67+
68+ private fun readAttachment(context: Context, uri: Uri): Attachment {
69+ val resolver = context.contentResolver
70+ var name: String? = null
71+ var size = -1L
72+ resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { cursor ->
73+ if (cursor.moveToFirst()) {
74+ name = cursor.getString(0)
75+ if (!cursor.isNull(1)) size = cursor.getLong(1)
76+ }
77+ }
78+ val mime = resolver.getType(uri)
79+ var fileName = name ?: uri.lastPathSegment?.substringAfterLast('/') ?: "attachment"
80+ // The host tells images by their extension.
81+ if (!fileName.contains('.')) {
82+ MimeTypeMap.getSingleton().getExtensionFromMimeType(mime)?.let { fileName = "$fileName.$it" }
83+ }
84+ if (size > MaxUpload) error("$fileName is too large to attach (over 10 MB).")
85+ val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } ?: error("Couldn't read $fileName.")
86+ if (bytes.size > MaxUpload) error("$fileName is too large to attach (over 10 MB).")
87+ val image = if (mime?.startsWith("image/") == true) thumbnail(bytes) else null
88+ return Attachment(fileName, Base64.encodeToString(bytes, Base64.NO_WRAP), bytes.size.toLong(), image)
89+ }
90+
91+ /** A small copy of an image, for its 64dp tile. */
92+ private fun thumbnail(bytes: ByteArray) = runCatching {
93+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
94+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
95+ var sample = 1
96+ while (bounds.outWidth / (sample * 2) >= 192 && bounds.outHeight / (sample * 2) >= 192) sample *= 2
97+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample })?.asImageBitmap()
98+ }.getOrNull()
99+
100+ /**
101+ * What's attached to the message, as T3 Code shows it above the prompt:
102+ * images as thumbnails, other files as a list with their size.
103+ */
104+ @Composable
105+ fun AttachmentList(state: ComposerState) {
106+ val p = palette
107+ val images = state.attachments.filter { it.image != null }
108+ val files = state.attachments.filter { it.image == null }
109+ Column {
110+ if (images.isNotEmpty()) {
111+ Row(
112+ Modifier.padding(bottom = 12.dp).horizontalScroll(rememberScrollState()),
113+ horizontalArrangement = Arrangement.spacedBy(8.dp),
114+ ) {
115+ for (attachment in images) {
116+ val shape = RoundedCornerShape(8.dp)
117+ Box(Modifier.size(64.dp).well(p, shape).clip(shape)) {
118+ Image(
119+ attachment.image!!,
120+ contentDescription = attachment.name,
121+ modifier = Modifier.fillMaxSize(),
122+ contentScale = ContentScale.Crop,
123+ )
124+ Box(Modifier.align(Alignment.TopEnd).padding(4.dp)) {
125+ Cap(Modifier.size(20.dp), icon = Lucide.X, size = CapSize.Mini) { state.attachments.remove(attachment) }
126+ }
127+ }
128+ }
129+ }
130+ }
131+ if (files.isNotEmpty()) {
132+ Column(Modifier.padding(bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
133+ for (attachment in files) {
134+ Row(
135+ Modifier.fillMaxWidth().padding(vertical = 2.dp),
136+ horizontalArrangement = Arrangement.spacedBy(8.dp),
137+ verticalAlignment = Alignment.CenterVertically,
138+ ) {
139+ LucideIcon(Lucide.File, size = 16.dp, tint = p.ink3)
140+ Txt(attachment.name, Modifier.weight(1f), size = 14.sp, maxLines = 1)
141+ Txt(formatSize(attachment.size), color = p.ink3, size = 12.sp, maxLines = 1)
142+ Cap(Modifier.size(28.dp), icon = Lucide.X, kind = CapKind.Ghost, size = CapSize.Mini) {
143+ state.attachments.remove(attachment)
144+ }
145+ }
146+ }
147+ }
148+ }
149+ }
150+ }
+288−0android/app/src/main/java/cc/vectrum/code/ui/composer/Bits.ktaddedView File
@@ -0,0 +1,288 @@
1+ package cc.vectrum.code.ui.composer
2+
3+ import androidx.compose.foundation.background
4+ import androidx.compose.foundation.border
5+ import androidx.compose.foundation.clickable
6+ import androidx.compose.foundation.interaction.MutableInteractionSource
7+ import androidx.compose.foundation.interaction.collectIsPressedAsState
8+ import androidx.compose.foundation.layout.Arrangement
9+ import androidx.compose.foundation.layout.Box
10+ import androidx.compose.foundation.layout.Column
11+ import androidx.compose.foundation.layout.ColumnScope
12+ import androidx.compose.foundation.layout.Row
13+ import androidx.compose.foundation.layout.RowScope
14+ import androidx.compose.foundation.layout.Spacer
15+ import androidx.compose.foundation.layout.defaultMinSize
16+ import androidx.compose.foundation.layout.fillMaxWidth
17+ import androidx.compose.foundation.layout.height
18+ import androidx.compose.foundation.layout.heightIn
19+ import androidx.compose.foundation.layout.padding
20+ import androidx.compose.foundation.layout.size
21+ import androidx.compose.foundation.layout.width
22+ import androidx.compose.foundation.layout.widthIn
23+ import androidx.compose.foundation.shape.CircleShape
24+ import androidx.compose.foundation.shape.RoundedCornerShape
25+ import androidx.compose.runtime.Composable
26+ import androidx.compose.runtime.getValue
27+ import androidx.compose.runtime.remember
28+ import androidx.compose.ui.Alignment
29+ import androidx.compose.ui.Modifier
30+ import androidx.compose.ui.draw.alpha
31+ import androidx.compose.ui.graphics.Color
32+ import androidx.compose.ui.graphics.RectangleShape
33+ import androidx.compose.ui.layout.layout
34+ import androidx.compose.ui.semantics.Role
35+ import androidx.compose.ui.text.font.FontWeight
36+ import androidx.compose.ui.unit.Dp
37+ import androidx.compose.ui.unit.dp
38+ import androidx.compose.ui.unit.sp
39+ import cc.vectrum.code.theme.Cap
40+ import cc.vectrum.code.theme.CapKind
41+ import cc.vectrum.code.theme.CapSize
42+ import cc.vectrum.code.theme.Duo
43+ import cc.vectrum.code.theme.ShadowLayer
44+ import cc.vectrum.code.theme.Txt
45+ import cc.vectrum.code.theme.metal
46+ import cc.vectrum.code.theme.palette
47+ import cc.vectrum.code.ui.Lucide
48+ import cc.vectrum.code.ui.LucideIcon
49+
50+ // The composer's surroundings, laid out like T3 Code's (src/ui/composer.rs):
51+ // banners docked on top of it that tuck under its edge, the context strip
52+ // hanging below it, and the small controls in its footer and in that strip.
53+
54+ /** How far docked banners and the strip sit in from the composer's sides (22px on the desktop). */
55+ val Inset = 16.dp
56+
57+ /** How far a docked banner tucks under whatever sits below it. */
58+ val Overlap = 17.dp
59+
60+ /** The icon column of a banner row. */
61+ val IconColumn = 24.dp
62+
63+ /** How far the composer sits in from the sheet's sides on a phone (24px on the desktop). */
64+ val ComposerPad = 12.dp
65+
66+ enum class Tone { Neutral, Warning }
67+
68+ enum class Density { Default, Comfortable, Spacious }
69+
70+ /** `a` moved `t` of the way towards `b`, in RGB as the desktop mixes. */
71+ fun mix(a: Color, b: Color, t: Float) = Color(
72+ red = a.red + (b.red - a.red) * t,
73+ green = a.green + (b.green - a.green) * t,
74+ blue = a.blue + (b.blue - a.blue) * t,
75+ alpha = a.alpha + (b.alpha - a.alpha) * t,
76+ )
77+
78+ /** Lays the content out `by` shorter than it is, so what follows covers its bottom. */
79+ fun Modifier.tuck(by: Dp) = layout { measurable, constraints ->
80+ val placeable = measurable.measure(constraints)
81+ val overlap = by.roundToPx()
82+ layout(placeable.width, (placeable.height - overlap).coerceAtLeast(0)) { placeable.place(0, 0) }
83+ }
84+
85+ /**
86+ * A surface docked on top of the composer: rounded on top, its bottom
87+ * tucked under the composer (or under the next docked surface). `joined`
88+ * drops the rounded top where it sits under another, so the stack reads as
89+ * one card.
90+ */
91+ @Composable
92+ fun Banner(
93+ modifier: Modifier = Modifier,
94+ tone: Tone = Tone.Neutral,
95+ density: Density = Density.Default,
96+ joined: Boolean = false,
97+ content: @Composable ColumnScope.() -> Unit,
98+ ) {
99+ val p = palette
100+ val (padX, padY) = when (density) {
101+ Density.Default -> 4.dp to 4.dp
102+ Density.Comfortable -> 4.dp to 5.dp
103+ Density.Spacious -> 12.dp to 12.dp
104+ }
105+ val (top, bottom, ring) = when (tone) {
106+ Tone.Neutral -> Triple(p.sHi, p.s, null)
107+ Tone.Warning -> Triple(mix(p.sHi, p.amber, 0.08f), mix(p.s, p.amber, 0.08f), p.amber.copy(alpha = 0.28f))
108+ }
109+ val layers = if (ring != null) p.raiseLite + ShadowLayer(0f, 0f, 0f, 1f, true, ring) else p.raiseLite
110+ val shape = if (joined) RectangleShape else RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
111+ Column(
112+ modifier
113+ .fillMaxWidth()
114+ .tuck(Overlap)
115+ .metal(shape, Duo(top, bottom).brush, layers)
116+ .padding(start = padX, end = padX, top = padY, bottom = padY + Overlap),
117+ content = content,
118+ )
119+ }
120+
121+ /** One row of a docked banner: an icon column, the content, and actions on the right. */
122+ @Composable
123+ fun BannerRow(modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) {
124+ Row(
125+ modifier.fillMaxWidth().heightIn(min = IconColumn),
126+ horizontalArrangement = Arrangement.spacedBy(4.dp),
127+ verticalAlignment = Alignment.CenterVertically,
128+ content = content,
129+ )
130+ }
131+
132+ /** The icon column of a banner row. */
133+ @Composable
134+ fun IconCell(icon: Lucide? = null, tint: Color = palette.ink3, size: Dp = 12.dp, content: (@Composable () -> Unit)? = null) {
135+ Box(Modifier.size(IconColumn), contentAlignment = Alignment.Center) {
136+ if (icon != null) LucideIcon(icon, size = size, tint = tint)
137+ content?.invoke()
138+ }
139+ }
140+
141+ /** The chevron that says a banner folds: down while open, up while folded. */
142+ @Composable
143+ fun ToggleIcon(expanded: Boolean) {
144+ Box(Modifier.size(24.dp), contentAlignment = Alignment.Center) {
145+ LucideIcon(if (expanded) Lucide.ChevronDown else Lucide.ChevronUp, size = 14.dp, tint = palette.ink3)
146+ }
147+ }
148+
149+ /** A count in a banner's actions: `2/5`, `3`. */
150+ @Composable
151+ fun Count(text: String, color: Color = palette.ink3) {
152+ Box(Modifier.defaultMinSize(minWidth = IconColumn), contentAlignment = Alignment.Center) {
153+ Txt(text, color = color, size = 12.sp, weight = FontWeight.Medium, maxLines = 1)
154+ }
155+ }
156+
157+ /** A small icon button in a banner's actions, like its dismiss X. */
158+ @Composable
159+ fun IconButton(icon: Lucide, enabled: Boolean = true, onClick: () -> Unit) {
160+ Cap(Modifier.size(28.dp), icon = icon, kind = CapKind.Ghost, size = CapSize.Mini, enabled = enabled, onClick = onClick)
161+ }
162+
163+ /** A small labelled ghost button in a banner's actions: Stop, Steer, Cancel. */
164+ @Composable
165+ fun MiniButton(label: String, icon: Lucide? = null, enabled: Boolean = true, onClick: () -> Unit) {
166+ Cap(Modifier.height(28.dp), label = label, icon = icon, kind = CapKind.Ghost, size = CapSize.Mini, enabled = enabled, onClick = onClick)
167+ }
168+
169+ /**
170+ * A footer control of the composer: a ghost button with an icon, a label
171+ * and a caret, like T3 Code's model, reasoning and access triggers.
172+ */
173+ @Composable
174+ fun Control(
175+ modifier: Modifier = Modifier,
176+ icon: Lucide? = null,
177+ iconTint: Color? = null,
178+ label: String? = null,
179+ caret: Boolean = true,
180+ selected: Boolean = false,
181+ height: Dp = 32.dp,
182+ padding: Dp = 10.dp,
183+ fontSize: Float = 13f,
184+ glyph: Dp = 14.dp,
185+ labelWidth: Dp = Dp.Unspecified,
186+ onClick: () -> Unit,
187+ ) {
188+ val p = palette
189+ val interaction = remember { MutableInteractionSource() }
190+ val pressed by interaction.collectIsPressedAsState()
191+ val ink = if (selected || pressed) p.ink else p.ink2
192+ Row(
193+ modifier
194+ .height(height)
195+ .then(
196+ when {
197+ selected -> Modifier.metal(CircleShape, p.pressed.brush, p.pressedSh)
198+ pressed -> Modifier.background(p.rowHover, CircleShape)
199+ else -> Modifier
200+ },
201+ )
202+ .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onClick)
203+ .padding(horizontal = padding),
204+ horizontalArrangement = Arrangement.spacedBy(6.dp),
205+ verticalAlignment = Alignment.CenterVertically,
206+ ) {
207+ if (icon != null) LucideIcon(icon, size = glyph, tint = iconTint ?: ink)
208+ if (label != null) {
209+ Txt(
210+ label,
211+ if (labelWidth != Dp.Unspecified) Modifier.widthIn(max = labelWidth) else Modifier,
212+ color = ink,
213+ size = fontSize.sp,
214+ weight = FontWeight.Medium,
215+ maxLines = 1,
216+ )
217+ }
218+ if (caret) LucideIcon(Lucide.ChevronDown, size = 12.dp, tint = p.ink3)
219+ }
220+ }
221+
222+ /** The hairline between footer controls. */
223+ @Composable
224+ fun ControlSeparator() {
225+ Spacer(Modifier.padding(horizontal = 2.dp).width(1.dp).height(16.dp).background(palette.rule))
226+ }
227+
228+ /**
229+ * The strip hanging below the composer, tucked under its bottom edge. Like
230+ * T3 Code's tray it is a solid shelf with a hairline edge, filled a shade
231+ * above the pane so it reads in every theme.
232+ */
233+ @Composable
234+ fun Strip(modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) {
235+ val p = palette
236+ val shape = RoundedCornerShape(bottomStart = 18.dp, bottomEnd = 18.dp)
237+ Row(
238+ modifier
239+ .padding(horizontal = Inset)
240+ .fillMaxWidth()
241+ .background(mix(p.s, p.sHi, 0.75f), shape)
242+ .border(1.dp, p.ink.copy(alpha = if (p.dark) 0.14f else 0.16f), shape)
243+ .padding(start = 4.dp, end = 8.dp, top = 20.dp, bottom = 4.dp),
244+ horizontalArrangement = Arrangement.spacedBy(4.dp),
245+ verticalAlignment = Alignment.CenterVertically,
246+ content = content,
247+ )
248+ }
249+
250+ /** A control in the strip: a small ghost button, icon, label and caret. */
251+ @Composable
252+ fun StripControl(icon: Lucide, label: String, modifier: Modifier = Modifier, iconTint: Color? = null, onClick: () -> Unit) {
253+ Control(modifier, icon = icon, iconTint = iconTint, label = label, height = 30.dp, padding = 7.dp, fontSize = 12f, glyph = 12.dp, labelWidth = 160.dp, onClick = onClick)
254+ }
255+
256+ /** A strip control that can no longer be changed: just its icon and label. */
257+ @Composable
258+ fun StripLabel(icon: Lucide, label: String, modifier: Modifier = Modifier) {
259+ val p = palette
260+ Row(
261+ modifier.height(30.dp).padding(horizontal = 7.dp),
262+ horizontalArrangement = Arrangement.spacedBy(4.dp),
263+ verticalAlignment = Alignment.CenterVertically,
264+ ) {
265+ LucideIcon(icon, size = 12.dp, tint = p.ink3)
266+ Txt(label, Modifier.widthIn(max = 200.dp), color = p.ink3, size = 12.sp, maxLines = 1)
267+ }
268+ }
269+
270+ /** The vertical hairline between strip controls. */
271+ @Composable
272+ fun StripSeparator() {
273+ Spacer(Modifier.padding(horizontal = 2.dp).width(1.dp).height(14.dp).background(palette.rule))
274+ }
275+
276+ /** A row tinted while pressed, for the rows of the dock and the pickers. */
277+ @Composable
278+ fun Modifier.pressable(enabled: Boolean = true, shape: RoundedCornerShape = RoundedCornerShape(8.dp), onClick: () -> Unit): Modifier {
279+ val p = palette
280+ val interaction = remember { MutableInteractionSource() }
281+ val pressed by interaction.collectIsPressedAsState()
282+ return this
283+ .then(if (pressed) Modifier.background(p.menuRowHover, shape) else Modifier)
284+ .clickable(enabled = enabled, interactionSource = interaction, indication = null, onClick = onClick)
285+ }
286+
287+ /** Dims what can't be picked, as the desktop's menus do. */
288+ fun Modifier.dimmed(dim: Boolean, alpha: Float = 0.64f) = if (dim) alpha(alpha) else this
+415−3android/app/src/main/java/cc/vectrum/code/ui/composer/Composer.ktView File
@@ -1,19 +1,431 @@
1+ @file:OptIn(ExperimentalFoundationApi::class)
2+
13 package cc.vectrum.code.ui.composer
24
5+ import androidx.compose.foundation.ExperimentalFoundationApi
6+ import androidx.compose.foundation.combinedClickable
7+ import androidx.compose.foundation.interaction.MutableInteractionSource
8+ import androidx.compose.foundation.interaction.collectIsPressedAsState
9+ import androidx.compose.foundation.layout.Arrangement
310 import androidx.compose.foundation.layout.Box
11+ import androidx.compose.foundation.layout.BoxWithConstraints
12+ import androidx.compose.foundation.layout.Column
13+ import androidx.compose.foundation.layout.Row
14+ import androidx.compose.foundation.layout.fillMaxWidth
415 import androidx.compose.foundation.layout.padding
16+ import androidx.compose.foundation.layout.size
17+ import androidx.compose.foundation.layout.widthIn
18+ import androidx.compose.foundation.layout.wrapContentWidth
19+ import androidx.compose.foundation.shape.CircleShape
20+ import androidx.compose.foundation.shape.RoundedCornerShape
21+ import androidx.compose.foundation.text.BasicTextField
522 import androidx.compose.runtime.Composable
23+ import androidx.compose.runtime.LaunchedEffect
24+ import androidx.compose.runtime.getValue
25+ import androidx.compose.runtime.mutableStateOf
26+ import androidx.compose.runtime.remember
27+ import androidx.compose.runtime.setValue
28+ import androidx.compose.ui.Alignment
629 import androidx.compose.ui.Modifier
30+ import androidx.compose.ui.draw.alpha
31+ import androidx.compose.ui.focus.onFocusChanged
32+ import androidx.compose.ui.graphics.Color
33+ import androidx.compose.ui.graphics.SolidColor
34+ import androidx.compose.ui.semantics.Role
35+ import androidx.compose.ui.text.TextStyle
36+ import androidx.compose.ui.text.input.TextFieldValue
737 import androidx.compose.ui.unit.dp
38+ import androidx.compose.ui.unit.sp
839 import cc.vectrum.code.data.AppState
940 import cc.vectrum.code.data.OpenThread
1041 import cc.vectrum.code.data.ThreadRef
11- import cc.vectrum.code.theme.Engraved
42+ import cc.vectrum.code.net.ClientMessage
43+ import cc.vectrum.code.net.SavedItem
44+ import cc.vectrum.code.net.ThreadAction
45+ import cc.vectrum.code.net.str
46+ import cc.vectrum.code.theme.Cap
47+ import cc.vectrum.code.theme.CapKind
48+ import cc.vectrum.code.theme.CapSize
49+ import cc.vectrum.code.theme.Duo
50+ import cc.vectrum.code.theme.Sans
51+ import cc.vectrum.code.theme.ShadowLayer
52+ import cc.vectrum.code.theme.Txt
53+ import cc.vectrum.code.theme.metal
54+ import cc.vectrum.code.theme.palette
55+ import cc.vectrum.code.ui.Lucide
56+ import cc.vectrum.code.ui.LucideIcon
1257 import cc.vectrum.code.ui.Nav
58+ import cc.vectrum.code.ui.PopupMenu
59+ import cc.vectrum.code.ui.MenuRow
1360 import cc.vectrum.code.ui.Screen
1461
15- // Placeholder until it's built.
62+ // The composer (src/ui/chat.rs, src/ui/chat/footer.rs): the raised card the
63+ // prompt is typed in, with the model, traits and access controls in its
64+ // footer and the primary action on the right: send, stop, steer or queue,
65+ // Next or Submit on a question, Implement or Refine on a plan.
66+
67+ const val DefaultPlaceholder = "Ask anything, @tag files/folders, \$use skills, or / for commands"
68+
69+ /** The widest the conversation and the composer grow, as the desktop's comfortable chat width. */
70+ val ContentWidth = 736.dp
71+
72+ /** Lays the composer and what docks on it out as the desktop does: inset from the sheet, at most [ContentWidth] wide. */
73+ fun Modifier.composerColumn() = fillMaxWidth()
74+ .padding(horizontal = ComposerPad)
75+ .wrapContentWidth()
76+ .widthIn(max = ContentWidth)
77+ .fillMaxWidth()
78+
79+ /** The key a thread's composer state is kept under. */
80+ fun composerKey(thread: OpenThread) = "${thread.host.saved.id}/${thread.id}"
81+
82+ /** The composer of an open thread, its actions going to the host. */
1683 @Composable
1784 fun Composer(app: AppState, nav: Nav, thread: OpenThread) {
18- Box(Modifier.padding(16.dp)) { Engraved("Composer") }
85+ val state = ComposerState.of(composerKey(thread))
86+ val summary = thread.summary
87+ val detail = thread.detail
88+ val items = thread.items
89+ val plan = pendingPlan(items)
90+ val approval = pendingApproval(items) != null && plan == null
91+ val askTool = pendingAsk(items)
92+ val form = askTool?.let { (ix, tool) -> state.form(ix, parseQuestions(tool.input)) }
93+ val choiceOnly = pendingQuestion(items) != null && form == null
94+ val host = thread.host
95+ val noProvider = host.instances.isNotEmpty() && host.instances.none { it.status != "disabled" }
96+ val placeholder = when {
97+ approval -> "Resolve this approval request to continue"
98+ choiceOnly -> "Choose an option above"
99+ form != null -> "Type your own answer, or leave this blank to use the selected option"
100+ plan != null -> "Add feedback to refine the plan, or leave this blank to implement it"
101+ noProvider -> "Enable a provider in Settings to send a message"
102+ else -> DefaultPlaceholder
103+ }
104+
105+ // A question shown anew brings its own typed answer into the composer.
106+ val shown = form?.let { it.ix to it.current }
107+ LaunchedEffect(shown) {
108+ if (shown == state.askShown) return@LaunchedEffect
109+ val hadQuestion = state.askShown != null
110+ state.askShown = shown
111+ if (form != null) {
112+ state.setText(form.custom[form.current])
113+ } else if (hadQuestion) {
114+ // The form is answered: the answer typed for it goes away.
115+ state.setText("")
116+ state.ask = null
117+ }
118+ }
119+
120+ val started = summary.hasItems || items.isNotEmpty()
121+ val target = PickTarget(
122+ host = host,
123+ instance = summary.instance.ifEmpty { summary.provider },
124+ provider = summary.provider,
125+ model = summary.model,
126+ reasoning = summary.reasoning,
127+ fastMode = summary.fastMode,
128+ permissionMode = summary.permissionMode,
129+ planReturn = state.planReturn,
130+ started = started,
131+ fallbackName = detail.activeModel,
132+ pickModel = { instance, model ->
133+ if (instance.provider == summary.provider) thread.act(ThreadAction.SetModel(model.id))
134+ },
135+ setReasoning = { thread.act(ThreadAction.SetReasoning(it)) },
136+ setFastMode = { thread.act(ThreadAction.SetFastMode(it)) },
137+ setAccess = { mode ->
138+ if (summary.permissionMode == "plan") state.planReturn = mode else thread.act(ThreadAction.SetPermissionMode(mode))
139+ },
140+ setPlanning = { on ->
141+ if (on) {
142+ state.planReturn = summary.permissionMode
143+ thread.act(ThreadAction.SetPermissionMode("plan"))
144+ } else {
145+ thread.act(ThreadAction.SetPermissionMode(state.planReturn))
146+ }
147+ },
148+ )
149+
150+ fun offline(): Boolean {
151+ if (host.connected) return false
152+ app.showNotice("${host.name} is offline.", error = true)
153+ return true
154+ }
155+
156+ /** Declines Claude's plan with the composer's text as feedback, so it plans again. */
157+ fun refine(ix: Int) {
158+ val text = state.text.text.trim()
159+ if (text.isEmpty() || offline()) return
160+ state.setText("")
161+ thread.act(ThreadAction.Permission(ix, "deny"))
162+ thread.act(ThreadAction.Send(text))
163+ }
164+
165+ /**
166+ * Sends what's in the composer. While the agent works, the follow-up
167+ * setting decides between steering it now and queueing for after the
168+ * turn; `opposite` flips that for this one message.
169+ */
170+ fun submit(opposite: Boolean) {
171+ val text = state.text.text.trim()
172+ if (form != null) {
173+ form.setCustom(state.text.text)
174+ form.advance { answers -> thread.act(ThreadAction.AskSubmit(form.ix, answers)) }
175+ return
176+ }
177+ if (approval || choiceOnly) return
178+ if (plan != null) {
179+ if (text.isEmpty()) thread.act(ThreadAction.Permission(plan.ix, "allow")) else refine(plan.ix)
180+ return
181+ }
182+ if (!state.hasInput || offline()) return
183+ val uploads = state.attachments.map { it.upload() }
184+ state.clear()
185+ state.menuDismissed = null
186+ val queueMode = app.prefs.string("follow_up") == "queue"
187+ if ((thread.working || detail.preparing) && queueMode != opposite) {
188+ thread.act(ThreadAction.Queue(text, uploads))
189+ } else {
190+ thread.act(ThreadAction.Send(text, uploads))
191+ }
192+ }
193+
194+ val rightWidth = 36f + when {
195+ form != null -> if (form.current > 0) 120f else 80f
196+ plan != null -> 130f
197+ else -> 32f
198+ } + if (thread.working && form != null) 40f else 0f
199+
200+ ComposerFrame(
201+ app = app,
202+ state = state,
203+ target = target,
204+ placeholder = placeholder,
205+ rightWidth = rightWidth,
206+ editable = !approval && !choiceOnly,
207+ approval = approval,
208+ asking = form != null,
209+ modifier = Modifier.composerColumn().padding(bottom = 12.dp),
210+ onOpenProviders = if (noProvider) ({ nav.go(Screen.Settings("providers")) }) else null,
211+ onValueChange = { value ->
212+ state.text = value
213+ form?.setCustom(value.text)
214+ },
215+ ) {
216+ when {
217+ form != null -> {
218+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
219+ if (thread.working) StopCap { thread.act(ThreadAction.Interrupt) }
220+ if (form.current > 0) {
221+ Cap(icon = Lucide.ChevronLeft, size = CapSize.Regular) { form.previous() }
222+ }
223+ val enabled = if (form.isLast) form.isComplete else form.resolved(form.current) != null
224+ Cap(label = if (form.isLast) "Submit" else "Next", kind = CapKind.Primary, enabled = enabled) { submit(false) }
225+ }
226+ }
227+ plan != null -> PlanActions(state.hasInput, onRefine = { refine(plan.ix) }, onImplement = {
228+ thread.act(ThreadAction.Permission(plan.ix, "allow"))
229+ }, onImplementNew = { implementInNewThread(app, nav, thread, plan.ix, plan.tool) })
230+ thread.working && !state.hasInput -> StopCap { thread.act(ThreadAction.Interrupt) }
231+ else -> {
232+ val queueFirst = app.prefs.string("follow_up") == "queue"
233+ val icon = when {
234+ detail.preparing -> Lucide.LoaderCircle
235+ thread.working -> if (queueFirst) Lucide.ListPlus else Lucide.CornerUpRight
236+ else -> Lucide.ArrowUp
237+ }
238+ SendCap(
239+ icon = icon,
240+ enabled = state.hasInput,
241+ onClick = { submit(false) },
242+ // A long press does the other of steer and queue, as Ctrl-click on the desktop.
243+ onLongClick = if (thread.working) ({ submit(true) }) else null,
244+ )
245+ }
246+ }
247+ }
248+ }
249+
250+ /**
251+ * The composer card with its footer and pickers, for an open thread and a
252+ * new thread alike. `actions` is the right side of the footer, after the
253+ * attach button.
254+ */
255+ @Composable
256+ fun ComposerFrame(
257+ app: AppState,
258+ state: ComposerState,
259+ target: PickTarget,
260+ placeholder: String,
261+ rightWidth: Float,
262+ modifier: Modifier = Modifier,
263+ editable: Boolean = true,
264+ approval: Boolean = false,
265+ asking: Boolean = false,
266+ onOpenProviders: (() -> Unit)? = null,
267+ onValueChange: (TextFieldValue) -> Unit = { state.text = it },
268+ actions: @Composable () -> Unit,
269+ ) {
270+ val p = palette
271+ var focused by remember { mutableStateOf(false) }
272+ var sheet by remember { mutableStateOf<PickerSheet?>(null) }
273+ val planToggle = app.prefs.bool("plan_mode") && asksPermission(target.provider)
274+ val attach = rememberAttach(app, state)
275+ LaunchedEffect(state.modelPickerRequested) {
276+ if (state.modelPickerRequested) {
277+ state.modelPickerRequested = false
278+ sheet = PickerSheet.Model
279+ }
280+ }
281+ BoxWithConstraints(modifier) {
282+ val width = maxWidth.value
283+ // MetalUI marks focus with a green-deep ring.
284+ val layers = if (focused && !approval) p.raise + ShadowLayer(0f, 0f, 0f, 1f, false, p.greenDeep.copy(alpha = 0.4f)) else p.raise
285+ Column(
286+ Modifier
287+ .fillMaxWidth()
288+ .metal(RoundedCornerShape(24.dp), Duo(p.sHi, p.s).brush, layers)
289+ .then(if (approval) Modifier.alpha(0.92f) else Modifier)
290+ .padding(1.dp),
291+ ) {
292+ Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = if (approval) 16.dp else 8.dp)) {
293+ if (state.attachments.isNotEmpty() && !asking) AttachmentList(state)
294+ Box(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
295+ if (state.text.text.isEmpty()) {
296+ Txt(placeholder, color = p.fieldInk, size = 14.sp, lineHeight = 20.sp, maxLines = 2)
297+ }
298+ BasicTextField(
299+ value = state.text,
300+ onValueChange = onValueChange,
301+ enabled = editable,
302+ textStyle = TextStyle(color = p.ink, fontSize = 14.sp, lineHeight = 20.sp, fontFamily = Sans),
303+ cursorBrush = SolidColor(p.ink),
304+ minLines = if (approval) 1 else 2,
305+ maxLines = 8,
306+ modifier = Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused },
307+ )
308+ }
309+ }
310+ if (!approval) {
311+ val fit = footerFit(target, width, rightWidth, planToggle)
312+ Row(
313+ Modifier
314+ .fillMaxWidth()
315+ .padding(start = 6.dp, end = 16.dp, bottom = 16.dp, top = if (asking) 8.dp else 0.dp),
316+ horizontalArrangement = Arrangement.spacedBy(8.dp),
317+ verticalAlignment = Alignment.CenterVertically,
318+ ) {
319+ FooterControls(
320+ target,
321+ width,
322+ rightWidth,
323+ planToggle,
324+ Modifier.weight(1f),
325+ onOpenProviders = onOpenProviders,
326+ onOpen = { sheet = it },
327+ )
328+ // Answers to Claude's questions can't carry files.
329+ if (!asking) {
330+ Cap(Modifier.size(32.dp), icon = Lucide.Paperclip, kind = CapKind.Ghost, size = CapSize.Compact, onClick = attach)
331+ }
332+ actions()
333+ }
334+ PickerSheets(app, sheet, target, planToggle, fit) { sheet = null }
335+ }
336+ }
337+ }
338+ }
339+
340+ /** The primary cap: send, or steer or queue while the agent works. */
341+ @Composable
342+ fun SendCap(icon: Lucide, enabled: Boolean, onClick: () -> Unit, onLongClick: (() -> Unit)? = null) {
343+ val p = palette
344+ val interaction = remember { MutableInteractionSource() }
345+ val pressed by interaction.collectIsPressedAsState()
346+ val down = enabled && pressed
347+ Box(
348+ Modifier
349+ .size(32.dp)
350+ .metal(CircleShape, (if (down) p.primaryPressed else p.primary).brush, if (down) p.primaryPressedSh else p.primarySh)
351+ .combinedClickable(
352+ interactionSource = interaction,
353+ indication = null,
354+ enabled = enabled,
355+ role = Role.Button,
356+ onLongClick = onLongClick,
357+ onClick = onClick,
358+ )
359+ .alpha(if (enabled) 1f else 0.4f),
360+ contentAlignment = Alignment.Center,
361+ ) {
362+ LucideIcon(icon, size = 16.dp, tint = p.primaryInk)
363+ }
364+ }
365+
366+ /** Interrupts the agent. */
367+ @Composable
368+ fun StopCap(onClick: () -> Unit) {
369+ val p = palette
370+ val interaction = remember { MutableInteractionSource() }
371+ val pressed by interaction.collectIsPressedAsState()
372+ Box(
373+ Modifier
374+ .size(32.dp)
375+ .metal(CircleShape, (if (pressed) p.dangerPressed else p.danger).brush, if (pressed) p.dangerPressedSh else p.dangerSh)
376+ .combinedClickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onClick),
377+ contentAlignment = Alignment.Center,
378+ ) {
379+ LucideIcon(Lucide.Square, size = 11.dp, tint = Color.White)
380+ }
381+ }
382+
383+ /** Implement, with Implement in a new thread under its caret; Refine once feedback is typed. */
384+ @Composable
385+ private fun PlanActions(hasInput: Boolean, onRefine: () -> Unit, onImplement: () -> Unit, onImplementNew: () -> Unit) {
386+ if (hasInput) {
387+ Cap(label = "Refine", kind = CapKind.Primary, onClick = onRefine)
388+ return
389+ }
390+ var menu by remember { mutableStateOf(false) }
391+ Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
392+ Cap(label = "Implement", kind = CapKind.Primary, onClick = onImplement)
393+ Box {
394+ Cap(icon = Lucide.ChevronDown, kind = CapKind.Primary) { menu = true }
395+ PopupMenu(menu, { menu = false }, alignment = Alignment.BottomEnd, minWidth = 220.dp) {
396+ MenuRow("Implement in a new thread") {
397+ menu = false
398+ onImplementNew()
399+ }
400+ }
401+ }
402+ }
403+ }
404+
405+ /**
406+ * Hands Claude's plan to a new thread in the same project, and declines it
407+ * here, as the desktop's "Implement in a new thread".
408+ */
409+ private fun implementInNewThread(app: AppState, nav: Nav, thread: OpenThread, ix: Int, tool: SavedItem.Tool) {
410+ val host = thread.host
411+ val summary = thread.summary
412+ val plan = tool.input.str("plan") ?: ""
413+ val project = thread.project
414+ val defaults = newThreadDefaults(host, summary.project, project?.git == true, from = summary)
415+ thread.act(ThreadAction.Permission(ix, "deny"))
416+ host.create(
417+ ClientMessage.Create(
418+ reference = 0,
419+ project = summary.project,
420+ instance = defaults.instance,
421+ provider = defaults.provider,
422+ model = defaults.model,
423+ permissionMode = defaults.permissionMode,
424+ reasoning = defaults.reasoning,
425+ fastMode = defaults.fastMode,
426+ worktree = defaults.worktree,
427+ base = null,
428+ text = "Implement this plan:\n\n$plan",
429+ ),
430+ ) { id -> nav.openThread(ThreadRef(host.saved.id, id)) }
19431 }
+639−5android/app/src/main/java/cc/vectrum/code/ui/composer/Dock.ktView File
@@ -1,17 +1,651 @@
11 package cc.vectrum.code.ui.composer
22
3+ import android.text.format.DateFormat
4+ import androidx.compose.foundation.background
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.Spacer
10+ import androidx.compose.foundation.layout.fillMaxWidth
11+ import androidx.compose.foundation.layout.height
12+ import androidx.compose.foundation.layout.heightIn
413 import androidx.compose.foundation.layout.padding
14+ import androidx.compose.foundation.layout.size
15+ import androidx.compose.foundation.layout.width
16+ import androidx.compose.foundation.layout.widthIn
17+ import androidx.compose.foundation.rememberScrollState
18+ import androidx.compose.foundation.shape.CircleShape
19+ import androidx.compose.foundation.shape.RoundedCornerShape
20+ import androidx.compose.foundation.verticalScroll
521 import androidx.compose.runtime.Composable
22+ import androidx.compose.runtime.LaunchedEffect
23+ import androidx.compose.runtime.getValue
24+ import androidx.compose.runtime.mutableStateOf
25+ import androidx.compose.runtime.remember
26+ import androidx.compose.runtime.setValue
27+ import androidx.compose.ui.Alignment
628 import androidx.compose.ui.Modifier
29+ import androidx.compose.ui.platform.LocalContext
30+ import androidx.compose.ui.text.TextRange
31+ import androidx.compose.ui.text.font.FontWeight
32+ import androidx.compose.ui.text.input.TextFieldValue
733 import androidx.compose.ui.unit.dp
34+ import androidx.compose.ui.unit.sp
835 import cc.vectrum.code.data.AppState
936 import cc.vectrum.code.data.OpenThread
10- import cc.vectrum.code.data.ThreadRef
11- import cc.vectrum.code.theme.Engraved
37+ import cc.vectrum.code.net.RemoteCommand
38+ import cc.vectrum.code.net.SavedItem
39+ import cc.vectrum.code.net.ThreadAction
40+ import cc.vectrum.code.net.str
41+ import cc.vectrum.code.theme.Cap
42+ import cc.vectrum.code.theme.CapKind
43+ import cc.vectrum.code.theme.CapSize
44+ import cc.vectrum.code.theme.Duo
45+ import cc.vectrum.code.theme.Mono
46+ import cc.vectrum.code.theme.Tag
47+ import cc.vectrum.code.theme.Txt
48+ import cc.vectrum.code.theme.metal
49+ import cc.vectrum.code.theme.palette
50+ import cc.vectrum.code.ui.Lucide
51+ import cc.vectrum.code.ui.LucideIcon
52+ import cc.vectrum.code.ui.MenuRow
1253 import cc.vectrum.code.ui.Nav
13- import cc.vectrum.code.ui.Screen
54+ import cc.vectrum.code.ui.PopupMenu
55+ import kotlinx.coroutines.delay
1456
15- // Placeholder until it's built.
57+ // What docks on top of the composer (src/ui/chat/dock.rs), like T3 Code's
58+ // banner stack: queued messages, notices (background work, a usage limit),
59+ // and what takes over while the agent waits on you (an approval, a
60+ // question, a plan), then the agent's task list.
61+
62+ /** Whether the phone shows times on a 12-hour clock, as the time format says. */
63+ @Composable
64+ fun twelveHours(app: AppState): Boolean = when (app.prefs.string("time_format")) {
65+ "hour12" -> true
66+ "hour24" -> false
67+ else -> !DateFormat.is24HourFormat(LocalContext.current)
68+ }
69+
70+ /** The surfaces docked on top of an open thread's composer, each tucked under the next. */
71+ @Composable
72+ fun Dock(app: AppState, nav: Nav, thread: OpenThread) {
73+ val state = ComposerState.of(composerKey(thread))
74+ val summary = thread.summary
75+ val detail = thread.detail
76+ val items = thread.items
77+ Column(Modifier.composerColumn()) {
78+ StatusLine(app, thread)
79+ Column(Modifier.padding(horizontal = Inset)) {
80+ // The `/` and `$` menus cover everything else while open.
81+ val commands = thread.host.instances.find { it.id == summary.instance }?.commands
82+ if (commands != null && CommandMenu(app, state, commands)) return@Column
83+
84+ val plan = pendingPlan(items)
85+ val approval = pendingApproval(items)
86+ val ask = pendingAsk(items)
87+ val question = pendingQuestion(items)
88+ val steps = taskSteps(items)
89+ val blocking = approval != null || ask != null || question != null
90+ val showTasks = !blocking && steps.isNotEmpty() && (thread.working || steps.any { it.status != StepStatus.Completed })
91+ var joined = false
92+ if (detail.queue.isNotEmpty()) {
93+ QueueBanner(thread, state, joined)
94+ joined = true
95+ }
96+ val notices = notices(thread)
97+ if (notices.isNotEmpty()) {
98+ NoticeStack(app, thread, state, notices, joined)
99+ joined = true
100+ }
101+ when {
102+ plan != null -> {
103+ PlanReady(planTitle(plan.tool), joined)
104+ joined = true
105+ }
106+ approval != null -> {
107+ ApprovalBanner(thread, approval, joined)
108+ joined = true
109+ }
110+ ask != null -> {
111+ QuestionBanner(thread, state, ask.first, ask.second, joined)
112+ joined = true
113+ }
114+ question != null -> {
115+ ExtensionQuestion(thread, question.first, question.second, joined)
116+ joined = true
117+ }
118+ }
119+ if (showTasks) TasksBanner(state, steps, joined)
120+ }
121+ }
122+ }
123+
124+ /**
125+ * The line closing the conversation, as T3 Code ends its timeline: once
126+ * the agent is done, whether the thread is snoozed or settled.
127+ */
128+ @Composable
129+ private fun StatusLine(app: AppState, thread: OpenThread) {
130+ val p = palette
131+ val summary = thread.summary
132+ val snoozed = summary.snoozedUntil?.takeIf { it > System.currentTimeMillis() }
133+ if (snoozed == null && !summary.settled) return
134+ val twelve = twelveHours(app)
135+ val (icon, label, action) = if (snoozed != null) {
136+ Triple(Lucide.AlarmClock, "Snoozed, ${whenText(snoozed, twelve)}", "Wake now")
137+ } else {
138+ Triple(Lucide.CircleCheck, "Settled", "Un-settle")
139+ }
140+ Row(
141+ Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
142+ horizontalArrangement = Arrangement.spacedBy(6.dp),
143+ verticalAlignment = Alignment.CenterVertically,
144+ ) {
145+ LucideIcon(icon, size = 14.dp, tint = p.ink3)
146+ Txt(label, Modifier.weight(1f, fill = false), color = p.ink3, size = 12.sp, maxLines = 1)
147+ Txt("·", color = p.ink3, size = 12.sp)
148+ Txt(
149+ action,
150+ Modifier.pressable(shape = RoundedCornerShape(4.dp)) {
151+ thread.act(if (snoozed != null) ThreadAction.Snooze(null) else ThreadAction.Unsettle)
152+ }.padding(vertical = 6.dp),
153+ color = p.ink3,
154+ size = 12.sp,
155+ weight = FontWeight.Medium,
156+ maxLines = 1,
157+ )
158+ }
159+ }
160+
161+ // ---- the `/` and `$` menus -----------------------------------------------------
162+
163+ /**
164+ * The menu for the word at the cursor, docked on top of the composer like
165+ * T3 Code's. Returns whether it shows.
166+ */
167+ @Composable
168+ fun CommandMenu(app: AppState, state: ComposerState, commands: List<RemoteCommand>): Boolean {
169+ val p = palette
170+ val text = state.text
171+ if (state.menuDismissed == text.text) return false
172+ val word = commandWord(text) ?: return false
173+ val items = commandItems(commands, word, app.prefs.bool("slash_skills"))
174+ val trigger = word.trigger
175+ fun accept(command: RemoteCommand) {
176+ val before = text.text.substring(0, word.start)
177+ val after = text.text.substring(text.selection.end.coerceAtMost(text.text.length))
178+ // `/model` opens the model picker, as in T3 Code.
179+ if (command.name == "model" && trigger == '/') {
180+ state.text = TextFieldValue(before + after, TextRange(before.length))
181+ state.modelPickerRequested = true
182+ return
183+ }
184+ val replacement = "${if (trigger == '$') '$' else '/'}${command.name} "
185+ state.text = TextFieldValue(before + replacement + after, TextRange(before.length + replacement.length))
186+ }
187+ Banner {
188+ Column(Modifier.heightIn(max = 288.dp).verticalScroll(rememberScrollState()).padding(4.dp)) {
189+ for (command in items) {
190+ Row(
191+ Modifier
192+ .fillMaxWidth()
193+ .heightIn(min = 36.dp)
194+ .pressable(shape = RoundedCornerShape(6.dp)) { accept(command) }
195+ .padding(horizontal = 8.dp, vertical = 6.dp),
196+ horizontalArrangement = Arrangement.spacedBy(8.dp),
197+ verticalAlignment = Alignment.CenterVertically,
198+ ) {
199+ val skillUnderSlash = command.skill && trigger == '/'
200+ Row(Modifier.widthIn(max = 150.dp)) {
201+ if (skillUnderSlash) Txt("/skill:", color = p.ink3, size = 12.sp, weight = FontWeight.Medium, maxLines = 1)
202+ Txt(
203+ if (skillUnderSlash) command.name else "${if (trigger == '$') '$' else '/'}${command.name}",
204+ size = 12.sp,
205+ weight = FontWeight.Medium,
206+ maxLines = 1,
207+ )
208+ }
209+ Txt(
210+ command.description.ifEmpty { command.argumentHint },
211+ Modifier.weight(1f),
212+ color = p.ink3,
213+ size = 12.sp,
214+ maxLines = 1,
215+ )
216+ if (command.skill) Tag(if (trigger == '$') "Personal Skill" else "Personal")
217+ }
218+ }
219+ if (items.isEmpty()) {
220+ Txt(
221+ if (trigger == '$') "No skills found. Try / to browse provider commands." else "No matching command.",
222+ Modifier.padding(start = 14.dp, end = 14.dp, top = 20.dp, bottom = 12.dp),
223+ color = p.ink3,
224+ size = 12.sp,
225+ )
226+ }
227+ }
228+ }
229+ return true
230+ }
231+
232+ // ---- queued messages -----------------------------------------------------------
233+
234+ /** Messages waiting for the turn to end: each can be sent now as a steer, or removed. */
235+ @Composable
236+ private fun QueueBanner(thread: OpenThread, state: ComposerState, joined: Boolean) {
237+ val p = palette
238+ val queue = thread.detail.queue
239+ Banner(joined = joined) {
240+ BannerRow(Modifier.pressable(shape = RoundedCornerShape(6.dp)) { state.queueExpanded = !state.queueExpanded }) {
241+ IconCell(Lucide.ListOrdered)
242+ Txt("Queued", Modifier.weight(1f), color = p.ink3, size = 12.sp, lineHeight = 16.sp, maxLines = 1)
243+ Count(queue.size.toString())
244+ ToggleIcon(state.queueExpanded)
245+ }
246+ if (state.queueExpanded) {
247+ Column(Modifier.heightIn(max = 160.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(1.dp)) {
248+ queue.forEachIndexed { ix, message ->
249+ BannerRow {
250+ IconCell()
251+ Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
252+ if (message.attachments.isNotEmpty()) LucideIcon(Lucide.Paperclip, size = 12.dp, tint = p.ink3)
253+ Txt(message.text.lineSequence().firstOrNull().orEmpty(), color = p.ink2, size = 12.sp, maxLines = 1)
254+ }
255+ MiniButton("Steer", icon = Lucide.CornerUpRight, enabled = thread.working) {
256+ thread.act(ThreadAction.SteerQueued(ix))
257+ }
258+ IconButton(Lucide.X) { thread.act(ThreadAction.RemoveQueued(ix)) }
259+ }
260+ }
261+ }
262+ }
263+ }
264+ }
265+
266+ // ---- notices -------------------------------------------------------------------
267+
268+ /** A notice docked above the composer. */
269+ private sealed interface DockNotice {
270+ val tone: Tone
271+
272+ /** Commands, monitors or subagents left running in the background. */
273+ class BackgroundWork(val title: String, val detail: String?, val waiting: Boolean, val taskIds: List<String>) : DockNotice {
274+ override val tone = Tone.Neutral
275+ }
276+
277+ /** A usage limit stopped the thread. */
278+ class Limit(val resets: Long, val resumeAt: Long?) : DockNotice {
279+ override val tone = Tone.Warning
280+ }
281+ }
282+
283+ private val Nouns = listOf("subagent" to "subagents", "command" to "commands", "monitor" to "monitors", "background task" to "background tasks")
284+
285+ /** The notices, front first: work running in the background, then a usage limit. */
286+ private fun notices(thread: OpenThread): List<DockNotice> {
287+ val detail = thread.detail
288+ val notices = mutableListOf<DockNotice>()
289+ if (detail.backgroundTasks.isNotEmpty()) {
290+ // What each task is, from the tool call that started it.
291+ fun kind(description: String): Int {
292+ val tool = thread.items.asReversed().firstOrNull { item ->
293+ item is SavedItem.Tool && item.background != null && item.input.str("description") == description
294+ } as? SavedItem.Tool
295+ return when (tool?.name) {
296+ "Task", "Agent" -> 0
297+ "Bash" -> 1
298+ "Monitor" -> 2
299+ else -> 3
300+ }
301+ }
302+ val tasks = detail.backgroundTasks.map { kind(it.description) to it }.sortedBy { it.first }
303+ // Subagents and monitors wake the agent; a dev server doesn't.
304+ val waiting = tasks.any { it.first == 0 || it.first == 2 }
305+ val title = if (tasks.size == 1) {
306+ val (kind, task) = tasks[0]
307+ val noun = Nouns[kind].first
308+ val named = task.description.isNotEmpty()
309+ when {
310+ waiting && named -> "Waiting on $noun ${task.description}"
311+ waiting -> "Waiting on a $noun"
312+ named -> "Running: ${task.description}"
313+ else -> "Running a $noun"
314+ }
315+ } else {
316+ val groups = (0..3).mapNotNull { kind ->
317+ val count = tasks.count { it.first == kind }
318+ if (count == 0) null else "$count ${if (count == 1) Nouns[kind].first else Nouns[kind].second}"
319+ }
320+ val joined = if (groups.size <= 1) groups.joinToString("") else "${groups.dropLast(1).joinToString(", ")} and ${groups.last()}"
321+ "${if (waiting) "Waiting on" else "Running"} $joined"
322+ }
323+ val description = if (tasks.size > 1) tasks.joinToString(", ") { it.second.description } else null
324+ notices.add(DockNotice.BackgroundWork(title, description, waiting, tasks.map { it.second.taskId }))
325+ }
326+ val resets = detail.limitResets
327+ if (resets != null && resets > System.currentTimeMillis()) notices.add(DockNotice.Limit(resets, detail.resumeAt))
328+ return notices
329+ }
330+
331+ /** The notices: the front one docked, the others behind a peek tab that unfolds them. */
332+ @Composable
333+ private fun NoticeStack(app: AppState, thread: OpenThread, state: ComposerState, notices: List<DockNotice>, joined: Boolean) {
334+ val p = palette
335+ val front = notices.first()
336+ val rest = notices.drop(1)
337+ val expanded = state.noticesExpanded && rest.isNotEmpty()
338+ Column {
339+ if (expanded) {
340+ Column(Modifier.padding(bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
341+ for (notice in rest) {
342+ Box(
343+ Modifier
344+ .fillMaxWidth()
345+ .metal(RoundedCornerShape(16.dp), Duo(p.sHi, p.s).brush, p.raiseLite)
346+ .padding(horizontal = 4.dp, vertical = 5.dp),
347+ ) { NoticeRow(app, thread, notice) }
348+ }
349+ }
350+ }
351+ if (rest.isNotEmpty() && !expanded) {
352+ Spacer(
353+ Modifier
354+ .padding(horizontal = 8.dp)
355+ .fillMaxWidth()
356+ .tuck(4.dp)
357+ .height(12.dp)
358+ .metal(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), Duo(p.sHi, p.s).brush, p.raiseLite)
359+ .pressable { state.noticesExpanded = !state.noticesExpanded },
360+ )
361+ }
362+ Banner(tone = front.tone, density = Density.Comfortable, joined = joined) {
363+ NoticeRow(app, thread, front)
364+ }
365+ }
366+ }
367+
368+ @Composable
369+ private fun NoticeRow(app: AppState, thread: OpenThread, notice: DockNotice) {
370+ val p = palette
371+ when (notice) {
372+ is DockNotice.BackgroundWork -> BannerRow {
373+ IconCell {
374+ Spacer(Modifier.size(6.dp).background(if (notice.waiting) p.greenInk else p.ink, CircleShape))
375+ }
376+ Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
377+ Txt(notice.title, Modifier.weight(1f, fill = notice.detail == null), size = 12.sp, weight = FontWeight.Medium, maxLines = 1)
378+ if (notice.detail != null) Txt(notice.detail, Modifier.weight(1f), color = p.ink3, size = 12.sp, maxLines = 1)
379+ }
380+ MiniButton("Stop") { notice.taskIds.forEach { thread.act(ThreadAction.StopTask(it)) } }
381+ }
382+ is DockNotice.Limit -> BannerRow {
383+ val twelve = twelveHours(app)
384+ IconCell(Lucide.Gauge)
385+ Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
386+ Txt("Usage limit reached", size = 12.sp, weight = FontWeight.Medium, maxLines = 1)
387+ Txt(
388+ if (notice.resumeAt != null) "Continues at ${whenText(notice.resumeAt, twelve)}" else "Resets at ${whenText(notice.resets, twelve)}",
389+ Modifier.weight(1f),
390+ color = p.ink3,
391+ size = 12.sp,
392+ maxLines = 1,
393+ )
394+ }
395+ if (notice.resumeAt != null) {
396+ MiniButton("Cancel") { thread.act(ThreadAction.CancelResume) }
397+ } else {
398+ MiniButton("Continue then") { thread.act(ThreadAction.ScheduleResume(notice.resets + 60_000)) }
399+ }
400+ }
401+ }
402+ }
403+
404+ // ---- what the agent waits on ---------------------------------------------------
405+
406+ /** An approval the agent waits on: what it wants to run, Decline and Approve, and the rarer answers behind "…". */
407+ @Composable
408+ private fun ApprovalBanner(thread: OpenThread, approval: Approval, joined: Boolean) {
409+ val p = palette
410+ val tool = approval.tool
411+ val ix = approval.ix
412+ val mcp = tool.name.startsWith("mcp__")
413+ Banner(tone = Tone.Warning, density = Density.Spacious, joined = joined) {
414+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
415+ Box(Modifier.padding(top = 2.dp)) { LucideIcon(Lucide.Shield, size = 16.dp, tint = p.amberInk) }
416+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
417+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
418+ Txt(approvalLabel(tool), color = p.amberInk, size = 11.sp, weight = FontWeight.Medium, maxLines = 1)
419+ if (mcp) {
420+ Txt(
421+ tool.name.removePrefix("mcp__").replace("__", " · "),
422+ Modifier.weight(1f),
423+ color = p.ink3,
424+ size = 11.sp,
425+ maxLines = 1,
426+ )
427+ } else {
428+ Spacer(Modifier.weight(1f))
429+ }
430+ if (approval.count > 1) Txt("1/${approval.count}", color = p.ink3, size = 11.sp, maxLines = 1)
431+ }
432+ Box(Modifier.heightIn(max = 80.dp).verticalScroll(rememberScrollState())) {
433+ Txt(approvalDetail(tool), size = 12.sp, lineHeight = 16.sp, family = if (mcp) cc.vectrum.code.theme.Sans else Mono)
434+ }
435+ }
436+ }
437+ Row(
438+ Modifier.fillMaxWidth().padding(top = 10.dp),
439+ horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
440+ verticalAlignment = Alignment.CenterVertically,
441+ ) {
442+ Cap(Modifier.height(32.dp), label = "Decline", size = CapSize.Compact) { thread.act(ThreadAction.Permission(ix, "deny")) }
443+ Cap(Modifier.height(32.dp), label = "Approve", kind = CapKind.Primary, size = CapSize.Compact) {
444+ thread.act(ThreadAction.Permission(ix, "allow"))
445+ }
446+ var menu by remember { mutableStateOf(false) }
447+ Box {
448+ Cap(Modifier.size(32.dp), icon = Lucide.Ellipsis, size = CapSize.Compact) { menu = true }
449+ PopupMenu(menu, { menu = false }, alignment = Alignment.BottomEnd, minWidth = 220.dp) {
450+ MenuRow("Cancel") {
451+ menu = false
452+ thread.act(ThreadAction.Permission(ix, "deny"))
453+ thread.act(ThreadAction.Interrupt)
454+ }
455+ if (offersAlways(tool)) {
456+ MenuRow("Always allow this session") {
457+ menu = false
458+ thread.act(ThreadAction.Permission(ix, "always"))
459+ }
460+ }
461+ }
462+ }
463+ }
464+ }
465+ }
466+
467+ /** Claude's plan, ready to implement: the footer below offers Implement or Refine. */
468+ @Composable
469+ private fun PlanReady(title: String, joined: Boolean) {
470+ val p = palette
471+ Banner(joined = joined) {
472+ BannerRow {
473+ IconCell()
474+ Txt("Plan ready", color = p.ink3, size = 12.sp, weight = FontWeight.Medium, maxLines = 1)
475+ Txt(title, Modifier.weight(1f), color = p.ink.copy(alpha = 0.85f), size = 12.sp, maxLines = 1)
476+ }
477+ }
478+ }
479+
480+ /**
481+ * Claude's AskUserQuestion form, one question at a time. The header folds
482+ * it away; options pick with a tap; the composer below takes an answer in
483+ * the user's own words.
484+ */
485+ @Composable
486+ private fun QuestionBanner(thread: OpenThread, state: ComposerState, ix: Int, tool: SavedItem.Tool, joined: Boolean) {
487+ val p = palette
488+ val form = state.form(ix, parseQuestions(tool.input))
489+ val current = form.current
490+ val question = form.questions.getOrNull(current) ?: return
491+ val collapsed = state.questionCollapsed == (ix to current)
492+ val count = form.questions.size
493+ val customActive = form.custom[current].isNotBlank()
494+ val pick = state.optimisticPick
495+ LaunchedEffect(pick) {
496+ if (pick == null || pick.first != ix) return@LaunchedEffect
497+ // A single-select question moves on by itself a moment later, as in T3 Code.
498+ delay(200)
499+ state.optimisticPick = null
500+ form.advance { answers -> thread.act(ThreadAction.AskSubmit(ix, answers)) }
501+ }
502+ Banner(joined = joined) {
503+ BannerRow(Modifier.pressable(shape = RoundedCornerShape(6.dp)) {
504+ state.questionCollapsed = if (collapsed) null else ix to current
505+ }) {
506+ IconCell()
507+ Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
508+ Txt(question.header, color = p.ink3, size = 12.sp, weight = FontWeight.Medium, maxLines = 1)
509+ if (collapsed) Txt(question.question, Modifier.weight(1f), color = p.ink2, size = 12.sp, maxLines = 1)
510+ }
511+ if (count > 1) Txt("${current + 1}/$count", color = p.ink3, size = 10.sp, weight = FontWeight.Medium, maxLines = 1)
512+ ToggleIcon(!collapsed)
513+ IconButton(Lucide.X) { thread.act(ThreadAction.AskSkip(ix)) }
514+ }
515+ if (!collapsed) {
516+ Column(
517+ Modifier
518+ .heightIn(max = 384.dp)
519+ .verticalScroll(rememberScrollState())
520+ .padding(start = 28.dp, end = 4.dp, bottom = 4.dp),
521+ ) {
522+ Txt(question.question, color = p.ink.copy(alpha = 0.85f), size = 14.sp, lineHeight = 20.sp)
523+ if (question.multiSelect) {
524+ Txt("Select one or more options.", Modifier.padding(top = 4.dp), color = p.ink3, size = 12.sp)
525+ }
526+ Column(Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
527+ question.options.forEachIndexed { option, choice ->
528+ val selected = (!customActive && option in form.picked[current]) || pick == Triple(ix, current, option)
529+ OptionRow(choice.label, choice.description?.takeIf { it != choice.label }, option, selected) {
530+ val single = form.toggle(option)
531+ if (state.text.text.isNotEmpty()) state.setText("")
532+ if (single) state.optimisticPick = Triple(ix, current, option)
533+ }
534+ }
535+ }
536+ }
537+ }
538+ }
539+ }
540+
541+ /** A Pi extension's question: its title and message, and its options. */
542+ @Composable
543+ private fun ExtensionQuestion(thread: OpenThread, ix: Int, question: SavedItem.Question, joined: Boolean) {
544+ val p = palette
545+ Banner(joined = joined) {
546+ BannerRow {
547+ IconCell()
548+ Txt("Question", color = p.ink3, size = 12.sp, weight = FontWeight.Medium, maxLines = 1)
549+ }
550+ Column(Modifier.padding(start = 28.dp, end = 4.dp, bottom = 4.dp)) {
551+ Txt(question.title, color = p.ink.copy(alpha = 0.85f), size = 14.sp, lineHeight = 20.sp)
552+ if (question.message != null) {
553+ Txt(question.message, Modifier.padding(top = 4.dp), color = p.ink3, size = 12.sp)
554+ }
555+ Column(Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
556+ question.options.forEachIndexed { option, label ->
557+ OptionRow(label, null, option, false) { thread.act(ThreadAction.Answer(ix, option)) }
558+ }
559+ }
560+ }
561+ }
562+ }
563+
564+ @Composable
565+ private fun OptionRow(label: String, description: String?, option: Int, selected: Boolean, onClick: () -> Unit) {
566+ val p = palette
567+ val shape = RoundedCornerShape(8.dp)
568+ Row(
569+ Modifier
570+ .fillMaxWidth()
571+ .then(if (selected) Modifier.background(p.ink.copy(alpha = 0.07f), shape) else Modifier)
572+ .pressable(shape = shape, onClick = onClick)
573+ .padding(horizontal = 10.dp, vertical = 8.dp),
574+ horizontalArrangement = Arrangement.spacedBy(8.dp),
575+ verticalAlignment = Alignment.CenterVertically,
576+ ) {
577+ val ink = if (selected) p.ink else p.ink.copy(alpha = 0.85f)
578+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
579+ Txt(label, color = ink, size = 14.sp, weight = FontWeight.Medium)
580+ if (description != null) Txt(description, color = p.ink3, size = 11.sp)
581+ }
582+ when {
583+ selected -> LucideIcon(Lucide.Check, size = 14.dp, tint = p.greenInk)
584+ option < 9 -> Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
585+ Txt("${option + 1}", color = p.ink3, size = 10.sp, weight = FontWeight.Medium)
586+ }
587+ }
588+ }
589+ }
590+
591+ // ---- the task list -------------------------------------------------------------
592+
593+ /** The agent's task list: one row with the current step and progress, unfolding into every step. */
16594 @Composable
17- fun Dock(app: AppState, nav: Nav, thread: OpenThread) {}
595+ private fun TasksBanner(state: ComposerState, steps: List<TaskStep>, joined: Boolean) {
596+ val p = palette
597+ val expanded = state.tasksExpanded
598+ val total = steps.size
599+ val done = steps.count { it.status == StepStatus.Completed }
600+ val current = (
601+ steps.firstOrNull { it.status == StepStatus.InProgress }
602+ ?: steps.firstOrNull { it.status == StepStatus.Pending }
603+ ?: steps.lastOrNull()
604+ )?.text.orEmpty()
605+ Banner(density = Density.Comfortable, joined = joined) {
606+ BannerRow(Modifier.pressable(shape = RoundedCornerShape(6.dp)) { state.tasksExpanded = !state.tasksExpanded }) {
607+ IconCell(Lucide.ListTodo)
608+ Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
609+ Txt("Tasks", color = p.ink3, size = 12.sp, maxLines = 1)
610+ Txt(current, Modifier.weight(1f), color = p.ink2, size = 12.sp, weight = FontWeight.Medium, maxLines = 1)
611+ }
612+ Count("$done/$total", if (done >= total) p.greenInk else p.ink3)
613+ if (total in 2..10) {
614+ Row(Modifier.width(64.dp), horizontalArrangement = Arrangement.spacedBy(2.dp)) {
615+ for (step in steps) {
616+ val color = when (step.status) {
617+ StepStatus.Completed -> p.greenInk
618+ StepStatus.InProgress -> p.blueInk
619+ StepStatus.Pending -> p.ink3.copy(alpha = 0.25f)
620+ }
621+ Spacer(Modifier.weight(1f).height(3.dp).background(color, CircleShape))
622+ }
623+ }
624+ }
625+ ToggleIcon(expanded)
626+ }
627+ if (expanded) {
628+ Column(Modifier.heightIn(max = 384.dp).verticalScroll(rememberScrollState())) {
629+ for (step in steps) {
630+ val (icon, color, text) = when (step.status) {
631+ StepStatus.Completed -> Triple(Lucide.Check, p.greenInk, p.ink3.copy(alpha = 0.7f))
632+ StepStatus.InProgress -> Triple(Lucide.CircleDot, p.blueInk, p.ink)
633+ StepStatus.Pending -> Triple(Lucide.Circle, p.ink3.copy(alpha = 0.4f), p.ink3)
634+ }
635+ Row(
636+ Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 4.dp, end = 8.dp),
637+ horizontalArrangement = Arrangement.spacedBy(4.dp),
638+ ) {
639+ Box(Modifier.width(IconColumn).height(16.dp), contentAlignment = Alignment.Center) {
640+ LucideIcon(icon, size = 12.dp, tint = color)
641+ }
642+ Txt(step.text, Modifier.weight(1f), color = text, size = 12.sp, lineHeight = 16.sp)
643+ if (step.status == StepStatus.InProgress) {
644+ Txt("now", color = p.ink3.copy(alpha = 0.6f), size = 10.sp, lineHeight = 16.sp)
645+ }
646+ }
647+ }
648+ }
649+ }
650+ }
651+ }
+350−0android/app/src/main/java/cc/vectrum/code/ui/composer/Model.ktaddedView File
@@ -0,0 +1,350 @@
1+ package cc.vectrum.code.ui.composer
2+
3+ import androidx.compose.ui.graphics.Color
4+ import androidx.compose.ui.text.input.TextFieldValue
5+ import cc.vectrum.code.net.RemoteCommand
6+ import cc.vectrum.code.net.RemoteModel
7+ import cc.vectrum.code.net.SavedItem
8+ import cc.vectrum.code.net.str
9+ import cc.vectrum.code.ui.Lucide
10+ import kotlinx.serialization.json.JsonArray
11+ import kotlinx.serialization.json.JsonElement
12+ import kotlinx.serialization.json.JsonObject
13+ import kotlinx.serialization.json.JsonPrimitive
14+ import kotlinx.serialization.json.booleanOrNull
15+ import kotlinx.serialization.json.contentOrNull
16+ import java.time.Instant
17+ import java.time.LocalDate
18+ import java.time.ZoneId
19+ import java.time.format.DateTimeFormatter
20+ import java.time.temporal.ChronoUnit
21+ import java.util.Locale
22+
23+ // What the composer, its pickers and the dock know about a thread, as the
24+ // desktop works it out (src/ui/pickers.rs, src/ui/chat/dock.rs,
25+ // src/claude.rs): plain functions over the wire types.
26+
27+ // ---- providers -----------------------------------------------------------------
28+
29+ /** Claude's own orange, for the provider mark. */
30+ val ClaudeOrange = Color(0xffd97757)
31+
32+ fun providerIcon(provider: String): Lucide = when (provider) {
33+ "pi" -> Lucide.Pi
34+ "jcode" -> Lucide.Feather
35+ else -> Lucide.Asterisk
36+ }
37+
38+ fun providerLabel(provider: String): String = when (provider) {
39+ "pi" -> "Pi"
40+ "jcode" -> "jcode"
41+ else -> "Claude"
42+ }
43+
44+ /** The agent's full name, for menus and messages. */
45+ fun agentName(provider: String): String = when (provider) {
46+ "pi" -> "Pi"
47+ "jcode" -> "jcode"
48+ else -> "Claude Code"
49+ }
50+
51+ /** Whether the agent asks before running tools. Pi and jcode just run them. */
52+ fun asksPermission(provider: String) = provider == "claude"
53+
54+ // ---- reasoning -----------------------------------------------------------------
55+
56+ /** Claude's `--effort` levels, offered when the model doesn't list its own. */
57+ val DefaultEfforts = listOf("low", "medium", "high", "xhigh", "max")
58+
59+ fun effortLabel(effort: String): String = when (effort) {
60+ "off" -> "Off"
61+ "minimal" -> "Minimal"
62+ "low" -> "Low"
63+ "medium" -> "Medium"
64+ "high" -> "High"
65+ "xhigh" -> "Extra High"
66+ "max" -> "Max"
67+ else -> effort.replaceFirstChar { it.uppercase() }
68+ }
69+
70+ /** `default`, an effort level, `ultracode` or `ultrathink`, as the menus say it. */
71+ fun reasoningLabel(reasoning: String): String = when (reasoning) {
72+ "default" -> "Default"
73+ "ultracode" -> "Ultracode"
74+ "ultrathink" -> "Ultrathink"
75+ else -> effortLabel(reasoning)
76+ }
77+
78+ /** The reasoning levels a model offers. */
79+ fun reasoningLevels(provider: String, model: RemoteModel?): List<String> = when (provider) {
80+ // Most of jcode's providers take no effort setting at all.
81+ "jcode" -> model?.efforts ?: emptyList()
82+ else -> model?.efforts?.takeIf { it.isNotEmpty() } ?: DefaultEfforts
83+ }
84+
85+ /** The level `Default` stands for on this model, when known. */
86+ fun defaultLevel(provider: String, model: RemoteModel?): String? =
87+ if (provider == "claude") model?.defaultEffort else null
88+
89+ /** Whether the model takes any trait at all: a reasoning level or fast mode. */
90+ fun hasTraits(provider: String, model: RemoteModel?) =
91+ reasoningLevels(provider, model).isNotEmpty() || model?.fastMode == true
92+
93+ /**
94+ * The traits trigger's label, as T3 Code builds it: the reasoning level,
95+ * with "Fast" after it while fast mode is on: `High`, `High Fast`.
96+ */
97+ fun traitsLabel(provider: String, reasoning: String, fastMode: Boolean, model: RemoteModel?): String {
98+ val level = if (reasoning == "default") {
99+ defaultLevel(provider, model)?.let(::effortLabel) ?: "Default"
100+ } else {
101+ reasoningLabel(reasoning)
102+ }
103+ val fast = fastMode && model?.fastMode == true
104+ if (reasoningLevels(provider, model).isEmpty()) return if (fast) "Fast" else "Normal"
105+ return if (fast) "$level Fast" else level
106+ }
107+
108+ /** The reasoning a thread keeps when it moves to `model`. */
109+ fun reasoningFor(provider: String, reasoning: String, model: RemoteModel?): String = when {
110+ reasoning in setOf("ultracode", "ultrathink") && provider == "claude" -> reasoning
111+ reasoning != "default" && model != null && reasoning in model.efforts -> reasoning
112+ else -> "default"
113+ }
114+
115+ // ---- access --------------------------------------------------------------------
116+
117+ /** T3 Code's four runtime modes. */
118+ val AccessModes = listOf("default", "acceptEdits", "auto", "bypassPermissions")
119+
120+ /** The access modes a thread can pick from: only Full access for agents without approval prompts. */
121+ fun accessModes(provider: String): List<String> = if (asksPermission(provider)) AccessModes else listOf("bypassPermissions")
122+
123+ fun accessLabel(mode: String): String = when (mode) {
124+ "acceptEdits" -> "Auto-accept edits"
125+ "auto" -> "Auto"
126+ "plan" -> "Plan"
127+ "bypassPermissions" -> "Full access"
128+ else -> "Supervised"
129+ }
130+
131+ fun accessDescription(mode: String): String = when (mode) {
132+ "acceptEdits" -> "Auto-approve edits, ask before other actions."
133+ "auto" -> "Supported providers approve routine actions; others still ask."
134+ "plan" -> "Read-only: Claude plans before it changes anything."
135+ "bypassPermissions" -> "Allow commands and edits without prompts."
136+ else -> "Ask before commands and file changes."
137+ }
138+
139+ fun accessIcon(mode: String): Lucide = when (mode) {
140+ "acceptEdits" -> Lucide.PenLine
141+ "auto" -> Lucide.Sparkles
142+ "plan" -> Lucide.PencilRuler
143+ "bypassPermissions" -> Lucide.LockOpen
144+ else -> Lucide.Lock
145+ }
146+
147+ // ---- what the thread waits on --------------------------------------------------
148+
149+ const val AskUserQuestion = "AskUserQuestion"
150+
151+ class AskOption(val label: String, val description: String?)
152+
153+ class AskQuestion(val question: String, val header: String, val options: List<AskOption>, val multiSelect: Boolean)
154+
155+ /** An AskUserQuestion call's questions, as the desktop reads them. */
156+ fun parseQuestions(input: JsonElement): List<AskQuestion> {
157+ val questions = (input as? JsonObject)?.get("questions") as? JsonArray ?: return emptyList()
158+ return questions.mapNotNull { question ->
159+ val text = question.str("question") ?: return@mapNotNull null
160+ val options = ((question as? JsonObject)?.get("options") as? JsonArray).orEmpty().mapNotNull { option ->
161+ val label = option.str("label") ?: return@mapNotNull null
162+ AskOption(label, option.str("description")?.takeIf { it.isNotEmpty() })
163+ }
164+ val multi = ((question as? JsonObject)?.get("multiSelect") as? JsonPrimitive)?.booleanOrNull ?: false
165+ AskQuestion(text, question.str("header") ?: "", options, multi)
166+ }
167+ }
168+
169+ private fun SavedItem.Tool.isAsk() = name == AskUserQuestion && parseQuestions(input).isNotEmpty()
170+
171+ private val SavedItem.Tool.pending get() = permission == "pending"
172+
173+ /** A tool call waiting on an approval (not a question form). */
174+ class Approval(val ix: Int, val tool: SavedItem.Tool, val count: Int)
175+
176+ /** The first tool call waiting on an approval, with how many wait in all. */
177+ fun pendingApproval(items: List<SavedItem>): Approval? {
178+ val pending = items.withIndex().filter { (_, item) -> item is SavedItem.Tool && item.pending && !item.isAsk() }
179+ val (ix, tool) = pending.firstOrNull() ?: return null
180+ return Approval(ix, tool as SavedItem.Tool, pending.size)
181+ }
182+
183+ /** The plan Claude asks to start on, if ExitPlanMode waits for an answer. */
184+ fun pendingPlan(items: List<SavedItem>): Approval? = pendingApproval(items)?.takeIf { it.tool.name == "ExitPlanMode" }
185+
186+ /** The AskUserQuestion form waiting for answers, if any. */
187+ fun pendingAsk(items: List<SavedItem>): Pair<Int, SavedItem.Tool>? = items.withIndex()
188+ .firstOrNull { (_, item) -> item is SavedItem.Tool && item.pending && item.isAsk() }
189+ ?.let { (ix, item) -> ix to item as SavedItem.Tool }
190+
191+ /** A question from a Pi extension waiting for an answer. */
192+ fun pendingQuestion(items: List<SavedItem>): Pair<Int, SavedItem.Question>? = items.withIndex()
193+ .firstOrNull { (_, item) -> item is SavedItem.Question && item.answer == null }
194+ ?.let { (ix, item) -> ix to item as SavedItem.Question }
195+
196+ /** A plan's title: its first heading, or its first line. */
197+ fun planTitle(tool: SavedItem.Tool): String {
198+ val lines = (tool.input.str("plan") ?: "").lines().map { it.trim() }
199+ val line = lines.firstOrNull { it.startsWith("#") } ?: lines.firstOrNull { it.isNotEmpty() } ?: "Proposed plan"
200+ return line.trimStart('#').trim()
201+ }
202+
203+ /** What kind of approval a tool call asks for, as T3 Code labels it. */
204+ fun approvalLabel(tool: SavedItem.Tool): String = when {
205+ tool.name in setOf("Bash", "BashOutput", "KillShell") -> "Command approval"
206+ tool.name in setOf("Read", "Glob", "Grep", "LS", "NotebookRead") -> "File read approval"
207+ tool.name in setOf("Edit", "MultiEdit", "Write", "NotebookEdit") -> "File change approval"
208+ tool.name.startsWith("mcp__") -> "App permission approval"
209+ else -> "App access approval"
210+ }
211+
212+ private val PrettyJson = kotlinx.serialization.json.Json { prettyPrint = true }
213+
214+ /** The detail an approval shows: the command, the path, or the input. */
215+ fun approvalDetail(tool: SavedItem.Tool): String {
216+ tool.input.str("command")?.let { return it }
217+ for (key in listOf("file_path", "notebook_path", "path", "pattern", "url")) tool.input.str(key)?.let { return it }
218+ val input = tool.input
219+ if (input is JsonObject && input.isEmpty()) return tool.name
220+ return runCatching { PrettyJson.encodeToString(JsonElement.serializer(), input) }.getOrDefault(tool.name)
221+ }
222+
223+ /** Whether the prompt offers to stop asking: Claude's suggestions to accept edits or add a rule. */
224+ fun offersAlways(tool: SavedItem.Tool): Boolean = tool.suggestions.any { suggestion ->
225+ when (suggestion.str("type")) {
226+ "setMode" -> suggestion.str("mode") == "acceptEdits"
227+ "addRules", "replaceRules" -> true
228+ else -> false
229+ }
230+ }
231+
232+ enum class StepStatus { Pending, InProgress, Completed }
233+
234+ /** A step of the agent's task list (its latest TodoWrite). */
235+ class TaskStep(val text: String, val status: StepStatus)
236+
237+ /** The agent's task list, from the thread's latest TodoWrite call. */
238+ fun taskSteps(items: List<SavedItem>): List<TaskStep> {
239+ val todos = items.asReversed().firstNotNullOfOrNull { item ->
240+ (item as? SavedItem.Tool)?.takeIf { it.name == "TodoWrite" }?.let { (it.input as? JsonObject)?.get("todos") as? JsonArray }
241+ } ?: return emptyList()
242+ return todos.mapNotNull { todo ->
243+ val status = when (todo.str("status")) {
244+ "completed" -> StepStatus.Completed
245+ "in_progress" -> StepStatus.InProgress
246+ else -> StepStatus.Pending
247+ }
248+ // The form the agent shows while it works on a step reads better.
249+ val text = if (status == StepStatus.InProgress) todo.str("activeForm") ?: todo.str("content") else todo.str("content")
250+ text?.let { TaskStep(it, status) }
251+ }
252+ }
253+
254+ // ---- the `/` and `$` menus -----------------------------------------------------
255+
256+ /** The word at the cursor that opens a menu: its trigger, where it starts, and what follows the trigger. */
257+ data class CommandWord(val trigger: Char, val start: Int, val query: String)
258+
259+ /** `/` at the start of a line lists commands (and skills, when set to); `$` lists skills. */
260+ fun commandWord(value: TextFieldValue): CommandWord? {
261+ val text = value.text
262+ val cursor = value.selection.end.coerceIn(0, text.length)
263+ val before = text.substring(0, cursor)
264+ val start = before.indexOfLast { it.isWhitespace() } + 1
265+ val word = before.substring(start)
266+ val trigger = word.firstOrNull()?.takeIf { it == '/' || it == '$' } ?: return null
267+ // `/` only opens a command at the start of a line.
268+ if (trigger == '/' && start > 0 && before[start - 1] != '\n') return null
269+ return CommandWord(trigger, start, word.substring(1).lowercase())
270+ }
271+
272+ /** The most rows the menus list. */
273+ const val MenuRows = 40
274+
275+ /** The commands the menu lists for a word: those starting with it first. */
276+ fun commandItems(commands: List<RemoteCommand>, word: CommandWord, skillsToo: Boolean): List<RemoteCommand> {
277+ val matching = commands.filter { command ->
278+ val wanted = if (word.trigger == '$') command.skill else !command.skill || skillsToo
279+ wanted && command.name.lowercase().contains(word.query)
280+ }
281+ val (prefix, inside) = matching.partition { it.name.lowercase().startsWith(word.query) }
282+ return (prefix + inside).take(MenuRows)
283+ }
284+
285+ // ---- the footer ----------------------------------------------------------------
286+
287+ /** About how wide a character of a footer label is. */
288+ const val CharWidth = 7f
289+
290+ /**
291+ * How much of the footer's left side shows: blocks (traits, then mode)
292+ * lose their labels from the last, then move into the "…" menu.
293+ */
294+ data class FooterLayout(val iconOnly: Int = 0, val hidden: Int = 0)
295+
296+ /** Fits the footer's controls into the composer's width, as the desktop does. */
297+ fun footerLayout(width: Float, blocks: List<Pair<Float, Float>>, modelWidth: Float, rightWidth: Float): FooterLayout {
298+ if (width <= 0f) return FooterLayout()
299+ val available = width - 34f - rightWidth - 8f
300+ fun fits(layout: FooterLayout): Boolean {
301+ var used = modelWidth
302+ val shown = blocks.size - layout.hidden
303+ blocks.take(shown).forEachIndexed { ix, (full, icon) ->
304+ val iconOnly = ix >= blocks.size - minOf(layout.iconOnly, blocks.size)
305+ used += 5f + if (iconOnly) icon else full
306+ }
307+ if (layout.hidden > 0) used += 32f
308+ return used <= available
309+ }
310+ var layout = FooterLayout()
311+ while (!fits(layout)) {
312+ layout = when {
313+ layout.iconOnly < blocks.size -> layout.copy(iconOnly = layout.iconOnly + 1)
314+ layout.hidden < blocks.size -> layout.copy(hidden = layout.hidden + 1)
315+ else -> return layout
316+ }
317+ }
318+ return layout
319+ }
320+
321+ // ---- time ----------------------------------------------------------------------
322+
323+ /**
324+ * A moment near now, as briefly as it reads clearly: `14:32`, `tomorrow
325+ * 09:00`, `Fri 09:00`, `Oct 21, 09:00`. `twelveHours` as the time format says.
326+ */
327+ fun whenText(millis: Long, twelveHours: Boolean, zone: ZoneId = ZoneId.systemDefault()): String {
328+ val local = Instant.ofEpochMilli(millis).atZone(zone)
329+ val today = LocalDate.now(zone)
330+ val days = ChronoUnit.DAYS.between(today, local.toLocalDate())
331+ val clock = local.format(DateTimeFormatter.ofPattern(if (twelveHours) "h:mm a" else "HH:mm", Locale.US))
332+ return when {
333+ days == 0L -> clock
334+ days == 1L -> "tomorrow $clock"
335+ days == -1L -> "yesterday $clock"
336+ days in 2..6 -> "${local.format(DateTimeFormatter.ofPattern("EEE", Locale.US))} $clock"
337+ local.year == today.year -> "${local.format(DateTimeFormatter.ofPattern("MMM d", Locale.US))}, $clock"
338+ else -> "${local.format(DateTimeFormatter.ofPattern("MMM d yyyy", Locale.US))}, $clock"
339+ }
340+ }
341+
342+ /** `12 KB`, `3.4 MB`. */
343+ fun formatSize(bytes: Long): String = when {
344+ bytes < 1024 -> "$bytes B"
345+ bytes < 1024 * 1024 -> String.format(Locale.ROOT, "%.0f KB", bytes / 1024.0)
346+ else -> String.format(Locale.ROOT, "%.1f MB", bytes / (1024.0 * 1024.0))
347+ }
348+
349+ /** A JSON setting's string value, or `null` for a missing or `null` one. */
350+ fun JsonElement?.stringValue(): String? = (this as? JsonPrimitive)?.contentOrNull
+293−5android/app/src/main/java/cc/vectrum/code/ui/composer/NewThreadScreen.ktView File
@@ -1,19 +1,307 @@
1+ @file:OptIn(ExperimentalLayoutApi::class)
2+
13 package cc.vectrum.code.ui.composer
24
3- import androidx.compose.foundation.layout.Box
5+ import androidx.compose.foundation.layout.Arrangement
6+ import androidx.compose.foundation.layout.BoxWithConstraints
7+ import androidx.compose.foundation.layout.Column
8+ import androidx.compose.foundation.layout.ExperimentalLayoutApi
9+ import androidx.compose.foundation.layout.FlowRow
10+ import androidx.compose.foundation.layout.Row
11+ import androidx.compose.foundation.layout.Spacer
12+ import androidx.compose.foundation.layout.fillMaxSize
13+ import androidx.compose.foundation.layout.fillMaxWidth
14+ import androidx.compose.foundation.layout.height
15+ import androidx.compose.foundation.layout.heightIn
416 import androidx.compose.foundation.layout.padding
17+ import androidx.compose.foundation.layout.widthIn
18+ import androidx.compose.foundation.rememberScrollState
19+ import androidx.compose.foundation.verticalScroll
520 import androidx.compose.runtime.Composable
21+ import androidx.compose.runtime.LaunchedEffect
22+ import androidx.compose.runtime.getValue
23+ import androidx.compose.runtime.mutableStateOf
24+ import androidx.compose.runtime.remember
25+ import androidx.compose.runtime.setValue
26+ import androidx.compose.ui.Alignment
627 import androidx.compose.ui.Modifier
28+ import androidx.compose.ui.draw.drawBehind
29+ import androidx.compose.ui.geometry.Offset
30+ import androidx.compose.ui.graphics.PathEffect
31+ import androidx.compose.ui.platform.LocalConfiguration
32+ import androidx.compose.ui.text.TextStyle
33+ import androidx.compose.ui.text.style.TextAlign
734 import androidx.compose.ui.unit.dp
35+ import androidx.compose.ui.unit.sp
36+ import androidx.compose.ui.zIndex
837 import cc.vectrum.code.data.AppState
9- import cc.vectrum.code.data.OpenThread
38+ import cc.vectrum.code.data.HostState
1039 import cc.vectrum.code.data.ThreadRef
11- import cc.vectrum.code.theme.Engraved
40+ import cc.vectrum.code.net.ClientMessage
41+ import cc.vectrum.code.net.ConnectionStatus
42+ import cc.vectrum.code.net.ProjectInfo
43+ import cc.vectrum.code.theme.Badge
44+ import cc.vectrum.code.theme.Cap
45+ import cc.vectrum.code.theme.CapKind
46+ import cc.vectrum.code.theme.CapSize
47+ import cc.vectrum.code.theme.Led
48+ import cc.vectrum.code.theme.StatusLed
49+ import cc.vectrum.code.theme.Txt
50+ import cc.vectrum.code.theme.palette
51+ import cc.vectrum.code.ui.BottomSheet
52+ import cc.vectrum.code.ui.Lucide
53+ import cc.vectrum.code.ui.MenuItem
54+ import cc.vectrum.code.ui.MenuLabel
55+ import cc.vectrum.code.ui.MenuRow
56+ import cc.vectrum.code.ui.MenuSeparator
1257 import cc.vectrum.code.ui.Nav
1358 import cc.vectrum.code.ui.Screen
59+ import cc.vectrum.code.ui.pair.AddProjectSheet
60+ import cc.vectrum.code.util.initials
61+ import cc.vectrum.code.util.tintFor
62+ import kotlinx.coroutines.delay
63+
64+ // A new thread's start screen (the desktop's `render_start` in
65+ // src/ui/chat.rs): "What should we build in <project>?" with the project as
66+ // a switcher, the composer, and the strip under it. The thread starts on
67+ // the host once the first message is sent.
68+
69+ /** The project a new thread starts in: a host and one of its projects. */
70+ private data class Pick(val host: String, val project: String)
1471
15- // Placeholder until it's built.
1672 @Composable
1773 fun NewThreadScreen(app: AppState, nav: Nav, screen: Screen.NewThread) {
18- Box(Modifier.padding(16.dp)) { Engraved("New thread") }
74+ val p = palette
75+ val connected = app.hosts.filter { it.connected }
76+ val withProjects = connected.filter { it.projects.isNotEmpty() }
77+ var picked by remember(screen) {
78+ mutableStateOf(if (screen.host != null && screen.project != null) Pick(screen.host, screen.project) else null)
79+ }
80+ // The picked project if it's still there, else the one of the latest thread, else the first.
81+ val pick = picked?.takeIf { pick -> withProjects.any { it.saved.id == pick.host && it.projects.any { project -> project.path == pick.project } } }
82+ ?: withProjects.flatMap { host -> host.threads.values.map { host to it } }
83+ .filter { (host, thread) -> host.projects.any { it.path == thread.project } }
84+ .maxByOrNull { it.second.updated }
85+ ?.let { (host, thread) -> Pick(host.saved.id, thread.project) }
86+ ?: withProjects.firstOrNull()?.let { Pick(it.saved.id, it.projects.first().path) }
87+ val host = pick?.let { app.host(it.host) }
88+ val project = host?.projects?.find { it.path == pick.project }
89+ var addProject by remember { mutableStateOf(false) }
90+
91+ Column(Modifier.fillMaxSize()) {
92+ TopRow(nav)
93+ BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
94+ Column(
95+ Modifier
96+ .fillMaxWidth()
97+ .verticalScroll(rememberScrollState())
98+ .heightIn(min = maxHeight)
99+ .padding(bottom = 48.dp),
100+ verticalArrangement = Arrangement.Center,
101+ horizontalAlignment = Alignment.CenterHorizontally,
102+ ) {
103+ when {
104+ host == null || project == null -> Empty(app, nav, connected) { addProject = true }
105+ else -> Start(app, nav, host, project, withProjects, onPick = { picked = it }, onAddProject = { addProject = true })
106+ }
107+ }
108+ }
109+ }
110+ AddProjectSheet(app, host ?: connected.firstOrNull(), addProject) { addProject = false }
111+ }
112+
113+ /** The sidebar's button, on a phone where the sidebar is a drawer. */
114+ @Composable
115+ private fun TopRow(nav: Nav) {
116+ if (LocalConfiguration.current.screenWidthDp >= 840) return
117+ Row(Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
118+ Cap(icon = Lucide.PanelLeftOpen, kind = CapKind.Ghost, size = CapSize.Regular) { nav.sidebarOpen = true }
119+ }
120+ }
121+
122+ /** No host to start a thread on, or one without projects. */
123+ @Composable
124+ private fun Empty(app: AppState, nav: Nav, connected: List<HostState>, onAddProject: () -> Unit) {
125+ val p = palette
126+ val connecting = app.hosts.filter { it.status is ConnectionStatus.Connecting }
127+ Column(
128+ Modifier.padding(horizontal = 32.dp).widthIn(max = 420.dp),
129+ horizontalAlignment = Alignment.CenterHorizontally,
130+ verticalArrangement = Arrangement.spacedBy(8.dp),
131+ ) {
132+ when {
133+ connected.isNotEmpty() -> {
134+ Txt("No projects yet", size = 20.sp, lineHeight = 28.sp)
135+ Txt(
136+ "Add a project on ${connected.first().name} to start a thread in it.",
137+ color = p.ink3,
138+ size = 14.sp,
139+ lineHeight = 20.sp,
140+ style = TextStyle(textAlign = TextAlign.Center),
141+ )
142+ Spacer(Modifier.height(8.dp))
143+ Cap(label = "Add project", icon = Lucide.FolderPlus, kind = CapKind.Primary, onClick = onAddProject)
144+ }
145+ connecting.isNotEmpty() -> {
146+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
147+ StatusLed(Led.Waiting, pulse = true)
148+ Txt("Connecting to ${connecting.first().name}…", size = 16.sp)
149+ }
150+ }
151+ else -> {
152+ Txt("Not connected", size = 20.sp, lineHeight = 28.sp)
153+ Txt(
154+ "Threads run on a computer with Vectrum Code. Connect to one to start a thread.",
155+ color = p.ink3,
156+ size = 14.sp,
157+ lineHeight = 20.sp,
158+ style = TextStyle(textAlign = TextAlign.Center),
159+ )
160+ Spacer(Modifier.height(8.dp))
161+ Cap(label = "Connections", icon = Lucide.Network, kind = CapKind.Primary) { nav.go(Screen.Settings("connections")) }
162+ }
163+ }
164+ }
165+ }
166+
167+ @Composable
168+ private fun Start(
169+ app: AppState,
170+ nav: Nav,
171+ host: HostState,
172+ project: ProjectInfo,
173+ hosts: List<HostState>,
174+ onPick: (Pick) -> Unit,
175+ onAddProject: () -> Unit,
176+ ) {
177+ val p = palette
178+ val state = ComposerState.of("new")
179+ // A host that didn't list its agents yet gets its defaults again once it does.
180+ val picks = remember(host.saved.id, project.path, host.instances.isEmpty()) {
181+ newThreadDefaults(host, project.path, project.git)
182+ }
183+ var projectSheet by remember { mutableStateOf(false) }
184+ var sending by remember { mutableStateOf(false) }
185+ LaunchedEffect(sending) {
186+ // The host answers with the thread, or with an error the notice shows.
187+ if (sending) {
188+ delay(15_000)
189+ sending = false
190+ }
191+ }
192+
193+ FlowRow(
194+ Modifier.padding(horizontal = 24.dp),
195+ horizontalArrangement = Arrangement.Center,
196+ verticalArrangement = Arrangement.Center,
197+ ) {
198+ val heading = if (LocalConfiguration.current.screenWidthDp < 600) 26.sp else 30.sp
199+ Txt("What should we build in ", Modifier.align(Alignment.CenterVertically), size = heading, lineHeight = 36.sp)
200+ val rule = p.ink3.copy(alpha = 0.6f)
201+ Row(verticalAlignment = Alignment.CenterVertically) {
202+ Txt(
203+ project.name,
204+ Modifier
205+ .widthIn(max = 256.dp)
206+ .pressable { projectSheet = true }
207+ .padding(horizontal = 2.dp)
208+ .drawBehind {
209+ val y = size.height - 1.dp.toPx() / 2
210+ drawLine(
211+ rule,
212+ Offset(0f, y),
213+ Offset(size.width, y),
214+ strokeWidth = 1.dp.toPx(),
215+ pathEffect = PathEffect.dashPathEffect(floatArrayOf(4.dp.toPx(), 3.dp.toPx())),
216+ )
217+ },
218+ size = heading,
219+ lineHeight = 36.sp,
220+ maxLines = 1,
221+ )
222+ Txt("?", size = heading, lineHeight = 36.sp)
223+ }
224+ }
225+ Spacer(Modifier.height(24.dp))
226+
227+ val instance = host.instances.find { it.id == picks.instance }
228+ val noProvider = host.instances.isNotEmpty() && host.instances.none { it.status != "disabled" }
229+ val target = picks.target(host)
230+
231+ fun start() {
232+ val text = state.text.text.trim()
233+ if (!state.hasInput || sending) return
234+ if (!host.connected) {
235+ app.showNotice("${host.name} is offline.", error = true)
236+ return
237+ }
238+ sending = true
239+ host.create(
240+ ClientMessage.Create(
241+ reference = 0,
242+ project = project.path,
243+ instance = picks.instance,
244+ provider = picks.provider,
245+ model = picks.model,
246+ permissionMode = picks.permissionMode,
247+ reasoning = picks.reasoning,
248+ fastMode = picks.fastMode,
249+ worktree = picks.worktree && project.git,
250+ base = if (picks.worktree && project.git) picks.base else null,
251+ text = text,
252+ attachments = state.attachments.map { it.upload() },
253+ ),
254+ ) { id ->
255+ sending = false
256+ state.clear()
257+ nav.openThread(ThreadRef(host.saved.id, id))
258+ }
259+ }
260+
261+ Column(Modifier.composerColumn().padding(horizontal = Inset)) {
262+ if (instance != null) CommandMenu(app, state, instance.commands)
263+ }
264+ Column(Modifier.composerColumn()) {
265+ ComposerFrame(
266+ app = app,
267+ state = state,
268+ target = target,
269+ placeholder = if (noProvider) "Enable a provider in Settings to send a message" else DefaultPlaceholder,
270+ rightWidth = 68f,
271+ modifier = Modifier.zIndex(1f),
272+ onOpenProviders = if (noProvider) ({ nav.go(Screen.Settings("providers")) }) else null,
273+ ) {
274+ SendCap(if (sending) Lucide.LoaderCircle else Lucide.ArrowUp, enabled = state.hasInput && !sending, onClick = ::start)
275+ }
276+ NewThreadStrip(host, project, picks, hosts, Modifier.tuckUnder(16.dp)) { machine ->
277+ // The same project on that machine, or its first one.
278+ val same = machine.projects.find { it.name == project.name } ?: machine.projects.first()
279+ onPick(Pick(machine.saved.id, same.path))
280+ }
281+ }
282+
283+ BottomSheet(projectSheet, { projectSheet = false }) {
284+ val many = hosts.size > 1
285+ for ((ix, each) in hosts.withIndex()) {
286+ if (many) {
287+ if (ix > 0) MenuSeparator()
288+ MenuLabel(each.name)
289+ }
290+ for (candidate in each.projects) {
291+ val selected = each == host && candidate.path == project.path
292+ MenuItem(selected = selected, onClick = {
293+ projectSheet = false
294+ onPick(Pick(each.saved.id, candidate.path))
295+ }) {
296+ Badge(initials(candidate.name), tintFor(candidate.name))
297+ Txt(candidate.name, Modifier.weight(1f), size = 13.5.sp, maxLines = 1)
298+ }
299+ }
300+ }
301+ MenuSeparator()
302+ MenuRow("Add project", icon = Lucide.FolderPlus) {
303+ projectSheet = false
304+ onAddProject()
305+ }
306+ }
19307 }
+561−0android/app/src/main/java/cc/vectrum/code/ui/composer/Pickers.ktaddedView File
@@ -0,0 +1,561 @@
1+ package cc.vectrum.code.ui.composer
2+
3+ import androidx.compose.foundation.background
4+ import androidx.compose.foundation.layout.Arrangement
5+ import androidx.compose.foundation.layout.Box
6+ import androidx.compose.foundation.layout.Column
7+ import androidx.compose.foundation.layout.ColumnScope
8+ import androidx.compose.foundation.layout.Row
9+ import androidx.compose.foundation.layout.Spacer
10+ import androidx.compose.foundation.layout.defaultMinSize
11+ import androidx.compose.foundation.layout.fillMaxHeight
12+ import androidx.compose.foundation.layout.fillMaxWidth
13+ import androidx.compose.foundation.layout.height
14+ import androidx.compose.foundation.layout.offset
15+ import androidx.compose.foundation.layout.padding
16+ import androidx.compose.foundation.layout.size
17+ import androidx.compose.foundation.layout.width
18+ import androidx.compose.foundation.layout.widthIn
19+ import androidx.compose.foundation.rememberScrollState
20+ import androidx.compose.foundation.shape.CircleShape
21+ import androidx.compose.foundation.shape.RoundedCornerShape
22+ import androidx.compose.foundation.text.BasicTextField
23+ import androidx.compose.foundation.verticalScroll
24+ import androidx.compose.runtime.Composable
25+ import androidx.compose.runtime.getValue
26+ import androidx.compose.runtime.mutableStateListOf
27+ import androidx.compose.runtime.mutableStateOf
28+ import androidx.compose.runtime.remember
29+ import androidx.compose.runtime.setValue
30+ import androidx.compose.ui.Alignment
31+ import androidx.compose.ui.Modifier
32+ import androidx.compose.ui.graphics.SolidColor
33+ import androidx.compose.ui.text.TextStyle
34+ import androidx.compose.ui.text.font.FontWeight
35+ import androidx.compose.ui.text.style.TextAlign
36+ import androidx.compose.ui.unit.dp
37+ import androidx.compose.ui.unit.sp
38+ import cc.vectrum.code.data.AppState
39+ import cc.vectrum.code.net.RemoteInstance
40+ import cc.vectrum.code.net.RemoteModel
41+ import cc.vectrum.code.theme.Rule
42+ import cc.vectrum.code.theme.Sans
43+ import cc.vectrum.code.theme.Txt
44+ import cc.vectrum.code.theme.metal
45+ import cc.vectrum.code.theme.palette
46+ import cc.vectrum.code.ui.BottomSheet
47+ import cc.vectrum.code.ui.Lucide
48+ import cc.vectrum.code.ui.LucideIcon
49+ import cc.vectrum.code.ui.MenuItem
50+ import cc.vectrum.code.ui.MenuLabel
51+ import cc.vectrum.code.ui.MenuSeparator
52+ import cc.vectrum.code.ui.OutlineBadge
53+
54+ // The composer's pickers, laid out like T3 Code's (src/ui/pickers.rs): a
55+ // model picker spanning every provider (a rail of providers with favorites
56+ // on top, a search field, and the models), the traits menu for reasoning
57+ // and fast mode, and the access menu. On a phone they rise as sheets.
58+
59+ /** A provider's mark, at `size`. */
60+ @Composable
61+ fun ProviderGlyph(provider: String, size: androidx.compose.ui.unit.Dp) {
62+ LucideIcon(providerIcon(provider), size = size, tint = if (provider == "claude") ClaudeOrange else palette.ink)
63+ }
64+
65+ /** The pickers the composer's footer opens. */
66+ enum class PickerSheet { Model, Traits, Access, Overflow }
67+
68+ /**
69+ * The footer's left side: the model, traits and access controls, folding
70+ * into "…" when they don't fit `width` (dp) beside `rightWidth` of actions.
71+ */
72+ @Composable
73+ fun FooterControls(
74+ target: PickTarget,
75+ width: Float,
76+ rightWidth: Float,
77+ planToggle: Boolean,
78+ modifier: Modifier = Modifier,
79+ onOpenProviders: (() -> Unit)?,
80+ onOpen: (PickerSheet) -> Unit,
81+ ) {
82+ // Without an agent to talk to, the footer only offers to set one up.
83+ if (onOpenProviders != null) {
84+ Row(modifier) {
85+ Control(icon = Lucide.CircleAlert, label = "Open provider settings", caret = false, onClick = onOpenProviders)
86+ }
87+ return
88+ }
89+ val layout = footerFit(target, width, rightWidth, planToggle)
90+ Row(modifier, horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
91+ Control(
92+ Modifier.weight(1f, fill = false),
93+ label = target.modelLabel,
94+ labelWidth = 200.dp,
95+ onClick = { onOpen(PickerSheet.Model) },
96+ icon = providerIcon(target.provider),
97+ iconTint = if (target.provider == "claude") ClaudeOrange else palette.ink,
98+ )
99+ if (layout.traits != null && layout.traitsShown) {
100+ ControlSeparator()
101+ Control(
102+ icon = Lucide.Brain,
103+ label = if (layout.traitsIconOnly) null else layout.traits,
104+ onClick = { onOpen(PickerSheet.Traits) },
105+ )
106+ }
107+ if (layout.modeShown) {
108+ ControlSeparator()
109+ Control(
110+ icon = accessIcon(target.access),
111+ label = if (layout.modeIconOnly) null else accessLabel(target.access),
112+ onClick = { onOpen(PickerSheet.Access) },
113+ )
114+ if (planToggle) {
115+ ControlSeparator()
116+ Control(
117+ icon = if (target.planning) Lucide.PencilRuler else Lucide.Bot,
118+ label = if (layout.modeIconOnly) null else if (target.planning) "Plan" else "Build",
119+ caret = false,
120+ selected = target.planning,
121+ onClick = { target.setPlanning(!target.planning) },
122+ )
123+ }
124+ }
125+ if (layout.anyHidden) {
126+ Control(icon = Lucide.Ellipsis, caret = false, onClick = { onOpen(PickerSheet.Overflow) })
127+ }
128+ }
129+ }
130+
131+ /** What of the footer shows, worked out as the desktop's `footer_layout`. */
132+ class FooterFit(
133+ val traits: String?,
134+ val traitsShown: Boolean,
135+ val traitsIconOnly: Boolean,
136+ val modeShown: Boolean,
137+ val modeIconOnly: Boolean,
138+ ) {
139+ val anyHidden get() = (traits != null && !traitsShown) || !modeShown
140+ }
141+
142+ fun footerFit(target: PickTarget, width: Float, rightWidth: Float, planToggle: Boolean): FooterFit {
143+ val model = target.entry
144+ val traits = if (hasTraits(target.provider, model)) traitsLabel(target.provider, target.reasoning, target.fastMode, model) else null
145+ fun labelWidth(label: String) = label.length * CharWidth
146+ val modelWidth = 52f + minOf(labelWidth(target.modelLabel), 200f)
147+ val blocks = mutableListOf<Pair<Float, Float>>()
148+ if (traits != null) blocks.add(54f + labelWidth(traits) to 46f)
149+ var modeWidth = 54f + labelWidth(accessLabel(target.access))
150+ if (planToggle) modeWidth += 5f + 70f
151+ blocks.add(modeWidth to 46f + if (planToggle) 37f else 0f)
152+ val layout = footerLayout(width, blocks, modelWidth, rightWidth)
153+ fun shown(ix: Int) = ix < blocks.size - layout.hidden
154+ fun iconOnly(ix: Int) = ix >= blocks.size - minOf(layout.iconOnly, blocks.size)
155+ val modeIx = blocks.size - 1
156+ return FooterFit(
157+ traits = traits,
158+ traitsShown = traits != null && shown(0),
159+ traitsIconOnly = traits != null && iconOnly(0),
160+ modeShown = shown(modeIx),
161+ modeIconOnly = iconOnly(modeIx),
162+ )
163+ }
164+
165+ /** The footer's sheets: the model picker, the traits and access menus, and "…". */
166+ @Composable
167+ fun PickerSheets(app: AppState, open: PickerSheet?, target: PickTarget, planToggle: Boolean, fit: FooterFit, onDismiss: () -> Unit) {
168+ BottomSheet(open == PickerSheet.Model, onDismiss) {
169+ ModelPicker(app, target, onDismiss)
170+ }
171+ BottomSheet(open == PickerSheet.Traits, onDismiss) {
172+ TraitsItems(target, onDismiss)
173+ }
174+ BottomSheet(open == PickerSheet.Access, onDismiss) {
175+ AccessItems(target, onDismiss)
176+ }
177+ BottomSheet(open == PickerSheet.Overflow, onDismiss) {
178+ if (fit.traits != null && !fit.traitsShown) {
179+ TraitsItems(target, onDismiss)
180+ MenuSeparator()
181+ }
182+ if (!fit.modeShown && planToggle) {
183+ MenuLabel("Mode")
184+ MenuItem(selected = !target.planning, onClick = { if (target.planning) target.setPlanning(false) }) {
185+ Txt("Chat", size = 13.5.sp, maxLines = 1)
186+ }
187+ MenuItem(selected = target.planning, onClick = { if (!target.planning) target.setPlanning(true) }) {
188+ Txt("Plan", size = 13.5.sp, maxLines = 1)
189+ }
190+ MenuSeparator()
191+ }
192+ MenuLabel("Access")
193+ for (mode in accessModes(target.provider)) {
194+ MenuItem(selected = mode == target.access, onClick = { target.setAccess(mode) }) {
195+ Txt(accessLabel(mode), size = 13.5.sp, maxLines = 1)
196+ }
197+ }
198+ }
199+ }
200+
201+ /**
202+ * The traits menu's groups: a "Reasoning" group with each level (the
203+ * model's default marked), Claude's Ultracode and Ultrathink, then "Fast
204+ * Mode" On and Off where the model has it.
205+ */
206+ @Composable
207+ fun ColumnScope.TraitsItems(target: PickTarget, close: () -> Unit) {
208+ val p = palette
209+ val provider = target.provider
210+ val model = target.entry
211+ val levels = reasoningLevels(provider, model)
212+ val default = defaultLevel(provider, model)
213+ val current = if (target.reasoning == "default") default ?: "default" else target.reasoning
214+
215+ @Composable
216+ fun item(reasoning: String, label: String, description: String?, isDefault: Boolean) {
217+ MenuItem(selected = current == reasoning, onClick = {
218+ target.setReasoning(reasoning)
219+ close()
220+ }) {
221+ Column(Modifier.weight(1f)) {
222+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
223+ Txt(label, Modifier.weight(1f, fill = false), size = 13.5.sp, maxLines = 1)
224+ if (isDefault) OutlineBadge("Default")
225+ }
226+ if (description != null) {
227+ Txt(description, Modifier.widthIn(max = 280.dp), color = p.ink3.copy(alpha = 0.8f), size = 12.sp)
228+ }
229+ }
230+ }
231+ }
232+
233+ var any = false
234+ if (levels.isNotEmpty() || provider == "claude") {
235+ any = true
236+ MenuLabel(if (provider == "pi") "Thinking" else "Reasoning")
237+ if (default == null && provider == "claude") item("default", "Default", null, true)
238+ for (level in levels) item(level, effortLabel(level), null, default == level)
239+ if (provider == "claude") {
240+ if (model == null || model.ultracode) {
241+ item("ultracode", "Ultracode", "xhigh effort plus multi-agent workflow orchestration", false)
242+ }
243+ item("ultrathink", "Ultrathink", null, false)
244+ }
245+ }
246+ if (model?.fastMode == true) {
247+ if (any) MenuSeparator()
248+ MenuLabel("Fast Mode")
249+ MenuItem(selected = target.fastMode, onClick = { target.setFastMode(true); close() }) {
250+ Txt("On", size = 13.5.sp, maxLines = 1)
251+ }
252+ MenuItem(selected = !target.fastMode, onClick = { target.setFastMode(false); close() }) {
253+ Txt("Off", size = 13.5.sp, maxLines = 1)
254+ }
255+ }
256+ }
257+
258+ /** The access menu: each mode with its icon, label and description. */
259+ @Composable
260+ fun ColumnScope.AccessItems(target: PickTarget, close: () -> Unit) {
261+ val p = palette
262+ val provider = target.provider
263+ val autoSupported = target.entry?.autoMode ?: true
264+ for (mode in accessModes(provider)) {
265+ val enabled = mode != "auto" || autoSupported
266+ val description = when {
267+ !asksPermission(provider) -> "${agentName(provider)} runs its tools without asking; it has no approval prompts."
268+ enabled -> accessDescription(mode)
269+ else -> "Not available for this model."
270+ }
271+ MenuItem(
272+ Modifier.dimmed(!enabled),
273+ selected = mode == target.access,
274+ enabled = enabled && asksPermission(provider),
275+ onClick = {
276+ target.setAccess(mode)
277+ close()
278+ },
279+ ) {
280+ Column(Modifier.weight(1f).padding(vertical = 2.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
281+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
282+ LucideIcon(accessIcon(mode), size = 14.dp, tint = p.ink3)
283+ Txt(accessLabel(mode), size = 13.5.sp, weight = FontWeight.Medium, maxLines = 1)
284+ }
285+ Txt(description, color = p.ink3, size = 12.sp, lineHeight = 16.sp)
286+ }
287+ }
288+ }
289+ }
290+
291+ /** Which models the picker lists: favorites, or one provider instance's. */
292+ private sealed interface Rail {
293+ data object Favorites : Rail
294+
295+ data class Instance(val id: String) : Rail
296+ }
297+
298+ private fun RemoteInstance.label() = name.ifBlank { providerLabel(provider) }
299+
300+ /**
301+ * The model picker: favorites and each provider on a rail, a search
302+ * field, and the models. Favorites are set on the desktop; here they're
303+ * shown as they are.
304+ */
305+ @Composable
306+ fun ColumnScope.ModelPicker(app: AppState, target: PickTarget, close: () -> Unit) {
307+ val p = palette
308+ val host = target.host
309+ val instances = host?.instances.orEmpty().filter { it.status != "disabled" }
310+ if (host == null || instances.isEmpty()) {
311+ // An older host doesn't list its models: only the thread's own is known.
312+ MenuLabel("Model")
313+ MenuItem(selected = true, onClick = close) {
314+ ProviderGlyph(target.provider, 14.dp)
315+ Txt(target.modelLabel, Modifier.weight(1f), size = 13.5.sp, maxLines = 1)
316+ }
317+ return
318+ }
319+ // Models go with their instance: two accounts of one provider list the same models.
320+ fun find(key: String): Pair<RemoteInstance, RemoteModel>? {
321+ val split = key.indexOf(':')
322+ if (split < 0) return null
323+ val instance = instances.find { it.id == key.substring(0, split) } ?: return null
324+ return instance.models.find { it.id == key.substring(split + 1) }?.let { instance to it }
325+ }
326+ val favoriteKeys = host.favorites.toSet()
327+ val favorites = host.favorites.mapNotNull(::find).filter { !it.second.hidden }
328+ fun isFavorite(entry: Pair<RemoteInstance, RemoteModel>) = "${entry.first.id}:${entry.second.id}" in favoriteKeys
329+
330+ var rail by remember { mutableStateOf<Rail>(if (favorites.isNotEmpty()) Rail.Favorites else Rail.Instance(target.instance)) }
331+ var query by remember { mutableStateOf("") }
332+ val legacyOpen = remember {
333+ mutableStateListOf<String>().apply { if (target.entry?.legacy == true) add(target.instance) }
334+ }
335+ val current = target.entry
336+ val searching = query.isNotBlank()
337+
338+ val words = query.trim().lowercase().split(Regex("\\s+")).filter { it.isNotEmpty() }
339+ val (models, legacy) = when {
340+ searching -> {
341+ // Every word must match somewhere; favorites rank first.
342+ val matches = instances.flatMap { instance -> instance.models.filter { !it.hidden }.map { instance to it } }
343+ .filter { (instance, model) ->
344+ val haystack = "${model.name} ${model.detail} ${model.id} ${providerLabel(instance.provider)} ${instance.label()}".lowercase()
345+ words.all { haystack.contains(it) }
346+ }
347+ .sortedWith(compareBy({ !isFavorite(it) }, { !it.second.name.lowercase().startsWith(words[0]) }))
348+ matches to emptyList()
349+ }
350+ rail == Rail.Favorites -> favorites to emptyList()
351+ else -> {
352+ val id = (rail as Rail.Instance).id
353+ // Favorites float to the top, the rest keep the provider's order.
354+ val instance = instances.find { it.id == id }
355+ val all = instance?.models.orEmpty().filter { !it.hidden }.map { instance!! to it }
356+ all.filter { !it.second.legacy }.sortedBy { !isFavorite(it) } to all.filter { it.second.legacy }
357+ }
358+ }
359+ val railInstance = (rail as? Rail.Instance)?.id
360+ val legacyShown = railInstance != null && railInstance in legacyOpen
361+
362+ @Composable
363+ fun row(entry: Pair<RemoteInstance, RemoteModel>) {
364+ val (instance, model) = entry
365+ // Claude's default entry stands for the current model it resolves to.
366+ val selected = current != null && instance.id == target.instance &&
367+ (current.id == model.id || (current.id == "default" && !model.legacy && current.resolved == model.resolved))
368+ // As in T3 Code, a thread can't move to another provider once it started.
369+ val locked = target.started && instance.id != target.instance
370+ val detail = if (instance.provider == "claude") instance.label() else "${instance.label()} · ${model.detail}"
371+ Row(
372+ Modifier
373+ .fillMaxWidth()
374+ .defaultMinSize(minHeight = 40.dp)
375+ .then(if (selected) Modifier.background(p.ink.copy(alpha = 0.08f), RoundedCornerShape(8.dp)) else Modifier)
376+ .pressable {
377+ if (locked) {
378+ app.showNotice(
379+ "This thread does not support switching providers after it has started. Start a new thread to use this model.",
380+ )
381+ } else {
382+ target.pickModel(instance, model)
383+ close()
384+ }
385+ }
386+ .dimmed(locked)
387+ .padding(horizontal = 8.dp, vertical = 6.dp),
388+ horizontalArrangement = Arrangement.spacedBy(6.dp),
389+ verticalAlignment = Alignment.CenterVertically,
390+ ) {
391+ Column(Modifier.weight(1f)) {
392+ Txt(model.name, size = 12.sp, lineHeight = 16.5.sp, weight = FontWeight.Medium, maxLines = 1)
393+ Row(
394+ Modifier.padding(top = 4.dp),
395+ horizontalArrangement = Arrangement.spacedBy(6.dp),
396+ verticalAlignment = Alignment.CenterVertically,
397+ ) {
398+ ProviderGlyph(instance.provider, 12.dp)
399+ Txt(detail, color = p.ink3.copy(alpha = 0.7f), size = 12.sp, maxLines = 1)
400+ }
401+ }
402+ if (isFavorite(entry)) LucideIcon(Lucide.StarFill, size = 13.dp, tint = p.amber)
403+ }
404+ }
405+
406+ Row(Modifier.fillMaxWidth().height(400.dp).padding(2.dp)) {
407+ if (!searching) {
408+ Column(
409+ Modifier
410+ .width(48.dp)
411+ .fillMaxHeight()
412+ .background(p.ink.copy(alpha = 0.03f), RoundedCornerShape(topStart = 12.dp, bottomStart = 12.dp))
413+ .verticalScroll(rememberScrollState())
414+ .padding(4.dp),
415+ verticalArrangement = Arrangement.spacedBy(4.dp),
416+ ) {
417+ RailButton(rail == Rail.Favorites, { rail = Rail.Favorites; query = "" }) {
418+ LucideIcon(Lucide.StarFill, size = 20.dp, tint = p.ink)
419+ }
420+ Spacer(Modifier.fillMaxWidth().height(1.dp).background(p.rule))
421+ for (instance in instances) {
422+ val shared = instances.count { it.provider == instance.provider } > 1
423+ RailButton(rail == Rail.Instance(instance.id), { rail = Rail.Instance(instance.id); query = "" }) {
424+ Box(Modifier.size(24.dp), contentAlignment = Alignment.Center) {
425+ ProviderGlyph(instance.provider, 20.dp)
426+ if (shared) {
427+ // A badge with the instance's initials tells two instances of a kind apart.
428+ val initials = instance.label().split(Regex("\\s+")).filter { it.isNotEmpty() }.take(2)
429+ .map { it.first() }.joinToString("").uppercase()
430+ Box(
431+ Modifier
432+ .align(Alignment.BottomEnd)
433+ .offset(2.dp, 2.dp)
434+ .height(12.dp)
435+ .defaultMinSize(minWidth = 12.dp)
436+ .metal(CircleShape, p.menuBg, p.raiseSm)
437+ .padding(horizontal = 2.dp),
438+ contentAlignment = Alignment.Center,
439+ ) {
440+ Txt(initials, color = p.ink3, size = 7.sp, weight = FontWeight.SemiBold, maxLines = 1)
441+ }
442+ }
443+ }
444+ }
445+ }
446+ }
447+ Spacer(Modifier.width(1.dp).fillMaxHeight().background(p.rule.copy(alpha = 0.7f)))
448+ }
449+ Column(Modifier.weight(1f).fillMaxHeight().background(p.ink.copy(alpha = 0.02f))) {
450+ Column(Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)) {
451+ Row(
452+ Modifier.fillMaxWidth().padding(bottom = 6.dp),
453+ horizontalArrangement = Arrangement.spacedBy(6.dp),
454+ verticalAlignment = Alignment.CenterVertically,
455+ ) {
456+ LucideIcon(Lucide.Search, size = 16.dp, tint = p.ink3.copy(alpha = 0.55f))
457+ Box(Modifier.weight(1f)) {
458+ if (query.isEmpty()) Txt("Search models...", color = p.fieldInk, size = 14.sp, maxLines = 1)
459+ BasicTextField(
460+ value = query,
461+ onValueChange = { query = it },
462+ singleLine = true,
463+ textStyle = TextStyle(color = p.ink, fontSize = 14.sp, fontFamily = Sans),
464+ cursorBrush = SolidColor(p.ink),
465+ modifier = Modifier.fillMaxWidth(),
466+ )
467+ }
468+ }
469+ Rule()
470+ }
471+ Column(
472+ Modifier
473+ .weight(1f)
474+ .verticalScroll(rememberScrollState())
475+ .padding(start = 8.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
476+ verticalArrangement = Arrangement.spacedBy(2.dp),
477+ ) {
478+ val setup = if (searching || railInstance == null) null else setupText(instances.find { it.id == railInstance })
479+ models.forEach { row(it) }
480+ if (models.isEmpty() && legacy.isEmpty() && setup == null) {
481+ Txt(
482+ "No models found",
483+ Modifier.fillMaxWidth().padding(8.dp),
484+ color = p.ink3,
485+ size = 14.sp,
486+ style = TextStyle(textAlign = TextAlign.Center),
487+ )
488+ }
489+ if (legacy.isNotEmpty() && railInstance != null) {
490+ Row(
491+ Modifier
492+ .fillMaxWidth()
493+ .pressable {
494+ if (legacyShown) legacyOpen.remove(railInstance) else legacyOpen.add(railInstance)
495+ }
496+ .padding(horizontal = 8.dp, vertical = 6.dp),
497+ horizontalArrangement = Arrangement.spacedBy(8.dp),
498+ verticalAlignment = Alignment.CenterVertically,
499+ ) {
500+ Column(Modifier.weight(1f)) {
501+ Txt("Legacy models", size = 12.sp, weight = FontWeight.Medium, maxLines = 1)
502+ Txt(
503+ "${legacy.size} models",
504+ Modifier.padding(top = 4.dp),
505+ color = p.ink3.copy(alpha = 0.7f),
506+ size = 12.sp,
507+ maxLines = 1,
508+ )
509+ }
510+ LucideIcon(if (legacyShown) Lucide.ChevronDown else Lucide.ChevronRight, size = 16.dp, tint = p.ink3)
511+ }
512+ if (legacyShown) legacy.forEach { row(it) }
513+ }
514+ if (setup != null) {
515+ Rule()
516+ Txt(setup, Modifier.padding(12.dp), color = p.ink3, size = 12.sp, lineHeight = 16.5.sp)
517+ }
518+ }
519+ }
520+ }
521+ }
522+
523+ /** Why a provider lists no models, if it doesn't. */
524+ private fun setupText(instance: RemoteInstance?): String? {
525+ instance ?: return null
526+ val label = instance.label()
527+ return when (instance.status) {
528+ "ready" -> if (instance.models.isNotEmpty()) null else "No models are available for this provider."
529+ "disabled" -> "$label is turned off in Settings → Providers."
530+ "checking" -> "Asking $label for its models…"
531+ "missing" -> when (instance.provider) {
532+ "pi" -> "Open provider setup to install Pi on this environment: npm install -g @earendil-works/pi-coding-agent"
533+ "jcode" -> "Open provider setup to install jcode on this environment."
534+ else -> "Open provider setup to install Claude on this environment."
535+ }
536+ else -> "$label provider is unavailable."
537+ }
538+ }
539+
540+ @Composable
541+ private fun RailButton(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
542+ val p = palette
543+ Box(Modifier.fillMaxWidth()) {
544+ Box(
545+ Modifier
546+ .size(40.dp)
547+ .pressable(onClick = onClick),
548+ contentAlignment = Alignment.Center,
549+ ) { content() }
550+ if (selected) {
551+ Spacer(
552+ Modifier
553+ .align(Alignment.TopEnd)
554+ .offset(x = 4.dp, y = 10.dp)
555+ .width(3.dp)
556+ .height(20.dp)
557+ .background(p.greenDeep, RoundedCornerShape(topStart = 2.dp, bottomStart = 2.dp)),
558+ )
559+ }
560+ }
561+ }
+177−0android/app/src/main/java/cc/vectrum/code/ui/composer/State.ktaddedView File
@@ -0,0 +1,177 @@
1+ package cc.vectrum.code.ui.composer
2+
3+ import androidx.compose.runtime.getValue
4+ import androidx.compose.runtime.mutableIntStateOf
5+ import androidx.compose.runtime.mutableStateListOf
6+ import androidx.compose.runtime.mutableStateOf
7+ import androidx.compose.runtime.setValue
8+ import androidx.compose.ui.graphics.ImageBitmap
9+ import androidx.compose.ui.text.TextRange
10+ import androidx.compose.ui.text.input.TextFieldValue
11+ import cc.vectrum.code.data.HostState
12+ import cc.vectrum.code.net.RemoteInstance
13+ import cc.vectrum.code.net.RemoteModel
14+ import cc.vectrum.code.net.Upload
15+
16+ /** A file attached to the message, read and ready to send. */
17+ class Attachment(val name: String, val data: String, val size: Long, val image: ImageBitmap?) {
18+ fun upload() = Upload(name, data)
19+ }
20+
21+ /**
22+ * What a composer holds between screens: the draft, its attachments, and
23+ * how its docked banners are folded. The dock and the composer share it,
24+ * and it outlives a visit to another thread, as the desktop keeps a
25+ * thread's draft.
26+ */
27+ class ComposerState {
28+ var text by mutableStateOf(TextFieldValue(""))
29+ val attachments = mutableStateListOf<Attachment>()
30+
31+ /** The open AskUserQuestion form's answers so far. Its fields are state; the form itself is a cache. */
32+ var ask: AskForm? = null
33+
34+ /** The question the composer holds the answer to, by form and question. */
35+ var askShown: Pair<Int, Int>? = null
36+
37+ /** A question folded away, by form and question. */
38+ var questionCollapsed by mutableStateOf<Pair<Int, Int>?>(null)
39+
40+ /** The option just picked on a single-select question, shown checked until the form moves on. */
41+ var optimisticPick by mutableStateOf<Triple<Int, Int, Int>?>(null)
42+
43+ var queueExpanded by mutableStateOf(true)
44+ var tasksExpanded by mutableStateOf(false)
45+ var noticesExpanded by mutableStateOf(false)
46+
47+ /** The composer text the `/` menu was dismissed at. */
48+ var menuDismissed by mutableStateOf<String?>(null)
49+
50+ /** The access mode plan mode returns to. */
51+ var planReturn by mutableStateOf("default")
52+
53+ /** `/model` asks the composer to open the model picker. */
54+ var modelPickerRequested by mutableStateOf(false)
55+
56+ /** Something to send: text or a file. */
57+ val hasInput: Boolean get() = text.text.isNotBlank() || attachments.isNotEmpty()
58+
59+ fun setText(value: String) {
60+ text = TextFieldValue(value, TextRange(value.length))
61+ }
62+
63+ fun clear() {
64+ setText("")
65+ attachments.clear()
66+ }
67+
68+ /** The form for the question tool at `ix`, kept while it stays open. */
69+ fun form(ix: Int, questions: List<AskQuestion>): AskForm {
70+ val current = ask
71+ if (current != null && current.ix == ix && current.questions.size == questions.size) return current
72+ return AskForm(ix, questions).also { ask = it }
73+ }
74+
75+ companion object {
76+ private val all = mutableMapOf<String, ComposerState>()
77+
78+ /** The composer state of a thread, or of a host's new thread. */
79+ fun of(key: String): ComposerState = all.getOrPut(key) { ComposerState() }
80+ }
81+ }
82+
83+ /** Answers to an AskUserQuestion form, kept on the phone until they're submitted. */
84+ class AskForm(val ix: Int, val questions: List<AskQuestion>) {
85+ /** The question on screen. */
86+ var current by mutableIntStateOf(0)
87+
88+ /** The options selected on each question: at most one on a single-select question. */
89+ val picked = mutableStateListOf<List<Int>>().apply { repeat(questions.size) { add(emptyList()) } }
90+
91+ /** What the user typed as their own answer to each question; a non-empty one wins. */
92+ val custom = mutableStateListOf<String>().apply { repeat(questions.size) { add("") } }
93+
94+ val isLast: Boolean get() = current + 1 >= questions.size
95+
96+ /** The answer question `q` has so far: the user's own words, or the selected options. */
97+ fun resolved(q: Int): String? {
98+ val own = custom.getOrNull(q)?.trim().orEmpty()
99+ if (own.isNotEmpty()) return own
100+ val question = questions.getOrNull(q) ?: return null
101+ val labels = picked.getOrNull(q).orEmpty().sorted().mapNotNull { question.options.getOrNull(it)?.label }
102+ return labels.takeIf { it.isNotEmpty() }?.joinToString(", ")
103+ }
104+
105+ val isComplete: Boolean get() = questions.indices.all { resolved(it) != null }
106+
107+ /** Selects an option on the current question; returns whether it's single-select, which moves on by itself. */
108+ fun toggle(option: Int): Boolean {
109+ val question = questions[current]
110+ if (option >= question.options.size) return false
111+ val now = picked[current]
112+ picked[current] = when {
113+ !question.multiSelect -> listOf(option)
114+ option in now -> now - option
115+ else -> now + option
116+ }
117+ custom[current] = ""
118+ return !question.multiSelect
119+ }
120+
121+ /** Takes what the composer holds as the current question's own answer. */
122+ fun setCustom(text: String) {
123+ if (custom[current] == text) return
124+ if (text.isNotBlank() && questions[current].multiSelect) picked[current] = emptyList()
125+ custom[current] = text
126+ }
127+
128+ /** Moves on once the question has an answer; on the last one, hands the answers to `submit`. */
129+ fun advance(submit: (List<String>) -> Unit) {
130+ if (!isLast) {
131+ if (resolved(current) != null) current += 1
132+ return
133+ }
134+ if (isComplete) submit(questions.indices.mapNotNull { resolved(it) })
135+ }
136+
137+ fun previous() {
138+ if (current > 0) current -= 1
139+ }
140+ }
141+
142+ /**
143+ * What the composer's pickers work on: an open thread's settings, which
144+ * change on the host, or a new thread's, kept here until it starts.
145+ */
146+ class PickTarget(
147+ val host: HostState?,
148+ val instance: String,
149+ val provider: String,
150+ val model: String?,
151+ val reasoning: String,
152+ val fastMode: Boolean,
153+ val permissionMode: String,
154+ val planReturn: String,
155+ /** A started thread can't move to another provider. */
156+ val started: Boolean,
157+ /** The model the thread runs, when the host has no catalog to name it from. */
158+ val fallbackName: String? = null,
159+ val pickModel: (RemoteInstance, RemoteModel) -> Unit,
160+ val setReasoning: (String) -> Unit,
161+ val setFastMode: (Boolean) -> Unit,
162+ val setAccess: (String) -> Unit,
163+ val setPlanning: (Boolean) -> Unit,
164+ ) {
165+ val entry: RemoteModel? get() = host?.model(instance, model)
166+ val planning: Boolean get() = permissionMode == "plan"
167+
168+ /** The access mode shown: Full access for agents that don't ask, the mode plan mode returns to while planning. */
169+ val access: String
170+ get() = when {
171+ !asksPermission(provider) -> "bypassPermissions"
172+ planning -> planReturn
173+ else -> permissionMode
174+ }
175+
176+ val modelLabel: String get() = entry?.name ?: model ?: fallbackName ?: "Choose model"
177+ }
+313−0android/app/src/main/java/cc/vectrum/code/ui/composer/Strip.ktaddedView File

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

+71−0android/app/src/test/java/cc/vectrum/code/ui/composer/ModelTest.ktaddedView File

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.