diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt index 3952463..701d9b6 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.nativeKeyCode import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.testTag @@ -42,12 +43,15 @@ import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing import dev.blazelight.p4oc.ui.theme.TuiCodeFontSize +import android.view.KeyEvent as AndroidKeyEvent data class ModelOption( val key: String, val displayName: String ) +internal val LocalPromptHistory = staticCompositionLocalOf> { emptyList() } + private fun nextCommandIndex( currentIndex: Int, delta: Int, @@ -78,13 +82,19 @@ fun ChatInputBar( enterToSend: Boolean = false, ) { val theme = LocalOpenCodeTheme.current + val promptHistory = LocalPromptHistory.current val focusRequester = remember { FocusRequester() } val textState = rememberTextFieldState(initialText = value) + var historyIndex by remember { mutableStateOf(null) } + var draftBeforeHistory by remember { mutableStateOf("") } + val currentText = textState.text.toString() // External value changes (e.g. a programmatic set from the parent) → field. LaunchedEffect(value) { if (value != textState.text.toString()) { textState.setTextAndPlaceCursorAtEnd(value) + historyIndex = null + draftBeforeHistory = value } } // Field edits → hoisted state. TextFieldState manages the IME composing @@ -93,6 +103,13 @@ fun ChatInputBar( LaunchedEffect(textState) { snapshotFlow { textState.text.toString() }.collect { onValueChange(it) } } + LaunchedEffect(currentText, historyIndex, promptHistory) { + val activeHistoryText = historyIndex?.let { promptHistory.getOrNull(it) } + if (activeHistoryText != null && currentText != activeHistoryText) { + historyIndex = null + draftBeforeHistory = currentText + } + } // Request focus when triggered LaunchedEffect(requestFocus) { @@ -106,7 +123,6 @@ fun ChatInputBar( } // Determine button state - val currentText = textState.text.toString() val hasContent = currentText.isNotBlank() || attachedFiles.isNotEmpty() val queueIsFull = queuedCount >= 10 val canSend = hasContent && enabled && !isLoading && !isBusy @@ -158,6 +174,26 @@ fun ChatInputBar( // TextFieldState.clearText() resets the editing buffer including the IME // composing region, so the field stays cleared after send. textState.clearText() + historyIndex = null + draftBeforeHistory = "" + } + + fun navigatePromptHistory(delta: Int): Boolean { + if (promptHistory.isEmpty()) return false + val currentIndex = historyIndex + val nextIndex = when { + currentIndex == null -> { + draftBeforeHistory = currentText + if (delta < 0) promptHistory.lastIndex else 0 + } + delta < 0 -> (currentIndex - 1).coerceAtLeast(0) + currentIndex < promptHistory.lastIndex -> currentIndex + 1 + else -> null + } + + historyIndex = nextIndex + textState.setTextAndPlaceCursorAtEnd(nextIndex?.let(promptHistory::get) ?: draftBeforeHistory) + return true } fun submitFromEnter(): Boolean = when { @@ -314,7 +350,11 @@ fun ChatInputBar( else -> false } } - else -> false + else -> when (event.key.nativeKeyCode) { + AndroidKeyEvent.KEYCODE_VOLUME_UP -> navigatePromptHistory(delta = -1) + AndroidKeyEvent.KEYCODE_VOLUME_DOWN -> navigatePromptHistory(delta = 1) + else -> false + } } } .testTag("chat_input"), diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt index 2c204cc..ac71d58 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt @@ -26,6 +26,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.blazelight.p4oc.R import dev.blazelight.p4oc.core.network.ConnectionState +import dev.blazelight.p4oc.domain.model.Message +import dev.blazelight.p4oc.domain.model.MessageWithParts import dev.blazelight.p4oc.domain.model.Part import dev.blazelight.p4oc.domain.model.SessionConnectionState import dev.blazelight.p4oc.domain.model.SessionPresence @@ -37,6 +39,7 @@ import dev.blazelight.p4oc.ui.components.TuiTopBar import dev.blazelight.p4oc.ui.components.chat.ChatInputBar import dev.blazelight.p4oc.ui.components.chat.FilePickerDialog import dev.blazelight.p4oc.ui.components.chat.JumpToBottomButton +import dev.blazelight.p4oc.ui.components.chat.LocalPromptHistory import dev.blazelight.p4oc.ui.components.chat.ModelAgentSelectorBar import dev.blazelight.p4oc.ui.components.chat.QueuedMessagesStrip import dev.blazelight.p4oc.ui.components.command.CommandPalette @@ -144,6 +147,7 @@ fun ChatScreen( var currentMatchIndex by remember(uiState.session?.id) { mutableStateOf(0) } val messageBlocks = remember(messages) { groupMessagesIntoBlocks(messages) } val searchMatches = remember(messageBlocks, searchQuery) { findChatMatches(messageBlocks, searchQuery) } + val promptHistory = remember(messages) { messages.toPromptHistory() } // Scroll UX state: follow new tail content only while the user remains pinned to bottom. var shouldFollowTail by remember(uiState.session?.id) { mutableStateOf(true) } @@ -289,35 +293,37 @@ fun ChatScreen( recentModels = recentModels, onToggleFavorite = viewModel.modelAgentManager::toggleFavoriteModel ) - ChatInputBar( - value = uiState.inputText, - onValueChange = { text -> - viewModel.updateInput(text) - if (text.startsWith("/") && !text.contains(" ")) { - viewModel.refreshCommandsIfNeeded() - } - }, - onSend = viewModel::sendMessage, - isLoading = uiState.isSending, - enabled = connectionState is ConnectionState.Connected, - isBusy = uiState.isBusy, - queuedCount = uiState.queuedMessages.size, - onQueueMessage = viewModel::queueMessage, - onAbort = viewModel::abortSession, - attachedFiles = attachedFiles, - onAttachClick = { - viewModel.filePickerManager.loadPickerFiles() - showFilePicker = true - }, - onRemoveAttachment = viewModel.filePickerManager::detachFile, - commands = uiState.commands, - isLoadingCommands = uiState.isLoadingCommands, - commandLoadError = uiState.commandLoadError, - onRetryCommands = { viewModel.refreshCommandsIfNeeded(force = true) }, - onCommandSelected = { /* Command text is already updated via onValueChange */ }, - requestFocus = isActiveTab, - enterToSend = chatSettings.enterToSend, - ) + CompositionLocalProvider(LocalPromptHistory provides promptHistory) { + ChatInputBar( + value = uiState.inputText, + onValueChange = { text -> + viewModel.updateInput(text) + if (text.startsWith("/") && !text.contains(" ")) { + viewModel.refreshCommandsIfNeeded() + } + }, + onSend = viewModel::sendMessage, + isLoading = uiState.isSending, + enabled = connectionState is ConnectionState.Connected, + isBusy = uiState.isBusy, + queuedCount = uiState.queuedMessages.size, + onQueueMessage = viewModel::queueMessage, + onAbort = viewModel::abortSession, + attachedFiles = attachedFiles, + onAttachClick = { + viewModel.filePickerManager.loadPickerFiles() + showFilePicker = true + }, + onRemoveAttachment = viewModel.filePickerManager::detachFile, + commands = uiState.commands, + isLoadingCommands = uiState.isLoadingCommands, + commandLoadError = uiState.commandLoadError, + onRetryCommands = { viewModel.refreshCommandsIfNeeded(force = true) }, + onCommandSelected = { /* Command text is already updated via onValueChange */ }, + requestFocus = isActiveTab, + enterToSend = chatSettings.enterToSend, + ) + } } } } @@ -709,4 +715,16 @@ private suspend fun LazyListState.scrollChatToBottom() { if (target >= 0) scrollToItem(target, Int.MAX_VALUE) } +private fun List.toPromptHistory(): List { + val prompts = mapNotNull { messageWithParts -> + if (messageWithParts.message !is Message.User) return@mapNotNull null + messageWithParts.parts + .filterIsInstance() + .joinToString(separator = "\n") { it.text } + .trim() + .takeIf { it.isNotEmpty() } + } + return prompts.asReversed().distinct().asReversed() +} + // MessageBlock, groupMessagesIntoBlocks, and MessageBlockView are now in MessageBlockUtils.kt