The search bar as a shell

Pipelines

The overlay's search bar doubles as a small shell. A source, a chain of filters, and somewhere to send the result — written the way you would write it in a terminal.

Text that starts with $ or |, or that contains an unquoted |, is a pipeline rather than a search. Everything else searches as before: neither character means anything to the fuzzy finder, so taking them over cannot change what an existing search does.

A pipeline is a source followed by filters:

$1 | trim | upper
$jiraBaseURL ++ /browse
| trim                       ← a leading | starts from $1
"hello" | upper              ← or start from text written out
$1 | trim > $tidy            ← the result goes to a variable
$1 | split , >> @recipients  ← or onto the end of a list
$1 + 5                       ← an operator separates steps like a |
$1 ++ " (copied)"            ← and ++ joins text on the end
$1 ++ $($2 | strftime %F)    ← a $( … ) is a pipeline inside a pipeline

Text in quotes is a pipeline too when it does something: when a step follows it, or when there is a $ inside it. A quoted phrase on its own is still a search, since quotes round a phrase are an ordinary thing to type into a search box.

References

The clipboard history is addressable as if it were variables. $1 is the most recent item, $2 the one before it, and so on for as far as the history goes. $name is the variable of that name, and ${name} is the same thing — the braces are for a name the text around it would otherwise run into.

Because positions and names share one namespace, no variable may be named a whole number: such a name could never be reached, so the Variables pane refuses it, storing one from the overlay is refused, and the engines ignore it.

$!name is a secret — a variable kept encrypted, which reads only once you have proved you are there. The ! counts at the head of a name and nowhere else, so $not!aname is one name with a ! in the middle of it. @!name reads the same secret as a list, by its lines.

References expand inside filter arguments too, whatever they are quoted with. $$ is a literal $, and a reference to something that does not exist is left as written.

Note that a pipeline's result becomes the newest history entry, so $1 then refers to it and everything below shifts down by one.

Text written out

A pipeline can start from text in quotes instead of from something that was copied:

"hello" | upper
'Dear $name,' ++ $1
"hello@$($domain | trim)"

Either quote does it, and the quotes are the whole of the source — "a" b is two things where one was expected, and is refused. A | inside the quotes is a character rather than a separator, so a whole command can sit inside one.

What is written there is expanded exactly as a filter's argument is: $name, $1, ${name} and $( … ) all stand for what they name, and $$ is a literal $. Both quote styles expand alike, which is the rule everywhere else in a command.

Text written out is the one source that is a whole pipeline on its own — every other one is somewhere to read from. So a pipeline action of just "hello" is an action that replaces the text with hello. It is not somewhere a result can be sent: > "name" names nothing, and is refused.

eval: the same expansion, on text you stored

Quotes only expand what you type into the bar. eval does the same thing to text that was already there — it reads what it is handed as if it had been written out in quotes:

$greeting                  Dear $name,
$greeting | eval           Dear Marion,

That is the difference between storing a sentence and storing a template. A variable full of $s is just characters until this runs on it, and then every $name, $1, ${name} and $( … ) in it stands for what it names:

$line | eval               Sent $(| now | strftime %H:%M)
$1 | eval                  expand what you just copied

A name that stands for nothing is left exactly as written, the same answer an argument gets — so a $ that was only ever a dollar sign survives, and $$ is how to write one that must not expand.

It may not reach itself: text that evaluates to text that evaluates would never finish, so a second eval inside one is refused rather than run. Over a list it is map that runs it, an item at a time.

Filters

A filter is an action, named by its keyword. A keyword is a single word, so the first word of a step is always the filter and everything after it is arguments — join-lines , is the filter join-lines with the argument ,.

Quoting keeps an argument as one token, and decides nothing else. Single and double quotes are interchangeable, and references expand inside both: 'hello ${who}' and "hello ${who}" are the same argument. This is deliberately not the shell's rule. A quote may sit in the middle of a token, so a"b c"d is the one argument ab cd, and "" is an empty one. Which leaves $$ as the way to write a literal $'$5.00' is the fifth clipboard item followed by .00, and '$$5.00' is the price.

The first argument a filter receives is always the text from the previous stage, or the value of the source for the first filter. For a script that is $1, exactly as when a trigger runs it, and the pipeline's extra arguments follow as $2 onwards.

An unknown filter, or one that fails, stops the pipeline and is reported — except for a name still being typed at the very end of the bar, which is not an error yet. That one is previewed as far as the filters before it, and the rows below offer the names it could become.

Arithmetic

The four operators +, -, * and / separate steps in place of a |, so that a sum reads as one:

$1 + 5
$1 * 1.2 - 0.5
$subtotal + $shipping
$1 | trim + 5      ← mixed freely with ordinary filters

