Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<List<String>> { emptyList() }

private fun nextCommandIndex(
currentIndex: Int,
delta: Int,
Expand Down Expand Up @@ -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<Int?>(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
Expand All @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"),
Expand Down
76 changes: 47 additions & 29 deletions app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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,
)
}
}
}
}
Expand Down Expand Up @@ -709,4 +715,16 @@ private suspend fun LazyListState.scrollChatToBottom() {
if (target >= 0) scrollToItem(target, Int.MAX_VALUE)
}

private fun List<MessageWithParts>.toPromptHistory(): List<String> {
val prompts = mapNotNull { messageWithParts ->
if (messageWithParts.message !is Message.User) return@mapNotNull null
messageWithParts.parts
.filterIsInstance<Part.Text>()
.joinToString(separator = "\n") { it.text }
.trim()
.takeIf { it.isNotEmpty() }
}
return prompts.asReversed().distinct().asReversed()
}

// MessageBlock, groupMessagesIntoBlocks, and MessageBlockView are now in MessageBlockUtils.kt