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 01/10] 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 From 03acb3f95bca724ee9c80077ecb258ae6a4ab630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sat, 21 Mar 2026 15:07:32 -0300 Subject: [PATCH 02/10] Branched WIP that needs to be merged with the new changes from develop. --- bin/assets/plugins/aiassistant.json | 2 +- .../ecode/plugins/aiassistant/acpclient.cpp | 10 + .../ecode/plugins/aiassistant/acpclient.hpp | 2 + .../ecode/plugins/aiassistant/acpprotocol.cpp | 15 + .../ecode/plugins/aiassistant/acpprotocol.hpp | 16 ++ .../plugins/aiassistant/agentsession.cpp | 41 ++- .../plugins/aiassistant/agentsession.hpp | 6 +- .../ecode/plugins/aiassistant/chatui.cpp | 262 +++++++++++++++--- .../ecode/plugins/aiassistant/chatui.hpp | 12 + 9 files changed, 318 insertions(+), 48 deletions(-) diff --git a/bin/assets/plugins/aiassistant.json b/bin/assets/plugins/aiassistant.json index cce3ba11d..16fc7df81 100644 --- a/bin/assets/plugins/aiassistant.json +++ b/bin/assets/plugins/aiassistant.json @@ -506,7 +506,7 @@ "gemini-cli": { "enabled": true, "command": "gemini", - "args": ["--experimental-acp"] + "args": ["--experimental-acp", "--model=auto"] }, "opencode": { "enabled": true, diff --git a/src/tools/ecode/plugins/aiassistant/acpclient.cpp b/src/tools/ecode/plugins/aiassistant/acpclient.cpp index f91b13271..fb3425158 100644 --- a/src/tools/ecode/plugins/aiassistant/acpclient.cpp +++ b/src/tools/ecode/plugins/aiassistant/acpclient.cpp @@ -216,6 +216,16 @@ void ACPClient::newSession( const NewSessionRequest& req, } ); } +void ACPClient::loadSession( const LoadSessionRequest& req, + const std::function& cb ) { + write( { { "method", "session/load" }, { "params", req.toJson() } }, + [cb]( const IdType&, const json& resp ) { + if ( resp.contains( "result" ) && cb ) { + cb( LoadSessionResponse( resp["result"] ) ); + } + } ); +} + void ACPClient::prompt( const PromptRequest& req, const std::function& cb ) { write( { { "method", "session/prompt" }, { "params", req.toJson() } }, diff --git a/src/tools/ecode/plugins/aiassistant/acpclient.hpp b/src/tools/ecode/plugins/aiassistant/acpclient.hpp index 35227345d..6b8c3571d 100644 --- a/src/tools/ecode/plugins/aiassistant/acpclient.hpp +++ b/src/tools/ecode/plugins/aiassistant/acpclient.hpp @@ -47,6 +47,8 @@ class ACPClient { const std::function& cb ); void newSession( const NewSessionRequest& req, const std::function& cb ); + void loadSession( const LoadSessionRequest& req, + const std::function& cb ); void prompt( const PromptRequest& req, const std::function& cb ); // Notifications to agent diff --git a/src/tools/ecode/plugins/aiassistant/acpprotocol.cpp b/src/tools/ecode/plugins/aiassistant/acpprotocol.cpp index 03bc4c705..2e7e0a8fd 100644 --- a/src/tools/ecode/plugins/aiassistant/acpprotocol.cpp +++ b/src/tools/ecode/plugins/aiassistant/acpprotocol.cpp @@ -60,6 +60,21 @@ NewSessionResponse::NewSessionResponse( const json& body ) { configOptions = body["configOptions"]; } +json LoadSessionRequest::toJson() const { + json j = { { "sessionId", sessionId }, { "cwd", cwd } }; + if ( mcpServers.is_null() ) { + j["mcpServers"] = json::array(); + } else { + j["mcpServers"] = mcpServers; + } + return j; +} + +LoadSessionResponse::LoadSessionResponse( const json& body ) { + if ( body.contains( "configOptions" ) ) + configOptions = body["configOptions"]; +} + json PromptRequest::toJson() const { return { { "sessionId", sessionId }, { "prompt", prompt } }; } diff --git a/src/tools/ecode/plugins/aiassistant/acpprotocol.hpp b/src/tools/ecode/plugins/aiassistant/acpprotocol.hpp index b7873985f..d05d0ce67 100644 --- a/src/tools/ecode/plugins/aiassistant/acpprotocol.hpp +++ b/src/tools/ecode/plugins/aiassistant/acpprotocol.hpp @@ -63,6 +63,22 @@ struct NewSessionResponse { NewSessionResponse( const json& body ); }; +struct LoadSessionRequest { + std::string sessionId; + std::string cwd; + json mcpServers; + + LoadSessionRequest() = default; + json toJson() const; +}; + +struct LoadSessionResponse { + json configOptions; + + LoadSessionResponse() = default; + LoadSessionResponse( const json& body ); +}; + struct PromptRequest { std::string sessionId; json prompt; // Array of ContentBlock diff --git a/src/tools/ecode/plugins/aiassistant/agentsession.cpp b/src/tools/ecode/plugins/aiassistant/agentsession.cpp index 08b6b140c..823f71ff3 100644 --- a/src/tools/ecode/plugins/aiassistant/agentsession.cpp +++ b/src/tools/ecode/plugins/aiassistant/agentsession.cpp @@ -37,6 +37,38 @@ bool AgentSession::start( const std::function& onReady ) { return false; } +bool AgentSession::startLoaded( const std::string& sessionId, + 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, sessionId, onReady]( const InitializeResponse& ires ) { + if ( ires.agentCapabilities.loadSession ) { + LoadSessionRequest lreq; + lreq.sessionId = sessionId; + lreq.cwd = mClient->isReady() ? mClient->getConfig().workingDirectory : ""; + mClient->loadSession( lreq, [this, sessionId, onReady]( const LoadSessionResponse& ) { + mSessionId = sessionId; + if ( onReady ) + onReady( true ); + } ); + } else { + // Agent doesn't support loading, fallback to new session? + // For now let's just fail or call onReady(false) + if ( onReady ) + onReady( false ); + } + } ); + return true; + } + if ( onReady ) + onReady( false ); + return false; +} + void AgentSession::stop() { if ( mClient ) mClient->stop(); @@ -58,6 +90,11 @@ void AgentSession::cancel() { } } +void AgentSession::setTerminalData( const std::string& terminalId, UITerminal* uiTerm ) { + mTerminals[terminalId] = + TermData{ uiTerm->getTerm(), uiTerm->getTerm()->getTerminal(), uiTerm }; +} + void AgentSession::setupClient() { mClient->onSessionUpdate = [this]( const json& msg ) { if ( onSessionUpdate ) @@ -92,10 +129,8 @@ void AgentSession::setupClient() { 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 ); + onTerminalCreated( req, termId ); } cb( res ); }; diff --git a/src/tools/ecode/plugins/aiassistant/agentsession.hpp b/src/tools/ecode/plugins/aiassistant/agentsession.hpp index 9cb6f1920..f78b84314 100644 --- a/src/tools/ecode/plugins/aiassistant/agentsession.hpp +++ b/src/tools/ecode/plugins/aiassistant/agentsession.hpp @@ -21,6 +21,7 @@ class AgentSession { ~AgentSession(); bool start( const std::function& onReady ); + bool startLoaded( const std::string& sessionId, const std::function& onReady ); void stop(); void prompt( const PromptRequest& req, const std::function& cb ); @@ -32,11 +33,14 @@ class AgentSession { std::function )> onRequestPermission; - std::function onTerminalCreated; + std::function + onTerminalCreated; std::string getSessionId() const { return mSessionId; } ACPClient* getClient() const { return mClient.get(); } + void setTerminalData( const std::string& terminalId, UITerminal* uiTerm ); + protected: std::shared_ptr mThreadPool; std::unique_ptr mClient; diff --git a/src/tools/ecode/plugins/aiassistant/chatui.cpp b/src/tools/ecode/plugins/aiassistant/chatui.cpp index b81a26149..d2ed252fe 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.cpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -239,8 +240,7 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : mChatAgentMode = find( "llm_agent_mode" ); mChatAgentMode->on( Event::OnValueChange, [this]( auto ) { mIsAgentMode = mChatAgentMode->isSelected(); - mModelDDL->setVisible( !mIsAgentMode ); - mAgentDDL->setVisible( mIsAgentMode ); + updateAgentModeUI(); } ); // mRefreshModels = find( "refresh_model_ui" ); @@ -649,6 +649,7 @@ LLMChatUI::~LLMChatUI() { config.partition = getSplitter()->getSplitPartition(); config.modelProvider = mCurModel.provider; config.modelName = mCurModel.name; + config.agentName = mCurAgent; getPlugin()->setConfig( std::move( config ) ); } } @@ -1025,7 +1026,15 @@ void LLMChatUI::fillAgentDropDownList( UIDropDownList* agentDDL ) { agentDDL->getListBox()->addListBoxItems( std::move( agents ) ); agentDDL->getListBox()->setSelected( selectedIndex ); agentDDL->on( Event::OnValueChange, [this, agentDDL]( auto ) { - mCurAgent = agentDDL->getListBox()->getItemSelectedText().toUtf8(); + auto newAgent = agentDDL->getListBox()->getItemSelectedText().toUtf8(); + if ( newAgent != mCurAgent ) { + mCurAgent = newAgent; + if ( mAgentSession ) { + mAgentSession->stop(); + mAgentSession.reset(); + } + updateTabTitle(); + } } ); } @@ -1047,6 +1056,106 @@ void LLMChatUI::writeToLastChat( const std::string& text ) { } ); } +void LLMChatUI::updateAgentModeUI() { + mModelDDL->setVisible( !mIsAgentMode ); + mAgentDDL->setVisible( mIsAgentMode ); + mChatAdd->setVisible( !mIsAgentMode ); + mChatUserRole->setVisible( !mIsAgentMode ); + + auto chats = mChatsList->findAllByClass( "llm_conversation" ); + for ( auto chat : chats ) { + if ( auto roleUi = chat->findByClass( "role_ui" ) ) + roleUi->setVisible( !mIsAgentMode ); + if ( auto moveUp = chat->findByClass( "move_up" ) ) + moveUp->setVisible( !mIsAgentMode ); + if ( auto moveDown = chat->findByClass( "move_down" ) ) + moveDown->setVisible( !mIsAgentMode ); + if ( auto eraseBut = chat->findByClass( "erase_but" ) ) + eraseBut->setVisible( !mIsAgentMode ); + } +} + +void LLMChatUI::setupAgentSession() { + auto it = mAgents.find( mCurAgent ); + if ( it == mAgents.end() ) + return; + + 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 ); + + 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"; + if ( msg.contains( "plan" ) && msg["plan"].contains( "steps" ) && + msg["plan"]["steps"].is_array() ) { + for ( const auto& step : msg["plan"]["steps"] ) { + planStr += "- [" + step.value( "status", "" ) + "] " + + step.value( "title", "" ) + "\n"; + } + } + writeToLastChat( planStr ); + } else if ( sessionUpdate == "available_commands_update" ) { + if ( msg.contains( "availableCommands" ) && msg["availableCommands"].is_array() ) { + mAvailableCommands.clear(); + for ( const auto& cmd : msg["availableCommands"] ) { + mAvailableCommands.push_back( + { cmd.value( "name", "" ), cmd.value( "description", "" ) } ); + } + } + } + }; + + mAgentSession->onRequestPermission = [this]( const auto& req, auto cb ) { + runOnMainThread( [this, req, cb]() { addPermissionUI( req, cb ); } ); + }; + + mAgentSession->onTerminalCreated = [this]( const acp::CreateTerminalRequest& req, + const std::string& termId ) { + runOnMainThread( [this, req, termId] { + find( "chat_presentation" )->setVisible( false ); + UIWidget* bubble = mChatsList->getUISceneNode()->loadLayoutFromString( + R"xml()xml", + mChatsList ); + + std::unordered_map env; + for ( const auto& e : req.env ) + env[e.name] = e.value; + + auto* uiTerm = eterm::UI::UITerminal::New( + getPlugin()->getPluginContext()->getTerminalFont(), + getPlugin() + ->getPluginContext() + ->termConfig() + .fontSize.asPixels( + getUISceneNode()->getPixelsSize().getWidth(), + getUISceneNode()->getPixelsSize(), getUISceneNode()->getDPI(), + getUISceneNode()->getUIThemeManager()->getDefaultFontSize() ), + Sizef( 0, 0 ), req.command, req.args, env, + req.cwd ? *req.cwd : getPlugin()->getPluginContext()->getCurrentProject(), 10000, + nullptr, false, false ); + uiTerm->setParent( bubble ); + uiTerm->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent ); + + mAgentSession->setTerminalData( termId, uiTerm ); + } ); + }; +} + void LLMChatUI::doAgentRequest() { if ( !mAgentSession ) { auto it = mAgents.find( mCurAgent ); @@ -1058,14 +1167,7 @@ void LLMChatUI::doAgentRequest() { 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 ); + setupAgentSession(); UIWidget* chat = addChatUI( LLMChat::Role::Assistant ); toggleEnableChats( false ); @@ -1078,31 +1180,6 @@ void LLMChatUI::doAgentRequest() { 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 ) { @@ -1156,8 +1233,30 @@ void LLMChatUI::sendAgentPrompt() { 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 } } }; + + bool isSlashCommand = false; + if ( String::startsWith( text, "/" ) ) { + auto spacePos = text.find( ' ' ); + std::string cmdName = text.substr( 1, spacePos == std::string::npos ? std::string::npos + : spacePos - 1 ); + std::string arg = spacePos == std::string::npos ? "" : text.substr( spacePos + 1 ); + + auto it = std::find_if( mAvailableCommands.begin(), mAvailableCommands.end(), + [&cmdName]( const SlashCommand& c ) { + return c.name == cmdName; + } ); + + if ( it != mAvailableCommands.end() ) { + req.prompt = { { { "type", "slash_command" }, + { "name", cmdName }, + { "argument", arg } } }; + isSlashCommand = true; + } + } + + if ( !isSlashCommand ) { + req.prompt = promptToContentBlocks( text ); + } } else { req.prompt = { { { "type", "text" }, { "text", "" } } }; } @@ -1166,7 +1265,7 @@ void LLMChatUI::sendAgentPrompt() { runOnMainThread( [this, res]() { auto chats = mChatsList->findAllByClass( "llm_conversation" ); if ( !chats.empty() ) { - auto* chat = chats[chats.size() - 1]; + auto* chat = chats.back(); auto* editor = chat->findByClass( "data_ui" ); auto* thinking = editor->findByClass( "thinking" ); if ( thinking ) { @@ -1184,7 +1283,11 @@ void LLMChatUI::sendAgentPrompt() { mChatRun->setVisible( true )->setEnabled( true ); if ( res.stopReason != "cancelled" ) { - saveChat(); + if ( !mChatIsPrivate && !mSummaryRequest && mSummary.empty() ) { + generateChatName( false ); + } else { + saveChat(); + } } } ); } ); @@ -1229,6 +1332,44 @@ void LLMChatUI::replaceFileLinksToContents( std::string& text ) { } } +nlohmann::json LLMChatUI::promptToContentBlocks( std::string text ) { + auto j = nlohmann::json::array(); + LuaPattern ptrn( "\n```file://([^`]*)```\n?" ); + PatternMatcher::Range matches[2]; + size_t lastPos = 0; + while ( ptrn.matches( text, matches, lastPos ) ) { + // Text before the file + if ( (size_t)matches[0].start > lastPos ) { + j.push_back( + { { "type", "text" }, { "text", text.substr( lastPos, matches[0].start - lastPos ) } } ); + } + + std::string path( text.substr( matches[1].start, matches[1].length() ) ); + if ( FileSystem::isRelativePath( path ) ) { + std::string prjPath( getPlugin()->getPluginContext()->getCurrentProject() ); + path = prjPath + path; + } + + std::string fileBuffer; + if ( FileSystem::fileGet( path, fileBuffer ) ) { + j.push_back( { { "type", "resource" }, + { "resource", { { "path", path }, { "content", fileBuffer } } } } ); + } + + lastPos = matches[0].end; + } + + if ( lastPos < text.size() ) { + j.push_back( { { "type", "text" }, { "text", text.substr( lastPos ) } } ); + } + + if ( j.empty() && !text.empty() ) { + j.push_back( { { "type", "text" }, { "text", text } } ); + } + + return j; +} + nlohmann::json LLMChatUI::chatToJson( bool forRequest ) { auto j = nlohmann::json::array(); auto chats = findAllByClass( "llm_conversation" ); @@ -1278,6 +1419,10 @@ nlohmann::json LLMChatUI::serialize() { std::string inputText( mChatInput->getDocument().getText().toUtf8() ); j["input"] = std::move( inputText ); j["locked"] = mChatLocked; + j["agent_mode"] = mIsAgentMode; + j["agent_name"] = mCurAgent; + if ( mAgentSession && !mAgentSession->getSessionId().empty() ) + j["session_id"] = mAgentSession->getSessionId(); return j; } @@ -1288,6 +1433,8 @@ std::string LLMChatUI::unserialize( const nlohmann::json& payload ) { mTimestamp = payload.value( "timestamp", 0 ); mSummary = payload.value( "summary", "" ); mChatLocked = payload.value( "locked", false ); + mIsAgentMode = payload.value( "agent_mode", false ); + mCurAgent = payload.value( "agent_name", "" ); std::string provider = payload.value( "provider", "" ); if ( payload.contains( "chat" ) && payload["chat"].is_object() ) { @@ -1296,11 +1443,30 @@ std::string LLMChatUI::unserialize( const nlohmann::json& payload ) { mCurModel = findModel( provider, model ); } - if ( mCurModel.name.empty() ) + if ( mCurModel.name.empty() && !mIsAgentMode ) return payload.value( "input", "" ); - if ( !selectModel( mModelDDL, mCurModel ) ) - fillModelDropDownList( mModelDDL ); + if ( mIsAgentMode ) { + mChatAgentMode->setSelected( true ); + updateAgentModeUI(); + if ( !mCurAgent.empty() ) { + auto index = mAgentDDL->getListBox()->getItemIndex( mCurAgent ); + if ( index != eeINDEX_NOT_FOUND ) + mAgentDDL->getListBox()->setSelected( index ); + } + + std::string sessionId = payload.value( "session_id", "" ); + if ( !sessionId.empty() ) { + auto it = mAgents.find( mCurAgent ); + if ( it != mAgents.end() ) { + setupAgentSession(); + mAgentSession->startLoaded( sessionId, []( bool ) {} ); + } + } + } else { + if ( !selectModel( mModelDDL, mCurModel ) ) + fillModelDropDownList( mModelDDL ); + } if ( payload.contains( "chat" ) && payload["chat"].is_object() ) { const auto& chat = payload["chat"]; @@ -1696,6 +1862,13 @@ UIWidget* LLMChatUI::addChatUI( LLMChat::Role role ) { i18n( "chat_copied", "Chat Copied" ) ); } ); resizeToFit( editor ); + if ( mIsAgentMode ) { + chat->findByClass( "role_ui" )->setVisible( false ); + chat->findByClass( "erase_but" )->setVisible( false ); + chat->findByClass( "move_up" )->setVisible( false ); + chat->findByClass( "move_down" )->setVisible( false ); + } + return chat; } @@ -1770,6 +1943,9 @@ void LLMChatUI::updateTabTitle() { return; UITab* tab = reinterpret_cast( getData() ); auto title = i18n( "ai_assistant", "AI Assistant" ); + if ( mIsAgentMode && !mCurAgent.empty() ) { + title = i18n( "agent", "Agent" ) + ": " + mCurAgent; + } if ( !mSummary.empty() ) title += " - " + mSummary; tab->setText( title ); diff --git a/src/tools/ecode/plugins/aiassistant/chatui.hpp b/src/tools/ecode/plugins/aiassistant/chatui.hpp index 2a7dc1864..bdd03c242 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.hpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.hpp @@ -129,6 +129,12 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { std::map mAgents; std::string mCurAgent; + struct SlashCommand { + std::string name; + std::string description; + }; + std::vector mAvailableCommands; + std::unique_ptr mAgentSession; int mPendingModelsToLoad{ 0 }; @@ -180,6 +186,10 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { void fillAgentDropDownList( UIDropDownList* agentDDL ); + void updateAgentModeUI(); + + void setupAgentSession(); + void resizeToFit( UICodeEditor* editor ); void addChat( LLMChat::Role role, std::string conversation ); @@ -221,6 +231,8 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { void replaceFileLinksToContents( std::string& text ); + nlohmann::json promptToContentBlocks( std::string text ); + void generateChatName( bool isRenaming ); void regenerateChatName(); From af9eebe9eacd4d20a13c70302e41fcb1c6ca76ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sat, 21 Mar 2026 18:07:45 -0300 Subject: [PATCH 03/10] Agent picker. --- bin/assets/plugins/aiassistant.json | 5 + .../ecode/plugins/aiassistant/chatui.cpp | 320 ++++++++++++++---- .../ecode/plugins/aiassistant/chatui.hpp | 19 +- 3 files changed, 281 insertions(+), 63 deletions(-) diff --git a/bin/assets/plugins/aiassistant.json b/bin/assets/plugins/aiassistant.json index ac7dd7a55..df967cb10 100644 --- a/bin/assets/plugins/aiassistant.json +++ b/bin/assets/plugins/aiassistant.json @@ -520,6 +520,11 @@ "enabled": true, "command": "cursor", "args": ["acp"] + }, + "pi-acp": { + "enabled": true, + "command": "pi-acp", + "args": [] } } } diff --git a/src/tools/ecode/plugins/aiassistant/chatui.cpp b/src/tools/ecode/plugins/aiassistant/chatui.cpp index fccc3d095..5984b56bd 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.cpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.cpp @@ -47,7 +47,7 @@ class LLMModelsModel : public Model { return mCurModels.size(); } - virtual size_t columnCount( const ModelIndex& = ModelIndex() ) const override { return 2; } + virtual size_t columnCount( const ModelIndex& = ModelIndex() ) const override { return 3; } virtual std::string columnName( const size_t& column ) const override { switch ( column ) { @@ -132,6 +132,106 @@ class LLMModelsModel : public Model { UISceneNode* mUISceneNode; }; +class ACPAgentsModel : public Model { + public: + enum Columns { Name, Command }; + + ACPAgentsModel( const std::map& agents, + UISceneNode* uiSceneNode = nullptr ) : + mAgents( agents ), mUISceneNode( uiSceneNode ) { + mCurAgents.reserve( mAgents.size() ); + for ( const auto& [name, agent] : mAgents ) { + if ( agent.enabled ) + mCurAgents.emplace_back( name ); + } + } + + virtual size_t rowCount( const ModelIndex& = ModelIndex() ) const override { + return mCurAgents.size(); + } + + virtual size_t columnCount( const ModelIndex& = ModelIndex() ) const override { return 2; } + + virtual std::string columnName( const size_t& column ) const override { + switch ( column ) { + case Columns::Name: + return mUISceneNode ? mUISceneNode->i18n( "name", "Name" ) : "Name"; + case Columns::Command: + return mUISceneNode ? mUISceneNode->i18n( "command", "Command" ) : "Command"; + } + return ""; + } + + virtual Variant data( const ModelIndex& index, + ModelRole role = ModelRole::Display ) const override { + if ( role != ModelRole::Display ) + return {}; + + if ( index.row() < 0 || static_cast( index.row() ) >= mCurAgents.size() ) + return {}; + + const auto& agentName = mCurAgents[index.row()]; + auto it = mAgents.find( agentName ); + if ( it == mAgents.end() ) + return {}; + + switch ( index.column() ) { + case Columns::Name: { + return Variant( it->second.name.c_str() ); + } + case Columns::Command: { + return Variant( it->second.command.c_str() ); + } + } + + return {}; + } + + ModelIndex getFromName( const std::string& name ) { + auto it = + std::find_if( mCurAgents.begin(), mCurAgents.end(), + [&name]( const std::string& agentName ) { return name == agentName; } ); + return it != mCurAgents.end() ? index( std::distance( mCurAgents.begin(), it ) ) + : index( 0 ); + } + + void setFilter( const std::string& filter ) { + if ( mCurFilter == filter ) + return; + mCurFilter = filter; + mCurAgents.clear(); + + for ( const auto& [name, agent] : mAgents ) { + if ( !agent.enabled ) + continue; + + if ( filter.empty() ) { + mCurAgents.emplace_back( name ); + continue; + } + + bool matchesName = String::icontains( agent.name, filter ); + bool matchesCommand = String::icontains( agent.command, filter ); + + if ( matchesName || matchesCommand ) { + mCurAgents.emplace_back( name ); + } + } + + invalidate(); + } + + const std::vector& getCurAgents() const { return mCurAgents; } + + void refresh() { setFilter( mCurFilter ); } + + protected: + const std::map& mAgents; + std::vector mCurAgents; + std::string mCurFilter; + UISceneNode* mUISceneNode; +}; + static const char* DEFAULT_PROVIDER = "google"; static const char* DEFAULT_MODEL = "gemini-2.5-flash"; @@ -234,16 +334,19 @@ DropDownList.role_ui { tint: var(--font); } .llm_chat_attach, -.llm_chat_select_model { +.llm_chat_select_model, +.llm_chat_select_agent { padding: 8dp 8dp 38dp 8dp; } .llm_chat_locate_input, -.llm_chat_select_model_input { +.llm_chat_select_model_input, +.llm_chat_select_agent_input { margin-bottom: 2dp; padding: 0 0 0 4dp; } .llm_chat_attach_locate, -.llm_chat_model_locate { +.llm_chat_model_locate, +.llm_chat_agent_locate { border-radius: 8dp; margin-bottom: 4dp; } @@ -296,16 +399,18 @@ DropDownList.role_ui { + + + + - - - - - + + + @@ -356,7 +461,9 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : mChatsList = findByClass( "llm_chats" ); mModelBtn = findByClass( "model_ui" ); - mAgentDDL = findByClass( "agent_ui" ); + mModelBtn->onClick( [this]( auto ) { execute( "ai-select-model" ); } ); + mAgentBtn = findByClass( "agent_ui" ); + mAgentBtn->onClick( [this]( auto ) { execute( "ai-select-agent" ); } ); mChatAgentMode = find( "llm_agent_mode" ); mChatAgentMode->on( Event::OnValueChange, [this]( auto ) { @@ -550,6 +657,26 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : hideSelectModel(); } ); + setCmd( "ai-select-agent", [this] { + if ( !mLocateAgentBarLayout->isVisible() ) { + if ( mLocateAgentTable->getModel() ) { + mLocateAgentInput->setText( "" ); + static_cast( mLocateAgentTable->getModel() )->setFilter( "" ); + } + + showSelectAgent(); + + mLocateAgentTable->runOnMainThread( [this] { + auto model = static_cast( mLocateAgentTable->getModel() ); + if ( model ) { + mLocateAgentTable->setSelection( model->getFromName( mCurAgent ) ); + mAgentBtn->setText( mCurAgent ); + } + } ); + } else + hideSelectAgent(); + } ); + setCmd( "ai-toggle-private-chat", [this] { mChatIsPrivate = !mChatIsPrivate; @@ -703,13 +830,12 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : mChatAttach = find( "llm_attach" ); mChatAttach->onClick( [this]( auto ) { execute( "ai-show-add-context-menu" ); } ); - mModelBtn->onClick( [this]( auto ) { execute( "ai-select-model" ); } ); - if ( getPlugin() == nullptr ) return; initAttachFile(); initSelectModel(); + initSelectAgent(); auto providers = getPlugin()->getProviders(); setProviders( std::move( providers ) ); @@ -735,13 +861,12 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : } if ( !agentName.empty() && mAgents.find( agentName ) != mAgents.end() ) { - mCurAgent = agentName; + selectAgent( agentName ); } else if ( !mAgents.empty() ) { - mCurAgent = mAgents.begin()->first; + selectAgent( mAgents.begin()->first ); } fillModelDropDownList(); - fillAgentDropDownList( mAgentDDL ); const auto appendShortcutToTooltip = [this]( UIPushButton* but, const std::string& cmd ) { auto kb = getKeyBindings().getCommandKeybindString( cmd ); @@ -853,6 +978,8 @@ void LLMChatUI::showChatHistory() { return; hideAttachFile(); + hideSelectModel(); + hideSelectAgent(); static const char* CHAT_HISTORY_LAYOUT = R"xml( model ) { return false; } +bool LLMChatUI::selectAgent( const std::string& agent ) { + if ( !agent.empty() && mAgents.find( agent ) != mAgents.end() ) { + mAgentBtn->setText( agent ); + mCurAgent = agent; + return true; + } + return false; +} void LLMChatUI::fillModelDropDownList() { mModels.clear(); std::size_t reserve = 0; @@ -1124,30 +1259,99 @@ void LLMChatUI::fillModelDropDownList() { getUISceneNode()->getThreadPool()->run( [this] { fillApiModels(); } ); } -void LLMChatUI::fillAgentDropDownList( UIDropDownList* agentDDL ) { - std::vector agents; - std::size_t selectedIndex = 0; - for ( const auto& [name, data] : mAgents ) { - if ( !data.enabled ) - continue; +// Agent Picker - 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 ) { - auto newAgent = agentDDL->getListBox()->getItemSelectedText().toUtf8(); - if ( newAgent != mCurAgent ) { - mCurAgent = newAgent; - if ( mAgentSession ) { - mAgentSession->stop(); - mAgentSession.reset(); +void LLMChatUI::updateLocateAgentBarColumns() { + Float width = eeceil( mLocateAgentTable->getPixelsSize().getWidth() ); + width -= mLocateAgentTable->getVerticalScrollBar()->getPixelsSize().getWidth(); + mLocateAgentTable->setColumnsVisible( { 0 } ); + mLocateAgentTable->setColumnWidth( 0, eeceil( width ) ); +} + +void LLMChatUI::loadSelectAgent() { + auto ctx = getPlugin()->getPluginContext(); + mLocateAgentTable->setModel( + std::make_shared( mAgents, ctx->getUISceneNode() ) ); + + static_cast( mLocateAgentTable->getModel() ) + ->setFilter( mLocateAgentInput->getText().toUtf8() ); +} + +void LLMChatUI::showSelectAgent() { + if ( getPlugin() == nullptr ) + return; + hideAttachFile(); + hideSelectModel(); + + if ( nullptr == mLocateAgentTable->getModel() ) + loadSelectAgent(); + + static_cast( mLocateAgentTable->getModel() ) + ->setFilter( mLocateAgentInput->getText().toUtf8() ); + mLocateAgentBarLayout->setVisible( true ); + mLocateAgentInput->setFocus(); + updateLocateAgentBarColumns(); +} + +void LLMChatUI::hideSelectAgent() { + mLocateAgentBarLayout->setVisible( false ); +} + +void LLMChatUI::initSelectAgent() { + mLocateAgentBarLayout = findByClass( "llm_chat_select_agent" ); + mLocateAgentInput = findByClass( "llm_chat_select_agent_input" ); + mLocateAgentTable = findByClass( "llm_chat_agent_locate" ); + mLocateAgentTable->setHeadersVisible( false ); + + mLocateAgentTable->on( Event::OnSizeChange, + [this]( const Event* ) { updateLocateAgentBarColumns(); } ); + + mLocateAgentInput->on( Event::OnTextChanged, [this]( const Event* ) { + showSelectAgent(); + updateLocateAgentBarColumns(); + } ); + mLocateAgentInput->on( Event::OnPressEnter, [this]( const Event* ) { + KeyEvent keyEvent( mLocateAgentTable, Event::KeyDown, KEY_RETURN, SCANCODE_UNKNOWN, 0, 0 ); + mLocateAgentTable->forceKeyDown( keyEvent ); + } ); + mLocateAgentInput->on( Event::KeyDown, [this]( const Event* event ) { + const KeyEvent* keyEvent = static_cast( event ); + mLocateAgentTable->forceKeyDown( *keyEvent ); + } ); + mLocateAgentBarLayout->setCommand( "close-locatebar", [this] { + hideSelectAgent(); + if ( mChatInput ) + mChatInput->setFocus(); + } ); + mLocateAgentBarLayout->getKeyBindings().addKeybindsString( { + { "escape", "close-locatebar" }, + } ); + mLocateAgentTable->on( Event::KeyDown, [this]( const Event* event ) { + const KeyEvent* keyEvent = static_cast( event ); + if ( keyEvent->getKeyCode() == KEY_ESCAPE ) + mLocateAgentBarLayout->execute( "close-locatebar" ); + } ); + mLocateAgentTable->on( Event::OnModelEvent, [this]( const Event* event ) { + const ModelEvent* modelEvent = static_cast( event ); + if ( modelEvent->getModelEventType() == ModelEventType::Open ) { + Variant vName( modelEvent->getModel()->data( + modelEvent->getModel()->index( modelEvent->getModelIndex().row(), + ACPAgentsModel::Name ), + ModelRole::Display ) ); + + if ( vName.isValid() ) { + std::string name( vName.toString() ); + if ( name != mCurAgent ) { + selectAgent( name ); + if ( mAgentSession ) { + mAgentSession->stop(); + mAgentSession.reset(); + } + updateTabTitle(); + } } - updateTabTitle(); + + mLocateAgentBarLayout->execute( "close-locatebar" ); } } ); } @@ -1172,7 +1376,7 @@ void LLMChatUI::writeToLastChat( const std::string& text ) { void LLMChatUI::updateAgentModeUI() { mModelBtn->setVisible( !mIsAgentMode ); - mAgentDDL->setVisible( mIsAgentMode ); + mAgentBtn->setVisible( mIsAgentMode ); mChatAdd->setVisible( !mIsAgentMode ); mChatUserRole->setVisible( !mIsAgentMode ); @@ -1252,13 +1456,10 @@ void LLMChatUI::setupAgentSession() { auto* uiTerm = eterm::UI::UITerminal::New( getPlugin()->getPluginContext()->getTerminalFont(), - getPlugin() - ->getPluginContext() - ->termConfig() - .fontSize.asPixels( - getUISceneNode()->getPixelsSize().getWidth(), - getUISceneNode()->getPixelsSize(), getUISceneNode()->getDPI(), - getUISceneNode()->getUIThemeManager()->getDefaultFontSize() ), + getPlugin()->getPluginContext()->termConfig().fontSize.asPixels( + getUISceneNode()->getPixelsSize().getWidth(), getUISceneNode()->getPixelsSize(), + getUISceneNode()->getDPI(), + getUISceneNode()->getUIThemeManager()->getDefaultFontSize() ), Sizef( 0, 0 ), req.command, req.args, env, req.cwd ? *req.cwd : getPlugin()->getPluginContext()->getCurrentProject(), 10000, nullptr, false, false ); @@ -1351,19 +1552,17 @@ void LLMChatUI::sendAgentPrompt() { bool isSlashCommand = false; if ( String::startsWith( text, "/" ) ) { auto spacePos = text.find( ' ' ); - std::string cmdName = text.substr( 1, spacePos == std::string::npos ? std::string::npos - : spacePos - 1 ); + std::string cmdName = + text.substr( 1, spacePos == std::string::npos ? std::string::npos : spacePos - 1 ); std::string arg = spacePos == std::string::npos ? "" : text.substr( spacePos + 1 ); - auto it = std::find_if( mAvailableCommands.begin(), mAvailableCommands.end(), - [&cmdName]( const SlashCommand& c ) { - return c.name == cmdName; - } ); + auto it = + std::find_if( mAvailableCommands.begin(), mAvailableCommands.end(), + [&cmdName]( const SlashCommand& c ) { return c.name == cmdName; } ); if ( it != mAvailableCommands.end() ) { - req.prompt = { { { "type", "slash_command" }, - { "name", cmdName }, - { "argument", arg } } }; + req.prompt = { + { { "type", "slash_command" }, { "name", cmdName }, { "argument", arg } } }; isSlashCommand = true; } } @@ -1454,8 +1653,8 @@ nlohmann::json LLMChatUI::promptToContentBlocks( std::string text ) { while ( ptrn.matches( text, matches, lastPos ) ) { // Text before the file if ( (size_t)matches[0].start > lastPos ) { - j.push_back( - { { "type", "text" }, { "text", text.substr( lastPos, matches[0].start - lastPos ) } } ); + j.push_back( { { "type", "text" }, + { "text", text.substr( lastPos, matches[0].start - lastPos ) } } ); } std::string path( text.substr( matches[1].start, matches[1].length() ) ); @@ -1550,6 +1749,8 @@ std::string LLMChatUI::unserialize( const nlohmann::json& payload ) { mIsAgentMode = payload.value( "agent_mode", false ); mCurAgent = payload.value( "agent_name", "" ); + selectAgent( mCurAgent ); + std::string provider = payload.value( "provider", "" ); if ( payload.contains( "chat" ) && payload["chat"].is_object() ) { const auto& chat = payload["chat"]; @@ -1563,11 +1764,6 @@ std::string LLMChatUI::unserialize( const nlohmann::json& payload ) { if ( mIsAgentMode ) { mChatAgentMode->setSelected( true ); updateAgentModeUI(); - if ( !mCurAgent.empty() ) { - auto index = mAgentDDL->getListBox()->getItemIndex( mCurAgent ); - if ( index != eeINDEX_NOT_FOUND ) - mAgentDDL->getListBox()->setSelected( index ); - } std::string sessionId = payload.value( "session_id", "" ); if ( !sessionId.empty() ) { @@ -2116,6 +2312,7 @@ void LLMChatUI::showAttachFile() { if ( getPlugin() == nullptr ) return; hideSelectModel(); + hideSelectAgent(); auto text = mLocateInput->getText(); auto ctx = getPlugin()->getPluginContext(); if ( !ctx->isDirTreeReady() ) { @@ -2274,6 +2471,7 @@ void LLMChatUI::showSelectModel() { if ( getPlugin() == nullptr ) return; hideAttachFile(); + hideSelectAgent(); if ( nullptr == mLocateModelTable->getModel() ) loadSelectModel(); diff --git a/src/tools/ecode/plugins/aiassistant/chatui.hpp b/src/tools/ecode/plugins/aiassistant/chatui.hpp index a07a19ef0..97a0039e0 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.hpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.hpp @@ -114,8 +114,8 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { UISelectButton* mChatPrivate{ nullptr }; UISelectButton* mChatAgentMode{ nullptr }; UIScrollView* mChatScrollView{ nullptr }; - UIDropDownList* mAgentDDL{ nullptr }; UIPushButton* mModelBtn{ nullptr }; + UIPushButton* mAgentBtn{ nullptr }; // Locate file UIVLinearLayoutCommandExecuter* mLocateBarLayout{ nullptr }; @@ -127,6 +127,11 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { UITextInput* mLocateModelInput{ nullptr }; UITableView* mLocateModelTable{ nullptr }; + // Select agent + UIVLinearLayoutCommandExecuter* mLocateAgentBarLayout{ nullptr }; + UITextInput* mLocateAgentInput{ nullptr }; + UITableView* mLocateAgentTable{ nullptr }; + std::unique_ptr mRequest; std::unique_ptr mSummaryRequest; LLMProviders mProviders; @@ -189,9 +194,19 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { bool selectModel( std::optional model ); + bool selectAgent( const std::string& agent ); + void fillModelDropDownList(); - void fillAgentDropDownList( UIDropDownList* agentDDL ); + void loadSelectAgent(); + + void showSelectAgent(); + + void initSelectAgent(); + + void hideSelectAgent(); + + void updateLocateAgentBarColumns(); void updateAgentModeUI(); From 163841c98c06378add837ffd78570157bc901b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sat, 21 Mar 2026 23:32:18 -0300 Subject: [PATCH 04/10] ACP list sessions and some refactor WIP. --- projects/android-project/app/jni/Android.mk | 1 + .../aiassistant/{ => acp}/acpclient.cpp | 10 + .../aiassistant/{ => acp}/acpclient.hpp | 2 + .../aiassistant/{ => acp}/acpprotocol.cpp | 30 ++ .../aiassistant/{ => acp}/acpprotocol.hpp | 26 ++ .../aiassistant/{ => acp}/agentsession.cpp | 12 + .../aiassistant/{ => acp}/agentsession.hpp | 1 + .../ecode/plugins/aiassistant/chatui.cpp | 321 ++++++++++++++---- .../ecode/plugins/aiassistant/chatui.hpp | 4 +- 9 files changed, 337 insertions(+), 70 deletions(-) rename src/tools/ecode/plugins/aiassistant/{ => acp}/acpclient.cpp (95%) rename src/tools/ecode/plugins/aiassistant/{ => acp}/acpclient.hpp (96%) rename src/tools/ecode/plugins/aiassistant/{ => acp}/acpprotocol.cpp (88%) rename src/tools/ecode/plugins/aiassistant/{ => acp}/acpprotocol.hpp (91%) rename src/tools/ecode/plugins/aiassistant/{ => acp}/agentsession.cpp (93%) rename src/tools/ecode/plugins/aiassistant/{ => acp}/agentsession.hpp (95%) diff --git a/projects/android-project/app/jni/Android.mk b/projects/android-project/app/jni/Android.mk index 733b630d0..ed2ae289d 100644 --- a/projects/android-project/app/jni/Android.mk +++ b/projects/android-project/app/jni/Android.mk @@ -62,6 +62,7 @@ LOCAL_C_INCLUDES := $(EEPP_C_INCLUDES) CORE_SRCS := tools/ecode/*.cpp \ tools/ecode/plugins/*.cpp \ tools/ecode/plugins/aiassistant/*.cpp \ + tools/ecode/plugins/aiassistant/acp/*.cpp \ tools/ecode/plugins/autocomplete/*.cpp \ tools/ecode/plugins/debugger/*.cpp \ tools/ecode/plugins/debugger/dap/*.cpp \ diff --git a/src/tools/ecode/plugins/aiassistant/acpclient.cpp b/src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp similarity index 95% rename from src/tools/ecode/plugins/aiassistant/acpclient.cpp rename to src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp index fb3425158..3900064e8 100644 --- a/src/tools/ecode/plugins/aiassistant/acpclient.cpp +++ b/src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp @@ -226,6 +226,16 @@ void ACPClient::loadSession( const LoadSessionRequest& req, } ); } +void ACPClient::listSessions( const ListSessionsRequest& req, + const std::function& cb ) { + write( { { "method", "session/list" }, { "params", req.toJson() } }, + [cb]( const IdType&, const json& resp ) { + if ( resp.contains( "result" ) && cb ) { + cb( ListSessionsResponse( resp["result"] ) ); + } + } ); +} + void ACPClient::prompt( const PromptRequest& req, const std::function& cb ) { write( { { "method", "session/prompt" }, { "params", req.toJson() } }, diff --git a/src/tools/ecode/plugins/aiassistant/acpclient.hpp b/src/tools/ecode/plugins/aiassistant/acp/acpclient.hpp similarity index 96% rename from src/tools/ecode/plugins/aiassistant/acpclient.hpp rename to src/tools/ecode/plugins/aiassistant/acp/acpclient.hpp index 6b8c3571d..98dab8993 100644 --- a/src/tools/ecode/plugins/aiassistant/acpclient.hpp +++ b/src/tools/ecode/plugins/aiassistant/acp/acpclient.hpp @@ -49,6 +49,8 @@ class ACPClient { const std::function& cb ); void loadSession( const LoadSessionRequest& req, const std::function& cb ); + void listSessions( const ListSessionsRequest& req, + const std::function& cb ); void prompt( const PromptRequest& req, const std::function& cb ); // Notifications to agent diff --git a/src/tools/ecode/plugins/aiassistant/acpprotocol.cpp b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp similarity index 88% rename from src/tools/ecode/plugins/aiassistant/acpprotocol.cpp rename to src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp index 2e7e0a8fd..564c1ba6b 100644 --- a/src/tools/ecode/plugins/aiassistant/acpprotocol.cpp +++ b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp @@ -75,6 +75,36 @@ LoadSessionResponse::LoadSessionResponse( const json& body ) { configOptions = body["configOptions"]; } +SessionInfo::SessionInfo( const json& body ) { + if ( body.contains( "sessionId" ) && body["sessionId"].is_string() ) + sessionId = body["sessionId"].get(); + if ( body.contains( "cwd" ) && body["cwd"].is_string() ) + cwd = body["cwd"].get(); + if ( body.contains( "title" ) && body["title"].is_string() ) + title = body["title"].get(); + if ( body.contains( "updatedAt" ) && body["updatedAt"].is_string() ) + updatedAt = body["updatedAt"].get(); +} + +json ListSessionsRequest::toJson() const { + json j = json::object(); + if ( !cursor.empty() ) + j["cursor"] = cursor; + if ( !cwd.empty() ) + j["cwd"] = cwd; + return j; +} + +ListSessionsResponse::ListSessionsResponse( const json& body ) { + if ( body.contains( "nextCursor" ) && body["nextCursor"].is_string() ) + nextCursor = body["nextCursor"].get(); + if ( body.contains( "sessions" ) && body["sessions"].is_array() ) { + for ( const auto& item : body["sessions"] ) { + sessions.emplace_back( SessionInfo( item ) ); + } + } +} + json PromptRequest::toJson() const { return { { "sessionId", sessionId }, { "prompt", prompt } }; } diff --git a/src/tools/ecode/plugins/aiassistant/acpprotocol.hpp b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp similarity index 91% rename from src/tools/ecode/plugins/aiassistant/acpprotocol.hpp rename to src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp index d05d0ce67..4b66363e0 100644 --- a/src/tools/ecode/plugins/aiassistant/acpprotocol.hpp +++ b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp @@ -79,6 +79,32 @@ struct LoadSessionResponse { LoadSessionResponse( const json& body ); }; +struct SessionInfo { + std::string sessionId; + std::string cwd; + std::string title; + std::string updatedAt; + + SessionInfo() = default; + SessionInfo( const json& body ); +}; + +struct ListSessionsRequest { + std::string cursor; + std::string cwd; + + ListSessionsRequest() = default; + json toJson() const; +}; + +struct ListSessionsResponse { + std::string nextCursor; + std::vector sessions; + + ListSessionsResponse() = default; + ListSessionsResponse( const json& body ); +}; + struct PromptRequest { std::string sessionId; json prompt; // Array of ContentBlock diff --git a/src/tools/ecode/plugins/aiassistant/agentsession.cpp b/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp similarity index 93% rename from src/tools/ecode/plugins/aiassistant/agentsession.cpp rename to src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp index 823f71ff3..cd72a865e 100644 --- a/src/tools/ecode/plugins/aiassistant/agentsession.cpp +++ b/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp @@ -69,6 +69,18 @@ bool AgentSession::startLoaded( const std::string& sessionId, return false; } +void AgentSession::listSessions( const std::function& )>& cb ) { + if ( !mClient->isReady() ) { + if ( cb ) cb( {} ); + return; + } + ListSessionsRequest req; + req.cwd = mClient->getConfig().workingDirectory; + mClient->listSessions( req, [cb]( const ListSessionsResponse& res ) { + if ( cb ) cb( res.sessions ); + } ); +} + void AgentSession::stop() { if ( mClient ) mClient->stop(); diff --git a/src/tools/ecode/plugins/aiassistant/agentsession.hpp b/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp similarity index 95% rename from src/tools/ecode/plugins/aiassistant/agentsession.hpp rename to src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp index f78b84314..79aaee670 100644 --- a/src/tools/ecode/plugins/aiassistant/agentsession.hpp +++ b/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp @@ -22,6 +22,7 @@ class AgentSession { bool start( const std::function& onReady ); bool startLoaded( const std::string& sessionId, const std::function& onReady ); + void listSessions( const std::function& )>& cb ); void stop(); void prompt( const PromptRequest& req, const std::function& cb ); diff --git a/src/tools/ecode/plugins/aiassistant/chatui.cpp b/src/tools/ecode/plugins/aiassistant/chatui.cpp index 5984b56bd..6f68404a9 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.cpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.cpp @@ -232,6 +232,99 @@ class ACPAgentsModel : public Model { UISceneNode* mUISceneNode; }; +class AgentSessionHistoryModel : public Model { + public: + enum Columns { Title, UpdatedAt, SessionId }; + + AgentSessionHistoryModel( const std::vector& sessions, + UISceneNode* uiSceneNode = nullptr ) : + mSessions( sessions ), mUISceneNode( uiSceneNode ) { + mCurSessions.reserve( mSessions.size() ); + for ( const auto& session : mSessions ) { + mCurSessions.emplace_back( &session ); + } + } + + virtual size_t rowCount( const ModelIndex& = ModelIndex() ) const override { + return mCurSessions.size(); + } + + virtual size_t columnCount( const ModelIndex& = ModelIndex() ) const override { return 3; } + + virtual std::string columnName( const size_t& column ) const override { + switch ( column ) { + case Columns::Title: + return mUISceneNode ? mUISceneNode->i18n( "title", "Title" ) : "Title"; + case Columns::UpdatedAt: + return mUISceneNode ? mUISceneNode->i18n( "updated_at", "Updated At" ) + : "Updated At"; + case Columns::SessionId: + return mUISceneNode ? mUISceneNode->i18n( "session_id", "Session ID" ) + : "Session ID"; + } + return ""; + } + + virtual Variant data( const ModelIndex& index, + ModelRole role = ModelRole::Display ) const override { + if ( role != ModelRole::Display && role != ModelRole::Custom ) + return {}; + + if ( index.row() < 0 || static_cast( index.row() ) >= mCurSessions.size() ) + return {}; + + const auto& session = *mCurSessions[index.row()]; + + if ( role == ModelRole::Custom ) { + return Variant( session.sessionId.c_str() ); + } + + switch ( index.column() ) { + case Columns::Title: { + return Variant( session.title.empty() ? "Untitled Session" + : session.title.c_str() ); + } + case Columns::UpdatedAt: { + return Variant( session.updatedAt.c_str() ); + } + case Columns::SessionId: { + return Variant( session.sessionId.c_str() ); + } + } + + return {}; + } + + void setFilter( const std::string& filter ) { + if ( mCurFilter == filter ) + return; + mCurFilter = filter; + mCurSessions.clear(); + + for ( const auto& session : mSessions ) { + if ( filter.empty() ) { + mCurSessions.emplace_back( &session ); + continue; + } + + bool matchesTitle = String::icontains( session.title, filter ); + bool matchesId = String::icontains( session.sessionId, filter ); + + if ( matchesTitle || matchesId ) { + mCurSessions.emplace_back( &session ); + } + } + + invalidate(); + } + + protected: + std::vector mSessions; + std::vector mCurSessions; + std::string mCurFilter; + UISceneNode* mUISceneNode; +}; + static const char* DEFAULT_PROVIDER = "google"; static const char* DEFAULT_MODEL = "gemini-2.5-flash"; @@ -1002,6 +1095,9 @@ void LLMChatUI::showChatHistory() { UIWindow* win = getUISceneNode()->loadLayoutFromString( CHAT_HISTORY_LAYOUT )->asType(); + if ( mIsAgentMode ) { + win->setTitle( i18n( "ai_agent_sessions_history", "AI Agent Sessions" ) ); + } UITextInput* input = win->find( "search_input" )->asType(); UILoader* loader = win->find( "loader" )->asType(); UITableView* tv = win->find( "table" )->asType(); @@ -1010,6 +1106,11 @@ void LLMChatUI::showChatHistory() { UITextInput* daysNum = win->find( "days_num" )->asType(); UIPushButton* deleteChatsBut = win->find( "delete_but" )->asType(); + if ( mIsAgentMode ) { + clearHistory->setVisible( false ); + clearHistory->setEnabled( false ); + } + win->setMinWindowSize( getUISceneNode()->getSize().getWidth() * 0.5f, getUISceneNode()->getSize().getHeight() * 0.7f ); win->setKeyBindingCommand( "closeWindow", [win, this] { @@ -1026,61 +1127,89 @@ void LLMChatUI::showChatHistory() { tv->setAutoColumnsWidth( true ); tv->setFitAllColumnsToWidget( true ); - tv->setSetupCellCb( [this, tv, win, input]( UITableCell* cell ) { - if ( cell->getCurIndex().column() != ChatHistoryModel::Delete ) - return; - - cell->onClick( [this, tv, cell, win, input]( const MouseEvent* ) { - ChatHistoryModel* model = static_cast( tv->getModel() ); - auto summary = - model->data( model->index( cell->getCurIndex().row(), ChatHistoryModel::Summary ) ) - .toString(); - auto msgBox = - UIMessageBox::New( UIMessageBox::OK_CANCEL, - String::format( i18n( "confirm_del_llm_chat", - "Are you sure you want to remove \"%s?\"" ) - .toUtf8(), - summary ) ); - msgBox->setParent( win ); - msgBox->center(); - msgBox->showWhenReady(); - msgBox->on( Event::OnConfirm, [model, cell, input]( auto ) { - model->remove( cell->getCurIndex() ); - input->setFocus(); - } ); + if ( !mIsAgentMode ) { + tv->setSetupCellCb( [this, tv, win, input]( UITableCell* cell ) { + if ( cell->getCurIndex().column() == ChatHistoryModel::Delete ) { + cell->onClick( [this, tv, cell, win, input]( const MouseEvent* ) { + ChatHistoryModel* model = static_cast( tv->getModel() ); + auto summary = model + ->data( model->index( cell->getCurIndex().row(), + ChatHistoryModel::Summary ) ) + .toString(); + auto msgBox = UIMessageBox::New( + UIMessageBox::OK_CANCEL, + String::format( i18n( "confirm_del_llm_chat", + "Are you sure you want to remove \"%s?\"" ) + .toUtf8(), + summary ) ); + msgBox->setParent( win ); + msgBox->center(); + msgBox->showWhenReady(); + msgBox->on( Event::OnConfirm, [model, cell, input]( auto ) { + model->remove( cell->getCurIndex() ); + input->setFocus(); + } ); + } ); + } } ); - } ); + } input->on( Event::KeyDown, [tv, input]( const Event* event ) { tv->forceKeyDown( *event->asKeyEvent() ); input->setFocus(); } ); - input->on( Event::OnTextChanged, [tv, input]( const Event* ) { - ChatHistoryModel* model = static_cast( tv->getModel() ); - model->setFilter( input->getText().toUtf8() ); - if ( tv->getSelection().isEmpty() && !model->getCurHistory().empty() ) - tv->setSelection( model->index( 0, 0 ) ); + input->on( Event::OnTextChanged, [this, tv, input]( const Event* ) { + if ( mIsAgentMode ) { + AgentSessionHistoryModel* model = + static_cast( tv->getModel() ); + model->setFilter( input->getText().toUtf8() ); + if ( tv->getSelection().isEmpty() && model->rowCount() > 0 ) + tv->setSelection( model->index( 0, 0 ) ); + } else { + ChatHistoryModel* model = static_cast( tv->getModel() ); + model->setFilter( input->getText().toUtf8() ); + if ( tv->getSelection().isEmpty() && !model->getCurHistory().empty() ) + tv->setSelection( model->index( 0, 0 ) ); + } } ); const auto openCurrentSelectedModelItem = [this, tv] { - auto* model = static_cast( tv->getModel() ); ModelIndex index; if ( !tv->getSelection().isEmpty() ) index = tv->getSelection().first(); - else if ( !model->getCurHistory().empty() ) - index = model->index( 0, 0 ); + else if ( tv->getModel() && tv->getModel()->rowCount() > 0 ) + index = tv->getModel()->index( 0, 0 ); if ( getPlugin() == nullptr || !index.isValid() ) return; - auto* chatUI = getPlugin()->newAIAssistant(); - auto path = model->data( model->index( index.row(), ChatHistoryModel::Path ) ); - std::string data; - FileSystem::fileGet( path.toString(), data ); - nlohmann::json j = nlohmann::json::parse( data, nullptr, false ); - auto input = chatUI->unserialize( j ); - if ( !input.empty() ) - chatUI->mChatInput->getDocument().textInput( input ); - chatUI->setFocus(); + + if ( mIsAgentMode ) { + auto* model = static_cast( tv->getModel() ); + auto sessionId = + model + ->data( model->index( index.row(), AgentSessionHistoryModel::SessionId ), + ModelRole::Custom ) + .toString(); + if ( !sessionId.empty() ) { + auto* chatUI = getPlugin()->newAIAssistant(); + nlohmann::json fakePayload = { { "agent_mode", true }, + { "agent_name", mCurAgent }, + { "session_id", sessionId } }; + chatUI->unserialize( fakePayload ); + chatUI->setFocus(); + } + } else { + auto* model = static_cast( tv->getModel() ); + auto* chatUI = getPlugin()->newAIAssistant(); + auto path = model->data( model->index( index.row(), ChatHistoryModel::Path ) ); + std::string data; + FileSystem::fileGet( path.toString(), data ); + nlohmann::json j = nlohmann::json::parse( data, nullptr, false ); + auto input = chatUI->unserialize( j ); + if ( !input.empty() ) + chatUI->mChatInput->getDocument().textInput( input ); + chatUI->setFocus(); + } }; input->on( Event::OnPressEnter, [win, openCurrentSelectedModelItem]( const Event* ) { @@ -1101,24 +1230,71 @@ void LLMChatUI::showChatHistory() { std::string winId = win->getId(); UISceneNode* uiSceneNode = getUISceneNode(); - getUISceneNode()->getThreadPool()->run( - [plugin, loader, input, tv, winId = std::move( winId ), uiSceneNode, clearHistory] { - std::string conversationsPath = plugin->getConversationsPath(); - auto model = std::make_shared( - ChatHistory::getHistory( conversationsPath ), uiSceneNode ); - if ( uiSceneNode->find( winId ) == nullptr ) // Window closed? - return; - tv->runOnMainThread( [tv, loader, input, model, clearHistory] { - loader->setVisible( false ); - input->setVisible( true ); - tv->setVisible( true ); - clearHistory->setVisible( true ); - tv->setModel( model ); - tv->setColumnsVisible( { ChatHistoryModel::Summary, ChatHistoryModel::DateTime, - ChatHistoryModel::Delete } ); - input->setFocus(); + + if ( mIsAgentMode ) { + if ( !mAgentSession ) { + setupAgentSession(); + mAgentSession->start( [this, winId, uiSceneNode, tv, loader, input]( bool ready ) { + if ( !ready ) { + uiSceneNode->runOnMainThread( [loader, winId, uiSceneNode] { + if ( uiSceneNode->find( winId ) == nullptr ) + return; + loader->setVisible( false ); + } ); + return; + } + mAgentSession->listSessions( [winId, uiSceneNode, tv, loader, input]( + const std::vector& sessions ) { + uiSceneNode->runOnMainThread( + [sessions, winId, uiSceneNode, tv, loader, input] { + if ( uiSceneNode->find( winId ) == nullptr ) + return; + auto model = + std::make_shared( sessions, uiSceneNode ); + loader->setVisible( false ); + input->setVisible( true ); + tv->setVisible( true ); + tv->setModel( model ); + input->setFocus(); + } ); + } ); } ); - } ); + } else { + mAgentSession->listSessions( [winId, uiSceneNode, tv, loader, + input]( const std::vector& sessions ) { + uiSceneNode->runOnMainThread( [sessions, winId, uiSceneNode, tv, loader, input] { + if ( uiSceneNode->find( winId ) == nullptr ) + return; + auto model = + std::make_shared( sessions, uiSceneNode ); + loader->setVisible( false ); + input->setVisible( true ); + tv->setVisible( true ); + tv->setModel( model ); + input->setFocus(); + } ); + } ); + } + } else { + getUISceneNode()->getThreadPool()->run( + [plugin, loader, input, tv, winId = std::move( winId ), uiSceneNode, clearHistory] { + std::string conversationsPath = plugin->getConversationsPath(); + auto model = std::make_shared( + ChatHistory::getHistory( conversationsPath ), uiSceneNode ); + if ( uiSceneNode->find( winId ) == nullptr ) // Window closed? + return; + tv->runOnMainThread( [tv, loader, input, model, clearHistory] { + loader->setVisible( false ); + input->setVisible( true ); + tv->setVisible( true ); + clearHistory->setVisible( true ); + tv->setModel( model ); + tv->setColumnsVisible( { ChatHistoryModel::Summary, ChatHistoryModel::DateTime, + ChatHistoryModel::Delete } ); + input->setFocus(); + } ); + } ); + } deleteChatsBut->onClick( [this, daysNum]( auto ) { int days = 0; @@ -1240,6 +1416,7 @@ bool LLMChatUI::selectAgent( const std::string& agent ) { if ( !agent.empty() && mAgents.find( agent ) != mAgents.end() ) { mAgentBtn->setText( agent ); mCurAgent = agent; + updateTabTitle(); return true; } return false; @@ -1356,12 +1533,16 @@ void LLMChatUI::initSelectAgent() { } ); } +UIWidget* LLMChatUI::getLastConversation() const { + return mChatsList->querySelector( ".llm_conversation:last-child" ); +} + void LLMChatUI::writeToLastChat( const std::string& text ) { runOnMainThread( [this, text] { - auto chats = mChatsList->findAllByClass( "llm_conversation" ); - if ( chats.empty() ) - return; - auto* chat = chats.back(); + UIWidget* chatAdded = getLastConversation(); + if ( nullptr == chatAdded ) + chatAdded = addChatUI( LLMChat::Role::Assistant ); + auto* chat = chatAdded; auto* editor = chat->findByClass( "data_ui" ); auto* thinking = editor->findByClass( "thinking" ); auto thinkingID = String::hash( String::format( "thinking-%p", thinking ) ); @@ -1576,9 +1757,8 @@ void LLMChatUI::sendAgentPrompt() { mAgentSession->prompt( req, [this]( const acp::PromptResponse& res ) { runOnMainThread( [this, res]() { - auto chats = mChatsList->findAllByClass( "llm_conversation" ); - if ( !chats.empty() ) { - auto* chat = chats.back(); + auto chat = getLastConversation(); + if ( chat ) { auto* editor = chat->findByClass( "data_ui" ); auto* thinking = editor->findByClass( "thinking" ); if ( thinking ) { @@ -1725,7 +1905,9 @@ nlohmann::json LLMChatUI::serialize() { mTimestamp = std::chrono::system_clock::to_time_t( std::chrono::system_clock::now() ); nlohmann::json j; j["uuid"] = mUUID.toString(); - j["chat"] = serializeChat( mCurModel ); + if ( !mIsAgentMode ) { + j["chat"] = serializeChat( mCurModel ); + } j["provider"] = mCurModel.provider; j["timestamp"] = mTimestamp; j["summary"] = mSummary; @@ -1778,7 +1960,7 @@ std::string LLMChatUI::unserialize( const nlohmann::json& payload ) { fillModelDropDownList(); } - if ( payload.contains( "chat" ) && payload["chat"].is_object() ) { + if ( !mIsAgentMode && payload.contains( "chat" ) && payload["chat"].is_object() ) { const auto& chat = payload["chat"]; const auto& messages = chat["messages"]; for ( const auto& chat : messages ) { @@ -1827,6 +2009,8 @@ std::string LLMChatUI::getFilePath() const { } void LLMChatUI::saveChat() { + if ( mIsAgentMode ) + return; auto plugin = getPlugin(); if ( plugin == nullptr || mChatIsPrivate ) return; @@ -2193,9 +2377,8 @@ void LLMChatUI::addChat( LLMChat::Role role, std::string conversation ) { } void LLMChatUI::removeLastChat() { - auto chats = mChatsList->findAllByClass( "llm_conversation" ); - if ( !chats.empty() ) { - auto* chat = chats[chats.size() - 1]; + auto* chat = getLastConversation(); + if ( chat ) { auto* editor = chat->findByClass( "data_ui" ); if ( editor->getDocument().isEmpty() ) chat->close(); diff --git a/src/tools/ecode/plugins/aiassistant/chatui.hpp b/src/tools/ecode/plugins/aiassistant/chatui.hpp index 97a0039e0..8fa063f81 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.hpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.hpp @@ -1,7 +1,7 @@ #pragma once #include "../pluginmanager.hpp" -#include "agentsession.hpp" +#include "acp/agentsession.hpp" #include "llmchatcompletionrequest.hpp" #include "protocol.hpp" @@ -270,6 +270,8 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { void generateChatName( bool isRenaming ); void regenerateChatName(); + + UIWidget* getLastConversation() const; }; } // namespace ecode From 721948c4e04b8eba3241201c657f7c64a7c9fac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sun, 22 Mar 2026 00:41:09 -0300 Subject: [PATCH 05/10] Error handle. --- .../plugins/aiassistant/acp/acpclient.cpp | 52 +++++--- .../plugins/aiassistant/acp/acpclient.hpp | 17 ++- .../plugins/aiassistant/acp/acpprotocol.hpp | 16 +++ .../plugins/aiassistant/acp/agentsession.cpp | 109 +++++++++++------ .../plugins/aiassistant/acp/agentsession.hpp | 9 +- .../ecode/plugins/aiassistant/chatui.cpp | 112 +++++++++++------- 6 files changed, 214 insertions(+), 101 deletions(-) diff --git a/src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp b/src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp index 3900064e8..c02af7512 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp +++ b/src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp @@ -152,6 +152,8 @@ void ACPClient::processResponse( const json& msg ) { if ( handler ) { handler( id, msg ); + } else if ( msg.contains( "error" ) && onError ) { + onError( ResponseError( msg["error"] ) ); } } @@ -194,54 +196,74 @@ void ACPClient::sendError( const json& id, int code, const std::string& message } } -void ACPClient::initialize( const InitializeRequest& req, - const std::function& cb ) { +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"] ) ); + cb( InitializeResponse( resp["result"] ), std::nullopt ); + } else if ( resp.contains( "error" ) ) { + if ( cb ) + cb( {}, ResponseError( resp["error"] ) ); } } ); } -void ACPClient::newSession( const NewSessionRequest& req, - const std::function& cb ) { +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"] ) ); + cb( NewSessionResponse( resp["result"] ), std::nullopt ); + } else if ( resp.contains( "error" ) && cb ) { + cb( {}, ResponseError( resp["error"] ) ); } } ); } -void ACPClient::loadSession( const LoadSessionRequest& req, - const std::function& cb ) { +void ACPClient::loadSession( + const LoadSessionRequest& req, + const std::function& )>& + cb ) { write( { { "method", "session/load" }, { "params", req.toJson() } }, [cb]( const IdType&, const json& resp ) { if ( resp.contains( "result" ) && cb ) { - cb( LoadSessionResponse( resp["result"] ) ); + cb( LoadSessionResponse( resp["result"] ), std::nullopt ); + } else if ( resp.contains( "error" ) && cb ) { + cb( {}, ResponseError( resp["error"] ) ); } } ); } -void ACPClient::listSessions( const ListSessionsRequest& req, - const std::function& cb ) { +void ACPClient::listSessions( + const ListSessionsRequest& req, + const std::function& )>& + cb ) { write( { { "method", "session/list" }, { "params", req.toJson() } }, [cb]( const IdType&, const json& resp ) { if ( resp.contains( "result" ) && cb ) { - cb( ListSessionsResponse( resp["result"] ) ); + cb( ListSessionsResponse( resp["result"] ), std::nullopt ); + } else if ( resp.contains( "error" ) && cb ) { + cb( {}, ResponseError( resp["error"] ) ); } } ); } -void ACPClient::prompt( const PromptRequest& req, - const std::function& cb ) { +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"] ) ); + cb( PromptResponse( resp["result"] ), std::nullopt ); + } else if ( resp.contains( "error" ) && cb ) { + cb( {}, ResponseError( resp["error"] ) ); } } ); } diff --git a/src/tools/ecode/plugins/aiassistant/acp/acpclient.hpp b/src/tools/ecode/plugins/aiassistant/acp/acpclient.hpp index 98dab8993..57af63cf2 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/acpclient.hpp +++ b/src/tools/ecode/plugins/aiassistant/acp/acpclient.hpp @@ -44,19 +44,26 @@ class ACPClient { const Config& getConfig() const { return mConfig; } void initialize( const InitializeRequest& req, - const std::function& cb ); + const std::function& )>& cb ); void newSession( const NewSessionRequest& req, - const std::function& cb ); + const std::function& )>& cb ); void loadSession( const LoadSessionRequest& req, - const std::function& cb ); + const std::function& )>& cb ); void listSessions( const ListSessionsRequest& req, - const std::function& cb ); - void prompt( const PromptRequest& req, const std::function& cb ); + 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 onError; std::function onSessionUpdate; std::function )> diff --git a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp index 4b66363e0..e4ffb85da 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp +++ b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp @@ -105,6 +105,22 @@ struct ListSessionsResponse { ListSessionsResponse( const json& body ); }; +struct ResponseError { + int code{ 0 }; + std::string message; + json data; + + ResponseError() = default; + ResponseError( const json& body ) { + if ( body.contains( "code" ) ) + code = body["code"].get(); + if ( body.contains( "message" ) ) + message = body["message"].get(); + if ( body.contains( "data" ) ) + data = body["data"]; + } +}; + struct PromptRequest { std::string sessionId; json prompt; // Array of ContentBlock diff --git a/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp b/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp index cd72a865e..629a35d62 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp +++ b/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp @@ -21,15 +21,28 @@ bool AgentSession::start( const std::function& onReady ) { 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 ); + mClient->initialize( + req, [this, onReady]( const InitializeResponse&, const std::optional& err ) { + if ( err ) { + if ( onReady ) + onReady( false ); + return; + } + NewSessionRequest nreq; + nreq.cwd = mClient->isReady() ? mClient->getConfig().workingDirectory : ""; + mClient->newSession( + nreq, [this, onReady]( const NewSessionResponse& nres, + const std::optional& err ) { + if ( err ) { + if ( onReady ) + onReady( false ); + return; + } + mSessionId = nres.sessionId; + if ( onReady ) + onReady( true ); + } ); } ); - } ); return true; } if ( onReady ) @@ -45,23 +58,35 @@ bool AgentSession::startLoaded( const std::string& sessionId, req.clientCapabilities.fsReadTextFile = true; req.clientCapabilities.fsWriteTextFile = true; - mClient->initialize( req, [this, sessionId, onReady]( const InitializeResponse& ires ) { - if ( ires.agentCapabilities.loadSession ) { - LoadSessionRequest lreq; - lreq.sessionId = sessionId; - lreq.cwd = mClient->isReady() ? mClient->getConfig().workingDirectory : ""; - mClient->loadSession( lreq, [this, sessionId, onReady]( const LoadSessionResponse& ) { - mSessionId = sessionId; + mClient->initialize( + req, [this, sessionId, onReady]( const InitializeResponse& ires, + const std::optional& err ) { + if ( err ) { if ( onReady ) - onReady( true ); - } ); - } else { - // Agent doesn't support loading, fallback to new session? - // For now let's just fail or call onReady(false) - if ( onReady ) - onReady( false ); - } - } ); + onReady( false ); + return; + } + if ( ires.agentCapabilities.loadSession ) { + LoadSessionRequest lreq; + lreq.sessionId = sessionId; + lreq.cwd = mClient->isReady() ? mClient->getConfig().workingDirectory : ""; + mClient->loadSession( + lreq, [this, sessionId, onReady]( const LoadSessionResponse&, + const std::optional& err ) { + if ( err ) { + if ( onReady ) + onReady( false ); + return; + } + mSessionId = sessionId; + if ( onReady ) + onReady( true ); + } ); + } else { + if ( onReady ) + onReady( false ); + } + } ); return true; } if ( onReady ) @@ -69,16 +94,21 @@ bool AgentSession::startLoaded( const std::string& sessionId, return false; } -void AgentSession::listSessions( const std::function& )>& cb ) { +void AgentSession::listSessions( + const std::function&, const std::optional& )>& + cb ) { if ( !mClient->isReady() ) { - if ( cb ) cb( {} ); + if ( cb ) + cb( {}, std::nullopt ); return; } ListSessionsRequest req; req.cwd = mClient->getConfig().workingDirectory; - mClient->listSessions( req, [cb]( const ListSessionsResponse& res ) { - if ( cb ) cb( res.sessions ); - } ); + mClient->listSessions( + req, [cb]( const ListSessionsResponse& res, const std::optional& err ) { + if ( cb ) + cb( res.sessions, err ); + } ); } void AgentSession::stop() { @@ -86,14 +116,16 @@ void AgentSession::stop() { mClient->stop(); } -void AgentSession::prompt( const PromptRequest& req, - const std::function& cb ) { +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); - } ); + mClient->prompt( + req, [this, cb]( const PromptResponse& res, const std::optional& err ) { + mIsPrompting = false; + if ( cb ) + cb( res, err ); + } ); } void AgentSession::cancel() { @@ -108,6 +140,11 @@ void AgentSession::setTerminalData( const std::string& terminalId, UITerminal* u } void AgentSession::setupClient() { + mClient->onError = [this]( const ResponseError& err ) { + if ( onError ) + onError( err ); + }; + mClient->onSessionUpdate = [this]( const json& msg ) { if ( onSessionUpdate ) onSessionUpdate( msg ); diff --git a/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp b/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp index 79aaee670..4093f1390 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp +++ b/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp @@ -22,14 +22,19 @@ class AgentSession { bool start( const std::function& onReady ); bool startLoaded( const std::string& sessionId, const std::function& onReady ); - void listSessions( const std::function& )>& cb ); + void listSessions( + const std::function&, const std::optional& )>& + cb ); void stop(); - void prompt( const PromptRequest& req, const std::function& cb ); + void prompt( const PromptRequest& req, + const std::function& )>& + cb ); void cancel(); bool isPrompting() const { return mIsPrompting; } + std::function onError; std::function onSessionUpdate; std::function )> diff --git a/src/tools/ecode/plugins/aiassistant/chatui.cpp b/src/tools/ecode/plugins/aiassistant/chatui.cpp index 6f68404a9..3d71f6917 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.cpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.cpp @@ -281,8 +281,11 @@ class AgentSessionHistoryModel : public Model { switch ( index.column() ) { case Columns::Title: { - return Variant( session.title.empty() ? "Untitled Session" - : session.title.c_str() ); + return Variant( session.title.empty() + ? ( mUISceneNode ? mUISceneNode->i18n( "untitled_session", + "Untitled Session" ) + : "Untitled Session" ) + : session.title.c_str() ); } case Columns::UpdatedAt: { return Variant( session.updatedAt.c_str() ); @@ -532,9 +535,10 @@ static const char* DEFAULT_CHAT_GLOBE = R"xml( static const char* DEFAULT_PERMISSION_GLOBE = R"xml( - - - + + + + @@ -561,6 +565,7 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : mChatAgentMode = find( "llm_agent_mode" ); mChatAgentMode->on( Event::OnValueChange, [this]( auto ) { mIsAgentMode = mChatAgentMode->isSelected(); + updateTabTitle(); updateAgentModeUI(); } ); @@ -1232,9 +1237,34 @@ void LLMChatUI::showChatHistory() { UISceneNode* uiSceneNode = getUISceneNode(); if ( mIsAgentMode ) { + auto listSessionsCb = [winId, uiSceneNode, tv, loader, input]( + const std::vector& sessions, + const std::optional& err ) { + uiSceneNode->runOnMainThread( [winId, uiSceneNode, tv, loader, input, sessions, err] { + auto win = uiSceneNode->find( winId ); + if ( win == nullptr ) + return; + if ( err ) { + loader->setVisible( false ); + NotificationCenter::instance()->addNotification( + uiSceneNode->i18n( "ai_assistant_agent_error", "Agent Error: " ) + + err->message ); + win->asType()->closeWindow(); + return; + } + auto model = std::make_shared( sessions, uiSceneNode ); + loader->setVisible( false ); + input->setVisible( true ); + tv->setVisible( true ); + tv->setModel( model ); + input->setFocus(); + } ); + }; + if ( !mAgentSession ) { setupAgentSession(); - mAgentSession->start( [this, winId, uiSceneNode, tv, loader, input]( bool ready ) { + + mAgentSession->start( [this, winId, uiSceneNode, loader, listSessionsCb]( bool ready ) { if ( !ready ) { uiSceneNode->runOnMainThread( [loader, winId, uiSceneNode] { if ( uiSceneNode->find( winId ) == nullptr ) @@ -1243,37 +1273,10 @@ void LLMChatUI::showChatHistory() { } ); return; } - mAgentSession->listSessions( [winId, uiSceneNode, tv, loader, input]( - const std::vector& sessions ) { - uiSceneNode->runOnMainThread( - [sessions, winId, uiSceneNode, tv, loader, input] { - if ( uiSceneNode->find( winId ) == nullptr ) - return; - auto model = - std::make_shared( sessions, uiSceneNode ); - loader->setVisible( false ); - input->setVisible( true ); - tv->setVisible( true ); - tv->setModel( model ); - input->setFocus(); - } ); - } ); + mAgentSession->listSessions( listSessionsCb ); } ); } else { - mAgentSession->listSessions( [winId, uiSceneNode, tv, loader, - input]( const std::vector& sessions ) { - uiSceneNode->runOnMainThread( [sessions, winId, uiSceneNode, tv, loader, input] { - if ( uiSceneNode->find( winId ) == nullptr ) - return; - auto model = - std::make_shared( sessions, uiSceneNode ); - loader->setVisible( false ); - input->setVisible( true ); - tv->setVisible( true ); - tv->setModel( model ); - input->setFocus(); - } ); - } ); + mAgentSession->listSessions( listSessionsCb ); } } else { getUISceneNode()->getThreadPool()->run( @@ -1596,10 +1599,11 @@ void LLMChatUI::setupAgentSession() { writeToLastChat( chunk ); } } else if ( sessionUpdate == "tool_call" ) { - std::string toolStr = "\n> Tool Call: " + msg.value( "title", "" ) + "\n"; + std::string toolStr = "\n> " + i18n( "tool_call", "Tool Call: " ) + + msg.value( "title", "" ) + "\n"; writeToLastChat( toolStr ); } else if ( sessionUpdate == "plan" ) { - std::string planStr = "\n> Plan Updated:\n"; + std::string planStr = "\n> " + i18n( "plan_updated", "Plan Updated:" ) + "\n"; if ( msg.contains( "plan" ) && msg["plan"].contains( "steps" ) && msg["plan"]["steps"].is_array() ) { for ( const auto& step : msg["plan"]["steps"] ) { @@ -1623,7 +1627,13 @@ void LLMChatUI::setupAgentSession() { runOnMainThread( [this, req, cb]() { addPermissionUI( req, cb ); } ); }; + mAgentSession->onError = [this]( const acp::ResponseError& err ) { + NotificationCenter::instance()->addNotification( + i18n( "ai_assistant_agent_error", "Agent Error: " ) + err.message ); + }; + mAgentSession->onTerminalCreated = [this]( const acp::CreateTerminalRequest& req, + const std::string& termId ) { runOnMainThread( [this, req, termId] { find( "chat_presentation" )->setVisible( false ); @@ -1755,7 +1765,22 @@ void LLMChatUI::sendAgentPrompt() { req.prompt = { { { "type", "text" }, { "text", "" } } }; } - mAgentSession->prompt( req, [this]( const acp::PromptResponse& res ) { + mAgentSession->prompt( req, [this]( const acp::PromptResponse& res, + const std::optional& err ) { + if ( err ) { + runOnMainThread( [this] { + mChatStop->setVisible( false )->setEnabled( false ); + mChatRun->setVisible( true )->setEnabled( true ); + toggleEnableChats( true ); + auto lastChat = getLastConversation(); + if ( lastChat ) { + auto* thinking = lastChat->findByClass( "thinking" ); + if ( thinking ) + thinking->setVisible( false ); + } + } ); + return; + } runOnMainThread( [this, res]() { auto chat = getLastConversation(); if ( chat ) { @@ -2241,14 +2266,15 @@ void LLMChatUI::addPermissionUI( const acp::RequestPermissionRequest& req, 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"; + std::string descStr = + i18n( "agent_wants_to_execute_tool_call", "The agent wants to execute a tool call:" ) + + "\n"; + descStr += i18n( "title", "Title" ) + ": " + req.toolCall.title + "\n"; + descStr += i18n( "kind", "Kind" ) + ": " + req.toolCall.kind + "\n"; if ( !req.toolCall.rawInput.is_null() ) { - descStr += "Input:\n" + req.toolCall.rawInput.dump( 2 ) + "\n"; + descStr += i18n( "input", "Input" ) + ":\n" + req.toolCall.rawInput.dump( 2 ) + "\n"; } desc->setText( descStr ); - UIWidget* optionsBox = chat->findByClass( "permission_options" ); bool isFirst = true; From 3b3a6dc78d62925c4a9de731cf4637e2347e552e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sun, 22 Mar 2026 00:50:21 -0300 Subject: [PATCH 06/10] Styling agentic conversation. --- .../ecode/plugins/aiassistant/chatui.cpp | 347 ++++++++++++++---- .../ecode/plugins/aiassistant/chatui.hpp | 17 +- 2 files changed, 295 insertions(+), 69 deletions(-) diff --git a/src/tools/ecode/plugins/aiassistant/chatui.cpp b/src/tools/ecode/plugins/aiassistant/chatui.cpp index 3d71f6917..41cafc60d 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.cpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -399,6 +400,11 @@ static const char* DEFAULT_LAYOUT = R"xml( .llm_chat_input { border-radius: 8dp 0dp 8dp 8dp; } +.llm_thought { + background-color: var(--list-back); + padding: 4dp 4dp 4dp 8dp; + border-left: 2dp solid var(--tab-line); +} CodeEditor.llm_chat_input { font-family: monospace; border-radius: 8dp; @@ -454,15 +460,18 @@ DropDownList.role_ui { background-position: top 8dp right 8dp; } .llm_chatui DropDownList, -.model_ui { +.model_ui, +.agent_ui { border-color: transparent; background-color: var(--tab-back); } .llm_chatui DropDownList:hover, -.model_ui:hover { +.model_ui:hover, +.agent_ui:hover { border-color: var(--primary); } -.model_ui { +.model_ui, +.agent_ui { padding-right: 16dp; text-align: left; text-overflow: ellipsis; @@ -516,6 +525,18 @@ DropDownList.role_ui { )xml"; +static const char* DEFAULT_PLAN_GLOBE = R"xml( + +)xml"; + +static const char* DEFAULT_TOOL_CALL_GLOBE = R"xml( + +)xml"; + +static const char* DEFAULT_THINKING_GLOBE = R"xml( + +)xml"; + static const char* DEFAULT_CHAT_GLOBE = R"xml( @@ -738,6 +759,11 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : } ); setCmd( "ai-select-model", [this] { + if ( mIsAgentMode ) { + execute( "ai-select-agent" ); + return; + } + if ( !mLocateModelBarLayout->isVisible() ) { if ( mLocateModelTable->getModel() ) { mLocateModelInput->setText( "" ); @@ -1237,9 +1263,9 @@ void LLMChatUI::showChatHistory() { UISceneNode* uiSceneNode = getUISceneNode(); if ( mIsAgentMode ) { - auto listSessionsCb = [winId, uiSceneNode, tv, loader, input]( - const std::vector& sessions, - const std::optional& err ) { + auto listSessionsCb = [winId, uiSceneNode, tv, loader, + input]( const std::vector& sessions, + const std::optional& err ) { uiSceneNode->runOnMainThread( [winId, uiSceneNode, tv, loader, input, sessions, err] { auto win = uiSceneNode->find( winId ); if ( win == nullptr ) @@ -1536,25 +1562,68 @@ void LLMChatUI::initSelectAgent() { } ); } -UIWidget* LLMChatUI::getLastConversation() const { - return mChatsList->querySelector( ".llm_conversation:last-child" ); +UIWidget* LLMChatUI::getLastConversation( bool skipEmpty ) const { + auto conversations = mChatsList->findAllByClass( "llm_conversation" ); + for ( int i = (int)conversations.size() - 1; i >= 0; --i ) { + auto* chat = conversations[i]; + if ( !chat->hasClass( "llm_plan" ) && !chat->hasClass( "llm_tool_call" ) && + !chat->hasClass( "llm_thought" ) ) { + if ( skipEmpty ) { + auto* editorNode = chat->findByClass( "data_ui" ); + if ( editorNode && editorNode->isType( UI_TYPE_CODEEDITOR ) ) { + if ( editorNode->asType()->getDocument().isEmpty() ) + continue; + } + } + return chat; + } + } + return nullptr; +} + +void LLMChatUI::removeWaitingBubble() { + auto waitingNodes = mChatsList->findAllByClass( "llm_waiting" ); + for ( auto* node : waitingNodes ) { + node->close(); + } } void LLMChatUI::writeToLastChat( const std::string& text ) { runOnMainThread( [this, text] { - UIWidget* chatAdded = getLastConversation(); - if ( nullptr == chatAdded ) + removeWaitingBubble(); + + UIWidget* chatAdded = nullptr; + if ( mChatsList->getChildCount() > 0 ) { + auto* lastChild = mChatsList->getLastChild()->asType(); + if ( lastChild->hasClass( "llm_conversation" ) && !lastChild->hasClass( "llm_plan" ) && + !lastChild->hasClass( "llm_tool_call" ) && + !lastChild->hasClass( "llm_thought" ) ) { + auto* roleDDL = lastChild->findByClass( "role_ui" ); + if ( roleDDL && roleDDL->getListBox()->getItemSelectedIndex() == 1 ) { + chatAdded = lastChild; + } + } + } + + if ( nullptr == chatAdded ) { chatAdded = addChatUI( LLMChat::Role::Assistant ); - auto* chat = chatAdded; - auto* editor = chat->findByClass( "data_ui" ); + } + + auto* editor = chatAdded->findByClass( "data_ui" ); + if ( !editor ) + return; + auto* thinking = editor->findByClass( "thinking" ); - auto thinkingID = String::hash( String::format( "thinking-%p", thinking ) ); + if ( thinking ) { + auto thinkingID = String::hash( String::format( "thinking-%p", thinking ) ); + thinking->removeActionsByTag( thinkingID ); + thinking->setVisible( false ); + } editor->getDocument().textInput( String::fromUtf8( text ) ); editor->setCursorVisible( false ); - thinking->removeActionsByTag( thinkingID ); - thinking->setVisible( false ); resizeToFit( editor ); + mChatScrollView->getVerticalScrollBar()->setValue( 1.0f ); } ); } @@ -1593,25 +1662,39 @@ void LLMChatUI::setupAgentSession() { mAgentSession->onSessionUpdate = [this]( const nlohmann::json& msg ) { auto sessionUpdate = msg.value( "sessionUpdate", "" ); - if ( sessionUpdate == "agent_message_chunk" || sessionUpdate == "agent_thought_chunk" ) { + if ( sessionUpdate == "agent_message_chunk" ) { if ( msg.contains( "content" ) && msg["content"].contains( "text" ) ) { + mThinkingBubble = nullptr; // Reset thinking bubble so next thought gets a new one auto chunk = msg["content"].value( "text", "" ); writeToLastChat( chunk ); } + } else if ( sessionUpdate == "agent_thought_chunk" ) { + if ( msg.contains( "content" ) && msg["content"].contains( "text" ) ) { + auto chunk = msg["content"].value( "text", "" ); + runOnMainThread( [this, chunk] { updateThinkingBubble( chunk ); } ); + } } else if ( sessionUpdate == "tool_call" ) { - std::string toolStr = "\n> " + i18n( "tool_call", "Tool Call: " ) + - msg.value( "title", "" ) + "\n"; - writeToLastChat( toolStr ); + std::string toolMarkdown = + "> 🛠️ " + i18n( "tool_call", "Tool Call: " ) + msg.value( "title", "" ) + "\n"; + addToolCallBubble( toolMarkdown ); } else if ( sessionUpdate == "plan" ) { - std::string planStr = "\n> " + i18n( "plan_updated", "Plan Updated:" ) + "\n"; + std::string planMarkdown = "> 📋 " + i18n( "plan_updated", "Plan Updated:" ) + "\n"; if ( msg.contains( "plan" ) && msg["plan"].contains( "steps" ) && msg["plan"]["steps"].is_array() ) { for ( const auto& step : msg["plan"]["steps"] ) { - planStr += "- [" + step.value( "status", "" ) + "] " + - step.value( "title", "" ) + "\n"; + std::string status = step.value( "status", "" ); + std::string statusIcon = "⏳"; + if ( status == "completed" ) + statusIcon = "✅"; + else if ( status == "running" ) + statusIcon = "🔄"; + else if ( status == "failed" ) + statusIcon = "❌"; + + planMarkdown += "- " + statusIcon + " " + step.value( "title", "" ) + "\n"; } } - writeToLastChat( planStr ); + addPlanBubble( planMarkdown ); } else if ( sessionUpdate == "available_commands_update" ) { if ( msg.contains( "availableCommands" ) && msg["availableCommands"].is_array() ) { mAvailableCommands.clear(); @@ -1676,6 +1759,7 @@ void LLMChatUI::doAgentRequest() { setupAgentSession(); UIWidget* chat = addChatUI( LLMChat::Role::Assistant ); + chat->addClass( "llm_waiting" ); toggleEnableChats( false ); auto* editor = chat->findByClass( "data_ui" ); editor->setEnabled( false ); @@ -1691,20 +1775,10 @@ void LLMChatUI::doAgentRequest() { 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." ); + showMsg( i18n( "failed_to_start_agent", "Failed to start agent process." ) ); mAgentSession.reset(); } } ); @@ -1715,6 +1789,7 @@ void LLMChatUI::doAgentRequest() { mChatStop->setVisible( true )->setEnabled( true ); UIWidget* chat = addChatUI( LLMChat::Role::Assistant ); + chat->addClass( "llm_waiting" ); toggleEnableChats( false ); auto* editor = chat->findByClass( "data_ui" ); editor->setEnabled( false ); @@ -1724,6 +1799,7 @@ void LLMChatUI::doAgentRequest() { thinking->setPosition( { PixelDensity::dpToPx( 8 ), PixelDensity::dpToPx( 3 ) } ); thinking->setInterval( [thinking] { thinking->rotate( 360 / 32 ); }, Seconds( 0.125 ), thinkingID ); + sendAgentPrompt(); } } @@ -1733,33 +1809,69 @@ void LLMChatUI::sendAgentPrompt() { 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(); + auto lastChat = getLastConversation( true ); + if ( lastChat ) { + auto* editorNode = lastChat->findByClass( "data_ui" ); + if ( !editorNode || !editorNode->isType( UI_TYPE_CODEEDITOR ) ) { + req.prompt = { { { "type", "text" }, { "text", "" } } }; + } else { + auto* editor = editorNode->asType(); + std::string text = editor->getDocument().getText().toUtf8(); - bool isSlashCommand = false; - if ( String::startsWith( text, "/" ) ) { - auto spacePos = text.find( ' ' ); - std::string cmdName = - text.substr( 1, spacePos == std::string::npos ? std::string::npos : spacePos - 1 ); - std::string arg = spacePos == std::string::npos ? "" : text.substr( spacePos + 1 ); + bool isSlashCommand = false; + bool showHelp = false; + if ( String::startsWith( text, "/" ) ) { + auto spacePos = text.find( ' ' ); + std::string cmdName = text.substr( + 1, spacePos == std::string::npos ? std::string::npos : spacePos - 1 ); + std::string arg = spacePos == std::string::npos ? "" : text.substr( spacePos + 1 ); - auto it = - std::find_if( mAvailableCommands.begin(), mAvailableCommands.end(), - [&cmdName]( const SlashCommand& c ) { return c.name == cmdName; } ); + auto it = std::find_if( + mAvailableCommands.begin(), mAvailableCommands.end(), + [&cmdName]( const SlashCommand& c ) { return c.name == cmdName; } ); - if ( it != mAvailableCommands.end() ) { - req.prompt = { - { { "type", "slash_command" }, { "name", cmdName }, { "argument", arg } } }; - isSlashCommand = true; + if ( it != mAvailableCommands.end() ) { + req.prompt = { + { { "type", "slash_command" }, { "name", cmdName }, { "argument", arg } } }; + isSlashCommand = true; + } else { + showHelp = true; + } } - } - if ( !isSlashCommand ) { - req.prompt = promptToContentBlocks( text ); + if ( !isSlashCommand && !showHelp ) { + req.prompt = promptToContentBlocks( text ); + } + + if ( showHelp && !mAvailableCommands.empty() ) { + std::string help = i18n( "available_commands", "Available Commands:" ) + "\n"; + for ( const auto& cmd : mAvailableCommands ) { + help += "- **/" + cmd.name + "**: " + cmd.description + "\n"; + } + help += "\n" + i18n( "type_slash_command_to_use", + "Type / followed by the command name to use it." ); + writeToLastChat( help ); + + runOnMainThread( [this] { + mChatStop->setVisible( false )->setEnabled( false ); + mChatRun->setVisible( true )->setEnabled( true ); + toggleEnableChats( true ); + auto lastChat = getLastConversation(); + if ( lastChat ) { + auto* editorNode = lastChat->findByClass( "data_ui" ); + if ( editorNode && editorNode->isType( UI_TYPE_CODEEDITOR ) ) { + auto* editor = editorNode->asType(); + auto* thinking = editor->findByClass( "thinking" ); + if ( thinking ) + thinking->setVisible( false ); + editor->setEnabled( true ); + if ( editor->hasFocus() ) + mChatInput->setFocus(); + } + } + } ); + return; + } } } else { req.prompt = { { { "type", "text" }, { "text", "" } } }; @@ -1785,6 +1897,8 @@ void LLMChatUI::sendAgentPrompt() { auto chat = getLastConversation(); if ( chat ) { auto* editor = chat->findByClass( "data_ui" ); + if ( !editor ) + return; auto* thinking = editor->findByClass( "thinking" ); if ( thinking ) { auto thinkingID = String::hash( String::format( "thinking-%p", thinking ) ); @@ -1893,7 +2007,10 @@ nlohmann::json LLMChatUI::chatToJson( bool forRequest ) { auto chats = findAllByClass( "llm_conversation" ); for ( const auto& chat : chats ) { UIDropDownList* roleDDL = chat->findByClass( "role_ui" ); - UICodeEditor* codeEditor = chat->findByClass( "data_ui" ); + UIWidget* editorNode = chat->findByClass( "data_ui" ); + if ( !roleDDL || !editorNode || !editorNode->isType( UI_TYPE_CODEEDITOR ) ) + continue; + UICodeEditor* codeEditor = editorNode->asType(); std::string role = LLMChat::roleToString( static_cast( roleDDL->getListBox()->getItemSelectedIndex() ) ); auto text = codeEditor->getDocument().getText().toUtf8(); @@ -2136,11 +2253,15 @@ void LLMChatUI::doRequest() { mChatStop->setVisible( true )->setEnabled( true ); UIWidget* chat = addChatUI( LLMChat::Role::Assistant ); + chat->addClass( "llm_waiting" ); toggleEnableChats( false ); // editor is not disabled (to allow copy with locked document) auto model = mCurModel; auto* editor = chat->findByClass( "data_ui" ); + if ( !editor ) + return; + // new editor must be disabled because even on locked document it's possible to move the cursor editor->setEnabled( false ); @@ -2155,14 +2276,21 @@ void LLMChatUI::doRequest() { mRequest = std::make_unique( apiUrl, apiKeyStr, serializeChat( model, true ).dump(), model.provider ); + mThinkingBubble = nullptr; mRequest->streamedResponseCb = [this, editor, thinking, thinkingID]( const std::string& chunk, bool fromReasoning ) { - if ( fromReasoning || - ( mRequest && mRequest->getResponse().empty() && allNewLines( chunk ) ) ) + if ( fromReasoning ) { + runOnMainThread( [this, chunk] { updateThinkingBubble( chunk ); } ); return; + } + + if ( mRequest && mRequest->getResponse().empty() && allNewLines( chunk ) ) + return; + auto conversation = chunk; editor->runOnMainThread( [this, conversation = std::move( conversation ), editor, thinking, thinkingID] { + mThinkingBubble = nullptr; editor->getDocument().textInput( String::fromUtf8( conversation ) ); editor->setCursorVisible( false ); thinking->removeActionsByTag( thinkingID ); @@ -2233,14 +2361,21 @@ void LLMChatUI::doRequest() { } void LLMChatUI::toggleEnableChat( UIWidget* chat, bool enabled ) { - chat->findByClass( "role_ui" )->setEnabled( enabled ); - UICodeEditor* editor = chat->findByClass( "data_ui" )->asType(); - // editor->setEnabled( enabled ); - editor->setLocked( !enabled ); - chat->findByClass( "erase_but" )->setEnabled( enabled ); - chat->findByClass( "move_up" )->setEnabled( enabled ); - chat->findByClass( "move_down" )->setEnabled( enabled ); - // chat->findByClass( "copy_contents" )->setEnabled( enabled ); + auto* roleDDL = chat->findByClass( "role_ui" ); + if ( roleDDL ) + roleDDL->setEnabled( enabled ); + auto* editorNode = chat->findByClass( "data_ui" ); + if ( editorNode && editorNode->isType( UI_TYPE_CODEEDITOR ) ) + editorNode->asType()->setLocked( !enabled ); + auto* eraseBut = chat->findByClass( "erase_but" ); + if ( eraseBut ) + eraseBut->setEnabled( enabled ); + auto* moveUp = chat->findByClass( "move_up" ); + if ( moveUp ) + moveUp->setEnabled( enabled ); + auto* moveDown = chat->findByClass( "move_down" ); + if ( moveDown ) + moveDown->setEnabled( enabled ); } void LLMChatUI::toggleEnableChats( bool enabled ) { @@ -2276,6 +2411,8 @@ void LLMChatUI::addPermissionUI( const acp::RequestPermissionRequest& req, } desc->setText( descStr ); UIWidget* optionsBox = chat->findByClass( "permission_options" ); + if ( !optionsBox ) + return; bool isFirst = true; for ( const auto& opt : req.options ) { @@ -2305,7 +2442,81 @@ void LLMChatUI::addPermissionUI( const acp::RequestPermissionRequest& req, } } - mChatScrollView->setAnchorScroll( true ); + mChatScrollView->getVerticalScrollBar()->setValue( 1.0f ); +} + +UIWidget* LLMChatUI::addMarkdownBubble( const std::string& layout, const std::string& markdown ) { + find( "chat_presentation" )->setVisible( false ); + UIWidget* chat = mChatsList->getUISceneNode()->loadLayoutFromString( layout, mChatsList ); + if ( chat ) + chat->asType()->loadFromString( markdown ); + return chat; +} + +void LLMChatUI::addPlanBubble( const std::string& markdown ) { + runOnMainThread( [this, markdown] { + removeWaitingBubble(); + + UIWidget* bubble = nullptr; + if ( mChatsList->getLastChild() ) { + auto* lastChild = mChatsList->getLastChild()->asType(); + if ( lastChild->hasClass( "llm_plan" ) ) + bubble = lastChild; + } + + if ( !bubble ) { + bubble = addMarkdownBubble( DEFAULT_PLAN_GLOBE, markdown ); + } else { + bubble->asType()->loadFromString( markdown ); + } + mChatScrollView->getVerticalScrollBar()->setValue( 1.0f ); + } ); +} + +void LLMChatUI::addToolCallBubble( const std::string& markdown ) { + runOnMainThread( [this, markdown] { + removeWaitingBubble(); + + UIWidget* bubble = nullptr; + if ( mChatsList->getLastChild() ) { + auto* lastChild = mChatsList->getLastChild()->asType(); + if ( lastChild->hasClass( "llm_tool_call" ) ) + bubble = lastChild; + } + + if ( !bubble ) { + mCurToolCall = markdown; + bubble = addMarkdownBubble( DEFAULT_TOOL_CALL_GLOBE, mCurToolCall ); + } else { + mCurToolCall += "\n" + markdown; + bubble->asType()->loadFromString( mCurToolCall ); + } + + mChatScrollView->getVerticalScrollBar()->setValue( 1.0f ); + } ); +} + +void LLMChatUI::updateThinkingBubble( const std::string& chunk ) { + runOnMainThread( [this, chunk] { + removeWaitingBubble(); + + UIWidget* bubble = nullptr; + if ( mChatsList->getLastChild() ) { + auto* lastChild = mChatsList->getLastChild()->asType(); + if ( lastChild->hasClass( "llm_thought" ) ) { + bubble = lastChild; + } + } + + if ( !bubble ) { + mCurThinking = ""; + bubble = addMarkdownBubble( DEFAULT_THINKING_GLOBE, mCurThinking ); + } + + mCurThinking += chunk; + bubble->asType()->loadFromString( mCurThinking ); + mChatScrollView->getVerticalScrollBar()->setValue( 1.0f ); + } ); } UIWidget* LLMChatUI::addChatUI( LLMChat::Role role ) { diff --git a/src/tools/ecode/plugins/aiassistant/chatui.hpp b/src/tools/ecode/plugins/aiassistant/chatui.hpp index 8fa063f81..9d1a8bc28 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.hpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.hpp @@ -148,6 +148,9 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { std::vector mAvailableCommands; std::unique_ptr mAgentSession; + UIWidget* mThinkingBubble{ nullptr }; + std::string mCurThinking; + std::string mCurToolCall; int mPendingModelsToLoad{ 0 }; bool mChatIsPrivate{ false }; bool mIsAgentMode{ false }; @@ -185,6 +188,16 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { UIWidget* addChatUI( LLMChat::Role role ); + UIWidget* addMarkdownBubble( const std::string& layout, const std::string& markdown ); + + void addPlanBubble( const std::string& markdown ); + + void addToolCallBubble( const std::string& markdown ); + + void addThinkingBubble(); + + void updateThinkingBubble( const std::string& chunk ); + void addPermissionUI( const acp::RequestPermissionRequest& req, std::function cb ); @@ -271,7 +284,9 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { void regenerateChatName(); - UIWidget* getLastConversation() const; + void removeWaitingBubble(); + + UIWidget* getLastConversation( bool skipEmpty = false ) const; }; } // namespace ecode From 00739a7b4ee2006316352f8ef932a440ab1712f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sun, 22 Mar 2026 20:52:43 -0300 Subject: [PATCH 07/10] Fix and improve ACP request permission. --- src/eepp/ui/uiselectbutton.cpp | 3 +- .../plugins/aiassistant/acp/acpprotocol.cpp | 2 +- .../ecode/plugins/aiassistant/chatui.cpp | 65 +++++++++++++------ 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/eepp/ui/uiselectbutton.cpp b/src/eepp/ui/uiselectbutton.cpp index 8c6385dff..7d21e04ae 100644 --- a/src/eepp/ui/uiselectbutton.cpp +++ b/src/eepp/ui/uiselectbutton.cpp @@ -131,8 +131,7 @@ void UISelectButton::setSelected( bool set ) { void UISelectButton::setSelectOnClick( bool set ) { if ( set ) { if ( mSelectOnClickCbId == 0 ) - mSelectOnClickCbId = - on( Event::MouseClick, [this]( const Event* ) { toggleSelection(); } ); + mSelectOnClickCbId = onClick( [this]( const Event* ) { toggleSelection(); } ); } else { if ( mSelectOnClickCbId != 0 ) removeEventListener( mSelectOnClickCbId ); diff --git a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp index 564c1ba6b..cda4c0417 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp +++ b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp @@ -305,7 +305,7 @@ json RequestPermissionResponse::toJson() const { json j = { { "outcome", outcome } }; if ( optionId ) j["optionId"] = *optionId; - return j; + return { { "outcome", j } }; } }} // namespace ecode::acp diff --git a/src/tools/ecode/plugins/aiassistant/chatui.cpp b/src/tools/ecode/plugins/aiassistant/chatui.cpp index 41cafc60d..3e515dc77 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.cpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.cpp @@ -416,7 +416,9 @@ CodeEditor.llm_chat_input { text-as-fallback: true; } .llm_chatui PushButton:hover, -.llm_chatui SelectButton:hover { +.llm_chatui SelectButton:hover, +.llm_chatui .permission_options PushButton:focus, +.llm_chatui .permission_options SelectButton:focus { border-color: var(--primary); } .llm_conversation_opt PushButton { @@ -482,6 +484,21 @@ DropDownList.role_ui { foreground-size: 12dp 16dp; foreground-tint: var(--icon); } +.tool_permission { + background-color: var(--list-back); + border-radius: 8dp; + padding: 4dp; +} +.tool_permission .llm_conversation_opt { + background-color: var(--back); + border-radius: 4dp; +} +.tool_permission .permission_options { + margin-top: 8dp; +} +.tool_permission .permission_options > PushButton { + margin-right: 8dp; +} ]]> @@ -555,14 +572,13 @@ static const char* DEFAULT_CHAT_GLOBE = R"xml( )xml"; static const char* DEFAULT_PERMISSION_GLOBE = R"xml( - - - - - - - - + + + + + + + )xml"; @@ -2400,27 +2416,29 @@ void LLMChatUI::addPermissionUI( const acp::RequestPermissionRequest& req, UIWidget* chat = mChatsList->getUISceneNode()->loadLayoutFromString( DEFAULT_PERMISSION_GLOBE, mChatsList ); - UITextView* desc = chat->findByClass( "permission_desc" ); + UIMarkdownView* desc = chat->findByClass( "permission_desc" ); std::string descStr = + "##### " + i18n( "agent_wants_to_execute_tool_call", "The agent wants to execute a tool call:" ) + - "\n"; - descStr += i18n( "title", "Title" ) + ": " + req.toolCall.title + "\n"; - descStr += i18n( "kind", "Kind" ) + ": " + req.toolCall.kind + "\n"; + "\n\n"; + descStr += "**" + i18n( "title", "Title" ) + ":** " + req.toolCall.title + "\n\n"; + descStr += "**" + i18n( "kind", "Kind" ) + ":** `" + req.toolCall.kind + "`\n\n"; if ( !req.toolCall.rawInput.is_null() ) { - descStr += i18n( "input", "Input" ) + ":\n" + req.toolCall.rawInput.dump( 2 ) + "\n"; + descStr += + "**" + i18n( "input", "Input" ) + ":**\n\n" + req.toolCall.rawInput.dump( 2 ) + "\n"; } - desc->setText( descStr ); + desc->loadFromString( descStr ); UIWidget* optionsBox = chat->findByClass( "permission_options" ); if ( !optionsBox ) return; bool isFirst = true; + UIPushButton* firstBut = nullptr; + UIPushButton* lastBut = nullptr; 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; @@ -2431,15 +2449,22 @@ void LLMChatUI::addPermissionUI( const acp::RequestPermissionRequest& req, res.optionId = optId; cbCopy( res ); - if ( mChatInput ) { + if ( mChatInput ) mChatInput->setFocus(); - } } ); if ( isFirst ) { isFirst = false; - but->setFocus(); + firstBut = but; + but->runOnMainThread( [but] { but->setFocus(); } ); } + + lastBut = but; + } + + if ( lastBut && firstBut != lastBut ) { + lastBut->on( Event::OnTabNavigate, + [firstBut]( const Event* event ) { firstBut->setFocus(); } ); } mChatScrollView->getVerticalScrollBar()->setValue( 1.0f ); From 0e7909a2f0582c859b22bb601d7ea6b1aa6ba76a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sun, 22 Mar 2026 21:05:59 -0300 Subject: [PATCH 08/10] Add resource_link support. Agent Config WIP. --- bin/assets/fonts/codicon.ttf | Bin 90728 -> 126168 bytes src/eepp/ui/iconmanager.cpp | 1 + .../plugins/aiassistant/acp/acpclient.cpp | 57 +++++++++ .../plugins/aiassistant/acp/acpclient.hpp | 4 + .../plugins/aiassistant/acp/acpprotocol.cpp | 91 ++++++++++++++ .../plugins/aiassistant/acp/acpprotocol.hpp | 22 ++++ .../plugins/aiassistant/acp/agentsession.cpp | 10 +- .../plugins/aiassistant/acp/agentsession.hpp | 3 + .../ecode/plugins/aiassistant/chatui.cpp | 112 +++++++++++++++++- .../ecode/plugins/aiassistant/chatui.hpp | 3 + 10 files changed, 298 insertions(+), 5 deletions(-) diff --git a/bin/assets/fonts/codicon.ttf b/bin/assets/fonts/codicon.ttf index 0989abf8ef1af54f0928ae2bbeee9c76cda8394b..fe0b1d15ef1ac3be374da6f927c39a38b827de04 100644 GIT binary patch literal 126168 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%0D|J(1`8v<;q6porGg2#1S!qxw*B&Z}WLvY7=D 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 diff --git a/src/eepp/ui/iconmanager.cpp b/src/eepp/ui/iconmanager.cpp index 6b3ef07cf..cd62f8318 100644 --- a/src/eepp/ui/iconmanager.cpp +++ b/src/eepp/ui/iconmanager.cpp @@ -299,6 +299,7 @@ UIIconTheme* IconManager::init( const std::string& iconThemeName, FontTrueType* { "chat-sparkle", 0xec4f }, { "inspect", 0xebd1 }, { "link", 0xeb15 }, + { "agent", 0xec67 }, } ) { iconTheme->add( UIGlyphIcon::New( icon.first, codIconFont, icon.second ) ); diff --git a/src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp b/src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp index c02af7512..eda871da1 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp +++ b/src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp @@ -241,6 +241,63 @@ void ACPClient::loadSession( } ); } +void ACPClient::setConfigOption( + const SetConfigOptionRequest& req, + const std::function& )>& cb ) { + auto fallback = [this, req, cb]() { + if ( req.configId == "model" ) { + write( { { "method", "session/set_model" }, + { "params", { { "sessionId", req.sessionId }, { "modelId", req.optionId } } } }, + [req, cb]( const IdType&, const json& resp2 ) { + if ( resp2.contains( "result" ) && cb ) { + cb( SetConfigOptionResponse( resp2["result"], req.configId, + req.optionId ), + std::nullopt ); + } else if ( resp2.contains( "error" ) && cb ) { + cb( {}, ResponseError( resp2["error"] ) ); + } + } ); + } else if ( req.configId == "mode" ) { + write( { { "method", "session/set_mode" }, + { "params", { { "sessionId", req.sessionId }, { "modeId", req.optionId } } } }, + [req, cb]( const IdType&, const json& resp2 ) { + if ( resp2.contains( "result" ) && cb ) { + cb( SetConfigOptionResponse( resp2["result"], req.configId, + req.optionId ), + std::nullopt ); + } else if ( resp2.contains( "error" ) && cb ) { + cb( {}, ResponseError( resp2["error"] ) ); + } + } ); + } else if ( cb ) { + cb( {}, ResponseError{ -32601, "Method not found" } ); + } + }; + + if ( mLegacyConfigOnly ) { + fallback(); + return; + } + + write( { { "method", "session/set_config_option" }, { "params", req.toJson() } }, + [this, req, fallback, cb]( const IdType&, const json& resp ) { + if ( resp.contains( "result" ) && cb ) { + cb( SetConfigOptionResponse( resp["result"], req.configId, req.optionId ), + std::nullopt ); + } else if ( resp.contains( "error" ) ) { + ResponseError err( resp["error"] ); + if ( err.code == -32601 ) { + mLegacyConfigOnly = true; + fallback(); + return; + } + if ( cb ) + cb( {}, err ); + } + } ); +} + void ACPClient::listSessions( const ListSessionsRequest& req, const std::function& )>& diff --git a/src/tools/ecode/plugins/aiassistant/acp/acpclient.hpp b/src/tools/ecode/plugins/aiassistant/acp/acpclient.hpp index 57af63cf2..88e777689 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/acpclient.hpp +++ b/src/tools/ecode/plugins/aiassistant/acp/acpclient.hpp @@ -52,6 +52,9 @@ class ACPClient { void loadSession( const LoadSessionRequest& req, const std::function& )>& cb ); + void setConfigOption( const SetConfigOptionRequest& req, + const std::function& )>& cb ); void listSessions( const ListSessionsRequest& req, const std::function& )>& cb ); @@ -102,6 +105,7 @@ class ACPClient { std::map mHandlers; std::string mReceiveBuffer; + bool mLegacyConfigOnly{ false }; void readStdOut( const char* bytes, size_t n ); void readStdErr( const char* bytes, size_t n ); diff --git a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp index cda4c0417..5bc6d7641 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp +++ b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp @@ -2,6 +2,84 @@ namespace ecode { namespace acp { +json parseLegacyConfigOptions( const json& body, json configOptions, const std::string& forceId, + const std::string& forceValue ) { + if ( configOptions.is_null() ) { + configOptions = json::array(); + } + + auto updateOrAdd = [&configOptions]( json newOpt ) { + bool found = false; + for ( auto& opt : configOptions ) { + if ( opt.is_object() && opt.contains( "id" ) && opt["id"] == newOpt["id"] ) { + opt = std::move( newOpt ); + found = true; + break; + } + } + if ( !found ) { + configOptions.push_back( std::move( newOpt ) ); + } + }; + + if ( body.contains( "models" ) && body["models"].is_object() ) { + auto models = body["models"]; + if ( models.contains( "availableModels" ) && models["availableModels"].is_array() ) { + json modelConfig = { { "id", "model" }, + { "name", "Model" }, + { "type", "select" }, + { "category", "model" }, + { "options", json::array() } }; + for ( const auto& model : models["availableModels"] ) { + modelConfig["options"].push_back( + { { "id", model.value( "modelId", "" ) }, { "name", model.value( "name", "" ) } } ); + } + if ( models.contains( "currentModelId" ) ) { + modelConfig["currentValue"] = models.value( "currentModelId", "" ); + modelConfig["default"] = models.value( "currentModelId", "" ); + } else if ( !modelConfig["options"].empty() ) { + modelConfig["currentValue"] = modelConfig["options"][0]["id"]; + modelConfig["default"] = modelConfig["options"][0]["id"]; + } + updateOrAdd( std::move( modelConfig ) ); + } + } + + if ( body.contains( "modes" ) && body["modes"].is_object() ) { + auto modes = body["modes"]; + if ( modes.contains( "availableModes" ) && modes["availableModes"].is_array() ) { + json modeConfig = { { "id", "mode" }, + { "name", "Mode" }, + { "type", "select" }, + { "category", "mode" }, + { "options", json::array() } }; + for ( const auto& mode : modes["availableModes"] ) { + modeConfig["options"].push_back( + { { "id", mode.value( "id", "" ) }, { "name", mode.value( "name", "" ) } } ); + } + if ( modes.contains( "currentModeId" ) ) { + modeConfig["currentValue"] = modes.value( "currentModeId", "" ); + modeConfig["default"] = modes.value( "currentModeId", "" ); + } else if ( !modeConfig["options"].empty() ) { + modeConfig["currentValue"] = modeConfig["options"][0]["id"]; + modeConfig["default"] = modeConfig["options"][0]["id"]; + } + updateOrAdd( std::move( modeConfig ) ); + } + } + + if ( !forceId.empty() ) { + for ( auto& opt : configOptions ) { + if ( opt.is_object() && opt.contains( "id" ) && opt["id"] == forceId ) { + opt["currentValue"] = forceValue; + break; + } + } + } + + return configOptions; +} + ClientCapabilities::ClientCapabilities( const json& body ) { if ( body.contains( "terminal" ) ) terminal = body.value( "terminal", false ); @@ -58,6 +136,7 @@ NewSessionResponse::NewSessionResponse( const json& body ) { sessionId = body.value( "sessionId", "" ); if ( body.contains( "configOptions" ) ) configOptions = body["configOptions"]; + configOptions = parseLegacyConfigOptions( body, configOptions ); } json LoadSessionRequest::toJson() const { @@ -73,6 +152,18 @@ json LoadSessionRequest::toJson() const { LoadSessionResponse::LoadSessionResponse( const json& body ) { if ( body.contains( "configOptions" ) ) configOptions = body["configOptions"]; + configOptions = parseLegacyConfigOptions( body, configOptions ); +} + +json SetConfigOptionRequest::toJson() const { + return { { "sessionId", sessionId }, { "configId", configId }, { "optionId", optionId } }; +} + +SetConfigOptionResponse::SetConfigOptionResponse( const json& body, const std::string& configId, + const std::string& optionId ) { + if ( body.contains( "configOptions" ) ) + configOptions = body["configOptions"]; + configOptions = parseLegacyConfigOptions( body, configOptions, configId, optionId ); } SessionInfo::SessionInfo( const json& body ) { diff --git a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp index e4ffb85da..f1dc19436 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp +++ b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp @@ -11,6 +11,9 @@ using json = nlohmann::json; namespace ecode { namespace acp { +json parseLegacyConfigOptions( const json& body, json configOptions, const std::string& forceId = "", + const std::string& forceValue = "" ); + struct ClientCapabilities { bool terminal{ false }; bool fsReadTextFile{ false }; @@ -79,6 +82,23 @@ struct LoadSessionResponse { LoadSessionResponse( const json& body ); }; +struct SetConfigOptionRequest { + std::string sessionId; + std::string configId; + std::string optionId; + + SetConfigOptionRequest() = default; + json toJson() const; +}; + +struct SetConfigOptionResponse { + json configOptions; + + SetConfigOptionResponse() = default; + SetConfigOptionResponse( const json& body, const std::string& configId = "", + const std::string& optionId = "" ); +}; + struct SessionInfo { std::string sessionId; std::string cwd; @@ -111,6 +131,8 @@ struct ResponseError { json data; ResponseError() = default; + ResponseError( int code, std::string message, json data = {} ) : + code( code ), message( std::move( message ) ), data( std::move( data ) ) {} ResponseError( const json& body ) { if ( body.contains( "code" ) ) code = body["code"].get(); diff --git a/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp b/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp index 629a35d62..f8e30079e 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp +++ b/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp @@ -39,6 +39,7 @@ bool AgentSession::start( const std::function& onReady ) { return; } mSessionId = nres.sessionId; + mConfigOptions = nres.configOptions; if ( onReady ) onReady( true ); } ); @@ -71,7 +72,7 @@ bool AgentSession::startLoaded( const std::string& sessionId, lreq.sessionId = sessionId; lreq.cwd = mClient->isReady() ? mClient->getConfig().workingDirectory : ""; mClient->loadSession( - lreq, [this, sessionId, onReady]( const LoadSessionResponse&, + lreq, [this, sessionId, onReady]( const LoadSessionResponse& lres, const std::optional& err ) { if ( err ) { if ( onReady ) @@ -79,6 +80,7 @@ bool AgentSession::startLoaded( const std::string& sessionId, return; } mSessionId = sessionId; + mConfigOptions = lres.configOptions; if ( onReady ) onReady( true ); } ); @@ -146,6 +148,12 @@ void AgentSession::setupClient() { }; mClient->onSessionUpdate = [this]( const json& msg ) { + auto sessionUpdate = msg.value( "sessionUpdate", "" ); + if ( sessionUpdate == "config_options_update" && msg.contains( "configOptions" ) ) { + mConfigOptions = msg["configOptions"]; + } else if ( msg.contains( "models" ) || msg.contains( "modes" ) ) { + mConfigOptions = parseLegacyConfigOptions( msg, mConfigOptions ); + } if ( onSessionUpdate ) onSessionUpdate( msg ); }; diff --git a/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp b/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp index 4093f1390..bc5779e88 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp +++ b/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp @@ -44,6 +44,8 @@ class AgentSession { std::string getSessionId() const { return mSessionId; } ACPClient* getClient() const { return mClient.get(); } + json getConfigOptions() const { return mConfigOptions; } + void setConfigOptions( const json& opts ) { mConfigOptions = opts; } void setTerminalData( const std::string& terminalId, UITerminal* uiTerm ); @@ -51,6 +53,7 @@ class AgentSession { std::shared_ptr mThreadPool; std::unique_ptr mClient; std::string mSessionId; + json mConfigOptions; bool mIsPrompting{ false }; struct TermData { diff --git a/src/tools/ecode/plugins/aiassistant/chatui.cpp b/src/tools/ecode/plugins/aiassistant/chatui.cpp index 3e515dc77..15abca831 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.cpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.cpp @@ -532,6 +532,7 @@ DropDownList.role_ui { + @@ -598,6 +599,8 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : mModelBtn->onClick( [this]( auto ) { execute( "ai-select-model" ); } ); mAgentBtn = findByClass( "agent_ui" ); mAgentBtn->onClick( [this]( auto ) { execute( "ai-select-agent" ); } ); + mAgentConfigBtn = findByClass( "agent_config_ui" ); + mAgentConfigBtn->onClick( [this]( auto ) { showAgentConfigMenu(); } ); mChatAgentMode = find( "llm_agent_mode" ); mChatAgentMode->on( Event::OnValueChange, [this]( auto ) { @@ -1519,6 +1522,106 @@ void LLMChatUI::hideSelectAgent() { mLocateAgentBarLayout->setVisible( false ); } +void LLMChatUI::showAgentConfigMenu() { + if ( !mAgentSession || !mAgentSession->getClient()->isReady() ) { + if ( !mAgentSession ) + setupAgentSession(); + + if ( mAgentSession && !mAgentSession->getClient()->isReady() ) { + mAgentConfigBtn->setEnabled( false ); + mAgentSession->start( [this]( bool ready ) { + runOnMainThread( [this, ready]() { + mAgentConfigBtn->setEnabled( true ); + if ( ready ) { + showAgentConfigMenu(); + } else { + NotificationCenter::instance()->addNotification( + i18n( "failed_to_start_agent", "Failed to start agent process." ) ); + mAgentSession.reset(); + } + } ); + } ); + return; + } + return; + } + + auto configOptions = mAgentSession->getConfigOptions(); + if ( !configOptions.is_array() || configOptions.empty() ) { + NotificationCenter::instance()->addNotification( + i18n( "no_agent_configs", "No Config Options Available" ) ); + return; + } + + UIPopUpMenu* menu = UIPopUpMenu::New(); + + for ( const auto& opt : configOptions ) { + if ( !opt.is_object() || !opt.contains( "id" ) || !opt.contains( "name" ) || + !opt.contains( "options" ) || !opt["options"].is_array() ) + continue; + + UIPopUpMenu* subMenu = UIPopUpMenu::New(); + std::string optId = opt["id"].get(); + std::string currentVal = + opt.contains( "currentValue" ) ? opt["currentValue"].get() : ""; + + for ( const auto& subopt : opt["options"] ) { + if ( !subopt.is_object() || !subopt.contains( "id" ) || !subopt.contains( "name" ) ) + continue; + + std::string subId = subopt["id"].get(); + std::string subName = subopt["name"].get(); + + Drawable* icon = nullptr; + if ( subId == currentVal ) { + icon = getUISceneNode()->findIconDrawable( "ok", PixelDensity::dpToPxI( 12 ) ); + } + + auto* item = subMenu->add( subName, icon ); + item->setId( subId ); + } + + subMenu->on( Event::OnItemClicked, [this, optId]( const Event* event ) { + UIMenuItem* item = event->getNode()->asType(); + std::string subId( item->getId() ); + acp::SetConfigOptionRequest req; + req.sessionId = mAgentSession->getSessionId(); + req.configId = optId; + req.optionId = subId; + mAgentSession->getClient()->setConfigOption( + req, [this, optId, subId]( const acp::SetConfigOptionResponse& res, + const std::optional& err ) { + if ( err ) { + runOnMainThread( [this, err]() { + NotificationCenter::instance()->addNotification( + i18n( "agent_config_error", "Failed to update agent config: " ) + + err->message ); + } ); + } else { + auto newOpts = res.configOptions; + if ( newOpts.empty() ) { + newOpts = acp::parseLegacyConfigOptions( + {}, mAgentSession->getConfigOptions(), optId, subId ); + } + mAgentSession->setConfigOptions( newOpts ); + } + } ); + } ); + + menu->addSubMenu( opt["name"].get(), nullptr, subMenu ); + } + + if ( menu->getCount() == 0 ) { + menu->add( i18n( "no_agent_configs", "No Config Options Available" ) )->setEnabled( false ); + } + + menu->runOnMainThread( [this, menu] { + auto pos( mAgentConfigBtn->getScreenPos() ); + UIMenu::findBestMenuPos( pos, menu, nullptr, nullptr, mAgentConfigBtn ); + menu->showAtScreenPosition( pos ); + } ); +} + void LLMChatUI::initSelectAgent() { mLocateAgentBarLayout = findByClass( "llm_chat_select_agent" ); mLocateAgentInput = findByClass( "llm_chat_select_agent_input" ); @@ -1646,6 +1749,7 @@ void LLMChatUI::writeToLastChat( const std::string& text ) { void LLMChatUI::updateAgentModeUI() { mModelBtn->setVisible( !mIsAgentMode ); mAgentBtn->setVisible( mIsAgentMode ); + mAgentConfigBtn->setVisible( mIsAgentMode ); mChatAdd->setVisible( !mIsAgentMode ); mChatUserRole->setVisible( !mIsAgentMode ); @@ -1998,10 +2102,10 @@ nlohmann::json LLMChatUI::promptToContentBlocks( std::string text ) { path = prjPath + path; } - std::string fileBuffer; - if ( FileSystem::fileGet( path, fileBuffer ) ) { - j.push_back( { { "type", "resource" }, - { "resource", { { "path", path }, { "content", fileBuffer } } } } ); + if ( FileSystem::fileExists( path ) ) { + j.push_back( { { "type", "resource_link" }, + { "name", FileSystem::fileNameFromPath( path ) }, + { "uri", "file://" + path } } ); } lastPos = matches[0].end; diff --git a/src/tools/ecode/plugins/aiassistant/chatui.hpp b/src/tools/ecode/plugins/aiassistant/chatui.hpp index 9d1a8bc28..0fbc44a2e 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.hpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.hpp @@ -116,6 +116,7 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { UIScrollView* mChatScrollView{ nullptr }; UIPushButton* mModelBtn{ nullptr }; UIPushButton* mAgentBtn{ nullptr }; + UIPushButton* mAgentConfigBtn{ nullptr }; // Locate file UIVLinearLayoutCommandExecuter* mLocateBarLayout{ nullptr }; @@ -274,6 +275,8 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { void hideSelectModel(); + void showAgentConfigMenu(); + void insertFileToDocument( std::string path, std::shared_ptr cdoc ); void replaceFileLinksToContents( std::string& text ); From c5b5203a6a1eb67b389d37a2f7d81a11099b7a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Mon, 23 Mar 2026 00:04:14 -0300 Subject: [PATCH 09/10] Improved agent config. --- .agent/SOUL.md | 21 ++ include/eepp/ui/models/itemlistmodel.hpp | 7 + src/eepp/ui/uidropdownlist.cpp | 8 +- src/eepp/ui/uidropdownmodellist.cpp | 8 +- .../plugins/aiassistant/acp/acpclient.cpp | 58 +++-- .../plugins/aiassistant/acp/acpprotocol.cpp | 6 +- .../plugins/aiassistant/acp/acpprotocol.hpp | 3 +- .../plugins/aiassistant/acp/agentsession.cpp | 93 ++++--- .../plugins/aiassistant/acp/agentsession.hpp | 9 +- .../ecode/plugins/aiassistant/chatui.cpp | 238 +++++++++++------- .../ecode/plugins/aiassistant/chatui.hpp | 2 +- 11 files changed, 266 insertions(+), 187 deletions(-) create mode 100644 .agent/SOUL.md diff --git a/.agent/SOUL.md b/.agent/SOUL.md new file mode 100644 index 000000000..bbaa58d9f --- /dev/null +++ b/.agent/SOUL.md @@ -0,0 +1,21 @@ +# Agent Identity: Negen + +**Role & Personality:** +Your name is Negen (from negentropy: the process of creating order out of chaos). You are an elite, performance-obsessed C++ coding agent embedded within the `eepp` repository and `ecode` ecosystem. Your tone is sharp, analytical, and highly efficient. You treat system resources as sacred and write code that is clean, fast, and secure. + +**Core Directives:** + +1. **Performance & Memory Management:** + - Performance is the absolute key in `eepp`. + - Favor stack-allocated memory over heap allocations whenever possible. + - Any heap allocation must be heavily justified. + - Exercise reason: maximize stack use for speed, but actively calculate boundaries to prevent stack-overflows. + +2. **Protect the Render Loop:** + - Render time is critical. + - You must be intensely mindful of the performance impact of any code executing within the main loop or main thread. Avoid blocking operations or expensive computations during the render cycle at all costs. + +3. **Self-Review & DRY Principle:** + - Immediately after implementing a feature, you must perform a strict self-review of your changes. + - Actively hunt for mistakes and inefficiencies. + - Eradicate code duplication. Whenever common logic is detected, encapsulate it into a distinct, reusable function or method. diff --git a/include/eepp/ui/models/itemlistmodel.hpp b/include/eepp/ui/models/itemlistmodel.hpp index b40258a89..f3c08e426 100644 --- a/include/eepp/ui/models/itemlistmodel.hpp +++ b/include/eepp/ui/models/itemlistmodel.hpp @@ -155,6 +155,13 @@ template class ItemPairListOwnerModel final : public Mo return std::make_shared>( data ); } + static std::shared_ptr create( std::vector>&& data ) { + return std::make_shared>( std::move( data ) ); + } + + explicit ItemPairListOwnerModel( std::vector>&& data ) : + mData( std::move( data ) ) {} + explicit ItemPairListOwnerModel( const std::vector>& data ) : mData( data ) {} virtual ~ItemPairListOwnerModel() {} diff --git a/src/eepp/ui/uidropdownlist.cpp b/src/eepp/ui/uidropdownlist.cpp index cc6cab9e3..051254a95 100644 --- a/src/eepp/ui/uidropdownlist.cpp +++ b/src/eepp/ui/uidropdownlist.cpp @@ -112,15 +112,15 @@ UIDropDownList* UIDropDownList::showList() { Float width = getPopUpWidth( contentsWidth ); - Float height = - (Int32)( eemin( mListBox->getItemsCount(), mStyleConfig.MaxNumVisibleItems ) * - mListBox->getRowHeight() ) + + Float height = std::ceil( + std::ceil( eemin( mListBox->getItemsCount(), mStyleConfig.MaxNumVisibleItems ) * + PixelDensity::pxToDp( mListBox->getRowHeight() ) ) + tPadding.Top + tPadding.Bottom + ( mListBox->getHorizontalScrollBar() && mListBox->getHorizontalScrollBar()->isVisible() && PixelDensity::dpToPx( width ) < mListBox->getMaxTextWidth() ? mListBox->getHorizontalScrollBar()->getSize().getHeight() - : 0.f ); + : 0.f ) ); mListBox->setSize( width, height ); mListBox->getVerticalScrollBar()->setValue( sliderValue ); diff --git a/src/eepp/ui/uidropdownmodellist.cpp b/src/eepp/ui/uidropdownmodellist.cpp index 9eed3e2b5..8fc7e4b7e 100644 --- a/src/eepp/ui/uidropdownmodellist.cpp +++ b/src/eepp/ui/uidropdownmodellist.cpp @@ -167,14 +167,14 @@ UIDropDownModelList* UIDropDownModelList::showList() { Float width = getPopUpWidth( contentsWidth ); - Float height = - (Int32)( eemin( (Uint32)mModel->rowCount(), mStyleConfig.MaxNumVisibleItems ) * - mListView->getRowHeight() ) + + Float height = std::ceil( + std::ceil( eemin( (Uint32)mModel->rowCount(), mStyleConfig.MaxNumVisibleItems ) * + PixelDensity::pxToDp( mListView->getRowHeight() ) ) + tPadding.Top + tPadding.Bottom + mListView->getHeaderHeight() + ( mListView->getHorizontalScrollBar() && mListView->getHorizontalScrollBar()->isVisible() ? mListView->getHorizontalScrollBar()->getSize().getHeight() - : 0.f ); + : 0.f ) ); mListView->setSize( width, height ); diff --git a/src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp b/src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp index eda871da1..b0dc4bddb 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp +++ b/src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp @@ -196,10 +196,9 @@ void ACPClient::sendError( const json& id, int code, const std::string& message } } -void ACPClient::initialize( - const InitializeRequest& req, - const std::function& )>& - cb ) { +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" ) ) { @@ -213,10 +212,9 @@ void ACPClient::initialize( } ); } -void ACPClient::newSession( - const NewSessionRequest& req, - const std::function& )>& - cb ) { +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 ) { @@ -247,29 +245,29 @@ void ACPClient::setConfigOption( const std::optional& )>& cb ) { auto fallback = [this, req, cb]() { if ( req.configId == "model" ) { - write( { { "method", "session/set_model" }, - { "params", { { "sessionId", req.sessionId }, { "modelId", req.optionId } } } }, - [req, cb]( const IdType&, const json& resp2 ) { - if ( resp2.contains( "result" ) && cb ) { - cb( SetConfigOptionResponse( resp2["result"], req.configId, - req.optionId ), - std::nullopt ); - } else if ( resp2.contains( "error" ) && cb ) { - cb( {}, ResponseError( resp2["error"] ) ); - } - } ); + write( + { { "method", "session/set_model" }, + { "params", { { "sessionId", req.sessionId }, { "modelId", req.optionId } } } }, + [req, cb]( const IdType&, const json& resp2 ) { + if ( resp2.contains( "result" ) && cb ) { + cb( SetConfigOptionResponse( resp2["result"], req.configId, req.optionId ), + std::nullopt ); + } else if ( resp2.contains( "error" ) && cb ) { + cb( {}, ResponseError( resp2["error"] ) ); + } + } ); } else if ( req.configId == "mode" ) { - write( { { "method", "session/set_mode" }, - { "params", { { "sessionId", req.sessionId }, { "modeId", req.optionId } } } }, - [req, cb]( const IdType&, const json& resp2 ) { - if ( resp2.contains( "result" ) && cb ) { - cb( SetConfigOptionResponse( resp2["result"], req.configId, - req.optionId ), - std::nullopt ); - } else if ( resp2.contains( "error" ) && cb ) { - cb( {}, ResponseError( resp2["error"] ) ); - } - } ); + write( + { { "method", "session/set_mode" }, + { "params", { { "sessionId", req.sessionId }, { "modeId", req.optionId } } } }, + [req, cb]( const IdType&, const json& resp2 ) { + if ( resp2.contains( "result" ) && cb ) { + cb( SetConfigOptionResponse( resp2["result"], req.configId, req.optionId ), + std::nullopt ); + } else if ( resp2.contains( "error" ) && cb ) { + cb( {}, ResponseError( resp2["error"] ) ); + } + } ); } else if ( cb ) { cb( {}, ResponseError{ -32601, "Method not found" } ); } diff --git a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp index 5bc6d7641..1d389ea1b 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp +++ b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp @@ -31,8 +31,8 @@ json parseLegacyConfigOptions( const json& body, json configOptions, const std:: { "category", "model" }, { "options", json::array() } }; for ( const auto& model : models["availableModels"] ) { - modelConfig["options"].push_back( - { { "id", model.value( "modelId", "" ) }, { "name", model.value( "name", "" ) } } ); + modelConfig["options"].push_back( { { "id", model.value( "modelId", "" ) }, + { "name", model.value( "name", "" ) } } ); } if ( models.contains( "currentModelId" ) ) { modelConfig["currentValue"] = models.value( "currentModelId", "" ); @@ -160,7 +160,7 @@ json SetConfigOptionRequest::toJson() const { } SetConfigOptionResponse::SetConfigOptionResponse( const json& body, const std::string& configId, - const std::string& optionId ) { + const std::string& optionId ) { if ( body.contains( "configOptions" ) ) configOptions = body["configOptions"]; configOptions = parseLegacyConfigOptions( body, configOptions, configId, optionId ); diff --git a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp index f1dc19436..53668ab79 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp +++ b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp @@ -11,7 +11,8 @@ using json = nlohmann::json; namespace ecode { namespace acp { -json parseLegacyConfigOptions( const json& body, json configOptions, const std::string& forceId = "", +json parseLegacyConfigOptions( const json& body, json configOptions, + const std::string& forceId = "", const std::string& forceValue = "" ); struct ClientCapabilities { diff --git a/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp b/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp index f8e30079e..2ef064123 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp +++ b/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp @@ -21,29 +21,28 @@ bool AgentSession::start( const std::function& onReady ) { req.clientCapabilities.fsReadTextFile = true; req.clientCapabilities.fsWriteTextFile = true; - mClient->initialize( - req, [this, onReady]( const InitializeResponse&, const std::optional& err ) { + mClient->initialize( req, [this, onReady]( const InitializeResponse&, + const std::optional& err ) { + if ( err ) { + if ( onReady ) + onReady( false ); + return; + } + NewSessionRequest nreq; + nreq.cwd = mClient->isReady() ? mClient->getConfig().workingDirectory : ""; + mClient->newSession( nreq, [this, onReady]( const NewSessionResponse& nres, + const std::optional& err ) { if ( err ) { if ( onReady ) onReady( false ); return; } - NewSessionRequest nreq; - nreq.cwd = mClient->isReady() ? mClient->getConfig().workingDirectory : ""; - mClient->newSession( - nreq, [this, onReady]( const NewSessionResponse& nres, - const std::optional& err ) { - if ( err ) { - if ( onReady ) - onReady( false ); - return; - } - mSessionId = nres.sessionId; - mConfigOptions = nres.configOptions; - if ( onReady ) - onReady( true ); - } ); + mSessionId = nres.sessionId; + mConfigOptions = nres.configOptions; + if ( onReady ) + onReady( true ); } ); + } ); return true; } if ( onReady ) @@ -59,36 +58,36 @@ bool AgentSession::startLoaded( const std::string& sessionId, req.clientCapabilities.fsReadTextFile = true; req.clientCapabilities.fsWriteTextFile = true; - mClient->initialize( - req, [this, sessionId, onReady]( const InitializeResponse& ires, + mClient->initialize( req, [this, sessionId, + onReady]( const InitializeResponse& ires, const std::optional& err ) { - if ( err ) { - if ( onReady ) - onReady( false ); - return; - } - if ( ires.agentCapabilities.loadSession ) { - LoadSessionRequest lreq; - lreq.sessionId = sessionId; - lreq.cwd = mClient->isReady() ? mClient->getConfig().workingDirectory : ""; - mClient->loadSession( - lreq, [this, sessionId, onReady]( const LoadSessionResponse& lres, - const std::optional& err ) { - if ( err ) { - if ( onReady ) - onReady( false ); - return; - } - mSessionId = sessionId; - mConfigOptions = lres.configOptions; + if ( err ) { + if ( onReady ) + onReady( false ); + return; + } + if ( ires.agentCapabilities.loadSession ) { + LoadSessionRequest lreq; + lreq.sessionId = sessionId; + lreq.cwd = mClient->isReady() ? mClient->getConfig().workingDirectory : ""; + mClient->loadSession( + lreq, [this, sessionId, onReady]( const LoadSessionResponse& lres, + const std::optional& err ) { + if ( err ) { if ( onReady ) - onReady( true ); - } ); - } else { - if ( onReady ) - onReady( false ); - } - } ); + onReady( false ); + return; + } + mSessionId = sessionId; + mConfigOptions = lres.configOptions; + if ( onReady ) + onReady( true ); + } ); + } else { + if ( onReady ) + onReady( false ); + } + } ); return true; } if ( onReady ) @@ -97,8 +96,8 @@ bool AgentSession::startLoaded( const std::string& sessionId, } void AgentSession::listSessions( - const std::function&, const std::optional& )>& - cb ) { + const std::function&, + const std::optional& )>& cb ) { if ( !mClient->isReady() ) { if ( cb ) cb( {}, std::nullopt ); diff --git a/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp b/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp index bc5779e88..3cbfa8eeb 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp +++ b/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp @@ -22,14 +22,13 @@ class AgentSession { bool start( const std::function& onReady ); bool startLoaded( const std::string& sessionId, const std::function& onReady ); - void listSessions( - const std::function&, const std::optional& )>& - cb ); + void listSessions( const std::function&, + const std::optional& )>& cb ); void stop(); void prompt( const PromptRequest& req, - const std::function& )>& - cb ); + const std::function& )>& cb ); void cancel(); bool isPrompting() const { return mIsPrompting; } diff --git a/src/tools/ecode/plugins/aiassistant/chatui.cpp b/src/tools/ecode/plugins/aiassistant/chatui.cpp index 15abca831..5b82893d0 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.cpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.cpp @@ -8,8 +8,10 @@ #include #include +#include #include #include +#include #include #include #include @@ -19,7 +21,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -499,6 +503,13 @@ DropDownList.role_ui { .tool_permission .permission_options > PushButton { margin-right: 8dp; } +.agent_config_label { + font-weight: bold; + margin-bottom: 4dp; +} +.agent_config_dropdown { + margin-bottom: 8dp; +} ]]> @@ -532,8 +543,8 @@ DropDownList.role_ui { - + @@ -600,7 +611,7 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : mAgentBtn = findByClass( "agent_ui" ); mAgentBtn->onClick( [this]( auto ) { execute( "ai-select-agent" ); } ); mAgentConfigBtn = findByClass( "agent_config_ui" ); - mAgentConfigBtn->onClick( [this]( auto ) { showAgentConfigMenu(); } ); + mAgentConfigBtn->onClick( [this]( auto ) { showAgentConfigWindow(); } ); mChatAgentMode = find( "llm_agent_mode" ); mChatAgentMode->on( Event::OnValueChange, [this]( auto ) { @@ -1522,104 +1533,147 @@ void LLMChatUI::hideSelectAgent() { mLocateAgentBarLayout->setVisible( false ); } -void LLMChatUI::showAgentConfigMenu() { - if ( !mAgentSession || !mAgentSession->getClient()->isReady() ) { - if ( !mAgentSession ) - setupAgentSession(); +void LLMChatUI::showAgentConfigWindow() { + if ( !mAgentSession ) + setupAgentSession(); - if ( mAgentSession && !mAgentSession->getClient()->isReady() ) { - mAgentConfigBtn->setEnabled( false ); - mAgentSession->start( [this]( bool ready ) { - runOnMainThread( [this, ready]() { - mAgentConfigBtn->setEnabled( true ); - if ( ready ) { - showAgentConfigMenu(); - } else { + static const char* AGENT_CONFIG_LAYOUT = R"xml( + + + + + )xml"; + + UIWindow* win = + getUISceneNode()->loadLayoutFromString( AGENT_CONFIG_LAYOUT )->asType(); + UILoader* loader = win->find( "loader" )->asType(); + UILinearLayout* container = win->find( "config_container" )->asType(); + + win->setMinWindowSize( getUISceneNode()->getSize().getWidth() * 0.4f, + getUISceneNode()->getSize().getHeight() * 0.3f ); + win->setKeyBindingCommand( "closeWindow", [win, this] { + win->closeWindow(); + setFocus(); + } ); + win->getKeyBindings().addKeybind( { KEY_ESCAPE }, "closeWindow" ); + win->center(); + win->getModalWidget()->addClass( "shadowbg" ); + win->on( Event::OnWindowReady, [win, loader]( auto ) { + win->setFocus(); + loader->center(); + } ); + + auto setupConfig = [this, loader, container, win]() { + auto configOptions = mAgentSession->getConfigOptions(); + if ( !configOptions.is_array() || configOptions.empty() ) { + NotificationCenter::instance()->addNotification( + i18n( "no_agent_configs", "No Config Options Available" ) ); + win->closeWindow(); + return; + } + + loader->setVisible( false ); + container->setVisible( true ); + + for ( const auto& opt : configOptions ) { + if ( !opt.is_object() || !opt.contains( "id" ) || !opt.contains( "name" ) || + !opt.contains( "options" ) || !opt["options"].is_array() ) + continue; + + std::string optId = opt["id"].get(); + std::string currentValId = opt.value( "currentValue", "" ); + std::string currentValName = ""; + + UITextView* label = UITextView::New(); + label->setParent( container ); + label->setText( opt["name"].get() ); + label->addClass( "agent_config_label" ); + + UIDropDownModelList* dropdown = UIDropDownModelList::New(); + dropdown->setParent( container ); + dropdown->setLayoutWidthPolicy( SizePolicy::MatchParent ); + dropdown->addClass( "agent_config_dropdown" ); + + std::vector> options; + int selectedIndex = -1; + int count = 0; + for ( const auto& subopt : opt["options"] ) { + if ( !subopt.is_object() || !subopt.contains( "id" ) || !subopt.contains( "name" ) ) + continue; + std::string subId = subopt["id"].get(); + std::string subName = subopt["name"].get(); + options.push_back( { subName, subId } ); + if ( subId == currentValId ) { + selectedIndex = count; + currentValName = subName; + } + count++; + } + + auto model = Models::ItemPairListOwnerModel::create( + std::move( options ) ); + dropdown->setModel( model ); + dropdown->getListView()->setColumnsVisible( { 0 } ); + dropdown->getListView()->setAutoExpandOnSingleColumn( true ); + if ( selectedIndex != -1 ) { + dropdown->setText( currentValName ); + } + + dropdown->on( Event::OnItemSelected, [this, optId, model, dropdown]( const Event* ) { + auto* listView = dropdown->getListView(); + if ( !listView || listView->getSelection().isEmpty() ) + return; + ModelIndex index = listView->getSelection().first(); + std::string subId = model->data( model->index( index.row(), 1 ) ).toString(); + + acp::SetConfigOptionRequest req; + req.sessionId = mAgentSession->getSessionId(); + req.configId = optId; + req.optionId = subId; + mAgentSession->getClient()->setConfigOption( + req, [this, optId, subId]( const acp::SetConfigOptionResponse& res, + const std::optional& err ) { + if ( err ) { + runOnMainThread( [this, err]() { + NotificationCenter::instance()->addNotification( + i18n( "agent_config_error", + "Failed to update agent config: " ) + + err->message ); + } ); + } else { + auto newOpts = res.configOptions; + if ( newOpts.empty() ) { + newOpts = acp::parseLegacyConfigOptions( + {}, mAgentSession->getConfigOptions(), optId, subId ); + } + mAgentSession->setConfigOptions( newOpts ); + } + } ); + } ); + } + }; + + if ( mAgentSession && !mAgentSession->getClient()->isReady() ) { + mAgentConfigBtn->setEnabled( false ); + mAgentSession->start( [this, setupConfig, win]( bool ready ) { + runOnMainThread( [this, setupConfig, win, ready]() { + mAgentConfigBtn->setEnabled( true ); + if ( ready && win->isVisible() ) { + setupConfig(); + } else { + if ( !ready ) { NotificationCenter::instance()->addNotification( i18n( "failed_to_start_agent", "Failed to start agent process." ) ); mAgentSession.reset(); } - } ); + win->closeWindow(); + } } ); - return; - } - return; - } - - auto configOptions = mAgentSession->getConfigOptions(); - if ( !configOptions.is_array() || configOptions.empty() ) { - NotificationCenter::instance()->addNotification( - i18n( "no_agent_configs", "No Config Options Available" ) ); - return; - } - - UIPopUpMenu* menu = UIPopUpMenu::New(); - - for ( const auto& opt : configOptions ) { - if ( !opt.is_object() || !opt.contains( "id" ) || !opt.contains( "name" ) || - !opt.contains( "options" ) || !opt["options"].is_array() ) - continue; - - UIPopUpMenu* subMenu = UIPopUpMenu::New(); - std::string optId = opt["id"].get(); - std::string currentVal = - opt.contains( "currentValue" ) ? opt["currentValue"].get() : ""; - - for ( const auto& subopt : opt["options"] ) { - if ( !subopt.is_object() || !subopt.contains( "id" ) || !subopt.contains( "name" ) ) - continue; - - std::string subId = subopt["id"].get(); - std::string subName = subopt["name"].get(); - - Drawable* icon = nullptr; - if ( subId == currentVal ) { - icon = getUISceneNode()->findIconDrawable( "ok", PixelDensity::dpToPxI( 12 ) ); - } - - auto* item = subMenu->add( subName, icon ); - item->setId( subId ); - } - - subMenu->on( Event::OnItemClicked, [this, optId]( const Event* event ) { - UIMenuItem* item = event->getNode()->asType(); - std::string subId( item->getId() ); - acp::SetConfigOptionRequest req; - req.sessionId = mAgentSession->getSessionId(); - req.configId = optId; - req.optionId = subId; - mAgentSession->getClient()->setConfigOption( - req, [this, optId, subId]( const acp::SetConfigOptionResponse& res, - const std::optional& err ) { - if ( err ) { - runOnMainThread( [this, err]() { - NotificationCenter::instance()->addNotification( - i18n( "agent_config_error", "Failed to update agent config: " ) + - err->message ); - } ); - } else { - auto newOpts = res.configOptions; - if ( newOpts.empty() ) { - newOpts = acp::parseLegacyConfigOptions( - {}, mAgentSession->getConfigOptions(), optId, subId ); - } - mAgentSession->setConfigOptions( newOpts ); - } - } ); } ); - - menu->addSubMenu( opt["name"].get(), nullptr, subMenu ); + } else if ( mAgentSession && mAgentSession->getClient()->isReady() ) { + setupConfig(); } - - if ( menu->getCount() == 0 ) { - menu->add( i18n( "no_agent_configs", "No Config Options Available" ) )->setEnabled( false ); - } - - menu->runOnMainThread( [this, menu] { - auto pos( mAgentConfigBtn->getScreenPos() ); - UIMenu::findBestMenuPos( pos, menu, nullptr, nullptr, mAgentConfigBtn ); - menu->showAtScreenPosition( pos ); - } ); } void LLMChatUI::initSelectAgent() { diff --git a/src/tools/ecode/plugins/aiassistant/chatui.hpp b/src/tools/ecode/plugins/aiassistant/chatui.hpp index 0fbc44a2e..bcc849501 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.hpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.hpp @@ -275,7 +275,7 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { void hideSelectModel(); - void showAgentConfigMenu(); + void showAgentConfigWindow(); void insertFileToDocument( std::string path, std::shared_ptr cdoc ); From a5822268cb9844d90bcde35c2c3092c3d564fe91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Mon, 23 Mar 2026 02:13:12 -0300 Subject: [PATCH 10/10] ACP Support Improvement for ecode (Terminal output, better error reporting). ACP plans. Safety memory clean ups. --- .agent/plans/acp-improvement.md | 54 ++++++++++++++ include/eepp/graphics/fonttruetype.hpp | 29 ++++---- include/eepp/system/resourcemanager.hpp | 10 ++- src/eepp/graphics/fontmanager.cpp | 6 +- src/eepp/graphics/fonttruetype.cpp | 70 ++++++++++++++----- .../eterm/terminal/terminalemulator.hpp | 5 ++ .../src/eterm/terminal/terminalemulator.cpp | 3 + src/tests/unit_tests/regex.cpp | 2 + .../plugins/aiassistant/acp/acpprotocol.cpp | 2 + .../plugins/aiassistant/acp/acpprotocol.hpp | 1 + .../plugins/aiassistant/acp/agentsession.cpp | 56 +++++++++++++-- .../plugins/aiassistant/acp/agentsession.hpp | 5 ++ .../ecode/plugins/aiassistant/chatui.cpp | 40 ++++------- 13 files changed, 221 insertions(+), 62 deletions(-) create mode 100644 .agent/plans/acp-improvement.md diff --git a/.agent/plans/acp-improvement.md b/.agent/plans/acp-improvement.md new file mode 100644 index 000000000..ef606d089 --- /dev/null +++ b/.agent/plans/acp-improvement.md @@ -0,0 +1,54 @@ +# Plan: ACP Support Improvement for ecode + +This plan outlines the steps to complete and improve the Agent Client Protocol (ACP) implementation in `ecode`. + +## Current Status & Gaps +The current implementation has the basic structure but misses several critical features for a full ACP experience: +- **Terminal output** is not captured (returns empty). +- **Tool call updates** are not handled in the UI. +- **Plan schema** is non-standard. +- **Slash commands** are not exposed to the user. +- **Terminal limits** are ignored. + +## Proposed Steps + +### 1. Research & Refinement (Done) +- [x] Analyze ACP documentation. +- [x] Review current `ecode` implementation. +- [x] Identify missing pieces. + +### 2. Phase 1: Terminal Output Capture (Done) +We need a way to capture the output from the terminal process to satisfy `terminal/output` requests. +- [x] Modify `src/modules/eterm/include/eterm/terminal/terminalemulator.hpp`: + - Add `using DataCb = std::function;` + - Add `void setDataCb(DataCb cb);` +- [x] Modify `src/modules/eterm/src/eterm/terminal/terminalemulator.cpp`: + - Call `mDataCb` in `ttyread()` when new data is received. +- [x] Update `src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp`: + - Add an output buffer to `TermData`. +- [x] Update `src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp`: + - Set the `DataCb` in `onTerminalCreated`. + - Implement `onTerminalOutput` to return and clear the buffer. + - Respect `outputByteLimit`. + +### 3. Phase 2: UI & Protocol Updates +- [ ] Update `src/tools/ecode/plugins/aiassistant/chatui.cpp`: + - Fix `plan` update handling to use the standard `entries` schema. + - Implement `tool_call_update` handling. + - Update tool call UI to reflect status (pending, in_progress, completed, failed, cancelled). +- [ ] Implement embedded terminals in tool calls: + - When a `tool_call_update` contains a `terminalId`, link the terminal UI to that tool call bubble if possible. + +### 4. Phase 3: UX Improvements +- [ ] Expose slash commands: + - Show `mAvailableCommands` in the UI. + - Add simple autocompletion for `/` commands in `mChatInput`. +- [ ] Enhance terminal management: + - Ensure `terminal/wait_for_exit` works correctly (async). + +### 5. Phase 4: Validation +- [ ] Test with a compatible ACP agent. +- [ ] Verify file system tools. +- [ ] Verify terminal creation and output reading. +- [ ] Verify plan updates. +- [ ] Verify tool call permissions. diff --git a/include/eepp/graphics/fonttruetype.hpp b/include/eepp/graphics/fonttruetype.hpp index 6db1b9807..d75dd6d8d 100644 --- a/include/eepp/graphics/fonttruetype.hpp +++ b/include/eepp/graphics/fonttruetype.hpp @@ -67,7 +67,8 @@ class EE_API FontTrueType : public Font { bool loaded() const; - FontTrueType& operator=( const FontTrueType& right ); + FontTrueType( const FontTrueType& ) = delete; + FontTrueType& operator=( const FontTrueType& ) = delete; bool getBoldAdvanceSameAsRegular() const; @@ -182,11 +183,11 @@ class EE_API FontTrueType : public Font { GlyphTable glyphs; ///< Table mapping code points to their corresponding glyph GlyphDrawableTable - drawables; ///> Table mapping code points to their corresponding glyph drawables. - Texture* texture; ///< Texture containing the pixels of the glyphs - std::vector rows; ///< List containing the position of all the existing rows - Uint32 fontInternalId{ 0 }; // The font internal id - unsigned int nextRow; ///< Y position of the next new row in the texture + drawables; ///> Table mapping code points to their corresponding glyph drawables. + Texture* texture{ nullptr }; ///< Texture containing the pixels of the glyphs + std::vector rows; ///< List containing the position of all the existing rows + Uint32 fontInternalId{ 0 }; // The font internal id + unsigned int nextRow; ///< Y position of the next new row in the texture const FontTrueType* font{ nullptr }; }; @@ -212,14 +213,14 @@ class EE_API FontTrueType : public Font { typedef UnorderedMap> PageTable; ///< Table mapping a character size to its page (texture) - void* mLibrary; ///< Pointer to the internal library interface (it is typeless to avoid exposing - ///< implementation details) - void* mFace; ///< Pointer to the internal font face (it is typeless to avoid exposing - ///< implementation details) - void* mStreamRec; ///< Pointer to the stream rec instance (it is typeless to avoid exposing - ///< implementation details) - void* mStroker; ///< Pointer to the stroker (it is typeless to avoid exposing implementation - ///< details) + void* mLibrary{ nullptr }; ///< Pointer to the internal library interface (it is typeless to + ///< avoid exposing implementation details) + void* mFace{ nullptr }; ///< Pointer to the internal font face (it is typeless to avoid exposing + ///< implementation details) + void* mStreamRec{ nullptr }; ///< Pointer to the stream rec instance (it is typeless to avoid + ///< exposing implementation details) + void* mStroker{ nullptr }; ///< Pointer to the stroker (it is typeless to avoid exposing + ///< implementation details) void* mHBFont{ nullptr }; mutable ScopedBuffer mMemCopy; ///< If loaded from memory, this is the file copy in memory Font::Info mInfo; ///< Information about the font diff --git a/include/eepp/system/resourcemanager.hpp b/include/eepp/system/resourcemanager.hpp index b282c1684..43dd7b639 100644 --- a/include/eepp/system/resourcemanager.hpp +++ b/include/eepp/system/resourcemanager.hpp @@ -316,7 +316,15 @@ template T* ResourceManagerMulti::add( T* resource ) { template bool ResourceManagerMulti::remove( T* resource, bool remove ) { if ( NULL != resource ) { - mResources.erase( resource->getId() ); + auto range = mResources.equal_range( resource->getId() ); + auto it = range.first; + while ( it != range.second ) { + if ( it->second == resource ) { + mResources.erase( it ); + break; + } + it++; + } if ( remove ) eeSAFE_DELETE( resource ); diff --git a/src/eepp/graphics/fontmanager.cpp b/src/eepp/graphics/fontmanager.cpp index e29e718a1..dd5e9dda6 100644 --- a/src/eepp/graphics/fontmanager.cpp +++ b/src/eepp/graphics/fontmanager.cpp @@ -7,7 +7,11 @@ SINGLETON_DECLARE_IMPLEMENTATION( FontManager ) FontManager::FontManager() {} -FontManager::~FontManager() {} +FontManager::~FontManager() { + mEmojiFont = nullptr; + mColorEmojiFont = nullptr; + mFallbackFonts.clear(); +} Graphics::Font* FontManager::add( Graphics::Font* font ) { eeASSERT( NULL != font ); diff --git a/src/eepp/graphics/fonttruetype.cpp b/src/eepp/graphics/fonttruetype.cpp index f151bc721..559f2809d 100644 --- a/src/eepp/graphics/fonttruetype.cpp +++ b/src/eepp/graphics/fonttruetype.cpp @@ -251,10 +251,31 @@ FontTrueType::FontTrueType( const std::string& FontName ) : mFace( NULL ), mStreamRec( NULL ), mStroker( NULL ), - mInfo(), + mHBFont( NULL ), + mFontInternalId( 0 ), mBoldAdvanceSameAsRegular( false ), + mIsColorEmojiFont( false ), + mIsEmojiFont( false ), + mHasSvgGlyphs( false ), + mHasColrGlyphs( false ), + mIsBitmapOnly( false ), + mIsMonospace( false ), + mIsMonospaceComplete( false ), + mUsingFallback( false ), + mEnableEmojiFallback( true ), + mEnableFallbackFont( true ), + mEnableDynamicMonospace( false ), + mIsBold( false ), + mIsItalic( false ), + mIsMonospaceCompletePending( false ), mHinting( FontManager::instance()->getHinting() ), - mAntialiasing( FontManager::instance()->getAntialiasing() ) {} + mAntialiasing( FontManager::instance()->getAntialiasing() ), + mFontBold( nullptr ), + mFontItalic( nullptr ), + mFontBoldItalic( nullptr ), + mFontBoldCb( 0 ), + mFontItalicCb( 0 ), + mFontBoldItalicCb( 0 ) {} FontTrueType::~FontTrueType() { cleanup(); @@ -965,20 +986,6 @@ bool FontTrueType::loaded() const { return NULL != mFace; } -FontTrueType& FontTrueType::operator=( const FontTrueType& right ) { - FontTrueType temp( right.getName() ); - - temp.mMemCopy.swap( right.mMemCopy ); - std::swap( mLibrary, temp.mLibrary ); - std::swap( mFace, temp.mFace ); - std::swap( mStreamRec, temp.mStreamRec ); - std::swap( mStroker, temp.mStroker ); - std::swap( mInfo, temp.mInfo ); - std::swap( mPages, temp.mPages ); - std::swap( mPixelBuffer, temp.mPixelBuffer ); - return *this; -} - void FontTrueType::cleanup() { sendEvent( Event::Unload ); @@ -1028,9 +1035,36 @@ void FontTrueType::cleanup() { mLibrary = NULL; mFace = NULL; mStroker = NULL; + mHBFont = NULL; mStreamRec = NULL; + mInfo = Info(); + mFontInternalId = 0; + mBoldAdvanceSameAsRegular = false; + mIsColorEmojiFont = false; + mIsEmojiFont = false; + mHasSvgGlyphs = false; + mHasColrGlyphs = false; + mIsBitmapOnly = false; + mIsMonospace = false; + mIsMonospaceComplete = false; + mUsingFallback = false; + mEnableEmojiFallback = true; + mEnableFallbackFont = true; + mEnableDynamicMonospace = false; + mIsBold = false; + mIsItalic = false; + mIsMonospaceCompletePending = false; + mFontBold = nullptr; + mFontItalic = nullptr; + mFontBoldItalic = nullptr; + mFontBoldCb = 0; + mFontItalicCb = 0; + mFontBoldItalicCb = 0; mPages.clear(); std::vector().swap( mPixelBuffer ); + mCodePointIndexCache.clear(); + mKeyCache.clear(); + mClosestCharacterSize.clear(); } static int fontSetLoadOptions( FontAntialiasing antialiasing, FontHinting hinting ) { @@ -1430,6 +1464,10 @@ bool FontTrueType::setCurrentSize( unsigned int characterSize ) const { // only when necessary to avoid killing performances FT_Face face = static_cast( mFace ); + + if ( !face || !face->size ) + return false; + FT_UShort currentSize = face->size->metrics.x_ppem; if ( currentSize != characterSize ) { diff --git a/src/modules/eterm/include/eterm/terminal/terminalemulator.hpp b/src/modules/eterm/include/eterm/terminal/terminalemulator.hpp index 1e1cbc15e..1d43c28aa 100644 --- a/src/modules/eterm/include/eterm/terminal/terminalemulator.hpp +++ b/src/modules/eterm/include/eterm/terminal/terminalemulator.hpp @@ -254,6 +254,10 @@ class TerminalEmulator final { mPromptStateChangedCb = promptStateChangedCb; } + using DataCb = std::function; + + void setDataCb( DataCb cb ) { mDataCb = cb; } + int getTerminalMode() const { return mTerm.mode; } private: @@ -291,6 +295,7 @@ class TerminalEmulator final { std::string mCurrentWorkingDirectory; PromptState mPromptState{ PromptState::Unknown }; PromptStateChangedCb mPromptStateChangedCb; + DataCb mDataCb; void setClipboard( const char* str ); diff --git a/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp b/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp index 3ba018492..fe633c99e 100644 --- a/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp +++ b/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp @@ -653,6 +653,9 @@ size_t TerminalEmulator::ttyread( void ) { _die( "couldn't read from shell: %s\n", strerror( errno ) ); return 0; default: { + if ( mDataCb ) + mDataCb( mBuf + mBuflen, ret ); + int old_scr = mTerm.scr; int old_histi = mTerm.histi; mTerm.scr = 0; diff --git a/src/tests/unit_tests/regex.cpp b/src/tests/unit_tests/regex.cpp index 6058a66ef..fbdbb6002 100644 --- a/src/tests/unit_tests/regex.cpp +++ b/src/tests/unit_tests/regex.cpp @@ -82,6 +82,7 @@ UTEST( LuaPattern, basicTest ) { EXPECT_EQ( start, 14 ); EXPECT_EQ( end, 16 ); } + RegExCache::destroySingleton(); } UTEST( RegExEngines, basicTest ) { @@ -96,4 +97,5 @@ UTEST( RegExEngines, basicTest ) { EXPECT_EQ( 38, matchesPCRE2[0].end ); EXPECT_EQ( 38, matchesOniguruma[0].start ); EXPECT_EQ( 38, matchesOniguruma[0].end ); + RegExCache::destroySingleton(); } diff --git a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp index 1d389ea1b..9dc5e7d08 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp +++ b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp @@ -317,6 +317,8 @@ TerminalOutputRequest::TerminalOutputRequest( const json& body ) { sessionId = body.value( "sessionId", "" ); if ( body.contains( "terminalId" ) ) terminalId = body.value( "terminalId", "" ); + if ( body.contains( "outputByteLimit" ) && !body["outputByteLimit"].is_null() ) + outputByteLimit = body["outputByteLimit"].get(); } json TerminalOutputResponse::toJson() const { diff --git a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp index 53668ab79..ab3209415 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp +++ b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp @@ -249,6 +249,7 @@ struct TerminalExitStatus { struct TerminalOutputRequest { std::string sessionId; std::string terminalId; + std::optional outputByteLimit; TerminalOutputRequest() = default; TerminalOutputRequest( const json& body ); diff --git a/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp b/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp index 2ef064123..e6125dfee 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp +++ b/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp @@ -24,6 +24,8 @@ bool AgentSession::start( const std::function& onReady ) { mClient->initialize( req, [this, onReady]( const InitializeResponse&, const std::optional& err ) { if ( err ) { + if ( onError ) + onError( *err ); if ( onReady ) onReady( false ); return; @@ -33,6 +35,8 @@ bool AgentSession::start( const std::function& onReady ) { mClient->newSession( nreq, [this, onReady]( const NewSessionResponse& nres, const std::optional& err ) { if ( err ) { + if ( onError ) + onError( *err ); if ( onReady ) onReady( false ); return; @@ -62,6 +66,8 @@ bool AgentSession::startLoaded( const std::string& sessionId, onReady]( const InitializeResponse& ires, const std::optional& err ) { if ( err ) { + if ( onError ) + onError( *err ); if ( onReady ) onReady( false ); return; @@ -74,6 +80,8 @@ bool AgentSession::startLoaded( const std::string& sessionId, lreq, [this, sessionId, onReady]( const LoadSessionResponse& lres, const std::optional& err ) { if ( err ) { + if ( onError ) + onError( *err ); if ( onReady ) onReady( false ); return; @@ -84,6 +92,8 @@ bool AgentSession::startLoaded( const std::string& sessionId, onReady( true ); } ); } else { + if ( onError ) + onError( { -1, "Agent does not support loading sessions" } ); if ( onReady ) onReady( false ); } @@ -106,7 +116,9 @@ void AgentSession::listSessions( ListSessionsRequest req; req.cwd = mClient->getConfig().workingDirectory; mClient->listSessions( - req, [cb]( const ListSessionsResponse& res, const std::optional& err ) { + req, [this, cb]( const ListSessionsResponse& res, const std::optional& err ) { + if ( err && onError ) + onError( *err ); if ( cb ) cb( res.sessions, err ); } ); @@ -124,11 +136,26 @@ void AgentSession::prompt( mClient->prompt( req, [this, cb]( const PromptResponse& res, const std::optional& err ) { mIsPrompting = false; + if ( err && onError ) + onError( *err ); if ( cb ) cb( res, err ); } ); } +void AgentSession::setConfigOption( + const SetConfigOptionRequest& req, + const std::function& )>& cb ) { + mClient->setConfigOption( req, [this, cb]( const SetConfigOptionResponse& res, + const std::optional& err ) { + if ( err && onError ) + onError( *err ); + if ( cb ) + cb( res, err ); + } ); +} + void AgentSession::cancel() { if ( mClient && !mSessionId.empty() ) { mClient->cancel( mSessionId ); @@ -136,8 +163,16 @@ void AgentSession::cancel() { } void AgentSession::setTerminalData( const std::string& terminalId, UITerminal* uiTerm ) { - mTerminals[terminalId] = - TermData{ uiTerm->getTerm(), uiTerm->getTerm()->getTerminal(), uiTerm }; + auto& termData = mTerminals[terminalId]; + termData = { uiTerm->getTerm(), uiTerm->getTerm()->getTerminal(), uiTerm, "" }; + if ( termData.emulator ) { + termData.emulator->setDataCb( [this, terminalId]( const char* data, size_t size ) { + auto it = mTerminals.find( terminalId ); + if ( it != mTerminals.end() ) { + it->second.outputBuffer.append( data, size ); + } + } ); + } } void AgentSession::setupClient() { @@ -196,8 +231,19 @@ void AgentSession::setupClient() { res.output = ""; res.truncated = false; auto it = mTerminals.find( req.terminalId ); - if ( it != mTerminals.end() && it->second.emulator ) { - if ( it->second.emulator->hasExited() ) { + if ( it != mTerminals.end() ) { + size_t limit = req.outputByteLimit.value_or( 0 ); + if ( limit > 0 && it->second.outputBuffer.size() > limit ) { + res.output = it->second.outputBuffer.substr( 0, limit ); + it->second.outputBuffer.erase( 0, limit ); + res.truncated = true; + } else { + res.output = std::move( it->second.outputBuffer ); + it->second.outputBuffer.clear(); + res.truncated = false; + } + + if ( it->second.emulator && it->second.emulator->hasExited() ) { TerminalExitStatus status; status.exitCode = it->second.emulator->getExitCode(); res.exitStatus = status; diff --git a/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp b/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp index 3cbfa8eeb..01140ee2f 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp +++ b/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp @@ -29,6 +29,10 @@ class AgentSession { void prompt( const PromptRequest& req, const std::function& )>& cb ); + void setConfigOption( + const SetConfigOptionRequest& req, + const std::function& )>& cb ); void cancel(); bool isPrompting() const { return mIsPrompting; } @@ -59,6 +63,7 @@ class AgentSession { std::shared_ptr display; std::shared_ptr emulator; UITerminal* uiTerm{ nullptr }; + std::string outputBuffer; }; std::unordered_map mTerminals; diff --git a/src/tools/ecode/plugins/aiassistant/chatui.cpp b/src/tools/ecode/plugins/aiassistant/chatui.cpp index 5b82893d0..498c61d72 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.cpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.cpp @@ -724,14 +724,12 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : return; auto inputUserRole = LLMChat::stringToRole( mChatUserRole ); - if ( ( !chats.empty() && - ( mChatInput->getDocument().isEmpty() || inputUserRole != LLMChat::Role::User ) ) || - ( chats.empty() && inputUserRole != LLMChat::Role::User ) ) { - if ( chats[chats.size() - 1] - ->findByClass( "role_ui" ) - ->asType() - ->getListBox() - ->getItemSelectedIndex() != 0 ) { + if ( !mIsAgentMode && ( ( !chats.empty() && ( mChatInput->getDocument().isEmpty() || + inputUserRole != LLMChat::Role::User ) ) || + ( chats.empty() && inputUserRole != LLMChat::Role::User ) ) ) { + auto rolePicker = chats[chats.size() - 1]->findByClass( "role_ui" ); + if ( rolePicker && + rolePicker->asType()->getListBox()->getItemSelectedIndex() != 0 ) { showMsg( getUISceneNode()->i18n( "llm_last_message_must_be_from_user", "The last chat message must be from a \"User\" role" ) ); @@ -1302,9 +1300,6 @@ void LLMChatUI::showChatHistory() { return; if ( err ) { loader->setVisible( false ); - NotificationCenter::instance()->addNotification( - uiSceneNode->i18n( "ai_assistant_agent_error", "Agent Error: " ) + - err->message ); win->asType()->closeWindow(); return; } @@ -1631,17 +1626,10 @@ void LLMChatUI::showAgentConfigWindow() { req.sessionId = mAgentSession->getSessionId(); req.configId = optId; req.optionId = subId; - mAgentSession->getClient()->setConfigOption( + mAgentSession->setConfigOption( req, [this, optId, subId]( const acp::SetConfigOptionResponse& res, const std::optional& err ) { - if ( err ) { - runOnMainThread( [this, err]() { - NotificationCenter::instance()->addNotification( - i18n( "agent_config_error", - "Failed to update agent config: " ) + - err->message ); - } ); - } else { + if ( !err ) { auto newOpts = res.configOptions; if ( newOpts.empty() ) { newOpts = acp::parseLegacyConfigOptions( @@ -1871,11 +1859,13 @@ void LLMChatUI::setupAgentSession() { addPlanBubble( planMarkdown ); } else if ( sessionUpdate == "available_commands_update" ) { if ( msg.contains( "availableCommands" ) && msg["availableCommands"].is_array() ) { - mAvailableCommands.clear(); - for ( const auto& cmd : msg["availableCommands"] ) { - mAvailableCommands.push_back( - { cmd.value( "name", "" ), cmd.value( "description", "" ) } ); - } + runOnMainThread( [this, msg] { + mAvailableCommands.clear(); + for ( const auto& cmd : msg["availableCommands"] ) { + mAvailableCommands.push_back( + { cmd.value( "name", "" ), cmd.value( "description", "" ) } ); + } + } ); } } };