Skip to content

Repository files navigation

unrager

A calm Twitter/X client for the terminal — with native iOS, macOS, and Linux apps.
Local LLM drops rage-bait before it reaches your eyes.

crates.io installs CI GPL-3.0 License Rust 1.85+

unrager home feed with rage filter active, scrolling into a thread, then asking gemma to explain the focal post

Your home feed, minus the tweets the LLM quietly ate. The −N in the status bar is all that remains of them. Everything LLM-shaped — filter, translate, explain, summarize — runs on your machine via Ollama. Nothing leaves your box.

What is this

unrager is a Rust TUI for reading Twitter/X without the engagement-optimized rage. It connects through the same GraphQL endpoints the web client uses (no API key, no cost), and pipes every incoming tweet through a local Ollama model that classifies it against your personal rubric. Tweets that match are physically removed from the feed before rendering — they never existed.

Everything intelligent runs locally: the rage filter classifies each tweet, translate (T) turns foreign-language posts into English, ask (A) lets gemma explain / counter / summarize a post, and brief (B) writes a short profile analysis from someone's recent timeline. One Ollama process, one gemma model, four features. No cloud round-trips, no provider keys, no token bills, no data leaving your machine.

Every text input (reply, ask, command palette) is a Vim-mode editor — Insert/Normal modes, hjkl, w/b, dd/dw, counts, ^/$. If that's your muscle memory, composing here feels native.

It also has a CLI for one-shot reads and an OAuth 2.0 write path for posting.

unrager               # TUI
unrager home -n 20    # one-shot CLI
unrager tweet "..."   # post via official API

And it's not just the terminal: unrager serve exposes the same feeds over an HTTP/SSE API that native iOS, macOS, and Linux apps read — the server does all the X work, the clients stay thin. Pair over Tailscale and read your filtered feed from your phone or desktop. See Native apps.

How auth works

Reading uses your existing browser login. On startup, unrager reads two cookies — auth_token and ct0 — from whichever Chromium-family browser you're logged into x.com with, decrypts them in memory using your OS credential store (macOS Keychain, Linux Secret Service — or Chromium’s keyring-less basic store), and hands them to X's GraphQL endpoints the same way the web client does. Nothing is written to disk, nothing is logged, nothing leaves your machine. The whole path is under src/auth/ — ~300 lines, open-source, cargo-auditable.

Writing is the other story: unrager tweet / reply go through the official X API v2 with your own OAuth 2.0 PKCE client. unrager never sees your password, your read cookies aren't used for writes, and posts are attributed to your developer client (not a shared one). No writes until you run unrager auth login.

Quick start

curl -fsSL https://unrager.com/install.sh | bash
unrager

# optional: enable the rage filter
ollama pull gemma4

Want to poke around without setting up X cookies? Run unrager demo — it launches the TUI against a bundled offline feed of 15 tweets designed to exercise the filter. No cookies, no network, just a terminal (and optionally Ollama to see the filter drop roughly five of them).

Uninstall:

curl -fsSL https://unrager.com/install.sh | bash -s -- --uninstall

Works on macOS (Apple Silicon + Intel) and Linux (x86_64 + aarch64). On Windows, use WSL2 with a Linux browser — the native Windows build path isn't wired up (Chromium cookie decryption on Windows uses DPAPI, which unrager doesn't implement; PRs welcome). Builds from source via cargo install --path . on any Unix platform with Rust 1.85+.

The TUI reads cookies from your logged-in browser automatically (Vivaldi, Chrome, Brave, Edge, Opera, Arc). If you're logged into x.com in more than one of them, pin the source with cookie_browser = "Vivaldi" in config.toml (or the UNRAGER_BROWSER env var) and unrager uses only that browser — never silently falling back to another. The filter enables itself when Ollama is reachable and disables silently when it isn't.

The rage filter

Every tweet is classified by a local LLM against a user-editable rubric (~/.config/unrager/filter.toml). Matching tweets are physically removed — not collapsed, not grayed out, gone. Verdicts cache to SQLite keyed by (tweet_id, rubric_hash), so reloads are instant and editing the rubric invalidates automatically.

