AeroSpace
A bar with live AeroSpace workspaces. aerospace.ts builds them from halo trigger events and aerospace queries; copy it into your config. The main.tsx header has the aerospace.toml lines it needs.

Window-manager support is something you build, not a halo module: a trigger from the WM's hooks plus a hydrate
query. The focused workspace:
const focused = externalSignal("aerospace.workspace", {
hydrate: "aerospace list-workspaces --focused",
parse: (payload) => payload.focused ?? payload.stdout?.trim(),
});
<Text>{() => `WS ${focused()}`}</Text>;
Add to aerospace.toml:
exec-on-workspace-change = ['/bin/bash', '-c',
'halo trigger aerospace.workspace focused=$AEROSPACE_FOCUSED_WORKSPACE prev=$AEROSPACE_PREV_WORKSPACE']
on-focus-changed = ['exec-and-forget halo trigger aerospace.focus']
For every workspace per display, with focus and emptiness, copy aerospace.ts (below) into your config. It maps
AeroSpace monitors to halo displays through ScreenInfo.index. Use halo's absolute path in aerospace.toml if
AeroSpace's PATH doesn't include it.
Run it
swift run halo start --config examples/aerospace # from a clone
halo start --config "$(brew --prefix)/share/halo/examples/aerospace" # Homebrew
Source
main.tsx
// A bar showing AeroSpace workspaces per display. Add to your aerospace.toml (use halo's absolute path if
// AeroSpace's PATH doesn't include it):
//
// exec-on-workspace-change = ['/bin/bash', '-c',
// 'halo trigger aerospace.workspace focused=$AEROSPACE_FOCUSED_WORKSPACE prev=$AEROSPACE_PREV_WORKSPACE']
// on-focus-changed = ['exec-and-forget halo trigger aerospace.focus'] # optional: keeps "empty" state fresh
import { Box, PerScreen, Surface, Text, type ScreenInfo } from "halo";
import { aerospace, type Workspace } from "@/aerospace/aerospace";
import { clock } from "halo/services";
const surface = "#1e1e2ee0";
const foreground = "#cdd6f4";
const muted = "#6c7086";
const accent = "#89b4fa";
const spaces = aerospace();
const time = clock.format("EEE d MMM HH:mm");
function Pill(props: { workspace: Workspace }) {
const { focused, empty, name } = props.workspace;
return (
<Box
style={{ padding: [2, 8], radius: 5, background: focused ? accent : "transparent" }}
>
<Text
style={{
fontSize: 12,
fontWeight: "semibold",
color: focused ? "#1e1e2e" : empty ? muted : foreground,
}}
>
{name}
</Text>
</Box>
);
}
function Bar(props: { screen: ScreenInfo }) {
const workspaces = spaces.on(props.screen);
return (
<Surface
screen={props.screen}
anchor={["top", "left", "right"]}
margins={[4, 8, 0, 8]}
size={{ height: 32 }}
layer="belowMenu"
avoid={["menuBar", "notch"]}
>
<Box
style={{
flexDirection: "row",
justify: "space-between",
align: "center",
padding: [0, 8],
background: surface,
radius: 8,
}}
>
<Box style={{ flexDirection: "row", align: "center", gap: 4 }}>
{() => workspaces().map((workspace) => <Pill workspace={workspace} />)}
</Box>
<Text style={{ fontSize: 13, fontWeight: "medium", color: foreground }}>
{time}
</Text>
</Box>
</Surface>
);
}
export default () => <PerScreen>{(screen) => <Bar screen={screen} />}</PerScreen>;
aerospace.ts
// AeroSpace workspaces as signals, built only on halo's public API: `halo trigger` events from aerospace.toml
// wake it, `aerospace list-workspaces` supplies the state. Copy it into your config and adapt it; the same
// shape works for yabai or any WM with a CLI and change hooks.
import { screens, sh, type ScreenInfo } from "halo";
import { computed, effect, externalSignal, signal, type Accessor } from "halo/signals";
/** An AeroSpace workspace as `aerospace()` reports it. */
export interface Workspace {
name: string;
/** The halo display (`ScreenInfo.id`) showing this workspace, matched exactly through the macOS display index. */
screen: number | undefined;
focused: boolean;
empty: boolean;
}
const separator = "--halo--";
const query =
`aerospace list-workspaces --monitor all --format '%{workspace}|%{monitor-appkit-nsscreen-screens-id}'` +
` && echo ${separator} && aerospace list-workspaces --focused` +
` && echo ${separator} && aerospace list-workspaces --monitor all --empty no`;
/** Live AeroSpace state, from `aerospace()`. */
export interface AeroSpace {
/** Every workspace with its display, focus and emptiness. */
workspaces: Accessor<Workspace[]>;
/** The focused workspace's name. */
focused: Accessor<string | undefined>;
/** Workspaces on one display, in AeroSpace's order. */
on(screen: ScreenInfo): Accessor<Workspace[]>;
}
/**
* AeroSpace workspaces, refreshed on every `aerospace.workspace` / `aerospace.focus` trigger.
* Call once per config, at module level or inside a component.
*/
export function aerospace(options: { command?: string } = {}): AeroSpace {
const event = externalSignal("aerospace.workspace", {
parse: (payload) => ({ ...payload }),
});
const focusEvent = externalSignal("aerospace.focus", {
parse: (payload) => ({ ...payload }),
});
const raw = signal<string | undefined>(undefined);
const command = options.command ?? query;
let generation = 0;
effect(() => {
event();
focusEvent();
const current = ++generation;
sh(command).then((result) => {
if (current !== generation) return;
if (result.code !== 0)
return console.warn(
`aerospace: query failed (${result.code}): ${result.stderr.trim()}`,
);
raw.set(result.stdout);
});
});
const warned = new Set<string>();
const workspaces = computed<Workspace[]>(() => {
const output = raw();
if (output === undefined) return [];
const [all = "", focusedOutput = "", nonEmpty = ""] = output.split(separator);
const focused = event()?.focused ?? focusedOutput.trim();
const occupied = new Set(
nonEmpty
.split("\n")
.map((line) => line.trim())
.filter(Boolean),
);
const byIndex = new Map<number, ScreenInfo>(
screens().map((screen) => [screen.index, screen]),
);
return all
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [name = "", index = ""] = line.split("|");
const screen = byIndex.get(Number(index));
if (!screen && !warned.has(index)) {
warned.add(index);
console.warn(
`aerospace: no display at macOS index ${index} for workspace ${name}; it won't be shown`,
);
}
return {
name,
screen: screen?.id,
focused: name === focused,
empty: !occupied.has(name),
};
});
});
return {
workspaces,
focused: computed(() => workspaces().find((workspace) => workspace.focused)?.name),
on(screen: ScreenInfo): Accessor<Workspace[]> {
return computed(
() => workspaces().filter((workspace) => workspace.screen === screen.id),
{
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
},
);
},
};
}