That is the whole of the syntax. Each operator is an ordinary filter whose keyword is the operator itself and whose argument is the number, so $1 + 5 and $1 | + 5 are the same command written two ways.

Which means there is no operator precedence. Steps run in the order they are written, arithmetic included, because each sum is a whole step rather than part of an expression: $1 + 2 * 3 multiplies the sum by three and does not add six. This is the one place the search bar reads differently from the thing it looks like, so a command mixing + or - with * or / warns, spelling out both readings.

An operator needs a space either side of it. That is what keeps dateadd -30min, sed -E and join-lines the arguments and keywords they are, and it is why $1 +5 is refused with a message saying to space it.

Arithmetic does not make a query a pipeline: swift + xcode is still a search, because a + in the middle of a sentence is far likelier to be one than a sum. A source is what starts a pipeline, as always.

Numbers are read and written plainly — a . for the point, nothing between the thousands, a leading - for a negative — and text that is not a number is an error rather than a guess: 12kg, $4.99 and an empty clipboard all fail and leave the text alone, as does dividing by zero. The arithmetic is decimal, so 0.1 + 0.2 is 0.3; division is carried to ten decimal places and rounded there.

Joining text

++ is the fifth operator and the one that is not a sum: it joins what follows onto the end of the text, with nothing between.

$1 ++ "!"
$1 | ++ "!"              ← the same thing, since ++ is an ordinary filter
$1 ++ " — " ++ $2        ← two clipboard items with a dash between them
$1 | trim ++ $(| strftime %F)

The words after it are the text to join, the way a date format is, so quotes are needed only to keep the spaces at either end — $1 ++ and more needs none. It needs a space either side like the others.

Commands inside commands

$( … ) is a pipeline written inside another one. It runs on its own and stands for the text it produced, wherever a value goes — an argument to a filter, or the source the outer pipeline reads from.

$1 | trim ++ $($2 | dateadd +2min | strftime %m/%d/%y)
$($1 | trim) | upper                    ← as the source
$greeting ++ $($($1 | trim) | upper)    ← and they nest

The parentheses hold the whole of the command inside them: its | does not split the pipeline around it, its whitespace does not split the argument it is part of — so no quotes are needed around one — and a > inside is that command's own redirect. Parentheses in the text inside have to balance or be quoted.

A leading | inside means what it means outside. A > $name inside still writes that variable, and the substitution stands for the text that went into it. A forget inside keeps the run out of the history and drops the entry that command read from, which need not be the one the outer pipeline is reading.

