1408 lines
51 KiB
C++
1408 lines
51 KiB
C++
// TODO
|
||
#include "ollie_kate.h"
|
||
#include "ollie_ghost.h"
|
||
|
||
#ifdef HAVE_KATE_DIFF
|
||
#include "ktexteditor_utils.h"
|
||
#endif
|
||
|
||
#include <KPluginFactory>
|
||
#include <QKeyEvent>
|
||
#include <KTextEditor/Document>
|
||
#include <KTextEditor/Editor>
|
||
#include <ktexteditor_version.h>
|
||
#if KTEXTEDITOR_VERSION_MAJOR < 6
|
||
#include <KTextEditor/CodeCompletionInterface>
|
||
#include <KTextEditor/MovingInterface>
|
||
#endif
|
||
|
||
#include <QVBoxLayout>
|
||
#include <QHBoxLayout>
|
||
#include <QPushButton>
|
||
#include <QComboBox>
|
||
#include <QFont>
|
||
#include <QApplication>
|
||
#include <QMenu>
|
||
#include <QDir>
|
||
#include <QFileInfo>
|
||
#include <QInputDialog>
|
||
#include <QScrollBar>
|
||
#include <QTimer>
|
||
#include <QCheckBox>
|
||
#include <QSet>
|
||
#include <QFormLayout>
|
||
#include <QMainWindow>
|
||
#include <QStatusBar>
|
||
#include <QUrl>
|
||
#include <KSharedConfig>
|
||
#include <KConfigGroup>
|
||
|
||
K_PLUGIN_CLASS_WITH_JSON(OllieKatePlugin, "metadata.json")
|
||
|
||
static QString ninepBin()
|
||
{
|
||
QString bin = QStandardPaths::findExecutable("9p");
|
||
if (!bin.isEmpty()) return bin;
|
||
QString plan9 = qEnvironmentVariable("PLAN9");
|
||
if (!plan9.isEmpty() && QFileInfo::exists(plan9 + "/bin/9p"))
|
||
return plan9 + "/bin/9p";
|
||
return "9p";
|
||
}
|
||
|
||
// --- Plugin ---
|
||
|
||
#if QT_VERSION_MAJOR >= 6
|
||
OllieKatePlugin::OllieKatePlugin(QObject *parent, const KPluginMetaData &data, const QVariantList &args)
|
||
: KTextEditor::Plugin(parent)
|
||
{
|
||
Q_UNUSED(data);
|
||
Q_UNUSED(args);
|
||
loadConfig();
|
||
}
|
||
#else
|
||
OllieKatePlugin::OllieKatePlugin(QObject *parent, const QVariantList &args)
|
||
: KTextEditor::Plugin(parent)
|
||
{
|
||
Q_UNUSED(args);
|
||
loadConfig();
|
||
}
|
||
#endif
|
||
|
||
QObject *OllieKatePlugin::createView(KTextEditor::MainWindow *mainWindow)
|
||
{
|
||
return new OllieKateView(this, mainWindow);
|
||
}
|
||
|
||
int OllieKatePlugin::configPages() const
|
||
{
|
||
return 1;
|
||
}
|
||
|
||
KTextEditor::ConfigPage *OllieKatePlugin::configPage(int number, QWidget *parent)
|
||
{
|
||
if (number != 0) return nullptr;
|
||
|
||
// Inline config page
|
||
class OllieConfigPage : public KTextEditor::ConfigPage
|
||
{
|
||
public:
|
||
OllieConfigPage(OllieKatePlugin *plugin, QWidget *parent)
|
||
: KTextEditor::ConfigPage(parent), m_plugin(plugin)
|
||
{
|
||
auto *layout = new QFormLayout(this);
|
||
|
||
// Default session backend/model
|
||
m_defaultBackendEdit = new QLineEdit(this);
|
||
m_defaultBackendEdit->setPlaceholderText(QStringLiteral("(use OLLIE_BACKEND)"));
|
||
m_defaultModelEdit = new QLineEdit(this);
|
||
m_defaultModelEdit->setPlaceholderText(QStringLiteral("(use OLLIE_MODEL)"));
|
||
|
||
layout->addRow(QStringLiteral("Default backend:"), m_defaultBackendEdit);
|
||
layout->addRow(QStringLiteral("Default model:"), m_defaultModelEdit);
|
||
|
||
auto *defaultNote = new QLabel(
|
||
QStringLiteral("Override OLLIE_BACKEND / OLLIE_MODEL for chat sessions."),
|
||
this);
|
||
defaultNote->setWordWrap(true);
|
||
defaultNote->setStyleSheet(QStringLiteral("color: gray;"));
|
||
layout->addRow(defaultNote);
|
||
|
||
// Completion backend/model
|
||
m_backendEdit = new QLineEdit(this);
|
||
m_backendEdit->setPlaceholderText(QStringLiteral("(use OLLIE_COMPLETE_BACKEND)"));
|
||
m_modelEdit = new QLineEdit(this);
|
||
m_modelEdit->setPlaceholderText(QStringLiteral("(use OLLIE_COMPLETE_MODEL)"));
|
||
|
||
layout->addRow(QStringLiteral("Completion backend:"), m_backendEdit);
|
||
layout->addRow(QStringLiteral("Completion model:"), m_modelEdit);
|
||
|
||
auto *note = new QLabel(
|
||
QStringLiteral("Override OLLIE_COMPLETE_BACKEND / OLLIE_COMPLETE_MODEL for ghost text."),
|
||
this);
|
||
note->setWordWrap(true);
|
||
note->setStyleSheet(QStringLiteral("color: gray;"));
|
||
layout->addRow(note);
|
||
|
||
m_enabledCheck = new QCheckBox(QStringLiteral("Completion enabled on startup"), this);
|
||
layout->addRow(m_enabledCheck);
|
||
|
||
// Diff viewer mode
|
||
auto *diffSep = new QLabel(QString(), this);
|
||
layout->addRow(diffSep);
|
||
|
||
m_diffModeCombo = new QComboBox(this);
|
||
m_diffModeCombo->addItem(QStringLiteral("Unified"), QStringLiteral("unified"));
|
||
m_diffModeCombo->addItem(QStringLiteral("Side by Side"), QStringLiteral("side-by-side"));
|
||
m_diffModeCombo->addItem(QStringLiteral("Raw"), QStringLiteral("raw"));
|
||
layout->addRow(QStringLiteral("Diff view style:"), m_diffModeCombo);
|
||
|
||
auto *diffNote = new QLabel(
|
||
QStringLiteral("How diffs are displayed in Kate's diff viewer."),
|
||
this);
|
||
diffNote->setWordWrap(true);
|
||
diffNote->setStyleSheet(QStringLiteral("color: gray;"));
|
||
layout->addRow(diffNote);
|
||
|
||
reset();
|
||
|
||
connect(m_enabledCheck, &QCheckBox::toggled, this, &ConfigPage::changed);
|
||
connect(m_backendEdit, &QLineEdit::textChanged, this, &ConfigPage::changed);
|
||
connect(m_modelEdit, &QLineEdit::textChanged, this, &ConfigPage::changed);
|
||
connect(m_defaultBackendEdit, &QLineEdit::textChanged, this, &ConfigPage::changed);
|
||
connect(m_defaultModelEdit, &QLineEdit::textChanged, this, &ConfigPage::changed);
|
||
connect(m_diffModeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &ConfigPage::changed);
|
||
}
|
||
|
||
QString name() const override { return QStringLiteral("Ollie"); }
|
||
QString fullName() const override { return QStringLiteral("Ollie Completion Settings"); }
|
||
QIcon icon() const override { return QIcon::fromTheme(QStringLiteral("system-run")); }
|
||
|
||
void apply() override {
|
||
m_plugin->setDefaultBackend(m_defaultBackendEdit->text().trimmed());
|
||
m_plugin->setDefaultModel(m_defaultModelEdit->text().trimmed());
|
||
m_plugin->setCompletionBackend(m_backendEdit->text().trimmed());
|
||
m_plugin->setCompletionModel(m_modelEdit->text().trimmed());
|
||
m_plugin->setCompletionEnabled(m_enabledCheck->isChecked());
|
||
m_plugin->setDiffMode(m_diffModeCombo->currentData().toString());
|
||
}
|
||
void reset() override {
|
||
m_defaultBackendEdit->setText(m_plugin->defaultBackend());
|
||
m_defaultModelEdit->setText(m_plugin->defaultModel());
|
||
m_backendEdit->setText(m_plugin->completionBackend());
|
||
m_modelEdit->setText(m_plugin->completionModel());
|
||
m_enabledCheck->setChecked(m_plugin->completionEnabled());
|
||
int idx = m_diffModeCombo->findData(m_plugin->diffMode());
|
||
m_diffModeCombo->setCurrentIndex(idx >= 0 ? idx : 0);
|
||
}
|
||
void defaults() override {
|
||
m_defaultBackendEdit->clear();
|
||
m_defaultModelEdit->clear();
|
||
m_backendEdit->clear();
|
||
m_modelEdit->clear();
|
||
m_enabledCheck->setChecked(true);
|
||
m_diffModeCombo->setCurrentIndex(0);
|
||
}
|
||
|
||
private:
|
||
OllieKatePlugin *m_plugin;
|
||
QLineEdit *m_defaultBackendEdit;
|
||
QLineEdit *m_defaultModelEdit;
|
||
QLineEdit *m_backendEdit;
|
||
QLineEdit *m_modelEdit;
|
||
QCheckBox *m_enabledCheck;
|
||
QComboBox *m_diffModeCombo;
|
||
};
|
||
|
||
return new OllieConfigPage(this, parent);
|
||
}
|
||
|
||
QString OllieKatePlugin::completionBackend() const { return m_completionBackend; }
|
||
QString OllieKatePlugin::completionModel() const { return m_completionModel; }
|
||
|
||
void OllieKatePlugin::setCompletionBackend(const QString &backend)
|
||
{
|
||
m_completionBackend = backend;
|
||
saveConfig();
|
||
}
|
||
|
||
void OllieKatePlugin::setCompletionModel(const QString &model)
|
||
{
|
||
m_completionModel = model;
|
||
saveConfig();
|
||
}
|
||
|
||
bool OllieKatePlugin::completionEnabled() const { return m_completionEnabled; }
|
||
|
||
void OllieKatePlugin::setCompletionEnabled(bool enabled)
|
||
{
|
||
m_completionEnabled = enabled;
|
||
saveConfig();
|
||
}
|
||
|
||
QString OllieKatePlugin::defaultBackend() const { return m_defaultBackend; }
|
||
QString OllieKatePlugin::defaultModel() const { return m_defaultModel; }
|
||
QString OllieKatePlugin::diffMode() const { return m_diffMode; }
|
||
|
||
void OllieKatePlugin::setDefaultBackend(const QString &backend)
|
||
{
|
||
m_defaultBackend = backend;
|
||
saveConfig();
|
||
}
|
||
|
||
void OllieKatePlugin::setDefaultModel(const QString &model)
|
||
{
|
||
m_defaultModel = model;
|
||
saveConfig();
|
||
}
|
||
|
||
void OllieKatePlugin::setDiffMode(const QString &mode)
|
||
{
|
||
m_diffMode = mode;
|
||
saveConfig();
|
||
}
|
||
|
||
void OllieKatePlugin::loadConfig()
|
||
{
|
||
KConfigGroup grp(KSharedConfig::openConfig(), QStringLiteral("Ollie"));
|
||
m_completionBackend = grp.readEntry("CompletionBackend", QString());
|
||
m_completionModel = grp.readEntry("CompletionModel", QString());
|
||
m_completionEnabled = grp.readEntry("CompletionEnabled", true);
|
||
m_defaultBackend = grp.readEntry("DefaultBackend", QString());
|
||
m_defaultModel = grp.readEntry("DefaultModel", QString());
|
||
m_diffMode = grp.readEntry("DiffMode", QStringLiteral("unified"));
|
||
}
|
||
|
||
void OllieKatePlugin::saveConfig()
|
||
{
|
||
KConfigGroup grp(KSharedConfig::openConfig(), QStringLiteral("Ollie"));
|
||
grp.writeEntry("CompletionBackend", m_completionBackend);
|
||
grp.writeEntry("CompletionModel", m_completionModel);
|
||
grp.writeEntry("CompletionEnabled", m_completionEnabled);
|
||
grp.writeEntry("DefaultBackend", m_defaultBackend);
|
||
grp.writeEntry("DefaultModel", m_defaultModel);
|
||
grp.writeEntry("DiffMode", m_diffMode);
|
||
grp.sync();
|
||
}
|
||
|
||
// --- View ---
|
||
|
||
OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *mainWindow)
|
||
: QObject(mainWindow)
|
||
, m_plugin(plugin)
|
||
, m_mainWindow(mainWindow)
|
||
, m_renderer(new ChatRenderer())
|
||
{
|
||
m_renderer->setDiffStorage(&m_diffBlocks);
|
||
m_renderer->setApplyStorage(&m_applyBlocks);
|
||
// 9P: no interface object needed — we use run9p/write9p helpers
|
||
|
||
// Create tool view (side panel)
|
||
m_toolView = m_mainWindow->createToolView(
|
||
plugin,
|
||
"ollie_panel",
|
||
KTextEditor::MainWindow::Right,
|
||
QIcon::fromTheme("system-run"),
|
||
"Ollie");
|
||
|
||
auto *container = new QWidget(m_toolView);
|
||
auto *layout = new QVBoxLayout(container);
|
||
layout->setContentsMargins(2, 2, 2, 2);
|
||
layout->setSpacing(2);
|
||
|
||
// Session indicator + kill button
|
||
auto *headerLayout = new QHBoxLayout();
|
||
headerLayout->setContentsMargins(0, 0, 0, 0);
|
||
m_sessionLabel = new QLabel("No session", container);
|
||
m_sessionLabel->setStyleSheet(QStringLiteral("color: gray; padding: 2px;"));
|
||
m_sessionLabel->setFont(QApplication::font());
|
||
headerLayout->addWidget(m_sessionLabel);
|
||
headerLayout->addStretch();
|
||
|
||
auto *killBtn = new QPushButton("×", container);
|
||
killBtn->setFixedSize(20, 20);
|
||
killBtn->setToolTip("Kill session");
|
||
killBtn->setFlat(true);
|
||
connect(killBtn, &QPushButton::clicked, this, [this]() {
|
||
if (m_sessionId.isEmpty()) return;
|
||
write9p(agentPath() + "/ctl", "kill");
|
||
});
|
||
headerLayout->addWidget(killBtn);
|
||
layout->addLayout(headerLayout);
|
||
|
||
// CWD label
|
||
m_cwdLabel = new QLabel("", container);
|
||
QFont smallFont = QApplication::font();
|
||
smallFont.setPointSizeF(smallFont.pointSizeF() * 0.85);
|
||
m_cwdLabel->setStyleSheet(QStringLiteral("color: gray; padding: 0px 2px;"));
|
||
m_cwdLabel->setFont(smallFont);
|
||
m_cwdLabel->setWordWrap(true);
|
||
layout->addWidget(m_cwdLabel);
|
||
|
||
// Completion status indicator (in the main window status bar)
|
||
bool startEnabled = m_plugin->completionEnabled();
|
||
if (auto *win = qobject_cast<QMainWindow *>(m_mainWindow->window())) {
|
||
// Reuse existing widget if another view already created it
|
||
m_completionStatus = win->statusBar()->findChild<QLabel *>(QStringLiteral("ollie_completion_status"));
|
||
if (!m_completionStatus) {
|
||
m_completionStatus = new QLabel();
|
||
m_completionStatus->setObjectName(QStringLiteral("ollie_completion_status"));
|
||
m_completionStatus->setFont(smallFont);
|
||
win->statusBar()->addPermanentWidget(m_completionStatus);
|
||
}
|
||
m_completionStatus->setText(startEnabled
|
||
? QStringLiteral("✦ completion on")
|
||
: QStringLiteral("✦ completion off"));
|
||
m_completionStatus->setStyleSheet(startEnabled
|
||
? QStringLiteral("color: green; padding: 0px 4px;")
|
||
: QStringLiteral("color: gray; padding: 0px 4px;"));
|
||
} else {
|
||
m_completionStatus = nullptr;
|
||
}
|
||
|
||
// Button bar
|
||
auto *buttonBar = new QHBoxLayout();
|
||
|
||
auto *newBtn = new QPushButton("New", container);
|
||
newBtn->setToolTip("New session in current file's directory");
|
||
connect(newBtn, &QPushButton::clicked, this, &OllieKateView::newSession);
|
||
buttonBar->addWidget(newBtn);
|
||
|
||
auto *pickBtn = new QPushButton("Pick", container);
|
||
pickBtn->setToolTip("Switch to an existing session matching the project directory");
|
||
connect(pickBtn, &QPushButton::clicked, this, &OllieKateView::pickSession);
|
||
buttonBar->addWidget(pickBtn);
|
||
|
||
auto *renameBtn = new QPushButton("Rename", container);
|
||
renameBtn->setToolTip("Rename the current session");
|
||
connect(renameBtn, &QPushButton::clicked, this, &OllieKateView::renameSession);
|
||
buttonBar->addWidget(renameBtn);
|
||
|
||
|
||
buttonBar->addStretch();
|
||
layout->addLayout(buttonBar);
|
||
|
||
// Chat view
|
||
m_chatView = new QTextBrowser(container);
|
||
m_chatView->setReadOnly(true);
|
||
m_chatView->setOpenLinks(false);
|
||
m_chatView->setOpenExternalLinks(false);
|
||
QFont chatFont(QApplication::font());
|
||
chatFont.setPointSize(qMax(12, QApplication::font().pointSize() + 2));
|
||
m_chatView->setFont(chatFont);
|
||
m_chatView->setPlaceholderText("No active session.\nClick 'New' or use context actions.");
|
||
connect(m_chatView, &QTextBrowser::anchorClicked, this, &OllieKateView::onAnchorClicked);
|
||
layout->addWidget(m_chatView);
|
||
|
||
// Scroll-to-bottom button (floats over chat view)
|
||
m_scrollBottomBtn = new QPushButton("↓", container);
|
||
m_scrollBottomBtn->setFixedSize(28, 28);
|
||
m_scrollBottomBtn->setToolTip("Scroll to bottom");
|
||
m_scrollBottomBtn->hide();
|
||
connect(m_scrollBottomBtn, &QPushButton::clicked, this, [this]() {
|
||
auto *sb = m_chatView->verticalScrollBar();
|
||
sb->setValue(sb->maximum());
|
||
m_scrollBottomBtn->hide();
|
||
});
|
||
// Position over bottom-right of chat view, updated on resize
|
||
auto repositionBtn = [this]() {
|
||
if (!m_chatView) return;
|
||
QPoint pos = m_chatView->geometry().bottomRight() - QPoint(36, 36);
|
||
m_scrollBottomBtn->move(pos);
|
||
};
|
||
m_chatView->installEventFilter(this); // handle resize in eventFilter
|
||
repositionBtn();
|
||
|
||
// Also show/hide on manual scroll + lazy-load older messages
|
||
connect(m_chatView->verticalScrollBar(), &QScrollBar::valueChanged, this, [this](int value) {
|
||
auto *sb = m_chatView->verticalScrollBar();
|
||
if (value >= sb->maximum() - 4) {
|
||
m_scrollBottomBtn->hide();
|
||
} else if (!m_chatView->toPlainText().isEmpty()) {
|
||
m_scrollBottomBtn->show();
|
||
}
|
||
|
||
// Lazy-load: when scrolled near the top, prepend older blocks
|
||
if (value <= 50 && m_renderer->hasOlderBlocks() && !m_loadingOlder) {
|
||
m_loadingOlder = true;
|
||
m_renderer->prependOlder(m_chatView, 20);
|
||
m_loadingOlder = false;
|
||
}
|
||
});
|
||
|
||
// Prompt input
|
||
auto *promptLayout = new QHBoxLayout();
|
||
m_promptInput = new QPlainTextEdit(container);
|
||
m_promptInput->setPlaceholderText("Message...");
|
||
m_promptInput->setMaximumHeight(80);
|
||
m_promptInput->setTabChangesFocus(true);
|
||
m_promptInput->installEventFilter(this);
|
||
connect(m_promptInput, &QPlainTextEdit::textChanged, this, &OllieKateView::processEmojis);
|
||
promptLayout->addWidget(m_promptInput);
|
||
|
||
auto *sendBtn = new QPushButton("⏎", container);
|
||
sendBtn->setFixedWidth(30);
|
||
connect(sendBtn, &QPushButton::clicked, this, &OllieKateView::submitPrompt);
|
||
promptLayout->addWidget(sendBtn);
|
||
|
||
m_stopBtn = new QPushButton("■", container);
|
||
m_stopBtn->setToolTip("Stop (interrupt the current agent turn)");
|
||
m_stopBtn->setFixedWidth(30);
|
||
connect(m_stopBtn, &QPushButton::clicked, this, [this]() {
|
||
if (!m_sessionId.isEmpty()) {
|
||
write9p(agentPath() + "/ctl", "stop");
|
||
}
|
||
});
|
||
promptLayout->addWidget(m_stopBtn);
|
||
|
||
layout->addLayout(promptLayout);
|
||
|
||
// Start 9P streaming processes
|
||
startChatStream();
|
||
startStateStream();
|
||
|
||
// 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 *view) {
|
||
setupGhostProvider();
|
||
if (view) setupContextMenu(view);
|
||
});
|
||
|
||
// Set up context menu on initial active view
|
||
if (auto *view = mainWindow->activeView())
|
||
setupContextMenu(view);
|
||
|
||
// Register "Review Diff" action with Git++ plugin (kate-worktree)
|
||
setupGitPlusPlusDiffAction();
|
||
}
|
||
|
||
OllieKateView::~OllieKateView()
|
||
{
|
||
clearAnnotations();
|
||
delete m_renderer;
|
||
delete m_ghost;
|
||
delete m_toolView;
|
||
}
|
||
|
||
// --- D-Bus signal handlers ---
|
||
|
||
void OllieKateView::onChatUpdated(const QString &session_id, qlonglong offset, const QString &new_text)
|
||
{
|
||
if (session_id != m_sessionId) return;
|
||
|
||
if (offset == m_chatOffset) {
|
||
m_rawChat += new_text;
|
||
m_chatOffset += new_text.size();
|
||
m_renderer->render(m_chatView, m_rawChat);
|
||
annotateFileReferences(m_rawChat);
|
||
} else {
|
||
// Offset mismatch — refetch
|
||
// Refetch via 9P
|
||
QByteArray chatData = run9p({"read", agentPath() + "/log"});
|
||
if (!chatData.isEmpty()) {
|
||
m_rawChat = QString::fromUtf8(chatData);
|
||
m_chatOffset = chatData.size();
|
||
m_renderer->render(m_chatView, m_rawChat);
|
||
annotateFileReferences(m_rawChat);
|
||
m_scrollBottomBtn->hide();
|
||
}
|
||
}
|
||
}
|
||
|
||
void OllieKateView::onStateChanged(const QString &session_id, const QString &new_state)
|
||
{
|
||
if (session_id != m_sessionId) return;
|
||
QString dot;
|
||
if (new_state == "idle") dot = "○ ";
|
||
else if (new_state.startsWith("calling")) dot = "⚡ ";
|
||
else dot = "● ";
|
||
m_sessionLabel->setText(dot + m_sessionId + " [" + new_state + "]");
|
||
}
|
||
|
||
void OllieKateView::onSessionCreated(const QString &session_id)
|
||
{
|
||
// If we have no session, adopt this one
|
||
if (m_sessionId.isEmpty()) {
|
||
m_sessionId = session_id;
|
||
m_chatView->clear();
|
||
m_chatOffset = 0;
|
||
}
|
||
}
|
||
|
||
void OllieKateView::onSessionKilled(const QString &session_id)
|
||
{
|
||
if (session_id == m_sessionId) {
|
||
m_sessionId.clear();
|
||
m_chatOffset = 0;
|
||
m_rawChat.clear();
|
||
m_chatView->clear();
|
||
m_sessionLabel->setText("No session");
|
||
m_cwdLabel->setText("");
|
||
// Defer session switch to let Qt finish processing the D-Bus signal.
|
||
// Calling render() synchronously here can crash because the QTextDocument
|
||
// is still tearing down resources from the clear() above.
|
||
QTimer::singleShot(0, this, &OllieKateView::refreshSessions);
|
||
}
|
||
}
|
||
|
||
// --- Actions ---
|
||
|
||
void OllieKateView::submitPrompt()
|
||
{
|
||
QString text = m_promptInput->toPlainText().trimmed();
|
||
if (text.isEmpty()) return;
|
||
|
||
ensureSession();
|
||
if (m_sessionId.isEmpty()) return;
|
||
|
||
write9p(agentPath() + "/prompt", text.toUtf8());
|
||
m_promptInput->clear();
|
||
}
|
||
|
||
void OllieKateView::askAboutFile()
|
||
{
|
||
auto *view = m_mainWindow->activeView();
|
||
if (!view || !view->document()) return;
|
||
|
||
QString filePath = view->document()->url().toLocalFile();
|
||
if (filePath.isEmpty()) return;
|
||
|
||
ensureSession();
|
||
if (m_sessionId.isEmpty()) return;
|
||
|
||
QString prompt = "Describe and explain this file: " + filePath;
|
||
write9p(agentPath() + "/prompt", prompt.toUtf8());
|
||
}
|
||
|
||
void OllieKateView::explainSelection()
|
||
{
|
||
auto *view = m_mainWindow->activeView();
|
||
if (!view) return;
|
||
|
||
QString selection = view->selectionText();
|
||
if (selection.isEmpty()) {
|
||
// No selection — explain current line
|
||
auto cursor = view->cursorPosition();
|
||
selection = view->document()->line(cursor.line());
|
||
}
|
||
if (selection.isEmpty()) return;
|
||
|
||
ensureSession();
|
||
if (m_sessionId.isEmpty()) return;
|
||
|
||
QString filePath = view->document()->url().toLocalFile();
|
||
QString context = filePath.isEmpty() ? "" : " (from " + filePath + ")";
|
||
QString prompt = "Explain this code" + context + ":\n\n```\n" + selection + "\n```";
|
||
write9p(agentPath() + "/prompt", prompt.toUtf8());
|
||
}
|
||
|
||
void OllieKateView::newSession()
|
||
{
|
||
QString cwd = projectBaseDir();
|
||
if (cwd.isEmpty()) {
|
||
cwd = QDir::homePath();
|
||
}
|
||
|
||
QString backend = m_plugin->defaultBackend();
|
||
QString model = m_plugin->defaultModel();
|
||
QString newCfg = "cwd=" + cwd + "\nbackend=" + backend + "\nmodel=" + model + "\n";
|
||
write9p("session/new", newCfg.toUtf8());
|
||
QByteArray idxOut = run9p({"read", "session/idx"});
|
||
QString lastId = QString::fromUtf8(idxOut).trimmed().split(QChar('\n')).last().split(QChar('\t')).value(0);
|
||
QString reply_value = lastId;
|
||
if (!lastId.isEmpty()) {
|
||
m_sessionId = lastId;
|
||
m_rawChat.clear();
|
||
m_chatView->clear();
|
||
m_chatOffset = 0;
|
||
m_sessionLabel->setText("○ " + m_sessionId + " [idle]");
|
||
m_cwdLabel->setText(cwd);
|
||
} else {
|
||
m_chatView->setHtml("<p style='color:#e53935;'>Error creating session</p>");
|
||
}
|
||
}
|
||
|
||
void OllieKateView::pickSession()
|
||
{
|
||
QString baseDir = projectBaseDir();
|
||
if (baseDir.isEmpty()) {
|
||
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>Cannot determine project directory.</p>"));
|
||
return;
|
||
}
|
||
|
||
QStringList reply;
|
||
{
|
||
QByteArray out = run9p({"read", "session/idx"});
|
||
QString raw = QString::fromUtf8(out).trimmed();
|
||
if (!raw.isEmpty()) reply = raw.split(QChar('\n'));
|
||
}
|
||
if (reply.isEmpty()) {
|
||
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No active sessions found.</p>"));
|
||
return;
|
||
}
|
||
|
||
QStringList entries = reply;
|
||
entries.sort(Qt::CaseInsensitive);
|
||
|
||
// Find first session whose cwd matches the project base dir
|
||
for (const QString &entry : entries) {
|
||
QStringList parts = entry.split('\t');
|
||
if (parts.isEmpty()) continue;
|
||
QString id = parts[0];
|
||
|
||
// Get config for this session via 9P
|
||
QByteArray agentLs = run9p({"ls", "session/" + id + "/agent"});
|
||
QString aid = QString::fromUtf8(agentLs).trimmed().split(QChar('\n')).value(0, "0");
|
||
QString cfgStr = QString::fromUtf8(run9p({"read", "session/" + id + "/agent/" + aid + "/cfg"}));
|
||
if (cfgStr.isEmpty()) continue;
|
||
for (const QString &line : cfgStr.split(QChar('\n'))) {
|
||
if (line.startsWith("cwd=")) {
|
||
QString cwd = line.mid(4);
|
||
if (cwd == baseDir) {
|
||
switchToSession(id);
|
||
return;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No session found with cwd: ") + baseDir.toHtmlEscaped() + QStringLiteral("</p>"));
|
||
}
|
||
|
||
void OllieKateView::renameSession()
|
||
{
|
||
if (m_sessionId.isEmpty()) {
|
||
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No active session to rename.</p>"));
|
||
return;
|
||
}
|
||
|
||
bool ok;
|
||
QString newName = QInputDialog::getText(
|
||
m_toolView, "Rename Session",
|
||
"New name for session " + m_sessionId + ":",
|
||
QLineEdit::Normal, m_sessionId, &ok);
|
||
|
||
if (!ok || newName.trimmed().isEmpty()) return;
|
||
newName = newName.trimmed();
|
||
|
||
write9p("session/" + m_sessionId + "/ctl", ("rn " + newName).toUtf8());
|
||
{
|
||
m_sessionId = newName;
|
||
QString stateVal = QString::fromUtf8(run9p({"read", agentPath() + "/state"})).trimmed();
|
||
QString dot = (stateVal == "idle") ? "○ " : (stateVal.startsWith("calling") ? "⚡ " : "● ");
|
||
m_sessionLabel->setText(dot + m_sessionId + " [" + stateVal + "]");
|
||
}
|
||
}
|
||
|
||
// --- Helpers ---
|
||
|
||
QString OllieKateView::projectBaseDir()
|
||
{
|
||
// Try Kate's project plugin first
|
||
QObject *projectView = m_mainWindow->pluginView("kateprojectplugin");
|
||
if (projectView) {
|
||
QString dir = projectView->property("projectBaseDir").toString();
|
||
if (!dir.isEmpty()) return dir;
|
||
}
|
||
|
||
// Fall back to active file's directory
|
||
auto *view = m_mainWindow->activeView();
|
||
if (view && view->document() && !view->document()->url().toLocalFile().isEmpty()) {
|
||
return QFileInfo(view->document()->url().toLocalFile()).absolutePath();
|
||
}
|
||
|
||
return QString();
|
||
}
|
||
|
||
void OllieKateView::switchToSession(const QString &sessionId)
|
||
{
|
||
stopStreams();
|
||
m_sessionId = sessionId;
|
||
m_rawChat.clear();
|
||
m_chatView->clear();
|
||
m_chatOffset = 0;
|
||
|
||
// Fetch state
|
||
QString stateVal = QString::fromUtf8(run9p({"read", agentPath() + "/state"})).trimmed();
|
||
QString state = stateVal.isEmpty() ? "idle" : stateVal;
|
||
QString dot = (state == "idle") ? "○ " : (state.startsWith("calling") ? "⚡ " : "● ");
|
||
m_sessionLabel->setText(dot + m_sessionId + " [" + state + "]");
|
||
|
||
// Fetch cwd
|
||
QString cfgVal = QString::fromUtf8(run9p({"read", agentPath() + "/cfg"})).trimmed();
|
||
if (!cfgVal.isEmpty()) {
|
||
for (const QString &line : cfgVal.split(QChar('\n'))) {
|
||
if (line.startsWith("cwd=")) {
|
||
m_cwdLabel->setText(line.mid(4));
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
// Fetch existing chat via 9P
|
||
QByteArray chatData = run9p({"read", agentPath() + "/log"});
|
||
if (!chatData.isEmpty()) {
|
||
m_rawChat = QString::fromUtf8(chatData);
|
||
m_chatOffset = chatData.size();
|
||
m_renderer->render(m_chatView, m_rawChat);
|
||
m_scrollBottomBtn->hide();
|
||
}
|
||
|
||
// Start streaming for new data
|
||
startChatStream();
|
||
startStateStream();
|
||
}
|
||
|
||
void OllieKateView::ensureSession()
|
||
{
|
||
if (!m_sessionId.isEmpty()) return;
|
||
// 9P is always reachable — skip daemon check
|
||
newSession();
|
||
newSession();
|
||
}
|
||
|
||
QString OllieKateView::activeSessionId()
|
||
{
|
||
return m_sessionId;
|
||
}
|
||
|
||
void OllieKateView::processEmojis()
|
||
{
|
||
static const QHash<QString, QString> quickEmojis = {
|
||
{";)", QStringLiteral("\U0001F609")}, {":)", QStringLiteral("\U0001F642")},
|
||
{":D", QStringLiteral("\U0001F603")}, {":(", QStringLiteral("\U0001F61E")},
|
||
{":P", QStringLiteral("\U0001F61B")}, {":p", QStringLiteral("\U0001F61B")},
|
||
{"<3", QStringLiteral("\u2764\uFE0F")}, {":O", QStringLiteral("\U0001F62E")},
|
||
{":o", QStringLiteral("\U0001F62E")}, {"B)", QStringLiteral("\U0001F60E")},
|
||
{":*", QStringLiteral("\U0001F618")}, {"XD", QStringLiteral("\U0001F606")},
|
||
{"xD", QStringLiteral("\U0001F606")}, {":|", QStringLiteral("\U0001F610")},
|
||
{">:(", QStringLiteral("\U0001F620")}, {"'(", QStringLiteral("\U0001F622")},
|
||
};
|
||
|
||
static const QHash<QString, QString> shortcodes = {
|
||
{"thumbsup", QStringLiteral("\U0001F44D")}, {"thumbsdown", QStringLiteral("\U0001F44E")},
|
||
{"fire", QStringLiteral("\U0001F525")}, {"rocket", QStringLiteral("\U0001F680")},
|
||
{"check", QStringLiteral("\u2705")}, {"x", QStringLiteral("\u274C")},
|
||
{"eyes", QStringLiteral("\U0001F440")}, {"thinking", QStringLiteral("\U0001F914")},
|
||
{"shrug", QStringLiteral("\U0001F937")}, {"wave", QStringLiteral("\U0001F44B")},
|
||
{"clap", QStringLiteral("\U0001F44F")}, {"pray", QStringLiteral("\U0001F64F")},
|
||
{"heart", QStringLiteral("\u2764\uFE0F")}, {"broken_heart", QStringLiteral("\U0001F494")},
|
||
{"star", QStringLiteral("\u2B50")}, {"sparkles", QStringLiteral("\u2728")},
|
||
{"zap", QStringLiteral("\u26A1")}, {"100", QStringLiteral("\U0001F4AF")},
|
||
{"tada", QStringLiteral("\U0001F389")}, {"party", QStringLiteral("\U0001F389")},
|
||
{"beer", QStringLiteral("\U0001F37A")}, {"beers", QStringLiteral("\U0001F37B")},
|
||
{"coffee", QStringLiteral("\u2615")}, {"wine", QStringLiteral("\U0001F377")},
|
||
{"pizza", QStringLiteral("\U0001F355")}, {"taco", QStringLiteral("\U0001F32E")},
|
||
{"cookie", QStringLiteral("\U0001F36A")}, {"cake", QStringLiteral("\U0001F382")},
|
||
{"sun", QStringLiteral("\u2600\uFE0F")}, {"moon", QStringLiteral("\U0001F319")},
|
||
{"rainbow", QStringLiteral("\U0001F308")}, {"ocean", QStringLiteral("\U0001F30A")},
|
||
{"dog", QStringLiteral("\U0001F436")}, {"cat", QStringLiteral("\U0001F431")},
|
||
{"bug", QStringLiteral("\U0001F41B")}, {"snake", QStringLiteral("\U0001F40D")},
|
||
{"unicorn", QStringLiteral("\U0001F984")}, {"skull", QStringLiteral("\U0001F480")},
|
||
{"ghost", QStringLiteral("\U0001F47B")}, {"robot", QStringLiteral("\U0001F916")},
|
||
{"poop", QStringLiteral("\U0001F4A9")}, {"brain", QStringLiteral("\U0001F9E0")},
|
||
{"bulb", QStringLiteral("\U0001F4A1")}, {"gear", QStringLiteral("\u2699\uFE0F")},
|
||
{"lock", QStringLiteral("\U0001F512")}, {"key", QStringLiteral("\U0001F511")},
|
||
{"warning", QStringLiteral("\u26A0\uFE0F")}, {"no_entry", QStringLiteral("\u26D4")},
|
||
{"muscle", QStringLiteral("\U0001F4AA")}, {"ok_hand", QStringLiteral("\U0001F44C")},
|
||
{"fist", QStringLiteral("\u270A")}, {"v", QStringLiteral("\u270C\uFE0F")},
|
||
{"metal", QStringLiteral("\U0001F918")}, {"middle_finger", QStringLiteral("\U0001F595")},
|
||
{"smile", QStringLiteral("\U0001F604")}, {"joy", QStringLiteral("\U0001F602")},
|
||
{"rofl", QStringLiteral("\U0001F923")}, {"wink", QStringLiteral("\U0001F609")},
|
||
{"blush", QStringLiteral("\U0001F60A")}, {"heart_eyes", QStringLiteral("\U0001F60D")},
|
||
{"cool", QStringLiteral("\U0001F60E")}, {"angry", QStringLiteral("\U0001F620")},
|
||
{"cry", QStringLiteral("\U0001F622")}, {"sob", QStringLiteral("\U0001F62D")},
|
||
{"scream", QStringLiteral("\U0001F631")}, {"facepalm", QStringLiteral("\U0001F926")},
|
||
{"gift", QStringLiteral("\U0001F381")}, {"balloon", QStringLiteral("\U0001F388")},
|
||
{"trophy", QStringLiteral("\U0001F3C6")}, {"crown", QStringLiteral("\U0001F451")},
|
||
{"gem", QStringLiteral("\U0001F48E")}, {"money", QStringLiteral("\U0001F4B0")},
|
||
{"computer", QStringLiteral("\U0001F4BB")}, {"phone", QStringLiteral("\U0001F4F1")},
|
||
{"earth", QStringLiteral("\U0001F30D")}, {"airplane", QStringLiteral("\u2708\uFE0F")},
|
||
};
|
||
|
||
static bool processing = false;
|
||
if (processing) return;
|
||
processing = true;
|
||
|
||
QTextCursor cursor = m_promptInput->textCursor();
|
||
int pos = cursor.position();
|
||
QString text = m_promptInput->toPlainText();
|
||
|
||
// Quick emoticons (check last 2-4 chars before cursor)
|
||
for (int len = 2; len <= qMin(4, pos); ++len) {
|
||
QString candidate = text.mid(pos - len, len);
|
||
auto it = quickEmojis.find(candidate);
|
||
if (it != quickEmojis.end()) {
|
||
cursor.setPosition(pos - len);
|
||
cursor.setPosition(pos, QTextCursor::KeepAnchor);
|
||
cursor.insertText(it.value());
|
||
processing = false;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// :shortcode: (find closing : at cursor, opening : before it)
|
||
if (pos >= 3 && text[pos - 1] == ':') {
|
||
int start = text.lastIndexOf(':', pos - 2);
|
||
if (start >= 0 && start < pos - 2) {
|
||
QString code = text.mid(start + 1, pos - start - 2);
|
||
auto it = shortcodes.find(code);
|
||
if (it != shortcodes.end()) {
|
||
cursor.setPosition(start);
|
||
cursor.setPosition(pos, QTextCursor::KeepAnchor);
|
||
cursor.insertText(it.value());
|
||
}
|
||
}
|
||
}
|
||
|
||
processing = false;
|
||
}
|
||
|
||
void OllieKateView::onAnchorClicked(const QUrl &url)
|
||
{
|
||
if (url.scheme() == QStringLiteral("ollie-diff")) {
|
||
bool ok;
|
||
int index = url.path().mid(1).toInt(&ok); // path is "/N"
|
||
if (ok) {
|
||
showDiffBlock(index);
|
||
}
|
||
} else if (url.scheme() == QStringLiteral("ollie-apply")) {
|
||
bool ok;
|
||
int index = url.path().mid(1).toInt(&ok); // path is "/N"
|
||
if (ok) {
|
||
applyCodeBlock(index);
|
||
}
|
||
}
|
||
}
|
||
|
||
void OllieKateView::showDiffBlock(int index)
|
||
{
|
||
if (index < 0 || index >= m_diffBlocks.size()) return;
|
||
|
||
QByteArray diffData = m_diffBlocks[index].toUtf8();
|
||
QString mode = m_plugin->diffMode();
|
||
|
||
// Set Kate's diff style preference before opening
|
||
{
|
||
KConfigGroup grp(KSharedConfig::openConfig(QStringLiteral("katerc")),
|
||
QStringLiteral("Diff"));
|
||
// Kate uses: 0 = Side By Side, 1 = Unified, 2 = Raw
|
||
int style = 1; // unified default
|
||
if (mode == QStringLiteral("side-by-side")) style = 0;
|
||
else if (mode == QStringLiteral("raw")) style = 2;
|
||
grp.writeEntry("DiffStyle", style);
|
||
grp.sync();
|
||
}
|
||
|
||
#ifdef HAVE_KATE_DIFF
|
||
DiffParams params;
|
||
params.tabTitle = QStringLiteral("Ollie Diff %1").arg(index + 1);
|
||
params.flags = DiffParams::Flag(DiffParams::ShowFileName);
|
||
Utils::showDiff(diffData, params, m_mainWindow);
|
||
#else
|
||
// Fallback: open as a new document with Diff highlighting
|
||
auto *view = m_mainWindow->openUrl(QUrl());
|
||
if (view) {
|
||
view->document()->setText(QString::fromUtf8(diffData));
|
||
view->document()->setHighlightingMode(QStringLiteral("Diff"));
|
||
}
|
||
#endif
|
||
}
|
||
|
||
void OllieKateView::refreshSessions()
|
||
{
|
||
QStringList reply;
|
||
{
|
||
QByteArray out = run9p({"read", "session/idx"});
|
||
QString raw = QString::fromUtf8(out).trimmed();
|
||
if (!raw.isEmpty()) reply = raw.split(QChar('\n'));
|
||
}
|
||
if (reply.isEmpty()) return;
|
||
|
||
QStringList sessions = reply;
|
||
sessions.sort(Qt::CaseInsensitive);
|
||
if (sessions.isEmpty()) {
|
||
m_sessionId.clear();
|
||
return;
|
||
}
|
||
|
||
// If we already have a session, keep it
|
||
if (!m_sessionId.isEmpty()) return;
|
||
|
||
// Try to find a session matching the project base dir
|
||
QString baseDir = projectBaseDir();
|
||
if (!baseDir.isEmpty()) {
|
||
for (const QString &entry : sessions) {
|
||
QStringList parts = entry.split('\t');
|
||
if (parts.isEmpty()) continue;
|
||
QString id = parts[0];
|
||
|
||
QByteArray agentLs2 = run9p({"ls", "session/" + id + "/agent"});
|
||
QString aid2 = QString::fromUtf8(agentLs2).trimmed().split(QChar('\n')).value(0, "0");
|
||
QString cfgStr2 = QString::fromUtf8(run9p({"read", "session/" + id + "/agent/" + aid2 + "/cfg"}));
|
||
if (cfgStr2.isEmpty()) continue;
|
||
for (const QString &line : cfgStr2.split(QChar('\n'))) {
|
||
if (line.startsWith("cwd=")) {
|
||
if (line.mid(4) == baseDir) {
|
||
switchToSession(id);
|
||
return;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fall back to first session
|
||
QStringList parts = sessions.first().split('\t');
|
||
if (!parts.isEmpty()) {
|
||
switchToSession(parts[0]);
|
||
}
|
||
}
|
||
|
||
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->view() == view)
|
||
return;
|
||
|
||
// Remove old provider
|
||
delete m_ghost;
|
||
m_ghost = nullptr;
|
||
|
||
// Create new ghost provider for the active view
|
||
// TODO: ghost provider needs 9P port
|
||
m_ghost = nullptr;
|
||
m_ghost->setEnabled(m_plugin->completionEnabled());
|
||
|
||
// Install event filter on the view widget for Tab interception
|
||
if (QWidget *proxy = view->focusProxy()) {
|
||
proxy->installEventFilter(this);
|
||
}
|
||
}
|
||
|
||
void OllieKateView::setupGitPlusPlusDiffAction()
|
||
{
|
||
QObject *gitView = m_mainWindow->pluginView(QStringLiteral("gitplusplus"));
|
||
if (!gitView) return;
|
||
|
||
auto *action = new QAction(QIcon::fromTheme(QStringLiteral("document-preview")),
|
||
QStringLiteral("Ollie: Review Diff"), this);
|
||
connect(action, &QAction::triggered, this, &OllieKateView::reviewDiff);
|
||
|
||
QMetaObject::invokeMethod(gitView, "registerDiffAction",
|
||
Q_ARG(QAction *, action));
|
||
}
|
||
|
||
bool OllieKateView::eventFilter(QObject *obj, QEvent *event)
|
||
{
|
||
// Reposition scroll-to-bottom button when chat view resizes
|
||
if (obj == m_chatView && event->type() == QEvent::Resize) {
|
||
QPoint pos = m_chatView->geometry().bottomRight() - QPoint(36, 36);
|
||
m_scrollBottomBtn->move(pos);
|
||
}
|
||
|
||
// Prompt input: Enter sends, Shift+Enter / Ctrl+J inserts newline
|
||
if (obj == m_promptInput && event->type() == QEvent::KeyPress) {
|
||
auto *ke = static_cast<QKeyEvent *>(event);
|
||
if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) {
|
||
if (ke->modifiers() & (Qt::ShiftModifier | Qt::ControlModifier)) {
|
||
m_promptInput->insertPlainText("\n");
|
||
} else {
|
||
submitPrompt();
|
||
}
|
||
return true;
|
||
}
|
||
if (ke->key() == Qt::Key_J && (ke->modifiers() & Qt::ControlModifier)) {
|
||
m_promptInput->insertPlainText("\n");
|
||
return true;
|
||
}
|
||
}
|
||
|
||
if (event->type() == QEvent::KeyPress && m_ghost) {
|
||
auto *keyEvent = static_cast<QKeyEvent *>(event);
|
||
|
||
// Second key of Meta+O chord
|
||
if (m_ghostPrefix) {
|
||
m_ghostPrefix = false;
|
||
switch (keyEvent->key()) {
|
||
case Qt::Key_N:
|
||
m_ghost->cycle(+1);
|
||
return true;
|
||
case Qt::Key_P:
|
||
m_ghost->cycle(-1);
|
||
return true;
|
||
case Qt::Key_C:
|
||
m_ghost->setEnabled(!m_ghost->isEnabled());
|
||
if (m_ghost->isEnabled()) {
|
||
m_completionStatus->setText(QStringLiteral("\u2726 completion on"));
|
||
m_completionStatus->setStyleSheet(QStringLiteral("color: green; padding: 0px 2px;"));
|
||
} else {
|
||
m_completionStatus->setText(QStringLiteral("\u2726 completion off"));
|
||
m_completionStatus->setStyleSheet(QStringLiteral("color: gray; padding: 0px 2px;"));
|
||
}
|
||
return true;
|
||
case Qt::Key_D:
|
||
m_ghost->dismiss();
|
||
return true;
|
||
case Qt::Key_Tab:
|
||
m_ghost->requestCompletion();
|
||
return true;
|
||
default:
|
||
// Not a recognized chord — pass through
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Meta+O starts the chord prefix
|
||
if (keyEvent->key() == Qt::Key_O && keyEvent->modifiers() == Qt::MetaModifier) {
|
||
m_ghostPrefix = true;
|
||
return true;
|
||
}
|
||
|
||
if (m_ghost->hasSuggestion()) {
|
||
// Don't intercept keys while a popup or completion widget is active
|
||
auto *activeView = m_mainWindow->activeView();
|
||
#if KTEXTEDITOR_VERSION_MAJOR >= 6
|
||
if (QApplication::activePopupWidget() ||
|
||
(activeView && activeView->isCompletionActive()))
|
||
return QObject::eventFilter(obj, event);
|
||
#else
|
||
auto *ccIface = activeView ? dynamic_cast<KTextEditor::CodeCompletionInterface *>(activeView) : nullptr;
|
||
if (QApplication::activePopupWidget() ||
|
||
(ccIface && ccIface->isCompletionActive()))
|
||
return QObject::eventFilter(obj, event);
|
||
#endif
|
||
|
||
if (keyEvent->key() == Qt::Key_Tab && keyEvent->modifiers() == Qt::NoModifier) {
|
||
if (m_ghost->accept()) {
|
||
return true; // consumed
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return QObject::eventFilter(obj, event);
|
||
}
|
||
|
||
// --- Context Menu Integration ---
|
||
|
||
void OllieKateView::setupContextMenu(KTextEditor::View *view)
|
||
{
|
||
if (!view) return;
|
||
|
||
// Connect directly to the view's contextMenuAboutToShow signal.
|
||
// This signal is emitted by KateView before showing the context menu.
|
||
connect(view, &KTextEditor::View::contextMenuAboutToShow,
|
||
this, [this](KTextEditor::View *v, QMenu *menu) {
|
||
// Remove previous ollie actions to avoid duplicates
|
||
const auto actions = menu->actions();
|
||
for (auto *a : actions) {
|
||
if (a->objectName().startsWith(QStringLiteral("ollie_ctx_"))) {
|
||
menu->removeAction(a);
|
||
delete a;
|
||
}
|
||
}
|
||
|
||
auto *sep = menu->addSeparator();
|
||
sep->setObjectName(QStringLiteral("ollie_ctx_sep"));
|
||
|
||
auto *askAction = menu->addAction(QIcon::fromTheme(QStringLiteral("help-about")),
|
||
QStringLiteral("Ollie: Ask about file"));
|
||
askAction->setObjectName(QStringLiteral("ollie_ctx_ask"));
|
||
connect(askAction, &QAction::triggered, this, &OllieKateView::askAboutFile);
|
||
|
||
// "Review Diff" — visible when the document is a diff
|
||
if (v->document()->highlightingMode() == QStringLiteral("Diff")) {
|
||
auto *reviewDiffAction = menu->addAction(QIcon::fromTheme(QStringLiteral("document-preview")),
|
||
QStringLiteral("Ollie: Review Diff"));
|
||
reviewDiffAction->setObjectName(QStringLiteral("ollie_ctx_review_diff"));
|
||
connect(reviewDiffAction, &QAction::triggered, this, &OllieKateView::reviewDiff);
|
||
}
|
||
|
||
if (!v->selection()) return;
|
||
|
||
auto *explainAction = menu->addAction(QIcon::fromTheme(QStringLiteral("help-contents")),
|
||
QStringLiteral("Ollie: Explain this"));
|
||
explainAction->setObjectName(QStringLiteral("ollie_ctx_explain"));
|
||
connect(explainAction, &QAction::triggered, this, &OllieKateView::explainSelection);
|
||
|
||
auto *fixAction = menu->addAction(QIcon::fromTheme(QStringLiteral("tools-wizard")),
|
||
QStringLiteral("Ollie: Fix this"));
|
||
fixAction->setObjectName(QStringLiteral("ollie_ctx_fix"));
|
||
connect(fixAction, &QAction::triggered, this, [this]() { submitSelectionPrompt(QStringLiteral("Fix")); });
|
||
|
||
auto *refactorAction = menu->addAction(QIcon::fromTheme(QStringLiteral("edit-rename")),
|
||
QStringLiteral("Ollie: Refactor this"));
|
||
refactorAction->setObjectName(QStringLiteral("ollie_ctx_refactor"));
|
||
connect(refactorAction, &QAction::triggered, this, [this]() { submitSelectionPrompt(QStringLiteral("Refactor")); });
|
||
|
||
auto *testAction = menu->addAction(QIcon::fromTheme(QStringLiteral("document-new")),
|
||
QStringLiteral("Ollie: Add tests for this"));
|
||
testAction->setObjectName(QStringLiteral("ollie_ctx_test"));
|
||
connect(testAction, &QAction::triggered, this, [this]() { submitSelectionPrompt(QStringLiteral("Write unit tests for")); });
|
||
|
||
auto *docAction = menu->addAction(QIcon::fromTheme(QStringLiteral("documentation")),
|
||
QStringLiteral("Ollie: Document this"));
|
||
docAction->setObjectName(QStringLiteral("ollie_ctx_doc"));
|
||
connect(docAction, &QAction::triggered, this, [this]() { submitSelectionPrompt(QStringLiteral("Add documentation comments to")); });
|
||
});
|
||
}
|
||
|
||
void OllieKateView::submitSelectionPrompt(const QString &action)
|
||
{
|
||
auto *view = m_mainWindow->activeView();
|
||
if (!view || !view->selection()) return;
|
||
|
||
QString selection = view->selectionText();
|
||
if (selection.isEmpty()) return;
|
||
|
||
ensureSession();
|
||
if (m_sessionId.isEmpty()) return;
|
||
|
||
QString filePath = view->document()->url().toLocalFile();
|
||
auto cursor = view->selectionRange().start();
|
||
QString location;
|
||
if (!filePath.isEmpty()) {
|
||
location = " in `" + filePath + ":" + QString::number(cursor.line() + 1) + "`";
|
||
}
|
||
|
||
QString prompt = action + " this code" + location + ":\n\n```\n" + selection + "\n```";
|
||
write9p(agentPath() + "/prompt", prompt.toUtf8());
|
||
}
|
||
|
||
void OllieKateView::reviewDiff()
|
||
{
|
||
QString diff;
|
||
|
||
// Try to get diff from Git++ plugin (kate-worktree)
|
||
QObject *gitView = m_mainWindow->pluginView(QStringLiteral("gitplusplus"));
|
||
if (gitView) {
|
||
QByteArray data = gitView->property("lastDiff").toByteArray();
|
||
if (!data.isEmpty())
|
||
diff = QString::fromUtf8(data);
|
||
}
|
||
|
||
// Fall back to active document content (e.g. diff opened as plain text)
|
||
if (diff.isEmpty()) {
|
||
auto *view = m_mainWindow->activeView();
|
||
if (view && view->document())
|
||
diff = view->document()->text();
|
||
}
|
||
|
||
if (diff.trimmed().isEmpty()) return;
|
||
|
||
ensureSession();
|
||
if (m_sessionId.isEmpty()) return;
|
||
|
||
QString prompt = QStringLiteral("Review this diff. Point out potential issues, bugs, "
|
||
"or improvements:\n\n```diff\n") + diff + QStringLiteral("\n```");
|
||
write9p(agentPath() + "/prompt", prompt.toUtf8());
|
||
}
|
||
|
||
// --- Apply Code Blocks ---
|
||
|
||
void OllieKateView::applyCodeBlock(int index)
|
||
{
|
||
if (index < 0 || index >= m_applyBlocks.size()) return;
|
||
|
||
const auto &block = m_applyBlocks[index];
|
||
if (block.code.trimmed().isEmpty()) return;
|
||
|
||
KTextEditor::View *view = nullptr;
|
||
KTextEditor::Document *doc = nullptr;
|
||
|
||
if (!block.filePath.isEmpty()) {
|
||
// Open the target file in Kate
|
||
view = m_mainWindow->openUrl(QUrl::fromLocalFile(block.filePath));
|
||
} else {
|
||
// No file path detected — apply to the currently active document
|
||
view = m_mainWindow->activeView();
|
||
}
|
||
|
||
if (!view || !view->document()) return;
|
||
doc = view->document();
|
||
|
||
// Line-range replace mode: replace lines startLine through endLine
|
||
if (block.startLine > 0 && block.endLine > 0) {
|
||
int start = block.startLine - 1; // convert to 0-indexed
|
||
int end = block.endLine - 1;
|
||
if (end >= doc->lines()) end = doc->lines() - 1;
|
||
if (start > end) start = end;
|
||
|
||
KTextEditor::Range range(
|
||
KTextEditor::Cursor(start, 0),
|
||
KTextEditor::Cursor(end, doc->lineLength(end)));
|
||
doc->replaceText(range, block.code);
|
||
view->setCursorPosition(KTextEditor::Cursor(start, 0));
|
||
return;
|
||
}
|
||
|
||
// If there's a selection in the target view, replace it with the code block
|
||
if (view->selection()) {
|
||
doc->replaceText(view->selectionRange(), block.code);
|
||
return;
|
||
}
|
||
|
||
// Otherwise insert at cursor position
|
||
KTextEditor::Cursor cursor = view->cursorPosition();
|
||
doc->insertText(cursor, block.code);
|
||
}
|
||
|
||
// --- Inline Annotations ---
|
||
|
||
void OllieKateView::clearAnnotations()
|
||
{
|
||
if (m_annotationDoc) {
|
||
// Document still alive — safe to delete its MovingRanges
|
||
qDeleteAll(m_annotations);
|
||
}
|
||
// If document is gone, ranges are already destroyed — just drop pointers
|
||
m_annotations.clear();
|
||
m_annotationDoc = nullptr;
|
||
}
|
||
|
||
void OllieKateView::annotateFileReferences(const QString &chatText)
|
||
{
|
||
clearAnnotations();
|
||
|
||
auto *view = m_mainWindow->activeView();
|
||
if (!view || !view->document()) return;
|
||
|
||
QString currentFile = view->document()->url().toLocalFile();
|
||
if (currentFile.isEmpty()) return;
|
||
|
||
// Match patterns like /path/to/file.ext:123 or /path/to/file.ext:123:45
|
||
static const QRegularExpression fileRefRe(
|
||
R"((?:^|[\s`])(/[^\s:]+):(\d+)(?::(\d+))?)",
|
||
QRegularExpression::MultilineOption);
|
||
|
||
auto *doc = view->document();
|
||
m_annotationDoc = doc;
|
||
#if KTEXTEDITOR_VERSION_MAJOR < 6
|
||
auto *movingIface = qobject_cast<KTextEditor::MovingInterface *>(doc);
|
||
#else
|
||
auto *movingIface = doc;
|
||
#endif
|
||
if (!movingIface) return;
|
||
|
||
KTextEditor::Attribute::Ptr attr(new KTextEditor::Attribute());
|
||
attr->setBackground(m_renderer ? QColor(255, 255, 0, 40) : QColor(255, 255, 0, 40));
|
||
attr->setFontBold(false);
|
||
|
||
QSet<int> annotatedLines;
|
||
|
||
auto it = fileRefRe.globalMatch(chatText);
|
||
while (it.hasNext()) {
|
||
auto match = it.next();
|
||
QString path = match.captured(1);
|
||
int line = match.captured(2).toInt() - 1; // 0-indexed
|
||
|
||
// Only annotate if it references the currently open file
|
||
if (path != currentFile) continue;
|
||
if (line < 0 || line >= doc->lines()) continue;
|
||
if (annotatedLines.contains(line)) continue;
|
||
annotatedLines.insert(line);
|
||
|
||
KTextEditor::Range range(line, 0, line, doc->lineLength(line));
|
||
auto *movingRange = movingIface->newMovingRange(range);
|
||
movingRange->setAttribute(attr);
|
||
movingRange->setZDepth(-100.0); // behind text
|
||
m_annotations.append(movingRange);
|
||
}
|
||
}
|
||
|
||
// --- 9P helpers ---
|
||
|
||
QString OllieKateView::serverAddr()
|
||
{
|
||
QString ns = qEnvironmentVariable("NAMESPACE");
|
||
if (ns.isEmpty()) {
|
||
QString user = qEnvironmentVariable("USER");
|
||
QString display = qEnvironmentVariable("DISPLAY");
|
||
ns = "/tmp/ns." + user + "." + display;
|
||
}
|
||
return "unix!" + ns + "/ollie";
|
||
}
|
||
|
||
QString OllieKateView::agentPath()
|
||
{
|
||
if (m_sessionId.isEmpty()) return {};
|
||
// Discover agent ID (cache could be added later)
|
||
QByteArray out = run9p({"ls", "session/" + m_sessionId + "/agent"});
|
||
QString aid = QString::fromUtf8(out).trimmed().split('\n').value(0, "0");
|
||
return "session/" + m_sessionId + "/agent/" + aid;
|
||
}
|
||
|
||
QByteArray OllieKateView::run9p(const QStringList &args)
|
||
{
|
||
QProcess proc;
|
||
QStringList fullArgs = {"-a", serverAddr()};
|
||
fullArgs.append(args);
|
||
proc.start(ninepBin(), fullArgs);
|
||
if (!proc.waitForFinished(10000)) return {};
|
||
return proc.readAllStandardOutput();
|
||
}
|
||
|
||
void OllieKateView::write9p(const QString &path, const QByteArray &data)
|
||
{
|
||
QProcess proc;
|
||
proc.start(ninepBin(), {"-a", serverAddr(), "write", path});
|
||
proc.waitForStarted(3000);
|
||
proc.write(data);
|
||
proc.closeWriteChannel();
|
||
proc.waitForFinished(5000);
|
||
}
|
||
|
||
// --- 9P Streaming ---
|
||
|
||
void OllieKateView::startChatStream()
|
||
{
|
||
if (m_chatProc || m_sessionId.isEmpty()) return;
|
||
m_chatProc = new QProcess(this);
|
||
connect(m_chatProc, &QProcess::readyReadStandardOutput, this, [this]() {
|
||
QByteArray data = m_chatProc->readAllStandardOutput();
|
||
if (!data.isEmpty()) {
|
||
QString text = QString::fromUtf8(data);
|
||
m_rawChat += text;
|
||
m_chatOffset += data.size();
|
||
// Append streaming text and re-render tail
|
||
m_renderer->render(m_chatView, m_rawChat);
|
||
}
|
||
});
|
||
connect(m_chatProc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
||
this, [this]() { m_chatProc->deleteLater(); m_chatProc = nullptr; });
|
||
m_chatProc->start(ninepBin(), {"-a", serverAddr(), "read", agentPath() + "/chat"});
|
||
}
|
||
|
||
void OllieKateView::startStateStream()
|
||
{
|
||
if (m_stateProc || m_sessionId.isEmpty()) return;
|
||
m_stateProc = new QProcess(this);
|
||
connect(m_stateProc, &QProcess::readyReadStandardOutput, this, [this]() {
|
||
QByteArray data = m_stateProc->readAllStandardOutput();
|
||
QString state = QString::fromUtf8(data).trimmed();
|
||
if (!state.isEmpty()) {
|
||
QStringList lines = state.split('\n');
|
||
state = lines.last().trimmed();
|
||
QString dot = (state == "idle") ? "○ " : (state.startsWith("calling") ? "⚡ " : "● ");
|
||
m_sessionLabel->setText(dot + m_sessionId + " [" + state + "]");
|
||
}
|
||
// statewait returns once per state change — restart
|
||
m_stateProc->deleteLater();
|
||
m_stateProc = nullptr;
|
||
startStateStream();
|
||
});
|
||
connect(m_stateProc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
||
this, [this]() {
|
||
if (m_stateProc) { m_stateProc->deleteLater(); m_stateProc = nullptr; }
|
||
});
|
||
m_stateProc->start(ninepBin(), {"-a", serverAddr(), "read", agentPath() + "/statewait"});
|
||
}
|
||
|
||
void OllieKateView::stopStreams()
|
||
{
|
||
if (m_chatProc) {
|
||
m_chatProc->disconnect();
|
||
m_chatProc->kill();
|
||
m_chatProc->waitForFinished(1000);
|
||
m_chatProc->deleteLater();
|
||
m_chatProc = nullptr;
|
||
}
|
||
if (m_stateProc) {
|
||
m_stateProc->disconnect();
|
||
m_stateProc->kill();
|
||
m_stateProc->waitForFinished(1000);
|
||
m_stateProc->deleteLater();
|
||
m_stateProc = nullptr;
|
||
}
|
||
}
|
||
|
||
#include "ollie_kate.moc"
|