Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user