From 442263a6a79d16cd7096e92047e61375e342dcbf Mon Sep 17 00:00:00 2001
From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com>
Date: Tue, 21 Jul 2026 13:10:00 +0200
Subject: [PATCH] feat: add support acp in common chat (#369)
---
CMakeLists.txt | 7 +
QodeAssist.qrc | 1 +
README.md | 58 +-
docs/acp-agents.md | 114 +
docs/agent-roles.md | 174 -
docs/chat-summarization.md | 28 +-
docs/file-context.md | 22 +-
docs/project-rules.md | 35 -
docs/quick-refactoring.md | 2 -
resources/agents/acp-registry-snapshot.json | 1351 ++++++
sources/CMakeLists.txt | 1 +
sources/ChatView/AgentRoleController.cpp | 124 -
sources/ChatView/AgentRoleController.hpp | 39 -
...tFileManager.cpp => AttachmentStaging.cpp} | 27 +-
...tFileManager.hpp => AttachmentStaging.hpp} | 7 +-
sources/ChatView/CMakeLists.txt | 17 +-
sources/ChatView/ChatCompressor.cpp | 63 +-
sources/ChatView/ChatCompressor.hpp | 14 +
.../ChatView/ChatConfigurationController.cpp | 86 +-
.../ChatView/ChatConfigurationController.hpp | 21 +
sources/ChatView/ChatController.cpp | 231 +-
sources/ChatView/ChatController.hpp | 53 +-
sources/ChatView/ChatFileStore.cpp | 429 ++
sources/ChatView/ChatFileStore.hpp | 89 +
sources/ChatView/ChatHistoryBridge.cpp | 107 -
sources/ChatView/ChatHistoryBridge.hpp | 37 -
sources/ChatView/ChatHistoryStore.cpp | 236 -
sources/ChatView/ChatHistoryStore.hpp | 51 -
sources/ChatView/ChatModel.cpp | 106 +-
sources/ChatView/ChatModel.hpp | 49 +-
sources/ChatView/ChatRootView.cpp | 739 ++--
sources/ChatView/ChatRootView.hpp | 149 +-
sources/ChatView/ChatSerializer.cpp | 169 -
sources/ChatView/ChatSerializer.hpp | 40 -
sources/ChatView/ConversationCoordinator.cpp | 373 ++
sources/ChatView/ConversationCoordinator.hpp | 100 +
sources/ChatView/ConversationPorts.hpp | 70 +
sources/ChatView/FileEditController.cpp | 44 +-
sources/ChatView/FileMentionItem.cpp | 14 +-
sources/ChatView/FileMentionItem.hpp | 6 +-
sources/ChatView/InputTokenCounter.cpp | 15 +-
sources/ChatView/InputTokenCounter.hpp | 2 -
sources/ChatView/LlmChatBackend.cpp | 198 +-
sources/ChatView/LlmChatBackend.hpp | 25 +-
sources/ChatView/TurnContextAdapters.cpp | 38 +-
sources/ChatView/TurnContextAdapters.hpp | 18 +-
sources/ChatView/icons/link-file-dark.svg | 12 -
sources/ChatView/icons/link-file-light.svg | 12 -
sources/ChatView/qml/RootItem.qml | 237 +-
.../ChatView/qml/chatparts/BlockPayload.js | 29 +
.../ChatView/qml/chatparts/FileEditBlock.qml | 29 +-
.../qml/chatparts/PermissionBlock.qml | 208 +
sources/ChatView/qml/chatparts/PlanBlock.qml | 151 +
sources/ChatView/qml/chatparts/ToolBlock.qml | 172 +-
sources/ChatView/qml/controls/BottomBar.qml | 39 +-
.../ChatView/qml/controls/ContextViewer.qml | 298 +-
sources/ChatView/qml/controls/DropZone.qml | 148 +
.../qml/controls/SkillCommandPopup.qml | 53 +-
.../ChatView/qml/controls/SplitDropZone.qml | 276 --
sources/ChatView/qml/controls/TopBar.qml | 76 +-
sources/acp/AcpChatBackend.cpp | 1067 +++++
sources/acp/AcpChatBackend.hpp | 135 +
sources/acp/AgentBinding.cpp | 88 +
sources/acp/AgentBinding.hpp | 30 +
sources/acp/AgentCatalog.cpp | 65 +
sources/acp/AgentCatalog.hpp | 35 +
sources/acp/AgentCatalogStore.cpp | 165 +
sources/acp/AgentCatalogStore.hpp | 57 +
sources/acp/AgentDefinition.cpp | 94 +
sources/acp/AgentDefinition.hpp | 69 +
sources/acp/AgentKnowledgeService.hpp | 21 +
sources/acp/AgentLaunch.cpp | 185 +
sources/acp/AgentLaunch.hpp | 29 +
sources/acp/AgentRegistryParser.cpp | 223 +
sources/acp/AgentRegistryParser.hpp | 30 +
sources/acp/AgentSpawn.cpp | 76 +
sources/acp/AgentSpawn.hpp | 30 +
sources/acp/AgentTester.cpp | 180 +
sources/acp/AgentTester.hpp | 50 +
sources/acp/CMakeLists.txt | 29 +
sources/acp/ChatPermissionProvider.cpp | 56 +
sources/acp/ChatPermissionProvider.hpp | 41 +
sources/completion/LLMClientInterface.cpp | 12 -
sources/completion/QodeAssistClient.cpp | 5 -
sources/context/CMakeLists.txt | 3 +-
sources/context/ContentFile.hpp | 1 +
sources/context/ContextManager.cpp | 16 +-
sources/context/ContextManager.hpp | 1 +
sources/context/DocumentContextReader.cpp | 6 -
...ChangesManager.cpp => FileEditManager.cpp} | 134 +-
.../{ChangesManager.h => FileEditManager.hpp} | 53 +-
sources/context/IContextManager.hpp | 1 +
sources/context/RulesLoader.cpp | 166 -
sources/context/RulesLoader.hpp | 42 -
sources/external/llmqore | 2 +-
sources/mcp/AgentKnowledgeServer.cpp | 129 +
sources/mcp/AgentKnowledgeServer.hpp | 48 +
sources/plugin/qodeassist.cpp | 16 +-
sources/refactor/QuickRefactorHandler.cpp | 12 -
sources/session/AgentPlan.cpp | 57 +
sources/session/AgentPlan.hpp | 49 +
sources/session/BlockCodec.cpp | 38 +
sources/session/BlockCodec.hpp | 32 +
sources/session/CMakeLists.txt | 5 +
sources/session/ChatBackend.hpp | 12 +
sources/session/ContentBlock.hpp | 22 +-
sources/session/FencedText.cpp | 36 +
sources/session/FencedText.hpp | 13 +
sources/session/FileEditPayload.cpp | 31 +-
sources/session/HistoryProjection.cpp | 141 +-
sources/session/HistoryProjection.hpp | 27 +-
sources/session/HistorySerializer.cpp | 31 +-
sources/session/PermissionRequest.cpp | 116 +
sources/session/PermissionRequest.hpp | 87 +
sources/session/Session.cpp | 455 +-
sources/session/Session.hpp | 27 +-
sources/session/SessionEvent.cpp | 9 +-
sources/session/SessionEvent.hpp | 55 +-
sources/session/TurnContext.cpp | 12 -
sources/session/TurnContext.hpp | 11 +-
sources/session/TurnContextBuilder.cpp | 15 +-
sources/session/TurnContextBuilder.hpp | 17 +-
sources/session/TurnContextPorts.hpp | 12 -
sources/session/TurnLedger.cpp | 103 +
sources/session/TurnLedger.hpp | 50 +
sources/session/TurnRequest.hpp | 9 -
sources/settings/AgentRole.cpp | 211 -
sources/settings/AgentRole.hpp | 67 -
sources/settings/AgentRoleDialog.cpp | 110 -
sources/settings/AgentRoleDialog.hpp | 50 -
sources/settings/AgentRolesWidget.cpp | 242 -
sources/settings/AgentRolesWidget.hpp | 43 -
sources/settings/AgentsSettings.cpp | 56 +
sources/settings/AgentsSettings.hpp | 22 +
sources/settings/AgentsWidget.cpp | 348 ++
sources/settings/AgentsWidget.hpp | 74 +
sources/settings/CMakeLists.txt | 3 -
sources/settings/ChatAssistantSettings.cpp | 24 -
sources/settings/ChatAssistantSettings.hpp | 4 -
sources/settings/CodeCompletionSettings.cpp | 13 +-
sources/settings/CodeCompletionSettings.hpp | 2 -
sources/settings/SettingsConstants.hpp | 9 +-
sources/tools/EditFileTool.cpp | 18 +-
sources/tools/EditorStateTools.cpp | 266 ++
sources/tools/EditorStateTools.hpp | 66 +
sources/tools/FindFileTool.hpp | 1 +
sources/tools/GetIssuesListTool.hpp | 1 +
sources/tools/ListProjectFilesTool.hpp | 1 +
sources/tools/ProjectSearchTool.hpp | 1 +
sources/tools/ReadFileTool.hpp | 1 +
sources/tools/ReadOriginalHistoryTool.cpp | 6 +
sources/tools/ReadOriginalHistoryTool.hpp | 1 +
sources/tools/SkillTool.hpp | 1 +
sources/tools/TodoTool.hpp | 1 +
tests/FakeAcpAgent.hpp | 324 ++
tests/FakeLlmProvider.hpp | 184 +
tests/QodeAssistTest.cpp | 3910 ++++++++++++++++-
tests/QodeAssistTest.hpp | 135 +-
158 files changed, 14270 insertions(+), 4118 deletions(-)
create mode 100644 docs/acp-agents.md
delete mode 100644 docs/agent-roles.md
delete mode 100644 docs/project-rules.md
create mode 100644 resources/agents/acp-registry-snapshot.json
delete mode 100644 sources/ChatView/AgentRoleController.cpp
delete mode 100644 sources/ChatView/AgentRoleController.hpp
rename sources/ChatView/{ChatFileManager.cpp => AttachmentStaging.cpp} (86%)
rename sources/ChatView/{ChatFileManager.hpp => AttachmentStaging.hpp} (88%)
create mode 100644 sources/ChatView/ChatFileStore.cpp
create mode 100644 sources/ChatView/ChatFileStore.hpp
delete mode 100644 sources/ChatView/ChatHistoryBridge.cpp
delete mode 100644 sources/ChatView/ChatHistoryBridge.hpp
delete mode 100644 sources/ChatView/ChatHistoryStore.cpp
delete mode 100644 sources/ChatView/ChatHistoryStore.hpp
delete mode 100644 sources/ChatView/ChatSerializer.cpp
delete mode 100644 sources/ChatView/ChatSerializer.hpp
create mode 100644 sources/ChatView/ConversationCoordinator.cpp
create mode 100644 sources/ChatView/ConversationCoordinator.hpp
create mode 100644 sources/ChatView/ConversationPorts.hpp
delete mode 100644 sources/ChatView/icons/link-file-dark.svg
delete mode 100644 sources/ChatView/icons/link-file-light.svg
create mode 100644 sources/ChatView/qml/chatparts/BlockPayload.js
create mode 100644 sources/ChatView/qml/chatparts/PermissionBlock.qml
create mode 100644 sources/ChatView/qml/chatparts/PlanBlock.qml
create mode 100644 sources/ChatView/qml/controls/DropZone.qml
delete mode 100644 sources/ChatView/qml/controls/SplitDropZone.qml
create mode 100644 sources/acp/AcpChatBackend.cpp
create mode 100644 sources/acp/AcpChatBackend.hpp
create mode 100644 sources/acp/AgentBinding.cpp
create mode 100644 sources/acp/AgentBinding.hpp
create mode 100644 sources/acp/AgentCatalog.cpp
create mode 100644 sources/acp/AgentCatalog.hpp
create mode 100644 sources/acp/AgentCatalogStore.cpp
create mode 100644 sources/acp/AgentCatalogStore.hpp
create mode 100644 sources/acp/AgentDefinition.cpp
create mode 100644 sources/acp/AgentDefinition.hpp
create mode 100644 sources/acp/AgentKnowledgeService.hpp
create mode 100644 sources/acp/AgentLaunch.cpp
create mode 100644 sources/acp/AgentLaunch.hpp
create mode 100644 sources/acp/AgentRegistryParser.cpp
create mode 100644 sources/acp/AgentRegistryParser.hpp
create mode 100644 sources/acp/AgentSpawn.cpp
create mode 100644 sources/acp/AgentSpawn.hpp
create mode 100644 sources/acp/AgentTester.cpp
create mode 100644 sources/acp/AgentTester.hpp
create mode 100644 sources/acp/CMakeLists.txt
create mode 100644 sources/acp/ChatPermissionProvider.cpp
create mode 100644 sources/acp/ChatPermissionProvider.hpp
rename sources/context/{ChangesManager.cpp => FileEditManager.cpp} (95%)
rename sources/context/{ChangesManager.h => FileEditManager.hpp} (87%)
delete mode 100644 sources/context/RulesLoader.cpp
delete mode 100644 sources/context/RulesLoader.hpp
create mode 100644 sources/mcp/AgentKnowledgeServer.cpp
create mode 100644 sources/mcp/AgentKnowledgeServer.hpp
create mode 100644 sources/session/AgentPlan.cpp
create mode 100644 sources/session/AgentPlan.hpp
create mode 100644 sources/session/BlockCodec.cpp
create mode 100644 sources/session/BlockCodec.hpp
create mode 100644 sources/session/FencedText.cpp
create mode 100644 sources/session/FencedText.hpp
create mode 100644 sources/session/PermissionRequest.cpp
create mode 100644 sources/session/PermissionRequest.hpp
create mode 100644 sources/session/TurnLedger.cpp
create mode 100644 sources/session/TurnLedger.hpp
delete mode 100644 sources/settings/AgentRole.cpp
delete mode 100644 sources/settings/AgentRole.hpp
delete mode 100644 sources/settings/AgentRoleDialog.cpp
delete mode 100644 sources/settings/AgentRoleDialog.hpp
delete mode 100644 sources/settings/AgentRolesWidget.cpp
delete mode 100644 sources/settings/AgentRolesWidget.hpp
create mode 100644 sources/settings/AgentsSettings.cpp
create mode 100644 sources/settings/AgentsSettings.hpp
create mode 100644 sources/settings/AgentsWidget.cpp
create mode 100644 sources/settings/AgentsWidget.hpp
create mode 100644 sources/tools/EditorStateTools.cpp
create mode 100644 sources/tools/EditorStateTools.hpp
create mode 100644 tests/FakeAcpAgent.hpp
create mode 100644 tests/FakeLlmProvider.hpp
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 1ff59e9..1daeece 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -64,6 +64,7 @@ add_qtc_plugin(QodeAssist
LLMQore
Skills
QodeAssistSession
+ QodeAssistAcp
QodeAssistChatViewplugin
SOURCES
.github/workflows/build_cmake.yml
@@ -167,10 +168,14 @@ add_qtc_plugin(QodeAssist
sources/tools/TodoTool.hpp sources/tools/TodoTool.cpp
sources/tools/ReadOriginalHistoryTool.hpp sources/tools/ReadOriginalHistoryTool.cpp
sources/tools/SkillTool.hpp sources/tools/SkillTool.cpp
+ sources/tools/EditorStateTools.hpp sources/tools/EditorStateTools.cpp
+ sources/mcp/AgentKnowledgeServer.hpp sources/mcp/AgentKnowledgeServer.cpp
sources/mcp/McpServerManager.hpp sources/mcp/McpServerManager.cpp
sources/mcp/McpServerConnection.hpp sources/mcp/McpServerConnection.cpp
sources/mcp/McpClientsManager.hpp sources/mcp/McpClientsManager.cpp
sources/settings/McpClientsListAspect.hpp sources/settings/McpClientsListAspect.cpp
+ sources/settings/AgentsSettings.hpp sources/settings/AgentsSettings.cpp
+ sources/settings/AgentsWidget.hpp sources/settings/AgentsWidget.cpp
)
target_include_directories(QodeAssist PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/sources)
@@ -180,6 +185,8 @@ extend_qtc_plugin(QodeAssist
DEPENDS Qt::Test
SOURCES
tests/QodeAssistTest.hpp tests/QodeAssistTest.cpp
+ tests/FakeAcpAgent.hpp
+ tests/FakeLlmProvider.hpp
)
if(WITH_TESTS)
diff --git a/QodeAssist.qrc b/QodeAssist.qrc
index f75afc4..6906e4e 100644
--- a/QodeAssist.qrc
+++ b/QodeAssist.qrc
@@ -10,5 +10,6 @@
resources/images/suggest-new-icon@2x.png
resources/images/qode-assist-chat-icon.png
resources/images/qode-assist-chat-icon@2x.png
+ resources/agents/acp-registry-snapshot.json
diff --git a/README.md b/README.md
index d83740e..d564139 100644
--- a/README.md
+++ b/README.md
@@ -38,10 +38,10 @@ QodeAssist enhances Qt Creator with AI-powered coding assistance:
- **Agent Skills** — reusable folders of specialized instructions loaded on demand; discovered from `.qodeassist/skills/` and `.claude/skills/`, invoked automatically, with `/skill`, or always-on
- **MCP Server** — expose QodeAssist's project-aware tools to external MCP clients (Claude Code, VS Code, Claude Desktop via bridge)
- **MCP Client Hub** — connect QodeAssist to external MCP servers and use their tools in Chat and Quick Refactor (authenticated MCP servers are not supported yet)
-- **File Context** — attach, link, or auto-sync open editor files for richer prompts
+- **File Context** — attach files and images to your messages for richer prompts
- **Many Providers** — Ollama, llama.cpp, LM Studio (Chat + Responses), Claude, OpenAI (Chat + Responses), Google AI, Mistral, Codestral, OpenRouter, Qwen (OpenAI + Responses), DeepSeek, any OpenAI-compatible endpoint
- **Reasoning / Thinking** — streamed chain-of-thought is shown for reasoning models across Claude, Google, OpenAI Responses, and any OpenAI-compatible endpoint that returns `reasoning_content` (DeepSeek, Qwen QwQ/Qwen3-Thinking, LM Studio, OpenRouter, …)
-- **Customizable** — per-project rules (`.qodeassist/rules/`), agent roles, reusable refactor templates, full prompt-template control
+- **Customizable** — per-project and global agent skills, reusable refactor templates, full prompt-template control
**Join our [Discord Community](https://discord.gg/BGMkUsXUgf)** to get support and connect with other users!
@@ -217,9 +217,7 @@ For optimal coding assistance, we recommend using these top-tier models:
### Additional Configuration
-- **[Agent Roles](docs/agent-roles.md)** - Create AI personas with specialized system prompts
- **[Chat Summarization](docs/chat-summarization.md)** - Compress conversations to save context tokens
-- **[Project Rules](docs/project-rules.md)** - Customize AI behavior for your project
- **[Ignoring Files](docs/ignoring-files.md)** - Exclude files from context using `.qodeassistignore`
## Features
@@ -255,10 +253,8 @@ Configure in: `Tools → Options → QodeAssist → Code Completion → General
- Multiple chat panels: side panel, bottom panel, and popup window
- Chat history with auto-save and restore
- Token usage monitoring
-- **[Agent Roles](docs/agent-roles.md)** - Switch between AI personas (Developer, Reviewer, custom roles)
- **[Chat Summarization](docs/chat-summarization.md)** - Compress long conversations into AI-generated summaries
-- **[File Context](docs/file-context.md)** - Attach or link files for better context
-- Automatic syncing with open editor files (optional)
+- **[File Context](docs/file-context.md)** - Attach files for better context
- Extended thinking / reasoning mode - shows streamed chain-of-thought for reasoning models (Claude, Google, OpenAI Responses, and OpenAI-compatible endpoints returning `reasoning_content` such as DeepSeek, Qwen, LM Studio, OpenRouter)
### Quick Refactoring
@@ -350,15 +346,13 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
│ Examples: Codestral, Qwen2.5-Coder, DeepSeek-Coder │
│ │
│ 1. System Prompt (from Code Completion Settings - FIM variant) │
-│ 2. Project Rules: │
-│ └─ .qodeassist/rules/completion/*.md │
-│ 3. Open Files Context (optional, if enabled): │
+│ 2. Open Files Context (optional, if enabled): │
│ └─ Currently open editor files │
-│ 4. Code Context: │
+│ 3. Code Context: │
│ ├─ Code before cursor (prefix) │
│ └─ Code after cursor (suffix) │
│ │
-│ Final Prompt: FIM_Template(Prefix: SystemPrompt + Rules + OpenFiles + │
+│ Final Prompt: FIM_Template(Prefix: SystemPrompt + OpenFiles + │
│ CodeBefore, │
│ Suffix: CodeAfter) │
└─────────────────────────────────────────────────────────────────────────────┘
@@ -377,18 +371,16 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
│ │
│ 1. System Prompt (from Code Completion Settings - Non-FIM variant) │
│ └─ Includes response formatting instructions │
-│ 2. Project Rules: │
-│ └─ .qodeassist/rules/completion/*.md │
-│ 3. Open Files Context (optional, if enabled): │
+│ 2. Open Files Context (optional, if enabled): │
│ └─ Currently open editor files │
-│ 4. Code Context: │
+│ 3. Code Context: │
│ ├─ File information (language, path) │
│ ├─ Code before cursor │
│ ├─ marker │
│ └─ Code after cursor │
-│ 5. User Message: "Complete the code at cursor position" │
+│ 4. User Message: "Complete the code at cursor position" │
│ │
-│ Final Prompt: [System: SystemPrompt + Rules] │
+│ Final Prompt: [System: SystemPrompt] │
│ [User: OpenFiles + Context + CompletionRequest] │
└─────────────────────────────────────────────────────────────────────────────┘
```
@@ -403,15 +395,10 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
│ CHAT ASSISTANT │
├─────────────────────────────────────────────────────────────────────────────┤
│ 1. System Prompt (from Chat Assistant Settings) │
-│ 2. Agent Role (optional, from role selector): │
-│ └─ Role-specific system prompt (Developer, Reviewer, custom) │
-│ 3. Project Rules: │
-│ ├─ .qodeassist/rules/common/*.md │
-│ └─ .qodeassist/rules/chat/*.md │
+│ 2. Project Info (active project name, source root, build directory) │
+│ 3. Skills (always-on bodies + catalog + /invoked skills) │
│ 4. File Context (optional): │
-│ ├─ Attached files (manual) │
-│ ├─ Linked files (persistent) │
-│ └─ Open editor files (if auto-sync enabled) │
+│ └─ Attached files (manual) │
│ 5. Tool Definitions (if enabled): │
│ ├─ ReadProjectFileByName │
│ ├─ ListProjectFiles │
@@ -420,7 +407,7 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
│ 6. Conversation History │
│ 7. User Message │
│ │
-│ Final Prompt: [System: SystemPrompt + AgentRole + Rules + Tools] │
+│ Final Prompt: [System: SystemPrompt + ProjectInfo + Skills + Tools] │
│ [History: Previous messages] │
│ [User: FileContext + UserMessage] │
└─────────────────────────────────────────────────────────────────────────────┘
@@ -436,10 +423,7 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
│ QUICK REFACTORING │
├─────────────────────────────────────────────────────────────────────────────┤
│ 1. System Prompt (from Quick Refactor Settings) │
-│ 2. Project Rules: │
-│ ├─ .qodeassist/rules/common/*.md │
-│ └─ .qodeassist/rules/quickrefactor/*.md │
-│ 3. Code Context: │
+│ 2. Code Context: │
│ ├─ File information (language, path) │
│ ├─ Code before selection (configurable amount) │
│ ├─ marker │
@@ -447,15 +431,15 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
│ ├─ marker │
│ ├─ marker (position within selection) │
│ └─ Code after selection (configurable amount) │
-│ 4. Refactor Instruction: │
+│ 3. Refactor Instruction: │
│ ├─ Built-in (e.g., "Improve Code", "Alternative Solution") │
│ ├─ Custom Instruction (from library) │
│ │ └─ ~/.config/QtProject/qtcreator/qodeassist/ │
│ │ quick_refactor/instructions/*.json │
│ └─ Additional Details (optional user input) │
-│ 5. Tool Definitions (if enabled) │
+│ 4. Tool Definitions (if enabled) │
│ │
-│ Final Prompt: [System: SystemPrompt + Rules] │
+│ Final Prompt: [System: SystemPrompt] │
│ [User: Context + Markers + Instruction + Details] │
└─────────────────────────────────────────────────────────────────────────────┘
```
@@ -464,9 +448,8 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
### Key Points
-- **Project Rules** are automatically loaded from `.qodeassist/rules/` directory structure
- **System Prompts** are configured independently for each feature in Settings
-- **Agent Roles** add role-specific prompts on top of the base system prompt (Chat only)
+- **Skills** provide reusable, project- or globally-scoped instructions for Chat: always-on skills are injected into the system prompt, others are invoked with `/skill-name`
- **FIM vs Non-FIM models** for code completion use different System Prompts:
- FIM models: Direct completion prompt
- Non-FIM models: Prompt includes response formatting instructions
@@ -474,7 +457,7 @@ QodeAssist uses a flexible prompt composition system that adapts to different co
- **Custom Instructions** provide reusable templates that can be augmented with specific details
- **Tool Calling** is available for Chat and Quick Refactor when enabled
-See [Project Rules Documentation](docs/project-rules.md), [Agent Roles Guide](docs/agent-roles.md), and [Quick Refactoring Guide](docs/quick-refactoring.md) for more details.
+See the [Quick Refactoring Guide](docs/quick-refactoring.md) for more details.
## QtCreator Version Compatibility
@@ -522,7 +505,6 @@ For additional support, join our [Discord Community](https://discord.gg/BGMkUsXU
- [x] Diff sharing with models
- [x] Tools / function calling (file I/O, build, terminal, diagnostics)
- [x] Agent Skills (project + global directories, `/skill` commands, always-on, `load_skill` tool)
-- [x] Project-specific rules (`.qodeassist/rules/`)
- [x] MCP (Model Context Protocol) — QodeAssist as a server
- [x] MCP — QodeAssist as a client (consume external MCP tools; authenticated MCP servers not yet supported)
- [ ] Full project source sharing
diff --git a/docs/acp-agents.md b/docs/acp-agents.md
new file mode 100644
index 0000000..8484d0b
--- /dev/null
+++ b/docs/acp-agents.md
@@ -0,0 +1,114 @@
+# ACP agents
+
+QodeAssist can talk to external coding agents that speak the
+[Agent Client Protocol](https://agentclientprotocol.com) (ACP). The **QodeAssist >
+Agents** settings page lists the agents it knows about and lets you verify that one
+starts and answers the ACP handshake.
+
+## Where the list comes from
+
+The list is merged from three sources. When the same agent `id` appears in more than
+one, the higher entry wins:
+
+1. **Your JSON files** in `qodeassist/agents/` inside the Qt Creator user resource
+ directory (**Open Agents Folder...** opens it). Press **Reload** after editing.
+2. **The ACP registry**, downloaded from
+ `https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json` when you press
+ **Refresh from Registry** and cached on disk. There is no background polling: the
+ list only changes when you ask for it.
+3. **A bundled snapshot**, so the list is never empty on a fresh install or offline.
+
+If a download fails, the cached copy and the bundled snapshot stay in place.
+
+## Finding the agent's executable
+
+Qt Creator started from the Dock or a launcher does not inherit your shell's `PATH`, so
+`npx` or `uvx` installed through Homebrew, nvm or uv is often invisible to it — the agent
+then fails to start with `execve: No such file or directory`. **Extra PATH for launching
+agents** on the Agents page lists directories that are both searched for the executable
+and prepended to the agent's own `PATH`. On macOS it defaults to
+`/opt/homebrew/bin:/usr/local/bin`. An `env` entry in the agent definition still wins over
+it.
+
+## Credentials and other environment variables
+
+Some agents authenticate through an environment variable rather than the ACP handshake —
+the Claude adapter, for instance, declares no `authMethods` at all and reads
+`CLAUDE_CODE_OAUTH_TOKEN`. Since a Qt Creator started from the dock inherits no shell
+environment, that variable never reaches the agent and it fails with an expired-session
+error.
+
+**Forward these variables to agents** on the Agents page takes variable *names*, not
+values, and defaults to `CLAUDE_CODE_OAUTH_TOKEN`. For each name QodeAssist uses the value
+from its own environment when it has one — which covers Windows, and any platform when Qt
+Creator was launched from a terminal. On macOS and Linux the remaining names are read once
+per session from a login shell (`$SHELL -l -i -c env`), so a token exported in your shell
+profile works without being copied anywhere. Nothing is stored in the settings but the
+names.
+
+An `env` entry in the agent definition still wins over a forwarded variable.
+
+## Distributions
+
+An entry describes how the agent is started:
+
+- `npx` — launched as `npx -y `. Requires Node.js on `PATH`.
+- `uvx` — launched as `uvx `. Requires uv on `PATH`.
+- `command` — launched directly. This is the QodeAssist extension used by your own
+ JSON files.
+- `binary` — a downloadable platform archive. QodeAssist does not download binaries,
+ so these agents are listed but cannot be started. Install the agent yourself and add
+ a `command` entry for it.
+
+## Defining your own agent
+
+Create a `.json` file in the agents folder. A file holds either a single agent object
+or a registry-shaped `{"agents": [...]}` document.
+
+```json
+{
+ "id": "my-agent",
+ "name": "My Agent",
+ "version": "1.0.0",
+ "description": "Locally installed agent",
+ "distribution": {
+ "command": {
+ "cmd": "/usr/local/bin/my-agent",
+ "args": ["acp"],
+ "env": { "MY_AGENT_LOG": "debug" }
+ }
+ }
+}
+```
+
+Reusing an `id` from the registry overrides that entry — which is how you make a
+`binary` agent launchable after installing it manually:
+
+```json
+{
+ "id": "cursor",
+ "name": "Cursor",
+ "distribution": {
+ "command": { "cmd": "/usr/local/bin/cursor-agent", "args": ["acp"] }
+ }
+}
+```
+
+Agent definitions hold no secrets. Agents that need credentials authenticate through
+the ACP handshake.
+
+## Testing an agent
+
+Select an agent and press **Test**. QodeAssist starts the process, runs the ACP
+`initialize` handshake and reports the agent's name, version, protocol version,
+whether it supports session persistence (`loadSession`), its prompt and MCP
+capabilities, and its authentication methods. On failure it shows the error together
+with the agent's own output.
+
+## Long conversations and reopened sessions
+
+When an agent conversation grows long, the **Hand over** button in the chat bottom
+bar summarizes the transcript with your Chat Assistant configuration and continues in
+a fresh agent session seeded with that summary — see
+[chat-summarization.md](chat-summarization.md) for details. The same handover is
+offered on the read-only banner when a saved agent session can no longer be resumed.
diff --git a/docs/agent-roles.md b/docs/agent-roles.md
deleted file mode 100644
index c8195a5..0000000
--- a/docs/agent-roles.md
+++ /dev/null
@@ -1,174 +0,0 @@
-# Agent Roles
-
-Agent Roles allow you to define different AI personas with specialized system prompts for various tasks. Switch between roles instantly in the chat interface to adapt the AI's behavior to your current needs.
-
-## Overview
-
-Agent Roles are reusable system prompt configurations that modify how the AI assistant responds. Instead of manually changing system prompts, you can create roles like "Developer", "Code Reviewer", or "Documentation Writer" and switch between them with a single click.
-
-**Key Features:**
-- **Quick Switching**: Change roles from the chat toolbar dropdown
-- **Custom Prompts**: Each role has its own specialized system prompt
-- **Built-in Roles**: Pre-configured Developer and Code Reviewer roles
-- **Persistent**: Roles are saved locally and loaded on startup
-- **Extensible**: Create unlimited custom roles for different tasks
-
-## Default Roles
-
-QodeAssist comes with three built-in roles:
-
-### Developer
-Experienced Qt/C++ developer with a structured workflow: analyze the problem, propose a solution, wait for approval, then implement. Best for implementation tasks where you want thoughtful, minimal code changes.
-
-### Code Reviewer
-Expert C++/QML code reviewer specializing in C++20 and Qt6. Checks for bugs, memory leaks, thread safety, Qt patterns, and production readiness. Provides direct, specific feedback with code examples.
-
-### Researcher
-Research-oriented developer who investigates problems and explores solutions. Analyzes problems, presents multiple approaches with trade-offs, and recommends the best option. Does not write implementation code — focuses on helping you make informed decisions.
-
-## Using Agent Roles
-
-### Switching Roles in Chat
-
-1. Open the Chat Assistant (side panel, bottom panel, or popup window)
-2. Locate the **Role selector** dropdown in the top toolbar (next to the configuration selector)
-3. Select a role from the dropdown
-4. The AI will now use the selected role's system prompt
-
-**Note**: Selecting "No Role" uses only the base system prompt without role specialization.
-
-### Viewing Active Role
-
-Click the **Context** button (📋) in the chat toolbar to view:
-- Base system prompt
-- Current agent role and its system prompt
-- Active project rules
-
-## Managing Agent Roles
-
-### Opening the Role Manager
-
-Navigate to: `Qt Creator → Preferences → QodeAssist → Chat Assistant`
-
-Scroll down to the **Agent Roles** section where you can manage all your roles.
-
-### Creating a New Role
-
-1. Click **Add...** button
-2. Fill in the role details:
- - **Name**: Display name shown in the dropdown (e.g., "Documentation Writer")
- - **ID**: Unique identifier for the role file (e.g., "doc_writer")
- - **Description**: Brief explanation of the role's purpose
- - **System Prompt**: The specialized instructions for this role
-3. Click **OK** to save
-
-### Editing a Role
-
-1. Select a role from the list
-2. Click **Edit...** or double-click the role
-3. Modify the fields as needed
-4. Click **OK** to save changes
-
-**Note**: Built-in roles cannot be edited directly. Duplicate them to create a modifiable copy.
-
-### Duplicating a Role
-
-1. Select a role to duplicate
-2. Click **Duplicate...**
-3. Modify the copy as needed
-4. Click **OK** to save as a new role
-
-### Deleting a Role
-
-1. Select a custom role (built-in roles cannot be deleted)
-2. Click **Delete**
-3. Confirm deletion
-
-## Creating Effective Roles
-
-### System Prompt Tips
-
-- **Be specific**: Clearly define the role's expertise and focus areas
-- **Set expectations**: Describe the desired response format and style
-- **Include guidelines**: Add specific rules or constraints for responses
-- **Use structured prompts**: Break down complex roles into bullet points
-
-## Storage Location
-
-Agent roles are stored as JSON files in:
-
-```
-~/.config/QtProject/qtcreator/qodeassist/agent_roles/
-```
-
-**On different platforms:**
-- **Linux**: `~/.config/QtProject/qtcreator/qodeassist/agent_roles/`
-- **macOS**: `~/Library/Application Support/QtProject/Qt Creator/qodeassist/agent_roles/`
-- **Windows**: `%APPDATA%\QtProject\qtcreator\qodeassist\agent_roles\`
-
-### File Format
-
-Each role is stored as a JSON file named `{id}.json`:
-
-```json
-{
- "id": "doc_writer",
- "name": "Documentation Writer",
- "description": "Technical documentation and code comments",
- "systemPrompt": "You are a technical documentation specialist...",
- "isBuiltin": false
-}
-```
-
-### Manual Editing
-
-You can:
-- Edit JSON files directly in any text editor
-- Copy role files between machines
-- Share roles with team members
-- Version control your roles
-- Click **Open Roles Folder...** to quickly access the directory
-
-## How Roles Work
-
-When a role is selected, the final system prompt is composed as:
-
-```
-┌─────────────────────────────────────────────────┐
-│ Final System Prompt = Base Prompt + Role Prompt │
-├─────────────────────────────────────────────────┤
-│ 1. Base System Prompt (from Chat Settings) │
-│ 2. Agent Role System Prompt │
-│ 3. Project Rules (common/ + chat/) │
-│ 4. Linked Files Context │
-└─────────────────────────────────────────────────┘
-```
-
-This allows roles to augment rather than replace your base configuration.
-
-## Best Practices
-
-1. **Keep roles focused**: Each role should have a clear, specific purpose
-2. **Use descriptive names**: Make it easy to identify roles at a glance
-3. **Test your prompts**: Verify roles produce the expected behavior
-4. **Iterate and improve**: Refine prompts based on AI responses
-5. **Share with team**: Export and share useful roles with colleagues
-
-## Troubleshooting
-
-### Role Not Appearing in Dropdown
-- Restart Qt Creator after adding roles manually
-- Check JSON file format validity
-- Verify file is in the correct directory
-
-### Role Behavior Not as Expected
-- Review the system prompt for clarity
-- Check if base system prompt conflicts with role prompt
-- Try a more specific or detailed prompt
-
-## Related Documentation
-
-- [Project Rules](project-rules.md) - Project-specific AI behavior customization
-- [Chat Assistant Features](../README.md#chat-assistant) - Overview of chat functionality
-- [File Context](file-context.md) - Attaching files to chat context
-
diff --git a/docs/chat-summarization.md b/docs/chat-summarization.md
index a98d030..b659590 100644
--- a/docs/chat-summarization.md
+++ b/docs/chat-summarization.md
@@ -11,7 +11,7 @@ When conversations grow long, they consume more context tokens with each message
- Important context for continuing the conversation
**Key Features:**
-- **One-click compression**: Summarize directly from the chat toolbar
+- **One-click compression**: Summarize directly from the chat bottom bar
- **Preserves original**: Creates a new chat file, keeping the original intact
- **Smart summaries**: AI extracts the most relevant information
- **Markdown formatted**: Summaries are well-structured and readable
@@ -21,10 +21,23 @@ When conversations grow long, they consume more context tokens with each message
### Compressing a Chat
1. Open any chat with conversation history
-2. Click the **Compress** button (📦) in the chat top bar
+2. Click the **Compress** button (📦) in the chat bottom bar
3. Wait for the AI to generate the summary
4. A new chat opens with the compressed summary
+### Agent Sessions: Hand Over
+
+In a chat bound to an ACP agent the same button reads **Hand over**. Instead of
+writing a compressed chat file, it summarizes the transcript using your configured
+Chat Assistant provider (the same LLM that powers compression) and starts a fresh
+agent session with the summary as its opening context. The transcript in the chat
+stays intact; only the agent-side session is replaced.
+
+The button is disabled with an explanatory tooltip when the Chat Assistant
+configuration is incomplete (provider, model, template, or URL missing), because the
+summary is generated by that configuration. Auto-compression never triggers for agent
+sessions — the agent manages its own context window.
+
### What Gets Preserved
The summarization process:
@@ -94,9 +107,12 @@ No additional configuration is required.
## Troubleshooting
-### Compression Button Not Visible
-- Ensure you have an active chat with messages
-- Check that the chat top bar is visible
+### Compression Button Disabled
+- Hover the button: the tooltip explains why (for example, the Chat Assistant
+ configuration is unassigned, or an agent chat has nothing to summarize yet)
+- Assign the chat feature in Preferences → QodeAssist if the tooltip says so
+- While a compression or summary is running, the button is replaced by a progress
+ indicator with a Cancel action in the bottom bar
### Compression Fails
- Verify your Chat Assistant provider is configured correctly
@@ -110,6 +126,4 @@ No additional configuration is required.
## Related Documentation
-- [Agent Roles](agent-roles.md) - Switch between AI personas
- [File Context](file-context.md) - Attach files to chat
-- [Project Rules](project-rules.md) - Customize AI behavior
diff --git a/docs/file-context.md b/docs/file-context.md
index 04892b8..7d0a0d2 100644
--- a/docs/file-context.md
+++ b/docs/file-context.md
@@ -1,6 +1,6 @@
# File Context Feature
-QodeAssist provides two powerful ways to include source code files in your chat conversations: Attachments and Linked Files. Each serves a distinct purpose and helps provide better context for the AI assistant.
+QodeAssist lets you include source code files in your chat conversations as attachments.
## Attached Files
@@ -14,20 +14,8 @@ Attachments are designed for one-time code analysis and specific queries:
- Quick implementation questions
- Files can be attached using the paperclip icon in the chat interface
- Multiple files can be attached to a single message
+- Files can also be added by dragging them onto the chat panel or with `@` mentions
-## Linked Files
-
-Linked files provide persistent context throughout the conversation:
-
-- Files remain accessible for the entire chat session
-- Content is included in every message exchange
-- Files are automatically refreshed - always using latest content from disk
-- Perfect for:
- - Long-term refactoring discussions
- - Complex architectural changes
- - Multi-file implementations
- - Maintaining context across related questions
-- Can be managed using the link icon in the chat interface
-- Supports automatic syncing with open editor files (can be enabled in settings)
-- Files can be added/removed at any time during the conversation
-
+For persistent, conversation-long context, enable tools (or use an ACP agent): the
+model reads the files it needs from the project itself, always seeing the latest
+content from disk.
diff --git a/docs/project-rules.md b/docs/project-rules.md
deleted file mode 100644
index b8bf9d3..0000000
--- a/docs/project-rules.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# Project Rules Configuration
-
-QodeAssist supports project-specific rules to customize AI behavior for your codebase. Create a `.qodeassist/rules/` directory in your project root.
-
-## Quick Start
-
-```bash
-mkdir -p .qodeassist/rules/{common,completion,chat,quickrefactor}
-```
-
-## Directory Structure
-
-```
-.qodeassist/
-└── rules/
- ├── common/ # Applied to all contexts
- ├── completion/ # Code completion only
- ├── chat/ # Chat assistant only
- └── quickrefactor/ # Quick refactor only
-```
-
-All `.md` files in each directory are automatically loaded and added to the system prompt.
-
-## Example
-
-Create `.qodeassist/rules/common/general.md`:
-
-```markdown
-# Project Guidelines
-- Use snake_case for private members
-- Prefix interfaces with 'I'
-- Always document public APIs
-- Prefer Qt containers over STL
-```
-
diff --git a/docs/quick-refactoring.md b/docs/quick-refactoring.md
index 1357936..91e9cc4 100644
--- a/docs/quick-refactoring.md
+++ b/docs/quick-refactoring.md
@@ -206,7 +206,6 @@ The LLM receives:
- **Cursor Position**: Marked with `` tag
- **Selection Markers**: `` and `` tags
- **Your Instructions**: Built-in, custom, or typed
-- **Project Rules**: If configured (see [Project Rules](project-rules.md))
### Context Configuration
@@ -270,7 +269,6 @@ Fully local setup for offline or secure environments.
## Related Documentation
-- [Project Rules](project-rules.md) - Project-specific AI behavior customization
- [File Context](file-context.md) - Attaching files to chat context
- [Ignoring Files](ignoring-files.md) - Exclude files from AI context
- [Provider Configuration](../README.md#configuration) - Setting up LLM providers
diff --git a/resources/agents/acp-registry-snapshot.json b/resources/agents/acp-registry-snapshot.json
new file mode 100644
index 0000000..050caa2
--- /dev/null
+++ b/resources/agents/acp-registry-snapshot.json
@@ -0,0 +1,1351 @@
+{
+ "version": "1.0.0",
+ "agents": [
+ {
+ "id": "agoragentic-acp",
+ "name": "Agoragentic",
+ "version": "1.3.0",
+ "description": "Agent marketplace with 174+ AI capabilities. Browse, invoke, and pay for agent services settled in USDC on Base L2.",
+ "repository": "https://github.com/rhein1/agoragentic-integrations",
+ "website": "https://agoragentic.com",
+ "authors": [
+ "ACRE / Agoragentic"
+ ],
+ "license": "MIT",
+ "distribution": {
+ "npx": {
+ "package": "agoragentic-mcp@1.3.0",
+ "args": [
+ "--acp"
+ ]
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/agoragentic-acp.svg"
+ },
+ {
+ "id": "amp-acp",
+ "name": "Amp",
+ "version": "0.8.1",
+ "description": "ACP wrapper for Amp - the frontier coding agent",
+ "repository": "https://github.com/tao12345666333/amp-acp",
+ "authors": [
+ "tao12345666333"
+ ],
+ "license": "Apache-2.0",
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/amp-acp.svg",
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-darwin-aarch64.tar.gz",
+ "cmd": "./amp-acp"
+ },
+ "darwin-x86_64": {
+ "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-darwin-x86_64.tar.gz",
+ "cmd": "./amp-acp"
+ },
+ "linux-aarch64": {
+ "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-linux-aarch64.tar.gz",
+ "cmd": "./amp-acp"
+ },
+ "linux-x86_64": {
+ "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-linux-x86_64.tar.gz",
+ "cmd": "./amp-acp"
+ },
+ "windows-x86_64": {
+ "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-windows-x86_64.zip",
+ "cmd": "amp-acp.exe"
+ }
+ }
+ }
+ },
+ {
+ "id": "auggie",
+ "name": "Auggie CLI",
+ "version": "0.33.0",
+ "description": "Augment Code's powerful software agent, backed by industry-leading context engine",
+ "repository": "https://github.com/augmentcode/auggie",
+ "website": "https://www.augmentcode.com/",
+ "authors": [
+ "Augment Code "
+ ],
+ "license": "proprietary",
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/auggie.svg",
+ "distribution": {
+ "npx": {
+ "package": "@augmentcode/auggie@0.33.0",
+ "args": [
+ "--acp"
+ ],
+ "env": {
+ "AUGMENT_DISABLE_AUTO_UPDATE": "1"
+ }
+ }
+ }
+ },
+ {
+ "id": "autohand",
+ "name": "Autohand Code",
+ "version": "0.2.1",
+ "description": "Autohand Code - AI coding agent powered by Autohand AI",
+ "repository": "https://github.com/autohandai/autohand-acp",
+ "website": "https://www.autohand.ai/cli/",
+ "authors": [
+ "Autohand AI"
+ ],
+ "license": "Apache-2.0",
+ "distribution": {
+ "npx": {
+ "package": "@autohandai/autohand-acp@0.2.1"
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/autohand.svg"
+ },
+ {
+ "id": "claude-acp",
+ "name": "Claude Agent",
+ "version": "0.59.0",
+ "description": "ACP wrapper for Anthropic's Claude",
+ "repository": "https://github.com/agentclientprotocol/claude-agent-acp",
+ "authors": [
+ "Anthropic",
+ "Zed Industries",
+ "JetBrains"
+ ],
+ "license": "proprietary",
+ "distribution": {
+ "npx": {
+ "package": "@agentclientprotocol/claude-agent-acp@0.59.0"
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/claude-acp.svg"
+ },
+ {
+ "id": "cline",
+ "name": "Cline",
+ "version": "3.0.46",
+ "description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
+ "repository": "https://github.com/cline/cline",
+ "website": "https://cline.bot/cli",
+ "authors": [
+ "Cline Bot Inc."
+ ],
+ "license": "Apache-2.0",
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/cline.svg",
+ "distribution": {
+ "npx": {
+ "package": "cline@3.0.46",
+ "args": [
+ "--acp"
+ ]
+ }
+ }
+ },
+ {
+ "id": "codebuddy-code",
+ "name": "Codebuddy Code",
+ "version": "2.106.7",
+ "description": "Tencent Cloud's official intelligent coding tool",
+ "website": "https://www.codebuddy.cn/cli/",
+ "authors": [
+ "Tencent Cloud"
+ ],
+ "license": "Proprietary",
+ "distribution": {
+ "npx": {
+ "package": "@tencent-ai/codebuddy-code@2.106.7",
+ "args": [
+ "--acp"
+ ]
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/codebuddy-code.svg"
+ },
+ {
+ "id": "codex-acp",
+ "name": "Codex",
+ "version": "1.1.4",
+ "description": "ACP adapter for OpenAI's coding assistant",
+ "repository": "https://github.com/agentclientprotocol/codex-acp",
+ "authors": [
+ "OpenAI",
+ "JetBrains s.r.o",
+ "Zed Industries"
+ ],
+ "license": "Apache-2.0",
+ "distribution": {
+ "npx": {
+ "package": "@agentclientprotocol/codex-acp@1.1.4"
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/codex-acp.svg"
+ },
+ {
+ "id": "cortex-code",
+ "name": "Cortex Code",
+ "version": "1.0.73",
+ "description": "Snowflake's Cortex Code coding agent",
+ "repository": "https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code",
+ "authors": [
+ "Snowflake"
+ ],
+ "license": "proprietary",
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-darwin-arm64.tar.gz",
+ "cmd": "./coco-1.0.73+180523.e6179a031de9-darwin-arm64/cortex",
+ "args": [
+ "acp",
+ "serve"
+ ]
+ },
+ "darwin-x86_64": {
+ "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-darwin-amd64.tar.gz",
+ "cmd": "./coco-1.0.73+180523.e6179a031de9-darwin-amd64/cortex",
+ "args": [
+ "acp",
+ "serve"
+ ]
+ },
+ "linux-x86_64": {
+ "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-linux-amd64.tar.gz",
+ "cmd": "./coco-1.0.73+180523.e6179a031de9-linux-amd64/cortex",
+ "args": [
+ "acp",
+ "serve"
+ ]
+ },
+ "linux-aarch64": {
+ "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-linux-arm64.tar.gz",
+ "cmd": "./coco-1.0.73+180523.e6179a031de9-linux-arm64/cortex",
+ "args": [
+ "acp",
+ "serve"
+ ]
+ },
+ "windows-x86_64": {
+ "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-windows-amd64.tar.gz",
+ "cmd": "./coco-1.0.73+180523.e6179a031de9-windows-amd64/cortex.exe",
+ "args": [
+ "acp",
+ "serve"
+ ]
+ },
+ "windows-aarch64": {
+ "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-windows-arm64.tar.gz",
+ "cmd": "./coco-1.0.73+180523.e6179a031de9-windows-arm64/cortex.exe",
+ "args": [
+ "acp",
+ "serve"
+ ]
+ }
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/cortex-code.svg"
+ },
+ {
+ "id": "corust-agent",
+ "name": "Corust Agent",
+ "version": "0.6.0",
+ "description": "Co-building with a seasoned Rust partner.",
+ "repository": "https://github.com/Corust-ai/corust-agent-release",
+ "website": "https://corust.ai/",
+ "authors": [
+ "Corust AI "
+ ],
+ "license": "GPL-3.0-or-later",
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-darwin-arm64.tar.gz",
+ "cmd": "./corust-agent-acp"
+ },
+ "darwin-x86_64": {
+ "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-darwin-x64.tar.gz",
+ "cmd": "./corust-agent-acp"
+ },
+ "linux-x86_64": {
+ "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-linux-x64.tar.gz",
+ "cmd": "./corust-agent-acp"
+ },
+ "windows-x86_64": {
+ "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-windows-x64.zip",
+ "cmd": "./corust-agent-acp.exe"
+ }
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/corust-agent.svg"
+ },
+ {
+ "id": "crow-cli",
+ "name": "crow-cli",
+ "version": "0.1.24",
+ "description": "Minimal ACP Native Coding Agent",
+ "repository": "https://github.com/crow-cli/crow-cli",
+ "website": "https://crow-ai.dev",
+ "authors": [
+ "Thomas Wood"
+ ],
+ "license": "Apache-2.0",
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-darwin-aarch64.tar.gz",
+ "cmd": "./crow-cli",
+ "args": [
+ "acp"
+ ]
+ },
+ "darwin-x86_64": {
+ "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-darwin-x86_64.tar.gz",
+ "cmd": "./crow-cli",
+ "args": [
+ "acp"
+ ]
+ },
+ "linux-aarch64": {
+ "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-linux-aarch64.tar.gz",
+ "cmd": "./crow-cli",
+ "args": [
+ "acp"
+ ]
+ },
+ "linux-x86_64": {
+ "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-linux-x86_64.tar.gz",
+ "cmd": "./crow-cli",
+ "args": [
+ "acp"
+ ]
+ },
+ "windows-x86_64": {
+ "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-windows-x86_64.zip",
+ "cmd": "./crow-cli.exe",
+ "args": [
+ "acp"
+ ]
+ }
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/crow-cli.svg"
+ },
+ {
+ "id": "cursor",
+ "name": "Cursor",
+ "version": "2026.07.09",
+ "description": "Cursor's coding agent",
+ "website": "https://cursor.com/docs/cli/acp",
+ "authors": [
+ "Cursor"
+ ],
+ "license": "proprietary",
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/darwin/arm64/agent-cli-package.tar.gz",
+ "cmd": "./dist-package/cursor-agent",
+ "args": [
+ "acp"
+ ]
+ },
+ "darwin-x86_64": {
+ "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/darwin/x64/agent-cli-package.tar.gz",
+ "cmd": "./dist-package/cursor-agent",
+ "args": [
+ "acp"
+ ]
+ },
+ "linux-aarch64": {
+ "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/linux/arm64/agent-cli-package.tar.gz",
+ "cmd": "./dist-package/cursor-agent",
+ "args": [
+ "acp"
+ ]
+ },
+ "linux-x86_64": {
+ "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/linux/x64/agent-cli-package.tar.gz",
+ "cmd": "./dist-package/cursor-agent",
+ "args": [
+ "acp"
+ ]
+ },
+ "windows-aarch64": {
+ "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/windows/arm64/agent-cli-package.zip",
+ "cmd": "./dist-package\\cursor-agent.cmd",
+ "args": [
+ "acp"
+ ]
+ },
+ "windows-x86_64": {
+ "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/windows/x64/agent-cli-package.zip",
+ "cmd": "./dist-package\\cursor-agent.cmd",
+ "args": [
+ "acp"
+ ]
+ }
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/cursor.svg"
+ },
+ {
+ "id": "deepagents",
+ "name": "DeepAgents",
+ "version": "0.1.7",
+ "description": "Batteries-included AI coding and general purpose agent powered by LangChain.",
+ "repository": "https://github.com/langchain-ai/deepagentsjs",
+ "website": "https://docs.langchain.com/oss/javascript/deepagents/overview",
+ "authors": [
+ "LangChain"
+ ],
+ "license": "MIT",
+ "distribution": {
+ "npx": {
+ "package": "deepagents-acp@0.1.7",
+ "args": []
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/deepagents.svg"
+ },
+ {
+ "id": "devin",
+ "name": "Devin",
+ "version": "3000.1.27",
+ "description": "Devin CLI coding agent by Cognition",
+ "website": "https://docs.devin.ai/cli",
+ "authors": [
+ "Cognition"
+ ],
+ "license": "proprietary",
+ "repository": "https://github.com/CognitionAI/devin-cli",
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://static.devin.ai/cli/3000.1.27/devin-3000.1.27-aarch64-apple-darwin.tar.gz",
+ "cmd": "./bin/devin",
+ "args": [
+ "acp"
+ ]
+ },
+ "darwin-x86_64": {
+ "archive": "https://static.devin.ai/cli/3000.1.27/devin-3000.1.27-x86_64-apple-darwin.tar.gz",
+ "cmd": "./bin/devin",
+ "args": [
+ "acp"
+ ]
+ },
+ "linux-aarch64": {
+ "archive": "https://static.devin.ai/cli/3000.1.27/devin-3000.1.27-aarch64-unknown-linux.tar.gz",
+ "cmd": "./bin/devin",
+ "args": [
+ "acp"
+ ]
+ },
+ "linux-x86_64": {
+ "archive": "https://static.devin.ai/cli/3000.1.27/devin-3000.1.27-x86_64-unknown-linux.tar.gz",
+ "cmd": "./bin/devin",
+ "args": [
+ "acp"
+ ]
+ },
+ "windows-aarch64": {
+ "archive": "https://static.devin.ai/cli/3000.1.27/devin-3000.1.27-aarch64-pc-windows.zip",
+ "cmd": "./bin\\devin.exe",
+ "args": [
+ "acp"
+ ]
+ },
+ "windows-x86_64": {
+ "archive": "https://static.devin.ai/cli/3000.1.27/devin-3000.1.27-x86_64-pc-windows.zip",
+ "cmd": "./bin\\devin.exe",
+ "args": [
+ "acp"
+ ]
+ }
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/devin.svg"
+ },
+ {
+ "id": "dimcode",
+ "name": "DimCode",
+ "version": "0.2.31",
+ "description": "A coding agent that puts leading models at your command.",
+ "website": "https://dimcode.dev/docs/acp.html",
+ "authors": [
+ "ArcShips"
+ ],
+ "license": "proprietary",
+ "distribution": {
+ "npx": {
+ "package": "dimcode@0.2.31",
+ "args": [
+ "acp"
+ ]
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/dimcode.svg"
+ },
+ {
+ "id": "dirac",
+ "name": "Dirac",
+ "version": "0.4.19",
+ "description": "Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
+ "repository": "https://github.com/dirac-run/dirac",
+ "website": "https://dirac.run",
+ "authors": [
+ "Dirac Delta Labs"
+ ],
+ "license": "Apache-2.0",
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/dirac.svg",
+ "distribution": {
+ "npx": {
+ "package": "dirac-cli@0.4.19",
+ "args": [
+ "--acp"
+ ]
+ }
+ }
+ },
+ {
+ "id": "factory-droid",
+ "name": "Factory Droid",
+ "version": "0.175.0",
+ "description": "Factory Droid - AI coding agent powered by Factory AI",
+ "website": "https://factory.ai/product/cli",
+ "authors": [
+ "Factory AI"
+ ],
+ "license": "proprietary",
+ "distribution": {
+ "npx": {
+ "package": "droid@0.175.0",
+ "args": [
+ "exec",
+ "--output-format",
+ "acp-daemon"
+ ],
+ "env": {
+ "DROID_DISABLE_AUTO_UPDATE": "true",
+ "FACTORY_DROID_AUTO_UPDATE_ENABLED": "false"
+ }
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/factory-droid.svg"
+ },
+ {
+ "id": "fast-agent",
+ "name": "fast-agent",
+ "version": "0.9.16",
+ "description": "Code and build agents with comprehensive multi-provider support",
+ "repository": "https://github.com/evalstate/fast-agent",
+ "website": "https://fast-agent.ai",
+ "authors": [
+ "enquiries@fast-agent.ai"
+ ],
+ "license": "Apache 2.0",
+ "distribution": {
+ "uvx": {
+ "package": "fast-agent-acp==0.9.16",
+ "args": [
+ "-x"
+ ]
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/fast-agent.svg"
+ },
+ {
+ "id": "gemini",
+ "name": "Gemini CLI",
+ "version": "0.51.0",
+ "description": "Google's official CLI for Gemini",
+ "repository": "https://github.com/google-gemini/gemini-cli",
+ "website": "https://geminicli.com",
+ "authors": [
+ "Google"
+ ],
+ "license": "Apache-2.0",
+ "distribution": {
+ "npx": {
+ "package": "@google/gemini-cli@0.51.0",
+ "args": [
+ "--acp"
+ ]
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/gemini.svg"
+ },
+ {
+ "id": "github-copilot-cli",
+ "name": "GitHub Copilot",
+ "version": "1.0.71",
+ "description": "GitHub's AI pair programmer",
+ "repository": "https://github.com/github/copilot-cli",
+ "website": "https://github.com/features/copilot/cli/",
+ "authors": [
+ "GitHub"
+ ],
+ "license": "proprietary",
+ "distribution": {
+ "npx": {
+ "package": "@github/copilot@1.0.71",
+ "args": [
+ "--acp"
+ ]
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/github-copilot-cli.svg"
+ },
+ {
+ "id": "glm-acp-agent",
+ "name": "GLM Agent",
+ "version": "1.2.0",
+ "description": "ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
+ "repository": "https://github.com/stefandevo/glm-acp-agent",
+ "authors": [
+ "Stefan de Vogelaere"
+ ],
+ "license": "Apache-2.0",
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/glm-acp-agent.svg",
+ "distribution": {
+ "npx": {
+ "package": "glm-acp-agent@1.2.0"
+ }
+ }
+ },
+ {
+ "id": "goose",
+ "name": "goose",
+ "version": "1.43.0",
+ "description": "A local, extensible, open source AI agent that automates engineering tasks",
+ "repository": "https://github.com/block/goose",
+ "website": "https://block.github.io/goose/",
+ "authors": [
+ "Block"
+ ],
+ "license": "Apache-2.0",
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://github.com/block/goose/releases/download/v1.43.0/goose-aarch64-apple-darwin.tar.bz2",
+ "cmd": "./goose",
+ "args": [
+ "acp"
+ ],
+ "sha256": "c24ffdd8a3863288592ab211758d41f0a779428f77e92fcaa73e9882899a4eec"
+ },
+ "darwin-x86_64": {
+ "archive": "https://github.com/block/goose/releases/download/v1.43.0/goose-x86_64-apple-darwin.tar.bz2",
+ "cmd": "./goose",
+ "args": [
+ "acp"
+ ],
+ "sha256": "6d0fad77f52f8177a3c3f8f40b8af3260a4ec057c06cfd8dcaf3af1b39de1e78"
+ },
+ "linux-aarch64": {
+ "archive": "https://github.com/block/goose/releases/download/v1.43.0/goose-aarch64-unknown-linux-gnu.tar.bz2",
+ "cmd": "./goose",
+ "args": [
+ "acp"
+ ],
+ "sha256": "5d2e93b7c740409ce09782d98b70db87eac2e1fcff527bdbaf44a98da8b3b597"
+ },
+ "linux-x86_64": {
+ "archive": "https://github.com/block/goose/releases/download/v1.43.0/goose-x86_64-unknown-linux-gnu.tar.bz2",
+ "cmd": "./goose",
+ "args": [
+ "acp"
+ ],
+ "sha256": "b218b08c0a86c9f356623f51bb91fc14c4de6667bc9c15d6d922778beb20f1bf"
+ },
+ "windows-x86_64": {
+ "archive": "https://github.com/block/goose/releases/download/v1.43.0/goose-x86_64-pc-windows-msvc.zip",
+ "cmd": "./goose-package\\goose.exe",
+ "args": [
+ "acp"
+ ],
+ "sha256": "e76ddbaa331f54f837e7697108f901f09d7e56b07e81c1b7fa41da9e58ba5a7a"
+ }
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/goose.svg"
+ },
+ {
+ "id": "grok-build",
+ "name": "Grok Build",
+ "version": "0.2.106",
+ "description": "xAI's coding agent and CLI",
+ "website": "https://x.ai/cli",
+ "authors": [
+ "xAI"
+ ],
+ "license": "proprietary",
+ "distribution": {
+ "npx": {
+ "package": "@xai-official/grok@0.2.106",
+ "args": [
+ "agent",
+ "stdio"
+ ]
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/grok-build.svg"
+ },
+ {
+ "id": "harn",
+ "name": "Harn",
+ "version": "0.10.28",
+ "description": "Harn runs .harn agent pipelines as a native ACP coding agent over stdio.",
+ "repository": "https://github.com/burin-labs/harn",
+ "website": "https://harnlang.com",
+ "authors": [
+ "Burin Labs"
+ ],
+ "license": "Apache-2.0",
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.28/harn-aarch64-apple-darwin.tar.gz",
+ "cmd": "./harn",
+ "args": [
+ "serve",
+ "acp"
+ ],
+ "sha256": "1416f454593e89e93a12ef3b06af6ef0b132e0347459a2a27a9da5aad8cef409"
+ },
+ "darwin-x86_64": {
+ "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.28/harn-x86_64-apple-darwin.tar.gz",
+ "cmd": "./harn",
+ "args": [
+ "serve",
+ "acp"
+ ],
+ "sha256": "80857c07cdf474fa470517fc1b1271e82bca3c38eb0b3f8c545670fea22a51e4"
+ },
+ "linux-aarch64": {
+ "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.28/harn-aarch64-unknown-linux-gnu.tar.gz",
+ "cmd": "./harn",
+ "args": [
+ "serve",
+ "acp"
+ ],
+ "sha256": "85f533acb4ef039e853e28c29d3429ba24aebb2b77ea35eab0989a2702896c54"
+ },
+ "linux-x86_64": {
+ "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.28/harn-x86_64-unknown-linux-gnu.tar.gz",
+ "cmd": "./harn",
+ "args": [
+ "serve",
+ "acp"
+ ],
+ "sha256": "125b8b16d649c2e361777751294dd9f6d80c89bbb0b48813f4f3bbcba57e8a4e"
+ },
+ "windows-x86_64": {
+ "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.28/harn-x86_64-pc-windows-msvc.zip",
+ "cmd": "harn.exe",
+ "args": [
+ "serve",
+ "acp"
+ ],
+ "sha256": "b698ce750925dcf061c5149f37315ddf1806278d39955d8e36fa64ca2ad77af2"
+ }
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/harn.svg"
+ },
+ {
+ "id": "junie",
+ "name": "Junie",
+ "version": "2144.9.0",
+ "description": "AI Coding Agent by JetBrains",
+ "repository": "https://github.com/JetBrains/junie",
+ "website": "https://junie.jetbrains.com",
+ "authors": [
+ "JetBrains"
+ ],
+ "license": "proprietary",
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-macos-aarch64.zip",
+ "cmd": "./Applications/junie.app/Contents/MacOS/junie",
+ "args": [
+ "--acp=true"
+ ]
+ },
+ "darwin-x86_64": {
+ "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-macos-amd64.zip",
+ "cmd": "./Applications/junie.app/Contents/MacOS/junie",
+ "args": [
+ "--acp=true"
+ ]
+ },
+ "linux-aarch64": {
+ "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-linux-aarch64.zip",
+ "cmd": "./junie-app/bin/junie",
+ "args": [
+ "--acp=true"
+ ]
+ },
+ "linux-x86_64": {
+ "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-linux-amd64.zip",
+ "cmd": "./junie-app/bin/junie",
+ "args": [
+ "--acp=true"
+ ]
+ },
+ "windows-x86_64": {
+ "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-windows-amd64.zip",
+ "cmd": "./junie/junie.exe",
+ "args": [
+ "--acp=true"
+ ]
+ }
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/junie.svg"
+ },
+ {
+ "id": "kilo",
+ "name": "Kilo",
+ "version": "7.4.11",
+ "description": "The open source coding agent",
+ "repository": "https://github.com/Kilo-Org/kilocode",
+ "website": "https://kilo.ai/",
+ "authors": [
+ "Kilo Code"
+ ],
+ "license": "MIT",
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg",
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/kilo-darwin-arm64.zip",
+ "cmd": "./kilo",
+ "args": [
+ "acp"
+ ],
+ "sha256": "14a030a354f3b51f0241662627702e7b06cddf3fcb6e0f1415279e9d3a3b8998"
+ },
+ "darwin-x86_64": {
+ "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/kilo-darwin-x64.zip",
+ "cmd": "./kilo",
+ "args": [
+ "acp"
+ ],
+ "sha256": "66e302e09b96fd9794012bdb608622b589e4810ba31eca35918d8acd1a32a438"
+ },
+ "linux-aarch64": {
+ "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/kilo-linux-arm64.tar.gz",
+ "cmd": "./kilo",
+ "args": [
+ "acp"
+ ],
+ "sha256": "48c5405eb05efb3558d120b521d1a2b097cd8e99d36d7caf015e7c467922793c"
+ },
+ "linux-x86_64": {
+ "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/kilo-linux-x64.tar.gz",
+ "cmd": "./kilo",
+ "args": [
+ "acp"
+ ],
+ "sha256": "b060dd4e094b0f9966de03d8a0d0d5cc8e6bb23ab04f1d04b7e3951d13079770"
+ },
+ "windows-x86_64": {
+ "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/kilo-windows-x64.zip",
+ "cmd": "./kilo.exe",
+ "args": [
+ "acp"
+ ],
+ "sha256": "1c04d25b1484526b1eb8abe44bbcfc179fb71b1efa88b0164cd5709ca8bb00e7"
+ }
+ },
+ "npx": {
+ "package": "@kilocode/cli@7.4.11",
+ "args": [
+ "acp"
+ ]
+ }
+ }
+ },
+ {
+ "id": "kimi",
+ "name": "Kimi CLI",
+ "version": "1.49.0",
+ "description": "Moonshot AI's coding assistant",
+ "repository": "https://github.com/MoonshotAI/kimi-cli",
+ "website": "https://moonshotai.github.io/kimi-cli/",
+ "authors": [
+ "Moonshot AI"
+ ],
+ "license": "MIT",
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.49.0/kimi-1.49.0-aarch64-apple-darwin.tar.gz",
+ "cmd": "./kimi",
+ "args": [
+ "acp"
+ ],
+ "sha256": "15018b20b203aee09658fdc64840c4846fc17c108d8dba1a19a95581d3ce2921"
+ },
+ "linux-aarch64": {
+ "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.49.0/kimi-1.49.0-aarch64-unknown-linux-gnu.tar.gz",
+ "cmd": "./kimi",
+ "args": [
+ "acp"
+ ],
+ "sha256": "5ac54cabce16ede27b9d2069b9b88edee25528646e7bb5befa9980a1ca71febb"
+ },
+ "linux-x86_64": {
+ "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.49.0/kimi-1.49.0-x86_64-unknown-linux-gnu.tar.gz",
+ "cmd": "./kimi",
+ "args": [
+ "acp"
+ ],
+ "sha256": "6ce0b83f583c45a64cc9f51ffe7e1a8e03ee79acda69945fcf8c23341b9d892f"
+ },
+ "windows-aarch64": {
+ "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.49.0/kimi-1.49.0-aarch64-pc-windows-msvc.zip",
+ "cmd": "./kimi.exe",
+ "args": [
+ "acp"
+ ],
+ "sha256": "3ac8f05c7bd18d902a324c6c03a71084cfbe785b9669bbd556c071ee1d8f2f26"
+ },
+ "windows-x86_64": {
+ "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.49.0/kimi-1.49.0-x86_64-pc-windows-msvc.zip",
+ "cmd": "./kimi.exe",
+ "args": [
+ "acp"
+ ],
+ "sha256": "2acbbc7ca8c8ac4b03dab1d970f53a292bd226168151b423499feab9fc203ddd"
+ }
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/kimi.svg"
+ },
+ {
+ "id": "minion-code",
+ "name": "Minion Code",
+ "version": "0.1.44",
+ "description": "An enhanced AI code assistant built on the Minion framework with rich development tools",
+ "repository": "https://github.com/femto/minion-code",
+ "authors": [
+ "femto"
+ ],
+ "license": "AGPL-3.0",
+ "distribution": {
+ "uvx": {
+ "package": "minion-code@0.1.44",
+ "args": [
+ "acp"
+ ]
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/minion-code.svg"
+ },
+ {
+ "id": "mistral-vibe",
+ "name": "Mistral Vibe",
+ "version": "2.21.0",
+ "description": "Mistral's open-source coding assistant",
+ "repository": "https://github.com/mistralai/mistral-vibe",
+ "website": "https://mistral.ai/products/vibe",
+ "authors": [
+ "Mistral AI"
+ ],
+ "license": "Apache-2.0",
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/mistral-vibe.svg",
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.21.0/vibe-acp-darwin-aarch64-2.21.0.zip",
+ "cmd": "./vibe-acp",
+ "sha256": "243973c7bd69d4c5701ac4b5ce047df8dd1cd269df97d0b5c8ddb7fb64c3fd36"
+ },
+ "darwin-x86_64": {
+ "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.21.0/vibe-acp-darwin-x86_64-2.21.0.zip",
+ "cmd": "./vibe-acp",
+ "sha256": "77096fcf59c4369737ca77b1eb1cfede1dbf8d427bcc9e90f6f8bdbf45dae08b"
+ },
+ "linux-aarch64": {
+ "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.21.0/vibe-acp-linux-aarch64-2.21.0.zip",
+ "cmd": "./vibe-acp",
+ "sha256": "948d66eaa73d2c28d90b922cc6cc5afffb83a9aa4d50b4e3eaed811751238d56"
+ },
+ "linux-x86_64": {
+ "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.21.0/vibe-acp-linux-x86_64-2.21.0.zip",
+ "cmd": "./vibe-acp",
+ "sha256": "90792067c779f863b6c0d7dc6c5b06f50a9f82ac937cfd9276e35ad4e7d61690"
+ },
+ "windows-x86_64": {
+ "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.21.0/vibe-acp-windows-x86_64-2.21.0.zip",
+ "cmd": "./vibe-acp.exe",
+ "sha256": "4505cda1ff3d29c2493d1d1b261a84c677c1fb1106527b24d6ce9ca966b5f094"
+ }
+ }
+ }
+ },
+ {
+ "id": "nova",
+ "name": "Nova",
+ "version": "1.1.27",
+ "description": "Nova by Compass AI - a fully-fledged software engineer at your command",
+ "repository": "https://github.com/Compass-Agentic-Platform/nova",
+ "website": "https://www.compassap.ai/portfolio/nova.html",
+ "authors": [
+ "Compass AI"
+ ],
+ "license": "proprietary",
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/nova.svg",
+ "distribution": {
+ "npx": {
+ "package": "@compass-ai/nova@1.1.27",
+ "args": [
+ "acp"
+ ]
+ }
+ }
+ },
+ {
+ "id": "opencode",
+ "name": "OpenCode",
+ "version": "1.18.3",
+ "description": "The open source coding agent",
+ "repository": "https://github.com/anomalyco/opencode",
+ "website": "https://opencode.ai",
+ "authors": [
+ "Anomaly"
+ ],
+ "license": "MIT",
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/opencode.svg",
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-darwin-arm64.zip",
+ "cmd": "./opencode",
+ "args": [
+ "acp"
+ ],
+ "sha256": "946f62b155638b911144b7bef520ee4a6442f696297907873463bca3524e40ef"
+ },
+ "darwin-x86_64": {
+ "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-darwin-x64.zip",
+ "cmd": "./opencode",
+ "args": [
+ "acp"
+ ],
+ "sha256": "4ea147867ba19e4ec03559df557811f1674f40788aea4d10326dc563b7667c6d"
+ },
+ "linux-aarch64": {
+ "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-linux-arm64.tar.gz",
+ "cmd": "./opencode",
+ "args": [
+ "acp"
+ ],
+ "sha256": "da0a631174eba380b2a1d51f9d364fa3812da433e72743c72471d4b5da59c69d"
+ },
+ "linux-x86_64": {
+ "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-linux-x64.tar.gz",
+ "cmd": "./opencode",
+ "args": [
+ "acp"
+ ],
+ "sha256": "60f27b2679f00a511b6539f97e02448afaf58d9c66e2448285ea0c517ca84583"
+ },
+ "windows-aarch64": {
+ "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-windows-arm64.zip",
+ "cmd": "./opencode",
+ "args": [
+ "acp"
+ ],
+ "sha256": "a549fb2e9041db9438bcd9b77bfa0a4b2476caf2d550f37479aabfec1b079bfb"
+ },
+ "windows-x86_64": {
+ "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-windows-x64.zip",
+ "cmd": "./opencode.exe",
+ "args": [
+ "acp"
+ ],
+ "sha256": "68bc62930f6cb5755e0409aa9de0bb270a66ed2b8c9cf0c029e9f2287ed5486e"
+ }
+ }
+ }
+ },
+ {
+ "id": "pi-acp",
+ "name": "pi ACP",
+ "version": "0.0.31",
+ "description": "ACP adapter for pi coding agent",
+ "repository": "https://github.com/svkozak/pi-acp",
+ "authors": [
+ "Sergii Kozak "
+ ],
+ "license": "MIT",
+ "distribution": {
+ "npx": {
+ "package": "pi-acp@0.0.31"
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/pi-acp.svg"
+ },
+ {
+ "id": "poolside",
+ "name": "Poolside",
+ "version": "1.0.11",
+ "description": "Poolside's coding agent",
+ "repository": "https://github.com/poolsideai/pool",
+ "website": "https://poolside.ai",
+ "authors": [
+ "Poolside "
+ ],
+ "license": "proprietary",
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-darwin-arm64.tar.gz",
+ "cmd": "./pool-darwin-arm64",
+ "args": [
+ "acp"
+ ]
+ },
+ "darwin-x86_64": {
+ "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-darwin-amd64.tar.gz",
+ "cmd": "./pool-darwin-amd64",
+ "args": [
+ "acp"
+ ]
+ },
+ "linux-aarch64": {
+ "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-linux-arm64.tar.gz",
+ "cmd": "./pool-linux-arm64",
+ "args": [
+ "acp"
+ ]
+ },
+ "linux-x86_64": {
+ "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-linux-amd64.tar.gz",
+ "cmd": "./pool-linux-amd64",
+ "args": [
+ "acp"
+ ]
+ },
+ "windows-aarch64": {
+ "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-windows-arm64.tar.gz",
+ "cmd": "./pool-windows-arm64.exe",
+ "args": [
+ "acp"
+ ]
+ },
+ "windows-x86_64": {
+ "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-windows-amd64.tar.gz",
+ "cmd": "./pool-windows-amd64.exe",
+ "args": [
+ "acp"
+ ]
+ }
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/poolside.svg"
+ },
+ {
+ "id": "qoder",
+ "name": "Qoder CLI",
+ "version": "0.2.14",
+ "description": "AI coding assistant with agentic capabilities",
+ "website": "https://qoder.com",
+ "authors": [
+ "Qoder AI"
+ ],
+ "license": "proprietary",
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/qoder.svg",
+ "distribution": {
+ "npx": {
+ "package": "@qoder-ai/qodercli@0.2.14",
+ "args": [
+ "--acp"
+ ]
+ }
+ }
+ },
+ {
+ "id": "qwen-code",
+ "name": "Qwen Code",
+ "version": "0.20.0",
+ "description": "Alibaba's Qwen coding assistant",
+ "repository": "https://github.com/QwenLM/qwen-code",
+ "website": "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
+ "authors": [
+ "Alibaba Qwen Team"
+ ],
+ "license": "Apache-2.0",
+ "distribution": {
+ "npx": {
+ "package": "@qwen-code/qwen-code@0.20.0",
+ "args": [
+ "--acp",
+ "--experimental-skills"
+ ]
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/qwen-code.svg"
+ },
+ {
+ "id": "sigit",
+ "name": "siGit Code",
+ "version": "1.4.1",
+ "description": "Local-first coding agent. Runs entirely on your machine with optional on-device LLM inference via Onde.",
+ "repository": "https://github.com/getsigit/sigit",
+ "website": "https://github.com/getsigit/sigit",
+ "authors": [
+ "smbCloud"
+ ],
+ "license": "Apache-2.0",
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://github.com/getsigit/sigit/releases/download/v1.4.1/sigit-macos-arm64.tar.gz",
+ "cmd": "./sigit"
+ },
+ "darwin-x86_64": {
+ "archive": "https://github.com/getsigit/sigit/releases/download/v1.4.1/sigit-macos-amd64.tar.gz",
+ "cmd": "./sigit"
+ },
+ "linux-aarch64": {
+ "archive": "https://github.com/getsigit/sigit/releases/download/v1.4.1/sigit-linux-arm64",
+ "cmd": "./sigit-linux-arm64"
+ },
+ "linux-x86_64": {
+ "archive": "https://github.com/getsigit/sigit/releases/download/v1.4.1/sigit-linux-amd64",
+ "cmd": "./sigit-linux-amd64"
+ },
+ "windows-aarch64": {
+ "archive": "https://github.com/getsigit/sigit/releases/download/v1.4.1/sigit-win-arm64.exe",
+ "cmd": "./sigit-win-arm64.exe"
+ },
+ "windows-x86_64": {
+ "archive": "https://github.com/getsigit/sigit/releases/download/v1.4.1/sigit-win-amd64.exe",
+ "cmd": "./sigit-win-amd64.exe"
+ }
+ },
+ "npx": {
+ "package": "@smbcloud/sigit@1.4.1"
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/sigit.svg"
+ },
+ {
+ "id": "stakpak",
+ "name": "Stakpak",
+ "version": "0.3.88",
+ "description": "Open-source DevOps agent in Rust with enterprise-grade security",
+ "repository": "https://github.com/stakpak/agent",
+ "website": "https://stakpak.dev",
+ "authors": [
+ "Stakpak Team "
+ ],
+ "license": "Apache-2.0",
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/stakpak.svg",
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-darwin-aarch64.tar.gz",
+ "cmd": "./stakpak",
+ "args": [
+ "acp"
+ ]
+ },
+ "darwin-x86_64": {
+ "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-darwin-x86_64.tar.gz",
+ "cmd": "./stakpak",
+ "args": [
+ "acp"
+ ]
+ },
+ "linux-aarch64": {
+ "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-linux-aarch64.tar.gz",
+ "cmd": "./stakpak",
+ "args": [
+ "acp"
+ ]
+ },
+ "linux-x86_64": {
+ "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-linux-x86_64.tar.gz",
+ "cmd": "./stakpak",
+ "args": [
+ "acp"
+ ]
+ },
+ "windows-x86_64": {
+ "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-windows-x86_64.zip",
+ "cmd": "./stakpak.exe",
+ "args": [
+ "acp"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "id": "vtcode",
+ "name": "VT Code",
+ "version": "0.96.14",
+ "description": "An open-source coding agent with LLM-native code understanding and robust shell safety. Supports multiple LLM providers with automatic failover and efficient context management.",
+ "repository": "https://github.com/vinhnx/VTCode",
+ "website": "https://github.com/vinhnx/VTCode/blob/main/docs/guides/zed-acp.md",
+ "authors": [
+ "vinhnx"
+ ],
+ "license": "MIT",
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-aarch64-apple-darwin.tar.gz",
+ "cmd": "./vtcode",
+ "args": [
+ "acp"
+ ],
+ "env": {
+ "VT_ACP_ENABLED": "1",
+ "VT_ACP_ZED_ENABLED": "1"
+ }
+ },
+ "darwin-x86_64": {
+ "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-apple-darwin.tar.gz",
+ "cmd": "./vtcode",
+ "args": [
+ "acp"
+ ],
+ "env": {
+ "VT_ACP_ENABLED": "1",
+ "VT_ACP_ZED_ENABLED": "1"
+ }
+ },
+ "linux-x86_64": {
+ "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-unknown-linux-gnu.tar.gz",
+ "cmd": "./vtcode",
+ "args": [
+ "acp"
+ ],
+ "env": {
+ "VT_ACP_ENABLED": "1",
+ "VT_ACP_ZED_ENABLED": "1"
+ }
+ },
+ "windows-x86_64": {
+ "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-pc-windows-msvc.zip",
+ "cmd": "vtcode.exe",
+ "args": [
+ "acp"
+ ],
+ "env": {
+ "VT_ACP_ENABLED": "1",
+ "VT_ACP_ZED_ENABLED": "1"
+ }
+ }
+ }
+ },
+ "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/vtcode.svg"
+ }
+ ],
+ "extensions": []
+}
diff --git a/sources/CMakeLists.txt b/sources/CMakeLists.txt
index 7bba6c0..0ae014b 100644
--- a/sources/CMakeLists.txt
+++ b/sources/CMakeLists.txt
@@ -4,5 +4,6 @@ add_subdirectory(logger)
add_subdirectory(settings)
add_subdirectory(context)
add_subdirectory(session)
+add_subdirectory(acp)
add_subdirectory(UIControls)
add_subdirectory(ChatView)
diff --git a/sources/ChatView/AgentRoleController.cpp b/sources/ChatView/AgentRoleController.cpp
deleted file mode 100644
index af21f53..0000000
--- a/sources/ChatView/AgentRoleController.cpp
+++ /dev/null
@@ -1,124 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#include "AgentRoleController.hpp"
-
-#include
-
-#include "AgentRole.hpp"
-#include "ChatAssistantSettings.hpp"
-#include "GeneralSettings.hpp"
-
-namespace QodeAssist::Chat {
-
-AgentRoleController::AgentRoleController(QObject *parent)
- : QObject(parent)
-{
- connect(
- &Settings::chatAssistantSettings().systemPrompt,
- &Utils::BaseAspect::changed,
- this,
- &AgentRoleController::baseSystemPromptChanged);
-
- loadAvailableRoles();
-}
-
-QStringList AgentRoleController::availableRoles() const
-{
- return m_availableRoles;
-}
-
-QString AgentRoleController::currentRole() const
-{
- return m_currentRole;
-}
-
-QString AgentRoleController::baseSystemPrompt() const
-{
- return Settings::chatAssistantSettings().systemPrompt();
-}
-
-QString AgentRoleController::currentRoleDescription() const
-{
- const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId();
- if (lastRoleId.isEmpty())
- return Settings::AgentRolesManager::getNoRole().description;
-
- const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId);
- if (role.id.isEmpty())
- return Settings::AgentRolesManager::getNoRole().description;
-
- return role.description;
-}
-
-QString AgentRoleController::currentRoleSystemPrompt() const
-{
- const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId();
- if (lastRoleId.isEmpty())
- return QString();
-
- const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId);
- if (role.id.isEmpty())
- return QString();
-
- return role.systemPrompt;
-}
-
-void AgentRoleController::loadAvailableRoles()
-{
- const QList roles = Settings::AgentRolesManager::loadAllRoles();
-
- m_availableRoles.clear();
- m_availableRoles.append(Settings::AgentRolesManager::getNoRole().name);
-
- for (const auto &role : roles)
- m_availableRoles.append(role.name);
-
- const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId();
- m_currentRole = Settings::AgentRolesManager::getNoRole().name;
-
- if (!lastRoleId.isEmpty()) {
- for (const auto &role : roles) {
- if (role.id == lastRoleId) {
- m_currentRole = role.name;
- break;
- }
- }
- }
-
- emit availableRolesChanged();
- emit currentRoleChanged();
-}
-
-void AgentRoleController::applyRole(const QString &roleName)
-{
- auto &settings = Settings::chatAssistantSettings();
-
- if (roleName == Settings::AgentRolesManager::getNoRole().name) {
- settings.lastUsedRoleId.setValue("");
- settings.writeSettings();
- m_currentRole = roleName;
- emit currentRoleChanged();
- return;
- }
-
- const QList roles = Settings::AgentRolesManager::loadAllRoles();
-
- for (const auto &role : roles) {
- if (role.name == roleName) {
- settings.lastUsedRoleId.setValue(role.id);
- settings.writeSettings();
- m_currentRole = role.name;
- emit currentRoleChanged();
- break;
- }
- }
-}
-
-void AgentRoleController::openSettings()
-{
- Settings::showSettings(Utils::Id("QodeAssist.AgentRoles"));
-}
-
-} // namespace QodeAssist::Chat
diff --git a/sources/ChatView/AgentRoleController.hpp b/sources/ChatView/AgentRoleController.hpp
deleted file mode 100644
index 7eefe67..0000000
--- a/sources/ChatView/AgentRoleController.hpp
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include
-#include
-
-namespace QodeAssist::Chat {
-
-class AgentRoleController : public QObject
-{
- Q_OBJECT
-
-public:
- explicit AgentRoleController(QObject *parent = nullptr);
-
- QStringList availableRoles() const;
- QString currentRole() const;
- QString baseSystemPrompt() const;
- QString currentRoleDescription() const;
- QString currentRoleSystemPrompt() const;
-
- void loadAvailableRoles();
- void applyRole(const QString &roleName);
- void openSettings();
-
-signals:
- void availableRolesChanged();
- void currentRoleChanged();
- void baseSystemPromptChanged();
-
-private:
- QStringList m_availableRoles;
- QString m_currentRole;
-};
-
-} // namespace QodeAssist::Chat
diff --git a/sources/ChatView/ChatFileManager.cpp b/sources/ChatView/AttachmentStaging.cpp
similarity index 86%
rename from sources/ChatView/ChatFileManager.cpp
rename to sources/ChatView/AttachmentStaging.cpp
index 5c3bf7b..e81961d 100644
--- a/sources/ChatView/ChatFileManager.cpp
+++ b/sources/ChatView/AttachmentStaging.cpp
@@ -2,13 +2,12 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-#include "ChatFileManager.hpp"
+#include "AttachmentStaging.hpp"
#include "Logger.hpp"
#include
#include
#include
-#include
#include
#include
#include
@@ -17,14 +16,14 @@
namespace QodeAssist::Chat {
-ChatFileManager::ChatFileManager(QObject *parent)
+AttachmentStaging::AttachmentStaging(QObject *parent)
: QObject(parent)
, m_intermediateStorageDir(getIntermediateStorageDir())
{}
-ChatFileManager::~ChatFileManager() = default;
+AttachmentStaging::~AttachmentStaging() = default;
-QStringList ChatFileManager::processDroppedFiles(const QStringList &filePaths)
+QStringList AttachmentStaging::processDroppedFiles(const QStringList &filePaths)
{
QStringList processedPaths;
processedPaths.reserve(filePaths.size());
@@ -52,17 +51,17 @@ QStringList ChatFileManager::processDroppedFiles(const QStringList &filePaths)
return processedPaths;
}
-void ChatFileManager::setChatFilePath(const QString &chatFilePath)
+void AttachmentStaging::setChatFilePath(const QString &chatFilePath)
{
m_chatFilePath = chatFilePath;
}
-QString ChatFileManager::chatFilePath() const
+QString AttachmentStaging::chatFilePath() const
{
return m_chatFilePath;
}
-void ChatFileManager::clearIntermediateStorage()
+void AttachmentStaging::clearIntermediateStorage()
{
QDir dir(m_intermediateStorageDir);
if (!dir.exists()) {
@@ -82,13 +81,13 @@ void ChatFileManager::clearIntermediateStorage()
}
}
-bool ChatFileManager::isFileAccessible(const QString &filePath)
+bool AttachmentStaging::isFileAccessible(const QString &filePath)
{
QFileInfo fileInfo(filePath);
return fileInfo.exists() && fileInfo.isFile() && fileInfo.isReadable();
}
-void ChatFileManager::cleanupGlobalIntermediateStorage()
+void AttachmentStaging::cleanupGlobalIntermediateStorage()
{
const QString basePath = Core::ICore::userResourcePath().toFSPathString();
const QString intermediatePath = QDir(basePath).filePath("qodeassist/chat_temp_files");
@@ -113,13 +112,13 @@ void ChatFileManager::cleanupGlobalIntermediateStorage()
}
if (removedCount > 0 || failedCount > 0) {
- LOG_MESSAGE(QString("ChatFileManager global cleanup: removed=%1, failed=%2")
+ LOG_MESSAGE(QString("AttachmentStaging global cleanup: removed=%1, failed=%2")
.arg(removedCount)
.arg(failedCount));
}
}
-QString ChatFileManager::copyToIntermediateStorage(const QString &filePath)
+QString AttachmentStaging::copyToIntermediateStorage(const QString &filePath)
{
QFileInfo fileInfo(filePath);
if (!fileInfo.exists() || !fileInfo.isFile()) {
@@ -154,7 +153,7 @@ QString ChatFileManager::copyToIntermediateStorage(const QString &filePath)
return destinationPath;
}
-QString ChatFileManager::getIntermediateStorageDir()
+QString AttachmentStaging::getIntermediateStorageDir()
{
const QString basePath = Core::ICore::userResourcePath().toFSPathString();
const QString intermediatePath = QDir(basePath).filePath("qodeassist/chat_temp_files");
@@ -168,7 +167,7 @@ QString ChatFileManager::getIntermediateStorageDir()
return intermediatePath;
}
-QString ChatFileManager::generateIntermediateFileName(const QString &originalPath)
+QString AttachmentStaging::generateIntermediateFileName(const QString &originalPath)
{
const QFileInfo fileInfo(originalPath);
const QString extension = fileInfo.suffix();
diff --git a/sources/ChatView/ChatFileManager.hpp b/sources/ChatView/AttachmentStaging.hpp
similarity index 88%
rename from sources/ChatView/ChatFileManager.hpp
rename to sources/ChatView/AttachmentStaging.hpp
index 0f1ef41..5a372e5 100644
--- a/sources/ChatView/ChatFileManager.hpp
+++ b/sources/ChatView/AttachmentStaging.hpp
@@ -7,17 +7,16 @@
#include
#include
#include
-#include
namespace QodeAssist::Chat {
-class ChatFileManager : public QObject
+class AttachmentStaging : public QObject
{
Q_OBJECT
public:
- explicit ChatFileManager(QObject *parent = nullptr);
- ~ChatFileManager();
+ explicit AttachmentStaging(QObject *parent = nullptr);
+ ~AttachmentStaging();
QStringList processDroppedFiles(const QStringList &filePaths);
void setChatFilePath(const QString &chatFilePath);
diff --git a/sources/ChatView/CMakeLists.txt b/sources/ChatView/CMakeLists.txt
index 2fae156..4294ca5 100644
--- a/sources/ChatView/CMakeLists.txt
+++ b/sources/ChatView/CMakeLists.txt
@@ -16,7 +16,10 @@ qt_add_qml_module(QodeAssistChatView
qml/chatparts/TextBlock.qml
qml/chatparts/ThinkingBlock.qml
qml/chatparts/ToolBlock.qml
+ qml/chatparts/PermissionBlock.qml
+ qml/chatparts/PlanBlock.qml
qml/chatparts/ChatItem.qml
+ qml/chatparts/BlockPayload.js
qml/controls/AttachedFilesPlace.qml
qml/controls/BottomBar.qml
@@ -26,7 +29,7 @@ qt_add_qml_module(QodeAssistChatView
qml/controls/SkillCommandPopup.qml
qml/controls/Toast.qml
qml/controls/TopBar.qml
- qml/controls/SplitDropZone.qml
+ qml/controls/DropZone.qml
qml/controls/MessageNavigator.qml
RESOURCES
@@ -34,8 +37,6 @@ qt_add_qml_module(QodeAssistChatView
icons/attach-file-dark.svg
icons/close-dark.svg
icons/close-light.svg
- icons/link-file-light.svg
- icons/link-file-dark.svg
icons/image-dark.svg
icons/load-chat-dark.svg
icons/save-chat-dark.svg
@@ -67,22 +68,21 @@ qt_add_qml_module(QodeAssistChatView
ChatModel.hpp ChatModel.cpp
ChatRootView.hpp ChatRootView.cpp
ChatController.hpp ChatController.cpp
+ ConversationPorts.hpp
+ ConversationCoordinator.hpp ConversationCoordinator.cpp
LlmChatBackend.hpp LlmChatBackend.cpp
MessagePart.hpp
ChatUtils.h ChatUtils.cpp
- ChatSerializer.hpp ChatSerializer.cpp
- ChatHistoryBridge.hpp ChatHistoryBridge.cpp
+ ChatFileStore.hpp ChatFileStore.cpp
TurnContextAdapters.hpp TurnContextAdapters.cpp
ChatView.hpp ChatView.cpp
ChatData.hpp
FileItem.hpp FileItem.cpp
- ChatFileManager.hpp ChatFileManager.cpp
+ AttachmentStaging.hpp AttachmentStaging.cpp
ChatCompressor.hpp ChatCompressor.cpp
- AgentRoleController.hpp AgentRoleController.cpp
ChatConfigurationController.hpp ChatConfigurationController.cpp
FileEditController.hpp FileEditController.cpp
InputTokenCounter.hpp InputTokenCounter.cpp
- ChatHistoryStore.hpp ChatHistoryStore.cpp
FileMentionItem.hpp FileMentionItem.cpp
SessionFileRegistry.hpp SessionFileRegistry.cpp
)
@@ -98,6 +98,7 @@ target_link_libraries(QodeAssistChatView
QodeAssistSettings
Context
QodeAssistSession
+ QodeAssistAcp
QodeAssistUIControlsplugin
QodeAssistLogger
LLMQore
diff --git a/sources/ChatView/ChatCompressor.cpp b/sources/ChatView/ChatCompressor.cpp
index 666da2f..e5922f4 100644
--- a/sources/ChatView/ChatCompressor.cpp
+++ b/sources/ChatView/ChatCompressor.cpp
@@ -25,15 +25,48 @@ ChatCompressor::ChatCompressor(QObject *parent)
: QObject(parent)
{}
+QString ChatCompressor::configurationIssue()
+{
+ auto &settings = Settings::generalSettings();
+
+ if (settings.caProvider().isEmpty())
+ return tr("no provider is assigned to the chat feature");
+ if (settings.caModel().isEmpty())
+ return tr("no model is assigned to the chat feature");
+ if (settings.caTemplate().isEmpty())
+ return tr("no prompt template is assigned to the chat feature");
+ if (settings.caUrl().isEmpty())
+ return tr("the chat feature has no URL configured");
+
+ if (!Providers::ProvidersManager::instance().getProviderByName(settings.caProvider()))
+ return tr("the provider \"%1\" is not available").arg(settings.caProvider());
+
+ if (!Templates::PromptTemplateManager::instance().getChatTemplateByName(settings.caTemplate()))
+ return tr("the prompt template \"%1\" is not available").arg(settings.caTemplate());
+
+ return {};
+}
+
+void ChatCompressor::startSummary(const Session::ConversationHistory &history)
+{
+ beginCompression(QString(), history, /*summaryOnly*/ true);
+}
+
void ChatCompressor::startCompression(
const QString &chatFilePath, const Session::ConversationHistory &history)
+{
+ beginCompression(chatFilePath, history, /*summaryOnly*/ false);
+}
+
+void ChatCompressor::beginCompression(
+ const QString &chatFilePath, const Session::ConversationHistory &history, bool summaryOnly)
{
if (m_isCompressing) {
emit compressionFailed(tr("Compression already in progress"));
return;
}
- if (chatFilePath.isEmpty()) {
+ if (!summaryOnly && chatFilePath.isEmpty()) {
emit compressionFailed(tr("No chat file to compress"));
return;
}
@@ -62,11 +95,13 @@ void ChatCompressor::startCompression(
}
m_isCompressing = true;
+ m_summaryOnly = summaryOnly;
m_rows = rows;
m_originalChatPath = chatFilePath;
m_accumulatedSummary.clear();
emit compressionStarted();
+ emit compressingChanged();
connectProviderSignals();
@@ -122,6 +157,19 @@ void ChatCompressor::onFullResponseReceived(const QString &requestId, const QStr
LOG_MESSAGE(
QString("Received summary, length: %1 characters").arg(m_accumulatedSummary.length()));
+ if (m_summaryOnly) {
+ const QString summary = m_accumulatedSummary.trimmed();
+ cleanupState();
+
+ if (summary.isEmpty()) {
+ emit compressionFailed(tr("The summary came back empty"));
+ return;
+ }
+
+ emit summaryReady(summary);
+ return;
+ }
+
QString compressedPath = createCompressedChatPath(m_originalChatPath);
if (!createCompressedChatFile(m_originalChatPath, compressedPath, m_accumulatedSummary)) {
handleCompressionError(tr("Failed to save compressed chat"));
@@ -181,12 +229,13 @@ void ChatCompressor::buildRequestPayload(
QVector messages;
for (const Session::MessageRow &row : std::as_const(m_rows)) {
- if (row.kind == Session::RowKind::Tool || row.kind == Session::RowKind::FileEdit
- || row.kind == Session::RowKind::Thinking)
+ const Session::RowTreatment treatment
+ = Session::rowTreatmentFor(Session::RowAudience::Compression, row.kind);
+ if (treatment == Session::RowTreatment::Omit)
continue;
LLMCore::Message apiMessage;
- apiMessage.role = row.kind == Session::RowKind::User ? "user" : "assistant";
+ apiMessage.role = treatment == Session::RowTreatment::UserText ? "user" : "assistant";
apiMessage.content = row.content;
messages.append(apiMessage);
}
@@ -282,12 +331,18 @@ void ChatCompressor::cleanupState()
{
disconnectAllSignals();
+ const bool wasCompressing = m_isCompressing;
+
m_isCompressing = false;
+ m_summaryOnly = false;
m_currentRequestId.clear();
m_originalChatPath.clear();
m_accumulatedSummary.clear();
m_rows.clear();
m_provider = nullptr;
+
+ if (wasCompressing)
+ emit compressingChanged();
}
} // namespace QodeAssist::Chat
diff --git a/sources/ChatView/ChatCompressor.hpp b/sources/ChatView/ChatCompressor.hpp
index 8e10092..d9aba11 100644
--- a/sources/ChatView/ChatCompressor.hpp
+++ b/sources/ChatView/ChatCompressor.hpp
@@ -30,13 +30,22 @@ public:
void startCompression(
const QString &chatFilePath, const Session::ConversationHistory &history);
+ void startSummary(const Session::ConversationHistory &history);
+
+signals:
+ void compressingChanged();
+
+public:
bool isCompressing() const;
void cancelCompression();
+ static QString configurationIssue();
+
signals:
void compressionStarted();
void compressionCompleted(const QString &compressedChatPath);
+ void summaryReady(const QString &summary);
void compressionFailed(const QString &error);
private slots:
@@ -54,8 +63,13 @@ private:
void cleanupState();
void handleCompressionError(const QString &error);
void buildRequestPayload(QJsonObject &payload, Templates::PromptTemplate *promptTemplate);
+ void beginCompression(
+ const QString &chatFilePath,
+ const Session::ConversationHistory &history,
+ bool summaryOnly);
bool m_isCompressing = false;
+ bool m_summaryOnly = false;
QString m_currentRequestId;
QString m_originalChatPath;
QString m_accumulatedSummary;
diff --git a/sources/ChatView/ChatConfigurationController.cpp b/sources/ChatView/ChatConfigurationController.cpp
index fd4673c..35c2cad 100644
--- a/sources/ChatView/ChatConfigurationController.cpp
+++ b/sources/ChatView/ChatConfigurationController.cpp
@@ -8,9 +8,19 @@
#include "ConfigurationManager.hpp"
#include "GeneralSettings.hpp"
+#include "acp/AgentCatalogStore.hpp"
namespace QodeAssist::Chat {
+namespace {
+
+QString agentEntry(const QString &agentName)
+{
+ return ChatConfigurationController::tr("Agent: %1").arg(agentName);
+}
+
+} // namespace
+
ChatConfigurationController::ChatConfigurationController(QObject *parent)
: QObject(parent)
{
@@ -29,6 +39,26 @@ ChatConfigurationController::ChatConfigurationController(QObject *parent)
loadAvailableConfigurations();
}
+void ChatConfigurationController::setAgentCatalog(Acp::AgentCatalogStore *store)
+{
+ if (m_agents == store)
+ return;
+
+ if (m_agents)
+ disconnect(m_agents, nullptr, this, nullptr);
+
+ m_agents = store;
+ if (m_agents) {
+ connect(
+ m_agents,
+ &Acp::AgentCatalogStore::catalogChanged,
+ this,
+ &ChatConfigurationController::loadAvailableConfigurations);
+ }
+
+ loadAvailableConfigurations();
+}
+
QStringList ChatConfigurationController::availableConfigurations() const
{
return m_availableConfigurations;
@@ -41,12 +71,33 @@ QString ChatConfigurationController::currentConfiguration() const
void ChatConfigurationController::updateCurrentConfiguration()
{
+ if (!m_boundAgentName.isEmpty()) {
+ m_currentConfiguration = agentEntry(m_boundAgentName);
+ emit currentConfigurationChanged();
+ return;
+ }
+
auto &settings = Settings::generalSettings();
m_currentConfiguration
= QString("%1 - %2").arg(settings.caProvider.value(), settings.caModel.value());
emit currentConfigurationChanged();
}
+void ChatConfigurationController::setBoundAgent(const Acp::AgentDefinition &agent)
+{
+ m_boundAgentName = agent.name;
+ updateCurrentConfiguration();
+}
+
+void ChatConfigurationController::clearBoundAgent()
+{
+ if (m_boundAgentName.isEmpty())
+ return;
+
+ m_boundAgentName.clear();
+ updateCurrentConfiguration();
+}
+
void ChatConfigurationController::loadAvailableConfigurations()
{
auto &manager = Settings::ConfigurationManager::instance();
@@ -56,23 +107,56 @@ void ChatConfigurationController::loadAvailableConfigurations()
Settings::ConfigurationType::Chat);
m_availableConfigurations.clear();
+ m_agentIdByEntry.clear();
m_availableConfigurations.append(QObject::tr("Current Settings"));
for (const Settings::AIConfiguration &config : configs) {
m_availableConfigurations.append(config.name);
}
+ if (m_agents) {
+ for (const Acp::AgentDefinition &agent : m_agents->catalog().launchableAgents()) {
+ const QString entry = agentEntry(agent.name);
+ m_agentIdByEntry.insert(entry, agent.id);
+ m_availableConfigurations.append(entry);
+ }
+ }
+
updateCurrentConfiguration();
emit availableConfigurationsChanged();
}
+std::optional ChatConfigurationController::agentById(const QString &agentId)
+{
+ if (agentId.isEmpty() || !m_agents)
+ return std::nullopt;
+
+ if (auto agent = m_agents->catalog().agent(agentId))
+ return agent;
+
+ m_agents->reload();
+ return m_agents->catalog().agent(agentId);
+}
+
void ChatConfigurationController::applyConfiguration(const QString &configName)
{
- if (configName == QObject::tr("Current Settings")) {
+ const QString agentId = m_agentIdByEntry.value(configName);
+ if (!agentId.isEmpty()) {
+ if (!m_agents)
+ return;
+ if (const auto agent = m_agents->catalog().agent(agentId))
+ emit agentRequested(*agent);
return;
}
+ if (configName == QObject::tr("Current Settings")) {
+ emit llmRequested();
+ return;
+ }
+
+ emit llmRequested();
+
auto &manager = Settings::ConfigurationManager::instance();
QVector configs = manager.configurations(
Settings::ConfigurationType::Chat);
diff --git a/sources/ChatView/ChatConfigurationController.hpp b/sources/ChatView/ChatConfigurationController.hpp
index bb4ec1e..fd35c24 100644
--- a/sources/ChatView/ChatConfigurationController.hpp
+++ b/sources/ChatView/ChatConfigurationController.hpp
@@ -4,9 +4,18 @@
#pragma once
+#include
+
+#include
#include
#include
+#include "acp/AgentDefinition.hpp"
+
+namespace QodeAssist::Acp {
+class AgentCatalogStore;
+}
+
namespace QodeAssist::Chat {
class ChatConfigurationController : public QObject
@@ -19,18 +28,30 @@ public:
QStringList availableConfigurations() const;
QString currentConfiguration() const;
+ void setAgentCatalog(Acp::AgentCatalogStore *store);
+
void loadAvailableConfigurations();
void applyConfiguration(const QString &configName);
+ void setBoundAgent(const Acp::AgentDefinition &agent);
+ void clearBoundAgent();
+
+ std::optional agentById(const QString &agentId);
+
signals:
void availableConfigurationsChanged();
void currentConfigurationChanged();
+ void agentRequested(const QodeAssist::Acp::AgentDefinition &agent);
+ void llmRequested();
private:
void updateCurrentConfiguration();
+ Acp::AgentCatalogStore *m_agents = nullptr;
QStringList m_availableConfigurations;
+ QHash m_agentIdByEntry;
QString m_currentConfiguration;
+ QString m_boundAgentName;
};
} // namespace QodeAssist::Chat
diff --git a/sources/ChatView/ChatController.cpp b/sources/ChatView/ChatController.cpp
index cdbb304..ba0f590 100644
--- a/sources/ChatView/ChatController.cpp
+++ b/sources/ChatView/ChatController.cpp
@@ -8,15 +8,19 @@
#include
#include
-#include "ChatHistoryBridge.hpp"
-#include "ChatSerializer.hpp"
+#include
+#include
+#include
+
+#include "ChatFileStore.hpp"
#include "LlmChatBackend.hpp"
#include "TurnContextAdapters.hpp"
-#include "context/ChangesManager.h"
-#include "context/RulesLoader.hpp"
+#include "context/FileEditManager.hpp"
#include "logger/Logger.hpp"
#include "session/FileEditPayload.hpp"
#include "session/TurnContextBuilder.hpp"
+#include "acp/AcpChatBackend.hpp"
+#include "mcp/AgentKnowledgeServer.hpp"
#include "settings/ChatAssistantSettings.hpp"
namespace QodeAssist::Chat {
@@ -28,9 +32,42 @@ ChatController::ChatController(
, m_chatModel(chatModel)
, m_contextManager(new Context::ContextManager(this))
, m_session(new Session::Session(this))
- , m_backend(new LlmChatBackend(promptProvider, this))
+ , m_llmBackend(new LlmChatBackend(promptProvider, this))
+ , m_acpBackend(new Acp::AcpChatBackend(this))
+ , m_agentKnowledge(new Mcp::AgentKnowledgeServer(this))
+ , m_backend(m_llmBackend)
{
- new ChatHistoryBridge(m_session, chatModel, this);
+ connect(m_session, &Session::Session::rowsReset, m_chatModel, &ChatModel::resetMessages);
+ connect(m_session, &Session::Session::rowsAppended, m_chatModel, &ChatModel::appendMessages);
+ connect(m_session, &Session::Session::rowUpdated, m_chatModel, &ChatModel::updateMessage);
+ connect(m_session, &Session::Session::rowsRemoved, m_chatModel, &ChatModel::removeMessages);
+ m_chatModel->resetMessages(m_session->rows());
+
+ m_acpBackend->setStoredContentLoader(&ChatFileStore::loadRawContentFromStorage);
+ m_agentKnowledge->setIgnorePredicate([this](const QString &filePath) {
+ auto *project = ProjectExplorer::ProjectManager::projectForFile(
+ Utils::FilePath::fromString(filePath));
+ return m_contextManager->ignoreManager()->shouldIgnore(filePath, project);
+ });
+ m_acpBackend->setKnowledgeService(m_agentKnowledge);
+
+ connect(
+ m_session,
+ &Session::Session::sessionInfoReceived,
+ this,
+ &ChatController::sessionInfoReceived);
+
+ connect(
+ m_acpBackend,
+ &Acp::AcpChatBackend::agentSessionUnavailable,
+ this,
+ &ChatController::agentSessionUnavailable);
+
+ connect(
+ m_acpBackend,
+ &Acp::AcpChatBackend::availableCommandsChanged,
+ this,
+ &ChatController::agentCommandsChanged);
m_session->setBackend(m_backend);
@@ -39,7 +76,7 @@ ChatController::ChatController(
connect(m_session, &Session::Session::turnFinished, this, [this](const QString &turnId) {
QString applyError;
- if (!Context::ChangesManager::instance().applyPendingEditsForRequest(turnId, &applyError)) {
+ if (!Context::FileEditManager::instance().applyPendingEditsForRequest(turnId, &applyError)) {
LOG_MESSAGE(QString("Some edits for request %1 were not auto-applied: %2")
.arg(turnId, applyError));
}
@@ -56,14 +93,53 @@ ChatController::ChatController(
connect(m_session, &Session::Session::rowsReset, this, [this] { registerHistoricalEdits(); });
- auto &changes = Context::ChangesManager::instance();
- connect(&changes, &Context::ChangesManager::fileEditApplied, this, [this](const QString &id) {
+ connect(
+ m_session,
+ &Session::Session::agentFileEditRecorded,
+ this,
+ [](const QString &turnId,
+ const QString &editId,
+ const QString &filePath,
+ const QString &oldContent,
+ const QString &newContent) {
+ const QFileInfo info(filePath);
+ const QString canonical = info.canonicalFilePath();
+ const Utils::FilePath target = Utils::FilePath::fromString(
+ canonical.isEmpty() ? info.absoluteFilePath() : canonical);
+
+ bool insideProject = false;
+ const QList projects
+ = ProjectExplorer::ProjectManager::projects();
+ for (const ProjectExplorer::Project *project : projects) {
+ if (target.isChildOf(project->projectDirectory())) {
+ insideProject = true;
+ break;
+ }
+ }
+
+ if (!insideProject) {
+ LOG_MESSAGE(
+ QString("Agent edit %1 targets %2 outside every open project; recorded "
+ "in the transcript only, without apply/undo actions")
+ .arg(editId, filePath));
+ return;
+ }
+
+ Context::FileEditManager::instance().registerAppliedFileEdit(
+ editId, target.toUrlishString(), oldContent, newContent, turnId);
+ });
+
+ auto &changes = Context::FileEditManager::instance();
+ connect(&changes, &Context::FileEditManager::fileEditApplied, this, [this](const QString &id) {
m_session->updateFileEditStatus(id, "applied", "Successfully applied");
});
- connect(&changes, &Context::ChangesManager::fileEditRejected, this, [this](const QString &id) {
+ connect(&changes, &Context::FileEditManager::fileEditRejected, this, [this](const QString &id) {
m_session->updateFileEditStatus(id, "rejected", "Rejected by user");
});
- connect(&changes, &Context::ChangesManager::fileEditArchived, this, [this](const QString &id) {
+ connect(&changes, &Context::FileEditManager::fileEditUndone, this, [this](const QString &id) {
+ recordFileEditStatus(id, "rejected", "Successfully undone");
+ });
+ connect(&changes, &Context::FileEditManager::fileEditArchived, this, [this](const QString &id) {
m_session->updateFileEditStatus(id, "archived", "Archived (from previous conversation turn)");
});
}
@@ -73,32 +149,107 @@ void ChatController::setSkillsManager(Skills::SkillsManager *skillsManager)
m_skillsManager = skillsManager;
}
+void ChatController::bindAgent(const Acp::AgentDefinition &agent)
+{
+ m_acpBackend->bindAgent(agent);
+ activateBackend(m_acpBackend);
+}
+
+void ChatController::bindLlm()
+{
+ activateBackend(m_llmBackend);
+}
+
+QString ChatController::boundAgentId() const
+{
+ return m_backend == m_acpBackend ? m_acpBackend->boundAgentId() : QString();
+}
+
+QString ChatController::boundAgentName() const
+{
+ return m_backend == m_acpBackend ? m_acpBackend->boundAgentName() : QString();
+}
+
+QList ChatController::agentCommands() const
+{
+ if (m_backend != m_acpBackend)
+ return {};
+ return m_acpBackend->availableCommands();
+}
+
+bool ChatController::transcriptEmpty() const
+{
+ return m_session->rows().isEmpty();
+}
+
+Acp::AgentBinding ChatController::agentBinding() const
+{
+ if (m_backend != m_acpBackend)
+ return {};
+
+ return Acp::AgentBinding{m_acpBackend->boundAgentId(), m_acpBackend->bindingSessionId()};
+}
+
+void ChatController::resumeAgentSession(const QString &sessionId)
+{
+ m_acpBackend->resumeSession(sessionId);
+}
+
+void ChatController::startFreshAgentSession()
+{
+ m_acpBackend->startFreshSession();
+}
+
+void ChatController::startFreshAgentSession(const QString &handoverSummary)
+{
+ m_acpBackend->clearToolSession(m_chatFilePath);
+ m_acpBackend->startFreshSession();
+ m_acpBackend->setHandoverSummary(handoverSummary);
+}
+
+void ChatController::releaseAgentSession()
+{
+ m_acpBackend->clearToolSession(m_chatFilePath);
+}
+
+bool ChatController::conversationStarted() const
+{
+ return !m_session->history().messages().isEmpty();
+}
+
+void ChatController::activateBackend(Session::ChatBackend *backend)
+{
+ if (m_backend == backend)
+ return;
+
+ m_session->cancel();
+
+ if (m_backend)
+ m_backend->clearToolSession(m_chatFilePath);
+
+ m_backend = backend;
+ m_session->setBackend(backend);
+ backend->setChatFilePath(m_chatFilePath);
+}
+
Session::Session *ChatController::session() const
{
return m_session;
}
-void ChatController::sendMessage(
- const QString &message,
- const QList &attachments,
- const QList &linkedFiles,
- bool useTools,
- bool useThinking)
+void ChatController::sendMessage(const QString &message, const QList &attachments)
{
if (message.trimmed().isEmpty() && attachments.isEmpty()) {
LOG_MESSAGE("Ignoring empty chat message");
return;
}
- Context::ChangesManager::instance().archiveAllNonArchivedEdits();
+ Context::FileEditManager::instance().archiveAllNonArchivedEdits();
- m_session->sendTurn(
- composeUserBlocks(message, attachments),
- buildTurnContext(message, linkedFiles),
- Session::TurnOptions{useTools, useThinking});
+ m_session->sendTurn(composeUserBlocks(message, attachments), buildTurnContext(message));
}
-void ChatController::clearMessages()
+void ChatController::clearConversation()
{
m_backend->clearToolSession(m_chatFilePath);
m_session->clear();
@@ -114,6 +265,11 @@ void ChatController::resetToRow(int rowIndex)
m_session->truncateRows(rowIndex);
}
+void ChatController::respondToPermission(const QString &requestId, const QString &optionId)
+{
+ m_session->respondPermission(requestId, optionId);
+}
+
QList ChatController::composeUserBlocks(
const QString &message, const QList &attachments)
{
@@ -132,7 +288,7 @@ QList ChatController::composeUserBlocks(
if (!textFiles.isEmpty() && !m_chatFilePath.isEmpty()) {
for (const auto &file : m_contextManager->getContentFiles(textFiles)) {
QString storedPath;
- if (!ChatSerializer::saveContentToStorage(
+ if (!ChatFileStore::saveContentToStorage(
m_chatFilePath, file.filename, file.content.toUtf8().toBase64(), storedPath)) {
continue;
}
@@ -152,7 +308,7 @@ QList ChatController::composeUserBlocks(
const QFileInfo fileInfo(imagePath);
QString storedPath;
- if (!ChatSerializer::saveContentToStorage(
+ if (!ChatFileStore::saveContentToStorage(
m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) {
continue;
}
@@ -169,32 +325,23 @@ QList ChatController::composeUserBlocks(
return blocks;
}
-std::optional ChatController::buildTurnContext(
- const QString &message, const QList &linkedFiles) const
+Session::TurnContext ChatController::buildTurnContext(const QString &message) const
{
auto &chatAssistantSettings = Settings::chatAssistantSettings();
- if (!chatAssistantSettings.useSystemPrompt())
- return std::nullopt;
Session::TurnContextRequest contextRequest;
contextRequest.message = message;
- contextRequest.basePrompt = chatAssistantSettings.systemPrompt();
- contextRequest.linkedFilePaths = linkedFiles;
+ contextRequest.needs = m_backend->contextNeeds();
- const QString lastRoleId = chatAssistantSettings.lastUsedRoleId();
- if (!lastRoleId.isEmpty()) {
- const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId);
- if (!role.id.isEmpty())
- contextRequest.rolePrompt = role.systemPrompt;
- }
+ if (contextRequest.needs.systemPrompt)
+ contextRequest.basePrompt = chatAssistantSettings.systemPrompt();
- auto *project = Context::RulesLoader::getActiveProject();
+ auto *project = activeProject();
ProjectContextQtCreator projectPort(project);
- LinkedFilesQtCreator linkedFilesPort(m_contextManager);
auto skillsPort = makeSkillsContext(m_skillsManager, project);
- const Session::TurnContextBuilder builder(projectPort, skillsPort.get(), linkedFilesPort);
+ const Session::TurnContextBuilder builder(projectPort, skillsPort.get());
return builder.build(contextRequest);
}
@@ -202,7 +349,7 @@ std::optional ChatController::buildTurnContext(
void ChatController::recordFileEditStatus(
const QString &editId, const QString &status, const QString &fallbackMessage)
{
- const auto edit = Context::ChangesManager::instance().getFileEdit(editId);
+ const auto edit = Context::FileEditManager::instance().getFileEdit(editId);
const QString message = edit.statusMessage.isEmpty() ? fallbackMessage : edit.statusMessage;
m_session->updateFileEditStatus(editId, status, message);
}
@@ -232,7 +379,7 @@ void ChatController::registerHistoricalEdits()
continue;
}
- Context::ChangesManager::instance().addFileEdit(
+ Context::FileEditManager::instance().addFileEdit(
editId,
filePath,
payload->value("old_content").toString(),
diff --git a/sources/ChatView/ChatController.hpp b/sources/ChatView/ChatController.hpp
index 10a1d0c..9da3a61 100644
--- a/sources/ChatView/ChatController.hpp
+++ b/sources/ChatView/ChatController.hpp
@@ -7,7 +7,12 @@
#include
#include
+#include
+
#include "ChatModel.hpp"
+#include "ConversationPorts.hpp"
+#include "acp/AgentBinding.hpp"
+#include "acp/AgentDefinition.hpp"
#include "session/Session.hpp"
#include "templates/IPromptProvider.hpp"
#include
@@ -16,12 +21,19 @@ namespace QodeAssist::Skills {
class SkillsManager;
}
+namespace QodeAssist::Acp {
+class AcpChatBackend;
+}
+
+namespace QodeAssist::Mcp {
+class AgentKnowledgeServer;
+}
+
namespace QodeAssist::Chat {
-class ChatHistoryBridge;
class LlmChatBackend;
-class ChatController : public QObject
+class ChatController : public QObject, public IConversationPort
{
Q_OBJECT
@@ -31,15 +43,10 @@ public:
void setSkillsManager(Skills::SkillsManager *skillsManager);
- void sendMessage(
- const QString &message,
- const QList &attachments = {},
- const QList &linkedFiles = {},
- bool useTools = false,
- bool useThinking = false);
- void clearMessages();
+ void sendMessage(const QString &message, const QList &attachments = {});
void cancelRequest();
void resetToRow(int rowIndex);
+ void respondToPermission(const QString &requestId, const QString &optionId);
Session::Session *session() const;
Context::ContextManager *contextManager() const;
@@ -47,7 +54,26 @@ public:
void setChatFilePath(const QString &filePath);
QString chatFilePath() const;
+ QString boundAgentId() const override;
+ QString boundAgentName() const;
+ QList agentCommands() const;
+ bool conversationStarted() const override;
+ bool transcriptEmpty() const override;
+ Acp::AgentBinding agentBinding() const override;
+
+ void bindAgent(const Acp::AgentDefinition &agent) override;
+ void bindLlm() override;
+ void clearConversation() override;
+
+ void resumeAgentSession(const QString &sessionId) override;
+ void startFreshAgentSession() override;
+ void startFreshAgentSession(const QString &handoverSummary) override;
+ void releaseAgentSession() override;
+
signals:
+ void sessionInfoReceived(const QString &title);
+ void agentCommandsChanged();
+ void agentSessionUnavailable(const QString &reason);
void errorOccurred(const QString &error);
void messageReceivedCompletely();
void requestStarted(const QString &requestId);
@@ -57,11 +83,11 @@ signals:
private:
QList composeUserBlocks(
const QString &message, const QList &attachments);
- std::optional buildTurnContext(
- const QString &message, const QList &linkedFiles) const;
+ Session::TurnContext buildTurnContext(const QString &message) const;
void recordFileEditStatus(
const QString &editId, const QString &status, const QString &fallbackMessage);
void registerHistoricalEdits();
+ void activateBackend(Session::ChatBackend *backend);
bool isImageFile(const QString &filePath) const;
QString getMediaTypeForImage(const QString &filePath) const;
@@ -72,7 +98,10 @@ private:
Context::ContextManager *m_contextManager = nullptr;
Skills::SkillsManager *m_skillsManager = nullptr;
Session::Session *m_session = nullptr;
- LlmChatBackend *m_backend = nullptr;
+ LlmChatBackend *m_llmBackend = nullptr;
+ Acp::AcpChatBackend *m_acpBackend = nullptr;
+ Mcp::AgentKnowledgeServer *m_agentKnowledge = nullptr;
+ Session::ChatBackend *m_backend = nullptr;
QString m_chatFilePath;
};
diff --git a/sources/ChatView/ChatFileStore.cpp b/sources/ChatView/ChatFileStore.cpp
new file mode 100644
index 0000000..0072561
--- /dev/null
+++ b/sources/ChatView/ChatFileStore.cpp
@@ -0,0 +1,429 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include "ChatFileStore.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+#include "Logger.hpp"
+#include "ProjectSettings.hpp"
+#include "session/HistorySerializer.hpp"
+#include "session/Session.hpp"
+
+namespace QodeAssist::Chat {
+
+ChatFileStore::ChatFileStore(Session::Session *session, QObject *parent)
+ : QObject(parent)
+ , m_session(session)
+{}
+
+QString ChatFileStore::historyDir() const
+{
+ QString path;
+
+ if (auto project = ProjectExplorer::ProjectManager::startupProject()) {
+ Settings::ProjectSettings projectSettings(project);
+ path = projectSettings.chatHistoryPath().toFSPathString();
+ } else {
+ QDir baseDir(Core::ICore::userResourcePath().toFSPathString());
+ path = baseDir.filePath("qodeassist/chat_history");
+ }
+
+ QDir dir(path);
+ if (!dir.exists() && !dir.mkpath(".")) {
+ LOG_MESSAGE(QString("Failed to create directory: %1").arg(path));
+ return QString();
+ }
+
+ return path;
+}
+
+QString ChatFileStore::suggestedFileName() const
+{
+ QString shortMessage;
+
+ if (!m_session)
+ return generateChatFileName(shortMessage, historyDir());
+
+ const QList &rows = m_session->rows();
+ if (!rows.isEmpty()) {
+ shortMessage = rows.first().content.split('\n').first().simplified().left(30);
+
+ if (shortMessage.isEmpty() && !rows.first().images.isEmpty())
+ shortMessage = "image_chat";
+ }
+
+ return generateChatFileName(shortMessage, historyDir());
+}
+
+QString ChatFileStore::autosaveFilePath(const QString &recentFilePath) const
+{
+ if (!recentFilePath.isEmpty()) {
+ return recentFilePath;
+ }
+
+ QString dir = historyDir();
+ if (dir.isEmpty()) {
+ return QString();
+ }
+
+ return QDir(dir).filePath(suggestedFileName() + ".json");
+}
+
+QString ChatFileStore::autosaveFilePath(
+ const QString &recentFilePath, const QString &firstMessage, bool hasImageAttachments) const
+{
+ if (!recentFilePath.isEmpty()) {
+ return recentFilePath;
+ }
+
+ QString dir = historyDir();
+ if (dir.isEmpty()) {
+ return QString();
+ }
+
+ QString shortMessage = firstMessage.split('\n').first().simplified().left(30);
+
+ if (shortMessage.isEmpty() && hasImageAttachments) {
+ shortMessage = "image_chat";
+ }
+
+ QString fileName = generateChatFileName(shortMessage, dir);
+ return QDir(dir).filePath(fileName + ".json");
+}
+
+void ChatFileStore::setBindingReader(BindingReader reader)
+{
+ m_bindingReader = std::move(reader);
+}
+
+void ChatFileStore::setBindingWriter(BindingWriter writer)
+{
+ m_bindingWriter = std::move(writer);
+}
+
+SerializationResult ChatFileStore::save(const QString &filePath) const
+{
+ if (!m_session)
+ return {false, QString("Chat session is no longer available")};
+
+ const Acp::AgentBinding binding = m_bindingReader ? m_bindingReader() : Acp::AgentBinding{};
+ return saveToFile(m_session->history(), binding, filePath);
+}
+
+SerializationResult ChatFileStore::load(const QString &filePath) const
+{
+ if (!m_session)
+ return {false, QString("Chat session is no longer available")};
+
+ Session::ConversationHistory history;
+ Acp::AgentBinding binding;
+ const SerializationResult result = loadFromFile(history, binding, filePath);
+ if (!result.success)
+ return result;
+
+ m_session->setHistory(history);
+ if (m_bindingWriter)
+ m_bindingWriter(binding);
+
+ return result;
+}
+
+void ChatFileStore::showSaveDialog()
+{
+ QString initialDir = historyDir();
+
+ QFileDialog *dialog = new QFileDialog(nullptr, tr("Save Chat History"));
+ dialog->setAcceptMode(QFileDialog::AcceptSave);
+ dialog->setFileMode(QFileDialog::AnyFile);
+ dialog->setNameFilter(tr("JSON files (*.json)"));
+ dialog->setDefaultSuffix("json");
+ if (!initialDir.isEmpty()) {
+ dialog->setDirectory(initialDir);
+ dialog->selectFile(suggestedFileName() + ".json");
+ }
+
+ connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) {
+ if (result == QFileDialog::Accepted) {
+ QStringList files = dialog->selectedFiles();
+ if (!files.isEmpty()) {
+ emit saveRequested(files.first());
+ }
+ }
+ dialog->deleteLater();
+ });
+
+ dialog->open();
+}
+
+void ChatFileStore::showLoadDialog()
+{
+ QString initialDir = historyDir();
+
+ QFileDialog *dialog = new QFileDialog(nullptr, tr("Load Chat History"));
+ dialog->setAcceptMode(QFileDialog::AcceptOpen);
+ dialog->setFileMode(QFileDialog::ExistingFile);
+ dialog->setNameFilter(tr("JSON files (*.json)"));
+ if (!initialDir.isEmpty()) {
+ dialog->setDirectory(initialDir);
+ }
+
+ connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) {
+ if (result == QFileDialog::Accepted) {
+ QStringList files = dialog->selectedFiles();
+ if (!files.isEmpty()) {
+ emit loadRequested(files.first());
+ }
+ }
+ dialog->deleteLater();
+ });
+
+ dialog->open();
+}
+
+void ChatFileStore::openHistoryFolder() const
+{
+ QString path;
+ if (auto project = ProjectExplorer::ProjectManager::startupProject()) {
+ Settings::ProjectSettings projectSettings(project);
+ path = projectSettings.chatHistoryPath().toFSPathString();
+ } else {
+ QDir baseDir(Core::ICore::userResourcePath().toFSPathString());
+ path = baseDir.filePath("qodeassist/chat_history");
+ }
+
+ QDir dir(path);
+ if (!dir.exists()) {
+ dir.mkpath(".");
+ }
+
+ QUrl url = QUrl::fromLocalFile(dir.absolutePath());
+ QDesktopServices::openUrl(url);
+}
+
+QString ChatFileStore::generateChatFileName(const QString &shortMessage, const QString &dir) const
+{
+ static const QRegularExpression saitizeSymbols = QRegularExpression("[\\/:*?\"<>|\\s]");
+ static const QRegularExpression underSymbols = QRegularExpression("_+");
+
+ QStringList parts;
+ QString sanitizedMessage = shortMessage;
+ sanitizedMessage.replace(saitizeSymbols, "_");
+ sanitizedMessage.replace(underSymbols, "_");
+ sanitizedMessage = sanitizedMessage.trimmed();
+
+ if (!sanitizedMessage.isEmpty()) {
+ if (sanitizedMessage.startsWith('_')) {
+ sanitizedMessage.remove(0, 1);
+ }
+ if (sanitizedMessage.endsWith('_')) {
+ sanitizedMessage.chop(1);
+ }
+
+ QString fullPath = QDir(dir).filePath(sanitizedMessage);
+ QFileInfo fileInfo(fullPath);
+ if (!fileInfo.exists() && QFileInfo(fileInfo.path()).isWritable()) {
+ parts << sanitizedMessage;
+ }
+ }
+
+ parts << QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm");
+
+ QString fileName = parts.join("_");
+ QString fullPath = QDir(dir).filePath(fileName);
+ QFileInfo finalCheck(fullPath);
+
+ if (fileName.isEmpty() || finalCheck.exists() || !QFileInfo(finalCheck.path()).isWritable()) {
+ fileName = QString("chat_%1").arg(QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm"));
+ }
+
+ return fileName;
+}
+
+SerializationResult ChatFileStore::saveToFile(
+ const Session::ConversationHistory &history,
+ const Acp::AgentBinding &binding,
+ const QString &filePath)
+{
+ if (!ensureDirectoryExists(filePath)) {
+ return {false, "Failed to create directory structure"};
+ }
+
+ QJsonObject root = Session::HistorySerializer::toJson(history);
+ if (!binding.isEmpty())
+ root["agent"] = binding.toJson();
+
+ QSaveFile file(filePath);
+ if (!file.open(QIODevice::WriteOnly)) {
+ return {false, QString("Failed to open file for writing: %1").arg(filePath)};
+ }
+
+ if (file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)) == -1) {
+ return {false, QString("Failed to write to file: %1").arg(file.errorString())};
+ }
+
+ if (!file.commit()) {
+ return {false, QString("Failed to save file: %1").arg(file.errorString())};
+ }
+
+ return {true, QString()};
+}
+
+SerializationResult ChatFileStore::loadFromFile(
+ Session::ConversationHistory &history, Acp::AgentBinding &binding, const QString &filePath)
+{
+ QFile file(filePath);
+ if (!file.open(QIODevice::ReadOnly)) {
+ return {false, QString("Failed to open file for reading: %1").arg(filePath)};
+ }
+
+ QJsonParseError error;
+ QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error);
+ if (error.error != QJsonParseError::NoError) {
+ return {false, QString("JSON parse error: %1").arg(error.errorString())};
+ }
+
+ const QJsonObject root = doc.object();
+ const QString version = root["version"].toString();
+
+ if (!Session::HistorySerializer::isSupportedVersion(version)) {
+ return {false, QString("Unsupported version: %1").arg(version)};
+ }
+
+ int droppedBlocks = 0;
+ const auto loaded = Session::HistorySerializer::fromJson(root, &droppedBlocks);
+ if (!loaded) {
+ return {false, QString("Failed to read chat history from: %1").arg(filePath)};
+ }
+
+ if (version != Session::HistorySerializer::currentVersion()) {
+ LOG_MESSAGE(QString("Converted chat from format %1 to %2")
+ .arg(version, Session::HistorySerializer::currentVersion()));
+ }
+
+ history = *loaded;
+
+ QString bindingError;
+ binding = Acp::AgentBinding::fromJson(root["agent"], &bindingError);
+ if (!bindingError.isEmpty()) {
+ const QString warning
+ = QString("This chat records which agent held it, but %1, so it opens unbound")
+ .arg(bindingError);
+ LOG_MESSAGE(QString("%1: %2").arg(filePath, warning));
+ return {true, QString(), warning};
+ }
+
+ if (droppedBlocks > 0) {
+ const QString warning
+ = QString(
+ "%1 message part(s) in this chat could not be read and will be lost if "
+ "the chat is saved again")
+ .arg(droppedBlocks);
+ LOG_MESSAGE(QString("%1: %2").arg(filePath, warning));
+ return {true, QString(), warning};
+ }
+
+ return {true, QString(), QString()};
+}
+
+bool ChatFileStore::ensureDirectoryExists(const QString &filePath)
+{
+ QFileInfo fileInfo(filePath);
+ QDir dir = fileInfo.dir();
+ return dir.exists() || dir.mkpath(".");
+}
+
+QString ChatFileStore::getChatContentFolder(const QString &chatFilePath)
+{
+ QFileInfo fileInfo(chatFilePath);
+ QString baseName = fileInfo.completeBaseName();
+ QString dirPath = fileInfo.absolutePath();
+ return QDir(dirPath).filePath(baseName + "_content");
+}
+
+bool ChatFileStore::saveContentToStorage(
+ const QString &chatFilePath,
+ const QString &fileName,
+ const QString &base64Data,
+ QString &storedPath)
+{
+ QString contentFolder = getChatContentFolder(chatFilePath);
+ QDir dir;
+ if (!dir.exists(contentFolder)) {
+ if (!dir.mkpath(contentFolder)) {
+ LOG_MESSAGE(QString("Failed to create content folder: %1").arg(contentFolder));
+ return false;
+ }
+ }
+
+ QFileInfo originalFileInfo(fileName);
+ QString extension = originalFileInfo.suffix();
+ QString baseName = originalFileInfo.completeBaseName();
+ QString uniqueName = QString("%1_%2.%3")
+ .arg(baseName)
+ .arg(QUuid::createUuid().toString(QUuid::WithoutBraces).left(8))
+ .arg(extension);
+
+ QString fullPath = QDir(contentFolder).filePath(uniqueName);
+
+ QByteArray contentData = QByteArray::fromBase64(base64Data.toUtf8());
+ QFile file(fullPath);
+ if (!file.open(QIODevice::WriteOnly)) {
+ LOG_MESSAGE(QString("Failed to open file for writing: %1").arg(fullPath));
+ return false;
+ }
+
+ if (file.write(contentData) == -1) {
+ LOG_MESSAGE(QString("Failed to write content data: %1").arg(file.errorString()));
+ return false;
+ }
+
+ file.close();
+
+ storedPath = uniqueName;
+ LOG_MESSAGE(QString("Saved content: %1 to %2").arg(fileName, fullPath));
+
+ return true;
+}
+
+QByteArray ChatFileStore::loadRawContentFromStorage(
+ const QString &chatFilePath, const QString &storedPath)
+{
+ QString contentFolder = getChatContentFolder(chatFilePath);
+ QString fullPath = QDir(contentFolder).filePath(storedPath);
+
+ QFile file(fullPath);
+ if (!file.open(QIODevice::ReadOnly)) {
+ LOG_MESSAGE(QString("Failed to open content file: %1").arg(fullPath));
+ return QByteArray();
+ }
+
+ QByteArray contentData = file.readAll();
+ file.close();
+
+ return contentData;
+}
+
+QString ChatFileStore::loadContentFromStorage(
+ const QString &chatFilePath, const QString &storedPath)
+{
+ return QString::fromLatin1(loadRawContentFromStorage(chatFilePath, storedPath).toBase64());
+}
+
+} // namespace QodeAssist::Chat
diff --git a/sources/ChatView/ChatFileStore.hpp b/sources/ChatView/ChatFileStore.hpp
new file mode 100644
index 0000000..2c7131b
--- /dev/null
+++ b/sources/ChatView/ChatFileStore.hpp
@@ -0,0 +1,89 @@
+// Copyright (C) 2024-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#pragma once
+
+#include
+
+#include
+#include
+#include
+
+#include "acp/AgentBinding.hpp"
+#include "session/ConversationHistory.hpp"
+
+namespace QodeAssist::Session {
+class Session;
+}
+
+namespace QodeAssist::Chat {
+
+struct SerializationResult
+{
+ bool success{false};
+ QString errorMessage;
+ QString warningMessage;
+};
+
+class ChatFileStore : public QObject
+{
+ Q_OBJECT
+
+public:
+ explicit ChatFileStore(Session::Session *session, QObject *parent = nullptr);
+
+ QString historyDir() const;
+ QString suggestedFileName() const;
+ QString autosaveFilePath(const QString &recentFilePath) const;
+ QString autosaveFilePath(
+ const QString &recentFilePath,
+ const QString &firstMessage,
+ bool hasImageAttachments) const;
+
+ using BindingReader = std::function;
+ using BindingWriter = std::function;
+
+ void setBindingReader(BindingReader reader);
+ void setBindingWriter(BindingWriter writer);
+
+ SerializationResult save(const QString &filePath) const;
+ SerializationResult load(const QString &filePath) const;
+
+ void showSaveDialog();
+ void showLoadDialog();
+ void openHistoryFolder() const;
+
+ static SerializationResult saveToFile(
+ const Session::ConversationHistory &history,
+ const Acp::AgentBinding &binding,
+ const QString &filePath);
+ static SerializationResult loadFromFile(
+ Session::ConversationHistory &history,
+ Acp::AgentBinding &binding,
+ const QString &filePath);
+
+ static QString getChatContentFolder(const QString &chatFilePath);
+ static bool saveContentToStorage(
+ const QString &chatFilePath,
+ const QString &fileName,
+ const QString &base64Data,
+ QString &storedPath);
+ static QString loadContentFromStorage(const QString &chatFilePath, const QString &storedPath);
+ static QByteArray loadRawContentFromStorage(
+ const QString &chatFilePath, const QString &storedPath);
+
+signals:
+ void saveRequested(const QString &filePath);
+ void loadRequested(const QString &filePath);
+
+private:
+ QString generateChatFileName(const QString &shortMessage, const QString &dir) const;
+ static bool ensureDirectoryExists(const QString &filePath);
+
+ QPointer m_session;
+ BindingReader m_bindingReader;
+ BindingWriter m_bindingWriter;
+};
+
+} // namespace QodeAssist::Chat
diff --git a/sources/ChatView/ChatHistoryBridge.cpp b/sources/ChatView/ChatHistoryBridge.cpp
deleted file mode 100644
index 233bf44..0000000
--- a/sources/ChatView/ChatHistoryBridge.cpp
+++ /dev/null
@@ -1,107 +0,0 @@
-// Copyright (C) 2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#include "ChatHistoryBridge.hpp"
-
-#include "ChatModel.hpp"
-#include "session/Session.hpp"
-
-namespace QodeAssist::Chat {
-
-namespace {
-
-ChatModel::ChatRole toChatRole(Session::RowKind kind)
-{
- switch (kind) {
- case Session::RowKind::System:
- return ChatModel::ChatRole::System;
- case Session::RowKind::User:
- return ChatModel::ChatRole::User;
- case Session::RowKind::Assistant:
- return ChatModel::ChatRole::Assistant;
- case Session::RowKind::Tool:
- return ChatModel::ChatRole::Tool;
- case Session::RowKind::FileEdit:
- return ChatModel::ChatRole::FileEdit;
- case Session::RowKind::Thinking:
- return ChatModel::ChatRole::Thinking;
- }
- return ChatModel::ChatRole::Assistant;
-}
-
-ChatModel::Message toChatMessage(const Session::MessageRow &row)
-{
- ChatModel::Message message;
- message.role = toChatRole(row.kind);
- message.content = row.content;
- message.id = row.id;
- message.isRedacted = row.redacted;
- message.signature = row.signature;
- message.toolName = row.toolName;
- message.toolArguments = row.toolArguments;
- message.toolResult = row.toolResult;
-
- for (const Session::AttachmentBlock &attachment : row.attachments)
- message.attachments.append(Context::ContentFile{attachment.fileName, attachment.storedPath});
-
- for (const Session::ImageBlock &image : row.images)
- message.images.append(
- ChatModel::ImageAttachment{image.fileName, image.storedPath, image.mediaType});
-
- message.promptTokens = row.usage.promptTokens;
- message.completionTokens = row.usage.completionTokens;
- message.cachedPromptTokens = row.usage.cachedPromptTokens;
- message.reasoningTokens = row.usage.reasoningTokens;
-
- return message;
-}
-
-QVector toChatMessages(const QList &rows)
-{
- QVector messages;
- messages.reserve(rows.size());
- for (const Session::MessageRow &row : rows)
- messages.append(toChatMessage(row));
- return messages;
-}
-
-} // namespace
-
-ChatHistoryBridge::ChatHistoryBridge(Session::Session *session, ChatModel *model, QObject *parent)
- : QObject(parent)
- , m_model(model)
-{
- connect(session, &Session::Session::rowsReset, this, &ChatHistoryBridge::onRowsReset);
- connect(session, &Session::Session::rowsAppended, this, &ChatHistoryBridge::onRowsAppended);
- connect(session, &Session::Session::rowUpdated, this, &ChatHistoryBridge::onRowUpdated);
- connect(session, &Session::Session::rowsRemoved, this, &ChatHistoryBridge::onRowsRemoved);
-
- onRowsReset(session->rows());
-}
-
-void ChatHistoryBridge::onRowsReset(const QList &rows)
-{
- if (m_model)
- m_model->resetMessages(toChatMessages(rows));
-}
-
-void ChatHistoryBridge::onRowsAppended(const QList &rows)
-{
- if (m_model)
- m_model->appendMessages(toChatMessages(rows));
-}
-
-void ChatHistoryBridge::onRowUpdated(int index, const Session::MessageRow &row)
-{
- if (m_model)
- m_model->updateMessage(index, toChatMessage(row));
-}
-
-void ChatHistoryBridge::onRowsRemoved(int first, int count)
-{
- if (m_model)
- m_model->removeMessages(first, count);
-}
-
-} // namespace QodeAssist::Chat
diff --git a/sources/ChatView/ChatHistoryBridge.hpp b/sources/ChatView/ChatHistoryBridge.hpp
deleted file mode 100644
index 5f21b1f..0000000
--- a/sources/ChatView/ChatHistoryBridge.hpp
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include
-#include
-#include
-
-#include "session/HistoryProjection.hpp"
-
-namespace QodeAssist::Session {
-class Session;
-}
-
-namespace QodeAssist::Chat {
-
-class ChatModel;
-
-class ChatHistoryBridge : public QObject
-{
- Q_OBJECT
-
-public:
- ChatHistoryBridge(Session::Session *session, ChatModel *model, QObject *parent = nullptr);
-
-private:
- void onRowsReset(const QList &rows);
- void onRowsAppended(const QList &rows);
- void onRowUpdated(int index, const Session::MessageRow &row);
- void onRowsRemoved(int first, int count);
-
- QPointer m_model;
-};
-
-} // namespace QodeAssist::Chat
diff --git a/sources/ChatView/ChatHistoryStore.cpp b/sources/ChatView/ChatHistoryStore.cpp
deleted file mode 100644
index 12ffcc2..0000000
--- a/sources/ChatView/ChatHistoryStore.cpp
+++ /dev/null
@@ -1,236 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#include "ChatHistoryStore.hpp"
-
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-#include
-#include
-#include
-
-#include "Logger.hpp"
-#include "ProjectSettings.hpp"
-#include "session/Session.hpp"
-
-namespace QodeAssist::Chat {
-
-ChatHistoryStore::ChatHistoryStore(Session::Session *session, QObject *parent)
- : QObject(parent)
- , m_session(session)
-{}
-
-QString ChatHistoryStore::historyDir() const
-{
- QString path;
-
- if (auto project = ProjectExplorer::ProjectManager::startupProject()) {
- Settings::ProjectSettings projectSettings(project);
- path = projectSettings.chatHistoryPath().toFSPathString();
- } else {
- QDir baseDir(Core::ICore::userResourcePath().toFSPathString());
- path = baseDir.filePath("qodeassist/chat_history");
- }
-
- QDir dir(path);
- if (!dir.exists() && !dir.mkpath(".")) {
- LOG_MESSAGE(QString("Failed to create directory: %1").arg(path));
- return QString();
- }
-
- return path;
-}
-
-QString ChatHistoryStore::suggestedFileName() const
-{
- QString shortMessage;
-
- if (!m_session)
- return generateChatFileName(shortMessage, historyDir());
-
- const QList &rows = m_session->rows();
- if (!rows.isEmpty()) {
- shortMessage = rows.first().content.split('\n').first().simplified().left(30);
-
- if (shortMessage.isEmpty() && !rows.first().images.isEmpty())
- shortMessage = "image_chat";
- }
-
- return generateChatFileName(shortMessage, historyDir());
-}
-
-QString ChatHistoryStore::autosaveFilePath(const QString &recentFilePath) const
-{
- if (!recentFilePath.isEmpty()) {
- return recentFilePath;
- }
-
- QString dir = historyDir();
- if (dir.isEmpty()) {
- return QString();
- }
-
- return QDir(dir).filePath(suggestedFileName() + ".json");
-}
-
-QString ChatHistoryStore::autosaveFilePath(
- const QString &recentFilePath, const QString &firstMessage, bool hasImageAttachments) const
-{
- if (!recentFilePath.isEmpty()) {
- return recentFilePath;
- }
-
- QString dir = historyDir();
- if (dir.isEmpty()) {
- return QString();
- }
-
- QString shortMessage = firstMessage.split('\n').first().simplified().left(30);
-
- if (shortMessage.isEmpty() && hasImageAttachments) {
- shortMessage = "image_chat";
- }
-
- QString fileName = generateChatFileName(shortMessage, dir);
- return QDir(dir).filePath(fileName + ".json");
-}
-
-SerializationResult ChatHistoryStore::save(const QString &filePath) const
-{
- if (!m_session)
- return {false, QString("Chat session is no longer available")};
-
- return ChatSerializer::saveToFile(m_session->history(), filePath);
-}
-
-SerializationResult ChatHistoryStore::load(const QString &filePath) const
-{
- if (!m_session)
- return {false, QString("Chat session is no longer available")};
-
- Session::ConversationHistory history;
- const SerializationResult result = ChatSerializer::loadFromFile(history, filePath);
- if (result.success)
- m_session->setHistory(history);
- return result;
-}
-
-void ChatHistoryStore::showSaveDialog()
-{
- QString initialDir = historyDir();
-
- QFileDialog *dialog = new QFileDialog(nullptr, tr("Save Chat History"));
- dialog->setAcceptMode(QFileDialog::AcceptSave);
- dialog->setFileMode(QFileDialog::AnyFile);
- dialog->setNameFilter(tr("JSON files (*.json)"));
- dialog->setDefaultSuffix("json");
- if (!initialDir.isEmpty()) {
- dialog->setDirectory(initialDir);
- dialog->selectFile(suggestedFileName() + ".json");
- }
-
- connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) {
- if (result == QFileDialog::Accepted) {
- QStringList files = dialog->selectedFiles();
- if (!files.isEmpty()) {
- emit saveRequested(files.first());
- }
- }
- dialog->deleteLater();
- });
-
- dialog->open();
-}
-
-void ChatHistoryStore::showLoadDialog()
-{
- QString initialDir = historyDir();
-
- QFileDialog *dialog = new QFileDialog(nullptr, tr("Load Chat History"));
- dialog->setAcceptMode(QFileDialog::AcceptOpen);
- dialog->setFileMode(QFileDialog::ExistingFile);
- dialog->setNameFilter(tr("JSON files (*.json)"));
- if (!initialDir.isEmpty()) {
- dialog->setDirectory(initialDir);
- }
-
- connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) {
- if (result == QFileDialog::Accepted) {
- QStringList files = dialog->selectedFiles();
- if (!files.isEmpty()) {
- emit loadRequested(files.first());
- }
- }
- dialog->deleteLater();
- });
-
- dialog->open();
-}
-
-void ChatHistoryStore::openHistoryFolder() const
-{
- QString path;
- if (auto project = ProjectExplorer::ProjectManager::startupProject()) {
- Settings::ProjectSettings projectSettings(project);
- path = projectSettings.chatHistoryPath().toFSPathString();
- } else {
- QDir baseDir(Core::ICore::userResourcePath().toFSPathString());
- path = baseDir.filePath("qodeassist/chat_history");
- }
-
- QDir dir(path);
- if (!dir.exists()) {
- dir.mkpath(".");
- }
-
- QUrl url = QUrl::fromLocalFile(dir.absolutePath());
- QDesktopServices::openUrl(url);
-}
-
-QString ChatHistoryStore::generateChatFileName(const QString &shortMessage, const QString &dir) const
-{
- static const QRegularExpression saitizeSymbols = QRegularExpression("[\\/:*?\"<>|\\s]");
- static const QRegularExpression underSymbols = QRegularExpression("_+");
-
- QStringList parts;
- QString sanitizedMessage = shortMessage;
- sanitizedMessage.replace(saitizeSymbols, "_");
- sanitizedMessage.replace(underSymbols, "_");
- sanitizedMessage = sanitizedMessage.trimmed();
-
- if (!sanitizedMessage.isEmpty()) {
- if (sanitizedMessage.startsWith('_')) {
- sanitizedMessage.remove(0, 1);
- }
- if (sanitizedMessage.endsWith('_')) {
- sanitizedMessage.chop(1);
- }
-
- QString fullPath = QDir(dir).filePath(sanitizedMessage);
- QFileInfo fileInfo(fullPath);
- if (!fileInfo.exists() && QFileInfo(fileInfo.path()).isWritable()) {
- parts << sanitizedMessage;
- }
- }
-
- parts << QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm");
-
- QString fileName = parts.join("_");
- QString fullPath = QDir(dir).filePath(fileName);
- QFileInfo finalCheck(fullPath);
-
- if (fileName.isEmpty() || finalCheck.exists() || !QFileInfo(finalCheck.path()).isWritable()) {
- fileName = QString("chat_%1").arg(QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm"));
- }
-
- return fileName;
-}
-
-} // namespace QodeAssist::Chat
diff --git a/sources/ChatView/ChatHistoryStore.hpp b/sources/ChatView/ChatHistoryStore.hpp
deleted file mode 100644
index bd16f82..0000000
--- a/sources/ChatView/ChatHistoryStore.hpp
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include
-#include
-#include
-
-#include "ChatSerializer.hpp"
-
-namespace QodeAssist::Session {
-class Session;
-}
-
-namespace QodeAssist::Chat {
-
-class ChatHistoryStore : public QObject
-{
- Q_OBJECT
-
-public:
- explicit ChatHistoryStore(Session::Session *session, QObject *parent = nullptr);
-
- QString historyDir() const;
- QString suggestedFileName() const;
- QString autosaveFilePath(const QString &recentFilePath) const;
- QString autosaveFilePath(
- const QString &recentFilePath,
- const QString &firstMessage,
- bool hasImageAttachments) const;
-
- SerializationResult save(const QString &filePath) const;
- SerializationResult load(const QString &filePath) const;
-
- void showSaveDialog();
- void showLoadDialog();
- void openHistoryFolder() const;
-
-signals:
- void saveRequested(const QString &filePath);
- void loadRequested(const QString &filePath);
-
-private:
- QString generateChatFileName(const QString &shortMessage, const QString &dir) const;
-
- QPointer m_session;
-};
-
-} // namespace QodeAssist::Chat
diff --git a/sources/ChatView/ChatModel.cpp b/sources/ChatView/ChatModel.cpp
index 145e623..b33c753 100644
--- a/sources/ChatView/ChatModel.cpp
+++ b/sources/ChatView/ChatModel.cpp
@@ -10,7 +10,6 @@
#include
#include
-#include
#include "logger/Logger.hpp"
@@ -18,19 +17,33 @@ namespace QodeAssist::Chat {
namespace {
-auto usageOf(const ChatModel::Message &message)
+ChatModel::ChatRole toChatRole(Session::RowKind kind)
{
- return std::tie(
- message.promptTokens,
- message.completionTokens,
- message.cachedPromptTokens,
- message.reasoningTokens);
+ switch (kind) {
+ case Session::RowKind::System:
+ return ChatModel::ChatRole::System;
+ case Session::RowKind::User:
+ return ChatModel::ChatRole::User;
+ case Session::RowKind::Assistant:
+ return ChatModel::ChatRole::Assistant;
+ case Session::RowKind::Tool:
+ case Session::RowKind::AgentTool:
+ return ChatModel::ChatRole::Tool;
+ case Session::RowKind::FileEdit:
+ return ChatModel::ChatRole::FileEdit;
+ case Session::RowKind::Thinking:
+ return ChatModel::ChatRole::Thinking;
+ case Session::RowKind::Permission:
+ return ChatModel::ChatRole::Permission;
+ case Session::RowKind::Plan:
+ return ChatModel::ChatRole::Plan;
+ }
+ return ChatModel::ChatRole::Assistant;
}
-bool carriesUsage(const ChatModel::Message &message)
+bool carriesUsage(const Session::MessageRow &row)
{
- return message.promptTokens != 0 || message.completionTokens != 0
- || message.cachedPromptTokens != 0 || message.reasoningTokens != 0;
+ return !row.usage.isEmpty();
}
} // namespace
@@ -49,10 +62,10 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
if (!index.isValid() || index.row() >= m_messages.size())
return QVariant();
- const Message &message = m_messages[index.row()];
+ const Session::MessageRow &message = m_messages[index.row()];
switch (static_cast(role)) {
case Roles::RoleType:
- return QVariant::fromValue(message.role);
+ return QVariant::fromValue(toChatRole(message.kind));
case Roles::Content: {
return message.content;
}
@@ -60,37 +73,47 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
QVariantList attachmentsList;
for (const auto &attachment : message.attachments) {
QVariantMap attachmentMap;
- attachmentMap["fileName"] = attachment.filename;
- attachmentMap["storedPath"] = attachment.content;
-
+ attachmentMap["fileName"] = attachment.fileName;
+ attachmentMap["storedPath"] = attachment.storedPath;
+
if (!m_chatFilePath.isEmpty()) {
QFileInfo fileInfo(m_chatFilePath);
QString baseName = fileInfo.completeBaseName();
QString dirPath = fileInfo.absolutePath();
QString contentFolder = QDir(dirPath).filePath(baseName + "_content");
- QString fullPath = QDir(contentFolder).filePath(attachment.content);
+ QString fullPath = QDir(contentFolder).filePath(attachment.storedPath);
attachmentMap["filePath"] = fullPath;
} else {
attachmentMap["filePath"] = QString();
}
-
+
attachmentsList.append(attachmentMap);
}
return attachmentsList;
}
case Roles::IsRedacted: {
- return message.isRedacted;
+ return message.redacted;
}
case Roles::PromptTokens:
- return message.promptTokens;
+ return message.usage.promptTokens;
case Roles::CompletionTokens:
- return message.completionTokens;
+ return message.usage.completionTokens;
case Roles::CachedPromptTokens:
- return message.cachedPromptTokens;
+ return message.usage.cachedPromptTokens;
case Roles::ReasoningTokens:
- return message.reasoningTokens;
+ return message.usage.reasoningTokens;
case Roles::TotalTokens:
- return message.promptTokens + message.completionTokens;
+ return message.usage.promptTokens + message.usage.completionTokens;
+ case Roles::ToolKind:
+ return message.toolKind;
+ case Roles::ToolStatus:
+ return message.toolStatus;
+ case Roles::ToolName:
+ return message.toolName;
+ case Roles::ToolResult:
+ return message.toolResult;
+ case Roles::ToolDetails:
+ return QVariant::fromValue(message.toolDetails);
case Roles::Images: {
QVariantList imagesList;
for (const auto &image : message.images) {
@@ -98,7 +121,7 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
imageMap["fileName"] = image.fileName;
imageMap["storedPath"] = image.storedPath;
imageMap["mediaType"] = image.mediaType;
-
+
if (!m_chatFilePath.isEmpty()) {
QFileInfo fileInfo(m_chatFilePath);
QString baseName = fileInfo.completeBaseName();
@@ -111,7 +134,7 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const
imageMap["imageUrl"] = QString();
imageMap["filePath"] = QString();
}
-
+
imagesList.append(imageMap);
}
return imagesList;
@@ -134,32 +157,37 @@ QHash ChatModel::roleNames() const
roles[Roles::CachedPromptTokens] = "cachedPromptTokens";
roles[Roles::ReasoningTokens] = "reasoningTokens";
roles[Roles::TotalTokens] = "totalTokens";
+ roles[Roles::ToolKind] = "toolKind";
+ roles[Roles::ToolStatus] = "toolStatus";
+ roles[Roles::ToolDetails] = "toolDetails";
+ roles[Roles::ToolName] = "toolName";
+ roles[Roles::ToolResult] = "toolResult";
return roles;
}
-void ChatModel::resetMessages(const QVector &messages)
+void ChatModel::resetMessages(const QList &rows)
{
beginResetModel();
- m_messages = messages;
+ m_messages = rows;
endResetModel();
emit modelReseted();
emit sessionUsageChanged();
}
-void ChatModel::appendMessages(const QVector &messages)
+void ChatModel::appendMessages(const QList &rows)
{
- if (messages.isEmpty())
+ if (rows.isEmpty())
return;
- beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size() + messages.size() - 1);
- m_messages.append(messages);
+ beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size() + rows.size() - 1);
+ m_messages.append(rows);
endInsertRows();
- if (std::any_of(messages.cbegin(), messages.cend(), carriesUsage))
+ if (std::any_of(rows.cbegin(), rows.cend(), carriesUsage))
emit sessionUsageChanged();
}
-void ChatModel::updateMessage(int index, const Message &message)
+void ChatModel::updateMessage(int index, const Session::MessageRow &row)
{
if (index < 0 || index >= m_messages.size()) {
LOG_MESSAGE(QString("Session/model desync: update of row %1 with %2 rows present")
@@ -168,8 +196,8 @@ void ChatModel::updateMessage(int index, const Message &message)
return;
}
- const bool usageChanged = usageOf(m_messages[index]) != usageOf(message);
- m_messages[index] = message;
+ const bool usageChanged = m_messages[index].usage != row.usage;
+ m_messages[index] = row;
emit dataChanged(this->index(index), this->index(index));
if (usageChanged)
@@ -262,7 +290,7 @@ QVariantList ChatModel::userMessagePreviews(int maxLength) const
QVariantList result;
const int limit = maxLength > 4 ? maxLength : 80;
for (int i = 0; i < m_messages.size(); ++i) {
- if (m_messages[i].role != ChatRole::User)
+ if (m_messages[i].kind != Session::RowKind::User)
continue;
QString preview = m_messages[i].content;
preview.replace(QLatin1Char('\n'), QLatin1Char(' '));
@@ -283,7 +311,7 @@ int ChatModel::sessionPromptTokens() const
{
int total = 0;
for (const auto &m : m_messages)
- total += m.promptTokens;
+ total += m.usage.promptTokens;
return total;
}
@@ -291,7 +319,7 @@ int ChatModel::sessionCompletionTokens() const
{
int total = 0;
for (const auto &m : m_messages)
- total += m.completionTokens;
+ total += m.usage.completionTokens;
return total;
}
@@ -299,7 +327,7 @@ int ChatModel::sessionCachedPromptTokens() const
{
int total = 0;
for (const auto &m : m_messages)
- total += m.cachedPromptTokens;
+ total += m.usage.cachedPromptTokens;
return total;
}
diff --git a/sources/ChatView/ChatModel.hpp b/sources/ChatView/ChatModel.hpp
index 2b049c6..fcc5c4b 100644
--- a/sources/ChatView/ChatModel.hpp
+++ b/sources/ChatView/ChatModel.hpp
@@ -7,10 +7,9 @@
#include "MessagePart.hpp"
#include
-#include
#include
-#include "context/ContentFile.hpp"
+#include "session/HistoryProjection.hpp"
namespace QodeAssist::Chat {
@@ -24,7 +23,7 @@ class ChatModel : public QAbstractListModel
QML_ELEMENT
public:
- enum ChatRole { System, User, Assistant, Tool, FileEdit, Thinking };
+ enum ChatRole { System, User, Assistant, Tool, FileEdit, Thinking, Permission, Plan };
Q_ENUM(ChatRole)
enum Roles {
@@ -37,47 +36,24 @@ public:
CompletionTokens,
CachedPromptTokens,
ReasoningTokens,
- TotalTokens
+ TotalTokens,
+ ToolKind,
+ ToolStatus,
+ ToolDetails,
+ ToolName,
+ ToolResult
};
Q_ENUM(Roles)
- struct ImageAttachment
- {
- QString fileName; // Original filename
- QString storedPath; // Path to stored image file (relative to chat folder)
- QString mediaType; // MIME type
- };
-
- struct Message
- {
- ChatRole role;
- QString content;
- QString id;
- bool isRedacted = false;
- QString signature = QString();
-
- QList attachments;
- QList images;
-
- QString toolName;
- QJsonObject toolArguments;
- QString toolResult;
-
- int promptTokens = 0;
- int completionTokens = 0;
- int cachedPromptTokens = 0;
- int reasoningTokens = 0;
- };
-
explicit ChatModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash roleNames() const override;
- void resetMessages(const QVector &messages);
- void appendMessages(const QVector &messages);
- void updateMessage(int index, const Message &message);
+ void resetMessages(const QList &rows);
+ void appendMessages(const QList &rows);
+ void updateMessage(int index, const Session::MessageRow &row);
void removeMessages(int first, int count);
Q_INVOKABLE QList processMessageContent(const QString &content) const;
@@ -96,10 +72,9 @@ signals:
void sessionUsageChanged();
private:
- QVector m_messages;
+ QList m_messages;
QString m_chatFilePath;
};
} // namespace QodeAssist::Chat
-Q_DECLARE_METATYPE(QodeAssist::Chat::ChatModel::Message)
Q_DECLARE_METATYPE(QodeAssist::Chat::MessagePart)
diff --git a/sources/ChatView/ChatRootView.cpp b/sources/ChatView/ChatRootView.cpp
index 1711d36..34d688b 100644
--- a/sources/ChatView/ChatRootView.cpp
+++ b/sources/ChatView/ChatRootView.cpp
@@ -6,8 +6,6 @@
#include
#include
-#include
-#include
#include
#include
#include
@@ -16,6 +14,7 @@
#include
#include
#include
+#include
#include
#include
@@ -28,20 +27,19 @@
#include "plugin/QodeAssistConstants.hpp"
-#include "AgentRoleController.hpp"
#include "ChatAssistantSettings.hpp"
#include "ChatConfigurationController.hpp"
+#include "acp/AgentCatalogStore.hpp"
#include "ChatCompressor.hpp"
-#include "ChatHistoryStore.hpp"
+#include "ChatFileStore.hpp"
#include "FileEditController.hpp"
#include "GeneralSettings.hpp"
#include "InputTokenCounter.hpp"
#include "SettingsConstants.hpp"
#include "Logger.hpp"
-#include "providers/ProvidersManager.hpp"
#include "SessionFileRegistry.hpp"
#include "context/ContextManager.hpp"
-#include "context/RulesLoader.hpp"
+#include "TurnContextAdapters.hpp"
#include "ProjectSettings.hpp"
#include "SkillsSettings.hpp"
#include "skills/SkillsManager.hpp"
@@ -49,12 +47,6 @@
namespace QodeAssist::Chat {
namespace {
-bool isChatEditor(Core::IEditor *editor)
-{
- return editor && editor->document()
- && editor->document()->id() == Utils::Id(Constants::QODE_ASSIST_CHAT_EDITOR_ID);
-}
-
QKeySequence sendMessageKeySequence()
{
auto command = Core::ActionManager::command(Constants::QODE_ASSIST_CHAT_SEND_MESSAGE);
@@ -76,22 +68,16 @@ ChatRootView::ChatRootView(QQuickItem *parent)
, m_chatModel(new ChatModel(this))
, m_promptProvider(Templates::PromptTemplateManager::instance())
, m_controller(new ChatController(m_chatModel, &m_promptProvider, this))
- , m_fileManager(new ChatFileManager(this))
+ , m_attachmentStaging(new AttachmentStaging(this))
, m_isRequestInProgress(false)
, m_chatCompressor(new ChatCompressor(this))
- , m_agentRoleController(new AgentRoleController(this))
, m_configurationController(new ChatConfigurationController(this))
, m_fileEditController(new FileEditController(this))
, m_tokenCounter(new InputTokenCounter(
m_controller->session(), m_controller->contextManager(), this))
- , m_historyStore(new ChatHistoryStore(m_controller->session(), this))
+ , m_historyStore(new ChatFileStore(m_controller->session(), this))
{
- m_isSyncOpenFiles = Settings::chatAssistantSettings().linkOpenFiles();
- connect(
- &Settings::chatAssistantSettings().linkOpenFiles,
- &Utils::BaseAspect::changed,
- this,
- [this]() { setIsSyncOpenFiles(Settings::chatAssistantSettings().linkOpenFiles()); });
+ m_coordinator = new ConversationCoordinator({m_controller, this, this, this}, this);
QMetaObject::invokeMethod(
this,
@@ -124,6 +110,52 @@ ChatRootView::ChatRootView(QQuickItem *parent)
&ChatConfigurationController::currentConfigurationChanged,
this,
&ChatRootView::currentConfigurationChanged);
+ connect(
+ m_configurationController,
+ &ChatConfigurationController::agentRequested,
+ m_coordinator,
+ &ConversationCoordinator::chooseAgent);
+ connect(
+ m_configurationController,
+ &ChatConfigurationController::llmRequested,
+ m_coordinator,
+ &ConversationCoordinator::chooseLlm);
+
+ connect(
+ m_coordinator,
+ &ConversationCoordinator::switchConfirmationNeeded,
+ this,
+ &ChatRootView::chatTargetSwitchNeedsNewChat);
+ connect(
+ m_coordinator,
+ &ConversationCoordinator::switchCancelled,
+ this,
+ &ChatRootView::currentConfigurationChanged);
+ connect(
+ m_coordinator,
+ &ConversationCoordinator::boundToAgent,
+ this,
+ [this](const Acp::AgentDefinition &agent) {
+ m_configurationController->setBoundAgent(agent);
+ emit isAgentBoundChanged();
+ });
+ connect(m_coordinator, &ConversationCoordinator::boundToLlm, this, [this] {
+ m_configurationController->clearBoundAgent();
+ emit isAgentBoundChanged();
+ });
+ connect(
+ m_coordinator,
+ &ConversationCoordinator::sessionIssueChanged,
+ this,
+ &ChatRootView::agentSessionIssueChanged);
+ connect(
+ m_coordinator,
+ &ConversationCoordinator::errorSurfaced,
+ this,
+ [this](const QString &message) {
+ m_lastErrorMessage = message;
+ emit lastErrorMessageChanged();
+ });
connect(
m_controller,
@@ -143,6 +175,7 @@ ChatRootView::ChatRootView(QQuickItem *parent)
connect(m_chatModel, &ChatModel::modelReseted, this, [this]() {
setRecentFilePath(QString{});
+ m_coordinator->conversationReset();
m_fileEditController->clearCurrentRequestId();
});
auto maybeEmitTitle = [this] {
@@ -152,55 +185,83 @@ ChatRootView::ChatRootView(QQuickItem *parent)
m_cachedChatTitle = newTitle;
emit chatTitleChanged();
};
- connect(m_chatModel, &ChatModel::modelReseted, this, maybeEmitTitle);
+ connect(
+ m_controller,
+ &ChatController::sessionInfoReceived,
+ m_coordinator,
+ &ConversationCoordinator::titleSuggested);
+ connect(m_coordinator, &ConversationCoordinator::agentTitleChanged, this, maybeEmitTitle);
+
+ connect(
+ m_controller,
+ &ChatController::agentSessionUnavailable,
+ m_coordinator,
+ &ConversationCoordinator::agentSessionUnavailable);
+
+ m_historyStore->setBindingReader([this] { return m_coordinator->bindingForSave(); });
+ m_historyStore->setBindingWriter(
+ [this](const Acp::AgentBinding &binding) { m_coordinator->restoreAgentBinding(binding); });
connect(m_chatModel, &QAbstractItemModel::modelReset, this, maybeEmitTitle);
connect(m_chatModel, &QAbstractItemModel::rowsInserted, this, maybeEmitTitle);
connect(m_chatModel, &QAbstractItemModel::rowsRemoved, this, maybeEmitTitle);
+
+ connect(this, &ChatRootView::isAgentBoundChanged, this, &ChatRootView::shrinkContextStateChanged);
+ connect(
+ m_controller,
+ &ChatController::agentCommandsChanged,
+ this,
+ &ChatRootView::slashCommandsChanged);
+ connect(
+ this, &ChatRootView::agentSessionIssueChanged, this, &ChatRootView::shrinkContextStateChanged);
+ connect(
+ m_chatModel,
+ &QAbstractItemModel::modelReset,
+ this,
+ &ChatRootView::shrinkContextStateChanged);
+ connect(
+ m_chatModel,
+ &QAbstractItemModel::rowsInserted,
+ this,
+ &ChatRootView::shrinkContextStateChanged);
+ connect(
+ m_chatModel,
+ &QAbstractItemModel::rowsRemoved,
+ this,
+ &ChatRootView::shrinkContextStateChanged);
+ connect(
+ &Settings::generalSettings().caProvider,
+ &Utils::BaseAspect::changed,
+ this,
+ &ChatRootView::shrinkContextStateChanged);
+ connect(
+ &Settings::generalSettings().caModel,
+ &Utils::BaseAspect::changed,
+ this,
+ &ChatRootView::shrinkContextStateChanged);
+ connect(
+ &Settings::generalSettings().caTemplate,
+ &Utils::BaseAspect::changed,
+ this,
+ &ChatRootView::shrinkContextStateChanged);
+ connect(
+ &Settings::generalSettings().caUrl,
+ &Utils::BaseAspect::changed,
+ this,
+ &ChatRootView::shrinkContextStateChanged);
connect(this, &ChatRootView::attachmentFilesChanged, this, [this]() {
m_tokenCounter->setAttachments(m_attachmentFiles);
});
- connect(this, &ChatRootView::linkedFilesChanged, this, [this]() {
- m_tokenCounter->setLinkedFiles(m_linkedFiles);
- });
- connect(this, &ChatRootView::useToolsChanged, this, &ChatRootView::updateInputTokensCount);
-
connect(
m_tokenCounter,
&InputTokenCounter::inputTokensChanged,
this,
&ChatRootView::inputTokensCountChanged);
connect(
- m_agentRoleController,
- &AgentRoleController::availableRolesChanged,
- this,
- &ChatRootView::availableAgentRolesChanged);
- connect(
- m_agentRoleController,
- &AgentRoleController::currentRoleChanged,
- this,
- &ChatRootView::currentAgentRoleChanged);
- connect(
- m_agentRoleController,
- &AgentRoleController::baseSystemPromptChanged,
+ &Settings::chatAssistantSettings().systemPrompt,
+ &Utils::BaseAspect::changed,
this,
&ChatRootView::baseSystemPromptChanged);
- auto editors = Core::EditorManager::instance();
-
- connect(editors, &Core::EditorManager::editorCreated, this, &ChatRootView::onEditorCreated);
- connect(
- editors,
- &Core::EditorManager::editorAboutToClose,
- this,
- &ChatRootView::onEditorAboutToClose);
-
- connect(editors, &Core::EditorManager::currentEditorAboutToChange, this, [this]() {
- if (m_isSyncOpenFiles) {
- for (auto editor : std::as_const(m_currentEditors)) {
- onAppendLinkFileFromEditor(editor);
- }
- }
- });
connect(
&Settings::chatAssistantSettings().textFontFamily,
&Utils::BaseAspect::changed,
@@ -261,49 +322,11 @@ ChatRootView::ChatRootView(QQuickItem *parent)
});
connect(
- m_historyStore, &ChatHistoryStore::saveRequested, this, &ChatRootView::saveHistory);
+ m_historyStore, &ChatFileStore::saveRequested, this, &ChatRootView::saveHistory);
connect(
- m_historyStore, &ChatHistoryStore::loadRequested, this, &ChatRootView::loadHistory);
+ m_historyStore, &ChatFileStore::loadRequested, this, &ChatRootView::loadHistory);
- refreshRules();
-
- connect(
- ProjectExplorer::ProjectManager::instance(),
- &ProjectExplorer::ProjectManager::startupProjectChanged,
- this,
- &ChatRootView::refreshRules);
-
- connect(
- ProjectExplorer::ProjectManager::instance(),
- &ProjectExplorer::ProjectManager::projectAdded,
- this,
- &ChatRootView::openFilesChanged);
-
- connect(
- ProjectExplorer::ProjectManager::instance(),
- &ProjectExplorer::ProjectManager::projectRemoved,
- this,
- &ChatRootView::openFilesChanged);
-
- connect(
- &Settings::chatAssistantSettings().enableChatTools,
- &Utils::BaseAspect::changed,
- this,
- &ChatRootView::useToolsChanged);
-
- connect(
- &Settings::chatAssistantSettings().enableThinkingMode,
- &Utils::BaseAspect::changed,
- this,
- &ChatRootView::useThinkingChanged);
-
- connect(
- &Settings::generalSettings().caProvider,
- &Utils::BaseAspect::changed,
- this,
- &ChatRootView::isThinkingSupportChanged);
-
- connect(m_fileManager, &ChatFileManager::fileOperationFailed, this, [this](const QString &error) {
+ connect(m_attachmentStaging, &AttachmentStaging::fileOperationFailed, this, [this](const QString &error) {
m_lastErrorMessage = error;
emit lastErrorMessageChanged();
});
@@ -320,24 +343,28 @@ ChatRootView::ChatRootView(QQuickItem *parent)
loadHistory(compressedChatPath);
- if (m_pendingSend.active) {
- PendingSend p = m_pendingSend;
- m_pendingSend = {};
- dispatchSend(p.message, p.attachments, p.linkedFiles, p.useTools, p.useThinking);
- }
+ m_coordinator->compressionSettled();
});
+ connect(
+ m_chatCompressor,
+ &ChatCompressor::compressingChanged,
+ this,
+ &ChatRootView::isCompressingChanged);
+
+ connect(
+ m_chatCompressor,
+ &ChatCompressor::summaryReady,
+ m_coordinator,
+ &ConversationCoordinator::summaryProduced);
+
connect(m_chatCompressor, &ChatCompressor::compressionFailed, this, [this](const QString &error) {
emit isCompressingChanged();
m_lastErrorMessage = error;
emit lastErrorMessageChanged();
emit compressionFailed(error);
- if (m_pendingSend.active) {
- PendingSend p = m_pendingSend;
- m_pendingSend = {};
- dispatchSend(p.message, p.attachments, p.linkedFiles, p.useTools, p.useThinking);
- }
+ m_coordinator->compressionSettled();
});
}
@@ -348,6 +375,18 @@ ChatRootView::~ChatRootView()
}
}
+void ChatRootView::componentComplete()
+{
+ QQuickItem::componentComplete();
+
+ if (auto context = qmlContext(this)) {
+ if (auto *store = qobject_cast(
+ context->contextProperty("agentCatalog").value())) {
+ m_configurationController->setAgentCatalog(store);
+ }
+ }
+}
+
SessionFileRegistry *ChatRootView::sessionFileRegistry() const
{
if (!m_sessionFileRegistryResolved) {
@@ -372,6 +411,35 @@ Skills::SkillsManager *ChatRootView::skillsManager() const
return m_skillsManager;
}
+QVariantList ChatRootView::searchSlashCommands(const QString &query) const
+{
+ if (isAgentBound())
+ return searchAgentCommands(query);
+ return searchSkills(query);
+}
+
+QVariantList ChatRootView::searchAgentCommands(const QString &query) const
+{
+ QVariantList results;
+ const QString source = m_controller->boundAgentName();
+ const QString needle = query.trimmed().toLower();
+
+ const QList commands = m_controller->agentCommands();
+ for (const LLMQore::Acp::AvailableCommand &command : commands) {
+ if (!needle.isEmpty() && !command.name.toLower().contains(needle)
+ && !command.description.toLower().contains(needle)) {
+ continue;
+ }
+ results.append(
+ QVariantMap{
+ {QStringLiteral("name"), command.name},
+ {QStringLiteral("description"), command.description},
+ {QStringLiteral("inputHint"), command.inputHint},
+ {QStringLiteral("source"), source}});
+ }
+ return results;
+}
+
QVariantList ChatRootView::searchSkills(const QString &query) const
{
QVariantList results;
@@ -379,7 +447,7 @@ QVariantList ChatRootView::searchSkills(const QString &query) const
if (!manager || !Settings::skillsSettings().enableSkills())
return results;
- auto *project = Context::RulesLoader::getActiveProject();
+ auto *project = activeProject();
QStringList projectSkillDirs;
if (project) {
Settings::ProjectSettings projectSettings(project);
@@ -399,9 +467,12 @@ QVariantList ChatRootView::searchSkills(const QString &query) const
&& !skill.description.toLower().contains(needle)) {
continue;
}
- results.append(QVariantMap{
- {QStringLiteral("name"), skill.name},
- {QStringLiteral("description"), skill.description}});
+ results.append(
+ QVariantMap{
+ {QStringLiteral("name"), skill.name},
+ {QStringLiteral("description"), skill.description},
+ {QStringLiteral("inputHint"), QString()},
+ {QStringLiteral("source"), tr("QodeAssist")}});
}
return results;
}
@@ -414,58 +485,40 @@ ChatModel *ChatRootView::chatModel() const
void ChatRootView::sendMessage(const QString &message)
{
const QStringList attachments = m_attachmentFiles;
- const QStringList linkedFiles = m_linkedFiles;
- const bool tools = useTools();
- const bool thinking = useThinking();
-
- if (deferSendForAutoCompress(message, attachments, linkedFiles, tools, thinking))
- return;
-
- dispatchSend(message, attachments, linkedFiles, tools, thinking);
+ m_coordinator->requestSend(message, attachments);
}
-bool ChatRootView::deferSendForAutoCompress(
- const QString &message,
- const QStringList &attachments,
- const QStringList &linkedFiles,
- bool useToolsArg,
- bool useThinkingArg)
+bool ChatRootView::autoCompressEnabled() const
{
- auto &settings = Settings::chatAssistantSettings();
- if (!settings.autoCompress())
+ return Settings::chatAssistantSettings().autoCompress();
+}
+
+int ChatRootView::autoCompressThreshold() const
+{
+ return Settings::chatAssistantSettings().autoCompressThreshold();
+}
+
+int ChatRootView::estimatedNextTokens() const
+{
+ return m_tokenCounter->inputTokens();
+}
+
+bool ChatRootView::prepareChatFileForCompression(
+ const QString &message, const QStringList &attachments)
+{
+ if (!m_recentFilePath.isEmpty())
+ return true;
+
+ const QString filePath = getAutosaveFilePath(message, attachments);
+ if (filePath.isEmpty())
return false;
- const int threshold = settings.autoCompressThreshold();
- const int inputTokens = m_tokenCounter->inputTokens();
- if (inputTokens < threshold)
- return false;
-
- if (m_recentFilePath.isEmpty()) {
- QString filePath = getAutosaveFilePath(message, attachments);
- if (filePath.isEmpty())
- return false;
- setRecentFilePath(filePath);
- LOG_MESSAGE(QString("Set chat file path for new chat (auto-compress): %1").arg(filePath));
- }
-
- if (m_chatCompressor->isCompressing() || m_pendingSend.active)
- return false;
-
- LOG_MESSAGE(QString("Auto-compress preempt: estimated next=%1 ≥ threshold=%2; deferring send")
- .arg(inputTokens)
- .arg(threshold));
-
- m_pendingSend = {message, attachments, linkedFiles, useToolsArg, useThinkingArg, true};
- compressCurrentChat();
+ setRecentFilePath(filePath);
+ LOG_MESSAGE(QString("Set chat file path for new chat (auto-compress): %1").arg(filePath));
return true;
}
-void ChatRootView::dispatchSend(
- const QString &message,
- const QStringList &attachments,
- const QStringList &linkedFiles,
- bool useToolsArg,
- bool useThinkingArg)
+void ChatRootView::dispatch(const QString &message, const QStringList &attachments)
{
if (m_recentFilePath.isEmpty()) {
QString filePath = getAutosaveFilePath(message, attachments);
@@ -482,9 +535,9 @@ void ChatRootView::dispatchSend(
setRequestProgressStatus(true);
m_controller->setSkillsManager(skillsManager());
- m_controller->sendMessage(message, attachments, linkedFiles, useToolsArg, useThinkingArg);
+ m_controller->sendMessage(message, attachments);
- m_fileManager->clearIntermediateStorage();
+ m_attachmentStaging->clearIntermediateStorage();
clearAttachmentFiles();
}
@@ -507,27 +560,19 @@ void ChatRootView::clearAttachmentFiles()
m_attachmentFiles.clear();
emit attachmentFilesChanged();
- m_fileManager->clearIntermediateStorage();
-}
-
-void ChatRootView::clearLinkedFiles()
-{
- if (m_linkedFiles.isEmpty()) {
- return;
- }
-
- m_linkedFiles.clear();
- emit linkedFilesChanged();
+ m_attachmentStaging->clearIntermediateStorage();
}
void ChatRootView::clearMessages()
{
- m_controller->clearMessages();
- clearLinkedFiles();
+ m_controller->clearConversation();
}
void ChatRootView::resetChatToMessage(int index)
{
+ if (m_coordinator->refuseWhileReadOnly())
+ return;
+
m_controller->resetToRow(index);
setRequestProgressStatus(false);
}
@@ -580,12 +625,10 @@ void ChatRootView::loadHistory(const QString &filePath)
}
}
- if (!m_pendingSend.active)
- m_fileManager->clearIntermediateStorage();
+ if (!m_coordinator->hasDeferredSend())
+ m_attachmentStaging->clearIntermediateStorage();
m_attachmentFiles.clear();
- m_linkedFiles.clear();
emit attachmentFilesChanged();
- emit linkedFilesChanged();
m_fileEditController->clearCurrentRequestId();
updateInputTokensCount();
@@ -638,11 +681,6 @@ QStringList ChatRootView::attachmentFiles() const
return m_attachmentFiles;
}
-QStringList ChatRootView::linkedFiles() const
-{
- return m_linkedFiles;
-}
-
void ChatRootView::showAttachFilesDialog()
{
QFileDialog dialog(nullptr, tr("Select Files to Attach"));
@@ -663,7 +701,7 @@ void ChatRootView::addFilesToAttachList(const QStringList &filePaths)
return;
}
- const QStringList processedPaths = m_fileManager->processDroppedFiles(filePaths);
+ const QStringList processedPaths = m_attachmentStaging->processDroppedFiles(filePaths);
bool filesAdded = false;
for (const QString &filePath : processedPaths) {
@@ -691,66 +729,6 @@ void ChatRootView::removeFileFromAttachList(int index)
LOG_MESSAGE(QString("Removed attachment file: %1").arg(removedFile));
}
-void ChatRootView::showLinkFilesDialog()
-{
- QFileDialog dialog(nullptr, tr("Select Files to Attach"));
- dialog.setFileMode(QFileDialog::ExistingFiles);
-
- if (auto project = ProjectExplorer::ProjectManager::startupProject()) {
- dialog.setDirectory(project->projectDirectory().toFSPathString());
- }
-
- if (dialog.exec() == QDialog::Accepted) {
- addFilesToLinkList(dialog.selectedFiles());
- }
-}
-
-void ChatRootView::addFilesToLinkList(const QStringList &filePaths)
-{
- if (filePaths.isEmpty()) {
- return;
- }
-
- bool filesAdded = false;
- QStringList imageFiles;
-
- for (const QString &filePath : filePaths) {
- if (isImageFile(filePath)) {
- imageFiles.append(filePath);
- continue;
- }
-
- if (!m_linkedFiles.contains(filePath)) {
- m_linkedFiles.append(filePath);
- filesAdded = true;
- }
- }
-
- if (!imageFiles.isEmpty()) {
- addFilesToAttachList(imageFiles);
- m_lastInfoMessage
- = tr("Images automatically moved to Attach zone (%n file(s))", "", imageFiles.size());
- emit lastInfoMessageChanged();
- }
-
- if (filesAdded) {
- emit linkedFilesChanged();
- }
-}
-
-void ChatRootView::removeFileFromLinkList(int index)
-{
- if (index < 0 || index >= m_linkedFiles.size()) {
- return;
- }
-
- const QString removedFile = m_linkedFiles.at(index);
- m_linkedFiles.removeAt(index);
- emit linkedFilesChanged();
-
- LOG_MESSAGE(QString("Removed linked file: %1").arg(removedFile));
-}
-
void ChatRootView::showAddImageDialog()
{
QFileDialog dialog(nullptr, tr("Select Images to Attach"));
@@ -812,44 +790,11 @@ QString ChatRootView::sendShortcutText() const
return sendMessageKeySequence().toString(QKeySequence::NativeText);
}
-void ChatRootView::setIsSyncOpenFiles(bool state)
-{
- if (m_isSyncOpenFiles != state) {
- m_isSyncOpenFiles = state;
- emit isSyncOpenFilesChanged();
- }
-
- if (m_isSyncOpenFiles) {
- for (auto editor : std::as_const(m_currentEditors)) {
- onAppendLinkFileFromEditor(editor);
- }
- }
-}
-
void ChatRootView::openChatHistoryFolder()
{
m_historyStore->openHistoryFolder();
}
-void ChatRootView::openRulesFolder()
-{
- auto project = ProjectExplorer::ProjectManager::startupProject();
- if (!project) {
- return;
- }
-
- QString projectPath = project->projectDirectory().toFSPathString();
- QString rulesPath = QDir(projectPath).filePath(".qodeassist/rules");
-
- QDir dir(rulesPath);
- if (!dir.exists()) {
- dir.mkpath(".");
- }
-
- QUrl url = QUrl::fromLocalFile(dir.absolutePath());
- QDesktopServices::openUrl(url);
-}
-
void ChatRootView::openSettings()
{
QMetaObject::invokeMethod(
@@ -900,6 +845,9 @@ QString ChatRootView::chatTitle() const
QString ChatRootView::computeChatTitle() const
{
+ if (!m_coordinator->agentTitle().isEmpty())
+ return m_coordinator->agentTitle();
+
for (const Session::MessageRow &row : m_controller->session()->rows()) {
if (row.kind != Session::RowKind::User)
continue;
@@ -987,57 +935,6 @@ int ChatRootView::inputTokensCount() const
return m_tokenCounter->inputTokens();
}
-bool ChatRootView::isSyncOpenFiles() const
-{
- return m_isSyncOpenFiles;
-}
-
-void ChatRootView::onEditorAboutToClose(Core::IEditor *editor)
-{
- if (isChatEditor(editor)) {
- return;
- }
-
- if (auto document = editor->document(); document && isSyncOpenFiles()) {
- QString filePath = document->filePath().toFSPathString();
- m_linkedFiles.removeOne(filePath);
- emit linkedFilesChanged();
- }
-
- if (editor) {
- m_currentEditors.removeOne(editor);
- }
-
- emit openFilesChanged();
-}
-
-void ChatRootView::onAppendLinkFileFromEditor(Core::IEditor *editor)
-{
- if (isChatEditor(editor)) {
- return;
- }
-
- if (auto document = editor->document(); document && isSyncOpenFiles()) {
- QString filePath = document->filePath().toFSPathString();
- if (!m_linkedFiles.contains(filePath) && !shouldIgnoreFileForAttach(document->filePath())) {
- m_linkedFiles.append(filePath);
- emit linkedFilesChanged();
- }
- }
-}
-
-void ChatRootView::onEditorCreated(Core::IEditor *editor, const Utils::FilePath &filePath)
-{
- if (isChatEditor(editor)) {
- return;
- }
-
- if (editor && editor->document()) {
- m_currentEditors.append(editor);
- emit openFilesChanged();
- }
-}
-
QString ChatRootView::chatFileName() const
{
return QFileInfo(m_recentFilePath).baseName();
@@ -1065,25 +962,10 @@ void ChatRootView::setRecentFilePath(const QString &filePath)
m_recentFilePath = filePath;
m_controller->setChatFilePath(filePath);
- m_fileManager->setChatFilePath(filePath);
+ m_attachmentStaging->setChatFilePath(filePath);
emit chatFileNameChanged();
}
-bool ChatRootView::shouldIgnoreFileForAttach(const Utils::FilePath &filePath)
-{
- auto project = ProjectExplorer::ProjectManager::projectForFile(filePath);
- if (project
- && m_controller->contextManager()
- ->ignoreManager()
- ->shouldIgnore(filePath.toFSPathString(), project)) {
- LOG_MESSAGE(QString("Ignoring file for attachment due to .qodeassistignore: %1")
- .arg(filePath.toFSPathString()));
- return true;
- }
-
- return false;
-}
-
QString ChatRootView::textFontFamily() const
{
return Settings::chatAssistantSettings().textFontFamily.stringValue();
@@ -1127,85 +1009,32 @@ QString ChatRootView::lastErrorMessage() const
return m_lastErrorMessage;
}
-QVariantList ChatRootView::activeRules() const
+void ChatRootView::respondToPermission(const QString &requestId, const QString &optionId)
{
- return m_activeRules;
-}
-
-int ChatRootView::activeRulesCount() const
-{
- return m_activeRules.size();
-}
-
-QString ChatRootView::getRuleContent(int index)
-{
- if (index < 0 || index >= m_activeRules.size())
- return QString();
-
- return Context::RulesLoader::loadRuleFileContent(
- m_activeRules[index].toMap()["filePath"].toString());
-}
-
-void ChatRootView::refreshRules()
-{
- m_activeRules.clear();
-
- auto project = Context::RulesLoader::getActiveProject();
- if (!project) {
- emit activeRulesChanged();
- emit activeRulesCountChanged();
- return;
- }
-
- auto ruleFiles
- = Context::RulesLoader::getRuleFilesForProject(project, Context::RulesContext::Chat);
-
- for (const auto &ruleFile : ruleFiles) {
- QVariantMap ruleMap;
- ruleMap["filePath"] = ruleFile.filePath;
- ruleMap["fileName"] = ruleFile.fileName;
- ruleMap["category"] = ruleFile.category;
- m_activeRules.append(ruleMap);
- }
-
- emit activeRulesChanged();
- emit activeRulesCountChanged();
-}
-
-bool ChatRootView::useTools() const
-{
- return Settings::chatAssistantSettings().enableChatTools();
-}
-
-void ChatRootView::setUseTools(bool enabled)
-{
- Settings::chatAssistantSettings().enableChatTools.setValue(enabled);
- Settings::chatAssistantSettings().writeSettings();
-}
-
-bool ChatRootView::useThinking() const
-{
- return Settings::chatAssistantSettings().enableThinkingMode();
-}
-
-void ChatRootView::setUseThinking(bool enabled)
-{
- Settings::chatAssistantSettings().enableThinkingMode.setValue(enabled);
- Settings::chatAssistantSettings().writeSettings();
+ m_controller->respondToPermission(requestId, optionId);
}
void ChatRootView::applyFileEdit(const QString &editId)
{
+ if (m_coordinator->refuseWhileReadOnly())
+ return;
+
m_fileEditController->applyFileEdit(editId);
}
void ChatRootView::rejectFileEdit(const QString &editId)
{
+ if (m_coordinator->refuseWhileReadOnly())
+ return;
+
m_fileEditController->rejectFileEdit(editId);
}
void ChatRootView::undoFileEdit(const QString &editId)
{
+ if (m_coordinator->refuseWhileReadOnly())
+ return;
+
m_fileEditController->undoFileEdit(editId);
}
@@ -1216,11 +1045,17 @@ void ChatRootView::openFileEditInEditor(const QString &editId)
void ChatRootView::applyAllFileEditsForCurrentMessage()
{
+ if (m_coordinator->refuseWhileReadOnly())
+ return;
+
m_fileEditController->applyAllForCurrentMessage();
}
void ChatRootView::undoAllFileEditsForCurrentMessage()
{
+ if (m_coordinator->refuseWhileReadOnly())
+ return;
+
m_fileEditController->undoAllForCurrentMessage();
}
@@ -1254,14 +1089,6 @@ QString ChatRootView::lastInfoMessage() const
return m_lastInfoMessage;
}
-bool ChatRootView::isThinkingSupport() const
-{
- auto providerName = Settings::generalSettings().caProvider();
- auto provider = Providers::ProvidersManager::instance().getProviderByName(providerName);
-
- return provider && provider->capabilities().testFlag(Providers::ProviderCapability::Thinking);
-}
-
bool ChatRootView::hasImageAttachments(const QStringList &attachments) const
{
for (const QString &filePath : attachments) {
@@ -1290,57 +1117,72 @@ void ChatRootView::applyConfiguration(const QString &configName)
m_configurationController->applyConfiguration(configName);
}
-QStringList ChatRootView::availableConfigurations() const
+void ChatRootView::confirmChatTargetSwitch()
{
- return m_configurationController->availableConfigurations();
+ m_coordinator->confirmSwitch();
}
-QString ChatRootView::currentConfiguration() const
+void ChatRootView::cancelChatTargetSwitch()
{
- return m_configurationController->currentConfiguration();
+ m_coordinator->cancelSwitch();
}
-void ChatRootView::loadAvailableAgentRoles()
+bool ChatRootView::isAgentBound() const
{
- m_agentRoleController->loadAvailableRoles();
+ return !m_controller->boundAgentId().isEmpty();
}
-void ChatRootView::applyAgentRole(const QString &roleName)
+QString ChatRootView::agentSessionIssue() const
{
- m_agentRoleController->applyRole(roleName);
+ return m_coordinator->sessionIssue();
}
-QStringList ChatRootView::availableAgentRoles() const
+bool ChatRootView::canStartNewAgentSession() const
{
- return m_agentRoleController->availableRoles();
+ return m_coordinator->canStartFreshSession();
}
-QString ChatRootView::currentAgentRole() const
+void ChatRootView::startNewAgentSession()
{
- return m_agentRoleController->currentRole();
+ m_coordinator->startFreshSession();
}
-QString ChatRootView::baseSystemPrompt() const
+bool ChatRootView::canHandOverSummary() const
{
- return m_agentRoleController->baseSystemPrompt();
+ return m_coordinator->canHandOverSummary();
}
-QString ChatRootView::currentAgentRoleDescription() const
+QString ChatRootView::summaryHandoverTooltip() const
{
- return m_agentRoleController->currentRoleDescription();
+ return m_coordinator->summaryHandoverTooltip();
}
-QString ChatRootView::currentAgentRoleSystemPrompt() const
+void ChatRootView::startNewAgentSessionWithSummary()
{
- return m_agentRoleController->currentRoleSystemPrompt();
+ m_coordinator->handOverSummary();
}
-void ChatRootView::openAgentRolesSettings()
+std::optional ChatRootView::agentById(const QString &agentId) const
{
- m_agentRoleController->openSettings();
+ return m_configurationController->agentById(agentId);
}
-void ChatRootView::compressCurrentChat()
+QString ChatRootView::compressionConfigurationIssue() const
+{
+ return ChatCompressor::configurationIssue();
+}
+
+bool ChatRootView::isCompressionRunning() const
+{
+ return m_chatCompressor->isCompressing();
+}
+
+void ChatRootView::startTranscriptSummary()
+{
+ m_chatCompressor->startSummary(m_controller->session()->history());
+}
+
+void ChatRootView::startCompression()
{
if (m_chatCompressor->isCompressing()) {
m_lastErrorMessage = tr("Compression is already in progress");
@@ -1356,8 +1198,37 @@ void ChatRootView::compressCurrentChat()
autosave();
- m_chatCompressor->startCompression(
- m_recentFilePath, m_controller->session()->history());
+ m_chatCompressor->startCompression(m_recentFilePath, m_controller->session()->history());
+}
+
+QStringList ChatRootView::availableConfigurations() const
+{
+ return m_configurationController->availableConfigurations();
+}
+
+QString ChatRootView::currentConfiguration() const
+{
+ return m_configurationController->currentConfiguration();
+}
+
+QString ChatRootView::baseSystemPrompt() const
+{
+ return Settings::chatAssistantSettings().systemPrompt();
+}
+
+void ChatRootView::compressCurrentChat()
+{
+ m_coordinator->shrinkContext();
+}
+
+bool ChatRootView::canShrinkContext() const
+{
+ return m_coordinator->canShrinkContext();
+}
+
+QString ChatRootView::shrinkContextTooltip() const
+{
+ return m_coordinator->shrinkContextTooltip();
}
void ChatRootView::cancelCompression()
diff --git a/sources/ChatView/ChatRootView.hpp b/sources/ChatView/ChatRootView.hpp
index c186789..c842a93 100644
--- a/sources/ChatView/ChatRootView.hpp
+++ b/sources/ChatView/ChatRootView.hpp
@@ -9,10 +9,11 @@
#include
#include "ChatController.hpp"
-#include "ChatFileManager.hpp"
+#include "AttachmentStaging.hpp"
#include "ChatModel.hpp"
+#include "ConversationCoordinator.hpp"
#include "templates/PromptProviderChat.hpp"
-#include
+#include
namespace QodeAssist::Skills {
class SkillsManager;
@@ -21,21 +22,21 @@ class SkillsManager;
namespace QodeAssist::Chat {
class ChatCompressor;
-class AgentRoleController;
class ChatConfigurationController;
class FileEditController;
class InputTokenCounter;
-class ChatHistoryStore;
+class ChatFileStore;
class SessionFileRegistry;
-class ChatRootView : public QQuickItem
+class ChatRootView : public QQuickItem,
+ private IAgentCatalogPort,
+ private ICompressionPort,
+ private ISendPort
{
Q_OBJECT
Q_PROPERTY(QodeAssist::Chat::ChatModel *chatModel READ chatModel NOTIFY chatModelChanged FINAL)
Q_PROPERTY(QString currentTemplate READ currentTemplate NOTIFY currentTemplateChanged FINAL)
- Q_PROPERTY(bool isSyncOpenFiles READ isSyncOpenFiles NOTIFY isSyncOpenFilesChanged FINAL)
Q_PROPERTY(QStringList attachmentFiles READ attachmentFiles NOTIFY attachmentFilesChanged FINAL)
- Q_PROPERTY(QStringList linkedFiles READ linkedFiles NOTIFY linkedFilesChanged FINAL)
Q_PROPERTY(int inputTokensCount READ inputTokensCount NOTIFY inputTokensCountChanged FINAL)
Q_PROPERTY(QString chatFileName READ chatFileName NOTIFY chatFileNameChanged FINAL)
Q_PROPERTY(QString textFontFamily READ textFontFamily NOTIFY textFamilyChanged FINAL)
@@ -46,24 +47,25 @@ class ChatRootView : public QQuickItem
Q_PROPERTY(bool isRequestInProgress READ isRequestInProgress NOTIFY isRequestInProgressChanged FINAL)
Q_PROPERTY(QString lastErrorMessage READ lastErrorMessage NOTIFY lastErrorMessageChanged FINAL)
Q_PROPERTY(QString lastInfoMessage READ lastInfoMessage NOTIFY lastInfoMessageChanged FINAL)
- Q_PROPERTY(QVariantList activeRules READ activeRules NOTIFY activeRulesChanged FINAL)
- Q_PROPERTY(int activeRulesCount READ activeRulesCount NOTIFY activeRulesCountChanged FINAL)
- Q_PROPERTY(bool useTools READ useTools WRITE setUseTools NOTIFY useToolsChanged FINAL)
- Q_PROPERTY(bool useThinking READ useThinking WRITE setUseThinking NOTIFY useThinkingChanged FINAL)
Q_PROPERTY(QString sendShortcutText READ sendShortcutText NOTIFY sendShortcutTextChanged FINAL)
Q_PROPERTY(int currentMessageTotalEdits READ currentMessageTotalEdits NOTIFY currentMessageEditsStatsChanged FINAL)
Q_PROPERTY(int currentMessageAppliedEdits READ currentMessageAppliedEdits NOTIFY currentMessageEditsStatsChanged FINAL)
Q_PROPERTY(int currentMessagePendingEdits READ currentMessagePendingEdits NOTIFY currentMessageEditsStatsChanged FINAL)
Q_PROPERTY(int currentMessageRejectedEdits READ currentMessageRejectedEdits NOTIFY currentMessageEditsStatsChanged FINAL)
- Q_PROPERTY(bool isThinkingSupport READ isThinkingSupport NOTIFY isThinkingSupportChanged FINAL)
+ Q_PROPERTY(bool isAgentBound READ isAgentBound NOTIFY isAgentBoundChanged FINAL)
+ Q_PROPERTY(QString agentSessionIssue READ agentSessionIssue NOTIFY agentSessionIssueChanged FINAL)
+ Q_PROPERTY(bool canStartNewAgentSession READ canStartNewAgentSession NOTIFY
+ agentSessionIssueChanged FINAL)
+ Q_PROPERTY(bool canHandOverSummary READ canHandOverSummary NOTIFY agentSessionIssueChanged FINAL)
+ Q_PROPERTY(QString summaryHandoverTooltip READ summaryHandoverTooltip NOTIFY
+ agentSessionIssueChanged FINAL)
+ Q_PROPERTY(bool canShrinkContext READ canShrinkContext NOTIFY shrinkContextStateChanged FINAL)
+ Q_PROPERTY(QString shrinkContextTooltip READ shrinkContextTooltip NOTIFY
+ shrinkContextStateChanged FINAL)
Q_PROPERTY(QStringList availableConfigurations READ availableConfigurations NOTIFY availableConfigurationsChanged FINAL)
Q_PROPERTY(QString currentConfiguration READ currentConfiguration NOTIFY currentConfigurationChanged FINAL)
- Q_PROPERTY(QStringList availableAgentRoles READ availableAgentRoles NOTIFY availableAgentRolesChanged FINAL)
- Q_PROPERTY(QString currentAgentRole READ currentAgentRole NOTIFY currentAgentRoleChanged FINAL)
Q_PROPERTY(QString baseSystemPrompt READ baseSystemPrompt NOTIFY baseSystemPromptChanged FINAL)
- Q_PROPERTY(QString currentAgentRoleDescription READ currentAgentRoleDescription NOTIFY currentAgentRoleChanged FINAL)
- Q_PROPERTY(QString currentAgentRoleSystemPrompt READ currentAgentRoleSystemPrompt NOTIFY currentAgentRoleChanged FINAL)
Q_PROPERTY(bool isCompressing READ isCompressing NOTIFY isCompressingChanged FINAL)
Q_PROPERTY(bool isInEditor READ isInEditor NOTIFY isInEditorChanged FINAL)
Q_PROPERTY(QString chatTitle READ chatTitle NOTIFY chatTitleChanged FINAL)
@@ -88,23 +90,17 @@ public:
QString getAutosaveFilePath(const QString &firstMessage, const QStringList &attachments) const;
QStringList attachmentFiles() const;
- QStringList linkedFiles() const;
Q_INVOKABLE void showAttachFilesDialog();
Q_INVOKABLE void addFilesToAttachList(const QStringList &filePaths);
Q_INVOKABLE void removeFileFromAttachList(int index);
- Q_INVOKABLE void showLinkFilesDialog();
- Q_INVOKABLE void addFilesToLinkList(const QStringList &filePaths);
- Q_INVOKABLE void removeFileFromLinkList(int index);
Q_INVOKABLE QStringList convertUrlsToLocalPaths(const QVariantList &urls) const;
Q_INVOKABLE void showAddImageDialog();
Q_INVOKABLE bool isImageFile(const QString &filePath) const;
Q_INVOKABLE void calculateMessageTokensCount(const QString &message);
Q_INVOKABLE bool isSendShortcut(int key, int modifiers) const;
QString sendShortcutText() const;
- Q_INVOKABLE void setIsSyncOpenFiles(bool state);
Q_INVOKABLE void openChatHistoryFolder();
- Q_INVOKABLE void openRulesFolder();
Q_INVOKABLE void openSettings();
Q_INVOKABLE void openFileInEditor(const QString &filePath);
@@ -117,16 +113,9 @@ public:
Q_INVOKABLE void updateInputTokensCount();
int inputTokensCount() const;
- bool isSyncOpenFiles() const;
-
- void onEditorAboutToClose(Core::IEditor *editor);
- void onAppendLinkFileFromEditor(Core::IEditor *editor);
- void onEditorCreated(Core::IEditor *editor, const Utils::FilePath &filePath);
-
QString chatFileName() const;
Q_INVOKABLE QString chatFilePath() const;
void setRecentFilePath(const QString &filePath);
- bool shouldIgnoreFileForAttach(const Utils::FilePath &filePath);
QString textFontFamily() const;
QString codeFontFamily() const;
@@ -139,18 +128,10 @@ public:
void setRequestProgressStatus(bool state);
QString lastErrorMessage() const;
-
- QVariantList activeRules() const;
- int activeRulesCount() const;
- Q_INVOKABLE QString getRuleContent(int index);
- Q_INVOKABLE void refreshRules();
- Q_INVOKABLE QVariantList searchSkills(const QString &query) const;
+ Q_INVOKABLE QVariantList searchSlashCommands(const QString &query) const;
- bool useTools() const;
- void setUseTools(bool enabled);
- bool useThinking() const;
- void setUseThinking(bool enabled);
+ Q_INVOKABLE void respondToPermission(const QString &requestId, const QString &optionId);
Q_INVOKABLE void applyFileEdit(const QString &editId);
Q_INVOKABLE void rejectFileEdit(const QString &editId);
@@ -163,21 +144,16 @@ public:
Q_INVOKABLE void loadAvailableConfigurations();
Q_INVOKABLE void applyConfiguration(const QString &configName);
+ Q_INVOKABLE void confirmChatTargetSwitch();
+ Q_INVOKABLE void cancelChatTargetSwitch();
QStringList availableConfigurations() const;
QString currentConfiguration() const;
Q_INVOKABLE void compressCurrentChat();
Q_INVOKABLE void cancelCompression();
- Q_INVOKABLE void loadAvailableAgentRoles();
- Q_INVOKABLE void applyAgentRole(const QString &roleId);
- Q_INVOKABLE void openAgentRolesSettings();
- QStringList availableAgentRoles() const;
- QString currentAgentRole() const;
QString baseSystemPrompt() const;
- QString currentAgentRoleDescription() const;
- QString currentAgentRoleSystemPrompt() const;
-
+
int currentMessageTotalEdits() const;
int currentMessageAppliedEdits() const;
int currentMessagePendingEdits() const;
@@ -185,8 +161,16 @@ public:
QString lastInfoMessage() const;
- bool isThinkingSupport() const;
-
+ bool isAgentBound() const;
+ QString agentSessionIssue() const;
+ bool canStartNewAgentSession() const;
+ bool canHandOverSummary() const;
+ QString summaryHandoverTooltip() const;
+ bool canShrinkContext() const;
+ QString shrinkContextTooltip() const;
+ Q_INVOKABLE void startNewAgentSession();
+ Q_INVOKABLE void startNewAgentSessionWithSummary();
+
bool isCompressing() const;
bool isInEditor() const;
@@ -201,7 +185,6 @@ public slots:
void copyToClipboard(const QString &text);
void cancelRequest();
void clearAttachmentFiles();
- void clearLinkedFiles();
void clearMessages();
void resetChatToMessage(int index);
@@ -209,9 +192,7 @@ signals:
void chatModelChanged();
void currentTemplateChanged();
void attachmentFilesChanged();
- void linkedFilesChanged();
void inputTokensCountChanged();
- void isSyncOpenFilesChanged();
void chatFileNameChanged();
void textFamilyChanged();
void codeFamilyChanged();
@@ -224,19 +205,17 @@ signals:
void lastErrorMessageChanged();
void lastInfoMessageChanged();
void sendShortcutTextChanged();
- void activeRulesChanged();
- void activeRulesCountChanged();
- void useToolsChanged();
- void useThinkingChanged();
void currentMessageEditsStatsChanged();
- void isThinkingSupportChanged();
+ void isAgentBoundChanged();
+ void agentSessionIssueChanged();
+ void shrinkContextStateChanged();
+ void slashCommandsChanged();
void availableConfigurationsChanged();
void currentConfigurationChanged();
+ void chatTargetSwitchNeedsNewChat(const QString &targetName);
- void availableAgentRolesChanged();
- void currentAgentRoleChanged();
void baseSystemPromptChanged();
void isCompressingChanged();
@@ -246,69 +225,59 @@ signals:
void isInEditorChanged();
void chatTitleChanged();
- void openFilesChanged();
-
void closeHostRequested();
+protected:
+ void componentComplete() override;
+
private:
+ QVariantList searchSkills(const QString &query) const;
+ QVariantList searchAgentCommands(const QString &query) const;
QString computeChatTitle() const;
void triggerOpenChatCommand(Utils::Id commandId);
void handOffSession();
- bool deferSendForAutoCompress(
- const QString &message,
- const QStringList &attachments,
- const QStringList &linkedFiles,
- bool useTools,
- bool useThinking);
- void dispatchSend(
- const QString &message,
- const QStringList &attachments,
- const QStringList &linkedFiles,
- bool useTools,
- bool useThinking);
bool hasImageAttachments(const QStringList &attachments) const;
+ std::optional agentById(const QString &agentId) const override;
+ QString compressionConfigurationIssue() const override;
+ bool isCompressionRunning() const override;
+ void startTranscriptSummary() override;
+ void startCompression() override;
+ bool autoCompressEnabled() const override;
+ int autoCompressThreshold() const override;
+ int estimatedNextTokens() const override;
+ bool prepareChatFileForCompression(
+ const QString &message, const QStringList &attachments) override;
+ void dispatch(const QString &message, const QStringList &attachments) override;
+
SessionFileRegistry *sessionFileRegistry() const;
Skills::SkillsManager *skillsManager() const;
ChatModel *m_chatModel;
Templates::PromptProviderChat m_promptProvider;
ChatController *m_controller;
- ChatFileManager *m_fileManager;
+ AttachmentStaging *m_attachmentStaging;
QString m_currentTemplate;
QString m_recentFilePath;
QStringList m_attachmentFiles;
- QStringList m_linkedFiles;
- struct PendingSend {
- QString message;
- QStringList attachments;
- QStringList linkedFiles;
- bool useTools = false;
- bool useThinking = false;
- bool active = false;
- };
- PendingSend m_pendingSend;
- bool m_isSyncOpenFiles;
bool m_isInEditor = false;
mutable QString m_cachedChatTitle;
- QList m_currentEditors;
bool m_isRequestInProgress;
QString m_lastErrorMessage;
- QVariantList m_activeRules;
-
+
QString m_lastInfoMessage;
ChatCompressor *m_chatCompressor;
- AgentRoleController *m_agentRoleController;
ChatConfigurationController *m_configurationController;
FileEditController *m_fileEditController;
InputTokenCounter *m_tokenCounter;
- ChatHistoryStore *m_historyStore;
+ ChatFileStore *m_historyStore;
mutable QPointer m_sessionFileRegistry;
mutable bool m_sessionFileRegistryResolved = false;
mutable QPointer m_skillsManager;
mutable bool m_skillsManagerResolved = false;
+ ConversationCoordinator *m_coordinator = nullptr;
};
} // namespace QodeAssist::Chat
diff --git a/sources/ChatView/ChatSerializer.cpp b/sources/ChatView/ChatSerializer.cpp
deleted file mode 100644
index 86281da..0000000
--- a/sources/ChatView/ChatSerializer.cpp
+++ /dev/null
@@ -1,169 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#include "ChatSerializer.hpp"
-#include "Logger.hpp"
-
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-#include "session/HistorySerializer.hpp"
-
-namespace QodeAssist::Chat {
-
-SerializationResult ChatSerializer::saveToFile(
- const Session::ConversationHistory &history, const QString &filePath)
-{
- if (!ensureDirectoryExists(filePath)) {
- return {false, "Failed to create directory structure"};
- }
-
- const QJsonObject root = Session::HistorySerializer::toJson(history);
-
- QSaveFile file(filePath);
- if (!file.open(QIODevice::WriteOnly)) {
- return {false, QString("Failed to open file for writing: %1").arg(filePath)};
- }
-
- if (file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)) == -1) {
- return {false, QString("Failed to write to file: %1").arg(file.errorString())};
- }
-
- if (!file.commit()) {
- return {false, QString("Failed to save file: %1").arg(file.errorString())};
- }
-
- return {true, QString()};
-}
-
-SerializationResult ChatSerializer::loadFromFile(
- Session::ConversationHistory &history, const QString &filePath)
-{
- QFile file(filePath);
- if (!file.open(QIODevice::ReadOnly)) {
- return {false, QString("Failed to open file for reading: %1").arg(filePath)};
- }
-
- QJsonParseError error;
- QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error);
- if (error.error != QJsonParseError::NoError) {
- return {false, QString("JSON parse error: %1").arg(error.errorString())};
- }
-
- const QJsonObject root = doc.object();
- const QString version = root["version"].toString();
-
- if (!Session::HistorySerializer::isSupportedVersion(version)) {
- return {false, QString("Unsupported version: %1").arg(version)};
- }
-
- int droppedBlocks = 0;
- const auto loaded = Session::HistorySerializer::fromJson(root, &droppedBlocks);
- if (!loaded) {
- return {false, QString("Failed to read chat history from: %1").arg(filePath)};
- }
-
- if (version != Session::HistorySerializer::currentVersion()) {
- LOG_MESSAGE(QString("Converted chat from format %1 to %2")
- .arg(version, Session::HistorySerializer::currentVersion()));
- }
-
- history = *loaded;
-
- if (droppedBlocks > 0) {
- const QString warning
- = QString(
- "%1 message part(s) in this chat could not be read and will be lost if "
- "the chat is saved again")
- .arg(droppedBlocks);
- LOG_MESSAGE(QString("%1: %2").arg(filePath, warning));
- return {true, QString(), warning};
- }
-
- return {true, QString(), QString()};
-}
-
-bool ChatSerializer::ensureDirectoryExists(const QString &filePath)
-{
- QFileInfo fileInfo(filePath);
- QDir dir = fileInfo.dir();
- return dir.exists() || dir.mkpath(".");
-}
-
-QString ChatSerializer::getChatContentFolder(const QString &chatFilePath)
-{
- QFileInfo fileInfo(chatFilePath);
- QString baseName = fileInfo.completeBaseName();
- QString dirPath = fileInfo.absolutePath();
- return QDir(dirPath).filePath(baseName + "_content");
-}
-
-bool ChatSerializer::saveContentToStorage(
- const QString &chatFilePath,
- const QString &fileName,
- const QString &base64Data,
- QString &storedPath)
-{
- QString contentFolder = getChatContentFolder(chatFilePath);
- QDir dir;
- if (!dir.exists(contentFolder)) {
- if (!dir.mkpath(contentFolder)) {
- LOG_MESSAGE(QString("Failed to create content folder: %1").arg(contentFolder));
- return false;
- }
- }
-
- QFileInfo originalFileInfo(fileName);
- QString extension = originalFileInfo.suffix();
- QString baseName = originalFileInfo.completeBaseName();
- QString uniqueName = QString("%1_%2.%3")
- .arg(baseName)
- .arg(QUuid::createUuid().toString(QUuid::WithoutBraces).left(8))
- .arg(extension);
-
- QString fullPath = QDir(contentFolder).filePath(uniqueName);
-
- QByteArray contentData = QByteArray::fromBase64(base64Data.toUtf8());
- QFile file(fullPath);
- if (!file.open(QIODevice::WriteOnly)) {
- LOG_MESSAGE(QString("Failed to open file for writing: %1").arg(fullPath));
- return false;
- }
-
- if (file.write(contentData) == -1) {
- LOG_MESSAGE(QString("Failed to write content data: %1").arg(file.errorString()));
- return false;
- }
-
- file.close();
-
- storedPath = uniqueName;
- LOG_MESSAGE(QString("Saved content: %1 to %2").arg(fileName, fullPath));
-
- return true;
-}
-
-QString ChatSerializer::loadContentFromStorage(const QString &chatFilePath, const QString &storedPath)
-{
- QString contentFolder = getChatContentFolder(chatFilePath);
- QString fullPath = QDir(contentFolder).filePath(storedPath);
-
- QFile file(fullPath);
- if (!file.open(QIODevice::ReadOnly)) {
- LOG_MESSAGE(QString("Failed to open content file: %1").arg(fullPath));
- return QString();
- }
-
- QByteArray contentData = file.readAll();
- file.close();
-
- return contentData.toBase64();
-}
-
-} // namespace QodeAssist::Chat
diff --git a/sources/ChatView/ChatSerializer.hpp b/sources/ChatView/ChatSerializer.hpp
deleted file mode 100644
index 6d95373..0000000
--- a/sources/ChatView/ChatSerializer.hpp
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2024-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-#pragma once
-
-#include
-
-#include "session/ConversationHistory.hpp"
-
-namespace QodeAssist::Chat {
-
-struct SerializationResult
-{
- bool success{false};
- QString errorMessage;
- QString warningMessage;
-};
-
-class ChatSerializer
-{
-public:
- static SerializationResult saveToFile(
- const Session::ConversationHistory &history, const QString &filePath);
- static SerializationResult loadFromFile(
- Session::ConversationHistory &history, const QString &filePath);
-
- // Content management (images and text files)
- static QString getChatContentFolder(const QString &chatFilePath);
- static bool saveContentToStorage(const QString &chatFilePath,
- const QString &fileName,
- const QString &base64Data,
- QString &storedPath);
- static QString loadContentFromStorage(const QString &chatFilePath, const QString &storedPath);
-
-private:
- static bool ensureDirectoryExists(const QString &filePath);
-};
-
-} // namespace QodeAssist::Chat
diff --git a/sources/ChatView/ConversationCoordinator.cpp b/sources/ChatView/ConversationCoordinator.cpp
new file mode 100644
index 0000000..3c89e1a
--- /dev/null
+++ b/sources/ChatView/ConversationCoordinator.cpp
@@ -0,0 +1,373 @@
+// Copyright (C) 2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include "ConversationCoordinator.hpp"
+
+#include "logger/Logger.hpp"
+
+namespace QodeAssist::Chat {
+
+ConversationCoordinator::ConversationCoordinator(const Ports &ports, QObject *parent)
+ : QObject(parent)
+ , m_ports(ports)
+{}
+
+void ConversationCoordinator::requestSend(const QString &message, const QStringList &attachments)
+{
+ if (refuseWhileReadOnly())
+ return;
+
+ if (!m_ports.ops->boundAgentId().isEmpty()
+ && m_ports.compression->isCompressionRunning()) {
+ emit errorSurfaced(
+ tr("A handover summary is being prepared; wait for it to finish before sending"));
+ return;
+ }
+
+ if (deferSendForAutoCompress(message, attachments))
+ return;
+
+ m_ports.send->dispatch(message, attachments);
+}
+
+bool ConversationCoordinator::refuseWhileReadOnly()
+{
+ if (m_sessionIssue.isEmpty())
+ return false;
+
+ emit errorSurfaced(m_sessionIssue);
+ return true;
+}
+
+bool ConversationCoordinator::hasDeferredSend() const
+{
+ return m_deferredSend.active;
+}
+
+bool ConversationCoordinator::deferSendForAutoCompress(
+ const QString &message, const QStringList &attachments)
+{
+ if (!m_ports.ops->boundAgentId().isEmpty())
+ return false;
+
+ if (!m_ports.send->autoCompressEnabled())
+ return false;
+
+ const int threshold = m_ports.send->autoCompressThreshold();
+ const int inputTokens = m_ports.send->estimatedNextTokens();
+ if (inputTokens < threshold)
+ return false;
+
+ if (!m_ports.send->prepareChatFileForCompression(message, attachments))
+ return false;
+
+ if (m_ports.compression->isCompressionRunning() || m_deferredSend.active)
+ return false;
+
+ LOG_MESSAGE(QString("Auto-compress preempt: estimated next=%1 ≥ threshold=%2; deferring send")
+ .arg(inputTokens)
+ .arg(threshold));
+
+ m_deferredSend = {message, attachments, true};
+ m_ports.compression->startCompression();
+ return true;
+}
+
+void ConversationCoordinator::compressionSettled()
+{
+ if (!m_deferredSend.active)
+ return;
+
+ const DeferredSend deferred = m_deferredSend;
+ m_deferredSend = {};
+
+ if (refuseWhileReadOnly())
+ return;
+
+ m_ports.send->dispatch(deferred.message, deferred.attachments);
+}
+
+void ConversationCoordinator::chooseAgent(const Acp::AgentDefinition &agent)
+{
+ if (m_ports.ops->boundAgentId() == agent.id)
+ return;
+
+ if (m_ports.ops->conversationStarted()) {
+ m_pendingAgent = agent;
+ m_pendingLlmSwitch = false;
+ emit switchConfirmationNeeded(agent.name);
+ return;
+ }
+
+ bindAgentNow(agent);
+}
+
+void ConversationCoordinator::chooseLlm()
+{
+ if (m_ports.ops->boundAgentId().isEmpty())
+ return;
+
+ if (m_ports.ops->conversationStarted()) {
+ m_pendingAgent.reset();
+ m_pendingLlmSwitch = true;
+ emit switchConfirmationNeeded(tr("direct LLM chat"));
+ return;
+ }
+
+ bindLlmNow();
+}
+
+void ConversationCoordinator::confirmSwitch()
+{
+ const auto agent = m_pendingAgent;
+ const bool toLlm = m_pendingLlmSwitch;
+
+ m_pendingAgent.reset();
+ m_pendingLlmSwitch = false;
+
+ if (!agent && !toLlm)
+ return;
+
+ m_ports.ops->clearConversation();
+
+ if (agent)
+ bindAgentNow(*agent);
+ else
+ bindLlmNow();
+}
+
+void ConversationCoordinator::cancelSwitch()
+{
+ m_pendingAgent.reset();
+ m_pendingLlmSwitch = false;
+ emit switchCancelled();
+}
+
+bool ConversationCoordinator::switchPending() const
+{
+ return m_pendingAgent.has_value() || m_pendingLlmSwitch;
+}
+
+void ConversationCoordinator::bindAgentNow(const Acp::AgentDefinition &agent)
+{
+ m_ports.ops->bindAgent(agent);
+ emit boundToAgent(agent);
+}
+
+void ConversationCoordinator::bindLlmNow()
+{
+ m_ports.ops->bindLlm();
+ emit boundToLlm();
+}
+
+void ConversationCoordinator::restoreAgentBinding(const Acp::AgentBinding &binding)
+{
+ setSessionIssue(QString(), false);
+ m_quarantinedBinding = {};
+ m_ports.ops->releaseAgentSession();
+
+ if (binding.isEmpty()) {
+ bindLlmNow();
+ return;
+ }
+
+ const auto agent = m_ports.catalog->agentById(binding.agentId);
+ if (!agent || !agent->isLaunchable()) {
+ m_quarantinedBinding = binding;
+ bindLlmNow();
+ setSessionIssue(
+ tr("This chat was held with the agent \"%1\", which is not available any more. "
+ "The transcript is read-only, and the agent it names is kept so the chat can be "
+ "continued once that agent is installed again.")
+ .arg(binding.displayId()),
+ false);
+ return;
+ }
+
+ bindAgentNow(*agent);
+
+ if (!binding.sessionId.isEmpty()) {
+ m_ports.ops->resumeAgentSession(binding.sessionId);
+ return;
+ }
+
+ if (m_ports.ops->conversationStarted()) {
+ setSessionIssue(
+ tr("This chat records the agent \"%1\" but not a session to reopen, so the transcript "
+ "is read-only. Start a new session to keep working with this agent — it will not "
+ "have the context above.")
+ .arg(binding.displayId()),
+ true);
+ }
+}
+
+Acp::AgentBinding ConversationCoordinator::bindingForSave() const
+{
+ return m_quarantinedBinding.isEmpty() ? m_ports.ops->agentBinding() : m_quarantinedBinding;
+}
+
+QString ConversationCoordinator::sessionIssue() const
+{
+ return m_sessionIssue;
+}
+
+bool ConversationCoordinator::readOnly() const
+{
+ return !m_sessionIssue.isEmpty();
+}
+
+bool ConversationCoordinator::canStartFreshSession() const
+{
+ return m_sessionRecoverable;
+}
+
+bool ConversationCoordinator::canHandOverSummary() const
+{
+ return m_sessionRecoverable && m_ports.compression->compressionConfigurationIssue().isEmpty()
+ && !m_ports.ops->transcriptEmpty();
+}
+
+QString ConversationCoordinator::summaryHandoverTooltip() const
+{
+ if (!m_sessionRecoverable)
+ return {};
+
+ if (m_ports.ops->transcriptEmpty())
+ return tr("There is nothing to summarise yet.");
+
+ const QString issue = m_ports.compression->compressionConfigurationIssue();
+ if (!issue.isEmpty()) {
+ return tr("A summary cannot be produced because %1. Start a new session without one, or "
+ "assign the chat feature in the settings.")
+ .arg(issue);
+ }
+
+ return tr("Summarise this transcript and give it to the new session as context.");
+}
+
+void ConversationCoordinator::startFreshSession()
+{
+ if (!m_sessionRecoverable)
+ return;
+
+ m_ports.ops->startFreshAgentSession();
+ setSessionIssue(QString(), false);
+}
+
+void ConversationCoordinator::handOverSummary()
+{
+ if (!canHandOverSummary())
+ return;
+
+ if (m_ports.compression->isCompressionRunning()) {
+ emit errorSurfaced(tr("A summary is already being prepared"));
+ return;
+ }
+
+ m_ports.compression->startTranscriptSummary();
+}
+
+void ConversationCoordinator::shrinkContext()
+{
+ if (refuseWhileReadOnly())
+ return;
+
+ if (!canShrinkContext()) {
+ emit errorSurfaced(shrinkContextTooltip());
+ return;
+ }
+
+ if (m_ports.ops->boundAgentId().isEmpty()) {
+ m_ports.compression->startCompression();
+ return;
+ }
+
+ if (m_ports.compression->isCompressionRunning()) {
+ emit errorSurfaced(tr("A summary is already being prepared"));
+ return;
+ }
+
+ m_ports.compression->startTranscriptSummary();
+}
+
+bool ConversationCoordinator::canShrinkContext() const
+{
+ if (readOnly())
+ return false;
+
+ if (!m_ports.compression->compressionConfigurationIssue().isEmpty())
+ return false;
+
+ if (m_ports.ops->boundAgentId().isEmpty())
+ return true;
+
+ return !m_ports.ops->transcriptEmpty();
+}
+
+QString ConversationCoordinator::shrinkContextTooltip() const
+{
+ const QString issue = m_ports.compression->compressionConfigurationIssue();
+ if (!issue.isEmpty()) {
+ return tr("Unavailable because %1. Assign the chat feature in the settings.").arg(issue);
+ }
+
+ if (m_ports.ops->boundAgentId().isEmpty())
+ return tr("Compress chat (create summarized copy using LLM)");
+
+ if (m_ports.ops->transcriptEmpty())
+ return tr("There is nothing to summarise yet.");
+
+ return tr("Summarise this conversation and hand it to a fresh agent session as context.");
+}
+
+void ConversationCoordinator::summaryProduced(const QString &summary)
+{
+ m_ports.ops->startFreshAgentSession(summary);
+ setSessionIssue(QString(), false);
+}
+
+QString ConversationCoordinator::agentTitle() const
+{
+ return m_agentTitle;
+}
+
+void ConversationCoordinator::titleSuggested(const QString &title)
+{
+ if (m_agentTitle == title)
+ return;
+
+ m_agentTitle = title;
+ emit agentTitleChanged();
+}
+
+void ConversationCoordinator::agentSessionUnavailable(const QString &reason)
+{
+ LOG_MESSAGE(QString("Agent session could not be reopened: %1").arg(reason));
+ setSessionIssue(
+ tr("The previous session with this agent could not be reopened, so the transcript "
+ "is read-only. Start a new session to keep working with this agent — it will "
+ "not have the context above."),
+ true);
+}
+
+void ConversationCoordinator::conversationReset()
+{
+ if (!m_agentTitle.isEmpty()) {
+ m_agentTitle.clear();
+ emit agentTitleChanged();
+ }
+ setSessionIssue(QString(), false);
+}
+
+void ConversationCoordinator::setSessionIssue(const QString &issue, bool recoverable)
+{
+ if (m_sessionIssue == issue && m_sessionRecoverable == recoverable)
+ return;
+
+ m_sessionIssue = issue;
+ m_sessionRecoverable = recoverable;
+ emit sessionIssueChanged();
+}
+
+} // namespace QodeAssist::Chat
diff --git a/sources/ChatView/ConversationCoordinator.hpp b/sources/ChatView/ConversationCoordinator.hpp
new file mode 100644
index 0000000..dffd75f
--- /dev/null
+++ b/sources/ChatView/ConversationCoordinator.hpp
@@ -0,0 +1,100 @@
+// Copyright (C) 2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#pragma once
+
+#include
+
+#include
+#include
+#include
+
+#include "ConversationPorts.hpp"
+
+namespace QodeAssist::Chat {
+
+class ConversationCoordinator : public QObject
+{
+ Q_OBJECT
+
+public:
+ struct Ports
+ {
+ IConversationPort *ops = nullptr;
+ IAgentCatalogPort *catalog = nullptr;
+ ICompressionPort *compression = nullptr;
+ ISendPort *send = nullptr;
+ };
+
+ explicit ConversationCoordinator(const Ports &ports, QObject *parent = nullptr);
+
+ void requestSend(const QString &message, const QStringList &attachments);
+ bool refuseWhileReadOnly();
+ bool hasDeferredSend() const;
+
+ void chooseAgent(const Acp::AgentDefinition &agent);
+ void chooseLlm();
+ void confirmSwitch();
+ void cancelSwitch();
+ bool switchPending() const;
+
+ void restoreAgentBinding(const Acp::AgentBinding &binding);
+ Acp::AgentBinding bindingForSave() const;
+
+ QString sessionIssue() const;
+ bool readOnly() const;
+ bool canStartFreshSession() const;
+
+ bool canHandOverSummary() const;
+ QString summaryHandoverTooltip() const;
+ void startFreshSession();
+ void handOverSummary();
+
+ void shrinkContext();
+ bool canShrinkContext() const;
+ QString shrinkContextTooltip() const;
+
+ QString agentTitle() const;
+
+ void conversationReset();
+
+public slots:
+ void titleSuggested(const QString &title);
+ void agentSessionUnavailable(const QString &reason);
+ void summaryProduced(const QString &summary);
+ void compressionSettled();
+
+signals:
+ void sessionIssueChanged();
+ void agentTitleChanged();
+ void boundToAgent(const QodeAssist::Acp::AgentDefinition &agent);
+ void boundToLlm();
+ void switchConfirmationNeeded(const QString &targetName);
+ void switchCancelled();
+ void errorSurfaced(const QString &message);
+
+private:
+ struct DeferredSend
+ {
+ QString message;
+ QStringList attachments;
+ bool active = false;
+ };
+
+ void setSessionIssue(const QString &issue, bool recoverable);
+ bool deferSendForAutoCompress(const QString &message, const QStringList &attachments);
+ void bindAgentNow(const Acp::AgentDefinition &agent);
+ void bindLlmNow();
+
+ Ports m_ports;
+ std::optional m_pendingAgent;
+ bool m_pendingLlmSwitch = false;
+ DeferredSend m_deferredSend;
+ QString m_agentTitle;
+ QString m_sessionIssue;
+ bool m_sessionRecoverable = false;
+ Acp::AgentBinding m_quarantinedBinding;
+};
+
+} // namespace QodeAssist::Chat
diff --git a/sources/ChatView/ConversationPorts.hpp b/sources/ChatView/ConversationPorts.hpp
new file mode 100644
index 0000000..7cc6cfc
--- /dev/null
+++ b/sources/ChatView/ConversationPorts.hpp
@@ -0,0 +1,70 @@
+// Copyright (C) 2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#pragma once
+
+#include
+
+#include
+#include
+
+#include "acp/AgentBinding.hpp"
+#include "acp/AgentDefinition.hpp"
+
+namespace QodeAssist::Chat {
+
+class IConversationPort
+{
+public:
+ virtual ~IConversationPort() = default;
+
+ virtual QString boundAgentId() const = 0;
+ virtual bool conversationStarted() const = 0;
+ virtual bool transcriptEmpty() const = 0;
+ virtual Acp::AgentBinding agentBinding() const = 0;
+
+ virtual void bindAgent(const Acp::AgentDefinition &agent) = 0;
+ virtual void bindLlm() = 0;
+ virtual void clearConversation() = 0;
+
+ virtual void resumeAgentSession(const QString &sessionId) = 0;
+ virtual void startFreshAgentSession() = 0;
+ virtual void startFreshAgentSession(const QString &handoverSummary) = 0;
+ virtual void releaseAgentSession() = 0;
+};
+
+class IAgentCatalogPort
+{
+public:
+ virtual ~IAgentCatalogPort() = default;
+
+ virtual std::optional agentById(const QString &agentId) const = 0;
+};
+
+class ICompressionPort
+{
+public:
+ virtual ~ICompressionPort() = default;
+
+ virtual QString compressionConfigurationIssue() const = 0;
+ virtual bool isCompressionRunning() const = 0;
+ virtual void startTranscriptSummary() = 0;
+ virtual void startCompression() = 0;
+};
+
+class ISendPort
+{
+public:
+ virtual ~ISendPort() = default;
+
+ virtual bool autoCompressEnabled() const = 0;
+ virtual int autoCompressThreshold() const = 0;
+ virtual int estimatedNextTokens() const = 0;
+ virtual bool prepareChatFileForCompression(
+ const QString &message, const QStringList &attachments)
+ = 0;
+ virtual void dispatch(const QString &message, const QStringList &attachments) = 0;
+};
+
+} // namespace QodeAssist::Chat
diff --git a/sources/ChatView/FileEditController.cpp b/sources/ChatView/FileEditController.cpp
index 208a7bf..2e037fe 100644
--- a/sources/ChatView/FileEditController.cpp
+++ b/sources/ChatView/FileEditController.cpp
@@ -11,27 +11,27 @@
#include
#include "Logger.hpp"
-#include "context/ChangesManager.h"
+#include "context/FileEditManager.hpp"
namespace QodeAssist::Chat {
FileEditController::FileEditController(QObject *parent)
: QObject(parent)
{
- auto &changes = Context::ChangesManager::instance();
- connect(&changes, &Context::ChangesManager::fileEditAdded, this, [this](const QString &) {
+ auto &changes = Context::FileEditManager::instance();
+ connect(&changes, &Context::FileEditManager::fileEditAdded, this, [this](const QString &) {
updateStats();
});
- connect(&changes, &Context::ChangesManager::fileEditApplied, this, [this](const QString &) {
+ connect(&changes, &Context::FileEditManager::fileEditApplied, this, [this](const QString &) {
updateStats();
});
- connect(&changes, &Context::ChangesManager::fileEditRejected, this, [this](const QString &) {
+ connect(&changes, &Context::FileEditManager::fileEditRejected, this, [this](const QString &) {
updateStats();
});
- connect(&changes, &Context::ChangesManager::fileEditUndone, this, [this](const QString &) {
+ connect(&changes, &Context::FileEditManager::fileEditUndone, this, [this](const QString &) {
updateStats();
});
- connect(&changes, &Context::ChangesManager::fileEditArchived, this, [this](const QString &) {
+ connect(&changes, &Context::FileEditManager::fileEditArchived, this, [this](const QString &) {
updateStats();
});
}
@@ -76,11 +76,11 @@ int FileEditController::rejectedEdits() const
void FileEditController::applyFileEdit(const QString &editId)
{
LOG_MESSAGE(QString("Applying file edit: %1").arg(editId));
- if (Context::ChangesManager::instance().applyFileEdit(editId)) {
+ if (Context::FileEditManager::instance().applyFileEdit(editId)) {
emit infoMessage(QString("File edit applied successfully"));
updateStats();
} else {
- auto edit = Context::ChangesManager::instance().getFileEdit(editId);
+ auto edit = Context::FileEditManager::instance().getFileEdit(editId);
emit errorOccurred(
edit.statusMessage.isEmpty()
? QString("Failed to apply file edit")
@@ -91,11 +91,11 @@ void FileEditController::applyFileEdit(const QString &editId)
void FileEditController::rejectFileEdit(const QString &editId)
{
LOG_MESSAGE(QString("Rejecting file edit: %1").arg(editId));
- if (Context::ChangesManager::instance().rejectFileEdit(editId)) {
+ if (Context::FileEditManager::instance().rejectFileEdit(editId)) {
emit infoMessage(QString("File edit rejected"));
updateStats();
} else {
- auto edit = Context::ChangesManager::instance().getFileEdit(editId);
+ auto edit = Context::FileEditManager::instance().getFileEdit(editId);
emit errorOccurred(
edit.statusMessage.isEmpty()
? QString("Failed to reject file edit")
@@ -106,11 +106,11 @@ void FileEditController::rejectFileEdit(const QString &editId)
void FileEditController::undoFileEdit(const QString &editId)
{
LOG_MESSAGE(QString("Undoing file edit: %1").arg(editId));
- if (Context::ChangesManager::instance().undoFileEdit(editId)) {
+ if (Context::FileEditManager::instance().undoFileEdit(editId)) {
emit infoMessage(QString("File edit undone successfully"));
updateStats();
} else {
- auto edit = Context::ChangesManager::instance().getFileEdit(editId);
+ auto edit = Context::FileEditManager::instance().getFileEdit(editId);
emit errorOccurred(
edit.statusMessage.isEmpty()
? QString("Failed to undo file edit")
@@ -122,7 +122,7 @@ void FileEditController::openFileEditInEditor(const QString &editId)
{
LOG_MESSAGE(QString("Opening file edit in editor: %1").arg(editId));
- auto edit = Context::ChangesManager::instance().getFileEdit(editId);
+ auto edit = Context::FileEditManager::instance().getFileEdit(editId);
if (edit.editId.isEmpty()) {
emit errorOccurred(QString("File edit not found: %1").arg(editId));
return;
@@ -143,7 +143,7 @@ void FileEditController::openFileEditInEditor(const QString &editId)
QString currentContent = doc->toPlainText();
int position = -1;
- if (edit.status == Context::ChangesManager::Applied && !edit.newContent.isEmpty()) {
+ if (edit.status == Context::FileEditManager::Applied && !edit.newContent.isEmpty()) {
position = currentContent.indexOf(edit.newContent);
} else if (!edit.oldContent.isEmpty()) {
position = currentContent.indexOf(edit.oldContent);
@@ -171,7 +171,7 @@ void FileEditController::applyAllForCurrentMessage()
LOG_MESSAGE(QString("Applying all file edits for message: %1").arg(m_currentRequestId));
QString errorMsg;
- bool success = Context::ChangesManager::instance()
+ bool success = Context::FileEditManager::instance()
.reapplyAllEditsForRequest(m_currentRequestId, &errorMsg);
if (success) {
@@ -196,7 +196,7 @@ void FileEditController::undoAllForCurrentMessage()
LOG_MESSAGE(QString("Undoing all file edits for message: %1").arg(m_currentRequestId));
QString errorMsg;
- bool success = Context::ChangesManager::instance()
+ bool success = Context::FileEditManager::instance()
.undoAllEditsForRequest(m_currentRequestId, &errorMsg);
if (success) {
@@ -225,7 +225,7 @@ void FileEditController::updateStats()
return;
}
- auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId);
+ auto edits = Context::FileEditManager::instance().getEditsForRequest(m_currentRequestId);
int total = edits.size();
int applied = 0;
@@ -234,16 +234,16 @@ void FileEditController::updateStats()
for (const auto &edit : edits) {
switch (edit.status) {
- case Context::ChangesManager::Applied:
+ case Context::FileEditManager::Applied:
applied++;
break;
- case Context::ChangesManager::Pending:
+ case Context::FileEditManager::Pending:
pending++;
break;
- case Context::ChangesManager::Rejected:
+ case Context::FileEditManager::Rejected:
rejected++;
break;
- case Context::ChangesManager::Archived:
+ case Context::FileEditManager::Archived:
total--;
break;
}
diff --git a/sources/ChatView/FileMentionItem.cpp b/sources/ChatView/FileMentionItem.cpp
index e608181..bc032c1 100644
--- a/sources/ChatView/FileMentionItem.cpp
+++ b/sources/ChatView/FileMentionItem.cpp
@@ -4,6 +4,8 @@
#include "FileMentionItem.hpp"
+#include "settings/ChatAssistantSettings.hpp"
+
#include
#include
#include
@@ -11,6 +13,7 @@
#include
#include
+
#include
#include
@@ -113,8 +116,7 @@ void FileMentionItem::dismiss()
emit dismissed();
}
-QVariantMap FileMentionItem::applyCurrentSelection(
- const QString &text, int cursorPosition, bool useTools)
+QVariantMap FileMentionItem::applyCurrentSelection(const QString &text, int cursorPosition)
{
if (m_currentIndex < 0 || m_currentIndex >= m_searchResults.size()) {
dismiss();
@@ -139,8 +141,7 @@ QVariantMap FileMentionItem::applyCurrentSelection(
item.value("absolutePath").toString(),
item.value("relativePath").toString(),
item.value("projectName").toString(),
- currentQuery,
- useTools);
+ currentQuery);
if (result.value("mode").toString() == "mention")
replacement = result.value("mentionText").toString();
@@ -158,8 +159,7 @@ QVariantMap FileMentionItem::handleFileSelection(
const QString &absolutePath,
const QString &relativePath,
const QString &projectName,
- const QString ¤tQuery,
- bool useTools)
+ const QString ¤tQuery)
{
QVariantMap result;
const QString fileName = relativePath.section('/', -1);
@@ -172,7 +172,7 @@ QVariantMap FileMentionItem::handleFileSelection(
mentionKey = projPrefix + ":" + fileName;
}
- if (useTools) {
+ if (Settings::chatAssistantSettings().enableChatTools()) {
registerMention(mentionKey, absolutePath);
result["mode"] = QStringLiteral("mention");
result["mentionText"] = "@" + mentionKey + " ";
diff --git a/sources/ChatView/FileMentionItem.hpp b/sources/ChatView/FileMentionItem.hpp
index eba52c2..e0f6be0 100644
--- a/sources/ChatView/FileMentionItem.hpp
+++ b/sources/ChatView/FileMentionItem.hpp
@@ -36,11 +36,9 @@ public:
const QString &absolutePath,
const QString &relativePath,
const QString &projectName,
- const QString ¤tQuery,
- bool useTools);
+ const QString ¤tQuery);
- Q_INVOKABLE QVariantMap applyCurrentSelection(
- const QString &text, int cursorPosition, bool useTools);
+ Q_INVOKABLE QVariantMap applyCurrentSelection(const QString &text, int cursorPosition);
Q_INVOKABLE void registerMention(const QString &mentionKey, const QString &absolutePath);
Q_INVOKABLE void clearMentions();
diff --git a/sources/ChatView/InputTokenCounter.cpp b/sources/ChatView/InputTokenCounter.cpp
index 0ebd08d..bfbc1ed 100644
--- a/sources/ChatView/InputTokenCounter.cpp
+++ b/sources/ChatView/InputTokenCounter.cpp
@@ -79,12 +79,6 @@ void InputTokenCounter::setAttachments(const QStringList &attachments)
recompute();
}
-void InputTokenCounter::setLinkedFiles(const QStringList &linkedFiles)
-{
- m_linkedFiles = linkedFiles;
- recompute();
-}
-
void InputTokenCounter::rewireToolsChangedConnection()
{
if (m_toolsChangedConn)
@@ -160,14 +154,11 @@ void InputTokenCounter::recompute()
inputTokens += estimateFileTokens(textPaths);
}
- if (!m_linkedFiles.isEmpty()) {
- QStringList textPaths;
- inputTokens += splitImageEstimate(m_linkedFiles, textPaths);
- inputTokens += estimateFileTokens(textPaths);
- }
-
if (m_session) {
for (const Session::MessageRow &row : m_session->rows()) {
+ if (Session::rowTreatmentFor(Session::RowAudience::TokenCount, row.kind)
+ == Session::RowTreatment::Omit)
+ continue;
inputTokens += Context::TokenUtils::estimateTokens(row.content);
inputTokens += 4; // + role
}
diff --git a/sources/ChatView/InputTokenCounter.hpp b/sources/ChatView/InputTokenCounter.hpp
index c75749b..8dab84b 100644
--- a/sources/ChatView/InputTokenCounter.hpp
+++ b/sources/ChatView/InputTokenCounter.hpp
@@ -35,7 +35,6 @@ public:
void setMessage(const QString &message);
void setAttachments(const QStringList &attachments);
- void setLinkedFiles(const QStringList &linkedFiles);
void recompute();
void recomputeSoon();
@@ -62,7 +61,6 @@ private:
QHash m_fileTokens;
QStringList m_attachments;
- QStringList m_linkedFiles;
int m_messageTokens{0};
int m_inputTokens{0};
int m_lastSentEstimate{0};
diff --git a/sources/ChatView/LlmChatBackend.cpp b/sources/ChatView/LlmChatBackend.cpp
index fc221b2..1fa905a 100644
--- a/sources/ChatView/LlmChatBackend.cpp
+++ b/sources/ChatView/LlmChatBackend.cpp
@@ -6,14 +6,18 @@
#include
-#include
+#include
-#include "ChatSerializer.hpp"
+#include
+
+#include "ChatFileStore.hpp"
#include "llmcore/ContextData.hpp"
#include "logger/Logger.hpp"
#include "providers/ProvidersManager.hpp"
+#include "session/FencedText.hpp"
#include "session/FileEditPayload.hpp"
#include "session/HistoryProjection.hpp"
+#include "settings/ChatAssistantSettings.hpp"
#include "settings/GeneralSettings.hpp"
#include "settings/ToolsSettings.hpp"
#include "tools/ReadOriginalHistoryTool.hpp"
@@ -24,8 +28,16 @@ namespace QodeAssist::Chat {
LlmChatBackend::LlmChatBackend(Templates::IPromptProvider *promptProvider, QObject *parent)
: Session::ChatBackend(parent)
, m_promptProvider(promptProvider)
+ , m_providerResolver([](const QString &name) {
+ return Providers::ProvidersManager::instance().getProviderByName(name);
+ })
{}
+void LlmChatBackend::setProviderResolver(ProviderResolver resolver)
+{
+ m_providerResolver = std::move(resolver);
+}
+
LlmChatBackend::~LlmChatBackend()
{
cancel();
@@ -42,7 +54,7 @@ void LlmChatBackend::sendTurn(const Session::TurnRequest &request)
}
const auto providerName = Settings::generalSettings().caProvider();
- auto *provider = Providers::ProvidersManager::instance().getProviderByName(providerName);
+ auto *provider = m_providerResolver(providerName);
if (!provider) {
const QString error = tr("No provider found with name: %1").arg(providerName);
@@ -62,7 +74,7 @@ void LlmChatBackend::sendTurn(const Session::TurnRequest &request)
}
LLMCore::ContextData context;
- if (request.context)
+ if (request.context && Settings::chatAssistantSettings().useSystemPrompt())
context.systemPrompt = Session::renderSystemPrompt(*request.context);
context.history = renderHistory(*request.history, provider, promptTemplate);
@@ -73,8 +85,8 @@ void LlmChatBackend::sendTurn(const Session::TurnRequest &request)
promptTemplate,
context,
LLMCore::RequestType::Chat,
- request.options.useTools,
- request.options.useThinking);
+ Settings::chatAssistantSettings().enableChatTools(),
+ Settings::chatAssistantSettings().enableThinkingMode());
provider->client()->setMaxToolContinuations(Settings::toolsSettings().maxToolContinuations());
provider->client()->setTransferTimeout(
@@ -88,10 +100,10 @@ void LlmChatBackend::sendTurn(const Session::TurnRequest &request)
m_provider = provider;
m_dropPreToolText = !promptTemplate->supportsToolHistory();
- m_requestId
- = provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint);
+ const QString requestId = m_ledger.beginTurn(
+ provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint));
- emit sessionEvent(Session::TurnStarted{.turnId = m_requestId});
+ emit sessionEvent(Session::TurnStarted{.turnId = requestId});
bindToolSessions(provider);
}
@@ -102,7 +114,7 @@ void LlmChatBackend::cancel()
return;
auto *provider = m_provider;
- const QString requestId = m_requestId;
+ const QString requestId = m_ledger.activeTurnId();
releaseRequest();
@@ -114,14 +126,23 @@ void LlmChatBackend::cancel()
void LlmChatBackend::releaseRequest()
{
- if (m_provider)
+ cancelPendingPermissions();
+
+ if (m_provider) {
disconnect(m_provider->client(), nullptr, this, nullptr);
+ if (m_provider->toolsManager())
+ m_provider->toolsManager()->setExecutionGate({});
+ }
m_provider = nullptr;
- m_requestId.clear();
m_dropPreToolText = false;
}
+Session::TurnContextNeeds LlmChatBackend::contextNeeds() const
+{
+ return {Settings::chatAssistantSettings().useSystemPrompt()};
+}
+
void LlmChatBackend::setChatFilePath(const QString &filePath)
{
m_chatFilePath = filePath;
@@ -133,7 +154,7 @@ void LlmChatBackend::clearToolSession(const QString &filePath)
return;
const auto providerName = Settings::generalSettings().caProvider();
- auto *provider = Providers::ProvidersManager::instance().getProviderByName(providerName);
+ auto *provider = m_providerResolver(providerName);
if (!provider || !provider->capabilities().testFlag(Providers::ProviderCapability::Tools)
|| !provider->toolsManager()) {
@@ -209,6 +230,106 @@ void LlmChatBackend::bindToolSessions(Providers::Provider *provider)
provider->toolsManager()->tool("read_original_history"))) {
historyTool->setCurrentSessionId(m_chatFilePath);
}
+
+ installExecutionGate(provider);
+}
+
+void LlmChatBackend::installExecutionGate(Providers::Provider *provider)
+{
+ provider->toolsManager()->setExecutionGate(
+ [this](
+ const QString &requestId,
+ const QString &toolId,
+ const QString &toolName,
+ const QJsonObject &input) {
+ return gateToolExecution(requestId, toolId, toolName, input);
+ });
+}
+
+QFuture LlmChatBackend::gateToolExecution(
+ const QString &requestId,
+ const QString &toolId,
+ const QString &toolName,
+ const QJsonObject &input)
+{
+ const auto allow = [] {
+ QPromise promise;
+ promise.start();
+ promise.addResult(true);
+ promise.finish();
+ return promise.future();
+ };
+
+ if (!m_ledger.isActiveTurn(requestId))
+ return allow();
+
+ if (!m_provider || !m_provider->toolsManager())
+ return allow();
+
+ auto *tool = m_provider->toolsManager()->tool(toolName);
+ if (tool && tool->safety() == ::LLMQore::ToolSafety::ReadOnly)
+ return allow();
+
+ auto promise = std::make_shared>();
+ promise->start();
+
+ const QString permissionId = m_ledger.registerPermission(
+ [promise](const QString &optionId) {
+ const bool allowed = optionId == Session::PermissionOptionKind::AllowOnce
+ || optionId == Session::PermissionOptionKind::AllowAlways;
+ promise->addResult(allowed);
+ promise->finish();
+ },
+ [promise] {
+ promise->addResult(false);
+ promise->finish();
+ });
+
+ QFuture decision = promise->future();
+
+ const QString title = tool && !tool->displayName().isEmpty()
+ ? tr("Run %1").arg(tool->displayName())
+ : tr("Run %1").arg(toolName);
+
+ emit sessionEvent(
+ Session::PermissionRequested{
+ .turnId = requestId,
+ .requestId = permissionId,
+ .toolCallId = toolId,
+ .title = title,
+ .toolKind = toolName,
+ .options
+ = {Session::PermissionOption{"allow_once", tr("Allow"), "allow_once"},
+ Session::PermissionOption{
+ "allow_always", tr("Allow for this conversation"), "allow_always"},
+ Session::PermissionOption{"reject_once", tr("Don't run it"), "reject_once"}}});
+
+ Q_UNUSED(input)
+ return decision;
+}
+
+bool LlmChatBackend::respondPermission(const QString &requestId, const QString &optionId)
+{
+ if (!m_ledger.resolvePermission(requestId, optionId))
+ return false;
+
+ emit sessionEvent(
+ Session::PermissionResolved{
+ .turnId = m_ledger.activeTurnId(), .requestId = requestId, .optionId = optionId});
+
+ return true;
+}
+
+void LlmChatBackend::cancelPendingPermissions()
+{
+ const QString turnId = m_ledger.activeTurnId();
+ const QStringList cancelled = m_ledger.endTurn();
+
+ for (const QString &requestId : cancelled) {
+ emit sessionEvent(
+ Session::PermissionResolved{
+ .turnId = turnId, .requestId = requestId, .cancelled = true});
+ }
}
QVector LlmChatBackend::renderHistory(
@@ -222,7 +343,10 @@ QVector LlmChatBackend::renderHistory(
int toolCallMsgIdx = -1;
for (const Session::MessageRow &row : Session::projectToRows(history)) {
- if (row.kind == Session::RowKind::Tool) {
+ const Session::RowTreatment treatment
+ = Session::rowTreatmentFor(Session::RowAudience::Prompt, row.kind);
+
+ if (treatment == Session::RowTreatment::ToolExchange) {
if (!toolHistory || row.toolName.isEmpty())
continue;
@@ -250,29 +374,29 @@ QVector LlmChatBackend::renderHistory(
toolCallMsgIdx = -1;
- if (row.kind == Session::RowKind::FileEdit)
+ if (treatment == Session::RowTreatment::Omit)
continue;
LLMCore::Message apiMessage;
- apiMessage.role = row.kind == Session::RowKind::User ? "user" : "assistant";
+ apiMessage.role = treatment == Session::RowTreatment::UserText ? "user" : "assistant";
apiMessage.content = row.content;
if (!row.attachments.isEmpty() && !m_chatFilePath.isEmpty()) {
apiMessage.content += "\n\nAttached files:";
for (const Session::AttachmentBlock &attachment : row.attachments) {
- const QString fileContent
- = ChatSerializer::loadContentFromStorage(m_chatFilePath, attachment.storedPath);
+ const QByteArray fileContent = ChatFileStore::loadRawContentFromStorage(
+ m_chatFilePath, attachment.storedPath);
if (fileContent.isEmpty())
continue;
- const QString decodedContent = QString::fromUtf8(
- QByteArray::fromBase64(fileContent.toUtf8()));
- apiMessage.content += QString("\n\nFile: %1\n```\n%2\n```")
- .arg(attachment.fileName, decodedContent);
+ apiMessage.content
+ += "\n\n"
+ + Session::fencedFileBlock(
+ attachment.fileName, QString::fromUtf8(fileContent));
}
}
- apiMessage.isThinking = row.kind == Session::RowKind::Thinking;
+ apiMessage.isThinking = treatment == Session::RowTreatment::AssistantThinking;
apiMessage.isRedacted = row.redacted;
apiMessage.signature = row.signature;
@@ -296,7 +420,7 @@ QVector LlmChatBackend::loadImagesFromStorage(
for (const Session::ImageBlock &storedImage : storedImages) {
const QString base64Data
- = ChatSerializer::loadContentFromStorage(m_chatFilePath, storedImage.storedPath);
+ = ChatFileStore::loadContentFromStorage(m_chatFilePath, storedImage.storedPath);
if (base64Data.isEmpty()) {
LOG_MESSAGE(QString("Warning: Failed to load image: %1").arg(storedImage.storedPath));
continue;
@@ -315,7 +439,7 @@ QVector LlmChatBackend::loadImagesFromStorage(
void LlmChatBackend::handleChunk(const QString &requestId, const QString &chunk)
{
- if (requestId != m_requestId)
+ if (!m_ledger.isActiveTurn(requestId))
return;
emit sessionEvent(Session::TextDelta{.turnId = requestId, .text = chunk});
@@ -323,7 +447,7 @@ void LlmChatBackend::handleChunk(const QString &requestId, const QString &chunk)
void LlmChatBackend::handleCompleted(const QString &requestId, const QString &fullText)
{
- if (requestId != m_requestId)
+ if (!m_ledger.isActiveTurn(requestId))
return;
LOG_MESSAGE(
@@ -337,7 +461,7 @@ void LlmChatBackend::handleCompleted(const QString &requestId, const QString &fu
void LlmChatBackend::handleFinalized(
const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info)
{
- if (requestId != m_requestId || !info.usage)
+ if (!m_ledger.isActiveTurn(requestId) || !info.usage)
return;
const auto &usage = *info.usage;
@@ -361,7 +485,7 @@ void LlmChatBackend::handleFinalized(
void LlmChatBackend::handleFailed(const QString &requestId, const QString &error)
{
- if (requestId != m_requestId)
+ if (!m_ledger.isActiveTurn(requestId))
return;
LOG_MESSAGE(QString("Chat request %1 failed: %2").arg(requestId, error));
@@ -374,7 +498,7 @@ void LlmChatBackend::handleFailed(const QString &requestId, const QString &error
void LlmChatBackend::handleThinkingBlock(
const QString &requestId, const QString &thinking, const QString &signature)
{
- if (requestId != m_requestId)
+ if (!m_ledger.isActiveTurn(requestId))
return;
emit sessionEvent(
@@ -391,14 +515,15 @@ void LlmChatBackend::handleToolStarted(
const QString &toolName,
const QJsonObject &arguments)
{
- if (requestId != m_requestId)
+ if (!m_ledger.isActiveTurn(requestId))
return;
emit sessionEvent(
- Session::ToolCallStarted{
+ Session::ToolCallUpdated{
.turnId = requestId,
.toolId = toolId,
.name = toolName,
+ .status = QStringLiteral("in_progress"),
.arguments = arguments,
.dropPrecedingText = m_dropPreToolText});
}
@@ -409,12 +534,17 @@ void LlmChatBackend::handleToolResult(
const QString &toolName,
const QString &toolOutput)
{
- if (requestId != m_requestId)
+ if (!m_ledger.isActiveTurn(requestId))
return;
+ const bool failed = toolOutput.startsWith(QLatin1String("Error: "));
emit sessionEvent(
- Session::ToolCallCompleted{
- .turnId = requestId, .toolId = toolId, .name = toolName, .result = toolOutput});
+ Session::ToolCallUpdated{
+ .turnId = requestId,
+ .toolId = toolId,
+ .name = toolName,
+ .status = failed ? QStringLiteral("failed") : QStringLiteral("completed"),
+ .result = toolOutput});
}
} // namespace QodeAssist::Chat
diff --git a/sources/ChatView/LlmChatBackend.hpp b/sources/ChatView/LlmChatBackend.hpp
index 104e3c2..1356970 100644
--- a/sources/ChatView/LlmChatBackend.hpp
+++ b/sources/ChatView/LlmChatBackend.hpp
@@ -4,13 +4,16 @@
#pragma once
-#include
+#include
+
+#include
#include
#include
#include "providers/Provider.hpp"
#include "session/ChatBackend.hpp"
+#include "session/TurnLedger.hpp"
#include "templates/IPromptProvider.hpp"
namespace QodeAssist::Chat {
@@ -20,19 +23,32 @@ class LlmChatBackend : public Session::ChatBackend
Q_OBJECT
public:
+ using ProviderResolver = std::function;
+
explicit LlmChatBackend(Templates::IPromptProvider *promptProvider, QObject *parent = nullptr);
~LlmChatBackend() override;
+ void setProviderResolver(ProviderResolver resolver);
+
void sendTurn(const Session::TurnRequest &request) override;
void cancel() override;
+ bool respondPermission(const QString &requestId, const QString &optionId) override;
+ Session::TurnContextNeeds contextNeeds() const override;
- void setChatFilePath(const QString &filePath);
- void clearToolSession(const QString &filePath);
+ void setChatFilePath(const QString &filePath) override;
+ void clearToolSession(const QString &filePath) override;
private:
void connectClient(Providers::Provider *provider);
void releaseRequest();
void bindToolSessions(Providers::Provider *provider);
+ void installExecutionGate(Providers::Provider *provider);
+ QFuture gateToolExecution(
+ const QString &requestId,
+ const QString &toolId,
+ const QString &toolName,
+ const QJsonObject &input);
+ void cancelPendingPermissions();
QVector renderHistory(
const Session::ConversationHistory &history,
Providers::Provider *provider,
@@ -59,10 +75,11 @@ private:
const QString &toolOutput);
Templates::IPromptProvider *m_promptProvider = nullptr;
+ ProviderResolver m_providerResolver;
QString m_chatFilePath;
Providers::Provider *m_provider = nullptr;
- QString m_requestId;
+ Session::TurnLedger m_ledger;
bool m_dropPreToolText = false;
};
diff --git a/sources/ChatView/TurnContextAdapters.cpp b/sources/ChatView/TurnContextAdapters.cpp
index a60ad01..df4bbd9 100644
--- a/sources/ChatView/TurnContextAdapters.cpp
+++ b/sources/ChatView/TurnContextAdapters.cpp
@@ -4,18 +4,31 @@
#include "TurnContextAdapters.hpp"
+#include
#include
#include
+#include
#include
#include "ProjectSettings.hpp"
#include "SkillsSettings.hpp"
-#include "context/ContextManager.hpp"
-#include "context/RulesLoader.hpp"
#include "skills/SkillsManager.hpp"
namespace QodeAssist::Chat {
+ProjectExplorer::Project *activeProject()
+{
+ auto currentEditor = Core::EditorManager::currentEditor();
+ if (currentEditor && currentEditor->document()) {
+ auto project = ProjectExplorer::ProjectManager::projectForFile(
+ currentEditor->document()->filePath());
+ if (project)
+ return project;
+ }
+
+ return ProjectExplorer::ProjectManager::startupProject();
+}
+
ProjectContextQtCreator::ProjectContextQtCreator(ProjectExplorer::Project *project)
: m_project(project)
{}
@@ -38,14 +51,6 @@ Session::ProjectInfo ProjectContextQtCreator::projectInfo() const
return info;
}
-QString ProjectContextQtCreator::projectRules() const
-{
- if (!m_project)
- return {};
-
- return Context::RulesLoader::loadRulesForProject(m_project, Context::RulesContext::Chat);
-}
-
SkillsContextQtCreator::SkillsContextQtCreator(
Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project)
: m_skillsManager(skillsManager)
@@ -81,19 +86,6 @@ std::optional SkillsContextQtCreator::findSkill(const QSt
return Session::InvokedSkill{skill->name, skill->body};
}
-LinkedFilesQtCreator::LinkedFilesQtCreator(Context::ContextManager *contextManager)
- : m_contextManager(contextManager)
-{}
-
-QList LinkedFilesQtCreator::readFiles(const QList &paths) const
-{
- QList files;
- for (const auto &file : m_contextManager->getContentFiles(paths))
- files.append(Session::LinkedFile{file.filename, file.content});
-
- return files;
-}
-
std::unique_ptr makeSkillsContext(
Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project)
{
diff --git a/sources/ChatView/TurnContextAdapters.hpp b/sources/ChatView/TurnContextAdapters.hpp
index fdc96b3..cc5df67 100644
--- a/sources/ChatView/TurnContextAdapters.hpp
+++ b/sources/ChatView/TurnContextAdapters.hpp
@@ -12,23 +12,20 @@ namespace ProjectExplorer {
class Project;
}
-namespace QodeAssist::Context {
-class ContextManager;
-}
-
namespace QodeAssist::Skills {
class SkillsManager;
}
namespace QodeAssist::Chat {
+ProjectExplorer::Project *activeProject();
+
class ProjectContextQtCreator : public Session::IProjectContextPort
{
public:
explicit ProjectContextQtCreator(ProjectExplorer::Project *project);
Session::ProjectInfo projectInfo() const override;
- QString projectRules() const override;
private:
ProjectExplorer::Project *m_project = nullptr;
@@ -47,17 +44,6 @@ private:
Skills::SkillsManager *m_skillsManager = nullptr;
};
-class LinkedFilesQtCreator : public Session::ILinkedFilesPort
-{
-public:
- explicit LinkedFilesQtCreator(Context::ContextManager *contextManager);
-
- QList readFiles(const QList &paths) const override;
-
-private:
- Context::ContextManager *m_contextManager = nullptr;
-};
-
std::unique_ptr makeSkillsContext(
Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project);
diff --git a/sources/ChatView/icons/link-file-dark.svg b/sources/ChatView/icons/link-file-dark.svg
deleted file mode 100644
index 5a1acda..0000000
--- a/sources/ChatView/icons/link-file-dark.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
diff --git a/sources/ChatView/icons/link-file-light.svg b/sources/ChatView/icons/link-file-light.svg
deleted file mode 100644
index 6c37bc0..0000000
--- a/sources/ChatView/icons/link-file-light.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
diff --git a/sources/ChatView/qml/RootItem.qml b/sources/ChatView/qml/RootItem.qml
index 58f615a..e920ca5 100644
--- a/sources/ChatView/qml/RootItem.qml
+++ b/sources/ChatView/qml/RootItem.qml
@@ -47,23 +47,16 @@ ChatRootView {
color: palette.window
}
- SplitDropZone {
+ DropZone {
anchors.fill: parent
z: 99
- onFilesDroppedToAttach: (urlStrings) => {
+ onFilesDropped: (urlStrings) => {
var localPaths = root.convertUrlsToLocalPaths(urlStrings)
if (localPaths.length > 0) {
root.addFilesToAttachList(localPaths)
}
}
-
- onFilesDroppedToLink: (urlStrings) => {
- var localPaths = root.convertUrlsToLocalPaths(urlStrings)
- if (localPaths.length > 0) {
- root.addFilesToLinkList(localPaths)
- }
- }
}
QoABusyOverlay {
@@ -76,7 +69,7 @@ ChatRootView {
anchors.bottomMargin: bottomBar.height
active: root.isCompressing
- text: qsTr("Compressing chat…")
+ text: root.isAgentBound ? qsTr("Preparing the handover summary…") : qsTr("Compressing chat…")
}
ColumnLayout {
@@ -138,19 +131,6 @@ ChatRootView {
relocateTooltip.text: (typeof _chatview !== 'undefined')
? qsTr("Move this chat to an editor tab")
: qsTr("Move this chat to a separate window")
- toolsButton {
- checked: root.useTools
- onCheckedChanged: {
- root.useTools = toolsButton.checked
- }
- }
- thinkingMode {
- checked: root.useThinking
- enabled: root.isThinkingSupport
- onCheckedChanged: {
- root.useThinking = thinkingMode.checked
- }
- }
settingsButton.onClicked: root.openSettings()
configSelector {
model: root.availableConfigurations
@@ -165,18 +145,6 @@ ChatRootView {
root.loadAvailableConfigurations()
}
}
-
- roleSelector {
- model: root.availableAgentRoles
- displayText: root.currentAgentRole
- onActivated: function(index) {
- root.applyAgentRole(root.availableAgentRoles[index])
- }
-
- popup.onAboutToShow: {
- root.loadAvailableAgentRoles()
- }
- }
}
RowLayout {
@@ -248,6 +216,10 @@ ChatRootView {
return fileEditMessageComponent
} else if (model.roleType === ChatModel.Thinking) {
return thinkingMessageComponent
+ } else if (model.roleType === ChatModel.Permission) {
+ return permissionMessageComponent
+ } else if (model.roleType === ChatModel.Plan) {
+ return planMessageComponent
} else {
return chatItemComponent
}
@@ -357,7 +329,20 @@ ChatRootView {
ToolBlock {
width: parent.width
- toolContent: model.content
+ toolName: model.toolName || ""
+ toolResult: model.toolResult || ""
+ toolKind: model.toolKind || ""
+ toolStatus: model.toolStatus || ""
+ toolDetails: model.toolDetails || ({})
+ }
+ }
+
+ Component {
+ id: planMessageComponent
+
+ PlanBlock {
+ width: parent.width
+ planContent: model.content
}
}
@@ -386,6 +371,19 @@ ChatRootView {
}
}
+ Component {
+ id: permissionMessageComponent
+
+ PermissionBlock {
+ width: parent.width
+ permissionContent: model.content
+
+ onRespond: function(requestId, optionId) {
+ root.respondToPermission(requestId, optionId)
+ }
+ }
+ }
+
Component {
id: thinkingMessageComponent
@@ -405,12 +403,76 @@ ChatRootView {
}
}
+ Rectangle {
+ id: agentSessionBanner
+
+ Layout.fillWidth: true
+ Layout.margins: 5
+ visible: root.agentSessionIssue.length > 0
+ implicitHeight: bannerLayout.implicitHeight + 16
+ radius: 4
+ color: palette.base
+ border.width: 1
+ border.color: palette.mid
+
+ ColumnLayout {
+ id: bannerLayout
+
+ anchors {
+ left: parent.left
+ right: parent.right
+ top: parent.top
+ margins: 8
+ }
+ spacing: 6
+
+ Text {
+ Layout.fillWidth: true
+ text: root.agentSessionIssue
+ textFormat: Text.PlainText
+ color: palette.text
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ visible: root.canStartNewAgentSession
+ spacing: 8
+
+ QoAButton {
+ text: qsTr("Continue with a new session")
+ onClicked: root.startNewAgentSession()
+ }
+
+ QoAButton {
+ id: handoverButton
+
+ text: qsTr("Continue with a summary")
+ enabled: root.canHandOverSummary
+
+ onClicked: root.startNewAgentSessionWithSummary()
+
+ QoAToolTip {
+ visible: handoverButton.hovered
+ && root.summaryHandoverTooltip.length > 0
+ text: root.summaryHandoverTooltip
+ delay: 300
+ }
+ }
+
+ Item { Layout.fillWidth: true }
+ }
+ }
+ }
+
ScrollView {
id: view
Layout.fillWidth: true
Layout.minimumHeight: 30
Layout.maximumHeight: root.height / 2
+ enabled: root.agentSessionIssue.length === 0
QQC.TextArea {
id: messageInput
@@ -452,20 +514,7 @@ ChatRootView {
}
}
fileMentionPopup.dismiss()
-
- const slashIndex = textBefore.lastIndexOf('/')
- if (slashIndex >= 0) {
- const beforeSlash = slashIndex === 0
- ? ' '
- : textBefore.charAt(slashIndex - 1)
- const skillQuery = textBefore.substring(slashIndex + 1)
- if ((beforeSlash === ' ' || beforeSlash === '\n')
- && /^[a-z0-9-]*$/.test(skillQuery)) {
- skillCommandPopup.updateSearch(skillQuery)
- return
- }
- }
- skillCommandPopup.dismiss()
+ root.refreshSlashPopup()
}
Keys.onPressed: function(event) {
@@ -561,17 +610,6 @@ ChatRootView {
onRemoveFileFromListByIndex: (index) => root.removeFileFromAttachList(index)
}
- AttachedFilesPlace {
- id: linkedFilesPlace
-
- Layout.fillWidth: true
- attachedFilesModel: root.linkedFiles
- iconPath: palette.window.hslLightness > 0.5 ? "qrc:/qt/qml/ChatView/icons/link-file-dark.svg"
- : "qrc:/qt/qml/ChatView/icons/link-file-light.svg"
- accentColor: Qt.tint(palette.mid, Qt.rgba(0, 0.3, 0.8, 0.4))
- onRemoveFileFromListByIndex: (index) => root.removeFileFromLinkList(index)
- }
-
FileEditsActionBar {
id: fileEditsActionBar
@@ -593,6 +631,7 @@ ChatRootView {
isCompressing: root.isCompressing
isProcessing: root.isRequestInProgress
+ agentMode: root.isAgentBound
sendButton.onClicked: !root.isRequestInProgress ? root.sendChatMessage()
: root.cancelRequest()
sendButton.icon.source: root.isRequestInProgress
@@ -608,14 +647,12 @@ ChatRootView {
? root.lastErrorMessage
: qsTr("Send message to LLM %1").arg(root.sendShortcutText))
compressButton.onClicked: compressConfirmDialog.open()
+ compressButton.enabled: root.canShrinkContext
+ compressButton.text: root.isAgentBound ? qsTr("Hand over") : qsTr("Compress")
+ compressTooltip.text: root.shrinkContextTooltip
cancelCompressButton.onClicked: root.cancelCompression()
- syncOpenFiles {
- checked: root.isSyncOpenFiles
- onCheckedChanged: root.setIsSyncOpenFiles(bottomBar.syncOpenFiles.checked)
- }
attachFiles.onClicked: root.showAttachFilesDialog()
attachImages.onClicked: root.showAddImageDialog()
- linkFiles.onClicked: root.showLinkFilesDialog()
}
}
@@ -656,13 +693,36 @@ ChatRootView {
function applyMentionSelection() {
var result = fileMentionPopup.applyCurrentSelection(
- messageInput.text, messageInput.cursorPosition, root.useTools)
+ messageInput.text, messageInput.cursorPosition)
if (result.text !== undefined) {
messageInput.text = result.text
messageInput.cursorPosition = result.cursorPosition
}
}
+ onSlashCommandsChanged: {
+ if (skillCommandPopup.visible)
+ refreshSlashPopup()
+ }
+
+ function refreshSlashPopup() {
+ const cursorPos = messageInput.cursorPosition
+ const textBefore = messageInput.text.substring(0, cursorPos)
+ const slashIndex = textBefore.lastIndexOf('/')
+ if (slashIndex >= 0) {
+ const beforeSlash = slashIndex === 0
+ ? ' '
+ : textBefore.charAt(slashIndex - 1)
+ const skillQuery = textBefore.substring(slashIndex + 1)
+ if ((beforeSlash === ' ' || beforeSlash === '\n')
+ && /^\S*$/.test(skillQuery)) {
+ skillCommandPopup.updateSearch(skillQuery)
+ return
+ }
+ }
+ skillCommandPopup.dismiss()
+ }
+
function applySkillSelection() {
const name = skillCommandPopup.currentName()
if (name === "")
@@ -688,16 +748,42 @@ ChatRootView {
scrollToBottom()
}
+ onChatTargetSwitchNeedsNewChat: function(targetName) {
+ chatTargetSwitchDialog.targetName = targetName
+ chatTargetSwitchDialog.open()
+ }
+
Dialog {
- id: compressConfirmDialog
+ id: chatTargetSwitchDialog
+
+ property string targetName: ""
anchors.centerIn: parent
- title: qsTr("Compress Chat")
+ title: qsTr("Start a New Conversation")
modal: true
standardButtons: Dialog.Yes | Dialog.No
Label {
- text: qsTr("Create a summarized copy of this chat?\n\nThe summary will be generated by LLM and saved as a new chat file.")
+ text: qsTr("A conversation stays with the kind it started with, so switching to %1 needs a new one.\n\nClear this chat and switch?").arg(chatTargetSwitchDialog.targetName)
+ wrapMode: Text.WordWrap
+ }
+
+ onAccepted: root.confirmChatTargetSwitch()
+ onRejected: root.cancelChatTargetSwitch()
+ }
+
+ Dialog {
+ id: compressConfirmDialog
+
+ anchors.centerIn: parent
+ title: root.isAgentBound ? qsTr("Hand Over Session") : qsTr("Compress Chat")
+ modal: true
+ standardButtons: Dialog.Yes | Dialog.No
+
+ Label {
+ text: root.isAgentBound
+ ? qsTr("Summarise this conversation and continue in a fresh agent session?\n\nThe summary will be generated by the LLM chat configuration and given to the new session as context.")
+ : qsTr("Create a summarized copy of this chat?\n\nThe summary will be generated by LLM and saved as a new chat file.")
wrapMode: Text.WordWrap
}
@@ -840,19 +926,8 @@ ChatRootView {
y: (parent.height - height) / 2
baseSystemPrompt: root.baseSystemPrompt
- currentAgentRole: root.currentAgentRole
- currentAgentRoleDescription: root.currentAgentRoleDescription
- currentAgentRoleSystemPrompt: root.currentAgentRoleSystemPrompt
- activeRules: root.activeRules
- activeRulesCount: root.activeRulesCount
onOpenSettings: root.openSettings()
- onOpenAgentRolesSettings: root.openAgentRolesSettings()
- onOpenRulesFolder: root.openRulesFolder()
- onRefreshRules: root.refreshRules()
- onRuleSelected: function(index) {
- contextViewer.selectedRuleContent = root.getRuleContent(index)
- }
}
Connections {
diff --git a/sources/ChatView/qml/chatparts/BlockPayload.js b/sources/ChatView/qml/chatparts/BlockPayload.js
new file mode 100644
index 0000000..09f6792
--- /dev/null
+++ b/sources/ChatView/qml/chatparts/BlockPayload.js
@@ -0,0 +1,29 @@
+// Copyright (C) 2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+.pragma library
+
+function parseMarkerPayload(marker, content) {
+ if (!content || !content.startsWith(marker))
+ return null;
+
+ try {
+ const parsed = JSON.parse(content.substring(marker.length));
+ return (parsed && typeof parsed === "object") ? parsed : null;
+ } catch (e) {
+ return null;
+ }
+}
+
+function parseFileEdit(content) {
+ return parseMarkerPayload("QODEASSIST_FILE_EDIT:", content);
+}
+
+function parsePermission(content) {
+ return parseMarkerPayload("QODEASSIST_PERMISSION:", content);
+}
+
+function parsePlan(content) {
+ return parseMarkerPayload("QODEASSIST_PLAN:", content);
+}
diff --git a/sources/ChatView/qml/chatparts/FileEditBlock.qml b/sources/ChatView/qml/chatparts/FileEditBlock.qml
index c913b64..de7edb4 100644
--- a/sources/ChatView/qml/chatparts/FileEditBlock.qml
+++ b/sources/ChatView/qml/chatparts/FileEditBlock.qml
@@ -8,6 +8,7 @@ import QtQuick.Layouts
import UIControls
import ChatView
import Qt.labs.platform as Platform
+import "BlockPayload.js" as BlockPayload
Rectangle {
id: root
@@ -39,13 +40,11 @@ Rectangle {
readonly property bool isArchived: editStatus === "archived"
readonly property color appliedColor: Qt.rgba(0.2, 0.8, 0.2, 0.8)
- readonly property color revertedColor: Qt.rgba(0.8, 0.6, 0.2, 0.8)
readonly property color rejectedColor: Qt.rgba(0.8, 0.2, 0.2, 0.8)
readonly property color archivedColor: Qt.rgba(0.5, 0.5, 0.5, 0.8)
readonly property color pendingColor: palette.highlight
readonly property color appliedBgColor: Qt.rgba(0.2, 0.8, 0.2, 0.3)
- readonly property color revertedBgColor: Qt.rgba(0.8, 0.6, 0.2, 0.3)
readonly property color rejectedBgColor: Qt.rgba(0.8, 0.2, 0.2, 0.3)
readonly property color archivedBgColor: Qt.rgba(0.5, 0.5, 0.5, 0.3)
@@ -84,23 +83,15 @@ Rectangle {
readonly property int removedLines: countLines(oldContent)
function parseEditData(content) {
- try {
- const marker = "QODEASSIST_FILE_EDIT:";
- let jsonStr = content;
- if (content.indexOf(marker) >= 0) {
- jsonStr = content.substring(content.indexOf(marker) + marker.length);
- }
- return JSON.parse(jsonStr);
- } catch (e) {
- return {
- edit_id: "",
- file: "",
- old_content: "",
- new_content: "",
- status: "error",
- status_message: ""
- };
- }
+ const parsed = BlockPayload.parseFileEdit(content);
+ return parsed !== null ? parsed : {
+ edit_id: "",
+ file: "",
+ old_content: "",
+ new_content: "",
+ status: "error",
+ status_message: ""
+ };
}
function getFileName(path) {
diff --git a/sources/ChatView/qml/chatparts/PermissionBlock.qml b/sources/ChatView/qml/chatparts/PermissionBlock.qml
new file mode 100644
index 0000000..d15113e
--- /dev/null
+++ b/sources/ChatView/qml/chatparts/PermissionBlock.qml
@@ -0,0 +1,208 @@
+// Copyright (C) 2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+import QtQuick
+import QtQuick.Layouts
+import UIControls
+import "BlockPayload.js" as BlockPayload
+
+Rectangle {
+ id: root
+
+ property string permissionContent: ""
+
+ readonly property var permissionData: parsePermissionData(permissionContent)
+ readonly property bool isMalformed: permissionData === null
+ readonly property string requestId: isMalformed ? "" : (permissionData.requestId || "")
+ readonly property string title: isMalformed
+ ? qsTr("Unreadable permission request")
+ : (permissionData.title || qsTr("The agent asks for permission"))
+ readonly property string toolKind: isMalformed ? "" : (permissionData.toolKind || "")
+ readonly property var options: isMalformed ? [] : (permissionData.options || [])
+ readonly property string status: isMalformed ? "error" : (permissionData.status || "pending")
+ readonly property string selectedOptionId: isMalformed ? "" : (permissionData.selectedOptionId || "")
+ readonly property bool automatic: !isMalformed && permissionData.automatic === true
+
+ readonly property bool isPending: status === "pending" && options.length > 0
+ readonly property bool isCancelled: status === "cancelled"
+ readonly property bool wasDeclinedUnpresentable: isCancelled && options.length === 0
+
+ readonly property var selectedOption: findOption(selectedOptionId)
+ readonly property bool wasAllowed: selectedOption !== null
+ && selectedOption.allows === true
+
+ readonly property int borderRadius: 6
+ readonly property int contentMargin: 10
+ readonly property int badgeRadius: 3
+ readonly property int badgePaddingH: 12
+ readonly property int badgePaddingV: 6
+ readonly property int titleMaxLines: 4
+ readonly property int rowSpacing: 8
+
+ readonly property color allowedColor: Qt.rgba(0.2, 0.8, 0.2, 0.8)
+ readonly property color deniedColor: Qt.rgba(0.8, 0.2, 0.2, 0.8)
+ readonly property color cancelledColor: Qt.rgba(0.5, 0.5, 0.5, 0.8)
+
+ readonly property color statusColor: {
+ if (isMalformed)
+ return deniedColor;
+ if (isPending)
+ return palette.highlight;
+ if (isCancelled)
+ return cancelledColor;
+ return wasAllowed ? allowedColor : deniedColor;
+ }
+
+ readonly property string statusText: {
+ if (isMalformed)
+ return qsTr("UNREADABLE");
+ if (isPending)
+ return qsTr("WAITING FOR YOU");
+ if (wasDeclinedUnpresentable)
+ return qsTr("DECLINED");
+ if (isCancelled)
+ return qsTr("NO LONGER AVAILABLE");
+ if (automatic)
+ return wasAllowed ? qsTr("ALLOWED AUTOMATICALLY") : qsTr("DENIED AUTOMATICALLY");
+ return wasAllowed ? qsTr("ALLOWED") : qsTr("DENIED");
+ }
+
+ readonly property string explanation: {
+ if (isMalformed)
+ return qsTr("This permission record could not be read, so it cannot be answered.");
+ if (wasDeclinedUnpresentable)
+ return qsTr("The agent did not offer a set of options this chat could present safely, so the request was declined.");
+ if (isCancelled)
+ return qsTr("This request ended before it was answered.");
+ if (isPending)
+ return "";
+ if (selectedOption === null)
+ return "";
+ return automatic
+ ? qsTr("Answered automatically with \"%1\", because you chose that for this action type for the rest of the conversation.").arg(selectedOption.name)
+ : qsTr("You answered \"%1\".").arg(selectedOption.name);
+ }
+
+ signal respond(string requestId, string optionId)
+
+ implicitHeight: layout.implicitHeight + 2 * contentMargin
+ radius: borderRadius
+ color: palette.base
+ border.width: 1
+ border.color: statusColor
+
+ function parsePermissionData(content) {
+ return BlockPayload.parsePermission(content);
+ }
+
+ function findOption(optionId) {
+ if (!optionId)
+ return null;
+ for (let i = 0; i < root.options.length; ++i) {
+ if (root.options[i].id === optionId)
+ return root.options[i];
+ }
+ return null;
+ }
+
+ ColumnLayout {
+ id: layout
+
+ anchors {
+ left: parent.left
+ right: parent.right
+ top: parent.top
+ margins: root.contentMargin
+ }
+ spacing: root.rowSpacing
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: root.rowSpacing
+
+ Rectangle {
+ Layout.preferredWidth: statusLabel.implicitWidth + root.badgePaddingH
+ Layout.preferredHeight: statusLabel.implicitHeight + root.badgePaddingV
+ Layout.alignment: Qt.AlignTop
+ radius: root.badgeRadius
+ color: root.statusColor
+
+ Text {
+ id: statusLabel
+
+ anchors.centerIn: parent
+ text: root.statusText
+ textFormat: Text.PlainText
+ font.pixelSize: 10
+ font.bold: true
+ color: palette.base
+ }
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: root.title
+ textFormat: Text.PlainText
+ font.pixelSize: 13
+ font.bold: true
+ color: palette.text
+ wrapMode: Text.WordWrap
+ maximumLineCount: root.titleMaxLines
+ elide: Text.ElideRight
+ }
+ }
+
+ Text {
+ Layout.fillWidth: true
+ visible: root.toolKind.length > 0
+ text: qsTr("Action type: %1").arg(root.toolKind)
+ textFormat: Text.PlainText
+ font.pixelSize: 11
+ color: palette.placeholderText
+ elide: Text.ElideRight
+ }
+
+ Text {
+ Layout.fillWidth: true
+ visible: text.length > 0
+ text: root.explanation
+ textFormat: Text.PlainText
+ font.pixelSize: 11
+ color: palette.placeholderText
+ wrapMode: Text.WordWrap
+ }
+
+ Flow {
+ Layout.fillWidth: true
+ visible: root.isPending
+ spacing: root.rowSpacing
+
+ Repeater {
+ model: root.options
+
+ delegate: QoAButton {
+ id: optionButton
+
+ required property var modelData
+
+ text: optionButton.modelData.name || optionButton.modelData.id
+ accentColor: optionButton.modelData.allows === true
+ ? root.allowedColor
+ : root.deniedColor
+
+ contentItem: Text {
+ text: optionButton.text
+ textFormat: Text.PlainText
+ color: palette.buttonText
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ }
+
+ onClicked: root.respond(root.requestId, optionButton.modelData.id)
+ }
+ }
+ }
+ }
+}
diff --git a/sources/ChatView/qml/chatparts/PlanBlock.qml b/sources/ChatView/qml/chatparts/PlanBlock.qml
new file mode 100644
index 0000000..2c80ca1
--- /dev/null
+++ b/sources/ChatView/qml/chatparts/PlanBlock.qml
@@ -0,0 +1,151 @@
+// Copyright (C) 2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+import QtQuick
+import QtQuick.Layouts
+import "BlockPayload.js" as BlockPayload
+
+Rectangle {
+ id: root
+
+ property string planContent: ""
+ property bool expanded: true
+
+ readonly property var planData: parsePlanData(planContent)
+ readonly property var entries: planData === null ? [] : (planData.entries || [])
+ readonly property int completedCount: countCompleted()
+ readonly property bool isComplete: entries.length > 0 && completedCount === entries.length
+
+ readonly property int borderRadius: 6
+ readonly property int contentMargin: 10
+ readonly property int rowSpacing: 6
+
+ readonly property color completedColor: Qt.rgba(0.2, 0.8, 0.2, 0.9)
+ readonly property color activeColor: palette.highlight
+ readonly property color pendingColor: palette.placeholderText
+
+ implicitHeight: layout.implicitHeight + 2 * contentMargin
+ radius: borderRadius
+ color: palette.base
+ border.width: 1
+ border.color: isComplete ? completedColor : palette.mid
+
+ function parsePlanData(content) {
+ return BlockPayload.parsePlan(content);
+ }
+
+ function countCompleted() {
+ let done = 0;
+ for (let i = 0; i < root.entries.length; ++i) {
+ if (root.entries[i].status === "completed")
+ ++done;
+ }
+ return done;
+ }
+
+ function entryColor(status) {
+ if (status === "completed")
+ return root.completedColor;
+ if (status === "in_progress")
+ return root.activeColor;
+ return root.pendingColor;
+ }
+
+ function entryMarker(status) {
+ if (status === "completed")
+ return "✓";
+ if (status === "in_progress")
+ return "▶";
+ return "○";
+ }
+
+ ColumnLayout {
+ id: layout
+
+ anchors {
+ left: parent.left
+ right: parent.right
+ top: parent.top
+ margins: root.contentMargin
+ }
+ spacing: root.rowSpacing
+
+ MouseArea {
+ Layout.fillWidth: true
+ Layout.preferredHeight: header.implicitHeight
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.expanded = !root.expanded
+
+ RowLayout {
+ id: header
+
+ anchors.fill: parent
+ spacing: root.rowSpacing
+
+ Text {
+ text: qsTr("Plan")
+ textFormat: Text.PlainText
+ font.pixelSize: 13
+ font.bold: true
+ color: palette.text
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: qsTr("%1 of %2 done").arg(root.completedCount).arg(root.entries.length)
+ textFormat: Text.PlainText
+ font.pixelSize: 11
+ color: palette.placeholderText
+ }
+
+ Text {
+ text: root.expanded ? "▼" : "▶"
+ font.pixelSize: 10
+ color: palette.mid
+ }
+ }
+ }
+
+ Repeater {
+ model: root.expanded ? root.entries : []
+
+ delegate: RowLayout {
+ id: entryRow
+
+ required property var modelData
+
+ Layout.fillWidth: true
+ spacing: root.rowSpacing
+
+ Text {
+ Layout.alignment: Qt.AlignTop
+ text: root.entryMarker(entryRow.modelData.status)
+ textFormat: Text.PlainText
+ font.pixelSize: 12
+ color: root.entryColor(entryRow.modelData.status)
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: entryRow.modelData.content || ""
+ textFormat: Text.PlainText
+ font.pixelSize: 12
+ font.strikeout: entryRow.modelData.status === "completed"
+ color: entryRow.modelData.status === "completed" ? palette.placeholderText
+ : palette.text
+ wrapMode: Text.WordWrap
+ }
+
+ Text {
+ Layout.alignment: Qt.AlignTop
+ visible: entryRow.modelData.priority === "high"
+ text: qsTr("high")
+ textFormat: Text.PlainText
+ font.pixelSize: 10
+ color: palette.highlight
+ }
+ }
+ }
+ }
+}
diff --git a/sources/ChatView/qml/chatparts/ToolBlock.qml b/sources/ChatView/qml/chatparts/ToolBlock.qml
index a4df730..e975302 100644
--- a/sources/ChatView/qml/chatparts/ToolBlock.qml
+++ b/sources/ChatView/qml/chatparts/ToolBlock.qml
@@ -8,14 +8,52 @@ import Qt.labs.platform as Platform
Rectangle {
id: root
- property string toolContent: ""
+ property string toolName: ""
+ property string toolResult: ""
+ property string toolKind: ""
+ property string toolStatus: ""
+ property var toolDetails: ({})
property bool expanded: false
property alias headerOpacity: headerRow.opacity
- readonly property int firstNewline: toolContent.indexOf('\n')
- readonly property string toolName: firstNewline > 0 ? toolContent.substring(0, firstNewline) : toolContent
- readonly property string toolResult: firstNewline > 0 ? toolContent.substring(firstNewline + 1) : ""
+ readonly property int legacyNewline: toolName === "" ? toolResult.indexOf('\n') : -1
+ readonly property string headerName: {
+ if (toolName !== "")
+ return toolName;
+ return legacyNewline > 0 ? toolResult.substring(0, legacyNewline) : toolResult;
+ }
+ readonly property string resultText: {
+ if (toolName !== "")
+ return toolResult;
+ return legacyNewline > 0 ? toolResult.substring(legacyNewline + 1) : "";
+ }
+
+ readonly property var locations: (toolDetails && toolDetails.locations) ? toolDetails.locations : []
+ readonly property var diffs: (toolDetails && toolDetails.diffs) ? toolDetails.diffs : []
+
+ readonly property bool isRunning: toolStatus === "pending" || toolStatus === "in_progress"
+ readonly property bool hasFailed: toolStatus === "failed"
+
+ readonly property color statusColor: {
+ if (hasFailed)
+ return Qt.rgba(0.8, 0.2, 0.2, 0.9);
+ if (isRunning)
+ return palette.highlight;
+ if (toolStatus === "completed")
+ return Qt.rgba(0.2, 0.8, 0.2, 0.9);
+ return palette.mid;
+ }
+
+ readonly property string statusMarker: {
+ if (hasFailed)
+ return "✗";
+ if (toolStatus === "completed")
+ return "✓";
+ if (isRunning)
+ return "…";
+ return "";
+ }
radius: 6
color: palette.base
@@ -45,10 +83,26 @@ Rectangle {
spacing: 8
Text {
- text: qsTr("Tool: %1").arg(root.toolName)
+ anchors.verticalCenter: parent.verticalCenter
+ text: root.statusMarker
+ textFormat: Text.PlainText
+ visible: text.length > 0
+ font.pixelSize: 12
+ color: root.statusColor
+ }
+
+ Text {
+ id: headerTitle
+
+ width: headerRow.width - x - 30
+ text: root.toolKind.length > 0
+ ? root.toolKind + ": " + root.headerName
+ : qsTr("Tool: %1").arg(root.headerName)
+ textFormat: Text.PlainText
font.pixelSize: 13
font.bold: true
- color: palette.text
+ color: root.hasFailed ? root.statusColor : palette.text
+ elide: Text.ElideRight
}
Text {
@@ -60,8 +114,8 @@ Rectangle {
}
}
- Column {
- id: contentColumn
+ Loader {
+ id: contentLoader
anchors {
left: parent.left
@@ -69,20 +123,88 @@ Rectangle {
top: header.bottom
margins: 10
}
- spacing: 8
+ active: root.expanded
+ sourceComponent: contentComponent
+ }
- TextEdit {
- id: resultText
+ Component {
+ id: contentComponent
- width: parent.width
- text: root.toolResult
- readOnly: true
- selectByMouse: true
- color: palette.text
- wrapMode: Text.WordWrap
- font.family: "monospace"
- font.pixelSize: 11
- selectionColor: palette.highlight
+ Column {
+ property alias resultEditor: resultEditorItem
+
+ spacing: 8
+
+ Repeater {
+ model: root.locations
+
+ delegate: Text {
+ id: locationEntry
+
+ required property var modelData
+
+ width: contentLoader.width
+ text: locationEntry.modelData.line !== undefined
+ ? "→ " + locationEntry.modelData.path + ":" + locationEntry.modelData.line
+ : "→ " + locationEntry.modelData.path
+ textFormat: Text.PlainText
+ color: palette.placeholderText
+ font.pixelSize: 11
+ elide: Text.ElideMiddle
+ }
+ }
+
+ Repeater {
+ model: root.diffs
+
+ delegate: Column {
+ id: diffEntry
+
+ required property var modelData
+
+ width: contentLoader.width
+ spacing: 2
+
+ Text {
+ width: parent.width
+ text: qsTr("Diff") + ": " + (diffEntry.modelData.path || "")
+ textFormat: Text.PlainText
+ color: palette.text
+ font.pixelSize: 11
+ font.bold: true
+ elide: Text.ElideMiddle
+ }
+
+ TextEdit {
+ width: parent.width
+ text: (diffEntry.modelData.oldText || "") + "\n" + (diffEntry.modelData.newText || "")
+ textFormat: TextEdit.PlainText
+ readOnly: true
+ selectByMouse: true
+ color: palette.text
+ wrapMode: Text.WordWrap
+ font.family: "monospace"
+ font.pixelSize: 11
+ selectionColor: palette.highlight
+ }
+ }
+ }
+
+ TextEdit {
+ id: resultEditorItem
+
+ width: parent.width
+ visible: text.length > 0
+ text: root.resultText
+ textFormat: TextEdit.PlainText
+ readOnly: true
+ selectByMouse: true
+ color: palette.text
+ wrapMode: Text.WordWrap
+ font.family: "monospace"
+ font.pixelSize: 11
+ selectionColor: palette.highlight
+ }
}
}
@@ -98,14 +220,14 @@ Rectangle {
Platform.MenuItem {
text: qsTr("Copy")
- enabled: resultText.selectedText.length > 0
- onTriggered: resultText.copy()
+ enabled: contentLoader.item && contentLoader.item.resultEditor.selectedText.length > 0
+ onTriggered: contentLoader.item.resultEditor.copy()
}
Platform.MenuItem {
text: qsTr("Select All")
- enabled: resultText.text.length > 0
- onTriggered: resultText.selectAll()
+ enabled: contentLoader.item && contentLoader.item.resultEditor.text.length > 0
+ onTriggered: contentLoader.item.resultEditor.selectAll()
}
Platform.MenuSeparator {}
@@ -140,7 +262,7 @@ Rectangle {
when: root.expanded
PropertyChanges {
target: root
- implicitHeight: header.height + contentColumn.height + 20
+ implicitHeight: header.height + contentLoader.height + 20
}
}
]
diff --git a/sources/ChatView/qml/controls/BottomBar.qml b/sources/ChatView/qml/controls/BottomBar.qml
index e831054..8e8a048 100644
--- a/sources/ChatView/qml/controls/BottomBar.qml
+++ b/sources/ChatView/qml/controls/BottomBar.qml
@@ -12,15 +12,15 @@ Rectangle {
id: root
property alias sendButton: sendButtonId
- property alias syncOpenFiles: syncOpenFilesId
property alias attachFiles: attachFilesId
property alias attachImages: attachImagesId
- property alias linkFiles: linkFilesId
property alias compressButton: compressButtonId
+ property alias compressTooltip: compressTooltipId
property alias cancelCompressButton: cancelCompressButtonId
property bool isCompressing: false
property bool isProcessing: false
+ property bool agentMode: false
property alias sendButtonTooltip: sendButtonTooltipId
color: palette.window.hslLightness > 0.5 ?
@@ -72,33 +72,6 @@ Rectangle {
}
}
- QoAButton {
- id: linkFilesId
-
- icon {
- source: "qrc:/qt/qml/ChatView/icons/link-file-dark.svg"
- height: 15
- width: 8
- }
-
- QoAToolTip {
- visible: linkFilesId.hovered
- delay: 250
- text: qsTr("Link file to context")
- }
- }
-
- CheckBox {
- id: syncOpenFilesId
-
- text: qsTr("Sync open files")
-
- QoAToolTip {
- visible: syncOpenFilesId.hovered
- text: qsTr("Automatically synchronize currently opened files with the model context")
- }
- }
-
Item {
Layout.fillWidth: true
}
@@ -119,7 +92,7 @@ Rectangle {
}
Text {
- text: qsTr("Compressing...")
+ text: root.agentMode ? qsTr("Preparing summary...") : qsTr("Compressing...")
anchors.verticalCenter: parent.verticalCenter
color: palette.text
font.pixelSize: 12
@@ -134,7 +107,7 @@ Rectangle {
QoAToolTip {
visible: cancelCompressButtonId.hovered
delay: 250
- text: qsTr("Cancel compression")
+ text: root.agentMode ? qsTr("Cancel the summary") : qsTr("Cancel compression")
}
}
}
@@ -143,6 +116,7 @@ Rectangle {
id: compressButtonId
visible: !root.isCompressing
+ opacity: enabled ? 1.0 : 0.4
text: qsTr("Compress")
icon {
@@ -152,9 +126,10 @@ Rectangle {
}
QoAToolTip {
+ id: compressTooltipId
+
visible: compressButtonId.hovered
delay: 250
- text: qsTr("Compress chat (create summarized copy using LLM)")
}
}
diff --git a/sources/ChatView/qml/controls/ContextViewer.qml b/sources/ChatView/qml/controls/ContextViewer.qml
index a57723b..a2245d0 100644
--- a/sources/ChatView/qml/controls/ContextViewer.qml
+++ b/sources/ChatView/qml/controls/ContextViewer.qml
@@ -14,18 +14,8 @@ Popup {
id: root
property string baseSystemPrompt
- property string currentAgentRole
- property string currentAgentRoleDescription
- property string currentAgentRoleSystemPrompt
- property var activeRules
- property int activeRulesCount
- property string selectedRuleContent
signal openSettings()
- signal openAgentRolesSettings()
- signal openRulesFolder()
- signal refreshRules()
- signal ruleSelected(int index)
modal: true
focus: true
@@ -59,11 +49,6 @@ Popup {
Layout.fillWidth: true
}
- QoAButton {
- text: qsTr("Refresh")
- onClicked: root.refreshRules()
- }
-
QoAButton {
text: qsTr("Close")
onClicked: root.close()
@@ -159,281 +144,6 @@ Popup {
}
}
}
-
- CollapsibleSection {
- id: agentRoleSection
-
- Layout.fillWidth: true
- title: qsTr("Agent Role")
- badge: root.currentAgentRole
- badgeColor: root.currentAgentRoleSystemPrompt.length > 0 ? Qt.rgba(0.3, 0.4, 0.7, 1.0) : palette.mid
-
- sectionContent: ColumnLayout {
- spacing: 8
-
- Text {
- text: root.currentAgentRoleDescription
- font.pixelSize: 11
- font.italic: true
- color: palette.mid
- wrapMode: Text.WordWrap
- Layout.fillWidth: true
- visible: root.currentAgentRoleDescription.length > 0
- }
-
- Rectangle {
- Layout.fillWidth: true
- Layout.preferredHeight: Math.min(Math.max(agentPromptText.implicitHeight + 16, 50), 200)
- color: palette.base
- border.color: palette.mid
- border.width: 1
- radius: 2
- visible: root.currentAgentRoleSystemPrompt.length > 0
-
- Flickable {
- id: agentPromptFlickable
-
- anchors.fill: parent
- anchors.margins: 8
- contentHeight: agentPromptText.implicitHeight
- clip: true
- boundsBehavior: Flickable.StopAtBounds
-
- TextEdit {
- id: agentPromptText
-
- width: agentPromptFlickable.width
- text: root.currentAgentRoleSystemPrompt
- readOnly: true
- selectByMouse: true
- wrapMode: Text.WordWrap
- color: palette.text
- font.family: "monospace"
- font.pixelSize: 11
- }
-
- QQC.ScrollBar.vertical: QQC.ScrollBar {
- policy: agentPromptFlickable.contentHeight > agentPromptFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff
- }
- }
- }
-
- Text {
- text: qsTr("No role selected. Using base system prompt only.")
- font.pixelSize: 11
- color: palette.mid
- wrapMode: Text.WordWrap
- Layout.fillWidth: true
- visible: root.currentAgentRoleSystemPrompt.length === 0
- }
-
- RowLayout {
- Layout.fillWidth: true
-
- Item { Layout.fillWidth: true }
-
- QoAButton {
- text: qsTr("Copy")
- enabled: root.currentAgentRoleSystemPrompt.length > 0
- onClicked: utils.copyToClipboard(root.currentAgentRoleSystemPrompt)
- }
-
- QoAButton {
- text: qsTr("Manage Roles")
- onClicked: {
- root.openAgentRolesSettings()
- root.close()
- }
- }
- }
- }
- }
-
- CollapsibleSection {
- id: projectRulesSection
-
- Layout.fillWidth: true
- title: qsTr("Project Rules")
- badge: root.activeRulesCount > 0 ? qsTr("%1 active").arg(root.activeRulesCount) : qsTr("None")
- badgeColor: root.activeRulesCount > 0 ? Qt.rgba(0.6, 0.5, 0.2, 1.0) : palette.mid
-
- sectionContent: ColumnLayout {
- spacing: 8
-
- SplitView {
- Layout.fillWidth: true
- Layout.preferredHeight: 220
- orientation: Qt.Horizontal
- visible: root.activeRulesCount > 0
-
- Rectangle {
- SplitView.minimumWidth: 120
- SplitView.preferredWidth: 180
- color: palette.base
- border.color: palette.mid
- border.width: 1
- radius: 2
-
- ColumnLayout {
- anchors.fill: parent
- anchors.margins: 5
- spacing: 5
-
- Text {
- text: qsTr("Rules (%1)").arg(rulesList.count)
- font.pixelSize: 11
- font.bold: true
- color: palette.text
- Layout.fillWidth: true
- }
-
- ListView {
- id: rulesList
-
- Layout.fillWidth: true
- Layout.fillHeight: true
- clip: true
- model: root.activeRules
- currentIndex: 0
- boundsBehavior: Flickable.StopAtBounds
-
- delegate: ItemDelegate {
- required property var modelData
- required property int index
-
- width: ListView.view.width
- height: ruleItemContent.implicitHeight + 8
- highlighted: ListView.isCurrentItem
-
- background: Rectangle {
- color: {
- if (parent.highlighted)
- return palette.highlight
- if (parent.hovered)
- return Qt.tint(palette.base, Qt.rgba(0, 0, 0, 0.05))
- return "transparent"
- }
- radius: 2
- }
-
- contentItem: ColumnLayout {
- id: ruleItemContent
- spacing: 2
-
- Text {
- text: modelData.fileName
- font.pixelSize: 10
- color: parent.parent.highlighted ? palette.highlightedText : palette.text
- elide: Text.ElideMiddle
- Layout.fillWidth: true
- }
-
- Text {
- text: modelData.category
- font.pixelSize: 9
- color: parent.parent.highlighted ? palette.highlightedText : palette.mid
- Layout.fillWidth: true
- }
- }
-
- onClicked: {
- rulesList.currentIndex = index
- root.ruleSelected(index)
- }
- }
-
- QQC.ScrollBar.vertical: QQC.ScrollBar {
- policy: rulesList.contentHeight > rulesList.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff
- }
- }
- }
- }
-
- Rectangle {
- SplitView.fillWidth: true
- SplitView.minimumWidth: 200
- color: palette.base
- border.color: palette.mid
- border.width: 1
- radius: 2
-
- ColumnLayout {
- anchors.fill: parent
- anchors.margins: 5
- spacing: 5
-
- RowLayout {
- Layout.fillWidth: true
- spacing: 5
-
- Text {
- text: qsTr("Content")
- font.pixelSize: 11
- font.bold: true
- color: palette.text
- Layout.fillWidth: true
- }
-
- QoAButton {
- text: qsTr("Copy")
- enabled: root.selectedRuleContent.length > 0
- onClicked: utils.copyToClipboard(root.selectedRuleContent)
- }
- }
-
- Flickable {
- id: ruleContentFlickable
-
- Layout.fillWidth: true
- Layout.fillHeight: true
- contentHeight: ruleContentArea.implicitHeight
- clip: true
- boundsBehavior: Flickable.StopAtBounds
-
- TextEdit {
- id: ruleContentArea
-
- width: ruleContentFlickable.width
- text: root.selectedRuleContent
- readOnly: true
- selectByMouse: true
- wrapMode: Text.WordWrap
- selectionColor: palette.highlight
- color: palette.text
- font.family: "monospace"
- font.pixelSize: 11
- }
-
- QQC.ScrollBar.vertical: QQC.ScrollBar {
- policy: ruleContentFlickable.contentHeight > ruleContentFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff
- }
- }
- }
- }
- }
-
- Text {
- text: qsTr("No project rules found.\nCreate .md files in .qodeassist/rules/common/ or .qodeassist/rules/chat/")
- font.pixelSize: 11
- color: palette.mid
- wrapMode: Text.WordWrap
- horizontalAlignment: Text.AlignHCenter
- Layout.fillWidth: true
- visible: root.activeRulesCount === 0
- }
-
- RowLayout {
- Layout.fillWidth: true
-
- Item { Layout.fillWidth: true }
-
- QoAButton {
- text: qsTr("Open Rules Folder")
- onClicked: root.openRulesFolder()
- }
- }
- }
- }
}
QQC.ScrollBar.vertical: QQC.ScrollBar {
@@ -448,7 +158,7 @@ Popup {
}
Text {
- text: qsTr("Final prompt: Base System Prompt + Agent Role + Project Info + Project Rules + Linked Files")
+ text: qsTr("Final prompt: Base System Prompt + Project Info + Skills")
font.pixelSize: 9
color: palette.mid
wrapMode: Text.WordWrap
@@ -534,10 +244,4 @@ Popup {
active: sectionRoot.expanded
}
}
-
- onOpened: {
- if (root.activeRulesCount > 0) {
- root.ruleSelected(0)
- }
- }
}
diff --git a/sources/ChatView/qml/controls/DropZone.qml b/sources/ChatView/qml/controls/DropZone.qml
new file mode 100644
index 0000000..5e3ffd7
--- /dev/null
+++ b/sources/ChatView/qml/controls/DropZone.qml
@@ -0,0 +1,148 @@
+// Copyright (C) 2025-2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+import QtQuick
+import QtQuick.Controls
+
+Item {
+ id: root
+
+ signal filesDropped(var urlStrings)
+
+ property int filesCount: 0
+ property bool isDragActive: false
+
+ Item {
+ id: dropOverlay
+
+ anchors.fill: parent
+ visible: false
+ z: 999
+ opacity: 0
+
+ Behavior on opacity {
+ NumberAnimation { duration: 200; easing.type: Easing.InOutQuad }
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: Qt.rgba(palette.shadow.r, palette.shadow.g, palette.shadow.b, 0.6)
+ }
+
+ Rectangle {
+ anchors {
+ top: parent.top
+ horizontalCenter: parent.horizontalCenter
+ topMargin: 30
+ }
+ width: fileCountText.width + 40
+ height: 50
+ color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.9)
+ radius: 25
+ visible: root.filesCount > 0
+
+ Text {
+ id: fileCountText
+ anchors.centerIn: parent
+ text: qsTr("%n file(s) to drop", "", root.filesCount)
+ font.pixelSize: 16
+ font.bold: true
+ color: palette.highlightedText
+ }
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: root.isDragActive
+ ? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3)
+ : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15)
+ border.width: root.isDragActive ? 3 : 2
+ border.color: root.isDragActive
+ ? palette.highlight
+ : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5)
+
+ Column {
+ anchors.centerIn: parent
+ spacing: 15
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: qsTr("Attach")
+ font.pixelSize: 24
+ font.bold: true
+ color: root.isDragActive ? palette.highlightedText : palette.text
+ }
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: qsTr("Images & Text Files")
+ font.pixelSize: 14
+ color: root.isDragActive ? palette.highlightedText : palette.text
+ opacity: 0.8
+ }
+ }
+
+ Behavior on color { ColorAnimation { duration: 150 } }
+ Behavior on border.width { NumberAnimation { duration: 150 } }
+ Behavior on border.color { ColorAnimation { duration: 150 } }
+ }
+ }
+
+ DropArea {
+ id: globalDropArea
+
+ anchors.fill: parent
+
+ onEntered: (drag) => {
+ if (drag.hasUrls) {
+ root.isDragActive = true
+ root.filesCount = drag.urls.length
+ dropOverlay.visible = true
+ dropOverlay.opacity = 1
+ }
+ }
+
+ onExited: {
+ root.isDragActive = false
+ root.filesCount = 0
+ dropOverlay.opacity = 0
+
+ Qt.callLater(function() {
+ if (!root.isDragActive) {
+ dropOverlay.visible = false
+ }
+ })
+ }
+
+ onDropped: (drop) => {
+ root.isDragActive = false
+ root.filesCount = 0
+ dropOverlay.opacity = 0
+
+ Qt.callLater(function() {
+ dropOverlay.visible = false
+ })
+
+ if (!drop.hasUrls || drop.urls.length === 0) {
+ return
+ }
+
+ var urlStrings = []
+ for (var i = 0; i < drop.urls.length; i++) {
+ var urlString = drop.urls[i].toString()
+ if (urlString.startsWith("file://") || urlString.indexOf("://") === -1) {
+ urlStrings.push(urlString)
+ }
+ }
+
+ if (urlStrings.length === 0) {
+ return
+ }
+
+ drop.accept(Qt.CopyAction)
+
+ root.filesDropped(urlStrings)
+ }
+ }
+}
diff --git a/sources/ChatView/qml/controls/SkillCommandPopup.qml b/sources/ChatView/qml/controls/SkillCommandPopup.qml
index 78ccdd8..0f9371c 100644
--- a/sources/ChatView/qml/controls/SkillCommandPopup.qml
+++ b/sources/ChatView/qml/controls/SkillCommandPopup.qml
@@ -9,7 +9,6 @@ import QtQuick.Layouts
Rectangle {
id: root
- // Object exposing Q_INVOKABLE QVariantList searchSkills(query).
property var skillProvider: null
property var searchResults: []
property int currentIndex: 0
@@ -25,7 +24,7 @@ Rectangle {
radius: 4
function updateSearch(query) {
- searchResults = skillProvider ? skillProvider.searchSkills(query) : []
+ searchResults = skillProvider ? skillProvider.searchSlashCommands(query) : []
currentIndex = 0
}
@@ -87,19 +86,55 @@ Rectangle {
anchors.bottomMargin: 4
spacing: 1
- Text {
+ RowLayout {
Layout.fillWidth: true
- text: "/" + delegateItem.modelData.name
- color: delegateItem.index === root.currentIndex
- ? palette.highlightedText
- : palette.text
- font.bold: true
- elide: Text.ElideRight
+ spacing: 6
+
+ Text {
+ Layout.fillWidth: true
+ text: "/" + delegateItem.modelData.name
+ textFormat: Text.PlainText
+ color: delegateItem.index === root.currentIndex
+ ? palette.highlightedText
+ : palette.text
+ font.bold: true
+ elide: Text.ElideRight
+ }
+
+ Rectangle {
+ visible: sourceBadge.text.length > 0
+ implicitWidth: Math.min(sourceBadge.implicitWidth + 10, 140)
+ implicitHeight: sourceBadge.implicitHeight + 2
+ radius: height / 2
+ color: delegateItem.index === root.currentIndex
+ ? Qt.rgba(palette.highlightedText.r,
+ palette.highlightedText.g,
+ palette.highlightedText.b, 0.2)
+ : palette.alternateBase
+
+ Text {
+ id: sourceBadge
+
+ anchors.fill: parent
+ anchors.leftMargin: 5
+ anchors.rightMargin: 5
+ verticalAlignment: Text.AlignVCenter
+ horizontalAlignment: Text.AlignHCenter
+ text: delegateItem.modelData.source || ""
+ textFormat: Text.PlainText
+ elide: Text.ElideRight
+ color: delegateItem.index === root.currentIndex
+ ? palette.highlightedText
+ : palette.mid
+ font.pixelSize: 10
+ }
+ }
}
Text {
Layout.fillWidth: true
text: delegateItem.modelData.description
+ textFormat: Text.PlainText
color: delegateItem.index === root.currentIndex
? Qt.rgba(palette.highlightedText.r,
palette.highlightedText.g,
diff --git a/sources/ChatView/qml/controls/SplitDropZone.qml b/sources/ChatView/qml/controls/SplitDropZone.qml
deleted file mode 100644
index a748194..0000000
--- a/sources/ChatView/qml/controls/SplitDropZone.qml
+++ /dev/null
@@ -1,276 +0,0 @@
-// Copyright (C) 2025-2026 Petr Mironychev
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
-
-import QtQuick
-import QtQuick.Controls
-
-Item {
- id: root
-
- signal filesDroppedToAttach(var urlStrings)
- signal filesDroppedToLink(var urlStrings)
-
- property string activeZone: ""
- property int filesCount: 0
- property bool isDragActive: false
-
- Item {
- id: splitDropOverlay
-
- anchors.fill: parent
- visible: false
- z: 999
- opacity: 0
-
- Behavior on opacity {
- NumberAnimation { duration: 200; easing.type: Easing.InOutQuad }
- }
-
- Rectangle {
- anchors.fill: parent
- color: Qt.rgba(palette.shadow.r, palette.shadow.g, palette.shadow.b, 0.6)
- }
-
- Rectangle {
- anchors {
- top: parent.top
- horizontalCenter: parent.horizontalCenter
- topMargin: 30
- }
- width: fileCountText.width + 40
- height: 50
- color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.9)
- radius: 25
- visible: root.filesCount > 0
-
- Text {
- id: fileCountText
- anchors.centerIn: parent
- text: qsTr("%n file(s) to drop", "", root.filesCount)
- font.pixelSize: 16
- font.bold: true
- color: palette.highlightedText
- }
- }
-
- Rectangle {
- id: leftZone
-
- anchors {
- left: parent.left
- top: parent.top
- bottom: parent.bottom
- }
- width: parent.width / 2
- color: root.activeZone === "left"
- ? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3)
- : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15)
- border.width: root.activeZone === "left" ? 3 : 2
- border.color: root.activeZone === "left"
- ? palette.highlight
- : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5)
-
- Column {
- anchors.centerIn: parent
- spacing: 15
-
- Text {
- anchors.horizontalCenter: parent.horizontalCenter
- text: qsTr("Attach")
- font.pixelSize: 24
- font.bold: true
- color: root.activeZone === "left" ? palette.highlightedText : palette.text
- }
-
- Text {
- anchors.horizontalCenter: parent.horizontalCenter
- text: qsTr("Images & Text Files")
- font.pixelSize: 14
- color: root.activeZone === "left" ? palette.highlightedText : palette.text
- opacity: 0.8
- }
-
- Text {
- anchors.horizontalCenter: parent.horizontalCenter
- text: qsTr("(for one-time use)")
- font.pixelSize: 12
- font.italic: true
- color: root.activeZone === "left" ? palette.highlightedText : palette.text
- opacity: 0.6
- }
- }
-
- Behavior on color { ColorAnimation { duration: 150 } }
- Behavior on border.width { NumberAnimation { duration: 150 } }
- Behavior on border.color { ColorAnimation { duration: 150 } }
- }
-
- Rectangle {
- id: rightZone
-
- anchors {
- right: parent.right
- top: parent.top
- bottom: parent.bottom
- }
- width: parent.width / 2
- color: root.activeZone === "right"
- ? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3)
- : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15)
- border.width: root.activeZone === "right" ? 3 : 2
- border.color: root.activeZone === "right"
- ? palette.highlight
- : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5)
-
- Column {
- anchors.centerIn: parent
- spacing: 15
-
- Text {
- anchors.horizontalCenter: parent.horizontalCenter
- text: qsTr("LINK")
- font.pixelSize: 24
- font.bold: true
- color: root.activeZone === "right" ? palette.highlightedText : palette.text
- }
-
- Text {
- anchors.horizontalCenter: parent.horizontalCenter
- text: qsTr("Text Files")
- font.pixelSize: 14
- color: root.activeZone === "right" ? palette.highlightedText : palette.text
- opacity: 0.8
- }
-
- Text {
- anchors.horizontalCenter: parent.horizontalCenter
- text: qsTr("(added to context)")
- font.pixelSize: 12
- font.italic: true
- color: root.activeZone === "right" ? palette.highlightedText : palette.text
- opacity: 0.6
- }
- }
-
- Behavior on color { ColorAnimation { duration: 150 } }
- Behavior on border.width { NumberAnimation { duration: 150 } }
- Behavior on border.color { ColorAnimation { duration: 150 } }
- }
-
- Rectangle {
- anchors {
- horizontalCenter: parent.horizontalCenter
- top: parent.top
- bottom: parent.bottom
- }
- width: 2
- color: palette.mid
- opacity: 0.4
- }
-
- MouseArea {
- id: leftDropArea
-
- anchors {
- left: parent.left
- top: parent.top
- bottom: parent.bottom
- }
- width: parent.width / 2
- hoverEnabled: true
-
- onEntered: {
- root.activeZone = "left"
- }
- }
-
- MouseArea {
- id: rightDropArea
-
- anchors {
- right: parent.right
- top: parent.top
- bottom: parent.bottom
- }
- width: parent.width / 2
- hoverEnabled: true
-
- onEntered: {
- root.activeZone = "right"
- }
- }
- }
-
- DropArea {
- id: globalDropArea
-
- anchors.fill: parent
-
- onEntered: (drag) => {
- if (drag.hasUrls) {
- root.isDragActive = true
- root.filesCount = drag.urls.length
- splitDropOverlay.visible = true
- splitDropOverlay.opacity = 1
- root.activeZone = ""
- }
- }
-
- onExited: {
- root.isDragActive = false
- root.filesCount = 0
- splitDropOverlay.opacity = 0
-
- Qt.callLater(function() {
- if (!root.isDragActive) {
- splitDropOverlay.visible = false
- root.activeZone = ""
- }
- })
- }
-
- onPositionChanged: (drag) => {
- if (drag.hasUrls) {
- root.activeZone = drag.x < globalDropArea.width / 2 ? "left" : "right"
- }
- }
-
- onDropped: (drop) => {
- const targetZone = root.activeZone
- root.isDragActive = false
- root.filesCount = 0
- splitDropOverlay.opacity = 0
-
- Qt.callLater(function() {
- splitDropOverlay.visible = false
- root.activeZone = ""
- })
-
- if (!drop.hasUrls || drop.urls.length === 0) {
- return
- }
-
- var urlStrings = []
- for (var i = 0; i < drop.urls.length; i++) {
- var urlString = drop.urls[i].toString()
- if (urlString.startsWith("file://") || urlString.indexOf("://") === -1) {
- urlStrings.push(urlString)
- }
- }
-
- if (urlStrings.length === 0) {
- return
- }
-
- drop.accept(Qt.CopyAction)
-
- if (targetZone === "right") {
- root.filesDroppedToLink(urlStrings)
- } else {
- root.filesDroppedToAttach(urlStrings)
- }
- }
- }
-}
-
diff --git a/sources/ChatView/qml/controls/TopBar.qml b/sources/ChatView/qml/controls/TopBar.qml
index efe792e..e605f41 100644
--- a/sources/ChatView/qml/controls/TopBar.qml
+++ b/sources/ChatView/qml/controls/TopBar.qml
@@ -23,11 +23,8 @@ Rectangle {
property alias pinButton: pinButtonId
property alias relocateButton: relocateButtonId
property alias contextButton: contextButtonId
- property alias toolsButton: toolsButtonId
- property alias thinkingMode: thinkingModeId
property alias settingsButton: settingsButtonId
property alias configSelector: configSelectorId
- property alias roleSelector: roleSelector
property alias relocateTooltip: relocateTooltipId
color: palette.window.hslLightness > 0.5 ?
@@ -147,82 +144,11 @@ Rectangle {
text: qsTr("Switch saved AI configuration")
}
}
-
- QoAComboBox {
- id: roleSelector
-
- implicitHeight: 25
-
- model: []
- currentIndex: 0
-
- QoAToolTip {
- visible: roleSelector.hovered
- delay: 250
- text: qsTr("Switch agent role (different system prompts)")
- }
- }
}
Row {
spacing: 10
- QoAButton {
- id: toolsButtonId
-
- anchors.verticalCenter: parent.verticalCenter
-
- checkable: true
- opacity: enabled ? 1.0 : 0.2
-
- icon {
- source: checked ? "qrc:/qt/qml/ChatView/icons/tools-icon-on.svg"
- : "qrc:/qt/qml/ChatView/icons/tools-icon-off.svg"
- color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
- height: 15
- width: 15
- }
-
- QoAToolTip {
- visible: toolsButtonId.hovered
- delay: 250
- text: {
- if (!toolsButtonId.enabled) {
- return qsTr("Tools are disabled in General Settings")
- }
- return toolsButtonId.checked
- ? qsTr("Tools enabled: AI can use tools to read files, search project, and build code")
- : qsTr("Tools disabled: Simple conversation without tool access")
- }
- }
- }
-
- QoAButton {
- id: thinkingModeId
-
- anchors.verticalCenter: parent.verticalCenter
-
- checkable: true
- opacity: enabled ? 1.0 : 0.2
-
- icon {
- source: checked ? "qrc:/qt/qml/ChatView/icons/thinking-icon-on.svg"
- : "qrc:/qt/qml/ChatView/icons/thinking-icon-off.svg"
- color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF"
- height: 15
- width: 15
- }
-
- QoAToolTip {
- visible: thinkingModeId.hovered
- delay: 250
- text: thinkingModeId.enabled
- ? (thinkingModeId.checked ? qsTr("Thinking Mode enabled (Check model list support it)")
- : qsTr("Thinking Mode disabled"))
- : qsTr("Thinking Mode is not available for this provider")
- }
- }
-
QoAButton {
id: settingsButtonId
@@ -345,7 +271,7 @@ Rectangle {
QoAToolTip {
visible: contextButtonId.hovered
delay: 250
- text: qsTr("View chat context (system prompt, role, rules)")
+ text: qsTr("View chat context (system prompt)")
}
}
diff --git a/sources/acp/AcpChatBackend.cpp b/sources/acp/AcpChatBackend.cpp
new file mode 100644
index 0000000..d50fdd7
--- /dev/null
+++ b/sources/acp/AcpChatBackend.cpp
@@ -0,0 +1,1067 @@
+// Copyright (C) 2026 Petr Mironychev
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE
+
+#include "AcpChatBackend.hpp"
+
+#include
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+#include
+#include
+
+namespace QodeAssist::Acp {
+
+namespace {
+
+constexpr int authRequiredErrorCode = -32000;
+constexpr int maxStderrLines = 20;
+constexpr qsizetype maxInlineAttachmentBytes = 512 * 1024;
+constexpr qsizetype maxInlineImageBytes = 8 * 1024 * 1024;
+constexpr qsizetype maxPermissionOptions = 16;
+constexpr qsizetype maxPermissionTitleLength = 300;
+constexpr qsizetype maxPermissionKindLength = 40;
+constexpr qsizetype maxPermissionOptionNameLength = 120;
+constexpr qsizetype maxAgentCommands = 128;
+constexpr qsizetype maxAgentCommandNameLength = 64;
+constexpr qsizetype maxToolResultLength = 64 * 1024;
+constexpr qsizetype maxToolDiffLength = 64 * 1024;
+constexpr qsizetype maxToolLocations = 64;
+constexpr qsizetype maxToolDiffs = 16;
+constexpr qsizetype maxAgentTitleLength = 60;
+
+QString clampAgentText(const QString &text, qsizetype limit)
+{
+ if (text.size() <= limit)
+ return text;
+ return text.first(limit - 1) + QChar(0x2026);
+}
+
+QString truncationNotice(qsizetype shown, qsizetype total)
+{
+ return QLatin1Char('\n')
+ + QObject::tr("[truncated by QodeAssist: %1 of %2 bytes shown]").arg(shown).arg(total);
+}
+
+QString clampAgentBody(const QString &text, qsizetype limit)
+{
+ if (text.size() <= limit)
+ return text;
+ return text.first(limit) + truncationNotice(limit, text.size());
+}
+
+QString singleLineAgentText(const QString &text, qsizetype limit)
+{
+ QString line;
+ line.reserve(qMin(text.size(), limit));
+
+ for (const QChar c : text) {
+ if (c.isSpace()) {
+ if (!line.isEmpty() && !line.endsWith(QLatin1Char(' ')))
+ line.append(QLatin1Char(' '));
+ continue;
+ }
+ if (c.category() == QChar::Other_Control || c.category() == QChar::Other_Format)
+ continue;
+ line.append(c);
+ }
+
+ return clampAgentText(line.trimmed(), limit);
+}
+
+QString validatePermissionOptions(const QList &options)
+{
+ if (options.isEmpty())
+ return QStringLiteral("the agent offered no options to choose from");
+
+ if (options.size() > maxPermissionOptions) {
+ return QStringLiteral("the agent offered %1 options, more than the %2 allowed")
+ .arg(options.size())
+ .arg(maxPermissionOptions);
+ }
+
+ QSet seen;
+ for (const LLMQore::Acp::PermissionOption &option : options) {
+ if (option.optionId.isEmpty())
+ return QStringLiteral("the agent offered an option with no id");
+ if (seen.contains(option.optionId))
+ return QStringLiteral("the agent offered option id %1 twice").arg(option.optionId);
+ seen.insert(option.optionId);
+ }
+
+ return {};
+}
+
+QString attachmentUri(const QString &fileName)
+{
+ return QStringLiteral("qodeassist://attachment/")
+ + QString::fromUtf8(QUrl::toPercentEncoding(fileName));
+}
+
+QMimeType mimeTypeForFileName(const QString &fileName)
+{
+ return QMimeDatabase().mimeTypeForFile(fileName, QMimeDatabase::MatchExtension);
+}
+
+QString textMimeTypeForFileName(const QString &fileName)
+{
+ const QMimeType type = mimeTypeForFileName(fileName);
+ return type.inherits(QStringLiteral("text/plain")) ? type.name() : QStringLiteral("text/plain");
+}
+
+LLMQore::Acp::ContentBlock makeResource(
+ const QString &uri, const QString &mimeType, const QString &text)
+{
+ LLMQore::Acp::EmbeddedResource resource;
+ resource.uri = uri;
+ resource.mimeType = mimeType;
+ resource.text = text;
+
+ LLMQore::Acp::ContentBlock block;
+ block.type = QStringLiteral("resource");
+ block.resource = resource;
+ return block;
+}
+
+LLMQore::Acp::ContentBlock makeImage(const QString &mediaType, const QString &base64Data)
+{
+ LLMQore::Acp::ContentBlock block;
+ block.type = QStringLiteral("image");
+ block.mimeType = mediaType;
+ block.data = base64Data;
+ return block;
+}
+
+bool hasSendableContent(const QList &userBlocks)
+{
+ for (const Session::ContentBlock &block : userBlocks) {
+ if (const auto *text = std::get_if(&block)) {
+ if (!text->text.isEmpty())
+ return true;
+ continue;
+ }
+ if (std::get_if(&block)
+ || std::get_if(&block)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+QString textOf(const LLMQore::Acp::ContentBlock &block)
+{
+ return block.type == QLatin1String("text") ? block.text : QString();
+}
+
+int tokenCount(const QJsonValue &value)
+{
+ qint64 count = -1;
+
+ if (value.isDouble()) {
+ const double raw = value.toDouble(-1.0);
+ if (raw >= 0.0 && raw == std::floor(raw))
+ count = static_cast(raw);
+ } else if (value.isString()) {
+ bool parsed = false;
+ const qint64 raw = value.toString().toLongLong(&parsed);
+ if (parsed && raw >= 0)
+ count = raw;
+ }
+
+ if (count < 0)
+ return 0;
+
+ return static_cast(qMin(count, std::numeric_limits::max()));
+}
+
+int usageValue(const QJsonObject &usage, QLatin1StringView camelCase, QLatin1StringView snakeCase)
+{
+ const int fromCamelCase = tokenCount(usage.value(camelCase));
+ return fromCamelCase > 0 ? fromCamelCase : tokenCount(usage.value(snakeCase));
+}
+
+Session::Usage usageFromPromptResult(const QJsonObject &usage)
+{
+ return Session::Usage{
+ .promptTokens = usageValue(usage, QLatin1StringView("inputTokens"),
+ QLatin1StringView("input_tokens")),
+ .completionTokens = usageValue(usage, QLatin1StringView("outputTokens"),
+ QLatin1StringView("output_tokens")),
+ .cachedPromptTokens = usageValue(usage, QLatin1StringView("cachedReadTokens"),
+ QLatin1StringView("cached_read_tokens")),
+ .reasoningTokens = 0};
+}
+
+QString describeContentBlock(const LLMQore::Acp::ContentBlock &block)
+{
+ if (block.type == QLatin1String("text"))
+ return block.text;
+
+ if (block.type == QLatin1String("resource") && block.resource) {
+ return block.resource->text.isEmpty()
+ ? QObject::tr("[resource %1]").arg(block.resource->uri)
+ : block.resource->text;
+ }
+
+ if (block.type == QLatin1String("resource_link"))
+ return QObject::tr("[link %1]").arg(block.uri);
+
+ if (block.type == QLatin1String("image") || block.type == QLatin1String("audio"))
+ return QObject::tr("[%1 result, not shown]").arg(block.type);
+
+ LOG_MESSAGE(QString("Unhandled ACP tool result content type: %1").arg(block.type));
+ return QObject::tr("[%1 result, not shown]").arg(block.type);
+}
+
+QString toolResultText(const LLMQore::Acp::ToolCall &toolCall)
+{
+ QStringList parts;
+
+ for (const LLMQore::Acp::ToolCallContent &content : toolCall.content) {
+ if (content.type == QLatin1String("content")) {
+ if (content.content)
+ parts.append(describeContentBlock(*content.content));
+ } else if (content.type == QLatin1String("terminal")) {
+ parts.append(QObject::tr("Terminal %1").arg(content.terminalId));
+ } else if (content.type != QLatin1String("diff")) {
+ LOG_MESSAGE(QString("Unhandled ACP tool call content type: %1").arg(content.type));
+ }
+ }
+
+ parts.removeAll(QString());
+ return clampAgentBody(parts.join(QLatin1Char('\n')), maxToolResultLength);
+}
+
+QJsonObject toolDetailsOf(const LLMQore::Acp::ToolCall &toolCall)
+{
+ QJsonArray locations;
+ for (const LLMQore::Acp::ToolCallLocation &location : toolCall.locations) {
+ if (locations.size() >= maxToolLocations) {
+ LOG_MESSAGE(QString("Dropping %1 extra locations reported by tool call %2")
+ .arg(toolCall.locations.size() - maxToolLocations)
+ .arg(toolCall.toolCallId));
+ break;
+ }
+ QJsonObject entry{{"path", singleLineAgentText(location.path, maxPermissionTitleLength)}};
+ if (location.line)
+ entry["line"] = *location.line;
+ locations.append(entry);
+ }
+
+ QJsonArray diffs;
+ for (const LLMQore::Acp::ToolCallContent &content : toolCall.content) {
+ if (content.type != QLatin1String("diff"))
+ continue;
+ if (diffs.size() >= maxToolDiffs) {
+ LOG_MESSAGE(
+ QString("Dropping extra diffs reported by tool call %1").arg(toolCall.toolCallId));
+ break;
+ }
+ QJsonObject diff{
+ {"path", singleLineAgentText(content.path, maxPermissionTitleLength)},
+ {"oldText", clampAgentBody(content.oldText, maxToolDiffLength)},
+ {"newText", clampAgentBody(content.newText, maxToolDiffLength)}};
+ if (content.oldText.size() > maxToolDiffLength
+ || content.newText.size() > maxToolDiffLength) {
+ diff["truncated"] = true;
+ }
+ diffs.append(diff);
+ }
+
+ QJsonObject details;
+ if (!locations.isEmpty())
+ details["locations"] = locations;
+ if (!diffs.isEmpty())
+ details["diffs"] = diffs;
+ return details;
+}
+
+} // namespace
+
+AcpChatBackend::AcpChatBackend(QObject *parent)
+ : Session::ChatBackend(parent)
+ , m_clientFactory(&spawnAgent)
+ , m_permissions(new ChatPermissionProvider(&m_ledger, this))
+{
+ m_permissions->setRequestHandler(
+ [this](
+ const QString &requestId,
+ const LLMQore::Acp::ToolCall &toolCall,
+ const QList &options) {
+ requestPermission(requestId, toolCall, options);
+ });
+}
+
+void AcpChatBackend::setClientFactory(ClientFactory factory)
+{
+ m_clientFactory = std::move(factory);
+}
+
+void AcpChatBackend::setStoredContentLoader(StoredContentLoader loader)
+{
+ m_storedContentLoader = std::move(loader);
+}
+
+void AcpChatBackend::setKnowledgeService(AgentKnowledgeService *service)
+{
+ m_knowledgeService = service;
+}
+
+QList AcpChatBackend::knowledgeServers()
+{
+ if (!m_knowledgeService)
+ return {};
+
+ if (!m_agentInfo.agentCapabilities.mcpCapabilities.http) {
+ LOG_MESSAGE(
+ QString("Agent %1 does not accept HTTP MCP servers, so QodeAssist knowledge is not "
+ "offered")
+ .arg(m_agent ? m_agent->id : QString()));
+ return {};
+ }
+
+ const QString url = m_knowledgeService->start();
+ if (url.isEmpty()) {
+ LOG_MESSAGE("The QodeAssist knowledge server did not start, continuing without it");
+ return {};
+ }
+
+ m_knowledgeServerRunning = true;
+ LOG_MESSAGE(QString("Offering the agent the QodeAssist knowledge server at %1").arg(url));
+
+ return {LLMQore::Acp::McpServer::http(m_knowledgeService->serverName(), url)};
+}
+
+void AcpChatBackend::stopKnowledgeServer()
+{
+ if (!m_knowledgeServerRunning)
+ return;
+
+ m_knowledgeServerRunning = false;
+ if (m_knowledgeService)
+ m_knowledgeService->stop();
+}
+
+void AcpChatBackend::setChatFilePath(const QString &filePath)
+{
+ m_chatFilePath = filePath;
+}
+
+void AcpChatBackend::bindAgent(const AgentDefinition &agent)
+{
+ if (m_agent && m_agent->id == agent.id)
+ return;
+
+ cancel();
+ releaseClient();
+
+ m_agent = agent;
+ m_sessionId.clear();
+ m_resumeSessionId.clear();
+ m_handoverSummary.clear();
+ m_authenticated = false;
+}
+
+QString AcpChatBackend::boundAgentId() const
+{
+ return m_agent ? m_agent->id : QString();
+}
+
+void AcpChatBackend::adoptEarlyCommands()
+{
+ if (!m_earlyCommandsSessionId.isEmpty() && m_earlyCommandsSessionId == m_sessionId) {
+ m_availableCommands = m_earlyCommands;
+ emit availableCommandsChanged();
+ }
+ m_earlyCommands.clear();
+ m_earlyCommandsSessionId.clear();
+
+ if (!m_earlyTitleSessionId.isEmpty() && m_earlyTitleSessionId == m_sessionId
+ && !m_earlyTitle.isEmpty()) {
+ emit sessionEvent(Session::SessionInfo{.title = m_earlyTitle});
+ }
+ m_earlyTitle.clear();
+ m_earlyTitleSessionId.clear();
+}
+
+QString AcpChatBackend::boundAgentName() const
+{
+ return m_agent ? singleLineAgentText(m_agent->name, maxPermissionKindLength) : QString();
+}
+
+void AcpChatBackend::sendTurn(const Session::TurnRequest &request)
+{
+ if (!m_agent) {
+ emit sessionEvent(
+ Session::TurnFailed{.turnId = {}, .error = tr("No agent is bound to this chat.")});
+ return;
+ }
+
+ cancel();
+
+ if (!hasSendableContent(request.userBlocks)) {
+ emit sessionEvent(
+ Session::TurnFailed{.turnId = {}, .error = tr("There is nothing to send to the agent.")});
+ return;
+ }
+
+ m_pendingTurn = PendingTurn{request.userBlocks};
+
+ const QString turnId = m_ledger.beginTurn(QStringLiteral("acp-%1").arg(++m_turnCounter));
+ m_stderr.clear();
+ emit sessionEvent(Session::TurnStarted{.turnId = turnId});
+
+ if (!m_client) {
+ startClient();
+ return;
+ }
+
+ if (m_sessionId.isEmpty()) {
+ if (!m_establishingSession)
+ resumeOrStartSession();
+ return;
+ }
+
+ sendPrompt();
+}
+
+void AcpChatBackend::startClient()
+{
+ m_workingDirectory = agentWorkingDirectory();
+
+ const AgentProcess process = m_clientFactory(*m_agent, m_workingDirectory, this);
+ m_client = process.client;
+ m_runner = process.command;
+
+ if (!m_client) {
+ failTurn(tr("This agent has no launchable distribution."), true);
+ return;
+ }
+
+ m_client->setPermissionProvider(m_permissions);
+ connectClient();
+
+ const int generation = m_clientGeneration;
+
+ m_client->connectAndInitialize(std::chrono::seconds(60))
+ .then(
+ this,
+ [this, generation](const LLMQore::Acp::InitializeResult &result) {
+ if (generation != m_clientGeneration)
+ return;
+ m_agentInfo = result;
+ if (m_ledger.hasActiveTurn())
+ resumeOrStartSession();
+ })
+ .onFailed(this, [this, generation](const std::exception &e) {
+ if (generation != m_clientGeneration)
+ return;
+ failTurn(QString::fromUtf8(e.what()), true);
+ });
+}
+
+void AcpChatBackend::resumeSession(const QString &sessionId)
+{
+ m_resumeSessionId = sessionId;
+}
+
+void AcpChatBackend::startFreshSession()
+{
+ m_resumeSessionId.clear();
+}
+
+void AcpChatBackend::setHandoverSummary(const QString &summary)
+{
+ m_handoverSummary = summary;
+}
+
+void AcpChatBackend::resumeOrStartSession()
+{
+ if (m_resumeSessionId.isEmpty()) {
+ startSession();
+ return;
+ }
+
+ const QString resumed = m_resumeSessionId;
+
+ if (!m_agentInfo.agentCapabilities.loadSession) {
+ m_resumeSessionId.clear();
+ failTurn(
+ tr("This agent cannot reopen a previous conversation, so the transcript below is "
+ "read-only. Start a new session to continue with this agent."),
+ false);
+ emit agentSessionUnavailable(
+ tr("%1 does not support reopening a session.")
+ .arg(m_agent ? m_agent->name : tr("This agent")));
+ return;
+ }
+
+ LLMQore::Acp::LoadSessionParams params;
+ params.sessionId = resumed;
+ params.cwd = m_workingDirectory;
+ params.mcpServers = knowledgeServers();
+
+ const int generation = m_clientGeneration;
+ m_establishingSession = true;
+
+ m_client->loadSession(params, std::chrono::seconds(60))
+ .then(
+ this,
+ [this, resumed, generation](const LLMQore::Acp::NewSessionResult &result) {
+ if (generation != m_clientGeneration)
+ return;
+ m_establishingSession = false;
+ m_resumeSessionId.clear();
+ m_sessionId = result.sessionId.isEmpty() ? resumed : result.sessionId;
+ adoptEarlyCommands();
+ if (m_ledger.hasActiveTurn())
+ sendPrompt();
+ })
+ .onFailed(this, [this, generation](const std::exception &e) {
+ if (generation != m_clientGeneration)
+ return;
+ m_establishingSession = false;
+ m_resumeSessionId.clear();
+ stopKnowledgeServer();
+ const QString reason = QString::fromUtf8(e.what());
+ LOG_MESSAGE(QString("Could not reopen the agent session: %1").arg(reason));
+ failTurn(
+ tr("The previous agent session could not be reopened, so the transcript below is "
+ "read-only. Start a new session to continue with this agent."),
+ false);
+ emit agentSessionUnavailable(reason);
+ });
+}
+
+void AcpChatBackend::startSession()
+{
+ LLMQore::Acp::NewSessionParams params;
+ params.cwd = m_workingDirectory;
+ params.mcpServers = knowledgeServers();
+
+ const int generation = m_clientGeneration;
+ m_establishingSession = true;
+
+ m_client->newSession(params, std::chrono::seconds(60))
+ .then(
+ this,
+ [this, generation](const LLMQore::Acp::NewSessionResult &result) {
+ if (generation != m_clientGeneration)
+ return;
+ m_establishingSession = false;
+ m_sessionId = result.sessionId;
+ adoptEarlyCommands();
+ if (m_ledger.hasActiveTurn())
+ sendPrompt();
+ })
+ .onFailed(
+ this,
+ [this, generation](const LLMQore::Rpc::RemoteError &error) {
+ if (generation != m_clientGeneration)
+ return;
+ m_establishingSession = false;
+ const bool needsAuthentication = error.code() == authRequiredErrorCode
+ && !m_authenticated
+ && !m_agentInfo.authMethods.isEmpty();
+ if (needsAuthentication)
+ authenticateAndRetry();
+ else
+ failTurn(error.remoteMessage(), true);
+ })
+ .onFailed(this, [this, generation](const std::exception &e) {
+ if (generation != m_clientGeneration)
+ return;
+ m_establishingSession = false;
+ failTurn(QString::fromUtf8(e.what()), true);
+ });
+}
+
+void AcpChatBackend::authenticateAndRetry()
+{
+ const LLMQore::Acp::AuthMethod method = m_agentInfo.authMethods.first();
+ LOG_MESSAGE(QString("ACP agent requires authentication, using method: %1").arg(method.id));
+
+ m_authenticated = true;
+
+ const int generation = m_clientGeneration;
+ const QString turnId = m_ledger.activeTurnId();
+
+ m_client->authenticate(method.id, std::chrono::minutes(5))
+ .then(
+ this,
+ [this, generation, turnId]() {
+ if (generation != m_clientGeneration || !m_ledger.isActiveTurn(turnId))
+ return;
+ resumeOrStartSession();
+ })
+ .onFailed(this, [this, generation, turnId, method](const std::exception &e) {
+ if (generation != m_clientGeneration || !m_ledger.isActiveTurn(turnId))
+ return;
+ failTurn(
+ tr("Authentication (%1) failed: %2")
+ .arg(method.name.isEmpty() ? method.id : method.name)
+ .arg(QString::fromUtf8(e.what())),
+ true);
+ });
+}
+
+QList AcpChatBackend::buildPrompt() const
+{
+ QList blocks;
+
+ if (!m_handoverSummary.isEmpty()) {
+ blocks.append(
+ LLMQore::Acp::ContentBlock::makeText(
+ tr("This conversation continues an earlier session you do not have. Here is a "
+ "summary of what happened before, prepared by QodeAssist:\n\n%1")
+ .arg(m_handoverSummary)));
+ }
+
+ for (const Session::ContentBlock &block : m_pendingTurn.userBlocks) {
+ if (const auto *text = std::get_if(&block)) {
+ if (!text->text.isEmpty())
+ blocks.append(LLMQore::Acp::ContentBlock::makeText(text->text));
+ } else if (const auto *attachment = std::get_if(&block)) {
+ appendAttachment(blocks, *attachment);
+ } else if (const auto *image = std::get_if(&block)) {
+ appendImage(blocks, *image);
+ }
+ }
+
+ return blocks;
+}
+
+void AcpChatBackend::appendAttachment(
+ QList &blocks, const Session::AttachmentBlock &attachment) const
+{
+ const QByteArray content = m_storedContentLoader
+ ? m_storedContentLoader(m_chatFilePath, attachment.storedPath)
+ : QByteArray();
+ if (content.isEmpty()) {
+ LOG_MESSAGE(QString("Could not load attachment %1 for the agent").arg(attachment.fileName));
+ blocks.append(
+ LLMQore::Acp::ContentBlock::makeText(
+ tr("The user attached %1, but it has no readable content.")
+ .arg(attachment.fileName)));
+ return;
+ }
+
+ QString text = QString::fromUtf8(content.first(qMin(content.size(), maxInlineAttachmentBytes)));
+ if (content.size() > maxInlineAttachmentBytes) {
+ LOG_MESSAGE(QString("Truncating attachment %1 from %2 to %3 bytes for the agent")
+ .arg(attachment.fileName)
+ .arg(content.size())
+ .arg(maxInlineAttachmentBytes));
+ text += QLatin1Char('\n')
+ + tr("[truncated by QodeAssist: %1 of %2 bytes shown]")
+ .arg(maxInlineAttachmentBytes)
+ .arg(content.size());
+ }
+
+ if (m_agentInfo.agentCapabilities.promptCapabilities.embeddedContext) {
+ blocks.append(
+ makeResource(
+ attachmentUri(attachment.fileName),
+ textMimeTypeForFileName(attachment.fileName),
+ text));
+ return;
+ }
+
+ blocks.append(
+ LLMQore::Acp::ContentBlock::makeText(
+ Session::fencedFileBlock(attachment.fileName, text)));
+}
+
+void AcpChatBackend::appendImage(
+ QList &blocks, const Session::ImageBlock &image) const
+{
+ if (!m_agentInfo.agentCapabilities.promptCapabilities.image) {
+ blocks.append(
+ LLMQore::Acp::ContentBlock::makeText(
+ tr("The user attached the image %1, which this agent cannot receive.")
+ .arg(image.fileName)));
+ return;
+ }
+
+ const QByteArray content = m_storedContentLoader
+ ? m_storedContentLoader(m_chatFilePath, image.storedPath)
+ : QByteArray();
+ if (content.isEmpty()) {
+ LOG_MESSAGE(QString("Could not load image %1 for the agent").arg(image.fileName));
+ blocks.append(
+ LLMQore::Acp::ContentBlock::makeText(
+ tr("The user attached the image %1, but it has no readable content.")
+ .arg(image.fileName)));
+ return;
+ }
+
+ if (content.size() > maxInlineImageBytes) {
+ LOG_MESSAGE(QString("Image %1 is %2 bytes, over the %3 byte limit for an agent prompt")
+ .arg(image.fileName)
+ .arg(content.size())
+ .arg(maxInlineImageBytes));
+ blocks.append(
+ LLMQore::Acp::ContentBlock::makeText(
+ tr("The user attached the image %1, which is too large to send (%2 bytes).")
+ .arg(image.fileName)
+ .arg(content.size())));
+ return;
+ }
+
+ blocks.append(makeImage(image.mediaType, QString::fromLatin1(content.toBase64())));
+}
+
+void AcpChatBackend::sendPrompt()
+{
+ const QString turnId = m_ledger.activeTurnId();
+
+ m_client->prompt(m_sessionId, buildPrompt(), std::chrono::minutes(30))
+ .then(
+ this,
+ [this, turnId](const LLMQore::Acp::PromptResult &result) {
+ if (!m_ledger.isActiveTurn(turnId))
+ return;
+
+ m_handoverSummary.clear();
+
+ const Session::Usage usage = usageFromPromptResult(result.usage);
+ if (!usage.isEmpty())
+ emit sessionEvent(
+ Session::UsageReported{.turnId = turnId, .usage = usage});
+
+ m_pendingTurn = {};
+ finishTurn(turnId);
+ emit sessionEvent(Session::TurnCompleted{.turnId = turnId});
+ })
+ .onFailed(this, [this, turnId](const std::exception &e) {
+ if (!m_ledger.isActiveTurn(turnId))
+ return;
+ failTurn(QString::fromUtf8(e.what()), false);
+ });
+}
+
+void AcpChatBackend::connectClient()
+{
+ connect(
+ m_client,
+ &LLMQore::Acp::AcpClient::agentMessageChunk,
+ this,
+ [this](const QString &, const LLMQore::Acp::ContentBlock &content) {
+ const QString text = textOf(content);
+ if (!m_ledger.hasActiveTurn() || text.isEmpty())
+ return;
+ emit sessionEvent(Session::TextDelta{.turnId = m_ledger.activeTurnId(), .text = text});
+ });
+
+ connect(
+ m_client,
+ &LLMQore::Acp::AcpClient::agentThoughtChunk,
+ this,
+ [this](const QString &, const LLMQore::Acp::ContentBlock &content) {
+ const QString text = textOf(content);
+ if (!m_ledger.hasActiveTurn() || text.isEmpty())
+ return;
+ emit sessionEvent(
+ Session::ThinkingReceived{.turnId = m_ledger.activeTurnId(), .text = text});
+ });
+
+ const auto onToolCall
+ = [this](const QString &sessionId, const LLMQore::Acp::ToolCall &toolCall) {
+ if (!m_ledger.hasActiveTurn() || sessionId != m_sessionId)
+ return;
+ emit sessionEvent(
+ Session::ToolCallUpdated{
+ .turnId = m_ledger.activeTurnId(),
+ .toolId = singleLineAgentText(toolCall.toolCallId, maxPermissionTitleLength),
+ .name = singleLineAgentText(toolCall.title, maxPermissionTitleLength),
+ .kind = singleLineAgentText(toolCall.kind, maxPermissionKindLength),
+ .status = singleLineAgentText(toolCall.status, maxPermissionKindLength),
+ .result = toolResultText(toolCall),
+ .details = toolDetailsOf(toolCall),
+ .fromAgent = true});
+ };
+
+ connect(m_client, &LLMQore::Acp::AcpClient::toolCallStarted, this, onToolCall);
+ connect(m_client, &LLMQore::Acp::AcpClient::toolCallUpdated, this, onToolCall);
+
+ connect(
+ m_client,
+ &LLMQore::Acp::AcpClient::planUpdated,
+ this,
+ [this](const QString &, const LLMQore::Acp::Plan &plan) {
+ if (!m_ledger.hasActiveTurn())
+ return;
+
+ QList