Notch

Notch

Overview

HudNotch puts a small surface at the top of the screen. On a display with a camera housing, it grows out of the housing as two wings with concave shoulders. On other displays, it draws a black island in the same place. Tools post activities to it over a Unix socket. An activity can ask a question, and the answer goes back to the tool that asked.

The notch never activates its app and never takes key on its own. It opens when something needs attention and folds back to a pill. The person can hover over it, or click it, to reopen it. A panel only becomes key when the person clicks into its reply field.

Three products:

ProductWhat it holds
HudsonNotchCoreActivity model, wire format, stage reducer, socket server and client, configuration and metrics. Foundation only, no UI.
HudsonNotchHudNotchController, HudNotchSurface, shapes, HudNotchTuner. Depends on HudsonUI and HudsonShell.
hudson-notchCommand-line client.

HudsonNotchDemo is a menu bar host for trying it out.

Activities

An activity is one piece of work from one sender, keyed by id. Posting the same id again updates it in place.

FieldMeaning
idStable key. Reuse it to update.
sourceShown as the eyebrow (for example Xcode).
title, detailThe card's text.
statenotice, working, waiting, done or failed.
toneinfo, success, warning or error. Defaults from the state.
progress0 to 1. Draws a bar on the card and a ring on the pill.
choicesButtons: {id, title, role}, where the role is primary, normal or cancel.
replyPromptAdds a text field with this placeholder.
link{title, url}, opened with the default handler.
ttlSeconds the notch stays open. The default is 6, or 12 for an activity that asks.

When an activity has choices or a reply prompt, it defaults to waiting.

Attention rules, in HudNotchStage:

  • A new activity, a state change, a new question or a new title opens the notch.
  • Updates that only change progress or detail don't open it, so a working job can stream progress quietly.
  • The collapsed pill shows the oldest waiting activity first, then the newest working one.
  • Once the stage is over capacity (8 by default), settled activities are evicted. Ongoing ones never are.

Wire format

The wire is JSON lines on ~/Library/Application Support/Hudson/notch.sock. The socket's directory is created with mode 0700 and the socket with 0600.

{"op":"post","id":"job-42","source":"Claude Code","title":"Keep the old link?","choices":[{"id":"keep","title":"Keep","role":"primary"},{"id":"later","title":"Later","role":"cancel"}]}
{"op":"dismiss","id":"job-42"}
{"op":"pulse"}
{"op":"subscribe"}

The notch writes back:

{"op":"reply","id":"job-42","choice":"keep","at":"2026-09-24T03:52:00Z"}
{"op":"reply","id":"job-42","text":"the old link is in the changelog","at":"…"}
{"op":"dismissed","id":"job-42"}

A response goes to two places: the connection that posted the waiting activity, if it's still open, and every connection that sent subscribe. A sender that writes one line and closes still works. A line without op is read as a post. The older event shape (body, level, action, agent.name) maps onto the matching activity fields.

Pressing a cancel choice or the close button sends dismissed. Any other choice, or submitting the reply field, sends reply. The card then shows the answer and waits for the sender's next post.

Command line

hudson-notch post --id build --source Xcode --title "Building fab" --state working --progress 0.4
hudson-notch post --id build --source Xcode --title "Building fab" --state done

hudson-notch ask --title "Keep the old link?" \
  --choice keep:Keep:primary --choice redirect:Redirect --choice later:Later:cancel \
  --reply-prompt "Or say why" --timeout 120
# {"at":"…","choice":"keep","id":"…","op":"reply"}

hudson-notch listen      # every reply and dismissal, as JSON lines
hudson-notch dismiss build
hudson-notch pulse

ask exits 0 on a reply, 2 on a dismissal and 3 on a timeout. Every command takes --socket PATH.

From Swift:

let client = HudNotchClient()
try client.send(.post(HudNotchActivity(id: "build", source: "Xcode", title: "Building", state: .working, progress: 0.4)))

let answer = try await client.ask(
    HudNotchActivity(title: "Keep the old link?", choices: [
        HudNotchChoice(id: "keep", title: "Keep", role: .primary),
        HudNotchChoice(id: "later", title: "Later", role: .cancel),
    ]),
    timeout: 120
)

Hosting it

let notch = HudNotchController(
    persistenceKey: "MyApp.notch",
    copy: .init(name: "MyApp", idleTitle: "Nothing running", idleDetail: "Updates appear here")
)
notch.start()
try notch.serve(socketURL: myAppSupport.appendingPathComponent("notch.sock"))
notch.onResponse = { response in /* in-process listeners */ }

// In-process, without the socket:
notch.post(HudNotchActivity(title: "Saved", state: .done))

Give each host its own socket URL so two notch apps never share one. If a socket is already live, serve throws alreadyRunning. A stale socket file is replaced.

Answers get ⌘1…⌘9 in order and a cancel choice gets Escape. They work once the person has clicked into the notch, since it never takes key on its own.

Theme

HudNotchTheme dresses the notch in the host's brand. Pass it as theme: to HudNotchController. The default, .hudson, uses HudsonUI's palette and type.

