ollie/kde/kate/ollie_kate.cpp

955 lines
38 KiB
C++

#include "ollie_kate.h"
#include "ollie_ghost.h"
#include <KPluginFactory>
#include <KSharedConfig>
#include <KConfigGroup>
#include <KTextEditor/Document>
#include <KTextEditor/Editor>
#include <QApplication>
#include <QCheckBox>
#include <QDir>
#include <QFileInfo>
#include <QHash>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QMainWindow>
#include <QMenu>
#include <QProcess>
#include <QStatusBar>
#include <QToolButton>
#include <QTreeView>
#include <QVBoxLayout>
K_PLUGIN_CLASS_WITH_JSON(OllieKatePlugin, "metadata.json")
// --- Plugin ---
OllieKatePlugin::OllieKatePlugin(QObject *parent, const KPluginMetaData &data, const QVariantList &args)
: KTextEditor::Plugin(parent)
{
Q_UNUSED(data);
Q_UNUSED(args);
loadConfig();
}
QObject *OllieKatePlugin::createView(KTextEditor::MainWindow *mainWindow)
{
return new OllieKateView(this, mainWindow);
}
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);
}
void OllieKatePlugin::saveConfig()
{
KConfigGroup grp(KSharedConfig::openConfig(), QStringLiteral("Ollie"));
grp.writeEntry("CompletionEnabled", m_completionEnabled);
grp.sync();
}
void OllieKatePlugin::setCompletionEnabled(bool enabled)
{
m_completionEnabled = enabled;
saveConfig();
}
int OllieKatePlugin::configPages() const
{
return 1;
}
KTextEditor::ConfigPage *OllieKatePlugin::configPage(int number, QWidget *parent)
{
if (number != 0) return nullptr;
class OllieConfigPage : public KTextEditor::ConfigPage
{
public:
OllieConfigPage(OllieKatePlugin *plugin, QWidget *parent)
: KTextEditor::ConfigPage(parent), m_plugin(plugin)
{
auto *layout = new QVBoxLayout(this);
m_enabledCheck = new QCheckBox(QStringLiteral("Automatic completion (ghost text)"), this);
layout->addWidget(m_enabledCheck);
layout->addStretch();
reset();
connect(m_enabledCheck, &QCheckBox::toggled, this, &ConfigPage::changed);
}
QString name() const override { return QStringLiteral("Ollie"); }
QString fullName() const override { return QStringLiteral("Ollie Settings"); }
QIcon icon() const override { return QIcon::fromTheme(QStringLiteral("system-run")); }
void apply() override {
m_plugin->setCompletionEnabled(m_enabledCheck->isChecked());
}
void reset() override {
m_enabledCheck->setChecked(m_plugin->completionEnabled());
}
void defaults() override {
m_enabledCheck->setChecked(true);
}
private:
OllieKatePlugin *m_plugin;
QCheckBox *m_enabledCheck;
};
return new OllieConfigPage(this, parent);
}
// --- View ---
OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *mainWindow)
: QObject(mainWindow)
, m_plugin(plugin)
, m_mainWindow(mainWindow)
, m_9p(new Lib9pClient(this))
, m_heartbeatTimer(new QTimer(this))
{
// Connect to 9P server
m_connected = m_9p->connectDefault();
if (!m_connected) {
qWarning() << "Ollie: failed to connect to 9P server:" << m_9p->lastError();
}
// Heartbeat timer — detect server death and reconnect
m_heartbeatTimer->setInterval(5000);
connect(m_heartbeatTimer, &QTimer::timeout, this, &OllieKateView::heartbeat);
m_heartbeatTimer->start();
// Status bar indicator
if (auto *win = qobject_cast<QMainWindow *>(m_mainWindow->window())) {
m_statusLabel = new QLabel();
m_statusLabel->setObjectName(QStringLiteral("ollie_status"));
QFont smallFont = QApplication::font();
smallFont.setPointSizeF(smallFont.pointSizeF() * 0.85);
m_statusLabel->setFont(smallFont);
m_statusLabel->setStyleSheet(QStringLiteral("color: gray; padding: 0px 4px;"));
win->statusBar()->addPermanentWidget(m_statusLabel);
}
// Auto-select agent for current project
selectAgentForProject();
updateStatusLabel();
// Ghost text completion
setupGhostProvider();
// React to view/project changes
connect(mainWindow, &KTextEditor::MainWindow::viewChanged,
this, [this](KTextEditor::View *view) {
setupGhostProvider();
if (view) setupContextMenu(view);
selectAgentForProject();
updateStatusLabel();
});
QObject *projectView = mainWindow->pluginView("kateprojectplugin");
if (projectView) {
QObject::connect(projectView, SIGNAL(projectChanged()),
this, SLOT(onProjectChanged()));
}
connect(mainWindow, &KTextEditor::MainWindow::pluginViewCreated,
this, [this](const QString &name, QObject *view) {
if (name == "kateprojectplugin") {
QObject::connect(view, SIGNAL(projectChanged()),
this, SLOT(onProjectChanged()));
selectAgentForProject();
updateStatusLabel();
}
});
// Context menu on initial view
if (auto *view = mainWindow->activeView())
setupContextMenu(view);
// Project tree context menu
setupProjectTreeContextMenu();
// Kate project plugin's built-in Git widget integration
setupProjectGitWidget();
}
OllieKateView::~OllieKateView()
{
m_heartbeatTimer->stop();
delete m_ghost;
if (m_statusLabel) {
delete m_statusLabel;
}
}
// --- Agent Naming ---
// Compute agent name from directory path
// Format: prefix:basename (parent split at first _ if present)
// Examples: /home/user/src/ollie -> src:ollie
// /home/user/proj/MD-20470_feature/mobydick -> md-20470:mobydick
QString OllieKateView::agentNameFromCwd(const QString &cwd)
{
QFileInfo fi(cwd);
QString parent = QFileInfo(fi.path()).fileName(); // parent directory name
QString basename = fi.fileName();
// Split parent at first underscore if present
int underscoreIdx = parent.indexOf('_');
QString prefix = (underscoreIdx > 0) ? parent.left(underscoreIdx) : parent;
// Slugify: lowercase, replace non-alnum with -
QString slug = (prefix + ":" + basename).toLower();
QString result;
for (QChar c : slug) {
if (c.isLetterOrNumber() || c == ':' || c == '-') {
result += c;
} else {
result += '-';
}
}
return result;
}
// --- Agent Management ---
void OllieKateView::ensureAgent()
{
QString cwd = projectBaseDir();
if (cwd.isEmpty()) cwd = QDir::homePath();
QString agentName = agentNameFromCwd(cwd);
// Ensure kate session exists
if (m_sessionId.isEmpty()) {
QByteArray resp = m_9p->rdwr("session/new", ("name=kate\ncwd=" + cwd + "\n").toUtf8());
if (resp.isEmpty()) {
qWarning() << "Ollie Kate: failed to create session";
return;
}
m_sessionId = "kate";
}
// Check if agent with this name already exists
QByteArray check = m_9p->read("session/kate/agent/" + agentName + "/cfg");
if (!check.isEmpty()) {
m_agentId = agentName;
// Ensure cwd is up to date
m_9p->rdwr("session/kate/agent/" + agentName + "/ctl", ("cwd " + cwd).toUtf8());
} else {
// Create new agent with computed name
QString agentCfg = "name=" + agentName + "\ncwd=" + cwd + "\n";
QByteArray resp = m_9p->rdwr("session/kate/agent/new", agentCfg.toUtf8());
if (!resp.isEmpty()) {
m_agentId = agentName;
}
}
updateStatusLabel();
}
bool OllieKateView::selectAgentForProject()
{
// Check if kate session exists
QByteArray sessCheck = m_9p->read("session/kate/id");
if (sessCheck.isEmpty()) return false;
m_sessionId = "kate";
QString cwd = projectBaseDir();
if (cwd.isEmpty()) return false;
QString agentName = agentNameFromCwd(cwd);
// Check if agent exists
QByteArray check = m_9p->read("session/kate/agent/" + agentName + "/cfg");
if (!check.isEmpty()) {
m_agentId = agentName;
// Ensure cwd is up to date
m_9p->rdwr("session/kate/agent/" + agentName + "/ctl", ("cwd " + cwd).toUtf8());
updateStatusLabel();
return true;
}
// Agent doesn't exist yet — will be created on first use
m_agentId.clear();
updateStatusLabel();
return false;
}
void OllieKateView::switchToAgent(const QString &agentId)
{
m_sessionId = "kate";
m_agentId = agentId;
updateStatusLabel();
}
void OllieKateView::onProjectChanged()
{
// Switch to the agent for the new project (or create if needed)
selectAgentForProject();
}
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 git root detection from active document
auto *view = m_mainWindow->activeView();
if (view && view->document() && !view->document()->url().toLocalFile().isEmpty()) {
QString docDir = QFileInfo(view->document()->url().toLocalFile()).absolutePath();
// Walk up to find .git directory
QDir dir(docDir);
while (!dir.isRoot()) {
if (QFileInfo(dir.filePath(".git")).exists()) {
return dir.absolutePath();
}
if (!dir.cdUp()) break;
}
// No git root found, use document directory
return docDir;
}
return QString();
}
QString OllieKateView::agentPath() const
{
if (m_sessionId.isEmpty() || m_agentId.isEmpty()) return {};
return "session/" + m_sessionId + "/agent/" + m_agentId;
}
void OllieKateView::updateStatusLabel()
{
if (!m_statusLabel) return;
if (!m_connected) {
m_statusLabel->setText(QStringLiteral("✗ ollie: disconnected"));
m_statusLabel->setStyleSheet(QStringLiteral("color: red; padding: 0px 4px;"));
} else if (m_agentId.isEmpty()) {
m_statusLabel->setText(QStringLiteral("○ ollie: no agent"));
m_statusLabel->setStyleSheet(QStringLiteral("color: gray; padding: 0px 4px;"));
} else {
m_statusLabel->setText(QStringLiteral("● ollie: kate/") + m_agentId);
m_statusLabel->setStyleSheet(QStringLiteral("color: green; padding: 0px 4px;"));
}
}
// --- Connection Recovery ---
void OllieKateView::heartbeat()
{
// Try a cheap read to verify daemon is alive
if (!m_9p->isConnected()) {
if (!m_9p->connectDefault()) {
if (m_connected) {
m_connected = false;
updateStatusLabel();
}
return;
}
}
QByteArray result = m_9p->read(QStringLiteral("help"));
bool alive = !result.isEmpty();
if (alive && !m_connected) {
// Recovered — re-establish state
m_connected = true;
selectAgentForProject();
updateStatusLabel();
} else if (!alive && m_connected) {
// Lost connection
m_connected = false;
m_9p->disconnect();
updateStatusLabel();
}
}
// --- Context Menu ---
void OllieKateView::setupContextMenu(KTextEditor::View *view)
{
if (!view) return;
connect(view, &KTextEditor::View::contextMenuAboutToShow,
this, [this](KTextEditor::View *v, QMenu *menu) {
// Remove previous ollie actions
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" when 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);
}
auto *startAction = menu->addAction(QIcon::fromTheme(QStringLiteral("system-run")),
QStringLiteral("Ollie: Start Session"));
startAction->setObjectName(QStringLiteral("ollie_ctx_start"));
connect(startAction, &QAction::triggered, this, &OllieKateView::startSession);
// Git diff review actions (available when in a git repo)
QString baseDir = projectBaseDir();
if (!baseDir.isEmpty()) {
auto *reviewStagedAction = menu->addAction(QIcon::fromTheme(QStringLiteral("vcs-diff")),
QStringLiteral("Ollie: Review Staged Changes"));
reviewStagedAction->setObjectName(QStringLiteral("ollie_ctx_review_staged"));
connect(reviewStagedAction, &QAction::triggered, this, &OllieKateView::reviewStagedChanges);
auto *reviewUnstagedAction = menu->addAction(QIcon::fromTheme(QStringLiteral("vcs-diff")),
QStringLiteral("Ollie: Review Unstaged Changes"));
reviewUnstagedAction->setObjectName(QStringLiteral("ollie_ctx_review_unstaged"));
connect(reviewUnstagedAction, &QAction::triggered, this, &OllieKateView::reviewUnstagedChanges);
}
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")); });
auto *verbatimAction = menu->addAction(QIcon::fromTheme(QStringLiteral("document-send")),
QStringLiteral("Ollie: Send verbatim"));
verbatimAction->setObjectName(QStringLiteral("ollie_ctx_verbatim"));
connect(verbatimAction, &QAction::triggered, this, &OllieKateView::sendVerbatim);
});
}
void OllieKateView::setupProjectGitWidget()
{
// Don't retry if we already have the button
if (m_mainWindow->window()->findChild<QToolButton *>(QStringLiteral("ollie_git_btn"))) {
return;
}
// Find the git tool view by name — it's created with ID "kateprojectgit"
QWidget *gitToolView = m_mainWindow->window()->findChild<QWidget *>(QStringLiteral("kateprojectgit"));
if (!gitToolView) {
// Schedule retry — plugin view might not be created yet
QTimer::singleShot(2000, this, &OllieKateView::setupProjectGitWidget);
return;
}
// Find the tree view inside the git tool view and store for later use
auto *treeView = gitToolView->findChild<QTreeView *>();
if (treeView) {
m_gitTreeView = treeView;
}
// The GitWidget is inside the tool view and has a QHBoxLayout with buttons at the top
// Find the first QHBoxLayout (the button bar) and add our button there
QHBoxLayout *btnLayout = nullptr;
for (auto *child : gitToolView->findChildren<QHBoxLayout *>()) {
// The button bar has QToolButtons as direct children
bool hasToolButtons = false;
for (int i = 0; i < child->count(); ++i) {
if (auto *item = child->itemAt(i)) {
if (qobject_cast<QToolButton *>(item->widget())) {
hasToolButtons = true;
break;
}
}
}
if (hasToolButtons) {
btnLayout = child;
break;
}
}
if (!btnLayout) {
// Schedule retry — GitWidget might not be initialized yet (it delays init)
QTimer::singleShot(2000, this, &OllieKateView::setupProjectGitWidget);
return;
}
// Create a button for Ollie actions
auto *ollieBtn = new QToolButton(gitToolView);
ollieBtn->setObjectName(QStringLiteral("ollie_git_btn"));
ollieBtn->setIcon(QIcon::fromTheme(QStringLiteral("system-run")));
ollieBtn->setToolTip(QStringLiteral("Ollie AI Actions"));
ollieBtn->setPopupMode(QToolButton::InstantPopup);
ollieBtn->setAutoRaise(true);
auto *menu = new QMenu(ollieBtn);
menu->addAction(QIcon::fromTheme(QStringLiteral("document-preview")),
QStringLiteral("Review Staged Changes"),
this, &OllieKateView::reviewStagedChanges);
menu->addAction(QIcon::fromTheme(QStringLiteral("document-preview")),
QStringLiteral("Review Unstaged Changes"),
this, &OllieKateView::reviewUnstagedChanges);
menu->addAction(QIcon::fromTheme(QStringLiteral("help-about")),
QStringLiteral("Explain Selected Files"),
this, &OllieKateView::askAboutGitSelection);
ollieBtn->setMenu(menu);
// Insert before the menu button (last in the row)
int insertPos = btnLayout->count() > 0 ? btnLayout->count() - 1 : 0;
btnLayout->insertWidget(insertPos, ollieBtn);
}
void OllieKateView::onGitTreeContextMenu(const QPoint &)
{
// Unused - context menu integration not possible without Kate modification
}
void OllieKateView::setupProjectTreeContextMenu()
{
// Find the project tree view — it's inside the kateproject tool view
QWidget *projectToolView = m_mainWindow->window()->findChild<QWidget *>(QStringLiteral("kateproject"));
if (!projectToolView) {
// Schedule retry — plugin view might not be created yet
QTimer::singleShot(2000, this, &OllieKateView::setupProjectTreeContextMenu);
return;
}
// Find the tree view inside the project tool view
auto *treeView = projectToolView->findChild<QTreeView *>();
if (!treeView) {
QTimer::singleShot(2000, this, &OllieKateView::setupProjectTreeContextMenu);
return;
}
// Avoid duplicate setup
if (m_projectTreeView == treeView) {
return;
}
m_projectTreeView = treeView;
// Enable custom context menu
treeView->setContextMenuPolicy(Qt::CustomContextMenu);
connect(treeView, &QTreeView::customContextMenuRequested,
this, &OllieKateView::onProjectTreeContextMenu);
}
void OllieKateView::onProjectTreeContextMenu(const QPoint &pos)
{
if (!m_projectTreeView) return;
auto *model = m_projectTreeView->model();
if (!model) return;
QModelIndex index = m_projectTreeView->indexAt(pos);
if (!index.isValid()) return;
// Get the file path from the model
// Kate's project model stores the path in UserRole or as a property
QString filePath = model->data(index, Qt::UserRole).toString();
if (filePath.isEmpty()) {
// Try DisplayRole as fallback and construct path
QString name = model->data(index, Qt::DisplayRole).toString();
QString baseDir = projectBaseDir();
if (!baseDir.isEmpty() && !name.isEmpty()) {
// Walk up the tree to construct full path
QStringList parts;
parts.prepend(name);
QModelIndex parent = index.parent();
while (parent.isValid()) {
parts.prepend(model->data(parent, Qt::DisplayRole).toString());
parent = parent.parent();
}
// Remove the project root name (first element) and join
if (!parts.isEmpty()) parts.removeFirst();
filePath = baseDir + QStringLiteral("/") + parts.join(QStringLiteral("/"));
}
}
if (filePath.isEmpty()) return;
QFileInfo fi(filePath);
bool isDir = fi.isDir();
QMenu menu(m_projectTreeView);
if (isDir) {
auto *askAction = menu.addAction(QIcon::fromTheme(QStringLiteral("help-about")),
QStringLiteral("Ollie: Ask about directory"));
connect(askAction, &QAction::triggered, this, [this, filePath]() {
ensureAgent();
QString baseDir = projectBaseDir();
QString relPath = filePath;
if (!baseDir.isEmpty() && filePath.startsWith(baseDir)) {
relPath = filePath.mid(baseDir.length() + 1);
}
QString prompt = QStringLiteral("Explain the purpose and structure of the directory: ") + relPath;
m_9p->write(agentPath() + QStringLiteral("/prompt"), prompt.toUtf8());
});
auto *startAction = menu.addAction(QIcon::fromTheme(QStringLiteral("system-run")),
QStringLiteral("Ollie: Start Session Here"));
connect(startAction, &QAction::triggered, this, [this, filePath]() {
ensureAgent();
QString prompt = QStringLiteral("I want to work on code in: ") + filePath;
m_9p->write(agentPath() + QStringLiteral("/prompt"), prompt.toUtf8());
});
} else {
auto *askAction = menu.addAction(QIcon::fromTheme(QStringLiteral("help-about")),
QStringLiteral("Ollie: Ask about file"));
connect(askAction, &QAction::triggered, this, [this, filePath]() {
ensureAgent();
QString baseDir = projectBaseDir();
QString relPath = filePath;
if (!baseDir.isEmpty() && filePath.startsWith(baseDir)) {
relPath = filePath.mid(baseDir.length() + 1);
}
QString prompt = QStringLiteral("Explain the purpose and design of the file: ") + relPath;
m_9p->write(agentPath() + QStringLiteral("/prompt"), prompt.toUtf8());
});
auto *reviewAction = menu.addAction(QIcon::fromTheme(QStringLiteral("document-preview")),
QStringLiteral("Ollie: Review file"));
connect(reviewAction, &QAction::triggered, this, [this, filePath]() {
ensureAgent();
QString baseDir = projectBaseDir();
QString relPath = filePath;
if (!baseDir.isEmpty() && filePath.startsWith(baseDir)) {
relPath = filePath.mid(baseDir.length() + 1);
}
QString prompt = QStringLiteral("Review the code in ") + relPath + QStringLiteral(" for bugs, issues, and improvements.");
m_9p->write(agentPath() + QStringLiteral("/prompt"), prompt.toUtf8());
});
auto *testAction = menu.addAction(QIcon::fromTheme(QStringLiteral("system-run")),
QStringLiteral("Ollie: Write tests"));
connect(testAction, &QAction::triggered, this, [this, filePath]() {
ensureAgent();
QString baseDir = projectBaseDir();
QString relPath = filePath;
if (!baseDir.isEmpty() && filePath.startsWith(baseDir)) {
relPath = filePath.mid(baseDir.length() + 1);
}
QString prompt = QStringLiteral("Write tests for the code in ") + relPath;
m_9p->write(agentPath() + QStringLiteral("/prompt"), prompt.toUtf8());
});
}
menu.exec(m_projectTreeView->viewport()->mapToGlobal(pos));
}
void OllieKateView::reviewStagedChanges()
{
QString baseDir = projectBaseDir();
if (baseDir.isEmpty()) return;
// Get staged diff via git
QProcess git;
git.setWorkingDirectory(baseDir);
git.start(QStringLiteral("git"), {QStringLiteral("diff"), QStringLiteral("--cached")});
if (!git.waitForFinished(5000)) return;
QByteArray diff = git.readAllStandardOutput();
if (diff.trimmed().isEmpty()) return;
ensureAgent();
if (m_agentId.isEmpty()) return;
QString prompt = QStringLiteral("Read-only task. Review the following staged diff. Do not edit files. Identify correctness bugs, regressions, security issues, missing tests, and maintainability concerns. For each finding, explain the impact and cite the relevant hunk. If there are no findings, say so explicitly.\n\n```diff\n")
+ QString::fromUtf8(diff) + QStringLiteral("\n```");
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
void OllieKateView::reviewUnstagedChanges()
{
QString baseDir = projectBaseDir();
if (baseDir.isEmpty()) return;
// Get unstaged diff via git
QProcess git;
git.setWorkingDirectory(baseDir);
git.start(QStringLiteral("git"), {QStringLiteral("diff")});
if (!git.waitForFinished(5000)) return;
QByteArray diff = git.readAllStandardOutput();
if (diff.trimmed().isEmpty()) return;
ensureAgent();
if (m_agentId.isEmpty()) return;
QString prompt = QStringLiteral("Read-only task. Review the following unstaged diff. Do not edit files. Identify correctness bugs, regressions, security issues, missing tests, and maintainability concerns. For each finding, explain the impact and cite the relevant hunk. If there are no findings, say so explicitly.\n\n```diff\n")
+ QString::fromUtf8(diff) + QStringLiteral("\n```");
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
void OllieKateView::askAboutGitSelection()
{
if (!m_gitTreeView) return;
// Get selected files from git tree view
QStringList files;
auto *selModel = m_gitTreeView->selectionModel();
if (selModel) {
const auto indexes = selModel->selectedIndexes();
for (const auto &idx : indexes) {
// Try to get file name from the model
QString fileName = idx.data(Qt::DisplayRole).toString();
if (!fileName.isEmpty() && !files.contains(fileName)) {
files.append(fileName);
}
}
}
if (files.isEmpty()) return;
QString baseDir = projectBaseDir();
if (baseDir.isEmpty()) return;
ensureAgent();
if (m_agentId.isEmpty()) return;
QString fileList = files.join(QStringLiteral(", "));
QString prompt = QStringLiteral("Read-only task. Inspect these files and describe their current git status and any changes: ") + fileList +
QStringLiteral("\n\nBase directory: ") + baseDir +
QStringLiteral("\n\nExplain what changes are pending for these files, whether they are staged or unstaged, and any notable patterns in the modifications.");
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
// --- Actions ---
void OllieKateView::askAboutFile()
{
auto *view = m_mainWindow->activeView();
if (!view || !view->document()) return;
QString filePath = view->document()->url().toLocalFile();
if (filePath.isEmpty()) return;
ensureAgent();
if (m_agentId.isEmpty()) return;
QString prompt = QStringLiteral("Read-only task. Inspect the complete file ") + filePath + QStringLiteral(" and describe it. Do not edit files. Report the file's purpose, main components, important data flow, dependencies, and relevant tests or checks. Do not guess details that are not present in the file or repository.\n\nFile: ") + filePath + QStringLiteral("\n\nGive a concise implementation-oriented overview.");
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
void OllieKateView::explainSelection()
{
auto *view = m_mainWindow->activeView();
if (!view) return;
QString selection = view->selectionText();
if (selection.isEmpty()) {
auto cursor = view->cursorPosition();
selection = view->document()->line(cursor.line());
}
if (selection.isEmpty()) return;
ensureAgent();
if (m_agentId.isEmpty()) return;
QString filePath = view->document()->url().toLocalFile();
QString prompt = QStringLiteral("Read-only task. Inspect the actual file ") + filePath + QStringLiteral(" and explain the selected code below. Do not edit files. Explain what it does, how it works, important inputs and outputs, and any risks or edge cases. The selection is the target, not necessarily the complete context.\n\nFile: ") + filePath + QStringLiteral("\nSelected code:\n\n") + selection + QStringLiteral("\n\nGive a concise explanation. Do not propose or apply a patch.");
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
void OllieKateView::submitSelectionPrompt(const QString &action)
{
auto *view = m_mainWindow->activeView();
if (!view || !view->selection()) return;
QString selection = view->selectionText();
if (selection.isEmpty()) return;
ensureAgent();
if (m_agentId.isEmpty()) return;
QString filePath = view->document()->url().toLocalFile();
QString prompt;
if (action == QStringLiteral("Fix")) {
prompt = QStringLiteral("Edit the source file ") + filePath + QStringLiteral(".\n\nThe selected code below is the target. It may be incomplete context:\n\n") + selection + QStringLiteral("\n\nInstructions:\n- Read the actual file and relevant surrounding code before editing.\n- Find the concrete bug or problem in the selected code.\n- Make the smallest safe fix. Do not rewrite unrelated code.\n- Preserve intended behavior outside the fix.\n- Apply the fix directly to the file. Do not only describe a patch.\n- Run formatting and the most relevant tests or diagnostics if available.\n\nAfter editing, report the file changed, the problem fixed, and verification performed.");
} else if (action == QStringLiteral("Refactor")) {
prompt = QStringLiteral("Edit the source file ") + filePath + QStringLiteral(".\n\nThe selected code below is the target. It may be incomplete context:\n\n") + selection + QStringLiteral("\n\nInstructions:\n- Read the actual file and relevant surrounding code before editing.\n- Refactor the selected code without changing its external behavior.\n- Make one focused improvement. Do not rewrite unrelated code.\n- Follow the existing project style. Do not add unnecessary abstractions.\n- Apply the refactor directly to the file. Do not only describe a patch.\n- Run formatting and the most relevant tests or diagnostics if available.\n\nAfter editing, report the file changed, the refactor made, and verification performed.");
} else if (action == QStringLiteral("Write unit tests for")) {
prompt = QStringLiteral("Edit the source tree to add tests for the selected code in ") + filePath + QStringLiteral(".\n\nThe selected code below is the target. It may be incomplete context:\n\n") + selection + QStringLiteral("\n\nInstructions:\n- Read the actual source file, existing tests, and project test conventions first.\n- Add focused unit tests for normal behavior, important edge cases, and errors.\n- Do not change production code unless a test requires a minimal testability fix.\n- Do not rewrite or remove existing tests.\n- Apply the tests directly to the repository. Do not only describe test code.\n- Format the files and run the relevant tests.\n\nAfter editing, report the test files changed and the test command and result.");
} else {
prompt = QStringLiteral("Edit the source file ") + filePath + QStringLiteral(".\n\nThe selected code below is the target. It may be incomplete context:\n\n") + selection + QStringLiteral("\n\nInstructions:\n- Read the actual file and relevant surrounding code before editing.\n- Add documentation comments to the selected declarations.\n- Do not change executable code or unrelated code.\n- Match the language and existing project style.\n- Document behavior, parameters, return values, errors, and non-obvious edge cases.\n- Apply the edit directly to the file. Do not only describe a patch.\n- Run formatting or relevant checks if available.\n\nAfter editing, report the file changed and a one-sentence summary.");
}
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
void OllieKateView::sendVerbatim()
{
auto *view = m_mainWindow->activeView();
if (!view || !view->selection()) return;
QString selection = view->selectionText();
if (selection.isEmpty()) return;
ensureAgent();
if (m_agentId.isEmpty()) return;
// Derive fence language from document highlighting mode
QString mode = view->document()->highlightingMode().toLower();
// Map common Kate highlighting names to fence languages
static const QHash<QString, QString> modeMap = {
{"c++", "cpp"}, {"c", "c"}, {"cmake", "cmake"},
{"javascript", "javascript"}, {"typescript", "typescript"},
{"python", "python"}, {"rust", "rust"}, {"go", "go"},
{"bash", "bash"}, {"zsh", "bash"}, {"shell", "bash"},
{"json", "json"}, {"yaml", "yaml"}, {"toml", "toml"},
{"html", "html"}, {"css", "css"}, {"sql", "sql"},
{"java", "java"}, {"kotlin", "kotlin"}, {"ruby", "ruby"},
{"perl", "perl"}, {"lua", "lua"}, {"diff", "diff"},
{"markdown", "markdown"}, {"xml", "xml"},
};
QString lang = modeMap.value(mode, mode);
QString prompt = "```" + lang + "\n" + selection + "\n```";
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
void OllieKateView::reviewDiff()
{
auto *view = m_mainWindow->activeView();
if (!view || !view->document()) return;
QString diff = view->document()->text();
if (diff.trimmed().isEmpty()) return;
ensureAgent();
if (m_agentId.isEmpty()) return;
QString prompt = QStringLiteral("Read-only task. Review the following diff. Do not edit files. Identify correctness bugs, regressions, security issues, missing tests, and maintainability concerns. For each finding, explain the impact and cite the relevant hunk. If there are no findings, say so explicitly.\n\n```diff\n") + diff + QStringLiteral("\n```");
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
void OllieKateView::startSession()
{
ensureAgent();
// Update cwd to current project
QString baseDir = projectBaseDir();
if (!baseDir.isEmpty() && !m_agentId.isEmpty()) {
m_9p->write(agentPath() + "/ctl", ("cwd " + baseDir).toUtf8());
}
updateStatusLabel();
}
// --- Ghost Text ---
void OllieKateView::setupGhostProvider()
{
auto *view = m_mainWindow->activeView();
if (!view) return;
if (m_ghost && m_ghost->view() == view) return;
if (m_ghost) {
delete m_ghost;
m_ghost = nullptr;
}
m_ghost = new OllieGhostProvider(view, m_plugin, this);
m_ghost->setEnabled(m_plugin->completionEnabled());
if (QWidget *proxy = view->focusProxy()) {
proxy->installEventFilter(this);
}
}
// --- Event Filter (ghost text key handling) ---
bool OllieKateView::eventFilter(QObject *obj, QEvent *event)
{
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_statusLabel) {
// Update status to reflect completion state
updateStatusLabel();
}
return true;
case Qt::Key_D:
m_ghost->dismiss();
return true;
case Qt::Key_Tab:
m_ghost->requestCompletion();
return true;
default:
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()) {
auto *activeView = m_mainWindow->activeView();
if (QApplication::activePopupWidget() ||
(activeView && activeView->isCompletionActive()))
return QObject::eventFilter(obj, event);
if (keyEvent->key() == Qt::Key_Tab && keyEvent->modifiers() == Qt::NoModifier) {
if (m_ghost->accept()) {
return true;
}
}
if (keyEvent->key() == Qt::Key_Escape) {
m_ghost->dismiss();
return true;
}
}
}
return QObject::eventFilter(obj, event);
}
#include "ollie_kate.moc"