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

View File

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

View File

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

View File

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

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