From 9c966ac427a22c19de96c7d58c704432247edf51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Mon, 23 Mar 2026 13:22:17 -0300 Subject: [PATCH 1/2] ACP Improvements phase 2 ready. --- .agent/plans/acp-improvement.md | 4 +- projects/scripts/acp_mock_agent.py | 138 ++++++++++++++++ src/eepp/ui/uitextview.cpp | 2 +- .../ecode/plugins/aiassistant/chatui.cpp | 150 +++++++++++++++--- .../ecode/plugins/aiassistant/chatui.hpp | 7 + 5 files changed, 274 insertions(+), 27 deletions(-) create mode 100644 projects/scripts/acp_mock_agent.py diff --git a/.agent/plans/acp-improvement.md b/.agent/plans/acp-improvement.md index ef606d089..880726cbd 100644 --- a/.agent/plans/acp-improvement.md +++ b/.agent/plans/acp-improvement.md @@ -32,11 +32,11 @@ We need a way to capture the output from the terminal process to satisfy `termin - Respect `outputByteLimit`. ### 3. Phase 2: UI & Protocol Updates -- [ ] Update `src/tools/ecode/plugins/aiassistant/chatui.cpp`: +- [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). -- [ ] Implement embedded terminals in tool calls: +- [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 diff --git a/projects/scripts/acp_mock_agent.py b/projects/scripts/acp_mock_agent.py new file mode 100644 index 000000000..fffa1b3a3 --- /dev/null +++ b/projects/scripts/acp_mock_agent.py @@ -0,0 +1,138 @@ +import sys +import json +import time + +def send_notification(method, params): + msg = {"jsonrpc": "2.0", "method": method, "params": params} + sys.stdout.write(json.dumps(msg) + "\n") + sys.stdout.flush() + +def send_request(msg_id, method, params): + msg = {"jsonrpc": "2.0", "id": msg_id, "method": method, "params": params} + sys.stdout.write(json.dumps(msg) + "\n") + sys.stdout.flush() + +def main(): + last_req_id = 1000 + + for line in sys.stdin: + try: + req = json.loads(line) + method = req.get("method") + msg_id = req.get("id") + + if not method and "result" in req: + # This is a response to one of our requests (like terminal/create) + result = req.get("result") + if "terminalId" in result: + term_id = result["terminalId"] + # Now link this terminal to our tool call + send_notification("session/update", { + "sessionId": "test-session", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "build-1", + "status": "running", + "terminalId": term_id + } + }) + # Wait 2 seconds then complete it + time.sleep(2) + send_notification("session/update", { + "sessionId": "test-session", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "build-1", + "status": "completed", + "rawOutput": "Build successful: 0 errors, 0 warnings" + } + }) + # Final message + send_notification("session/update", { + "sessionId": "test-session", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": {"text": "\n\nAll tasks finished successfully!"} + } + }) + continue + + if method == "initialize": + sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": msg_id, "result": { + "protocolVersion": 1, + "agentCapabilities": {"loadSession": True} + }}) + "\n") + sys.stdout.flush() + + elif method == "session/new": + sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": msg_id, "result": { + "sessionId": "test-session", "configOptions": [] + }}) + "\n") + sys.stdout.flush() + + elif method == "session/prompt": + # 1. Send Plan + send_notification("session/update", { + "sessionId": "test-session", + "update": { + "sessionUpdate": "plan", + "plan": { + "entries": [ + {"title": "Analyzing project structure", "status": "completed"}, + {"title": "Running build tests", "status": "running"}, + {"title": "Deploying changes", "status": "pending"} + ] + } + } + }) + + # 2. Tool Call 1 (Synchronous style) + send_notification("session/update", { + "sessionId": "test-session", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "search-1", + "title": "Searching for UI components", + "status": "completed", + "rawInput": {"pattern": "UIButton", "dir": "src/"}, + "rawOutput": ["src/ui/button.cpp", "src/ui/button.hpp"] + } + }) + + # 3. Thought + send_notification("session/update", { + "sessionId": "test-session", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": {"text": "I found the buttons. Now I will try to build the project."} + } + }) + + # 4. Tool Call 2 (Terminal style) + send_notification("session/update", { + "sessionId": "test-session", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "build-1", + "title": "Compiling project", + "status": "running" + } + }) + + # 5. Request Terminal for Tool Call 2 + last_req_id += 1 + send_request(last_req_id, "terminal/create", { + "sessionId": "test-session", + "command": "make", + "args": ["-j4"] + }) + + # Confirm prompt request + sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": msg_id, "result": {"stopReason": "complete"}}) + "\n") + sys.stdout.flush() + + except Exception as e: + sys.stderr.write(f"Error: {str(e)}\n") + +if __name__ == "__main__": + main() diff --git a/src/eepp/ui/uitextview.cpp b/src/eepp/ui/uitextview.cpp index 841a9a050..6e9f281c3 100644 --- a/src/eepp/ui/uitextview.cpp +++ b/src/eepp/ui/uitextview.cpp @@ -886,7 +886,7 @@ void UITextView::loadFromXmlNode( const pugi::xml_node& node ) { UIWidget::loadFromXmlNode( node ); - if ( !node.text().empty() ) { + if ( node.first_child().empty() && !node.text().empty() ) { setText( getTranslatorString( node.text().as_string() ) ); } diff --git a/src/tools/ecode/plugins/aiassistant/chatui.cpp b/src/tools/ecode/plugins/aiassistant/chatui.cpp index 498c61d72..e95a03652 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.cpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.cpp @@ -404,7 +404,8 @@ static const char* DEFAULT_LAYOUT = R"xml( .llm_chat_input { border-radius: 8dp 0dp 8dp 8dp; } -.llm_thought { +.llm_thought, +.llm_plan { background-color: var(--list-back); padding: 4dp 4dp 4dp 8dp; border-left: 2dp solid var(--tab-line); @@ -510,6 +511,9 @@ DropDownList.role_ui { .agent_config_dropdown { margin-bottom: 8dp; } +.llm_chats Terminal { + margin-top: 8dp; +} ]]> @@ -1828,35 +1832,19 @@ void LLMChatUI::setupAgentSession() { 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 ); + if ( !chunk.empty() ) + 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 ); } ); + if ( !chunk.empty() ) + runOnMainThread( [this, chunk] { updateThinkingBubble( chunk ); } ); } - } else if ( sessionUpdate == "tool_call" ) { - std::string toolMarkdown = - "> 🛠️ " + i18n( "tool_call", "Tool Call: " ) + msg.value( "title", "" ) + "\n"; - addToolCallBubble( toolMarkdown ); + } else if ( sessionUpdate == "tool_call" || sessionUpdate == "tool_call_update" ) { + addToolCallUpdate( msg ); } else if ( sessionUpdate == "plan" ) { - 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"] ) { - 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"; - } - } - addPlanBubble( planMarkdown ); + addPlanUpdate( msg ); } else if ( sessionUpdate == "available_commands_update" ) { if ( msg.contains( "availableCommands" ) && msg["availableCommands"].is_array() ) { runOnMainThread( [this, msg] { @@ -1901,15 +1889,19 @@ void LLMChatUI::setupAgentSession() { Sizef( 0, 0 ), req.command, req.args, env, req.cwd ? *req.cwd : getPlugin()->getPluginContext()->getCurrentProject(), 10000, nullptr, false, false ); + uiTerm->setClass( "eterm" ); uiTerm->setParent( bubble ); uiTerm->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent ); + mTerminalBubbles[termId] = bubble; mAgentSession->setTerminalData( termId, uiTerm ); } ); }; } void LLMChatUI::doAgentRequest() { + mToolCallBubbles.clear(); + mTerminalBubbles.clear(); if ( !mAgentSession ) { auto it = mAgents.find( mCurAgent ); if ( it == mAgents.end() ) { @@ -2646,6 +2638,37 @@ void LLMChatUI::addPlanBubble( const std::string& markdown ) { } ); } +void LLMChatUI::addPlanUpdate( const nlohmann::json& msg ) { + runOnMainThread( [this, msg] { + removeWaitingBubble(); + std::string planMarkdown = "📋 " + i18n( "plan_updated", "Plan Updated:" ) + "\n"; + if ( msg.contains( "plan" ) ) { + const auto& plan = msg["plan"]; + const auto& entries = + plan.contains( "entries" ) + ? plan["entries"] + : ( plan.contains( "steps" ) ? plan["steps"] : nlohmann::json::array() ); + if ( entries.is_array() ) { + for ( const auto& step : entries ) { + std::string status = step.value( "status", "" ); + std::string statusIcon = "⏳"; + if ( status == "completed" || status == "success" ) + statusIcon = "✅"; + else if ( status == "running" || status == "in_progress" ) + statusIcon = "🔄"; + else if ( status == "failed" || status == "error" ) + statusIcon = "❌"; + else if ( status == "cancelled" ) + statusIcon = "🚫"; + + planMarkdown += "- " + statusIcon + " " + step.value( "title", "" ) + "\n"; + } + } + } + addPlanBubble( planMarkdown ); + } ); +} + void LLMChatUI::addToolCallBubble( const std::string& markdown ) { runOnMainThread( [this, markdown] { removeWaitingBubble(); @@ -2669,6 +2692,85 @@ void LLMChatUI::addToolCallBubble( const std::string& markdown ) { } ); } +void LLMChatUI::addToolCallUpdate( const nlohmann::json& msg ) { + runOnMainThread( [this, msg] { + removeWaitingBubble(); + std::string toolCallId = msg.value( "toolCallId", "" ); + std::string terminalId = msg.value( "terminalId", "" ); + std::string status = msg.value( "status", "" ); + std::string title = msg.value( "title", "" ); + + UIWidget* bubble = nullptr; + if ( !toolCallId.empty() ) { + auto it = mToolCallBubbles.find( toolCallId ); + if ( it != mToolCallBubbles.end() ) + bubble = it->second; + } + + if ( !bubble ) { + if ( mChatsList->getLastChild() ) { + auto* lastChild = mChatsList->getLastChild()->asType(); + if ( lastChild->hasClass( "llm_tool_call" ) && toolCallId.empty() ) + bubble = lastChild; + } + } + + std::string statusIcon = "⏳"; + if ( status == "completed" || status == "success" ) + statusIcon = "✅"; + else if ( status == "running" || status == "in_progress" ) + statusIcon = "🔄"; + else if ( status == "failed" || status == "error" ) + statusIcon = "❌"; + else if ( status == "cancelled" ) + statusIcon = "🚫"; + + std::string toolMarkdown = "> " + statusIcon + " 🛠️ " + i18n( "tool_call", "Tool Call: " ) + + ( title.empty() ? toolCallId : title ) + "\n\n"; + + if ( msg.contains( "rawInput" ) ) { + auto rawInput = msg["rawInput"]; + toolMarkdown += + "\n```json\n" + + ( rawInput.is_string() ? rawInput.get() : rawInput.dump( 2 ) ) + + "\n```\n"; + } + if ( msg.contains( "rawOutput" ) ) { + auto rawOutput = msg["rawOutput"]; + toolMarkdown += + "\n```json\n" + + ( rawOutput.is_string() ? rawOutput.get() : rawOutput.dump( 2 ) ) + + "\n```\n"; + } + + if ( !bubble ) { + bubble = addMarkdownBubble( DEFAULT_TOOL_CALL_GLOBE, toolMarkdown ); + if ( !toolCallId.empty() ) + mToolCallBubbles[toolCallId] = bubble; + } else { + bubble->asType()->loadFromString( toolMarkdown ); + } + + if ( !terminalId.empty() ) { + auto it = mTerminalBubbles.find( terminalId ); + if ( it != mTerminalBubbles.end() ) { + UIWidget* terminalBubble = it->second; + eterm::UI::UITerminal* uiTerm = + terminalBubble->findByClass( "eterm" ); + if ( uiTerm ) { + uiTerm->setParent( bubble ); + uiTerm->setLayoutHeightPolicy( SizePolicy::Fixed ); + uiTerm->setPixelsSize( uiTerm->getPixelsSize().getWidth(), + PixelDensity::dpToPx( 300 ) ); + terminalBubble->close(); + mTerminalBubbles.erase( it ); + } + } + } + mChatScrollView->getVerticalScrollBar()->setValue( 1.0f ); + } ); +} + void LLMChatUI::updateThinkingBubble( const std::string& chunk ) { runOnMainThread( [this, chunk] { removeWaitingBubble(); diff --git a/src/tools/ecode/plugins/aiassistant/chatui.hpp b/src/tools/ecode/plugins/aiassistant/chatui.hpp index bcc849501..fc233b61e 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.hpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.hpp @@ -8,6 +8,7 @@ #include #include +#include #include namespace EE { namespace UI { @@ -148,6 +149,8 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { }; std::vector mAvailableCommands; + UnorderedMap mToolCallBubbles; + UnorderedMap mTerminalBubbles; std::unique_ptr mAgentSession; UIWidget* mThinkingBubble{ nullptr }; std::string mCurThinking; @@ -193,8 +196,12 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { void addPlanBubble( const std::string& markdown ); + void addPlanUpdate( const nlohmann::json& msg ); + void addToolCallBubble( const std::string& markdown ); + void addToolCallUpdate( const nlohmann::json& msg ); + void addThinkingBubble(); void updateThinkingBubble( const std::string& chunk ); From 5429b15e04468b2802e3db4c62bd35680eb92c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Mon, 23 Mar 2026 14:25:44 -0300 Subject: [PATCH 2/2] More ACP client WIP, finishing the protocol implementation. --- .agent/plans/acp-improvement.md | 54 -------- projects/scripts/acp_mock_agent.py | 13 ++ src/eepp/ui/uicodeeditor.cpp | 2 +- src/eepp/ui/uiscenenode.cpp | 2 +- .../plugins/aiassistant/acp/agentsession.cpp | 51 ++++++-- .../plugins/aiassistant/acp/agentsession.hpp | 9 +- .../ecode/plugins/aiassistant/chatui.cpp | 38 ++++++ .../ecode/plugins/aiassistant/chatui.hpp | 2 + src/tools/ecode/plugins/plugin.cpp | 115 ++++++++++++------ src/tools/ecode/plugins/plugin.hpp | 14 ++- 10 files changed, 188 insertions(+), 112 deletions(-) delete mode 100644 .agent/plans/acp-improvement.md diff --git a/.agent/plans/acp-improvement.md b/.agent/plans/acp-improvement.md deleted file mode 100644 index 880726cbd..000000000 --- a/.agent/plans/acp-improvement.md +++ /dev/null @@ -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;` - - 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. diff --git a/projects/scripts/acp_mock_agent.py b/projects/scripts/acp_mock_agent.py index fffa1b3a3..9fe0e5c4d 100644 --- a/projects/scripts/acp_mock_agent.py +++ b/projects/scripts/acp_mock_agent.py @@ -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", { diff --git a/src/eepp/ui/uicodeeditor.cpp b/src/eepp/ui/uicodeeditor.cpp index 2053e278c..77d98662e 100644 --- a/src/eepp/ui/uicodeeditor.cpp +++ b/src/eepp/ui/uicodeeditor.cpp @@ -2006,7 +2006,7 @@ Vector2f UICodeEditor::getRelativeScreenPosition( const TextPosition& pos ) { Vector2f startScroll( start - mScroll ); auto offset = getTextPositionOffset( pos ); return { static_cast( startScroll.x + offset.x ), - static_cast( startScroll.y + offset.y + getLineOffset() ) }; + static_cast( startScroll.y + offset.y + mPaddingPx.Top + getLineOffset() ) }; } bool UICodeEditor::getShowLinesRelativePosition() const { diff --git a/src/eepp/ui/uiscenenode.cpp b/src/eepp/ui/uiscenenode.cpp index dce59e127..bd16aadff 100644 --- a/src/eepp/ui/uiscenenode.cpp +++ b/src/eepp/ui/uiscenenode.cpp @@ -445,7 +445,7 @@ void UISceneNode::setThreadPool( const std::shared_ptr& 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 ) }; } diff --git a/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp b/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp index e6125dfee..5038f5cab 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp +++ b/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp @@ -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& err ) { - if ( err && onError ) - onError( *err ); - if ( cb ) - cb( res.sessions, err ); - } ); + mClient->listSessions( req, [this, cb]( const ListSessionsResponse& res, + const std::optional& 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 ); }; } diff --git a/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp b/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp index 01140ee2f..d87a13131 100644 --- a/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp +++ b/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp @@ -29,10 +29,9 @@ class AgentSession { void prompt( const PromptRequest& req, const std::function& )>& cb ); - void setConfigOption( - const SetConfigOptionRequest& req, - const std::function& )>& cb ); + void setConfigOption( const SetConfigOptionRequest& req, + const std::function& )>& cb ); void cancel(); bool isPrompting() const { return mIsPrompting; } @@ -64,6 +63,8 @@ class AgentSession { std::shared_ptr emulator; UITerminal* uiTerm{ nullptr }; std::string outputBuffer; + Uint32 eventCbId{ 0 }; + std::vector> exitCallbacks; }; std::unordered_map mTerminals; diff --git a/src/tools/ecode/plugins/aiassistant/chatui.cpp b/src/tools/ecode/plugins/aiassistant/chatui.cpp index e95a03652..e52f25703 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.cpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.cpp @@ -634,6 +634,7 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : mChatScrollView->getVerticalScrollBar()->setValue( 1 ); mChatInput = findByClass( "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( "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 commands; + for ( const auto& cmd : mAvailableCommands ) { + commands.push_back( cmd.name + " - " + cmd.description ); + } + + getPlugin()->createListView( + mChatInput, ItemListOwnerModel::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(); diff --git a/src/tools/ecode/plugins/aiassistant/chatui.hpp b/src/tools/ecode/plugins/aiassistant/chatui.hpp index fc233b61e..0e7730177 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.hpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.hpp @@ -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 ); diff --git a/src/tools/ecode/plugins/plugin.cpp b/src/tools/ecode/plugins/plugin.cpp index 5f4055392..75b5c73f2 100644 --- a/src/tools/ecode/plugins/plugin.cpp +++ b/src/tools/ecode/plugins/plugin.cpp @@ -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, + 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( event ); + if ( onModelEventCb ) + onModelEventCb( modelEvent ); + } ); + lv->setSelection( model->index( 0 ) ); + lv->setFocus(); + return lv; +} + void Plugin::createListView( UICodeEditor* editor, std::shared_ptr model, const ModelEventCallback& onModelEventCb, const std::function 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, if ( splitter->editorExists( editor ) ) editor->setFocus(); } ); - lv->on( Event::OnModelEvent, [onModelEventCb]( const Event* event ) { - const ModelEvent* modelEvent = static_cast( event ); - if ( onModelEventCb ) - onModelEventCb( modelEvent ); - } ); lv->on( Event::OnClose, [lv, editor, cursorCb, focusCb]( const Event* ) { lv->getUISceneNode()->getUIEventDispatcher()->removeFocusEventCallback( focusCb ); editor->removeEventListener( cursorCb ); diff --git a/src/tools/ecode/plugins/plugin.hpp b/src/tools/ecode/plugins/plugin.hpp index c3f5f5a61..24a45b371 100644 --- a/src/tools/ecode/plugins/plugin.hpp +++ b/src/tools/ecode/plugins/plugin.hpp @@ -74,6 +74,12 @@ class Plugin : public UICodeEditorPlugin { virtual void onLoadProject( const std::string& /*projectFolder*/, const std::string& /*projectStatePath*/ ) {} + typedef std::function ModelEventCallback; + + void createListView( UICodeEditor* editor, std::shared_ptr model, + const ModelEventCallback& onModelEventCb, + const std::function onCreateCb = {} ); + protected: PluginManager* mManager{ nullptr }; std::shared_ptr mThreadPool; @@ -88,14 +94,10 @@ class Plugin : public UICodeEditorPlugin { void waitUntilLoaded(); - typedef std::function ModelEventCallback; - bool editorExists( UICodeEditor* editor ); - void createListView( UICodeEditor* editor, std::shared_ptr model, - const ModelEventCallback& onModelEventCb, - const std::function onCreateCb = {} ); - + static UIListView* createListViewHelper( UICodeEditor* editor, std::shared_ptr model, + const ModelEventCallback& onModelEventCb ); }; class PluginBase : public Plugin {