kde/gui: Acme-style mouse actions and one-handed workflow
Add Plan 9/Acme-inspired mouse interaction: - B2 (middle-click): Execute selected text as prompt - B3 (right-click): Plumb selection via plan9port, fallback to context menu Plumber integration (plumber.h/cpp): - Wraps plan9port's plumb command for context-aware actions - Auto-detects plumb binary in common plan9port locations - Graceful fallback when plumber not available Block tag line (ChatBlockDelegate): - Hover-visible action buttons: Copy, Retry, Continue, Edit - Per-block-type actions (Retry for assistant/error, Edit for user) Action toolbar (ChatPane): - Stop, Compact, Copy Last, Templates dropdown - Mouse-first workflow without keyboard shortcuts Input area improvements: - Larger touch targets (48px send button, 32px toggle) - Clear and Paste buttons - B2/B3 mouse actions in prompt input - Placeholder text explains mouse actions Context menus now include: - Execute Selection (B2) - Plumb Selection (B3) - Standard Cut/Copy/Paste/Select All
This commit is contained in:
parent
5f68871c65
commit
75a2da080e
|
|
@ -152,6 +152,7 @@ if(OLLIE_KF5)
|
||||||
gui/chatblockmodel.cpp
|
gui/chatblockmodel.cpp
|
||||||
gui/thememanager.cpp
|
gui/thememanager.cpp
|
||||||
gui/clipboardhelper.cpp
|
gui/clipboardhelper.cpp
|
||||||
|
gui/plumber.cpp
|
||||||
${GUI_KF5_QRC}
|
${GUI_KF5_QRC}
|
||||||
)
|
)
|
||||||
add_dependencies(ollie-gui lib9p_build)
|
add_dependencies(ollie-gui lib9p_build)
|
||||||
|
|
@ -244,6 +245,7 @@ else()
|
||||||
gui/chatblockmodel.cpp
|
gui/chatblockmodel.cpp
|
||||||
gui/thememanager.cpp
|
gui/thememanager.cpp
|
||||||
gui/clipboardhelper.cpp
|
gui/clipboardhelper.cpp
|
||||||
|
gui/plumber.cpp
|
||||||
)
|
)
|
||||||
add_dependencies(ollie-gui lib9p_build)
|
add_dependencies(ollie-gui lib9p_build)
|
||||||
target_include_directories(ollie-gui PRIVATE
|
target_include_directories(ollie-gui PRIVATE
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
// ChatBlockDelegate.qml — Delegate for rendering chat blocks in the ListView
|
// ChatBlockDelegate.qml — Delegate for rendering chat blocks in the ListView
|
||||||
|
// Acme-style mouse actions:
|
||||||
|
// B1 = select (default)
|
||||||
|
// B2 (middle-click) = execute selection as prompt
|
||||||
|
// B3 (right-click) = plumb to plan9port / context menu
|
||||||
import QtQuick 2.15
|
import QtQuick 2.15
|
||||||
import QtQuick.Controls 2.15
|
import QtQuick.Controls 2.15
|
||||||
import QtQuick.Layouts 1.15
|
import QtQuick.Layouts 1.15
|
||||||
|
|
@ -28,6 +32,9 @@ Column {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show action buttons on hover
|
||||||
|
property bool hovered: headerArea.containsMouse || contentArea.containsMouse
|
||||||
|
|
||||||
// Header color based on block type and theme
|
// Header color based on block type and theme
|
||||||
function headerColor() {
|
function headerColor() {
|
||||||
var bg = theme.baseColor
|
var bg = theme.baseColor
|
||||||
|
|
@ -76,15 +83,17 @@ Column {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clickable header
|
// Clickable header with action buttons
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: root.width
|
width: root.width
|
||||||
height: 22
|
height: 28
|
||||||
radius: 3
|
radius: 3
|
||||||
color: root.headerColor()
|
color: root.headerColor()
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
|
id: headerArea
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: root.blockExpanded = !root.blockExpanded
|
onClicked: root.blockExpanded = !root.blockExpanded
|
||||||
}
|
}
|
||||||
|
|
@ -92,6 +101,7 @@ Column {
|
||||||
RowLayout {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.leftMargin: 6
|
anchors.leftMargin: 6
|
||||||
|
anchors.rightMargin: 6
|
||||||
spacing: 4
|
spacing: 4
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
|
|
@ -108,15 +118,86 @@ Column {
|
||||||
color: theme.textColor
|
color: theme.textColor
|
||||||
opacity: 0.9
|
opacity: 0.9
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Action buttons (visible on hover) — Acme-style tag line
|
||||||
|
Row {
|
||||||
|
spacing: 2
|
||||||
|
visible: root.hovered
|
||||||
|
opacity: 0.8
|
||||||
|
|
||||||
|
// Copy block content
|
||||||
|
ToolButton {
|
||||||
|
text: "📋"
|
||||||
|
font.pixelSize: 12
|
||||||
|
implicitWidth: 24
|
||||||
|
implicitHeight: 22
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.text: "Copy"
|
||||||
|
onClicked: {
|
||||||
|
var content = getBlockPlainText()
|
||||||
|
if (content) clipboard.copyText(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry (assistant blocks only)
|
||||||
|
ToolButton {
|
||||||
|
text: "🔄"
|
||||||
|
font.pixelSize: 12
|
||||||
|
implicitWidth: 24
|
||||||
|
implicitHeight: 22
|
||||||
|
visible: blockType === "assistant" || blockType === "error"
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.text: "Retry"
|
||||||
|
onClicked: ollie.submit("retry")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue (assistant blocks only)
|
||||||
|
ToolButton {
|
||||||
|
text: "➡️"
|
||||||
|
font.pixelSize: 12
|
||||||
|
implicitWidth: 24
|
||||||
|
implicitHeight: 22
|
||||||
|
visible: blockType === "assistant"
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.text: "Continue"
|
||||||
|
onClicked: ollie.submit("continue")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit & Resend (user blocks only)
|
||||||
|
ToolButton {
|
||||||
|
text: "✏️"
|
||||||
|
font.pixelSize: 12
|
||||||
|
implicitWidth: 24
|
||||||
|
implicitHeight: 22
|
||||||
|
visible: blockType === "user"
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.text: "Edit & Resend"
|
||||||
|
onClicked: {
|
||||||
|
var content = getBlockPlainText()
|
||||||
|
// Find the prompt input and set its text
|
||||||
|
// This requires the parent ChatPane to expose the input
|
||||||
|
chatPane.setPromptText(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block content rendered as segments
|
// Block content rendered as segments
|
||||||
Column {
|
Column {
|
||||||
|
id: contentColumn
|
||||||
width: root.width
|
width: root.width
|
||||||
visible: root.blockExpanded
|
visible: root.blockExpanded
|
||||||
spacing: 4
|
spacing: 4
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: contentArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
propagateComposedEvents: true
|
||||||
|
acceptedButtons: Qt.NoButton
|
||||||
|
}
|
||||||
|
|
||||||
// Fast path: single TextEdit for the concatenated HTML.
|
// Fast path: single TextEdit for the concatenated HTML.
|
||||||
// Diagrams are handled separately via the segments list.
|
// Diagrams are handled separately via the segments list.
|
||||||
TextEdit {
|
TextEdit {
|
||||||
|
|
@ -134,15 +215,36 @@ Column {
|
||||||
selectionColor: theme.highlightColor
|
selectionColor: theme.highlightColor
|
||||||
selectedTextColor: theme.highlightedTextColor
|
selectedTextColor: theme.highlightedTextColor
|
||||||
|
|
||||||
|
// Acme-style mouse handling: B2=execute, B3=plumb/menu
|
||||||
MouseArea {
|
MouseArea {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
acceptedButtons: Qt.RightButton
|
acceptedButtons: Qt.MiddleButton | Qt.RightButton
|
||||||
cursorShape: Qt.IBeamCursor
|
cursorShape: Qt.IBeamCursor
|
||||||
onClicked: {
|
propagateComposedEvents: true
|
||||||
if (!mainTextEdit._menu) {
|
|
||||||
mainTextEdit._menu = contextMenuComponent.createObject(mainTextEdit)
|
onClicked: function(mouse) {
|
||||||
|
if (mouse.button === Qt.MiddleButton) {
|
||||||
|
// B2: Execute selection as prompt
|
||||||
|
var sel = mainTextEdit.selectedText
|
||||||
|
if (sel && sel.trim() !== "") {
|
||||||
|
executeText(sel)
|
||||||
|
}
|
||||||
|
mouse.accepted = true
|
||||||
|
} else if (mouse.button === Qt.RightButton) {
|
||||||
|
// B3: Plumb selection, or show context menu
|
||||||
|
var sel = mainTextEdit.selectedText
|
||||||
|
if (sel && sel.trim() !== "" && plumber.available()) {
|
||||||
|
plumber.plumb(sel.trim())
|
||||||
|
mouse.accepted = true
|
||||||
|
} else {
|
||||||
|
// Fall back to context menu
|
||||||
|
if (!mainTextEdit._menu) {
|
||||||
|
mainTextEdit._menu = contextMenuComponent.createObject(mainTextEdit)
|
||||||
|
}
|
||||||
|
mainTextEdit._menu.popup()
|
||||||
|
mouse.accepted = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
mainTextEdit._menu.popup()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
property var _menu: null
|
property var _menu: null
|
||||||
|
|
@ -170,6 +272,51 @@ Column {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get plain text content of this block
|
||||||
|
function getBlockPlainText() {
|
||||||
|
// Use the mainTextEdit's text property stripped of HTML
|
||||||
|
if (mainTextEdit.visible) {
|
||||||
|
return mainTextEdit.getText(0, mainTextEdit.length)
|
||||||
|
}
|
||||||
|
// For diagram blocks, concatenate segment text
|
||||||
|
var text = ""
|
||||||
|
if (root.blockSegments) {
|
||||||
|
for (var i = 0; i < root.blockSegments.length; i++) {
|
||||||
|
var seg = root.blockSegments[i]
|
||||||
|
if (seg && seg.source) text += seg.source + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute text: send as prompt or wrap if it looks like code
|
||||||
|
function executeText(text) {
|
||||||
|
if (!text || text.trim() === "") return
|
||||||
|
var trimmed = text.trim()
|
||||||
|
|
||||||
|
// If multi-line or looks like code, wrap in "run this"
|
||||||
|
if (trimmed.indexOf('\n') >= 0 || looksLikeCommand(trimmed)) {
|
||||||
|
ollie.submit("Run this:\n```\n" + trimmed + "\n```")
|
||||||
|
} else {
|
||||||
|
ollie.submit(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if text looks like an executable command
|
||||||
|
function looksLikeCommand(text) {
|
||||||
|
var cmdPrefixes = [
|
||||||
|
"ls", "cd", "cat", "grep", "find", "make", "go ", "npm", "yarn",
|
||||||
|
"git ", "docker", "kubectl", "curl", "wget", "ssh", "scp",
|
||||||
|
"python", "node", "ruby", "cargo", "rustc", "gcc", "clang",
|
||||||
|
"./", "sudo"
|
||||||
|
]
|
||||||
|
var lower = text.toLowerCase().trim()
|
||||||
|
for (var i = 0; i < cmdPrefixes.length; i++) {
|
||||||
|
if (lower.startsWith(cmdPrefixes[i])) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Text segment component
|
// Text segment component
|
||||||
Component {
|
Component {
|
||||||
id: textComponent
|
id: textComponent
|
||||||
|
|
@ -188,17 +335,33 @@ Column {
|
||||||
selectionColor: theme.highlightColor
|
selectionColor: theme.highlightColor
|
||||||
selectedTextColor: theme.highlightedTextColor
|
selectedTextColor: theme.highlightedTextColor
|
||||||
|
|
||||||
// Left-click: disable ListView flick while selecting text
|
// Acme-style mouse handling
|
||||||
// Right-click: lazy context menu
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
acceptedButtons: Qt.RightButton
|
acceptedButtons: Qt.MiddleButton | Qt.RightButton
|
||||||
cursorShape: Qt.IBeamCursor
|
cursorShape: Qt.IBeamCursor
|
||||||
onClicked: {
|
propagateComposedEvents: true
|
||||||
if (!textEdit._menu) {
|
|
||||||
textEdit._menu = contextMenuComponent.createObject(textEdit)
|
onClicked: function(mouse) {
|
||||||
|
if (mouse.button === Qt.MiddleButton) {
|
||||||
|
var sel = textEdit.selectedText
|
||||||
|
if (sel && sel.trim() !== "") {
|
||||||
|
executeText(sel)
|
||||||
|
}
|
||||||
|
mouse.accepted = true
|
||||||
|
} else if (mouse.button === Qt.RightButton) {
|
||||||
|
var sel = textEdit.selectedText
|
||||||
|
if (sel && sel.trim() !== "" && plumber.available()) {
|
||||||
|
plumber.plumb(sel.trim())
|
||||||
|
mouse.accepted = true
|
||||||
|
} else {
|
||||||
|
if (!textEdit._menu) {
|
||||||
|
textEdit._menu = contextMenuComponent.createObject(textEdit)
|
||||||
|
}
|
||||||
|
textEdit._menu.popup()
|
||||||
|
mouse.accepted = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
textEdit._menu.popup()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,6 +379,18 @@ Column {
|
||||||
Menu {
|
Menu {
|
||||||
id: ctxMenu
|
id: ctxMenu
|
||||||
property TextEdit target: parent
|
property TextEdit target: parent
|
||||||
|
|
||||||
|
MenuItem {
|
||||||
|
text: "Execute (B2)"
|
||||||
|
enabled: ctxMenu.target ? ctxMenu.target.selectedText.length > 0 : false
|
||||||
|
onTriggered: if (ctxMenu.target) executeText(ctxMenu.target.selectedText)
|
||||||
|
}
|
||||||
|
MenuItem {
|
||||||
|
text: "Plumb (B3)"
|
||||||
|
enabled: ctxMenu.target ? ctxMenu.target.selectedText.length > 0 && plumber.available() : false
|
||||||
|
onTriggered: if (ctxMenu.target) plumber.plumb(ctxMenu.target.selectedText.trim())
|
||||||
|
}
|
||||||
|
MenuSeparator {}
|
||||||
MenuItem {
|
MenuItem {
|
||||||
text: "Copy"
|
text: "Copy"
|
||||||
enabled: ctxMenu.target ? ctxMenu.target.selectedText.length > 0 : false
|
enabled: ctxMenu.target ? ctxMenu.target.selectedText.length > 0 : false
|
||||||
|
|
@ -312,13 +487,26 @@ Column {
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
acceptedButtons: Qt.RightButton
|
acceptedButtons: Qt.MiddleButton | Qt.RightButton
|
||||||
cursorShape: Qt.IBeamCursor
|
cursorShape: Qt.IBeamCursor
|
||||||
onClicked: {
|
propagateComposedEvents: true
|
||||||
if (!sourceEdit._menu) {
|
onClicked: function(mouse) {
|
||||||
sourceEdit._menu = contextMenuComponent.createObject(sourceEdit)
|
if (mouse.button === Qt.MiddleButton) {
|
||||||
|
var sel = sourceEdit.selectedText
|
||||||
|
if (sel && sel.trim() !== "") executeText(sel)
|
||||||
|
mouse.accepted = true
|
||||||
|
} else if (mouse.button === Qt.RightButton) {
|
||||||
|
var sel = sourceEdit.selectedText
|
||||||
|
if (sel && sel.trim() !== "" && plumber.available()) {
|
||||||
|
plumber.plumb(sel.trim())
|
||||||
|
} else {
|
||||||
|
if (!sourceEdit._menu) {
|
||||||
|
sourceEdit._menu = contextMenuComponent.createObject(sourceEdit)
|
||||||
|
}
|
||||||
|
sourceEdit._menu.popup()
|
||||||
|
}
|
||||||
|
mouse.accepted = true
|
||||||
}
|
}
|
||||||
sourceEdit._menu.popup()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
property Menu _menu: null
|
property Menu _menu: null
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,12 @@ Item {
|
||||||
// Do not opt the chat pane into focus navigation.
|
// Do not opt the chat pane into focus navigation.
|
||||||
activeFocusOnTab: false
|
activeFocusOnTab: false
|
||||||
|
|
||||||
|
// Expose method to set prompt text (for Edit & Resend)
|
||||||
|
function setPromptText(text) {
|
||||||
|
promptInput.text = text
|
||||||
|
promptInput.forceActiveFocus()
|
||||||
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
chatView.positionViewAtEnd()
|
chatView.positionViewAtEnd()
|
||||||
}
|
}
|
||||||
|
|
@ -44,12 +50,12 @@ Item {
|
||||||
Layout.margins: 6
|
Layout.margins: 6
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
// Sidebar toggle button
|
// Sidebar toggle button (larger touch target)
|
||||||
ToolButton {
|
ToolButton {
|
||||||
text: chatPane.sidebarVisible ? "◀" : "▶"
|
text: chatPane.sidebarVisible ? "◀" : "▶"
|
||||||
font.pixelSize: 12
|
font.pixelSize: 14
|
||||||
implicitWidth: 24
|
implicitWidth: 32
|
||||||
implicitHeight: 24
|
implicitHeight: 32
|
||||||
onClicked: chatPane.toggleSidebar()
|
onClicked: chatPane.toggleSidebar()
|
||||||
ToolTip.text: chatPane.sidebarVisible ? "Hide sidebar (Ctrl+B)" : "Show sidebar (Ctrl+B)"
|
ToolTip.text: chatPane.sidebarVisible ? "Hide sidebar (Ctrl+B)" : "Show sidebar (Ctrl+B)"
|
||||||
ToolTip.visible: hovered
|
ToolTip.visible: hovered
|
||||||
|
|
@ -144,6 +150,8 @@ Item {
|
||||||
|
|
||||||
delegate: ChatBlockDelegate {
|
delegate: ChatBlockDelegate {
|
||||||
width: chatView.width - 8
|
width: chatView.width - 8
|
||||||
|
// Pass reference to chatPane for Edit & Resend
|
||||||
|
property var chatPane: chatPane
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,7 +209,80 @@ Item {
|
||||||
|
|
||||||
Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor }
|
Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor }
|
||||||
|
|
||||||
// Input
|
// 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: "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 {
|
RowLayout {
|
||||||
id: inputRow
|
id: inputRow
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
|
@ -215,6 +296,19 @@ Item {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 context menu overlay
|
// TextArea in ScrollView for overflow, with context menu overlay
|
||||||
Item {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
|
@ -235,7 +329,7 @@ Item {
|
||||||
if (!ollie.activeSessionId) return "No session"
|
if (!ollie.activeSessionId) return "No session"
|
||||||
if (!ollie.activeAgentId) return "No agent"
|
if (!ollie.activeAgentId) return "No agent"
|
||||||
if (ollie.isSessionPaused(ollie.activeSessionId)) return "Session paused"
|
if (ollie.isSessionPaused(ollie.activeSessionId)) return "Session paused"
|
||||||
return "Message..."
|
return "Message... (B2=execute selection, B3=plumb)"
|
||||||
}
|
}
|
||||||
enabled: ollie.daemonConnected && ollie.activeSessionId !== "" && ollie.activeAgentId !== "" && !ollie.isSessionPaused(ollie.activeSessionId)
|
enabled: ollie.daemonConnected && ollie.activeSessionId !== "" && ollie.activeAgentId !== "" && !ollie.isSessionPaused(ollie.activeSessionId)
|
||||||
wrapMode: TextEdit.Wrap
|
wrapMode: TextEdit.Wrap
|
||||||
|
|
@ -259,6 +353,18 @@ Item {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Acme-style mouse handling in input
|
||||||
|
TapHandler {
|
||||||
|
acceptedButtons: Qt.MiddleButton
|
||||||
|
onTapped: {
|
||||||
|
// B2: Execute selection as prompt immediately
|
||||||
|
var sel = promptInput.selectedText
|
||||||
|
if (sel && sel.trim() !== "") {
|
||||||
|
ollie.submit(sel.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TapHandler {
|
TapHandler {
|
||||||
acceptedButtons: Qt.RightButton
|
acceptedButtons: Qt.RightButton
|
||||||
onTapped: promptContextMenu.popup()
|
onTapped: promptContextMenu.popup()
|
||||||
|
|
@ -268,6 +374,23 @@ Item {
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
id: promptContextMenu
|
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 {
|
MenuItem {
|
||||||
text: "Cut"
|
text: "Cut"
|
||||||
enabled: promptInput.selectedText.length > 0
|
enabled: promptInput.selectedText.length > 0
|
||||||
|
|
@ -291,11 +414,26 @@ Item {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
Button {
|
||||||
text: "▶"
|
text: "▶"
|
||||||
|
font.pixelSize: 16
|
||||||
Layout.alignment: Qt.AlignBottom
|
Layout.alignment: Qt.AlignBottom
|
||||||
Layout.preferredWidth: 36
|
Layout.preferredWidth: 48
|
||||||
Layout.preferredHeight: 36
|
Layout.preferredHeight: 48
|
||||||
focusPolicy: Qt.NoFocus
|
focusPolicy: Qt.NoFocus
|
||||||
enabled: promptInput.enabled && promptInput.text.trim() !== ""
|
enabled: promptInput.enabled && promptInput.text.trim() !== ""
|
||||||
onClicked: inputRow.doSubmit()
|
onClicked: inputRow.doSubmit()
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
#include "chatblockmodel.h"
|
#include "chatblockmodel.h"
|
||||||
#include "thememanager.h"
|
#include "thememanager.h"
|
||||||
#include "clipboardhelper.h"
|
#include "clipboardhelper.h"
|
||||||
|
#include "plumber.h"
|
||||||
|
|
||||||
int main(int argc, char *argv[])
|
int main(int argc, char *argv[])
|
||||||
{
|
{
|
||||||
|
|
@ -48,6 +49,7 @@ int main(int argc, char *argv[])
|
||||||
auto *chatModel = new ChatBlockModel(&app);
|
auto *chatModel = new ChatBlockModel(&app);
|
||||||
auto *theme = new ThemeManager(&app);
|
auto *theme = new ThemeManager(&app);
|
||||||
auto *clipboard = new ClipboardHelper(&app);
|
auto *clipboard = new ClipboardHelper(&app);
|
||||||
|
auto *plumber = new Plumber(&app);
|
||||||
|
|
||||||
QObject::connect(ollie, &Ollie9pClient::chatReceived,
|
QObject::connect(ollie, &Ollie9pClient::chatReceived,
|
||||||
chatModel, &ChatBlockModel::appendData);
|
chatModel, &ChatBlockModel::appendData);
|
||||||
|
|
@ -58,6 +60,7 @@ int main(int argc, char *argv[])
|
||||||
engine.rootContext()->setContextProperty("chatModel", chatModel);
|
engine.rootContext()->setContextProperty("chatModel", chatModel);
|
||||||
engine.rootContext()->setContextProperty("theme", theme);
|
engine.rootContext()->setContextProperty("theme", theme);
|
||||||
engine.rootContext()->setContextProperty("clipboard", clipboard);
|
engine.rootContext()->setContextProperty("clipboard", clipboard);
|
||||||
|
engine.rootContext()->setContextProperty("plumber", plumber);
|
||||||
|
|
||||||
const QUrl url(QStringLiteral("qrc:/main.qml"));
|
const QUrl url(QStringLiteral("qrc:/main.qml"));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
#include "plumber.h"
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QDebug>
|
||||||
|
|
||||||
|
Plumber::Plumber(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
// Find plumb binary - check common plan9port locations
|
||||||
|
QStringList searchPaths = {
|
||||||
|
"/usr/local/plan9/bin/plumb",
|
||||||
|
"/usr/lib/plan9/bin/plumb",
|
||||||
|
QDir::homePath() + "/.local/plan9/bin/plumb",
|
||||||
|
QDir::homePath() + "/plan9port/bin/plumb"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Also check PLAN9 environment variable
|
||||||
|
QString plan9 = qEnvironmentVariable("PLAN9");
|
||||||
|
if (!plan9.isEmpty()) {
|
||||||
|
searchPaths.prepend(plan9 + "/bin/plumb");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const QString &path : searchPaths) {
|
||||||
|
if (QFileInfo::exists(path)) {
|
||||||
|
m_plumbPath = path;
|
||||||
|
qDebug() << "Plumber: found plumb at" << m_plumbPath;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_plumbPath.isEmpty()) {
|
||||||
|
qDebug() << "Plumber: plumb binary not found, B3-plumb disabled";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Plumber::available() const
|
||||||
|
{
|
||||||
|
return !m_plumbPath.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plumber::plumb(const QString &text)
|
||||||
|
{
|
||||||
|
if (text.isEmpty()) return;
|
||||||
|
|
||||||
|
if (!available()) {
|
||||||
|
emit plumbError("plumb binary not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QProcess *proc = new QProcess(this);
|
||||||
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
||||||
|
this, [this, proc](int exitCode, QProcess::ExitStatus status) {
|
||||||
|
if (exitCode != 0 || status != QProcess::NormalExit) {
|
||||||
|
QString err = proc->readAllStandardError();
|
||||||
|
if (!err.isEmpty()) {
|
||||||
|
emit plumbError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
proc->deleteLater();
|
||||||
|
});
|
||||||
|
|
||||||
|
// plumb reads from stdin
|
||||||
|
proc->start(m_plumbPath, QStringList());
|
||||||
|
proc->write(text.toUtf8());
|
||||||
|
proc->closeWriteChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Plumber::plumbTo(const QString &text, const QString &dst)
|
||||||
|
{
|
||||||
|
if (text.isEmpty()) return;
|
||||||
|
|
||||||
|
if (!available()) {
|
||||||
|
emit plumbError("plumb binary not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QProcess *proc = new QProcess(this);
|
||||||
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
||||||
|
this, [this, proc](int exitCode, QProcess::ExitStatus status) {
|
||||||
|
if (exitCode != 0 || status != QProcess::NormalExit) {
|
||||||
|
QString err = proc->readAllStandardError();
|
||||||
|
if (!err.isEmpty()) {
|
||||||
|
emit plumbError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
proc->deleteLater();
|
||||||
|
});
|
||||||
|
|
||||||
|
// plumb -d destination reads from stdin
|
||||||
|
proc->start(m_plumbPath, QStringList() << "-d" << dst);
|
||||||
|
proc->write(text.toUtf8());
|
||||||
|
proc->closeWriteChannel();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
#ifndef PLUMBER_H
|
||||||
|
#define PLUMBER_H
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QProcess>
|
||||||
|
#if QT_VERSION_MAJOR >= 6
|
||||||
|
#include <QQmlEngine>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Plumber wraps plan9port's plumb command for context-aware actions.
|
||||||
|
// B3-click on text → plumber routes to appropriate handler (editor, browser, etc.)
|
||||||
|
class Plumber : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
#if QT_VERSION_MAJOR >= 6
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_SINGLETON
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit Plumber(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
// Plumb text to the plan9port plumber daemon
|
||||||
|
Q_INVOKABLE void plumb(const QString &text);
|
||||||
|
|
||||||
|
// Plumb with explicit source/destination
|
||||||
|
Q_INVOKABLE void plumbTo(const QString &text, const QString &dst);
|
||||||
|
|
||||||
|
// Check if plumber is available
|
||||||
|
Q_INVOKABLE bool available() const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void plumbError(const QString &error);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_plumbPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // PLUMBER_H
|
||||||
Loading…
Reference in New Issue