Guide
UI and app binary
The UI is plain Win32 through golang.org/x/sys/windows and raw syscalls (user32, gdi32, dwmapi, shell32, shcore). No CGO, no webview, no GUI library. Code lives in internal/ui; wiring and the CLI live in cmd/zagros.
System tray #
Zagros is a tray application. It starts with no main window and no taskbar button (the launcher window is a hidden WS_EX_TOOLWINDOW popup). The tray icon is created at startup.
| Interaction | Result |
|---|---|
| Left-click | Toggle the launcher (show if hidden, hide if shown) |
| Right-click | Context menu |
| Hover | Tooltip with state: Zagros - ready (Alt+Space), Zagros - open, or Zagros - hotkey unavailable, plus a second line such as 3 extension(s) enabled |
Context menu:
- Zagros X.Y.Z (greyed out) shows the running version, or
devfor an unversioned build - Show
- Settings opens
zagros.tomlin the default editor (falls back to Notepad) - Reload extensions and themes
- Open config folder
- Extensions... opens the extension manager (greyed out when neither the extension manager nor the marketplace is available)
- Start with Windows (checkmark reflects the HKCU
Runentry; toggling it also writesstart_with_windowsto the config throughOptions.OnAutostart) - Log console (checked while open) opens a console window that shows every extension and launcher log live, or closes it. Logs are kept in memory only
- Exit
Behaviour details:
- Explorer restarts. The window registers the
TaskbarCreatedmessage and re-adds the icon when it arrives. - Notifications.
App.Notify(title, text)shows a tray balloon. It is thread-safe and is whatext.Platform.Notify(extensionnotify()) uses, and what error reporting uses (failed actions, reload results, hotkey conflicts). - Tooltip state.
App.SetTrayState(text)sets the second tooltip line; the app updates it after extension changes. - Single instance. A named mutex (
Local\ZagrosLauncherSingleton) guards startup. A second launch finds the first window by class name and posts a show message, then exits.zagros ext installposts a reload message to the running instance. - Hotkey failure. If
RegisterHotKeyfails (the combination is taken) the tooltip changes and a balloon explains it; the tray icon still works.
Keyboard #
Launcher:
| Key | Action |
|---|---|
Alt+Space (configurable, hotkey in the config) | Show or hide the launcher from anywhere |
Esc | Hide |
Up / Down | Move selection (wraps) |
PgUp / PgDn | Move one page |
Home / End | Caret to start/end of the query. With an empty query, first/last result |
Ctrl+Home / Ctrl+End | First/last result, always |
Enter | Run the selected item's action |
Ctrl+Enter | Run the item's first alternate action (Item.Alt[0]) |
Tab | Completes the query when the item offers it (for example g on a web search, so you can type the query); otherwise opens the popup menu with the item's alternate actions |
Ctrl+1 ... Ctrl+9 | Run the Nth visible result |
Left / Right, with Ctrl | Move caret by character or word; Shift extends selection |
Backspace / Delete, with Ctrl | Delete character or word |
Ctrl+A / Ctrl+C / Ctrl+X / Ctrl+V | Select all, copy, cut, paste (single line; newlines become spaces) |
| Mouse | Click a row to select, double-click to run; click or drag in the input to place the caret or select; wheel scrolls |
The launcher hides when it loses focus. Actions of kind setquery replace the query and keep the launcher open; every other kind hides the launcher first and runs on a goroutine, so a slow action never blocks the UI.
Dialogs: Esc closes (permission prompt: counts as Cancel), Enter confirms the permission prompt, wheel scrolls long lists. In the extension manager Tab switches tabs and the arrow keys, PgUp/PgDn, Home/End move the selection.
Windows #
- Launcher (
launcher.go,render.go). One window created at startup, shown withSetWindowPosand hidden withSW_HIDE.WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_LAYERED, borderless. Centred horizontally on the monitor that holds the cursor, 20% from the top. A fade-in of at most 100 ms (Options.FadeMS) uses layered alpha. - Permission dialog (
prompt.go). Implements the ext permission prompter throughApp.PromptPermissions, callable from any goroutine; it blocks until the user decides. It lists the extension name, publisher, source, and every permission with scope and a plain-English explanation, each with Allow/Deny, plus Allow all / Done / Cancel. Undecided permissions are denied. The UI stores nothing; the caller persists the returned grants. - Extension manager (
manager.go,manager_draw.go). A list on the left and a detail pane on the right, under a tab strip with a filter box. Typing anywhere filters the current tab (name, description, keywords, author);Escclears the filter, then closes.Enterruns the main action of the selected row (enable or disable, install, use).- Installed tab. The detail pane answers what the extension does and how to run it: description, How to use (the manifest
usage, its keywords as tags, and eachexamplesquery with a Try button that types it into the launcher), Settings, Permissions and Uninstall. A switch in the header enables or disables the extension; a load error is shown in red and the row is taggederror; Open folder and Homepage open the install folder and the manifesthomepage. - Settings are edited in place: a boolean flips on click, a choice cycles through its options, and other types open an inline editor (
Entersaves,Esccancels, an invalid value shows its error under the field). The values go throughExtAdmin.SetSettingand are stored in<data dir>/ext/<id>/settings.json, which the pane prints; they are not inzagros.toml.zagros ext config <id> [key [value]]does the same from the command line. - Permissions list every declared permission with the reason from the manifest; a granted one has a Revoke button (it turns red on hover), an ungranted one says so.
- Install file... / Install folder... and drag-and-drop of a
.zagrosfile or project folder install a local extension throughExtAdmin.InstallLocal, then reload so the permission dialog appears. - Marketplace tab (list and install; each entry shows EXT or THEME, the permissions it will ask for, and what to do after installing; themes go through
InstallTheme) and Themes tab (built-in, user and installed themes; "Use this theme" setstheme = "<name>"with a minimal edit of thetheme =line inzagros.toml, so comments survive, then reloads). - Keyboard.
Tab/Shift+Tabmove a focus ring (accent outline) through every control in the pane and the footer, scrolling the pane to keep it visible;SpaceorEnterpresses the focused control; typing returns to the filter.Ctrl+Tab/Ctrl+Shift+TaborCtrl+1..3switch tabs. - The detail pane scrolls on its own (wheel over it).
--selftestrenders all three tabs;--selftest-shots <dir>also saves a PNG of each state for review. Network and install calls, and enabling, disabling, uninstalling and revoking, run on goroutines and post results back to the UI thread (the buttons grey out meanwhile). Long lists get a scrollbar: drag the thumb, click the track to page, or use the wheel. Scrolling is by pixel and glides over a few frames (an eased animation driven by a 15 ms panel timer), so small touchpad steps move the list a little instead of being rounded to whole rows; arrow keys glide the selected row into view.--selftestalso times a manager frame and drives the scrollbar.
- Installed tab. The detail pane answers what the extension does and how to run it: description, How to use (the manifest
- Dialogs are borderless top-level windows drawn with the active theme (
panel.go): own title bar (drag to move), owner-drawn buttons with hover.
Rendering #
GDI only. The frame is composed in a memory DC and blitted once per WM_PAINT (no flicker). Text uses CLEARTYPE_QUALITY fonts from the theme (font, font_size). The title shows fuzzy-match highlights (bold plus accent colour), the subtitle is dim, the category tag sits at the right when show_tags is on. Icons come from SHGetFileInfo on a worker goroutine (large icon scaled to the row), cached in a 128-entry LRU, with a letter tile as the placeholder until the icon arrives and as the fallback for URLs and glyph names.
DPI: the process is Per-Monitor-V2 aware (SetProcessDpiAwarenessContext at runtime, no manifest needed). Every theme metric is in 96-DPI pixels and scaled by the DPI of the monitor the launcher opens on (Scale, ComputeMetrics). Metrics are recomputed on each show and fonts whenever face, size or DPI change, so moving between monitors with different scaling works.
Corners: radius > 0 asks DWM for rounded corners (Windows 11); on older systems a window region is used. radius = 0 (the Boxy themes) turns rounding off explicitly.
Queries #
Each keystroke cancels the previous query and starts Catalog.QueryStream(ctx, q, emit) on a goroutine; see "Performance architecture" below for tiers, snapshots and coalescing.
Performance architecture #
Typing must never wait for anything slower than a repaint. The rules, and where they live:
- The UI thread only draws and edits. No file, shell, network or process call is reachable from
WM_CHAR/WM_PAINT. Actions run on goroutines (activate), tray commands that open files or write the config run on goroutines, queries and icon loads run on workers. (The extension manager window still callsExtAdminsynchronously on a click; it is not on the typing path.) - Progressive results (
stream_ui.go,core/stream.go). Each keystroke callsCatalog.QueryStream. Providers are tiered by the optionalcore.Tieredinterface (core/tier.go): tier 0 (default) is in-memory work started immediately; tier 1 (Everything, extensions, recent files) is slow I/O, started after a 40 ms debounce with its own 1.5 s budget. Every arriving provider result is merged and re-ranked (order is by provider registration, so the final order does not depend on arrival order) and sent as aSnapshot. A tier-0 snapshot is always sent so stale rows of the previous query disappear.Catalog.Queryis a wrapper that returns the last snapshot (CLI, tests). - Coalescing (
perf.go). The query goroutine writes snapshots into a one-slotlatestSlotand posts a message only when the slot was empty. The UI thread applies at most one snapshot per 16 ms (rateGate,timerApply), always the newest, and drops snapshots whose sequence number is not the current query. An unchanged list (SameItems) causes no repaint. - Stable selection and window. If the user moved the selection, it follows its item ID across updates (
SelectionAfterUpdate); otherwise row 0 stays selected. The window height depends only on the row count (capped bymax_results);SetWindowPosand the corner region are only touched when it changes. - Async icons (
iconload.go,perf.go). Painting looks the icon up in aniconLRU(128 entries, UI-thread only). A miss draws the letter tile and queues the path on a single worker goroutine (queue of 24, deduplicated, newest first; requests for rows no longer visible are dropped after every paint). The worker runs on a COM-initialised locked thread. Network paths (UNC, remote drives) and per-extension types (.txt, ...) useSHGFI_USEFILEATTRIBUTES, so the shell never opens them; programs, shortcuts and folders get a real lookup, with a fallback to the generic icon for missing files. TheHICONis rendered onto black and white backgrounds to recover alpha and stored as a premultiplied 32-bit DIB; painting is oneAlphaBlend. The cache survives hide/show. - GDI objects are created once. Fonts are keyed by face, size and DPI and survive hide/show; fills use
ExtTextOutwith no brushes or pens; the back buffer is allocated once at the maximum height. After 5 minutes hidden the icon cache is trimmed to 32 entries, the back buffer is freed and the working set is trimmed. (Trimming the working set right after every hide made the next show fault everything back in, so it is gone.) - Warm start. After the tray and hotkey exist,
startBackgroundstarts the icon worker (which pays the one-time shell icon initialisation), warms the font cache and allocates fonts and back buffer from a goroutine, andCatalog.Warmloads the apps index (core.Warmer). Queries never block on an index: if the apps index is not ready the snapshot lists the provider inNotReadyand the status line shows "Indexing apps...". When the index lands, the provider'sOnReadycallback makes the catalog fireOnInvalidateand the launcher re-runs the query, keeping the selection. - Everything (
builtin/everything*.go,evcache.go) calls the SDK DLL in-process. Results of the last 16 searches are kept for 10 s: an identical search (backspace) is answered from the cache, and a plain-word search that extends a complete earlier one (fewer hits than the limit) is filtered locally on file names. Searches superseded while waiting for the DLL lock return at once. Only without the DLL ises.exeused: after a 60 ms debounce, and cancelling the query kills the process.
Tracing #
zagros --trace (or ZAGROS_TRACE=1) appends timing lines to <data dir>\trace.log; with tracing off each event is one atomic load. Events: key_to_first_paint, key_to_items_paint (first paint that shows results) and key_to_final_paint measured from the character being inserted, provider (per provider and tier, with the query), icon_load, paint, fonts, warm, warm_fonts, warm_backbuf. core.Trace also keeps per-name statistics (Summary).
--selftest includes a burst test: 50 keystrokes 20 ms apart through the real pipeline with an instant provider and a 200 ms tier-1 provider, pumping window messages. It fails if any frame takes more than 50 ms to paint, if instant results take more than 30 ms to be painted after a keystroke, if the slow provider's items are missing from the final list, or if the icon worker loaded nothing, and prints the trace summary. Measured on the development machine: waiting for all providers (the old behaviour) 201 ms; streamed results painted within 2.0 ms at worst (0.7 ms average), worst paint 1.4 ms.
Memory strategy #
The target is a small idle footprint (SetProcessWorkingSetSize brings the working set to well under 1 MB right after startup and after a long idle period, and it grows only while the launcher is in use).
- One window, created once; hide and show, never destroy.
- While hidden: the result list is released; fonts, the back buffer and the icon cache are kept (trimmed after 5 minutes hidden, see "Performance architecture").
- The back buffer is allocated once at the maximum height, not per paint or per show.
- No timers run while hidden (caret blink and fade timers exist only while visible).
- A
debug.FreeOSMemoryand working-set trim runs 8 s after startup and after 5 minutes hidden, not after every hide (that made the next show page-fault). - Providers load their index in the background after startup (
Catalog.Warm); a query that arrives earlier shows "Indexing...".
The Go runtime itself keeps some committed address space; "private bytes" in Task Manager is larger than the working set.
Pure logic and tests #
Layout, editing and parsing are free of Win32 and unit tested (internal/ui/pure_test.go):
layout.go:Metrics(scaling, window height, row rectangles,HitRow),Scroll(selection and scroll window),CaretIndexAt,HScroll.editor.go:Editor(insert, delete, word moves, selection).hotkey.go:ParseHotkey("Ctrl+Shift+K").match.go: fallback highlighter andSplitRuns.perf.go(perf_test.go):SelectionAfterUpdate,SameItems,rateGate,latestSlot,iconQueue,iconLRU. Stream merge and tiering are tested ininternal/core/stream_test.go, Everything result reuse ininternal/builtin/evcache_test.go.
zagros --selftest creates the windows, renders the launcher at 96 and 168 DPI, the permission dialog and the extension manager into offscreen bitmaps, checks pixels, runs the typing-burst latency test (see "Tracing"), adds and removes the tray icon, and exits 0. It never shows a window.
App binary and CLI #
zagros run (tray + hotkey)zagros --versionzagros --config print the config path and open itzagros --selftestzagros ext install <path|folder|github:owner/repo[@tag]|url|registry-id>zagros ext listzagros ext remove <id> [--keep-data]zagros ext pack <dir> [-o file]zagros ext validate <dir>zagros theme list built-in, user and installed themeszagros theme install <registry-id|https url to .toml>zagros theme remove <name>zagros market list kind, id, description of every entryzagros market search <query>zagros ext install <id> on a theme id stops with a pointer to zagros theme install. Installs and removals post a reload to a running instance.
The MCP tool install_extension and the manager's local install never grant permissions themselves; the running launcher asks after the reload.
CLI installs ask for permission consent in the terminal (y, N, or each). The GUI-subsystem binary attaches to the parent console for CLI output.
Build with scripts/build.ps1 (output dist/zagros.exe, flags -H=windowsgui -s -w, version from git describe). The icon is assets/zagros.ico, generated by go run ./scripts/genicon and embedded for the tray and dialogs.
Wiring #
cmd/zagros/run.go builds core (config, history, catalog), the built-in providers, ext.Manager (with guiPrompter, platform, and the executor chain Manager.WrapExecutor(executor)), the market client, and passes small interfaces to ui.New (ui.Catalog, ui.ExtAdmin, ui.Market). Extension discovery runs from Options.OnReady, after the window exists, so permission prompts can be shown. Providers are re-synchronised when the manager reports a change.
Scopes (Tab) #
Tab on an item enters its scope: the input shows a coloured chip with the scope name ("Web search", "Actions", or an extension's label) and the list shows that tool's items instead of the root results. In the Web search scope the list is the search engines for what you typed. Items with only secondary actions open an "Actions" scope that lists the default action and the alternates. Esc, or Backspace on an empty input, leaves the scope and restores the text you had. Extensions use the same mechanism (scope(...) and on_scope, see the extension guide). The old native popup menu is gone.
Slow extension results (tasks) #
An extension that needs more than its 5 second hook budget starts a background task(...) and returns an item(..., pending = True) row. The row shows "Loading..." and Enter does nothing on it. When the task ends, the extension provider calls the catalog's invalidate callback (core.ReadyNotifier, the same path an index uses when it finishes loading), and the UI runs the current query again through the usual coalesced stream; the placeholder is replaced by the real rows. A short delay (30 ms) merges several finishing tasks into one re-run. Typing does not wait for tasks: on_query returns at once, and a task whose query was left behind is cancelled. Built-in providers do the same with Warmer/ReadyNotifier (see Progressive results). Measured in the typing benchmark below.
Update notices #
The background updater (ADR 0027) reports through App.Flash(text): a one-off line on the empty launcher, shown for 8 seconds the next time it opens (or at once when it is open and empty) and replaced by typing. It is the only place that line appears, so an update notice never stays on screen; the tray balloon says the same once. The manager marks extensions with a newer registry version with an update tag and an Update to X button (ExtAdmin.UpdateExt).
Loading and failure feedback #
Extensions are tier-1 providers, so the launcher also reports on them without any code in the extension. When a tier-1 provider is still running 300 ms after its debounce, the snapshot carries its name (Snapshot.Slow) and the status line reads "Waiting for Dictionary...". On the last snapshot Snapshot.Problems explains every provider that gave nothing: "X did not answer in time" when it used its 1.5 second budget, and "X: reason" when it implements core.Failer (the extension provider does, with the first line of the error its hook raised). A provider that is fast or answers normally never shows up there. Inside a Tab scope the same "Waiting for ..." line appears after 300 ms. An extension that wants a row instead of a status line uses task() and pending, above.
The manager keeps the last 100 log.* lines and host errors per extension in memory (ext.LogBuffer, filled by wrapping Options.Log), shows the newest eight in the detail pane under Log with Copy and Clear. Logs are never written to disk. The tray item Log console starts zagros logs --window in a console of its own; it streams every entry (extensions, plus the launcher's own log output as source zagros) from the running launcher over a local socket (<data dir>/logs.sock, an empty file while Zagros runs). Closing the window only ends the viewer. The last 2000 lines are replayed first, then new ones arrive live; zagros logs [id] [-n 200] does the same in any terminal. Folders whose extension failed to load (bad manifest, newer min_zagros, script error at load) are listed as broken:<folder> with the error, so they are never invisible. A build whose version is not a release number (dev) passes every min_zagros check.
Typing and the result list #
The input box is painted on its own path: each key is echoed straight to the window before the query starts, and the result list is repainted separately (and coalesced while you type fast). Typing therefore never waits on providers or icons.
Web search fallback #
When a query is not claimed by a keyword, the last row offers a web search with the first engine in web_search. Press Tab on it to pick one of the other engines for the same query; your text is left alone. On the "Search Google" keyword hint, Tab completes to the keyword (g). On generated passwords, Tab generates a new set. Extensions can offer completions too: item(..., complete="kw ").