A portable Windows snippet manager. Press a global hotkey, pick a snippet from a nested menu at your mouse cursor, and watch it get typed or pasted into whichever field had focus.
Built as a spiritual successor to GhostWriter and an alternative to PhraseExpress.
New here? The full syntax reference — every token, every operator, three fully-worked real-world examples — lives in TUTORIAL.md. This README covers installation, distribution, translations, and troubleshooting.
- Global hotkey (default
Ctrl+Alt+Space) opens a nested popup at the cursor. - Snippets and folders with unlimited nesting. Drag-and-drop to reorder or re-nest.
- Four injection modes per snippet — see Pick the right injection mode:
clipboard (CTRL+V)— backup / Ctrl+V / restore.clipboard (Shift+INS)— same, using Shift+Insert (PuTTY, xterm).typing (Key Events)— real key events.typing (Web Terminal)— bespoke Win32SendInputtyper with real VK + scan codes for web-based terminals (ShellInABox, vManage, xterm.js) that inspectevent.keyCode.
- Rich token language — dates, clipboard, waits, named keys, key combos, DeepL translation, context variables, database lookups, snippet includes, multi-branch
{IF}/{ELSIF}/{ELSE}/{END}conditionals. Full reference in TUTORIAL.md. - Team snippets over SMB — a dedicated "Team" tab reads shared snippets from a network share, caches them locally for offline use, and filters to the current Windows group via share permissions. The admin edition (
Poltergeist-Admin.exe) adds a "Publish to team share" action; the user edition keeps the tab read-only. - Per-folder hotkeys — assign a hotkey to any top-level folder to jump straight into its sub-menu, with Team > Personal > main-popup precedence.
- Context-aware filtering — regexes in
Options > Context extractionparse the user's selection / clipboard on every hotkey press. Snippets and folders can filter themselves on the extracted variables via a "Show when..." field (supports=,!=,in,not in,contains,startswith,endswith,matches;?for optional,hideto keep a snippet out of the popup entirely). - CSV / XLSX databases — drop a spreadsheet onto the team share and snippets can do
{DATABASE=Sites,$region-$site,INET_SOLUTION}lookups against it. - DeepL translation (optional) —
{TRANSLATION=DE}...{TRANSLATION_END}; batches identical language pairs into one API call. - Token syntax highlighting in the snippet editor with per-category colour chips.
- Per-node colours for visual tagging in both tree and popup.
- Light / Dark theme follows Windows by default.
- Portable — config + snippets in a single
poltergeist.jsonnext to the exe. No installer, no registry, no autostart persistence. - Localised UI — ships with English, German, Spanish and French (see Translations).
pip install -r requirements.txt
python main.pyTested on Python 3.11 and 3.12. A virtual environment is optional but recommended
(py -3.12 -m venv .venv && .\.venv\Scripts\Activate.ps1 before pip install).
For contributors: pip install -r requirements-dev.txt also pulls in
PySide6-stubs so static analyzers stop emitting false-positive
"No name 'QObject' in module 'PySide6.QtCore'" errors.
.\.venv\Scripts\Activate.ps1
pwsh .\build.ps1Produces both editions side-by-side (see the next section):
dist/Poltergeist.exe- user edition (Team tab is read-only).dist/Poltergeist-Admin.exe- admin edition with "Publish to team share".dist/poltergeist-defaults.json- staged copy of the distribution template, see below.
Flags: pwsh .\build.ps1 -UserOnly / -AdminOnly /
-SkipTranslations for the obvious shortcuts. If you just want to
run PyInstaller once without the translation step and the admin
build, pyinstaller build.spec --noconfirm still works - the
result is the user edition only.
Drop the produced exe into any folder; its poltergeist.json will be
written beside the exe on first exit.
For distributing Poltergeist to a team, you almost never want new hires to meet a completely empty app - the team share path, database paths, and context-extraction regexes are usually identical across the team, and typing them in by hand is error-prone.
Poltergeist therefore supports a one-shot bootstrap from a
poltergeist-defaults.json file dropped next to the executable.
How to produce a distribution ZIP
- Run
pwsh .\build.ps1. This builds the exes and copiespoltergeist-defaults.example.jsontodist/poltergeist-defaults.json. - Edit
dist/poltergeist-defaults.json- set the team share path, tune thecontext_patterns, tweak the example welcome snippet. Leavedeepl_api_keyempty - users enter their own. - ZIP
dist/Poltergeist.exe+dist/poltergeist-defaults.jsontogether (plusPoltergeist-Admin.exeif shipping the admin edition to your snippet maintainer). - Hand the ZIP to new hires.
What happens when a new hire launches the app
- No
poltergeist.jsonexists next to the exe yet, so the app readspoltergeist-defaults.json, adopts it as the starting config, and writes outpoltergeist.jsonon the first save. - From then on, the defaults file is ignored. The user owns their config.
- A user can force a "factory reset" by deleting their
poltergeist.json- the next launch re-bootstraps from the defaults file.
What NOT to put in the defaults file
- Secrets (DeepL API keys, anything else). Keys shipped inside a
JSON file are trivial to extract with a hex editor; treat the
defaults file as world-readable. Our workflow leaves
deepl_api_keyempty and points users at the same internal KB article that holds the team share path. - Stale snapshots of
tree_team. The team tree is pulled live from the share on every launch, so there's no point baking one in.
See poltergeist-defaults.example.json in this repo for a
self-documenting template.
Poltergeist/
main.py entry point + single-instance mutex
app.py application controller (wires everything)
config.py portable JSON config: locate / load / save
models/tree.py Folder / Snippet / Settings data model
services/
hotkey_manager.py global hotkey via `keyboard`
injector.py clipboard-paste-with-restore / typing
tokens.py {TAB} {ENTER} {DATE[:fmt]} {CLIPBOARD}
translation.py DeepL client + {TRANSLATION=XX}...{TRANSLATION_END} expansion
focus.py save/restore foreground HWND
autostart.py Startup-folder .lnk toggle
theme.py system-theme detection + palette application
icons.py icon loader (inverts black lineart to white in dark mode)
i18n.py translation loader (QTranslator + system-locale resolution)
ui/
main_window.py tree + editor + toolbar
snippet_editor.py name / mode / body + Insert Token menu
tree_widget.py QTreeWidget with DnD nesting
options_dialog.py separate settings dialog
popup_menu.py nested QMenu at cursor
hotkey_capture.py key-combo recorder widget
about_dialog.py About / credits window
tray.py system tray icon + menu
icons/
AppIcon.png colored app icon
AppIcon.ico generated at build time from AppIcon.png
*.png black lineart icons (inverted to white at runtime for dark mode)
translations/
poltergeist_<xx>.ts source catalogs (XML, edited by hand or in Qt Linguist)
poltergeist_<xx>.qm compiled catalogs (binary, bundled into the exe)
_apply_translations.py first-pass translation bootstrapper (dev tool, not shipped)
build.spec PyInstaller spec
build-translations.ps1 helper: refresh .ts from source + compile .qm files
requirements.txt
poltergeist.json lives next to the executable and holds every
setting plus both snippet trees (tree_personal, tree_team).
It's created on first launch — or, when distributing the app, seeded
from poltergeist-defaults.json (see above).
Rather than duplicate the schema here, refer to
poltergeist-defaults.example.json
— a fully-commented, schema-complete template with all top-level
keys annotated.
Snippets support a small DSL: plain text plus tokens wrapped in curly braces. The most common ones at a glance:
| Token | Purpose |
|---|---|
{DATE}, {DATE:%d.%m.%Y} |
Today, default or explicit strftime format |
{CLIPBOARD} |
Text captured before Poltergeist overwrote the clipboard |
{TAB}, {ENTER}, {WAIT=250} |
Whitespace keys + mid-injection pauses |
{CTRL+A}, {ALT+F4}, {F2} |
Any modifier + key combo |
{VAR=country} |
Value extracted from the selection via a context-pattern regex |
{DATABASE=Sites,$region-$site,INET_SOLUTION} |
Lookup in a CSV / XLSX on the team share |
{INCLUDE=SnippetName} |
Inline the body of another snippet (reusable helpers) |
{IF ...}...{ELSIF ...}...{ELSE}...{END} |
Multi-branch conditionals on context variables |
{TRANSLATION=DE}...{TRANSLATION_END} |
Translate block via DeepL at injection time |
{{ / }} |
Literal { / } |
The complete reference — every token, every operator, escape rules, conditional semantics, and three fully-worked real-world examples — is in TUTORIAL.md.
| Target | Recommended mode |
|---|---|
| Outlook, Teams, Slack desktop, Notepad, browsers (address / form fields), VS Code | clipboard (fastest) |
| Cisco vManage SSH / ShellInABox / any web-based SSH terminal | typing (compat) |
| PuTTY, real xterm, X11 terminals that treat Shift+Insert as paste | clipboard (Shift+Insert) |
| Apps where Ctrl+V is captured (some VPN clients) or where you need Enter to fire as a key instead of a newline character (IRC, chat boxes) | typing |
| Electron / Citrix surfaces with aggressive scan-code filtering | typing (compat) |
| Elevated (UAC) windows | none work without running Poltergeist elevated too |
Web-based terminals (ShellInABox, xterm.js, etc.) are the classic
"why doesn't typing work here?" target - their JavaScript typically
reads event.keyCode to figure out what character was typed, which
means any keystroke that arrives as VK_PACKET (231 - Windows'
marker for KEYEVENTF_UNICODE synthesised input) gets silently
dropped. typing (compat) sidesteps this by calling VkKeyScanW
for every character and emitting the real VK code + scan code that a
physical keypress would produce, so the JS sees keyCode=83 for
s, keyCode=13 for Enter, etc. - exactly as if you'd typed it on
your keyboard.
Open Options > DeepL translation and paste your auth key; the field is
validated live against the /v2/usage endpoint. The key only lights up
green once DeepL accepts it, and the "Translate to..." option in the
snippet editor's Insert Token menu stays hidden until then.
The body of a translation block can also contain other tokens - e.g.
{TRANSLATION=DE}{CLIPBOARD}{TRANSLATION_END} translates whatever is
currently on the clipboard. A snippet may contain any number of
translation blocks; blocks sharing the same (source, target) pair are
batched into a single API call (so one German + one French translation
= 2 API calls, not N). If the first DeepL call errors out we bail
immediately rather than burning through additional quota.
DeepL auto-detects the source language by default, which is usually
what you want. For short or ambiguous inputs you can pin the source
explicitly with {TRANSLATION=SRC>TGT}...{TRANSLATION_END}, for example
{TRANSLATION=EN>DE} to force English -> German. The snippet editor's
Insert Token > Translate to... > From specific source language...
menu opens a small picker that builds the token for you.
The app uses the official deepl-python library. If the DeepL translation group in Options says "deepl package not available...", run:
pip install deeplin the same Python environment you launch Poltergeist from, then restart the app.
Poltergeist ships with the truststore
package and calls truststore.inject_into_ssl() before any DeepL
request, so Python's SSL layer uses the OS certificate store
instead of the bundled certifi list. This means any root CA you've
installed into Windows (typically how corporate MITM proxies
distribute their interception cert) is trusted automatically, without
any config.
If your corporate proxy requires authentication, Python reads it from
the HTTPS_PROXY environment variable (not from Windows system proxy
settings the way curl does), so set HTTPS_PROXY in your user env
if you get a ProxyError / 407 / 502 back from DeepL.
The UI uses Qt's standard translation pipeline: every user-visible string
is wrapped in tr(...) / QCoreApplication.translate(...), extracted
into an XML catalog (translations/poltergeist_<xx>.ts), then compiled
to a binary catalog (translations/poltergeist_<xx>.qm) that gets
loaded at startup by services/i18n.py.
At runtime the active language is resolved in this order:
- The explicit
settings.languagevalue frompoltergeist.json(set via Options > Application > Language). - The Windows system locale, if a matching
.qmships with the app. - English (source strings, no catalog loaded).
Changing the language in Options writes the new code to
poltergeist.json and pops a "restart to apply" prompt - live
retranslation isn't supported.
The build-translations.ps1 helper runs both pyside6-lupdate (to
re-extract English source strings into every .ts) and
pyside6-lrelease (to compile .ts -> .qm):
.\build-translations.ps1 # extract + compile
.\build-translations.ps1 -CompileOnly # skip extraction, just recompileYou need PySide6 installed in the active environment (the Linguist
binaries ship with it; no separate Qt SDK required).
.\build-translations.ps1once to make sure the.tsfiles are up-to-date with the current source strings.- Open
translations/poltergeist_<lang>.tsin your editor of choice and fill in the<translation>element for each message. Remove thetype="unfinished"attribute once a translation is reviewed (lrelease still ships unfinished ones, but the attribute is a useful "needs review" flag). .\build-translations.ps1 -CompileOnlyto produce new.qmfiles.- Restart Poltergeist (or change the language in Options) to see the
updated strings. For source builds the files are picked up
directly from
translations/; for PyInstaller builds the.qmfiles are bundled bybuild.specand extracted next to the exe on launch.
- Add a new
(code, native name, English name)entry toSUPPORTED_LANGUAGESinservices/i18n.py(the language combo in Options reads this list). - Add the two-letter code to the
$Languagesarray inbuild-translations.ps1(e.g."it"for Italian). - Run
.\build-translations.ps1to generate the new.ts, fill in translations, then run it again (or-CompileOnly) to produce the.qm. - Rebuild the exe; the new language appears in Options automatically on next launch.
- Hotkey does nothing. Some corporate anti-virus tools block low-level keyboard hooks. Try a different hotkey, or run the exe from an elevated context (shouldn't be necessary, but some policies require it).
- Focus not restored. If you press the hotkey while a UAC-elevated window is focused, an un-elevated Poltergeist can't restore focus to it. Run the target app un-elevated, or run both elevated.
- Team tab is empty. Check
Options > Team > Status.Share unreachable and no cache available yetmeans the SMB path isn't mounted or the user isn't in the group; once reachable, clickRefresh from shareor restart the app. - Snippets vanished from the popup after adding a filter. The "Show when:" rule is probably gating the snippet on a context variable that isn't being captured. Suffix the operator with
?(e.g.type contains? Sto) to also show the snippet when the variable is empty — see TUTORIAL.md.
Copyright 2026 Lutz Schwemer Panchez
This project is licensed under the Apache License, Version 2.0. The full license text is in LICENSE.
