This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-kde/kate/ollie_kate.cpp

475 lines
15 KiB
C++

#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 <QApplication>
#include <QMenu>
#include <QDir>
#include <QFileInfo>
#include <QInputDialog>
#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 ---
#if QT_VERSION_MAJOR >= 6
OllieKatePlugin::OllieKatePlugin(QObject *parent, const KPluginMetaData &data, const QVariantList &args)
: KTextEditor::Plugin(parent)
{
Q_UNUSED(data);
Q_UNUSED(args);
}
#else
OllieKatePlugin::OllieKatePlugin(QObject *parent, const QVariantList &args)
: KTextEditor::Plugin(parent)
{
Q_UNUSED(args);
}
#endif
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(QStringLiteral("color: gray; padding: 2px;"));
m_sessionLabel->setFont(QApplication::font());
layout->addWidget(m_sessionLabel);
// CWD label
m_cwdLabel = new QLabel("", container);
QFont smallFont = QApplication::font();
smallFont.setPointSizeF(smallFont.pointSizeF() * 0.85);
m_cwdLabel->setStyleSheet(QStringLiteral("color: gray; padding: 0px 2px;"));
m_cwdLabel->setFont(smallFont);
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 *pickBtn = new QPushButton("Pick", container);
pickBtn->setToolTip("Switch to an existing session matching the project directory");
connect(pickBtn, &QPushButton::clicked, this, &OllieKateView::pickSession);
buttonBar->addWidget(pickBtn);
auto *renameBtn = new QPushButton("Rename", container);
renameBtn->setToolTip("Rename the current session");
connect(renameBtn, &QPushButton::clicked, this, &OllieKateView::renameSession);
buttonBar->addWidget(renameBtn);
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);
QFont monoFont("monospace");
monoFont.setPointSize(QApplication::font().pointSize());
m_chatView->setFont(monoFont);
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 + " [" + 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 = projectBaseDir();
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 + " [idle]");
m_cwdLabel->setText(cwd);
} else {
m_chatView->setPlainText("Error creating session: " + reply.error().message());
}
}
void OllieKateView::pickSession()
{
QString baseDir = projectBaseDir();
if (baseDir.isEmpty()) {
m_chatView->setPlainText("Cannot determine project directory.");
return;
}
QDBusReply<QStringList> reply = m_iface->call("ListSessions");
if (!reply.isValid() || reply.value().isEmpty()) {
m_chatView->setPlainText("No active sessions found.");
return;
}
// Find first session whose cwd matches the project base dir
for (const QString &entry : reply.value()) {
QStringList parts = entry.split('\t');
if (parts.isEmpty()) continue;
QString id = parts[0];
QDBusReply<QString> cfgReply = m_iface->call("GetConfig", id);
if (!cfgReply.isValid()) continue;
for (const QString &line : cfgReply.value().split('\n')) {
if (line.startsWith("cwd=")) {
QString cwd = line.mid(4);
if (cwd == baseDir) {
switchToSession(id);
return;
}
break;
}
}
}
m_chatView->setPlainText("No session found with cwd: " + baseDir);
}
void OllieKateView::renameSession()
{
if (m_sessionId.isEmpty()) {
m_chatView->setPlainText("No active session to rename.");
return;
}
bool ok;
QString newName = QInputDialog::getText(
m_toolView, "Rename Session",
"New name for session " + m_sessionId + ":",
QLineEdit::Normal, m_sessionId, &ok);
if (!ok || newName.trimmed().isEmpty()) return;
newName = newName.trimmed();
QDBusReply<bool> reply = m_iface->call("RenameSession", m_sessionId, newName);
if (reply.isValid() && reply.value()) {
m_sessionId = newName;
QString dot = "○ ";
QDBusReply<QString> stateReply = m_iface->call("GetState", m_sessionId);
if (stateReply.isValid()) {
QString state = stateReply.value();
dot = (state == "idle") ? "○ " : (state.startsWith("calling") ? "⚡ " : "● ");
m_sessionLabel->setText(dot + m_sessionId + " [" + state + "]");
} else {
m_sessionLabel->setText(dot + m_sessionId + " [idle]");
}
} else {
m_chatView->setPlainText("Failed to rename session.");
}
}
// --- Helpers ---
QString OllieKateView::projectBaseDir()
{
// Try Kate's project plugin first
QObject *projectView = m_mainWindow->pluginView("kateprojectplugin");
if (projectView) {
QString dir = projectView->property("projectBaseDir").toString();
if (!dir.isEmpty()) return dir;
}
// Fall back to active file's directory
auto *view = m_mainWindow->activeView();
if (view && view->document() && !view->document()->url().toLocalFile().isEmpty()) {
return QFileInfo(view->document()->url().toLocalFile()).absolutePath();
}
return QString();
}
void OllieKateView::switchToSession(const QString &sessionId)
{
m_sessionId = sessionId;
m_chatView->clear();
m_chatOffset = 0;
// Fetch state
QDBusReply<QString> stateReply = m_iface->call("GetState", m_sessionId);
QString state = stateReply.isValid() ? stateReply.value() : "idle";
QString dot = (state == "idle") ? "○ " : (state.startsWith("calling") ? "⚡ " : "● ");
m_sessionLabel->setText(dot + m_sessionId + " [" + state + "]");
// Fetch cwd
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();
}
}
void OllieKateView::ensureSession()
{
if (!m_sessionId.isEmpty()) return;
if (!m_iface->isValid()) {
m_chatView->setPlainText("Error: ollie-kde daemon not running.\nStart with: ollie-kde or rc-service --user ollie-kde start");
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;
}
// If we already have a session, keep it
if (!m_sessionId.isEmpty()) return;
// Try to find a session matching the project base dir
QString baseDir = projectBaseDir();
if (!baseDir.isEmpty()) {
for (const QString &entry : sessions) {
QStringList parts = entry.split('\t');
if (parts.isEmpty()) continue;
QString id = parts[0];
QDBusReply<QString> cfgReply = m_iface->call("GetConfig", id);
if (!cfgReply.isValid()) continue;
for (const QString &line : cfgReply.value().split('\n')) {
if (line.startsWith("cwd=")) {
if (line.mid(4) == baseDir) {
switchToSession(id);
return;
}
break;
}
}
}
}
// Fall back to first session
QStringList parts = sessions.first().split('\t');
if (!parts.isEmpty()) {
switchToSession(parts[0]);
}
}
#include "ollie_kate.moc"