halo DocsGitHub

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.

AeroSpace

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),
        },
      );
    },
  };
}