mirror of
https://github.com/SpartanJ/eepp.git
synced 2026-05-28 17:16:29 +03:00
Merge branch 'feature/acp' into develop
This commit is contained in:
@@ -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
|
||||
- [ ] Update `src/tools/ecode/plugins/aiassistant/chatui.cpp`:
|
||||
- Fix `plan` update handling to use the standard `entries` schema.
|
||||
- Implement `tool_call_update` handling.
|
||||
- Update tool call UI to reflect status (pending, in_progress, completed, failed, cancelled).
|
||||
- [ ] Implement embedded terminals in tool calls:
|
||||
- When a `tool_call_update` contains a `terminalId`, link the terminal UI to that tool call bubble if possible.
|
||||
|
||||
### 4. Phase 3: UX Improvements
|
||||
- [ ] Expose slash commands:
|
||||
- Show `mAvailableCommands` in the UI.
|
||||
- Add simple autocompletion for `/` commands in `mChatInput`.
|
||||
- [ ] Enhance terminal management:
|
||||
- Ensure `terminal/wait_for_exit` works correctly (async).
|
||||
|
||||
### 5. Phase 4: Validation
|
||||
- [ ] Test with a compatible ACP agent.
|
||||
- [ ] Verify file system tools.
|
||||
- [ ] Verify terminal creation and output reading.
|
||||
- [ ] Verify plan updates.
|
||||
- [ ] Verify tool call permissions.
|
||||
151
projects/scripts/acp_mock_agent.py
Normal file
151
projects/scripts/acp_mock_agent.py
Normal file
@@ -0,0 +1,151 @@
|
||||
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()
|
||||
|
||||
# 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", {
|
||||
"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()
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ) };
|
||||
}
|
||||
|
||||
@@ -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() ) );
|
||||
}
|
||||
|
||||
|
||||
@@ -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 );
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
]]>
|
||||
</style>
|
||||
<Splitter lw="mp" lh="mp" orientation="vertical" splitter-partition="75%" padding="4dp">
|
||||
@@ -630,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(); } );
|
||||
|
||||
@@ -655,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" );
|
||||
@@ -1828,35 +1845,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 +1902,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() ) {
|
||||
@@ -2089,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();
|
||||
@@ -2646,6 +2676,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 +2730,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<UIWidget>();
|
||||
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<std::string>() : rawInput.dump( 2 ) ) +
|
||||
"\n```\n";
|
||||
}
|
||||
if ( msg.contains( "rawOutput" ) ) {
|
||||
auto rawOutput = msg["rawOutput"];
|
||||
toolMarkdown +=
|
||||
"\n```json\n" +
|
||||
( rawOutput.is_string() ? rawOutput.get<std::string>() : rawOutput.dump( 2 ) ) +
|
||||
"\n```\n";
|
||||
}
|
||||
|
||||
if ( !bubble ) {
|
||||
bubble = addMarkdownBubble( DEFAULT_TOOL_CALL_GLOBE, toolMarkdown );
|
||||
if ( !toolCallId.empty() )
|
||||
mToolCallBubbles[toolCallId] = bubble;
|
||||
} else {
|
||||
bubble->asType<UIMarkdownView>()->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::UI::UITerminal>( "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();
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <eepp/ui/uilinearlayout.hpp>
|
||||
#include <eepp/ui/widgetcommandexecuter.hpp>
|
||||
|
||||
#include <eepp/core/containers.hpp>
|
||||
#include <nlohmann/json_fwd.hpp>
|
||||
|
||||
namespace EE { namespace UI {
|
||||
@@ -148,6 +149,8 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter {
|
||||
};
|
||||
std::vector<SlashCommand> mAvailableCommands;
|
||||
|
||||
UnorderedMap<std::string, UIWidget*> mToolCallBubbles;
|
||||
UnorderedMap<std::string, UIWidget*> mTerminalBubbles;
|
||||
std::unique_ptr<acp::AgentSession> 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 );
|
||||
@@ -228,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 );
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user