← Cycles
Qnative (provisional) · c1

Spike

activekicked off 2026-11-09; built and measured by 2026-11-10 — answers `design:c131` Phase 0; Outcome written, awaiting the team's review before closecreated 2026-11-09· last activity 2026-11-10· mode: archaeology (the patterns are qdash's bible and qtray's loop; port them before inventing) and evolution (replicate the one-machine slice first, optimize later).

c131 Phase 0: a hello app on GPUI Kit 0.9.1 that answers the platform questions before anything is promised. One window with a sidebar and a data table bound to a JSON inventory; a tray icon beside GPUI's event loop; the `DataNozzle` with its three transports (`synthetic`, `local`, `plane`) behind one process-wide switch; the footprint measured with strobe; Windows and Linux builds attempted in CI; the findings written back to design's primer.

Why

Design's c130 primer reads about GPUI Kit; nobody has published a tray icon running inside a GPUI app, the footprint is a quote rather than a number, and the kit's font and menu support is documented only for the kit's own gallery. c131's build plan promises a one-binary app with the tray absorbed (D7), Inter bundled (D14), three data sources from the first commit (D3, D5) and a measured budget (R7). Each of those is a promise the plan should not make before a hello app has been run. This cycle runs it.

Objective

Done means: cargo run -p app -- --data synthetic opens a window with a sidebar and a table of harnesses, MCP servers and skills; the tray menu opens the window (or the fallback is recorded); --data plane --plane-url http://127.0.0.1:<port> shows the same data through a local fixture server; the footprint numbers (RSS idle, CPU idle, startup, binary size; debug and release) are in README.md with the date and the strobe version; a GitHub Actions workflow attempts Windows and Linux builds; design's research/platform.md carries a dated "From the spike" section; nothing user-facing carries the provisional name.

