The next side project, open-source contribution, or dataset worth exploring is out there — scattered across browser tabs you never get around to checking.
radar is a keyboard-driven terminal feed aggregator that pulls Reddit, GitHub Issues, Kaggle datasets, and RSS/Atom feeds into one distraction-free TUI. No ads, no algorithmic ranking, no tab switching — just the signal you configured, arriving on its own schedule.
radar polls each source concurrently on a configurable interval and persists items in a local SQLite database. Read/unread state survives across sessions and startup is instant — no waiting for network fetches on launch.
Sources can be added or removed directly from the TUI. Changes take effect immediately without restarting.
- Five built-in source types — Reddit (public RSS, no key required), GitHub Issues & PRs (REST API v3), Kaggle datasets (API v1), The Hacker News, and any RSS/Atom feed
- Concurrent polling — each source runs in its own goroutine with an independent schedule; one slow or failing source never blocks others
- Persistent read/unread state — items are stored in a local SQLite database; an unread badge in the header updates in real time
- Bookmarks — press
bfrom the list or detail view to save an item for later; bookmarked items show a★prefix and are collected in a dedicated Saved tab - Source tab bar — switch between a unified "All" view and per-source tabs with
←/→ - Incremental title search — press
/and type; results filter as you go - Detail view — full summary, URL, tags, and source metadata; press
oto open in the system browser - In-TUI source management — press
ato open the Sources screen: browse existing sources, delete them, or add new ones through a guided form, all without touching a config file - Everforest dark theme — consistent palette via lipgloss; renders cleanly in any 256-color terminal
- No CGO — the SQLite driver is pure Go (
modernc.org/sqlite); installs with a singlego installon any platform
Requires Go 1.25 or later.
go install github.com/matzalazar/radar/cmd/radar@latestOr build from source:
git clone https://github.com/matzalazar/radar.git
cd radar
make build # produces ./radar
# or: go build -o radar ./cmd/radarRun radar with no arguments. If no config file is found, radar starts with an empty source list and a prompt to add your first source.
radarPress a to open the Sources screen and add your first source through the guided form. The source starts polling immediately — no restart needed.
radar searches for its configuration file in the following order, stopping at the first match:
| Priority | Path |
|---|---|
| 1 | $RADAR_CONFIG (environment variable) |
| 2 | ./radar.toml (working directory) |
| 3 | $XDG_CONFIG_HOME/radar/radar.toml |
| 4 | ~/.config/radar/radar.toml |
A full annotated example is provided in radar.toml.example. Copy it to one of the paths above and edit as needed.
[general]
poll_on_startup = true # fetch all sources immediately on launch
db_path = "~/.local/share/radar/radar.db" # SQLite database path; ~ is expandedEach [[sources]] block defines one data source. All source types share three common fields:
| Field | Required | Description |
|---|---|---|
type |
✓ | Source type: reddit, github_issues, kaggle, or rss |
name |
✓ | Human-readable label shown in the TUI |
poll_interval |
✓ | Fetch frequency as a Go duration string: "15m", "1h", "6h", "24h" |
tags |
— | List of strings attached to every item from this source |
Fetches new posts from a subreddit using the public RSS endpoint. No API key required.
[[sources]]
type = "reddit"
name = "r/golang"
subreddit = "golang"
poll_interval = "30m"
tags = ["go"]| Field | Required | Description |
|---|---|---|
subreddit |
✓ | Subreddit name without /r/ |
Fetches open issues (and pull requests, which GitHub exposes through the same endpoint) from any public or private repository using the REST API v3.
[[sources]]
type = "github_issues"
name = "Bubble Tea"
owner = "charmbracelet"
repo = "bubbletea"
labels = ["help wanted", "good first issue"]
poll_interval = "1h"
tags = ["go", "oss"]
# token = "ghp_yourPersonalAccessTokenHere"| Field | Required | Description |
|---|---|---|
owner |
✓ | GitHub username or organisation |
repo |
✓ | Repository name |
labels |
— | Filter to issues carrying all listed labels |
token |
— | Personal access token — raises the rate limit from 60 to 5,000 requests/hour and grants access to private repos |
Security note: if you set
token, restrict the config file's permissions (chmod 600 ~/.config/radar/radar.toml) and never commit it to version control.
Fetches datasets from the Kaggle API v1, sorted by "hottest". Requires a free Kaggle account.
[[sources]]
type = "kaggle"
name = "NLP datasets"
search = "nlp text classification"
poll_interval = "6h"
tags = ["ml", "nlp"]| Field | Required | Description |
|---|---|---|
search |
— | Free-text query to filter datasets |
username |
— | Kaggle username (see credential resolution below) |
key |
— | Kaggle API key (see credential resolution below) |
Credential resolution — radar tries the following in order, stopping at the first complete pair:
usernameandkeyfields in the[[sources]]blockKAGGLE_USERNAMEandKAGGLE_KEYenvironment variables~/.kaggle/kaggle.json— the standard file created by the Kaggle CLI
To set up ~/.kaggle/kaggle.json: sign in to kaggle.com, go to Settings → API → Create New Token, then:
mkdir -p ~/.kaggle
mv ~/Downloads/kaggle.json ~/.kaggle/kaggle.json
chmod 600 ~/.kaggle/kaggle.jsonIf credentials cannot be resolved, the Kaggle source is skipped at startup with a warning; all other sources continue normally.
Fetches items from any standard RSS 2.0 or Atom 1.0 feed.
[[sources]]
type = "rss"
name = "The Go Blog"
url = "https://go.dev/blog/feed.atom"
poll_interval = "1h"| Field | Required | Description |
|---|---|---|
url |
✓ | Full URL of the RSS or Atom feed |
| Key | Action |
|---|---|
j / ↓ |
Move cursor down |
k / ↑ |
Move cursor up |
Enter |
Open detail view and mark item as read |
b |
Toggle bookmark (★ indicator; appears in the Saved tab) |
← / → |
Switch source tabs (including the Saved tab) |
/ |
Start incremental title search |
a |
Open Sources screen (add / delete sources) |
s |
Open Settings screen (reset database) |
r |
Manually refresh all sources now |
q / Ctrl+C |
Quit |
| Key | Action |
|---|---|
b |
Toggle bookmark |
o |
Open item URL in the system browser |
Esc / Backspace |
Return to list |
q / Ctrl+C |
Quit |
| Key | Action |
|---|---|
j / k |
Move cursor |
Enter (on "Add new source") |
Open source type wizard |
d |
Delete highlighted source (prompts for confirmation) |
y / n |
Confirm or cancel deletion |
Esc / q |
Return to list |
| Key | Action |
|---|---|
j / k |
Move cursor |
Enter |
Select source type and open the field form |
Esc / q |
Return to Sources screen |
| Key | Action |
|---|---|
Tab / ↓ |
Next field |
Shift+Tab / ↑ |
Previous field |
Ctrl+S |
Save — source is written to config and starts polling immediately |
Esc |
Return to type selection |
| Key | Action |
|---|---|
j / k |
Move cursor |
Enter |
Select action |
d / Delete |
Delete highlighted source |
y / n |
Confirm or cancel |
Esc / q |
Return to list |
radar is designed so that new source types can be added by implementing a single interface and wiring it in four places.
1. Implement core.Source in a new file under internal/sources/:
type MySource struct { /* ... */ }
func (s *MySource) ID() string { return "mysource:" + s.query }
func (s *MySource) Name() string { return s.name }
func (s *MySource) PollInterval() time.Duration { return s.pollInterval }
func (s *MySource) Fetch(ctx context.Context) ([]core.Item, error) { /* ... */ }2. Register it in the factory (internal/sources/factory.go):
// In BuildSource:
case "mysource":
return NewMySource(cfg.Name, cfg.URL, cfg.PollInterval.Duration, cfg.Tags, nil), nil
// In SourceIDFor:
case "mysource":
return "mysource:" + cfg.URL3. Add config validation (config/config.go, inside validate()):
case "mysource":
if cfg.URL == "" {
return fmt.Errorf("config: source[%d] (%s): mysource requires url", i, cfg.Name)
}4. Add the TUI form (internal/tui/views/setup.go):
// In AvailableSourceTypes:
{"mysource", "My Source", "Short description"},
// In FormFieldsFor:
case "mysource":
return []FormField{
{Label: "Name", Required: true},
{Label: "URL", Required: true},
{Label: "Poll interval", Value: "1h"},
}That's it — no other changes required. The scheduler, database layer, and TUI list/detail views handle the new type automatically.
cmd/radar/main.go
│
├── config.Load() — parses radar.toml (BurntSushi/toml)
│
├── core.OpenStore() — opens SQLite (modernc.org/sqlite, pure Go)
│ tables: items, sources_state
│
├── sources.BuildSources() — one Source per [[sources]] block
│ │
│ ├── RedditSource public RSS, no auth
│ ├── GitHubIssuesSource REST API v3, optional token
│ ├── KaggleSource API v1, basic auth
│ └── RSSSource generic RSS/Atom (mmcdole/gofeed)
│
├── core.NewScheduler() — one goroutine per source
│ │ context-cancellable, WaitGroup-tracked
│ └── program.Send(FetchResultMsg) ──► Bubble Tea event loop
│
└── tui.New() — Bubble Tea model (Update / View / Init)
│
├── views/list.go item rows with relative timestamps
├── views/detail.go per-source rendering (Markdown/HTML strip)
├── views/setup.go Sources manager + add-source wizard
├── views/settings.go Settings screen (reset DB)
└── styles/theme.go Everforest lipgloss palette
Design decisions:
- Pure-Go SQLite — no CGO, no system libraries;
SetMaxOpenConns(1)respects SQLite's single-writer model; schema applied withCREATE TABLE IF NOT EXISTSon every open so the database self-initializes. - Deterministic item IDs —
sha256(sourceID + "\x00" + externalID)[:8]encoded as hex; enablesINSERT OR IGNOREdeduplication without requiring external IDs to be globally unique. - Per-source goroutines — each source gets its own
context.CancelFunc; sources added at runtime start a new goroutine immediately, removed sources have their context cancelled;scheduler.Stop()waits on async.WaitGroup. - Unidirectional message flow — the scheduler is entirely external to the TUI model; it delivers results through
program.Send(FetchResultMsg{...}), enqueuing into Bubble Tea's event loop with no shared mutable state between goroutines and the model. - Custom
TomlDuration— implementsencoding.TextUnmarshalerso Go duration strings ("1h30m","15m") appear as plain TOML string values without any special syntax.
| Component | Library |
|---|---|
| Language | Go 1.25+ |
| TUI framework | Bubble Tea v1.3 |
| Terminal styling | lipgloss v1.1 |
| List / spinner components | bubbles v1.0 |
| Config format | BurntSushi/toml v1.6 |
| Database | modernc.org/sqlite v1.48 (pure Go, no CGO) |
| RSS/Atom parsing | mmcdole/gofeed v1.3 |
make build # compile to ./radar
make test # run all tests
make vet # go vet
make lint # golangci-lint (requires golangci-lint installed)
make coverage # open HTML coverage reportTests cover the config parser, the SQLite store, and the source factory. Run with -race in CI.
MIT — see LICENSE.
