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_ghost.cpp

544 lines
16 KiB
C++

#include "ollie_ghost.h"
#include "ollie_kate.h"
#include <ktexteditor_version.h>
#if KTEXTEDITOR_VERSION_MAJOR < 6
#include <KTextEditor/InlineNoteInterface>
#include <KTextEditor/CodeCompletionInterface>
#include <KTextEditor/ConfigInterface>
#endif
#include <QApplication>
#include <QFileInfo>
#include <QPaintEvent>
#include <QScrollBar>
#include <QProcess>
#include <QJsonDocument>
#include <QJsonObject>
// === GhostOverlay ===
GhostOverlay::GhostOverlay(QWidget *parent)
: QWidget(parent)
{
setAttribute(Qt::WA_TransparentForMouseEvents);
setAutoFillBackground(false);
hide();
}
void GhostOverlay::setLines(const QStringList &lines, const QFont &font)
{
m_lines = lines;
m_font = font;
if (m_lines.isEmpty()) {
hide();
return;
}
QFontMetricsF fm(m_font);
qreal maxWidth = 0;
for (const QString &line : m_lines) {
qreal w = fm.horizontalAdvance(line);
if (w > maxWidth) maxWidth = w;
}
int height = qRound(fm.lineSpacing() * m_lines.size()) + 4;
int width = qRound(maxWidth) + 8;
setFixedSize(width, height);
show();
raise();
update();
}
void GhostOverlay::clear()
{
m_lines.clear();
hide();
}
void GhostOverlay::paintEvent(QPaintEvent *event)
{
Q_UNUSED(event);
if (m_lines.isEmpty()) return;
QPainter painter(this);
// Draw background — editor base color with a tint to distinguish from real text
QColor bgColor = QApplication::palette().color(QPalette::Base);
// Shift slightly toward highlight color to signal "this is a suggestion"
QColor tint = QApplication::palette().color(QPalette::Highlight);
bgColor = QColor(
(bgColor.red() * 4 + tint.red()) / 5,
(bgColor.green() * 4 + tint.green()) / 5,
(bgColor.blue() * 4 + tint.blue()) / 5
);
bgColor.setAlphaF(0.92);
painter.fillRect(rect(), bgColor);
// Draw ghost text
painter.setFont(m_font);
QColor ghostColor = QApplication::palette().color(QPalette::Text);
ghostColor.setAlphaF(0.6);
painter.setPen(ghostColor);
QFontMetricsF fm(m_font);
qreal y = fm.ascent();
for (const QString &line : m_lines) {
painter.drawText(QPointF(2, y), line);
y += fm.lineSpacing();
}
}
// === OllieGhostProvider ===
OllieGhostProvider::OllieGhostProvider(KTextEditor::View *view, OllieKatePlugin *plugin, QObject *parent)
: KTextEditor::InlineNoteProvider() // note: KDE's ctor takes no parent arg
, m_view(view)
, m_plugin(plugin)
, m_debounce(new QTimer(this))
{
// InlineNoteProvider inherits QObject but has no parent-accepting ctor
QObject::setParent(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);
connect(view, &QObject::destroyed, this, [this]() { m_view = nullptr; });
#if KTEXTEDITOR_VERSION_MAJOR >= 6
view->registerInlineNoteProvider(this);
#else
// Use dynamic_cast instead of qobject_cast: during view construction,
// qt_metacast() can access uninitialized KateViewConfig and crash.
// dynamic_cast only needs the vtable which is valid once the C++ ctor runs.
auto *noteIface = dynamic_cast<KTextEditor::InlineNoteInterface *>(view);
if (noteIface)
noteIface->registerInlineNoteProvider(this);
#endif
}
OllieGhostProvider::~OllieGhostProvider()
{
// m_overlay is parented to the view widget and may already be destroyed
// by Qt's child cleanup before we get here — just null it out.
m_overlay = nullptr;
if (m_view) {
#if KTEXTEDITOR_VERSION_MAJOR >= 6
m_view->unregisterInlineNoteProvider(this);
#else
auto *iface = dynamic_cast<KTextEditor::InlineNoteInterface *>(m_view);
if (iface)
iface->unregisterInlineNoteProvider(this);
#endif
}
}
// --- InlineNoteProvider interface ---
#if KTEXTEDITOR_VERSION_MAJOR >= 6
QList<int> OllieGhostProvider::inlineNotes(int line) const
#else
QVector<int> OllieGhostProvider::inlineNotes(int line) const
#endif
{
if (m_suggestionLines.isEmpty() || m_suggestionStartLine < 0)
return {};
// Only show inline note on the FIRST suggestion line
if (line != m_suggestionStartLine)
return {};
if (m_suggestionLines[0].isEmpty())
return {};
// Don't show ghost text if cursor was mid-line — the suggestion would
// visually overlap or conflict with existing text after the cursor.
int lineLen = m_view->document()->lineLength(line);
if (m_suggestionStartCol < lineLen)
return {};
return {lineLen};
}
QSize OllieGhostProvider::inlineNoteSize(const KTextEditor::InlineNote &note) const
{
Q_UNUSED(note);
if (m_suggestionLines.isEmpty())
return QSize(0, 0);
QFont font = note.font();
QFontMetricsF fm(font);
qreal width = fm.horizontalAdvance(m_suggestionLines[0]);
return QSize(qRound(width), note.lineHeight());
}
#if KTEXTEDITOR_VERSION_MAJOR >= 6
void OllieGhostProvider::paintInlineNote(const KTextEditor::InlineNote &note, QPainter &painter, Qt::LayoutDirection direction) const
{
Q_UNUSED(direction);
#else
void OllieGhostProvider::paintInlineNote(const KTextEditor::InlineNote &note, QPainter &painter) const
{
#endif
if (m_suggestionLines.isEmpty())
return;
QFont font = note.font();
painter.setFont(font);
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[0]);
}
// --- 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();
}
void OllieGhostProvider::cycle(int direction)
{
if (direction > 0) {
// Next: if we have a cached next, show it; otherwise request new
if (m_historyIndex + 1 < m_history.size()) {
m_historyIndex++;
showSuggestionFromHistory();
} else {
// Request a new completion (will append to history)
requestCompletion();
}
} else if (direction < 0) {
// Previous: show earlier suggestion if available
if (m_historyIndex > 0) {
m_historyIndex--;
showSuggestionFromHistory();
}
}
}
void OllieGhostProvider::setEnabled(bool enabled)
{
m_enabled = enabled;
if (!enabled) {
m_debounce->stop();
dismiss();
}
}
bool OllieGhostProvider::hasSuggestion() const
{
return !m_suggestionLines.isEmpty();
}
// --- Private slots ---
void OllieGhostProvider::onCursorPositionChanged()
{
// Don't react to cursor changes while completion popup is active
#if KTEXTEDITOR_VERSION_MAJOR >= 6
if (m_view->isCompletionActive())
return;
#else
auto *ccIface = dynamic_cast<KTextEditor::CodeCompletionInterface *>(m_view);
if (ccIface && ccIface->isCompletionActive())
return;
#endif
if (hasSuggestion()) {
KTextEditor::Cursor cur = m_view->cursorPosition();
if (cur != m_triggerCursor) {
clearSuggestion();
}
}
if (m_enabled)
m_debounce->start();
}
void OllieGhostProvider::onTextChanged()
{
if (hasSuggestion()) {
clearSuggestion();
}
if (m_enabled)
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();
QString cwd = QFileInfo(filePath).absolutePath();
m_triggerCursor = cursor;
m_requesting = true;
// Trim prefix/suffix to limits matching agent/complete.go
const int prefixMax = 12000;
const int suffixMax = 1000;
if (prefix.size() > prefixMax)
prefix = prefix.right(prefixMax);
if (suffix.size() > suffixMax)
suffix = suffix.left(suffixMax);
// Build the completion prompt
QString fileHint;
if (!filePath.isEmpty())
fileHint = " in " + filePath;
QString contextBlock;
if (!cwd.isEmpty())
contextBlock = "\nWorking directory: " + cwd;
QString prompt = QString(
"Implement the code at the cursor%2. The prefix ends at the point where new code is needed. Write the implementation — do not echo stubs, TODOs, or placeholder returns from the prefix. Output ONLY raw code. No reasoning, no shell commands, no explanations, no markdown fences, no backticks, no preamble. Your entire response must be valid code that can be inserted directly into the file.%1"
"<prefix>\n%3\n</prefix>\n<suffix>\n%4\n</suffix>"
).arg(contextBlock).arg(fileHint).arg(prefix).arg(suffix);
// Build JSON request for ollie-9p rdwr generate
QJsonObject request;
request["prompt"] = prompt;
QString backendName = m_plugin ? m_plugin->completionBackend() : QString();
QString modelName = m_plugin ? m_plugin->completionModel() : QString();
if (!backendName.isEmpty())
request["backend"] = backendName;
if (!modelName.isEmpty())
request["model"] = modelName;
QJsonDocument docJson(request);
QString jsonStr = QString::fromUtf8(docJson.toJson(QJsonDocument::Compact));
// Run ollie-9p rdwr generate
QProcess *process = new QProcess(this);
process->setProgram("ollie-9p");
process->setArguments({"rdwr", "generate"});
connect(process, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
this, [this, process](int exitCode, QProcess::ExitStatus exitStatus) {
process->deleteLater();
m_requesting = false;
if (exitStatus != QProcess::NormalExit || exitCode != 0) {
return;
}
QString result = QString::fromUtf8(process->readAllStandardOutput());
onCompletionReply(result);
});
process->start();
process->write(jsonStr.toUtf8());
process->closeWriteChannel();
}
void OllieGhostProvider::onCompletionReply(const QString &result)
{
if (result.trimmed().isEmpty())
return;
if (m_view->cursorPosition() != m_triggerCursor)
return;
// Strip completion noise (safety net — prompt asks for raw code only)
QString stripped = stripCompletionNoise(result);
// Strip prefix echo (model may repeat trailing prefix chars)
stripped = stripPrefixEcho(stripped);
if (stripped.isEmpty())
return;
QStringList lines = stripped.split(QStringLiteral("\n"));
// Append to history and advance index
m_history.append(lines);
m_historyIndex = m_history.size() - 1;
m_suggestionStartLine = m_triggerCursor.line();
m_suggestionStartCol = m_triggerCursor.column();
m_suggestionLines = lines;
// Notify Kate to repaint the first line (inline note)
emit inlineNotesChanged(m_suggestionStartLine);
// Show overlay for continuation lines (multi-line suggestions)
if (m_suggestionLines.size() > 1) {
showOverlay();
}
}
// --- Private helpers ---
void OllieGhostProvider::showOverlay()
{
// Get the editor's internal widget (viewport) to parent the overlay
QWidget *viewWidget = m_view->focusProxy();
if (!viewWidget)
viewWidget = qobject_cast<QWidget *>(m_view);
if (!viewWidget)
return;
if (!m_overlay) {
m_overlay = new GhostOverlay(viewWidget);
connect(m_overlay, &QObject::destroyed, this, [this]() { m_overlay = nullptr; });
}
// Gather continuation lines (skip first, it's the inline note)
QStringList contLines = m_suggestionLines.mid(1);
// Get font from the view's config
#if KTEXTEDITOR_VERSION_MAJOR >= 6
QFont font = m_view->configValue(QStringLiteral("font")).value<QFont>();
#else
QFont font;
auto *cfgIface = dynamic_cast<KTextEditor::ConfigInterface *>(m_view);
if (cfgIface)
font = cfgIface->configValue(QStringLiteral("font")).value<QFont>();
else
font = m_view->font();
#endif
m_overlay->setLines(contLines, font);
// Position the overlay below the cursor line
// Use cursorPositionCoordinates to get pixel position of cursor
QPoint cursorPos = m_view->cursorPositionCoordinates();
QFontMetricsF fm(font);
int lineHeight = qRound(fm.lineSpacing());
// Place overlay one line below the cursor
m_overlay->move(cursorPos.x(), cursorPos.y() + lineHeight);
}
void OllieGhostProvider::hideOverlay()
{
if (m_overlay) {
m_overlay->clear();
}
}
void OllieGhostProvider::showSuggestionFromHistory()
{
if (m_historyIndex < 0 || m_historyIndex >= m_history.size())
return;
// Clear visual state without resetting history
if (!m_suggestionLines.isEmpty()) {
emit inlineNotesChanged(m_suggestionStartLine);
hideOverlay();
}
m_suggestionStartLine = m_triggerCursor.line();
m_suggestionStartCol = m_triggerCursor.column();
m_suggestionLines = m_history[m_historyIndex];
emit inlineNotesChanged(m_suggestionStartLine);
if (m_suggestionLines.size() > 1) {
showOverlay();
}
}
void OllieGhostProvider::clearSuggestion()
{
if (m_suggestionLines.isEmpty())
return;
m_suggestionLines.clear();
m_suggestionStartLine = -1;
m_suggestionStartCol = -1;
m_history.clear();
m_historyIndex = -1;
emit inlineNotesChanged(m_triggerCursor.line());
hideOverlay();
}
QString OllieGhostProvider::gatherPrefix() const
{
auto *doc = m_view->document();
KTextEditor::Cursor cursor = m_view->cursorPosition();
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();
KTextEditor::Cursor endCursor(doc->lines() - 1, doc->lineLength(doc->lines() - 1));
KTextEditor::Range suffixRange(cursor, endCursor);
return doc->text(suffixRange);
}
// === Completion post-processing helpers (mirrors agent/complete.go) ===
static QString stripCompletionNoise(const QString &s)
{
QStringList lines = s.split('\n');
QStringList filtered;
for (const QString &line : lines) {
QString trimmed = line.trimmed();
if (trimmed.startsWith("```"))
continue;
if (trimmed == "<prefix>" || trimmed == "</prefix>" ||
trimmed == "<suffix>" || trimmed == "</suffix>")
continue;
if (trimmed.startsWith(":: "))
continue;
filtered.append(line);
}
return filtered.join('\n');
}
static QString stripPrefixEcho(const QString &prefix, const QString &result)
{
int tailMax = 200;
if (prefix.size() < tailMax)
tailMax = prefix.size();
for (int i = tailMax; i > 0; --i) {
QString tail = prefix.right(i);
if (result.startsWith(tail))
return result.mid(i);
}
return result;
}