App kit: Toggle, Slider, Panel, cmux.theme and cmux.toast for customize panes
Leo asked for a frame that makes a vibe-coded customization look like part of cmux:
"if you want to customize it, it should just be like ultra easy to just be able to vibe code something that's like not only there, but looks cohesive ... there are few f***ing ways to get this done than like to introduce some semblance of frame, right?"
"and also of extensibility of people wanting to customize it with creating custom things for it specific ui elements"
This adds that frame to the customize app (~/.config/cmux/customize/, local/customize) and every other app pane. The kit is a few new scene nodes drawn by the app's own controls and surfaces, tone names in place of colors, and a toast. There is also a copy-paste template that agents start from.
The kit
| API | What the app draws |
|---|---|
Toggle(value, { label?, disabled?, onChange? }) | The native switch in a settings row: the label leading, the switch trailing, inked in the scope's primary text color. |
Slider(value, { min?, max?, step?, disabled?, onChange? }) | The native slider, snapped to step (by default a hundredth of the range), in the same ink. |
Panel({ title?, style?, spacing? }, children) / Panel(children) | A group on the sidebar cards' glass surface (CardSurfaceView, which takes the theme tint), or on an opaque elevated card with style: "card". Padding and radius are Metrics.space3, and the title uses the card's eyebrow style. |
cmux.theme | Frozen tone names: accent, text.primary, text.secondary, text.tertiary, success, warning, danger, separator, hover, selected. They are what color props already accept, so a pane never writes hex, and its colors follow the theme live. |
cmux.toast({ title, message?, tone? }) / cmux.toast("text") | A notice in the app's existing toast presenter (CmuxToastCenter) in the main window. Each app gets one slot (app:<id>): a new toast replaces that app's last one. The detail shows the message and then the app's id, so an app's notice is never mistaken for one of cmux's own. |
Toggle and Slider are two-way bound like TextField:
- With
onChange, a change shows at once and sendschange {value}; the nextvaluethe app sends wins. - While a slider drag is in progress, the app's echoes of earlier values do not move the thumb.
- Without
onChange, the control only shows the app's value. - A
changepayload of the wrong type is dropped, never coerced.
Wired end to end:
- The JS view DSL, the runtime dispatch and the rebuilt dist.
ABI.md.gen-cmux-global.ts, which regeneratescmux-app.d.ts,ops.jsonandscopes.json. Nothing generated was hand-edited.- The Swift
AppSceneOpandAppSceneNodeView. - The daemon's provider families, plus the Mac's fallback for an older daemon: on its
bad-requestthe Mac registers again without the newest family it sent (toast, thenmemory), one at a time, so a daemon that knowsmemorybut nottoastkeepsmemory. - The iOS parity row. The phone draws no app scenes, so it reads "not from a phone".
Toast scope
toast.show has scope none, so every app holds it without a grant, including unverified local apps, which get only :read scopes at install. The reasoning:
- It is local and makes no network call.
- It is capped: a 200-character title and a 1000-character message.
- It is attributed to the app in the detail.
- It is limited to one slot per app, under the presenter's own limits.
The daemon still refuses it for a preview host and for an app that is stopping (a_toast_needs_no_grant_but_not_a_preview_or_a_stopping_app). The Swift permission policy reads none the way it reads storage:local, and a manifest cannot request none.
The unified notice system has not landed. Until it does, tone is validated (success, warning or danger; anything else is invalid_params) but not drawn, because the presenter has one look. When the notice system lands, tone maps onto it with no API change.
Docs
skills/cmux-customization/SKILL.mdgains "Matching the app".references/local-app-views.mdgains the same section: the kit table, the rules (no literal colors or sizes, two-way Toggle and Slider), and a focus timer template (a manifest andmain.js) built only from the kit, with no scopes.customize_example.rsvalidates the template's manifest and script as a package and checks that it uses only the kit.kit.test.tsmounts the documented script as written, taps Start, and checks the toast and the progress swap.
Changelog
- Added: App panes and the customize folder get Toggle, Slider and Panel nodes,
cmux.themetone names andcmux.toast, all drawn with cmux's own controls and surfaces so a vibe-coded pane matches the app, plus a copy-paste focus timer template in the customization skill.
Red / green
Each failing test was committed before its fix, and red and green come from the same command.
- JS (JavaScript):
cmux-ci test --timeout 1500 -- bash -c 'cd cmux-tui/crates/cmux-app-host && bun test js/test/kit.test.ts'- Red at 7e749cf8 (run 41822589907): 0 pass, 10 fail.
- Review-fix red at 51519cb5: 2 fail (the dropped-payload tests), 11 pass (run 41825611102).
- Green at 95bdcbdf (run 41826016417): 90 pass, 0 fail (with
bun js/build.ts --check,bun tools/gen-cmux-global.ts --checkandsync-app-runtime.sh --check, all up to date, plusruntime.test.tsandtools.test.ts).
- Swift:
cmux-ci run --class light --script scripts/ci/package-test-lane.sh --arg suite --arg Packages/macOS/CmuxNext --arg AppSceneKitTests,ToastAppOpsTests- Red at 4f43a932: the suites did not compile (
cannot find 'ToastAppOps' in scope, the missing node types). - At 637246bf: ToastAppOpsTests 4/4 passed; AppSceneKitTests 4/5. The NSSlider test was wrong: SwiftUI drives the NSSlider over a normalized range, so it now compares the thumb's fraction.
- Review-fix red at 51519cb5: 3 tests fail (the display-only Toggle flips;
snapreturns 0.30000000000000004 and 0.7000000000000001; and the NSSlider read above). - Green at aeffa447: AppSceneKitTests and ToastAppOpsTests, 10/10. On macOS 27 SwiftUI's Slider has no NSSlider, so that test reads one only where it exists (the snapping is tested on its own).
- Provider fallback,
--arg AppsProviderChannelNewerFamilyTests: red at cddf438d:aDaemonWithMemoryButNoToastKeepsMemoryfailed, the retry sent["coderouter"]and lostmemory. Green at 8b78c7e3, with AppSceneKitTests and ToastAppOpsTests in the same run: 3/3, 6/6 and 4/4 pass.
- Red at 4f43a932: the suites did not compile (
- Rust:
./scripts/verify-cmux-tui-hosted.sh --filter ā¦(hosted Linux and macOS).- Red at 51519cb5 (run 41830317591,
--filter toast):a_toast_routes_to_the_provider_for_an_app_with_no_scopesFAILED.register_providerrefusedtoastas an unknown family, which would have refused the Mac's whole provider registration. - Green at 95bdcbdf (run 41828170954): passes on hosted Linux and macOS.
- The template test (
customize_example.rs) at 6cb4e64b (run 41830141618): passes. lint (linux)is red on base (a clippyneedless_borrowatcrates/cmux-tui/src/cloud_environment.rs:22), not from this PR.
- Red at 51519cb5 (run 41830317591,
Observed / Not run
Observed: the runs above on the fleet and on hosted runners, and the generated-file checks.
Not run:
-
I did not draw the toast's tone; the presenter has no tones yet (see above).
-
No live
isDark:cmux.themeholds names, and the host resolves them live, so a pane needs no light/dark branch. -
I did not test the slider drag-echo handling (it ignores app values until the drag ends): it needs a real pointer drag. Only the snapping and the initial value are unit-tested.
-
I did not verify in a running app that the Mac provider registers with the
toastfamily against a live daemon. The daemon-side routing test covers registration and routing, and the Mac sendstoastthrough the existing family registration. -
A slider can briefly snap back at the end of a drag before the app's echo of the final value arrives; a follow-up.
-
No test checks that a Slider without
onChangeis disabled (the Toggle one is tested).
Perf
There is no animation. The Toggle and Slider are native controls. A slider drag sends at most one change per distinct snapped value (at most about 100 by default, fewer with step).
Capture
No capture yet: no scene: or tour: state loads a customize-folder app. The capture host starts the app with its own HOME, and no existing scene puts an app into that HOME's ~/.config/cmux/customize or waits for the daemon to install and mount it. A scene that seeds the template and waits for the section's mount to be ready is a change of its own, and I have left it out of this PR. Until then, AppSceneKitTests hosts the nodes in a themed window and checks that the real AppKit controls and surfaces are drawn: an NSSwitch that sends its new value, an NSSlider at its value, and a CardSurfaceView with the theme tint and Metrics.space3 radius.
š¤ Generated with Claude Code
Agent session: agent session