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

1212 lines
45 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "ollie_kate.h"
#include "ollie_ghost.h"
#ifdef HAVE_KATE_DIFF
#include "ktexteditor_utils.h"
#endif
#include <KPluginFactory>
#include <QKeyEvent>
#include <KTextEditor/Document>
#include <KTextEditor/Editor>
#include <ktexteditor_version.h>
#if KTEXTEDITOR_VERSION_MAJOR < 6
#include <KTextEditor/CodeCompletionInterface>
#endif
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QPushButton>
#include <QComboBox>
#include <QDBusReply>
#include <QFont>
#include <QApplication>
#include <QMenu>
#include <QDir>
#include <QFileInfo>
#include <QInputDialog>
#include <QScrollBar>
#include <QCheckBox>
#include <QSet>
#include <QFormLayout>
#include <QMainWindow>
#include <QStatusBar>
#include <QUrl>
#include <KSharedConfig>
#include <KConfigGroup>
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);
loadConfig();
}
#else
OllieKatePlugin::OllieKatePlugin(QObject *parent, const QVariantList &args)
: KTextEditor::Plugin(parent)
{
Q_UNUSED(args);
loadConfig();
}
#endif
QObject *OllieKatePlugin::createView(KTextEditor::MainWindow *mainWindow)
{
return new OllieKateView(this, mainWindow);
}
int OllieKatePlugin::configPages() const
{
return 1;
}
KTextEditor::ConfigPage *OllieKatePlugin::configPage(int number, QWidget *parent)
{
if (number != 0) return nullptr;
// Inline config page
class OllieConfigPage : public KTextEditor::ConfigPage
{
public:
OllieConfigPage(OllieKatePlugin *plugin, QWidget *parent)
: KTextEditor::ConfigPage(parent), m_plugin(plugin)
{
auto *layout = new QFormLayout(this);
// Default session backend/model
m_defaultBackendEdit = new QLineEdit(this);
m_defaultBackendEdit->setPlaceholderText(QStringLiteral("(use OLLIE_BACKEND)"));
m_defaultModelEdit = new QLineEdit(this);
m_defaultModelEdit->setPlaceholderText(QStringLiteral("(use OLLIE_MODEL)"));
layout->addRow(QStringLiteral("Default backend:"), m_defaultBackendEdit);
layout->addRow(QStringLiteral("Default model:"), m_defaultModelEdit);
auto *defaultNote = new QLabel(
QStringLiteral("Override OLLIE_BACKEND / OLLIE_MODEL for chat sessions."),
this);
defaultNote->setWordWrap(true);
defaultNote->setStyleSheet(QStringLiteral("color: gray;"));
layout->addRow(defaultNote);
// Completion backend/model
m_backendEdit = new QLineEdit(this);
m_backendEdit->setPlaceholderText(QStringLiteral("(use OLLIE_COMPLETE_BACKEND)"));
m_modelEdit = new QLineEdit(this);
m_modelEdit->setPlaceholderText(QStringLiteral("(use OLLIE_COMPLETE_MODEL)"));
layout->addRow(QStringLiteral("Completion backend:"), m_backendEdit);
layout->addRow(QStringLiteral("Completion model:"), m_modelEdit);
auto *note = new QLabel(
QStringLiteral("Override OLLIE_COMPLETE_BACKEND / OLLIE_COMPLETE_MODEL for ghost text."),
this);
note->setWordWrap(true);
note->setStyleSheet(QStringLiteral("color: gray;"));
layout->addRow(note);
m_enabledCheck = new QCheckBox(QStringLiteral("Completion enabled on startup"), this);
layout->addRow(m_enabledCheck);
// Diff viewer mode
auto *diffSep = new QLabel(QString(), this);
layout->addRow(diffSep);
m_diffModeCombo = new QComboBox(this);
m_diffModeCombo->addItem(QStringLiteral("Unified"), QStringLiteral("unified"));
m_diffModeCombo->addItem(QStringLiteral("Side by Side"), QStringLiteral("side-by-side"));
m_diffModeCombo->addItem(QStringLiteral("Raw"), QStringLiteral("raw"));
layout->addRow(QStringLiteral("Diff view style:"), m_diffModeCombo);
auto *diffNote = new QLabel(
QStringLiteral("How diffs are displayed in Kate's diff viewer."),
this);
diffNote->setWordWrap(true);
diffNote->setStyleSheet(QStringLiteral("color: gray;"));
layout->addRow(diffNote);
reset();
connect(m_enabledCheck, &QCheckBox::toggled, this, &ConfigPage::changed);
connect(m_backendEdit, &QLineEdit::textChanged, this, &ConfigPage::changed);
connect(m_modelEdit, &QLineEdit::textChanged, this, &ConfigPage::changed);
connect(m_defaultBackendEdit, &QLineEdit::textChanged, this, &ConfigPage::changed);
connect(m_defaultModelEdit, &QLineEdit::textChanged, this, &ConfigPage::changed);
connect(m_diffModeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &ConfigPage::changed);
}
QString name() const override { return QStringLiteral("Ollie"); }
QString fullName() const override { return QStringLiteral("Ollie Completion Settings"); }
QIcon icon() const override { return QIcon::fromTheme(QStringLiteral("system-run")); }
void apply() override {
m_plugin->setDefaultBackend(m_defaultBackendEdit->text().trimmed());
m_plugin->setDefaultModel(m_defaultModelEdit->text().trimmed());
m_plugin->setCompletionBackend(m_backendEdit->text().trimmed());
m_plugin->setCompletionModel(m_modelEdit->text().trimmed());
m_plugin->setCompletionEnabled(m_enabledCheck->isChecked());
m_plugin->setDiffMode(m_diffModeCombo->currentData().toString());
}
void reset() override {
m_defaultBackendEdit->setText(m_plugin->defaultBackend());
m_defaultModelEdit->setText(m_plugin->defaultModel());
m_backendEdit->setText(m_plugin->completionBackend());
m_modelEdit->setText(m_plugin->completionModel());
m_enabledCheck->setChecked(m_plugin->completionEnabled());
int idx = m_diffModeCombo->findData(m_plugin->diffMode());
m_diffModeCombo->setCurrentIndex(idx >= 0 ? idx : 0);
}
void defaults() override {
m_defaultBackendEdit->clear();
m_defaultModelEdit->clear();
m_backendEdit->clear();
m_modelEdit->clear();
m_enabledCheck->setChecked(true);
m_diffModeCombo->setCurrentIndex(0);
}
private:
OllieKatePlugin *m_plugin;
QLineEdit *m_defaultBackendEdit;
QLineEdit *m_defaultModelEdit;
QLineEdit *m_backendEdit;
QLineEdit *m_modelEdit;
QCheckBox *m_enabledCheck;
QComboBox *m_diffModeCombo;
};
return new OllieConfigPage(this, parent);
}
QString OllieKatePlugin::completionBackend() const { return m_completionBackend; }
QString OllieKatePlugin::completionModel() const { return m_completionModel; }
void OllieKatePlugin::setCompletionBackend(const QString &backend)
{
m_completionBackend = backend;
saveConfig();
}
void OllieKatePlugin::setCompletionModel(const QString &model)
{
m_completionModel = model;
saveConfig();
}
bool OllieKatePlugin::completionEnabled() const { return m_completionEnabled; }
void OllieKatePlugin::setCompletionEnabled(bool enabled)
{
m_completionEnabled = enabled;
saveConfig();
}
QString OllieKatePlugin::defaultBackend() const { return m_defaultBackend; }
QString OllieKatePlugin::defaultModel() const { return m_defaultModel; }
QString OllieKatePlugin::diffMode() const { return m_diffMode; }
void OllieKatePlugin::setDefaultBackend(const QString &backend)
{
m_defaultBackend = backend;
saveConfig();
}
void OllieKatePlugin::setDefaultModel(const QString &model)
{
m_defaultModel = model;
saveConfig();
}
void OllieKatePlugin::setDiffMode(const QString &mode)
{
m_diffMode = mode;
saveConfig();
}
void OllieKatePlugin::loadConfig()
{
KConfigGroup grp(KSharedConfig::openConfig(), QStringLiteral("Ollie"));
m_completionBackend = grp.readEntry("CompletionBackend", QString());
m_completionModel = grp.readEntry("CompletionModel", QString());
m_completionEnabled = grp.readEntry("CompletionEnabled", true);
m_defaultBackend = grp.readEntry("DefaultBackend", QString());
m_defaultModel = grp.readEntry("DefaultModel", QString());
m_diffMode = grp.readEntry("DiffMode", QStringLiteral("unified"));
}
void OllieKatePlugin::saveConfig()
{
KConfigGroup grp(KSharedConfig::openConfig(), QStringLiteral("Ollie"));
grp.writeEntry("CompletionBackend", m_completionBackend);
grp.writeEntry("CompletionModel", m_completionModel);
grp.writeEntry("CompletionEnabled", m_completionEnabled);
grp.writeEntry("DefaultBackend", m_defaultBackend);
grp.writeEntry("DefaultModel", m_defaultModel);
grp.writeEntry("DiffMode", m_diffMode);
grp.sync();
}
// --- View ---
OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *mainWindow)
: QObject(mainWindow)
, m_plugin(plugin)
, m_mainWindow(mainWindow)
, m_renderer(new ChatRenderer())
{
m_renderer->setDiffStorage(&m_diffBlocks);
m_renderer->setApplyStorage(&m_applyBlocks);
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 + kill button
auto *headerLayout = new QHBoxLayout();
headerLayout->setContentsMargins(0, 0, 0, 0);
m_sessionLabel = new QLabel("No session", container);
m_sessionLabel->setStyleSheet(QStringLiteral("color: gray; padding: 2px;"));
m_sessionLabel->setFont(QApplication::font());
headerLayout->addWidget(m_sessionLabel);
headerLayout->addStretch();
auto *killBtn = new QPushButton("×", container);
killBtn->setFixedSize(20, 20);
killBtn->setToolTip("Kill session");
killBtn->setFlat(true);
connect(killBtn, &QPushButton::clicked, this, [this]() {
if (m_sessionId.isEmpty()) return;
m_iface->asyncCall("KillSession", m_sessionId);
});
headerLayout->addWidget(killBtn);
layout->addLayout(headerLayout);
// 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);
// Completion status indicator (in the main window status bar)
bool startEnabled = m_plugin->completionEnabled();
if (auto *win = qobject_cast<QMainWindow *>(m_mainWindow->window())) {
// Reuse existing widget if another view already created it
m_completionStatus = win->statusBar()->findChild<QLabel *>(QStringLiteral("ollie_completion_status"));
if (!m_completionStatus) {
m_completionStatus = new QLabel();
m_completionStatus->setObjectName(QStringLiteral("ollie_completion_status"));
m_completionStatus->setFont(smallFont);
win->statusBar()->addPermanentWidget(m_completionStatus);
}
m_completionStatus->setText(startEnabled
? QStringLiteral("✦ completion on")
: QStringLiteral("✦ completion off"));
m_completionStatus->setStyleSheet(startEnabled
? QStringLiteral("color: green; padding: 0px 4px;")
: QStringLiteral("color: gray; padding: 0px 4px;"));
} else {
m_completionStatus = nullptr;
}
// 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 QTextBrowser(container);
m_chatView->setReadOnly(true);
m_chatView->setOpenLinks(false);
m_chatView->setOpenExternalLinks(false);
QFont chatFont(QApplication::font());
chatFont.setPointSize(qMax(12, QApplication::font().pointSize() + 2));
m_chatView->setFont(chatFont);
m_chatView->setPlaceholderText("No active session.\nClick 'New' or use context actions.");
connect(m_chatView, &QTextBrowser::anchorClicked, this, &OllieKateView::onAnchorClicked);
layout->addWidget(m_chatView);
// Scroll-to-bottom button (floats over chat view)
m_scrollBottomBtn = new QPushButton("↓", container);
m_scrollBottomBtn->setFixedSize(28, 28);
m_scrollBottomBtn->setToolTip("Scroll to bottom");
m_scrollBottomBtn->hide();
connect(m_scrollBottomBtn, &QPushButton::clicked, this, [this]() {
auto *sb = m_chatView->verticalScrollBar();
sb->setValue(sb->maximum());
m_scrollBottomBtn->hide();
});
// Position over bottom-right of chat view, updated on resize
auto repositionBtn = [this]() {
if (!m_chatView) return;
QPoint pos = m_chatView->geometry().bottomRight() - QPoint(36, 36);
m_scrollBottomBtn->move(pos);
};
m_chatView->installEventFilter(this); // handle resize in eventFilter
repositionBtn();
// Also show/hide on manual scroll
connect(m_chatView->verticalScrollBar(), &QScrollBar::valueChanged, this, [this](int value) {
auto *sb = m_chatView->verticalScrollBar();
if (value >= sb->maximum() - 4) {
m_scrollBottomBtn->hide();
} else if (!m_chatView->toPlainText().isEmpty()) {
m_scrollBottomBtn->show();
}
});
// Prompt input
auto *promptLayout = new QHBoxLayout();
m_promptInput = new QPlainTextEdit(container);
m_promptInput->setPlaceholderText("Message...");
m_promptInput->setMaximumHeight(80);
m_promptInput->setTabChangesFocus(true);
m_promptInput->installEventFilter(this);
connect(m_promptInput, &QPlainTextEdit::textChanged, this, &OllieKateView::processEmojis);
promptLayout->addWidget(m_promptInput);
auto *sendBtn = new QPushButton("⏎", container);
sendBtn->setFixedWidth(30);
connect(sendBtn, &QPushButton::clicked, this, &OllieKateView::submitPrompt);
promptLayout->addWidget(sendBtn);
m_stopBtn = new QPushButton("■", container);
m_stopBtn->setToolTip("Stop (interrupt the current agent turn)");
m_stopBtn->setFixedWidth(30);
connect(m_stopBtn, &QPushButton::clicked, this, [this]() {
if (!m_sessionId.isEmpty()) {
m_iface->call("Interrupt", m_sessionId);
}
});
promptLayout->addWidget(m_stopBtn);
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();
// Set up ghost-text completion on active view
setupGhostProvider();
connect(mainWindow, &KTextEditor::MainWindow::viewChanged,
this, [this](KTextEditor::View *view) {
setupGhostProvider();
if (view) setupContextMenu(view);
});
// Set up context menu on initial active view
if (auto *view = mainWindow->activeView())
setupContextMenu(view);
}
OllieKateView::~OllieKateView()
{
clearAnnotations();
delete m_renderer;
delete m_ghost;
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) {
m_rawChat += new_text;
m_chatOffset += new_text.size();
m_renderer->render(m_chatView, m_rawChat);
annotateFileReferences(m_rawChat);
} 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_rawChat = reply.arguments()[0].toString();
m_chatOffset = reply.arguments()[1].toLongLong();
m_renderer->render(m_chatView, m_rawChat);
annotateFileReferences(m_rawChat);
m_scrollBottomBtn->hide();
}
}
}
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_rawChat.clear();
m_chatView->clear();
m_sessionLabel->setText("No session");
m_cwdLabel->setText("");
refreshSessions();
}
}
// --- Actions ---
void OllieKateView::submitPrompt()
{
QString text = m_promptInput->toPlainText().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();
}
QString backend = m_plugin->defaultBackend();
QString model = m_plugin->defaultModel();
QDBusReply<QString> reply = m_iface->call("CreateSession", cwd, backend, model, "default");
if (reply.isValid()) {
m_sessionId = reply.value();
m_rawChat.clear();
m_chatView->clear();
m_chatOffset = 0;
m_sessionLabel->setText("○ " + m_sessionId + " [idle]");
m_cwdLabel->setText(cwd);
} else {
m_chatView->setHtml("<p style='color:#e53935;'>Error creating session: " + reply.error().message().toHtmlEscaped() + "</p>");
}
}
void OllieKateView::pickSession()
{
QString baseDir = projectBaseDir();
if (baseDir.isEmpty()) {
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>Cannot determine project directory.</p>"));
return;
}
QDBusReply<QStringList> reply = m_iface->call("ListSessions");
if (!reply.isValid() || reply.value().isEmpty()) {
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No active sessions found.</p>"));
return;
}
QStringList entries = reply.value();
entries.sort(Qt::CaseInsensitive);
// Find first session whose cwd matches the project base dir
for (const QString &entry : entries) {
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->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No session found with cwd: ") + baseDir.toHtmlEscaped() + QStringLiteral("</p>"));
}
void OllieKateView::renameSession()
{
if (m_sessionId.isEmpty()) {
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No active session to rename.</p>"));
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->setHtml(QStringLiteral("<p style='color:#e53935;'>Failed to rename session.</p>"));
}
}
// --- 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_rawChat.clear();
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_rawChat = chatReply.arguments()[0].toString();
m_chatOffset = chatReply.arguments()[1].toLongLong();
m_renderer->render(m_chatView, m_rawChat);
m_scrollBottomBtn->hide();
}
}
void OllieKateView::ensureSession()
{
if (!m_sessionId.isEmpty()) return;
if (!m_iface->isValid()) {
m_chatView->setHtml(QStringLiteral("<p style='color:#e53935;'>Error: ollie-kde daemon not running.<br>Start with: ollie-kde or rc-service --user ollie-kde start</p>"));
return;
}
newSession();
}
QString OllieKateView::activeSessionId()
{
return m_sessionId;
}
void OllieKateView::processEmojis()
{
static const QHash<QString, QString> quickEmojis = {
{";)", QStringLiteral("\U0001F609")}, {":)", QStringLiteral("\U0001F642")},
{":D", QStringLiteral("\U0001F603")}, {":(", QStringLiteral("\U0001F61E")},
{":P", QStringLiteral("\U0001F61B")}, {":p", QStringLiteral("\U0001F61B")},
{"<3", QStringLiteral("\u2764\uFE0F")}, {":O", QStringLiteral("\U0001F62E")},
{":o", QStringLiteral("\U0001F62E")}, {"B)", QStringLiteral("\U0001F60E")},
{":*", QStringLiteral("\U0001F618")}, {"XD", QStringLiteral("\U0001F606")},
{"xD", QStringLiteral("\U0001F606")}, {":|", QStringLiteral("\U0001F610")},
{">:(", QStringLiteral("\U0001F620")}, {"'(", QStringLiteral("\U0001F622")},
};
static const QHash<QString, QString> shortcodes = {
{"thumbsup", QStringLiteral("\U0001F44D")}, {"thumbsdown", QStringLiteral("\U0001F44E")},
{"fire", QStringLiteral("\U0001F525")}, {"rocket", QStringLiteral("\U0001F680")},
{"check", QStringLiteral("\u2705")}, {"x", QStringLiteral("\u274C")},
{"eyes", QStringLiteral("\U0001F440")}, {"thinking", QStringLiteral("\U0001F914")},
{"shrug", QStringLiteral("\U0001F937")}, {"wave", QStringLiteral("\U0001F44B")},
{"clap", QStringLiteral("\U0001F44F")}, {"pray", QStringLiteral("\U0001F64F")},
{"heart", QStringLiteral("\u2764\uFE0F")}, {"broken_heart", QStringLiteral("\U0001F494")},
{"star", QStringLiteral("\u2B50")}, {"sparkles", QStringLiteral("\u2728")},
{"zap", QStringLiteral("\u26A1")}, {"100", QStringLiteral("\U0001F4AF")},
{"tada", QStringLiteral("\U0001F389")}, {"party", QStringLiteral("\U0001F389")},
{"beer", QStringLiteral("\U0001F37A")}, {"beers", QStringLiteral("\U0001F37B")},
{"coffee", QStringLiteral("\u2615")}, {"wine", QStringLiteral("\U0001F377")},
{"pizza", QStringLiteral("\U0001F355")}, {"taco", QStringLiteral("\U0001F32E")},
{"cookie", QStringLiteral("\U0001F36A")}, {"cake", QStringLiteral("\U0001F382")},
{"sun", QStringLiteral("\u2600\uFE0F")}, {"moon", QStringLiteral("\U0001F319")},
{"rainbow", QStringLiteral("\U0001F308")}, {"ocean", QStringLiteral("\U0001F30A")},
{"dog", QStringLiteral("\U0001F436")}, {"cat", QStringLiteral("\U0001F431")},
{"bug", QStringLiteral("\U0001F41B")}, {"snake", QStringLiteral("\U0001F40D")},
{"unicorn", QStringLiteral("\U0001F984")}, {"skull", QStringLiteral("\U0001F480")},
{"ghost", QStringLiteral("\U0001F47B")}, {"robot", QStringLiteral("\U0001F916")},
{"poop", QStringLiteral("\U0001F4A9")}, {"brain", QStringLiteral("\U0001F9E0")},
{"bulb", QStringLiteral("\U0001F4A1")}, {"gear", QStringLiteral("\u2699\uFE0F")},
{"lock", QStringLiteral("\U0001F512")}, {"key", QStringLiteral("\U0001F511")},
{"warning", QStringLiteral("\u26A0\uFE0F")}, {"no_entry", QStringLiteral("\u26D4")},
{"muscle", QStringLiteral("\U0001F4AA")}, {"ok_hand", QStringLiteral("\U0001F44C")},
{"fist", QStringLiteral("\u270A")}, {"v", QStringLiteral("\u270C\uFE0F")},
{"metal", QStringLiteral("\U0001F918")}, {"middle_finger", QStringLiteral("\U0001F595")},
{"smile", QStringLiteral("\U0001F604")}, {"joy", QStringLiteral("\U0001F602")},
{"rofl", QStringLiteral("\U0001F923")}, {"wink", QStringLiteral("\U0001F609")},
{"blush", QStringLiteral("\U0001F60A")}, {"heart_eyes", QStringLiteral("\U0001F60D")},
{"cool", QStringLiteral("\U0001F60E")}, {"angry", QStringLiteral("\U0001F620")},
{"cry", QStringLiteral("\U0001F622")}, {"sob", QStringLiteral("\U0001F62D")},
{"scream", QStringLiteral("\U0001F631")}, {"facepalm", QStringLiteral("\U0001F926")},
{"gift", QStringLiteral("\U0001F381")}, {"balloon", QStringLiteral("\U0001F388")},
{"trophy", QStringLiteral("\U0001F3C6")}, {"crown", QStringLiteral("\U0001F451")},
{"gem", QStringLiteral("\U0001F48E")}, {"money", QStringLiteral("\U0001F4B0")},
{"computer", QStringLiteral("\U0001F4BB")}, {"phone", QStringLiteral("\U0001F4F1")},
{"earth", QStringLiteral("\U0001F30D")}, {"airplane", QStringLiteral("\u2708\uFE0F")},
};
static bool processing = false;
if (processing) return;
processing = true;
QTextCursor cursor = m_promptInput->textCursor();
int pos = cursor.position();
QString text = m_promptInput->toPlainText();
// Quick emoticons (check last 2-4 chars before cursor)
for (int len = 2; len <= qMin(4, pos); ++len) {
QString candidate = text.mid(pos - len, len);
auto it = quickEmojis.find(candidate);
if (it != quickEmojis.end()) {
cursor.setPosition(pos - len);
cursor.setPosition(pos, QTextCursor::KeepAnchor);
cursor.insertText(it.value());
processing = false;
return;
}
}
// :shortcode: (find closing : at cursor, opening : before it)
if (pos >= 3 && text[pos - 1] == ':') {
int start = text.lastIndexOf(':', pos - 2);
if (start >= 0 && start < pos - 2) {
QString code = text.mid(start + 1, pos - start - 2);
auto it = shortcodes.find(code);
if (it != shortcodes.end()) {
cursor.setPosition(start);
cursor.setPosition(pos, QTextCursor::KeepAnchor);
cursor.insertText(it.value());
}
}
}
processing = false;
}
void OllieKateView::onAnchorClicked(const QUrl &url)
{
if (url.scheme() == QStringLiteral("ollie-diff")) {
bool ok;
int index = url.path().mid(1).toInt(&ok); // path is "/N"
if (ok) {
showDiffBlock(index);
}
} else if (url.scheme() == QStringLiteral("ollie-apply")) {
bool ok;
int index = url.path().mid(1).toInt(&ok); // path is "/N"
if (ok) {
applyCodeBlock(index);
}
}
}
void OllieKateView::showDiffBlock(int index)
{
if (index < 0 || index >= m_diffBlocks.size()) return;
QByteArray diffData = m_diffBlocks[index].toUtf8();
QString mode = m_plugin->diffMode();
// Set Kate's diff style preference before opening
{
KConfigGroup grp(KSharedConfig::openConfig(QStringLiteral("katerc")),
QStringLiteral("Diff"));
// Kate uses: 0 = Side By Side, 1 = Unified, 2 = Raw
int style = 1; // unified default
if (mode == QStringLiteral("side-by-side")) style = 0;
else if (mode == QStringLiteral("raw")) style = 2;
grp.writeEntry("DiffStyle", style);
grp.sync();
}
#ifdef HAVE_KATE_DIFF
DiffParams params;
params.tabTitle = QStringLiteral("Ollie Diff %1").arg(index + 1);
params.flags = DiffParams::Flag(DiffParams::ShowFileName);
Utils::showDiff(diffData, params, m_mainWindow);
#else
// Fallback: open as a new document with Diff highlighting
auto *view = m_mainWindow->openUrl(QUrl());
if (view) {
view->document()->setText(QString::fromUtf8(diffData));
view->document()->setHighlightingMode(QStringLiteral("Diff"));
}
#endif
}
void OllieKateView::refreshSessions()
{
QDBusReply<QStringList> reply = m_iface->call("ListSessions");
if (!reply.isValid()) return;
QStringList sessions = reply.value();
sessions.sort(Qt::CaseInsensitive);
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]);
}
}
void OllieKateView::setupGhostProvider()
{
auto *view = m_mainWindow->activeView();
if (!view)
return;
// If ghost provider is already on this view, keep it
if (m_ghost && m_ghost->view() == view)
return;
// Remove old provider
delete m_ghost;
m_ghost = nullptr;
// Create new ghost provider for the active view
m_ghost = new OllieGhostProvider(view, m_iface, m_plugin, this);
m_ghost->setEnabled(m_plugin->completionEnabled());
// Install event filter on the view widget for Tab interception
if (QWidget *proxy = view->focusProxy()) {
proxy->installEventFilter(this);
}
}
bool OllieKateView::eventFilter(QObject *obj, QEvent *event)
{
// Reposition scroll-to-bottom button when chat view resizes
if (obj == m_chatView && event->type() == QEvent::Resize) {
QPoint pos = m_chatView->geometry().bottomRight() - QPoint(36, 36);
m_scrollBottomBtn->move(pos);
}
// Prompt input: Enter sends, Shift+Enter / Ctrl+J inserts newline
if (obj == m_promptInput && event->type() == QEvent::KeyPress) {
auto *ke = static_cast<QKeyEvent *>(event);
if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) {
if (ke->modifiers() & (Qt::ShiftModifier | Qt::ControlModifier)) {
m_promptInput->insertPlainText("\n");
} else {
submitPrompt();
}
return true;
}
if (ke->key() == Qt::Key_J && (ke->modifiers() & Qt::ControlModifier)) {
m_promptInput->insertPlainText("\n");
return true;
}
}
if (event->type() == QEvent::KeyPress && m_ghost) {
auto *keyEvent = static_cast<QKeyEvent *>(event);
// Second key of Meta+O chord
if (m_ghostPrefix) {
m_ghostPrefix = false;
switch (keyEvent->key()) {
case Qt::Key_N:
m_ghost->cycle(+1);
return true;
case Qt::Key_P:
m_ghost->cycle(-1);
return true;
case Qt::Key_C:
m_ghost->setEnabled(!m_ghost->isEnabled());
if (m_ghost->isEnabled()) {
m_completionStatus->setText(QStringLiteral("\u2726 completion on"));
m_completionStatus->setStyleSheet(QStringLiteral("color: green; padding: 0px 2px;"));
} else {
m_completionStatus->setText(QStringLiteral("\u2726 completion off"));
m_completionStatus->setStyleSheet(QStringLiteral("color: gray; padding: 0px 2px;"));
}
return true;
case Qt::Key_D:
m_ghost->dismiss();
return true;
case Qt::Key_Tab:
m_ghost->requestCompletion();
return true;
default:
// Not a recognized chord — pass through
break;
}
}
// Meta+O starts the chord prefix
if (keyEvent->key() == Qt::Key_O && keyEvent->modifiers() == Qt::MetaModifier) {
m_ghostPrefix = true;
return true;
}
if (m_ghost->hasSuggestion()) {
// Don't intercept keys while a popup or completion widget is active
auto *activeView = m_mainWindow->activeView();
#if KTEXTEDITOR_VERSION_MAJOR >= 6
if (QApplication::activePopupWidget() ||
(activeView && activeView->isCompletionActive()))
return QObject::eventFilter(obj, event);
#else
auto *ccIface = activeView ? dynamic_cast<KTextEditor::CodeCompletionInterface *>(activeView) : nullptr;
if (QApplication::activePopupWidget() ||
(ccIface && ccIface->isCompletionActive()))
return QObject::eventFilter(obj, event);
#endif
if (keyEvent->key() == Qt::Key_Tab && keyEvent->modifiers() == Qt::NoModifier) {
if (m_ghost->accept()) {
return true; // consumed
}
}
}
}
return QObject::eventFilter(obj, event);
}
// --- Context Menu Integration ---
void OllieKateView::setupContextMenu(KTextEditor::View *view)
{
if (!view) return;
// Connect directly to the view's contextMenuAboutToShow signal.
// This signal is emitted by KateView before showing the context menu.
connect(view, &KTextEditor::View::contextMenuAboutToShow,
this, [this](KTextEditor::View *v, QMenu *menu) {
// Remove previous ollie actions to avoid duplicates
const auto actions = menu->actions();
for (auto *a : actions) {
if (a->objectName().startsWith(QStringLiteral("ollie_ctx_"))) {
menu->removeAction(a);
delete a;
}
}
if (!v->selection()) return;
auto *sep = menu->addSeparator();
sep->setObjectName(QStringLiteral("ollie_ctx_sep"));
auto *fixAction = menu->addAction(QIcon::fromTheme(QStringLiteral("tools-wizard")),
QStringLiteral("Ollie: Fix this"));
fixAction->setObjectName(QStringLiteral("ollie_ctx_fix"));
connect(fixAction, &QAction::triggered, this, [this]() { submitSelectionPrompt(QStringLiteral("Fix")); });
auto *refactorAction = menu->addAction(QIcon::fromTheme(QStringLiteral("edit-rename")),
QStringLiteral("Ollie: Refactor this"));
refactorAction->setObjectName(QStringLiteral("ollie_ctx_refactor"));
connect(refactorAction, &QAction::triggered, this, [this]() { submitSelectionPrompt(QStringLiteral("Refactor")); });
auto *testAction = menu->addAction(QIcon::fromTheme(QStringLiteral("document-new")),
QStringLiteral("Ollie: Add tests for this"));
testAction->setObjectName(QStringLiteral("ollie_ctx_test"));
connect(testAction, &QAction::triggered, this, [this]() { submitSelectionPrompt(QStringLiteral("Write unit tests for")); });
auto *docAction = menu->addAction(QIcon::fromTheme(QStringLiteral("documentation")),
QStringLiteral("Ollie: Document this"));
docAction->setObjectName(QStringLiteral("ollie_ctx_doc"));
connect(docAction, &QAction::triggered, this, [this]() { submitSelectionPrompt(QStringLiteral("Add documentation comments to")); });
});
}
void OllieKateView::submitSelectionPrompt(const QString &action)
{
auto *view = m_mainWindow->activeView();
if (!view || !view->selection()) return;
QString selection = view->selectionText();
if (selection.isEmpty()) return;
ensureSession();
if (m_sessionId.isEmpty()) return;
QString filePath = view->document()->url().toLocalFile();
auto cursor = view->selectionRange().start();
QString location;
if (!filePath.isEmpty()) {
location = " in `" + filePath + ":" + QString::number(cursor.line() + 1) + "`";
}
QString prompt = action + " this code" + location + ":\n\n```\n" + selection + "\n```";
m_iface->call("Submit", m_sessionId, prompt);
}
// --- Apply Code Blocks ---
void OllieKateView::applyCodeBlock(int index)
{
if (index < 0 || index >= m_applyBlocks.size()) return;
const auto &block = m_applyBlocks[index];
if (block.code.trimmed().isEmpty()) return;
KTextEditor::View *view = nullptr;
KTextEditor::Document *doc = nullptr;
if (!block.filePath.isEmpty()) {
// Open the target file in Kate
view = m_mainWindow->openUrl(QUrl::fromLocalFile(block.filePath));
} else {
// No file path detected — apply to the currently active document
view = m_mainWindow->activeView();
}
if (!view || !view->document()) return;
doc = view->document();
// If there's a selection in the target view, replace it with the code block
if (view->selection()) {
doc->replaceText(view->selectionRange(), block.code);
return;
}
// Otherwise, replace the entire document content if it looks like a full file,
// or insert at cursor position
if (block.code.contains('\n') && block.code.split('\n').size() > 5) {
// Heuristic: if the code block is substantial, insert at cursor
KTextEditor::Cursor cursor = view->cursorPosition();
doc->insertText(cursor, block.code + QStringLiteral("\n"));
} else {
// Short snippet — insert at cursor
KTextEditor::Cursor cursor = view->cursorPosition();
doc->insertText(cursor, block.code);
}
}
// --- Inline Annotations ---
void OllieKateView::clearAnnotations()
{
qDeleteAll(m_annotations);
m_annotations.clear();
}
void OllieKateView::annotateFileReferences(const QString &chatText)
{
clearAnnotations();
auto *view = m_mainWindow->activeView();
if (!view || !view->document()) return;
QString currentFile = view->document()->url().toLocalFile();
if (currentFile.isEmpty()) return;
// Match patterns like /path/to/file.ext:123 or /path/to/file.ext:123:45
static const QRegularExpression fileRefRe(
R"((?:^|[\s`])(/[^\s:]+):(\d+)(?::(\d+))?)",
QRegularExpression::MultilineOption);
auto *doc = view->document();
auto *movingIface = qobject_cast<KTextEditor::Document *>(doc);
if (!movingIface) return;
KTextEditor::Attribute::Ptr attr(new KTextEditor::Attribute());
attr->setBackground(m_renderer ? QColor(255, 255, 0, 40) : QColor(255, 255, 0, 40));
attr->setFontBold(false);
QSet<int> annotatedLines;
auto it = fileRefRe.globalMatch(chatText);
while (it.hasNext()) {
auto match = it.next();
QString path = match.captured(1);
int line = match.captured(2).toInt() - 1; // 0-indexed
// Only annotate if it references the currently open file
if (path != currentFile) continue;
if (line < 0 || line >= doc->lines()) continue;
if (annotatedLines.contains(line)) continue;
annotatedLines.insert(line);
KTextEditor::Range range(line, 0, line, doc->lineLength(line));
auto *movingRange = doc->newMovingRange(range);
movingRange->setAttribute(attr);
movingRange->setZDepth(-100.0); // behind text
m_annotations.append(movingRange);
}
}
#include "ollie_kate.moc"