955 lines
38 KiB
C++
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"
|