From 16e8fc7a75988596e7d0b5f2e27a8bc8c219e742 Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:30:18 +0200 Subject: [PATCH] feat: ghost-text code completion via InlineNoteProvider Adds OllieGhostProvider that renders AI completions as translucent gray text after the cursor. Uses the new Complete D-Bus method with 500ms debounce. Tab accepts, Escape dismisses, any edit clears. Works on both KF5 and KF6 builds. --- CMakeLists.txt | 4 +- kate/ollie_ghost.cpp | 254 +++++++++++++++++++++++++++++++++++++++++++ kate/ollie_ghost.h | 74 +++++++++++++ kate/ollie_kate.cpp | 46 ++++++++ kate/ollie_kate.h | 8 ++ 5 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 kate/ollie_ghost.cpp create mode 100644 kate/ollie_ghost.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9338bc6..5461b2c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,7 +58,7 @@ if(OLLIE_KF5) ) # --- Kate Plugin --- - kcoreaddons_add_plugin(ollie_kate SOURCES kate/ollie_kate.cpp INSTALL_NAMESPACE "kf5/ktexteditor") + kcoreaddons_add_plugin(ollie_kate SOURCES kate/ollie_kate.cpp kate/ollie_ghost.cpp INSTALL_NAMESPACE "kf5/ktexteditor") target_link_libraries(ollie_kate KF5::TextEditor Qt5::Core @@ -151,7 +151,7 @@ else() # --- Kate Plugin --- find_package(KF6TextEditor REQUIRED) - kcoreaddons_add_plugin(ollie_kate SOURCES kate/ollie_kate.cpp INSTALL_NAMESPACE "kf6/ktexteditor") + kcoreaddons_add_plugin(ollie_kate SOURCES kate/ollie_kate.cpp kate/ollie_ghost.cpp INSTALL_NAMESPACE "kf6/ktexteditor") target_link_libraries(ollie_kate KF6::TextEditor Qt6::Core diff --git a/kate/ollie_ghost.cpp b/kate/ollie_ghost.cpp new file mode 100644 index 0000000..b77bda7 --- /dev/null +++ b/kate/ollie_ghost.cpp @@ -0,0 +1,254 @@ +#include "ollie_ghost.h" + +#include +#include +#include +#include +#include +#include + +static const char *DBUS_SERVICE = "org.ollie.SessionManager"; +static const char *DBUS_PATH = "/org/ollie/SessionManager"; +static const char *DBUS_IFACE = "org.ollie.SessionManager"; + +OllieGhostProvider::OllieGhostProvider(KTextEditor::View *view, QDBusInterface *iface, QObject *parent) + : KTextEditor::InlineNoteProvider() + , m_view(view) + , m_iface(iface) + , m_debounce(new QTimer(this)) +{ + Q_UNUSED(parent); + m_debounce->setSingleShot(true); + m_debounce->setInterval(500); + connect(m_debounce, &QTimer::timeout, this, &OllieGhostProvider::requestCompletion); + + connect(view, &KTextEditor::View::cursorPositionChanged, + this, &OllieGhostProvider::onCursorPositionChanged); + connect(view->document(), &KTextEditor::Document::textChanged, + this, &OllieGhostProvider::onTextChanged); + + view->registerInlineNoteProvider(this); +} + +OllieGhostProvider::~OllieGhostProvider() +{ + m_view->unregisterInlineNoteProvider(this); +} + +// --- InlineNoteProvider interface --- + +QList OllieGhostProvider::inlineNotes(int line) const +{ + if (m_suggestionLines.isEmpty() || m_suggestionStartLine < 0) + return {}; + + // We show notes on lines covered by the suggestion + int endLine = m_suggestionStartLine + m_suggestionLines.size() - 1; + if (line < m_suggestionStartLine || line > endLine) + return {}; + + int idx = line - m_suggestionStartLine; + if (m_suggestionLines[idx].isEmpty()) + return {}; + + // Place note at end of line (or at cursor col for first line) + int lineLen = m_view->document()->lineLength(line); + int col = lineLen; // after last character + if (idx == 0) { + col = m_suggestionStartCol; + } + // Column must be >= line length for after-text placement + if (col < lineLen) + col = lineLen; + + return {col}; +} + +QSize OllieGhostProvider::inlineNoteSize(const KTextEditor::InlineNote ¬e) const +{ + int line = note.position().line(); + int idx = line - m_suggestionStartLine; + if (idx < 0 || idx >= m_suggestionLines.size()) + return QSize(0, 0); + + QFont font = note.font(); + QFontMetricsF fm(font); + qreal width = fm.horizontalAdvance(m_suggestionLines[idx]); + return QSize(qRound(width), note.lineHeight()); +} + +void OllieGhostProvider::paintInlineNote(const KTextEditor::InlineNote ¬e, QPainter &painter, Qt::LayoutDirection direction) const +{ + Q_UNUSED(direction); + + int line = note.position().line(); + int idx = line - m_suggestionStartLine; + if (idx < 0 || idx >= m_suggestionLines.size()) + return; + + QFont font = note.font(); + painter.setFont(font); + + // Draw in dimmed color + QColor ghostColor = QApplication::palette().color(QPalette::Text); + ghostColor.setAlphaF(0.4); + painter.setPen(ghostColor); + + QFontMetricsF fm(font); + qreal y = fm.ascent(); + painter.drawText(QPointF(0, y), m_suggestionLines[idx]); +} + +// --- Public API --- + +bool OllieGhostProvider::accept() +{ + if (m_suggestionLines.isEmpty() || m_suggestionStartLine < 0) + return false; + + auto *doc = m_view->document(); + QString text = m_suggestionLines.join(QStringLiteral("\n")); + + // Insert at the trigger cursor position + KTextEditor::Cursor insertPos(m_suggestionStartLine, m_suggestionStartCol); + doc->insertText(insertPos, text); + + clearSuggestion(); + return true; +} + +void OllieGhostProvider::dismiss() +{ + if (!m_suggestionLines.isEmpty()) { + clearSuggestion(); + } + m_debounce->stop(); +} + +bool OllieGhostProvider::hasSuggestion() const +{ + return !m_suggestionLines.isEmpty(); +} + +// --- Private slots --- + +void OllieGhostProvider::onCursorPositionChanged() +{ + // If cursor moved away from suggestion, dismiss it + if (hasSuggestion()) { + KTextEditor::Cursor cur = m_view->cursorPosition(); + if (cur != m_triggerCursor) { + clearSuggestion(); + } + } + m_debounce->start(); +} + +void OllieGhostProvider::onTextChanged() +{ + // Any text change invalidates the current suggestion + if (hasSuggestion()) { + clearSuggestion(); + } + m_debounce->start(); +} + +void OllieGhostProvider::requestCompletion() +{ + if (m_requesting) + return; + + auto *doc = m_view->document(); + if (!doc || doc->url().toLocalFile().isEmpty()) + return; + + KTextEditor::Cursor cursor = m_view->cursorPosition(); + QString prefix = gatherPrefix(); + QString suffix = gatherSuffix(); + QString filePath = doc->url().toLocalFile(); + + // Determine cwd from project or file directory + QString cwd = QFileInfo(filePath).absolutePath(); + + m_triggerCursor = cursor; + m_requesting = true; + + // Async D-Bus call + QDBusMessage msg = QDBusMessage::createMethodCall( + DBUS_SERVICE, DBUS_PATH, DBUS_IFACE, "Complete"); + msg << cwd << filePath << prefix << suffix << QString(); + + QDBusPendingCall pending = QDBusConnection::sessionBus().asyncCall(msg, 15000); + auto *watcher = new QDBusPendingCallWatcher(pending, this); + connect(watcher, &QDBusPendingCallWatcher::finished, this, [this](QDBusPendingCallWatcher *w) { + w->deleteLater(); + m_requesting = false; + + QDBusPendingReply reply = *w; + if (reply.isError()) { + return; + } + onCompletionReply(reply.value()); + }); +} + +void OllieGhostProvider::onCompletionReply(const QString &result) +{ + if (result.trimmed().isEmpty()) + return; + + // Check cursor hasn't moved since we triggered + if (m_view->cursorPosition() != m_triggerCursor) + return; + + m_suggestionStartLine = m_triggerCursor.line(); + m_suggestionStartCol = m_triggerCursor.column(); + m_suggestionLines = result.split(QStringLiteral("\n")); + + // Notify Kate to repaint affected lines + for (int i = 0; i < m_suggestionLines.size(); ++i) { + emit inlineNotesChanged(m_suggestionStartLine + i); + } +} + +// --- Private helpers --- + +void OllieGhostProvider::clearSuggestion() +{ + if (m_suggestionLines.isEmpty()) + return; + + QList affectedLines; + for (int i = 0; i < m_suggestionLines.size(); ++i) { + affectedLines.append(m_suggestionStartLine + i); + } + + m_suggestionLines.clear(); + m_suggestionStartLine = -1; + m_suggestionStartCol = -1; + + for (int line : affectedLines) { + emit inlineNotesChanged(line); + } +} + +QString OllieGhostProvider::gatherPrefix() const +{ + auto *doc = m_view->document(); + KTextEditor::Cursor cursor = m_view->cursorPosition(); + + // Get text from start of document to cursor + KTextEditor::Range prefixRange(KTextEditor::Cursor(0, 0), cursor); + return doc->text(prefixRange); +} + +QString OllieGhostProvider::gatherSuffix() const +{ + auto *doc = m_view->document(); + KTextEditor::Cursor cursor = m_view->cursorPosition(); + + // Get text from cursor to end of document + KTextEditor::Cursor endCursor(doc->lines() - 1, doc->lineLength(doc->lines() - 1)); + KTextEditor::Range suffixRange(cursor, endCursor); + return doc->text(suffixRange); +} diff --git a/kate/ollie_ghost.h b/kate/ollie_ghost.h new file mode 100644 index 0000000..de4596e --- /dev/null +++ b/kate/ollie_ghost.h @@ -0,0 +1,74 @@ +#ifndef OLLIE_GHOST_H +#define OLLIE_GHOST_H + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +/** + * OllieGhostProvider renders AI code completions as translucent gray text + * at the cursor position using KTextEditor's InlineNoteProvider API. + * + * Flow: + * 1. Cursor moves or text changes → debounce timer starts (500ms) + * 2. Timer fires → gather prefix/suffix, call D-Bus Complete asynchronously + * 3. Result arrives → store suggestion lines, emit inlineNotesChanged + * 4. paintInlineNote renders gray text after end-of-line + * 5. Tab → accept (insert text), any other input → dismiss + */ +class OllieGhostProvider : public KTextEditor::InlineNoteProvider +{ + Q_OBJECT + +public: + explicit OllieGhostProvider(KTextEditor::View *view, QDBusInterface *iface, QObject *parent = nullptr); + ~OllieGhostProvider() override; + + // InlineNoteProvider interface + QList inlineNotes(int line) const override; + QSize inlineNoteSize(const KTextEditor::InlineNote ¬e) const override; + void paintInlineNote(const KTextEditor::InlineNote ¬e, QPainter &painter, Qt::LayoutDirection direction) const override; + + /// Accept the current suggestion (insert into document). Returns true if accepted. + bool accept(); + + /// Dismiss the current suggestion without inserting. + void dismiss(); + + /// Returns true if a suggestion is currently visible. + bool hasSuggestion() const; + +private slots: + void onCursorPositionChanged(); + void onTextChanged(); + void requestCompletion(); + void onCompletionReply(const QString &result); + +private: + void clearSuggestion(); + QString gatherPrefix() const; + QString gatherSuffix() const; + + KTextEditor::View *m_view; + QDBusInterface *m_iface; + QTimer *m_debounce; + + // Current suggestion state + QStringList m_suggestionLines; // suggestion split by newline + int m_suggestionStartLine = -1; // document line where suggestion starts + int m_suggestionStartCol = -1; // column (end of line where cursor was) + KTextEditor::Cursor m_triggerCursor; // cursor position when completion was triggered + + bool m_requesting = false; // true while D-Bus call is in flight +}; + +#endif // OLLIE_GHOST_H diff --git a/kate/ollie_kate.cpp b/kate/ollie_kate.cpp index 1b8fc28..d964079 100644 --- a/kate/ollie_kate.cpp +++ b/kate/ollie_kate.cpp @@ -1,6 +1,8 @@ #include "ollie_kate.h" +#include "ollie_ghost.h" #include +#include #include #include @@ -149,10 +151,16 @@ OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *m // 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 *) { setupGhostProvider(); }); } OllieKateView::~OllieKateView() { + delete m_ghost; delete m_toolView; } @@ -471,4 +479,42 @@ void OllieKateView::refreshSessions() } } +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->parent() == 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, view); + + // Install event filter on the view widget for Tab interception + view->focusProxy()->installEventFilter(this); +} + +bool OllieKateView::eventFilter(QObject *obj, QEvent *event) +{ + if (event->type() == QEvent::KeyPress && m_ghost && m_ghost->hasSuggestion()) { + auto *keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Tab && keyEvent->modifiers() == Qt::NoModifier) { + if (m_ghost->accept()) { + return true; // consumed + } + } + if (keyEvent->key() == Qt::Key_Escape) { + m_ghost->dismiss(); + return true; + } + } + return QObject::eventFilter(obj, event); +} + #include "ollie_kate.moc" diff --git a/kate/ollie_kate.h b/kate/ollie_kate.h index 46b13d7..ea244e7 100644 --- a/kate/ollie_kate.h +++ b/kate/ollie_kate.h @@ -14,8 +14,10 @@ #include #include #include +#include class OllieKateView; +class OllieGhostProvider; class OllieKatePlugin : public KTextEditor::Plugin { @@ -40,6 +42,9 @@ public: explicit OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *mainWindow); ~OllieKateView() override; +protected: + bool eventFilter(QObject *obj, QEvent *event) 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); @@ -73,8 +78,11 @@ private: QString m_sessionId; qlonglong m_chatOffset = 0; + OllieGhostProvider *m_ghost = nullptr; + QString projectBaseDir(); void switchToSession(const QString &sessionId); + void setupGhostProvider(); }; #endif // OLLIE_KATE_PLUGIN_H