diff --git a/.gitignore b/.gitignore
index 7dda67a..fac905b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,11 @@ __pycache__/
/config/dot/gtk-4.0/settings.ini
/config/dot/tmux/current-theme.conf
/config/dot/hypr/hyprlock.conf
+
+# Build products of the Vicinae extension. The source is the repository's; the
+# dependency tree and the bundle it produces are machine state, rebuilt by
+# `panama apps`.
+/config/local/share/vicinae/extensions/*/node_modules/
+/config/local/share/vicinae/extensions/*/dist/
+/config/local/share/vicinae/extensions/*/build/
+/config/local/share/vicinae/extensions/*/package-lock.json
diff --git a/README.md b/README.md
index c138ea1..2c01c57 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,7 @@ is what decides; anything not on it is a panel Panama owns itself.
|---|---|
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
-| `config/dot/vicinae/` | Raycast-style launcher, themed |
+| `config/dot/vicinae/` | Raycast-style launcher, themed. Its commands live in `config/local/share/vicinae/` — script commands, and one compiled extension that adds web search with live suggestions |
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
| `config/dot/xdg-desktop-portal/` | Portal backend routing |
@@ -84,11 +84,13 @@ config/
copy/ Files copied verbatim over / (needs sudo)
dot/ Symlinked into ~/.config
firefox/ Vendored Firefox chrome, linked into the browser profile
- local/ Icons and the cursor theme, linked into ~/.local/share
+ local/ Icons, the cursor theme, and the launcher's commands and
+ extensions, linked into ~/.local/share
old/ Backups of whatever was replaced (gitignored)
wallpapers/ Copied into ~/Pictures/Wallpapers when absent
setup/
apps/ Applications built from source, one file each
+ lib/ Shared by more than one stage; the extras catalog reader
packages/ One package per line; extras/ holds the optional categories
scripts/ Run in order by ./install
tests/ Contracts. See below
@@ -97,7 +99,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
-126 of them, under `tests/`. Run the lot, or a subset by pattern:
+128 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh
panama test # everything
diff --git a/config/dot/hypr/input.lua b/config/dot/hypr/input.lua
index e27097c..cea64aa 100644
--- a/config/dot/hypr/input.lua
+++ b/config/dot/hypr/input.lua
@@ -44,7 +44,28 @@ hl.config({
},
})
--- Desktop machine: no touchpad, no gestures worth wiring. If a laptop ever
--- runs this config, add touchpad settings in overrides.lua.
+-- ── Touchpad gestures ───────────────────────────────────────────────────────
+--
+-- GNOME's gestures, reproduced: three fingers sideways moves between
+-- workspaces, three fingers up opens the overview, three fingers down closes
+-- it. That is the same muscle memory the keybinds were built to preserve.
+--
+-- Registered unconditionally rather than behind a preference. Hyprland 0.56
+-- dropped `gestures:workspace_swipe` in favour of this `gesture` keyword, and a
+-- registration is read at config time -- so a toggle would need a reload to
+-- take effect, which is worse than the nothing these cost on a machine with no
+-- touchpad. What IS tunable at runtime lives in Settings: how far a swipe has
+-- to travel, and which way round it goes.
+--
+-- open and close rather than toggle twice: with a toggle on both directions,
+-- swiping up from an already-open overview would close it, and swiping down
+-- would reopen it. GNOME does not do that, and neither does this.
+local overview = function(fn)
+ return function() hl.exec_cmd("qs ipc call overview " .. fn) end
+end
+
+hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
+hl.gesture({ fingers = 3, direction = "up", action = overview("open") })
+hl.gesture({ fingers = 3, direction = "down", action = overview("close") })
return true
diff --git a/config/dot/hypr/looks.lua b/config/dot/hypr/looks.lua
index 3a97d90..7ac02f0 100644
--- a/config/dot/hypr/looks.lua
+++ b/config/dot/hypr/looks.lua
@@ -195,6 +195,18 @@ hl.config({
-- Don't let apps steal focus by shouting; matches GNOME's behavior.
focus_on_activate = false,
+
+ -- Window swallowing: a terminal hides itself while a graphical
+ -- application launched from it is open, and comes back when that
+ -- application exits. Off by default -- it is a real change in how the
+ -- desktop behaves, and one that is confusing rather than broken if you
+ -- did not ask for it: your terminal appears to vanish.
+ --
+ -- The regex is narrow on purpose. Anything matching it can swallow, so
+ -- a permissive pattern means windows disappearing in cases nobody
+ -- intended. Only the two terminals this desktop actually ships.
+ enable_swallow = prefs.get("windowSwallow", false),
+ swallow_regex = "^(kitty|com\\.mitchellh\\.ghostty)$",
},
render = {
diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml
index cd7b4fc..618f5dc 100644
--- a/config/dot/quickshell/config/PreferenceSchema.qml
+++ b/config/dot/quickshell/config/PreferenceSchema.qml
@@ -672,6 +672,24 @@ Singleton {
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
},
+ // Tuning for the three-finger gestures registered in hypr/input.lua.
+ // The gestures themselves are not settings: Hyprland reads a gesture
+ // registration at config time, so switching one on would need a reload,
+ // and these two are the parts it will accept at runtime.
+ {
+ key: "swipeDistance", type: "int", def: 300, min: 100, max: 800, step: 20,
+ unit: "px", group: "touchpad",
+ label: "Swipe distance",
+ detail: "How far a three-finger swipe must travel to change workspace",
+ hypr: { path: ["gestures", "workspace_swipe_distance"], option: "gestures:workspace_swipe_distance", readAs: "int" }
+ },
+ {
+ key: "swipeInvert", type: "bool", def: true, group: "touchpad",
+ label: "Natural swipe direction",
+ detail: "Swiping left moves to the workspace on the right, as content follows your fingers",
+ hypr: { path: ["gestures", "workspace_swipe_invert"], option: "gestures:workspace_swipe_invert", readAs: "bool" }
+ },
+
// ── Multitasking ────────────────────────────────────────────────────
//
// GNOME's Multitasking panel, in Hyprland's terms. The Desktop page
@@ -733,6 +751,12 @@ Singleton {
detail: "An application asking for attention is switched to, rather than only highlighted",
hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
},
+ {
+ key: "windowSwallow", type: "bool", def: false, group: "multitasking",
+ label: "Hide the terminal that launched a window",
+ detail: "A terminal disappears while an application started from it is open, and returns when it closes",
+ hypr: { path: ["misc", "enable_swallow"], option: "misc:enable_swallow", readAs: "bool" }
+ },
{
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
label: "Pointer changes active display",
diff --git a/config/dot/quickshell/modules/settings/DesktopPage.qml b/config/dot/quickshell/modules/settings/DesktopPage.qml
index 1a111c3..e2524a4 100644
--- a/config/dot/quickshell/modules/settings/DesktopPage.qml
+++ b/config/dot/quickshell/modules/settings/DesktopPage.qml
@@ -192,7 +192,8 @@ SettingsPage {
ToggleRow { setting: "workspaceBackAndForth" }
ToggleRow { setting: "allowWorkspaceCycles" }
ToggleRow { setting: "focusOnActivate" }
- ToggleRow { setting: "mouseMoveFocusesMonitor"; divider: false }
+ ToggleRow { setting: "mouseMoveFocusesMonitor" }
+ ToggleRow { setting: "windowSwallow"; divider: false }
}
SettingsCard {
diff --git a/config/dot/quickshell/modules/settings/MousePage.qml b/config/dot/quickshell/modules/settings/MousePage.qml
index bd9d552..7f3d1b1 100644
--- a/config/dot/quickshell/modules/settings/MousePage.qml
+++ b/config/dot/quickshell/modules/settings/MousePage.qml
@@ -52,6 +52,15 @@ SettingsPage {
ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false }
}
+ SettingsCard {
+ visible: InputDevices.hasTouchpad
+ title: "Gestures"
+ subtitle: "Three fingers sideways moves between workspaces, up opens the overview, and down closes it — the same gestures GNOME used. Which gestures exist is fixed by the compositor at startup; what they feel like is here."
+
+ SliderRow { setting: "swipeDistance" }
+ ToggleRow { setting: "swipeInvert"; divider: false }
+ }
+
SettingsCard {
title: "Pointer"
diff --git a/config/local/share/vicinae/extensions/panama-search/assets/extension_icon.svg b/config/local/share/vicinae/extensions/panama-search/assets/extension_icon.svg
new file mode 100644
index 0000000..b7fc2dd
--- /dev/null
+++ b/config/local/share/vicinae/extensions/panama-search/assets/extension_icon.svg
@@ -0,0 +1,42 @@
+
+
+
diff --git a/config/local/share/vicinae/extensions/panama-search/package.json b/config/local/share/vicinae/extensions/panama-search/package.json
new file mode 100644
index 0000000..2d29a21
--- /dev/null
+++ b/config/local/share/vicinae/extensions/panama-search/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "panama-search",
+ "title": "Panama Search",
+ "description": "Web search from the launcher, with live suggestions and bang syntax",
+ "categories": ["Web"],
+ "license": "MIT",
+ "author": "gib",
+ "icon": "extension_icon.svg",
+ "commands": [
+ {
+ "name": "search",
+ "title": "Search the web",
+ "subtitle": "Panama",
+ "description": "Search with live suggestions, opened in the default browser",
+ "mode": "view"
+ }
+ ],
+ "preferences": [],
+ "scripts": {
+ "build": "vici build",
+ "dev": "vici develop"
+ },
+ "dependencies": {
+ "@vicinae/api": "^0.8.2"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.0",
+ "typescript": "^5.9.2"
+ }
+}
diff --git a/config/local/share/vicinae/extensions/panama-search/src/search.tsx b/config/local/share/vicinae/extensions/panama-search/src/search.tsx
new file mode 100644
index 0000000..23d894d
--- /dev/null
+++ b/config/local/share/vicinae/extensions/panama-search/src/search.tsx
@@ -0,0 +1,121 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Action, ActionPanel, Icon, List } from "@vicinae/api";
+
+// Where a search goes. A client-side bang redirector: it resolves
+// DuckDuckGo-style bangs in the browser rather than round-tripping through a
+// search engine to be bounced, and falls through to an ordinary search when
+// there is no bang. Bang support is a property of this URL, not of this
+// extension, which is why there is no bang parsing below.
+const ENGINE = "https://bang.gibbyb.com/?q=";
+
+// The suggestion endpoint Firefox's address bar uses. Answers with
+// [query, [suggestion, ...], ...] and needs no key.
+const SUGGEST = "https://suggestqueries.google.com/complete/search?client=firefox&q=";
+
+// Long enough that typing a word is one request rather than one per keystroke,
+// short enough that the list still feels attached to the keyboard.
+const DEBOUNCE_MS = 150;
+
+const searchUrl = (query: string) => ENGINE + encodeURIComponent(query);
+
+export default function SearchCommand() {
+ const [query, setQuery] = useState("");
+ const [suggestions, setSuggestions] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ // Both are refs rather than state: changing them must not re-render, and the
+ // cleanup below needs whatever the latest one is, not the one captured when
+ // an effect happened to run.
+ const inFlight = useRef(null);
+ const debounce = useRef | null>(null);
+
+ // Typing is faster than the network, so responses can arrive out of order. An
+ // older, slower answer landing after a newer one would leave the list showing
+ // suggestions for a query that is no longer on screen -- so each new keystroke
+ // aborts the request before it.
+ useEffect(() => () => {
+ inFlight.current?.abort();
+ if (debounce.current) clearTimeout(debounce.current);
+ }, []);
+
+ const onSearchTextChange = useCallback((text: string) => {
+ setQuery(text);
+ if (debounce.current) clearTimeout(debounce.current);
+ inFlight.current?.abort();
+
+ const trimmed = text.trim();
+
+ // A bang says *where* to search rather than what for, so Google's guesses
+ // about it are noise: "!yt" suggests nothing anybody wants.
+ if (trimmed === "" || trimmed.startsWith("!")) {
+ setSuggestions([]);
+ setLoading(false);
+ return;
+ }
+
+ setLoading(true);
+ debounce.current = setTimeout(async () => {
+ const controller = new AbortController();
+ inFlight.current = controller;
+ try {
+ const response = await fetch(SUGGEST + encodeURIComponent(trimmed), {
+ signal: controller.signal,
+ });
+ const body = (await response.json()) as unknown;
+ const returned = Array.isArray(body) ? body[1] : undefined;
+ setSuggestions(
+ Array.isArray(returned)
+ ? returned.filter((entry): entry is string => typeof entry === "string")
+ : [],
+ );
+ } catch {
+ // Offline, rate-limited, or aborted by the next keystroke. The typed
+ // query is still searchable, so this costs suggestions rather than the
+ // command -- which is the right way round for something you reach for
+ // when you already know what you want.
+ setSuggestions([]);
+ } finally {
+ if (inFlight.current === controller) setLoading(false);
+ }
+ }, DEBOUNCE_MS);
+ }, []);
+
+ const trimmed = query.trim();
+
+ return (
+
+ {trimmed !== "" && (
+
+
+
+ }
+ />
+ )}
+
+ {suggestions
+ .filter((suggestion) => suggestion !== trimmed)
+ .map((suggestion) => (
+
+
+
+ }
+ />
+ ))}
+
+
+ );
+}
diff --git a/config/local/share/vicinae/extensions/panama-search/tsconfig.json b/config/local/share/vicinae/extensions/panama-search/tsconfig.json
new file mode 100644
index 0000000..bc6a73e
--- /dev/null
+++ b/config/local/share/vicinae/extensions/panama-search/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "target": "ES2022",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true
+ },
+ "include": ["src/**/*"]
+}
diff --git a/config/local/share/vicinae/scripts/search-web b/config/local/share/vicinae/scripts/search-web
new file mode 100755
index 0000000..6557eea
--- /dev/null
+++ b/config/local/share/vicinae/scripts/search-web
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+# @vicinae.schemaVersion 1
+# @vicinae.title Search the web
+# @vicinae.mode silent
+# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
+# @vicinae.description Search from the launcher, with bang syntax, in the default browser.
+# @vicinae.keywords ["search", "web", "google", "bang", "find"]
+# @vicinae.argument1 { "type": "text", "placeholder": "search, or !bang to jump", "percentEncoded": true }
+
+# Search without leaving the launcher.
+#
+# Make this the fallback command -- "Manage fallback commands" in Vicinae -- and
+# anything typed that matches nothing else offers this at the bottom of the
+# results. That designation is per-machine state Vicinae keeps in its own
+# database, so it is one click rather than something Panama ships.
+#
+# The engine is a client-side bang redirector: it resolves DuckDuckGo-style
+# bangs in the browser rather than round-tripping through a search engine to be
+# redirected, and falls through to a normal search when there is no bang. So
+# `!yt tiling` reaches YouTube directly, and bang support costs nothing here --
+# it is a property of where this points, not of the launcher.
+#
+# percentEncoded on the argument means Vicinae URL-encodes the query before it
+# arrives, which is what keeps `&`, `#` and spaces from truncating the search.
+#
+# xdg-open rather than a named browser: the default browser is already a setting
+# this desktop owns, on the Applications page, and naming one here would quietly
+# outrank it.
+exec xdg-open "https://bang.gibbyb.com/?q=$1"
diff --git a/docs/settings.md b/docs/settings.md
index 8ee886d..1aed787 100644
--- a/docs/settings.md
+++ b/docs/settings.md
@@ -4,7 +4,7 @@
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
after changing the schema; a contract fails when this copy is stale.
-133 settings across 27 groups. 67 of them are applied to the compositor and confirmed by reading the value back.
+136 settings across 27 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
## accessibility
@@ -181,6 +181,7 @@ Found on **Desktop & Dock**.
| **Switch back and forth** `workspaceBackAndForth` `binds:workspace_back_and_forth` | false | Selecting the workspace you are already on returns you to the previous one |
| **Wrap around at the ends** `allowWorkspaceCycles` `binds:allow_workspace_cycles` | false | Moving past the last workspace continues from the first |
| **Let applications take focus** `focusOnActivate` `misc:focus_on_activate` | false | An application asking for attention is switched to, rather than only highlighted |
+| **Hide the terminal that launched a window** `windowSwallow` `misc:enable_swallow` | false | A terminal disappears while an application started from it is open, and returns when it closes |
| **Pointer changes active display** `mouseMoveFocusesMonitor` `misc:mouse_move_focuses_monitor` | true | Moving the pointer to another display makes it the active one |
## nightLight
@@ -263,6 +264,8 @@ Found on **Mouse & Touchpad**.
| **Scroll speed** `touchpadScrollFactor` `input:touchpad:scroll_factor` | 1.0 | Multiplies how far a two-finger scroll travels. Range 0.1–4.0. |
| **Drag lock** `touchpadDragLock` `input:touchpad:drag_lock` | 0 | Keeps a tap-and-drag active when you lift a finger mid-drag Choices: Off, On, On, until you tap again. |
| **Middle-click by pressing both buttons** `touchpadMiddleButtonEmulation` `input:touchpad:middle_button_emulation` | false | Pressing left and right together acts as a middle click |
+| **Swipe distance** `swipeDistance` `gestures:workspace_swipe_distance` | 300 px | How far a three-finger swipe must travel to change workspace. Range 100–800. |
+| **Natural swipe direction** `swipeInvert` `gestures:workspace_swipe_invert` | true | Swiping left moves to the workspace on the right, as content follows your fingers |
## typography
diff --git a/setup/scripts/link-vicinae-scripts b/setup/scripts/link-vicinae-scripts
index f7c8982..fc5a19b 100755
--- a/setup/scripts/link-vicinae-scripts
+++ b/setup/scripts/link-vicinae-scripts
@@ -46,6 +46,43 @@ fi
ln -s "$source_dir" "$target_dir"
+# ── Extensions ───────────────────────────────────────────────────────────────
+#
+# Script commands are a file and a shebang, so they are linked. Extensions are
+# not: they are TypeScript that has to be compiled, and `vici build` writes its
+# output straight into Vicinae's data directory rather than leaving a bundle to
+# link. So the source lives in this repository and the build is what installs
+# it.
+#
+# Never fatal, and never a reason to fail a stage. A build wants npm and the
+# network, and neither is guaranteed at this point in an install -- npm arrives
+# with nvm earlier in install-packages, which can itself be skipped. An
+# extension that did not build is a launcher missing one command, not a desktop
+# that failed to install.
+extensions_source="$panama_path/config/local/share/vicinae/extensions"
+if [[ -d "$extensions_source" ]] && command -v npm >/dev/null 2>&1; then
+ for extension in "$extensions_source"/*/; do
+ [[ -f "$extension/package.json" ]] || continue
+ name="$(basename "$extension")"
+
+ # Skip a build that would produce what is already there. `npm install`
+ # alone takes long enough to be worth not repeating on every re-run of
+ # a stage that is otherwise nearly instant.
+ built="$vicinae_data_dir/extensions/$name"
+ if [[ -d "$built" && "$extension/src" -ot "$built" ]]; then
+ printf 'Vicinae extension %s is already built\n' "$name"
+ continue
+ fi
+
+ printf 'Building Vicinae extension %s\n' "$name"
+ if ! (cd "$extension" && npm install --silent >/dev/null 2>&1 && npm run build >/dev/null 2>&1); then
+ printf 'Vicinae extension %s did not build; skipping\n' "$name" >&2
+ fi
+ done
+elif [[ -d "$extensions_source" ]]; then
+ printf 'npm is not available, so Vicinae extensions were not built\n' >&2
+fi
+
# The server also rescans periodically, but an explicit reload makes a setup
# run deterministic. If Vicinae is not active yet, its startup scan is enough.
if command -v vicinae >/dev/null 2>&1 && vicinae ping >/dev/null 2>&1; then
diff --git a/tests/hypr/gestures-contract b/tests/hypr/gestures-contract
new file mode 100755
index 0000000..5af989f
--- /dev/null
+++ b/tests/hypr/gestures-contract
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+
+# The touchpad gestures, and the window swallowing beside them.
+#
+# Both are behaviour that only exists on hardware this machine may not have, so
+# neither can be checked by running it. What can be pinned is the shape:
+#
+# * Three gestures, mirroring GNOME. Sideways moves workspaces, up opens the
+# overview, down closes it.
+# * Up and down do not both toggle. That is the obvious way to write it and it
+# is wrong: swiping up from an open overview would close it, and swiping
+# down would reopen it, which is the opposite of what the fingers mean.
+# * Swallowing is off by default and driven by a preference. Turning it on for
+# everybody would make terminals appear to vanish on a machine nobody asked.
+# * The swallow regex names only terminals this desktop ships. Anything the
+# pattern matches can swallow, so a broad pattern is windows disappearing in
+# cases nobody intended.
+
+set -uo pipefail
+
+repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+input="$repo_dir/config/dot/hypr/input.lua"
+looks="$repo_dir/config/dot/hypr/looks.lua"
+schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
+
+findings=()
+note() { findings+=("$1"); }
+
+# ── Gestures ─────────────────────────────────────────────────────────────────
+
+gestures="$(grep -c 'hl\.gesture({' "$input" || true)"
+(( gestures == 3 )) || note "input.lua registers $gestures gestures, not the 3 that mirror GNOME"
+
+grep -qE 'fingers = 3, direction = "horizontal",[[:space:]]*action = "workspace"' "$input" \
+ || note 'three fingers sideways does not switch workspaces'
+grep -qE 'fingers = 3, direction = "up",[[:space:]]*action = overview\("open"\)' "$input" \
+ || note 'three fingers up does not open the overview'
+grep -qE 'fingers = 3, direction = "down",[[:space:]]*action = overview\("close"\)' "$input" \
+ || note 'three fingers down does not close the overview'
+
+# The specific mistake worth a test of its own.
+if grep -qE 'direction = "(up|down)",[[:space:]]*action = overview\("toggle"\)' "$input"; then
+ note 'a vertical gesture toggles the overview, so swiping the same way twice undoes itself'
+fi
+
+# The tuning that Hyprland does accept at runtime has to be reachable, or the
+# gestures are unadjustable without editing this file -- which is the thing
+# Settings exists to avoid.
+for key in swipeDistance swipeInvert; do
+ grep -q "key: \"$key\"" "$schema" \
+ || note "$key is not a preference, so the gestures cannot be tuned from Settings"
+done
+
+# ── Swallowing ───────────────────────────────────────────────────────────────
+
+grep -qE 'enable_swallow = prefs\.get\("windowSwallow", false\)' "$looks" \
+ || note 'window swallowing is not preference-driven and off by default'
+
+regex_line="$(grep -E '^\s*swallow_regex' "$looks" || true)"
+[[ -n "$regex_line" ]] || note 'swallowing is enabled with no regex, so nothing can ever swallow'
+
+# Anchored at both ends: an unanchored pattern matches any class containing the
+# name, which is a much larger set than the one intended.
+grep -qE 'swallow_regex\s*=\s*"\^\(.*\)\$"' "$looks" \
+ || note 'the swallow regex is not anchored, so it matches more window classes than it names'
+
+for terminal in kitty ghostty; do
+ grep -q "$terminal" <<<"$regex_line" \
+ || note "the swallow regex does not cover $terminal, which this desktop ships"
+done
+
+# Whatever the regex names has to be something the machine will actually have.
+declared="$(cat "$repo_dir"/setup/packages/* 2>/dev/null | sed 's/#.*//' | tr -d ' ' | grep -v '^$')"
+for terminal in kitty ghostty; do
+ grep -qix "$terminal" <<<"$declared" \
+ || note "the swallow regex names $terminal, which no package list installs"
+done
+
+# ── Report ───────────────────────────────────────────────────────────────────
+
+if (( ${#findings[@]} > 0 )); then
+ mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
+ printf 'gestures contract: %d finding(s)\n' "${#findings[@]}" >&2
+ printf ' - %s\n' "${findings[@]}" >&2
+ exit 1
+fi
+
+printf 'gestures contract: PASS\n'
diff --git a/tests/quickshell/panama-commands-contract b/tests/quickshell/panama-commands-contract
index 34fd2bc..c222fd0 100755
--- a/tests/quickshell/panama-commands-contract
+++ b/tests/quickshell/panama-commands-contract
@@ -51,6 +51,19 @@ done < <(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\([a-z-]*\)"/\1/')
(( ${#expected[@]} > 18 )) || fail 'no generated per-page commands were found; run scripts/panama-settings-commands'
+# Commands that do not go through panama-action, and should not.
+#
+# Every command above asks the shell to do something, so routing them through
+# one dispatcher is what keeps that surface small. search-web is a different
+# animal: it takes a query and opens a browser, and neither half needs the
+# shell. Sending it through panama-action would mean a web search stops working
+# when Quickshell is down -- which is exactly when somebody is reaching for the
+# launcher to look up what went wrong.
+#
+# They are still commands, so everything else below applies to them: a title,
+# a description, search vocabulary, the Panama icon, and closing quietly.
+declare -a standalone=(search-web)
+
# Generated commands must match their source. A stale command dispatches to a
# page that has been renamed or removed, and the launcher reports nothing wrong.
"$repo_dir/config/dot/quickshell/scripts/panama-settings-commands" --check >/dev/null \
@@ -69,11 +82,12 @@ chmod +x "$work/home/.config/quickshell/scripts/panama-action"
# none: a shebang and the executable bit already select the interpreter, and an
# extension is one more thing that has to stay in sync -- which it did not.
mapfile -t actual_files < <(find "$commands_dir" -maxdepth 1 -type f -printf '%f\n' | sort)
-[[ ${#actual_files[@]} -eq ${#expected[@]} ]] \
- || fail "expected ${#expected[@]} commands, found ${#actual_files[@]}"
+declared=$(( ${#expected[@]} + ${#standalone[@]} ))
+[[ ${#actual_files[@]} -eq $declared ]] \
+ || fail "expected $declared commands, found ${#actual_files[@]}"
declare -A seen_titles=()
-for script_name in "${!expected[@]}"; do
+for script_name in "${!expected[@]}" "${standalone[@]}"; do
script="$commands_dir/$script_name"
[[ -x "$script" ]] || fail "$script_name is missing or not executable"
@@ -109,6 +123,15 @@ for script_name in "${!expected[@]}"; do
|| fail 'health command bypasses the stable dispatcher path'
fi
+ # A standalone command has nothing to dispatch, and running it would open a
+ # browser at whoever is running the tests.
+ if [[ -z ${expected[$script_name]+x} ]]; then
+ if grep -Fq 'panama-action' "$script"; then
+ fail "$script_name is listed as standalone but goes through panama-action"
+ fi
+ continue
+ fi
+
: >"$dispatch_log"
HOME="$work/home" PANAMA_COMMAND_TEST_LOG="$dispatch_log" "$script"
dispatched="$(cat "$dispatch_log")"
@@ -116,4 +139,4 @@ for script_name in "${!expected[@]}"; do
|| fail "$script_name dispatched [$dispatched], expected [${expected[$script_name]}]"
done
-printf 'Panama commands contract: PASS (%d commands)\n' "${#expected[@]}"
+printf 'Panama commands contract: PASS (%d commands)\n' "$declared"
diff --git a/tests/setup/launcher-search-contract b/tests/setup/launcher-search-contract
new file mode 100755
index 0000000..0191719
--- /dev/null
+++ b/tests/setup/launcher-search-contract
@@ -0,0 +1,119 @@
+#!/usr/bin/env bash
+
+# Searching the web from the launcher, in both the forms Panama ships it.
+#
+# There are two, deliberately: a script command that needs nothing but bash, and
+# an extension that adds live suggestions but has to be compiled. That is also
+# the risk this pins -- the search engine is written down twice, and two copies
+# of a URL drift.
+#
+# `vicinae script check` is the validator for the first, and it exits 0 even when
+# it rejects a file. Checking its exit status would pass on a script Vicinae
+# refuses to load, so its output is what counts.
+
+set -uo pipefail
+
+repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+script="$repo_dir/config/local/share/vicinae/scripts/search-web"
+extension="$repo_dir/config/local/share/vicinae/extensions/panama-search"
+stage="$repo_dir/setup/scripts/link-vicinae-scripts"
+
+findings=()
+note() { findings+=("$1"); }
+
+# ── The script command ───────────────────────────────────────────────────────
+
+if [[ ! -x "$script" ]]; then
+ note 'search-web is missing or not executable, so Vicinae will not load it'
+else
+ if command -v vicinae >/dev/null 2>&1; then
+ output="$(vicinae script check "$script" 2>&1)"
+ [[ "$output" == *Error* ]] && note "Vicinae rejects search-web: $output"
+ fi
+
+ # Exactly one argument. Vicinae only offers a no-view command as a fallback
+ # -- type anything, get "Search" at the bottom -- when it takes a single
+ # text argument, which is the whole point of shipping this one.
+ arguments="$(grep -c '@vicinae.argument' "$script" || true)"
+ (( arguments == 1 )) \
+ || note "search-web declares $arguments arguments; a fallback command takes exactly 1"
+
+ grep -q '"percentEncoded": true' "$script" \
+ || note 'the argument is not percent-encoded, so a query containing & or # is truncated'
+
+ grep -qE '@vicinae\.mode silent' "$script" \
+ || note 'search-web is not a silent command, so it would render a view it has nothing to put in'
+
+ # The default browser is a setting this desktop already owns. Naming a
+ # browser here would quietly outrank the Applications page.
+ grep -q 'xdg-open' "$script" \
+ || note 'search-web does not open through xdg-open, so it ignores the default browser'
+ grep -qE '\b(helium|firefox|chromium|google-chrome)\b' "$script" \
+ && note 'search-web names a specific browser instead of following the default'
+fi
+
+# ── The extension ────────────────────────────────────────────────────────────
+
+manifest="$extension/package.json"
+if [[ ! -f "$manifest" ]]; then
+ note 'the panama-search extension has no manifest'
+else
+ if command -v jq >/dev/null 2>&1; then
+ jq -e . "$manifest" >/dev/null 2>&1 || note 'the extension manifest is not valid JSON'
+
+ for field in name title description commands; do
+ jq -e "has(\"$field\")" "$manifest" >/dev/null 2>&1 \
+ || note "the extension manifest has no \"$field\", which Vicinae requires"
+ done
+
+ # A command's name is its source file. Getting this wrong builds an
+ # extension with a command that cannot be opened.
+ while read -r command_name; do
+ [[ -n "$command_name" ]] || continue
+ [[ -f "$extension/src/$command_name.tsx" || -f "$extension/src/$command_name.ts" ]] \
+ || note "the manifest declares command \"$command_name\" with no matching file in src/"
+ done < <(jq -r '.commands[]?.name // empty' "$manifest" 2>/dev/null)
+ fi
+fi
+
+# ── One engine, written twice ────────────────────────────────────────────────
+#
+# The script command and the extension both have to know where a search goes.
+# Nothing makes them agree, so this does: searching from the fallback and
+# searching from the suggestions list must not reach different places.
+
+engine_in_script="$(grep -oE 'https://[^"]+\?q=' "$script" 2>/dev/null | head -1)"
+engine_in_extension="$(grep -oE 'https://[^"]+\?q=' "$extension/src/search.tsx" 2>/dev/null | head -1)"
+
+if [[ -z "$engine_in_script" ]]; then
+ note 'no search engine URL found in search-web'
+elif [[ -z "$engine_in_extension" ]]; then
+ note 'no search engine URL found in the extension'
+elif [[ "$engine_in_script" != "$engine_in_extension" ]]; then
+ note "the script searches $engine_in_script but the extension searches $engine_in_extension"
+fi
+
+# ── Provisioning ─────────────────────────────────────────────────────────────
+#
+# An extension is compiled, so unlike a script command it cannot simply be
+# linked. If nothing builds it, it ships as source nobody can run.
+
+grep -q 'npm run build' "$stage" \
+ || note 'no stage builds the Vicinae extensions, so they never reach the launcher'
+grep -q 'command -v npm' "$stage" \
+ || note 'the extension build does not check for npm, so a machine without it fails the stage'
+
+# node_modules is a dependency tree, not configuration.
+git -C "$repo_dir" check-ignore -q "$extension/node_modules" 2>/dev/null \
+ || note 'the extension node_modules is not gitignored'
+
+# ── Report ───────────────────────────────────────────────────────────────────
+
+if (( ${#findings[@]} > 0 )); then
+ mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
+ printf 'launcher search contract: %d finding(s)\n' "${#findings[@]}" >&2
+ printf ' - %s\n' "${findings[@]}" >&2
+ exit 1
+fi
+
+printf 'launcher search contract: PASS\n'