diff --git a/.gitignore b/.gitignore index 809a46f..318e859 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ build/ bin/ +# Development tooling +.clangd +compile_commands.json + # CMake files CMakeCache.txt CMakeFiles/ diff --git a/CLAUDE.md b/CLAUDE.md index e7499a4..0b137da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Kate-Code Plugin -A Kate text editor plugin that wraps Claude Code using claude-code-acp. +A Kate text editor plugin that wraps Claude Code using claude-agent-acp. ## Build Instructions @@ -22,7 +22,7 @@ Settings > Configure Kate > Plugins > Enable "Kate Code" ### Layer Structure - **Plugin**: KateCodePlugin, KateCodeView - Kate integration -- **ACP**: ACPService, ACPSession - JSON-RPC 2.0 over stdin/stdout to claude-code-acp +- **ACP**: ACPService, ACPSession - JSON-RPC 2.0 over stdin/stdout to claude-agent-acp - **MCP**: MCPServer - Built-in MCP server executable for Kate editor tools - **UI**: ChatWidget, ChatWebView, ChatInputWidget, PermissionDialog - **Util**: KDEColorScheme - reads ~/.config/kdeglobals @@ -30,7 +30,7 @@ Settings > Configure Kate > Plugins > Enable "Kate Code" ### Key Files - `src/plugin/KateCodePlugin.{h,cpp}` - Plugin registration via K_PLUGIN_CLASS_WITH_JSON - `src/plugin/KateCodeView.{h,cpp}` - Creates side panel tool view, provides Kate context -- `src/acp/ACPService.{h,cpp}` - QProcess-based claude-code-acp subprocess management +- `src/acp/ACPService.{h,cpp}` - QProcess-based claude-agent-acp subprocess management - `src/acp/ACPSession.{h,cpp}` - Protocol flow and session state management - `src/acp/ACPModels.h` - Data structures (Message, ToolCall, TodoItem, etc.) - `src/mcp/MCPServer.{h,cpp}` - MCP protocol handler (initialize, tools/list, tools/call) diff --git a/CMakeLists.txt b/CMakeLists.txt index 76343a1..8f8f94c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(katecode VERSION 1.0.0) +project(katecode VERSION 1.0.1) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -21,6 +21,7 @@ find_package(KF6 ${REQUIRED_KF_VERSION} REQUIRED COMPONENTS SyntaxHighlighting Wallet Pty + Parts ) find_package(Qt6 REQUIRED COMPONENTS diff --git a/PKGBUILD b/PKGBUILD index 7bf1d11..e0fa0fe 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,10 +1,10 @@ # Maintainer: Your Name pkgname=kate-code -pkgver=1.0.0 +pkgver=1.0.1 pkgrel=1 pkgdesc="Claude Code integration for Kate text editor" arch=('x86_64') -url="https://github.com/undefinedopcode/kate-code" +url="https://github.com/molove/kate-code" license=('MIT') depends=( 'ktexteditor' @@ -15,6 +15,7 @@ depends=( 'kwallet' 'kpty' 'qt6-webengine' + 'ttf-material-symbols-variable' ) makedepends=( 'cmake' @@ -24,7 +25,7 @@ makedepends=( optdepends=( 'claude-code-acp: Required for Claude Code functionality' ) -source=("${pkgname}::git+https://github.com/undefinedopcode/kate-code.git") +source=("${pkgname}::git+https://github.com/molove/kate-code.git") sha256sums=('SKIP') build() { diff --git a/README.md b/README.md index a7b3b7a..c2c4a43 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ See what Claude is doing with inline tool call displays: - Image paste support: paste images from clipboard directly into chat messages ### Architecture -- **ACP Protocol**: JSON-RPC 2.0 over stdin/stdout with `claude-code-acp` subprocess +- **ACP Protocol**: JSON-RPC 2.0 over stdin/stdout with `claude-agent-acp` subprocess - **Qt WebChannel**: Bidirectional C++/JavaScript bridge for real-time UI updates - **Web UI**: HTML/CSS/JS rendered in Qt WebEngineView for rich formatting @@ -57,18 +57,18 @@ See what Claude is doing with inline tool call displays: - C++17 compatible compiler ### Runtime Dependencies -- `claude-code-acp` binary installed and available in PATH - - Install from: https://github.com/zed-industries/claude-code-acp +- `claude-agent-acp` binary installed and available in PATH + - Install from: https://github.com/agentclientprotocol/claude-agent-acp ## Installation -### Install claude-code-acp +### Install claude-agent-acp -Follow the instructions at https://github.com/zed-industries/claude-code-acp to install the ACP binary. +Follow the instructions at https://github.com/agentclientprotocol/claude-agent-acp to install the ACP binary (the installed binary is named `claude-agent-acp`). Verify installation: ```bash -which claude-code-acp +which claude-agent-acp ``` ### Option 1: Install from Package (Recommended) @@ -131,8 +131,8 @@ cmake -B build -DCMAKE_BUILD_TYPE=Release # Build cmake --build build -# Install to system (requires sudo) -sudo cmake --install build +# Install to system (requires sudo) - Arch/Fedora/most distros need --prefix /usr +sudo cmake --install build --prefix /usr # Or install to user directory cmake --install build --prefix ~/.local @@ -156,7 +156,7 @@ The plugin will be installed to: ### Starting a Session 1. The Kate Code panel appears in Kate's side panel area (left or right sidebar) -2. Click the **Connect** button to start a claude-code-acp session +2. Click the **Connect** button to start a claude-agent-acp session 3. The plugin will initialize using your current project's directory as the working directory ### Sending Messages @@ -241,7 +241,7 @@ The plugin automatically adapts to your KDE color scheme by reading `~/.config/k - Restart Kate completely (close all windows) ### Connection fails -- Verify `claude-code-acp` is in PATH: `which claude-code-acp` +- Verify `claude-agent-acp` is in PATH: `which claude-agent-acp` - Look for error messages in terminal when launching Kate from command line: `kate` ### Messages not displaying @@ -321,5 +321,5 @@ Contributions welcome! Please: ## Acknowledgments - Built with Qt 6 and KDE Frameworks 6 -- Integrates with [claude-code-acp](https://github.com/zed-industries/claude-code-acp) by Zed Industries +- Integrates with [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp) by Zed Industries - Markdown rendering by [marked.js](https://marked.js.org/) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 48a6f84..7a48979 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -48,6 +48,7 @@ target_link_libraries(katecode KF6::SyntaxHighlighting KF6::Wallet KF6::Pty + KF6::Parts Qt6::Core Qt6::DBus Qt6::Widgets diff --git a/src/acp/ACPService.cpp b/src/acp/ACPService.cpp index e4ac267..e9ec1e7 100644 --- a/src/acp/ACPService.cpp +++ b/src/acp/ACPService.cpp @@ -10,7 +10,7 @@ ACPService::ACPService(QObject *parent) : QObject(parent) , m_process(nullptr) , m_messageId(0) - , m_executable(QStringLiteral("claude-code-acp")) + , m_executable(QStringLiteral("claude-agent-acp")) { } diff --git a/src/acp/ACPSession.cpp b/src/acp/ACPSession.cpp index 04735f3..e2f2219 100644 --- a/src/acp/ACPSession.cpp +++ b/src/acp/ACPSession.cpp @@ -233,7 +233,6 @@ void ACPSession::createNewSession() if (!mcpServerPath.isEmpty() && QFileInfo::exists(mcpServerPath)) { QJsonObject kateMcp; - kateMcp[QStringLiteral("type")] = QStringLiteral("stdio"); kateMcp[QStringLiteral("name")] = QStringLiteral("kate"); kateMcp[QStringLiteral("command")] = mcpServerPath; kateMcp[QStringLiteral("args")] = QJsonArray(); @@ -269,6 +268,27 @@ void ACPSession::loadSession(const QString &sessionId) params[QStringLiteral("sessionId")] = sessionId; params[QStringLiteral("cwd")] = m_workingDir; + // Include mcpServers so Kate tools are available in the resumed session + QJsonArray mcpServers; + QString mcpServerPath; +#ifdef KATE_MCP_SERVER_PATH + mcpServerPath = QStringLiteral(KATE_MCP_SERVER_PATH); +#endif + if (mcpServerPath.isEmpty() || !QFileInfo::exists(mcpServerPath)) { + const QString found = QStandardPaths::findExecutable(QStringLiteral("kate-mcp-server")); + if (!found.isEmpty()) + mcpServerPath = found; + } + if (!mcpServerPath.isEmpty() && QFileInfo::exists(mcpServerPath)) { + QJsonObject kateMcp; + kateMcp[QStringLiteral("name")] = QStringLiteral("kate"); + kateMcp[QStringLiteral("command")] = mcpServerPath; + kateMcp[QStringLiteral("args")] = QJsonArray(); + kateMcp[QStringLiteral("env")] = QJsonArray(); + mcpServers.append(kateMcp); + } + params[QStringLiteral("mcpServers")] = mcpServers; + m_sessionLoadRequestId = m_service->sendRequest(QStringLiteral("session/load"), params); qDebug() << "[ACPSession] Sent session/load request, id:" << m_sessionLoadRequestId; } @@ -1728,6 +1748,23 @@ void ACPSession::handleFsWriteTextFile(const QJsonObject ¶ms, int requestId) // Check if this is a new file bool isNewFile = !QFile::exists(path); + // Capture old content for diff display before writing + QString oldContent; + if (!isNewFile) { + if (m_documentProvider) { + KTextEditor::Document *doc = m_documentProvider(path); + if (doc) { + oldContent = doc->text(); + } + } + if (oldContent.isEmpty()) { + QFile oldFile(path); + if (oldFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + oldContent = QString::fromUtf8(oldFile.readAll()); + } + } + } + // Try to write through Kate document if open if (m_documentProvider) { KTextEditor::Document *doc = m_documentProvider(path); @@ -1797,6 +1834,11 @@ void ACPSession::handleFsWriteTextFile(const QJsonObject ¶ms, int requestId) } } + // Emit for diff display (only meaningful if content actually changed) + if (!isNewFile && content != oldContent) { + Q_EMIT fsEditApplied(path, oldContent, content); + } + QJsonObject result; result[QStringLiteral("result")] = QJsonValue::Null; m_service->sendResponse(requestId, result); diff --git a/src/acp/ACPSession.h b/src/acp/ACPSession.h index b6f6f39..83ae595 100644 --- a/src/acp/ACPSession.h +++ b/src/acp/ACPSession.h @@ -83,6 +83,9 @@ class ACPSession : public QObject void terminalOutputUpdated(const QString &terminalId, const QString &output, bool finished); void toolCallTerminalIdSet(const QString &messageId, const QString &toolCallId, const QString &terminalId); + // Emitted when a built-in Edit/Write tool writes a file, with old and new content for diff display + void fsEditApplied(const QString &filePath, const QString &oldText, const QString &newText); + private Q_SLOTS: void onConnected(); void onDisconnected(int exitCode); diff --git a/src/config/SettingsStore.cpp b/src/config/SettingsStore.cpp index a0363c7..b89fd16 100644 --- a/src/config/SettingsStore.cpp +++ b/src/config/SettingsStore.cpp @@ -194,7 +194,7 @@ void SettingsStore::setAutoResumeSessions(bool enable) QList SettingsStore::builtinProviders() const { return { - {QStringLiteral("claude-code"), QStringLiteral("Claude Code"), QStringLiteral("claude-code-acp"), QString(), true}, + {QStringLiteral("claude-code"), QStringLiteral("Claude Code"), QStringLiteral("claude-agent-acp"), QString(), true}, {QStringLiteral("vibe-mistral"), QStringLiteral("Vibe (Mistral)"), QStringLiteral("vibe-acp"), QString(), true}, }; } diff --git a/src/katecode.json b/src/katecode.json index 9790b4f..4753e83 100644 --- a/src/katecode.json +++ b/src/katecode.json @@ -11,7 +11,7 @@ "Id": "katecode", "License": "MIT", "Name": "Kate Code", - "Version": "1.0.0", + "Version": "1.0.1", "Website": "https://github.com/anthropics/claude-code" } } diff --git a/src/mcp/EditorDBusService.cpp b/src/mcp/EditorDBusService.cpp index 0babecd..2247fad 100644 --- a/src/mcp/EditorDBusService.cpp +++ b/src/mcp/EditorDBusService.cpp @@ -5,18 +5,22 @@ #include "EditorDBusService.h" +#include #include #include #include #include #include +#include #include #include #include #include #include +#include #include #include +#include EditorDBusService::EditorDBusService(QObject *parent) : QObject(parent) @@ -114,6 +118,15 @@ QString EditorDBusService::editDocument(const QString &filePath, const QString & doc = view->document(); } + // Handle empty oldText: only allowed if the document is empty + if (oldText.isEmpty()) { + if (!doc->text().isEmpty()) { + return QStringLiteral("ERROR: old_string is empty but document is not — use a non-empty old_string to make a targeted edit"); + } + bool success = doc->setText(newText); + return success ? QStringLiteral("OK") : QStringLiteral("ERROR: Failed to write to document"); + } + // Find and replace the text QString content = doc->text(); int pos = content.indexOf(oldText); @@ -154,6 +167,7 @@ QString EditorDBusService::editDocument(const QString &filePath, const QString & return QStringLiteral("ERROR: Edit succeeded but failed to save document"); } + Q_EMIT editApplied(filePath, oldText, newText); return QStringLiteral("OK"); } @@ -265,6 +279,194 @@ QString EditorDBusService::askUserQuestion(const QString &questionsJson) return response; } +QString EditorDBusService::getActiveDocument() +{ + KTextEditor::Application *app = KTextEditor::Editor::instance()->application(); + if (!app) + return QStringLiteral("ERROR: KTextEditor application not available"); + KTextEditor::MainWindow *mainWindow = app->activeMainWindow(); + if (!mainWindow) + return QStringLiteral("ERROR: No active main window"); + KTextEditor::View *view = mainWindow->activeView(); + if (!view) + return QStringLiteral("ERROR: No active view"); + KTextEditor::Document *doc = view->document(); + + QString path = doc->url().toLocalFile(); + KTextEditor::Cursor cursor = view->cursorPosition(); + QString selText = view->selectionText(); + + QJsonObject result; + result[QStringLiteral("path")] = path.isEmpty() + ? QStringLiteral("untitled:") + doc->documentName() + : path; + result[QStringLiteral("line")] = cursor.line() + 1; + result[QStringLiteral("column")] = cursor.column() + 1; + result[QStringLiteral("isModified")] = doc->isModified(); + if (!selText.isEmpty()) + result[QStringLiteral("selection")] = selText; + + return QString::fromUtf8(QJsonDocument(result).toJson(QJsonDocument::Compact)); +} + +QString EditorDBusService::openDocument(const QString &filePath, int line, int column) +{ + KTextEditor::Application *app = KTextEditor::Editor::instance()->application(); + if (!app) + return QStringLiteral("ERROR: KTextEditor application not available"); + KTextEditor::MainWindow *mainWindow = app->activeMainWindow(); + if (!mainWindow) + return QStringLiteral("ERROR: No active main window"); + + QUrl url = QUrl::fromLocalFile(filePath); + KTextEditor::View *view = mainWindow->openUrl(url); + if (!view) + return QStringLiteral("ERROR: Could not open file: %1").arg(filePath); + + if (line > 0) { + int col = column > 0 ? column - 1 : 0; + view->setCursorPosition(KTextEditor::Cursor(line - 1, col)); + } + return QStringLiteral("OK"); +} + +QString EditorDBusService::closeDocument(const QString &filePath) +{ + KTextEditor::Application *app = KTextEditor::Editor::instance()->application(); + if (!app) + return QStringLiteral("ERROR: KTextEditor application not available"); + + QUrl url = QUrl::fromLocalFile(filePath); + KTextEditor::Document *doc = app->findUrl(url); + if (!doc) + return QStringLiteral("ERROR: Document not open: %1").arg(filePath); + if (!app->closeDocument(doc)) + return QStringLiteral("ERROR: Failed to close document (user may have cancelled)"); + return QStringLiteral("OK"); +} + +QString EditorDBusService::saveDocument(const QString &filePath) +{ + KTextEditor::Application *app = KTextEditor::Editor::instance()->application(); + if (!app) + return QStringLiteral("ERROR: KTextEditor application not available"); + + if (filePath.isEmpty()) { + QStringList failed; + for (KTextEditor::Document *doc : app->documents()) { + if (doc->isModified() && !doc->save()) + failed.append(doc->url().toLocalFile()); + } + if (!failed.isEmpty()) + return QStringLiteral("ERROR: Failed to save: %1").arg(failed.join(QStringLiteral(", "))); + return QStringLiteral("OK"); + } + + QUrl url = QUrl::fromLocalFile(filePath); + KTextEditor::Document *doc = app->findUrl(url); + if (!doc) + return QStringLiteral("ERROR: Document not open: %1").arg(filePath); + if (!doc->save()) + return QStringLiteral("ERROR: Failed to save document"); + return QStringLiteral("OK"); +} + +QString EditorDBusService::getDocumentStatus(const QString &filePath) +{ + KTextEditor::Application *app = KTextEditor::Editor::instance()->application(); + if (!app) + return QStringLiteral("ERROR: KTextEditor application not available"); + + QUrl url = QUrl::fromLocalFile(filePath); + KTextEditor::Document *doc = app->findUrl(url); + if (!doc) + return QStringLiteral("ERROR: Document not open: %1").arg(filePath); + + QJsonObject result; + result[QStringLiteral("path")] = filePath; + result[QStringLiteral("isModified")] = doc->isModified(); + result[QStringLiteral("isReadOnly")] = !doc->isReadWrite(); + return QString::fromUtf8(QJsonDocument(result).toJson(QJsonDocument::Compact)); +} + +QString EditorDBusService::revertDocument(const QString &filePath) +{ + KTextEditor::Application *app = KTextEditor::Editor::instance()->application(); + if (!app) + return QStringLiteral("ERROR: KTextEditor application not available"); + + QUrl url = QUrl::fromLocalFile(filePath); + KTextEditor::Document *doc = app->findUrl(url); + if (!doc) + return QStringLiteral("ERROR: Document not open: %1").arg(filePath); + if (!doc->documentReload()) + return QStringLiteral("ERROR: Failed to revert document"); + return QStringLiteral("OK"); +} + +QString EditorDBusService::setSessionNote(const QString &sessionId, const QString ¬e) +{ + if (sessionId.isEmpty()) { + return QStringLiteral("ERROR: sessionId is required"); + } + Q_EMIT sessionNoteUpdateRequested(sessionId, note); + return QStringLiteral("OK"); +} + +QString EditorDBusService::getSessionId() +{ + return m_currentSessionId; +} + +void EditorDBusService::updateCurrentSessionId(const QString &sessionId) +{ + m_currentSessionId = sessionId; +} + +QString EditorDBusService::getClipboardText() +{ + QClipboard *clipboard = QGuiApplication::clipboard(); + if (!clipboard) + return QStringLiteral("ERROR: Clipboard not available"); + return clipboard->text(); +} + +QString EditorDBusService::pasteToTerminal(const QString &text) +{ + KTextEditor::Application *app = KTextEditor::Editor::instance()->application(); + if (!app) + return QStringLiteral("ERROR: KTextEditor application not available"); + + KTextEditor::MainWindow *mainWindow = app->activeMainWindow(); + if (!mainWindow) + return QStringLiteral("ERROR: No active main window"); + + // Kate's terminal plugin registers itself as "katekonsoleplugin" + QObject *terminalView = mainWindow->pluginView(QStringLiteral("katekonsoleplugin")); + if (!terminalView) + return QStringLiteral("ERROR: Kate terminal plugin not available or not enabled"); + + // The KonsolePart may not be a direct Qt-child of the plugin view — it is often + // parented to a container widget deeper in the hierarchy. Search the entire main + // window widget tree so we find it wherever it lives. + TerminalInterface *terminal = nullptr; + QWidget *win = mainWindow->window(); + if (win) { + const auto allObjs = win->findChildren(QString(), Qt::FindChildrenRecursively); + for (QObject *obj : allObjs) { + terminal = qobject_cast(obj); + if (terminal) + break; + } + } + + if (!terminal) + return QStringLiteral("ERROR: Could not find TerminalInterface in terminal plugin"); + + terminal->sendInput(text); + return QStringLiteral("OK"); +} + void EditorDBusService::provideQuestionResponse(const QString &requestId, const QString &responseJson) { qDebug() << "[EditorDBusService] provideQuestionResponse called, requestId:" << requestId; diff --git a/src/mcp/EditorDBusService.h b/src/mcp/EditorDBusService.h index eea5a96..2facb37 100644 --- a/src/mcp/EditorDBusService.h +++ b/src/mcp/EditorDBusService.h @@ -25,6 +25,9 @@ class EditorDBusService : public QObject // Called by UI when user responds to a question void provideQuestionResponse(const QString &requestId, const QString &responseJson); + // Called by ChatWidget when the active session ID changes + void updateCurrentSessionId(const QString &sessionId); + public Q_SLOTS: QStringList listDocuments(); @@ -45,14 +48,54 @@ public Q_SLOTS: // Returns JSON object with answers keyed by question header, or "ERROR: ..." on failure. QString askUserQuestion(const QString &questionsJson); + // Returns JSON with path, line, column, isModified, and optional selection of the active view. + QString getActiveDocument(); + + // Opens filePath in Kate, optionally moving the cursor to line/column (1-based; pass 0 to skip). + QString openDocument(const QString &filePath, int line, int column); + + // Closes the document identified by filePath. + QString closeDocument(const QString &filePath); + + // Saves filePath, or all modified documents if filePath is empty. + QString saveDocument(const QString &filePath); + + // Returns JSON with path, isModified, isReadOnly for the given document. + QString getDocumentStatus(const QString &filePath); + + // Reloads filePath from disk, discarding unsaved changes. + QString revertDocument(const QString &filePath); + + // Sets the note for a session in the session store. + // Returns "OK" on success or "ERROR: ..." on failure. + QString setSessionNote(const QString &sessionId, const QString ¬e); + + // Returns the ID of the currently active kate-code session, or empty string if not connected. + QString getSessionId(); + + // Returns the current clipboard text content. + QString getClipboardText(); + + // Sends text to Kate's embedded terminal without executing it (no Enter). + // Returns "OK" on success or "ERROR: ..." on failure. + QString pasteToTerminal(const QString &text); + Q_SIGNALS: + // Emitted when editDocument succeeds, with the edit parameters + void editApplied(const QString &filePath, const QString &oldText, const QString &newText); + // Emitted when a question needs to be shown to the user void questionRequested(const QString &requestId, const QString &questionsJson); // Emitted when a question times out or is cancelled (UI should remove the prompt) void questionCancelled(const QString &requestId); + // Emitted when a session note should be updated in the session store + void sessionNoteUpdateRequested(const QString &sessionId, const QString ¬e); + private: + QString m_currentSessionId; + // Track pending question requests struct PendingQuestion { QEventLoop *eventLoop; diff --git a/src/mcp/MCPServer.cpp b/src/mcp/MCPServer.cpp index e8d048b..61450df 100644 --- a/src/mcp/MCPServer.cpp +++ b/src/mcp/MCPServer.cpp @@ -268,8 +268,224 @@ QJsonObject MCPServer::handleToolsList(int id, const QJsonObject ¶ms) askUserAnnotations[QStringLiteral("destructiveHint")] = false; askUserTool[QStringLiteral("annotations")] = askUserAnnotations; + // katecode_active_document tool definition + QJsonObject activeDocTool; + activeDocTool[QStringLiteral("name")] = QStringLiteral("katecode_active_document"); + activeDocTool[QStringLiteral("description")] = + QStringLiteral("Returns the file currently active in Kate, with cursor position and any selected text. " + "Use this to understand what the user is looking at without them having to tell you."); + QJsonObject activeDocSchema; + activeDocSchema[QStringLiteral("type")] = QStringLiteral("object"); + activeDocSchema[QStringLiteral("properties")] = QJsonObject(); + activeDocTool[QStringLiteral("inputSchema")] = activeDocSchema; + QJsonObject activeDocAnnotations; + activeDocAnnotations[QStringLiteral("readOnlyHint")] = true; + activeDocAnnotations[QStringLiteral("destructiveHint")] = false; + activeDocTool[QStringLiteral("annotations")] = activeDocAnnotations; + + // katecode_open tool definition + QJsonObject openTool; + openTool[QStringLiteral("name")] = QStringLiteral("katecode_open"); + openTool[QStringLiteral("description")] = + QStringLiteral("Opens a file in Kate. Optionally jumps to a specific line and column " + "so the user can see the relevant code."); + QJsonObject openFilePathProp; + openFilePathProp[QStringLiteral("type")] = QStringLiteral("string"); + openFilePathProp[QStringLiteral("description")] = QStringLiteral("Absolute path to the file to open"); + QJsonObject openLineProp; + openLineProp[QStringLiteral("type")] = QStringLiteral("integer"); + openLineProp[QStringLiteral("description")] = QStringLiteral("Line to jump to (1-based). Omit to open at current position."); + QJsonObject openColProp; + openColProp[QStringLiteral("type")] = QStringLiteral("integer"); + openColProp[QStringLiteral("description")] = QStringLiteral("Column to jump to (1-based). Only used if line is provided."); + QJsonObject openProps; + openProps[QStringLiteral("file_path")] = openFilePathProp; + openProps[QStringLiteral("line")] = openLineProp; + openProps[QStringLiteral("column")] = openColProp; + QJsonObject openSchema; + openSchema[QStringLiteral("type")] = QStringLiteral("object"); + openSchema[QStringLiteral("properties")] = openProps; + openSchema[QStringLiteral("required")] = QJsonArray{QStringLiteral("file_path")}; + openTool[QStringLiteral("inputSchema")] = openSchema; + QJsonObject openAnnotations; + openAnnotations[QStringLiteral("readOnlyHint")] = false; + openAnnotations[QStringLiteral("destructiveHint")] = false; + openTool[QStringLiteral("annotations")] = openAnnotations; + + // katecode_close tool definition + QJsonObject closeTool; + closeTool[QStringLiteral("name")] = QStringLiteral("katecode_close"); + closeTool[QStringLiteral("description")] = QStringLiteral("Closes an open document in Kate."); + QJsonObject closeFilePathProp; + closeFilePathProp[QStringLiteral("type")] = QStringLiteral("string"); + closeFilePathProp[QStringLiteral("description")] = QStringLiteral("Absolute path of the open document to close"); + QJsonObject closeProps; + closeProps[QStringLiteral("file_path")] = closeFilePathProp; + QJsonObject closeSchema; + closeSchema[QStringLiteral("type")] = QStringLiteral("object"); + closeSchema[QStringLiteral("properties")] = closeProps; + closeSchema[QStringLiteral("required")] = QJsonArray{QStringLiteral("file_path")}; + closeTool[QStringLiteral("inputSchema")] = closeSchema; + QJsonObject closeAnnotations; + closeAnnotations[QStringLiteral("readOnlyHint")] = false; + closeAnnotations[QStringLiteral("destructiveHint")] = false; + closeTool[QStringLiteral("annotations")] = closeAnnotations; + + // katecode_save tool definition + QJsonObject saveTool; + saveTool[QStringLiteral("name")] = QStringLiteral("katecode_save"); + saveTool[QStringLiteral("description")] = + QStringLiteral("Saves a document. If file_path is omitted, saves all modified documents."); + QJsonObject saveFilePathProp; + saveFilePathProp[QStringLiteral("type")] = QStringLiteral("string"); + saveFilePathProp[QStringLiteral("description")] = + QStringLiteral("Absolute path of document to save. Omit to save all modified documents."); + QJsonObject saveProps; + saveProps[QStringLiteral("file_path")] = saveFilePathProp; + QJsonObject saveSchema; + saveSchema[QStringLiteral("type")] = QStringLiteral("object"); + saveSchema[QStringLiteral("properties")] = saveProps; + saveTool[QStringLiteral("inputSchema")] = saveSchema; + QJsonObject saveAnnotations; + saveAnnotations[QStringLiteral("readOnlyHint")] = false; + saveAnnotations[QStringLiteral("destructiveHint")] = false; + saveTool[QStringLiteral("annotations")] = saveAnnotations; + + // katecode_status tool definition + QJsonObject statusTool; + statusTool[QStringLiteral("name")] = QStringLiteral("katecode_status"); + statusTool[QStringLiteral("description")] = + QStringLiteral("Returns the status of an open document: whether it has unsaved changes and whether it is read-only."); + QJsonObject statusFilePathProp; + statusFilePathProp[QStringLiteral("type")] = QStringLiteral("string"); + statusFilePathProp[QStringLiteral("description")] = QStringLiteral("Absolute path of the open document"); + QJsonObject statusProps; + statusProps[QStringLiteral("file_path")] = statusFilePathProp; + QJsonObject statusSchema; + statusSchema[QStringLiteral("type")] = QStringLiteral("object"); + statusSchema[QStringLiteral("properties")] = statusProps; + statusSchema[QStringLiteral("required")] = QJsonArray{QStringLiteral("file_path")}; + statusTool[QStringLiteral("inputSchema")] = statusSchema; + QJsonObject statusAnnotations; + statusAnnotations[QStringLiteral("readOnlyHint")] = true; + statusAnnotations[QStringLiteral("destructiveHint")] = false; + statusTool[QStringLiteral("annotations")] = statusAnnotations; + + // katecode_revert tool definition + QJsonObject revertTool; + revertTool[QStringLiteral("name")] = QStringLiteral("katecode_revert"); + revertTool[QStringLiteral("description")] = + QStringLiteral("Reverts a document to its on-disk state, discarding any unsaved changes."); + QJsonObject revertFilePathProp; + revertFilePathProp[QStringLiteral("type")] = QStringLiteral("string"); + revertFilePathProp[QStringLiteral("description")] = QStringLiteral("Absolute path of the open document to revert"); + QJsonObject revertProps; + revertProps[QStringLiteral("file_path")] = revertFilePathProp; + QJsonObject revertSchema; + revertSchema[QStringLiteral("type")] = QStringLiteral("object"); + revertSchema[QStringLiteral("properties")] = revertProps; + revertSchema[QStringLiteral("required")] = QJsonArray{QStringLiteral("file_path")}; + revertTool[QStringLiteral("inputSchema")] = revertSchema; + QJsonObject revertAnnotations; + revertAnnotations[QStringLiteral("readOnlyHint")] = false; + revertAnnotations[QStringLiteral("destructiveHint")] = true; + revertTool[QStringLiteral("annotations")] = revertAnnotations; + + // katecode_set_session_note tool definition + QJsonObject setNoteTool; + setNoteTool[QStringLiteral("name")] = QStringLiteral("katecode_set_session_note"); + setNoteTool[QStringLiteral("description")] = + QStringLiteral("Updates the note stored against the current kate-code session. " + "Use this at the end of a session (or whenever useful) to record a brief " + "summary of what was accomplished, so it is visible in the session picker " + "when resuming later."); + + QJsonObject noteSessionIdProp; + noteSessionIdProp[QStringLiteral("type")] = QStringLiteral("string"); + noteSessionIdProp[QStringLiteral("description")] = QStringLiteral("The session ID to update (visible in the 'Connected! Session ID:' message)"); + + QJsonObject noteNoteProp; + noteNoteProp[QStringLiteral("type")] = QStringLiteral("string"); + noteNoteProp[QStringLiteral("description")] = QStringLiteral("Brief note summarising what was done this session (a few sentences at most)"); + + QJsonObject noteProps; + noteProps[QStringLiteral("session_id")] = noteSessionIdProp; + noteProps[QStringLiteral("note")] = noteNoteProp; + + QJsonObject noteSchema; + noteSchema[QStringLiteral("type")] = QStringLiteral("object"); + noteSchema[QStringLiteral("properties")] = noteProps; + noteSchema[QStringLiteral("required")] = QJsonArray{QStringLiteral("session_id"), QStringLiteral("note")}; + setNoteTool[QStringLiteral("inputSchema")] = noteSchema; + + QJsonObject noteAnnotations; + noteAnnotations[QStringLiteral("readOnlyHint")] = false; + noteAnnotations[QStringLiteral("destructiveHint")] = false; + noteAnnotations[QStringLiteral("idempotentHint")] = true; + setNoteTool[QStringLiteral("annotations")] = noteAnnotations; + + // katecode_get_session_id tool definition + QJsonObject getSessionIdTool; + getSessionIdTool[QStringLiteral("name")] = QStringLiteral("katecode_get_session_id"); + getSessionIdTool[QStringLiteral("description")] = + QStringLiteral("Returns the ID of the currently active kate-code session. " + "Use this to obtain the session_id required by katecode_set_session_note."); + QJsonObject getSessionIdSchema; + getSessionIdSchema[QStringLiteral("type")] = QStringLiteral("object"); + getSessionIdSchema[QStringLiteral("properties")] = QJsonObject{}; + getSessionIdTool[QStringLiteral("inputSchema")] = getSessionIdSchema; + QJsonObject getSessionIdAnnotations; + getSessionIdAnnotations[QStringLiteral("readOnlyHint")] = true; + getSessionIdAnnotations[QStringLiteral("destructiveHint")] = false; + getSessionIdTool[QStringLiteral("annotations")] = getSessionIdAnnotations; + + // katecode_read_clipboard tool definition + QJsonObject readClipboardTool; + readClipboardTool[QStringLiteral("name")] = QStringLiteral("katecode_read_clipboard"); + readClipboardTool[QStringLiteral("description")] = + QStringLiteral("Returns the current clipboard text content. " + "Useful for reading text the user has copied or selected (e.g. from the terminal)."); + QJsonObject readClipboardSchema; + readClipboardSchema[QStringLiteral("type")] = QStringLiteral("object"); + readClipboardSchema[QStringLiteral("properties")] = QJsonObject(); + readClipboardTool[QStringLiteral("inputSchema")] = readClipboardSchema; + QJsonObject readClipboardAnnotations; + readClipboardAnnotations[QStringLiteral("readOnlyHint")] = true; + readClipboardAnnotations[QStringLiteral("destructiveHint")] = false; + readClipboardTool[QStringLiteral("annotations")] = readClipboardAnnotations; + + // katecode_paste_to_terminal tool definition + QJsonObject pasteTerminalTool; + pasteTerminalTool[QStringLiteral("name")] = QStringLiteral("katecode_paste_to_terminal"); + pasteTerminalTool[QStringLiteral("description")] = + QStringLiteral("Sends text to Kate's embedded terminal without executing it (no Enter key). " + "The user can review the text and press Enter themselves. " + "Requires Kate's terminal plugin to be enabled."); + + QJsonObject pasteTextProp; + pasteTextProp[QStringLiteral("type")] = QStringLiteral("string"); + pasteTextProp[QStringLiteral("description")] = QStringLiteral("The text to paste into the terminal"); + + QJsonObject pasteTerminalProps; + pasteTerminalProps[QStringLiteral("text")] = pasteTextProp; + + QJsonObject pasteTerminalSchema; + pasteTerminalSchema[QStringLiteral("type")] = QStringLiteral("object"); + pasteTerminalSchema[QStringLiteral("properties")] = pasteTerminalProps; + pasteTerminalSchema[QStringLiteral("required")] = QJsonArray{QStringLiteral("text")}; + pasteTerminalTool[QStringLiteral("inputSchema")] = pasteTerminalSchema; + + QJsonObject pasteTerminalAnnotations; + pasteTerminalAnnotations[QStringLiteral("readOnlyHint")] = false; + pasteTerminalAnnotations[QStringLiteral("destructiveHint")] = false; + pasteTerminalTool[QStringLiteral("annotations")] = pasteTerminalAnnotations; + QJsonObject result; - result[QStringLiteral("tools")] = QJsonArray{docsTool, readTool, editTool, writeTool, askUserTool}; + result[QStringLiteral("tools")] = QJsonArray{ + docsTool, readTool, editTool, writeTool, askUserTool, + activeDocTool, openTool, closeTool, saveTool, statusTool, revertTool, + setNoteTool, getSessionIdTool, readClipboardTool, pasteTerminalTool + }; return makeResponse(id, result); } @@ -289,6 +505,26 @@ QJsonObject MCPServer::handleToolsCall(int id, const QJsonObject ¶ms) return makeResponse(id, executeWrite(arguments)); } else if (toolName == QStringLiteral("katecode_ask_user")) { return makeResponse(id, executeAskUserQuestion(arguments)); + } else if (toolName == QStringLiteral("katecode_active_document")) { + return makeResponse(id, executeActiveDocument(arguments)); + } else if (toolName == QStringLiteral("katecode_open")) { + return makeResponse(id, executeOpen(arguments)); + } else if (toolName == QStringLiteral("katecode_close")) { + return makeResponse(id, executeClose(arguments)); + } else if (toolName == QStringLiteral("katecode_save")) { + return makeResponse(id, executeSave(arguments)); + } else if (toolName == QStringLiteral("katecode_status")) { + return makeResponse(id, executeStatus(arguments)); + } else if (toolName == QStringLiteral("katecode_revert")) { + return makeResponse(id, executeRevert(arguments)); + } else if (toolName == QStringLiteral("katecode_set_session_note")) { + return makeResponse(id, executeSetSessionNote(arguments)); + } else if (toolName == QStringLiteral("katecode_get_session_id")) { + return makeResponse(id, executeGetSessionId(arguments)); + } else if (toolName == QStringLiteral("katecode_read_clipboard")) { + return makeResponse(id, executeReadClipboard(arguments)); + } else if (toolName == QStringLiteral("katecode_paste_to_terminal")) { + return makeResponse(id, executePasteToTerminal(arguments)); } return makeErrorResponse(id, -32602, QStringLiteral("Unknown tool: %1").arg(toolName)); @@ -488,10 +724,10 @@ QJsonObject MCPServer::executeEdit(const QJsonObject &arguments) const QString oldString = arguments[QStringLiteral("old_string")].toString(); const QString newString = arguments[QStringLiteral("new_string")].toString(); - if (filePath.isEmpty() || oldString.isEmpty()) { + if (filePath.isEmpty()) { QJsonObject textContent; textContent[QStringLiteral("type")] = QStringLiteral("text"); - textContent[QStringLiteral("text")] = QStringLiteral("Error: file_path and old_string are required"); + textContent[QStringLiteral("text")] = QStringLiteral("Error: file_path is required"); QJsonObject result; result[QStringLiteral("content")] = QJsonArray{textContent}; result[QStringLiteral("isError")] = true; @@ -607,6 +843,249 @@ QJsonObject MCPServer::makeErrorResult(const QString &message) return result; } +QJsonObject MCPServer::executeActiveDocument(const QJsonObject &arguments) +{ + Q_UNUSED(arguments); + + QDBusInterface iface(QStringLiteral("org.kde.katecode.editor"), + QStringLiteral("/KateCode/Editor"), + QStringLiteral("org.kde.katecode.Editor"), + QDBusConnection::sessionBus()); + if (!iface.isValid()) + return makeErrorResult(QStringLiteral("Error: Could not connect to Kate editor DBus service.")); + + QDBusReply reply = iface.call(QStringLiteral("getActiveDocument")); + if (!reply.isValid()) + return makeErrorResult(QStringLiteral("Error: DBus call failed: %1").arg(reply.error().message())); + + const QString response = reply.value(); + if (response.startsWith(QStringLiteral("ERROR:"))) + return makeErrorResult(response); + + // Parse JSON and format as human-readable text + QJsonParseError parseError; + QJsonDocument doc = QJsonDocument::fromJson(response.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { + QJsonObject textContent; + textContent[QStringLiteral("type")] = QStringLiteral("text"); + textContent[QStringLiteral("text")] = response; + QJsonObject result; + result[QStringLiteral("content")] = QJsonArray{textContent}; + return result; + } + + QJsonObject obj = doc.object(); + QString text = QStringLiteral("Active document: %1\nCursor: line %2, column %3\nModified: %4") + .arg(obj[QStringLiteral("path")].toString()) + .arg(obj[QStringLiteral("line")].toInt()) + .arg(obj[QStringLiteral("column")].toInt()) + .arg(obj[QStringLiteral("isModified")].toBool() ? QStringLiteral("yes") : QStringLiteral("no")); + if (obj.contains(QStringLiteral("selection"))) + text += QStringLiteral("\nSelection: \"%1\"").arg(obj[QStringLiteral("selection")].toString()); + + QJsonObject textContent; + textContent[QStringLiteral("type")] = QStringLiteral("text"); + textContent[QStringLiteral("text")] = text; + QJsonObject result; + result[QStringLiteral("content")] = QJsonArray{textContent}; + return result; +} + +QJsonObject MCPServer::executeOpen(const QJsonObject &arguments) +{ + const QString filePath = arguments[QStringLiteral("file_path")].toString(); + if (filePath.isEmpty()) + return makeErrorResult(QStringLiteral("Error: file_path is required")); + + const int line = arguments[QStringLiteral("line")].toInt(0); + const int column = arguments[QStringLiteral("column")].toInt(0); + + QDBusInterface iface(QStringLiteral("org.kde.katecode.editor"), + QStringLiteral("/KateCode/Editor"), + QStringLiteral("org.kde.katecode.Editor"), + QDBusConnection::sessionBus()); + if (!iface.isValid()) + return makeErrorResult(QStringLiteral("Error: Could not connect to Kate editor DBus service.")); + + QDBusReply reply = iface.call(QStringLiteral("openDocument"), filePath, line, column); + if (!reply.isValid()) + return makeErrorResult(QStringLiteral("Error: DBus call failed: %1").arg(reply.error().message())); + + const QString response = reply.value(); + bool isError = response.startsWith(QStringLiteral("ERROR:")); + QJsonObject textContent; + textContent[QStringLiteral("type")] = QStringLiteral("text"); + textContent[QStringLiteral("text")] = response; + QJsonObject result; + result[QStringLiteral("content")] = QJsonArray{textContent}; + if (isError) + result[QStringLiteral("isError")] = true; + return result; +} + +QJsonObject MCPServer::executeClose(const QJsonObject &arguments) +{ + const QString filePath = arguments[QStringLiteral("file_path")].toString(); + if (filePath.isEmpty()) + return makeErrorResult(QStringLiteral("Error: file_path is required")); + + QDBusInterface iface(QStringLiteral("org.kde.katecode.editor"), + QStringLiteral("/KateCode/Editor"), + QStringLiteral("org.kde.katecode.Editor"), + QDBusConnection::sessionBus()); + if (!iface.isValid()) + return makeErrorResult(QStringLiteral("Error: Could not connect to Kate editor DBus service.")); + + QDBusReply reply = iface.call(QStringLiteral("closeDocument"), filePath); + if (!reply.isValid()) + return makeErrorResult(QStringLiteral("Error: DBus call failed: %1").arg(reply.error().message())); + + const QString response = reply.value(); + bool isError = response.startsWith(QStringLiteral("ERROR:")); + QJsonObject textContent; + textContent[QStringLiteral("type")] = QStringLiteral("text"); + textContent[QStringLiteral("text")] = response; + QJsonObject result; + result[QStringLiteral("content")] = QJsonArray{textContent}; + if (isError) + result[QStringLiteral("isError")] = true; + return result; +} + +QJsonObject MCPServer::executeSave(const QJsonObject &arguments) +{ + // file_path is optional — empty string means save all + const QString filePath = arguments[QStringLiteral("file_path")].toString(); + + QDBusInterface iface(QStringLiteral("org.kde.katecode.editor"), + QStringLiteral("/KateCode/Editor"), + QStringLiteral("org.kde.katecode.Editor"), + QDBusConnection::sessionBus()); + if (!iface.isValid()) + return makeErrorResult(QStringLiteral("Error: Could not connect to Kate editor DBus service.")); + + QDBusReply reply = iface.call(QStringLiteral("saveDocument"), filePath); + if (!reply.isValid()) + return makeErrorResult(QStringLiteral("Error: DBus call failed: %1").arg(reply.error().message())); + + const QString response = reply.value(); + bool isError = response.startsWith(QStringLiteral("ERROR:")); + QJsonObject textContent; + textContent[QStringLiteral("type")] = QStringLiteral("text"); + textContent[QStringLiteral("text")] = response; + QJsonObject result; + result[QStringLiteral("content")] = QJsonArray{textContent}; + if (isError) + result[QStringLiteral("isError")] = true; + return result; +} + +QJsonObject MCPServer::executeStatus(const QJsonObject &arguments) +{ + const QString filePath = arguments[QStringLiteral("file_path")].toString(); + if (filePath.isEmpty()) + return makeErrorResult(QStringLiteral("Error: file_path is required")); + + QDBusInterface iface(QStringLiteral("org.kde.katecode.editor"), + QStringLiteral("/KateCode/Editor"), + QStringLiteral("org.kde.katecode.Editor"), + QDBusConnection::sessionBus()); + if (!iface.isValid()) + return makeErrorResult(QStringLiteral("Error: Could not connect to Kate editor DBus service.")); + + QDBusReply reply = iface.call(QStringLiteral("getDocumentStatus"), filePath); + if (!reply.isValid()) + return makeErrorResult(QStringLiteral("Error: DBus call failed: %1").arg(reply.error().message())); + + const QString response = reply.value(); + if (response.startsWith(QStringLiteral("ERROR:"))) + return makeErrorResult(response); + + QJsonParseError parseError; + QJsonDocument doc = QJsonDocument::fromJson(response.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { + QJsonObject textContent; + textContent[QStringLiteral("type")] = QStringLiteral("text"); + textContent[QStringLiteral("text")] = response; + QJsonObject result; + result[QStringLiteral("content")] = QJsonArray{textContent}; + return result; + } + + QJsonObject obj = doc.object(); + QString text = QStringLiteral("Document: %1\nModified: %2\nRead-only: %3") + .arg(obj[QStringLiteral("path")].toString()) + .arg(obj[QStringLiteral("isModified")].toBool() ? QStringLiteral("yes") : QStringLiteral("no")) + .arg(obj[QStringLiteral("isReadOnly")].toBool() ? QStringLiteral("yes") : QStringLiteral("no")); + + QJsonObject textContent; + textContent[QStringLiteral("type")] = QStringLiteral("text"); + textContent[QStringLiteral("text")] = text; + QJsonObject result; + result[QStringLiteral("content")] = QJsonArray{textContent}; + return result; +} + +QJsonObject MCPServer::executeRevert(const QJsonObject &arguments) +{ + const QString filePath = arguments[QStringLiteral("file_path")].toString(); + if (filePath.isEmpty()) + return makeErrorResult(QStringLiteral("Error: file_path is required")); + + QDBusInterface iface(QStringLiteral("org.kde.katecode.editor"), + QStringLiteral("/KateCode/Editor"), + QStringLiteral("org.kde.katecode.Editor"), + QDBusConnection::sessionBus()); + if (!iface.isValid()) + return makeErrorResult(QStringLiteral("Error: Could not connect to Kate editor DBus service.")); + + QDBusReply reply = iface.call(QStringLiteral("revertDocument"), filePath); + if (!reply.isValid()) + return makeErrorResult(QStringLiteral("Error: DBus call failed: %1").arg(reply.error().message())); + + const QString response = reply.value(); + bool isError = response.startsWith(QStringLiteral("ERROR:")); + QJsonObject textContent; + textContent[QStringLiteral("type")] = QStringLiteral("text"); + textContent[QStringLiteral("text")] = response; + QJsonObject result; + result[QStringLiteral("content")] = QJsonArray{textContent}; + if (isError) + result[QStringLiteral("isError")] = true; + return result; +} + +QJsonObject MCPServer::executeSetSessionNote(const QJsonObject &arguments) +{ + const QString sessionId = arguments[QStringLiteral("session_id")].toString(); + const QString note = arguments[QStringLiteral("note")].toString(); + + if (sessionId.isEmpty()) + return makeErrorResult(QStringLiteral("Error: session_id is required")); + + QDBusInterface iface(QStringLiteral("org.kde.katecode.editor"), + QStringLiteral("/KateCode/Editor"), + QStringLiteral("org.kde.katecode.Editor"), + QDBusConnection::sessionBus()); + if (!iface.isValid()) + return makeErrorResult(QStringLiteral("Error: Could not connect to Kate editor DBus service.")); + + QDBusReply reply = iface.call(QStringLiteral("setSessionNote"), sessionId, note); + if (!reply.isValid()) + return makeErrorResult(QStringLiteral("Error: DBus call failed: %1").arg(reply.error().message())); + + const QString response = reply.value(); + bool isError = response.startsWith(QStringLiteral("ERROR:")); + QJsonObject textContent; + textContent[QStringLiteral("type")] = QStringLiteral("text"); + textContent[QStringLiteral("text")] = response; + QJsonObject result; + result[QStringLiteral("content")] = QJsonArray{textContent}; + if (isError) + result[QStringLiteral("isError")] = true; + return result; +} + QJsonObject MCPServer::executeAskUserQuestion(const QJsonObject &arguments) { const QJsonArray questions = arguments[QStringLiteral("questions")].toArray(); @@ -722,3 +1201,84 @@ QJsonObject MCPServer::executeAskUserQuestion(const QJsonObject &arguments) result[QStringLiteral("content")] = QJsonArray{textContent}; return result; } + +QJsonObject MCPServer::executeGetSessionId(const QJsonObject &arguments) +{ + Q_UNUSED(arguments); + + QDBusInterface iface(QStringLiteral("org.kde.katecode.editor"), + QStringLiteral("/KateCode/Editor"), + QStringLiteral("org.kde.katecode.Editor"), + QDBusConnection::sessionBus()); + if (!iface.isValid()) + return makeErrorResult(QStringLiteral("Error: Could not connect to Kate editor DBus service.")); + + QDBusReply reply = iface.call(QStringLiteral("getSessionId")); + if (!reply.isValid()) + return makeErrorResult(QStringLiteral("Error: DBus call failed: %1").arg(reply.error().message())); + + const QString sessionId = reply.value(); + QJsonObject textContent; + textContent[QStringLiteral("type")] = QStringLiteral("text"); + textContent[QStringLiteral("text")] = sessionId.isEmpty() + ? QStringLiteral("No active session") + : sessionId; + QJsonObject result; + result[QStringLiteral("content")] = QJsonArray{textContent}; + return result; +} + +QJsonObject MCPServer::executeReadClipboard(const QJsonObject &arguments) +{ + Q_UNUSED(arguments); + + QDBusInterface iface(QStringLiteral("org.kde.katecode.editor"), + QStringLiteral("/KateCode/Editor"), + QStringLiteral("org.kde.katecode.Editor"), + QDBusConnection::sessionBus()); + if (!iface.isValid()) + return makeErrorResult(QStringLiteral("Error: Could not connect to Kate editor DBus service.")); + + QDBusReply reply = iface.call(QStringLiteral("getClipboardText")); + if (!reply.isValid()) + return makeErrorResult(QStringLiteral("Error: DBus call failed: %1").arg(reply.error().message())); + + const QString text = reply.value(); + QJsonObject textContent; + textContent[QStringLiteral("type")] = QStringLiteral("text"); + textContent[QStringLiteral("text")] = text.isEmpty() + ? QStringLiteral("(clipboard is empty)") + : text; + QJsonObject result; + result[QStringLiteral("content")] = QJsonArray{textContent}; + return result; +} + +QJsonObject MCPServer::executePasteToTerminal(const QJsonObject &arguments) +{ + const QString text = arguments[QStringLiteral("text")].toString(); + if (text.isEmpty()) + return makeErrorResult(QStringLiteral("Error: text is required")); + + QDBusInterface iface(QStringLiteral("org.kde.katecode.editor"), + QStringLiteral("/KateCode/Editor"), + QStringLiteral("org.kde.katecode.Editor"), + QDBusConnection::sessionBus()); + if (!iface.isValid()) + return makeErrorResult(QStringLiteral("Error: Could not connect to Kate editor DBus service.")); + + QDBusReply reply = iface.call(QStringLiteral("pasteToTerminal"), text); + if (!reply.isValid()) + return makeErrorResult(QStringLiteral("Error: DBus call failed: %1").arg(reply.error().message())); + + const QString response = reply.value(); + bool isError = response.startsWith(QStringLiteral("ERROR:")); + QJsonObject textContent; + textContent[QStringLiteral("type")] = QStringLiteral("text"); + textContent[QStringLiteral("text")] = response; + QJsonObject result; + result[QStringLiteral("content")] = QJsonArray{textContent}; + if (isError) + result[QStringLiteral("isError")] = true; + return result; +} diff --git a/src/mcp/MCPServer.h b/src/mcp/MCPServer.h index 02ee7ee..7330778 100644 --- a/src/mcp/MCPServer.h +++ b/src/mcp/MCPServer.h @@ -28,6 +28,16 @@ class MCPServer QJsonObject executeEdit(const QJsonObject &arguments); QJsonObject executeWrite(const QJsonObject &arguments); QJsonObject executeAskUserQuestion(const QJsonObject &arguments); + QJsonObject executeActiveDocument(const QJsonObject &arguments); + QJsonObject executeOpen(const QJsonObject &arguments); + QJsonObject executeClose(const QJsonObject &arguments); + QJsonObject executeSave(const QJsonObject &arguments); + QJsonObject executeStatus(const QJsonObject &arguments); + QJsonObject executeRevert(const QJsonObject &arguments); + QJsonObject executeSetSessionNote(const QJsonObject &arguments); + QJsonObject executeGetSessionId(const QJsonObject &arguments); + QJsonObject executeReadClipboard(const QJsonObject &arguments); + QJsonObject executePasteToTerminal(const QJsonObject &arguments); QJsonObject makeResponse(int id, const QJsonObject &result); QJsonObject makeErrorResponse(int id, int code, const QString &message); diff --git a/src/plugin/KateCodeView.cpp b/src/plugin/KateCodeView.cpp index d18e866..88acce4 100644 --- a/src/plugin/KateCodeView.cpp +++ b/src/plugin/KateCodeView.cpp @@ -147,6 +147,10 @@ void KateCodeView::createToolView() // Connect edit navigation connect(m_chatWidget, &ChatWidget::jumpToEditRequested, this, &KateCodeView::jumpToEdit); + // EditorDBusService -> ChatWidget: edit data for diff display + connect(m_plugin->dbusService(), &EditorDBusService::editApplied, + m_chatWidget, &ChatWidget::onEditApplied); + // Connect user question signals (MCP AskUserQuestion tool) // EditorDBusService -> ChatWidget: show question UI connect(m_plugin->dbusService(), &EditorDBusService::questionRequested, @@ -158,6 +162,14 @@ void KateCodeView::createToolView() connect(m_chatWidget, &ChatWidget::userQuestionAnswered, m_plugin->dbusService(), &EditorDBusService::provideQuestionResponse); + // EditorDBusService -> ChatWidget: update session note + connect(m_plugin->dbusService(), &EditorDBusService::sessionNoteUpdateRequested, + m_chatWidget, &ChatWidget::setSessionNote); + + // ChatWidget -> EditorDBusService: keep current session ID up to date + connect(m_chatWidget, &ChatWidget::sessionIdChanged, + m_plugin->dbusService(), &EditorDBusService::updateCurrentSessionId); + // Connect debug log output to Kate's Output panel connect(m_chatWidget, &ChatWidget::debugLogMessage, this, [this](const QString &message) { QVariantMap msg; diff --git a/src/ui/ChatWebView.cpp b/src/ui/ChatWebView.cpp index 35d07ce..762c019 100644 --- a/src/ui/ChatWebView.cpp +++ b/src/ui/ChatWebView.cpp @@ -34,6 +34,7 @@ ChatWebView::ChatWebView(QWidget *parent) QJsonDocument doc = QJsonDocument::fromJson(answersJson.toUtf8()); Q_EMIT userQuestionAnswered(requestId, doc.object()); }); + connect(m_bridge, &WebBridge::concernFlagged, this, &ChatWebView::concernFlagged); } ChatWebView::~ChatWebView() @@ -98,16 +99,19 @@ void ChatWebView::injectColorScheme() qDebug() << "[ChatWebView] No theme JSON, using fallback:" << codeBg; } - inlineCodeBg = isLight ? QStringLiteral("rgba(0, 0, 0, 0.08)") - : QStringLiteral("rgba(0, 0, 0, 0.3)"); + // Determine if the code background is light or dark (independent of KDE UI theme) + bool isLightCodeBg = QColor(codeBg).lightnessF() > 0.5; + + inlineCodeBg = isLightCodeBg ? QStringLiteral("rgba(0, 0, 0, 0.08)") + : QStringLiteral("rgba(0, 0, 0, 0.3)"); // Theme-aware task purple: darker on light themes, lighter on dark themes for contrast QString taskPurple = isLight ? QStringLiteral("#9c27b0") : QStringLiteral("#ce93d8"); QString taskPurpleBg = isLight ? QStringLiteral("rgba(156, 39, 176, 0.08)") : QStringLiteral("rgba(206, 147, 216, 0.15)"); - // Terminal text color: dark text on light backgrounds, light text on dark backgrounds - QString terminalFg = isLight ? QStringLiteral("#1e1e1e") : QStringLiteral("#e0e0e0"); + // Terminal text color: based on code background lightness, not KDE UI theme + QString terminalFg = isLightCodeBg ? QStringLiteral("#1e1e1e") : QStringLiteral("#e0e0e0"); // Escape the CSS for JavaScript string literal QString escapedCSS = kateThemeCSS; @@ -280,6 +284,24 @@ void ChatWebView::updateToolCall(const QString &messageId, const QString &toolCa runJavaScript(script); } +void ChatWebView::setToolCallDiff(const QString &messageId, const QString &toolCallId, + const QString &filePath, const QString &oldText, const QString &newText) +{ + if (!m_isLoaded) return; + + QString b64OldText = QString::fromLatin1(oldText.toUtf8().toBase64()); + QString b64NewText = QString::fromLatin1(newText.toUtf8().toBase64()); + + QString script = QStringLiteral("setToolCallDiff('%1', '%2', '%3', '%4', '%5');") + .arg(escapeJsString(messageId), + escapeJsString(toolCallId), + escapeJsString(filePath), + b64OldText, + b64NewText); + + runJavaScript(script); +} + void ChatWebView::showPermissionRequest(const PermissionRequest &request) { qDebug() << "[ChatWebView] showPermissionRequest called - requestId:" << request.requestId @@ -475,7 +497,8 @@ void ChatWebView::clearEditSummary() runJavaScript(QStringLiteral("clearEditSummary();")); } -void ChatWebView::updateDiffColors(const QString &removeBackground, const QString &addBackground) +void ChatWebView::updateDiffColors(const QString &removeBackground, const QString &addBackground, + const QString &removeForeground, const QString &addForeground) { if (!m_isLoaded) { return; @@ -484,10 +507,13 @@ void ChatWebView::updateDiffColors(const QString &removeBackground, const QStrin QString script = QStringLiteral( "document.documentElement.style.setProperty('--diff-remove-bg', '%1');" "document.documentElement.style.setProperty('--diff-add-bg', '%2');" - ).arg(removeBackground, addBackground); + "document.documentElement.style.setProperty('--diff-remove-fg', '%3');" + "document.documentElement.style.setProperty('--diff-add-fg', '%4');" + ).arg(removeBackground, addBackground, removeForeground, addForeground); runJavaScript(script); - qDebug() << "[ChatWebView] Updated diff colors: remove=" << removeBackground << "add=" << addBackground; + qDebug() << "[ChatWebView] Updated diff colors: remove=" << removeBackground << "/" << removeForeground + << "add=" << addBackground << "/" << addForeground; } // WebBridge implementation @@ -496,6 +522,11 @@ void WebBridge::respondToPermission(int requestId, const QString &optionId) Q_EMIT permissionResponse(requestId, optionId); } +void WebBridge::flagConcern() +{ + Q_EMIT concernFlagged(); +} + void WebBridge::logFromJS(const QString &message) { qDebug() << "[JS]" << message; diff --git a/src/ui/ChatWebView.h b/src/ui/ChatWebView.h index 6537542..f9c2cb1 100644 --- a/src/ui/ChatWebView.h +++ b/src/ui/ChatWebView.h @@ -18,6 +18,7 @@ class ChatWebView : public QWebEngineView void finishMessage(const QString &messageId); void addToolCall(const QString &messageId, const ToolCall &toolCall); void updateToolCall(const QString &messageId, const QString &toolCallId, const QString &status, const QString &result, const QString &filePath = QString(), const QString &toolName = QString()); + void setToolCallDiff(const QString &messageId, const QString &toolCallId, const QString &filePath, const QString &oldText, const QString &newText); void showPermissionRequest(const PermissionRequest &request); void updateTodos(const QList &todos); void clearMessages(); @@ -35,13 +36,15 @@ class ChatWebView : public QWebEngineView void clearEditSummary(); // Diff color scheme support - void updateDiffColors(const QString &removeBackground, const QString &addBackground); + void updateDiffColors(const QString &removeBackground, const QString &addBackground, + const QString &removeForeground, const QString &addForeground); Q_SIGNALS: void permissionResponseReady(int requestId, const QString &optionId); void jumpToEditRequested(const QString &filePath, int startLine, int endLine); void webViewReady(); void userQuestionAnswered(const QString &requestId, const QJsonObject &answers); + void concernFlagged(); private Q_SLOTS: void onLoadFinished(bool ok); @@ -69,9 +72,11 @@ public Q_SLOTS: Q_INVOKABLE void logFromJS(const QString &message); Q_INVOKABLE void jumpToEdit(const QString &filePath, int startLine, int endLine); Q_INVOKABLE void submitQuestionAnswers(const QString &requestId, const QString &answersJson); + Q_INVOKABLE void flagConcern(); Q_SIGNALS: void permissionResponse(int requestId, const QString &optionId); void jumpToEditRequested(const QString &filePath, int startLine, int endLine); void questionAnswersSubmitted(const QString &requestId, const QString &answersJson); + void concernFlagged(); }; diff --git a/src/ui/ChatWidget.cpp b/src/ui/ChatWidget.cpp index b4ca40c..c1f5f3e 100644 --- a/src/ui/ChatWidget.cpp +++ b/src/ui/ChatWidget.cpp @@ -12,7 +12,6 @@ #include "../util/SummaryGenerator.h" #include "../util/SummaryStore.h" -#include #include #include #include @@ -26,7 +25,7 @@ #include #include #include -#include +#include #include #include @@ -55,20 +54,16 @@ ChatWidget::ChatWidget(QWidget *parent) headerLayout->addWidget(m_titleLabel); headerLayout->addStretch(); - // ACP Provider selector - m_providerCombo = new QComboBox(this); - m_providerCombo->setToolTip(QStringLiteral("Select ACP Provider")); - m_providerCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents); - headerLayout->addWidget(m_providerCombo); - connect(m_providerCombo, &QComboBox::currentIndexChanged, this, &ChatWidget::onProviderComboChanged); - - // Resume Session button (icon only) - m_resumeSessionButton = new QToolButton(this); - m_resumeSessionButton->setIcon(QIcon::fromTheme(QStringLiteral("view-history"))); - m_resumeSessionButton->setToolTip(QStringLiteral("Resume Session")); - m_resumeSessionButton->setAutoRaise(true); - m_resumeSessionButton->setEnabled(false); - headerLayout->addWidget(m_resumeSessionButton); + // ACP Provider button — icon that opens a QMenu to switch providers + m_providerButton = new QToolButton(this); + m_providerButton->setIcon(QIcon::fromTheme(QStringLiteral("configure"))); + m_providerButton->setToolButtonStyle(Qt::ToolButtonIconOnly); + m_providerButton->setPopupMode(QToolButton::InstantPopup); + m_providerButton->setAutoRaise(true); + m_providerButton->setStyleSheet(QStringLiteral("QToolButton::menu-indicator { width: 0; }")); + m_providerButton->setToolTip(QStringLiteral("ACP Provider — click to change")); + m_providerButton->setVisible(false); // shown by populateProviderCombo() if >1 provider + headerLayout->addWidget(m_providerButton); // New Session button (icon only) m_newSessionButton = new QToolButton(this); @@ -78,6 +73,15 @@ ChatWidget::ChatWidget(QWidget *parent) m_newSessionButton->setEnabled(false); headerLayout->addWidget(m_newSessionButton); + // Flag concern button (icon only) — signals Claude to pause at next step + m_flagConcernButton = new QToolButton(this); + m_flagConcernButton->setIcon(QIcon::fromTheme(QStringLiteral("flag-red"))); + m_flagConcernButton->setToolTip(QStringLiteral("Flag concern — Claude will pause at next step")); + m_flagConcernButton->setAutoRaise(true); + m_flagConcernButton->setCheckable(true); + m_flagConcernButton->setEnabled(false); + headerLayout->addWidget(m_flagConcernButton); + // Connect/Disconnect button (icon only) m_connectButton = new QToolButton(this); m_connectButton->setIcon(QIcon::fromTheme(QStringLiteral("network-connect"))); @@ -116,8 +120,8 @@ ChatWidget::ChatWidget(QWidget *parent) // Connect signals connect(m_connectButton, &QPushButton::clicked, this, &ChatWidget::onConnectClicked); - connect(m_resumeSessionButton, &QPushButton::clicked, this, &ChatWidget::onResumeSessionClicked); connect(m_newSessionButton, &QPushButton::clicked, this, &ChatWidget::onNewSessionClicked); + connect(m_flagConcernButton, &QToolButton::clicked, this, &ChatWidget::onFlagConcernClicked); connect(m_inputWidget, &ChatInputWidget::messageSubmitted, this, &ChatWidget::onMessageSubmitted); connect(m_inputWidget, &ChatInputWidget::imageAttached, this, &ChatWidget::onImageAttached); connect(m_inputWidget, &ChatInputWidget::permissionModeChanged, this, &ChatWidget::onPermissionModeChanged); @@ -130,6 +134,7 @@ ChatWidget::ChatWidget(QWidget *parent) connect(m_session, &ACPSession::messageFinished, this, &ChatWidget::onMessageFinished); connect(m_session, &ACPSession::toolCallAdded, this, &ChatWidget::onToolCallAdded); connect(m_session, &ACPSession::toolCallUpdated, this, &ChatWidget::onToolCallUpdated); + connect(m_session, &ACPSession::fsEditApplied, this, &ChatWidget::onEditApplied); connect(m_session, &ACPSession::todosUpdated, this, &ChatWidget::onTodosUpdated); connect(m_session, &ACPSession::permissionRequested, this, &ChatWidget::onPermissionRequested); connect(m_session, &ACPSession::modesAvailable, this, &ChatWidget::onModesAvailable); @@ -235,82 +240,57 @@ void ChatWidget::onConnectClicked() return; } - // Reset user message tracking for new session - m_userSentMessage = false; - - // Get current project root QString projectRoot = m_projectRootProvider ? m_projectRootProvider() : QDir::homePath(); - - // Clear any pending summary from previous attempt m_pendingSummaryContext.clear(); + m_userSentMessage = false; - m_pendingAction = PendingAction::CreateSession; - - // Add system message - Message sysMsg; - sysMsg.id = QStringLiteral("sys_connect"); - sysMsg.role = QStringLiteral("system"); - sysMsg.timestamp = QDateTime::currentDateTime(); - sysMsg.content = QStringLiteral("Starting new session in: %1").arg(projectRoot); - m_chatWebView->addMessage(sysMsg); - - m_session->start(projectRoot); -} - -void ChatWidget::onResumeSessionClicked() -{ - // Get current project root - QString projectRoot = m_projectRootProvider ? m_projectRootProvider() : QDir::homePath(); - - // Check if any sessions with summaries exist - QStringList sessionIds = m_summaryStore->listSessionSummaries(projectRoot); - if (sessionIds.isEmpty()) { - // No sessions to resume - show message - Message sysMsg; - sysMsg.id = QStringLiteral("sys_no_sessions"); - sysMsg.role = QStringLiteral("system"); - sysMsg.content = QStringLiteral("No previous sessions found for: %1").arg(projectRoot); - sysMsg.timestamp = QDateTime::currentDateTime(); - m_chatWebView->addMessage(sysMsg); - return; - } + // If saved sessions exist, show the session picker + if (m_sessionStore->hasSession(projectRoot)) { + SessionSelectionDialog dialog(projectRoot, m_sessionStore, m_summaryStore, this); + if (dialog.exec() != QDialog::Accepted) { + return; // cancelled + } - // Show dialog to let user select a session to resume - SessionSelectionDialog dialog(projectRoot, m_summaryStore, this); - if (dialog.exec() == QDialog::Accepted) { if (dialog.selectedResult() == SessionSelectionDialog::Result::Resume) { - // Store summary of selected session to send after session connects - QString selectedId = dialog.selectedSessionId(); - m_pendingSummaryContext = m_summaryStore->loadSummary(projectRoot, selectedId); - - // If already connected, stop current session first (like onNewSessionClicked) - if (m_session->isConnected()) { - // Trigger summary generation for current session before stopping - triggerSummaryGeneration(); - - // Stop current session and reset WebView to reclaim memory - m_session->stop(); - resetWebView(); - } - - // Reset user message tracking for new session - m_userSentMessage = false; + m_pendingAction = PendingAction::LoadSession; + m_pendingSessionId = dialog.selectedSessionId(); + m_pendingSessionName.clear(); + m_pendingSessionNote.clear(); + Message sysMsg; + sysMsg.id = QStringLiteral("sys_connect"); + sysMsg.role = QStringLiteral("system"); + sysMsg.timestamp = QDateTime::currentDateTime(); + sysMsg.content = QStringLiteral("Resuming session in: %1").arg(projectRoot); + m_chatWebView->addMessage(sysMsg); + } else { + // New session chosen from dialog m_pendingAction = PendingAction::CreateSession; + m_pendingSessionName = dialog.selectedSessionName(); + m_pendingSessionNote = dialog.selectedSessionNote(); - // Add system message Message sysMsg; sysMsg.id = QStringLiteral("sys_connect"); sysMsg.role = QStringLiteral("system"); sysMsg.timestamp = QDateTime::currentDateTime(); - sysMsg.content = QStringLiteral("Resuming session with prior context in: %1").arg(projectRoot); + sysMsg.content = QStringLiteral("Starting new session in: %1").arg(projectRoot); m_chatWebView->addMessage(sysMsg); - - m_session->start(projectRoot); } - // If NewSession was selected in the dialog, do nothing (user can click Connect) + } else { + // First time connecting to this project — go straight to new session + m_pendingAction = PendingAction::CreateSession; + m_pendingSessionName.clear(); + m_pendingSessionNote.clear(); + + Message sysMsg; + sysMsg.id = QStringLiteral("sys_connect"); + sysMsg.role = QStringLiteral("system"); + sysMsg.timestamp = QDateTime::currentDateTime(); + sysMsg.content = QStringLiteral("Starting new session in: %1").arg(projectRoot); + m_chatWebView->addMessage(sysMsg); } - // Cancelled - do nothing + + m_session->start(projectRoot); } void ChatWidget::onNewSessionClicked() @@ -326,8 +306,7 @@ void ChatWidget::onNewSessionClicked() // Get current project root QString projectRoot = m_projectRootProvider ? m_projectRootProvider() : QDir::homePath(); - // Clear stored session and any pending summary context - m_sessionStore->clearSession(projectRoot); + // Clear any pending summary context m_pendingSummaryContext.clear(); // Stop current session @@ -397,9 +376,11 @@ void ChatWidget::onStatusChanged(ConnectionStatus status) m_connectButton->setIcon(QIcon::fromTheme(QStringLiteral("network-connect"))); m_connectButton->setToolTip(QStringLiteral("Connect")); m_connectButton->setEnabled(true); - m_resumeSessionButton->setEnabled(true); m_newSessionButton->setEnabled(false); - m_providerCombo->setEnabled(true); + m_flagConcernButton->setEnabled(false); + m_flagConcernButton->setChecked(false); + m_concernFlagged = false; + m_providerButton->setEnabled(true); m_inputWidget->setEnabled(false); m_statusIndicator->setStyleSheet(QStringLiteral("QLabel { color: #888888; font-size: 14px; }")); m_statusIndicator->setToolTip(QStringLiteral("Disconnected")); @@ -413,9 +394,8 @@ void ChatWidget::onStatusChanged(ConnectionStatus status) break; case ConnectionStatus::Connecting: m_connectButton->setEnabled(false); - m_resumeSessionButton->setEnabled(false); m_newSessionButton->setEnabled(false); - m_providerCombo->setEnabled(false); + m_providerButton->setEnabled(false); m_statusIndicator->setStyleSheet(QStringLiteral("QLabel { color: #f0ad4e; font-size: 14px; }")); m_statusIndicator->setToolTip(QStringLiteral("Connecting...")); sysMsg.id = QStringLiteral("sys_connecting"); @@ -426,13 +406,12 @@ void ChatWidget::onStatusChanged(ConnectionStatus status) m_connectButton->setIcon(QIcon::fromTheme(QStringLiteral("network-disconnect"))); m_connectButton->setToolTip(QStringLiteral("Disconnect")); m_connectButton->setEnabled(true); - m_resumeSessionButton->setEnabled(true); m_newSessionButton->setEnabled(true); - m_providerCombo->setEnabled(false); + m_flagConcernButton->setEnabled(true); + m_providerButton->setEnabled(false); m_inputWidget->setEnabled(true); m_statusIndicator->setStyleSheet(QStringLiteral("QLabel { color: #5cb85c; font-size: 14px; }")); m_statusIndicator->setToolTip(QStringLiteral("Connected")); - m_titleLabel->setText(QStringLiteral("Kate Code - Session")); sysMsg.id = QStringLiteral("sys_connected"); sysMsg.content = QStringLiteral("Connected! Session ID: %1").arg(m_session->sessionId()); m_chatWebView->addMessage(sysMsg); @@ -440,9 +419,25 @@ void ChatWidget::onStatusChanged(ConnectionStatus status) // Save session ID for future resume and summary generation { QString projectRoot = m_projectRootProvider ? m_projectRootProvider() : QDir::homePath(); - m_sessionStore->saveSession(projectRoot, m_session->sessionId()); + m_sessionStore->addSession(projectRoot, m_session->sessionId(), m_pendingSessionName, m_pendingSessionNote); + m_pendingSessionName.clear(); + m_pendingSessionNote.clear(); m_lastSessionId = m_session->sessionId(); m_lastProjectRoot = projectRoot; + Q_EMIT sessionIdChanged(m_lastSessionId); + + // Show session name in title if one is set + QList sessions = m_sessionStore->listSessions(projectRoot); + QString sessionName; + for (const SessionEntry &e : sessions) { + if (e.id == m_lastSessionId) { + sessionName = e.name; + break; + } + } + m_titleLabel->setText(sessionName.isEmpty() + ? QStringLiteral("Kate Code") + : QStringLiteral("Kate Code — %1").arg(sessionName)); } // Populate file list for @-completion @@ -463,9 +458,8 @@ void ChatWidget::onStatusChanged(ConnectionStatus status) m_connectButton->setIcon(QIcon::fromTheme(QStringLiteral("network-connect"))); m_connectButton->setToolTip(QStringLiteral("Connect")); m_connectButton->setEnabled(true); - m_resumeSessionButton->setEnabled(true); m_newSessionButton->setEnabled(false); - m_providerCombo->setEnabled(true); + m_providerButton->setEnabled(true); m_statusIndicator->setStyleSheet(QStringLiteral("QLabel { color: #d9534f; font-size: 14px; }")); m_statusIndicator->setToolTip(QStringLiteral("Error")); break; @@ -493,6 +487,27 @@ void ChatWidget::onMessageFinished(const QString &messageId) // Prompt finished - update running state m_inputWidget->setPromptRunning(false); + + // If user flagged a concern, send pause message now that Claude is between steps + if (m_concernFlagged) { + m_concernFlagged = false; + m_flagConcernButton->setChecked(false); + m_session->sendMessage( + QStringLiteral("The user has flagged a concern or question about the current task. " + "Please pause here and ask them what they'd like to clarify before continuing."), + QString(), QString(), {}); + } +} + +void ChatWidget::onFlagConcernClicked() +{ + m_concernFlagged = m_flagConcernButton->isChecked(); +} + +void ChatWidget::onConcernFlaggedFromWeb() +{ + m_concernFlagged = true; + m_flagConcernButton->setChecked(true); } void ChatWidget::onToolCallAdded(const QString &messageId, const ToolCall &toolCall) @@ -505,9 +520,24 @@ void ChatWidget::onToolCallAdded(const QString &messageId, const ToolCall &toolC } } +void ChatWidget::onEditApplied(const QString &filePath, const QString &oldText, const QString &newText) +{ + m_recentEdits.append({filePath, oldText, newText}); +} + void ChatWidget::onToolCallUpdated(const QString &messageId, const QString &toolCallId, const QString &status, const QString &result, const QString &filePath, const QString &toolName) { Q_UNUSED(messageId); + + // If this is an edit tool completion and we have captured D-Bus edit data, update the diff display + bool isEditTool = toolName == QStringLiteral("Edit") + || toolName.endsWith(QStringLiteral("_katecode_edit")) + || toolName == QStringLiteral("mcp__acp__Edit"); + if (isEditTool && !m_recentEdits.isEmpty()) { + RecentEditData edit = m_recentEdits.takeFirst(); + m_chatWebView->setToolCallDiff(messageId, toolCallId, edit.filePath, edit.oldText, edit.newText); + } + m_chatWebView->updateToolCall(messageId, toolCallId, status, result, filePath, toolName); // Clear highlights when tool call completes or fails @@ -560,9 +590,9 @@ void ChatWidget::onSessionLoadFailed(const QString &error) { qWarning() << "[ChatWidget] Session load failed, creating new:" << error; - // Clear stale session from storage + // Remove stale session entry from history QString projectRoot = m_projectRootProvider ? m_projectRootProvider() : QDir::homePath(); - m_sessionStore->clearSession(projectRoot); + m_sessionStore->removeSession(projectRoot, m_pendingSessionId); // Show system message Message sysMsg; @@ -1003,8 +1033,10 @@ void ChatWidget::applyDiffColors() QString removeBackground = colorToRgba(colors.deletionBackground); QString addBackground = colorToRgba(colors.additionBackground); + QString removeForeground = colorToRgba(colors.deletionForeground); + QString addForeground = colorToRgba(colors.additionForeground); - m_chatWebView->updateDiffColors(removeBackground, addBackground); + m_chatWebView->updateDiffColors(removeBackground, addBackground, removeForeground, addForeground); } void ChatWidget::applyACPBackend() @@ -1024,56 +1056,41 @@ void ChatWidget::applyACPBackend() void ChatWidget::populateProviderCombo() { - if (!m_settingsStore || !m_providerCombo) { + if (!m_settingsStore || !m_providerButton) { return; } - // Block signals to avoid triggering onProviderComboChanged during population - m_providerCombo->blockSignals(true); - m_providerCombo->clear(); - const auto providerList = m_settingsStore->providers(); QString activeId = m_settingsStore->activeProviderId(); - int activeIndex = 0; - for (int i = 0; i < providerList.size(); ++i) { - const auto &p = providerList[i]; - bool available = SettingsStore::isExecutableAvailable(p.executable); + auto *menu = new QMenu(m_providerButton); + for (const auto &p : providerList) { + bool available = SettingsStore::isExecutableAvailable(p.executable); QString displayText = p.description; if (!available) { displayText += QStringLiteral(" (not found)"); } - m_providerCombo->addItem(displayText, p.id); - - // Gray out unavailable providers - if (!available) { - auto *model = qobject_cast(m_providerCombo->model()); - if (model) { - QStandardItem *item = model->item(i); - item->setFlags(item->flags() & ~Qt::ItemIsEnabled); - } - } + QAction *action = menu->addAction(displayText); + action->setData(p.id); + action->setEnabled(available); + action->setCheckable(true); + action->setChecked(p.id == activeId); - if (p.id == activeId) { - activeIndex = i; - } + connect(action, &QAction::triggered, this, [this, id = p.id]() { + m_settingsStore->setActiveProviderId(id); + applyACPBackend(); + populateProviderCombo(); + }); } - m_providerCombo->setCurrentIndex(activeIndex); - m_providerCombo->blockSignals(false); -} - -void ChatWidget::onProviderComboChanged(int index) -{ - if (!m_settingsStore || index < 0) { - return; - } + // Replace existing menu + delete m_providerButton->menu(); + m_providerButton->setMenu(menu); - QString providerId = m_providerCombo->itemData(index).toString(); - m_settingsStore->setActiveProviderId(providerId); - applyACPBackend(); + // Only show button when there is a real choice + m_providerButton->setVisible(providerList.size() > 1); } void ChatWidget::resizeEvent(QResizeEvent *event) @@ -1120,6 +1137,13 @@ void ChatWidget::removeUserQuestion(const QString &requestId) m_chatWebView->removeUserQuestion(requestId); } +void ChatWidget::setSessionNote(const QString &sessionId, const QString ¬e) +{ + QString projectRoot = m_projectRootProvider ? m_projectRootProvider() : QDir::homePath(); + m_sessionStore->setSessionNote(projectRoot, sessionId, note); + qDebug() << "[ChatWidget] Session note updated for" << sessionId; +} + void ChatWidget::onUserQuestionAnswered(const QString &requestId, const QJsonObject &answers) { qDebug() << "[ChatWidget] onUserQuestionAnswered, requestId:" << requestId; @@ -1144,6 +1168,9 @@ void ChatWidget::connectWebViewSignals() // Connect web view user question responses (MCP AskUserQuestion tool) connect(m_chatWebView, &ChatWebView::userQuestionAnswered, this, &ChatWidget::onUserQuestionAnswered); + // Connect concern flag from web view (permission dialog button) + connect(m_chatWebView, &ChatWebView::concernFlagged, this, &ChatWidget::onConcernFlaggedFromWeb); + // Edit tracking: connect EditTracker to ChatWebView connect(m_session->editTracker(), &EditTracker::editRecorded, m_chatWebView, &ChatWebView::addTrackedEdit); connect(m_session->editTracker(), &EditTracker::editsCleared, m_chatWebView, &ChatWebView::clearEditSummary); diff --git a/src/ui/ChatWidget.h b/src/ui/ChatWidget.h index 2d91204..499109a 100644 --- a/src/ui/ChatWidget.h +++ b/src/ui/ChatWidget.h @@ -16,10 +16,10 @@ class SessionStore; class SummaryStore; class SummaryGenerator; class SettingsStore; -class QComboBox; class QPushButton; class QToolButton; class QLabel; +class QMenu; class ChatWidget : public QWidget { @@ -61,6 +61,10 @@ public Q_SLOTS: void showUserQuestion(const QString &requestId, const QString &questionsJson); // Remove user question UI (on timeout or cancel) void removeUserQuestion(const QString &requestId); + // Update session note in the session store (MCP katecode_set_session_note tool) + void setSessionNote(const QString &sessionId, const QString ¬e); + // Capture edit data from EditorDBusService for diff display + void onEditApplied(const QString &filePath, const QString &oldText, const QString &newText); protected: void resizeEvent(QResizeEvent *event) override; @@ -79,10 +83,14 @@ public Q_SLOTS: // User question response (MCP AskUserQuestion tool) void userQuestionAnswered(const QString &requestId, const QString &responseJson); + // Emitted when a session becomes active (MCP katecode_get_session_id tool) + void sessionIdChanged(const QString &sessionId); + private Q_SLOTS: void onConnectClicked(); - void onResumeSessionClicked(); void onNewSessionClicked(); + void onFlagConcernClicked(); + void onConcernFlaggedFromWeb(); void onStopClicked(); void onMessageSubmitted(const QString &message); void onPermissionModeChanged(const QString &mode); @@ -120,7 +128,6 @@ private Q_SLOTS: void applyDiffColors(); void applyACPBackend(); void populateProviderCombo(); - void onProviderComboChanged(int index); void updateTerminalSize(); void resetWebView(); void connectWebViewSignals(); @@ -151,10 +158,10 @@ private Q_SLOTS: // UI components ChatWebView *m_chatWebView; ChatInputWidget *m_inputWidget; - QComboBox *m_providerCombo; + QToolButton *m_providerButton; QToolButton *m_connectButton; - QToolButton *m_resumeSessionButton; QToolButton *m_newSessionButton; + QToolButton *m_flagConcernButton; QLabel *m_titleLabel; QLabel *m_statusIndicator; QWidget *m_contextChipsContainer; @@ -163,6 +170,8 @@ private Q_SLOTS: SessionStore *m_sessionStore; PendingAction m_pendingAction; QString m_pendingSessionId; + QString m_pendingSessionName; + QString m_pendingSessionNote; // Summary generation SettingsStore *m_settingsStore; @@ -177,6 +186,13 @@ private Q_SLOTS: // Track whether user has sent a message (for summary generation decision) bool m_userSentMessage = false; + // Concern flag — set by toolbar button or permission dialog; triggers pause message on next message finish + bool m_concernFlagged = false; + // Track pending summary generation waiting for API key bool m_pendingSummaryAfterKeyLoad = false; + + // Recent edit data captured from EditorDBusService, consumed when tool_call_update arrives + struct RecentEditData { QString filePath, oldText, newText; }; + QList m_recentEdits; }; diff --git a/src/ui/SessionSelectionDialog.cpp b/src/ui/SessionSelectionDialog.cpp index 8d4bd16..734fbac 100644 --- a/src/ui/SessionSelectionDialog.cpp +++ b/src/ui/SessionSelectionDialog.cpp @@ -1,129 +1,274 @@ #include "SessionSelectionDialog.h" #include "../util/SummaryStore.h" -#include #include -#include -#include +#include +#include +#include #include +#include +#include #include -#include +#include #include #include +static constexpr int NewSessionRow = 0; +static constexpr int NewSessionDetailPage = 0; +static constexpr int ExistingSessionDetailPage = 1; +static constexpr int ManualIdDetailPage = 2; +static const QString ManualIdSentinel = QStringLiteral("__manual__"); + SessionSelectionDialog::SessionSelectionDialog(const QString &projectRoot, + SessionStore *sessionStore, SummaryStore *summaryStore, QWidget *parent) : QDialog(parent) , m_projectRoot(projectRoot) - , m_summaryStore(summaryStore) - , m_sessionCombo(nullptr) - , m_summaryPreview(nullptr) + , m_sessionStore(sessionStore) + , m_sessionList(nullptr) + , m_detailStack(nullptr) + , m_nameEdit(nullptr) + , m_newNoteEdit(nullptr) + , m_existingNoteEdit(nullptr) + , m_renameButton(nullptr) + , m_idEdit(nullptr) , m_result(Result::Cancelled) { - setWindowTitle(tr("Session Selection")); - setMinimumSize(500, 400); - resize(600, 500); + Q_UNUSED(summaryStore) + + setWindowTitle(tr("Connect to Session")); + setMinimumSize(520, 460); + resize(620, 540); auto *layout = new QVBoxLayout(this); - layout->setSpacing(12); + layout->setSpacing(10); - // Description - auto *descLabel = new QLabel(tr("Previous sessions found for this project."), this); - descLabel->setWordWrap(true); + auto *descLabel = new QLabel(tr("Select a session to resume, or start a new one:"), this); layout->addWidget(descLabel); - // Resume option - m_resumeRadio = new QRadioButton(tr("Resume session:"), this); - m_resumeRadio->setChecked(true); - layout->addWidget(m_resumeRadio); - - // Session dropdown - m_sessionCombo = new QComboBox(this); - m_sessionCombo->setMinimumWidth(300); - - // Load sessions (up to 10, most recent first) - QStringList sessionIds = m_summaryStore->listSessionSummaries(projectRoot); - int count = qMin(sessionIds.size(), 10); - for (int i = 0; i < count; ++i) { - const QString &sessionId = sessionIds.at(i); - QString summaryPath = m_summaryStore->summaryPath(projectRoot, sessionId); - QFileInfo fileInfo(summaryPath); - QString displayText = fileInfo.lastModified().toString(QStringLiteral("yyyy-MM-dd hh:mm")); - QString shortId = sessionId.left(12); - if (sessionId.length() > 12) { - shortId += QStringLiteral("..."); - } - displayText += QStringLiteral(" - ") + shortId; - m_sessionCombo->addItem(displayText, sessionId); + // Session list + m_sessionList = new QListWidget(this); + m_sessionList->setSelectionMode(QAbstractItemView::SingleSelection); + + auto *newItem = new QListWidgetItem(tr("+ New Session"), m_sessionList); + newItem->setData(Qt::UserRole, QString()); + + QList sessions = m_sessionStore->listSessions(projectRoot); + for (const SessionEntry &entry : sessions) { + QString label = entry.name + QStringLiteral(" — ") + + entry.timestamp.toString(QStringLiteral("yyyy-MM-dd hh:mm")); + auto *item = new QListWidgetItem(label, m_sessionList); + item->setData(Qt::UserRole, entry.id); } - layout->addWidget(m_sessionCombo); + auto *manualItem = new QListWidgetItem(tr("↩ Resume by session ID..."), m_sessionList); + manualItem->setData(Qt::UserRole, ManualIdSentinel); - // Summary preview - auto *summaryGroup = new QGroupBox(tr("Session Summary"), this); - auto *summaryLayout = new QVBoxLayout(summaryGroup); + m_sessionList->setCurrentRow(NewSessionRow); + layout->addWidget(m_sessionList, 1); - m_summaryPreview = new QTextEdit(this); - m_summaryPreview->setReadOnly(true); - m_summaryPreview->setStyleSheet(QStringLiteral( - "QTextEdit { background-color: palette(alternate-base); font-size: small; }")); - summaryLayout->addWidget(m_summaryPreview); + // Detail stack + m_detailStack = new QStackedWidget(this); - layout->addWidget(summaryGroup, 1); // stretch factor 1 to expand + // Page 0: new session — name + note + auto *newPage = new QWidget(this); + auto *newLayout = new QVBoxLayout(newPage); + newLayout->setContentsMargins(0, 0, 0, 0); + newLayout->addWidget(new QLabel(tr("Session name (optional):"), newPage)); + m_nameEdit = new QLineEdit(newPage); + m_nameEdit->setPlaceholderText( + QDateTime::currentDateTime().toString(QStringLiteral("'Session 'yyyy-MM-dd"))); + newLayout->addWidget(m_nameEdit); + newLayout->addWidget(new QLabel(tr("Note (optional):"), newPage)); + m_newNoteEdit = new QTextEdit(newPage); + m_newNoteEdit->setPlaceholderText(tr("What is this session for?")); + m_newNoteEdit->setMaximumHeight(80); + newLayout->addWidget(m_newNoteEdit); + newLayout->addStretch(); + m_detailStack->addWidget(newPage); // index 0 - // Load first session's summary - if (m_sessionCombo->count() > 0) { - onSessionChanged(0); - } + // Page 1: existing session — editable note + rename button + auto *existingPage = new QWidget(this); + auto *existingLayout = new QVBoxLayout(existingPage); + existingLayout->setContentsMargins(0, 0, 0, 0); + + auto *noteHeaderLayout = new QHBoxLayout(); + noteHeaderLayout->addWidget(new QLabel(tr("Note:"), existingPage)); + noteHeaderLayout->addStretch(); + m_renameButton = new QPushButton(tr("Rename"), existingPage); + m_renameButton->setFlat(true); + noteHeaderLayout->addWidget(m_renameButton); + m_deleteButton = new QPushButton(tr("Delete"), existingPage); + m_deleteButton->setFlat(true); + noteHeaderLayout->addWidget(m_deleteButton); + existingLayout->addLayout(noteHeaderLayout); - connect(m_sessionCombo, QOverload::of(&QComboBox::currentIndexChanged), - this, &SessionSelectionDialog::onSessionChanged); + m_existingNoteEdit = new QTextEdit(existingPage); + m_existingNoteEdit->setPlaceholderText(tr("Add a note about this session...")); + existingLayout->addWidget(m_existingNoteEdit, 1); + m_detailStack->addWidget(existingPage); // index 1 - // New session option - m_newRadio = new QRadioButton(tr("Start new session"), this); - layout->addWidget(m_newRadio); + // Page 2: manual ID input + auto *manualPage = new QWidget(this); + auto *manualLayout = new QVBoxLayout(manualPage); + manualLayout->setContentsMargins(0, 0, 0, 0); + manualLayout->addWidget(new QLabel(tr("Session ID:"), manualPage)); + m_idEdit = new QLineEdit(manualPage); + m_idEdit->setPlaceholderText(tr("Paste session ID here")); + manualLayout->addWidget(m_idEdit); + manualLayout->addStretch(); + m_detailStack->addWidget(manualPage); // index 2 - auto *newSessionLabel = new QLabel(tr(" Previous sessions will be preserved"), this); - newSessionLabel->setStyleSheet(QStringLiteral("color: gray; font-size: small;")); - layout->addWidget(newSessionLabel); + layout->addWidget(m_detailStack); // Buttons auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Cancel, this); auto *continueButton = buttonBox->addButton(tr("Continue"), QDialogButtonBox::AcceptRole); continueButton->setDefault(true); - connect(continueButton, &QPushButton::clicked, this, &SessionSelectionDialog::onContinueClicked); + connect(m_sessionList, &QListWidget::currentRowChanged, + this, &SessionSelectionDialog::onItemChanged); + connect(m_renameButton, &QPushButton::clicked, + this, &SessionSelectionDialog::onRenameClicked); + connect(m_deleteButton, &QPushButton::clicked, + this, &SessionSelectionDialog::onDeleteClicked); + connect(continueButton, &QPushButton::clicked, + this, &SessionSelectionDialog::onContinueClicked); connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); layout->addWidget(buttonBox); } -void SessionSelectionDialog::onContinueClicked() +void SessionSelectionDialog::saveCurrentNote() { - if (m_resumeRadio->isChecked()) { - m_result = Result::Resume; + if (m_lastExistingSessionId.isEmpty()) { + return; + } + m_sessionStore->setSessionNote(m_projectRoot, m_lastExistingSessionId, + m_existingNoteEdit->toPlainText().trimmed()); +} + +void SessionSelectionDialog::onItemChanged(int row) +{ + if (row < 0 || row >= m_sessionList->count()) { + return; + } + + // Auto-save note for the session we're leaving + if (m_detailStack->currentIndex() == ExistingSessionDetailPage) { + saveCurrentNote(); + } + + QString sessionId = m_sessionList->item(row)->data(Qt::UserRole).toString(); + + if (row == NewSessionRow) { + m_lastExistingSessionId.clear(); + m_detailStack->setCurrentIndex(NewSessionDetailPage); + } else if (sessionId == ManualIdSentinel) { + m_lastExistingSessionId.clear(); + m_detailStack->setCurrentIndex(ManualIdDetailPage); + m_idEdit->setFocus(); } else { - m_result = Result::NewSession; + m_lastExistingSessionId = sessionId; + m_detailStack->setCurrentIndex(ExistingSessionDetailPage); + + // Load the note for this session + QList sessions = m_sessionStore->listSessions(m_projectRoot); + for (const SessionEntry &entry : sessions) { + if (entry.id == sessionId) { + m_existingNoteEdit->setPlainText(entry.note); + break; + } + } } - accept(); } -void SessionSelectionDialog::onSessionChanged(int index) +void SessionSelectionDialog::onRenameClicked() { - if (index < 0 || !m_summaryPreview) { + int row = m_sessionList->currentRow(); + if (row <= NewSessionRow || row >= m_sessionList->count() - 1) { return; } - QString sessionId = m_sessionCombo->itemData(index).toString(); - QString summary = m_summaryStore->loadSummary(m_projectRoot, sessionId); - m_summaryPreview->setPlainText(summary); + QString currentName = m_sessionList->item(row)->text().section(QStringLiteral(" — "), 0, 0); + bool ok = false; + QString newName = QInputDialog::getText(this, tr("Rename Session"), + tr("New name:"), QLineEdit::Normal, + currentName, &ok); + if (!ok || newName.trimmed().isEmpty()) { + return; + } + + newName = newName.trimmed(); + QString sessionId = m_sessionList->item(row)->data(Qt::UserRole).toString(); + m_sessionStore->renameSession(m_projectRoot, sessionId, newName); + + // Update the list item label + QString timestamp = m_sessionList->item(row)->text().section(QStringLiteral(" — "), 1); + m_sessionList->item(row)->setText(newName + QStringLiteral(" — ") + timestamp); } -QString SessionSelectionDialog::selectedSessionId() const +void SessionSelectionDialog::onDeleteClicked() { - if (!m_sessionCombo || m_sessionCombo->count() == 0) { - return QString(); + int row = m_sessionList->currentRow(); + if (row <= NewSessionRow || row >= m_sessionList->count() - 1) { + return; + } + + QString sessionName = m_sessionList->item(row)->text().section(QStringLiteral(" — "), 0, 0); + auto answer = QMessageBox::question(this, tr("Delete Session"), + tr("Do you really want to delete \"%1\"?").arg(sessionName), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + + if (answer != QMessageBox::Yes) { + return; } - return m_sessionCombo->currentData().toString(); + + QString sessionId = m_sessionList->item(row)->data(Qt::UserRole).toString(); + m_sessionStore->removeSession(m_projectRoot, sessionId); + m_lastExistingSessionId.clear(); + + delete m_sessionList->takeItem(row); + + // Select nearest remaining session + int newRow = qMin(row, m_sessionList->count() - 2); // -2 to avoid manual ID row + m_sessionList->setCurrentRow(qMax(newRow, NewSessionRow)); +} + +void SessionSelectionDialog::onContinueClicked() +{ + // Save any unsaved note before accepting + if (m_detailStack->currentIndex() == ExistingSessionDetailPage) { + saveCurrentNote(); + } + + int row = m_sessionList->currentRow(); + QString sessionId = m_sessionList->item(row)->data(Qt::UserRole).toString(); + + if (row == NewSessionRow) { + m_result = Result::NewSession; + m_selectedSessionId.clear(); + m_selectedSessionName = m_nameEdit->text().trimmed(); + if (m_selectedSessionName.isEmpty()) { + m_selectedSessionName = QDateTime::currentDateTime() + .toString(QStringLiteral("'Session 'yyyy-MM-dd")); + } + m_selectedSessionNote = m_newNoteEdit->toPlainText().trimmed(); + } else if (sessionId == ManualIdSentinel) { + m_selectedSessionId = m_idEdit->text().trimmed(); + if (m_selectedSessionId.isEmpty()) { + m_idEdit->setPlaceholderText(tr("Session ID required")); + return; + } + m_result = Result::Resume; + m_selectedSessionName.clear(); + m_selectedSessionNote.clear(); + } else { + m_result = Result::Resume; + m_selectedSessionId = sessionId; + m_selectedSessionName.clear(); + m_selectedSessionNote.clear(); + } + accept(); } diff --git a/src/ui/SessionSelectionDialog.h b/src/ui/SessionSelectionDialog.h index ba96d88..b20c393 100644 --- a/src/ui/SessionSelectionDialog.h +++ b/src/ui/SessionSelectionDialog.h @@ -1,20 +1,22 @@ #pragma once +#include "../util/SessionStore.h" #include -class QRadioButton; +class QListWidget; +class QLineEdit; class QLabel; class QPushButton; +class QStackedWidget; class QTextEdit; -class QComboBox; class SummaryStore; /** - * SessionSelectionDialog - Asks user whether to resume or start new session. + * SessionSelectionDialog - Shown on Connect when saved sessions exist. * - * Shown when connecting to a project that has stored session summaries. - * Shows a dropdown of available sessions (up to 10, most recent first) - * and displays the selected session's summary. + * Lists saved sessions most-recent-first. "New Session" is always at the top. + * Selecting an existing session shows an editable note and a Rename button. + * Selecting "New Session" shows name + note input fields. */ class SessionSelectionDialog : public QDialog { @@ -28,23 +30,46 @@ class SessionSelectionDialog : public QDialog }; explicit SessionSelectionDialog(const QString &projectRoot, + SessionStore *sessionStore, SummaryStore *summaryStore, QWidget *parent = nullptr); ~SessionSelectionDialog() override = default; Result selectedResult() const { return m_result; } - QString selectedSessionId() const; + QString selectedSessionId() const { return m_selectedSessionId; } + QString selectedSessionName() const { return m_selectedSessionName; } + QString selectedSessionNote() const { return m_selectedSessionNote; } private Q_SLOTS: + void onItemChanged(int row); void onContinueClicked(); - void onSessionChanged(int index); + void onRenameClicked(); + void onDeleteClicked(); + void saveCurrentNote(); private: QString m_projectRoot; - SummaryStore *m_summaryStore; - QComboBox *m_sessionCombo; - QRadioButton *m_resumeRadio; - QRadioButton *m_newRadio; - QTextEdit *m_summaryPreview; + SessionStore *m_sessionStore; + + QListWidget *m_sessionList; + QStackedWidget *m_detailStack; + + // New session page widgets + QLineEdit *m_nameEdit; + QTextEdit *m_newNoteEdit; + + // Existing session page widgets + QTextEdit *m_existingNoteEdit; + QPushButton *m_renameButton; + QPushButton *m_deleteButton; + + // Manual ID page widgets + QLineEdit *m_idEdit; + + QString m_lastExistingSessionId; // tracks which session's note is loaded + Result m_result; + QString m_selectedSessionId; + QString m_selectedSessionName; + QString m_selectedSessionNote; }; diff --git a/src/util/SessionStore.cpp b/src/util/SessionStore.cpp index f642eef..07e100c 100644 --- a/src/util/SessionStore.cpp +++ b/src/util/SessionStore.cpp @@ -10,43 +10,139 @@ SessionStore::SessionStore(QObject *parent) qDebug() << "[SessionStore] Initialized, config file:" << m_settings.fileName(); } -void SessionStore::saveSession(const QString &projectRoot, const QString &sessionId) +void SessionStore::addSession(const QString &projectRoot, const QString &sessionId, const QString &name, const QString ¬e) { if (projectRoot.isEmpty() || sessionId.isEmpty()) { - qWarning() << "[SessionStore] Cannot save: empty project root or session ID"; + qWarning() << "[SessionStore] Cannot add: empty project root or session ID"; return; } QString key = normalizeKey(projectRoot); + QString sessionName = name.isEmpty() + ? QStringLiteral("Session %1").arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd"))) + : name; + + // Load existing sessions + QList existing = listSessions(projectRoot); + + // If session already exists, preserve its name/note unless new ones are provided + QString resolvedNote = note; + for (const SessionEntry &e : existing) { + if (e.id == sessionId) { + if (name.isEmpty()) sessionName = e.name; + if (note.isEmpty()) resolvedNote = e.note; + break; + } + } + + // Remove duplicate if same ID already in list + existing.erase(std::remove_if(existing.begin(), existing.end(), + [&sessionId](const SessionEntry &e) { return e.id == sessionId; }), + existing.end()); + + // Prepend new entry + SessionEntry entry; + entry.id = sessionId; + entry.name = sessionName; + entry.note = resolvedNote; + entry.timestamp = QDateTime::currentDateTime(); + existing.prepend(entry); + + // Cap at MaxSessions + if (existing.size() > MaxSessions) { + existing = existing.mid(0, MaxSessions); + } - m_settings.beginGroup(QStringLiteral("Sessions")); - m_settings.setValue(key, sessionId); + // Write back + m_settings.beginGroup(QStringLiteral("Sessions2")); + m_settings.beginGroup(key); + m_settings.remove(QString()); // clear existing entries for this key + m_settings.beginWriteArray(QStringLiteral("sessions")); + for (int i = 0; i < existing.size(); ++i) { + m_settings.setArrayIndex(i); + m_settings.setValue(QStringLiteral("id"), existing.at(i).id); + m_settings.setValue(QStringLiteral("name"), existing.at(i).name); + m_settings.setValue(QStringLiteral("note"), existing.at(i).note); + m_settings.setValue(QStringLiteral("timestamp"), existing.at(i).timestamp.toString(Qt::ISODate)); + } + m_settings.endArray(); + m_settings.endGroup(); m_settings.endGroup(); m_settings.sync(); - qDebug() << "[SessionStore] Saved session for" << projectRoot << ":" << sessionId; + qDebug() << "[SessionStore] Added session" << sessionId << "for" << projectRoot; } -QString SessionStore::getLastSession(const QString &projectRoot) const +QList SessionStore::listSessions(const QString &projectRoot) const { if (projectRoot.isEmpty()) { - return QString(); + return {}; } QString key = normalizeKey(projectRoot); - - // Need to cast away const for QSettings access QSettings &settings = const_cast(m_settings); - settings.beginGroup(QStringLiteral("Sessions")); - QString sessionId = settings.value(key).toString(); + settings.beginGroup(QStringLiteral("Sessions2")); + settings.beginGroup(key); + int count = settings.beginReadArray(QStringLiteral("sessions")); + + QList result; + for (int i = 0; i < count; ++i) { + settings.setArrayIndex(i); + SessionEntry entry; + entry.id = settings.value(QStringLiteral("id")).toString(); + entry.name = settings.value(QStringLiteral("name")).toString(); + entry.note = settings.value(QStringLiteral("note")).toString(); + entry.timestamp = QDateTime::fromString( + settings.value(QStringLiteral("timestamp")).toString(), Qt::ISODate); + if (!entry.id.isEmpty()) { + result.append(entry); + } + } + + settings.endArray(); + settings.endGroup(); settings.endGroup(); - if (!sessionId.isEmpty()) { - qDebug() << "[SessionStore] Found session for" << projectRoot << ":" << sessionId; + return result; +} + +bool SessionStore::hasSession(const QString &projectRoot) const +{ + return !listSessions(projectRoot).isEmpty(); +} + +void SessionStore::removeSession(const QString &projectRoot, const QString &sessionId) +{ + QList sessions = listSessions(projectRoot); + sessions.erase(std::remove_if(sessions.begin(), sessions.end(), + [&sessionId](const SessionEntry &e) { return e.id == sessionId; }), + sessions.end()); + writeAll(projectRoot, sessions); +} + +void SessionStore::renameSession(const QString &projectRoot, const QString &sessionId, const QString &newName) +{ + QList sessions = listSessions(projectRoot); + for (auto &entry : sessions) { + if (entry.id == sessionId) { + entry.name = newName; + break; + } } + writeAll(projectRoot, sessions); +} - return sessionId; +void SessionStore::setSessionNote(const QString &projectRoot, const QString &sessionId, const QString ¬e) +{ + QList sessions = listSessions(projectRoot); + for (auto &entry : sessions) { + if (entry.id == sessionId) { + entry.note = note; + break; + } + } + writeAll(projectRoot, sessions); } void SessionStore::clearSession(const QString &projectRoot) @@ -56,28 +152,48 @@ void SessionStore::clearSession(const QString &projectRoot) } QString key = normalizeKey(projectRoot); - - m_settings.beginGroup(QStringLiteral("Sessions")); + m_settings.beginGroup(QStringLiteral("Sessions2")); m_settings.remove(key); m_settings.endGroup(); m_settings.sync(); - qDebug() << "[SessionStore] Cleared session for" << projectRoot; + qDebug() << "[SessionStore] Cleared sessions for" << projectRoot; } -bool SessionStore::hasSession(const QString &projectRoot) const +QString SessionStore::getLastSession(const QString &projectRoot) const +{ + QList sessions = listSessions(projectRoot); + return sessions.isEmpty() ? QString() : sessions.first().id; +} + +void SessionStore::saveSession(const QString &projectRoot, const QString &sessionId) { - return !getLastSession(projectRoot).isEmpty(); + addSession(projectRoot, sessionId, QString()); +} + +void SessionStore::writeAll(const QString &projectRoot, const QList &sessions) +{ + QString key = normalizeKey(projectRoot); + m_settings.beginGroup(QStringLiteral("Sessions2")); + m_settings.beginGroup(key); + m_settings.remove(QString()); + m_settings.beginWriteArray(QStringLiteral("sessions")); + for (int i = 0; i < sessions.size(); ++i) { + m_settings.setArrayIndex(i); + m_settings.setValue(QStringLiteral("id"), sessions.at(i).id); + m_settings.setValue(QStringLiteral("name"), sessions.at(i).name); + m_settings.setValue(QStringLiteral("note"), sessions.at(i).note); + m_settings.setValue(QStringLiteral("timestamp"), sessions.at(i).timestamp.toString(Qt::ISODate)); + } + m_settings.endArray(); + m_settings.endGroup(); + m_settings.endGroup(); + m_settings.sync(); } QString SessionStore::normalizeKey(const QString &projectRoot) const { - // Use cleaned absolute path as key, but replace slashes for QSettings compatibility QString normalized = QDir::cleanPath(projectRoot); - - // Replace slashes with a separator that works in QSettings keys - // QSettings on some platforms treats slashes as group separators normalized.replace(QLatin1Char('/'), QLatin1String("__")); - return normalized; } diff --git a/src/util/SessionStore.h b/src/util/SessionStore.h index 14ecd69..5412e4d 100644 --- a/src/util/SessionStore.h +++ b/src/util/SessionStore.h @@ -1,14 +1,22 @@ #pragma once +#include #include #include #include +struct SessionEntry { + QString id; + QString name; + QString note; + QDateTime timestamp; +}; + /** - * SessionStore - Persists session IDs per project root. + * SessionStore - Persists named session history per project root. * * Uses QSettings to store the mapping in ~/.config/kate-code.conf. - * Each project root has at most one associated session ID. + * Each project root has a list of sessions (most recent first), capped at 20. */ class SessionStore : public QObject { @@ -18,21 +26,37 @@ class SessionStore : public QObject explicit SessionStore(QObject *parent = nullptr); ~SessionStore() override = default; - // Save session ID for a project root - void saveSession(const QString &projectRoot, const QString &sessionId); + // Add a session to the front of the list for a project root (capped at 20) + void addSession(const QString &projectRoot, const QString &sessionId, const QString &name, const QString ¬e = QString()); - // Get the last session ID for a project root (empty if none) - QString getLastSession(const QString &projectRoot) const; + // Rename a session + void renameSession(const QString &projectRoot, const QString &sessionId, const QString &newName); - // Clear the stored session for a project root - void clearSession(const QString &projectRoot); + // Set or update the note for a session + void setSessionNote(const QString &projectRoot, const QString &sessionId, const QString ¬e); + + // List all sessions for a project root, most recent first + QList listSessions(const QString &projectRoot) const; - // Check if a session exists for a project root + // Check if any sessions exist for a project root bool hasSession(const QString &projectRoot) const; + // Remove a specific session entry from the list + void removeSession(const QString &projectRoot, const QString &sessionId); + + // Clear all sessions for a project root + void clearSession(const QString &projectRoot); + + // Compatibility: get the most recent session ID (empty if none) + QString getLastSession(const QString &projectRoot) const; + + // Compatibility: save a single session (adds to front of list with auto-name) + void saveSession(const QString &projectRoot, const QString &sessionId); + private: - // Normalize the project root path for consistent keys QString normalizeKey(const QString &projectRoot) const; + void writeAll(const QString &projectRoot, const QList &sessions); QSettings m_settings; + static constexpr int MaxSessions = 20; }; diff --git a/src/web/chat.css b/src/web/chat.css index 56a6038..b60027b 100644 --- a/src/web/chat.css +++ b/src/web/chat.css @@ -756,15 +756,16 @@ pre.diff .diff-context { pre.diff .diff-remove { background-color: var(--diff-remove-bg, #7a4347); - /* Don't set color - let syntax highlighting show through */ + color: var(--diff-remove-fg, #e0e0e0); } pre.diff .diff-add { background-color: var(--diff-add-bg, #275850); - /* Don't set color - let syntax highlighting show through */ + color: var(--diff-add-fg, #e0e0e0); } -/* Ensure hljs spans inside diff lines are inline, not block */ +/* Ensure hljs spans inside diff lines are inline, not block. + Also override any hljs colours so the explicit diff foreground shows through. */ pre.diff .diff-context span, pre.diff .diff-remove span, pre.diff .diff-add span { @@ -772,6 +773,10 @@ pre.diff .diff-add span { padding: 0; } +/* Override hljs colours inside add/remove lines so explicit diff-fg wins */ +pre.diff .diff-remove span { color: var(--diff-remove-fg, #e0e0e0); } +pre.diff .diff-add span { color: var(--diff-add-fg, #e0e0e0); } + /* Multiple edits styling */ .edit-section { margin-top: 12px; @@ -988,6 +993,27 @@ pre.diff .diff-add span { font-weight: 500; } +.permission-flag-concern { + padding: 4px 10px; + border: 1px solid var(--text-muted); + border-radius: 3px; + cursor: pointer; + font-size: 11px; + color: var(--text-muted); + margin-left: auto; +} + +.permission-flag-concern:hover { + border-color: #f0ad4e; + color: #f0ad4e; +} + +.permission-flag-active { + border-color: #f0ad4e; + color: #f0ad4e; + background-color: rgba(240, 173, 78, 0.1); +} + .permission-content { margin: 4px 0; } diff --git a/src/web/chat.js b/src/web/chat.js index 0da982c..cd9ed85 100644 --- a/src/web/chat.js +++ b/src/web/chat.js @@ -537,6 +537,25 @@ function updateToolCall(messageId, toolCallId, status, base64Result, filePath, t updateMessageDOM(messageId); } +// Update tool call diff data after D-Bus edit parameters become available +// oldText and newText are base64-encoded to safely pass multiline content +function setToolCallDiff(messageId, toolCallId, filePath, b64OldText, b64NewText) { + if (!messages[messageId]) return; + let toolCall = messages[messageId].toolCalls.find(tc => tc.id === toolCallId); + if (!toolCall) return; + + if (filePath) toolCall.filePath = filePath; + try { + toolCall.oldText = b64OldText ? decodeURIComponent(escape(atob(b64OldText))) : ''; + toolCall.newText = b64NewText ? decodeURIComponent(escape(atob(b64NewText))) : ''; + } catch (e) { + console.error('setToolCallDiff decode error:', e); + return; + } + + updateMessageDOM(messageId); +} + // Render a message to DOM function renderMessage(message) { const container = document.getElementById('messages'); @@ -1321,6 +1340,8 @@ function showPermissionRequest(requestId, toolName, input, options) { html += `
${escapeHtml(label)}
`; }); + html += `
⚠️ I have a concern
`; + html += ` @@ -1339,6 +1360,14 @@ function showPermissionRequest(requestId, toolName, input, options) { scrollToBottom(); } +// Flag a concern from the permission request UI +function flagConcernFromPermission(el) { + el.classList.toggle('permission-flag-active'); + if (window.bridge) { + window.bridge.flagConcern(); + } +} + // Respond to permission request function respondToPermission(requestId, optionId) { // Remove the permission UI