From db54ae50b23c9935ee0d816cdedd8d9ac8655aa6 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Wed, 28 Jan 2026 17:34:44 -0800 Subject: [PATCH] Initial commit: Moltbot Windows Hub monorepo - Moltbot.Tray: Windows system tray companion - Moltbot.Shared: Gateway client library - Moltbot.CommandPalette: PowerToys Command Palette extension Consolidated from clawdbot-windows-tray, clawdbot-shared, and moltbot-commandpalette-extension repos. --- .gitignore | 345 +++++++ LICENSE | 21 + README.md | 109 +++ moltbot-windows-hub.slnx | 9 + .../Assets/LockScreenLogo.scale-200.png | Bin 0 -> 732 bytes .../Assets/SplashScreen.scale-200.png | Bin 0 -> 7628 bytes .../Assets/Square150x150Logo.scale-200.png | Bin 0 -> 2928 bytes .../Assets/Square44x44Logo.scale-200.png | Bin 0 -> 1037 bytes ...x44Logo.targetsize-24_altform-unplated.png | Bin 0 -> 475 bytes .../Assets/StoreLogo.png | Bin 0 -> 766 bytes .../Assets/Wide310x150Logo.scale-200.png | Bin 0 -> 3364 bytes .../Directory.Build.props | 10 + .../Directory.Packages.props | 18 + .../Moltbot.CommandPalette.csproj | 101 ++ src/Moltbot.CommandPalette/Moltbot.cs | 35 + .../MoltbotCommandsProvider.cs | 29 + .../Package.appxmanifest | 80 ++ .../Pages/MoltbotPage.cs | 513 +++++++++++ src/Moltbot.CommandPalette/Program.cs | 44 + .../Properties/launchSettings.json | 11 + src/Moltbot.CommandPalette/app.manifest | 20 + src/Moltbot.CommandPalette/nuget.config | 12 + src/Moltbot.Shared/IMoltbotLogger.cs | 35 + src/Moltbot.Shared/Models.cs | 186 ++++ src/Moltbot.Shared/Moltbot.Shared.csproj | 11 + src/Moltbot.Shared/MoltbotGatewayClient.cs | 862 ++++++++++++++++++ src/Moltbot.Tray/.gitignore | 345 +++++++ src/Moltbot.Tray/AutoStartManager.cs | 59 ++ src/Moltbot.Tray/DEVELOPMENT.md | 167 ++++ src/Moltbot.Tray/DeepLinkHandler.cs | 136 +++ src/Moltbot.Tray/GlobalHotkey.cs | 93 ++ src/Moltbot.Tray/ISSUE_TEMPLATE/bug_report.md | 45 + .../ISSUE_TEMPLATE/feature_request.md | 32 + src/Moltbot.Tray/IconHelper.cs | 95 ++ src/Moltbot.Tray/LICENSE | 21 + src/Moltbot.Tray/Logger.cs | 106 +++ src/Moltbot.Tray/Moltbot.Tray.csproj | 39 + src/Moltbot.Tray/NotificationHistoryForm.cs | 161 ++++ src/Moltbot.Tray/Program.cs | 34 + src/Moltbot.Tray/QuickSendDialog.cs | 135 +++ src/Moltbot.Tray/README.md | 225 +++++ src/Moltbot.Tray/SettingsDialog.cs | 377 ++++++++ src/Moltbot.Tray/SettingsManager.cs | 153 ++++ src/Moltbot.Tray/StatusDetailForm.cs | 183 ++++ src/Moltbot.Tray/TrayApplication.cs | 805 ++++++++++++++++ src/Moltbot.Tray/WebChatForm.cs | 176 ++++ src/Moltbot.Tray/build.bat | 48 + src/Moltbot.Tray/moltbot.ico | Bin 0 -> 488 bytes src/Moltbot.Tray/screenshots/README.md | 26 + src/Moltbot.Tray/workflows/build.yml | 86 ++ 50 files changed, 5998 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 moltbot-windows-hub.slnx create mode 100644 src/Moltbot.CommandPalette/Assets/LockScreenLogo.scale-200.png create mode 100644 src/Moltbot.CommandPalette/Assets/SplashScreen.scale-200.png create mode 100644 src/Moltbot.CommandPalette/Assets/Square150x150Logo.scale-200.png create mode 100644 src/Moltbot.CommandPalette/Assets/Square44x44Logo.scale-200.png create mode 100644 src/Moltbot.CommandPalette/Assets/Square44x44Logo.targetsize-24_altform-unplated.png create mode 100644 src/Moltbot.CommandPalette/Assets/StoreLogo.png create mode 100644 src/Moltbot.CommandPalette/Assets/Wide310x150Logo.scale-200.png create mode 100644 src/Moltbot.CommandPalette/Directory.Build.props create mode 100644 src/Moltbot.CommandPalette/Directory.Packages.props create mode 100644 src/Moltbot.CommandPalette/Moltbot.CommandPalette.csproj create mode 100644 src/Moltbot.CommandPalette/Moltbot.cs create mode 100644 src/Moltbot.CommandPalette/MoltbotCommandsProvider.cs create mode 100644 src/Moltbot.CommandPalette/Package.appxmanifest create mode 100644 src/Moltbot.CommandPalette/Pages/MoltbotPage.cs create mode 100644 src/Moltbot.CommandPalette/Program.cs create mode 100644 src/Moltbot.CommandPalette/Properties/launchSettings.json create mode 100644 src/Moltbot.CommandPalette/app.manifest create mode 100644 src/Moltbot.CommandPalette/nuget.config create mode 100644 src/Moltbot.Shared/IMoltbotLogger.cs create mode 100644 src/Moltbot.Shared/Models.cs create mode 100644 src/Moltbot.Shared/Moltbot.Shared.csproj create mode 100644 src/Moltbot.Shared/MoltbotGatewayClient.cs create mode 100644 src/Moltbot.Tray/.gitignore create mode 100644 src/Moltbot.Tray/AutoStartManager.cs create mode 100644 src/Moltbot.Tray/DEVELOPMENT.md create mode 100644 src/Moltbot.Tray/DeepLinkHandler.cs create mode 100644 src/Moltbot.Tray/GlobalHotkey.cs create mode 100644 src/Moltbot.Tray/ISSUE_TEMPLATE/bug_report.md create mode 100644 src/Moltbot.Tray/ISSUE_TEMPLATE/feature_request.md create mode 100644 src/Moltbot.Tray/IconHelper.cs create mode 100644 src/Moltbot.Tray/LICENSE create mode 100644 src/Moltbot.Tray/Logger.cs create mode 100644 src/Moltbot.Tray/Moltbot.Tray.csproj create mode 100644 src/Moltbot.Tray/NotificationHistoryForm.cs create mode 100644 src/Moltbot.Tray/Program.cs create mode 100644 src/Moltbot.Tray/QuickSendDialog.cs create mode 100644 src/Moltbot.Tray/README.md create mode 100644 src/Moltbot.Tray/SettingsDialog.cs create mode 100644 src/Moltbot.Tray/SettingsManager.cs create mode 100644 src/Moltbot.Tray/StatusDetailForm.cs create mode 100644 src/Moltbot.Tray/TrayApplication.cs create mode 100644 src/Moltbot.Tray/WebChatForm.cs create mode 100644 src/Moltbot.Tray/build.bat create mode 100644 src/Moltbot.Tray/moltbot.ico create mode 100644 src/Moltbot.Tray/screenshots/README.md create mode 100644 src/Moltbot.Tray/workflows/build.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ae99a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,345 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these files may be extracted +*.azurePubxml + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment the next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +CrystalDecisions.ReportingServices.ViewerObjectModel.dll + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4c7463e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Scott Hanselman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..776bae4 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# ๐Ÿฆž Moltbot Windows Hub + +A Windows companion suite for [Moltbot](https://moltbot.com) - the AI-powered personal assistant. + +## Projects + +This monorepo contains three projects: + +| Project | Description | +|---------|-------------| +| **Moltbot.Tray** | System tray application for quick access to Moltbot | +| **Moltbot.Shared** | Shared gateway client library | +| **Moltbot.CommandPalette** | PowerToys Command Palette extension | + +## ๐Ÿš€ Quick Start + +### Prerequisites +- .NET 9.0 SDK +- Windows 10/11 +- PowerToys (for Command Palette extension) + +### Build +```bash +dotnet build +``` + +### Run Tray App +```bash +dotnet run --project src/Moltbot.Tray +``` + +## ๐Ÿ“ฆ Moltbot.Tray + +Windows system tray companion that connects to your local Moltbot gateway. + +### Features +- ๐Ÿฆž Lobster icon in system tray (connected/disconnected states) +- ๐Ÿ’ฌ Quick Send - Send messages via global hotkey (Ctrl+Shift+M) +- ๐ŸŒ Web Chat - Embedded chat window +- ๐Ÿ“Š Status Display - View sessions and channels +- ๐Ÿ”” Toast Notifications - Clickable Windows notifications +- ๐Ÿš€ Auto-start with Windows +- โš™๏ธ Settings management + +### Mac Parity Status + +| Feature | Mac | Windows | +|---------|-----|---------| +| System tray icon | โœ… | โœ… | +| Connection status | โœ… | โœ… | +| Quick send hotkey | โœ… | โœ… | +| Web chat window | โœ… | โœ… | +| Toast notifications | โœ… | โœ… | +| Auto-start | โœ… | โœ… | +| Session display | โœ… | โœ… | +| Channel health | โœ… | โœ… | +| Deep links | โœ… | ๐Ÿ”„ | + +## ๐Ÿ“ฆ Moltbot.CommandPalette + +PowerToys Command Palette extension for quick Moltbot access. + +### Commands +- **๐Ÿฆž Open Dashboard** - Launch web dashboard +- **๐Ÿ’ฌ Quick Send** - Send a message +- **๐Ÿ“Š Full Status** - View gateway status +- **โšก Sessions** - View active sessions +- **๐Ÿ“ก Channels** - View channel health +- **๐Ÿ”„ Health Check** - Trigger health refresh + +### Installation +1. Build the solution in Release mode +2. Deploy the MSIX package via Visual Studio +3. Open Command Palette (Win+Alt+Space) +4. Type "Moltbot" to see commands + +## ๐Ÿ“ฆ Moltbot.Shared + +Shared library containing: +- `MoltbotGatewayClient` - WebSocket client for gateway protocol +- `IMoltbotLogger` - Logging interface +- Data models (SessionInfo, ChannelHealth, etc.) + +## Development + +### Project Structure +``` +moltbot-windows-hub/ +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ Moltbot.Shared/ # Shared gateway library +โ”‚ โ”œโ”€โ”€ Moltbot.Tray/ # System tray app +โ”‚ โ””โ”€โ”€ Moltbot.CommandPalette/ # PowerToys extension +โ”œโ”€โ”€ moltbot-windows-hub.sln +โ”œโ”€โ”€ README.md +โ”œโ”€โ”€ LICENSE +โ””โ”€โ”€ .gitignore +``` + +### Configuration + +Settings are stored in: +- Tray: `%APPDATA%\MoltbotTray\settings.json` +- Logs: `%APPDATA%\MoltbotTray\moltbot-tray.log` + +Default gateway: `ws://localhost:18789` + +## License + +MIT License - see [LICENSE](LICENSE) diff --git a/moltbot-windows-hub.slnx b/moltbot-windows-hub.slnx new file mode 100644 index 0000000..a98cf65 --- /dev/null +++ b/moltbot-windows-hub.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/Moltbot.CommandPalette/Assets/LockScreenLogo.scale-200.png b/src/Moltbot.CommandPalette/Assets/LockScreenLogo.scale-200.png new file mode 100644 index 0000000000000000000000000000000000000000..37b5ec99eeea36629b966dd4666ac41b44cacc1a GIT binary patch literal 732 zcmbV~O-lk%7=`cDBq~{HC1sS;7no8yNKr|aWtj;k*(lK`nw4px1%1#!OAFe!>92^3 zHbILv{e{;4h0e85(E3JD=y zN5i4b8}!fT$t%P`hXE{T6X47m0JX${m<*I)8%?ZkjzL5ZSM~LYOAK)ir)~+ljaM5? zkA_r#R_ zj~5IP(+QXiW{SK6o|P7+#+)jeJ`3E7#?F$kqQ6(f5HGb$VcF}XyE5(tWgl(ot-vrH zp&uNfLQjIIW`$FFtB4>SX_s_a;lr!JBE1E)1G~AJ5yKK4c6aj19LME0vRu-(=yrRx z)1OPmFx^aB0eIvHfZ8}fQ==J=x&AwUpY*4FJw9NTOv*ejA0ozc8b}}44`*Q-6ZD}V u`VT-CaGGnMUmfV+8d}PuNEw69Gt=E{p^Mz(X&I5k|5Zen<6-p01K=CR&8pb| literal 0 HcmV?d00001 diff --git a/src/Moltbot.CommandPalette/Assets/SplashScreen.scale-200.png b/src/Moltbot.CommandPalette/Assets/SplashScreen.scale-200.png new file mode 100644 index 0000000000000000000000000000000000000000..c192fb8035e8c643fd8b32319fe38d06d2576e86 GIT binary patch literal 7628 zcmdT}TW?fV6yDRJ(*h2lV$~_sIxQv!8&H^mhL#L62vthe5H7aBJe0I#rjR~}TqHc0 z_!D^W2l!}Wv_>BYFMZ&}#7E)LKfqt``}Vi@xy?D#sWpO=eOY_$wbovHUG~}M%(F)i zKRt7@xTUnk7*m`&RsY zd3G3C^rmVi(sLweJvt#M1N{QXcZy1r3^g;*U1)h2oHpUbE;jr;rC0mR zE^HU&^4%pWa(@}km#9Sr_+fwps3>`LYDrsW#+sf%w!;|b&})`y>n8eJgfq}7T%u2z zSjRe8ZI4?L;G`BudnG6E={M*PpzaG4o4cf`n+xjEo-}{{F!`>AZ#u#ra|DQmrag@S zH8qWPFc4K96tX}I0BoTiGRsASSg>hG402iu0x-=i(uSv3R@rS*wbfrlPSmB*PtZmo=ZrDt`8$07qGD0yyX5kndRWgjj3 z^fwLEx@9nU8I@YBwdqp2b;zA+pt0@QCe36D+)%Fu*0$>|Plv{@&Apl`07#S;H&Zf* zRN9mXb=KiT(HJGA1fa(c_&9626e$l{3LhJ{F@=)J+Lz5*qob@EPR4pk9h)i74tcGC zDi}1uj@%|AJF4Z~7CieuCLidd75vi4o!pKatbH|LItt)EPzr`oOaRq+#n>EWghyBX z>$_hw{BRJI#Yjrkt3;qyY-HA}kvW1W3@+pdGTbLJ^%>*+W4%m8B?kF*CZx-zKChs< z#G@V=gdOA|r^3BJLW-`7i9Kc5p^a>Hr9a(TwK*)5X051XkQ4(;TKiXmp`By;#tjbi zH90y61)y3M`fKWQFhk3rUbjJX~(RvjoiCQRTzdJoHVLOwR{0mErY<1;uOc>t0 zOZU7Y}urbF>RtcvA<4)N>WdpmQPjhi-?%6hj>2bv+|?&gYedk~pVa3} zO@MuF-wepcM{X>P%%ypvf%om>DZ2;D9B_!1t3wYlTqz)Xb6<*{QaSV5x8Bm&JEWTMF18wcPL z!kB(nFaVH|T_}iHHx5VaQRsOaUW+_KcI<+ZBAF@dAJ__t#8VveDjk)JZmvX;sP0)r z%i~%~C+XcSaFK4zn2tN0^L6{SOE*;#;unzfGL=>`h-$p6ky)@EQIhX1fLAQ1q|~zu8HRg$wNqt7$56u z1rW|;6ZYExr6y7!rpW5bkW>$dcxEI>AU{DAlZ0u1(71fybUTxkJht8PcF$nV=9PzG zc0Bo>Uj|UDGnkYX*4b;{uj#sghHuyyhi3r>B~n;qlr#z(Qi*85KcJd;!SY{VD86-3 z2cO6a8SI$tiE(wwG_XFV$G?o5M(>hz(JhPLad=+sLcO91-qjZKhDpF3^rE_5UIKV& z9F02+0PzS#$wji|noy$=Kl8hxTngV{+8$NQ#SS?UQjt6&9m6au`VX6JKA@vi{O8xp z0cM)V2+AGQn`Z)apH-3L$%$>c@SfPoFUH6L{1}?}VLI~Xiq$zAo#64*33M0ovFh(X z?lO%ftVO?VwJJ5}SN$0Tpx1u=<_o{ShFNVfW50oCP9Q_lIW!UH|P^(D0 yh*oJzZ*V97n>D&}?*?u4->tD@vyE@)JHu~FC?8)Rnn1^>oH}ua*P;&_X#NKK=wYP* literal 0 HcmV?d00001 diff --git a/src/Moltbot.CommandPalette/Assets/Square150x150Logo.scale-200.png b/src/Moltbot.CommandPalette/Assets/Square150x150Logo.scale-200.png new file mode 100644 index 0000000000000000000000000000000000000000..17b5826340d95afec408e5a76de8fd0b7cba9312 GIT binary patch literal 2928 zcmai0-)j_C6rNpYlI$+=M>LA7#Fw2EcV_h=HI`t58)J2|B*x&5!&2(tk7l&mHZ6j& zjf!9?f-j*@1z-D6`k=l_Dl|_brHe>kL{@U@$Im^oUY<)eLSzLPeWaX-5^`NUwR!?0+4|#QGVb{scVt;~e zaC2O8(PxhILYL8Ik3fy}oq~qUp}J zyG&i?T{T2|(jI~QVrISJ~z=t?Y3(F@Y;N#?#?T|U2hcT~o;=YPT2iB>{0m`JNJ zCCW9ZMH!3!@BTQ9;k`d5lk;1{n;39!2bVTNx0WMuzJBql@YwHW0fTtB*jYqBS3v+#Ekv={RSy z!8GJTb?1`}z>^Q4L?XvmCy*5?QYdkyZKuLz1Y;Z)0Z}iBa;z;R!Bf}S5aib~B>_Z@ zTscEtHi+YaUF)_UtOcnSx`No1uqy;1k$;*O620POww=atQ=Ig6h@?q)vTrT~kZb!X zpM*dsDHP`5CQ}&|4S#!6DzZEz*%a*{!+~FgwhU^+^3*BmTLhOIv!1LRA@XWJyj+SG z`mEF-V3Z^qGRYi|UtsI+-WS5gDB4*$;_5A_rfQOvlr3MrAoCxjbkzCm1sK2-#~8tUxf5mA>#@ge|Ar-%ufa8kSV zL-SZ2f+Y`Fg?myjqFU<;IbBU4nH7T6rpM?~EI`4(x6Ixp4BGKS{_=GPX>(#^2MB6~ zxnwg2Vv~Ml;cG{vG2qajmgA*3pA{&0Av#{CYUc^Ycvu}VQ&u5JijZsa&5GP~E=LcwrWRbCSgyos_y~eosT~N literal 0 HcmV?d00001 diff --git a/src/Moltbot.CommandPalette/Assets/Square44x44Logo.scale-200.png b/src/Moltbot.CommandPalette/Assets/Square44x44Logo.scale-200.png new file mode 100644 index 0000000000000000000000000000000000000000..a1bfb0b0b034c73cad9e816056da39557f1446b4 GIT binary patch literal 1037 zcmb7@-A)rx6opTJ7!*4Stw1S4nwDTRfCVWe6p)r_Yb{iRg#ay5OjWQ@1_*^zLVN{p zd;k-lL2d|lUK=k>jJ^S{;I}=E-fFVexAr-6W@gWsnahjwC%LrQ5@=CMnVE;y0=X={ zV!*x2i}T2+Qcf#l#q-CE(AQ6GaK>-J*!PdtYpaLq^Lyak1a9BRgL`B*{-YCW1IW7d5g+hbjelS7{!M`)V})nppOX$Hu^&@+nlqc zp#VnGRy5MuMN~>^MBV~Y<&ZAqEUV~hYz_m%2>SL+&rCmczz^ndG@>B-w@a`vc%)Al zuj&lT`0zlU6v->0&&2OO5oz1eNajyg;C;qRKoIgLjlW%0T43M#+Grlw^TPWX2cUdTV7PFs1l&-#XOs+R- z9dcKB>1a~N*bVaWp*$tpTGhGJyA)}x%SGmonVi=Lwh4K@KJ3W#I{eS{ylh>tjUQZP Lk~#U06D9BqtddJ9 literal 0 HcmV?d00001 diff --git a/src/Moltbot.CommandPalette/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/src/Moltbot.CommandPalette/Assets/Square44x44Logo.targetsize-24_altform-unplated.png new file mode 100644 index 0000000000000000000000000000000000000000..2791be249375d9a884e60622c97690a281a2e7cd GIT binary patch literal 475 zcmZXRy-LGi6ozA~v5iSY$xu6JiqNGL6r`fG(AI<+lu$%${V9ZqU?-&owTR&8;w|VE zxanAW6XGC_PANKZWJa9Q;dnH>t^idA zUTy#8^NnY8h*un4J;2>_Fpnp2-EKjY37smTf8A%@R*h;4H}HS8|Fmx7aE4ZBfPNBO zssl1uF4nv=VRcG%<$#SN^8gk$!Js`2WxO0m7dW|Wf4q?T`-5s86Bb^=dDnA@bZV|Z z^k)Gomw_z0FpxWXS2>u*s~An^{&{-!2&H0&^pC)aYOE29UC_(L q3DxQq<8>;TEE|{V}3tBlKdkr@I#qbkH9axZWML^ literal 0 HcmV?d00001 diff --git a/src/Moltbot.CommandPalette/Assets/StoreLogo.png b/src/Moltbot.CommandPalette/Assets/StoreLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..3130b9780d51757c09b0e166c379bb20b9e2f1e4 GIT binary patch literal 766 zcma))%Sr-a6vsc4!nC33Vhkc;Nl;WYYDVF$nK3UdveGsYr5Q?#=tf}_)+6)`(xzRs z2?B8wwQw6Pf}WvQ=zlt9?F!EM{m%`*IiKH0y|%QNkgN`?5JE~NV=GV}xupYiML+5_ zm=Go~71Oe1E`WhUYRK{YsnMzpBRb~H$+FM#b_Fdx-R4uAGea3x_t|RWZXB^x04BNU z4F-eJUe)hMI>@C83p2=~zu$!E$a7AYS<>_+nlAvxU#bg`PHs#)pzE7JU-Ls>9*|sk z9pb7PF_IMRKWd<(WbD-GjF|tsv(CF`Fqb*=ZVrPRRdN0lAfM}Ff zC@NI!l*ec-x;R%Jfa!Bi74lkjH-Co9Kgk*jlAv7URI*!83Q|+b3xA->U*P+8_FQ8`gb>oq?98`&W@l#C zyX)<%o6nt@aRx>Plv2*@Q?(bAN_Uh>J&?75d~#&_>Ly63e#qI{<8$u|09knlVt!cc zXX{7WS*VA>N5QM$-#&i`@_w*T{m1p+H{Tu;AkVa)By#F_skLltj2jr(DG@n-;3Rs zA>FwYq+`~a%TPQBVgIuJvDpywFol-e(*ALYLvDX1R=Tgq8T0IHSP*v8=?S5091QYo zX(2ONkNaw-3sD1)-UspXI>Z84C>#xY>-+f=L60b^83kA#p}VE|b>p=FLa@-E1TzJ_ zNN8Exk*sG{fxV!HRVy}~d%`VOw19d-2vn~69rC5A7BqWB5|xuLD&A{o=*b?RT3G5gV5OmGqDsVhd0HdbEJHeWOlC?3gKRHIQ$Y_Fipih7$cPQGWRLh6avK-7m zEX&y$nEr|_FTwC#hB*wi88ud|>1N8%X!#-A1VFtePB)z@x?+=-W8=BA`5VP)!jYgL zbFL()UzW*ap6}9`o>M_7yrHRo2yy4)#^f|wvvldwOU0zmrn{GVMzJf<2JFNF#?jL&rFqa7W& zG6cho{OD@M@~uzzH1&^7Hg~4RrEXZ)FSl~xe%Je+`^n5ug$fF?CtOHNjib}b0bC#I za$ZMABG6A4wWnA}7!p>J-Z@Ju8XRm8H;fL@Kf$E{h}fFu1`eHcbIoo^K6)L^YHeXj zEIw9p#R+qv!9dkGo8DhE!9u*y7(9^*%4N*a-f)3Ch;@=&qZuSY*q~N42g%m~+&i2R z?L%+m;7S9OfPpZAM8H5={`C{dL&N%v)@PRq*o1&i=Be)-{WjoC?*2rnnog~lfG{j{ z2P3%nB4r^ggBnm-V$jV?(hi~-?5>KDNBbF4lfrW-4As- zKtz$1zbk>(Sx=XzgW9KS0PWhhX7?s*?a4m(VSZ%43wh_W4M=Xr0@vsmSZM2O{K^(w zF&3HX*z>U`um4g_Jb2CH^&G!As@{6VQnxO@3Y`z4hqVJJDgDMxd|5`v?w;Woh^MC? z04$rc1+US6eui`S?w`s%eA7YvpF;4&Px@Z84`DE<{~qoK{O?h-Q)hTRKhc2r3&sWn AEdT%j literal 0 HcmV?d00001 diff --git a/src/Moltbot.CommandPalette/Directory.Build.props b/src/Moltbot.CommandPalette/Directory.Build.props new file mode 100644 index 0000000..9eb15ca --- /dev/null +++ b/src/Moltbot.CommandPalette/Directory.Build.props @@ -0,0 +1,10 @@ + + + x64;ARM64 + true + Recommended + <_SkipUpgradeNetAnalyzersNuGetWarning>true + direct + $(Platform) + + diff --git a/src/Moltbot.CommandPalette/Directory.Packages.props b/src/Moltbot.CommandPalette/Directory.Packages.props new file mode 100644 index 0000000..b6e619f --- /dev/null +++ b/src/Moltbot.CommandPalette/Directory.Packages.props @@ -0,0 +1,18 @@ + + + true + + + + + + + + + + + + + + + diff --git a/src/Moltbot.CommandPalette/Moltbot.CommandPalette.csproj b/src/Moltbot.CommandPalette/Moltbot.CommandPalette.csproj new file mode 100644 index 0000000..d3dff86 --- /dev/null +++ b/src/Moltbot.CommandPalette/Moltbot.CommandPalette.csproj @@ -0,0 +1,101 @@ + + + WinExe + Moltbot + app.manifest + + 10.0.26100.68-preview + net9.0-windows10.0.26100.0 + 10.0.19041.0 + 10.0.19041.0 + win-x64;win-arm64 + + win-$(Platform).pubxml + true + enable + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + true + + + + true + true + + true + 2 + + IL2081;$(WarningsNotAsErrors) + + + true + + + + + + false + + true + true + true + + + + + true + + + false + + + + + diff --git a/src/Moltbot.CommandPalette/Moltbot.cs b/src/Moltbot.CommandPalette/Moltbot.cs new file mode 100644 index 0000000..bc32675 --- /dev/null +++ b/src/Moltbot.CommandPalette/Moltbot.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; +using System.Threading; +using Microsoft.CommandPalette.Extensions; + +namespace Moltbot; + +[Guid("b71e1e6d-20f4-4fd8-9d8e-cc5dc94ca8b5")] +public sealed partial class Moltbot : IExtension, IDisposable +{ + private readonly ManualResetEvent _extensionDisposedEvent; + + private readonly MoltbotCommandsProvider _provider = new(); + + public Moltbot(ManualResetEvent extensionDisposedEvent) + { + this._extensionDisposedEvent = extensionDisposedEvent; + } + + public object? GetProvider(ProviderType providerType) + { + return providerType switch + { + ProviderType.Commands => _provider, + _ => null, + }; + } + + public void Dispose() => this._extensionDisposedEvent.Set(); +} + diff --git a/src/Moltbot.CommandPalette/MoltbotCommandsProvider.cs b/src/Moltbot.CommandPalette/MoltbotCommandsProvider.cs new file mode 100644 index 0000000..5810b32 --- /dev/null +++ b/src/Moltbot.CommandPalette/MoltbotCommandsProvider.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Moltbot; + +public partial class MoltbotCommandsProvider : CommandProvider +{ + private readonly ICommandItem[] _commands; + + public MoltbotCommandsProvider() + { + DisplayName = "Moltbot"; + Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png"); + _commands = [ + new CommandItem(new MoltbotPage()) { Title = DisplayName }, + ]; + } + + public override ICommandItem[] TopLevelCommands() + { + return _commands; + } + +} + diff --git a/src/Moltbot.CommandPalette/Package.appxmanifest b/src/Moltbot.CommandPalette/Package.appxmanifest new file mode 100644 index 0000000..adabf2d --- /dev/null +++ b/src/Moltbot.CommandPalette/Package.appxmanifest @@ -0,0 +1,80 @@ + + + + + + + + + Moltbot + A Lone Developer + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Moltbot.CommandPalette/Pages/MoltbotPage.cs b/src/Moltbot.CommandPalette/Pages/MoltbotPage.cs new file mode 100644 index 0000000..addda1b --- /dev/null +++ b/src/Moltbot.CommandPalette/Pages/MoltbotPage.cs @@ -0,0 +1,513 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Moltbot.Shared; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Moltbot; + +internal sealed partial class MoltbotPage : ListPage +{ + private static string _gatewayUrl = "ws://localhost:18789"; + private static string _token = ""; + + public MoltbotPage() + { + Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png"); + Title = "Moltbot"; + Name = "Open"; + + // Try to load settings from tray app + LoadSettings(); + } + + private static void LoadSettings() + { + try + { + var settingsPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "MoltbotTray", "settings.json"); + + if (File.Exists(settingsPath)) + { + var json = File.ReadAllText(settingsPath); + var settings = System.Text.Json.JsonDocument.Parse(json); + + if (settings.RootElement.TryGetProperty("GatewayUrl", out var url)) + _gatewayUrl = url.GetString() ?? _gatewayUrl; + if (settings.RootElement.TryGetProperty("Token", out var token)) + _token = token.GetString() ?? ""; + } + } + catch { } + } + + public override IListItem[] GetItems() + { + var items = new List + { + new ListItem(new OpenDashboardCommand(_gatewayUrl, _token)) + { + Title = "๐Ÿฆž Open Dashboard", + Subtitle = "Open Moltbot web dashboard in browser" + }, + new ListItem(new QuickSendCommand(_gatewayUrl, _token)) + { + Title = "๐Ÿ’ฌ Quick Send", + Subtitle = "Send a message to Moltbot" + }, + new ListItem(new StatusPage(_gatewayUrl, _token)) + { + Title = "๐Ÿ“Š Full Status", + Subtitle = "View gateway, sessions, and channels" + }, + new ListItem(new SessionsPage(_gatewayUrl, _token)) + { + Title = "โšก Sessions", + Subtitle = "View active agent sessions" + }, + new ListItem(new ChannelsPage(_gatewayUrl, _token)) + { + Title = "๐Ÿ“ก Channels", + Subtitle = "View Telegram, WhatsApp status" + }, + new ListItem(new HealthCheckCommand(_gatewayUrl, _token)) + { + Title = "๐Ÿ”„ Health Check", + Subtitle = "Run a gateway health check" + } + }; + + return items.ToArray(); + } +} + +/// +/// Command to open the Moltbot dashboard in the browser. +/// +internal sealed partial class OpenDashboardCommand : InvokableCommand +{ + private readonly string _gatewayUrl; + private readonly string _token; + + public OpenDashboardCommand(string gatewayUrl, string token) + { + _gatewayUrl = gatewayUrl; + _token = token; + } + + public override ICommandResult Invoke() + { + try + { + var url = GetDashboardUrl(_gatewayUrl, _token); + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url) + { + UseShellExecute = true + }); + } + catch { } + + return CommandResult.Hide(); + } + + internal static string GetDashboardUrl(string gatewayUrl, string token) + { + var url = gatewayUrl + .Replace("ws://", "http://") + .Replace("wss://", "https://"); + + if (!string.IsNullOrEmpty(token)) + { + var separator = url.Contains('?') ? "&" : "?"; + url = $"{url}{separator}token={Uri.EscapeDataString(token)}"; + } + return url; + } +} + +/// +/// Command to send a quick message - prompts for input then sends. +/// +internal sealed partial class QuickSendCommand : InvokableCommand +{ + private readonly string _gatewayUrl; + private readonly string _token; + + public QuickSendCommand(string gatewayUrl, string token) + { + _gatewayUrl = gatewayUrl; + _token = token; + Name = "Send message to Moltbot"; + } + + public override ICommandResult Invoke() + { + // Open a simple input dialog using Windows forms + try + { + // Use the dashboard URL with a message prompt + var url = OpenDashboardCommand.GetDashboardUrl(_gatewayUrl, _token); + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url) + { + UseShellExecute = true + }); + } + catch { } + + return CommandResult.Hide(); + } +} + +/// +/// Command to run a health check. +/// +internal sealed partial class HealthCheckCommand : InvokableCommand +{ + private readonly string _gatewayUrl; + private readonly string _token; + + public HealthCheckCommand(string gatewayUrl, string token) + { + _gatewayUrl = gatewayUrl; + _token = token; + } + + public override ICommandResult Invoke() + { + // Just run the health check and show a toast/notification + Task.Run(async () => + { + try + { + using var client = new MoltbotGatewayClient(_gatewayUrl, _token); + await client.ConnectAsync(); + await client.CheckHealthAsync(); + await Task.Delay(1000); + await client.DisconnectAsync(); + } + catch { } + }); + + // Keep palette open - user can check status page + return CommandResult.KeepOpen(); + } +} + +/// +/// Page showing active sessions. +/// +internal sealed partial class SessionsPage : ContentPage +{ + private readonly string _gatewayUrl; + private readonly string _token; + + public SessionsPage(string gatewayUrl, string token) + { + _gatewayUrl = gatewayUrl; + _token = token; + Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png"); + Title = "Sessions"; + Name = "View sessions"; + } + + public override IContent[] GetContent() + { + var sb = new StringBuilder(); + sb.AppendLine("## โšก Active Sessions"); + sb.AppendLine(); + + try + { + using var client = new MoltbotGatewayClient(_gatewayUrl, _token); + + var task = client.ConnectAsync(); + task.Wait(TimeSpan.FromSeconds(3)); + + if (!task.IsCompletedSuccessfully) + { + sb.AppendLine("โŒ Could not connect to gateway"); + return [new MarkdownContent { Body = sb.ToString() }]; + } + + client.RequestSessionsAsync().Wait(TimeSpan.FromSeconds(2)); + var sessions = client.GetSessionList(); + + if (sessions.Length == 0) + { + sb.AppendLine("_No active sessions_"); + } + else + { + // Group by main/sub + var mainSessions = new List(); + var subSessions = new List(); + + foreach (var s in sessions) + { + if (s.IsMain) mainSessions.Add(s); + else subSessions.Add(s); + } + + if (mainSessions.Count > 0) + { + sb.AppendLine("### โšก Main Sessions"); + foreach (var s in mainSessions) + { + sb.AppendLine($"- **{s.ShortKey}**"); + if (!string.IsNullOrEmpty(s.Model)) + sb.AppendLine($" - Model: `{s.Model}`"); + if (!string.IsNullOrEmpty(s.Channel)) + sb.AppendLine($" - Channel: {s.Channel}"); + if (s.StartedAt.HasValue) + sb.AppendLine($" - Started: {s.StartedAt:g}"); + } + sb.AppendLine(); + } + + if (subSessions.Count > 0) + { + sb.AppendLine($"### ๐Ÿ”น Sub-Sessions ({subSessions.Count})"); + foreach (var s in subSessions) + { + var activity = !string.IsNullOrEmpty(s.CurrentActivity) ? $" - {s.CurrentActivity}" : ""; + sb.AppendLine($"- {s.ShortKey}{activity}"); + } + } + } + + client.DisconnectAsync().Wait(TimeSpan.FromSeconds(1)); + } + catch (Exception ex) + { + sb.AppendLine($"โŒ Error: {ex.Message}"); + } + + return [new MarkdownContent { Body = sb.ToString() }]; + } +} + +/// +/// Page showing channel health. +/// +internal sealed partial class ChannelsPage : ContentPage +{ + private readonly string _gatewayUrl; + private readonly string _token; + private ChannelHealth[]? _channels; + + public ChannelsPage(string gatewayUrl, string token) + { + _gatewayUrl = gatewayUrl; + _token = token; + Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png"); + Title = "Channels"; + Name = "View channels"; + } + + public override IContent[] GetContent() + { + var sb = new StringBuilder(); + sb.AppendLine("## ๐Ÿ“ก Channel Status"); + sb.AppendLine(); + + try + { + using var client = new MoltbotGatewayClient(_gatewayUrl, _token); + + client.ChannelHealthUpdated += (s, channels) => _channels = channels; + + var task = client.ConnectAsync(); + task.Wait(TimeSpan.FromSeconds(3)); + + if (!task.IsCompletedSuccessfully) + { + sb.AppendLine("โŒ Could not connect to gateway"); + return [new MarkdownContent { Body = sb.ToString() }]; + } + + // Health check fetches channel status + client.CheckHealthAsync().Wait(TimeSpan.FromSeconds(2)); + + if (_channels == null || _channels.Length == 0) + { + sb.AppendLine("_No channels configured_"); + } + else + { + foreach (var ch in _channels) + { + var statusIcon = ch.Status.ToLowerInvariant() switch + { + "running" or "ok" or "connected" => "๐ŸŸข", + "ready" => "๐ŸŸก", + "linked" => "๐Ÿ”ต", + "stopped" or "configured" => "โšช", + "error" => "๐Ÿ”ด", + _ => "โšซ" + }; + + var name = char.ToUpper(ch.Name[0]) + ch.Name[1..]; + sb.AppendLine($"### {statusIcon} {name}"); + sb.AppendLine(); + sb.AppendLine($"- **Status:** {ch.Status}"); + + if (ch.IsLinked) + sb.AppendLine("- **Linked:** โœ… Yes"); + + if (!string.IsNullOrEmpty(ch.AuthAge)) + sb.AppendLine($"- **Auth Age:** {ch.AuthAge}"); + + if (!string.IsNullOrEmpty(ch.Error)) + sb.AppendLine($"- **Error:** โš ๏ธ {ch.Error}"); + + sb.AppendLine(); + } + } + + client.DisconnectAsync().Wait(TimeSpan.FromSeconds(1)); + } + catch (Exception ex) + { + sb.AppendLine($"โŒ Error: {ex.Message}"); + } + + return [new MarkdownContent { Body = sb.ToString() }]; + } +} + +/// +/// Page showing full Moltbot status information. +/// +internal sealed partial class StatusPage : ContentPage +{ + private readonly string _gatewayUrl; + private readonly string _token; + private ChannelHealth[]? _channels; + + public StatusPage(string gatewayUrl, string token) + { + _gatewayUrl = gatewayUrl; + _token = token; + Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png"); + Title = "Moltbot Status"; + Name = "View status"; + } + + public override IContent[] GetContent() + { + var markdown = new MarkdownContent + { + Body = GetStatusMarkdown() + }; + return [markdown]; + } + + private string GetStatusMarkdown() + { + var sb = new StringBuilder(); + + try + { + using var client = new MoltbotGatewayClient(_gatewayUrl, _token); + + client.ChannelHealthUpdated += (s, channels) => _channels = channels; + + var task = client.ConnectAsync(); + task.Wait(TimeSpan.FromSeconds(3)); + + if (!task.IsCompletedSuccessfully) + { + return "## โŒ Disconnected\n\nCould not connect to gateway.\n\nMake sure Moltbot gateway is running."; + } + + sb.AppendLine("## ๐Ÿฆž Moltbot Status"); + sb.AppendLine(); + sb.AppendLine("### Connection"); + sb.AppendLine($"- **Gateway:** `{_gatewayUrl}`"); + sb.AppendLine("- **Status:** โœ… Connected"); + sb.AppendLine(); + + // Get health and sessions + client.CheckHealthAsync().Wait(TimeSpan.FromSeconds(2)); + client.RequestSessionsAsync().Wait(TimeSpan.FromSeconds(1)); + + var sessions = client.GetSessionList(); + + // Sessions + sb.AppendLine("### โšก Sessions"); + if (sessions.Length == 0) + { + sb.AppendLine("_No active sessions_"); + } + else + { + var mainCount = 0; + var subCount = 0; + foreach (var s in sessions) + { + if (s.IsMain) mainCount++; + else subCount++; + } + sb.AppendLine($"- Main: **{mainCount}** | Sub: **{subCount}**"); + sb.AppendLine(); + + foreach (var s in sessions.Take(5)) + { + var icon = s.IsMain ? "โšก" : "๐Ÿ”น"; + sb.AppendLine($"- {icon} {s.DisplayText}"); + } + + if (sessions.Length > 5) + sb.AppendLine($"- _...and {sessions.Length - 5} more_"); + } + sb.AppendLine(); + + // Channels + sb.AppendLine("### ๐Ÿ“ก Channels"); + if (_channels == null || _channels.Length == 0) + { + sb.AppendLine("_No channels configured_"); + } + else + { + foreach (var ch in _channels) + { + var statusIcon = ch.Status.ToLowerInvariant() switch + { + "running" or "ok" => "๐ŸŸข", + "ready" => "๐ŸŸก", + "linked" => "๐Ÿ”ต", + "stopped" => "โšช", + "error" => "๐Ÿ”ด", + _ => "โšซ" + }; + var name = char.ToUpper(ch.Name[0]) + ch.Name[1..]; + sb.AppendLine($"- {statusIcon} **{name}:** {ch.Status}"); + } + } + + client.DisconnectAsync().Wait(TimeSpan.FromSeconds(1)); + + sb.AppendLine(); + sb.AppendLine("---"); + sb.AppendLine("_Use ๐Ÿฆž Open Dashboard for the full web interface_"); + + return sb.ToString(); + } + catch (Exception ex) + { + return $"## โŒ Error\n\n{ex.Message}\n\nMake sure the gateway is running and your settings are correct."; + } + } +} + diff --git a/src/Moltbot.CommandPalette/Program.cs b/src/Moltbot.CommandPalette/Program.cs new file mode 100644 index 0000000..fa4c18d --- /dev/null +++ b/src/Moltbot.CommandPalette/Program.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions; +using Shmuelie.WinRTServer; +using Shmuelie.WinRTServer.CsWinRT; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Moltbot; + +public class Program +{ + [MTAThread] + public static void Main(string[] args) + { + if (args.Length > 0 && args[0] == "-RegisterProcessAsComServer") + { + global::Shmuelie.WinRTServer.ComServer server = new(); + + ManualResetEvent extensionDisposedEvent = new(false); + + // We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called. + // This makes sure that only one instance of SampleExtension is alive, which is returned every time the host asks for the IExtension object. + // If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate. + Moltbot extensionInstance = new(extensionDisposedEvent); + server.RegisterClass(() => extensionInstance); + server.Start(); + + // This will make the main thread wait until the event is signalled by the extension class. + // Since we have single instance of the extension object, we exit as soon as it is disposed. + extensionDisposedEvent.WaitOne(); + server.Stop(); + server.UnsafeDispose(); + } + else + { + Console.WriteLine("Not being launched as a Extension... exiting."); + } + } +} + diff --git a/src/Moltbot.CommandPalette/Properties/launchSettings.json b/src/Moltbot.CommandPalette/Properties/launchSettings.json new file mode 100644 index 0000000..219d366 --- /dev/null +++ b/src/Moltbot.CommandPalette/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Moltbot (Package)": { + "commandName": "MsixPackage", + "doNotLaunchApp": true + }, + "Moltbot (Unpackaged)": { + "commandName": "Project" + } + } +} diff --git a/src/Moltbot.CommandPalette/app.manifest b/src/Moltbot.CommandPalette/app.manifest new file mode 100644 index 0000000..49ff8a8 --- /dev/null +++ b/src/Moltbot.CommandPalette/app.manifest @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + true/PM + PerMonitorV2 + + + \ No newline at end of file diff --git a/src/Moltbot.CommandPalette/nuget.config b/src/Moltbot.CommandPalette/nuget.config new file mode 100644 index 0000000..ff82043 --- /dev/null +++ b/src/Moltbot.CommandPalette/nuget.config @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/Moltbot.Shared/IMoltbotLogger.cs b/src/Moltbot.Shared/IMoltbotLogger.cs new file mode 100644 index 0000000..371e0ab --- /dev/null +++ b/src/Moltbot.Shared/IMoltbotLogger.cs @@ -0,0 +1,35 @@ +namespace Moltbot.Shared; + +/// +/// Simple logger interface for the gateway client. +/// Implementations can write to file, console, debug output, etc. +/// +public interface IMoltbotLogger +{ + void Info(string message); + void Warn(string message); + void Error(string message, Exception? ex = null); +} + +/// +/// Default no-op logger for when logging isn't needed. +/// +public class NullLogger : IMoltbotLogger +{ + public static readonly NullLogger Instance = new(); + public void Info(string message) { } + public void Warn(string message) { } + public void Error(string message, Exception? ex = null) { } +} + +/// +/// Console logger for simple debugging. +/// +public class ConsoleLogger : IMoltbotLogger +{ + public void Info(string message) => Console.WriteLine($"[INFO] {message}"); + public void Warn(string message) => Console.WriteLine($"[WARN] {message}"); + public void Error(string message, Exception? ex = null) => + Console.WriteLine($"[ERROR] {message}{(ex != null ? $": {ex.Message}" : "")}"); +} + diff --git a/src/Moltbot.Shared/Models.cs b/src/Moltbot.Shared/Models.cs new file mode 100644 index 0000000..d7ea7cd --- /dev/null +++ b/src/Moltbot.Shared/Models.cs @@ -0,0 +1,186 @@ +namespace Moltbot.Shared; + +public enum ConnectionStatus +{ + Disconnected, + Connecting, + Connected, + Error +} + +public enum ActivityKind +{ + Idle, + Job, + Exec, + Read, + Write, + Edit, + Search, + Browser, + Message, + Tool +} + +public class AgentActivity +{ + public string SessionKey { get; set; } = ""; + public bool IsMain { get; set; } + public ActivityKind Kind { get; set; } = ActivityKind.Idle; + public string State { get; set; } = ""; + public string ToolName { get; set; } = ""; + public string Label { get; set; } = ""; + + public string Glyph => Kind switch + { + ActivityKind.Exec => "๐Ÿ’ป", + ActivityKind.Read => "๐Ÿ“„", + ActivityKind.Write => "โœ๏ธ", + ActivityKind.Edit => "๐Ÿ“", + ActivityKind.Search => "๐Ÿ”", + ActivityKind.Browser => "๐ŸŒ", + ActivityKind.Message => "๐Ÿ’ฌ", + ActivityKind.Tool => "๐Ÿ› ๏ธ", + ActivityKind.Job => "โšก", + _ => "" + }; + + public string DisplayText => Kind == ActivityKind.Idle + ? "" + : $"{(IsMain ? "Main" : "Sub")} ยท {Glyph} {Label}"; +} + +public class MoltbotNotification +{ + public string Title { get; set; } = ""; + public string Message { get; set; } = ""; + public string Type { get; set; } = ""; +} + +public class ChannelHealth +{ + public string Name { get; set; } = ""; + public string Status { get; set; } = "unknown"; + public bool IsLinked { get; set; } + public string? Error { get; set; } + public string? AuthAge { get; set; } + public string? Type { get; set; } + + public string DisplayText + { + get + { + var label = Status.ToLowerInvariant() switch + { + "ok" or "connected" or "running" => "[ON]", + "linked" => "[LINKED]", + "ready" => "[READY]", + "connecting" or "reconnecting" => "[...]", + "error" or "disconnected" => "[ERR]", + "stale" => "[STALE]", + "configured" or "stopped" => "[OFF]", + "not configured" => "[N/A]", + _ => "[OFF]" + }; + var detail = IsLinked && AuthAge != null ? $"linked ยท {AuthAge}" : Status; + if (Error != null) detail += $" ({Error})"; + return $"{label} {Capitalize(Name)}: {detail}"; + } + } + + private static string Capitalize(string s) => + string.IsNullOrEmpty(s) ? s : char.ToUpper(s[0]) + s[1..]; +} + +public class SessionInfo +{ + public string Key { get; set; } = ""; + public bool IsMain { get; set; } + public string Status { get; set; } = "unknown"; + public string? Model { get; set; } + public string? Channel { get; set; } + public string? CurrentActivity { get; set; } + public DateTime? StartedAt { get; set; } + public DateTime LastSeen { get; set; } = DateTime.UtcNow; + + public string DisplayText + { + get + { + var prefix = IsMain ? "Main" : "Sub"; + var parts = new List { prefix }; + + if (!string.IsNullOrEmpty(Channel)) + parts.Add(Channel); + + if (!string.IsNullOrEmpty(CurrentActivity)) + parts.Add(CurrentActivity); + else if (!string.IsNullOrEmpty(Status) && Status != "unknown" && Status != "active") + parts.Add(Status); + + return string.Join(" ยท ", parts); + } + } + + /// Gets a shortened, user-friendly version of the session key. + public string ShortKey + { + get + { + if (string.IsNullOrEmpty(Key)) return "unknown"; + + // Extract meaningful part from session keys like "agent:main:subagent:uuid" + var parts = Key.Split(':'); + if (parts.Length >= 3) + { + // Return something like "subagent" or "cron" + return parts[^2]; // Second to last part + } + + // For file paths, just return filename + if (Key.Contains('/') || Key.Contains('\\')) + { + return Path.GetFileName(Key); + } + + return Key.Length > 20 ? Key[..17] + "..." : Key; + } + } +} + +public class GatewayUsageInfo +{ + public long InputTokens { get; set; } + public long OutputTokens { get; set; } + public long TotalTokens { get; set; } + public double CostUsd { get; set; } + public int RequestCount { get; set; } + public string? Model { get; set; } + + public string DisplayText + { + get + { + var parts = new List(); + if (TotalTokens > 0) + parts.Add($"Tokens: {FormatCount(TotalTokens)}"); + if (CostUsd > 0) + parts.Add($"${CostUsd:F2}"); + if (RequestCount > 0) + parts.Add($"{RequestCount} requests"); + if (!string.IsNullOrEmpty(Model)) + parts.Add(Model); + return parts.Count > 0 + ? string.Join(" ยท ", parts) + : "No usage data"; + } + } + + private static string FormatCount(long n) + { + if (n >= 1_000_000) return $"{n / 1_000_000.0:F1}M"; + if (n >= 1_000) return $"{n / 1_000.0:F1}K"; + return n.ToString(); + } +} + diff --git a/src/Moltbot.Shared/Moltbot.Shared.csproj b/src/Moltbot.Shared/Moltbot.Shared.csproj new file mode 100644 index 0000000..7979499 --- /dev/null +++ b/src/Moltbot.Shared/Moltbot.Shared.csproj @@ -0,0 +1,11 @@ + + + + net9.0 + enable + enable + Moltbot.Shared + + + + diff --git a/src/Moltbot.Shared/MoltbotGatewayClient.cs b/src/Moltbot.Shared/MoltbotGatewayClient.cs new file mode 100644 index 0000000..2e470e7 --- /dev/null +++ b/src/Moltbot.Shared/MoltbotGatewayClient.cs @@ -0,0 +1,862 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Moltbot.Shared; + +public class MoltbotGatewayClient : IDisposable +{ + private ClientWebSocket? _webSocket; + private readonly string _gatewayUrl; + private readonly string _token; + private readonly IMoltbotLogger _logger; + private CancellationTokenSource _cts; + private bool _disposed; + private int _reconnectAttempts; + private static readonly int[] BackoffMs = { 1000, 2000, 4000, 8000, 15000, 30000, 60000 }; + + // Tracked state + private readonly Dictionary _sessions = new(); + private GatewayUsageInfo? _usage; + + // Events + public event EventHandler? StatusChanged; + public event EventHandler? NotificationReceived; + public event EventHandler? ActivityChanged; + public event EventHandler? ChannelHealthUpdated; + public event EventHandler? SessionsUpdated; + public event EventHandler? UsageUpdated; + + public MoltbotGatewayClient(string gatewayUrl, string token, IMoltbotLogger? logger = null) + { + _gatewayUrl = gatewayUrl; + _token = token; + _logger = logger ?? NullLogger.Instance; + _cts = new CancellationTokenSource(); + } + + public async Task ConnectAsync() + { + try + { + StatusChanged?.Invoke(this, ConnectionStatus.Connecting); + _logger.Info($"Connecting to gateway: {_gatewayUrl}"); + + _webSocket = new ClientWebSocket(); + _webSocket.Options.KeepAliveInterval = TimeSpan.FromSeconds(30); + // Set Origin header to localhost to satisfy secure context check + _webSocket.Options.SetRequestHeader("Origin", "http://localhost:18789"); + var uri = new Uri(_gatewayUrl); + await _webSocket.ConnectAsync(uri, _cts.Token); + + _reconnectAttempts = 0; + _logger.Info("Gateway connected, waiting for challenge..."); + + // Don't send connect yet - wait for challenge event in ListenForMessagesAsync + _ = Task.Run(() => ListenForMessagesAsync(), _cts.Token); + } + catch (Exception ex) + { + _logger.Error("Connection failed", ex); + StatusChanged?.Invoke(this, ConnectionStatus.Error); + throw; + } + } + + public async Task DisconnectAsync() + { + if (_webSocket?.State == WebSocketState.Open) + { + try + { + await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disconnecting", CancellationToken.None); + } + catch (Exception ex) + { + _logger.Warn($"Error during disconnect: {ex.Message}"); + } + } + StatusChanged?.Invoke(this, ConnectionStatus.Disconnected); + _logger.Info("Disconnected"); + } + + public async Task CheckHealthAsync() + { + if (_webSocket?.State != WebSocketState.Open) + { + await ReconnectWithBackoffAsync(); + return; + } + + try + { + var req = new + { + type = "req", + id = Guid.NewGuid().ToString(), + method = "health", + @params = new { deep = true } + }; + await SendRawAsync(JsonSerializer.Serialize(req)); + } + catch (Exception ex) + { + _logger.Error("Health check failed", ex); + StatusChanged?.Invoke(this, ConnectionStatus.Error); + await ReconnectWithBackoffAsync(); + } + } + + public async Task SendChatMessageAsync(string message) + { + if (_webSocket?.State != WebSocketState.Open) + throw new InvalidOperationException("Gateway connection is not open"); + + var req = new + { + type = "req", + id = Guid.NewGuid().ToString(), + method = "chat.send", + @params = new { message } + }; + await SendRawAsync(JsonSerializer.Serialize(req)); + _logger.Info($"Sent chat message ({message.Length} chars)"); + } + + /// Request session list from gateway. + public async Task RequestSessionsAsync() + { + if (_webSocket?.State != WebSocketState.Open) return; + var req = new + { + type = "req", + id = Guid.NewGuid().ToString(), + method = "sessions.list" + }; + await SendRawAsync(JsonSerializer.Serialize(req)); + } + + /// Request usage/context info from gateway (may not be supported on all gateways). + public async Task RequestUsageAsync() + { + // Usage endpoint may not exist on all gateways - fail silently + if (_webSocket?.State != WebSocketState.Open) return; + try + { + var req = new + { + type = "req", + id = Guid.NewGuid().ToString(), + method = "usage" + }; + await SendRawAsync(JsonSerializer.Serialize(req)); + } + catch { } + } + + // --- Connection management --- + + private async Task ReconnectWithBackoffAsync() + { + var delay = BackoffMs[Math.Min(_reconnectAttempts, BackoffMs.Length - 1)]; + _reconnectAttempts++; + _logger.Warn($"Reconnecting in {delay}ms (attempt {_reconnectAttempts})"); + StatusChanged?.Invoke(this, ConnectionStatus.Connecting); + + try + { + await Task.Delay(delay, _cts.Token); + _webSocket?.Dispose(); + _webSocket = null; + await ConnectAsync(); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _logger.Error("Reconnect failed", ex); + StatusChanged?.Invoke(this, ConnectionStatus.Error); + // Don't recurse โ€” the listen loop will trigger reconnect again + } + } + + private async Task SendConnectMessageAsync(string? nonce = null) + { + // Use "cli" client ID for native apps - no browser security checks + var msg = new + { + type = "req", + id = Guid.NewGuid().ToString(), + method = "connect", + @params = new + { + minProtocol = 3, + maxProtocol = 3, + client = new + { + id = "cli", // Native client ID + version = "1.0.0", + platform = "windows", + mode = "cli", + displayName = "Clawdbot Windows Tray" + }, + role = "operator", + scopes = new[] { "operator.admin", "operator.approvals", "operator.pairing" }, + caps = Array.Empty(), + commands = Array.Empty(), + permissions = new { }, + auth = new { token = _token }, + locale = "en-US", + userAgent = "clawdbot-windows-tray/1.0.0" + } + }; + await SendRawAsync(JsonSerializer.Serialize(msg)); + } + + private async Task SendRawAsync(string message) + { + if (_webSocket?.State == WebSocketState.Open) + { + var bytes = Encoding.UTF8.GetBytes(message); + await _webSocket.SendAsync(new ArraySegment(bytes), + WebSocketMessageType.Text, true, _cts.Token); + } + } + + // --- Message loop --- + + private async Task ListenForMessagesAsync() + { + var buffer = new byte[16384]; // Larger buffer for big events + var sb = new StringBuilder(); + + try + { + while (_webSocket?.State == WebSocketState.Open && !_cts.Token.IsCancellationRequested) + { + var result = await _webSocket.ReceiveAsync( + new ArraySegment(buffer), _cts.Token); + + if (result.MessageType == WebSocketMessageType.Text) + { + sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); + if (result.EndOfMessage) + { + ProcessMessage(sb.ToString()); + sb.Clear(); + } + } + else if (result.MessageType == WebSocketMessageType.Close) + { + var closeStatus = _webSocket.CloseStatus?.ToString() ?? "unknown"; + var closeDesc = _webSocket.CloseStatusDescription ?? "no description"; + _logger.Info($"Server closed connection: {closeStatus} - {closeDesc}"); + StatusChanged?.Invoke(this, ConnectionStatus.Disconnected); + break; + } + } + } + catch (WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) + { + _logger.Warn("Connection closed prematurely"); + StatusChanged?.Invoke(this, ConnectionStatus.Disconnected); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _logger.Error("Listen error", ex); + StatusChanged?.Invoke(this, ConnectionStatus.Error); + } + + // Auto-reconnect if not intentionally disposed + if (!_disposed && !_cts.Token.IsCancellationRequested) + { + await ReconnectWithBackoffAsync(); + } + } + + // --- Message processing --- + + private void ProcessMessage(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (!root.TryGetProperty("type", out var typeProp)) return; + var type = typeProp.GetString(); + + switch (type) + { + case "res": + HandleResponse(root); + break; + case "event": + HandleEvent(root); + break; + } + } + catch (JsonException ex) + { + _logger.Warn($"JSON parse error: {ex.Message}"); + } + catch (Exception ex) + { + _logger.Error("Message processing error", ex); + } + } + + private void HandleResponse(JsonElement root) + { + if (!root.TryGetProperty("payload", out var payload)) return; + + // Handle hello-ok + if (payload.TryGetProperty("type", out var t) && t.GetString() == "hello-ok") + { + _logger.Info("Handshake complete (hello-ok)"); + StatusChanged?.Invoke(this, ConnectionStatus.Connected); + + // Request initial state after handshake + _ = Task.Run(async () => + { + await Task.Delay(500); + await CheckHealthAsync(); + await RequestSessionsAsync(); + await RequestUsageAsync(); + }); + } + + // Handle health response โ€” channels + if (payload.TryGetProperty("channels", out var channels)) + { + ParseChannelHealth(channels); + } + + // Handle sessions response + if (payload.TryGetProperty("sessions", out var sessions)) + { + ParseSessions(sessions); + } + + // Handle usage response + if (payload.TryGetProperty("usage", out var usage)) + { + ParseUsage(usage); + } + } + + private void HandleEvent(JsonElement root) + { + if (!root.TryGetProperty("event", out var eventProp)) return; + var eventType = eventProp.GetString(); + + switch (eventType) + { + case "connect.challenge": + HandleConnectChallenge(root); + break; + case "agent": + HandleAgentEvent(root); + break; + case "health": + if (root.TryGetProperty("payload", out var hp) && + hp.TryGetProperty("channels", out var ch)) + ParseChannelHealth(ch); + break; + case "chat": + HandleChatEvent(root); + break; + case "session": + HandleSessionEvent(root); + break; + } + } + + private void HandleConnectChallenge(JsonElement root) + { + string? nonce = null; + if (root.TryGetProperty("payload", out var payload) && + payload.TryGetProperty("nonce", out var nonceProp)) + { + nonce = nonceProp.GetString(); + } + + _logger.Info($"Received challenge, nonce: {nonce}"); + _ = SendConnectMessageAsync(nonce); + } + + private void HandleAgentEvent(JsonElement root) + { + if (!root.TryGetProperty("payload", out var payload)) return; + + // Determine session + var sessionKey = "unknown"; + if (root.TryGetProperty("sessionKey", out var sk)) + sessionKey = sk.GetString() ?? "unknown"; + var isMain = sessionKey == "main" || sessionKey.Contains(":main:"); + + // Parse activity from stream field + if (payload.TryGetProperty("stream", out var streamProp)) + { + var stream = streamProp.GetString(); + + if (stream == "job") + { + HandleJobEvent(payload, sessionKey, isMain); + } + else if (stream == "tool") + { + HandleToolEvent(payload, sessionKey, isMain); + } + } + + // Check for notification content + if (payload.TryGetProperty("content", out var content)) + { + var text = content.GetString() ?? ""; + if (!string.IsNullOrEmpty(text)) + { + EmitNotification(text); + } + } + } + + private void HandleJobEvent(JsonElement payload, string sessionKey, bool isMain) + { + var state = "unknown"; + if (payload.TryGetProperty("data", out var data) && + data.TryGetProperty("state", out var stateProp)) + state = stateProp.GetString() ?? "unknown"; + + var activity = new AgentActivity + { + SessionKey = sessionKey, + IsMain = isMain, + Kind = ActivityKind.Job, + State = state, + Label = $"Job: {state}" + }; + + if (state == "done" || state == "error") + activity.Kind = ActivityKind.Idle; + + _logger.Info($"Agent activity: {activity.Label} (session: {sessionKey})"); + ActivityChanged?.Invoke(this, activity); + + // Update tracked session + UpdateTrackedSession(sessionKey, isMain, state == "done" || state == "error" ? null : $"Job: {state}"); + } + + private void HandleToolEvent(JsonElement payload, string sessionKey, bool isMain) + { + var phase = ""; + var toolName = ""; + var label = ""; + + if (payload.TryGetProperty("data", out var data)) + { + if (data.TryGetProperty("phase", out var phaseProp)) + phase = phaseProp.GetString() ?? ""; + if (data.TryGetProperty("name", out var nameProp)) + toolName = nameProp.GetString() ?? ""; + + // Extract detail from args + if (data.TryGetProperty("args", out var args)) + { + if (args.TryGetProperty("command", out var cmd)) + label = TruncateLabel(cmd.GetString()?.Split('\n')[0] ?? ""); + else if (args.TryGetProperty("path", out var path)) + label = ShortenPath(path.GetString() ?? ""); + else if (args.TryGetProperty("file_path", out var filePath)) + label = ShortenPath(filePath.GetString() ?? ""); + else if (args.TryGetProperty("query", out var query)) + label = TruncateLabel(query.GetString() ?? ""); + else if (args.TryGetProperty("url", out var url)) + label = TruncateLabel(url.GetString() ?? ""); + } + } + + if (string.IsNullOrEmpty(label)) + label = toolName; + + var kind = ClassifyTool(toolName); + + // On tool result, briefly show then go idle + if (phase == "result") + kind = ActivityKind.Idle; + + var activity = new AgentActivity + { + SessionKey = sessionKey, + IsMain = isMain, + Kind = kind, + State = phase, + ToolName = toolName, + Label = label + }; + + _logger.Info($"Tool: {toolName} ({phase}) โ€” {label}"); + ActivityChanged?.Invoke(this, activity); + + // Update tracked session + if (kind != ActivityKind.Idle) + { + UpdateTrackedSession(sessionKey, isMain, $"{activity.Glyph} {label}"); + } + } + + private void HandleChatEvent(JsonElement root) + { + _logger.Info($"Chat event received: {root.GetRawText().Substring(0, Math.Min(200, root.GetRawText().Length))}"); + + if (!root.TryGetProperty("payload", out var payload)) return; + + if (payload.TryGetProperty("text", out var textProp)) + { + var text = textProp.GetString() ?? ""; + if (payload.TryGetProperty("role", out var role) && + role.GetString() == "assistant" && + !string.IsNullOrEmpty(text)) + { + _logger.Info($"Assistant response: {text.Substring(0, Math.Min(100, text.Length))}"); + // Only notify for short assistant messages (likely alerts/responses) + if (text.Length < 500) + { + EmitNotification(text); + } + } + } + } + + private void HandleSessionEvent(JsonElement root) + { + // Re-request sessions list when session events come through + _ = RequestSessionsAsync(); + } + + // --- State tracking --- + + private void UpdateTrackedSession(string sessionKey, bool isMain, string? currentActivity) + { + if (!_sessions.ContainsKey(sessionKey)) + { + _sessions[sessionKey] = new SessionInfo + { + Key = sessionKey, + IsMain = isMain, + Status = "active" + }; + } + + _sessions[sessionKey].CurrentActivity = currentActivity; + _sessions[sessionKey].LastSeen = DateTime.UtcNow; + + SessionsUpdated?.Invoke(this, GetSessionList()); + } + + public SessionInfo[] GetSessionList() + { + var list = new List(_sessions.Values); + list.Sort((a, b) => + { + // Main session first, then by last seen + if (a.IsMain != b.IsMain) return a.IsMain ? -1 : 1; + return b.LastSeen.CompareTo(a.LastSeen); + }); + return list.ToArray(); + } + + // --- Parsing helpers --- + + private void ParseChannelHealth(JsonElement channels) + { + var healthList = new List(); + + // Debug: log raw channel data + _logger.Info($"Raw channel health JSON: {channels.GetRawText()}"); + + foreach (var prop in channels.EnumerateObject()) + { + var ch = new ChannelHealth { Name = prop.Name }; + var val = prop.Value; + + // Get running status + bool isRunning = false; + bool isConfigured = false; + bool isLinked = false; + bool probeOk = false; + + if (val.TryGetProperty("running", out var running)) + isRunning = running.GetBoolean(); + if (val.TryGetProperty("configured", out var configured)) + isConfigured = configured.GetBoolean(); + if (val.TryGetProperty("linked", out var linked)) + { + isLinked = linked.GetBoolean(); + ch.IsLinked = isLinked; + } + // Check probe status for webhook-based channels like Telegram + if (val.TryGetProperty("probe", out var probe) && probe.TryGetProperty("ok", out var ok)) + probeOk = ok.GetBoolean(); + + // Determine status string + if (val.TryGetProperty("status", out var status)) + ch.Status = status.GetString() ?? "unknown"; + else if (isRunning) + ch.Status = "running"; + else if (probeOk && isConfigured) + ch.Status = "ready"; // Webhook mode, bot is responding + else if (isLinked) + ch.Status = "linked"; // Authenticated but not running + else if (isConfigured) + ch.Status = "stopped"; + else + ch.Status = "not configured"; + + if (val.TryGetProperty("error", out var error)) + ch.Error = error.GetString(); + if (val.TryGetProperty("authAge", out var authAge)) + ch.AuthAge = authAge.GetString(); + if (val.TryGetProperty("type", out var chType)) + ch.Type = chType.GetString(); + + healthList.Add(ch); + } + + if (healthList.Count > 0) + { + _logger.Info($"Channel health: {string.Join(", ", healthList.ConvertAll(c => $"{c.Name}={c.Status}"))}"); + ChannelHealthUpdated?.Invoke(this, healthList.ToArray()); + } + } + + private void ParseSessions(JsonElement sessions) + { + try + { + _sessions.Clear(); + + // Handle both Array format and Object (dictionary) format + if (sessions.ValueKind == JsonValueKind.Array) + { + foreach (var item in sessions.EnumerateArray()) + { + ParseSessionItem(item); + } + } + else if (sessions.ValueKind == JsonValueKind.Object) + { + // Object format: keys are session IDs, values could be session info objects or simple strings + foreach (var prop in sessions.EnumerateObject()) + { + var sessionKey = prop.Name; + + // Skip metadata fields that aren't actual sessions + if (sessionKey is "recent" or "count" or "path" or "defaults" or "ts") + continue; + + // Skip non-session keys (must look like a session key pattern) + if (!sessionKey.Contains(':') && !sessionKey.Contains("agent") && !sessionKey.Contains("session")) + continue; + + var session = new SessionInfo { Key = sessionKey }; + var item = prop.Value; + + // Detect main session from key pattern - "agent:main:main" ends with ":main" + var endsWithMain = sessionKey.EndsWith(":main"); + session.IsMain = sessionKey == "main" || endsWithMain || sessionKey.Contains(":main:main"); + _logger.Info($"Session key={sessionKey}, endsWithMain={endsWithMain}, IsMain={session.IsMain}"); + + // Value might be an object with session details or just a string status + if (item.ValueKind == JsonValueKind.Object) + { + // Only override IsMain if the JSON explicitly says true + if (item.TryGetProperty("isMain", out var isMain) && isMain.GetBoolean()) + session.IsMain = true; + if (item.TryGetProperty("status", out var status)) + session.Status = status.GetString() ?? "active"; + if (item.TryGetProperty("model", out var model)) + session.Model = model.GetString(); + if (item.TryGetProperty("channel", out var channel)) + session.Channel = channel.GetString(); + if (item.TryGetProperty("startedAt", out var started)) + { + if (DateTime.TryParse(started.GetString(), out var dt)) + session.StartedAt = dt; + } + } + else if (item.ValueKind == JsonValueKind.String) + { + // Simple string value - skip if it looks like a path (metadata) + var strVal = item.GetString() ?? ""; + if (strVal.StartsWith("/") || strVal.Contains("/.")) + continue; + session.Status = strVal; + } + else if (item.ValueKind == JsonValueKind.Number) + { + // Skip numeric values (like count) + continue; + } + + _sessions[session.Key] = session; + } + } + + SessionsUpdated?.Invoke(this, GetSessionList()); + } + catch (Exception ex) + { + _logger.Warn($"Failed to parse sessions: {ex.Message}"); + } + } + + private void ParseSessionItem(JsonElement item) + { + var session = new SessionInfo(); + if (item.TryGetProperty("key", out var key)) + session.Key = key.GetString() ?? "unknown"; + + // Detect main from key pattern first + session.IsMain = session.Key == "main" || + session.Key.EndsWith(":main") || + session.Key.Contains(":main:main"); + + // Only override if JSON explicitly says true + if (item.TryGetProperty("isMain", out var isMain) && isMain.GetBoolean()) + session.IsMain = true; + + if (item.TryGetProperty("status", out var status)) + session.Status = status.GetString() ?? "unknown"; + if (item.TryGetProperty("model", out var model)) + session.Model = model.GetString(); + if (item.TryGetProperty("channel", out var channel)) + session.Channel = channel.GetString(); + if (item.TryGetProperty("startedAt", out var started)) + { + if (DateTime.TryParse(started.GetString(), out var dt)) + session.StartedAt = dt; + } + + _sessions[session.Key] = session; + } + + private void ParseUsage(JsonElement usage) + { + try + { + _usage = new GatewayUsageInfo(); + if (usage.TryGetProperty("inputTokens", out var inp)) + _usage.InputTokens = inp.GetInt64(); + if (usage.TryGetProperty("outputTokens", out var outp)) + _usage.OutputTokens = outp.GetInt64(); + if (usage.TryGetProperty("totalTokens", out var tot)) + _usage.TotalTokens = tot.GetInt64(); + if (usage.TryGetProperty("cost", out var cost)) + _usage.CostUsd = cost.GetDouble(); + if (usage.TryGetProperty("requestCount", out var req)) + _usage.RequestCount = req.GetInt32(); + if (usage.TryGetProperty("model", out var model)) + _usage.Model = model.GetString(); + + UsageUpdated?.Invoke(this, _usage); + } + catch (Exception ex) + { + _logger.Warn($"Failed to parse usage: {ex.Message}"); + } + } + + // --- Notification classification --- + + private void EmitNotification(string text) + { + var (title, type) = ClassifyNotification(text); + NotificationReceived?.Invoke(this, new MoltbotNotification + { + Title = title, + Message = text.Length > 200 ? text[..200] + "โ€ฆ" : text, + Type = type + }); + } + + private static (string title, string type) ClassifyNotification(string text) + { + var lower = text.ToLowerInvariant(); + if (lower.Contains("blood sugar") || lower.Contains("glucose") || + lower.Contains("cgm") || lower.Contains("mg/dl")) + return ("๐Ÿฉธ Blood Sugar Alert", "health"); + if (lower.Contains("urgent") || lower.Contains("critical") || + lower.Contains("emergency")) + return ("๐Ÿšจ Urgent Alert", "urgent"); + if (lower.Contains("reminder")) + return ("โฐ Reminder", "reminder"); + if (lower.Contains("stock") || lower.Contains("in stock") || + lower.Contains("available now")) + return ("๐Ÿ“ฆ Stock Alert", "stock"); + if (lower.Contains("email") || lower.Contains("inbox") || + lower.Contains("gmail")) + return ("๐Ÿ“ง Email", "email"); + if (lower.Contains("calendar") || lower.Contains("meeting") || + lower.Contains("event")) + return ("๐Ÿ“… Calendar", "calendar"); + if (lower.Contains("error") || lower.Contains("failed") || + lower.Contains("exception")) + return ("โš ๏ธ Error", "error"); + if (lower.Contains("build") || lower.Contains("ci ") || + lower.Contains("deploy")) + return ("๐Ÿ”จ Build", "build"); + return ("๐Ÿค– Clawdbot", "info"); + } + + // --- Utility --- + + private static ActivityKind ClassifyTool(string toolName) + { + return toolName.ToLowerInvariant() switch + { + "exec" => ActivityKind.Exec, + "read" => ActivityKind.Read, + "write" => ActivityKind.Write, + "edit" => ActivityKind.Edit, + "web_search" => ActivityKind.Search, + "web_fetch" => ActivityKind.Search, + "browser" => ActivityKind.Browser, + "message" => ActivityKind.Message, + "tts" => ActivityKind.Tool, + "image" => ActivityKind.Tool, + _ => ActivityKind.Tool + }; + } + + private static string ShortenPath(string path) + { + if (string.IsNullOrEmpty(path)) return path; + var parts = path.Replace('\\', '/').Split('/'); + return parts.Length > 2 + ? $"โ€ฆ/{parts[^2]}/{parts[^1]}" + : parts[^1]; + } + + private static string TruncateLabel(string text, int maxLen = 60) + { + if (string.IsNullOrEmpty(text) || text.Length <= maxLen) return text; + return text[..(maxLen - 1)] + "โ€ฆ"; + } + + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + _cts.Cancel(); + _webSocket?.Dispose(); + _cts.Dispose(); + } + } +} diff --git a/src/Moltbot.Tray/.gitignore b/src/Moltbot.Tray/.gitignore new file mode 100644 index 0000000..3ae99a0 --- /dev/null +++ b/src/Moltbot.Tray/.gitignore @@ -0,0 +1,345 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these files may be extracted +*.azurePubxml + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment the next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +CrystalDecisions.ReportingServices.ViewerObjectModel.dll + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd \ No newline at end of file diff --git a/src/Moltbot.Tray/AutoStartManager.cs b/src/Moltbot.Tray/AutoStartManager.cs new file mode 100644 index 0000000..ff88a22 --- /dev/null +++ b/src/Moltbot.Tray/AutoStartManager.cs @@ -0,0 +1,59 @@ +using Microsoft.Win32; +using System; +using System.IO; +using System.Reflection; +using System.Windows.Forms; + +namespace MoltbotTray; + +public static class AutoStartManager +{ + private const string RegistryKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; + private const string ApplicationName = "MoltbotTray"; + + public static void SetAutoStart(bool enabled) + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, true); + if (key != null) + { + if (enabled) + { + var exePath = GetExecutablePath(); + key.SetValue(ApplicationName, $"\"{exePath}\""); + } + else + { + key.DeleteValue(ApplicationName, false); + } + } + } + catch (Exception ex) + { + MessageBox.Show($"Failed to update auto-start setting: {ex.Message}", + "Auto-start Error", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + public static bool IsAutoStartEnabled() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath); + return key?.GetValue(ApplicationName) != null; + } + catch + { + return false; + } + } + + private static string GetExecutablePath() + { + // Use ProcessPath for single-file deployments (Assembly.Location is empty) + var location = Environment.ProcessPath ?? Application.ExecutablePath; + + return location; + } +} diff --git a/src/Moltbot.Tray/DEVELOPMENT.md b/src/Moltbot.Tray/DEVELOPMENT.md new file mode 100644 index 0000000..0d80a9a --- /dev/null +++ b/src/Moltbot.Tray/DEVELOPMENT.md @@ -0,0 +1,167 @@ +# Development Notes + +## Architecture Overview + +This Windows system tray application is built with .NET 9 and Windows Forms, designed to be lightweight and efficient while providing seamless integration with the Clawdbot gateway. It mirrors the macOS menu bar app's functionality for Windows users. + +### Component Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Program โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ TrayApplication โ”‚ +โ”‚ (entry) โ”‚ โ”‚ - System tray icon โ”‚ +โ”‚ - Mutex โ”‚ โ”‚ - Context menu โ”‚ +โ”‚ - URI reg โ”‚ โ”‚ - Event dispatch (UI) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ - Session awareness โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ events + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ ClawdbotGatewayClient โ”‚ + โ”‚ - WebSocket connection โ”‚ + โ”‚ - Protocol v3 handshake โ”‚ + โ”‚ - Event parsing โ”‚ + โ”‚ - Session/usage trackingโ”‚ + โ”‚ - Auto-reconnect โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### Key Components + +| Component | File | Purpose | +|-----------|------|---------| +| **Program** | `Program.cs` | Entry point, single-instance mutex, URI scheme registration | +| **TrayApplication** | `TrayApplication.cs` | Main `ApplicationContext` managing the tray icon, context menu, and UI event dispatch | +| **ClawdbotGatewayClient** | `ClawdbotGatewayClient.cs` | WebSocket client implementing gateway protocol v3 with event parsing, session tracking, and usage monitoring | +| **SettingsManager** | `SettingsManager.cs` | JSON-based settings persistence in `%APPDATA%\ClawdbotTray\` | +| **SettingsDialog** | `SettingsDialog.cs` | Settings UI with URL/token config, test connection (with timeout), and notification preferences | +| **Logger** | `Logger.cs` | Thread-safe file + debug logger with automatic rotation (1MB), writes to `%LOCALAPPDATA%\ClawdbotTray\clawdbot-tray.log` | +| **DeepLinkHandler** | `DeepLinkHandler.cs` | `clawdbot://` URI scheme registration and processing for cross-app integration | +| **WebChatForm** | `WebChatForm.cs` | WebView2-based chat panel (singleton) with toolbar, fallback to browser | +| **QuickSendDialog** | `QuickSendDialog.cs` | Lightweight dialog for sending messages (supports Ctrl+Enter) | +| **StatusDetailForm** | `StatusDetailForm.cs` | Rich status view showing gateway connection, sessions, channels, usage, and app info | +| **NotificationHistoryForm** | `NotificationHistoryForm.cs` | Scrollable history of received notifications | +| **AutoStartManager** | `AutoStartManager.cs` | Windows startup integration via `HKCU\...\Run` registry key | +| **GlobalHotkey** | `GlobalHotkey.cs` | System-wide hotkey registration for quick access | + +### Data Flow + +1. **Gateway โ†’ Client**: WebSocket messages parsed into typed events (`agent`, `chat`, `health`, `session`, `usage`) +2. **Client โ†’ TrayApp**: C# events marshaled to UI thread via `SynchronizationContext.Post` +3. **TrayApp โ†’ UI**: Context menu items, tray icon, and toast notifications updated + +### Event Types from Gateway + +| Event | Handler | UI Result | +|-------|---------|-----------| +| `agent` (stream=job) | `HandleJobEvent` | Activity row update, icon badge | +| `agent` (stream=tool) | `HandleToolEvent` | Activity row with tool name + args detail | +| `chat` | `HandleChatEvent` | Toast notification for short assistant messages | +| `health` | `ParseChannelHealth` | Channel health rows in context menu | +| `session` | `HandleSessionEvent` | Session list refresh | +| `usage` | `ParseUsage` | Usage row (tokens, model, cost) | + +### Notification Classification + +Notifications are classified two ways: + +1. **Structured** (preferred): Events with explicit `type`, `category`, or `notificationType` fields +2. **Text-based** (fallback): Keyword matching on notification content (glucose, reminder, stock, email, calendar, etc.) + +### Session Awareness + +The activity display uses a stable session selection algorithm: + +1. Active main session always takes priority +2. Currently displayed session is kept if still active (prevents flip-flop) +3. Falls back to most recently active sub-session +4. 3-second debounce window prevents jitter during rapid activity changes + +### Tray Icon + +The tray icon is a programmatically drawn 16ร—16 circle: + +- **Teal**: Connected +- **Amber**: Connecting +- **Red**: Error +- **Gray**: Disconnected + +An activity badge (small corner dot) appears during tool execution: +- **Orange**: exec (running commands) +- **Green**: write/edit (file changes) +- **Blue**: read (file access) +- **Purple**: search/browser (web activity) + +### Settings Storage + +Settings are stored as JSON in `%APPDATA%\ClawdbotTray\settings.json`: + +```json +{ + "GatewayUrl": "ws://localhost:18789", + "Token": "...", + "AutoStart": false, + "ShowNotifications": true, + "NotificationSound": "Default" +} +``` + +### Deep Links + +The app registers `clawdbot://` URI scheme for cross-app integration: + +``` +clawdbot://agent?message=Hello&key=optional-auth-key +``` + +Without a key, the user is prompted before sending. With a key, the message is sent directly. + +## Build & Development + +### Prerequisites + +- .NET 9 SDK +- Windows 10 SDK (19041+) โ€” cross-compilation from Linux supported via `EnableWindowsTargeting` +- WebView2 Runtime (for chat panel, optional at runtime) + +### Build + +```bash +dotnet build +``` + +### Publish (single-file, self-contained) + +```bash +dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true +``` + +### CI + +GitHub Actions builds on every push. Check status: + +```bash +gh run list --repo shanselman/clawdbot-windows-tray --limit 1 +``` + +## Dependencies + +| Package | Purpose | +|---------|---------| +| `Microsoft.Toolkit.Uwp.Notifications` (7.1.3) | Toast notifications with rich content | +| `Microsoft.Web.WebView2` (1.0.3124.44) | Embedded browser for chat panel | +| `System.Text.Json` (9.0.0) | JSON serialization for settings and gateway protocol | + +## Security Considerations + +- **Token storage**: Plaintext in user AppData (future: Windows Credential Manager) +- **Deep links**: Untrusted deep links prompt user confirmation +- **WebSocket**: Supports both `ws://` (local) and `wss://` (remote) +- **Auto-start**: Registry-based, current user only (no elevation needed) +- **Logging**: Sensitive data (tokens) not logged + +## Known Limitations + +- Toast notifications require Windows 10 1903+ +- WebView2 Runtime must be installed separately for chat panel +- Single-instance enforced via Mutex (deep link forwarding to running instance TODO) +- Tray icon tooltip limited to 63 characters (full detail shown in context menu activity row) diff --git a/src/Moltbot.Tray/DeepLinkHandler.cs b/src/Moltbot.Tray/DeepLinkHandler.cs new file mode 100644 index 0000000..76c07c0 --- /dev/null +++ b/src/Moltbot.Tray/DeepLinkHandler.cs @@ -0,0 +1,136 @@ +using Microsoft.Win32; +using Moltbot.Shared; +using System; +using System.Collections.Specialized; +using System.Threading.Tasks; +using System.Web; +using System.Windows.Forms; + +namespace MoltbotTray; + +/// +/// Handles clawdbot:// URI scheme registration and processing. +/// Matches macOS deep link support (clawdbot://agent?message=...) +/// +public static class DeepLinkHandler +{ + private const string UriScheme = "Moltbot"; + private const string FriendlyName = "Clawdbot Agent Command"; + + /// + /// Registers the clawdbot:// URI scheme in the Windows registry. + /// Requires elevation for HKCR, falls back to HKCU. + /// + public static void RegisterUriScheme() + { + try + { + var exePath = Environment.ProcessPath ?? Application.ExecutablePath; + + // Try HKCU\Software\Classes (no elevation needed) + using var key = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{UriScheme}"); + if (key == null) return; + + key.SetValue("", $"URL:{FriendlyName}"); + key.SetValue("URL Protocol", ""); + + using var iconKey = key.CreateSubKey("DefaultIcon"); + iconKey?.SetValue("", $"\"{exePath}\",1"); + + using var commandKey = key.CreateSubKey(@"shell\open\command"); + commandKey?.SetValue("", $"\"{exePath}\" \"%1\""); + + Logger.Info($"Registered URI scheme: {UriScheme}://"); + } + catch (Exception ex) + { + Logger.Error("Failed to register URI scheme", ex); + } + } + + /// + /// Checks if the app was launched with a deep link argument. + /// + public static bool TryGetDeepLink(string[] args, out Uri? uri) + { + uri = null; + if (args.Length == 0) return false; + + foreach (var arg in args) + { + if (arg.StartsWith($"{UriScheme}://", StringComparison.OrdinalIgnoreCase)) + { + try + { + uri = new Uri(arg); + return true; + } + catch { } + } + } + return false; + } + + /// + /// Processes a clawdbot:// deep link. + /// Supports: clawdbot://agent?message=...&sessionKey=...&channel=... + /// + public static async Task ProcessDeepLinkAsync(Uri uri, MoltbotGatewayClient client) + { + Logger.Info($"Processing deep link: {uri}"); + + var host = uri.Host.ToLowerInvariant(); + var query = HttpUtility.ParseQueryString(uri.Query); + + switch (host) + { + case "agent": + await HandleAgentDeepLinkAsync(query, client); + break; + default: + Logger.Warn($"Unknown deep link host: {host}"); + break; + } + } + + private static async Task HandleAgentDeepLinkAsync(NameValueCollection query, MoltbotGatewayClient client) + { + var message = query["message"]; + if (string.IsNullOrWhiteSpace(message)) + { + Logger.Warn("Deep link: missing message parameter"); + return; + } + + var key = query["key"]; + var hasKey = !string.IsNullOrEmpty(key); + + // Without a key, prompt for confirmation (safety) + if (!hasKey) + { + var preview = message.Length > 100 ? message[..100] + "โ€ฆ" : message; + var result = MessageBox.Show( + $"A deep link wants to send this message to Clawdbot:\n\n\"{preview}\"\n\nAllow?", + "Clawdbot Deep Link", + MessageBoxButtons.YesNo, + MessageBoxIcon.Question); + + if (result != DialogResult.Yes) + { + Logger.Info("Deep link: user declined"); + return; + } + } + + try + { + await client.SendChatMessageAsync(message); + Logger.Info($"Deep link: sent message ({message.Length} chars)"); + } + catch (Exception ex) + { + Logger.Error("Deep link: failed to send", ex); + } + } +} + diff --git a/src/Moltbot.Tray/GlobalHotkey.cs b/src/Moltbot.Tray/GlobalHotkey.cs new file mode 100644 index 0000000..7375411 --- /dev/null +++ b/src/Moltbot.Tray/GlobalHotkey.cs @@ -0,0 +1,93 @@ +using System; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace MoltbotTray; + +/// +/// Registers a system-wide hotkey that works even when the app is not focused. +/// Default: Ctrl+Shift+Space to open Quick Send. +/// +public class GlobalHotkey : IDisposable +{ + [DllImport("user32.dll")] + private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); + + [DllImport("user32.dll")] + private static extern bool UnregisterHotKey(IntPtr hWnd, int id); + + private const int HotkeyId = 9001; + private const uint MOD_CONTROL = 0x0002; + private const uint MOD_SHIFT = 0x0004; + private const uint VK_SPACE = 0x20; + + private readonly HotkeyWindow _window; + private bool _registered; + + public event EventHandler? HotkeyPressed; + + public GlobalHotkey() + { + _window = new HotkeyWindow(this); + } + + public bool Register() + { + try + { + _registered = RegisterHotKey(_window.Handle, HotkeyId, MOD_CONTROL | MOD_SHIFT, VK_SPACE); + if (_registered) + Logger.Info("Global hotkey registered: Ctrl+Shift+Space"); + else + Logger.Warn("Failed to register global hotkey (may be in use by another app)"); + return _registered; + } + catch (Exception ex) + { + Logger.Error("Hotkey registration error", ex); + return false; + } + } + + public void Dispose() + { + if (_registered) + { + UnregisterHotKey(_window.Handle, HotkeyId); + _registered = false; + } + _window.Dispose(); + } + + internal void OnHotkeyPressed() + { + HotkeyPressed?.Invoke(this, EventArgs.Empty); + } + + private class HotkeyWindow : NativeWindow, IDisposable + { + private const int WM_HOTKEY = 0x0312; + private readonly GlobalHotkey _owner; + + public HotkeyWindow(GlobalHotkey owner) + { + _owner = owner; + CreateHandle(new CreateParams()); + } + + protected override void WndProc(ref Message m) + { + if (m.Msg == WM_HOTKEY && m.WParam.ToInt32() == HotkeyId) + { + _owner.OnHotkeyPressed(); + } + base.WndProc(ref m); + } + + public void Dispose() + { + DestroyHandle(); + } + } +} + diff --git a/src/Moltbot.Tray/ISSUE_TEMPLATE/bug_report.md b/src/Moltbot.Tray/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dadc328 --- /dev/null +++ b/src/Moltbot.Tray/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,45 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**System Information:** +- Windows version: [e.g. Windows 10 21H2, Windows 11 22H2] +- .NET version (if known): [e.g. .NET 10.0.1] +- Clawdbot version: [e.g. 1.2.3] +- Gateway URL: [e.g. ws://localhost:18789 or remote] + +**Gateway Information:** +- Is Clawdbot gateway running? [Yes/No] +- Gateway location: [WSL2, remote server, etc.] +- Can you connect to gateway from browser? [Yes/No] +- Gateway logs (if available): [paste relevant logs] + +**Additional context** +Add any other context about the problem here. + +**Configuration** +- Auto-start enabled: [Yes/No] +- Notifications enabled: [Yes/No] +- First time setup: [Yes/No] +- Custom gateway URL: [Yes/No] \ No newline at end of file diff --git a/src/Moltbot.Tray/ISSUE_TEMPLATE/feature_request.md b/src/Moltbot.Tray/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..1b5557e --- /dev/null +++ b/src/Moltbot.Tray/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,32 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Use case** +Describe how this feature would be used and who would benefit from it. + +**Implementation ideas** +If you have ideas about how this could be implemented, please share them. + +**Additional context** +Add any other context or screenshots about the feature request here. + +**Priority** +How important is this feature to you? +- [ ] Nice to have +- [ ] Would be very useful +- [ ] Critical for my workflow \ No newline at end of file diff --git a/src/Moltbot.Tray/IconHelper.cs b/src/Moltbot.Tray/IconHelper.cs new file mode 100644 index 0000000..de37387 --- /dev/null +++ b/src/Moltbot.Tray/IconHelper.cs @@ -0,0 +1,95 @@ +using System.Drawing; + +namespace MoltbotTray; + +/// +/// Shared icon helper for creating the lobster icon used throughout the app. +/// +public static class IconHelper +{ + private static Icon? _cachedLobsterIcon; + + /// + /// Gets the lobster icon for use in forms and windows. + /// + public static Icon GetLobsterIcon() + { + if (_cachedLobsterIcon != null) + return _cachedLobsterIcon; + + var bitmap = new Bitmap(16, 16); + using (var g = Graphics.FromImage(bitmap)) + { + g.Clear(Color.Transparent); + DrawPixelLobster(g); + } + + var hIcon = bitmap.GetHicon(); + _cachedLobsterIcon = Icon.FromHandle(hIcon); + return _cachedLobsterIcon; + } + + private static void DrawPixelLobster(Graphics g) + { + // Pixel lobster from SVG - 16x16 pixel art + var outline = Color.FromArgb(58, 10, 13); // #3a0a0d - dark outline + var body = Color.FromArgb(255, 79, 64); // #ff4f40 - red body + var claw = Color.FromArgb(255, 119, 95); // #ff775f - lighter claws + var eyeDark = Color.FromArgb(8, 16, 22); // #081016 - pupils + var eyeLight = Color.FromArgb(245, 251, 255); // #f5fbff - eye whites + + // Outline (dark border) + var outlinePixels = new[] { + (1,5), (1,6), (1,7), + (2,4), (2,8), + (3,3), (3,9), + (4,2), (4,10), + (5,2), (6,2), (7,2), (8,2), (9,2), (10,2), + (11,2), (12,3), (12,9), + (13,4), (13,8), + (14,5), (14,6), (14,7), + (5,11), (6,11), (7,11), (8,11), (9,11), (10,11), + (4,12), (11,12), + (3,13), (12,13), + (5,14), (6,14), (7,14), (8,14), (9,14), (10,14) + }; + foreach (var (x, y) in outlinePixels) + SetPixel(g, x, y, outline); + + // Body (red) + var bodyPixels = new[] { + (5,3), (6,3), (7,3), (8,3), (9,3), (10,3), + (4,4), (5,4), (7,4), (8,4), (10,4), (11,4), + (3,5), (4,5), (5,5), (7,5), (8,5), (10,5), (11,5), (12,5), + (3,6), (4,6), (5,6), (6,6), (7,6), (8,6), (9,6), (10,6), (11,6), (12,6), + (3,7), (4,7), (5,7), (6,7), (7,7), (8,7), (9,7), (10,7), (11,7), (12,7), + (4,8), (5,8), (6,8), (7,8), (8,8), (9,8), (10,8), (11,8), + (5,9), (6,9), (7,9), (8,9), (9,9), (10,9), + (5,12), (6,12), (7,12), (8,12), (9,12), (10,12), + (6,13), (7,13), (8,13), (9,13) + }; + foreach (var (x, y) in bodyPixels) + SetPixel(g, x, y, body); + + // Claws (lighter red) + var clawPixels = new[] { + (1,6), (2,5), (2,6), (2,7), + (13,5), (13,6), (13,7), (14,6) + }; + foreach (var (x, y) in clawPixels) + SetPixel(g, x, y, claw); + + // Eyes + SetPixel(g, 6, 4, eyeLight); + SetPixel(g, 9, 4, eyeLight); + SetPixel(g, 6, 5, eyeDark); + SetPixel(g, 9, 5, eyeDark); + } + + private static void SetPixel(Graphics g, int x, int y, Color c) + { + using var brush = new SolidBrush(c); + g.FillRectangle(brush, x, y, 1, 1); + } +} + diff --git a/src/Moltbot.Tray/LICENSE b/src/Moltbot.Tray/LICENSE new file mode 100644 index 0000000..4c7463e --- /dev/null +++ b/src/Moltbot.Tray/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Scott Hanselman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/src/Moltbot.Tray/Logger.cs b/src/Moltbot.Tray/Logger.cs new file mode 100644 index 0000000..aa978e0 --- /dev/null +++ b/src/Moltbot.Tray/Logger.cs @@ -0,0 +1,106 @@ +using System; +using System.Diagnostics; +using System.IO; +using Moltbot.Shared; + +namespace MoltbotTray; + +/// +/// Simple file + debug logger for troubleshooting. +/// Writes to %LOCALAPPDATA%\MoltbotTray\clawdbot-tray.log +/// +public static class Logger +{ + private static readonly string LogDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "MoltbotTray"); + private static readonly string LogPath = Path.Combine(LogDir, "clawdbot-tray.log"); + private static readonly object Lock = new(); + private static bool _initialized; + private static StreamWriter? _writer; + + /// Get a logger instance that implements IMoltbotLogger for the shared library. + public static IMoltbotLogger Instance { get; } = new LoggerAdapter(); + + public static void Info(string message) => Write("INFO", message); + public static void Warn(string message) => Write("WARN", message); + public static void Error(string message) => Write("ERROR", message); + public static void Error(string message, Exception ex) => Write("ERROR", $"{message}: {ex.Message}\n Stack: {ex.StackTrace}"); + + /// Flush and close the log file (call on app exit). + public static void Shutdown() + { + lock (Lock) + { + _writer?.Flush(); + _writer?.Dispose(); + _writer = null; + _initialized = false; + } + } + + private static void EnsureInitialized() + { + if (_initialized) return; + try + { + Directory.CreateDirectory(LogDir); + RotateIfNeeded(); + _writer = new StreamWriter(LogPath, append: true) { AutoFlush = true }; + _initialized = true; + } + catch + { + // Can't init โ€” fall back to Debug.WriteLine only + } + } + + private static void RotateIfNeeded() + { + try + { + var info = new FileInfo(LogPath); + if (info.Exists && info.Length > 1_048_576) + { + var backup = LogPath + ".1"; + if (File.Exists(backup)) File.Delete(backup); + File.Move(LogPath, backup); + } + } + catch { } + } + + private static void Write(string level, string message) + { + var line = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] [{level}] {message}"; + Debug.WriteLine(line); + + try + { + lock (Lock) + { + EnsureInitialized(); + _writer?.WriteLine(line); + } + } + catch + { + // Don't crash if we can't write logs + } + } + + /// Adapter to make the static Logger work with IMoltbotLogger interface. + private class LoggerAdapter : IMoltbotLogger + { + public void Info(string message) => Logger.Info(message); + public void Warn(string message) => Logger.Warn(message); + public void Error(string message, Exception? ex = null) + { + if (ex != null) + Logger.Error(message, ex); + else + Logger.Error(message); + } + } +} + diff --git a/src/Moltbot.Tray/Moltbot.Tray.csproj b/src/Moltbot.Tray/Moltbot.Tray.csproj new file mode 100644 index 0000000..c02cb28 --- /dev/null +++ b/src/Moltbot.Tray/Moltbot.Tray.csproj @@ -0,0 +1,39 @@ + + + + WinExe + net9.0-windows10.0.19041.0 + true + enable + enable + true + MSB3277 + moltbot.ico + Moltbot Windows Tray + Windows system tray companion app for Moltbot + Scott Hanselman + Moltbot Tray + Copyright ยฉ 2026 Scott Hanselman + 1.0.0 + 1.0.0 + 1.0.0 + true + true + + + + + + + + + + + + + + + + + + diff --git a/src/Moltbot.Tray/NotificationHistoryForm.cs b/src/Moltbot.Tray/NotificationHistoryForm.cs new file mode 100644 index 0000000..7ffa390 --- /dev/null +++ b/src/Moltbot.Tray/NotificationHistoryForm.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Windows.Forms; + +namespace MoltbotTray; + +/// +/// Shows recent notification history in a simple list view. +/// +public class NotificationHistoryForm : Form +{ + private ListView? _listView; + private Button _clearButton = null!; + private Button _closeButton = null!; + private static NotificationHistoryForm? _instance; + + private static readonly List _history = new(); + private const int MaxHistory = 200; + + public static void AddEntry(string title, string message, string type) + { + lock (_history) + { + _history.Add(new NotificationEntry + { + Timestamp = DateTime.Now, + Title = title, + Message = message, + Type = type + }); + + // Trim old entries + while (_history.Count > MaxHistory) + _history.RemoveAt(0); + } + + // If window is open, refresh it + _instance?.RefreshList(); + } + + public static void ShowOrFocus() + { + if (_instance != null && !_instance.IsDisposed) + { + _instance.BringToFront(); + _instance.Focus(); + return; + } + + _instance = new NotificationHistoryForm(); + _instance.Show(); + } + + private NotificationHistoryForm() + { + InitializeComponent(); + RefreshList(); + } + + private void InitializeComponent() + { + Text = "Notification History โ€” Moltbot Tray"; + Size = new Size(600, 450); + MinimumSize = new Size(400, 300); + StartPosition = FormStartPosition.CenterScreen; + Icon = IconHelper.GetLobsterIcon(); + + _listView = new ListView + { + Dock = DockStyle.Fill, + View = View.Details, + FullRowSelect = true, + GridLines = true, + Font = new Font("Segoe UI", 9F) + }; + _listView.Columns.Add("Time", 130); + _listView.Columns.Add("Type", 80); + _listView.Columns.Add("Title", 150); + _listView.Columns.Add("Message", 300); + + var buttonPanel = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 40, + FlowDirection = FlowDirection.RightToLeft, + Padding = new Padding(5) + }; + + _closeButton = new Button + { + Text = "&Close", + Size = new Size(75, 26), + Font = new Font("Segoe UI", 9F) + }; + _closeButton.Click += (_, _) => Close(); + + _clearButton = new Button + { + Text = "C&lear All", + Size = new Size(85, 26), + Font = new Font("Segoe UI", 9F) + }; + _clearButton.Click += (_, _) => + { + lock (_history) _history.Clear(); + RefreshList(); + }; + + buttonPanel.Controls.Add(_closeButton); + buttonPanel.Controls.Add(_clearButton); + + Controls.Add(_listView); + Controls.Add(buttonPanel); + } + + private void RefreshList() + { + if (_listView == null || _listView.IsDisposed) return; + + if (InvokeRequired) + { + Invoke(new Action(RefreshList)); + return; + } + + _listView.BeginUpdate(); + _listView.Items.Clear(); + + lock (_history) + { + // Show newest first + for (int i = _history.Count - 1; i >= 0; i--) + { + var entry = _history[i]; + var item = new ListViewItem(entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")); + item.SubItems.Add(entry.Type); + item.SubItems.Add(entry.Title); + item.SubItems.Add(entry.Message.Replace('\n', ' ')); + _listView.Items.Add(item); + } + } + + _listView.EndUpdate(); + } + + protected override void OnFormClosed(FormClosedEventArgs e) + { + _instance = null; + base.OnFormClosed(e); + } + + private class NotificationEntry + { + public DateTime Timestamp { get; set; } + public string Title { get; set; } = ""; + public string Message { get; set; } = ""; + public string Type { get; set; } = ""; + } +} + diff --git a/src/Moltbot.Tray/Program.cs b/src/Moltbot.Tray/Program.cs new file mode 100644 index 0000000..1aefd4f --- /dev/null +++ b/src/Moltbot.Tray/Program.cs @@ -0,0 +1,34 @@ +using MoltbotTray; +using System; +using System.Threading; +using System.Windows.Forms; + +namespace MoltbotTray; + +internal static class Program +{ + [STAThread] + static void Main(string[] args) + { + // Single instance check + using var mutex = new Mutex(true, "MoltbotTray", out bool createdNew); + if (!createdNew) + { + // TODO: Forward deep link args to running instance via named pipe + MessageBox.Show("Moltbot Tray is already running.", "Moltbot Tray", + MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + // Register URI scheme on first run + DeepLinkHandler.RegisterUriScheme(); + + Application.SetHighDpiMode(HighDpiMode.SystemAware); + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + + var trayApp = new TrayApplication(args); + Application.Run(trayApp); + } +} + diff --git a/src/Moltbot.Tray/QuickSendDialog.cs b/src/Moltbot.Tray/QuickSendDialog.cs new file mode 100644 index 0000000..7658bb5 --- /dev/null +++ b/src/Moltbot.Tray/QuickSendDialog.cs @@ -0,0 +1,135 @@ +using System; +using System.Drawing; +using System.Windows.Forms; + +namespace MoltbotTray; + +public partial class QuickSendDialog : Form +{ + private TextBox _messageTextBox = null!; + private Button _sendButton = null!; + private Button _cancelButton = null!; + private Label _hintLabel = null!; + + public string Message => _messageTextBox.Text; + + public QuickSendDialog() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + // Form properties + Text = "Quick Send โ€” Clawdbot"; + Size = new Size(500, 220); + StartPosition = FormStartPosition.CenterScreen; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + ShowInTaskbar = true; + TopMost = true; // Always on top when opened via hotkey + Icon = IconHelper.GetLobsterIcon(); + + // Label + var label = new Label + { + Text = "Send a message to Clawdbot:", + Location = new Point(12, 12), + Size = new Size(460, 20), + Font = new Font("Segoe UI", 9.5F, FontStyle.Regular) + }; + + // Message text box + _messageTextBox = new TextBox + { + Location = new Point(12, 36), + Size = new Size(460, 90), + Multiline = true, + ScrollBars = ScrollBars.Vertical, + Font = new Font("Segoe UI", 10F, FontStyle.Regular), + AcceptsReturn = false // Enter sends, Shift+Enter for newline + }; + + // Hint label + _hintLabel = new Label + { + Text = "Enter to send ยท Esc to cancel ยท Shift+Enter for new line", + Location = new Point(12, 132), + Size = new Size(300, 18), + Font = new Font("Segoe UI", 8F, FontStyle.Regular), + ForeColor = Color.Gray + }; + + // Send button + _sendButton = new Button + { + Text = "&Send", + Location = new Point(316, 148), + Size = new Size(75, 28), + UseVisualStyleBackColor = true, + Font = new Font("Segoe UI", 9F, FontStyle.Regular) + }; + _sendButton.Click += OnSendClick; + + // Cancel button + _cancelButton = new Button + { + Text = "&Cancel", + Location = new Point(397, 148), + Size = new Size(75, 28), + UseVisualStyleBackColor = true, + Font = new Font("Segoe UI", 9F, FontStyle.Regular) + }; + _cancelButton.Click += OnCancelClick; + + // Set dialog buttons + AcceptButton = _sendButton; + CancelButton = _cancelButton; + + // Add controls + Controls.Add(label); + Controls.Add(_messageTextBox); + Controls.Add(_hintLabel); + Controls.Add(_sendButton); + Controls.Add(_cancelButton); + + // Focus the text box on show + Shown += (_, _) => + { + _messageTextBox.Focus(); + Activate(); // Ensure window is focused when opened via hotkey + }; + } + + private void OnSendClick(object? sender, EventArgs e) + { + if (string.IsNullOrWhiteSpace(_messageTextBox.Text)) + { + _messageTextBox.Focus(); + return; + } + + DialogResult = DialogResult.OK; + Close(); + } + + private void OnCancelClick(object? sender, EventArgs e) + { + DialogResult = DialogResult.Cancel; + Close(); + } + + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) + { + // Ctrl+Enter or Enter (without Shift) as send + if (keyData == (Keys.Control | Keys.Enter) || keyData == Keys.Enter) + { + OnSendClick(null, EventArgs.Empty); + return true; + } + + return base.ProcessCmdKey(ref msg, keyData); + } +} + diff --git a/src/Moltbot.Tray/README.md b/src/Moltbot.Tray/README.md new file mode 100644 index 0000000..fa14846 --- /dev/null +++ b/src/Moltbot.Tray/README.md @@ -0,0 +1,225 @@ +# Clawdbot Windows Tray + +A Windows system tray companion for [Clawdbot](https://github.com/clawdbot/clawdbot) โ€” the Windows equivalent of the macOS menu bar app. Provides desktop notifications, embedded chat, live agent activity monitoring, and gateway status tracking. + +## Features + +### System Tray +- **Lobster icon** ๐Ÿฆž when connected (pixel art), color-coded circles for other states +- **Activity badge** showing what the agent is doing (exec, read, write, edit, search, browser, message, tool) +- **Context menu** with status, sessions, channels, usage, quick send, settings, and auto-start +- **Text status labels** `[ON]`/`[OFF]`/`[READY]`/`[LINKED]` for clarity +- **Clickable status** opens detailed status view +- **Double-click** opens embedded web chat +- **Open Dashboard** opens browser with authenticated session + +### Session Awareness +- **Live session tracking** โ€” see main and sub-sessions in real-time +- **Session detail** โ€” model, channel, current activity per session +- **Activity display** โ€” "Main ยท ๐Ÿ’ป pnpm test" or "Sub ยท ๐Ÿ“„ reading file" + +### Usage & Context +- **Token usage** display (input/output/total with human-readable formatting) +- **Cost tracking** when available from gateway +- **Request count** and active model + +### WebChat Panel +- **Embedded chat** via WebView2 โ€” no browser needed +- **Dark mode** background +- Toolbar with home, refresh, pop-out to browser, and DevTools +- Singleton window (double-click tray icon or "Open Web Chat" menu) + +### Notifications +- **Windows toast notifications** with per-type filtering: + - ๐Ÿฉธ Health / CGM alerts + - ๐Ÿšจ Urgent / error alerts + - โฐ Reminders + - ๐Ÿ“ง Email notifications + - ๐Ÿ“… Calendar events + - ๐Ÿ”จ Build / CI + - ๐Ÿ“ฆ Stock availability + - ๐Ÿค– General info +- **Clickable toasts** โ€” Quick Send toasts open dashboard when clicked +- **Notification history** โ€” scrollable list with timestamps, even for filtered-out notifications +- Fallback to balloon tips if toast fails + +### Channel Health +- Live WhatsApp, Telegram, and other channel status +- Smart status detection: `[READY]` (probe OK), `[LINKED]` (authenticated), `[ON]`/`[OFF]` +- Shows linked state, auth age, errors, and stale warnings +- On-demand health check button + +### Keyboard Shortcuts +- **Ctrl+Shift+Space** โ€” Global hotkey to open Quick Send from anywhere +- **Enter** โ€” Send message in Quick Send dialog +- **Shift+Enter** โ€” New line in Quick Send +- **Esc** โ€” Cancel Quick Send + +### Deep Links +- Registers `clawdbot://` URI scheme +- `clawdbot://agent?message=Hello` sends a message to the agent +- Confirmation prompt for safety (bypass with `key` parameter) + +### Quality of Life +- **ARM64 support** โ€” native builds for Windows on ARM +- **Auto-start** via Windows Registry +- **Exponential backoff** on reconnect (1s โ†’ 60s) +- **File logging** to `%LOCALAPPDATA%\ClawdbotTray\clawdbot-tray.log` (with rotation at 1MB) +- **Open Log File** menu item for quick debugging +- **Single instance** enforcement (mutex) +- **Proper GDI handle cleanup** (no icon leaks) +- **Status detail view** โ€” rich dark-themed status panel + +## Requirements + +- Windows 10 version 1903+ (for toast notifications) +- .NET 9 Runtime (included in self-contained builds) +- [WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) (for chat panel) +- Clawdbot gateway running (typically in WSL2) + +## Quick Start + +1. Download the latest release from [Releases](https://github.com/shanselman/clawdbot-windows-tray/releases) + - **x64**: For Intel/AMD processors + - **arm64**: For Windows on ARM (e.g., Surface Pro X, Snapdragon laptops) +2. Run `ClawdbotTray.exe` +3. Right-click tray icon โ†’ Settings +4. Enter gateway URL (`ws://localhost:18789`) and your token +5. Done โ€” you'll see the icon turn green when connected + +### Finding Your Gateway Token + +```bash +# In WSL2: +cat ~/.clawdbot/clawdbot.json | grep token +# Or: +clawdbot config get gateway.auth.token +``` + +## Build from Source + +```bash +git clone https://github.com/shanselman/clawdbot-windows-tray.git +cd clawdbot-windows-tray + +# Windows โ€” auto-detects architecture +build.bat + +# Manual build +dotnet build -c Release -r win-x64 +dotnet build -c Release -r win-arm64 + +# Self-contained single-file executable +dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -o publish +dotnet publish -c Release -r win-arm64 --self-contained -p:PublishSingleFile=true -o publish-arm64 + +# Cross-compile from Linux (for CI) +dotnet build -p:EnableWindowsTargeting=true -r win-x64 +``` + +## Project Structure + +``` +โ”œโ”€โ”€ Program.cs # Entry point, single instance, deep link registration +โ”œโ”€โ”€ TrayApplication.cs # Tray icon, menu, event wiring, UI updates +โ”œโ”€โ”€ ClawdbotGatewayClient.cs # WebSocket client, protocol v3, event parsing, state tracking +โ”œโ”€โ”€ WebChatForm.cs # WebView2 chat panel (singleton, dark mode) +โ”œโ”€โ”€ QuickSendDialog.cs # Quick message input (Enter to send, TopMost) +โ”œโ”€โ”€ StatusDetailForm.cs # Rich status detail view (dark theme) +โ”œโ”€โ”€ NotificationHistoryForm.cs # Scrollable notification history +โ”œโ”€โ”€ GlobalHotkey.cs # Ctrl+Shift+Space system-wide hotkey +โ”œโ”€โ”€ DeepLinkHandler.cs # clawdbot:// URI scheme handler +โ”œโ”€โ”€ SettingsManager.cs # JSON config with notification filters +โ”œโ”€โ”€ SettingsDialog.cs # Settings UI (connection, startup, notification filters) +โ”œโ”€โ”€ AutoStartManager.cs # Windows Registry auto-start +โ”œโ”€โ”€ Logger.cs # File + debug logger with rotation +โ””โ”€โ”€ ClawdbotTray.csproj # .NET 9, Windows Forms, WebView2 +``` + +## macOS Parity Status + +This Windows tray app aims for feature parity with the [Clawdbot macOS menu bar app](https://github.com/clawdbot/clawdbot-macos). + +| Feature | macOS | Windows | Notes | +|---------|:-----:|:-------:|-------| +| System tray/menu bar icon | โœ… | โœ… | Lobster ๐Ÿฆž when connected | +| Status colors/indicators | โœ… | โœ… | Text labels `[ON]/[OFF]` for clarity | +| Activity badges | โœ… | โœ… | exec/read/write/search/browser | +| Toast/native notifications | โœ… | โœ… | Windows toast + fallback | +| Per-type notification filters | โœ… | โœ… | Health, urgent, email, etc. | +| Clickable notifications | โœ… | โœ… | Opens dashboard with auth | +| Notification history | โ€” | โœ… | Windows-only feature | +| Embedded chat (WebView) | โœ… | โœ… | WebView2 | +| Open Dashboard in browser | โœ… | โœ… | Token auto-included | +| Channel health display | โœ… | โœ… | Telegram, WhatsApp status | +| Session awareness (main/sub) | โœ… | โœ… | Live session tracking | +| Usage/token display | โœ… | โœ… | Input/output/total | +| Deep link URI scheme | โœ… | โœ… | `clawdbot://` | +| Global hotkey | โ€” | โœ… | Ctrl+Shift+Space | +| Auto-start | โœ… | โœ… | Registry-based | +| Quick send | โœ… | โœ… | Fire-and-forget to main session | +| Health check (on-demand) | โœ… | โœ… | | +| Status detail view | โ€” | โœ… | Windows-only feature | +| File logging | โœ… | โœ… | With rotation | +| ARM64 support | โœ… | โœ… | Apple Silicon / Windows ARM | +| Canvas panel | โœ… | ๐Ÿ”œ | Planned | +| Voice wake / push-to-talk | โœ… | ๐Ÿ”œ | Planned | +| Skills settings UI | โœ… | ๐Ÿ”œ | Planned | +| TCC permissions management | โœ… | N/A | macOS-specific | +| PeekabooBridge (UI automation) | โœ… | N/A | macOS-specific | +| XPC / node host service | โœ… | N/A | macOS-specific | + +## Settings + +Settings are stored in `%APPDATA%\ClawdbotTray\settings.json`: + +```json +{ + "GatewayUrl": "ws://localhost:18789", + "Token": "your-token", + "AutoStart": false, + "ShowNotifications": true, + "NotificationSound": "Default", + "NotifyHealth": true, + "NotifyUrgent": true, + "NotifyReminder": true, + "NotifyEmail": true, + "NotifyCalendar": true, + "NotifyBuild": true, + "NotifyStock": true, + "NotifyInfo": true, + "ShowGlobalHotkey": true +} +``` + +## Troubleshooting + +**Can't connect?** +- Check gateway: `clawdbot gateway status` in WSL2 +- Verify token matches `~/.clawdbot/clawdbot.json` +- Try WSL2 IP directly: `ws://:18789` (`wsl hostname -I`) + +**No notifications?** +- Check Windows Settings โ†’ Notifications +- Check Focus Assist / Do Not Disturb +- Check notification filter settings in the app + +**WebChat blank?** +- Install [WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) +- Check logs: `%LOCALAPPDATA%\ClawdbotTray\clawdbot-tray.log` +- Right-click tray โ†’ Open Log File + +**Global hotkey not working?** +- Another app may have registered Ctrl+Shift+Space +- Check Settings โ†’ Global hotkey is enabled +- Check the log file for "Failed to register global hotkey" + +## License + +MIT + +## Credits + +- Built with .NET 9, Windows Forms, and [WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) +- Toast notifications via [Microsoft.Toolkit.Uwp.Notifications](https://github.com/CommunityToolkit/WindowsCommunityToolkit) +- Part of the [Clawdbot](https://github.com/clawdbot/clawdbot) ecosystem diff --git a/src/Moltbot.Tray/SettingsDialog.cs b/src/Moltbot.Tray/SettingsDialog.cs new file mode 100644 index 0000000..a91f0cc --- /dev/null +++ b/src/Moltbot.Tray/SettingsDialog.cs @@ -0,0 +1,377 @@ +using Moltbot.Shared; +using System; +using System.Drawing; +using System.Windows.Forms; + +namespace MoltbotTray; + +public partial class SettingsDialog : Form +{ + private readonly SettingsManager _settings; + + private TextBox _gatewayUrlTextBox = null!; + private TextBox _tokenTextBox = null!; + private CheckBox _autoStartCheckBox = null!; + private CheckBox _showNotificationsCheckBox = null!; + private CheckBox _globalHotkeyCheckBox = null!; + private ComboBox _notificationSoundComboBox = null!; + private Button _testConnectionButton = null!; + private Button _okButton = null!; + private Button _cancelButton = null!; + private Label _statusLabel = null!; + + // Notification filter checkboxes + private CheckBox _notifyHealthCb = null!; + private CheckBox _notifyUrgentCb = null!; + private CheckBox _notifyReminderCb = null!; + private CheckBox _notifyEmailCb = null!; + private CheckBox _notifyCalendarCb = null!; + private CheckBox _notifyBuildCb = null!; + private CheckBox _notifyStockCb = null!; + private CheckBox _notifyInfoCb = null!; + private Panel _notifyFilterPanel = null!; + + public SettingsDialog(SettingsManager settings) + { + _settings = settings; + InitializeComponent(); + LoadSettings(); + } + + private void InitializeComponent() + { + Text = "Settings โ€” Moltbot Tray"; + Size = new Size(480, 560); + StartPosition = FormStartPosition.CenterScreen; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + ShowInTaskbar = false; + AutoScroll = true; + Icon = IconHelper.GetLobsterIcon(); + + var y = 12; + var labelFont = new Font("Segoe UI", 9F); + var headerFont = new Font("Segoe UI", 9F, FontStyle.Bold); + + // --- Connection Section --- + var connHeader = new Label + { + Text = "CONNECTION", + Location = new Point(12, y), + Size = new Size(200, 20), + Font = headerFont, + ForeColor = Color.FromArgb(0, 120, 215) + }; + y += 22; + + var gatewayUrlLabel = new Label + { + Text = "Gateway URL:", + Location = new Point(12, y), + Size = new Size(100, 20), + Font = labelFont + }; + y += 22; + + _gatewayUrlTextBox = new TextBox + { + Location = new Point(12, y), + Size = new Size(310, 23), + Font = labelFont + }; + + _testConnectionButton = new Button + { + Text = "Test", + Location = new Point(330, y - 1), + Size = new Size(65, 25), + Font = labelFont + }; + _testConnectionButton.Click += OnTestConnection; + y += 30; + + var tokenLabel = new Label + { + Text = "Token:", + Location = new Point(12, y), + Size = new Size(100, 20), + Font = labelFont + }; + y += 22; + + _tokenTextBox = new TextBox + { + Location = new Point(12, y), + Size = new Size(310, 23), + Font = labelFont, + UseSystemPasswordChar = true + }; + + _statusLabel = new Label + { + Text = "", + Location = new Point(330, y + 2), + Size = new Size(130, 20), + Font = new Font("Segoe UI", 8F), + ForeColor = Color.DarkGreen + }; + y += 35; + + // --- Startup Section --- + var startupHeader = new Label + { + Text = "STARTUP", + Location = new Point(12, y), + Size = new Size(200, 20), + Font = headerFont, + ForeColor = Color.FromArgb(0, 120, 215) + }; + y += 22; + + _autoStartCheckBox = new CheckBox + { + Text = "Start automatically with Windows", + Location = new Point(12, y), + Size = new Size(280, 22), + Font = labelFont + }; + y += 26; + + _globalHotkeyCheckBox = new CheckBox + { + Text = "Global hotkey (Ctrl+Shift+Space โ†’ Quick Send)", + Location = new Point(12, y), + Size = new Size(340, 22), + Font = labelFont + }; + y += 35; + + // --- Notifications Section --- + var notifyHeader = new Label + { + Text = "NOTIFICATIONS", + Location = new Point(12, y), + Size = new Size(200, 20), + Font = headerFont, + ForeColor = Color.FromArgb(0, 120, 215) + }; + y += 22; + + _showNotificationsCheckBox = new CheckBox + { + Text = "Show desktop notifications", + Location = new Point(12, y), + Size = new Size(250, 22), + Font = labelFont + }; + _showNotificationsCheckBox.CheckedChanged += (_, _) => + { + _notifyFilterPanel.Enabled = _showNotificationsCheckBox.Checked; + }; + y += 26; + + var soundLabel = new Label + { + Text = "Sound:", + Location = new Point(12, y), + Size = new Size(50, 20), + Font = labelFont + }; + + _notificationSoundComboBox = new ComboBox + { + Location = new Point(65, y - 2), + Size = new Size(140, 23), + DropDownStyle = ComboBoxStyle.DropDownList, + Font = labelFont + }; + _notificationSoundComboBox.Items.AddRange(new[] { "Default", "None", "Critical", "Information" }); + y += 30; + + // Filter panel + var filterLabel = new Label + { + Text = "Show toasts for:", + Location = new Point(12, y), + Size = new Size(120, 20), + Font = labelFont, + ForeColor = Color.Gray + }; + y += 22; + + _notifyFilterPanel = new Panel + { + Location = new Point(12, y), + Size = new Size(440, 72), + BorderStyle = BorderStyle.None + }; + + // Two columns of filter checkboxes + var cbFont = new Font("Segoe UI", 8.5F); + _notifyHealthCb = MakeFilterCb("๐Ÿฉธ Health", 0, 0, cbFont); + _notifyUrgentCb = MakeFilterCb("๐Ÿšจ Urgent", 0, 24, cbFont); + _notifyReminderCb = MakeFilterCb("โฐ Reminders", 0, 48, cbFont); + _notifyEmailCb = MakeFilterCb("๐Ÿ“ง Email", 150, 0, cbFont); + _notifyCalendarCb = MakeFilterCb("๐Ÿ“… Calendar", 150, 24, cbFont); + _notifyBuildCb = MakeFilterCb("๐Ÿ”จ Build/CI", 150, 48, cbFont); + _notifyStockCb = MakeFilterCb("๐Ÿ“ฆ Stock", 300, 0, cbFont); + _notifyInfoCb = MakeFilterCb("๐Ÿค– General", 300, 24, cbFont); + + _notifyFilterPanel.Controls.AddRange(new Control[] + { + _notifyHealthCb, _notifyUrgentCb, _notifyReminderCb, + _notifyEmailCb, _notifyCalendarCb, _notifyBuildCb, + _notifyStockCb, _notifyInfoCb + }); + + y += 80; + + // --- Buttons --- + y += 10; + _okButton = new Button + { + Text = "&OK", + Location = new Point(300, y), + Size = new Size(75, 28), + Font = labelFont + }; + _okButton.Click += OnOkClick; + + _cancelButton = new Button + { + Text = "&Cancel", + Location = new Point(382, y), + Size = new Size(75, 28), + Font = labelFont + }; + _cancelButton.Click += OnCancelClick; + + AcceptButton = _okButton; + CancelButton = _cancelButton; + + // Add all controls + Controls.AddRange(new Control[] + { + connHeader, gatewayUrlLabel, _gatewayUrlTextBox, _testConnectionButton, + tokenLabel, _tokenTextBox, _statusLabel, + startupHeader, _autoStartCheckBox, _globalHotkeyCheckBox, + notifyHeader, _showNotificationsCheckBox, soundLabel, _notificationSoundComboBox, + filterLabel, _notifyFilterPanel, + _okButton, _cancelButton + }); + } + + private static CheckBox MakeFilterCb(string text, int x, int y, Font font) + { + return new CheckBox + { + Text = text, + Location = new Point(x, y), + Size = new Size(140, 22), + Font = font, + Checked = true + }; + } + + private void LoadSettings() + { + _gatewayUrlTextBox.Text = _settings.GatewayUrl; + _tokenTextBox.Text = _settings.Token; + _autoStartCheckBox.Checked = _settings.AutoStart; + _globalHotkeyCheckBox.Checked = _settings.ShowGlobalHotkey; + _showNotificationsCheckBox.Checked = _settings.ShowNotifications; + _notifyFilterPanel.Enabled = _settings.ShowNotifications; + + var soundIndex = _notificationSoundComboBox.Items.IndexOf(_settings.NotificationSound); + _notificationSoundComboBox.SelectedIndex = soundIndex >= 0 ? soundIndex : 0; + + _notifyHealthCb.Checked = _settings.NotifyHealth; + _notifyUrgentCb.Checked = _settings.NotifyUrgent; + _notifyReminderCb.Checked = _settings.NotifyReminder; + _notifyEmailCb.Checked = _settings.NotifyEmail; + _notifyCalendarCb.Checked = _settings.NotifyCalendar; + _notifyBuildCb.Checked = _settings.NotifyBuild; + _notifyStockCb.Checked = _settings.NotifyStock; + _notifyInfoCb.Checked = _settings.NotifyInfo; + } + + private void SaveSettings() + { + _settings.GatewayUrl = _gatewayUrlTextBox.Text.Trim(); + _settings.Token = _tokenTextBox.Text.Trim(); + _settings.AutoStart = _autoStartCheckBox.Checked; + _settings.ShowGlobalHotkey = _globalHotkeyCheckBox.Checked; + _settings.ShowNotifications = _showNotificationsCheckBox.Checked; + _settings.NotificationSound = _notificationSoundComboBox.SelectedItem?.ToString() ?? "Default"; + _settings.NotifyHealth = _notifyHealthCb.Checked; + _settings.NotifyUrgent = _notifyUrgentCb.Checked; + _settings.NotifyReminder = _notifyReminderCb.Checked; + _settings.NotifyEmail = _notifyEmailCb.Checked; + _settings.NotifyCalendar = _notifyCalendarCb.Checked; + _settings.NotifyBuild = _notifyBuildCb.Checked; + _settings.NotifyStock = _notifyStockCb.Checked; + _settings.NotifyInfo = _notifyInfoCb.Checked; + } + + private async void OnTestConnection(object? sender, EventArgs e) + { + _testConnectionButton.Enabled = false; + _statusLabel.Text = "Testing..."; + _statusLabel.ForeColor = Color.Blue; + + try + { + var testClient = new MoltbotGatewayClient( + _gatewayUrlTextBox.Text.Trim(), + _tokenTextBox.Text.Trim()); + + await testClient.ConnectAsync(); + await testClient.DisconnectAsync(); + testClient.Dispose(); + + _statusLabel.Text = "โœ… Connected"; + _statusLabel.ForeColor = Color.DarkGreen; + } + catch (Exception ex) + { + _statusLabel.Text = $"โŒ {ex.Message}"; + _statusLabel.ForeColor = Color.Red; + } + finally + { + _testConnectionButton.Enabled = true; + } + } + + private void OnOkClick(object? sender, EventArgs e) + { + if (string.IsNullOrWhiteSpace(_gatewayUrlTextBox.Text)) + { + MessageBox.Show("Gateway URL is required.", "Settings", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + _gatewayUrlTextBox.Focus(); + return; + } + + if (!Uri.TryCreate(_gatewayUrlTextBox.Text.Trim(), UriKind.Absolute, out var uri) || + (uri.Scheme != "ws" && uri.Scheme != "wss")) + { + MessageBox.Show("Gateway URL must be a valid WebSocket URL (ws:// or wss://).", "Settings", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + _gatewayUrlTextBox.Focus(); + return; + } + + SaveSettings(); + DialogResult = DialogResult.OK; + Close(); + } + + private void OnCancelClick(object? sender, EventArgs e) + { + DialogResult = DialogResult.Cancel; + Close(); + } +} + diff --git a/src/Moltbot.Tray/SettingsManager.cs b/src/Moltbot.Tray/SettingsManager.cs new file mode 100644 index 0000000..57cc98f --- /dev/null +++ b/src/Moltbot.Tray/SettingsManager.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; + +namespace MoltbotTray; + +public class SettingsManager +{ + private static readonly string SettingsDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "MoltbotTray"); + + private static readonly string SettingsFile = Path.Combine(SettingsDirectory, "settings.json"); + + public string GatewayUrl { get; set; } = "ws://localhost:18789"; + public string Token { get; set; } = ""; + public bool AutoStart { get; set; } = false; + public bool ShowNotifications { get; set; } = true; + public string NotificationSound { get; set; } = "Default"; + + // Notification filters โ€” which types to show toasts for + public bool NotifyHealth { get; set; } = true; + public bool NotifyUrgent { get; set; } = true; + public bool NotifyReminder { get; set; } = true; + public bool NotifyEmail { get; set; } = true; + public bool NotifyCalendar { get; set; } = true; + public bool NotifyBuild { get; set; } = true; + public bool NotifyStock { get; set; } = true; + public bool NotifyInfo { get; set; } = true; + + // UI preferences + public bool ShowGlobalHotkey { get; set; } = true; + public bool MinimizeToTray { get; set; } = true; + + public SettingsManager() + { + Load(); + } + + /// Check if a notification type should produce a toast. + public bool ShouldNotify(string type) + { + if (!ShowNotifications) return false; + return type switch + { + "health" => NotifyHealth, + "urgent" => NotifyUrgent, + "reminder" => NotifyReminder, + "email" => NotifyEmail, + "calendar" => NotifyCalendar, + "build" => NotifyBuild, + "stock" => NotifyStock, + "error" => NotifyUrgent, // Errors use urgent setting + "info" => NotifyInfo, + _ => NotifyInfo + }; + } + + public void Load() + { + try + { + if (File.Exists(SettingsFile)) + { + var json = File.ReadAllText(SettingsFile); + var settings = JsonSerializer.Deserialize(json); + + if (settings != null) + { + GatewayUrl = settings.GatewayUrl ?? "ws://localhost:18789"; + Token = settings.Token ?? ""; + AutoStart = settings.AutoStart; + ShowNotifications = settings.ShowNotifications; + NotificationSound = settings.NotificationSound ?? "Default"; + NotifyHealth = settings.NotifyHealth ?? true; + NotifyUrgent = settings.NotifyUrgent ?? true; + NotifyReminder = settings.NotifyReminder ?? true; + NotifyEmail = settings.NotifyEmail ?? true; + NotifyCalendar = settings.NotifyCalendar ?? true; + NotifyBuild = settings.NotifyBuild ?? true; + NotifyStock = settings.NotifyStock ?? true; + NotifyInfo = settings.NotifyInfo ?? true; + ShowGlobalHotkey = settings.ShowGlobalHotkey ?? true; + MinimizeToTray = settings.MinimizeToTray ?? true; + } + } + } + catch (Exception) + { + // Use defaults if loading fails + } + } + + public void Save() + { + try + { + Directory.CreateDirectory(SettingsDirectory); + + var settings = new SettingsData + { + GatewayUrl = GatewayUrl, + Token = Token, + AutoStart = AutoStart, + ShowNotifications = ShowNotifications, + NotificationSound = NotificationSound, + NotifyHealth = NotifyHealth, + NotifyUrgent = NotifyUrgent, + NotifyReminder = NotifyReminder, + NotifyEmail = NotifyEmail, + NotifyCalendar = NotifyCalendar, + NotifyBuild = NotifyBuild, + NotifyStock = NotifyStock, + NotifyInfo = NotifyInfo, + ShowGlobalHotkey = ShowGlobalHotkey, + MinimizeToTray = MinimizeToTray + }; + + var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(SettingsFile, json); + Logger.Info("Settings saved"); + } + catch (Exception ex) + { + Logger.Error("Failed to save settings", ex); + throw new Exception($"Failed to save settings: {ex.Message}", ex); + } + } + + public static string GetSettingsDirectory() => SettingsDirectory; + + public static string GetSettingsFile() => SettingsFile; + + private class SettingsData + { + public string? GatewayUrl { get; set; } + public string? Token { get; set; } + public bool AutoStart { get; set; } + public bool ShowNotifications { get; set; } + public string? NotificationSound { get; set; } + public bool? NotifyHealth { get; set; } + public bool? NotifyUrgent { get; set; } + public bool? NotifyReminder { get; set; } + public bool? NotifyEmail { get; set; } + public bool? NotifyCalendar { get; set; } + public bool? NotifyBuild { get; set; } + public bool? NotifyStock { get; set; } + public bool? NotifyInfo { get; set; } + public bool? ShowGlobalHotkey { get; set; } + public bool? MinimizeToTray { get; set; } + } +} diff --git a/src/Moltbot.Tray/StatusDetailForm.cs b/src/Moltbot.Tray/StatusDetailForm.cs new file mode 100644 index 0000000..3887200 --- /dev/null +++ b/src/Moltbot.Tray/StatusDetailForm.cs @@ -0,0 +1,183 @@ +using Moltbot.Shared; +using System; +using System.Drawing; +using System.Text; +using System.Windows.Forms; + +namespace MoltbotTray; + +/// +/// Shows detailed gateway status, sessions, channels, and usage in a rich view. +/// +public class StatusDetailForm : Form +{ + private RichTextBox _textBox = null!; + private Button _refreshButton = null!; + private Button _closeButton = null!; + private readonly MoltbotGatewayClient? _client; + private readonly SettingsManager? _settings; + private readonly ConnectionStatus _status; + + private static StatusDetailForm? _instance; + + public static void ShowOrFocus(MoltbotGatewayClient? client, SettingsManager? settings, ConnectionStatus status) + { + if (_instance != null && !_instance.IsDisposed) + { + _instance.BringToFront(); + _instance.Focus(); + return; + } + + _instance = new StatusDetailForm(client, settings, status); + _instance.Show(); + } + + private StatusDetailForm(MoltbotGatewayClient? client, SettingsManager? settings, ConnectionStatus status) + { + _client = client; + _settings = settings; + _status = status; + InitializeComponent(); + RefreshStatus(); + } + + private void InitializeComponent() + { + Text = "Clawdbot Status"; + Size = new Size(520, 500); + MinimumSize = new Size(400, 350); + StartPosition = FormStartPosition.CenterScreen; + Icon = IconHelper.GetLobsterIcon(); + + _textBox = new RichTextBox + { + Dock = DockStyle.Fill, + ReadOnly = true, + Font = new Font("Cascadia Code", 10F, FontStyle.Regular, GraphicsUnit.Point), + BackColor = Color.FromArgb(30, 30, 30), + ForeColor = Color.FromArgb(220, 220, 220), + BorderStyle = BorderStyle.None, + WordWrap = true + }; + + var buttonPanel = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 40, + FlowDirection = FlowDirection.RightToLeft, + Padding = new Padding(5) + }; + + _closeButton = new Button + { + Text = "&Close", + Size = new Size(75, 26), + Font = new Font("Segoe UI", 9F) + }; + _closeButton.Click += (_, _) => Close(); + + _refreshButton = new Button + { + Text = "&Refresh", + Size = new Size(75, 26), + Font = new Font("Segoe UI", 9F) + }; + _refreshButton.Click += async (_, _) => + { + if (_client != null) + { + await _client.CheckHealthAsync(); + await _client.RequestSessionsAsync(); + await _client.RequestUsageAsync(); + } + RefreshStatus(); + }; + + buttonPanel.Controls.Add(_closeButton); + buttonPanel.Controls.Add(_refreshButton); + + Controls.Add(_textBox); + Controls.Add(buttonPanel); + } + + private void RefreshStatus() + { + var sb = new StringBuilder(); + + // Header + sb.AppendLine("โšก CLAWDBOT STATUS"); + sb.AppendLine(new string('โ”€', 40)); + sb.AppendLine(); + + // Connection + var statusIcon = _status switch + { + ConnectionStatus.Connected => "๐ŸŸข", + ConnectionStatus.Connecting => "๐ŸŸก", + ConnectionStatus.Error => "๐Ÿ”ด", + _ => "โšช" + }; + sb.AppendLine($" Gateway: {statusIcon} {_status}"); + sb.AppendLine($" URL: {_settings?.GatewayUrl ?? "not configured"}"); + sb.AppendLine($" Token: {(_settings?.Token?.Length > 0 ? "โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" : "not set")}"); + sb.AppendLine(); + + // Sessions + if (_client != null) + { + var sessions = _client.GetSessionList(); + if (sessions.Length > 0) + { + sb.AppendLine("๐Ÿง  SESSIONS"); + sb.AppendLine(new string('โ”€', 40)); + foreach (var s in sessions) + { + sb.AppendLine($" {s.DisplayText}"); + if (s.Model != null) + sb.AppendLine($" Model: {s.Model}"); + if (s.StartedAt != null) + sb.AppendLine($" Started: {s.StartedAt:HH:mm:ss}"); + } + sb.AppendLine(); + } + } + + // App info + sb.AppendLine("โ„น๏ธ APP INFO"); + sb.AppendLine(new string('โ”€', 40)); + sb.AppendLine($" Version: 1.0.0"); + sb.AppendLine($" Runtime: {Environment.Version}"); + sb.AppendLine($" OS: {Environment.OSVersion}"); + sb.AppendLine($" Machine: {Environment.MachineName}"); + sb.AppendLine($" PID: {Environment.ProcessId}"); + sb.AppendLine($" Uptime: {GetUptime()}"); + sb.AppendLine(); + + // Auto-start + sb.AppendLine("โš™๏ธ SETTINGS"); + sb.AppendLine(new string('โ”€', 40)); + sb.AppendLine($" Auto-start: {(_settings?.AutoStart == true ? "โœ…" : "โŒ")}"); + sb.AppendLine($" Notifications: {(_settings?.ShowNotifications == true ? "โœ…" : "โŒ")}"); + sb.AppendLine($" Sound: {_settings?.NotificationSound ?? "Default"}"); + + _textBox.Text = sb.ToString(); + } + + private static string GetUptime() + { + var elapsed = DateTime.Now - System.Diagnostics.Process.GetCurrentProcess().StartTime; + if (elapsed.TotalHours >= 1) + return $"{elapsed.Hours}h {elapsed.Minutes}m"; + if (elapsed.TotalMinutes >= 1) + return $"{elapsed.Minutes}m {elapsed.Seconds}s"; + return $"{elapsed.Seconds}s"; + } + + protected override void OnFormClosed(FormClosedEventArgs e) + { + _instance = null; + base.OnFormClosed(e); + } +} + diff --git a/src/Moltbot.Tray/TrayApplication.cs b/src/Moltbot.Tray/TrayApplication.cs new file mode 100644 index 0000000..73ced7d --- /dev/null +++ b/src/Moltbot.Tray/TrayApplication.cs @@ -0,0 +1,805 @@ +using Microsoft.Toolkit.Uwp.Notifications; +using Moltbot.Shared; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using ActivityKind = Moltbot.Shared.ActivityKind; + +namespace MoltbotTray; + +public class TrayApplication : ApplicationContext +{ + private NotifyIcon? _notifyIcon; + private ContextMenuStrip? _contextMenu; + private MoltbotGatewayClient? _gatewayClient; + private SettingsManager? _settings; + private System.Windows.Forms.Timer? _healthCheckTimer; + private System.Windows.Forms.Timer? _sessionPollTimer; + private GlobalHotkey? _globalHotkey; + private ConnectionStatus _currentStatus = ConnectionStatus.Disconnected; + private AgentActivity? _currentActivity; + private readonly SynchronizationContext? _syncContext; + + // Session-aware activity: track per-session state to avoid flip-flopping + private readonly Dictionary _sessionActivities = new(); + private string? _displayedSessionKey; + private DateTime _lastSessionSwitch = DateTime.MinValue; + private static readonly TimeSpan SessionSwitchDebounce = TimeSpan.FromSeconds(3); + + // Menu items for dynamic updates + private ToolStripMenuItem? _statusItem; + private ToolStripMenuItem? _activityItem; + private ToolStripMenuItem? _usageItem; + private ToolStripSeparator? _channelSeparator; + private ToolStripSeparator? _sessionSeparator; + private readonly List _channelItems = new(); + private readonly List _sessionItems = new(); + + private readonly string[] _startupArgs; + + // P/Invoke for proper icon cleanup + [DllImport("user32.dll", CharSet = CharSet.Auto)] + private static extern bool DestroyIcon(IntPtr handle); + + public TrayApplication(string[]? args = null) + { + _startupArgs = args ?? Array.Empty(); + _syncContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext(); + Logger.Info("Application starting"); + InitializeComponent(); + InitializeAsync(); + } + + private void InitializeComponent() + { + _settings = new SettingsManager(); + + // Register toast activation handler + ToastNotificationManagerCompat.OnActivated += OnToastActivated; + + _contextMenu = new ContextMenuStrip(); + + // Title + var titleItem = new ToolStripMenuItem("โšก Moltbot Tray") { Enabled = false }; + _contextMenu.Items.Add(titleItem); + _contextMenu.Items.Add(new ToolStripSeparator()); + + // Status (clickable โ€” opens detail view) + _statusItem = new ToolStripMenuItem("Status: Disconnected"); + _statusItem.Click += OnShowStatusDetail; + _contextMenu.Items.Add(_statusItem); + + // Activity (hidden when idle) + _activityItem = new ToolStripMenuItem("") { Enabled = false, Visible = false }; + _contextMenu.Items.Add(_activityItem); + + // Usage (hidden until data available) + _usageItem = new ToolStripMenuItem("") { Enabled = false, Visible = false }; + _contextMenu.Items.Add(_usageItem); + + // Session separator + placeholder + _sessionSeparator = new ToolStripSeparator { Visible = false }; + _contextMenu.Items.Add(_sessionSeparator); + + // Channel health separator + placeholder + _channelSeparator = new ToolStripSeparator { Visible = false }; + _contextMenu.Items.Add(_channelSeparator); + + _contextMenu.Items.Add(new ToolStripSeparator()); + + // Actions + _contextMenu.Items.Add("Open Dashboard", null, OnOpenDashboard); + _contextMenu.Items.Add("Open Web Chat", null, OnOpenWebUI); + _contextMenu.Items.Add("Quick Send...", null, OnQuickSend); + _contextMenu.Items.Add("Notification History...", null, OnNotificationHistory); + _contextMenu.Items.Add("Run Health Check", null, OnManualHealthCheck); + _contextMenu.Items.Add(new ToolStripSeparator()); + + // Settings + _contextMenu.Items.Add("Settings...", null, OnSettings); + var autoStartMenuItem = new ToolStripMenuItem("Auto-start", null, OnToggleAutoStart) + { + Checked = _settings.AutoStart + }; + _contextMenu.Items.Add(autoStartMenuItem); + _contextMenu.Items.Add(new ToolStripSeparator()); + + // Log file access + _contextMenu.Items.Add("Open Log File", null, OnOpenLogFile); + _contextMenu.Items.Add("Exit", null, OnExit); + + // Tray icon + _notifyIcon = new NotifyIcon + { + Icon = CreateStatusIcon(ConnectionStatus.Disconnected), + ContextMenuStrip = _contextMenu, + Text = "Moltbot Tray โ€” Disconnected", + Visible = true + }; + _notifyIcon.DoubleClick += OnDoubleClick; + + // Health check timer (30s) + _healthCheckTimer = new System.Windows.Forms.Timer { Interval = 30000, Enabled = true }; + _healthCheckTimer.Tick += OnHealthCheck; + + // Session/usage poll timer (60s) โ€” less frequent + _sessionPollTimer = new System.Windows.Forms.Timer { Interval = 60000, Enabled = true }; + _sessionPollTimer.Tick += OnSessionPoll; + + // Global hotkey: Ctrl+Shift+Space โ†’ Quick Send + _globalHotkey = new GlobalHotkey(); + _globalHotkey.HotkeyPressed += (_, _) => OnQuickSend(null, EventArgs.Empty); + _globalHotkey.Register(); + } + + private async void InitializeAsync() + { + try + { + _gatewayClient = new MoltbotGatewayClient(_settings!.GatewayUrl, _settings.Token, Logger.Instance); + _gatewayClient.StatusChanged += OnStatusChanged; + _gatewayClient.NotificationReceived += OnNotificationReceived; + _gatewayClient.ActivityChanged += OnActivityChanged; + _gatewayClient.ChannelHealthUpdated += OnChannelHealthUpdated; + _gatewayClient.SessionsUpdated += OnSessionsUpdated; + _gatewayClient.UsageUpdated += OnUsageUpdated; + + await _gatewayClient.ConnectAsync(); + + // Process deep link if launched via URI + if (DeepLinkHandler.TryGetDeepLink(_startupArgs, out var uri) && uri != null) + { + await DeepLinkHandler.ProcessDeepLinkAsync(uri, _gatewayClient); + } + } + catch (Exception ex) + { + Logger.Error("Initial connection failed", ex); + ShowErrorToast("Connection Failed", $"Failed to connect: {ex.Message}"); + } + } + + // --- Event Handlers (marshal to UI thread) --- + + private void OnStatusChanged(object? sender, ConnectionStatus status) + { + _syncContext?.Post(_ => UpdateStatus(status), null); + } + + private void OnNotificationReceived(object? sender, MoltbotNotification n) + { + _syncContext?.Post(_ => ShowNotificationToast(n.Title, n.Message, n.Type), null); + } + + private void OnActivityChanged(object? sender, AgentActivity activity) + { + _syncContext?.Post(_ => UpdateActivity(activity), null); + } + + private void OnChannelHealthUpdated(object? sender, ChannelHealth[] channels) + { + _syncContext?.Post(_ => UpdateChannelHealth(channels), null); + } + + private void OnSessionsUpdated(object? sender, SessionInfo[] sessions) + { + _syncContext?.Post(_ => UpdateSessions(sessions), null); + } + + private void OnUsageUpdated(object? sender, GatewayUsageInfo usage) + { + _syncContext?.Post(_ => UpdateUsage(usage), null); + } + + // --- UI Updates --- + + private void UpdateStatus(ConnectionStatus status) + { + _currentStatus = status; + + if (_notifyIcon != null) + { + var oldIcon = _notifyIcon.Icon; + _notifyIcon.Icon = CreateStatusIcon(status, _currentActivity?.Kind); + SafeDestroyIcon(oldIcon); + + var tooltip = _currentActivity?.Kind != ActivityKind.Idle && !string.IsNullOrEmpty(_currentActivity?.DisplayText) + ? $"Clawdbot โ€” {_currentActivity.DisplayText}" + : $"Clawdbot โ€” {status}"; + _notifyIcon.Text = tooltip.Length > 63 ? tooltip[..63] : tooltip; + } + + if (_statusItem != null) + { + var label = status switch + { + ConnectionStatus.Connected => "[ON]", + ConnectionStatus.Connecting => "[...]", + ConnectionStatus.Error => "[ERR]", + _ => "[OFF]" + }; + _statusItem.Text = $"{label} Gateway: {status}"; + } + } + + private void UpdateActivity(AgentActivity activity) + { + // Track per-session activity for stable display + _sessionActivities[activity.SessionKey] = activity; + + // Resolve which session to display using stable selection: + // 1. Active main session always wins + // 2. Keep current session if still active (prevents flip-flop) + // 3. Fall back to most recently active non-main session + var displayActivity = ResolveDisplayActivity(activity); + _currentActivity = displayActivity; + + if (_activityItem != null) + { + if (displayActivity.Kind != ActivityKind.Idle && !string.IsNullOrEmpty(displayActivity.DisplayText)) + { + _activityItem.Text = displayActivity.DisplayText; + _activityItem.Visible = true; + } + else + { + _activityItem.Visible = false; + } + } + + // Also update the tray icon to reflect activity + UpdateStatus(_currentStatus); + } + + /// + /// Selects the best session to display in the activity row. + /// Avoids rapid switching between sessions by applying a debounce window. + /// + private AgentActivity ResolveDisplayActivity(AgentActivity incoming) + { + var now = DateTime.UtcNow; + + // If main session is active, always prefer it + if (incoming.IsMain && incoming.Kind != ActivityKind.Idle) + { + _displayedSessionKey = incoming.SessionKey; + _lastSessionSwitch = now; + return incoming; + } + + // If the currently displayed session is still active, keep it (no flip-flop) + if (_displayedSessionKey != null && + _sessionActivities.TryGetValue(_displayedSessionKey, out var current) && + current.Kind != ActivityKind.Idle) + { + // Only allow switching away if debounce period has passed + if (now - _lastSessionSwitch < SessionSwitchDebounce) + return current; + } + + // Check if any main session is active + foreach (var kvp in _sessionActivities) + { + if (kvp.Value.IsMain && kvp.Value.Kind != ActivityKind.Idle) + { + _displayedSessionKey = kvp.Key; + _lastSessionSwitch = now; + return kvp.Value; + } + } + + // No main active โ€” show the incoming active session if it has work + if (incoming.Kind != ActivityKind.Idle) + { + _displayedSessionKey = incoming.SessionKey; + _lastSessionSwitch = now; + return incoming; + } + + // Everything is idle + _displayedSessionKey = null; + return incoming; + } + + private void UpdateChannelHealth(ChannelHealth[] channels) + { + // Remove old channel items + foreach (var item in _channelItems) + _contextMenu?.Items.Remove(item); + _channelItems.Clear(); + + if (channels.Length == 0) + { + if (_channelSeparator != null) _channelSeparator.Visible = false; + return; + } + + if (_channelSeparator != null) _channelSeparator.Visible = true; + + var insertIndex = _contextMenu?.Items.IndexOf(_channelSeparator!) ?? -1; + if (insertIndex < 0) return; + + // Add header + insertIndex++; + var header = new ToolStripMenuItem("๐Ÿ“ก Channels") { Enabled = false }; + _contextMenu!.Items.Insert(insertIndex, header); + _channelItems.Add(header); + + foreach (var ch in channels) + { + insertIndex++; + var item = new ToolStripMenuItem($" {ch.DisplayText}") { Enabled = false }; + _contextMenu.Items.Insert(insertIndex, item); + _channelItems.Add(item); + } + } + + private void UpdateSessions(SessionInfo[] sessions) + { + // Log session data for debugging + Logger.Info($"UpdateSessions: {sessions.Length} sessions"); + foreach (var s in sessions) + Logger.Info($" Session: key={s.Key}, isMain={s.IsMain}, status={s.Status}, channel={s.Channel}"); + + // Remove old session items + foreach (var item in _sessionItems) + _contextMenu?.Items.Remove(item); + _sessionItems.Clear(); + + if (sessions.Length == 0) + { + if (_sessionSeparator != null) _sessionSeparator.Visible = false; + return; + } + + if (_sessionSeparator != null) _sessionSeparator.Visible = true; + + var insertIndex = _contextMenu?.Items.IndexOf(_sessionSeparator!) ?? -1; + if (insertIndex < 0) return; + + // Add header + insertIndex++; + var header = new ToolStripMenuItem("๐Ÿง  Sessions") { Enabled = false }; + _contextMenu!.Items.Insert(insertIndex, header); + _sessionItems.Add(header); + + foreach (var session in sessions) + { + insertIndex++; + // Use ShortKey if DisplayText is too minimal + var displayText = session.DisplayText; + if (displayText == "โšก Main" || displayText == "๐Ÿ”น Sub") + displayText = $"{displayText} ยท {session.ShortKey}"; + var item = new ToolStripMenuItem($" {displayText}") { Enabled = false }; + _contextMenu.Items.Insert(insertIndex, item); + _sessionItems.Add(item); + } + } + + private void UpdateUsage(GatewayUsageInfo usage) + { + if (_usageItem != null) + { + _usageItem.Text = $"๐Ÿ“Š {usage.DisplayText}"; + _usageItem.Visible = true; + } + } + + // --- Icon Creation (with proper cleanup) --- + + private Icon CreateStatusIcon(ConnectionStatus status, ActivityKind? activity = null) + { + var bitmap = new Bitmap(16, 16); + using (var g = Graphics.FromImage(bitmap)) + { + g.Clear(Color.Transparent); + + if (status == ConnectionStatus.Connected) + { + // Draw pixel lobster when connected + DrawPixelLobster(g); + } + else + { + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + + // Base color from status + var baseColor = status switch + { + ConnectionStatus.Connecting => Color.FromArgb(255, 180, 0), // Amber + ConnectionStatus.Error => Color.FromArgb(220, 50, 50), // Red + _ => Color.FromArgb(128, 128, 128) // Gray + }; + + // Main circle for non-connected states + using var brush = new SolidBrush(baseColor); + g.FillEllipse(brush, 1, 1, 13, 13); + } + + // Activity badge (small dot in corner when working) + if (activity is not null and not ActivityKind.Idle && status == ConnectionStatus.Connected) + { + var badgeColor = activity switch + { + ActivityKind.Exec => Color.FromArgb(255, 100, 0), // Orange + ActivityKind.Write or ActivityKind.Edit => Color.FromArgb(100, 200, 50), // Green + ActivityKind.Read => Color.FromArgb(80, 150, 255), // Blue + ActivityKind.Search or ActivityKind.Browser => Color.FromArgb(180, 80, 255), // Purple + ActivityKind.Message => Color.FromArgb(50, 200, 100), // Bright green + _ => Color.White + }; + using var badgeBrush = new SolidBrush(badgeColor); + g.FillEllipse(badgeBrush, 10, 0, 6, 6); + using var borderPen = new Pen(Color.Black, 1); + g.DrawEllipse(borderPen, 10, 0, 6, 6); + } + } + + var hIcon = bitmap.GetHicon(); + var icon = Icon.FromHandle(hIcon); + bitmap.Dispose(); + return icon; + } + + private void DrawPixelLobster(Graphics g) + { + // Pixel lobster from SVG - 16x16 pixel art + var outline = Color.FromArgb(58, 10, 13); // #3a0a0d - dark outline + var body = Color.FromArgb(255, 79, 64); // #ff4f40 - red body + var claw = Color.FromArgb(255, 119, 95); // #ff775f - lighter claws + var eyeDark = Color.FromArgb(8, 16, 22); // #081016 - pupils + var eyeLight = Color.FromArgb(245, 251, 255); // #f5fbff - eye whites + + // Outline (dark border) + var outlinePixels = new[] { + (1,5), (1,6), (1,7), + (2,4), (2,8), + (3,3), (3,9), + (4,2), (4,10), + (5,2), (6,2), (7,2), (8,2), (9,2), (10,2), + (11,2), (12,3), (12,9), + (13,4), (13,8), + (14,5), (14,6), (14,7), + (5,11), (6,11), (7,11), (8,11), (9,11), (10,11), + (4,12), (11,12), + (3,13), (12,13), + (5,14), (6,14), (7,14), (8,14), (9,14), (10,14) + }; + foreach (var (x, y) in outlinePixels) + bitmap_SetPixel(g, x, y, outline); + + // Body (red) + var bodyPixels = new[] { + (5,3), (6,3), (7,3), (8,3), (9,3), (10,3), + (4,4), (5,4), (7,4), (8,4), (10,4), (11,4), + (3,5), (4,5), (5,5), (7,5), (8,5), (10,5), (11,5), (12,5), + (3,6), (4,6), (5,6), (6,6), (7,6), (8,6), (9,6), (10,6), (11,6), (12,6), + (3,7), (4,7), (5,7), (6,7), (7,7), (8,7), (9,7), (10,7), (11,7), (12,7), + (4,8), (5,8), (6,8), (7,8), (8,8), (9,8), (10,8), (11,8), + (5,9), (6,9), (7,9), (8,9), (9,9), (10,9), + (5,12), (6,12), (7,12), (8,12), (9,12), (10,12), + (6,13), (7,13), (8,13), (9,13) + }; + foreach (var (x, y) in bodyPixels) + bitmap_SetPixel(g, x, y, body); + + // Claws (lighter red) + var clawPixels = new[] { + (1,6), (2,5), (2,6), (2,7), + (13,5), (13,6), (13,7), (14,6) + }; + foreach (var (x, y) in clawPixels) + bitmap_SetPixel(g, x, y, claw); + + // Eyes + bitmap_SetPixel(g, 6, 4, eyeLight); + bitmap_SetPixel(g, 9, 4, eyeLight); + bitmap_SetPixel(g, 6, 5, eyeDark); + bitmap_SetPixel(g, 9, 5, eyeDark); + } + + private void bitmap_SetPixel(Graphics g, int x, int y, Color c) + { + using var brush = new SolidBrush(c); + g.FillRectangle(brush, x, y, 1, 1); + } + + private static void SafeDestroyIcon(Icon? icon) + { + if (icon == null) return; + try + { + DestroyIcon(icon.Handle); + icon.Dispose(); + } + catch { } + } + + // --- Toast Notifications --- + + private void ShowNotificationToast(string title, string message, string type = "info") + { + // Always log to history regardless of filter + NotificationHistoryForm.AddEntry(title, message, type); + + // Check per-type filter + if (_settings?.ShouldNotify(type) != true) return; + + try + { + new ToastContentBuilder() + .AddText(title) + .AddText(message) + .Show(); + } + catch (Exception) + { + _notifyIcon?.ShowBalloonTip(3000, title, message, ToolTipIcon.Info); + } + } + + private void ShowErrorToast(string title, string message) + { + try + { + new ToastContentBuilder() + .AddText(title) + .AddText(message) + .Show(); + } + catch + { + _notifyIcon?.ShowBalloonTip(3000, title, message, ToolTipIcon.Error); + } + } + + private void OnToastActivated(ToastNotificationActivatedEventArgsCompat e) + { + // Parse arguments from toast + var args = ToastArguments.Parse(e.Argument); + + if (args.TryGetValue("action", out var action) && action == "openDashboard") + { + if (args.TryGetValue("url", out var url)) + { + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch (Exception ex) + { + Logger.Error($"Failed to open dashboard from toast: {ex.Message}"); + } + } + } + } + + // --- Menu Actions --- + + private async void OnHealthCheck(object? sender, EventArgs e) + { + if (_gatewayClient != null && _currentStatus != ConnectionStatus.Connecting) + await _gatewayClient.CheckHealthAsync(); + } + + private async void OnSessionPoll(object? sender, EventArgs e) + { + if (_gatewayClient != null && _currentStatus == ConnectionStatus.Connected) + { + await _gatewayClient.RequestSessionsAsync(); + await _gatewayClient.RequestUsageAsync(); + } + } + + private async void OnManualHealthCheck(object? sender, EventArgs e) + { + Logger.Info("Manual health check triggered"); + if (_gatewayClient != null) + { + await _gatewayClient.CheckHealthAsync(); + await _gatewayClient.RequestSessionsAsync(); + await _gatewayClient.RequestUsageAsync(); + } + } + + private void OnOpenWebUI(object? sender, EventArgs e) + { + try + { + WebChatForm.ShowOrFocus(_settings!.GatewayUrl, _settings.Token); + } + catch (Exception ex) + { + // Fallback to browser if WebView2 fails + Logger.Warn($"WebView2 failed, falling back to browser: {ex.Message}"); + var url = _settings!.GatewayUrl + .Replace("ws://", "http://") + .Replace("wss://", "https://"); + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch (Exception ex2) + { + ShowErrorToast("Failed to open Web UI", ex2.Message); + } + } + } + + private string GetDashboardUrl() + { + var baseUrl = _settings!.GatewayUrl + .Replace("ws://", "http://") + .Replace("wss://", "https://"); + + // Add token if available + if (!string.IsNullOrEmpty(_settings.Token)) + { + var separator = baseUrl.Contains("?") ? "&" : "?"; + return $"{baseUrl}{separator}token={Uri.EscapeDataString(_settings.Token)}"; + } + return baseUrl; + } + + private void OnOpenDashboard(object? sender, EventArgs e) + { + try + { + Process.Start(new ProcessStartInfo(GetDashboardUrl()) { UseShellExecute = true }); + } + catch (Exception ex) + { + ShowErrorToast("Failed to open Dashboard", ex.Message); + } + } + + private async void OnQuickSend(object? sender, EventArgs e) + { + using var dialog = new QuickSendDialog(); + if (dialog.ShowDialog() == DialogResult.OK) + { + try + { + await _gatewayClient!.SendChatMessageAsync(dialog.Message); + ShowClickableToast("Message Sent", "Click to continue chat in dashboard"); + } + catch (Exception ex) + { + ShowErrorToast("Failed to Send", ex.Message); + } + } + } + + private void ShowClickableToast(string title, string message) + { + NotificationHistoryForm.AddEntry(title, message, "info"); + + try + { + new ToastContentBuilder() + .AddText(title) + .AddText(message) + .AddArgument("action", "openDashboard") + .AddArgument("url", GetDashboardUrl()) + .Show(); + } + catch + { + _notifyIcon?.ShowBalloonTip(3000, title, message, ToolTipIcon.Info); + } + } + + private void OnSettings(object? sender, EventArgs e) + { + using var dialog = new SettingsDialog(_settings!); + if (dialog.ShowDialog() == DialogResult.OK) + { + _settings!.Save(); + Task.Run(async () => await ReconnectAsync()); + } + } + + private void OnToggleAutoStart(object? sender, EventArgs e) + { + var menuItem = (ToolStripMenuItem)sender!; + _settings!.AutoStart = !_settings.AutoStart; + menuItem.Checked = _settings.AutoStart; + _settings.Save(); + AutoStartManager.SetAutoStart(_settings.AutoStart); + Logger.Info($"Auto-start: {_settings.AutoStart}"); + } + + private void OnShowStatusDetail(object? sender, EventArgs e) + { + StatusDetailForm.ShowOrFocus(_gatewayClient, _settings, _currentStatus); + } + + private void OnNotificationHistory(object? sender, EventArgs e) + { + NotificationHistoryForm.ShowOrFocus(); + } + + private void OnOpenLogFile(object? sender, EventArgs e) + { + try + { + var logDir = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "MoltbotTray"); + var logPath = System.IO.Path.Combine(logDir, "clawdbot-tray.log"); + + if (System.IO.File.Exists(logPath)) + { + Process.Start(new ProcessStartInfo(logPath) { UseShellExecute = true }); + } + else + { + Process.Start(new ProcessStartInfo(logDir) { UseShellExecute = true }); + } + } + catch (Exception ex) + { + ShowErrorToast("Failed to Open Log", ex.Message); + } + } + + private async Task ReconnectAsync() + { + try + { + if (_gatewayClient != null) + { + await _gatewayClient.DisconnectAsync(); + _gatewayClient.Dispose(); + } + + _gatewayClient = new MoltbotGatewayClient(_settings!.GatewayUrl, _settings.Token, Logger.Instance); + _gatewayClient.StatusChanged += OnStatusChanged; + _gatewayClient.NotificationReceived += OnNotificationReceived; + _gatewayClient.ActivityChanged += OnActivityChanged; + _gatewayClient.ChannelHealthUpdated += OnChannelHealthUpdated; + _gatewayClient.SessionsUpdated += OnSessionsUpdated; + _gatewayClient.UsageUpdated += OnUsageUpdated; + + await _gatewayClient.ConnectAsync(); + } + catch (Exception ex) + { + Logger.Error("Reconnection failed", ex); + ShowErrorToast("Reconnection Failed", ex.Message); + } + } + + private void OnDoubleClick(object? sender, EventArgs e) => OnOpenWebUI(sender, e); + private void OnExit(object? sender, EventArgs e) => ExitThread(); + + // --- Cleanup --- + + protected override void Dispose(bool disposing) + { + if (disposing) + { + Logger.Info("Application shutting down"); + _globalHotkey?.Dispose(); + _healthCheckTimer?.Dispose(); + _sessionPollTimer?.Dispose(); + _gatewayClient?.Dispose(); + _notifyIcon?.Dispose(); + _contextMenu?.Dispose(); + Logger.Shutdown(); + } + base.Dispose(disposing); + } + + protected override void ExitThreadCore() + { + if (_notifyIcon != null) _notifyIcon.Visible = false; + base.ExitThreadCore(); + } +} + diff --git a/src/Moltbot.Tray/WebChatForm.cs b/src/Moltbot.Tray/WebChatForm.cs new file mode 100644 index 0000000..df1add5 --- /dev/null +++ b/src/Moltbot.Tray/WebChatForm.cs @@ -0,0 +1,176 @@ +using Microsoft.Web.WebView2.WinForms; +using Microsoft.Web.WebView2.Core; +using System; +using System.Drawing; +using System.IO; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace MoltbotTray; + +/// +/// Embeds the Clawdbot WebChat UI via WebView2, matching the macOS native chat panel. +/// +public class WebChatForm : Form +{ + private WebView2? _webView; + private readonly string _gatewayUrl; + private readonly string _token; + private ToolStrip? _toolbar; + private bool _initialized; + + private static WebChatForm? _instance; + + /// + /// Show or focus the singleton WebChat window. + /// + public static void ShowOrFocus(string gatewayUrl, string token) + { + if (_instance != null && !_instance.IsDisposed) + { + _instance.BringToFront(); + _instance.Focus(); + return; + } + + _instance = new WebChatForm(gatewayUrl, token); + _instance.Show(); + } + + private WebChatForm(string gatewayUrl, string token) + { + _gatewayUrl = gatewayUrl; + _token = token; + InitializeComponent(); + _ = InitializeWebViewAsync(); + } + + private void InitializeComponent() + { + Text = "Clawdbot Chat"; + Size = new Size(520, 750); + MinimumSize = new Size(380, 450); + StartPosition = FormStartPosition.CenterScreen; + Icon = IconHelper.GetLobsterIcon(); + BackColor = Color.FromArgb(30, 30, 30); + + // Toolbar + _toolbar = new ToolStrip + { + GripStyle = ToolStripGripStyle.Hidden, + RenderMode = ToolStripRenderMode.System, + BackColor = Color.FromArgb(45, 45, 45), + ForeColor = Color.White + }; + + var homeBtn = new ToolStripButton("๐Ÿ  Home") { ForeColor = Color.White }; + homeBtn.Click += (_, _) => NavigateToChat(); + + var refreshBtn = new ToolStripButton("โ†ป Refresh") { ForeColor = Color.White }; + refreshBtn.Click += (_, _) => _webView?.Reload(); + + var popoutBtn = new ToolStripButton("โ†— Browser") { ForeColor = Color.White }; + popoutBtn.Click += (_, _) => + { + var url = _gatewayUrl.Replace("ws://", "http://").Replace("wss://", "https://"); + try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo($"{url}?token={Uri.EscapeDataString(_token)}") { UseShellExecute = true }); } + catch { } + }; + + var devToolsBtn = new ToolStripButton("๐Ÿ”ง DevTools") { ForeColor = Color.White }; + devToolsBtn.Click += (_, _) => _webView?.CoreWebView2?.OpenDevToolsWindow(); + + _toolbar.Items.Add(homeBtn); + _toolbar.Items.Add(refreshBtn); + _toolbar.Items.Add(popoutBtn); + _toolbar.Items.Add(new ToolStripSeparator()); + _toolbar.Items.Add(devToolsBtn); + + // WebView2 fills remaining space + _webView = new WebView2 + { + Dock = DockStyle.Fill, + DefaultBackgroundColor = Color.FromArgb(30, 30, 30) + }; + + // Controls layout โ€” toolbar on top, webview fills rest + Controls.Add(_webView); + Controls.Add(_toolbar); + _toolbar.Dock = DockStyle.Top; + } + + private async Task InitializeWebViewAsync() + { + try + { + // Use a dedicated user data folder + var userDataDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "MoltbotTray", "WebView2"); + + var env = await CoreWebView2Environment.CreateAsync( + userDataFolder: userDataDir); + + await _webView!.EnsureCoreWebView2Async(env); + + // Configure WebView2 + var settings = _webView.CoreWebView2.Settings; + settings.IsStatusBarEnabled = false; + settings.AreDefaultContextMenusEnabled = true; + settings.IsZoomControlEnabled = true; + + _initialized = true; + Logger.Info("WebView2 initialized"); + + NavigateToChat(); + } + catch (WebView2RuntimeNotFoundException) + { + Logger.Error("WebView2 runtime not found"); + var result = MessageBox.Show( + "The Microsoft WebView2 Runtime is required for the chat panel.\n\n" + + "Would you like to download it?", + "WebView2 Required", + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning); + + if (result == DialogResult.Yes) + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo( + "https://developer.microsoft.com/en-us/microsoft-edge/webview2/") + { UseShellExecute = true }); + } + Close(); + } + catch (Exception ex) + { + Logger.Error("WebView2 init failed", ex); + MessageBox.Show($"Failed to initialize chat panel:\n{ex.Message}", + "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + Close(); + } + } + + private void NavigateToChat() + { + if (!_initialized || _webView?.CoreWebView2 == null) return; + + // Convert ws:// to http:// for the web UI + var httpUrl = _gatewayUrl + .Replace("ws://", "http://") + .Replace("wss://", "https://"); + + // The gateway serves WebChat at the root with token auth + var chatUrl = $"{httpUrl}?token={Uri.EscapeDataString(_token)}"; + _webView.CoreWebView2.Navigate(chatUrl); + Logger.Info($"Navigating to WebChat: {httpUrl}"); + } + + protected override void OnFormClosed(FormClosedEventArgs e) + { + _webView?.Dispose(); + _instance = null; + base.OnFormClosed(e); + } +} + diff --git a/src/Moltbot.Tray/build.bat b/src/Moltbot.Tray/build.bat new file mode 100644 index 0000000..0e82bef --- /dev/null +++ b/src/Moltbot.Tray/build.bat @@ -0,0 +1,48 @@ +@echo off +setlocal + +echo =================================== +echo Clawdbot Windows Tray - Build +echo =================================== +echo. + +:: Detect architecture +if "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set RID=win-arm64 +) else ( + set RID=win-x64 +) + +echo Architecture: %RID% +echo. + +:: Build +echo [1/3] Building Debug... +dotnet build -c Debug -r %RID% +if errorlevel 1 goto :error + +echo. +echo [2/3] Building Release... +dotnet build -c Release -r %RID% +if errorlevel 1 goto :error + +echo. +echo [3/3] Publishing self-contained... +dotnet publish -c Release -r %RID% --self-contained -p:PublishSingleFile=true -o publish +if errorlevel 1 goto :error + +echo. +echo =================================== +echo Build complete! +echo Output: publish\ClawdbotTray.exe +echo Architecture: %RID% +echo =================================== +goto :end + +:error +echo. +echo BUILD FAILED +exit /b 1 + +:end +endlocal diff --git a/src/Moltbot.Tray/moltbot.ico b/src/Moltbot.Tray/moltbot.ico new file mode 100644 index 0000000000000000000000000000000000000000..67a38bd48320074b2887bbaf232e0bd571f5fad4 GIT binary patch literal 488 zcmZQzU}Ruo5D;Jh0tJRuKvE6FQUDS_z62u!!v!F#Gr-TCmrIHZ$mI3(a0vp^ARPi6 zY(P@}^Z!I3Rp{yB7*a83ZSZa01_d6SSg-O6g{ACsx#|rXFYP{d<#+QpIaODcPA2W+ zq3xgd?b`c!scMvj;$N4tDiP7DE4htIS+i}6Eb}9-pO9Ttn9-zZbP0l+c95r3yf41+Ytt ztE#>LIrkwhy*mA(V6y>_Gq3kW*;|FD`QC(+X+I03y14;KIm|Fwbu literal 0 HcmV?d00001 diff --git a/src/Moltbot.Tray/screenshots/README.md b/src/Moltbot.Tray/screenshots/README.md new file mode 100644 index 0000000..33e630e --- /dev/null +++ b/src/Moltbot.Tray/screenshots/README.md @@ -0,0 +1,26 @@ +# Screenshots + +This directory contains screenshots for the README and documentation. + +## Required Screenshots + +1. **tray-menu.png** - System tray icon with context menu open +2. **settings-dialog.png** - Settings configuration dialog +3. **quick-send-dialog.png** - Quick send message dialog +4. **notification-example.png** - Example of Windows toast notification + +## Taking Screenshots + +1. Run the application +2. Right-click the tray icon to show the menu +3. Open various dialogs and take screenshots +4. Use tools like Snipping Tool or Windows+Shift+S +5. Save as PNG files with descriptive names + +## Image Guidelines + +- Use PNG format for transparency support +- Keep file sizes reasonable (< 500KB each) +- Show the application in a clean Windows environment +- Include relevant context (Windows version, other tray icons, etc.) +- Ensure text is readable at normal viewing sizes \ No newline at end of file diff --git a/src/Moltbot.Tray/workflows/build.yml b/src/Moltbot.Tray/workflows/build.yml new file mode 100644 index 0000000..0373ce8 --- /dev/null +++ b/src/Moltbot.Tray/workflows/build.yml @@ -0,0 +1,86 @@ +name: Build and Release + +on: + push: + branches: [ main ] + tags: [ 'v*' ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: windows-latest + strategy: + matrix: + rid: [win-x64, win-arm64] + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Restore dependencies + run: dotnet restore -r ${{ matrix.rid }} + + - name: Build Debug + run: dotnet build --no-restore -c Debug -r ${{ matrix.rid }} + + - name: Build Release + run: dotnet build --no-restore -c Release -r ${{ matrix.rid }} + + - name: Publish Self-Contained + run: dotnet publish -c Release -r ${{ matrix.rid }} --self-contained -p:PublishSingleFile=true -o publish-${{ matrix.rid }} + + - name: Upload Build Artifacts + uses: actions/upload-artifact@v4 + with: + name: clawdbot-windows-tray-${{ matrix.rid }} + path: publish-${{ matrix.rid }}/ + + release: + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download x64 artifact + uses: actions/download-artifact@v4 + with: + name: clawdbot-windows-tray-win-x64 + path: artifacts/win-x64 + + - name: Download arm64 artifact + uses: actions/download-artifact@v4 + with: + name: clawdbot-windows-tray-win-arm64 + path: artifacts/win-arm64 + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + artifacts/win-x64/ClawdbotTray.exe + artifacts/win-arm64/ClawdbotTray.exe + body: | + ## Clawdbot Windows Tray ${{ github.ref_name }} + + ### Downloads + - **x64**: `ClawdbotTray.exe` (Intel/AMD 64-bit) + - **arm64**: `ClawdbotTray.exe` (ARM64 โ€” Windows on ARM) + + ### Requirements + - Windows 10 version 1903 or later + - [WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) (for embedded chat) + - Clawdbot gateway running on your network + + ### Quick Start + 1. Download the executable for your architecture + 2. Run `ClawdbotTray.exe` + 3. Right-click tray icon โ†’ Settings + 4. Enter your gateway URL and token