A terminal-based Linux news aggregator. Pulls articles from multiple sources, lets you filter by topic, learns what you don't care about over time, and lets you save articles with personal notes for later reference.
This document is the source of truth for how the project is structured and why. Update it whenever a real architectural decision is made — this file should always reflect the current state of the project, not just the v1 plan.
- Language: Rust
- TUI framework: ratatui
- Async runtime: tokio
- HTTP client: reqwest
- Serialization: serde (+ serde_json for APIs, toml for config)
- Database: SQLite via rusqlite (embedded, no server)
- Editor: Zed (rust-analyzer built in, no config needed)
- Single static binary, fast startup — this app lives in a Hyprland scratchpad and gets toggled open/closed constantly. Startup latency matters.
- No garbage collector, predictable resource use for something running in the background all day.
- ratatui's widget set (List, Tabs, Paragraph, Block) maps directly onto the layout this app needs.
Three layers, kept deliberately separate so each can be worked on independently:
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Fetchers │ --> │ Storage │ --> │ TUI │
│ (RSS, Reddit,│ │ (SQLite) │ │ (ratatui) │
│ HN, custom) │ │ │ │ │
└─────────────┘ └──────────────┘ └─────────────┘
Each source type (RSS/Atom, Reddit JSON, HN/Algolia, future custom sources)
implements a common Fetcher trait. Every fetcher normalizes whatever it
pulls down into the same Article struct — the rest of the app never needs
to know or care where an article came from.
Fetches run concurrently via tokio, so one slow/dead source never blocks a refresh of everything else.
Adding a new source should never require touching fetcher logic unless
the source has no RSS feed. See docs/ADDING_A_SOURCE.md.
Embedded via rusqlite — no server, no daemon, just a .db file on disk.
- Location:
~/.local/share/tuxwire/tuxwire.db - Config lives separately at
~/.config/tuxwire/(sources.toml,theme.toml) — data and config are never mixed.
articles
id INTEGER PRIMARY KEY
title TEXT
url TEXT
source TEXT
topic TEXT
timestamp TEXT
read BOOLEAN DEFAULT 0
skipped BOOLEAN DEFAULT 0
saved BOOLEAN DEFAULT 0
note TEXT NULL
skip_weights
keyword TEXT PRIMARY KEY
weight INTEGER DEFAULT 0
Every schema change ships as a numbered SQL file in migrations/
(e.g. 002_add_priority.sql). On startup, tuxwire checks how many
migrations have already run (PRAGMA user_version or a small
migrations-log table) and applies any new ones, in order, automatically.
Rule: no destructive schema changes. Existing saved articles and notes must survive every update. This matters more here than in most projects — saved articles are meant to persist indefinitely until manually removed.
The screen is split into two vertical regions: a fixed-height banner bar across the full terminal width at the top, and everything else below it. Same pattern as Claude Code's own terminal header — a reserved top row that never scrolls or changes, with the working area rendered underneath.
- Banner bar (top, full width, fixed height): the
tuxwirefiglet wordmark, rendered persistently — full-width means the original figlet ASCII banner (37 chars,standardfont) fits comfortably with room to spare, no need for a cramped/narrow substitute. This region never changes regardless of topic, view, or selection state. - Left pane (topic sidebar), below the banner bar: three stacked
sections, always visible regardless of topic or view selected:
- Topics — an expandable tree, not a flat list:
- Each topic shows an indicator (
▸collapsed /▾expanded). Right/lexpands the selected topic, revealing its individual sources indented underneath (source names come from the already -loadedsources.tomlconfig, grouped by topic — no new query needed, since one-topic-per-source means the grouping is already known).Left/hcollapses it back down.Up/Downflow naturally through topic rows and, when a topic is expanded, its nested source rows.- Selecting a topic row shows all articles across every source in that topic — unchanged existing behavior.
- Selecting a source row (only reachable when its parent topic is expanded) filters the article list to just that one source. This is an additional filter layered on the existing topic filter, not a new query path — a source's topic is already fixed, so "articles from source X" is just "articles in X's topic, further filtered by source name."
- Each topic shows an indicator (
- Keys — a static reference list of the active v1 keybinds (
ssave,xclose,nnote,Ssaved view,rrefresh,aadd source). Exists specifically because the person using this has no prior Rust/TUI-app experience and shouldn't need to memorize the keybind table before the app is usable — the reference lives right on screen instead. - Colors — a small colored square next to each state label
(unread/read/saved/skipped), color values pulled live from the
loaded
theme.tomlrather than hardcoded, so the legend always matches whatever's actually rendering in the article list. The Keys and Colors sections are purely static — no interactivity, no state — and render identically in both the Topic view and the Saved view.
- Topics — an expandable tree, not a flat list:
- Right pane, below the banner bar: article list for the selected topic (or saved articles, in Saved view), sorted by recency
- Footer: remaining hints not already covered by the sidebar's Keys section, plus last-refresh timestamp
- Saved view: a dedicated view filtering to
saved = trueacross all topics, independent of the topic sidebar (toggled withS)
| Key | Action |
|---|---|
j / k |
navigate list |
Right / l |
expand selected topic (show its sources) |
Left / h |
collapse selected topic |
Enter |
open article in w3m (falls back to $BROWSER/xdg-open if w3m isn't installed) |
x |
skip article (recolors, no keyword-learning behind it — see below) |
s |
save article (auto-marks as read) |
n |
edit note on a saved article |
r |
refresh all sources |
S |
view saved articles |
y |
view history (every article ever fetched) |
/ |
search titles/notes across the current view |
a |
add a new source |
E |
export every saved article into one combined Markdown file (Saved view only; regenerates the file fresh each press) |
q |
quit |
| State | Meaning | Notes |
|---|---|---|
| unread | default state, not yet interacted with | |
| read | opened, or saved (saving implies read) | |
| skipped | explicitly dismissed | purely visual/state — see below |
| saved | permanent until manually unsaved | supports an optional free-text note |
Saving auto-marks as read — these are not tracked as independent
booleans in practice; pressing s sets both saved = true, read = true
in one action.
x marks an article skipped: it's recolored (mauve, per the theme) and
that's it. This is deliberately simple and considered complete as-is
for v1 — it does exactly what's needed (get an uninteresting article out
of visual focus) without any further behavior behind it.
An earlier version of this doc described a keyword-weighting system where skipping an article would log its keywords and future similar articles would be auto-deprioritized. That's explicitly cut from v1 scope:
- The
skip_weightstable andStorage::increment_skip_weight/Storage::skip_weightmethods already exist from the initial schema, but are intentionally left unwired — not a bug, not unfinished, just unused for now. - A plain skip-and-recolor loop has proven sufficient through active use; keyword-scoring adds real complexity (extraction, stopword handling, scoring-and-resorting on every fetch) for a behavior that hasn't turned out to be missed.
- If this is revisited for v2, the existing schema and methods are already there to build on — nothing needs to be re-designed, just wired up.
Adding a new source is a config edit, not a code change (for anything with an RSS feed). Every source is assigned exactly one topic — not a list. This is a deliberate choice: a source's articles only ever live under one sidebar category, which keeps topic filtering, the skip-weighting system, and duplicate-article handling all working against a single unambiguous grouping instead of articles potentially appearing in more than one place:
[[source]]
name = "Phoronix"
type = "rss"
url = "https://www.phoronix.com/rss.php"
topic = "kernel"
[[source]]
name = "KrebsOnSecurity"
type = "rss"
url = "https://krebsonsecurity.com/feed/"
topic = "security"
[[source]]
name = "r/linux"
type = "reddit"
url = "linux"
topic = "distros"Topics themselves are not a fixed list anywhere in code — Storage::topics()
returns whatever distinct topic values exist across configured sources, so
the sidebar is fully driven by what the user has actually assigned. Adding
a brand-new category is just typing a new topic = "..." value on a source;
no code change required.
Current source list: 9to5Linux, Phoronix, It's FOSS, LinuxGamingNews, Linuxiac, LWN.net, OMG! Ubuntu (candidates to add: Arch Linux news, a dedicated CVE/security feed, KrebsOnSecurity).
All colors are config-driven — never hardcoded in the UI code — so any shade can be tweaked without touching Rust:
[theme]
background = "#24273a" # Catppuccin Macchiato — base
panel_border = "#363a4f" # surface0
text_primary = "#cad3f5" # text (unread)
text_muted = "#6e738d" # overlay0 (read)
text_dim = "#494d64" # surface1 (skipped)
accent_unread = "#f5a97f" # peach
accent_read = "#8bd5ca" # teal
accent_skipped = "#c6a0f6" # mauve
accent_saved = "#eed49f" # yellow
accent_breaking = "#ed8796" # red
accent_selected = "#8aadf4" # bluePalette: Catppuccin Macchiato (dark only — no light theme). Chosen for being colorful without being chaotic; every hue in the palette is desaturated to sit together cleanly. Mocha and Frappé remain available as easy drop-in swaps (same accent hues, different base/text shades) since the theme loader reads from this file rather than anything compiled in.
For v1, a single entry point: the in-app a keybind.
Deliberately simple — no HTML parsing, no feed autodiscovery. Paste the actual feed URL, tuxwire validates it by trying to parse it, that's the whole mechanism:
- Prompt for a feed URL directly (not the site's homepage) — most sites'
feed URLs are easy to find or guess (
/feed,/rss.xml,/feed.xml). - Validation via the existing parser, nothing new: tuxwire attempts to
fetch and parse the URL with
feed-rs, the same crate already used for every regular fetch. If it parses successfully, that is the proof it's a valid feed — no separate discovery logic needed. - On success, confirm screen: guessed name (from the feed's own
<title>metadata, editable), and a single required topic — pick from existing topics (queried live viaStorage::topics()) or type a new one. No source can be left without a topic. - On failure, show a clear error ("couldn't parse this as a feed — check the URL") and let the user retry or cancel.
- On confirm, write a new
[[source]]block intosources.tomlusing thetoml_editcrate rather than plaintoml—toml_editpreserves existing formatting/comments on write instead of rewriting the whole file.
This intentionally reuses the app's existing fetch/parse path as validation rather than adding HTML scraping or a fallback-path-guessing system — less code, fewer moving parts, and one less thing that can break independently of the rest of the app.
A phone-capture inbox (sharing a URL from Android straight into a pending list) was considered and deliberately deferred — see Roadmap.
struct Article {
id: i64,
title: String,
url: String,
source: String,
topic: String,
timestamp: DateTime<Utc>,
read: bool,
skipped: bool,
saved: bool,
note: Option<String>,
}tuxwire/
├── src/
│ ├── main.rs
│ ├── fetchers/ # one module per source type
│ ├── storage/ # SQLite access + migrations
│ ├── scoring.rs # reserved for v2 skip-weighting (unused in v1)
│ ├── theme.rs # theme.toml loading
│ └── ui/ # ratatui views/widgets
├── migrations/
│ └── 001_init.sql
├── docs/
│ ├── ARCHITECTURE.md # this file
│ └── ADDING_A_SOURCE.md
├── CHANGELOG.md # git-commit / changelog style, matches blog aesthetic
├── sources.toml.example
├── theme.toml.example
└── Cargo.toml
This is a hard requirement, not a nice-to-have. The person building this has no prior Rust experience and is learning the language through this codebase — by reading it, both on GitHub and locally. Code that isn't heavily commented isn't just unfriendly here, it defeats the actual purpose of the project.
- Every function, struct, and non-trivial block gets a doc comment
(
///) explaining not just what it does but why it's written this way — especially anything involving ownership, borrowing, lifetimes, traits, or async, since those are the concepts genuinely new to a newcomer. A one-line "gets the article title" comment on an obvious getter is not the goal; explaining why a&strvsStringwas chosen, or why aResultis being propagated with?instead of unwrapped, is. - Prefer comments that teach the underlying Rust concept over comments that just restate the code in English.
- Doc comments should be written so
cargo doc --openproduces something genuinely useful to read — treat this as building a personal Rust reference alongside the app itself, not just satisfying a linter. - When a non-obvious design decision is made (why this crate over an
alternative, why this pattern over a simpler one), it belongs either in
a comment at the point of use, or as a note in
ARCHITECTURE.mdif it's project-wide — not left unexplained. - Favor clarity over cleverness. Idiomatic Rust is the goal, but a very terse idiom that's opaque to a newcomer is worth a short comment explaining what it expands to conceptually.
These aren't v1, but are far enough along in design that they belong here rather than staying as loose chat ideas. Build order: v1 core first (fetchers → storage → TUI → notes), then these three, roughly in the order listed.
Exports saved articles as Markdown files, fitting directly into the existing Hugo/rclone blog pipeline — "things I want to try" can become a ready-made post skeleton.
- Triggered by the
Ekeybind from the Saved view — no-op fromView::Topic/View::History, since there's no guarantee the selected row there is even a saved article, and there's nothing selection- dependent about the export itself anyway (see below). - One combined file, not one file per article.
Ewrites every saved article into a singlesaved-articles.md, each article's block separated from the next by a Markdown thematic break (---on its own line). Simpler than a one-file-per-article design (no per-title filename to sanitize or disambiguate, no directory listing to manage) and a better fit for the actual use case — skimming or grepping the whole saved collection at once, or feeding it into the blog pipeline as one document, rather than opening files one at a time. - Regenerates the file from scratch every press, never appends.
Ealways re-derives the whole file from the current saved set (Storage::saved_articles), so it can never drift out of sync with an article being un-saved, re-noted, or removed since the last export — there's no "what's new since last time" delta to track, just "write what's saved right now." - Must include a link back to the original article — the note is
context, not a replacement for the source. Uses the real
saved_at(andnoted_at, if the note was added/edited after saving) timestamps — not the article's publish date:
## Btrfs send/receive got noticeably faster in 6.18
Source: [r/linux](https://reddit.com/r/linux/...) — saved 2026-08-12, noted 2026-08-14
> your note goes here, verbatim from the `note` column
---
## Another saved article
Source: [Phoronix](https://phoronix.com/...) — saved 2026-08-10(A saved article with no note simply has no > line at all — nothing
to quote.) Every subsequent article's block is separated from the one
before it by a --- on its own line, so the combined file still reads
as a sequence of distinct entries rather than one run-on document.
- Output location: config-driven, no per-export prompt. Consistent
with the rest of the app (
sources.toml,theme.toml— nothing else interactively prompts for a path), export writes to a default directory set in a newexport.toml:
[export]
path = "~/tuxwire-notes/"~/tuxwire-notes/ (i.e. inside the person's own home directory, not
/home/ directly — that top-level path needs root to write to on most
systems) is the shipped default. rclone isn't set up yet, so this isn't
pointed at a synced folder for now — easy to repoint at one later by
editing this single line once rclone is in place. Fully user-editable
by design: anyone who wants a different location just edits this file,
same as customizing sources or theme. No interactive file-save dialog
— not a natural TUI pattern and unnecessary for something that doesn't
change often. Documented in the README's Configuration section so it's
discoverable without reading the source.
- Filename is fixed, not derived from any article title — always
saved-articles.mdunder that directory. Combining every saved article into one file removes the need to sanitize a title for filesystem safety or disambiguate two articles that would otherwise collide on the same name — problems that only existed in the one-file-per-article design this replaced. - A saved-without-a-note article omits the
>blockquote entirely rather than emitting an empty one — nothing to quote.
Notes are currently write-and-forget — saved, but nothing resurfaces them or makes them easy to find later beyond scrolling the Saved view. Two small, low-complexity additions close that gap without a new subsystem:
Schema additions:
saved_at, set the momentsis pressed. The existingtimestampcolumn is the article's publish date, not when the person saved it — those are genuinely different facts, and export/retrieval both want the latter.noted_at, set wheneverupdate_notestores a non-empty note (i.e. every timenis confirmed withEnterand the note isn't empty) — distinct fromsaved_at, since a note can be written or edited well after the article was originally saved. If the note is cleared back to empty,noted_atclears too (None), keeping "no note" and "no note date" consistent with each other. Small migration, not a redesign — same shape as thesaved_ataddition.
No new dedicated "notes view" needed. The existing Saved view (S)
already covers this once it's given two small additions:
- Show source name and
saved_atdate under each title in the list (currently only shows the truncated note preview); ifnoted_atdiffers meaningfully fromsaved_at(note added/edited after the initial save), show that too rather than only the save date - The existing
npopup already functions as "read the full note" — since it opens pre-filled with the complete text andEscleaves it untouched, no separate read-only view is needed - Opening the article (
Enter) already provides the link-back
History view (y keybind): a dedicated view of every article ever
fetched, regardless of read/saved/skipped state — nothing in tuxwire
deletes articles today, so this data already exists, this just exposes
it. Solves "I read something a few days ago, didn't save it, and want it
back" without requiring the person to have remembered to save it in the
moment. Same list rendering as Topic/Saved views, reusing existing
navigation (j/k, Enter, n, s, x all work the same way here).
Search (/ keybind, plain substring match, not full-text search):
"Keyword search" and "full-text search" aren't the same thing — full-text
search (SQLite's FTS5 extension) adds ranking, stemming, and fuzzy
matching, but requires a separate virtual table kept in sync with
articles via triggers — a real extra subsystem. At the scale of one
person's article collection (realistically hundreds, not thousands), a
plain LIKE '%term%' substring match across title + note is
proportionate and sufficient. Searches across all articles, not just
saved ones — this is what actually answers "find that thing I read a
few days ago," not a separate log/tracking table. Live-filters whichever
view is currently open (Topic, Saved, or History) as the person types;
Esc clears the search and restores the full list. Revisit FTS5 only if
the collection ever grows large enough that relevance ranking genuinely
starts to matter — unlikely for a personal tool at this scale.
Considered and deliberately not built: a separate reaction/interaction log (a table recording every read/skip/save event over time, auto- pruned after 30 days). Unnecessary complexity for what's actually needed — nothing currently deletes articles, so the History view + widened search already solves the real use case (finding a past article) without a second data model to maintain. A pruning policy is still worth having eventually, just framed differently: unsaved articles could be cleaned up after some age to keep the database from growing forever, while saved articles are never pruned (same permanence rule already established for saved state) — a small housekeeping feature, separate from retrieval, and not urgent.
Deferred, not v1.1: resurfacing old saved articles back to the person periodically (a lightweight "things you meant to try" nudge, distinct from search since it's proactive rather than something the person has to remember to go look for). Genuinely novel — most readers, terminal or GUI, treat "saved" as a one-way archive. Worth building once retrieval and search themselves are solid and have seen real use.
A lightweight self-hosted-analytics-style view, in the same spirit as the GoatCounter dashboards and the homelab dashboard already tracking the blogs and desktop stats. Explicitly the next build priority among the remaining near-term features.
- Articles read per day/week, broken down by topic and by source
- Top sources by volume, most-skipped articles/topics (simple counts — not dependent on the deferred skip-weighting system)
- Could be a dedicated ratatui view with simple bar/sparkline widgets, or even just a formatted text summary to start — doesn't need to be visually elaborate to be useful
Considered for solving spotty cell service at work, but decided against —
same category of cut as ntfy phone-capture and in-app highlighting: real
complexity (a new content column, an HTML-to-text extraction step,
pruning policy for cached-but-unsaved content) for a problem that turned
out not to be worth solving this way. Not pursuing.
Explicitly not started. We're still in the build phase — the app should be in a clean, polished, feature-complete state we're happy with before any packaging work begins, so there's room to tweak and change things without also having to update a published package every time. This section exists so the plan doesn't rely on memory once that time comes, not as a current task.
Tier 1 — Linux package managers (approachable, well-trodden path):
- AUR — the natural first target, given the desktop is CachyOS/Arch-
based already. A
PKGBUILDdescribing fetch/build/install for a Rust binary is a short, well-documented pattern. - COPR (Fedora) — similar effort tier, RPM
.specfile instead of aPKGBUILD. Less familiar syntax, same shape of task. - Further out, similarly scoped: a Nix flake, a Homebrew tap.
Tier 2 — Termux packages: getting tuxwire into termux-packages
(a build-recipe PR) so pkg install tuxwire works directly, rather than
requiring a manual clone-and-build on Android.
Tier 3 — native Android app (F-Droid): a genuinely separate project,
not a packaging step — extracting fetchers/storage/scoring into a shared
Rust core (uniffi-rs) and building a real Kotlin/Jetpack Compose UI
around it, since F-Droid distributes actual Android app packages (APK,
manifest, Activity), not CLI binaries running inside Termux. Realistically
weeks-to-months of casual evening work, several genuinely new skill areas
(Kotlin, Compose, Android's build toolchain, cross-compiling via
uniffi-rs). A long-term "someday" chapter, not a near-term target.
Packaging niceties already decided, ready for whenever Tier 1 starts:
w3m should be listed as an optdepends (AUR) / Recommends (COPR) —
optional, not required, surfaced to the person at install time by the
package manager itself rather than an in-app first-run popup (deliberately
not building the latter — anyone capable of building this from source can
read one line in the README about it, same reasoning as the cuts above).
Deferred until the rest of v1 + near-term features are solid:
- Phone capture → ntfy inbox. Would reuse the existing ntfy
phone→desktop pipeline (same pattern as the notification scratchpad) so
sharing a URL from Android drops it into a
pending_sourcestable for later review. Deliberately cut from v1 scope: it's a full extra subsystem (polling, auth/tunnel-down handling, a second review UI) for a convenience that manual capture-and-paste already covers. Notes and the stats view are a better use of time right now. Revisit once the core app (including notes + stats) is genuinely daily-usable.
Currently in exploration on the terminal-browser-integration branch —
not merged into master until it's genuinely liked:
- In-terminal article reading. Two very different approaches under
consideration, worth stating separately since they're different sized
problems, not two flavors of the same one:
- Option A (starting point):
Entersuspends tuxwire's screen, spawns a terminal browser (w3mfirst choice — good rendering, common, scriptable) full-screen pointed at the article URL, and resumes tuxwire exactly where it left off on quit. Same suspend/resume pattern already used for shelling out to$EDITORon notes — genuinely adapting existing plumbing to a new target program, not new architecture. Not true side-by-side; it's a full takeover, same as the current$BROWSERbehavior just rendered in-terminal instead of a GUI browser. - Option B (stretch goal, only if A feels unsatisfying): a true
split pane — sidebar/article list still visible while the browser
renders in an adjacent region. This needs an embedded pty inside a
ratatui widget (the
tui-termcrate, built onvt100to interpret the spawned program's escape codes) — a real, working approach but genuinely new complexity: a new dependency, coordinating resize events between two "terminals," and routing keystrokes to the right pane without tuxwire's own nav keys colliding with the browser's. Treat as its own dedicated effort, not a quick add. - No offline-caching/content-extraction work needed for either option — unlike the earlier in-app-reading idea, a terminal browser handles its own fetching and rendering, so this sidesteps that whole subsystem.
- Option A (starting point):
Other open questions:
- Multi-line vs. single-line notes UI — inline popup (
tui-textarea) vs. shelling out to$EDITORfor real Vim; likely support both, config-toggled - Whether the "Saved" view is a pseudo-topic in the sidebar or a separate pane
- Custom fetchers for any source without RSS
- Possible future: syncing the SQLite db via rclone across machines
- Offline caching strategy: cache-on-fetch vs. cache-on-save/open
- Export location/config for Markdown export — likely an
export.toml - Duplicate article detection across sources (same story, multiple sites — note: with the one-topic-per-source rule now settled, this is scoped to duplicates within a topic, not cross-topic, which simplifies it)
- A
:command mode as an alternative to single-key binds for less-common actions
This is a living document — treat every future feature decision as an edit here first, code second.