Android: enhance chat composer with model selection and slash commands

- Add model selection dropdown in menu
- Add slash commands support (/status, /clear, /compact, etc.)
- Add context usage percentage indicator
- Reorganize menu: + icon for attachments, cleaner layout
- Remove inline session picker (moved to drawer)

AI-assisted (Claude) - lightly tested
This commit is contained in:
yishai 2026-01-29 04:18:58 +02:00
parent 55631f83a1
commit f700ebe606

View File

@ -26,17 +26,17 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.ModelTraining
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.automirrored.filled.ScreenShare
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@ -74,9 +74,13 @@ fun ChatComposer(
mainSessionKey: String,
healthOk: Boolean,
thinkingLevel: String,
currentModel: String = "",
availableModels: List<String> = emptyList(),
slashCommands: List<SlashCommand> = defaultSlashCommands,
pendingRunCount: Int,
errorText: String?,
attachments: List<PendingImageAttachment>,
contextUsagePercent: Int? = null,
seamColor: Color = Color(0xFF3B82F6),
talkEnabled: Boolean = false,
onOpenCamera: () -> Unit = {},
@ -85,6 +89,7 @@ fun ChatComposer(
onShareScreen: () -> Unit = {},
onRemoveAttachment: (id: String) -> Unit,
onSetThinkingLevel: (level: String) -> Unit,
onSetModel: (model: String) -> Unit = {},
onSelectSession: (sessionKey: String) -> Unit,
onRefresh: () -> Unit,
onAbort: () -> Unit,
@ -94,13 +99,10 @@ fun ChatComposer(
var input by rememberSaveable { mutableStateOf("") }
var showMenu by remember { mutableStateOf(false) }
var showThinkingMenu by remember { mutableStateOf(false) }
var showSessionMenu by remember { mutableStateOf(false) }
var showModelMenu by remember { mutableStateOf(false) }
var showCommandsMenu by remember { mutableStateOf(false) }
val haptic = LocalHapticFeedback.current
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
val currentSessionLabel =
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
val hasText = input.trim().isNotEmpty()
val canSend = pendingRunCount == 0 && (hasText || attachments.isNotEmpty()) && healthOk
@ -134,14 +136,14 @@ fun ChatComposer(
modifier = Modifier.fillMaxWidth().padding(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Menu button
// Menu button (+)
Box {
IconButton(
onClick = { showMenu = true },
modifier = Modifier.size(44.dp),
) {
Icon(
Icons.Default.Menu,
Icons.Default.Add,
contentDescription = "Menu",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -150,7 +152,7 @@ fun ChatComposer(
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
modifier = Modifier.background(Color(0xFF3A3A4A)), // Brighter shade for contrast
modifier = Modifier.background(Color(0xFF3A3A4A)),
) {
// Attachment buttons row: Camera | Photos | Files
Row(
@ -203,44 +205,50 @@ fun ChatComposer(
leadingIcon = { Icon(Icons.AutoMirrored.Filled.ScreenShare, contentDescription = null, tint = Color.White.copy(alpha = 0.8f)) },
)
// Settings (Session & Thinking)
var showSettingsSubmenu by remember { mutableStateOf(false) }
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
color = Color.White.copy(alpha = 0.15f),
)
// Model selection
DropdownMenuItem(
text = { Text("Settings", color = Color.White) },
text = { Text("Model", color = Color.White) },
onClick = {
showSettingsSubmenu = !showSettingsSubmenu
showMenu = false
showModelMenu = true
},
leadingIcon = { Icon(Icons.Default.Tune, contentDescription = null, tint = Color.White.copy(alpha = 0.8f)) },
leadingIcon = { Icon(Icons.Default.ModelTraining, contentDescription = null, tint = Color.White.copy(alpha = 0.8f)) },
trailingIcon = {
Text(
if (showSettingsSubmenu) "" else "",
color = Color.White.copy(alpha = 0.5f),
style = MaterialTheme.typography.bodySmall
modelDisplayName(currentModel),
color = Color.White.copy(alpha = 0.5f),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
)
},
)
if (showSettingsSubmenu) {
// Session selector (indented)
DropdownMenuItem(
text = { Text(" Session", color = Color.White.copy(alpha = 0.9f)) },
onClick = {
showMenu = false
showSessionMenu = true
},
trailingIcon = { Text(currentSessionLabel, color = Color.White.copy(alpha = 0.5f), style = MaterialTheme.typography.labelSmall) },
)
// Thinking level (indented)
DropdownMenuItem(
text = { Text(" Thinking", color = Color.White.copy(alpha = 0.9f)) },
onClick = {
showMenu = false
showThinkingMenu = true
},
trailingIcon = { Text(thinkingLabel(thinkingLevel), color = Color.White.copy(alpha = 0.5f), style = MaterialTheme.typography.labelSmall) },
)
}
// Thinking level
DropdownMenuItem(
text = { Text("Thinking", color = Color.White) },
onClick = {
showMenu = false
showThinkingMenu = true
},
leadingIcon = { Icon(Icons.Default.Tune, contentDescription = null, tint = Color.White.copy(alpha = 0.8f)) },
trailingIcon = { Text(thinkingLabel(thinkingLevel), color = Color.White.copy(alpha = 0.5f), style = MaterialTheme.typography.labelSmall) },
)
// Slash commands
DropdownMenuItem(
text = { Text("Commands", color = Color.White) },
onClick = {
showMenu = false
showCommandsMenu = true
},
leadingIcon = { Icon(Icons.Default.Terminal, contentDescription = null, tint = Color.White.copy(alpha = 0.8f)) },
trailingIcon = { Text("/", color = Color.White.copy(alpha = 0.5f), style = MaterialTheme.typography.labelSmall) },
)
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
@ -259,29 +267,69 @@ fun ChatComposer(
}
}
// Session menu (separate dropdown)
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
for (entry in sessionOptions) {
// Model menu (separate dropdown)
DropdownMenu(
expanded = showModelMenu,
onDismissRequest = { showModelMenu = false },
modifier = Modifier.background(Color(0xFF3A3A4A)),
) {
if (availableModels.isEmpty()) {
DropdownMenuItem(
text = { Text(entry.displayName ?: entry.key) },
onClick = {
onSelectSession(entry.key)
showSessionMenu = false
},
trailingIcon = {
if (entry.key == sessionKey) Text("") else Spacer(Modifier.width(10.dp))
},
text = { Text("No models available", color = Color.White.copy(alpha = 0.5f)) },
onClick = { showModelMenu = false },
)
} else {
for (model in availableModels) {
val isSelected = model == currentModel
DropdownMenuItem(
text = { Text(modelDisplayName(model), color = Color.White) },
onClick = {
onSetModel(model)
showModelMenu = false
},
trailingIcon = {
if (isSelected) Text("", color = Color.White) else Spacer(Modifier.width(10.dp))
},
)
}
}
}
// Thinking menu (separate dropdown)
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
DropdownMenu(
expanded = showThinkingMenu,
onDismissRequest = { showThinkingMenu = false },
modifier = Modifier.background(Color(0xFF3A3A4A)),
) {
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
}
// Commands menu (separate dropdown)
DropdownMenu(
expanded = showCommandsMenu,
onDismissRequest = { showCommandsMenu = false },
modifier = Modifier.background(Color(0xFF3A3A4A)),
) {
for (cmd in slashCommands) {
DropdownMenuItem(
text = {
Column {
Text("/${cmd.name}", color = Color.White)
if (cmd.description.isNotEmpty()) {
Text(cmd.description, color = Color.White.copy(alpha = 0.5f), style = MaterialTheme.typography.labelSmall)
}
}
},
onClick = {
onSend("/${cmd.name}")
showCommandsMenu = false
},
)
}
}
// Text field
Box(
@ -359,7 +407,7 @@ fun ChatComposer(
}
// Connection status pill (compact)
ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk)
ConnectionPill(healthOk = healthOk, contextUsagePercent = contextUsagePercent)
}
}
@ -447,7 +495,7 @@ private fun MiniOrb(
}
@Composable
private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
private fun ConnectionPill(healthOk: Boolean, contextUsagePercent: Int? = null) {
Surface(
shape = RoundedCornerShape(999.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.7f),
@ -467,6 +515,22 @@ private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (contextUsagePercent != null) {
Text(
"·",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
Text(
"${contextUsagePercent}%",
style = MaterialTheme.typography.labelSmall,
color = when {
contextUsagePercent >= 90 -> Color(0xFFE74C3C)
contextUsagePercent >= 70 -> Color(0xFFF39C12)
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
@ -542,3 +606,38 @@ private fun AttachmentChip(fileName: String, onRemove: () -> Unit) {
}
}
}
// Model display name helper
private fun modelDisplayName(model: String): String {
if (model.isEmpty()) return "Default"
// Extract short name from provider/model format
val parts = model.split("/")
val name = parts.lastOrNull() ?: model
return when {
name.contains("opus", ignoreCase = true) -> "Opus"
name.contains("sonnet", ignoreCase = true) -> "Sonnet"
name.contains("haiku", ignoreCase = true) -> "Haiku"
name.contains("gpt-4o", ignoreCase = true) -> "GPT-4o"
name.contains("gpt-4", ignoreCase = true) -> "GPT-4"
name.contains("o1", ignoreCase = true) -> "o1"
name.contains("o3", ignoreCase = true) -> "o3"
name.contains("gemini", ignoreCase = true) -> "Gemini"
name.length > 15 -> name.take(12) + ""
else -> name
}
}
// Slash command data class
data class SlashCommand(
val name: String,
val description: String = "",
)
// Default slash commands
val defaultSlashCommands = listOf(
SlashCommand("status", "Show session status"),
SlashCommand("clear", "Clear conversation"),
SlashCommand("reasoning", "Toggle extended thinking"),
SlashCommand("model", "Show or change model"),
SlashCommand("help", "Show available commands"),
)