1package soy.iko.opencode.ui.chat
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.items
15import androidx.compose.foundation.lazy.rememberLazyListState
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.automirrored.filled.ArrowBack
18import androidx.compose.material.icons.automirrored.filled.Send
19import androidx.compose.material.icons.filled.SmartToy
20import androidx.compose.material.icons.filled.Stop
21import androidx.compose.material.icons.filled.Terminal
22import androidx.compose.material.icons.filled.Tune
23import androidx.compose.material3.CircularProgressIndicator
24import androidx.compose.material3.ExperimentalMaterial3Api
25import androidx.compose.material3.Icon
26import androidx.compose.material3.IconButton
27import androidx.compose.material3.MaterialTheme
28import androidx.compose.material3.OutlinedTextField
29import androidx.compose.material3.Scaffold
30import androidx.compose.material3.SnackbarHost
31import androidx.compose.material3.SnackbarHostState
32import androidx.compose.material3.Surface
33import androidx.compose.material3.Text
34import androidx.compose.material3.TopAppBar
35import androidx.compose.runtime.Composable
36import androidx.compose.runtime.LaunchedEffect
37import androidx.compose.runtime.getValue
38import androidx.compose.runtime.mutableStateOf
39import androidx.compose.runtime.remember
40import androidx.compose.runtime.setValue
41import androidx.compose.ui.Alignment
42import androidx.compose.ui.Modifier
43import androidx.compose.ui.unit.dp
44import androidx.lifecycle.compose.collectAsStateWithLifecycle
45import androidx.lifecycle.viewmodel.compose.viewModel
46import soy.iko.opencode.di.AppContainer
47import soy.iko.opencode.ui.vmFactory
49@OptIn(ExperimentalMaterial3Api::class)
52 container: AppContainer,
56 val vm: ChatViewModel = viewModel(factory = vmFactory { ChatViewModel(container, sessionId) })
57 val messages by vm.messages.collectAsStateWithLifecycle()
58 val running by vm.running.collectAsStateWithLifecycle()
59 val error by vm.error.collectAsStateWithLifecycle()
60 val models by vm.models.collectAsStateWithLifecycle()
61 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
62 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
63 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
64 val agents by vm.agents.collectAsStateWithLifecycle()
65 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
66 val commands by vm.commands.collectAsStateWithLifecycle()
68 val listState = rememberLazyListState()
69 val snackbar = remember { SnackbarHostState() }
70 var input by remember { mutableStateOf("") }
71 var showModelPicker by remember { mutableStateOf(false) }
72 var showAgentPicker by remember { mutableStateOf(false) }
73 var showCommandPicker by remember { mutableStateOf(false) }
75 // Keep the newest content in view as parts stream in.
76 LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size) {
77 if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
79 LaunchedEffect(error) {
81 snackbar.showSnackbar(it)
90 Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
94 append(selectedModel?.modelLabel ?: "default model")
95 selectedAgent?.let { append(" • $it") }
97 style = MaterialTheme.typography.labelSmall,
98 color = MaterialTheme.colorScheme.onSurfaceVariant,
103 IconButton(onClick = onBack) {
104 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
108 IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
109 Icon(Icons.Filled.Terminal, contentDescription = "Commands")
111 IconButton(onClick = { showAgentPicker = true }, enabled = agents.isNotEmpty()) {
112 Icon(Icons.Filled.SmartToy, contentDescription = "Choose agent")
114 IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
115 Icon(Icons.Filled.Tune, contentDescription = "Choose model")
120 snackbarHost = { SnackbarHost(snackbar) },
124 onValueChange = { input = it },
126 enabled = vm.connected,
131 onAbort = { vm.abort() },
135 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
139 modifier = Modifier.align(Alignment.Center).padding(24.dp),
140 color = MaterialTheme.colorScheme.error,
144 state = connectionState,
145 modifier = Modifier.align(Alignment.TopCenter),
149 modifier = Modifier.fillMaxSize(),
150 contentPadding = PaddingValues(16.dp),
151 verticalArrangement = Arrangement.spacedBy(16.dp),
153 items(messages, key = { it.info.id }) { message ->
154 MessageBubble(message)
157 item(key = "__typing") {
158 Row(verticalAlignment = Alignment.CenterVertically) {
159 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
160 Text(" working…", style = MaterialTheme.typography.labelMedium)
169 if (showModelPicker) {
172 selected = selectedModel,
173 onSelect = { vm.selectModel(it) },
174 onDismiss = { showModelPicker = false },
178 if (showAgentPicker) {
181 selected = selectedAgent,
182 onSelect = { vm.selectAgent(it?.name) },
183 onDismiss = { showAgentPicker = false },
187 if (showCommandPicker) {
190 onSelect = { vm.runCommand(it) },
191 onDismiss = { showCommandPicker = false },
195 pendingPermission?.let { permission ->
197 permission = permission,
198 onRespond = { response -> vm.respondPermission(permission, response) },
204private fun ConnectionBanner(
205 state: soy.iko.opencode.data.network.EventStreamClient.ConnectionState,
206 modifier: Modifier = Modifier,
208 val text = when (state) {
209 soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connecting -> "Connecting…"
210 soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected -> "Reconnecting…"
211 soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected -> null
215 modifier = modifier.fillMaxWidth(),
216 color = MaterialTheme.colorScheme.tertiaryContainer,
219 modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
220 horizontalArrangement = Arrangement.Center,
221 verticalAlignment = Alignment.CenterVertically,
223 CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 2.dp)
226 style = MaterialTheme.typography.labelMedium,
227 color = MaterialTheme.colorScheme.onTertiaryContainer,
235private fun ChatInputBar(
237 onValueChange: (String) -> Unit,
243 Surface(tonalElevation = 3.dp) {
245 modifier = Modifier.fillMaxWidth().padding(8.dp),
246 verticalAlignment = Alignment.Bottom,
250 onValueChange = onValueChange,
251 modifier = Modifier.weight(1f),
252 placeholder = { Text("Message opencode…") },
257 IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) {
258 Icon(Icons.Filled.Stop, contentDescription = "Stop")
263 enabled = enabled && value.isNotBlank(),
264 modifier = Modifier.padding(start = 4.dp),
266 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