# Conflicts: # android/scripts/icons.txt
Showing 17 changed files with 3304 additions and 13 deletions.
| @@ -52,6 +52,7 @@ enum class Lucide(val res: Int) { | ||
| 52 | 52 | Contrast(R.drawable.lucide_contrast), |
| 53 | 53 | Copy(R.drawable.lucide_copy), |
| 54 | 54 | CornerDownRight(R.drawable.lucide_corner_down_right), |
| 55 | + CornerUpRight(R.drawable.lucide_corner_up_right), | |
| 55 | 56 | Cpu(R.drawable.lucide_cpu), |
| 56 | 57 | DollarSign(R.drawable.lucide_dollar_sign), |
| 57 | 58 | Ellipsis(R.drawable.lucide_ellipsis), |
| @@ -96,6 +97,7 @@ enum class Lucide(val res: Int) { | ||
| 96 | 97 | List(R.drawable.lucide_list), |
| 97 | 98 | ListChecks(R.drawable.lucide_list_checks), |
| 98 | 99 | ListOrdered(R.drawable.lucide_list_ordered), |
| 100 | + ListPlus(R.drawable.lucide_list_plus), | |
| 99 | 101 | ListTodo(R.drawable.lucide_list_todo), |
| 100 | 102 | LoaderCircle(R.drawable.lucide_loader_circle), |
| 101 | 103 | Lock(R.drawable.lucide_lock), |
| @@ -123,7 +125,9 @@ enum class Lucide(val res: Int) { | ||
| 123 | 125 | PanelRightClose(R.drawable.lucide_panel_right_close), |
| 124 | 126 | PanelRightOpen(R.drawable.lucide_panel_right_open), |
| 125 | 127 | Paperclip(R.drawable.lucide_paperclip), |
| 128 | + PenLine(R.drawable.lucide_pen_line), | |
| 126 | 129 | Pencil(R.drawable.lucide_pencil), |
| 130 | + PencilRuler(R.drawable.lucide_pencil_ruler), | |
| 127 | 131 | Pi(R.drawable.lucide_pi), |
| 128 | 132 | Pin(R.drawable.lucide_pin), |
| 129 | 133 | PinOff(R.drawable.lucide_pin_off), |
| @@ -152,6 +156,7 @@ enum class Lucide(val res: Int) { | ||
| 152 | 156 | SquarePen(R.drawable.lucide_square_pen), |
| 153 | 157 | SquareStop(R.drawable.lucide_square_stop), |
| 154 | 158 | SquareTerminal(R.drawable.lucide_square_terminal), |
| 159 | + StarFill(R.drawable.lucide_star_fill), | |
| 155 | 160 | Sun(R.drawable.lucide_sun), |
| 156 | 161 | SunMoon(R.drawable.lucide_sun_moon), |
| 157 | 162 | Terminal(R.drawable.lucide_terminal), |
| @@ -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 | + } | |
| @@ -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 | |
| @@ -1,19 +1,431 @@ | ||
| 1 | + @file:OptIn(ExperimentalFoundationApi::class) | |
| 2 | + | |
| 1 | 3 | package cc.vectrum.code.ui.composer |
| 2 | 4 | |
| 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 | |
| 3 | 10 | 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 | |
| 4 | 15 | 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 | |
| 5 | 22 | 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 | |
| 6 | 29 | 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 | |
| 7 | 37 | import androidx.compose.ui.unit.dp |
| 38 | + import androidx.compose.ui.unit.sp | |
| 8 | 39 | import cc.vectrum.code.data.AppState |
| 9 | 40 | import cc.vectrum.code.data.OpenThread |
| 10 | 41 | 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 | |
| 12 | 57 | import cc.vectrum.code.ui.Nav |
| 58 | + import cc.vectrum.code.ui.PopupMenu | |
| 59 | + import cc.vectrum.code.ui.MenuRow | |
| 13 | 60 | import cc.vectrum.code.ui.Screen |
| 14 | 61 | |
| 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. */ | |
| 16 | 83 | @Composable |
| 17 | 84 | 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)) } | |
| 19 | 431 | } |
| @@ -1,17 +1,651 @@ | ||
| 1 | 1 | package cc.vectrum.code.ui.composer |
| 2 | 2 | |
| 3 | + import android.text.format.DateFormat | |
| 4 | + import androidx.compose.foundation.background | |
| 5 | + import androidx.compose.foundation.layout.Arrangement | |
| 3 | 6 | import androidx.compose.foundation.layout.Box |
| 7 | + import androidx.compose.foundation.layout.Column | |
| 8 | + import androidx.compose.foundation.layout.Row | |
| 9 | + import androidx.compose.foundation.layout.Spacer | |
| 10 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 11 | + import androidx.compose.foundation.layout.height | |
| 12 | + import androidx.compose.foundation.layout.heightIn | |
| 4 | 13 | 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 | |
| 5 | 21 | 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 | |
| 6 | 28 | 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 | |
| 7 | 33 | import androidx.compose.ui.unit.dp |
| 34 | + import androidx.compose.ui.unit.sp | |
| 8 | 35 | import cc.vectrum.code.data.AppState |
| 9 | 36 | 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 | |
| 12 | 53 | 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 | |
| 14 | 56 | |
| 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. */ | |
| 16 | 594 | @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 | + } | |
| @@ -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 | |
| @@ -1,19 +1,307 @@ | ||
| 1 | + @file:OptIn(ExperimentalLayoutApi::class) | |
| 2 | + | |
| 1 | 3 | package cc.vectrum.code.ui.composer |
| 2 | 4 | |
| 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 | |
| 4 | 16 | 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 | |
| 5 | 20 | 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 | |
| 6 | 27 | 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 | |
| 7 | 34 | import androidx.compose.ui.unit.dp |
| 35 | + import androidx.compose.ui.unit.sp | |
| 36 | + import androidx.compose.ui.zIndex | |
| 8 | 37 | import cc.vectrum.code.data.AppState |
| 9 | - import cc.vectrum.code.data.OpenThread | |
| 38 | + import cc.vectrum.code.data.HostState | |
| 10 | 39 | 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 | |
| 12 | 57 | import cc.vectrum.code.ui.Nav |
| 13 | 58 | 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) | |
| 14 | 71 | |
| 15 | - // Placeholder until it's built. | |
| 16 | 72 | @Composable |
| 17 | 73 | 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 | + } | |
| 19 | 307 | } |
| @@ -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 | + } | |
| @@ -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 | + } | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.