diff --git a/.agent/SOUL.md b/.agent/SOUL.md new file mode 100644 index 000000000..bbaa58d9f --- /dev/null +++ b/.agent/SOUL.md @@ -0,0 +1,21 @@ +# Agent Identity: Negen + +**Role & Personality:** +Your name is Negen (from negentropy: the process of creating order out of chaos). You are an elite, performance-obsessed C++ coding agent embedded within the `eepp` repository and `ecode` ecosystem. Your tone is sharp, analytical, and highly efficient. You treat system resources as sacred and write code that is clean, fast, and secure. + +**Core Directives:** + +1. **Performance & Memory Management:** + - Performance is the absolute key in `eepp`. + - Favor stack-allocated memory over heap allocations whenever possible. + - Any heap allocation must be heavily justified. + - Exercise reason: maximize stack use for speed, but actively calculate boundaries to prevent stack-overflows. + +2. **Protect the Render Loop:** + - Render time is critical. + - You must be intensely mindful of the performance impact of any code executing within the main loop or main thread. Avoid blocking operations or expensive computations during the render cycle at all costs. + +3. **Self-Review & DRY Principle:** + - Immediately after implementing a feature, you must perform a strict self-review of your changes. + - Actively hunt for mistakes and inefficiencies. + - Eradicate code duplication. Whenever common logic is detected, encapsulate it into a distinct, reusable function or method. diff --git a/.agent/plans/acp-improvement.md b/.agent/plans/acp-improvement.md new file mode 100644 index 000000000..ef606d089 --- /dev/null +++ b/.agent/plans/acp-improvement.md @@ -0,0 +1,54 @@ +# Plan: ACP Support Improvement for ecode + +This plan outlines the steps to complete and improve the Agent Client Protocol (ACP) implementation in `ecode`. + +## Current Status & Gaps +The current implementation has the basic structure but misses several critical features for a full ACP experience: +- **Terminal output** is not captured (returns empty). +- **Tool call updates** are not handled in the UI. +- **Plan schema** is non-standard. +- **Slash commands** are not exposed to the user. +- **Terminal limits** are ignored. + +## Proposed Steps + +### 1. Research & Refinement (Done) +- [x] Analyze ACP documentation. +- [x] Review current `ecode` implementation. +- [x] Identify missing pieces. + +### 2. Phase 1: Terminal Output Capture (Done) +We need a way to capture the output from the terminal process to satisfy `terminal/output` requests. +- [x] Modify `src/modules/eterm/include/eterm/terminal/terminalemulator.hpp`: + - Add `using DataCb = std::function;` + - Add `void setDataCb(DataCb cb);` +- [x] Modify `src/modules/eterm/src/eterm/terminal/terminalemulator.cpp`: + - Call `mDataCb` in `ttyread()` when new data is received. +- [x] Update `src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp`: + - Add an output buffer to `TermData`. +- [x] Update `src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp`: + - Set the `DataCb` in `onTerminalCreated`. + - Implement `onTerminalOutput` to return and clear the buffer. + - Respect `outputByteLimit`. + +### 3. Phase 2: UI & Protocol Updates +- [ ] Update `src/tools/ecode/plugins/aiassistant/chatui.cpp`: + - Fix `plan` update handling to use the standard `entries` schema. + - Implement `tool_call_update` handling. + - Update tool call UI to reflect status (pending, in_progress, completed, failed, cancelled). +- [ ] Implement embedded terminals in tool calls: + - When a `tool_call_update` contains a `terminalId`, link the terminal UI to that tool call bubble if possible. + +### 4. Phase 3: UX Improvements +- [ ] Expose slash commands: + - Show `mAvailableCommands` in the UI. + - Add simple autocompletion for `/` commands in `mChatInput`. +- [ ] Enhance terminal management: + - Ensure `terminal/wait_for_exit` works correctly (async). + +### 5. Phase 4: Validation +- [ ] Test with a compatible ACP agent. +- [ ] Verify file system tools. +- [ ] Verify terminal creation and output reading. +- [ ] Verify plan updates. +- [ ] Verify tool call permissions. diff --git a/bin/assets/fonts/codicon.ttf b/bin/assets/fonts/codicon.ttf index 0989abf8e..fe0b1d15e 100644 Binary files a/bin/assets/fonts/codicon.ttf and b/bin/assets/fonts/codicon.ttf differ diff --git a/bin/assets/plugins/aiassistant.json b/bin/assets/plugins/aiassistant.json index 882de7e68..df967cb10 100644 --- a/bin/assets/plugins/aiassistant.json +++ b/bin/assets/plugins/aiassistant.json @@ -500,5 +500,31 @@ "fetch_models_url": "http://localhost:8080/api/tags", "open_api": true } + }, + "agents": { + "gemini-cli": { + "enabled": true, + "command": "gemini", + "args": ["--experimental-acp", "--model=auto"] + }, + "opencode": { + "enabled": true, + "command": "opencode", + "args": ["acp"] + }, + "claude-agent": { + "enabled": true, + "command": "claude-agent-acp" + }, + "cursor": { + "enabled": true, + "command": "cursor", + "args": ["acp"] + }, + "pi-acp": { + "enabled": true, + "command": "pi-acp", + "args": [] + } } } diff --git a/include/eepp/graphics/fonttruetype.hpp b/include/eepp/graphics/fonttruetype.hpp index 6db1b9807..d75dd6d8d 100644 --- a/include/eepp/graphics/fonttruetype.hpp +++ b/include/eepp/graphics/fonttruetype.hpp @@ -67,7 +67,8 @@ class EE_API FontTrueType : public Font { bool loaded() const; - FontTrueType& operator=( const FontTrueType& right ); + FontTrueType( const FontTrueType& ) = delete; + FontTrueType& operator=( const FontTrueType& ) = delete; bool getBoldAdvanceSameAsRegular() const; @@ -182,11 +183,11 @@ class EE_API FontTrueType : public Font { GlyphTable glyphs; ///< Table mapping code points to their corresponding glyph GlyphDrawableTable - drawables; ///> Table mapping code points to their corresponding glyph drawables. - Texture* texture; ///< Texture containing the pixels of the glyphs - std::vector rows; ///< List containing the position of all the existing rows - Uint32 fontInternalId{ 0 }; // The font internal id - unsigned int nextRow; ///< Y position of the next new row in the texture + drawables; ///> Table mapping code points to their corresponding glyph drawables. + Texture* texture{ nullptr }; ///< Texture containing the pixels of the glyphs + std::vector rows; ///< List containing the position of all the existing rows + Uint32 fontInternalId{ 0 }; // The font internal id + unsigned int nextRow; ///< Y position of the next new row in the texture const FontTrueType* font{ nullptr }; }; @@ -212,14 +213,14 @@ class EE_API FontTrueType : public Font { typedef UnorderedMap> PageTable; ///< Table mapping a character size to its page (texture) - void* mLibrary; ///< Pointer to the internal library interface (it is typeless to avoid exposing - ///< implementation details) - void* mFace; ///< Pointer to the internal font face (it is typeless to avoid exposing - ///< implementation details) - void* mStreamRec; ///< Pointer to the stream rec instance (it is typeless to avoid exposing - ///< implementation details) - void* mStroker; ///< Pointer to the stroker (it is typeless to avoid exposing implementation - ///< details) + void* mLibrary{ nullptr }; ///< Pointer to the internal library interface (it is typeless to + ///< avoid exposing implementation details) + void* mFace{ nullptr }; ///< Pointer to the internal font face (it is typeless to avoid exposing + ///< implementation details) + void* mStreamRec{ nullptr }; ///< Pointer to the stream rec instance (it is typeless to avoid + ///< exposing implementation details) + void* mStroker{ nullptr }; ///< Pointer to the stroker (it is typeless to avoid exposing + ///< implementation details) void* mHBFont{ nullptr }; mutable ScopedBuffer mMemCopy; ///< If loaded from memory, this is the file copy in memory Font::Info mInfo; ///< Information about the font diff --git a/include/eepp/system/resourcemanager.hpp b/include/eepp/system/resourcemanager.hpp index b282c1684..43dd7b639 100644 --- a/include/eepp/system/resourcemanager.hpp +++ b/include/eepp/system/resourcemanager.hpp @@ -316,7 +316,15 @@ template T* ResourceManagerMulti::add( T* resource ) { template bool ResourceManagerMulti::remove( T* resource, bool remove ) { if ( NULL != resource ) { - mResources.erase( resource->getId() ); + auto range = mResources.equal_range( resource->getId() ); + auto it = range.first; + while ( it != range.second ) { + if ( it->second == resource ) { + mResources.erase( it ); + break; + } + it++; + } if ( remove ) eeSAFE_DELETE( resource ); diff --git a/include/eepp/ui/models/itemlistmodel.hpp b/include/eepp/ui/models/itemlistmodel.hpp index b40258a89..f3c08e426 100644 --- a/include/eepp/ui/models/itemlistmodel.hpp +++ b/include/eepp/ui/models/itemlistmodel.hpp @@ -155,6 +155,13 @@ template class ItemPairListOwnerModel final : public Mo return std::make_shared>( data ); } + static std::shared_ptr create( std::vector>&& data ) { + return std::make_shared>( std::move( data ) ); + } + + explicit ItemPairListOwnerModel( std::vector>&& data ) : + mData( std::move( data ) ) {} + explicit ItemPairListOwnerModel( const std::vector>& data ) : mData( data ) {} virtual ~ItemPairListOwnerModel() {} diff --git a/projects/android-project/app/jni/Android.mk b/projects/android-project/app/jni/Android.mk index 733b630d0..ed2ae289d 100644 --- a/projects/android-project/app/jni/Android.mk +++ b/projects/android-project/app/jni/Android.mk @@ -62,6 +62,7 @@ LOCAL_C_INCLUDES := $(EEPP_C_INCLUDES) CORE_SRCS := tools/ecode/*.cpp \ tools/ecode/plugins/*.cpp \ tools/ecode/plugins/aiassistant/*.cpp \ + tools/ecode/plugins/aiassistant/acp/*.cpp \ tools/ecode/plugins/autocomplete/*.cpp \ tools/ecode/plugins/debugger/*.cpp \ tools/ecode/plugins/debugger/dap/*.cpp \ diff --git a/src/eepp/graphics/fontmanager.cpp b/src/eepp/graphics/fontmanager.cpp index e29e718a1..dd5e9dda6 100644 --- a/src/eepp/graphics/fontmanager.cpp +++ b/src/eepp/graphics/fontmanager.cpp @@ -7,7 +7,11 @@ SINGLETON_DECLARE_IMPLEMENTATION( FontManager ) FontManager::FontManager() {} -FontManager::~FontManager() {} +FontManager::~FontManager() { + mEmojiFont = nullptr; + mColorEmojiFont = nullptr; + mFallbackFonts.clear(); +} Graphics::Font* FontManager::add( Graphics::Font* font ) { eeASSERT( NULL != font ); diff --git a/src/eepp/graphics/fonttruetype.cpp b/src/eepp/graphics/fonttruetype.cpp index f151bc721..559f2809d 100644 --- a/src/eepp/graphics/fonttruetype.cpp +++ b/src/eepp/graphics/fonttruetype.cpp @@ -251,10 +251,31 @@ FontTrueType::FontTrueType( const std::string& FontName ) : mFace( NULL ), mStreamRec( NULL ), mStroker( NULL ), - mInfo(), + mHBFont( NULL ), + mFontInternalId( 0 ), mBoldAdvanceSameAsRegular( false ), + mIsColorEmojiFont( false ), + mIsEmojiFont( false ), + mHasSvgGlyphs( false ), + mHasColrGlyphs( false ), + mIsBitmapOnly( false ), + mIsMonospace( false ), + mIsMonospaceComplete( false ), + mUsingFallback( false ), + mEnableEmojiFallback( true ), + mEnableFallbackFont( true ), + mEnableDynamicMonospace( false ), + mIsBold( false ), + mIsItalic( false ), + mIsMonospaceCompletePending( false ), mHinting( FontManager::instance()->getHinting() ), - mAntialiasing( FontManager::instance()->getAntialiasing() ) {} + mAntialiasing( FontManager::instance()->getAntialiasing() ), + mFontBold( nullptr ), + mFontItalic( nullptr ), + mFontBoldItalic( nullptr ), + mFontBoldCb( 0 ), + mFontItalicCb( 0 ), + mFontBoldItalicCb( 0 ) {} FontTrueType::~FontTrueType() { cleanup(); @@ -965,20 +986,6 @@ bool FontTrueType::loaded() const { return NULL != mFace; } -FontTrueType& FontTrueType::operator=( const FontTrueType& right ) { - FontTrueType temp( right.getName() ); - - temp.mMemCopy.swap( right.mMemCopy ); - std::swap( mLibrary, temp.mLibrary ); - std::swap( mFace, temp.mFace ); - std::swap( mStreamRec, temp.mStreamRec ); - std::swap( mStroker, temp.mStroker ); - std::swap( mInfo, temp.mInfo ); - std::swap( mPages, temp.mPages ); - std::swap( mPixelBuffer, temp.mPixelBuffer ); - return *this; -} - void FontTrueType::cleanup() { sendEvent( Event::Unload ); @@ -1028,9 +1035,36 @@ void FontTrueType::cleanup() { mLibrary = NULL; mFace = NULL; mStroker = NULL; + mHBFont = NULL; mStreamRec = NULL; + mInfo = Info(); + mFontInternalId = 0; + mBoldAdvanceSameAsRegular = false; + mIsColorEmojiFont = false; + mIsEmojiFont = false; + mHasSvgGlyphs = false; + mHasColrGlyphs = false; + mIsBitmapOnly = false; + mIsMonospace = false; + mIsMonospaceComplete = false; + mUsingFallback = false; + mEnableEmojiFallback = true; + mEnableFallbackFont = true; + mEnableDynamicMonospace = false; + mIsBold = false; + mIsItalic = false; + mIsMonospaceCompletePending = false; + mFontBold = nullptr; + mFontItalic = nullptr; + mFontBoldItalic = nullptr; + mFontBoldCb = 0; + mFontItalicCb = 0; + mFontBoldItalicCb = 0; mPages.clear(); std::vector().swap( mPixelBuffer ); + mCodePointIndexCache.clear(); + mKeyCache.clear(); + mClosestCharacterSize.clear(); } static int fontSetLoadOptions( FontAntialiasing antialiasing, FontHinting hinting ) { @@ -1430,6 +1464,10 @@ bool FontTrueType::setCurrentSize( unsigned int characterSize ) const { // only when necessary to avoid killing performances FT_Face face = static_cast( mFace ); + + if ( !face || !face->size ) + return false; + FT_UShort currentSize = face->size->metrics.x_ppem; if ( currentSize != characterSize ) { diff --git a/src/eepp/ui/iconmanager.cpp b/src/eepp/ui/iconmanager.cpp index 6b3ef07cf..cd62f8318 100644 --- a/src/eepp/ui/iconmanager.cpp +++ b/src/eepp/ui/iconmanager.cpp @@ -299,6 +299,7 @@ UIIconTheme* IconManager::init( const std::string& iconThemeName, FontTrueType* { "chat-sparkle", 0xec4f }, { "inspect", 0xebd1 }, { "link", 0xeb15 }, + { "agent", 0xec67 }, } ) { iconTheme->add( UIGlyphIcon::New( icon.first, codIconFont, icon.second ) ); diff --git a/src/eepp/ui/uidropdownlist.cpp b/src/eepp/ui/uidropdownlist.cpp index cc6cab9e3..051254a95 100644 --- a/src/eepp/ui/uidropdownlist.cpp +++ b/src/eepp/ui/uidropdownlist.cpp @@ -112,15 +112,15 @@ UIDropDownList* UIDropDownList::showList() { Float width = getPopUpWidth( contentsWidth ); - Float height = - (Int32)( eemin( mListBox->getItemsCount(), mStyleConfig.MaxNumVisibleItems ) * - mListBox->getRowHeight() ) + + Float height = std::ceil( + std::ceil( eemin( mListBox->getItemsCount(), mStyleConfig.MaxNumVisibleItems ) * + PixelDensity::pxToDp( mListBox->getRowHeight() ) ) + tPadding.Top + tPadding.Bottom + ( mListBox->getHorizontalScrollBar() && mListBox->getHorizontalScrollBar()->isVisible() && PixelDensity::dpToPx( width ) < mListBox->getMaxTextWidth() ? mListBox->getHorizontalScrollBar()->getSize().getHeight() - : 0.f ); + : 0.f ) ); mListBox->setSize( width, height ); mListBox->getVerticalScrollBar()->setValue( sliderValue ); diff --git a/src/eepp/ui/uidropdownmodellist.cpp b/src/eepp/ui/uidropdownmodellist.cpp index 9eed3e2b5..8fc7e4b7e 100644 --- a/src/eepp/ui/uidropdownmodellist.cpp +++ b/src/eepp/ui/uidropdownmodellist.cpp @@ -167,14 +167,14 @@ UIDropDownModelList* UIDropDownModelList::showList() { Float width = getPopUpWidth( contentsWidth ); - Float height = - (Int32)( eemin( (Uint32)mModel->rowCount(), mStyleConfig.MaxNumVisibleItems ) * - mListView->getRowHeight() ) + + Float height = std::ceil( + std::ceil( eemin( (Uint32)mModel->rowCount(), mStyleConfig.MaxNumVisibleItems ) * + PixelDensity::pxToDp( mListView->getRowHeight() ) ) + tPadding.Top + tPadding.Bottom + mListView->getHeaderHeight() + ( mListView->getHorizontalScrollBar() && mListView->getHorizontalScrollBar()->isVisible() ? mListView->getHorizontalScrollBar()->getSize().getHeight() - : 0.f ); + : 0.f ) ); mListView->setSize( width, height ); diff --git a/src/eepp/ui/uiselectbutton.cpp b/src/eepp/ui/uiselectbutton.cpp index 8c6385dff..7d21e04ae 100644 --- a/src/eepp/ui/uiselectbutton.cpp +++ b/src/eepp/ui/uiselectbutton.cpp @@ -131,8 +131,7 @@ void UISelectButton::setSelected( bool set ) { void UISelectButton::setSelectOnClick( bool set ) { if ( set ) { if ( mSelectOnClickCbId == 0 ) - mSelectOnClickCbId = - on( Event::MouseClick, [this]( const Event* ) { toggleSelection(); } ); + mSelectOnClickCbId = onClick( [this]( const Event* ) { toggleSelection(); } ); } else { if ( mSelectOnClickCbId != 0 ) removeEventListener( mSelectOnClickCbId ); diff --git a/src/modules/eterm/include/eterm/terminal/terminalemulator.hpp b/src/modules/eterm/include/eterm/terminal/terminalemulator.hpp index 1e1cbc15e..1d43c28aa 100644 --- a/src/modules/eterm/include/eterm/terminal/terminalemulator.hpp +++ b/src/modules/eterm/include/eterm/terminal/terminalemulator.hpp @@ -254,6 +254,10 @@ class TerminalEmulator final { mPromptStateChangedCb = promptStateChangedCb; } + using DataCb = std::function; + + void setDataCb( DataCb cb ) { mDataCb = cb; } + int getTerminalMode() const { return mTerm.mode; } private: @@ -291,6 +295,7 @@ class TerminalEmulator final { std::string mCurrentWorkingDirectory; PromptState mPromptState{ PromptState::Unknown }; PromptStateChangedCb mPromptStateChangedCb; + DataCb mDataCb; void setClipboard( const char* str ); diff --git a/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp b/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp index 3ba018492..fe633c99e 100644 --- a/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp +++ b/src/modules/eterm/src/eterm/terminal/terminalemulator.cpp @@ -653,6 +653,9 @@ size_t TerminalEmulator::ttyread( void ) { _die( "couldn't read from shell: %s\n", strerror( errno ) ); return 0; default: { + if ( mDataCb ) + mDataCb( mBuf + mBuflen, ret ); + int old_scr = mTerm.scr; int old_histi = mTerm.histi; mTerm.scr = 0; diff --git a/src/tests/unit_tests/regex.cpp b/src/tests/unit_tests/regex.cpp index 6058a66ef..fbdbb6002 100644 --- a/src/tests/unit_tests/regex.cpp +++ b/src/tests/unit_tests/regex.cpp @@ -82,6 +82,7 @@ UTEST( LuaPattern, basicTest ) { EXPECT_EQ( start, 14 ); EXPECT_EQ( end, 16 ); } + RegExCache::destroySingleton(); } UTEST( RegExEngines, basicTest ) { @@ -96,4 +97,5 @@ UTEST( RegExEngines, basicTest ) { EXPECT_EQ( 38, matchesPCRE2[0].end ); EXPECT_EQ( 38, matchesOniguruma[0].start ); EXPECT_EQ( 38, matchesOniguruma[0].end ); + RegExCache::destroySingleton(); } diff --git a/src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp b/src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp new file mode 100644 index 000000000..b0dc4bddb --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/acp/acpclient.cpp @@ -0,0 +1,330 @@ +#include "acpclient.hpp" +#include + +namespace ecode { namespace acp { + +ACPClient::ACPClient( std::shared_ptr threadPool, const Config& config ) : + mThreadPool( threadPool ), mConfig( config ) {} + +ACPClient::~ACPClient() { + stop(); +} + +bool ACPClient::start() { + auto flags = Process::getDefaultOptions() | Process::EnableAsync | Process::UseAbsolutePath; + bool ret = mProcess.create( mConfig.command, mConfig.args, flags, mConfig.environment, + mConfig.workingDirectory ); + if ( ret && mProcess.isAlive() ) { + mProcess.startAsyncRead( + [this]( const char* bytes, size_t n ) { readStdOut( bytes, n ); }, + [this]( const char* bytes, size_t n ) { readStdErr( bytes, n ); } ); + return true; + } + return false; +} + +void ACPClient::stop() { + mShuttingDown = true; + if ( mProcess.isAlive() ) { + mProcess.kill(); + } +} + +bool ACPClient::isRunning() { + return !mShuttingDown && mProcess.isAlive(); +} + +bool ACPClient::isReady() { + return mReady; +} + +void ACPClient::readStdOut( const char* bytes, size_t n ) { + mReceiveBuffer.append( bytes, n ); + size_t pos; + while ( ( pos = mReceiveBuffer.find( '\n' ) ) != std::string::npos ) { + std::string line = mReceiveBuffer.substr( 0, pos ); + mReceiveBuffer.erase( 0, pos + 1 ); + if ( line.empty() || line == "\r" ) + continue; + + try { + json msg = json::parse( line ); + Log::debug( "ACPClient RECV: %s", line.c_str() ); + processMessage( msg ); + } catch ( const json::exception& e ) { + Log::error( "ACPClient JSON parse error: %s\nLine: %s", e.what(), line.c_str() ); + } + } +} + +void ACPClient::readStdErr( const char* bytes, size_t n ) { + std::string err( bytes, n ); + Log::debug( "ACPClient stderr: %s", err.c_str() ); +} + +void ACPClient::processMessage( const json& msg ) { + if ( msg.contains( "method" ) ) { + if ( msg.contains( "id" ) ) { + processRequest( msg ); + } else { + processNotification( msg ); + } + } else if ( msg.contains( "result" ) || msg.contains( "error" ) ) { + processResponse( msg ); + } +} + +void ACPClient::processRequest( const json& msg ) { + std::string method = msg.value( "method", "" ); + json id = msg["id"]; + + if ( method == "fs/read_text_file" && onReadTextFile ) { + ReadTextFileRequest req( msg.value( "params", json::object() ) ); + onReadTextFile( req, [this, id]( const ReadTextFileResponse& res ) { + sendResponse( id, res.toJson() ); + } ); + } else if ( method == "fs/write_text_file" && onWriteTextFile ) { + WriteTextFileRequest req( msg.value( "params", json::object() ) ); + onWriteTextFile( req, [this, id]( const WriteTextFileResponse& res ) { + sendResponse( id, res.toJson() ); + } ); + } else if ( method == "session/request_permission" && onRequestPermission ) { + RequestPermissionRequest req( msg.value( "params", json::object() ) ); + onRequestPermission( req, [this, id]( const RequestPermissionResponse& res ) { + sendResponse( id, res.toJson() ); + } ); + } else if ( method == "terminal/create" && onCreateTerminal ) { + CreateTerminalRequest req( msg.value( "params", json::object() ) ); + onCreateTerminal( req, [this, id]( const CreateTerminalResponse& res ) { + sendResponse( id, res.toJson() ); + } ); + } else if ( method == "terminal/output" && onTerminalOutput ) { + TerminalOutputRequest req( msg.value( "params", json::object() ) ); + onTerminalOutput( req, [this, id]( const TerminalOutputResponse& res ) { + sendResponse( id, res.toJson() ); + } ); + } else if ( method == "terminal/kill" && onKillTerminal ) { + KillTerminalRequest req( msg.value( "params", json::object() ) ); + onKillTerminal( req, [this, id]( const KillTerminalResponse& res ) { + sendResponse( id, res.toJson() ); + } ); + } else if ( method == "terminal/release" && onReleaseTerminal ) { + ReleaseTerminalRequest req( msg.value( "params", json::object() ) ); + onReleaseTerminal( req, [this, id]( const ReleaseTerminalResponse& res ) { + sendResponse( id, res.toJson() ); + } ); + } else if ( method == "terminal/wait_for_exit" && onWaitForTerminalExit ) { + WaitForTerminalExitRequest req( msg.value( "params", json::object() ) ); + onWaitForTerminalExit( req, [this, id]( const WaitForTerminalExitResponse& res ) { + sendResponse( id, res.toJson() ); + } ); + } else { + sendError( id, -32601, "Method not found: " + method ); + } +} + +void ACPClient::processNotification( const json& msg ) { + std::string method = msg.value( "method", "" ); + if ( method == "session/update" && onSessionUpdate ) { + auto params = msg.value( "params", json::object() ); + if ( params.contains( "update" ) ) { + onSessionUpdate( params["update"] ); + } else { + onSessionUpdate( params ); // Fallback if schema shifts or malformed + } + } +} + +void ACPClient::processResponse( const json& msg ) { + if ( !msg.contains( "id" ) || !msg["id"].is_number_integer() ) + return; + IdType id = msg["id"].get(); + + JsonReplyHandler handler; + { + Lock l( mHandlersMutex ); + auto it = mHandlers.find( id ); + if ( it != mHandlers.end() ) { + handler = it->second; + mHandlers.erase( it ); + } + } + + if ( handler ) { + handler( id, msg ); + } else if ( msg.contains( "error" ) && onError ) { + onError( ResponseError( msg["error"] ) ); + } +} + +int ACPClient::write( json&& msg, const JsonReplyHandler& h ) { + msg["jsonrpc"] = "2.0"; + int msgId = 0; + + if ( h ) { + msgId = ++mLastMsgId; + msg["id"] = msgId; + Lock l( mHandlersMutex ); + mHandlers[msgId] = h; + } + + std::string out = msg.dump() + "\n"; + if ( isRunning() ) { + Log::debug( "ACPClient SEND: %s", out.c_str() ); + mProcess.write( out ); + } + return msgId; +} + +void ACPClient::sendResponse( const json& id, json&& result ) { + json msg = { { "jsonrpc", "2.0" }, { "id", id }, { "result", result } }; + std::string out = msg.dump() + "\n"; + if ( isRunning() ) { + Log::debug( "ACPClient SEND: %s", out.c_str() ); + mProcess.write( out ); + } +} + +void ACPClient::sendError( const json& id, int code, const std::string& message ) { + json msg = { { "jsonrpc", "2.0" }, + { "id", id }, + { "error", { { "code", code }, { "message", message } } } }; + std::string out = msg.dump() + "\n"; + if ( isRunning() ) { + Log::debug( "ACPClient SEND: %s", out.c_str() ); + mProcess.write( out ); + } +} + +void ACPClient::initialize( const InitializeRequest& req, + const std::function& )>& cb ) { + write( { { "method", "initialize" }, { "params", req.toJson() } }, + [this, cb]( const IdType&, const json& resp ) { + if ( resp.contains( "result" ) ) { + mReady = true; + if ( cb ) + cb( InitializeResponse( resp["result"] ), std::nullopt ); + } else if ( resp.contains( "error" ) ) { + if ( cb ) + cb( {}, ResponseError( resp["error"] ) ); + } + } ); +} + +void ACPClient::newSession( const NewSessionRequest& req, + const std::function& )>& cb ) { + write( { { "method", "session/new" }, { "params", req.toJson() } }, + [cb]( const IdType&, const json& resp ) { + if ( resp.contains( "result" ) && cb ) { + cb( NewSessionResponse( resp["result"] ), std::nullopt ); + } else if ( resp.contains( "error" ) && cb ) { + cb( {}, ResponseError( resp["error"] ) ); + } + } ); +} + +void ACPClient::loadSession( + const LoadSessionRequest& req, + const std::function& )>& + cb ) { + write( { { "method", "session/load" }, { "params", req.toJson() } }, + [cb]( const IdType&, const json& resp ) { + if ( resp.contains( "result" ) && cb ) { + cb( LoadSessionResponse( resp["result"] ), std::nullopt ); + } else if ( resp.contains( "error" ) && cb ) { + cb( {}, ResponseError( resp["error"] ) ); + } + } ); +} + +void ACPClient::setConfigOption( + const SetConfigOptionRequest& req, + const std::function& )>& cb ) { + auto fallback = [this, req, cb]() { + if ( req.configId == "model" ) { + write( + { { "method", "session/set_model" }, + { "params", { { "sessionId", req.sessionId }, { "modelId", req.optionId } } } }, + [req, cb]( const IdType&, const json& resp2 ) { + if ( resp2.contains( "result" ) && cb ) { + cb( SetConfigOptionResponse( resp2["result"], req.configId, req.optionId ), + std::nullopt ); + } else if ( resp2.contains( "error" ) && cb ) { + cb( {}, ResponseError( resp2["error"] ) ); + } + } ); + } else if ( req.configId == "mode" ) { + write( + { { "method", "session/set_mode" }, + { "params", { { "sessionId", req.sessionId }, { "modeId", req.optionId } } } }, + [req, cb]( const IdType&, const json& resp2 ) { + if ( resp2.contains( "result" ) && cb ) { + cb( SetConfigOptionResponse( resp2["result"], req.configId, req.optionId ), + std::nullopt ); + } else if ( resp2.contains( "error" ) && cb ) { + cb( {}, ResponseError( resp2["error"] ) ); + } + } ); + } else if ( cb ) { + cb( {}, ResponseError{ -32601, "Method not found" } ); + } + }; + + if ( mLegacyConfigOnly ) { + fallback(); + return; + } + + write( { { "method", "session/set_config_option" }, { "params", req.toJson() } }, + [this, req, fallback, cb]( const IdType&, const json& resp ) { + if ( resp.contains( "result" ) && cb ) { + cb( SetConfigOptionResponse( resp["result"], req.configId, req.optionId ), + std::nullopt ); + } else if ( resp.contains( "error" ) ) { + ResponseError err( resp["error"] ); + if ( err.code == -32601 ) { + mLegacyConfigOnly = true; + fallback(); + return; + } + if ( cb ) + cb( {}, err ); + } + } ); +} + +void ACPClient::listSessions( + const ListSessionsRequest& req, + const std::function& )>& + cb ) { + write( { { "method", "session/list" }, { "params", req.toJson() } }, + [cb]( const IdType&, const json& resp ) { + if ( resp.contains( "result" ) && cb ) { + cb( ListSessionsResponse( resp["result"] ), std::nullopt ); + } else if ( resp.contains( "error" ) && cb ) { + cb( {}, ResponseError( resp["error"] ) ); + } + } ); +} + +void ACPClient::prompt( + const PromptRequest& req, + const std::function& )>& cb ) { + write( { { "method", "session/prompt" }, { "params", req.toJson() } }, + [cb]( const IdType&, const json& resp ) { + if ( resp.contains( "result" ) && cb ) { + cb( PromptResponse( resp["result"] ), std::nullopt ); + } else if ( resp.contains( "error" ) && cb ) { + cb( {}, ResponseError( resp["error"] ) ); + } + } ); +} + +void ACPClient::cancel( const std::string& sessionId ) { + write( { { "method", "session/cancel" }, { "params", { { "sessionId", sessionId } } } } ); +} + +}} // namespace ecode::acp diff --git a/src/tools/ecode/plugins/aiassistant/acp/acpclient.hpp b/src/tools/ecode/plugins/aiassistant/acp/acpclient.hpp new file mode 100644 index 000000000..88e777689 --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/acp/acpclient.hpp @@ -0,0 +1,123 @@ +#pragma once + +#include "acpprotocol.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +using namespace EE; +using namespace EE::System; + +namespace ecode { namespace acp { + +class ACPClient { + public: + using IdType = int; + using JsonReplyHandler = std::function; + + struct Config { + std::string command; + std::vector args; + std::string workingDirectory; + std::unordered_map environment; + }; + + ACPClient( std::shared_ptr threadPool, const Config& config ); + ~ACPClient(); + + bool start(); + void stop(); + + bool isRunning(); + bool isReady(); + + const Config& getConfig() const { return mConfig; } + + void initialize( const InitializeRequest& req, + const std::function& )>& cb ); + void newSession( const NewSessionRequest& req, + const std::function& )>& cb ); + void loadSession( const LoadSessionRequest& req, + const std::function& )>& cb ); + void setConfigOption( const SetConfigOptionRequest& req, + const std::function& )>& cb ); + void listSessions( const ListSessionsRequest& req, + const std::function& )>& cb ); + void prompt( const PromptRequest& req, + const std::function& )>& cb ); + + // Notifications to agent + void cancel( const std::string& sessionId ); + + // Callbacks from agent + std::function onError; + std::function onSessionUpdate; + std::function )> + onReadTextFile; + std::function )> + onWriteTextFile; + std::function )> + onRequestPermission; + std::function )> + onCreateTerminal; + std::function )> + onTerminalOutput; + std::function )> + onKillTerminal; + std::function )> + onReleaseTerminal; + std::function )> + onWaitForTerminalExit; + + protected: + std::shared_ptr mThreadPool; + Config mConfig; + Process mProcess; + bool mReady{ false }; + bool mShuttingDown{ false }; + std::atomic mLastMsgId{ 0 }; + + Mutex mHandlersMutex; + std::map mHandlers; + + std::string mReceiveBuffer; + bool mLegacyConfigOnly{ false }; + + void readStdOut( const char* bytes, size_t n ); + void readStdErr( const char* bytes, size_t n ); + + void processMessage( const json& msg ); + void processRequest( const json& msg ); + void processNotification( const json& msg ); + void processResponse( const json& msg ); + + int write( json&& msg, const JsonReplyHandler& h = nullptr ); + void sendResponse( const json& id, json&& result ); + void sendError( const json& id, int code, const std::string& message ); +}; + +}} // namespace ecode::acp diff --git a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp new file mode 100644 index 000000000..9dc5e7d08 --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.cpp @@ -0,0 +1,404 @@ +#include "acpprotocol.hpp" + +namespace ecode { namespace acp { + +json parseLegacyConfigOptions( const json& body, json configOptions, const std::string& forceId, + const std::string& forceValue ) { + if ( configOptions.is_null() ) { + configOptions = json::array(); + } + + auto updateOrAdd = [&configOptions]( json newOpt ) { + bool found = false; + for ( auto& opt : configOptions ) { + if ( opt.is_object() && opt.contains( "id" ) && opt["id"] == newOpt["id"] ) { + opt = std::move( newOpt ); + found = true; + break; + } + } + if ( !found ) { + configOptions.push_back( std::move( newOpt ) ); + } + }; + + if ( body.contains( "models" ) && body["models"].is_object() ) { + auto models = body["models"]; + if ( models.contains( "availableModels" ) && models["availableModels"].is_array() ) { + json modelConfig = { { "id", "model" }, + { "name", "Model" }, + { "type", "select" }, + { "category", "model" }, + { "options", json::array() } }; + for ( const auto& model : models["availableModels"] ) { + modelConfig["options"].push_back( { { "id", model.value( "modelId", "" ) }, + { "name", model.value( "name", "" ) } } ); + } + if ( models.contains( "currentModelId" ) ) { + modelConfig["currentValue"] = models.value( "currentModelId", "" ); + modelConfig["default"] = models.value( "currentModelId", "" ); + } else if ( !modelConfig["options"].empty() ) { + modelConfig["currentValue"] = modelConfig["options"][0]["id"]; + modelConfig["default"] = modelConfig["options"][0]["id"]; + } + updateOrAdd( std::move( modelConfig ) ); + } + } + + if ( body.contains( "modes" ) && body["modes"].is_object() ) { + auto modes = body["modes"]; + if ( modes.contains( "availableModes" ) && modes["availableModes"].is_array() ) { + json modeConfig = { { "id", "mode" }, + { "name", "Mode" }, + { "type", "select" }, + { "category", "mode" }, + { "options", json::array() } }; + for ( const auto& mode : modes["availableModes"] ) { + modeConfig["options"].push_back( + { { "id", mode.value( "id", "" ) }, { "name", mode.value( "name", "" ) } } ); + } + if ( modes.contains( "currentModeId" ) ) { + modeConfig["currentValue"] = modes.value( "currentModeId", "" ); + modeConfig["default"] = modes.value( "currentModeId", "" ); + } else if ( !modeConfig["options"].empty() ) { + modeConfig["currentValue"] = modeConfig["options"][0]["id"]; + modeConfig["default"] = modeConfig["options"][0]["id"]; + } + updateOrAdd( std::move( modeConfig ) ); + } + } + + if ( !forceId.empty() ) { + for ( auto& opt : configOptions ) { + if ( opt.is_object() && opt.contains( "id" ) && opt["id"] == forceId ) { + opt["currentValue"] = forceValue; + break; + } + } + } + + return configOptions; +} + +ClientCapabilities::ClientCapabilities( const json& body ) { + if ( body.contains( "terminal" ) ) + terminal = body.value( "terminal", false ); + 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"]; + configOptions = parseLegacyConfigOptions( body, configOptions ); +} + +json LoadSessionRequest::toJson() const { + json j = { { "sessionId", sessionId }, { "cwd", cwd } }; + if ( mcpServers.is_null() ) { + j["mcpServers"] = json::array(); + } else { + j["mcpServers"] = mcpServers; + } + return j; +} + +LoadSessionResponse::LoadSessionResponse( const json& body ) { + if ( body.contains( "configOptions" ) ) + configOptions = body["configOptions"]; + configOptions = parseLegacyConfigOptions( body, configOptions ); +} + +json SetConfigOptionRequest::toJson() const { + return { { "sessionId", sessionId }, { "configId", configId }, { "optionId", optionId } }; +} + +SetConfigOptionResponse::SetConfigOptionResponse( const json& body, const std::string& configId, + const std::string& optionId ) { + if ( body.contains( "configOptions" ) ) + configOptions = body["configOptions"]; + configOptions = parseLegacyConfigOptions( body, configOptions, configId, optionId ); +} + +SessionInfo::SessionInfo( const json& body ) { + if ( body.contains( "sessionId" ) && body["sessionId"].is_string() ) + sessionId = body["sessionId"].get(); + if ( body.contains( "cwd" ) && body["cwd"].is_string() ) + cwd = body["cwd"].get(); + if ( body.contains( "title" ) && body["title"].is_string() ) + title = body["title"].get(); + if ( body.contains( "updatedAt" ) && body["updatedAt"].is_string() ) + updatedAt = body["updatedAt"].get(); +} + +json ListSessionsRequest::toJson() const { + json j = json::object(); + if ( !cursor.empty() ) + j["cursor"] = cursor; + if ( !cwd.empty() ) + j["cwd"] = cwd; + return j; +} + +ListSessionsResponse::ListSessionsResponse( const json& body ) { + if ( body.contains( "nextCursor" ) && body["nextCursor"].is_string() ) + nextCursor = body["nextCursor"].get(); + if ( body.contains( "sessions" ) && body["sessions"].is_array() ) { + for ( const auto& item : body["sessions"] ) { + sessions.emplace_back( SessionInfo( item ) ); + } + } +} + +json PromptRequest::toJson() const { + return { { "sessionId", sessionId }, { "prompt", prompt } }; +} + +PromptResponse::PromptResponse( const json& body ) { + if ( body.contains( "stopReason" ) && body["stopReason"].is_string() ) + stopReason = body.value( "stopReason", "" ); +} + +ToolCallLocation::ToolCallLocation( const json& body ) { + if ( body.contains( "path" ) ) + path = body.value( "path", "" ); + if ( body.contains( "line" ) && !body["line"].is_null() ) + line = body["line"].get(); +} + +ToolCall::ToolCall( const json& body ) { + if ( body.contains( "title" ) ) + title = body.value( "title", "" ); + if ( body.contains( "toolCallId" ) ) + toolCallId = body.value( "toolCallId", "" ); + if ( body.contains( "kind" ) && body["kind"].is_string() ) + kind = body.value( "kind", "" ); + if ( body.contains( "rawInput" ) ) + rawInput = body["rawInput"]; + if ( body.contains( "rawOutput" ) ) + rawOutput = body["rawOutput"]; + if ( body.contains( "locations" ) && body["locations"].is_array() ) { + for ( const auto& l : body["locations"] ) { + locations.push_back( ToolCallLocation( l ) ); + } + } +} + +ReadTextFileRequest::ReadTextFileRequest( const json& body ) { + if ( body.contains( "sessionId" ) ) + sessionId = body.value( "sessionId", "" ); + if ( body.contains( "path" ) ) + path = body.value( "path", "" ); + if ( body.contains( "line" ) && !body["line"].is_null() ) + line = body["line"].get(); + if ( body.contains( "limit" ) && !body["limit"].is_null() ) + limit = body["limit"].get(); +} + +json ReadTextFileResponse::toJson() const { + return { { "content", content } }; +} + +WriteTextFileRequest::WriteTextFileRequest( const json& body ) { + if ( body.contains( "sessionId" ) ) + sessionId = body.value( "sessionId", "" ); + if ( body.contains( "path" ) ) + path = body.value( "path", "" ); + if ( body.contains( "content" ) ) + content = body.value( "content", "" ); +} + +json WriteTextFileResponse::toJson() const { + return json::object(); +} + +EnvVariable::EnvVariable( const json& body ) { + if ( body.contains( "name" ) ) + name = body.value( "name", "" ); + if ( body.contains( "value" ) ) + value = body.value( "value", "" ); +} + +json EnvVariable::toJson() const { + return { { "name", name }, { "value", value } }; +} + +CreateTerminalRequest::CreateTerminalRequest( const json& body ) { + if ( body.contains( "sessionId" ) ) + sessionId = body.value( "sessionId", "" ); + if ( body.contains( "command" ) ) + command = body.value( "command", "" ); + if ( body.contains( "args" ) && body["args"].is_array() ) { + for ( const auto& a : body["args"] ) + args.push_back( a.get() ); + } + if ( body.contains( "cwd" ) && !body["cwd"].is_null() ) + cwd = body["cwd"].get(); + if ( body.contains( "env" ) && body["env"].is_array() ) { + for ( const auto& e : body["env"] ) + env.push_back( EnvVariable( e ) ); + } + if ( body.contains( "outputByteLimit" ) && !body["outputByteLimit"].is_null() ) + outputByteLimit = body["outputByteLimit"].get(); +} + +json CreateTerminalResponse::toJson() const { + return { { "terminalId", terminalId } }; +} + +TerminalExitStatus::TerminalExitStatus( const json& body ) { + if ( body.contains( "exitCode" ) && !body["exitCode"].is_null() ) + exitCode = body["exitCode"].get(); + if ( body.contains( "signal" ) && !body["signal"].is_null() ) + signal = body["signal"].get(); +} + +json TerminalExitStatus::toJson() const { + json j; + if ( exitCode ) + j["exitCode"] = *exitCode; + else + j["exitCode"] = nullptr; + if ( signal ) + j["signal"] = *signal; + else + j["signal"] = nullptr; + return j; +} + +TerminalOutputRequest::TerminalOutputRequest( const json& body ) { + if ( body.contains( "sessionId" ) ) + sessionId = body.value( "sessionId", "" ); + if ( body.contains( "terminalId" ) ) + terminalId = body.value( "terminalId", "" ); + if ( body.contains( "outputByteLimit" ) && !body["outputByteLimit"].is_null() ) + outputByteLimit = body["outputByteLimit"].get(); +} + +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 { { "outcome", j } }; +} + +}} // namespace ecode::acp diff --git a/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp new file mode 100644 index 000000000..ab3209415 --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/acp/acpprotocol.hpp @@ -0,0 +1,336 @@ +#pragma once + +#include +#include +#include +#include + +#include + +using json = nlohmann::json; + +namespace ecode { namespace acp { + +json parseLegacyConfigOptions( const json& body, json configOptions, + const std::string& forceId = "", + const std::string& forceValue = "" ); + +struct ClientCapabilities { + bool terminal{ false }; + bool fsReadTextFile{ false }; + 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 LoadSessionRequest { + std::string sessionId; + std::string cwd; + json mcpServers; + + LoadSessionRequest() = default; + json toJson() const; +}; + +struct LoadSessionResponse { + json configOptions; + + LoadSessionResponse() = default; + LoadSessionResponse( const json& body ); +}; + +struct SetConfigOptionRequest { + std::string sessionId; + std::string configId; + std::string optionId; + + SetConfigOptionRequest() = default; + json toJson() const; +}; + +struct SetConfigOptionResponse { + json configOptions; + + SetConfigOptionResponse() = default; + SetConfigOptionResponse( const json& body, const std::string& configId = "", + const std::string& optionId = "" ); +}; + +struct SessionInfo { + std::string sessionId; + std::string cwd; + std::string title; + std::string updatedAt; + + SessionInfo() = default; + SessionInfo( const json& body ); +}; + +struct ListSessionsRequest { + std::string cursor; + std::string cwd; + + ListSessionsRequest() = default; + json toJson() const; +}; + +struct ListSessionsResponse { + std::string nextCursor; + std::vector sessions; + + ListSessionsResponse() = default; + ListSessionsResponse( const json& body ); +}; + +struct ResponseError { + int code{ 0 }; + std::string message; + json data; + + ResponseError() = default; + ResponseError( int code, std::string message, json data = {} ) : + code( code ), message( std::move( message ) ), data( std::move( data ) ) {} + ResponseError( const json& body ) { + if ( body.contains( "code" ) ) + code = body["code"].get(); + if ( body.contains( "message" ) ) + message = body["message"].get(); + if ( body.contains( "data" ) ) + data = body["data"]; + } +}; + +struct PromptRequest { + std::string sessionId; + json prompt; // Array of ContentBlock + + PromptRequest() = default; + json toJson() const; +}; + +struct PromptResponse { + std::string stopReason; + + PromptResponse() = default; + PromptResponse( const json& body ); +}; + +struct ToolCallLocation { + std::string path; + std::optional line; + + ToolCallLocation() = default; + ToolCallLocation( const json& body ); +}; + +struct ToolCall { + std::string title; + std::string toolCallId; + std::string kind; + json rawInput; + json rawOutput; + std::vector locations; + + ToolCall() = default; + ToolCall( const json& body ); +}; + +struct ReadTextFileRequest { + std::string sessionId; + std::string path; + std::optional line; + std::optional limit; + + ReadTextFileRequest() = default; + ReadTextFileRequest( const json& body ); +}; + +struct ReadTextFileResponse { + std::string content; + + ReadTextFileResponse() = default; + json toJson() const; +}; + +struct WriteTextFileRequest { + std::string sessionId; + std::string path; + std::string content; + + WriteTextFileRequest() = default; + WriteTextFileRequest( const json& body ); +}; + +struct WriteTextFileResponse { + WriteTextFileResponse() = default; + json toJson() const; +}; + +struct EnvVariable { + std::string name; + std::string value; + EnvVariable() = default; + EnvVariable( const json& body ); + json toJson() const; +}; + +struct CreateTerminalRequest { + std::string sessionId; + std::string command; + std::vector args; + std::optional cwd; + std::vector env; + std::optional outputByteLimit; + + CreateTerminalRequest() = default; + CreateTerminalRequest( const json& body ); +}; + +struct CreateTerminalResponse { + std::string terminalId; + + CreateTerminalResponse() = default; + json toJson() const; +}; + +struct TerminalExitStatus { + std::optional exitCode; + std::optional signal; + + TerminalExitStatus() = default; + TerminalExitStatus( const json& body ); + json toJson() const; +}; + +struct TerminalOutputRequest { + std::string sessionId; + std::string terminalId; + std::optional outputByteLimit; + + TerminalOutputRequest() = default; + TerminalOutputRequest( const json& body ); +}; + +struct TerminalOutputResponse { + std::string output; + bool truncated{ false }; + std::optional exitStatus; + + TerminalOutputResponse() = default; + json toJson() const; +}; + +struct KillTerminalRequest { + std::string sessionId; + std::string terminalId; + + KillTerminalRequest() = default; + KillTerminalRequest( const json& body ); +}; + +struct KillTerminalResponse { + KillTerminalResponse() = default; + json toJson() const; +}; + +struct ReleaseTerminalRequest { + std::string sessionId; + std::string terminalId; + + ReleaseTerminalRequest() = default; + ReleaseTerminalRequest( const json& body ); +}; + +struct ReleaseTerminalResponse { + ReleaseTerminalResponse() = default; + json toJson() const; +}; + +struct WaitForTerminalExitRequest { + std::string sessionId; + std::string terminalId; + + WaitForTerminalExitRequest() = default; + WaitForTerminalExitRequest( const json& body ); +}; + +struct WaitForTerminalExitResponse { + std::optional exitCode; + std::optional signal; + + WaitForTerminalExitResponse() = default; + json toJson() const; +}; + +struct PermissionOption { + std::string optionId; + std::string name; + std::string kind; + + PermissionOption() = default; + PermissionOption( const json& body ); + json toJson() const; +}; + +struct RequestPermissionRequest { + std::string sessionId; + ToolCall toolCall; + std::vector options; + + RequestPermissionRequest() = default; + RequestPermissionRequest( const json& body ); +}; + +struct RequestPermissionResponse { + std::string outcome; + std::optional optionId; + + RequestPermissionResponse() = default; + json toJson() const; +}; + +}} // namespace ecode::acp diff --git a/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp b/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp new file mode 100644 index 000000000..e6125dfee --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/acp/agentsession.cpp @@ -0,0 +1,287 @@ +#include "agentsession.hpp" +#include +#include + +namespace ecode { namespace acp { + +AgentSession::AgentSession( std::shared_ptr threadPool, + const ACPClient::Config& config ) : + mThreadPool( threadPool ), mClient( std::make_unique( threadPool, config ) ) { + setupClient(); +} + +AgentSession::~AgentSession() { + stop(); +} + +bool AgentSession::start( const std::function& onReady ) { + if ( mClient->start() ) { + InitializeRequest req; + req.clientCapabilities.terminal = true; + req.clientCapabilities.fsReadTextFile = true; + req.clientCapabilities.fsWriteTextFile = true; + + mClient->initialize( req, [this, onReady]( const InitializeResponse&, + const std::optional& err ) { + if ( err ) { + if ( onError ) + onError( *err ); + if ( onReady ) + onReady( false ); + return; + } + NewSessionRequest nreq; + nreq.cwd = mClient->isReady() ? mClient->getConfig().workingDirectory : ""; + mClient->newSession( nreq, [this, onReady]( const NewSessionResponse& nres, + const std::optional& err ) { + if ( err ) { + if ( onError ) + onError( *err ); + if ( onReady ) + onReady( false ); + return; + } + mSessionId = nres.sessionId; + mConfigOptions = nres.configOptions; + if ( onReady ) + onReady( true ); + } ); + } ); + return true; + } + if ( onReady ) + onReady( false ); + return false; +} + +bool AgentSession::startLoaded( const std::string& sessionId, + const std::function& onReady ) { + if ( mClient->start() ) { + InitializeRequest req; + req.clientCapabilities.terminal = true; + req.clientCapabilities.fsReadTextFile = true; + req.clientCapabilities.fsWriteTextFile = true; + + mClient->initialize( req, [this, sessionId, + onReady]( const InitializeResponse& ires, + const std::optional& err ) { + if ( err ) { + if ( onError ) + onError( *err ); + if ( onReady ) + onReady( false ); + return; + } + if ( ires.agentCapabilities.loadSession ) { + LoadSessionRequest lreq; + lreq.sessionId = sessionId; + lreq.cwd = mClient->isReady() ? mClient->getConfig().workingDirectory : ""; + mClient->loadSession( + lreq, [this, sessionId, onReady]( const LoadSessionResponse& lres, + const std::optional& err ) { + if ( err ) { + if ( onError ) + onError( *err ); + if ( onReady ) + onReady( false ); + return; + } + mSessionId = sessionId; + mConfigOptions = lres.configOptions; + if ( onReady ) + onReady( true ); + } ); + } else { + if ( onError ) + onError( { -1, "Agent does not support loading sessions" } ); + if ( onReady ) + onReady( false ); + } + } ); + return true; + } + if ( onReady ) + onReady( false ); + return false; +} + +void AgentSession::listSessions( + const std::function&, + const std::optional& )>& cb ) { + if ( !mClient->isReady() ) { + if ( cb ) + cb( {}, std::nullopt ); + return; + } + 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 ); + } ); +} + +void AgentSession::stop() { + if ( mClient ) + mClient->stop(); +} + +void AgentSession::prompt( + const PromptRequest& req, + const std::function& )>& cb ) { + mIsPrompting = true; + mClient->prompt( + req, [this, cb]( const PromptResponse& res, const std::optional& err ) { + mIsPrompting = false; + if ( err && onError ) + onError( *err ); + if ( cb ) + cb( res, err ); + } ); +} + +void AgentSession::setConfigOption( + const SetConfigOptionRequest& req, + const std::function& )>& cb ) { + mClient->setConfigOption( req, [this, cb]( const SetConfigOptionResponse& res, + const std::optional& err ) { + if ( err && onError ) + onError( *err ); + if ( cb ) + cb( res, err ); + } ); +} + +void AgentSession::cancel() { + if ( mClient && !mSessionId.empty() ) { + mClient->cancel( mSessionId ); + } +} + +void AgentSession::setTerminalData( const std::string& terminalId, UITerminal* uiTerm ) { + auto& termData = mTerminals[terminalId]; + termData = { uiTerm->getTerm(), uiTerm->getTerm()->getTerminal(), uiTerm, "" }; + if ( termData.emulator ) { + termData.emulator->setDataCb( [this, terminalId]( const char* data, size_t size ) { + auto it = mTerminals.find( terminalId ); + if ( it != mTerminals.end() ) { + it->second.outputBuffer.append( data, size ); + } + } ); + } +} + +void AgentSession::setupClient() { + mClient->onError = [this]( const ResponseError& err ) { + if ( onError ) + onError( err ); + }; + + mClient->onSessionUpdate = [this]( const json& msg ) { + auto sessionUpdate = msg.value( "sessionUpdate", "" ); + if ( sessionUpdate == "config_options_update" && msg.contains( "configOptions" ) ) { + mConfigOptions = msg["configOptions"]; + } else if ( msg.contains( "models" ) || msg.contains( "modes" ) ) { + mConfigOptions = parseLegacyConfigOptions( msg, mConfigOptions ); + } + if ( onSessionUpdate ) + onSessionUpdate( msg ); + }; + + 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; + if ( onTerminalCreated ) { + onTerminalCreated( req, 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() ) { + size_t limit = req.outputByteLimit.value_or( 0 ); + if ( limit > 0 && it->second.outputBuffer.size() > limit ) { + res.output = it->second.outputBuffer.substr( 0, limit ); + it->second.outputBuffer.erase( 0, limit ); + res.truncated = true; + } else { + res.output = std::move( it->second.outputBuffer ); + it->second.outputBuffer.clear(); + res.truncated = false; + } + + if ( it->second.emulator && it->second.emulator->hasExited() ) { + TerminalExitStatus status; + status.exitCode = it->second.emulator->getExitCode(); + res.exitStatus = status; + } + } + cb( res ); + }; + + mClient->onKillTerminal = [this]( const KillTerminalRequest& req, auto cb ) { + auto it = mTerminals.find( req.terminalId ); + if ( it != mTerminals.end() && it->second.emulator ) { + it->second.emulator->terminate(); + } + cb( KillTerminalResponse() ); + }; + + mClient->onReleaseTerminal = [this]( const ReleaseTerminalRequest& req, auto cb ) { + auto it = mTerminals.find( req.terminalId ); + if ( it != mTerminals.end() ) { + if ( it->second.emulator ) + it->second.emulator->terminate(); + if ( it->second.uiTerm ) + it->second.uiTerm->close(); + mTerminals.erase( it ); + } + cb( ReleaseTerminalResponse() ); + }; + + mClient->onWaitForTerminalExit = [this]( const WaitForTerminalExitRequest& req, auto cb ) { + WaitForTerminalExitResponse res; + auto it = mTerminals.find( req.terminalId ); + if ( it != mTerminals.end() && it->second.emulator ) { + if ( it->second.emulator->hasExited() ) { + res.exitCode = it->second.emulator->getExitCode(); + } + } + cb( res ); + }; +} + +}} // namespace ecode::acp diff --git a/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp b/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp new file mode 100644 index 000000000..01140ee2f --- /dev/null +++ b/src/tools/ecode/plugins/aiassistant/acp/agentsession.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include "acpclient.hpp" +#include +#include +#include +#include +#include +#include +#include + +using namespace EE::System; +using namespace eterm::Terminal; +using namespace eterm::UI; + +namespace ecode { namespace acp { + +class AgentSession { + public: + AgentSession( std::shared_ptr threadPool, const ACPClient::Config& config ); + ~AgentSession(); + + bool start( const std::function& onReady ); + bool startLoaded( const std::string& sessionId, const std::function& onReady ); + void listSessions( const std::function&, + const std::optional& )>& cb ); + void stop(); + + void prompt( const PromptRequest& req, + const std::function& )>& cb ); + void setConfigOption( + const SetConfigOptionRequest& req, + const std::function& )>& cb ); + void cancel(); + + bool isPrompting() const { return mIsPrompting; } + + std::function onError; + std::function onSessionUpdate; + std::function )> + onRequestPermission; + std::function + onTerminalCreated; + + std::string getSessionId() const { return mSessionId; } + ACPClient* getClient() const { return mClient.get(); } + json getConfigOptions() const { return mConfigOptions; } + void setConfigOptions( const json& opts ) { mConfigOptions = opts; } + + void setTerminalData( const std::string& terminalId, UITerminal* uiTerm ); + + protected: + std::shared_ptr mThreadPool; + std::unique_ptr mClient; + std::string mSessionId; + json mConfigOptions; + bool mIsPrompting{ false }; + + struct TermData { + std::shared_ptr display; + std::shared_ptr emulator; + UITerminal* uiTerm{ nullptr }; + std::string outputBuffer; + }; + std::unordered_map mTerminals; + + void setupClient(); +}; + +}} // namespace ecode::acp diff --git a/src/tools/ecode/plugins/aiassistant/aiassistantplugin.cpp b/src/tools/ecode/plugins/aiassistant/aiassistantplugin.cpp index 7ff601c00..0151cde08 100644 --- a/src/tools/ecode/plugins/aiassistant/aiassistantplugin.cpp +++ b/src/tools/ecode/plugins/aiassistant/aiassistantplugin.cpp @@ -111,6 +111,34 @@ static std::map parseLLMProviders( const nlohmann::jso return providers; } +static std::map parseACPAgents( const nlohmann::json& j ) { + std::map agents; + for ( const auto& item : j.items() ) { + std::string agentName = item.key(); + const auto& agentJson = item.value(); + + ACPAgent agent; + agent.name = agentName; + agent.enabled = agentJson.value( "enabled", true ); + agent.command = agentJson.value( "command", "" ); + + if ( agentJson.contains( "args" ) && agentJson["args"].is_array() ) { + for ( const auto& arg : agentJson["args"] ) { + agent.args.push_back( arg.get() ); + } + } + + if ( agentJson.contains( "environment" ) && agentJson["environment"].is_object() ) { + for ( const auto& envItem : agentJson["environment"].items() ) { + agent.environment[envItem.key()] = envItem.value().get(); + } + } + + agents[agentName] = agent; + } + return agents; +} + Plugin* AIAssistantPlugin::New( PluginManager* pluginManager ) { return eeNew( AIAssistantPlugin, ( pluginManager, false ) ); } @@ -398,6 +426,13 @@ void AIAssistantPlugin::loadAIAssistantConfig( const std::string& path, bool upd } } + if ( j.contains( "agents" ) ) { + auto agents = parseACPAgents( j["agents"] ); + for ( const auto& [key, value] : agents ) { + mAgents[key] = value; + } + } + if ( !j.contains( "providers" ) ) return; @@ -601,6 +636,7 @@ void AIAssistantPlugin::onSaveState( IniFile* state ) { config.partition = mainChat->getSplitter()->getSplitPartition(); config.modelProvider = mainChat->getCurModel().provider; config.modelName = mainChat->getCurModel().name; + config.agentName = mainChat->getCurAgent(); } else { config = mConfig; } @@ -613,6 +649,7 @@ void AIAssistantPlugin::onSaveState( IniFile* state ) { state->setValue( keyname, "split_partition", config.partition.toString() ); state->setValue( keyname, "default_provider", config.modelProvider ); state->setValue( keyname, "default_model", config.modelName ); + state->setValue( keyname, "default_agent", config.agentName ); } } // namespace ecode diff --git a/src/tools/ecode/plugins/aiassistant/aiassistantplugin.hpp b/src/tools/ecode/plugins/aiassistant/aiassistantplugin.hpp index a3e7614fc..cf157fd45 100644 --- a/src/tools/ecode/plugins/aiassistant/aiassistantplugin.hpp +++ b/src/tools/ecode/plugins/aiassistant/aiassistantplugin.hpp @@ -14,6 +14,7 @@ class AIAssistantPlugin : public PluginBase { StyleSheetLength partition; std::string modelProvider; std::string modelName; + std::string agentName; }; static PluginDefinition Definition() { @@ -37,6 +38,7 @@ class AIAssistantPlugin : public PluginBase { std::string getDescription() override { return Definition().description; } const LLMProviders& getProviders() { return mProviders; } + const ACPAgents& getAgents() { return mAgents; } std::string getPluginStatePath() const; @@ -50,6 +52,7 @@ class AIAssistantPlugin : public PluginBase { protected: LLMProviders mProviders; + ACPAgents mAgents; bool mUIInit{ false }; bool mBrokenUserConfigFile{ false }; UIWidget* mStatusBar{ nullptr }; diff --git a/src/tools/ecode/plugins/aiassistant/chatui.cpp b/src/tools/ecode/plugins/aiassistant/chatui.cpp index a3925f1fc..498c61d72 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.cpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.cpp @@ -8,19 +8,25 @@ #include #include +#include #include #include +#include #include #include +#include #include #include #include #include #include #include +#include #include +#include #include #include +#include #include #include @@ -46,7 +52,7 @@ class LLMModelsModel : public Model { return mCurModels.size(); } - virtual size_t columnCount( const ModelIndex& = ModelIndex() ) const override { return 2; } + virtual size_t columnCount( const ModelIndex& = ModelIndex() ) const override { return 3; } virtual std::string columnName( const size_t& column ) const override { switch ( column ) { @@ -131,6 +137,202 @@ class LLMModelsModel : public Model { UISceneNode* mUISceneNode; }; +class ACPAgentsModel : public Model { + public: + enum Columns { Name, Command }; + + ACPAgentsModel( const std::map& agents, + UISceneNode* uiSceneNode = nullptr ) : + mAgents( agents ), mUISceneNode( uiSceneNode ) { + mCurAgents.reserve( mAgents.size() ); + for ( const auto& [name, agent] : mAgents ) { + if ( agent.enabled ) + mCurAgents.emplace_back( name ); + } + } + + virtual size_t rowCount( const ModelIndex& = ModelIndex() ) const override { + return mCurAgents.size(); + } + + virtual size_t columnCount( const ModelIndex& = ModelIndex() ) const override { return 2; } + + virtual std::string columnName( const size_t& column ) const override { + switch ( column ) { + case Columns::Name: + return mUISceneNode ? mUISceneNode->i18n( "name", "Name" ) : "Name"; + case Columns::Command: + return mUISceneNode ? mUISceneNode->i18n( "command", "Command" ) : "Command"; + } + return ""; + } + + virtual Variant data( const ModelIndex& index, + ModelRole role = ModelRole::Display ) const override { + if ( role != ModelRole::Display ) + return {}; + + if ( index.row() < 0 || static_cast( index.row() ) >= mCurAgents.size() ) + return {}; + + const auto& agentName = mCurAgents[index.row()]; + auto it = mAgents.find( agentName ); + if ( it == mAgents.end() ) + return {}; + + switch ( index.column() ) { + case Columns::Name: { + return Variant( it->second.name.c_str() ); + } + case Columns::Command: { + return Variant( it->second.command.c_str() ); + } + } + + return {}; + } + + ModelIndex getFromName( const std::string& name ) { + auto it = + std::find_if( mCurAgents.begin(), mCurAgents.end(), + [&name]( const std::string& agentName ) { return name == agentName; } ); + return it != mCurAgents.end() ? index( std::distance( mCurAgents.begin(), it ) ) + : index( 0 ); + } + + void setFilter( const std::string& filter ) { + if ( mCurFilter == filter ) + return; + mCurFilter = filter; + mCurAgents.clear(); + + for ( const auto& [name, agent] : mAgents ) { + if ( !agent.enabled ) + continue; + + if ( filter.empty() ) { + mCurAgents.emplace_back( name ); + continue; + } + + bool matchesName = String::icontains( agent.name, filter ); + bool matchesCommand = String::icontains( agent.command, filter ); + + if ( matchesName || matchesCommand ) { + mCurAgents.emplace_back( name ); + } + } + + invalidate(); + } + + const std::vector& getCurAgents() const { return mCurAgents; } + + void refresh() { setFilter( mCurFilter ); } + + protected: + const std::map& mAgents; + std::vector mCurAgents; + std::string mCurFilter; + UISceneNode* mUISceneNode; +}; + +class AgentSessionHistoryModel : public Model { + public: + enum Columns { Title, UpdatedAt, SessionId }; + + AgentSessionHistoryModel( const std::vector& sessions, + UISceneNode* uiSceneNode = nullptr ) : + mSessions( sessions ), mUISceneNode( uiSceneNode ) { + mCurSessions.reserve( mSessions.size() ); + for ( const auto& session : mSessions ) { + mCurSessions.emplace_back( &session ); + } + } + + virtual size_t rowCount( const ModelIndex& = ModelIndex() ) const override { + return mCurSessions.size(); + } + + virtual size_t columnCount( const ModelIndex& = ModelIndex() ) const override { return 3; } + + virtual std::string columnName( const size_t& column ) const override { + switch ( column ) { + case Columns::Title: + return mUISceneNode ? mUISceneNode->i18n( "title", "Title" ) : "Title"; + case Columns::UpdatedAt: + return mUISceneNode ? mUISceneNode->i18n( "updated_at", "Updated At" ) + : "Updated At"; + case Columns::SessionId: + return mUISceneNode ? mUISceneNode->i18n( "session_id", "Session ID" ) + : "Session ID"; + } + return ""; + } + + virtual Variant data( const ModelIndex& index, + ModelRole role = ModelRole::Display ) const override { + if ( role != ModelRole::Display && role != ModelRole::Custom ) + return {}; + + if ( index.row() < 0 || static_cast( index.row() ) >= mCurSessions.size() ) + return {}; + + const auto& session = *mCurSessions[index.row()]; + + if ( role == ModelRole::Custom ) { + return Variant( session.sessionId.c_str() ); + } + + switch ( index.column() ) { + case Columns::Title: { + return Variant( session.title.empty() + ? ( mUISceneNode ? mUISceneNode->i18n( "untitled_session", + "Untitled Session" ) + : "Untitled Session" ) + : session.title.c_str() ); + } + case Columns::UpdatedAt: { + return Variant( session.updatedAt.c_str() ); + } + case Columns::SessionId: { + return Variant( session.sessionId.c_str() ); + } + } + + return {}; + } + + void setFilter( const std::string& filter ) { + if ( mCurFilter == filter ) + return; + mCurFilter = filter; + mCurSessions.clear(); + + for ( const auto& session : mSessions ) { + if ( filter.empty() ) { + mCurSessions.emplace_back( &session ); + continue; + } + + bool matchesTitle = String::icontains( session.title, filter ); + bool matchesId = String::icontains( session.sessionId, filter ); + + if ( matchesTitle || matchesId ) { + mCurSessions.emplace_back( &session ); + } + } + + invalidate(); + } + + protected: + std::vector mSessions; + std::vector mCurSessions; + std::string mCurFilter; + UISceneNode* mUISceneNode; +}; + static const char* DEFAULT_PROVIDER = "google"; static const char* DEFAULT_MODEL = "gemini-2.5-flash"; @@ -202,6 +404,11 @@ static const char* DEFAULT_LAYOUT = R"xml( .llm_chat_input { border-radius: 8dp 0dp 8dp 8dp; } +.llm_thought { + background-color: var(--list-back); + padding: 4dp 4dp 4dp 8dp; + border-left: 2dp solid var(--tab-line); +} CodeEditor.llm_chat_input { font-family: monospace; border-radius: 8dp; @@ -213,7 +420,9 @@ CodeEditor.llm_chat_input { text-as-fallback: true; } .llm_chatui PushButton:hover, -.llm_chatui SelectButton:hover { +.llm_chatui SelectButton:hover, +.llm_chatui .permission_options PushButton:focus, +.llm_chatui .permission_options SelectButton:focus { border-color: var(--primary); } .llm_conversation_opt PushButton { @@ -233,16 +442,19 @@ DropDownList.role_ui { tint: var(--font); } .llm_chat_attach, -.llm_chat_select_model { +.llm_chat_select_model, +.llm_chat_select_agent { padding: 8dp 8dp 38dp 8dp; } .llm_chat_locate_input, -.llm_chat_select_model_input { +.llm_chat_select_model_input, +.llm_chat_select_agent_input { margin-bottom: 2dp; padding: 0 0 0 4dp; } .llm_chat_attach_locate, -.llm_chat_model_locate { +.llm_chat_model_locate, +.llm_chat_agent_locate { border-radius: 8dp; margin-bottom: 4dp; } @@ -254,15 +466,18 @@ DropDownList.role_ui { background-position: top 8dp right 8dp; } .llm_chatui DropDownList, -.model_ui { +.model_ui, +.agent_ui { border-color: transparent; background-color: var(--tab-back); } .llm_chatui DropDownList:hover, -.model_ui:hover { +.model_ui:hover, +.agent_ui:hover { border-color: var(--primary); } -.model_ui { +.model_ui, +.agent_ui { padding-right: 16dp; text-align: left; text-overflow: ellipsis; @@ -273,6 +488,28 @@ DropDownList.role_ui { foreground-size: 12dp 16dp; foreground-tint: var(--icon); } +.tool_permission { + background-color: var(--list-back); + border-radius: 8dp; + padding: 4dp; +} +.tool_permission .llm_conversation_opt { + background-color: var(--back); + border-radius: 4dp; +} +.tool_permission .permission_options { + margin-top: 8dp; +} +.tool_permission .permission_options > PushButton { + margin-right: 8dp; +} +.agent_config_label { + font-weight: bold; + margin-bottom: 4dp; +} +.agent_config_dropdown { + margin-bottom: 8dp; +} ]]> @@ -295,12 +532,19 @@ DropDownList.role_ui { + + + + + + + @@ -310,6 +554,18 @@ DropDownList.role_ui { )xml"; +static const char* DEFAULT_PLAN_GLOBE = R"xml( + +)xml"; + +static const char* DEFAULT_TOOL_CALL_GLOBE = R"xml( + +)xml"; + +static const char* DEFAULT_THINKING_GLOBE = R"xml( + +)xml"; + static const char* DEFAULT_CHAT_GLOBE = R"xml( @@ -327,6 +583,18 @@ static const char* DEFAULT_CHAT_GLOBE = R"xml( )xml"; +static const char* DEFAULT_PERMISSION_GLOBE = R"xml( + + + + + + + + + +)xml"; + LLMChatUI::LLMChatUI( PluginManager* manager ) : UILinearLayout(), WidgetCommandExecuter( getInput() ), mManager( manager ) { setClass( "llm_chatui" ); @@ -339,6 +607,18 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : mChatsList = findByClass( "llm_chats" ); mModelBtn = findByClass( "model_ui" ); + mModelBtn->onClick( [this]( auto ) { execute( "ai-select-model" ); } ); + mAgentBtn = findByClass( "agent_ui" ); + mAgentBtn->onClick( [this]( auto ) { execute( "ai-select-agent" ); } ); + mAgentConfigBtn = findByClass( "agent_config_ui" ); + mAgentConfigBtn->onClick( [this]( auto ) { showAgentConfigWindow(); } ); + + mChatAgentMode = find( "llm_agent_mode" ); + mChatAgentMode->on( Event::OnValueChange, [this]( auto ) { + mIsAgentMode = mChatAgentMode->isSelected(); + updateTabTitle(); + updateAgentModeUI(); + } ); mChatMore = find( "llm_more" ); mChatMore->onClick( [this]( auto ) { execute( "ai-show-menu" ); } ); @@ -426,12 +706,16 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : setCmd( "ai-prompt", [this] { // "ai-prompt-stop" - if ( mRequest ) { - if ( !mRequest->isCancelled() ) { + if ( mRequest || ( mAgentSession && mAgentSession->isPrompting() ) ) { + if ( mRequest && !mRequest->isCancelled() ) { mRequest->cancel(); return; - } else + } else if ( mAgentSession && mAgentSession->isPrompting() ) { + mAgentSession->cancel(); + return; + } else { mRequest.reset(); + } } auto chats = findAllByClass( "llm_conversation" ); @@ -440,14 +724,12 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : return; auto inputUserRole = LLMChat::stringToRole( mChatUserRole ); - if ( ( !chats.empty() && - ( mChatInput->getDocument().isEmpty() || inputUserRole != LLMChat::Role::User ) ) || - ( chats.empty() && inputUserRole != LLMChat::Role::User ) ) { - if ( chats[chats.size() - 1] - ->findByClass( "role_ui" ) - ->asType() - ->getListBox() - ->getItemSelectedIndex() != 0 ) { + if ( !mIsAgentMode && ( ( !chats.empty() && ( mChatInput->getDocument().isEmpty() || + inputUserRole != LLMChat::Role::User ) ) || + ( chats.empty() && inputUserRole != LLMChat::Role::User ) ) ) { + auto rolePicker = chats[chats.size() - 1]->findByClass( "role_ui" ); + if ( rolePicker && + rolePicker->asType()->getListBox()->getItemSelectedIndex() != 0 ) { showMsg( getUISceneNode()->i18n( "llm_last_message_must_be_from_user", "The last chat message must be from a \"User\" role" ) ); @@ -456,7 +738,12 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : } execute( "ai-add-chat" ); - doRequest(); + + if ( mIsAgentMode ) { + doAgentRequest(); + } else { + doRequest(); + } } ); setCmd( "ai-prompt-stop", [this] { @@ -500,6 +787,11 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : } ); setCmd( "ai-select-model", [this] { + if ( mIsAgentMode ) { + execute( "ai-select-agent" ); + return; + } + if ( !mLocateModelBarLayout->isVisible() ) { if ( mLocateModelTable->getModel() ) { mLocateModelInput->setText( "" ); @@ -517,6 +809,26 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : hideSelectModel(); } ); + setCmd( "ai-select-agent", [this] { + if ( !mLocateAgentBarLayout->isVisible() ) { + if ( mLocateAgentTable->getModel() ) { + mLocateAgentInput->setText( "" ); + static_cast( mLocateAgentTable->getModel() )->setFilter( "" ); + } + + showSelectAgent(); + + mLocateAgentTable->runOnMainThread( [this] { + auto model = static_cast( mLocateAgentTable->getModel() ); + if ( model ) { + mLocateAgentTable->setSelection( model->getFromName( mCurAgent ) ); + mAgentBtn->setText( mCurAgent ); + } + } ); + } else + hideSelectAgent(); + } ); + setCmd( "ai-toggle-private-chat", [this] { mChatIsPrivate = !mChatIsPrivate; @@ -670,18 +982,19 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : mChatAttach = find( "llm_attach" ); mChatAttach->onClick( [this]( auto ) { execute( "ai-show-add-context-menu" ); } ); - mModelBtn->onClick( [this]( auto ) { execute( "ai-select-model" ); } ); - if ( getPlugin() == nullptr ) return; initAttachFile(); initSelectModel(); + initSelectAgent(); auto providers = getPlugin()->getProviders(); setProviders( std::move( providers ) ); mCurModel = getDefaultModel(); + mAgents = getPlugin()->getAgents(); + AppConfig& config = getPlugin()->getPluginContext()->getConfig(); auto partition = config.iniState.getValue( "aiassistant", "split_partition", "" ); @@ -690,6 +1003,7 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : auto modelProvider = config.iniState.getValue( "aiassistant", "default_provider", "" ); auto modelName = config.iniState.getValue( "aiassistant", "default_model", "" ); + auto agentName = config.iniState.getValue( "aiassistant", "default_agent", "" ); if ( !modelProvider.empty() && !modelName.empty() ) { auto modelOpt = getModel( modelProvider, modelName ); @@ -698,6 +1012,12 @@ LLMChatUI::LLMChatUI( PluginManager* manager ) : } } + if ( !agentName.empty() && mAgents.find( agentName ) != mAgents.end() ) { + selectAgent( agentName ); + } else if ( !mAgents.empty() ) { + selectAgent( mAgents.begin()->first ); + } + fillModelDropDownList(); const auto appendShortcutToTooltip = [this]( UIPushButton* but, const std::string& cmd ) { @@ -739,6 +1059,7 @@ LLMChatUI::~LLMChatUI() { config.partition = getSplitter()->getSplitPartition(); config.modelProvider = mCurModel.provider; config.modelName = mCurModel.name; + config.agentName = mCurAgent; getPlugin()->setConfig( std::move( config ) ); } } @@ -809,6 +1130,8 @@ void LLMChatUI::showChatHistory() { return; hideAttachFile(); + hideSelectModel(); + hideSelectAgent(); static const char* CHAT_HISTORY_LAYOUT = R"xml( loadLayoutFromString( CHAT_HISTORY_LAYOUT )->asType(); + if ( mIsAgentMode ) { + win->setTitle( i18n( "ai_agent_sessions_history", "AI Agent Sessions" ) ); + } UITextInput* input = win->find( "search_input" )->asType(); UILoader* loader = win->find( "loader" )->asType(); UITableView* tv = win->find( "table" )->asType(); @@ -839,6 +1165,11 @@ void LLMChatUI::showChatHistory() { UITextInput* daysNum = win->find( "days_num" )->asType(); UIPushButton* deleteChatsBut = win->find( "delete_but" )->asType(); + if ( mIsAgentMode ) { + clearHistory->setVisible( false ); + clearHistory->setEnabled( false ); + } + win->setMinWindowSize( getUISceneNode()->getSize().getWidth() * 0.5f, getUISceneNode()->getSize().getHeight() * 0.7f ); win->setKeyBindingCommand( "closeWindow", [win, this] { @@ -855,61 +1186,89 @@ void LLMChatUI::showChatHistory() { tv->setAutoColumnsWidth( true ); tv->setFitAllColumnsToWidget( true ); - tv->setSetupCellCb( [this, tv, win, input]( UITableCell* cell ) { - if ( cell->getCurIndex().column() != ChatHistoryModel::Delete ) - return; - - cell->onClick( [this, tv, cell, win, input]( const MouseEvent* ) { - ChatHistoryModel* model = static_cast( tv->getModel() ); - auto summary = - model->data( model->index( cell->getCurIndex().row(), ChatHistoryModel::Summary ) ) - .toString(); - auto msgBox = - UIMessageBox::New( UIMessageBox::OK_CANCEL, - String::format( i18n( "confirm_del_llm_chat", - "Are you sure you want to remove \"%s?\"" ) - .toUtf8(), - summary ) ); - msgBox->setParent( win ); - msgBox->center(); - msgBox->showWhenReady(); - msgBox->on( Event::OnConfirm, [model, cell, input]( auto ) { - model->remove( cell->getCurIndex() ); - input->setFocus(); - } ); + if ( !mIsAgentMode ) { + tv->setSetupCellCb( [this, tv, win, input]( UITableCell* cell ) { + if ( cell->getCurIndex().column() == ChatHistoryModel::Delete ) { + cell->onClick( [this, tv, cell, win, input]( const MouseEvent* ) { + ChatHistoryModel* model = static_cast( tv->getModel() ); + auto summary = model + ->data( model->index( cell->getCurIndex().row(), + ChatHistoryModel::Summary ) ) + .toString(); + auto msgBox = UIMessageBox::New( + UIMessageBox::OK_CANCEL, + String::format( i18n( "confirm_del_llm_chat", + "Are you sure you want to remove \"%s?\"" ) + .toUtf8(), + summary ) ); + msgBox->setParent( win ); + msgBox->center(); + msgBox->showWhenReady(); + msgBox->on( Event::OnConfirm, [model, cell, input]( auto ) { + model->remove( cell->getCurIndex() ); + input->setFocus(); + } ); + } ); + } } ); - } ); + } input->on( Event::KeyDown, [tv, input]( const Event* event ) { tv->forceKeyDown( *event->asKeyEvent() ); input->setFocus(); } ); - input->on( Event::OnTextChanged, [tv, input]( const Event* ) { - ChatHistoryModel* model = static_cast( tv->getModel() ); - model->setFilter( input->getText().toUtf8() ); - if ( tv->getSelection().isEmpty() && !model->getCurHistory().empty() ) - tv->setSelection( model->index( 0, 0 ) ); + input->on( Event::OnTextChanged, [this, tv, input]( const Event* ) { + if ( mIsAgentMode ) { + AgentSessionHistoryModel* model = + static_cast( tv->getModel() ); + model->setFilter( input->getText().toUtf8() ); + if ( tv->getSelection().isEmpty() && model->rowCount() > 0 ) + tv->setSelection( model->index( 0, 0 ) ); + } else { + ChatHistoryModel* model = static_cast( tv->getModel() ); + model->setFilter( input->getText().toUtf8() ); + if ( tv->getSelection().isEmpty() && !model->getCurHistory().empty() ) + tv->setSelection( model->index( 0, 0 ) ); + } } ); const auto openCurrentSelectedModelItem = [this, tv] { - auto* model = static_cast( tv->getModel() ); ModelIndex index; if ( !tv->getSelection().isEmpty() ) index = tv->getSelection().first(); - else if ( !model->getCurHistory().empty() ) - index = model->index( 0, 0 ); + else if ( tv->getModel() && tv->getModel()->rowCount() > 0 ) + index = tv->getModel()->index( 0, 0 ); if ( getPlugin() == nullptr || !index.isValid() ) return; - auto* chatUI = getPlugin()->newAIAssistant(); - auto path = model->data( model->index( index.row(), ChatHistoryModel::Path ) ); - std::string data; - FileSystem::fileGet( path.toString(), data ); - nlohmann::json j = nlohmann::json::parse( data, nullptr, false ); - auto input = chatUI->unserialize( j ); - if ( !input.empty() ) - chatUI->mChatInput->getDocument().textInput( input ); - chatUI->setFocus(); + + if ( mIsAgentMode ) { + auto* model = static_cast( tv->getModel() ); + auto sessionId = + model + ->data( model->index( index.row(), AgentSessionHistoryModel::SessionId ), + ModelRole::Custom ) + .toString(); + if ( !sessionId.empty() ) { + auto* chatUI = getPlugin()->newAIAssistant(); + nlohmann::json fakePayload = { { "agent_mode", true }, + { "agent_name", mCurAgent }, + { "session_id", sessionId } }; + chatUI->unserialize( fakePayload ); + chatUI->setFocus(); + } + } else { + auto* model = static_cast( tv->getModel() ); + auto* chatUI = getPlugin()->newAIAssistant(); + auto path = model->data( model->index( index.row(), ChatHistoryModel::Path ) ); + std::string data; + FileSystem::fileGet( path.toString(), data ); + nlohmann::json j = nlohmann::json::parse( data, nullptr, false ); + auto input = chatUI->unserialize( j ); + if ( !input.empty() ) + chatUI->mChatInput->getDocument().textInput( input ); + chatUI->setFocus(); + } }; input->on( Event::OnPressEnter, [win, openCurrentSelectedModelItem]( const Event* ) { @@ -930,24 +1289,66 @@ void LLMChatUI::showChatHistory() { std::string winId = win->getId(); UISceneNode* uiSceneNode = getUISceneNode(); - getUISceneNode()->getThreadPool()->run( - [plugin, loader, input, tv, winId = std::move( winId ), uiSceneNode, clearHistory] { - std::string conversationsPath = plugin->getConversationsPath(); - auto model = std::make_shared( - ChatHistory::getHistory( conversationsPath ), uiSceneNode ); - if ( uiSceneNode->find( winId ) == nullptr ) // Window closed? - return; - tv->runOnMainThread( [tv, loader, input, model, clearHistory] { + + if ( mIsAgentMode ) { + auto listSessionsCb = [winId, uiSceneNode, tv, loader, + input]( const std::vector& sessions, + const std::optional& err ) { + uiSceneNode->runOnMainThread( [winId, uiSceneNode, tv, loader, input, sessions, err] { + auto win = uiSceneNode->find( winId ); + if ( win == nullptr ) + return; + if ( err ) { + loader->setVisible( false ); + win->asType()->closeWindow(); + return; + } + auto model = std::make_shared( sessions, uiSceneNode ); loader->setVisible( false ); input->setVisible( true ); tv->setVisible( true ); - clearHistory->setVisible( true ); tv->setModel( model ); - tv->setColumnsVisible( { ChatHistoryModel::Summary, ChatHistoryModel::DateTime, - ChatHistoryModel::Delete } ); input->setFocus(); } ); - } ); + }; + + if ( !mAgentSession ) { + setupAgentSession(); + + mAgentSession->start( [this, winId, uiSceneNode, loader, listSessionsCb]( bool ready ) { + if ( !ready ) { + uiSceneNode->runOnMainThread( [loader, winId, uiSceneNode] { + if ( uiSceneNode->find( winId ) == nullptr ) + return; + loader->setVisible( false ); + } ); + return; + } + mAgentSession->listSessions( listSessionsCb ); + } ); + } else { + mAgentSession->listSessions( listSessionsCb ); + } + } else { + getUISceneNode()->getThreadPool()->run( + [plugin, loader, input, tv, winId = std::move( winId ), uiSceneNode, clearHistory] { + std::string conversationsPath = plugin->getConversationsPath(); + auto model = std::make_shared( + ChatHistory::getHistory( conversationsPath ), uiSceneNode ); + if ( uiSceneNode->find( winId ) == nullptr ) // Window closed? + return; + tv->runOnMainThread( [tv, loader, input, model, clearHistory] { + loader->setVisible( false ); + input->setVisible( true ); + tv->setVisible( true ); + clearHistory->setVisible( true ); + tv->setModel( model ); + tv->setColumnsVisible( { ChatHistoryModel::Summary, ChatHistoryModel::DateTime, + ChatHistoryModel::Delete } ); + input->setFocus(); + } ); + } ); + } deleteChatsBut->onClick( [this, daysNum]( auto ) { int days = 0; @@ -1065,6 +1466,15 @@ bool LLMChatUI::selectModel( std::optional model ) { return false; } +bool LLMChatUI::selectAgent( const std::string& agent ) { + if ( !agent.empty() && mAgents.find( agent ) != mAgents.end() ) { + mAgentBtn->setText( agent ); + mCurAgent = agent; + updateTabTitle(); + return true; + } + return false; +} void LLMChatUI::fillModelDropDownList() { mModels.clear(); std::size_t reserve = 0; @@ -1080,6 +1490,605 @@ void LLMChatUI::fillModelDropDownList() { getUISceneNode()->getThreadPool()->run( [this] { fillApiModels(); } ); } +// Agent Picker + +void LLMChatUI::updateLocateAgentBarColumns() { + Float width = eeceil( mLocateAgentTable->getPixelsSize().getWidth() ); + width -= mLocateAgentTable->getVerticalScrollBar()->getPixelsSize().getWidth(); + mLocateAgentTable->setColumnsVisible( { 0 } ); + mLocateAgentTable->setColumnWidth( 0, eeceil( width ) ); +} + +void LLMChatUI::loadSelectAgent() { + auto ctx = getPlugin()->getPluginContext(); + mLocateAgentTable->setModel( + std::make_shared( mAgents, ctx->getUISceneNode() ) ); + + static_cast( mLocateAgentTable->getModel() ) + ->setFilter( mLocateAgentInput->getText().toUtf8() ); +} + +void LLMChatUI::showSelectAgent() { + if ( getPlugin() == nullptr ) + return; + hideAttachFile(); + hideSelectModel(); + + if ( nullptr == mLocateAgentTable->getModel() ) + loadSelectAgent(); + + static_cast( mLocateAgentTable->getModel() ) + ->setFilter( mLocateAgentInput->getText().toUtf8() ); + mLocateAgentBarLayout->setVisible( true ); + mLocateAgentInput->setFocus(); + updateLocateAgentBarColumns(); +} + +void LLMChatUI::hideSelectAgent() { + mLocateAgentBarLayout->setVisible( false ); +} + +void LLMChatUI::showAgentConfigWindow() { + if ( !mAgentSession ) + setupAgentSession(); + + static const char* AGENT_CONFIG_LAYOUT = R"xml( + + + + + )xml"; + + UIWindow* win = + getUISceneNode()->loadLayoutFromString( AGENT_CONFIG_LAYOUT )->asType(); + UILoader* loader = win->find( "loader" )->asType(); + UILinearLayout* container = win->find( "config_container" )->asType(); + + win->setMinWindowSize( getUISceneNode()->getSize().getWidth() * 0.4f, + getUISceneNode()->getSize().getHeight() * 0.3f ); + win->setKeyBindingCommand( "closeWindow", [win, this] { + win->closeWindow(); + setFocus(); + } ); + win->getKeyBindings().addKeybind( { KEY_ESCAPE }, "closeWindow" ); + win->center(); + win->getModalWidget()->addClass( "shadowbg" ); + win->on( Event::OnWindowReady, [win, loader]( auto ) { + win->setFocus(); + loader->center(); + } ); + + auto setupConfig = [this, loader, container, win]() { + auto configOptions = mAgentSession->getConfigOptions(); + if ( !configOptions.is_array() || configOptions.empty() ) { + NotificationCenter::instance()->addNotification( + i18n( "no_agent_configs", "No Config Options Available" ) ); + win->closeWindow(); + return; + } + + loader->setVisible( false ); + container->setVisible( true ); + + for ( const auto& opt : configOptions ) { + if ( !opt.is_object() || !opt.contains( "id" ) || !opt.contains( "name" ) || + !opt.contains( "options" ) || !opt["options"].is_array() ) + continue; + + std::string optId = opt["id"].get(); + std::string currentValId = opt.value( "currentValue", "" ); + std::string currentValName = ""; + + UITextView* label = UITextView::New(); + label->setParent( container ); + label->setText( opt["name"].get() ); + label->addClass( "agent_config_label" ); + + UIDropDownModelList* dropdown = UIDropDownModelList::New(); + dropdown->setParent( container ); + dropdown->setLayoutWidthPolicy( SizePolicy::MatchParent ); + dropdown->addClass( "agent_config_dropdown" ); + + std::vector> options; + int selectedIndex = -1; + int count = 0; + for ( const auto& subopt : opt["options"] ) { + if ( !subopt.is_object() || !subopt.contains( "id" ) || !subopt.contains( "name" ) ) + continue; + std::string subId = subopt["id"].get(); + std::string subName = subopt["name"].get(); + options.push_back( { subName, subId } ); + if ( subId == currentValId ) { + selectedIndex = count; + currentValName = subName; + } + count++; + } + + auto model = Models::ItemPairListOwnerModel::create( + std::move( options ) ); + dropdown->setModel( model ); + dropdown->getListView()->setColumnsVisible( { 0 } ); + dropdown->getListView()->setAutoExpandOnSingleColumn( true ); + if ( selectedIndex != -1 ) { + dropdown->setText( currentValName ); + } + + dropdown->on( Event::OnItemSelected, [this, optId, model, dropdown]( const Event* ) { + auto* listView = dropdown->getListView(); + if ( !listView || listView->getSelection().isEmpty() ) + return; + ModelIndex index = listView->getSelection().first(); + std::string subId = model->data( model->index( index.row(), 1 ) ).toString(); + + acp::SetConfigOptionRequest req; + req.sessionId = mAgentSession->getSessionId(); + req.configId = optId; + req.optionId = subId; + mAgentSession->setConfigOption( + req, [this, optId, subId]( const acp::SetConfigOptionResponse& res, + const std::optional& err ) { + if ( !err ) { + auto newOpts = res.configOptions; + if ( newOpts.empty() ) { + newOpts = acp::parseLegacyConfigOptions( + {}, mAgentSession->getConfigOptions(), optId, subId ); + } + mAgentSession->setConfigOptions( newOpts ); + } + } ); + } ); + } + }; + + if ( mAgentSession && !mAgentSession->getClient()->isReady() ) { + mAgentConfigBtn->setEnabled( false ); + mAgentSession->start( [this, setupConfig, win]( bool ready ) { + runOnMainThread( [this, setupConfig, win, ready]() { + mAgentConfigBtn->setEnabled( true ); + if ( ready && win->isVisible() ) { + setupConfig(); + } else { + if ( !ready ) { + NotificationCenter::instance()->addNotification( + i18n( "failed_to_start_agent", "Failed to start agent process." ) ); + mAgentSession.reset(); + } + win->closeWindow(); + } + } ); + } ); + } else if ( mAgentSession && mAgentSession->getClient()->isReady() ) { + setupConfig(); + } +} + +void LLMChatUI::initSelectAgent() { + mLocateAgentBarLayout = findByClass( "llm_chat_select_agent" ); + mLocateAgentInput = findByClass( "llm_chat_select_agent_input" ); + mLocateAgentTable = findByClass( "llm_chat_agent_locate" ); + mLocateAgentTable->setHeadersVisible( false ); + + mLocateAgentTable->on( Event::OnSizeChange, + [this]( const Event* ) { updateLocateAgentBarColumns(); } ); + + mLocateAgentInput->on( Event::OnTextChanged, [this]( const Event* ) { + showSelectAgent(); + updateLocateAgentBarColumns(); + } ); + mLocateAgentInput->on( Event::OnPressEnter, [this]( const Event* ) { + KeyEvent keyEvent( mLocateAgentTable, Event::KeyDown, KEY_RETURN, SCANCODE_UNKNOWN, 0, 0 ); + mLocateAgentTable->forceKeyDown( keyEvent ); + } ); + mLocateAgentInput->on( Event::KeyDown, [this]( const Event* event ) { + const KeyEvent* keyEvent = static_cast( event ); + mLocateAgentTable->forceKeyDown( *keyEvent ); + } ); + mLocateAgentBarLayout->setCommand( "close-locatebar", [this] { + hideSelectAgent(); + if ( mChatInput ) + mChatInput->setFocus(); + } ); + mLocateAgentBarLayout->getKeyBindings().addKeybindsString( { + { "escape", "close-locatebar" }, + } ); + mLocateAgentTable->on( Event::KeyDown, [this]( const Event* event ) { + const KeyEvent* keyEvent = static_cast( event ); + if ( keyEvent->getKeyCode() == KEY_ESCAPE ) + mLocateAgentBarLayout->execute( "close-locatebar" ); + } ); + mLocateAgentTable->on( Event::OnModelEvent, [this]( const Event* event ) { + const ModelEvent* modelEvent = static_cast( event ); + if ( modelEvent->getModelEventType() == ModelEventType::Open ) { + Variant vName( modelEvent->getModel()->data( + modelEvent->getModel()->index( modelEvent->getModelIndex().row(), + ACPAgentsModel::Name ), + ModelRole::Display ) ); + + if ( vName.isValid() ) { + std::string name( vName.toString() ); + if ( name != mCurAgent ) { + selectAgent( name ); + if ( mAgentSession ) { + mAgentSession->stop(); + mAgentSession.reset(); + } + updateTabTitle(); + } + } + + mLocateAgentBarLayout->execute( "close-locatebar" ); + } + } ); +} + +UIWidget* LLMChatUI::getLastConversation( bool skipEmpty ) const { + auto conversations = mChatsList->findAllByClass( "llm_conversation" ); + for ( int i = (int)conversations.size() - 1; i >= 0; --i ) { + auto* chat = conversations[i]; + if ( !chat->hasClass( "llm_plan" ) && !chat->hasClass( "llm_tool_call" ) && + !chat->hasClass( "llm_thought" ) ) { + if ( skipEmpty ) { + auto* editorNode = chat->findByClass( "data_ui" ); + if ( editorNode && editorNode->isType( UI_TYPE_CODEEDITOR ) ) { + if ( editorNode->asType()->getDocument().isEmpty() ) + continue; + } + } + return chat; + } + } + return nullptr; +} + +void LLMChatUI::removeWaitingBubble() { + auto waitingNodes = mChatsList->findAllByClass( "llm_waiting" ); + for ( auto* node : waitingNodes ) { + node->close(); + } +} + +void LLMChatUI::writeToLastChat( const std::string& text ) { + runOnMainThread( [this, text] { + removeWaitingBubble(); + + UIWidget* chatAdded = nullptr; + if ( mChatsList->getChildCount() > 0 ) { + auto* lastChild = mChatsList->getLastChild()->asType(); + if ( lastChild->hasClass( "llm_conversation" ) && !lastChild->hasClass( "llm_plan" ) && + !lastChild->hasClass( "llm_tool_call" ) && + !lastChild->hasClass( "llm_thought" ) ) { + auto* roleDDL = lastChild->findByClass( "role_ui" ); + if ( roleDDL && roleDDL->getListBox()->getItemSelectedIndex() == 1 ) { + chatAdded = lastChild; + } + } + } + + if ( nullptr == chatAdded ) { + chatAdded = addChatUI( LLMChat::Role::Assistant ); + } + + auto* editor = chatAdded->findByClass( "data_ui" ); + if ( !editor ) + return; + + auto* thinking = editor->findByClass( "thinking" ); + if ( thinking ) { + auto thinkingID = String::hash( String::format( "thinking-%p", thinking ) ); + thinking->removeActionsByTag( thinkingID ); + thinking->setVisible( false ); + } + + editor->getDocument().textInput( String::fromUtf8( text ) ); + editor->setCursorVisible( false ); + resizeToFit( editor ); + mChatScrollView->getVerticalScrollBar()->setValue( 1.0f ); + } ); +} + +void LLMChatUI::updateAgentModeUI() { + mModelBtn->setVisible( !mIsAgentMode ); + mAgentBtn->setVisible( mIsAgentMode ); + mAgentConfigBtn->setVisible( mIsAgentMode ); + mChatAdd->setVisible( !mIsAgentMode ); + mChatUserRole->setVisible( !mIsAgentMode ); + + auto chats = mChatsList->findAllByClass( "llm_conversation" ); + for ( auto chat : chats ) { + if ( auto roleUi = chat->findByClass( "role_ui" ) ) + roleUi->setVisible( !mIsAgentMode ); + if ( auto moveUp = chat->findByClass( "move_up" ) ) + moveUp->setVisible( !mIsAgentMode ); + if ( auto moveDown = chat->findByClass( "move_down" ) ) + moveDown->setVisible( !mIsAgentMode ); + if ( auto eraseBut = chat->findByClass( "erase_but" ) ) + eraseBut->setVisible( !mIsAgentMode ); + } +} + +void LLMChatUI::setupAgentSession() { + auto it = mAgents.find( mCurAgent ); + if ( it == mAgents.end() ) + return; + + acp::ACPClient::Config config; + config.command = it->second.command; + config.args = it->second.args; + config.environment = it->second.environment; + config.workingDirectory = getPlugin()->getPluginContext()->getCurrentProject(); + + mAgentSession = + std::make_unique( getUISceneNode()->getThreadPool(), config ); + + mAgentSession->onSessionUpdate = [this]( const nlohmann::json& msg ) { + auto sessionUpdate = msg.value( "sessionUpdate", "" ); + if ( sessionUpdate == "agent_message_chunk" ) { + if ( msg.contains( "content" ) && msg["content"].contains( "text" ) ) { + mThinkingBubble = nullptr; // Reset thinking bubble so next thought gets a new one + auto chunk = msg["content"].value( "text", "" ); + writeToLastChat( chunk ); + } + } else if ( sessionUpdate == "agent_thought_chunk" ) { + if ( msg.contains( "content" ) && msg["content"].contains( "text" ) ) { + auto chunk = msg["content"].value( "text", "" ); + runOnMainThread( [this, chunk] { updateThinkingBubble( chunk ); } ); + } + } else if ( sessionUpdate == "tool_call" ) { + std::string toolMarkdown = + "> 🛠️ " + i18n( "tool_call", "Tool Call: " ) + msg.value( "title", "" ) + "\n"; + addToolCallBubble( toolMarkdown ); + } 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 ); + } else if ( sessionUpdate == "available_commands_update" ) { + if ( msg.contains( "availableCommands" ) && msg["availableCommands"].is_array() ) { + runOnMainThread( [this, msg] { + mAvailableCommands.clear(); + for ( const auto& cmd : msg["availableCommands"] ) { + mAvailableCommands.push_back( + { cmd.value( "name", "" ), cmd.value( "description", "" ) } ); + } + } ); + } + } + }; + + mAgentSession->onRequestPermission = [this]( const auto& req, auto cb ) { + runOnMainThread( [this, req, cb]() { addPermissionUI( req, cb ); } ); + }; + + mAgentSession->onError = [this]( const acp::ResponseError& err ) { + NotificationCenter::instance()->addNotification( + i18n( "ai_assistant_agent_error", "Agent Error: " ) + err.message ); + }; + + mAgentSession->onTerminalCreated = [this]( const acp::CreateTerminalRequest& req, + + const std::string& termId ) { + runOnMainThread( [this, req, termId] { + find( "chat_presentation" )->setVisible( false ); + UIWidget* bubble = mChatsList->getUISceneNode()->loadLayoutFromString( + R"xml()xml", + mChatsList ); + + std::unordered_map env; + for ( const auto& e : req.env ) + env[e.name] = e.value; + + auto* uiTerm = eterm::UI::UITerminal::New( + getPlugin()->getPluginContext()->getTerminalFont(), + getPlugin()->getPluginContext()->termConfig().fontSize.asPixels( + getUISceneNode()->getPixelsSize().getWidth(), getUISceneNode()->getPixelsSize(), + getUISceneNode()->getDPI(), + getUISceneNode()->getUIThemeManager()->getDefaultFontSize() ), + Sizef( 0, 0 ), req.command, req.args, env, + req.cwd ? *req.cwd : getPlugin()->getPluginContext()->getCurrentProject(), 10000, + nullptr, false, false ); + uiTerm->setParent( bubble ); + uiTerm->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent ); + + mAgentSession->setTerminalData( termId, uiTerm ); + } ); + }; +} + +void LLMChatUI::doAgentRequest() { + if ( !mAgentSession ) { + auto it = mAgents.find( mCurAgent ); + if ( it == mAgents.end() ) { + showMsg( "Agent not configured." ); + return; + } + + mChatRun->setVisible( false )->setEnabled( false ); + mChatStop->setVisible( true )->setEnabled( true ); + + setupAgentSession(); + + UIWidget* chat = addChatUI( LLMChat::Role::Assistant ); + chat->addClass( "llm_waiting" ); + toggleEnableChats( false ); + auto* editor = chat->findByClass( "data_ui" ); + editor->setEnabled( false ); + auto* thinking = editor->findByClass( "thinking" ); + auto thinkingID = String::hash( String::format( "thinking-%p", thinking ) ); + thinking->setVisible( true ); + thinking->setPosition( { PixelDensity::dpToPx( 8 ), PixelDensity::dpToPx( 3 ) } ); + thinking->setInterval( [thinking] { thinking->rotate( 360 / 32 ); }, Seconds( 0.125 ), + thinkingID ); + + mAgentSession->start( [this]( bool ready ) { + runOnMainThread( [this, ready]() { + if ( ready ) { + sendAgentPrompt(); + } else { + mChatStop->setVisible( false )->setEnabled( false ); + mChatRun->setVisible( true )->setEnabled( true ); + toggleEnableChats( true ); + showMsg( i18n( "failed_to_start_agent", "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 ); + chat->addClass( "llm_waiting" ); + toggleEnableChats( false ); + auto* editor = chat->findByClass( "data_ui" ); + editor->setEnabled( false ); + auto* thinking = editor->findByClass( "thinking" ); + auto thinkingID = String::hash( String::format( "thinking-%p", thinking ) ); + thinking->setVisible( true ); + thinking->setPosition( { PixelDensity::dpToPx( 8 ), PixelDensity::dpToPx( 3 ) } ); + thinking->setInterval( [thinking] { thinking->rotate( 360 / 32 ); }, Seconds( 0.125 ), + thinkingID ); + + sendAgentPrompt(); + } +} + +void LLMChatUI::sendAgentPrompt() { + acp::PromptRequest req; + req.sessionId = mAgentSession->getSessionId(); + + // Create prompt from the last user message + auto lastChat = getLastConversation( true ); + if ( lastChat ) { + auto* editorNode = lastChat->findByClass( "data_ui" ); + if ( !editorNode || !editorNode->isType( UI_TYPE_CODEEDITOR ) ) { + req.prompt = { { { "type", "text" }, { "text", "" } } }; + } else { + auto* editor = editorNode->asType(); + std::string text = editor->getDocument().getText().toUtf8(); + + bool isSlashCommand = false; + bool showHelp = false; + if ( String::startsWith( text, "/" ) ) { + auto spacePos = text.find( ' ' ); + std::string cmdName = text.substr( + 1, spacePos == std::string::npos ? std::string::npos : spacePos - 1 ); + std::string arg = spacePos == std::string::npos ? "" : text.substr( spacePos + 1 ); + + auto it = std::find_if( + mAvailableCommands.begin(), mAvailableCommands.end(), + [&cmdName]( const SlashCommand& c ) { return c.name == cmdName; } ); + + if ( it != mAvailableCommands.end() ) { + req.prompt = { + { { "type", "slash_command" }, { "name", cmdName }, { "argument", arg } } }; + isSlashCommand = true; + } else { + showHelp = true; + } + } + + if ( !isSlashCommand && !showHelp ) { + req.prompt = promptToContentBlocks( text ); + } + + if ( showHelp && !mAvailableCommands.empty() ) { + std::string help = i18n( "available_commands", "Available Commands:" ) + "\n"; + for ( const auto& cmd : mAvailableCommands ) { + help += "- **/" + cmd.name + "**: " + cmd.description + "\n"; + } + help += "\n" + i18n( "type_slash_command_to_use", + "Type / followed by the command name to use it." ); + writeToLastChat( help ); + + runOnMainThread( [this] { + mChatStop->setVisible( false )->setEnabled( false ); + mChatRun->setVisible( true )->setEnabled( true ); + toggleEnableChats( true ); + auto lastChat = getLastConversation(); + if ( lastChat ) { + auto* editorNode = lastChat->findByClass( "data_ui" ); + if ( editorNode && editorNode->isType( UI_TYPE_CODEEDITOR ) ) { + auto* editor = editorNode->asType(); + auto* thinking = editor->findByClass( "thinking" ); + if ( thinking ) + thinking->setVisible( false ); + editor->setEnabled( true ); + if ( editor->hasFocus() ) + mChatInput->setFocus(); + } + } + } ); + return; + } + } + } else { + req.prompt = { { { "type", "text" }, { "text", "" } } }; + } + + mAgentSession->prompt( req, [this]( const acp::PromptResponse& res, + const std::optional& err ) { + if ( err ) { + runOnMainThread( [this] { + mChatStop->setVisible( false )->setEnabled( false ); + mChatRun->setVisible( true )->setEnabled( true ); + toggleEnableChats( true ); + auto lastChat = getLastConversation(); + if ( lastChat ) { + auto* thinking = lastChat->findByClass( "thinking" ); + if ( thinking ) + thinking->setVisible( false ); + } + } ); + return; + } + runOnMainThread( [this, res]() { + auto chat = getLastConversation(); + if ( chat ) { + auto* editor = chat->findByClass( "data_ui" ); + if ( !editor ) + return; + auto* thinking = editor->findByClass( "thinking" ); + if ( thinking ) { + auto thinkingID = String::hash( String::format( "thinking-%p", thinking ) ); + 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" ) { + if ( !mChatIsPrivate && !mSummaryRequest && mSummary.empty() ) { + generateChatName( false ); + } else { + saveChat(); + } + } + } ); + } ); +} + void LLMChatUI::resizeToFit( UICodeEditor* editor ) { Float visibleLineCount = editor->getDocumentView().getVisibleLinesCount(); Float lineHeight = editor->getLineHeight(); @@ -1119,12 +2128,53 @@ void LLMChatUI::replaceFileLinksToContents( std::string& text ) { } } +nlohmann::json LLMChatUI::promptToContentBlocks( std::string text ) { + auto j = nlohmann::json::array(); + LuaPattern ptrn( "\n```file://([^`]*)```\n?" ); + PatternMatcher::Range matches[2]; + size_t lastPos = 0; + while ( ptrn.matches( text, matches, lastPos ) ) { + // Text before the file + if ( (size_t)matches[0].start > lastPos ) { + j.push_back( { { "type", "text" }, + { "text", text.substr( lastPos, matches[0].start - lastPos ) } } ); + } + + std::string path( text.substr( matches[1].start, matches[1].length() ) ); + if ( FileSystem::isRelativePath( path ) ) { + std::string prjPath( getPlugin()->getPluginContext()->getCurrentProject() ); + path = prjPath + path; + } + + if ( FileSystem::fileExists( path ) ) { + j.push_back( { { "type", "resource_link" }, + { "name", FileSystem::fileNameFromPath( path ) }, + { "uri", "file://" + path } } ); + } + + lastPos = matches[0].end; + } + + if ( lastPos < text.size() ) { + j.push_back( { { "type", "text" }, { "text", text.substr( lastPos ) } } ); + } + + if ( j.empty() && !text.empty() ) { + j.push_back( { { "type", "text" }, { "text", text } } ); + } + + return j; +} + nlohmann::json LLMChatUI::chatToJson( bool forRequest ) { auto j = nlohmann::json::array(); auto chats = findAllByClass( "llm_conversation" ); for ( const auto& chat : chats ) { UIDropDownList* roleDDL = chat->findByClass( "role_ui" ); - UICodeEditor* codeEditor = chat->findByClass( "data_ui" ); + UIWidget* editorNode = chat->findByClass( "data_ui" ); + if ( !roleDDL || !editorNode || !editorNode->isType( UI_TYPE_CODEEDITOR ) ) + continue; + UICodeEditor* codeEditor = editorNode->asType(); std::string role = LLMChat::roleToString( static_cast( roleDDL->getListBox()->getItemSelectedIndex() ) ); auto text = codeEditor->getDocument().getText().toUtf8(); @@ -1161,13 +2211,19 @@ nlohmann::json LLMChatUI::serialize() { mTimestamp = std::chrono::system_clock::to_time_t( std::chrono::system_clock::now() ); nlohmann::json j; j["uuid"] = mUUID.toString(); - j["chat"] = serializeChat( mCurModel ); + if ( !mIsAgentMode ) { + j["chat"] = serializeChat( mCurModel ); + } j["provider"] = mCurModel.provider; j["timestamp"] = mTimestamp; j["summary"] = mSummary; std::string inputText( mChatInput->getDocument().getText().toUtf8() ); j["input"] = std::move( inputText ); j["locked"] = mChatLocked; + j["agent_mode"] = mIsAgentMode; + j["agent_name"] = mCurAgent; + if ( mAgentSession && !mAgentSession->getSessionId().empty() ) + j["session_id"] = mAgentSession->getSessionId(); return j; } @@ -1178,6 +2234,10 @@ std::string LLMChatUI::unserialize( const nlohmann::json& payload ) { mTimestamp = payload.value( "timestamp", 0 ); mSummary = payload.value( "summary", "" ); mChatLocked = payload.value( "locked", false ); + mIsAgentMode = payload.value( "agent_mode", false ); + mCurAgent = payload.value( "agent_name", "" ); + + selectAgent( mCurAgent ); std::string provider = payload.value( "provider", "" ); if ( payload.contains( "chat" ) && payload["chat"].is_object() ) { @@ -1186,13 +2246,27 @@ std::string LLMChatUI::unserialize( const nlohmann::json& payload ) { mCurModel = findModel( provider, model ); } - if ( mCurModel.name.empty() ) + if ( mCurModel.name.empty() && !mIsAgentMode ) return payload.value( "input", "" ); - if ( !selectModel( mCurModel ) ) - fillModelDropDownList(); + if ( mIsAgentMode ) { + mChatAgentMode->setSelected( true ); + updateAgentModeUI(); - if ( payload.contains( "chat" ) && payload["chat"].is_object() ) { + std::string sessionId = payload.value( "session_id", "" ); + if ( !sessionId.empty() ) { + auto it = mAgents.find( mCurAgent ); + if ( it != mAgents.end() ) { + setupAgentSession(); + mAgentSession->startLoaded( sessionId, []( bool ) {} ); + } + } + } else { + if ( !selectModel( mCurModel ) ) + fillModelDropDownList(); + } + + if ( !mIsAgentMode && payload.contains( "chat" ) && payload["chat"].is_object() ) { const auto& chat = payload["chat"]; const auto& messages = chat["messages"]; for ( const auto& chat : messages ) { @@ -1241,6 +2315,8 @@ std::string LLMChatUI::getFilePath() const { } void LLMChatUI::saveChat() { + if ( mIsAgentMode ) + return; auto plugin = getPlugin(); if ( plugin == nullptr || mChatIsPrivate ) return; @@ -1341,11 +2417,15 @@ void LLMChatUI::doRequest() { mChatStop->setVisible( true )->setEnabled( true ); UIWidget* chat = addChatUI( LLMChat::Role::Assistant ); + chat->addClass( "llm_waiting" ); toggleEnableChats( false ); // editor is not disabled (to allow copy with locked document) auto model = mCurModel; auto* editor = chat->findByClass( "data_ui" ); + if ( !editor ) + return; + // new editor must be disabled because even on locked document it's possible to move the cursor editor->setEnabled( false ); @@ -1360,14 +2440,21 @@ void LLMChatUI::doRequest() { mRequest = std::make_unique( apiUrl, apiKeyStr, serializeChat( model, true ).dump(), model.provider ); + mThinkingBubble = nullptr; mRequest->streamedResponseCb = [this, editor, thinking, thinkingID]( const std::string& chunk, bool fromReasoning ) { - if ( fromReasoning || - ( mRequest && mRequest->getResponse().empty() && allNewLines( chunk ) ) ) + if ( fromReasoning ) { + runOnMainThread( [this, chunk] { updateThinkingBubble( chunk ); } ); return; + } + + if ( mRequest && mRequest->getResponse().empty() && allNewLines( chunk ) ) + return; + auto conversation = chunk; editor->runOnMainThread( [this, conversation = std::move( conversation ), editor, thinking, thinkingID] { + mThinkingBubble = nullptr; editor->getDocument().textInput( String::fromUtf8( conversation ) ); editor->setCursorVisible( false ); thinking->removeActionsByTag( thinkingID ); @@ -1438,14 +2525,21 @@ void LLMChatUI::doRequest() { } void LLMChatUI::toggleEnableChat( UIWidget* chat, bool enabled ) { - chat->findByClass( "role_ui" )->setEnabled( enabled ); - UICodeEditor* editor = chat->findByClass( "data_ui" )->asType(); - // editor->setEnabled( enabled ); - editor->setLocked( !enabled ); - chat->findByClass( "erase_but" )->setEnabled( enabled ); - chat->findByClass( "move_up" )->setEnabled( enabled ); - chat->findByClass( "move_down" )->setEnabled( enabled ); - // chat->findByClass( "copy_contents" )->setEnabled( enabled ); + auto* roleDDL = chat->findByClass( "role_ui" ); + if ( roleDDL ) + roleDDL->setEnabled( enabled ); + auto* editorNode = chat->findByClass( "data_ui" ); + if ( editorNode && editorNode->isType( UI_TYPE_CODEEDITOR ) ) + editorNode->asType()->setLocked( !enabled ); + auto* eraseBut = chat->findByClass( "erase_but" ); + if ( eraseBut ) + eraseBut->setEnabled( enabled ); + auto* moveUp = chat->findByClass( "move_up" ); + if ( moveUp ) + moveUp->setEnabled( enabled ); + auto* moveDown = chat->findByClass( "move_down" ); + if ( moveDown ) + moveDown->setEnabled( enabled ); } void LLMChatUI::toggleEnableChats( bool enabled ) { @@ -1463,6 +2557,141 @@ Drawable* LLMChatUI::findIcon( const std::string& name, const size_t iconSize ) return nullptr; } +void LLMChatUI::addPermissionUI( const acp::RequestPermissionRequest& req, + std::function cb ) { + find( "chat_presentation" )->setVisible( false ); + + UIWidget* chat = + mChatsList->getUISceneNode()->loadLayoutFromString( DEFAULT_PERMISSION_GLOBE, mChatsList ); + + UIMarkdownView* desc = chat->findByClass( "permission_desc" ); + std::string descStr = + "##### " + + i18n( "agent_wants_to_execute_tool_call", "The agent wants to execute a tool call:" ) + + "\n\n"; + descStr += "**" + i18n( "title", "Title" ) + ":** " + req.toolCall.title + "\n\n"; + descStr += "**" + i18n( "kind", "Kind" ) + ":** `" + req.toolCall.kind + "`\n\n"; + if ( !req.toolCall.rawInput.is_null() ) { + descStr += + "**" + i18n( "input", "Input" ) + ":**\n\n" + req.toolCall.rawInput.dump( 2 ) + "\n"; + } + desc->loadFromString( descStr ); + UIWidget* optionsBox = chat->findByClass( "permission_options" ); + if ( !optionsBox ) + return; + + bool isFirst = true; + UIPushButton* firstBut = nullptr; + UIPushButton* lastBut = nullptr; + for ( const auto& opt : req.options ) { + UIPushButton* but = UIPushButton::New(); + but->setParent( optionsBox ); + but->setText( opt.name ); + + 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; + firstBut = but; + but->runOnMainThread( [but] { but->setFocus(); } ); + } + + lastBut = but; + } + + if ( lastBut && firstBut != lastBut ) { + lastBut->on( Event::OnTabNavigate, + [firstBut]( const Event* event ) { firstBut->setFocus(); } ); + } + + mChatScrollView->getVerticalScrollBar()->setValue( 1.0f ); +} + +UIWidget* LLMChatUI::addMarkdownBubble( const std::string& layout, const std::string& markdown ) { + find( "chat_presentation" )->setVisible( false ); + UIWidget* chat = mChatsList->getUISceneNode()->loadLayoutFromString( layout, mChatsList ); + if ( chat ) + chat->asType()->loadFromString( markdown ); + return chat; +} + +void LLMChatUI::addPlanBubble( const std::string& markdown ) { + runOnMainThread( [this, markdown] { + removeWaitingBubble(); + + UIWidget* bubble = nullptr; + if ( mChatsList->getLastChild() ) { + auto* lastChild = mChatsList->getLastChild()->asType(); + if ( lastChild->hasClass( "llm_plan" ) ) + bubble = lastChild; + } + + if ( !bubble ) { + bubble = addMarkdownBubble( DEFAULT_PLAN_GLOBE, markdown ); + } else { + bubble->asType()->loadFromString( markdown ); + } + mChatScrollView->getVerticalScrollBar()->setValue( 1.0f ); + } ); +} + +void LLMChatUI::addToolCallBubble( const std::string& markdown ) { + runOnMainThread( [this, markdown] { + removeWaitingBubble(); + + UIWidget* bubble = nullptr; + if ( mChatsList->getLastChild() ) { + auto* lastChild = mChatsList->getLastChild()->asType(); + if ( lastChild->hasClass( "llm_tool_call" ) ) + bubble = lastChild; + } + + if ( !bubble ) { + mCurToolCall = markdown; + bubble = addMarkdownBubble( DEFAULT_TOOL_CALL_GLOBE, mCurToolCall ); + } else { + mCurToolCall += "\n" + markdown; + bubble->asType()->loadFromString( mCurToolCall ); + } + + mChatScrollView->getVerticalScrollBar()->setValue( 1.0f ); + } ); +} + +void LLMChatUI::updateThinkingBubble( const std::string& chunk ) { + runOnMainThread( [this, chunk] { + removeWaitingBubble(); + + UIWidget* bubble = nullptr; + if ( mChatsList->getLastChild() ) { + auto* lastChild = mChatsList->getLastChild()->asType(); + if ( lastChild->hasClass( "llm_thought" ) ) { + bubble = lastChild; + } + } + + if ( !bubble ) { + mCurThinking = ""; + bubble = addMarkdownBubble( DEFAULT_THINKING_GLOBE, mCurThinking ); + } + + mCurThinking += chunk; + bubble->asType()->loadFromString( mCurThinking ); + mChatScrollView->getVerticalScrollBar()->setValue( 1.0f ); + } ); +} + UIWidget* LLMChatUI::addChatUI( LLMChat::Role role ) { find( "chat_presentation" )->setVisible( false ); @@ -1537,6 +2766,13 @@ UIWidget* LLMChatUI::addChatUI( LLMChat::Role role ) { i18n( "chat_copied", "Chat Copied" ) ); } ); resizeToFit( editor ); + if ( mIsAgentMode ) { + chat->findByClass( "role_ui" )->setVisible( false ); + chat->findByClass( "erase_but" )->setVisible( false ); + chat->findByClass( "move_up" )->setVisible( false ); + chat->findByClass( "move_down" )->setVisible( false ); + } + return chat; } @@ -1551,9 +2787,8 @@ void LLMChatUI::addChat( LLMChat::Role role, std::string conversation ) { } void LLMChatUI::removeLastChat() { - auto chats = mChatsList->findAllByClass( "llm_conversation" ); - if ( !chats.empty() ) { - auto* chat = chats[chats.size() - 1]; + auto* chat = getLastConversation(); + if ( chat ) { auto* editor = chat->findByClass( "data_ui" ); if ( editor->getDocument().isEmpty() ) chat->close(); @@ -1611,6 +2846,9 @@ void LLMChatUI::updateTabTitle() { return; UITab* tab = reinterpret_cast( getData() ); auto title = i18n( "ai_assistant", "AI Assistant" ); + if ( mIsAgentMode && !mCurAgent.empty() ) { + title = i18n( "agent", "Agent" ) + ": " + mCurAgent; + } if ( !mSummary.empty() ) title += " - " + mSummary; tab->setText( title ); @@ -1667,6 +2905,7 @@ void LLMChatUI::showAttachFile() { if ( getPlugin() == nullptr ) return; hideSelectModel(); + hideSelectAgent(); auto text = mLocateInput->getText(); auto ctx = getPlugin()->getPluginContext(); if ( !ctx->isDirTreeReady() ) { @@ -1825,6 +3064,7 @@ void LLMChatUI::showSelectModel() { if ( getPlugin() == nullptr ) return; hideAttachFile(); + hideSelectAgent(); if ( nullptr == mLocateModelTable->getModel() ) loadSelectModel(); diff --git a/src/tools/ecode/plugins/aiassistant/chatui.hpp b/src/tools/ecode/plugins/aiassistant/chatui.hpp index 0f3c2c73f..bcc849501 100644 --- a/src/tools/ecode/plugins/aiassistant/chatui.hpp +++ b/src/tools/ecode/plugins/aiassistant/chatui.hpp @@ -1,6 +1,7 @@ #pragma once #include "../pluginmanager.hpp" +#include "acp/agentsession.hpp" #include "llmchatcompletionrequest.hpp" #include "protocol.hpp" @@ -91,6 +92,8 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { bool chatExistsInDisk() const; + const std::string& getCurAgent() const { return mCurAgent; } + protected: UUID mUUID; std::string mSummary; @@ -109,8 +112,11 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { UIPushButton* mRefreshModels{ nullptr }; UIPushButton* mChatAttach{ nullptr }; UISelectButton* mChatPrivate{ nullptr }; + UISelectButton* mChatAgentMode{ nullptr }; UIScrollView* mChatScrollView{ nullptr }; UIPushButton* mModelBtn{ nullptr }; + UIPushButton* mAgentBtn{ nullptr }; + UIPushButton* mAgentConfigBtn{ nullptr }; // Locate file UIVLinearLayoutCommandExecuter* mLocateBarLayout{ nullptr }; @@ -122,13 +128,33 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { UITextInput* mLocateModelInput{ nullptr }; UITableView* mLocateModelTable{ nullptr }; + // Select agent + UIVLinearLayoutCommandExecuter* mLocateAgentBarLayout{ nullptr }; + UITextInput* mLocateAgentInput{ nullptr }; + UITableView* mLocateAgentTable{ nullptr }; + std::unique_ptr mRequest; std::unique_ptr mSummaryRequest; LLMProviders mProviders; LLMModel mCurModel; std::vector mModels; + + std::map mAgents; + std::string mCurAgent; + + struct SlashCommand { + std::string name; + std::string description; + }; + std::vector mAvailableCommands; + + std::unique_ptr mAgentSession; + UIWidget* mThinkingBubble{ nullptr }; + std::string mCurThinking; + std::string mCurToolCall; int mPendingModelsToLoad{ 0 }; bool mChatIsPrivate{ false }; + bool mIsAgentMode{ false }; bool mChatLocked{ false }; bool mLinkMode{ false }; std::vector mNewModels; @@ -151,6 +177,10 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { void doRequest(); + void doAgentRequest(); + + void sendAgentPrompt(); + void toggleEnableChat( UIWidget* chat, bool enabled ); void toggleEnableChats( bool enabled ); @@ -159,18 +189,49 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { UIWidget* addChatUI( LLMChat::Role role ); + UIWidget* addMarkdownBubble( const std::string& layout, const std::string& markdown ); + + void addPlanBubble( const std::string& markdown ); + + void addToolCallBubble( const std::string& markdown ); + + void addThinkingBubble(); + + void updateThinkingBubble( const std::string& chunk ); + + void addPermissionUI( const acp::RequestPermissionRequest& req, + std::function cb ); + void fillApiModels(); String getModelDisplayName( const LLMModel& model ) const; bool selectModel( std::optional model ); + bool selectAgent( const std::string& agent ); + void fillModelDropDownList(); + void loadSelectAgent(); + + void showSelectAgent(); + + void initSelectAgent(); + + void hideSelectAgent(); + + void updateLocateAgentBarColumns(); + + void updateAgentModeUI(); + + void setupAgentSession(); + void resizeToFit( UICodeEditor* editor ); void addChat( LLMChat::Role role, std::string conversation ); + void writeToLastChat( const std::string& text ); + void removeLastChat(); void setProviders( LLMProviders&& providers ); @@ -214,13 +275,21 @@ class LLMChatUI : public UILinearLayout, public WidgetCommandExecuter { void hideSelectModel(); + void showAgentConfigWindow(); + void insertFileToDocument( std::string path, std::shared_ptr cdoc ); void replaceFileLinksToContents( std::string& text ); + nlohmann::json promptToContentBlocks( std::string text ); + void generateChatName( bool isRenaming ); void regenerateChatName(); + + void removeWaitingBubble(); + + UIWidget* getLastConversation( bool skipEmpty = false ) const; }; } // namespace ecode diff --git a/src/tools/ecode/plugins/aiassistant/protocol.hpp b/src/tools/ecode/plugins/aiassistant/protocol.hpp index 9166bf2f2..cba36436b 100644 --- a/src/tools/ecode/plugins/aiassistant/protocol.hpp +++ b/src/tools/ecode/plugins/aiassistant/protocol.hpp @@ -4,6 +4,7 @@ #include #include #include +#include namespace ecode { @@ -41,4 +42,14 @@ struct LLMProvider { using LLMProviders = std::map; -} // namespace ecode +struct ACPAgent { + bool enabled{ true }; + std::string name; + std::string command; + std::vector args; + std::unordered_map environment; +}; + +using ACPAgents = std::map; + +} // namespace ecode \ No newline at end of file