phase 7c: Kate plugin
- Side panel (right dock) with live chat view, streaming via ChatUpdated - Prompt input at bottom, enter to send - Context actions: 'Ask File' (describe current file), 'Explain' (explain selection) - Auto-creates session in current file's directory - Adopts existing sessions on load
This commit is contained in:
parent
eab8cfa635
commit
22771d3c8d
|
|
@ -71,6 +71,18 @@ target_link_libraries(krunner_ollie
|
||||||
Qt6::Widgets
|
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 ---
|
# --- Plasmoid QML Plugin ---
|
||||||
|
|
||||||
qt6_add_qml_module(ollie-plasmoid-plugin
|
qt6_add_qml_module(ollie-plasmoid-plugin
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,335 @@
|
||||||
|
#include "ollie_kate.h"
|
||||||
|
|
||||||
|
#include <KPluginFactory>
|
||||||
|
#include <KTextEditor/Document>
|
||||||
|
#include <KTextEditor/Editor>
|
||||||
|
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QDBusReply>
|
||||||
|
#include <QFont>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QScrollBar>
|
||||||
|
|
||||||
|
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<QString> 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<QStringList> 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<QString> 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"
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
#ifndef OLLIE_KATE_PLUGIN_H
|
||||||
|
#define OLLIE_KATE_PLUGIN_H
|
||||||
|
|
||||||
|
#include <KTextEditor/Plugin>
|
||||||
|
#include <KTextEditor/MainWindow>
|
||||||
|
#include <KTextEditor/View>
|
||||||
|
#include <KPluginMetaData>
|
||||||
|
|
||||||
|
#include <QDBusInterface>
|
||||||
|
#include <QDBusConnection>
|
||||||
|
#include <QTextEdit>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QAction>
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue