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:
parent
bbff78cd7e
commit
16e8fc7a75
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ¬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<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);
|
||||
}
|
||||
|
|
@ -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 ¬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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Reference in New Issue