505 lines
14 KiB
C++
505 lines
14 KiB
C++
#include "ollie_ghost.h"
|
|
#include "ollie_kate.h"
|
|
|
|
#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; });
|
|
|
|
view->registerInlineNoteProvider(this);
|
|
}
|
|
|
|
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) {
|
|
m_view->unregisterInlineNoteProvider(this);
|
|
}
|
|
}
|
|
|
|
// --- InlineNoteProvider interface ---
|
|
|
|
QList<int> OllieGhostProvider::inlineNotes(int line) const
|
|
{
|
|
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 ¬e) 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());
|
|
}
|
|
|
|
void OllieGhostProvider::paintInlineNote(const KTextEditor::InlineNote ¬e, QPainter &painter, Qt::LayoutDirection direction) const
|
|
{
|
|
Q_UNUSED(direction);
|
|
|
|
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 (m_view->isCompletionActive())
|
|
return;
|
|
|
|
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();
|
|
}
|
|
|
|
// === Completion post-processing helpers (mirrors agent/complete.go) ===
|
|
|
|
static QString stripCompletionNoise(const QString &s);
|
|
static QString stripPrefixEcho(const QString &result, const QString &prefix = QString());
|
|
|
|
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
|
|
QFont font = m_view->configValue(QStringLiteral("font")).value<QFont>();
|
|
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 &result, const QString &prefix)
|
|
{
|
|
if (prefix.isEmpty())
|
|
return 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;
|
|
} |