Ned, I would love to stand here and talk with you —but I’m not going to.” — Phil Connors, Groundhog Day.

This is the next entry in my series of Rofi modules written in Python to extend its functionality. While a standard application launcher handles installed software, this script, myrofi_search.py, transforms Rofi into a universal command center.
It integrates three distinct workflows into a single, searchable interface:
#!/usr/bin/env python3
"""
myrofi_search.py — Rofi search launcher with bangs, terminal help, and AI
──────────────────────────────────────────────────────────────────────────
BANG SEARCHES — prefix query with !bang:
(no bang) → Google !g → Google (explicit)
!yt → YouTube !ddg → DuckDuckGo
!w → Wikipedia !a → Amazon Spain
!e → eBay Spain !gh → GitHub
!so → Stack Overflow !maps → Google Maps
!img → Google Images !arch → Arch Wiki
!nix → NixOS options !pypi → PyPI
TERMINAL HELP — opens a new Alacritty window:
!help curl → curl cheat.sh/curl (community cheatsheets)
!cheat curl → same as !help
!tldr rsync → tldr rsync (simplified man pages)
!man niri → man niri (full manual)
AI QUERIES — streams Ollama output live in Alacritty:
!ai explain NFS vs Samba
!gemma what is a Wayland compositor
Before Python, test the raw HTTP endpoint: curl -v http://192.168.1.12:11434/api/tags
Usage from rofi-launcher.sh:
12) python3 ~/dotfiles/scripts/myrofi_search.py ;;
Dependencies:
sudo pacman -S wl-clipboard python-requests tldr
"""
import os
import sys
import subprocess
import shutil
import urllib.parse
import tempfile
import stat
# ── Import launch_terminal from the sibling module ────────────────────────────
# `myrofi_terminal.py` provides `launch_terminal(command, title, wait)` which:
# - Switches to a dedicated terminal workspace (if using a tiling compositor, e.g., Niri, i3)
# - Writes the given command to a temporary bash script with a proper shebang
# - Launches Alacritty executing that script, fully detached
# - Optionally adds a "Press Enter to close" footer when `wait=True`
#
# IMPORTANT: the `command` argument is treated as BASH code, not Python.
# For the AI feature we write a separate Python file and pass
# `python3 /tmp/that_file.py` as the command.
# - __file__ → path to the current script
# os.path.abspath → normalize
# os.path.dirname → get containing folder
# sys.path.insert(0, ...) → give it highest priority in module resolution
# This is a standard pattern when you want to keep helper modules next to your script
_SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
if _SCRIPTS_DIR not in sys.path:
# Add the script’s directory to sys.path
# This ensures that Python can import modules located in the same directory as this file.
sys.path.insert(0, _SCRIPTS_DIR)
# Try to import an optional module
try:
# If myrofi_terminal.py exists in the same directory, the import succeeds and _HAS_LAUNCH_TERMINAL = True.
from myrofi_terminal import launch_terminal
_HAS_LAUNCH_TERMINAL = True
except ImportError:
# If not, the script continues gracefully with _HAS_LAUNCH_TERMINAL = False
_HAS_LAUNCH_TERMINAL = False
# ════════════════════════════════════════════════════ ══════════════════════════
# CONFIGURATION — adjust to your environment
# ══════════════════════════════════════════════════════════════════════════════
# Rofi theme and font – must match the main rofi launcher for visual consistency.
ROFI_THEME = os.path.expanduser("~/dotfiles/myArch/config/rofi/rounded-dark")
ROFI_FONT = "hack 20"
# Browser command. This will be launched with the final search URL.
# Use the exact binary name; it must be in $PATH or an absolute path.
BROWSER = "google-chrome-stable"
# Ollama configuration
# OLLAMA_HOST is the full base URL of the Ollama API (default: localhost:11434).
# OLLAMA_MODEL is the name of the model to use (must be pulled beforehand).
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://192.168.1.12:11434")
OLLAMA_MODEL = "gemma4:31b"
# ──────────────────────────────────────────────────────────────────────────────
# BANG DEFINITIONS
# ──────────────────────────────────────────────────────────────────────────────
# Mapping from bang keyword (without the '!') to a URL template.
# The placeholder `{q}` will be replaced with the URL‑encoded search query.
BANGS: dict[str, str] = {
"g": "https://www.google.com/search?q={q}",
"yt": "https://www.youtube.com/results?search_query={q}",
"ddg": "https://duckduckgo.com/?q={q}",
"w": "https://en.wikipedia.org/w/index.php?search={q}",
"a": "https://www.amazon.es/s?k={q}",
"e": "https://www.ebay.es/sch/i.html?_nkw={q}",
"gh": "https://github.com/search?q={q}",
"so": "https://stackoverflow.com/search?q={q}",
"maps": "https://www.google.com/maps/search/{q}",
"img": "https://www.google.com/search?tbm=isch&q={q}",
"arch": "https://wiki.archlinux.org/index.php?search={q}",
"nix": "https://search.nixos.org/options?query={q}",
"pypi": "https://pypi.org/search/?q={q}",
}
# Sets of bangs that trigger terminal‑based help commands rather than web searches.
# We use sets for fast membership testing.
_HELP_BANGS = {"help", "cheat", "cht"}
_TLDR_BANGS = {"tldr"}
_MAN_BANGS = {"man"}
_AI_BANGS = {"ai", "gemma", "llm", "ask"}
# ══════════════════════════════════════════════════════════════════════════════
# ROFI INPUT — get the search query from the user
# ══════════════════════════════════════════════════════════════════════════════
def prompt_user() -> str | None:
"""Show an empty rofi input box and return what the user typed.
Returns:
The user's input as a string, or None if they pressed Escape.
Rofi is invoked in dmenu mode with an empty input. The user can type
anything; we later parse it for bangs. The prompt includes a hint
showing available bangs to reduce confusion.
"""
# Run rofi with -dmenu. The `-lines 0` tells rofi that we do not want rofi to show a list of
# options – it's a pure text input box. `-format s` outputs the raw string.
result = subprocess.run(
[
"rofi", "-dmenu", "-i",
"-p", " 🔍 Search (!yt !w !ddg !ai !help !tldr ...)",
"-lines", "0",
"-format", "s",
"-theme", ROFI_THEME,
"-font", ROFI_FONT,
],
capture_output=True,
text=True,
input="", # Empty input – rofi starts with an empty box
)
# Exit code 1 means user pressed Escape or closed the window.
if result.returncode != 0:
return None
# Strip whitespace; if the user just pressed Enter, return None (no query).
query = result.stdout.strip()
return query if query else None
# ══════════════════════════════════════════════════════════════════════════════
# QUERY PARSING — extract bang and search terms
# ══════════════════════════════════════════════════════════════════════════════
def parse_query(raw: str) -> tuple[str, str]:
"""
Split raw input into (bang, query_text).
"python tutorial" → ("", "python tutorial")
"!yt lo-fi music" → ("yt", "lo-fi music")
"!ai explain NFS" → ("ai", "explain NFS")
The bang is the word immediately after the '!', converted to lowercase.
Everything after that word becomes the query. If no bang is present,
the first element is an empty string.
"""
# Leading/trailing whitespace is removed.
raw = raw.strip()
if raw.startswith("!"):
# If the string starts with "!", we:
parts = raw[1:].split(None, 1)
# remove the "!" and split into at most two parts
# !yt lo-fi →["yt", "lo-fi"]; !ai explain NFS → ["ai", "explain NFS"]
bang = parts[0].lower()
# lowercase the bang
query = parts[1] if len(parts) > 1 else ""
# If the user typed something after the bang → use it
# If they didn’t → return an empty query
return bang, query
return "", raw
# ══════════════════════════════════════════════════════════════════════════════
# BROWSER SEARCH
# ══════════════════════════════════════════════════════════════════════════════
def open_browser(url: str) -> None:
"""
Launch the configured browser with the given URL, fully detached.
The browser process is started with `start_new_session=True` so it survives
the termination of this Python script. Output is redirected to /dev/null
to prevent console clutter.
"""
# `subprocess.Popen` forks and returns immediately – no waiting for the
# browser to finish. This is essential so the script can exit cleanly
# after launching the browser, leaving it running independently.
subprocess.Popen(
[BROWSER, url],
stdout=subprocess.DEVNULL, # Discard any browser stdout output
stderr=subprocess.DEVNULL, # Discard any browser stderr output
start_new_session=True, # Child gets its own session, detached from terminal
)
def search_web(bang: str, query: str) -> None:
"""
Perform a web search based on the provided bang and query.
If no bang is given (empty string), default to Google.
If the bang is unknown, fall back to Google with the full input as the query,
and show a notification explaining the fallback.
Args:
bang: The bang keyword without '!' (e.g., "yt", "w", "ai").
query: The search terms to look up.
"""
# If both bang and query are empty, there's nothing to search. Exit quietly.
if not query and not bang:
return
# Retrieve the URL template for the given bang. If no bang was provided
# (empty string), we treat it as a Google search. The `or "g"` ensures
# that `BANGS.get("")` falls back to the "g" (Google) template.
template = BANGS.get(bang or "g")
if template is None:
# Unknown bang – fall back to Google, search only the query
url = BANGS["g"].replace("{q}", urllib.parse.quote_plus(query))
notify("Search", f"Unknown bang !{bang} — falling back to Google")
else:
# Known bang – encode the query and build the final URL.
url = template.replace("{q}", urllib.parse.quote_plus(query))
open_browser(url)
# ══════════════════════════════════════════════════════════════════════════════
# TERMINAL HELPERS
# ══════════════════════════════════════════════════════════════════════════════
def _run_in_terminal(command: str, title: str) -> None:
"""
Run a BASH command string in a new Alacritty window.
Uses launch_terminal() from myrofi_terminal.py which:
1. Writes #!/usr/bin/env bash + command to a temp file.
2. Appends "Press Enter to close" footer.
3. Opens alacritty with start_new_session=True.
command must be valid BASH — not Python code.
For Python scripts use _run_python_in_terminal() below.
"""
# ──────────────────────────────────────────────────────────────────────────
# Primary path: use the shared launch_terminal helper.
# ──────────────────────────────────────────────────────────────────────────
# `myrofi_terminal.launch_terminal` is a robust function that handles:
# - Workspace switching (optional, depending on compositor)
# - Temporary script file creation with proper shebang and cleanup
# - Detached terminal launch with appropriate flags
# We reuse it here to keep terminal behaviour consistent across scripts.
if _HAS_LAUNCH_TERMINAL:
launch_terminal(command=command, title=title, wait=True)
else:
# ──────────────────────────────────────────────────────────────────────────
# Fallback: minimal standalone terminal launcher.
# ──────────────────────────────────────────────────────────────────────────
# This fallback runs only if myrofi_terminal.py could not be imported.
# It implements the same core idea: write the command to a temporary
# bash script, make it executable, and launch Alacritty to run it.
import time
# Create a temporary file that will hold the bash script.
# `delete=False` because the terminal will run it after we exit.
tmp = tempfile.NamedTemporaryFile(
mode="w", prefix="rofi-sh-", suffix=".sh", delete=False
)
# Write the script contents:
# 1. Shebang for bash
# 2. The actual command
# 3. A "done" message and a prompt to press Enter
# 4. Self‑deletion of the temporary file to keep /tmp clean
tmp.write("#!/usr/bin/env bash\n")
tmp.write(f"{command}\n")
tmp.write('\necho\necho "─── done ───"\nread -rp "Press Enter..."\n')
tmp.write(f'rm -f "{tmp.name}"\n')
tmp.close()
# Make the temporary script executable.
os.chmod(tmp.name, os.stat(tmp.name).st_mode | stat.S_IEXEC)
# Brief delay to ensure the filesystem has flushed the file.
# This prevents Alacritty for trying to read the file before it is fully written.
time.sleep(0.15)
# Launch Alacritty, executing the temporary script.
# `-e bash script.sh` tells Alacritty to run bash with our script.
# The process is detached with `start_new_session=True`.
subprocess.Popen(
["alacritty", "--title", title, "-e", "bash", tmp.name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
def _run_python_in_terminal(py_source: str, title: str) -> None:
"""
Write a Python script to a temp .py file and run it in a new terminal.
WHY a separate .py file instead of passing Python code as the command:
launch_terminal() wraps the command in #!/usr/bin/env bash.
If we pass Python code as the command, bash tries to execute it
and fails silently — the terminal opens but shows nothing.
The fix: write the Python code to a separate .py file, then pass
`python3 /tmp/that_file.py` as the BASH command to launch_terminal.
Bash runs one line: python3 /tmp/rofi-ai-XXXX.py — which works.
The .py file deletes itself as its last line so /tmp stays clean.
"""
# ──────────────────────────────────────────────────────────────────────────
# Step 1: Write the Python source code to its own temporary file.
# ──────────────────────────────────────────────────────────────────────────
# The file is created with `delete=False` because we need it to persist
# after this function returns – the terminal will execute it later.
py_tmp = tempfile.NamedTemporaryFile(
mode="w", prefix="rofi-ai-", suffix=".py", delete=False
)
py_tmp.write(py_source)
py_tmp.close()
# Make the Python script executable (though not strictly required for
# `python3 file.py`, it's a good practice).
os.chmod(py_tmp.name, os.stat(py_tmp.name).st_mode | stat.S_IEXEC)
# ──────────────────────────────────────────────────────────────────────────
# Step 2: Build the BASH command that runs the Python script.
# ──────────────────────────────────────────────────────────────────────────
# This is a simple string: "python3 /path/to/tempfile.py"
# It is valid bash, so `_run_in_terminal` can safely wrap it in a bash script.
bash_command = f"python3 {py_tmp.name}"
# ──────────────────────────────────────────────────────────────────────────
# Step 3: Delegate to `_run_in_terminal` to actually launch the terminal.
# ──────────────────────────────────────────────────────────────────────────
_run_in_terminal(command=bash_command, title=title)
def open_cheat(topic: str) -> None:
"""Open cheat.sh for the topic in a new terminal (curl cheat.sh/topic).
Cheat.sh is a unified cheat sheet service that provides concise, community‑driven examples
for commands, programming languages, and more."""
# If the caller passes an empty string or None, default to the root page.
# This allows a simple "!cheat" invocation to show the cheat.sh homepage.
if not topic:
topic = "/"
# The topic is URL‑encoded using urllib.parse.quote(topic, safe="/").
# This is essential because cheat.sh uses slash‑delimited hierarchies
# (e.g., "python/list"). urllib.parse.quote replaces spaces and
# special characters while leaving "/" untouched, producing a valid URL segment.
safe = urllib.parse.quote(topic, safe="/")
# Build the shell command to execute in the new terminal.
# - `curl -s` fetches the page silently (no progress meter).
# - The output is piped into `less -R` to provide a scrollable pager
# that honours ANSI colour escape sequences (syntax highlighting from cheat.sh).
command = f'curl -s "cheat.sh/{safe}" | less -R'
# Delegate to a helper that launches a terminal emulator with the given command.
# The terminal window title is set to "cheat.sh/{topic}" for easy identification.
_run_in_terminal(command=command, title=f"cheat.sh/{topic}")
def open_tldr(topic: str) -> None:
"""Run tldr for the topic in a new terminal."""
# Validate that the user actually provided a command name.
# Without a topic, `tldr` would show its general help, but here we choose to
# give a more explicit usage hint via a desktop notification.
if not topic:
# The summary "Search" and body "Usage: …" guide the user toward correct syntax.
notify("Search", "Usage: !tldr e.g. !tldr curl" )
return # Exit early; no terminal is opened.
# Construct the command string. `tldr` is expected to be installed and in $PATH.
# No additional flags are needed; the tldr client will fetch the appropriate page.
command = f"tldr {topic}"
# Launch a terminal with the command and a descriptive title.
_run_in_terminal(command=command, title=f"tldr {topic}")
def open_man(topic: str) -> None:
"""Run man for the topic in a new terminal."""
# Same validation pattern as `open_tldr`. An empty topic would cause `man` to
# display its own usage, but a desktop notification provides a more consistent
# user experience across the "search" commands.
if not topic:
notify("Search", "Usage: !man e.g. !man curl" )
return
# The classic `man` command. The system's default pager (usually `less`) will
# handle scrolling and searching.
command = f"man {topic}"
# Open the manual page in a new terminal.
_run_in_terminal(command=command, title=f"man {topic}")
# ══════════════════════════════════════════════════════════════════════════════
# AI QUERY — streaming Ollama output
# ══════════════════════════════════════════════════════════════════════════════
def ask_ai(query: str) -> None:
"""
Send a user query to an Ollama model and stream the response live
inside a newly spawned terminal window.
──────────────────────────────────────────────────────────────────────
OVERVIEW
──────────────────────────────────────────────────────────────────────
This function provides a low-latency, real-time AI interaction by
streaming tokens as they are generated, instead of waiting for the
full response.
The user sees output almost immediately (first token), improving
perceived responsiveness and UX.
──────────────────────────────────────────────────────────────────────
HIGH-LEVEL ARCHITECTURE
──────────────────────────────────────────────────────────────────────
The function uses a "self-contained script execution" pattern:
1. Build a standalone Python script as a string.
2. Inject runtime values (host, model, query).
3. Write the script to a temporary file in /tmp.
4. Launch a new terminal running:
python3 /tmp/rofi-ai-XXXX.py
5. The script executes independently, streams output, and deletes itself.
This avoids:
- Shell quoting issues
- Mixing Bash and Python execution contexts
- Blocking the main UI process
──────────────────────────────────────────────────────────────────────
STREAMING BEHAVIOR
──────────────────────────────────────────────────────────────────────
Ollama's `/api/generate` endpoint returns NDJSON:
{"response": "Hel", "done": false}
{"response": "lo", "done": false}
{"response": "!", "done": true}
Each chunk is processed immediately:
- Tokens are printed as they arrive
- `flush=True` ensures no buffering delay
Result:
"Hello!" appears progressively, not all at once.
"""
# If the user invoked the command without a question (e.g., "!ai" alone),
# show a notification with usage examples and exit. This prevents opening
# an empty terminal that would immediately show an error.
if not query:
notify("Search", "Usage: !ai e.g. !ai explain NFS vs Samba" )
return
# Build the Python script as a multi‑line string.
# We use %‑style formatting (with a dictionary) rather than f‑strings
# because f‑strings would immediately interpolate `{model}` and `{host}`,
# which are meant to be literal placeholders inside the generated script.
# `repr(query)` produces a properly quoted and escaped Python string literal.
py_source = """\
#!/usr/bin/env python3
# Self-contained Ollama streaming client
# Generated by myrofi_search.py — deletes itself after running
import sys, json, os, time, textwrap
# ── Dependency check ─────────────────────────────────────────────────────────
# The script requires the `requests` library. If it's not installed, we
# display a helpful message and exit gracefully instead of crashing with
# an ImportError. The user can then install it via their package manager.
try:
import requests
except ImportError:
print("\\n ❌ requests not found.")
print(" sudo pacman -S python-requests")
input("\\n Press Enter to close...")
sys.exit(1)
# ── Configuration (injected by the outer script) ─────────────────────────────
HOST = %(host)s # Ollama server URL (e.g., "http://192.168.1.100:11434")
MODEL = %(model)s # Model name (e.g., "llama3.2", "gemma4:31b")
QUERY = %(query)s # The user's question, safely quoted as a Python string
THIS = %(this)s # Path to this temporary file — will be deleted on exit
# ── Banner ────────────────────────────────────────────────────────────────────
# Print a clear header showing which model and server are being used,
# followed by the user's query. This provides context in the terminal window.
print()
print(" ┌─────────────────────────────────────────────────────┐")
print(f" │ Model : {MODEL:<43} │")
print(f" │ Server: {HOST:<43} │")
print(" └─────────────────────────────────────────────────────┘")
print()
print(f" {QUERY}")
print(" " + "─" * 57)
print()
# ── Connecting spinner ────────────────────────────────────────────────────────
# While the HTTP request is in flight and we haven't received the first token,
# a rotating spinner provides visual feedback that something is happening.
# This is run in a separate daemon thread so it can animate concurrently with
# the blocking network call.
import threading
_first_token = threading.Event() # Set when the first token arrives
_done = threading.Event() # Set on error or completion (stops spinner)
def _spinner():
dots = [" ⏳ Thinking ", " ⏳ Thinking. ",
" ⏳ Thinking.. ", " ⏳ Thinking..."]
i = 0
# Continue spinning until either the first token arrives or an error/exit occurs.
while not _first_token.is_set() and not _done.is_set():
# Overwrite the current line with the next spinner frame.
print(f"\\r{dots[i %% 4]}", end="", flush=True)
i += 1
time.sleep(0.4)
# Erase the spinner line before the response starts, so the response can start on a fresh line
print("\\r" + " " * 24 + "\\r", end="", flush=True)
# Start the spinner thread. It's a daemon thread so it won't prevent the
# program from exiting if the main thread finishes unexpectedly.
spinner_thread = threading.Thread(target=_spinner, daemon=True)
spinner_thread.start()
# ── Streaming request ─────────────────────────────────────────────────────────
try:
# Perform a POST request to the Ollama generate endpoint with streaming enabled.
# - `stream=True` tells requests to not download the whole body at once,
# allowing us to iterate over lines as they arrive.
# - A generous timeout (3000 seconds) accommodates long‑running generations.
resp = requests.post(
f"{HOST}/api/generate",
json={"model": MODEL, "prompt": QUERY, "stream": True},
stream=True,
timeout=3000,
)
# Raise an exception for HTTP error status codes (4xx, 5xx).
resp.raise_for_status()
except requests.exceptions.ConnectionError:
# Ollama isn't reachable. Stop the spinner, print a user‑friendly error,
# and suggest a diagnostic command.
_done.set()
spinner_thread.join(timeout=1)
print(f" ❌ Cannot connect to Ollama at {HOST}")
print()
print(" Is Ollama running on your Windows machine?")
print(f" Test: curl {HOST}/api/tags")
input("\\n Press Enter to close...")
# Clean up the temporary file before exiting.
os.unlink(THIS)
sys.exit(1)
except Exception as e:
# Catch any other unexpected error (e.g., malformed URL, timeout).
_done.set()
spinner_thread.join(timeout=1)
print(f" ❌ Error: {e}")
input("\\n Press Enter to close...")
os.unlink(THIS)
sys.exit(1)
# ── Stream response tokens ────────────────────────────────────────────────────
# Ollama streams NDJSON — one JSON object per line.
# Each line is a separate JSON object {"response": "", "done": false/true}.
# containing a "response" field (the next token) and a "done" flag.
# We print each token immediately (flush=True) so words appear as generated.
full_response = [] # Collect tokens for potential future use (e.g., saving to file)
for raw_line in resp.iter_lines():
# `iter_lines()` yields each line as it arrives. Empty lines can appear
# (e.g., keep‑alive newlines) and should be ignored.
if not raw_line:
continue
try:
# data is a JSON object {"response": "", "done": false/true}.
data = json.loads(raw_line)
token = data.get("response", "")
if token:
# The first token has arrived — stop the spinner.
if not _first_token.is_set():
# Mark that the first token has arrived so the spinner can stop and clear itself.
_first_token.set()
# Wait briefly for spinner thread to clean up
spinner_thread.join(timeout=1)
# Print the token immediately with `flush=True` so it appears
# on screen without waiting for a buffer to fill.
print(token, end="", flush=True)
full_response.append(token)
# Check if this is the final chunk.
if data.get("done"):
break
except json.JSONDecodeError:
# Malformed line (shouldn't happen with Ollama, but we ignore gracefully).
continue
# After the stream ends, add some blank space and a separator for visual clarity.
print("\\n")
print(" " + "─" * 57)
# Signal global completion so the spinner thread shuts down if it hasn’t already.
_done.set()
# Self‑destruct: remove the temporary Python file so /tmp stays clean.
os.unlink(THIS)
""" % {
# Inject the actual values into the script template.
# `repr()` ensures strings are properly escaped for Python source.
# Insert the Ollama server URL into the generated script, safely quoted as a Python literal.
"host": repr(OLLAMA_HOST),
"model": repr(OLLAMA_MODEL),
"query": repr(query),
"this": "__file__", # Placeholder — will be replaced with the actual file path
}
# Replace the __file__ placeholder with the actual temp file path.
# We do this after creating the file so we know its name.
# NamedTemporaryFile gives us the name before we write, so we can
# inject it directly into the script.
import time as _time
py_tmp = tempfile.NamedTemporaryFile(
mode="w", prefix="rofi-ai-", suffix=".py", delete=False
)
# Substitute the placeholder with the real file path, properly quoted.
final_source = py_source.replace(
'"__file__"', repr(py_tmp.name)
)
# Write the final script to the temporary file and close it.
py_tmp.write(final_source)
py_tmp.close()
# Make the temporary file executable so it can be run directly if desired
# (though we call it via `python3` anyway).
os.chmod(py_tmp.name, os.stat(py_tmp.name).st_mode | stat.S_IEXEC)
# Construct the bash command that will be passed to the terminal.
# It simply runs the generated Python script using the system's python3.
bash_cmd = f"python3 {py_tmp.name}"
# Launch a new terminal window with the command and a title truncated to 40 characters.
# The helper `_run_in_terminal` handles the terminal‑emulator specifics.
# It wraps this in #!/usr/bin/env bash — valid.
_run_in_terminal(command=bash_cmd, title=f"AI: {query[:40]}")
# ══════════════════════════════════════════════════════════════════════════════
# NOTIFICATIONS
# ══════════════════════════════════════════════════════════════════════════════
def notify(title: str, body: str = "", error: bool = False) -> None:
"""
Send a desktop notification using `notify-send`.
This is a fire‑and‑forget helper. If `notify-send` is not installed,
the function silently returns — no error is raised, as notifications
are considered a non‑essential convenience feature.
Parameters:
title : The notification summary (e.g., "Search").
body : Optional additional text (e.g., usage instructions).
error : If True, uses an error icon and different urgency hint.
"""
# Check whether `notify-send` is available in the system PATH.
# `shutil.which` returns the full path if the executable exists,
# or `None` if it cannot be found. If it's missing, we simply
# exit the function quietly — the user may not have a notification
# daemon running, but the script should continue to work.
if not shutil.which("notify-send"):
return
# Choose `dialog-error` for error messages; `system-search`
# conveys a search‑related notification. The icon is passed with `-i`.
icon = "dialog-error" if error else "system-search"
# Launch `notify-send` as a background process using `subprocess.Popen`.
# We do not wait for the notification to disappear; the process is
# detached so the main script can continue immediately.
# - `-i, --icon` : sets the icon.
# - `-t, --expire-time` : duration in milliseconds (3000 = 3 seconds).
# - `stdout` and `stderr` are redirected to `DEVNULL` to prevent any
# output from cluttering the terminal or interfering with Rofi.
subprocess.Popen(
["notify-send", "-i", icon, "-t", "3000", title, body],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# ══════════════════════════════════════════════════════════════════════════════
# DISPATCH
# ══════════════════════════════════════════════════════════════════════════════
def dispatch(bang: str, query: str) -> None:
"""
Route a parsed query to the appropriate handler based on the bang prefix.
The function implements a priority order:
1. Specialised search handlers (!cheat, !tldr, !man, !ai).
2. Known web search bangs (e.g., !g, !yt, !gh) — fall through to `search_web`.
3. Empty bang (direct query) — also sent to `search_web` with default engine.
4. Unknown bang — notify the user and fall back to a Google search
including the unrecognised bang as part of the query.
Parameters:
bang : The bang string (e.g., "g", "yt", "cheat", "ai").
May be an empty string if the user typed a plain query.
query : The remainder of the input after the bang and any whitespace.
For specialised commands like !ai, this is the question.
For web searches, this is the search term.
"""
# 1. `_HELP_BANGS` contains strings like "cheat", "cht", and "help".
# If the bang matches one of these, call `open_cheat` with the query.
# `open_cheat` will handle an empty query by showing the cheat.sh homepage.
if bang in _HELP_BANGS: open_cheat(query)
# `_TLDR_BANGS` contains strings such as ("tldr", "tl").
# `open_tldr` expects a command name; it shows a usage notification
# if `query` is empty and then returns without opening a terminal.
elif bang in _TLDR_BANGS: open_tldr(query)
# `_MAN_BANGS` holds values like ("man", "m").
# Similarly, `open_man` validates the query before launching a terminal.
elif bang in _MAN_BANGS: open_man(query)
# `_AI_BANGS` contains aliases for the AI assistant, e.g., ("ai", "ask").
# `ask_ai` streams the response into a new terminal.
elif bang in _AI_BANGS: ask_ai(query)
# `BANGS` is a dictionary mapping bang strings to their corresponding
# search engine URLs or identifiers (e.g., {"g": "google", "yt": "youtube"}).
# If the bang is a key in `BANGS` *or* if the bang is empty (plain query),
# we delegate to `search_web`. An empty bang triggers the default search
# engine (usually Google) as defined inside `search_web`.
elif bang in BANGS or bang == "":
search_web(bang, query)
# Unknown bang — fallback
# If the bang is neither a special command nor a recognised web search,
# we inform the user via a notification and then perform a Google search
# with the original input (including the unknown bang) as the query.
# This prevents the user's input from being lost and gives a chance
# to still get relevant results.
else:
notify("Search", f"Unknown bang !{bang} — falling back to Google")
# The fallback search uses the "g" bang (Google) and constructs
# a query that preserves the full original input, e.g.:
# "!unknownbang rest of query"
# This way, the user can see what they typed and perhaps discover
# the correct bang later.
search_web("g", f"!{bang} {query}".strip())
def main() -> None:
"""
Main entry point for the Rofi search script.
Flow:
1. Call `prompt_user()` to display the Rofi input dialog and
retrieve the user's typed string. If the user cancels (Esc or
empty input with no selection), `prompt_user` returns `None`.
2. If the input is `None`, exit the script cleanly with status 0.
3. Parse the raw string using `parse_query(raw)` to separate the
bang (!word) from the rest of the query.
4. Dispatch the parsed components to the appropriate handler via
`dispatch(bang, query)`.
"""
# Show Rofi and capture the user's input.
raw = prompt_user()
# User pressed Escape or cancelled the dialog — exit quietly.
if raw is None:
sys.exit(0)
# Parse the input: extract the bang (if any) and the query text.
# `parse_query` returns a tuple `(bang, query)`.
# - `bang` is a string like "g", "ai", "cheat", or "" if no bang.
# - `query` is the remaining text after the bang, with leading/trailing
# whitespace stripped.
bang, query = parse_query(raw)
# Route the parsed components to the correct handler.
dispatch(bang, query)
# ══════════════════════════════════════════════════════════════════════════════
# SCRIPT EXECUTION GUARD
# ══════════════════════════════════════════════════════════════════════════════
if __name__ == "__main__":
main()