From bbba9b9023761a9eca958b6e90cdabb03983add3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Mon, 9 Mar 2026 20:19:10 -0300 Subject: [PATCH] ACP WIP --- bin/assets/plugins/aiassistant.json | 21 ++ .../ecode/plugins/aiassistant/acpclient.cpp | 233 +++++++++++++++ .../ecode/plugins/aiassistant/acpclient.hpp | 108 +++++++ .../ecode/plugins/aiassistant/acpprotocol.cpp | 266 +++++++++++++++++ .../ecode/plugins/aiassistant/acpprotocol.hpp | 254 ++++++++++++++++ .../plugins/aiassistant/agentsession.cpp | 150 ++++++++++ .../plugins/aiassistant/agentsession.hpp | 56 ++++ .../plugins/aiassistant/aiassistantplugin.cpp | 37 +++ .../plugins/aiassistant/aiassistantplugin.hpp | 3 + .../ecode/plugins/aiassistant/chatui.cpp | 279 +++++++++++++++++- .../ecode/plugins/aiassistant/chatui.hpp | 23 ++ .../ecode/plugins/aiassistant/protocol.hpp | 13 +- 12 files changed, 1438 insertions(+), 5 deletions(-) create mode 100644 src/tools/ecode/plugins/aiassistant/acpclient.cpp create mode 100644 src/tools/ecode/plugins/aiassistant/acpclient.hpp create mode 100644 src/tools/ecode/plugins/aiassistant/acpprotocol.cpp create mode 100644 src/tools/ecode/plugins/aiassistant/acpprotocol.hpp create mode 100644 src/tools/ecode/plugins/aiassistant/agentsession.cpp create mode 100644 src/tools/ecode/plugins/aiassistant/agentsession.hpp diff --git a/bin/assets/plugins/aiassistant.json b/bin/assets/plugins/aiassistant.json index cd1e05654..cce3ba11d 100644 --- a/bin/assets/plugins/aiassistant.json +++ b/bin/assets/plugins/aiassistant.json @@ -501,5 +501,26 @@ "fetch_models_url": "http://localhost:8080/api/tags", "open_api": true } + }, + "agents": { + "gemini-cli": { + "enabled": true, + "command": "gemini", + "args": ["--experimental-acp"] + }, + "opencode": { + "enabled": true, + "command": "opencode", + "args": ["acp"] + }, + "claude-agent": { + "enabled": true, + "command": "claude-agent-acp" + }, + "cursor": { + "enabled": true, + "command": "cursor", + "args": ["acp"] + } } } diff --git a/src/tools/ecode/plugins/aiassistant/acpclient.cpp b/src/tools/ecode/plugins/aiassistant/acpclient.cpp new file mode 100644 index 000000000..f91b13271 --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/acpclient.cpp @@ -0,0 +1,233 @@ +#include "acpclient.hpp" +#include + +namespace ecode { namespace acp { + +ACPClient::ACPClient( std::shared_ptr threadPool, const Config& config ) : + mThreadPool( threadPool ), mConfig( config ) {} + +ACPClient::~ACPClient() { + stop(); +} + +bool ACPClient::start() { + auto flags = Process::getDefaultOptions() | Process::EnableAsync | Process::UseAbsolutePath; + bool ret = mProcess.create( mConfig.command, mConfig.args, flags, mConfig.environment, + mConfig.workingDirectory ); + if ( ret && mProcess.isAlive() ) { + mProcess.startAsyncRead( + [this]( const char* bytes, size_t n ) { readStdOut( bytes, n ); }, + [this]( const char* bytes, size_t n ) { readStdErr( bytes, n ); } ); + return true; + } + return false; +} + +void ACPClient::stop() { + mShuttingDown = true; + if ( mProcess.isAlive() ) { + mProcess.kill(); + } +} + +bool ACPClient::isRunning() { + return !mShuttingDown && mProcess.isAlive(); +} + +bool ACPClient::isReady() { + return mReady; +} + +void ACPClient::readStdOut( const char* bytes, size_t n ) { + mReceiveBuffer.append( bytes, n ); + size_t pos; + while ( ( pos = mReceiveBuffer.find( '\n' ) ) != std::string::npos ) { + std::string line = mReceiveBuffer.substr( 0, pos ); + mReceiveBuffer.erase( 0, pos + 1 ); + if ( line.empty() || line == "\r" ) + continue; + + try { + json msg = json::parse( line ); + Log::debug( "ACPClient RECV: %s", line.c_str() ); + processMessage( msg ); + } catch ( const json::exception& e ) { + Log::error( "ACPClient JSON parse error: %s\nLine: %s", e.what(), line.c_str() ); + } + } +} + +void ACPClient::readStdErr( const char* bytes, size_t n ) { + std::string err( bytes, n ); + Log::debug( "ACPClient stderr: %s", err.c_str() ); +} + +void ACPClient::processMessage( const json& msg ) { + if ( msg.contains( "method" ) ) { + if ( msg.contains( "id" ) ) { + processRequest( msg ); + } else { + processNotification( msg ); + } + } else if ( msg.contains( "result" ) || msg.contains( "error" ) ) { + processResponse( msg ); + } +} + +void ACPClient::processRequest( const json& msg ) { + std::string method = msg.value( "method", "" ); + json id = msg["id"]; + + if ( method == "fs/read_text_file" && onReadTextFile ) { + ReadTextFileRequest req( msg.value( "params", json::object() ) ); + onReadTextFile( req, [this, id]( const ReadTextFileResponse& res ) { + sendResponse( id, res.toJson() ); + } ); + } else if ( method == "fs/write_text_file" && onWriteTextFile ) { + WriteTextFileRequest req( msg.value( "params", json::object() ) ); + onWriteTextFile( req, [this, id]( const WriteTextFileResponse& res ) { + sendResponse( id, res.toJson() ); + } ); + } else if ( method == "session/request_permission" && onRequestPermission ) { + RequestPermissionRequest req( msg.value( "params", json::object() ) ); + onRequestPermission( req, [this, id]( const RequestPermissionResponse& res ) { + sendResponse( id, res.toJson() ); + } ); + } else if ( method == "terminal/create" && onCreateTerminal ) { + CreateTerminalRequest req( msg.value( "params", json::object() ) ); + onCreateTerminal( req, [this, id]( const CreateTerminalResponse& res ) { + sendResponse( id, res.toJson() ); + } ); + } else if ( method == "terminal/output" && onTerminalOutput ) { + TerminalOutputRequest req( msg.value( "params", json::object() ) ); + onTerminalOutput( req, [this, id]( const TerminalOutputResponse& res ) { + sendResponse( id, res.toJson() ); + } ); + } else if ( method == "terminal/kill" && onKillTerminal ) { + KillTerminalRequest req( msg.value( "params", json::object() ) ); + onKillTerminal( req, [this, id]( const KillTerminalResponse& res ) { + sendResponse( id, res.toJson() ); + } ); + } else if ( method == "terminal/release" && onReleaseTerminal ) { + ReleaseTerminalRequest req( msg.value( "params", json::object() ) ); + onReleaseTerminal( req, [this, id]( const ReleaseTerminalResponse& res ) { + sendResponse( id, res.toJson() ); + } ); + } else if ( method == "terminal/wait_for_exit" && onWaitForTerminalExit ) { + WaitForTerminalExitRequest req( msg.value( "params", json::object() ) ); + onWaitForTerminalExit( req, [this, id]( const WaitForTerminalExitResponse& res ) { + sendResponse( id, res.toJson() ); + } ); + } else { + sendError( id, -32601, "Method not found: " + method ); + } +} + +void ACPClient::processNotification( const json& msg ) { + std::string method = msg.value( "method", "" ); + if ( method == "session/update" && onSessionUpdate ) { + auto params = msg.value( "params", json::object() ); + if ( params.contains( "update" ) ) { + onSessionUpdate( params["update"] ); + } else { + onSessionUpdate( params ); // Fallback if schema shifts or malformed + } + } +} + +void ACPClient::processResponse( const json& msg ) { + if ( !msg.contains( "id" ) || !msg["id"].is_number_integer() ) + return; + IdType id = msg["id"].get(); + + JsonReplyHandler handler; + { + Lock l( mHandlersMutex ); + auto it = mHandlers.find( id ); + if ( it != mHandlers.end() ) { + handler = it->second; + mHandlers.erase( it ); + } + } + + if ( handler ) { + handler( id, msg ); + } +} + +int ACPClient::write( json&& msg, const JsonReplyHandler& h ) { + msg["jsonrpc"] = "2.0"; + int msgId = 0; + + if ( h ) { + msgId = ++mLastMsgId; + msg["id"] = msgId; + Lock l( mHandlersMutex ); + mHandlers[msgId] = h; + } + + std::string out = msg.dump() + "\n"; + if ( isRunning() ) { + Log::debug( "ACPClient SEND: %s", out.c_str() ); + mProcess.write( out ); + } + return msgId; +} + +void ACPClient::sendResponse( const json& id, json&& result ) { + json msg = { { "jsonrpc", "2.0" }, { "id", id }, { "result", result } }; + std::string out = msg.dump() + "\n"; + if ( isRunning() ) { + Log::debug( "ACPClient SEND: %s", out.c_str() ); + mProcess.write( out ); + } +} + +void ACPClient::sendError( const json& id, int code, const std::string& message ) { + json msg = { { "jsonrpc", "2.0" }, + { "id", id }, + { "error", { { "code", code }, { "message", message } } } }; + std::string out = msg.dump() + "\n"; + if ( isRunning() ) { + Log::debug( "ACPClient SEND: %s", out.c_str() ); + mProcess.write( out ); + } +} + +void ACPClient::initialize( const InitializeRequest& req, + const std::function& cb ) { + write( { { "method", "initialize" }, { "params", req.toJson() } }, + [this, cb]( const IdType&, const json& resp ) { + if ( resp.contains( "result" ) ) { + mReady = true; + if ( cb ) + cb( InitializeResponse( resp["result"] ) ); + } + } ); +} + +void ACPClient::newSession( const NewSessionRequest& req, + const std::function& cb ) { + write( { { "method", "session/new" }, { "params", req.toJson() } }, + [cb]( const IdType&, const json& resp ) { + if ( resp.contains( "result" ) && cb ) { + cb( NewSessionResponse( resp["result"] ) ); + } + } ); +} + +void ACPClient::prompt( const PromptRequest& req, + const std::function& cb ) { + write( { { "method", "session/prompt" }, { "params", req.toJson() } }, + [cb]( const IdType&, const json& resp ) { + if ( resp.contains( "result" ) && cb ) { + cb( PromptResponse( resp["result"] ) ); + } + } ); +} + +void ACPClient::cancel( const std::string& sessionId ) { + write( { { "method", "session/cancel" }, { "params", { { "sessionId", sessionId } } } } ); +} + +}} // namespace ecode::acp diff --git a/src/tools/ecode/plugins/aiassistant/acpclient.hpp b/src/tools/ecode/plugins/aiassistant/acpclient.hpp new file mode 100644 index 000000000..35227345d --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/acpclient.hpp @@ -0,0 +1,108 @@ +#pragma once + +#include "acpprotocol.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +using namespace EE; +using namespace EE::System; + +namespace ecode { namespace acp { + +class ACPClient { + public: + using IdType = int; + using JsonReplyHandler = std::function; + + struct Config { + std::string command; + std::vector args; + std::string workingDirectory; + std::unordered_map environment; + }; + + ACPClient( std::shared_ptr threadPool, const Config& config ); + ~ACPClient(); + + bool start(); + void stop(); + + bool isRunning(); + bool isReady(); + + const Config& getConfig() const { return mConfig; } + + void initialize( const InitializeRequest& req, + const std::function& cb ); + void newSession( const NewSessionRequest& req, + const std::function& cb ); + void prompt( const PromptRequest& req, const std::function& cb ); + + // Notifications to agent + void cancel( const std::string& sessionId ); + + // Callbacks from agent + std::function onSessionUpdate; + std::function )> + onReadTextFile; + std::function )> + onWriteTextFile; + std::function )> + onRequestPermission; + std::function )> + onCreateTerminal; + std::function )> + onTerminalOutput; + std::function )> + onKillTerminal; + std::function )> + onReleaseTerminal; + std::function )> + onWaitForTerminalExit; + + protected: + std::shared_ptr mThreadPool; + Config mConfig; + Process mProcess; + bool mReady{ false }; + bool mShuttingDown{ false }; + std::atomic mLastMsgId{ 0 }; + + Mutex mHandlersMutex; + std::map mHandlers; + + std::string mReceiveBuffer; + + void readStdOut( const char* bytes, size_t n ); + void readStdErr( const char* bytes, size_t n ); + + void processMessage( const json& msg ); + void processRequest( const json& msg ); + void processNotification( const json& msg ); + void processResponse( const json& msg ); + + int write( json&& msg, const JsonReplyHandler& h = nullptr ); + void sendResponse( const json& id, json&& result ); + void sendError( const json& id, int code, const std::string& message ); +}; + +}} // namespace ecode::acp diff --git a/src/tools/ecode/plugins/aiassistant/acpprotocol.cpp b/src/tools/ecode/plugins/aiassistant/acpprotocol.cpp new file mode 100644 index 000000000..03bc4c705 --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/acpprotocol.cpp @@ -0,0 +1,266 @@ +#include "acpprotocol.hpp" + +namespace ecode { namespace acp { + +ClientCapabilities::ClientCapabilities( const json& body ) { + if ( body.contains( "terminal" ) ) + terminal = body.value( "terminal", false ); + if ( body.contains( "fs" ) ) { + auto fs = body["fs"]; + if ( fs.contains( "readTextFile" ) ) + fsReadTextFile = fs.value( "readTextFile", false ); + if ( fs.contains( "writeTextFile" ) ) + fsWriteTextFile = fs.value( "writeTextFile", false ); + } +} + +json ClientCapabilities::toJson() const { + return { + { "terminal", terminal }, + { "fs", { { "readTextFile", fsReadTextFile }, { "writeTextFile", fsWriteTextFile } } } }; +} + +AgentCapabilities::AgentCapabilities( const json& body ) { + if ( body.contains( "loadSession" ) ) + loadSession = body.value( "loadSession", false ); + if ( body.contains( "mcpCapabilities" ) ) + mcpCapabilities = body["mcpCapabilities"]; + if ( body.contains( "promptCapabilities" ) ) + promptCapabilities = body["promptCapabilities"]; + if ( body.contains( "sessionCapabilities" ) ) + sessionCapabilities = body["sessionCapabilities"]; +} + +json InitializeRequest::toJson() const { + return { { "protocolVersion", protocolVersion }, + { "clientCapabilities", clientCapabilities.toJson() } }; +} + +InitializeResponse::InitializeResponse( const json& body ) { + if ( body.contains( "protocolVersion" ) ) + protocolVersion = body.value( "protocolVersion", 1 ); + if ( body.contains( "agentCapabilities" ) ) + agentCapabilities = AgentCapabilities( body["agentCapabilities"] ); +} + +json NewSessionRequest::toJson() const { + json j = { { "cwd", cwd } }; + if ( mcpServers.is_null() ) { + j["mcpServers"] = json::array(); + } else { + j["mcpServers"] = mcpServers; + } + return j; +} + +NewSessionResponse::NewSessionResponse( const json& body ) { + if ( body.contains( "sessionId" ) ) + sessionId = body.value( "sessionId", "" ); + if ( body.contains( "configOptions" ) ) + configOptions = body["configOptions"]; +} + +json PromptRequest::toJson() const { + return { { "sessionId", sessionId }, { "prompt", prompt } }; +} + +PromptResponse::PromptResponse( const json& body ) { + if ( body.contains( "stopReason" ) && body["stopReason"].is_string() ) + stopReason = body.value( "stopReason", "" ); +} + +ToolCallLocation::ToolCallLocation( const json& body ) { + if ( body.contains( "path" ) ) + path = body.value( "path", "" ); + if ( body.contains( "line" ) && !body["line"].is_null() ) + line = body["line"].get(); +} + +ToolCall::ToolCall( const json& body ) { + if ( body.contains( "title" ) ) + title = body.value( "title", "" ); + if ( body.contains( "toolCallId" ) ) + toolCallId = body.value( "toolCallId", "" ); + if ( body.contains( "kind" ) && body["kind"].is_string() ) + kind = body.value( "kind", "" ); + if ( body.contains( "rawInput" ) ) + rawInput = body["rawInput"]; + if ( body.contains( "rawOutput" ) ) + rawOutput = body["rawOutput"]; + if ( body.contains( "locations" ) && body["locations"].is_array() ) { + for ( const auto& l : body["locations"] ) { + locations.push_back( ToolCallLocation( l ) ); + } + } +} + +ReadTextFileRequest::ReadTextFileRequest( const json& body ) { + if ( body.contains( "sessionId" ) ) + sessionId = body.value( "sessionId", "" ); + if ( body.contains( "path" ) ) + path = body.value( "path", "" ); + if ( body.contains( "line" ) && !body["line"].is_null() ) + line = body["line"].get(); + if ( body.contains( "limit" ) && !body["limit"].is_null() ) + limit = body["limit"].get(); +} + +json ReadTextFileResponse::toJson() const { + return { { "content", content } }; +} + +WriteTextFileRequest::WriteTextFileRequest( const json& body ) { + if ( body.contains( "sessionId" ) ) + sessionId = body.value( "sessionId", "" ); + if ( body.contains( "path" ) ) + path = body.value( "path", "" ); + if ( body.contains( "content" ) ) + content = body.value( "content", "" ); +} + +json WriteTextFileResponse::toJson() const { + return json::object(); +} + +EnvVariable::EnvVariable( const json& body ) { + if ( body.contains( "name" ) ) + name = body.value( "name", "" ); + if ( body.contains( "value" ) ) + value = body.value( "value", "" ); +} + +json EnvVariable::toJson() const { + return { { "name", name }, { "value", value } }; +} + +CreateTerminalRequest::CreateTerminalRequest( const json& body ) { + if ( body.contains( "sessionId" ) ) + sessionId = body.value( "sessionId", "" ); + if ( body.contains( "command" ) ) + command = body.value( "command", "" ); + if ( body.contains( "args" ) && body["args"].is_array() ) { + for ( const auto& a : body["args"] ) + args.push_back( a.get() ); + } + if ( body.contains( "cwd" ) && !body["cwd"].is_null() ) + cwd = body["cwd"].get(); + if ( body.contains( "env" ) && body["env"].is_array() ) { + for ( const auto& e : body["env"] ) + env.push_back( EnvVariable( e ) ); + } + if ( body.contains( "outputByteLimit" ) && !body["outputByteLimit"].is_null() ) + outputByteLimit = body["outputByteLimit"].get(); +} + +json CreateTerminalResponse::toJson() const { + return { { "terminalId", terminalId } }; +} + +TerminalExitStatus::TerminalExitStatus( const json& body ) { + if ( body.contains( "exitCode" ) && !body["exitCode"].is_null() ) + exitCode = body["exitCode"].get(); + if ( body.contains( "signal" ) && !body["signal"].is_null() ) + signal = body["signal"].get(); +} + +json TerminalExitStatus::toJson() const { + json j; + if ( exitCode ) + j["exitCode"] = *exitCode; + else + j["exitCode"] = nullptr; + if ( signal ) + j["signal"] = *signal; + else + j["signal"] = nullptr; + return j; +} + +TerminalOutputRequest::TerminalOutputRequest( const json& body ) { + if ( body.contains( "sessionId" ) ) + sessionId = body.value( "sessionId", "" ); + if ( body.contains( "terminalId" ) ) + terminalId = body.value( "terminalId", "" ); +} + +json TerminalOutputResponse::toJson() const { + json j = { { "output", output }, { "truncated", truncated } }; + if ( exitStatus ) + j["exitStatus"] = exitStatus->toJson(); + return j; +} + +KillTerminalRequest::KillTerminalRequest( const json& body ) { + if ( body.contains( "sessionId" ) ) + sessionId = body.value( "sessionId", "" ); + if ( body.contains( "terminalId" ) ) + terminalId = body.value( "terminalId", "" ); +} + +json KillTerminalResponse::toJson() const { + return json::object(); +} + +ReleaseTerminalRequest::ReleaseTerminalRequest( const json& body ) { + if ( body.contains( "sessionId" ) ) + sessionId = body.value( "sessionId", "" ); + if ( body.contains( "terminalId" ) ) + terminalId = body.value( "terminalId", "" ); +} + +json ReleaseTerminalResponse::toJson() const { + return json::object(); +} + +WaitForTerminalExitRequest::WaitForTerminalExitRequest( const json& body ) { + if ( body.contains( "sessionId" ) ) + sessionId = body.value( "sessionId", "" ); + if ( body.contains( "terminalId" ) ) + terminalId = body.value( "terminalId", "" ); +} + +json WaitForTerminalExitResponse::toJson() const { + json j; + if ( exitCode ) + j["exitCode"] = *exitCode; + else + j["exitCode"] = nullptr; + if ( signal ) + j["signal"] = *signal; + else + j["signal"] = nullptr; + return j; +} + +PermissionOption::PermissionOption( const json& body ) { + if ( body.contains( "optionId" ) ) + optionId = body.value( "optionId", "" ); + if ( body.contains( "name" ) ) + name = body.value( "name", "" ); + if ( body.contains( "kind" ) ) + kind = body.value( "kind", "" ); +} + +json PermissionOption::toJson() const { + return { { "optionId", optionId }, { "name", name }, { "kind", kind } }; +} + +RequestPermissionRequest::RequestPermissionRequest( const json& body ) { + if ( body.contains( "sessionId" ) ) + sessionId = body.value( "sessionId", "" ); + if ( body.contains( "toolCall" ) ) + toolCall = ToolCall( body["toolCall"] ); + if ( body.contains( "options" ) && body["options"].is_array() ) { + for ( const auto& o : body["options"] ) + options.push_back( PermissionOption( o ) ); + } +} + +json RequestPermissionResponse::toJson() const { + json j = { { "outcome", outcome } }; + if ( optionId ) + j["optionId"] = *optionId; + return j; +} + +}} // namespace ecode::acp diff --git a/src/tools/ecode/plugins/aiassistant/acpprotocol.hpp b/src/tools/ecode/plugins/aiassistant/acpprotocol.hpp new file mode 100644 index 000000000..b7873985f --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/acpprotocol.hpp @@ -0,0 +1,254 @@ +#pragma once + +#include +#include +#include +#include + +#include + +using json = nlohmann::json; + +namespace ecode { namespace acp { + +struct ClientCapabilities { + bool terminal{ false }; + bool fsReadTextFile{ false }; + bool fsWriteTextFile{ false }; + + ClientCapabilities() = default; + ClientCapabilities( const json& body ); + json toJson() const; +}; + +struct AgentCapabilities { + bool loadSession{ false }; + json mcpCapabilities; + json promptCapabilities; + json sessionCapabilities; + + AgentCapabilities() = default; + AgentCapabilities( const json& body ); +}; + +struct InitializeRequest { + int protocolVersion{ 1 }; + ClientCapabilities clientCapabilities; + + InitializeRequest() = default; + json toJson() const; +}; + +struct InitializeResponse { + int protocolVersion{ 1 }; + AgentCapabilities agentCapabilities; + + InitializeResponse() = default; + InitializeResponse( const json& body ); +}; + +struct NewSessionRequest { + std::string cwd; + json mcpServers; + + NewSessionRequest() = default; + json toJson() const; +}; + +struct NewSessionResponse { + std::string sessionId; + json configOptions; + + NewSessionResponse() = default; + NewSessionResponse( const json& body ); +}; + +struct PromptRequest { + std::string sessionId; + json prompt; // Array of ContentBlock + + PromptRequest() = default; + json toJson() const; +}; + +struct PromptResponse { + std::string stopReason; + + PromptResponse() = default; + PromptResponse( const json& body ); +}; + +struct ToolCallLocation { + std::string path; + std::optional line; + + ToolCallLocation() = default; + ToolCallLocation( const json& body ); +}; + +struct ToolCall { + std::string title; + std::string toolCallId; + std::string kind; + json rawInput; + json rawOutput; + std::vector locations; + + ToolCall() = default; + ToolCall( const json& body ); +}; + +struct ReadTextFileRequest { + std::string sessionId; + std::string path; + std::optional line; + std::optional limit; + + ReadTextFileRequest() = default; + ReadTextFileRequest( const json& body ); +}; + +struct ReadTextFileResponse { + std::string content; + + ReadTextFileResponse() = default; + json toJson() const; +}; + +struct WriteTextFileRequest { + std::string sessionId; + std::string path; + std::string content; + + WriteTextFileRequest() = default; + WriteTextFileRequest( const json& body ); +}; + +struct WriteTextFileResponse { + WriteTextFileResponse() = default; + json toJson() const; +}; + +struct EnvVariable { + std::string name; + std::string value; + EnvVariable() = default; + EnvVariable( const json& body ); + json toJson() const; +}; + +struct CreateTerminalRequest { + std::string sessionId; + std::string command; + std::vector args; + std::optional cwd; + std::vector env; + std::optional outputByteLimit; + + CreateTerminalRequest() = default; + CreateTerminalRequest( const json& body ); +}; + +struct CreateTerminalResponse { + std::string terminalId; + + CreateTerminalResponse() = default; + json toJson() const; +}; + +struct TerminalExitStatus { + std::optional exitCode; + std::optional signal; + + TerminalExitStatus() = default; + TerminalExitStatus( const json& body ); + json toJson() const; +}; + +struct TerminalOutputRequest { + std::string sessionId; + std::string terminalId; + + TerminalOutputRequest() = default; + TerminalOutputRequest( const json& body ); +}; + +struct TerminalOutputResponse { + std::string output; + bool truncated{ false }; + std::optional exitStatus; + + TerminalOutputResponse() = default; + json toJson() const; +}; + +struct KillTerminalRequest { + std::string sessionId; + std::string terminalId; + + KillTerminalRequest() = default; + KillTerminalRequest( const json& body ); +}; + +struct KillTerminalResponse { + KillTerminalResponse() = default; + json toJson() const; +}; + +struct ReleaseTerminalRequest { + std::string sessionId; + std::string terminalId; + + ReleaseTerminalRequest() = default; + ReleaseTerminalRequest( const json& body ); +}; + +struct ReleaseTerminalResponse { + ReleaseTerminalResponse() = default; + json toJson() const; +}; + +struct WaitForTerminalExitRequest { + std::string sessionId; + std::string terminalId; + + WaitForTerminalExitRequest() = default; + WaitForTerminalExitRequest( const json& body ); +}; + +struct WaitForTerminalExitResponse { + std::optional exitCode; + std::optional signal; + + WaitForTerminalExitResponse() = default; + json toJson() const; +}; + +struct PermissionOption { + std::string optionId; + std::string name; + std::string kind; + + PermissionOption() = default; + PermissionOption( const json& body ); + json toJson() const; +}; + +struct RequestPermissionRequest { + std::string sessionId; + ToolCall toolCall; + std::vector options; + + RequestPermissionRequest() = default; + RequestPermissionRequest( const json& body ); +}; + +struct RequestPermissionResponse { + std::string outcome; + std::optional optionId; + + RequestPermissionResponse() = default; + json toJson() const; +}; + +}} // namespace ecode::acp diff --git a/src/tools/ecode/plugins/aiassistant/agentsession.cpp b/src/tools/ecode/plugins/aiassistant/agentsession.cpp new file mode 100644 index 000000000..08b6b140c --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/agentsession.cpp @@ -0,0 +1,150 @@ +#include "agentsession.hpp" +#include +#include + +namespace ecode { namespace acp { + +AgentSession::AgentSession( std::shared_ptr threadPool, + const ACPClient::Config& config ) : + mThreadPool( threadPool ), mClient( std::make_unique( threadPool, config ) ) { + setupClient(); +} + +AgentSession::~AgentSession() { + stop(); +} + +bool AgentSession::start( const std::function& onReady ) { + if ( mClient->start() ) { + InitializeRequest req; + req.clientCapabilities.terminal = true; + req.clientCapabilities.fsReadTextFile = true; + req.clientCapabilities.fsWriteTextFile = true; + + mClient->initialize( req, [this, onReady]( const InitializeResponse& ) { + NewSessionRequest nreq; + nreq.cwd = mClient->isReady() ? mClient->getConfig().workingDirectory : ""; + mClient->newSession( nreq, [this, onReady]( const NewSessionResponse& nres ) { + mSessionId = nres.sessionId; + if ( onReady ) + onReady( true ); + } ); + } ); + return true; + } + if ( onReady ) + onReady( false ); + return false; +} + +void AgentSession::stop() { + if ( mClient ) + mClient->stop(); +} + +void AgentSession::prompt( const PromptRequest& req, + const std::function& cb ) { + mIsPrompting = true; + mClient->prompt( req, [this, cb](const PromptResponse& res) { + mIsPrompting = false; + if ( cb ) + cb(res); + } ); +} + +void AgentSession::cancel() { + if ( mClient && !mSessionId.empty() ) { + mClient->cancel( mSessionId ); + } +} + +void AgentSession::setupClient() { + mClient->onSessionUpdate = [this]( const json& msg ) { + if ( onSessionUpdate ) + onSessionUpdate( msg ); + }; + + mClient->onReadTextFile = []( const ReadTextFileRequest& req, auto cb ) { + ReadTextFileResponse res; + std::string content; + if ( FileSystem::fileGet( req.path, content ) ) { + res.content = content; + } + cb( res ); + }; + + mClient->onWriteTextFile = []( const WriteTextFileRequest& req, auto cb ) { + FileSystem::fileWrite( req.path, req.content ); + cb( WriteTextFileResponse() ); + }; + + mClient->onRequestPermission = [this]( const RequestPermissionRequest& req, auto cb ) { + if ( onRequestPermission ) { + onRequestPermission( req, cb ); + } else { + RequestPermissionResponse res; + res.outcome = "rejected"; + cb( res ); + } + }; + + mClient->onCreateTerminal = [this]( const CreateTerminalRequest& req, auto cb ) { + CreateTerminalResponse res; + std::string termId = String::format( "term-%u", String::hash( req.command ) ); + res.terminalId = termId; + // Wait for UI? No, ACPClient is running in threads. We just trigger the event + // and return the ID. + if ( onTerminalCreated ) { + onTerminalCreated( nullptr, termId ); + } + cb( res ); + }; + + mClient->onTerminalOutput = [this]( const TerminalOutputRequest& req, auto cb ) { + TerminalOutputResponse res; + res.output = ""; + res.truncated = false; + auto it = mTerminals.find( req.terminalId ); + if ( it != mTerminals.end() && it->second.emulator ) { + if ( it->second.emulator->hasExited() ) { + TerminalExitStatus status; + status.exitCode = it->second.emulator->getExitCode(); + res.exitStatus = status; + } + } + cb( res ); + }; + + mClient->onKillTerminal = [this]( const KillTerminalRequest& req, auto cb ) { + auto it = mTerminals.find( req.terminalId ); + if ( it != mTerminals.end() && it->second.emulator ) { + it->second.emulator->terminate(); + } + cb( KillTerminalResponse() ); + }; + + mClient->onReleaseTerminal = [this]( const ReleaseTerminalRequest& req, auto cb ) { + auto it = mTerminals.find( req.terminalId ); + if ( it != mTerminals.end() ) { + if ( it->second.emulator ) + it->second.emulator->terminate(); + if ( it->second.uiTerm ) + it->second.uiTerm->close(); + mTerminals.erase( it ); + } + cb( ReleaseTerminalResponse() ); + }; + + mClient->onWaitForTerminalExit = [this]( const WaitForTerminalExitRequest& req, auto cb ) { + WaitForTerminalExitResponse res; + auto it = mTerminals.find( req.terminalId ); + if ( it != mTerminals.end() && it->second.emulator ) { + if ( it->second.emulator->hasExited() ) { + res.exitCode = it->second.emulator->getExitCode(); + } + } + cb( res ); + }; +} + +}} // namespace ecode::acp diff --git a/src/tools/ecode/plugins/aiassistant/agentsession.hpp b/src/tools/ecode/plugins/aiassistant/agentsession.hpp new file mode 100644 index 000000000..9cb6f1920 --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/agentsession.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include "acpclient.hpp" +#include +#include +#include +#include +#include +#include +#include + +using namespace EE::System; +using namespace eterm::Terminal; +using namespace eterm::UI; + +namespace ecode { namespace acp { + +class AgentSession { + public: + AgentSession( std::shared_ptr threadPool, const ACPClient::Config& config ); + ~AgentSession(); + + bool start( const std::function& onReady ); + void stop(); + + void prompt( const PromptRequest& req, const std::function& cb ); + void cancel(); + + bool isPrompting() const { return mIsPrompting; } + + std::function onSessionUpdate; + std::function )> + onRequestPermission; + std::function onTerminalCreated; + + std::string getSessionId() const { return mSessionId; } + ACPClient* getClient() const { return mClient.get(); } + + protected: + std::shared_ptr mThreadPool; + std::unique_ptr mClient; + std::string mSessionId; + bool mIsPrompting{ false }; + + struct TermData { + std::shared_ptr display; + std::shared_ptr emulator; + UITerminal* uiTerm{ nullptr }; + }; + std::unordered_map mTerminals; + + void setupClient(); +}; + +}} // namespace ecode::acp diff --git a/src/tools/ecode/plugins/aiassistant/aiassistantplugin.cpp b/src/tools/ecode/plugins/aiassistant/aiassistantplugin.cpp index ce6a0241b..8e4a8b96b 100644 --- a/src/tools/ecode/plugins/aiassistant/aiassistantplugin.cpp +++ b/src/tools/ecode/plugins/aiassistant/aiassistantplugin.cpp @@ -108,6 +108,34 @@ static std::map parseLLMProviders( const nlohmann::jso return providers; } +static std::map parseACPAgents( const nlohmann::json& j ) { + std::map agents; + for ( const auto& item : j.items() ) { + std::string agentName = item.key(); + const auto& agentJson = item.value(); + + ACPAgent agent; + agent.name = agentName; + agent.enabled = agentJson.value( "enabled", true ); + agent.command = agentJson.value( "command", "" ); + + if ( agentJson.contains( "args" ) && agentJson["args"].is_array() ) { + for ( const auto& arg : agentJson["args"] ) { + agent.args.push_back( arg.get() ); + } + } + + if ( agentJson.contains( "environment" ) && agentJson["environment"].is_object() ) { + for ( const auto& envItem : agentJson["environment"].items() ) { + agent.environment[envItem.key()] = envItem.value().get(); + } + } + + agents[agentName] = agent; + } + return agents; +} + Plugin* AIAssistantPlugin::New( PluginManager* pluginManager ) { return eeNew( AIAssistantPlugin, ( pluginManager, false ) ); } @@ -395,6 +423,13 @@ void AIAssistantPlugin::loadAIAssistantConfig( const std::string& path, bool upd } } + if ( j.contains( "agents" ) ) { + auto agents = parseACPAgents( j["agents"] ); + for ( const auto& [key, value] : agents ) { + mAgents[key] = value; + } + } + if ( !j.contains( "providers" ) ) return; @@ -598,6 +633,7 @@ void AIAssistantPlugin::onSaveState( IniFile* state ) { config.partition = mainChat->getSplitter()->getSplitPartition(); config.modelProvider = mainChat->getCurModel().provider; config.modelName = mainChat->getCurModel().name; + config.agentName = mainChat->getCurAgent(); } else { config = mConfig; } @@ -610,6 +646,7 @@ void AIAssistantPlugin::onSaveState( IniFile* state ) { state->setValue( keyname, "split_partition", config.partition.toString() ); state->setValue( keyname, "default_provider", config.modelProvider ); state->setValue( keyname, "default_model", config.modelName ); + state->setValue( keyname, "default_agent", config.agentName ); } } // namespace ecode diff --git a/src/tools/ecode/plugins/aiassistant/aiassistantplugin.hpp b/src/tools/ecode/plugins/aiassistant/aiassistantplugin.hpp index a3e7614fc..cf157fd45 100644 --- a/src/tools/ecode/plugins/aiassistant/aiassistantplugin.hpp +++ b/src/tools/ecode/plugins/aiassistant/aiassistantplugin.hpp @@ -14,6 +14,7 @@ class AIAssistantPlugin : public PluginBase { StyleSheetLength partition; std::string modelProvider; std::string modelName; + std::string agentName; }; static PluginDefinition Definition() { @@ -37,6 +38,7 @@ class AIAssistantPlugin : public PluginBase { std::string getDescription() override { return Definition().description; } const LLMProviders& getProviders() { return mProviders; } + const ACPAgents& getAgents() { return mAgents; } std::string getPluginStatePath() const; @@ -50,6 +52,7 @@ class AIAssistantPlugin : public PluginBase { protected: LLMProviders mProviders; + ACPAgents mAgents; bool mUIInit{ false }; bool mBrokenUserConfigFile{ false }; UIWidget* mStatusBar{ nullptr }; diff --git a/src/tools/ecode/plugins/aiassistant/chatui.cpp b/src/tools/ecode/plugins/aiassistant/chatui.cpp index f45b43f7e..b81a26149 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.cpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.cpp @@ -179,8 +179,10 @@ DropDownList.role_ui { + + @@ -208,6 +210,18 @@ static const char* DEFAULT_CHAT_GLOBE = R"xml( )xml"; +static const char* DEFAULT_PERMISSION_GLOBE = R"xml( + + + + + + + + + +)xml"; + LLMChatUI::LLMChatUI( PluginManager* manager ) : UILinearLayout(), WidgetCommandExecuter( getInput() ), mManager( manager ) { setClass( "llm_chatui" ); @@ -220,6 +234,14 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : mChatsList = findByClass( "llm_chats" ); mModelDDL = findByClass( "model_ui" ); + mAgentDDL = findByClass( "agent_ui" ); + + mChatAgentMode = find( "llm_agent_mode" ); + mChatAgentMode->on( Event::OnValueChange, [this]( auto ) { + mIsAgentMode = mChatAgentMode->isSelected(); + mModelDDL->setVisible( !mIsAgentMode ); + mAgentDDL->setVisible( mIsAgentMode ); + } ); // mRefreshModels = find( "refresh_model_ui" ); // mRefreshModels->onClick( [this]( auto ) { execute( "ai-refresh-local-models" ); } ); @@ -314,12 +336,16 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : setCmd( "ai-prompt", [this] { // "ai-prompt-stop" - if ( mRequest ) { - if ( !mRequest->isCancelled() ) { + if ( mRequest || ( mAgentSession && mAgentSession->isPrompting() ) ) { + if ( mRequest && !mRequest->isCancelled() ) { mRequest->cancel(); return; - } else + } else if ( mAgentSession && mAgentSession->isPrompting() ) { + mAgentSession->cancel(); + return; + } else { mRequest.reset(); + } } auto chats = findAllByClass( "llm_conversation" ); @@ -344,7 +370,12 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : } execute( "ai-add-chat" ); - doRequest(); + + if ( mIsAgentMode ) { + doAgentRequest(); + } else { + doRequest(); + } } ); setCmd( "ai-prompt-stop", [this] { @@ -550,6 +581,8 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : setProviders( std::move( providers ) ); mCurModel = getDefaultModel(); + mAgents = getPlugin()->getAgents(); + AppConfig& config = getPlugin()->getPluginContext()->getConfig(); auto partition = config.iniState.getValue( "aiassistant", "split_partition", "" ); @@ -558,6 +591,7 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : auto modelProvider = config.iniState.getValue( "aiassistant", "default_provider", "" ); auto modelName = config.iniState.getValue( "aiassistant", "default_model", "" ); + auto agentName = config.iniState.getValue( "aiassistant", "default_agent", "" ); if ( !modelProvider.empty() && !modelName.empty() ) { auto modelOpt = getModel( modelProvider, modelName ); @@ -566,7 +600,14 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : } } + if ( !agentName.empty() && mAgents.find( agentName ) != mAgents.end() ) { + mCurAgent = agentName; + } else if ( !mAgents.empty() ) { + mCurAgent = mAgents.begin()->first; + } + fillModelDropDownList( mModelDDL ); + fillAgentDropDownList( mAgentDDL ); const auto appendShortcutToTooltip = [this]( UIPushButton* but, const std::string& cmd ) { auto kb = getKeyBindings().getCommandKeybindString( cmd ); @@ -968,6 +1009,187 @@ void LLMChatUI::fillModelDropDownList( UIDropDownList* modelDDL ) { [this, modelDDL] { fillApiModels( modelDDL ); } ); } +void LLMChatUI::fillAgentDropDownList( UIDropDownList* agentDDL ) { + std::vector agents; + std::size_t selectedIndex = 0; + for ( const auto& [name, data] : mAgents ) { + if ( !data.enabled ) + continue; + + String agentName( String::format( "%s", data.name ) ); + if ( data.name == mCurAgent ) + selectedIndex = agents.size(); + agents.push_back( std::move( agentName ) ); + } + agentDDL->getListBox()->clear(); + agentDDL->getListBox()->addListBoxItems( std::move( agents ) ); + agentDDL->getListBox()->setSelected( selectedIndex ); + agentDDL->on( Event::OnValueChange, [this, agentDDL]( auto ) { + mCurAgent = agentDDL->getListBox()->getItemSelectedText().toUtf8(); + } ); +} + +void LLMChatUI::writeToLastChat( const std::string& text ) { + runOnMainThread( [this, text] { + auto chats = mChatsList->findAllByClass( "llm_conversation" ); + if ( chats.empty() ) + return; + auto* chat = chats.back(); + auto* editor = chat->findByClass( "data_ui" ); + auto* thinking = editor->findByClass( "thinking" ); + auto thinkingID = String::hash( String::format( "thinking-%p", thinking ) ); + + editor->getDocument().textInput( String::fromUtf8( text ) ); + editor->setCursorVisible( false ); + thinking->removeActionsByTag( thinkingID ); + thinking->setVisible( false ); + resizeToFit( editor ); + } ); +} + +void LLMChatUI::doAgentRequest() { + if ( !mAgentSession ) { + auto it = mAgents.find( mCurAgent ); + if ( it == mAgents.end() ) { + showMsg( "Agent not configured." ); + return; + } + + mChatRun->setVisible( false )->setEnabled( false ); + mChatStop->setVisible( true )->setEnabled( true ); + + acp::ACPClient::Config config; + config.command = it->second.command; + config.args = it->second.args; + config.environment = it->second.environment; + config.workingDirectory = getPlugin()->getPluginContext()->getCurrentProject(); + + mAgentSession = + std::make_unique( getUISceneNode()->getThreadPool(), config ); + + UIWidget* chat = addChatUI( LLMChat::Role::Assistant ); + toggleEnableChats( false ); + auto* editor = chat->findByClass( "data_ui" ); + editor->setEnabled( false ); + auto* thinking = editor->findByClass( "thinking" ); + auto thinkingID = String::hash( String::format( "thinking-%p", thinking ) ); + thinking->setVisible( true ); + thinking->setPosition( { PixelDensity::dpToPx( 8 ), PixelDensity::dpToPx( 3 ) } ); + thinking->setInterval( [thinking] { thinking->rotate( 360 / 32 ); }, Seconds( 0.125 ), + thinkingID ); + + mAgentSession->onSessionUpdate = [this]( const nlohmann::json& msg ) { + auto sessionUpdate = msg.value( "sessionUpdate", "" ); + if ( sessionUpdate == "agent_message_chunk" || + sessionUpdate == "agent_thought_chunk" ) { + if ( msg.contains( "content" ) && msg["content"].contains( "text" ) ) { + auto chunk = msg["content"].value( "text", "" ); + writeToLastChat( chunk ); + } + } else if ( sessionUpdate == "tool_call" ) { + std::string toolStr = "\n> Tool Call: " + msg.value( "title", "" ) + "\n"; + writeToLastChat( toolStr ); + } else if ( sessionUpdate == "plan" ) { + std::string planStr = "\n> Plan Updated:\n"; + writeToLastChat( planStr ); + } + }; + + mAgentSession->onRequestPermission = [this]( const auto& req, auto cb ) { + runOnMainThread( [this, req, cb]() { addPermissionUI( req, cb ); } ); + }; + + mAgentSession->onTerminalCreated = []( UITerminal* term, const std::string& termId ) { + // This will be called from AgentSession. + }; + + mAgentSession->start( [this]( bool ready ) { + runOnMainThread( [this, ready]() { + if ( ready ) { + sendAgentPrompt(); + } else { + auto chats = mChatsList->findAllByClass( "llm_conversation" ); + if ( !chats.empty() ) { + auto* chat = chats.back(); + auto* editor = chat->findByClass( "data_ui" ); + auto* thinking = editor->findByClass( "thinking" ); + auto thinkingID = String::hash( String::format( "thinking-%p", thinking ) ); + thinking->removeActionsByTag( thinkingID ); + thinking->setVisible( false ); + editor->setEnabled( true ); + } + mChatStop->setVisible( false )->setEnabled( false ); + mChatRun->setVisible( true )->setEnabled( true ); + toggleEnableChats( true ); + showMsg( "Failed to start agent process." ); + mAgentSession.reset(); + } + } ); + } ); + } else { + // Existing session, just send the prompt + mChatRun->setVisible( false )->setEnabled( false ); + mChatStop->setVisible( true )->setEnabled( true ); + + UIWidget* chat = addChatUI( LLMChat::Role::Assistant ); + toggleEnableChats( false ); + auto* editor = chat->findByClass( "data_ui" ); + editor->setEnabled( false ); + auto* thinking = editor->findByClass( "thinking" ); + auto thinkingID = String::hash( String::format( "thinking-%p", thinking ) ); + thinking->setVisible( true ); + thinking->setPosition( { PixelDensity::dpToPx( 8 ), PixelDensity::dpToPx( 3 ) } ); + thinking->setInterval( [thinking] { thinking->rotate( 360 / 32 ); }, Seconds( 0.125 ), + thinkingID ); + sendAgentPrompt(); + } +} + +void LLMChatUI::sendAgentPrompt() { + acp::PromptRequest req; + req.sessionId = mAgentSession->getSessionId(); + + // Create prompt from the last user message + auto chats = findAllByClass( "llm_conversation" ); + if ( !chats.empty() ) { + auto* lastChat = + chats[chats.size() - 2]; // Assistant is the last one (added in doAgentRequest) + auto* editor = lastChat->findByClass( "data_ui" ); + std::string text = editor->getDocument().getText().toUtf8(); + replaceFileLinksToContents( text ); + req.prompt = { { { "type", "text" }, { "text", text } } }; + } else { + req.prompt = { { { "type", "text" }, { "text", "" } } }; + } + + mAgentSession->prompt( req, [this]( const acp::PromptResponse& res ) { + runOnMainThread( [this, res]() { + auto chats = mChatsList->findAllByClass( "llm_conversation" ); + if ( !chats.empty() ) { + auto* chat = chats[chats.size() - 1]; + auto* editor = chat->findByClass( "data_ui" ); + auto* thinking = editor->findByClass( "thinking" ); + if ( thinking ) { + auto thinkingID = String::hash( String::format( "thinking-%p", thinking ) ); + thinking->removeActionsByTag( thinkingID ); + thinking->setVisible( false ); + } + editor->setEnabled( true ); + if ( editor->hasFocus() ) + mChatInput->setFocus(); + } + + toggleEnableChats( true ); + mChatStop->setVisible( false )->setEnabled( false ); + mChatRun->setVisible( true )->setEnabled( true ); + + if ( res.stopReason != "cancelled" ) { + saveChat(); + } + } ); + } ); +} + void LLMChatUI::resizeToFit( UICodeEditor* editor ) { Float visibleLineCount = editor->getDocumentView().getVisibleLinesCount(); Float lineHeight = editor->getLineHeight(); @@ -1351,6 +1573,55 @@ Drawable* LLMChatUI::findIcon( const std::string& name, const size_t iconSize ) return nullptr; } +void LLMChatUI::addPermissionUI( const acp::RequestPermissionRequest& req, + std::function cb ) { + find( "chat_presentation" )->setVisible( false ); + + UIWidget* chat = + mChatsList->getUISceneNode()->loadLayoutFromString( DEFAULT_PERMISSION_GLOBE, mChatsList ); + + UITextView* desc = chat->findByClass( "permission_desc" ); + std::string descStr = "The agent wants to execute a tool call:\n"; + descStr += "Title: " + req.toolCall.title + "\n"; + descStr += "Kind: " + req.toolCall.kind + "\n"; + if ( !req.toolCall.rawInput.is_null() ) { + descStr += "Input:\n" + req.toolCall.rawInput.dump( 2 ) + "\n"; + } + desc->setText( descStr ); + + UIWidget* optionsBox = chat->findByClass( "permission_options" ); + + bool isFirst = true; + for ( const auto& opt : req.options ) { + UIPushButton* but = UIPushButton::New(); + but->setParent( optionsBox ); + but->setText( opt.name ); + but->setTooltipText( opt.kind ); + but->setLayoutMargin( Rectf( 0, 0, 8, 0 ) ); + + auto cbCopy = cb; + auto optId = opt.optionId; + but->onClick( [chat, cbCopy, optId, this]( const Event* ) { + chat->close(); // Close the permission request UI once selected + acp::RequestPermissionResponse res; + res.outcome = "selected"; + res.optionId = optId; + cbCopy( res ); + + if ( mChatInput ) { + mChatInput->setFocus(); + } + } ); + + if ( isFirst ) { + isFirst = false; + but->setFocus(); + } + } + + mChatScrollView->setAnchorScroll( true ); +} + UIWidget* LLMChatUI::addChatUI( LLMChat::Role role ) { find( "chat_presentation" )->setVisible( false ); diff --git a/src/tools/ecode/plugins/aiassistant/chatui.hpp b/src/tools/ecode/plugins/aiassistant/chatui.hpp index f8e5edec6..2a7dc1864 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.hpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.hpp @@ -1,6 +1,7 @@ #pragma once #include "../pluginmanager.hpp" +#include "agentsession.hpp" #include "llmchatcompletionrequest.hpp" #include "protocol.hpp" @@ -91,6 +92,8 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { bool chatExistsInDisk() const; + const std::string& getCurAgent() const { return mCurAgent; } + protected: UUID mUUID; std::string mSummary; @@ -109,8 +112,10 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { UIPushButton* mRefreshModels{ nullptr }; UIPushButton* mChatAttach{ nullptr }; UISelectButton* mChatPrivate{ nullptr }; + UISelectButton* mChatAgentMode{ nullptr }; UIScrollView* mChatScrollView{ nullptr }; UIDropDownList* mModelDDL{ nullptr }; + UIDropDownList* mAgentDDL{ nullptr }; UIVLinearLayoutCommandExecuter* mLocateBarLayout{ nullptr }; UITextInput* mLocateInput{ nullptr }; UITableView* mLocateTable{ nullptr }; @@ -120,8 +125,15 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { LLMProviders mProviders; LLMModel mCurModel; std::unordered_map mModelsMap; + + std::map mAgents; + std::string mCurAgent; + + std::unique_ptr mAgentSession; + int mPendingModelsToLoad{ 0 }; bool mChatIsPrivate{ false }; + bool mIsAgentMode{ false }; bool mChatLocked{ false }; bool mLinkMode{ false }; @@ -143,6 +155,10 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { void doRequest(); + void doAgentRequest(); + + void sendAgentPrompt(); + void toggleEnableChat( UIWidget* chat, bool enabled ); void toggleEnableChats( bool enabled ); @@ -151,6 +167,9 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { UIWidget* addChatUI( LLMChat::Role role ); + void addPermissionUI( const acp::RequestPermissionRequest& req, + std::function cb ); + void fillApiModels( UIDropDownList* modelDDL ); String getModelDisplayName( const LLMModel& model ) const; @@ -159,10 +178,14 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { void fillModelDropDownList( UIDropDownList* modelDDL ); + void fillAgentDropDownList( UIDropDownList* agentDDL ); + void resizeToFit( UICodeEditor* editor ); void addChat( LLMChat::Role role, std::string conversation ); + void writeToLastChat( const std::string& text ); + void removeLastChat(); void setProviders( LLMProviders&& providers ); diff --git a/src/tools/ecode/plugins/aiassistant/protocol.hpp b/src/tools/ecode/plugins/aiassistant/protocol.hpp index 0382f4add..251dbf189 100644 --- a/src/tools/ecode/plugins/aiassistant/protocol.hpp +++ b/src/tools/ecode/plugins/aiassistant/protocol.hpp @@ -4,6 +4,7 @@ #include #include #include +#include namespace ecode { @@ -40,4 +41,14 @@ struct LLMProvider { using LLMProviders = std::map; -} // namespace ecode +struct ACPAgent { + bool enabled{ true }; + std::string name; + std::string command; + std::vector args; + std::unordered_map environment; +}; + +using ACPAgents = std::map; + +} // namespace ecode \ No newline at end of file