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:
Levi Neely 2026-09-04 13:47:51 +02:00
parent 5f68871c65
commit 75a2da080e
6 changed files with 491 additions and 27 deletions

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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"));

94
kde/gui/plumber.cpp Normal file
View File

@ -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();
}

39
kde/gui/plumber.h Normal file
View File

@ -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