# GUI app shell The desktop app has a tray (menu bar) icon whose click drops a compact panel, and a regular window. Both show one small web UI served from embedded assets. The page talks to Go over a JSON API on the same handler, with no binding generator and no bundler. `magpie web` serves the same page to a browser for a machine with no desktop. Package `internal/gui` hosts all three. ## Responsibilities and sources of truth | Part | Responsibility | Source | | --- | --- | --- | | Native host | `Run` starts the Wails v3 app: the tray icon, the panel, the main window, single-instance handover, `magpie://` import links and open at login. `host` implements `Windows` for the API: show and hide, open URLs and folders, copy to the clipboard, fit and tint the panel, and set the text size. | [`app.go`](../../internal/gui/app.go), [`mainwindow.go`](../../internal/gui/mainwindow.go), [`traymenu.go`](../../internal/gui/traymenu.go) | | Backend | `startBackend` starts the gateway this process serves, unless another magpie already serves it. In that case this process only shows the other one's status (`gw` is nil). `stopServing` drains requests in flight. | [`backend.go`](../../internal/gui/backend.go) | | API and assets | `Handler` serves `assets/` (embedded with `//go:embed assets`) and the `/api/*` routes, such as `/api/state`, `/api/set` and `/api/agents/{action}/{id}`. `held` lets one API request's reads share one catalog build (`provider.Hold`), and drops it after any write. `revalidated` and `versionedPage` name each script by its content hash, so a cache never serves an old `app.js` under a new page. | [`api.go`](../../internal/gui/api.go) | | Browser mode | `StartWeb` serves the page and the gateway for `magpie web`. The browser signs in with a run key in a link. Gateway mode shows Providers, Gateway, Routing, Usage, Sessions, Plugins and Settings on a machine that is only a gateway. | [`web.go`](../../internal/gui/web.go), [`gatewaymode.go`](../../internal/gui/gatewaymode.go) | | The page | `app.js` keeps one state object per view and renders it into a list, with no framework. The views have their own files: `routing.js`, `library.js`, `plugins.js`, `sessions.js` and `context.js` (Usage's Context tab, and the context window card the Routing page draws under a request's story, fed by `/api/context` in `context.go`, which scores and tags each agent; its "Tuned for you" cards are fed by `/api/tune` in `tune.go`, see [agent wiring](agent-wiring.md)). Strings go through `t()` with translations in `i18n.js` (zh, zh-TW, ja and de; the English text is the key and the fallback). zh-TW is Traditional Chinese with Taiwan's words; a system language of zh-TW, zh-HK, zh-MO or any zh-Hant picks it, and the tray menu and notifications follow (`trayLang`). Dropdowns use `openProtoMenu`. Every app menu (`openProtoMenu`, `openRowMenu`, `openSessCombo`) is placed by `placeMenu`: under its anchor, or over it when the window runs out below; one that fits neither way takes the larger room, its height held to it, and scrolls inside itself, and a scroll inside a menu doesn't close it (#1437, `removed-groups-menu-scroll.test.cjs`). Scrolling is done only by `scrollOnPurpose(e)` with the reader's event. | [`assets/app.js`](../../internal/gui/assets/app.js), [`assets/i18n.js`](../../internal/gui/assets/i18n.js) | | No GUI build | `-tags nogui` builds magpie without the GUI: `hasGUI` is false and `runGUI` says to use `magpie tui`. The files of `internal/gui` that need Wails build under `!nogui`. | [`gui_off.go`](../../gui_off.go) | ## Runtime path 1. `magpie` with no arguments, `magpie app` or `magpie tray` calls `runGUI`, which calls `gui.Run`. A `nogui` build runs the TUI instead. A second launch hands over to the instance already running. 2. `startBackend` brings up the gateway, or finds another magpie serving it. 3. The panel and the window load `index.html` from `Handler`. `boot.js` passes the saved language, theme, text size and desktop font choices before the first paint. 4. The page reads `GET /api/state` and writes through `POST` routes. Each write answers with the new state, built under `held`. 5. Closing the window hides it. On the Mac a full-screen window first leaves full screen (`closeStep`). The system's close on the panel (a title bar's X, Alt+F4, a window manager's close key) hides it as Escape does, so the tray icon opens it again; a panel lightweight mode let go, no longer `h.panel`, closes (`makePanel`, `TestPanelCloseHidesIt`). Lightweight mode lets go only of a window closed this way: one covered by another app's window, minimised, or open as magpie was hidden is still open (`windowUp`, not Wails' `IsVisible`, which is the occlusion state), and so is it for the Dock while the window is open and for a restart (#1381, `TestCoveredWindowStaysUp`). 6. The window opens as it was last left. Its settled size and whether it was maximised are kept in `settings.Window` and `settings.WindowMaximised` (`settle`; per machine, see `KeepOwn`). A maximised window keeps the size it restores to. `makeMain` opens it at that size (`openSize`). On its first show, `placeMain` maximises it again on the Mac and Windows (on Windows once the page has come). On Linux, `makeMain` makes it maximised with `StartState`. On Windows a size larger than the screen's work area is fitted and centred (`fitRoom`), and the larger size stays kept. The window's position is not kept. ## Constraints and failure behavior ### Routing purpose filter The request heading's purpose menu in [`routing.js`](../../internal/gui/assets/routing.js) uses `openProtoMenu`'s live checkbox selection: each tick immediately shows requests matching any selected purpose, with the menu kept open. An empty selection, All purposes or Clear filter restores every purpose without changing the day or request/session view. A live menu stays open when its redraw clamps the list's scroll; the reader scrolling outside still dismisses it. Choosing All purposes closes the menu and returns keyboard focus to the purpose button without scrolling; unticking the last checkbox keeps the menu open and focused on that checkbox. Counts, the current story and replay follow the same filtered list. Selected purposes remain available when a day has no matching requests. Opening a request from another page clears the filter only when that request is excluded. Usage's purpose picker remains a single choice sent to the ledger API. See `purpose-filter.test.cjs` and `routing-purpose-state.test.cjs`. ### Routing key and account folds Three or more API keys, or three or more accounts, of one provider standing next to each other for one model (same fixed effort, fallback and group-in-group heading) fold into one stage row, `li.rt-fold` in [`routing.js`](../../internal/gui/assets/routing.js) (`FOLD_AT`, `rebuild`). It shows the count and their state together: the one that is lit and what it is doing, how many are available and how many rest (`together`). Requests that land on a folded seat fly to the fold (`shownRow`). Clicking it (or Enter / Space) shows each in place, wired from the fold. Accounts and keys folds a provider's keys, and its accounts, into one `.rt-keys` row each with their summed tally. A group in the group's heading (`li.rt-sub`, `subNode`) folds the same way (`toggleSub`): folded, it says how many it routes to and what they do together, a request to any seat under it flies to the heading (`row.shut`), and its wire is the last one drawn (`wiresTo`). Keys start folded. Accounts and group-in-group headings start open when they hold fewer than `LONG` (6), folded otherwise (Aiirobyte on Discord: a group of many accounts took the page). What the reader opens is kept in localStorage `magpie.routingKeysOpen` and what they fold in `magpie.routingFolded` (`keepOpen`, `isOpen`), keyed `provider/model` for keys and `provider/model@` for accounts on the stage, `provider/*` and `provider/@` in the list, and `group/` for a heading. Display only: the gateway's order and choice of seat don't change. Two stay two rows. See `routing-keys-fold.test.cjs` and `routing-group-fold.test.cjs`. ### Desktop fonts Settings → General offers independent interface and code fonts, each with the installed family's actual styles. `GET /api/fonts` calls [`fonts.List`](../../internal/fonts/fonts.go): DirectWrite on Windows, CoreText on macOS and Fontconfig on Linux. Discovery is lazy, cached in the handler, and refreshed by the menu. It returns names and CSS traits, never font files or paths. A failed refresh answers 503 and preserves the previous collection; an empty successful collection is distinct from failure. `magpie web` and `nogui` do not expose this desktop catalogue. `settings.UIFont` and `settings.CodeFont` keep the family, style name, OpenType weight, CSS slope and width percentage. A missing choice keeps the platform default. A settings POST can restore that default with an explicit null; omitting the field preserves it, as does every browser-mode save. `KeepOwn` preserves this computer's choices during sync and backup restore. Invalid traits reject the save without replacing the previous settings. [`fonts.js`](../../internal/gui/assets/fonts.js) applies choices at boot and through `applyPrefs`. After a successful font change `host.fontsChanged` notifies both existing webviews through `receiveFonts`, without waiting for the other window to gain focus; a released lightweight webview uses boot settings when recreated. An in-flight local save ignores the notification and finishes its own queue. The selected family precedes the existing platform fallback stack. The interface and code traits apply independently; explicit component weights still mark emphasis. A choice takes precedence over Omarchy's font, while restoring the default follows the theme again. If discovery finds a saved face missing, the page retains its name, explains the fallback and uses the default until the face is available or the user chooses another. A discovery error never clears a saved choice. The browser's own typography is unchanged. The menus support search, keyboard selection and refresh. Styles are ordered by weight, then normal, italic and oblique; width and name break remaining ties. Family changes keep a matching style where possible, otherwise the nearest regular face; named variable instances are offered without arbitrary axis controls. ### Shared UI rules - Every user-visible string has zh, zh-TW, ja and de translations with the same placeholders. `gui-zh-tw.test.cjs`, `gui-ja.test.cjs` and `gui-de.test.cjs` fail on a missing one; `gui-zh-tw.test.cjs` also fails on a zh-TW string with a Simplified character left in it. - A click never moves the page. Code scrolls a view only with the reader's event in hand (`scrollOnPurpose`); `click-scroll.test.cjs` guards this. - There are no native `