1package soy.iko.opencode.ui.chat
3import androidx.activity.compose.BackHandler
4import androidx.compose.animation.AnimatedVisibility
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.PaddingValues
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.lazy.LazyColumn
17import androidx.compose.foundation.lazy.items
18import androidx.compose.foundation.lazy.rememberLazyListState
19import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.automirrored.filled.ArrowBack
21import androidx.compose.material.icons.automirrored.filled.Send
22import androidx.compose.material.icons.filled.KeyboardArrowDown
23import androidx.compose.material.icons.filled.SmartToy
24import androidx.compose.material.icons.filled.Stop
25import androidx.compose.material.icons.filled.Terminal
26import androidx.compose.material.icons.filled.Tune
27import androidx.compose.material3.AlertDialog
28import androidx.compose.material3.CircularProgressIndicator
29import androidx.compose.material3.ExperimentalMaterial3Api
30import androidx.compose.material3.ExtendedFloatingActionButton
31import androidx.compose.material3.FloatingActionButton
32import androidx.compose.material3.Icon
33import androidx.compose.material3.IconButton
34import androidx.compose.material3.MaterialTheme
35import androidx.compose.material3.OutlinedTextField
36import androidx.compose.material3.Scaffold
37import androidx.compose.material3.SnackbarHost
38import androidx.compose.material3.SnackbarHostState
39import androidx.compose.material3.Surface
40import androidx.compose.material3.Text
41import androidx.compose.material3.TextButton
42import androidx.compose.material3.TopAppBar
43import androidx.compose.runtime.Composable
44import androidx.compose.runtime.LaunchedEffect
45import androidx.compose.runtime.derivedStateOf
46import androidx.compose.runtime.getValue
47import androidx.compose.runtime.mutableStateOf
48import androidx.compose.runtime.remember
49import androidx.compose.runtime.rememberCoroutineScope
50import androidx.compose.runtime.setValue
51import androidx.compose.ui.Alignment
52import androidx.compose.ui.Modifier
53import androidx.compose.ui.platform.LocalHapticFeedback
54import androidx.compose.ui.unit.dp
55import androidx.lifecycle.compose.collectAsStateWithLifecycle
56import androidx.lifecycle.viewmodel.compose.viewModel
57import kotlinx.coroutines.launch
58import soy.iko.opencode.data.network.EventStreamClient
59import soy.iko.opencode.di.AppContainer
60import soy.iko.opencode.ui.vmFactory
62@OptIn(ExperimentalMaterial3Api::class)
65 container: AppContainer,
69 val vm: ChatViewModel = viewModel(factory = vmFactory { ChatViewModel(container, sessionId) })
70 val messages by vm.messages.collectAsStateWithLifecycle()
71 val running by vm.running.collectAsStateWithLifecycle()
72 val error by vm.error.collectAsStateWithLifecycle()
73 val models by vm.models.collectAsStateWithLifecycle()
74 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
75 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
76 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
77 val agents by vm.agents.collectAsStateWithLifecycle()
78 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
79 val commands by vm.commands.collectAsStateWithLifecycle()
80 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
81 val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
82 val haptics = LocalHapticFeedback.current
83 val scope = rememberCoroutineScope()
85 val listState = rememberLazyListState()
86 val snackbar = remember { SnackbarHostState() }
87 var input by remember { mutableStateOf("") }
88 var showModelPicker by remember { mutableStateOf(false) }
89 var showAgentPicker by remember { mutableStateOf(false) }
90 var showCommandPicker by remember { mutableStateOf(false) }
91 var showExitConfirm by remember { mutableStateOf(false) }
93 // Track whether the list is pinned to the bottom so streaming auto-scroll
94 // doesn't fight a user who scrolled up to read earlier output.
95 val isPinnedToBottom by remember {
97 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
98 lastVisible >= messages.lastIndex || messages.isEmpty()
102 // Keep the newest content in view as parts stream in — only if already at the bottom.
103 LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size) {
104 if (messages.isNotEmpty() && isPinnedToBottom) {
105 listState.animateScrollToItem(messages.lastIndex)
108 LaunchedEffect(error) {
109 val msg = error ?: return@LaunchedEffect
110 val result = if (failedDraft != null) {
111 snackbar.showSnackbar(message = msg, actionLabel = "Retry")
113 snackbar.showSnackbar(msg)
115 if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
120 if (vm.send(input)) {
122 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
126 BackHandler(enabled = running) { showExitConfirm = true }
132 Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
133 Text(sessionTitle ?: "Session")
136 append(selectedModel?.modelLabel ?: "default model")
137 selectedAgent?.let { append(" • $it") }
139 style = MaterialTheme.typography.labelSmall,
140 color = MaterialTheme.colorScheme.onSurfaceVariant,
145 IconButton(onClick = {
146 if (running) showExitConfirm = true else onBack()
148 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
152 IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
153 Icon(Icons.Filled.Terminal, contentDescription = "Commands")
155 IconButton(onClick = { showAgentPicker = true }, enabled = agents.isNotEmpty()) {
156 Icon(Icons.Filled.SmartToy, contentDescription = "Choose agent")
158 IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
159 Icon(Icons.Filled.Tune, contentDescription = "Choose model")
164 snackbarHost = { SnackbarHost(snackbar) },
168 onValueChange = { input = it },
170 enabled = vm.connected,
172 onAbort = { vm.abort() },
176 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
180 modifier = Modifier.align(Alignment.Center).padding(24.dp),
181 color = MaterialTheme.colorScheme.error,
185 state = connectionState,
186 modifier = Modifier.align(Alignment.TopCenter),
190 modifier = Modifier.fillMaxSize(),
191 contentPadding = PaddingValues(16.dp),
192 verticalArrangement = Arrangement.spacedBy(16.dp),
194 items(messages, key = { it.info.id }) { message ->
195 MessageBubble(message, isRunning = running)
198 item(key = "__typing") {
199 Row(verticalAlignment = Alignment.CenterVertically) {
200 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
201 Text(" working…", style = MaterialTheme.typography.labelMedium)
206 // Jump-to-latest affordance when the user has scrolled away during a stream.
208 visible = !isPinnedToBottom && messages.isNotEmpty(),
209 modifier = Modifier.align(Alignment.BottomEnd),
211 ExtendedFloatingActionButton(
213 if (messages.isNotEmpty()) {
214 scope.launch { listState.animateScrollToItem(messages.lastIndex) }
217 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
218 text = { Text("Latest") },
219 modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
226 if (showExitConfirm) {
228 onDismissRequest = { showExitConfirm = false },
229 title = { Text("Stop and exit?") },
230 text = { Text("The agent is still working. Exit anyway?") },
232 TextButton(onClick = {
233 showExitConfirm = false
236 }) { Text("Stop & exit") }
239 TextButton(onClick = { showExitConfirm = false }) { Text("Stay") }
244 if (showModelPicker) {
247 selected = selectedModel,
248 onSelect = { vm.selectModel(it) },
249 onDismiss = { showModelPicker = false },
253 if (showAgentPicker) {
256 selected = selectedAgent,
257 onSelect = { vm.selectAgent(it?.name) },
258 onDismiss = { showAgentPicker = false },
262 if (showCommandPicker) {
265 onSelect = { vm.runCommand(it) },
266 onDismiss = { showCommandPicker = false },
270 pendingPermission?.let { permission ->
272 permission = permission,
273 onRespond = { response -> vm.respondPermission(permission, response) },
279private fun ConnectionBanner(
280 state: EventStreamClient.ConnectionState,
281 modifier: Modifier = Modifier,
283 val text = when (state) {
284 EventStreamClient.ConnectionState.Connecting -> "Connecting…"
285 EventStreamClient.ConnectionState.Disconnected -> "Reconnecting…"
286 EventStreamClient.ConnectionState.Connected -> null
290 modifier = modifier.fillMaxWidth(),
291 color = MaterialTheme.colorScheme.tertiaryContainer,
294 modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
295 horizontalArrangement = Arrangement.Center,
296 verticalAlignment = Alignment.CenterVertically,
298 CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 2.dp)
301 style = MaterialTheme.typography.labelMedium,
302 color = MaterialTheme.colorScheme.onTertiaryContainer,
310private fun ChatInputBar(
312 onValueChange: (String) -> Unit,
318 Surface(tonalElevation = 3.dp) {
320 modifier = Modifier.fillMaxWidth().padding(8.dp),
321 verticalAlignment = Alignment.Bottom,
325 onValueChange = onValueChange,
326 modifier = Modifier.weight(1f),
327 placeholder = { Text("Message opencode…") },
332 IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) {
333 Icon(Icons.Filled.Stop, contentDescription = "Stop")
338 enabled = enabled && value.isNotBlank(),
339 modifier = Modifier.padding(start = 4.dp),
341 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list