drop_topics = [
    "american electoral politics, presidents, congress, partisan fights",
    "war, military conflict, battlefield footage, casualty counts",
    "gender wars, men-vs-women discourse, trad-vs-feminist fights",
    # add your own
]
extra_guidance = "Keep technical, scientific, art, music, sports tweets..."

[ollama]
model = "gemma4:latest"
host = "http://localhost:11434"

Toggle with <space> r. The status bar shows −N when the filter is actively hiding tweets, or filter off · doctor when Ollama isn't reachable or no gemma4 model is installed — the hint points at unrager doctor, which explains why.

Reading threads

split pane showing a tweet with replies sorted by likes

Enter opens a tweet into a split detail pane. The focal tweet and all its replies form one scrollable list. Push deeper into any reply with Enter, pop back with Esc. Press s to cycle reply sort order — newest, likes, replies, retweets, views — it persists across sessions. X expands inline thread replies without leaving the current view.

The left pane stays live. Tab swaps focus between panes, ,/. adjusts the split width.

Composing (Vim-mode everywhere)

reply composer open over the home feed, with the vim insert-mode indicator and a 24/280 character counter at the bottom

Every text input in the TUI — reply (r), ask-gemma input (A), command palette (:) — is a miniature Vim editor. Insert/Normal modes, hjkl motion, w/b word jumps, dd/dw, counts, ^/$, everything a two-minute muscle-memory habit expects. The status line at the bottom of each pane shows INSERT or NORMAL and the live character counter (24/280).

Submit (Enter in insert mode) doesn't post over the X GraphQL write endpoint — that path errors out on most accounts in a way unrager can't route around. Instead, the composed text is copied to your clipboard and the parent tweet opens in your browser. Paste, send, done. The full Vim editor experience inside the terminal; the actual post happens in the place X is happiest about. Press Esc twice (Insert → Normal → exit) to close the editor — your draft is kept in memory until you leave the parent tweet's detail pane, so an accidental close doesn't lose your text.

Submitting a reply with r auto-likes the tweet you're replying to — that happens the moment you hit Enter, before the browser opens, so the reciprocal-like etiquette stays intact. The like is gated the same way o is: skipped if it's your own tweet, already liked, or X is write-rate-limiting you.

Search and translation

search results for nvidia with multilingual content and translation

:search nvidia pulls live results in every language. Press T on any tweet to translate it to English via the same local Ollama instance. Press T again to revert. Translations are ephemeral — in memory only.

Ask gemma about a post

ask pane open next to the home feed, gemma streaming an inline explanation of the selected post, with preset chips Explain / Replies / Counter / ELI5 / Entities at the bottom

Press A on any tweet to open an ask pane powered by your local Ollama gemma model (the same one used for the rage filter and translation). The post is pinned to the top, a chip row exposes preset prompts (1 Explain · 2 Replies · 3 Counter · 4 ELI5 · 5 Entities) that fire with a single keystroke when the input is empty, and the reply streams inline token-by-token. Gemma4's vision is used automatically — up to four photos on the post are base64-attached to the first turn. When you open the pane from the detail view, the loaded replies are pulled into context, so 2 Replies actually summarizes the thread; and when you A a reply inside a thread, the thread's root post and the other replies are included as context so gemma sees the conversation, not just the isolated reply. The pane title shows what's in scope (ask · @handle · 2 imgs · 14 replies · ready). Conversations live only in memory. Thinking is enabled for the chat path since replies benefit from reasoning; filter and translate keep thinking off for speed.

