Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Riptide

Surfing the web really fast. Riptide is a keyboard-driven browser in the spirit of qutebrowser, built on CEF (Chromium 154) and controlled entirely from Rust through the cef crate.

Status: early prototype, Linux/X11 only, not ready for daily browsing. The project plan tracks what's done and what's missing.

What works today

  • Keyboard first: normal, insert, command, hint, caret and passthrough modes, with qutebrowser's bindings. Also counts, marks, macros, / search, :navigate, and a command line with history and completion.
  • Tabs and windows: pinned tabs, a tab bar that works with the mouse, favicons, :tab-select, moving tabs between windows, and private windows.
  • Hints: for links, inputs, images, yanking and downloads, including number hints, iframes from any site and shadow DOM.
  • Privacy: ad and tracker blocking with EasyList, EasyPrivacy and uBlock Origin's own lists, including its scriptlets (which strip YouTube's video ads), stand-ins for blocked files, tracking parameters taken off links, and element hiding with :has-text() and the like, in frames too, plus your own rules, which an element picker writes for you. Also: Google background calls turned off, the Chromium sandbox where Linux allows it, and per-site permissions and certificate decisions.
  • Configuration: config.toml, or config.lua with full scripting (functions on keys, custom commands, event hooks). Live :set, per-site settings, and a generated :help page.
  • Extensions and plugins: Chrome (Manifest V3) extensions from the Web Store, such as uBlock Origin Lite, with their popups floating over the page; and sandboxed Lua plugins, like the ones that fill logins from pass, Bitwarden or KeePassXC.
  • Scripts and data: userscripts, Greasemonkey scripts, :open-editor, and qutebrowser's quickmark, bookmark and history formats (:history-import).
  • Page tools: zoom (+ - =), DevTools (wi), print or save as PDF, fullscreen, view source (gf), :jseval, tab muting, :messages, and . to repeat the last command.
  • Everything else: sessions with crash recovery, history and downloads pages, spell checking with keyboard-driven fixes, dark mode, opt-in Widevine, and handing commands to a running browser from the terminal (riptide ':open -t x').

Where to start

  • New here: Installing, then Keys and modes.
  • Looking something up: the Reference lists every command, binding and setting. In the browser, F1 or :help shows the same, with your own config applied.
  • Working on riptide itself: the Developer guide.

Riptide is licensed under GPL-3.0-or-later.

Installing

Release downloads

Each GitHub release has three Linux x86_64 downloads, with SHA256SUMS. Each contains the binary and the CEF runtime it needs.

DownloadInstall
riptide_X.Y.Z_amd64.debUbuntu 24.04+ and Debian 13+: sudo apt install ./riptide_X.Y.Z_amd64.deb. It installs to /opt/riptide with riptide on your PATH, a desktop entry, and apt-managed dependencies. Its setuid sandbox helper works even where user namespaces are blocked.
riptide-X.Y.Z-linux-x86_64.AppImageAny distribution: chmod +x it and run it.
riptide-X.Y.Z-linux-x86_64.tar.gzUnpack it anywhere and run riptide.

To check a download, run sha256sum -c SHA256SUMS --ignore-missing, or verify where it was built with gh attestation verify <file> --repo joshzcold/riptide.

The nightly pre-release is rebuilt from main every night it changes. It has the newest features, and may be broken.

macOS and Windows builds compile and pass their unit tests, but can't run the browser yet; they need the app bundle and installer work that's still planned.

Nix and Arch Linux

Both install the latest release's Linux tarball.

  • Nix (flakes): nix run github:joshzcold/riptide, or add github:joshzcold/riptide as a flake input and use its packages.x86_64-linux.default. The package (packaging/nix/package.nix) patches the binaries for NixOS. The Nix store can't hold a setuid sandbox helper, so it relies on user namespaces for the sandbox, which NixOS allows.
  • Arch Linux: packaging/aur/PKGBUILD builds riptide-bin. It installs to /opt/riptide with riptide on your PATH, plus a desktop entry and icon. Run makepkg -si in that directory. It isn't published to the AUR yet.

To package a tarball you built yourself with Nix:

./task package
nix-build -E 'with import <nixpkgs> {}; callPackage ./packaging/nix/package.nix {
  tarball = ./dist/riptide-0.1.0-linux-x86_64.tar.gz; }'

Building from source

Requirements: Rust 1.88+ (edition 2024).

Tasks run through Task. The ./task wrapper uses your installed task if there is one. Otherwise it downloads a pinned, checksum-verified release into .bin/.

./task setup          # once: download CEF (~1.5 GB) into $CEF_PATH, default ~/.local/share/cef
./task run            # build and launch; or: ./task run -- example.com

The developer guide covers the build in more detail, including building without Task.

The sandbox

On Linux, Chromium's sandbox needs unprivileged user namespaces or a setuid-root chrome-sandbox next to the binary. Riptide checks at startup. If neither is available, it runs without the sandbox and logs a warning. The help page (:help) shows the result under "Sandbox". --no-sandbox turns it off on purpose.

