Files
magpie/docs/subsystems/gui-shell.md
T
yetone 8db24fd62f gui: Routing folds three or more of a provider's keys for one model into one row that shows the count and their state, and opens in place to each key (#1319, tkhs101)
53 agnes-3.0-flash keys were 53 near-identical stage rows and 53 rows
under Accounts and keys. Now three or more keys of one provider standing
together for one model (same fixed effort, fallback and group-in-group
heading) fold into one stage row: "Agnes 53 keys agnes-3.0-flash", then
the lit key and what it does, "51 available · 2 resting". A request on a
folded key flies to the fold; a key failing and the next answering show
there (its 429 tag too). A click, Enter or Space opens it in place: each
key keeps its own row, state and wire, drawn from the fold. Accounts and
keys folds the provider's keys into one row with their summed tally and
models, and opens the same way. Open folds are kept per reader in
localStorage magpie.routingKeysOpen. Two keys stay two rows; one key is
as before. Display only: the gateway's order and choice don't change.
New strings in zh, zh-TW, ja and de. docs/subsystems/gui-shell.md says how.

routing-account-order's relay has three keys, so it now opens their fold
to read their order.

Run: routing-keys-fold.test.cjs (32: folded, open/persist/Enter, live
updates folded and open, Accounts and keys, 440px; Chromium and WebKit;
en, zh, ja, de) — fails on main (no fold). All routing-*.test.cjs,
panel-routing, click-scroll, fold-show-grip, gui-ja, gui-de, gui-zh-tw,
i18n-keys: 409 pass. Every other test that opens Routing (29 files, 152
tests) passes but purpose-filter's WebKit "returning focus does not scroll
the page", which fails the same alone on origin/main (off by 1px).
go vet -tags nogui ./internal/gui (darwin, GOOS=linux, GOOS=windows).
2026-10-08 22:20:22 +08:00

13 KiB

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, mainwindow.go, 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
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
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 only Providers, Gateway, Routing, Usage, Plugins and Settings on a machine that is only a gateway. web.go, 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). 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. Scrolling is done only by scrollOnPurpose(e) with the reader's event. assets/app.js, 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

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).
  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 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 folds

Three or more API keys 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 (FOLD_AT, rebuild). It shows the key count and the keys' state together: the key that is lit and what it is doing, how many are available and how many rest. Requests that land on a folded key fly to the fold (shownRow). Clicking it (or Enter / Space) shows each key in place, wired from the fold. Accounts and keys folds the same provider's keys into one .rt-keys row with their summed tally. Which folds are open is kept per reader in localStorage magpie.routingKeysOpen (provider/model on the stage, provider/* in the list). Display only: the gateway's order and choice of key don't change. Two keys stay two rows. See routing-keys-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: 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 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 <select> elements and no colored left-border stripes. State is shown with a dot or a swatch.
  • The Usage page's cards and the tray panel's Allowances tab share one order, settings.UsageOrder (byUsageOrder). The panel's Arrange (panelArrange) moves rows in it and hides subscriptions from that tab alone (settings.PanelUsageHidden); both save through POST /api/usage/arrange, which changes only the field it is sent, and the Settings save keeps both. Hiding is display only: a menu bar cell's card shows though hidden (panelPeek). See TestUsageArrangePanelHidden and panel-arrange.test.cjs.
  • The page must work in Chromium (Windows' WebView2) and WebKit (macOS, and WebKitGTK on Linux). GUI tests run in both engines.
  • On Linux the panel is frameless and the main window has a hidden title bar (plainTitlebar). GTK 3 asks KDE's Wayland session (KWin's decoration protocol) for a frame drawn by KWin on any window without client-side decorations, an undecorated one too, so ownFrame tells KWin the panel draws its own on each realize (#1283). GTK 4 builds ask for none themselves.
  • Tests never touch a live agent config. The package's TestMain runs under testenv's home of its own. A macOS test that shows windows runs them in a process of its own through runAppKit (appkit_darwin_test.go). AppKit and WebKit take their home from the account, not from HOME, so runAppKit gives them a temporary one with CFFIXED_USER_HOME; appearance, contrast and languages still come from the account. The test fails when that process leaves a folder in the real ~/Library/WebKit or ~/Library/Caches. Playwright tests serve assets/ with isolated /api fixtures.
  • POST /api/settings/codex-auto-review accepts an empty value (Codex's own choice) or an exact model/group ID in provider.Served, including unlisted providers. A known provider with an unknown or empty model name is rejected without changing the saved reviewer or catalog tag. Generic gateway request resolution remains permissive; it is not the validator for this setting. See Handler in api.go and TestCodexAutoReviewSetting in codex_auto_review_test.go.

openModal makes the background inert when an editor opens fresh, leaving confirmations and menus interactive through redraws. closeModal releases that background as soon as the close animation starts, so clicks reach the page while the editor fades away. Opening an editor during that animation protects the background again. ux-safety.test.cjs checks these transitions. Routing's New group buttons share newGroup, which asks before replacing a dirty group draft; Cancel retains the draft and Discard opens the new editor. newGroupWith applies the same guard when creating a group from a model and waits for show to accept navigation before creating its draft.

Verification

go test -tags nogui ./internal/gui
go test -v ./internal/fonts         # native discovery on each desktop OS
make test-ui                      # every internal/gui/tests/*.test.cjs, Chromium and WebKit
BROWSER=webkit node --test internal/gui/tests/click-scroll.test.cjs

make test-ui needs Node.js and Playwright (playwright install chromium webkit). Set NODE_PATH when Playwright is installed outside the repo. The Test workflow in CI does not run this suite, so a GUI change must run it locally and report the result. See tests/README.md.