The Context tab now replays the period's calls of each agent against the
settings that decide what they cost, and shows, per agent and per model,
the value that would have used the fewest tokens for how this user works:
Claude Code's auto-compact window per model (modelSettings.<model>.
autoCompactWindow), its main and subagent prompt cache lifetimes
(promptCacheTtl, subagentPromptCacheTtl: 5m or 1h), and Codex's
model_auto_compact_token_limit. Each row says where the value comes from
now (the agent's default, the user's own setting, one magpie set, or a
variable that comes first and locks it), why (how fast conversations grow,
what a compaction leaves, how long the user pauses), the cost curve, and
the saving. Nothing is written until "Use …" is clicked; what was there is
stashed and Undo puts it back, unless the user changed it since. A window
the user set past the model's own is shown capped, as Claude Code caps it
(min(setting, model window)), so no saving is claimed that wouldn't happen.
Advice under a 3% saving is "already the leanest".
On the owner's 7 days (88K Claude Code calls, 956 Codex): opus keeps 200K
(their 500K is capped to it), fable 5.1 200K → 160K saves 5% (2.56M
tokens), both cache lifetimes and Codex's 272K are already the leanest.
Run: go vet ./... and go test ./... pass (internal/tune, internal/agent
TestTune*: user-set values kept, env locks, undo after a user edit, TOML
key order). context-tune.test.cjs passes in Chromium and WebKit, en/zh/
zh-TW/ja/de, at 360 and 1100px (22); context-window, context-scroll-end
pass. Full GUI suite: library-servers-bulk and market-have failed once
under load and pass alone; market-have fails on origin/main 494dedcf too
(1 of 8 runs). Claude Code 2.1.296 in a sandbox config dir shows
"Auto-compact window for Fable 5.1: 160k tokens (from settings)" for the
written modelSettings key. The real-data advice was read from the owner's
own ~/.claude and ~/.codex, nothing written.
15 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 Providers, Gateway, Routing, Usage, Sessions, 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; its "Tuned for you" cards are fed by /api/tune in tune.go, see agent wiring). 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, 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
magpiewith no arguments,magpie appormagpie traycallsrunGUI, which callsgui.Run. Anoguibuild runs the TUI instead. A second launch hands over to the instance already running.startBackendbrings up the gateway, or finds another magpie serving it.- The panel and the window load
index.htmlfromHandler.boot.jspasses the saved language, theme, text size and desktop font choices before the first paint. - The page reads
GET /api/stateand writes throughPOSTroutes. Each write answers with the new state, built underheld. - 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 longerh.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). - The window opens as it was last left. Its settled size and whether it was maximised are kept in
settings.Windowandsettings.WindowMaximised(settle; per machine, seeKeepOwn). A maximised window keeps the size it restores to.makeMainopens it at that size (openSize). On its first show,placeMainmaximises it again on the Mac and Windows (on Windows once the page has come). On Linux,makeMainmakes it maximised withStartState. 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 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 (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/<id> 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: 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.cjsandgui-de.test.cjsfail on a missing one;gui-zh-tw.test.cjsalso 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.cjsguards 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 throughPOST /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). SeeTestUsageArrangePanelHiddenandpanel-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, soownFrametells KWin the panel draws its own on each realize (#1283). GTK 4 builds ask for none themselves. - On GTK 3 builds, the app gives GDK a font DPI before the first webview when it has none (
fontDPI,fontdpi_gtk3.h). GDK's unset value is -1, and WebKitGTK 2.54's GTK 3 settings reader multiplies it by 1024 unchecked, so every page lays out at a negative width with a 9000000px font (#1371). An unset DPI takesgtk-xft-dpi, or 96, times a validGDK_DPI_SCALE; a positive one is left alone. Wails initialises GTK (its GtkApplication) only insideRunand makes each window from a default-idle-priority callback on the main loop, sofontDPI, called just beforeRun, queues the check as aG_PRIORITY_HIGHidle: it runs on the main thread after GTK's own start, before the first window.TestFontDPIUnsetopens a real WebKitGTK page throughmagpie_font_dpiand needs a display (Xvfb). - Tests never touch a live agent config. The package's
TestMainruns undertestenv's home of its own. A macOS test that shows windows runs them in a process of its own throughrunAppKit(appkit_darwin_test.go). AppKit and WebKit take their home from the account, not from HOME, sorunAppKitgives them a temporary one withCFFIXED_USER_HOME; appearance, contrast and languages still come from the account. The test fails when that process leaves a folder in the real~/Library/WebKitor~/Library/Caches. Playwright tests serveassets/with isolated/apifixtures. POST /api/settings/codex-auto-reviewaccepts an empty value (Codex's own choice) or an exact model/group ID inprovider.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. SeeHandlerinapi.goandTestCodexAutoReviewSettingincodex_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.