diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
deleted file mode 100644
index adfdcb1..0000000
--- a/.claude/CLAUDE.md
+++ /dev/null
@@ -1,7 +0,0 @@
-
-# Recent Activity
-
-
-
-*No recent activity*
-
\ No newline at end of file
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..ae5bbc5
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,15 @@
+# Build artifacts
+build/
+dist/
+pkg/
+src/build/
+src/kate-code/
+
+# Git
+.git/
+
+# Packages
+*.tar.gz
+*.pkg.tar.zst
+*.deb
+*.rpm
diff --git a/CLAUDE.md b/CLAUDE.md
index d565dec..fc4d8e5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -11,10 +11,16 @@ cmake --build build
## Installation
+Test install:
```bash
cmake --install build --prefix ~/.local
```
+Full install:
+- Bump the version (minor if minor etc)
+- Make an RPM. Use the spec at /home/ben/rpmbuild/SPECS/kate-code.spec
+- Copy the new RPM to the local repo at /volumes/localrepo
+
Then restart Kate and enable the plugin via:
Settings > Configure Kate > Plugins > Enable "Kate Code"
@@ -23,6 +29,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
+- **MCP**: MCPServer - Built-in MCP server executable for Kate editor tools
- **UI**: ChatWidget, ChatWebView, ChatInputWidget, PermissionDialog
- **Util**: KDEColorScheme - reads ~/.config/kdeglobals
@@ -32,51 +39,19 @@ Settings > Configure Kate > Plugins > Enable "Kate Code"
- `src/acp/ACPService.{h,cpp}` - QProcess-based claude-code-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)
+- `src/mcp/main.cpp` - MCP server entry point (stdio JSON-RPC loop)
- `src/ui/ChatWidget.{h,cpp}` - Main chat interface with ACP integration
- `src/ui/ChatWebView.{h,cpp}` - QWebEngineView for HTML/CSS/JS rendering
- `src/ui/ChatInputWidget.{h,cpp}` - Multiline text input with send button
- `src/ui/PermissionDialog.{h,cpp}` - Modal dialog for tool approval requests
+- `src/config/SettingsStore.{h,cpp}` - QSettings + KWallet storage, ACPProvider management
+- `src/config/KateCodeConfigPage.{h,cpp}` - Kate config page with provider table, diff colors, summaries
- `src/util/KDEColorScheme.{h,cpp}` - KDE color extraction from kdeglobals
- `src/web/chat.{html,css,js}` - Web assets for chat interface
- `src/katecode.qrc` - Qt resource file for web assets
- `src/katecode.json` - KPlugin metadata
-## Development Progress
-
-### Phase 1: Core Plugin Structure ✓
-- [x] CMake build system with KF6/Qt6 dependencies
-- [x] Directory structure
-- [x] KateCodePlugin with K_PLUGIN_CLASS_WITH_JSON registration
-- [x] KateCodeView with side panel tool view (left position)
-- [x] Basic ChatWidget placeholder
-- [x] Successful compilation
-
-### Phase 2: ACP Integration ✓
-- [x] ACPService with QProcess management
-- [x] ACPModels data structures
-- [x] ACPSession with protocol flow (initialize → session/new → session/prompt)
-- [x] ChatWidget integrated with ACP session
-- [x] Basic UI for testing (Connect button, chat display, message input)
-
-### Phase 3: Chat Functionality ✓
-- [x] ChatWebView with QWebEngineView and HTML/CSS/JS rendering
-- [x] ChatInputWidget with multiline input (Enter=send, Shift+Enter=newline)
-- [x] Message streaming display with role indicators and timestamps
-- [x] Tool call display in chat UI
-- [x] Qt resource system for bundling web assets
-- [x] JavaScript bridge for updating messages from C++
-
-### Phase 4: Full Features ✓
-- [x] KDE color scheme extraction from ~/.config/kdeglobals
-- [x] Color injection into WebView via JavaScript
-- [x] Permission dialog for tool approvals with option selection
-- [x] Error display via QMessageBox
-- [x] Permission response handling back to ACP
-
-### Phase 5: Kate Integration (TODO)
-- [ ] Context actions
-- [ ] File/selection context in prompts
-
## Lessons Learned
### Build System
@@ -98,18 +73,19 @@ Settings > Configure Kate > Plugins > Enable "Kate Code"
- `plan` (todos)
- Permission requests via `session/request_permission` with requestId that requires response
+### ACP Provider Configuration
+- Providers are defined by `ACPProvider` struct: id, description, executable, options, builtin flag
+- Two built-in providers (Claude Code, Vibe/Mistral) are hardcoded and cannot be deleted
+- Custom providers are stored in QSettings via `beginWriteArray("ACP/customProviders")`
+- Active provider selection persisted as `ACP/activeProvider` string id
+- Provider selector is a QComboBox in the ChatWidget header (disabled while connected)
+- Unavailable executables shown grayed with "(not found)" suffix
+- Settings page shows a QTableWidget for managing custom providers (add/edit/remove)
+- Old `ACPBackend` enum settings are auto-migrated on first launch
+
### Web Interface
- QWebEngineView loads HTML from Qt resources (qrc:/katecode/web/chat.html)
- JavaScript functions exposed: `addMessage()`, `updateMessage()`, `finishMessage()`, `addToolCall()`, `updateToolCall()`
- C++ calls JavaScript via `page()->runJavaScript()` with proper string escaping
- CSS uses CSS variables for theming (ready for KDE color injection in Phase 4)
- Responsive layout with message bubbles, tool call display, and streaming indicator
-
-
-
-# Recent Activity
-
-
-
-*No recent activity*
-
\ No newline at end of file
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 7f7d2e0..d3fa47e 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.5.1)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -18,6 +18,8 @@ find_package(KF6 ${REQUIRED_KF_VERSION} REQUIRED COMPONENTS
TextEditor
CoreAddons
XmlGui
+ SyntaxHighlighting
+ Pty
)
find_package(Qt6 REQUIRED COMPONENTS
diff --git a/Dockerfile.arch b/Dockerfile.arch
new file mode 100644
index 0000000..621e9ae
--- /dev/null
+++ b/Dockerfile.arch
@@ -0,0 +1,62 @@
+# Dockerfile for building kate-code .pkg.tar.zst package (Arch Linux)
+# Usage:
+# docker build -f Dockerfile.arch -t kate-code-arch .
+# docker run --rm -v $(pwd)/dist:/dist kate-code-arch
+
+FROM archlinux:base-devel
+
+# Update and install build dependencies
+RUN pacman -Syu --noconfirm && \
+ pacman -S --noconfirm --needed \
+ cmake \
+ extra-cmake-modules \
+ ktexteditor \
+ ki18n \
+ kcoreaddons \
+ kxmlgui \
+ syntax-highlighting \
+ kwallet \
+ kpty \
+ qt6-webengine \
+ git \
+ && pacman -Scc --noconfirm
+
+# Create build user (makepkg cannot run as root)
+RUN useradd -m builder && \
+ echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
+ mkdir -p /home/builder/build && \
+ chown builder:builder /home/builder/build
+
+WORKDIR /home/builder/build
+
+# Copy source
+COPY --chown=builder:builder . /home/builder/build/
+
+# Create tarball and modify PKGBUILD for local source
+RUN tar czf /tmp/kate-code-1.0.0.tar.gz \
+ --transform 's,^\.,kate-code-1.0.0,' \
+ --exclude='.git' \
+ --exclude='build' \
+ --exclude='dist' \
+ --exclude='pkg' \
+ --exclude='src/build' \
+ --exclude='src/kate-code' \
+ --exclude='*.tar.gz' \
+ --exclude='*.pkg.tar.zst' \
+ . && \
+ mv /tmp/kate-code-1.0.0.tar.gz . && \
+ sed -i 's|source=.*|source=("kate-code-1.0.0.tar.gz")|' PKGBUILD && \
+ sed -i 's|sha256sums=.*|sha256sums=("SKIP")|' PKGBUILD && \
+ sed -i 's|-S "$pkgname"|-S "$srcdir/kate-code-1.0.0"|' PKGBUILD
+
+# Switch to builder user
+USER builder
+
+# Build the package using makepkg
+RUN makepkg -sf --noconfirm --skipchecksums
+
+# Copy package to dist
+USER root
+RUN mkdir -p /dist && cp /home/builder/build/*.pkg.tar.zst /dist/
+
+CMD ["cp", "-r", "/dist/.", "/output/"]
diff --git a/Dockerfile.deb b/Dockerfile.deb
new file mode 100644
index 0000000..5c8e665
--- /dev/null
+++ b/Dockerfile.deb
@@ -0,0 +1,36 @@
+# Dockerfile for building kate-code .deb package
+# Usage:
+# docker build -f Dockerfile.deb -t kate-code-deb .
+# docker run --rm -v $(pwd)/dist:/dist kate-code-deb
+
+FROM debian:trixie
+
+# Install build dependencies
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ cmake \
+ extra-cmake-modules \
+ libkf6texteditor-dev \
+ libkf6i18n-dev \
+ libkf6coreaddons-dev \
+ libkf6xmlgui-dev \
+ libkf6syntaxhighlighting-dev \
+ libkf6wallet-dev \
+ libkf6pty-dev \
+ qt6-webengine-dev \
+ devscripts \
+ debhelper \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /build
+
+# Copy source
+COPY . /build/
+
+# Build the package
+RUN dpkg-buildpackage -us -uc -b
+
+# Move .deb to output directory
+RUN mkdir -p /dist && mv /kate-code_*.deb /dist/ 2>/dev/null || mv ../*.deb /dist/
+
+CMD ["cp", "-r", "/dist/.", "/output/"]
diff --git a/Dockerfile.rpm b/Dockerfile.rpm
new file mode 100644
index 0000000..0ebaf38
--- /dev/null
+++ b/Dockerfile.rpm
@@ -0,0 +1,48 @@
+# Dockerfile for building kate-code .rpm package
+# Usage:
+# docker build -f Dockerfile.rpm -t kate-code-rpm .
+# docker run --rm -v $(pwd)/dist:/dist kate-code-rpm
+
+FROM fedora:41
+
+# Install build dependencies
+RUN dnf install -y \
+ rpm-build \
+ cmake \
+ extra-cmake-modules \
+ gcc-c++ \
+ kf6-ktexteditor-devel \
+ kf6-ki18n-devel \
+ kf6-kcoreaddons-devel \
+ kf6-kxmlgui-devel \
+ kf6-syntax-highlighting-devel \
+ kf6-kwallet-devel \
+ kf6-kpty-devel \
+ qt6-qtwebengine-devel \
+ && dnf clean all
+
+WORKDIR /build
+
+# Copy source
+COPY . /build/
+
+# Create tarball for rpmbuild
+RUN mkdir -p /root/rpmbuild/{SOURCES,SPECS} && \
+ tar czf /root/rpmbuild/SOURCES/kate-code-1.0.0.tar.gz \
+ --transform 's,^,kate-code-1.0.0/,' \
+ --exclude='.git' \
+ --exclude='build' \
+ --exclude='dist' \
+ --exclude='*.tar.gz' \
+ --exclude='*.pkg.tar.zst' \
+ . && \
+ cp kate-code.spec /root/rpmbuild/SPECS/
+
+# Build the package
+RUN rpmbuild -ba /root/rpmbuild/SPECS/kate-code.spec
+
+# Copy RPM to dist
+RUN mkdir -p /dist && \
+ cp /root/rpmbuild/RPMS/*/*.rpm /dist/
+
+CMD ["cp", "-r", "/dist/.", "/output/"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..bf8fec7
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Kate Code Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..49e6dfd
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,19 @@
+# Kate Code Plugin Makefile
+
+BUILD_DIR := build
+BUILD_TYPE ?= Release
+
+.PHONY: build install package clean
+
+build:
+ cmake -B $(BUILD_DIR) -DCMAKE_BUILD_TYPE=$(BUILD_TYPE)
+ cmake --build $(BUILD_DIR)
+
+install: build
+ cmake --install $(BUILD_DIR)
+
+package:
+ ./build-packages.sh
+
+clean:
+ rm -rf $(BUILD_DIR)
diff --git a/PKGBUILD b/PKGBUILD
new file mode 100644
index 0000000..7bf1d11
--- /dev/null
+++ b/PKGBUILD
@@ -0,0 +1,39 @@
+# Maintainer: Your Name
+pkgname=kate-code
+pkgver=1.0.0
+pkgrel=1
+pkgdesc="Claude Code integration for Kate text editor"
+arch=('x86_64')
+url="https://github.com/undefinedopcode/kate-code"
+license=('MIT')
+depends=(
+ 'ktexteditor'
+ 'ki18n'
+ 'kcoreaddons'
+ 'kxmlgui'
+ 'syntax-highlighting'
+ 'kwallet'
+ 'kpty'
+ 'qt6-webengine'
+)
+makedepends=(
+ 'cmake'
+ 'extra-cmake-modules'
+ 'gcc'
+)
+optdepends=(
+ 'claude-code-acp: Required for Claude Code functionality'
+)
+source=("${pkgname}::git+https://github.com/undefinedopcode/kate-code.git")
+sha256sums=('SKIP')
+
+build() {
+ cmake -B build -S "$pkgname" \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_INSTALL_PREFIX=/usr
+ cmake --build build
+}
+
+package() {
+ DESTDIR="$pkgdir" cmake --install build
+}
diff --git a/README.md b/README.md
index dbf010e..c6b1013 100644
--- a/README.md
+++ b/README.md
@@ -1,105 +1,159 @@
# Kate Code
-A Kate text editor plugin that integrates Claude Code using the ACP (Agent Client Protocol), bringing AI-powered coding assistance directly into your KDE development environment.
+A plugin for the Kate text editor that integrates ACP-compatible coding agents directly into the editor's interface. It provides an interactive chat panel where you can work with Claude Code, Vibe, or any other configured ACP provider without leaving your KDE development environment.
+
+
## Features
-- **Real-time Chat Interface**: Qt WebEngineView-based chat with markdown rendering and message streaming
-- **Inline Tool Call Display**: See what tools Claude is using with expandable details
- - Bash commands with full command and output display
- - File operations with path information
- - Collapsible tool results for better readability
-- **Task Management**: Collapsible task list anchored at bottom showing current progress
- - Real-time status updates (pending, in progress, completed)
- - Progress counter showing completed vs total tasks
-- **Permission Handling**: Inline permission dialogs for tool approvals
- - Allow always, allow once, or reject operations
- - Clear display of tool inputs and options
-- **KDE Integration**:
- - Automatic color scheme extraction from `~/.config/kdeglobals`
- - Seamless integration with Kate's side panel layout
- - Context-aware: passes current file, selection, and project root to Claude
-- **Full ACP Protocol Support**: JSON-RPC 2.0 over stdin/stdout with claude-code-acp subprocess
+### Chat Interface
+- **Real-time Streaming**: Messages stream in as the selected agent responds, with markdown formatting via marked.js
+- **Syntax Highlighting**: Language-aware code highlighting via highlight.js supporting 50+ languages
+- **Theme Integration**: Automatically adapts to your Kate/KDE color scheme (dark/light)
+- **Copy Buttons**: One-click clipboard copy on all code blocks
+
+### Tool Visualization
+See what the active agent is doing with inline tool call displays:
+- **Bash**: Command preview in header, full command and output in expandable details
+- **Edit**: Unified diffs with syntax-highlighted code showing exactly what changed
+- **Write**: Syntax-highlighted file content with copy button
+- **Task/TaskOutput**: Sub-agent status with badges for agent type, background execution, and resume state
+- **Read/Glob/Grep**: File operation summaries
+
+### Task Management
+- Collapsible task list showing multi-step operation progress
+- Real-time status: pending (○), in-progress (⟳), completed (✓)
+- Progress counter: "Tasks (3/5)" format
+- State persists across sessions via localStorage
+
+### Permission System
+- Inline permission dialogs for tool approvals
+- Options: Always Allow, Allow Once, Reject
+- Clear display of tool inputs and available actions
+
+### KDE Integration
+- Automatic color scheme extraction from `~/.config/kdeglobals`
+- Kate theme colors applied to highlight.js syntax highlighting
+- Seamless side panel integration
+- Context-aware: passes the current file, selection, and project root to the active agent
+- Image paste support: paste images from clipboard directly into chat messages
+
+### Architecture
+- **ACP Protocol**: JSON-RPC 2.0 over stdin/stdout with the selected ACP provider process
+- **Qt WebChannel**: Bidirectional C++/JavaScript bridge for real-time UI updates
+- **Web UI**: HTML/CSS/JS rendered in Qt WebEngineView for rich formatting
## Prerequisites
### System Requirements
- KDE Plasma / Kate text editor
-- Qt 6 (Core, Widgets, WebEngineWidgets, WebChannel)
+- Qt 6 (Core, DBus, Widgets, WebEngineWidgets, WebChannel)
- KDE Frameworks 6 (KF6):
- KTextEditor
- KI18n
- KCoreAddons
- KXmlGui
+ - SyntaxHighlighting
+ - Pty
- CMake 3.16 or higher
- C++17 compatible compiler
### Runtime Dependencies
-- `claude-code-acp` binary installed and available in PATH
- - Install from: https://github.com/anthropics/claude-code
-- Valid Anthropic API key configured for claude-code
+- At least one ACP-compatible agent executable installed and available in `PATH`
+- Kate Code seeds editable provider entries for Claude Code (`claude-code-acp`) and Vibe (`vibe-acp`), and supports custom ACP providers
## Installation
-### 1. Install Dependencies
+### Install an ACP provider
+
+Install the ACP-compatible agent you want to use and ensure its executable is available in `PATH`. For example, follow the [claude-code-acp installation instructions](https://github.com/zed-industries/claude-code-acp) for Claude Code.
-#### Arch Linux / Manjaro
+Verify the executable, for example:
```bash
-sudo pacman -S extra-cmake-modules qt6-webengine kf6-ktexteditor kf6-ki18n kf6-kcoreaddons kf6-kxmlgui
+which claude-code-acp
```
-#### Ubuntu / Debian
+### Option 1: Install from Package (Recommended)
+
+#### Arch Linux (AUR or local build)
```bash
-sudo apt install cmake extra-cmake-modules qt6-webengine-dev \
- libkf6texteditor-dev libkf6i18n-dev libkf6coreaddons-dev libkf6xmlgui-dev
+# Build and install from PKGBUILD
+makepkg -si
```
-#### Fedora
+#### Fedora / RHEL
```bash
-sudo dnf install cmake extra-cmake-modules qt6-qtwebengine-devel \
- kf6-ktexteditor-devel kf6-ki18n-devel kf6-kcoreaddons-devel kf6-kxmlgui-devel
+# Build RPM using Docker
+./build-packages.sh rpm
+
+# Install the resulting package
+sudo dnf install dist/kate-code-1.0.0-1.*.rpm
+```
+
+#### Debian / Ubuntu
+```bash
+# Build deb using Docker
+./build-packages.sh deb
+
+# Install the resulting package
+sudo dpkg -i dist/kate-code_1.0.0-1_amd64.deb
+sudo apt-get install -f # Install any missing dependencies
```
-### 2. Install claude-code-acp
+### Option 2: Build from Source
-Follow the instructions at https://github.com/anthropics/claude-code to install the ACP binary.
+#### Install Build Dependencies
-Verify installation:
+**Arch Linux / Manjaro:**
```bash
-which claude-code-acp
+sudo pacman -S cmake extra-cmake-modules qt6-webengine \
+ kf6-ktexteditor kf6-ki18n kf6-kcoreaddons kf6-kxmlgui kf6-syntax-highlighting
```
-### 3. Build the Plugin
+**Debian / Ubuntu:**
+```bash
+sudo apt install cmake extra-cmake-modules qt6-webengine-dev \
+ libkf6texteditor-dev libkf6i18n-dev libkf6coreaddons-dev \
+ libkf6xmlgui-dev libkf6syntaxhighlighting-dev
+```
+**Fedora:**
```bash
-# Clone the repository (or navigate to your local copy)
-cd kate-code
+sudo dnf install cmake extra-cmake-modules gcc-c++ qt6-qtwebengine-devel \
+ kf6-ktexteditor-devel kf6-ki18n-devel kf6-kcoreaddons-devel \
+ kf6-kxmlgui-devel kf6-syntax-highlighting-devel
+```
-# Create build directory
-mkdir -p build
-cd build
+**openSUSE Tumbleweed:**
+```bash
+sudo zypper install cmake kf6-extra-cmake-modules gcc-c++ \
+ qt6-base-devel qt6-webchannel-devel qt6-webenginewidgets-devel \
+ kf6-ktexteditor-devel kf6-ki18n-devel kf6-kcoreaddons-devel \
+ kf6-kxmlgui-devel kf6-syntax-highlighting-devel kf6-kpty-devel
+```
-# Configure with CMake
-cmake ..
+#### Build and Install
-# Build
-cmake --build .
-```
+```bash
+# Configure
+cmake -B build -DCMAKE_BUILD_TYPE=Release
-### 4. Install the Plugin
+# Build
+cmake --build build
-**Important**: Kate plugins require system-wide installation to be discovered by KDE's plugin system.
+# Install to system (requires sudo)
+sudo cmake --install build
-```bash
-# Install to system directories (requires sudo)
-sudo cmake --install .
+# Or install to user directory
+cmake --install build --prefix ~/.local
```
The plugin will be installed to:
- Library: `/usr/lib/qt6/plugins/kf6/ktexteditor/katecode.so`
-- Metadata: `/usr/share/kf6/ktexteditor/katecode.json`
+- Metadata: `/usr/lib/qt6/plugins/kf6/ktexteditor/katecode.json`
+- UI Resource: `/usr/share/kate/plugins/katecode/katecodeui.rc`
-### 5. Enable in Kate
+### Enable in Kate
1. Restart Kate completely (close all windows)
2. Open Kate
@@ -112,20 +166,31 @@ 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
-3. The plugin will initialize using your current project's directory as the working directory
+2. Choose a configured ACP provider from the provider dropdown
+3. Click **Connect** to start a session using the current project's directory as its working directory
### Sending Messages
- Type your message in the input field at the bottom
- Press **Enter** to send (Shift+Enter for newline)
-- Claude's response will stream in real-time with markdown formatting
+- The agent's response will stream in real time with markdown formatting
+
+### Agent Mode
+
+Use the mode dropdown next to the input field to select an operating mode exposed by the active ACP provider. Available modes and their behavior depend on that provider; common examples include default and plan modes.
+
+### Code Blocks
+
+When the agent shows code in responses:
+- **Syntax highlighting** is applied automatically based on the language
+- Click the **📋 copy button** in the top-right corner of any code block to copy to clipboard
+- Highlighting theme automatically matches your KDE color scheme (light/dark)
### Context Awareness
-The plugin automatically provides context to Claude:
-- **Current File**: If you have a file open, Claude knows which file you're working on
-- **Selection**: If you have text selected, it's passed to Claude as context
+The plugin automatically provides context to the active agent:
+- **Current File**: If you have a file open, the agent knows which file you're working on
+- **Selection**: If you have text selected, it is passed to the agent as context
- **Project Root**: Automatically detected from:
- Kate's project plugin (if available)
- VCS markers (`.git`, `.hg`, `.svn`)
@@ -134,14 +199,14 @@ The plugin automatically provides context to Claude:
### Tool Calls
-When Claude uses tools, they appear inline in the conversation:
+When the agent uses tools, they appear inline in the conversation:
- **Collapsed view**: Shows tool name and key info (command for Bash, filename for file ops)
- **Expanded view**: Click to see full command/input and complete output
- **Status indicators**: Visual feedback for pending, running, completed, or failed operations
### Task List
-- Appears at the bottom of the chat when Claude is working on multi-step tasks
+- Appears at the bottom of the chat when the agent is working on multi-step tasks
- Click the header to collapse/expand (state persists across sessions)
- Shows progress counter: "Tasks (3/5)" means 3 completed out of 5 total
- **Icons**:
@@ -151,22 +216,30 @@ When Claude uses tools, they appear inline in the conversation:
### Permissions
-When Claude needs approval to run certain tools:
+When the agent needs approval to run certain tools:
- A permission dialog appears inline in the chat
- Shows the tool name, input details, and available options
- Options typically include: "Always Allow", "Allow", "Reject"
- Click an option to respond
-## Configuration
+### Session Summarization & Resume (Experimental)
-### API Key
+Kate Code includes optional session summarization and context re-seeding:
-Configure your Anthropic API key for claude-code:
-```bash
-export ANTHROPIC_API_KEY="your-key-here"
-```
+- **Configurable Summary Agent**: Use the current live agent or select any configured ACP provider as the summary agent
+- **Background Summaries**: A provider other than “Current agent” runs briefly in the background against the saved transcript
+- **Context Resume**: A new session can be seeded from a stored summary; abandoned raw sessions can be summarized before resuming
+- **Local Storage**: Summaries are stored under `~/.kate-code/summaries/`
+
+Enable summaries and choose the summary agent under **Settings → Configure Kate → Kate Code → Advanced**. Kate Code does not store a separate API key or use KWallet for summaries; each ACP provider uses its own authentication and configuration.
+
+This feature is experimental and behavior may change in future releases.
+
+## Configuration
+
+### ACP Providers
-Add to your `~/.bashrc` or `~/.zshrc` to persist across sessions.
+Manage providers under **Settings → Configure Kate → Kate Code → General**. Providers can be added, edited, removed, and reordered. Each entry can configure its executable, command-line options, MCP configuration, ACP session settings, and resume capability. The seeded Claude Code and Vibe entries are ordinary editable providers.
### Color Scheme
@@ -180,8 +253,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`
-- Check your API key is set: `echo $ANTHROPIC_API_KEY`
+- Verify the selected provider executable is installed and available in `PATH`
- Look for error messages in terminal when launching Kate from command line: `kate`
### Messages not displaying
@@ -248,7 +320,7 @@ kate 2>&1 | grep -E "\[ACP|ChatWebView|ChatWidget\]"
## License
-[Add your license here]
+MIT License
## Contributing
@@ -261,5 +333,5 @@ Contributions welcome! Please:
## Acknowledgments
- Built with Qt 6 and KDE Frameworks 6
-- Integrates with [Claude Code](https://github.com/anthropics/claude-code) via ACP
+- Supports ACP-compatible agents, with seeded providers for [claude-code-acp](https://github.com/zed-industries/claude-code-acp) and Vibe
- Markdown rendering by [marked.js](https://marked.js.org/)
diff --git a/Screenshot.png b/Screenshot.png
new file mode 100644
index 0000000..38c3c8d
Binary files /dev/null and b/Screenshot.png differ
diff --git a/build-packages.sh b/build-packages.sh
new file mode 100755
index 0000000..11e3885
--- /dev/null
+++ b/build-packages.sh
@@ -0,0 +1,65 @@
+#!/bin/bash
+# Build .deb, .rpm, and .pkg.tar.zst packages using Docker
+# Usage: ./build-packages.sh [deb|rpm|arch|all]
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+cd "$SCRIPT_DIR"
+
+mkdir -p dist
+
+build_deb() {
+ echo "=== Building .deb package ==="
+ docker build -f Dockerfile.deb -t kate-code-deb .
+ docker rm deb-out 2>/dev/null || true
+ docker create --name deb-out kate-code-deb
+ docker cp deb-out:/dist/. dist/
+ docker rm deb-out
+ echo "=== .deb package built in dist/ ==="
+}
+
+build_rpm() {
+ echo "=== Building .rpm package ==="
+ docker build -f Dockerfile.rpm -t kate-code-rpm .
+ docker rm rpm-out 2>/dev/null || true
+ docker create --name rpm-out kate-code-rpm
+ docker cp rpm-out:/dist/. dist/
+ docker rm rpm-out
+ echo "=== .rpm package built in dist/ ==="
+}
+
+build_arch() {
+ echo "=== Building .pkg.tar.zst package (Arch) ==="
+ docker build -f Dockerfile.arch -t kate-code-arch .
+ docker rm arch-out 2>/dev/null || true
+ docker create --name arch-out kate-code-arch
+ docker cp arch-out:/dist/. dist/
+ docker rm arch-out
+ echo "=== .pkg.tar.zst package built in dist/ ==="
+}
+
+case "${1:-all}" in
+ deb)
+ build_deb
+ ;;
+ rpm)
+ build_rpm
+ ;;
+ arch)
+ build_arch
+ ;;
+ all)
+ build_deb
+ build_rpm
+ build_arch
+ ;;
+ *)
+ echo "Usage: $0 [deb|rpm|arch|all]"
+ exit 1
+ ;;
+esac
+
+echo ""
+echo "Packages built:"
+ls -la dist/
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..5d0f2ae
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,5 @@
+kate-code (1.0.0-1) unstable; urgency=medium
+
+ * Initial release.
+
+ -- April Fri, 16 Jan 2026 12:00:00 +0000
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..39b1da9
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,25 @@
+Source: kate-code
+Section: editors
+Priority: optional
+Maintainer: April
+Build-Depends: debhelper-compat (= 13),
+ cmake,
+ extra-cmake-modules,
+ libkf6texteditor-dev,
+ libkf6i18n-dev,
+ libkf6coreaddons-dev,
+ libkf6xmlgui-dev,
+ libkf6syntaxhighlighting-dev,
+ qt6-webengine-dev
+Standards-Version: 4.6.2
+Homepage: https://github.com/undefinedopcode/kate-code
+Rules-Requires-Root: no
+
+Package: kate-code
+Architecture: any
+Depends: ${shlibs:Depends}, ${misc:Depends}
+Recommends: claude-code-acp
+Description: Claude Code integration for Kate text editor
+ A Kate plugin that provides Claude AI assistant integration via the
+ Agent Client Protocol (ACP). Features include chat interface, tool
+ execution with permission controls, and session management.
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..33017d1
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,27 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: kate-code
+Upstream-Contact: Kate Code Contributors
+Source: https://github.com/undefinedopcode/kate-code
+
+Files: *
+Copyright: 2025 Kate Code Contributors
+License: MIT
+
+License: MIT
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+ .
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+ .
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000..393af14
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,7 @@
+#!/usr/bin/make -f
+
+%:
+ dh $@
+
+override_dh_auto_configure:
+ dh_auto_configure -- -DCMAKE_BUILD_TYPE=Release
diff --git a/debian/source/format b/debian/source/format
new file mode 100644
index 0000000..89ae9db
--- /dev/null
+++ b/debian/source/format
@@ -0,0 +1 @@
+3.0 (native)
diff --git a/dist/kate-code-1.0.0-1.fc41.x86_64.rpm b/dist/kate-code-1.0.0-1.fc41.x86_64.rpm
new file mode 100644
index 0000000..030ca76
Binary files /dev/null and b/dist/kate-code-1.0.0-1.fc41.x86_64.rpm differ
diff --git a/dist/kate-code-debuginfo-1.0.0-1.fc41.x86_64.rpm b/dist/kate-code-debuginfo-1.0.0-1.fc41.x86_64.rpm
new file mode 100644
index 0000000..ce0c76e
Binary files /dev/null and b/dist/kate-code-debuginfo-1.0.0-1.fc41.x86_64.rpm differ
diff --git a/dist/kate-code-debugsource-1.0.0-1.fc41.x86_64.rpm b/dist/kate-code-debugsource-1.0.0-1.fc41.x86_64.rpm
new file mode 100644
index 0000000..b4877e1
Binary files /dev/null and b/dist/kate-code-debugsource-1.0.0-1.fc41.x86_64.rpm differ
diff --git a/kate-code.spec b/kate-code.spec
new file mode 100644
index 0000000..3aa4a8f
--- /dev/null
+++ b/kate-code.spec
@@ -0,0 +1,121 @@
+Name: kate-code
+Version: 1.5.1
+Release: 1%{?dist}
+Summary: Claude Code integration for Kate text editor
+
+License: MIT
+URL: https://github.com/undefinedopcode/kate-code
+Source0: %{name}-%{version}.tar.gz
+
+BuildRequires: cmake >= 3.16
+BuildRequires: extra-cmake-modules >= 6.0.0
+BuildRequires: gcc-c++
+BuildRequires: kf6-ktexteditor-devel
+BuildRequires: kf6-ki18n-devel
+BuildRequires: kf6-kcoreaddons-devel
+BuildRequires: kf6-kxmlgui-devel
+BuildRequires: kf6-syntax-highlighting-devel
+BuildRequires: kf6-kpty-devel
+BuildRequires: qt6-webenginewidgets-devel
+
+Recommends: claude-code-acp
+
+%description
+A Kate plugin that provides Claude AI assistant integration via the
+Agent Client Protocol (ACP). Features include chat interface, tool
+execution with permission controls, and session management.
+
+%prep
+%autosetup
+
+%build
+%cmake -DCMAKE_BUILD_TYPE=Release
+%cmake_build
+
+%install
+%cmake_install
+
+%files
+%license LICENSE
+%{_libdir}/qt6/plugins/kf6/ktexteditor/katecode.so
+%{_libdir}/qt6/plugins/kf6/ktexteditor/katecode.json
+%{_datadir}/kate/plugins/katecode/katecodeui.rc
+%{_libexecdir}/kate-mcp-server
+
+%changelog
+* Thu Jul 02 2026 Ben - 1.5.1-1
+- Fix kate-mcp-server crashing with SIGABRT ("has encountered a fatal
+ error and was closed") on every kate MCP tool call: the per-process
+ editor D-Bus name ended in an all-digit pid element, which is an
+ invalid bus name and made libdbus abort the process. The pid element
+ is now prefixed ("...editor.p"), and the MCP server validates
+ the name before use so a malformed name degrades to an error instead
+ of a crash
+
+* Thu Jul 02 2026 Ben - 1.5.0-1
+- Generate session summaries with a configured ACP agent chosen from a
+ provider dropdown, replacing the hard-coded Anthropic-API models; the
+ default "Current agent" asks the live session to summarise itself
+ before disconnect/quit, behind a cancellable progress dialogue
+- Frame injected resume context as a session restore so agents no longer
+ re-run the previous session's last task, and have the agent reply with
+ a one-sentence overview confirming the restore
+- Remove the now-unused Anthropic API key settings and KWallet plumbing
+- Fix a review sweep of bugs: summary-vs-cancelled-prompt race, chat
+ wedged after a session/prompt error response, broken reconnect after
+ an agent crash, single-agent gate leaks and bypass, terminal-wait
+ use-after-free, terminal args quoting, UTF-8 corruption on chunked
+ agent output, duplicate summaries, and transcript/summary folder-name
+ mismatches
+- Stop kate-mcp-server dying intermittently ("MCP error -32000:
+ Connection closed"): survive interrupted reads, answer MCP pings,
+ ignore SIGPIPE and complete partial writes
+
+* Wed Jul 01 2026 Ben - 1.4.1-1
+- Surface Codex systemError and genuine ACP errors as distinct chat
+ messages instead of dropping them or disguising them as normal output
+- Make agent output reliably reach the end of the log: scroll the message
+ container, clear stale content on a fresh connect, record the assistant
+ transcript for agents that end a turn via the prompt response, and log
+ JS exceptions from injected calls
+
+* Wed Jul 01 2026 Ben - 1.4.0-1
+- Fix a crash when closing Kate while an agent session was active
+- Support agents in separate Kate processes via a per-process editor DBus
+ name; block a second agent within one process with a clear message
+- Prefer ACP session/set_config_option for the mode dropdown with a
+ session/set_mode fallback and rollback on failure
+- Detect prompt capabilities and orient the agent that it runs in Kate
+- Queue follow-up prompts instead of creating a second streaming cursor
+- Make the input area resizable up to half the output; add a waiting
+ indicator and a file-include button
+- Add a save-output control and a global command auto-approval allow-list
+- Stop the streaming caret from flashing
+
+* Tue Jun 30 2026 Ben - 1.3.1-2
+- Preserve ordered WebView updates received while the chat page is loading
+- Add a control to clear only the displayed chat output
+
+* Tue Jun 30 2026 Ben - 1.3.1-1
+- Restore useful ACP approval and sandbox mode labels and plain-text chat input
+- Fix web UI icons when the Material Symbols font is unavailable
+- Document native Codex ACP session KVPs for model, reasoning effort, and mode
+
+* Mon Jun 29 2026 Ben - 1.3.0-1
+- Add ordered ACP provider descriptions with full configuration editing
+- Add per-provider ACP session configuration options for model and related settings
+- Preserve Kate and configured external MCP servers for new and resumed sessions
+- Improve standard ACP session/load, config option, and mode update compatibility
+
+* Mon Jun 22 2026 Ben - 1.2.0-1
+- Source resumable sessions from transcripts so abandoned sessions appear
+- Add per-provider true ACP session/load resume with context fallback
+- Make built-in providers editable and removable (at least one kept)
+- Add optional ACP JSON traffic logging to file (flushed per line)
+- Rename Summaries config tab to Advanced; add log and resume options
+
+* Wed May 27 2026 Ben - 1.1.0-1
+- Improve Codex ACP MCP integration and local MCP discovery
+
+* Fri Jan 16 2026 April - 1.0.0-1
+- Initial release
diff --git a/pkg/kate-code/usr/lib/qt6/plugins/kf6/ktexteditor/katecode.json b/pkg/kate-code/usr/lib/qt6/plugins/kf6/ktexteditor/katecode.json
new file mode 100644
index 0000000..9790b4f
--- /dev/null
+++ b/pkg/kate-code/usr/lib/qt6/plugins/kf6/ktexteditor/katecode.json
@@ -0,0 +1,17 @@
+{
+ "KPlugin": {
+ "Authors": [
+ {
+ "Name": "Kate Code Contributors"
+ }
+ ],
+ "Category": "Editor",
+ "Description": "Claude Code integration for Kate text editor",
+ "Icon": "code-context",
+ "Id": "katecode",
+ "License": "MIT",
+ "Name": "Kate Code",
+ "Version": "1.0.0",
+ "Website": "https://github.com/anthropics/claude-code"
+ }
+}
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index b9b80cf..173d4f9 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -8,15 +8,32 @@ set(katecode_SRCS
# ACP layer
acp/ACPService.cpp
acp/ACPSession.cpp
+ acp/TerminalManager.cpp
# UI layer
ui/ChatWidget.cpp
ui/ChatWebView.cpp
ui/ChatInputWidget.cpp
ui/PermissionDialog.cpp
+ ui/SessionSelectionDialog.cpp
+
+ # Config layer
+ config/SettingsStore.cpp
+ config/KateCodeConfigPage.cpp
+
+ # MCP layer (DBus service, runs in Kate process)
+ mcp/EditorDBusService.cpp
# Util layer
util/KDEColorScheme.cpp
+ util/KateThemeConverter.cpp
+ util/DiffHighlightManager.cpp
+ util/EditTracker.cpp
+ util/SessionStore.cpp
+ util/TranscriptWriter.cpp
+ util/SummaryStore.cpp
+ util/SummaryGenerator.cpp
+ util/ACPLogger.cpp
)
# Qt resources
@@ -29,7 +46,10 @@ target_link_libraries(katecode
KF6::I18n
KF6::CoreAddons
KF6::XmlGui
+ KF6::SyntaxHighlighting
+ KF6::Pty
Qt6::Core
+ Qt6::DBus
Qt6::Widgets
Qt6::WebEngineWidgets
Qt6::WebChannel
@@ -38,3 +58,22 @@ target_link_libraries(katecode
# Installation
install(TARGETS katecode DESTINATION ${KDE_INSTALL_PLUGINDIR}/kf6/ktexteditor)
install(FILES katecode.json DESTINATION ${KDE_INSTALL_PLUGINDIR}/kf6/ktexteditor)
+install(FILES katecodeui.rc DESTINATION ${KDE_INSTALL_DATADIR}/kate/plugins/katecode)
+
+# Kate MCP Server executable
+add_executable(kate-mcp-server
+ mcp/main.cpp
+ mcp/MCPServer.cpp
+)
+
+target_link_libraries(kate-mcp-server
+ Qt6::Core
+ Qt6::DBus
+)
+
+install(TARGETS kate-mcp-server DESTINATION ${KDE_INSTALL_LIBEXECDIR})
+
+# Tell the plugin where the MCP server is installed
+target_compile_definitions(katecode PRIVATE
+ KATE_MCP_SERVER_PATH="${KDE_INSTALL_FULL_LIBEXECDIR}/kate-mcp-server"
+)
diff --git a/src/acp/ACPModels.h b/src/acp/ACPModels.h
index b61216d..8ff37ad 100644
--- a/src/acp/ACPModels.h
+++ b/src/acp/ACPModels.h
@@ -3,6 +3,7 @@
#include
#include
#include
+#include
#include
enum class ConnectionStatus {
@@ -12,6 +13,24 @@ enum class ConnectionStatus {
Error
};
+enum class TerminalStatus {
+ Running,
+ Exited,
+ Killed,
+ Released
+};
+
+struct EditDiff {
+ QString oldText; // Original text
+ QString newText; // New text
+ QString filePath; // Optional file path for this specific edit
+
+ // Position tracking (populated during file write)
+ int startLine = -1; // 0-based line where edit starts (-1 = unknown)
+ int oldLineCount = 0; // Lines removed
+ int newLineCount = 0; // Lines added
+};
+
struct ToolCall {
QString id;
QString name;
@@ -20,6 +39,22 @@ struct ToolCall {
QString result;
QString filePath; // File path if tool operates on a file
int contentPosition = 0;
+
+ // Edit/Write specific fields
+ QString oldText; // For Edit tool - original text (deprecated, use edits)
+ QString newText; // For Edit tool - new text (deprecated, use edits)
+ QString operationType; // "create", "edit", etc.
+ QList edits; // Multiple edits for Edit tool
+
+ // Terminal specific fields
+ QString terminalId; // For embedded terminal output
+};
+
+struct ImageAttachment {
+ QString id; // Unique identifier for removal
+ QString mimeType; // "image/png", "image/jpeg", "image/gif", "image/webp"
+ QByteArray data; // Raw image bytes
+ QSize dimensions; // Original dimensions for preview scaling
};
struct Message {
@@ -29,6 +64,7 @@ struct Message {
QDateTime timestamp;
bool isStreaming = false;
QList toolCalls;
+ QList images; // For user messages with image attachments
};
struct TodoItem {
@@ -45,3 +81,26 @@ struct PermissionRequest {
QList options;
QString sessionId;
};
+
+struct SlashCommand {
+ QString name;
+ QString description;
+};
+
+struct ContextChunk {
+ QString filePath;
+ int startLine;
+ int endLine;
+ QString content;
+ QString id; // Unique identifier for removal
+};
+
+struct TrackedEdit {
+ QString toolCallId;
+ QString filePath;
+ int startLine; // 0-based line where edit starts
+ int oldLineCount; // Lines removed
+ int newLineCount; // Lines added
+ bool isNewFile; // True if this was a file creation
+ QDateTime timestamp;
+};
diff --git a/src/acp/ACPService.cpp b/src/acp/ACPService.cpp
index 0426bf6..31d380a 100644
--- a/src/acp/ACPService.cpp
+++ b/src/acp/ACPService.cpp
@@ -1,29 +1,47 @@
#include "ACPService.h"
#include
+#include
+#include
#include
+#include
ACPService::ACPService(QObject *parent)
: QObject(parent)
, m_process(nullptr)
, m_messageId(0)
+ , m_executable(QStringLiteral("claude-code-acp"))
{
}
+void ACPService::setExecutable(const QString &executable, const QStringList &args)
+{
+ m_executable = executable;
+ m_executableArgs = args;
+}
+
ACPService::~ACPService()
{
+ // Disconnect signals before cleanup to prevent signal emission during destruction
+ if (m_process) {
+ disconnect(m_process, nullptr, this, nullptr);
+ }
stop();
}
bool ACPService::start(const QString &workingDir)
{
- qDebug() << "[ACPService] Starting claude-code-acp in:" << workingDir;
+ qDebug() << "[ACPService] Starting" << m_executable << "in:" << workingDir;
if (m_process) {
qDebug() << "[ACPService] Stopping existing process";
stop();
}
+ // A partial line left over from a crashed process must not prepend
+ // itself to the new process's first message.
+ m_buffer.clear();
+
m_process = new QProcess(this);
m_process->setWorkingDirectory(workingDir);
@@ -32,13 +50,40 @@ bool ACPService::start(const QString &workingDir)
connect(m_process, &QProcess::finished, this, &ACPService::onFinished);
connect(m_process, &QProcess::errorOccurred, this, &ACPService::onError);
- qDebug() << "[ACPService] Starting process: claude-code-acp";
- m_process->start(QStringLiteral("claude-code-acp"), QStringList());
+ // Resolve executable path - when launched from desktop environments,
+ // user-local paths like ~/.local/bin may not be on PATH
+ QString resolvedExecutable = m_executable;
+ if (!QFileInfo(resolvedExecutable).isAbsolute()) {
+ QString found = QStandardPaths::findExecutable(resolvedExecutable);
+ if (found.isEmpty()) {
+ // Fallback: check common user-local directories where curl|bash
+ // installers and package managers typically place binaries
+ const QString home = QDir::homePath();
+ const QStringList fallbackDirs = {
+ home + QStringLiteral("/.local/bin"),
+ home + QStringLiteral("/bin"),
+ home + QStringLiteral("/.cargo/bin"),
+ };
+ for (const QString &dir : fallbackDirs) {
+ QString candidate = dir + QLatin1Char('/') + resolvedExecutable;
+ if (QFileInfo::exists(candidate)) {
+ found = candidate;
+ break;
+ }
+ }
+ }
+ if (!found.isEmpty()) {
+ resolvedExecutable = found;
+ }
+ }
+
+ qDebug() << "[ACPService] Starting process:" << resolvedExecutable << m_executableArgs;
+ m_process->start(resolvedExecutable, m_executableArgs);
qDebug() << "[ACPService] Waiting for process to start...";
if (!m_process->waitForStarted(5000)) {
qDebug() << "[ACPService] Process failed to start";
- Q_EMIT errorOccurred(QStringLiteral("Failed to start claude-code-acp"));
+ Q_EMIT errorOccurred(QStringLiteral("Failed to start %1").arg(m_executable));
return false;
}
@@ -50,10 +95,17 @@ bool ACPService::start(const QString &workingDir)
void ACPService::stop()
{
if (m_process) {
+ // Disconnect signals BEFORE killing to prevent onFinished from being called
+ // This avoids a race condition where both stop() and onFinished() try to clean up m_process
+ disconnect(m_process, nullptr, this, nullptr);
+
m_process->kill();
m_process->waitForFinished(1000);
m_process->deleteLater();
m_process = nullptr;
+
+ // Emit disconnected signal since onFinished won't be called (signals disconnected)
+ Q_EMIT disconnected(0);
}
}
@@ -78,10 +130,33 @@ int ACPService::sendRequest(const QString &method, const QJsonObject ¶ms)
QByteArray data = QJsonDocument(msg).toJson(QJsonDocument::Compact) + "\n";
qDebug() << "[ACPService] >>" << method << "id:" << m_messageId;
+ Q_EMIT jsonPayload(QStringLiteral(">>"), QString::fromUtf8(data).trimmed());
m_process->write(data);
return m_messageId;
}
+void ACPService::sendNotification(const QString &method, const QJsonObject ¶ms)
+{
+ if (!m_process || m_process->state() != QProcess::Running) {
+ qWarning() << "[ACPService] Cannot send notification: ACP not connected";
+ return;
+ }
+
+ QJsonObject msg;
+ msg[QStringLiteral("jsonrpc")] = QStringLiteral("2.0");
+ msg[QStringLiteral("method")] = method;
+
+ if (!params.isEmpty()) {
+ msg[QStringLiteral("params")] = params;
+ }
+
+ QByteArray data = QJsonDocument(msg).toJson(QJsonDocument::Compact) + "\n";
+ qDebug() << "[ACPService] >> notification:" << method;
+
+ Q_EMIT jsonPayload(QStringLiteral(">>"), QString::fromUtf8(data).trimmed());
+ m_process->write(data);
+}
+
void ACPService::sendResponse(int requestId, const QJsonObject &result, const QJsonObject &error)
{
if (!m_process || m_process->state() != QProcess::Running) {
@@ -101,6 +176,7 @@ void ACPService::sendResponse(int requestId, const QJsonObject &result, const QJ
QByteArray data = QJsonDocument(msg).toJson(QJsonDocument::Compact) + "\n";
qDebug() << "[ACPService] >> response for request id:" << requestId;
+ Q_EMIT jsonPayload(QStringLiteral(">>"), QString::fromUtf8(data).trimmed());
m_process->write(data);
}
@@ -115,20 +191,21 @@ void ACPService::onStdout()
return;
}
- QByteArray data = m_process->readAllStandardOutput();
- m_buffer += QString::fromUtf8(data);
+ m_buffer += m_process->readAllStandardOutput();
- // Parse newline-delimited JSON
- QStringList lines = m_buffer.split(QLatin1Char('\n'));
- m_buffer = lines.takeLast(); // Keep incomplete line in buffer
+ // Parse newline-delimited JSON, splitting at the byte level so multi-byte
+ // UTF-8 sequences that straddle a chunk boundary decode correctly.
+ int newlineIndex;
+ while ((newlineIndex = m_buffer.indexOf('\n')) >= 0) {
+ const QByteArray line = m_buffer.left(newlineIndex);
+ m_buffer.remove(0, newlineIndex + 1);
- for (const QString &line : lines) {
if (line.trimmed().isEmpty()) {
continue;
}
QJsonParseError parseError;
- QJsonDocument doc = QJsonDocument::fromJson(line.toUtf8(), &parseError);
+ QJsonDocument doc = QJsonDocument::fromJson(line, &parseError);
if (parseError.error != QJsonParseError::NoError) {
qWarning() << "[ACPService] Failed to parse JSON:" << parseError.errorString();
@@ -142,6 +219,8 @@ void ACPService::onStdout()
void ACPService::handleMessage(const QJsonObject &msg)
{
+ Q_EMIT jsonPayload(QStringLiteral("<<"), QString::fromUtf8(QJsonDocument(msg).toJson(QJsonDocument::Compact)));
+
if (msg.contains(QStringLiteral("method"))) {
// Notification or request from ACP
QString method = msg[QStringLiteral("method")].toString();
@@ -164,7 +243,8 @@ void ACPService::handleMessage(const QJsonObject &msg)
QJsonObject result = msg[QStringLiteral("result")].toObject();
QJsonObject error = msg[QStringLiteral("error")].toObject();
- qDebug() << "[ACPService] << response for request id:" << id;
+ qDebug() << "[ACPService] << response for request id:" << id
+ << "raw:" << QJsonDocument(msg).toJson(QJsonDocument::Compact);
Q_EMIT responseReceived(id, result, error);
}
}
@@ -179,16 +259,27 @@ void ACPService::onStderr()
QString message = QString::fromUtf8(data).trimmed();
if (!message.isEmpty()) {
+ // Bridge stderr is largely informational (progress, warnings). Log it,
+ // but do NOT route it through errorOccurred: that signal is surfaced in
+ // the chat now and should carry only genuine errors (process failures
+ // and JSON-RPC error responses), not noisy stderr chatter.
qDebug() << "[ACPService] stderr:" << message;
- Q_EMIT errorOccurred(message);
}
}
void ACPService::onFinished(int exitCode, QProcess::ExitStatus exitStatus)
{
+ Q_UNUSED(exitStatus);
qDebug() << "[ACPService] Process finished with exit code:" << exitCode;
+
+ if (m_process) {
+ // Disconnect signals to prevent further callbacks during cleanup
+ disconnect(m_process, nullptr, this, nullptr);
+ m_process->deleteLater();
+ m_process = nullptr;
+ }
+
Q_EMIT disconnected(exitCode);
- m_process = nullptr;
}
void ACPService::onError(QProcess::ProcessError error)
diff --git a/src/acp/ACPService.h b/src/acp/ACPService.h
index 00645b8..732f234 100644
--- a/src/acp/ACPService.h
+++ b/src/acp/ACPService.h
@@ -13,10 +13,12 @@ class ACPService : public QObject
explicit ACPService(QObject *parent = nullptr);
~ACPService() override;
+ void setExecutable(const QString &executable, const QStringList &args = QStringList());
bool start(const QString &workingDir);
void stop();
int sendRequest(const QString &method, const QJsonObject ¶ms = QJsonObject());
+ void sendNotification(const QString &method, const QJsonObject ¶ms = QJsonObject());
void sendResponse(int requestId, const QJsonObject &result = QJsonObject(), const QJsonObject &error = QJsonObject());
bool isRunning() const;
@@ -27,6 +29,7 @@ class ACPService : public QObject
void connected();
void disconnected(int exitCode);
void errorOccurred(const QString &message);
+ void jsonPayload(const QString &direction, const QString &json);
private Q_SLOTS:
void onStdout();
@@ -39,5 +42,9 @@ private Q_SLOTS:
QProcess *m_process;
int m_messageId;
- QString m_buffer;
+ // Raw bytes: split into lines before UTF-8 decoding, so multi-byte
+ // sequences straddling a read boundary are never mangled.
+ QByteArray m_buffer;
+ QString m_executable;
+ QStringList m_executableArgs;
};
diff --git a/src/acp/ACPSession.cpp b/src/acp/ACPSession.cpp
index 7a69191..531d1fd 100644
--- a/src/acp/ACPSession.cpp
+++ b/src/acp/ACPSession.cpp
@@ -1,40 +1,143 @@
#include "ACPSession.h"
#include "ACPService.h"
+#include "TerminalManager.h"
+#include "../util/EditTracker.h"
+#include "../util/TranscriptWriter.h"
+#include
+#include
+#include
+#include
+
+#include
#include
+#include
+#include
+#include
#include
+#include
+#include
+#include
+#include
+#include
+#include
#include
#include
+// Helper functions to check tool types (mirrors logic in chat.js)
+// Uses suffix matching for katecode tools to handle different MCP host prefixes
+static bool isReadTool(const QString &name)
+{
+ return name == QStringLiteral("Read") || name == QStringLiteral("mcp__acp__Read") ||
+ name.endsWith(QStringLiteral("_katecode_read"));
+}
+
+static bool isWriteTool(const QString &name)
+{
+ return name == QStringLiteral("Write") || name == QStringLiteral("mcp__acp__Write") ||
+ name.endsWith(QStringLiteral("_katecode_write"));
+}
+
+static bool isEditTool(const QString &name)
+{
+ return name == QStringLiteral("Edit") || name == QStringLiteral("mcp__acp__Edit") ||
+ name.endsWith(QStringLiteral("_katecode_edit"));
+}
+
+static bool isBashTool(const QString &name)
+{
+ return name == QStringLiteral("Bash") || name == QStringLiteral("mcp__acp__Bash");
+}
+
+// Infer tool name from toolCallId prefix (e.g., Gemini uses "run_shell_command-")
+static QString inferToolNameFromId(const QString &toolCallId)
+{
+ // Extract prefix before the last dash-digits segment
+ int dashIdx = toolCallId.lastIndexOf(QLatin1Char('-'));
+ if (dashIdx <= 0) return {};
+
+ QString prefix = toolCallId.left(dashIdx);
+
+ if (prefix == QStringLiteral("run_shell_command") || prefix == QStringLiteral("bash") || prefix == QStringLiteral("execute")) {
+ return QStringLiteral("Bash");
+ } else if (prefix == QStringLiteral("read_file") || prefix == QStringLiteral("read")) {
+ return QStringLiteral("Read");
+ } else if (prefix == QStringLiteral("write_file") || prefix == QStringLiteral("write") || prefix == QStringLiteral("create_file")) {
+ return QStringLiteral("Write");
+ } else if (prefix == QStringLiteral("edit_file") || prefix == QStringLiteral("edit") || prefix == QStringLiteral("patch_file")) {
+ return QStringLiteral("Edit");
+ }
+ return {};
+}
+
ACPSession::ACPSession(QObject *parent)
: QObject(parent)
, m_service(new ACPService(this))
+ , m_terminalManager(new TerminalManager(this))
+ , m_transcript(new TranscriptWriter(this))
, m_status(ConnectionStatus::Disconnected)
, m_initializeRequestId(-1)
, m_sessionNewRequestId(-1)
+ , m_sessionLoadRequestId(-1)
+ , m_sessionConfigRequestId(-1)
, m_promptRequestId(-1)
, m_messageCounter(0)
+ , m_editTracker(new EditTracker(this))
{
connect(m_service, &ACPService::connected, this, &ACPSession::onConnected);
connect(m_service, &ACPService::disconnected, this, &ACPSession::onDisconnected);
connect(m_service, &ACPService::notificationReceived, this, &ACPSession::onNotification);
connect(m_service, &ACPService::responseReceived, this, &ACPSession::onResponse);
+ connect(m_service, &ACPService::jsonPayload, this, &ACPSession::jsonPayload);
connect(m_service, &ACPService::errorOccurred, this, &ACPSession::onError);
+
+ // Forward terminal output to UI
+ connect(m_terminalManager, &TerminalManager::outputAvailable,
+ this, &ACPSession::terminalOutputUpdated);
}
ACPSession::~ACPSession()
{
+ // Disconnect from service signals before cleanup to prevent signal emission during destruction
+ if (m_service) {
+ disconnect(m_service, nullptr, this, nullptr);
+ }
stop();
}
-void ACPSession::start(const QString &workingDir)
+QString ACPSession::transcriptFilePath() const
+{
+ return m_transcript ? m_transcript->transcriptPath() : QString();
+}
+
+void ACPSession::setExecutable(const QString &executable, const QStringList &args)
+{
+ m_service->setExecutable(executable, args);
+}
+
+void ACPSession::setMcpConfigPath(const QString &path)
+{
+ m_mcpConfigPath = path;
+ qDebug() << "[ACPSession] MCP config path set to:" << path;
+}
+
+void ACPSession::setSessionConfig(const QJsonObject &config)
+{
+ m_sessionConfig = config;
+ qDebug() << "[ACPSession] Session configuration keys set to:" << config.keys();
+}
+
+void ACPSession::start(const QString &workingDir, const QString &permissionMode)
{
+ Q_UNUSED(permissionMode); // Modes are now discovered from agent
+
if (m_status != ConnectionStatus::Disconnected) {
return;
}
m_workingDir = workingDir;
m_status = ConnectionStatus::Connecting;
+ m_editTracker->clear();
Q_EMIT statusChanged(m_status);
if (!m_service->start(workingDir)) {
@@ -46,12 +149,160 @@ void ACPSession::start(const QString &workingDir)
void ACPSession::stop()
{
- m_service->stop();
+ m_transcript->finishSession();
+ m_terminalManager->releaseAll();
+
+ // Set status BEFORE stopping the service, because m_service->stop()
+ // may synchronously trigger onDisconnected() via QProcess signals.
+ // If we set Disconnected first, onDisconnected() sees the state is
+ // already Disconnected and skips emitting a duplicate statusChanged.
m_status = ConnectionStatus::Disconnected;
- m_sessionId.clear();
+ resetSessionState();
+
+ m_service->stop();
+
Q_EMIT statusChanged(m_status);
}
+void ACPSession::resetSessionState()
+{
+ m_sessionId.clear();
+ m_sessionLoadId.clear();
+ m_initializeRequestId = -1;
+ m_sessionNewRequestId = -1;
+ m_sessionLoadRequestId = -1;
+ m_sessionConfigRequestId = -1;
+ m_pendingSessionConfigKeys.clear();
+ m_currentSessionConfigKey.clear();
+ m_availableConfigOptions = {};
+ m_promptRequestId = -1;
+ m_promptQueue.clear();
+ m_currentMessageId.clear();
+ m_currentMessageContent.clear();
+ m_messageCounter = 0;
+ // Fail any in-flight or parked summary so a caller waiting on
+ // summaryResult() returns.
+ if (isSummaryRunning()) {
+ m_summaryRequestId = -1;
+ m_summaryAfterPromptId = -1;
+ m_pendingSummaryPrompt.clear();
+ m_summaryCollected.clear();
+ Q_EMIT summaryResult(QString(), QStringLiteral("Session ended before the summary completed"));
+ }
+ // Discard any in-flight or queued interactive mode-change state.
+ m_interactiveModeRequestId = -1;
+ m_pendingModeValue.clear();
+ m_queuedModeValue.clear();
+ // Reset agent capability flags so they don't leak across reconnections.
+ m_supportsImage = false;
+ m_supportsEmbeddedContext = false;
+ m_supportsPromptQueueing = false;
+}
+
+void ACPSession::setTerminalSize(int columns, int rows)
+{
+ m_terminalManager->setDefaultTerminalSize(columns, rows);
+}
+
+void ACPSession::setDocumentProvider(DocumentProvider provider)
+{
+ m_documentProvider = provider;
+}
+
+void ACPSession::cancelPrompt()
+{
+ if (m_promptRequestId < 0) {
+ qDebug() << "[ACPSession] cancelPrompt called but no prompt running";
+ return;
+ }
+
+ qDebug() << "[ACPSession] Cancelling prompt request:" << m_promptRequestId;
+
+ // Send $/cancel_request notification per ACP protocol
+ QJsonObject params;
+ params[QStringLiteral("id")] = m_promptRequestId;
+ m_service->sendNotification(QStringLiteral("$/cancel_request"), params);
+
+ // Finish any streaming message
+ if (!m_currentMessageId.isEmpty()) {
+ Q_EMIT messageFinished(m_currentMessageId);
+ m_currentMessageId.clear();
+ m_currentMessageContent.clear();
+ }
+
+ m_promptRequestId = -1;
+ // Discard queued prompts: a cancellation signals the user wants to stop, so
+ // silently auto-sending stale follow-ups would be surprising.
+ if (!m_promptQueue.isEmpty()) {
+ qDebug() << "[ACPSession] Discarding" << m_promptQueue.size() << "queued prompt(s) on cancel";
+ m_promptQueue.clear();
+ }
+ Q_EMIT promptCancelled();
+}
+
+void ACPSession::requestSummary(const QString &prompt)
+{
+ if (m_status != ConnectionStatus::Connected || m_sessionId.isEmpty()) {
+ Q_EMIT summaryResult(QString(), QStringLiteral("No connected session to summarise"));
+ return;
+ }
+ if (isSummaryRunning()) {
+ Q_EMIT summaryResult(QString(), QStringLiteral("A summary request is already running"));
+ return;
+ }
+
+ // The session is about to end, so a running task loses to the summary.
+ // Cancellation is only a notification: the cancelled turn keeps streaming
+ // chunks until its session/prompt response arrives, and chunks carry no
+ // request id. Park the summary prompt until that response so stale text
+ // cannot leak into the summary (and so agents that reject concurrent
+ // prompts are not sent one).
+ if (isPromptRunning()) {
+ qDebug() << "[ACPSession] requestSummary: cancelling the running prompt first";
+ const int cancelledId = m_promptRequestId;
+ cancelPrompt();
+ m_summaryAfterPromptId = cancelledId;
+ m_pendingSummaryPrompt = prompt;
+ return;
+ }
+
+ sendSummaryPrompt(prompt);
+}
+
+void ACPSession::sendSummaryPrompt(const QString &prompt)
+{
+ QJsonObject textBlock;
+ textBlock[QStringLiteral("type")] = QStringLiteral("text");
+ textBlock[QStringLiteral("text")] = prompt;
+ QJsonArray promptBlocks;
+ promptBlocks.append(textBlock);
+
+ QJsonObject params;
+ params[QStringLiteral("sessionId")] = m_sessionId;
+ params[QStringLiteral("prompt")] = promptBlocks;
+
+ m_summaryCollected.clear();
+ m_summaryRequestId = m_service->sendRequest(QStringLiteral("session/prompt"), params);
+ qDebug() << "[ACPSession] Sent summary session/prompt request, id:" << m_summaryRequestId;
+ if (m_summaryRequestId < 0) {
+ Q_EMIT summaryResult(QString(), QStringLiteral("Failed to send the summary prompt"));
+ }
+}
+
+void ACPSession::cancelSummary()
+{
+ if (m_summaryRequestId >= 0) {
+ qDebug() << "[ACPSession] Cancelling summary request:" << m_summaryRequestId;
+ QJsonObject params;
+ params[QStringLiteral("id")] = m_summaryRequestId;
+ m_service->sendNotification(QStringLiteral("$/cancel_request"), params);
+ }
+ m_summaryRequestId = -1;
+ m_summaryAfterPromptId = -1;
+ m_pendingSummaryPrompt.clear();
+ m_summaryCollected.clear();
+}
+
void ACPSession::sendPermissionResponse(int requestId, const QJsonObject &outcome)
{
QJsonObject result;
@@ -61,77 +312,515 @@ void ACPSession::sendPermissionResponse(int requestId, const QJsonObject &outcom
qDebug() << "[ACPSession] Sent permission response for request:" << requestId;
}
-void ACPSession::sendMessage(const QString &content, const QString &filePath, const QString &selection)
+void ACPSession::setMode(const QString &modeId)
+{
+ if (m_sessionId.isEmpty()) {
+ qWarning() << "[ACPSession] setMode: no active session, ignoring request for" << modeId;
+ return;
+ }
+
+ // Nothing to do if the mode is already confirmed and no other request is pending.
+ if (modeId == m_currentMode && m_interactiveModeRequestId < 0) {
+ qDebug() << "[ACPSession] setMode: mode" << modeId << "already active, skipping";
+ return;
+ }
+
+ // Coalesce rapid selections: queue the latest value and let the in-flight
+ // response handler send it once the current round-trip completes.
+ if (m_interactiveModeRequestId >= 0) {
+ qDebug() << "[ACPSession] setMode: request in flight, queuing" << modeId;
+ m_queuedModeValue = modeId;
+ return;
+ }
+
+ // Decide transport: prefer session/set_config_option when the agent has
+ // advertised a config option with id=="mode"; fall back to session/set_mode
+ // for older agents that only advertise legacy modes.
+ bool useModern = false;
+ for (const QJsonValue &value : m_availableConfigOptions) {
+ if (value.toObject()[QStringLiteral("id")].toString() == QStringLiteral("mode")) {
+ useModern = true;
+ break;
+ }
+ }
+
+ if (useModern) {
+ // Validate that modeId is one of the advertised values (use m_availableModes
+ // which updateSessionConfigOptions() already built from the option's choices).
+ bool valid = false;
+ for (const QJsonValue &v : m_availableModes) {
+ if (v.toObject()[QStringLiteral("id")].toString() == modeId) {
+ valid = true;
+ break;
+ }
+ }
+ if (!valid) {
+ qWarning() << "[ACPSession] setMode: mode" << modeId
+ << "not in advertised options, ignoring";
+ return;
+ }
+
+ QJsonObject params;
+ params[QStringLiteral("sessionId")] = m_sessionId;
+ params[QStringLiteral("configId")] = QStringLiteral("mode");
+ params[QStringLiteral("value")] = modeId;
+ int reqId = m_service->sendRequest(
+ QStringLiteral("session/set_config_option"), params);
+ if (reqId < 0) {
+ qWarning() << "[ACPSession] setMode: failed to send session/set_config_option";
+ return;
+ }
+ m_interactiveModeRequestId = reqId;
+ m_pendingModeValue = modeId;
+ qDebug() << "[ACPSession] setMode: sent session/set_config_option mode=" << modeId
+ << "reqId=" << reqId;
+ } else {
+ // Legacy transport: session/set_mode
+ QJsonObject params;
+ params[QStringLiteral("sessionId")] = m_sessionId;
+ params[QStringLiteral("modeId")] = modeId;
+ int reqId = m_service->sendRequest(
+ QStringLiteral("session/set_mode"), params);
+ if (reqId < 0) {
+ qWarning() << "[ACPSession] setMode: failed to send session/set_mode";
+ return;
+ }
+ m_interactiveModeRequestId = reqId;
+ m_pendingModeValue = modeId;
+ qDebug() << "[ACPSession] setMode: sent session/set_mode modeId=" << modeId
+ << "reqId=" << reqId;
+ }
+}
+
+static QString expandConfigPath(const QString &path)
+{
+ QString expanded = path.trimmed();
+ if (expanded == QStringLiteral("~")) {
+ expanded = QDir::homePath();
+ } else if (expanded.startsWith(QStringLiteral("~/"))) {
+ expanded = QDir::homePath() + expanded.mid(1);
+ }
+
+ static const QRegularExpression envVarPattern(QStringLiteral("\\$\\{([A-Za-z_][A-Za-z0-9_]*)\\}"));
+ QRegularExpressionMatchIterator it = envVarPattern.globalMatch(expanded);
+ QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
+ while (it.hasNext()) {
+ const QRegularExpressionMatch match = it.next();
+ expanded.replace(match.captured(0), env.value(match.captured(1)));
+ }
+
+ return QDir::cleanPath(expanded);
+}
+
+static QJsonArray loadExternalMcpServers(const QString &configPath)
+{
+ QJsonArray servers;
+
+ // Skip if no config path is set
+ if (configPath.isEmpty()) {
+ qDebug() << "[ACPSession] No MCP config path configured, skipping external servers";
+ return servers;
+ }
+
+ const QString expandedConfigPath = expandConfigPath(configPath);
+ QFile configFile(expandedConfigPath);
+
+ if (!configFile.exists()) {
+ qDebug() << "[ACPSession] No external MCP config found at:" << expandedConfigPath;
+ return servers;
+ }
+
+ if (!configFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ qWarning() << "[ACPSession] Failed to open MCP config:" << expandedConfigPath;
+ return servers;
+ }
+
+ QByteArray data = configFile.readAll();
+ configFile.close();
+
+ QJsonDocument doc = QJsonDocument::fromJson(data);
+ if (doc.isNull() || !doc.isObject()) {
+ qWarning() << "[ACPSession] Invalid JSON in MCP config:" << expandedConfigPath;
+ return servers;
+ }
+
+ QJsonObject root = doc.object();
+ QJsonObject mcpServersObj = root[QStringLiteral("mcpServers")].toObject();
+
+ if (mcpServersObj.isEmpty()) {
+ qDebug() << "[ACPSession] No mcpServers found in config";
+ return servers;
+ }
+
+ for (auto it = mcpServersObj.begin(); it != mcpServersObj.end(); ++it) {
+ QString serverName = it.key();
+ QJsonObject serverConfig = it.value().toObject();
+
+ if (serverConfig.isEmpty()) {
+ qWarning() << "[ACPSession] Empty config for MCP server:" << serverName;
+ continue;
+ }
+
+ // Build server object in ACP format
+ QJsonObject server;
+ server[QStringLiteral("name")] = serverName;
+ server[QStringLiteral("type")] = serverConfig.value(QStringLiteral("type")).toString(QStringLiteral("stdio"));
+ server[QStringLiteral("command")] = serverConfig[QStringLiteral("command")];
+ server[QStringLiteral("args")] = serverConfig[QStringLiteral("args")].toArray();
+
+ // Convert env object to array of {name, value} objects for ACP protocol
+ QJsonObject envObj = serverConfig[QStringLiteral("env")].toObject();
+ QJsonArray envArray;
+ for (auto envIt = envObj.begin(); envIt != envObj.end(); ++envIt) {
+ QJsonObject envEntry;
+ envEntry[QStringLiteral("name")] = envIt.key();
+ envEntry[QStringLiteral("value")] = envIt.value().toString();
+ envArray.append(envEntry);
+ }
+ server[QStringLiteral("env")] = envArray;
+
+ servers.append(server);
+ qDebug() << "[ACPSession] Loaded external MCP server:" << serverName
+ << "command:" << serverConfig[QStringLiteral("command")].toString();
+ }
+
+ qDebug() << "[ACPSession] Loaded" << servers.size() << "external MCP server(s)";
+ return servers;
+}
+
+static void addExecutableCandidate(QStringList &candidates, const QString &path)
+{
+ if (path.isEmpty()) {
+ return;
+ }
+
+ const QString cleanPath = QDir::cleanPath(path);
+ if (!candidates.contains(cleanPath)) {
+ candidates.append(cleanPath);
+ }
+}
+
+static QString findKateMcpServerPath()
+{
+ QStringList candidates;
+
+#ifdef KATE_MCP_SERVER_PATH
+ const QString compiledPath = QStringLiteral(KATE_MCP_SERVER_PATH);
+ addExecutableCandidate(candidates, compiledPath);
+
+ static const QStringList installPrefixes = {
+ QDir::homePath() + QStringLiteral("/.local"),
+ QStringLiteral("/usr/local"),
+ QStringLiteral("/usr"),
+ };
+ static const QStringList compiledPrefixes = {
+ QStringLiteral("/usr/local"),
+ QStringLiteral("/usr"),
+ };
+ for (const QString &compiledPrefix : compiledPrefixes) {
+ if (!compiledPath.startsWith(compiledPrefix + QLatin1Char('/'))) {
+ continue;
+ }
+
+ const QString relativePath = compiledPath.mid(compiledPrefix.size());
+ for (const QString &installPrefix : installPrefixes) {
+ addExecutableCandidate(candidates, installPrefix + relativePath);
+ }
+ }
+#endif
+
+ addExecutableCandidate(candidates, QCoreApplication::applicationDirPath() + QStringLiteral("/kate-mcp-server"));
+ addExecutableCandidate(candidates, QDir::homePath() + QStringLiteral("/.local/libexec/kate-mcp-server"));
+ addExecutableCandidate(candidates, QDir::homePath() + QStringLiteral("/.local/lib64/libexec/kate-mcp-server"));
+ addExecutableCandidate(candidates, QStringLiteral("/usr/local/libexec/kate-mcp-server"));
+ addExecutableCandidate(candidates, QStringLiteral("/usr/local/lib64/libexec/kate-mcp-server"));
+ addExecutableCandidate(candidates, QStringLiteral("/usr/libexec/kate-mcp-server"));
+ addExecutableCandidate(candidates, QStringLiteral("/usr/lib64/libexec/kate-mcp-server"));
+
+ addExecutableCandidate(candidates, QStandardPaths::findExecutable(QStringLiteral("kate-mcp-server")));
+
+ for (const QString &candidate : candidates) {
+ const QFileInfo info(candidate);
+ if (info.exists() && info.isFile() && info.isExecutable()) {
+ return candidate;
+ }
+ }
+
+ qWarning() << "[ACPSession] Kate MCP server not found. Checked:" << candidates;
+ return {};
+}
+
+QJsonArray ACPSession::buildMcpServers() const
+{
+ QJsonArray mcpServers;
+ const QString mcpServerPath = findKateMcpServerPath();
+
+ if (!mcpServerPath.isEmpty()) {
+ QJsonObject kateMcp;
+ kateMcp[QStringLiteral("type")] = QStringLiteral("stdio");
+ kateMcp[QStringLiteral("name")] = QStringLiteral("kate");
+ kateMcp[QStringLiteral("command")] = mcpServerPath;
+ kateMcp[QStringLiteral("args")] = QJsonArray();
+
+ // The child MCP server must inherit the desktop session bus so it can
+ // reach the editor service even when the ACP agent sanitizes its env.
+ QJsonArray envArray;
+ static const QStringList sessionEnvVars = {
+ QStringLiteral("DBUS_SESSION_BUS_ADDRESS"),
+ QStringLiteral("XDG_RUNTIME_DIR"),
+ QStringLiteral("DISPLAY"),
+ QStringLiteral("WAYLAND_DISPLAY"),
+ QStringLiteral("HOME"),
+ };
+ const QProcessEnvironment sysEnv = QProcessEnvironment::systemEnvironment();
+ for (const QString &varName : sessionEnvVars) {
+ const QString value = sysEnv.value(varName);
+ if (!value.isEmpty()) {
+ QJsonObject entry;
+ entry[QStringLiteral("name")] = varName;
+ entry[QStringLiteral("value")] = value;
+ envArray.append(entry);
+ }
+ }
+
+ // Tell the child MCP server which DBus service name to target so it
+ // reaches THIS process's editor instance, not a different Kate process.
+ // The "p" prefix is required (a D-Bus name element must not start with
+ // a digit) and must match EditorDBusService::registerOnBus() exactly.
+ {
+ QJsonObject entry;
+ entry[QStringLiteral("name")] = QStringLiteral("KATECODE_DBUS_SERVICE");
+ entry[QStringLiteral("value")] = QString(QStringLiteral("org.kde.katecode.editor.p")
+ + QString::number(QCoreApplication::applicationPid()));
+ envArray.append(entry);
+ }
+
+ kateMcp[QStringLiteral("env")] = envArray;
+ mcpServers.append(kateMcp);
+ qDebug() << "[ACPSession] Added Kate MCP server:" << mcpServerPath;
+ } else {
+ qWarning() << "[ACPSession] Kate MCP server not found";
+ }
+
+ if (!m_mcpConfigPath.isEmpty()) {
+ const QJsonArray externalServers = loadExternalMcpServers(m_mcpConfigPath);
+ for (const QJsonValue &server : externalServers) {
+ mcpServers.append(server);
+ }
+ } else {
+ qDebug() << "[ACPSession] No MCP config path configured for this provider";
+ }
+
+ return mcpServers;
+}
+
+void ACPSession::createNewSession()
+{
+ if (m_status != ConnectionStatus::Connecting) {
+ qWarning() << "[ACPSession] createNewSession called but not in Connecting state";
+ return;
+ }
+
+ qDebug() << "[ACPSession] Creating new session";
+
+ QJsonObject params;
+ params[QStringLiteral("cwd")] = m_workingDir;
+ params[QStringLiteral("mcpServers")] = buildMcpServers();
+
+ m_sessionNewRequestId = m_service->sendRequest(QStringLiteral("session/new"), params);
+ qDebug() << "[ACPSession] Sent session/new request, id:" << m_sessionNewRequestId;
+}
+
+void ACPSession::loadSession(const QString &sessionId)
+{
+ if (m_status != ConnectionStatus::Connecting) {
+ qWarning() << "[ACPSession] loadSession called but not in Connecting state";
+ return;
+ }
+
+ if (sessionId.isEmpty()) {
+ qWarning() << "[ACPSession] loadSession called with empty session ID";
+ Q_EMIT sessionLoadFailed(QStringLiteral("Empty session ID"));
+ return;
+ }
+
+ qDebug() << "[ACPSession] Loading existing session:" << sessionId;
+
+ QJsonObject params;
+ params[QStringLiteral("sessionId")] = sessionId;
+ params[QStringLiteral("cwd")] = m_workingDir;
+ // A resumed Codex/app-server thread still needs the client-provided MCP
+ // definitions for this process, including Kate and provider JSON servers.
+ params[QStringLiteral("mcpServers")] = buildMcpServers();
+
+ // ACP session/load responses do not normally repeat the session id, so
+ // retain the requested id until the response arrives.
+ m_sessionLoadId = sessionId;
+ m_sessionLoadRequestId = m_service->sendRequest(QStringLiteral("session/load"), params);
+ qDebug() << "[ACPSession] Sent session/load request, id:" << m_sessionLoadRequestId;
+}
+
+void ACPSession::sendMessage(const QString &content, const QString &filePath, const QString &selection, const QList &contextChunks, const QList &images)
{
if (m_status != ConnectionStatus::Connected) {
qWarning() << "[ACPSession] Cannot send message: not connected";
return;
}
- // Create user message (for display)
+ const bool isFirstMessage = (m_messageCounter == 0);
+
+ // Emit the user message immediately so the UI updates regardless of
+ // whether the prompt can be dispatched now or must be queued.
Message userMsg;
userMsg.id = QStringLiteral("msg_%1").arg(++m_messageCounter);
userMsg.role = QStringLiteral("user");
userMsg.timestamp = QDateTime::currentDateTime();
userMsg.content = content;
+ userMsg.images = images;
Q_EMIT messageAdded(userMsg);
+ m_transcript->recordMessage(userMsg);
+
+ // If a session/prompt round-trip is already in flight, queue this prompt
+ // instead of sending a concurrent request (which agents may mishandle).
+ // The assistant placeholder is created only when the prompt is dispatched.
+ if (isPromptRunning()) {
+ m_promptQueue.append(QueuedPrompt{content, filePath, selection, contextChunks, images});
+ qDebug() << "[ACPSession] Prompt busy; queued follow-up (" << m_promptQueue.size() << " queued)";
+ return;
+ }
+
+ dispatchPrompt(content, filePath, selection, contextChunks, images, isFirstMessage);
+}
- // Create assistant placeholder for streaming
+void ACPSession::dispatchPrompt(const QString &content, const QString &filePath, const QString &selection, const QList &contextChunks, const QList &images, bool isFirstMessage)
+{
+ // Create assistant placeholder for streaming — exactly one per dispatched turn.
Message assistantMsg;
assistantMsg.id = QStringLiteral("msg_%1").arg(++m_messageCounter);
assistantMsg.role = QStringLiteral("assistant");
assistantMsg.timestamp = QDateTime::currentDateTime();
assistantMsg.isStreaming = true;
m_currentMessageId = assistantMsg.id;
+ m_currentMessageContent.clear();
+ m_currentMessageTimestamp = assistantMsg.timestamp;
Q_EMIT messageAdded(assistantMsg);
// Build prompt blocks for ACP using proper resource blocks
QJsonArray promptBlocks;
- // Add file context as embedded resource if available
- if (!filePath.isEmpty() && !selection.isEmpty()) {
- // Add resource block with selection
- QJsonObject resourceBlock;
- resourceBlock[QStringLiteral("type")] = QStringLiteral("resource");
-
- QJsonObject resource;
- resource[QStringLiteral("uri")] = QUrl::fromLocalFile(filePath).toString();
- resource[QStringLiteral("text")] = selection;
-
- // Try to guess MIME type from file extension
- QString mimeType = QStringLiteral("text/plain");
- if (filePath.endsWith(QStringLiteral(".cpp")) || filePath.endsWith(QStringLiteral(".h")) ||
- filePath.endsWith(QStringLiteral(".cc")) || filePath.endsWith(QStringLiteral(".cxx"))) {
- mimeType = QStringLiteral("text/x-c++");
- } else if (filePath.endsWith(QStringLiteral(".py"))) {
- mimeType = QStringLiteral("text/x-python");
- } else if (filePath.endsWith(QStringLiteral(".js"))) {
- mimeType = QStringLiteral("text/javascript");
- } else if (filePath.endsWith(QStringLiteral(".rs"))) {
- mimeType = QStringLiteral("text/x-rust");
- }
- resource[QStringLiteral("mimeType")] = mimeType;
-
- resourceBlock[QStringLiteral("resource")] = resource;
- promptBlocks.append(resourceBlock);
- } else if (!filePath.isEmpty()) {
- // Add just a file reference (no content)
- QJsonObject resourceBlock;
- resourceBlock[QStringLiteral("type")] = QStringLiteral("resource");
-
- QJsonObject resource;
- resource[QStringLiteral("uri")] = QUrl::fromLocalFile(filePath).toString();
- resource[QStringLiteral("text")] = QStringLiteral("(current file)");
- resource[QStringLiteral("mimeType")] = QStringLiteral("text/plain");
-
- resourceBlock[QStringLiteral("resource")] = resource;
- promptBlocks.append(resourceBlock);
+ // Add file context as embedded resource if available.
+ // Embedded-context (resource) blocks are only sent when the agent advertised
+ // embeddedContext support in its promptCapabilities.
+ if (m_supportsEmbeddedContext) {
+ if (!filePath.isEmpty() && !selection.isEmpty()) {
+ // Add resource block with selection
+ QJsonObject resourceBlock;
+ resourceBlock[QStringLiteral("type")] = QStringLiteral("resource");
+
+ QJsonObject resource;
+ resource[QStringLiteral("uri")] = QUrl::fromLocalFile(filePath).toString();
+ resource[QStringLiteral("text")] = selection;
+
+ // Try to guess MIME type from file extension
+ QString mimeType = QStringLiteral("text/plain");
+ if (filePath.endsWith(QStringLiteral(".cpp")) || filePath.endsWith(QStringLiteral(".h")) ||
+ filePath.endsWith(QStringLiteral(".cc")) || filePath.endsWith(QStringLiteral(".cxx"))) {
+ mimeType = QStringLiteral("text/x-c++");
+ } else if (filePath.endsWith(QStringLiteral(".py"))) {
+ mimeType = QStringLiteral("text/x-python");
+ } else if (filePath.endsWith(QStringLiteral(".js"))) {
+ mimeType = QStringLiteral("text/javascript");
+ } else if (filePath.endsWith(QStringLiteral(".rs"))) {
+ mimeType = QStringLiteral("text/x-rust");
+ }
+ resource[QStringLiteral("mimeType")] = mimeType;
+
+ resourceBlock[QStringLiteral("resource")] = resource;
+ promptBlocks.append(resourceBlock);
+ } else if (!filePath.isEmpty()) {
+ // Add just a file reference (no content)
+ QJsonObject resourceBlock;
+ resourceBlock[QStringLiteral("type")] = QStringLiteral("resource");
+
+ QJsonObject resource;
+ resource[QStringLiteral("uri")] = QUrl::fromLocalFile(filePath).toString();
+ resource[QStringLiteral("text")] = QStringLiteral("(current file)");
+ resource[QStringLiteral("mimeType")] = QStringLiteral("text/plain");
+
+ resourceBlock[QStringLiteral("resource")] = resource;
+ promptBlocks.append(resourceBlock);
+ }
+
+ // Add context chunks as embedded resources
+ for (const ContextChunk &chunk : contextChunks) {
+ QJsonObject resourceBlock;
+ resourceBlock[QStringLiteral("type")] = QStringLiteral("resource");
+
+ QJsonObject resource;
+ resource[QStringLiteral("uri")] = QUrl::fromLocalFile(chunk.filePath).toString();
+ resource[QStringLiteral("text")] = chunk.content;
+
+ // Guess MIME type from file extension
+ QString mimeType = QStringLiteral("text/plain");
+ if (chunk.filePath.endsWith(QStringLiteral(".cpp")) || chunk.filePath.endsWith(QStringLiteral(".h")) ||
+ chunk.filePath.endsWith(QStringLiteral(".cc")) || chunk.filePath.endsWith(QStringLiteral(".cxx"))) {
+ mimeType = QStringLiteral("text/x-c++");
+ } else if (chunk.filePath.endsWith(QStringLiteral(".py"))) {
+ mimeType = QStringLiteral("text/x-python");
+ } else if (chunk.filePath.endsWith(QStringLiteral(".js"))) {
+ mimeType = QStringLiteral("text/javascript");
+ } else if (chunk.filePath.endsWith(QStringLiteral(".rs"))) {
+ mimeType = QStringLiteral("text/x-rust");
+ }
+ resource[QStringLiteral("mimeType")] = mimeType;
+
+ resourceBlock[QStringLiteral("resource")] = resource;
+ promptBlocks.append(resourceBlock);
+ }
+ } else if (!filePath.isEmpty() || !contextChunks.isEmpty()) {
+ qDebug() << "[ACPSession] Skipping embedded-context blocks: agent did not advertise embeddedContext capability";
+ }
+
+ // Image blocks are only sent when the agent advertised image support.
+ if (m_supportsImage) {
+ for (const ImageAttachment &img : images) {
+ QJsonObject imageBlock;
+ imageBlock[QStringLiteral("type")] = QStringLiteral("image");
+ imageBlock[QStringLiteral("mimeType")] = img.mimeType;
+ imageBlock[QStringLiteral("data")] = QString::fromLatin1(img.data.toBase64());
+ promptBlocks.append(imageBlock);
+
+ qDebug() << "[ACPSession] Added image block - mimeType:" << img.mimeType
+ << "data size:" << img.data.size() << "bytes"
+ << "base64 length:" << img.data.toBase64().size();
+ }
+ } else if (!images.isEmpty()) {
+ qWarning() << "[ACPSession] Skipping" << images.size()
+ << "image(s): agent did not advertise image capability";
}
// Add user's actual message
QJsonObject textBlock;
textBlock[QStringLiteral("type")] = QStringLiteral("text");
- textBlock[QStringLiteral("text")] = content;
+
+ // On the first message, inject editor context and tool preferences.
+ QString messageText = content;
+ if (isFirstMessage) {
+ messageText += QStringLiteral("\n\n"
+ "You are running inside the KDE Kate text editor via the Kate Code plugin. "
+ "The files the user has open in Kate are your working context. "
+ "File reads, edits, and writes should go through the kate MCP tools so the user "
+ "can review changes directly in the editor. "
+ "The workspace root is the current project directory.\n\n"
+ "Use mcp__kate__katecode_documents when you need to see which files are open in Kate.\n\n"
+ "In sessions with mcp__kate__katecode_read always use it instead of Read as it contains the most up-to-date contents.\n\n"
+ "In sessions with mcp__kate__katecode_write always use it instead of Write as it will\n"
+ "allow the user to conveniently review changes.\n\n"
+ "In sessions with mcp__kate__katecode_edit always use it instead of Edit as it will\n"
+ "allow the user to conveniently review changes."
+ "");
+ }
+ textBlock[QStringLiteral("text")] = messageText;
promptBlocks.append(textBlock);
QJsonObject params;
@@ -145,13 +834,36 @@ void ACPSession::sendMessage(const QString &content, const QString &filePath, co
void ACPSession::onConnected()
{
qDebug() << "[ACPSession] ACP process started, sending initialize";
- m_status = ConnectionStatus::Connecting;
- Q_EMIT statusChanged(m_status);
+ if (m_status != ConnectionStatus::Connecting) {
+ m_status = ConnectionStatus::Connecting;
+ Q_EMIT statusChanged(m_status);
+ }
// Send initialize request
QJsonObject params;
params[QStringLiteral("protocolVersion")] = 1;
+ // Advertise terminal support so agent uses terminal/* methods
+ QJsonObject capabilities;
+ capabilities[QStringLiteral("terminal")] = true;
+
+ // Advertise filesystem support
+ QJsonObject fsCapabilities;
+ fsCapabilities[QStringLiteral("readTextFile")] = true;
+ fsCapabilities[QStringLiteral("writeTextFile")] = true;
+ capabilities[QStringLiteral("fs")] = fsCapabilities;
+
+ // Select options are part of the base protocol. Explicitly advertise the
+ // optional boolean form because provider session configuration can retain
+ // and send native JSON booleans.
+ QJsonObject configOptionCapabilities;
+ configOptionCapabilities[QStringLiteral("boolean")] = QJsonObject();
+ QJsonObject sessionCapabilities;
+ sessionCapabilities[QStringLiteral("configOptions")] = configOptionCapabilities;
+ capabilities[QStringLiteral("session")] = sessionCapabilities;
+
+ params[QStringLiteral("clientCapabilities")] = capabilities;
+
m_initializeRequestId = m_service->sendRequest(QStringLiteral("initialize"), params);
qDebug() << "[ACPSession] Sent initialize request, id:" << m_initializeRequestId;
}
@@ -159,8 +871,34 @@ void ACPSession::onConnected()
void ACPSession::onDisconnected(int exitCode)
{
qDebug() << "[ACPSession] Disconnected with exit code:" << exitCode;
+ bool wasAlreadyDisconnected = (m_status == ConnectionStatus::Disconnected);
m_status = ConnectionStatus::Disconnected;
- m_sessionId.clear();
+
+ if (wasAlreadyDisconnected) {
+ // Deliberate stop(): state was already reset there.
+ m_sessionId.clear();
+ return;
+ }
+
+ // Unexpected agent death: record what was streamed, finalise the message
+ // and mirror stop()'s cleanup so a reconnect starts from a clean slate
+ // (a stale m_promptRequestId would queue every future prompt forever).
+ const QString unfinishedMessageId = m_currentMessageId;
+ if (!unfinishedMessageId.isEmpty() && !m_currentMessageContent.isEmpty()) {
+ Message assistantMsg;
+ assistantMsg.id = unfinishedMessageId;
+ assistantMsg.role = QStringLiteral("assistant");
+ assistantMsg.content = m_currentMessageContent;
+ assistantMsg.timestamp = m_currentMessageTimestamp;
+ m_transcript->recordMessage(assistantMsg);
+ }
+ m_transcript->finishSession();
+ m_terminalManager->releaseAll();
+ resetSessionState(); // also fails any in-flight summary
+
+ if (!unfinishedMessageId.isEmpty()) {
+ Q_EMIT messageFinished(unfinishedMessageId);
+ }
Q_EMIT statusChanged(m_status);
}
@@ -170,14 +908,97 @@ void ACPSession::onNotification(const QString &method, const QJsonObject ¶ms
handleSessionUpdate(params);
} else if (method == QStringLiteral("session/request_permission")) {
handlePermissionRequest(params, requestId);
+ } else if (method == QStringLiteral("terminal/create")) {
+ handleTerminalCreate(params, requestId);
+ } else if (method == QStringLiteral("terminal/output")) {
+ handleTerminalOutput(params, requestId);
+ } else if (method == QStringLiteral("terminal/wait_for_exit")) {
+ handleTerminalWaitForExit(params, requestId);
+ } else if (method == QStringLiteral("terminal/kill")) {
+ handleTerminalKill(params, requestId);
+ } else if (method == QStringLiteral("terminal/release")) {
+ handleTerminalRelease(params, requestId);
+ } else if (method == QStringLiteral("fs/read_text_file")) {
+ handleFsReadTextFile(params, requestId);
+ } else if (method == QStringLiteral("fs/write_text_file")) {
+ handleFsWriteTextFile(params, requestId);
}
}
void ACPSession::onResponse(int id, const QJsonObject &result, const QJsonObject &error)
{
- if (!error.isEmpty()) {
- qWarning() << "[ACPSession] Error response for id" << id << ":" << error;
- Q_EMIT errorOccurred(error[QStringLiteral("message")].toString());
+ // Handle session/load errors specially - we want to fall back to new session
+ if (id == m_sessionLoadRequestId) {
+ handleSessionLoadResponse(id, result, error);
+ return;
+ }
+
+ // Configuration errors are non-fatal: report the failed option and keep
+ // applying any remaining per-provider session settings.
+ if (id == m_sessionConfigRequestId) {
+ handleSessionConfigResponse(result, error);
+ return;
+ }
+
+ // Interactive mode-change response (distinct from the startup config flow).
+ if (id == m_interactiveModeRequestId) {
+ handleInteractiveModeResponse(result, error);
+ return;
+ }
+
+ // A prompt cancelled on behalf of requestSummary() has now fully finished
+ // (this is its response), so no further chunks from the old turn can
+ // arrive: it is safe to send the parked summary prompt.
+ if (id == m_summaryAfterPromptId) {
+ m_summaryAfterPromptId = -1;
+ const QString prompt = m_pendingSummaryPrompt;
+ m_pendingSummaryPrompt.clear();
+ sendSummaryPrompt(prompt);
+ return;
+ }
+
+ // Silent summary prompt (requestSummary): resolve it without touching the
+ // chat state, and before the generic error handling below.
+ if (id == m_summaryRequestId) {
+ const QString collected = m_summaryCollected.trimmed();
+ m_summaryRequestId = -1;
+ m_summaryCollected.clear();
+ if (!error.isEmpty()) {
+ Q_EMIT summaryResult(QString(),
+ error[QStringLiteral("message")].toString(QStringLiteral("agent error")));
+ } else if (collected.isEmpty()) {
+ Q_EMIT summaryResult(QString(), QStringLiteral("Agent returned an empty summary"));
+ } else {
+ Q_EMIT summaryResult(collected, QString());
+ }
+ return;
+ }
+
+ if (!error.isEmpty()) {
+ qWarning() << "[ACPSession] Error response for id" << id << ":" << error;
+ Q_EMIT errorOccurred(error[QStringLiteral("message")].toString());
+
+ // A failed session/prompt must still finalise the turn, otherwise
+ // isPromptRunning() stays true and every future prompt queues forever.
+ if (id == m_promptRequestId) {
+ if (!m_currentMessageId.isEmpty()) {
+ Q_EMIT messageFinished(m_currentMessageId);
+ m_currentMessageId.clear();
+ m_currentMessageContent.clear();
+ }
+ m_promptRequestId = -1;
+ if (!m_promptQueue.isEmpty()) {
+ const QueuedPrompt next = m_promptQueue.takeFirst();
+ dispatchPrompt(next.content, next.filePath, next.selection, next.contextChunks, next.images, false);
+ }
+ } else if (id == m_initializeRequestId || id == m_sessionNewRequestId) {
+ // Failed session setup would otherwise leave the UI stuck on
+ // "Connecting" with every button disabled.
+ m_initializeRequestId = -1;
+ m_sessionNewRequestId = -1;
+ m_status = ConnectionStatus::Error;
+ Q_EMIT statusChanged(m_status);
+ }
return;
}
@@ -186,13 +1007,35 @@ void ACPSession::onResponse(int id, const QJsonObject &result, const QJsonObject
} else if (id == m_sessionNewRequestId) {
handleSessionNewResponse(id, result);
} else if (id == m_promptRequestId) {
- // Prompt completed - finish streaming message
+ // Prompt completed — finish the streaming message.
qDebug() << "[ACPSession] Prompt response received, finishing message:" << m_currentMessageId;
if (!m_currentMessageId.isEmpty()) {
+ // Record transcript here for agents that end the turn via the
+ // session/prompt RESPONSE rather than agent_message_end. If
+ // agent_message_end already fired it cleared both fields, so this
+ // path sees them empty and is a safe no-op (no double-record).
+ if (!m_currentMessageContent.isEmpty() && m_transcript) {
+ Message assistantMsg;
+ assistantMsg.id = m_currentMessageId;
+ assistantMsg.role = QStringLiteral("assistant");
+ assistantMsg.content = m_currentMessageContent;
+ assistantMsg.timestamp = m_currentMessageTimestamp;
+ m_transcript->recordMessage(assistantMsg);
+ }
Q_EMIT messageFinished(m_currentMessageId);
m_currentMessageId.clear();
+ m_currentMessageContent.clear();
}
m_promptRequestId = -1;
+
+ // Flush the next queued prompt if one arrived while this turn was running.
+ // isPromptRunning() is now false, so dispatchPrompt sends immediately
+ // and creates exactly one new assistant placeholder.
+ if (!m_promptQueue.isEmpty()) {
+ const QueuedPrompt next = m_promptQueue.takeFirst();
+ // Not the first message; the session is already established.
+ dispatchPrompt(next.content, next.filePath, next.selection, next.contextChunks, next.images, false);
+ }
}
}
@@ -206,18 +1049,30 @@ void ACPSession::handleInitializeResponse(int id, const QJsonObject &result)
Q_UNUSED(id);
qDebug() << "[ACPSession] Initialize response received:" << result;
- // Send session/new request
- QJsonObject params;
- params[QStringLiteral("cwd")] = m_workingDir;
- params[QStringLiteral("mcpServers")] = QJsonArray(); // No MCP servers
+ // Parse agent prompt capabilities so we can degrade gracefully on servers
+ // that don't support all block types (e.g. Codex omits image/embeddedContext).
+ const QJsonObject agentCaps = result[QStringLiteral("agentCapabilities")].toObject();
+ const QJsonObject promptCaps = agentCaps[QStringLiteral("promptCapabilities")].toObject();
+ m_supportsImage = promptCaps[QStringLiteral("image")].toBool(false);
+ m_supportsEmbeddedContext = promptCaps[QStringLiteral("embeddedContext")].toBool(false);
+ m_supportsPromptQueueing = agentCaps[QStringLiteral("_meta")].toObject()
+ [QStringLiteral("claudeCode")].toObject()
+ [QStringLiteral("promptQueueing")].toBool(false);
- m_sessionNewRequestId = m_service->sendRequest(QStringLiteral("session/new"), params);
- qDebug() << "[ACPSession] Sent session/new request, id:" << m_sessionNewRequestId;
+ qDebug() << "[ACPSession] Agent capabilities:"
+ << "image=" << m_supportsImage
+ << "embeddedContext=" << m_supportsEmbeddedContext
+ << "promptQueueing=" << m_supportsPromptQueueing;
+
+ // Don't automatically create session - let ChatWidget decide
+ // whether to load an existing session or create a new one
+ Q_EMIT initializeComplete();
}
void ACPSession::handleSessionNewResponse(int id, const QJsonObject &result)
{
Q_UNUSED(id);
+ m_sessionNewRequestId = -1;
m_sessionId = result[QStringLiteral("sessionId")].toString();
qDebug() << "[ACPSession] Session created with ID:" << m_sessionId;
@@ -225,10 +1080,261 @@ void ACPSession::handleSessionNewResponse(int id, const QJsonObject &result)
qWarning() << "[ACPSession] ERROR: Session ID is empty! Full result:" << result;
m_status = ConnectionStatus::Error;
Q_EMIT errorOccurred(QStringLiteral("Failed to get session ID from ACP"));
+ Q_EMIT statusChanged(m_status);
+ return;
+ }
+
+ parseSessionSetupResult(result);
+ beginSessionConfiguration(false);
+}
+
+void ACPSession::handleSessionLoadResponse(int id, const QJsonObject &result, const QJsonObject &error)
+{
+ Q_UNUSED(id);
+ const QString requestedSessionId = m_sessionLoadId;
+ m_sessionLoadId.clear();
+ m_sessionLoadRequestId = -1;
+
+ if (!error.isEmpty()) {
+ m_sessionId.clear();
+ QString errorMsg = error[QStringLiteral("message")].toString();
+ qWarning() << "[ACPSession] Session load failed:" << errorMsg;
+ Q_EMIT sessionLoadFailed(errorMsg);
+ return;
+ }
+
+ // session/load returns mode/config state; unlike session/new, the standard
+ // response does not include sessionId. Accept it when supplied by a legacy
+ // agent, otherwise use the id from the request.
+ m_sessionId = result[QStringLiteral("sessionId")].toString(requestedSessionId);
+ qDebug() << "[ACPSession] Session loaded with ID:" << m_sessionId;
+
+ if (m_sessionId.isEmpty()) {
+ qWarning() << "[ACPSession] ERROR: Session ID is empty after load!";
+ Q_EMIT sessionLoadFailed(QStringLiteral("Empty session ID in response"));
+ return;
+ }
+
+ parseSessionSetupResult(result);
+ beginSessionConfiguration(true);
+}
+
+void ACPSession::parseSessionSetupResult(const QJsonObject &result)
+{
+ const QJsonObject modes = result[QStringLiteral("modes")].toObject();
+ if (!modes.isEmpty()) {
+ m_availableModes = modes[QStringLiteral("availableModes")].toArray();
+ m_currentMode = modes[QStringLiteral("currentModeId")].toString();
+ } else {
+ // Compatibility with older agents that returned mode fields at the top level.
+ m_availableModes = result[QStringLiteral("availableModes")].toArray();
+ m_currentMode = result[QStringLiteral("currentModeId")].toString();
+ }
+
+ updateSessionConfigOptions(result[QStringLiteral("configOptions")].toArray(), false);
+
+ qDebug() << "[ACPSession] Available modes:" << m_availableModes.size();
+ qDebug() << "[ACPSession] Current mode:" << m_currentMode;
+ qDebug() << "[ACPSession] Available config options:" << m_availableConfigOptions.size();
+}
+
+void ACPSession::updateSessionConfigOptions(const QJsonArray &configOptions, bool emitChanges)
+{
+ m_availableConfigOptions = configOptions;
+
+ for (const QJsonValue &value : configOptions) {
+ const QJsonObject option = value.toObject();
+ if (option[QStringLiteral("id")].toString() != QStringLiteral("mode")) {
+ continue;
+ }
+
+ QJsonArray modes;
+ auto appendMode = [&modes](const QJsonObject &choice) {
+ const QString id = choice[QStringLiteral("value")].toString();
+ if (id.isEmpty()) {
+ return;
+ }
+ QJsonObject mode;
+ mode[QStringLiteral("id")] = id;
+ mode[QStringLiteral("name")] = choice[QStringLiteral("name")];
+ mode[QStringLiteral("description")] = choice[QStringLiteral("description")];
+ modes.append(mode);
+ };
+
+ // ACP select choices may be a flat array or grouped one level deep.
+ for (const QJsonValue &choiceValue : option[QStringLiteral("options")].toArray()) {
+ const QJsonObject choice = choiceValue.toObject();
+ if (choice.contains(QStringLiteral("options"))) {
+ for (const QJsonValue &nestedValue : choice[QStringLiteral("options")].toArray()) {
+ appendMode(nestedValue.toObject());
+ }
+ } else {
+ appendMode(choice);
+ }
+ }
+
+ if (!modes.isEmpty()) {
+ m_availableModes = modes;
+ }
+ m_currentMode = option[QStringLiteral("currentValue")].toString(m_currentMode);
+
+ if (emitChanges) {
+ Q_EMIT modesAvailable(m_availableModes);
+ if (!m_currentMode.isEmpty()) {
+ Q_EMIT modeChanged(m_currentMode);
+ }
+ }
+ break;
+ }
+}
+
+void ACPSession::beginSessionConfiguration(bool loadedSession)
+{
+ m_configuringLoadedSession = loadedSession;
+ m_pendingSessionConfigKeys.clear();
+ m_currentSessionConfigKey.clear();
+
+ QSet advertised;
+ for (const QJsonValue &value : m_availableConfigOptions) {
+ const QString id = value.toObject()[QStringLiteral("id")].toString();
+ if (!id.isEmpty()) {
+ advertised.insert(id);
+ }
+ }
+
+ QStringList remainingKeys = m_sessionConfig.keys();
+ QStringList configuredKeys;
+ // Model must be selected before reasoning effort because changing model can
+ // replace the advertised effort choices.
+ static const QStringList preferredOrder = {
+ QStringLiteral("model"),
+ QStringLiteral("reasoning_effort"),
+ QStringLiteral("mode"),
+ };
+ for (const QString &key : preferredOrder) {
+ if (remainingKeys.removeOne(key)) {
+ configuredKeys.append(key);
+ }
+ }
+ configuredKeys.append(remainingKeys);
+
+ QStringList skipped;
+ for (const QString &key : configuredKeys) {
+ if (advertised.contains(key)) {
+ m_pendingSessionConfigKeys.append(key);
+ } else {
+ skipped.append(key);
+ }
+ }
+
+ if (!skipped.isEmpty()) {
+ const QString message = QStringLiteral("ACP agent did not advertise session configuration option(s): %1")
+ .arg(skipped.join(QStringLiteral(", ")));
+ qWarning() << "[ACPSession]" << message;
+ Q_EMIT errorOccurred(message);
+ }
+
+ sendNextSessionConfigOption();
+}
+
+void ACPSession::sendNextSessionConfigOption()
+{
+ if (m_pendingSessionConfigKeys.isEmpty()) {
+ completeSessionSetup(m_configuringLoadedSession);
+ return;
+ }
+
+ m_currentSessionConfigKey = m_pendingSessionConfigKeys.takeFirst();
+ QJsonObject params;
+ params[QStringLiteral("sessionId")] = m_sessionId;
+ params[QStringLiteral("configId")] = m_currentSessionConfigKey;
+ const QJsonValue value = m_sessionConfig.value(m_currentSessionConfigKey);
+ params[QStringLiteral("value")] = value;
+ if (value.isBool()) {
+ // Required by the ACP boolean config-option request variant.
+ params[QStringLiteral("type")] = QStringLiteral("boolean");
+ }
+ m_sessionConfigRequestId = m_service->sendRequest(QStringLiteral("session/set_config_option"), params);
+ if (m_sessionConfigRequestId < 0) {
+ qWarning() << "[ACPSession] Failed to send session config option" << m_currentSessionConfigKey;
+ sendNextSessionConfigOption();
+ }
+}
+
+void ACPSession::handleSessionConfigResponse(const QJsonObject &result, const QJsonObject &error)
+{
+ if (!error.isEmpty()) {
+ const QString message = QStringLiteral("Failed to apply ACP session configuration %1: %2")
+ .arg(m_currentSessionConfigKey,
+ error[QStringLiteral("message")].toString(QStringLiteral("unknown error")));
+ qWarning() << "[ACPSession]" << message;
+ Q_EMIT errorOccurred(message);
+ } else if (result.contains(QStringLiteral("configOptions"))) {
+ updateSessionConfigOptions(result[QStringLiteral("configOptions")].toArray(), false);
+ }
+
+ m_sessionConfigRequestId = -1;
+ m_currentSessionConfigKey.clear();
+ sendNextSessionConfigOption();
+}
+
+void ACPSession::handleInteractiveModeResponse(const QJsonObject &result, const QJsonObject &error)
+{
+ // Always clear the in-flight state first; we capture what we need locally.
+ const QString attempted = m_pendingModeValue;
+ m_interactiveModeRequestId = -1;
+ m_pendingModeValue.clear();
+
+ if (!error.isEmpty()) {
+ const QString errMsg = error[QStringLiteral("message")].toString(
+ QStringLiteral("unknown error"));
+ qWarning() << "[ACPSession] Interactive mode change to" << attempted
+ << "rejected by agent:" << errMsg;
+ // Roll the dropdown back to the last confirmed mode so the UI is not
+ // left showing a mode the agent refused.
+ Q_EMIT modeChanged(m_currentMode);
} else {
- m_status = ConnectionStatus::Connected;
+ if (result.contains(QStringLiteral("configOptions"))) {
+ // Modern response: let updateSessionConfigOptions() drive m_currentMode
+ // and emit modesAvailable/modeChanged so the dropdown reflects the
+ // confirmed state returned by the agent.
+ updateSessionConfigOptions(
+ result[QStringLiteral("configOptions")].toArray(), true);
+ } else {
+ // Non-conforming success (e.g. legacy session/set_mode with no body):
+ // accept the attempted value as confirmed and notify the UI.
+ qDebug() << "[ACPSession] Interactive mode change to" << attempted
+ << "succeeded (no configOptions in response)";
+ m_currentMode = attempted;
+ Q_EMIT modeChanged(m_currentMode);
+ }
+ }
+
+ // If the user changed the dropdown again while this request was in flight,
+ // send the coalesced latest selection now.
+ if (!m_queuedModeValue.isEmpty()) {
+ const QString queued = m_queuedModeValue;
+ m_queuedModeValue.clear();
+ if (queued != m_currentMode) {
+ setMode(queued);
+ }
+ }
+}
+
+void ACPSession::completeSessionSetup(bool loadedSession)
+{
+ m_status = ConnectionStatus::Connected;
+
+ // TranscriptWriter appends when a real ACP session is resumed.
+ m_transcript->startSession(m_sessionId, m_workingDir);
+
+ Q_EMIT modesAvailable(m_availableModes);
+ if (!m_currentMode.isEmpty()) {
+ Q_EMIT modeChanged(m_currentMode);
}
+ qDebug() << "[ACPSession]" << (loadedSession ? "Loaded" : "New")
+ << "session ready with configured ACP options";
Q_EMIT statusChanged(m_status);
}
@@ -249,7 +1355,12 @@ void ACPSession::handleSessionUpdate(const QJsonObject ¶ms)
qDebug() << "[ACPSession] Chunk received - messageId:" << m_currentMessageId
<< "text length:" << text.length() << "text:" << text.left(50);
- if (!text.isEmpty() && !m_currentMessageId.isEmpty()) {
+ if (m_summaryRequestId >= 0) {
+ // Silent summary in flight: collect the text away from the chat
+ // view and the transcript.
+ m_summaryCollected += text;
+ } else if (!text.isEmpty() && !m_currentMessageId.isEmpty()) {
+ m_currentMessageContent += text; // Accumulate for transcript
Q_EMIT messageUpdated(m_currentMessageId, text);
}
}
@@ -257,18 +1368,47 @@ void ACPSession::handleSessionUpdate(const QJsonObject ¶ms)
// Finish streaming
qDebug() << "[ACPSession] Agent message ended - messageId:" << m_currentMessageId;
if (!m_currentMessageId.isEmpty()) {
+ // Record complete assistant message to transcript
+ if (!m_currentMessageContent.isEmpty()) {
+ Message assistantMsg;
+ assistantMsg.id = m_currentMessageId;
+ assistantMsg.role = QStringLiteral("assistant");
+ assistantMsg.content = m_currentMessageContent;
+ assistantMsg.timestamp = m_currentMessageTimestamp;
+ m_transcript->recordMessage(assistantMsg);
+ }
Q_EMIT messageFinished(m_currentMessageId);
m_currentMessageId.clear();
+ m_currentMessageContent.clear();
+ } else if (m_summaryRequestId >= 0) {
+ qDebug() << "[ACPSession] agent_message_end for the silent summary prompt";
} else {
qWarning() << "[ACPSession] agent_message_end but no current message ID!";
}
}
else if (updateType == QStringLiteral("tool_call")) {
// Tool call started - data is at root level, not nested
+
+ // DEBUG: Log full tool_call JSON to see format (especially for edits)
+ qDebug() << "[ACPSession] tool_call raw JSON:"
+ << QJsonDocument(update).toJson(QJsonDocument::Compact);
+
ToolCall toolCall;
toolCall.id = update[QStringLiteral("toolCallId")].toString();
toolCall.status = update[QStringLiteral("status")].toString();
- toolCall.input = update[QStringLiteral("rawInput")].toObject();
+ // rawInput may be a JSON object or a JSON string that needs parsing
+ QJsonValue rawInputVal = update[QStringLiteral("rawInput")];
+ if (rawInputVal.isObject()) {
+ toolCall.input = rawInputVal.toObject();
+ } else if (rawInputVal.isString()) {
+ QJsonDocument rawDoc = QJsonDocument::fromJson(rawInputVal.toString().toUtf8());
+ if (rawDoc.isObject()) {
+ toolCall.input = rawDoc.object();
+ }
+ }
+
+ // Track current tool call ID for edit tracking
+ m_currentToolCallId = toolCall.id;
// Get tool name from _meta.claudeCode.toolName or fall back to title
QJsonObject meta = update[QStringLiteral("_meta")].toObject();
@@ -278,6 +1418,9 @@ void ACPSession::handleSessionUpdate(const QJsonObject ¶ms)
toolCall.name = update[QStringLiteral("title")].toString();
}
+ // vibe-acp uses "kind" field to indicate tool type (e.g., "execute" for Bash)
+ QString kind = update[QStringLiteral("kind")].toString();
+
// Extract file path if present
// Try locations array first
QJsonArray locations = update[QStringLiteral("locations")].toArray();
@@ -290,12 +1433,227 @@ void ACPSession::handleSessionUpdate(const QJsonObject ¶ms)
toolCall.filePath = toolCall.input[QStringLiteral("file_path")].toString();
}
+ // Infer tool type from vibe-acp kind or title if toolName is not a known tool
+ // vibe-acp uses "kind" field: "execute" for Bash, or titles like "Reading ...", "Editing ..."
+ if (!isReadTool(toolCall.name) && !isWriteTool(toolCall.name) &&
+ !isEditTool(toolCall.name) && !isBashTool(toolCall.name)) {
+ // Check kind field first - more reliable than title matching
+ if (kind == QStringLiteral("execute")) {
+ toolCall.name = QStringLiteral("Bash");
+ // Extract command from rawInput for display
+ QString command = toolCall.input[QStringLiteral("command")].toString();
+ if (!command.isEmpty()) {
+ toolCall.operationType = QStringLiteral("bash");
+ }
+ }
+ }
+ if (!isReadTool(toolCall.name) && !isWriteTool(toolCall.name) &&
+ !isEditTool(toolCall.name) && !isBashTool(toolCall.name)) {
+ QString title = update[QStringLiteral("title")].toString();
+ if (title.startsWith(QStringLiteral("Reading "))) {
+ toolCall.name = QStringLiteral("Read");
+ if (toolCall.filePath.isEmpty()) {
+ // Extract path from title - it may be relative
+ QString titlePath = title.mid(8); // len("Reading ")
+ if (!titlePath.isEmpty()) {
+ toolCall.filePath = QDir(m_workingDir).absoluteFilePath(titlePath);
+ }
+ }
+ } else if (title.startsWith(QStringLiteral("Editing "))) {
+ toolCall.name = QStringLiteral("Edit");
+ if (toolCall.filePath.isEmpty()) {
+ QString titlePath = title.mid(8);
+ if (!titlePath.isEmpty()) {
+ toolCall.filePath = QDir(m_workingDir).absoluteFilePath(titlePath);
+ }
+ }
+ } else if (title.startsWith(QStringLiteral("Writing "))) {
+ toolCall.name = QStringLiteral("Write");
+ if (toolCall.filePath.isEmpty()) {
+ QString titlePath = title.mid(8);
+ if (!titlePath.isEmpty()) {
+ toolCall.filePath = QDir(m_workingDir).absoluteFilePath(titlePath);
+ }
+ }
+ } else if (title.startsWith(QStringLiteral("Patching "))) {
+ // vibe-acp Edit uses "Patching file.txt (N blocks)" format
+ toolCall.name = QStringLiteral("Edit");
+ if (toolCall.filePath.isEmpty()) {
+ QString titlePath = title.mid(9); // len("Patching ")
+ // Remove trailing " (N blocks)" if present
+ int parenIdx = titlePath.lastIndexOf(QStringLiteral(" ("));
+ if (parenIdx > 0) {
+ titlePath = titlePath.left(parenIdx);
+ }
+ if (!titlePath.isEmpty()) {
+ toolCall.filePath = QDir(m_workingDir).absoluteFilePath(titlePath);
+ }
+ }
+ } else if (title.contains(QStringLiteral("bash")) || title.contains(QStringLiteral("Bash")) ||
+ title.startsWith(QStringLiteral("Running "))) {
+ toolCall.name = QStringLiteral("Bash");
+ }
+ }
+
+ // Final fallback: infer from toolCallId prefix (e.g., Gemini "run_shell_command-")
+ if (!isReadTool(toolCall.name) && !isWriteTool(toolCall.name) &&
+ !isEditTool(toolCall.name) && !isBashTool(toolCall.name)) {
+ QString inferred = inferToolNameFromId(toolCall.id);
+ if (!inferred.isEmpty()) {
+ toolCall.name = inferred;
+ }
+ }
+
+ // Extract Edit/Write specific fields from content array
+ QJsonArray contentArray = update[QStringLiteral("content")].toArray();
+ for (int i = 0; i < contentArray.size(); ++i) {
+ QJsonObject contentItem = contentArray[i].toObject();
+ QString type = contentItem[QStringLiteral("type")].toString();
+
+ if (type == QStringLiteral("diff")) {
+ // This is an Edit operation
+ toolCall.operationType = QStringLiteral("edit");
+
+ EditDiff edit;
+ edit.oldText = contentItem[QStringLiteral("oldText")].toString();
+ edit.newText = contentItem[QStringLiteral("newText")].toString();
+ edit.filePath = contentItem[QStringLiteral("filePath")].toString();
+
+ toolCall.edits.append(edit);
+
+ // Keep backward compatibility with single-edit fields
+ if (i == 0) {
+ toolCall.oldText = edit.oldText;
+ toolCall.newText = edit.newText;
+ }
+
+ qDebug() << "[ACPSession] Edit" << i + 1 << "detected - old:" << edit.oldText.length()
+ << "chars, new:" << edit.newText.length() << "chars";
+ } else if (type == QStringLiteral("terminal")) {
+ // This tool call has embedded terminal output
+ toolCall.terminalId = contentItem[QStringLiteral("terminalId")].toString();
+ qDebug() << "[ACPSession] Terminal embedded - id:" << toolCall.terminalId;
+ }
+ }
+
+ if (!toolCall.edits.isEmpty()) {
+ qDebug() << "[ACPSession] Total edits in tool call:" << toolCall.edits.size();
+ }
+
+ // Fallback: Extract edit data from rawInput for MCP tools (e.g., mcp__kate__katecode_edit)
+ // MCP tools use old_string/new_string in rawInput, not diff objects in content array
+ if (toolCall.edits.isEmpty() && isEditTool(toolCall.name)) {
+ QString oldStr = toolCall.input[QStringLiteral("old_string")].toString();
+ QString newStr = toolCall.input[QStringLiteral("new_string")].toString();
+
+ if (!oldStr.isEmpty() || !newStr.isEmpty()) {
+ EditDiff edit;
+ edit.oldText = oldStr;
+ edit.newText = newStr;
+ edit.filePath = toolCall.filePath;
+ toolCall.edits.append(edit);
+ toolCall.oldText = oldStr;
+ toolCall.newText = newStr;
+
+ qDebug() << "[ACPSession] Edit from rawInput - old:" << oldStr.length()
+ << "chars, new:" << newStr.length() << "chars";
+ }
+ }
+
+ // Fallback: Extract write content from rawInput for MCP tools (e.g., mcp__kate__katecode_write)
+ if (isWriteTool(toolCall.name) && toolCall.newText.isEmpty()) {
+ QString content = toolCall.input[QStringLiteral("content")].toString();
+ if (!content.isEmpty()) {
+ toolCall.newText = content;
+ qDebug() << "[ACPSession] Write content from rawInput:" << content.length() << "chars";
+ }
+ }
+
qDebug() << "[ACPSession] Tool call - id:" << toolCall.id
<< "name:" << toolCall.name << "status:" << toolCall.status
- << "file:" << toolCall.filePath;
+ << "file:" << toolCall.filePath << "operation:" << toolCall.operationType;
+
+ // Store tool input for later lookup (e.g., to check ExitPlanMode parameters)
+ m_toolCallInputs[toolCall.id] = toolCall.input;
if (!m_currentMessageId.isEmpty()) {
Q_EMIT toolCallAdded(m_currentMessageId, toolCall);
+ m_transcript->recordToolCall(toolCall);
+
+ // Handle already-completed tool calls (some agents send tool_call with status=completed
+ // and rawOutput in a single event, without a separate tool_call_update)
+ if (toolCall.status == QStringLiteral("completed")) {
+ QString result;
+ QJsonValue rawOutputValue = update[QStringLiteral("rawOutput")];
+
+ // Check if rawOutput is an object (bash tool format with exitCode, stdout, stderr)
+ if (rawOutputValue.isObject()) {
+ QJsonObject rawObj = rawOutputValue.toObject();
+ if (rawObj.contains(QStringLiteral("stdout")) || rawObj.contains(QStringLiteral("stderr"))) {
+ QString stdoutText = rawObj[QStringLiteral("stdout")].toString();
+ QString stderrText = rawObj[QStringLiteral("stderr")].toString();
+ int exitCode = rawObj[QStringLiteral("exitCode")].toInt();
+
+ QStringList parts;
+ if (!stdoutText.isEmpty()) {
+ parts.append(stdoutText);
+ }
+ if (!stderrText.isEmpty()) {
+ if (!stdoutText.isEmpty()) {
+ parts.append(QStringLiteral("stderr:\n") + stderrText);
+ } else {
+ parts.append(stderrText);
+ }
+ }
+ if (!parts.isEmpty()) {
+ result = parts.join(QStringLiteral("\n"));
+ } else if (exitCode != 0) {
+ result = QStringLiteral("Exit code: %1").arg(exitCode);
+ }
+ qDebug() << "[ACPSession] tool_call completed inline with bash rawOutput - exitCode:"
+ << exitCode << "stdout len:" << stdoutText.length();
+ }
+ }
+
+ // Fall back to rawOutput as string
+ if (result.isEmpty()) {
+ QString rawOutput = rawOutputValue.toString();
+ if (!rawOutput.isEmpty()) {
+ // Try parsing as JSON
+ QJsonDocument rawDoc = QJsonDocument::fromJson(rawOutput.toUtf8());
+ if (!rawDoc.isNull() && rawDoc.isObject()) {
+ QJsonObject rawObj = rawDoc.object();
+ QJsonValue contentValue = rawObj[QStringLiteral("content")];
+ if (contentValue.isString()) {
+ result = contentValue.toString();
+ } else if (contentValue.isArray()) {
+ QJsonArray contentArray = contentValue.toArray();
+ QStringList texts;
+ for (const QJsonValue &item : contentArray) {
+ if (item.isObject()) {
+ QString text = item.toObject()[QStringLiteral("text")].toString();
+ if (!text.isEmpty()) {
+ texts.append(text);
+ }
+ }
+ }
+ result = texts.join(QString());
+ }
+ }
+ if (result.isEmpty()) {
+ result = rawOutput; // Use as-is
+ }
+ qDebug() << "[ACPSession] tool_call completed inline - result length:" << result.length();
+ }
+ }
+
+ // Emit update if we extracted a result
+ if (!result.isEmpty()) {
+ Q_EMIT toolCallUpdated(m_currentMessageId, toolCall.id, toolCall.status, result,
+ toolCall.filePath, toolCall.name);
+ m_transcript->recordToolUpdate(toolCall.id, toolCall.status, result);
+ }
+ }
}
}
else if (updateType == QStringLiteral("tool_call_update")) {
@@ -303,20 +1661,240 @@ void ACPSession::handleSessionUpdate(const QJsonObject ¶ms)
QString toolCallId = update[QStringLiteral("toolCallId")].toString();
QString status = update[QStringLiteral("status")].toString();
+ // DEBUG: Log full tool_call_update JSON to see format (especially for edits)
+ qDebug() << "[ACPSession] tool_call_update raw JSON:"
+ << QJsonDocument(update).toJson(QJsonDocument::Compact);
+
// Extract result text from content array
QString result;
+ QString operationType;
+ QString newText;
+ QString updateFilePath; // File path extracted from this update
+
+ QString terminalId; // Terminal ID extracted from content (vibe-acp sends this in tool_call_update)
+
QJsonArray contentArray = update[QStringLiteral("content")].toArray();
- if (!contentArray.isEmpty()) {
- QJsonObject contentItem = contentArray[0].toObject();
- QJsonObject content = contentItem[QStringLiteral("content")].toObject();
- result = content[QStringLiteral("text")].toString();
+ for (int i = 0; i < contentArray.size(); ++i) {
+ QJsonObject contentItem = contentArray[i].toObject();
+ QString contentType = contentItem[QStringLiteral("type")].toString();
+
+ if (contentType == QStringLiteral("terminal")) {
+ // vibe-acp sends terminal info in tool_call_update (not in initial tool_call)
+ terminalId = contentItem[QStringLiteral("terminalId")].toString();
+ if (terminalId.isEmpty()) {
+ terminalId = contentItem[QStringLiteral("terminal_id")].toString();
+ }
+ qDebug() << "[ACPSession] tool_call_update has terminal content - id:" << terminalId;
+ } else if (contentType == QStringLiteral("content")) {
+ QJsonObject content = contentItem[QStringLiteral("content")].toObject();
+ QString text = content[QStringLiteral("text")].toString();
+ if (!text.isEmpty()) {
+ result = text;
+ }
+ }
+ }
+
+ // Check for tool response in _meta.claudeCode.toolResponse
+ // toolResponse can be either an object (Write tool) or an array (Bash/other tools)
+ QJsonObject meta = update[QStringLiteral("_meta")].toObject();
+ QJsonObject claudeCode = meta[QStringLiteral("claudeCode")].toObject();
+ QString toolName = claudeCode[QStringLiteral("toolName")].toString();
+ QJsonValue toolResponseValue = claudeCode[QStringLiteral("toolResponse")];
+
+ qDebug() << "[ACPSession] DEBUG - toolName:" << toolName
+ << "toolResponse isArray:" << toolResponseValue.isArray()
+ << "toolResponse isObject:" << toolResponseValue.isObject()
+ << "has _meta:" << !meta.isEmpty();
+
+ if (toolResponseValue.isArray()) {
+ // Bash and other tools return an array of content items
+ QJsonArray toolResponseArray = toolResponseValue.toArray();
+ for (const QJsonValue &item : toolResponseArray) {
+ QJsonObject itemObj = item.toObject();
+ QString text = itemObj[QStringLiteral("text")].toString();
+ if (!text.isEmpty()) {
+ result = text;
+ qDebug() << "[ACPSession] Tool response (array) - text length:" << text.length();
+ break;
+ }
+ }
+ } else if (toolResponseValue.isObject()) {
+ // Write tool returns an object with type, content, filePath
+ QJsonObject toolResponse = toolResponseValue.toObject();
+ operationType = toolResponse[QStringLiteral("type")].toString();
+ newText = toolResponse[QStringLiteral("content")].toString();
+ QString filePath = toolResponse[QStringLiteral("filePath")].toString();
+
+ qDebug() << "[ACPSession] DEBUG - operationType:" << operationType
+ << "filePath:" << filePath
+ << "content length:" << newText.length();
+
+ if (operationType == QStringLiteral("create") && toolName == QStringLiteral("Write")) {
+ // Write tool result - show the actual file content
+ result = newText;
+ qDebug() << "[ACPSession] Write tool - created file" << filePath << "with" << newText.length() << "bytes";
+ }
+ }
+
+ // If result is still empty or just a summary, check rawOutput (vibe-acp format)
+ // vibe-acp tools like Read return actual content in rawOutput as a JSON string
+ // Bash tools may return rawOutput as an object with exitCode, stdout, stderr
+ if (result.isEmpty() || (!update[QStringLiteral("rawOutput")].isUndefined() && result.length() < 200)) {
+ QJsonValue rawOutputValue = update[QStringLiteral("rawOutput")];
+
+ // Check if rawOutput is an object (bash tool format with exitCode, stdout, stderr)
+ if (rawOutputValue.isObject()) {
+ QJsonObject rawObj = rawOutputValue.toObject();
+ // Check for bash-style output (has stdout or stderr fields)
+ if (rawObj.contains(QStringLiteral("stdout")) || rawObj.contains(QStringLiteral("stderr"))) {
+ QString stdoutText = rawObj[QStringLiteral("stdout")].toString();
+ QString stderrText = rawObj[QStringLiteral("stderr")].toString();
+ int exitCode = rawObj[QStringLiteral("exitCode")].toInt();
+
+ // Combine stdout and stderr for display
+ QStringList parts;
+ if (!stdoutText.isEmpty()) {
+ parts.append(stdoutText);
+ }
+ if (!stderrText.isEmpty()) {
+ // Prefix stderr if there's also stdout
+ if (!stdoutText.isEmpty()) {
+ parts.append(QStringLiteral("stderr:\n") + stderrText);
+ } else {
+ parts.append(stderrText);
+ }
+ }
+ if (!parts.isEmpty()) {
+ result = parts.join(QStringLiteral("\n"));
+ } else if (exitCode != 0) {
+ // No output but non-zero exit - report the exit code
+ result = QStringLiteral("Exit code: %1").arg(exitCode);
+ }
+ qDebug() << "[ACPSession] Bash rawOutput - exitCode:" << exitCode
+ << "stdout len:" << stdoutText.length()
+ << "stderr len:" << stderrText.length();
+ }
+ }
+
+ // Fall back to rawOutput as string (original format)
+ QString rawOutput = rawOutputValue.toString();
+ if (!rawOutput.isEmpty() && result.isEmpty()) {
+ // rawOutput may be a JSON string (e.g., Read tool returns {"path":...,"content":...})
+ QJsonDocument rawDoc = QJsonDocument::fromJson(rawOutput.toUtf8());
+ if (!rawDoc.isNull() && rawDoc.isObject()) {
+ QJsonObject rawObj = rawDoc.object();
+
+ // Check if this is an Edit/Patch result (has blocks_applied field)
+ if (rawObj.contains(QStringLiteral("blocks_applied"))) {
+ int blocksApplied = rawObj[QStringLiteral("blocks_applied")].toInt();
+ int linesChanged = rawObj[QStringLiteral("lines_changed")].toInt();
+ QString file = rawObj[QStringLiteral("file")].toString();
+ if (!file.isEmpty()) {
+ updateFilePath = file;
+ }
+ // The "content" field contains SEARCH/REPLACE diff text - use it as result
+ QString diffContent = rawObj[QStringLiteral("content")].toString();
+ if (!diffContent.isEmpty()) {
+ result = diffContent;
+ } else {
+ result = QStringLiteral("%1 block(s) applied, %2 line(s) changed")
+ .arg(blocksApplied).arg(linesChanged);
+ }
+ qDebug() << "[ACPSession] Edit rawOutput - file:" << file
+ << "blocks:" << blocksApplied << "lines:" << linesChanged;
+ } else {
+ // Handle both string and array formats for content
+ QJsonValue contentValue = rawObj[QStringLiteral("content")];
+ QString fileContent;
+ if (contentValue.isString()) {
+ fileContent = contentValue.toString();
+ } else if (contentValue.isArray()) {
+ // Anthropic tool result format: [{"type":"text","text":"..."}]
+ QJsonArray contentArray = contentValue.toArray();
+ QStringList texts;
+ for (const QJsonValue &item : contentArray) {
+ if (item.isObject()) {
+ QJsonObject block = item.toObject();
+ QString text = block[QStringLiteral("text")].toString();
+ if (!text.isEmpty()) {
+ texts.append(text);
+ }
+ }
+ }
+ fileContent = texts.join(QString());
+ }
+ if (!fileContent.isEmpty()) {
+ result = fileContent;
+ qDebug() << "[ACPSession] Extracted content from rawOutput - length:" << result.length();
+ }
+ }
+ // Extract file path from rawOutput (e.g., Read tool returns {"path":"/abs/path"})
+ // Also check "file" field (Edit tool uses this)
+ QString rawPath = rawObj[QStringLiteral("path")].toString();
+ if (rawPath.isEmpty()) {
+ rawPath = rawObj[QStringLiteral("file")].toString();
+ }
+ if (!rawPath.isEmpty() && updateFilePath.isEmpty()) {
+ updateFilePath = rawPath;
+ qDebug() << "[ACPSession] Extracted file path from rawOutput:" << updateFilePath;
+ }
+ } else {
+ // rawOutput is plain text
+ result = rawOutput;
+ qDebug() << "[ACPSession] Using rawOutput as plain text - length:" << result.length();
+ }
+ }
+ }
+
+ // Infer tool name if not provided by _meta (needed when tool_call event was skipped)
+ if (toolName.isEmpty() || (!isReadTool(toolName) && !isWriteTool(toolName) &&
+ !isEditTool(toolName) && !isBashTool(toolName))) {
+ QString kind = update[QStringLiteral("kind")].toString();
+ if (kind == QStringLiteral("execute")) {
+ toolName = QStringLiteral("Bash");
+ }
+ }
+ if (toolName.isEmpty() || (!isReadTool(toolName) && !isWriteTool(toolName) &&
+ !isEditTool(toolName) && !isBashTool(toolName))) {
+ QString inferred = inferToolNameFromId(toolCallId);
+ if (!inferred.isEmpty()) {
+ toolName = inferred;
+ }
}
qDebug() << "[ACPSession] Tool call update - id:" << toolCallId
- << "status:" << status << "result length:" << result.length();
+ << "status:" << status << "operation:" << operationType
+ << "toolName:" << toolName << "result length:" << result.length();
if (!m_currentMessageId.isEmpty()) {
- Q_EMIT toolCallUpdated(m_currentMessageId, toolCallId, status, result);
+ // Link terminal to tool call if we found one (vibe-acp sends terminal in tool_call_update)
+ if (!terminalId.isEmpty()) {
+ Q_EMIT toolCallTerminalIdSet(m_currentMessageId, toolCallId, terminalId);
+ }
+
+ // Only emit update if we have a result OR status changed
+ // (Don't overwrite good results with empty ones from status-only updates)
+ if (!result.isEmpty() || !status.isEmpty()) {
+ Q_EMIT toolCallUpdated(m_currentMessageId, toolCallId, status, result, updateFilePath, toolName);
+ m_transcript->recordToolUpdate(toolCallId, status, result);
+ }
+ }
+
+ // Detect ExitPlanMode completion and switch to appropriate mode
+ if (toolName == QStringLiteral("ExitPlanMode") && status == QStringLiteral("completed")) {
+ // Check if launchSwarm was requested (means "Accept Edits" mode)
+ QJsonObject toolInput = m_toolCallInputs.value(toolCallId);
+ bool launchSwarm = toolInput[QStringLiteral("launchSwarm")].toBool(false);
+
+ QString newMode = launchSwarm ? QStringLiteral("acceptEdits") : QStringLiteral("default");
+ qDebug() << "[ACPSession] ExitPlanMode completed, launchSwarm:" << launchSwarm
+ << "switching to mode:" << newMode;
+
+ m_currentMode = newMode;
+ Q_EMIT modeChanged(newMode);
+
+ // Clean up stored input
+ m_toolCallInputs.remove(toolCallId);
}
}
else if (updateType == QStringLiteral("plan")) {
@@ -340,10 +1918,82 @@ void ACPSession::handleSessionUpdate(const QJsonObject ¶ms)
qDebug() << "[ACPSession] Plan update with" << todos.size() << "entries";
Q_EMIT todosUpdated(todos);
}
+ else if (updateType == QStringLiteral("config_option_update")) {
+ updateSessionConfigOptions(update[QStringLiteral("configOptions")].toArray(),
+ m_status == ConnectionStatus::Connected);
+ }
+ else if (updateType == QStringLiteral("current_mode_update")) {
+ // Agent changed the mode
+ const QString newMode = update[QStringLiteral("currentModeId")].toString(
+ update[QStringLiteral("modeId")].toString());
+ qDebug() << "[ACPSession] Mode changed to:" << newMode;
+ m_currentMode = newMode;
+ if (!newMode.isEmpty()) {
+ Q_EMIT modeChanged(newMode);
+ }
+ }
+ else if (updateType == QStringLiteral("available_commands_update")) {
+ // Available slash commands updated
+ qDebug() << "[ACPSession] available_commands_update raw payload:" << QJsonDocument(update).toJson(QJsonDocument::Compact);
+
+ QJsonArray commandsArray = update[QStringLiteral("availableCommands")].toArray();
+ QList commands;
+
+ for (const QJsonValue &value : commandsArray) {
+ QJsonObject cmdObj = value.toObject();
+ SlashCommand cmd;
+ cmd.name = cmdObj[QStringLiteral("name")].toString();
+ cmd.description = cmdObj[QStringLiteral("description")].toString();
+ commands.append(cmd);
+ }
+
+ qDebug() << "[ACPSession] Available commands updated:" << commands.size() << "commands";
+ m_availableCommands = commands;
+ Q_EMIT commandsAvailable(commands);
+ }
+ else if (updateType == QStringLiteral("session_info_update")) {
+ // Codex uses this to report thread-level status changes (e.g. upstream stream failures).
+ const QString threadStatus =
+ update[QStringLiteral("_meta")].toObject()
+ [QStringLiteral("codex")].toObject()
+ [QStringLiteral("threadStatus")].toObject()
+ [QStringLiteral("type")].toString();
+
+ if (threadStatus == QStringLiteral("systemError") ||
+ threadStatus == QStringLiteral("error")) {
+ qWarning() << "[ACPSession] session_info_update: threadStatus =" << threadStatus
+ << "— signalling sessionError";
+ // Do NOT clear m_currentMessageId or m_promptRequestId: the trailing
+ // agent_message_chunk and prompt response still finalise the turn normally.
+ Q_EMIT sessionError(QStringLiteral(
+ "The agent reported a system error; this turn failed. "
+ "Any details (including an OpenAI request ID) follow below. "
+ "You can send another message to retry."));
+ } else {
+ qDebug() << "[ACPSession] session_info_update: threadStatus =" << threadStatus;
+ }
+ }
+ else {
+ // Log any unrecognised update type so they are not silently dropped.
+ qDebug() << "[ACPSession] Unhandled session/update type:" << updateType;
+ }
}
void ACPSession::handlePermissionRequest(const QJsonObject ¶ms, int requestId)
{
+ // A summary turn must not run tools; decline immediately (mirroring the
+ // headless summariser) so the agent finishes its turn instead of hanging
+ // behind the modal wait dialogue until the timeout.
+ if (isSummaryRunning()) {
+ QJsonObject outcome;
+ outcome[QStringLiteral("outcome")] = QStringLiteral("cancelled");
+ QJsonObject result;
+ result[QStringLiteral("outcome")] = outcome;
+ m_service->sendResponse(requestId, result);
+ qDebug() << "[ACPSession] Declined a tool permission request during summary generation";
+ return;
+ }
+
qDebug() << "[ACPSession] Permission request params:" << params;
PermissionRequest request;
@@ -356,20 +2006,62 @@ void ACPSession::handlePermissionRequest(const QJsonObject ¶ms, int requestI
request.id = toolCall[QStringLiteral("toolCallId")].toString();
request.input = toolCall[QStringLiteral("rawInput")].toObject();
- // Get tool name from title field
- request.toolName = toolCall[QStringLiteral("title")].toString();
+ // Get tool name - try _meta.claudeCode.toolName first (most reliable)
+ QJsonObject meta = toolCall[QStringLiteral("_meta")].toObject();
+ QJsonObject claudeCode = meta[QStringLiteral("claudeCode")].toObject();
+ request.toolName = claudeCode[QStringLiteral("toolName")].toString();
- // Try alternate field names if title is empty
+ // Fall back to name field
if (request.toolName.isEmpty()) {
request.toolName = toolCall[QStringLiteral("name")].toString();
}
if (request.toolName.isEmpty()) {
request.toolName = toolCall[QStringLiteral("toolName")].toString();
}
+
+ // Infer tool type from kind field or title if not a known tool
+ // (same logic as tool_call handler - needed for vibe-acp / Gemini)
+ QString title = toolCall[QStringLiteral("title")].toString();
+ QString kind = toolCall[QStringLiteral("kind")].toString();
+
+ if (request.toolName.isEmpty() ||
+ (!isReadTool(request.toolName) && !isWriteTool(request.toolName) &&
+ !isEditTool(request.toolName) && !isBashTool(request.toolName))) {
+ // Check kind field first - more reliable than title matching
+ if (kind == QStringLiteral("execute")) {
+ request.toolName = QStringLiteral("Bash");
+ }
+ }
+ if (request.toolName.isEmpty() ||
+ (!isReadTool(request.toolName) && !isWriteTool(request.toolName) &&
+ !isEditTool(request.toolName) && !isBashTool(request.toolName))) {
+ if (title.startsWith(QStringLiteral("Reading "))) {
+ request.toolName = QStringLiteral("Read");
+ } else if (title.startsWith(QStringLiteral("Editing "))) {
+ request.toolName = QStringLiteral("Edit");
+ } else if (title.startsWith(QStringLiteral("Writing "))) {
+ request.toolName = QStringLiteral("Write");
+ } else if (title.startsWith(QStringLiteral("Patching "))) {
+ request.toolName = QStringLiteral("Edit");
+ } else if (title.contains(QStringLiteral("bash")) || title.contains(QStringLiteral("Bash")) ||
+ title.startsWith(QStringLiteral("Running "))) {
+ request.toolName = QStringLiteral("Bash");
+ }
+ }
+
+ // Fallback: infer from toolCallId prefix (e.g., Gemini "run_shell_command-")
+ if (request.toolName.isEmpty() ||
+ (!isReadTool(request.toolName) && !isWriteTool(request.toolName) &&
+ !isEditTool(request.toolName) && !isBashTool(request.toolName))) {
+ QString inferred = inferToolNameFromId(request.id);
+ if (!inferred.isEmpty()) {
+ request.toolName = inferred;
+ }
+ }
+
+ // If still no recognized tool name, use the title as-is
if (request.toolName.isEmpty()) {
- QJsonObject meta = toolCall[QStringLiteral("_meta")].toObject();
- QJsonObject claudeCode = meta[QStringLiteral("claudeCode")].toObject();
- request.toolName = claudeCode[QStringLiteral("toolName")].toString();
+ request.toolName = title;
}
QJsonArray optionsArray = params[QStringLiteral("options")].toArray();
@@ -382,3 +2074,536 @@ void ACPSession::handlePermissionRequest(const QJsonObject ¶ms, int requestI
Q_EMIT permissionRequested(request);
}
+
+void ACPSession::handleTerminalCreate(const QJsonObject ¶ms, int requestId)
+{
+ QString command = params[QStringLiteral("command")].toString();
+ QJsonArray argsArray = params[QStringLiteral("args")].toArray();
+ QJsonArray envArray = params[QStringLiteral("env")].toArray();
+ QString cwd = params[QStringLiteral("cwd")].toString();
+ qint64 outputByteLimit = params[QStringLiteral("outputByteLimit")].toVariant().toLongLong();
+
+ qDebug() << "[ACPSession] terminal/create - command:" << command << "cwd:" << cwd;
+
+ // Build environment from base system environment plus any overrides
+ QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
+ env.insert(QStringLiteral("GIT_PAGER"), QStringLiteral("cat")); // Prevent git from using pager
+ for (const QJsonValue &v : envArray) {
+ QJsonObject e = v.toObject();
+ env.insert(e[QStringLiteral("name")].toString(), e[QStringLiteral("value")].toString());
+ }
+
+ // Use working dir from params, or fall back to session working dir
+ if (cwd.isEmpty()) {
+ cwd = m_workingDir;
+ }
+
+ // With an explicit args array, run the executable directly so argument
+ // boundaries and shell metacharacters survive. Only a bare command string
+ // (e.g. "git status") goes through the shell for word splitting.
+ QString program;
+ QStringList arguments;
+ if (!argsArray.isEmpty()) {
+ program = command;
+ for (const QJsonValue &v : argsArray) {
+ arguments.append(v.toString());
+ }
+ } else {
+ program = QStringLiteral("/bin/bash");
+ arguments = QStringList{QStringLiteral("-c"), command};
+ }
+
+ QString terminalId = m_terminalManager->createTerminal(
+ program, arguments, env, cwd, outputByteLimit);
+
+ if (terminalId.isEmpty()) {
+ // Failed to create terminal
+ QJsonObject error;
+ error[QStringLiteral("code")] = -32000;
+ error[QStringLiteral("message")] = QStringLiteral("Failed to create terminal");
+ m_service->sendResponse(requestId, QJsonObject(), error);
+ return;
+ }
+
+ QJsonObject result;
+ result[QStringLiteral("terminalId")] = terminalId;
+ m_service->sendResponse(requestId, result);
+}
+
+void ACPSession::handleTerminalOutput(const QJsonObject ¶ms, int requestId)
+{
+ QString terminalId = params[QStringLiteral("terminalId")].toString();
+
+ qDebug() << "[ACPSession] terminal/output - terminalId:" << terminalId;
+
+ if (!m_terminalManager->isValid(terminalId)) {
+ QJsonObject error;
+ error[QStringLiteral("code")] = -32001;
+ error[QStringLiteral("message")] = QStringLiteral("Terminal not found");
+ m_service->sendResponse(requestId, QJsonObject(), error);
+ return;
+ }
+
+ auto outputResult = m_terminalManager->getOutput(terminalId);
+
+ QJsonObject result;
+ result[QStringLiteral("output")] = outputResult.output;
+ result[QStringLiteral("truncated")] = outputResult.truncated;
+
+ if (outputResult.exitStatus.has_value()) {
+ QJsonObject exitStatus;
+ exitStatus[QStringLiteral("exitCode")] = outputResult.exitStatus.value();
+ result[QStringLiteral("exitStatus")] = exitStatus;
+ }
+
+ m_service->sendResponse(requestId, result);
+}
+
+void ACPSession::handleTerminalWaitForExit(const QJsonObject ¶ms, int requestId)
+{
+ QString terminalId = params[QStringLiteral("terminalId")].toString();
+ int timeoutMs = params[QStringLiteral("timeout")].toInt(-1);
+
+ qDebug() << "[ACPSession] terminal/wait_for_exit - terminalId:" << terminalId << "timeout:" << timeoutMs;
+
+ if (!m_terminalManager->isValid(terminalId)) {
+ QJsonObject error;
+ error[QStringLiteral("code")] = -32001;
+ error[QStringLiteral("message")] = QStringLiteral("Terminal not found");
+ m_service->sendResponse(requestId, QJsonObject(), error);
+ return;
+ }
+
+ auto waitResult = m_terminalManager->waitForExit(terminalId, timeoutMs);
+
+ QJsonObject result;
+ result[QStringLiteral("output")] = waitResult.output;
+ result[QStringLiteral("truncated")] = waitResult.truncated;
+
+ if (waitResult.success) {
+ QJsonObject exitStatus;
+ exitStatus[QStringLiteral("exitCode")] = waitResult.exitStatus;
+ result[QStringLiteral("exitStatus")] = exitStatus;
+ }
+
+ m_service->sendResponse(requestId, result);
+}
+
+void ACPSession::handleTerminalKill(const QJsonObject ¶ms, int requestId)
+{
+ QString terminalId = params[QStringLiteral("terminalId")].toString();
+
+ qDebug() << "[ACPSession] terminal/kill - terminalId:" << terminalId;
+
+ if (!m_terminalManager->isValid(terminalId)) {
+ QJsonObject error;
+ error[QStringLiteral("code")] = -32001;
+ error[QStringLiteral("message")] = QStringLiteral("Terminal not found");
+ m_service->sendResponse(requestId, QJsonObject(), error);
+ return;
+ }
+
+ m_terminalManager->killTerminal(terminalId);
+
+ // Get final output after kill
+ auto outputResult = m_terminalManager->getOutput(terminalId);
+
+ QJsonObject result;
+ result[QStringLiteral("output")] = outputResult.output;
+ result[QStringLiteral("truncated")] = outputResult.truncated;
+
+ if (outputResult.exitStatus.has_value()) {
+ QJsonObject exitStatus;
+ exitStatus[QStringLiteral("exitCode")] = outputResult.exitStatus.value();
+ result[QStringLiteral("exitStatus")] = exitStatus;
+ }
+
+ m_service->sendResponse(requestId, result);
+}
+
+void ACPSession::handleTerminalRelease(const QJsonObject ¶ms, int requestId)
+{
+ QString terminalId = params[QStringLiteral("terminalId")].toString();
+
+ qDebug() << "[ACPSession] terminal/release - terminalId:" << terminalId;
+
+ if (!m_terminalManager->isValid(terminalId)) {
+ QJsonObject error;
+ error[QStringLiteral("code")] = -32001;
+ error[QStringLiteral("message")] = QStringLiteral("Terminal not found");
+ m_service->sendResponse(requestId, QJsonObject(), error);
+ return;
+ }
+
+ // Get output before releasing
+ auto outputResult = m_terminalManager->getOutput(terminalId);
+
+ // Release the terminal (this kills and cleans up)
+ m_terminalManager->releaseTerminal(terminalId);
+
+ QJsonObject result;
+ result[QStringLiteral("output")] = outputResult.output;
+ result[QStringLiteral("truncated")] = outputResult.truncated;
+
+ if (outputResult.exitStatus.has_value()) {
+ QJsonObject exitStatus;
+ exitStatus[QStringLiteral("exitCode")] = outputResult.exitStatus.value();
+ result[QStringLiteral("exitStatus")] = exitStatus;
+ }
+
+ m_service->sendResponse(requestId, result);
+}
+
+void ACPSession::handleFsReadTextFile(const QJsonObject ¶ms, int requestId)
+{
+ QString path = params[QStringLiteral("path")].toString();
+ int line = params[QStringLiteral("line")].toInt(1); // 1-based, default to 1
+ int limit = params[QStringLiteral("limit")].toInt(-1); // -1 means no limit
+
+ qDebug() << "[ACPSession] fs/read_text_file - path:" << path << "line:" << line << "limit:" << limit;
+
+ if (path.isEmpty()) {
+ QJsonObject error;
+ error[QStringLiteral("code")] = -32602;
+ error[QStringLiteral("message")] = QStringLiteral("Missing required parameter: path");
+ m_service->sendResponse(requestId, QJsonObject(), error);
+ return;
+ }
+
+ QString content;
+ bool fromKate = false;
+
+ // Try to get content from Kate document first (may have unsaved changes)
+ if (m_documentProvider) {
+ KTextEditor::Document *doc = m_documentProvider(path);
+ if (doc) {
+ content = doc->text();
+ fromKate = true;
+ qDebug() << "[ACPSession] Reading from Kate document:" << path;
+ }
+ }
+
+ // Fall back to filesystem if not open in Kate
+ if (!fromKate) {
+ QFile file(path);
+ if (!file.exists()) {
+ QJsonObject error;
+ error[QStringLiteral("code")] = -32001;
+ error[QStringLiteral("message")] = QString(QStringLiteral("File not found: ") + path);
+ m_service->sendResponse(requestId, QJsonObject(), error);
+ return;
+ }
+
+ if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ QJsonObject error;
+ error[QStringLiteral("code")] = -32001;
+ error[QStringLiteral("message")] = QString(QStringLiteral("Cannot open file: ") + file.errorString());
+ m_service->sendResponse(requestId, QJsonObject(), error);
+ return;
+ }
+
+ content = QString::fromUtf8(file.readAll());
+ file.close();
+ }
+
+ // Apply line offset and limit
+ QStringList lines = content.split(QLatin1Char('\n'));
+ QStringList resultLines;
+
+ for (int i = 0; i < lines.size(); ++i) {
+ int currentLine = i + 1; // 1-based line numbers
+
+ // Skip lines before the requested start line
+ if (currentLine < line) {
+ continue;
+ }
+
+ resultLines.append(lines[i]);
+
+ // Check limit
+ if (limit > 0 && resultLines.size() >= limit) {
+ break;
+ }
+ }
+
+ QJsonObject result;
+ result[QStringLiteral("content")] = resultLines.join(QLatin1Char('\n'));
+ m_service->sendResponse(requestId, result);
+}
+
+// Helper struct for tracking line changes
+struct LineChange {
+ int startLine; // 0-based start line in old document
+ int oldLineCount; // Number of lines to remove
+ int newLineCount; // Number of lines to insert
+ QStringList newLines; // The new lines to insert
+};
+
+// Compute minimal line-based changes between old and new content
+static QList computeLineChanges(const QStringList &oldLines, const QStringList &newLines)
+{
+ QList changes;
+
+ int oldSize = oldLines.size();
+ int newSize = newLines.size();
+ int i = 0, j = 0;
+
+ while (i < oldSize || j < newSize) {
+ // Find common prefix from current position
+ int commonStart = 0;
+ while (i + commonStart < oldSize && j + commonStart < newSize &&
+ oldLines[i + commonStart] == newLines[j + commonStart]) {
+ ++commonStart;
+ }
+ i += commonStart;
+ j += commonStart;
+
+ if (i >= oldSize && j >= newSize) {
+ break; // Done
+ }
+
+ // Find common suffix from the end of remaining content
+ int oldRemaining = oldSize - i;
+ int newRemaining = newSize - j;
+ int commonEnd = 0;
+ while (commonEnd < oldRemaining && commonEnd < newRemaining &&
+ oldLines[oldSize - 1 - commonEnd] == newLines[newSize - 1 - commonEnd]) {
+ ++commonEnd;
+ }
+
+ // The change spans from current position to just before the common suffix
+ int oldChangeCount = oldRemaining - commonEnd;
+ int newChangeCount = newRemaining - commonEnd;
+
+ if (oldChangeCount > 0 || newChangeCount > 0) {
+ LineChange change;
+ change.startLine = i;
+ change.oldLineCount = oldChangeCount;
+ change.newLineCount = newChangeCount;
+ for (int k = 0; k < newChangeCount; ++k) {
+ change.newLines.append(newLines[j + k]);
+ }
+ changes.append(change);
+ }
+
+ // Move past the changed region
+ i += oldChangeCount;
+ j += newChangeCount;
+ }
+
+ return changes;
+}
+
+// Apply surgical edits to a Kate document, preserving cursor position where possible
+// Returns the list of line changes applied (empty on failure or no changes)
+static QList applySurgicalEdits(KTextEditor::Document *doc, const QString &newContent)
+{
+ QString oldContent = doc->text();
+
+ // If content is identical, no changes needed
+ if (oldContent == newContent) {
+ return QList();
+ }
+
+ // Split into lines for comparison
+ QStringList oldLines = oldContent.split(QLatin1Char('\n'));
+ QStringList newLines = newContent.split(QLatin1Char('\n'));
+
+ // Compute the changes
+ QList changes = computeLineChanges(oldLines, newLines);
+
+ if (changes.isEmpty()) {
+ // Content differs only in ways not captured by line comparison (shouldn't happen)
+ if (doc->setText(newContent)) {
+ // Return a single change representing the whole document
+ LineChange wholeDoc;
+ wholeDoc.startLine = 0;
+ wholeDoc.oldLineCount = oldLines.size();
+ wholeDoc.newLineCount = newLines.size();
+ return QList() << wholeDoc;
+ }
+ return QList();
+ }
+
+ // Save cursor positions from all views
+ QList views = doc->views();
+ QList savedCursors;
+ for (KTextEditor::View *view : views) {
+ savedCursors.append(view->cursorPosition());
+ }
+
+ // Start an editing transaction for undo grouping (RAII - finishes when scope exits)
+ KTextEditor::Document::EditingTransaction transaction(doc);
+
+ // Apply changes in reverse order to avoid line number shifting issues
+ for (int changeIdx = changes.size() - 1; changeIdx >= 0; --changeIdx) {
+ const LineChange &change = changes[changeIdx];
+
+ // Calculate the range to replace
+ int startLine = change.startLine;
+ int endLine = change.startLine + change.oldLineCount;
+
+ KTextEditor::Cursor startPos(startLine, 0);
+ KTextEditor::Cursor endPos;
+
+ if (endLine >= oldLines.size()) {
+ // Replacing to end of document
+ int lastLine = oldLines.size() - 1;
+ endPos = KTextEditor::Cursor(lastLine, oldLines[lastLine].length());
+ } else {
+ // Replacing up to start of next unchanged line
+ endPos = KTextEditor::Cursor(endLine, 0);
+ }
+
+ // Build replacement text
+ QString replacement = change.newLines.join(QLatin1Char('\n'));
+
+ // Add trailing newline if we're not at document end and replacing full lines
+ if (endLine < oldLines.size() && !replacement.isEmpty()) {
+ replacement += QLatin1Char('\n');
+ } else if (change.oldLineCount > 0 && endLine < oldLines.size()) {
+ // We're deleting lines that had a trailing newline
+ // The replacement should not add one if empty
+ }
+
+ // Special case: inserting new lines at document end
+ if (startLine >= oldLines.size()) {
+ startPos = KTextEditor::Cursor(oldLines.size() - 1, oldLines.last().length());
+ if (!replacement.isEmpty()) {
+ replacement = QLatin1Char('\n') + replacement;
+ }
+ }
+
+ KTextEditor::Range range(startPos, endPos);
+ doc->replaceText(range, replacement);
+
+ // Update oldLines to reflect the change for subsequent iterations
+ // (since we're going in reverse, this updates line count for earlier changes)
+ for (int r = 0; r < change.oldLineCount && startLine < oldLines.size(); ++r) {
+ oldLines.removeAt(startLine);
+ }
+ for (int a = 0; a < change.newLines.size(); ++a) {
+ oldLines.insert(startLine + a, change.newLines[a]);
+ }
+ }
+
+ // Transaction finishes automatically when 'transaction' goes out of scope
+
+ // Restore cursor positions, clamping to valid positions
+ for (int v = 0; v < views.size(); ++v) {
+ KTextEditor::Cursor savedCursor = savedCursors[v];
+ int newLine = savedCursor.line();
+
+ // Clamp to valid range
+ if (newLine >= doc->lines()) {
+ newLine = doc->lines() - 1;
+ }
+ if (newLine < 0) {
+ newLine = 0;
+ }
+
+ int newCol = savedCursor.column();
+ int lineLength = doc->lineLength(newLine);
+ if (newCol > lineLength) {
+ newCol = lineLength;
+ }
+
+ views[v]->setCursorPosition(KTextEditor::Cursor(newLine, newCol));
+ }
+
+ return changes;
+}
+
+void ACPSession::handleFsWriteTextFile(const QJsonObject ¶ms, int requestId)
+{
+ QString path = params[QStringLiteral("path")].toString();
+ QString content = params[QStringLiteral("content")].toString();
+
+ qDebug() << "[ACPSession] fs/write_text_file - path:" << path << "content length:" << content.length();
+
+ if (path.isEmpty()) {
+ QJsonObject error;
+ error[QStringLiteral("code")] = -32602;
+ error[QStringLiteral("message")] = QStringLiteral("Missing required parameter: path");
+ m_service->sendResponse(requestId, QJsonObject(), error);
+ return;
+ }
+
+ bool writtenViaKate = false;
+
+ // Check if this is a new file
+ bool isNewFile = !QFile::exists(path);
+
+ // Try to write through Kate document if open
+ if (m_documentProvider) {
+ KTextEditor::Document *doc = m_documentProvider(path);
+ if (doc) {
+ qDebug() << "[ACPSession] Writing through Kate document:" << path;
+
+ // Use surgical edits to preserve cursor position and minimize gutter markers
+ QList changes = applySurgicalEdits(doc, content);
+ if (!changes.isEmpty()) {
+ bool saved = doc->save();
+ if (saved) {
+ writtenViaKate = true;
+ qDebug() << "[ACPSession] Kate document saved successfully (surgical edit)";
+
+ // Record edits for tracking
+ for (const LineChange &change : changes) {
+ m_editTracker->recordEdit(m_currentToolCallId, path,
+ change.startLine, change.oldLineCount, change.newLineCount);
+ }
+ } else {
+ qWarning() << "[ACPSession] Failed to save Kate document, falling back to direct write";
+ }
+ } else {
+ // Empty changes means content was identical - no edit to track
+ writtenViaKate = true;
+ qDebug() << "[ACPSession] Kate document unchanged (identical content)";
+ }
+ }
+ }
+
+ // Fall back to direct filesystem write
+ if (!writtenViaKate) {
+ // Ensure parent directory exists
+ QFileInfo fileInfo(path);
+ QDir parentDir = fileInfo.absoluteDir();
+ if (!parentDir.exists()) {
+ if (!parentDir.mkpath(QStringLiteral("."))) {
+ QJsonObject error;
+ error[QStringLiteral("code")] = -32001;
+ error[QStringLiteral("message")] = QString(QStringLiteral("Cannot create parent directory: ") + parentDir.absolutePath());
+ m_service->sendResponse(requestId, QJsonObject(), error);
+ return;
+ }
+ }
+
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
+ QJsonObject error;
+ error[QStringLiteral("code")] = -32001;
+ error[QStringLiteral("message")] = QString(QStringLiteral("Cannot open file for writing: ") + file.errorString());
+ m_service->sendResponse(requestId, QJsonObject(), error);
+ return;
+ }
+
+ QTextStream out(&file);
+ out << content;
+ file.close();
+
+ // Record edit for tracking (count lines in content)
+ int lineCount = content.count(QLatin1Char('\n')) + (content.isEmpty() ? 0 : 1);
+ if (isNewFile) {
+ m_editTracker->recordNewFile(m_currentToolCallId, path, lineCount);
+ } else {
+ // For direct writes to existing files, we don't know the exact changes
+ // Record as a full-file replacement
+ m_editTracker->recordEdit(m_currentToolCallId, path, 0, -1, lineCount);
+ }
+ }
+
+ 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 38161a2..c56b0a4 100644
--- a/src/acp/ACPSession.h
+++ b/src/acp/ACPSession.h
@@ -1,10 +1,22 @@
#pragma once
#include "ACPModels.h"
+#include
+#include
+#include
#include
#include
+#include
+#include
+
+namespace KTextEditor {
+class Document;
+}
class ACPService;
+class EditTracker;
+class TerminalManager;
+class TranscriptWriter;
class ACPSession : public QObject
{
@@ -14,15 +26,57 @@ class ACPSession : public QObject
explicit ACPSession(QObject *parent = nullptr);
~ACPSession() override;
- void start(const QString &workingDir);
+ void setExecutable(const QString &executable, const QStringList &args = QStringList());
+ void setMcpConfigPath(const QString &path);
+ void setSessionConfig(const QJsonObject &config);
+ void start(const QString &workingDir, const QString &permissionMode = QStringLiteral("default"));
void stop();
- void sendMessage(const QString &content, const QString &filePath = QString(), const QString &selection = QString());
+ // Session management - called after initializeComplete signal
+ void createNewSession();
+ void loadSession(const QString &sessionId);
+
+ void sendMessage(const QString &content, const QString &filePath = QString(), const QString &selection = QString(), const QList &contextChunks = QList(), const QList &images = QList());
void sendPermissionResponse(int requestId, const QJsonObject &outcome);
+ void setMode(const QString &modeId);
bool isConnected() const { return m_status == ConnectionStatus::Connected; }
+ bool isPromptRunning() const { return m_promptRequestId >= 0; }
ConnectionStatus status() const { return m_status; }
QString sessionId() const { return m_sessionId; }
+ // The directory the session was started in (empty before first start).
+ QString workingDir() const { return m_workingDir; }
+
+ // Passthrough to the TranscriptWriter — empty if no session has started.
+ QString transcriptFilePath() const;
+
+ // Agent prompt capabilities, parsed from the initialize response.
+ bool supportsImage() const { return m_supportsImage; }
+ bool supportsEmbeddedContext() const { return m_supportsEmbeddedContext; }
+ bool supportsPromptQueueing() const { return m_supportsPromptQueueing; }
+
+ void cancelPrompt();
+
+ // Ask the live agent for a session summary without touching the chat UI or
+ // the transcript. Any running prompt is cancelled first (the session is
+ // about to end). The outcome arrives via summaryResult().
+ void requestSummary(const QString &prompt);
+ void cancelSummary();
+ bool isSummaryRunning() const { return m_summaryRequestId >= 0 || m_summaryAfterPromptId >= 0; }
+
+ QJsonArray availableModes() const { return m_availableModes; }
+ QString currentMode() const { return m_currentMode; }
+ QList availableCommands() const { return m_availableCommands; }
+
+ // Set terminal size based on view width (columns calculated from pixel width)
+ void setTerminalSize(int columns, int rows = 40);
+
+ // Document provider for Kate integration (reads/writes use Kate documents when open)
+ using DocumentProvider = std::function;
+ void setDocumentProvider(DocumentProvider provider);
+
+ // Edit tracker for tracking file modifications
+ EditTracker *editTracker() const { return m_editTracker; }
Q_SIGNALS:
void statusChanged(ConnectionStatus status);
@@ -30,10 +84,32 @@ class ACPSession : public QObject
void messageUpdated(const QString &messageId, const QString &content);
void messageFinished(const QString &messageId);
void toolCallAdded(const QString &messageId, const ToolCall &toolCall);
- void toolCallUpdated(const QString &messageId, const QString &toolCallId, const QString &status, const QString &result);
+ void toolCallUpdated(const QString &messageId, const QString &toolCallId, const QString &status, const QString &result, const QString &filePath = QString(), const QString &toolName = QString());
void todosUpdated(const QList &todos);
void permissionRequested(const PermissionRequest &request);
+ void modesAvailable(const QJsonArray &modes);
+ void modeChanged(const QString &modeId);
+ void commandsAvailable(const QList &commands);
void errorOccurred(const QString &message);
+ // Emitted when the agent reports a systemError threadStatus in session_info_update.
+ void sessionError(const QString &message);
+ void promptCancelled();
+
+ // Debug: raw JSON-RPC payloads (direction is ">>" for sent, "<<" for received)
+ void jsonPayload(const QString &direction, const QString &json);
+
+ // Emitted after initialize completes, before session creation
+ void initializeComplete();
+
+ // Emitted if session/load fails (caller should fall back to createNewSession)
+ void sessionLoadFailed(const QString &error);
+
+ // Outcome of requestSummary(); error is empty on success.
+ void summaryResult(const QString &summary, const QString &error);
+
+ // Terminal signals for UI updates
+ void terminalOutputUpdated(const QString &terminalId, const QString &output, bool finished);
+ void toolCallTerminalIdSet(const QString &messageId, const QString &toolCallId, const QString &terminalId);
private Q_SLOTS:
void onConnected();
@@ -43,22 +119,125 @@ private Q_SLOTS:
void onError(const QString &message);
private:
+ // A follow-up prompt queued while another session/prompt is in flight.
+ struct QueuedPrompt {
+ QString content;
+ QString filePath;
+ QString selection;
+ QList contextChunks;
+ QList images;
+ };
+
+ // Send the assistant placeholder and session/prompt request.
+ // isFirstMessage controls whether the is injected.
+ void dispatchPrompt(const QString &content,
+ const QString &filePath,
+ const QString &selection,
+ const QList &contextChunks,
+ const QList &images,
+ bool isFirstMessage);
+
+ // Send the silent summary session/prompt now (requestSummary defers this
+ // while a cancelled prompt's response is outstanding).
+ void sendSummaryPrompt(const QString &prompt);
+ // Clear per-session protocol state; shared by stop() and unexpected
+ // disconnects so a reconnect always starts clean.
+ void resetSessionState();
void handleInitializeResponse(int id, const QJsonObject &result);
void handleSessionNewResponse(int id, const QJsonObject &result);
+ void handleSessionLoadResponse(int id, const QJsonObject &result, const QJsonObject &error);
+ void handleSessionConfigResponse(const QJsonObject &result, const QJsonObject &error);
+ // Handler for interactive (dropdown) mode-change requests.
+ void handleInteractiveModeResponse(const QJsonObject &result, const QJsonObject &error);
void handleSessionUpdate(const QJsonObject ¶ms);
void handlePermissionRequest(const QJsonObject ¶ms, int requestId);
+ QJsonArray buildMcpServers() const;
+ void parseSessionSetupResult(const QJsonObject &result);
+ void updateSessionConfigOptions(const QJsonArray &configOptions, bool emitChanges);
+ void beginSessionConfiguration(bool loadedSession);
+ void sendNextSessionConfigOption();
+ void completeSessionSetup(bool loadedSession);
+
+ // Terminal request handlers
+ void handleTerminalCreate(const QJsonObject ¶ms, int requestId);
+ void handleTerminalOutput(const QJsonObject ¶ms, int requestId);
+ void handleTerminalWaitForExit(const QJsonObject ¶ms, int requestId);
+ void handleTerminalKill(const QJsonObject ¶ms, int requestId);
+ void handleTerminalRelease(const QJsonObject ¶ms, int requestId);
+
+ // Filesystem request handlers
+ void handleFsReadTextFile(const QJsonObject ¶ms, int requestId);
+ void handleFsWriteTextFile(const QJsonObject ¶ms, int requestId);
ACPService *m_service;
+ TerminalManager *m_terminalManager;
+ TranscriptWriter *m_transcript;
ConnectionStatus m_status;
QString m_sessionId;
QString m_workingDir;
+ QString m_currentMode;
+ QJsonArray m_availableModes;
+ QList m_availableCommands;
QString m_currentMessageId;
+ QString m_currentMessageContent; // Accumulated content for transcript
+ QDateTime m_currentMessageTimestamp;
+ QHash m_toolCallInputs; // Track tool inputs by toolCallId
// Request tracking
int m_initializeRequestId;
int m_sessionNewRequestId;
+ int m_sessionLoadRequestId;
+ int m_sessionConfigRequestId;
int m_promptRequestId;
+ QString m_sessionLoadId;
+
+ // Follow-up prompts queued while a session/prompt round-trip is in flight.
+ QList m_promptQueue;
+
+ // In-flight silent summary prompt (requestSummary): while m_summaryRequestId
+ // is set, agent_message_chunk text accumulates here instead of the chat.
+ // When a prompt had to be cancelled first, the summary prompt is parked in
+ // m_pendingSummaryPrompt until the cancelled request's response arrives
+ // (m_summaryAfterPromptId) so stale chunks cannot leak into the summary.
+ int m_summaryRequestId = -1;
+ int m_summaryAfterPromptId = -1;
+ QString m_pendingSummaryPrompt;
+ QString m_summaryCollected;
+
+ // Interactive mode-change state (separate from the startup config flow).
+ // m_interactiveModeRequestId: id of the in-flight session/set_config_option or
+ // session/set_mode request triggered by the user's dropdown selection (-1 = none).
+ // m_pendingModeValue: the value sent in that request.
+ // m_queuedModeValue: the latest value requested while a request is in flight;
+ // sent as a follow-up once the current response arrives.
+ int m_interactiveModeRequestId = -1;
+ QString m_pendingModeValue;
+ QString m_queuedModeValue;
// Message counter
int m_messageCounter;
+
+ // Kate document provider
+ DocumentProvider m_documentProvider;
+
+ // Edit tracker
+ EditTracker *m_editTracker;
+
+ // Current tool call ID for edit tracking
+ QString m_currentToolCallId;
+
+ // MCP config path for loading external MCP servers
+ QString m_mcpConfigPath;
+
+ // Per-provider ACP session configuration applied after session/new or load.
+ QJsonObject m_sessionConfig;
+ QJsonArray m_availableConfigOptions;
+ QStringList m_pendingSessionConfigKeys;
+ QString m_currentSessionConfigKey;
+ bool m_configuringLoadedSession = false;
+
+ // Agent prompt capabilities from the initialize response (default false).
+ bool m_supportsImage = false;
+ bool m_supportsEmbeddedContext = false;
+ bool m_supportsPromptQueueing = false;
};
diff --git a/src/acp/TerminalManager.cpp b/src/acp/TerminalManager.cpp
new file mode 100644
index 0000000..52faafd
--- /dev/null
+++ b/src/acp/TerminalManager.cpp
@@ -0,0 +1,322 @@
+#include "TerminalManager.h"
+
+#include
+#include
+#include
+
+#include
+
+TerminalManager::TerminalManager(QObject *parent)
+ : QObject(parent)
+ , m_idCounter(0)
+{
+}
+
+TerminalManager::~TerminalManager()
+{
+ releaseAll();
+}
+
+QString TerminalManager::generateTerminalId()
+{
+ return QStringLiteral("term_%1").arg(++m_idCounter);
+}
+
+QString TerminalManager::createTerminal(const QString &command, const QStringList &args,
+ const QProcessEnvironment &env, const QString &cwd,
+ qint64 outputByteLimit)
+{
+ QString terminalId = generateTerminalId();
+
+ qDebug() << "[TerminalManager] Creating terminal" << terminalId << "command:" << command << "args:" << args;
+
+ auto *process = new KPtyProcess(this);
+ process->setWorkingDirectory(cwd);
+ process->setProcessEnvironment(env);
+
+ // Use PTY for all channels - this makes programs think they're in a real terminal
+ process->setPtyChannels(KPtyProcess::AllChannels);
+
+ // Store terminal ID in process property for slot handlers
+ process->setProperty("terminalId", terminalId);
+
+ connect(process, &KPtyProcess::finished, this, &TerminalManager::onProcessFinished);
+ connect(process, &KPtyProcess::errorOccurred, this, &TerminalManager::onProcessError);
+
+ TerminalData data;
+ data.process = process;
+ data.outputByteLimit = outputByteLimit;
+ data.command = command;
+ m_terminals.insert(terminalId, data);
+
+ process->setProgram(command);
+ process->setArguments(args);
+ process->start();
+
+ if (!process->waitForStarted(5000)) {
+ qWarning() << "[TerminalManager] Failed to start process for terminal" << terminalId;
+ m_terminals.remove(terminalId);
+ process->deleteLater();
+ return QString();
+ }
+
+ // Connect to PTY device for reading output after process starts
+ KPtyDevice *pty = process->pty();
+ if (pty) {
+ pty->setProperty("terminalId", terminalId);
+ connect(pty, &KPtyDevice::readyRead, this, &TerminalManager::onProcessReadyRead);
+
+ // Set terminal window size so programs know available columns/rows
+ pty->setWinSize(m_defaultRows, m_defaultColumns);
+ }
+
+ qDebug() << "[TerminalManager] Terminal" << terminalId << "started with PTY size" << m_defaultColumns << "x" << m_defaultRows;
+ return terminalId;
+}
+
+void TerminalManager::onProcessReadyRead()
+{
+ auto *pty = qobject_cast(sender());
+ if (!pty) {
+ return;
+ }
+
+ QString terminalId = pty->property("terminalId").toString();
+ if (!m_terminals.contains(terminalId)) {
+ return;
+ }
+
+ QByteArray newData = pty->readAll();
+ m_terminals[terminalId].outputBuffer.append(newData);
+
+ truncateOutputIfNeeded(terminalId);
+
+ // Emit signal for live UI updates
+ QString output = QString::fromUtf8(m_terminals[terminalId].outputBuffer);
+ bool finished = m_terminals[terminalId].status != TerminalStatus::Running;
+ Q_EMIT outputAvailable(terminalId, output, finished);
+}
+
+void TerminalManager::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus)
+{
+ Q_UNUSED(exitStatus);
+
+ auto *process = qobject_cast(sender());
+ if (!process) {
+ return;
+ }
+
+ QString terminalId = process->property("terminalId").toString();
+ if (!m_terminals.contains(terminalId)) {
+ return;
+ }
+
+ qDebug() << "[TerminalManager] Terminal" << terminalId << "finished with exit code:" << exitCode;
+
+ // Read any remaining output from PTY
+ KPtyDevice *pty = process->pty();
+ if (pty) {
+ QByteArray remaining = pty->readAll();
+ if (!remaining.isEmpty()) {
+ m_terminals[terminalId].outputBuffer.append(remaining);
+ truncateOutputIfNeeded(terminalId);
+ }
+ }
+
+ m_terminals[terminalId].exitCode = exitCode;
+ if (m_terminals[terminalId].status == TerminalStatus::Running) {
+ m_terminals[terminalId].status = TerminalStatus::Exited;
+ }
+
+ // Emit final output update and exit signal
+ QString output = QString::fromUtf8(m_terminals[terminalId].outputBuffer);
+ Q_EMIT outputAvailable(terminalId, output, true);
+ Q_EMIT terminalExited(terminalId, exitCode);
+}
+
+void TerminalManager::onProcessError(QProcess::ProcessError error)
+{
+ auto *process = qobject_cast(sender());
+ if (!process) {
+ return;
+ }
+
+ QString terminalId = process->property("terminalId").toString();
+ qWarning() << "[TerminalManager] Terminal" << terminalId << "error:" << error << process->errorString();
+}
+
+void TerminalManager::truncateOutputIfNeeded(const QString &terminalId)
+{
+ if (!m_terminals.contains(terminalId)) {
+ return;
+ }
+
+ auto &data = m_terminals[terminalId];
+ if (data.outputByteLimit > 0 && data.outputBuffer.size() > data.outputByteLimit) {
+ // Truncate from the beginning to stay within limit
+ qint64 excess = data.outputBuffer.size() - data.outputByteLimit;
+ data.outputBuffer.remove(0, excess);
+ data.truncated = true;
+ }
+}
+
+TerminalManager::OutputResult TerminalManager::getOutput(const QString &terminalId) const
+{
+ OutputResult result;
+
+ if (!m_terminals.contains(terminalId)) {
+ qWarning() << "[TerminalManager] getOutput: terminal not found:" << terminalId;
+ return result;
+ }
+
+ const auto &data = m_terminals[terminalId];
+ result.output = QString::fromUtf8(data.outputBuffer);
+ result.truncated = data.truncated;
+
+ if (data.status != TerminalStatus::Running) {
+ result.exitStatus = data.exitCode;
+ }
+
+ return result;
+}
+
+TerminalManager::WaitResult TerminalManager::waitForExit(const QString &terminalId, int timeoutMs)
+{
+ WaitResult result;
+
+ if (!m_terminals.contains(terminalId)) {
+ qWarning() << "[TerminalManager] waitForExit: terminal not found:" << terminalId;
+ return result;
+ }
+
+ auto &data = m_terminals[terminalId];
+
+ // If already finished, return immediately
+ if (data.status != TerminalStatus::Running) {
+ result.output = QString::fromUtf8(data.outputBuffer);
+ result.truncated = data.truncated;
+ result.exitStatus = data.exitCode;
+ result.success = true;
+ return result;
+ }
+
+ // Wait for process to finish using event loop
+ QEventLoop loop;
+
+ // Connect to finished signal
+ QMetaObject::Connection finishedConn = connect(data.process, &QProcess::finished,
+ &loop, &QEventLoop::quit);
+
+ // Set up timeout if specified
+ QTimer timer;
+ if (timeoutMs > 0) {
+ timer.setSingleShot(true);
+ connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit);
+ timer.start(timeoutMs);
+ }
+
+ // Wait
+ loop.exec();
+
+ disconnect(finishedConn);
+ timer.stop();
+
+ // The nested loop pumps arbitrary events: terminal/release from the agent
+ // or a disconnect (releaseAll) may have removed the entry, so the earlier
+ // `data` reference can dangle. Re-look the terminal up before reading.
+ auto it = m_terminals.find(terminalId);
+ if (it == m_terminals.end()) {
+ qDebug() << "[TerminalManager] waitForExit: terminal released during wait:" << terminalId;
+ result.success = false;
+ return result;
+ }
+
+ // Check if we timed out
+ if (it->status == TerminalStatus::Running) {
+ // Timeout occurred
+ qDebug() << "[TerminalManager] waitForExit: timeout for terminal" << terminalId;
+ result.output = QString::fromUtf8(it->outputBuffer);
+ result.truncated = it->truncated;
+ result.success = false;
+ return result;
+ }
+
+ result.output = QString::fromUtf8(it->outputBuffer);
+ result.truncated = it->truncated;
+ result.exitStatus = it->exitCode;
+ result.success = true;
+ return result;
+}
+
+bool TerminalManager::killTerminal(const QString &terminalId)
+{
+ if (!m_terminals.contains(terminalId)) {
+ qWarning() << "[TerminalManager] killTerminal: terminal not found:" << terminalId;
+ return false;
+ }
+
+ auto &data = m_terminals[terminalId];
+
+ if (data.status != TerminalStatus::Running) {
+ // Already stopped
+ return true;
+ }
+
+ qDebug() << "[TerminalManager] Killing terminal" << terminalId;
+
+ if (data.process) {
+ data.process->kill();
+ data.process->waitForFinished(1000);
+ }
+
+ data.status = TerminalStatus::Killed;
+ return true;
+}
+
+bool TerminalManager::releaseTerminal(const QString &terminalId)
+{
+ if (!m_terminals.contains(terminalId)) {
+ qWarning() << "[TerminalManager] releaseTerminal: terminal not found:" << terminalId;
+ return false;
+ }
+
+ qDebug() << "[TerminalManager] Releasing terminal" << terminalId;
+
+ auto &data = m_terminals[terminalId];
+
+ if (data.process) {
+ // Disconnect signals before cleanup
+ disconnect(data.process, nullptr, this, nullptr);
+
+ if (data.status == TerminalStatus::Running) {
+ data.process->kill();
+ data.process->waitForFinished(1000);
+ }
+ data.process->deleteLater();
+ }
+
+ m_terminals.remove(terminalId);
+ return true;
+}
+
+bool TerminalManager::isValid(const QString &terminalId) const
+{
+ return m_terminals.contains(terminalId);
+}
+
+void TerminalManager::releaseAll()
+{
+ qDebug() << "[TerminalManager] Releasing all terminals (" << m_terminals.size() << "terminals)";
+
+ QStringList ids = m_terminals.keys();
+ for (const QString &id : ids) {
+ releaseTerminal(id);
+ }
+}
+
+void TerminalManager::setDefaultTerminalSize(int columns, int rows)
+{
+ m_defaultColumns = qBound(40, columns, 500); // Reasonable bounds
+ m_defaultRows = qBound(10, rows, 200);
+ qDebug() << "[TerminalManager] Default terminal size set to" << m_defaultColumns << "x" << m_defaultRows;
+}
diff --git a/src/acp/TerminalManager.h b/src/acp/TerminalManager.h
new file mode 100644
index 0000000..3f64d4b
--- /dev/null
+++ b/src/acp/TerminalManager.h
@@ -0,0 +1,92 @@
+#pragma once
+
+#include "ACPModels.h"
+#include
+#include
+#include
+#include
+
+#include
+
+class TerminalManager : public QObject
+{
+ Q_OBJECT
+
+public:
+ explicit TerminalManager(QObject *parent = nullptr);
+ ~TerminalManager() override;
+
+ // Create a new terminal and spawn the command
+ // Returns empty string on failure
+ QString createTerminal(const QString &command, const QStringList &args,
+ const QProcessEnvironment &env, const QString &cwd,
+ qint64 outputByteLimit = 0);
+
+ // Query terminal output (non-blocking)
+ struct OutputResult {
+ QString output;
+ bool truncated = false;
+ std::optional exitStatus;
+ };
+ OutputResult getOutput(const QString &terminalId) const;
+
+ // Wait for terminal to exit (blocking with event processing)
+ // Returns false if terminal not found or timeout
+ struct WaitResult {
+ QString output;
+ bool truncated = false;
+ int exitStatus = -1;
+ bool success = false;
+ };
+ WaitResult waitForExit(const QString &terminalId, int timeoutMs = -1);
+
+ // Kill the terminal process (keeps terminal valid for output queries)
+ bool killTerminal(const QString &terminalId);
+
+ // Release terminal (kill if running, invalidate)
+ bool releaseTerminal(const QString &terminalId);
+
+ // Check if terminal exists and is valid
+ bool isValid(const QString &terminalId) const;
+
+ // Release all terminals (called on session end)
+ void releaseAll();
+
+ // Set default terminal size for new terminals (columns x rows)
+ void setDefaultTerminalSize(int columns, int rows);
+
+ // Get current default terminal size
+ int defaultColumns() const { return m_defaultColumns; }
+ int defaultRows() const { return m_defaultRows; }
+
+Q_SIGNALS:
+ // Emitted when new output is available (for live UI updates)
+ void outputAvailable(const QString &terminalId, const QString &output, bool finished);
+
+ // Emitted when terminal exits
+ void terminalExited(const QString &terminalId, int exitCode);
+
+private Q_SLOTS:
+ void onProcessReadyRead();
+ void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus);
+ void onProcessError(QProcess::ProcessError error);
+
+private:
+ QString generateTerminalId();
+ void truncateOutputIfNeeded(const QString &terminalId);
+
+ struct TerminalData {
+ KPtyProcess *process = nullptr;
+ QByteArray outputBuffer;
+ TerminalStatus status = TerminalStatus::Running;
+ int exitCode = -1;
+ qint64 outputByteLimit = 0;
+ bool truncated = false;
+ QString command; // For debugging/display
+ };
+
+ QHash m_terminals;
+ int m_idCounter = 0;
+ int m_defaultColumns = 120; // Default terminal width
+ int m_defaultRows = 40; // Default terminal height
+};
diff --git a/src/config/KateCodeConfigPage.cpp b/src/config/KateCodeConfigPage.cpp
new file mode 100644
index 0000000..0ce97e5
--- /dev/null
+++ b/src/config/KateCodeConfigPage.cpp
@@ -0,0 +1,733 @@
+#include "KateCodeConfigPage.h"
+#include "SettingsStore.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+static QJsonValue parseSessionConfigValue(const QString &text);
+
+static QString sessionConfigValueText(const QJsonValue &value)
+{
+ if (value.isString()) {
+ const QString text = value.toString();
+ const QJsonValue reparsed = parseSessionConfigValue(text);
+ if (reparsed.isString() && reparsed.toString() == text) {
+ return text;
+ }
+
+ // Quote strings such as "true", "42", or JSON-looking text so an
+ // unchanged edit round-trips as a string rather than changing type.
+ QJsonArray wrapper;
+ wrapper.append(text);
+ QString encoded = QString::fromUtf8(QJsonDocument(wrapper).toJson(QJsonDocument::Compact));
+ return encoded.mid(1, encoded.size() - 2);
+ }
+ if (value.isBool()) {
+ return value.toBool() ? QStringLiteral("true") : QStringLiteral("false");
+ }
+ if (value.isDouble()) {
+ return QString::number(value.toDouble(), 'g', 15);
+ }
+ if (value.isNull() || value.isUndefined()) {
+ return QStringLiteral("null");
+ }
+ if (value.isArray()) {
+ return QString::fromUtf8(QJsonDocument(value.toArray()).toJson(QJsonDocument::Compact));
+ }
+ return QString::fromUtf8(QJsonDocument(value.toObject()).toJson(QJsonDocument::Compact));
+}
+
+static QJsonValue parseSessionConfigValue(const QString &text)
+{
+ const QString trimmed = text.trimmed();
+ if (trimmed.isEmpty()) {
+ return QString();
+ }
+
+ QJsonParseError parseError;
+ const QByteArray wrapped = QByteArrayLiteral("{\"value\":") + trimmed.toUtf8() + QByteArrayLiteral("}");
+ const QJsonDocument document = QJsonDocument::fromJson(wrapped, &parseError);
+ if (parseError.error == QJsonParseError::NoError && document.isObject()) {
+ return document.object().value(QStringLiteral("value"));
+ }
+ return trimmed;
+}
+
+static QString providerToolTip(const ACPProvider &provider)
+{
+ QStringList lines = {
+ i18n("Executable: %1", provider.executable),
+ i18n("CLI options: %1", provider.options.isEmpty() ? i18n("(none)") : provider.options),
+ i18n("MCP config JSON: %1", provider.mcpConfigPath.isEmpty() ? i18n("(none)") : provider.mcpConfigPath),
+ i18n("True resume: %1", provider.trueResume ? i18n("Yes") : i18n("No")),
+ };
+ if (provider.sessionConfig.isEmpty()) {
+ lines.append(i18n("ACP session configuration: (none)"));
+ } else {
+ lines.append(i18n("ACP session configuration:"));
+ for (auto it = provider.sessionConfig.constBegin(); it != provider.sessionConfig.constEnd(); ++it) {
+ lines.append(QStringLiteral(" %1 = %2").arg(it.key(), sessionConfigValueText(it.value())));
+ }
+ }
+ return lines.join(QLatin1Char('\n'));
+}
+
+KateCodeConfigPage::KateCodeConfigPage(SettingsStore *settings, QWidget *parent)
+ : KTextEditor::ConfigPage(parent)
+ , m_settings(settings)
+ , m_hasChanges(false)
+{
+ setupUi();
+
+ // Load current settings
+ reset();
+}
+
+KateCodeConfigPage::~KateCodeConfigPage() = default;
+
+QString KateCodeConfigPage::name() const
+{
+ return i18n("Kate Code");
+}
+
+QString KateCodeConfigPage::fullName() const
+{
+ return i18n("Kate Code Plugin Settings");
+}
+
+QIcon KateCodeConfigPage::icon() const
+{
+ return QIcon::fromTheme(QStringLiteral("code-context"));
+}
+
+void KateCodeConfigPage::setupUi()
+{
+ auto *mainLayout = new QVBoxLayout(this);
+
+ // Create tab widget
+ m_tabWidget = new QTabWidget(this);
+
+ // Create General tab
+ auto *generalTab = new QWidget();
+ setupGeneralTab(generalTab);
+ m_tabWidget->addTab(generalTab, i18n("General"));
+
+ // Create Advanced tab
+ auto *advancedTab = new QWidget();
+ setupAdvancedTab(advancedTab);
+ m_tabWidget->addTab(advancedTab, i18n("Advanced"));
+
+ mainLayout->addWidget(m_tabWidget);
+}
+
+void KateCodeConfigPage::setupGeneralTab(QWidget *tab)
+{
+ auto *tabLayout = new QVBoxLayout(tab);
+
+ // ACP Providers Group
+ auto *providerGroup = new QGroupBox(i18n("ACP Providers"), tab);
+ auto *providerLayout = new QVBoxLayout(providerGroup);
+
+ m_providerList = new QListWidget(tab);
+ m_providerList->setSelectionMode(QAbstractItemView::SingleSelection);
+ m_providerList->setEditTriggers(QAbstractItemView::NoEditTriggers);
+ m_providerList->setDragDropMode(QAbstractItemView::InternalMove);
+ m_providerList->setDefaultDropAction(Qt::MoveAction);
+ m_providerList->setDropIndicatorShown(true);
+ m_providerList->setToolTip(i18n("Drag providers to reorder them. Hover over a provider to see its full configuration."));
+ providerLayout->addWidget(m_providerList);
+
+ auto *buttonLayout = new QHBoxLayout();
+ m_addProviderButton = new QPushButton(i18n("Add..."), tab);
+ m_editProviderButton = new QPushButton(i18n("Edit..."), tab);
+ m_removeProviderButton = new QPushButton(i18n("Remove"), tab);
+ m_moveProviderUpButton = new QPushButton(i18n("Move Up"), tab);
+ m_moveProviderDownButton = new QPushButton(i18n("Move Down"), tab);
+ m_editProviderButton->setEnabled(false);
+ m_removeProviderButton->setEnabled(false);
+ m_moveProviderUpButton->setEnabled(false);
+ m_moveProviderDownButton->setEnabled(false);
+ buttonLayout->addWidget(m_addProviderButton);
+ buttonLayout->addWidget(m_editProviderButton);
+ buttonLayout->addWidget(m_removeProviderButton);
+ buttonLayout->addWidget(m_moveProviderUpButton);
+ buttonLayout->addWidget(m_moveProviderDownButton);
+ buttonLayout->addStretch();
+ providerLayout->addLayout(buttonLayout);
+
+ connect(m_addProviderButton, &QPushButton::clicked, this, &KateCodeConfigPage::onAddProvider);
+ connect(m_editProviderButton, &QPushButton::clicked, this, &KateCodeConfigPage::onEditProvider);
+ connect(m_removeProviderButton, &QPushButton::clicked, this, &KateCodeConfigPage::onRemoveProvider);
+ connect(m_moveProviderUpButton, &QPushButton::clicked, this, &KateCodeConfigPage::onMoveProviderUp);
+ connect(m_moveProviderDownButton, &QPushButton::clicked, this, &KateCodeConfigPage::onMoveProviderDown);
+ connect(m_providerList, &QListWidget::currentRowChanged, this, [this](int) {
+ updateProviderButtons();
+ });
+ connect(m_providerList, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *) {
+ onEditProvider();
+ });
+ connect(m_providerList->model(), &QAbstractItemModel::rowsMoved, this, [this]() {
+ saveProviderOrder();
+ updateProviderButtons();
+ });
+
+ auto *providerNote = new QLabel(i18n("Drag providers to set their order; only descriptions are shown here. Edit a provider to see its full launch, MCP, resume, and ACP session configuration. At least one provider must remain."), tab);
+ providerNote->setWordWrap(true);
+ providerNote->setStyleSheet(QStringLiteral("color: gray; font-size: small;"));
+ providerLayout->addWidget(providerNote);
+
+ tabLayout->addWidget(providerGroup);
+
+ // Diff Colors Group
+ auto *diffGroup = new QGroupBox(i18n("Diff Highlighting"), tab);
+ auto *diffLayout = new QFormLayout(diffGroup);
+
+ m_diffColorSchemeCombo = new QComboBox(tab);
+ m_diffColorSchemeCombo->addItem(
+ SettingsStore::schemeDisplayName(DiffColorScheme::RedGreen),
+ static_cast(DiffColorScheme::RedGreen));
+ m_diffColorSchemeCombo->addItem(
+ SettingsStore::schemeDisplayName(DiffColorScheme::BlueOrange),
+ static_cast(DiffColorScheme::BlueOrange));
+ m_diffColorSchemeCombo->addItem(
+ SettingsStore::schemeDisplayName(DiffColorScheme::PurpleGreen),
+ static_cast(DiffColorScheme::PurpleGreen));
+ connect(m_diffColorSchemeCombo, &QComboBox::currentIndexChanged,
+ this, &KateCodeConfigPage::onSettingChanged);
+ diffLayout->addRow(i18n("Color scheme:"), m_diffColorSchemeCombo);
+
+ auto *diffNote = new QLabel(i18n("Choose a colorblind-friendly scheme if you have difficulty distinguishing red and green."), tab);
+ diffNote->setWordWrap(true);
+ diffNote->setStyleSheet(QStringLiteral("color: gray; font-size: small;"));
+ diffLayout->addRow(diffNote);
+
+ tabLayout->addWidget(diffGroup);
+
+ // Debugging Group
+ auto *debugGroup = new QGroupBox(i18n("Debugging"), tab);
+ auto *debugLayout = new QVBoxLayout(debugGroup);
+
+ m_debugLoggingCheck = new QCheckBox(i18n("Log ACP protocol JSON to Output view"), tab);
+ connect(m_debugLoggingCheck, &QCheckBox::toggled,
+ this, &KateCodeConfigPage::onSettingChanged);
+ debugLayout->addWidget(m_debugLoggingCheck);
+
+ auto *debugNote = new QLabel(i18n("When enabled, all JSON-RPC messages sent to and received from the ACP server are logged to Kate's Output panel."), tab);
+ debugNote->setWordWrap(true);
+ debugNote->setStyleSheet(QStringLiteral("color: gray; font-size: small;"));
+ debugLayout->addWidget(debugNote);
+
+ tabLayout->addWidget(debugGroup);
+
+ // Stretch to push everything to top
+ tabLayout->addStretch();
+}
+
+void KateCodeConfigPage::setupAdvancedTab(QWidget *tab)
+{
+ auto *tabLayout = new QVBoxLayout(tab);
+
+ // Session Summaries Group
+ auto *summaryGroup = new QGroupBox(i18n("Session Summaries"), tab);
+ auto *summaryLayout = new QFormLayout(summaryGroup);
+
+ m_enableSummariesCheck = new QCheckBox(i18n("Generate summaries when sessions end"), tab);
+ connect(m_enableSummariesCheck, &QCheckBox::toggled,
+ this, &KateCodeConfigPage::onSettingChanged);
+ summaryLayout->addRow(m_enableSummariesCheck);
+
+ m_summaryProviderCombo = new QComboBox(tab);
+ connect(m_summaryProviderCombo, &QComboBox::currentIndexChanged,
+ this, &KateCodeConfigPage::onSettingChanged);
+ summaryLayout->addRow(i18n("Summary agent:"), m_summaryProviderCombo);
+
+ auto *summaryNote = new QLabel(i18n("The selected agent generates the summary when a session ends. "
+ "\"Current agent\" asks the live session to summarise itself before it disconnects; "
+ "other agents run briefly in the background. "
+ "Summaries are stored in ~/.kate-code/summaries/ and can be used as context when resuming sessions."), tab);
+ summaryNote->setWordWrap(true);
+ summaryNote->setStyleSheet(QStringLiteral("color: gray; font-size: small;"));
+ summaryLayout->addRow(summaryNote);
+
+ tabLayout->addWidget(summaryGroup);
+
+ // Session Resume Group
+ auto *sessionGroup = new QGroupBox(i18n("Session Resume"), tab);
+ auto *sessionLayout = new QVBoxLayout(sessionGroup);
+
+ m_autoResumeCheck = new QCheckBox(i18n("Prompt to resume previous session when connecting"), tab);
+ connect(m_autoResumeCheck, &QCheckBox::toggled,
+ this, &KateCodeConfigPage::onSettingChanged);
+ sessionLayout->addWidget(m_autoResumeCheck);
+
+ m_summariseOnResumeCheck = new QCheckBox(i18n("Summarise an abandoned (raw) session before resuming"), tab);
+ connect(m_summariseOnResumeCheck, &QCheckBox::toggled,
+ this, &KateCodeConfigPage::onSettingChanged);
+ sessionLayout->addWidget(m_summariseOnResumeCheck);
+
+ auto *resumeNote = new QLabel(i18n("When resuming a session that has no summary yet, generate one with the summary agent first. Otherwise the raw transcript is used as context."), tab);
+ resumeNote->setWordWrap(true);
+ resumeNote->setStyleSheet(QStringLiteral("color: gray; font-size: small;"));
+ sessionLayout->addWidget(resumeNote);
+
+ tabLayout->addWidget(sessionGroup);
+
+ // ACP File Logging Group
+ auto *logGroup = new QGroupBox(i18n("ACP Session Logging"), tab);
+ auto *logLayout = new QFormLayout(logGroup);
+
+ m_acpLogEnableCheck = new QCheckBox(i18n("Write raw ACP JSON traffic to a file"), tab);
+ connect(m_acpLogEnableCheck, &QCheckBox::toggled,
+ this, &KateCodeConfigPage::onSettingChanged);
+ logLayout->addRow(m_acpLogEnableCheck);
+
+ m_acpLogDirEdit = new QLineEdit(tab);
+ m_acpLogDirEdit->setPlaceholderText(i18n("e.g. /tmp"));
+ connect(m_acpLogDirEdit, &QLineEdit::textChanged,
+ this, &KateCodeConfigPage::onSettingChanged);
+ logLayout->addRow(i18n("Base directory:"), m_acpLogDirEdit);
+
+ m_acpLogSubdirEdit = new QLineEdit(tab);
+ m_acpLogSubdirEdit->setPlaceholderText(i18n("e.g. kate_code_sessions"));
+ connect(m_acpLogSubdirEdit, &QLineEdit::textChanged,
+ this, &KateCodeConfigPage::onSettingChanged);
+ logLayout->addRow(i18n("Subdirectory name:"), m_acpLogSubdirEdit);
+
+ auto *logNote = new QLabel(i18n("Each session writes one timestamped .json file (one JSON object per line, flushed immediately) into the subdirectory created inside the base directory. This is separate from the on-screen chat."), tab);
+ logNote->setWordWrap(true);
+ logNote->setStyleSheet(QStringLiteral("color: gray; font-size: small;"));
+ logLayout->addRow(logNote);
+
+ tabLayout->addWidget(logGroup);
+
+ // Command Auto-Approval Group
+ auto *approvalGroup = new QGroupBox(i18n("Command Auto-Approval"), tab);
+ auto *approvalLayout = new QVBoxLayout(approvalGroup);
+
+ auto *approvalNote = new QLabel(
+ i18n("Commands run by the agent whose text matches one of these glob patterns "
+ "are approved automatically without prompting. Enter one pattern per line. "
+ "Leave empty to always prompt.\n"
+ "Examples: git status, ls *, npm run *"),
+ tab);
+ approvalNote->setWordWrap(true);
+ approvalLayout->addWidget(approvalNote);
+
+ m_allowedCommandsEdit = new QPlainTextEdit(tab);
+ m_allowedCommandsEdit->setPlaceholderText(i18n("e.g.\ngit status\nls *\nnpm run *"));
+ connect(m_allowedCommandsEdit, &QPlainTextEdit::textChanged,
+ this, &KateCodeConfigPage::onSettingChanged);
+ approvalLayout->addWidget(m_allowedCommandsEdit);
+
+ tabLayout->addWidget(approvalGroup);
+
+ // Stretch to push everything to top
+ tabLayout->addStretch();
+}
+
+void KateCodeConfigPage::populateProviderList()
+{
+ const QString selectedId = m_providerList->currentItem()
+ ? m_providerList->currentItem()->data(Qt::UserRole).toString()
+ : QString();
+ const QSignalBlocker blocker(m_providerList);
+ m_providerList->clear();
+ const auto providerList = m_settings->providers();
+ for (const auto &p : providerList) {
+ auto *item = new QListWidgetItem(p.description, m_providerList);
+ item->setData(Qt::UserRole, p.id);
+ item->setToolTip(providerToolTip(p));
+ if (p.id == selectedId) {
+ m_providerList->setCurrentItem(item);
+ }
+ }
+ if (!m_providerList->currentItem() && m_providerList->count() > 0) {
+ m_providerList->setCurrentRow(0);
+ }
+ updateProviderButtons();
+
+ // The summary-agent dropdown mirrors the provider set, so refresh it too,
+ // keeping the on-screen (possibly unapplied) selection.
+ populateSummaryProviderCombo(m_summaryProviderCombo->currentData().toString());
+}
+
+void KateCodeConfigPage::populateSummaryProviderCombo(const QString &selectedId)
+{
+ const QSignalBlocker blocker(m_summaryProviderCombo);
+ m_summaryProviderCombo->clear();
+ m_summaryProviderCombo->addItem(i18n("Current agent"), SettingsStore::CURRENT_AGENT_PROVIDER_ID);
+ const auto providerList = m_settings->providers();
+ for (const auto &p : providerList) {
+ m_summaryProviderCombo->addItem(p.description, p.id);
+ }
+
+ const int index = m_summaryProviderCombo->findData(selectedId);
+ m_summaryProviderCombo->setCurrentIndex(index >= 0 ? index : 0);
+}
+
+void KateCodeConfigPage::apply()
+{
+ if (!m_hasChanges) {
+ return;
+ }
+
+ // Save settings
+ m_settings->setSummariesEnabled(m_enableSummariesCheck->isChecked());
+ m_settings->setSummaryProviderId(m_summaryProviderCombo->currentData().toString());
+ m_settings->setAutoResumeSessions(m_autoResumeCheck->isChecked());
+ m_settings->setSummariseOnResume(m_summariseOnResumeCheck->isChecked());
+ m_settings->setAcpLogEnabled(m_acpLogEnableCheck->isChecked());
+ m_settings->setAcpLogDirectory(m_acpLogDirEdit->text().trimmed());
+ m_settings->setAcpLogSubdirectory(m_acpLogSubdirEdit->text().trimmed());
+ m_settings->setDiffColorScheme(static_cast(m_diffColorSchemeCombo->currentData().toInt()));
+ m_settings->setDebugLogging(m_debugLoggingCheck->isChecked());
+
+ // Save allowed command patterns (split on newlines, trim, drop blanks)
+ const QStringList rawLines = m_allowedCommandsEdit->toPlainText().split(QLatin1Char('\n'));
+ QStringList patterns;
+ for (const QString &line : rawLines) {
+ const QString trimmed = line.trimmed();
+ if (!trimmed.isEmpty()) {
+ patterns.append(trimmed);
+ }
+ }
+ m_settings->setAllowedCommandPatterns(patterns);
+
+ m_hasChanges = false;
+}
+
+void KateCodeConfigPage::defaults()
+{
+ m_enableSummariesCheck->setChecked(false);
+ m_summaryProviderCombo->setCurrentIndex(0); // Current agent
+ m_autoResumeCheck->setChecked(true);
+ m_summariseOnResumeCheck->setChecked(false);
+ m_acpLogEnableCheck->setChecked(false);
+ m_acpLogDirEdit->setText(QDir::homePath() + QStringLiteral("/.kate-code"));
+ m_acpLogSubdirEdit->setText(QStringLiteral("kate_code_sessions"));
+ m_diffColorSchemeCombo->setCurrentIndex(0); // RedGreen (default)
+ m_debugLoggingCheck->setChecked(false);
+ m_allowedCommandsEdit->clear();
+ m_hasChanges = true;
+ Q_EMIT changed();
+}
+
+void KateCodeConfigPage::reset()
+{
+ // Load current settings
+ m_enableSummariesCheck->setChecked(m_settings->summariesEnabled());
+ populateSummaryProviderCombo(m_settings->summaryProviderId());
+
+ m_autoResumeCheck->setChecked(m_settings->autoResumeSessions());
+ m_summariseOnResumeCheck->setChecked(m_settings->summariseOnResume());
+
+ // Load ACP file-logging settings
+ m_acpLogEnableCheck->setChecked(m_settings->acpLogEnabled());
+ m_acpLogDirEdit->setText(m_settings->acpLogDirectory());
+ m_acpLogSubdirEdit->setText(m_settings->acpLogSubdirectory());
+
+ // Load diff color scheme
+ int schemeIndex = m_diffColorSchemeCombo->findData(static_cast(m_settings->diffColorScheme()));
+ if (schemeIndex >= 0) {
+ m_diffColorSchemeCombo->setCurrentIndex(schemeIndex);
+ }
+
+ // Load debug setting
+ m_debugLoggingCheck->setChecked(m_settings->debugLogging());
+
+ // Load allowed command patterns (join with newlines for display)
+ m_allowedCommandsEdit->setPlainText(m_settings->allowedCommandPatterns().join(QLatin1Char('\n')));
+
+ // Load provider list
+ populateProviderList();
+
+ m_hasChanges = false;
+}
+
+void KateCodeConfigPage::onAddProvider()
+{
+ ACPProvider provider;
+ provider.id = QStringLiteral("custom-%1").arg(QDateTime::currentMSecsSinceEpoch());
+ provider.builtin = false;
+ if (!editProviderDialog(provider, i18n("Add ACP Provider"))) {
+ return;
+ }
+
+ m_settings->addCustomProvider(provider);
+ populateProviderList();
+ for (int row = 0; row < m_providerList->count(); ++row) {
+ if (m_providerList->item(row)->data(Qt::UserRole).toString() == provider.id) {
+ m_providerList->setCurrentRow(row);
+ break;
+ }
+ }
+}
+
+void KateCodeConfigPage::onEditProvider()
+{
+ QListWidgetItem *item = m_providerList->currentItem();
+ if (!item) {
+ return;
+ }
+
+ ACPProvider provider = providerForItem(item);
+ if (provider.id.isEmpty()) {
+ return;
+ }
+
+ if (!editProviderDialog(provider, i18n("Edit ACP Provider"))) {
+ return;
+ }
+
+ m_settings->updateCustomProvider(provider.id, provider);
+ populateProviderList();
+}
+
+void KateCodeConfigPage::onRemoveProvider()
+{
+ QListWidgetItem *item = m_providerList->currentItem();
+ if (!item) {
+ return;
+ }
+
+ // Always keep at least one provider available.
+ if (m_providerList->count() <= 1) {
+ QMessageBox::warning(this, i18n("Cannot Remove Provider"),
+ i18n("At least one provider must remain."));
+ return;
+ }
+
+ QString providerId = item->data(Qt::UserRole).toString();
+ QString providerName = item->text();
+
+ int result = QMessageBox::question(this,
+ i18n("Remove Provider"),
+ i18n("Remove provider \"%1\"?", providerName),
+ QMessageBox::Yes | QMessageBox::No);
+
+ if (result != QMessageBox::Yes) {
+ return;
+ }
+
+ m_settings->removeCustomProvider(providerId);
+ populateProviderList();
+}
+
+void KateCodeConfigPage::onMoveProviderUp()
+{
+ const int row = m_providerList->currentRow();
+ if (row <= 0) {
+ return;
+ }
+ QListWidgetItem *item = m_providerList->takeItem(row);
+ m_providerList->insertItem(row - 1, item);
+ m_providerList->setCurrentRow(row - 1);
+ saveProviderOrder();
+}
+
+void KateCodeConfigPage::onMoveProviderDown()
+{
+ const int row = m_providerList->currentRow();
+ if (row < 0 || row >= m_providerList->count() - 1) {
+ return;
+ }
+ QListWidgetItem *item = m_providerList->takeItem(row);
+ m_providerList->insertItem(row + 1, item);
+ m_providerList->setCurrentRow(row + 1);
+ saveProviderOrder();
+}
+
+ACPProvider KateCodeConfigPage::providerForItem(const QListWidgetItem *item) const
+{
+ if (!item) {
+ return {};
+ }
+ const QString id = item->data(Qt::UserRole).toString();
+ for (const ACPProvider &provider : m_settings->providers()) {
+ if (provider.id == id) {
+ return provider;
+ }
+ }
+ return {};
+}
+
+void KateCodeConfigPage::updateProviderButtons()
+{
+ const int row = m_providerList->currentRow();
+ const bool hasSelection = row >= 0;
+ m_editProviderButton->setEnabled(hasSelection);
+ m_removeProviderButton->setEnabled(hasSelection && m_providerList->count() > 1);
+ m_moveProviderUpButton->setEnabled(hasSelection && row > 0);
+ m_moveProviderDownButton->setEnabled(hasSelection && row < m_providerList->count() - 1);
+}
+
+void KateCodeConfigPage::saveProviderOrder()
+{
+ QStringList ids;
+ ids.reserve(m_providerList->count());
+ for (int row = 0; row < m_providerList->count(); ++row) {
+ ids.append(m_providerList->item(row)->data(Qt::UserRole).toString());
+ }
+ m_settings->setProviderOrder(ids);
+}
+
+bool KateCodeConfigPage::editProviderDialog(ACPProvider &provider, const QString &title)
+{
+ QDialog dialog(this);
+ dialog.setWindowTitle(title);
+ dialog.resize(760, 560);
+ auto *dialogLayout = new QVBoxLayout(&dialog);
+ auto *formLayout = new QFormLayout();
+ dialogLayout->addLayout(formLayout);
+
+ auto *descEdit = new QLineEdit(provider.description, &dialog);
+ descEdit->setPlaceholderText(i18n("e.g. Codex GPT-5.6 Sol"));
+ formLayout->addRow(i18n("Description:"), descEdit);
+
+ auto *exeEdit = new QLineEdit(provider.executable, &dialog);
+ exeEdit->setPlaceholderText(i18n("e.g. codex-acp"));
+ formLayout->addRow(i18n("Executable:"), exeEdit);
+
+ auto *optEdit = new QLineEdit(provider.options, &dialog);
+ optEdit->setPlaceholderText(i18n("Arguments passed to the ACP executable (optional)"));
+ formLayout->addRow(i18n("CLI options:"), optEdit);
+
+ auto *mcpEdit = new QLineEdit(provider.mcpConfigPath, &dialog);
+ mcpEdit->setPlaceholderText(i18n("e.g. ~/.cursor/mcp.json (optional)"));
+ formLayout->addRow(i18n("MCP config JSON:"), mcpEdit);
+
+ auto *resumeCheck = new QCheckBox(i18n("Try real ACP session/load, fall back to context"), &dialog);
+ resumeCheck->setChecked(provider.trueResume);
+ formLayout->addRow(i18n("True resume:"), resumeCheck);
+
+ auto *configLabel = new QLabel(i18n("ACP session configuration"), &dialog);
+ QFont labelFont = configLabel->font();
+ labelFont.setBold(true);
+ configLabel->setFont(labelFont);
+ dialogLayout->addWidget(configLabel);
+
+ auto *configHelp = new QLabel(i18n("These key/value pairs are applied after session creation through ACP session/set_config_option when the agent advertises a matching option. Values may be plain text or JSON. For @agentclientprotocol/codex-acp use model (for example gpt-5.6-sol), reasoning_effort (for example xhigh), and mode (read-only, agent, or agent-full-access). The mode controls both approval and sandboxing; Codex config.toml keys such as approval_policy and sandbox_mode are not ACP session option ids."), &dialog);
+ configHelp->setWordWrap(true);
+ dialogLayout->addWidget(configHelp);
+
+ auto *configTable = new QTableWidget(0, 2, &dialog);
+ configTable->setHorizontalHeaderLabels({i18n("Configuration key"), i18n("Value (text or JSON)")});
+ configTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
+ configTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch);
+ configTable->verticalHeader()->setVisible(false);
+ configTable->setSelectionBehavior(QAbstractItemView::SelectRows);
+ configTable->setSelectionMode(QAbstractItemView::SingleSelection);
+ dialogLayout->addWidget(configTable, 1);
+
+ auto addConfigRow = [configTable](const QString &key = QString(), const QString &value = QString()) {
+ const int row = configTable->rowCount();
+ configTable->insertRow(row);
+ configTable->setItem(row, 0, new QTableWidgetItem(key));
+ configTable->setItem(row, 1, new QTableWidgetItem(value));
+ };
+ for (auto it = provider.sessionConfig.constBegin(); it != provider.sessionConfig.constEnd(); ++it) {
+ addConfigRow(it.key(), sessionConfigValueText(it.value()));
+ }
+
+ auto *configButtons = new QHBoxLayout();
+ auto *addConfigButton = new QPushButton(i18n("Add Parameter"), &dialog);
+ auto *removeConfigButton = new QPushButton(i18n("Remove Parameter"), &dialog);
+ removeConfigButton->setEnabled(false);
+ configButtons->addWidget(addConfigButton);
+ configButtons->addWidget(removeConfigButton);
+ configButtons->addStretch();
+ dialogLayout->addLayout(configButtons);
+
+ connect(addConfigButton, &QPushButton::clicked, &dialog, [configTable, addConfigRow]() {
+ addConfigRow();
+ configTable->setCurrentCell(configTable->rowCount() - 1, 0);
+ configTable->editItem(configTable->currentItem());
+ });
+ connect(removeConfigButton, &QPushButton::clicked, &dialog, [configTable]() {
+ if (configTable->currentRow() >= 0) {
+ configTable->removeRow(configTable->currentRow());
+ }
+ });
+ connect(configTable, &QTableWidget::currentCellChanged, &dialog,
+ [removeConfigButton](int row) { removeConfigButton->setEnabled(row >= 0); });
+
+ auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog);
+ connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
+ connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
+ dialogLayout->addWidget(buttons);
+
+ while (dialog.exec() == QDialog::Accepted) {
+ const QString description = descEdit->text().trimmed();
+ const QString executable = exeEdit->text().trimmed();
+ if (description.isEmpty() || executable.isEmpty()) {
+ QMessageBox::warning(&dialog, i18n("Invalid Provider"), i18n("Description and Executable are required."));
+ continue;
+ }
+
+ QJsonObject sessionConfig;
+ bool valid = true;
+ for (int row = 0; row < configTable->rowCount(); ++row) {
+ const QString key = configTable->item(row, 0) ? configTable->item(row, 0)->text().trimmed() : QString();
+ const QString value = configTable->item(row, 1) ? configTable->item(row, 1)->text() : QString();
+ if (key.isEmpty() && value.trimmed().isEmpty()) {
+ continue;
+ }
+ if (key.isEmpty()) {
+ QMessageBox::warning(&dialog, i18n("Invalid Session Configuration"),
+ i18n("Every ACP session configuration value requires a key."));
+ valid = false;
+ break;
+ }
+ if (sessionConfig.contains(key)) {
+ QMessageBox::warning(&dialog, i18n("Invalid Session Configuration"),
+ i18n("The ACP session configuration key \"%1\" is duplicated.", key));
+ valid = false;
+ break;
+ }
+ sessionConfig.insert(key, parseSessionConfigValue(value));
+ }
+ if (!valid) {
+ continue;
+ }
+
+ provider.description = description;
+ provider.executable = executable;
+ provider.options = optEdit->text().trimmed();
+ provider.mcpConfigPath = mcpEdit->text().trimmed();
+ provider.trueResume = resumeCheck->isChecked();
+ provider.sessionConfig = sessionConfig;
+ return true;
+ }
+ return false;
+}
+
+void KateCodeConfigPage::onSettingChanged()
+{
+ m_hasChanges = true;
+ Q_EMIT changed();
+}
diff --git a/src/config/KateCodeConfigPage.h b/src/config/KateCodeConfigPage.h
new file mode 100644
index 0000000..eb9ea4c
--- /dev/null
+++ b/src/config/KateCodeConfigPage.h
@@ -0,0 +1,92 @@
+#pragma once
+
+#include
+
+class SettingsStore;
+struct ACPProvider;
+class QLineEdit;
+class QCheckBox;
+class QComboBox;
+class QPushButton;
+class QLabel;
+class QPlainTextEdit;
+class QListWidget;
+class QListWidgetItem;
+class QTabWidget;
+class QTableWidget;
+
+class KateCodeConfigPage : public KTextEditor::ConfigPage
+{
+ Q_OBJECT
+
+public:
+ explicit KateCodeConfigPage(SettingsStore *settings, QWidget *parent = nullptr);
+ ~KateCodeConfigPage() override;
+
+ // KTextEditor::ConfigPage interface
+ QString name() const override;
+ QString fullName() const override;
+ QIcon icon() const override;
+
+public Q_SLOTS:
+ void apply() override;
+ void defaults() override;
+ void reset() override;
+
+private Q_SLOTS:
+ void onSettingChanged();
+
+ void onAddProvider();
+ void onEditProvider();
+ void onRemoveProvider();
+ void onMoveProviderUp();
+ void onMoveProviderDown();
+
+private:
+ void setupUi();
+ void setupGeneralTab(QWidget *tab);
+ void setupAdvancedTab(QWidget *tab);
+ void populateProviderList();
+ void populateSummaryProviderCombo(const QString &selectedId);
+ bool editProviderDialog(ACPProvider &provider, const QString &title);
+ ACPProvider providerForItem(const QListWidgetItem *item) const;
+ void updateProviderButtons();
+ void saveProviderOrder();
+
+ SettingsStore *m_settings;
+
+ // Tab widget
+ QTabWidget *m_tabWidget;
+
+ // General tab - ACP Providers section
+ QListWidget *m_providerList;
+ QPushButton *m_addProviderButton;
+ QPushButton *m_editProviderButton;
+ QPushButton *m_removeProviderButton;
+ QPushButton *m_moveProviderUpButton;
+ QPushButton *m_moveProviderDownButton;
+
+ // General tab - Diff colors section
+ QComboBox *m_diffColorSchemeCombo;
+
+ // Advanced tab - Summary options
+ QCheckBox *m_enableSummariesCheck;
+ QComboBox *m_summaryProviderCombo;
+
+ // Advanced tab - Session resume
+ QCheckBox *m_autoResumeCheck;
+ QCheckBox *m_summariseOnResumeCheck;
+
+ // Advanced tab - ACP file logging
+ QCheckBox *m_acpLogEnableCheck;
+ QLineEdit *m_acpLogDirEdit;
+ QLineEdit *m_acpLogSubdirEdit;
+
+ // General tab - Debug section
+ QCheckBox *m_debugLoggingCheck;
+
+ // Advanced tab - Command auto-approval section
+ QPlainTextEdit *m_allowedCommandsEdit;
+
+ bool m_hasChanges;
+};
diff --git a/src/config/SettingsStore.cpp b/src/config/SettingsStore.cpp
new file mode 100644
index 0000000..a6de016
--- /dev/null
+++ b/src/config/SettingsStore.cpp
@@ -0,0 +1,544 @@
+#include "SettingsStore.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+const QString SettingsStore::CURRENT_AGENT_PROVIDER_ID = QStringLiteral("__current__");
+
+SettingsStore::SettingsStore(QObject *parent)
+ : QObject(parent)
+ , m_settings(QStringLiteral("kate-code"), QStringLiteral("kate-code"))
+{
+ qDebug() << "[SettingsStore] Initialized, config file:" << m_settings.fileName();
+ migrateOldBackendSettings();
+ // The Anthropic-API summariser is gone; drop its stale model setting.
+ m_settings.remove(QStringLiteral("Summaries/model"));
+ seedDefaultProvidersIfNeeded();
+}
+
+SettingsStore::~SettingsStore() = default;
+
+bool SettingsStore::summariesEnabled() const
+{
+ return m_settings.value(QStringLiteral("Summaries/enabled"), false).toBool();
+}
+
+void SettingsStore::setSummariesEnabled(bool enable)
+{
+ m_settings.setValue(QStringLiteral("Summaries/enabled"), enable);
+ m_settings.sync();
+ Q_EMIT settingsChanged();
+}
+
+QString SettingsStore::summaryProviderId() const
+{
+ // Default to the current agent: it already holds the session context, so
+ // summaries work without any extra configuration.
+ return m_settings.value(QStringLiteral("Summaries/providerId"),
+ CURRENT_AGENT_PROVIDER_ID).toString();
+}
+
+void SettingsStore::setSummaryProviderId(const QString &id)
+{
+ m_settings.setValue(QStringLiteral("Summaries/providerId"), id);
+ m_settings.sync();
+ Q_EMIT settingsChanged();
+}
+
+bool SettingsStore::autoResumeSessions() const
+{
+ return m_settings.value(QStringLiteral("Sessions/autoResume"), true).toBool();
+}
+
+void SettingsStore::setAutoResumeSessions(bool enable)
+{
+ m_settings.setValue(QStringLiteral("Sessions/autoResume"), enable);
+ m_settings.sync();
+ Q_EMIT settingsChanged();
+}
+
+bool SettingsStore::summariseOnResume() const
+{
+ return m_settings.value(QStringLiteral("Sessions/summariseOnResume"), false).toBool();
+}
+
+void SettingsStore::setSummariseOnResume(bool enable)
+{
+ m_settings.setValue(QStringLiteral("Sessions/summariseOnResume"), enable);
+ m_settings.sync();
+ Q_EMIT settingsChanged();
+}
+
+bool SettingsStore::acpLogEnabled() const
+{
+ return m_settings.value(QStringLiteral("AcpLog/enabled"), false).toBool();
+}
+
+void SettingsStore::setAcpLogEnabled(bool enable)
+{
+ m_settings.setValue(QStringLiteral("AcpLog/enabled"), enable);
+ m_settings.sync();
+ Q_EMIT settingsChanged();
+}
+
+QString SettingsStore::acpLogDirectory() const
+{
+ const QString defaultDir = QDir::homePath() + QStringLiteral("/.kate-code");
+ return m_settings.value(QStringLiteral("AcpLog/directory"), defaultDir).toString();
+}
+
+void SettingsStore::setAcpLogDirectory(const QString &dir)
+{
+ m_settings.setValue(QStringLiteral("AcpLog/directory"), dir);
+ m_settings.sync();
+ Q_EMIT settingsChanged();
+}
+
+QString SettingsStore::acpLogSubdirectory() const
+{
+ return m_settings.value(QStringLiteral("AcpLog/subdirectory"),
+ QStringLiteral("kate_code_sessions")).toString();
+}
+
+void SettingsStore::setAcpLogSubdirectory(const QString &name)
+{
+ m_settings.setValue(QStringLiteral("AcpLog/subdirectory"), name);
+ m_settings.sync();
+ Q_EMIT settingsChanged();
+}
+
+// --- ACP Provider Management ---
+
+QList SettingsStore::defaultProviders() const
+{
+ // Seed providers offered on first run. They are fully editable and removable
+ // afterwards, so this list is only used to populate empty storage.
+ ACPProvider claude{QStringLiteral("claude-code"), QStringLiteral("Claude Code"), QStringLiteral("claude-code-acp"), QString(), QString(), {}, true, false};
+ ACPProvider vibe{QStringLiteral("vibe-mistral"), QStringLiteral("Vibe (Mistral)"), QStringLiteral("vibe-acp"), QString(), QString(), {}, true, false};
+ return {claude, vibe};
+}
+
+QList SettingsStore::storedProviders() const
+{
+ QList list;
+ int size = m_settings.beginReadArray(QStringLiteral("ACP/customProviders"));
+ for (int i = 0; i < size; ++i) {
+ m_settings.setArrayIndex(i);
+ ACPProvider p;
+ p.id = m_settings.value(QStringLiteral("id")).toString();
+ p.description = m_settings.value(QStringLiteral("description")).toString();
+ p.executable = m_settings.value(QStringLiteral("executable")).toString();
+ p.options = m_settings.value(QStringLiteral("options")).toString();
+ p.mcpConfigPath = m_settings.value(QStringLiteral("mcpConfigPath")).toString();
+ const QByteArray sessionConfigJson = m_settings.value(QStringLiteral("sessionConfig")).toString().toUtf8();
+ if (!sessionConfigJson.isEmpty()) {
+ QJsonParseError parseError;
+ const QJsonDocument document = QJsonDocument::fromJson(sessionConfigJson, &parseError);
+ if (parseError.error == QJsonParseError::NoError && document.isObject()) {
+ p.sessionConfig = document.object();
+ } else {
+ qWarning() << "[SettingsStore] Ignoring invalid ACP session config for" << p.id
+ << parseError.errorString();
+ }
+ }
+ p.trueResume = m_settings.value(QStringLiteral("trueResume"), false).toBool();
+ p.builtin = false;
+ if (!p.id.isEmpty() && !p.executable.isEmpty()) {
+ list.append(p);
+ }
+ }
+ m_settings.endArray();
+ return list;
+}
+
+void SettingsStore::writeProviders(const QList &list)
+{
+ m_settings.beginWriteArray(QStringLiteral("ACP/customProviders"), list.size());
+ for (int i = 0; i < list.size(); ++i) {
+ m_settings.setArrayIndex(i);
+ m_settings.setValue(QStringLiteral("id"), list[i].id);
+ m_settings.setValue(QStringLiteral("description"), list[i].description);
+ m_settings.setValue(QStringLiteral("executable"), list[i].executable);
+ m_settings.setValue(QStringLiteral("options"), list[i].options);
+ m_settings.setValue(QStringLiteral("mcpConfigPath"), list[i].mcpConfigPath);
+ m_settings.setValue(QStringLiteral("sessionConfig"),
+ QString::fromUtf8(QJsonDocument(list[i].sessionConfig).toJson(QJsonDocument::Compact)));
+ m_settings.setValue(QStringLiteral("trueResume"), list[i].trueResume);
+ }
+ m_settings.endArray();
+ m_settings.sync();
+ Q_EMIT settingsChanged();
+}
+
+void SettingsStore::seedDefaultProvidersIfNeeded()
+{
+ if (m_settings.value(QStringLiteral("ACP/seeded"), false).toBool()) {
+ return;
+ }
+
+ // Prepend the default providers ahead of any pre-existing custom entries,
+ // skipping ids that somehow already exist so upgrades stay idempotent.
+ QList existing = storedProviders();
+ QList merged;
+ for (const ACPProvider &seed : defaultProviders()) {
+ bool present = false;
+ for (const ACPProvider &e : existing) {
+ if (e.id == seed.id) { present = true; break; }
+ }
+ if (!present) {
+ merged.append(seed);
+ }
+ }
+ merged.append(existing);
+
+ writeProviders(merged);
+ m_settings.setValue(QStringLiteral("ACP/seeded"), true);
+ m_settings.sync();
+ qDebug() << "[SettingsStore] Seeded default providers, total:" << merged.size();
+}
+
+QList SettingsStore::providers() const
+{
+ QList all = storedProviders();
+ // Safety net: never present an empty list (e.g. corrupted storage).
+ if (all.isEmpty()) {
+ all = defaultProviders();
+ }
+ return all;
+}
+
+ACPProvider SettingsStore::activeProvider() const
+{
+ QString id = activeProviderId();
+ const auto all = providers();
+ for (const auto &p : all) {
+ if (p.id == id) {
+ return p;
+ }
+ }
+ // Fallback to first builtin
+ if (!all.isEmpty()) {
+ return all.first();
+ }
+ return {};
+}
+
+QString SettingsStore::activeProviderId() const
+{
+ return m_settings.value(QStringLiteral("ACP/activeProvider"), QStringLiteral("claude-code")).toString();
+}
+
+ACPProvider SettingsStore::providerById(const QString &id) const
+{
+ const auto all = providers();
+ for (const auto &p : all) {
+ if (p.id == id) {
+ return p;
+ }
+ }
+ return {};
+}
+
+void SettingsStore::setActiveProviderId(const QString &id)
+{
+ m_settings.setValue(QStringLiteral("ACP/activeProvider"), id);
+ m_settings.sync();
+ Q_EMIT settingsChanged();
+}
+
+// The mutators below start from providers() rather than storedProviders() so
+// they also work when the displayed list came from the defaults fallback
+// (empty or corrupted storage); writeProviders() then persists that list.
+
+void SettingsStore::addCustomProvider(const ACPProvider &provider)
+{
+ auto list = providers();
+ list.append(provider);
+ writeProviders(list);
+}
+
+void SettingsStore::updateCustomProvider(const QString &id, const ACPProvider &provider)
+{
+ auto list = providers();
+ bool found = false;
+ for (int i = 0; i < list.size(); ++i) {
+ if (list[i].id == id) {
+ list[i] = provider;
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ list.append(provider);
+ }
+ writeProviders(list);
+}
+
+void SettingsStore::removeCustomProvider(const QString &id)
+{
+ auto list = providers();
+ list.removeIf([&id](const ACPProvider &p) { return p.id == id; });
+ writeProviders(list);
+
+ // A stored summary provider that no longer exists would silently break
+ // summaries; fall back to the current agent.
+ if (summaryProviderId() == id) {
+ setSummaryProviderId(CURRENT_AGENT_PROVIDER_ID);
+ }
+}
+
+void SettingsStore::setProviderOrder(const QStringList &providerIds)
+{
+ const QList current = providers();
+ QList reordered;
+ reordered.reserve(current.size());
+
+ for (const QString &id : providerIds) {
+ for (const ACPProvider &provider : current) {
+ if (provider.id == id && std::none_of(reordered.cbegin(), reordered.cend(), [&id](const ACPProvider &entry) {
+ return entry.id == id;
+ })) {
+ reordered.append(provider);
+ break;
+ }
+ }
+ }
+
+ for (const ACPProvider &provider : current) {
+ const bool alreadyAdded = std::any_of(reordered.cbegin(), reordered.cend(), [&provider](const ACPProvider &entry) {
+ return entry.id == provider.id;
+ });
+ if (!alreadyAdded) {
+ reordered.append(provider);
+ }
+ }
+
+ writeProviders(reordered);
+}
+
+bool SettingsStore::isExecutableAvailable(const QString &executable)
+{
+ if (executable.isEmpty()) {
+ return false;
+ }
+
+ // Absolute path: just check existence
+ if (QFileInfo(executable).isAbsolute()) {
+ return QFileInfo::exists(executable);
+ }
+
+ // Search PATH
+ if (!QStandardPaths::findExecutable(executable).isEmpty()) {
+ return true;
+ }
+
+ // Fallback: common user-local directories
+ const QString home = QDir::homePath();
+ const QStringList fallbackDirs = {
+ home + QStringLiteral("/.local/bin"),
+ home + QStringLiteral("/bin"),
+ home + QStringLiteral("/.cargo/bin"),
+ };
+ for (const QString &dir : fallbackDirs) {
+ if (QFileInfo::exists(dir + QLatin1Char('/') + executable)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+void SettingsStore::migrateOldBackendSettings()
+{
+ // One-time migration from old ACPBackend enum settings
+ if (!m_settings.contains(QStringLiteral("ACP/backend"))) {
+ return; // Nothing to migrate
+ }
+
+ int oldBackend = m_settings.value(QStringLiteral("ACP/backend"), 0).toInt();
+ QString oldCustomExe = m_settings.value(QStringLiteral("ACP/customExecutable")).toString();
+
+ // Map old enum to new provider id
+ QString newActiveId;
+ switch (oldBackend) {
+ case 1: // VibeACP
+ newActiveId = QStringLiteral("vibe-mistral");
+ break;
+ case 2: // Custom
+ if (!oldCustomExe.isEmpty()) {
+ // Create a custom provider entry
+ ACPProvider custom;
+ custom.id = QStringLiteral("custom-migrated");
+ custom.description = QStringLiteral("Custom (migrated)");
+ custom.executable = oldCustomExe;
+ custom.builtin = false;
+ addCustomProvider(custom);
+ newActiveId = custom.id;
+ } else {
+ newActiveId = QStringLiteral("claude-code");
+ }
+ break;
+ case 0: // ClaudeCodeACP
+ default:
+ newActiveId = QStringLiteral("claude-code");
+ break;
+ }
+
+ m_settings.setValue(QStringLiteral("ACP/activeProvider"), newActiveId);
+
+ // Remove old keys
+ m_settings.remove(QStringLiteral("ACP/backend"));
+ m_settings.remove(QStringLiteral("ACP/customExecutable"));
+ m_settings.sync();
+ qDebug() << "[SettingsStore] Migrated old ACP backend settings to provider:" << newActiveId;
+}
+
+bool SettingsStore::debugLogging() const
+{
+ return m_settings.value(QStringLiteral("Debug/logging"), false).toBool();
+}
+
+void SettingsStore::setDebugLogging(bool enable)
+{
+ m_settings.setValue(QStringLiteral("Debug/logging"), enable);
+ m_settings.sync();
+ Q_EMIT settingsChanged();
+}
+
+DiffColorScheme SettingsStore::diffColorScheme() const
+{
+ int scheme = m_settings.value(QStringLiteral("Diffs/colorScheme"), 0).toInt();
+ qDebug() << "[SettingsStore] diffColorScheme() returning:" << scheme;
+ return static_cast(scheme);
+}
+
+void SettingsStore::setDiffColorScheme(DiffColorScheme scheme)
+{
+ m_settings.setValue(QStringLiteral("Diffs/colorScheme"), static_cast(scheme));
+ m_settings.sync();
+ Q_EMIT settingsChanged();
+}
+
+DiffColors SettingsStore::diffColors() const
+{
+ return colorsForScheme(diffColorScheme());
+}
+
+DiffColors SettingsStore::colorsForScheme(DiffColorScheme scheme, bool forLightBackground)
+{
+ DiffColors colors;
+
+ // Colors are optimized for contrast against their target background:
+ // - Dark backgrounds: muted, darker colors that don't overwhelm
+ // - Light backgrounds: brighter, more saturated colors for visibility
+
+ if (forLightBackground) {
+ // Light background colors - brighter and more saturated for contrast
+ switch (scheme) {
+ case DiffColorScheme::BlueOrange:
+ // Colorblind-friendly: blue for deletions, orange for additions
+ colors.deletionBackground = QColor(200, 210, 240); // Light blue
+ colors.deletionForeground = QColor(30, 60, 150); // Dark blue text
+ colors.additionBackground = QColor(255, 230, 200); // Light orange
+ colors.additionForeground = QColor(150, 70, 0); // Dark orange text
+ break;
+
+ case DiffColorScheme::PurpleGreen:
+ // Alternative colorblind-friendly: purple for deletions
+ colors.deletionBackground = QColor(230, 210, 245); // Light purple
+ colors.deletionForeground = QColor(100, 40, 140); // Dark purple text
+ colors.additionBackground = QColor(210, 245, 210); // Light green
+ colors.additionForeground = QColor(30, 100, 30); // Dark green text
+ break;
+
+ case DiffColorScheme::RedGreen:
+ default:
+ // Traditional: red for deletions, green for additions
+ colors.deletionBackground = QColor(255, 220, 220); // Light red/pink
+ colors.deletionForeground = QColor(150, 30, 30); // Dark red text
+ colors.additionBackground = QColor(210, 255, 220); // Light green
+ colors.additionForeground = QColor(30, 100, 30); // Dark green text
+ break;
+ }
+ } else {
+ // Dark background colors - muted and darker
+ switch (scheme) {
+ case DiffColorScheme::BlueOrange:
+ // Colorblind-friendly: blue for deletions, orange for additions
+ colors.deletionBackground = QColor(50, 53, 77); // Dark muted blue
+ colors.deletionForeground = QColor(50, 80, 180); // Dark blue
+ colors.additionBackground = QColor(77, 58, 40); // Dark muted orange
+ colors.additionForeground = QColor(180, 100, 40); // Dark orange
+ break;
+
+ case DiffColorScheme::PurpleGreen:
+ // Alternative colorblind-friendly: purple for deletions
+ colors.deletionBackground = QColor(58, 40, 77); // Dark muted purple
+ colors.deletionForeground = QColor(120, 60, 160); // Dark purple
+ colors.additionBackground = QColor(40, 77, 40); // Dark muted green
+ colors.additionForeground = QColor(40, 140, 40); // Dark green
+ break;
+
+ case DiffColorScheme::RedGreen:
+ default:
+ // Traditional: red for deletions, green for additions
+ colors.deletionBackground = QColor(122, 67, 71); // Dark muted red (#7a4347)
+ colors.deletionForeground = QColor(180, 60, 60); // Dark red
+ colors.additionBackground = QColor(39, 88, 80); // Dark muted teal (#275850)
+ colors.additionForeground = QColor(60, 140, 60); // Dark green
+ break;
+ }
+ }
+
+ return colors;
+}
+
+QString SettingsStore::schemeDisplayName(DiffColorScheme scheme)
+{
+ switch (scheme) {
+ case DiffColorScheme::BlueOrange:
+ return QStringLiteral("Blue / Orange (colorblind-friendly)");
+ case DiffColorScheme::PurpleGreen:
+ return QStringLiteral("Purple / Green (colorblind-friendly)");
+ case DiffColorScheme::RedGreen:
+ default:
+ return QStringLiteral("Red / Green (default)");
+ }
+}
+
+QStringList SettingsStore::allowedCommandPatterns() const
+{
+ return m_settings.value(QStringLiteral("Permissions/allowedCommands"), QStringList()).toStringList();
+}
+
+void SettingsStore::setAllowedCommandPatterns(const QStringList &patterns)
+{
+ m_settings.setValue(QStringLiteral("Permissions/allowedCommands"), patterns);
+ m_settings.sync();
+ Q_EMIT settingsChanged();
+}
+
+bool SettingsStore::isCommandAllowed(const QString &command) const
+{
+ const QString trimmed = command.trimmed();
+ const QStringList patterns = allowedCommandPatterns();
+
+ for (const QString &pattern : patterns) {
+ const QString trimmedPattern = pattern.trimmed();
+ if (trimmedPattern.isEmpty()) {
+ continue; // Ignore blank lines from the editor
+ }
+ // wildcardToRegularExpression() anchors the pattern for a full match.
+ const QRegularExpression re(QRegularExpression::wildcardToRegularExpression(trimmedPattern),
+ QRegularExpression::NoPatternOption); // case-sensitive
+ if (re.isValid() && re.match(trimmed).hasMatch()) {
+ return true;
+ }
+ }
+ return false; // Empty list means nothing is auto-approved
+}
diff --git a/src/config/SettingsStore.h b/src/config/SettingsStore.h
new file mode 100644
index 0000000..e24c7a7
--- /dev/null
+++ b/src/config/SettingsStore.h
@@ -0,0 +1,121 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// ACP provider definition
+struct ACPProvider {
+ QString id; // Stable identifier (e.g. "claude-code", "vibe-mistral", "custom-1")
+ QString description; // Display name
+ QString executable; // Binary name or path
+ QString options; // Command-line arguments string
+ QString mcpConfigPath; // Path to MCP server config JSON file (e.g. ~/.cursor/mcp.json)
+ QJsonObject sessionConfig; // ACP session config option id -> desired JSON value
+ bool builtin = false; // true for the default-seeded providers (kept for reference only)
+ bool trueResume = false; // try ACP session/load (option B) before falling back to context injection (option A)
+};
+
+// Color schemes for diff highlighting (colorblind-friendly options)
+enum class DiffColorScheme {
+ RedGreen, // Traditional: red for deletions (default)
+ BlueOrange, // Colorblind-friendly: blue for deletions, orange for additions
+ PurpleGreen, // Alternative colorblind-friendly
+};
+
+// Color pair for diff highlighting
+struct DiffColors {
+ QColor deletionBackground;
+ QColor deletionForeground;
+ QColor additionBackground;
+ QColor additionForeground;
+};
+
+class SettingsStore : public QObject
+{
+ Q_OBJECT
+
+public:
+ explicit SettingsStore(QObject *parent = nullptr);
+ ~SettingsStore() override;
+
+ // Summary settings (stored in QSettings)
+ bool summariesEnabled() const;
+ void setSummariesEnabled(bool enable);
+
+ // ACP provider used to generate summaries. The sentinel
+ // CURRENT_AGENT_PROVIDER_ID means "ask the live session's agent".
+ QString summaryProviderId() const;
+ void setSummaryProviderId(const QString &id);
+
+ // Session settings
+ bool autoResumeSessions() const;
+ void setAutoResumeSessions(bool enable);
+
+ // Summarise an abandoned (raw) session with the summary model when resuming
+ bool summariseOnResume() const;
+ void setSummariseOnResume(bool enable);
+
+ // ACP session logging to file (separate from on-screen output)
+ bool acpLogEnabled() const;
+ void setAcpLogEnabled(bool enable);
+ QString acpLogDirectory() const; // base directory to place the log folder in
+ void setAcpLogDirectory(const QString &dir);
+ QString acpLogSubdirectory() const; // name of the folder created inside the base directory
+ void setAcpLogSubdirectory(const QString &name);
+
+ // ACP provider management
+ QList providers() const;
+ ACPProvider activeProvider() const;
+ QString activeProviderId() const;
+ void setActiveProviderId(const QString &id);
+ // Returns the provider with the given id, or a default-constructed
+ // (empty-executable) provider when the id is unknown.
+ ACPProvider providerById(const QString &id) const;
+
+ // Sentinel summary-provider id meaning "use the currently connected agent".
+ static const QString CURRENT_AGENT_PROVIDER_ID;
+
+ void addCustomProvider(const ACPProvider &provider);
+ void updateCustomProvider(const QString &id, const ACPProvider &provider);
+ void removeCustomProvider(const QString &id);
+ void setProviderOrder(const QStringList &providerIds);
+
+ // Check if an executable can be found on PATH or common directories
+ static bool isExecutableAvailable(const QString &executable);
+
+ // Debug settings
+ bool debugLogging() const;
+ void setDebugLogging(bool enable);
+
+ // Diff color scheme settings
+ DiffColorScheme diffColorScheme() const;
+ void setDiffColorScheme(DiffColorScheme scheme);
+ DiffColors diffColors() const;
+
+ // Global glob patterns for auto-approving matching agent commands
+ QStringList allowedCommandPatterns() const;
+ void setAllowedCommandPatterns(const QStringList &patterns);
+ bool isCommandAllowed(const QString &command) const;
+
+ // Static helper to get colors for a scheme
+ // forLightBackground: true if the code block background is light (needs brighter diff colors)
+ static DiffColors colorsForScheme(DiffColorScheme scheme, bool forLightBackground = false);
+ static QString schemeDisplayName(DiffColorScheme scheme);
+
+Q_SIGNALS:
+ void settingsChanged();
+
+private:
+ void migrateOldBackendSettings();
+ void seedDefaultProvidersIfNeeded();
+ QList defaultProviders() const;
+ QList storedProviders() const;
+ void writeProviders(const QList &list);
+
+ mutable QSettings m_settings;
+};
diff --git a/src/katecode.json b/src/katecode.json
index 9790b4f..0d9b431 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.5.1",
"Website": "https://github.com/anthropics/claude-code"
}
}
diff --git a/src/katecode.qrc b/src/katecode.qrc
index 44b6b3f..6ba8f27 100644
--- a/src/katecode.qrc
+++ b/src/katecode.qrc
@@ -1,8 +1,15 @@
+ katecodeui.rc
web/chat.html
web/chat.css
web/chat.js
+ web/terminal-renderer.js
+ web/vendor/marked.min.js
+ web/vendor/highlight.min.js
+ web/vendor/atom-one-dark.min.css
+ web/vendor/atom-one-light.min.css
+ web/vendor/shades-of-purple.min.css
diff --git a/src/katecodeui.rc b/src/katecodeui.rc
new file mode 100644
index 0000000..c889511
--- /dev/null
+++ b/src/katecodeui.rc
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/src/mcp/EditorDBusService.cpp b/src/mcp/EditorDBusService.cpp
new file mode 100644
index 0000000..8c27e77
--- /dev/null
+++ b/src/mcp/EditorDBusService.cpp
@@ -0,0 +1,295 @@
+/*
+ SPDX-License-Identifier: MIT
+ SPDX-FileCopyrightText: 2025 Kate Code contributors
+*/
+
+#include "EditorDBusService.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+EditorDBusService::EditorDBusService(QObject *parent)
+ : QObject(parent)
+{
+}
+
+bool EditorDBusService::registerOnBus()
+{
+ QDBusConnection bus = QDBusConnection::sessionBus();
+
+ // Primary name is unique per process so multiple Kate instances can coexist.
+ // The "p" prefix on the pid is required: a D-Bus name element must not
+ // begin with a digit, so a bare pid would be an invalid bus name.
+ // ACPSession::buildMcpServers() must build the SAME name for the child.
+ const QString primaryName = QStringLiteral("org.kde.katecode.editor.p")
+ + QString::number(QCoreApplication::applicationPid());
+ if (!bus.registerService(primaryName)) {
+ qWarning() << "[KateCode] Failed to register DBus service:" << bus.lastError().message();
+ return false;
+ }
+
+ // Best-effort: also claim the bare legacy name so the first process to start
+ // is reachable by older tooling. A second process silently skips this.
+ if (!bus.registerService(QStringLiteral("org.kde.katecode.editor"))) {
+ qDebug() << "[KateCode] Legacy DBus service name already claimed by another process (harmless)";
+ }
+
+ if (!bus.registerObject(QStringLiteral("/KateCode/Editor"), this, QDBusConnection::ExportAllSlots)) {
+ qWarning() << "[KateCode] Failed to register DBus object:" << bus.lastError().message();
+ return false;
+ }
+
+ qDebug() << "[KateCode] DBus service registered:" << primaryName;
+ return true;
+}
+
+QStringList EditorDBusService::listDocuments()
+{
+ QStringList result;
+
+ KTextEditor::Application *app = KTextEditor::Editor::instance()->application();
+ if (!app) {
+ return result;
+ }
+
+ const QList docs = app->documents();
+ for (KTextEditor::Document *doc : docs) {
+ const QString path = doc->url().toLocalFile();
+ if (!path.isEmpty()) {
+ result.append(path);
+ } else {
+ // Untitled documents — use document name
+ result.append(QStringLiteral("untitled:%1").arg(doc->documentName()));
+ }
+ }
+
+ return result;
+}
+
+QString EditorDBusService::readDocument(const QString &filePath)
+{
+ KTextEditor::Application *app = KTextEditor::Editor::instance()->application();
+ if (!app) {
+ return QStringLiteral("ERROR: KTextEditor application not available");
+ }
+
+ // Find existing document by URL
+ QUrl url = QUrl::fromLocalFile(filePath);
+ KTextEditor::Document *doc = app->findUrl(url);
+
+ if (doc) {
+ // Document is open — return its content
+ return doc->text();
+ }
+
+ // Document not open — read from disk
+ QFile file(filePath);
+ if (!file.exists()) {
+ return QStringLiteral("ERROR: File not found: %1").arg(filePath);
+ }
+ if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ return QStringLiteral("ERROR: Cannot open file: %1").arg(file.errorString());
+ }
+ return QString::fromUtf8(file.readAll());
+}
+
+QString EditorDBusService::editDocument(const QString &filePath, const QString &oldText, const QString &newText)
+{
+ 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) {
+ // Try to open the document
+ KTextEditor::MainWindow *mainWindow = app->activeMainWindow();
+ if (!mainWindow) {
+ return QStringLiteral("ERROR: No active main window");
+ }
+ KTextEditor::View *view = mainWindow->openUrl(url);
+ if (!view) {
+ return QStringLiteral("ERROR: Could not open document: %1").arg(filePath);
+ }
+ doc = view->document();
+ }
+
+ // Find and replace the text
+ QString content = doc->text();
+ int pos = content.indexOf(oldText);
+ if (pos < 0) {
+ return QStringLiteral("ERROR: old_text not found in document");
+ }
+
+ // Check for uniqueness
+ int secondPos = content.indexOf(oldText, pos + 1);
+ if (secondPos >= 0) {
+ return QStringLiteral("ERROR: old_text is not unique in document (found at multiple positions)");
+ }
+
+ // Calculate line/column for the replacement
+ int startLine = content.left(pos).count(QLatin1Char('\n'));
+ int startCol = pos - content.lastIndexOf(QLatin1Char('\n'), pos) - 1;
+ if (startCol < 0) {
+ startCol = pos;
+ }
+
+ int endPos = pos + oldText.length();
+ int endLine = content.left(endPos).count(QLatin1Char('\n'));
+ int endCol = endPos - content.lastIndexOf(QLatin1Char('\n'), endPos - 1) - 1;
+ if (endCol < 0) {
+ endCol = endPos;
+ }
+
+ // Perform the replacement
+ KTextEditor::Range range(startLine, startCol, endLine, endCol);
+ bool success = doc->replaceText(range, newText);
+
+ if (!success) {
+ return QStringLiteral("ERROR: Failed to replace text");
+ }
+
+ // Auto-save the document
+ if (!doc->save()) {
+ return QStringLiteral("ERROR: Edit succeeded but failed to save document");
+ }
+
+ return QStringLiteral("OK");
+}
+
+QString EditorDBusService::writeDocument(const QString &filePath, const QString &content)
+{
+ 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) {
+ // Document is open — replace its content and save
+ doc->setText(content);
+ if (!doc->save()) {
+ return QStringLiteral("ERROR: Write succeeded but failed to save document");
+ }
+ return QStringLiteral("OK");
+ }
+
+ // Document not open — create new or open and set content
+ KTextEditor::MainWindow *mainWindow = app->activeMainWindow();
+ if (!mainWindow) {
+ return QStringLiteral("ERROR: No active main window");
+ }
+
+ // Check if file exists
+ QFile file(filePath);
+ bool fileExists = file.exists();
+
+ if (fileExists) {
+ // Open existing file
+ KTextEditor::View *view = mainWindow->openUrl(url);
+ if (!view) {
+ return QStringLiteral("ERROR: Could not open document: %1").arg(filePath);
+ }
+ view->document()->setText(content);
+ if (!view->document()->save()) {
+ return QStringLiteral("ERROR: Write succeeded but failed to save document");
+ }
+ } else {
+ // Create new document with content, then save to path
+ KTextEditor::View *view = mainWindow->openUrl(QUrl());
+ if (!view) {
+ return QStringLiteral("ERROR: Could not create new document");
+ }
+ view->document()->setText(content);
+ if (!view->document()->saveAs(url)) {
+ return QStringLiteral("ERROR: Could not save document to: %1").arg(filePath);
+ }
+ }
+
+ return QStringLiteral("OK");
+}
+
+QString EditorDBusService::askUserQuestion(const QString &questionsJson)
+{
+ // Generate unique request ID
+ QString requestId = QStringLiteral("q_%1_%2")
+ .arg(QCoreApplication::applicationPid())
+ .arg(m_nextQuestionId++);
+
+ qDebug() << "[EditorDBusService] askUserQuestion called, requestId:" << requestId;
+
+ // Create event loop for blocking
+ QEventLoop eventLoop;
+
+ m_pendingQuestions[requestId] = PendingQuestion{
+ &eventLoop,
+ QString(),
+ false
+ };
+
+ // Emit signal to Kate plugin UI
+ Q_EMIT questionRequested(requestId, questionsJson);
+
+ // Set up timeout (5 minutes)
+ QTimer timeoutTimer;
+ timeoutTimer.setSingleShot(true);
+ connect(&timeoutTimer, &QTimer::timeout, &eventLoop, &QEventLoop::quit);
+ timeoutTimer.start(300000);
+
+ qDebug() << "[EditorDBusService] Blocking on event loop for user response...";
+
+ // Block until response or timeout
+ eventLoop.exec();
+
+ timeoutTimer.stop();
+
+ // Retrieve response
+ QString response;
+ if (m_pendingQuestions.contains(requestId)) {
+ PendingQuestion pending = m_pendingQuestions.take(requestId);
+ if (pending.completed) {
+ response = pending.response;
+ qDebug() << "[EditorDBusService] Got user response:" << response;
+ } else {
+ response = QStringLiteral("ERROR: Question timeout or cancelled");
+ qDebug() << "[EditorDBusService] Question timed out or was cancelled";
+ // Notify UI to remove the question prompt
+ Q_EMIT questionCancelled(requestId);
+ }
+ } else {
+ response = QStringLiteral("ERROR: Request not found");
+ }
+
+ return response;
+}
+
+void EditorDBusService::provideQuestionResponse(const QString &requestId, const QString &responseJson)
+{
+ qDebug() << "[EditorDBusService] provideQuestionResponse called, requestId:" << requestId;
+
+ if (m_pendingQuestions.contains(requestId)) {
+ m_pendingQuestions[requestId].response = responseJson;
+ m_pendingQuestions[requestId].completed = true;
+
+ // Wake up the waiting event loop
+ if (m_pendingQuestions[requestId].eventLoop) {
+ m_pendingQuestions[requestId].eventLoop->quit();
+ }
+ } else {
+ qWarning() << "[EditorDBusService] No pending question found for requestId:" << requestId;
+ }
+}
diff --git a/src/mcp/EditorDBusService.h b/src/mcp/EditorDBusService.h
new file mode 100644
index 0000000..eea5a96
--- /dev/null
+++ b/src/mcp/EditorDBusService.h
@@ -0,0 +1,64 @@
+/*
+ SPDX-License-Identifier: MIT
+ SPDX-FileCopyrightText: 2025 Kate Code contributors
+*/
+
+#pragma once
+
+#include
+#include
+#include
+#include
+
+class EditorDBusService : public QObject
+{
+ Q_OBJECT
+ Q_CLASSINFO("D-Bus Interface", "org.kde.katecode.Editor")
+
+public:
+ explicit EditorDBusService(QObject *parent = nullptr);
+
+ // Register this service on the session bus.
+ // Returns true on success.
+ bool registerOnBus();
+
+ // Called by UI when user responds to a question
+ void provideQuestionResponse(const QString &requestId, const QString &responseJson);
+
+public Q_SLOTS:
+ QStringList listDocuments();
+
+ // Read a document's content. Returns the text content.
+ // If the document is not open, returns an error string starting with "ERROR:".
+ QString readDocument(const QString &filePath);
+
+ // Edit a document by replacing old_text with new_text.
+ // Returns "OK" on success or "ERROR: ..." on failure.
+ QString editDocument(const QString &filePath, const QString &oldText, const QString &newText);
+
+ // Write content to a document (creates or overwrites).
+ // Returns "OK" on success or "ERROR: ..." on failure.
+ QString writeDocument(const QString &filePath, const QString &content);
+
+ // Ask the user questions - blocks until user responds or timeout.
+ // questionsJson is a JSON array of question objects.
+ // Returns JSON object with answers keyed by question header, or "ERROR: ..." on failure.
+ QString askUserQuestion(const QString &questionsJson);
+
+Q_SIGNALS:
+ // 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);
+
+private:
+ // Track pending question requests
+ struct PendingQuestion {
+ QEventLoop *eventLoop;
+ QString response;
+ bool completed;
+ };
+ QHash m_pendingQuestions;
+ int m_nextQuestionId = 0;
+};
diff --git a/src/mcp/MCPServer.cpp b/src/mcp/MCPServer.cpp
new file mode 100644
index 0000000..59b15d4
--- /dev/null
+++ b/src/mcp/MCPServer.cpp
@@ -0,0 +1,772 @@
+/*
+ SPDX-License-Identifier: MIT
+ SPDX-FileCopyrightText: 2025 Kate Code contributors
+*/
+
+#include "MCPServer.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// Returns the DBus service name of the editor to talk to. When launched by
+// ACPSession the env var is set to the pid-specific name of the parent Kate
+// process. The legacy bare name is the fallback for single-process scenarios.
+static QString editorServiceName()
+{
+ return qEnvironmentVariable("KATECODE_DBUS_SERVICE",
+ QStringLiteral("org.kde.katecode.editor"));
+}
+
+// A valid D-Bus well-known bus name: at least two dot-separated elements,
+// each element matching [A-Za-z_-][A-Za-z0-9_-]* (so it cannot start with a
+// digit). This matters because constructing a QDBusInterface with an invalid
+// destination makes libdbus abort() the entire process; we check first and
+// return a normal MCP error instead.
+static bool isValidBusName(const QString &name)
+{
+ if (name.isEmpty() || name.size() > 255) {
+ return false;
+ }
+ const QStringList elements = name.split(QLatin1Char('.'));
+ if (elements.size() < 2) {
+ return false;
+ }
+ static const QRegularExpression element(QStringLiteral("\\A[A-Za-z_-][A-Za-z0-9_-]*\\z"));
+ for (const QString &e : elements) {
+ if (!element.match(e).hasMatch()) {
+ return false;
+ }
+ }
+ return true;
+}
+
+MCPServer::MCPServer() = default;
+
+QJsonObject MCPServer::handleMessage(const QJsonObject &msg)
+{
+ const QString method = msg[QStringLiteral("method")].toString();
+ const int id = msg[QStringLiteral("id")].toInt(-1);
+ const QJsonObject params = msg[QStringLiteral("params")].toObject();
+
+ // Notifications have no id — no response needed
+ if (id < 0) {
+ return {};
+ }
+
+ if (method == QStringLiteral("initialize")) {
+ return handleInitialize(id, params);
+ } else if (method == QStringLiteral("tools/list")) {
+ return handleToolsList(id, params);
+ } else if (method == QStringLiteral("tools/call")) {
+ return handleToolsCall(id, params);
+ } else if (method == QStringLiteral("ping")) {
+ // MCP health check: must answer with an empty result, not an error —
+ // clients may treat a failed ping as a dead connection and close it.
+ return makeResponse(id, QJsonObject());
+ }
+
+ return makeErrorResponse(id, -32601, QStringLiteral("Method not found: %1").arg(method));
+}
+
+QJsonObject MCPServer::handleInitialize(int id, const QJsonObject ¶ms)
+{
+ Q_UNUSED(params);
+ m_initialized = true;
+
+ QJsonObject serverInfo;
+ serverInfo[QStringLiteral("name")] = QStringLiteral("kate-mcp-server");
+ serverInfo[QStringLiteral("version")] = QStringLiteral("0.1.0");
+
+ QJsonObject capabilities;
+ capabilities[QStringLiteral("tools")] = QJsonObject();
+
+ QJsonObject result;
+ result[QStringLiteral("protocolVersion")] = QStringLiteral("2024-11-05");
+ result[QStringLiteral("serverInfo")] = serverInfo;
+ result[QStringLiteral("capabilities")] = capabilities;
+
+ return makeResponse(id, result);
+}
+
+QJsonObject MCPServer::handleToolsList(int id, const QJsonObject ¶ms)
+{
+ Q_UNUSED(params);
+
+ // katecode_documents tool definition
+ QJsonObject docsTool;
+ docsTool[QStringLiteral("name")] = QStringLiteral("katecode_documents");
+ docsTool[QStringLiteral("description")] =
+ QStringLiteral("Returns a list of all documents currently open in the Kate editor.");
+
+ QJsonObject docsSchema;
+ docsSchema[QStringLiteral("type")] = QStringLiteral("object");
+ docsSchema[QStringLiteral("properties")] = QJsonObject();
+ docsTool[QStringLiteral("inputSchema")] = docsSchema;
+
+ // Read-only annotation for documents tool
+ QJsonObject docsAnnotations;
+ docsAnnotations[QStringLiteral("readOnlyHint")] = true;
+ docsAnnotations[QStringLiteral("destructiveHint")] = false;
+ docsTool[QStringLiteral("annotations")] = docsAnnotations;
+
+ // katecode_read tool definition
+ QJsonObject readTool;
+ readTool[QStringLiteral("name")] = QStringLiteral("katecode_read");
+ readTool[QStringLiteral("description")] =
+ QStringLiteral("Reads the content of a file. If the file is open in Kate, returns the current buffer content (which may have unsaved changes). Otherwise reads from disk. By default returns up to 2000 lines; use offset and limit for larger files.\n\nIn sessions with mcp__kate__katecode_read always use it instead of Read or mcp__acp__Read, as it contains the most up-to-date contents provided by the editor.");
+
+ QJsonObject readPathProp;
+ readPathProp[QStringLiteral("type")] = QStringLiteral("string");
+ readPathProp[QStringLiteral("description")] = QStringLiteral("The absolute path to the file to read");
+
+ QJsonObject readOffsetProp;
+ readOffsetProp[QStringLiteral("type")] = QStringLiteral("integer");
+ readOffsetProp[QStringLiteral("description")] = QStringLiteral("Line number to start reading from (1-based). Default: 1");
+
+ QJsonObject readLimitProp;
+ readLimitProp[QStringLiteral("type")] = QStringLiteral("integer");
+ readLimitProp[QStringLiteral("description")] = QStringLiteral("Maximum number of lines to return. Default: 2000");
+
+ QJsonObject readProps;
+ readProps[QStringLiteral("file_path")] = readPathProp;
+ readProps[QStringLiteral("offset")] = readOffsetProp;
+ readProps[QStringLiteral("limit")] = readLimitProp;
+
+ QJsonObject readSchema;
+ readSchema[QStringLiteral("type")] = QStringLiteral("object");
+ readSchema[QStringLiteral("properties")] = readProps;
+ readSchema[QStringLiteral("required")] = QJsonArray{QStringLiteral("file_path")};
+ readTool[QStringLiteral("inputSchema")] = readSchema;
+
+ // Read-only annotation for read tool
+ QJsonObject readAnnotations;
+ readAnnotations[QStringLiteral("readOnlyHint")] = true;
+ readAnnotations[QStringLiteral("destructiveHint")] = false;
+ readTool[QStringLiteral("annotations")] = readAnnotations;
+
+ // katecode_edit tool definition
+ QJsonObject editTool;
+ editTool[QStringLiteral("name")] = QStringLiteral("katecode_edit");
+ editTool[QStringLiteral("description")] =
+ QStringLiteral("Edits a file by replacing old_string with new_string. The old_string must be unique in the file. Opens the file in Kate if not already open.\n\nIn sessions with mcp__kate__katecode_edit always use it instead of Edit or mcp__acp__Edit, as it will update the editor buffer directly.");
+
+ QJsonObject editPathProp;
+ editPathProp[QStringLiteral("type")] = QStringLiteral("string");
+ editPathProp[QStringLiteral("description")] = QStringLiteral("The absolute path to the file to edit");
+
+ QJsonObject editOldProp;
+ editOldProp[QStringLiteral("type")] = QStringLiteral("string");
+ editOldProp[QStringLiteral("description")] = QStringLiteral("The text to replace (must be unique in the file)");
+
+ QJsonObject editNewProp;
+ editNewProp[QStringLiteral("type")] = QStringLiteral("string");
+ editNewProp[QStringLiteral("description")] = QStringLiteral("The replacement text");
+
+ QJsonObject editProps;
+ editProps[QStringLiteral("file_path")] = editPathProp;
+ editProps[QStringLiteral("old_string")] = editOldProp;
+ editProps[QStringLiteral("new_string")] = editNewProp;
+
+ QJsonObject editSchema;
+ editSchema[QStringLiteral("type")] = QStringLiteral("object");
+ editSchema[QStringLiteral("properties")] = editProps;
+ editSchema[QStringLiteral("required")] = QJsonArray{QStringLiteral("file_path"), QStringLiteral("old_string"), QStringLiteral("new_string")};
+ editTool[QStringLiteral("inputSchema")] = editSchema;
+
+ // Destructive annotation for edit tool (modifies files)
+ QJsonObject editAnnotations;
+ editAnnotations[QStringLiteral("readOnlyHint")] = false;
+ editAnnotations[QStringLiteral("destructiveHint")] = true;
+ editAnnotations[QStringLiteral("idempotentHint")] = false;
+ editTool[QStringLiteral("annotations")] = editAnnotations;
+
+ // katecode_write tool definition
+ QJsonObject writeTool;
+ writeTool[QStringLiteral("name")] = QStringLiteral("katecode_write");
+ writeTool[QStringLiteral("description")] =
+ QStringLiteral("Writes content to a file. If the file is open in Kate, updates the buffer. Otherwise creates or overwrites the file.\n\nIn sessions with mcp__kate__katecode_write always use it instead of Write or mcp__acp__Write, as it will update the editor buffer directly.");
+
+ QJsonObject writePathProp;
+ writePathProp[QStringLiteral("type")] = QStringLiteral("string");
+ writePathProp[QStringLiteral("description")] = QStringLiteral("The absolute path to the file to write");
+
+ QJsonObject writeContentProp;
+ writeContentProp[QStringLiteral("type")] = QStringLiteral("string");
+ writeContentProp[QStringLiteral("description")] = QStringLiteral("The content to write to the file");
+
+ QJsonObject writeProps;
+ writeProps[QStringLiteral("file_path")] = writePathProp;
+ writeProps[QStringLiteral("content")] = writeContentProp;
+
+ QJsonObject writeSchema;
+ writeSchema[QStringLiteral("type")] = QStringLiteral("object");
+ writeSchema[QStringLiteral("properties")] = writeProps;
+ writeSchema[QStringLiteral("required")] = QJsonArray{QStringLiteral("file_path"), QStringLiteral("content")};
+ writeTool[QStringLiteral("inputSchema")] = writeSchema;
+
+ // Destructive annotation for write tool (modifies/creates files)
+ QJsonObject writeAnnotations;
+ writeAnnotations[QStringLiteral("readOnlyHint")] = false;
+ writeAnnotations[QStringLiteral("destructiveHint")] = true;
+ writeAnnotations[QStringLiteral("idempotentHint")] = true; // Writing same content twice = same result
+ writeTool[QStringLiteral("annotations")] = writeAnnotations;
+
+ // katecode_ask_user tool definition
+ QJsonObject askUserTool;
+ askUserTool[QStringLiteral("name")] = QStringLiteral("katecode_ask_user");
+ askUserTool[QStringLiteral("description")] =
+ QStringLiteral("Ask the user 1-4 structured questions with selectable options. "
+ "Use this to gather clarifications, preferences, or decisions from the user. "
+ "Each question has a header (≤12 chars, used as answer key), question text, "
+ "multiSelect flag (checkboxes vs radio buttons), and 2-4 options. "
+ "An 'Other' option is automatically added for custom text input. "
+ "Returns answers as JSON object keyed by question headers.\n\n"
+ "In sessions with mcp__kate__katecode_ask_user always use it instead of AskUserQuestion, "
+ "as it will integrate with the editor and allow easy user feedback.");
+
+ // Option schema
+ QJsonObject optionLabelProp;
+ optionLabelProp[QStringLiteral("type")] = QStringLiteral("string");
+ optionLabelProp[QStringLiteral("description")] = QStringLiteral("Display text for the option (1-5 words)");
+
+ QJsonObject optionDescProp;
+ optionDescProp[QStringLiteral("type")] = QStringLiteral("string");
+ optionDescProp[QStringLiteral("description")] = QStringLiteral("Explanation of the choice");
+
+ QJsonObject optionProps;
+ optionProps[QStringLiteral("label")] = optionLabelProp;
+ optionProps[QStringLiteral("description")] = optionDescProp;
+
+ QJsonObject optionSchema;
+ optionSchema[QStringLiteral("type")] = QStringLiteral("object");
+ optionSchema[QStringLiteral("properties")] = optionProps;
+ optionSchema[QStringLiteral("required")] = QJsonArray{QStringLiteral("label"), QStringLiteral("description")};
+
+ // Question schema
+ QJsonObject questionHeaderProp;
+ questionHeaderProp[QStringLiteral("type")] = QStringLiteral("string");
+ questionHeaderProp[QStringLiteral("description")] = QStringLiteral("Short label (≤12 chars), used as key in response");
+ questionHeaderProp[QStringLiteral("maxLength")] = 12;
+
+ QJsonObject questionTextProp;
+ questionTextProp[QStringLiteral("type")] = QStringLiteral("string");
+ questionTextProp[QStringLiteral("description")] = QStringLiteral("The question text (should end with '?')");
+
+ QJsonObject multiSelectProp;
+ multiSelectProp[QStringLiteral("type")] = QStringLiteral("boolean");
+ multiSelectProp[QStringLiteral("description")] = QStringLiteral("Allow multiple selections (checkboxes) vs single selection (radio buttons)");
+
+ QJsonObject optionsArrayProp;
+ optionsArrayProp[QStringLiteral("type")] = QStringLiteral("array");
+ optionsArrayProp[QStringLiteral("items")] = optionSchema;
+ optionsArrayProp[QStringLiteral("minItems")] = 2;
+ optionsArrayProp[QStringLiteral("maxItems")] = 4;
+ optionsArrayProp[QStringLiteral("description")] = QStringLiteral("2-4 options for the user to choose from");
+
+ QJsonObject questionProps;
+ questionProps[QStringLiteral("header")] = questionHeaderProp;
+ questionProps[QStringLiteral("question")] = questionTextProp;
+ questionProps[QStringLiteral("multiSelect")] = multiSelectProp;
+ questionProps[QStringLiteral("options")] = optionsArrayProp;
+
+ QJsonObject questionSchema;
+ questionSchema[QStringLiteral("type")] = QStringLiteral("object");
+ questionSchema[QStringLiteral("properties")] = questionProps;
+ questionSchema[QStringLiteral("required")] = QJsonArray{
+ QStringLiteral("header"),
+ QStringLiteral("question"),
+ QStringLiteral("multiSelect"),
+ QStringLiteral("options")
+ };
+
+ // Questions array
+ QJsonObject questionsArrayProp;
+ questionsArrayProp[QStringLiteral("type")] = QStringLiteral("array");
+ questionsArrayProp[QStringLiteral("items")] = questionSchema;
+ questionsArrayProp[QStringLiteral("minItems")] = 1;
+ questionsArrayProp[QStringLiteral("maxItems")] = 4;
+ questionsArrayProp[QStringLiteral("description")] = QStringLiteral("1-4 questions to ask the user");
+
+ QJsonObject askUserProps;
+ askUserProps[QStringLiteral("questions")] = questionsArrayProp;
+
+ QJsonObject askUserSchema;
+ askUserSchema[QStringLiteral("type")] = QStringLiteral("object");
+ askUserSchema[QStringLiteral("properties")] = askUserProps;
+ askUserSchema[QStringLiteral("required")] = QJsonArray{QStringLiteral("questions")};
+ askUserTool[QStringLiteral("inputSchema")] = askUserSchema;
+
+ // Read-only annotation (doesn't modify files, just asks user)
+ QJsonObject askUserAnnotations;
+ askUserAnnotations[QStringLiteral("readOnlyHint")] = true;
+ askUserAnnotations[QStringLiteral("destructiveHint")] = false;
+ askUserTool[QStringLiteral("annotations")] = askUserAnnotations;
+
+ QJsonObject result;
+ result[QStringLiteral("tools")] = QJsonArray{docsTool, readTool, editTool, writeTool, askUserTool};
+
+ return makeResponse(id, result);
+}
+
+QJsonObject MCPServer::handleToolsCall(int id, const QJsonObject ¶ms)
+{
+ const QString toolName = params[QStringLiteral("name")].toString();
+ const QJsonObject arguments = params[QStringLiteral("arguments")].toObject();
+
+ // Every tool reaches Kate over D-Bus. Refuse cleanly if the configured
+ // service name is malformed rather than letting the QDBusInterface
+ // constructor abort the whole server inside libdbus.
+ if (!isValidBusName(editorServiceName())) {
+ return makeResponse(id, makeErrorResult(
+ QStringLiteral("Error: invalid Kate editor D-Bus service name: \"%1\". "
+ "Update the Kate Code plugin.").arg(editorServiceName())));
+ }
+
+ if (toolName == QStringLiteral("katecode_documents")) {
+ return makeResponse(id, executeDocuments(arguments));
+ } else if (toolName == QStringLiteral("katecode_read")) {
+ return makeResponse(id, executeRead(arguments));
+ } else if (toolName == QStringLiteral("katecode_edit")) {
+ return makeResponse(id, executeEdit(arguments));
+ } else if (toolName == QStringLiteral("katecode_write")) {
+ return makeResponse(id, executeWrite(arguments));
+ } else if (toolName == QStringLiteral("katecode_ask_user")) {
+ return makeResponse(id, executeAskUserQuestion(arguments));
+ }
+
+ return makeErrorResponse(id, -32602, QStringLiteral("Unknown tool: %1").arg(toolName));
+}
+
+QJsonObject MCPServer::executeDocuments(const QJsonObject &arguments)
+{
+ Q_UNUSED(arguments);
+
+ QDBusInterface iface(editorServiceName(),
+ QStringLiteral("/KateCode/Editor"),
+ QStringLiteral("org.kde.katecode.Editor"),
+ QDBusConnection::sessionBus());
+
+ if (!iface.isValid()) {
+ QJsonObject textContent;
+ textContent[QStringLiteral("type")] = QStringLiteral("text");
+ textContent[QStringLiteral("text")] = QStringLiteral("Error: Could not connect to Kate editor DBus service.");
+ QJsonObject result;
+ result[QStringLiteral("content")] = QJsonArray{textContent};
+ result[QStringLiteral("isError")] = true;
+ return result;
+ }
+
+ QDBusReply reply = iface.call(QStringLiteral("listDocuments"));
+ if (!reply.isValid()) {
+ QJsonObject textContent;
+ textContent[QStringLiteral("type")] = QStringLiteral("text");
+ textContent[QStringLiteral("text")] = QStringLiteral("Error: DBus call failed: %1").arg(reply.error().message());
+ QJsonObject result;
+ result[QStringLiteral("content")] = QJsonArray{textContent};
+ result[QStringLiteral("isError")] = true;
+ return result;
+ }
+
+ const QStringList docs = reply.value();
+ QString text;
+ if (docs.isEmpty()) {
+ text = QStringLiteral("No documents currently open in Kate.");
+ } else {
+ text = QStringLiteral("Open documents (%1):\n").arg(docs.size());
+ for (const QString &doc : docs) {
+ text += QStringLiteral(" %1\n").arg(doc);
+ }
+ }
+
+ QJsonObject textContent;
+ textContent[QStringLiteral("type")] = QStringLiteral("text");
+ textContent[QStringLiteral("text")] = text;
+
+ QJsonObject result;
+ result[QStringLiteral("content")] = QJsonArray{textContent};
+ return result;
+}
+
+QJsonObject MCPServer::makeResponse(int id, const QJsonObject &result)
+{
+ QJsonObject response;
+ response[QStringLiteral("jsonrpc")] = QStringLiteral("2.0");
+ response[QStringLiteral("id")] = id;
+ response[QStringLiteral("result")] = result;
+ return response;
+}
+
+QJsonObject MCPServer::makeErrorResponse(int id, int code, const QString &message)
+{
+ QJsonObject error;
+ error[QStringLiteral("code")] = code;
+ error[QStringLiteral("message")] = message;
+
+ QJsonObject response;
+ response[QStringLiteral("jsonrpc")] = QStringLiteral("2.0");
+ response[QStringLiteral("id")] = id;
+ response[QStringLiteral("error")] = error;
+ return response;
+}
+
+QJsonObject MCPServer::executeRead(const QJsonObject &arguments)
+{
+ const QString filePath = arguments[QStringLiteral("file_path")].toString();
+
+ // Parse optional offset and limit (1-based line numbers)
+ const int defaultLimit = 2000;
+ int offset = 1; // 1-based, default to first line
+ int limit = defaultLimit;
+
+ if (arguments.contains(QStringLiteral("offset"))) {
+ offset = qMax(1, arguments[QStringLiteral("offset")].toInt(1));
+ }
+ if (arguments.contains(QStringLiteral("limit"))) {
+ limit = qMax(1, arguments[QStringLiteral("limit")].toInt(defaultLimit));
+ }
+
+ if (filePath.isEmpty()) {
+ QJsonObject textContent;
+ textContent[QStringLiteral("type")] = QStringLiteral("text");
+ textContent[QStringLiteral("text")] = QStringLiteral("Error: file_path is required");
+ QJsonObject result;
+ result[QStringLiteral("content")] = QJsonArray{textContent};
+ result[QStringLiteral("isError")] = true;
+ return result;
+ }
+
+ QDBusInterface iface(editorServiceName(),
+ QStringLiteral("/KateCode/Editor"),
+ QStringLiteral("org.kde.katecode.Editor"),
+ QDBusConnection::sessionBus());
+
+ if (!iface.isValid()) {
+ QJsonObject textContent;
+ textContent[QStringLiteral("type")] = QStringLiteral("text");
+ textContent[QStringLiteral("text")] = QStringLiteral("Error: Could not connect to Kate editor DBus service.");
+ QJsonObject result;
+ result[QStringLiteral("content")] = QJsonArray{textContent};
+ result[QStringLiteral("isError")] = true;
+ return result;
+ }
+
+ QDBusReply reply = iface.call(QStringLiteral("readDocument"), filePath);
+ if (!reply.isValid()) {
+ QJsonObject textContent;
+ textContent[QStringLiteral("type")] = QStringLiteral("text");
+ textContent[QStringLiteral("text")] = QStringLiteral("Error: DBus call failed: %1").arg(reply.error().message());
+ QJsonObject result;
+ result[QStringLiteral("content")] = QJsonArray{textContent};
+ result[QStringLiteral("isError")] = true;
+ return result;
+ }
+
+ const QString content = reply.value();
+ if (content.startsWith(QStringLiteral("ERROR:"))) {
+ QJsonObject textContent;
+ textContent[QStringLiteral("type")] = QStringLiteral("text");
+ textContent[QStringLiteral("text")] = content;
+ QJsonObject result;
+ result[QStringLiteral("content")] = QJsonArray{textContent};
+ result[QStringLiteral("isError")] = true;
+ return result;
+ }
+
+ // Split content into lines and apply offset/limit
+ QStringList allLines = content.split(QLatin1Char('\n'));
+ const int totalLines = allLines.size();
+
+ // Convert 1-based offset to 0-based index
+ int startIdx = offset - 1;
+ if (startIdx >= totalLines) {
+ startIdx = qMax(0, totalLines - 1);
+ }
+
+ int endIdx = qMin(startIdx + limit, totalLines);
+ int linesOmittedBefore = startIdx;
+ int linesOmittedAfter = totalLines - endIdx;
+
+ // Build output with line numbers (like Claude Code's Read tool)
+ // Format: " 42→content" with arrow separator
+ QString outputText;
+ int lineNumWidth = QString::number(endIdx).length();
+
+ for (int i = startIdx; i < endIdx; ++i) {
+ int lineNum = i + 1; // 1-based line number
+ outputText += QStringLiteral("%1→%2\n")
+ .arg(lineNum, lineNumWidth)
+ .arg(allLines[i]);
+ }
+
+ // Remove trailing newline if present
+ if (outputText.endsWith(QLatin1Char('\n'))) {
+ outputText.chop(1);
+ }
+
+ // Add truncation warnings
+ QString header;
+ if (linesOmittedBefore > 0 || linesOmittedAfter > 0) {
+ header = QStringLiteral("(%1 total lines").arg(totalLines);
+ if (linesOmittedBefore > 0) {
+ header += QStringLiteral(", %1 omitted before").arg(linesOmittedBefore);
+ }
+ if (linesOmittedAfter > 0) {
+ header += QStringLiteral(", %1 omitted after").arg(linesOmittedAfter);
+ }
+ header += QStringLiteral(")\n\n");
+ }
+
+ QJsonObject textContent;
+ textContent[QStringLiteral("type")] = QStringLiteral("text");
+ textContent[QStringLiteral("text")] = QString(header + outputText);
+
+ QJsonObject result;
+ result[QStringLiteral("content")] = QJsonArray{textContent};
+ return result;
+}
+
+QJsonObject MCPServer::executeEdit(const QJsonObject &arguments)
+{
+ const QString filePath = arguments[QStringLiteral("file_path")].toString();
+ const QString oldString = arguments[QStringLiteral("old_string")].toString();
+ const QString newString = arguments[QStringLiteral("new_string")].toString();
+
+ if (filePath.isEmpty() || oldString.isEmpty()) {
+ QJsonObject textContent;
+ textContent[QStringLiteral("type")] = QStringLiteral("text");
+ textContent[QStringLiteral("text")] = QStringLiteral("Error: file_path and old_string are required");
+ QJsonObject result;
+ result[QStringLiteral("content")] = QJsonArray{textContent};
+ result[QStringLiteral("isError")] = true;
+ return result;
+ }
+
+ QDBusInterface iface(editorServiceName(),
+ QStringLiteral("/KateCode/Editor"),
+ QStringLiteral("org.kde.katecode.Editor"),
+ QDBusConnection::sessionBus());
+
+ if (!iface.isValid()) {
+ QJsonObject textContent;
+ textContent[QStringLiteral("type")] = QStringLiteral("text");
+ textContent[QStringLiteral("text")] = QStringLiteral("Error: Could not connect to Kate editor DBus service.");
+ QJsonObject result;
+ result[QStringLiteral("content")] = QJsonArray{textContent};
+ result[QStringLiteral("isError")] = true;
+ return result;
+ }
+
+ QDBusReply reply = iface.call(QStringLiteral("editDocument"), filePath, oldString, newString);
+ if (!reply.isValid()) {
+ QJsonObject textContent;
+ textContent[QStringLiteral("type")] = QStringLiteral("text");
+ textContent[QStringLiteral("text")] = QStringLiteral("Error: DBus call failed: %1").arg(reply.error().message());
+ QJsonObject result;
+ result[QStringLiteral("content")] = QJsonArray{textContent};
+ result[QStringLiteral("isError")] = true;
+ return result;
+ }
+
+ 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::executeWrite(const QJsonObject &arguments)
+{
+ const QString filePath = arguments[QStringLiteral("file_path")].toString();
+ const QString content = arguments[QStringLiteral("content")].toString();
+
+ if (filePath.isEmpty()) {
+ QJsonObject textContent;
+ textContent[QStringLiteral("type")] = QStringLiteral("text");
+ textContent[QStringLiteral("text")] = QStringLiteral("Error: file_path is required");
+ QJsonObject result;
+ result[QStringLiteral("content")] = QJsonArray{textContent};
+ result[QStringLiteral("isError")] = true;
+ return result;
+ }
+
+ QDBusInterface iface(editorServiceName(),
+ QStringLiteral("/KateCode/Editor"),
+ QStringLiteral("org.kde.katecode.Editor"),
+ QDBusConnection::sessionBus());
+
+ if (!iface.isValid()) {
+ QJsonObject textContent;
+ textContent[QStringLiteral("type")] = QStringLiteral("text");
+ textContent[QStringLiteral("text")] = QStringLiteral("Error: Could not connect to Kate editor DBus service.");
+ QJsonObject result;
+ result[QStringLiteral("content")] = QJsonArray{textContent};
+ result[QStringLiteral("isError")] = true;
+ return result;
+ }
+
+ QDBusReply reply = iface.call(QStringLiteral("writeDocument"), filePath, content);
+ if (!reply.isValid()) {
+ QJsonObject textContent;
+ textContent[QStringLiteral("type")] = QStringLiteral("text");
+ textContent[QStringLiteral("text")] = QStringLiteral("Error: DBus call failed: %1").arg(reply.error().message());
+ QJsonObject result;
+ result[QStringLiteral("content")] = QJsonArray{textContent};
+ result[QStringLiteral("isError")] = true;
+ return result;
+ }
+
+ 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::makeErrorResult(const QString &message)
+{
+ QJsonObject textContent;
+ textContent[QStringLiteral("type")] = QStringLiteral("text");
+ textContent[QStringLiteral("text")] = message;
+
+ QJsonObject result;
+ result[QStringLiteral("content")] = QJsonArray{textContent};
+ result[QStringLiteral("isError")] = true;
+ return result;
+}
+
+QJsonObject MCPServer::executeAskUserQuestion(const QJsonObject &arguments)
+{
+ const QJsonArray questions = arguments[QStringLiteral("questions")].toArray();
+
+ // Validate: 1-4 questions
+ if (questions.isEmpty()) {
+ return makeErrorResult(QStringLiteral("Error: questions array is required and cannot be empty"));
+ }
+ if (questions.size() > 4) {
+ return makeErrorResult(QStringLiteral("Error: questions array must have at most 4 items"));
+ }
+
+ // Validate each question
+ for (int i = 0; i < questions.size(); ++i) {
+ const QJsonObject q = questions[i].toObject();
+ const QString header = q[QStringLiteral("header")].toString();
+ const QString questionText = q[QStringLiteral("question")].toString();
+ const QJsonArray options = q[QStringLiteral("options")].toArray();
+
+ if (header.isEmpty()) {
+ return makeErrorResult(QStringLiteral("Error: question %1 is missing 'header'").arg(i + 1));
+ }
+ if (header.length() > 12) {
+ return makeErrorResult(QStringLiteral("Error: question %1 header exceeds 12 characters").arg(i + 1));
+ }
+ if (questionText.isEmpty()) {
+ return makeErrorResult(QStringLiteral("Error: question %1 is missing 'question' text").arg(i + 1));
+ }
+ if (options.size() < 2) {
+ return makeErrorResult(QStringLiteral("Error: question %1 must have at least 2 options").arg(i + 1));
+ }
+ if (options.size() > 4) {
+ return makeErrorResult(QStringLiteral("Error: question %1 must have at most 4 options").arg(i + 1));
+ }
+ }
+
+ // Serialize questions to JSON string for DBus
+ const QString questionsJson = QString::fromUtf8(
+ QJsonDocument(questions).toJson(QJsonDocument::Compact));
+
+ // Call DBus method - this will block until user responds
+ QDBusInterface iface(editorServiceName(),
+ 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. "
+ "Is Kate running with the Kate Code plugin enabled?"));
+ }
+
+ // Set timeout to 5 minutes (user interaction can take time)
+ iface.setTimeout(300000);
+
+ QDBusReply reply = iface.call(QStringLiteral("askUserQuestion"), questionsJson);
+
+ if (!reply.isValid()) {
+ return makeErrorResult(QStringLiteral("Error: DBus call failed: %1").arg(reply.error().message()));
+ }
+
+ const QString responseJson = reply.value();
+
+ // Check for error response
+ if (responseJson.startsWith(QStringLiteral("ERROR:"))) {
+ return makeErrorResult(responseJson);
+ }
+
+ // Parse the JSON response and format it nicely
+ QJsonParseError parseError;
+ QJsonDocument doc = QJsonDocument::fromJson(responseJson.toUtf8(), &parseError);
+
+ if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
+ // Fallback to raw JSON if parsing fails
+ QJsonObject textContent;
+ textContent[QStringLiteral("type")] = QStringLiteral("text");
+ textContent[QStringLiteral("text")] = responseJson;
+
+ QJsonObject result;
+ result[QStringLiteral("content")] = QJsonArray{textContent};
+ return result;
+ }
+
+ // Format the response as readable text (plain text, no markdown)
+ QJsonObject answers = doc.object();
+ QString formattedText;
+
+ for (auto it = answers.begin(); it != answers.end(); ++it) {
+ const QString &header = it.key();
+ const QJsonValue &value = it.value();
+
+ formattedText += QStringLiteral("%1: ").arg(header);
+
+ if (value.isArray()) {
+ // Multi-select: list all selected options
+ QStringList selections;
+ const QJsonArray arr = value.toArray();
+ for (const QJsonValue &v : arr) {
+ selections << v.toString();
+ }
+ formattedText += selections.join(QStringLiteral(", "));
+ } else {
+ // Single-select: just the value
+ formattedText += value.toString();
+ }
+ formattedText += QStringLiteral("\n");
+ }
+
+ QJsonObject textContent;
+ textContent[QStringLiteral("type")] = QStringLiteral("text");
+ textContent[QStringLiteral("text")] = formattedText.trimmed();
+
+ QJsonObject result;
+ result[QStringLiteral("content")] = QJsonArray{textContent};
+ return result;
+}
diff --git a/src/mcp/MCPServer.h b/src/mcp/MCPServer.h
new file mode 100644
index 0000000..02ee7ee
--- /dev/null
+++ b/src/mcp/MCPServer.h
@@ -0,0 +1,37 @@
+/*
+ SPDX-License-Identifier: MIT
+ SPDX-FileCopyrightText: 2025 Kate Code contributors
+*/
+
+#pragma once
+
+#include
+#include
+#include
+
+class MCPServer
+{
+public:
+ MCPServer();
+
+ // Process a single JSON-RPC message and return the response.
+ // Returns a null QJsonObject for notifications (no response needed).
+ QJsonObject handleMessage(const QJsonObject &msg);
+
+private:
+ QJsonObject handleInitialize(int id, const QJsonObject ¶ms);
+ QJsonObject handleToolsList(int id, const QJsonObject ¶ms);
+ QJsonObject handleToolsCall(int id, const QJsonObject ¶ms);
+
+ QJsonObject executeDocuments(const QJsonObject &arguments);
+ QJsonObject executeRead(const QJsonObject &arguments);
+ QJsonObject executeEdit(const QJsonObject &arguments);
+ QJsonObject executeWrite(const QJsonObject &arguments);
+ QJsonObject executeAskUserQuestion(const QJsonObject &arguments);
+
+ QJsonObject makeResponse(int id, const QJsonObject &result);
+ QJsonObject makeErrorResponse(int id, int code, const QString &message);
+ QJsonObject makeErrorResult(const QString &message);
+
+ bool m_initialized = false;
+};
diff --git a/src/mcp/main.cpp b/src/mcp/main.cpp
new file mode 100644
index 0000000..704a8ba
--- /dev/null
+++ b/src/mcp/main.cpp
@@ -0,0 +1,101 @@
+/*
+ SPDX-License-Identifier: MIT
+ SPDX-FileCopyrightText: 2025 Kate Code contributors
+
+ Kate MCP Server - standalone MCP server for Kate editor integration.
+ Speaks JSON-RPC 2.0 over stdin/stdout (newline-delimited).
+ Uses QSocketNotifier + event loop so that DBus calls work.
+*/
+
+#include "MCPServer.h"
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+// write() can be interrupted or partial (large read responses exceed the pipe
+// buffer); emitting a truncated JSON line would corrupt the protocol.
+static void writeAll(const QByteArray &data)
+{
+ const char *ptr = data.constData();
+ qint64 remaining = data.size();
+ while (remaining > 0) {
+ const ssize_t n = write(STDOUT_FILENO, ptr, static_cast(remaining));
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return; // client gone (EPIPE etc.); EOF on stdin will end us
+ }
+ ptr += n;
+ remaining -= n;
+ }
+}
+
+static void processLine(const QByteArray &line, MCPServer &server)
+{
+ const QByteArray trimmed = line.trimmed();
+ if (trimmed.isEmpty()) {
+ return;
+ }
+
+ QJsonParseError parseError;
+ const QJsonDocument doc = QJsonDocument::fromJson(trimmed, &parseError);
+ if (parseError.error != QJsonParseError::NoError) {
+ return;
+ }
+
+ const QJsonObject response = server.handleMessage(doc.object());
+ if (response.isEmpty()) {
+ return;
+ }
+
+ writeAll(QJsonDocument(response).toJson(QJsonDocument::Compact) + '\n');
+}
+
+int main(int argc, char *argv[])
+{
+ QCoreApplication app(argc, argv);
+
+ // A client tearing down its pipe must not kill us via SIGPIPE mid-write;
+ // the subsequent EOF on stdin ends the process cleanly instead.
+ signal(SIGPIPE, SIG_IGN);
+
+ MCPServer server;
+ QByteArray buffer;
+
+ auto *notifier = new QSocketNotifier(STDIN_FILENO, QSocketNotifier::Read, &app);
+ QObject::connect(notifier, &QSocketNotifier::activated, [&]() {
+ char buf[4096];
+ const ssize_t n = read(STDIN_FILENO, buf, sizeof(buf));
+ if (n < 0 && (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)) {
+ // Interrupted read or spurious wakeup (parents such as node can
+ // leave the shared pipe description non-blocking) — NOT end of
+ // input. Quitting here silently killed the MCP connection.
+ return;
+ }
+ if (n <= 0) {
+ // EOF or a real error — quit
+ notifier->setEnabled(false);
+ QCoreApplication::quit();
+ return;
+ }
+
+ buffer.append(buf, static_cast(n));
+
+ // Process complete lines
+ int pos;
+ while ((pos = buffer.indexOf('\n')) >= 0) {
+ const QByteArray line = buffer.left(pos);
+ buffer.remove(0, pos + 1);
+ processLine(line, server);
+ }
+ });
+
+ return app.exec();
+}
diff --git a/src/plugin/KateCodePlugin.cpp b/src/plugin/KateCodePlugin.cpp
index 1aff220..814e40a 100644
--- a/src/plugin/KateCodePlugin.cpp
+++ b/src/plugin/KateCodePlugin.cpp
@@ -1,14 +1,37 @@
#include "KateCodePlugin.h"
#include "KateCodeView.h"
+#include "../config/KateCodeConfigPage.h"
+#include "../config/SettingsStore.h"
+#include "../mcp/EditorDBusService.h"
#include
#include
+#include
+#include
K_PLUGIN_FACTORY_WITH_JSON(KateCodePluginFactory, "katecode.json", registerPlugin();)
KateCodePlugin::KateCodePlugin(QObject *parent, const QVariantList &)
: KTextEditor::Plugin(parent)
+ , m_settings(new SettingsStore(this))
+ , m_dbusService(new EditorDBusService(this))
{
+ // Defer DBus registration to the next event loop iteration. Calling
+ // registerService() directly in the constructor can fail (empty error)
+ // if the session bus connection has not fully settled during plugin
+ // load. The single-shot timer also adds a 1 s retry so that transient
+ // failures during Kate startup are recovered automatically.
+ QTimer::singleShot(0, this, [this]() {
+ if (!m_dbusService->registerOnBus()) {
+ qWarning() << "[KateCode] DBus registration failed on first attempt, retrying in 1s";
+ QTimer::singleShot(1000, this, [this]() {
+ m_dbusService->registerOnBus();
+ });
+ }
+ });
+
+ // Connect to application shutdown to trigger summary generation
+ connect(qApp, &QApplication::aboutToQuit, this, &KateCodePlugin::onAboutToQuit);
}
KateCodePlugin::~KateCodePlugin()
@@ -16,6 +39,15 @@ KateCodePlugin::~KateCodePlugin()
// Views are cleaned up automatically as they're children of MainWindow
}
+void KateCodePlugin::onAboutToQuit()
+{
+ qDebug() << "[KateCodePlugin] Application shutting down, preparing views...";
+ for (KateCodeView *view : m_views) {
+ view->prepareForShutdown();
+ }
+ qDebug() << "[KateCodePlugin] Shutdown preparation complete";
+}
+
QObject *KateCodePlugin::createView(KTextEditor::MainWindow *mainWindow)
{
auto *view = new KateCodeView(this, mainWindow);
@@ -23,4 +55,40 @@ QObject *KateCodePlugin::createView(KTextEditor::MainWindow *mainWindow)
return view;
}
+bool KateCodePlugin::acquireAgentSlot(QObject *owner)
+{
+ if (m_agentOwner.isNull() || m_agentOwner == owner) {
+ m_agentOwner = owner;
+ Q_EMIT agentActivityChanged();
+ return true;
+ }
+ return false;
+}
+
+void KateCodePlugin::releaseAgentSlot(QObject *owner)
+{
+ if (m_agentOwner == owner) {
+ m_agentOwner = nullptr;
+ Q_EMIT agentActivityChanged();
+ }
+}
+
+bool KateCodePlugin::agentSlotAvailableFor(QObject *owner) const
+{
+ return m_agentOwner.isNull() || m_agentOwner == owner;
+}
+
+int KateCodePlugin::configPages() const
+{
+ return 1;
+}
+
+KTextEditor::ConfigPage *KateCodePlugin::configPage(int number, QWidget *parent)
+{
+ if (number != 0) {
+ return nullptr;
+ }
+ return new KateCodeConfigPage(m_settings, parent);
+}
+
#include "KateCodePlugin.moc"
diff --git a/src/plugin/KateCodePlugin.h b/src/plugin/KateCodePlugin.h
index 65a86a1..bdb0a6a 100644
--- a/src/plugin/KateCodePlugin.h
+++ b/src/plugin/KateCodePlugin.h
@@ -2,9 +2,12 @@
#include
#include
+#include
#include
+class EditorDBusService;
class KateCodeView;
+class SettingsStore;
class KateCodePlugin : public KTextEditor::Plugin
{
@@ -16,6 +19,46 @@ class KateCodePlugin : public KTextEditor::Plugin
QObject *createView(KTextEditor::MainWindow *mainWindow) override;
+ // Config page support
+ int configPages() const override;
+ KTextEditor::ConfigPage *configPage(int number, QWidget *parent) override;
+
+ // Settings access for views
+ SettingsStore *settings() const { return m_settings; }
+
+ // DBus service access for views (used for question routing)
+ EditorDBusService *dbusService() const { return m_dbusService; }
+
+ // Called from KateCodeView::~KateCodeView() to prevent dangling pointers.
+ // Also emits agentActivityChanged() so other windows can refresh their buttons.
+ void removeView(KateCodeView *view)
+ {
+ m_views.removeOne(view);
+ Q_EMIT agentActivityChanged();
+ }
+
+ // Process-wide single-active-agent gate.
+ // Returns true and claims the slot when it is free or already owned by
+ // owner; returns false when another window holds it.
+ bool acquireAgentSlot(QObject *owner);
+ // Releases the slot if owner currently holds it.
+ void releaseAgentSlot(QObject *owner);
+ // Returns true when the slot is free or already held by owner.
+ bool agentSlotAvailableFor(QObject *owner) const;
+
+Q_SIGNALS:
+ // Emitted whenever the agent-slot owner changes so all windows can
+ // update their button enabled-states.
+ void agentActivityChanged();
+
+private Q_SLOTS:
+ void onAboutToQuit();
+
private:
QList m_views;
+ SettingsStore *m_settings;
+ EditorDBusService *m_dbusService;
+ // Tracks which ChatWidget currently holds the single-agent slot.
+ // QPointer auto-clears if the owning widget is destroyed.
+ QPointer m_agentOwner;
};
diff --git a/src/plugin/KateCodeView.cpp b/src/plugin/KateCodeView.cpp
index 871d317..ceb13ce 100644
--- a/src/plugin/KateCodeView.cpp
+++ b/src/plugin/KateCodeView.cpp
@@ -1,14 +1,24 @@
#include "KateCodeView.h"
#include "KateCodePlugin.h"
+#include "../acp/ACPModels.h"
+#include "../config/SettingsStore.h"
+#include "../mcp/EditorDBusService.h"
#include "../ui/ChatWidget.h"
+#include "../util/DiffHighlightManager.h"
+#include
#include
+#include
#include
+#include
#include
+#include
+#include
#include
#include
#include
#include
+#include
KateCodeView::KateCodeView(KateCodePlugin *plugin, KTextEditor::MainWindow *mainWindow)
: QObject(mainWindow)
@@ -17,12 +27,87 @@ KateCodeView::KateCodeView(KateCodePlugin *plugin, KTextEditor::MainWindow *main
, m_mainWindow(mainWindow)
, m_toolView(nullptr)
, m_chatWidget(nullptr)
+ , m_diffHighlightManager(nullptr)
{
createToolView();
+
+ // Create diff highlight manager for showing pending edits in editor
+ m_diffHighlightManager = new DiffHighlightManager(m_mainWindow, m_plugin->settings(), this);
+
+ // Editor diff highlighting disabled - diffs are shown inline in tool call UI instead
+ // If re-enabling, connect ChatWidget::toolCallHighlightRequested/toolCallClearRequested
+ // to m_diffHighlightManager->highlightToolCall() and clearToolCallHighlights()
+ Q_UNUSED(m_diffHighlightManager);
+
+ // Load UI definition file from Qt resources
+ setXMLFile(QStringLiteral(":/katecode/katecodeui.rc"));
+ qDebug() << "[KateCodeView] Loaded UI file from resources: :/katecode/katecodeui.rc";
+
+ // Create context menu action for adding selection to context
+ QAction *addContextAction = new QAction(QIcon::fromTheme(QStringLiteral("list-add")),
+ i18n("Add to Claude Context..."),
+ this);
+ actionCollection()->addAction(QStringLiteral("kate_code_add_context"), addContextAction);
+ actionCollection()->setDefaultShortcut(addContextAction, Qt::CTRL | Qt::ALT | Qt::Key_A);
+ connect(addContextAction, &QAction::triggered, this, &KateCodeView::addSelectionToContext);
+ qDebug() << "[KateCodeView] Registered action: kate_code_add_context with shortcut Ctrl+Alt+A";
+
+ // Quick Action: Explain Code
+ QAction *explainAction = new QAction(QIcon::fromTheme(QStringLiteral("help-about")),
+ i18n("Explain Code"),
+ this);
+ actionCollection()->addAction(QStringLiteral("kate_code_explain"), explainAction);
+ actionCollection()->setDefaultShortcut(explainAction, Qt::CTRL | Qt::ALT | Qt::Key_E);
+ connect(explainAction, &QAction::triggered, this, &KateCodeView::explainCode);
+
+ // Quick Action: Find Bugs
+ QAction *findBugsAction = new QAction(QIcon::fromTheme(QStringLiteral("tools-report-bug")),
+ i18n("Find Bugs"),
+ this);
+ actionCollection()->addAction(QStringLiteral("kate_code_find_bugs"), findBugsAction);
+ actionCollection()->setDefaultShortcut(findBugsAction, Qt::CTRL | Qt::ALT | Qt::Key_B);
+ connect(findBugsAction, &QAction::triggered, this, &KateCodeView::findBugs);
+
+ // Quick Action: Suggest Improvements
+ QAction *improvementsAction = new QAction(QIcon::fromTheme(QStringLiteral("tools-wizard")),
+ i18n("Suggest Improvements"),
+ this);
+ actionCollection()->addAction(QStringLiteral("kate_code_improvements"), improvementsAction);
+ actionCollection()->setDefaultShortcut(improvementsAction, Qt::CTRL | Qt::ALT | Qt::Key_I);
+ connect(improvementsAction, &QAction::triggered, this, &KateCodeView::suggestImprovements);
+
+ // Quick Action: Add Tests
+ QAction *addTestsAction = new QAction(QIcon::fromTheme(QStringLiteral("document-new")),
+ i18n("Add Tests"),
+ this);
+ actionCollection()->addAction(QStringLiteral("kate_code_add_tests"), addTestsAction);
+ actionCollection()->setDefaultShortcut(addTestsAction, Qt::CTRL | Qt::ALT | Qt::Key_T);
+ connect(addTestsAction, &QAction::triggered, this, &KateCodeView::addTests);
+
+ qDebug() << "[KateCodeView] Registered Quick Actions: Explain, Find Bugs, Improvements, Add Tests";
+
+ // Register the action with Kate's editor context menu
+ m_mainWindow->guiFactory()->addClient(this);
+ qDebug() << "[KateCodeView] Added XMLGUI client to Kate";
}
KateCodeView::~KateCodeView()
{
+ // Remove this view from the plugin's list so onAboutToQuit() cannot
+ // dereference us after we are destroyed (e.g. when a window is closed
+ // before the application quits). We do NOT call prepareForShutdown() here:
+ // the tool view (and its ChatWidget) may already have been destroyed by the
+ // MainWindow, so touching m_chatWidget would be a use-after-free. ChatWidget
+ // severs its own signals in its destructor, and onAboutToQuit() still runs
+ // prepareForShutdown() on the application-quit path while everything is
+ // alive.
+ m_plugin->removeView(this);
+
+ // Remove XMLGUI client before destruction to prevent leaks and crashes
+ if (m_mainWindow && m_mainWindow->guiFactory()) {
+ m_mainWindow->guiFactory()->removeClient(this);
+ }
+
// ToolView is owned by MainWindow and cleaned up automatically
}
@@ -63,6 +148,43 @@ void KateCodeView::createToolView()
m_chatWidget->setFilePathProvider([this]() { return getCurrentFilePath(); });
m_chatWidget->setSelectionProvider([this]() { return getCurrentSelection(); });
m_chatWidget->setProjectRootProvider([this]() { return getProjectRoot(); });
+ m_chatWidget->setFileListProvider([this]() { return getProjectFiles(); });
+ m_chatWidget->setDocumentProvider([this](const QString &path) { return findDocumentByPath(path); });
+
+ // Inject settings store for summary generation
+ m_chatWidget->setSettingsStore(m_plugin->settings());
+
+ // Wire the process-wide single-agent gate so only one window runs an agent
+ // at a time within this Kate process.
+ m_chatWidget->setAgentSlotHooks(
+ [this]() { return m_plugin->acquireAgentSlot(m_chatWidget); },
+ [this]() { m_plugin->releaseAgentSlot(m_chatWidget); },
+ [this]() { return m_plugin->agentSlotAvailableFor(m_chatWidget); });
+ connect(m_plugin, &KateCodePlugin::agentActivityChanged,
+ m_chatWidget, &ChatWidget::refreshAgentAvailability);
+
+ // Connect edit navigation
+ connect(m_chatWidget, &ChatWidget::jumpToEditRequested, this, &KateCodeView::jumpToEdit);
+
+ // Connect user question signals (MCP AskUserQuestion tool)
+ // EditorDBusService -> ChatWidget: show question UI
+ connect(m_plugin->dbusService(), &EditorDBusService::questionRequested,
+ m_chatWidget, &ChatWidget::showUserQuestion);
+ // EditorDBusService -> ChatWidget: remove question UI on timeout/cancel
+ connect(m_plugin->dbusService(), &EditorDBusService::questionCancelled,
+ m_chatWidget, &ChatWidget::removeUserQuestion);
+ // ChatWidget -> EditorDBusService: return user's answer
+ connect(m_chatWidget, &ChatWidget::userQuestionAnswered,
+ m_plugin->dbusService(), &EditorDBusService::provideQuestionResponse);
+
+ // Connect debug log output to Kate's Output panel
+ connect(m_chatWidget, &ChatWidget::debugLogMessage, this, [this](const QString &message) {
+ QVariantMap msg;
+ msg[QStringLiteral("text")] = message;
+ msg[QStringLiteral("type")] = QStringLiteral("Log");
+ msg[QStringLiteral("category")] = QStringLiteral("Kate Code");
+ m_mainWindow->showMessage(msg);
+ });
}
QString KateCodeView::getCurrentFilePath() const
@@ -151,3 +273,244 @@ QString KateCodeView::getProjectRoot() const
qDebug() << "[KateCode] No project root found, using document directory";
return QFileInfo(filePath).absolutePath();
}
+
+QStringList KateCodeView::getProjectFiles() const
+{
+ QString projectRoot = getProjectRoot();
+ if (projectRoot.isEmpty()) {
+ return QStringList();
+ }
+
+ QStringList files;
+ QDir rootDir(projectRoot);
+
+ // Directories to ignore
+ static const QStringList ignoredDirs = {
+ QStringLiteral(".git"),
+ QStringLiteral(".hg"),
+ QStringLiteral(".svn"),
+ QStringLiteral("node_modules"),
+ QStringLiteral("build"),
+ QStringLiteral("dist"),
+ QStringLiteral("target"),
+ QStringLiteral(".idea"),
+ QStringLiteral(".vscode"),
+ QStringLiteral("__pycache__"),
+ QStringLiteral(".pytest_cache"),
+ QStringLiteral(".tox"),
+ QStringLiteral("venv"),
+ QStringLiteral(".venv"),
+ QStringLiteral("env")
+ };
+
+ // Recursive function to scan directories
+ std::function scanDir = [&](const QDir &dir, const QString &relativePath) {
+ // Get all entries
+ QFileInfoList entries = dir.entryInfoList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
+
+ for (const QFileInfo &entry : entries) {
+ QString name = entry.fileName();
+ QString relPath = relativePath.isEmpty() ? name : relativePath + QStringLiteral("/") + name;
+
+ if (entry.isDir()) {
+ // Skip ignored directories
+ if (!ignoredDirs.contains(name)) {
+ scanDir(QDir(entry.filePath()), relPath);
+ }
+ } else if (entry.isFile()) {
+ files.append(relPath);
+ }
+ }
+ };
+
+ scanDir(rootDir, QString());
+
+ qDebug() << "[KateCode] Found" << files.size() << "files in project";
+ return files;
+}
+
+KTextEditor::Document *KateCodeView::findDocumentByPath(const QString &path) const
+{
+ if (path.isEmpty()) {
+ return nullptr;
+ }
+
+ // Normalize the file path
+ QString normalizedPath = QDir::cleanPath(path);
+ QUrl fileUrl = QUrl::fromLocalFile(normalizedPath);
+
+ // Get all documents from the application
+ KTextEditor::Application *app = KTextEditor::Editor::instance()->application();
+ if (!app) {
+ qWarning() << "[KateCodeView] No KTextEditor application available";
+ return nullptr;
+ }
+
+ const QList docs = app->documents();
+ for (KTextEditor::Document *doc : docs) {
+ if (doc->url() == fileUrl || doc->url().toLocalFile() == normalizedPath) {
+ return doc;
+ }
+ }
+
+ return nullptr;
+}
+
+void KateCodeView::addSelectionToContext()
+{
+ KTextEditor::View *view = m_mainWindow->activeView();
+ if (!view || !view->document()) {
+ qWarning() << "[KateCode] No active view or document";
+ return;
+ }
+
+ QString selection = view->selectionText();
+ if (selection.isEmpty()) {
+ qWarning() << "[KateCode] No text selected";
+ return;
+ }
+
+ QString filePath = view->document()->url().toLocalFile();
+ if (filePath.isEmpty()) {
+ qWarning() << "[KateCode] No file path for document";
+ return;
+ }
+
+ // Get line numbers for the selection
+ auto selectionRange = view->selectionRange();
+ int startLine = selectionRange.start().line() + 1; // Convert to 1-based
+ int endLine = selectionRange.end().line() + 1;
+
+ // Add to chat widget's context
+ if (m_chatWidget) {
+ m_chatWidget->addContextChunk(filePath, startLine, endLine, selection);
+ qDebug() << "[KateCode] Added selection to context:" << filePath
+ << "lines" << startLine << "-" << endLine;
+ } else {
+ qWarning() << "[KateCode] Chat widget not available";
+ }
+}
+
+void KateCodeView::explainCode()
+{
+ sendQuickAction(i18n("Please explain what this code does, including its purpose, key logic, and any important details."));
+}
+
+void KateCodeView::findBugs()
+{
+ sendQuickAction(i18n("Please analyze this code for potential bugs, errors, or issues. Consider edge cases, error handling, and correctness."));
+}
+
+void KateCodeView::suggestImprovements()
+{
+ sendQuickAction(i18n("Please suggest improvements for this code. Consider readability, performance, maintainability, and best practices."));
+}
+
+void KateCodeView::addTests()
+{
+ sendQuickAction(i18n("Please generate comprehensive test cases for this code. Include unit tests covering normal cases, edge cases, and error conditions."));
+}
+
+void KateCodeView::sendQuickAction(const QString &prompt)
+{
+ KTextEditor::View *view = m_mainWindow->activeView();
+ if (!view || !view->document()) {
+ qWarning() << "[KateCode] No active view or document for quick action";
+ return;
+ }
+
+ QString selection = view->selectionText();
+ if (selection.isEmpty()) {
+ qWarning() << "[KateCode] No text selected for quick action";
+ return;
+ }
+
+ QString filePath = view->document()->url().toLocalFile();
+
+ // Send prompt with selection through ChatWidget
+ if (m_chatWidget) {
+ m_chatWidget->sendPromptWithSelection(prompt, filePath, selection);
+
+ // Show and focus the chat panel so user sees response
+ m_mainWindow->showToolView(m_toolView);
+
+ qDebug() << "[KateCode] Sent quick action prompt with selection from:" << filePath;
+ } else {
+ qWarning() << "[KateCode] Chat widget not available for quick action";
+ }
+}
+
+void KateCodeView::prepareForShutdown()
+{
+ if (m_chatWidget) {
+ m_chatWidget->prepareForShutdown();
+ }
+}
+
+void KateCodeView::jumpToEdit(const QString &filePath, int startLine, int endLine)
+{
+ qDebug() << "[KateCodeView] jumpToEdit:" << filePath << "lines" << startLine << "-" << endLine;
+
+ if (filePath.isEmpty()) {
+ qWarning() << "[KateCodeView] Cannot jump to edit: empty file path";
+ return;
+ }
+
+ // Open the file if not already open
+ QUrl fileUrl = QUrl::fromLocalFile(filePath);
+ m_mainWindow->openUrl(fileUrl);
+
+ // Get the view for this document
+ KTextEditor::View *view = m_mainWindow->activeView();
+ if (!view) {
+ qWarning() << "[KateCodeView] No active view after opening file";
+ return;
+ }
+
+ // Verify we have the right document
+ if (view->document()->url().toLocalFile() != filePath) {
+ // Try to find the right view
+ KTextEditor::Application *app = KTextEditor::Editor::instance()->application();
+ const QList docs = app->documents();
+ for (KTextEditor::Document *doc : docs) {
+ if (doc->url().toLocalFile() == filePath) {
+ view = m_mainWindow->activateView(doc);
+ break;
+ }
+ }
+ }
+
+ if (!view) {
+ qWarning() << "[KateCodeView] Could not get view for file:" << filePath;
+ return;
+ }
+
+ // Calculate the selection range
+ // startLine and endLine are 0-based
+ int docLines = view->document()->lines();
+
+ // Clamp to valid range
+ int selStart = qBound(0, startLine, docLines - 1);
+ int selEnd = qBound(selStart, endLine, docLines);
+
+ // Create selection from start of startLine to start of endLine (or end of last line)
+ KTextEditor::Cursor startCursor(selStart, 0);
+ KTextEditor::Cursor endCursor;
+
+ if (selEnd >= docLines) {
+ // Select to end of document
+ int lastLine = docLines - 1;
+ endCursor = KTextEditor::Cursor(lastLine, view->document()->lineLength(lastLine));
+ } else {
+ // Select to start of endLine
+ endCursor = KTextEditor::Cursor(selEnd, 0);
+ }
+
+ KTextEditor::Range range(startCursor, endCursor);
+
+ // Set the selection and cursor position
+ view->setSelection(range);
+ view->setCursorPosition(startCursor);
+
+ qDebug() << "[KateCodeView] Selected range:" << selStart + 1 << "-" << selEnd + 1;
+}
diff --git a/src/plugin/KateCodeView.h b/src/plugin/KateCodeView.h
index 7938149..892de68 100644
--- a/src/plugin/KateCodeView.h
+++ b/src/plugin/KateCodeView.h
@@ -1,11 +1,13 @@
#pragma once
+#include
#include
#include
#include
class KateCodePlugin;
class ChatWidget;
+class DiffHighlightManager;
class QWidget;
class KateCodeView : public QObject, public KXMLGUIClient
@@ -20,12 +22,31 @@ class KateCodeView : public QObject, public KXMLGUIClient
QString getCurrentFilePath() const;
QString getCurrentSelection() const;
QString getProjectRoot() const;
+ QStringList getProjectFiles() const;
+ KTextEditor::Document *findDocumentByPath(const QString &path) const;
+
+ // Shutdown hook for summary generation
+ void prepareForShutdown();
+
+private Q_SLOTS:
+ void addSelectionToContext();
+
+ // Quick Actions
+ void explainCode();
+ void findBugs();
+ void suggestImprovements();
+ void addTests();
+
+ // Edit navigation
+ void jumpToEdit(const QString &filePath, int startLine, int endLine);
private:
+ void sendQuickAction(const QString &prompt);
void createToolView();
KateCodePlugin *m_plugin;
KTextEditor::MainWindow *m_mainWindow;
QWidget *m_toolView;
ChatWidget *m_chatWidget;
-};
+ DiffHighlightManager *m_diffHighlightManager;
+};
\ No newline at end of file
diff --git a/src/ui/ChatInputWidget.cpp b/src/ui/ChatInputWidget.cpp
index 39864eb..0859d63 100644
--- a/src/ui/ChatInputWidget.cpp
+++ b/src/ui/ChatInputWidget.cpp
@@ -1,34 +1,389 @@
#include "ChatInputWidget.h"
+#include
+#include
+#include
+#include
#include
+#include
+#include
+#include
#include
+#include
+#include
+#include
+#include
#include
+#include
+#include
+#include
+#include