diff --git a/CMakeLists.txt b/CMakeLists.txt index 3315a48..598da6b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,6 +71,18 @@ target_link_libraries(krunner_ollie Qt6::Widgets ) +# --- Kate Plugin --- + +find_package(KF6TextEditor REQUIRED) + +kcoreaddons_add_plugin(ollie_kate SOURCES kate/ollie_kate.cpp INSTALL_NAMESPACE "kf6/ktexteditor") +target_link_libraries(ollie_kate + KF6::TextEditor + Qt6::Core + Qt6::DBus + Qt6::Widgets +) + # --- Plasmoid QML Plugin --- qt6_add_qml_module(ollie-plasmoid-plugin diff --git a/kate/metadata.json b/kate/metadata.json new file mode 100644 index 0000000..8f5a30d --- /dev/null +++ b/kate/metadata.json @@ -0,0 +1,14 @@ +{ + "KPlugin": { + "Id": "ollie-kate", + "Name": "Ollie", + "Description": "AI agent side panel and context actions", + "Icon": "system-run", + "Authors": [ + { + "Name": "ollie-kde" + } + ] + }, + "X-KDE-PluginInfo-EnabledByDefault": true +} diff --git a/kate/ollie_kate.cpp b/kate/ollie_kate.cpp new file mode 100644 index 0000000..a7666d4 --- /dev/null +++ b/kate/ollie_kate.cpp @@ -0,0 +1,335 @@ +#include "ollie_kate.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +K_PLUGIN_CLASS_WITH_JSON(OllieKatePlugin, "metadata.json") + +static const char *SERVICE = "org.ollie.SessionManager"; +static const char *OBJ_PATH = "/org/ollie/SessionManager"; +static const char *IFACE = "org.ollie.SessionManager"; + +// --- Plugin --- + +OllieKatePlugin::OllieKatePlugin(QObject *parent, const KPluginMetaData &data, const QVariantList &args) + : KTextEditor::Plugin(parent) +{ + Q_UNUSED(data); + Q_UNUSED(args); +} + +QObject *OllieKatePlugin::createView(KTextEditor::MainWindow *mainWindow) +{ + return new OllieKateView(this, mainWindow); +} + +// --- View --- + +OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *mainWindow) + : QObject(mainWindow) + , m_mainWindow(mainWindow) +{ + m_iface = new QDBusInterface(SERVICE, OBJ_PATH, IFACE, + QDBusConnection::sessionBus(), this); + + // Create tool view (side panel) + m_toolView = m_mainWindow->createToolView( + plugin, + "ollie_panel", + KTextEditor::MainWindow::Right, + QIcon::fromTheme("system-run"), + "Ollie"); + + auto *container = new QWidget(m_toolView); + auto *layout = new QVBoxLayout(container); + layout->setContentsMargins(2, 2, 2, 2); + layout->setSpacing(2); + + // Session indicator + m_sessionLabel = new QLabel("No session", container); + m_sessionLabel->setStyleSheet("font-size: 10px; color: gray; padding: 2px;"); + layout->addWidget(m_sessionLabel); + + // CWD label + m_cwdLabel = new QLabel("", container); + m_cwdLabel->setStyleSheet("font-size: 9px; color: gray; padding: 0px 2px;"); + m_cwdLabel->setWordWrap(true); + layout->addWidget(m_cwdLabel); + + // Button bar + auto *buttonBar = new QHBoxLayout(); + + auto *newBtn = new QPushButton("New", container); + newBtn->setToolTip("New session in current file's directory"); + connect(newBtn, &QPushButton::clicked, this, &OllieKateView::newSession); + buttonBar->addWidget(newBtn); + + auto *askBtn = new QPushButton("Ask File", container); + askBtn->setToolTip("Ask Ollie about the current file"); + connect(askBtn, &QPushButton::clicked, this, &OllieKateView::askAboutFile); + buttonBar->addWidget(askBtn); + + auto *explainBtn = new QPushButton("Explain", container); + explainBtn->setToolTip("Explain selected text"); + connect(explainBtn, &QPushButton::clicked, this, &OllieKateView::explainSelection); + buttonBar->addWidget(explainBtn); + + buttonBar->addStretch(); + layout->addLayout(buttonBar); + + // Chat view + m_chatView = new QTextEdit(container); + m_chatView->setReadOnly(true); + m_chatView->setFont(QFont("monospace", 10)); + m_chatView->setPlaceholderText("No active session.\nClick 'New' or use context actions."); + layout->addWidget(m_chatView); + + // Prompt input + auto *promptLayout = new QHBoxLayout(); + m_promptInput = new QLineEdit(container); + m_promptInput->setPlaceholderText("Message..."); + connect(m_promptInput, &QLineEdit::returnPressed, this, &OllieKateView::submitPrompt); + promptLayout->addWidget(m_promptInput); + + auto *sendBtn = new QPushButton("⏎", container); + sendBtn->setFixedWidth(30); + connect(sendBtn, &QPushButton::clicked, this, &OllieKateView::submitPrompt); + promptLayout->addWidget(sendBtn); + + layout->addLayout(promptLayout); + + // Connect D-Bus signals + QDBusConnection bus = QDBusConnection::sessionBus(); + bus.connect(SERVICE, OBJ_PATH, IFACE, "ChatUpdated", + this, SLOT(onChatUpdated(QString,qlonglong,QString))); + bus.connect(SERVICE, OBJ_PATH, IFACE, "StateChanged", + this, SLOT(onStateChanged(QString,QString))); + bus.connect(SERVICE, OBJ_PATH, IFACE, "SessionCreated", + this, SLOT(onSessionCreated(QString))); + bus.connect(SERVICE, OBJ_PATH, IFACE, "SessionKilled", + this, SLOT(onSessionKilled(QString))); + + // Pick up existing session if any + refreshSessions(); +} + +OllieKateView::~OllieKateView() +{ + delete m_toolView; +} + +// --- D-Bus signal handlers --- + +void OllieKateView::onChatUpdated(const QString &session_id, qlonglong offset, const QString &new_text) +{ + if (session_id != m_sessionId) return; + + if (offset == m_chatOffset) { + appendChat(new_text); + m_chatOffset += new_text.size(); + } else { + // Offset mismatch — refetch + QDBusMessage msg = QDBusMessage::createMethodCall(SERVICE, OBJ_PATH, IFACE, "GetChat"); + msg << m_sessionId << (qlonglong)0; + QDBusMessage reply = QDBusConnection::sessionBus().call(msg); + if (reply.type() == QDBusMessage::ReplyMessage && reply.arguments().size() >= 2) { + m_chatView->clear(); + m_chatView->setPlainText(reply.arguments()[0].toString()); + m_chatOffset = reply.arguments()[1].toLongLong(); + } + } +} + +void OllieKateView::onStateChanged(const QString &session_id, const QString &new_state) +{ + if (session_id != m_sessionId) return; + QString dot; + if (new_state == "idle") dot = "○ "; + else if (new_state.startsWith("calling")) dot = "⚡ "; + else dot = "● "; + m_sessionLabel->setText(dot + m_sessionId.left(14) + " [" + new_state + "]"); +} + +void OllieKateView::onSessionCreated(const QString &session_id) +{ + // If we have no session, adopt this one + if (m_sessionId.isEmpty()) { + m_sessionId = session_id; + m_chatView->clear(); + m_chatOffset = 0; + } +} + +void OllieKateView::onSessionKilled(const QString &session_id) +{ + if (session_id == m_sessionId) { + m_sessionId.clear(); + m_chatOffset = 0; + m_chatView->setPlainText(""); + m_sessionLabel->setText("No session"); + m_cwdLabel->setText(""); + refreshSessions(); + } +} + +// --- Actions --- + +void OllieKateView::submitPrompt() +{ + QString text = m_promptInput->text().trimmed(); + if (text.isEmpty()) return; + + ensureSession(); + if (m_sessionId.isEmpty()) return; + + m_iface->call("Submit", m_sessionId, text); + m_promptInput->clear(); +} + +void OllieKateView::askAboutFile() +{ + auto *view = m_mainWindow->activeView(); + if (!view || !view->document()) return; + + QString filePath = view->document()->url().toLocalFile(); + if (filePath.isEmpty()) return; + + ensureSession(); + if (m_sessionId.isEmpty()) return; + + QString prompt = "Describe and explain this file: " + filePath; + m_iface->call("Submit", m_sessionId, prompt); +} + +void OllieKateView::explainSelection() +{ + auto *view = m_mainWindow->activeView(); + if (!view) return; + + QString selection = view->selectionText(); + if (selection.isEmpty()) { + // No selection — explain current line + auto cursor = view->cursorPosition(); + selection = view->document()->line(cursor.line()); + } + if (selection.isEmpty()) return; + + ensureSession(); + if (m_sessionId.isEmpty()) return; + + QString filePath = view->document()->url().toLocalFile(); + QString context = filePath.isEmpty() ? "" : " (from " + filePath + ")"; + QString prompt = "Explain this code" + context + ":\n\n```\n" + selection + "\n```"; + m_iface->call("Submit", m_sessionId, prompt); +} + +void OllieKateView::newSession() +{ + QString cwd; + + // Try Kate's project plugin for the project base directory + QObject *projectView = m_mainWindow->pluginView("kateprojectplugin"); + if (projectView) { + cwd = projectView->property("projectBaseDir").toString(); + } + + // Fall back to active file's directory + if (cwd.isEmpty()) { + auto *view = m_mainWindow->activeView(); + if (view && view->document() && !view->document()->url().toLocalFile().isEmpty()) { + cwd = QFileInfo(view->document()->url().toLocalFile()).absolutePath(); + } + } + + // Last resort + if (cwd.isEmpty()) { + cwd = QDir::homePath(); + } + + QDBusReply reply = m_iface->call("CreateSession", cwd, QString(), QString(), "default"); + if (reply.isValid()) { + m_sessionId = reply.value(); + m_chatView->clear(); + m_chatOffset = 0; + m_sessionLabel->setText("○ " + m_sessionId.left(14) + " [idle]"); + m_cwdLabel->setText(cwd); + } +} + +// --- Helpers --- + +void OllieKateView::ensureSession() +{ + if (!m_sessionId.isEmpty()) return; + newSession(); +} + +QString OllieKateView::activeSessionId() +{ + return m_sessionId; +} + +void OllieKateView::appendChat(const QString &text) +{ + QTextCursor cursor = m_chatView->textCursor(); + cursor.movePosition(QTextCursor::End); + cursor.insertText(text); + m_chatView->verticalScrollBar()->setValue(m_chatView->verticalScrollBar()->maximum()); +} + +void OllieKateView::refreshSessions() +{ + QDBusReply reply = m_iface->call("ListSessions"); + if (!reply.isValid()) return; + + QStringList sessions = reply.value(); + if (sessions.isEmpty()) { + m_sessionId.clear(); + return; + } + + // Pick the first session if we don't have one + if (m_sessionId.isEmpty()) { + QStringList parts = sessions.first().split('\t'); + if (!parts.isEmpty()) { + m_sessionId = parts[0]; + QString state = parts.size() > 1 ? parts[1] : "idle"; + QString dot = (state == "idle") ? "○ " : "● "; + m_sessionLabel->setText(dot + m_sessionId.left(14) + " [" + state + "]"); + + // Fetch cwd from config + QDBusReply cfgReply = m_iface->call("GetConfig", m_sessionId); + if (cfgReply.isValid()) { + for (const QString &line : cfgReply.value().split('\n')) { + if (line.startsWith("cwd=")) { + m_cwdLabel->setText(line.mid(4)); + break; + } + } + } + + // Fetch existing chat + QDBusMessage msg = QDBusMessage::createMethodCall(SERVICE, OBJ_PATH, IFACE, "GetChat"); + msg << m_sessionId << (qlonglong)0; + QDBusMessage chatReply = QDBusConnection::sessionBus().call(msg); + if (chatReply.type() == QDBusMessage::ReplyMessage && chatReply.arguments().size() >= 2) { + m_chatView->setPlainText(chatReply.arguments()[0].toString()); + m_chatOffset = chatReply.arguments()[1].toLongLong(); + } + } + } +} + +#include "ollie_kate.moc" diff --git a/kate/ollie_kate.h b/kate/ollie_kate.h new file mode 100644 index 0000000..42b38e1 --- /dev/null +++ b/kate/ollie_kate.h @@ -0,0 +1,69 @@ +#ifndef OLLIE_KATE_PLUGIN_H +#define OLLIE_KATE_PLUGIN_H + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +class OllieKateView; + +class OllieKatePlugin : public KTextEditor::Plugin +{ + Q_OBJECT + +public: + explicit OllieKatePlugin(QObject *parent, const KPluginMetaData &data, const QVariantList &args = {}); + ~OllieKatePlugin() override = default; + + QObject *createView(KTextEditor::MainWindow *mainWindow) override; +}; + +class OllieKateView : public QObject +{ + Q_OBJECT + +public: + explicit OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *mainWindow); + ~OllieKateView() override; + +private slots: + void onChatUpdated(const QString &session_id, qlonglong offset, const QString &new_text); + void onStateChanged(const QString &session_id, const QString &new_state); + void onSessionCreated(const QString &session_id); + void onSessionKilled(const QString &session_id); + + void submitPrompt(); + void askAboutFile(); + void explainSelection(); + void newSession(); + void refreshSessions(); + +private: + void ensureSession(); + QString activeSessionId(); + void appendChat(const QString &text); + + KTextEditor::MainWindow *m_mainWindow; + QDBusInterface *m_iface; + QWidget *m_toolView; + QLabel *m_sessionLabel; + QLabel *m_cwdLabel; + QTextEdit *m_chatView; + QLineEdit *m_promptInput; + QAction *m_askFileAction; + QAction *m_explainAction; + QAction *m_newSessionAction; + + QString m_sessionId; + qlonglong m_chatOffset = 0; +}; + +#endif // OLLIE_KATE_PLUGIN_H