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.defaultMinSize
7import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.heightIn
9import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.size
11import androidx.compose.foundation.rememberScrollState
12import androidx.compose.foundation.selection.selectable
13import androidx.compose.foundation.text.KeyboardActions
14import androidx.compose.foundation.text.KeyboardOptions
15import androidx.compose.foundation.verticalScroll
16import androidx.compose.material3.AlertDialog
17import androidx.compose.material3.CircularProgressIndicator
18import androidx.compose.material3.MaterialTheme
19import androidx.compose.material3.OutlinedTextField
20import androidx.compose.material3.RadioButton
21import androidx.compose.material3.Text
22import androidx.compose.material3.TextButton
23import androidx.compose.runtime.Composable
24import androidx.compose.runtime.LaunchedEffect
25import androidx.compose.runtime.getValue
26import androidx.compose.runtime.mutableStateOf
27import androidx.compose.runtime.remember
28import androidx.compose.runtime.saveable.Saver
29import androidx.compose.runtime.saveable.rememberSaveable
30import androidx.compose.runtime.setValue
31import androidx.compose.ui.Alignment
32import androidx.compose.ui.Modifier
33import androidx.compose.ui.focus.FocusRequester
34import androidx.compose.ui.focus.focusRequester
35import androidx.compose.ui.semantics.Role
36import androidx.compose.ui.text.font.FontFamily
37import androidx.compose.ui.text.input.ImeAction
38import androidx.compose.ui.text.style.TextOverflow
39import androidx.compose.ui.unit.dp
40import androidx.compose.ui.res.stringResource
41import soy.iko.opencode.R
42import soy.iko.opencode.data.network.NetworkConfig
44/** The user's directory choice in the new-session picker. */
45private sealed interface DirChoice {
46 /** Create with no directory override — the server uses its launch cwd. */
47 object ServerDefault : DirChoice
49 /** A known worktree / recent-session directory. */
50 data class Known(val path: String) : DirChoice
52 /** A path the user types by hand. */
53 object Custom : DirChoice
56// Persist the radio selection across configuration changes / process death as a short
57// string tag ("d" = server default, "c" = custom, "k:<path>" = a known directory).
58private val dirChoiceSaver = Saver<DirChoice, String>(
61 is DirChoice.ServerDefault -> "d"
62 is DirChoice.Custom -> "c"
63 is DirChoice.Known -> "k:${it.path}"
68 it == "c" -> DirChoice.Custom
69 it.startsWith("k:") -> DirChoice.Known(it.removePrefix("k:"))
70 else -> DirChoice.ServerDefault
76 * Dialog shown when creating a new session: lets the user pick the working directory the
77 * agent will run in. Offers the server's default (cwd), the directories the server knows
78 * about ([options].projects) plus any directories of existing [sessionDirectories], and a
79 * free-text path. Sends the resolved path (or null for the server default) to [onCreate].
81 * [lastChosenDirectory] preselects the directory used for the previous new session.
85 options: DirectoryOptionsState,
86 sessionDirectories: List<String>,
87 lastChosenDirectory: String?,
89 onCreate: (directory: String?, title: String?) -> Unit,
90 onDismiss: () -> Unit,
92 // Directories to list as selectable rows: known projects + directories of existing
93 // sessions + the last chosen one, minus the server default (which has its own row).
94 val knownDirs = remember(options.projects, sessionDirectories, lastChosenDirectory, options.serverDefault) {
95 (options.projects + sessionDirectories + listOfNotNull(lastChosenDirectory))
96 .filter { it.isNotBlank() && it != options.serverDefault }
100 // Preselect the last chosen directory (or the most-recent session's directory); both are
101 // included in knownDirs above, so a non-null, non-default preselect always maps to a row.
102 var choice by rememberSaveable(stateSaver = dirChoiceSaver) {
103 val preselect = lastChosenDirectory ?: sessionDirectories.firstOrNull { it.isNotBlank() }
105 if (preselect == null || preselect == options.serverDefault) DirChoice.ServerDefault
106 else DirChoice.Known(preselect),
109 var customText by rememberSaveable { mutableStateOf("") }
110 // Optional title so a new session doesn't land in the list as "Untitled session" requiring a
111 // rename. Trimmed to a sane max; an all-whitespace value is treated as no title.
112 var titleText by rememberSaveable { mutableStateOf("") }
113 val titleLimit = NetworkConfig.maxSessionTitleChars
115 // The initializer above may have run while options were still loading (serverDefault
116 // null), pinning choice to DirChoice.Known(preselect). Once the real serverDefault
117 // arrives, knownDirs filters that path out (it has its own row), so no DirectoryRow
118 // matches a Known(serverDefault) choice and every radio shows unselected. Collapse it
119 // into ServerDefault so the selected radio always tracks a visible row.
120 LaunchedEffect(options.serverDefault) {
122 if (current is DirChoice.Known && current.path == options.serverDefault) {
123 choice = DirChoice.ServerDefault
127 val resolved: String? = when (val c = choice) {
128 is DirChoice.ServerDefault -> null
129 is DirChoice.Known -> c.path
130 is DirChoice.Custom -> customText.trim().takeIf { it.isNotBlank() }
132 val resolvedTitle: String? = titleText.trim().takeIf { it.isNotBlank() }
133 // Custom requires a non-blank path; the other choices are always valid.
134 val canCreate = !creating && (choice !is DirChoice.Custom || customText.isNotBlank())
137 onDismissRequest = { if (!creating) onDismiss() },
138 title = { Text(stringResource(R.string.new_session)) },
143 .heightIn(max = 380.dp)
144 .verticalScroll(rememberScrollState()),
148 onValueChange = { titleText = it.take(titleLimit) },
149 modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
150 label = { Text(stringResource(R.string.session_title_hint)) },
151 placeholder = { Text(stringResource(R.string.new_session)) },
154 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
155 supportingText = { TitleCharCounter(titleText.length, titleLimit) },
158 stringResource(R.string.directory_label),
159 style = MaterialTheme.typography.labelLarge,
160 color = MaterialTheme.colorScheme.onSurfaceVariant,
161 modifier = Modifier.padding(bottom = 4.dp),
165 label = stringResource(R.string.directory_server_default),
166 sublabel = options.serverDefault,
167 selected = choice is DirChoice.ServerDefault,
169 onSelect = { choice = DirChoice.ServerDefault },
172 knownDirs.forEach { path ->
174 label = path.trimEnd('/').substringAfterLast('/').ifBlank { path },
176 selected = (choice as? DirChoice.Known)?.path == path,
178 onSelect = { choice = DirChoice.Known(path) },
183 label = stringResource(R.string.directory_custom),
185 selected = choice is DirChoice.Custom,
187 onSelect = { choice = DirChoice.Custom },
189 if (choice is DirChoice.Custom) {
192 onTextChange = { customText = it },
194 canCreate = canCreate,
195 onCreate = { onCreate(resolved, resolvedTitle) },
199 if (options.loading && !options.loaded) {
201 modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
202 verticalAlignment = Alignment.CenterVertically,
204 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
206 stringResource(R.string.directory_loading),
207 style = MaterialTheme.typography.bodySmall,
208 color = MaterialTheme.colorScheme.onSurfaceVariant,
209 modifier = Modifier.padding(start = 8.dp),
216 TextButton(onClick = { onCreate(resolved, resolvedTitle) }, enabled = canCreate) {
218 CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
220 Text(stringResource(R.string.create))
225 TextButton(onClick = onDismiss, enabled = !creating) { Text(stringResource(R.string.cancel)) }
230/** Character counter for the session-title field, shown only once the title nears the cap so a
231 * normal short title isn't cluttered. Extracted from [NewSessionDialog] to keep its complexity
232 * under the detekt threshold. */
234private fun TitleCharCounter(length: Int, limit: Int) {
235 if (length >= (limit * 0.8f).toInt()) {
236 Text("$length / $limit")
240/** The custom-path text field shown when the Custom directory choice is selected. Autofocuses
241 * on appearance and marks itself error when blank so the user sees why Create is disabled.
242 * Extracted from [NewSessionDialog] to keep its cyclomatic complexity under the threshold. */
244private fun CustomPathField(
246 onTextChange: (String) -> Unit,
249 onCreate: () -> Unit,
251 // Autofocus the path field the moment the Custom row is chosen so the keyboard is ready
252 // without a second tap.
253 val customFocus = remember { FocusRequester() }
254 LaunchedEffect(Unit) { customFocus.requestFocus() }
255 // Mark the field error when Custom is selected but the path is still blank, so the user
256 // sees *why* Create is disabled instead of inferring it from the disabled button alone.
257 val customBlank = text.isBlank()
260 onValueChange = onTextChange,
263 .focusRequester(customFocus)
264 .padding(start = 36.dp, top = 4.dp, bottom = 4.dp),
265 placeholder = { Text(stringResource(R.string.directory_custom_hint)) },
268 isError = customBlank,
269 supportingText = if (customBlank) {
270 { Text(stringResource(R.string.custom_path_required), color = MaterialTheme.colorScheme.error) }
272 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
273 keyboardActions = KeyboardActions(onDone = { if (canCreate) onCreate() }),
278private fun DirectoryRow(
283 onSelect: () -> Unit,
288 .defaultMinSize(minHeight = 48.dp)
289 .selectable(selected = selected, enabled = enabled, role = Role.RadioButton, onClick = onSelect)
290 .padding(vertical = 4.dp),
291 verticalAlignment = Alignment.CenterVertically,
293 RadioButton(selected = selected, onClick = null, enabled = enabled)
294 Column(modifier = Modifier.padding(start = 12.dp)) {
297 style = MaterialTheme.typography.bodyLarge,
299 overflow = TextOverflow.Ellipsis,
301 if (!sublabel.isNullOrBlank() && sublabel != label) {
302 // Full path can be long; let it scroll horizontally rather than truncate so
303 // the user can verify exactly which directory they're selecting.
306 style = MaterialTheme.typography.bodySmall,
307 color = MaterialTheme.colorScheme.onSurfaceVariant,
308 fontFamily = FontFamily.Monospace,
310 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