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

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