From d641e6bedf0f103d513f9553eef8307713497778 Mon Sep 17 00:00:00 2001 From: gevorg Date: Sun, 19 Jul 2026 00:21:08 +0400 Subject: [PATCH 1/4] Fix command crash, add model selector, and improve UX - Fix wrong Notepad++ message IDs: NPPM_GETFILENAME collided with NPPM_LAUNCHFINDINFILESDLG (opened the Find dialog and crashed) and NPPM_SETSTATUSBAR was mapped to NPPM_DMMSHOW. - Add a model selector to the Settings dialog (persisted to the registry) covering GPT-4o/4.1 and the GPT-5 family; the combo is editable for future models. - Send model-aware request params: GPT-5/o-series use max_completion_tokens and omit temperature; chat models keep max_tokens + temperature. - Deliver LLM results via a hidden message-only window instead of the fragile NPPM_MSGTOPLUGIN routing that left requests stuck on "Processing". - Use INTERNET_OPEN_TYPE_PRECONFIG for proxy support and raise the timeout. - Replace the "Processing..." status with an outcome message that auto-clears. - Only treat real text inserts/deletes as document edits so styling no longer triggers a false "document changed" discard; fix SCNotification x64 struct layout (Sci_Position is pointer-sized). Co-authored-by: Cursor --- src/LLMAssistant.rc | 16 +++--- src/LLMService.cpp | 72 ++++++++++++++++++++++--- src/LLMService.h | 12 ++++- src/NppPlugin.cpp | 7 ++- src/PluginDefinition.cpp | 112 ++++++++++++++++++++++++++++++++++----- src/PluginDefinition.h | 6 ++- src/PluginInterface.h | 32 ++++++++--- src/SettingsDialog.cpp | 42 +++++++++++++-- src/resource.h | 2 + 9 files changed, 257 insertions(+), 44 deletions(-) diff --git a/src/LLMAssistant.rc b/src/LLMAssistant.rc index 703b524..8fdf758 100644 --- a/src/LLMAssistant.rc +++ b/src/LLMAssistant.rc @@ -2,18 +2,20 @@ #include // Settings Dialog -IDD_SETTINGS DIALOGEX 0, 0, 300, 150 +IDD_SETTINGS DIALOGEX 0, 0, 300, 178 STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | WS_POPUP | WS_CAPTION | WS_SYSMENU CAPTION "LLM Assistant Settings" FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN - LTEXT "OpenAI API Key:",IDC_STATIC_API_KEY_LABEL,14,20,60,8 - EDITTEXT IDC_API_KEY,80,18,150,14,ES_AUTOHSCROLL | ES_PASSWORD - PUSHBUTTON "Test Connection",IDC_TEST_CONNECTION,235,18,55,14 + LTEXT "OpenAI API Key:",IDC_STATIC_API_KEY_LABEL,14,22,60,8 + EDITTEXT IDC_API_KEY,80,20,150,14,ES_AUTOHSCROLL | ES_PASSWORD + PUSHBUTTON "Test Connection",IDC_TEST_CONNECTION,235,20,55,14 LTEXT "",IDC_CONNECTION_STATUS,80,40,210,10 - LTEXT "Enter your OpenAI API key to enable LLM functionality.\nYou can get an API key from https://platform.openai.com/api-keys",IDC_STATIC_INSTRUCTIONS,14,58,270,20 - DEFPUSHBUTTON "OK",IDOK,180,120,50,14 - PUSHBUTTON "Cancel",IDCANCEL,235,120,50,14 + LTEXT "Model:",IDC_STATIC_MODEL_LABEL,14,60,60,8 + COMBOBOX IDC_MODEL_COMBO,80,58,150,120,CBS_DROPDOWN | CBS_AUTOHSCROLL | WS_VSCROLL | WS_TABSTOP + LTEXT "Enter your OpenAI API key to enable LLM functionality.\nYou can get an API key from https://platform.openai.com/api-keys",IDC_STATIC_INSTRUCTIONS,14,82,270,20 + DEFPUSHBUTTON "OK",IDOK,180,150,50,14 + PUSHBUTTON "Cancel",IDCANCEL,235,150,50,14 END // Input Prompt Dialog diff --git a/src/LLMService.cpp b/src/LLMService.cpp index d54babf..5772016 100644 --- a/src/LLMService.cpp +++ b/src/LLMService.cpp @@ -2,24 +2,48 @@ #include "SimpleJson.h" #include "TextCodec.h" +#include +#include #include #include #pragma comment(lib, "wininet.lib") namespace { -constexpr DWORD kRequestTimeoutMilliseconds = 30000; +constexpr DWORD kRequestTimeoutMilliseconds = 60000; std::wstring lastErrorMessage(const wchar_t* operation) { const DWORD error = GetLastError(); return std::wstring(operation) + L" failed (Win32 error " + std::to_wstring(error) + L")."; } + +// Reasoning models (GPT-5 family and the o-series) reject the classic +// "max_tokens" and any non-default "temperature". They require +// "max_completion_tokens" and no temperature override. The chat-tuned +// variant "gpt-5-chat*" behaves like gpt-4o and keeps the classic params. +bool isReasoningModel(const std::wstring& model) +{ + std::wstring name = model; + std::transform(name.begin(), name.end(), name.begin(), towlower); + const auto startsWith = [&](const wchar_t* prefix) { return name.rfind(prefix, 0) == 0; }; + + if (startsWith(L"gpt-5")) { + return !startsWith(L"gpt-5-chat"); + } + return startsWith(L"o1") || startsWith(L"o3") || startsWith(L"o4"); +} } LLMService* LLMService::instance = nullptr; -LLMService::LLMService() : apiUrl(L"https://api.openai.com/v1/chat/completions") +const wchar_t* LLMService::defaultModel() +{ + return L"gpt-4o"; +} + +LLMService::LLMService() + : model(defaultModel()), apiUrl(L"https://api.openai.com/v1/chat/completions") { } @@ -61,14 +85,26 @@ std::wstring LLMService::getApiKey() const return apiKey; } +void LLMService::setModel(const std::wstring& modelName) +{ + std::lock_guard lock(settingsMutex); + model = modelName.empty() ? std::wstring(defaultModel()) : modelName; +} + +std::wstring LLMService::getModel() const +{ + std::lock_guard lock(settingsMutex); + return model; +} + bool LLMService::isConfigured() const { return !getApiKey().empty(); } -LLMResult LLMService::callLLM(const std::wstring& prompt, const std::wstring& model) +LLMResult LLMService::callLLM(const std::wstring& prompt) { - return callLLMWithApiKey(prompt, getApiKey(), model); + return callLLMWithApiKey(prompt, getApiKey(), getModel()); } LLMResult LLMService::callLLMWithApiKey( @@ -89,8 +125,14 @@ LLMResult LLMService::callLLMWithApiKey( JsonBuilder builder; builder.startObject(); builder.addString("model", modelUtf8); - builder.addNumber("max_tokens", 1000); - builder.addNumber("temperature", 0.7); + if (isReasoningModel(model)) { + // Reasoning tokens are billed against this budget too, so keep it + // generous to avoid empty responses from the GPT-5 family. + builder.addNumber("max_completion_tokens", 4000); + } else { + builder.addNumber("max_tokens", 1000); + builder.addNumber("temperature", 0.7); + } builder.startArray("messages"); builder.addObjectToArray(); builder.addString("role", "user"); @@ -101,7 +143,8 @@ LLMResult LLMService::callLLMWithApiKey( const std::string headers = "Content-Type: application/json\r\nAuthorization: Bearer " + apiKeyUtf8 + "\r\n"; - const HttpResponse response = makeHttpRequest(urlUtf8, headers, builder.toString()); + const std::string body = builder.toString(); + const HttpResponse response = makeHttpRequest(urlUtf8, headers, body); if (!response.succeeded) { return LLMResult::Failure(response.error); } @@ -123,7 +166,7 @@ LLMResult LLMService::callLLMWithApiKey( LLMService::HttpResponse LLMService::makeHttpRequest( const std::string& url, const std::string& headers, const std::string& postData) { - HINTERNET internet = InternetOpenA("LLM Assistant", INTERNET_OPEN_TYPE_DIRECT, nullptr, nullptr, 0); + HINTERNET internet = InternetOpenA("LLM Assistant", INTERNET_OPEN_TYPE_PRECONFIG, nullptr, nullptr, 0); if (!internet) { return { false, 0, "", lastErrorMessage(L"InternetOpen") }; } @@ -252,6 +295,15 @@ void LLMService::loadSettings() buffer[bufferSize / sizeof(wchar_t)] = L'\0'; setApiKey(buffer); } + + wchar_t modelBuffer[256] = { 0 }; + DWORD modelSize = sizeof(modelBuffer) - sizeof(wchar_t); + DWORD modelType = REG_SZ; + if (RegQueryValueEx(key, L"Model", nullptr, &modelType, reinterpret_cast(modelBuffer), &modelSize) + == ERROR_SUCCESS && modelType == REG_SZ && modelSize > 0) { + modelBuffer[modelSize / sizeof(wchar_t)] = L'\0'; + setModel(modelBuffer); + } RegCloseKey(key); } @@ -266,5 +318,9 @@ void LLMService::saveSettings() const DWORD dataSize = static_cast((currentApiKey.length() + 1) * sizeof(wchar_t)); RegSetValueEx(key, L"ApiKey", 0, REG_SZ, reinterpret_cast(currentApiKey.c_str()), dataSize); + + const std::wstring currentModel = getModel(); + const DWORD modelSize = static_cast((currentModel.length() + 1) * sizeof(wchar_t)); + RegSetValueEx(key, L"Model", 0, REG_SZ, reinterpret_cast(currentModel.c_str()), modelSize); RegCloseKey(key); } diff --git a/src/LLMService.h b/src/LLMService.h index 2e552f6..41dbcd4 100644 --- a/src/LLMService.h +++ b/src/LLMService.h @@ -9,9 +9,14 @@ class LLMService { +public: + // Default model used when none has been configured yet. + static const wchar_t* defaultModel(); + private: static LLMService* instance; std::wstring apiKey; + std::wstring model; std::wstring apiUrl; mutable std::mutex settingsMutex; @@ -38,11 +43,14 @@ class LLMService // Configuration void setApiKey(const std::wstring& key); std::wstring getApiKey() const; + void setModel(const std::wstring& modelName); + std::wstring getModel() const; // Main LLM call function. This method is safe to call from a background thread. - LLMResult callLLM(const std::wstring& prompt, const std::wstring& model = L"gpt-3.5-turbo"); + // callLLM uses the configured API key and model. + LLMResult callLLM(const std::wstring& prompt); LLMResult callLLMWithApiKey(const std::wstring& prompt, const std::wstring& apiKey, - const std::wstring& model = L"gpt-3.5-turbo"); + const std::wstring& model); // Utility functions bool isConfigured() const; diff --git a/src/NppPlugin.cpp b/src/NppPlugin.cpp index cdb78d3..71ab6c8 100644 --- a/src/NppPlugin.cpp +++ b/src/NppPlugin.cpp @@ -55,7 +55,12 @@ extern "C" __declspec(dllexport) void beNotified(SCNotification *notifyCode) break; default: - if (notifyCode->nmhdr.code == 2008) { // SCN_MODIFIED + // Only count actual text inserts/deletes as document changes. + // Scintilla also fires SCN_MODIFIED for styling, folding and markers, + // which would otherwise cause the plugin to think the user edited the + // document while an LLM request was running and discard the response. + if (notifyCode->nmhdr.code == SCN_MODIFIED + && (notifyCode->modificationType & (SC_MOD_INSERTTEXT | SC_MOD_DELETETEXT))) { noteDocumentModified(notifyCode->nmhdr.hwndFrom); } break; diff --git a/src/PluginDefinition.cpp b/src/PluginDefinition.cpp index 71fa7c5..b076383 100644 --- a/src/PluginDefinition.cpp +++ b/src/PluginDefinition.cpp @@ -16,7 +16,7 @@ #include #define STATUSBAR_DOC_TYPE 0 -#define NPPM_SETSTATUSBAR (NPPMSG + 30) +#define NPPM_SETSTATUSBAR (NPPMSG + 24) #define SCI_GETDOCPOINTER 2357 #define LLM_INTERNAL_RESULT_READY 1 @@ -49,6 +49,65 @@ bool g_runtimeInitialized = false; bool g_serviceInitialized = false; std::atomic g_shuttingDown(false); std::map g_documentRevisions; +HWND g_messageWindow = nullptr; +const wchar_t* const kMessageWindowClass = L"LLMAssistantMessageWindow"; + +// Timer that clears a completion message from the status bar after a short delay. +constexpr UINT_PTR kStatusClearTimerId = 1; +constexpr UINT kStatusClearDelayMs = 4000; + +void processCompletion(RequestCompletion* completion); +void showStatusMessage(const wchar_t* message); +void clearStatusMessage(); + +// Shows a status message and schedules it to be cleared automatically. +void showTransientStatusMessage(const wchar_t* message) +{ + showStatusMessage(message); + if (g_messageWindow) { + SetTimer(g_messageWindow, kStatusClearTimerId, kStatusClearDelayMs, nullptr); + } +} + +LRESULT CALLBACK messageWindowProc(HWND window, UINT message, WPARAM wParam, LPARAM lParam) +{ + if (message == WM_LLM_RESULT) { + processCompletion(reinterpret_cast(lParam)); + return 0; + } + if (message == WM_TIMER && wParam == kStatusClearTimerId) { + KillTimer(window, kStatusClearTimerId); + clearStatusMessage(); + return 0; + } + return DefWindowProc(window, message, wParam, lParam); +} + +void createMessageWindow() +{ + if (g_messageWindow) { + return; + } + + WNDCLASSEXW windowClass = {}; + windowClass.cbSize = sizeof(windowClass); + windowClass.lpfnWndProc = messageWindowProc; + windowClass.hInstance = g_module; + windowClass.lpszClassName = kMessageWindowClass; + RegisterClassExW(&windowClass); + + g_messageWindow = CreateWindowExW(0, kMessageWindowClass, L"", 0, 0, 0, 0, 0, + HWND_MESSAGE, nullptr, g_module, nullptr); +} + +void destroyMessageWindow() +{ + if (g_messageWindow) { + DestroyWindow(g_messageWindow); + g_messageWindow = nullptr; + } + UnregisterClassW(kMessageWindowClass, g_module); +} void showStatusMessage(const wchar_t* message) { @@ -60,7 +119,8 @@ void showStatusMessage(const wchar_t* message) void clearStatusMessage() { - showStatusMessage(L""); + // Notepad++ ignores an empty status string, so send a single space to blank it. + showStatusMessage(L" "); } HWND currentScintilla() @@ -143,15 +203,22 @@ void joinCompletedWorker() void processCompletion(RequestCompletion* completion) { g_requestActive = false; - clearStatusMessage(); + + if (!completion) { + clearStatusMessage(); + return; + } if (completion->isConnectionTest) { + clearStatusMessage(); if (IsWindow(completion->dialog)) { setConnectionTestStatus(completion->dialog, completion->result.success, completion->result.error); } } else if (!completion->result.success) { + showTransientStatusMessage(L"LLM request failed."); MessageBox(nppData._nppHandle, completion->result.error.c_str(), L"LLM Assistant", MB_OK | MB_ICONERROR); } else if (!targetCanBeUpdated(completion->target)) { + showTransientStatusMessage(L"LLM response discarded - document changed."); MessageBox(nppData._nppHandle, L"The document changed while the LLM request was running. The response was discarded.", L"LLM Assistant", MB_OK | MB_ICONWARNING); @@ -160,6 +227,7 @@ void processCompletion(RequestCompletion* completion) ? completion->originalText + completion->result.content : completion->result.content; replaceCapturedRange(completion->target, replacement); + showTransientStatusMessage(L"LLM response applied."); } delete completion; @@ -167,14 +235,21 @@ void processCompletion(RequestCompletion* completion) void dispatchCompletion(RequestCompletion* completion) { - CommunicationInfo info = { - LLM_INTERNAL_RESULT_READY, - L"LLMAssistant.dll", - completion - }; - const LRESULT delivered = SendMessage(nppData._nppHandle, NPPM_MSGTOPLUGIN, - reinterpret_cast(L"LLMAssistant.dll"), reinterpret_cast(&info)); - if (!delivered) { + // Deliver the result to the UI thread through the plugin's own hidden + // message-only window. PostMessage is cross-thread safe and the window's + // messages are pumped by Notepad++'s main UI thread, so processCompletion + // always runs on the UI thread. This avoids the fragile NPPM_MSGTOPLUGIN + // routing that previously dropped results and left the request "stuck". + if (!g_messageWindow || !IsWindow(g_messageWindow)) { + g_requestActive = false; + clearStatusMessage(); + delete completion; + return; + } + + if (!PostMessage(g_messageWindow, WM_LLM_RESULT, 0, reinterpret_cast(completion))) { + g_requestActive = false; + clearStatusMessage(); delete completion; } } @@ -190,6 +265,9 @@ void startRequest(const DocumentTarget& target, const std::wstring& text, const joinCompletedWorker(); g_requestActive = true; + if (g_messageWindow) { + KillTimer(g_messageWindow, kStatusClearTimerId); + } showStatusMessage(L"Processing LLM request..."); g_worker = std::thread([target, text, prompt, appendOriginalText]() { RequestCompletion* completion = new RequestCompletion { @@ -258,6 +336,7 @@ void pluginRuntimeInit() } LLMService::getInstance().initialize(); + createMessageWindow(); g_serviceInitialized = true; g_runtimeInitialized = true; g_shuttingDown = false; @@ -273,6 +352,7 @@ void pluginCleanUp() if (g_worker.joinable()) { g_worker.join(); } + destroyMessageWindow(); LLMService::getInstance().cleanup(); g_runtimeInitialized = false; g_serviceInitialized = false; @@ -378,7 +458,7 @@ void openSettings() dialog.show(nppData._nppHandle); } -void testConnection(HWND dialog, const std::wstring& apiKey) +void testConnection(HWND dialog, const std::wstring& apiKey, const std::wstring& model) { if (g_requestActive) { setConnectionTestStatus(dialog, false, L"Another LLM request is already in progress."); @@ -389,14 +469,18 @@ void testConnection(HWND dialog, const std::wstring& apiKey) return; } + const std::wstring testModel = model.empty() ? std::wstring(LLMService::defaultModel()) : model; joinCompletedWorker(); g_requestActive = true; + if (g_messageWindow) { + KillTimer(g_messageWindow, kStatusClearTimerId); + } showStatusMessage(L"Testing OpenAI connection..."); - g_worker = std::thread([dialog, apiKey]() { + g_worker = std::thread([dialog, apiKey, testModel]() { RequestCompletion* completion = new RequestCompletion { {}, LLMService::getInstance().callLLMWithApiKey( - L"Reply with the single word: connected.", apiKey), + L"Reply with the single word: connected.", apiKey, testModel), true, dialog, L"", diff --git a/src/PluginDefinition.h b/src/PluginDefinition.h index a869d25..56e219b 100644 --- a/src/PluginDefinition.h +++ b/src/PluginDefinition.h @@ -11,6 +11,10 @@ // relaying it back through Notepad++ while the plugin's modal dialog is running. #define WM_LLM_TEST_COMPLETE (WM_APP + 110) +// Posted from the worker thread to the plugin's hidden message-only window when a +// text-processing LLM request finishes. lParam carries the RequestCompletion*. +#define WM_LLM_RESULT (WM_APP + 111) + //-------------------------------------// //-- STEP 1. DEFINE YOUR PLUGIN NAME --// //-------------------------------------// @@ -65,7 +69,7 @@ void checkGrammar(); void translateText(); void summarizeText(); void openSettings(); -void testConnection(HWND dialog, const std::wstring& apiKey); +void testConnection(HWND dialog, const std::wstring& apiKey, const std::wstring& model); // Finalizes a connection test on the UI thread. Called by the Settings dialog when it // receives WM_LLM_TEST_COMPLETE. Resets request state, clears the status bar, updates diff --git a/src/PluginInterface.h b/src/PluginInterface.h index 9b3ae23..a04c120 100644 --- a/src/PluginInterface.h +++ b/src/PluginInterface.h @@ -21,12 +21,28 @@ // Notepad++ messages #define NPPMSG (WM_USER + 1000) #define NPPM_GETCURRENTSCINTILLA (NPPMSG + 4) -#define NPPM_GETFILENAME (NPPMSG + 29) #define NPPM_MSGTOPLUGIN (NPPMSG + 47) +// File path getters use the RUNCOMMAND_USER scheme, NOT the NPPMSG scheme. +// (NPPMSG + 29) is NPPM_LAUNCHFINDINFILESDLG, so using it for NPPM_GETFILENAME +// opened the Find-in-Files dialog and crashed Notepad++. +#define RUNCOMMAND_USER (WM_USER + 3000) +#define FILE_NAME 3 +#define NPPM_GETFILENAME (RUNCOMMAND_USER + FILE_NAME) + #define NPPN_FIRST 1000 #define NPPN_SHUTDOWN (NPPN_FIRST + 9) +// Scintilla notification + modification-type flags. +#define SCN_MODIFIED 2008 +#define SC_MOD_INSERTTEXT 0x1 +#define SC_MOD_DELETETEXT 0x2 + +// Scintilla's position type is pointer-sized (ptrdiff_t), not int. Getting this +// wrong shifts every field after it, so modificationType must be read through a +// struct with the correct 64-bit layout. +typedef intptr_t Sci_Position; + // Forward declarations struct SCNotification; @@ -54,20 +70,22 @@ typedef struct { ShortcutKey* _pShKey; } FuncItem; -// SCNotification structure (simplified) +// SCNotification structure. Field types must match Scintilla's real layout +// (Sci_Position is pointer-sized) so that fields such as modificationType are +// read from the correct offsets on x64. struct SCNotification { NMHDR nmhdr; - int position; + Sci_Position position; int ch; int modifiers; int modificationType; const char* text; - int length; - int linesAdded; + Sci_Position length; + Sci_Position linesAdded; int message; uintptr_t wParam; intptr_t lParam; - int line; + Sci_Position line; int foldLevelNow; int foldLevelPrev; int margin; @@ -75,7 +93,7 @@ struct SCNotification { int x; int y; int token; - int annotationLinesAdded; + Sci_Position annotationLinesAdded; int updated; }; diff --git a/src/SettingsDialog.cpp b/src/SettingsDialog.cpp index 572ff2b..305bf9f 100644 --- a/src/SettingsDialog.cpp +++ b/src/SettingsDialog.cpp @@ -146,7 +146,34 @@ void SettingsDialog::onInitDialog() // Set current API key in the text field std::wstring currentKey = LLMService::getInstance().getApiKey(); SetDlgItemText(hDlg, IDC_API_KEY, currentKey.c_str()); - + + // Populate the model selector with newer OpenAI models (gpt-4o and above). + // The combo is editable, so a brand-new model name can also be typed in + // without needing a plugin rebuild. + HWND combo = GetDlgItem(hDlg, IDC_MODEL_COMBO); + static const wchar_t* const kModels[] = { + L"gpt-5", + L"gpt-5-mini", + L"gpt-5-nano", + L"gpt-5-chat-latest", + L"gpt-4.1", + L"gpt-4.1-mini", + L"gpt-4.1-nano", + L"gpt-4o" + }; + for (const wchar_t* model : kModels) { + SendMessage(combo, CB_ADDSTRING, 0, reinterpret_cast(model)); + } + + const std::wstring currentModel = LLMService::getInstance().getModel(); + const LRESULT index = SendMessage(combo, CB_FINDSTRINGEXACT, static_cast(-1), + reinterpret_cast(currentModel.c_str())); + if (index != CB_ERR) { + SendMessage(combo, CB_SETCURSEL, static_cast(index), 0); + } else { + SetWindowText(combo, currentModel.c_str()); + } + // Set focus to API key field SetFocus(GetDlgItem(hDlg, IDC_API_KEY)); } @@ -154,11 +181,16 @@ void SettingsDialog::onInitDialog() void SettingsDialog::onOK() { // Get API key from text field - wchar_t buffer[512]; + wchar_t buffer[512] = {}; GetDlgItemText(hDlg, IDC_API_KEY, buffer, sizeof(buffer) / sizeof(wchar_t)); - + + // Get selected/typed model from the combo box + wchar_t modelBuffer[256] = {}; + GetDlgItemText(hDlg, IDC_MODEL_COMBO, modelBuffer, sizeof(modelBuffer) / sizeof(wchar_t)); + // Save to LLM service LLMService::getInstance().setApiKey(buffer); + LLMService::getInstance().setModel(modelBuffer); LLMService::getInstance().saveSettings(); MessageBox(hDlg, L"Settings saved successfully!", L"LLM Assistant", MB_OK | MB_ICONINFORMATION); @@ -173,6 +205,8 @@ void SettingsDialog::onTestConnection() { wchar_t buffer[512] = {}; GetDlgItemText(hDlg, IDC_API_KEY, buffer, sizeof(buffer) / sizeof(wchar_t)); + wchar_t modelBuffer[256] = {}; + GetDlgItemText(hDlg, IDC_MODEL_COMBO, modelBuffer, sizeof(modelBuffer) / sizeof(wchar_t)); setConnectionTestPending(hDlg); - testConnection(hDlg, buffer); + testConnection(hDlg, buffer, modelBuffer); } diff --git a/src/resource.h b/src/resource.h index 3465c11..f03d09d 100644 --- a/src/resource.h +++ b/src/resource.h @@ -14,6 +14,8 @@ #define IDC_STATIC_PROMPT_LABEL 1006 #define IDC_STATIC_HELP 1007 #define IDC_CONNECTION_STATUS 1008 +#define IDC_MODEL_COMBO 1009 +#define IDC_STATIC_MODEL_LABEL 1010 // Menu command IDs #define ID_ENHANCE_TEXT 40001 From 7059e5b78826577e48d7447f76b8e5ae91c58b9c Mon Sep 17 00:00:00 2001 From: gevorg Date: Sun, 19 Jul 2026 10:57:02 +0400 Subject: [PATCH 2/4] Add Notepad++ Plugins Admin listing support Enable submission to the official nppPluginList so the plugin is searchable, installable, and auto-updatable via Plugins Admin. - Add VERSIONINFO to the DLL (was missing) via src/Version.h so Plugins Admin can detect updates - Add build-and-release GitHub Actions workflow: build x64, run tests, package a Plugins-Admin-ready zip (DLL at zip root), publish release and emit the SHA-256 needed for the pl.x64.json entry - Add packaging/make-release-zip.ps1 to reproduce the zip + SHA-256 and generate the JSON entry locally - Add nppPluginList entry template and submission guide - Document Plugins Admin install path in README Co-authored-by: Cursor --- .github/workflows/build-and-release.yml | 109 ++++++++++++++++++++++++ .gitignore | 4 + README.md | 13 ++- packaging/PLUGINS_ADMIN_SUBMISSION.md | 108 +++++++++++++++++++++++ packaging/make-release-zip.ps1 | 100 ++++++++++++++++++++++ packaging/nppPluginList-entry.json | 10 +++ src/LLMAssistant.rc | 35 ++++++++ src/Version.h | 33 +++++++ 8 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build-and-release.yml create mode 100644 packaging/PLUGINS_ADMIN_SUBMISSION.md create mode 100644 packaging/make-release-zip.ps1 create mode 100644 packaging/nppPluginList-entry.json create mode 100644 src/Version.h diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml new file mode 100644 index 0000000..a1869ee --- /dev/null +++ b/.github/workflows/build-and-release.yml @@ -0,0 +1,109 @@ +name: build-and-release + +# Builds the x64 plugin, runs the Catch2 test suite, and packages a +# Plugins-Admin-ready zip. +# +# * push / PR -> build + test + upload the zip as a CI artifact +# * push a "v*" tag -> additionally publish a GitHub Release with the zip +# and print the SHA-256 needed for the nppPluginList PR +# +# The DLL is placed at the ROOT of the zip and named exactly LLMAssistant.dll, +# which is what Notepad++ Plugins Admin requires. + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Add MSBuild to PATH + uses: microsoft/setup-msbuild@v2 + + - name: Build plugin (x64 Release) + run: > + msbuild LLMAssistant.vcxproj + /p:Configuration=Release + /p:Platform=x64 + /p:PlatformToolset=v143 + /p:WindowsTargetPlatformVersion=10.0 + /m + + - name: Configure tests (CMake) + run: cmake -S . -B CMakeBuild + + - name: Build tests + run: cmake --build CMakeBuild --config Debug + + - name: Run tests + run: ctest --test-dir CMakeBuild --output-on-failure -C Debug + + - name: Package Plugins-Admin zip + id: package + shell: pwsh + run: | + if ("${{ github.ref_type }}" -eq "tag") { + $ver = "${{ github.ref_name }}" -replace '^v', '' + } else { + $ver = "dev" + } + $zip = "LLMAssistant_v${ver}_x64.zip" + + # DLL must sit at the zip root and be named exactly LLMAssistant.dll. + Copy-Item "x64\Release\LLMAssistant.dll" ".\LLMAssistant.dll" -Force + if (Test-Path $zip) { Remove-Item $zip -Force } + Compress-Archive -Path ".\LLMAssistant.dll" -DestinationPath $zip -Force + + $hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToUpper() + "zip=$zip" >> $env:GITHUB_OUTPUT + "version=$ver" >> $env:GITHUB_OUTPUT + "sha256=$hash" >> $env:GITHUB_OUTPUT + + Write-Host "===============================================" + Write-Host "Package : $zip" + Write-Host "SHA-256 : $hash <-- use as \"id\" in pl.x64.json" + Write-Host "===============================================" + + - name: Upload CI artifact + uses: actions/upload-artifact@v4 + with: + name: LLMAssistant-x64 + path: ${{ steps.package.outputs.zip }} + if-no-files-found: error + + - name: Publish GitHub Release + if: github.ref_type == 'tag' + uses: softprops/action-gh-release@v2 + with: + files: ${{ steps.package.outputs.zip }} + generate_release_notes: true + body: | + ### Notepad++ Plugins Admin metadata + + Add / update this in `nppPluginList` (`src/pl.x64.json`): + + ```json + { + "folder-name": "LLMAssistant", + "display-name": "LLM Assistant", + "version": "${{ steps.package.outputs.version }}", + "id": "${{ steps.package.outputs.sha256 }}", + "repository": "${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ steps.package.outputs.zip }}", + "description": "AI-powered text processing (enhance, translate, summarize, grammar, code) via the OpenAI API, directly inside Notepad++.", + "author": "Gvo87", + "homepage": "${{ github.server_url }}/${{ github.repository }}" + } + ``` + + `id` is the SHA-256 of the attached zip: `${{ steps.package.outputs.sha256 }}` diff --git a/.gitignore b/.gitignore index 224b53e..c40f516 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ x64/ CMakeBuild/ _deps/ + +# Release packaging output +dist/ +*.zip diff --git a/README.md b/README.md index e21a5dd..957833f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,18 @@ A powerful Notepad++ plugin that integrates Large Language Model (LLM) capabilit ## πŸ“¦ Installation -### Quick Installation +### Via Plugins Admin (recommended) +Once the plugin is accepted into the official +[nppPluginList](https://github.com/notepad-plus-plus/nppPluginList), you can install +it directly from Notepad++: +1. Open **Plugins β†’ Plugins Admin…** +2. On the **Available** tab, search for **LLM Assistant** +3. Check the box and click **Install** β€” Notepad++ handles the download and future updates automatically + +Maintainers: see [`packaging/PLUGINS_ADMIN_SUBMISSION.md`](packaging/PLUGINS_ADMIN_SUBMISSION.md) +for how releases and the Plugins Admin listing are produced. + +### Quick Installation (manual) 1. **Download** the plugin: `LLMAssistant.dll` 2. **Create directory**: `C:\Program Files\Notepad++\plugins\LLMAssistant\` 3. **Copy file**: Place `LLMAssistant.dll` in the created directory diff --git a/packaging/PLUGINS_ADMIN_SUBMISSION.md b/packaging/PLUGINS_ADMIN_SUBMISSION.md new file mode 100644 index 0000000..b38fe0f --- /dev/null +++ b/packaging/PLUGINS_ADMIN_SUBMISSION.md @@ -0,0 +1,108 @@ +# Getting LLM Assistant into Notepad++ Plugins Admin + +This guide takes the plugin from source to being searchable, installable, and +auto-updatable inside **Notepad++ β†’ Plugins β†’ Plugins Admin**. + +Plugins Admin is driven by the official +[`notepad-plus-plus/nppPluginList`](https://github.com/notepad-plus-plus/nppPluginList) +repository. A plugin appears there only after a small JSON entry (pointing at a +downloadable release zip) is merged into `src/pl.x64.json`. This repo already +contains everything needed to produce that entry. + +--- + +## What Plugins Admin requires + +| Requirement | How this repo satisfies it | +| --- | --- | +| DLL carries a real binary version (for update detection) | `src/Version.h` + `VERSIONINFO` in `src/LLMAssistant.rc` | +| A downloadable `.zip` with the DLL **at the zip root**, named exactly `LLMAssistant.dll` | `packaging/make-release-zip.ps1` and the release workflow | +| SHA-256 of that zip (used as `id`) | printed by the script and the workflow | +| `folder-name` == DLL name (`LLMAssistant`) | enforced in the JSON entry | +| A stable download URL | GitHub Release asset created by the workflow | + +--- + +## Step 1 β€” Cut a versioned release of the plugin + +1. Set the version in `src/Version.h` (e.g. `1.0`) β€” this is the single source of + truth and gets compiled into the DLL. +2. Commit, then tag and push: + + ```bash + git tag v1.0 + git push origin v1.0 + ``` + +3. The [`build-and-release`](../.github/workflows/build-and-release.yml) workflow + will build the x64 Release DLL, run the test suite, package + `LLMAssistant_v1.0_x64.zip` (DLL at the zip root), publish a **GitHub Release**, + and print the **SHA-256** in both the logs and the release notes. + +> No CI? Build locally with `build.bat`, then run: +> +> ```powershell +> pwsh packaging\make-release-zip.ps1 -Version 1.0 +> ``` +> +> It writes `dist\LLMAssistant_v1.0_x64.zip`, prints the SHA-256, and generates the +> exact JSON entry to paste. + +## Step 2 β€” Fill in the nppPluginList entry + +Use `packaging/nppPluginList-entry.json` as the template. Replace `id` with the +SHA-256 from Step 1 and make sure `repository` points at the uploaded release zip: + +```json +{ + "folder-name": "LLMAssistant", + "display-name": "LLM Assistant", + "version": "1.0", + "id": "", + "repository": "https://github.com/Gvo87/NotepadPlusPlus-LLM-Plugin/releases/download/v1.0/LLMAssistant_v1.0_x64.zip", + "description": "AI-powered text processing (enhance, translate, summarize, grammar, code) via the OpenAI API, directly inside Notepad++.", + "author": "Gvo87", + "homepage": "https://github.com/Gvo87/NotepadPlusPlus-LLM-Plugin" +} +``` + +## Step 3 β€” Test locally before the PR (required) + +1. Download a **debug** build of Notepad++ (x64) β€” see the + [official instructions](https://npp-user-manual.org/docs/plugins/#test-your-plugins-locally). +2. Grab `src/pl.x64.json` from your fork of `nppPluginList` and save it to + `\plugins\Config\nppPluginList.json`. +3. Add your entry to the `npp-plugins` array in that file. +4. Launch `notepad++.x64.dbg.exe` and open **Plugins β†’ Plugins Admin**. +5. Confirm the plugin shows under **Available**, installs correctly, and appears + under **Installed**. + +## Step 4 β€” Open the PR + +Fork [`notepad-plus-plus/nppPluginList`](https://github.com/notepad-plus-plus/nppPluginList), +add the entry to `src/pl.x64.json` (keep the array sorted/consistent with +neighbors), commit, and open a PR that **only** modifies that JSON file. Once +merged and the signed `nppPluginList.dll` ships, everyone can search and install +LLM Assistant from Plugins Admin. + +--- + +## How auto-update works afterwards + +Plugins Admin compares the **installed DLL's binary version** with the `version` +in `pl.x64.json`. To ship an update: + +1. Bump `src/Version.h`. +2. Tag `vX.Y` and push β†’ the workflow builds and publishes a new release zip. +3. Open a follow-up PR to `nppPluginList` updating that plugin's `version`, `id` + (new SHA-256), and `repository` URL. + +Users then see the new version under the **Updates** tab. + +## Notes / limits + +- This plugin ships **x64 only**, so it goes in `pl.x64.json`. To also list it for + 32-bit or ARM64 Notepad++, add the corresponding build configurations to the + project and workflow and submit entries to `pl.x86.json` / `pl.arm64.json`. +- If the plugin ever adds/removes Notepad++ API calls with version constraints, + set `npp-compatible-versions` (and `old-versions-compatibility`) in the entry. diff --git a/packaging/make-release-zip.ps1 b/packaging/make-release-zip.ps1 new file mode 100644 index 0000000..53e6fca --- /dev/null +++ b/packaging/make-release-zip.ps1 @@ -0,0 +1,100 @@ +<# +.SYNOPSIS + Builds a Notepad++ Plugins-Admin-ready zip for LLM Assistant and prints the + SHA-256 + a ready-to-paste nppPluginList (pl.x64.json) entry. + +.DESCRIPTION + Notepad++ Plugins Admin requires: + * a downloadable .zip, + * with the plugin DLL at the ROOT of the zip, + * named exactly LLMAssistant.dll, + * and the SHA-256 of that zip recorded as "id" in nppPluginList. + + This script packages an already-built DLL (x64\Release\LLMAssistant.dll by + default) into that exact layout, then emits the metadata you need for the PR. + +.PARAMETER Version + Plugin version, e.g. "1.0". Must match the DLL's binary version resource and + the git tag (vVersion). Defaults to reading src\Version.h. + +.PARAMETER DllPath + Path to the built DLL. Defaults to x64\Release\LLMAssistant.dll. + +.PARAMETER OutDir + Where to write the zip. Defaults to .\dist. + +.EXAMPLE + pwsh packaging\make-release-zip.ps1 -Version 1.0 +#> +[CmdletBinding()] +param( + [string]$Version, + [string]$DllPath = "x64\Release\LLMAssistant.dll", + [string]$OutDir = "dist" +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +Set-Location $repoRoot + +function Read-VersionFromHeader { + $header = Join-Path $repoRoot "src\Version.h" + if (-not (Test-Path $header)) { return $null } + $text = Get-Content $header -Raw + $maj = [regex]::Match($text, 'LLM_VERSION_MAJOR\s+(\d+)').Groups[1].Value + $min = [regex]::Match($text, 'LLM_VERSION_MINOR\s+(\d+)').Groups[1].Value + if ($maj -and $min) { return "$maj.$min" } + return $null +} + +if (-not $Version) { + $Version = Read-VersionFromHeader + if (-not $Version) { throw "Could not determine version. Pass -Version explicitly (e.g. -Version 1.0)." } + Write-Host "Using version from src\Version.h: $Version" +} + +if (-not (Test-Path $DllPath)) { + throw "DLL not found at '$DllPath'. Build the Release|x64 configuration first (build.bat), or pass -DllPath." +} + +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + +# Stage the DLL at the zip root under the exact required name. +$staging = Join-Path $OutDir "_staging" +if (Test-Path $staging) { Remove-Item $staging -Recurse -Force } +New-Item -ItemType Directory -Force -Path $staging | Out-Null +Copy-Item $DllPath (Join-Path $staging "LLMAssistant.dll") -Force + +$zipName = "LLMAssistant_v${Version}_x64.zip" +$zipPath = Join-Path $OutDir $zipName +if (Test-Path $zipPath) { Remove-Item $zipPath -Force } +Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $zipPath -Force +Remove-Item $staging -Recurse -Force + +$sha = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToUpper() + +$repoUrl = (git config --get remote.origin.url) 2>$null +if ($repoUrl) { $repoUrl = $repoUrl -replace '\.git$', '' } else { $repoUrl = "https://github.com/Gvo87/NotepadPlusPlus-LLM-Plugin" } +$downloadUrl = "$repoUrl/releases/download/v$Version/$zipName" + +Write-Host "" +Write-Host "====================================================================" +Write-Host " Package : $zipPath" +Write-Host " SHA-256 : $sha" +Write-Host "====================================================================" +Write-Host "" +Write-Host "Add this entry to nppPluginList -> src/pl.x64.json (npp-plugins array):" +Write-Host "" + +$entry = [ordered]@{ + "folder-name" = "LLMAssistant" + "display-name" = "LLM Assistant" + "version" = $Version + "id" = $sha + "repository" = $downloadUrl + "description" = "AI-powered text processing (enhance, translate, summarize, grammar, code) via the OpenAI API, directly inside Notepad++." + "author" = "Gvo87" + "homepage" = $repoUrl +} + +$entry | ConvertTo-Json | Write-Output diff --git a/packaging/nppPluginList-entry.json b/packaging/nppPluginList-entry.json new file mode 100644 index 0000000..0eb4578 --- /dev/null +++ b/packaging/nppPluginList-entry.json @@ -0,0 +1,10 @@ +{ + "folder-name": "LLMAssistant", + "display-name": "LLM Assistant", + "version": "1.0", + "id": "REPLACE_WITH_SHA256_OF_THE_RELEASE_ZIP", + "repository": "https://github.com/Gvo87/NotepadPlusPlus-LLM-Plugin/releases/download/v1.0/LLMAssistant_v1.0_x64.zip", + "description": "AI-powered text processing (enhance, translate, summarize, grammar, code) via the OpenAI API, directly inside Notepad++.", + "author": "Gvo87", + "homepage": "https://github.com/Gvo87/NotepadPlusPlus-LLM-Plugin" +} diff --git a/src/LLMAssistant.rc b/src/LLMAssistant.rc index 8fdf758..7a7af67 100644 --- a/src/LLMAssistant.rc +++ b/src/LLMAssistant.rc @@ -1,6 +1,41 @@ #include "resource.h" +#include "Version.h" #include +// Version information (read by Notepad++ Plugins Admin for update detection) +VS_VERSION_INFO VERSIONINFO + FILEVERSION LLM_VERSION_COMMA + PRODUCTVERSION LLM_VERSION_COMMA + FILEFLAGSMASK 0x3fL +#ifdef _DEBUG + FILEFLAGS 0x1L +#else + FILEFLAGS 0x0L +#endif + FILEOS 0x40004L + FILETYPE 0x2L + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "Gvo87" + VALUE "FileDescription", "LLM Assistant - AI text processing for Notepad++" + VALUE "FileVersion", LLM_VERSION_STRING + VALUE "InternalName", "LLMAssistant" + VALUE "LegalCopyright", "Copyright (C) 2026" + VALUE "OriginalFilename", "LLMAssistant.dll" + VALUE "ProductName", "LLM Assistant" + VALUE "ProductVersion", LLM_VERSION_STRING + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END + // Settings Dialog IDD_SETTINGS DIALOGEX 0, 0, 300, 178 STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | WS_POPUP | WS_CAPTION | WS_SYSMENU diff --git a/src/Version.h b/src/Version.h new file mode 100644 index 0000000..fae5e3d --- /dev/null +++ b/src/Version.h @@ -0,0 +1,33 @@ +#ifndef LLMASSISTANT_VERSION_H +#define LLMASSISTANT_VERSION_H + +// Single source of truth for the plugin version. +// +// This version is compiled into the DLL's VERSIONINFO resource. Notepad++'s +// Plugins Admin reads that binary version and compares it against the "version" +// field of the entry in nppPluginList (pl.x64.json) to decide whether an update +// is available. When you cut a new release, bump the numbers here, tag the repo +// with a matching "vMAJOR.MINOR" tag, and update the nppPluginList entry so +// auto-update keeps working. + +#define LLM_VERSION_MAJOR 1 +#define LLM_VERSION_MINOR 0 +#define LLM_VERSION_PATCH 0 +#define LLM_VERSION_BUILD 0 + +// Comma form used by FILEVERSION / PRODUCTVERSION. +#define LLM_VERSION_COMMA LLM_VERSION_MAJOR, LLM_VERSION_MINOR, LLM_VERSION_PATCH, LLM_VERSION_BUILD + +// Helpers to stringize the dotted version. +#define LLM_STR_HELPER(x) #x +#define LLM_STR(x) LLM_STR_HELPER(x) + +// Full "1.0.0.0" string for the version resource. +#define LLM_VERSION_STRING \ + LLM_STR(LLM_VERSION_MAJOR) "." LLM_STR(LLM_VERSION_MINOR) "." \ + LLM_STR(LLM_VERSION_PATCH) "." LLM_STR(LLM_VERSION_BUILD) + +// Short "1.0" string that matches the nppPluginList "version" field. +#define LLM_VERSION_SHORT LLM_STR(LLM_VERSION_MAJOR) "." LLM_STR(LLM_VERSION_MINOR) + +#endif // LLMASSISTANT_VERSION_H From 0303d21a315183faa64d557a32ec000748ff8081 Mon Sep 17 00:00:00 2001 From: gevorg Date: Sun, 19 Jul 2026 11:10:32 +0400 Subject: [PATCH 3/4] Address Copilot review comments - destroyMessageWindow: drain queued WM_LLM_RESULT messages and free their RequestCompletion payloads before DestroyWindow, preventing a message drop and heap leak during shutdown - make-release-zip.ps1: guard the git lookup so the script does not abort under ErrorActionPreference=Stop on machines without Git in PATH Co-authored-by: Cursor --- packaging/make-release-zip.ps1 | 7 ++++++- src/PluginDefinition.cpp | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packaging/make-release-zip.ps1 b/packaging/make-release-zip.ps1 index 53e6fca..630c15e 100644 --- a/packaging/make-release-zip.ps1 +++ b/packaging/make-release-zip.ps1 @@ -73,7 +73,12 @@ Remove-Item $staging -Recurse -Force $sha = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToUpper() -$repoUrl = (git config --get remote.origin.url) 2>$null +# Guard the git lookup: on machines without Git in PATH this must not abort the +# script (ErrorActionPreference is "Stop"); fall back to the known repo URL. +$repoUrl = $null +if (Get-Command git -ErrorAction SilentlyContinue) { + try { $repoUrl = (& git config --get remote.origin.url) 2>$null } catch { $repoUrl = $null } +} if ($repoUrl) { $repoUrl = $repoUrl -replace '\.git$', '' } else { $repoUrl = "https://github.com/Gvo87/NotepadPlusPlus-LLM-Plugin" } $downloadUrl = "$repoUrl/releases/download/v$Version/$zipName" diff --git a/src/PluginDefinition.cpp b/src/PluginDefinition.cpp index b076383..9e55b9c 100644 --- a/src/PluginDefinition.cpp +++ b/src/PluginDefinition.cpp @@ -103,6 +103,15 @@ void createMessageWindow() void destroyMessageWindow() { if (g_messageWindow) { + // Drain any WM_LLM_RESULT messages a worker thread posted but that the + // UI thread hasn't processed yet. DestroyWindow would otherwise silently + // discard them, leaking the heap-allocated RequestCompletion payloads + // (and leaving those results unapplied). + MSG msg; + while (PeekMessage(&msg, g_messageWindow, WM_LLM_RESULT, WM_LLM_RESULT, PM_REMOVE)) { + delete reinterpret_cast(msg.lParam); + } + KillTimer(g_messageWindow, kStatusClearTimerId); DestroyWindow(g_messageWindow); g_messageWindow = nullptr; } From 008d6d8a67d834419ca11ee4df457e3b7dc8d978 Mon Sep 17 00:00:00 2001 From: gevorg Date: Sun, 19 Jul 2026 11:20:22 +0400 Subject: [PATCH 4/4] Fix Unicode test failure by compiling sources as UTF-8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source files are UTF-8, but without /utf-8 MSVC decodes string literals using the system ANSI code page (CP1252 on the CI runner). That corrupted literals like L"CafΓ© πŸ™‚", so the "OpenAI content parses escaped Unicode" test failed in CI (the runtime UTF-8 decoding was correct; the literal was not). - Add /utf-8 to the CMake test target (fixes the failing test in CI) - Add /utf-8 to the plugin project (both configs) so the shipping DLL is compiled with the correct source encoding as well Co-authored-by: Cursor --- CMakeLists.txt | 7 +++++++ LLMAssistant.vcxproj | 2 ++ 2 files changed, 9 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 80ec69c..177230a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,5 +23,12 @@ target_include_directories(LLMAssistantTests PRIVATE src) target_compile_features(LLMAssistantTests PRIVATE cxx_std_17) target_link_libraries(LLMAssistantTests PRIVATE Catch2::Catch2WithMain) +# The source files are UTF-8. Without /utf-8, MSVC decodes narrow/wide string +# literals using the system ANSI code page (e.g. CP1252 on CI runners), which +# corrupts non-ASCII literals like L"CafΓ© πŸ™‚" and breaks the Unicode tests. +if(MSVC) + target_compile_options(LLMAssistantTests PRIVATE /utf-8) +endif() + include(Catch) catch_discover_tests(LLMAssistantTests) diff --git a/LLMAssistant.vcxproj b/LLMAssistant.vcxproj index c735e6e..45b7a7a 100644 --- a/LLMAssistant.vcxproj +++ b/LLMAssistant.vcxproj @@ -59,6 +59,7 @@ true _DEBUG;LLMASSISTANT_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true + /utf-8 %(AdditionalOptions) $(ProjectDir)include;%(AdditionalIncludeDirectories) @@ -79,6 +80,7 @@ true NDEBUG;LLMASSISTANT_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) true + /utf-8 %(AdditionalOptions) $(ProjectDir)include;%(AdditionalIncludeDirectories)