diff --git a/kde/CMakeLists.txt b/kde/CMakeLists.txt index 8dc6930..bf708db 100644 --- a/kde/CMakeLists.txt +++ b/kde/CMakeLists.txt @@ -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 diff --git a/kde/gui/ChatBlockDelegate.qml b/kde/gui/ChatBlockDelegate.qml index 5450aef..e694be1 100644 --- a/kde/gui/ChatBlockDelegate.qml +++ b/kde/gui/ChatBlockDelegate.qml @@ -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 diff --git a/kde/gui/ChatPane.qml b/kde/gui/ChatPane.qml index b2397ce..bf32c80 100644 --- a/kde/gui/ChatPane.qml +++ b/kde/gui/ChatPane.qml @@ -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() diff --git a/kde/gui/main.cpp b/kde/gui/main.cpp index a8e5ee2..d0c0104 100644 --- a/kde/gui/main.cpp +++ b/kde/gui/main.cpp @@ -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")); diff --git a/kde/gui/plumber.cpp b/kde/gui/plumber.cpp new file mode 100644 index 0000000..f8d9c4d --- /dev/null +++ b/kde/gui/plumber.cpp @@ -0,0 +1,94 @@ +#include "plumber.h" +#include +#include +#include +#include + +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::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::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(); +} diff --git a/kde/gui/plumber.h b/kde/gui/plumber.h new file mode 100644 index 0000000..a5e6be2 --- /dev/null +++ b/kde/gui/plumber.h @@ -0,0 +1,39 @@ +#ifndef PLUMBER_H +#define PLUMBER_H + +#include +#include +#if QT_VERSION_MAJOR >= 6 +#include +#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