@@ -0,0 +1,331 @@
1+package soy.iko.crush.ui.screens
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.Row
8+import androidx.compose.foundation.layout.Spacer
9+import androidx.compose.foundation.layout.fillMaxSize
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.height
12+import androidx.compose.foundation.layout.padding
13+import androidx.compose.foundation.layout.size
14+import androidx.compose.foundation.layout.widthIn
15+import androidx.compose.foundation.lazy.LazyColumn
16+import androidx.compose.foundation.lazy.items
17+import androidx.compose.foundation.lazy.rememberLazyListState
18+import androidx.compose.foundation.shape.RoundedCornerShape
19+import androidx.compose.material.icons.Icons
20+import androidx.compose.material.icons.automirrored.filled.Send
21+import androidx.compose.material.icons.filled.Add
22+import androidx.compose.material.icons.filled.Stop
23+import androidx.compose.material3.AlertDialog
24+import androidx.compose.material3.Button
25+import androidx.compose.material3.Card
26+import androidx.compose.material3.CardDefaults
27+import androidx.compose.material3.CircularProgressIndicator
28+import androidx.compose.material3.ExperimentalMaterial3Api
29+import androidx.compose.material3.FloatingActionButton
30+import androidx.compose.material3.HorizontalDivider
31+import androidx.compose.material3.Icon
32+import androidx.compose.material3.IconButton
33+import androidx.compose.material3.MaterialTheme
34+import androidx.compose.material3.OutlinedTextField
35+import androidx.compose.material3.Scaffold
36+import androidx.compose.material3.Surface
37+import androidx.compose.material3.Text
38+import androidx.compose.material3.TextButton
39+import androidx.compose.material3.TopAppBar
40+import androidx.compose.runtime.Composable
41+import androidx.compose.runtime.LaunchedEffect
42+import androidx.compose.runtime.getValue
43+import androidx.compose.runtime.mutableStateOf
44+import androidx.compose.runtime.remember
45+import androidx.compose.runtime.setValue
46+import androidx.compose.ui.Alignment
47+import androidx.compose.ui.Modifier
48+import androidx.compose.ui.draw.clip
49+import androidx.compose.ui.text.font.FontFamily
50+import androidx.compose.ui.text.font.FontWeight
51+import androidx.compose.ui.unit.dp
52+import soy.iko.crush.proto.MessageRole
53+import soy.iko.crush.ui.ChatUiState
54+import soy.iko.crush.ui.MessageRenderer
56+@OptIn(ExperimentalMaterial3Api::class)
60+ onSend: (String) -> Unit,
61+ onCancel: () -> Unit,
62+ onNewSession: () -> Unit,
63+ onPickSession: (String) -> Unit,
64+ onGrantPermission: (soy.iko.crush.proto.PermissionRequest, Boolean) -> Unit,
66+ var input by remember { mutableStateOf("") }
67+ var showSessionPicker by remember { mutableStateOf(false) }
68+ val listState = rememberLazyListState()
70+ // Auto-scroll to bottom when new messages arrive.
71+ LaunchedEffect(state.messages.size) {
72+ if (state.messages.isNotEmpty()) {
73+ listState.animateScrollToItem(state.messages.lastIndex)
82+ Text("Crush", fontWeight = FontWeight.Bold)
83+ val subtitle = state.currentSessionId?.let { "session $it" } ?: "no session"
86+ style = MaterialTheme.typography.labelSmall,
87+ color = MaterialTheme.colorScheme.onSurfaceVariant,
93+ CircularProgressIndicator(
94+ modifier = Modifier.size(20.dp),
97+ IconButton(onClick = onCancel) {
98+ Icon(Icons.Filled.Stop, contentDescription = "Cancel run")
101+ IconButton(onClick = { showSessionPicker = true }) {
102+ Icon(Icons.Filled.Add, contentDescription = "Sessions")
110+ onTextChange = { input = it },
112+ if (input.isNotBlank()) {
117+ enabled = state.currentSessionId != null && !state.isBusy,
126+ if (state.error != null) {
127+ ErrorBanner(state.error)
134+ .padding(horizontal = 12.dp),
135+ verticalArrangement = Arrangement.spacedBy(8.dp),
136+ contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
138+ items(state.messages, key = { it.id }) { msg ->
141+ if (state.isBusy && state.messages.isEmpty()) {
142+ item { TypingIndicator() }
146+ requests = state.pendingPermissions,
147+ onGrant = onGrantPermission,
152+ if (showSessionPicker) {
154+ sessions = state.sessions,
155+ currentId = state.currentSessionId,
157+ showSessionPicker = false
158+ if (it != null) onPickSession(it)
161+ showSessionPicker = false
164+ onDismiss = { showSessionPicker = false },
170+private fun MessageBubble(message: soy.iko.crush.proto.Message) {
171+ val isUser = message.role == MessageRole.user
172+ val bg = if (isUser) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
173+ else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
174+ val alignment = if (isUser) Alignment.End else Alignment.Start
177+ modifier = Modifier.fillMaxWidth(),
178+ horizontalAlignment = alignment,
181+ MessageRenderer.roleLabel(message.role),
182+ style = MaterialTheme.typography.labelSmall,
183+ color = MaterialTheme.colorScheme.onSurfaceVariant,
184+ modifier = Modifier.padding(bottom = 2.dp, start = 4.dp, end = 4.dp),
188+ .widthIn(max = 340.dp)
189+ .clip(RoundedCornerShape(16.dp))
193+ val text = MessageRenderer.renderText(message)
194+ if (text.isNotEmpty()) {
197+ style = MaterialTheme.typography.bodyMedium.copy(
198+ fontFamily = if (isUser) FontFamily.Default else FontFamily.Monospace,
200+ color = MaterialTheme.colorScheme.onSurface,
202+ } else if (message.parts.isNotEmpty()) {
203+ // Non-text parts (tool calls etc.) — render compactly.
205+ message.parts.joinToString("\n") { MessageRenderer.renderPart(it) },
206+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
207+ color = MaterialTheme.colorScheme.onSurfaceVariant,
215+private fun InputBar(
217+ onTextChange: (String) -> Unit,
221+ Surface(tonalElevation = 3.dp) {
226+ verticalAlignment = Alignment.CenterVertically,
230+ onValueChange = onTextChange,
231+ modifier = Modifier.weight(1f),
232+ placeholder = { Text("Message Crush…") },
236+ Spacer(Modifier.size(8.dp))
237+ IconButton(onClick = onSend, enabled = enabled && text.isNotBlank()) {
238+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
245+private fun ErrorBanner(message: String) {
249+ .background(MaterialTheme.colorScheme.error.copy(alpha = 0.12f))
254+ color = MaterialTheme.colorScheme.error,
255+ style = MaterialTheme.typography.bodySmall,
261+private fun TypingIndicator() {
263+ modifier = Modifier.padding(12.dp),
264+ verticalAlignment = Alignment.CenterVertically,
265+ horizontalArrangement = Arrangement.spacedBy(6.dp),
267+ CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
268+ Text("Crush is thinking…", style = MaterialTheme.typography.bodySmall)
273+private fun PermissionQueue(
274+ requests: List<soy.iko.crush.proto.PermissionRequest>,
275+ onGrant: (soy.iko.crush.proto.PermissionRequest, Boolean) -> Unit,
277+ if (requests.isEmpty()) return
278+ val req = requests.first()
280+ onDismissRequest = { onGrant(req, false) },
281+ title = { Text("Permission required") },
284+ Text("Tool: ${req.toolName}")
285+ Spacer(Modifier.height(4.dp))
286+ Text(req.description, style = MaterialTheme.typography.bodySmall)
289+ confirmButton = { Button({ onGrant(req, true) }) { Text("Allow") } },
290+ dismissButton = { TextButton({ onGrant(req, false) }) { Text("Deny") } },
295+private fun SessionPickerSheet(
296+ sessions: List<soy.iko.crush.proto.Session>,
298+ onPick: (String?) -> Unit,
300+ onDismiss: () -> Unit,
303+ onDismissRequest = onDismiss,
304+ title = { Text("Sessions") },
307+ sessions.forEach { s ->
308+ val active = s.id == currentId
310+ s.title.ifBlank { s.id.take(8) },
311+ fontWeight = if (active) FontWeight.Bold else FontWeight.Normal,
312+ color = if (active) MaterialTheme.colorScheme.primary
313+ else MaterialTheme.colorScheme.onSurface,
317+ .clip(RoundedCornerShape(8.dp))
318+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
321+ Spacer(Modifier.height(4.dp))
323+ if (sessions.isEmpty()) {
324+ Text("No sessions yet", color = MaterialTheme.colorScheme.onSurfaceVariant)
328+ confirmButton = { Button(onNew) { Text("New session") } },
329+ dismissButton = { TextButton(onDismiss) { Text("Close") } },