swoop.sh: install, every feature, and a guide to writing your own extension.
Say it like a bird does: swoop down, grab the thing, gone.
An open-source keyboard launcher built the Unix way: fzf does the finding, libghostty does the drawing, and every extension is a program that prints lines.
The plan, every design decision, and the work in progress live in the issues. Start there.
swoop is one tool built on swoopkit, the toolkit in this repository:
the launcher script, swoop-nav, swoop-run, swoop-preview,
swoop-match and the other small programs, the extension contract
(CONTRACT.md), and the frame. The kit has no rows and no name of its own to show. A tool is a
folder with an extensions folder and, beside it, one file called tool
that says what the tool is: name for its folders (~/.config/<name> and
the rest) and its command, title for what the frame shows, id for its
launchd labels, hotkey for the key that opens it. swoop is that file
plus the bundled extensions; a tool of your own is another file and your
own extensions, with the kit's programs unchanged. The programs and the
SWOOP_* variables keep their names under every tool, so an extension
written for one runs under another.
With Homebrew, on a Mac or on Linux:
brew install beatzball/tap/swoop
brew services start swoop # Mac: the frame at login, alt+shift+spaceOr with nothing but curl:
curl -fsSL https://raw.githubusercontent.com/beatzball/swoop/main/scripts/get | bashNo Go, no Xcode tools, no fzf needed. It downloads the release for your OS
and arch into ~/.local/share/swoop/<version>, links
~/.local/share/swoop/current to it, and fetches fzf into the same place if
fzf is not on your PATH. On a Mac it also writes two launchd agents that run
at login: the frame, which owns the hotkey and the panel, and the clipboard
watcher. Press alt+shift+space. On Linux and Windows there is no frame yet;
it tells you how to run swoop in a terminal.
| bash -s -- --version 0.4.0 installs a given release instead of the
latest. Run it again to upgrade. Logs are in ~/Library/Logs/swoop, and
~/.local/share/swoop/current/scripts/uninstall removes the agents and keeps
your history.
However it was installed, swoop restart restarts the frame and the
clipboard watcher, swoop stop and swoop start stop and start them, and
swoop status says whether they run, where from, and how they were
installed, which extension has which key, and which lists were too slow
the last time the launcher was slow to open. The bird in the menu bar has the same: Restart, and Quit, which
keeps it down until the next login or swoop start. After an upgrade the
frame restarts itself, once the panel is hidden.
Releases are not signed yet, so macOS takes each upgraded frame for a new
program: after brew upgrade or a new release, remove swoop-shell-mac from
System Settings, Privacy & Security, Accessibility with the minus button and
add it again. The switch shows on either way; only a new grant works.
git clone https://github.com/beatzball/swoop.git && cd swoop && make installThe same two agents, pointed at the checkout. You need Go, fzf, and Xcode's
tools. make install again after a pull restarts them on the new build;
make uninstall removes them and keeps your history.
The first make install also makes swoop-dev, a code-signing certificate
in your login keychain, and signs the frame with it, so macOS sees every
build as the same program and an Accessibility grant survives the next
install. Trusting it shows one macOS password prompt, the first time only.
Without the frame, swoop is still a terminal program: the same one the frame runs. You need Go and fzf. Then:
make build # compiles the tools into bin/
bin/swoop # lists your apps; type to filter; Enter opens; Esc quitsOn Linux the apps come from .desktop files, in XDG order: your own
~/.local/share/applications first, then each of XDG_DATA_DIRS.
It runs in any terminal. Icons and previews are pictures in a terminal that
draws them (Ghostty, kitty, WezTerm, Konsole) and glyphs and text anywhere
else; SWOOP_PICTURES=1 or 0 overrides the guess. The frame above is the
launcher; this is the same program without it, which is also how you run it
on Linux or Windows, or
inside Ghostty's own quick terminal if you prefer that to the frame. For the
quick terminal, add to your Ghostty config:
keybind = global:alt+space=toggle_quick_terminal
quick-terminal-position = center
reload it with cmd+shift+,, grant Ghostty Accessibility access when it
asks, and add to the end of your ~/.zshrc, with the path to your checkout:
# Ghostty sets this in its quick terminal and nowhere else.
if [[ -n "$GHOSTTY_QUICK_TERMINAL" ]] && [[ -x /absolute/path/to/swoop/bin/swoop ]]; then
exec /absolute/path/to/swoop/bin/swoop
fiTake those lines out again to get a plain quick terminal back.
The frame is a small Swift program, shell/mac, that does nothing but show
a floating panel with swoop in it, drawn by libghostty, on a global hotkey.
make install runs it for you; to run it by hand instead:
make build shell-mac
SWOOP_LAUNCHER="$PWD/bin/swoop" shell/mac/.build/release/swoop-shell-macThen press alt+shift+space. Esc closes it, Enter opens what you picked, a
click elsewhere hides it, and the next press is a fresh launcher with an
empty bar. SWOOP_HOTKEY=alt+space picks another key. SWOOP_LAUNCHER
names the launcher the frame runs, and it is how swoop status and a
later make install know a frame started by hand as swoop's. It needs no
Ghostty.app. One permission, Accessibility, and only to paste emoji and
snippets for you: macOS asks the first time.
cmd+[ and cmd+] move the divider between the list and the preview, 5% a
step, and the width is remembered in ~/.config/swoop/config (preview = 58). In a plain terminal the keys are alt+left and alt+right. Drag any
edge of the panel to resize it; the size is remembered in
~/.config/swoop/shell-mac.json beside the font size, and can be typed
there too (width, height). A bird in the menu bar opens the launcher,
opens it on the Settings pane, and quits; SWOOP_NO_MENU_BAR=1 leaves it
out.
Type an extension's keyword and a space to ask only that extension.
def ap opens Define with ap typed; clip and a space opens Clipboard
History; win l lists only the window rows for l. Esc goes back to the
whole list. The word alone does nothing special, so calc still finds
Calculator. The bundled keywords: def, calc, clip, files, ai,
links, emoji, snip, win, notes, tasks, rem, stats,
settings.
Press Tab from anywhere. The pane opens with whatever you had typed still
in the bar. Enter sends it, the bar clears, and the answer arrives on the
right: your prompt, dots while the model thinks, then the text as it comes.
Type again and press Enter to continue the same conversation; each exchange
appends below the last. The list under Ask AI > holds your conversations,
newest first; move to one to read it, and ctrl-k on it offers Copy last
answer, Copy conversation, and Delete. Esc brings the launcher back with
the text you had before Tab.
swoop does not know what a model is. It runs one command with the
conversation so far on its stdin and shows what comes out of its stdout, as
it comes. Name the command in ~/.config/swoop/ai, one line:
ollama run llama3.2
Or pick one in Settings, under AI model, or with ctrl-k on the New conversation row in the pane:
| you have | pick | streams | searches the web |
|---|---|---|---|
| Claude Code | claude |
yes | yes, with the switch |
| Codex | codex |
no, answers whole | yes, with the switch |
| Ollama | ollama:<model>, each one listed |
yes | no |
| LM Studio | lmstudio:<model>, each loaded one listed |
yes | no |
| an OpenAI key | openai:<model>, key under API key |
yes | yes, with the switch |
| OpenRouter, Groq, vLLM, any server with the OpenAI API | openai:<model>, its address under API URL |
yes | no |
| anything else | the line in ~/.config/swoop/ai |
if it does | if it does |
Without a choice it uses the line in that file, then claude -p if claude
is on your PATH, then ollama run with the model named by ollama = llama3.2 in ~/.config/swoop/config, or else the newest one you pulled;
the line under the dots says which. Copy on Linux needs wl-copy or
xclip, and the Copy rows say so when neither is there. Web search is off until you turn it on in Settings; on, the models
that can will search and fetch, and the line under the dots says when the
one you picked cannot. Anything that reads a question and prints an answer
works, streaming or not. Conversations are files in
~/.local/state/swoop/ai, readable by you only.
Answers are markdown, drawn by swoop's own small renderer. It is also a
command, swoop-md, for anything else that wants markdown in a terminal:
printf '# hi\n\n- one\n- two\n' | swoop-md -w 40And the transcript can be drawn by any command instead. One line in
~/.config/swoop/config, the answer on its stdin, its stdout shown:
render = glow -s dark
A quicklink is a name, a link, and what opens it. Type the name, press
Enter, and the link opens: a URL, a folder, a file, a deeplink another app
owns. A link that holds {argument} asks first: Enter opens a pane, what
you type goes into the link, encoded, and Enter again opens it. Five ship
ready to use: Google, DuckDuckGo, Wikipedia, YouTube, GitHub. Type wiki,
Enter, a term, Enter.
They are one file, ~/.config/swoop/quicklinks.tsv: name, link, app,
tab-separated, one per line. Edit it, or let the tool:
swoop-links add 'Home' ~ Finder
swoop-links add 'npm' 'https://www.npmjs.com/search?q={argument}'
swoop-links import ~/Downloads/quicklinks.json # a JSON export: name, link, openWith
swoop-links defaults > ~/.config/swoop/quicklinks.tsv # start from the fivectrl-k on a quicklink copies the link, filled in, or deletes it.
Type emoji, Enter, then a name or a keyword: rocket, +1, flag jap,
arrow, euro, alpha. Every emoji and flag is there, and a few hundred
symbols: arrows, math, currency, punctuation, keyboard keys, box drawing,
Greek. What you used last comes first.
Enter pastes into the app you came from. The frame hides, then presses
cmd+V, but only when a text field has the focus (or the app shows none,
like a terminal); on a button or the desktop it copies and a
notification says so. The keystroke needs Accessibility for
swoop-shell-mac: the first paste brings up the system dialog, and until
it is on Enter copies. In a plain terminal, and on Linux for now, Enter
copies. ctrl-k copies, or pastes in one of the six skin
tones; the default tone is in Settings. Words of your own go in
~/.config/swoop/emoji.keywords, one line per character: 😀 grin happy.
A snippet is named text you paste often: a signature, an address, a reply.
Type snippets, Enter, for the pane: New snippet first, then every
snippet, filtered as you type. Or type a snippet's name straight at the
root. Enter pastes it into the app you came from,
the same way as emoji (only into a text field, Accessibility for
swoop-shell-mac; in a plain terminal, and on Linux for now, Enter
copies). Each snippet is a markdown file in
~/.config/swoop/snippets/: the first line is the name, an optional
keyword: line follows, and the rest is the text.
Signature
keyword: ;sig
Best,
{cursor}
Placeholders are filled when you paste:
| write | you get |
|---|---|
{date} |
today, 2026-09-29 |
{time} |
now, 21:15 |
{clipboard} |
whatever you copied last |
{uuid} |
a fresh id |
{cursor} |
nothing; the caret ends up here after the paste |
Anything else in braces stays as written. ctrl-k on a snippet edits it in your editor, copies it, makes a new one, or deletes it. To bring snippets from another launcher, or write one from the shell:
swoop-snippets import ~/Downloads/snippets.json # a JSON export: name, text, keyword
echo 'Thanks, {clipboard}' | swoop-snippets add 'Thanks' ';ty'Type notes, Enter: your notes, the most recently changed first, each
previewed as rendered markdown. Typing searches the titles and the text.
Enter opens the note in your editor, right in the panel; quit the editor
and the list is back, the preview showing your change. New note, at
the top, makes a note whose first line is what you typed, and opens it
the same way; with nothing typed, the note is named by the date and
time, 2026-09-28 21:15. The editor is the Editor setting (editor = nvim in the
config), else $EDITOR, else nano. ctrl-k opens the note in the app
that opens .md files instead, copies the text, shows the file in its
folder, or deletes it. An editor wants room: widen the list with the
divider keys, or drag the panel's edge.
Each note is a markdown file in ~/.local/share/swoop/notes/, and its
first line is its title. Any editor works, and so does any sync. Delete
moves the file to deleted/ in that folder, so you can get it back.
Type tasks, Enter. The open tasks are under headers by when they are
due: Past due, Today, Tomorrow, This week, This month,
Later, Unscheduled. A header shows only when it has a task; under
it the soonest comes first. The cursor skips the headers: Up and Down go
from task to task. Type to filter, and a header with no match
goes too. Type a task and press Enter to add it. End it
with today, tomorrow, a weekday, or a date like 2026-10-01, and
that is its due date: buy milk tomorrow. Enter on a task ticks it, and
you stay in the list. ctrl-k undoes, deletes, or copies, and Edit the list opens the whole file in your editor, in the panel.
A ticked task leaves the list. Done, the last row, opens the done
ones, the most recently done first; Back to open at the top, or Esc,
comes back. Enter on a done task opens it again.
This week is the rest of the calendar week after tomorrow, and This month
the rest of the month after that week. The week starts on Monday; week = sunday in the settings file, or Week starts on in Settings, makes it
Sunday.
The tasks are one markdown checklist, ~/.local/share/swoop/tasks.md,
so any editor can change it too:
- [ ] buy milk due: 2026-09-29
- [x] call the bank
On a Mac, reminders is the same view over Apple Reminders: every list,
under the same headers. Enter completes a reminder; typed text adds one
to your default list, with the same date words. The first time, macOS
asks to let swoop use Reminders.
Rows that move the window you were in: Left, Right, Top, and Bottom Half;
First, Center, and Last Third; First and Last Two Thirds; the four
quarters; Maximize, Almost Maximize, Reasonable Size, Center; Next and
Previous Display. Type left half, Enter. The launcher's panel does not
take focus, so the app you came from is the one that moves. The preview
names the window and the frame it will get.
On macOS this needs Accessibility. The first time, Enter opens System Settings, Privacy & Security, Accessibility: turn on swoop-shell-mac (or the terminal swoop runs in), and run the row again. Already on and still refused? Remove it with the minus button and add it again: a new build needs a new grant. Other systems come with their frames.
The list starts with the five things you opened most recently, marked
"recent", then everything else in its usual order. Every Enter that opens
something is one line in ~/.local/state/swoop/usage.jsonl, yours alone
and never sent anywhere. Type stats for the whole picture: what you open
most, how often, when last, and a fortnight by day. ctrl-k there clears
the log; Settings turns the group off.
Type settings and press Enter, or cmd+, in the frame (alt+, in a
terminal). One row per setting, the current value beside it; Enter on a row
to change it: the hotkey, the preview width, the AI model, whether the model
may search the web, the editor notes and tasks open in, the day the week starts on, what
draws the transcript. Typing filters the rows by words, in any order, in a
title or a value: key api finds API key, nvim finds Editor when that is
the editor. A word's letters need only be there in order, so akey finds
API key too, and the best match comes first. Every view filters this way:
Tasks, Notes, Snippets, Emoji, a keyword's rows. Extensions lists every
extension with a box for on or off; Enter turns one off, and its rows leave
the root until you turn it back on (off = reminders, tasks in the file;
Settings itself cannot be turned off). Every value is one line in
~/.config/swoop/config, which you can also edit by hand. The frame watches
that file, so a new hotkey works within a second, no restart.
One thing: swoop-clipd, the clipboard watcher. The launcher starts it the
first time and it keeps running, polling the clipboard a few times a second
and appending new text to ~/.local/share/swoop/clipboard/history.jsonl,
readable by you only. Two things it never keeps: a copy that carries the
"concealed" mark some password managers set, and a copy made while an app on
the ignore list is in front. The list defaults to the known password managers;
write your own, one bundle id per line, at ~/.config/swoop/clipboard.ignore.
Copy a password from your manager and run bin/swoop-clipd types to see what
it marks, and osascript -e 'id of app "Its Name"' to get its bundle id.
If a manager still gets through, start the watcher with SWOOP_CLIPD_DEBUG=1
and read watcher.log next to the history: each change is logged with the app
in front and the marks seen, never the text.
swoop-clipd status says whether it is running; swoop-clipd delete <id> and
swoop-clipd clear remove entries; kill it if you would rather it did not run.
The contract is CONTRACT.md: every verb, row kind, file
and variable, what the launcher promises and what an extension must do,
with a version number at the top. swoop-check <folder> runs your
extension the way the launcher does and names each rule it breaks. What
follows here is the contract in brief.
An extension is a folder with one program in it, named the same:
~/.config/swoop/extensions/hello/hello
The program answers four commands. Each is one run, then exit:
hello list # print one line per result
hello preview <id> # print the right-hand pane for one result
hello run <id> [action] # do it; the action is one of yours, from below
hello view <id> [query] # print the rows of a pane, for a row of kind "view"
hello actions <id> # optional: what ctrl-k offers on that row
An action row's id is the action's name, and its kind says what happens
after: action closes the launcher, refresh runs it and comes back to
the list, reloaded. Clipboard History's Delete is a refresh.
A row of kind view opens a pane instead of running: the launcher asks
view for its rows, again on every keystroke, and shows exactly what comes
back. extensions/define/define is one: Enter on "Define Word" and you are
typing into the dictionary. Inside a pane, a row of kind toggle runs
on Enter and the pane stays, reloaded: that is how Tasks ticks a task. A
row of kind group is a header over the rows below it: that is Today
over the tasks due today. The launcher draws its title at the left edge,
and the cursor skips it. A
row or action of kind terminal gets the whole terminal while it runs, and
the launcher comes back after, reloaded, the preview redrawn: that is how
Notes opens an editor.
The launcher does not filter a pane's rows: the view command gets the
text in the bar and prints what should show. swoop-match does that the
way the bundled views do. Print every row and pipe them through it:
rows | swoop-match "$3"
It keeps the rows where every word typed matches the title or the
subtitle, in any order, a word's letters in order (akey for API key),
the best match first. A group header with no matching row under it is
left out. extensions/reminders/reminders filters with it.
A file called keyword beside the program, holding one word, gives the
extension a keyword: hello and a space in the bar then asks only it. When
its list is one row of kind view, the keyword opens that view.
Three more things are files beside the program too. Ask AI and Settings are built from them, and the launcher knows neither by name:
key tab ask Ask AI # a key, the view it opens, its title
views ask bar=prompt preview=wrap,follow # what that view's pane is like
- A key. Each line of
keyclaims one: the key opens that view from anywhere, as Enter on its row would. The keys to claim aretab,shift-tab,f1tof12, andalt-with a letter, a digit,,,.or/. When two extensions claim one key, the first by name has it, andswoop statussays who has which. An extension turned off in Settings gives its key up. The keyword of an extension with a key opens the same view - A bar that is a prompt.
bar=prompton a view's line inviews: typing does not filter or reload the rows, and Enter runshello send <id> <text>with the row under the cursor and the text. Exit 0 and the bar clears and the rows are listed again; any other exit leaves the text where it is.sendreturns at once. For an answer that takes time, leave a worker running and have it ask fzf to redraw, withrefresh-previeworreload(swoop-nav rows)posted to the socket in$FZF_SOCKwith the key in$FZF_API_KEY - A preview of its own.
preview=on a view's line: a width in percent,wrapfor prose,followto keep the end of a growing text in view. It holds while the pane is open
Two more files beside the program are for a long list that does not change
while the launcher is open. cache, holding the word run, has list
asked once per launch and the rows kept for the run, instead of on every
keystroke. icons, holding the word id, says each row's id is the path
of a file, and the row shows that file's icon in place of its glyph; it
needs cache. extensions/apps uses both: the apps are an extension like
the rest, and can be turned off in Settings.
A result line is five fields separated by tabs. Only the last three are shown:
id kind icon title subtitle
That is the contract in brief, and CONTRACT.md is all of
it. A shell script is enough; extensions/system/system
in this repository is one, and it is what puts Sleep and Lock Screen in the
list. Any language works, as long as it starts fast: list runs when the
launcher opens and preview on every cursor move.
Writing an Extension builds one from nothing, a step at a time, and tries each step in the launcher.
swoop is one tool on swoopkit, and the kit makes others. One command
makes a tool of your own, a folder with its own name, its own folders
under ~/.config and none of swoop's rows:
swoop new mytool # bin/swoop new mytool, from a checkout
mytool/bin/mytool # three rows; type to filter, Enter runs, Esc quits
mytool/bin/mytool start # on a Mac: its own frame, beside swoop'sThe folder holds tool, the file with its name, title, id and hotkey;
extensions/hello/hello, one extension in ten lines of shell; bin/mytool,
the command, which names the tool file and runs the kit's launcher; and a
README. The files come from templates/tool in this repository, and
make test makes a tool from them and checks it.
An extension learns the tool it runs under from SWOOP_TOOL_NAME, which
the launcher sets from the tool file, so one that keeps a file of its
own puts it in that tool's folder and runs unchanged under any tool.
start gives the tool a frame of its own on a Mac: a copy of the kit's
frame signed under the tool's id, and two launchd agents under that
id. It runs beside swoop's, each with its own process, key, folders and
Accessibility entry; mytool stop leaves swoop running, and swoop status and mytool status each show their own.
Build Your Own Tool has the rest: what each file is for, how to add an extension, how to name the frame, and how to ship it.