Ubuntu 23.10 and later block user namespaces through AppArmor unless a program has a profile that allows them. Pick one of these fixes:

  • An AppArmor profile (recommended). It only affects this binary. Save it as /etc/apparmor.d/riptide, then load it with sudo apparmor_parser -r /etc/apparmor.d/riptide:
    abi <abi/4.0>,
    include <tunables/global>
    
    profile riptide /path/to/riptide/target/*/riptide flags=(unconfined) {
      userns,
      include if exists <local/riptide>
    }
    
  • Setuid helper: sudo chown root:root target/debug/chrome-sandbox && sudo chmod 4755 target/debug/chrome-sandbox. A rebuild that copies the file again undoes this.
  • System-wide: sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0. It lowers the hardening for every program.

Inside an AppImage, chrome-sandbox can't be setuid, so the sandbox needs user namespaces.

macOS and Windows builds run without the sandbox for now; it needs the app bundle and installer work that's still planned.

Window managers

riptide's windows have the class riptide (WM_CLASS riptide, Riptide) for window manager rules and docks, and riptide's logo as their icon. They can be resized, so tiling window managers tile them, call windows included. Before 2026-10-07 they asked for a fixed 1280×800, which made dwm and similar window managers float them.

If another browser's screen-share picker doesn't list riptide's window, check its state with xprop WM_STATE and click the window. Programs that list windows skip any that aren't in the Normal state. dwm's swallow patch leaves a program started from a terminal in the Withdrawn state, because it marks the program's window instead of the terminal's. Either fix the patch (in swallow(), mark p instead of c as withdrawn), or exclude riptide from swallowing with a rule for the class Riptide.

Where things live

Run riptide --paths to see the config and data directories.

PlatformConfig directoryData directory (profile, cookies, cache)
Linux$XDG_CONFIG_HOME/riptide, default ~/.config/riptide$XDG_DATA_HOME/riptide, default ~/.local/share/riptide
macOS~/.config/riptide (like Neovim, WezTerm, Zed)~/Library/Application Support/riptide
Windows%APPDATA%\riptide\config%LOCALAPPDATA%\riptide\data

XDG_CONFIG_HOME and XDG_DATA_HOME are honoured on every platform. --basedir DIR puts everything under DIR/config and DIR/data, which is handy for a separate profile.

Keys and modes

Riptide is modal, like vim and qutebrowser. Keys do different things depending on the mode, which the status bar shows:

ModeWhat keys doEnter withLeave with
normalRun commands: scroll, follow links, switch tabsEscape from any other mode—
insertGo to the page, for typing into text fieldsi, or automatically when a text field gets focusEscape
commandEdit a :command line: (or o, O, go…)Return runs it, Escape cancels
hintPick an element by its labelf, F, ;y…choosing a label, or Escape
caretMove a text cursor and select textv / VEscape
passthroughEvery key goes to the pageCtrl-vShift-Escape

Most keys take a count first: 5j scrolls five times, 3J moves three tabs right, 50G jumps to 50%.

Common keys

This is a tour, not the whole list. The default key bindings page has every binding in every mode, and :help bindings shows yours.

KeysCommand
j k h lscroll down/up/left/right (accept a count, e.g. 5j)
gg / Gscroll-to-perc 0 / scroll-to-perc (50G = 50%)
0 / $Scroll to the far left / right
Ctrl-d Ctrl-uHalf page down / up
Ctrl-f Ctrl-bFull page down / up
H / Lback / forward
r / Rreload / reload -f
o / O:open / :open -t (new tab)
go / gOEdit the current URL, in this tab / a new tab
Ctrl-topen -t (start page in a new tab)
J K, gTtab-next / tab-prev
Alt-1…Alt-9, g0 g$tab-focus N / first / last (a count also works, e.g. 3J)
Ctrl-Tab, Ctrl-^tab-focus last (previously focused tab)
gt, T:tab-select: a list of the open tabs, this window's first (by number; other windows' as window/tab). Type a number to pick that tab, or words to filter by title or URL, then Return
gDtab-give: move the tab to a new window
:tab-clone [-b] [-w], :tab-give [N], :tab-take W/TDuplicate the tab (in the background / a new window), move it to window N or a new window, or bring a tab here from another window. Pages are reopened, so their back/forward history stays behind.
d, Ctrl-wtab-close
u, Ctrl-Shift-tundo (reopen the last closed tab where it was)
gJ gK, gmtab-move + / - / to the start (or to the count)
cotab-only (keeps pinned tabs; :tab-only --force closes them too)
Ctrl-ptab-pin: pin or unpin the tab (a count picks one, e.g. 3 Ctrl-p)
f / F / ;bHint elements; click / open in a new tab / open in a background tab. With hints.mode = "number", labels are numbers and typing letters narrows the elements by their text (a single match is followed). Elements inside iframes (from any site, such as Gmail's Chat) and inside web components' shadow DOM are hinted too.
;y / ;h / ;tHint a link to yank / an element to hover / an input to focus
;i / ;IHint an image; open it here / in a new tab
;o / ;OHint a link and put :open (or :open -t) with its URL on the command line
;rRapid hinting: open several links in background tabs (leave with Escape)
yy / yt / ydYank the URL / title / domain (yY yT yD: to the primary selection). Tracking parameters in url.yank_ignored_parameters (utm_*, fbclid…) are left out of the URL.
pp / PpOpen the clipboard contents here / in a new tab (pP / PP: the primary selection)
mQuickmark this page (type a name, then Return)
b / BOpen a quickmark here / in a new tab
MBookmark this page
gb / gBOpen a bookmark here / in a new tab
`a / 'aSet / jump to mark a: a–z remember this page's scroll position, A–Z also the page itself; '' returns to where the last jump started
Ctrl-e (insert mode)open-editor: edit the text field in editor.command
gu / gUnavigate up: one level up the URL, here / in a new tab (a count goes further)
[[ ]] / {{ }}navigate prev / next: follow the page's previous/next link (rel links, or link text matching hints.prev_regexes / hints.next_regexes, such as "Next »"), here / in a new tab
Ctrl-a / Ctrl-xnavigate increment / decrement: change the last number in the URL (page/9 → page/10)
Return / Ctrl-Returnselection-follow: follow the link a search found (or the focused link) here / in a new tab; otherwise the page gets the key
/ ? then n NFind text in the page forward / backward, then go to the next / previous match. Matches highlight as you type (search.incremental); case is ignored unless the text has a capital (search.ignore_case). :search with no text clears it.
v / VCaret mode: move with h j k l w b e 0 $ gg G, and between blocks with [ ] (start of the previous/next) and { } (end of the previous/next). Select with v (or V for lines), drop the selection with Ctrl-Space, swap the ends with o, yank with y (Y: to the primary selection), leave with Escape
qa … q / @aRecord a macro into register a / replay it (@@ repeats the last one, 3@a runs it three times). Keys typed into pages are replayed too.
F1, :help [topic]Help: every command, setting (with its current value and where it was set) and key binding, generated from the running browser. :help :open, :help hints.chars, :help rt.ui.float, :help <plugin> and :help bindings jump to an entry, and :help completes them; / searches.
:versionVersion, git commit, CEF/Chromium versions, paths and loaded config files
:history [-t]Browsing history by day, with a search box
:history-import [path]Import qutebrowser's history.sqlite (default: qutebrowser's data directory); importing twice adds nothing new
:q, :qaClose this window (closing the last one quits) / quit, closing every window. As in qutebrowser, these are aliases for :close and :quit (aliases).
ZZ, :wqSave the tabs as the default session and quit (ZQ quits without saving)
:Command line
iInsert mode (also entered automatically when a text field gets focus)
Ctrl-vPassthrough mode (leave with Shift-Escape)
EscapeLeave insert mode, or clear a pending key sequence
ZQ ZZ Ctrl-qquit

Opening pages

o (:open) takes an address or words to search for. Text that looks like an address (example.org, localhost:8080) opens; anything else goes to the DEFAULT search engine in url.searchengines. Start with an engine's name to use it instead:

[url.searchengines]
DEFAULT = "https://duckduckgo.com/?q={}"
w = "https://en.wikipedia.org/w/index.php?search={}"

:open w riptide searches Wikipedia. url.auto_search changes when text is searched: schemeless searches everything without https:// or another scheme, and never opens it as an address unless it starts with an engine's name. With url.open_base_url = true, :open w alone opens Wikipedia's home page.

Ctrl-a and Ctrl-x add or take one from the last number in the URL's path or query (page/9 → page/10). url.incdec_segments picks which parts they look at: host, port, path, query, anchor.

Searching, scrolling and zoom

/ and ? search the page, and n/N move between matches. Searches go on from the top after the last match, saying so, unless you set search.wrap = false (they stop at the last match) or search.wrap_messages = false (they wrap quietly). search.ignore_case (smart, always, never) and search.incremental set how matching works.

scrolling.smooth = true animates scrolling by keys, and scrolling.bar shows page scrollbars always, never or as thin overlay ones (after a restart). + and - step through zoom.levels, and = goes back to zoom.default:

"zoom.levels" = ["50%", "75%", "100%", "125%", "150%", "200%"]

Insert mode and key mappings

Clicking into a text field enters insert mode (input.insert_mode.auto_enter), and loading a new page leaves it (input.insert_mode.leave_on_load). A field the page focuses by itself, like a search box with autofocus, doesn't take insert mode unless you set input.insert_mode.auto_load = true. Your keys keep working in normal mode until you click or press i.

input.mode_override picks the mode a site's pages start in, and when you switch to their tab. Set it per site, e.g. :set -u ssh.example.com input.mode_override passthrough for a web terminal.

Digits before a binding are a count (3j); input.match_counts = false lets you bind digits themselves. A half-typed chain like g waits for the next key, or for input.partial_timeout milliseconds if you set one. :cmd-repeat 3 tab-next runs a command several times and :cmd-run-with-count 3 tab-focus gives it a count, which helps in bindings. :debug-keytester shows the name and binding of each key you press, until Escape, which helps when writing bindings.

bindings.key_mappings treats one key as another in every mode, before bindings are looked up. By default Ctrl-[ is Escape, Ctrl-m and Ctrl-j are Return, Ctrl-i is Tab and Ctrl-6 is Ctrl-^. To add your own, include the defaults you want to keep:

[bindings.key_mappings]
"<Ctrl-[>" = "<Escape>"
"<Ctrl-m>" = "<Return>"
"<Ctrl-g>" = "<Escape>"

The mouse's back and forward buttons go back and forward. With input.mouse.rocker_gestures = true, holding the right button and clicking the left goes back, and the other way round goes forward; pages lose their context menu.

input.spatial_navigation = true moves focus between links and fields with the arrow keys in passthrough and insert mode. input.media_keys = false stops the keyboard's play and pause keys from controlling pages. Both apply after a restart.

Hints

:hint [group] [target] labels elements and acts on the one you pick. f is :hint, and ;y is :hint links yank. The groups come from hints.selectors:

  • Built in: all, links, images, media, inputs, and blocks, the page's parts, which ;x (:hint blocks hide) uses to hide one for good.
  • Your own: add a group with a CSS selector list. Your groups are added to the built-in ones, which stay.
c.hints.selectors = { code = "pre, code" }
rt.bind(";c", "hint code yank")

When a hint is followed is set by hints.auto_follow:

ValueFollows
unique-match (default)As soon as one hint is left
full-matchOnly when you type a whole label, not when number-mode text narrows to one
alwaysEither way
neverOnly when you press Return (:hint-follow)

Labels use hints.chars. hints.min_chars makes them at least that long, and hints.scatter = false hands them out in order instead of spreading them over the alphabet. A page that starts loading ends hint mode unless hints.leave_on_load = false.

hints.mode = "word" labels each link with a word from its own text or URL, so you type news for a News link. Words come from hints.dictionary (default /usr/share/dict/words); links without a usable word get the shortest unused one. In rapid mode (:hint --rapid), hints.hide_unmatched_rapid_hints = false keeps the labels you're not typing on screen.

hints.auto_follow_timeout ignores keys for a moment after a hint is followed, so a fast second keystroke doesn't land in the page. :hint --rapid (;r) keeps the labels up after each pick.

Key hints

Type the start of a key chain, like g or ;, and pause: after keyhint.delay (500 ms), a popup lists every binding the keys can still become, with its command. Finish the chain to run it, or press Escape.

To leave chains out of the popup, add globs to keyhint.blacklist. They match the whole chain:

keyhint.blacklist = ["<Ctrl-x>*", "g$"]

The command line

In the command line, Tab / Shift-Tab cycle through completions. :open completes from search engines, quickmarks, bookmarks, history and, for paths starting with / or ~/, files. Every typed word must match, in any order.

In :open, history, bookmarks, quickmarks and tabs show each site's icon (remembered from your visits, but not from private windows). Command names, settings, setting values and themes match anywhere in the name: :set hints offers hints.chars and also colors.hints.bg. Names that start with what you typed come first, then names with a part that starts with it (after a ., - or _), then the rest. When no name contains what you typed, its letters in order still match, so :set clrhnt offers colors.hints.*; names with fewer gaps come first. The matched text is highlighted in the list (colors.completion.match.fg).

SettingWhat it does
completion.open_categoriesWhich of those :open offers, in order
completion.web_history.excludeURL globs never suggested from history, e.g. ["*://*.bank.example/*"]
completion.showalways, only after pressing Tab (auto), or never
completion.heightRows (12) or a share of the window (50%)
completion.min_charsCharacters to type after the command before its arguments complete
completion.cmd_history_max_itemsHow many command lines Up and Down remember
completion.shrinkfalse keeps the list completion.height tall however few items it has
completion.timestamp_formatWhen each history entry was last visited, e.g. %d %b %H:%M; empty hides it
completion.delayMilliseconds to wait after a key before updating the list, for slow history searches
completion.quickWith one command or setting name left, Tab takes it and goes on to its arguments
ui.overlay.positionfloating shows the command and its list in a box near the top of the page (Themes)
completion.use_best_matchReturn on an unknown command runs the first one it starts, so :rel runs :reload
:set, :quickmark-load, :bookmark-load and :session-load complete their own names. :session-save [name], :session-load name and :session-delete name manage sessions. With auto_save.session = true, the tabs are saved on quit and restored at the next start.

The command line and prompts support readline keys (Ctrl-a/e/u/k/w/h, Alt-b/Alt-f by word, Alt-d/Alt-Backspace to delete a word, Ctrl-y to paste what was last deleted, Ctrl-v to paste the clipboard and Shift-Insert the primary selection, arrows), history (Up/Down), command chaining with ;;, and completion of command names.

To clean up what completion offers, select an entry with Tab and press Ctrl-d (:completion-item-del). It deletes history entries, quickmarks, bookmarks and sessions, and closes tabs listed by T. Ctrl-c (:completion-item-yank) copies the selected entry, and Ctrl-Shift-c copies it to the primary selection.

Every command is listed in the commands reference. . repeats the last command, and :messages shows earlier status bar messages.

The status bar

The status bar shows the mode, messages and the command line on the left. On the right are widgets, in the order statusbar.widgets lists them:

WidgetShows
keypressKeys typed so far, and the count
urlThe page's address, green for HTTPS
scroll / scroll_rawHow far down the page you are: [Top], [42%], [Bot], [All] / just the number
history[<] and [>] when you can go back or forward
tabs[current/total]
progressLoading progress
search_matchMatch [2/14] after a / search
downloads, muted, zoomRunning downloads, a muted tab, and a zoom other than 100%
mediaWhat the tab captures: [V], [A], [A/V], [Share]
sharing[sharing your screen] (or a window or tab) while any tab shares; :share-stop stops it
clock, clock:%a %H:%MThe time, in an optional strftime format
text:…Fixed text
lua:<name>Text from config.lua or a plugin; see Status bar widgets
statusbar.widgets = ["keypress", "url", "scroll", "tabs", "clock:%H:%M"]

statusbar.position puts the bar at the top or bottom. statusbar.show hides it:

  • never keeps it hidden except while you type a command or answer a prompt, since those happen in the bar.
  • in-mode also shows it outside normal mode, and while a message is up.

Tabs and windows

The tab keys are in Keys and modes: J/K switch tabs, d closes one, u reopens it, T picks one by name.

Pinned tabs

Pinned tabs stay where you put them (drag or :tab-move them anywhere, between unpinned tabs too), shrink to their icon and number (tabs.pinned.shrink), and survive co unless you add --force. d on a pinned tab asks first (tabs.pinned.close: ask, refuse or close); :tab-close --force doesn't, and u reopens it pinned. With tabs.pinned.frozen (the default), :open in a pinned tab opens a new tab instead. Sessions remember which tabs are pinned.

The tab bar

The tab bar shows site icons (tabs.favicons.show: always, never or pinned) and works with the mouse: click to select, middle-click to close (tabs.close_mouse_button: middle, right or none; on the empty part of the bar it opens a new tab, see tabs.close_mouse_button_on_bar), scroll to switch (tabs.mousewheel_switching), and drag to reorder: the tab shrinks and follows the pointer, and the others move aside to show where it lands.

The current tab is the darkest in the bar. To also underline it (or, in a vertical bar, mark its right edge), set colors.tabs.selected.accent to a CSS color, e.g. :set colors.tabs.selected.accent #2ec4b6; :config-unset colors.tabs.selected.accent removes the line again.

Tab titles follow tabs.title.format (default {audio}{media}{index}: {current_title}), and shrunk pinned tabs follow tabs.title.format_pinned (default {index}). The fields are {index}, {aligned_index}, {current_title}, {current_url}, {host}, {perc} (loading progress), {audio} ([M] on a muted tab), {media} and {private}.

{media}, and the status bar's media widget, show what a page is capturing: [V] while it uses a camera, [A] for a microphone, [A/V] for both, and [Share] while it shares your screen, a window or a tab (see Sharing your screen). tabs.tooltips = false turns off the title-and-URL tooltip.

In a top or bottom bar, tabs share the width evenly. tabs.max_width caps each tab and tabs.min_width keeps them from getting narrower; once they don't fit, the bar scrolls to keep the current tab in view. tabs.title.alignment (left, center, right) places the title, and tabs.indicator.width sets the loading indicator's width (0 hides it):

tabs.max_width = 250
tabs.min_width = 120
tabs.title.alignment = "center"

tabs.position puts the bar at the top, bottom, left or right (a vertical list, tabs.width pixels wide), and tabs.show hides it: always, never, multiple (only with more than one tab) or switching (briefly after switching tabs).

New tabs and popups

Links that open new windows (target=_blank, window.open) open as tabs next to the current one, keeping window.opener. Closing the last tab is ignored, like qutebrowser.

After you close the current tab, tabs.select_on_remove picks the next one to show: next (default), prev, or last-used. J/K wrap around from the last tab to the first unless tabs.wrap = false. u can reopen the last tabs.undo_stack_size closed tabs (100 by default).

Where new tabs go is set by tabs.new_position.related (tabs opened from a page) and tabs.new_position.unrelated (everything else).

Modes per tab

With tabs.mode_on_change = "restore", each tab keeps its own mode: leave a tab while typing in insert mode, and you're back in insert mode when you return. The default normal leaves insert mode on every switch, and persist keeps the current mode.

Windows and private windows

:open -w url opens a new window and :open -p url a private one. Private windows use an in-memory profile shared by all private windows: no cookies or cache on disk, no history, and they're left out of sessions. Their status bar is gray. :close closes the current window and :quit closes all of them. Sessions save and restore every normal window.

Call windows

:open --call url opens a video call in a call window. When the call's page shares your screen, Chrome's own picker opens, so you can share a single window or the whole screen, or a tab with its sound. In an ordinary tab, screen sharing asks in the status bar and always shares the whole screen.

:open --call https://meet.google.com/abc-defg-hij

Well-known call services open in a call window by themselves: Google Meet, Microsoft Teams, Zoom's web client and join links, Webex, Jitsi Meet and Whereby. Their URL patterns are the default of content.call_sites; :open and links in ordinary tabs send matching pages to a call window, and once the page has loaded the status bar says so. :tab-call reopens any other tab in one.

To add a service, extend the list. To stop opening calls in their own window, clear it; screen sharing on those sites then shares the whole screen, unless you open the call with :open --call or :tab-call.

-- Setting the list replaces it, so list every site you want, e.g. your own Jitsi:
c.content.call_sites = { "meet.google.com", "teams.microsoft.com", "meet.example.org" }
-- Or opt out:
c.content.call_sites = {}

The settings page (:settings) and :help content.call_sites show the default list.

  • Only the first tab: only the tab a call window opened with shares this way. Further tabs you open in that window are ordinary ones.
  • Popups: popups from the call tab open in call windows of their own.
  • The picker's Tab list only offers call tabs, not riptide's ordinary tabs.
  • Sessions: call windows are saved as ordinary windows, so a restored call shares the whole screen until you reopen it with --call.
  • Turning it off: content.desktop_capture = false still refuses screen sharing everywhere, call windows included.
  • In the background: a call keeps running at full speed in a tab you've switched away from, as in Chrome, since it plays sound. A silent page in a background tab has its timers slowed to once a second.
  • Stopping a share: see Sharing your screen.

Sharing your screen

While a page shares your screen, a window or a tab, its tab shows [Share] in place of [V] ([Share/A] with a microphone). The status bar's sharing widget says what's being shared, such as [sharing your screen], whichever tab you're on. When a share starts, the status bar names the site.

:share-stop stops every share, in any tab or window. The page is told as if you'd pressed Chrome's "Stop sharing", so the call site updates its own buttons. You can still stop from the site or from Chrome's bar in a call window.

Picture-in-picture

gp (:pip) floats the page's main video in a small window that stays on top while you work in other tabs, such as a call or a talk; gp again brings it back. Sites' own picture-in-picture buttons work too, including Meet's floating call window.

Some tiling window managers don't float it by themselves; it's titled "Picture in picture". In dwm, a rule floats it:

{ NULL, NULL, "Picture in picture", 0, 1, 0, 0, -1 },

Muting a call from another tab

cm (:call-mute) mutes or unmutes your microphone in the call while you're in another tab. It presses the call site's own mute key in the tab that's using the microphone, so the site's mute button stays right. content.call_mute_keys has each site's key (Meet <Ctrl-d>, Teams <Ctrl-Shift-m>, Zoom <Alt-a>, Webex <Ctrl-m>, Jitsi m); add others the same way.

  • A flash of the call: Chromium only takes keys in the tab that's showing, so the call tab shows for a moment and then the tab you were on comes back. The first time, it shows for under a second, while the page gets ready for keys.
  • Another window: a call in another window isn't reached; riptide names the key to press there.

tabs.tabs_are_windows = true opens every tab, and every popup, in its own window and hides the tab bar, which suits tiling window managers that arrange windows themselves.

window.hide_decoration = true asks the window manager for windows without a title bar or borders, which suits tiling window managers. It applies to windows opened after the change.

:tab-give and :tab-take move tabs between windows, and :tab-clone duplicates one.

Sessions, history and bookmarks

Where browsing data lives

WhatWhereFormat
History<data>/history.sqliteSQLite; :history-clear --force empties it
Quickmarks<config>/quickmarksqutebrowser's: one name url per line
Bookmarks<config>/bookmarks/urlsqutebrowser's: one url title per line
Sessions<data>/sessions/<name>.tomlTOML

Quickmarks and bookmarks sit next to the config, so you can keep them in dotfiles. Sessions keep each tab's page, its back/forward history (up to 50 pages each way) and how far it was scrolled.

In a restored tab, H and L load the saved pages again, so they come from the network rather than the cache, and anything typed into forms on them is gone. The scroll position is the one at the last autosave (auto_save.interval).

Sessions

:session-save [name], :session-load name and :session-delete name manage sessions, and :session-load completes their names. ZZ or :wq saves the tabs and quits; ZQ quits without saving. With auto_save.session = true, the tabs are saved on quit and restored at the next start.

Unnamed saves and the restore at startup use session.default_name. Left empty (the default), that's the session you last loaded with :session-load, or default if you haven't loaded one, so after :session-load work a :wq saves work.

:save writes everything to disk now: config, cookies, quickmarks, bookmarks and the session. Name some of them to save only those, e.g. :save cookies session.

With session.lazy_restore = true, a restored session loads only the tab you're on. The others keep their titles in the tab bar and load when you first switch to them, which makes restoring many tabs fast.

confirm_quit asks before :quit, or before closing the last window, quits the browser. List the reasons to ask: multiple-tabs (more than one tab is open), downloads (downloads are still running), always, or the default never:

confirm_quit = ["multiple-tabs", "downloads"]

Crash recovery

Every auto_save.interval milliseconds (15 s by default, 0 turns it off), the open tabs are saved for crash recovery. Quitting riptide deletes that save. Being stopped from outside keeps it, like a crash: Ctrl-C in its terminal, the terminal closing, kill, or logging out.

After a crash, the next start keeps the saved tabs as a session named after the time of the crash, such as _crashed-2026-10-06-115803 (UTC). The last five crashes are kept, and :session-load _crashed- completes their names.

Start after a crashWhat happens
Plain riptideThe crashed tabs reopen.
With URLs on the command lineOnly those URLs open. A message names the session holding the crashed tabs, and the new run's autosaves don't touch it.
The browser crashed again within a minute of reopening a crash's tabsThey aren't reopened a second time, in case they caused the crash. The start page opens, and a message points to :recover.

:recover lists the tabs of each kept crash, by window. Tick the ones you want and press Reopen selected, or Reopen all; they open in the current window with their back/forward history. Forget deletes that crash's tabs.

Crash reports

When riptide itself crashes because of a bug (a Rust panic), it writes a report to crashes/ in its data directory (riptide --paths shows where). The report has the version, the error and a backtrace, and no URLs or page content. The next start shows a message with the report's path. The newest ten reports are kept, and nothing is sent anywhere unless you send it.

:crash-report shows the newest report in a tab, where you can edit it before sending:

  • Open a GitHub issue: fills in a new issue with the report. A report too long for a link is shortened, and the page asks you to attach the file.
  • Email it: opens your mail program with the report. It only appears when crash_report.email is set.
  • Copy: copies the report.

Backtraces name the source files of the build, which for a build of your own include its directory.

When Chromium itself crashes (the browser, or a page's process), its crash reporter leaves a crash dump in pending/ or completed/ in the data directory. The next start mentions it, and :crash-report lists the dumps with their paths. A dump is a binary snapshot of the crashed process's memory, so it can hold parts of the pages that were open: only attach one to an issue if you're comfortable with that. Dumps are never uploaded, and the newest ten are kept. The crash reporter is turned on by crash_reporter.cfg next to the riptide executable, which riptide writes and the packages ship.

A crashed tab

Pages run in processes separate from the browser. If a page's process crashes, runs out of memory or is killed, the browser keeps running and the tab shows a notice saying why. r reloads the page, and the tab keeps its back and forward history.

History

:history (-t for a new tab) shows your browsing history by day, with a search box. :open completes from history as you type, with every typed word matching somewhere in the title or URL. completion.web_history.max_items sets how many entries completion shows. :history-clear --force empties it, and :history-import brings in qutebrowser's.

Quickmarks and bookmarks

KeysDoes
mQuickmark this page (type a name, then Return)
b / BOpen a quickmark here / in a new tab
MBookmark this page
gb / gBOpen a bookmark here / in a new tab

:quickmark-add, :quickmark-del, :bookmark-add and :bookmark-del do the same from the command line.

:bookmark-list shows your quickmarks and bookmarks on a page (-t in a new tab). If you edit the quickmarks or bookmarks/urls files by hand, :quickmarks-reload (or :bookmarks-reload) reads them again. :quickmark-save writes the quickmarks file, although every change is saved anyway.

Restarting

:restart saves your tabs, quits, and starts riptide again with the same config and data directories, then restores them. Use it after changing a setting that only applies at startup, like colors.webpage.darkmode.enabled.

Prompts, downloads and permissions

Prompts

Everything that needs an answer appears in a box floating near the bottom of the page, one at a time. prompt.position = "center" puts the box in the middle of the page, "docked" puts it in a strip above the status bar, and prompt.width sets the box's width. The floating box lists each answer on its own line with its key, and you can click an answer instead of pressing the key:

  • JavaScript alert, confirm, prompt and leave-page warnings
  • HTTP logins (username, then a hidden password)
  • where to save a download
  • site permission requests (camera, microphone, location, notifications…)
ModeKeys
prompt (text)type, readline keys (Ctrl-w deletes one path component), Return accepts, Escape cancels
yesnoy / n, Return (the default), Escape cancels

Permissions

A permission request shows the asking site in large type, with its host in bold so a look-alike name stands out, and an icon for each thing it wants (camera, microphone, location, notifications, screen…). A question belongs to the tab that asked: if a tab in the background asks, you stay where you are, the tab gets a ? badge in the tab bar, and the question shows when you go to that tab.

For permission prompts:

  • y allows once and n (or Escape) means "not now".
  • A always allows and N always blocks. These are saved as per-site settings in autoconfig.toml, as in qutebrowser, so they survive restarts. That includes camera and microphone. Chromium also remembers y for its own permission prompts.

The content.geolocation, content.notifications.enabled, content.media.audio_capture, content.media.video_capture, content.desktop_capture, content.mouse_lock (pointer lock, as games use) and content.register_protocol_handler (a site offering to handle mailto: links) settings (ask, true or false) answer without asking.

Notifications

content.notifications.presenter picks where notifications from sites go:

PresenterWhere
auto (the default)Your desktop, sent by Chromium
libnotifyYour desktop, sent by riptide with notify-send (from libnotify), following the settings below. Clicking one shows its tab.
messagesriptide's status bar, for messages.timeout milliseconds

With libnotify:

SettingWhat it does
content.notifications.app_nameThe app name on the notification (riptide), which notification services can match to style riptide's own
content.notifications.urgencylow, normal or critical
content.notifications.timeoutMilliseconds it stays; -1 lets your desktop decide and 0 keeps it until you dismiss it
content.notifications.site_iconShow the site's icon
content.notifications.show_originPut the site it came from on the first line (this also starts status bar messages with it)

How notifications look (colors, fonts, position, sound) is up to your notification service: dunst, mako, GNOME or KDE. Most can style one app differently, for example in dunst:

[riptide]
appname = riptide
background = "#181616"
foreground = "#c5c9c5"

If notify-send isn't installed, riptide shows the notification in the status bar and says why.

A link riptide can't show, like mailto:, magnet: or zoommtg:, asks before going to your desktop's handler (xdg-open). content.unknown_url_scheme_policy = "allow-all" hands them over without asking, and "disallow" never does.

Untrusted TLS certificates (self-signed, expired, wrong host…) ask before the page loads: y loads it once, A always loads that site, N always blocks it. content.tls.certificate_errors (ask, block or load-insecurely) sets the default and can be set per site. :debug-clear-ssl-errors forgets the y answers given this session.

Per-site settings

The permission settings above, content.tls.certificate_errors, content.blocking.enabled, content.headers.user_agent and the content.* settings listed in Privacy can differ per site. The last matching pattern wins. Patterns are hosts (example.com, *.example.com for subdomains too), origins (https://meet.example.com) or match patterns (*://*.example.com/app/*):

:set -u https://meet.example.com content.media.video_capture true
:set -u *.example.org content.javascript.enabled false

:config-unset -u https://meet.example.com content.media.video_capture forgets one site's value, and the Sites tab of :settings lists and forgets them with a click.

[per_domain."*.example.com"]          # config.toml or autoconfig.toml
"content.blocking.enabled" = false
rt.set("content.geolocation", "false", "*.tracker.example")  -- config.lua

Downloads

Downloads go to downloads.location.directory, or the system Downloads folder if that's empty (on Linux, XDG_DOWNLOAD_DIR or ~/.config/user-dirs.dirs). Server-suggested names are reduced to a plain file name, existing files get (1) appended, and typing an existing path asks before overwriting. Set downloads.location.prompt = false to skip the question. The status bar shows ↓2 41% while downloads run.

Command
:download [url]Download a URL, or the current page
;dHint a link to download
:download-cancel, :download-openThe newest running / finished download, or the one given as a count (2:download-open)
:download-retryStart the newest failed or cancelled download again
:download-remove [--all]Take a download off the list, cancelling it if it's running; --all takes every finished one, like :download-clear
:download-deleteDelete the newest finished download's file
:download-clearForget finished downloads
:downloadsA page listing this session's downloads with their numbers and progress

In the "Save file to" prompt, Tab completes file and directory names, as in a shell, and Alt-e picks the folder with fileselect.folder.command (see below). Ctrl-x opens the file instead of keeping it: it downloads to a temporary folder and opens with downloads.open_dispatcher or your desktop's default; :prompt-open-download zathura names a program. Alt-y copies the download's URL, in any prompt that has one.

:download-open uses downloads.open_dispatcher if you set one ({} is the file, or it goes at the end), and the system's opener (xdg-open, open or start) otherwise.

SettingWhat it does
downloads.location.suggestionWhat the save prompt starts with: folder and name (both), the folder (path), or the name (filename; a bare name saves into the download folder)
downloads.location.rememberStart the prompt in the folder the last download went to (on by default)
downloads.remove_finishedTake finished downloads off the list after this many milliseconds (-1, the default, keeps them)

Choosing files to upload

Upload fields open Chromium's file dialog. To use a terminal file manager instead, set fileselect.handler = "external". Riptide runs the command for the field (fileselect.single_file.command, fileselect.multiple_files.command or fileselect.folder.command) with {} replaced by a file to write the chosen paths to, one per line. The defaults run ranger in xterm, as qutebrowser does. For yazi in foot:

fileselect.handler = "external"
fileselect.single_file.command = ["foot", "yazi", "--chooser-file={}"]
fileselect.multiple_files.command = ["foot", "yazi", "--chooser-file={}"]

Privacy and content blocking

Content blocking

Ads and trackers are blocked at the network level with Adblock Plus filter lists, using Brave's adblock-rust. Run :adblock-update once to download the lists in content.blocking.adblock.lists (by default EasyList, EasyPrivacy, and uBlock Origin's own lists: uBlock filters, Privacy, Quick fixes and Unbreak, as uBlock Origin enables them; file:// lists work too). The compiled engine is cached in the data directory and loads in the background at startup. Hosts files (lines like 0.0.0.0 ads.example.com, as in StevenBlack's lists) work in the same setting; riptide recognizes them and blocks each listed host:

"content.blocking.adblock.lists" = [
  "https://easylist.to/easylist/easylist.txt",
  "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts",
]

The status bar shows how many requests were blocked on the current page, e.g. ⊘12 (the blocked widget in statusbar.widgets).

  • content.blocking.enabled turns blocking on or off.
  • content.blocking.whitelist lists hosts where nothing is blocked (subdomains included).
  • Top-level pages are never blocked, so a bad rule can't make a site unreachable. Their tracking parameters are taken out, though ($removeparam): ?utm_source=mail&id=7 loads as ?id=7.
  • Element-hiding rules (##.ad, example.com##.sponsored) are applied once a page loads: the site-specific ones, and the generic ones for the classes and ids the page uses, checked again 2 and 6 seconds later for ads that arrive late.
  • Scriptlet rules (example.com##+js(set-constant, adsEnabled, false)) run in the page before its own scripts, which is how lists defeat anti-adblock walls and in-player video ads. $redirect rules answer a blocked request with a harmless stand-in (an empty script, a 1×1 image), so the page carries on as if it had loaded. Both use uBlock Origin's scriptlets and stand-ins, built into riptide. As in uBlock Origin, the "trusted" scriptlets, which can click page elements or set arbitrary values, only run for rules from uBlock Origin's own lists.

If you set content.blocking.adblock.lists yourself before uBlock Origin's lists became defaults, add them to get most of the scriptlet rules:

"content.blocking.adblock.lists" = [
  "https://easylist.to/easylist/easylist.txt",
  "https://easylist.to/easylist/easyprivacy.txt",
  "https://ublockorigin.github.io/uAssets/filters/filters.min.txt",
  "https://ublockorigin.github.io/uAssets/filters/privacy.min.txt",
  "https://ublockorigin.github.io/uAssets/filters/quick-fixes.min.txt",
  "https://ublockorigin.github.io/uAssets/filters/unbreak.min.txt",
]

Run :adblock-update after changing the lists.

Procedural element hiding works too, for elements CSS alone can't pick out: :has-text(), :upward(), :matches-css() (and -before, -after), :matches-attr(), :matches-path(), :min-text-length() and :xpath(), with the actions :remove(), :style(), :remove-attr() and :remove-class(). They're applied as the page loads and again whenever it changes, so content added later is caught too.

Frames get element hiding too, by the rules for their own site: an ad in a frame from another site is hidden like one in the page. The page's site still decides whether anything is blocked, so allowing a site (content.blocking.whitelist) covers its frames.

Not supported yet: scriptlets in frames from another site than the page (frames from the page's own site get them).

Your own rules

content.blocking.adblock.rules holds rules of your own, in the same syntax as the lists. They apply as soon as you change them, without :adblock-update, and work even with no lists at all:

"content.blocking.adblock.rules" = [
  "news.example.com##.newsletter-signup",
  "||tracker.example.net^",
]

To hide something on a page without writing the rule yourself, press ;x (:hint blocks hide) and pick it. riptide proposes a rule for it, such as example.com##.promo-box, which you can edit (to ##.promo-box for every site, say) before pressing Return. The element and others like it disappear at once, and the rule is saved to content.blocking.adblock.rules. To take a rule back, remove it from the setting, on the settings page or with :set.

Your rules are never trusted: their scriptlets can't use uBlock Origin's trusted ones.

Network traffic

Chromium calls Google in the background. riptide turns off the calls that only serve Google and keeps the security updates (crates/rt-cef/src/privacy.rs). Measured on a fresh profile left on about:blank for 90 seconds, with --log-net-log:

RequestPurposeStatus
update.googleapis.com, edgedl.me.gvt1.comComponent updates (all of them for one run if you turn on content.widevine)Only the components Chromium marks as security data still update: certificate revocation lists (CRLSets) and the subresource filter rules. The ~20 others no longer download, saving ~115 MB per profile. These include Widevine, optimization hints, the on-device suggest model, TTS and the password-strength data.
clients2.google.com/timeSecure network time, used to explain certificate date errorskept
redirector.gvt1.com/…/dictSpell-check dictionaryonly once per language in spellcheck.languages (empty by default)
www.google.com/async/folaeAI Mode eligibilityoff (--disable-features=AimEnabled)
www.google.com preconnectsDefault search engine warm-upoff (Chrome's default search engine is disabled; riptide has its own url.searchengines)
accounts.google.com/ListAccountsGoogle accounts in the cookie jarstill sent once at startup. Google sign-in is off, but something still asks for the cookie jar; it carries your google.com cookies if you have any.

Beyond the pages you visit, riptide goes online for what you set up or ask for: :adblock-update downloads the filter lists (from easylist.to and ublockorigin.github.io by default), :extension-install and :extension-update ask the Chrome Web Store (clients2.google.com), and plugins added with rt.pack.add are fetched with git the first time riptide starts with them, and when you check for updates.

The preferences are written into the profile (Local State, Default/Preferences) before Chromium starts, since most of these services start within 100 ms. To check for yourself: riptide --basedir /tmp/t --log-net-log=/tmp/net.json about:blank, then grep -o '"url":"[^"]*' /tmp/net.json | sort -u.

Proxy and network

SettingWhat it does
content.proxysystem (default), none, a proxy URL (socks5://127.0.0.1:9050, http://proxy:3128), or pac+ and a PAC script's URL. Names are looked up through a SOCKS5 proxy, not locally
content.webrtc_ip_handling_policyWhich addresses video calls may reveal: all-interfaces (default), default-public-and-private-interfaces, default-public-interface-only, or disable-non-proxied-udp to keep WebRTC behind the proxy
content.dns_prefetchfalse stops looking up the hosts of links before you follow them
content.canvas_readingfalse stops pages reading back what they drew, a common fingerprinting trick; some sites break (after a restart)
content.cache.sizeDisk cache size in bytes; 0 lets Chromium choose (after a restart)
content.local_content_can_access_file_urlstrue lets file:// pages read other local files (after a restart)
content.webglfalse turns off WebGL, which 3D graphics need and fingerprinting scripts use (after a restart)
content.proxy = "socks5://127.0.0.1:9050"
content.webrtc_ip_handling_policy = "disable-non-proxied-udp"

Cookies, JavaScript, images and the user agent

SettingWhat it does
content.cookies.acceptall (default), no-3rdparty to refuse cookies from other sites embedded in a page, or never
content.cookies.storefalse makes every cookie last only until the browser closes
content.javascript.enabledfalse turns JavaScript off; set it per site to block or allow it on chosen sites only
content.headers.user_agentThe user agent sites see, in requests and in navigator.userAgent; empty for Chromium's own. Set it per site for sites that check it
content.headers.do_not_trackSends DNT: 1 (the default); false stops it
content.headers.referersame-domain (default) sends the Referer only within a site and its subdomains; always or never
content.headers.accept_languageThe languages sites are asked for, e.g. de-DE,de;q=0.9. Requests follow a change at once; navigator.languages after a restart
content.headers.customExtra headers for every request, e.g. { "X-Requested-By" = "me" }
content.imagesfalse stops loading images
content.autoplayfalse keeps videos from playing until you interact with the page (after a restart)
content.pdf_viewerfalse downloads PDFs instead of showing them
content.prefers_reduced_motiontrue asks pages for fewer animations (after a restart)
content.javascript.can_close_tabsfalse stops pages closing their own tab with window.close() (login popups do this)
content.javascript.log_message.levelsPage console messages to show in the status bar and :messages, e.g. ["error", "warning"]; per site too
content.mutetrue mutes pages; :tab-mute mutes one tab instead
content.javascript.can_open_tabs_automaticallytrue lets pages open tabs without a click (popups)
content.javascript.clipboardnone, access (copy after a click, the default) or access-paste (read it too)

content.images, content.mute, popups, the clipboard, JavaScript, console messages and the user agent can also be set per site:

content.cookies.accept = "no-3rdparty"

[per_domain."*.example.org"]
"content.javascript.enabled" = false
"content.images" = false

Private windows

:open -p url opens a private window. Private windows share an in-memory profile: no cookies or cache on disk, no history, and they're left out of sessions. Their status bar is gray.

The sandbox

Riptide runs Chromium's sandbox wherever Linux allows it. See Installing for how to enable it on Ubuntu.

Pages: dark mode, spell checking, DRM

Dark mode

  • colors.webpage.preferred_color_scheme (auto, light or dark) is what pages see in prefers-color-scheme. It applies immediately.
  • colors.webpage.darkmode.enabled = true renders light pages dark with Chromium's automatic dark mode. It applies at once to open tabs, and can be set per site, e.g. :set -u docs.example.com colors.webpage.darkmode.enabled false for a site that's already dark.
  • colors.webpage.bg is the color a new tab shows before its page paints; set it to a dark color such as #1e1e2e so loading pages don't flash white.

Saving and printing

CommandWhat it does
:printOpens the system print dialog
:print --pdf ~/page.pdfSaves the page as a PDF, backgrounds included
:screenshot ~/shot.pngSaves what the tab shows as an image; .jpg and .webp work too. It won't replace an existing file without --force.
gf, :view-sourceShows the page's source in a new tab
:debug-dump-page ~/page.htmlSaves the page's current HTML, as scripts have changed it
wi, :devtoolsOpens or closes the developer tools; :devtools-focus brings them to the front

Spell checking

Off by default. Turn it on with a list of languages, e.g. c.spellcheck.languages = { "en-US", "de-DE" } in config.lua or :set spellcheck.languages '["en-US"]'. Chromium then downloads each dictionary once from Google (redirector.gvt1.com) and underlines mistakes as you type.

To keep that request away from Google, install dictionaries yourself first: :spell-install en-US de-DE, or :spell-install alone to pick from the 47 languages (type a code or a name such as german) downloads them from Chromium's own dictionary repository at a fixed version, checks each file against a checksum built into riptide, saves it where Chromium looks, and turns the language on. Chromium then uses that file and fetches nothing.

From the keyboard, in a text field:

  • :spell-suggest lists fixes for the word at the text cursor as completions. Tab picks one, Return replaces the word, and you're back in insert mode.
  • :spell-add adds that word to your dictionary.

Nothing is bound by default. For example, rt.bind("<Ctrl-s>", "spell-suggest", "insert"). Right-click suggestions work too.

Widevine (DRM)

Off by default. With c.content.widevine = true and a restart, Chromium downloads Google's Widevine CDM (about 21 MB) into the data directory, and it loads from the next start. Chromium has no switch for a single component, so component updates are on during that one run. Once the CDM is installed they go back off, and the CDM itself isn't updated. To update or remove it, delete <data>/WidevineCdm.

Limits: only VP9/AV1 streams work (prebuilt CEF has no H.264/AAC), Linux Widevine is the software-only level (L3) that services often cap at lower resolutions, and this hasn't been checked against Google's Widevine terms for third-party browsers.

Programs, userscripts and Greasemonkey

Programs and userscripts

  • :spawn [-v] [-m] [-o] [-d] <cmd> [args] runs a program, with arguments split like a shell would (no shell runs). {url} is the current page. -v reports success too, -m shows the program's output as messages, -o shows it in a new tab (riptide://process/), and -d detaches. A non-zero exit is shown as an error. For example, rt.bind(",m", "spawn -d mpv {url}").
  • :spawn -u <name> runs a userscript. It is looked up in <config>/userscripts/, then <data>/userscripts/, then PATH. It gets RIPTIDE_URL, RIPTIDE_CURRENT_URL, RIPTIDE_TITLE, RIPTIDE_SELECTED_TEXT, RIPTIDE_SELECTED_HTML, RIPTIDE_HTML/RIPTIDE_TEXT (files with the page's HTML and text), RIPTIDE_TAB_INDEX, RIPTIDE_COUNT, RIPTIDE_MODE, RIPTIDE_USER_AGENT, RIPTIDE_CONFIG_DIR, RIPTIDE_DATA_DIR, RIPTIDE_DOWNLOAD_DIR and RIPTIDE_VERSION. Commands it writes to RIPTIDE_FIFO, one per line, run when it exits. A one-word argument is unquoted as qutebrowser does it, so message-info 'two words' and fake-key \a work.
  • Hints can run them on a link: :hint links spawn mpv {hint-url} (the URL is appended if there's no {hint-url}), or :hint links userscript name, which gets the link as RIPTIDE_URL and RIPTIDE_MODE=hints. For example, rt.bind(";m", "hint links spawn mpv").
  • :open-editor, or Ctrl-e in insert mode, edits the focused text field in editor.command (default gvim -f {file} -c "normal {line}G{column0}l", as in qutebrowser). The text is written back when the editor exits successfully. For a terminal editor: c.editor.command = { "foot", "nvim", "+call cursor({line}, {column})", "{file}" }. :edit-text is the same command under qutebrowser's name.
  • :edit-url edits the page's address in the editor and opens what you save. It takes :open's flags, so :edit-url -t opens the result in a new tab.
  • :cmd-edit edits the command line you're typing in the editor and puts it back, or runs it with --run. It works from command mode, so bind it there: rt.bind("<Ctrl-x>", "cmd-edit", "command").

Greasemonkey scripts

*.js files in <data>/greasemonkey/ (as in qutebrowser) or <config>/greasemonkey/ run in matching pages. They follow the usual // ==UserScript== block: @match, @include, @exclude, @run-at (document-start, document-end (the default) or document-idle) and @noframes. Scripts get GM_info, GM_addStyle, unsafeWindow, and GM_getValue, GM_setValue, GM_deleteValue and GM_listValues, plus the promise versions under GM.*. Values are kept per script and survive restarts; a page sees the values as they were when it loaded. @require URLs are downloaded once, into the data directory, and run before the script. A new @require is fetched in the background; reload the page once it says the download is done. :greasemonkey-reload reads the files again; reload a page to run the new versions.

Two more APIs need the script to ask for them with @grant:

APIWhat it does
GM_xmlhttpRequest (GM.xmlHttpRequest)Fetches a URL from another site, with the page's cookies for that site. It only reaches the page's own host and the hosts listed with @connect (and their subdomains); @connect * allows any. Responses are text, or parsed JSON with responseType: "json".
GM_openInTab (GM.openInTab)Opens an http(s) URL in a new tab, in the background with GM_openInTab(url, true) or { active: false }.
// ==UserScript==
// @match https://news.example/*
// @grant GM_xmlhttpRequest
// @connect api.example.org
// ==/UserScript==
GM_xmlhttpRequest({ url: "https://api.example.org/scores", responseType: "json",
  onload: (r) => console.log(r.response) });

A request riptide refuses calls onerror with error: "not allowed" and says why in the status bar.

Passwords

The password plugins fill logins from your password manager's command line tool. Password manager extensions can't fill logins in riptide, so this is the way to use Bitwarden, KeePassXC or pass here. They live in riptide-plugins: add the one for your password manager in config.lua.

rt.pack.add({ "https://github.com/joshzcold/riptide-plugins", subdir = "pass" })

Or open :plugins, press Browse and Add the one you want. riptide installs it the first time it starts with this (it needs git), along with the passwords plugin it fills through, and asks you to allow each: the password manager plugin to run programs (your password manager), and passwords to fill in pages. Like other plugins from git, they stay on the version installed until you update them from :plugins. Add more than one, and the picker lists the logins of all of them.

KeyCommandFills
<Space>pp:password-fillthe username and the password
<Space>pu:password-fill-usernameonly the username
<Space>pw:password-fill-passwordonly the password

It finds the logins saved for the site you're on. If there's more than one, it asks which, each with a key: 1, 2, …. It fills the form around the focused field, or the first form with a password field, so you don't need to click into it first.

A login saved for example.com fills on example.com and its subdomains, and never on a name that only contains it, such as example.com.evil.net. If the tab moves to another site while your password manager answers, nothing is filled. Passwords aren't kept in the command history, :messages or riptide's log.

Password managers

Plugin (subdir)ToolUnlocking
passpass, or gopass with gopass = trueGPG's own pinentry
rbwrbw, for Bitwardenrbw's agent and pinentry
bitwardenthe Bitwarden CLI (bw)riptide asks for the master password once and keeps the session until riptide quits or :password-lock
keepassxckeepassxc-cli, with database = "~/Passwords.kdbx"riptide asks for the database password on every fill, or keeps it for remember = 300 seconds

The master and database passwords go to the tool on its input or in its environment, never on its command line, where other programs could see them. For pass and gopass, use a graphical pinentry (such as pinentry-gnome3 or pinentry-qt): riptide has no terminal for a text one.

How entries are matched

  • pass and gopass: an entry belongs to a site when part of its path is the site, such as websites/example.com/alice. The first line is the password. The username is a login:, user:, username: or email: line, or else the part of the path after the site (alice).
  • Bitwarden and KeePassXC: an entry belongs to a site when one of its saved addresses (or its name) is the site.

Options

Each password manager plugin takes its own options, and passwords takes the ones about filling; add passwords yourself only to pass those:

rt.pack.add({
  { "https://github.com/joshzcold/riptide-plugins", subdir = "keepassxc", opts = {
    database = "~/Passwords.kdbx",
    keyfile = "~/Passwords.key",  -- optional
    remember = 300,               -- seconds to keep the database password
    command = "/opt/bin/keepassxc-cli",  -- the tool, if it isn't on your PATH
  } },
  { "https://github.com/joshzcold/riptide-plugins", subdir = "passwords", opts = {
    submit = true,                -- press the form's submit button after :password-fill
    keys = { login = "<Ctrl-Shift-l>" },  -- your own keys; false for none
  } },
})

pass also takes store = "~/.password-store" (if not $PASSWORD_STORE_DIR) and gopass = true; rbw and bitwarden take command.

Extensions

riptide runs Chrome extensions that use Manifest V3, such as uBlock Origin Lite, Bitwarden or KeePassXC-Browser. Firefox add-ons don't run (riptide is Chromium), and neither do Manifest V2 extensions such as the original uBlock Origin, which Chromium no longer supports.

Installing

  1. Find the extension on the Chrome Web Store. Ignore its "Switch to Chrome" banner; riptide shows a message saying how to install instead.
  2. On the extension's page, run :extension-install. If the store offers a download (Add to Chrome), that works too: riptide installs the .crx file instead of asking where to save it.
  3. riptide shows what the extension asks for, such as "read and change everything on every site you visit", and asks before installing. Extensions load when riptide starts, so it then offers to restart.

:extension-install also takes a store page's address or an extension's id from anywhere, or a .crx file (/path/to/file.crx). The Extensions tab (:extensions) has the same steps, and a box to paste a store page into.

:extension-install https://chromewebstore.google.com/detail/ublock-origin-lite/ddkjiahejlhfcafbddmgiahcphecmpfh

Installed extensions keep their Web Store id, so their settings carry over between versions, and desktop apps that talk to them recognise them. They're in extensions/ in the data folder (riptide --paths).

Managing them

:extensions opens the Extensions tab of :settings. It lists every extension with its version, where it's from and what it can do, and these buttons:

  • Popup opens the extension's popup over the top right of the page, as Chrome does. Drag its bar to move it; the next popup opens where you left the last one. Escape or its ✕ closes it, and so does switching tabs. Options opens its options page in a tab.
  • Remove deletes an installed one.
  • Check for updates asks the Web Store for newer versions. Update to … then shows what the new version asks for that the old one didn't, before installing it.
  • Chrome's extensions page turns extensions on or off, and shows their errors.

When something changed since riptide started, the tab says so, with a Restart now button.

Command
:extension-install [page, id or file]install or update (the store page you're on, without an argument), after showing what it asks for
:extension-open <name> [popup|options]open its popup over the page (or, without one, its options in a tab)
:extension-update [name]check every installed extension for updates, or update one
:extension-remove <name or id>delete an installed extension
:extensionsthe Extensions tab

Names complete with Tab. See Limits for what works differently from Chrome.

To load an extension you're writing, or one you unpacked yourself, list its folder:

# config.toml
extensions.load = ["~/code/my-extension"]

What works

Blocking, and changes an extension makes to every page (such as dark mode or hiding page elements), work in every tab. So do extensions' options pages and settings.

What doesn't work is anything that needs the extension to know which site you're on. riptide's tabs aren't Chrome's, so to an extension you're never "on" a site. See the first row of Limits, and Password managers.

Limits

LimitWhat to do instead
Extensions can't tell which site you're on. Anything that depends on it doesn't work: uBlock Origin Lite's per-site switch, filtering level and element picker (its popup says "not a website"), and password managers' login suggestions.Settings that don't depend on the site, such as filter lists and the default blocking level, work from the popup or options. For logins, see Password managers.
Only Manifest V3. Chromium no longer runs Manifest V2 extensions, such as the original uBlock Origin, and riptide refuses to install them. Firefox add-ons don't run either.uBlock Origin Lite, or riptide's own ad blocker (content.blocking).
Changes need a restart. Extensions load when riptide starts.Accept the "Restart now?" question, or use Restart now on the Extensions tab.
No automatic updates.Press Check for updates on the Extensions tab, or run :extension-update.
Not in private windows.Use a normal window.
Chrome's own Remove only hides an extension until the next start, because riptide loads it from its folder each time.Remove on the Extensions tab, or :extension-remove.
Desktop apps are linked at startup. riptide only finds a password manager's desktop app if the app set itself up for another Chromium browser first.Turn on the app's browser integration for Chromium or Chrome, then restart riptide.
Not tested yet: extensions' keyboard shortcuts and passkeys.Please report what you find.

What an install checks

riptide downloads extensions over HTTPS from Google, and checks that the file's key gives the extension id you asked for. It doesn't check the Web Store's signature on the file. A .crx file you install from disk has no id to check, so only install files you trust. Either way, you see what the extension asks for before anything is installed.

Password managers

Password manager extensions install, unlock and sync, but they can't fill logins. To suggest a login, a password manager asks which site you're on, and in riptide the answer is always "none". So its menu in a login field says "No items to show", and its "fill this page" button does nothing. Your vault is fine; the extension just never learns which site's logins to offer.

To fill logins, use the passwords plugin instead. It reads the same vault through your password manager's command line tool, finds the logins for the site you're on, and fills them with <Space>pp:

Password managerUse the passwords plugin with
Bitwardenrbw or the Bitwarden CLI (bw)
KeePassXCkeepassxc-cli
pass, gopasspass or gopass

1Password and Proton Pass have no backend in the passwords plugin yet.

Desktop apps

KeePassXC-Browser and 1Password talk to their desktop app through a "native messaging host" that the app installs for other browsers. When riptide starts, it links the hosts it finds for Chromium, Chrome, Brave, Edge and Vivaldi into its data folder. It only links a host that allows one of your installed extensions. In KeePassXC, turn on browser integration for Chromium.

From the terminal

While the browser is running, riptide hands its arguments to that instance (per profile, so --basedir instances stay separate) and exits:

riptide https://example.com        # opens per new_instance_open_target (default: new tab)
riptide --target tab-bg notes.html # relative files become file:// URLs
riptide ':tab-focus 1' ':reload'   # arguments starting with ':' run as commands

With several windows open, URLs go to the one you used last; new_instance_open_target_window can pick the first-opened or last-opened window instead.

The browser listens on a Unix socket in $XDG_RUNTIME_DIR/riptide/ (or the data directory), inside a 0700 directory and with 0600 permissions, so only your user can send commands. On Windows each start is a new instance for now.

Internal pages

The tab bar, status bar and overlay are HTML pages served from the browser itself at riptide://ui/…. Web pages can't link to, frame or redirect to riptide:// addresses, and only riptide://ui/ pages get the rt.send() channel to Rust. The browser accepts only the messages each page is allowed to send.

Pages you can open: riptide://help/ (:help), riptide://history/ (:history), riptide://downloads/ (:downloads), riptide://settings/ (:settings) and riptide://changelog/ (:changelog). The first start after an update opens the changelog in a background tab when the version's minor or major number changed; changelog_after_upgrade (major, minor, patch or never) sets how big a step that takes.

Config files

Run riptide --paths to see where config and data live. All config files are optional and load in this order (later wins):

FilePurpose
autoconfig.tomlWritten by :set, :bind and :unbind. Don't edit it by hand.
config.tomlDeclarative settings and bindings. See the example below.
config.luaThe same, as a Lua 5.4 program. See Lua.

The directories for each platform are listed in Installing.

:settings opens a page with every setting, grouped by section and searchable. Each one has a control for its kind (a checkbox, a list of choices, a number, a color picker, or rows for lists and maps), and a change applies at once and is saved in autoconfig.toml, like :set. The page shows where a changed value came from, marks settings your config.toml or config.lua also sets (those win at the next start), and has a reset button for anything not at its default. Settings that can differ per site list their per-site values under the setting, each editable, with ✕ to forget it and "+ For a site" to add one for a pattern such as *.example.com. In normal mode, j and k move a highlight between settings and Return edits the highlighted one: a switch toggles, a choice steps to the next, and a text field gets the cursor (Escape leaves it). f hints reach every control too.

The page's Keys tab lists each mode's bindings, marking the ones you added or changed. Edit a binding's command in place, remove it with ✕, or put a default back with ↺ (removed defaults are listed at the bottom with a Restore button). To add one, type the keys in riptide's notation (gx, <Ctrl-y>) or press Record and then the keys, enter a command and press Bind. The page warns first when the new keys are the start of another binding, or another binding is the start of them, since riptide then waits to see which you mean. Like :bind, it's saved in autoconfig.toml. Keys riptide itself uses in insert mode, such as Escape, can't be recorded; type them instead.

The Sites tab lists every site with saved values: the answers you gave with A or N when it asked for the camera, microphone, location or notifications, certificate exceptions, and anything set with :set -u. ✕ forgets one value (permissions go back to asking), "Forget all" forgets the site, and "Clear data" deletes its cookies and stored data (local storage, IndexedDB, cache). The field at the top clears any site, e.g. example.com. One-time y answers are kept by Chromium itself and aren't listed.

Every setting is listed in the settings reference, and the completion popup lists them as you type :set , with each one's current value. After a name it offers the values: true/false, a setting's choices, or its current and default values. In the browser:

  • :set hints.chars asdf changes a setting; :set hints.uppercase! toggles one; :set hints.chars shows the value.
  • :bind <Ctrl-x> tab-close adds a binding (--mode insert for other modes); :bind <Ctrl-x> shows one; :unbind d removes one.
  • :config-list-add and :config-list-remove change one item of a list setting, and :config-dict-add [--replace] and :config-dict-remove one key of a map setting. For example, :config-dict-add url.searchengines ddg https://duckduckgo.com/?q={}. Like :set, they're saved in autoconfig.toml.
  • :config-diff lists the settings that differ from their defaults, :config-clear puts them all back, and :config-write-toml [--force] writes your current settings to config.toml.
  • :config-edit opens config.lua (or config.toml) in editor.command and reloads it when you close the editor.
  • :config-source reloads every file. Errors show in the status bar with file:line, and the rest of the file still applies.

A few settings, such as content.webgl and input.spatial_navigation, only apply when riptide starts; the reference marks them "after a restart". When you change one, the status bar says so, and :restart restarts riptide with your tabs.

Per-site values use [per_domain."<pattern>"] tables; see Per-site settings.

Example config.toml

# Example config.toml for riptide.
# Copy to the config directory (run `riptide --paths` to find it).
# Every key is a setting name, as listed in `:set` completion and the README.

hints.chars = "asdfghjkl"
hints.uppercase = false

messages.timeout = 3000             # milliseconds; 0 keeps messages up

tabs.last_close = "ignore"          # ignore, blank, startpage, default-page, close
tabs.new_position.related = "next"  # prev, next, first, last
tabs.new_position.unrelated = "last"

url.default_page = "https://start.duckduckgo.com/"
url.start_pages = ["https://start.duckduckgo.com/"]

# ":open w rust" searches Wikipedia; anything else uses DEFAULT.
[url.searchengines]
DEFAULT = "https://duckduckgo.com/?q={}"
w = "https://en.wikipedia.org/w/index.php?search={}"
gh = "https://github.com/search?q={}"

[aliases]
q = "quit"
qa = "quit"
wq = "quit"

[bindings.normal]
"<Ctrl-x>" = "tab-close"
"gw" = "cmd-set-text -s :open w"
# An empty command removes a default binding:
# "d" = ""

[bindings.insert]
"<Ctrl-[>" = "mode-leave"

Lua

config.lua gets c (qutebrowser-style c.hints.chars = "asdf"), rt.set/get/bind/unbind, rt.platform (linux, macos, windows), rt.config_dir, and require() from the config directory (name.lua or lua/name.lua). It is a normal Lua with the standard library, trusted like a shell rc file.

The Lua VM stays alive after the file runs, so config can also script the browser:

-- A key bound to a function, with access to the page and the count.
rt.bind("<Ctrl-g>", function() rt.message(rt.title() .. " — " .. rt.url()) end)

-- A command, :wiki rust, with completion next to the built-in ones.
rt.command("wiki", function(args)
  rt.open("https://en.wikipedia.org/wiki/" .. args, "tab")
end, "Search Wikipedia")

-- A hook: runs whenever a page on news.example.com finishes loading.
rt.on("load_finished", { pattern = "news.example.com" }, function(e)
  rt.run("scroll-to-perc 0")
end)

Keys and commands

rt.keymap.set(mode, keys, rhs, opts) binds keys like vim.keymap.set: mode is one mode or a list, rhs a command line or a function, and opts.desc describes it in the key hints popup. rt.keymap.del(mode, keys) removes a binding. rt.bind and rt.unbind still work, and both also work inside callbacks.

rt.command(name, fn, opts) takes a description, or a table with desc and complete. complete gets what's typed after the command and returns its completions, as strings or { name = "…", desc = "…" }:

rt.keymap.set({ "normal", "insert" }, "<Ctrl-y>", "reload", { desc = "Reload the page" })

rt.command("project", function(name) rt.open("https://github.com/me/" .. name, "tab") end, {
  desc = "Open one of my projects",
  complete = function(arglead)
    local out = {}
    for _, p in ipairs({ "riptide", "dotfiles", "notes" }) do
      if p:find(arglead, 1, true) then table.insert(out, p) end
    end
    return out
  end,
})

Keeping data

rt.store(name) keeps data between runs, saved as <data>/plugin-data/<name>.json on every change. It has get(key), set(key, value), all() and clear(); values are strings, numbers, booleans and tables of them:

local list = rt.store("reading-list")
rt.keymap.set("normal", "<Space>r", function()
  local urls = list.get("urls") or {}
  table.insert(urls, rt.url())
  list.set("urls", urls)
  rt.notify("Saved for later (" .. #urls .. ")")
end, { desc = "Read later" })

Status bar widgets

rt.statusbar.widget(name, fn) adds a widget that statusbar.widgets shows as lua:<name>. The function returns its text each time the bar is drawn, so keep it quick: it gets 50 ms, and one that fails or runs longer is removed with an error.

c.statusbar.widgets = { "keypress", "url", "lua:list", "tabs" }
local list = rt.store("reading-list")
rt.statusbar.widget("list", function()
  local urls = list.get("urls") or {}
  return #urls > 0 and ("📚" .. #urls) or ""
end)

An empty string hides the widget. What a widget function asks for besides its text, such as rt.notify, is ignored; to change a widget on a timer, use rt.every, which redraws the bar when it runs.

Events

rt.on(event, [opts], fn) runs fn with a table describing the event:

EventWhenThe table has
startupriptide has started and loaded config.lua
quitriptide is about to quit
load_started, load_finisheda tab starts or finishes loading a pageurl
url_changeda tab's address changesurl
title_changeda tab's title changesurl, title
tab_opened, tab_closeda tab opens or closesurl
tab_selectedanother tab becomes the current oneurl, index (from 1)
window_opened, window_closeda window opens or closesprivate ("true", "false") on opening
mode_changedthe mode changesfrom, to
setting_changeda setting changes (:set, the settings page)name, value (as text)
download_started, download_finisheda download starts or endsurl, path; state (done, failed, cancelled) when it ends

opts can have:

  • pattern: only pages matching it, written as for :set -u (example.com, *.example.com, https://example.com, *://*.example.com/docs/*).
  • once = true: run the first time only.
  • group: a name, so rt.off(group) removes them all.

rt.on returns an id for rt.off(id). rt.group(name, { clear = true }) removes the group's hooks and returns its name, so a script that runs again (:config-source) doesn't add its hooks twice:

local g = rt.group("reading", { clear = true })
rt.on("tab_selected", { group = g, pattern = "*.wikipedia.org" }, function(e)
  rt.message("Reading tab " .. e.index)
end)
rt.on("download_finished", { group = g }, function(e)
  if e.state == "done" then rt.spawn({ "notify-send", "Downloaded", e.path }) end
end)

rt.defer(ms, fn) calls fn once after ms milliseconds, and rt.every(ms, fn) keeps calling it; both return a handle whose :stop() cancels it. rt.notify(text, level) (or rt.message) shows a message, with level info (the default), warning or error:

-- Remind me to stretch every 45 minutes.
rt.every(45 * 60 * 1000, function() rt.notify("Time to stretch", "warning") end)

A callback that runs for more than 2 seconds is stopped with an error, so a mistake like an endless loop can't freeze the browser; long work belongs in rt.spawn or a timer.

In callbacks, rt.url(), rt.title(), rt.mode(), rt.count() and rt.tabs() (the window's tabs, with title, url, current and pinned) describe the current state. rt.run(line), rt.open(url, target), rt.message(text, level) and rt.set(...) act on it. Errors show as config.lua:line: message. :config-source reloads everything.

rt.spawn(argv, [opts], [callback]) runs a program in the background, without a shell, and calls callback with {code, stdout, stderr, error} when it exits. argv is a list, or a command line split as :spawn splits it. opts can set stdin, cwd and env:

rt.command("translate", function(text)
  rt.spawn({ "trans", "-brief", ":en" }, { stdin = text }, function(r)
    if r.code == 0 then rt.message(r.stdout) else rt.message(r.stderr, "error") end
  end)
end)

rt.json.decode(text) and rt.json.encode(value) read and write JSON, such as a program's output; null becomes nil.

Questions and the page

rt.ui.select(items, opts, on_choice) and rt.ui.input(opts, on_confirm) ask in the prompt area, like Neovim's vim.ui. A picker gives each item a key (1–9, then a–z); secret = true masks what's typed:

rt.ui.select({ "work", "home" }, { prompt = "Profile" }, function(choice)
  if choice then rt.notify("Using " .. choice) end
end)
rt.ui.input({ prompt = "Passphrase", secret = true }, function(text) --[[ nil if cancelled ]] end)

rt.page acts on the current tab's page:

rt.page.type(text)types into the focused field, like :insert-text
rt.page.key(keys)presses keys, like :fake-key
rt.page.fill_login({ host, username, password, submit })fills the page's login form, but only while the tab is still on host
rt.page.eval(code, fn)evaluates a JavaScript expression and calls fn(value) with its value, or fn(nil, why)
rt.page.css(css)adds a stylesheet to the page until it next loads
rt.page.selection(fn)calls fn(text) with the selected text
rt.page.hint({ selector, action })hints the elements a CSS selector matches and calls action({ url, text }) with the one you pick, instead of clicking it

eval runs in the page's own world: the page can see the code and change what it returns, so treat the value as the page's word.

They never go through a command line, so what's typed isn't kept in the command history or :messages. They don't act on riptide's own pages, and a plugin needs the pages permission for the site.

Floats

rt.ui.float(opts) draws a box of text over the page, beside whatever else is on screen, and returns a handle with update(changes), close() and is_open(). Lines are text, or lists of { text, highlight } chunks coloured by the theme (title, muted, accent, match, url, key, info, warning, error); they're never HTML.

local list = rt.store("reading-list")
rt.keymap.set("normal", "<Space>l", function()
  local lines = {}
  for i, url in ipairs(list.get("urls") or {}) do
    lines[#lines + 1] = { { tostring(i), "key" }, { " " .. url, "url" } }
  end
  rt.ui.float({
    title = "Reading list",
    lines = lines,
    keys = { c = function(f) list.clear(); f:close() end },
  })
end, { desc = "Show the reading list" })
Option
positioncenter (the default), top, bottom, top-right or bottom-right of the page
widththe widest it gets, in characters (default 60)
timeoutclose by itself after this many milliseconds, for a notice
keysfunctions for keys pressed in normal mode while it's the newest float with keys; Escape closes it
on_closeruns when riptide closes it: Escape, its timeout or its window closing

A plugin's floats show its name in the corner, so a float can't pass for riptide's own question.

Panels

rt.ui.panel(opts) keeps lines beside the page (side = "left" or "right") or below it ("bottom"): a tab tree, a reading list, notes. It takes the same title and lines as a float, plus size in pixels, and returns a handle with update, close, focus and is_open. Each side of a window holds one panel; a new one replaces it.

A panel takes keys only while it has focus: :panel-focus (or focus()) gives it focus, j/k move its cursor, and its keys functions get the cursor's line. Escape returns to the page, and clicking a line focuses the panel there.

local function tab_lines()
  local lines = {}
  for i, tab in ipairs(rt.tabs()) do
    lines[i] = { { tostring(i) .. " ", "key" }, { tab.title, tab.current and "accent" or nil } }
  end
  return lines
end
local tree = rt.ui.panel({
  title = "Tabs",
  lines = tab_lines(),
  keys = { ["<Return>"] = function(_, line) rt.run("tab-select " .. line) end },
})
for _, event in ipairs({ "tab_opened", "tab_closed", "tab_selected", "title_changed" }) do
  rt.on(event, function() tree:update({ lines = tab_lines() }) end)
end
rt.keymap.set("normal", "<Space>t", function() tree:focus() end, { desc = "Focus the tab tree" })

For completion and type checking in Neovim, VS Code and other editors using lua-language-server:

dir="$(riptide --paths | sed -n 's/^config: //p')"
mkdir -p "$dir" && riptide --lua-types > "$dir/rt.meta.lua"

The Lua API reference lists every function and setting with its type.

Example config.lua

-- Example config.lua for riptide. Loaded after config.toml.
-- Copy to the config directory (run `riptide --paths` to find it).
--
--   c.<setting> = value      set an option (same names as config.toml)
--   rt.get(name)             read an option's current value
--   rt.bind(keys, command [, mode])
--   rt.unbind(keys [, mode])
--   rt.platform              "linux", "macos" or "windows"
--   rt.config_dir, rt.data_dir, rt.version
--   require("name")          loads name.lua or lua/name.lua from the config dir

c.hints.chars = "asdfjkl"

-- One file for every machine.
if rt.platform == "macos" then
  c.hints.uppercase = true
end

-- Build settings with code.
local engines = rt.get("url.searchengines")
engines.rs = "https://docs.rs/releases/search?query={}"
engines.crates = "https://crates.io/search?q={}"
c.url.searchengines = engines

for i, page in ipairs({ "news", "mail" }) do
  rt.bind("g" .. i, "open -t https://example.com/" .. page)
end

rt.bind("<Ctrl-e>", "mode-leave", "insert")

-- Keep machine-specific settings out of version control:
-- pcall(require, "local")

Plugins

Plugins add commands, key bindings, hooks and more, written in Lua like config.lua. They work like Neovim's: a plugin is a folder with lua/<name>/init.lua, and you set it up from config.lua.

Adding a plugin

The quickest way is the Plugins tab (:plugins): Browse lists the plugins in riptide-plugins (or the repository plugins.catalog names) with what each asks for, and Add installs one. You can also paste a git URL and the folder in it, or run :pack-add <url> [folder]. Plugins added this way are kept in plugins.toml in the config folder, which riptide writes for you:

# plugins.toml
[[plugin]]
src = "https://github.com/joshzcold/riptide-plugins"
subdir = "keepassxc"
opts = { database = "~/Passwords.kdbx" }  # options, which you can add by hand

Remove on the tab takes such a plugin out of plugins.toml again. For more control, add plugins in config.lua instead; if both name the same plugin, config.lua's wins:

-- config.lua
rt.pack.add({
  "https://github.com/someone/riptide-tab-tools",
  { "https://github.com/someone/reading-list", version = "v1.2", opts = { folder = "~/Reading" } },
  { dir = "~/code/my-plugin", config = function() require("my-plugin").setup() end },
})

A spec is a git URL, or a table:

Key
"url" or srcWhere to clone it from. riptide installs it into <data>/pack/<name> the first time, in the background.
versionA tag, branch or commit to install; by default the newest commit.
subdirThe plugin's folder in a repository of several, e.g. subdir = "passwords"; its name is the folder's by default.
dirA folder on your computer instead (~/ works), for writing your own.
nameIts name, which require uses; by default the URL's or folder's last part, without a riptide- prefix or a .nvim-style suffix.
optsPassed to require(name).setup(opts) once it loads.
configA function run once it loads, instead of opts.
trusted = trueSkip the sandbox and give it everything, as Neovim does. Only for code you vouch for.
event, cmd, keysLoad it only when needed; see below.

Plugins load after config.lua has run, so set them up with opts or config rather than calling require straight away.

Loading when needed

A plugin with event, cmd or keys loads only once you need it, which keeps startup quick:

rt.pack.add({
  { "https://github.com/someone/reading-list", cmd = { "read-later", "reading-list" }, opts = {} },
  { dir = "~/code/tab-tools", keys = { "<Space>t", { "<C-t>", mode = "insert" } }, opts = {} },
  { dir = "~/code/history-sync", event = "window_closed", opts = {} },
})
KeyLoads it
cmdwhen you run one of these commands; it then runs with your arguments
keyswhen you press one of these keys (normal mode unless a mode is given); the keys are pressed again for the plugin's own binding
eventbefore the first of these events' hooks run, so the plugin's hooks see it too

Its permissions are still asked for at startup, so a key or command never stops to ask. The Plugins tab lists what each one waits for, and Load now or :pack-load <name> loads it straight away.

Versions and the lockfile

rt-pack-lock.json in the config folder pins each plugin from git to the commit installed, along with the permissions you approved, so a plugin never changes on its own and the same config gives the same plugins on every computer. Keep it with your dotfiles: on another computer, riptide installs exactly the pinned commits, and if the lockfile moves a plugin to another commit, riptide checks that one out at the next start, or straight away with :pack-restore. Plugins from one repository share a checkout, so they're always on the same commit and update together. Git runs without asking for passwords, so a private repository fails with a message instead of waiting.

CommandPlugins tabDoes
:pack-check [name]Check all, Check for updatesfetches and lists each plugin's new commits; nothing changes yet
Update, Update allmoves to the commits a check listed, the ones you read
:pack-update [name]fetches and moves straight to the newest commits
:pack-syncSynccleans, updates everything and installs what's missing
:pack-restoreRestoreputs every plugin back on its commit in rt-pack-lock.json
:pack-cleanCleandeletes checkouts and lockfile entries of plugins no longer in config.lua

Every update is recorded in rt-pack-lock.json and reloads your config once; if a new version asks for more permissions, you're asked about those first.

Every plugins.check_interval days (1 by default; 0 turns it off), riptide checks in the background a few seconds after it starts and says which plugins have updates, such as "Plugin updates for pass, passwords; review them on :plugins". It mentions each update once, so a plugin you leave as it is doesn't come up again until it has newer commits, and the check changes nothing.

The plugins page

:plugins opens the Plugins tab of :settings: each plugin with where it comes from, the commit it's on, whether it loaded (and the error if it didn't) and the permissions you approved, with the buttons above.

To review before updating, press Check all (or :pack-check), read each plugin's new commits on the page, then press Update on one or Update all. Revoke forgets the permissions you approved, so the plugin asks again. Remove deletes a plugin's lockfile entry and its checkout, unless another plugin uses it; take it out of config.lua too, or it's installed again at the next start.

Permissions

Plugins run in a sandbox. Without asking, a plugin can react to events, bind keys to its functions, add commands, use timers, show messages, ask you questions (rt.ui, which names the plugin asking), open URLs and keep its own data (rt.store, which other plugins can't read). Anything more is a permission it lists in its riptide-plugin.toml, and the first time it loads, riptide shows them and asks you:

PermissionLets it
spawnrun programs on your computer (rt.spawn)
filesread and write your files (Lua's io and os)
commandsrun any riptide command (rt.run) and bind keys to command lines; this includes running programs
settingsread and change your settings (rt.get, rt.set, c)
clipboardread and write the clipboard
keyssee every key you press
network = ["host", …]connect to these hosts
pages = ["*.example.com", …]type and fill logins into pages on these sites (rt.page); ["*"] is every site
frames = ["host", …]show these sites inside its own pages

Your answer is kept in rt-pack-lock.json in the config folder, so it's asked once; a new version that asks for more asks again, for the new permissions only. Saying no leaves the plugin unloaded. To be asked again, press Revoke on :plugins, or delete its entry in rt-pack-lock.json.

In the sandbox, load only runs text in the plugin's own globals, require only finds the plugin's own modules and those of the plugins it lists in dependencies (not your config's, nor other plugins'), and a plugin's changes to its rt table don't affect anyone else. Every callback, a plugin's included, is stopped after 2 seconds.

Writing a plugin

reading-list/
├── riptide-plugin.toml     what it is and what it needs
├── lua/reading-list/
│   └── init.lua            require("reading-list")
└── plugin/
    └── reading-list.lua    runs when it loads (optional)
# riptide-plugin.toml
name = "reading-list"
description = "Save pages to read later"
[permissions]
spawn = true
-- lua/reading-list/init.lua
local M = {}
function M.setup(opts)
  local list = rt.store()
  rt.keymap.set("normal", "<Space>r", function()
    local urls = list.get("urls") or {}
    table.insert(urls, rt.url())
    list.set("urls", urls)
    rt.notify("Saved for later")
  end, { desc = "Read later" })
end
return M

Options

A plugin lists its options in riptide-plugin.toml, and the Plugins tab shows a form for them; for a plugin added there, Save options keeps them in plugins.toml and reloads it, so setup(opts) gets them. For one from config.lua, the tab shows them and they go in rt.pack.add's opts.

[[option]]
name = "database"
type = "path"          # string (the default), path, number, bool, choice or secret
description = "Your .kdbx file"
required = true

[[option]]
name = "remember"
type = "number"
default = 0            # shown in the form; the plugin applies it when unset

[[option]]
name = "password"
type = "secret"
description = "The database password, so it doesn't ask"

A riptide older than the plugin ignores keys it doesn't know, such as [[option]] before 0.4, with a warning in its log; it still refuses a permission it doesn't know.

A secret option is never written to a file: the form saves it in your OS keyring (the macOS Keychain, Windows' Credential Manager, or the Secret Service, such as GNOME Keyring or KWallet, on Linux), and the plugin reads it when it needs it, with rt.secret.get("password", function(value, err) … end). A plugin can read only its own secret options, and only the ones its manifest declares. The Plugins tab shows whether a secret is set, never its value; Clear takes it out of the keyring. On Linux without a keyring service, saving a secret says so and the plugin can ask instead.

Dependencies

A plugin that builds on another lists it in riptide-plugin.toml:

dependencies = ["passwords"]

It loads once its dependencies have, and may require their modules; their functions run with their own permissions, not the dependent's. You don't have to add a dependency yourself: riptide installs it from the same repository (the folder beside it, for one from subdir) or from the folder beside a dir plugin, and asks for its permissions as usual. Add it to rt.pack.add only to pass it options. A plugin whose dependency is refused or fails doesn't load, and :plugins says why.

Pages

A plugin can ship HTML in a pages/ folder and open it in a tab with rt.ui.page({ path, on_message }). Its pages are served as riptide://<name>.plugin/…, each plugin its own origin, and they talk to the plugin, and only to it, with messages:

-- plugin/reading-list.lua
rt.command("reading-list", function()
  rt.ui.page({
    path = "index.html",
    on_message = function(name, data, page)
      if name == "ready" then page:send("urls", rt.store().get("urls") or {}) end
    end,
  })
end)
// pages/app.js, loaded with <script src="app.js"> from pages/index.html
addEventListener("rtmessage", (e) => {
  if (e.detail.name === "urls") render(e.detail.data);
});
rt.send("ready", JSON.stringify({}));

where = "panel" docks the page beside the page area instead, with side (left, right or bottom) and size in pixels: a sidebar that shows a web app in an iframe, say. Clicking a field in it types there, as in a tab, and :panel-focus moves the keyboard to it and back. page:close() closes it.

Pages run only their own files: inline scripts, other sites' scripts and eval are blocked. They may show other sites in iframes only over https and only for the hosts the frames permission lists, and connect to only the hosts network lists. A plugin's pages are served once it has loaded.

Testing

riptide --plugin-test DIR runs the plugin in DIR in a throwaway profile, with the permissions its manifest asks for already approved, and runs its test/*_spec.lua files. It prints TAP and exits with 1 if a test failed, so it works in CI under xvfb-run:

-- test/reading_list_spec.lua
describe("reading-list", function()
  it("saves the page", function()
    keys("<Space>r")
    wait_until(function() return last_message() end)
    assert.matches("^Saved", last_message())
  end)
end)
In a spec
describe(name, fn), it(name, fn), before_each(fn)group and name tests
assert(v), assert.equals(expected, actual), assert.same (deep), assert.truthy, assert.falsy, assert.matches(pattern, s), assert.has_error(fn, pattern)checks
keys("<Space>r"), run("open x")press riptide keys or run a command
wait(ms), wait_until(fn, timeout), wait_for(event, { pattern, timeout })let the browser work; wait_for returns the event
messages(), last_message(), clear_messages()what rt.notify showed, the plugin's included
page("login.html")the address of a file in the plugin's test/ folder, served as an ordinary web page (http://plugin-test.localhost/…)
plugin_test.dirthe plugin's folder, e.g. for opts.command = plugin_test.dir .. "/test/bin/fake-tool"

Secret options are kept in memory for the run, never in your keyring. Each test gets 10 seconds. test/config.lua, if there is one, replaces the default rt.pack.add({ dir = DIR, opts = {} }), to pass other options. riptide-plugin-template is a plugin to start from, with a spec and a GitHub workflow that runs it, and riptide-plugins collects plugins, each installed with subdir.

:help <name> shows a plugin's description, permissions and its doc/<name>.md (or README.md), as text.

The Lua page describes the API, and :help rt.ui.float (any rt. function) shows one function; errors name the plugin's file and line, e.g. reading-list/lua/reading-list/init.lua:7: ….

Themes and colors

ui.theme sets the colors of riptide's own parts: the tab bar, the status bar, completion, prompts, hint labels, and the riptide://history and riptide://downloads pages (those pick up a new theme when they next load). Web pages keep their own colors (see dark mode for those).

ThemeLook
riptideDark blue and teal (the default)
riptide-lightThe same, light
gruvbox-dark, gruvbox-lightGruvbox
catppuccin-mocha, catppuccin-latteCatppuccin
nordNord
draculaDracula
solarized-dark, solarized-lightSolarized
tokyo-nightTokyo Night

:theme nord switches and saves the choice, like :set ui.theme nord; :theme alone lists them, and :theme <Tab> completes the names. While you type or Tab through the names, riptide shows each theme as it's named; Escape goes back to the one you had. In a config file:

ui.theme = "gruvbox-dark"

ui.theme = "auto" follows your desktop's light or dark preference and switches as soon as it changes, using ui.auto_theme.dark and ui.auto_theme.light:

ui.theme = "auto"
"ui.auto_theme.dark" = "tokyo-night"
"ui.auto_theme.light" = "solarized-light"

If you set colors.webpage.preferred_color_scheme to light or dark, auto follows that instead, so riptide matches the pages.

Your own themes

A file in themes/ in the config directory adds a theme named after the file. themes/kanagawa-dragon.toml makes kanagawa-dragon, which :theme, ui.theme and ui.auto_theme.* then accept. Give a palette of base colors, and riptide works out every color from them as it does for the built-in themes (readable text included):

# ~/.config/riptide/themes/kanagawa-dragon.toml
[palette]
base = "#0a0c0f"      # status bar and headers: the darkest background
surface = "#181616"   # tab bar, completion and prompts
fg = "#c5c9c5"
accent = "#8ba4b0"    # https URLs, the selected completion, key hints
yellow = "#c4b28a"    # hint labels, prompt frames, matches
red = "#c4746e"       # errors
green = "#8a9a7b"     # insert mode
blue = "#658594"      # passthrough mode, pinned tabs

[colors]              # optional: any colors.* setting, without "colors."
"completion.item.selected.bg" = "#2d4f67"
"completion.item.selected.fg" = "#c8c093"
Palette colorRequiredIf left out
base, surface, fg, accent, yellow, red, green, blueyes
surface2, surface3 (alternating tabs)nosurface mixed with a little fg
muted (descriptions)nofg mixed toward surface
orange (warnings)nobetween red and yellow

Palette colors are #rrggbb; [colors] takes any CSS color. Theme files are read with the config, so after editing one run :config-source. A file with a mistake is skipped with a message saying what's wrong.

In config.lua, rt.theme defines a theme the same way:

rt.theme("kanagawa-dragon", {
  palette = {
    base = "#0a0c0f", surface = "#181616", fg = "#c5c9c5", accent = "#8ba4b0",
    yellow = "#c4b28a", red = "#c4746e", green = "#8a9a7b", blue = "#658594",
  },
  colors = { ["completion.item.selected.bg"] = "#2d4f67" },
})
c.ui.theme = "kanagawa-dragon"

Themes from other programs

Two kinds of theme file made for other programs work as they are. Copy one into themes/, and its file name becomes the theme's name:

FileWhat's read
<name>.yaml or .yml: a base16 schemebase00…base0F, in either the older flat layout or under palette:
<name>.py: a qutebrowser themec.colors.… = … and config.set("colors.…", …) lines, with values as strings, variables (base00 = "#…") or dict entries (palette['bg']). No Python is run.

A qutebrowser theme's status bar, completion, hint, error, insert and passthrough colors become the palette, and every other colors.* setting riptide also has is used as it is. Many Neovim and terminal themes ship a base16 file; Kanagawa's is in extras/base16/.

Changing single colors

colors.* settings change one color on top of the theme, with qutebrowser's names where there is one. An empty value (the default) uses the theme's. Colors are #rrggbb, #rgb, rgb(…), hsl(…) or a CSS color name:

ui.theme = "nord"
"colors.statusbar.insert.bg" = "#2e7d32"
"colors.hints.bg" = "#ffd54f"
c.colors.tabs.selected.odd.bg = "#000000"

The settings reference lists every colors.* setting. Every built-in theme keeps text readable: each text color has at least 4.5:1 contrast with its background (WCAG AA), which riptide's tests check.

Fonts

fonts.default_family and fonts.default_size set riptide's font, and the per-part settings (fonts.statusbar, fonts.tabs.selected, fonts.tabs.unselected, fonts.completion.entry, fonts.completion.category, fonts.prompts, fonts.hints, fonts.keyhint) take a CSS font in which default_size and default_family stand for those two, as in qutebrowser:

"fonts.default_family" = '"JetBrains Mono", monospace'
"fonts.default_size" = "11pt"
"fonts.tabs.selected" = "bold default_size default_family"

The tab bar, the status bar and the completion rows grow to fit their fonts. statusbar.padding and tabs.padding add room around the text, in pixels like CSS padding:

"statusbar.padding" = "4px 8px"
"tabs.padding" = "2px 6px"

Padding or font sizes set in ui.css resize the bars too.

Pages have their own fonts: fonts.web.family.standard, .fixed, .serif and .sans_serif (empty keeps Chromium's) and fonts.web.size.default, .default_fixed and .minimum in pixels. Pages pick them up when they reload.

Floating command line

ui.overlay.position = "floating" turns the command line into a box near the top of the page, like a command palette: what you type shows in the box, with its completions under it, and the key hints appear there too. ui.overlay.width sets the box's width in pixels.

"ui.overlay.position" = "floating"
"ui.overlay.width" = 900

The default, docked, keeps the list full width above the status bar, as in qutebrowser. Questions have their own setting, prompt.position.

Hint labels

Hint labels take the theme's colors.hints.* and fonts.hints. hints.radius rounds their corners (in pixels, 0 for square) and hints.padding sets the room around the letters:

"hints.radius" = 0
"hints.padding" = "1px 4px"

For anything else, put CSS in hints.css in the config directory. Labels are .label elements and the typed part of a label is .matched; the page's own CSS can't reach them, and yours applies only to them:

.label { box-shadow: 0 1px 3px rgb(0 0 0 / 40%); }
.matched { opacity: 0.5; }

hints.css is read again each time hints are shown.

Custom CSS

ui.css in the config directory is added to riptide's tab bar, status bar and overlay after their own styles, for anything the settings don't cover. The theme's colors are there as CSS variables (--rt-statusbar-bg, --rt-tabs-selected-bg, … one per colors.* setting), so a rule can reuse them:

/* ~/.config/riptide/ui.css */
.tab.selected { border-bottom: 2px solid var(--rt-prompts-border); }
#bar.insert { font-style: italic; }

