@@ -0,0 +1,331 @@
1+package soy.iko.crush.ui.screens
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.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
55+
56+@OptIn(ExperimentalMaterial3Api::class)
57+@Composable
58+fun ChatScreen(
59+ state: ChatUiState,
60+ onSend: (String) -> Unit,
61+ onCancel: () -> Unit,
62+ onNewSession: () -> Unit,
63+ onPickSession: (String) -> Unit,
64+ onGrantPermission: (soy.iko.crush.proto.PermissionRequest, Boolean) -> Unit,
65+) {
66+ var input by remember { mutableStateOf("") }
67+ var showSessionPicker by remember { mutableStateOf(false) }
68+ val listState = rememberLazyListState()
69+
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)
74+ }
75+ }
76+
77+ Scaffold(
78+ topBar = {
79+ TopAppBar(
80+ title = {
81+ Column {
82+ Text("Crush", fontWeight = FontWeight.Bold)
83+ val subtitle = state.currentSessionId?.let { "session $it" } ?: "no session"
84+ Text(
85+ subtitle,
86+ style = MaterialTheme.typography.labelSmall,
87+ color = MaterialTheme.colorScheme.onSurfaceVariant,
88+ )
89+ }
90+ },
91+ actions = {
92+ if (state.isBusy) {
93+ CircularProgressIndicator(
94+ modifier = Modifier.size(20.dp),
95+ strokeWidth = 2.dp,
96+ )
97+ IconButton(onClick = onCancel) {
98+ Icon(Icons.Filled.Stop, contentDescription = "Cancel run")
99+ }
100+ }
101+ IconButton(onClick = { showSessionPicker = true }) {
102+ Icon(Icons.Filled.Add, contentDescription = "Sessions")
103+ }
104+ },
105+ )
106+ },
107+ bottomBar = {
108+ InputBar(
109+ text = input,
110+ onTextChange = { input = it },
111+ onSend = {
112+ if (input.isNotBlank()) {
113+ onSend(input)
114+ input = ""
115+ }
116+ },
117+ enabled = state.currentSessionId != null && !state.isBusy,
118+ )
119+ },
120+ ) { padding ->
121+ Column(
122+ modifier = Modifier
123+ .fillMaxSize()
124+ .padding(padding),
125+ ) {
126+ if (state.error != null) {
127+ ErrorBanner(state.error)
128+ }
129+ LazyColumn(
130+ state = listState,
131+ modifier = Modifier
132+ .weight(1f)
133+ .fillMaxWidth()
134+ .padding(horizontal = 12.dp),
135+ verticalArrangement = Arrangement.spacedBy(8.dp),
136+ contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
137+ ) {
138+ items(state.messages, key = { it.id }) { msg ->
139+ MessageBubble(msg)
140+ }
141+ if (state.isBusy && state.messages.isEmpty()) {
142+ item { TypingIndicator() }
143+ }
144+ }
145+ PermissionQueue(
146+ requests = state.pendingPermissions,
147+ onGrant = onGrantPermission,
148+ )
149+ }
150+ }
151+
152+ if (showSessionPicker) {
153+ SessionPickerSheet(
154+ sessions = state.sessions,
155+ currentId = state.currentSessionId,
156+ onPick = {
157+ showSessionPicker = false
158+ if (it != null) onPickSession(it)
159+ },
160+ onNew = {
161+ showSessionPicker = false
162+ onNewSession()
163+ },
164+ onDismiss = { showSessionPicker = false },
165+ )
166+ }
167+}
168+
169+@Composable
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
175+
176+ Column(
177+ modifier = Modifier.fillMaxWidth(),
178+ horizontalAlignment = alignment,
179+ ) {
180+ Text(
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),
185+ )
186+ Box(
187+ modifier = Modifier
188+ .widthIn(max = 340.dp)
189+ .clip(RoundedCornerShape(16.dp))
190+ .background(bg)
191+ .padding(12.dp),
192+ ) {
193+ val text = MessageRenderer.renderText(message)
194+ if (text.isNotEmpty()) {
195+ Text(
196+ text,
197+ style = MaterialTheme.typography.bodyMedium.copy(
198+ fontFamily = if (isUser) FontFamily.Default else FontFamily.Monospace,
199+ ),
200+ color = MaterialTheme.colorScheme.onSurface,
201+ )
202+ } else if (message.parts.isNotEmpty()) {
203+ // Non-text parts (tool calls etc.) — render compactly.
204+ Text(
205+ message.parts.joinToString("\n") { MessageRenderer.renderPart(it) },
206+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
207+ color = MaterialTheme.colorScheme.onSurfaceVariant,
208+ )
209+ }
210+ }
211+ }
212+}
213+
214+@Composable
215+private fun InputBar(
216+ text: String,
217+ onTextChange: (String) -> Unit,
218+ onSend: () -> Unit,
219+ enabled: Boolean,
220+) {
221+ Surface(tonalElevation = 3.dp) {
222+ Row(
223+ modifier = Modifier
224+ .fillMaxWidth()
225+ .padding(8.dp),
226+ verticalAlignment = Alignment.CenterVertically,
227+ ) {
228+ OutlinedTextField(
229+ value = text,
230+ onValueChange = onTextChange,
231+ modifier = Modifier.weight(1f),
232+ placeholder = { Text("Message Crush…") },
233+ maxLines = 5,
234+ enabled = enabled,
235+ )
236+ Spacer(Modifier.size(8.dp))
237+ IconButton(onClick = onSend, enabled = enabled && text.isNotBlank()) {
238+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
239+ }
240+ }
241+ }
242+}
243+
244+@Composable
245+private fun ErrorBanner(message: String) {
246+ Box(
247+ modifier = Modifier
248+ .fillMaxWidth()
249+ .background(MaterialTheme.colorScheme.error.copy(alpha = 0.12f))
250+ .padding(8.dp),
251+ ) {
252+ Text(
253+ message,
254+ color = MaterialTheme.colorScheme.error,
255+ style = MaterialTheme.typography.bodySmall,
256+ )
257+ }
258+}
259+
260+@Composable
261+private fun TypingIndicator() {
262+ Row(
263+ modifier = Modifier.padding(12.dp),
264+ verticalAlignment = Alignment.CenterVertically,
265+ horizontalArrangement = Arrangement.spacedBy(6.dp),
266+ ) {
267+ CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
268+ Text("Crush is thinking…", style = MaterialTheme.typography.bodySmall)
269+ }
270+}
271+
272+@Composable
273+private fun PermissionQueue(
274+ requests: List<soy.iko.crush.proto.PermissionRequest>,
275+ onGrant: (soy.iko.crush.proto.PermissionRequest, Boolean) -> Unit,
276+) {
277+ if (requests.isEmpty()) return
278+ val req = requests.first()
279+ AlertDialog(
280+ onDismissRequest = { onGrant(req, false) },
281+ title = { Text("Permission required") },
282+ text = {
283+ Column {
284+ Text("Tool: ${req.toolName}")
285+ Spacer(Modifier.height(4.dp))
286+ Text(req.description, style = MaterialTheme.typography.bodySmall)
287+ }
288+ },
289+ confirmButton = { Button({ onGrant(req, true) }) { Text("Allow") } },
290+ dismissButton = { TextButton({ onGrant(req, false) }) { Text("Deny") } },
291+ )
292+}
293+
294+@Composable
295+private fun SessionPickerSheet(
296+ sessions: List<soy.iko.crush.proto.Session>,
297+ currentId: String?,
298+ onPick: (String?) -> Unit,
299+ onNew: () -> Unit,
300+ onDismiss: () -> Unit,
301+) {
302+ AlertDialog(
303+ onDismissRequest = onDismiss,
304+ title = { Text("Sessions") },
305+ text = {
306+ Column {
307+ sessions.forEach { s ->
308+ val active = s.id == currentId
309+ Text(
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,
314+ modifier = Modifier
315+ .fillMaxWidth()
316+ .padding(8.dp)
317+ .clip(RoundedCornerShape(8.dp))
318+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
319+ .padding(8.dp),
320+ )
321+ Spacer(Modifier.height(4.dp))
322+ }
323+ if (sessions.isEmpty()) {
324+ Text("No sessions yet", color = MaterialTheme.colorScheme.onSurfaceVariant)
325+ }
326+ }
327+ },
328+ confirmButton = { Button(onNew) { Text("New session") } },
329+ dismissButton = { TextButton(onDismiss) { Text("Close") } },
330+ )
331+}