1package soy.iko.opencode.ui.chat
3import androidx.compose.foundation.layout.Arrangement
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.rememberScrollState
10import androidx.compose.foundation.text.selection.SelectionContainer
11import androidx.compose.foundation.verticalScroll
12import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.filled.Shield
14import androidx.compose.material3.AlertDialog
15import androidx.compose.material3.Button
16import androidx.compose.material3.Icon
17import androidx.compose.material3.MaterialTheme
18import androidx.compose.material3.OutlinedButton
19import androidx.compose.material3.Text
20import androidx.compose.material3.TextButton
21import androidx.compose.runtime.Composable
22import androidx.compose.runtime.mutableStateOf
23import androidx.compose.runtime.remember
24import androidx.compose.runtime.getValue
25import androidx.compose.runtime.setValue
26import androidx.compose.ui.Modifier
27import androidx.compose.ui.platform.LocalHapticFeedback
28import androidx.compose.ui.res.stringResource
29import androidx.compose.ui.text.font.FontFamily
30import androidx.compose.ui.unit.dp
31import soy.iko.opencode.data.model.Permission
32import soy.iko.opencode.data.model.PermissionResponse
33import soy.iko.opencode.R
36 * Modal asking the user to approve a tool the agent wants to run. The agent run is
37 * paused until one of these responses is sent. Non-dismissable by tap-outside so the
38 * decision is explicit.
42 permission: Permission,
43 onRespond: (PermissionResponse) -> Unit,
47 val haptics = LocalHapticFeedback.current
48 // Guard against double-respond: back press + button tap, or rapid double-tap,
49 // could call onRespond twice. Once a response is sent, subsequent calls are no-ops.
50 // Keyed on permission.id: the host mounts this dialog against a conflating StateFlow,
51 // so a null -> permissionB transition can collapse to just permissionB, reusing this
52 // composable for a different permission. Without the key `responded` would stay true
53 // and every button (and back-press) would be a no-op — an undismissable modal.
54 // Plain `remember` (not `rememberSaveable`): a failed respondPermission re-enqueues
55 // the same permission id (ChatViewModel.respondPermission -> enqueuePermission), and
56 // rememberSaveable would restore a stale `true` from the Bundle across a config
57 // change in between, locking the re-queued modal with all buttons no-op. Plain
58 // remember resets to false on every fresh composition, so a re-queued permission is
60 var responded by remember(permission.id) { mutableStateOf(false) }
61 val respond: (PermissionResponse) -> Unit = { response ->
68 // Back press routes here (dismissOnBackPress defaults true) and is treated as an
69 // explicit reject — the safe default. Tap-outside is disabled below so it can't
70 // dismiss silently. A host-composition BackHandler doesn't work: AlertDialog
71 // renders in its own window whose back dispatcher never reaches the host.
72 onDismissRequest = { respond(PermissionResponse.REJECT) },
73 properties = androidx.compose.ui.window.DialogProperties(dismissOnClickOutside = false),
74 icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
76 Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
77 // A backlog indicator ("N of M") when more than one request is queued, so the
78 // user knows the newest dialog isn't the only pending decision.
81 stringResource(R.string.permission_progress, position, total),
82 style = MaterialTheme.typography.labelMedium,
83 color = MaterialTheme.colorScheme.onSurfaceVariant,
86 Text(permission.title ?: stringResource(R.string.permission_title))
90 Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
91 // The command / pattern can be long or multi-line; make it selectable and cap
92 // its height with an inner scroll so it can't push the Allow/Reject buttons off
93 // screen. Keeps the monospace styling.
97 .heightIn(max = 200.dp)
98 .verticalScroll(rememberScrollState()),
99 verticalArrangement = Arrangement.spacedBy(8.dp),
101 permission.type?.let {
102 Text(it, style = MaterialTheme.typography.labelLarge, fontFamily = FontFamily.Monospace)
104 permission.patternText?.let {
107 style = MaterialTheme.typography.bodyMedium,
108 fontFamily = FontFamily.Monospace,
109 color = MaterialTheme.colorScheme.onSurfaceVariant,
115 stringResource(R.string.permission_text),
116 style = MaterialTheme.typography.bodySmall,
117 color = MaterialTheme.colorScheme.onSurfaceVariant,
122 // At very large font scales (200%+ accessibility text scale) the four full-width
123 // buttons can overflow the dialog's max height, pushing Reject off-screen and
124 // making it unreachable — the opposite of safe. Scroll + cap the column so the
125 // full set stays answerable; vertically centering when it fits keeps the look
126 // unchanged at normal scales.
130 .heightIn(max = 320.dp)
131 .verticalScroll(rememberScrollState()),
132 verticalArrangement = Arrangement.spacedBy(4.dp),
136 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
137 respond(PermissionResponse.ONCE)
139 modifier = Modifier.fillMaxWidth(),
140 ) { Text(stringResource(R.string.allow_once)) }
143 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
144 respond(PermissionResponse.SESSION)
146 modifier = Modifier.fillMaxWidth(),
147 ) { Text(stringResource(R.string.allow_for_session)) }
150 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
151 respond(PermissionResponse.ALWAYS)
153 modifier = Modifier.fillMaxWidth(),
154 ) { Text(stringResource(R.string.always_allow)) }
155 // Reject is an OutlinedButton (not a low-prominence TextButton) in the
156 // same column so the actions are visually balanced — the prior layout buried
157 // Reject as a small dismiss-button, nudging users toward granting. Reject
158 // uses the error color to signal its consequence.
161 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
162 respond(PermissionResponse.REJECT)
164 modifier = Modifier.fillMaxWidth(),
165 ) { Text(stringResource(R.string.reject), color = MaterialTheme.colorScheme.error) }
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