The command palette supports :home, :user <handle>, :search <query>, :mentions, :notifs, :bookmarks, and :read <id|url>. History navigates with ]/[.

Notifications

notifications view showing likes, replies, retweets, and follows

Press n or :notifs to open notifications as a detail pane without losing your place in the source timeline. Likes, retweets, follows, and quotes come from the main notifications feed; replies are merged from the mentions endpoint. Type icons stay vivid for scanning, handles keep their palette color. Press x to expand a snippet, Enter to open the target tweet in a stacked detail view on top. Esc pops back to the notifications list; Esc again pops back to the source timeline.

notifications with split detail pane showing a threaded conversation

Unread badge (Nn) appears in the header when on other views. Auto-refreshes at the top of the list. Read tracking is separate from tweet seen state.

Profile view

own profile showing tweets with full metrics and expanded bodies

p opens the profile of whoever your cursor is on — the selected tweet's author, the notification actor, or your own profile if nothing's selected. The header pins a kitty-graphics avatar, display name, handle, and follower / following counts; on non-kitty terminals (or while the image is downloading) it falls back to a text-only header. When X exposes an about_profile block (via the AboutAccountQuery endpoint), the header also surfaces the country flag + based in <country>, the location source ("via United States App Store" / "via Web"), joined <Mon YYYY>, blue-verification start date, the X affiliate badge handle, and the number of past username changes. The country flag also rides next to the handle in every feed row — in the TUI and, via unrager serve, in the iOS, macOS, and Linux apps. Your own profile renders with full metrics forced visible — including an analytics block on the focal tweet (views, likes, retweets, replies, quotes, bookmarks, engagement rate). :user <handle> opens anyone's timeline. Press R to toggle between their tweets and replies. <space> o switches between all tweets and originals only (hides replies, quotes, retweets).

Help overlay

scrollable help overlay showing iconography section

? opens a scrollable help overlay with every keybinding and an iconography reference for all the glyphs used in the interface. Scroll with j/k, any other key closes it.

Key bindings
Key Action
j / k / / Move selection
g / G Top / bottom
Ctrl-d / Ctrl-u Half-page down / up
Enter / l Open tweet into detail pane
q / Esc Pop detail (or quit on home:following)
Tab Swap active pane
, / . Narrow / widen split
: Command palette
? Help overlay
<space> Leader — which-key popup for session toggles
<space> o Toggle all / originals on home feed
<space> f Toggle For You / Following
<space> m Toggle metric counts
<space> n Toggle display names
<space> d Toggle relative / absolute timestamps
<space> t Cycle x-dark / x-light theme
<space> i Toggle media auto-expand
<space> a Toggle author-avatar chips in feeds (kitty terminals only)
<space> r Toggle rage filter
R Toggle tweets / replies on user profile
T Translate selected tweet to English (toggle)
A Ask gemma about the selected post
B Deep profile brief on the selected author
f Like / unlike
x Expand / collapse tweet body
X Inline thread replies
s Cycle reply sort in detail pane
p Open selected author's profile (falls back to own)
P Open own profile in browser
n Open notifications as a detail pane
o Open tweet in browser (auto-likes, except on your own tweets)
O Open tweet author's profile in browser
m Open all media (photos/GIFs/videos) in native viewer
M Open URLs in tweet body (music links auto-routed through song.link)
S Screenshot composer, default action save to ~/.cache/unrager/screenshots/
C Screenshot composer, default action copy to clipboard
y Yank fixupx URL to clipboard
Y Yank tweet JSON to clipboard
r Reply to selected tweet — Enter copies text + opens the parent in browser, auto-likes the target (skipped on own tweets)
c Compose a new tweet — Enter copies text + opens X's composer in browser
Ctrl-r Reload source / refresh thread replies
u Jump to next unread
U Mark all as read
] / [ History forward / back
W Changelog (release history)
Ctrl-c Quit immediately

Install modes

unrager is modular. Pick the install you want — you can always re-install with a different flavor later.

Oneliner (prebuilt binary)

# TUI + CLI (default) — the primary product
curl -fsSL https://unrager.com/install.sh | bash

# TUI + HTTP API server — for the native iOS / macOS / Linux apps over Tailscale
UNRAGER_FLAVOR=full curl -fsSL https://unrager.com/install.sh | bash

# CLI only (no TUI) — scripts, pipelines, CI
UNRAGER_FLAVOR=cli curl -fsSL https://unrager.com/install.sh | bash

Re-run the installer with a different UNRAGER_FLAVOR to switch — it replaces the binary in place. The installer also supports UNRAGER_INSTALL_DIR (default ~/.local/bin) and --uninstall.

From source

# TUI + CLI (default)
cargo install unrager

# TUI + HTTP API server
cargo install unrager --features server

# CLI only
cargo install unrager --no-default-features

Approximate release-build sizes: full 23 MB, TUI-only 15 MB, CLI-only 12 MB. Feature flags gate:

Feature What it pulls What you lose without it
tui (default) ratatui, crossterm, image, termbg bare unrager can't launch; user/notifs/doctor subcommands hidden
server (default, implies tui) axum, tower, tower-http unrager serve — the HTTP API the native iOS / macOS / Linux apps talk to

Upgrading later: cargo install unrager --force — no uninstall step, cargo replaces the binary in place.

Serving the API (native clients)

unrager serve (requires the server feature, on by default) exposes the HTTP API that the native iOS, macOS, and Linux apps talk to. The server does all the X work — cookie/OAuth auth, GraphQL, the rage filter, ask/brief/translate over Ollama — so the clients stay thin native UIs.

unrager serve                          # bind 127.0.0.1:7777
unrager serve --bind 0.0.0.0:7777      # reach across Tailscale / LAN from your phone

Over Tailscale (or your LAN), set an ACL so only your own devices can hit the port. The server expects the network to be the trust boundary — there is no app-level auth. Point the app at the server's host/port in its settings.

The API surface (/api/*): all seven sources, tweet detail + thread, profile + likers, compose/reply, like, media proxy (photos, videos, gifs, link cards, polls, YouTube, X broadcasts), and filter/ask/brief/translate streaming over SSE, plus session + filter-config persistence and seen-tracking — feature parity with the TUI.

While unrager serve is running it owns the filter + seen caches; the TUI detects the lockfile (~/.cache/unrager/server.lock) — run one or the other.

Native apps (iOS + macOS + Linux)

Three native clients ship in the repo — all thin UIs over the same /api/* contract unrager serve exposes. None is an App Store / store-published app (unrager uses your X session): you build and run them yourself. Point each at your server in its settings (or bake a UNRAGER_DEFAULT_SERVER default for your tailnet) and read every feed, thread, and profile — with the rage filter, ask, brief, and translate intact.

Client Stack Lives in
iPhone UIKit, iOS 26 Liquid Glass ios/
Mac AppKit, macOS 26 macos/
Linux desktop GTK4 + libadwaita (relm4) linux/

iOS + macOS

The Apple apps share the UnragerKit Swift package (Codable models, the typed async/await APIClient with SSE, image pipeline, logging). Sideload with your own Apple Developer account:

# build the project (XcodeGen + a local Swift package; no Xcode account needed)
cd ios && xcodegen generate          # → Unrager.xcodeproj

# run in the iOS 26 simulator
xcodebuild -scheme Unrager -destination 'generic/platform=iOS Simulator' \
  -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
xcrun simctl install booted build/Build/Products/Debug-iphonesimulator/Unrager.app

# install on your own iPhone (one-time: register the device + mint an ad-hoc profile)
python3 scripts/provision.py --udid <UDID> --serial <DIST_CERT_SERIAL> --name "iPhone"
UDID=<UDID> ./scripts/install-device.sh     # build → ad-hoc sign → devicectl install + launch

macOS builds with cd macos && xcodegen generate && xcodebuild ...; distribute the Mac app as a notarized DMG (Developer ID).

Linux

A native GNOME desktop client — GTK4 / libadwaita via relm4, the Linux peer of the Apple apps. It's a separate cargo workspace (linux/, kept out of the root build since it pulls heavy GTK system deps) that reuses the byte-exact unrager-model wire types. On launch it reuses a running local unrager serve or spawns its own, so a single command gets you a window:

# system deps — Arch:  sudo pacman -S gtk4 libadwaita
#                Debian/Ubuntu:  sudo apt install libgtk-4-dev libadwaita-1-dev build-essential
cd linux && cargo run -p unrager-gtk

Inline photos fill the tweet column at their true aspect (Compact/Standard/Large media setting), video and GIFs show a poster with a play badge, every screen has loading/empty/error states, and right-clicking any image opens a native copy/save menu. See linux/README.md for the full rundown.

More

  • Inline media — photos, video posters, and GIF first-frames render inside the terminal via the kitty graphics protocol on Ghostty, Kitty, and WezTerm. Multiple images side-by-side. Toggle with I. Falls back to // glyphs elsewhere.
  • Author avatars — every feed row, detail focal, reply, and inline thread carries a square kitty-graphics chip of the poster's avatar; profile headers pin a larger one beside the name. Avatars cache at ~/.cache/unrager/avatars/ (LRU-pruned to 50 MB, self-invalidating since X rotates the URL on every photo change). Postcard screenshots composite the chip in too. Toggle feed chips with <space> a; disabling skips downloads and suppresses screenshot avatars. Kitty-only — halfblock and disabled terminals fall back to the no-gutter layout.
  • Inline cards — YouTube links, X Articles, X Broadcasts (with a red ● LIVE badge while running), generic link previews (any brand), and polls render as bordered preview cards with cover image, title, description, and metadata. m opens the source URL in your browser.
  • Originals modeV on home feeds hides replies, quotes, and retweets. appears in the status bar. Persists across sessions.
  • Notifications view — press n or :notifs to browse notifications in a dedicated feed. Enter opens the target tweet or navigates to the actor's profile. Ambient whisper continues in the status bar independently.
  • Read tracking — tweets mark as read on cursor. For You feed hides already-seen tweets and deduplicates across pages. u jumps to next unread.
  • Theme engine — built-in x-dark (X.com brand colors layered over a Rosé Pine surface palette) and x-light (X.com brand over Solarized Light). Swap live with :theme x-dark|x-light|auto or toggle with Z. The choice persists across sessions. The Twitter blue, like-pink, retweet-green, and quote-purple are real X brand hex values; greys, borders, and the ribbon palette come from Rosé Pine / Solarized so the TUI sits comfortably inside those terminals.
  • Color-hashed handles — FNV-1a hash into a per-theme 20-color palette, consistent across every mention in every tweet body.
  • Zebra striping — alternating row backgrounds drawn from the active theme.
  • Sharey copies a fixupx embed URL, o opens in browser, m downloads every attachment on the selected tweet (all photos, GIFs, and video MP4s). On macOS images go to QuickLook (qlmanage -p) — space/Esc closes and focus returns to the terminal — while videos open in QuickTime Player via an osascript wrapper that polls for the document close and reactivates the spawning terminal, so Cmd+W alone gets you back to unrager. Linux uses xdg-open for everything. Cache lives under ~/.cache/unrager/media/<tweet_id>/.
  • PostcardS (or C) opens a theme picker for rasterizing the focal tweet to a PNG. Six distinctive presets (glass, synthwave, cutout, moss, blueprint, arcade), a "match TUI" option, or tune a custom theme from two hex colors (bg + accent; text auto-picked from bg luminance). Commit with s (save to ~/.cache/unrager/screenshots/) or y (copy PNG to system clipboard). Press T in the modal to capture the entire reply chain — root tweet down to the focal — as one tall image with a continuous accent bar and hairline dividers between blocks. Press n to toggle between handle-only (default) and Display Name @handle rendering. Press m to toggle the metrics row (replies / RTs / likes / views / quotes / bookmarks) — off by default for a cleaner postcard look. Both toggles persist in session.json. Every color emoji in the card — country flags, faces, ZWJ sequences like 👨‍👩‍👧‍👦, skin-tone variants, keycaps — renders as a full-color image composited onto the character grid (ab_glyph only rasterizes monochrome outlines, so the bundled fonts can't draw color emoji themselves). The PNGs are pulled on demand from the maintained Twemoji set, whose version is resolved to the newest release at runtime so the latest Unicode emoji always render — not just whatever a build-time-bundled font happens to know — and cached under ~/.cache/unrager/emoji/. Anything offline, 404, or newer than the set falls back to the monospace outline. Renders at 2× density (~1400px wide, retina-sharp) with 22pt editorial-feel typography. Media images are composited beneath the text; videos are skipped. Watermark sits bottom-right in muted color.
  • Configurable browserconfig.toml supports {} URL placeholder for Chromium --app={} kiosk mode. cookie_browser = "Vivaldi" (or UNRAGER_BROWSER) pins cookie extraction to a single browser so a stale login in another never gets picked up.
  • Digital clock overlay — optional floating clock with big block-character digits. Every element is toggleable via [clock] in config.toml (see below) — time, date, seconds, 12/24h, position, accent color, border. Set enabled = false to hide completely.
  • Session persistence — source, selection, toggles, split width, feed mode, reply sort all survive restarts.
CLI

Read-only (no cost, no API key)

Command Purpose
unrager whoami Confirm which account your cookies belong to
unrager doctor Check cookies, Ollama, and gemma4 setup
unrager update Self-update to the latest release
unrager read <id|url> Fetch a single tweet
unrager thread <id|url> Full conversation thread
unrager home [--following] Home timeline
unrager user <@handle> A user's tweets
unrager search "<query>" Live search
unrager mentions [--user @h] Mentions feed
unrager bookmarks "<query>" Search bookmarks
unrager notifs Recent notifications

All accept -n <count>, --json, --max-pages <n>.

Write (requires OAuth 2.0 + credits)

Command Purpose
unrager auth login OAuth 2.0 PKCE flow (free)
unrager auth status Show token state
unrager auth logout Delete cached tokens
unrager tweet "<text>" [--dry-run] Post a tweet
unrager reply <id|url> "<text>" [--dry-run] Reply to a tweet

Setup

Requirements
  • macOS (stores the cookie key in your login Keychain) or Linux with a Secret Service provider (kwalletd6 on KDE, gnome-keyring on GNOME)
  • Chromium-family browser logged into X — auto-detected: Vivaldi, Chrome, Chromium, Brave, Edge (all channels), Opera, Arc. Override with UNRAGER_COOKIES_PATH.
  • Rust 1.85+ (edition 2024) — only if building from source
  • Ollama (optional) — for the rage filter and translation. Default model gemma4:latest, configurable in filter.toml.
  • X developer account (optional) — only for posting. OAuth 2.0 Native App + pay-per-use credits at console.x.com.
Configuration

Config paths are platform-native: Linux uses ~/.config/unrager/ + ~/.cache/unrager/, macOS uses ~/Library/Application Support/unrager/ + ~/Library/Caches/unrager/.

File (Linux) Purpose
~/.config/unrager/config.toml General settings (browser command, theme, etc.)
~/.config/unrager/session.json TUI session (source, selection, toggles)
~/.config/unrager/tokens.json OAuth 2.0 tokens (mode 0600)
~/.config/unrager/filter.toml Rage filter rubric (auto-created)
~/.cache/unrager/seen.db Read-tracking SQLite
~/.cache/unrager/filter.db Filter verdict cache
~/.cache/unrager/feed.db Materialized Home buffer — pre-fetched, pre-classified For You + Following (capped ring; feed.db.writer.lock guards the single writer)
~/.cache/unrager/about.db AboutAccountQuery cache (country flag + about_profile, keyed by rest_id; negative entries TTL 30d; shared by the TUI and unrager serve, which exposes it to the native clients via GET /api/about/{rest_id})
~/.cache/unrager/media/<tweet_id>/ Downloaded attachments for m (external viewer)
~/.cache/unrager/screenshots/ PNG screenshots written by S
~/.cache/unrager/avatars/<sha256>.bin Author-avatar disk cache (LRU-pruned to 50 MB)
~/.cache/unrager/emoji/<stem>.png Color emoji PNGs (Twemoji) composited into screenshots
~/.cache/unrager/mordor-user-<hash>.opus Sliced Mordor loop (generated from [sound] source)

Feed buffer

Home (For You + Following) is served from a small local SQLite buffer that a background worker keeps fresh and pre-classified, so the app opens instantly instead of fetching X live on every launch. The worker runs inside unrager serve (so the iOS / macOS / Linux clients get it for free) or, when no server is running, inside the TUI itself; whichever process wins feed.db.writer.lock does the ingest and the rest read the shared buffer. It tops the buffer up toward the cap in the background and then parks (no fetching, no GPU classification) until the app is opened — polling briskly only while a client is actively using the feed — so the buffer is already large when you open the app, and a full, idle buffer costs nothing. The buffer is a capped ring; reaching its bottom is the end of what's cached. Every client shows an "updated Nm ago" freshness indicator (TUI footer; a pinned caption on iOS / macOS / Linux that ticks live), backed by GET /api/feed/status. Defaults are sane; override in config.toml:

[feed]
buffer_cap = 500        # tweets kept per feed (For You / Following)
active_poll_secs = 180  # poll cadence while the app is open
recent_poll_secs = 1200 # cadence while filling the buffer in the background

Theme

[theme]
name = "auto"   # auto | x-dark | x-light

auto follows the terminal background detected at startup (OSC 11). The Z key and :theme <name> command both override and persist whatever you pick. The clock's accent field accepts "auto" (default — follows the theme accent), an ANSI color name, a 256-color index, or a #rrggbb hex.

The Mordor wallpaper and fiery accents on the For You feed require both a dark theme and a dark terminal background. A light terminal suppresses them even if your theme is x-dark, so the cream that bleeds through transparent cells doesn't clash with the image. Terminal-background detection is done once at startup; if you switch your system between light and dark while unrager is running, re-run :theme x-light / :theme x-dark or restart to re-detect.

Mordor-mode audio

Opt-in: set UNRAGER_SOUND=1 AND configure an audio file — unrager then plays it on loop while Mordor mode is active and stops the instant you leave For You or switch to a light theme. There is no built-in fallback: if you don't configure a source, Mordor mode is silent. No audio libraries are linked into the binary — playback shells out to whichever of ffplay, mpv, paplay, pw-play, afplay, or aplay is on your $PATH.

To use your own audio, point at it in config.toml:

[sound]
source = "/path/to/your/audio.flac"   # any format ffmpeg can read

That's the whole minimum config. On next launch unrager slices, fades, downmixes, and encodes the file into a small Opus loop cached under ~/.cache/unrager/mordor-user-<hash>.opus (~3 KB per second of audio). Requires ffmpeg on $PATH for the one-time encode.

All optional keys:

[sound]
source = "/path/to/your/audio.flac"
start = "0:55"           # MM:SS, HH:MM:SS, or raw seconds (default: 0)
end = "1:40"             # same formats — or use `duration` instead
duration = 45            # seconds after `start` — ignored if `end` is set
fade_ms = 50             # fade-in/out at loop boundaries (default: 50)
volume = 0.5             # 0.0–1.0 pre-master gain (default: 0.5)

The cache key is a hash of the source path, its mtime, and every knob above, so editing any of them (or replacing the source file) invalidates the cache and re-encodes on the next launch. Stale encodes accumulate in ~/.cache/unrager/ — delete mordor-user-*.opus manually if you want to reclaim a few KB.

Precedence — unrager tries these in order, using the first that works:

  1. [sound] source = "..." in config.toml (requires ffmpeg on $PATH)
  2. A pre-encoded file you dropped at ~/.config/unrager/mordor-sound.{opus,ogg,oga,flac,mp3,wav} — used raw, no processing

If ffmpeg is missing, or the configured source can't be found, or the encode fails, unrager logs a warning and falls through to the next option. If neither source is present, Mordor mode is silent.

Backend compatibilityffplay/mpv decode everything and get gapless looping via -loop 0 / --loop=inf. paplay handles WAV/FLAC/Ogg (libsndfile), afplay handles WAV/MP3/FLAC (CoreAudio), aplay/pw-play are WAV-only. Players without native loop support run inside a shell while :; do … done with a small pulse between iterations.

Verifying it's workingtail -f ~/.cache/unrager/unrager.log.$(date +%Y-%m-%d) | grep -i mordor while you toggle into For You. You should see sound enabled backend=Ffplay format=Opus path=.../mordor-user-*.opus when the config is picked up, mordor loop started on every Mordor entry, and mordor loop stopped on exit. If you see nothing, either UNRAGER_SOUND=1 isn't set, no source is configured, or the encode failed — earlier log lines will say which.

Clock

Every field has a default — omit [clock] entirely to get the defaults (enabled, top-right, time + date, 24h).

[clock]
enabled = true
position = "footer"        # footer | header | top_left | top_right | bottom_left | bottom_right
show_time = true
show_date = true
show_seconds = false
hour_format = "auto"       # auto | h12 | h24
date_format = "auto"       # "auto" or any chrono strftime string (e.g. "%a %d %b")
accent = "cyan"            # ANSI name, 0–255 index, or #rrggbb
border = true              # only applies to the corner overlays

hour_format = "auto" (the default) reads the OS locale via sys-locale — so en_US/en_CA/en_AU/en_IN/etc. see 3:15 PM, while most of Europe/Asia see 15:15. date_format = "auto" picks %a, %b %-d for 12h locales and %a %-d %b otherwise. Force either with hour_format = "h12" / "h24" or an explicit strftime string.

footer / header render the clock right-aligned inside that row — one line of text, no box, no overlay. The four corner positions render as a floating overlay with an optional rounded border.

Write path setup

Posting uses the official X API v2 (not cookie auth), so your account is never at risk.

The fast path:

unrager auth setup

Interactive wizard — prints the X developer-portal URL, waits for you to register a Native App, prompts for the Client ID, writes it to ~/.config/unrager/config.toml, then runs auth login at the end. One command, one paste.

The manual path, if you'd rather do it by hand:

  1. Create a developer account at developer.x.com
  2. Register a Native App (PKCE, no client secret)
  3. Set callback URL to http://127.0.0.1:8765/callback
  4. Configure your Client ID — either export it:
    export UNRAGER_X_CLIENT_ID=<your_client_id>
    or add it to ~/.config/unrager/config.toml:
    [oauth]
    client_id = "<your_client_id>"
    The env var wins when both are set. There is no embedded default — auth login, tweet, and reply fail with an explanatory error if neither is configured.
  5. Load pay-per-use credits at console.x.com
  6. unrager auth login — opens browser for the OAuth flow
  7. unrager tweet "hello from unrager"

Why your own Client ID? X enforces per-client rate limits and may flag traffic from many unrelated users sharing a single client. Read and TUI are unaffected — only tweet and reply go through OAuth.

Architecture

Reads:   browser cookies  ->  GraphQL (same endpoints as x.com)  ->  free, unlimited
Writes:  OAuth 2.0 PKCE   ->  official X API v2                  ->  pay-per-use
Filter:  tweet text        ->  local Ollama                       ->  HIDE/KEEP -> SQLite cache
Media:   CDN fetch         ->  downscale 400px                    ->  kitty graphics transmit
Security model
  1. Browser cookies — read at runtime, decrypted in memory using the OS credential store (macOS Keychain, Linux Secret Service), never written to disk or logged
  2. OAuth tokens~/.config/unrager/tokens.json mode 0600, atomic writes
  3. Client ID — supplied by the user via UNRAGER_X_CLIENT_ID env var or [oauth] client_id in config.toml. No embedded default, so the app identity used against X is always your own.
  4. Filter — runs entirely locally, tweet text never leaves your machine

Contributing

cargo fmt --all
cargo clippy --all-targets -- -D warnings
cargo test

Legal

Not affiliated with X Corp. Uses X's web GraphQL endpoints the same way the web client does. Do not use this to scrape at scale or run bots.

Emoji graphics composited into postcard screenshots are from Twemoji (the maintained jdecked/twemoji fork), licensed CC-BY 4.0.

About

A calm Twitter/X TUI with a local-LLM rage filter

Resources

Stars

13 stars

Watchers

1 watching

Forks

Releases

Contributors

Languages