From 73e262ffafe3cc32012e35e9f142261bd7707794 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 5 Aug 2026 17:51:27 +0200 Subject: [PATCH] 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 --- CMakeLists.txt | 79 ++++--- dolphin/ollie-session-here | 20 +- kate/chat_renderer.cpp | 20 +- kate/ollie_kate.cpp | 459 +++++++++++++++++++------------------ kate/ollie_kate.h | 19 +- 5 files changed, 333 insertions(+), 264 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 439f371..290fa2a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/dolphin/ollie-session-here b/dolphin/ollie-session-here index d2792c3..f722bf8 100755 --- a/dolphin/ollie-session-here +++ b/dolphin/ollie-session-here @@ -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 diff --git a/kate/chat_renderer.cpp b/kate/chat_renderer.cpp index b3de0b9..49d8cc5 100644 --- a/kate/chat_renderer.cpp +++ b/kate/chat_renderer.cpp @@ -74,7 +74,11 @@ QList 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 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()) { diff --git a/kate/ollie_kate.cpp b/kate/ollie_kate.cpp index 2efe877..2bd9360 100644 --- a/kate/ollie_kate.cpp +++ b/kate/ollie_kate.cpp @@ -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("

No agent selected. Click 'New' to create one.

")); + 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("

No agent selected. Click 'New' to create one.

")); + 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("

No agent selected. Click 'New' to create one.

")); + 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("

Error creating session

"); + 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("

Failed to create agent: ") + + m_9p->lastError().toHtmlEscaped() + QStringLiteral("

")); + 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("

Cannot determine project directory.

")); + if (m_sessionId.isEmpty()) { + m_chatView->setHtml(QStringLiteral("

No session. Use 'New' to create one.

")); 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("

No active sessions found.

")); + + 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("

No agents exist. Use 'New' to create one.

")); 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("

Only one agent exists. Use 'New' to create another.

")); + 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("

No session found with cwd: ") + baseDir.toHtmlEscaped() + QStringLiteral("

")); + + 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("

No active session to rename.

")); - 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("

No agent selected. Click 'New' to create one.

")); + 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("

No agent selected. Click 'New' to create one.

")); + 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(); diff --git a/kate/ollie_kate.h b/kate/ollie_kate.h index 8e90327..9d3eb31 100644 --- a/kate/ollie_kate.h +++ b/kate/ollie_kate.h @@ -21,6 +21,7 @@ #include #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;