Skip to content

feat(companion): a floating Stacky that reacts to your agents - #233

Open
adefreitas wants to merge 12 commits into
StackOneHQ:mainfrom
adefreitas:feat/companion
Open

adefreitas wants to merge 12 commits into
StackOneHQ:mainfrom
adefreitas:feat/companion

Conversation

@adefreitas

@adefreitas adefreitas commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds the companion: a small animated Stacky that floats on the desktop, takes the pill widget's place, and reacts to what stack-nudge knows (a waiting prompt, a busy agent, a finished turn, mute, quiet). It is on for new installs, offered once to existing installs after an update, and can be switched in Settings → Appearance → Floating widget. Builds on #227 (Settings… in the menu bar menu) and #228 (the widget no longer squeezes the panel into a pill).

Screen.Recording.2026-10-29.at.18.36.12.mov
Screen.Recording.2026-10-29.at.18.39.55.mov

Changes

  • Skin engine (panel/Companion/): a skin is Core Animation layers built from baked PNG parts and a manifest, so the render server runs the animation and the app does no per-frame work. CompanionSkins is the registry, so more skins slot in beside Stacky. Only regular Stacky ships (assets/skins/stacky, 3.7 MB), copied into the bundle by build.sh.
  • Floating window: borderless, never takes focus. Click opens the panel beside it, drag moves it, and its spot is saved as its bottom-right corner so a size change doesn't make it jump. A spot on a display that went away is pulled to the nearest screen. Animation pauses while covered, on display sleep and when locked; Reduce Motion holds it still.
  • Poses, highest first: muted, quota past the alert threshold (alert), a queued event or permission prompt (pending, the wave), a busy agent (thinking), asleep after five quiet minutes, else idle. Open sessions that are not working leave it idle (not polling) and only keep it from napping; only a busy session changes the pose. A permission prompt is a waiting pose like any queued event, not an alert. The wave means something wants you to act, so a finished agent turn still waves.
  • Count badge at the character's corner for events waiting on you (grey while muted), switched off with STACKNUDGE_COMPANION_BADGE.
  • Info nod: a new finished agent turn plays one small nod when the character is otherwise calm (idle or asleep), notifications aren't muted, the session isn't muted and Reduce Motion is off; a burst earns one. Only live arrivals nod, not events restored at launch.
  • CompanionInputs is where a later source of reactions publishes a value that CompanionController reads into CompanionSignals; it holds only the nod request today, and nothing here depends on feeds, GitHub, calendar or AI.
  • The pill gives way while the companion is on and returns with its settings when it is off (widgetPreferred remembers the user's choice).
  • Settings → Appearance → Floating widget: one picker row (Pill, Companion, Off) replacing the Widget toggle and the Companion on/off row. The pill's rows (snap, corner, opacity, type, mascot) apply only to Pill. STACKNUDGE_FLOATING_WIDGET holds it; a config without it is migrated on load (STACKNUDGE_COMPANION=true gives companion, else STACKNUDGE_COMPACT_MODE on unless false gives pill, else off) and the result is written back. Both old keys are still read, never written. The post-update Turn on card sets it too.
  • Settings → Companion: Count badge, Size, Preview pose (forces any pose, never saved) and Reset position. While Floating widget isn't Companion the tab shows a note, a Turn on companion row that switches it, and the settings dimmed. They follow the fix(panel): make the keys mean the same thing on every page #224 key rules: toggles and actions act on Space/⏎, Floating widget, Size and Preview pose are value rows that pick, arrows alone never change anything. All are in selectedRowIsPicker and SettingsRowTests.
  • Right-click menu: the menu-bar menu at the pointer, including feat(panel): Settings… in the menu bar menu and ⌘, to open Settings #227's Settings… (⌘,, the same showSettings), with a Companion submenu (Size, Reset position). Holding ⌥ while it is open reveals Debug: Pose preview and Info (nod). A 50 ms timer in the event-tracking run loop mode reads the key live, only while the menu is open.
  • Post-update "Meet Stacky" card with a Turn on button, shown once (STACKNUDGE_COMPANION_OFFERED). It is keyboard-reachable: Tab / ⇧Tab move focus between Turn on and Got it (accent ring, the Settings selection style), ⏎ presses the focused one, and focus starts on Got it so ⏎ still dismisses. The decision is the pure PanelController.postUpdateKeyAction; the Tab hint shows only while the card does. Esc still hides the panel, via the global handler. Following fix(panel): swallow a held key after recording a shortcut #226, pressing ⏎ on the card arms the global key router to swallow that key's repeats, so a held ⏎ does not carry on into the Events page (GlobalKeyRouter.swallowRepeats).
  • Layout race: turning the companion on or off flips compactMode and compactExpanded together. fix(widget): turning the widget on no longer squeezes the panel into a pill #228 now defers applyCompactLayout in both sinks, and setFloatingWidget (which drives them for Pill, Companion and Off) relies on that. The core adds no sink of its own, so each change lays out once.
  • Config: STACKNUDGE_COMPANION (on in notify.conf.example, so new installs start with it), _SIZE, _BADGE, _POS, _OFFERED. README, CONTRIBUTING and notify.conf.example updated. Nothing is read or sent over the network, so PRIVACY.md needs no change.

Testing

With SDKROOT=/Library/Developer/CommandLineTools/SDKs/MacOSX26.5.sdk:

  • ./build.sh: builds, only the existing ImplicitStrongCapture warnings.
  • make test-without-xcode: 1510 tests, 0 failures.
  • make typecheck-tests: OK.

Manual (the window and animation can't run in CI; I did not run the app):

  1. Fresh config: the companion appears bottom right. Click it: the panel opens beside it. Click again or press the hotkey: it hides.
  2. Drag it, relaunch: it comes back where you dropped it. Settings → Companion → Reset position (Space): it returns bottom right.
  3. Right-click: menu-bar menu appears at the pointer. Companion → Size changes it live. Hold ⌥ with the menu open: Debug appears and disappears with the key. Pose preview shows each pose; the first menu item says a pose is previewed and stops it. Info (nod) plays one nod.
  4. Settings → Appearance → Floating widget: Space then → steps Pill, Companion, Off; Pill returns at full panel size (not squeezed) and the pill's rows are dimmed otherwise. In the Companion tab → on Size does nothing; Space then ←/→ steps it. With the companion off the tab shows the note and Turn on companion.
  5. Mute notifications: muted pose and a grey badge. Trigger a permission prompt: the waiting wave with a count badge, not alert.
  6. Idle, then finish an agent turn: one nod, then the wave while it waits to be read.
  7. Remove STACKNUDGE_FLOATING_WIDGET, STACKNUDGE_COMPANION and STACKNUDGE_COMPANION_OFFERED from the config, update from an older build: the post-update screen shows the card once.
  8. On the update screen with the "Meet Stacky" card: Tab rings Turn on, ⇧Tab goes back to Got it, ⏎ on Turn on turns the companion on and the card goes, ⏎ on Got it leaves. Hold ⏎ on Turn on: nothing happens on the next page. Without the card, Tab does nothing and no Tab hint shows.
  9. Right-click the companion: Settings… (and ⌘,) opens Settings.
  10. Migration: with STACKNUDGE_COMPANION=true and no STACKNUDGE_FLOATING_WIDGET in the config, launch: the key is written as companion.

Related issues

None.

🤖 Generated with Claude Code

adefreitas and others added 12 commits October 30, 2026 15:04
A skin draws the companion as Core Animation layers, so the app does no
per-frame work: the render server runs the animations. Art is baked PNG
parts plus a manifest per skin under assets/skins, copied into the bundle
by build.sh. BakedRig plays back each group's recorded keyframes, so a
skin adds no motion code of its own. CompanionSkins is the registry, so
further skins slot in beside Stacky.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
STACKNUDGE_COMPANION puts Stacky in a borderless window on the desktop.
Click opens the panel beside it, drag moves it, and the spot is saved as
its bottom-right corner so a size change never makes it jump. Without a
display for the saved spot it is pulled to the nearest one. The pill
widget gives way while it is on and comes back with its settings when
it is off (widgetPreferred keeps the user's choice), and right-click
shows the menu-bar menu at the pointer.

Its pose follows what the app knows, highest first: muted, a quota past
the alert threshold, a queued event or permission prompt, a busy agent,
live sessions, then asleep after five quiet minutes. A permission prompt
is a waiting pose like any queued event, not an alert, since it is the
most routine thing the character sees. Animations pause while the window
is covered, the display sleeps or the screen is locked.

Settings gets a Companion category (on/off and Size). On/off acts on
Space or Enter and Size picks, as every other row does since the key
rules were unified. Turning the companion on or off flips compactMode
and compactExpanded together, whose layout sinks run in willSet, so the
second one laid out against a half-updated pair and shrank the panel to
a pill. The layout is run once more after both have landed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The menu-bar menu, which the character's right-click shows at the
pointer, gets a Companion submenu while the companion is on. It holds
the size for now, saved the way the Settings row saves it. The menu
object is kept by MenuBarController because a menu item does not retain
its target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Existing installs predate the companion and keep the pill, so the
post-update screen shows a card with a Turn on button, once.
STACKNUDGE_COMPANION_OFFERED records that it was seen; leaving the
screen any way counts, so the offer never returns. New installs start
with the companion on from notify.conf.example and never see it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README gets a Companion section with the two settings and how the pose
follows the app, the menu-bar table gets the submenu, and
notify.conf.example turns the companion on for new installs, since
install.sh copies it as the first config. CONTRIBUTING lists where the
code and the art live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…osition

The character now shows how many events wait on you: an app-icon style
badge at its corner, grey while muted, switched off with Count badge
(STACKNUDGE_COMPANION_BADGE).

A new finished agent turn plays one small nod when the character is calm
before and after (idle or asleep), notifications aren't muted, Reduce
Motion is off and the session isn't muted; a burst earns one. The pose
afterwards is still the waiting one: the wave means something wants you
to act, and a finished turn does, so it still waves. Only live arrivals
nod; events restored at launch never reach onAppend.

Settings gets Preview pose, which forces any pose on the character
without saving it and outranks every real signal, and Reset position,
which forgets the saved spot. Preview pose picks and Reset position acts
on Space or Enter, per the key rules. CompanionInputs is where a source
of reactions publishes a value for CompanionController to read into
CompanionSignals; it holds only the nod request for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Companion submenu gets Reset position. Holding Option while the menu
is open reveals a Debug submenu: Pose preview (any pose, or Off to show
the real state) and Info (nod), which plays the nod once. While a pose
is previewed the menu's first item says so and stops it on a click.

An open menu runs its own tracking loop, which event monitors never see,
so a 50 ms timer in the event-tracking run loop mode reads the Option
key and shows or hides Debug live. It exists only while the menu is
open. The previews are views only: nothing appends an event or writes a
log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README and notify.conf.example cover the count badge, the nod, the waiting
wave, Reset position, Preview pose and the Option-revealed Debug menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tab and Shift+Tab move focus between Turn on and Got it while the
companion card shows, and Return presses the focused button. Focus
starts on Got it, so Return still dismisses. The focused button gets
the accent ring Settings rows use for the selection, and the action bar
lists Tab (Move) only while the card shows. Esc still hides the panel
through the global handler. The key decision is the pure
PanelController.postUpdateKeyAction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion or neither

Appearance gets a Floating widget picker (Pill, Companion, Off) in place of
the Widget toggle, and the Companion tab drops its on/off row. While the
companion is off the tab shows a note and a Turn on companion row, and its
settings are dimmed. The setting is STACKNUDGE_FLOATING_WIDGET. A config
without it is migrated on load: STACKNUDGE_COMPANION=true gives companion,
else STACKNUDGE_COMPACT_MODE (on unless false) gives pill, else off, and the
result is written back. The post-update Turn on card goes through the same
setter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Live sessions drove the polling pose, so with any agent session open the
character looped its scanning animation even though nothing was happening.
Now only a busy session changes the pose (thinking); an idle open session
leaves it idle and only keeps it from napping. Polling is left for a
later signal that means something is actually being checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant