fix(#599): place cursor at end of text when a slash command is inserted from the dropdown (#600)

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andhy4ri authored and GitHub committed 2026-07-15 10:36:18 +03:00
1 parent 5376d46683
commit 0823fe3117
3 files changed
+54 -18

No files matched your search

@@ -1,5 +1,8 @@
package com.m57.hermescontrol.ui.chat package com.m57.hermescontrol.ui.chat
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
/** /**
* Pure, testable decision logic for the chat input bar. * Pure, testable decision logic for the chat input bar.
* *
@@ -32,4 +35,14 @@ object ChatInputPolicy {
text: String, text: String,
isAgentTyping: Boolean, isAgentTyping: Boolean,
): Boolean = isAgentTyping && text.isNotBlank() ): Boolean = isAgentTyping && text.isNotBlank()
/**
* Builds the [TextFieldValue] for a slash command inserted via the
* suggestion dropdown. The cursor is placed at the END of the text, not the
* middle (issue #599): a plain-String [OutlinedTextField] value leaves the
* cursor at the end of the shared prefix when an external update replaces
* `/h` with `/help`, so the dropdown click must carry an explicit end
* selection.
*/
fun commandFieldValue(command: String): TextFieldValue = TextFieldValue(command, TextRange(command.length))
} }
@@ -114,6 +114,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -187,7 +188,9 @@ fun ChatScreen(
} }
} }
val scrollScope = rememberCoroutineScope() val scrollScope = rememberCoroutineScope()
var inputText by rememberSaveable { mutableStateOf("") } var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(""))
}
var isListening by rememberSaveable { mutableStateOf(false) } var isListening by rememberSaveable { mutableStateOf(false) }
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) } var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
var showReloginDialog by rememberSaveable { mutableStateOf(false) } var showReloginDialog by rememberSaveable { mutableStateOf(false) }
@@ -212,12 +215,13 @@ fun ChatScreen(
?.firstOrNull() ?.firstOrNull()
.orEmpty() .orEmpty()
if (spokenText.isNotBlank()) { if (spokenText.isNotBlank()) {
inputText = val merged =
if (inputText.isBlank()) { if (inputFieldValue.text.isBlank()) {
spokenText spokenText
} else { } else {
"$inputText $spokenText" "${inputFieldValue.text} $spokenText"
} }
inputFieldValue = ChatInputPolicy.commandFieldValue(merged)
} }
} }
} }
@@ -469,11 +473,11 @@ fun ChatScreen(
} }
ChatInputBar( ChatInputBar(
inputText = inputText, inputFieldValue = inputFieldValue,
onInputChange = { inputText = it }, onInputChange = { inputFieldValue = it },
onSend = { onSend = {
viewModel.sendMessage(inputText) viewModel.sendMessage(inputFieldValue.text)
inputText = "" inputFieldValue = TextFieldValue("")
scrollScope.launch { scrollScope.launch {
val totalItems = val totalItems =
state.messages.size + state.messages.size +
@@ -686,8 +690,8 @@ private fun ReasoningIndicator(reasoningText: String) {
@Composable @Composable
private fun ChatInputBar( private fun ChatInputBar(
inputText: String, inputFieldValue: TextFieldValue,
onInputChange: (String) -> Unit, onInputChange: (TextFieldValue) -> Unit,
onSend: () -> Unit, onSend: () -> Unit,
onMicTap: () -> Unit, onMicTap: () -> Unit,
isListening: Boolean, isListening: Boolean,
@@ -704,8 +708,8 @@ private fun ChatInputBar(
// gateway's prompt.submit busy-input policy queues it as the next turn // gateway's prompt.submit busy-input policy queues it as the next turn
// (tui_gateway/server.py:_handle_busy_submit), so the message is never // (tui_gateway/server.py:_handle_busy_submit), so the message is never
// dropped. Slash commands were already allowed; regular prompts now are too. // dropped. Slash commands were already allowed; regular prompts now are too.
val isSlashCommand = inputText.startsWith("/") val isSlashCommand = inputFieldValue.text.startsWith("/")
val canSend = ChatInputPolicy.canSend(inputText, pendingAttachments, isConnected) val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected)
// Attachment menu state // Attachment menu state
var showAttachmentMenu by remember { mutableStateOf(false) } var showAttachmentMenu by remember { mutableStateOf(false) }
@@ -776,11 +780,12 @@ private fun ChatInputBar(
val commandNames = commandCatalog.pairs.map { it[0] }.filter { it !in hiddenSlashDisplay } val commandNames = commandCatalog.pairs.map { it[0] }.filter { it !in hiddenSlashDisplay }
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
visible = inputText.startsWith("/") && !inputText.contains(" "), visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "),
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(), enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(), exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
) { ) {
val filteredCommands = commandNames.filter { it.startsWith(inputText, ignoreCase = true) } val filteredCommands =
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) }
if (filteredCommands.isNotEmpty()) { if (filteredCommands.isNotEmpty()) {
androidx.compose.material3.Surface( androidx.compose.material3.Surface(
modifier = modifier =
@@ -807,7 +812,7 @@ private fun ChatInputBar(
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
}, },
onClick = { onInputChange(cmd) }, onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) },
) )
} }
} }
@@ -905,7 +910,7 @@ private fun ChatInputBar(
} }
OutlinedTextField( OutlinedTextField(
value = inputText, value = inputFieldValue,
onValueChange = onInputChange, onValueChange = onInputChange,
modifier = modifier =
Modifier Modifier
@@ -917,7 +922,7 @@ private fun ChatInputBar(
Text( Text(
if (!isConnected) { if (!isConnected) {
stringResource(R.string.chat_input_placeholder_not_connected) stringResource(R.string.chat_input_placeholder_not_connected)
} else if (ChatInputPolicy.showQueuePlaceholder(inputText, isAgentTyping)) { } else if (ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping)) {
stringResource(R.string.chat_input_placeholder_queue) stringResource(R.string.chat_input_placeholder_queue)
} else if (isAgentTyping) { } else if (isAgentTyping) {
stringResource(R.string.chat_input_placeholder_waiting) stringResource(R.string.chat_input_placeholder_waiting)
@@ -946,7 +951,7 @@ private fun ChatInputBar(
targetState = targetState =
when { when {
isListening -> "listening" isListening -> "listening"
inputText.isBlank() && pendingAttachments.isEmpty() -> "mic" inputFieldValue.text.isBlank() && pendingAttachments.isEmpty() -> "mic"
else -> "send" else -> "send"
}, },
transitionSpec = { transitionSpec = {
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat package com.m57.hermescontrol.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -89,4 +90,21 @@ class ChatInputPolicyTest {
ChatInputPolicy.canSend("/stop", emptyList(), isConnected = true), ChatInputPolicy.canSend("/stop", emptyList(), isConnected = true),
) )
} }
@Test
fun commandFieldValue_placesCursorAtEnd() {
val cmd = "/help"
val value = ChatInputPolicy.commandFieldValue(cmd)
assertEquals("command text must be preserved", cmd, value.text)
assertEquals("cursor must be at the end, not the middle", cmd.length, value.selection.start)
assertEquals("selection must be collapsed at the end", cmd.length, value.selection.end)
}
@Test
fun commandFieldValue_endSelectionOnPrefixReplacement() {
// Regression guard for issue #599: inserting /help over an existing /h
// prefix must not leave the cursor at position 2 (middle of the text).
val value = ChatInputPolicy.commandFieldValue("/help")
assertTrue("cursor must be past the shared /h prefix", value.selection.start > 2)
}
} }