kate: fix agent selection, block rendering, project change detection

- newAgent() is idempotent: selects existing agent by cwd if one exists
- Use rdwr() to get new agent ID from server instead of ls() ordering
- selectAgentForProject() returns bool for caller branching
- Connect to Kate project plugin projectChanged signal
- Update chat block parser to [[[type]]] / [[[end]]] format
- Guard pluginViewCreated for KF5 < 5.78
This commit is contained in:
Levi Neely 2026-08-05 17:51:27 +02:00
parent 2c7b5e6fc5
commit 73e262ffaf
5 changed files with 333 additions and 264 deletions

View File

@ -60,19 +60,30 @@ if(OLLIE_KF5)
Qt5::Widgets
)
# --- Kate Plugin (disabled — needs rework) ---
# kcoreaddons_add_plugin(ollie_kate SOURCES kate/ollie_kate.cpp kate/ollie_ghost.cpp kate/chat_renderer.cpp INSTALL_NAMESPACE "ktexteditor")
# target_link_libraries(ollie_kate
# KF5::TextEditor
# KF5::SyntaxHighlighting
# Qt5::Core
# Qt5::Widgets
# )
# if(HAVE_KATE_DIFF)
# target_compile_definitions(ollie_kate PRIVATE HAVE_KATE_DIFF)
# target_include_directories(ollie_kate PRIVATE ${CMAKE_SOURCE_DIR}/kate)
# target_link_libraries(ollie_kate ${KATEPRIVATE_LIB})
# endif()
# --- Kate Plugin ---
kcoreaddons_add_plugin(ollie_kate SOURCES
kate/ollie_kate.cpp
kate/ollie_ghost.cpp
kate/chat_renderer.cpp
gui/lib9pclient.cpp
INSTALL_NAMESPACE "ktexteditor"
)
target_include_directories(ollie_kate PRIVATE
${CMAKE_SOURCE_DIR}/gui
$ENV{HOME}/.local/include
)
target_link_libraries(ollie_kate
KF5::TextEditor
KF5::SyntaxHighlighting
Qt5::Core
Qt5::Widgets
$ENV{HOME}/.local/lib/libollie9p.so
)
if(HAVE_KATE_DIFF)
target_compile_definitions(ollie_kate PRIVATE HAVE_KATE_DIFF)
target_include_directories(ollie_kate PRIVATE ${CMAKE_SOURCE_DIR}/kate)
target_link_libraries(ollie_kate ${KATEPRIVATE_LIB})
endif()
# --- GUI App (9P-based) ---
# Strip Qt6-only grouped palette syntax (palette.active.*, palette.inactive.*,
@ -131,22 +142,32 @@ else()
Qt6::Widgets
)
# --- Kate Plugin (disabled — needs rework) ---
# find_package(KF6TextEditor REQUIRED)
# find_package(KF6SyntaxHighlighting REQUIRED)
# kcoreaddons_add_plugin(ollie_kate SOURCES kate/ollie_kate.cpp kate/ollie_ghost.cpp kate/chat_renderer.cpp INSTALL_NAMESPACE "kf6/ktexteditor")
# target_link_libraries(ollie_kate
# KF6::TextEditor
# KF6::SyntaxHighlighting
# Qt6::Core
# Qt6::DBus
# Qt6::Widgets
# )
# if(HAVE_KATE_DIFF)
# target_compile_definitions(ollie_kate PRIVATE HAVE_KATE_DIFF)
# target_include_directories(ollie_kate PRIVATE ${CMAKE_SOURCE_DIR}/kate)
# target_link_libraries(ollie_kate ${KATEPRIVATE_LIB})
# endif()
# --- Kate Plugin ---
find_package(KF6TextEditor REQUIRED)
kcoreaddons_add_plugin(ollie_kate SOURCES
kate/ollie_kate.cpp
kate/ollie_ghost.cpp
kate/chat_renderer.cpp
gui/lib9pclient.cpp
INSTALL_NAMESPACE "kf6/ktexteditor"
)
target_include_directories(ollie_kate PRIVATE
${CMAKE_SOURCE_DIR}/gui
$ENV{HOME}/.local/include
)
target_link_libraries(ollie_kate
KF6::TextEditor
KF6::SyntaxHighlighting
Qt6::Core
Qt6::DBus
Qt6::Widgets
$ENV{HOME}/.local/lib/libollie9p.so
)
if(HAVE_KATE_DIFF)
target_compile_definitions(ollie_kate PRIVATE HAVE_KATE_DIFF)
target_include_directories(ollie_kate PRIVATE ${CMAKE_SOURCE_DIR}/kate)
target_link_libraries(ollie_kate ${KATEPRIVATE_LIB})
endif()
# --- GUI App (9P-based, no D-Bus) ---
find_package(KF6SyntaxHighlighting REQUIRED)

