ollie/kde/gui/ChatPane.qml

518 lines
22 KiB
QML

import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
Item {
id: chatPane
property bool sidebarVisible: true
property int maxVisibleBlocks: 80
signal toggleSidebar()
// Do not opt the chat pane into focus navigation.
activeFocusOnTab: false
// Expose method to set prompt text (for Edit & Resend)
function setPromptText(text) {
promptInput.text = text
promptInput.forceActiveFocus()
}
function scrollToBottom() {
chatView.positionViewAtEnd()
}
function trimVisibleBlocks() {
if (chatModel.count > chatPane.maxVisibleBlocks)
chatModel.trimFront(chatPane.maxVisibleBlocks)
}
function loadChat() {
chatModel.reset()
if (ollie.activeSessionId && ollie.activeAgentId) {
var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId)
if (log) {
chatModel.appendData(log)
chatModel.trimFront(chatPane.maxVisibleBlocks)
}
}
scrollToBottom()
}
Component.onCompleted: {
loadChat()
scrollToBottom()
}
ColumnLayout {
anchors.fill: parent
spacing: 0
// Header - shows active session/agent and state
RowLayout {
Layout.fillWidth: true
Layout.margins: 6
spacing: 8
// Sidebar toggle button (larger touch target)
ToolButton {
text: chatPane.sidebarVisible ? "◀" : "▶"
font.pixelSize: 14
implicitWidth: 32
implicitHeight: 32
onClicked: chatPane.toggleSidebar()
ToolTip.text: chatPane.sidebarVisible ? "Hide sidebar (Ctrl+B)" : "Show sidebar (Ctrl+B)"
ToolTip.visible: hovered
}
// Active session/agent label
Label {
text: {
var sessionName = ""
var agentName = ""
for (var i = 0; i < sessionModel.count; ++i) {
var item = sessionModel.get(i)
if (item.type === "session" && item.id === ollie.activeSessionId) {
sessionName = item.displayName || item.id
}
if (item.type === "agent" && item.agentId === ollie.activeAgentId && item.parentId === ollie.activeSessionId) {
agentName = item.displayName || item.agentId
}
}
if (sessionName && agentName) {
return sessionName + " / " + agentName
} else if (sessionName) {
return sessionName + " (no agent)"
}
return "(no session)"
}
font.bold: true
font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
elide: Text.ElideMiddle
Layout.fillWidth: true
}
// State indicator
Rectangle {
width: 10; height: 10; radius: 5
color: {
if (!ollie.activeAgentId) return "#9e9e9e" // gray when no agent
if (ollie.activeSessionId && ollie.isSessionPaused(ollie.activeSessionId)) return "#9e9e9e" // gray when paused
if (ollie.activeState === "idle") return "#4caf50"
if (ollie.activeState.startsWith("calling")) return "#ff9800"
return "#2196f3"
}
}
Label {
text: {
if (!ollie.activeAgentId) return ""
if (ollie.activeSessionId && ollie.isSessionPaused(ollie.activeSessionId)) return "inactive"
return ollie.activeState
}
opacity: 0.7
font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.85 : 11
}
}
Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor }
// Chat display — block-based ListView with scroll-to-bottom button
Item {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
// Placeholder when no agent selected or disconnected
Label {
anchors.centerIn: parent
text: {
if (!ollie.daemonConnected) return "Not connected"
if (!ollie.activeSessionId) return "Select a session"
return "Select an agent to view chat"
}
color: theme.midColor
font.italic: true
font.pixelSize: theme.fontSize > 0 ? theme.fontSize : 12
visible: !ollie.activeAgentId || !ollie.daemonConnected
}
ListView {
id: chatView
model: chatModel
spacing: 4
anchors.fill: parent
anchors.margins: 4
visible: ollie.daemonConnected && ollie.activeAgentId !== ""
onCountChanged: {
if (!scrollButton.visible) chatPane.scrollToBottom()
}
onContentHeightChanged: {
if (!scrollButton.visible) chatPane.scrollToBottom()
}
delegate: ChatBlockDelegate {
width: chatView.width - 8
}
}
// Chat overview and navigation minimap
Rectangle {
id: chatMinimap
anchors.top: chatView.top
anchors.right: parent.right
anchors.bottom: chatView.bottom
width: 24
color: Qt.rgba(theme.midColor.r, theme.midColor.g, theme.midColor.b, 0.05)
visible: chatView.visible && chatModel.count > 0
Column {
anchors.fill: parent
spacing: 1
Repeater {
model: chatModel
Rectangle {
width: chatMinimap.width
height: Math.max(2, chatMinimap.height / Math.max(1, chatModel.count) - 1)
opacity: 0.65
color: {
switch (model.blockType) {
case "user": return "#4caf50"
case "assistant": return "#2196f3"
case "error": return "#f44336"
case "tool": return "#ff9800"
default: return theme.midColor
}
}
}
}
}
MouseArea {
anchors.fill: parent
onClicked: chatView.positionViewAtIndex(
Math.floor(mouse.y / height * chatModel.count), ListView.Beginning)
}
}
Button {
id: scrollButton
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.margins: 12
text: "↓"
visible: chatView.visible && !chatView.atYEnd
onClicked: chatPane.scrollToBottom()
ToolTip.text: "Scroll to bottom"
ToolTip.visible: hovered
}
}
Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor }
// Action toolbar — quick actions without keyboard
RowLayout {
id: actionToolbar
Layout.fillWidth: true
Layout.margins: 4
spacing: 4
visible: ollie.daemonConnected && ollie.activeAgentId !== ""
ToolButton {
text: "⏹ Stop"
font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11
implicitHeight: 28
enabled: ollie.activeState !== "idle"
onClicked: ollie.interrupt()
ToolTip.visible: hovered
ToolTip.text: "Stop the agent (Escape)"
}
ToolButton {
text: "📦 Compact"
font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11
implicitHeight: 28
onClicked: ollie.submit("/compact")
ToolTip.visible: hovered
ToolTip.text: "Compact context window"
}
ToolButton {
text: "📋 Copy Last"
font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11
implicitHeight: 28
enabled: chatModel.count > 0
onClicked: {
// Find last assistant block and copy its content
for (var i = chatModel.count - 1; i >= 0; i--) {
var block = chatModel.get(i)
if (block && block.blockType === "assistant") {
// The renderedHtml is HTML, but we want plain text
// For now, copy the HTML - TODO: strip tags
clipboard.copyText(block.blockRenderedHtml.replace(/<[^>]*>/g, ''))
break
}
}
}
ToolTip.visible: hovered
ToolTip.text: "Copy last assistant response"
}
// Templates dropdown
ToolButton {
id: templateButton
text: "📝 Templates ▾"
font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11
implicitHeight: 28
onClicked: templateMenu.popup()
Menu {
id: templateMenu
MenuItem { text: "Yes"; onTriggered: ollie.submit("Yes") }
MenuItem { text: "No"; onTriggered: ollie.submit("No") }
MenuSeparator {}
MenuItem { text: "Explain this"; onTriggered: promptInput.text = "Explain this:\n" }
MenuItem { text: "Fix the bug"; onTriggered: promptInput.text = "Fix the bug in " }
MenuItem { text: "Write tests"; onTriggered: promptInput.text = "Write tests for " }
MenuItem { text: "Summarize"; onTriggered: promptInput.text = "Summarize " }
MenuItem { text: "Refactor"; onTriggered: promptInput.text = "Refactor " }
MenuItem { text: "Add comments"; onTriggered: promptInput.text = "Add comments to " }
MenuSeparator {}
MenuItem { text: "Continue"; onTriggered: ollie.submit("continue") }
MenuItem { text: "Retry"; onTriggered: ollie.submit("retry") }
}
}
Item { Layout.fillWidth: true } // Spacer
}
// Input area
RowLayout {
id: inputRow
Layout.fillWidth: true
Layout.margins: 6
spacing: 4
function doSubmit() {
if (promptInput.text.trim() !== "") {
ollie.submit(promptInput.text.trim())
promptInput.text = ""
}
}
// Clear button
ToolButton {
text: "✕"
font.pixelSize: 12
implicitWidth: 28
implicitHeight: 28
Layout.alignment: Qt.AlignBottom
visible: promptInput.text.length > 0
onClicked: promptInput.text = ""
ToolTip.visible: hovered
ToolTip.text: "Clear input"
}
// TextArea in ScrollView for overflow, with mouse chording
Item {
Layout.fillWidth: true
Layout.maximumHeight: 200
Layout.preferredHeight: Math.min(promptInput.contentHeight + 16, 200)
clip: true
ScrollView {
id: promptScroll
anchors.fill: parent
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
TextArea {
id: promptInput
width: promptScroll.width
placeholderText: {
if (!ollie.daemonConnected) return "Not connected"
if (!ollie.activeSessionId) return "No session"
if (!ollie.activeAgentId) return "No agent"
if (ollie.isSessionPaused(ollie.activeSessionId)) return "Session paused"
return "B1=sel B2=exec B3=plumb B1+B2=cut B1+B3=paste"
}
enabled: ollie.daemonConnected && ollie.activeSessionId !== "" && ollie.activeAgentId !== "" && !ollie.isSessionPaused(ollie.activeSessionId)
wrapMode: TextEdit.Wrap
selectByMouse: true
persistentSelection: true
property string _lastText: ""
property int _lastCursorPos: 0
onTextChanged: {
_lastText = text
_lastCursorPos = cursorPosition
}
Keys.onReturnPressed: function(event) {
if (event.modifiers & Qt.ShiftModifier) {
event.accepted = false
} else {
inputRow.doSubmit()
event.accepted = true
}
}
}
}
// Acme-style mouse chording overlay
// B1 = select (handled by TextArea)
// B2 = execute selection
// B3 = plumb selection / context menu
// B1+B2 = cut (snarf)
// B1+B3 = paste
MouseArea {
id: chordArea
anchors.fill: parent
acceptedButtons: Qt.MiddleButton | Qt.RightButton
propagateComposedEvents: true
cursorShape: Qt.IBeamCursor
// Track if B1 (left) is currently held for chord detection
property bool leftHeld: false
// We can't directly detect B1 here since TextArea handles it,
// but we can check mouse.buttons in the handlers
onPressed: function(mouse) {
// Check if this is a chord (B1 already held)
if (mouse.buttons & Qt.LeftButton) {
leftHeld = true
if (mouse.button === Qt.MiddleButton) {
// B1+B2 = cut
promptInput.cut()
mouse.accepted = true
return
} else if (mouse.button === Qt.RightButton) {
// B1+B3 = paste
promptInput.paste()
mouse.accepted = true
return
}
}
leftHeld = false
mouse.accepted = false // Let it propagate for non-chord
}
onClicked: function(mouse) {
// Only handle non-chord clicks
if (leftHeld) {
leftHeld = false
return
}
if (mouse.button === Qt.MiddleButton) {
// B2: Execute selection as prompt
var sel = promptInput.selectedText
if (sel && sel.trim() !== "") {
ollie.submit(sel.trim())
} else {
// No selection - paste from clipboard and execute?
// Or just ignore. For now, ignore.
}
} else if (mouse.button === Qt.RightButton) {
// B3: Plumb selection or show context menu
var sel = promptInput.selectedText
if (sel && sel.trim() !== "" && plumber.available()) {
plumber.plumb(sel.trim())
} else {
promptContextMenu.popup()
}
}
}
onReleased: {
leftHeld = false
}
}
Menu {
id: promptContextMenu
MenuItem {
text: "Execute Selection (B2)"
enabled: promptInput.selectedText.length > 0
onTriggered: {
var sel = promptInput.selectedText
if (sel && sel.trim() !== "") ollie.submit(sel.trim())
}
}
MenuItem {
text: "Plumb Selection (B3)"
enabled: promptInput.selectedText.length > 0 && plumber.available()
onTriggered: {
var sel = promptInput.selectedText
if (sel && sel.trim() !== "") plumber.plumb(sel.trim())
}
}
MenuSeparator {}
MenuItem {
text: "Cut (B1+B2)"
enabled: promptInput.selectedText.length > 0
onTriggered: promptInput.cut()
}
MenuItem {
text: "Copy"
enabled: promptInput.selectedText.length > 0
onTriggered: promptInput.copy()
}
MenuItem {
text: "Paste (B1+B3)"
enabled: promptInput.canPaste
onTriggered: promptInput.paste()
}
MenuSeparator {}
MenuItem {
text: "Select All"
onTriggered: promptInput.selectAll()
}
}
}
// Paste button (mouse-first workflow)
ToolButton {
text: "📋"
font.pixelSize: 14
implicitWidth: 36
implicitHeight: 36
Layout.alignment: Qt.AlignBottom
enabled: promptInput.enabled
onClicked: promptInput.paste()
ToolTip.visible: hovered
ToolTip.text: "Paste from clipboard"
}
// Send button (larger touch target)
Button {
text: "▶"
font.pixelSize: 16
Layout.alignment: Qt.AlignBottom
Layout.preferredWidth: 48
Layout.preferredHeight: 48
focusPolicy: Qt.NoFocus
enabled: promptInput.enabled && promptInput.text.trim() !== ""
onClicked: inputRow.doSubmit()
ToolTip.visible: hovered
ToolTip.text: "Send (Enter)"
}
}
}
Connections {
target: chatModel
function onCountChanged() { chatPane.trimVisibleBlocks() }
}
Connections {
target: ollie
function onActiveSessionIdChanged() {
if (!ollie.activeAgentId) chatPane.loadChat()
}
function onActiveAgentIdChanged() {
if (ollie.activeAgentId) chatPane.loadChat()
}
}
}