Scope

  • Here: Cargo.toml (the workspace), crates/app (window, sidebar, table, font, tray), crates/nozzle (the DataNozzle trait, Synthetic, Local, Plane, the switch), crates/types (one type per served shape, mirrored from qdash's ui/app/types/api/*), crates/fixtures (the bundled JSON world), crates/fixture-plane (the local server), .github/workflows/, README.md (Footprint), this record.
  • Design: ../design/cycles/c130.native-app/research/platform.md, appended only (the primer session owns the file); nothing staged or committed there from here.
  • Out: pages beyond the inventory table, the messages crate, stories, packaging, signing, the real qdash socket client (the Local transport is a stub that compiles without qcontrol).

Key Changes

  • kick-off (2026-11-09): cycle created; workspace manifests for app, nozzle, types, fixtures, fixture-plane; GPUI Kit 0.9.1 docs (llms-full.txt, 100 831 lines, and the /skills page, which lists no skills for 0.9.1) and strobe's README and skill fetched before any Rust. Found on reading: the kit's Theme::update plus cx.text_system().add_fonts is the bundled-font path; the Sidebar and DataTable components exist in the styled layer; NativeMenu is AppKit on macOS and a drawn fallback on Linux; the kit documents no tray.
  • step 1, the hello app (2026-11-09): cargo run -p app opens one window (1120×721, centred) with the kit's TitleBar, a Sidebar (Harnesses · MCP servers · Skills) and a DataTable per listing, bound to a 12-row world bundled in fixtures; the footer names the source. types mirrors qdash's Harness, McpServer and Skill from ui/app/types/api/*, field for field. Inter 4.1 (OFL, crates/app/assets/fonts/) is registered with cx.text_system().add_fonts before the window; all_font_names() lists "Inter Variable" (the name inside the file, not the file name) and Theme::update takes it: bundled fonts are two calls, the kit's docs are right. Found: gpui-pre 0.3.12 uses std::hint::cold_path, stable since Rust 1.95, so the kit's stated floor (1.92) is wrong and Homebrew's Rust 1.94.1 could not build it; upgraded to 1.99.1. A full debug build is about three minutes for 424 crates on an M2 Max. API surprises against the docs: Sidebar::new takes an ElementId; TableDelegate::render_empty takes &mut self; copy, dump and the default accessibility label read cell_text, not what render_td draws; gpui_kit::open_window returns (AnyWindowHandle, Entity<V>). Verification: Screen Recording is not granted to the session that ran this, so the window was proven with a CoreGraphics window list (one layer-0 window, 1120×721) and a QPOINT_SPIKE_DUMP=1 dump of the three tables, not a screenshot.
  • step 2, the tray (2026-11-09): tray-icon 0.26.1 (the crate qtray uses, two minors on) is created inside Application::run's callback, where AppKit's loop is already running, as the crate requires. Menu clicks travel AppKit → MenuEvent::set_event_handler → an unbounded futures channel → a GPUI foreground task that calls back with &mut App; the AppKit side never touches GPUI. The two loops coexist on macOS: with the window open, CoreGraphics lists two windows for the process, the main one (layer 0, 1120×721) and the status item (layer 25, 38×24 at the top right). QuitMode::Explicit keeps the process alive when the last window closes; the app then steps to ActivationPolicy::Accessory; the tray's Open path reopens the window, switches back to Regular and activates; cx.quit() ends the process and the status item goes with it. gpui-pre 0.3.12 has every lifecycle call platform.md listed as unverified: QuitMode, set_activation_policy, on_reopen, activate, hide, set_menus, set_dock_menu, on_open_urls, set_app_identity. Verified with env hooks (QPOINT_SPIKE_CLOSE_AFTER, _REOPEN_AFTER, _QUIT_AFTER) and the window list at 1.5 s, 5 s, 9 s and 13 s; the one hop not exercised without a pointer is the AppKit click itself (clicking "Open Qpoint" prints tray: Open requested from the menu). D7's fallback (qtray stays and opens the app) is not needed on macOS. The icon is the brand symbol, black, 36 px, drawn as a template so macOS tints it. Surprises: png 0.18's output_buffer_size returns Option; with_icon_as_template is deprecated for with_icon_templated; Application::on_reopen borrows the builder rather than chaining. Linux is wired with the ksni backend (no GTK loop) and Windows with the default; CI is the first place either builds.
  • step 3, the nozzle (2026-11-09): nozzle carries the DataNozzle trait (one method per served listing, inventory() assembles them), the Switch (--data synthetic|local|plane, QPOINT_DATA; --plane-url, QPOINT_PLANE_URL; QPOINT_PLANE_TOKEN; arguments win over the environment; the default follows the build, D3) and three transports: Synthetic over the bundled world, Local over qdash's loopback API (it reads the URL from <data dir>/dash/qdash.sock.lock, the way qtray does; no qcontrol crate linked), Plane over a base URL with Authorization: Bearer. Local and Plane share one ureq client and the route table in types::routes. fixture-plane is a tiny_http server over the same world at the same routes with an optional credential, so the third source is real before the control plane exists (D5). The app reads the switch once in main, keeps the nozzle in a GPUI global, loads on the background executor and hands the shell the inventory or the reason there is none; the footer names what describe() says. Exercised: synthetic and plane show the same 12 rows (data: plane (http://127.0.0.1:54979)); the right token passes and the wrong one is drawn as "failed: … answered 401"; local without qcontrol says "qcontrol's dashboard is not running on this machine" (there is no qcontrol data dir on this Mac); --data plane without a URL and --data real exit 2 with one sentence each; QPOINT_DATA=synthetic alone works. Nothing per page knows the source (R2); seven unit tests cover the switch and the honest states.
  • step 4, the footprint (2026-11-09 and 2026-11-10): strobe 0.26.0 installed with npm (no bun on the Mac; npx strobe up placed the binary at ~/.local/bin/strobe, the engine, a LaunchAgent daemon on 127.0.0.1:47575 that also serves MCP with a token in ~/.local/share/strobe/mcp-token, and the skill under ~/.config/opencode/skills/, since it looked for opencode and not Claude Code; registering the MCP in Claude Code from a session is refused by auto mode, so Mark adds it). Measured the final app, debug and release, windowed and tray-only, 60 s each with strobe attached by PID after a 5 s settle; footprint(1) and ps as the cross-check; startup from the app's own clock. The numbers are in README.md under Footprint: windowed idle 52–80 MB private (RSS 66–68 MB; the spread is GPU-backed surface memory that follows the display), tray-only 16 MB in both builds, idle CPU at or under 0.005% of one core with 0.3–1.2 wakes/s, first render at 264 ms (release) and 270 ms (debug), binaries 17.9 MB (release, stripped, thin LTO, 1.8 MB of Inter) and 122 MB (debug). What strobe refused and why: a launch-controlled record exe: of a windowed app that is not frontmost (needs an idle minute and the Accessibility permission), frames (Screen Recording), any bare executable whose ~/Library/Preferences/<name>.plist exists (AppKit wrote app.plist on the first run; measured copies under distinct names). The first attempt on 11-09 fell into the Mac's clamshell sleep after the two release recordings and was re-run awake on 11-10; only awake, unlocked readings are reported. Uncommitted until here: the QPOINT_SPIKE_NO_WINDOW hook that starts in the tray only, used for the tray-only rows.
  • step 5, CI for three OSes (2026-11-10): .github/workflows/build.yml builds the workspace in debug and release on ubuntu-24.04, windows-latest and macos-latest with dtolnay/rust-toolchain@stable (Rust 1.95 or later is the real floor), the Ubuntu packages from the kit's Installation page plus libdbus-1-dev for the ksni tray backend, cargo test --workspace --exclude app for the headless crates, and the release binary's size. The repository has no remote, so the workflow has not run; the file records the expectations: macOS builds (it is where the spike ran); Linux should compile (a build needs no display; a run needs Vulkan 1.3 and a session); Windows is the least certain, since the kit's Windows backend and tray-icon's Win32 message loop have not been exercised together anywhere public. Results go here when a remote exists.

Outcome

2026-11-10. Five commits, one per step (a87da99, 292f593, 6c65973, fd2166a, 2f42064); the findings appended to design's research/platform.md under "From the spike"; the numbers in README.md under Footprint.

Every platform question the spike was asked has an answer, and none blocks c131's plan.

  • The tray moves into the app (D7, path B). tray-icon on GPUI's own AppKit loop, created in Application::run's callback, with clicks bridged by a channel; the status item and the window live in one process, the window closes into tray-only mode (QuitMode::Explicit, ActivationPolicy::Accessory) and comes back on Open. The fallback (qtray stays) is not needed on macOS. Every lifecycle call the primer listed as unverified exists in gpui-pre 0.3.12. Windows and Linux are wired per target, unbuilt until CI runs.
  • The kit covers the shell. TitleBar, Sidebar, DataTable, Empty and StatusBar carried the Inventory page without a drawn component (R8); bundled Inter is two calls and the font's internal name (D14); NativeMenu, set_menus, set_dock_menu, system notifications and on_open_urls are present but unexercised; the kit has no tray and no preferences story for an unbundled binary.
  • Three sources from the first commit (D3, D5). One switch, one DataNozzle, synthetic and plane showing the same rows, local honest about an absent qcontrol, a wrong credential drawn as a state (R1, R2, R5).
  • The budget is a number (R7). Idle: 52–80 MB private windowed (the spread follows the display's GPU surfaces; RSS 66–68 MB), 16 MB tray-only, at or under 0.005% of a core and about one wake a second; first render 264 ms; 17.9 MB release binary. Strobe attached by PID is the reproducible recipe on this Mac; its launch control needs Accessibility and frames need Screen Recording.
  • What it cost. Rust 1.95 or later (the kit says 1.92); a three-minute clean debug build; a day of API drift against the docs, listed in platform.md.

Carries forward to Phase 1: the stories crate as the proof the spike could not take (no screenshot without Screen Recording; the window was proven by CoreGraphics and a table dump); types generated from qdash's handlers and compared against ui/app/types/api; repositories between pages and the nozzle; the Meridian world replacing the hand-written one; per-display footprint rows; a bundled .app to test notifications and qpoint://; running the workflow once a remote exists. For Mark: click "Open Qpoint" in the tray once (the terminal prints tray: Open requested from the menu); register strobe's MCP (claude mcp add --transport http --scope local strobe http://127.0.0.1:47575/mcp --header "Authorization: Bearer $(cat ~/.local/share/strobe/mcp-token)"); grant Screen Recording and Accessibility to Claude.app if sessions should screenshot and let strobe drive windows.

Qpoint Brand Style Guide