View File

@ -1,21 +1,27 @@
#!/bin/sh
# ollie-session-here: start a session in the given directory
# Works from both Dolphin (passes %f) and terminal (uses $PWD).
#
# Session name is the base directory name (not full path).
# Session creation is idempotent — always write to session/new.
TARGET="$1"
[ -f "$TARGET" ] && TARGET="$(dirname "$TARGET")"
[ -z "$TARGET" ] && TARGET="$PWD"
TARGET="$(cd "$TARGET" 2>/dev/null && pwd)" || TARGET="$PWD"
RESULT=$(dbus-send --session --dest=org.ollie.SessionManager --type=method_call --print-reply \
/org/ollie/SessionManager org.ollie.SessionManager.CreateSession \
string:"$TARGET" string:"" string:"" string:"default" string:"" string:"" 2>&1)
# Extract base directory name for session name
SESSION_NAME="$(basename "$TARGET")"
SESSION_ID=$(echo "$RESULT" | grep 'string "' | head -1 | sed 's/.*string "\(.*\)"/\1/')
# Create session (idempotent) and agent via 9P
printf 'name=%s\ncwd=%s\n' "$SESSION_NAME" "$TARGET" | ollie-9p write session/new
printf '' | ollie-9p write "session/$SESSION_NAME/agent/new"
if [ -t 1 ]; then
# Terminal: print session ID for further use
echo "$SESSION_ID"
# Terminal: print session info
echo "Session: $SESSION_NAME"
echo "CWD: $TARGET"
else
# Dolphin/non-interactive: desktop notification
notify-send -t 3000 "Ollie" "Session started in $TARGET"
notify-send -t 3000 "Ollie" "Session '$SESSION_NAME' started in $TARGET"
fi

View File