FieldWhat it sets
bodyThe body's color. Keep it near black so it still reads as part of the housing.
ink, muted, dimText colors.
accentThe idle dot and anything not tied to an activity's tone.
tonesOverrides for tone colors. Tones without one use Hudson's status colors.
eyebrowFont, titleFont, detailFontType for the source line, the title and the detail.
action, actionInkFill and label for the primary choice. With action set, choices are drawn in the theme's colors with their ⌘ key shown. Nil keeps Hudson's buttons.
markA view drawn in place of the status dot on the left wing and before the eyebrow. It is tinted with the activity's tone.
let theme = HudNotchTheme(
    body: Color(red: 0.043, green: 0.039, blue: 0.031),
    accent: brandOrange,
    titleFont: .custom("EB Garamond", size: 18),
    action: brandOrange,
    mark: AnyView(LogoShape().fill())
)
let notch = HudNotchController(persistenceKey: "MyApp.notch", copy: copy, theme: theme)

HudNotchTuner(controller:) is a settings view for the shape: pokeout, radii, overlap, card heights, timing and display mode (automatic, notch or island). Changes persist under the persistenceKey.

Scenes

Activities cover status and questions. A host that needs its own view on the notch, such as a live meter, a one-line status under the housing or a small form, presents a HudNotchScene instead. The scene uses the same silhouette, panel and motion as activities. It is not a second notch window.

A scene has views for the two wings and a body below them:

size.contentHeightWhat shows
0The wings alone.
Short, such as 26 ptA chin: one line under the notch.
TallerThe notch opens as far as the content needs.

Changing size morphs the shape with resize. A new id cross-fades the body while the wings stay put, so a mark on a wing carries across.

notch.present(HudNotchScene(
    id: "status",
    size: .init(width: 400, contentHeight: 26),
    onTap: { showDetails() }
) {
    MyMark()                       // the left wing
} trailing: {
    Text("3 live").font(.caption.monospaced())
} content: {
    Text("Recording on studio")
})
notch.present(nil)                 // take it down
  • Precedence. An activity that asks for attention opens over the scene. The scene comes back when that card folds. Hover never opens the stage while a scene is up, and onHover reports the pointer instead.
  • Keys. The notch never takes key on its own. Give a scene onKeyDown or onFlagsChanged and it receives keys once the person clicks into it, or once you call focusScene(). Return true to consume an event. releaseFocus() gives the keyboard back, and so does presenting a scene without key handlers.
  • Room. The panel grows to fit the largest scene shown so far, so it never shrinks under a shape that is still animating.
  • Keycaps. HudNotchKeycaps draws a chord as hairline caps, with dashed slots for keys still to come and the last key lit with a flat wash. HudNotchKeycapStyle(theme:) derives its colors from the theme. HudNotchKeys (in HudsonNotchCore) names key presses and orders modifiers ⌃⌥⇧⌘, so a host can record and test a shortcut without a screen.
  • Previews. HudNotchScenePreview(scene:theme:) draws a scene still, on its silhouette, for snapshots and design reviews.

Motion

The notch is one black silhouette, HudNotchSilhouetteShape, drawn at different sizes for each state, so every change is a morph rather than a crossfade:

StateSilhouette
Tucked inThe size of the camera housing, where the hardware hides it. On a display without one it starts faded out.
PillThe housing plus the wings, with concave shoulders against the top of the screen. The island is a capsule.
CardThe open card, with the same shoulders and 22 pt bottom corners.

HudNotchMotion holds the timing:

  • Opening and appearing use an underdamped spring (open), so the card stretches a few points past its size and settles.
  • Closing and hiding use a tighter spring (close), so the shape tucks back without wobbling against the housing. hide() waits retractSeconds for it before ordering the panel out.
  • Size changes within a state, such as hover reach or a taller card for a question, use resize.
  • Width runs on its own, springier curves (widthOpen, widthResize), so an open or a resize stretches a few points sideways and settles while the height stays calm. Closing keeps close for both.
  • Content fades in with a light blur and lift about 90 ms after the shape starts. On close it fades while the shrinking silhouette masks it, so it reads as drawn back into the housing.
  • Something new arriving while the card is open gives it a small stretch (nudgeOut, then nudgeBack) along with the tinted outline flash.

With Reduce Motion on, state changes happen without springs and content only fades.

Appearance

HudNotchConfiguration.appearance sets how the body is drawn, with one HudNotchLook for the pill and one for the card. The tucked state uses the pill's look. Every value animates with the state change.

FieldRangeWhat it does
fillOpacity0–1Opacity of the black body. Below 1 the desktop shows through.
blur0–1Strength of the frosted backdrop under the body. Only visible when the fill is see-through.
borderWidth0–3 ptWidth of the rim.
borderOpacity0–1Brightness of the white rim. In notch style it fades out toward the top, so it never outlines the edge that meets the menu bar.
shadowOpacity, shadowRadius, shadowY0–1, 0–40 pt, 0–24 ptThe shadow. It is cut out of the body, so a see-through body never shows it from inside.

There are three presets: solid (the default, opaque like the housing), smoked and glass. The Tuner's Appearance section applies a preset and edits either state's look. Saved configurations without an appearance decode as solid.

Tests

swift test --filter HudsonNotchCoreTests

The tests cover wire decoding, including the legacy shape, the stage's attention rules, metrics, lenient configuration decoding, and a socket round trip with ask, respond and subscribe, plus key naming and scene room. The socket tests use a short /tmp path because sun_path holds only 104 bytes.

For AI agents