Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agent/memory/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Auto-grown list of memory entries. Agents append after each task that produced n
- [2026-05-21-claude-code-stats-cache-stale](facts/2026-05-21-claude-code-stats-cache-stale.md) — `stats-cache.json` is a stale periodic rollup; live token data lives in per-session JSONL files under `projects/`
- [2026-05-21-claude-usage-display-format](facts/2026-05-21-claude-usage-display-format.md) — Exact fields, computation logic, and date-range strategy of `claude /usage`; total tokens = input+output only (cache excluded)
- [2026-09-23-gpui-keymap-dispatch](facts/2026-09-23-gpui-keymap-dispatch.md) — GPUI bindings need a focused root element; same-node contexts tie on depth so insertion order decides; `NoAction` masks lower contexts
- [2026-09-24-state-json-shared-writers](facts/2026-09-24-state-json-shared-writers.md) — `state.json` has several writers (tray loop, startup, modal, CLI); update one field read-modify-write; app-recorded facts go there, not config

## patterns/

Expand Down
30 changes: 30 additions & 0 deletions .agent/memory/facts/2026-09-24-state-json-shared-writers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
title: state.json has several writers — update it read-modify-write
status: current
version: 0.1.0
last_updated: 2026-09-24
last_verified: 2026-09-24
source_refs:
- crates/aura-core/src/state.rs
- crates/aura-core/src/sponsor.rs
- crates/aura/src/main.rs
- crates/aura/src/app.rs
owner: "@rfluid"
tags: [memory, fact, state]
source_task: one-time sponsor nudge (docs/configuration.md#sponsor)
---

# state.json has several writers

- `AppState` (`~/.local/share/aura/state.json`) is written by the tray loop
(`modal_height`, while the modal is open), the tray startup (`first_run`),
the modal view (`active_profile`, `sponsor_nudge_done`), and the `aura state`
CLI. The view's copy is loaded once per open, so saving it wholesale can undo
a write made since. New writers should `AppState::load()`, set their one
field, and `save()` — see `main.rs` (modal height, first run) and
`AuraView::finish_sponsor_nudge`.
- App-recorded facts (timestamps, "already answered" flags) belong in
state.json; `config.toml` holds only user settings. The update chip's
`update.dismissed_version` predates this split.
- Every new `AppState` field needs `#[serde(default)]`: a state file from an
older build must still load (`a_state_file_without_a_modal_height_still_loads`).
50 changes: 50 additions & 0 deletions .design/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,56 @@ A small clickable pill containing the agent icon + agent name.
- Settings cog `⚙`: `COLOR_TEXT_DIM`, opens config via `xdg-open`/`$EDITOR`.
- Title `Aura ⟳`: `COLOR_ACCENT`. Clickable; triggers `refresh()`.

## Sponsor nudge

**Renderer**: `AuraView::render_sponsor_nudge` — `app.rs:1722`; dismiss
handler `AuraView::dismiss_sponsor_nudge` — `app.rs:912`.

One-time card between the header and the selector row, shown a week after the
first run until the user closes it with its × (gating:
`aura_core::sponsor`). Warm but calm: a faint accent *wash*, not a filled
banner, so it reads as information rather than an alert. Every color is
derived from theme tokens with `Theme::blend`, so custom `theme.toml` palettes
(light ones included) stay coherent — no literals.

- Strip: `px_4 py_2`, border-bottom `border_1` / `COLOR_BORDER`, `flex_shrink_0`
- Card: `flex_col`, padding `px_3 py_3`, gap `gap_2`, radius `rounded_md`,
`border_1`
- Header row (`items_center`, `gap_2`): 14px `heart.svg` in `COLOR_ACCENT` ·
title `text_sm` / `COLOR_TEXT`, `flex_1` (lexicon `sponsor_nudge_title`) ·
trailing `icon_button` with `close.svg` (20×20 hit area, 14px icon)
- Body: `text_xs` / `COLOR_TEXT_DIM` (lexicon `sponsor_nudge`)
- Actions row: `flex_wrap`, `gap_2`, `mt_1`. Buttons are `px_2 py_1`,
`rounded_md`, `text_xs`, icon 12px with `gap_1p5`
- Weight: regular (the app uses no font weights — see `tokens.md`); the
title's emphasis comes from `text_sm` + `COLOR_TEXT` against the dim body.

| Element | Bg / border (rest) | Text / icon | Hover |
| ---------------- | ------------------------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------- |
| Card | bg `blend(ACCENT, BG, 0.9)`, border `blend(ACCENT, BG, 0.7)` | — | — |
| × (dismiss) | none | icon `COLOR_TEXT_DIM` | bg `blend(ACCENT, BG, 0.8)` |
| Sponsor on GitHub| bg `blend(ACCENT, BG, 0.15)` | `on_accent_text(ACCENT)`, leading `github.svg` same | bg `COLOR_ACCENT` (brightens — never a downgrade) |
| Pix (BRL) | none, border `blend(ACCENT, BG, 0.55)` | `COLOR_TEXT`, trailing `arrow_up_right.svg` `TEXT_DIM` | bg `blend(ACCENT, BG, 0.8)`, border `blend(ACCENT, BG, 0.3)` |

| Click target | Opens | Then |
| ----------------- | ------------------------------ | -------------------------------------- |
| Sponsor on GitHub | `sponsor::SPONSOR_URL` | card stays up |
| Pix (BRL) | `sponsor::PIX_URL` | card stays up |
| × | nothing | `sponsor_nudge_done = true` (persisted)|

Notes:

- The sponsor buttons deliberately leave the card up: someone who paid via Pix
may still want to set up a GitHub sponsorship, or vice versa. Only the ×
retires it.

- The Pix label is plain text, not the 🇧🇷 flag: regional-indicator flags
need a color-emoji font plus ligature shaping, which the monospace stack
does not guarantee, so the flag could render as two boxed letters.
- No `cursor_pointer`: no other click target in the app sets one, so the card
follows suit.
- The body does not quote a spend figure: Aura tracks tokens, not cost.

## Period row

**Renderer**: `AuraView::render_period_row` — `app.rs:298-336`.
Expand Down
2 changes: 2 additions & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
github: Rfluid
custom: ["https://livepix.gg/rfluid"]
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,13 @@ Later

## Sponsor

See [SPONSOR.md](./SPONSOR.md) for ways to support Aura.
See [SPONSOR.md](./SPONSOR.md) for ways to support Aura, or sponsor on
[GitHub Sponsors](https://github.com/sponsors/Rfluid).

A week after first launch, Aura shows a one-time card in the modal asking you
to consider sponsoring. The sponsor links (GitHub Sponsors or Pix) leave it
open; only the card's × retires it for good. To never see it, run
`aura config set sponsor.nudge false`.

## Contributing

Expand Down
9 changes: 9 additions & 0 deletions SPONSOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
Aura is a hobby project I share freely. If it's useful to you and you'd like
to chip in, here's how — no recurring commitment, no expectations.

## GitHub Sponsors

Monthly or one-time, by card or PayPal:
**[github.com/sponsors/Rfluid](https://github.com/sponsors/Rfluid)**

## Pix (BRL)

**[livepix.gg/rfluid](https://livepix.gg/rfluid)**

## Bitcoin

```
Expand Down
3 changes: 3 additions & 0 deletions assets/icons/heart.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions crates/aura-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,26 @@ impl Default for KeybindingsConfig {
}
}

// ── Sponsor ──────────────────────────────────────────────────────────────────

/// Controls the one-time "consider sponsoring" card. When it shows and whether
/// it has been answered are tracked in `state.json` (see [`crate::sponsor`]);
/// this section only holds the opt-out.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct SponsorConfig {
/// Show the sponsor card once, a week after the first run. Default true.
/// Set false to never show it.
#[serde(default = "default_true")]
pub nudge: bool,
}

impl Default for SponsorConfig {
fn default() -> Self {
Self { nudge: true }
}
}

// ── AppConfig ─────────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
Expand All @@ -480,6 +500,8 @@ pub struct AppConfig {
pub update: UpdateConfig,
#[serde(default)]
pub keybindings: KeybindingsConfig,
#[serde(default)]
pub sponsor: SponsorConfig,
}

impl AppConfig {
Expand Down Expand Up @@ -550,6 +572,7 @@ impl AppConfig {
content: ContentConfig::default(),
update: UpdateConfig::default(),
keybindings: KeybindingsConfig::default(),
sponsor: SponsorConfig::default(),
}
}

Expand Down Expand Up @@ -821,6 +844,7 @@ mod tests {
},
update: UpdateConfig::default(),
keybindings: KeybindingsConfig::default(),
sponsor: SponsorConfig::default(),
};
cfg.apply_plugin_order();
let names: Vec<&str> = cfg.plugins.iter().map(|p| p.name.as_str()).collect();
Expand All @@ -841,6 +865,7 @@ mod tests {
},
update: UpdateConfig::default(),
keybindings: KeybindingsConfig::default(),
sponsor: SponsorConfig::default(),
};
cfg.apply_plugin_order();
let names: Vec<&str> = cfg.plugins.iter().map(|p| p.name.as_str()).collect();
Expand All @@ -857,6 +882,7 @@ mod tests {
content: ContentConfig::default(),
update: UpdateConfig::default(),
keybindings: KeybindingsConfig::default(),
sponsor: SponsorConfig::default(),
};
cfg.apply_plugin_order();
let names: Vec<&str> = cfg.plugins.iter().map(|p| p.name.as_str()).collect();
Expand Down Expand Up @@ -939,6 +965,7 @@ dismiss_all = true
content: ContentConfig::default(),
update: UpdateConfig::default(),
keybindings: KeybindingsConfig::default(),
sponsor: SponsorConfig::default(),
};

let added = cfg.merge_agents(vec![
Expand Down Expand Up @@ -989,6 +1016,7 @@ dismiss_all = true
content: ContentConfig::default(),
update: UpdateConfig::default(),
keybindings: KeybindingsConfig::default(),
sponsor: SponsorConfig::default(),
};

let added = cfg.merge_agents(vec![AgentConfig {
Expand Down
33 changes: 29 additions & 4 deletions crates/aura-core/src/config_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//! - `aura config wizard`
//!
//! Every settable scalar field under `[window]` / `[tray]` / `[content]` /
//! `[update]` / `[keybindings]` has a [`FieldDescriptor`] here. A unit test
//! `[update]` / `[keybindings]` / `[sponsor]` has a [`FieldDescriptor`] here. A unit test
//! (`registry_covers_every_field`) serializes a default config and asserts
//! each leaf key is described, so adding a struct field without documenting
//! it breaks the build.
Expand Down Expand Up @@ -60,7 +60,14 @@ pub struct SectionField {
/// The scalar `[section]`s of the config, in template-emission order. The
/// repeatable `[[agents]]` / `[[plugins]]` tables are not here — they are
/// documented by [`agent_fields`] / [`plugin_fields`] and edited elsewhere.
pub const SECTIONS: &[&str] = &["window", "tray", "content", "update", "keybindings"];
pub const SECTIONS: &[&str] = &[
"window",
"tray",
"content",
"update",
"keybindings",
"sponsor",
];

/// All settable scalar fields, in template-emission order ([`SECTIONS`]).
pub fn fields() -> &'static [FieldDescriptor] {
Expand Down Expand Up @@ -303,6 +310,19 @@ pub fn fields() -> &'static [FieldDescriptor] {
keybindings.toml.",
example: "true",
},
// ── [sponsor] ──
FieldDescriptor {
key: "sponsor.nudge",
type_label: "bool",
allowed: &["true", "false"],
default: "true",
summary: "Show the one-time sponsor card a week after the first run.",
description: "Show a small, dismissible \"consider sponsoring\" card in the modal \
once, seven days after Aura first ran. The sponsor links leave it open; only its × retires it \
for good (recorded in state.json, not here). Default true. Set false to never \
show it.",
example: "false",
},
]
}

Expand Down Expand Up @@ -513,6 +533,7 @@ pub fn get_value(cfg: &AppConfig, key: &str) -> Result<String, SchemaError> {
.unwrap_or_else(|| "(unset)".to_string()),
"update.dismiss_all" => cfg.update.dismiss_all.to_string(),
"keybindings.enabled" => cfg.keybindings.enabled.to_string(),
"sponsor.nudge" => cfg.sponsor.nudge.to_string(),
_ => return Err(unknown_key(key)),
};
Ok(v)
Expand Down Expand Up @@ -547,6 +568,7 @@ pub fn set_value(cfg: &mut AppConfig, key: &str, raw: &str) -> Result<(), Schema
"update.dismissed_version" => cfg.update.dismissed_version = parse_opt_string(raw),
"update.dismiss_all" => cfg.update.dismiss_all = parse_bool(key, raw)?,
"keybindings.enabled" => cfg.keybindings.enabled = parse_bool(key, raw)?,
"sponsor.nudge" => cfg.sponsor.nudge = parse_bool(key, raw)?,
_ => return Err(unknown_key(key)),
}
Ok(())
Expand Down Expand Up @@ -722,6 +744,7 @@ fn toml_rhs(cfg: &AppConfig, key: &str) -> Option<String> {
"update.dismissed_version" => return cfg.update.dismissed_version.as_deref().map(quote),
"update.dismiss_all" => cfg.update.dismiss_all.to_string(),
"keybindings.enabled" => cfg.keybindings.enabled.to_string(),
"sponsor.nudge" => cfg.sponsor.nudge.to_string(),
_ => return None,
})
}
Expand Down Expand Up @@ -782,8 +805,8 @@ fn wrap_text(text: &str, width: usize) -> Vec<String> {
mod tests {
use super::*;
use crate::config::{
AgentConfig, AgentKind, ContentConfig, KeybindingsConfig, PluginConfig, TrayConfig,
UpdateConfig, WindowConfig,
AgentConfig, AgentKind, ContentConfig, KeybindingsConfig, PluginConfig, SponsorConfig,
TrayConfig, UpdateConfig, WindowConfig,
};

/// Walk a serialized default config and assert every leaf key under each
Expand Down Expand Up @@ -929,6 +952,7 @@ mod tests {
assert_eq!(parsed.content, cfg.content);
assert_eq!(parsed.update, cfg.update);
assert_eq!(parsed.keybindings, cfg.keybindings);
assert_eq!(parsed.sponsor, cfg.sponsor);
}

#[test]
Expand Down Expand Up @@ -980,6 +1004,7 @@ mod tests {
dismiss_all: true,
},
keybindings: KeybindingsConfig { enabled: false },
sponsor: SponsorConfig { nudge: false },
};
assert_round_trips(&cfg);
}
Expand Down
38 changes: 38 additions & 0 deletions crates/aura-core/src/lexicon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ pub struct Lexicon {
/// end is part of the persona — keep or replace as you see fit.
pub update_available_fmt: fn(latest_version: &str) -> String,

// ── Sponsor nudge ───────────────────────────────────────────────────────
/// Header line of the one-time sponsor card (see `crate::sponsor`).
pub sponsor_nudge_title: &'static str,
/// Body of the sponsor card.
pub sponsor_nudge: &'static str,
/// Primary button: opens the GitHub Sponsors page.
pub sponsor_nudge_github: &'static str,
/// Secondary button: opens the Pix (BRL) tip page. The card's trailing ×
/// is an icon, so it has no string.
pub sponsor_nudge_pix: &'static str,

// ── Period pills ────────────────────────────────────────────────────────
pub period_all: &'static str,
pub period_7d: &'static str,
Expand Down Expand Up @@ -126,6 +137,12 @@ pub const POLITE: Lexicon = Lexicon {

update_available_fmt: polite_update_available,

sponsor_nudge_title: "Enjoying Aura?",
sponsor_nudge:
"Aura is free and built by one person. If it's been useful, consider sponsoring.",
sponsor_nudge_github: "Sponsor on GitHub",
sponsor_nudge_pix: "Pix (BRL)",

period_all: "All time",
period_7d: "Last 7 days",
period_30d: "Last 30 days",
Expand Down Expand Up @@ -163,6 +180,11 @@ pub const GOBLIN: Lexicon = Lexicon {

update_available_fmt: goblin_update_available,

sponsor_nudge_title: "Still here, huh?",
sponsor_nudge: "One person built this for free. If it's saved your butt, throw them a coin.",
sponsor_nudge_github: "Cough it up on GitHub",
sponsor_nudge_pix: "Pix it (BRL)",

period_all: "the whole damn time",
period_7d: "last week",
period_30d: "this month-ish",
Expand Down Expand Up @@ -240,6 +262,22 @@ mod tests {
GOBLIN.menu_open_config,
),
("menu_themes", POLITE.menu_themes, GOBLIN.menu_themes),
(
"sponsor_nudge_title",
POLITE.sponsor_nudge_title,
GOBLIN.sponsor_nudge_title,
),
("sponsor_nudge", POLITE.sponsor_nudge, GOBLIN.sponsor_nudge),
(
"sponsor_nudge_github",
POLITE.sponsor_nudge_github,
GOBLIN.sponsor_nudge_github,
),
(
"sponsor_nudge_pix",
POLITE.sponsor_nudge_pix,
GOBLIN.sponsor_nudge_pix,
),
("period_all", POLITE.period_all, GOBLIN.period_all),
("period_7d", POLITE.period_7d, GOBLIN.period_7d),
("period_30d", POLITE.period_30d, GOBLIN.period_30d),
Expand Down
1 change: 1 addition & 0 deletions crates/aura-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ pub mod lexicon;
pub mod plugin;
pub mod quota;
pub mod reader;
pub mod sponsor;
pub mod state;
pub mod theme;
Loading
Loading