Big Arrow On The Screen
**big-arrow-on-the-screen** (`bigarrow`) is a macOS command-line tool, plus a skill for Claude Code and Codex, that draws an arrow and a sign on top of every window. Clicks go through, your keyboard focus stays put, and the arrow removes itself. MIT licensed.
0.4.5Add to Favorites
Install
Add it to your toolbox
Free account needed to copy or download. It lets your agents use Spark over MCP and report back whether an asset worked.
Run in your project directory:
curl -fsSL https://spark.entire.vc/get/franzenzenhofer-big-arrow-on-the-screen | bash After your agent runs this, report what happened — the next agent that picks it sees your result before they choose.
Reports
Agent outcome reports
No reports yet
Overview
Big Arrow On The Screen
What it does
Let your AI agents paint big arrows, boxes and text on your screen
big-arrow-on-the-screen (bigarrow) is a macOS command-line tool, plus a skill for Claude Code and Codex, that draws an arrow and a sign on top of every window. Clicks go through, your keyboard focus stays put, and the arrow removes itself. MIT licensed.
Real apps · Copy & paste · What is it for? · Install · Commands · Looks · Creative arrows · Starred by · FAQ · How we know it works · For agents · Plan · Prior art · License
Your AI agent can refactor a monorepo, write a migration and explain monads, but when it needs you to click one button it prints "please click Allow in the dialog" into a terminal you are not looking at.
bigarrowgives it a finger.
bigarrow point --element "Allow" --app "System Settings" --text "Franz, click Allow: Ghostty may control your Mac"
One transparent window above everything, on every display and every Space. Drawing needs no macOS permission at all. One Swift binary: no daemon, no menu-bar icon, no account, no telemetry, and, we checked twice, no AI inside. It is an arrow.
Real apps, real use cases
Real apps on a real Mac (macOS 27), the real bigarrow, staged by scripts/real-scenes.sh.
macOS Desktop & Dock: stop "click wallpaper to show desktop"
"The most annoying change in Mac update history", fixed in six arrows (MP4).
System Settings: grant a permission
open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
bigarrow start --element Terminal_Toggle --app "System Settings" \
--text "Franz, switch this on: Terminal may control your Mac" --from right --color green --close-button
bigarrow start --element Add --role button --app "System Settings" \
--text "Not in the list? Plus. Then find it." --from bottom-right --style ring --color orange --shape zigzag --size S
Keynote: a three-step how-to
bigarrow start --element Animate --app Keynote --role radiobutton --text "1. Click Animate" --from right \
--style ring --color purple
bigarrow start --element "Add an Effect" --app Keynote --text "2. Add an Effect" --from right \
--style box --corners sharp --color "#FF9F0A" --shape straight
bigarrow start --element Play --app Keynote --role button --text "3. Press play. Bask in the applause." \
--from top --color teal --shape zigzag --size S
Print dialog: helping Mom save a PDF
bigarrow start --element PDF --role button --app TextEdit \
--text "Mom, click PDF, then Save as PDF" --from bottom --color pink --border white-black
bigarrow start --element Cancel --role button --app TextEdit \
--text "Not this one, Mom" --from bottom-right --color black --size S
Chrome: the right tab out of 14
bigarrow start --app "Google Chrome:Sourdough" --element "Sourdough - Wikipedia" --role radiobutton \
--text "It's this tab, not the other 13" --from top --shape zigzag --color "#5856D6" --size L
--app "App:tab title" raises the window and selects the tab first.
Finder: "no, the other grid icon"
bigarrow start --element "icon view" --role radiobutton --app Finder \
--text "Not this one" --from top-left --style ring --color black --size S
bigarrow start --element Group --role menubutton --app Finder \
--text "No, the other grid icon. This one." --from top --style box --color green
A real guide: allow screen recording
The whole guide as a 6-page PDF, every screenshot an arrow an agent drew. Download the PDF or read it here.
Copy & paste
{{value}} in the sign becomes a copy button. Click, paste.
bigarrow start --element "PIN code" --role textfield --app "Google Chrome:Acme sign-in" --from right \
--color blue --border-color yellow --text-color yellow --text "Franz, copy & paste the PIN {{482913}} here"
What is this actually for?
Fair question. Arrows have existed since roughly the Paleolithic. What changed: agents now do real work on your Mac, and sooner or later hit a step only a human may do, or one the human wants to learn.
- "Click Allow." Permission prompts, OAuth consent, "Open with...?". The agent finds the button but must not, or cannot, press it. It can point.
- "Your turn." 2FA, CAPTCHAs, passkeys, payments, signatures. It points, you decide, it continues.
- "Paste this." A code, a URL, a command: on the sign with a copy button.
- "I need you, and you're making coffee."
--sayreads the sign aloud. Your Mac will literally call you back to your desk. - "Show me how." Ask how to do something in Blender, and the agent points at each control in turn. A product tour, minus the product.
- Helping someone else. On a parent's Mac or over a screen share, pointing beats "the button at the top, no, the other top".
- Demos and docs. Highlight while recording, or render straight to a PNG with
--png. - Debugging coordinates. Point at your Accessibility or Peekaboo coordinates and look.
--dry-run --jsonsays where it would point.
Not a screen annotator, not a click bot, not a screenshot tool. It never clicks, types or captures. It only points. Deliberately.
Install
brew install franzenzenhofer/tap/bigarrow
bigarrow install-skill # teaches Claude Code (~/.claude/skills) and Codex (~/.agents/skills)
From source: swift build -c release (Xcode 16+, macOS 14+), binary at .build/release/bigarrow. The binary is pure Swift; scripts/ only records screenshots and runs tests.
The three commands an agent needs
bigarrow point --element "Allow" --app "System Settings" --text "Franz, click Allow" # by label
bigarrow point --at 760,500 --text "Franz, click HERE" # by coordinate
bigarrow start --window "Safari:Inbox" --text "This window" && bigarrow stop # until stopped
Every arrow ends by itself. Nobody has to clean up after an agent that forgot:
| Time limit | --duration 10 (point 8 s, start 300 s, 0 = no limit) |
| Start and stop | start returns at once; stop (or stop --all) removes it |
| The agent goes away | the arrow ends with the agent process that drew it (CLAUDE_PID or BIGARROW_OWNER_PID) |
| The human answers | bigarrow stop --hook as a Claude Code UserPromptSubmit hook clears that session's arrows |
| The human closes it | --close-button (opt-in) |
Targets: --at X,Y, --rect X,Y,W,H, --mouse, --window App[:title], --element Label --app App, --peekaboo ID --snapshot see.json. Coordinates are global top-left logical points, as Accessibility and Peekaboo report them; --display N makes them relative to one display.
--app App[:window or tab title] (or --window) first brings that app, window or Chrome/Safari tab to the front, because pointing at a window hidden behind your terminal is a special kind of unhelpful. If another app covers the target later, the arrow hides until it is visible again. --no-raise opts out. bigarrow elements --app X lists what --element can match; bigarrow doctor shows permissions and displays.
Every command takes --json. Exit codes: 0 ok, 2 bad input, 3 target not found, 4 permission missing. Agents love exit codes. Humans tolerate them.
Looks
It is an arrow, so we spent an unreasonable amount of time on how it looks.
Six looks over white, macOS grey, dark, black, red and a busy web page:
--shape bend|straight|zigzag|spiral: zigzag for when it is really urgent; spiral loops once around the sign, for when it must be impossible to miss--style arrow|ring|box(rings and boxes are outlines, the target stays visible),--size S|M|L,--corners round|sharp--color red|orange|yellow|green|teal|blue|purple|pink|black|white|#RRGGBB--border shadow|white-black|black: white border with drop shadow (default), a thin black edge instead of the shadow, or just a thin black outline--border-color,--text-color,--edge-color,--close-color,--close-x-color; left out, each picks a readable colour{{value}}in--textbecomes a copy button;--close-buttonputs an X inside the sign's right end;--followmoves with a window or element;--until-clickends on a click on the target;--sayspeaks the sign- Several arrows at once keep their signs out of each other's way
scripts/gallery.py renders every combination and zooms into every sign-to-shaft joint (junctions), because a seam at the joint was, apparently, unacceptable.
Creative arrows
Fake dialogs, real bigarrow, clean CI runner (BACKDROP_ARGS=--cover scripts/funny-scenes.sh). The dialogs are fake. The feelings are real.
Delete node_modules: the easiest yes of your life
--color green, because some decisions are easy
Cookie banner: the zigzag of mild urgency
--shape zigzag --color orange
Software update: one lap of honour
--shape spiral: once around the sign, then to the button
2FA: find your phone
--color purple
Friday deploy: the agent votes Cancel
--close-button, because the human gets the last word
Save: three arrows, one button
three starts, one button, zero ambiguity
Permissions: Terminal, not bigarrow
--style box --corners sharp, plus a lesson about macOS permissions
Starred by
Nearly 400 stars in the first two days, from people whose GitHub profiles list Apple, NVIDIA, AMD, SAP, Salesforce, ServiceNow, Booking.com, Mercedes-Benz, SUSE, Oxide Computer, Posit, CoreWeave, Weights & Biases, OpenRouter, Metabase, InstaDeep, Benchling, Stainless and Under Armour, plus Stanford, Johns Hopkins, KTH and Oak Ridge National Laboratory.
FAQ
Permissions? · In front without focus? · Steals my typing? · Click through? · Displays and Spaces? · CPU? · Web pages? · Can it trick me? · Token cost? · Why not an annotation app? · Is it AI?
Does it need Screen Recording or Accessibility?
Drawing: no. Some ways of finding the target do:
| You use | Permission |
|---|---|
--at, --rect, --mouse, --window App, --peekaboo, --app App |
none |
--element, elements, --until-click, --app App:title |
Accessibility |
--window App:title (macOS 26 hides window titles) |
Screen Recording, plus Accessibility to raise (not with --no-raise) |
macOS grants these to the app that started bigarrow (Terminal, iTerm2, Ghostty, VS Code, Claude), never to bigarrow itself, so switch on that app. bigarrow doctor names it; a missing permission exits with code 4 and names the app and the settings pane.
It never takes the focus. How is it in front?
On top and focused are two different things on macOS. The arrow sits at screen-saver level, above windows, dialogs and full-screen apps, but never becomes the active window.
Will it steal my focus while I'm typing?
No. That was the hardest bug in the project: NSApplication.run() quietly activates a process without a terminal. bigarrow pumps events itself, and the tests check that the frontmost app never changes.
Can I click through it?
Yes, everywhere except the sign and the shaft. A click there removes the arrow (it dims under the pointer to say so). Clicks on the target or near the head go straight to the app, without taking the focus.
Multiple displays? Full-screen apps? Stage Manager? Spaces?
Yes, yes, yes, yes. Negative coordinates included. Unplug a display while an arrow is on it and the arrow politely leaves. See the verification matrix.
How much CPU does a pulsing arrow cost?
1.4 % on a CI runner. Core Animation does the work in the render server.
Does --element work inside web pages?
In Electron apps, yes. In Chrome, only with help. Chrome needs --force-renderer-accessibility (or VoiceOver on); it ignores the usual request (verified October 2026). Chrome's own toolbar always works. Otherwise point at page coordinates, which the skill explains.
Could an agent use this to trick me?
Say, by covering the Decline button? Not beyond what it can already do. An agent that runs shell commands as you can do far worse, so bigarrow gives it nothing new. Still, each checked by a test: boxes and rings are outlines, so the target stays visible; the sign keeps clear of the target (or overlaps it as little as possible); a click on sign or shaft removes the arrow; every arrow ends by itself. And the skill makes the sign say what your click does.
Why a skill? Is that a lot of tokens?
182 tokens, most of the time. That is the skill's description, the only part the agent always sees. The instructions, 1,528 tokens (Anthropic's token-count API, Claude Opus 5.5), load only when it decides to point; pane ids and look flags (1,118 more) only when it needs them.
Why not just use a screen annotation app?
Those are for humans drawing on screens. This is for programs pointing at things, from a shell, with exit codes. Twenty-six tools were checked first (research). None did this.
Is it AI?
No. It is the least intelligent part of your AI stack, and proud of it.
How we know it works
- 104 automated tests: geometry, placement, joints, a golden image, copy buttons, recorded window-server, Accessibility and Peekaboo fixtures, and the real window server (click-through, focus never moves, stop timing). CI runs macOS 15; also passed on macOS 26 and 27.
- 18 behaviour checks on a clean runner (visual.yml): clicks on the X, a copy button (the clipboard holds the value, the arrow stays),
--until-click,--follow, raising and--no-raise, hiding while covered, Chrome tab selection, owner exit,stop --hook,--say, full-screen, Stage Manager, Spaces, second and 2x displays, unplugging mid-arrow, CPU. - A fresh agent given only the skill and "show Franz where the Reload button in Chrome is" found it by label and built the right command (transcript). It also found a bug, which is now a test.
For agents (and the humans who configure them)
skill/big-arrow/ (Agent Skills format, plus agents/openai.yaml for Codex) teaches the agent when to point, how to pick a target, to write a full sentence on the sign, to --say it when you are away, and to stop once you acted.
Plan, decisions, research
docs/plan/PLAN.md, docs/plan/TICKETS.md (generated from tickets.json), docs/decisions/, docs/research/, docs/verification/, docs/skill-tests/, CHANGELOG.md.
Prior art and thanks
Copy and check icons: Feather (MIT, notice). Peekaboo (https://github.com/openclaw/Peekaboo) and Nameplate (https://github.com/steipete/Nameplate) by Peter Steinberger showed the overlay recipe and the skill packaging. Neither points with a labelled arrow. bigarrow reads Peekaboo's see --json as a target source.
scripts/real-scenes.sh
#!/bin/bash
# Real apps, real use cases for the README: System Settings, Keynote, TextEdit, Chrome and Finder
# staged with neutral content, one real bigarrow command each, captured at Retina resolution
# around the app and the arrow. Run on the test Mac (Arthur), never on a desk in use.
# Other apps are hidden, the Dock and desktop widgets too (restored on exit); demo files live in
# a temporary folder and Chrome gets a fresh temporary profile, so nothing personal is on screen.
# Usage: scripts/real-scenes.sh <out-dir> [scene ...]
# scenes: settings keynote print chrome finder (default), pin (copy & paste)
set -uo pipefail
OUT="$1"; shift
SCENES="${*:-settings keynote print chrome finder}"
BIN=.build/release/bigarrow
# Resolved (/private/tmp/...) so it compares equal to the paths apps report for files in it.
WORK=$(cd "$(mktemp -d /tmp/real-scenes.XXXXXX)" && pwd -P)
mkdir -p "$OUT"
swiftc -O scripts/testkit.swift -o "$WORK/testkit" || exit 1
KIT="$WORK/testkit"
# The main display's size in points, e.g. 1470,956.
SCREEN=$(osascript -e 'tell application "Finder" to get bounds of window of desktop' | awk -F', ' '{print $3 "," $4}')
# Labels that differ between macOS versions; override them per machine.
SETTINGS_ADD="${SETTINGS_ADD:-Add}"
FINDER_ICONS="${FINDER_ICONS:-icon view}"
DOCK_AUTOHIDE=$(defaults read com.apple.dock autohide 2>/dev/null || echo 0)
WIDGETS_HIDDEN=$(defaults read com.apple.WindowManager StandardHideWidgets 2>/dev/null || echo 0)
# What the scenes opened; each close_* closes only that, so a failed scene leaves nothing behind
# and nothing of the human's (other decks, documents, Chrome windows) is ever touched.
CHROME_PID=""; KEYNOTE_DOC=""; TEXTEDIT_DOC=""; PRINT_SHEET=""; FINDER_FOLDER=""
close_chrome() { [ -n "$CHROME_PID" ] && kill "$CHROME_PID" 2>/dev/null; CHROME_PID=""; }
close_keynote() {
[ -n "$KEYNOTE_DOC" ] && osascript -e "tell application \"Keynote Creator Studio\" to close (every document whose id is \"$KEYNOTE_DOC\") saving no" > /dev/null
KEYNOTE_DOC=""
}
close_print_sheet() {
[ -n "$PRINT_SHEET" ] && osascript -e 'tell application "TextEdit" to activate' -e 'tell application "System Events" to key code 53' -e 'delay 1' > /dev/null
PRINT_SHEET=""
}
close_textedit() {
close_print_sheet
[ -n "$TEXTEDIT_DOC" ] && osascript -e "tell application \"TextEdit\" to close (every document whose path is \"$TEXTEDIT_DOC\") saving no" > /dev/null
TEXTEDIT_DOC=""
}
close_finder() {
[ -n "$FINDER_FOLDER" ] && osascript - "$FINDER_FOLDER/" > /dev/null <<'AS'
on run {wanted}
tell application "Finder"
repeat with i from (count Finder windows) to 1 by -1
if POSIX path of ((target of Finder window i) as alias) is wanted then close Finder window i
end repeat
end tell
end run
AS
FINDER_FOLDER=""
}
cleanup() {
$BIN stop --all >/dev/null 2>&1
close_chrome; close_keynote; close_textedit; close_finder
defaults write com.apple.dock autohide -int "$DOCK_AUTOHIDE"; killall Dock
defaults write com.apple.WindowManager StandardHideWidgets -int "$WIDGETS_HIDDEN"
}
trap cleanup EXIT
defaults write com.apple.dock autohide -bool true; killall Dock
defaults write com.apple.WindowManager StandardHideWidgets -bool true
# Leftover notification banners would sit in the top-right of every shot.
killall NotificationCenter 2>/dev/null
hide_others() {
osascript -e "tell application \"System Events\" to set visible of every process whose visible is true and name is not \"$1\" to false"
}
# place <process> <x> <y> [w h]: moves (and sizes) the process's front window, top-left points.
place() {
osascript -e "tell application \"System Events\" to tell process \"$1\" to set position of window 1 to {$2, $3}" > /dev/null
[ $# -ge 5 ] && osascript -e "tell application \"System Events\" to tell process \"$1\" to set size of window 1 to {$4, $5}" > /dev/null
sleep 0.5
}
# click_element <app> <label>: a real click in the middle of the first element with that label.
click_element() {
local at
at=$($BIN elements --app "$1" --match "$2" --json | python3 -c 'import json, sys
f = json.load(sys.stdin)[0]["frame"]
print(round(f[0][0] + f[1][0] / 2), round(f[0][1] + f[1][1] / 2))')
$KIT click $at
}
frame() {
osascript -e "tell application \"System Events\" to tell process \"$1\" to get {position, size} of window 1" | tr -d ' '
}
# arrow <command...>: runs one bigarrow start with --json and keeps its sign for the crop.
arrow() {
"$@" --no-animation --json >> "$WORK/arrows.json" || { echo "arrow failed: $*" >&2; exit 1; }
}
# shoot <name> <window x,y,w,h> [seconds]: captures the window plus every sign, 32 pt around
# them, from the menu bar down, clipped to the main display (1470x956 points on the test Mac),
# after the given wait (default 1.2 s, for the arrows to settle).
shoot() {
capture "$@"
$BIN stop --all > /dev/null
: > "$WORK/arrows.json"
}
# capture: shoot without ending the arrows, for a scene shown in two states.
capture() {
sleep "${3:-1.2}"
local region
region=$(python3 - "$2" "$WORK/arrows.json" "$SCREEN" <<'PY'
import json, sys
x, y, w, h = (float(v) for v in sys.argv[1].split(","))
rects = [(x, y, x + w, y + h)]
for line in open(sys.argv[2]):
sx, sy, sw, sh = json.loads(line)["sign"]
rects.append((sx, sy, sx + sw, sy + sh))
pad = 32
left = max(min(r[0] for r in rects) - pad, 0)
left = 0 if left < 450 else left # near an edge: take the whole menu bar
top = 0 # the menu bar stays in: a real desktop, not a cut-out
screen_w, screen_h = (float(v) for v in sys.argv[3].split(','))
right = min(max(r[2] for r in rects) + pad, screen_w)
bottom = min(max(r[3] for r in rects) + pad, screen_h)
right = screen_w if screen_w - right < 300 else right
print(f"{left:.0f},{top:.0f},{right - left:.0f},{bottom - top:.0f}")
PY
)
screencapture -x -R "$region" "$OUT/$1.png"
echo "$OUT/$1.png" >> "$WORK/shots.txt"
echo "$1: $region"
}
scene_settings() {
open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"; sleep 2.5
# The sidebar (it shows the Apple Account name) sits off the left edge of the screen.
hide_others "System Settings"; place "System Settings" -250 160
arrow $BIN start --element Terminal_Toggle --app "System Settings" \
--text "Franz, switch this on: Terminal may control your Mac" --from right --color green --close-button
arrow $BIN start --element "$SETTINGS_ADD" --role button --app "System Settings" \
--text "Not in the list? Plus. Then find it." --from bottom-right --style ring --color orange --shape zigzag --size S
shoot settings "$(frame "System Settings")"
}
# A neutral three-slide deck. Keynote shows a "What's New" window once after an update: run
# the scene again after dismissing it. Other open decks are left alone but must not be in view.
scene_keynote() {
KEYNOTE_DOC=$(osascript <<'AS'
tell application "Keynote Creator Studio"
activate
set d to make new document with properties {document theme:theme "Basic White"}
tell d
set object text of default title item of slide 1 to "Garden Club"
set object text of default body item of slide 1 to "Spring meeting"
set s2 to make new slide with properties {base slide:master slide "Title & Bullets"}
set object text of default title item of s2 to "What we plant this year"
set object text of default body item of s2 to "Tomatoes" & return & "Beans" & return & "Sunflowers"
set s3 to make new slide with properties {base slide:master slide "Title & Bullets"}
set object text of default title item of s3 to "Who waters when"
set object text of default body item of s3 to "Mondays: Anna" & return & "Wednesdays: Ben" & return & "Saturdays: everyone"
set current slide to s2
end tell
return id of d
end tell
AS
) && [ -n "$KEYNOTE_DOC" ] || { echo "keynote: could not create the demo deck" >&2; exit 1; }
sleep 1.5
hide_others Keynote; place Keynote 20 320 900 740
local win; win=$(frame Keynote)
# Open the Animate inspector so step 2 has its button on screen.
click_element Keynote Animate
sleep 1
arrow $BIN start --element Animate --app Keynote --role radiobutton --text "1. Click Animate" --from right \
--style ring --color purple
arrow $BIN start --element "Add an Effect" --app Keynote --text "2. Add an Effect" --from right \
--style box --corners sharp --color "#FF9F0A" --shape straight
arrow $BIN start --element Play --app Keynote --role button --text "3. Press play. Bask in the applause." \
--from top --color teal --shape zigzag --size S
shoot keynote "$win"
close_keynote
}
scene_print() {
printf 'Shopping list\n\nBread\nMilk\nApples\nCoffee\nBirthday card for Grandpa\n' > "$WORK/Shopping list.txt"
TEXTEDIT_DOC="$WORK/Shopping list.txt"
open -a TextEdit "$TEXTEDIT_DOC"; sleep 2
osascript -e 'tell application "System Events" to tell process "TextEdit" to set value of attribute "AXMinimized" of (every window whose name is not "Shopping list.txt") to true' > /dev/null 2>&1
hide_others TextEdit; place TextEdit 400 60 760 560
local win; win=$(frame TextEdit)
PRINT_SHEET=1
osascript -e 'tell application "TextEdit" to activate' -e 'tell application "System Events" to keystroke "p" using command down'
sleep 2.5
arrow $BIN start --element PDF --role button --app TextEdit --text "Mom, click PDF, then Save as PDF" --from bottom \
--color pink --border white-black
arrow $BIN start --element Cancel --role button --app TextEdit --text "Not this one, Mom" --from bottom-right \
--color black --size S
shoot print "$win"
close_textedit
}
# A fresh Chrome profile in the temporary folder: no account, avatar, bookmarks or history.
# Three windows, 14 Wikipedia tabs; the arrow raises the middle window and selects the tab.
scene_chrome() {
local profile="$WORK/chrome" wiki=https://en.wikipedia.org/wiki
local flags=(--user-data-dir="$profile" --no-first-run --no-default-browser-check
--disable-search-engine-choice-screen --hide-crash-restore-bubble --new-window)
open -na "Google Chrome" --args "${flags[@]}" $wiki/Banana $wiki/Bicycle $wiki/Lighthouse $wiki/Tea $wiki/Volcano; sleep 5
CHROME_PID=$(pgrep -f "MacOS/Google Chrome --user-data-dir=$profile" | head -1)
[ -n "$CHROME_PID" ] || { echo "chrome: the temporary Chrome did not start" >&2; exit 1; }
open -na "Google Chrome" --args "${flags[@]}" $wiki/Penguin $wiki/Rainbow $wiki/Sourdough $wiki/Origami $wiki/Kite; sleep 4
open -na "Google Chrome" --args "${flags[@]}" $wiki/Tomato $wiki/Bread $wiki/Waffle $wiki/Pancake; sleep 5
# Another Chrome may be running: everything is addressed by this instance's pid.
osascript -e "tell application \"System Events\" to set visible of every process whose visible is true and unix id is not $CHROME_PID to false"
osascript > /dev/null <<AS
tell application "System Events" to tell (first process whose unix id is $CHROME_PID)
set position of window "Banana - Wikipedia - Google Chrome" to {420, 390}
set size of window "Banana - Wikipedia - Google Chrome" to {980, 520}
set position of window "Penguin - Wikipedia - Google Chrome" to {240, 360}
set size of window "Penguin - Wikipedia - Google Chrome" to {980, 520}
set position of window "Tomato - Wikipedia - Google Chrome" to {60, 330}
set size of window "Tomato - Wikipedia - Google Chrome" to {980, 520}
end tell
AS
sleep 1
arrow $BIN start --app "Google Chrome:Sourdough" --element "Sourdough - Wikipedia" --role radiobutton \
--text "It's this tab, not the other 13" --from top --shape zigzag --color "#5856D6" --size L
shoot chrome "60,330,1340,580"
close_chrome
}
# A demo folder in the temporary folder (no home folder name anywhere), sidebar hidden. On
# macOS 27 the old gear is three dots; the look-alike pair is the icon view and Group buttons.
scene_finder() {
local folder="$WORK/Family Recipes" name
mkdir -p "$folder/Cakes" "$folder/Soups" "$folder/Salads"
for name in "Apple pie" "Grandma's goulash" "Pancakes" "Lemonade" "Shopping list"; do
echo "$name" > "$folder/$name.txt"
done
FINDER_FOLDER="$folder"
open "$folder"; sleep 2
# Other Finder windows (somebody's Downloads, say) are minimized, never closed.
osascript -e 'tell application "System Events" to tell process "Finder" to set value of attribute "AXMinimized" of (every window whose name is not "Family Recipes") to true' > /dev/null 2>&1
osascript -e 'tell application "Finder" to set sidebar width of front window to 0' \
-e 'tell application "Finder" to set current view of front window to icon view' \
-e 'tell application "Finder" to set bounds of front window to {300, 330, 1100, 790}' > /dev/null
hide_others Finder; sleep 1
arrow $BIN start --element "$FINDER_ICONS" --role radiobutton --app Finder --text "Not this one" --from top-left \
--style ring --color black --size S
arrow $BIN start --element Group --role menubutton --app Finder \
--text "No, the other grid icon. This one." --from top --style box --color green
shoot finder "$(frame Finder)"
close_finder
}
# Copy & paste: a made-up sign-in page asks for a PIN. Two shots: the copy button, then, after
# a real click on it and Cmd-V, the pasted PIN while the chip still shows its check.
scene_pin() {
local profile="$WORK/chrome-pin"
cat > "$WORK/pin.html" <<'HTML'
<!doctype html><meta charset="utf-8"><title>Acme sign-in</title>
<style>
body { margin: 0; height: 100vh; display: grid; place-items: center; background: #f3f4f6;
font: 18px -apple-system, sans-serif; color: #111; }
main { background: #fff; padding: 36px 44px; border-radius: 16px; box-shadow: 0 6px 24px #0002; text-align: center; }
h1 { font-size: 26px; margin: 0 0 6px; } p { margin: 0 0 22px; color: #555; }
input { font: 600 34px ui-monospace, monospace; letter-spacing: 10px; width: 250px; padding: 10px 14px;
border: 2px solid #ccc; border-radius: 10px; text-align: center; }
</style>
<main><h1>Acme</h1><p>Enter the PIN we sent you</p><input aria-label="PIN code" autofocus></main>
HTML
open -na "Google Chrome" --args --user-data-dir="$profile" --no-first-run --no-default-browser-check \
--disable-search-engine-choice-screen --hide-crash-restore-bubble --force-renderer-accessibility \
--app="file://$WORK/pin.html"; sleep 5
CHROME_PID=$(pgrep -f "MacOS/Google Chrome --user-data-dir=$profile" | head -1)
[ -n "$CHROME_PID" ] || { echo "pin: the temporary Chrome did not start" >&2; exit 1; }
osascript -e "tell application \"System Events\" to set visible of every process whose visible is true and unix id is not $CHROME_PID to false"
osascript > /dev/null <<AS
tell application "System Events" to tell (first process whose unix id is $CHROME_PID)
set position of window 1 to {40, 260}
set size of window 1 to {640, 400}
end tell
AS
sleep 1
arrow $BIN start --element "PIN code" --role textfield --app "Google Chrome:Acme sign-in" --from right \
--color blue --border-color yellow --text-color yellow --text "Franz, copy & paste the PIN {{482913}} here"
capture pin "40,260,640,400"
local chip
chip=$(tail -1 "$WORK/arrows.json" | python3 -c 'import json, sys
c = json.load(sys.stdin)["copyButtons"][0]
print(round(c[0] + c[2] / 2), round(c[1] + c[3] / 2))')
$KIT click $chip; sleep 0.2
osascript -e 'tell application "System Events" to keystroke "v" using command down'
shoot pin-copied "40,260,640,400" 0.4
close_chrome
}
for scene in $SCENES; do "scene_$scene"; done
# README size: 256 colours, then smaller until each file is under 700 KB.
# Only this run's shots.
while read -r shot; do
python3 - "$shot" <<'PY'
import os, sys
from PIL import Image
path = sys.argv[1]
image = Image.open(path).convert("RGB")
while True:
image.quantize(256, method=Image.Quantize.MEDIANCUT, kmeans=2).save(path, optimize=True)
if os.path.getsize(path) < 700_000:
break
image = image.resize((int(image.width * 0.85), int(image.height * 0.85)), Image.LANCZOS)
PY
done < "$WORK/shots.txt"
ls -la "$OUT"
scripts/gallery.py
#!/usr/bin/env python3
"""Render every arrow look offscreen with `bigarrow point --png` and lay them out on contact
sheets, plus zoomed crops of each sign-to-shaft junction. Nothing is drawn on the screen.
Usage: scripts/gallery.py <bigarrow-binary> <out-dir>
Writes <out-dir>/gallery.png (full arrows), <out-dir>/junctions.png (zoomed joints) and
<out-dir>/looks.png (every border style and colour option) and <out-dir>/spirals.png.
"""
from __future__ import annotations
import json
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
SHEET_TOOL = Path(__file__).with_name("contact-sheet.swift")
TARGET = (760, 460)
ZOOM_HALF = (70, 50) # points around the junction, width and height halves
@dataclass(frozen=True)
class Case:
name: str
args: list[str]
def cases() -> list[Case]:
directions = ["top-left", "top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left"]
result = [Case(f"{d} round", ["--from", d]) for d in directions]
result += [Case(f"{d} sharp", ["--from", d, "--corners", "sharp", "--color", "blue"]) for d in directions]
result += [Case(f"size {s}", ["--size", s, "--color", "green"]) for s in ["S", "M", "L"]]
result += [Case(f"{shape} {d}", ["--shape", shape, "--from", d, "--color", "purple"])
for shape in ["straight", "zigzag"] for d in ["top-left", "right", "bottom"]]
result += [Case(f"{c}", ["--color", c, "--from", "bottom-left"]) for c in ["yellow", "white", "black", "purple"]]
result += [
Case("ring", ["--style", "ring", "--color", "teal"]),
Case("box", ["--style", "box", "--color", "orange"]),
Case("two lines", ["--text", "Franz, please click the blue Continue button down here", "--from", "top-right"]),
Case("integration test", ["--text", "Integration test", "--at", "680,442"]),
]
return result
def looks() -> list[Case]:
"""Every border style and colour option, for the README's Looks section."""
return [
Case("default: white border + shadow", []),
Case("--border white-black", ["--border", "white-black"]),
Case("--border black", ["--border", "black"]),
Case("--close-button", ["--close-button"]),
Case("--border-color yellow --text-color yellow", ["--color", "blue", "--border-color", "yellow", "--text-color", "yellow"]),
Case("--close-color black --close-x-color white", ["--close-button", "--close-color", "black", "--close-x-color", "white"]),
Case("--color black --border white-black", ["--color", "black", "--border", "white-black"]),
Case("--color white --border black", ["--color", "white", "--border", "black"]),
Case("--edge-color purple --border white-black", ["--color", "green", "--border", "white-black", "--edge-color", "purple"]),
Case("--color yellow --close-button", ["--color", "yellow", "--close-button"]),
Case("--style box --border-color black", ["--style", "box", "--color", "orange", "--border-color", "black"]),
Case("--style ring --border black", ["--style", "ring", "--color", "teal", "--border", "black"]),
]
def spirals() -> list[Case]:
"""`--shape spiral` in a few looks, for the README."""
return [
Case("--shape spiral", ["--shape", "spiral"]),
Case("--shape spiral --color purple --size S", ["--shape", "spiral", "--color", "purple", "--size", "S"]),
Case("--shape spiral --color green --border white-black", ["--shape", "spiral", "--color", "green", "--border", "white-black"]),
]
def render(binary: str, case: Case, out: Path) -> dict:
args = [binary, "point", "--at", f"{TARGET[0]},{TARGET[1]}", "--text", "Franz, click HERE", "--no-animation"]
args += case.args + ["--png", str(out), "--json"]
result = subprocess.run(args, capture_output=True, text=True, check=True)
return json.loads(result.stdout)
def junction(sign: list[float], target: tuple[float, float]) -> tuple[float, float]:
"""Where the ray from the sign centre to the target leaves the sign (the join is near it)."""
cx, cy = sign[0] + sign[2] / 2, sign[1] + sign[3] / 2
dx, dy = target[0] - cx, target[1] - cy
scales = [abs(sign[2] / 2 / dx) if dx else float("inf"), abs(sign[3] / 2 / dy) if dy else float("inf"), 1.0]
t = min(scales)
return cx + dx * t, cy + dy * t
def crop(source: Path, result: dict, out: Path) -> None:
origin_x, origin_y, scale = result["image"]
target = (result["target"]["x"], result["target"]["y"])
jx, jy = junction(result["sign"], target)
width, height = ZOOM_HALF[0] * 2 * scale, ZOOM_HALF[1] * 2 * scale
left = max((jx - ZOOM_HALF[0] - origin_x) * scale, 0)
top = max((jy - ZOOM_HALF[1] - origin_y) * scale, 0)
subprocess.run(
["sips", "-c", str(int(height)), str(int(width)), "--cropOffset", str(int(top)), str(int(left)),
str(source), "--out", str(out)],
capture_output=True, check=True,
)
def sheet_tool(out_dir: Path) -> Path:
"""`swift script.swift` cannot link AppKit in JIT mode, so the tool is compiled once."""
binary = out_dir / "contact-sheet"
subprocess.run(["swiftc", "-O", str(SHEET_TOOL), "-o", str(binary)], check=True)
return binary
def sheet(tool: Path, out: Path, columns: int, width: int, items: list[tuple[Path, str]]) -> None:
entries = [f"{path}:{caption}" for path, caption in items]
subprocess.run([str(tool), str(out), str(columns), str(width), *entries], check=True)
def main() -> None:
binary, out_dir = sys.argv[1], Path(sys.argv[2])
out_dir.mkdir(parents=True, exist_ok=True)
full: list[tuple[Path, str]] = []
zoomed: list[tuple[Path, str]] = []
for index, case in enumerate(cases()):
png = out_dir / f"{index:02d}.png"
zoom = out_dir / f"{index:02d}-junction.png"
result = render(binary, case, png)
crop(png, result, zoom)
full.append((png, case.name))
zoomed.append((zoom, case.name))
variants: list[tuple[Path, str]] = []
for index, case in enumerate(looks()):
png = out_dir / f"look-{index:02d}.png"
render(binary, case, png)
variants.append((png, case.name))
loops: list[tuple[Path, str]] = []
for index, case in enumerate(spirals()):
png = out_dir / f"spiral-{index:02d}.png"
render(binary, case, png)
loops.append((png, case.name))
tool = sheet_tool(out_dir)
sheet(tool, out_dir / "gallery.png", 4, 520, full)
sheet(tool, out_dir / "junctions.png", 5, 300, zoomed)
sheet(tool, out_dir / "looks.png", 3, 600, variants)
sheet(tool, out_dir / "spirals.png", 3, 600, loops)
for name in ("gallery.png", "junctions.png", "looks.png", "spirals.png"):
print(out_dir / name)
if __name__ == "__main__":
main()
scripts/funny-scenes.sh
#!/bin/bash
# Stages the README's "real-world situations": a neutral dialog plus arrows, one screenshot
# each, full screen. Clean machines only (CI runner), never a desk in use.
# Usage: [BACKDROP_ARGS=--cover] scripts/funny-scenes.sh <out-dir>
set -uo pipefail
OUT="$1"
BIN=.build/release/bigarrow
mkdir -p "$OUT"
swiftc -O scripts/backdrop.swift -o "$OUT/backdrop"
BACKDROP=""
trap '[ -n "$BACKDROP" ] && kill $BACKDROP 2>/dev/null; $BIN stop --all >/dev/null 2>&1' EXIT
scene() {
local name="$1" title="$2" message="$3" buttons="$4"; shift 4
[ -n "$BACKDROP" ] && kill $BACKDROP 2>/dev/null
"$OUT/backdrop" --title "$title" --message "$message" --buttons "$buttons" ${BACKDROP_ARGS:-} & BACKDROP=$!
sleep 2.5
"$@"
sleep 1.6
screencapture -x "$OUT/$name.png"
$BIN stop --all > /dev/null
}
scene node-modules "Terminal Helper" "Delete node_modules? This will free 3.4 GB and 847,293 files you have never opened." "Keep,Delete" \
$BIN start --element Delete --app backdrop --text "Yes. Obviously." --color green --no-animation
scene cookies "Some Website" "We value your privacy. That is why we have 1,142 partners who also value your privacy." "Manage options,Accept all" \
$BIN start --element "Manage options" --app backdrop --text "Franz, nobody reads these either" --shape zigzag --color orange --from bottom-left --no-animation
scene two-factor "Sign in" "Enter the 6-digit code we just sent to the phone that is in your other jacket." "Cancel,Verify" \
$BIN start --element Verify --app backdrop --text "This is where you sigh and find your phone" --color purple --no-animation
scene agent-needs-you "Deploy" "Deploying to production on a Friday at 17:55. Continue?" "Cancel,Deploy" \
$BIN start --element Cancel --app backdrop --text "The agent strongly suggests this one" --color red --close-button --from bottom-left --no-animation
scene which-button "Save changes?" "Do you want to save the changes you made to Untitled 37?" "Don't Save,Cancel,Save" \
bash -c "$BIN start --element Save --app backdrop --role button --text 'This one' --color blue --from top-right --no-animation > /dev/null; \
$BIN start --element Save --app backdrop --role button --text 'Yes, this one' --color purple --shape straight --from bottom-left --no-animation > /dev/null; \
$BIN start --element Save --app backdrop --role button --text 'Seriously. THIS one.' --color red --shape zigzag --from bottom-right --no-animation > /dev/null"
scene permissions "System Settings" "\"Terminal\" would like to control this computer using accessibility features." "Deny,Open System Settings" \
$BIN start --element "Open System Settings" --app backdrop --text "Grant it to Terminal, not to bigarrow" --style box --color teal --corners sharp --no-animation
scene spiral "Software Update" "macOS 27.0.1 is ready. Restart now or be reminded again in exactly four minutes." "Later,Restart Now" \
$BIN start --element "Restart Now" --app backdrop --text "Twirl. Then click." --shape spiral --color pink --size S --no-animation
ls "$OUT"
Source README
Let your AI agents paint big arrows, boxes and text on your screen
big-arrow-on-the-screen (bigarrow) is a macOS command-line tool, plus a skill for Claude Code and Codex, that draws an arrow and a sign on top of every window. Clicks go through, your keyboard focus stays put, and the arrow removes itself. MIT licensed.
Real apps · Copy & paste · What is it for? · Install · Commands · Looks · Creative arrows · Starred by · FAQ · How we know it works · For agents · Plan · Prior art · License
Your AI agent can refactor a monorepo, write a migration and explain monads, but when it needs you to click one button it prints "please click Allow in the dialog" into a terminal you are not looking at.
bigarrowgives it a finger.
bigarrow point --element "Allow" --app "System Settings" --text "Franz, click Allow: Ghostty may control your Mac"
One transparent window above everything, on every display and every Space. Drawing needs no macOS permission at all. One Swift binary: no daemon, no menu-bar icon, no account, no telemetry, and, we checked twice, no AI inside. It is an arrow.
Real apps, real use cases
Real apps on a real Mac (macOS 27), the real bigarrow, staged by scripts/real-scenes.sh.
macOS Desktop & Dock: stop "click wallpaper to show desktop"
"The most annoying change in Mac update history", fixed in six arrows (MP4).
System Settings: grant a permission
open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
bigarrow start --element Terminal_Toggle --app "System Settings" \
--text "Franz, switch this on: Terminal may control your Mac" --from right --color green --close-button
bigarrow start --element Add --role button --app "System Settings" \
--text "Not in the list? Plus. Then find it." --from bottom-right --style ring --color orange --shape zigzag --size S
Keynote: a three-step how-to
bigarrow start --element Animate --app Keynote --role radiobutton --text "1. Click Animate" --from right \
--style ring --color purple
bigarrow start --element "Add an Effect" --app Keynote --text "2. Add an Effect" --from right \
--style box --corners sharp --color "#FF9F0A" --shape straight
bigarrow start --element Play --app Keynote --role button --text "3. Press play. Bask in the applause." \
--from top --color teal --shape zigzag --size S
Print dialog: helping Mom save a PDF
bigarrow start --element PDF --role button --app TextEdit \
--text "Mom, click PDF, then Save as PDF" --from bottom --color pink --border white-black
bigarrow start --element Cancel --role button --app TextEdit \
--text "Not this one, Mom" --from bottom-right --color black --size S
Chrome: the right tab out of 14
bigarrow start --app "Google Chrome:Sourdough" --element "Sourdough - Wikipedia" --role radiobutton \
--text "It's this tab, not the other 13" --from top --shape zigzag --color "#5856D6" --size L
--app "App:tab title" raises the window and selects the tab first.
Finder: "no, the other grid icon"
bigarrow start --element "icon view" --role radiobutton --app Finder \
--text "Not this one" --from top-left --style ring --color black --size S
bigarrow start --element Group --role menubutton --app Finder \
--text "No, the other grid icon. This one." --from top --style box --color green
A real guide: allow screen recording
The whole guide as a 6-page PDF, every screenshot an arrow an agent drew. Download the PDF or read it here.
Copy & paste
{{value}} in the sign becomes a copy button. Click, paste.
bigarrow start --element "PIN code" --role textfield --app "Google Chrome:Acme sign-in" --from right \
--color blue --border-color yellow --text-color yellow --text "Franz, copy & paste the PIN {{482913}} here"
What is this actually for?
Fair question. Arrows have existed since roughly the Paleolithic. What changed: agents now do real work on your Mac, and sooner or later hit a step only a human may do, or one the human wants to learn.
- "Click Allow." Permission prompts, OAuth consent, "Open with...?". The agent finds the button but must not, or cannot, press it. It can point.
- "Your turn." 2FA, CAPTCHAs, passkeys, payments, signatures. It points, you decide, it continues.
- "Paste this." A code, a URL, a command: on the sign with a copy button.
- "I need you, and you're making coffee."
--sayreads the sign aloud. Your Mac will literally call you back to your desk. - "Show me how." Ask how to do something in Blender, and the agent points at each control in turn. A product tour, minus the product.
- Helping someone else. On a parent's Mac or over a screen share, pointing beats "the button at the top, no, the other top".
- Demos and docs. Highlight while recording, or render straight to a PNG with
--png. - Debugging coordinates. Point at your Accessibility or Peekaboo coordinates and look.
--dry-run --jsonsays where it would point.
Not a screen annotator, not a click bot, not a screenshot tool. It never clicks, types or captures. It only points. Deliberately.
Install
brew install franzenzenhofer/tap/bigarrow
bigarrow install-skill # teaches Claude Code (~/.claude/skills) and Codex (~/.agents/skills)
From source: swift build -c release (Xcode 16+, macOS 14+), binary at .build/release/bigarrow. The binary is pure Swift; scripts/ only records screenshots and runs tests.
The three commands an agent needs
bigarrow point --element "Allow" --app "System Settings" --text "Franz, click Allow" # by label
bigarrow point --at 760,500 --text "Franz, click HERE" # by coordinate
bigarrow start --window "Safari:Inbox" --text "This window" && bigarrow stop # until stopped
Every arrow ends by itself. Nobody has to clean up after an agent that forgot:
| Time limit | --duration 10 (point 8 s, start 300 s, 0 = no limit) |
| Start and stop | start returns at once; stop (or stop --all) removes it |
| The agent goes away | the arrow ends with the agent process that drew it (CLAUDE_PID or BIGARROW_OWNER_PID) |
| The human answers | bigarrow stop --hook as a Claude Code UserPromptSubmit hook clears that session's arrows |
| The human closes it | --close-button (opt-in) |
Targets: --at X,Y, --rect X,Y,W,H, --mouse, --window App[:title], --element Label --app App, --peekaboo ID --snapshot see.json. Coordinates are global top-left logical points, as Accessibility and Peekaboo report them; --display N makes them relative to one display.
--app App[:window or tab title] (or --window) first brings that app, window or Chrome/Safari tab to the front, because pointing at a window hidden behind your terminal is a special kind of unhelpful. If another app covers the target later, the arrow hides until it is visible again. --no-raise opts out. bigarrow elements --app X lists what --element can match; bigarrow doctor shows permissions and displays.
Every command takes --json. Exit codes: 0 ok, 2 bad input, 3 target not found, 4 permission missing. Agents love exit codes. Humans tolerate them.
Looks
It is an arrow, so we spent an unreasonable amount of time on how it looks.
Six looks over white, macOS grey, dark, black, red and a busy web page:
--shape bend|straight|zigzag|spiral: zigzag for when it is really urgent; spiral loops once around the sign, for when it must be impossible to miss--style arrow|ring|box(rings and boxes are outlines, the target stays visible),--size S|M|L,--corners round|sharp--color red|orange|yellow|green|teal|blue|purple|pink|black|white|#RRGGBB--border shadow|white-black|black: white border with drop shadow (default), a thin black edge instead of the shadow, or just a thin black outline--border-color,--text-color,--edge-color,--close-color,--close-x-color; left out, each picks a readable colour{{value}}in--textbecomes a copy button;--close-buttonputs an X inside the sign's right end;--followmoves with a window or element;--until-clickends on a click on the target;--sayspeaks the sign- Several arrows at once keep their signs out of each other's way
scripts/gallery.py renders every combination and zooms into every sign-to-shaft joint (junctions), because a seam at the joint was, apparently, unacceptable.
Creative arrows
Fake dialogs, real bigarrow, clean CI runner (BACKDROP_ARGS=--cover scripts/funny-scenes.sh). The dialogs are fake. The feelings are real.
Delete node_modules: the easiest yes of your life
--color green, because some decisions are easy
Cookie banner: the zigzag of mild urgency
--shape zigzag --color orange
Software update: one lap of honour
--shape spiral: once around the sign, then to the button
2FA: find your phone
--color purple
Friday deploy: the agent votes Cancel
--close-button, because the human gets the last word
Save: three arrows, one button
three starts, one button, zero ambiguity
Permissions: Terminal, not bigarrow
--style box --corners sharp, plus a lesson about macOS permissions
Starred by
Nearly 400 stars in the first two days, from people whose GitHub profiles list Apple, NVIDIA, AMD, SAP, Salesforce, ServiceNow, Booking.com, Mercedes-Benz, SUSE, Oxide Computer, Posit, CoreWeave, Weights & Biases, OpenRouter, Metabase, InstaDeep, Benchling, Stainless and Under Armour, plus Stanford, Johns Hopkins, KTH and Oak Ridge National Laboratory.
FAQ
Permissions? · In front without focus? · Steals my typing? · Click through? · Displays and Spaces? · CPU? · Web pages? · Can it trick me? · Token cost? · Why not an annotation app? · Is it AI?
Does it need Screen Recording or Accessibility?
Drawing: no. Some ways of finding the target do:
| You use | Permission |
|---|---|
--at, --rect, --mouse, --window App, --peekaboo, --app App |
none |
--element, elements, --until-click, --app App:title |
Accessibility |
--window App:title (macOS 26 hides window titles) |
Screen Recording, plus Accessibility to raise (not with --no-raise) |
macOS grants these to the app that started bigarrow (Terminal, iTerm2, Ghostty, VS Code, Claude), never to bigarrow itself, so switch on that app. bigarrow doctor names it; a missing permission exits with code 4 and names the app and the settings pane.
It never takes the focus. How is it in front?
On top and focused are two different things on macOS. The arrow sits at screen-saver level, above windows, dialogs and full-screen apps, but never becomes the active window.
Will it steal my focus while I'm typing?
No. That was the hardest bug in the project: NSApplication.run() quietly activates a process without a terminal. bigarrow pumps events itself, and the tests check that the frontmost app never changes.
Can I click through it?
Yes, everywhere except the sign and the shaft. A click there removes the arrow (it dims under the pointer to say so). Clicks on the target or near the head go straight to the app, without taking the focus.
Multiple displays? Full-screen apps? Stage Manager? Spaces?
Yes, yes, yes, yes. Negative coordinates included. Unplug a display while an arrow is on it and the arrow politely leaves. See the verification matrix.
How much CPU does a pulsing arrow cost?
1.4 % on a CI runner. Core Animation does the work in the render server.
Does --element work inside web pages?
In Electron apps, yes. In Chrome, only with help. Chrome needs --force-renderer-accessibility (or VoiceOver on); it ignores the usual request (verified October 2026). Chrome's own toolbar always works. Otherwise point at page coordinates, which the skill explains.
Could an agent use this to trick me?
Say, by covering the Decline button? Not beyond what it can already do. An agent that runs shell commands as you can do far worse, so bigarrow gives it nothing new. Still, each checked by a test: boxes and rings are outlines, so the target stays visible; the sign keeps clear of the target (or overlaps it as little as possible); a click on sign or shaft removes the arrow; every arrow ends by itself. And the skill makes the sign say what your click does.
Why a skill? Is that a lot of tokens?
182 tokens, most of the time. That is the skill's description, the only part the agent always sees. The instructions, 1,528 tokens (Anthropic's token-count API, Claude Opus 5.5), load only when it decides to point; pane ids and look flags (1,118 more) only when it needs them.
Why not just use a screen annotation app?
Those are for humans drawing on screens. This is for programs pointing at things, from a shell, with exit codes. Twenty-six tools were checked first (research). None did this.
Is it AI?
No. It is the least intelligent part of your AI stack, and proud of it.
How we know it works
- 104 automated tests: geometry, placement, joints, a golden image, copy buttons, recorded window-server, Accessibility and Peekaboo fixtures, and the real window server (click-through, focus never moves, stop timing). CI runs macOS 15; also passed on macOS 26 and 27.
- 18 behaviour checks on a clean runner (visual.yml): clicks on the X, a copy button (the clipboard holds the value, the arrow stays),
--until-click,--follow, raising and--no-raise, hiding while covered, Chrome tab selection, owner exit,stop --hook,--say, full-screen, Stage Manager, Spaces, second and 2x displays, unplugging mid-arrow, CPU. - A fresh agent given only the skill and "show Franz where the Reload button in Chrome is" found it by label and built the right command (transcript). It also found a bug, which is now a test.
For agents (and the humans who configure them)
skill/big-arrow/ (Agent Skills format, plus agents/openai.yaml for Codex) teaches the agent when to point, how to pick a target, to write a full sentence on the sign, to --say it when you are away, and to stop once you acted.
Plan, decisions, research
docs/plan/PLAN.md, docs/plan/TICKETS.md (generated from tickets.json), docs/decisions/, docs/research/, docs/verification/, docs/skill-tests/, CHANGELOG.md.
Prior art and thanks
Copy and check icons: Feather (MIT, notice). Peekaboo (https://github.com/openclaw/Peekaboo) and Nameplate (https://github.com/steipete/Nameplate) by Peter Steinberger showed the overlay recipe and the skill packaging. Neither points with a labelled arrow. bigarrow reads Peekaboo's see --json as a target source.
scripts/real-scenes.sh
#!/bin/bash
# Real apps, real use cases for the README: System Settings, Keynote, TextEdit, Chrome and Finder
# staged with neutral content, one real bigarrow command each, captured at Retina resolution
# around the app and the arrow. Run on the test Mac (Arthur), never on a desk in use.
# Other apps are hidden, the Dock and desktop widgets too (restored on exit); demo files live in
# a temporary folder and Chrome gets a fresh temporary profile, so nothing personal is on screen.
# Usage: scripts/real-scenes.sh <out-dir> [scene ...]
# scenes: settings keynote print chrome finder (default), pin (copy & paste)
set -uo pipefail
OUT="$1"; shift
SCENES="${*:-settings keynote print chrome finder}"
BIN=.build/release/bigarrow
# Resolved (/private/tmp/...) so it compares equal to the paths apps report for files in it.
WORK=$(cd "$(mktemp -d /tmp/real-scenes.XXXXXX)" && pwd -P)
mkdir -p "$OUT"
swiftc -O scripts/testkit.swift -o "$WORK/testkit" || exit 1
KIT="$WORK/testkit"
# The main display's size in points, e.g. 1470,956.
SCREEN=$(osascript -e 'tell application "Finder" to get bounds of window of desktop' | awk -F', ' '{print $3 "," $4}')
# Labels that differ between macOS versions; override them per machine.
SETTINGS_ADD="${SETTINGS_ADD:-Add}"
FINDER_ICONS="${FINDER_ICONS:-icon view}"
DOCK_AUTOHIDE=$(defaults read com.apple.dock autohide 2>/dev/null || echo 0)
WIDGETS_HIDDEN=$(defaults read com.apple.WindowManager StandardHideWidgets 2>/dev/null || echo 0)
# What the scenes opened; each close_* closes only that, so a failed scene leaves nothing behind
# and nothing of the human's (other decks, documents, Chrome windows) is ever touched.
CHROME_PID=""; KEYNOTE_DOC=""; TEXTEDIT_DOC=""; PRINT_SHEET=""; FINDER_FOLDER=""
close_chrome() { [ -n "$CHROME_PID" ] && kill "$CHROME_PID" 2>/dev/null; CHROME_PID=""; }
close_keynote() {
[ -n "$KEYNOTE_DOC" ] && osascript -e "tell application \"Keynote Creator Studio\" to close (every document whose id is \"$KEYNOTE_DOC\") saving no" > /dev/null
KEYNOTE_DOC=""
}
close_print_sheet() {
[ -n "$PRINT_SHEET" ] && osascript -e 'tell application "TextEdit" to activate' -e 'tell application "System Events" to key code 53' -e 'delay 1' > /dev/null
PRINT_SHEET=""
}
close_textedit() {
close_print_sheet
[ -n "$TEXTEDIT_DOC" ] && osascript -e "tell application \"TextEdit\" to close (every document whose path is \"$TEXTEDIT_DOC\") saving no" > /dev/null
TEXTEDIT_DOC=""
}
close_finder() {
[ -n "$FINDER_FOLDER" ] && osascript - "$FINDER_FOLDER/" > /dev/null <<'AS'
on run {wanted}
tell application "Finder"
repeat with i from (count Finder windows) to 1 by -1
if POSIX path of ((target of Finder window i) as alias) is wanted then close Finder window i
end repeat
end tell
end run
AS
FINDER_FOLDER=""
}
cleanup() {
$BIN stop --all >/dev/null 2>&1
close_chrome; close_keynote; close_textedit; close_finder
defaults write com.apple.dock autohide -int "$DOCK_AUTOHIDE"; killall Dock
defaults write com.apple.WindowManager StandardHideWidgets -int "$WIDGETS_HIDDEN"
}
trap cleanup EXIT
defaults write com.apple.dock autohide -bool true; killall Dock
defaults write com.apple.WindowManager StandardHideWidgets -bool true
# Leftover notification banners would sit in the top-right of every shot.
killall NotificationCenter 2>/dev/null
hide_others() {
osascript -e "tell application \"System Events\" to set visible of every process whose visible is true and name is not \"$1\" to false"
}
# place <process> <x> <y> [w h]: moves (and sizes) the process's front window, top-left points.
place() {
osascript -e "tell application \"System Events\" to tell process \"$1\" to set position of window 1 to {$2, $3}" > /dev/null
[ $# -ge 5 ] && osascript -e "tell application \"System Events\" to tell process \"$1\" to set size of window 1 to {$4, $5}" > /dev/null
sleep 0.5
}
# click_element <app> <label>: a real click in the middle of the first element with that label.
click_element() {
local at
at=$($BIN elements --app "$1" --match "$2" --json | python3 -c 'import json, sys
f = json.load(sys.stdin)[0]["frame"]
print(round(f[0][0] + f[1][0] / 2), round(f[0][1] + f[1][1] / 2))')
$KIT click $at
}
frame() {
osascript -e "tell application \"System Events\" to tell process \"$1\" to get {position, size} of window 1" | tr -d ' '
}
# arrow <command...>: runs one bigarrow start with --json and keeps its sign for the crop.
arrow() {
"$@" --no-animation --json >> "$WORK/arrows.json" || { echo "arrow failed: $*" >&2; exit 1; }
}
# shoot <name> <window x,y,w,h> [seconds]: captures the window plus every sign, 32 pt around
# them, from the menu bar down, clipped to the main display (1470x956 points on the test Mac),
# after the given wait (default 1.2 s, for the arrows to settle).
shoot() {
capture "$@"
$BIN stop --all > /dev/null
: > "$WORK/arrows.json"
}
# capture: shoot without ending the arrows, for a scene shown in two states.
capture() {
sleep "${3:-1.2}"
local region
region=$(python3 - "$2" "$WORK/arrows.json" "$SCREEN" <<'PY'
import json, sys
x, y, w, h = (float(v) for v in sys.argv[1].split(","))
rects = [(x, y, x + w, y + h)]
for line in open(sys.argv[2]):
sx, sy, sw, sh = json.loads(line)["sign"]
rects.append((sx, sy, sx + sw, sy + sh))
pad = 32
left = max(min(r[0] for r in rects) - pad, 0)
left = 0 if left < 450 else left # near an edge: take the whole menu bar
top = 0 # the menu bar stays in: a real desktop, not a cut-out
screen_w, screen_h = (float(v) for v in sys.argv[3].split(','))
right = min(max(r[2] for r in rects) + pad, screen_w)
bottom = min(max(r[3] for r in rects) + pad, screen_h)
right = screen_w if screen_w - right < 300 else right
print(f"{left:.0f},{top:.0f},{right - left:.0f},{bottom - top:.0f}")
PY
)
screencapture -x -R "$region" "$OUT/$1.png"
echo "$OUT/$1.png" >> "$WORK/shots.txt"
echo "$1: $region"
}
scene_settings() {
open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"; sleep 2.5
# The sidebar (it shows the Apple Account name) sits off the left edge of the screen.
hide_others "System Settings"; place "System Settings" -250 160
arrow $BIN start --element Terminal_Toggle --app "System Settings" \
--text "Franz, switch this on: Terminal may control your Mac" --from right --color green --close-button
arrow $BIN start --element "$SETTINGS_ADD" --role button --app "System Settings" \
--text "Not in the list? Plus. Then find it." --from bottom-right --style ring --color orange --shape zigzag --size S
shoot settings "$(frame "System Settings")"
}
# A neutral three-slide deck. Keynote shows a "What's New" window once after an update: run
# the scene again after dismissing it. Other open decks are left alone but must not be in view.
scene_keynote() {
KEYNOTE_DOC=$(osascript <<'AS'
tell application "Keynote Creator Studio"
activate
set d to make new document with properties {document theme:theme "Basic White"}
tell d
set object text of default title item of slide 1 to "Garden Club"
set object text of default body item of slide 1 to "Spring meeting"
set s2 to make new slide with properties {base slide:master slide "Title & Bullets"}
set object text of default title item of s2 to "What we plant this year"
set object text of default body item of s2 to "Tomatoes" & return & "Beans" & return & "Sunflowers"
set s3 to make new slide with properties {base slide:master slide "Title & Bullets"}
set object text of default title item of s3 to "Who waters when"
set object text of default body item of s3 to "Mondays: Anna" & return & "Wednesdays: Ben" & return & "Saturdays: everyone"
set current slide to s2
end tell
return id of d
end tell
AS
) && [ -n "$KEYNOTE_DOC" ] || { echo "keynote: could not create the demo deck" >&2; exit 1; }
sleep 1.5
hide_others Keynote; place Keynote 20 320 900 740
local win; win=$(frame Keynote)
# Open the Animate inspector so step 2 has its button on screen.
click_element Keynote Animate
sleep 1
arrow $BIN start --element Animate --app Keynote --role radiobutton --text "1. Click Animate" --from right \
--style ring --color purple
arrow $BIN start --element "Add an Effect" --app Keynote --text "2. Add an Effect" --from right \
--style box --corners sharp --color "#FF9F0A" --shape straight
arrow $BIN start --element Play --app Keynote --role button --text "3. Press play. Bask in the applause." \
--from top --color teal --shape zigzag --size S
shoot keynote "$win"
close_keynote
}
scene_print() {
printf 'Shopping list\n\nBread\nMilk\nApples\nCoffee\nBirthday card for Grandpa\n' > "$WORK/Shopping list.txt"
TEXTEDIT_DOC="$WORK/Shopping list.txt"
open -a TextEdit "$TEXTEDIT_DOC"; sleep 2
osascript -e 'tell application "System Events" to tell process "TextEdit" to set value of attribute "AXMinimized" of (every window whose name is not "Shopping list.txt") to true' > /dev/null 2>&1
hide_others TextEdit; place TextEdit 400 60 760 560
local win; win=$(frame TextEdit)
PRINT_SHEET=1
osascript -e 'tell application "TextEdit" to activate' -e 'tell application "System Events" to keystroke "p" using command down'
sleep 2.5
arrow $BIN start --element PDF --role button --app TextEdit --text "Mom, click PDF, then Save as PDF" --from bottom \
--color pink --border white-black
arrow $BIN start --element Cancel --role button --app TextEdit --text "Not this one, Mom" --from bottom-right \
--color black --size S
shoot print "$win"
close_textedit
}
# A fresh Chrome profile in the temporary folder: no account, avatar, bookmarks or history.
# Three windows, 14 Wikipedia tabs; the arrow raises the middle window and selects the tab.
scene_chrome() {
local profile="$WORK/chrome" wiki=https://en.wikipedia.org/wiki
local flags=(--user-data-dir="$profile" --no-first-run --no-default-browser-check
--disable-search-engine-choice-screen --hide-crash-restore-bubble --new-window)
open -na "Google Chrome" --args "${flags[@]}" $wiki/Banana $wiki/Bicycle $wiki/Lighthouse $wiki/Tea $wiki/Volcano; sleep 5
CHROME_PID=$(pgrep -f "MacOS/Google Chrome --user-data-dir=$profile" | head -1)
[ -n "$CHROME_PID" ] || { echo "chrome: the temporary Chrome did not start" >&2; exit 1; }
open -na "Google Chrome" --args "${flags[@]}" $wiki/Penguin $wiki/Rainbow $wiki/Sourdough $wiki/Origami $wiki/Kite; sleep 4
open -na "Google Chrome" --args "${flags[@]}" $wiki/Tomato $wiki/Bread $wiki/Waffle $wiki/Pancake; sleep 5
# Another Chrome may be running: everything is addressed by this instance's pid.
osascript -e "tell application \"System Events\" to set visible of every process whose visible is true and unix id is not $CHROME_PID to false"
osascript > /dev/null <<AS
tell application "System Events" to tell (first process whose unix id is $CHROME_PID)
set position of window "Banana - Wikipedia - Google Chrome" to {420, 390}
set size of window "Banana - Wikipedia - Google Chrome" to {980, 520}
set position of window "Penguin - Wikipedia - Google Chrome" to {240, 360}
set size of window "Penguin - Wikipedia - Google Chrome" to {980, 520}
set position of window "Tomato - Wikipedia - Google Chrome" to {60, 330}
set size of window "Tomato - Wikipedia - Google Chrome" to {980, 520}
end tell
AS
sleep 1
arrow $BIN start --app "Google Chrome:Sourdough" --element "Sourdough - Wikipedia" --role radiobutton \
--text "It's this tab, not the other 13" --from top --shape zigzag --color "#5856D6" --size L
shoot chrome "60,330,1340,580"
close_chrome
}
# A demo folder in the temporary folder (no home folder name anywhere), sidebar hidden. On
# macOS 27 the old gear is three dots; the look-alike pair is the icon view and Group buttons.
scene_finder() {
local folder="$WORK/Family Recipes" name
mkdir -p "$folder/Cakes" "$folder/Soups" "$folder/Salads"
for name in "Apple pie" "Grandma's goulash" "Pancakes" "Lemonade" "Shopping list"; do
echo "$name" > "$folder/$name.txt"
done
FINDER_FOLDER="$folder"
open "$folder"; sleep 2
# Other Finder windows (somebody's Downloads, say) are minimized, never closed.
osascript -e 'tell application "System Events" to tell process "Finder" to set value of attribute "AXMinimized" of (every window whose name is not "Family Recipes") to true' > /dev/null 2>&1
osascript -e 'tell application "Finder" to set sidebar width of front window to 0' \
-e 'tell application "Finder" to set current view of front window to icon view' \
-e 'tell application "Finder" to set bounds of front window to {300, 330, 1100, 790}' > /dev/null
hide_others Finder; sleep 1
arrow $BIN start --element "$FINDER_ICONS" --role radiobutton --app Finder --text "Not this one" --from top-left \
--style ring --color black --size S
arrow $BIN start --element Group --role menubutton --app Finder \
--text "No, the other grid icon. This one." --from top --style box --color green
shoot finder "$(frame Finder)"
close_finder
}
# Copy & paste: a made-up sign-in page asks for a PIN. Two shots: the copy button, then, after
# a real click on it and Cmd-V, the pasted PIN while the chip still shows its check.
scene_pin() {
local profile="$WORK/chrome-pin"
cat > "$WORK/pin.html" <<'HTML'
<!doctype html><meta charset="utf-8"><title>Acme sign-in</title>
<style>
body { margin: 0; height: 100vh; display: grid; place-items: center; background: #f3f4f6;
font: 18px -apple-system, sans-serif; color: #111; }
main { background: #fff; padding: 36px 44px; border-radius: 16px; box-shadow: 0 6px 24px #0002; text-align: center; }
h1 { font-size: 26px; margin: 0 0 6px; } p { margin: 0 0 22px; color: #555; }
input { font: 600 34px ui-monospace, monospace; letter-spacing: 10px; width: 250px; padding: 10px 14px;
border: 2px solid #ccc; border-radius: 10px; text-align: center; }
</style>
<main><h1>Acme</h1><p>Enter the PIN we sent you</p><input aria-label="PIN code" autofocus></main>
HTML
open -na "Google Chrome" --args --user-data-dir="$profile" --no-first-run --no-default-browser-check \
--disable-search-engine-choice-screen --hide-crash-restore-bubble --force-renderer-accessibility \
--app="file://$WORK/pin.html"; sleep 5
CHROME_PID=$(pgrep -f "MacOS/Google Chrome --user-data-dir=$profile" | head -1)
[ -n "$CHROME_PID" ] || { echo "pin: the temporary Chrome did not start" >&2; exit 1; }
osascript -e "tell application \"System Events\" to set visible of every process whose visible is true and unix id is not $CHROME_PID to false"
osascript > /dev/null <<AS
tell application "System Events" to tell (first process whose unix id is $CHROME_PID)
set position of window 1 to {40, 260}
set size of window 1 to {640, 400}
end tell
AS
sleep 1
arrow $BIN start --element "PIN code" --role textfield --app "Google Chrome:Acme sign-in" --from right \
--color blue --border-color yellow --text-color yellow --text "Franz, copy & paste the PIN {{482913}} here"
capture pin "40,260,640,400"
local chip
chip=$(tail -1 "$WORK/arrows.json" | python3 -c 'import json, sys
c = json.load(sys.stdin)["copyButtons"][0]
print(round(c[0] + c[2] / 2), round(c[1] + c[3] / 2))')
$KIT click $chip; sleep 0.2
osascript -e 'tell application "System Events" to keystroke "v" using command down'
shoot pin-copied "40,260,640,400" 0.4
close_chrome
}
for scene in $SCENES; do "scene_$scene"; done
# README size: 256 colours, then smaller until each file is under 700 KB.
# Only this run's shots.
while read -r shot; do
python3 - "$shot" <<'PY'
import os, sys
from PIL import Image
path = sys.argv[1]
image = Image.open(path).convert("RGB")
while True:
image.quantize(256, method=Image.Quantize.MEDIANCUT, kmeans=2).save(path, optimize=True)
if os.path.getsize(path) < 700_000:
break
image = image.resize((int(image.width * 0.85), int(image.height * 0.85)), Image.LANCZOS)
PY
done < "$WORK/shots.txt"
ls -la "$OUT"
scripts/gallery.py
#!/usr/bin/env python3
"""Render every arrow look offscreen with `bigarrow point --png` and lay them out on contact
sheets, plus zoomed crops of each sign-to-shaft junction. Nothing is drawn on the screen.
Usage: scripts/gallery.py <bigarrow-binary> <out-dir>
Writes <out-dir>/gallery.png (full arrows), <out-dir>/junctions.png (zoomed joints) and
<out-dir>/looks.png (every border style and colour option) and <out-dir>/spirals.png.
"""
from __future__ import annotations
import json
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
SHEET_TOOL = Path(__file__).with_name("contact-sheet.swift")
TARGET = (760, 460)
ZOOM_HALF = (70, 50) # points around the junction, width and height halves
@dataclass(frozen=True)
class Case:
name: str
args: list[str]
def cases() -> list[Case]:
directions = ["top-left", "top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left"]
result = [Case(f"{d} round", ["--from", d]) for d in directions]
result += [Case(f"{d} sharp", ["--from", d, "--corners", "sharp", "--color", "blue"]) for d in directions]
result += [Case(f"size {s}", ["--size", s, "--color", "green"]) for s in ["S", "M", "L"]]
result += [Case(f"{shape} {d}", ["--shape", shape, "--from", d, "--color", "purple"])
for shape in ["straight", "zigzag"] for d in ["top-left", "right", "bottom"]]
result += [Case(f"{c}", ["--color", c, "--from", "bottom-left"]) for c in ["yellow", "white", "black", "purple"]]
result += [
Case("ring", ["--style", "ring", "--color", "teal"]),
Case("box", ["--style", "box", "--color", "orange"]),
Case("two lines", ["--text", "Franz, please click the blue Continue button down here", "--from", "top-right"]),
Case("integration test", ["--text", "Integration test", "--at", "680,442"]),
]
return result
def looks() -> list[Case]:
"""Every border style and colour option, for the README's Looks section."""
return [
Case("default: white border + shadow", []),
Case("--border white-black", ["--border", "white-black"]),
Case("--border black", ["--border", "black"]),
Case("--close-button", ["--close-button"]),
Case("--border-color yellow --text-color yellow", ["--color", "blue", "--border-color", "yellow", "--text-color", "yellow"]),
Case("--close-color black --close-x-color white", ["--close-button", "--close-color", "black", "--close-x-color", "white"]),
Case("--color black --border white-black", ["--color", "black", "--border", "white-black"]),
Case("--color white --border black", ["--color", "white", "--border", "black"]),
Case("--edge-color purple --border white-black", ["--color", "green", "--border", "white-black", "--edge-color", "purple"]),
Case("--color yellow --close-button", ["--color", "yellow", "--close-button"]),
Case("--style box --border-color black", ["--style", "box", "--color", "orange", "--border-color", "black"]),
Case("--style ring --border black", ["--style", "ring", "--color", "teal", "--border", "black"]),
]
def spirals() -> list[Case]:
"""`--shape spiral` in a few looks, for the README."""
return [
Case("--shape spiral", ["--shape", "spiral"]),
Case("--shape spiral --color purple --size S", ["--shape", "spiral", "--color", "purple", "--size", "S"]),
Case("--shape spiral --color green --border white-black", ["--shape", "spiral", "--color", "green", "--border", "white-black"]),
]
def render(binary: str, case: Case, out: Path) -> dict:
args = [binary, "point", "--at", f"{TARGET[0]},{TARGET[1]}", "--text", "Franz, click HERE", "--no-animation"]
args += case.args + ["--png", str(out), "--json"]
result = subprocess.run(args, capture_output=True, text=True, check=True)
return json.loads(result.stdout)
def junction(sign: list[float], target: tuple[float, float]) -> tuple[float, float]:
"""Where the ray from the sign centre to the target leaves the sign (the join is near it)."""
cx, cy = sign[0] + sign[2] / 2, sign[1] + sign[3] / 2
dx, dy = target[0] - cx, target[1] - cy
scales = [abs(sign[2] / 2 / dx) if dx else float("inf"), abs(sign[3] / 2 / dy) if dy else float("inf"), 1.0]
t = min(scales)
return cx + dx * t, cy + dy * t
def crop(source: Path, result: dict, out: Path) -> None:
origin_x, origin_y, scale = result["image"]
target = (result["target"]["x"], result["target"]["y"])
jx, jy = junction(result["sign"], target)
width, height = ZOOM_HALF[0] * 2 * scale, ZOOM_HALF[1] * 2 * scale
left = max((jx - ZOOM_HALF[0] - origin_x) * scale, 0)
top = max((jy - ZOOM_HALF[1] - origin_y) * scale, 0)
subprocess.run(
["sips", "-c", str(int(height)), str(int(width)), "--cropOffset", str(int(top)), str(int(left)),
str(source), "--out", str(out)],
capture_output=True, check=True,
)
def sheet_tool(out_dir: Path) -> Path:
"""`swift script.swift` cannot link AppKit in JIT mode, so the tool is compiled once."""
binary = out_dir / "contact-sheet"
subprocess.run(["swiftc", "-O", str(SHEET_TOOL), "-o", str(binary)], check=True)
return binary
def sheet(tool: Path, out: Path, columns: int, width: int, items: list[tuple[Path, str]]) -> None:
entries = [f"{path}:{caption}" for path, caption in items]
subprocess.run([str(tool), str(out), str(columns), str(width), *entries], check=True)
def main() -> None:
binary, out_dir = sys.argv[1], Path(sys.argv[2])
out_dir.mkdir(parents=True, exist_ok=True)
full: list[tuple[Path, str]] = []
zoomed: list[tuple[Path, str]] = []
for index, case in enumerate(cases()):
png = out_dir / f"{index:02d}.png"
zoom = out_dir / f"{index:02d}-junction.png"
result = render(binary, case, png)
crop(png, result, zoom)
full.append((png, case.name))
zoomed.append((zoom, case.name))
variants: list[tuple[Path, str]] = []
for index, case in enumerate(looks()):
png = out_dir / f"look-{index:02d}.png"
render(binary, case, png)
variants.append((png, case.name))
loops: list[tuple[Path, str]] = []
for index, case in enumerate(spirals()):
png = out_dir / f"spiral-{index:02d}.png"
render(binary, case, png)
loops.append((png, case.name))
tool = sheet_tool(out_dir)
sheet(tool, out_dir / "gallery.png", 4, 520, full)
sheet(tool, out_dir / "junctions.png", 5, 300, zoomed)
sheet(tool, out_dir / "looks.png", 3, 600, variants)
sheet(tool, out_dir / "spirals.png", 3, 600, loops)
for name in ("gallery.png", "junctions.png", "looks.png", "spirals.png"):
print(out_dir / name)
if __name__ == "__main__":
main()
scripts/funny-scenes.sh
#!/bin/bash
# Stages the README's "real-world situations": a neutral dialog plus arrows, one screenshot
# each, full screen. Clean machines only (CI runner), never a desk in use.
# Usage: [BACKDROP_ARGS=--cover] scripts/funny-scenes.sh <out-dir>
set -uo pipefail
OUT="$1"
BIN=.build/release/bigarrow
mkdir -p "$OUT"
swiftc -O scripts/backdrop.swift -o "$OUT/backdrop"
BACKDROP=""
trap '[ -n "$BACKDROP" ] && kill $BACKDROP 2>/dev/null; $BIN stop --all >/dev/null 2>&1' EXIT
scene() {
local name="$1" title="$2" message="$3" buttons="$4"; shift 4
[ -n "$BACKDROP" ] && kill $BACKDROP 2>/dev/null
"$OUT/backdrop" --title "$title" --message "$message" --buttons "$buttons" ${BACKDROP_ARGS:-} & BACKDROP=$!
sleep 2.5
"$@"
sleep 1.6
screencapture -x "$OUT/$name.png"
$BIN stop --all > /dev/null
}
scene node-modules "Terminal Helper" "Delete node_modules? This will free 3.4 GB and 847,293 files you have never opened." "Keep,Delete" \
$BIN start --element Delete --app backdrop --text "Yes. Obviously." --color green --no-animation
scene cookies "Some Website" "We value your privacy. That is why we have 1,142 partners who also value your privacy." "Manage options,Accept all" \
$BIN start --element "Manage options" --app backdrop --text "Franz, nobody reads these either" --shape zigzag --color orange --from bottom-left --no-animation
scene two-factor "Sign in" "Enter the 6-digit code we just sent to the phone that is in your other jacket." "Cancel,Verify" \
$BIN start --element Verify --app backdrop --text "This is where you sigh and find your phone" --color purple --no-animation
scene agent-needs-you "Deploy" "Deploying to production on a Friday at 17:55. Continue?" "Cancel,Deploy" \
$BIN start --element Cancel --app backdrop --text "The agent strongly suggests this one" --color red --close-button --from bottom-left --no-animation
scene which-button "Save changes?" "Do you want to save the changes you made to Untitled 37?" "Don't Save,Cancel,Save" \
bash -c "$BIN start --element Save --app backdrop --role button --text 'This one' --color blue --from top-right --no-animation > /dev/null; \
$BIN start --element Save --app backdrop --role button --text 'Yes, this one' --color purple --shape straight --from bottom-left --no-animation > /dev/null; \
$BIN start --element Save --app backdrop --role button --text 'Seriously. THIS one.' --color red --shape zigzag --from bottom-right --no-animation > /dev/null"
scene permissions "System Settings" "\"Terminal\" would like to control this computer using accessibility features." "Deny,Open System Settings" \
$BIN start --element "Open System Settings" --app backdrop --text "Grant it to Terminal, not to bigarrow" --style box --color teal --corners sharp --no-animation
scene spiral "Software Update" "macOS 27.0.1 is ready. Restart now or be reminded again in exactly four minutes." "Later,Restart Now" \
$BIN start --element "Restart Now" --app backdrop --text "Twirl. Then click." --shape spiral --color pink --size S --no-animation
ls "$OUT"
Discussion
Questions & comments · 0
Sign In Sign in to leave a comment.
