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.
This commit is contained in:
Levi Neely 2026-07-14 12:30:18 +02:00
parent bbff78cd7e
commit 16e8fc7a75
5 changed files with 384 additions and 2 deletions

View File

@ -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

254
kate/ollie_ghost.cpp Normal file
View File

@ -0,0 +1,254 @@
#include "ollie_ghost.h"
#include <QDBusMessage>
#include <QDBusPendingCall>
#include <QDBusPendingReply>
#include <QDBusPendingCallWatcher>
#include <QApplication>
#include <QFileInfo>
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<int> 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 &note) 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 &note, 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<QString> 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<int> 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);
}

74
kate/ollie_ghost.h Normal file
View File

@ -0,0 +1,74 @@
#ifndef OLLIE_GHOST_H
#define OLLIE_GHOST_H
#include <KTextEditor/InlineNoteProvider>
#include <KTextEditor/View>
#include <KTextEditor/Document>
#include <KTextEditor/Cursor>
#include <QDBusInterface>
#include <QTimer>
#include <QPainter>
#include <QFont>
#include <QFontMetricsF>
#include <QString>
#include <QStringList>
/**
* 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<int> inlineNotes(int line) const override;
QSize inlineNoteSize(const KTextEditor::InlineNote &note) const override;
void paintInlineNote(const KTextEditor::InlineNote &note, 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

View File

@ -1,6 +1,8 @@
#include "ollie_kate.h"
#include "ollie_ghost.h"
#include <KPluginFactory>
#include <QKeyEvent>
#include <KTextEditor/Document>
#include <KTextEditor/Editor>
@ -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<QKeyEvent *>(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"

View File

@ -14,8 +14,10 @@
#include <QLineEdit>
#include <QLabel>
#include <QAction>
#include <QEvent>
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