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/thememanager.cpp
|
||||
gui/clipboardhelper.cpp
|
||||
gui/plumber.cpp
|
||||
${GUI_KF5_QRC}
|
||||
)
|
||||
add_dependencies(ollie-gui lib9p_build)
|
||||
|
|
@ -244,6 +245,7 @@ else()
|
|||
gui/chatblockmodel.cpp
|
||||
gui/thememanager.cpp
|
||||
gui/clipboardhelper.cpp
|
||||
gui/plumber.cpp
|
||||
)
|
||||
add_dependencies(ollie-gui lib9p_build)
|
||||
target_include_directories(ollie-gui PRIVATE
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
// 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.Controls 2.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
|
||||
function headerColor() {
|
||||
var bg = theme.baseColor
|
||||
|
|
@ -76,15 +83,17 @@ Column {
|
|||
}
|
||||
}
|
||||
|
||||
// Clickable header
|
||||
// Clickable header with action buttons
|
||||
Rectangle {
|
||||
width: root.width
|
||||
height: 22
|
||||
height: 28
|
||||
radius: 3
|
||||
color: root.headerColor()
|
||||
|
||||
MouseArea {
|
||||
id: headerArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.blockExpanded = !root.blockExpanded
|
||||
}
|
||||
|
|
@ -92,6 +101,7 @@ Column {
|
|||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 6
|
||||
anchors.rightMargin: 6
|
||||
spacing: 4
|
||||
|
||||
Label {
|
||||
|
|
@ -108,15 +118,86 @@ Column {
|
|||
color: theme.textColor
|
||||
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
|
||||
Column {
|
||||
id: contentColumn
|
||||
width: root.width
|
||||
visible: root.blockExpanded
|
||||
spacing: 4
|
||||
|
||||
MouseArea {
|
||||
id: contentArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
propagateComposedEvents: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
}
|
||||
|
||||
// Fast path: single TextEdit for the concatenated HTML.
|
||||
// Diagrams are handled separately via the segments list.
|
||||
TextEdit {
|
||||
|
|
@ -134,15 +215,36 @@ Column {
|
|||
selectionColor: theme.highlightColor
|
||||
selectedTextColor: theme.highlightedTextColor
|
||||
|
||||
// Acme-style mouse handling: B2=execute, B3=plumb/menu
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton
|
||||
acceptedButtons: Qt.MiddleButton | Qt.RightButton
|
||||
cursorShape: Qt.IBeamCursor
|
||||
onClicked: {
|
||||
if (!mainTextEdit._menu) {
|
||||
mainTextEdit._menu = contextMenuComponent.createObject(mainTextEdit)
|
||||
propagateComposedEvents: true
|
||||
|
||||
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
|
||||
|
|
@ -170,6 +272,51 @@ Column {
|
|||
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
|
||||
Component {
|
||||
id: textComponent
|
||||
|
|
@ -188,17 +335,33 @@ Column {
|
|||
selectionColor: theme.highlightColor
|
||||
selectedTextColor: theme.highlightedTextColor
|
||||
|
||||
// Left-click: disable ListView flick while selecting text
|
||||
// Right-click: lazy context menu
|
||||
// Acme-style mouse handling
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton
|
||||
acceptedButtons: Qt.MiddleButton | Qt.RightButton
|
||||
cursorShape: Qt.IBeamCursor
|
||||
onClicked: {
|
||||
if (!textEdit._menu) {
|
||||
textEdit._menu = contextMenuComponent.createObject(textEdit)
|
||||
propagateComposedEvents: true
|
||||
|
||||
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 {
|
||||
id: ctxMenu
|
||||
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 {
|
||||
text: "Copy"
|
||||
enabled: ctxMenu.target ? ctxMenu.target.selectedText.length > 0 : false
|
||||
|
|
@ -312,13 +487,26 @@ Column {
|
|||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton
|
||||
acceptedButtons: Qt.MiddleButton | Qt.RightButton
|
||||
cursorShape: Qt.IBeamCursor
|
||||
onClicked: {
|
||||
if (!sourceEdit._menu) {
|
||||
sourceEdit._menu = contextMenuComponent.createObject(sourceEdit)
|
||||
propagateComposedEvents: true
|
||||
onClicked: function(mouse) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ Item {
|
|||
// 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()
|
||||
}
|
||||
|
|
@ -44,12 +50,12 @@ Item {
|
|||
Layout.margins: 6
|
||||
spacing: 8
|
||||
|
||||
// Sidebar toggle button
|
||||
// Sidebar toggle button (larger touch target)
|
||||
ToolButton {
|
||||
text: chatPane.sidebarVisible ? "◀" : "▶"
|
||||
font.pixelSize: 12
|
||||
implicitWidth: 24
|
||||
implicitHeight: 24
|
||||
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
|
||||
|
|
@ -144,6 +150,8 @@ Item {
|
|||
|
||||
delegate: ChatBlockDelegate {
|
||||
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 }
|
||||
|
||||
// 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 {
|
||||
id: inputRow
|
||||
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
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
|
|
@ -235,7 +329,7 @@ Item {
|
|||
if (!ollie.activeSessionId) return "No session"
|
||||
if (!ollie.activeAgentId) return "No agent"
|
||||
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)
|
||||
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 {
|
||||
acceptedButtons: Qt.RightButton
|
||||
onTapped: promptContextMenu.popup()
|
||||
|
|
@ -268,6 +374,23 @@ Item {
|
|||
|
||||
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"
|
||||
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 {
|
||||
text: "▶"
|
||||
font.pixelSize: 16
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
Layout.preferredWidth: 36
|
||||
Layout.preferredHeight: 36
|
||||
Layout.preferredWidth: 48
|
||||
Layout.preferredHeight: 48
|
||||
focusPolicy: Qt.NoFocus
|
||||
enabled: promptInput.enabled && promptInput.text.trim() !== ""
|
||||
onClicked: inputRow.doSubmit()
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
#include "chatblockmodel.h"
|
||||
#include "thememanager.h"
|
||||
#include "clipboardhelper.h"
|
||||
#include "plumber.h"
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
|
|
@ -48,6 +49,7 @@ int main(int argc, char *argv[])
|
|||
auto *chatModel = new ChatBlockModel(&app);
|
||||
auto *theme = new ThemeManager(&app);
|
||||
auto *clipboard = new ClipboardHelper(&app);
|
||||
auto *plumber = new Plumber(&app);
|
||||
|
||||
QObject::connect(ollie, &Ollie9pClient::chatReceived,
|
||||
chatModel, &ChatBlockModel::appendData);
|
||||
|
|
@ -58,6 +60,7 @@ int main(int argc, char *argv[])
|
|||
engine.rootContext()->setContextProperty("chatModel", chatModel);
|
||||
engine.rootContext()->setContextProperty("theme", theme);
|
||||
engine.rootContext()->setContextProperty("clipboard", clipboard);
|
||||
engine.rootContext()->setContextProperty("plumber", plumber);
|
||||
|
||||
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