More ACP client WIP, finishing the protocol implementation.

This commit is contained in:
Martín Lucas Golini
2026-03-23 14:25:44 -03:00
parent a105cdb301
commit 5429b15e04
10 changed files with 188 additions and 112 deletions

View File

@@ -1,54 +0,0 @@
# 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<void(const char*, size_t)>;`
- 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
- [x] 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).
- [x] 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.

View File

@@ -70,6 +70,19 @@ def main():
}}) + "\n")
sys.stdout.flush()
# Send available commands
send_notification("session/update", {
"sessionId": "test-session",
"update": {
"sessionUpdate": "available_commands_update",
"availableCommands": [
{"name": "search", "description": "Search in codebase"},
{"name": "build", "description": "Build the project"},
{"name": "test", "description": "Run tests"}
]
}
})
elif method == "session/prompt":
# 1. Send Plan
send_notification("session/update", {

View File

@@ -2006,7 +2006,7 @@ Vector2f UICodeEditor::getRelativeScreenPosition( const TextPosition& pos ) {
Vector2f startScroll( start - mScroll );
auto offset = getTextPositionOffset( pos );
return { static_cast<Float>( startScroll.x + offset.x ),
static_cast<Float>( startScroll.y + offset.y + getLineOffset() ) };
static_cast<Float>( startScroll.y + offset.y + mPaddingPx.Top + getLineOffset() ) };
}
bool UICodeEditor::getShowLinesRelativePosition() const {

View File

@@ -445,7 +445,7 @@ void UISceneNode::setThreadPool( const std::shared_ptr<ThreadPool>& threadPool )
static std::string getErrorContext( size_t offset, std::string_view content ) {
static constexpr auto CONTEXT_LENGTH = 40;
std::size_t left = std::max( 0ul, offset - CONTEXT_LENGTH );
std::size_t left = std::max( 0ul, offset >= CONTEXT_LENGTH ? offset - CONTEXT_LENGTH : 0ul );
std::size_t right = std::min( content.size(), offset + 40 );
return std::string{ content.substr( left, right - left ) };
}

View File

@@ -115,16 +115,22 @@ void AgentSession::listSessions(
}
ListSessionsRequest req;
req.cwd = mClient->getConfig().workingDirectory;
mClient->listSessions(
req, [this, cb]( const ListSessionsResponse& res, const std::optional<ResponseError>& err ) {
if ( err && onError )
onError( *err );
if ( cb )
cb( res.sessions, err );
} );
mClient->listSessions( req, [this, cb]( const ListSessionsResponse& res,
const std::optional<ResponseError>& err ) {
if ( err && onError )
onError( *err );
if ( cb )
cb( res.sessions, err );
} );
}
void AgentSession::stop() {
for ( auto& term : mTerminals ) {
if ( term.second.display && term.second.eventCbId )
term.second.display->popEventCallback( term.second.eventCbId );
}
mTerminals.clear();
if ( mClient )
mClient->stop();
}
@@ -164,7 +170,9 @@ void AgentSession::cancel() {
void AgentSession::setTerminalData( const std::string& terminalId, UITerminal* uiTerm ) {
auto& termData = mTerminals[terminalId];
termData = { uiTerm->getTerm(), uiTerm->getTerm()->getTerminal(), uiTerm, "" };
termData.display = uiTerm->getTerm();
termData.emulator = uiTerm->getTerm()->getTerminal();
termData.uiTerm = uiTerm;
if ( termData.emulator ) {
termData.emulator->setDataCb( [this, terminalId]( const char* data, size_t size ) {
auto it = mTerminals.find( terminalId );
@@ -173,6 +181,23 @@ void AgentSession::setTerminalData( const std::string& terminalId, UITerminal* u
}
} );
}
if ( termData.display ) {
termData.eventCbId =
termData.display->pushEventCallback( [this, terminalId]( const auto& event ) {
if ( event.type == TerminalDisplay::EventType::PROCESS_EXIT ) {
auto it = mTerminals.find( terminalId );
if ( it != mTerminals.end() ) {
WaitForTerminalExitResponse res;
res.exitCode = it->second.emulator ? it->second.emulator->getExitCode() : 0;
auto callbacks = std::move( it->second.exitCallbacks );
it->second.exitCallbacks.clear();
for ( const auto& cb : callbacks ) {
cb( res );
}
}
}
} );
}
}
void AgentSession::setupClient() {
@@ -263,6 +288,8 @@ void AgentSession::setupClient() {
mClient->onReleaseTerminal = [this]( const ReleaseTerminalRequest& req, auto cb ) {
auto it = mTerminals.find( req.terminalId );
if ( it != mTerminals.end() ) {
if ( it->second.display && it->second.eventCbId )
it->second.display->popEventCallback( it->second.eventCbId );
if ( it->second.emulator )
it->second.emulator->terminate();
if ( it->second.uiTerm )
@@ -273,14 +300,18 @@ void AgentSession::setupClient() {
};
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() ) {
WaitForTerminalExitResponse res;
res.exitCode = it->second.emulator->getExitCode();
cb( res );
} else {
it->second.exitCallbacks.push_back( cb );
}
} else {
cb( WaitForTerminalExitResponse() );
}
cb( res );
};
}

View File

@@ -29,10 +29,9 @@ class AgentSession {
void prompt( const PromptRequest& req,
const std::function<void( const PromptResponse&,
const std::optional<ResponseError>& )>& cb );
void setConfigOption(
const SetConfigOptionRequest& req,
const std::function<void( const SetConfigOptionResponse&,
const std::optional<ResponseError>& )>& cb );
void setConfigOption( const SetConfigOptionRequest& req,
const std::function<void( const SetConfigOptionResponse&,
const std::optional<ResponseError>& )>& cb );
void cancel();
bool isPrompting() const { return mIsPrompting; }
@@ -64,6 +63,8 @@ class AgentSession {
std::shared_ptr<TerminalEmulator> emulator;
UITerminal* uiTerm{ nullptr };
std::string outputBuffer;
Uint32 eventCbId{ 0 };
std::vector<std::function<void( const WaitForTerminalExitResponse& )>> exitCallbacks;
};
std::unordered_map<std::string, TermData> mTerminals;

View File

@@ -634,6 +634,7 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) :
mChatScrollView->getVerticalScrollBar()->setValue( 1 );
mChatInput = findByClass<UICodeEditor>( "llm_chat_input" );
mChatInput->setId( String::format( "chat_input_%p", mChatInput ) );
on( Event::OnFocus, [this]( auto ) { mChatInput->setFocus(); } );
@@ -659,6 +660,18 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) :
mChatInput->setSyntaxDefinition( markdown );
mChatInput->on( Event::OnTextChanged, [this]( const Event* ) {
if ( !mIsAgentMode || mAvailableCommands.empty() )
return;
auto& doc = mChatInput->getDocument();
auto cursor = doc.getSelection().start();
auto lineText = doc.getLineTextUtf8( cursor.line() );
if ( ( cursor.column() == 0 || cursor.column() == 1 ) && !lineText.empty() &&
lineText == "/\n" ) {
showSlashCommands();
}
} );
mChatRun = find<UIPushButton>( "llm_run" );
mChatRun->onClick( [this]( auto ) {
execute( "ai-prompt" );
@@ -2081,6 +2094,31 @@ void LLMChatUI::sendAgentPrompt() {
} );
}
void LLMChatUI::showSlashCommands() {
if ( !getPlugin() || mAvailableCommands.empty() )
return;
std::vector<std::string> commands;
for ( const auto& cmd : mAvailableCommands ) {
commands.push_back( cmd.name + " - " + cmd.description );
}
getPlugin()->createListView(
mChatInput, ItemListOwnerModel<std::string>::create( std::move( commands ) ),
[this]( const ModelEvent* event ) {
if ( event->getModelEventType() == ModelEventType::Open ) {
auto row = event->getModelIndex().row();
if ( row >= 0 && row < (int)mAvailableCommands.size() ) {
auto& doc = mChatInput->getDocument();
auto cursor = doc.getSelection().start();
doc.setSelection( { cursor.line(), 0 }, cursor );
doc.textInput( "/" + mAvailableCommands[row].name + " " );
}
event->getNode()->close();
}
} );
}
void LLMChatUI::resizeToFit( UICodeEditor* editor ) {
Float visibleLineCount = editor->getDocumentView().getVisibleLinesCount();
Float lineHeight = editor->getLineHeight();

View File

@@ -235,6 +235,8 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter {
void resizeToFit( UICodeEditor* editor );
void showSlashCommands();
void addChat( LLMChat::Role role, std::string conversation );
void writeToLastChat( const std::string& text );

View File

@@ -125,44 +125,92 @@ bool Plugin::editorExists( UICodeEditor* editor ) {
return mManager->getSplitter() && mManager->getSplitter()->editorExists( editor );
}
UIListView* Plugin::createListViewHelper( UICodeEditor* editor, std::shared_ptr<Model> model,
const ModelEventCallback& onModelEventCb ) {
auto lvs = editor->findAllByClass( "editor_listview" );
for ( auto* ilv : lvs )
ilv->close();
std::string id = editor->getId(); // ID must be stable and unique
UIListView* lv = UIListView::New();
lv->setParent( editor );
lv->addClass( "editor_listview" );
auto pos =
editor->getRelativeScreenPosition( editor->getDocumentRef()->getSelection().start() );
lv->setPixelsPosition( { pos.x, pos.y + editor->getLineHeight() } );
if ( !lv->getParent()->getLocalBounds().contains(
lv->getLocalBounds().setPosition( lv->getPixelsPosition() ) ) ) {
lv->setPixelsPosition( { pos.x, pos.y - lv->getPixelsSize().getHeight() } );
}
lv->setVisible( true );
lv->getVerticalScrollBar()->reloadStyle( true, true, true );
lv->setAutoExpandOnSingleColumn( false );
lv->setModel( model );
Float height = std::min( lv->getContentSize().y, lv->getRowHeight() * 8 );
Float colWidth = lv->getMaxColumnContentWidth( 0 ) + PixelDensity::dpToPx( 4 );
bool needsVScroll = lv->getContentSize().y > lv->getRowHeight() * 8;
Float width = colWidth + lv->getPixelsPadding().getWidth() +
( needsVScroll ? lv->getVerticalScrollBar()->getPixelsSize().getWidth() : 0 );
lv->setPixelsSize( { width, height } );
lv->setColumnWidth( 0, colWidth );
lv->setScrollMode( needsVScroll ? ScrollBarMode::Auto : ScrollBarMode::AlwaysOff,
ScrollBarMode::AlwaysOff );
lv->on( Event::OnModelEvent, [onModelEventCb]( const Event* event ) {
const ModelEvent* modelEvent = static_cast<const ModelEvent*>( event );
if ( onModelEventCb )
onModelEventCb( modelEvent );
} );
lv->setSelection( model->index( 0 ) );
lv->setFocus();
return lv;
}
void Plugin::createListView( UICodeEditor* editor, std::shared_ptr<Model> model,
const ModelEventCallback& onModelEventCb,
const std::function<void( UIListView* )> onCreateCb ) {
UICodeEditorSplitter* splitter = getManager()->getSplitter();
if ( nullptr == splitter || !editorExists( editor ) )
return;
editor->runOnMainThread( [model, editor, splitter, onModelEventCb, onCreateCb] {
auto lvs = editor->findAllByClass( "editor_listview" );
for ( auto* ilv : lvs )
ilv->close();
UIListView* lv = UIListView::New();
lv->setParent( editor );
lv->addClass( "editor_listview" );
auto pos =
editor->getRelativeScreenPosition( editor->getDocumentRef()->getSelection().start() );
lv->setPixelsPosition( { pos.x, pos.y + editor->getLineHeight() } );
if ( !lv->getParent()->getLocalBounds().contains(
lv->getLocalBounds().setPosition( lv->getPixelsPosition() ) ) ) {
lv->setPixelsPosition( { pos.x, pos.y - lv->getPixelsSize().getHeight() } );
}
lv->setVisible( true );
lv->getVerticalScrollBar()->reloadStyle( true, true, true );
lv->setAutoExpandOnSingleColumn( false );
lv->setModel( model );
Float height = std::min( lv->getContentSize().y, lv->getRowHeight() * 8 );
Float colWidth = lv->getMaxColumnContentWidth( 0 ) + PixelDensity::dpToPx( 4 );
bool needsVScroll = lv->getContentSize().y > lv->getRowHeight() * 8;
Float width = colWidth + lv->getPixelsPadding().getWidth() +
( needsVScroll ? lv->getVerticalScrollBar()->getPixelsSize().getWidth() : 0 );
lv->setPixelsSize( { width, height } );
lv->setColumnWidth( 0, colWidth );
lv->setScrollMode( needsVScroll ? ScrollBarMode::Auto : ScrollBarMode::AlwaysOff,
ScrollBarMode::AlwaysOff );
if ( nullptr == splitter || !editorExists( editor ) ) {
if ( editor->getId().empty() )
return;
UISceneNode* uiSceneNode = getUISceneNode();
editor->runOnMainThread( [model, editor, onModelEventCb, onCreateCb, uiSceneNode] {
UIListView* lv = createListViewHelper( editor, model, onModelEventCb );
std::string id = editor->getId();
if ( onCreateCb )
onCreateCb( lv );
Uint32 focusCb = lv->getUISceneNode()->getUIEventDispatcher()->addFocusEventCallback(
[lv]( const auto&, Node* focus, Node* ) {
if ( !lv->inParentTreeOf( focus ) && !lv->isClosing() )
lv->close();
} );
Uint32 cursorCb = editor->on( Event::OnCursorPosChange,
[lv, editor, id, uiSceneNode]( const Event* ) {
if ( !lv->isClosing() ) {
lv->close();
if ( uiSceneNode->find( id ) )
editor->setFocus();
}
} );
lv->on( Event::KeyDown, [lv, uiSceneNode, editor, id]( const Event* event ) {
if ( event->asKeyEvent()->getKeyCode() == EE::Window::KEY_ESCAPE &&
!lv->isClosing() )
lv->close();
if ( uiSceneNode->find( id ) )
editor->setFocus();
} );
lv->on( Event::OnClose, [lv, editor, cursorCb, focusCb]( const Event* ) {
lv->getUISceneNode()->getUIEventDispatcher()->removeFocusEventCallback( focusCb );
editor->removeEventListener( cursorCb );
} );
} );
return;
}
editor->runOnMainThread( [model, editor, splitter, onModelEventCb, onCreateCb] {
UIListView* lv = createListViewHelper( editor, model, onModelEventCb );
if ( onCreateCb )
onCreateCb( lv );
lv->setSelection( model->index( 0 ) );
lv->setFocus();
Uint32 focusCb = lv->getUISceneNode()->getUIEventDispatcher()->addFocusEventCallback(
[lv]( const auto&, Node* focus, Node* ) {
if ( !lv->inParentTreeOf( focus ) && !lv->isClosing() )
@@ -182,11 +230,6 @@ void Plugin::createListView( UICodeEditor* editor, std::shared_ptr<Model> model,
if ( splitter->editorExists( editor ) )
editor->setFocus();
} );
lv->on( Event::OnModelEvent, [onModelEventCb]( const Event* event ) {
const ModelEvent* modelEvent = static_cast<const ModelEvent*>( event );
if ( onModelEventCb )
onModelEventCb( modelEvent );
} );
lv->on( Event::OnClose, [lv, editor, cursorCb, focusCb]( const Event* ) {
lv->getUISceneNode()->getUIEventDispatcher()->removeFocusEventCallback( focusCb );
editor->removeEventListener( cursorCb );

View File

@@ -74,6 +74,12 @@ class Plugin : public UICodeEditorPlugin {
virtual void onLoadProject( const std::string& /*projectFolder*/,
const std::string& /*projectStatePath*/ ) {}
typedef std::function<void( const ModelEvent* )> ModelEventCallback;
void createListView( UICodeEditor* editor, std::shared_ptr<Model> model,
const ModelEventCallback& onModelEventCb,
const std::function<void( UIListView* )> onCreateCb = {} );
protected:
PluginManager* mManager{ nullptr };
std::shared_ptr<ThreadPool> mThreadPool;
@@ -88,14 +94,10 @@ class Plugin : public UICodeEditorPlugin {
void waitUntilLoaded();
typedef std::function<void( const ModelEvent* )> ModelEventCallback;
bool editorExists( UICodeEditor* editor );
void createListView( UICodeEditor* editor, std::shared_ptr<Model> model,
const ModelEventCallback& onModelEventCb,
const std::function<void( UIListView* )> onCreateCb = {} );
static UIListView* createListViewHelper( UICodeEditor* editor, std::shared_ptr<Model> model,
const ModelEventCallback& onModelEventCb );
};
class PluginBase : public Plugin {