This commit is contained in:
Martín Lucas Golini
2026-03-09 20:19:10 -03:00
parent a83812272c
commit bbba9b9023
12 changed files with 1438 additions and 5 deletions
+21
View File
@@ -501,5 +501,26 @@
"fetch_models_url": "http://localhost:8080/api/tags", "fetch_models_url": "http://localhost:8080/api/tags",
"open_api": true "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"]
}
} }
} }
@@ -0,0 +1,233 @@
#include "acpclient.hpp"
#include <eepp/system/log.hpp>
namespace ecode { namespace acp {
ACPClient::ACPClient( std::shared_ptr<ThreadPool> 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<IdType>();
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<void( const InitializeResponse& )>& 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<void( const NewSessionResponse& )>& 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<void( const PromptResponse& )>& 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
@@ -0,0 +1,108 @@
#pragma once
#include "acpprotocol.hpp"
#include <atomic>
#include <eepp/system/clock.hpp>
#include <eepp/system/mutex.hpp>
#include <eepp/system/process.hpp>
#include <eepp/system/threadpool.hpp>
#include <functional>
#include <map>
#include <memory>
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
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<void( const IdType& id, const json& )>;
struct Config {
std::string command;
std::vector<std::string> args;
std::string workingDirectory;
std::unordered_map<std::string, std::string> environment;
};
ACPClient( std::shared_ptr<ThreadPool> 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<void( const InitializeResponse& )>& cb );
void newSession( const NewSessionRequest& req,
const std::function<void( const NewSessionResponse& )>& cb );
void prompt( const PromptRequest& req, const std::function<void( const PromptResponse& )>& cb );
// Notifications to agent
void cancel( const std::string& sessionId );
// Callbacks from agent
std::function<void( const json& )> onSessionUpdate;
std::function<void( const ReadTextFileRequest&,
std::function<void( const ReadTextFileResponse& )> )>
onReadTextFile;
std::function<void( const WriteTextFileRequest&,
std::function<void( const WriteTextFileResponse& )> )>
onWriteTextFile;
std::function<void( const RequestPermissionRequest&,
std::function<void( const RequestPermissionResponse& )> )>
onRequestPermission;
std::function<void( const CreateTerminalRequest&,
std::function<void( const CreateTerminalResponse& )> )>
onCreateTerminal;
std::function<void( const TerminalOutputRequest&,
std::function<void( const TerminalOutputResponse& )> )>
onTerminalOutput;
std::function<void( const KillTerminalRequest&,
std::function<void( const KillTerminalResponse& )> )>
onKillTerminal;
std::function<void( const ReleaseTerminalRequest&,
std::function<void( const ReleaseTerminalResponse& )> )>
onReleaseTerminal;
std::function<void( const WaitForTerminalExitRequest&,
std::function<void( const WaitForTerminalExitResponse& )> )>
onWaitForTerminalExit;
protected:
std::shared_ptr<ThreadPool> mThreadPool;
Config mConfig;
Process mProcess;
bool mReady{ false };
bool mShuttingDown{ false };
std::atomic<int> mLastMsgId{ 0 };
Mutex mHandlersMutex;
std::map<IdType, JsonReplyHandler> 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
@@ -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<int>();
}
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<int>();
if ( body.contains( "limit" ) && !body["limit"].is_null() )
limit = body["limit"].get<int>();
}
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<std::string>() );
}
if ( body.contains( "cwd" ) && !body["cwd"].is_null() )
cwd = body["cwd"].get<std::string>();
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<uint64_t>();
}
json CreateTerminalResponse::toJson() const {
return { { "terminalId", terminalId } };
}
TerminalExitStatus::TerminalExitStatus( const json& body ) {
if ( body.contains( "exitCode" ) && !body["exitCode"].is_null() )
exitCode = body["exitCode"].get<uint32_t>();
if ( body.contains( "signal" ) && !body["signal"].is_null() )
signal = body["signal"].get<std::string>();
}
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
@@ -0,0 +1,254 @@
#pragma once
#include <eepp/core/containers.hpp>
#include <optional>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
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<int> line;
ToolCallLocation() = default;
ToolCallLocation( const json& body );
};
struct ToolCall {
std::string title;
std::string toolCallId;
std::string kind;
json rawInput;
json rawOutput;
std::vector<ToolCallLocation> locations;
ToolCall() = default;
ToolCall( const json& body );
};
struct ReadTextFileRequest {
std::string sessionId;
std::string path;
std::optional<int> line;
std::optional<int> 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<std::string> args;
std::optional<std::string> cwd;
std::vector<EnvVariable> env;
std::optional<uint64_t> outputByteLimit;
CreateTerminalRequest() = default;
CreateTerminalRequest( const json& body );
};
struct CreateTerminalResponse {
std::string terminalId;
CreateTerminalResponse() = default;
json toJson() const;
};
struct TerminalExitStatus {
std::optional<uint32_t> exitCode;
std::optional<std::string> 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<TerminalExitStatus> 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<uint32_t> exitCode;
std::optional<std::string> 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<PermissionOption> options;
RequestPermissionRequest() = default;
RequestPermissionRequest( const json& body );
};
struct RequestPermissionResponse {
std::string outcome;
std::optional<std::string> optionId;
RequestPermissionResponse() = default;
json toJson() const;
};
}} // namespace ecode::acp
@@ -0,0 +1,150 @@
#include "agentsession.hpp"
#include <eepp/system/filesystem.hpp>
#include <eepp/system/log.hpp>
namespace ecode { namespace acp {
AgentSession::AgentSession( std::shared_ptr<ThreadPool> threadPool,
const ACPClient::Config& config ) :
mThreadPool( threadPool ), mClient( std::make_unique<ACPClient>( threadPool, config ) ) {
setupClient();
}
AgentSession::~AgentSession() {
stop();
}
bool AgentSession::start( const std::function<void( bool )>& 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<void( const PromptResponse& )>& 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
@@ -0,0 +1,56 @@
#pragma once
#include "acpclient.hpp"
#include <eepp/system/threadpool.hpp>
#include <eterm/terminal/terminalemulator.hpp>
#include <eterm/ui/uiterminal.hpp>
#include <functional>
#include <memory>
#include <string>
#include <unordered_map>
using namespace EE::System;
using namespace eterm::Terminal;
using namespace eterm::UI;
namespace ecode { namespace acp {
class AgentSession {
public:
AgentSession( std::shared_ptr<ThreadPool> threadPool, const ACPClient::Config& config );
~AgentSession();
bool start( const std::function<void( bool )>& onReady );
void stop();
void prompt( const PromptRequest& req, const std::function<void( const PromptResponse& )>& cb );
void cancel();
bool isPrompting() const { return mIsPrompting; }
std::function<void( const json& )> onSessionUpdate;
std::function<void( const RequestPermissionRequest&,
std::function<void( const RequestPermissionResponse& )> )>
onRequestPermission;
std::function<void( UITerminal*, const std::string& terminalId )> onTerminalCreated;
std::string getSessionId() const { return mSessionId; }
ACPClient* getClient() const { return mClient.get(); }
protected:
std::shared_ptr<ThreadPool> mThreadPool;
std::unique_ptr<ACPClient> mClient;
std::string mSessionId;
bool mIsPrompting{ false };
struct TermData {
std::shared_ptr<TerminalDisplay> display;
std::shared_ptr<TerminalEmulator> emulator;
UITerminal* uiTerm{ nullptr };
};
std::unordered_map<std::string, TermData> mTerminals;
void setupClient();
};
}} // namespace ecode::acp
@@ -108,6 +108,34 @@ static std::map<std::string, LLMProvider> parseLLMProviders( const nlohmann::jso
return providers; return providers;
} }
static std::map<std::string, ACPAgent> parseACPAgents( const nlohmann::json& j ) {
std::map<std::string, ACPAgent> 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<std::string>() );
}
}
if ( agentJson.contains( "environment" ) && agentJson["environment"].is_object() ) {
for ( const auto& envItem : agentJson["environment"].items() ) {
agent.environment[envItem.key()] = envItem.value().get<std::string>();
}
}
agents[agentName] = agent;
}
return agents;
}
Plugin* AIAssistantPlugin::New( PluginManager* pluginManager ) { Plugin* AIAssistantPlugin::New( PluginManager* pluginManager ) {
return eeNew( AIAssistantPlugin, ( pluginManager, false ) ); 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" ) ) if ( !j.contains( "providers" ) )
return; return;
@@ -598,6 +633,7 @@ void AIAssistantPlugin::onSaveState( IniFile* state ) {
config.partition = mainChat->getSplitter()->getSplitPartition(); config.partition = mainChat->getSplitter()->getSplitPartition();
config.modelProvider = mainChat->getCurModel().provider; config.modelProvider = mainChat->getCurModel().provider;
config.modelName = mainChat->getCurModel().name; config.modelName = mainChat->getCurModel().name;
config.agentName = mainChat->getCurAgent();
} else { } else {
config = mConfig; config = mConfig;
} }
@@ -610,6 +646,7 @@ void AIAssistantPlugin::onSaveState( IniFile* state ) {
state->setValue( keyname, "split_partition", config.partition.toString() ); state->setValue( keyname, "split_partition", config.partition.toString() );
state->setValue( keyname, "default_provider", config.modelProvider ); state->setValue( keyname, "default_provider", config.modelProvider );
state->setValue( keyname, "default_model", config.modelName ); state->setValue( keyname, "default_model", config.modelName );
state->setValue( keyname, "default_agent", config.agentName );
} }
} // namespace ecode } // namespace ecode
@@ -14,6 +14,7 @@ class AIAssistantPlugin : public PluginBase {
StyleSheetLength partition; StyleSheetLength partition;
std::string modelProvider; std::string modelProvider;
std::string modelName; std::string modelName;
std::string agentName;
}; };
static PluginDefinition Definition() { static PluginDefinition Definition() {
@@ -37,6 +38,7 @@ class AIAssistantPlugin : public PluginBase {
std::string getDescription() override { return Definition().description; } std::string getDescription() override { return Definition().description; }
const LLMProviders& getProviders() { return mProviders; } const LLMProviders& getProviders() { return mProviders; }
const ACPAgents& getAgents() { return mAgents; }
std::string getPluginStatePath() const; std::string getPluginStatePath() const;
@@ -50,6 +52,7 @@ class AIAssistantPlugin : public PluginBase {
protected: protected:
LLMProviders mProviders; LLMProviders mProviders;
ACPAgents mAgents;
bool mUIInit{ false }; bool mUIInit{ false };
bool mBrokenUserConfigFile{ false }; bool mBrokenUserConfigFile{ false };
UIWidget* mStatusBar{ nullptr }; UIWidget* mStatusBar{ nullptr };
+275 -4
View File
@@ -179,8 +179,10 @@ DropDownList.role_ui {
<PushButton id="llm_more" class="llm_button" tooltip="@string(more_options, More Options)" icon="icon(more-fill, 14dp)" min-width="32dp" /> <PushButton id="llm_more" class="llm_button" tooltip="@string(more_options, More Options)" icon="icon(more-fill, 14dp)" min-width="32dp" />
<hbox lw="0" lw8="1" lh="mp" layout_gravity="center" padding-left="4dp" padding-right="4dp"> <hbox lw="0" lw8="1" lh="mp" layout_gravity="center" padding-left="4dp" padding-right="4dp">
<DropDownList class="model_ui" menu-width-mode="expand-if-needed-centered" lw="0" lw8="1" selected-index="0"></DropDownList> <DropDownList class="model_ui" menu-width-mode="expand-if-needed-centered" lw="0" lw8="1" selected-index="0"></DropDownList>
<DropDownList class="agent_ui" menu-width-mode="expand-if-needed-centered" lw="0" lw8="1" selected-index="0" visible="false"></DropDownList>
<!-- <PushButton id="refresh_model_ui" tooltip="@string(refresh_model_ui, Refresh Local Models)" icon="icon(refresh, 14dp)" /> --> <!-- <PushButton id="refresh_model_ui" tooltip="@string(refresh_model_ui, Refresh Local Models)" icon="icon(refresh, 14dp)" /> -->
</hbox> </hbox>
<SelectButton id="llm_agent_mode" class="llm_button" tooltip="@string(toggle_agent_mode, Toggle Agent Mode)" icon="icon(robot, 14dp)" min-width="32dp" margin-right="4dp" select-on-click="true" />
<PushButton id="llm_settings_but" class="llm_button" text="@string(settings, Settings)" tooltip="@string(settings, Settings)" icon="icon(settings, 14dp)" min-width="32dp" margin-right="4dp" /> <PushButton id="llm_settings_but" class="llm_button" text="@string(settings, Settings)" tooltip="@string(settings, Settings)" icon="icon(settings, 14dp)" min-width="32dp" margin-right="4dp" />
<!-- <SelectButton id="llm_private_chat" class="llm_button" tooltip="@string(private_chat, Toggle Private Chat)" icon="icon(chat-private, 14dp)" min-width="32dp" margin-right="8dp" select-on-click="true" /> --> <!-- <SelectButton id="llm_private_chat" class="llm_button" tooltip="@string(private_chat, Toggle Private Chat)" icon="icon(chat-private, 14dp)" min-width="32dp" margin-right="8dp" select-on-click="true" /> -->
<PushButton id="llm_add_chat" class="llm_button" text="@string(add, Add)" tooltip="@string(add_message, Add Message)" icon="icon(add, 15dp)" min-width="32dp" margin-right="4dp" /> <PushButton id="llm_add_chat" class="llm_button" text="@string(add, Add)" tooltip="@string(add_message, Add Message)" icon="icon(add, 15dp)" min-width="32dp" margin-right="4dp" />
@@ -208,6 +210,18 @@ static const char* DEFAULT_CHAT_GLOBE = R"xml(
</vbox> </vbox>
)xml"; )xml";
static const char* DEFAULT_PERMISSION_GLOBE = R"xml(
<vbox class="llm_conversation tool_permission" lw="mp" lh="wc" margin-bottom="8dp">
<hbox class="llm_conversation_opt" lw="mp" lh="wc" background-color="var(--primary)" padding="4dp">
<TextView text="Tool Call Permission Request" font-style="bold" margin-left="4dp" />
</hbox>
<vbox class="data_ui" lw="mp" lh="wc" padding="8dp" background-color="var(--tab-back)">
<TextView class="permission_desc" lw="mp" lh="wc" word-wrap="true" />
<hbox class="permission_options" lw="mp" lh="wc" margin-top="8dp" />
</vbox>
</vbox>
)xml";
LLMChatUI::LLMChatUI( PluginManager* manager ) : LLMChatUI::LLMChatUI( PluginManager* manager ) :
UILinearLayout(), WidgetCommandExecuter( getInput() ), mManager( manager ) { UILinearLayout(), WidgetCommandExecuter( getInput() ), mManager( manager ) {
setClass( "llm_chatui" ); setClass( "llm_chatui" );
@@ -220,6 +234,14 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) :
mChatsList = findByClass( "llm_chats" ); mChatsList = findByClass( "llm_chats" );
mModelDDL = findByClass<UIDropDownList>( "model_ui" ); mModelDDL = findByClass<UIDropDownList>( "model_ui" );
mAgentDDL = findByClass<UIDropDownList>( "agent_ui" );
mChatAgentMode = find<UISelectButton>( "llm_agent_mode" );
mChatAgentMode->on( Event::OnValueChange, [this]( auto ) {
mIsAgentMode = mChatAgentMode->isSelected();
mModelDDL->setVisible( !mIsAgentMode );
mAgentDDL->setVisible( mIsAgentMode );
} );
// mRefreshModels = find<UIPushButton>( "refresh_model_ui" ); // mRefreshModels = find<UIPushButton>( "refresh_model_ui" );
// mRefreshModels->onClick( [this]( auto ) { execute( "ai-refresh-local-models" ); } ); // mRefreshModels->onClick( [this]( auto ) { execute( "ai-refresh-local-models" ); } );
@@ -314,12 +336,16 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) :
setCmd( "ai-prompt", [this] { setCmd( "ai-prompt", [this] {
// "ai-prompt-stop" // "ai-prompt-stop"
if ( mRequest ) { if ( mRequest || ( mAgentSession && mAgentSession->isPrompting() ) ) {
if ( !mRequest->isCancelled() ) { if ( mRequest && !mRequest->isCancelled() ) {
mRequest->cancel(); mRequest->cancel();
return; return;
} else } else if ( mAgentSession && mAgentSession->isPrompting() ) {
mAgentSession->cancel();
return;
} else {
mRequest.reset(); mRequest.reset();
}
} }
auto chats = findAllByClass( "llm_conversation" ); auto chats = findAllByClass( "llm_conversation" );
@@ -344,7 +370,12 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) :
} }
execute( "ai-add-chat" ); execute( "ai-add-chat" );
doRequest();
if ( mIsAgentMode ) {
doAgentRequest();
} else {
doRequest();
}
} ); } );
setCmd( "ai-prompt-stop", [this] { setCmd( "ai-prompt-stop", [this] {
@@ -550,6 +581,8 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) :
setProviders( std::move( providers ) ); setProviders( std::move( providers ) );
mCurModel = getDefaultModel(); mCurModel = getDefaultModel();
mAgents = getPlugin()->getAgents();
AppConfig& config = getPlugin()->getPluginContext()->getConfig(); AppConfig& config = getPlugin()->getPluginContext()->getConfig();
auto partition = config.iniState.getValue( "aiassistant", "split_partition", "" ); 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 modelProvider = config.iniState.getValue( "aiassistant", "default_provider", "" );
auto modelName = config.iniState.getValue( "aiassistant", "default_model", "" ); auto modelName = config.iniState.getValue( "aiassistant", "default_model", "" );
auto agentName = config.iniState.getValue( "aiassistant", "default_agent", "" );
if ( !modelProvider.empty() && !modelName.empty() ) { if ( !modelProvider.empty() && !modelName.empty() ) {
auto modelOpt = getModel( modelProvider, modelName ); 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 ); fillModelDropDownList( mModelDDL );
fillAgentDropDownList( mAgentDDL );
const auto appendShortcutToTooltip = [this]( UIPushButton* but, const std::string& cmd ) { const auto appendShortcutToTooltip = [this]( UIPushButton* but, const std::string& cmd ) {
auto kb = getKeyBindings().getCommandKeybindString( cmd ); auto kb = getKeyBindings().getCommandKeybindString( cmd );
@@ -968,6 +1009,187 @@ void LLMChatUI::fillModelDropDownList( UIDropDownList* modelDDL ) {
[this, modelDDL] { fillApiModels( modelDDL ); } ); [this, modelDDL] { fillApiModels( modelDDL ); } );
} }
void LLMChatUI::fillAgentDropDownList( UIDropDownList* agentDDL ) {
std::vector<String> 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<UICodeEditor>( "data_ui" );
auto* thinking = editor->findByClass<UIImage>( "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<acp::AgentSession>( getUISceneNode()->getThreadPool(), config );
UIWidget* chat = addChatUI( LLMChat::Role::Assistant );
toggleEnableChats( false );
auto* editor = chat->findByClass<UICodeEditor>( "data_ui" );
editor->setEnabled( false );
auto* thinking = editor->findByClass<UIImage>( "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<UICodeEditor>( "data_ui" );
auto* thinking = editor->findByClass<UIImage>( "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<UICodeEditor>( "data_ui" );
editor->setEnabled( false );
auto* thinking = editor->findByClass<UIImage>( "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<UICodeEditor>( "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<UICodeEditor>( "data_ui" );
auto* thinking = editor->findByClass<UIImage>( "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 ) { void LLMChatUI::resizeToFit( UICodeEditor* editor ) {
Float visibleLineCount = editor->getDocumentView().getVisibleLinesCount(); Float visibleLineCount = editor->getDocumentView().getVisibleLinesCount();
Float lineHeight = editor->getLineHeight(); Float lineHeight = editor->getLineHeight();
@@ -1351,6 +1573,55 @@ Drawable* LLMChatUI::findIcon( const std::string& name, const size_t iconSize )
return nullptr; return nullptr;
} }
void LLMChatUI::addPermissionUI( const acp::RequestPermissionRequest& req,
std::function<void( const acp::RequestPermissionResponse& )> cb ) {
find( "chat_presentation" )->setVisible( false );
UIWidget* chat =
mChatsList->getUISceneNode()->loadLayoutFromString( DEFAULT_PERMISSION_GLOBE, mChatsList );
UITextView* desc = chat->findByClass<UITextView>( "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 ) { UIWidget* LLMChatUI::addChatUI( LLMChat::Role role ) {
find( "chat_presentation" )->setVisible( false ); find( "chat_presentation" )->setVisible( false );
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "../pluginmanager.hpp" #include "../pluginmanager.hpp"
#include "agentsession.hpp"
#include "llmchatcompletionrequest.hpp" #include "llmchatcompletionrequest.hpp"
#include "protocol.hpp" #include "protocol.hpp"
@@ -91,6 +92,8 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter {
bool chatExistsInDisk() const; bool chatExistsInDisk() const;
const std::string& getCurAgent() const { return mCurAgent; }
protected: protected:
UUID mUUID; UUID mUUID;
std::string mSummary; std::string mSummary;
@@ -109,8 +112,10 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter {
UIPushButton* mRefreshModels{ nullptr }; UIPushButton* mRefreshModels{ nullptr };
UIPushButton* mChatAttach{ nullptr }; UIPushButton* mChatAttach{ nullptr };
UISelectButton* mChatPrivate{ nullptr }; UISelectButton* mChatPrivate{ nullptr };
UISelectButton* mChatAgentMode{ nullptr };
UIScrollView* mChatScrollView{ nullptr }; UIScrollView* mChatScrollView{ nullptr };
UIDropDownList* mModelDDL{ nullptr }; UIDropDownList* mModelDDL{ nullptr };
UIDropDownList* mAgentDDL{ nullptr };
UIVLinearLayoutCommandExecuter* mLocateBarLayout{ nullptr }; UIVLinearLayoutCommandExecuter* mLocateBarLayout{ nullptr };
UITextInput* mLocateInput{ nullptr }; UITextInput* mLocateInput{ nullptr };
UITableView* mLocateTable{ nullptr }; UITableView* mLocateTable{ nullptr };
@@ -120,8 +125,15 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter {
LLMProviders mProviders; LLMProviders mProviders;
LLMModel mCurModel; LLMModel mCurModel;
std::unordered_map<String::HashType, LLMModel> mModelsMap; std::unordered_map<String::HashType, LLMModel> mModelsMap;
std::map<std::string, ACPAgent> mAgents;
std::string mCurAgent;
std::unique_ptr<acp::AgentSession> mAgentSession;
int mPendingModelsToLoad{ 0 }; int mPendingModelsToLoad{ 0 };
bool mChatIsPrivate{ false }; bool mChatIsPrivate{ false };
bool mIsAgentMode{ false };
bool mChatLocked{ false }; bool mChatLocked{ false };
bool mLinkMode{ false }; bool mLinkMode{ false };
@@ -143,6 +155,10 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter {
void doRequest(); void doRequest();
void doAgentRequest();
void sendAgentPrompt();
void toggleEnableChat( UIWidget* chat, bool enabled ); void toggleEnableChat( UIWidget* chat, bool enabled );
void toggleEnableChats( bool enabled ); void toggleEnableChats( bool enabled );
@@ -151,6 +167,9 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter {
UIWidget* addChatUI( LLMChat::Role role ); UIWidget* addChatUI( LLMChat::Role role );
void addPermissionUI( const acp::RequestPermissionRequest& req,
std::function<void( const acp::RequestPermissionResponse& )> cb );
void fillApiModels( UIDropDownList* modelDDL ); void fillApiModels( UIDropDownList* modelDDL );
String getModelDisplayName( const LLMModel& model ) const; String getModelDisplayName( const LLMModel& model ) const;
@@ -159,10 +178,14 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter {
void fillModelDropDownList( UIDropDownList* modelDDL ); void fillModelDropDownList( UIDropDownList* modelDDL );
void fillAgentDropDownList( UIDropDownList* agentDDL );
void resizeToFit( UICodeEditor* editor ); void resizeToFit( UICodeEditor* editor );
void addChat( LLMChat::Role role, std::string conversation ); void addChat( LLMChat::Role role, std::string conversation );
void writeToLastChat( const std::string& text );
void removeLastChat(); void removeLastChat();
void setProviders( LLMProviders&& providers ); void setProviders( LLMProviders&& providers );
@@ -4,6 +4,7 @@
#include <optional> #include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
#include <unordered_map>
namespace ecode { namespace ecode {
@@ -40,4 +41,14 @@ struct LLMProvider {
using LLMProviders = std::map<std::string, LLMProvider>; using LLMProviders = std::map<std::string, LLMProvider>;
struct ACPAgent {
bool enabled{ true };
std::string name;
std::string command;
std::vector<std::string> args;
std::unordered_map<std::string, std::string> environment;
};
using ACPAgents = std::map<std::string, ACPAgent>;
} // namespace ecode } // namespace ecode