1package soy.iko.opencode.ui.session
3import androidx.compose.foundation.horizontalScroll
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.heightIn
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.rememberScrollState
11import androidx.compose.foundation.selection.selectable
12import androidx.compose.foundation.text.KeyboardOptions
13import androidx.compose.foundation.verticalScroll
14import androidx.compose.material3.AlertDialog
15import androidx.compose.material3.CircularProgressIndicator
16import androidx.compose.material3.MaterialTheme
17import androidx.compose.material3.OutlinedTextField
18import androidx.compose.material3.RadioButton
19import androidx.compose.material3.Text
20import androidx.compose.material3.TextButton
21import androidx.compose.runtime.Composable
22import androidx.compose.runtime.LaunchedEffect
23import androidx.compose.runtime.getValue
24import androidx.compose.runtime.mutableStateOf
25import androidx.compose.runtime.remember
26import androidx.compose.runtime.saveable.Saver
27import androidx.compose.runtime.saveable.rememberSaveable
28import androidx.compose.runtime.setValue
29import androidx.compose.ui.Alignment
30import androidx.compose.ui.Modifier
31import androidx.compose.ui.semantics.Role
32import androidx.compose.ui.text.font.FontFamily
33import androidx.compose.ui.text.input.ImeAction
34import androidx.compose.ui.text.style.TextOverflow
35import androidx.compose.ui.unit.dp
36import androidx.compose.ui.res.stringResource
37import soy.iko.opencode.R
39/** The user's directory choice in the new-session picker. */
40private sealed interface DirChoice {
41 /** Create with no directory override — the server uses its launch cwd. */
42 object ServerDefault : DirChoice
44 /** A known worktree / recent-session directory. */
45 data class Known(val path: String) : DirChoice
47 /** A path the user types by hand. */
48 object Custom : DirChoice
51// Persist the radio selection across configuration changes / process death as a short
52// string tag ("d" = server default, "c" = custom, "k:<path>" = a known directory).
53private val dirChoiceSaver = Saver<DirChoice, String>(
56 is DirChoice.ServerDefault -> "d"
57 is DirChoice.Custom -> "c"
58 is DirChoice.Known -> "k:${it.path}"
63 it == "c" -> DirChoice.Custom
64 it.startsWith("k:") -> DirChoice.Known(it.removePrefix("k:"))
65 else -> DirChoice.ServerDefault
71 * Dialog shown when creating a new session: lets the user pick the working directory the
72 * agent will run in. Offers the server's default (cwd), the directories the server knows
73 * about ([options].projects) plus any directories of existing [sessionDirectories], and a
74 * free-text path. Sends the resolved path (or null for the server default) to [onCreate].
76 * [lastChosenDirectory] preselects the directory used for the previous new session.
80 options: DirectoryOptionsState,
81 sessionDirectories: List<String>,
82 lastChosenDirectory: String?,
84 onCreate: (String?) -> Unit,
85 onDismiss: () -> Unit,
87 // Directories to list as selectable rows: known projects + directories of existing
88 // sessions + the last chosen one, minus the server default (which has its own row).
89 val knownDirs = remember(options.projects, sessionDirectories, lastChosenDirectory, options.serverDefault) {
90 (options.projects + sessionDirectories + listOfNotNull(lastChosenDirectory))
91 .filter { it.isNotBlank() && it != options.serverDefault }
95 // Preselect the last chosen directory (or the most-recent session's directory); both are
96 // included in knownDirs above, so a non-null, non-default preselect always maps to a row.
97 var choice by rememberSaveable(stateSaver = dirChoiceSaver) {
98 val preselect = lastChosenDirectory ?: sessionDirectories.firstOrNull { it.isNotBlank() }
100 if (preselect == null || preselect == options.serverDefault) DirChoice.ServerDefault
101 else DirChoice.Known(preselect),
104 var customText by rememberSaveable { mutableStateOf("") }
106 // The initializer above may have run while options were still loading (serverDefault
107 // null), pinning choice to DirChoice.Known(preselect). Once the real serverDefault
108 // arrives, knownDirs filters that path out (it has its own row), so no DirectoryRow
109 // matches a Known(serverDefault) choice and every radio shows unselected. Collapse it
110 // into ServerDefault so the selected radio always tracks a visible row.
111 LaunchedEffect(options.serverDefault) {
113 if (current is DirChoice.Known && current.path == options.serverDefault) {
114 choice = DirChoice.ServerDefault
118 val resolved: String? = when (val c = choice) {
119 is DirChoice.ServerDefault -> null
120 is DirChoice.Known -> c.path
121 is DirChoice.Custom -> customText.trim().takeIf { it.isNotBlank() }
123 // Custom requires a non-blank path; the other choices are always valid.
124 val canCreate = !creating && (choice !is DirChoice.Custom || customText.isNotBlank())
127 onDismissRequest = { if (!creating) onDismiss() },
128 title = { Text(stringResource(R.string.new_session)) },
133 .heightIn(max = 380.dp)
134 .verticalScroll(rememberScrollState()),
137 stringResource(R.string.directory_label),
138 style = MaterialTheme.typography.labelLarge,
139 color = MaterialTheme.colorScheme.onSurfaceVariant,
140 modifier = Modifier.padding(bottom = 4.dp),
144 label = stringResource(R.string.directory_server_default),
145 sublabel = options.serverDefault,
146 selected = choice is DirChoice.ServerDefault,
148 onSelect = { choice = DirChoice.ServerDefault },
151 knownDirs.forEach { path ->
153 label = path.trimEnd('/').substringAfterLast('/').ifBlank { path },
155 selected = (choice as? DirChoice.Known)?.path == path,
157 onSelect = { choice = DirChoice.Known(path) },
162 label = stringResource(R.string.directory_custom),
164 selected = choice is DirChoice.Custom,
166 onSelect = { choice = DirChoice.Custom },
168 if (choice is DirChoice.Custom) {
171 onValueChange = { customText = it },
174 .padding(start = 36.dp, top = 4.dp, bottom = 4.dp),
175 placeholder = { Text(stringResource(R.string.directory_custom_hint)) },
178 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
182 if (options.loading && !options.loaded) {
184 modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
185 verticalAlignment = Alignment.CenterVertically,
187 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
189 stringResource(R.string.directory_loading),
190 style = MaterialTheme.typography.bodySmall,
191 color = MaterialTheme.colorScheme.onSurfaceVariant,
192 modifier = Modifier.padding(start = 8.dp),
199 TextButton(onClick = { onCreate(resolved) }, enabled = canCreate) {
201 CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
203 Text(stringResource(R.string.create))
208 TextButton(onClick = onDismiss, enabled = !creating) { Text(stringResource(R.string.cancel)) }
214private fun DirectoryRow(
219 onSelect: () -> Unit,
224 .selectable(selected = selected, enabled = enabled, role = Role.RadioButton, onClick = onSelect)
225 .padding(vertical = 4.dp),
226 verticalAlignment = Alignment.CenterVertically,
228 RadioButton(selected = selected, onClick = null, enabled = enabled)
229 Column(modifier = Modifier.padding(start = 12.dp)) {
232 style = MaterialTheme.typography.bodyLarge,
234 overflow = TextOverflow.Ellipsis,
236 if (!sublabel.isNullOrBlank() && sublabel != label) {
237 // Full path can be long; let it scroll horizontally rather than truncate so
238 // the user can verify exactly which directory they're selecting.
241 style = MaterialTheme.typography.bodySmall,
242 color = MaterialTheme.colorScheme.onSurfaceVariant,
243 fontFamily = FontFamily.Monospace,
245 modifier = Modifier.horizontalScroll(rememberScrollState()),
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