A $( with no ) yet is not an error — it is a command still being typed, so nothing is previewed until it is closed, and the rows below the bar go on offering the filters and variables for the command inside the parentheses, which is where the caret is. Right after the ( the rows are the ones an empty bar shows: the whole clipboard history, and the variables and actions a word matches. Choosing one writes it inside the parentheses and nowhere else.

Sending the result somewhere

A pipeline may end with > $name, which stores the result in that variable — keeping the old value in the variable's history — instead of putting it on the clipboard, the way a shell redirect writes to a file instead of the terminal. The clipboard is left alone.

>> is the same redirect, writing to the end of what is there rather than over it.

$1 >> @shopping            adds the newest copy to the end
$1 | split , >> @shopping  adds each of its items
$1 | split , > @shopping   the list becomes those items
$1 >> $notes               joins the text onto the end of a variable

A name that is not a list yet is made into one. What is added is the value the pipeline produced: one item for plain text, however many lines are in it, and each item where the result was itself a list — split is how several are added at once.

The destination has to be a variable or a list. > $1 is refused, because a clipboard position is where values come from rather than somewhere they can be written; so is a bare > with nothing after it. A > inside quotes is an ordinary argument.

Both arrows keep what was there in the entry's history, like any other change to a variable, so @shopping | undo 1 is the list as it was before the last thing was added.

Lists

A list holds several pieces of text under one name. It is written @name, and it is the same entry $name is: @shopping is the items, $shopping is the text they make. That is the whole of the relationship, because a list is text with a shape — its text is its elements, one to a line. Every other filter goes on reading a list as the text it looks like, so sort-lines and unique-lines need no list-shaped twin.

Four things produce one:

@shopping                     a list by name
@<$1, $greeting, and this>    a list written out
$1 | split ,                  the text, divided
⌘↩ on a row                   which writes @<$2, > into the bar

@<…> holds its elements as they were typed and expands them when the command runs, so @<$1, $2> names two things rather than spelling two. Only commas at the top level divide it. A trailing comma is room left for the next item rather than an empty item, which is what lets @<a, > be a list of one — the shape ⌘↩ leaves in the bar while more rows are being gathered. @<> is the empty list.

An @ starts a command the way a $ and a | do, but only at the front: an @ in the middle of a query is an address or a handle far more often than it is a list.

One item, @name[0]

Brackets on the reference take a single element. They count from zero[0] is the first — and a negative counts from the other end:

@shopping[0]              the first item
@shopping[1]              the second
@shopping[-1]             the last one
@shopping[1] | upper      a value like any other from there on

Everything that indexes a list counts from zero — the brackets, item, index, the position on an action, and the numbers beside a list's items in the Variables pane. So @shopping[1] and @shopping | item 1 are the same element, and -1 is the last one everywhere. Ask for an item the list does not have and the error says where the numbering runs — There is no item 3: the list holds 3, numbered 0 to 2 — because reaching for 3 and meaning the third is the easy mistake, and on a longer list it would quietly hand you the fourth instead.

@shopping[1] and @shopping | item 1 are two ways of writing one request, and either gives you the item's text rather than a list holding it. The brackets are worth reaching for when the item is what a command starts from, where the filter form reads backwards: @shopping[-1] ++ "!" says it in the order you thought it.

Plain text is read as its lines here as everywhere, so @notes[1] is the second line of an ordinary variable. A name that is no list at all is a different error from an index the list does not reach — the two are told apart rather than run together.

Three details worth knowing:

  • The -1 is an index, not a sum. Elsewhere $1 -5 is arithmetic written without its space and the bar says so; inside the brackets nothing is, because they have already said what is in them. Written after them it still is, so @shopping[1] + 5 needs its spaces like any other sum.
  • @{name} names a list outright. If you have a variable actually called sizes[2], @{sizes[2]} reaches it — braces mean the whole of this is the name, which is what they are for in ${last-1} too.
  • An item is a value, not somewhere to put one. > @name[0] is refused and points at > @name; so is map @shopping[0] upper, which names one item where a list was wanted.

An unfinished @name[ is a command half typed rather than one spelt wrong, so nothing is previewed and nothing is complained about until you close the bracket.

Text that meets something wanting a list is read as its lines, so $1 | join runs a column into a sentence without splitting it first. The coercion goes that way only. A list sent to a $name is an error, named where it happens and shown in the bar before — a variable holds one thing, and keeping one item while losing the rest is not something to do quietly. There are three ways to say which one thing you meant, and each of them says it out loud:

@shopping | join > $line     all of them, run together
@shopping | first > $next    one of them, chosen
$shopping > $line            the same entry read as text

Running a filter over every item

map runs another filter over each element and hands back a list of what each one gave. Its first argument is the filter to run; anything after that belongs to that filter:

@recipients | map upper
@recipients | map trim
| map @recipients upper       naming the list instead of handing it over
$1 | split , | map trim

The result is always as long as the list that went in — one answer to one item, which is the whole of what mapping means. A filter that produces a list of its own therefore lands as a single item holding that list's text, rather than melting into the items around it.

An operator has to be quoted, because a pipeline splits its steps on one: write map "+" 5 and map "++" "!", not map + 5.

The filter it exists for is eval. A list of templates becomes a list of what they say:

@letters | map eval
@rows | map eval > @filled

Text is read as its lines here as everywhere, so $1 | map trim tidies a column and gives you a list of it.

A list on the clipboard

When a command's result is a list, the first item goes on the clipboard and the rest wait behind it. Each paste puts the next one there, so a row of values goes into a row of fields with ⌘V and nothing else — which is what $1 | split , is for. The HUD says which item is up.

Copying anything new interrupts it: a fresh copy is a fresh intention, and a queue that went on feeding items into whatever came next would be the app arguing with you. Reaching the end ends it too, and starting another list replaces whatever was left of the first.

Gathering copies into one list

You do not have to write a command to get a list. ⌘⌥A starts gathering: every copy you make from then on is kept in one list, until you press it again.

Pressing it again does not just stop — it puts the gathered list on the clipboard, first item first. So the whole thing is:

⌘⌥A          start gathering
              copy, copy, copy — from anywhere, in any app
⌘⌥A          stop, and the first one is on the clipboard
⌘V ⌘V ⌘V     the rest follow, one per paste

Everything in this section applies to it from there — interrupting, continue, index.

The gathered list is @_

What you gathered is kept in a list called @_, so it does not disappear the moment you copy something else. It is an ordinary list, which means:

@_ > @shopping     keep this gathering under a real name
continue @_        pick it up where you were interrupted
@_ | first         read it without spending it

That is the difference between a stack you get one go at and one you can keep.

Three more things worth knowing:

  • Your copies are kept as normal too. The list is as well as the history, never instead of it, so abandoning a gathering half way through loses nothing.
  • Starting a new gathering replaces @_. The items are still in your history; it is the list that starts fresh. If a gathering was worth keeping, send it somewhere with a name before you start the next one.
  • One run keeps 50 copies, and says so when it reaches that. It also refuses to start while recording is paused, since nothing would be gathered.
  • Stopping does not paste anything by itself. You press ⌘⌥A while still in the app you were copying from, so an automatic paste would land the first item right back in it. The list goes on the clipboard and waits for your ⌘V.

Because gathering has no window of its own, three places show it is on: the HUD when it starts and stops, a banner across the top of the overlay with the count, and the menu bar item, which reads Paste 4 Gathered.

⌘⌥A can be changed in Preferences → General.

Reaching for the list again starts it over, from the first item. Carrying on is the rarer of the two, so it is the one that has to be said out loud:

continue @shopping     what has not been dealt yet
index @shopping 3      from the fourth item on

Being interrupted is the ordinary way to end up half way down a list, since something has to be copied to fill in the field beside it, and continue is how the rest of the form gets filled. What it hands over is the tail, so a second continue finds where the first left off.

Stepping the queue does not record anything: the list is already in the history as one entry, and nine entries for nine items would be the queue emptying the history it lives in.

A paste is recognised by watching for ⌘V, which is the only signal macOS offers. That needs the same Accessibility permission automatic pasting needs. Without it a list still lands on the clipboard first item first; it simply does not step on its own, and the rest of it is still in @name to be reached for.

Which of the two just happened is said in the HUD every time a list is dealt out: either the rest follow as you paste, or the rest need Accessibility permission to follow as you paste. The keyboard is watched only while a list is being dealt out, so granting the permission takes effect on the next list — there is no need to restart the app.

Running one

Substitute filters, arithmetic and the date filters preview live as the pipeline is typed: they are pure and instant. Script filters do not, because a half-typed argument would be run for real; the bar shows the text as far as it got and which filter is waiting. runs the whole pipeline including scripts, and the result becomes the clipboard contents and the newest entry in the history — or goes to the variable named by a trailing > $name.

A script may opt out of that caution with Run while a search-bar pipeline is being typed in the Actions pane, which is off by default and worth turning on only for scripts that are fast and change nothing.

only runs once there is nothing left to complete: while a name is still being typed — a filter's, or an entry's — it adds the highlighted one instead, so ↩ ↩ is type-a-few-letters then run. A reference that already names something is settled, so $1 and still puts the newest item on the clipboard.

What the rows offer while you type

While a pipeline is being typed the rows below the bar list whatever the end of it is asking for, and ⌘1⌘9, or fills the highlighted one in.

  • Where that is a filter — the step after the last | — the rows are matching filter names. A leading | reads the newest clipboard item, so there is no source to name and those rows are filters from the first keystroke.
  • Where it is a $, they are matching variables, by name alone. Contents are not searched and clipboard items are not listed: a pipeline reads those by position. Three places take one — the source, a $… argument, and the destination after a >.
  • Where it is a @, the same three places offer the lists instead. A @ lists only the lists, since offering a variable there would be offering a name that reads as something else, while a $ offers everything.

A bare $ lists no secrets, and $! lists nothing else. The ! is a second prompt, asked on purpose: mixing the two would put the secrets in front of everyone who typed a $.

Taking a row replaces only the reference being typed, leaving the step it belongs to and everything before it alone: $1 | join-lines $gre becomes $1 | join-lines $greeting.

The destination after a > is the one place does not finish a name, since a name that matches nothing is the ordinary case there. Its rows are an offer that or ⌘1⌘9 takes. The sigil need not have been typed yet — > tidy is a destination written wrong, and the names offered there are how it gets written right.

Command history

A pipeline that runs without error is remembered, without its source: only the chain of filters is kept, because the same chain is usually wanted again against something else. The last 100 are kept, in shell-history.json.

at the top of the list walks back through them, the gesture a shell has at an empty prompt: the first press puts the most recent command in the bar, each one after that goes further back, and comes forward again. Typing ends the walk. A command comes back written behind whatever source the bar already holds, so with $3 typed offers $3 | trim | upper.

The walk is offered only with the bar empty or holding a command: a search half typed is a different thing to be in the middle of, and writing a pipeline over it would lose the words you were still looking for.

Once a | has been typed, the bar offers the most recent past chain that carries on from what is written so far, drawn in grey past the caret. takes it; failing that fills in the highlighted filter name. Since also starts a command from a row, ⇥ ⇥ goes from a highlighted clipboard item to that item piped into the last filter chain that was run on anything.