This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-kde/kate/ollie_kate.cpp

1433 lines
52 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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")
// --- 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_9p(new Lib9pClient(this))
{
m_renderer->setDiffStorage(&m_diffBlocks);
m_renderer->setApplyStorage(&m_applyBlocks);
// Connect to 9P server
if (!m_9p->connectDefault()) {
qWarning() << "Ollie: failed to connect to 9P server:" << m_9p->lastError();
}
// 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;
m_9p->write(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;
}
// Agent management buttons
auto *buttonBar = new QHBoxLayout();
auto *newBtn = new QPushButton("New", container);
newBtn->setToolTip("Create a new agent in this session");
connect(newBtn, &QPushButton::clicked, this, &OllieKateView::newAgent);
buttonBar->addWidget(newBtn);
auto *pickBtn = new QPushButton("Pick", container);
pickBtn->setToolTip("Switch to another agent in this session");
connect(pickBtn, &QPushButton::clicked, this, &OllieKateView::pickAgent);
buttonBar->addWidget(pickBtn);
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()) {
m_9p->write(agentPath() + "/ctl", "stop");
}
});
promptLayout->addWidget(m_stopBtn);
layout->addLayout(promptLayout);
// Start 9P streaming processes
startChatStream();
startStateStream();
// Connect to existing session/agent if any (read-only)
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);
// Auto-select agent matching the new project
selectAgentForProject();
});
// React to Kate project changes
QObject *projectView = mainWindow->pluginView("kateprojectplugin");
if (projectView) {
QObject::connect(projectView, SIGNAL(projectChanged()),
this, SLOT(onProjectChanged()));
}
#if KTEXTEDITOR_VERSION >= QT_VERSION_CHECK(5, 78, 0)
// Also watch for the project plugin appearing later
connect(mainWindow, &KTextEditor::MainWindow::pluginViewCreated,
this, [this](const QString &name, QObject *view) {
if (name == "kateprojectplugin") {
QObject::connect(view, SIGNAL(projectChanged()),
this, SLOT(onProjectChanged()));
selectAgentForProject();
}
});
#endif
// 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;
}
// --- 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
QByteArray chatData = m_9p->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 + "kate/" + m_agentId + " [" + 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 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;
if (m_agentId.isEmpty()) {
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No agent selected. Click 'New' to create one.</p>"));
return;
}
m_9p->write(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;
if (m_agentId.isEmpty()) {
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No agent selected. Click 'New' to create one.</p>"));
return;
}
QString prompt = "Describe and explain this file: " + filePath;
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()) {
// No selection — explain current line
auto cursor = view->cursorPosition();
selection = view->document()->line(cursor.line());
}
if (selection.isEmpty()) return;
if (m_agentId.isEmpty()) {
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No agent selected. Click 'New' to create one.</p>"));
return;
}
QString filePath = view->document()->url().toLocalFile();
QString context = filePath.isEmpty() ? "" : " (from " + filePath + ")";
QString prompt = "Explain this code" + context + ":\n\n```\n" + selection + "\n```";
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
void OllieKateView::newAgent()
{
// Ensure kate session exists
if (m_sessionId.isEmpty()) {
QString sessionCfg = "name=kate\n";
m_9p->write("session/new", sessionCfg.toUtf8());
m_sessionId = "kate";
}
// If a matching agent already exists for this project, just select it
if (selectAgentForProject()) {
return;
}
// No matching agent — create one
QString cwd = projectBaseDir();
if (cwd.isEmpty()) cwd = QDir::homePath();
QString agentCfg = "cwd=" + cwd + "\n";
QByteArray resp = m_9p->rdwr("session/kate/agent/new", agentCfg.toUtf8());
if (resp.isEmpty()) {
m_chatView->setHtml(QStringLiteral("<p style='color:red;'>Failed to create agent: ") +
m_9p->lastError().toHtmlEscaped() + QStringLiteral("</p>"));
return;
}
QString newId = QString::fromUtf8(resp).trimmed();
switchToAgent(newId);
}
void OllieKateView::pickAgent()
{
if (m_sessionId.isEmpty()) {
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No session. Use 'New' to create one.</p>"));
return;
}
QStringList agents = m_9p->ls("session/" + m_sessionId + "/agent");
agents.removeAll("new"); // Filter out the "new" control file
agents.removeAll("idx"); // Filter out the "idx" index file
if (agents.isEmpty()) {
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No agents exist. Use 'New' to create one.</p>"));
return;
}
if (agents.size() == 1) {
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>Only one agent exists. Use 'New' to create another.</p>"));
return;
}
// Build list of agents with their cwd
QStringList items;
for (const QString &aid : agents) {
QString cfgVal = QString::fromUtf8(m_9p->read("session/" + m_sessionId + "/agent/" + aid + "/cfg")).trimmed();
QString cwd;
for (const QString &line : cfgVal.split(QChar('\n'))) {
if (line.startsWith("cwd=")) {
cwd = line.mid(4);
break;
}
}
QString marker = (aid == m_agentId) ? " ← current" : "";
items << aid + " (" + cwd + ")" + marker;
}
bool ok;
QString selected = QInputDialog::getItem(
m_toolView, "Pick Agent",
"Select an agent:",
items, agents.indexOf(m_agentId), false, &ok);
if (!ok || selected.isEmpty()) return;
// Extract agent ID from selection
QString newAgentId = selected.split(' ').first();
if (newAgentId == m_agentId) return; // Already on this agent
switchToAgent(newAgentId);
}
void OllieKateView::switchToAgent(const QString &agentId)
{
stopStreams();
m_sessionId = "kate";
m_agentId = agentId;
m_rawChat.clear();
m_chatView->clear();
m_chatOffset = 0;
QString stateVal = QString::fromUtf8(m_9p->read(agentPath() + "/state")).trimmed();
QString state = stateVal.isEmpty() ? "idle" : stateVal;
QString dot = (state == "idle") ? "○ " : (state.startsWith("calling") ? "⚡ " : "● ");
m_sessionLabel->setText(dot + "kate/" + m_agentId + " [" + state + "]");
// Get cwd from agent config
QString cfgVal = QString::fromUtf8(m_9p->read(agentPath() + "/cfg")).trimmed();
for (const QString &line : cfgVal.split(QChar('\n'))) {
if (line.startsWith("cwd=")) {
m_cwdLabel->setText(line.mid(4));
break;
}
}
// Fetch existing chat
QByteArray chatData = m_9p->read(agentPath() + "/log");
if (!chatData.isEmpty()) {
m_rawChat = QString::fromUtf8(chatData);
m_chatOffset = chatData.size();
m_renderer->render(m_chatView, m_rawChat);
}
startChatStream();
startStateStream();
}
bool OllieKateView::selectAgentForProject()
{
QString baseDir = projectBaseDir();
if (baseDir.isEmpty()) return false;
// Check if kate session exists
QByteArray sessCheck = m_9p->read("session/kate/id");
if (sessCheck.isEmpty()) {
return false;
}
m_sessionId = "kate";
// Normalize the project path for comparison
QString canonicalBase = QFileInfo(baseDir).canonicalFilePath();
if (canonicalBase.isEmpty()) canonicalBase = baseDir;
while (canonicalBase.endsWith('/')) canonicalBase.chop(1);
// Read agent index: name\tid\tcwd\tbackend\tmodel per line
QByteArray idxRaw = m_9p->read("session/kate/agent/idx");
QString idxData = QString::fromUtf8(idxRaw).trimmed();
for (const QString &line : idxData.split(QChar('\n'), Qt::SkipEmptyParts)) {
QStringList fields = line.split(QChar('\t'));
if (fields.size() >= 3) {
QString agentId = fields[1];
QString agentCwd = fields[2];
// Normalize agent cwd for comparison
QString canonicalCwd = QFileInfo(agentCwd).canonicalFilePath();
if (canonicalCwd.isEmpty()) canonicalCwd = agentCwd;
while (canonicalCwd.endsWith('/')) canonicalCwd.chop(1);
if (canonicalCwd == canonicalBase) {
if (agentId != m_agentId) {
switchToAgent(agentId);
}
return true;
}
}
}
return false;
}
void OllieKateView::onProjectChanged()
{
selectAgentForProject();
}
// --- 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;
// Get agent ID for this session
QStringList agents = m_9p->ls("session/" + sessionId + "/agent");
agents.removeAll("new");
agents.removeAll("idx");
m_agentId = agents.value(0);
// Fetch state
QString stateVal = QString::fromUtf8(m_9p->read(agentPath() + "/state")).trimmed();
QString state = stateVal.isEmpty() ? "idle" : stateVal;
QString dot = (state == "idle") ? "○ " : (state.startsWith("calling") ? "⚡ " : "● ");
m_sessionLabel->setText(dot + m_sessionId + "/" + m_agentId + " [" + state + "]");
// Fetch cwd
QString cfgVal = QString::fromUtf8(m_9p->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 = m_9p->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();
}
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()
{
// At startup, try to select agent matching current project
selectAgentForProject();
}
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
if (m_ghost) {
delete m_ghost;
m_ghost = nullptr;
}
// Create ghost provider with 9P-based completion
m_ghost = new OllieGhostProvider(view, m_plugin, this);
// 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;
if (m_agentId.isEmpty()) {
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No agent selected. Click 'New' to create one.</p>"));
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```";
m_9p->write(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;
if (m_agentId.isEmpty()) {
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No agent selected. Click 'New' to create one.</p>"));
return;
}
QString prompt = QStringLiteral("Review this diff. Point out potential issues, bugs, "
"or improvements:\n\n```diff\n") + diff + QStringLiteral("\n```");
m_9p->write(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::agentPath() const
{
if (m_sessionId.isEmpty() || m_agentId.isEmpty()) return {};
return "session/" + m_sessionId + "/agent/" + m_agentId;
}
// ninepBin and serverAddr only used for streaming processes
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";
}
static QString 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";
}
// --- 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 + "kate/" + m_agentId + " [" + 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"