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/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)
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..630c15e
--- /dev/null
+++ b/packaging/make-release-zip.ps1
@@ -0,0 +1,105 @@
+<#
+.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()
+
+# 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"
+
+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 703b524..7a7af67 100644
--- a/src/LLMAssistant.rc
+++ b/src/LLMAssistant.rc
@@ -1,19 +1,56 @@
#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, 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..9e55b9c 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,74 @@ 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) {
+ // 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;
+ }
+ UnregisterClassW(kMessageWindowClass, g_module);
+}
void showStatusMessage(const wchar_t* message)
{
@@ -60,7 +128,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 +212,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 +236,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 +244,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 +274,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 +345,7 @@ void pluginRuntimeInit()
}
LLMService::getInstance().initialize();
+ createMessageWindow();
g_serviceInitialized = true;
g_runtimeInitialized = true;
g_shuttingDown = false;
@@ -273,6 +361,7 @@ void pluginCleanUp()
if (g_worker.joinable()) {
g_worker.join();
}
+ destroyMessageWindow();
LLMService::getInstance().cleanup();
g_runtimeInitialized = false;
g_serviceInitialized = false;
@@ -378,7 +467,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 +478,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/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
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