Skip to content

Repository files navigation

swoop

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.

swoop: filtering apps, the action menu, the calculator, and Ask AI answering a question

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.

Install

With Homebrew, on a Mac or on Linux:

brew install beatzball/tap/swoop
brew services start swoop   # Mac: the frame at login, alt+shift+space

Or with nothing but curl:

curl -fsSL https://raw.githubusercontent.com/beatzball/swoop/main/scripts/get | bash

No 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.

From a checkout

git clone https://github.com/beatzball/swoop.git && cd swoop && make install

The 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.

Try it in any terminal

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 quits

On 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
fi

Take those lines out again to get a plain quick terminal back.

The macOS frame

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-mac

Then 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.

Keywords

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.

Ask AI

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 40

And 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

Quicklinks

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 five

ctrl-k on a quicklink copies the link, filled in, or deletes it.

Emoji and symbols

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.

Snippets

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'

Notes

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.

Tasks

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.

Window management

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.

Used recently, and Stats

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.

Settings

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.

What runs when the launcher is closed

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.

Write an extension

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 key claims one: the key opens that view from anywhere, as Enter on its row would. The keys to claim are tab, shift-tab, f1 to f12, and alt- with a letter, a digit, ,, . or /. When two extensions claim one key, the first by name has it, and swoop status says 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=prompt on a view's line in views: typing does not filter or reload the rows, and Enter runs hello 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. send returns at once. For an answer that takes time, leave a worker running and have it ask fzf to redraw, with refresh-preview or reload(swoop-nav rows) posted to the socket in $FZF_SOCK with the key in $FZF_API_KEY
  • A preview of its own. preview= on a view's line: a width in percent, wrap for prose, follow to 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.

Build your own tool

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's

The 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.

License

MIT

About

A keyboard launcher built the Unix way: fzf finds, libghostty draws, and every extension is a program that prints lines.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages