// TODO #include "ollie_kate.h" #include "ollie_ghost.h" #ifdef HAVE_KATE_DIFF #include "ktexteditor_utils.h" #endif #include #include #include #include #include #if KTEXTEDITOR_VERSION_MAJOR < 6 #include #include #endif #include #include #include #include #include #include #include #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 --- #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::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(m_mainWindow->window())) { // Reuse existing widget if another view already created it m_completionStatus = win->statusBar()->findChild(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 reply = m_iface->call("CreateSession", cwd, backend, model, "default", QString()); 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("

Error creating session: " + reply.error().message().toHtmlEscaped() + "

"); } } void OllieKateView::pickSession() { QString baseDir = projectBaseDir(); if (baseDir.isEmpty()) { m_chatView->setHtml(QStringLiteral("

Cannot determine project directory.

")); return; } QDBusReply reply = m_iface->call("ListSessions"); if (!reply.isValid() || reply.value().isEmpty()) { m_chatView->setHtml(QStringLiteral("

No active sessions found.

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

No session found with cwd: ") + baseDir.toHtmlEscaped() + QStringLiteral("

")); } void OllieKateView::renameSession() { if (m_sessionId.isEmpty()) { m_chatView->setHtml(QStringLiteral("

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 reply = m_iface->call("RenameSession", m_sessionId, newName); if (reply.isValid() && reply.value()) { m_sessionId = newName; QString dot = "○ "; QDBusReply 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("

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_rawChat.clear(); m_chatView->clear(); m_chatOffset = 0; // Fetch state QDBusReply 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 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("

Error: ollie-kde daemon not running.
Start with: ollie-kde or rc-service --user ollie-kde start

")); return; } newSession(); } QString OllieKateView::activeSessionId() { return m_sessionId; } void OllieKateView::processEmojis() { static const QHash 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 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 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 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(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(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(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(); // Line-range replace mode: replace lines startLine through endLine if (block.startLine > 0 && block.endLine > 0) { int start = block.startLine - 1; // convert to 0-indexed int end = block.endLine - 1; if (end >= doc->lines()) end = doc->lines() - 1; if (start > end) start = end; KTextEditor::Range range( KTextEditor::Cursor(start, 0), KTextEditor::Cursor(end, doc->lineLength(end))); doc->replaceText(range, block.code); view->setCursorPosition(KTextEditor::Cursor(start, 0)); return; } // 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 insert at cursor position 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(); #if KTEXTEDITOR_VERSION_MAJOR < 6 auto *movingIface = qobject_cast(doc); #else auto *movingIface = doc; #endif 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 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 = movingIface->newMovingRange(range); movingRange->setAttribute(attr); movingRange->setZDepth(-100.0); // behind text m_annotations.append(movingRange); } } #include "ollie_kate.moc"