package com.example.aiapp import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp /** * Something a session can be asked to do to itself, rather than something to say to it. * * These are the two this app understands, and understanding them is what lets it show them: a * suggestion while one is being typed, a name in the settings screen that sends one, and a bubble * that stays up while the session is too busy to run it. Anything else beginning with "/" is passed * through, because a dialect's own vocabulary grows without this list. */ data class SessionCommand( /** With the slash, as it is typed and as it is sent. */ val name: String, /** One line, in the suggestion list: what it does, not how. */ val summary: String, /** What follows the name, named for the reader, or null when nothing does. */ val argument: String?, ) { /** What to put in the box when this is picked: ready to send, or ready to be finished. */ fun typed(): String = if (argument == null) name else "$name " } val SESSION_COMMANDS = listOf( SessionCommand( "/compact", "Summarise the conversation so far and carry on from the summary", null, ), SessionCommand( "/clear", "Start fresh: drop the conversation from the session's context, keeping it on screen", null, ), SessionCommand("/rename", "Change what this session is called", "name"), ) /** * The commands worth offering for what has been typed so far. * * Only for a line that starts with a slash and has not yet become a whole command with an argument * -- once there is something after "/rename ", the reader is writing the name and a list of * commands underneath it is in the way. */ fun suggestedCommands(input: String): List { if (!input.startsWith("/") || input.contains(' ')) return emptyList() return SESSION_COMMANDS.filter { it.name.startsWith(input) } } /** * The commands matching what is being typed, above the box they are being typed into. * * Above rather than over: a list that covers the transcript hides what the command is about, and * the reader is usually looking at the thing they mean to act on. */ @Composable fun CommandSuggestions( commands: List, onPick: (SessionCommand) -> Unit, modifier: Modifier = Modifier, ) { if (commands.isEmpty()) return Card(modifier.fillMaxWidth().padding(horizontal = 16.dp)) { Column(Modifier.padding(vertical = 4.dp)) { commands.forEach { command -> Row( Modifier.fillMaxWidth() .clickable { onPick(command) } .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( // The command in the colour commands are, so the suggestion and the bubble // it becomes are visibly the same thing. if (command.argument == null) command.name else "${command.name} <${command.argument}>", style = MaterialTheme.typography.titleSmall, color = commandColor, ) Spacer(Modifier.width(12.dp)) Text( command.summary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } } /** * A command, where the reader put it: at their end of the conversation. * * Blue rather than the colour of something they said, because they did not say it to the model -- * it is an instruction to the session, and the reply to it is the session changing rather than * anything appearing here. * * [waiting] is a command the session is too busy to run yet, which is a state with a spinner and a * reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes. */ @Composable fun CommandBubble(text: String, waiting: Boolean = false) { Box(Modifier.fillMaxWidth()) { Card( colors = CardDefaults.cardColors(containerColor = commandColor), modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp), ) { Column(Modifier.padding(12.dp)) { // Stated beside the fill rather than inherited: a semantic colour has to carry its // own contrast, because the surface under it will not change to rescue it. Text(text, color = MaterialTheme.colorScheme.inverseOnSurface) if (waiting) { Spacer(Modifier.height(6.dp)) Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator( modifier = Modifier.width(12.dp).height(12.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.inverseOnSurface, ) Spacer(Modifier.width(6.dp)) Text( "waiting for this turn to end", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.inverseOnSurface, ) } } } } } }