While a question is up, the overlay's body has a class for what it's about: prompt-dialog (a page's alert, confirm, prompt or leave-page warning), prompt-permission, prompt-login, prompt-download, prompt-certificate or prompt-confirm (riptide asking before quitting, closing a pinned tab or opening another program). Setting --prompt-accent recolors the box's frame and keys:

body.prompt-permission { --prompt-accent: #e06c75; }
body.prompt-download { --prompt-accent: #98c379; }

content.user_stylesheets lists CSS files for web pages; relative paths are in the config directory, and like other content settings it can be set per site:

"content.user_stylesheets" = ["readable.css"]

[per_domain."news.example.com"]
"content.user_stylesheets" = ["readable.css", "news.css"]

Both are read again within a second of being saved: open pages and bars change without a reload.

Commands

Type these after :, or bind them to keys. :help shows the same list in the browser, with your own bindings.

CommandDefault keysDescription
:open<Ctrl-t> O PP Pp gO go o pP ppOpen a URL or search for text
:backHGo back in history
:forwardLGo forward in history
:reload<Ctrl-r> <F5> R rReload the current page
:stopStop loading the current page
:scroll<Down> <Up> h j k lScroll in a direction
:scroll-page<Ctrl-b> <Ctrl-d> <Ctrl-f> <Ctrl-u>Scroll by a multiple of the page size
:scroll-to-perc$ 0 G ggScroll to a percentage of the page
:mode-enter' <Ctrl-v> V ` i vEnter a key mode
:mode-leaveLeave the current mode
:cmd-set-textPreset the command line text
:tab-close<Ctrl-w> dClose the current tab (--force: a pinned one without asking)
:tab-pin<Ctrl-p>Pin or unpin the current tab (count: tab number)
:tab-next<Ctrl-PgDown> JSwitch to the next tab
:tab-prev<Ctrl-PgUp> K gTSwitch to the previous tab
:tab-focus<Alt-1> <Alt-2> <Alt-3> <Alt-4> <Alt-5> <Alt-6> <Alt-7> <Alt-8> <Alt-9> <Ctrl-Tab> <Ctrl-^> g$ g0 g^Select a tab by number, or 'last'
:tab-movegJ gK gmMove the current tab: +, -, start, end or a number
:tab-onlycoClose all tabs except the current one
:tab-cloneDuplicate the current tab: :tab-clone [-b] [-w]
:tab-givegDMove the current tab to window N, or to a new window: :tab-give [N]
:tab-callReopen the current tab in a call window, where screen sharing picks a tab, window or screen
:tab-takeMove a tab from another window here: :tab-take <window/tab>
:undo<Ctrl-T> uRe-open the last closed tab
:hint;I ;O ;b ;d ;f ;h ;i ;o ;r ;t ;x ;y F fLabel elements to follow: [--rapid] [group] [target] [fill text]
:yankyD yT yY yd yt yyCopy the page's url, title or domain to the clipboard
:setShow or change an option: :set name [value], :set name! toggles
:bindShow or set a key binding: :bind [--mode m] keys [command]
:unbindRemove a key binding: :unbind [--mode m] keys
:config-sourceReload the configuration files
:help<F1>Show help: :help [-t] [:command | setting | section]
:versionShow version, paths and loaded config files
:reportReport a bug: opens a new GitHub issue with the version filled in
:debug-keytesterShow the name and binding of each key you press, until Escape
:debug-log-filterChange the log filter while running, e.g. rt_cef=debug; default goes back to RT_LOG
:changelogShow what changed in each version: :changelog [-t]
:quickmark-addmSave a quickmark: :quickmark-add <url> <name>
:quickmark-loadB bOpen a quickmark: :quickmark-load [-t|-b] <name>
:quickmark-delDelete a quickmark (default: the current page's)
:bookmark-addMBookmark a URL (default: the current page)
:bookmark-loadgB gbOpen a bookmark: :bookmark-load [-t|-b] <url>
:bookmark-delDelete a bookmark (default: the current page)
:saveWrite config, cookies, quickmarks, bookmarks and the session to disk now: :save [what…]
:session-saveSave the open tabs: :session-save [name]
:session-loadReplace the open tabs with a saved session
:session-deleteDelete a saved session
:history-clearDelete all browsing history (needs --force)
:history-importImport qutebrowser's history: :history-import [path to history.sqlite]
:adblock-updateDownload the filter lists in content.blocking.adblock.lists
:spell-suggestSuggest fixes for the misspelled word at the cursor (insert mode)
:spell-replaceReplace the misspelled word: :spell-replace <word>
:spell-addAdd the word from the last :spell-suggest to your dictionary
:spell-installDownload spell-check dictionaries (checked against pinned checksums) and turn them on: :spell-install en-US de-DE, or pick from a list without a language
:spawnRun a program: :spawn [-u] [-v] [-m] [-o] [-d] <cmd> [args]; -u runs a userscript
:extension-installInstall a Chrome extension: the Web Store page you're on, or a store page address, an id or a .crx file; it loads after a restart
:extension-removeDelete an installed extension, by name or id; it's gone after :restart
:extensionsList extensions in :settings, with their popups, options, updates and Remove
:extension-openOpen an extension's popup over the page, or its options in a tab: :extension-open <name> [popup|options]
:extension-updateCheck installed extensions for updates, or update one: :extension-update [name]
:open-editorEdit the focused text field in editor.command (also :edit-text)
:edit-textEdit the focused text field in editor.command (qutebrowser's name for :open-editor)
:edit-urlEdit the page's URL in editor.command, then open it: [-t|-b|-w|-p] [-r] [url]
:cmd-editEdit the command line in editor.command, then put it back: [--run] runs it instead
:greasemonkey-reloadRead the scripts in the greasemonkey directories again
:closeClose the current window (:quit closes all of them)
:tab-selectT gtGo to a tab in any window: :tab-select <window/tab | text> (T)
:historyShow the browsing history: :history [-t]
:settingsOpen the settings page, to browse and change every setting
:pluginsShow your plugins, their permissions and updates
:pack-checkCheck plugins from git for new commits, to review and update on :plugins: :pack-check [name]
:pack-updateUpdate plugins from git to their newest commits, all or one: :pack-update [name]
:pack-addAdd a plugin from git, kept in plugins.toml: :pack-add <url> [folder in the repository]
:pack-syncRemove plugins no longer in config.lua, update the rest and install what's missing
:pack-cleanRemove the checkouts and lockfile entries of plugins no longer in config.lua
:pack-restorePut every plugin from git back on its commit in rt-pack-lock.json
:panel-focusFocus the next panel a plugin opened, then the page again; Escape also returns to the page
:pack-loadLoad a plugin that waits for an event, command or key now: :pack-load <name>
:recoverShow the tabs open at each recent crash, to reopen some or all of them
:crash-reportShow the newest crash report, to check and send as a GitHub issue or by email
:selection-follow<Ctrl-Return> <Return>Follow the link around the selection, e.g. after a search (Return; -t: new tab)
:zoom=Set the zoom: :zoom [percent] (=; no value: zoom.default)
:zoom-in+Zoom in a level (+; a count zooms further)
:zoom-out-Zoom out a level (-)
:devtools-focusBring this tab's developer tools to the front
:bookmark-listList quickmarks and bookmarks on a page: [-t] in a new tab
:quickmark-saveWrite the quickmarks file now
:quickmarks-reloadRead the quickmarks and bookmarks files again
:bookmarks-reloadRead the quickmarks and bookmarks files again
:debug-dump-pageSave the page's HTML to a file: :debug-dump-page <file>
:debug-clear-ssl-errorsForget the certificate errors allowed this session
:restartSave the session, quit and start again
:devtoolswiOpen the developer tools for this tab (wi)
:printPrint the page, or save it: :print [--pdf file]
:fullscreen<F11>Toggle fullscreen (F11)
:screenshotSave what the tab shows as an image: :screenshot [--force] file (.png, .jpg or .webp)
:view-sourcegfShow the page source in a new tab (gf)
:jsevalEvaluate a JavaScript expression in the page: :jseval <code>
:homeOpen the start page
:tab-mute<Alt-m>Mute or unmute this tab (Alt-m)
:pipgpFloat the page's main video in a picture-in-picture window, or bring it back (gp)
:call-mutecmMute or unmute your microphone in the call, from any tab or window, with the site's own mute key (content.call_mute_keys)
:share-stopStop sharing your screen, a window or a tab, from any tab or window
:messagesShow this session's messages
:repeat-command.Run the last command again (.)
:themeSwitch the color theme (ui.theme), or list the themes: :theme [name]
:cmd-repeat-lastRun the last command again, as . does
:cmd-repeatRun a command several times: :cmd-repeat N command
:cmd-run-with-countRun a command with a count, multiplied by any count typed first: :cmd-run-with-count N command
:scroll-pxScroll by pixels: :scroll-px <dx> <dy>
:cmd-laterRun a command later: :cmd-later <ms> <command>
:message-infoShow a message: :message-info <text>
:message-warningShow a warning: :message-warning <text>
:message-errorShow an error: :message-error <text>
:clear-messagesTake the messages off the screen
:config-cycleCycle a setting: :config-cycle <option> [values…] (no values: toggle)
:config-unsetPut a setting back to its default, or forget its value for one site: :config-unset [-u pattern] <option>
:config-list-addAdd a value to a list setting: :config-list-add <option> <value>
:config-list-removeRemove a value from a list setting: :config-list-remove <option> <value>
:config-dict-addSet a key in a map setting: :config-dict-add [--replace] <option> <key> <value>
:config-dict-removeRemove a key from a map setting: :config-dict-remove <option> <key>
:config-clearPut every setting back to its default
:config-diffShow the settings that differ from their defaults
:config-editEdit config.lua (or config.toml) in editor.command, then load it again
:config-write-tomlWrite the current settings to config.toml: [--force] replaces an existing one
:insert-textType text into the focused field: :insert-text <text>
:fake-keySend keys to the page: :fake-key [-g] <keys> (-g: to the browser)
:click-elementClick an element: :click-element id|css|focused [value]
:scroll-to-anchorScroll to the element with this id or name
:window-onlyClose every other window
:nopDo nothing (to make a key do nothing)
:navigate<Ctrl-a> <Ctrl-x> [[ ]] gU gu {{ }}Go up, prev, next, increment or decrement the URL: :navigate <where> [-t]
:searchFind text in the page: :search [-r] [text] (no text clears it); / and ? type one
:search-nextnGo to the next match of the last search
:search-prevNGo to the previous match of the last search
:set-markRemember the scroll position as a mark: a-z for this page, A-Z with its URL
:jump-markGo back to a mark; ' is where the last jump started
:macro-recordqRecord keys into a register until macro-record again (q + register)
:macro-run@Replay a macro (@ + register; @@ repeats the last one; a count repeats it)
:selection-toggleStart or stop selecting in caret mode (--line selects whole lines)
:selection-reverseSwap the ends of the selection (caret mode)
:downloadDownload a URL (default: the current page)
:download-cancelCancel a download (count: its number)
:download-openOpen a finished download (count: its number)
:download-clearRemove finished downloads from the list
:download-retryStart a failed or cancelled download again (count: its number)
:download-removeTake a download off the list, cancelling it if it runs (count: its number; --all: every finished one)
:download-deleteDelete a finished download's file and take it off the list (count: its number)
:downloadsList this session's downloads and their progress
:quit<Ctrl-q> ZQ ZZQuit the browser; --save keeps the tabs as the default session
:prompt-fileselect-externalIn a file prompt, pick the folder with fileselect.folder.command (Alt-e)
:hint-followFollow the hint with this label, or the match waiting for Return (Return in hint mode)
:completion-item-delDelete the selected completion: history entry, quickmark, bookmark or session, or close the tab (Ctrl-d)
:completion-item-yankYank the selected completion's text: [--sel] for the primary selection (Ctrl-c)

Default key bindings

Change these with :bind, [bindings.<mode>] in config.toml or rt.bind() in config.lua. :help bindings shows your current bindings, with your changes marked.

normal mode

KeysCommand
$scroll-to-perc --horizontal 100
'mode-enter jump_mark
+zoom-in
-zoom-out
.repeat-command
/cmd-set-text /
0scroll-to-perc --horizontal 0
:cmd-set-text :
;Ihint images tab
;Ohint links fill :open -t -r {hint-url}
;bhint all tab-bg
;dhint links download
;fhint all tab
;hhint all hover
;ihint images current
;ohint links fill :open {hint-url}
;rhint --rapid links tab-bg
;thint inputs
;xhint blocks hide
;yhint links yank
<Alt-1>tab-focus 1
<Alt-2>tab-focus 2
<Alt-3>tab-focus 3
<Alt-4>tab-focus 4
<Alt-5>tab-focus 5
<Alt-6>tab-focus 6
<Alt-7>tab-focus 7
<Alt-8>tab-focus 8
<Alt-9>tab-focus -1
<Alt-m>tab-mute
<Ctrl-PgDown>tab-next
<Ctrl-PgUp>tab-prev
<Ctrl-Return>selection-follow -t
<Ctrl-T>undo
<Ctrl-Tab>tab-focus last
<Ctrl-^>tab-focus last
<Ctrl-a>navigate increment
<Ctrl-b>scroll-page 0 -1
<Ctrl-d>scroll-page 0 0.5
<Ctrl-f>scroll-page 0 1
<Ctrl-p>tab-pin
<Ctrl-q>quit
<Ctrl-r>reload -f
<Ctrl-t>open -t
<Ctrl-u>scroll-page 0 -0.5
<Ctrl-v>mode-enter passthrough
<Ctrl-w>tab-close
<Ctrl-x>navigate decrement
<Down>scroll down
<Escape>clear-keychain
<F11>fullscreen
<F1>help
<F5>reload
<Return>selection-follow
<Up>scroll up
=zoom
?cmd-set-text ?
@macro-run
Bcmd-set-text -s :quickmark-load -t
Fhint all tab
Gscroll-to-perc
Hback
Jtab-next
Ktab-prev
Lforward
Mbookmark-add
Nsearch-prev
Ocmd-set-text -s :open -t
PPopen -t -- {primary}
Ppopen -t -- {clipboard}
Rreload -f
Tcmd-set-text -s :tab-select
Vmode-enter caret ;; selection-toggle --line
ZQquit
ZZquit --save
[[navigate prev
]]navigate next
`mode-enter set_mark
bcmd-set-text -s :quickmark-load
cmcall-mute
cotab-only
dtab-close
fhint
g$tab-focus -1
g0tab-focus 1
gBcmd-set-text -s :bookmark-load -t
gDtab-give
gJtab-move +
gKtab-move -
gOcmd-set-text :open -t -r {url}
gTtab-prev
gUnavigate up -t
g^tab-focus 1
gbcmd-set-text -s :bookmark-load
gfview-source
ggscroll-to-perc 0
gmtab-move
gocmd-set-text :open {url}
gppip
gtcmd-set-text -s :tab-select
gunavigate up
hscroll left
imode-enter insert
jscroll down
kscroll up
lscroll right
mcmd-set-text -s :quickmark-add {url}
nsearch-next
ocmd-set-text -s :open
pPopen -- {primary}
ppopen -- {clipboard}
qmacro-record
rreload
uundo
vmode-enter caret
widevtools
yDyank -s domain
yTyank -s title
yYyank -s
ydyank domain
ytyank title
yyyank
{{navigate prev -t
}}navigate next -t

insert mode

KeysCommand
<Ctrl-e>open-editor
<Escape>mode-leave

command mode

KeysCommand
<Alt-Backspace>rl-backward-kill-word
<Alt-b>rl-backward-word
<Alt-d>rl-kill-word
<Alt-f>rl-forward-word
<Backspace>rl-backward-delete-char
<Ctrl-C>completion-item-yank --sel
<Ctrl-a>rl-beginning-of-line
<Ctrl-b>rl-backward-char
<Ctrl-c>completion-item-yank
<Ctrl-d>completion-item-del
<Ctrl-e>rl-end-of-line
<Ctrl-f>rl-forward-char
<Ctrl-h>rl-backward-delete-char
<Ctrl-k>rl-kill-line
<Ctrl-n>command-history-next
<Ctrl-p>command-history-prev
<Ctrl-u>rl-unix-line-discard
<Ctrl-v>rl-paste
<Ctrl-w>rl-rubout
<Ctrl-y>rl-yank
<Delete>rl-delete-char
<Down>command-history-next
<End>rl-end-of-line
<Escape>mode-leave
<Home>rl-beginning-of-line
<Left>rl-backward-char
<Return>command-accept
<Right>rl-forward-char
<Shift-Insert>rl-paste --sel
<Shift-Tab>completion-item-focus prev
<Tab>completion-item-focus next
<Up>command-history-prev

passthrough mode

KeysCommand
<Shift-Escape>mode-leave

hint mode

KeysCommand
<Escape>mode-leave
<Return>hint-follow

prompt mode

KeysCommand
<Alt-Backspace>rl-backward-kill-word
<Alt-b>rl-backward-word
<Alt-d>rl-kill-word
<Alt-e>prompt-fileselect-external
<Alt-f>rl-forward-word
<Alt-y>prompt-yank
<Backspace>rl-backward-delete-char
<Ctrl-a>rl-beginning-of-line
<Ctrl-b>rl-backward-char
<Ctrl-e>rl-end-of-line
<Ctrl-f>rl-forward-char
<Ctrl-h>rl-backward-delete-char
<Ctrl-k>rl-kill-line
<Ctrl-u>rl-unix-line-discard
<Ctrl-v>rl-paste
<Ctrl-w>rl-filename-rubout
<Ctrl-x>prompt-open-download
<Ctrl-y>rl-yank
<Delete>rl-delete-char
<End>rl-end-of-line
<Escape>mode-leave
<Home>rl-beginning-of-line
<Left>rl-backward-char
<Return>prompt-accept
<Right>rl-forward-char
<Shift-Insert>rl-paste --sel
<Tab>prompt-complete

yesno mode

KeysCommand
<Alt-y>prompt-yank
<Escape>mode-leave
<Return>prompt-accept
Aprompt-accept --save yes
Nprompt-accept --save no
nprompt-accept no
yprompt-accept yes

set_mark mode

KeysCommand
<Escape>mode-leave

jump_mark mode

KeysCommand
<Escape>mode-leave

record_macro mode

KeysCommand
<Escape>mode-leave

run_macro mode

KeysCommand
<Escape>mode-leave

caret mode

KeysCommand
$move-to-end-of-line
0move-to-start-of-line
<Ctrl-Space>selection-drop
<Escape>mode-leave
<Return>yank selection
<Space>selection-toggle
Gmove-to-end-of-document
Vselection-toggle --line
Yyank -s selection
[move-to-start-of-prev-block
]move-to-start-of-next-block
bmove-to-prev-word
emove-to-end-of-word
ggmove-to-start-of-document
hmove-to-prev-char
jmove-to-next-line
kmove-to-prev-line
lmove-to-next-char
oselection-reverse
vselection-toggle
wmove-to-next-word
yyank selection
{move-to-end-of-prev-block
}move-to-end-of-next-block

Settings

Set these in config.toml (hints.chars = "asdf"), config.lua (c.hints.chars = "asdf") or with :set hints.chars asdf.

SettingTypeDefaultDescription
aliasestable<string, string>{"q":"close","qa":"quit","w":"session-save","wq":"quit --save","wqa":"quit --save"}Command aliases: name → command. As in qutebrowser, :q closes the window, :qa quits, :w saves the session and :wq saves and quits
auto_save.intervalinteger15000Milliseconds between crash-recovery saves of the open tabs (0 turns them off)
auto_save.sessionbooleanfalseSave the open tabs as the 'default' session on quit, and restore them at startup
bindings.key_mappingstable<string, string>{"<Ctrl-6>":"<Ctrl-^>","<Ctrl-[>":"<Escape>","<Ctrl-i>":"<Tab>","<Ctrl-j>":"<Return>","<Ctrl-m>":"<Return>","<Shift-Return>":"<Return>"}Keys treated as other keys in every mode, before bindings are looked up, e.g. Ctrl-[ as Escape
changelog_after_upgrademajor | minor | patch | neverminorOpen the changelog in a tab after an upgrade of at least this size: major, minor, patch or never
colors.completion.category.bgstring``Background of completion category headers; empty uses ui.theme's
colors.completion.category.fgstring``Text of completion category headers; empty uses ui.theme's
colors.completion.description.fgstring``Descriptions and details in the completion list; empty uses ui.theme's
colors.completion.fgstring``Completion list text; empty uses ui.theme's
colors.completion.item.selected.bgstring``Background of the selected completion; empty uses ui.theme's
colors.completion.item.selected.fgstring``Text of the selected completion; empty uses ui.theme's
colors.completion.match.fgstring``The typed text where it appears in completion items; empty uses ui.theme's
colors.completion.odd.bgstring``Completion list background; empty uses ui.theme's
colors.hints.bgstring``Background of hint labels; empty uses ui.theme's
colors.hints.borderstring``Border of hint labels; empty uses ui.theme's
colors.hints.fgstring``Text of hint labels; empty uses ui.theme's
colors.hints.match.fgstring``The typed part of hint labels; empty uses ui.theme's
colors.keyhint.suffix.fgstring``The keys still to type in the key hint popup; empty uses ui.theme's
colors.messages.error.bgstring``Background of error messages; empty uses ui.theme's
colors.messages.error.fgstring``Text of error messages; empty uses ui.theme's
colors.messages.warning.bgstring``Background of warnings; empty uses ui.theme's
colors.messages.warning.fgstring``Text of warnings; empty uses ui.theme's
colors.prompts.bgstring``Background of prompts; empty uses ui.theme's
colors.prompts.borderstring``Frame, title and keys of floating prompts; empty uses ui.theme's
colors.prompts.fgstring``Text of prompts; empty uses ui.theme's
colors.prompts.key.bgstring``Background of a floating prompt's keys; empty uses ui.theme's
colors.statusbar.insert.bgstring``Status bar background in insert mode; empty uses ui.theme's
colors.statusbar.insert.fgstring``Status bar text in insert mode; empty uses ui.theme's
colors.statusbar.normal.bgstring``Status bar background; empty uses ui.theme's
colors.statusbar.normal.fgstring``Status bar text; empty uses ui.theme's
colors.statusbar.passthrough.bgstring``Status bar background in passthrough mode; empty uses ui.theme's
colors.statusbar.passthrough.fgstring``Status bar text in passthrough mode; empty uses ui.theme's
colors.statusbar.private.bgstring``Status bar background in private windows; empty uses ui.theme's
colors.statusbar.private.fgstring``Status bar text in private windows; empty uses ui.theme's
colors.statusbar.url.error.fgstring``The address of a page that failed to load; empty uses ui.theme's
colors.statusbar.url.success.http.fgstring``An http:// address in the status bar; empty uses ui.theme's
colors.statusbar.url.success.https.fgstring``An https:// address in the status bar; empty uses ui.theme's
colors.tabs.bar.bgstring``Tab bar background behind the tabs; empty uses ui.theme's
colors.tabs.even.bgstring``Background of even-numbered tabs; empty uses ui.theme's
colors.tabs.indicator.errorstring``A tab's indicator when its page failed to load; empty uses ui.theme's
colors.tabs.indicator.startstring``A tab's loading indicator; empty uses ui.theme's
colors.tabs.odd.bgstring``Background of odd-numbered tabs; empty uses ui.theme's
colors.tabs.odd.fgstring``Text of tabs; empty uses ui.theme's
colors.tabs.pinned.odd.bgstring``Background of pinned tabs; empty uses ui.theme's
colors.tabs.pinned.odd.fgstring``Text of pinned tabs; empty uses ui.theme's
colors.tabs.selected.accentstring``Color of the line marking the current tab, any CSS color such as #2ec4b6; empty matches the tab, so no line shows
colors.tabs.selected.odd.bgstring``Background of the current tab; empty uses ui.theme's
colors.tabs.selected.odd.fgstring``Text of the current tab; empty uses ui.theme's
colors.webpage.bgstringwhiteBackground of a new tab before its page paints, e.g. #1e1e2e so dark themes don't flash white; #rrggbb, white or black
colors.webpage.darkmode.enabledbooleanfalseRender light pages dark with Chromium's automatic dark mode; applies at once and can be set per site
colors.webpage.preferred_color_schemeauto | light | darkautoThe color scheme pages see in prefers-color-scheme: auto follows the system
completion.cmd_history_max_itemsinteger100How many command lines Up and Down remember
completion.delayinteger0Milliseconds to wait after a key press before updating completions
completion.heightstring12Height of the completion list: rows (12) or a percentage of the window (50%)
completion.min_charsinteger0Characters to type after a command before its arguments complete
completion.open_categoriesstring[]["searchengines","quickmarks","bookmarks","history","filesystem"]What :open completes from, in order: searchengines, quickmarks, bookmarks, history, filesystem
completion.quickbooleantrueWhen only one command or setting name is left, Tab takes it and moves on to completing the next part
completion.showalways | auto | neveralwaysWhen to show completions: always, only after pressing Tab (auto), or never
completion.shrinkbooleantrueShrink the completion list to its items; false keeps it completion.height tall
completion.timestamp_formatstring%Y-%m-%d %H:%Mstrftime format of the last-visit time shown next to history completions; empty hides it
completion.use_best_matchbooleanfalseReturn runs the first command that starts with an unknown command name, so :rel runs :reload
completion.web_history.excludestring[][]URL globs (e.g. ://.bank.example/*) that :open never suggests from history
completion.web_history.max_itemsinteger100How many history entries :open completion shows (0 turns history completion off)
confirm_quitstring[]["never"]Ask before quitting: always, multiple-tabs (more than one tab open), downloads (downloads still running), or never
content.autoplaybooleantrueLet videos play by themselves; false waits until you interact with the page (after a restart)
content.blocking.adblock.listsstring[]["https://easylist.to/easylist/easylist.txt","https://easylist.to/easylist/easyprivacy.txt","https://ublockorigin.github.io/uAssets/filters/filters.min.txt","https://ublockorigin.github.io/uAssets/filters/privacy.min.txt","https://ublockorigin.github.io/uAssets/filters/quick-fixes.min.txt","https://ublockorigin.github.io/uAssets/filters/unbreak.min.txt"]Adblock Plus filter lists or hosts files that :adblock-update downloads (https://, or file:// for local lists); uBlock Origin's own lists may use its trusted scriptlets
content.blocking.adblock.rulesstring[][]Your own filter rules, in Adblock Plus syntax (e.g. example.com##.banner); they apply at once, without :adblock-update
content.blocking.enabledbooleantrueBlock ads and trackers with the filter lists from content.blocking.adblock.lists
content.blocking.whiteliststring[][]Sites where nothing is blocked, as host names; a host also covers its subdomains
content.cache.sizeinteger0Disk cache size in bytes; 0 lets Chromium choose (takes effect after a restart)
content.call_mute_keystable<string, string>{"*.webex.com":"<Ctrl-m>","*.zoom.us":"<Alt-a>","meet.google.com":"<Ctrl-d>","meet.jit.si":"m","teams.cloud.microsoft":"<Ctrl-Shift-m>","teams.live.com":"<Ctrl-Shift-m>","teams.microsoft.com":"<Ctrl-Shift-m>"}The key each call site mutes the microphone with, by URL pattern, for :call-mute (cm); keys as in :bind, e.g. <Ctrl-d>
content.call_sitesstring[]["meet.google.com","teams.microsoft.com","teams.live.com","teams.cloud.microsoft","*.zoom.us/wc/*","*.zoom.us/j/*","*.webex.com","meet.jit.si","whereby.com"]Video call sites, as URL patterns, that open in a call window. There, sharing your screen lets you pick a tab, a window or the whole screen; in an ordinary tab it always shares the whole screen. Clear the list to open these sites as ordinary tabs; you then lose that choice unless you use :open --call or :tab-call
content.canvas_readingbooleantrueLet pages read back what they drew on a canvas; false blocks a common fingerprinting trick but breaks some sites (after a restart)
content.cookies.acceptall | no-3rdparty | no-unknown-3rdparty | neverallWhich cookies sites may set: all, none from other sites (no-3rdparty; no-unknown-3rdparty is the same here), or never
content.cookies.storebooleantrueKeep cookies after the browser closes; false makes every cookie last only for the session
content.desktop_captureask | true | falseaskLet sites capture your screen or desktop audio: ask, true or false
content.dns_prefetchbooleantrueLook up the hosts of links before you follow them, which is faster but tells your DNS server about them
content.geolocationask | true | falseaskLet sites know your location: ask, true or false
content.headers.accept_languagestring``Languages sites are asked for, e.g. en-US,en;q=0.9 (also navigator.languages); empty for the system's
content.headers.customtable<string, string>{}Extra headers sent with every request: name → value
content.headers.do_not_trackbooleantrueSend DNT: 1 with every request, asking sites not to track you
content.headers.refereralways | never | same-domainsame-domainWhen to send the Referer header: always, never, or only within the same domain and its subdomains
content.headers.user_agentstring``User agent sent to sites and shown to their scripts; empty for Chromium's own. Can be set per site
content.imagesbooleantrueLoad images; can be set per site
content.javascript.can_close_tabsbooleantrueLet a page close its own tab with window.close(), as login popups do
content.javascript.can_open_tabs_automaticallybooleanfalseLet pages open tabs and windows without a click (popups); can be set per site
content.javascript.clipboardnone | access | access-pasteaccessWhat pages may do with the clipboard: nothing, copy with a click (access), or also read it (access-paste); can be set per site
content.javascript.enabledbooleantrueRun JavaScript on pages; can be set per site
content.javascript.log_message.levelsstring[][]Console messages from pages shown in the status bar and :messages, by level: debug, info, warning, error (can be set per site)
content.local_content_can_access_file_urlsbooleanfalseLet file:// pages read other local files, which a downloaded page could misuse (after a restart)
content.media.audio_captureask | true | falseaskLet sites use your microphone: ask, true or false
content.media.video_captureask | true | falseaskLet sites use your camera: ask, true or false
content.mouse_lockask | true | falseaskLet sites lock your mouse pointer, as games do: ask, true or false
content.mutebooleanfalseMute pages; can be set per site
content.notifications.app_namestringriptideThe app name on desktop notifications (presenter = libnotify), which notification services such as dunst and mako can match to style them
content.notifications.enabledask | true | falseaskLet sites show notifications: ask, true or false
content.notifications.presenterauto | libnotify | messagesautoWhere page notifications show: auto (Chromium's desktop notifications), libnotify (desktop notifications riptide sends with notify-send, following the other content.notifications settings) or messages (riptide's status bar)
content.notifications.show_originbooleantrueShow the site a notification came from (presenter = libnotify or messages)
content.notifications.site_iconbooleantrueShow the site's icon on desktop notifications (presenter = libnotify)
content.notifications.timeoutinteger-1Milliseconds a desktop notification stays (presenter = libnotify): -1 lets the desktop decide, 0 keeps it until dismissed
content.notifications.urgencylow | normal | criticalnormalHow urgent desktop notifications are (presenter = libnotify): low, normal or critical
content.pdf_viewerbooleantrueShow PDFs in the browser; false downloads them instead
content.prefers_reduced_motionbooleanfalseTell pages you prefer less motion, so they can tone down animations (after a restart)
content.proxystringsystemProxy: system, none, a proxy URL such as socks5://127.0.0.1:9050, or pac+ and a PAC script's URL
content.register_protocol_handlerask | true | falseaskLet sites register to handle links like mailto: : ask, true or false
content.tls.certificate_errorsask | block | load-insecurelyaskPages whose TLS certificate isn't trusted: ask, block, or load-insecurely
content.unknown_url_scheme_policyask | allow-all | disallowaskLinks to schemes the browser can't show (mailto:, magnet:, zoommtg:): ask before handing them to xdg-open, always hand them over, or never
content.user_stylesheetsstring[][]CSS files applied to pages (relative paths are in the config directory); reloaded when they change; can be set per site
content.webglbooleantrueAllow WebGL, which 3D graphics need and fingerprinting scripts use (after a restart)
content.webrtc_ip_handling_policyall-interfaces | default-public-and-private-interfaces | default-public-interface-only | disable-non-proxied-udpall-interfacesWhich IP addresses WebRTC (video calls) may reveal; disable-non-proxied-udp keeps it behind content.proxy
content.widevinebooleanfalseAllow Widevine DRM: Chromium downloads Google's CDM once (takes effect after a restart)
crash_report.emailstring``Where :crash-report's Email button sends a report; empty hides the button
downloads.location.directorystring``Where downloads go; empty means the system Downloads folder
downloads.location.promptbooleantrueAsk where to save each download (false saves straight to the directory)
downloads.location.rememberbooleantrueStart the save prompt in the folder the last download went to
downloads.location.suggestionboth | path | filenamebothWhat the save prompt starts with: the folder and file name (both), the folder (path), or the file name
downloads.open_dispatcherstring``Program that opens downloads (:download-open); {} is the file, or it's added at the end. Empty for the desktop's default
downloads.remove_finishedinteger-1Take finished downloads off the list after this many milliseconds; -1 keeps them
editor.commandstring[]["gvim","-f","{file}","-c","normal {line}G{column0}l"]Editor for :open-editor; fields: {file}, {line}, {column}, {line0}, {column0}
editor.remove_filebooleantrueDelete the temporary file after the editor closes; false keeps it, e.g. to recover text
extensions.loadstring[][]Folders of unpacked Chrome extensions to load, besides the ones :extension-install installs (after a restart)
fileselect.folder.commandstring[]["xterm","-e","ranger","--choosedir={}"]Program that picks a folder for fileselect.handler = external; {} is the file it writes the path to
fileselect.handlerdefault | externaldefaultFile pickers for upload fields: Chromium's own (default), or the fileselect.*.command programs (external)
fileselect.multiple_files.commandstring[]["xterm","-e","ranger","--choosefiles={}"]Program that picks several files for fileselect.handler = external; {} is the file it writes the paths to, one per line
fileselect.single_file.commandstring[]["xterm","-e","ranger","--choosefile={}"]Program that picks a file for fileselect.handler = external; {} is the file it writes the path to
fonts.completion.categorystringbold default_size default_familyFont of completion category headers (default_size and default_family stand for those settings)
fonts.completion.entrystringdefault_size default_familyFont of completion entries
fonts.default_familystring"DejaVu Sans Mono", Monospace, monospaceFont family that the other fonts.* settings call default_family
fonts.default_sizestring10ptFont size that the other fonts.* settings call default_size, e.g. 10pt or 13px
fonts.hintsstringbold default_size default_familyFont of hint labels
fonts.keyhintstringdefault_size default_familyFont of the key hint popup
fonts.promptsstringdefault_size default_familyFont of prompts
fonts.statusbarstringdefault_size default_familyFont of the status bar; sizes beyond the bar's height are cut off until bars size to their font
fonts.tabs.selectedstringdefault_size default_familyFont of the current tab
fonts.tabs.unselectedstringdefault_size default_familyFont of the other tabs
fonts.web.family.fixedstring``Monospace font for pages (CSS monospace); empty for Chromium's
fonts.web.family.sans_serifstring``Sans-serif font for pages; empty for Chromium's
fonts.web.family.serifstring``Serif font for pages; empty for Chromium's
fonts.web.family.standardstring``Font for pages that don't choose one; empty for Chromium's
fonts.web.size.defaultinteger16Default text size of pages, in pixels
fonts.web.size.default_fixedinteger13Default size of monospace text in pages, in pixels
fonts.web.size.minimuminteger0Smallest text size pages may use, in pixels (0 for no minimum)
hints.auto_followalways | unique-match | full-match | neverunique-matchWhen a hint is followed without Return: when one is left (unique-match), only when its label is typed in full (full-match), always, or never
hints.auto_follow_timeoutinteger0Ignore keys for this many milliseconds after following a hint, so extra typing doesn't reach the page
hints.charsstringasdfghjklCharacters used for hint labels
hints.dictionarystring/usr/share/dict/wordsWord list for hints.mode = word, one word per line
hints.hide_unmatched_rapid_hintsbooleantrueIn rapid hint mode (:hint --rapid), hide the labels that don't match what's typed
hints.leave_on_loadbooleantrueLeave hint mode when the page starts loading something new
hints.min_charsinteger1The shortest hint label, in characters
hints.modeletter | number | wordletterletter: labels from hints.chars; number: numbered labels, and typing letters filters by text; word: dictionary words from each link's text
hints.next_regexesstring[]["\\bnext\\b","\\bmore\\b","\\bnewer\\b","\\b[>→≫]\\b","\\b(>>|»)\\b","\\bcontinue\\b"]Link texts ]] follows to the next page, as JavaScript regular expressions (case doesn't matter)
hints.paddingstring0 3pxSpace around a hint label's text, as CSS padding, e.g. 1px 4px
hints.prev_regexesstring[]["\\bprev(ious)?\\b","\\bback\\b","\\bolder\\b","\\b[<←≪]\\b","\\b(<<|«)\\b"]Link texts [[ follows to the previous page, as JavaScript regular expressions (case doesn't matter)
hints.radiusinteger3Corner radius of hint labels in pixels; 0 is square
hints.scatterbooleantrueSpread hint labels over the alphabet so neighbours differ; false labels in order
hints.selectorstable<string, string>{"all":"a, area, textarea, select, input:not([type=hidden]), button, iframe, summary, [contenteditable]:not([contenteditable=false]), [onclick], [onmousedown], [role=link], [role=option], [role=button], [role=tab], [role=checkbox], [role=switch], [role=menuitem], [role=menuitemcheckbox], [role=menuitemradio], [role=treeitem], [aria-haspopup], [tabindex]:not([tabindex='-1'])","blocks":"[class], [id], img, iframe, ins, aside, video, embed, object","images":"img","inputs":"input:not([type]), input[type=text], input[type=search], input[type=email], input[type=url], input[type=tel], input[type=password], input[type=number], input[type=date], input[type=datetime-local], input[type=month], input[type=time], input[type=week], textarea, [contenteditable]:not([contenteditable=false])","links":"a[href], area[href], [role=link][href]","media":"audio, img, video"}Hint groups for :hint, as CSS selector lists; your entries are added to the built-in all, links, images, media and inputs
hints.uppercasebooleanfalseShow hint labels in upper case
input.forward_unbound_keysall | auto | noneautoPass unbound keys to the page in normal mode (auto: all but plain letters and digits)
input.insert_mode.auto_enterbooleantrueEnter insert mode when an editable element gets focus
input.insert_mode.auto_leavebooleantrueLeave insert mode when focus leaves an editable element
input.insert_mode.auto_loadbooleanfalseEnter insert mode when a page focuses a text field by itself, as autofocus does on load
input.insert_mode.leave_on_loadbooleantrueLeave insert mode when a new page starts loading
input.match_countsbooleantrueRead digits typed before a binding as a count (3j); false lets digits be bindings themselves
input.media_keysbooleantrueLet the keyboard's media keys (play, pause, next) control audio and video in pages (after a restart)
input.mode_overridenone | normal | insert | passthroughnoneMode to enter when a page loads or its tab is focused; set it per site, e.g. passthrough for a web terminal
input.mouse.rocker_gesturesbooleanfalseHold the right button and click the left to go back, or the other way round to go forward; turns off the page's context menu
input.partial_timeoutinteger0Milliseconds before a half-typed key chain or count is forgotten; 0 waits forever
input.spatial_navigationbooleanfalseMove focus between links and fields with the arrow keys, as on a TV (after a restart)
keyhint.blackliststring[][]Key chains the key hint popup leaves out, as globs on the whole chain (e.g. g* for every chain starting with g)
keyhint.delayinteger500How long after a partial key chain the popup listing its continuations appears, in milliseconds
messages.timeoutinteger3000Milliseconds before a status bar message clears (0 keeps it)
new_instance_open_targettab | tab-bg | windowtabWhere URLs from a second riptide invocation open
new_instance_open_target_windowfirst-opened | last-opened | last-focusedlast-focusedWhich window URLs from a second riptide invocation open in
plugins.catalogstringhttps://github.com/joshzcold/riptide-pluginsThe git repository of plugins the Plugins tab's Browse lists, one plugin per folder
plugins.check_intervalinteger1Every this many days, check plugins from git for new commits in the background and say once which have updates; nothing updates by itself (0: never)
prompt.positionbottom | center | dockedbottomWhere questions (permissions, logins, downloads, page dialogs) appear: bottom, a box floating near the bottom of the page; center, the same box in the middle; or docked above the status bar
prompt.widthinteger640Width in pixels of a floating prompt (prompt.position = bottom or center), at most the page's
scrolling.baralways | never | overlayalwaysPage scrollbars: always, never, or overlay (thin, shown while scrolling; after a restart)
scrolling.smoothbooleanfalseAnimate scrolling by keys instead of jumping
search.ignore_casesmart | always | neversmartCase in searches: smart ignores it unless the text has a capital, always, or never
search.incrementalbooleantrueSearch while typing after / or ?
search.wrapbooleantrueGo on from the top when a search passes the last match (or from the bottom, searching up)
search.wrap_messagesbooleantrueSay when a search wraps around the page
session.default_namestring``Session that :session-save, :wq and auto_save.session use; empty means the last one loaded, or default
session.lazy_restorebooleanfalseWhen restoring a session, load background tabs only when they are first shown
spellcheck.languagesstring[][]Spell-check languages such as en-US (empty: off); Chromium downloads each dictionary from Google once
statusbar.paddingstring0 4pxSpace around the status bar's text, as CSS padding (top right bottom left), e.g. 2px 8px; the bar grows to fit
statusbar.positiontop | bottombottomWhere the status bar is
statusbar.showalways | never | in-modealwaysWhen to show the status bar: always, only while typing a command or answering a prompt (never), or also outside normal mode and while a message is shown (in-mode)
statusbar.widgetsstring[]["keypress","downloads","blocked","muted","media","sharing","zoom","search_match","url","scroll","history","tabs","progress"]What the right side of the status bar shows, in order: keypress, downloads, blocked (requests the ad blocker stopped on the page), muted, media, sharing (a screen, window or tab being shared from any tab; :share-stop stops it), zoom, search_match, url, scroll, scroll_raw, history, tabs, progress, clock[:strftime format], text:…, lua:<name> (drawn by rt.statusbar.widget)
tabs.close_mouse_buttonmiddle | right | nonemiddleWhich mouse button closes a tab clicked in the tab bar
tabs.close_mouse_button_on_barnew-tab | close-current | close-last | ignorenew-tabWhat tabs.close_mouse_button does on the empty part of the tab bar
tabs.favicons.showalways | never | pinnedalwaysShow site icons in the tab bar: always, never, or only on pinned tabs
tabs.indicator.widthinteger3Width in pixels of the loading indicator at the left of each tab (0 hides it)
tabs.last_closeignore | blank | startpage | default-page | closeignoreWhat closing the last tab does
tabs.max_widthinteger-1Largest width in pixels of a tab in a top or bottom tab bar (-1 for no limit)
tabs.min_widthinteger-1Smallest width in pixels of a tab in a top or bottom tab bar; tabs that don't fit scroll (-1 for no minimum)
tabs.mode_on_changenormal | persist | restorenormalMode after switching tabs: normal, persist (keep insert/passthrough), or restore (the mode the tab was left in)
tabs.mousewheel_switchingbooleantrueSwitch tabs with the mouse wheel over the tab bar
tabs.new_position.relatedprev | next | first | lastnextWhere tabs opened from a page go (popups, hints)
tabs.new_position.unrelatedprev | next | first | lastlastWhere other new tabs go (:open -t)
tabs.paddingstring0 4px 0 0Space around each tab's title, as CSS padding (top right bottom left); the tab bar grows to fit
tabs.pinned.closeask | refuse | closeaskClosing a pinned tab without --force: ask first, refuse, or just close it
tabs.pinned.frozenbooleantrueKeep pinned tabs on their page: :open in a pinned tab opens a new tab
tabs.pinned.shrinkbooleantrueShrink pinned tabs to their icon and number
tabs.positiontop | bottom | left | righttopWhere the tab bar is; left and right list the tabs vertically
tabs.select_on_removenext | prev | last-usednextWhich tab to show after closing the current one: the next, the previous, or the one used before
tabs.showalways | never | multiple | switchingalwaysWhen to show the tab bar: always, never, with more than one tab, or briefly after switching tabs
tabs.show_switching_delayinteger800How long the tab bar stays after switching tabs with tabs.show = switching, in milliseconds
tabs.tabs_are_windowsbooleanfalseOpen every tab in its own window and hide the tab bar, for tiling window managers
tabs.title.alignmentleft | center | rightleftWhere tab titles sit in their tab: left, center or right
tabs.title.formatstring{audio}{media}{index}: {current_title}Tab titles; fields: {index}, {aligned_index}, {current_title}, {current_url}, {host}, {perc}, {audio}, {media} ([A/V] while the page uses a camera or the screen, and a microphone), {private}
tabs.title.format_pinnedstring{index}Titles of pinned tabs while tabs.pinned.shrink shrinks them; same fields as tabs.title.format
tabs.tooltipsbooleantrueShow a tab's title and URL when the mouse rests on it
tabs.undo_stack_sizeinteger100How many closed tabs u can reopen; 0 keeps none
tabs.widthinteger200Width of the tab bar in pixels when tabs.position is left or right
tabs.wrapbooleantrueWrap around from the last tab to the first (and back) when switching tabs
ui.auto_theme.darkstringriptideThe theme ui.theme = auto uses when the desktop (or colors.webpage.preferred_color_scheme) prefers dark
ui.auto_theme.lightstringriptide-lightThe theme ui.theme = auto uses when light is preferred
ui.overlay.positiondocked | floatingdockedWhere the command line's completions and the key hints appear: docked above the status bar, or floating, a box near the top of the page that also shows the command
ui.overlay.widthinteger800Width in pixels of the floating overlay (ui.overlay.position = floating), at most the page's
ui.themestringriptideColors of riptide's bars, prompts and hints: riptide, riptide-light, gruvbox, catppuccin, nord, dracula, solarized, tokyo-night or a theme from themes/ in the config directory (:theme); auto follows the light or dark preference
url.auto_searchnaive | schemeless | nevernaiveWhen :open searches: text that doesn't look like an address (naive), anything without a scheme:// (schemeless), or never
url.default_pagestringhttps://start.duckduckgo.com/Page for :open without a URL
url.incdec_segmentsstring[]["path","query"]Parts of the URL Ctrl-a and Ctrl-x change: host, port, path, query, anchor
url.open_base_urlbooleanfalseOpen a search engine's home page when :open gets just its name
url.searchenginestable<string, string>{"DEFAULT":"https://duckduckgo.com/?q={}"}Search engines; ':open g rust' uses the 'g' entry, anything else DEFAULT
url.start_pagesstring[]["https://start.duckduckgo.com/"]Pages opened at startup when no URL is given
url.yank_ignored_parametersstring[]["ref","utm_source","utm_medium","utm_campaign","utm_term","utm_content","utm_name","fbclid","gclid"]Query parameters dropped when yanking a URL, such as tracking tags
window.hide_decorationbooleanfalseAsk the window manager for no title bar or borders (applies to new windows)
window.title_formatstring{current_title}{title_sep}RiptideWindow title; fields: {current_title}, {title_sep}, {current_url}, {host}, {mode}
zoom.defaultinteger100Zoom in percent for pages, and what :zoom without a value resets to
zoom.levelsstring[]["25%","33%","50%","67%","75%","90%","100%","110%","125%","150%","175%","200%","250%","300%","400%","500%"]The zoom levels + and - step through, in percent

Lua API

These are the type definitions riptide --lua-types prints for lua-language-server. They document everything config.lua can use: the c settings proxy and the rt.* functions. Lua explains how to use them.

---@meta
---@diagnostic disable: missing-fields
-- Type definitions for riptide's config.lua, for lua-language-server.
-- Generated by `riptide --lua-types`; do not edit.

---@alias rt.Mode "normal"|"insert"|"command"|"passthrough"|"hint"|"prompt"|"yesno"|"set_mark"|"jump_mark"|"record_macro"|"run_macro"|"caret"
---@alias rt.SettingName "aliases"|"auto_save.interval"|"auto_save.session"|"bindings.key_mappings"|"changelog_after_upgrade"|"colors.completion.category.bg"|"colors.completion.category.fg"|"colors.completion.description.fg"|"colors.completion.fg"|"colors.completion.item.selected.bg"|"colors.completion.item.selected.fg"|"colors.completion.match.fg"|"colors.completion.odd.bg"|"colors.hints.bg"|"colors.hints.border"|"colors.hints.fg"|"colors.hints.match.fg"|"colors.keyhint.suffix.fg"|"colors.messages.error.bg"|"colors.messages.error.fg"|"colors.messages.warning.bg"|"colors.messages.warning.fg"|"colors.prompts.bg"|"colors.prompts.border"|"colors.prompts.fg"|"colors.prompts.key.bg"|"colors.statusbar.insert.bg"|"colors.statusbar.insert.fg"|"colors.statusbar.normal.bg"|"colors.statusbar.normal.fg"|"colors.statusbar.passthrough.bg"|"colors.statusbar.passthrough.fg"|"colors.statusbar.private.bg"|"colors.statusbar.private.fg"|"colors.statusbar.url.error.fg"|"colors.statusbar.url.success.http.fg"|"colors.statusbar.url.success.https.fg"|"colors.tabs.bar.bg"|"colors.tabs.even.bg"|"colors.tabs.indicator.error"|"colors.tabs.indicator.start"|"colors.tabs.odd.bg"|"colors.tabs.odd.fg"|"colors.tabs.pinned.odd.bg"|"colors.tabs.pinned.odd.fg"|"colors.tabs.selected.accent"|"colors.tabs.selected.odd.bg"|"colors.tabs.selected.odd.fg"|"colors.webpage.bg"|"colors.webpage.darkmode.enabled"|"colors.webpage.preferred_color_scheme"|"completion.cmd_history_max_items"|"completion.delay"|"completion.height"|"completion.min_chars"|"completion.open_categories"|"completion.quick"|"completion.show"|"completion.shrink"|"completion.timestamp_format"|"completion.use_best_match"|"completion.web_history.exclude"|"completion.web_history.max_items"|"confirm_quit"|"content.autoplay"|"content.blocking.adblock.lists"|"content.blocking.adblock.rules"|"content.blocking.enabled"|"content.blocking.whitelist"|"content.cache.size"|"content.call_mute_keys"|"content.call_sites"|"content.canvas_reading"|"content.cookies.accept"|"content.cookies.store"|"content.desktop_capture"|"content.dns_prefetch"|"content.geolocation"|"content.headers.accept_language"|"content.headers.custom"|"content.headers.do_not_track"|"content.headers.referer"|"content.headers.user_agent"|"content.images"|"content.javascript.can_close_tabs"|"content.javascript.can_open_tabs_automatically"|"content.javascript.clipboard"|"content.javascript.enabled"|"content.javascript.log_message.levels"|"content.local_content_can_access_file_urls"|"content.media.audio_capture"|"content.media.video_capture"|"content.mouse_lock"|"content.mute"|"content.notifications.app_name"|"content.notifications.enabled"|"content.notifications.presenter"|"content.notifications.show_origin"|"content.notifications.site_icon"|"content.notifications.timeout"|"content.notifications.urgency"|"content.pdf_viewer"|"content.prefers_reduced_motion"|"content.proxy"|"content.register_protocol_handler"|"content.tls.certificate_errors"|"content.unknown_url_scheme_policy"|"content.user_stylesheets"|"content.webgl"|"content.webrtc_ip_handling_policy"|"content.widevine"|"crash_report.email"|"downloads.location.directory"|"downloads.location.prompt"|"downloads.location.remember"|"downloads.location.suggestion"|"downloads.open_dispatcher"|"downloads.remove_finished"|"editor.command"|"editor.remove_file"|"extensions.load"|"fileselect.folder.command"|"fileselect.handler"|"fileselect.multiple_files.command"|"fileselect.single_file.command"|"fonts.completion.category"|"fonts.completion.entry"|"fonts.default_family"|"fonts.default_size"|"fonts.hints"|"fonts.keyhint"|"fonts.prompts"|"fonts.statusbar"|"fonts.tabs.selected"|"fonts.tabs.unselected"|"fonts.web.family.fixed"|"fonts.web.family.sans_serif"|"fonts.web.family.serif"|"fonts.web.family.standard"|"fonts.web.size.default"|"fonts.web.size.default_fixed"|"fonts.web.size.minimum"|"hints.auto_follow"|"hints.auto_follow_timeout"|"hints.chars"|"hints.dictionary"|"hints.hide_unmatched_rapid_hints"|"hints.leave_on_load"|"hints.min_chars"|"hints.mode"|"hints.next_regexes"|"hints.padding"|"hints.prev_regexes"|"hints.radius"|"hints.scatter"|"hints.selectors"|"hints.uppercase"|"input.forward_unbound_keys"|"input.insert_mode.auto_enter"|"input.insert_mode.auto_leave"|"input.insert_mode.auto_load"|"input.insert_mode.leave_on_load"|"input.match_counts"|"input.media_keys"|"input.mode_override"|"input.mouse.rocker_gestures"|"input.partial_timeout"|"input.spatial_navigation"|"keyhint.blacklist"|"keyhint.delay"|"messages.timeout"|"new_instance_open_target"|"new_instance_open_target_window"|"plugins.catalog"|"plugins.check_interval"|"prompt.position"|"prompt.width"|"scrolling.bar"|"scrolling.smooth"|"search.ignore_case"|"search.incremental"|"search.wrap"|"search.wrap_messages"|"session.default_name"|"session.lazy_restore"|"spellcheck.languages"|"statusbar.padding"|"statusbar.position"|"statusbar.show"|"statusbar.widgets"|"tabs.close_mouse_button"|"tabs.close_mouse_button_on_bar"|"tabs.favicons.show"|"tabs.indicator.width"|"tabs.last_close"|"tabs.max_width"|"tabs.min_width"|"tabs.mode_on_change"|"tabs.mousewheel_switching"|"tabs.new_position.related"|"tabs.new_position.unrelated"|"tabs.padding"|"tabs.pinned.close"|"tabs.pinned.frozen"|"tabs.pinned.shrink"|"tabs.position"|"tabs.select_on_remove"|"tabs.show"|"tabs.show_switching_delay"|"tabs.tabs_are_windows"|"tabs.title.alignment"|"tabs.title.format"|"tabs.title.format_pinned"|"tabs.tooltips"|"tabs.undo_stack_size"|"tabs.width"|"tabs.wrap"|"ui.auto_theme.dark"|"ui.auto_theme.light"|"ui.overlay.position"|"ui.overlay.width"|"ui.theme"|"url.auto_search"|"url.default_page"|"url.incdec_segments"|"url.open_base_url"|"url.searchengines"|"url.start_pages"|"url.yank_ignored_parameters"|"window.hide_decoration"|"window.title_format"|"zoom.default"|"zoom.levels"
---@alias rt.Event "startup"|"quit"|"load_started"|"load_finished"|"url_changed"|"title_changed"|"tab_opened"|"tab_closed"|"tab_selected"|"window_opened"|"window_closed"|"mode_changed"|"setting_changed"|"download_started"|"download_finished"

---@class rt
---@field platform "linux"|"macos"|"windows"
---@field version string
---@field config_dir string
---@field data_dir string
rt = {}

---@deprecated `hb` is the old name of `rt`.
hb = rt

---Set an option; `c.name = value` does the same. With `pattern` (e.g.
---`"*.example.com"` or `"https://meet.example.com"`), only for matching pages;
---this works for the content.* permission settings and content.blocking.enabled.
---@param name rt.SettingName
---@param value any
---@param pattern? string
function rt.set(name, value, pattern) end

---Get an option's current value.
---@param name rt.SettingName
---@return any
function rt.get(name) end

---Define a theme for `ui.theme` and `:theme`, like a `themes/<name>.toml`
---file: `palette` holds base, surface, fg, accent, yellow, red, green and blue
---(and optionally surface2, surface3, muted, orange) as `#rrggbb`, and
---`colors` any `colors.*` setting without the `colors.` prefix.
---@param name string a-z, 0-9, `-` and `_`
---@param spec { palette: table<string, string>, colors?: table<string, string> }
function rt.theme(name, spec) end

---Bind keys (qutebrowser notation, e.g. "<Ctrl-x>" or "gg") to a command,
---or to a Lua function that runs when they're pressed.
---@param keys string
---@param command string|fun()
---@param mode? rt.Mode defaults to "normal"
---@return string command what the keys run (`lua-call <n>` for a function)
function rt.bind(keys, command, mode) end

rt.keymap = {}

---Bind keys in one mode or several, as `vim.keymap.set`. `opts.desc` is shown
---in the key hints popup instead of the command.
---@param mode rt.Mode|rt.Mode[]
---@param keys string
---@param rhs string|fun()
---@param opts? { desc?: string }
function rt.keymap.set(mode, keys, rhs, opts) end

---Remove a binding in one mode or several.
---@param mode rt.Mode|rt.Mode[]
---@param keys string
function rt.keymap.del(mode, keys) end

---Remove a binding.
---@param keys string
---@param mode? rt.Mode defaults to "normal"
function rt.unbind(keys, mode) end

---Define a command, e.g. `:wiki rust`; `fn` gets the rest of the line.
---`opts` is a description, or a table with `desc` and `complete`, a function
---that gets what's typed after the command and returns completions: strings,
---or `{ name = "…", desc = "…" }` tables.
---@param name string letters, digits, `-` and `_`
---@param fn fun(args: string)
---@param opts? string|{ desc?: string, complete?: fun(arglead: string): (string|{ name: string, desc?: string })[] }
function rt.command(name, fn, opts) end

---Run `fn` on an event, with a table of what it's about (`e.url`, …):
---- `startup`: riptide has started and loaded config.lua
---- `quit`: riptide is about to quit
---- `load_started`: a tab started loading a page (url)
---- `load_finished`: a tab finished loading a page (url)
---- `url_changed`: a tab's address changed (url)
---- `title_changed`: a tab's title changed (url, title)
---- `tab_opened`: a tab was opened (url)
---- `tab_closed`: a tab was closed (url)
---- `tab_selected`: another tab became the current one (url, index from 1)
---- `window_opened`: a window was opened (private: "true" or "false")
---- `window_closed`: a window was closed
---- `mode_changed`: the mode changed (from, to)
---- `setting_changed`: a setting changed (name, value as text)
---- `download_started`: a download started (url, path)
---- `download_finished`: a download finished (url, path, state: done, failed or cancelled)
---`opts.pattern` only runs it for matching pages (as `:set -u` patterns),
---`opts.group` names it for `rt.off`/`rt.group`, and `opts.once` runs it once.
---`rt.on(event, fn)` works too. Returns an id for `rt.off`.
---@param event rt.Event
---@param opts { pattern?: string, group?: string, once?: boolean }|fun(e: table)
---@param fn? fun(e: table)
---@return integer
function rt.on(event, opts, fn) end

---Call `fn` once, `ms` milliseconds from now. `:stop()` on the result cancels it.
---@param ms integer
---@param fn fun()
---@return { stop: fun(self) }
function rt.defer(ms, fn) end

---Call `fn` every `ms` milliseconds (10 or more) until `:stop()` is called on the result.
---@param ms integer
---@param fn fun()
---@return { stop: fun(self) }
function rt.every(ms, fn) end

---Show a message in the status bar; the same as `rt.message`.
---@param text string
---@param level? "info"|"warning"|"error"
function rt.notify(text, level) end

---Data kept between runs under `name` (letters, digits, `-`, `_`), saved
---as JSON in the data folder on every change. Values are strings, numbers,
---booleans and tables of those.
---@param name? string Required in config.lua; in a plugin, its stores are its own and `name` defaults to "data".
---@return { get: fun(key: string): any, set: fun(key: string, value: any), all: fun(): table, clear: fun() }
function rt.store(name) end

---@class rt.PluginSpec
---@field [1]? string A git URL, the same as `src`.
---@field src? string A git URL to install it from.
---@field dir? string A folder on your computer instead.
---@field name? string Its name for `require`; by default from the URL or folder.
---@field version? string A tag, branch or commit.
---@field subdir? string The plugin's folder in a repository of several plugins.
---@field trusted? boolean Skip the sandbox and allow everything.
---@field opts? table Passed to `require(name).setup(opts)` once it loads.
---@field config? fun() Run once it loads, instead of `opts`.
---@field event? rt.Event|rt.Event[] Load when one of these events fires.
---@field cmd? string|string[] Load when one of these commands runs.
---@field keys? string|(string|{ [1]: string, mode?: string })[] Load when one of these keys is pressed.

rt.statusbar = {}

---A status bar widget: `fn` gives its text each time the bar is drawn,
---where `statusbar.widgets` lists `"lua:<name>"`. It gets 50 ms; one that
---fails or runs longer is removed. `nil` instead of `fn` removes it.
---@param name string Letters, digits, `-` and `_`.
---@param fn? fun(): string|number|nil
function rt.statusbar.widget(name, fn) end

rt.pack = {}

---Add plugins: a git URL, a spec, or a list of them. They load once
---`config.lua` has run, or with `event`, `cmd` or `keys` only when needed.
---@param specs string|rt.PluginSpec|(string|rt.PluginSpec)[]
function rt.pack.add(specs) end

---Remove a hook by the id `rt.on` returned, or every hook in a group.
---@param id integer|string
function rt.off(id) end

---A group name for hooks; `{ clear = true }` first removes the group's
---hooks, so a script that runs again doesn't add them twice.
---@param name string
---@param opts? { clear?: boolean }
---@return string
function rt.group(name, opts) end

---The current page's URL (in callbacks).
---@return string
function rt.url() end

---The current page's title (in callbacks).
---@return string
function rt.title() end

---The current mode, e.g. "normal" (in callbacks).
---@return string
function rt.mode() end

---The current window's tabs, in order (in callbacks).
---@return { index: integer, title: string, url: string, current: boolean, pinned: boolean }[]
function rt.tabs() end

---The count typed before the key, if any (in callbacks).
---@return integer?
function rt.count() end

---Run a command line, e.g. `rt.run("tab-close")` (in callbacks).
---@param line string
function rt.run(line) end

---Open a URL (in callbacks).
---@param url string
---@param target? "current"|"tab"|"tab-bg"|"window"|"private"
function rt.open(url, target) end

---Show a message in the status bar (in callbacks).
---@param text string
---@param level? "info"|"warning"|"error"
function rt.message(text, level) end

---@class rt.SpawnOpts
---@field stdin? string written to the program's standard input
---@field cwd? string the directory to run it in
---@field env? table<string, string> extra environment variables

---@class rt.SpawnResult
---@field code integer|nil the exit code; nil if a signal ended it or it didn't start
---@field stdout string
---@field stderr string
---@field error string|nil why it couldn't run

---Run a program in the background (in callbacks); no shell is involved.
---`argv` is a list, or a command line split like `:spawn` does.
---@param argv string[]|string
---@param opts? rt.SpawnOpts|fun(result: rt.SpawnResult)
---@param callback? fun(result: rt.SpawnResult) called when it exits
function rt.spawn(argv, opts, callback) end

rt.ui = {}

---@class rt.SelectOpts
---@field prompt? string the question shown above the items
---@field format? fun(item: any): string how to show an item; tostring by default

---Pick one of `items` in the prompt area, like `vim.ui.select`: each item has
---a key (1-9, then a-z; at most 35 items), and Return picks the first.
---`on_choice` gets the item and its index, or nil when cancelled.
---@param items any[]
---@param opts? rt.SelectOpts
---@param on_choice fun(item: any|nil, index: integer|nil)
function rt.ui.select(items, opts, on_choice) end

---@class rt.InputOpts
---@field prompt? string the question
---@field default? string text to start with
---@field secret? boolean mask what's typed, for passwords

---Ask for text in the prompt area, like `vim.ui.input`. `on_confirm` gets
---the text, or nil when cancelled.
---@param opts? rt.InputOpts
---@param on_confirm fun(text: string|nil)
function rt.ui.input(opts, on_confirm) end

---@alias rt.Highlight "title"|"muted"|"accent"|"match"|"url"|"key"|"info"|"warning"|"error"

---@class rt.FloatOpts
---@field title? string
---@field lines? (string|(string|{ [1]: string, [2]: rt.Highlight })[])[] Text, or chunks of text with highlights; never HTML.
---@field width? integer The widest it gets, in characters (10–200, default 60).
---@field position? "center"|"top"|"bottom"|"top-right"|"bottom-right"
---@field timeout? integer Close by itself after this many milliseconds.
---@field keys? table<string, fun(float: rt.Float)> Keys it takes in normal mode while it's the newest float with keys; Escape closes it.
---@field on_close? fun() Called when riptide closes it (Escape, its timeout).

---@class rt.Float
---@field id integer
local Float = {}
---Redraw with these options changed.
---@param changes rt.FloatOpts
function Float:update(changes) end
function Float:close() end
---@return boolean
function Float:is_open() end

---A box of text over the page. A plugin's floats show its name.
---@param opts rt.FloatOpts
---@return rt.Float
function rt.ui.float(opts) end

---@class rt.PanelOpts
---@field title? string
---@field lines? (string|(string|{ [1]: string, [2]: rt.Highlight })[])[] Text, or chunks of text with highlights; never HTML.
---@field side? "left"|"right"|"bottom" Default left.
---@field size? integer Width beside the page, or height below it, in pixels (default 300, or 200 below).
---@field keys? table<string, fun(panel: rt.Panel, line: integer)> Keys it takes while focused, with the cursor's line; j/k move the cursor and Escape returns to the page.
---@field on_close? fun() Called when riptide closes it (its window closing, another panel taking its side).

---@class rt.Panel
---@field id integer
local Panel = {}
---Redraw with these options changed.
---@param changes rt.PanelOpts
function Panel:update(changes) end
function Panel:close() end
---Give it the keyboard until Escape.
function Panel:focus() end
---@return boolean
function Panel:is_open() end

---@class rt.PageOpts
---@field path? string A file in the plugin's `pages/` folder (default `index.html`).
---@field where? "tab"|"panel" Default tab.
---@field side? "left"|"right"|"bottom" In a panel; default right.
---@field size? integer In a panel: its width, or height below the page, in pixels (default 400).
---@field on_message? fun(name: string, data: any, page: rt.Page) A message the page sent with `rt.send(name, json)`.

---@class rt.Page
---@field id integer
local Page = {}
---Send the page a message: it gets an `rtmessage` event with `detail.name` and `detail.data`.
---@param name string
---@param data any Anything `rt.json.encode` takes.
function Page:send(name, data) end
---Close its panel, or the tabs showing it.
function Page:close() end

rt.secret = {}

---One of this plugin's secret options (`type = "secret"` in its
---riptide-plugin.toml), from the OS keyring where the Plugins tab keeps it:
---`fn(value)`, `fn(nil)` when it isn't set, or `fn(nil, why)`. Only plugins
---have secret options, and each reads only its own.
---@param option string
---@param fn fun(value: string|nil, err: string|nil)
function rt.secret.get(option, fn) end

---Open one of this plugin's pages in a tab or a panel. Only plugins have pages.
---@param opts? rt.PageOpts
---@return rt.Page
function rt.ui.page(opts) end

---Lines beside or below the page, one panel per side of a window. A plugin's panels show its name.
---@param opts rt.PanelOpts
---@return rt.Panel
function rt.ui.panel(opts) end

---The current tab's page. Plugins need the `pages` permission for the site,
---checked when the action runs; nothing acts on riptide's own pages, and what's
---typed or filled is never logged or kept in history.
rt.page = {}

---Type text into the focused field, as `:insert-text` does.
---@param text string
function rt.page.type(text) end

---Press keys in the page, as `:fake-key` does, e.g. `"<Tab>"`.
---@param keys string
function rt.page.key(keys) end

---@class rt.Login
---@field host string the site the login is for; nothing is filled if the tab has moved on to another
---@field username? string
---@field password? string
---@field submit? boolean submit the form once it's filled

---Fill the page's login form: the focused field's form, or the first with a
---password field.
---@param login rt.Login
function rt.page.fill_login(login) end

---Evaluate a JavaScript expression in the page and get its value, as JSON
---turns it into Lua: `fn(value)`, or `fn(nil, why)`. It runs in the page's
---own world, so the page can see it and change what it returns.
---@param code string An expression; wrap statements in `(() => { … })()`.
---@param fn fun(value: any, err: string|nil)
function rt.page.eval(code, fn) end

---Add a stylesheet to the page, until it next loads.
---@param css string
function rt.page.css(css) end

---The page's selected text: `fn(text)`, or `fn(nil, why)`.
---@param fn fun(text: string|nil, err: string|nil)
function rt.page.selection(fn) end

---@class rt.HintedElement
---@field url string|nil Its link, if it has one.
---@field text string Its text, lowercased.

---Hint the elements `selector` matches, in frames of sites the plugin may act
---on, and hand the one you pick to `action`; nothing is clicked.
---@param opts { selector: string, action: fun(element: rt.HintedElement|nil, err: string|nil) }
function rt.page.hint(opts) end

rt.json = {}

---Parse JSON; `null` becomes nil.
---@param text string
---@return any
function rt.json.decode(text) end

---Write a value as JSON.
---@param value any
---@return string
function rt.json.encode(value) end

---@class rt.c
---@field aliases table<string, string> Command aliases: name → command. As in qutebrowser, :q closes the window, :qa quits, :w saves the session and :wq saves and quits
---@field auto_save rt.c.auto_save
---@field bindings rt.c.bindings
---@field changelog_after_upgrade "major"|"minor"|"patch"|"never" Open the changelog in a tab after an upgrade of at least this size: major, minor, patch or never
---@field colors rt.c.colors
---@field completion rt.c.completion
---@field confirm_quit string[] Ask before quitting: always, multiple-tabs (more than one tab open), downloads (downloads still running), or never
---@field content rt.c.content
---@field crash_report rt.c.crash_report
---@field downloads rt.c.downloads
---@field editor rt.c.editor
---@field extensions rt.c.extensions
---@field fileselect rt.c.fileselect
---@field fonts rt.c.fonts
---@field hints rt.c.hints
---@field input rt.c.input
---@field keyhint rt.c.keyhint
---@field messages rt.c.messages
---@field new_instance_open_target "tab"|"tab-bg"|"window" Where URLs from a second riptide invocation open
---@field new_instance_open_target_window "first-opened"|"last-opened"|"last-focused" Which window URLs from a second riptide invocation open in
---@field plugins rt.c.plugins
---@field prompt rt.c.prompt
---@field scrolling rt.c.scrolling
---@field search rt.c.search
---@field session rt.c.session
---@field spellcheck rt.c.spellcheck
---@field statusbar rt.c.statusbar
---@field tabs rt.c.tabs
---@field ui rt.c.ui
---@field url rt.c.url
---@field window rt.c.window
---@field zoom rt.c.zoom

---@class rt.c.auto_save
---@field interval integer Milliseconds between crash-recovery saves of the open tabs (0 turns them off)
---@field session boolean Save the open tabs as the 'default' session on quit, and restore them at startup

---@class rt.c.bindings
---@field key_mappings table<string, string> Keys treated as other keys in every mode, before bindings are looked up, e.g. Ctrl-[ as Escape

---@class rt.c.colors
---@field completion rt.c.colors.completion
---@field hints rt.c.colors.hints
---@field keyhint rt.c.colors.keyhint
---@field messages rt.c.colors.messages
---@field prompts rt.c.colors.prompts
---@field statusbar rt.c.colors.statusbar
---@field tabs rt.c.colors.tabs
---@field webpage rt.c.colors.webpage

---@class rt.c.colors.completion
---@field category rt.c.colors.completion.category
---@field description rt.c.colors.completion.description
---@field fg string Completion list text; empty uses ui.theme's
---@field item rt.c.colors.completion.item
---@field match rt.c.colors.completion.match
---@field odd rt.c.colors.completion.odd

---@class rt.c.colors.completion.category
---@field bg string Background of completion category headers; empty uses ui.theme's
---@field fg string Text of completion category headers; empty uses ui.theme's

---@class rt.c.colors.completion.description
---@field fg string Descriptions and details in the completion list; empty uses ui.theme's

---@class rt.c.colors.completion.item
---@field selected rt.c.colors.completion.item.selected

---@class rt.c.colors.completion.item.selected
---@field bg string Background of the selected completion; empty uses ui.theme's
---@field fg string Text of the selected completion; empty uses ui.theme's

---@class rt.c.colors.completion.match
---@field fg string The typed text where it appears in completion items; empty uses ui.theme's

---@class rt.c.colors.completion.odd
---@field bg string Completion list background; empty uses ui.theme's

---@class rt.c.colors.hints
---@field bg string Background of hint labels; empty uses ui.theme's
---@field border string Border of hint labels; empty uses ui.theme's
---@field fg string Text of hint labels; empty uses ui.theme's
---@field match rt.c.colors.hints.match

---@class rt.c.colors.hints.match
---@field fg string The typed part of hint labels; empty uses ui.theme's

---@class rt.c.colors.keyhint
---@field suffix rt.c.colors.keyhint.suffix

---@class rt.c.colors.keyhint.suffix
---@field fg string The keys still to type in the key hint popup; empty uses ui.theme's

---@class rt.c.colors.messages
---@field error rt.c.colors.messages.error
---@field warning rt.c.colors.messages.warning

---@class rt.c.colors.messages.error
---@field bg string Background of error messages; empty uses ui.theme's
---@field fg string Text of error messages; empty uses ui.theme's

---@class rt.c.colors.messages.warning
---@field bg string Background of warnings; empty uses ui.theme's
---@field fg string Text of warnings; empty uses ui.theme's

---@class rt.c.colors.prompts
---@field bg string Background of prompts; empty uses ui.theme's
---@field border string Frame, title and keys of floating prompts; empty uses ui.theme's
---@field fg string Text of prompts; empty uses ui.theme's
---@field key rt.c.colors.prompts.key

---@class rt.c.colors.prompts.key
---@field bg string Background of a floating prompt's keys; empty uses ui.theme's

---@class rt.c.colors.statusbar
---@field insert rt.c.colors.statusbar.insert
---@field normal rt.c.colors.statusbar.normal
---@field passthrough rt.c.colors.statusbar.passthrough
---@field private rt.c.colors.statusbar.private
---@field url rt.c.colors.statusbar.url

---@class rt.c.colors.statusbar.insert
---@field bg string Status bar background in insert mode; empty uses ui.theme's
---@field fg string Status bar text in insert mode; empty uses ui.theme's

---@class rt.c.colors.statusbar.normal
---@field bg string Status bar background; empty uses ui.theme's
---@field fg string Status bar text; empty uses ui.theme's

---@class rt.c.colors.statusbar.passthrough
---@field bg string Status bar background in passthrough mode; empty uses ui.theme's
---@field fg string Status bar text in passthrough mode; empty uses ui.theme's

---@class rt.c.colors.statusbar.private
---@field bg string Status bar background in private windows; empty uses ui.theme's
---@field fg string Status bar text in private windows; empty uses ui.theme's

---@class rt.c.colors.statusbar.url
---@field error rt.c.colors.statusbar.url.error
---@field success rt.c.colors.statusbar.url.success

---@class rt.c.colors.statusbar.url.error
---@field fg string The address of a page that failed to load; empty uses ui.theme's

---@class rt.c.colors.statusbar.url.success
---@field http rt.c.colors.statusbar.url.success.http
---@field https rt.c.colors.statusbar.url.success.https

---@class rt.c.colors.statusbar.url.success.http
---@field fg string An http:// address in the status bar; empty uses ui.theme's

---@class rt.c.colors.statusbar.url.success.https
---@field fg string An https:// address in the status bar; empty uses ui.theme's

---@class rt.c.colors.tabs
---@field bar rt.c.colors.tabs.bar
---@field even rt.c.colors.tabs.even
---@field indicator rt.c.colors.tabs.indicator
---@field odd rt.c.colors.tabs.odd
---@field pinned rt.c.colors.tabs.pinned
---@field selected rt.c.colors.tabs.selected

---@class rt.c.colors.tabs.bar
---@field bg string Tab bar background behind the tabs; empty uses ui.theme's

---@class rt.c.colors.tabs.even
---@field bg string Background of even-numbered tabs; empty uses ui.theme's

---@class rt.c.colors.tabs.indicator
---@field error string A tab's indicator when its page failed to load; empty uses ui.theme's
---@field start string A tab's loading indicator; empty uses ui.theme's

---@class rt.c.colors.tabs.odd
---@field bg string Background of odd-numbered tabs; empty uses ui.theme's
---@field fg string Text of tabs; empty uses ui.theme's

---@class rt.c.colors.tabs.pinned
---@field odd rt.c.colors.tabs.pinned.odd

---@class rt.c.colors.tabs.pinned.odd
---@field bg string Background of pinned tabs; empty uses ui.theme's
---@field fg string Text of pinned tabs; empty uses ui.theme's

---@class rt.c.colors.tabs.selected
---@field accent string Color of the line marking the current tab, any CSS color such as #2ec4b6; empty matches the tab, so no line shows
---@field odd rt.c.colors.tabs.selected.odd

---@class rt.c.colors.tabs.selected.odd
---@field bg string Background of the current tab; empty uses ui.theme's
---@field fg string Text of the current tab; empty uses ui.theme's

---@class rt.c.colors.webpage
---@field bg string Background of a new tab before its page paints, e.g. #1e1e2e so dark themes don't flash white; #rrggbb, white or black
---@field darkmode rt.c.colors.webpage.darkmode
---@field preferred_color_scheme "auto"|"light"|"dark" The color scheme pages see in prefers-color-scheme: auto follows the system

---@class rt.c.colors.webpage.darkmode
---@field enabled boolean Render light pages dark with Chromium's automatic dark mode; applies at once and can be set per site

---@class rt.c.completion
---@field cmd_history_max_items integer How many command lines Up and Down remember
---@field delay integer Milliseconds to wait after a key press before updating completions
---@field height string Height of the completion list: rows (12) or a percentage of the window (50%)
---@field min_chars integer Characters to type after a command before its arguments complete
---@field open_categories string[] What :open completes from, in order: searchengines, quickmarks, bookmarks, history, filesystem
---@field quick boolean When only one command or setting name is left, Tab takes it and moves on to completing the next part
---@field show "always"|"auto"|"never" When to show completions: always, only after pressing Tab (auto), or never
---@field shrink boolean Shrink the completion list to its items; false keeps it completion.height tall
---@field timestamp_format string strftime format of the last-visit time shown next to history completions; empty hides it
---@field use_best_match boolean Return runs the first command that starts with an unknown command name, so :rel runs :reload
---@field web_history rt.c.completion.web_history

---@class rt.c.completion.web_history
---@field exclude string[] URL globs (e.g. *://*.bank.example/*) that :open never suggests from history
---@field max_items integer How many history entries :open completion shows (0 turns history completion off)

---@class rt.c.content
---@field autoplay boolean Let videos play by themselves; false waits until you interact with the page (after a restart)
---@field blocking rt.c.content.blocking
---@field cache rt.c.content.cache
---@field call_mute_keys table<string, string> The key each call site mutes the microphone with, by URL pattern, for :call-mute (cm); keys as in :bind, e.g. <Ctrl-d>
---@field call_sites string[] Video call sites, as URL patterns, that open in a call window. There, sharing your screen lets you pick a tab, a window or the whole screen; in an ordinary tab it always shares the whole screen. Clear the list to open these sites as ordinary tabs; you then lose that choice unless you use :open --call or :tab-call
---@field canvas_reading boolean Let pages read back what they drew on a canvas; false blocks a common fingerprinting trick but breaks some sites (after a restart)
---@field cookies rt.c.content.cookies
---@field desktop_capture "ask"|"true"|"false" Let sites capture your screen or desktop audio: ask, true or false
---@field dns_prefetch boolean Look up the hosts of links before you follow them, which is faster but tells your DNS server about them
---@field geolocation "ask"|"true"|"false" Let sites know your location: ask, true or false
---@field headers rt.c.content.headers
---@field images boolean Load images; can be set per site
---@field javascript rt.c.content.javascript
---@field local_content_can_access_file_urls boolean Let file:// pages read other local files, which a downloaded page could misuse (after a restart)
---@field media rt.c.content.media
---@field mouse_lock "ask"|"true"|"false" Let sites lock your mouse pointer, as games do: ask, true or false
---@field mute boolean Mute pages; can be set per site
---@field notifications rt.c.content.notifications
---@field pdf_viewer boolean Show PDFs in the browser; false downloads them instead
---@field prefers_reduced_motion boolean Tell pages you prefer less motion, so they can tone down animations (after a restart)
---@field proxy string Proxy: system, none, a proxy URL such as socks5://127.0.0.1:9050, or pac+ and a PAC script's URL
---@field register_protocol_handler "ask"|"true"|"false" Let sites register to handle links like mailto: : ask, true or false
---@field tls rt.c.content.tls
---@field unknown_url_scheme_policy "ask"|"allow-all"|"disallow" Links to schemes the browser can't show (mailto:, magnet:, zoommtg:): ask before handing them to xdg-open, always hand them over, or never
---@field user_stylesheets string[] CSS files applied to pages (relative paths are in the config directory); reloaded when they change; can be set per site
---@field webgl boolean Allow WebGL, which 3D graphics need and fingerprinting scripts use (after a restart)
---@field webrtc_ip_handling_policy "all-interfaces"|"default-public-and-private-interfaces"|"default-public-interface-only"|"disable-non-proxied-udp" Which IP addresses WebRTC (video calls) may reveal; disable-non-proxied-udp keeps it behind content.proxy
---@field widevine boolean Allow Widevine DRM: Chromium downloads Google's CDM once (takes effect after a restart)

---@class rt.c.content.blocking
---@field adblock rt.c.content.blocking.adblock
---@field enabled boolean Block ads and trackers with the filter lists from content.blocking.adblock.lists
---@field whitelist string[] Sites where nothing is blocked, as host names; a host also covers its subdomains

---@class rt.c.content.blocking.adblock
---@field lists string[] Adblock Plus filter lists or hosts files that :adblock-update downloads (https://, or file:// for local lists); uBlock Origin's own lists may use its trusted scriptlets
---@field rules string[] Your own filter rules, in Adblock Plus syntax (e.g. example.com##.banner); they apply at once, without :adblock-update

---@class rt.c.content.cache
---@field size integer Disk cache size in bytes; 0 lets Chromium choose (takes effect after a restart)

---@class rt.c.content.cookies
---@field accept "all"|"no-3rdparty"|"no-unknown-3rdparty"|"never" Which cookies sites may set: all, none from other sites (no-3rdparty; no-unknown-3rdparty is the same here), or never
---@field store boolean Keep cookies after the browser closes; false makes every cookie last only for the session

---@class rt.c.content.headers
---@field accept_language string Languages sites are asked for, e.g. en-US,en;q=0.9 (also navigator.languages); empty for the system's
---@field custom table<string, string> Extra headers sent with every request: name → value
---@field do_not_track boolean Send DNT: 1 with every request, asking sites not to track you
---@field referer "always"|"never"|"same-domain" When to send the Referer header: always, never, or only within the same domain and its subdomains
---@field user_agent string User agent sent to sites and shown to their scripts; empty for Chromium's own. Can be set per site

---@class rt.c.content.javascript
---@field can_close_tabs boolean Let a page close its own tab with window.close(), as login popups do
---@field can_open_tabs_automatically boolean Let pages open tabs and windows without a click (popups); can be set per site
---@field clipboard "none"|"access"|"access-paste" What pages may do with the clipboard: nothing, copy with a click (access), or also read it (access-paste); can be set per site
---@field enabled boolean Run JavaScript on pages; can be set per site
---@field log_message rt.c.content.javascript.log_message

---@class rt.c.content.javascript.log_message
---@field levels string[] Console messages from pages shown in the status bar and :messages, by level: debug, info, warning, error (can be set per site)

---@class rt.c.content.media
---@field audio_capture "ask"|"true"|"false" Let sites use your microphone: ask, true or false
---@field video_capture "ask"|"true"|"false" Let sites use your camera: ask, true or false

---@class rt.c.content.notifications
---@field app_name string The app name on desktop notifications (presenter = libnotify), which notification services such as dunst and mako can match to style them
---@field enabled "ask"|"true"|"false" Let sites show notifications: ask, true or false
---@field presenter "auto"|"libnotify"|"messages" Where page notifications show: auto (Chromium's desktop notifications), libnotify (desktop notifications riptide sends with notify-send, following the other content.notifications settings) or messages (riptide's status bar)
---@field show_origin boolean Show the site a notification came from (presenter = libnotify or messages)
---@field site_icon boolean Show the site's icon on desktop notifications (presenter = libnotify)
---@field timeout integer Milliseconds a desktop notification stays (presenter = libnotify): -1 lets the desktop decide, 0 keeps it until dismissed
---@field urgency "low"|"normal"|"critical" How urgent desktop notifications are (presenter = libnotify): low, normal or critical

---@class rt.c.content.tls
---@field certificate_errors "ask"|"block"|"load-insecurely" Pages whose TLS certificate isn't trusted: ask, block, or load-insecurely

---@class rt.c.crash_report
---@field email string Where :crash-report's Email button sends a report; empty hides the button

---@class rt.c.downloads
---@field location rt.c.downloads.location
---@field open_dispatcher string Program that opens downloads (:download-open); {} is the file, or it's added at the end. Empty for the desktop's default
---@field remove_finished integer Take finished downloads off the list after this many milliseconds; -1 keeps them

---@class rt.c.downloads.location
---@field directory string Where downloads go; empty means the system Downloads folder
---@field prompt boolean Ask where to save each download (false saves straight to the directory)
---@field remember boolean Start the save prompt in the folder the last download went to
---@field suggestion "both"|"path"|"filename" What the save prompt starts with: the folder and file name (both), the folder (path), or the file name

---@class rt.c.editor
---@field command string[] Editor for :open-editor; fields: {file}, {line}, {column}, {line0}, {column0}
---@field remove_file boolean Delete the temporary file after the editor closes; false keeps it, e.g. to recover text

---@class rt.c.extensions
---@field load string[] Folders of unpacked Chrome extensions to load, besides the ones :extension-install installs (after a restart)

---@class rt.c.fileselect
---@field folder rt.c.fileselect.folder
---@field handler "default"|"external" File pickers for upload fields: Chromium's own (default), or the fileselect.*.command programs (external)
---@field multiple_files rt.c.fileselect.multiple_files
---@field single_file rt.c.fileselect.single_file

---@class rt.c.fileselect.folder
---@field command string[] Program that picks a folder for fileselect.handler = external; {} is the file it writes the path to

---@class rt.c.fileselect.multiple_files
---@field command string[] Program that picks several files for fileselect.handler = external; {} is the file it writes the paths to, one per line

---@class rt.c.fileselect.single_file
---@field command string[] Program that picks a file for fileselect.handler = external; {} is the file it writes the path to

---@class rt.c.fonts
---@field completion rt.c.fonts.completion
---@field default_family string Font family that the other fonts.* settings call default_family
---@field default_size string Font size that the other fonts.* settings call default_size, e.g. 10pt or 13px
---@field hints string Font of hint labels
---@field keyhint string Font of the key hint popup
---@field prompts string Font of prompts
---@field statusbar string Font of the status bar; sizes beyond the bar's height are cut off until bars size to their font
---@field tabs rt.c.fonts.tabs
---@field web rt.c.fonts.web

---@class rt.c.fonts.completion
---@field category string Font of completion category headers (default_size and default_family stand for those settings)
---@field entry string Font of completion entries

---@class rt.c.fonts.tabs
---@field selected string Font of the current tab
---@field unselected string Font of the other tabs

---@class rt.c.fonts.web
---@field family rt.c.fonts.web.family
---@field size rt.c.fonts.web.size

---@class rt.c.fonts.web.family
---@field fixed string Monospace font for pages (CSS monospace); empty for Chromium's
---@field sans_serif string Sans-serif font for pages; empty for Chromium's
---@field serif string Serif font for pages; empty for Chromium's
---@field standard string Font for pages that don't choose one; empty for Chromium's

---@class rt.c.fonts.web.size
---@field default integer Default text size of pages, in pixels
---@field default_fixed integer Default size of monospace text in pages, in pixels
---@field minimum integer Smallest text size pages may use, in pixels (0 for no minimum)

---@class rt.c.hints
---@field auto_follow "always"|"unique-match"|"full-match"|"never" When a hint is followed without Return: when one is left (unique-match), only when its label is typed in full (full-match), always, or never
---@field auto_follow_timeout integer Ignore keys for this many milliseconds after following a hint, so extra typing doesn't reach the page
---@field chars string Characters used for hint labels
---@field dictionary string Word list for hints.mode = word, one word per line
---@field hide_unmatched_rapid_hints boolean In rapid hint mode (:hint --rapid), hide the labels that don't match what's typed
---@field leave_on_load boolean Leave hint mode when the page starts loading something new
---@field min_chars integer The shortest hint label, in characters
---@field mode "letter"|"number"|"word" letter: labels from hints.chars; number: numbered labels, and typing letters filters by text; word: dictionary words from each link's text
---@field next_regexes string[] Link texts ]] follows to the next page, as JavaScript regular expressions (case doesn't matter)
---@field padding string Space around a hint label's text, as CSS padding, e.g. 1px 4px
---@field prev_regexes string[] Link texts [[ follows to the previous page, as JavaScript regular expressions (case doesn't matter)
---@field radius integer Corner radius of hint labels in pixels; 0 is square
---@field scatter boolean Spread hint labels over the alphabet so neighbours differ; false labels in order
---@field selectors table<string, string> Hint groups for :hint, as CSS selector lists; your entries are added to the built-in all, links, images, media and inputs
---@field uppercase boolean Show hint labels in upper case

---@class rt.c.input
---@field forward_unbound_keys "all"|"auto"|"none" Pass unbound keys to the page in normal mode (auto: all but plain letters and digits)
---@field insert_mode rt.c.input.insert_mode
---@field match_counts boolean Read digits typed before a binding as a count (3j); false lets digits be bindings themselves
---@field media_keys boolean Let the keyboard's media keys (play, pause, next) control audio and video in pages (after a restart)
---@field mode_override "none"|"normal"|"insert"|"passthrough" Mode to enter when a page loads or its tab is focused; set it per site, e.g. passthrough for a web terminal
---@field mouse rt.c.input.mouse
---@field partial_timeout integer Milliseconds before a half-typed key chain or count is forgotten; 0 waits forever
---@field spatial_navigation boolean Move focus between links and fields with the arrow keys, as on a TV (after a restart)

---@class rt.c.input.insert_mode
---@field auto_enter boolean Enter insert mode when an editable element gets focus
---@field auto_leave boolean Leave insert mode when focus leaves an editable element
---@field auto_load boolean Enter insert mode when a page focuses a text field by itself, as autofocus does on load
---@field leave_on_load boolean Leave insert mode when a new page starts loading

---@class rt.c.input.mouse
---@field rocker_gestures boolean Hold the right button and click the left to go back, or the other way round to go forward; turns off the page's context menu

---@class rt.c.keyhint
---@field blacklist string[] Key chains the key hint popup leaves out, as globs on the whole chain (e.g. g* for every chain starting with g)
---@field delay integer How long after a partial key chain the popup listing its continuations appears, in milliseconds

---@class rt.c.messages
---@field timeout integer Milliseconds before a status bar message clears (0 keeps it)

---@class rt.c.plugins
---@field catalog string The git repository of plugins the Plugins tab's Browse lists, one plugin per folder
---@field check_interval integer Every this many days, check plugins from git for new commits in the background and say once which have updates; nothing updates by itself (0: never)

---@class rt.c.prompt
---@field position "bottom"|"center"|"docked" Where questions (permissions, logins, downloads, page dialogs) appear: bottom, a box floating near the bottom of the page; center, the same box in the middle; or docked above the status bar
---@field width integer Width in pixels of a floating prompt (prompt.position = bottom or center), at most the page's

---@class rt.c.scrolling
---@field bar "always"|"never"|"overlay" Page scrollbars: always, never, or overlay (thin, shown while scrolling; after a restart)
---@field smooth boolean Animate scrolling by keys instead of jumping

---@class rt.c.search
---@field ignore_case "smart"|"always"|"never" Case in searches: smart ignores it unless the text has a capital, always, or never
---@field incremental boolean Search while typing after / or ?
---@field wrap boolean Go on from the top when a search passes the last match (or from the bottom, searching up)
---@field wrap_messages boolean Say when a search wraps around the page

---@class rt.c.session
---@field default_name string Session that :session-save, :wq and auto_save.session use; empty means the last one loaded, or default
---@field lazy_restore boolean When restoring a session, load background tabs only when they are first shown

---@class rt.c.spellcheck
---@field languages string[] Spell-check languages such as en-US (empty: off); Chromium downloads each dictionary from Google once

---@class rt.c.statusbar
---@field padding string Space around the status bar's text, as CSS padding (top right bottom left), e.g. 2px 8px; the bar grows to fit
---@field position "top"|"bottom" Where the status bar is
---@field show "always"|"never"|"in-mode" When to show the status bar: always, only while typing a command or answering a prompt (never), or also outside normal mode and while a message is shown (in-mode)
---@field widgets string[] What the right side of the status bar shows, in order: keypress, downloads, blocked (requests the ad blocker stopped on the page), muted, media, sharing (a screen, window or tab being shared from any tab; :share-stop stops it), zoom, search_match, url, scroll, scroll_raw, history, tabs, progress, clock[:strftime format], text:…, lua:<name> (drawn by rt.statusbar.widget)

---@class rt.c.tabs
---@field close_mouse_button "middle"|"right"|"none" Which mouse button closes a tab clicked in the tab bar
---@field close_mouse_button_on_bar "new-tab"|"close-current"|"close-last"|"ignore" What tabs.close_mouse_button does on the empty part of the tab bar
---@field favicons rt.c.tabs.favicons
---@field indicator rt.c.tabs.indicator
---@field last_close "ignore"|"blank"|"startpage"|"default-page"|"close" What closing the last tab does
---@field max_width integer Largest width in pixels of a tab in a top or bottom tab bar (-1 for no limit)
---@field min_width integer Smallest width in pixels of a tab in a top or bottom tab bar; tabs that don't fit scroll (-1 for no minimum)
---@field mode_on_change "normal"|"persist"|"restore" Mode after switching tabs: normal, persist (keep insert/passthrough), or restore (the mode the tab was left in)
---@field mousewheel_switching boolean Switch tabs with the mouse wheel over the tab bar
---@field new_position rt.c.tabs.new_position
---@field padding string Space around each tab's title, as CSS padding (top right bottom left); the tab bar grows to fit
---@field pinned rt.c.tabs.pinned
---@field position "top"|"bottom"|"left"|"right" Where the tab bar is; left and right list the tabs vertically
---@field select_on_remove "next"|"prev"|"last-used" Which tab to show after closing the current one: the next, the previous, or the one used before
---@field show "always"|"never"|"multiple"|"switching" When to show the tab bar: always, never, with more than one tab, or briefly after switching tabs
---@field show_switching_delay integer How long the tab bar stays after switching tabs with tabs.show = switching, in milliseconds
---@field tabs_are_windows boolean Open every tab in its own window and hide the tab bar, for tiling window managers
---@field title rt.c.tabs.title
---@field tooltips boolean Show a tab's title and URL when the mouse rests on it
---@field undo_stack_size integer How many closed tabs u can reopen; 0 keeps none
---@field width integer Width of the tab bar in pixels when tabs.position is left or right
---@field wrap boolean Wrap around from the last tab to the first (and back) when switching tabs

---@class rt.c.tabs.favicons
---@field show "always"|"never"|"pinned" Show site icons in the tab bar: always, never, or only on pinned tabs

---@class rt.c.tabs.indicator
---@field width integer Width in pixels of the loading indicator at the left of each tab (0 hides it)

---@class rt.c.tabs.new_position
---@field related "prev"|"next"|"first"|"last" Where tabs opened from a page go (popups, hints)
---@field unrelated "prev"|"next"|"first"|"last" Where other new tabs go (:open -t)

---@class rt.c.tabs.pinned
---@field close "ask"|"refuse"|"close" Closing a pinned tab without --force: ask first, refuse, or just close it
---@field frozen boolean Keep pinned tabs on their page: :open in a pinned tab opens a new tab
---@field shrink boolean Shrink pinned tabs to their icon and number

---@class rt.c.tabs.title
---@field alignment "left"|"center"|"right" Where tab titles sit in their tab: left, center or right
---@field format string Tab titles; fields: {index}, {aligned_index}, {current_title}, {current_url}, {host}, {perc}, {audio}, {media} ([A/V] while the page uses a camera or the screen, and a microphone), {private}
---@field format_pinned string Titles of pinned tabs while tabs.pinned.shrink shrinks them; same fields as tabs.title.format

---@class rt.c.ui
---@field auto_theme rt.c.ui.auto_theme
---@field overlay rt.c.ui.overlay
---@field theme string Colors of riptide's bars, prompts and hints: riptide, riptide-light, gruvbox, catppuccin, nord, dracula, solarized, tokyo-night or a theme from themes/ in the config directory (:theme); auto follows the light or dark preference

---@class rt.c.ui.auto_theme
---@field dark string The theme ui.theme = auto uses when the desktop (or colors.webpage.preferred_color_scheme) prefers dark
---@field light string The theme ui.theme = auto uses when light is preferred

---@class rt.c.ui.overlay
---@field position "docked"|"floating" Where the command line's completions and the key hints appear: docked above the status bar, or floating, a box near the top of the page that also shows the command
---@field width integer Width in pixels of the floating overlay (ui.overlay.position = floating), at most the page's

---@class rt.c.url
---@field auto_search "naive"|"schemeless"|"never" When :open searches: text that doesn't look like an address (naive), anything without a scheme:// (schemeless), or never
---@field default_page string Page for :open without a URL
---@field incdec_segments string[] Parts of the URL Ctrl-a and Ctrl-x change: host, port, path, query, anchor
---@field open_base_url boolean Open a search engine's home page when :open gets just its name
---@field searchengines table<string, string> Search engines; ':open g rust' uses the 'g' entry, anything else DEFAULT
---@field start_pages string[] Pages opened at startup when no URL is given
---@field yank_ignored_parameters string[] Query parameters dropped when yanking a URL, such as tracking tags

---@class rt.c.window
---@field hide_decoration boolean Ask the window manager for no title bar or borders (applies to new windows)
---@field title_format string Window title; fields: {current_title}, {title_sep}, {current_url}, {host}, {mode}

---@class rt.c.zoom
---@field default integer Zoom in percent for pages, and what :zoom without a value resets to
---@field levels string[] The zoom levels + and - step through, in percent

---@type rt.c
c = {}

Changelog

All notable changes to riptide. Generated by git-cliff from Conventional Commits.

0.3.0 - 2026-10-08

Features

  • settings: :settings opens a page to browse, change and reset every setting
  • tabs: Mark tabs using the camera, microphone or screen
  • settings: A clearer settings page with sections, a colors preview and checks while typing
  • settings: URLs & search second on the settings page
  • sessions: :recover lists each crash's tabs to reopen some or all
  • notifications: Libnotify presenter with app name, urgency, timeout and site icon
  • settings: A Keys tab to bind, unbind and restore keys, with clash warnings
  • settings: A Sites tab to forget saved permission answers and clear site data
  • windows: Well-known call sites open in call windows by default
  • settings: Edit per-site values on the page, and j/k/Return to move and edit
  • windows: Say when a call site opened in a call window
  • prompts: Questions belong to their tab, and permission requests show the site and what it wants
  • completion: Site icons in :open and tab completion
  • adblock: The status bar counts requests blocked on the page
  • adblock: Hosts files work as block lists
  • completion: Letters with gaps match when nothing contains the text
  • spell: :spell-install downloads dictionaries checked against pinned checksums
  • tabs: Cm mutes the call from any tab with the site's own key
  • spell: :spell-install alone lists the languages to pick from
  • tabs: Gp floats the page's video in picture-in-picture
  • Chromium's crash dumps are kept locally and listed in :crash-report
  • lua: Rt.on with patterns, groups and once, and eleven more events
  • lua: Rt.defer and rt.every timers, rt.notify levels, and a time limit per callback
  • lua: Rt.keymap with descriptions, and commands that complete their arguments
  • lua: Rt.store keeps data between runs
  • lua: Sandboxed plugins from a folder, loaded once you approve their permissions
  • tabs: Screen shares are marked, and :share-stop ends them from any tab
  • lua: Plugins install from git and stay pinned in rt-pack-lock.json
  • lua: A Plugins tab with update review, revoke and remove
  • lua: Plugins can wait for an event, command or key to load
  • lua: Rt.statusbar.widget draws lua:<name> widgets
  • lua: Rt.ui.select and rt.ui.input, rt.page to type and fill logins, and rt.json
  • lua: Rt.ui.float draws boxes of text over the page
  • lua: Builtin plugins, starting with passwords to fill logins from pass, rbw, bw or KeePassXC
  • lua: Rt.ui.panel docks lines beside or below the page
  • lua: Plugins ship HTML pages that talk only to their own plugin
  • Chrome extensions, installed from the Web Store with :extension-install
  • lua: Plugin pages can dock in a panel beside the page
  • lua: Rt.page.eval, rt.page.css and rt.page.selection
  • lua: Rt.page.hint hints a selector's elements and hands the pick to Lua
  • An Extensions tab in :settings, :extension-open and extension updates
  • help: :help rt.ui.float and :help <plugin>
  • Install extensions straight from the Web Store page
  • lua: Riptide --plugin-test runs a plugin's specs, and a plugin template
  • lua: Subdir installs one plugin from a repository of several
  • Extension popups float over the page, as in Chrome
  • Drag an extension's popup by its bar
  • lua: Plugins declare dependencies, which install and load first
  • lua: One checkout per repository, and check, update, sync, restore and clean
  • adblock: Scriptlets, $redirect stand-ins and $removeparam
  • lua: Riptide checks plugins for updates in the background
  • adblock: UBlock Origin's lists by default, with its trusted scriptlets
  • adblock: Procedural element hiding (:has-text, :upward, :remove and more)
  • adblock: Element hiding inside frames
  • lua: Check plugins for updates daily, mentioning each update once
  • lua: Add plugins from the Plugins tab or :pack-add, kept in plugins.toml
  • adblock: Your own rules, and an element picker that writes them (;x)

Bug fixes

  • window: Windows can be resized, so tiling window managers tile them
  • cmdline: :q closes the current window and :qa quits, as in qutebrowser
  • tabs: Middle-click and Ctrl+click open links in a background tab
  • ui: Ctrl+scroll no longer zooms riptide's own bars
  • lua: A plugin whose folder is missing shows as Failed on the Plugins tab
  • Say plainly what works when an extension has a popup
  • lua: Commands added after loading run in the same callback
  • extensions: Build on Windows, and wait for the limits link in the e2e test
  • lua: Plugin names and tests on Windows

0.2.0 - 2026-10-07

Features

  • tabs: Pinned tabs stay where they are and can sit between others
  • tabs: Gt lists this window's tabs by number alone
  • tabs: A dragged tab shrinks and stays centred on the pointer
  • content: Proxy, WebRTC IP policy, DNS prefetch, canvas reading, cache size
  • content: Autoplay, WebGL, reduced motion, PDF viewer, window.close and console messages
  • release: Ship a .deb with each release
  • cmdline: Ctrl-v and Shift-Insert paste into the command line and prompts
  • input: Partial_timeout, match_counts and mode_override
  • :report, :debug-keytester, :debug-log-filter, editor.remove_file, new_instance_open_target_window
  • Scrolling.bar and tabs.close_mouse_button_on_bar
  • content: Mailto: and other external links, mouse lock, protocol handlers, file access
  • downloads: Prompt-open-download (Ctrl-x) and prompt-yank (Alt-y)
  • input: Rocker gestures, spatial navigation and media keys
  • config: Say when a changed setting only applies after a restart
  • content: Notifications in the status bar, and keep tests off the desktop bus
  • tabs: Tabs.tabs_are_windows
  • cmdline: Cmd-repeat, cmd-repeat-last and cmd-run-with-count
  • hints: Hints inside cross-origin iframes and open shadow roots
  • tabs: A tab whose renderer crashed says so, and r reloads it
  • prompts: Float prompts in a box near the bottom of the page
  • A panic writes a crash report, and the next start says where
  • prompts: Amber floating prompts with a button per answer
  • theme: Themes and colors.* settings for riptide's bars, prompts and hints
  • theme: Fonts for riptide's bars, prompts and hints, and for pages
  • theme: Ui.css and content.user_stylesheets, reloaded on change
  • theme: Colors.webpage.bg, and dark mode that applies at once and per site
  • sessions: Restored tabs keep their back/forward history and scroll position
  • completion: Commands and settings match anywhere in their name, with the match highlighted
  • theme: Bars and completion rows grow to fit their fonts and padding
  • theme: A floating command line, ui.overlay.position and ui.overlay.width
  • hints: Hints.radius, hints.padding and a hints.css file shape the labels
  • prompts: Prompt.position center, and a CSS class for what each prompt is about
  • theme: Ui.theme = auto follows the light or dark preference
  • theme: :theme previews each theme while it's typed or picked
  • theme: Your own themes from themes/<name>.toml in the config directory
  • window: Riptide's logo as the window icon
  • theme: The downloads and history pages take the theme's colors
  • windows: Call windows, where screen sharing picks a tab, window or screen
  • theme: Import base16 schemes and qutebrowser theme files from themes/
  • windows: :tab-call and content.call_sites send calls to call windows
  • lua: Rt.theme defines a theme in config.lua
  • :crash-report shows the last crash report, to send as a GitHub issue or by email

Bug fixes

  • tabs: Place a dragged tab by the pointer, not its centre
  • sessions: Keep each crash's tabs, and don't reopen tabs that crash again
  • permissions: Alt-y in a permission prompt copies the site's origin
  • notifications: Name riptide's desktop file, not Chromium's
  • sessions: Crash recovery messages stay until the tabs have loaded
  • theme: Readable text in every built-in theme
  • theme: The current tab is always the darkest tab
  • sessions: Ctrl-C and a closed terminal keep the tabs for crash recovery
  • window: Give riptide's windows a WM_CLASS
  • windows: Keep ordinary windows' focus as before call windows
  • theme: In light themes the current tab is the brightest tab

0.1.0 - 2026-10-06

Features

  • Scaffold CEF-based vim-like browser (milestone 0)
  • Add ./task wrapper that bootstraps Task
  • Add tabs (milestone 3)
  • Add hints and yank/paste (milestone 4)
  • Add configuration: TOML, Lua and :set/:bind (milestone 5)
  • Add history, quickmarks, bookmarks and sessions (milestone 6)
  • Add prompts, downloads and permissions (milestone 7)
  • ui: Serve UI pages from hb:// with a UI-only message channel
  • tabs: Pinned tabs, tab bar mouse support and favicons
  • help: Generated help page, :help, :version and F1
  • remote: Hand arguments to a running browser from the terminal
  • ui: Add window.title_format
  • sandbox: Run Chromium's sandbox whenever Linux allows it
  • privacy: Stop background calls that only serve Google
  • adblock: Block ads and trackers with Adblock Plus filter lists
  • help: Add :changelog and an "Updated to" notice
  • spell: Spell checking with keyboard-driven suggestions
  • spawn: Add :spawn, qutebrowser-style userscripts and :open-editor
  • marks: Set and jump to marks with ` and '
  • macros: Record keys with q and replay them with @
  • caret: Add caret mode for keyboard text selection
  • greasemonkey: Run Greasemonkey scripts in matching pages
  • search: Find text in the page with /, ?, n and N
  • navigate: Go up the URL, to next/prev pages, or change its number
  • ui: Add dark mode and preferred color scheme settings
  • config: Per-site settings, and save 'always' permission answers
  • tabs: Restore each tab's mode when switching back
  • tls: Ask before loading pages with untrusted certificates
  • widevine: Opt-in Widevine DRM with content.widevine
  • windows: Multiple windows and private windows
  • lua: Script the browser from config.lua
  • clipboard: Yank to and open from the primary selection
  • tabs: Pick a tab in any window with :tab-select (T)
  • session: Crash-recovery autosave and a history page
  • history: Import qutebrowser's history with :history-import
  • tabs: Clone tabs and move them between windows
  • help: List commands from config.lua on the help page
  • hints: Hint elements inside same-origin iframes
  • hints: Number hints that filter by text
  • downloads: Tab path completion and a :downloads page
  • packaging: Build an AppImage next to the tarball
  • ui: Stack messages above the status bar
  • adblock: Hide ads with the lists' element-hiding rules
  • adblock: Re-check element hiding for ads that load late
  • spawn: Show output in a tab with :spawn -o, and Y in caret mode
  • hints: Run programs and userscripts on hinted links
  • search: Follow a search match's link with Return
  • lua: Add hb.tabs() for the current window's tabs
  • Zoom, devtools, print, fullscreen, view-source, jseval and more
  • Scroll-px, cmd-later, fake-key, click-element and more commands
  • breaking: Rename hackers-browser to riptide
  • tabs: Vertical tabs with tabs.position, and tabs.show
  • statusbar: Statusbar.position and statusbar.show
  • statusbar: Statusbar.widgets
  • keys: A key hint popup for partial key chains
  • completion: Delete and yank the selected completion
  • editor: Edit-url, cmd-edit and edit-text, and clean yanked URLs
  • hints: Hints.auto_follow, hint-follow and hints.selectors
  • input: Bindings.key_mappings and input.insert_mode.auto_load
  • sessions: Session.lazy_restore and confirm_quit
  • screenshot: Save the tab as an image with :screenshot
  • content: JavaScript, cookie and user agent settings
  • downloads: Retry, remove and delete downloads; external file picker
  • greasemonkey: @require and GM_setValue/getValue/deleteValue/listValues
  • prompts: Alt-e picks a folder for the download prompt
  • userscripts: Run qutebrowser's bundled userscripts unchanged
  • config: Config-list/dict add and remove, config-diff, config-clear, config-edit, config-write-toml
  • tabs: Tab title format, select_on_remove, wrap, undo stack size, tooltips
  • search: Search.wrap and wrap messages; smooth scrolling; zoom.levels
  • keys: Readline word commands, rl-yank, caret block moves, selection-drop
  • tools: Restart, bookmark-list, mark reloads, debug-dump-page, debug-clear-ssl-errors, devtools-focus
  • userscripts: breaking: Name the userscript variables RIPTIDE_*
  • completion: Completion.show, height, open_categories and more
  • urls: Url.auto_search, url.open_base_url and url.incdec_segments
  • hints: Hints.min_chars, scatter, leave_on_load and next/prev regexes
  • downloads: Location.suggestion and remember, open_dispatcher, remove_finished
  • content: Images, mute, popups, clipboard and protocol handler settings
  • tabs: Mark the current tab by making it darker, with an accent line
  • tabs: Title alignment, min and max tab width, indicator width, close button
  • completion: Shrink, timestamp_format, delay, quick and use_best_match
  • Update CEF to 154.0.33 (Chromium 154.0.8037.94)
  • hints: Word hints from hints.dictionary, and hints.hide_unmatched_rapid_hints
  • tabs: No accent line on the current tab by default; colors.tabs.selected.accent sets one
  • sessions: Session.default_name and :save
  • window: Window.hide_decoration and changelog_after_upgrade
  • greasemonkey: GM_xmlhttpRequest and GM_openInTab
  • lua: Rt.spawn runs programs and calls back with their output
  • content: Accept_language, do_not_track, referer and custom headers
  • tabs: Gt tab picker, pinned-tab prompt, live drag, gD, :set values
  • tabs: Gt tab picker, pinned-tab prompt, live drag, gD, :set values

Bug fixes

  • Build on Windows, where CEF event flags are i32
  • tabs: Focus a new tab again once its browser exists
  • keys: Send keys that land in a UI view on to the current tab
  • remote: Build valid file URLs from Windows paths
  • ui: Leave the port and user info out of {host}
  • permissions: Make per-site settings override Chromium's saved answers
  • tabs: U reopens a tab closed before its page loaded
  • content: Private windows crashed after content settings applied

Building

Requirements: Rust 1.88+ (edition 2024). The smoke test also needs Xvfb and xdotool.

Tasks run through Task. The ./task wrapper uses your installed task if there is one. Otherwise it downloads a pinned, checksum-verified release into .bin/. Arguments pass straight through.

./task setup          # once: download CEF (~1.5 GB) into $CEF_PATH, default ~/.local/share/cef
./task run            # build and launch; or: ./task run -- example.com
./task                # list all tasks

./task setup reads the pinned cef crate version from Cargo.lock and fetches the matching CEF build. It skips the download when that version is already installed, and build, run and lint run it automatically. Set CEF_PATH to keep the binaries somewhere else.

The build copies libcef.so and Chromium's resources next to the binary. The binary finds them through an $ORIGIN rpath.

Logging goes to stderr and uses the RT_LOG filter, e.g. RT_LOG=rt_cef=trace ./task run. Browser data and cef.log live in ~/.local/share/riptide/.

Without Task

git clone --depth 1 --branch cef-v154.4.0+154.0.33 https://github.com/tauri-apps/cef-rs /tmp/cef-rs
(cd /tmp/cef-rs && cargo run -p export-cef-dir -- --force "$HOME/.local/share/cef")
export CEF_PATH="$HOME/.local/share/cef"
cargo build && ./target/debug/riptide

Without CEF_PATH, the cef-dll-sys build script downloads the binaries into target/ instead.

Documentation

./task docs builds this book into docs/book/book/, and ./task docs-serve serves it at http://localhost:3000 with live reload. Both use scripts/mdbook.sh, which downloads a pinned, checksum-verified mdBook into .bin/ if the right version isn't installed. See Writing documentation.

Architecture

Riptide is one Rust workspace around CEF's prebuilt Chromium. The project plan records the reasoning behind each decision and the lessons learned per milestone; this page is the map.

Crates

CratePurpose
crates/riptideThe riptide binary: main(), which hands subprocesses to CEF and starts the browser.
crates/rt-coreModes, key parsing, bindings, commands, the command line, settings, URL guessing and the help data. No CEF dependency; unit tested.
crates/rt-configConfig paths per platform, the command line, TOML/Lua/autoconfig loading, the single-instance socket protocol, and the generated Lua types, settings docs and reference pages.
crates/rt-storageHistory (SQLite), quickmarks and bookmarks (qutebrowser formats), sessions (TOML).
crates/rt-adblockAd and tracker blocking with Adblock Plus filter lists (Brave's adblock-rust). resources/ubo.json holds uBlock Origin's scriptlets and $redirect stand-ins; refresh it with scripts/update-adblock-resources.sh (needs Node) when uBlock Origin releases.
crates/rt-cefCEF integration: window layout, handlers, renderer-process bindings, the riptide:// pages and the status bar and completion UI.

The rule that keeps this testable: rt-core never depends on CEF. CEF is an adapter that turns events into rt-core inputs and carries out the actions rt-core returns.

The core loop

Key event (OnPreKeyEvent, before the page sees it)
  → rt_core::Engine: mode, counts, multi-key sequences (gg, ;y)
  → a Command (:scroll down, :open -t …)
  → handled inside the engine (modes, command line), or
    returned as Effect::Run { command, count } for rt-cef to carry out on a tab, window, UI or storage

Engine takes a Key and returns KeyOutcome { consumed, effects }, so every binding and mode transition is unit tested without a browser.

Processes

There is a single executable. CEF launches its renderer, GPU and utility processes by running it again, and main() dispatches them through cef::execute_process.

  • Browser process: windows, tabs, modes, commands, config and storage.
  • Renderer processes: a Rust RenderProcessHandler that runs our page scripts (crates/rt-cef/js/: hints, scrolling, caret mode, navigation, spell checking, the editor) and reports results back through process messages. Pages can't forge those replies, and no global function is exposed to page scripts.

The window

Each window uses CEF Views in the Alloy runtime style. Every tab is its own BrowserView, and only the current one is visible. The tab bar, status bar, completion, prompts and messages are HTML pages (crates/rt-cef/ui/) served by the browser itself at riptide://ui/…, in their own views. Only riptide://ui/ pages get the rt.send() channel to Rust, and the browser accepts only the messages each page is allowed to send. Web pages can't link to, frame or redirect to riptide:// addresses.

Where to look

  • A new command: crates/rt-core/src/command.rs (COMMANDS and the parser), then its Effect handling in crates/rt-cef/src/shell.rs.
  • A new setting: crates/rt-core/src/settings.rs. Types, docs and :help pick it up from the registry.
  • A default binding: crates/rt-core/src/keymap.rs.
  • Page-side behaviour: crates/rt-cef/js/ and its Rust side in crates/rt-cef/src/.

CEF pitfalls

Things CEF, Chromium and the cef crate do that cost time to find out. Add to this page when you hit one, next to the area it belongs to.

Views and windows

  • A preferred size with a 0 width or height counts as unset. The view then takes its large default and squeezes the page out of the layout. Always return a non-empty size (window::bar_size).
  • Dropping the last reference to a BrowserView closes its browser synchronously, which re-enters our handlers. The shell's state is a UI-thread RefCell, so never drop CEF objects, or call CEF methods that fire callbacks, while it's borrowed (shell::with).
  • The cef crate answers 0 for can_resize, can_maximize and can_minimize unless the delegate implements them, where CEF's own default is 1. Windows then ask for a fixed size and tiling window managers float them. Both window delegates return 1.
  • A Chrome-style BrowserView doesn't exist until it's added to a window, so calling set_focusable on it first crashes. A Chrome-style window takes one Chrome-style view, which must be added before any Alloy view (the first view sets the window's profile). Popups must match their opener's style. See window::create_call and tabs::add_call_view.
  • Overlay views are opaque and only as big as their bounds. Rounded corners show square ones behind them, and there's no dimming the page behind a box.
  • A hidden view pauses requestAnimationFrame. A page that measures itself before it's shown must do it synchronously (ui/float.html).
  • A window doesn't finish closing while any browser in it is open, overlay views included. Floats and panels are closed in the window's can_close (float::close_window, panel::close_window), or :quit hangs.

Keys and input

  • The command line is a Rust-owned buffer; keys are consumed in OnPreKeyEvent, so UI pages never need keyboard focus.
  • Chromium ignores input to a page while it shows a JavaScript dialog, so prompt keys go through the status bar's browser, which gets focus for the duration.
  • A new tab can miss focus requested before its browser existed; on_after_created focuses it again.
  • send_key_event to a hidden tab is dropped (keys go to the window's focused view), and DevTools' Input.dispatchKeyEvent reports success without reaching the page in windowed browsers. The first key a page ever gets starts something that drops keys for about 400 ms (client::send_when_ready).
  • Under Xvfb, a key sent just as a page finishes loading can be lost below Views. The e2e harness waits two animation frames after each load.
  • Middle-clicks and Ctrl+clicks come through OnOpenURLFromTab, not popups, and Alloy loads them in the same tab if it isn't handled.
  • Ctrl+wheel over a bar zooms every riptide://ui page (Chromium saves zoom per host). The bar pages refuse it, and on_load_end resets a saved zoom for non-tab roles.

Pages and navigation

  • Loading a data: error page from OnLoadError adds a history entry, so back loops into the error. Draw into Chromium's own error document from OnLoadEnd instead.
  • BrowserHost::Find with find_next = false never activates or scrolls to a match. Every call passes true; a repeated search first calls StopFinding.
  • CEF passes a browser's original extra_info to on_browser_created again on reload, which would undo a :greasemonkey-reload. Script lists carry a generation number.
  • Page.addScriptToEvaluateOnNewDocument silently does nothing without Page.enable first (adblock::before_navigation).
  • Notification is defined after the context is created, so the renderer's stand-in takes its place on the first microtask, DOMContentLoaded and load.
  • A page starting to load clears every status message, so startup messages wait for the first load (shell::show_message_after_load).
  • A stored default for Chromium's PROTOCOL_HANDLERS content setting fails a CHECK when a private window's profile inherits it.

Profiles, preferences and services

  • Chromium names its profile folder Default whatever cache_path says, so cache_path points there.
  • Services start within 100 ms, so privacy preferences are written into Local State and Default/Preferences before CEF starts. SetPreference from on_context_initialized is too late, and through the cef crate it fails silently unless the error out-string is non-empty (an empty CefString is passed as NULL).
  • Feature names in libcef.so strings carry a k prefix that Chromium strips (kAimEnabled → AimEnabled).
  • Chrome's login prompt swallows HTTP auth unless --disable-chrome-login-prompt is set (cef#3603).
  • GetAuthCredentials runs on the IO thread; the prompt is posted to the UI thread.
  • Chromium saves permission answers per site, but camera and microphone requests go through a separate API that isn't saved; riptide keeps its own per-site answers. Chromium also holds non-media permission prompts from hidden tabs until they show.

The cef crate

  • CefStringList::clone copies the opaque C struct, so iterating a clone is always empty; read lists through the C API.
  • An empty CefString is passed to C as NULL.

Extensions

  • Manifest V2 is gone in Chromium 154: MV2 extensions silently don't load.
  • Alloy tabs are invisible to chrome.tabs.query, though messages reach them (sender.tab is set). Popups and keyboard commands that act on "the active tab" can't find riptide's tabs.
  • With a declarativeNetRequest extension, a page opened during startup may never start loading; extensions::after_startup reloads such tabs.
  • Letting Chromium finish a .crx download hands it to its own installer, which deletes the file. riptide cancels the download and fetches the URL itself.

Crashes

  • chrome://crash with the sandbox on leaves the tab loading instead of crashing it; the test channel's CrashTab aborts the renderer instead.
  • Crashpad reads crash_reporter.cfg next to the executable, and keeps dumps with no size or age limit on Linux (rt_storage::crash_reports::dumps prunes them).
  • A panic that reaches CEF's C callers panics again ("cannot unwind"); only the first panic on a thread is reported.

Building and releasing

  • CEF's libcef.so carries debug info (1.4 GB); stripping it gives 260 MB.
  • Inside an AppImage, chrome-sandbox can't be setuid, so the sandbox needs user namespaces there.
  • A tag pushed with a workflow's own token doesn't trigger other workflows, and GitHub Actions can't bypass a ruleset on a personal repository; see Releasing.
  • mdBook's smart punctuation turns --force into an en dash, so it's off.

Testing

  • Unix socket paths must fit in about 108 bytes, so the e2e harness uses a short XDG_RUNTIME_DIR.
  • CEF's zygote leaves the process group; the harness also stops the helpers that carry its --user-data-dir.
  • cargo test -p rt-e2e drives the built target/debug/riptide and doesn't rebuild it: run cargo build first, or ./task e2e.

Testing

CommandWhat it runs
./task testUnit tests for rt-core, rt-config, rt-storage, rt-adblock and rt-cef (modes, keys, commands, settings, config files, paths for all three platforms, history, marks, sessions, and the CEF layer's decisions); no browser needed. rt-cef's tests link CEF, so they run on Linux only.
./task e2eEnd-to-end tests in crates/rt-e2e: real browsers, each on its own Xvfb display, driven through the test channel. ./task e2e -- tabs runs only the tests whose names contain tabs.
./task smokeA short check with real X11 input (xdotool keys and clicks, window focus) that the e2e tests can't give: typing into a field, a trusted click, tab keys, a second window, the bundled help page, and :wq with a restart. build-release.yml also runs it against the release packages.
./task lintcargo fmt --check, clippy -D warnings, ShellCheck on the scripts, actionlint on the workflows, and cargo-deny (below)
./task checkAll of the above

The e2e and smoke tests use temporary profiles, so they never touch your browsing data or your browser.

End-to-end tests

Debug builds (and release builds with --features test-control) answer test requests on the command socket. Release builds refuse them. There are four requests:

RequestDoes
keysPresses keys such as 5j, <Escape> or :open x<Return>. They go through the same engine path as typed keys, and to the page when the engine doesn't use them.
runRuns a command line, as : would.
stateReturns JSON with the mode, windows, tabs (URL, title, pinned, loading, mode), the status bar, completion and the prompt.
evalRuns JavaScript in a tab and returns its string result.
evalbarRuns JavaScript in the window's tabbar, statusbar or completion overlay page (Browser::eval_bar).

crates/rt-e2e wraps them in a Browser that starts riptide with a scratch --basedir, its own Xvfb display, runtime directory and command socket, and a local HTTP server for the fixture pages in crates/rt-e2e/pages/. It stops only its own processes when the test ends. Its browsers run without the desktop's session bus and keep plugins' secrets in memory (RIPTIDE_SECRET_STORE=memory), so a test never touches your keyring. When a test fails, the end of the browser log is printed and the profile is kept in /tmp/rt-e2e-* for a look.

#![allow(unused)]
fn main() {
#[test]
#[ignore = "starts a browser; run with ./task e2e"]
fn d_closes_the_tab() {
    let b = Browser::start("page.html");
    b.run(&format!("open -t {}", b.url("second.html")));
    b.wait_until("the second tab loads", |s| s.tabs().len() == 2 && s.tab().title == "second");
    b.keys("d");
    b.wait_until("one tab is left", |s| s.tabs().len() == 1);
}
}
  • Wait, don't sleep: wait_until, wait_mode and wait_eval poll until the state matches, and fail with the last state after 15 seconds. Use s.tab().is_loaded(&url) to wait for a page: a new tab knows its URL before it starts loading, so checking the URL alone isn't enough.
  • Start options: Browser::launch().toml("…").lua("…").file("data/greasemonkey/x.user.js", "…").script("config/userscripts/us", "…").start("page.html") writes the profile before the browser starts (script makes the file executable). Config and files can use {server} (the fixture server), {pages} (the fixture directory) and {scratch} (an empty directory for the test's own files, also b.scratch()).
  • Files and second invocations: wait_file(path) polls until a file has content, e.g. a download or a userscript's output. invoke(&[url, ":cmd"]) runs riptide again on the same profile, which hands its arguments to the running browser.
  • Restarts: config_dir() and data_dir() give the profile's paths. After :quit and wait_exit(), a crash() (SIGKILL) or a terminate() (SIGTERM), restart() starts the browser again on the same profile, for testing sessions and crash recovery.
  • Hints: follow_hint("hint links tab", |h| h.url.as_deref() == Some(&url)) starts hints and presses the label of the element you pick by its text or link. state().hints lists the labels on screen.
  • Painting: start() and open() wait until the page has drawn a frame. Under load Chromium drops keys sent to a page that has loaded but not painted, so call wait_painted() after navigating some other way.
  • Every test is #[ignore]d, so a plain cargo test never starts browsers. ./task e2e runs them with --ignored, two at a time (E2E_THREADS changes that).
  • Clicks that should count as the user's go through hints (follow_hint). Insert mode ignores a script's focus(), so pages can't switch it on, and that includes test scripts.
  • The smoke test stays for what needs real X11 input (xdotool) and for checking the release packages. New behaviour gets an e2e test.

Unit tests in the CEF layer

Code in crates/rt-cef that decides something without needing CEF, such as where the tab bar goes, what a setting says to do, or which download a count means, is written as a plain function next to the code that uses it, and tested there. The CEF callback then only gathers its inputs and acts on the answer:

#![allow(unused)]
fn main() {
fn decide(setting: &str) -> Decision { … }        // pure, tested in this file

pub fn certificate_error(…, callback: Callback) -> bool {
    match decide(&setting) {                        // CEF glue around it
        Decision::Load => callback.cont(),
        …
    }
}
}

When the logic isn't specific to CEF and other crates could use it, it goes in rt-core instead (for example rt_core::html::escape).

Linters

./task lint runs pinned versions of each tool through scripts/tool.sh, which downloads them into .bin/ and checks their checksums. That way a new upstream release never adds warnings to CI without warning. To upgrade one, change its version and checksums there.

ToolChecksConfig
rustfmt, clippyRust formatting and lints, with warnings as errors. Every crate also takes the shared lints in [workspace.lints]: no dbg!, todo! or unimplemented!, and a // SAFETY: comment on each unsafe blockCargo.toml
ShellCheckscripts/*.sh and task# shellcheck disable=… comments, each with its reason
actionlint.github/workflows/*.yml, including ShellCheck on their run: blocks—
typosSpelling in code, docs, scripts and pages_typos.toml, for words that are right where they are
Biomecrates/rt-cef/js/*.js, the scripts riptide runs in pages, with warnings as errors; lint only, no formattingbiome.json
cargo-denyDependency licenses (each must be GPL-3.0-compatible), RustSec security advisories, yanked crates, and where crates come fromdeny.toml

A dependency with a license that isn't in deny.toml fails the check. Add the license only after checking that it's compatible with GPL-3.0.

Security advisories aren't part of ./task lint, because a new one can appear any day without anything in the repository changing. .github/workflows/audit.yml checks them daily and whenever Cargo.lock or deny.toml changes; run it yourself with scripts/tool.sh cargo-deny check advisories. A failing advisory is fixed by updating the crate, or listed in deny.toml's ignore with a reason.

Testing by hand

The local-testing skill describes how to drive the browser on a separate Xvfb display with a scratch --basedir, without touching a browser you're running yourself.

CI

.github/workflows/check.yml runs on every push to main and every pull request:

JobRuns
commit messagesscripts/check-commits.sh on the new commits
linux./task lint, ./task test, ./task smoke (Xvfb, cached CEF download)
macos, windowscargo build, the unit tests, and --version/--paths
docsbuilds this book and checks its internal links and anchors with lychee

macOS and Windows are built and unit-tested but can't run the browser yet; packaging (M10) adds the app bundle and installer they need.

.github/workflows/docs.yml publishes the book to GitHub Pages on every push to main, and checks its external links weekly.

Other scheduled jobs:

WorkflowWhenDoes
audit.ymldaily, and when Cargo.lock or deny.toml changesRustSec security advisories (cargo-deny)
cef-update.ymlweeklyOpens an "Update CEF to X" issue when crates.io has a newer cef than Cargo.lock pins
nightly.ymldailyThe rolling nightly pre-release (see Releasing)
DependabotweeklyPull requests that update the GitHub Actions the workflows use

Contributing

Commit messages

Commits follow Conventional Commits, which the changelog is generated from (feat and fix appear in it; docs, test, ci and chore don't): feat(tabs): add pinned tabs, fix: …, docs: …, ci: …. Run ./task hooks once to check messages locally before CI does.

Before you push

  • ./task check runs everything CI does: the linters, unit tests, the e2e tests and the smoke test.
  • Generated files must be current: UPDATE_LUA_TYPES=1 cargo test -p rt-config rewrites docs/lua/rt.meta.lua, docs/settings.md and the generated reference pages after you add or change a command, setting or default binding.
  • User-visible changes need documentation in the same change; see Writing documentation.

Writing documentation

This book is the documentation. It's built with mdBook from docs/book/ and published to https://joshzcold.github.io/riptide/ on every push to main. A change that users or contributors would notice updates the book in the same commit.

Where things go

ChangeUpdate
A new or changed command, setting or default bindingNothing by hand: regenerate the reference (below). Add or update a guide page only if the feature needs explaining beyond its one-line description.
New user-visible behaviour (a mode, a prompt, a page, a file riptide reads)The matching page under docs/book/src/guide/ or configuration/. Add a page to SUMMARY.md if no existing one fits.
How riptide is built, structured, tested or releasedThe matching page under docs/book/src/dev/.
A pitfall or lesson learned (a CEF quirk, a threading rule)CEF pitfalls, or the developer guide page for its area.
Plans and what's left to dodocs/PLAN.md only; it isn't published. Finished work is one line there, documented in the guides.

Refactors, internal-only fixes and test-only changes don't need documentation.

Generated pages

These are written by code and checked by unit tests, so CI fails when they're stale. Never edit them by hand:

FileGenerated from
docs/book/src/reference/commands.mdCOMMANDS and the default keymap, through rt_core::help::build (the same data as :help)
docs/book/src/reference/bindings.mdKeymap::defaults()
docs/settings.md (included by the settings page)the settings registry
docs/lua/rt.meta.lua (included by the Lua API page)the settings registry and the rt.* API

Regenerate all of them with:

UPDATE_LUA_TYPES=1 cargo test -p rt-config

To change their text, change the description in the registry (crates/rt-core/src/command.rs, settings.rs, keymap.rs) or the generator (crates/rt-config/src/reference.rs, lua_types.rs). The changelog page includes CHANGELOG.md, which git-cliff generates from commit messages.

Building and previewing

./task docs         # build into docs/book/book/
./task docs-serve   # serve at http://localhost:3000, rebuilding on save

Style

User guide and configuration pages:

  • Write for someone using the browser, in the second person (":set changes a setting"), starting with the task and then how to do it.
  • Show one short example per feature: the keys, or a config.lua/config.toml snippet.
  • Use plain language, and name settings and commands exactly, in backticks.

Developer guide pages:

  • Explain why as well as what, and link to the code (crates/rt-cef/src/shell.rs).
  • Keep instructions runnable: commands should work when pasted.

Both:

  • Keep sentences short, and use a table or list when there are three or more parallel items.
  • Link to other pages rather than repeating them, and to the reference for exhaustive lists.
  • Write links to repository files outside the book as full GitHub URLs, since the site can't serve them.

For AI agents

.claude/skills/docs-user/ and .claude/skills/docs-dev/ turn these rules into checklists for agents, and AGENTS.md points other tools at them. When the rules here change, update the skills too.

Releasing

Releases are cut from GitHub Actions; nothing needs to run locally.

Cutting a release

main only takes changes through pull requests, so a release is two of them.

  1. Open Actions → release → Run workflow on main.
  2. Leave Version empty to let git-cliff work out the next version from the Conventional Commits since the last tag. In 0.x, a feat bumps the minor version and anything else the patch version. Or type one, such as 0.3.0 or 0.3.0-rc.1 (anything with a - is published as a pre-release).
  3. Run it with Dry run ticked first (the default). It builds and tests everything, then keeps the packages and release notes as a release-dry-run artifact for a day, without pushing or publishing.
  4. Run it again with Dry run unticked. It commits chore(release): vX.Y.Z (the version in Cargo.toml and Cargo.lock, and CHANGELOG.md) to a release/vX.Y.Z branch and opens a pull request.
  5. Merge the pull request. That's the release: the push to main builds, smoke-tests, tags and publishes it.
  6. Merge the second pull request, chore(release): update the AUR and Nix packages for vX.Y.Z, which points the packages at the new tarball.

If Actions isn't allowed to create pull requests (Settings → Actions → General → Allow GitHub Actions to create and approve pull requests), the run's summary has a link to open each one instead. Pull requests opened by the workflow don't trigger CI; the release build runs the smoke tests after the merge anyway.

.github/workflows/release.yml:

JobDoes
planFrom Actions: prepares the release pull request, or for a dry run, the version to build. On every push to main: releases the commit if Cargo.toml's version has no tag yet (a merged release pull request), and otherwise does nothing.
buildRuns build-release.yml on the release commit (below).
publishWrites SHA256SUMS, records build provenance for every package, and creates the GitHub release, which tags the commit, with that version's section of the changelog. Then scripts/update-packages.sh points the AUR and Nix packages at the new tarball, in a pull request. Pre-releases leave the packages alone, and a dry run only prints the change.

Because a new version in Cargo.toml is what triggers a release, change it only through the release pull request.

What gets built

build-release.yml is shared by releases and nightlies:

  • Linux (x86_64): cargo build --release, then scripts/package-linux.sh packs the stripped binary and CEF runtime into riptide-<version>-linux-x86_64.tar.gz, an AppImage and riptide_<version>_amd64.deb. The whole smoke test then runs against the unpacked tarball, the extracted AppImage, and the .deb installed with apt (with its setuid sandbox), so a broken package never ships. ./task package, ./task appimage and ./task deb build the same files locally.
    • The .deb's Depends: comes from dpkg-shlibdeps, so it names the libraries of the distribution it's built on. CI builds on Ubuntu 24.04, which makes it suit Ubuntu 24.04+ and Debian 13+.
  • macOS and Windows (experimental): scripts/package-experimental.sh packs the binary with the CEF framework (macOS, .tar.gz) or runtime files (Windows, .zip). Neither runs the browser yet: they need the app bundle and installer work in M10. If either fails to build, the release goes ahead without it.

Nightly builds

.github/workflows/nightly.yml runs at 07:00 UTC. If main changed since the last nightly, it builds it the same way and replaces the rolling nightly pre-release, with files named riptide-nightly-<date>-<commit>-…. Running it by hand builds even if nothing changed. It never becomes the "latest" release.

Verifying a download

Every published package has a SHA256SUMS entry and a signed build provenance attestation that ties it to the workflow run and commit that built it:

sha256sum -c SHA256SUMS --ignore-missing
gh attestation verify riptide-0.2.0-linux-x86_64.tar.gz --repo joshzcold/riptide

After a release

Packages:

  • The AUR PKGBUILD (packaging/aur/) and the Nix package (packaging/nix/package.nix, used by the root flake.nix) are updated by the publish job's pull request. If that step fails, run scripts/update-packages.sh X.Y.Z riptide-X.Y.Z-linux-x86_64.tar.gz with the released tarball and open a pull request with the result.
  • To check the Nix package, run nix build .#riptide, then BIN=result/bin/riptide scripts/smoke-test.sh.

In the browser:

In the browser, :changelog shows the changelog it was built with, and the first start after an update says so in the status bar (and opens it, as changelog_after_upgrade decides). The documentation site isn't tied to releases: it's rebuilt from main on every push.