@ -74,7 +74,11 @@ QList<ChatBlock> ChatRenderer::parseBlocks(const QString &text) const
if (text.isEmpty()) return blocks;
static const QRegularExpression headerRe(
R"(^\[(user|assistant|reasoning|call|tool|error|info)(?::([^\]]*))?\](.*)$)",
R"(^\[\[\[(user|assistant|reasoning|call|tool|error|info)(?::([^\]]*))?\]\]\](.*)$)",
QRegularExpression::MultilineOption);
static const QRegularExpression endRe(
R"(^\[\[\[end\]\]\]\s*$)",
QRegularExpression::MultilineOption);
auto it = headerRe.globalMatch(text);
@ -99,7 +103,19 @@ QList<ChatBlock> ChatRenderer::parseBlocks(const QString &text) const
QString inlineContent = match.captured(3);
int contentStart = match.capturedEnd();
int contentEnd = (i + 1 < headers.size()) ? headers[i + 1].first : text.size();
// Find the [[[end]]] marker after this header (before next header)
int searchEnd = (i + 1 < headers.size()) ? headers[i + 1].first : text.size();
QString segment = text.mid(contentStart, searchEnd - contentStart);
auto endMatch = endRe.match(segment);
int contentEnd;
if (endMatch.hasMatch()) {
contentEnd = contentStart + endMatch.capturedStart();
} else {
// No [[[end]]] found — treat rest until next header as content (streaming)
contentEnd = searchEnd;
}
QString content;
if (typeStr == "info" && !inlineContent.trimmed().isEmpty()) {

View File

@ -39,16 +39,6 @@
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
@ -271,10 +261,15 @@ OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *m
, 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);
// 9P: no interface object needed — we use run9p/write9p helpers
// 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(
@ -304,7 +299,7 @@ OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *m
killBtn->setFlat(true);
connect(killBtn, &QPushButton::clicked, this, [this]() {
if (m_sessionId.isEmpty()) return;
write9p(agentPath() + "/ctl", "kill");
m_9p->write(agentPath() + "/ctl", "kill");
});
headerLayout->addWidget(killBtn);
layout->addLayout(headerLayout);
@ -339,25 +334,19 @@ OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *m
m_completionStatus = nullptr;
}
// Button bar
// Agent management buttons
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);
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 an existing session matching the project directory");
connect(pickBtn, &QPushButton::clicked, this, &OllieKateView::pickSession);
pickBtn->setToolTip("Switch to another agent in this session");
connect(pickBtn, &QPushButton::clicked, this, &OllieKateView::pickAgent);
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);
@ -429,7 +418,7 @@ OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *m
m_stopBtn->setFixedWidth(30);
connect(m_stopBtn, &QPushButton::clicked, this, [this]() {
if (!m_sessionId.isEmpty()) {
write9p(agentPath() + "/ctl", "stop");
m_9p->write(agentPath() + "/ctl", "stop");
}
});
promptLayout->addWidget(m_stopBtn);
@ -440,7 +429,7 @@ OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *m
startChatStream();
startStateStream();
// Pick up existing session if any
// Connect to existing session/agent if any (read-only)
refreshSessions();
// Set up ghost-text completion on active view
@ -449,8 +438,28 @@ OllieKateView::OllieKateView(OllieKatePlugin *plugin, KTextEditor::MainWindow *m
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);
@ -480,8 +489,7 @@ void OllieKateView::onChatUpdated(const QString &session_id, qlonglong offset, c
annotateFileReferences(m_rawChat);
} else {
// Offset mismatch — refetch
// Refetch via 9P
QByteArray chatData = run9p({"read", agentPath() + "/log"});
QByteArray chatData = m_9p->read(agentPath() + "/log");
if (!chatData.isEmpty()) {
m_rawChat = QString::fromUtf8(chatData);
m_chatOffset = chatData.size();
@ -499,7 +507,7 @@ void OllieKateView::onStateChanged(const QString &session_id, const QString &new
if (new_state == "idle") dot = "○ ";
else if (new_state.startsWith("calling")) dot = "⚡ ";
else dot = "● ";
m_sessionLabel->setText(dot + m_sessionId + " [" + new_state + "]");
m_sessionLabel->setText(dot + "kate/" + m_agentId + " [" + new_state + "]");
}
void OllieKateView::onSessionCreated(const QString &session_id)
@ -535,10 +543,12 @@ void OllieKateView::submitPrompt()
QString text = m_promptInput->toPlainText().trimmed();
if (text.isEmpty()) return;
ensureSession();
if (m_sessionId.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;
}
write9p(agentPath() + "/prompt", text.toUtf8());
m_9p->write(agentPath() + "/prompt", text.toUtf8());
m_promptInput->clear();
}
@ -550,11 +560,13 @@ void OllieKateView::askAboutFile()
QString filePath = view->document()->url().toLocalFile();
if (filePath.isEmpty()) return;
ensureSession();
if (m_sessionId.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;
write9p(agentPath() + "/prompt", prompt.toUtf8());
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
void OllieKateView::explainSelection()
@ -570,112 +582,180 @@ void OllieKateView::explainSelection()
}
if (selection.isEmpty()) return;
ensureSession();
if (m_sessionId.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```";
write9p(agentPath() + "/prompt", prompt.toUtf8());
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
void OllieKateView::newSession()
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 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>");
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::pickSession()
void OllieKateView::pickAgent()
{
QString baseDir = projectBaseDir();
if (baseDir.isEmpty()) {
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>Cannot determine project directory.</p>"));
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 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>"));
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;
}
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 (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=")) {
QString cwd = line.mid(4);
if (cwd == baseDir) {
switchToSession(id);
return;
}
cwd = line.mid(4);
break;
}
}
QString marker = (aid == m_agentId) ? " ← current" : "";
items << aid + " (" + cwd + ")" + marker;
}
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No session found with cwd: ") + baseDir.toHtmlEscaped() + QStringLiteral("</p>"));
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::renameSession()
void OllieKateView::switchToAgent(const QString &agentId)
{
if (m_sessionId.isEmpty()) {
m_chatView->setHtml(QStringLiteral("<p style='color:gray;font-style:italic;'>No active session to rename.</p>"));
return;
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;
}
}
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 + "]");
// 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 ---
@ -706,14 +786,20 @@ void OllieKateView::switchToSession(const QString &sessionId)
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(run9p({"read", agentPath() + "/state"})).trimmed();
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 + " [" + state + "]");
m_sessionLabel->setText(dot + m_sessionId + "/" + m_agentId + " [" + state + "]");
// Fetch cwd
QString cfgVal = QString::fromUtf8(run9p({"read", agentPath() + "/cfg"})).trimmed();
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=")) {
@ -723,7 +809,7 @@ void OllieKateView::switchToSession(const QString &sessionId)
}
}
// Fetch existing chat via 9P
QByteArray chatData = run9p({"read", agentPath() + "/log"});
QByteArray chatData = m_9p->read(agentPath() + "/log");
if (!chatData.isEmpty()) {
m_rawChat = QString::fromUtf8(chatData);
m_chatOffset = chatData.size();
@ -736,14 +822,6 @@ void OllieKateView::switchToSession(const QString &sessionId)
startStateStream();
}
void OllieKateView::ensureSession()
{
if (!m_sessionId.isEmpty()) return;
// 9P is always reachable — skip daemon check
newSession();
newSession();
}
QString OllieKateView::activeSessionId()
{
return m_sessionId;
@ -894,53 +972,8 @@ void OllieKateView::showDiffBlock(int index)
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]);
}
// At startup, try to select agent matching current project
selectAgentForProject();
}
void OllieKateView::setupGhostProvider()
@ -954,14 +987,14 @@ void OllieKateView::setupGhostProvider()
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());
if (m_ghost) {
delete m_ghost;
m_ghost = nullptr;
}
// Ghost provider disabled until 9P completion is implemented
// TODO: create ghost provider with 9P-based completion
// Install event filter on the view widget for Tab interception
if (QWidget *proxy = view->focusProxy()) {
proxy->installEventFilter(this);
@ -1143,8 +1176,10 @@ void OllieKateView::submitSelectionPrompt(const QString &action)
QString selection = view->selectionText();
if (selection.isEmpty()) return;
ensureSession();
if (m_sessionId.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();
@ -1154,7 +1189,7 @@ void OllieKateView::submitSelectionPrompt(const QString &action)
}
QString prompt = action + " this code" + location + ":\n\n```\n" + selection + "\n```";
write9p(agentPath() + "/prompt", prompt.toUtf8());
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
void OllieKateView::reviewDiff()
@ -1178,12 +1213,14 @@ void OllieKateView::reviewDiff()
if (diff.trimmed().isEmpty()) return;
ensureSession();
if (m_sessionId.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```");
write9p(agentPath() + "/prompt", prompt.toUtf8());
m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
// --- Apply Code Blocks ---
@ -1300,7 +1337,24 @@ void OllieKateView::annotateFileReferences(const QString &chatText)
// --- 9P helpers ---
QString OllieKateView::serverAddr()
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()) {
@ -1311,35 +1365,6 @@ QString OllieKateView::serverAddr()
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()
@ -1372,7 +1397,7 @@ void OllieKateView::startStateStream()
QStringList lines = state.split('\n');
state = lines.last().trimmed();
QString dot = (state == "idle") ? "○ " : (state.startsWith("calling") ? "⚡ " : "● ");
m_sessionLabel->setText(dot + m_sessionId + " [" + state + "]");
m_sessionLabel->setText(dot + "kate/" + m_agentId + " [" + state + "]");
}
// statewait returns once per state change — restart
m_stateProc->deleteLater();

View File

@ -21,6 +21,7 @@
#include <KTextEditor/Attribute>
#include "chat_renderer.h"
#include "lib9pclient.h"
class QPushButton;
class OllieKateView;
@ -90,17 +91,18 @@ private slots:
void onStateChanged(const QString &session_id, const QString &new_state);
void onSessionCreated(const QString &session_id);
void onSessionKilled(const QString &session_id);
void onProjectChanged();
void submitPrompt();
void askAboutFile();
void explainSelection();
void newSession();
void pickSession();
void renameSession();
void newAgent();
void pickAgent();
void refreshSessions();
private:
void ensureSession();
void switchToAgent(const QString &agentId);
bool selectAgentForProject(); // Auto-select agent matching project basedir
QString activeSessionId();
OllieKatePlugin *m_plugin;
@ -117,11 +119,9 @@ private:
QPushButton *m_stopBtn;
QPushButton *m_scrollBottomBtn;
// 9P helpers
QByteArray run9p(const QStringList &args);
void write9p(const QString &path, const QByteArray &data);
QString serverAddr();
QString agentPath();
// 9P client (direct connection, no shell-out)
Lib9pClient *m_9p;
QString agentPath() const; // Returns "session/kate/agent/{m_agentId}"
// Streaming processes
QProcess *m_chatProc = nullptr;
@ -131,6 +131,7 @@ private:
void stopStreams();
QString m_sessionId;
QString m_agentId; // Agent within the kate session
qlonglong m_chatOffset = 0;
QString m_rawChat; // accumulated raw chat text
ChatRenderer *m_renderer;