package net.kentonc.si.ui import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SystemUpdate import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import net.kentonc.si.AgentViewModel /** The whole app surface: transcript, input bar, approval dialog. */ @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable fun ChatScreen(vm: AgentViewModel) { val rows by vm.rows.collectAsState() val busy by vm.busy.collectAsState() val approval by vm.approval.collectAsState() val attachments by vm.attachments.collectAsState() Scaffold( topBar = { TopAppBar( title = { Text("superintelligence-android") }, actions = { UpdateAction(vm) IconButton(onClick = { vm.clearConversation() }, enabled = rows.isNotEmpty() || busy) { Icon(Icons.Filled.Delete, contentDescription = "Clear conversation") } IconButton(onClick = { vm.toggleSettings(true) }) { Icon(Icons.Filled.Settings, contentDescription = "Settings") } }, ) }, ) { padding -> val listState = rememberLazyListState() val lastLen = (rows.lastOrNull() as? AgentViewModel.MessageRow)?.markdown?.length ?: 0 val density = LocalDensity.current // Re-scroll when the keyboard appears/disappears too, so the draft stays visible. val imeVisible = WindowInsets.ime.getBottom(density) > 0 LaunchedEffect(rows.size, lastLen, imeVisible) { if (rows.isNotEmpty()) listState.animateScrollToItem(rows.lastIndex) } // Without this the input bar would sit behind the keyboard: edge-to-edge // means the window never resizes, so the IME inset must be padded by the // content itself. consumeWindowInsets keeps the padding from stacking on // top of the navigation-bar inset the Scaffold already applied. Column( Modifier .padding(padding) .consumeWindowInsets(padding) .imePadding() .fillMaxSize(), ) { LazyColumn(state = listState, modifier = Modifier.weight(1f)) { items(rows, key = { it.id }) { row -> when (row) { is AgentViewModel.MessageRow -> MessageBubble( row, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) is AgentViewModel.ToolRow -> ToolActivity( row, modifier = Modifier.padding(horizontal = 16.dp, vertical = 1.dp), ) is AgentViewModel.ErrorRow -> Text( "error: ${row.text}", color = MaterialTheme.colorScheme.error, fontSize = 13.sp, modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp), ) } } } AttachmentChips(attachments, vm::removeAttachment) InputBar(vm, busy) } } val request = approval if (request != null) ApprovalDialog( request, onResult = vm::answerApproval, ) } @Composable private fun MessageBubble(row: AgentViewModel.MessageRow, modifier: Modifier = Modifier) { val scheme = MaterialTheme.colorScheme when (row.role) { AgentViewModel.Role.User -> Column( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.End, ) { Surface( shape = RoundedCornerShape(14.dp, 4.dp, 14.dp, 14.dp), color = scheme.primaryContainer, modifier = Modifier.widthIn(max = 320.dp), ) { Text( row.markdown, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(10.dp), ) } if (row.images > 0) ImageNote(row.images) } // Assistant: plain markdown, widest use of the screen. AgentViewModel.Role.Assistant -> Column(modifier = modifier.fillMaxWidth()) { MarkdownText(row.markdown) if (row.images > 0) ImageNote(row.images) } } } @Composable private fun ImageNote(count: Int) { Text( "πŸ“Ž $count image(s)", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp), ) } // ─── in-app update ───────────────────────────────────────────────────────────── /** The self-update tap: stat-dependent icon in the top bar. Appears whenever * the hub has a newer release than the installed build. */ @Composable private fun UpdateAction(vm: AgentViewModel) { val status by vm.update.collectAsState() when (val s = status) { is net.kentonc.si.UpdateStatus.Available -> IconButton(onClick = vm::startUpdate) { Icon(Icons.Filled.SystemUpdate, contentDescription = "Update available: ${s.version}") } is net.kentonc.si.UpdateStatus.Downloading -> CircularProgressIndicator( progress = { s.percent / 100f }, strokeWidth = 3.dp, modifier = Modifier.size(22.dp), ) is net.kentonc.si.UpdateStatus.NeedsPermission -> TextButton(onClick = vm::startUpdate) { Text("Allow installs", fontSize = 13.sp) } is net.kentonc.si.UpdateStatus.Failed -> IconButton(onClick = vm::startUpdate) { Icon(Icons.Filled.ErrorOutline, contentDescription = "Update check failed: ${s.message}", tint = MaterialTheme.colorScheme.error) } is net.kentonc.si.UpdateStatus.Ready -> IconButton(onClick = vm::startUpdate) { Icon(Icons.Filled.SystemUpdate, contentDescription = "Installer ready for ${s.version}") } is net.kentonc.si.UpdateStatus.Idle -> {} } } // ─── tool activity ───────────────────────────────────────────────────────────── /** Dim, column-aligned machinery lines, mirroring the terminal OutputSink look. */ @Composable private fun ToolActivity(row: AgentViewModel.ToolRow, modifier: Modifier = Modifier) { val outcome = row.outcome Column(modifier) { Text( text = row.summary, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) if (outcome != null) { val (label, color) = when { outcome.startsWith("error:") -> "╰─ $outcome" to MaterialTheme.colorScheme.error outcome == "skipped" -> "╰─ $outcome" to MaterialTheme.colorScheme.tertiary else -> "╰─ $outcome" to MaterialTheme.colorScheme.primary } Text( text = label, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = color.copy(alpha = color.alpha * 0.9f), ) } } } // ─── input bar ───────────────────────────────────────────────────────────────── @Composable private fun AttachmentChips( attachments: List, onRemove: (String) -> Unit, ) { if (attachments.isEmpty()) return Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(horizontal = 12.dp)) { for (attachment in attachments) { Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.secondaryContainer) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(6.dp)) { Text("πŸ“Ž ${attachment.name}", fontSize = 12.sp) IconButton(onClick = { onRemove(attachment.name) }) { Icon(Icons.Filled.Close, contentDescription = "Remove attachment", modifier = Modifier) } } } } } } @Composable private fun InputBar(vm: AgentViewModel, busy: Boolean) { var draft by remember { mutableStateOf("") } val picker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> uri?.let(vm::addAttachment) } Row( verticalAlignment = Alignment.Bottom, modifier = Modifier .fillMaxWidth() .padding(8.dp), ) { IconButton( onClick = { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, enabled = !busy, ) { Icon(Icons.Filled.AttachFile, contentDescription = "Attach image") } OutlinedTextField( value = draft, onValueChange = { draft = it }, placeholder = { Text("Ask anything…") }, shape = RoundedCornerShape(22.dp), modifier = Modifier.weight(1f), ) IconButton( onClick = { if (busy) { vm.stop() } else { vm.send(draft) draft = "" } }, enabled = !busy || draft.isNotBlank(), ) { Icon( if (busy) Icons.Filled.Close else Icons.AutoMirrored.Filled.Send, contentDescription = if (busy) "Stop" else "Send", ) } } } // ─── approval ────────────────────────────────────────────────────────────────── /** Mirrors the Rust "run it? [Y/n]" stop: mutating tools wait for a decision. */ @Composable private fun ApprovalDialog(request: AgentViewModel.ApprovalRequest, onResult: (Boolean) -> Unit) { AlertDialog( onDismissRequest = { onResult(false) }, title = { Text("Run \"${request.name}\"?", style = MaterialTheme.typography.titleMedium) }, text = { Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(8.dp)) { Text( request.detail, fontFamily = FontFamily.Monospace, fontSize = 13.sp, modifier = Modifier.padding(8.dp), textAlign = TextAlign.Start, ) } }, confirmButton = { TextButton(onClick = { onResult(true) }) { Text("Run it") } }, dismissButton = { TextButton(onClick = { onResult(false) }) { Text("Decline") } }, ) }